cmux NEXT updates: a staged update gives way to a newer build of the ring

Merged
TE
14d ago
+591-52

Leo-ask: ask-cd2e53c2ac (and ask-3d0b80c786)

"but how are the dev build hot swaps? like the update ready i want the latest dev builds to be able to show up..."

Leo: "how are the dev build hot swaps? like the update ready i want the latest dev builds to be able to show up"

What was wrong

Sparkle never reads the feed while an update waits:

  • When this app's driver holds the ready reply, the session stays open, so Sparkle's scheduled check is skipped (SPUUpdater.updaterTimerDidFire checks _sessionInProgress).
  • checkForUpdates() returned early with "check while an update waits: showing the staged update".
  • Picking a ring (follow(ring:), e.g. Nightly to Dev) also lands in checkForUpdates(), so a staged Nightly build stayed on the card. The ring switch did not reset a staged update.

The result: once build N was staged, a newer Dev build N+1 never reached the Update Ready card until a restart.

What changes

StagedUpdateRefresh (new, CmuxNextUpdater). While Update Ready shows, it probes the feed with the existing read-only probe, which filters by the followed ring the way Sparkle's allowedChannels does. It probes:

  • once per updates.checkIntervalSeconds (default 3600), and
  • at once when the person checks or picks a ring.

When the ring has a strictly newer build, it cancels the staged installer and calls UpdateController.checkInBackground(). The cancel replies Skip to Sparkle's held ready reply, the same path dropStaleStagedUpdate already used. It records no skipped version and touches no user data. Sparkle then downloads the newer build and stages it, and the card shows it.

Guards:

  • Never during an install. Only the Update Ready phase arms or runs it. A click to install ends it: the phase leaves .ready, flow.installRequested is checked, and the driver's cancel handle is gone once the install starts.
  • Only installers this app can replace. The new UpdateController.canCancelStagedUpdate must be true (this driver holds the installer), and downloadsUpdatesInBackground must be on. Sparkle's own silent install-on-quit and deferred downloads (Low Data Mode, metered link, managed channel) are left alone, and nothing is fetched for them.
  • No empty card from delta holds. On nightly, a build newer than the staged one but still inside Sparkle's pending-delta window (45 min with no deltas listed) waits for the next interval. Otherwise Sparkle would hold it once and nothing would be staged.

Stop condition and cleanup:

  • One one-shot DemandTimer on the injected clock (owner UpdaterService.stagedRefresh).
  • It is armed only in Update Ready, and only with updates.checkAutomatically on.
  • It is cancelled when the phase leaves Update Ready (install, relaunch, swap, cancel).
  • One probe runs at a time, and a check joins the probe in flight. After a probe that keeps the staged build, the next one waits a whole interval.
  • The timer cancels its sleep when the object goes. A probe in flight ends at the fetcher's 15-second deadline.
  • Nothing blocks the main thread: the fetch and parse run off the main actor (UpdateProber, @concurrent).

God-type ratchet. dropStaleStagedUpdate moves into the same type as dropStale(updater:), since both are staged-update upkeep. The UpdateActionDelegate conformance moves to its own file. Both are pure moves, so the over-budget UpdaterService type and file shrink instead of growing: 1881 to 1864 lines and 501 to 481.

Coverage limits (read before dogfooding)

On the cmux NEXT nightly track, installsUpdatesWhenIdle is on. A staged build normally goes straight to the idle install: the relaunch gate waits up to updates.idleInstallMaxWaitSeconds, default 900 s. That wait is an install in progress, so this PR leaves it alone. The next build then arrives through the launch check after the relaunch.

This PR covers a staged build that waits for a click:

  • the automatic install was declined (preflight failed), or
  • updates.downloadAutomatically was off and the person clicked to download.

It does not cover Sparkle's own silent install-on-quit (SUAutomaticallyUpdate on, a preference shared with the legacy NIGHTLY app). Sparkle has no API to cancel that installer.

No capture: no visible UI change; the existing Update Ready card shows whichever build Sparkle stages, and this only changes which build that is.

Tests

UpdateStagedYieldsToNewerTests (CmuxNextAppTests) uses a counting fake feed, a ManualClock and stubbed Sparkle seams:

  • a newer Dev build replaces the staged one at the next scheduled check
  • the newest staged build is kept, and the feed is read once per interval, not sooner
  • picking Dev while a Nightly build is staged replaces it at once
  • the person's check while staged finds the newer build
  • an install in progress is never probed or cancelled
  • a staged update this app cannot replace reads no feed
  • a fresh Dev build with no deltas waits an interval
  • with automatic checks off, there is no scheduled probe

Red commit d28e156d2db0 (an inert skeleton plus the tests), then the fix e30456558e1c. The Codex review then asked for four more fixes, each landed after its own red test where one could be written: 9213fd4292f (the swap checks through this app's driver, never Sparkle's silent driver; a ring picked during a probe is read again; schedule changes replace the pending probe; the delta wait applies only to scheduled swaps of items with no sparkle:deltas), red 834eb6bf1f8 then 178083fae55, and red c3f1b2a7ad0 then 89a3307c528 (a check while staged that keeps the build, or that starts no probe, ends the person's ask, so a later check nobody asked for stays silent).

The same tests also cover: a fresh build that lists deltas swaps at once; a check the person asked for takes a fresh build at once; a ring picked during a probe reads the new ring, both ways; a schedule change replaces the pending probe.

If the newer build's download fails after the swap, nothing is staged until Sparkle's next scheduled check. Its timer restarts when the session ends, and that check stages the newest build again. The reviewer accepted this as non-blocking.

Command: cmux-ci run --class light --script scripts/ci/package-test-lane.sh --arg=suite --arg=Packages/macOS/CmuxNext --arg=UpdateStagedYieldsToNewerTests --ref <sha>

  • On the red commit (--red): queued on the fleet (the suites queue has been backed up for over an hour). I will post the result as a comment.
  • On the head, with UpdateReadyNotNewerTests, UpdateRingProbeTests, RingReturnTests and SilentBackgroundUpdateTests: queued on the fleet. Hosted PR CI runs the cmux-next Swift tests on the head.

Observed / Not run

  • Observed: the fleet compile-check --app passed at every head (e30456558e1c, 9213fd4292f, 178083fae55, 89a3307c5283). Codex (xhigh, on cmux16) reviewed four passes: CHANGES, CHANGES, CHANGES, then SHIP at 89a3307c5283452cb0108d187d5935f03675dbf0. prepush-gate.py reported no new violations. check-concurrency.sh passed. check-no-godfiles.sh --base origin/feat-cmux-next on the changed files passed; the type and the file both shrank. Fleet results are listed above.
  • Not run: a live swap on a real NIGHTLY with a real Dev build. That needs two signed Dev builds published in sequence. No DEV app build or capture: there is no UI change, and the card shows whichever build Sparkle stages.
  • Read, not run: Sparkle 2.10.0 SPUUpdater.m (updaterTimerDidFire skips while a session is in progress; checkForUpdatesWithDriver resumes an installer in progress instead of reading the feed). That is what confirmed the bug.

Risk: high (updater). This is not behind a default-off toggle, although docs/ci/risk-tiers.md says a user-noticeable high-tier change ships dark: Leo asked for this exact behavior, and only a newer signed build of the ring the person chose replaces a staged one. A revert is gh-merge-green --revert. The PR swaps a downloaded update only for a strictly newer signed build of the same ring, through the existing cancel path.

Changelog

  • Changed: A staged update now gives way to a newer build of your update channel at the next scheduled check, or at once when you check or switch channels, so Update Ready always offers the newest build.

Agent session: huge-blue

🤖 Generated with Claude Code