fix(linux): commit quit teardown at will-quit#951
Conversation
Yo-DDV
left a comment
There was a problem hiding this comment.
Hi @ilysenko, I found one blocking lifecycle regression in the final diff.
In scripts/patches/impl/main-process/quit-lifecycle.js (the generated branches at lines 69, 82, and 88), the hard-exit watchdog is armed before Electron has finished dispatching the remaining before-quit listeners and before window beforeunload handlers have had a chance to cancel. I reproduced that ordering against the generated helper: a later listener called preventDefault(), codexLinuxQuitCommitted remained false and the tray stayed alive initially, but the watchdog remained armed. Firing it then destroyed the tray and called app.exit(0) / process.exit(0).
Electron v42.1.0 explicitly allows before-quit and window beforeunload to cancel app.quit(), while app.exit() bypasses those hooks. This turns a canceled pre-commit quit into forced termination eight seconds later. It contradicts the PR's recoverability invariant and can discard user state.
Please keep the watchdog unarmed until the chosen commit point, or add a proven reset/disarm path for pre-commit vetoes, and cover a later-listener or window cancellation after the patched listener has run. I also tested the PR merged onto the current main: the 383 patch tests, 565 feature tests, and script smoke suite all pass. I did not find another blocker in the tray, launch-action, patch-drift, cleanup, or current-main integration paths.
6d47df1 to
b347739
Compare
|
@Yo-DDV You are right. The pre-commit arms are gone. No disarm path existed. Why they were there. An earlier max-reasoning review raised the inverse hole: accept the quit at What changed. I dropped the arm call from the literal Grep of the rebuilt codexLinuxShouldBypassQuitPrompt())||e||r.canQuitWithoutPrompt()||t||!s&&!l){_=!0,i.markAppQuitting();return}Why #939 stays closed without a pre-commit watchdog. Two independent things hold it. The Linux drain finalizer ends in The one cost. A quit accepted at Tests. One more thing from the same pass. The prompt-bypass drift warning could never fire. It keyed on the Validation.
Thank you for running it merged onto current |
ilysenko
left a comment
There was a problem hiding this comment.
Thanks for fixing the pre-commit watchdog issue. One lifecycle blocker remains.
The new test claims that a later before-quit veto leaves the app fully recoverable, but the upstream handler has already called markAppQuitting(). In the current 26.707.71524 bundle, that sets WindowManager.isAppQuitting = true and disposes the quick-chat controller. The test then checks close-to-tray using a separate mock with isAppQuitting: false, masking the real post-veto state.
Please either avoid/recover that irreversible upstream state before the commit point, or prove that this veto is unreachable in the current bundle and narrow the recoverability claim and test accordingly. The regression coverage should carry the actual post-markAppQuitting() state into the post-veto behavior check.
|
@OmerFarukOruc Just reviewed , came to same conculsion as Gary @ilysenko . Lets GTM. YOLO |
before-quit is cancellable and re-entrant, but the Linux patches treated it as a point of no return: every quit attempt destroyed the tray, even ones that were later vetoed. Combined with a sticky quit latch and a drain finalizer that ended in a second, cancellable app.quit(), a vetoed quit left the app alive with its context disposed, the tray gone, and the single-instance lock still held - unclosable and unopenable. Make will-quit the single commit point. Before it, a quit is fully recoverable and nothing is destroyed; after it, teardown is irreversible and process exit is guaranteed. - Move tray teardown to will-quit, including upstream's own before-quit tray-destroy listener. - Replace the sticky quit latch with codexLinuxQuitCommitted plus a one-shot explicit-quit ticket that expires if unconsumed. - End the Linux drain finalizer in app.exit(0)/process.exit(0) instead of a cancellable app.quit(), with an unconditional drain timeout. - Arm a hard-exit watchdog at will-quit so a committed quit always terminates, and arm it nowhere earlier so a quit that a later listener or a window cancels stays recoverable. - Require a live tray for close-to-tray; the gate previously read the tray setting, not tray liveness. - Detect prompt-bypass drift through canQuitWithoutPrompt() or the showMessageBoxSync/markQuitApproved() pair instead of a dialog shape upstream no longer emits, and refresh the fixtures to the current localized prompt. - Remove the duplicate quit-state helper copy, the dead appVar branch, and the legacy second-instance migration path. Fixes ilysenko#939
b347739 to
6c666b4
Compare
|
@ilysenko Addressed in I refreshed the upstream DMG and rebuilt against The patch now uses reversible I removed the synthetic “fully recoverable” test. Its replacement carries one mutable upstream state object through veto and retry, proving the veto leaves Validation:
I also narrowed the PR description to the listener graph audited in |
Summary
Fixes #939.
On Linux, a quit that starts but does not finish leaves the app permanently wedged: the tray icon is gone (so it cannot be closed) and the single-instance lock is still held (so it cannot be reopened). The process sits idle at ~0% CPU with its entire context already disposed.
The root cause is that
before-quitwas treated as a point of no return. It is not — it is cancellable and re-entrant. The patch set registeredapp.on('before-quit', () => codexLinuxDestroyTray())at module top level, so every quit attempt destroyed the tray, including ones that were later vetoed. It also latchedcodexLinuxQuitInProgresson with no reset, and ended the will-quit drain in a second, cancellableapp.quit()— which re-firesbefore-quitand can be vetoed again, stalling the quit forever with the tray already gone.This change makes
will-quitthe single commit point for irreversible app state. An accepted Linux quit first enters a reversibleATTEMPTINGstate.markAppQuitting()and quick-chat disposal are deferred untilwill-quit; a laterbefore-quitveto clears the attempt before either mutation runs. Afterwill-quit, teardown is irreversible and process exit is guaranteed.before-quitand ontowill-quit— including upstream's ownbefore-quittray-destroy listener, which the previous fix attempt missed. Without that, relocating only our listener would not have fixed the bug.codexLinuxQuitInProgresslatch with explicitATTEMPTINGandCOMMITTEDstates plus a one-shot explicit-quit ticket.ATTEMPTINGsuppresses close-to-tray and other launch actions without mutating upstream quit state; the commit callback performsmarkAppQuitting()only whenwill-quitfires.app.quit()recursively; Electron ignores that nested quit while the first quit is in progress, so this was a reachable pre-commit cancellation path. During an accepted Linux quit attempt the progress window now closes normally instead of cancelling and re-entering quit.app.exit(0)/process.exit(0)instead of a cancellableapp.quit(), with an unconditional drain timeout.Browser::Exit()bypasses both thebefore-quitandwill-quitveto paths, so a committed quit can no longer be stalled by any listener.unref()d hard-exit watchdog atwill-quit, in the same commit step that runs the deferred upstream mutation and destroys the tray. It backstops teardown once the audited listener graph reaches its commit point.canHideLastWindowToTray()reported the tray setting, not tray liveness, so the window could be hidden into a destroyed tray. The gate is now fail-closed on Linux: no live tray means a real close.appVar/directHandlerbranch (it always receivednull) and the legacy second-instance migration path, per the "no legacy patch paths" rule.showMessageBoxSyncdialog shape that upstream has since localized, so it could never fire. It now recognizes the confirmation site throughcanQuitWithoutPrompt()or through theshowMessageBoxSyncandmarkQuitApproved()pair, so a rename of either anchor surfaces as drift instead of silently recording thisrequired-upstreampatch as applied.Validation
Review-fix validation, run against the amended tree:
node --test scripts/patch-linux-window-ui.test.jsnode --test linux-features/*/test.jsbash tests/scripts_smoke.shnode scripts/ci/validate-patch-report.js codex-app/.codex-linux/patch-report.jsonReal-DMG rebuild.
MAX_BUILD_THREADS=0 make build-app-freshrefreshed the upstream DMG and rebuilt end to end against26.707.72221(Electron 42.1.0), which is newer than the26.707.71524build requested in the review. DMG SHA-256:40e34814e74e30943c209ebd4da94cd4de3581a52c5bffbe2bcf2e488d6361c6.accepted_with_warnings, with zero required blockers. The two warnings are unrelated optional drift inautomation-update-eager-toolandlinux-tooltip-window-controls-collision.linux-sqlite-backfill-quit-close, reportappliedwithrequired-upstreampolicy.app.asarcontains the reversible attempt state, deferredmarkAppQuitting()callbacks, thewill-quitcommit callback, and the SQLite close guard. It contains no rendererwill-prevent-unloadoronbeforeunloadhandler; the solebeforeunloadstring is a Computer Use dialog enum.Runtime reachability proof. A clean Electron 42.1.0 reproduction of the audited SQLite listener graph recorded
before-quit -> close-outer -> timeout, with nowill-quit. The nestedapp.quit()did not restart the lifecycle and the outercloseremained prevented. This confirms that cancellation was reachable and that merely narrowing the old test would not fix the bug.Tests added. The synthetic “fully recoverable” test called out in review is gone. Its replacement carries one mutable upstream state object through a later
before-quitveto and retry, proving the veto leavesisAppQuittingfalse, quick-chat undisposed, the tray alive, and close-to-tray usable. A separate behavioral test matches the actual SQLite close listener and proves it no longer prevents an accepted Linux quit. Existing tests still cover the committed watchdog/force-exit path, ticket expiry, drift reporting, and byte-identical reapplication.Known risk / scope. The proof and patch target the complete cancellation graph present in
26.707.72221: the close-to-tray listener, the SQLite backfill progress listener, the upstream quit confirmation, and the upstream drain listener. A future upstream window or renderer veto is drift and requires a fresh audit; this PR does not claim recoverability for listener shapes absent from the validated bundle.Checklist
CODEX.DMGand removes obsolete fallback code and tests from the affected area.