Skip to content

fix(desktop): guard installUpdate against repeat clicks on macOS#3549

Merged
Kitenite merged 2 commits intomainfrom
fix/3507-auto-update
Apr 18, 2026
Merged

fix(desktop): guard installUpdate against repeat clicks on macOS#3549
Kitenite merged 2 commits intomainfrom
fix/3507-auto-update

Conversation

@Kitenite
Copy link
Copy Markdown
Collaborator

@Kitenite Kitenite commented Apr 18, 2026

Summary

  • Repeat clicks on the Update button on macOS closed the app but left it on the old version.
  • Root cause: MacUpdater.quitAndInstall() registers a fresh update-downloaded listener on the native autoUpdater every call while Squirrel.Mac is still staging. Each extra click stacked another listener; when Squirrel finally fired, N parallel nativeUpdater.quitAndInstall() calls raced to swap the binary.
  • Fix: add an isInstalling guard + status === READY precondition in installUpdate, and clear the guard in the error handler so retries are possible.

Verified against electron-updater@6.8.3 source (MacUpdater.js lines 236–252) and cross-checked with t3code's equivalent updateInstallInFlight pattern.

Supersedes draft PR #3508 (same root-cause analysis, tightened comments to project conventions).

Closes #3507

Test plan

  • bun test src/main/lib/auto-updater.test.ts — 3 new tests pass (ignores when not READY, collapses repeats, resets guard on error)
  • bun run typecheck clean
  • bun run lint clean on changed files
  • Manual: install a prior build, publish a newer release, click Update repeatedly, confirm only one quitAndInstall fires and the app relaunches on the new version

Summary by cubic

Fixes a macOS auto-update bug where repeat Update clicks quit the app but relaunch on the old version. We now gate install requests and ignore duplicates until the install completes.

  • Bug Fixes
    • Gate installUpdate() with isInstalling and require status === READY to prevent parallel quitAndInstall calls from electron-updater on macOS.
    • Reset the in-flight flag on error so users can retry if the install fails.
    • Tests: add cases for not-ready ignores, repeat-click collapse, and guard reset on error; make the suite portable by mocking shared/constants to pin macOS and use a network-shaped error in reset to avoid clearing the cached update.

Written for commit f589326. Summary will update on new commits.

Summary by CodeRabbit

  • Tests

    • Added tests that verify update installation flow, ensure only one install triggers after download, and validate retry behavior after errors.
  • Bug Fixes

    • Prevented duplicate or concurrent update installation attempts.
    • Cleared the install guard on update errors so subsequent installs can proceed.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 18, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2b11bed0-0fa6-414b-9931-53d32080c535

📥 Commits

Reviewing files that changed from the base of the PR and between b51271a and f589326.

📒 Files selected for processing (1)
  • apps/desktop/src/main/lib/auto-updater.test.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/desktop/src/main/lib/auto-updater.test.ts

📝 Walkthrough

Walkthrough

Adds an isInstalling module guard to installUpdate() to prevent duplicate quits, resets the guard on error, and introduces a Bun test suite that mocks electron-updater, electron, and helpers to verify single quitAndInstall behavior, guard blocking, and recovery after errors.

Changes

Cohort / File(s) Summary
Auto-updater implementation
apps/desktop/src/main/lib/auto-updater.ts
Adds an isInstalling flag; installUpdate() returns early if already installing or status is not AUTO_UPDATE_STATUS.READY; sets isInstalling = true before calling autoUpdater.quitAndInstall(...); autoUpdater.on("error") handler clears isInstalling.
Auto-updater tests
apps/desktop/src/main/lib/auto-updater.test.ts
New Bun test file. Introduces FakeAutoUpdater and mocks for electron-updater, electron (app, dialog), and main/index helpers; tests that installs are blocked until READY, repeated installUpdate() calls only call quitAndInstall once, and an error clears the in-flight guard so a later update-downloaded can trigger another quitAndInstall.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 I hopped around a sleepy flag,
One install at once — no double gag.
When downloads shout "ready!", I leap,
Errors hush, then guards fall asleep.
A tidy hop, updates no longer lag. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically identifies the fix as guarding installUpdate against repeat clicks on macOS, which matches the main change in the changeset.
Description check ✅ Passed The description includes all required sections: clear summary of the problem and fix, related issues (closes #3507), type of change (bug fix), and comprehensive testing details with both automated and manual test plans.
Linked Issues check ✅ Passed The PR directly addresses issue #3507 by implementing a guard against repeat clicks on the Update button, preventing duplicate install attempts and allowing retries on error as required.
Out of Scope Changes check ✅ Passed All changes are scoped to the auto-updater functionality: test suite addition and guard implementation with error handling reset, directly addressing the linked issue with no extraneous changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/3507-auto-update

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
apps/desktop/src/main/lib/auto-updater.test.ts (2)

42-52: Nit: setupAutoUpdater() is re-invoked every beforeEach.

Each call re-registers all autoUpdater.on(...) listeners and schedules a fresh setInterval. The removeAllListeners() above handles the listener duplication, and interval.unref() keeps the process from hanging, but you're still accumulating timers for the lifetime of the test run. Calling setupAutoUpdater() once (e.g. in a beforeAll) and only resetting state (removeAllListeners + re-register, or just re-emit the error to clear isInstalling) in beforeEach would be cleaner. Not blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/lib/auto-updater.test.ts` around lines 42 - 52, The
test currently calls setupAutoUpdater() inside beforeEach which re-registers
autoUpdater.on listeners and creates new setInterval timers each run; instead,
move the setupAutoUpdater() invocation to a beforeAll so the module-level
listeners and interval are created once, and keep beforeEach to reset state by
calling fakeAutoUpdater.removeAllListeners(),
fakeAutoUpdater.quitAndInstall.mockClear(),
fakeAutoUpdater.checkForUpdates.mockClear(),
fakeAutoUpdater.setFeedURL.mockClear(), and re-emitting the reset error with
fakeAutoUpdater.emit("error", new Error("reset")) to clear isInstalling; ensure
tests still call any per-test re-registration if needed after
removeAllListeners().

16-40: Consider mocking main/env.main to make the tests hermetic.

auto-updater.ts early-returns from installUpdate() when env.NODE_ENV === "development". The suite relies on whatever main/env.main resolves to at test time (presumably not "development"), so if that module ever reads process.env.NODE_ENV directly — or a future change sets NODE_ENV=development in the test runner — the "collapses repeat install clicks" test would silently pass-through the dev-mode branch and quitAndInstall would never be called, masking a regression (or worse, turning green when it shouldn't).

Explicitly mocking it removes that coupling:

🧪 Suggested addition
 mock.module("main/index", () => ({
 	setSkipQuitConfirmation: mock(() => {}),
 }));
+
+mock.module("main/env.main", () => ({
+	env: { NODE_ENV: "production" },
+}));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/main/lib/auto-updater.test.ts` around lines 16 - 40, The
test must explicitly mock the runtime env module so installUpdate() can't
early-return in dev mode; before importing "./auto-updater" add a mock for the
"main/env.main" module that exports an env (or NODE_ENV) value that is not
"development" (e.g., "test" or "production"), ensuring installUpdate and
quitAndInstall branches are exercised; update the test setup where
mock.module(...) calls are made so the mock for main/env.main appears before the
import of installUpdate and AUTO_UPDATE_STATUS.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/desktop/src/main/lib/auto-updater.test.ts`:
- Around line 42-52: The test currently calls setupAutoUpdater() inside
beforeEach which re-registers autoUpdater.on listeners and creates new
setInterval timers each run; instead, move the setupAutoUpdater() invocation to
a beforeAll so the module-level listeners and interval are created once, and
keep beforeEach to reset state by calling fakeAutoUpdater.removeAllListeners(),
fakeAutoUpdater.quitAndInstall.mockClear(),
fakeAutoUpdater.checkForUpdates.mockClear(),
fakeAutoUpdater.setFeedURL.mockClear(), and re-emitting the reset error with
fakeAutoUpdater.emit("error", new Error("reset")) to clear isInstalling; ensure
tests still call any per-test re-registration if needed after
removeAllListeners().
- Around line 16-40: The test must explicitly mock the runtime env module so
installUpdate() can't early-return in dev mode; before importing
"./auto-updater" add a mock for the "main/env.main" module that exports an env
(or NODE_ENV) value that is not "development" (e.g., "test" or "production"),
ensuring installUpdate and quitAndInstall branches are exercised; update the
test setup where mock.module(...) calls are made so the mock for main/env.main
appears before the import of installUpdate and AUTO_UPDATE_STATUS.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ebb40bc5-4957-40c8-9b57-d08eb637901a

📥 Commits

Reviewing files that changed from the base of the PR and between 867ef87 and 9d8f6d7.

📒 Files selected for processing (2)
  • apps/desktop/src/main/lib/auto-updater.test.ts
  • apps/desktop/src/main/lib/auto-updater.ts

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

No issues found across 2 files

MacUpdater.quitAndInstall() registers a fresh native-updater
`update-downloaded` listener each call when Squirrel.Mac hasn't finished
staging. Repeat clicks on the update button stacked listeners, then fanned
out into parallel nativeUpdater.quitAndInstall() calls once Squirrel
fired — racing to swap the binary and leaving the app on the old version.
Matches the reporter's symptom (app quits, reopens on same version).

Add an `isInstalling` guard + `status === READY` precondition so repeat
clicks collapse to a single quitAndInstall, and clear the flag in the
error handler so the user can retry if Squirrel surfaces an error instead
of actually quitting.

Closes #3507
@Kitenite Kitenite force-pushed the fix/3507-auto-update branch from 9d8f6d7 to b51271a Compare April 18, 2026 06:18
@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 18, 2026

Greptile Summary

This PR fixes a macOS-specific race condition where repeated clicks on the "Update" button closed the app but left it running on the old version. The root cause was that MacUpdater.quitAndInstall() registers a new update-downloaded listener on the native autoUpdater each time it's called while Squirrel.Mac is still staging, causing N parallel nativeUpdater.quitAndInstall() calls to race when Squirrel eventually fires.

The fix adds two guards to installUpdate(): an isInstalling in-flight flag (collapsed to a single call) and a currentStatus === READY precondition (prevents premature calls). The error handler resets isInstalling so users can retry after a failed install.

Key changes:

  • auto-updater.ts: Adds isInstalling module-level flag, both guard checks, and error-handler reset — a minimal, targeted fix that directly mirrors the pattern used in t3code.
  • auto-updater.test.ts (new): Three tests covering "not ready ignores", "repeat-click collapse", and "error resets the guard". Logic is correct, but shared/constants (PLATFORM) and main/env.main are not mocked, making the test suite implicitly depend on the host OS being macOS or Linux.

Confidence Score: 4/5

Safe to merge — the implementation fix is correct and minimal; one P1 test portability issue should be resolved before expanding CI to Windows.

The production fix in auto-updater.ts is clean, well-commented, and directly addresses the root cause. The guards are ordered correctly and the error-handler reset enables retries. The only issue is in the test file: unguarded platform and env dependencies mean the test suite can silently or loudly fail on Windows runners. Since the bug is macOS-specific and current CI appears to be macOS/Linux, this is non-blocking today but should be fixed before adding any cross-platform CI coverage.

apps/desktop/src/main/lib/auto-updater.test.ts — needs mocks for shared/constants (PLATFORM) and optionally main/env.main to be platform-portable.

Important Files Changed

Filename Overview
apps/desktop/src/main/lib/auto-updater.ts Adds isInstalling guard and status === READY precondition to installUpdate(), resetting the flag in the error handler — cleanly fixes the macOS parallel-quitAndInstall race.
apps/desktop/src/main/lib/auto-updater.test.ts New test file covering three key scenarios; correct assertions, but tests implicitly require a non-Windows platform due to unguarded IS_AUTO_UPDATE_PLATFORM usage in setupAutoUpdater().

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[installUpdate called] --> B{NODE_ENV === development?}
    B -- Yes --> C[Log: skipped in dev mode\nemitStatus IDLE\nreturn]
    B -- No --> D{isInstalling === true?}
    D -- Yes --> E[Log: duplicate ignored\nreturn]
    D -- No --> F{currentStatus === READY?}
    F -- No --> G[Log: update not ready\nreturn]
    F -- Yes --> H[isInstalling = true]
    H --> I[setSkipQuitConfirmation]
    I --> J[autoUpdater.quitAndInstall false, true]
    J --> K{Squirrel outcome}
    K -- App quits --> L[Process exits — isInstalling moot]
    K -- Error fires --> M[error handler:\nisInstalling = false\nemitStatus ERROR]
    M --> N[User may retry]
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: apps/desktop/src/main/lib/auto-updater.test.ts
Line: 42-52

Comment:
**Tests silently depend on the runtime platform**

`setupAutoUpdater()` early-returns if `IS_AUTO_UPDATE_PLATFORM` is false (i.e. `process.platform !== 'darwin' && process.platform !== 'linux'`). `shared/constants` is not mocked in the test file, so `PLATFORM` is resolved from the actual host OS at module-load time.

On a Windows CI runner:
- `setupAutoUpdater()` exits before registering the `update-downloaded` and `error` handlers.
- `fakeAutoUpdater.emit("error", new Error("reset"))` becomes a no-op — `isInstalling` is never reset.
- `fakeAutoUpdater.emit("update-downloaded", ...)` becomes a no-op — `currentStatus` never reaches `READY`.
- Test 2 ("collapses repeat install clicks") asserts `quitAndInstall` was called once, but it's called zero times → **failure**.
- Test 3 ("clears the in-flight guard") has the same problem.

To make the tests portable, mock the platform dependency. One approach is to mock `shared/constants`:

```ts
mock.module("shared/constants", () => ({
  PLATFORM: { IS_MAC: true, IS_LINUX: false, IS_WINDOWS: false },
}));
```

Alternatively, mock `main/env.main` so `NODE_ENV` is always `"test"` regardless of the environment, since that early-return path also affects `installUpdate()` correctness in the tests.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(desktop): guard installUpdate agains..." | Re-trigger Greptile

Comment on lines +42 to +52
describe("installUpdate", () => {
beforeEach(() => {
fakeAutoUpdater.removeAllListeners();
fakeAutoUpdater.quitAndInstall.mockClear();
fakeAutoUpdater.checkForUpdates.mockClear();
fakeAutoUpdater.setFeedURL.mockClear();
autoUpdater.setupAutoUpdater();
// The module is a singleton; emit an error to reset isInstalling
// between tests (the error handler clears it).
fakeAutoUpdater.emit("error", new Error("reset"));
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Tests silently depend on the runtime platform

setupAutoUpdater() early-returns if IS_AUTO_UPDATE_PLATFORM is false (i.e. process.platform !== 'darwin' && process.platform !== 'linux'). shared/constants is not mocked in the test file, so PLATFORM is resolved from the actual host OS at module-load time.

On a Windows CI runner:

  • setupAutoUpdater() exits before registering the update-downloaded and error handlers.
  • fakeAutoUpdater.emit("error", new Error("reset")) becomes a no-op — isInstalling is never reset.
  • fakeAutoUpdater.emit("update-downloaded", ...) becomes a no-op — currentStatus never reaches READY.
  • Test 2 ("collapses repeat install clicks") asserts quitAndInstall was called once, but it's called zero times → failure.
  • Test 3 ("clears the in-flight guard") has the same problem.

To make the tests portable, mock the platform dependency. One approach is to mock shared/constants:

mock.module("shared/constants", () => ({
  PLATFORM: { IS_MAC: true, IS_LINUX: false, IS_WINDOWS: false },
}));

Alternatively, mock main/env.main so NODE_ENV is always "test" regardless of the environment, since that early-return path also affects installUpdate() correctness in the tests.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/main/lib/auto-updater.test.ts
Line: 42-52

Comment:
**Tests silently depend on the runtime platform**

`setupAutoUpdater()` early-returns if `IS_AUTO_UPDATE_PLATFORM` is false (i.e. `process.platform !== 'darwin' && process.platform !== 'linux'`). `shared/constants` is not mocked in the test file, so `PLATFORM` is resolved from the actual host OS at module-load time.

On a Windows CI runner:
- `setupAutoUpdater()` exits before registering the `update-downloaded` and `error` handlers.
- `fakeAutoUpdater.emit("error", new Error("reset"))` becomes a no-op — `isInstalling` is never reset.
- `fakeAutoUpdater.emit("update-downloaded", ...)` becomes a no-op — `currentStatus` never reaches `READY`.
- Test 2 ("collapses repeat install clicks") asserts `quitAndInstall` was called once, but it's called zero times → **failure**.
- Test 3 ("clears the in-flight guard") has the same problem.

To make the tests portable, mock the platform dependency. One approach is to mock `shared/constants`:

```ts
mock.module("shared/constants", () => ({
  PLATFORM: { IS_MAC: true, IS_LINUX: false, IS_WINDOWS: false },
}));
```

Alternatively, mock `main/env.main` so `NODE_ENV` is always `"test"` regardless of the environment, since that early-return path also affects `installUpdate()` correctness in the tests.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/src/main/lib/auto-updater.test.ts`:
- Around line 49-51: The test currently resets the singleton by calling
fakeAutoUpdater.emit("error", new Error("reset")), which triggers the production
error handler (setting status to ERROR and calling clearCachedUpdate); instead,
add and use a non-destructive test reset: either (A) expose a guarded test-only
helper on the AutoUpdater module (e.g., AutoUpdater._testReset or a
resetForTests() function in auto-updater.ts) that clears isInstalling and any
test-only state without invoking the real error handler, or (B) emit a synthetic
error shaped like a recoverable network error that isNetworkError(...) will
treat as non-fatal so the handler maps status back to IDLE; update the test to
call that helper or emit the synthetic network error rather than emitting a
plain Error("reset").
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 70405fdc-3343-4dfc-a07c-7e44ae734ada

📥 Commits

Reviewing files that changed from the base of the PR and between 9d8f6d7 and b51271a.

📒 Files selected for processing (2)
  • apps/desktop/src/main/lib/auto-updater.test.ts
  • apps/desktop/src/main/lib/auto-updater.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/desktop/src/main/lib/auto-updater.ts

Comment thread apps/desktop/src/main/lib/auto-updater.test.ts Outdated
Greptile flagged that setupAutoUpdater() short-circuits on non-mac/linux
hosts, so the suite would silently fail on a Windows CI runner (handlers
never register, guard never resets). Mock shared/constants to pin the
platform.

CodeRabbit flagged that the beforeEach reset emitted a non-network error,
triggering the real ERROR path (which also clears the cached update).
Use a network-shaped error so the handler maps back to IDLE without the
extra side effect.
@Kitenite Kitenite merged commit 92b6701 into main Apr 18, 2026
7 checks passed
@Kitenite Kitenite deleted the fix/3507-auto-update branch April 18, 2026 06:40
@github-actions
Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch
  • ⚠️ Electric Fly.io app

Thank you for your contribution! 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] Update didn't work for Mac client

1 participant