fix(local-tool): Restart / StopServer+StartServer 後前端 5 階段 UI 不更新

使用者在 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) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-04-16 08:19:33 +08:00
parent 485a2e01ff
commit ad9beab0ca

View File

@ -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-startpipeline 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-6openBrowser 改由
// runStartupStage5 統一負責,避免冷啟動時瀏覽器被開兩次:
// - 冷啟動app.startup → ctrl.Start → startInternal → (原本這裡 open 1 次)
// → runStartupStage5 → openBrowser (再 open 1 次) → CompleteStage(5)
// Linux/xdg-open 會開兩個 tabmacOS/open 通常會聚合但 log 兩次。
// - RestartServerpipeline 已 ready (current==7)IsInColdStart() 回 false
// startInternal 仍負責自己 openRestartServer 不走 runStartupStage5
// - RestartStartupSequencepipeline 重建後走 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=truecurrent 仍在 2-4 範圍)→ 這段 skip
// app.startup 後續呼叫 runStartupStage5 負責
// (B) Restart / StopServer+StartServerdidRebuild=true剛 rebuild
// 過 pipelineIsInColdStart=true → 呼叫 runStartupStage5 完整
// 跑 Stage 5 + Stage 6 watcher
// (C) RestartStartupSequence呼叫上層 ctrl.Start此函式didRebuild
// = falsepipeline 已由 RestartStartupSequence 自己 rebuild
// IsInColdStart=true → skip由 RestartStartupSequence 後續呼叫
// runStartupStage5 負責
// (D) 舊有 fallbackpipeline == 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 fallbackcold start 模式)
//
// M8-4b 補丁 M-3test 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 goroutinea.pipelineCancelFn+ 清
// sentinel fileremoveSentinelFile。本函式只負責重建 + 前進到 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 fallbackcold start 模式)
//
// M8-4b 補丁 M-3test 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 版本的完整狀態。