jim800121chen 8cd5751ce3 feat(local-tool): M8 重構 — Wails 控制台 + 瀏覽器 Web UI(R5 決策)
依 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>
2026-04-15 17:57:54 +08:00

169 lines
12 KiB
Markdown
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.

# Reviewer 審查 MAJ-4 shutdown-imminent broadcast2026-04-15
## 摘要
- **結論**:✅ 通過。實作完整、測試涵蓋、race 乾淨、文件契約對齊、兩條 flowquit / restart邏輯正確。
- **阻擋 M8-10** 否。M8-4 遺留的 MAJ-4「shutdown-imminent 廣播」已補齊,可與 M8-5 patch、M8-7 / M8-8 / M8-9 一併收斂進 M8-10。
- 發現 1 個 Minorpayload reason 與 TDD §2.3 範例文字不完全一致1 個 InfoHub 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」的文字描述不一致。但 **callerWails 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`真實 gorilla `websocket.DefaultDialer.Dial` 連上 `hub.BroadcastToRoom("system", ...)` `conn.ReadMessage()` 解析 JSON 驗證 `type``reason`
- `:49-58` Register 同步完成poll `hub.rooms["system"]` 非空best-effort 500ms)。 package 可存取 `hub.mu` 私有欄位無需匯出
- `httptest.Server` `defer srv.Close()``conn.Close()``SetReadDeadline` 2s 保護不會 leak。✅
## I. Test 品質15 個)
- **server/internal/api/handlers**5Quit / Restart / Invalid4 sub-case / NoHub / DefaultSleepPositive 全部親跑通過
- **server/internal/api/ws**4MultipleClients / EmptyRoom / FullChannelDoesNotBlock / SystemEventsHandler_ReceivesBroadcast 全過
- **visiona-local**6ZeroPort / SendsPostWithReason / SendsReasonRestart / Timeout / ServerError / ConnectionRefused 全過
- 合計 **15 個新 test 全部 PASS** sub-test)。
- 使用 `withNoSleep` helper `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 視窗
1. `OnBeforeClose` `app.shutdown(ctx)``app.go:280`
2. `a.pipelineCancelFn()` / `a.watchCancel()` / IPC close / sentinel 清理`:282-294`
3. `port := a.snapshotStatus().Port` `notifyShutdownImminent(ctx, port, "quit")``:303-304`
4. server `ShutdownNotify` handler`BroadcastToRoom("system", {type, reason: "quit", ts})` `time.Sleep(100ms)` `200 OK`
5. `/ws/system` write pump JSON 推到 TCP瀏覽器 `onmessage` `mapReason('quit')='quit'` `forceOffline('quit')` 立即顯示 overlay
6. `a.ctrl.Stop()``:309`)→ 7s grace server 退出
7. 瀏覽器 polling 後續都 ECONNREFUSED overlay 已顯示不重複觸發
正確
**Restart flow**使用者按 Restart
1. `ServerController.Restart()``server_control.go:282`
2. 鎖內複製 `oldPort := c.proc.port``:284-289`
3. `notifyShutdownImminent(ctx, oldPort, "restart")``:299`
4. server 廣播 `{reason: "restart"}` 100ms sleep 200
5. 瀏覽器 `onmessage` `mapReason('restart')='restart'` `restartDeferTimer = setTimeout(forceOffline('restart'), 10_000)``use-shutdown-watcher.ts:232-241`
6. `c.Stop()` server 退出`c.StartWithPort(oldPort)` server boot-id
7. 瀏覽器 pollingnormal mode10s interval先打到新 server`handleBootIdCheck` `'mismatch'` `window.location.reload()``:95-123`
8. Reload 後新 page 初始化 `pollOnce` `'first'` `markOnline` normal 模式
9. reload 發生在 10s defer 之前 defer timer unmount `clearTimeout``:312-315`)→ overlay 沒機會顯示
10. 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 品質達標
1. 介面`shutdownNotifyBroadcaster`乾淨解耦spy broadcaster 測試乾淨
2. Hub.BroadcastToRoom 重用既有 API未新造non-blocking 行為有專屬 test 覆蓋
3. `/ws/system` handler 沿用 M8-8 `upgrader` / `CheckOrigin`無繞過安全機制
4. notifyShutdownImminent helper best-effort 全面port<=0 / ctx nil / timeout / connection refused / 5xx 五種路徑都有測試)。
5. Wails 兩處呼叫點`shutdown` / `Restart`順序正確都在 Stop 之前port 來源正確
6. M8-7 前端 `use-shutdown-watcher.ts` reason 映射restart 10 deferM8-9 boot-id reload guard 完整對齊
7. 15 個新 test 全綠`go build / vet / test / test -race` server + visiona-local 均乾淨
8. 兩條完整 flowQuit / Restart code 推論時序正確overlay 不會 racereload loop guard
**建議**接受 patchMAJ-4 結案Minor 1 / 2 不阻擋留作文檔與 M8-10 final pass 時順手修正即可M8-4 遺留 5 Major 至此MAJ-4已補齊可推進 M8-10