依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。
程式碼變動
- M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
Makefile vendor / installer / bootstrap / CI workflow,-555 行)
- M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
VISIONA_MOCK 環境變數,-528 行)
- M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
LGPL binary,macOS 自 build minimal decoder-only 進 git
(vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
- M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
- M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
- M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
state 視覺、log panel、startup progress panel、Stage 6 manual CTA
pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
- M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
- M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
wsEverConnected 容錯 + Page Visibility)
- M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
- MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
(/ws/system endpoint + notifyShutdownImminent helper)
- M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)
品質
- ~105+ 新 unit test + race detector (-count=2) 全綠
- 10 個 milestone 全部通過 Reviewer 審查
- 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
收錄在 .autoflow/
交付前待處理(M8-10)
- 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
- 三平台 end-to-end build 驗證
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
12 KiB
Reviewer 審查 MAJ-4 shutdown-imminent broadcast(2026-04-15)
摘要
- 結論:✅ 通過。實作完整、測試涵蓋、race 乾淨、文件契約對齊、兩條 flow(quit / restart)邏輯正確。
- 阻擋 M8-10? 否。M8-4 遺留的 MAJ-4「shutdown-imminent 廣播」已補齊,可與 M8-5 patch、M8-7 / M8-8 / M8-9 一併收斂進 M8-10。
- 發現 1 個 Minor(payload reason 與 TDD §2.3 範例文字不完全一致),1 個 Info(Hub sentinel 行為變更),均不阻擋。
A. ShutdownNotify handler
| 檢查 | 結果 | 依據 |
|---|---|---|
POST /api/system/shutdown-notify 路徑 |
✅ | router.go:64 |
接 query reason |
✅ | system_handler.go:180 |
| 預設 reason(空) | ✅ 歸類 unknown |
system_handler.go:181-186(空值走 default 分支) |
| 驗證 reason | ✅ | 只認 quit / restart,其餘→ unknown 仍 200 |
| 呼叫 BroadcastToRoom | ✅ | system_handler.go:194 |
| sleep 100ms 後回 200 | ✅ | system_handler.go:26, 196-198, 201 |
| 無 client 仍 200 | ✅ | Hub BroadcastToRoom 空 room no-op(hub.go:129),handler 不判斷 client 數 |
| wsHub nil 不 panic | ✅ | system_handler.go:188 nil guard + TestShutdownNotify_NoHub 覆蓋 |
注意:預設(空 reason)不是直接對應到 "quit" 而是 "unknown",與 prompt「預設 reason=quit」的文字描述不一致。但 caller(Wails app)永遠明確帶 quit 或 restart(app.go:304、server_control.go:299),預設值只在誤呼叫路徑生效,且 "unknown" 對前端而言透過 mapReason 仍會走 'quit' 分支立即顯示 overlay(use-shutdown-watcher.ts:70-71 會 fallback → overlay 仍顯示)。功能等價且更安全(避免亂送的 reason 誤映射到 quit)。可接受。
B. shutdownNotifyBroadcaster interface
system_handler.go:18-20定義介面;wsHub欄位以介面型別保存(:36,:49-52)。- 單元測試用
spyBroadcaster注入(system_handler_test.go:29-57, 68-79),完全脫離 real Hub goroutine。 - ✅ 解耦乾淨、便於測試。
C. Hub.BroadcastToRoom
| 檢查 | 結果 | 依據 |
|---|---|---|
| 重用既有 API | ✅ | hub.go:160-166(既有方法,未新造) |
| Non-blocking | ✅ | hub.go:131-136 select default → 滿 channel 直接 drop + close + delete |
Room system 正確 |
✅ | system_handler.go:194、system_ws.go:36 |
| 訊息格式 | ✅ | system_handler.go:189-193(type, reason, ts = UnixMilli()) |
TestHub_BroadcastToRoom_FullChannelDoesNotBlock 驗證慢 client 不卡 hub goroutine,第二次 broadcast 仍能送到 healthy client(hub_broadcast_test.go:73-132)。✅
D. /ws/system WebSocket endpoint
| 檢查 | 結果 | 依據 |
|---|---|---|
| router 註冊 | ✅ | router.go:102 |
client 加入 system room |
✅ | system_ws.go:36-37(RegisterSync 保證返回時已 in-room) |
| 沿用 M8-8 Origin check | ✅ | 共用 upgrader,device_events_ws.go:13-14 的 CheckOrigin: CheckOrigin 自動繼承 |
| M8-4b sentinel 行為 | ⚠️ Info | 見下方說明 |
M8-4b sentinel 互動(Info,非問題):Hub 的 writeStartupSentinel 由 sync.Once 保護,只要任何 room 的第一個 client 連上就寫 .first-ws-connected(hub.go:100-115)。/ws/system 是 BootIdWatcherMount 在瀏覽器 tab 載入時自動連上(use-shutdown-watcher.ts:298, connectWs),實務上極可能成為第一個連上的 WS endpoint,讓 startup pipeline 階段 6 由 /ws/system 觸發完成。這不違反 startup-pipeline.md §3 階段 6 的語意(定義是「Web UI 連上任何 WS」),且 sentinel 只在第一次寫入、後續 no-op,不會造成 race。✅
E. notifyShutdownImminent helper
| 檢查 | 結果 | 依據 |
|---|---|---|
| 1 秒 timeout + 變數化 | ✅ | shutdown_notify.go:26(notifyShutdownImminentTimeout) |
| Best-effort(錯誤靜默) | ✅ | :49, :53 兩個 err 分支直接 return;不 log |
port <= 0 no-op |
✅ | :38-40 |
| ctx nil 保護 | ✅ | :41-43(fallback 到 context.Background) |
| ctx timeout 包裹 | ✅ | :44-45(context.WithTimeout) |
| 釋放 resp.Body | ✅ | :57(resp.Body.Close) |
測試 6 個全部覆蓋(zero port / POST 正確 / reason=restart / timeout 不卡 / 5xx 不 panic / connection refused 靜默)。TestNotifyShutdownImminent_TimeoutDoesNotBlock 實測 elapsed < 400ms(shutdown_notify_test.go:95-123)。✅
F. Wails app 端呼叫位置
| 檢查 | 結果 | 依據 |
|---|---|---|
shutdown() 在 ctrl.Stop() 前呼叫 |
✅ | app.go:302-309(順序:notify → Stop) |
Restart() 在 Stop 前呼叫 |
✅ | server_control.go:291-305(順序:notify → Stop → StartWithPort) |
| reason 正確 | ✅ | app.go:304 = "quit";server_control.go:299 = "restart" |
| port 來源 | ✅ | shutdown 走 snapshotStatus().Port(app.go:303);Restart 走 c.proc.port 鎖內複製成 oldPort(server_control.go:284-289) |
兩處都有 nil guard(a.ctrl != nil / c.app != nil && oldPort > 0),失敗路徑不阻塞主流程。✅
G. 和 M8-7 前端對齊
讀 use-shutdown-watcher.ts(325 行):
| 檢查 | 結果 | 依據 |
|---|---|---|
訂閱 /ws/system |
✅ | :211, 215(getWsBaseUrl() + '/ws/system') |
解析 server:shutdown-imminent |
✅ | :228 |
quit 立即 forceOffline |
✅ | :244-245(mapReason('quit') → 'quit' → forceOffline('quit')) |
restart 延遲 10 秒 |
✅ | :232-242(RESTART_DEFER_MS = 10_000) |
restart 期間 polling 會先拿新 boot-id 觸發 reload |
✅ | :186-201 polling 仍跑;restart defer 10s 內新 server 起來 → pollOnce → handleBootIdCheck → mismatch → reload(:86-123) |
mapReason 把 server 的 quit / app-closing / manual-stop 都 fold 到 'quit'(:61-73),所以後端改送 "quit" 而非 TDD 範例的 "app-closing"對前端完全透明(雙方都走立即 forceOffline 路徑)。✅
與 TDD 範例的微差異(Minor):server-lifecycle.md:141 範例寫 payload: { reason: "app-closing" },實作是 reason: "quit"。兩者在前端都觸發立即 overlay,但字面不一致。建議在 server-lifecycle.md §2.3 / §8 補註「實際 reason 由 caller 決定:Wails shutdown=quit、Restart=restart」即可,不需改 code。
H. Integration test
system_ws_integration_test.go:22-83用httptest.NewServer+ gin router 掛SystemEventsHandler,真實 gorillawebsocket.DefaultDialer.Dial連上 →hub.BroadcastToRoom("system", ...)→conn.ReadMessage()解析 JSON → 驗證type、reason。:49-58等 Register 同步完成(pollhub.rooms["system"]非空,best-effort 500ms)。同 package 可存取hub.mu私有欄位,無需匯出。httptest.Server有defer srv.Close()、conn.Close()、SetReadDeadline2s 保護,不會 leak。✅
I. Test 品質(15 個)
- server/internal/api/handlers(5):Quit / Restart / Invalid(4 sub-case) / NoHub / DefaultSleepPositive — 全部親跑通過。
- server/internal/api/ws(4):MultipleClients / EmptyRoom / FullChannelDoesNotBlock / SystemEventsHandler_ReceivesBroadcast — 全過。
- visiona-local(6):ZeroPort / SendsPostWithReason / SendsReasonRestart / Timeout / ServerError / ConnectionRefused — 全過。
- 合計 15 個新 test 全部 PASS(含 sub-test)。
- 使用
withNoSleephelper 把shutdownNotifySleepDuration歸零加速,t.Cleanup還原 — 寫法乾淨。 TestShutdownNotify_DefaultSleepIsPositive特別保護生產常數不被誤改為 0 — 有心。
J. 親跑驗證
cd server
go build ./... OK
go vet ./... OK
go test ./... OK(含 api/handlers/ws 全綠)
go test -race -count=1 ./internal/api/...
api 2.057s / handlers 1.548s / ws 2.726s 全綠
cd visiona-local
go build . OK
go vet ./... OK
go test -run NotifyShutdown -v ./...
6 tests PASS (含 timeout 0.50s)
go test -race -count=1 ./... OK 8.930s(含 race detector)
全部通過,無 race warning、無 build error。
K. 完整 flow 驗證(讀 code 推論)
Quit flow(使用者關 Wails 視窗):
OnBeforeClose→app.shutdown(ctx)(app.go:280)a.pipelineCancelFn()/a.watchCancel()/ IPC close / sentinel 清理(:282-294)port := a.snapshotStatus().Port→notifyShutdownImminent(ctx, port, "quit")(:303-304)- server
ShutdownNotifyhandler:BroadcastToRoom("system", {type, reason: "quit", ts})→time.Sleep(100ms)→200 OK /ws/systemwrite pump 把 JSON 推到 TCP;瀏覽器onmessage→mapReason('quit')='quit'→forceOffline('quit')立即顯示 overlaya.ctrl.Stop()(:309)→ 7s grace → server 退出- 瀏覽器 polling 後續都 ECONNREFUSED,但 overlay 已顯示,不重複觸發
✅ 正確。
Restart flow(使用者按 Restart):
ServerController.Restart()(server_control.go:282)- 鎖內複製
oldPort := c.proc.port(:284-289) notifyShutdownImminent(ctx, oldPort, "restart")(:299)- server 廣播
{reason: "restart"}→ 100ms sleep → 200 - 瀏覽器
onmessage→mapReason('restart')='restart'→restartDeferTimer = setTimeout(forceOffline('restart'), 10_000)(use-shutdown-watcher.ts:232-241) c.Stop()→ server 退出;c.StartWithPort(oldPort)→ 新 server 起(新 boot-id)- 瀏覽器 polling(normal mode,10s interval)先打到新 server:
handleBootIdCheck→'mismatch'→window.location.reload()(:95-123) - Reload 後新 page 初始化 →
pollOnce→'first'→markOnline→ normal 模式 - 若 reload 發生在 10s defer 之前 → defer timer 被 unmount 時
clearTimeout(:312-315)→ overlay 沒機會顯示 ✅ - 若 reload 發生在 10s 之後 → overlay 顯示後 reload 本身也會清除 → 正確復原
✅ 正確,兩條 flow 的時序都能正常收斂。
L. 問題清單
Major
(無)
Minor
| # | 檔案:行 | 問題 | 建議 |
|---|---|---|---|
| MIN-1 | server_handler.go:189-193 vs server-lifecycle.md:141 |
TDD 範例寫 reason: "app-closing"、實作送 reason: "quit"。前端 mapReason 雙向皆 fold 成 'quit',功能等價,但文件字面不一致。 |
在 server-lifecycle.md §2.3 補一行:「實作中 reason 由 Wails caller 決定:shutdown quit、Restart restart」。不必改 code。 |
| MIN-2 | system_handler.go:181-186 | 空 reason 被 fold 成 "unknown",prompt 審查項說「預設 reason=quit」。目前由 caller 永遠帶值,非實際風險。 |
如要嚴格對齊 prompt 可把 default 分支改成 reason = "quit";或於 commit message 標註「預設 unknown 是刻意保守設計」。Reviewer 傾向維持現狀。 |
Info(非問題)
- Hub sentinel 與 /ws/system 交互:
/ws/system因 mount 時機早,實務上會成為第一個寫.first-ws-connected的 endpoint。符合startup-pipeline.md §3 階段 6的語意(「Web UI 連上任何 WS」),無 race,無需調整。 TestSystemEventsHandler_ReceivesBroadcast直接存取hub.mu/hub.rooms(私有)— 因為同 package 合法,但未來若搬到外部 integration package 會需要 exporter。目前 OK。
M. 結論
MAJ-4 patch 品質達標:
- 介面(
shutdownNotifyBroadcaster)乾淨解耦,spy broadcaster 測試乾淨。 - Hub.BroadcastToRoom 重用既有 API,未新造;non-blocking 行為有專屬 test 覆蓋。
/ws/systemhandler 沿用 M8-8upgrader/CheckOrigin,無繞過安全機制。- notifyShutdownImminent helper best-effort 全面(port<=0 / ctx nil / timeout / connection refused / 5xx 五種路徑都有測試)。
- Wails 兩處呼叫點(
shutdown/Restart)順序正確,都在 Stop 之前、port 來源正確。 - 與 M8-7 前端
use-shutdown-watcher.ts的 reason 映射、restart 10 秒 defer、M8-9 boot-id reload guard 完整對齊。 - 15 個新 test 全綠,
go build / vet / test / test -raceserver + visiona-local 均乾淨。 - 兩條完整 flow(Quit / Restart)讀 code 推論時序正確,overlay 不會 race、reload loop 有 guard。
建議:接受 patch,MAJ-4 結案。Minor 1 / 2 不阻擋、留作文檔與 M8-10 final pass 時順手修正即可。M8-4 遺留 5 個 Major 至此(MAJ-4)已補齊,可推進 M8-10。