fix(local-tool): Windows popup 卡死 safety net + appLog 覆蓋啟動流程

使用者回報 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) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-04-15 22:31:15 +08:00
parent c649a81d9f
commit 35db6c8167
3 changed files with 78 additions and 9 deletions

View File

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

View File

@ -334,14 +334,42 @@ function subscribeEvents() {
});
// shutdown modalM8-4 1 秒後顯示)
// 用 watchdog 做 safety netpopup show 後最多顯示 15 秒,即使 Go 端
// 沒 emit hidestopGraceful 卡死、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', () => {
// stopGraceful 結束時對稱 hide。
EventsOn('shutdown:modal-hide', hideShutdownModal);
// 點 backdrop 空白處可手動關閉 popupEscape 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.setAttribute('hidden', '');
if (m && !m.hasAttribute('hidden')) hideShutdownModal();
}
});
// app level fatal保留相容

View File

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