# 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 收到 → 立即顯示 │ │ (不用等 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。具體: ```go 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`) ```go 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) ```go // 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 欄位與互斥 ```go 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 防呆 ```go 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` 與事件發送 ```go 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: ```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()` 清理掉再繼續: ```go 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` 修改: ```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` 的 `shutdownFn` timeout:原本是 10 秒,需要對齊成 **6 秒**(比 Wails 少 1 秒,確保 server 自己先完成 graceful cleanup,而不是被 Wails SIGKILL) - server 端若 6 秒內未完成,也會自己 os.Exit,Wails 的 cmd.Wait() 立即 return ```diff // 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 的實作 ```go 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`: ```diff 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` 增加: ```diff 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`: ```diff 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 初始化的地方): ```go 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 響應格式 ```json { "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 定版): 1. 初次成功回應 → 記錄 `initialBootId` 2. 往後每次 **10 s poll**(normal 模式): - 成功 + bootId 不變 → 沒事 - 成功 + bootId **變了** → `window.location.reload()` - 失敗 1 次 → `consecutiveFailures++` - 失敗 ≥ **2 次** → 顯示 ``,切 polling interval 為 **3 s**(active retry 模式) - 後續成功 → 清 failures + dismiss overlay + 切回 10 s interval --- ## 10. OS 崩潰通知(R5-D1) **觸發條件**:任一以下情境發生時,除了走 Error state + 控制台 banner 外,**同時**發一則 OS 原生通知。兩者並存,不互相取代: 1. watchServer 連續 3 次失敗 → Error state 2. StartServer 失敗(Python runtime 找不到、server binary 不存在、port 被佔、startServerV2 return error) 3. 階段化啟動總時 > 60 s(R5-E 硬上限) 4. Restart 因 port 被佔進 Error state(§3.2 F-2) **實作檔案**:`visiona-local/notify.go` ### 10.1 介面 ```go 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 //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 //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 //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 檔案位置與格式 - **路徑**:`/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` - **格式**:JSON,UTF-8,含 BOM 否也可以(load 時用 `encoding/json` 預設處理) - **檔案內容範例**: ```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()` 實作 ```go // 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()` 成功後: ```go if c.app.prefs.AutoOpenBrowser { _ = c.app.OpenInBrowser("") } ``` - Restart 時一樣檢查(R5-D3 明示每次都開) - `autoOpenedThisSession` flag **已移除**,不再有 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. 待確認 1. **Wails v2 的 `OnBeforeClose` 在 Linux AppImage 下是否正常觸發?** — 以往經驗 OnBeforeClose 在某些 Linux WM(i3, xmonad)下 flaky。M8-10 驗收時實機測試,若有問題 fallback 為 OnShutdown 裡做所有清理(OnShutdown 一定會觸發)。 2. **`shutdownGracePeriod` 7 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 重新敲定。 3. **Boot-ID 依賴(已定版)** — v2.1 二次審閱後定版:用純 `crypto/rand` 16 bytes → hex string(32 字元)生成 boot-id,**不引** `github.com/google/uuid`。實作範例見 §9.1。無需開發時再討論。 4. **Linux `notify-send` 不存在時的 fallback** — 目前沒有 fallback,直接認命。若 M8-10 測試發現 Ubuntu minimal 安裝缺 libnotify-bin,考慮在 AppImage bundle 內自帶或於 postinstall 提示。暫不處理,視實測結果決定。