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

41 KiB
Raw Blame History

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.mdstate machine 定義於該處)、v2/web-ui-offline-overlay.md(瀏覽器端)、v2/startup-pipeline.mdR5-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 4SIGTERM 之前先透過 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。具體

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 不允許 fallbackRestart 對應的典型使用情境是「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 允許(視同冷啟動) 同上
RestartStartupSequenceWails 控制台 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 當 writerkernel 直接 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 全部結束再改 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 欄位與互斥

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 不同於 mumu 保護 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.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 != nilc.proc.cmd.Process 還活著的話,強制 c.proc.stop() 清理掉再繼續:

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 修改:

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 的 shutdownGracePeriodapp.go:467 秒v2.0 曾提議 10 秒PM Q4 決定 7 秒)
  • server 端 server/main.go:166-173shutdownFn timeout原本是 10 秒,需要對齊成 6 秒(比 Wails 少 1 秒,確保 server 自己先完成 graceful cleanup而不是被 Wails SIGKILL
  • server 端若 6 秒內未完成,也會自己 os.ExitWails 的 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 完成):看不到 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 keystatusCard.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

 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 的 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.gorouter 初始化的地方):

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
  • 業務 loglogger.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 hookv2.1 定版):

  1. 初次成功回應 → 記錄 initialBootId
  2. 往後每次 10 s pollnormal 模式):
    • 成功 + bootId 不變 → 沒事
    • 成功 + bootId 變了window.location.reload()
    • 失敗 1 次 → consecutiveFailures++
    • 失敗 ≥ 2 次 → 顯示 <ServerOfflineOverlay>,切 polling interval 為 3 sactive 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 介面

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 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.jsonXDG_DATA_HOME
    • Windows%APPDATA%/visiona-local/preferences.json
  • 格式JSONUTF-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: trueLinux: falseR5-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-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() 成功後:

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 提示。暫不處理,視實測結果決定。