fix(local-tool): Windows 首次啟動再修 — waitHealthy pause + shutdown modal hide

續 a209470 修 Windows 乾淨環境啟動問題。使用者回報:
- 紅 banner「伺服器無法啟動 / 啟動時間超過 60 秒」— 即 pipeline total-timeout
- 但上方狀態列顯示「執行中 :3721 PID 8568 uptime 00:00:44」— server 實際活著
- Settings popup 上疊 shutdown-modal「正在停止伺服器…」永遠卡住

三個獨立問題:

1. Stage 3 waitHealthy 在 Windows 首次啟動時,Defender real-time scan
   會延遲 30-60 秒才讓 visiona-local-server.exe 真正 bind port。原本
   30 秒 timeout 可能 stage-failure,且這段等候時間計入 pipeline 60 秒
   total budget。修法:
   (a) healthCheckTimeout 30 秒 → 60 秒
   (b) startServerV2 的 waitHealthy call 在冷啟動時(IsInColdStart)
       包進 Pause/Resume hard timeout — 和 Stage 2 Python bootstrap 同理,
       首次 bootstrap 的 Windows Defender 掃描不該算進日常啟動預算。
       Restart(pipeline 已 ready)維持嚴格計時,不 pause。

2. stopGraceful 只 emit "shutdown:modal-show" 沒有對稱的 hide event,
   前端 popup 顯示後無法關閉(只能等應用重開)。修法:
   (a) stopGraceful 用 defer emit "shutdown:modal-hide"(若曾 show)
   (b) 前端 app.js 加對應 EventsOn listener 把 hidden attribute 設回

3. 配套:cwd bash working dir 會在 session 內持久(system prompt 明說
   "working directory persists between commands"),但 env vars 不持久
   — 非本次 commit 相關,僅自己的 mental note。

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

給 Windows 驗證用的 log 位置:
  %APPDATA%\visiona-local\logs\server.stdout.log     — server 端 log
  %APPDATA%\visiona-local\logs\server.stderr.log     — server 端 panic / 崩潰
  %APPDATA%\visiona-local\logs\wails.log             — Wails app (appLog) 訊息

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-04-15 21:59:20 +08:00
parent a2094708ec
commit c649a81d9f
3 changed files with 41 additions and 1 deletions

View File

@ -42,7 +42,13 @@ import (
const ( const (
defaultPreferredPort = 3721 defaultPreferredPort = 3721
portSearchRange = 20 portSearchRange = 20
healthCheckTimeout = 30 * time.Second // healthCheckTimeoutwaitHealthy 等 server /api/system/health 200 的上限。
//
// 從 30s → 60s 的理由Windows 首次啟動時 Defender real-time scan 會對
// visiona-local-server.exe 做完整掃描(未簽章 + 首次執行)可延遲 30-60 秒才
// 允許 process 真正執行。30 秒不夠60 秒涵蓋絕大多數企業環境(含 EDR
// 日常啟動 server 幾百毫秒就能回應,放寬上限不會影響正常情境。
healthCheckTimeout = 60 * time.Second
shutdownGracePeriod = 5 * time.Second shutdownGracePeriod = 5 * time.Second
appName = "visiona-local" appName = "visiona-local"
) )

View File

@ -338,6 +338,11 @@ function subscribeEvents() {
const m = document.getElementById('shutdown-modal'); const m = document.getElementById('shutdown-modal');
if (m) m.removeAttribute('hidden'); if (m) m.removeAttribute('hidden');
}); });
// stopGraceful 結束時對稱 hide避免 popup 卡住。
EventsOn('shutdown:modal-hide', () => {
const m = document.getElementById('shutdown-modal');
if (m) m.setAttribute('hidden', '');
});
// app level fatal保留相容 // app level fatal保留相容
EventsOn('app:error', (msg) => { EventsOn('app:error', (msg) => {

View File

@ -406,6 +406,15 @@ func (p *ServerProcess) stopGraceful() {
defer modalTimer.Stop() defer modalTimer.Stop()
defer graceTimer.Stop() defer graceTimer.Stop()
// 追蹤 modal 是否曾 showreturn 時需要對稱地 hide避免前端卡在
// 「正在停止伺服器…」popup
modalShown := false
defer func() {
if modalShown && p.app != nil && p.app.ctx != nil {
wailsRuntime.EventsEmit(p.app.ctx, "shutdown:modal-hide", nil)
}
}()
for { for {
select { select {
case <-done: case <-done:
@ -414,6 +423,7 @@ func (p *ServerProcess) stopGraceful() {
case <-modalTimer.C: case <-modalTimer.C:
if p.app != nil && p.app.ctx != nil { if p.app != nil && p.app.ctx != nil {
wailsRuntime.EventsEmit(p.app.ctx, "shutdown:modal-show", nil) wailsRuntime.EventsEmit(p.app.ctx, "shutdown:modal-show", nil)
modalShown = true
} }
case <-graceTimer.C: case <-graceTimer.C:
_ = p.cmd.Process.Kill() _ = p.cmd.Process.Kill()
@ -590,7 +600,23 @@ func (a *App) startServerV2(preferredPort int) (*ServerProcess, error) {
go a.logPump(stderrPipe, "stderr", stderrLog) go a.logPump(stderrPipe, "stderr", stderrLog)
// 11. 等 health check — 對應啟動階段 3「啟動本機伺服器」 // 11. 等 health check — 對應啟動階段 3「啟動本機伺服器」
//
// 冷啟動時 pause hard timeoutWindows 首次執行 visiona-local-server.exe 會被
// Defender / EDR real-time scan 卡 30-60 秒60 秒 healthCheckTimeout 本身足夠,
// 但不該把這段 scan 時間算進 startup pipeline 的 60 秒 total budget那是日常
// 啟動預算,首次 bootstrap 應豁免,和 Stage 2 Python bootstrap 同理)。
//
// 判斷冷啟動pipeline 處於 stage 1-6 範圍內IsInColdStart
// RestartServerpipeline 已 readycurrent==7不 pause維持嚴格計時。
pausedForWait := false
if a.startupPipeline != nil && a.startupPipeline.IsInColdStart() {
a.startupPipeline.PauseHardTimeout()
pausedForWait = true
}
if err := waitHealthy(port, healthCheckTimeout); err != nil { if err := waitHealthy(port, healthCheckTimeout); err != nil {
if pausedForWait {
a.startupPipeline.ResumeHardTimeout()
}
proc.forceKill() proc.forceKill()
removeIPCPort(a.dataDir) removeIPCPort(a.dataDir)
// 階段 3 失敗 // 階段 3 失敗
@ -599,6 +625,9 @@ func (a *App) startServerV2(preferredPort int) (*ServerProcess, error) {
} }
return nil, fmt.Errorf("server did not become healthy: %w", err) return nil, fmt.Errorf("server did not become healthy: %w", err)
} }
if pausedForWait {
a.startupPipeline.ResumeHardTimeout()
}
// 階段 3 完成 → 自動進入階段 4 running // 階段 3 完成 → 自動進入階段 4 running
if a.startupPipeline != nil { if a.startupPipeline != nil {
a.startupPipeline.CompleteStage(3) a.startupPipeline.CompleteStage(3)