cmux NEXT updater: an install that left the old build running says why, instead of Restart to Update again
Leo asked (2026-10-21): "Find out why the newest nightly is not on his Mac. Is nightly publishing stuck, is the cmux-next appcast stale, or did Sparkle download a build and fail to install it on relaunch (check the Sparkle logs and ~/Library/Caches)? Fix the root cause in the pipeline or updater. If an installed update ever fails, the app must say why instead of offering Restart again."
Leo-ask: ask-24cefc7e85
What I found
- Publishing and the appcast are not stuck now. At 2026-11-01 17:43 UTC,
files-next.cmux.com/nightly-next/appcast-arm64.xmlhad a build from 17:26 andnightly-next/next/appcast-arm64.xmlhad one from 17:34. - The staging bugs found since the ask are fixed on base: #22804 (the running build is never offered), #22874 (a stale same-build update no longer installs on quit), #25129 (staged nightly updates can be replaced), #24183 (a staged update gives way to a newer one), #28074/#27499 (Restart to Update installs the newest build; pulled builds are dropped), #27732 (the appcast fails closed on a key mismatch), and the click-time preflight
UpdateInstallPreflight(folder not writable, no space, staged copy gone). - One part was still missing: an install that fails after the app quits. Sparkle's installer swaps the bundle only after cmux has quit. If that swap fails, Sparkle reopens the old build. The update stages again and the card shows "Update Ready / Restart to Update" as though nothing happened. No code compared the build after the relaunch with the build that was being installed. This PR adds that check.
Change
-
UpdateInstallCheck: just before Sparkle relaunches (updaterWillRelaunchApplication), the updater saves{fromBuild, targetBuild, recordedAt}in the app's own defaults. The next launch reads that record once. If the app is still onfromBuild, the install failed. If it is ontargetBuild, the install worked. A third build, a record older than a day, or a no-op install counts as neither. Every path where the install ends with the app still running clears the record (newabandonInstall()), so a later launch of the same build isn't reported as a failed install. -
UpdateInstallFailure.diagnose: on a failed install, the app checks for causes it can see on disk:- App Translocation (a read-only copy)
- a bundle or folder it can't write to
- not enough free disk space
If none of these applies, the card says the installer kept this version and gave no reason. Sparkle's installer leaves no error the app can read, so the app does not guess. The folder and space checks reuse the preflight's checks (
UpdateInstallPreflight.placeFailure, split out offailure()with no change in behavior). -
The staged update card, for the build whose install failed, now shows "Update Didn't Install", the one-line reason, and a Try Again button in place of "Restart to Update". The full reason and what to do are in the popover and in the button's help text. A newer staged build gets the normal card. While Try Again installs, the card shows Installing as before. The updater log records
install of X did not replace Y: <cause>. -
10 new strings, translated into all 21 locales (non-English marked
needs_review).
Tests
Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UpdateInstallCheckTests.swift: judging the record, reading it only once, the relaunch writing it and the next launch reading it as failed, a relaunch into the new build counting as success, an abandoned install leaving no record, the card text for a failure and while installing, the reason applying only to the failed build, and diagnose (translocated path, a writable folder, a read-only folder).
Observed / Not run
- Observed:
scripts/cmux-next/prepush-gate.pyreported no new violations.scripts/lint-ios-package-conventions.shpassed. The appcast reads quoted above. - Not run: the Swift compile and the suite. This lane VM has no Swift toolchain, so they run in this PR's CI. Not run: a real failed install in a DEV build (making the installer fail needs a read-only or translocated app on a Mac).
risk: high (updater path).
Changelog
- Fixed: when an update fails to install after cmux restarts, the update card now says why and offers Try Again instead of offering Restart to Update again.
No capture: a native card state that needs a failed real install; the strings and layout reuse the existing staged update card.
š¤ Generated with Claude Code
Agent session: agent session
![View with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png)
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
When Sparkle's installer fails to replace cmux after it quits, the app relaunches the old build and showed "Restart to Update" again as if nothing happened. The updater now records which build it installs over which before relaunch, checks the outcome on the next launch, and a failed install makes the update card say why and offer Try Again instead.
- Saves a from/target build record just before Sparkle relaunches and reads it once at launch; still running the old build means the install failed.
- Diagnoses the cause on disk: App Translocation, an unwritable bundle or folder, or not enough disk space; otherwise the card says the installer kept this version with no reason.
- Any path where the install ends with the app still running clears the record, so a later launch doesn't report a false failure.