visionA/local-tool/visiona-local/startup_pipeline.go
jim800121chen f5655e38b1 feat(local-tool): hard timeout 5min + Stage 6 隱藏到 header + 全屏 splash
回應使用者三項需求:

1. 整體 hard timeout 180s → 300s(5 分鐘)
   每個 stage 已有 soft timeout 20s 提示機制,整體 budget 不需緊湊。
   5 分鐘是「使用者點完一杯咖啡都還沒好」的心理上限。pause 機制
   (Stage 1 seed / Stage 2 Python bootstrap / Stage 3 waitHealthy)
   仍維持作為「一次性 bootstrap 完全不算 budget」的快速通道。
   - 同步更新 i18n 紅 banner 文案 180 → 5 分鐘
   - 同步更新 unit tests(HardTimeout 用 -305s,SkipBypass 用 -320s,
     PreventsHardTimeout 註解 effective<300s)

2. Stage 6「等待 Web UI 連線」從 6 階段面板隱藏到 header 連線指示燈
   Go 端 pipeline 仍保持 6 階段(不動),前端 UI 只顯示 5 階段:
   - startup-panel.js: TOTAL_STAGES=5 顯示用,PIPELINE_STAGES=6 內部
     state 用。renderStages / paintProgressBar / 進度數字都用 5。
   - updateStage 仍會收 stage 6 events 更新內部 state(控 collapse 時機)
     但 stage 6 不 paint UI(n > TOTAL_STAGES early return)
   - 新增 onConnectionStatusChange listener 機制:stage 6 status 變化
     時通知外層
   - control-panel.js: setWebUIStatus 把連線狀態 (pending/running/
     completed/failed) 渲染到 header 的 meta-webui 指示燈:圓點顏色
     + 文字 (等待連線/已連線/未連線)
   - index.html: server-meta 新增 <dd id="meta-webui"> 指示燈位置
   - i18n: control.meta.webui / control.webui.{connected,waiting,disconnected}
   - style.css: .webui-status::before 圓點 + pulse 動畫 + 顏色對應
     state (pending=灰 / running=warning+pulse / connected=success / failed=destructive)
   - app.js: 註冊 onConnectionStatusChange listener,初始化呼叫
     setWebUIStatus('pending')

3. 全屏 spinner splash 取代「啟動中...」三個字
   原本 app 啟動最一開始的「啟動中」狀態只有 header 上三個字很不
   明顯,使用者體感像沒反應。改為 DOM ready 時就顯示 fullscreen
   spinner overlay,收到第一個 startup:progress event 才隱藏。
   - index.html: <div id="boot-splash"> 內含 logo + spinner-lg + 文字
   - style.css: .boot-splash position:fixed inset:0 z-index:1000,
     .boot-splash.hidden { display:none } 用 class 控制(避免和
     [hidden]!important 衝突)
   - app.js: hideBootSplash() helper,4 個 hide 觸發點:
     (a) 收到 startup:progress event
     (b) snapshot 補漏發現 pipeline 已啟動
     (c) 收到 startup:error event(即使失敗也要看到錯誤)
     (d) handleServerStatus 收到非 idle 狀態(restart wails app
         server 還活著的情境)

更新 fix marker 為「d946561+ (5min hard timeout + 5-stage UI + fullscreen splash)」

驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 163MB 重 build OK

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 01:23:55 +08:00

572 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
// startup_pipeline.go — M8-4b6 階段啟動進度 + soft/hard timeout + watcher
//
// TDD ground truth
// - .autoflow/04-architecture/v2/startup-pipeline.md518 行完整規格)
// - R5-E1 ~ R5-E6AC-1.3 從 10 秒硬指標 → 60 秒 + 階段化進度)
//
// 設計重點:
// 1. 6 個階段1 = Wails console / 2 = Python runtime / 3 = server / 4 = devices /
// 5 = open browser / 6 = wait Web UI WebSocket
// 2. soft timeout = 20 秒每階段emit "startup:stage-timeout" 但不中斷
// 3. hard timeout = 60 秒總時emit "startup:error" + 進 Error state
// 4. 階段 5/6 在 AutoOpenBrowser=false 時:
// - 階段 5 → status="skipped"
// - 階段 6 → 不檢查 timeout使用者必須手動點 Open in Browser 才會建立 WebSocket
// 5. 階段 6 透過 sentinel file `<dataDir>/.first-ws-connected` 偵測;
// server 端 Hub 在第一個 WS client 連上時寫檔(見 server/internal/api/ws/hub.go
// 6. RestartStartupSequence bindingRetry 按鈕用5 步驟重置整個流程
//
// 1-indexed stages陣列多配一格避免 off-by-onestages[1] ~ stages[6] 使用。
// current sentinel 值0 = 未啟動、1-6 = 進行中、7 = ready、-1 = 已失敗。
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// -----------------------------------------------------------------------
// 常量
// -----------------------------------------------------------------------
const (
startupTotalStages = 6
// 每階段 soft timeout 20 秒(用 wall clock 計時,不受 pause 影響)
// 觸發後 emit "startup:stage-timeout" 提示「正在重試」但不中斷流程。
// 這是「單一階段卡太久」的保護,搭配下方 hard timeout 兩層防線。
startupSoftTimeout = 20 * time.Second
// startupHardTimeout 從 R5-E1 原定 60 秒一路放寬到 300 秒5 分鐘)。
// 理由:每階段已經有 soft timeout 提示機制20 秒),整體 budget 不需
// 緊湊也能擋住真的卡死的情境。300 秒是「使用者點完一杯咖啡都還沒好」
// 的心理上限,這時候再 fail 才合理。
// pause 機制Stage 1 seed / Stage 2 Python bootstrap / Stage 3 waitHealthy
// 仍維持,作為「一次性 bootstrap 完全不算 budget」的快速通道。
startupHardTimeout = 300 * time.Second
startupWatcherTick = 1 * time.Second
)
// startupSentinelFileName 是 server 端寫入、Wails 端 poll 的檔名。
// 路徑:<dataDir>/.first-ws-connected
const startupSentinelFileName = ".first-ws-connected"
// -----------------------------------------------------------------------
// Event payload
// -----------------------------------------------------------------------
// StartupProgressEvent 對應 startup-pipeline.md §1.1。
// 每階段 status 變化running / completed / failed / skipped都會 emit 一次。
type StartupProgressEvent struct {
Stage int `json:"stage"` // 1-6
TotalStages int `json:"totalStages"` // 固定 6
LabelKey string `json:"labelKey"` // 如 "startup.stage.2.label"
Status string `json:"status"` // "pending" | "running" | "completed" | "failed" | "skipped"
StartedAt int64 `json:"startedAt"` // Unix ms該階段 startedAt
}
// StartupStageTimeoutEvent — soft timeout 提示,不中斷流程,只 emit 一次/階段。
type StartupStageTimeoutEvent struct {
Stage int `json:"stage"`
SoftTimeoutSeconds int `json:"softTimeoutSeconds"` // 固定 20
}
// StartupErrorEvent — 階段失敗或總時 hard timeout。emit 後 pipeline 停止。
type StartupErrorEvent struct {
Stage int `json:"stage"`
Error string `json:"error"`
Cause string `json:"cause"` // "stage-failure" | "total-timeout"
}
// StartupStageDetailEvent — 階段內細步進度提示,讓使用者看到當下在做什麼。
// 不影響 stage status只是 UI 上 stage-hint 的文案來源。
// DetailKey 是 i18n key前端查表顯示ElapsedSeconds > 0 時附在文案後當耗時提示。
type StartupStageDetailEvent struct {
Stage int `json:"stage"`
DetailKey string `json:"detailKey"` // i18n key, e.g. "startup.stage.3.detail.waitHealth"
ElapsedSeconds int `json:"elapsedSeconds"` // 0 時不顯示耗時
}
// StartupStageSnapshot 是單一 stage 的 snapshot給前端追上歷史狀態用
type StartupStageSnapshot struct {
Stage int `json:"stage"`
Status string `json:"status"` // "pending" | "running" | "completed" | "failed" | "skipped"
StartedAt int64 `json:"startedAt"`
}
// StartupSnapshot 是整個 pipeline 當前狀態的 snapshot。
// 前端 init 完成後呼叫 GetStartupSnapshot() 拉一次,補上 race window 中漏掉的
// progress events避免畫面顯示「Stage 1 等待中、Stage 2 完成」這種亂序。
type StartupSnapshot struct {
Current int `json:"current"` // -1 / 0 / 1-6 / 7 (ready)
TotalStages int `json:"totalStages"` // 固定 6
Stages []StartupStageSnapshot `json:"stages"` // 1-indexed but slice 0-based (len=6)
}
// -----------------------------------------------------------------------
// stageState — 單一階段的內部狀態
// -----------------------------------------------------------------------
type stageState struct {
status string // "pending" | "running" | "completed" | "failed" | "skipped"
startedAt time.Time
completedAt time.Time
softTimeoutEmitted bool
}
// -----------------------------------------------------------------------
// StartupPipeline — 主 struct
// -----------------------------------------------------------------------
// StartupPipeline 管理 6 階段啟動流程。
// 與 ServerController 解耦StartupPipeline 只 emit Wails event
// 它對 server lifecycle 的影響由 ServerController透過 emitError → setState負責。
type StartupPipeline struct {
app *App
mu sync.Mutex
stages [startupTotalStages + 1]stageState // 1-indexed
current int // 0=未啟動、1-6=進行中、7=ready、-1=failed
startedAt time.Time
// Hard timeout pause 機制(首次 Python bootstrap 專用)。
//
// R5-E1 的 60 秒 hard timeout 預算是針對「日常啟動」,不含首次安裝 Python
// runtime解壓 ~15MB tarball + 建 venv + pip install 9 個 wheel 含 numpy /
// opencv 合計 ~150MB這種一次性 bootstrap 工作——那可能花 2-5 分鐘。
//
// 解法:讓 app.go 在真正需要首次 bootstrap 時pythonBin 不存在)呼叫
// PauseHardTimeout(),完成後呼叫 ResumeHardTimeout()。暫停期間不算進
// sinceTotal避開 hard timeout。Soft timeout 繼續照常(每階段 20 秒的
// 視覺提示不受影響,使用者仍會看到「正在重試...」hint
//
// 只暫停 hard timeout不暫停整體時鐘——因為 soft timeout 的語意是
// 「單一階段停滯太久」,首次 bootstrap 確實會停滯,該提示就該提示。
pausedDuration time.Duration // 累積暫停總時
pauseStartedAt time.Time // 當前暫停開始時間zero = 未暫停
// watcher goroutine 控制。pipelineCancelFn 由 app.go 持有;本 struct 只記 done channel。
watcherCancel context.CancelFunc
watcherDone chan struct{}
}
// NewStartupPipeline 建立新的 pipeline。並未啟動 watcher必須呼叫 Start。
func NewStartupPipeline(app *App) *StartupPipeline {
return &StartupPipeline{
app: app,
current: 0,
}
}
// Start 啟動整個 pipeline從階段 1 開始 emit running並開啟 watcher goroutine。
// 只能呼叫一次(重複呼叫會建立多個 watcher 是合法的,但會浪費資源 — 上層應避免)。
func (p *StartupPipeline) Start(ctx context.Context) {
p.mu.Lock()
p.startedAt = time.Now()
p.current = 1
p.stages[1].status = "running"
p.stages[1].startedAt = time.Now()
p.mu.Unlock()
p.emitProgress(1)
watcherCtx, cancel := context.WithCancel(ctx)
p.watcherCancel = cancel
p.watcherDone = make(chan struct{})
go p.watcher(watcherCtx)
}
// CompleteStage 標記 stage 為 completed並進入下一階段若還有
// 若 stage == startupTotalStages → 觸發 markReady。
//
// 順序錯誤(重複呼叫或階段不對)→ 安靜 ignore避免被 race condition 害到。
func (p *StartupPipeline) CompleteStage(stage int) {
p.mu.Lock()
if p.current != stage || p.current <= 0 {
p.mu.Unlock()
return
}
p.stages[stage].status = "completed"
p.stages[stage].completedAt = time.Now()
p.mu.Unlock()
p.emitProgress(stage)
if stage == startupTotalStages {
p.markReady()
return
}
// 進入下一階段
p.mu.Lock()
next := stage + 1
p.current = next
p.stages[next].status = "running"
p.stages[next].startedAt = time.Now()
p.mu.Unlock()
p.emitProgress(next)
}
// SkipStage 標記 stage 為 skipped並進入下一階段。
// 用於:階段 5 在 prefs.AutoOpenBrowser=false 時跳過 OpenInBrowser 呼叫。
// Watcher 看到 status=skipped → 不檢查 soft timeout也不檢查 hard timeout
func (p *StartupPipeline) SkipStage(stage int) {
p.mu.Lock()
if p.current != stage || p.current <= 0 {
p.mu.Unlock()
return
}
p.stages[stage].status = "skipped"
p.stages[stage].completedAt = time.Now()
p.mu.Unlock()
p.emitProgress(stage)
if stage == startupTotalStages {
p.markReady()
return
}
p.mu.Lock()
next := stage + 1
p.current = next
p.stages[next].status = "running"
p.stages[next].startedAt = time.Now()
p.mu.Unlock()
p.emitProgress(next)
}
// PauseHardTimeout 暫停 hard timeout 計時。
// 用於首次 Python bootstrap可能花 2-5 分鐘解壓 + pip install期間。
// 重複呼叫安全:第二次呼叫會先 Resume 再 Pause避免累積偏差。
// 呼叫者app.go ensureBundledPython 的「真正 bootstrap」路徑。
func (p *StartupPipeline) PauseHardTimeout() {
p.mu.Lock()
defer p.mu.Unlock()
// 已在暫停 → 先結束前一次暫停區間再開新的(保持精確)
if !p.pauseStartedAt.IsZero() {
p.pausedDuration += time.Since(p.pauseStartedAt)
}
p.pauseStartedAt = time.Now()
}
// ResumeHardTimeout 結束 hard timeout 暫停。
// 若未處於暫停狀態則 no-op不回 error避免上層要做空檢查
func (p *StartupPipeline) ResumeHardTimeout() {
p.mu.Lock()
defer p.mu.Unlock()
if p.pauseStartedAt.IsZero() {
return
}
p.pausedDuration += time.Since(p.pauseStartedAt)
p.pauseStartedAt = time.Time{}
}
// effectiveSinceTotal 回傳扣除暫停時間後的總時。watcher 用這個判斷 hard timeout。
// 呼叫者必須已持有 p.mu。
func (p *StartupPipeline) effectiveSinceTotalLocked() time.Duration {
total := time.Since(p.startedAt) - p.pausedDuration
if !p.pauseStartedAt.IsZero() {
// 目前正在暫停中 → 扣掉當前暫停區間
total -= time.Since(p.pauseStartedAt)
}
if total < 0 {
total = 0
}
return total
}
// FailStage 標記 stage 為 failedpipeline 停止並進 Error state。
//
// 副作用(透過 emitError
// - emit "startup:error" Wails event
// - 若 ctrl 存在 → setState(Error)(前端會收到 server:state-change
// - 發 OS 通知R5-D1
func (p *StartupPipeline) FailStage(stage int, err error) {
p.mu.Lock()
if p.current <= 0 {
p.mu.Unlock()
return
}
p.stages[stage].status = "failed"
p.current = -1
p.mu.Unlock()
p.emitProgress(stage)
p.emitError(stage, err, "stage-failure")
p.stopWatcher()
}
// markReady 6 階段都完成後觸發。emit "startup:ready" 並停 watcher。
func (p *StartupPipeline) markReady() {
p.mu.Lock()
p.current = startupTotalStages + 1
p.mu.Unlock()
if p.app != nil && p.app.ctx != nil {
// 同步 emit成功路徑沒有 IPC backlog 風險)
wailsRuntime.EventsEmit(p.app.ctx, "startup:ready", nil)
}
p.stopWatcher()
}
// emitProgress 取當前 stage 狀態 snapshot 並 emit "startup:progress" event。
// 用 goroutine 包起來避免 Wails IPC 慢拖累呼叫者CompleteStage / SkipStage 等)。
func (p *StartupPipeline) emitProgress(stage int) {
p.mu.Lock()
if stage <= 0 || stage > startupTotalStages {
p.mu.Unlock()
return
}
st := p.stages[stage]
p.mu.Unlock()
if p.app == nil || p.app.ctx == nil {
return
}
go func() {
wailsRuntime.EventsEmit(p.app.ctx, "startup:progress", StartupProgressEvent{
Stage: stage,
TotalStages: startupTotalStages,
LabelKey: fmt.Sprintf("startup.stage.%d.label", stage),
Status: st.status,
StartedAt: st.startedAt.UnixMilli(),
})
}()
}
// Snapshot 回傳 pipeline 當前所有 stages 狀態,供前端 init 完成後追上歷史。
// 解 raceWails app 啟動到前端 EventsOn 掛上去之間Go 端可能已經 emit
// 多個 progress events 被丟掉。前端應該在 init 完成後呼叫一次此函式,把
// 已經發生但前端漏掉的 stage 狀態補上。
func (p *StartupPipeline) Snapshot() StartupSnapshot {
p.mu.Lock()
defer p.mu.Unlock()
stages := make([]StartupStageSnapshot, 0, startupTotalStages)
for i := 1; i <= startupTotalStages; i++ {
stages = append(stages, StartupStageSnapshot{
Stage: i,
Status: p.stages[i].status,
StartedAt: p.stages[i].startedAt.UnixMilli(),
})
}
return StartupSnapshot{
Current: p.current,
TotalStages: startupTotalStages,
Stages: stages,
}
}
// EmitStageDetail 在 stage 進行中送一條細步提示文字到前端,讓使用者看到
// 「當下在做什麼」。不改 stage status只更新 UI 上的 stage-hint 欄位。
//
// 呼叫點server_control.go 裡的 startServerV2 會在 spawn binary / 等 health
// check / 30 秒後 slow hint 等節點呼叫這個函式,對應到 i18n key
// startup.stage.3.detail.spawn # 正在啟動 server 子程序
// startup.stage.3.detail.waitHealth # 正在等 server 健康檢查通過
// startup.stage.3.detail.waitHealthSlow # 首次啟動 Defender 掃描可能需時 1-2 分鐘
//
// elapsedSeconds > 0 時前端會在文案後顯示已等時長。
func (p *StartupPipeline) EmitStageDetail(stage int, detailKey string, elapsedSeconds int) {
if p == nil || p.app == nil || p.app.ctx == nil {
return
}
go func() {
wailsRuntime.EventsEmit(p.app.ctx, "startup:stage-detail", StartupStageDetailEvent{
Stage: stage,
DetailKey: detailKey,
ElapsedSeconds: elapsedSeconds,
})
}()
}
// emitError emit "startup:error" 並通知 ctrl 進 Error state + 發 OS 通知。
// cause 取值:"stage-failure" | "total-timeout"
func (p *StartupPipeline) emitError(stage int, err error, cause string) {
if p.app == nil {
return
}
if p.app.ctx != nil {
go func() {
wailsRuntime.EventsEmit(p.app.ctx, "startup:error", StartupErrorEvent{
Stage: stage,
Error: err.Error(),
Cause: cause,
})
}()
}
// 同步通知 ServerController 進 Error state
if p.app.ctrl != nil {
p.app.ctrl.setState(ServerStateError, err.Error())
}
// R5-D1發 OS 通知fire-and-forget
go sendCrashNotification(
"visionA Local — 啟動失敗",
fmt.Sprintf("第 %d 階段失敗:%s", stage, err.Error()),
)
}
// watcher 每秒 tick
// 1. 階段 6 時檢查 sentinel file → 存在則 CompleteStage(6)
// 2. 檢查 hard timeout總時 > 60 s→ FailStage + emitError(total-timeout)
// 3. 檢查 soft timeout單一階段 > 20 s→ emit "startup:stage-timeout"
//
// skip timeout 規則:
// - 該階段已 skipped → 完全不檢查 soft / hard
// - 階段 6 + AutoOpenBrowser=false → 完全不檢查 soft / hard
//
// 退出條件ctx.Done()、current 已不在 1-6 範圍ready 或 failed
func (p *StartupPipeline) watcher(ctx context.Context) {
defer close(p.watcherDone)
ticker := time.NewTicker(startupWatcherTick)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
p.mu.Lock()
if p.current <= 0 || p.current > startupTotalStages {
p.mu.Unlock()
return
}
cur := p.current
st := p.stages[cur]
curStatus := st.status
sinceStage := time.Since(st.startedAt)
// Hard timeout 走扣除暫停時間的 effective 時鐘soft timeout 照常
// 用實際 stage startedAt —— 這是故意的:首次 bootstrap 時使用者
// 仍會看到 20 秒「正在重試」hint但不會在 60 秒時被強制 fail。
sinceTotal := p.effectiveSinceTotalLocked()
softEmitted := st.softTimeoutEmitted
p.mu.Unlock()
// 階段 6每次 tick 檢查 sentinel file
if cur == 6 {
if p.checkSentinelFile() {
p.CompleteStage(6)
return
}
}
// skip timeout 判斷
skipTimeout := false
if curStatus == "skipped" {
skipTimeout = true
}
if cur == 6 && p.app != nil && !p.app.prefs.AutoOpenBrowser {
skipTimeout = true
}
// hard timeout總時 > 60 s
if !skipTimeout && sinceTotal > startupHardTimeout {
err := fmt.Errorf("startup total timeout: %s > %s", sinceTotal, startupHardTimeout)
// 直接 mark failed + emit 兩個 event不走 FailStage 因為 cause 不一樣
p.mu.Lock()
p.stages[cur].status = "failed"
p.current = -1
p.mu.Unlock()
p.emitProgress(cur)
p.emitError(cur, err, "total-timeout")
p.stopWatcher()
return
}
if skipTimeout {
continue
}
// soft timeout單一階段 > 20 s
if sinceStage > startupSoftTimeout && !softEmitted {
p.mu.Lock()
p.stages[cur].softTimeoutEmitted = true
p.mu.Unlock()
if p.app != nil && p.app.ctx != nil {
stageRef := cur
go func() {
wailsRuntime.EventsEmit(p.app.ctx, "startup:stage-timeout", StartupStageTimeoutEvent{
Stage: stageRef,
SoftTimeoutSeconds: int(startupSoftTimeout.Seconds()),
})
}()
}
}
}
}
}
// checkSentinelFile 檢查 <dataDir>/.first-ws-connected 是否存在。
// 存在 → 階段 6 完成server 端的 WebSocket Hub 寫了這個檔,代表第一個 client 已連上)
func (p *StartupPipeline) checkSentinelFile() bool {
if p.app == nil || p.app.dataDir == "" {
return false
}
path := filepath.Join(p.app.dataDir, startupSentinelFileName)
_, err := os.Stat(path)
return err == nil
}
// stopWatcher 主動取消 watcher goroutine。重複呼叫安全cancel 之後再 cancel 是 no-op
func (p *StartupPipeline) stopWatcher() {
if p.watcherCancel != nil {
p.watcherCancel()
}
}
// HasFailedStage 回傳 pipeline 是否已有任何階段被標記為 failed。
//
// M8-4b 補丁M-1 修復startInternal 用這個判斷「pipeline 是否已經 FailStage 過」,
// 如果是則 skip 自己的 sendCrashNotification / setState(Error),避免使用者看到兩個
// 獨立的錯誤通知(一個由 pipeline.emitError 發、一個由 startInternal fallback 發)。
//
// 語義current == -1整個 pipeline 已停在 failed或任一 stage status == "failed"。
func (p *StartupPipeline) HasFailedStage() bool {
p.mu.Lock()
defer p.mu.Unlock()
if p.current == -1 {
return true
}
for i := 1; i <= startupTotalStages; i++ {
if p.stages[i].status == "failed" {
return true
}
}
return false
}
// IsInColdStart 回傳 pipeline 是否正在「冷啟動中」(stage 1-6 running)。
//
// M8-4b 補丁M-2 修復startInternal 的 R5-D3 openBrowser 邏輯用這個判斷
// 「目前是否走冷啟動 pipeline 路徑」。若是,由 runStartupStage5 / pipeline stage 5 hook
// 負責 openBrowserstartInternal 跳過避免瀏覽器開兩次Linux 會開兩個 tab
//
// 非冷啟動場景:
// - pipeline == nil單測或 Wails 未初始化)
// - current == 0尚未 Start
// - current == 7已 ready屬於 RestartServer 直接 Start 的情境)
// - current == -1已 failed
//
// 這些場景下 startInternal 仍需要自己呼叫 openBrowser。
func (p *StartupPipeline) IsInColdStart() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.current >= 1 && p.current <= startupTotalStages
}
// removeSentinelFile 移除 sentinel file。失敗時不回 error檔案不存在是正常情況
// 由 RestartStartupSequence、StartServer 前置、shutdown 呼叫。
func removeSentinelFile(dataDir string) {
if dataDir == "" {
return
}
path := filepath.Join(dataDir, startupSentinelFileName)
_ = os.Remove(path)
}