時序競態:UploadVideo 回 200 即刻 pipeline.Start() 廣播推論結果,但瀏覽器
200 後才連結果 WS。WP-4 上傳改 localhost 直連(極快)、結果 WS 仍走慢 tunnel
→ 窗口放大;Hub 對無 client 的 room 廣播靜默丟棄 → 早期結果全丟、第一筆永遠等不到。
修法(A2 主修 + B 保險,結果面維持走 tunnel、不動 ADR-019 混合路徑):
- A2:UploadVideo 存檔即回 200,但 pipeline 建好不 Start;背景 gated-start
等 WS join inference:<serial> room 後才 Start;15s 逾時降級照舊開跑(不永久卡)
- B:inference room 緩存最近 30 筆,client join 時先 replay 再收 live
(順帶修 image 模式晚連 WS 丟結果的同類 bug)
- CameraHandler 加 startMu:gated goroutine 的 check-then-act(二次檢查 startCtx
→ Start)與 stop 的 cancel+Stop 原子化,消滅「stop 後 gated 又 Start 舊 pipeline」race
reviewer 通過(Major-1 修復複審 ✅)。14 test -race 全過(含 3000 輪併發 atomicity
測試)、gosec 改的檔 0 新 finding。serial room key 兩端同形已查證排除。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
169 lines
5.8 KiB
Go
169 lines
5.8 KiB
Go
package handlers
|
||
|
||
// camera_handler_gatedstart_test.go — video-inference-stuck 修法 A2 的協調邏輯測試
|
||
//
|
||
// UploadVideo 完整路徑需要真實 ffmpeg + 影片檔(VideoSource 會 spawn ffmpeg),不適合
|
||
// 快速確定性單元測試。這裡聚焦驗證 A2 的核心「gated-start 協調契約」:
|
||
//
|
||
// 1. gated-start goroutine 等 inference room 有 client join 後才觸發「開跑」
|
||
// 2. 在 client join 前呼叫 stopActivePipeline(cancel pendingStartCancel)→ 不開跑
|
||
//
|
||
// 用真實 ws.Hub + 與 UploadVideo 相同的 gated-start pattern(context + WaitForRoomClient)
|
||
// 驗證行為,不牽涉 camera / ffmpeg / driver。
|
||
|
||
import (
|
||
"context"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"visiona-agent/server/internal/api/ws"
|
||
)
|
||
|
||
// runGatedStart 複製 UploadVideo 裡 gated-start goroutine 的協調骨架(不含真實 pipeline)。
|
||
// started 在「決定開跑」時設為 1;aborted 在「因 cancel 放棄開跑」時設為 1。
|
||
func runGatedStart(hub *ws.Hub, room string, startCtx context.Context, started, aborted *int32) {
|
||
go func() {
|
||
waitCtx, cancel := context.WithTimeout(startCtx, waitRoomJoinTimeout)
|
||
defer cancel()
|
||
_ = hub.WaitForRoomClient(waitCtx, room)
|
||
if startCtx.Err() != nil {
|
||
atomic.StoreInt32(aborted, 1)
|
||
return
|
||
}
|
||
atomic.StoreInt32(started, 1)
|
||
}()
|
||
}
|
||
|
||
func TestGatedStart_StartsAfterClientJoins(t *testing.T) {
|
||
hub := ws.NewHub()
|
||
go hub.Run()
|
||
|
||
room := "inference:DEVA"
|
||
var started, aborted int32
|
||
startCtx, cancel := context.WithCancel(context.Background())
|
||
defer cancel()
|
||
|
||
runGatedStart(hub, room, startCtx, &started, &aborted)
|
||
|
||
// 尚無 client → 不該開跑
|
||
time.Sleep(100 * time.Millisecond)
|
||
if atomic.LoadInt32(&started) != 0 {
|
||
t.Fatal("client join 前就開跑了(A2 gate 失效)")
|
||
}
|
||
|
||
// client join → 應開跑
|
||
c := &ws.Client{Send: make(chan []byte, 4)}
|
||
hub.RegisterSync(&ws.Subscription{Client: c, Room: room})
|
||
|
||
deadline := time.After(time.Second)
|
||
for atomic.LoadInt32(&started) == 0 {
|
||
select {
|
||
case <-deadline:
|
||
t.Fatal("client join 後 gated-start 未開跑")
|
||
case <-time.After(10 * time.Millisecond):
|
||
}
|
||
}
|
||
if atomic.LoadInt32(&aborted) != 0 {
|
||
t.Fatal("正常 join 不該被標記為 aborted")
|
||
}
|
||
}
|
||
|
||
func TestGatedStart_AbortsWhenCancelledBeforeJoin(t *testing.T) {
|
||
hub := ws.NewHub()
|
||
go hub.Run()
|
||
|
||
room := "inference:DEVB"
|
||
var started, aborted int32
|
||
startCtx, cancel := context.WithCancel(context.Background())
|
||
|
||
runGatedStart(hub, room, startCtx, &started, &aborted)
|
||
time.Sleep(50 * time.Millisecond)
|
||
|
||
// 模擬 stopActivePipeline:client join 前 cancel pendingStartCancel
|
||
cancel()
|
||
|
||
deadline := time.After(time.Second)
|
||
for atomic.LoadInt32(&aborted) == 0 {
|
||
select {
|
||
case <-deadline:
|
||
t.Fatal("cancel 後 gated-start 未放棄開跑(會洩漏 pipeline)")
|
||
case <-time.After(10 * time.Millisecond):
|
||
}
|
||
}
|
||
if atomic.LoadInt32(&started) != 0 {
|
||
t.Fatal("被 cancel 後不該開跑")
|
||
}
|
||
}
|
||
|
||
// TestGatedStart_CheckThenAct_Atomic_RealHandler — Reviewer Major-1 修復驗證。
|
||
//
|
||
// 用「真實 CameraHandler.startMu + pendingStartCancel + cancelPendingStartAndStopPipeline」
|
||
// 復現原 race:gated goroutine 的「檢查 startCtx.Err() → 開跑」與 handler 端 stop
|
||
// (鎖內 cancel)併發。
|
||
//
|
||
// 核心不變式(原子性保證):gated 在鎖內若決定「開跑」(started=1),則它檢查當下
|
||
// startCtx.Err() 必為 nil;而 stop 的 cancel 也在同一把鎖內。兩者互斥後,
|
||
// 「gated 觀察到 Err()==nil 卻仍被 cancel 搶先」這種狀態不可能出現。
|
||
// - 修好前(check 與 act 之間放掉鎖 / 無鎖):race detector 會抓到 pendingStartCancel /
|
||
// startCtx 的無同步併發存取;且可能出現 started=1 但 pendingStartCancel 未被正確清理。
|
||
// - 修好後:兩段都在 startMu 內,started 與 aborted 互斥、pendingStartCancel 狀態一致。
|
||
//
|
||
// 跑很多輪 + go test -race,任何原子性破綻都會被 race detector 抓到。
|
||
func TestGatedStart_CheckThenAct_Atomic_RealHandler(t *testing.T) {
|
||
const iterations = 3000
|
||
|
||
for i := 0; i < iterations; i++ {
|
||
h := &CameraHandler{} // 只用到 startMu / pendingStartCancel / pipeline,其餘不需初始化
|
||
|
||
startCtx, cancelStart := context.WithCancel(context.Background())
|
||
h.startMu.Lock()
|
||
h.pendingStartCancel = cancelStart
|
||
h.startMu.Unlock()
|
||
|
||
var started, aborted int32
|
||
|
||
var wg sync.WaitGroup
|
||
wg.Add(2)
|
||
|
||
// gated goroutine:忠實複製生產端 UploadVideo 的鎖內原子區塊
|
||
//(取 startMu → 二次檢查 startCtx.Err() → 開跑 → 清 pendingStartCancel)。
|
||
go func() {
|
||
defer wg.Done()
|
||
h.startMu.Lock()
|
||
defer h.startMu.Unlock()
|
||
if startCtx.Err() != nil {
|
||
atomic.StoreInt32(&aborted, 1)
|
||
return // 已被 stop 取消,放棄開跑(正確)
|
||
}
|
||
atomic.StoreInt32(&started, 1)
|
||
// 開跑成功:清掉自己登記的 cancel(生產端相同語意)。
|
||
h.pendingStartCancel = nil
|
||
}()
|
||
|
||
// stop goroutine:走真實的 cancelPendingStartAndStopPipeline(鎖內 cancel + 清 pipeline)。
|
||
go func() {
|
||
defer wg.Done()
|
||
h.cancelPendingStartAndStopPipeline()
|
||
}()
|
||
|
||
wg.Wait()
|
||
|
||
// 不變式 1:started 與 aborted 互斥(不可能同時、也不可能都沒發生)。
|
||
s, a := atomic.LoadInt32(&started), atomic.LoadInt32(&aborted)
|
||
if s == a { // 兩者相等 → 同為 0(都沒跑)或同為 1(同時發生),都代表原子性被破壞
|
||
t.Fatalf("iter %d:started(%d)/aborted(%d) 非互斥,check-then-act 原子性被破壞", i, s, a)
|
||
}
|
||
|
||
// 不變式 2:無論哪條路徑,最終 pendingStartCancel 都應是 nil
|
||
//(started → gated 清 nil;aborted → stop 清 nil)。殘留非 nil 代表狀態不一致。
|
||
h.startMu.Lock()
|
||
leftover := h.pendingStartCancel != nil
|
||
h.startMu.Unlock()
|
||
if leftover {
|
||
t.Fatalf("iter %d:pendingStartCancel 未被清乾淨(狀態不一致)", i)
|
||
}
|
||
}
|
||
}
|