fix(desktop): crash on boot when mainWindow is destroyed mid-startup (Object has been destroyed) - #38589
fix(desktop): crash on boot when mainWindow is destroyed mid-startup (Object has been destroyed)#38589xxxigm wants to merge 2 commits into
Conversation
startHermes() resolves with `...getWindowState()` once the backend is ready. getWindowState()/getWindowButtonPosition() queried mainWindow via optional chaining (`mainWindow?.isFullScreen?.()`), but a destroyed BrowserWindow is still a non-null object — optional chaining doesn't guard it, and the native call throws "Object has been destroyed". When the window is torn down during boot (updater relaunch, gateway reconnect), that exception rejected the entire boot with "Desktop boot failed: Object has been destroyed", even though the remote backend was healthy and ready. Move the geometry reads into a dependency-free window-state.cjs that checks isDestroyed() (and swallows residual throws) before touching the window, falling back to inert defaults. main.cjs now delegates to it. Fixes NousResearch#38468
Add window-state.test.cjs (run via test:desktop:platforms): getWindowState and getWindowButtonPosition never throw for a destroyed or missing window and fall back to inert defaults, while still reflecting a live window.
austinpickett
left a comment
There was a problem hiding this comment.
Hermes Agent Review — ✅ Approve
Checked out locally and ran the new test suite — 6/6 pass:
node --test apps/desktop/electron/window-state.test.cjs
✔ isWindowLive distinguishes null, destroyed, and live windows
✔ getWindowState does not throw for a destroyed window (regression #38468)
✔ getWindowState does not throw for a missing window
✔ getWindowState reflects a live window
✔ getWindowButtonPosition returns null off macOS and falls back when not live
✔ getWindowButtonPosition falls back when the live window returns nothing
ℹ pass 6 fail 0
The root-cause analysis is exactly right and the subtle part is handled correctly. A destroyed Electron BrowserWindow is still a non-null JS object, so optional chaining (win?.isFullScreen?.()) does not protect against it — every native accessor throws "Object has been destroyed". Since startHermes() resolves with ...getWindowState() once the backend is ready, a mid-boot window teardown (updater relaunch / gateway reconnect) turned that read into a whole-boot failure. The fix:
- extracts the geometry reads into
window-state.cjswith a realisDestroyed()guard plus try/catch (correctly handling even the pathological case whereisDestroyed()itself throws); - keeps the module
electron-import-free so it's unit-testable with plain fake windows — good design, matches the existingbootstrap-platform.cjs/backend-probes.cjspattern in this dir; - wires both
getWindowButtonPositionandgetWindowStateinmain.cjsthrough the new helpers; behavior is preserved for the live-window path.
Test coverage is thorough (null, destroyed, throws-on-isDestroyed, live, fullscreen, fallback). Registered in test:desktop:platforms.
Cluster note (not a blocker): this is one of several open desktop PRs following the same "extract a testable .cjs module + wire into main.cjs + register the test in package.json" pattern (also #37471/#39554/#38292/#42901/#39522/#40558). They'll have textual conflicts on main.cjs and the test:desktop:platforms line, but no semantic overlap — each adds a distinct module. Whichever lands first, the rest just need a trivial rebase. No action needed here.
Reviewed by Hermes Agent (local node --test run; verified vs origin/main).
austinpickett
left a comment
There was a problem hiding this comment.
Clean, well-scoped fix for a subtle Electron lifecycle bug. The root cause analysis is correct: optional chaining (?.) only guards null/undefined — it offers zero protection against a destroyed BrowserWindow, which remains a non-null object but throws "Object has been destroyed" on every native accessor.
What the PR gets right
- Extracts the two unsafe helpers into a standalone
window-state.cjswith noelectronimport, which makes them genuinely unit-testable with plain fake objects. isWindowLive()usesisDestroyed()as the primary gate, and wraps even that call in atry/catchfor the case where the window object is so far gone evenisDestroyed()throws — an edge case the comment explicitly calls out.- All three exported functions have the same defensive pattern: check liveness first, fall back to safe defaults, swallow any residual throws.
- Six targeted tests cover every path:
null,undefined, destroyed, thoroughly-destroyed (evenisDestroyedthrows), and live (full-screen + traffic-light position). All cases have explicit assertions. package.jsonwires the new test file into the existingtest:desktop:platformsrun, so CI covers it automatically.main.cjschanges are minimal — just delegation calls with no behaviour change for a live window.
Minor observation (non-blocking): the getWindowButtonPosition wrapper already exists in main.cjs as a thin shim. Once this PR lands it is purely a pass-through, so a future cleanup pass could inline the call-site directly. Not a reason to block.
Overall: well-diagnosed, well-tested, minimal blast radius. Approved.
|
LGTM — approved. The key insight that Two small things that could be done as follow-ups (not blockers):
Neither is a blocker. Merging as-is is fine. |
|
Thanks for diagnosing a real Electron lifecycle edge case. The premise remains live on current main: Problems
Suggested changes
Automated hermes-sweeper review. |
What does this PR do?
Fixes a desktop boot crash where startup fails right after the (remote) backend reports ready:
Fixes #38468
Root cause
startHermes()resolves its connection object with...getWindowState()once the backend is ready (for both the remote and local branches).getWindowState()/getWindowButtonPosition()read the window with optional chaining:But a destroyed Electron
BrowserWindowis still a non-null object — optional chaining only guardsnull/undefined, not a destroyed window. Calling any native method on it throwsObject has been destroyed.So when the window is torn down during the async boot (the reporter's logs show updater relaunch —
[updates] launched updater … exiting desktop; the same applies to gateway reconnect), spreadinggetWindowState()throws, and the boot's.catchreportsDesktop boot failed: Object has been destroyedeven though the backend is healthy and reachable.This matches the rest of the file, which already guards every other window access with
if (!mainWindow || mainWindow.isDestroyed()) return.Changes
electron/window-state.cjswithisWindowLive(),getWindowState(),getWindowButtonPosition()that checkisDestroyed()(and swallow residual throws) before touching the window, returning inert defaults otherwise.main.cjsdelegatesgetWindowState()/getWindowButtonPosition()to it. No behavioural change for a live window.How to test
Tests added
electron/window-state.test.cjs:getWindowState/getWindowButtonPositionnever throw for a destroyed or missing window and fall back to inert defaults, while still reflecting a live window (full-screen + traffic-light position).