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

400 lines
14 KiB
Markdown
Raw 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.

# 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
既有 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 各平台差異
- **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. 找下一個可用 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 邏輯)
```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-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` |