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

15 KiB
Raw Permalink Blame History

Reviewer 審查 M8-4b 補丁2026-04-15

摘要

  • 審查對象M8-4b 補丁3 Major + Stage 4 timeout 修復)
  • 審查檔案:visiona-local/startup_pipeline.goL385-423 新增 helpersvisiona-local/server_control.goL167-223 startInternal 守門、L630-652 probe timeout、L867-937 RestartStartupSequence + test hookvisiona-local/app.goL109-119 struct、L1676-1693 openBrowser varvisiona-local/startup_pipeline_test.goL393-660 4 個 regression test
  • 總結論: 通過 — 3 Major 全部正確修復、Stage 4 timeout 改好、4 個 regression test 完整覆蓋且全綠。Build/Vet/Test/Racecount=2visiona-localserver 兩個 module 都 PASS。
  • 3 Major 修復狀況M-1 / M-2 / M-3
  • 問題統計Critical 0 / Major 0 / Minor 2 / Suggestion 1
  • 阻擋 M8-10 嗎:,可推進 M8-10。

A. HasFailedStage helper 正確性

位置:visiona-local/startup_pipeline.go L385-404

  • 邏輯正確 current == -1FailStage 會把 current 設為 -1或迴圈檢查 stages[1..6].status == "failed"defensive path
  • Lock 保護 p.mu.Lock() + defer Unlock()mu 在 struct 是 sync.MutexL98沒 RLock 可用 — 任務敘述提到的「RLock」不適用Lock 是正確選擇。效能影響可忽略(只在 startInternal error 路徑讀一次)。
  • 文件註解清楚 L386-391 解釋語義與對 M-1 的作用。
  • Test 覆蓋 4 case TestStartupPipeline_HasFailedStageL537-566分別驗證new / running / FailStage 後current=-1/ 某 stage status=failed 但 current 未同步defensive

B. IsInColdStart helper 正確性

位置:visiona-local/startup_pipeline.go L406-423

  • 邏輯正確 current >= 1 && current <= startupTotalStagesstartupTotalStages=6stage 7ready與 current=-1failed、current=0未 Start都回 false。
  • Lock 保護 :同 HasFailedStage,用 Lock/Unlock
  • Test 覆蓋邊界 TestStartupPipeline_IsInColdStartL578-606覆蓋 current ∈ {0, 1..6, 7, -1},迴圈確認 1..6 全 true其餘全 false。

C. startInternal error 分支守門

位置:visiona-local/server_control.go L167-194

  • 正確用 pipeline.HasFailedStage() L176-179
  • pipeline == nil fallback pipelineHandled 預設 false → 走原 setState + emit + sendCrashNotification 邏輯。
  • 非冷啟動路徑RestartServer 等pipeline 已 ready current=7若此刻 Start 失敗 HasFailedStage=falsefallback 正常發通知
  • 註解L168-175解釋完整說明為什麼守門 + 哪些路徑仍需 fallback。
  • 不破壞 RestartServerctrl.Restart()ctrl.Stop() + ctrl.Start())的錯誤處理 RestartServer 發生時 pipeline.current==7 或 0HasFailedStage() 回 false。

小提醒(非 Major此守門建立在「pipeline FailStage 一定早於 startInternal return err」的假設上。實作上 startServerV2 每個 stage 失敗都立即 FailStage(見 server_control.go L437-448 / L554-566再 return err順序正確。若未來新增一條「不透過 FailStage 的錯誤返回路徑」,守門會被繞過。建議記為 Minor 追蹤(見 §L


D. openBrowser 守門

位置:visiona-local/server_control.go L202-222

  • pipeline.IsInColdStart() == false 才執行 R5-D3 openBrowser L213-216
  • RestartServer 路徑pipeline.current==7IsInColdStart() 回 false仍 openBrowser
  • pipeline == nil 路徑 inColdStart 維持 falsefallback 仍 openBrowser L214-216
  • 註解L204-212清楚列出三種場景冷啟動 / RestartServer / RestartStartupSequence的預期行為可維護性高。

E. openBrowser package-level var 改動

位置:visiona-local/app.go L1676-1693

  • 改為 var openBrowser = openBrowserExec L1681。預設值是實作函式production 透過 var 呼叫只多一個 indirection效能影響可忽略。
  • 所有呼叫端透過 var 呼叫:app.go L268runStartupStage5server_control.go L219startInternal R5-D3。Test 可替換 var 做 stub。
  • openBrowserExec 保留為獨立函式L1684-1693作為預設實作macOS/Windows/Linux 三分支原封不動
  • Test 重置良好 TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOncedefer func() { openBrowser = original }()L627保證 test 結束還原。

F. RestartStartupSequence test 重構

位置:visiona-local/startup_pipeline_test.go L393-522

  • 直接呼叫 a.RestartStartupSequence() L439— 不是手動複製 5 步驟。
  • restartStartFn test hook 攔截 Step 6 L424-433spy 記錄呼叫、檢查 sentinel 是否已清、snapshot 新 pipeline。
  • callLog 驗證順序 L497-516pipelineCancel 必須在 startFn 之前。
  • 驗證新 pipeline 建立 L466-486stage1=completed / stage2=running / current=2。
  • pipelineCancelFncontext.CancelFunc 型別允許 assign 任意 func() L407-410
  • 其他 side effect 驗證完整proc 被清為 nilL453-458 ForceKill 效果、sentinel file 已刪除L461-463、啟動前 ctrl state 設為 Error 模擬 Retry 前置條件L418
  • 「若未來把 Step 4 移到 Step 2 之前」的概念驗證test 雖無法直接抓 Step 4 ↔ Step 2 相對順序但能抓「Step 4 在 Step 6 之前」(sentinelClearedBeforeStart L489-491滿足原 Reviewer 對 M-3 的核心期待(驗證真的呼叫 method、不是手動複製

Minor F-1(見 §LnewPipelineTestAppa.ctx == nil,所以 RestartStartupSequence Step 5 的 watcher goroutine 分支不會啟動L518-521 的「清理新啟動的 watcher」註解有點誤導實際上 pipelineCancelFn 已在 Step 1 被清為 nil之後也沒有被重設。無功能影響純註解細節。


G. TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce

位置:visiona-local/startup_pipeline_test.go L615-660

  • 正確 stub openBrowser var L621-627defer 還原。
  • AutoOpenBrowser=trueL618確保 runStartupStage5 會走 openBrowser 分支(不 skip
  • restartStartFn 內模擬 server 啟動成功:設 fake proc port=12345、setState(Running)、手動推進 pipeline stages 到 stage 5 running L630-645。這讓 runStartupStage5 能從 ctrl.proc.port 取到 URL 並呼叫 openBrowser。
  • 斷言 openCount == 1 L652-654錯誤訊息清楚標示 want 1。
  • 驗證冷啟動路徑下「startInternal 的 R5-D3 open」不會發生 — 因為 restartStartFn 替換了 ctrl.Start(),完全 bypass startInternal所以實際上這個 test 驗證的是 runStartupStage5 本身只會 open 一次。

Minor G-1(見 §L此 test 沒有實際經過 startInternal 的 R5-D3 分支restartStartFn 完全替換 ctrl.Start所以嚴格來說它驗證的是「runStartupStage5 本身的 open 次數」,而不是「冷啟動中 startInternal + runStartupStage5 的 open 次數總和」。要完整驗證 M-2需要一個能跑 startInternal 的整合 test但 startInternal 會 spawn python server 無法 unit test。現況可接受因為 IsInColdStart 邏輯已被 B 節 TestStartupPipeline_IsInColdStart 單獨驗證startInternal 的守門條件 !inColdStart 是純布林邏輯,靜態即可推得正確。


H. Stage 4 probe timeout 2s

位置:visiona-local/server_control.go L630-652

  • 註解說明原因 L634-636 解釋 TDD §3「秒回即算完成」+「2s 足以涵蓋正常 latency」。
  • 不破壞既有 test Stage 4 相關 test 全綠(go test -count=1 PASS
  • 逾時仍算完成 L647-651 不論 err 或 status 都 CompleteStage(4)),符合 TDD §3 設計意圖。

I. Build/Test/Race 結果

指令 結果
cd visiona-local && go build . PASS無輸出
cd visiona-local && go vet ./... PASS無輸出
cd visiona-local && go test -count=1 ./... PASS8.210s
cd visiona-local && go test -race -count=2 ./... PASS16.225s
cd server && go build ./... PASS
cd server && go vet ./... PASS
cd server && go test -count=1 ./... PASSapi/handlers/ws/device/model 全通過)
cd server && go test -race ./... PASS

4 個新 test 單獨跑 verbose

--- PASS: TestStartupPipeline_RestartStartupSequence_StepsExecution (0.00s)
--- PASS: TestStartupPipeline_HasFailedStage (0.00s)
--- PASS: TestStartupPipeline_IsInColdStart (0.00s)
--- PASS: TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce (0.00s)

J. 合併檢查

git diff --stat 看過全部變更,無衝突標記、無重複區塊。合併狀況:

  • M8-4 原版 + M8-4 補丁Stop/ForceKill/handleWatchFailure/logPumpForceKill L273-291 的 cancelWatcher 順序修復仍在,RestartStartupSequence Step 2 正確利用
  • M8-4b 原版startServerV2 hook + probe + RestartStartupSequence補丁新增 helpers 與守門邏輯未動到既有 pipeline struct 欄位與 stage hook 位置
  • MAJ-4 補丁shutdown-imminent broadcast + notifyShutdownImminent不觸及 startup pipeline / server_control 的 startInternal / RestartStartupSequence 區塊
  • grep 未見 <<<<<<< / >>>>>>> 合併標記。

K. 3 Major 症狀驗證

M-1重複 OS 通知)

症狀:冷啟動 stage 3 失敗 → emitError 發通知 A → startInternal error 分支再發通知 B。

修復後的流程:

  1. startServerV2 失敗 → pipeline.FailStage(3, err)emitError 發通知「第 3 階段失敗」+ setState(Error) + current = -1
  2. startInternal 收 err → HasFailedStage() 回 true → pipelineHandled = trueskip setState/emit/sendCrashNotification
  3. 只剩一個通知

症狀消失確認Major M-1 修復路徑正確唯一前提是「startServerV2 的所有失敗路徑都走 FailStage」— 已確認 L437-448stage 2、L554-566stage 3、stage 4 只在 timeout 仍 complete 不 fail符合條件。

M-2重複開瀏覽器

症狀:冷啟動成功 → runStartupStage5 的 openBrowser 呼叫 1 次 + startInternal R5-D3 的 openBrowser 呼叫 1 次 = 2 次。

修復後的流程:

  1. 冷啟動 → startServerV2 成功 → startInternal 檢查 IsInColdStart()(此時 current ∈ 1..6)→ 回 true → skip R5-D3 openBrowser
  2. startServerV2 返回後app.startup 呼叫 runStartupStage5 → openBrowser 1 次
  3. RestartServer 路徑pipeline.current==7 → IsInColdStart 回 false → R5-D3 正常 open

症狀消失確認:冷啟動 openBrowser 只被呼叫一次。TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce 驗證 openCount==1。

M-3Retry test 重構)

症狀test 手動複製 Step 1-5改步驟順序抓不到。

修復後:直接呼叫 a.RestartStartupSequence() + callLog spy。若未來調整步驟順序cancel 晚於 startcallLog 順序斷言會 fail ;若把 sentinel 清除改到 start 之後Step 4 → Step 6 之後),sentinelClearedBeforeStart 斷言會 fail

概念驗證test 能抓到「cancel 必須早於 start」與「sentinel 必須在 start 前清」兩個順序不變量,涵蓋 M-3 的核心意圖。


L. 問題清單

Critical

(無)

Major

(無)

Minor

# 檔案 問題 建議
m-1 visiona-local/startup_pipeline_test.go L518-521 註解說「清理新啟動的 watcher goroutine」newPipelineTestAppa.ctx == nilStep 5 的 watcher 分支不會啟動pipelineCancelFn 在 Step 1 已被清為 nil。註解誤導但無功能影響 改註解為「若 ctx 存在則清理重建的 watcher此 test 走 nil ctx 分支,為 safety
m-2 visiona-local/server_control.go L167-194 pipelineHandled 守門建立在「startServerV2 所有失敗路徑都走 FailStage」的隱性假設上若未來新增一條不透過 FailStage return err 的路徑M-1 症狀會復活 startServerV2 函式開頭加註解「所有 err return 前必須先 FailStage」或改用 explicit flaga.ctrl.suppressFallbackError)由 emitErrorstartInternal 讀後 clear

Suggestion

# 內容
s-1 TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce 嚴格來說驗證的是 runStartupStage5 本身只 open 一次(因為 restartStartFn 替換了 ctrl.Start完全 bypass 了 startInternal 的 R5-D3 分支。M-2 的守門邏輯(IsInColdStart() == false 才 open是靠 TestStartupPipeline_IsInColdStart 單獨驗證的 pure boolean 邏輯。兩個 test 合起來足以推得正確性,但若希望更貼近「真實 M-2 症狀」,可另加一個 test 透過 a.ctrl.startInternal 直接呼叫(需要先在 ctrl 加 test-only hook 允許跳過 spawn非必要

M. 結論

判定: 通過

補丁對 M8-4b 原版 Reviewer 提出的 3 Major 全部正確修復:

  1. M-1重複通知 — 新增 HasFailedStage() helper + startInternal error 分支守門邏輯正確、lock 保護得當regression testTestStartupPipeline_HasFailedStage)覆蓋 4 case 全通過。
  2. M-2重複開瀏覽器 — 新增 IsInColdStart() helper + startInternal R5-D3 守門 + openBrowser 改 package-level var 以便 stubregression testTestStartupPipeline_IsInColdStart + TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce)驗證完整。
  3. M-3Retry test 重構) — 新增 restartStartFn test hook + 重構 TestStartupPipeline_RestartStartupSequence_StepsExecution 為直接呼叫 method + callLog spy 驗證順序,抓得到 cancel↔start 與 sentinel↔start 的順序不變量。

Stage 4 probe timeout 從 5s 改為 2s註解說明符合 TDD 原意。

工程品質

  • Build / Vet / Test / Race-count=2在 visiona-local 與 server 兩個 module 全綠
  • 合併乾淨,無衝突標記
  • 註解詳盡(每個 helper 都有中文註解解釋「為什麼」與「哪些場景走 fallback」
  • Lock 使用正確(sync.Mutex + Lock/Unlock,沒有 race condition
  • Test hook 透過 struct field 注入,不污染正式路徑(restartStartFn 預設 nil

不阻擋 M8-10。Minor 2 項 + Suggestion 1 項可記為技術債,不強制當下處理。

優點

  • Helper 命名清楚(HasFailedStage / IsInColdStart 語義一眼就懂)
  • 守門邏輯對 pipeline == nil fallback 處理完整,不會因單測環境或非冷啟動路徑被誤傷
  • 4 個 regression test 覆蓋面完整helper 本身 + 實際 RestartStartupSequence 流程 + openBrowser 呼叫次數
  • 透過改 openBrowser 為 package-level var 解決測試 stub 問題,是 Go idiomatic 的做法
  • restartStartFn test hook 設計乾淨:預設 nil → 走正式 a.ctrl.Start(),單測替換即可,不會意外漏到 production
  • 2s probe timeout 的註解說明了「為什麼不是 5s」— 文件化設計決策