Skip to content

fix(desktop): stop a racing relaunch from aborting the update install#970

Closed
huntharo wants to merge 1 commit into
mainfrom
fix/desktop-update-install-relaunch-race
Closed

fix(desktop): stop a racing relaunch from aborting the update install#970
huntharo wants to merge 1 commit into
mainfrom
fix/desktop-update-install-relaunch-race

Conversation

@huntharo

@huntharo huntharo commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

  • On macOS a Squirrel/ShipIt bundle swap refuses to run while any instance of the target app is alive — it aborts with SQRLInstallerErrorDomain -9 "App Still Running Error". Reopening the app during the brief, feedback-less quit → install → relaunch gap (typically the user relaunching it because it looked stalled) strands the update in an abort/retry loop that only clears once the app happens to stay closed long enough.
  • Add a cross-boot handoff marker (update-handoff-marker.ts) written just before quitAndInstall(). On the next boot, if a swap is plausibly still in flight (fresh marker, still on the old version), the freshly launched instance exits immediately so ShipIt can finish and relaunch the updated build. The marker fails open — missing / corrupt / stale / already-on-target all boot normally — with a 5-minute TTL so an aborted install can never wedge the app closed.
  • Pass isForceRunAfter=true to quitAndInstall() so ShipIt relaunches the updated app after the swap. It was leaving the app closed, which is what prompted people to reopen it mid-install in the first place.

Verification

  • pnpm test on the 3 affected files — 60 passed (8 new: the full marker decision matrix, isForceRunAfter + marker assertions on the install path, and a boot-guard test proving no window opens while an install is mid-flight).
  • pnpm typecheck clean; pnpm lint:eslint — 0 errors, 0 findings on the changed files.
  • The failure and the fix direction were reproduced by hand during diagnosis (a live instance blocks ShipIt; forcing the ShipIt spawn after the app stays closed completes the swap). A real Squirrel swap can't be exercised in CI without a signed release, so the logic is covered by unit/integration tests rather than a live run.

Notes

  • The marker lives at ~/.pwragent/update-handoff.json — app-wide (not per-profile), since the update targets /Applications/PwrAgent.app regardless of profile.
  • Only the explicit "Restart to update" path (installDownloadedAppUpdate) sets isForceRunAfter=true; the autoInstallOnAppQuit path is untouched, so a deliberate quit-with-pending-update doesn't force the app back open.
  • Deliberately not adding a single-instance lock: the runtime-config contract guarantees multiple instances can share a profile safely (sqlite WAL), and dev + packaged builds are run together. A lock would break that.
  • Deliberately not adding a speculative launchctl kickstart of the ShipIt job: the launchd spawn stall observed pre-reboot cleared on reboot, and a kickstart adds shell-out/timing risk for a transient condition.

On macOS a Squirrel/ShipIt bundle swap refuses to run while any instance of
the target app is alive (SQRLInstallerErrorDomain -9 "App Still Running
Error"). When the app is reopened during the brief, feedback-less
quit -> install -> relaunch gap -- most often the user relaunching it because
it looked stalled -- that instance strands the update in an abort/retry loop
that only clears once the app happens to stay closed long enough.

Add a cross-boot handoff marker written just before quitAndInstall(). On the
next boot, if a swap is plausibly still in flight (fresh marker, still on the
old version), the freshly launched instance exits immediately so ShipIt can
finish and relaunch the updated build. The marker fails open -- missing,
corrupt, stale, or already-on-target all boot normally -- with a 5 minute TTL
so an aborted install can never wedge the app closed.

Also pass isForceRunAfter=true to quitAndInstall() so ShipIt relaunches the
updated app after the swap. It was leaving the app closed, which is what
prompted people to reopen it mid-install in the first place.

Deliberately not adding a single-instance lock (the app supports multiple
instances sharing a profile per the runtime-config contract) nor a speculative
ShipIt kickstart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@huntharo huntharo closed this Jul 21, 2026
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.

1 participant