feat(local-tool): hard timeout 180s + 全 stage 細步 detail emit + Stage1 seed pause

使用者回報 Windows 仍看到「啟動時間超過 60 秒」紅 banner,且要求每個
階段做什麼都印出來給使用者知道。三件事一次到位:

1. startupHardTimeout 60s → 180s(直接放寬到 3 分鐘)
   即使三段 pause 機制(Stage 1 seed / Stage 2 Python bootstrap / Stage 3
   waitHealthy)都生效,Windows 乾淨環境段落間累積延遲仍可能超過 60 秒。
   180s 給意料之外的延遲足夠 buffer,搭配 pause + 細步進度 emit 涵蓋
   99% 情境。日常啟動只要幾秒,放寬不影響正常情境。
   - 同步更新 i18n 紅 banner 文案 60 → 180
   - 同步更新 unit tests(HardTimeout 用 -185s、SkipBypass 用 -200s、
     PreventsHardTimeout 用 wall=-300/paused=-250 對應 effective=50s)

2. Stage 1 seedUserDataDir 包進 PauseHardTimeout
   Windows 乾淨環境首次跑會被 Defender real-time scan 對 8 個 nef 檔
   逐個掃 5-30 秒。屬一次性 bootstrap,和 Stage 2/3 同理應豁免 hard
   timeout。第二次啟動 fileExists 早 early return,pause 影響 0ms。
   配套:seed 期間每 5 秒 emit slow hint 帶 elapsed 秒數,避免使用者
   看 spinner 不動以為當機。

3. 全 stage 細步 detail emit
   原本只有 Stage 3 有 sub-step 文案(spawn / waitHealth / waitHealthSlow)。
   現在 Stage 1/2/4/5/6 都有:

   Stage 1: migrate / lock / ipc / seed / seedSlow
   Stage 2: detect / bootstrap / venv / pip / driver
   Stage 3: spawn / waitHealth / waitHealthSlow
   Stage 4: probe
   Stage 5: open
   Stage 6: wait

   每個 detail 對應一段使用者能讀懂的中英文文案(i18n.js zh-TW + en)。
   前端 startup-panel 收到 startup:stage-detail event 後在對應 stage 列
   下方顯示文案,比看著「進行中...」靜止文字直觀很多。

   Stage 2 driver install 因為發生在 CompleteStage(2) 之後 current=3,
   emit 到 stage 3 而非 stage 2,避免被前端忽略(detail 只在 stage
   running 時顯示)。

更新 fix marker 為「9c9e005+ (180s hard timeout + all-stage sub-step
detail + Stage1 seed pause)」讓使用者拉新版後能從 wails.log 確認版本。

驗證:
- visiona-local 套件 go build / vet / test -race 全綠
- macOS dmg 163MB 重 build OK

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-04-16 00:45:50 +08:00
parent 9c9e005d33
commit ff5cab6b0e
5 changed files with 118 additions and 20 deletions

View File

@ -188,7 +188,7 @@ func (a *App) startup(ctx context.Context) {
a.appLog("==================================================") a.appLog("==================================================")
a.appLog("visionA-local startup build=%s buildTime=%s", appVersionString(), appBuildTimeString()) a.appLog("visionA-local startup build=%s buildTime=%s", appVersionString(), appBuildTimeString())
a.appLog("platform=%s arch=%s dataDir=%s", runtime.GOOS, runtime.GOARCH, dataDir) a.appLog("platform=%s arch=%s dataDir=%s", runtime.GOOS, runtime.GOARCH, dataDir)
a.appLog("fix marker: c649a81+ (Stage3 waitHealthy pause / shutdown modal safety net)") a.appLog("fix marker: 9c9e005+ (180s hard timeout + all-stage sub-step detail + Stage1 seed pause)")
a.appLog("==================================================") a.appLog("==================================================")
// M8-4載入 preferences.json讀取失敗 → 用 DefaultPreferences 預設) // M8-4載入 preferences.json讀取失敗 → 用 DefaultPreferences 預設)
@ -204,6 +204,7 @@ func (a *App) startup(ctx context.Context) {
a.startupPipeline.Start(pipelineCtx) a.startupPipeline.Start(pipelineCtx)
// 1. 舊資料目錄遷移(必須在 lock 之前,因為 lock 檔會寫到新路徑) // 1. 舊資料目錄遷移(必須在 lock 之前,因為 lock 檔會寫到新路徑)
a.startupPipeline.EmitStageDetail(1, "startup.stage.1.detail.migrate", 0)
migrateOldDataDirs(dataDir) migrateOldDataDirs(dataDir)
// 遷移後再次確認 dataDir 存在(遷移過程若發生異常狀況的保險) // 遷移後再次確認 dataDir 存在(遷移過程若發生異常狀況的保險)
@ -213,6 +214,7 @@ func (a *App) startup(ctx context.Context) {
} }
// 2. single-instance lock // 2. single-instance lock
a.startupPipeline.EmitStageDetail(1, "startup.stage.1.detail.lock", 0)
release, err := acquireSingleInstance(dataDir) release, err := acquireSingleInstance(dataDir)
if err != nil { if err != nil {
// 區分錯誤類型:只有真的偵測到另一個 instance 才 exit(0) quietly // 區分錯誤類型:只有真的偵測到另一個 instance 才 exit(0) quietly
@ -233,6 +235,7 @@ func (a *App) startup(ctx context.Context) {
// 3. 啟動 Wails 自己的 IPC serverL-3 // 3. 啟動 Wails 自己的 IPC serverL-3
// 供後來的 instance 透過 /ipc/raise 把現有視窗提到前景。 // 供後來的 instance 透過 /ipc/raise 把現有視窗提到前景。
// 失敗不擋啟動,只是犧牲 single-instance raise 能力。 // 失敗不擋啟動,只是犧牲 single-instance raise 能力。
a.startupPipeline.EmitStageDetail(1, "startup.stage.1.detail.ipc", 0)
if err := a.startIPCServer(); err != nil { if err := a.startIPCServer(); err != nil {
a.appLog("IPC server start failed: %v", err) a.appLog("IPC server start failed: %v", err)
} }
@ -240,10 +243,38 @@ func (a *App) startup(ctx context.Context) {
// 3.5. 首次啟動 seed把 installer 內建的 models.json / nef 預置模型 / scripts // 3.5. 首次啟動 seed把 installer 內建的 models.json / nef 預置模型 / scripts
// 複製到 user data-dir讓 server 能在 --data-dir=<user> 情境下讀到內建模型。 // 複製到 user data-dir讓 server 能在 --data-dir=<user> 情境下讀到內建模型。
// 失敗不擋啟動,只是 server 啟動後模型庫會是空的。 // 失敗不擋啟動,只是 server 啟動後模型庫會是空的。
//
// Pause hard timeoutseedUserDataDir 在 Windows 乾淨環境首次跑會被
// Defender real-time scan 對 8 個 nef 檔(每個 7-10 MB逐個掃描總時
// 5-30 秒。屬於一次性 bootstrap不該算進 pipeline 180 秒 budget。
// 第二次啟動以後 fileExists(userModelsJSON) 為 trueseedUserDataDir 早
// early return不會 pause不影響日常啟動
a.setBootstrapStatus("正在準備應用程式資料...") a.setBootstrapStatus("正在準備應用程式資料...")
a.startupPipeline.EmitStageDetail(1, "startup.stage.1.detail.seed", 0)
a.startupPipeline.PauseHardTimeout()
// 開背景 ticker每 5 秒 emit slow hint 帶 elapsed 時間,避免使用者
// 看到 spinner 不動以為 seed 卡住。Goroutine 在 seedUserDataDir return
// 後透過 close(seedDone) 退出。
seedStart := time.Now()
seedDone := make(chan struct{})
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-seedDone:
return
case <-ticker.C:
elapsed := int(time.Since(seedStart).Seconds())
a.startupPipeline.EmitStageDetail(1, "startup.stage.1.detail.seedSlow", elapsed)
}
}
}()
if err := a.seedUserDataDir(); err != nil { if err := a.seedUserDataDir(); err != nil {
a.appLog("seed user data dir failed: %v", err) a.appLog("seed user data dir failed: %v", err)
} }
close(seedDone)
a.startupPipeline.ResumeHardTimeout()
// M8-4b階段 1初始化 Wails 控制台)完成 → 自動進入階段 2 running // M8-4b階段 1初始化 Wails 控制台)完成 → 自動進入階段 2 running
a.appLog("startup: Stage 1 complete, entering Stage 2 (Python runtime)") a.appLog("startup: Stage 1 complete, entering Stage 2 (Python runtime)")
@ -275,6 +306,7 @@ func (a *App) runStartupStage5() {
a.startupPipeline.SkipStage(5) a.startupPipeline.SkipStage(5)
return return
} }
a.startupPipeline.EmitStageDetail(5, "startup.stage.5.detail.open", 0)
// 取得 server URL // 取得 server URL
url := "" url := ""
if a.ctrl != nil { if a.ctrl != nil {
@ -287,10 +319,12 @@ func (a *App) runStartupStage5() {
if url != "" { if url != "" {
// 不等瀏覽器真的開(只等命令 return失敗記 log 不擋流程 // 不等瀏覽器真的開(只等命令 return失敗記 log 不擋流程
if err := openBrowser(url); err != nil { if err := openBrowser(url); err != nil {
fmt.Fprintf(os.Stderr, "[visiona-local] startup stage 5: open browser failed: %v\n", err) a.appLog("startup stage 5: open browser failed: %v", err)
} }
} }
a.startupPipeline.CompleteStage(5) a.startupPipeline.CompleteStage(5)
// Stage 6 開始等 WebSocketemit detail
a.startupPipeline.EmitStageDetail(6, "startup.stage.6.detail.wait", 0)
} }
// shutdown 由 Wails 在 app 結束時呼叫。 // shutdown 由 Wails 在 app 結束時呼叫。
@ -808,6 +842,9 @@ func (p *ServerProcess) stop() {
// //
// R5-5a 之後python 失敗直接擋啟動(沒有模擬回退)。 // R5-5a 之後python 失敗直接擋啟動(沒有模擬回退)。
func (a *App) ensurePythonRuntime(mode PythonMode) (string, PythonMode, error) { func (a *App) ensurePythonRuntime(mode PythonMode) (string, PythonMode, error) {
if a.startupPipeline != nil {
a.startupPipeline.EmitStageDetail(2, "startup.stage.2.detail.detect", 0)
}
switch mode { switch mode {
case PythonModeAuto: case PythonModeAuto:
if bin, err := a.findSystemPython(); err == nil { if bin, err := a.findSystemPython(); err == nil {
@ -890,7 +927,7 @@ func (a *App) ensureBundledPython() (string, error) {
// 首次 bootstrap 路徑:解壓 tarball + 建 venv + pip install 9 個 wheel // 首次 bootstrap 路徑:解壓 tarball + 建 venv + pip install 9 個 wheel
// (含 numpy / opencv / KneronPLUS 合計 ~150 MB乾淨環境可能 2-5 分鐘。 // (含 numpy / opencv / KneronPLUS 合計 ~150 MB乾淨環境可能 2-5 分鐘。
// 暫停 pipeline hard timeout避免 60 秒 R5-E1 budget 把使用者擋在 Error state。 // 暫停 pipeline hard timeout避免 180 秒 budget 把使用者擋在 Error state。
// Soft timeout每階段 20 秒提示)繼續照常。 // Soft timeout每階段 20 秒提示)繼續照常。
if a.startupPipeline != nil { if a.startupPipeline != nil {
a.startupPipeline.PauseHardTimeout() a.startupPipeline.PauseHardTimeout()
@ -903,6 +940,9 @@ func (a *App) ensureBundledPython() (string, error) {
// 解壓 tarballstrip-components=1 剝掉 "python/" 前綴) // 解壓 tarballstrip-components=1 剝掉 "python/" 前綴)
a.setBootstrapStatus("正在解壓 Python runtime (~10 秒)...") a.setBootstrapStatus("正在解壓 Python runtime (~10 秒)...")
if a.startupPipeline != nil {
a.startupPipeline.EmitStageDetail(2, "startup.stage.2.detail.bootstrap", 0)
}
extract := exec.Command("tar", "-xzf", pyTarball, "-C", pyHome, "--strip-components=1") extract := exec.Command("tar", "-xzf", pyTarball, "-C", pyHome, "--strip-components=1")
configureSysProcAttr(extract) configureSysProcAttr(extract)
if out, err := extract.CombinedOutput(); err != nil { if out, err := extract.CombinedOutput(); err != nil {
@ -918,6 +958,9 @@ func (a *App) ensureBundledPython() (string, error) {
} }
a.setBootstrapStatus("正在建立 Python 虛擬環境 (~5 秒)...") a.setBootstrapStatus("正在建立 Python 虛擬環境 (~5 秒)...")
if a.startupPipeline != nil {
a.startupPipeline.EmitStageDetail(2, "startup.stage.2.detail.venv", 0)
}
venvCmd := exec.Command(embeddedPython, "-m", "venv", venvPath) venvCmd := exec.Command(embeddedPython, "-m", "venv", venvPath)
configureSysProcAttr(venvCmd) configureSysProcAttr(venvCmd)
if out, err := venvCmd.CombinedOutput(); err != nil { if out, err := venvCmd.CombinedOutput(); err != nil {
@ -940,6 +983,9 @@ func (a *App) ensureBundledPython() (string, error) {
} }
a.setBootstrapStatus(fmt.Sprintf("正在安裝 %d 個 Python 套件 (numpy / opencv / KneronPLUS ...) (~30-60 秒)...", len(wheels))) a.setBootstrapStatus(fmt.Sprintf("正在安裝 %d 個 Python 套件 (numpy / opencv / KneronPLUS ...) (~30-60 秒)...", len(wheels)))
if a.startupPipeline != nil {
a.startupPipeline.EmitStageDetail(2, "startup.stage.2.detail.pip", 0)
}
args := []string{"-m", "pip", "install", "--no-index", "--find-links", wheelsDir, "--prefer-binary"} args := []string{"-m", "pip", "install", "--no-index", "--find-links", wheelsDir, "--prefer-binary"}
args = append(args, wheels...) args = append(args, wheels...)
pipCmd := exec.Command(pythonBin, args...) pipCmd := exec.Command(pythonBin, args...)

View File

@ -62,10 +62,29 @@ const dict = {
'startup.stage.6.label': '等待 Web UI 連線', 'startup.stage.6.label': '等待 Web UI 連線',
'startup.stage.6.description': '正在與瀏覽器建立即時連線', 'startup.stage.6.description': '正在與瀏覽器建立即時連線',
'startup.stage.6.manualHint': '請點擊控制台的「在瀏覽器開啟」按鈕', 'startup.stage.6.manualHint': '請點擊控制台的「在瀏覽器開啟」按鈕',
// Stage 3 細步提示(由 Go 的 startup:stage-detail event 觸發) // 各 stage 細步提示(由 Go 的 startup:stage-detail event 觸發)
// Stage 1 - 初始化控制台
'startup.stage.1.detail.migrate': '檢查並遷移舊資料目錄...',
'startup.stage.1.detail.lock': '建立 single-instance lock...',
'startup.stage.1.detail.ipc': '啟動 Wails IPC server...',
'startup.stage.1.detail.seed': '正在準備內建模型資料(首次啟動會花幾秒鐘)...',
'startup.stage.1.detail.seedSlow': '正在準備內建模型資料Windows Defender 掃描檔案中,已 {elapsed} 秒)',
// Stage 2 - 檢查 Python 執行環境
'startup.stage.2.detail.detect': '偵測系統 Python 執行環境...',
'startup.stage.2.detail.bootstrap': '正在解壓內建 Python runtime首次啟動需 1-2 分鐘)...',
'startup.stage.2.detail.venv': '正在建立 Python 虛擬環境...',
'startup.stage.2.detail.pip': '正在安裝 Python 套件 numpy / opencv / KneronPLUS首次啟動需 1-3 分鐘)...',
'startup.stage.2.detail.driver': '正在安裝 Kneron USB 驅動程式(請點選 UAC 允許)...',
// Stage 3 - 啟動本機伺服器
'startup.stage.3.detail.spawn': '正在啟動伺服器子程序...', 'startup.stage.3.detail.spawn': '正在啟動伺服器子程序...',
'startup.stage.3.detail.waitHealth': '正在等待伺服器健康檢查通過(已等 {elapsed} 秒)', 'startup.stage.3.detail.waitHealth': '正在等待伺服器健康檢查通過(已等 {elapsed} 秒)',
'startup.stage.3.detail.waitHealthSlow': '首次啟動較久屬正常Windows Defender 掃描可能需 1-2 分鐘(已等 {elapsed} 秒)', 'startup.stage.3.detail.waitHealthSlow': '首次啟動較久屬正常Windows Defender 掃描可能需 1-2 分鐘(已等 {elapsed} 秒)',
// Stage 4 - 偵測 Kneron 裝置
'startup.stage.4.detail.probe': '正在掃描 USB 裝置...',
// Stage 5 - 開啟瀏覽器
'startup.stage.5.detail.open': '正在開啟系統預設瀏覽器...',
// Stage 6 - 等待 Web UI 連線
'startup.stage.6.detail.wait': '正在等待瀏覽器建立 WebSocket 連線...',
// 啟動完成後 collapsed 面板的標題與提示 // 啟動完成後 collapsed 面板的標題與提示
'startup.collapsed.title': '啟動完成', 'startup.collapsed.title': '啟動完成',
'startup.collapsed.hint': '· 點此展開檢視', 'startup.collapsed.hint': '· 點此展開檢視',
@ -77,7 +96,7 @@ const dict = {
'startup.status.skipped': '跳過(依偏好設定)', 'startup.status.skipped': '跳過(依偏好設定)',
'startup.timeout.message': '這個步驟花的時間比預期久,正在重試...', 'startup.timeout.message': '這個步驟花的時間比預期久,正在重試...',
'startup.error.title': '啟動失敗', 'startup.error.title': '啟動失敗',
'startup.error.description.timeout': '啟動時間超過 60 秒,可能是系統環境異常或網路中斷。', 'startup.error.description.timeout': '啟動時間超過 180 秒,可能是系統環境異常或網路中斷。',
'startup.error.description.stageFailed': '階段「{stageLabel}」執行失敗。', 'startup.error.description.stageFailed': '階段「{stageLabel}」執行失敗。',
'startup.error.failedStage': '失敗階段:{n} · {label}', 'startup.error.failedStage': '失敗階段:{n} · {label}',
'startup.error.retry': '重試', 'startup.error.retry': '重試',
@ -149,10 +168,29 @@ const dict = {
'startup.stage.6.label': 'Waiting for Web UI to connect', 'startup.stage.6.label': 'Waiting for Web UI to connect',
'startup.stage.6.description': 'Establishing realtime connection with the browser', 'startup.stage.6.description': 'Establishing realtime connection with the browser',
'startup.stage.6.manualHint': 'Please click "Open in Browser" in the Control Panel', 'startup.stage.6.manualHint': 'Please click "Open in Browser" in the Control Panel',
// Stage 3 sub-step hints (triggered by Go startup:stage-detail event) // All stage sub-step hints (triggered by Go startup:stage-detail event)
// Stage 1
'startup.stage.1.detail.migrate': 'Checking and migrating legacy data directories...',
'startup.stage.1.detail.lock': 'Acquiring single-instance lock...',
'startup.stage.1.detail.ipc': 'Starting Wails IPC server...',
'startup.stage.1.detail.seed': 'Preparing built-in model data (takes a few seconds on first launch)...',
'startup.stage.1.detail.seedSlow': 'Preparing built-in model data (Defender scanning files, {elapsed}s elapsed)',
// Stage 2
'startup.stage.2.detail.detect': 'Detecting system Python runtime...',
'startup.stage.2.detail.bootstrap': 'Extracting bundled Python runtime (takes 1-2 min on first launch)...',
'startup.stage.2.detail.venv': 'Creating Python virtual environment...',
'startup.stage.2.detail.pip': 'Installing Python packages numpy / opencv / KneronPLUS (takes 1-3 min on first launch)...',
'startup.stage.2.detail.driver': 'Installing Kneron USB driver (please allow UAC)...',
// Stage 3
'startup.stage.3.detail.spawn': 'Launching server subprocess...', 'startup.stage.3.detail.spawn': 'Launching server subprocess...',
'startup.stage.3.detail.waitHealth': 'Waiting for server health check ({elapsed}s elapsed)', 'startup.stage.3.detail.waitHealth': 'Waiting for server health check ({elapsed}s elapsed)',
'startup.stage.3.detail.waitHealthSlow': 'First launch is slow — Windows Defender scan may take 1-2 minutes ({elapsed}s elapsed)', 'startup.stage.3.detail.waitHealthSlow': 'First launch is slow — Windows Defender scan may take 1-2 minutes ({elapsed}s elapsed)',
// Stage 4
'startup.stage.4.detail.probe': 'Scanning USB devices...',
// Stage 5
'startup.stage.5.detail.open': 'Opening system default browser...',
// Stage 6
'startup.stage.6.detail.wait': 'Waiting for browser to establish WebSocket connection...',
// Collapsed panel after startup ready // Collapsed panel after startup ready
'startup.collapsed.title': 'Startup complete', 'startup.collapsed.title': 'Startup complete',
'startup.collapsed.hint': '· click to expand', 'startup.collapsed.hint': '· click to expand',
@ -164,7 +202,7 @@ const dict = {
'startup.status.skipped': 'Skipped (per preference)', 'startup.status.skipped': 'Skipped (per preference)',
'startup.timeout.message': 'This step is taking longer than expected, retrying...', 'startup.timeout.message': 'This step is taking longer than expected, retrying...',
'startup.error.title': 'Startup failed', 'startup.error.title': 'Startup failed',
'startup.error.description.timeout': 'Startup exceeded 60 seconds. Your environment may have issues or the network is interrupted.', 'startup.error.description.timeout': 'Startup exceeded 180 seconds. Your environment may have issues or the network is interrupted.',
'startup.error.description.stageFailed': 'Stage "{stageLabel}" failed.', 'startup.error.description.stageFailed': 'Stage "{stageLabel}" failed.',
'startup.error.failedStage': 'Failed stage: {n} · {label}', 'startup.error.failedStage': 'Failed stage: {n} · {label}',
'startup.error.retry': 'Retry', 'startup.error.retry': 'Retry',

View File

@ -529,7 +529,12 @@ func (a *App) startServerV2(preferredPort int) (*ServerProcess, error) {
} }
// 2. 首次啟動自動安裝 Kneron WinUSB driverWindows only // 2. 首次啟動自動安裝 Kneron WinUSB driverWindows only
// 邏輯上發生在 Stage 2 與 Stage 3 之間pipeline current 已切到 3
// 所以 emit 到 stage 3 的 detail避免 driver detail 被前端忽略。
if pyBin != "" { if pyBin != "" {
if a.startupPipeline != nil && runtime.GOOS == "windows" {
a.startupPipeline.EmitStageDetail(3, "startup.stage.2.detail.driver", 0)
}
if derr := a.ensureDriverInstalled(pyBin); derr != nil { if derr := a.ensureDriverInstalled(pyBin); derr != nil {
a.appLog("driver auto-install failed (non-fatal): %v", derr) a.appLog("driver auto-install failed (non-fatal): %v", derr)
} }
@ -718,6 +723,7 @@ func (a *App) probeDeviceListAndComplete(port int) {
if a.startupPipeline == nil { if a.startupPipeline == nil {
return return
} }
a.startupPipeline.EmitStageDetail(4, "startup.stage.4.detail.probe", 0)
url := fmt.Sprintf("http://127.0.0.1:%d/api/devices", port) url := fmt.Sprintf("http://127.0.0.1:%d/api/devices", port)
client := &http.Client{Timeout: 2 * time.Second} client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(url) resp, err := client.Get(url)

View File

@ -39,7 +39,14 @@ import (
const ( const (
startupTotalStages = 6 startupTotalStages = 6
startupSoftTimeout = 20 * time.Second startupSoftTimeout = 20 * time.Second
startupHardTimeout = 60 * time.Second // startupHardTimeout 從 R5-E1 原定 60 秒放寬到 180 秒。理由:即使有
// Stage 1 (seedUserDataDir) / Stage 2 (Python bootstrap) / Stage 3
// (waitHealthy) 三段 pause 機制豁免Windows 乾淨環境首次啟動仍可能在
// 段落間Defender 掃多個檔/EDR cloud lookup/段落間小工作)累積延遲,
// 使用者體感「應該還在啟動但被當失敗」非常挫折。180 秒給意料之外的
// 延遲足夠 buffer搭配 pause 機制 + 細步進度 emit 涵蓋 99% 情境。
// 日常啟動只要幾秒放寬不影響正常情境second launch 通常 < 5 秒)。
startupHardTimeout = 180 * time.Second
startupWatcherTick = 1 * time.Second startupWatcherTick = 1 * time.Second
) )

View File

@ -180,9 +180,9 @@ func TestStartupPipeline_Watcher_SoftTimeout(t *testing.T) {
func TestStartupPipeline_Watcher_HardTimeout(t *testing.T) { func TestStartupPipeline_Watcher_HardTimeout(t *testing.T) {
a, _ := newPipelineTestApp(t) a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a) p := NewStartupPipeline(a)
// 模擬「總時已經 65 秒,當前在階段 3」 // 模擬「總時已經 185 秒(超過 180 秒 hard timeout,當前在階段 3」
now := time.Now() now := time.Now()
p.startedAt = now.Add(-65 * time.Second) p.startedAt = now.Add(-185 * time.Second)
p.current = 3 p.current = 3
p.stages[3].status = "running" p.stages[3].status = "running"
p.stages[3].startedAt = now.Add(-30 * time.Second) p.stages[3].startedAt = now.Add(-30 * time.Second)
@ -262,13 +262,14 @@ func TestStartupPipeline_PauseHardTimeout_PreventsHardTimeout(t *testing.T) {
a, _ := newPipelineTestApp(t) a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a) p := NewStartupPipeline(a)
// 模擬 wall clock 已過 120 秒,但其中 90 秒是「首次 bootstrap」暫停 // 模擬 wall clock 已過 300 秒,但其中 250 秒是「首次 bootstrap」暫停
// effective = 50s < 180s hard timeoutpipeline 不該 fail
now := time.Now() now := time.Now()
p.startedAt = now.Add(-120 * time.Second) p.startedAt = now.Add(-300 * time.Second)
p.pausedDuration = 90 * time.Second // effective = 30s < 60s hard p.pausedDuration = 250 * time.Second
p.current = 2 p.current = 2
p.stages[2].status = "running" p.stages[2].status = "running"
p.stages[2].startedAt = now.Add(-120 * time.Second) p.stages[2].startedAt = now.Add(-300 * time.Second)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@ -283,7 +284,7 @@ func TestStartupPipeline_PauseHardTimeout_PreventsHardTimeout(t *testing.T) {
p.mu.Unlock() p.mu.Unlock()
if cur == -1 { if cur == -1 {
t.Fatal("pipeline failed due to hard timeout, but effective=30s should be under the 60s limit") t.Fatal("pipeline failed due to hard timeout, but effective=50s should be under the 180s limit")
} }
if status == "failed" { if status == "failed" {
t.Fatalf("stage 2 failed, want still running (effective time under limit)") t.Fatalf("stage 2 failed, want still running (effective time under limit)")
@ -315,12 +316,12 @@ func TestStartupPipeline_Watcher_SkippedStageNoTimeout(t *testing.T) {
a.prefs.AutoOpenBrowser = false a.prefs.AutoOpenBrowser = false
p := NewStartupPipeline(a) p := NewStartupPipeline(a)
// 階段 6 + AutoOpenBrowser=false總時 70s 也不該觸發 hard timeout // 階段 6 + AutoOpenBrowser=false總時 200s已超 180s hard timeout也不該觸發
now := time.Now() now := time.Now()
p.startedAt = now.Add(-70 * time.Second) p.startedAt = now.Add(-200 * time.Second)
p.current = 6 p.current = 6
p.stages[6].status = "running" p.stages[6].status = "running"
p.stages[6].startedAt = now.Add(-70 * time.Second) p.stages[6].startedAt = now.Add(-200 * time.Second)
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@ -348,10 +349,10 @@ func TestStartupPipeline_Watcher_SkippedStatusBypassesTimeout(t *testing.T) {
a, _ := newPipelineTestApp(t) a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a) p := NewStartupPipeline(a)
// 階段 5 已 skipped總時 65s 不該觸發 hard timeoutskipped 跳過所有檢查) // 階段 5 已 skipped總時 200s 不該觸發 hard timeoutskipped 跳過所有檢查)
// 注意skip 之後實際上 current 會是 6但這裡測試的是 skip 狀態本身的 bypass 行為 // 注意skip 之後實際上 current 會是 6但這裡測試的是 skip 狀態本身的 bypass 行為
now := time.Now() now := time.Now()
p.startedAt = now.Add(-65 * time.Second) p.startedAt = now.Add(-200 * time.Second)
p.current = 5 p.current = 5
p.stages[5].status = "skipped" p.stages[5].status = "skipped"
p.stages[5].startedAt = now.Add(-30 * time.Second) p.stages[5].startedAt = now.Add(-30 * time.Second)