cmux NEXT updater: Restart to Update installs the newest build; a pulled build is dropped (carries #27499)

Merged
TE
6d ago
+844-283

Carries #27499 and fixes the independent review's updater races. Restart to Update checks for a newer build before installing the staged one, even when downloads ask first. A pulled build is dropped; the replacement downloads with the same click.

Leo (2026-10-29): "those kinds of download/update things need to be more "latest" instead of me downloading whatever was the first one... but if there's multiple nightlies, i should only need to do one to get the latest"

Leo (2026-10-29), on the pulled-build banner: "what the f*** is this nightly was pulled, and why the f*** are the buttons like that? ... what the f*** does this nightly was pulled even fing mean? The rollback. I mean, why the f was it pulled? Like, why the f*** am I even getting a thing that I can download that I can just— that was pulled in the first place? Just f***ing cancel it, or, like, do something else about it. Just don't have that be the downloading thing."

Changes

  • The staged refresh separates the feed answer from a slow pulled-list read. A promptly found newer build replaces the staged build without waiting for that read.
  • Found pulled builds are refused before automatic download, before the offer, and before a waiting offer is accepted. Launch and appcast acceptance await the bounded pulled-list read.
  • A legacy Sparkle silent installer resumes through Sparkle's foreground cycle after its authoritative finish callback. That obtains a real Skip reply which cancels the installer; clearing only the app's model did not cancel it. Background mode disables new silent staging and owns a replaceable installer. Resumed legacy installers are cancelled, followed immediately by a fresh foreground check; Sparkle clears its skipped-version state on that user-initiated check. Ordinary quit cancellation only sees the later ready reply and does not skip a good version. Async offer reads have a generation invalidated on cancel, replacement, or session finish. Automatic pulled recovery honors the managed channel.
  • The card says “Getting the Newest Build…” while replacing an old build, adding “This build was pulled.” when that explains the running build's replacement. It has no rollback or wait buttons. The explicit Roll Back command remains.

Takes back #25725: no Updated-card behavior is removed. Its pulled-count test was retained while the obsolete pulled-banner tests were replaced.

Size: this is one updater state machine and its behavioral regressions, carried with the existing UI/tour/highlight. Keeping the refusal, install-replacement, silent-resume, and cancellation paths together avoids landing an intermediate unsafe installer state.

Verification

Command: cmux-ci run --class light --script scripts/ci/package-test-lane.sh --ref SHA --arg suite --arg Packages/macOS/CmuxUpdater --arg BackgroundInstallTests.

RefFleet stepResult
Test-only b7c48e63e7f7c61988fd7a517f08a8efbd5c76a063b01c347df909728686f07aRed: 10 assertions in the early-appcast and uncancellable-silent-installer regressions; 25 tests executed
Fixed 68f7344ed4c24606098b1b6e0ee851922f82588ca4b5b9d369c0a0ddcc03810dGreen: 25 tests passed

aManualCheckReplacesSparkleSilentInstall also failed at test-only f22a836937926760225087f0468eeddbe45abd2a (step 9c44f0db0846be3230bd683f) and passed at 78c530fd8e9afb06a18ff91bd2db2c9ae7fb10d6 (step 395bdb3c1d0f4148faf113c5). These tests use the real controller and driver callbacks, with a fake Sparkle transport only.

Controller verification at 3f8c1acbb67200055cb4794fcd45884e2fc30363 (the controller is unchanged at final 3c2cc970848d0f2fbac66c1ea10ed60069221da4): BackgroundInstallTests (26), UpdateControllerPipelineTests (17), and PromptReplyTests (12) pass, step 9838cd5bc1f746124f401760. Test-only 5d8512a26e4b6a08602c06af1c594a29add19425 failed the cancellation/resumption regressions, step 601d6baac854445dc92079cf. App suites at 3f8, step d40379134acb13f13fe1ada4: PulledBuildWatchTests (10), UpdateClickInstallsNewestTests (8), UpdateReadyNotNewerTests (9), UpdateInstallStatusTests (5) pass. UpdateStagedYieldsToNewerTests executes all 18 and has only the two named base assertion failures below. The previous 68f run (ec58c94ed3a15cc8c9f5dd48) matched. Managed-channel regression fails all three assertions at test-only 5d8512a, step 1fa353485d2be3e28c498362, and passes at 3f8. No test runner crashes.

Final head 3c2cc970848d0f2fbac66c1ea10ed60069221da4 passes all four app suites at fleet step 870129a9c1ca219dbd5e5edf: PulledBuildWatchTests (11), UpdateClickInstallsNewestTests (8), UpdateReadyNotNewerTests (9), UpdateInstallStatusTests (5), 33 total. Its new running-and-staged-pulled regression fails three assertions at test-only bdb530376f84928b24bd22bdcd0395207a09af76 (step 412bb2146829fa2e91534e8d) and passes at head. Both runs finish normally; the head compile is green.

Base comparison step 0b340c2d3a98bbef58a2733d at 483fbe33d4afd07641b7c3b423224c829458ea71 confirms two existing UpdateStagedYieldsToNewerTests assertion failures: theNewestStagedBuildIsKeptAndProbedOncePerInterval (fetches 2 vs 1), and aScheduleChangeReplacesThePendingProbe (fetches 1 vs 0). There was no crash. The same suite and errors occurred before this repair.

Observed / Not run

Observed: native tour update-newest at b7b96b003eddb4e70af4172f02d6f111a0f84aed, build 8bddab63ebf4b97035aa6529, launched and passed both updater states in light/dark. The pulled reason is one line and there are no buttons. Privacy check returned no findings. The later 68f tour also passed the same three steps at build bf0a1a708f94e5f337acc121; its uploader hit the known source-read broker incompatibility, so the published b7 stills below are retained. The later 3f8 tour, build 0bb75c4977a18cc41fbfb012, also passed launch and both states with no privacy findings. The final reason-selection repair preserves the same one-line state when both the running and staged build were pulled. Prepush gate and diff checks pass.

Not run: a live install against the production feed. The final repair selects the existing one-line running-build reason when both the running and staged build were pulled; it adds no new visual state.

Risk: high (updater). Existing download and restart settings remain; no new default is enabled. The async policy read is bounded by the existing 15-second network timeout per list, joins in-flight reads, and holds the Sparkle reply only until it completes. Silent-stage recovery starts only on Sparkle's finished-cycle callback. No polling or new timer.

Native capture

Viewed after image: yes
Visual verdict: The compact update card has a clear one-line reason and no distracting choices, matching the existing sidebar treatment.

Capture tour-update-newest at final head 3c2cc970848d (fleet build 6bdfe991afe20c48c9bb20bd, capture mini, text checked for private data). The final settled light/dark stills show the same one-line pulled reason and no buttons.

StateLightDark
Before (base DEBUG fixture, 6dd43cfa91ec)before lightbefore dark
After (final head 3c2cc970848d)after lightafter dark

Prior after stills from b7b96b003edd remain available: light and dark.

Changelog

Fixed: Restart to Update installs the newest build on the feed, and a pulled build is never offered.

Agent session: agent session