cmux NEXT: a stale same-build update no longer installs on quit

Merged
TE
16d ago
+147-10

Follow-up to #22804; risk:high (updater), needs human approval.

Summary

#22804 hides the "Update Ready" card when the staged update is the running build, an older one, or one of unknown version. Hiding it left Sparkle's install session open:

  1. On quit, Sparkle still installed that same or older build. prepareForQuit only cancels a staged installer while the phase is .ready, and the phase was now hidden, so the app reinstalled itself.
  2. While that session ran, Check for Updates did nothing visible. The controller ignores a check while the model is .installing, Sparkle refuses one while sessionInProgress, and the phase stayed hidden.

Fix: UpdaterService.dropStaleStagedUpdate() runs at the top of syncFlowPhase() (observation, quit, install click and Check for Updates all call it). When the source phase is .ready and offersStagedUpdate says no, it calls cancelStaged(), which replies Skip to Sparkle's held ready prompt (UpdateDriver.cancelStaged). In Sparkle 2.9.5 that is finishInstallationWithResponse:Skip -> SPUInstallerDriver cancelUpdate, which cancels the installer and ends the session without recording a skipped version. The driver state goes to .idle at once, so a manual check that follows goes through, waiting for Sparkle's session to end if needed. A newer staged build is left alone.

Sparkle's own silent driver (willInstallUpdateOnQuit, quitInstallItem) hands the delegate only an install block, with no cancel. It only stages builds newer than the host's CFBundleVersion, so it cannot stage a stale one. There cancelStaged() does nothing.

Testing

Red (test commit alone, on a throwaway branch that also carries #22821's one-line palette compile fix, since base CmuxNext does not compile without it):

cmux-ci run --class light --script scripts/ci/package-test-lane.sh --ref 1c2989f0d9956e888964ef8b63bdfc3054e88cf1 --arg=suite --arg=Packages/macOS/CmuxNext --arg=UpdateReadyNotNewerTests --env CMUX_CI_XCODE_APP=/Applications/Xcode_26.6.app --red, step 1e511dc53023987ed7fd86b7:

✘ Test aStagedBuildThatIsNotNewerHasItsInstallerCancelled() recorded an issue at UpdateReadyNotNewerTests.swift:70:13: Expectation failed: (cancels.count → 0) > 0   (builds 100 and 99)
✘ Test anUnknownStagedUpdateHasItsInstallerCancelled() recorded an issue at UpdateReadyNotNewerTests.swift:79:9: Expectation failed: (cancels.count → 0) > 0
✘ Test run with 8 tests in 1 suite failed after 0.025 seconds with 3 issues.

Green (the fix on top, b843877e011c4107fc805c2d227aa9231c6a7313, same command without --red), step 7a5468b0a37cf638f8147298: ✔ Test run with 8 tests in 1 suite passed after 0.061 seconds. That includes aNewerStagedBuildKeepsItsInstaller (a newer build is never cancelled, even after a check).

Observed / Not run

  • Observed: the red and green fleet runs above, for UpdateReadyNotNewerTests only. The prepush gate passed on both commits.
  • Not run: the full CmuxNext and CmuxUpdater suites. No live Sparkle install or relaunch: the test stubs cancelStaged. That Skip cancels the installer and ends the session I only read in Sparkle 2.9.5's source (SPUUIBasedUpdateDriver, SPUCoreBasedUpdateDriver, SPUInstallerDriver), and the existing cancellingAStagedUpdateRepliesSkipOnceAndClearsIt covers the driver side. I did not reproduce the stale session in a real app.
  • The PR branch does not carry the palette compile fix (#22821). Until that lands, CmuxNext on this branch does not compile, same as base.

Changelog

  • Fixed: cmux NEXT no longer reinstalls the build it is already running when it quits, and Check for Updates works again after a stale "Update Ready".

Review fix: never cancel a newer build of unknown version (aea9d7f0692 red, 684a4c3faf3 fix)

  • The driver now records the appcast item whenever it answers its own update-found prompt with Install (handlePromptReply), so an Install Update click with automatic downloads off (or a metered network) stages a known item instead of .ready(version: nil).
  • dropStaleStagedUpdate cancels only when the staged sparkle:version is known and not newer than CFBundleVersion. An unknown or display-version-only update stays hidden (as before) but keeps its installer.
  • cancelStagedUpdate() returns whether an installer was held; the "cancelling its installer" log line is written only then, so at most once per staged item.

Red, BackgroundInstallTests on aea9d7f0692 (fleet step 2c37c545eb9eea985c6e38d4): anInstallClickWithDownloadsOffStagesTheBuildItFound fails with stagedUpdate?.versionString → nil; the other 13 pass.
Green, BackgroundInstallTests, UpdateControllerPipelineTests, PromptReplyTests on 684a4c3faf3 (fleet step 0212567cb3b3d125e420a853): all 3 suites pass.

Observed / Not run

  • UpdateReadyNotNewerTests (CmuxNext) did not run on either SHA (steps 6bd57626c43390f81e4ed837, 9bc08775e3dd902b14f4cd89). CmuxNext fails to compile on base: CmuxNextSidebar/Views/Priority/SidebarPriorityRowView.swift:12 declares private let tag, which overrides NSView.tag (from #22778, still on feat-cmux-next 4ec528a1b75). The CmuxNext changes here (aStagedUpdateWithNoBuildNumberKeepsItsInstaller, theCancelIsLoggedOnlyWhenAnInstallerWasHeld) have been read but not executed.

Agent session: cosmic-gray

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


Summary by cubic

Fixes cmux NEXT reinstalling the build it is already running (or an older one) when the user quits, and makes Check for Updates respond again, by cancelling the installer of a stale staged update instead of only hiding its "Update Ready" card (follow-up to #22804).

When a staged update is the running build or an older one, the "Update Ready" card disappears. Previously that left Sparkle's install session open, so on quit the app installed that build again, and Check for Updates was ignored while the session ran.

Bug Fixes

  • A stale staged update's installer is now cancelled via Sparkle's Skip reply, which records no skipped version and ends the session so a later manual check works.
  • Only a known build number at or below CFBundleVersion proves staleness; unknown or display-version-only staged updates keep their installer.
  • The driver now records the appcast item whenever it replies Install, so an Install click with automatic downloads off never stages its download as an unknown update.
  • The cancel reports whether an installer was held, so the log line is written only for a real cancel, once per staged item.

Testing: only the UpdateReadyNotNewerTests suite and one BackgroundInstallTests case were run; the full CmuxNext and CmuxUpdater suites, and a live Sparkle install or relaunch, were not. The branch does not compile without the palette fix in #22821, same as base.

Leo-ask: ask-97f7ae2f2b