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 ( "bufio" "context" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "runtime" "strconv" "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 Local — Server 啟動失敗", "請打開 visionA Local 查看錯誤詳情或按 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-local] 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 Local — Server 崩潰", "本機伺服器停止回應。請打開 visionA Local 查看錯誤並按 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-local-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-local-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) if resp != nil { resp.Body.Close() } // 不論 err 或 status,都視為階段 4 完成(只是 probe) if err != nil { fmt.Fprintf(os.Stderr, "[visiona-local] startup stage 4: device probe non-fatal error: %v\n", err) } a.startupPipeline.CompleteStage(4) } 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-local] 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 自動暴露給前端 // ----------------------------------------------------------------------- // StartServer 啟動 server 子程序(允許 port fallback)。 // 對應 TDD v2/control-panel.md §4.2。 func (a *App) StartServer() error { if a.ctrl == nil { return fmt.Errorf("controller not initialized") } return a.ctrl.Start() } // StopServer 優雅停止 server。 func (a *App) StopServer() error { if a.ctrl == nil { return fmt.Errorf("controller not initialized") } return a.ctrl.Stop() } // RestartServer 強制保留舊 port 的重啟。 func (a *App) RestartServer() error { if a.ctrl == nil { return fmt.Errorf("controller not initialized") } return a.ctrl.Restart() } // ForceKillServer 強制 SIGKILL server(非 graceful,無通知)。 // 主要給 RestartStartupSequence 用。 func (a *App) ForceKillServer() error { if a.ctrl == nil { return fmt.Errorf("controller not initialized") } return a.ctrl.ForceKill() } // RestartStartupSequence 完整重置 6 階段啟動流程(M8-4b / R5-E)。 // 由 Wails 控制台 Error state 的「Retry」按鈕呼叫。 // // 與 RestartServer 的差別: // RestartServer : Stop → Start(保留 port、不重置 startup pipeline) // RestartStartupSequence : ForceKill → 清狀態 → 重建 StartupPipeline → 從階段 2 跑 // // TDD 規格見 .autoflow/04-architecture/v2/startup-pipeline.md §8。 // // 五個步驟: // 1. 停掉舊 watcher goroutine // 2. ForceKill server 子程序 // 3. 把 state machine 切回 Stopped(避免 Error state 殘留) // 4. 清 sentinel file(critical — 否則階段 6 會誤判為瞬間完成) // 5. 重建 StartupPipeline、emit 階段 1 completed、開新 watcher、呼叫 ctrl.Start() func (a *App) RestartStartupSequence() error { if a.ctrl == nil { return fmt.Errorf("controller not initialized") } // Step 1: 停掉舊 watcher goroutine if a.pipelineCancelFn != nil { a.pipelineCancelFn() a.pipelineCancelFn = nil } // Step 2: 強制殺掉 server 子程序(不走 7s grace,我們是在 recover failure) _ = a.ctrl.ForceKill() // Step 3: state machine → Stopped(避免 Error state 殘留) a.ctrl.setState(ServerStateStopped, "") // Step 4: 清 sentinel file(前次 Run 的殘留會讓階段 6 立刻完成) removeSentinelFile(a.dataDir) // Step 5: 重建 StartupPipeline 並把 current 前進到 stage 2 running a.rebuildStartupPipeline() // 呼叫 StartServer(內部會依序 CompleteStage(2..4)) // Retry 情境允許 port fallback(cold start 模式) // // M8-4b 補丁 M-3:test hook — 單元測試可用 restartStartFn 替換 ctrl.Start, // 避免在測試環境 spawn 真的 python server。正式環境走預設 a.ctrl.Start()。 startFn := a.ctrl.Start if a.restartStartFn != nil { startFn = a.restartStartFn } if err := startFn(); err != nil { // startServerV2 內已 FailStage,不需重複;err 仍 propagate 給前端讓 Retry 按鈕能 catch return err } // 階段 5:開瀏覽器(或 skip) a.runStartupStage5() // 階段 6 由 watcher poll sentinel file 觸發 return nil } // 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) } // ClearLogs 清空 ring buffer(只清 UI,不清磁碟檔)。 func (a *App) ClearLogs() { if a.logBuf != nil { a.logBuf.Reset() } if a.ctx != nil { wailsRuntime.EventsEmit(a.ctx, "log:clear", nil) } } // 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, } } // OpenInBrowser 用系統瀏覽器開啟 url。空字串 → 用當前 server URL。 func (a *App) OpenInBrowser(url string) error { if url == "" { a.mu.Lock() if a.ctrl != nil { a.ctrl.mu.Lock() if a.ctrl.proc != nil { url = fmt.Sprintf("http://127.0.0.1:%d", a.ctrl.proc.port) } a.ctrl.mu.Unlock() } a.mu.Unlock() } if url == "" { return fmt.Errorf("no server URL available") } return openBrowser(url) } // RevealLogsFolder 在檔案管理器中開啟 /logs/ 目錄。 func (a *App) RevealLogsFolder() error { logsDir := filepath.Join(a.dataDir, "logs") if err := os.MkdirAll(logsDir, 0o755); err != nil { return fmt.Errorf("mkdir logs dir: %w", err) } switch runtime.GOOS { case "darwin": return exec.Command("open", logsDir).Start() case "windows": return exec.Command("explorer", logsDir).Start() default: return exec.Command("xdg-open", logsDir).Start() } } // ExportLog 把 ring buffer 當前內容寫到一個時間戳檔案並回傳絕對路徑。 func (a *App) ExportLog() (string, error) { if a.logBuf == nil { return "", fmt.Errorf("log buffer not initialized") } exportDir := filepath.Join(a.dataDir, "exports") if err := os.MkdirAll(exportDir, 0o755); err != nil { return "", fmt.Errorf("mkdir exports dir: %w", err) } ts := time.Now().Format("20060102-150405") path := filepath.Join(exportDir, fmt.Sprintf("log-%s.txt", ts)) snap := a.logBuf.Snapshot(0) f, err := os.Create(path) if err != nil { return "", fmt.Errorf("create export file: %w", err) } defer f.Close() for _, line := range snap { // 格式:"[level] 15:04:05.000 [stream] message\n"(level 為空時省略前括號) t := time.UnixMilli(line.Ts).Format("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 := f.WriteString(prefix + line.Line + "\n"); err != nil { return "", fmt.Errorf("write export file: %w", err) } } abs, err := filepath.Abs(path) if err != nil { return path, nil } return abs, 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" }