From 35db6c81674dd383c7a79fb7f79bfd4d6a89a4a8 Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Wed, 15 Apr 2026 22:31:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(local-tool):=20Windows=20popup=20=E5=8D=A1?= =?UTF-8?q?=E6=AD=BB=20safety=20net=20+=20appLog=20=E8=A6=86=E8=93=8B?= =?UTF-8?q?=E5=95=9F=E5=8B=95=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用者回報 c649a81 之後仍看到「正在停止伺服器…」popup 一打開就卡住。 無法在不看 log 的情況下推斷根因,先加三層 safety net 確保 popup 不是 blocker,並把關鍵啟動訊息寫到 wails.log 供 Windows 除錯。 Safety net 三層: 1. 前端 watchdog:shutdown-modal 最多顯示 15 秒,超時自動 hide 並 toast 提示使用者 server 可能還沒停掉。 2. 前端 escape hatch:點 backdrop 空白處 / 按 Esc 可手動關閉 popup。 3. Go 端 hardBailout timer:stopGraceful 最多跑 shutdownGraceV2 + 2 秒 (目前 = 9 秒),到上限直接 return leak process,避免 Process.Wait 永遠阻塞(Windows 偶有情境)。graceTimer 分支的 `<-done` 也改成 非阻塞 `select-with-1s-timeout`。 Windows 除錯 log 強化: 4. startup 頭加版本識別標記到 wails.log: ================================================== visionA-local startup build=dev buildTime=unknown platform=windows arch=amd64 dataDir=... fix marker: c649a81+ (Stage3 waitHealthy pause / shutdown modal safety net) ================================================== 使用者拉新版後啟動可從此確認 build 是否是最新版。 5. app.go 把 startup 路徑上的 fmt.Fprintln(os.Stderr, ...) 改 appLog: IPC server start / seed failure / Stage 1 complete / ctrl.Start 結果。 Windows 上 stderr 是 null device,appLog 會同時寫檔到 wails.log。 6. server_control.go stopGraceful 加 appLog 記錄 entry / modal-show / grace timer / hard bailout / return,整條 Stop 路徑完全透明。 7. driver auto-install failed 訊息也改 appLog。 驗證: - visiona-local 套件 go build / vet / test -race 全綠 - macOS dmg 163MB 重 build OK 需要使用者協助:拉新版後在 Windows 乾淨環境試,啟動後貼以下三個檔案 內容給我: %APPDATA%\visiona-local\logs\wails.log — appLog 記錄整個啟動流程 %APPDATA%\visiona-local\logs\server.stdout.log — server subprocess stdout %APPDATA%\visiona-local\logs\server.stderr.log — server subprocess stderr log 裡有「fix marker: c649a81+」即為本 commit 或更新;若沒有 marker 或 marker 指向別的 commit 則代表 build 不是最新版。 Co-Authored-By: Claude Opus 4.6 (1M context) --- local-tool/visiona-local/app.go | 16 ++++++++-- local-tool/visiona-local/frontend/app.js | 36 +++++++++++++++++++--- local-tool/visiona-local/server_control.go | 35 +++++++++++++++++++-- 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/local-tool/visiona-local/app.go b/local-tool/visiona-local/app.go index d1f5698..a827999 100644 --- a/local-tool/visiona-local/app.go +++ b/local-tool/visiona-local/app.go @@ -178,6 +178,14 @@ func (a *App) startup(ctx context.Context) { return } + // 版本識別標記 — 使用者拉新版後看 wails.log 能確認 build 是否正確。 + // 請在每次影響啟動流程的修復時更新此訊息。 + a.appLog("==================================================") + 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("fix marker: c649a81+ (Stage3 waitHealthy pause / shutdown modal safety net)") + a.appLog("==================================================") + // M8-4:載入 preferences.json(讀取失敗 → 用 DefaultPreferences 預設) a.prefs = LoadPreferences(dataDir) @@ -221,7 +229,7 @@ func (a *App) startup(ctx context.Context) { // 供後來的 instance 透過 /ipc/raise 把現有視窗提到前景。 // 失敗不擋啟動,只是犧牲 single-instance raise 能力。 if err := a.startIPCServer(); err != nil { - fmt.Fprintln(os.Stderr, "[visiona-local] IPC server start failed:", err) + a.appLog("IPC server start failed: %v", err) } // 3.5. 首次啟動 seed:把 installer 內建的 models.json / nef 預置模型 / scripts @@ -229,10 +237,11 @@ func (a *App) startup(ctx context.Context) { // 失敗不擋啟動,只是 server 啟動後模型庫會是空的。 a.setBootstrapStatus("正在準備應用程式資料...") if err := a.seedUserDataDir(); err != nil { - fmt.Fprintln(os.Stderr, "[visiona-local] seed user data dir failed:", err) + a.appLog("seed user data dir failed: %v", err) } // M8-4b:階段 1(初始化 Wails 控制台)完成 → 自動進入階段 2 running + a.appLog("startup: Stage 1 complete, entering Stage 2 (Python runtime)") a.startupPipeline.CompleteStage(1) // 4. M8-4:走 ServerController 啟動(v2 路徑)。 @@ -241,9 +250,10 @@ func (a *App) startup(ctx context.Context) { if err := a.ctrl.Start(); err != nil { // pipeline.FailStage 已經由 startServerV2 → ctrl.startInternal 觸發失敗時 emit error + // 切到 Error state,這裡不需要呼叫 reportFatal(讓使用者看到 Retry 按鈕) - fmt.Fprintln(os.Stderr, "[visiona-local] startup pipeline: server start failed:", err) + a.appLog("startup: ctrl.Start failed: %v", err) return } + a.appLog("startup: ctrl.Start returned successfully") // 階段 5:開瀏覽器(或 skip) a.runStartupStage5() // 階段 6 由 watcher goroutine poll sentinel file 觸發 → CompleteStage(6) → markReady diff --git a/local-tool/visiona-local/frontend/app.js b/local-tool/visiona-local/frontend/app.js index bf560d1..d34f629 100644 --- a/local-tool/visiona-local/frontend/app.js +++ b/local-tool/visiona-local/frontend/app.js @@ -334,14 +334,42 @@ function subscribeEvents() { }); // shutdown modal(M8-4 1 秒後顯示) + // 用 watchdog 做 safety net:popup show 後最多顯示 15 秒,即使 Go 端 + // 沒 emit hide(stopGraceful 卡死、Process.Wait 阻塞等邊界情境)前端也 + // 會自己 hide,避免使用者卡在無法關閉的 popup。 + let shutdownModalWatchdog = null; + const hideShutdownModal = () => { + const m = document.getElementById('shutdown-modal'); + if (m) m.setAttribute('hidden', ''); + if (shutdownModalWatchdog) { + clearTimeout(shutdownModalWatchdog); + shutdownModalWatchdog = null; + } + }; EventsOn('shutdown:modal-show', () => { const m = document.getElementById('shutdown-modal'); if (m) m.removeAttribute('hidden'); + if (shutdownModalWatchdog) clearTimeout(shutdownModalWatchdog); + shutdownModalWatchdog = setTimeout(() => { + hideShutdownModal(); + showToast('伺服器停止耗時過久,popup 已自動關閉。請用工作管理員確認 server 是否已結束。'); + }, 15000); }); - // stopGraceful 結束時對稱 hide,避免 popup 卡住。 - EventsOn('shutdown:modal-hide', () => { - const m = document.getElementById('shutdown-modal'); - if (m) m.setAttribute('hidden', ''); + // stopGraceful 結束時對稱 hide。 + EventsOn('shutdown:modal-hide', hideShutdownModal); + // 點 backdrop 空白處可手動關閉 popup(Escape hatch) + const shutdownBackdrop = document.getElementById('shutdown-modal'); + if (shutdownBackdrop) { + shutdownBackdrop.addEventListener('click', (e) => { + if (e.target === shutdownBackdrop) hideShutdownModal(); + }); + } + // Esc 鍵也可以關 + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + const m = document.getElementById('shutdown-modal'); + if (m && !m.hasAttribute('hidden')) hideShutdownModal(); + } }); // app level fatal(保留相容) diff --git a/local-tool/visiona-local/server_control.go b/local-tool/visiona-local/server_control.go index a799342..9d22786 100644 --- a/local-tool/visiona-local/server_control.go +++ b/local-tool/visiona-local/server_control.go @@ -388,6 +388,10 @@ func (p *ServerProcess) stopGraceful() { if p == nil || p.cmd == nil || p.cmd.Process == nil { return } + pid := p.cmd.Process.Pid + if p.app != nil { + p.app.appLog("stopGraceful: entered pid=%d", pid) + } // Windows 沒有 SIGTERM,直接 Kill if runtime.GOOS == "windows" { _ = p.cmd.Process.Kill() @@ -410,11 +414,20 @@ func (p *ServerProcess) stopGraceful() { // 「正在停止伺服器…」popup)。 modalShown := false defer func() { + if p.app != nil { + p.app.appLog("stopGraceful: return pid=%d modalShown=%v", pid, modalShown) + } if modalShown && p.app != nil && p.app.ctx != nil { wailsRuntime.EventsEmit(p.app.ctx, "shutdown:modal-hide", nil) } }() + // Watchdog:無論哪個 branch,最多 graceTimer + 2 秒 後強制離開,避免 + // `<-done` 永遠阻塞(Windows 上 Process.Wait 偶有情況不 return)。 + // 2 秒是額外 safety margin,用 timer.After 實作,不改動主 select。 + hardBailout := time.NewTimer(shutdownGraceV2 + 2*time.Second) + defer hardBailout.Stop() + for { select { case <-done: @@ -424,10 +437,28 @@ func (p *ServerProcess) stopGraceful() { if p.app != nil && p.app.ctx != nil { wailsRuntime.EventsEmit(p.app.ctx, "shutdown:modal-show", nil) modalShown = true + p.app.appLog("stopGraceful: modal-show emitted") } case <-graceTimer.C: + if p.app != nil { + p.app.appLog("stopGraceful: grace timer, force-kill pid=%d", pid) + } _ = p.cmd.Process.Kill() - <-done + // 非阻塞等 done,最多 1 秒(防止 Windows Wait 卡死) + select { + case <-done: + case <-time.After(1 * time.Second): + if p.app != nil { + p.app.appLog("stopGraceful: Process.Wait did not return within 1s after Kill, leaking") + } + } + p.closeLogFiles() + return + case <-hardBailout.C: + // 絕對上限:無論如何都要離開 + if p.app != nil { + p.app.appLog("stopGraceful: hard bailout hit, leaking process pid=%d", pid) + } p.closeLogFiles() return } @@ -500,7 +531,7 @@ func (a *App) startServerV2(preferredPort int) (*ServerProcess, error) { // 2. 首次啟動自動安裝 Kneron WinUSB driver(Windows only) if pyBin != "" { if derr := a.ensureDriverInstalled(pyBin); derr != nil { - fmt.Fprintln(os.Stderr, "[visiona-local] driver auto-install failed (非致命):", derr) + a.appLog("driver auto-install failed (non-fatal): %v", derr) } }