From a2094708ecb7fcc5317e9c5bbee3b76e5c79450b Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Wed, 15 Apr 2026 21:30:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(local-tool):=20Windows=20=E4=B9=BE=E6=B7=A8?= =?UTF-8?q?=E7=92=B0=E5=A2=83=E5=95=9F=E5=8B=95=E5=A4=B1=E6=95=97=20?= =?UTF-8?q?=E2=80=94=20Stage=202=20=E8=B1=81=E5=85=8D=20hard=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用者在 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) --- local-tool/visiona-local/app.go | 25 ++++- local-tool/visiona-local/startup_pipeline.go | 61 +++++++++++- .../visiona-local/startup_pipeline_test.go | 96 +++++++++++++++++++ 3 files changed, 177 insertions(+), 5 deletions(-) diff --git a/local-tool/visiona-local/app.go b/local-tool/visiona-local/app.go index 8c054c1..7be0ce9 100644 --- a/local-tool/visiona-local/app.go +++ b/local-tool/visiona-local/app.go @@ -862,11 +862,20 @@ func (a *App) ensureBundledPython() (string, error) { pythonBin = filepath.Join(venvPath, "Scripts", "python.exe") } - // 已建立好就直接回傳(幂等) + // 已建立好就直接回傳(幂等)— 日常啟動走這條,不會暫停 hard timeout。 if _, err := os.Stat(pythonBin); err == nil { return pythonBin, nil } + // 首次 bootstrap 路徑:解壓 tarball + 建 venv + pip install 9 個 wheel + // (含 numpy / opencv / KneronPLUS 合計 ~150 MB),乾淨環境可能 2-5 分鐘。 + // 暫停 pipeline hard timeout,避免 60 秒 R5-E1 budget 把使用者擋在 Error state。 + // Soft timeout(每階段 20 秒提示)繼續照常。 + if a.startupPipeline != nil { + a.startupPipeline.PauseHardTimeout() + defer a.startupPipeline.ResumeHardTimeout() + } + if err := os.MkdirAll(pyHome, 0o755); err != nil { return "", fmt.Errorf("mkdir python home: %w", err) } @@ -1252,9 +1261,17 @@ func killStaleServerOnPort(port int) bool { if err != nil || pid <= 0 { continue } - // 確認 process name 含 visiona-local-server - psOut, _ := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "comm=").CombinedOutput() - if !strings.Contains(string(psOut), "visiona-local-server") { + // 取 process 的完整 command line(-o args=)而非 comm(後者只有 basename, + // `go run` 編出來的是 `server` 或 `exe`,無法與 packaged binary 區分)。 + // 我們接受兩種匹配: + // 1. 含 "visiona-local-server" 字串 — packaged binary 或 dev 直接 go build + // 2. 含 "/go-build" 路徑且 args 含 "visiona-local/server" — go run 編的 server + psOut, _ := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "args=").CombinedOutput() + psStr := string(psOut) + isVisionaServer := strings.Contains(psStr, "visiona-local-server") + isGoRunServer := strings.Contains(psStr, "/go-build") && + (strings.Contains(psStr, "visiona-local/server") || strings.Contains(psStr, "/exe/server")) + if !isVisionaServer && !isGoRunServer { continue } fmt.Fprintf(os.Stderr, "[visiona-local] killing stale visiona-local-server pid %d on port %d\n", pid, port) diff --git a/local-tool/visiona-local/startup_pipeline.go b/local-tool/visiona-local/startup_pipeline.go index 62eef26..1d2f954 100644 --- a/local-tool/visiona-local/startup_pipeline.go +++ b/local-tool/visiona-local/startup_pipeline.go @@ -100,6 +100,22 @@ type StartupPipeline struct { 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{} @@ -191,6 +207,46 @@ func (p *StartupPipeline) SkipStage(stage int) { 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): @@ -305,7 +361,10 @@ func (p *StartupPipeline) watcher(ctx context.Context) { st := p.stages[cur] curStatus := st.status sinceStage := time.Since(st.startedAt) - sinceTotal := time.Since(p.startedAt) + // Hard timeout 走扣除暫停時間的 effective 時鐘,soft timeout 照常 + // 用實際 stage startedAt —— 這是故意的:首次 bootstrap 時使用者 + // 仍會看到 20 秒「正在重試」hint,但不會在 60 秒時被強制 fail。 + sinceTotal := p.effectiveSinceTotalLocked() softEmitted := st.softTimeoutEmitted p.mu.Unlock() diff --git a/local-tool/visiona-local/startup_pipeline_test.go b/local-tool/visiona-local/startup_pipeline_test.go index e687aa6..2956c0d 100644 --- a/local-tool/visiona-local/startup_pipeline_test.go +++ b/local-tool/visiona-local/startup_pipeline_test.go @@ -210,6 +210,102 @@ func TestStartupPipeline_Watcher_HardTimeout(t *testing.T) { } } +// ----------------------------------------------------------------------- +// PauseHardTimeout / ResumeHardTimeout:首次 Python bootstrap 豁免 +// ----------------------------------------------------------------------- + +// Pause 期間 sinceTotal 不會累積,Resume 後從暫停時點繼續算。 +func TestStartupPipeline_PauseHardTimeout_ExcludesPausedDuration(t *testing.T) { + a, _ := newPipelineTestApp(t) + p := NewStartupPipeline(a) + + now := time.Now() + p.startedAt = now.Add(-10 * time.Second) + p.current = 2 + p.stages[2].status = "running" + p.stages[2].startedAt = now.Add(-10 * time.Second) + + // 未暫停:effective = 10s + p.mu.Lock() + eff := p.effectiveSinceTotalLocked() + p.mu.Unlock() + if eff < 9*time.Second || eff > 11*time.Second { + t.Fatalf("effective before pause=%s, want ~10s", eff) + } + + // 暫停 500ms 再看 + p.PauseHardTimeout() + time.Sleep(500 * time.Millisecond) + p.mu.Lock() + effDuringPause := p.effectiveSinceTotalLocked() + p.mu.Unlock() + // 暫停期間 effective 不該跟 wall clock 一起漲 —— 應該還是 ~10s + if effDuringPause > 10500*time.Millisecond { + t.Fatalf("effective during pause=%s, should stay ~10s (paused window excluded)", effDuringPause) + } + + // Resume 後再等 200ms,effective 繼續前進 + p.ResumeHardTimeout() + time.Sleep(200 * time.Millisecond) + p.mu.Lock() + effAfterResume := p.effectiveSinceTotalLocked() + p.mu.Unlock() + // 預期 ~10.2s,誤差允許 300ms + if effAfterResume < 10*time.Second || effAfterResume > 10500*time.Millisecond { + t.Fatalf("effective after resume=%s, want ~10.2s", effAfterResume) + } +} + +// 首次 bootstrap 情境:即使 wall clock 已超過 60s,只要暫停時間夠多, +// hard timeout 就不該觸發。 +func TestStartupPipeline_PauseHardTimeout_PreventsHardTimeout(t *testing.T) { + a, _ := newPipelineTestApp(t) + p := NewStartupPipeline(a) + + // 模擬 wall clock 已過 120 秒,但其中 90 秒是「首次 bootstrap」暫停 + now := time.Now() + p.startedAt = now.Add(-120 * time.Second) + p.pausedDuration = 90 * time.Second // effective = 30s < 60s hard + p.current = 2 + p.stages[2].status = "running" + p.stages[2].startedAt = now.Add(-120 * time.Second) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + p.watcherDone = make(chan struct{}) + p.watcherCancel = cancel + go p.watcher(ctx) + time.Sleep(1300 * time.Millisecond) + + p.mu.Lock() + cur := p.current + status := p.stages[2].status + p.mu.Unlock() + + if cur == -1 { + t.Fatal("pipeline failed due to hard timeout, but effective=30s should be under the 60s limit") + } + if status == "failed" { + t.Fatalf("stage 2 failed, want still running (effective time under limit)") + } + // 不 Fatal ctrl state—— watcher 在 test 裡不會被真的 Stop,只檢查 pipeline 不進 failed 就好 +} + +// Resume 未暫停時為 no-op。 +func TestStartupPipeline_ResumeHardTimeout_NoopWhenNotPaused(t *testing.T) { + a, _ := newPipelineTestApp(t) + p := NewStartupPipeline(a) + p.startedAt = time.Now() + // 不呼叫 Pause,直接 Resume 應不 panic 且 pausedDuration 保持為 0 + p.ResumeHardTimeout() + p.mu.Lock() + dur := p.pausedDuration + p.mu.Unlock() + if dur != 0 { + t.Fatalf("pausedDuration after noop resume=%s, want 0", dur) + } +} + // ----------------------------------------------------------------------- // Skip 規則:階段 5/6 + AutoOpenBrowser=false // -----------------------------------------------------------------------