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:
parent
dd35b561cf
commit
a2094708ec
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user