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>
This commit is contained in:
jim800121chen 2026-04-15 21:30:59 +08:00
parent dd35b561cf
commit a2094708ec
3 changed files with 177 additions and 5 deletions

View File

@ -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)

View File

@ -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 為 failedpipeline 停止並進 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()

View File

@ -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 後再等 200mseffective 繼續前進
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
// -----------------------------------------------------------------------