Skip to content

Commit 324c62c

Browse files
tomek-iarmada-lookoutclaudeyaqi-lyu
authored
XS✔ ◾ fix: stop update-ready reminder from re-popping and stacking within a session (#972)
* fix: stop update-ready reminder from re-popping and stacking within a session (#456) Clicking "Remind me later" on the update-ready dialog only guarded the dialog's own "Later" state, not re-entrancy: if the periodic ~10-minute update check fired another "update-downloaded" event while the first dialog was still open, a second dialog would stack on top of it. Add an isUpdateDialogOpen guard so only one reminder dialog can be shown at a time, cleared once the user responds. Closes #456 * address review: bound the update dialog + short-circuit post-restart events (#456) - Add a 5-minute timeout fallback so a never-settling showMessageBox() can't leave isUpdateDialogOpen stuck true forever, silently suppressing all future update-ready reminders (minor finding). - Add an isRestartingToInstall short-circuit so an update-downloaded event landing in the gap between the isUpdateDialogOpen reset and the deferred quitAndInstall() can't stack a second dialog moments before quit (minor finding). - Test: await the guard's reset/microtask flush via vi.waitFor instead of firing-and-forgetting the resolved dialog promise (nit). - Test: add coverage for the guard's release-and-reuse path via the new timeout fallback, and for the Restart Now short-circuit (nit). * address review: honor the real dialog response instead of the race (#456) The 5-minute Promise.race timeout previously reset isUpdateDialogOpen and had .then/.catch/.finally attached to the race, not to the real dialog.showMessageBox() promise. Once the timeout "won", a later update-downloaded event could stack a second native dialog on top of the still-open first one, and the user's eventual real click on the original dialog fired no callback at all (Promise.race only resolves the winning branch's continuation). Fix: attach the response handler directly to dialogPromise so a late click is always honored, and use a separate watchdog that only logs a warning and frees the guard for future events after 5 minutes - it never touches dialogPromise's own chain. A per-invocation requestId stops an abandoned dialog's very-late resolution from clobbering a newer dialog's guard state. Also documents (rather than "fixes") that isRestartingToInstall is intentionally never reset - quitAndInstall() is a fire-and-forget setImmediate() call with no failure signal, so permanently suppressing reminders once the user has committed to restarting is the safer failure mode. Removes the now-unused -1 timeout sentinel entirely (the Promise.race/synthetic-result approach it belonged to is gone). Adds a regression test pinning the blocking fix: dialog 1 times out, guard releases, dialog 2 opens for a new event, then dialog 1's original promise resolves late - asserting the late response is still honored and does not clobber dialog 2's guard state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * address review: guard .then() with requestId so a stale dialog can't clobber a newer one (#456) muster round 3 found that .finally() checked updateDialogRequestId before releasing the guard, but .then() had no such check — a late click on an abandoned, superseded dialog could still flip updateDialogDismissedInSession or trigger quitAndInstall() out from under a different, currently-open dialog. Added the same requestId check to .then(), clear the watchdog timer once a dialog settles on its own, and rewrote the round-2 regression test (which was inadvertently confounded by updateDialogDismissedInSession and didn't actually exercise the buggy path) to correctly pin the new, request-scoped behavior. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: keep update dialog guard until dialog closes --------- Co-authored-by: armada-lookout <armada-lookout@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Willow Lyu <iqay_lyu@hotmail.com>
1 parent 4b2d557 commit 324c62c

2 files changed

Lines changed: 181 additions & 6 deletions

File tree

src/backend/ipc/release-channel-handlers.test.ts

Lines changed: 136 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,37 @@
11
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "vitest";
22

33
// Keep Electron, the real autoUpdater, and encrypted storage out of this unit test — we're
4-
// exercising the token-health gating logic in ReleaseChannelIPCHandlers (#919), not Electron
5-
// itself.
4+
// exercising both the token-health gating logic (#919) and the update-ready reminder dialog's
5+
// anti-stacking guard behavior (#456) in ReleaseChannelIPCHandlers, driven via mocked
6+
// Electron/autoUpdater listeners rather than the real Electron runtime.
7+
const showMessageBoxMock = vi.fn().mockResolvedValue({ response: 1 });
68
vi.mock("electron", () => ({
79
app: {
810
getName: () => "YakShaver",
911
getVersion: () => "1.2.3",
1012
isPackaged: true,
1113
},
1214
BrowserWindow: { getAllWindows: () => [] },
13-
dialog: { showMessageBox: vi.fn().mockResolvedValue({ response: 1 }) },
15+
dialog: { showMessageBox: (...args: unknown[]) => showMessageBoxMock(...args) },
1416
ipcMain: { handle: vi.fn() },
1517
}));
1618

1719
const checkForUpdatesMock = vi.fn();
1820
const setFeedURLMock = vi.fn();
21+
const quitAndInstallMock = vi.fn();
22+
// Capture registered autoUpdater listeners (keyed by event name) so tests can fire events like
23+
// "update-downloaded" directly, the same way the real electron-updater instance would (#456).
24+
const autoUpdaterListeners = new Map<string, Array<(...args: unknown[]) => void>>();
25+
const autoUpdaterOnMock = vi.fn((event: string, listener: (...args: unknown[]) => void) => {
26+
const listeners = autoUpdaterListeners.get(event) ?? [];
27+
listeners.push(listener);
28+
autoUpdaterListeners.set(event, listeners);
29+
});
30+
function emitAutoUpdaterEvent(event: string, ...args: unknown[]) {
31+
for (const listener of autoUpdaterListeners.get(event) ?? []) {
32+
listener(...args);
33+
}
34+
}
1935
vi.mock("electron-updater", () => ({
2036
autoUpdater: {
2137
autoDownload: false,
@@ -24,9 +40,10 @@ vi.mock("electron-updater", () => ({
2440
allowPrerelease: false,
2541
allowDowngrade: false,
2642
requestHeaders: {},
27-
on: vi.fn(),
43+
on: (...args: [string, (...a: unknown[]) => void]) => autoUpdaterOnMock(...args),
2844
setFeedURL: (...args: unknown[]) => setFeedURLMock(...args),
2945
checkForUpdates: (...args: unknown[]) => checkForUpdatesMock(...args),
46+
quitAndInstall: (...args: unknown[]) => quitAndInstallMock(...args),
3047
},
3148
}));
3249

@@ -373,3 +390,118 @@ describe("ReleaseChannelIPCHandlers — PR releases require a healthy GitHub tok
373390
expect(result).toEqual({ available: false, currentVersion: "1.2.3" });
374391
});
375392
});
393+
394+
describe("ReleaseChannelIPCHandlers — update-ready reminder dialog (#456)", () => {
395+
beforeEach(() => {
396+
vi.clearAllMocks();
397+
autoUpdaterListeners.clear();
398+
showMessageBoxMock.mockReset();
399+
});
400+
401+
it("does not stack a second reminder dialog while the first is still awaiting a response", async () => {
402+
// The reported bug's second half: "If the reminder dialog is open already, a subsequent
403+
// update check should not open another reminder dialog on top of it." Simulate the first
404+
// dialog's promise never resolving (user hasn't answered yet) and fire a second
405+
// "update-downloaded" event (e.g. the periodic background check landing mid-dialog) — only one
406+
// dialog must ever be shown.
407+
let resolveFirstDialog: ((result: { response: number }) => void) | undefined;
408+
showMessageBoxMock.mockImplementationOnce(
409+
() =>
410+
new Promise((resolve) => {
411+
resolveFirstDialog = resolve;
412+
}),
413+
);
414+
415+
new ReleaseChannelIPCHandlers();
416+
417+
emitAutoUpdaterEvent("update-downloaded");
418+
emitAutoUpdaterEvent("update-downloaded");
419+
420+
expect(showMessageBoxMock).toHaveBeenCalledTimes(1);
421+
422+
// Resolve the first dialog and wait for the handler's .then/.finally chain to flush before
423+
// the test ends, matching the sibling test's vi.waitFor pattern rather than firing-and-forgetting.
424+
resolveFirstDialog?.({ response: 1 });
425+
await vi.waitFor(() => expect(showMessageBoxMock).toHaveBeenCalledTimes(1));
426+
});
427+
428+
it("keeps suppressing duplicate reminders while a long-running dialog remains open", async () => {
429+
vi.useFakeTimers();
430+
try {
431+
showMessageBoxMock.mockImplementation(() => new Promise(() => {})); // never settles
432+
433+
new ReleaseChannelIPCHandlers();
434+
435+
emitAutoUpdaterEvent("update-downloaded");
436+
expect(showMessageBoxMock).toHaveBeenCalledTimes(1);
437+
438+
// Advance beyond both the removed five-minute watchdog and the ten-minute update interval.
439+
await vi.advanceTimersByTimeAsync(11 * 60 * 1000);
440+
441+
// The original native dialog is still visible, so another event must remain suppressed.
442+
emitAutoUpdaterEvent("update-downloaded");
443+
expect(showMessageBoxMock).toHaveBeenCalledTimes(1);
444+
} finally {
445+
vi.useRealTimers();
446+
}
447+
});
448+
449+
it("releases the guard after a dialog error so a later event can retry", async () => {
450+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
451+
showMessageBoxMock
452+
.mockRejectedValueOnce(new Error("dialog failed"))
453+
.mockImplementationOnce(() => new Promise(() => {}));
454+
455+
try {
456+
new ReleaseChannelIPCHandlers();
457+
458+
emitAutoUpdaterEvent("update-downloaded");
459+
await vi.waitFor(() => expect(errorSpy).toHaveBeenCalledTimes(1));
460+
461+
emitAutoUpdaterEvent("update-downloaded");
462+
expect(showMessageBoxMock).toHaveBeenCalledTimes(2);
463+
} finally {
464+
errorSpy.mockRestore();
465+
}
466+
});
467+
468+
it("does not show a reminder again this session after the user clicks Later — the originally reported bug", async () => {
469+
// The reported bug's first half: clicking "Remind me later" must stop further reminders for
470+
// the rest of the current session, even when the periodic ~10-minute check fires again.
471+
showMessageBoxMock.mockResolvedValue({ response: 1 }); // 1 = "Later"
472+
473+
new ReleaseChannelIPCHandlers();
474+
475+
emitAutoUpdaterEvent("update-downloaded");
476+
await vi.waitFor(() => expect(showMessageBoxMock).toHaveBeenCalledTimes(1));
477+
478+
// Simulate the periodic check finding the same update again later in the session.
479+
emitAutoUpdaterEvent("update-downloaded");
480+
emitAutoUpdaterEvent("update-downloaded");
481+
482+
expect(showMessageBoxMock).toHaveBeenCalledTimes(1);
483+
});
484+
485+
it("does not show another reminder after the user clicks Restart Now, even before quitAndInstall fires", async () => {
486+
// Regression coverage (review on #456): isUpdateDialogOpen resets in .finally() synchronously,
487+
// but the real quit (autoUpdater.quitAndInstall) is deferred via setImmediate. A second
488+
// "update-downloaded" event landing in that gap must not stack a dialog moments before the app
489+
// quits — pinned here via the isRestartingToInstall short-circuit.
490+
showMessageBoxMock.mockResolvedValue({ response: 0 }); // 0 = "Restart Now"
491+
492+
new ReleaseChannelIPCHandlers();
493+
494+
emitAutoUpdaterEvent("update-downloaded");
495+
await vi.waitFor(() => expect(showMessageBoxMock).toHaveBeenCalledTimes(1));
496+
497+
// A second event landing after the dialog resolved (isUpdateDialogOpen already released) but
498+
// before the deferred quitAndInstall() fires must still be suppressed.
499+
emitAutoUpdaterEvent("update-downloaded");
500+
501+
expect(showMessageBoxMock).toHaveBeenCalledTimes(1);
502+
503+
// Let the deferred quitAndInstall() flush before the test ends, so it doesn't fire during a
504+
// later test.
505+
await vi.waitFor(() => expect(quitAndInstallMock).toHaveBeenCalledTimes(1));
506+
});
507+
});

src/backend/ipc/release-channel-handlers.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,23 @@ export class ReleaseChannelIPCHandlers {
7575
private updateCheckInterval: NodeJS.Timeout | null = null;
7676
private readonly UPDATE_CHECK_INTERVAL = 10 * 60 * 1000; // Check every 10 minutes
7777
private updateDialogDismissedInSession = false; // Track if user dismissed update dialog this session
78+
// Track whether the "Update Ready" dialog is currently open (#456) - guards against a second
79+
// autoUpdater "update-downloaded" event (e.g. a periodic re-check landing while the first
80+
// dialog is still awaiting a response) from stacking a duplicate dialog on top of it.
81+
private isUpdateDialogOpen = false;
82+
// Once the user picks "Restart Now" the app is on its way out (review on #456) - short-circuit
83+
// any further "update-downloaded" events immediately rather than relying solely on the
84+
// isUpdateDialogOpen guard, which resets in .finally() before the deferred quitAndInstall() call
85+
// actually fires and would otherwise leave a narrow window where a second event could stack a
86+
// dialog moments before quit.
87+
//
88+
// Never reset back to false (review on #456): quitAndInstall() is fired via a fire-and-forget
89+
// setImmediate() with no completion signal, so there is no reliable point at which we could learn
90+
// the quit was blocked/failed and it's safe to resume showing reminders. Once the user has
91+
// committed to restarting, permanently suppressing further reminders for the rest of this
92+
// (soon-to-exit) session is the safer failure mode versus risking a reminder popping up while the
93+
// app believes it is already on its way out.
94+
private isRestartingToInstall = false;
7895

7996
constructor() {
8097
ipcMain.handle(IPC_CHANNELS.RELEASE_CHANNEL_GET, () => this.getChannel());
@@ -111,11 +128,27 @@ export class ReleaseChannelIPCHandlers {
111128
});
112129

113130
autoUpdater.on("update-downloaded", () => {
114-
// Skip showing dialog if user already dismissed it this session
131+
// Skip showing dialog if user already dismissed it this session (#456) - once "Later" is
132+
// clicked, no further reminder should appear for the rest of the current session.
115133
if (this.updateDialogDismissedInSession) {
116134
return;
117135
}
118136

137+
// Skip if the app is already on its way out from a prior "Restart Now" (review on #456) -
138+
// checked before isUpdateDialogOpen since that guard resets in .finally() slightly before
139+
// the deferred quitAndInstall() call actually fires.
140+
if (this.isRestartingToInstall) {
141+
return;
142+
}
143+
144+
// Skip if a reminder dialog is already open (#456) - a subsequent "update-downloaded" event
145+
// (e.g. the periodic background check firing again) must not stack another dialog on top of
146+
// one the user hasn't responded to yet.
147+
if (this.isUpdateDialogOpen) {
148+
return;
149+
}
150+
this.isUpdateDialogOpen = true;
151+
119152
dialog
120153
.showMessageBox({
121154
type: "info",
@@ -127,6 +160,9 @@ export class ReleaseChannelIPCHandlers {
127160
})
128161
.then((result) => {
129162
if (result.response === 0) {
163+
// The app is quitting to install - short-circuit any further events immediately,
164+
// ahead of the isUpdateDialogOpen reset in .finally() below.
165+
this.isRestartingToInstall = true;
130166
// Set isQuitting to true so that the before-quit handler allows the app to quit
131167
setIsQuitting(true);
132168
// Force immediate quit and install
@@ -135,12 +171,19 @@ export class ReleaseChannelIPCHandlers {
135171
autoUpdater.quitAndInstall(false, true);
136172
});
137173
} else {
138-
// User chose "Later" - remember this for the current session
174+
// User chose "Later" - remember this for the current session so no further reminder
175+
// is shown, whether from another "update-downloaded" event or the periodic check.
139176
this.updateDialogDismissedInSession = true;
140177
}
141178
})
142179
.catch((err) => {
143180
console.error("Error showing update dialog:", err);
181+
})
182+
.finally(() => {
183+
// Keep the guard for the full lifetime of the native dialog. This dialog has no reliable
184+
// cross-platform cancellation configured, so releasing the guard on a timeout would
185+
// allow another dialog to stack on top while the original is still visible.
186+
this.isUpdateDialogOpen = false;
144187
});
145188
});
146189
}

0 commit comments

Comments
 (0)