From ad9beab0cac9d4c5e18b40adc090e8e70d7d93ee Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Thu, 16 Apr 2026 08:19:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(local-tool):=20Restart=20/=20StopServer+Sta?= =?UTF-8?q?rtServer=20=E5=BE=8C=E5=89=8D=E7=AB=AF=205=20=E9=9A=8E=E6=AE=B5?= =?UTF-8?q?=20UI=20=E4=B8=8D=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用者在 Windows 版 local tool 按「重新啟動伺服器」或 Stop 再 Start 後, 畫面中間的 5 階段面板停留在「等待中」不更新,但實際上 server 已經 成功啟動完成。 根因: Restart / StartServer 路徑 → ctrl.Restart() / ctrl.Start() → ctrl.startInternal() → startServerV2() 但 StartupPipeline 已經 markReady (current=7),所以 startServerV2 內 所有 CompleteStage(2/3/4) / EmitStageDetail / IsInColdStart 檢查都 early return 或 no-op,Go 端完全不 emit 任何 startup:progress event。 前端 resetStartupPanel 後開始等事件,但事件根本沒來 → UI 停留在 pending「等待中」。 RestartStartupSequence(Retry 按鈕)有自己完整重建 pipeline 的 5 步 邏輯,所以 Retry 路徑沒事。但 Restart / Stop+Start 走的是 ctrl.Restart / ctrl.Start 不走 RestartStartupSequence。 修法: 1. 抽 helper rebuildStartupPipeline() — 把 RestartStartupSequence 裡 「重建 pipeline + emit Stage 1 completed + 啟 watcher + 前進 Stage 2」 那 20 多行邏輯抽出來,讓 startInternal 也能呼叫。 2. startInternal 進場時偵測 pipeline 狀態: - cold start 路徑 (current ∈ [1..6]) → 不動 - 已 ready (current > totalStages) 或 已 failed (current == -1) → 清 sentinel file + 呼叫 rebuildStartupPipeline(),記 didRebuild=true 3. startInternal 成功返回前,Stage 5 openBrowser 處理改成三分支: - didRebuild=true → 呼叫 runStartupStage5(Restart 路徑) - cold start (IsInColdStart && !didRebuild) → skip 由上層呼叫 runStartupStage5(app.startup 冷啟動路徑或 RestartStartupSequence) - fallback (pipeline == nil) → 自己 openBrowser 一次 四條路徑的分流現在完整: (A) cold start : app.startup → Pipeline.Start 自己前進到 2 → startInternal 看 current=2 不 rebuild → didRebuild=false → app.startup 呼叫 stage5 (B) Restart / Stop+Start : ctrl.Restart/Start → startInternal 看 current=7 rebuild → didRebuild=true → startInternal 自己呼叫 stage5 (C) RestartStartupSequence: 自己 rebuild 前進到 2 → ctrl.Start → startInternal 看 current=2 不 rebuild → didRebuild=false → RestartStartupSequence 後續呼叫 stage5 (D) pipeline == nil : 走舊 fallback 自己 openBrowser 驗證: - visiona-local 套件 go build / vet / test -race 全綠 - 關鍵 test TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce 仍 pass(驗證 browser 不會被 cold start 開兩次) - macOS dmg 163MB 重 build OK Co-Authored-By: Claude Opus 4.6 (1M context) --- local-tool/visiona-local/server_control.go | 133 +++++++++++++++------ 1 file changed, 95 insertions(+), 38 deletions(-) diff --git a/local-tool/visiona-local/server_control.go b/local-tool/visiona-local/server_control.go index 1138a7e..e193b3f 100644 --- a/local-tool/visiona-local/server_control.go +++ b/local-tool/visiona-local/server_control.go @@ -162,6 +162,32 @@ func (c *ServerController) startInternal(preferredPort int) error { // 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 { @@ -199,25 +225,35 @@ func (c *ServerController) startInternal(preferredPort int) error { c.mu.Unlock() c.setState(ServerStateRunning, "") - // 5. R5-D3:每次 Start 成功都檢查 AutoOpenBrowser + // 5. Stage 5 openBrowser 處理 // - // M8-4b 補丁 M-2:若目前處於冷啟動 pipeline 中(stage 1-6),openBrowser 改由 - // runStartupStage5 統一負責,避免冷啟動時瀏覽器被開兩次: - // - 冷啟動:app.startup → ctrl.Start → startInternal → (原本這裡 open 1 次) - // → runStartupStage5 → openBrowser (再 open 1 次) → CompleteStage(5) - // Linux/xdg-open 會開兩個 tab;macOS/open 通常會聚合但 log 兩次。 - // - RestartServer:pipeline 已 ready (current==7),IsInColdStart() 回 false, - // startInternal 仍負責自己 open(RestartServer 不走 runStartupStage5)。 - // - RestartStartupSequence:pipeline 重建後走 Start → 這裡 skip,由上層的 - // runStartupStage5 負責(見 app.go Step 5)。 - 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) + // 三條路徑的分流(本段以 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 @@ -970,7 +1006,47 @@ func (a *App) RestartStartupSequence() error { // Step 4: 清 sentinel file(前次 Run 的殘留會讓階段 6 立刻完成) removeSentinelFile(a.dataDir) - // Step 5: 重建 StartupPipeline + // 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 本身), @@ -1001,25 +1077,6 @@ func (a *App) RestartStartupSequence() error { a.startupPipeline.stages[2].startedAt = time.Now() a.startupPipeline.mu.Unlock() a.startupPipeline.emitProgress(2) - - // 呼叫 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 } // GetServerStatusV2 回傳 v2 版本的完整狀態。