visionA/local-agent/server/internal/api/ws/hub_video_race_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

203 lines
5.6 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 ws
// hub_video_race_test.go — video-inference-stuck 修法 A2/B 的 Hub 行為測試
//
// 涵蓋:
// A2 WaitForRoomClientroom 有 client 時立即返回、無 client 時阻塞到 register、ctx 取消返回 false
// B late-join replayinference room 緩存最近 N 筆、join 時補送、ClearRoomReplay 清除
// 隔離 非 inference room如 flash:)不緩存 replay
import (
"context"
"encoding/json"
"testing"
"time"
)
// drainN 從 client.Send 收 n 筆訊息,逾時 fail。
func drainN(t *testing.T, c *Client, n int, timeout time.Duration) [][]byte {
t.Helper()
out := make([][]byte, 0, n)
deadline := time.After(timeout)
for len(out) < n {
select {
case msg := <-c.Send:
out = append(out, msg)
case <-deadline:
t.Fatalf("只收到 %d/%d 筆訊息就逾時", len(out), n)
}
}
return out
}
func TestHub_WaitForRoomClient_ReturnsWhenClientAlreadyPresent(t *testing.T) {
hub := NewHub()
go hub.Run()
makeRegisteredClient(hub, "inference:DEV1", 4)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if !hub.WaitForRoomClient(ctx, "inference:DEV1") {
t.Fatal("room 已有 clientWaitForRoomClient 應立即回 true")
}
}
func TestHub_WaitForRoomClient_BlocksUntilRegister(t *testing.T) {
hub := NewHub()
go hub.Run()
got := make(chan bool, 1)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
got <- hub.WaitForRoomClient(ctx, "inference:DEV2")
}()
// 確保 waiter 已掛上(尚未有 client
select {
case <-got:
t.Fatal("尚無 client 時 WaitForRoomClient 不該返回")
case <-time.After(100 * time.Millisecond):
}
// 現在 register 一個 client → 應喚醒 waiter
makeRegisteredClient(hub, "inference:DEV2", 4)
select {
case ok := <-got:
if !ok {
t.Fatal("client join 後 WaitForRoomClient 應回 true")
}
case <-time.After(time.Second):
t.Fatal("client join 後 WaitForRoomClient 未在時限內返回")
}
}
func TestHub_WaitForRoomClient_ReturnsFalseOnCtxCancel(t *testing.T) {
hub := NewHub()
go hub.Run()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if hub.WaitForRoomClient(ctx, "inference:NEVER") {
t.Fatal("無 client 且 ctx 逾時,應回 false")
}
}
func TestHub_LateJoinReplay_DeliversBufferedResults(t *testing.T) {
hub := NewHub()
go hub.Run()
room := "inference:DEV3"
// 在無 client 時廣播 3 筆模擬「WS 連上前的早期結果」)
for i := 0; i < 3; i++ {
hub.BroadcastToRoom(room, map[string]int{"frame": i})
}
// 讓 broadcast 都被 Run() 處理完(進 replay ring
time.Sleep(50 * time.Millisecond)
// 現在 client late-join → 應補送到那 3 筆
c := makeRegisteredClient(hub, room, 16)
msgs := drainN(t, c, 3, time.Second)
for i, m := range msgs {
var got map[string]int
if err := json.Unmarshal(m, &got); err != nil {
t.Fatalf("replay 第 %d 筆 bad json: %v", i, err)
}
if got["frame"] != i {
t.Errorf("replay 順序錯:第 %d 筆 frame=%d預期 %d", i, got["frame"], i)
}
}
}
func TestHub_LateJoinReplay_CapsAtBufferSize(t *testing.T) {
hub := NewHub()
go hub.Run()
room := "inference:DEV4"
total := replayBufferSize + 10
for i := 0; i < total; i++ {
hub.BroadcastToRoom(room, map[string]int{"frame": i})
}
time.Sleep(80 * time.Millisecond)
c := makeRegisteredClient(hub, room, replayBufferSize+8)
msgs := drainN(t, c, replayBufferSize, time.Second)
// 應只保留最後 replayBufferSize 筆,第一筆 frame 應為 total-replayBufferSize
var first map[string]int
if err := json.Unmarshal(msgs[0], &first); err != nil {
t.Fatalf("bad json: %v", err)
}
if first["frame"] != total-replayBufferSize {
t.Errorf("ring 未正確截斷:首筆 frame=%d預期 %d", first["frame"], total-replayBufferSize)
}
// 不應再有第 replayBufferSize+1 筆
select {
case extra := <-c.Send:
t.Errorf("replay 超出 buffer 上限,仍收到多餘訊息: %s", extra)
case <-time.After(150 * time.Millisecond):
}
}
func TestHub_ClearRoomReplay_DropsBuffer(t *testing.T) {
hub := NewHub()
go hub.Run()
room := "inference:DEV5"
hub.BroadcastToRoom(room, map[string]int{"frame": 0})
time.Sleep(50 * time.Millisecond)
hub.ClearRoomReplay(room)
time.Sleep(50 * time.Millisecond)
// clear 之後 late-join 不該收到任何 replay
c := makeRegisteredClient(hub, room, 4)
select {
case msg := <-c.Send:
t.Errorf("ClearRoomReplay 後仍補送 replay: %s", msg)
case <-time.After(150 * time.Millisecond):
}
}
func TestHub_NonInferenceRoom_NoReplay(t *testing.T) {
hub := NewHub()
go hub.Run()
room := "flash:DEV6" // 非 inference 前綴 → 不緩存
hub.BroadcastToRoom(room, map[string]string{"type": "progress"})
time.Sleep(50 * time.Millisecond)
c := makeRegisteredClient(hub, room, 4)
select {
case msg := <-c.Send:
t.Errorf("非 inference room 不應緩存 replay卻補送: %s", msg)
case <-time.After(150 * time.Millisecond):
}
}
// TestHub_ReplayAndLiveBroadcast_Orderinglate-join client 先收 replay、再收後續 live 訊息。
func TestHub_ReplayAndLiveBroadcast_Ordering(t *testing.T) {
hub := NewHub()
go hub.Run()
room := "inference:DEV7"
hub.BroadcastToRoom(room, map[string]int{"frame": 0}) // 早期(進 replay
time.Sleep(50 * time.Millisecond)
c := makeRegisteredClient(hub, room, 8)
// join 後再來一筆 live
hub.BroadcastToRoom(room, map[string]int{"frame": 1})
msgs := drainN(t, c, 2, time.Second)
for i, m := range msgs {
var got map[string]int
_ = json.Unmarshal(m, &got)
if got["frame"] != i {
t.Errorf("順序錯:第 %d 筆 frame=%d預期 %dreplay 應在 live 之前)", i, got["frame"], i)
}
}
}