# Lifecycle — visionA-local > 程序生命週期、single-instance lock、port 衝突、程序啟停、錯誤恢復 > > ⚠️ **Tray 已砍**(第三輪使用者決策 Q-A=A3)。原本這份文件含 Tray 實作章節,現已全數移除。 > 檔名暫時保留為 `tray-and-lifecycle.md` 以避免大量交叉引用改動,內容已不含 tray。 > 相關 i18n key(`tray.*`)、圖資產(`tray-*.png`)、原 `edge-ai-platform/server/tray/` 皆不再沿用。 --- ## 1. 生命週期總覽 ``` [使用者雙擊 app] ↓ [Wails 啟動] → [single-instance check] → [已有 instance 在跑?] │ │ │ no │ yes → 啟用已存在 instance 的視窗 → exit ▼ [檢查 .installed 標記] → [尚未安裝?] │ │ │ no │ yes → [進入安裝精靈](見 dependency-bundling.md) ▼ [spawn Go server 子行程] ↓ [等待 /api/system/health 200,最多 10 秒] ↓ [WebView loads http://127.0.0.1:{port}] ↓ [Dashboard 顯示] ↓ ... 使用中 ... ↓ [使用者關閉主視窗(= Quit,見 Q7=B)] ↓ [cleanupAndExit] ├─ 送 SIGTERM 給 Go server ├─ 等 3 秒 graceful shutdown ├─ 仍存活 → SIGKILL ├─ 移除 single-instance lock └─ Wails exit ``` ## 2. Single-Instance Lock ### 2.1 需求 同一個使用者不應該同時跑兩個 visionA-local 視窗(會搶 3721 port、搶 Python sidecar 造成衝突)。 ### 2.2 實作 **用檔案鎖 + PID + port 三重確認:** ```go // visiona-local/lifecycle.go func acquireSingleInstance(dataDir string) (release func(), err error) { lockPath := filepath.Join(dataDir, "visiona-local.lock") // 1. 嘗試建立 lock 檔(O_EXCL) f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) if err != nil && os.IsExist(err) { // 2. 已存在 → 讀取裡面的 PID data, _ := os.ReadFile(lockPath) pid, _ := strconv.Atoi(strings.TrimSpace(string(data))) // 3. 檢查該 PID 是否還活著 if pid > 0 && processAlive(pid) { // 活著 → 嘗試透過 IPC 叫醒該 instance(見 2.3) if raiseExistingInstance() { return nil, ErrAlreadyRunning } } // 4. stale lock → 覆寫 os.Remove(lockPath) f, err = os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) if err != nil { return nil, err } } // 5. 寫入自己的 PID fmt.Fprintf(f, "%d", os.Getpid()) f.Close() // 6. 回傳 cleanup function return func() { os.Remove(lockPath) }, nil } ``` ### 2.3 喚起已存在 instance 用 localhost HTTP 呼叫一個特殊 endpoint: ```go // Wails app 監聽 127.0.0.1:{random_port}/ipc/raise func raiseExistingInstance() bool { // 從 lock 檔附近的 .ipc-port 讀 port portFile := filepath.Join(dataDir, "visiona-local.ipc-port") data, err := os.ReadFile(portFile) if err != nil { return false } port := strings.TrimSpace(string(data)) resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%s/ipc/raise", port)) if err != nil { return false } defer resp.Body.Close() return resp.StatusCode == 200 } ``` 被喚起的 instance 收到 `/ipc/raise` 後執行 `runtime.WindowShow(ctx)` 讓主視窗浮到前面。 ### 2.3.1 第二次雙擊的完整序列 使用者在 app 已啟動時再次雙擊圖示 / 從 Spotlight / 從命令列啟動: ``` 新 instance 啟動(process B) ↓ acquireSingleInstance() → lock 檔存在、PID 存活 ↓ 呼叫 raiseExistingInstance() → 讀 .ipc-port → GET http://127.0.0.1:{ipc-port}/ipc/raise ↓ 既有 instance(process A)收到 → runtime.WindowShow(ctx) + runtime.WindowUnminimise(ctx) + runtime.WindowSetAlwaysOnTop(ctx,true) → 立刻 setAlwaysOnTop(false)(短暫提前到最上層後釋放) ↓ process A 回應 200 ↓ process B 看到 200 → 立即 os.Exit(0)(不執行任何 cleanup,因為沒動任何狀態) ↓ 使用者看到的效果:沒有新視窗,既有視窗被提到前景並閃一下 ``` **失敗分支**:若 `/ipc/raise` 無回應(既有 instance 卡住),process B 判定既有 instance 不健康 → 嘗試 SIGTERM PID → 等 2 秒 → 若仍活著顯示錯誤對話框「偵測到另一個已停止回應的 instance,請手動結束後重試」,process B 退出但不奪取 lock(避免雙活)。 ### 2.4 各平台差異 - **macOS**:Wails / Cocoa 本來就處理 "open another instance" 為 activate existing,但此行為只適用「從 Dock/Finder 啟動第二次」。從命令列或 Spotlight 仍可能啟動第二個程序,所以還是需要 app 層的鎖。 - **Windows**:必須自己實作。沒有原生機制會合併多個 exe 進入同一個 instance。 - **Linux**:同 Windows,但 AppImage 每次都是新程序,鎖檔更重要。 ## 3. Port 衝突處理 ### 3.1 問題 - 預設用 3721,但使用者可能有其他東西佔用 - 之前跑的 visionA-local 異常退出沒釋放 port ### 3.2 解法:動態 port + kill stale ```go // server/main.go 的 killExistingProcess 邏輯保留並擴充 // visiona-local/server_launcher.go 新增 func pickPort(preferred int) (int, error) { // 1. 先嘗試 preferred port if available(preferred) { return preferred, nil } // 2. 檢查 preferred port 上的程序是否是「上次沒清乾淨的 visiona-local-server」 if isOurStaleServer(preferred) { killByPort(preferred) time.Sleep(500 * time.Millisecond) if available(preferred) { return preferred, nil } } // 3. 找下一個可用 port(3722, 3723, ...) for p := preferred + 1; p < preferred+20; p++ { if available(p) { return p, nil } } return 0, errors.New("no free port in range") } func isOurStaleServer(port int) bool { // 檢查 process name 是否是 visiona-local-server // Linux/macOS: lsof -i :port + ps -p PID // Windows: netstat -ano + tasklist /FI "PID eq ..." // ... } ``` ### 3.3 UI 呈現 如果最終選到的 port 不是 3721,在 Settings > Server 狀態區塊顯示實際 port,並在 server logs 印一行 warning。不彈視窗打擾使用者。 ## 4. 程序啟停細節 ### 4.1 啟動 Go server(spawn 邏輯) ```go // visiona-local/server_launcher.go type ServerProcess struct { cmd *exec.Cmd cancel context.CancelFunc port int dataDir string } func (inst *Installer) launchServer() (*ServerProcess, error) { port, err := pickPort(3721) if err != nil { return nil, err } ctx, cancel := context.WithCancel(context.Background()) // 從 .installed 讀出 python.mode meta := readInstallMeta(inst.dataDir) binPath := filepath.Join(inst.dataDir, "bin", "visiona-local-server") if runtime.GOOS == "windows" { binPath += ".exe" } pythonBin := filepath.Join(inst.dataDir, "venv", "bin", "python3") if runtime.GOOS == "windows" { pythonBin = filepath.Join(inst.dataDir, "venv", "Scripts", "python.exe") } scriptsDir := filepath.Join(inst.dataDir, "scripts") dataSubdir := filepath.Join(inst.dataDir, "data") args := []string{ "--port", strconv.Itoa(port), "--host", "127.0.0.1", "--python", pythonBin, "--scripts-dir", scriptsDir, "--data-dir", dataSubdir, "--log-level", "info", } cmd := exec.CommandContext(ctx, binPath, args...) cmd.Stdout = inst.serverLogWriter("stdout") cmd.Stderr = inst.serverLogWriter("stderr") if err := cmd.Start(); err != nil { cancel() return nil, err } // 等健康檢查 if err := waitHealthy(port, 10*time.Second); err != nil { cmd.Process.Kill() cancel() return nil, fmt.Errorf("server did not become healthy: %w", err) } return &ServerProcess{cmd: cmd, cancel: cancel, port: port, dataDir: inst.dataDir}, nil } func (sp *ServerProcess) Stop() { // 1. graceful: SIGTERM sp.cmd.Process.Signal(syscall.SIGTERM) // 2. 等 3 秒 done := make(chan error, 1) go func() { done <- sp.cmd.Wait() }() select { case <-done: // ok case <-time.After(3 * time.Second): // 3. 強制殺 sp.cmd.Process.Kill() <-done } sp.cancel() } ``` ### 4.2 Server 意外崩潰偵測 Wails app 開一個 goroutine `watchServer()`: - 每 10 秒對 `/api/system/health` 做 healthcheck - 失敗 3 次 → 認定 server 掛了 - **不自動重啟**(第一版)→ 顯示「Server 無回應」錯誤視窗,提供「重啟 server / 重新啟動 app / 查看 log」三個選項 - 第二版可加自動重啟(最多 3 次,避免 crash loop) ### 4.3 Python sidecar 生命週期 Python sidecar 由 Go server 自行管理(不由 Wails 控),沿用 `edge-ai-platform/server/internal/device/` 的 bridge 模式: - 需要時 spawn(第一次 scan devices) - 空閒 N 分鐘後自動 kill(省記憶體) - Go server 關閉時一定要 reap(避免殭屍程序) - **Mock 模式下完全不 spawn**(見 `architecture-overview §7`) ### 4.4 Python sidecar crash auto-restart(第四輪補件) Python sidecar 可能因為 KneronPLUS 驅動異常、libusb 斷線、OOM 等原因崩潰。Go server 偵測 sidecar 崩潰後執行 auto-restart 策略: ``` device.Manager goroutine 監聽 python sidecar stdout/stderr pipe ↓ pipe EOF 或 process exit → 判定 crash ↓ restart_count < 3? ├─ Yes → sleep(backoff) → spawn 新 sidecar │ backoff: 1s → 3s → 10s(指數退避) │ spawn 成功 → 重置 restart_count、重新 scan devices │ spawn 失敗 → restart_count++ → 下一輪 └─ No(連續 3 次失敗)→ 放棄 auto-restart ↓ WebSocket /ws/devices/events push {"type":"sidecar_crashed","restart_attempts":3, "last_error":"..."} ↓ 前端顯示錯誤 modal:「硬體連線失敗,請檢查 USB 裝置或切換到 Mock 模式」 ``` **restart_count 重置時機**:任何一次成功跑滿 5 分鐘不崩,就重置為 0(避免累積記住舊錯誤)。 **同一時段 Go server 也透過 WebSocket broadcast 給前端**,讓使用者立即知道問題,不用等下次操作才發現。 ### 4.5 啟動時的資料目錄遷移 為了兼容早期開發階段可能殘留的舊路徑,Wails app 在 `acquireSingleInstance` **之後、`stepSetupPython*` 之前**執行一次性檢查: ```go // visiona-local/data_migration.go func MigrateOldDataDirs(newDir string, logger *log.Logger) { candidates := oldDataDirCandidates() // 平台相依 for _, old := range candidates { if _, err := os.Stat(old); err != nil { continue } if _, err := os.Stat(newDir); err == nil { logger.Printf("⚠️ 偵測到舊資料目錄 %s,但新路徑 %s 已存在,請手動清理舊路徑", old, newDir) continue } if err := os.Rename(old, newDir); err != nil { logger.Printf("⚠️ 遷移 %s → %s 失敗:%v", old, newDir, err) continue } breadcrumb := filepath.Join(newDir, ".migrated-from") os.WriteFile(breadcrumb, []byte(old+"\n"+time.Now().Format(time.RFC3339)), 0644) logger.Printf("✅ 已將 %s 遷移到 %s", old, newDir) } } func oldDataDirCandidates() []string { home, _ := os.UserHomeDir() switch runtime.GOOS { case "darwin": return []string{ filepath.Join(home, ".visiona-local"), // 隱藏資料夾 filepath.Join(home, "Library", "Application Support", "visionA-local"), // 大寫舊名 } case "windows": appdata := os.Getenv("APPDATA") return []string{ filepath.Join(home, ".visiona-local"), filepath.Join(appdata, "visionA-local"), } case "linux": return []string{ filepath.Join(home, ".visiona-local"), filepath.Join(home, ".local", "share", "visionA-local"), } } return nil } ``` **原則**: - 遷移失敗不擋啟動 - 新路徑已存在時不覆蓋 - `.migrated-from` breadcrumb 供 troubleshooting 與未來版本確認是否已遷移過 - 只遷移一次(之後舊路徑已不存在,迴圈自然跳過) --- ## 5. 錯誤恢復矩陣 | 錯誤情境 | 偵測方式 | 處理 | |---------|---------|-----| | Python runtime 壞掉(bundled 被防毒刪) | venv 執行失敗 | 自動 fallback 到 system python;仍壞則提示重裝 | | 3721 port 被佔用 | `pickPort` 找不到 | 顯示錯誤對話框 + 建議手動指定 port | | Go server 無法啟動 | health check timeout | 顯示錯誤 + 附上最後 20 行 log | | KneronPLUS driver 未安裝(Windows) | `pnputil /enum-drivers` 找不到 | 首次啟動時詢問 UAC;拒絕 → 降級到 Mock 模式 | | Python sidecar 崩潰 | Go server 偵測 pipe 斷開 | 指數退避 auto-restart(1s/3s/10s),最多 3 次;超過則 WebSocket push 錯誤事件給前端顯示 modal(見 §4.4)| | Lock 檔 stale(前一次沒清乾淨) | PID 不存在 | 自動清除並取得 lock | | WebView 載入失敗 | `/api/system/health` 200 但 WebView 白屏 | 顯示「重新載入」按鈕 | ## 6. 平台差異總表 | 項目 | macOS | Windows | Linux | |------|-------|---------|-------| | Single-instance 機制 | 檔案鎖 + Cocoa 協助 | 檔案鎖 + Named Mutex | 檔案鎖 | | 關閉視窗行為 | 關閉 = 結束(非 mac 慣例,但使用者決策 Q7=B) | 關閉 = 結束 | 關閉 = 結束 | | Signal handling | SIGTERM / SIGKILL | TerminateProcess + graceful Ctrl+Break | SIGTERM / SIGKILL | | Process tree 清理 | launchd 幫忙 reap | job object | prctl PR_SET_PDEATHSIG | | 檔案鎖位置 | `~/Library/Application Support/visiona-local/visiona-local.lock` | `%APPDATA%\visiona-local\visiona-local.lock` | `~/.local/share/visiona-local/visiona-local.lock` |