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

1007 lines
41 KiB
Markdown
Raw Permalink 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.

# v2/server-lifecycle.md — Server 生命週期與 Boot-ID 機制
> 所屬TDD v2 §2.3
> 版本v2.12026-04-14 吸收 PM 審閱 + R5-D + R5-E
> 決策依據R5-2視窗關閉 = 結束 server瀏覽器顯示 offline overlay、R5-4首次自動開瀏覽器、R5-D1OS 崩潰通知並存、R5-D2Linux 預設 auto-open OFF、R5-D3每次 Start 成功都開瀏覽器、R5-E階段化啟動 60 s 上限、PM Q4shutdown 7+1 秒)、三方共識 #10watchServer Error state、#14boot-id 重連)
> 對應 milestoneM8-4state machine + bindings、M8-4b階段化啟動管線見 `v2/startup-pipeline.md`、M8-9boot-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 Timeout20 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() ← 沿用 v1stage 2 的主要耗時)
t=1.600 pipeline.Complete(2) emit startup:progress(stage=2, completed)
├─ pipeline.Start(3) emit startup:progress(stage=3, running)
├─ ensureDriverInstalled() ← 沿用 v1Windows
├─ 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.AutoOpenBrowserR5-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 swheels extract | 3.0 s | 3.0 s | ~7.5 s |
| **日常啟動** | 0.3 swheels 已 extract | 1.5 sbinary 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 秒內**若仍未完成顯示「停止中…」modaltoast/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 = falseR5-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 → ECONNREFUSEDoverlay 已在畫面上不再重複觸發
```
**為什麼是 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 RestartF-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 寫死 pathport runtime inject使用者體驗崩壞保留舊 port 則瀏覽器 tab 透過 boot-id 偵測到變化後 reloadURL 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 當 writerkernel 直接 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 全部結束再改 statestate 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 |
| 業務 loglogger.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.ExitwatchServer 自己 return等使用者在控制台按 Restart
+ return
+}
```
**ServerProcess 的清理** Error state 不代表 process 一定已死可能
- process crash正常 Error state 主要情境)→ `cmd.Wait()` returnpipe EOF logPump 自清理
- process hangheartbeat 無回應但進程還活著)→ 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()
// 清理殘留 processError 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 Q47 秒 + 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-imminentMinor 4
├─ SIGTERM
├─ 啟動 1 秒 modal timergoroutine
│ ├─ 1 秒內若 server 仍未 exit → emit Wails event "shutdown:modal-show"
│ └─ 前端顯示「停止中…」modal
├─ cmd.Wait() 或 7 秒 timeout
├─ SIGKILL if needed7 秒到還沒 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.ExitWails 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 完成看不到 modalWails 秒關
- 稍慢情境1-7 s看到停止中…」modal最多等 7 s
- 卡死情境> 7 s7 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 string32 字元),**不引入** `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 的 SkipPathsv2.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` hookv2.1 定版):
1. 初次成功回應 → 記錄 `initialBootId`
2. 往後每次 **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 原生通知。兩者並存,不互相取代:
1. watchServer 連續 3 次失敗 → Error state
2. StartServer 失敗Python runtime 找不到、server binary 不存在、port 被佔、startServerV2 return error
3. 階段化啟動總時 > 60 sR5-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 ManagerCOM 呼叫複雜,維護成本高)
### 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`
- **格式**JSONUTF-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-D2Linux 預設關 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 runtimebundled+ 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 WMi3, xmonad下 flaky。M8-10 驗收時實機測試,若有問題 fallback 為 OnShutdown 裡做所有清理OnShutdown 一定會觸發)。
2. **`shutdownGracePeriod` 7 s 與 server 端 6 s 的對齊實務** — server 端 6 s timeout 可能在大量批次 IO flush 時不夠。實測,若發現 server 經常被 SIGKILL7 s 已到但 server 還沒自己 exit把 server timeout 調回 8 s、Wails 改 9 sPM Q4 的 7+1 改為 9+1。需回報給 PM 重新敲定。
3. **Boot-ID 依賴(已定版)** — v2.1 二次審閱後定版:用純 `crypto/rand` 16 bytes → hex string32 字元)生成 boot-id**不引** `github.com/google/uuid`。實作範例見 §9.1。無需開發時再討論。
4. **Linux `notify-send` 不存在時的 fallback** — 目前沒有 fallback直接認命。若 M8-10 測試發現 Ubuntu minimal 安裝缺 libnotify-bin考慮在 AppImage bundle 內自帶或於 postinstall 提示。暫不處理,視實測結果決定。