fix(settings): complete a close left unanswered by settings teardown

Leaving settings and closing the window in the same instant could race:
the renderer's close-request listener was gone while its fire-and-forget
disarm was still in flight, so a close intercepted in that window was
silently swallowed. The guard is mount-long in the renderer, so a
disarm can only mean settings is gone and its form already settled —
the main process now completes the pending intercepted intent (close or
quit) when a disarm finds one unanswered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-09 14:03:04 +02:00
1 parent bb46a707ec
commit 6946ac3131
2 files changed
+43

No files matched your search

@@ -258,6 +258,38 @@ describe('WindowCloseGuard', () => {
expect(win.fireClose()).toBe(false);
});
it('completes a close left unanswered when the renderer disarms', () => {
// Teardown race: close intercepted, then the settings page is torn
// down and disarms before answering — the close must not be
// swallowed.
const { guard, win } = createArmedGuard();
win.fireClose();
guard.setGuardActive(false);
expect(win.close).toHaveBeenCalledTimes(1);
});
it('resumes an unanswered quit when the renderer disarms', () => {
const { app, guard, win } = createArmedGuard();
app.fireBeforeQuit();
win.fireClose();
guard.setGuardActive(false);
expect(app.quit).toHaveBeenCalledTimes(1);
expect(win.close).not.toHaveBeenCalled();
});
it('closes nothing on a plain disarm without a pending intent', () => {
const { app, guard, win } = createArmedGuard();
guard.setGuardActive(false);
expect(win.close).not.toHaveBeenCalled();
expect(app.quit).not.toHaveBeenCalled();
});
it('drops an unanswered quit intent when the renderer navigates away', () => {
const { app, guard, win } = createArmedGuard();
@@ -101,6 +101,17 @@ export class WindowCloseGuard {
setGuardActive(active: boolean): void {
this.guardActive = active;
// A disarm with an unanswered intercepted close is the teardown
// race: the renderer left settings between the interception and
// this disarm reaching us, so nobody remains to confirm and the
// user's close would be silently swallowed. The guard is mount-long
// in the renderer, so a disarm only ever means "settings is gone" —
// its form was already settled by the router guard, nothing is left
// to protect, and the intercepted intent can complete.
if (!active && this.pendingIntent !== null) {
this.confirmClose();
}
}
/**