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 `/.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 startupSoftTimeout = 20 * time.Second startupHardTimeout = 60 * time.Second startupWatcherTick = 1 * time.Second ) // startupSentinelFileName 是 server 端寫入、Wails 端 poll 的檔名。 // 路徑:/.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" } // ----------------------------------------------------------------------- // 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 // 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) } // 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(), }) }() } // 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) sinceTotal := time.Since(p.startedAt) 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 檢查 /.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) }