依 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>
41 KiB
v2/server-lifecycle.md — Server 生命週期與 Boot-ID 機制
所屬:TDD v2 §2.3 版本:v2.1(2026-04-14 吸收 PM 審閱 + R5-D + R5-E) 決策依據:R5-2(視窗關閉 = 結束 server,瀏覽器顯示 offline overlay)、R5-4(首次自動開瀏覽器)、R5-D1(OS 崩潰通知並存)、R5-D2(Linux 預設 auto-open OFF)、R5-D3(每次 Start 成功都開瀏覽器)、R5-E(階段化啟動 60 s 上限)、PM Q4(shutdown 7+1 秒)、三方共識 #10(watchServer Error state)、#14(boot-id 重連) 對應 milestone:M8-4(state machine + bindings)、M8-4b(階段化啟動管線,見
v2/startup-pipeline.md)、M8-9(boot-id) 相關文件:v2/control-panel.md(state machine 定義於該處)、v2/web-ui-offline-overlay.md(瀏覽器端)、v2/startup-pipeline.md(R5-E 階段化啟動)
1. 目的
把 v1 的「隱式啟動、隱式 watch、隱式關閉」升級為明確的 Start / Stop / Restart / Error 狀態機,並建立 boot-id 機制讓瀏覽器 tab 能偵測 server 重啟後自動 reload。
2. 完整生命週期時間軸
2.1 App 冷啟動(R5-4 + R5-D3 + R5-E 階段化)
R5-E 上限:整段啟動流程不得超過 60 s,否則進 Error state。watcher goroutine 負責監視並發 startup:error event。
R5-E 階段分配與預算(樂觀 / 悲觀 / 最壞,單位秒):
| # | 階段 | labelKey | 樂觀 | 悲觀 | 最壞 | Soft Timeout(20 s) |
|---|---|---|---|---|---|---|
| 1 | 初始化 Wails 控制台 | startup.stage.1.label |
0.1 | 0.2 | 0.5 | 不太可能超 |
| 2 | 檢查 Python runtime | startup.stage.2.label |
0.5 | 1.5 | 4.0 | 可能超(首次 wheels extract) |
| 3 | 啟動本機伺服器 | startup.stage.3.label |
1.5 | 3.0 | 5.0 | 不太可能超 |
| 4 | 偵測 Kneron 裝置 | startup.stage.4.label |
0.5 | 1.5 | 2.0 | 不太可能超 |
| 5 | 開啟瀏覽器 | startup.stage.5.label |
0.2 | 0.5 | 1.0 | 不太可能超 |
| 6 | 等待 Web UI 連線 | startup.stage.6.label |
0.8 | 1.5 | 3.0 | 可能超(慢裝置) |
| — | 合計 | — | 3.6 | 8.2 | 15.5 | 硬上限 60 s |
(原 PM §11-2 提出的「10 秒可達性」已被 R5-E 取代為 60 s 上限 + 階段化進度;上方估算沿用 v2.0 互審的分析結果。)
冷啟動(首次安裝後第一次)時間軸:
t=0.000 使用者雙擊 visionA-local.app / .exe / .AppImage
t=0.000 Wails OnStartup 觸發
└─ pipeline = NewStartupPipeline(); pipeline.Start(1) emit startup:progress(stage=1, running)
t=0.010 migrateOldDataDirs() ← app.go:145 沿用
t=0.020 acquireSingleInstance(dataDir) ← app.go:154 沿用
t=0.030 startIPCServer() ← app.go:174 沿用
t=0.080 seedUserDataDir() ← app.go:182 沿用(data dir seed 8 個預置 .nef)
t=0.090 pipeline.Complete(1) emit startup:progress(stage=1, completed)
t=0.090 pipeline.Start(2) emit startup:progress(stage=2, running)
t=0.100 ctrl.Start() ← 新:startup 最後呼叫 ServerController.Start
├─ state: Stopped → Starting emit server:state-change
├─ ensurePythonRuntime() ← 沿用 v1(stage 2 的主要耗時)
t=1.600 pipeline.Complete(2) emit startup:progress(stage=2, completed)
├─ pipeline.Start(3) emit startup:progress(stage=3, running)
├─ ensureDriverInstalled() ← 沿用 v1(Windows)
├─ pickPort(3721) ← 沿用 v1(首次啟動允許 fallback 到 3722+)
├─ exec.Command(server binary, --host 127.0.0.1 --port N --data-dir D)
├─ cmd.StdoutPipe() + cmd.StderrPipe()
├─ cmd.Start()
├─ go logPump(stdoutPipe, "stdout", stdoutFile, ...)
├─ go logPump(stderrPipe, "stderr", stderrFile, ...)
├─ waitHealthy(port, 30s) ← 沿用 v1
├─ writeIPCPort(dataDir, port) ← 沿用 v1
├─ go watchServer(...) ← 沿用 v1(但失敗行為改成 Error state + OS 通知)
└─ state: Starting → Running emit server:state-change
t=4.100 pipeline.Complete(3) emit startup:progress(stage=3, completed)
t=4.100 pipeline.Start(4) emit startup:progress(stage=4, running)
t=4.600 GET /api/devices 第一次回應 (無硬體秒回)
t=4.600 pipeline.Complete(4) emit startup:progress(stage=4, completed)
t=4.600 pipeline.Start(5) emit startup:progress(stage=5, running)
└─ 檢查 prefs.AutoOpenBrowser(R5-D3:每次 Start 成功都開;預設依 R5-D2 分平台)
macOS/Windows 預設 true → OpenInBrowser("")
Linux 預設 false → 不開
t=4.700 pipeline.Complete(5) emit startup:progress(stage=5, completed)
t=4.700 pipeline.Start(6) emit startup:progress(stage=6, running)
t=4.700 瀏覽器 tab 開始載入 Next.js SPA
t=5.500 Next.js fetch /api/system/boot-id → 記錄 initial boot-id
t=5.500 Next.js 建立 WebSocket 連線 → server 的 WebSocket hub OnClientConnected callback
t=5.500 pipeline.Complete(6) emit startup:progress(stage=6, completed)
pipeline.Ready() emit startup:ready
t=5.500 Wails 控制台淡出啟動進度面板(300 ms ease)→ 主控台 UI 顯示
2.1a 日常啟動(非首次)
| 情境 | 階段 2 (Python) | 階段 3 (server) | 其他 | 總計(樂觀) |
|---|---|---|---|---|
| 首次安裝 | 1.5 s(wheels extract) | 3.0 s | 3.0 s | ~7.5 s |
| 日常啟動 | 0.3 s(wheels 已 extract) | 1.5 s(binary warm) | 2.0 s | ~3.8 s |
結論:日常啟動預期 2-4 秒可就緒;滿足 PM AC-2.1「日常啟動 ≤ 5 s」的目標。慢速裝置或帶毒掃描軟體可能到 6-8 秒,仍遠低於 60 s 硬上限。
2.2 使用者按 Restart
t=0.000 使用者在控制台按 [⟲ Restart]
t=0.005 JS: RestartServer() binding 呼叫
t=0.010 Go: ServerController.Restart()
├─ Stop():
│ ├─ state: Running → Stopping emit server:state-change
│ ├─ SIGTERM server subprocess
│ ├─ wait exit (max 5s)
│ ├─ 若 timeout → SIGKILL
│ ├─ proc = nil
│ └─ state: Stopping → Stopped emit server:state-change
t=0.500 Stop() 完成(server 優雅退出通常 < 1 s)
└─ Start():
├─ state: Stopped → Starting
├─ spawn 新 server 子程序(新 boot-id!)
├─ waitHealthy
└─ state: Starting → Running
t=3.000 新 server Running
瀏覽器 tab 同時間:
t=0.500 ~ t=3.000 期間:polling /api/system/boot-id → ECONNREFUSED(連續失敗)
連續失敗 < 3 次 → 不顯示 Offline Overlay
t=3.000 polling 下一次成功 → 回傳新 boot-id
偵測到 boot-id 變了 → window.location.reload()
t=3.200 瀏覽器 reload 完,UI 重新連上新 server
關鍵設計:Restart 期間瀏覽器 tab 的 ECONNREFUSED 不立即顯示 overlay(須連續 3 次失敗 = 15 s),而正常 Restart 約 3 s 完成,這段期間使用者看到的是 Next.js 的 loading state / 既有畫面,而非 overlay。Restart 完後 boot-id 變了,瀏覽器 force reload。
2.3 使用者關閉 Wails 視窗(R5-2 + PM Q4 + Minor 4 WebSocket 廣播)
PM Q4 決策:shutdown grace period = 7 秒,且在開始 SIGTERM 的 1 秒內若仍未完成,顯示「停止中…」modal(toast/overlay 形式)告知使用者。
Minor 4:SIGTERM 之前先透過 WebSocket 對所有已連線的 Web UI tab 廣播 server:shutdown-imminent 事件,讓瀏覽器 tab 立刻顯示 Offline Overlay,避免 race condition。
t=0.000 使用者按 × 關閉 Wails 視窗
t=0.000 Wails OnBeforeClose 觸發(v2 新增 handler)
├─ preventClose = false(R5-2:不 hide-to-tray,真的要關)
└─ Wails 開始 shutdown 序列
t=0.005 Wails OnShutdown 觸發(對應 a.shutdown(ctx),沿用 v1 app.go:194-209)
├─ watchCancel()
├─ ipcListener.Close()
├─ removeWailsIPCPort(dataDir)
├─ ctrl.Stop()
│ ├─ state: Running → Stopping emit server:state-change
│ ├─ Minor 4:透過 server 的 WebSocket hub 廣播 server:shutdown-imminent
│ │ (payload: { reason: "app-closing" })
│ │ 瀏覽器 tab 收到 → 立即顯示 <ServerOfflineOverlay>
│ │ (不用等 3 次 polling 失敗)
│ ├─ SIGTERM server
│ ├─ 啟動 7 秒 grace timer 與 1 秒 modal timer
│ │
t=1.005 │ │── 1 秒 modal timer 到(若 server 仍未 exit)
│ │ └─ Wails 控制台 log panel 上方 overlay 顯示「停止中…」modal
│ │ i18n key: statusCard.stoppingModal
│ │
t=~0.2 │ │── 通常 server 在 200-500 ms 內 cmd.Wait() return(正常情境)
t=7.005 │ │── 7 秒 grace timer 到(罕見:server 卡死)
│ │ └─ SIGKILL server process
│ │
│ └─ state: Stopping → Stopped
└─ releaseLock()
t=7.200 Wails app 退出(最壞情況)
t=0.600 通常情境:server 200 ms 內 exit,整段 shutdown < 1 s,不會觸發 modal 也不會觸發 SIGKILL
瀏覽器 tab(使用者可能沒關):
t=0.005 收到 WebSocket server:shutdown-imminent → 立即顯示 Offline Overlay
(舊行為:連續 3 次 boot-id polling 失敗 = 15 s 才顯示,race 風險已解)
t=0.100+ polling /api/system/boot-id → ECONNREFUSED;overlay 已在畫面上不再重複觸發
為什麼是 7 秒而不是 10 秒:
- v2.0 互審時 Architect 提議 10 秒,PM 在審閱中決定 7 秒,理由:使用者關閉視窗時希望「秒級」回饋,10 秒過久容易讓使用者以為當機,再去強制關閉整個 app
- 7 秒 + 1 秒 modal 的組合:絕大多數情境 < 1 秒完成(不顯示 modal);稍慢情境 1-7 秒完成(顯示 modal 讓使用者知道還在停);卡死情境 7 秒強制 SIGKILL(使用者不會等超過 7 秒)
- server 端
shutdownFn原本是 10 秒 timeout;這裡需要對齊為 7 秒,避免 server 還在自己的 timeout 期間時 Wails 已經 SIGKILL 它。見 §8 的修改說明
3. Port 分配策略
3.1 冷啟動(首次 StartServer)
呼叫 pickPort(defaultPreferredPort=3721)(app.go:450)。若 3721 被佔用,自動往下找(3722, 3723…,最多 20 個)。這是原 v1 行為,冷啟動時允許 fallback。
3.2 Restart(F-2 強制保留舊 port)
v2.1 變更:Restart Server 時必須保留舊 port,不可 fallback。具體:
func (c *ServerController) Restart() error {
c.mu.Lock()
oldPort := 0
if c.proc != nil {
oldPort = c.proc.port
}
c.mu.Unlock()
if err := c.Stop(); err != nil && err.Error() != "" {
return err
}
// 呼叫 Start 時帶入 preferred port
return c.startWithPort(oldPort)
}
func (c *ServerController) startWithPort(preferredPort int) error {
// ... 如常的 Starting transition
// 差別:呼叫 pickPort(preferredPort, /*forceMatch=*/true)
// 若 preferred 被佔用 → 不 fallback,回 error → state: Starting → Error
// + sendCrashNotification("Port 3721 被佔用,Restart 失敗")
}
為什麼 Restart 不允許 fallback:Restart 對應的典型使用情境是「server 狀態怪怪的,按一下重啟」,使用者期待原本開著的瀏覽器 tab 重新連上。若 Restart 換 port,瀏覽器 tab 會連不上舊 port、而 Next.js URL 是 static build 寫死 path(port 由 runtime inject),使用者體驗崩壞。保留舊 port 則瀏覽器 tab 透過 boot-id 偵測到變化後 reload,URL 原 port 仍有效 → 自動連回。
Port 被佔用時的處理:極罕見(Stop 剛 release、被第三方程式秒搶的情況),直接進 Error state + 發 OS 通知 + Wails 控制台 banner。使用者手動處理(殺掉佔用的程式、或按 Stop 後再 Start 讓它 fallback)。
3.3 冷啟動 vs Restart 決策矩陣(v2.1 定版)
| 情境 | 允許 port fallback? | 說明 |
|---|---|---|
| 冷啟動(app 啟動時的第一次 Start) | ✅ 允許(3721 → 3722 → … 最多 20 個) | 原 v1 行為 |
| 使用者在控制台按 Start(從 Stopped / Error 手動啟動) | ✅ 允許(視同冷啟動) | 沒有既有瀏覽器 tab 在等固定 port |
| 使用者在控制台按 Restart(從 Running) | ❌ 不允許(F-2 強制保留舊 port) | 保護既有瀏覽器 tab URL 不失效 |
| watchServer 連續失敗 → Error → 使用者按 Start | ✅ 允許(視同冷啟動) | 同上 |
| RestartStartupSequence(Wails 控制台 Startup Error state 的「Retry」按鈕) | ✅ 允許(視同冷啟動) | 此時整個啟動流程重跑,瀏覽器 tab 還沒載入,無保留 port 的必要 |
關鍵實作要點:
- Restart 前先記住
c.proc.port→ 呼叫c.startWithPort(oldPort)→pickPort時帶forceMatch=true - 若 port 被其他程序佔用(少見)→ 進 Error state + 呼叫
sendCrashNotification("Restart failed: port %d occupied") - RestartStartupSequence 則直接
c.Start()(無 preferred port,允許 fallback)
3.4 Restart 完成後的瀏覽器 tab 行為
既然 Restart 時 port 不變,boot-id 偵測到變化 → window.location.reload() → 使用 URL 原 port → 新 server 正好也在該 port 聽 → 連上。
若 Restart 失敗(port 被佔)→ 進 Error state → 瀏覽器 tab 的 polling 連續失敗 → 15 s 後顯示 Offline Overlay(也可能早於此透過 WebSocket shutdown-imminent 廣播觸發)。
4. Stdout / Stderr pipe 捕捉機制
4.1 現況(v1 app.go:484-516)
stdoutLog, _ := os.OpenFile(..., APPEND, 0o644)
stderrLog, _ := os.OpenFile(..., APPEND, 0o644)
cmd.Stdout = stdoutLog // 直接把 os.File 當 writer,kernel 直接 dup
cmd.Stderr = stderrLog
問題:Wails Go 拿不到 server log 的任何 byte,控制台 log panel 無法即時顯示。
4.2 新方案(v2)
// server_control.go 的 startServerV2() 函式
cmd := exec.Command(binPath, args...)
cmd.Dir = filepath.Dir(binPath)
configureSysProcAttr(cmd)
cmd.Env = env // 沿用 v1 的 VISIONA_BUNDLE_BIN_DIR / VISIONA_PYTHON 注入
// v2:用 StdoutPipe / StderrPipe,不直接給 os.File
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("StdoutPipe: %w", err)
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
return fmt.Errorf("StderrPipe: %w", err)
}
// 開磁碟檔(給 logPump 寫)
stdoutLog, _ := os.OpenFile(filepath.Join(logsDir, "server.stdout.log"),
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
stderrLog, _ := os.OpenFile(filepath.Join(logsDir, "server.stderr.log"),
os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err := cmd.Start(); err != nil {
stdoutPipe.Close()
stderrPipe.Close()
return fmt.Errorf("exec.Start: %w", err)
}
// v2:兩個 goroutine 同時 pump
pumpDone := make(chan struct{}, 2)
go a.logPump(stdoutPipe, "stdout", stdoutLog, pumpDone) // pumpDone 寫入 1
go a.logPump(stderrPipe, "stderr", stderrLog, pumpDone) // pumpDone 寫入 1
4.3 logPump 結束條件
- server process exit → pipe EOF → scanner loop 結束 → flush remaining batch → close(done)
- 外部 Close()(例如 Stop() 的 SIGKILL 路徑)→ scanner Err → 結束
兩個 pump 都結束後 pumpDone 會收到 2 次。ServerController 不需要等 pump 全部結束再改 state(state 在 Stop() 裡面改),pump 會在背景自動清理。
4.4 與現有 server WebSocket broadcaster 的關係
現有 server 有 pkg/logger/broadcaster.go 透過 /ws/server-logs 把 log 推給 Next.js Web UI 的 Settings 頁。這不受影響:
| log 流 | 寫入 | 讀取者 |
|---|---|---|
| stdout / stderr | Wails logPump 捕捉 | Wails 控制台 log panel |
| Gin logger middleware | broadcasterLogger 同時寫 stdout + ws broadcaster |
控制台(透過 stdout)+ Next.js Settings(透過 WS) |
| 業務 log(logger.Info 等) | 透過 server/pkg/logger 寫 stdout + WS broadcaster | 同上 |
→ 控制台看到的是「stdout/stderr 全部」,Next.js Settings 頁看到的是「logger + Gin」。兩個檢視不衝突。
5. ServerController 實作細節
5.1 欄位與互斥
type ServerController struct {
app *App
mu sync.Mutex // 保護 state / proc / startedAt / lastError
state ServerState
proc *ServerProcess
startedAt time.Time
lastError string
// 保證同時只有一個 transition 在進行(Start/Stop/Restart 互斥)
txMu sync.Mutex
}
txMu 不同於 mu:mu 保護 struct field 的快速讀寫;txMu 在 Start / Stop / Restart / watchServer 改 state 的整段邏輯內持有,避免「Start 還在跑、使用者又按 Stop」這類 race。
5.2 Start / Stop / Restart 防呆
func (c *ServerController) Start() error {
c.txMu.Lock()
defer c.txMu.Unlock()
c.mu.Lock()
s := c.state
c.mu.Unlock()
if s == ServerStateRunning || s == ServerStateStarting || s == ServerStateStopping {
return fmt.Errorf("cannot start: current state=%s", s)
}
// ... 真的啟動
}
func (c *ServerController) Stop() error {
c.txMu.Lock()
defer c.txMu.Unlock()
c.mu.Lock()
s := c.state
proc := c.proc
c.mu.Unlock()
if s == ServerStateStopped || s == ServerStateError {
return nil
}
if s == ServerStateStarting {
// 特別情境:Starting 中使用者就按 Stop — 等 Start 跑完再 stop
// 實作:直接先等 c.txMu 會拿到鎖的時候,Start 已結束
// (所以走到這裡 s 不可能是 Starting,除非 txMu 沒保護到)
return fmt.Errorf("wait for Start to finish")
}
if proc == nil {
return nil
}
// ... 真的停
}
實務上 Start 過程大概 1-3 秒,使用者在 UI 上 action bar 對應的按鈕會在 Starting 狀態被 disable,不太會誤按。但程式層仍要防呆。
5.3 setState 與事件發送
func (c *ServerController) setState(s ServerState, errMsg string) {
c.mu.Lock()
c.state = s
c.lastError = errMsg
if s == ServerStateRunning {
c.startedAt = time.Now()
}
status := c.app.snapshotStatus() // GetServerStatus 的純讀版本
c.mu.Unlock()
if c.app.ctx != nil {
wailsRuntime.EventsEmit(c.app.ctx, "server:state-change", status)
}
}
6. watchServer 改為 Error state
詳見 v2/control-panel.md §4.7。核心 diff:
-if failures >= 3 {
- if a.ctx != nil {
- wailsRuntime.EventsEmit(a.ctx, "server:dead", map[string]any{...})
- }
- a.reportFatal("server died", ...)
- return
-}
+if failures >= 3 {
+ a.ctrl.setState(ServerStateError, "health check failed 3 times")
+ if a.ctx != nil {
+ wailsRuntime.EventsEmit(a.ctx, "server:error", map[string]any{
+ "reason": "health check failed 3 times",
+ "port": sp.port,
+ })
+ }
+ // R5-D1:發 OS 原生通知讓使用者在 Wails 視窗最小化時也能收到
+ // sendCrashNotification 實作見 §10;非阻塞 goroutine,失敗不影響流程
+ go sendCrashNotification(
+ "visionA Local — Server 崩潰",
+ "本機伺服器停止回應。請打開 visionA Local 查看錯誤並按 Restart。",
+ )
+ // 不 os.Exit;watchServer 自己 return,等使用者在控制台按 Restart
+ return
+}
ServerProcess 的清理:進 Error state 不代表 process 一定已死。可能:
- process crash(正常 Error state 主要情境)→
cmd.Wait()會 return,pipe EOF → logPump 自清理 - process hang(heartbeat 無回應但進程還活著)→ Error state 記錄後 ServerController 會在使用者按 Start / Restart 時先強制 SIGKILL 舊 proc 再起新 proc
為了穩健,ServerController.Start() 在 Starting 前先檢查 c.proc != nil 且 c.proc.cmd.Process 還活著的話,強制 c.proc.stop() 清理掉再繼續:
func (c *ServerController) Start() error {
c.txMu.Lock()
defer c.txMu.Unlock()
// 清理殘留 process(Error state 進來時常見)
c.mu.Lock()
if c.proc != nil {
oldProc := c.proc
c.proc = nil
c.mu.Unlock()
oldProc.stop()
} else {
c.mu.Unlock()
}
// ...真的啟動
}
7. Wails OnBeforeClose handler(新)
visiona-local/main.go 修改:
package main
import (
"context"
"embed"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
//go:embed all:frontend
var assets embed.FS
func main() {
app := NewApp()
err := wails.Run(&options.App{
Title: "visionA Local — Edge AI Workspace",
Width: 1280,
Height: 800,
MinWidth: 960,
MinHeight: 640,
AssetServer: &assetserver.Options{
Assets: assets,
},
OnStartup: app.startup,
OnShutdown: app.shutdown,
OnBeforeClose: func(ctx context.Context) (prevent bool) {
// R5-2:關視窗 = 結束 server + 結束 app。不 hide-to-tray、不跳確認對話框。
// 回 false 允許 Wails 繼續 shutdown 流程(會呼叫 OnShutdown)。
return false
},
Bind: []interface{}{
app,
},
})
if err != nil {
panic(err)
}
}
為什麼明確寫 OnBeforeClose 而不是依 Wails default:
- Wails v2 default 是「直接關」,與我們要的一致
- 但明確寫出來可以在未來 R5-2 若反覆(使用者想改)時有一個明確的 hook point
- 也讓 reviewer 一眼看到「關視窗時我們不做額外處理」
app.shutdown(ctx) 繼續沿用 v1 的邏輯(app.go:194-209),唯一差別是把 a.stopServer() 換成 a.ctrl.Stop()。
8. Graceful shutdown 順序(PM Q4:7 秒 + 1 秒 modal)
Wails OnBeforeClose → return false
↓
Wails OnShutdown (app.shutdown)
↓
1. a.watchCancel() ← 停 health check goroutine,避免它看到 server 死掉再 emit error
2. a.ipcListener.Close() ← 停 Wails 自己的 IPC server
3. removeWailsIPCPort(a.dataDir) ← 刪 wails-ipc-port 檔
4. a.ctrl.Stop() ← 停 server 子程序(7 秒 grace period)
├─ state: Running → Stopping
├─ 透過 server WebSocket hub 廣播 server:shutdown-imminent(Minor 4)
├─ SIGTERM
├─ 啟動 1 秒 modal timer(goroutine)
│ ├─ 1 秒內若 server 仍未 exit → emit Wails event "shutdown:modal-show"
│ └─ 前端顯示「停止中…」modal
├─ cmd.Wait() 或 7 秒 timeout
├─ SIGKILL if needed(7 秒到還沒 exit)
├─ logPump goroutine 自行 EOF 結束
└─ state: Stopping → Stopped
5. a.releaseLock() ← 釋放 single-instance lock
↓
Wails 真的退出
8.1 Wails 與 server 端 timeout 對齊
- Wails 的
shutdownGracePeriod(app.go:46):7 秒(v2.0 曾提議 10 秒,PM Q4 決定 7 秒) - server 端
server/main.go:166-173的shutdownFntimeout:原本是 10 秒,需要對齊成 6 秒(比 Wails 少 1 秒,確保 server 自己先完成 graceful cleanup,而不是被 Wails SIGKILL) - server 端若 6 秒內未完成,也會自己 os.Exit,Wails 的 cmd.Wait() 立即 return
// server/main.go shutdownFn
-shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+shutdownCtx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
8.2 1 秒 modal 的實作
func (sp *ServerProcess) stop() {
// ... SIGTERM 先打出去
done := make(chan struct{})
go func() {
_ = sp.cmd.Wait()
close(done)
}()
modalTimer := time.NewTimer(1 * time.Second)
graceTimer := time.NewTimer(7 * time.Second)
defer modalTimer.Stop()
defer graceTimer.Stop()
for {
select {
case <-done:
return // 正常 exit
case <-modalTimer.C:
if sp.app.ctx != nil {
wailsRuntime.EventsEmit(sp.app.ctx, "shutdown:modal-show", nil)
}
case <-graceTimer.C:
_ = sp.cmd.Process.Kill() // SIGKILL
<-done // 等 Wait return
return
}
}
}
使用者體驗:
- 一般情境(< 1 s 完成):看不到 modal,Wails 秒關
- 稍慢情境(1-7 s):看到「停止中…」modal,最多等 7 s
- 卡死情境(> 7 s):7 s 時強制 SIGKILL,使用者總等待 7-7.5 s
8.3 modal 的前端 UI
Wails 前端(控制台 vanilla JS)訂閱 shutdown:modal-show event,在畫面中央顯示一個簡單 overlay(半透明背景 + 中央卡片),文案:
┌───────────────────────────────┐
│ │
│ 🔄 正在停止 server … │
│ │
│ 請稍候,最多 7 秒即會完成。 │
│ │
└───────────────────────────────┘
i18n key:statusCard.stoppingModal.title / statusCard.stoppingModal.body。文案細節交 Design Spec v2.1 敲定。
9. Boot-ID 機制
9.1 Server 端(server/main.go + system_handler.go)
定版決策(v2.1):使用純標準庫 crypto/rand 生成 16 bytes → hex string(32 字元),不引入 github.com/google/uuid 依賴。每次 StartServer() 成功後產生新 boot-id,寫入 global state 並透過 GET /api/system/boot-id 暴露。
server/main.go 在啟動時產生 boot-id,傳入 SystemHandler:
import (
...
+ "crypto/rand"
+ "encoding/hex"
)
+func newBootID() string {
+ b := make([]byte, 16)
+ _, _ = rand.Read(b[:16]) // crypto/rand 不會真的失敗(即使失敗也給 zero-value,不影響功能)
+ return hex.EncodeToString(b) // 32 字元 hex string
+}
func main() {
cfg := config.Load()
+ bootID := newBootID()
logger := pkglogger.New(cfg.LogLevel)
...
// Create system handler with injected version and restart function
- systemHandler := handlers.NewSystemHandler(Version, BuildTime, pythonBinForSystem, restartFn)
+ systemHandler := handlers.NewSystemHandler(Version, BuildTime, pythonBinForSystem, restartFn, bootID)
為什麼不用 google/uuid:
- 避免新增外部依賴(go.mod 越乾淨越好)
crypto/rand+hex都是標準庫,零成本- 32 字元 hex vs 36 字元 UUID,傳輸量略小
- UUID v4 本質上也是 16 bytes 隨機 + 格式化,換成 hex 只是省掉 dash
server/internal/api/handlers/system_handler.go 增加:
type SystemHandler struct {
startTime time.Time
version string
buildTime string
pythonBin string
shutdownFn func()
depsCache []deps.Dependency
+ bootID string
}
-func NewSystemHandler(version, buildTime, pythonBin string, shutdownFn func()) *SystemHandler {
+func NewSystemHandler(version, buildTime, pythonBin, bootID string, shutdownFn func()) *SystemHandler {
return &SystemHandler{
...
+ bootID: bootID,
}
}
+func (h *SystemHandler) BootID(c *gin.Context) {
+ c.JSON(200, gin.H{
+ "success": true,
+ "data": gin.H{"bootId": h.bootID, "startedAt": h.startTime.UnixMilli()},
+ })
+}
server/internal/api/router.go:
api.GET("/system/health", systemHandler.HealthCheck)
api.GET("/system/info", systemHandler.Info)
+api.GET("/system/boot-id", systemHandler.BootID)
9.1a Gin logger middleware 的 SkipPaths(v2.1 二次審閱補齊)
瀏覽器每個 tab 每 10 秒 poll 一次 /api/system/boot-id,正常模式下 health-check 也會被業務 code 輪詢。這兩個 endpoint 若每次都寫入 Gin access log,會把 server log 噴到滿是高頻輪詢訊息、淹沒真正有用的業務 log。
定案:Gin logger middleware 必須設定 SkipPaths,跳過 /api/system/boot-id 和 /api/system/health(= HealthCheck endpoint 的 path,請依實際 router mount 路徑確認)。
實作位置:server/internal/api/router.go(router 初始化的地方):
import (
"github.com/gin-gonic/gin"
)
func NewRouter(...) *gin.Engine {
router := gin.New()
// v2.1:跳過高頻輪詢 endpoint 的 access log
router.Use(gin.LoggerWithConfig(gin.LoggerConfig{
SkipPaths: []string{
"/api/system/boot-id",
"/api/system/health",
},
}))
router.Use(gin.Recovery())
// ... 其他 middleware 與 route 註冊
return router
}
注意:
SkipPaths是完整字串比對(不支援 regex / wildcard),要填入前端實際請求的 path- 業務 log(
logger.Info/logger.Warn等)不受影響,仍然正常寫入 → 這個 skip 只影響 Gin 的 access log 層 - WebSocket broadcaster 的 log 也不受影響(另一條管道)
9.2 響應格式
{
"success": true,
"data": {
"bootId": "6fa8c72e2b4b4b148e9a1f0d4a2f7e31",
"startedAt": 1744656180123
}
}
9.3 瀏覽器 tab 端
見 v2/web-ui-offline-overlay.md。核心:src/stores/system-store.ts 或新的 useBootIdWatcher.ts hook(v2.1 定版):
- 初次成功回應 → 記錄
initialBootId - 往後每次 10 s poll(normal 模式):
- 成功 + bootId 不變 → 沒事
- 成功 + bootId 變了 →
window.location.reload() - 失敗 1 次 →
consecutiveFailures++ - 失敗 ≥ 2 次 → 顯示
<ServerOfflineOverlay>,切 polling interval 為 3 s(active retry 模式) - 後續成功 → 清 failures + dismiss overlay + 切回 10 s interval
10. OS 崩潰通知(R5-D1)
觸發條件:任一以下情境發生時,除了走 Error state + 控制台 banner 外,同時發一則 OS 原生通知。兩者並存,不互相取代:
- watchServer 連續 3 次失敗 → Error state
- StartServer 失敗(Python runtime 找不到、server binary 不存在、port 被佔、startServerV2 return error)
- 階段化啟動總時 > 60 s(R5-E 硬上限)
- Restart 因 port 被佔進 Error state(§3.2 F-2)
實作檔案:visiona-local/notify.go
10.1 介面
package main
// sendCrashNotification 發送一則 OS 原生通知。非阻塞、最佳努力送達。
// 三平台各自用系統工具:
// macOS → osascript -e 'display notification "body" with title "title"'
// Linux → notify-send "title" "body" (libnotify)
// Windows → powershell BurntToast 或 msg * (依環境 fallback)
//
// 呼叫失敗(工具不存在、權限被關)時僅 log 到 stderr,不回傳 error,
// 不影響呼叫端的流程。
//
// 建議呼叫方式:go sendCrashNotification(...)(fire-and-forget)
func sendCrashNotification(title, body string)
10.2 macOS 實作
//go:build darwin
func sendCrashNotification(title, body string) {
// 需要對 title / body 做單引號跳脫避免 osascript 語法錯誤
safeTitle := strings.ReplaceAll(title, `"`, `\"`)
safeBody := strings.ReplaceAll(body, `"`, `\"`)
script := fmt.Sprintf(`display notification "%s" with title "%s" sound name "Funk"`, safeBody, safeTitle)
cmd := exec.Command("osascript", "-e", script)
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "[notify] osascript failed: %v\n", err)
}
}
注意事項:
- macOS 10.14+ 開始 osascript display notification 需要使用者授權(系統偏好 → 通知與專注模式),首次會彈出授權對話框
- 若使用者拒絕,後續呼叫靜默失敗(這是預期行為,不做額外處理)
- 不依賴 terminal-notifier 等第三方工具(避免 vendor)
10.3 Linux 實作
//go:build linux
func sendCrashNotification(title, body string) {
// notify-send 是 libnotify-bin 提供的標準工具,多數桌面環境預裝
cmd := exec.Command("notify-send", "-u", "critical", "-i", "dialog-error", title, body)
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "[notify] notify-send failed: %v\n", err)
// 不做第二段 fallback — Linux 桌面環境差異大,
// 若 notify-send 不存在就認命;控制台 banner 仍會顯示
}
}
10.4 Windows 實作
//go:build windows
func sendCrashNotification(title, body string) {
// 優先用 PowerShell BurntToast(系統級 toast,原生)
// 次選 msg *(老派但無條件可用,需要 Terminal Services 服務)
psScript := fmt.Sprintf(`
if (Get-Module -ListAvailable -Name BurntToast) {
Import-Module BurntToast -ErrorAction SilentlyContinue
New-BurntToastNotification -Text '%s','%s' -AppLogo ''
} else {
# 若沒裝 BurntToast 模組,直接用老派 msg * 廣播
msg * /TIME:10 "%s - %s"
}
`, escapePSString(title), escapePSString(body), escapePSString(title), escapePSString(body))
cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", psScript)
configureSysProcAttr(cmd) // 沿用現有的 hide-window flag
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "[notify] powershell notify failed: %v\n", err)
}
}
func escapePSString(s string) string {
return strings.ReplaceAll(s, "'", "''")
}
注意事項:
- BurntToast 不是系統預裝,但大多數開發者 / 進階使用者都有裝。沒裝就降級用
msg * msg *在 Windows 10/11 Home 版可能被限制,此時都失敗 → 最佳努力送達的設計,可接受- 不引入 Go Windows API 的 ToastNotification Manager(COM 呼叫複雜,維護成本高)
10.5 與控制台 Error banner 的關係
| 狀態 | 控制台 banner | OS 通知 |
|---|---|---|
| 使用者打開 Wails 視窗 | 一眼可見 | 附帶提醒(可能被忽略) |
| 使用者把 Wails 最小化 | 看不到 | 主要告知管道 |
| 使用者關閉 Wails | 不適用(已關) | 不適用(app 已退) |
結論:兩者互補,不取代。go sendCrashNotification(...) 與 setState(ServerStateError, ...) 同時呼叫,不做二選一判斷(例如「使用者正在看控制台就不發 OS 通知」這種複雜邏輯不做)。
11. Preferences 持久化(PM §11-1 回答)
11.1 檔案位置與格式
- 路徑:
<dataDir>/preferences.json- macOS:
~/Library/Application Support/visiona-local/preferences.json - Linux:
~/.local/share/visiona-local/preferences.json(XDG_DATA_HOME) - Windows:
%APPDATA%/visiona-local/preferences.json
- macOS:
- 格式:JSON,UTF-8,含 BOM 否也可以(load 時用
encoding/json預設處理) - 檔案內容範例:
{
"autoOpenBrowser": true,
"locale": "zh-TW",
"logRingSize": 2000
}
11.2 Go struct 定義
見 v2/control-panel.md §4.3 的 Preferences struct。關鍵欄位:
| 欄位 | 型別 | 預設 | 說明 |
|---|---|---|---|
AutoOpenBrowser |
bool |
macOS/Windows: true;Linux: false(R5-D2) |
Start 成功後是否開瀏覽器(每次都開,R5-D3) |
Locale |
string |
"" |
空字串 → 自動偵測(navigator.language);否則覆寫 |
LogRingSize |
int |
0 |
0 → 使用預設 2000;正整數 → 自訂 ring buffer 大小 |
11.3 DefaultPreferences() 實作
// visiona-local/preferences.go
package main
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
)
type Preferences struct {
AutoOpenBrowser bool `json:"autoOpenBrowser"`
Locale string `json:"locale,omitempty"`
LogRingSize int `json:"logRingSize,omitempty"`
}
// DefaultPreferences 依平台回傳預設值。
// R5-D2:Linux 預設關 AutoOpenBrowser(桌面環境差異大);
// macOS / Windows 預設開。
func DefaultPreferences() Preferences {
return Preferences{
AutoOpenBrowser: runtime.GOOS != "linux",
Locale: "",
LogRingSize: 0, // 0 = 使用 logBufferCap 預設 2000
}
}
func preferencesPath(dataDir string) string {
return filepath.Join(dataDir, "preferences.json")
}
// LoadPreferences 讀取 preferences.json。
// 讀取失敗 / 檔案不存在 / JSON 損毀 → 回傳 DefaultPreferences() + nil error。
// (讀取失敗不應阻止 app 啟動)
func LoadPreferences(dataDir string) Preferences {
path := preferencesPath(dataDir)
data, err := os.ReadFile(path)
if err != nil {
// 檔案不存在 / 無權限:fallback 到預設
return DefaultPreferences()
}
var p Preferences
if err := json.Unmarshal(data, &p); err != nil {
// JSON 損毀:fallback 到預設
// 不刪壞檔(保留讓使用者 debug),直接用預設覆寫 in-memory
return DefaultPreferences()
}
return p
}
// SavePreferences 寫 preferences.json。
// 採 atomic write-rename:
// 1. os.WriteFile(tmpPath, data, 0o644) 寫到 .preferences.json.tmp
// 2. os.Rename(tmpPath, realPath) 原子替換
// 避免寫到一半 crash 造成檔案損毀。
func SavePreferences(dataDir string, p Preferences) error {
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(p, "", " ")
if err != nil {
return err
}
realPath := preferencesPath(dataDir)
tmpPath := realPath + ".tmp"
if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
return err
}
if err := os.Rename(tmpPath, realPath); err != nil {
_ = os.Remove(tmpPath) // cleanup 失敗的 tmp
return err
}
return nil
}
11.4 何時讀取 / 寫入
| 時機 | 動作 |
|---|---|
App startup 初始化 a.prefs |
a.prefs = LoadPreferences(a.dataDir) |
使用者在控制台切換 Preferences toggle → SetPreferences binding |
SavePreferences(a.dataDir, p) + 更新 a.prefs |
| App shutdown | 不做任何事(每次變更都即時 save,不需要 shutdown flush) |
11.5 與 R5-D3 配合
每次 StartServer() 成功後:
if c.app.prefs.AutoOpenBrowser {
_ = c.app.OpenInBrowser("")
}
- Restart 時一樣檢查(R5-D3 明示每次都開)
autoOpenedThisSessionflag 已移除,不再有 per-session-once 概念
11.6 記憶體估算說明(PM §11-3 的 450 MB 指標澄清)
PM 原提問:AC-1.4 / NFR idle RAM ≤ 450 MB 是否合理?
Architect 分析(互審時已答):
- Wails 控制台(WebView2 / WKWebView):~120 MB(樂觀 80 MB / 悲觀 180 MB)
- Go server 子程序(Gin + handler):~60 MB(樂觀 40 MB / 悲觀 100 MB)
- Python runtime(bundled)+ KneronPLUS SDK(已載入):~180 MB(樂觀 150 MB / 悲觀 250 MB)
- 其他(ffmpeg idle 不佔、single-instance lock、IPC server):~10 MB
- 合計樂觀:~370 MB;合計悲觀:~500 MB
澄清(新加):450 MB 目標不含瀏覽器 tab 的記憶體消耗。
- 瀏覽器 tab 跑 Next.js Web UI,記憶體由系統瀏覽器(Chrome / Safari / Edge)管理,不在 visionA Local 進程樹內
- 因此
ps aux | grep visiona-local看到的 RSS 總和是「Wails + Go server + Python」三者,不包含瀏覽器 tab - 若使用者用 Chrome 開多個 Web UI tab,每個 tab 額外 ~100-200 MB(瀏覽器自己的事)
- NFR ≤ 450 MB 指標檢驗時,應以「visionA Local 啟動並 server Running、無瀏覽器 tab 連線」為量測條件
結論:樂觀情境明確達標,悲觀情境可能超過 450 MB 50 MB 左右。M8-10 smoke test 時量測實際值,若超出 > 10%(即 > 500 MB),啟動優化工作(例如 Python 延遲載入 SDK、Gin handler 減少 cache 大小)。
12. 待確認
- Wails v2 的
OnBeforeClose在 Linux AppImage 下是否正常觸發? — 以往經驗 OnBeforeClose 在某些 Linux WM(i3, xmonad)下 flaky。M8-10 驗收時實機測試,若有問題 fallback 為 OnShutdown 裡做所有清理(OnShutdown 一定會觸發)。 shutdownGracePeriod7 s 與 server 端 6 s 的對齊實務 — server 端 6 s timeout 可能在大量批次 IO flush 時不夠。實測,若發現 server 經常被 SIGKILL(7 s 已到但 server 還沒自己 exit),把 server timeout 調回 8 s、Wails 改 9 s(PM Q4 的 7+1 改為 9+1)。需回報給 PM 重新敲定。- Boot-ID 依賴(已定版) — v2.1 二次審閱後定版:用純
crypto/rand16 bytes → hex string(32 字元)生成 boot-id,不引github.com/google/uuid。實作範例見 §9.1。無需開發時再討論。 - Linux
notify-send不存在時的 fallback — 目前沒有 fallback,直接認命。若 M8-10 測試發現 Ubuntu minimal 安裝缺 libnotify-bin,考慮在 AppImage bundle 內自帶或於 postinstall 提示。暫不處理,視實測結果決定。