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