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:
parent
c649a81d9f
commit
35db6c8167
@ -178,6 +178,14 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
return
|
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 預設)
|
// M8-4:載入 preferences.json(讀取失敗 → 用 DefaultPreferences 預設)
|
||||||
a.prefs = LoadPreferences(dataDir)
|
a.prefs = LoadPreferences(dataDir)
|
||||||
|
|
||||||
@ -221,7 +229,7 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// 供後來的 instance 透過 /ipc/raise 把現有視窗提到前景。
|
// 供後來的 instance 透過 /ipc/raise 把現有視窗提到前景。
|
||||||
// 失敗不擋啟動,只是犧牲 single-instance raise 能力。
|
// 失敗不擋啟動,只是犧牲 single-instance raise 能力。
|
||||||
if err := a.startIPCServer(); err != nil {
|
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
|
// 3.5. 首次啟動 seed:把 installer 內建的 models.json / nef 預置模型 / scripts
|
||||||
@ -229,10 +237,11 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
// 失敗不擋啟動,只是 server 啟動後模型庫會是空的。
|
// 失敗不擋啟動,只是 server 啟動後模型庫會是空的。
|
||||||
a.setBootstrapStatus("正在準備應用程式資料...")
|
a.setBootstrapStatus("正在準備應用程式資料...")
|
||||||
if err := a.seedUserDataDir(); err != nil {
|
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
|
// M8-4b:階段 1(初始化 Wails 控制台)完成 → 自動進入階段 2 running
|
||||||
|
a.appLog("startup: Stage 1 complete, entering Stage 2 (Python runtime)")
|
||||||
a.startupPipeline.CompleteStage(1)
|
a.startupPipeline.CompleteStage(1)
|
||||||
|
|
||||||
// 4. M8-4:走 ServerController 啟動(v2 路徑)。
|
// 4. M8-4:走 ServerController 啟動(v2 路徑)。
|
||||||
@ -241,9 +250,10 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
if err := a.ctrl.Start(); err != nil {
|
if err := a.ctrl.Start(); err != nil {
|
||||||
// pipeline.FailStage 已經由 startServerV2 → ctrl.startInternal 觸發失敗時 emit error +
|
// pipeline.FailStage 已經由 startServerV2 → ctrl.startInternal 觸發失敗時 emit error +
|
||||||
// 切到 Error state,這裡不需要呼叫 reportFatal(讓使用者看到 Retry 按鈕)
|
// 切到 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
|
return
|
||||||
}
|
}
|
||||||
|
a.appLog("startup: ctrl.Start returned successfully")
|
||||||
// 階段 5:開瀏覽器(或 skip)
|
// 階段 5:開瀏覽器(或 skip)
|
||||||
a.runStartupStage5()
|
a.runStartupStage5()
|
||||||
// 階段 6 由 watcher goroutine poll sentinel file 觸發 → CompleteStage(6) → markReady
|
// 階段 6 由 watcher goroutine poll sentinel file 觸發 → CompleteStage(6) → markReady
|
||||||
|
|||||||
@ -334,14 +334,42 @@ function subscribeEvents() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// shutdown modal(M8-4 1 秒後顯示)
|
// 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', () => {
|
EventsOn('shutdown:modal-show', () => {
|
||||||
const m = document.getElementById('shutdown-modal');
|
const m = document.getElementById('shutdown-modal');
|
||||||
if (m) m.removeAttribute('hidden');
|
if (m) m.removeAttribute('hidden');
|
||||||
|
if (shutdownModalWatchdog) clearTimeout(shutdownModalWatchdog);
|
||||||
|
shutdownModalWatchdog = setTimeout(() => {
|
||||||
|
hideShutdownModal();
|
||||||
|
showToast('伺服器停止耗時過久,popup 已自動關閉。請用工作管理員確認 server 是否已結束。');
|
||||||
|
}, 15000);
|
||||||
});
|
});
|
||||||
// stopGraceful 結束時對稱 hide,避免 popup 卡住。
|
// stopGraceful 結束時對稱 hide。
|
||||||
EventsOn('shutdown:modal-hide', () => {
|
EventsOn('shutdown:modal-hide', hideShutdownModal);
|
||||||
const m = document.getElementById('shutdown-modal');
|
// 點 backdrop 空白處可手動關閉 popup(Escape hatch)
|
||||||
if (m) m.setAttribute('hidden', '');
|
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(保留相容)
|
// app level fatal(保留相容)
|
||||||
|
|||||||
@ -388,6 +388,10 @@ func (p *ServerProcess) stopGraceful() {
|
|||||||
if p == nil || p.cmd == nil || p.cmd.Process == nil {
|
if p == nil || p.cmd == nil || p.cmd.Process == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
pid := p.cmd.Process.Pid
|
||||||
|
if p.app != nil {
|
||||||
|
p.app.appLog("stopGraceful: entered pid=%d", pid)
|
||||||
|
}
|
||||||
// Windows 沒有 SIGTERM,直接 Kill
|
// Windows 沒有 SIGTERM,直接 Kill
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
_ = p.cmd.Process.Kill()
|
_ = p.cmd.Process.Kill()
|
||||||
@ -410,11 +414,20 @@ func (p *ServerProcess) stopGraceful() {
|
|||||||
// 「正在停止伺服器…」popup)。
|
// 「正在停止伺服器…」popup)。
|
||||||
modalShown := false
|
modalShown := false
|
||||||
defer func() {
|
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 {
|
if modalShown && p.app != nil && p.app.ctx != nil {
|
||||||
wailsRuntime.EventsEmit(p.app.ctx, "shutdown:modal-hide", 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 {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
@ -424,10 +437,28 @@ func (p *ServerProcess) stopGraceful() {
|
|||||||
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
|
modalShown = true
|
||||||
|
p.app.appLog("stopGraceful: modal-show emitted")
|
||||||
}
|
}
|
||||||
case <-graceTimer.C:
|
case <-graceTimer.C:
|
||||||
|
if p.app != nil {
|
||||||
|
p.app.appLog("stopGraceful: grace timer, force-kill pid=%d", pid)
|
||||||
|
}
|
||||||
_ = p.cmd.Process.Kill()
|
_ = 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()
|
p.closeLogFiles()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -500,7 +531,7 @@ func (a *App) startServerV2(preferredPort int) (*ServerProcess, error) {
|
|||||||
// 2. 首次啟動自動安裝 Kneron WinUSB driver(Windows only)
|
// 2. 首次啟動自動安裝 Kneron WinUSB driver(Windows only)
|
||||||
if pyBin != "" {
|
if pyBin != "" {
|
||||||
if derr := a.ensureDriverInstalled(pyBin); derr != nil {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user