Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 136 additions & 4 deletions src/backend/ipc/release-channel-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,37 @@
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from "vitest";

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

const checkForUpdatesMock = vi.fn();
const setFeedURLMock = vi.fn();
const quitAndInstallMock = vi.fn();
// Capture registered autoUpdater listeners (keyed by event name) so tests can fire events like
// "update-downloaded" directly, the same way the real electron-updater instance would (#456).
const autoUpdaterListeners = new Map<string, Array<(...args: unknown[]) => void>>();
const autoUpdaterOnMock = vi.fn((event: string, listener: (...args: unknown[]) => void) => {
const listeners = autoUpdaterListeners.get(event) ?? [];
listeners.push(listener);
autoUpdaterListeners.set(event, listeners);
});
function emitAutoUpdaterEvent(event: string, ...args: unknown[]) {
for (const listener of autoUpdaterListeners.get(event) ?? []) {
listener(...args);
}
}
vi.mock("electron-updater", () => ({
autoUpdater: {
autoDownload: false,
Expand All @@ -24,9 +40,10 @@ vi.mock("electron-updater", () => ({
allowPrerelease: false,
allowDowngrade: false,
requestHeaders: {},
on: vi.fn(),
on: (...args: [string, (...a: unknown[]) => void]) => autoUpdaterOnMock(...args),
setFeedURL: (...args: unknown[]) => setFeedURLMock(...args),
checkForUpdates: (...args: unknown[]) => checkForUpdatesMock(...args),
quitAndInstall: (...args: unknown[]) => quitAndInstallMock(...args),
},
}));

Expand Down Expand Up @@ -373,3 +390,118 @@ describe("ReleaseChannelIPCHandlers — PR releases require a healthy GitHub tok
expect(result).toEqual({ available: false, currentVersion: "1.2.3" });
});
});

describe("ReleaseChannelIPCHandlers — update-ready reminder dialog (#456)", () => {
beforeEach(() => {
vi.clearAllMocks();
autoUpdaterListeners.clear();
showMessageBoxMock.mockReset();
});

it("does not stack a second reminder dialog while the first is still awaiting a response", async () => {
// The reported bug's second half: "If the reminder dialog is open already, a subsequent
// update check should not open another reminder dialog on top of it." Simulate the first
// dialog's promise never resolving (user hasn't answered yet) and fire a second
// "update-downloaded" event (e.g. the periodic background check landing mid-dialog) — only one
// dialog must ever be shown.
let resolveFirstDialog: ((result: { response: number }) => void) | undefined;
showMessageBoxMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirstDialog = resolve;
}),
);

new ReleaseChannelIPCHandlers();

emitAutoUpdaterEvent("update-downloaded");
emitAutoUpdaterEvent("update-downloaded");

expect(showMessageBoxMock).toHaveBeenCalledTimes(1);

// Resolve the first dialog and wait for the handler's .then/.finally chain to flush before
// the test ends, matching the sibling test's vi.waitFor pattern rather than firing-and-forgetting.
resolveFirstDialog?.({ response: 1 });

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] resolveFirstDialog?.({ response: 1 }) is called without awaiting the resulting .then/.finally chain on the handler side, so the guard's reset and any pending microtasks aren't guaranteed to flush before the test ends. Low risk (the next beforeEach clears mocks), but the second new test's use of vi.waitFor(...) is the more robust pattern — worth matching here too.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree — implemented in ab0e2eb. Replaced the fire-and-forget resolve with await vi.waitFor(...) after resolving the dialog, matching the sibling test's pattern so the guard's reset and any pending microtasks are guaranteed to flush before the test ends.

await vi.waitFor(() => expect(showMessageBoxMock).toHaveBeenCalledTimes(1));
});

it("keeps suppressing duplicate reminders while a long-running dialog remains open", async () => {
vi.useFakeTimers();
try {
showMessageBoxMock.mockImplementation(() => new Promise(() => {})); // never settles

new ReleaseChannelIPCHandlers();

emitAutoUpdaterEvent("update-downloaded");
expect(showMessageBoxMock).toHaveBeenCalledTimes(1);

// Advance beyond both the removed five-minute watchdog and the ten-minute update interval.
await vi.advanceTimersByTimeAsync(11 * 60 * 1000);

// The original native dialog is still visible, so another event must remain suppressed.
emitAutoUpdaterEvent("update-downloaded");
expect(showMessageBoxMock).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] No test covers a third event after timeout, nor the abandoned first dialog's late resolution

The "releases the guard after the dialog settles" test only verifies that after the 5-minute timeout, a second update-downloaded event causes showMessageBoxMock to be called again (total count 2). It never asserts anything about the ORIGINAL (never-settling) dialogPromise from the first call — e.g. resolving it after the timeout and checking whether that causes any unwanted side effects (ignored user response, or interaction with a second dialog now in flight). This is exactly the gap that let the blocking issue (see the inline comment on release-channel-handlers.ts, the Promise.race block) through untested.

Suggested addition: a test that (1) triggers dialog 1, (2) lets it hang past the 5-minute timeout so the guard resets, (3) triggers dialog 2 via a third event, (4) then resolves dialog 1's original promise with e.g. { response: 0 }, and asserts what actually happens — currently neither the "correct" nor the buggy behavior is pinned by any test.

flagged by: code-review + codex-rescue (independent agreement)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree — added in 1b3ff9f. New test "still honors the original dialog's real answer after the watchdog timeout releases the guard, without disturbing a newer dialog (#456 blocking fix)": dialog 1 hangs past its watchdog (guard releases) -> a third event opens dialog 2 (still open, unanswered) -> dialog 1's original promise finally resolves ("Later", to keep this isolated from the separate isRestartingToInstall short-circuit). Asserts the late response is honored and that a fourth event is still correctly suppressed as a stack-on-top of dialog 2 — i.e. dialog 1's late .finally() did not incorrectly clobber dialog 2's guard state.

});

it("releases the guard after a dialog error so a later event can retry", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
showMessageBoxMock
.mockRejectedValueOnce(new Error("dialog failed"))
.mockImplementationOnce(() => new Promise(() => {}));

try {
new ReleaseChannelIPCHandlers();

emitAutoUpdaterEvent("update-downloaded");
await vi.waitFor(() => expect(errorSpy).toHaveBeenCalledTimes(1));

emitAutoUpdaterEvent("update-downloaded");
expect(showMessageBoxMock).toHaveBeenCalledTimes(2);
} finally {
errorSpy.mockRestore();
}
});

it("does not show a reminder again this session after the user clicks Later — the originally reported bug", async () => {
// The reported bug's first half: clicking "Remind me later" must stop further reminders for
// the rest of the current session, even when the periodic ~10-minute check fires again.
showMessageBoxMock.mockResolvedValue({ response: 1 }); // 1 = "Later"

new ReleaseChannelIPCHandlers();

emitAutoUpdaterEvent("update-downloaded");
await vi.waitFor(() => expect(showMessageBoxMock).toHaveBeenCalledTimes(1));

// Simulate the periodic check finding the same update again later in the session.
emitAutoUpdaterEvent("update-downloaded");
emitAutoUpdaterEvent("update-downloaded");

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] No test directly exercises the guard's release-and-reuse path (dialog resolves → guard clears → a later legitimate update-downloaded event is handled normally). Both new tests either overlap the guard while it's still open, or rely on updateDialogDismissedInSession short-circuiting first. Not a hard requirement given the call sites, but would directly pin down the .finally() reset behavior.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree — implemented in ab0e2eb. Added "releases the guard after the dialog settles so a later legitimate event is handled normally", which directly pins the .finally() reset: it uses the new timeout fallback (a dialog that never settles) so the guard's own release is what's under test, isolated from updateDialogDismissedInSession/isRestartingToInstall short-circuits. Also added a second test for the Restart Now short-circuit path while I was in there.

expect(showMessageBoxMock).toHaveBeenCalledTimes(1);
});

it("does not show another reminder after the user clicks Restart Now, even before quitAndInstall fires", async () => {
// Regression coverage (review on #456): isUpdateDialogOpen resets in .finally() synchronously,
// but the real quit (autoUpdater.quitAndInstall) is deferred via setImmediate. A second
// "update-downloaded" event landing in that gap must not stack a dialog moments before the app
// quits — pinned here via the isRestartingToInstall short-circuit.
showMessageBoxMock.mockResolvedValue({ response: 0 }); // 0 = "Restart Now"

new ReleaseChannelIPCHandlers();

emitAutoUpdaterEvent("update-downloaded");
await vi.waitFor(() => expect(showMessageBoxMock).toHaveBeenCalledTimes(1));

// A second event landing after the dialog resolved (isUpdateDialogOpen already released) but
// before the deferred quitAndInstall() fires must still be suppressed.
emitAutoUpdaterEvent("update-downloaded");

expect(showMessageBoxMock).toHaveBeenCalledTimes(1);

// Let the deferred quitAndInstall() flush before the test ends, so it doesn't fire during a
// later test.
await vi.waitFor(() => expect(quitAndInstallMock).toHaveBeenCalledTimes(1));
});
});
47 changes: 45 additions & 2 deletions src/backend/ipc/release-channel-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,23 @@ export class ReleaseChannelIPCHandlers {
private updateCheckInterval: NodeJS.Timeout | null = null;
private readonly UPDATE_CHECK_INTERVAL = 10 * 60 * 1000; // Check every 10 minutes
private updateDialogDismissedInSession = false; // Track if user dismissed update dialog this session
// Track whether the "Update Ready" dialog is currently open (#456) - guards against a second
// autoUpdater "update-downloaded" event (e.g. a periodic re-check landing while the first
// dialog is still awaiting a response) from stacking a duplicate dialog on top of it.
private isUpdateDialogOpen = false;
// Once the user picks "Restart Now" the app is on its way out (review on #456) - short-circuit
// any further "update-downloaded" events immediately rather than relying solely on the
// isUpdateDialogOpen guard, which resets in .finally() before the deferred quitAndInstall() call
// actually fires and would otherwise leave a narrow window where a second event could stack a
// dialog moments before quit.
//
// Never reset back to false (review on #456): quitAndInstall() is fired via a fire-and-forget
// setImmediate() with no completion signal, so there is no reliable point at which we could learn
// the quit was blocked/failed and it's safe to resume showing reminders. Once the user has
// committed to restarting, permanently suppressing further reminders for the rest of this
// (soon-to-exit) session is the safer failure mode versus risking a reminder popping up while the
// app believes it is already on its way out.
private isRestartingToInstall = false;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] isRestartingToInstall is never reset, so a failed/blocked quit permanently suppresses all future reminders

isRestartingToInstall is set to true when the user picks "Restart Now" and is checked at the very top of the handler (before isUpdateDialogOpen), but nothing ever sets it back to false. This is fine on the expected path (app quits via quitAndInstall), but quitAndInstall is called inside a setImmediate and can in principle be deferred/blocked by other before-quit handlers, an OS-level prompt, or fail outright (installer error, permissions issue) without the process actually exiting. In that edge case the app keeps running but every future update-downloaded event is now permanently ignored for the rest of the session — with no code path that ever clears the flag. Same failure class ("flag never resets if the deferred follow-up action doesn't happen") as the main blocking issue in this PR.

flagged by: codex-rescue

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discuss — agree this is a real gap, but implemented as documentation rather than a reset. There's no reliable signal to reset on: quitAndInstall() is fired via a fire-and-forget setImmediate() with no completion/failure callback, so we can't distinguish "quit is slow" from "quit is permanently blocked" without adding a second speculative watchdog/timeout guessing at how long a legitimate quit may take. Since the user has already explicitly committed to restarting, permanently suppressing further reminders for the rest of that (expected-to-exit-soon) session reads as the safer failure mode versus risking a reminder popping up while the app believes it's already quitting. Expanded the inline comment in 1b3ff9f to state this is intentional rather than an oversight. Happy to add a bounded reset watchdog (mirroring the dialog's own) if you'd rather have that — flagging as a possible follow-up rather than blocking this PR on it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] isRestartingToInstall never resets and there's no teardown for the new guard state / watchdog timer, unlike the existing stopPeriodicUpdateChecks() pattern

By design (per the PR's own comment) isRestartingToInstall is set true on 'Restart Now' and never reset, because quitAndInstall() is fire-and-forget with no completion signal. That's a defensible failure mode for the common case, but if quitAndInstall() ever fails to actually terminate the process (blocked by another before-quit handler, an install/code-signing failure on some platform, or an exception thrown inside the deferred setImmediate callback), the app keeps running indefinitely with all future update-ready reminders permanently and silently suppressed for the rest of the session, with no user-visible indication why.

Separately: the class already has a stopPeriodicUpdateChecks() cleanup method for updateCheckInterval, but this PR adds new persistent state (isUpdateDialogOpen, isRestartingToInstall, updateDialogRequestId) and a watchdog setTimeout with no equivalent teardown hook. Low real-world risk today since ReleaseChannelIPCHandlers is a singleton and stopPeriodicUpdateChecks() itself isn't currently called from anywhere — flagging only as a maintainability gap for if/when a dispose path is wired up.

Non-blocking; consider logging when isRestartingToInstall is set (for diagnosability) as a low-cost improvement.

flagged by: codex-rescue + code-review (independently, related observations)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discuss — this is the same point raised and discussed in review round 2 (see the earlier thread on this line), where the team's call was to keep it as a documented, intentional fail-safe rather than add a speculative secondary watchdog, since there's no reliable 'quit failed' signal to reset on. Standing by that decision rather than re-opening it here — happy to add a bounded reset watchdog as a tracked follow-up if a human reviewer wants it, but not implementing it speculatively in this pass. The teardown-hook gap is real but low-risk today (the class is a singleton and stopPeriodicUpdateChecks() itself isn't currently called from anywhere) — noting it as a follow-up rather than expanding this PR's scope.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] isRestartingToInstall is permanently sticky with no recovery path if quitAndInstall silently fails

If autoUpdater.quitAndInstall(false, true) fails silently or the quit is blocked for some unrelated reason, the app is left running indefinitely with all future update-ready reminders suppressed for the rest of the session, with no path back to healthy state short of an app restart. This is called out and accepted in the code's own comment as 'the safer failure mode,' which is a reasonable call for this Electron auto-updater edge case — not blocking. Worth noting there's no user-facing signal (log-only via console.warn) if this state is ever reached, so a support engineer debugging 'update reminders stopped working' would need to check the main-process console log rather than anything surfaced in the UI.

flagged by: codex-rescue


constructor() {
ipcMain.handle(IPC_CHANNELS.RELEASE_CHANNEL_GET, () => this.getChannel());
Expand Down Expand Up @@ -111,11 +128,27 @@ export class ReleaseChannelIPCHandlers {
});

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

// Skip if the app is already on its way out from a prior "Restart Now" (review on #456) -
// checked before isUpdateDialogOpen since that guard resets in .finally() slightly before
// the deferred quitAndInstall() call actually fires.
if (this.isRestartingToInstall) {
return;
}

// Skip if a reminder dialog is already open (#456) - a subsequent "update-downloaded" event
// (e.g. the periodic background check firing again) must not stack another dialog on top of
// one the user hasn't responded to yet.
if (this.isUpdateDialogOpen) {
return;
}
this.isUpdateDialogOpen = true;

dialog
.showMessageBox({
type: "info",
Expand All @@ -127,6 +160,9 @@ export class ReleaseChannelIPCHandlers {
})
.then((result) => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] .then() result handler is missing the requestId guard, so a stale/abandoned dialog's late click can hijack a newer, currently-open dialog

The new updateDialogRequestId mechanism is meant to distinguish "my own dialog settled" from "a newer dialog has already superseded me", but it is only checked in .finally() (line 208: if (this.updateDialogRequestId === requestId) { this.isUpdateDialogOpen = false; }). The .then((result) => {...}) callback that actually acts on the user's choice — setting updateDialogDismissedInSession = true on "Later", or setting isRestartingToInstall = true and firing quitAndInstall() on "Restart Now" — has no such guard and runs unconditionally whenever the promise resolves, however late.

Concrete failure sequence:

  1. Dialog ✨ Native Linux Support - Enable YakShaver Desktop App to run natively on Linux #1 opens (requestId=1).
  2. The 5-minute watchdog fires because dialog ✨ Native Linux Support - Enable YakShaver Desktop App to run natively on Linux #1 is still on screen and unanswered, releases the guard, and warns.
  3. A subsequent update-downloaded event opens dialog ✨ Azure Trusted Signing - Implement Azure Trusted Signing for Windows Apps #2 (requestId=2), which the user is now actively looking at and hasn't answered.
  4. The user finally clicks the stale, abandoned dialog ✨ Native Linux Support - Enable YakShaver Desktop App to run natively on Linux #1 (per the PR's own comment: "The original dialog is still on screen"):

This undermines the PR's own stated design intent for handling a late response from an abandoned dialog — the .then() branch needed the same if (this.updateDialogRequestId === requestId) guard that .finally() received. Suggested fix: wrap the body of .then in the same requestId check used in .finally.

Related: the third new test ("still honors the original dialog's real answer after the watchdog timeout releases the guard, without disturbing a newer dialog (#456 blocking fix)") does not actually catch this — see the separate major finding on that test.

flagged by: codex-rescue

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree — this was a real bug, thanks for catching it (and for verifying it directly against the actual head commit rather than trusting my prior review-round comments). Fixed in 49c3204: the .then() handler now checks this.updateDialogRequestId === requestId before acting on the result — the same guard .finally() already had — so a stale/abandoned dialog's late click is now logged and ignored rather than silently dismissing the session or triggering quitAndInstall() out from under a newer, currently-open dialog.

if (result.response === 0) {
// The app is quitting to install - short-circuit any further events immediately,
// ahead of the isUpdateDialogOpen reset in .finally() below.
this.isRestartingToInstall = true;
// Set isQuitting to true so that the before-quit handler allows the app to quit
setIsQuitting(true);
// Force immediate quit and install
Expand All @@ -135,12 +171,19 @@ export class ReleaseChannelIPCHandlers {
autoUpdater.quitAndInstall(false, true);
});
} else {
// User chose "Later" - remember this for the current session
// User chose "Later" - remember this for the current session so no further reminder
// is shown, whether from another "update-downloaded" event or the periodic check.
this.updateDialogDismissedInSession = true;
}
})
.catch((err) => {
console.error("Error showing update dialog:", err);
})
.finally(() => {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Guard resets before the actual quit fires on "Restart Now" (flagged by both lenses)

On the "Restart Now" branch, isUpdateDialogOpen resets to false synchronously in .finally(), but the real quit (autoUpdater.quitAndInstall) is deferred via setImmediate. In that brief window, updateDialogDismissedInSession is never set on this branch, so a second update-downloaded event landing in that gap could in principle stack a second dialog moments before the app quits — the same class of bug this PR closes, just narrowed rather than eliminated on this one path. Practically negligible (sub-millisecond window, needs a second full download to land in it) but worth a one-line note or a isQuitting short-circuit on the guard check.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree — implemented in ab0e2eb. Added an isRestartingToInstall flag set synchronously in the "Restart Now" branch (before the deferred quitAndInstall() fires) and checked ahead of the isUpdateDialogOpen guard, so an event landing in that narrow window is short-circuited rather than stacking a dialog moments before quit. Added a regression test covering this exact gap.

// Keep the guard for the full lifetime of the native dialog. This dialog has no reliable
// cross-platform cancellation configured, so releasing the guard on a timeout would
// allow another dialog to stack on top while the original is still visible.
this.isUpdateDialogOpen = false;
});
});
}
Expand Down
Loading