回應使用者三項需求:
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>
572 lines
20 KiB
Go
572 lines
20 KiB
Go
package main
|
||
|
||
// startup_pipeline.go — M8-4b:6 階段啟動進度 + soft/hard timeout + watcher
|
||
//
|
||
// TDD ground truth:
|
||
// - .autoflow/04-architecture/v2/startup-pipeline.md(518 行完整規格)
|
||
// - R5-E1 ~ R5-E6(AC-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 binding:Retry 按鈕用,5 步驟重置整個流程
|
||
//
|
||
// 1-indexed stages:陣列多配一格避免 off-by-one,stages[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 為 failed,pipeline 停止並進 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 完成後追上歷史。
|
||
// 解 race:Wails 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
|
||
// 負責 openBrowser,startInternal 跳過避免瀏覽器開兩次(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)
|
||
}
|