visionA/local-tool/visiona-local/startup_pipeline.go
jim800121chen a2094708ec fix(local-tool): Windows 乾淨環境啟動失敗 — Stage 2 豁免 hard timeout
使用者在 Windows 乾淨環境跑 installer 後首次啟動,看到「伺服器無法
啟動」紅 banner + Settings Stop 卡住。根因:

Stage 2 ensurePythonRuntime 在首次 bootstrap 要做 (1) 解壓 ~15MB
Python tarball (2) 建 venv (3) pip install 9 個 wheel(含 numpy 20MB
+ opencv 50MB + KneronPLUS 等 ~150MB 解壓後)。乾淨 Windows 環境上
這三步合計 2-5 分鐘,遠超 R5-E1 的 60 秒 startup hard timeout,導致
pipeline FailStage + emitError(total-timeout) → Error state → 紅 banner。

R5-E1 的 60 秒預算是針對「日常啟動」,不含首次一次性 bootstrap。

修法:StartupPipeline 加 PauseHardTimeout / ResumeHardTimeout API,
app.go 在 ensureBundledPython 偵測到「真正 bootstrap」條件(pythonBin
不存在)時呼叫 Pause,defer Resume。暫停期間 sinceTotal 扣掉 paused
duration,hard timeout 不觸發。Soft timeout(每階段 20 秒「正在重試」
hint)照常,使用者仍能看到進度提示。

配套:修 killStaleServerOnPort 識別 go run 編出來的 server(Bug A)。
原本用 ps -o comm= 比對 "visiona-local-server" 字串,但 go run 產物
comm 只是 "server"(或 "exe"),生產環境不受影響,但開發 / Reviewer
測試流程會踩到(早上 M8-4 Reviewer 留了一組 go run server 孤兒占住
port 3721 到現在)。改用 ps -o args= 取完整 command line,匹配 規則:
  1. 含 "visiona-local-server" — packaged binary
  2. 含 "/go-build" 且含 "visiona-local/server" 或 "/exe/server" — go run

驗證:
- visiona-local 套件 go build / vet / test / test -race 全綠
- server 套件 go build / vet / test 全綠
- 3 個新 unit test 通過:
  - PauseHardTimeout_ExcludesPausedDuration(effective 時鐘正確扣除)
  - PauseHardTimeout_PreventsHardTimeout(wall clock 120s + paused 90s
    = effective 30s,不觸發 60s hard timeout)
  - ResumeHardTimeout_NoopWhenNotPaused(idempotent)
- macOS dmg 重 build 163MB OK

待做(M8-10b):使用者在 Windows 乾淨環境重新 install + 驗證首次啟動。
如果仍失敗,Windows log 位置:
  %APPDATA%\visiona-local\logs\server.stdout.log
  %APPDATA%\visiona-local\logs\server.stderr.log
  %APPDATA%\visiona-local\logs\wails.log(若有)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:30:59 +08:00

493 lines
16 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
startupSoftTimeout = 20 * time.Second
startupHardTimeout = 60 * 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"
}
// -----------------------------------------------------------------------
// 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(),
})
}()
}
// 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)
}