package main // server_control.go — M8-4:Server 生命週期狀態機 + bindings + logPump // // 本檔涵蓋 TDD: // - v2/control-panel.md §4.2–§4.7:bindings、型別、state machine、logPump // - v2/server-lifecycle.md §4–§6:stdout/stderr pipe 捕捉、ServerController 防呆、 // watchServer 改為 Error state // // 設計重點: // 1. ServerController 用 txMu(transition lock)+ mu(field lock)雙鎖 // 2. Start / Stop / Restart 整段邏輯由 txMu 序列化,避免 race // 3. 新的 startServerV2 用 StdoutPipe / StderrPipe 接 server 子行程, // 配合 logPump goroutine 把每一行送進 LogBuffer + emit Wails event // 4. Restart 強制保留舊 port(R5-F-2);cold start 允許 fallback // 5. watchServer 崩潰時進 Error state,不 os.Exit;同時發 OS 通知(R5-D1) import ( "archive/zip" "bufio" "context" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "sync" "syscall" "time" wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" ) // ----------------------------------------------------------------------- // 型別定義 // ----------------------------------------------------------------------- // ServerState 是控制台顯示的高階狀態(TDD v2/control-panel.md §4.3)。 type ServerState string const ( ServerStateIdle ServerState = "idle" // 初始,尚未啟動過 ServerStateStopped ServerState = "stopped" // 曾經 Running 過後又被停掉 ServerStateStarting ServerState = "starting" // 啟動中 ServerStateRunning ServerState = "running" // 正常運作 ServerStateStopping ServerState = "stopping" // 停止中(SIGTERM → grace → SIGKILL) ServerStateError ServerState = "error" // 啟動失敗 / 健康檢查失敗 ) // ServerStatusV2 對應 control-panel.md §4.3。前端用這個結構更新 UI。 type ServerStatusV2 struct { State ServerState `json:"state"` Port int `json:"port,omitempty"` URL string `json:"url,omitempty"` PID int `json:"pid,omitempty"` PythonBin string `json:"pythonBin,omitempty"` PythonMode string `json:"pythonMode,omitempty"` StartedAt int64 `json:"startedAt,omitempty"` // Unix ms LastError string `json:"lastError,omitempty"` } // SystemInfo 給 Status card 顯示。 type SystemInfo struct { AppVersion string `json:"appVersion"` BuildTime string `json:"buildTime"` DataDir string `json:"dataDir"` LogsDir string `json:"logsDir"` Platform string `json:"platform"` } // ----------------------------------------------------------------------- // ServerController — 狀態機 + Start/Stop/Restart // ----------------------------------------------------------------------- // ServerController 持有 server 子行程的生命週期狀態。 // 它擁有 txMu(transition lock)+ mu(field lock)雙鎖: // - txMu:Start / Stop / Restart 整段邏輯期間持有,保證互斥 // - mu:保護 state / proc / startedAt / lastError 的快速讀寫 type ServerController struct { app *App mu sync.Mutex state ServerState proc *ServerProcess startedAt time.Time lastError string txMu sync.Mutex } // NewServerController 建立一個新的 controller。 func NewServerController(app *App) *ServerController { return &ServerController{ app: app, state: ServerStateIdle, } } // State 回傳目前狀態(thread-safe snapshot)。 func (c *ServerController) State() ServerState { c.mu.Lock() defer c.mu.Unlock() return c.state } // setState 更新狀態並 emit Wails event。 // 注意:呼叫方不得持有 c.mu,本函式內部會取 c.mu。 func (c *ServerController) setState(s ServerState, errMsg string) { c.mu.Lock() c.state = s c.lastError = errMsg if s == ServerStateRunning { c.startedAt = time.Now() } c.mu.Unlock() // 取得最新 snapshot 後 emit status := c.app.snapshotStatus() if c.app.ctx != nil { wailsRuntime.EventsEmit(c.app.ctx, "server:state-change", status) } } // Start 啟動 server 子程序。冷啟動或手動從 Stopped / Error / Idle 啟動用。 // 允許 port fallback。 func (c *ServerController) Start() error { return c.startInternal(0) } // StartWithPort 用指定的 preferred port 啟動。 // preferredPort == 0 表示走 cold start 邏輯(允許 fallback)。 // preferredPort != 0 表示必須使用該 port(Restart 路徑),port 被佔就失敗進 Error。 func (c *ServerController) StartWithPort(preferredPort int) error { return c.startInternal(preferredPort) } func (c *ServerController) startInternal(preferredPort int) error { c.txMu.Lock() defer c.txMu.Unlock() // 1. 先檢查狀態 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) } // 2. 清理殘留 process(Error state 進來時常見) c.mu.Lock() if c.proc != nil { oldProc := c.proc c.proc = nil c.mu.Unlock() oldProc.stopGraceful() // 不發通知、快速清理 } else { c.mu.Unlock() } // 3. 切到 Starting c.setState(ServerStateStarting, "") // 3.5. 非冷啟動路徑(pipeline 已完成或已失敗)自動重建 pipeline,讓前端 // 5 階段 UI 能跟著 Restart / StopServer+StartServer 更新狀態。 // cold-start(pipeline current 在 [1..6])不動,避免打斷正在進行中的 // 正常啟動流程。 // - current > totalStages (7) → markReady 已完成 → Restart 路徑 // - current == -1 → FailStage 已停 → Retry 路徑(但這條 // 一般走 RestartStartupSequence 不走這裡) // // didRebuild 記錄本次是不是走 rebuild 路徑;是的話成功返回前會呼叫 // runStartupStage5 把 Stage 5 openBrowser + CompleteStage 跑完,後續 // Stage 6 由 watcher 的 sentinel poll 觸發 markReady。 didRebuild := false if c.app != nil && c.app.startupPipeline != nil { pl := c.app.startupPipeline pl.mu.Lock() cur := pl.current pl.mu.Unlock() needRebuild := cur <= 0 || cur > startupTotalStages if needRebuild { // 清 sentinel file(和 RestartStartupSequence 一樣,避免階段 6 誤判) removeSentinelFile(c.app.dataDir) c.app.rebuildStartupPipeline() didRebuild = true } } // 4. 真的啟動 proc, err := c.app.startServerV2(preferredPort) if err != nil { // M8-4b 補丁 M-1:若 startup pipeline 已 FailStage(`pipeline.emitError` 已經 // setState(Error) + 發過 OS 通知),這條 fallback 路徑 skip 一次設定,避免: // - server:state-change event 連發兩次(payload 相同、前端無傷但冗餘) // - 使用者看到「啟動失敗」+「Server 啟動失敗」兩個不同的 OS 通知,誤以為 // 發生兩次獨立的錯誤 // // 非冷啟動路徑(RestartServer 等 pipeline 已 ready,current==7)或 pipeline==nil // 場景下,fallback 仍需要自己 setState + emit + 通知。 pipelineHandled := false if c.app != nil && c.app.startupPipeline != nil { pipelineHandled = c.app.startupPipeline.HasFailedStage() } if !pipelineHandled { c.setState(ServerStateError, err.Error()) if c.app.ctx != nil { wailsRuntime.EventsEmit(c.app.ctx, "server:error", map[string]any{ "reason": err.Error(), }) } // R5-D1:OS 通知 go sendCrashNotification( "visionA Agent — Server 啟動失敗", "請打開 visionA Agent 查看錯誤詳情或按 Restart 重試。", ) } return err } c.mu.Lock() c.proc = proc c.mu.Unlock() c.setState(ServerStateRunning, "") // 5. Stage 5 openBrowser 處理 // // 三條路徑的分流(本段以 didRebuild 和 IsInColdStart 區分): // (A) 冷啟動 (app.startup → ctrl.Start):didRebuild=false, // IsInColdStart=true(current 仍在 2-4 範圍)→ 這段 skip,由 // app.startup 後續呼叫 runStartupStage5 負責 // (B) Restart / StopServer+StartServer:didRebuild=true(剛 rebuild // 過 pipeline),IsInColdStart=true → 呼叫 runStartupStage5 完整 // 跑 Stage 5 + Stage 6 watcher // (C) RestartStartupSequence:呼叫上層 ctrl.Start(此函式),didRebuild // = false(pipeline 已由 RestartStartupSequence 自己 rebuild), // IsInColdStart=true → skip,由 RestartStartupSequence 後續呼叫 // runStartupStage5 負責 // (D) 舊有 fallback:pipeline == nil 或意外情境 → 自己 openBrowser 一次 if didRebuild { // Restart 路徑:自己跑 Stage 5(也會 CompleteStage(5) → Stage 6 watcher // 會 poll sentinel → 最後 markReady,前端 5 階段面板會完整跑完) c.app.runStartupStage5() } else { // 冷啟動和 RestartStartupSequence 由上層呼叫 runStartupStage5 inColdStart := false if c.app != nil && c.app.startupPipeline != nil { inColdStart = c.app.startupPipeline.IsInColdStart() } if !inColdStart && c.app.prefs.AutoOpenBrowser && proc != nil && proc.port > 0 { url := fmt.Sprintf("http://127.0.0.1:%d", proc.port) if err := openBrowser(url); err != nil { fmt.Fprintf(os.Stderr, "[visiona-agent] auto-open browser failed: %v\n", err) } } } return nil } // Stop 優雅停止 server。7 秒 grace + 1 秒 modal event。 func (c *ServerController) Stop() error { c.txMu.Lock() defer c.txMu.Unlock() // MAJ-1 修復:先 cancel watchServerV2 goroutine,避免它在 server 死後仍 poll // health endpoint,3 次失敗(30s)後誤觸 handleWatchFailure → setState(Error) // → 發 OS 崩潰通知。使用者按 Stop 後不該收到「Server 崩潰」警報。 c.cancelWatcher() c.mu.Lock() s := c.state proc := c.proc c.mu.Unlock() if s == ServerStateStopped || s == ServerStateError || s == ServerStateIdle { // 沒東西可停 return nil } if s == ServerStateStarting { // 理論上 txMu 已經保證不會併發,但保險起見 return fmt.Errorf("cannot stop while starting; retry shortly") } // 切到 Stopping(必須先 set,讓前端按鈕 disable) c.setState(ServerStateStopping, "") if proc != nil { proc.stopGraceful() } c.mu.Lock() c.proc = nil c.mu.Unlock() c.setState(ServerStateStopped, "") return nil } // cancelWatcher 取消正在跑的 watchServerV2 goroutine,並把 a.watchCancel 清掉。 // 必須在 txMu 已被持有的情況下呼叫(避免與 startServerV2 重新建立 watcher 競態)。 // // MAJ-1 修復用 helper:Stop / ForceKill 進場後第一件事就呼叫此函式, // 確保 watcher 不會在 transition 期間 race 地翻動 state。 func (c *ServerController) cancelWatcher() { if c.app == nil { return } c.app.mu.Lock() if c.app.watchCancel != nil { c.app.watchCancel() c.app.watchCancel = nil } c.app.mu.Unlock() } // Restart = Stop → Start,強制保留舊 port(R5-F-2)。 func (c *ServerController) Restart() error { // 記住舊 port c.mu.Lock() oldPort := 0 if c.proc != nil { oldPort = c.proc.port } c.mu.Unlock() // MAJ-4 補丁:通知瀏覽器 tab「server 要 restart 了」 // 前端收到 reason=restart → 延遲 10 秒再顯示 Offline Overlay,給 server 重啟時間。 // best-effort:失敗不影響 Stop / Start 流程。 if c.app != nil && oldPort > 0 { ctx := context.Background() if c.app.ctx != nil { ctx = c.app.ctx } notifyShutdownImminent(ctx, oldPort, "restart") } if err := c.Stop(); err != nil { return err } return c.StartWithPort(oldPort) } // ForceKill 非 graceful 直接 SIGKILL 目前的 proc(給 RestartStartupSequence 用)。 // 不 emit 崩潰通知,因為這是使用者主動要的「清乾淨重來」。 // // 呼叫後 state → Stopped。 func (c *ServerController) ForceKill() error { c.txMu.Lock() defer c.txMu.Unlock() // MAJ-1 修復:與 Stop() 同理,先 cancel watcher,避免 ForceKill 後 watcher // 還在跑 → 30 秒後把 Stopped 翻成 Error + 發崩潰通知。 c.cancelWatcher() c.mu.Lock() proc := c.proc c.proc = nil c.mu.Unlock() if proc != nil { proc.forceKill() } c.setState(ServerStateStopped, "") return nil } // handleWatchFailure 是 watchServer goroutine 偵測到連續失敗時呼叫的。 // 進 Error state + OS 通知,不 os.Exit。 // // MAJ-2 修復:必須持有 txMu,與 Start/Stop/ForceKill/Restart 序列化,避免: // - Stop 已把 state → Stopped,watcher 又把它翻回 Error // - ForceKill 已把 state → Stopped,watcher 又翻回 Error 並發崩潰通知 // // 進場後若 state 已不是 Running(代表使用者剛主動 Stop/ForceKill/Restart 完成), // 直接 return — watcher 的失敗結論已過時。 // // 死結風險評估:watchServerV2 是獨立 goroutine,由 startServerV2 啟動後與 // 啟動流程脫鉤,不會在持有 txMu 的情況下呼叫 handleWatchFailure,因此安全。 func (c *ServerController) handleWatchFailure(port int, reason string) { c.txMu.Lock() defer c.txMu.Unlock() c.mu.Lock() curState := c.state proc := c.proc c.mu.Unlock() // 二次檢查:已不是 Running 代表 Stop / ForceKill / Restart 在我們等 txMu 時搶先了。 // 不能覆蓋已完成的 transition。 if curState != ServerStateRunning { return } c.setState(ServerStateError, reason) if c.app.ctx != nil { wailsRuntime.EventsEmit(c.app.ctx, "server:error", map[string]any{ "reason": reason, "port": port, }) } // 非阻塞發 OS 通知 go sendCrashNotification( "visionA Agent — Server 崩潰", "本機伺服器停止回應。請打開 visionA Agent 查看錯誤並按 Restart。", ) // 嘗試清理 process(可能還沒死) if proc != nil { proc.forceKill() } } // ----------------------------------------------------------------------- // ServerProcess v2:擁有 pipe readers 與 pump 管線 // ----------------------------------------------------------------------- // stopGraceful 執行 7 秒 grace + 1 秒 modal 的 shutdown 流程。 // 對應 TDD v2/server-lifecycle.md §8.2 的 ServerProcess.stop()。 // // 注意:這是 v2 新增的方法,與 v1 的 ServerProcess.stop() 並存以免動到太多舊路徑。 // v2 路徑(ctrl.Stop 呼叫)一律走這個。 func (p *ServerProcess) stopGraceful() { if p == nil || p.cmd == nil || p.cmd.Process == nil { return } pid := p.cmd.Process.Pid if p.app != nil { p.app.appLog("stopGraceful: entered pid=%d", pid) } // Windows 沒有 SIGTERM,直接 Kill if runtime.GOOS == "windows" { _ = p.cmd.Process.Kill() } else { _ = p.cmd.Process.Signal(syscall.SIGTERM) } done := make(chan struct{}) go func() { _, _ = p.cmd.Process.Wait() close(done) }() modalTimer := time.NewTimer(1 * time.Second) graceTimer := time.NewTimer(shutdownGraceV2) defer modalTimer.Stop() defer graceTimer.Stop() // 追蹤 modal 是否曾 show,return 時需要對稱地 hide(避免前端卡在 // 「正在停止伺服器…」popup)。 modalShown := false defer func() { if p.app != nil { p.app.appLog("stopGraceful: return pid=%d modalShown=%v", pid, modalShown) } if modalShown && p.app != nil && p.app.ctx != nil { wailsRuntime.EventsEmit(p.app.ctx, "shutdown:modal-hide", nil) } }() // Watchdog:無論哪個 branch,最多 graceTimer + 2 秒 後強制離開,避免 // `<-done` 永遠阻塞(Windows 上 Process.Wait 偶有情況不 return)。 // 2 秒是額外 safety margin,用 timer.After 實作,不改動主 select。 hardBailout := time.NewTimer(shutdownGraceV2 + 2*time.Second) defer hardBailout.Stop() for { select { case <-done: p.closeLogFiles() return case <-modalTimer.C: if p.app != nil && p.app.ctx != nil { wailsRuntime.EventsEmit(p.app.ctx, "shutdown:modal-show", nil) modalShown = true p.app.appLog("stopGraceful: modal-show emitted") } case <-graceTimer.C: if p.app != nil { p.app.appLog("stopGraceful: grace timer, force-kill pid=%d", pid) } _ = p.cmd.Process.Kill() // 非阻塞等 done,最多 1 秒(防止 Windows Wait 卡死) select { case <-done: case <-time.After(1 * time.Second): if p.app != nil { p.app.appLog("stopGraceful: Process.Wait did not return within 1s after Kill, leaking") } } p.closeLogFiles() return case <-hardBailout.C: // 絕對上限:無論如何都要離開 if p.app != nil { p.app.appLog("stopGraceful: hard bailout hit, leaking process pid=%d", pid) } p.closeLogFiles() return } } } // forceKill 立即 SIGKILL,不 graceful、不 emit event。 func (p *ServerProcess) forceKill() { if p == nil || p.cmd == nil || p.cmd.Process == nil { return } _ = p.cmd.Process.Kill() _, _ = p.cmd.Process.Wait() p.closeLogFiles() } // closeLogFiles 安全關閉 stdout/stderr log 檔。 func (p *ServerProcess) closeLogFiles() { if p.stdoutLog != nil { _ = p.stdoutLog.Close() p.stdoutLog = nil } if p.stderrLog != nil { _ = p.stderrLog.Close() p.stderrLog = nil } } // shutdownGraceV2 = 7 秒 grace period(PM Q4)。 const shutdownGraceV2 = 7 * time.Second // ----------------------------------------------------------------------- // App.startServerV2 — 使用 pipe 捕捉 stdout/stderr // ----------------------------------------------------------------------- // startServerV2 是 v2 版本的 server spawn,改為: // 1. 使用 StdoutPipe / StderrPipe 讓 logPump 能逐行讀 // 2. 開兩個 goroutine 做 logPump(寫檔 + append LogBuffer + emit event) // 3. 健康檢查成功後才 writeIPCPort // 4. M8-4b:穿插 startupPipeline.CompleteStage(2..4) 的 hook // // preferredPort: // 0 → cold start / manual Start,允許從 defaultPreferredPort 開始 fallback // 非 0 → 強制指定 port(Restart 路徑),port 被佔就失敗 // // 成功回傳 *ServerProcess(含 port / cmd / pipes);失敗回 nil + error。 // // pipeline 注意事項:startServerV2 同時被冷啟動(pipeline 還在跑)和 RestartServer // (pipeline 已 ready,hook 會被 CompleteStage 內的 current!=stage 檢查擋下)共用。 // 因此呼叫 hook 是 idempotent / 安全的。 func (a *App) startServerV2(preferredPort int) (*ServerProcess, error) { // 1. 決定 Python runtime — 對應啟動階段 2「檢查 Python runtime」 pyBin, pyMode, err := a.ensurePythonRuntime(a.pythonMode) if err != nil { // 階段 2 失敗 if a.startupPipeline != nil { a.startupPipeline.FailStage(2, fmt.Errorf("python runtime unavailable: %w", err)) } return nil, fmt.Errorf("python runtime unavailable: %w", err) } a.mu.Lock() a.pythonBin = pyBin a.pythonModeR = pyMode a.mu.Unlock() // 階段 2 完成 → 自動進入階段 3 running if a.startupPipeline != nil { a.startupPipeline.CompleteStage(2) } // 2. 首次啟動自動安裝 Kneron WinUSB driver(Windows only) // 邏輯上發生在 Stage 2 與 Stage 3 之間(pipeline current 已切到 3), // 所以 emit 到 stage 3 的 detail,避免 driver detail 被前端忽略。 if pyBin != "" { if a.startupPipeline != nil && runtime.GOOS == "windows" { a.startupPipeline.EmitStageDetail(3, "startup.stage.2.detail.driver", 0) } if derr := a.ensureDriverInstalled(pyBin); derr != nil { a.appLog("driver auto-install failed (non-fatal): %v", derr) } } // 3. 挑 port var port int if preferredPort > 0 { // Restart 路徑:強制保留,不 fallback if !portAvailable(preferredPort) { // 先試著清掉 stale 的 visiona-agent-server if killStaleServerOnPort(preferredPort) { time.Sleep(500 * time.Millisecond) } } if !portAvailable(preferredPort) { return nil, fmt.Errorf("Restart failed: port %d occupied", preferredPort) } port = preferredPort } else { p, perr := pickPort(defaultPreferredPort) if perr != nil { return nil, fmt.Errorf("no free port: %w", perr) } port = p } // 4. 定位 server binary binPath, err := locateServerBinary() if err != nil { return nil, fmt.Errorf("server binary not found: %w", err) } // 5. 組參數 args := []string{ "--host", "127.0.0.1", "--port", strconv.Itoa(port), "--data-dir", a.dataDir, "--python-mode", string(pyMode), } if pyBin != "" { args = append(args, "--python", pyBin) } // 6. 開磁碟 log 檔(append 模式) logsDir := filepath.Join(a.dataDir, "logs") _ = os.MkdirAll(logsDir, 0o755) 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) // 7. 組 cmd cmd := exec.Command(binPath, args...) cmd.Dir = filepath.Dir(binPath) configureSysProcAttr(cmd) // 注入 bundle bin dir(ffmpeg / ffprobe) env := os.Environ() if binDir, err := locateBundleBinDir(); err == nil { env = append(env, "VISIONA_BUNDLE_BIN_DIR="+binDir) } if pyBin != "" { env = append(env, "VISIONA_PYTHON="+pyBin) } cmd.Env = env // 8. v2:用 Pipe 而不是 cmd.Stdout = file stdoutPipe, err := cmd.StdoutPipe() if err != nil { closeLogFilesSafe(stdoutLog, stderrLog) return nil, fmt.Errorf("StdoutPipe: %w", err) } stderrPipe, err := cmd.StderrPipe() if err != nil { _ = stdoutPipe.Close() closeLogFilesSafe(stdoutLog, stderrLog) return nil, fmt.Errorf("StderrPipe: %w", err) } // 9. 啟動 process if a.startupPipeline != nil && a.startupPipeline.IsInColdStart() { a.startupPipeline.EmitStageDetail(3, "startup.stage.3.detail.spawn", 0) } if err := cmd.Start(); err != nil { _ = stdoutPipe.Close() _ = stderrPipe.Close() closeLogFilesSafe(stdoutLog, stderrLog) return nil, fmt.Errorf("exec.Start: %w", err) } proc := &ServerProcess{ cmd: cmd, port: port, stdoutLog: stdoutLog, stderrLog: stderrLog, app: a, } // 10. 啟動兩個 logPump goroutine go a.logPump(stdoutPipe, "stdout", stdoutLog) go a.logPump(stderrPipe, "stderr", stderrLog) // 11. 等 health check — 對應啟動階段 3「啟動本機伺服器」 // // 冷啟動時 pause hard timeout:Windows 首次執行 visiona-agent-server.exe 會被 // Defender / EDR real-time scan 卡 30-120 秒,healthCheckTimeout 本身 180 秒足夠 // 涵蓋大多數情境,但不該把這段 scan 時間算進 startup pipeline 的 60 秒 total // budget(那是日常啟動預算,首次 bootstrap 應豁免,和 Stage 2 Python bootstrap // 同理)。 // // 判斷冷啟動:pipeline 處於 stage 1-6 範圍內(IsInColdStart)。 // RestartServer(pipeline 已 ready,current==7)不 pause,維持嚴格計時。 pausedForWait := false if a.startupPipeline != nil && a.startupPipeline.IsInColdStart() { a.startupPipeline.PauseHardTimeout() pausedForWait = true // Stage 3 sub-step 提示:告訴使用者 server binary 已送出,正在等待啟動 a.startupPipeline.EmitStageDetail(3, "startup.stage.3.detail.waitHealth", 0) } // waitHealthy progress callback:每 5 秒更新一次 stage-hint 顯示已等待時間; // 等待 >= 15 秒後改顯示 slow hint(首次啟動 Defender 掃描較慢是正常情況)。 waitProgress := func(elapsed int) { if a.startupPipeline == nil { return } key := "startup.stage.3.detail.waitHealth" if elapsed >= 15 { key = "startup.stage.3.detail.waitHealthSlow" } a.startupPipeline.EmitStageDetail(3, key, elapsed) } if err := waitHealthy(port, healthCheckTimeout, waitProgress); err != nil { if pausedForWait { a.startupPipeline.ResumeHardTimeout() } proc.forceKill() removeIPCPort(a.dataDir) // 階段 3 失敗 if a.startupPipeline != nil { a.startupPipeline.FailStage(3, fmt.Errorf("server did not become healthy: %w", err)) } return nil, fmt.Errorf("server did not become healthy: %w", err) } if pausedForWait { a.startupPipeline.ResumeHardTimeout() } // 階段 3 完成 → 自動進入階段 4 running if a.startupPipeline != nil { a.startupPipeline.CompleteStage(3) } // 12. 寫 ipc-port 檔 writeIPCPort(a.dataDir, port) // 12.5. M8-4b:階段 4「偵測 Kneron 裝置」— 探測 /api/devices 一次。 // 任何 HTTP response(含 5xx 或 timeout)都視為「server 已能 serve」,階段 4 完成。 // 真正的 device list 內容由前端負責處理(空 list 也算正常)。 if a.startupPipeline != nil { a.probeDeviceListAndComplete(port) } // 13. 啟動 watchServer(失敗進 Error state) watchCtx, cancel := context.WithCancel(context.Background()) a.mu.Lock() if a.watchCancel != nil { a.watchCancel() } a.watchCancel = cancel a.mu.Unlock() go a.watchServerV2(watchCtx, proc) return proc, nil } // probeDeviceListAndComplete 對 /api/devices 發一次 GET 作為「偵測 Kneron 裝置」階段, // 不論回應內容與狀態碼,只要拿到 HTTP response 就算階段 4 完成(我們只是想確認 // server 已能 serve 業務 endpoint,硬體有沒有插不重要 — 空 list 也合法)。 // // timeout 設 2 秒。TDD §3 原意是「秒回即算完成」;實測 /api/devices handler 讀內部 // registry,毫秒級就回,2 秒 timeout 足以涵蓋正常 latency,不需要 5 秒保守值。 // 逾時也算「偵測完成」(避免硬體 driver 卡住整個啟動流程)。 func (a *App) probeDeviceListAndComplete(port int) { if a.startupPipeline == nil { return } a.startupPipeline.EmitStageDetail(4, "startup.stage.4.detail.probe", 0) url := fmt.Sprintf("http://127.0.0.1:%d/api/devices", port) client := &http.Client{Timeout: 2 * time.Second} resp, err := client.Get(url) // Linux udev rule 自動偵測:解析 response body 檢查 udevHint if runtime.GOOS == "linux" && resp != nil && resp.StatusCode == 200 { a.checkAndInstallUdevRule(resp) } else if resp != nil { resp.Body.Close() } if err != nil { a.appLog("startup stage 4: device probe non-fatal error: %v", err) } a.startupPipeline.CompleteStage(4) } // checkAndInstallUdevRule 在 Linux 啟動流程中偵測 udev rule 是否需要安裝。 // 解析 /api/devices response body,如果 udevHint=true → 自動嘗試安裝: // 1. 先從 bundle 讀取 99-kneron.rules 到 /tmp // 2. 用 pkexec cp 提權複製到 /etc/udev/rules.d/(彈密碼框) // 3. pkexec udevadm reload + trigger // 成功後 appLog 提示使用者拔插裝置。失敗不阻擋啟動流程。 func (a *App) checkAndInstallUdevRule(resp *http.Response) { defer resp.Body.Close() var body struct { Data struct { UdevHint bool `json:"udevHint"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { return } if !body.Data.UdevHint { return } a.appLog("Linux udev rule 未安裝,正在嘗試自動安裝...") a.startupPipeline.EmitStageDetail(4, "startup.stage.4.detail.udev", 0) // 找 bundle 裡的 99-kneron.rules ruleSrc := "" if libDir := os.Getenv("VISIONA_BUNDLE_LIB_DIR"); libDir != "" { candidate := filepath.Join(libDir, "99-kneron.rules") if _, err := os.Stat(candidate); err == nil { ruleSrc = candidate } } if ruleSrc == "" { candidates := []string{ "installer/linux/99-kneron.rules", "../installer/linux/99-kneron.rules", } for _, c := range candidates { if _, err := os.Stat(c); err == nil { abs, _ := filepath.Abs(c) ruleSrc = abs break } } } if ruleSrc == "" { a.appLog("udev rule 來源檔找不到,跳過自動安裝") return } // AppImage FUSE mount 的檔案在 pkexec 提權後無法被 root 讀取, // 先 cp 到 /tmp 再 pkexec 從 /tmp 安裝。 tmpRule := "/tmp/visiona-agent-99-kneron.rules" data, err := os.ReadFile(ruleSrc) if err != nil { a.appLog("udev rule 讀取失敗:%v", err) return } if err := os.WriteFile(tmpRule, data, 0o644); err != nil { a.appLog("udev rule 寫 /tmp 失敗:%v", err) return } defer os.Remove(tmpRule) cpCmd := exec.Command("pkexec", "cp", tmpRule, "/etc/udev/rules.d/99-kneron.rules") if out, err := cpCmd.CombinedOutput(); err != nil { a.appLog("udev rule 安裝失敗(使用者可能取消了密碼輸入):%v (%s)", err, strings.TrimSpace(string(out))) return } _ = exec.Command("pkexec", "udevadm", "control", "--reload-rules").Run() _ = exec.Command("pkexec", "udevadm", "trigger").Run() a.appLog("udev rule 安裝成功。請拔掉 Kneron USB 裝置再重新插入,然後在 Web UI 點「掃描裝置」。") } func closeLogFilesSafe(files ...*os.File) { for _, f := range files { if f != nil { _ = f.Close() } } } // ----------------------------------------------------------------------- // logPump:pipe → LogBuffer → Wails event(10ms micro-batch) // ----------------------------------------------------------------------- // logPump 讀取 server 子程序的 stdout 或 stderr pipe, // 每行同時: // 1. 寫到磁碟 log 檔 // 2. append 到 ring buffer // 3. 累積到 10ms micro-batch,flush 時 emit "log:append" Wails event // // TDD ground truth:v2/control-panel.md §4.5、v2/server-lifecycle.md §4.2。 // // Pipe EOF(process exit)或 scanner 錯誤時 goroutine 自行退出。 func (a *App) logPump(pipe io.ReadCloser, stream string, fileWriter io.Writer) { defer pipe.Close() scanner := bufio.NewScanner(pipe) scanner.Buffer(make([]byte, 64*1024), 1*1024*1024) batch := make([]LogLine, 0, 16) ticker := time.NewTicker(10 * time.Millisecond) defer ticker.Stop() // 拆出 scanner goroutine,讓 main loop 可以 select 於 ticker + lineCh scanDone := make(chan struct{}) lineCh := make(chan string, 128) go func() { defer close(scanDone) for scanner.Scan() { text := scanner.Text() select { case lineCh <- text: default: // channel full:同步 block,避免 scanner 跑太快讓 batch 丟太多行 // 寧可 scanner 背壓一下,也要保留資料完整性 lineCh <- text } } }() flush := func() { if len(batch) == 0 { return } if a.ctx != nil && a.logBuf != nil { // Rate limit:burst 超過就不 emit,但 ring buffer 已 append 過 if a.logBuf.ShouldEmit() { wailsRuntime.EventsEmit(a.ctx, "log:append", batch) } } batch = batch[:0] } // processLine 把單行寫檔 + append ring buffer + 加進 batch。 // MAJ-5 修復後 main loop 與 drain loop 共用此 helper,確保兩條路徑語意一致。 processLine := func(line string) { // 1. 寫檔(持久化) if fileWriter != nil { _, _ = fileWriter.Write([]byte(line + "\n")) } // 2. ring buffer l := LogLine{ Ts: time.Now().UnixMilli(), Stream: stream, Line: line, Level: parseLogLevel(line), } if a.logBuf != nil { a.logBuf.Append(l) } // 3. 加到 batch batch = append(batch, l) } for { select { case line, ok := <-lineCh: if !ok { flush() return } processLine(line) case <-ticker.C: flush() case <-scanDone: // MAJ-5 修復:scanner goroutine 已結束(pipe EOF / process exit), // 但 lineCh buffer(cap 128)內可能還有未消化的行 — 包括 server crash // 時最後幾行 stderr stack trace。Go select 隨機選擇 ready case, // 直接 return 會導致這些行被丟棄。 // // 修法:drain lineCh 到底再 flush + return。drain 用 default 避免 // 永遠阻塞(scanner goroutine 已 close → 沒人會再寫入,drain 必能結束)。 ticker.Stop() drain: for { select { case line := <-lineCh: processLine(line) default: break drain } } flush() return } } } // ----------------------------------------------------------------------- // watchServerV2 — 取代 watchServer,失敗時進 Error state // ----------------------------------------------------------------------- // watchServerV2 每 10 秒打 /api/system/health,連續 3 次失敗進 Error state。 // 不再呼叫 reportFatal / os.Exit。 func (a *App) watchServerV2(ctx context.Context, sp *ServerProcess) { ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() failures := 0 healthURL := fmt.Sprintf("http://127.0.0.1:%d/api/system/health", sp.port) client := &http.Client{Timeout: 3 * time.Second} for { select { case <-ctx.Done(): return case <-ticker.C: resp, err := client.Get(healthURL) if err == nil && resp.StatusCode == 200 { resp.Body.Close() if failures > 0 && a.ctx != nil { wailsRuntime.EventsEmit(a.ctx, "server:recovered", nil) } failures = 0 continue } if resp != nil { resp.Body.Close() } failures++ fmt.Fprintf(os.Stderr, "[visiona-agent] server health check failed (%d/3): %v\n", failures, err) if failures >= 3 { if a.ctrl != nil { a.ctrl.handleWatchFailure(sp.port, "health check failed 3 times") } return } } } } // ----------------------------------------------------------------------- // Bindings — Wails 自動暴露給前端 // ----------------------------------------------------------------------- // DEPRECATED_IN_AGENT: StartServer / StopServer / RestartServer / ForceKillServer / // RestartStartupSequence 已移除。 // // 背景:local-tool 把這些 method 綁到前端,讓使用者從 UI 控制 server 生命週期 // (Retry / Restart / Force kill)。visionA Agent 的 3 個配置頁(狀態 / 配對 / // 設定)不需要由使用者直接操作 server — server 只由 tunnel 轉進來的雲端請求驅動, // 使用者不感知 server 存在。 // // 內部仍然透過 a.ctrl.Start() / a.ctrl.Stop() 等 controller method 控制生命週期, // 但不再透過 Wails binding 暴露給前端。測試若需要觸發啟停,直接呼叫 // a.ctrl.Start() / a.ctrl.Stop()。 // rebuildStartupPipeline 把 startup pipeline 重置為「Stage 1 completed + // Stage 2 running」的初始狀態並啟動 watcher。 // // 呼叫時機: // 1. RestartStartupSequence — 使用者按 Retry 按鈕時 recover failure // 2. startInternal — 非冷啟動路徑進場時,若 pipeline 已完成或失敗則 // 重建,讓 Restart / StopServer+StartServer 也能讓前端 5 階段 UI 更新 // // 前置:呼叫者必須先停舊 watcher goroutine(a.pipelineCancelFn)+ 清 // sentinel file(removeSentinelFile)。本函式只負責重建 + 前進到 stage 2。 func (a *App) rebuildStartupPipeline() { // 停舊 watcher(防禦:呼叫者沒清時保險) if a.pipelineCancelFn != nil { a.pipelineCancelFn() a.pipelineCancelFn = nil } // 新建 pipeline instance a.startupPipeline = NewStartupPipeline(a) // 階段 1「初始化 Wails 控制台」已是 running 狀態(Wails app 本身), // 不需要重做,直接 emit completed 然後手動切到階段 2 running now := time.Now() a.startupPipeline.mu.Lock() a.startupPipeline.startedAt = now a.startupPipeline.current = 1 a.startupPipeline.stages[1].status = "completed" a.startupPipeline.stages[1].startedAt = now a.startupPipeline.stages[1].completedAt = now a.startupPipeline.mu.Unlock() a.startupPipeline.emitProgress(1) // 啟動 watcher goroutine if a.ctx != nil { watcherCtx, cancel := context.WithCancel(a.ctx) a.pipelineCancelFn = cancel a.startupPipeline.watcherCancel = cancel a.startupPipeline.watcherDone = make(chan struct{}) go a.startupPipeline.watcher(watcherCtx) } // 切到階段 2 running a.startupPipeline.mu.Lock() a.startupPipeline.current = 2 a.startupPipeline.stages[2].status = "running" a.startupPipeline.stages[2].startedAt = time.Now() a.startupPipeline.mu.Unlock() a.startupPipeline.emitProgress(2) } // GetServerStatusV2 回傳 v2 版本的完整狀態。 // 注意:v1 的 GetServerStatus() 仍保留以維持相容性,但內容為舊 struct。 func (a *App) GetServerStatusV2() ServerStatusV2 { return a.snapshotStatus() } // snapshotStatus 是組裝 ServerStatusV2 的工具函式(內部使用)。 func (a *App) snapshotStatus() ServerStatusV2 { a.mu.Lock() pyBin := a.pythonBin pyMode := string(a.pythonModeR) a.mu.Unlock() st := ServerStatusV2{ PythonBin: pyBin, PythonMode: pyMode, } if a.ctrl != nil { a.ctrl.mu.Lock() st.State = a.ctrl.state st.LastError = a.ctrl.lastError if !a.ctrl.startedAt.IsZero() { st.StartedAt = a.ctrl.startedAt.UnixMilli() } proc := a.ctrl.proc a.ctrl.mu.Unlock() if proc != nil && proc.cmd != nil && proc.cmd.Process != nil { st.Port = proc.port st.URL = fmt.Sprintf("http://127.0.0.1:%d", proc.port) st.PID = proc.cmd.Process.Pid } } else { st.State = ServerStateIdle } return st } // GetRecentLogs 回傳 ring buffer 最後 n 行。 // n <= 0 或 > 2000 → 回傳全部。 func (a *App) GetRecentLogs(n int) []LogLine { if a.logBuf == nil { return []LogLine{} } return a.logBuf.Snapshot(n) } // DEPRECATED_IN_AGENT: ClearLogs 已移除。visionA Agent 的 3 個配置頁不提供 // 清空 log 按鈕;使用者只能看 RecentLog(狀態頁)或匯出 log(設定頁)。 // 真的需要清 buffer 的時機請呼叫 a.logBuf.Reset() 內部 method。 // GetSystemInfo 回傳靜態系統資訊。 func (a *App) GetSystemInfo() SystemInfo { logsDir := "" if a.dataDir != "" { logsDir = filepath.Join(a.dataDir, "logs") } return SystemInfo{ AppVersion: appVersionString(), BuildTime: appBuildTimeString(), DataDir: a.dataDir, LogsDir: logsDir, Platform: runtime.GOOS + "/" + runtime.GOARCH, } } // DEPRECATED_IN_AGENT: OpenInBrowser / RevealLogsFolder 已移除。 // // OpenInBrowser:local-tool 靠它讓使用者開「http://127.0.0.1:3721」到瀏覽器用 Web // UI;visionA Agent 的雲端 UI 是遠端的,沒有「開瀏覽器到本機」的需求。 // // RevealLogsFolder:AB2+AB3 決定不讓使用者從 UI 直接跳到 logs/ 檔案總管; // 設定頁提供「匯出 log zip」就夠了(ExportLog 仍保留)。 // ExportLog 把 ring buffer + logs/ 資料夾打包成 zip 並回傳絕對路徑。 // // AB10 更新:從單純文字檔改成 zip(對應 Design spec §6.2.3「匯出 Log」)。 // // zip 內容: // - `ring-buffer.txt` — 目前 in-memory ring buffer 快照(人類可讀) // - `logs/*.log` — logs/ 目錄內所有檔案(wails.log、server stdout/stderr) // // 輸出位置: // - 雛形:OS temp dir,檔名 `visionA-agent-log-YYYYMMDD-HHmmss.zip` // - 前端拿到 path 後可呼叫 SaveFileDialog 讓使用者選擇儲存位置(AF6 實作) // // 失敗情境: // - logBuf 未初始化 → error // - dataDir 為空 → 仍繼續(只 zip ring buffer) // - 部分檔案讀不到 → 跳過該檔並在 ring-buffer.txt 記一行 WARN;整個 export 不中斷 func (a *App) ExportLog() (string, error) { if a.logBuf == nil { return "", fmt.Errorf("log buffer not initialized") } ts := time.Now().Format("20060102-150405") zipPath := filepath.Join(os.TempDir(), fmt.Sprintf("visionA-agent-log-%s.zip", ts)) zf, err := os.Create(zipPath) if err != nil { return "", fmt.Errorf("create zip file: %w", err) } defer zf.Close() zw := zip.NewWriter(zf) defer zw.Close() // 1. 寫 ring buffer 快照為 ring-buffer.txt if err := writeRingBufferToZip(zw, a.logBuf); err != nil { return "", fmt.Errorf("write ring buffer: %w", err) } // 2. 把 logs/ 資料夾下所有檔案塞進 zip if a.dataDir != "" { logsDir := filepath.Join(a.dataDir, "logs") if err := addDirToZip(zw, logsDir, "logs"); err != nil { // 非致命——ring buffer 已經寫進去了 a.appLog("ExportLog: partial failure adding logs dir: %v", err) } } // 顯式 close zip writer(defer 會再 close 一次但是 no-op) if err := zw.Close(); err != nil { return "", fmt.Errorf("close zip writer: %w", err) } abs, absErr := filepath.Abs(zipPath) if absErr != nil { return zipPath, nil } return abs, nil } // writeRingBufferToZip 把 ring buffer 快照寫入 zip 中的 `ring-buffer.txt`。 func writeRingBufferToZip(zw *zip.Writer, buf *LogBuffer) error { w, err := zw.Create("ring-buffer.txt") if err != nil { return err } snap := buf.Snapshot(0) for _, line := range snap { t := time.UnixMilli(line.Ts).Format("2006-01-02 15:04:05.000") var prefix string if line.Level != "" { prefix = fmt.Sprintf("[%s] %s [%s] ", line.Level, t, line.Stream) } else { prefix = fmt.Sprintf("%s [%s] ", t, line.Stream) } if _, err := w.Write([]byte(prefix + line.Line + "\n")); err != nil { return err } } return nil } // addDirToZip 遞迴把 srcDir 下所有檔案放入 zip 的 prefix/ 底下。 // srcDir 不存在時直接 return nil(使用者可能首次啟動就按匯出)。 func addDirToZip(zw *zip.Writer, srcDir, prefix string) error { info, err := os.Stat(srcDir) if err != nil { if os.IsNotExist(err) { return nil } return err } if !info.IsDir() { return nil } return filepath.Walk(srcDir, func(path string, fi os.FileInfo, walkErr error) error { if walkErr != nil { return walkErr } if fi.IsDir() { return nil } rel, err := filepath.Rel(srcDir, path) if err != nil { return err } // zip 內的路徑用正斜線(跨平台標準) zipName := prefix + "/" + filepath.ToSlash(rel) f, err := os.Open(path) if err != nil { return err } defer f.Close() w, err := zw.Create(zipName) if err != nil { return err } if _, err := io.Copy(w, f); err != nil { return err } return nil }) } // GetPreferences 讀取 Preferences(in-memory)。 func (a *App) GetPreferences() Preferences { a.mu.Lock() defer a.mu.Unlock() return a.prefs } // SetPreferences 更新並持久化 Preferences。 func (a *App) SetPreferences(p Preferences) error { if a.dataDir == "" { return fmt.Errorf("data dir not ready") } if err := SavePreferences(a.dataDir, p); err != nil { return err } a.mu.Lock() a.prefs = p a.mu.Unlock() return nil } // ----------------------------------------------------------------------- // 版本字串 placeholder(M8 目前沒有 build 時注入版本,給一個合理預設) // ----------------------------------------------------------------------- func appVersionString() string { if v := os.Getenv("VISIONA_APP_VERSION"); v != "" { return v } return "dev" } func appBuildTimeString() string { if v := os.Getenv("VISIONA_BUILD_TIME"); v != "" { return v } return "unknown" }