[Bug]: A UI-broadcast failure inside the shutdown checkpoint cancels the quit, and the real cause is written to discarded stdout
Summary
Since #16497 (STA-5505) landed the checkpoint failure toast, a failed shutdown checkpoint is recoverable — you get "Quit canceled: the session snapshot could not be saved (Failed to stage renderer state before unload.)", and a second Cmd+Q degrades to a durable-session-only stage and quits. That is a real improvement over #15783's permanently-inert window.
Two defects remain in that path:
- Staging is all-or-nothing, and it includes work that is not durability.
store.updateUI(args.ui)runs inside the sametryas the session staging, and it synchronously fans out to UI-change listeners that touch other windows. A throw from any listener — a pure side effect, with the snapshot already safely built — returnsok: falseand vetoes the quit. - The one line that names the real cause is unreachable.
console.error('[app] Failed to stage renderer state before unload:', error)goes to main-process stdout only, which is discarded for a Finder-launched app. The user is left with a toast that names the symptom while the cause is gone — the exact failure mode #16497 set out to fix.
Environment: Orca 1.4.197 · macOS (Darwin 25.5.0, arm64)
Sibling of #15783 (still open), which covers the pre-#16497 hard lock-up and the same underlying orca-data.json trigger. This issue is the narrower, still-live part of that family.
Defect 1 — a UI broadcast can veto a durable snapshot
src/main/ipc/renderer-shutdown-checkpoint.ts:49:
ipcMain.on('app:stage-before-unload-sync', (event, args: StageBeforeUnloadSyncArgs) => {
let ok = true
try {
for (const { state, hostId } of args.sessions) {
store.stageWorkspaceSessionBeforeUnload(state, hostId)
}
store.updateUI(args.ui) // ← side-effecting broadcast, same try
} catch (error) {
console.error('[app] Failed to stage renderer state before unload:', error)
ok = false
}
pendingCheckpoint = ok ? flushStagedStateWithDeadline(store) : Promise.resolve({ ok: false })
event.returnValue = { ok }
})
updateUI → updatePersistedUI → notifyUIChanged (src/main/persistence/loading-store/profile-preferences.ts:144), which iterates listeners with no per-listener guard:
export function notifyUIChanged(owner: ProfilePreferences): void {
if (owner[profilePreferencesContext].runtime.uiChangeListeners.size === 0) {
return
}
const ui = owner.getUI()
for (const listener of owner[profilePreferencesContext].runtime.uiChangeListeners) {
listener(ui) // ← one throw aborts staging
}
}
Both registered listeners reach into other windows and guard only window.isDestroyed(), not webContents.isDestroyed():
src/main/ipc/ui.ts:54—window.webContents.send('ui:stateChanged', ui)across everyBrowserWindowsrc/main/window/dashboard-popout-window.ts:210—window.webContents.setZoomLevel(level)
During quit those two states diverge: webContents can be destroyed while the BrowserWindow has not yet flipped, and send / setZoomLevel then throw Object has been destroyed. Nothing about that failure means the session snapshot is unsafe to persist, but it cancels the quit all the same.
The same shape applies one line up: the for loop over args.sessions is unguarded, so one host's staging failure skips every remaining host — relevant with SSH/runtime hosts present, which is exactly the state #15783 documented (workspaceSessionsByHostId holding sessions for hosts that no longer exist).
Consequences
- A quit is canceled by a cosmetic broadcast failure.
- The user's second
Cmd+Qsucceeds but takes the degraded path (shutdown-checkpoint-persist.ts:86), discarding the full session snapshot — terminal scrollback and layout restore are lost for a reason unrelated to the snapshot. - With unsaved editor files,
canDegradeToDurableSession()is false (hasDirtyOpenFiles()),keepBlockingstays true on every attempt, and the quit is blocked indefinitely with no escape but SIGKILL — #15783's lock-up, still reachable.
Defect 2 — the cause is not recoverable from any log
console.error in main is not captured by any sink. There is no console patch anywhere under src/main, and the observability stack writes only its own records (main.trace.ndjson via local-file-sink.ts, daemon.log). Confirmed on an affected machine:
$ grep -n "Failed to stage renderer state before unload" \
~/Library/Application\ Support/Orca/logs/*.log
$ # no match — the line goes to discarded stdout
#15783 reported the same independently: not in logs/main.trace.ndjson, not in logs/daemon.log, not in log show --predicate 'process == "Orca"'; visible only by launching Orca.app/Contents/MacOS/Orca from a terminal.
So the toast can only ever say Failed to stage renderer state before unload. — the preload's generic wrapper message (src/preload/api/app-bridge.ts:38), never the actual exception. The renderer breadcrumb (renderer_shutdown_checkpoint_failed) records the same generic string, because that is all the renderer is given.
Expected vs actual
Expected: a failure in a UI broadcast, or in one host's staging, does not veto the quit; the durable snapshot is staged for every host that can be staged; and whatever did fail is named in a log the user can retrieve.
Actual: any throw anywhere in the handler cancels the quit, the second attempt silently drops the whole session snapshot, and the real exception is written to a stream nobody can read.
Suggested fixes
- Isolate the broadcast from staging. Run
store.updateUI(args.ui)in its owntry, and let it fail without clearingok— the UI broadcast is not durable state. Alternatively, guard insidenotifyUIChangedso one listener cannot abort the rest. - Guard the listeners properly. Check
webContents.isDestroyed()(not justwindow.isDestroyed()) inui.ts:54anddashboard-popout-window.ts:210, and wrap each listener body. - Isolate per-host staging. Catch inside the
args.sessionsloop so one bad host does not skip the others; report partial success rather than total failure. - Make the cause retrievable. Route the staging failure through the observability sink (or return the error message in the
sendSyncreply) so the toast and the breadcrumb can name the real exception instead of the wrapper.
Fix 1 alone would stop a cosmetic broadcast error from ever canceling a quit; fix 4 is what makes the next report of this diagnosable at all.
Not reproduced
I have not captured the throwing exception. This is derived from reading the shutdown path plus the observed toast and the empty log grep above; the Object has been destroyed race is the most likely trigger given the code, not a confirmed one. Deliberately not labelling has_repro.
Related: #15783 (same family, pre-#16497 behavior), #16497 (added the toast + retry-then-degrade), #14080 (closed).
Source: stablyai/orca