visionA/local-agent/server/internal/api/handlers/camera_handler_gatedstart_test.go
jim800121chen d0ab479a7f fix(local-agent): 影片上傳後推論卡「等待第一筆結果」— WS join 才開播 + late-join replay
時序競態: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>
2026-08-02 03:16:05 +08:00

169 lines
5.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 前呼叫 stopActivePipelinecancel pendingStartCancel→ 不開跑
//
// 用真實 ws.Hub + 與 UploadVideo 相同的 gated-start patterncontext + WaitForRoomClient
// 驗證行為,不牽涉 camera / ffmpeg / driver。
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"visiona-agent/server/internal/api/ws"
)
// runGatedStart 複製 UploadVideo 裡 gated-start goroutine 的協調骨架(不含真實 pipeline
// started 在「決定開跑」時設為 1aborted 在「因 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)
// 模擬 stopActivePipelineclient 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」
// 復現原 racegated 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()
// 不變式 1started 與 aborted 互斥(不可能同時、也不可能都沒發生)。
s, a := atomic.LoadInt32(&started), atomic.LoadInt32(&aborted)
if s == a { // 兩者相等 → 同為 0都沒跑或同為 1同時發生都代表原子性被破壞
t.Fatalf("iter %dstarted(%d)/aborted(%d) 非互斥check-then-act 原子性被破壞", i, s, a)
}
// 不變式 2無論哪條路徑最終 pendingStartCancel 都應是 nil
//started → gated 清 nilaborted → stop 清 nil。殘留非 nil 代表狀態不一致。
h.startMu.Lock()
leftover := h.pendingStartCancel != nil
h.startMu.Unlock()
if leftover {
t.Fatalf("iter %dpendingStartCancel 未被清乾淨(狀態不一致)", i)
}
}
}