From 6946ac3131f7442f3eba67e344d7bcd7b5027b7b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 9 Aug 2026 14:03:04 +0200 Subject: [PATCH] fix(settings): complete a close left unanswered by settings teardown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../window-close-guard.service.spec.ts | 32 +++++++++++++++++++ .../services/window-close-guard.service.ts | 11 +++++++ 2 files changed, 43 insertions(+) diff --git a/apps/electron-backend/src/app/services/window-close-guard.service.spec.ts b/apps/electron-backend/src/app/services/window-close-guard.service.spec.ts index dd1cde439..ee5a43a2b 100644 --- a/apps/electron-backend/src/app/services/window-close-guard.service.spec.ts +++ b/apps/electron-backend/src/app/services/window-close-guard.service.spec.ts @@ -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(); diff --git a/apps/electron-backend/src/app/services/window-close-guard.service.ts b/apps/electron-backend/src/app/services/window-close-guard.service.ts index 9a3bbdad8..3cf80b911 100644 --- a/apps/electron-backend/src/app/services/window-close-guard.service.ts +++ b/apps/electron-backend/src/app/services/window-close-guard.service.ts @@ -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(); + } } /**