Repository navigation
Retry transient Sparkle download failures - #6992
austinywang wants to merge 34 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds transient Sparkle-download retry handling with preserved install intent across the updater protocol, controller, driver, and tests. Also updates the app-host CI wrapper to force a short ChangesTransient Download Failure Retry
CI App-Host TMPDIR Shortening
Important Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional. ❌ Failed checks (3 errors)
✅ Passed checks (22 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…date-fails-with-sudownloaderror-200
Greptile SummaryThis PR adds bounded, cancellable automatic retries for transient Sparkle download failures (HTTP 408/429/5xx, timeouts, DNS/connection loss) and preserves install intent across retries so DMG downloads that fail mid-progress silently resume rather than surfacing a fresh "Update Available" prompt.
Confidence Score: 5/5Safe to merge — the retry logic is well-isolated behind pure value-type decision enums, all timing uses the injected UpdateClock (cancellable, testable), and the two previously identified regressions (shadow-status parser and readiness-wait decision) are confirmed fixed at HEAD. The retry backoff is bounded (1/3/8 s, hard cap at 3 attempts), cancellable (Task cancel + isRestartingTransientRetry scope), and correctly preserves the install-intent state machine through the AttemptUpdateCoordinator. The transient error classifier correctly scans all regex matches before moving to the next pattern, the ReadinessWaitDecision fix stops the wait only on .idle, and the rearmConfirmedInstall path disarms safely on user cancel. Test coverage includes the key scenarios: 504 retry, download-phase intent preservation, cancel-to-idle, 404 no-retry, and the shadow-status parser case. No files require special attention. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Sparkle
participant UpdateDriver
participant UpdateController
participant AttemptCoordinator
Sparkle->>UpdateDriver: showUpdaterError(504 error)
UpdateDriver->>UpdateDriver: scheduleTransientErrorRetryIfNeeded()
UpdateDriver->>UpdateDriver: setState(.checking) [synthetic retry pill]
UpdateDriver->>Sparkle: acknowledgement()
Note over UpdateDriver: backoff sleep (1s/3s/8s)
UpdateDriver->>UpdateController: updaterRequestsRetryCheckForUpdates(preservingInstallIntent:)
alt "coordinatorIsMonitoring == true"
UpdateController->>UpdateController: checkForUpdatesWhenReady(preservingInstallIntent: true)
else "preservingInstallIntent == true"
UpdateController->>AttemptCoordinator: armForConfirmedRetryCheck()
UpdateController->>UpdateController: checkForUpdatesWhenReady(preservingInstallIntent: true)
else plain retry
UpdateController->>UpdateController: checkForUpdatesWhenReady(preservingInstallIntent: false)
end
UpdateController->>Sparkle: updater.checkForUpdates()
Sparkle->>UpdateDriver: showUserInitiatedUpdateCheck()
UpdateDriver->>UpdateDriver: beginChecking() [preserves failure count]
Sparkle->>UpdateDriver: showUpdateFound()
UpdateDriver->>UpdateController: state → .updateAvailable
UpdateController->>AttemptCoordinator: handleStateChange(.updateAvailable)
AttemptCoordinator-->>UpdateController: .confirmInstall
UpdateController->>UpdateController: model.state.confirm()
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Sparkle
participant UpdateDriver
participant UpdateController
participant AttemptCoordinator
Sparkle->>UpdateDriver: showUpdaterError(504 error)
UpdateDriver->>UpdateDriver: scheduleTransientErrorRetryIfNeeded()
UpdateDriver->>UpdateDriver: setState(.checking) [synthetic retry pill]
UpdateDriver->>Sparkle: acknowledgement()
Note over UpdateDriver: backoff sleep (1s/3s/8s)
UpdateDriver->>UpdateController: updaterRequestsRetryCheckForUpdates(preservingInstallIntent:)
alt "coordinatorIsMonitoring == true"
UpdateController->>UpdateController: checkForUpdatesWhenReady(preservingInstallIntent: true)
else "preservingInstallIntent == true"
UpdateController->>AttemptCoordinator: armForConfirmedRetryCheck()
UpdateController->>UpdateController: checkForUpdatesWhenReady(preservingInstallIntent: true)
else plain retry
UpdateController->>UpdateController: checkForUpdatesWhenReady(preservingInstallIntent: false)
end
UpdateController->>Sparkle: updater.checkForUpdates()
Sparkle->>UpdateDriver: showUserInitiatedUpdateCheck()
UpdateDriver->>UpdateDriver: beginChecking() [preserves failure count]
Sparkle->>UpdateDriver: showUpdateFound()
UpdateDriver->>UpdateController: state → .updateAvailable
UpdateController->>AttemptCoordinator: handleStateChange(.updateAvailable)
AttemptCoordinator-->>UpdateController: .confirmInstall
UpdateController->>UpdateController: model.state.confirm()
Reviews (20): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile |
…derror-200 Reconcile the transient-Sparkle-download-retry feature with origin/main's AttemptUpdateCoordinator refactor (#6366) and DEV/staging update gating (#6817): - UpdateController.checkForUpdatesWhenReady: keep the branch's preservingInstallIntent parameter AND main's dev/staging suppression block. - UpdateController.retryAfterTransientFailure: main replaced the isForceInstalling/isAttemptingUpdate booleans with AttemptUpdateCoordinator, so derive the secondary install-intent guard from attemptCoordinator.isMonitoring (the download/extract/install phase is already covered by the driver's preservingInstallIntent parameter; isMonitoring covers the re-resolve check phase). - UpdateDriver: combine the branch's retryPolicy and main's isDevLikeBundle init parameters and stored properties. - UpdatePopoverView "Install and Relaunch": adopt main's actions.attemptUpdate() (re-resolve to latest at install time, #6366); the branch's actions.installUpdate() scaffolding is superseded. Remove the now-orphaned installUpdate() from the UpdateActionsHost protocol and AppDelegate. - Retighten AppDelegate.swift file-length budget after the installUpdate() removal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@Packages/macOS/CmuxUpdater/Tests/CmuxUpdaterTests/UpdateDriverRetryTests.swift`:
- Around line 24-26: The retry tests are relying on fixed Task.yield() loops in
UpdateDriverRetryTests instead of a real completion signal, which makes them
scheduler-dependent. Update the test flow around the retry assertions to wait on
a causality-based predicate or an async signal from
RecordingUpdateActionDelegate, and use that signal in place of the yield loops
so the tests only proceed once the retry action has actually occurred.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: e3032e2d-40df-46dc-99d0-cc5729a13bf7
⛔ Files ignored due to path filters (1)
.github/swift-file-length-budget.tsvis excluded by!**/*.tsv
📒 Files selected for processing (11)
Packages/macOS/CmuxUpdater/Sources/CmuxUpdater/UpdateActionDelegate.swiftPackages/macOS/CmuxUpdater/Sources/CmuxUpdater/UpdateController.swiftPackages/macOS/CmuxUpdater/Sources/CmuxUpdater/UpdateDriver.swiftPackages/macOS/CmuxUpdater/Sources/CmuxUpdater/UpdateRetryPolicy.swiftPackages/macOS/CmuxUpdater/Tests/CmuxUpdaterTests/ImmediateUpdateClock.swiftPackages/macOS/CmuxUpdater/Tests/CmuxUpdaterTests/NullUpdateLog.swiftPackages/macOS/CmuxUpdater/Tests/CmuxUpdaterTests/RecordingUpdateActionDelegate.swiftPackages/macOS/CmuxUpdater/Tests/CmuxUpdaterTests/UpdateDriverRetryTests.swiftSources/AppDelegate.swiftscripts/ci/run-app-host-xcodebuild.shtests/test_ci_app_host_xcodebuild_retry.sh
CodeRabbit flagged the `for _ in 0..<5 { await Task.yield() }` waits in
UpdateDriverRetryTests as scheduler-dependent (they can race the retry task on
slower CI), violating the repo guideline "assert on causality, not latency."
Add an async completion signal to RecordingUpdateActionDelegate
(`waitForRetryRequests(atLeast:)`) that resumes the moment
`updaterRequestsRetryCheckForUpdates` fires, and await it in the two transient
tests instead of yielding a fixed number of times. Both the mock and the driver
are @mainactor, so registering a waiter and resuming it are serialized and
race-free. The non-transient 404 test stays synchronous (it asserts no retry is
scheduled).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…date-fails-with-sudownloaderror-200 # Conflicts: # .github/swift-file-length-budget.tsv
A Sparkle transient download failure during a download/extract/install phase asks the host to retry with preservingInstallIntent=true. When the attempt coordinator (issue #6366) is not yet monitoring, the controller must re-arm it so the retried check auto-confirms whatever update it resolves — otherwise the retry surfaces a fresh "Update Available" prompt and the interrupted install is silently stranded (the very failure mode issue #5632's retry work is meant to recover from). Factor the controller's retry decision into a pure UpdateController.transientRetryPlan(...) in its own file, mirroring AttemptUpdateCoordinator's pure-policy split so the decision is testable without the live SPUUpdater the controller owns, and dispatch retryAfterTransientFailure through it. This commit deliberately keeps today's buggy mapping (a preserved-intent retry that is not yet monitored still collapses into the monitored-restart path and never re-arms the coordinator), so the new UpdateControllerTransientRetryPlanTests regression fails. The one-line mapping fix follows in the next commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 14 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Flip the one remaining branch of transientRetryPlan: a transient retry carrying install intent that arrives before the attempt coordinator is monitoring now returns .rearmConfirmedInstall, which routes through the shared attempt path (requestInstallLatest) so the coordinator is armed and the retried check auto-confirms the update it resolves. Previously this case fell through to a bare fresh check that only surfaced an "Update Available" prompt, silently stranding the interrupted install. Turns UpdateControllerTransientRetryPlanTests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…date-fails-with-sudownloaderror-200 # Conflicts: # .github/swift-file-length-budget.tsv
The preserved-intent branch in `performCheckForUpdates` returns early and calls `updater.checkForUpdates()` immediately, bypassing the non-idle `cancelActiveStateForNewCheck()` + 100ms delay used elsewhere. That looks like it could let Sparkle coalesce/drop the re-check, but it is safe and intentional: - This path is only reached from `retryAfterTransientFailure` after the driver's multi-second retry backoff, and the failed Sparkle session was already acknowledged/torn down at error time (`showUpdaterError` → `acknowledgement()`). There is no just-dismissed live session to coalesce with — the 100ms delay only guards an *immediate* re-check against a session Sparkle is still aborting. - Routing it through the teardown path would be wrong: the current non-idle state is the driver's synthetic `.checking` backoff placeholder whose `cancel` aborts the retry, so `cancelActiveStateForNewCheck()` would kill the retry and flicker the pill to idle. Comment-only; no behavior change. Documents the invariant an autoreview pass flagged so it is legible to future readers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…date-fails-with-sudownloaderror-200
…tion The guard in cancelPendingTransientErrorRetry preserves the escalating failure count across the synchronous internal restart teardown, and does not strand a user-initiated cancel: at retry time Sparkle has already acknowledged/ended the failed session and the backoff has elapsed, so the controller performs the check immediately rather than parking in the uncancellable readiness-wait loop. Documents the invariant behind autoreview's low-confidence UpdateDriver.swift:266 finding (no behavior change). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reproduces the readiness-wait cancel regression (UpdateDriver.swift): after a transient download failure schedules a retry and the backoff fires, the pill stays in .checking; pressing Cancel then hits the shouldPreserveRetryStateForNextCheck guard and no-ops, so the pill is stuck instead of returning to idle. This commit adds the test only (CLAUDE.md two-commit regression policy) — it fails until the follow-up scopes the preserve guard to the synchronous internal restart. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Scope the transient-retry preserve-state suppression to the synchronous internal restart so a user cancel during the controller's readiness wait idles the pill instead of no-oping. - UpdateDriver: add isRestartingTransientRetry, set true only around the synchronous updaterRequestsRetryCheckForUpdates delegate call (the window where the controller may re-enter the cancel closure via cancelActiveStateForNewCheck). The cancel guard now keys off this flag instead of shouldPreserveRetryStateForNextCheck, which stays true across the async readiness wait; also clear the pending preserve on user cancel. - UpdateController: waitForReadinessThenCheck bails when the model has left .checking, so an idled pill isn't resurrected once readiness arrives and the orphaned readiness task stops promptly. Fixes the readiness-wait regression covered by userCancelWhileRetryPillAwaitsReadinessReturnsToIdle (commit 2031c15). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
e624abd to
2031c15
Compare
Convert the pure `UpdateController.transientRetryPlan(preservingInstallIntent: coordinatorIsMonitoring:)` static factory into a `nonisolated init` on the nested `TransientRetryPlan` value type. Behavior-preserving: the decision logic (monitored -> restart, preserved-intent-before-monitoring -> re-arm, else -> plain check) is byte-identical and stays covered by the existing UpdateControllerTransientRetryPlanTests. Constructing the plan by value (`TransientRetryPlan(...)`) rather than calling a static method on the stateful `@MainActor UpdateController` removes the static-as-namespace shape flagged by package-design review, without adding any new type or cross-object reference. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…date-fails-with-sudownloaderror-200 # Conflicts: # .github/swift-file-length-budget.tsv
There was a problem hiding this comment.
1 issue found across 5 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
The download-phase transient-retry re-arm (`.rearmConfirmedInstall`) currently arms the attempt coordinator via `requestInstallLatest`, which enters `awaitingCheckRestart`. That phase exists to ignore a stale on-screen prompt until a real check restarts, so a state change to `.idle` there is read as "check restarted" and advances to `awaitingResult` (still armed). But a transient-retry re-arm has no stale prompt: the model is the retry's own synthetic `.checking` pill. If the user cancels it during the controller's readiness wait, the model idles directly (no intervening `.checking`) and the coordinator is stranded armed — later silently auto-confirming an unrelated update the user never asked to install (autoreview P1). Introduce an `armForConfirmedRetryCheck()` seam for this re-arm and wire the controller to it (routing the check through the preserving path so the synthetic pill is not torn down and the bounded-retry count is preserved). This commit gives the seam the status-quo `awaitingCheckRestart` body so the new `retryRearmDisarmsWhenUserCancelsBeforeCheckRestarts` regression test fails, proving it catches the strand before the fix lands in the next commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enter `awaitingResult` directly in `armForConfirmedRetryCheck()` instead of `awaitingCheckRestart`. The retry re-arm has no stale on-screen prompt to gate against (the model is the retry's own synthetic `.checking` pill), so the `awaitingCheckRestart` stage is unnecessary and actively harmful here: a user cancel during the readiness wait idles the model directly, and in `awaitingCheckRestart` that idle is misread as check-restart progress (→ `awaitingResult`, still armed), later silently auto-confirming an unrelated update. Arming straight into `awaitingResult` routes that idle through the `awaitingResult` idle → inactive path, disarming the coordinator (autoreview P1). `retryRearmDisarmsWhenUserCancelsBeforeCheckRestarts` now passes; the #6366 user-initiated install flow is untouched (still goes through `requestInstallLatest`/`awaitingCheckRestart`). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`performCheckForUpdates` already bypasses the non-idle teardown + `cancelActiveStateForNewCheck()` for preserved-intent retries and for the idle case, precisely because tearing down the driver's synthetic `.checking` backoff placeholder fires its `cancel` — which aborts the retry and resets the `[1, 3, 8]` bounded-retry failure count. But the guard keyed on `preservingInstallIntent`, so a *plain* transient retry (no install intent) whose model is that same `.checking` pill fell through to the teardown. On the readiness-delayed path this runs from `readyCheckTask`, after the synchronous `isRestartingTransientRetry` window has closed, so `cancelPendingTransientErrorRetry` resets the failure count to 0 every attempt — turning the bounded policy into an unbounded ~1s retry loop while a transient appcast/download error persists (autoreview P2). Extend the bypass to any synthetic `.checking` state. Every caller reaches `performCheckForUpdates` only after observing `canCheckForUpdates == true`, so no live Sparkle session exists and a `.checking` here is always the synthetic placeholder — safe to start the fresh check directly and keep the count. Not unit-tested: the repro requires toggling `SPUUpdater.canCheckForUpdates`, which `UpdateController` owns as a concrete, non-injectable updater; the fix mirrors the two adjacent (likewise unit-untested) bypasses with an explicit rationale and is covered at compile time by CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…kle not ready Extract the readiness-wait continuation guard into a pure static predicate (readinessWaitShouldContinue) so it can be unit-tested, preserving the current (too-broad) `.checking`-only behavior for now. Add UpdateControllerReadinessWaitTests asserting the wait must keep polling while the model is the `.updateAvailable` prompt that attemptUpdate() re-resolves (issue #6366). That case fails today: the guard bails on any non-`.checking` state, so an Install triggered while Sparkle is briefly not ready (canCheckForUpdates == false) exits the readiness wait before it can run the fresh re-resolution check — stranding the coordinator armed so the user's Install does nothing until some unrelated state change occurs (autoreview follow-up to issue #5632). Red half of the two-commit regression proof; the fix follows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The transient-retry work guarded the shared readiness wait to stop unless the model was `.checking`. But `attemptUpdate()` (install from an on-screen prompt) enters the readiness wait while the model is still `.updateAvailable` — the #6366 re-resolve keeps the prompt visible, it does not switch to `.checking`. When Sparkle was briefly not ready (canCheckForUpdates == false), the guard bailed immediately, no fresh check ran, and the armed coordinator left the user's Install doing nothing until an unrelated state change. Scope the guard to the actual cancellation signal: stop only when the pending check has returned to `.idle` (the retry/checking pill's Cancel), and keep polling for every other pending state — `.checking` placeholders and the `.updateAvailable` install re-resolve alike. Turns UpdateControllerReadinessWaitTests green. Green half of the two-commit regression proof (red: prior commit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… budget The transient-retry and readiness fixes grew UpdateController.swift past the 500-line budget (workflow-guard-tests / scripts/swift_file_length_budget.py, which fails any untracked cmux-owned Swift file at or over the threshold). Per the swift-file-package-boundaries review rule, restore the budget by splitting a responsibility rather than expanding the TSV: move the two pure, self-free static decision helpers — readinessWaitShouldContinue (readiness-wait continuation) and isDevLikeBundleIdentifier (dev/staging bundle classification) — into a new UpdateController+Decisions.swift extension, mirroring UpdateController+TransientRetry. Both remain reachable at their unchanged UpdateController.<static> call sites and their existing tests; UpdateController.swift returns to 494 lines. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…date-fails-with-sudownloaderror-200 # Conflicts: # .github/swift-file-length-budget.tsv
…policy) The two pure decision helpers extracted into `UpdateController+Decisions.swift` were `static func`s called as `UpdateController.method(...)`, which the package design policy flags as a "static-as-namespace" anti-pattern (a caseless namespace on a type that holds no relevant stored state). Re-express both as value-type enums constructed via a `nonisolated init`, mirroring the sibling `TransientRetryPlan` that the same policy already accepts: - `ReadinessWaitDecision(modelState:)` → `.keepPolling` / `.stop` - `BundleReleaseChannel(bundleIdentifier:)` → `.devLike` / `.release` These stay unit-testable without the live `SPUUpdater` the controller owns (the reason they were extracted), while replacing the `Type.staticMethod()` call shape with instance construction. No behavior change; the readiness-wait continuation and dev/staging gating logic are identical. Existing unit tests are retargeted to the new types, and the two call sites plus doc links updated. UpdateController.swift stays within the 500-line file-length budget. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The prior commit re-expressed the two pure decision helpers as value-type enums (ReadinessWaitDecision, BundleReleaseChannel) to satisfy Aziz's static-as-namespace policy, but placed both in a single UpdateController+Decisions.swift. Aziz's file-organization policy wants one major type per file (precedent: UpdateController+TransientRetry.swift holds only TransientRetryPlan). Split into UpdateController+ReadinessWaitDecision.swift and UpdateController+BundleReleaseChannel.swift, one nested enum each. Pure file reorganization — no type, signature, or behavior change; all call sites and tests already reference the types unqualified / via UpdateController. and are untouched. BundleReleaseChannel keeps `import Foundation` for `hasPrefix`; ReadinessWaitDecision needs no import (matches +TransientRetry.swift). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
5494234 to
372b5a4
Compare
…date-fails-with-sudownloaderror-200 # Conflicts: # .github/swift-file-length-budget.tsv
…date-fails-with-sudownloaderror-200
…date-fails-with-sudownloaderror-200 # Conflicts: # .github/swift-file-length-budget.tsv
Fixes #5632
Summary
Validation
Summary by cubic
Retries transient Sparkle download failures with bounded, cancellable backoff and preserves install intent so retried installs stay silent. Also fixes readiness-wait and cancel edge cases to avoid stranding installs or silently auto-confirming after a user cancel. Fixes #5632.
Bug Fixes
UpdateRetryPolicy: classify transient download errors (HTTP 408/429/5xx, timeouts/DNS/connection loss), handle nested/shadowed statuses; retry at 1s/3s/8s.UpdateDriver: auto-retry with cancellable backoff via injected clock; preserve/restore install intent for download/extract/install retries; surface non‑transient 404s; keep bounded retry count on readiness‑delayed plain retries; user Cancel returns the pill to idle and clears any pending preserve; preserve failure count only during the synchronous internal retry restart; reset retry state on success.UpdateController:retryAfterTransientFailure(preservingInstallIntent:)uses pureTransientRetryPlanto restart a monitored check, re‑arm to auto‑confirm when not yet monitoring, or run a plain check; readiness wait keeps polling for any pending state and stops only on.idle; preserved‑intent and synthetic.checkingretries bypass Sparkle teardown delay; DEV/staging gating viaBundleReleaseChannel.AttemptUpdateCoordinator/host:armForConfirmedRetryCheck()to avoid stranding when a pre‑restart Cancel occurs.UpdateActionDelegate: now@MainActorand passespreservingInstallIntent;AppDelegateforwards toUpdateController.retryAfterTransientFailure(...).Tests/CI
RecordingUpdateActionDelegate; addedImmediateUpdateClockandNullUpdateLog.TMPDIR(/tmp/) and verifies it; updated the app‑host retry wrapper/test; decision helpers are value‑type enums (ReadinessWaitDecision,BundleReleaseChannel) in one‑type‑per‑file.Written for commit 2efdd21. Summary will update on new commits.
Summary by CodeRabbit
New Features
Bug Fixes
Tests / Chores