jim800121chen c54f16fca0 Initial commit: visionA monorepo with local-tool subproject
local-tool/: visionA-local desktop app
- M1: Wails shell + Go server + Next.js UI + Mock mode (macOS dmg ready)
- M2: i18n (zh-TW/en) + Settings 4-tab refactor
- M3: Embedded Python 3.12 runtime (python-build-standalone) + KneronPLUS wheels
- M4: Windows Inno Setup script (build on Windows runner)
- M5: Linux AppImage script + udev rule (build on Linux runner)
- M6: ffmpeg (GPL, pending legal review) + yt-dlp bundled
- Lifecycle: watchServer health check, fatal native dialog,
            Wails IPC raise endpoint, stale process cleanup

.autoflow/: full PRD / Design Spec / Architecture / Testing docs
            (4 rounds tri-party discussion + cross review)
.github/workflows/: macOS / Windows / Linux build CI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:10:38 +08:00

14 KiB
Raw Permalink Blame History

Lifecycle — visionA-local

程序生命週期、single-instance lock、port 衝突、程序啟停、錯誤恢復

⚠️ Tray 已砍(第三輪使用者決策 Q-A=A3。原本這份文件含 Tray 實作章節,現已全數移除。 檔名暫時保留為 tray-and-lifecycle.md 以避免大量交叉引用改動,內容已不含 tray。 相關 i18n keytray.*)、圖資產(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 三重確認:

// 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

// 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
  ↓
既有 instanceprocess 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 各平台差異

  • macOSWails / 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

// 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. 找下一個可用 port3722, 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 serverspawn 邏輯)

// 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* 之前執行一次性檢查:

// 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-restart1s/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