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:
parent
485a2e01ff
commit
ad9beab0ca
@ -162,6 +162,32 @@ func (c *ServerController) startInternal(preferredPort int) error {
|
|||||||
// 3. 切到 Starting
|
// 3. 切到 Starting
|
||||||
c.setState(ServerStateStarting, "")
|
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. 真的啟動
|
// 4. 真的啟動
|
||||||
proc, err := c.app.startServerV2(preferredPort)
|
proc, err := c.app.startServerV2(preferredPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -199,25 +225,35 @@ func (c *ServerController) startInternal(preferredPort int) error {
|
|||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
c.setState(ServerStateRunning, "")
|
c.setState(ServerStateRunning, "")
|
||||||
|
|
||||||
// 5. R5-D3:每次 Start 成功都檢查 AutoOpenBrowser
|
// 5. Stage 5 openBrowser 處理
|
||||||
//
|
//
|
||||||
// M8-4b 補丁 M-2:若目前處於冷啟動 pipeline 中(stage 1-6),openBrowser 改由
|
// 三條路徑的分流(本段以 didRebuild 和 IsInColdStart 區分):
|
||||||
// runStartupStage5 統一負責,避免冷啟動時瀏覽器被開兩次:
|
// (A) 冷啟動 (app.startup → ctrl.Start):didRebuild=false,
|
||||||
// - 冷啟動:app.startup → ctrl.Start → startInternal → (原本這裡 open 1 次)
|
// IsInColdStart=true(current 仍在 2-4 範圍)→ 這段 skip,由
|
||||||
// → runStartupStage5 → openBrowser (再 open 1 次) → CompleteStage(5)
|
// app.startup 後續呼叫 runStartupStage5 負責
|
||||||
// Linux/xdg-open 會開兩個 tab;macOS/open 通常會聚合但 log 兩次。
|
// (B) Restart / StopServer+StartServer:didRebuild=true(剛 rebuild
|
||||||
// - RestartServer:pipeline 已 ready (current==7),IsInColdStart() 回 false,
|
// 過 pipeline),IsInColdStart=true → 呼叫 runStartupStage5 完整
|
||||||
// startInternal 仍負責自己 open(RestartServer 不走 runStartupStage5)。
|
// 跑 Stage 5 + Stage 6 watcher
|
||||||
// - RestartStartupSequence:pipeline 重建後走 Start → 這裡 skip,由上層的
|
// (C) RestartStartupSequence:呼叫上層 ctrl.Start(此函式),didRebuild
|
||||||
// runStartupStage5 負責(見 app.go Step 5)。
|
// = false(pipeline 已由 RestartStartupSequence 自己 rebuild),
|
||||||
inColdStart := false
|
// IsInColdStart=true → skip,由 RestartStartupSequence 後續呼叫
|
||||||
if c.app != nil && c.app.startupPipeline != nil {
|
// runStartupStage5 負責
|
||||||
inColdStart = c.app.startupPipeline.IsInColdStart()
|
// (D) 舊有 fallback:pipeline == nil 或意外情境 → 自己 openBrowser 一次
|
||||||
}
|
if didRebuild {
|
||||||
if !inColdStart && c.app.prefs.AutoOpenBrowser && proc != nil && proc.port > 0 {
|
// Restart 路徑:自己跑 Stage 5(也會 CompleteStage(5) → Stage 6 watcher
|
||||||
url := fmt.Sprintf("http://127.0.0.1:%d", proc.port)
|
// 會 poll sentinel → 最後 markReady,前端 5 階段面板會完整跑完)
|
||||||
if err := openBrowser(url); err != nil {
|
c.app.runStartupStage5()
|
||||||
fmt.Fprintf(os.Stderr, "[visiona-local] auto-open browser failed: %v\n", err)
|
} 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
|
return nil
|
||||||
@ -970,7 +1006,47 @@ func (a *App) RestartStartupSequence() error {
|
|||||||
// Step 4: 清 sentinel file(前次 Run 的殘留會讓階段 6 立刻完成)
|
// Step 4: 清 sentinel file(前次 Run 的殘留會讓階段 6 立刻完成)
|
||||||
removeSentinelFile(a.dataDir)
|
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)
|
a.startupPipeline = NewStartupPipeline(a)
|
||||||
|
|
||||||
// 階段 1「初始化 Wails 控制台」已是 running 狀態(Wails app 本身),
|
// 階段 1「初始化 Wails 控制台」已是 running 狀態(Wails app 本身),
|
||||||
@ -1001,25 +1077,6 @@ func (a *App) RestartStartupSequence() error {
|
|||||||
a.startupPipeline.stages[2].startedAt = time.Now()
|
a.startupPipeline.stages[2].startedAt = time.Now()
|
||||||
a.startupPipeline.mu.Unlock()
|
a.startupPipeline.mu.Unlock()
|
||||||
a.startupPipeline.emitProgress(2)
|
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 版本的完整狀態。
|
// GetServerStatusV2 回傳 v2 版本的完整狀態。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user