Skip to content

Keep update pill polling current after first update - #3833

Merged
austinywang merged 5 commits into
mainfrom
issue-3829-update-pill-keep-polling
May 12, 2026
Merged

austinywang merged 5 commits into
mainfrom
issue-3829-update-pill-keep-polling

Conversation

@austinywang

@austinywang austinywang commented May 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • add a unit regression for repeated appcast emissions updating the visible update pill version
  • refresh active updateAvailable state when Sparkle reports a later valid update item
  • clear stale installable state when the detected update is cleared

Fixes #3829.

Verification

  • git diff --check
  • Swift tests intentionally not run locally per repo policy; CI is the verification path.

Note

Medium Risk
Moderate risk because it changes update state transitions and dismissal/cancel behavior (including Sparkle reply callbacks), which could affect install/dismiss flows if mis-handled.

Overview
Ensures the update pill reflects the latest valid Sparkle appcast emission (including rollbacks) by recording update metadata separately and routing .updateAvailable transitions through UpdateViewModel.recordAvailableUpdate so the visible version and associated reply stay current.

Tightens lifecycle handling around re-checks and “no update found”: checkForUpdates now cancels/reset state via cancelActiveStateForNewCheck(), and updaterDidNotFindUpdate calls dismissDetectedAvailableUpdate() to clear cached detection, reset UI back to .idle, and send a single .dismiss reply for any active/override .updateAvailable.

Adds UpdateViewModelLatestEmissionTests regression coverage for repeated emissions, rollback behavior, reply freshness when overrides are visible, and avoiding double-reply on cancel+dismiss.

Reviewed by Cursor Bugbot for commit 0ace395. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Keeps the update pill tied to the latest Sparkle appcast emission so newer polls update the visible version (when idle), rollbacks revert it, and rechecks avoid duplicate dismiss replies. Fixes #3829.

  • Bug Fixes
    • Route all .updateAvailable transitions through UpdateViewModel.recordAvailableUpdate (via UpdateDriver.applyState) to refresh visible and override state with the latest SUAppcastItem and reply.
    • Treat passive polls as metadata only: recordDetectedUpdate updates the pill when idle and never swaps an active installable update, keeping actions tied to the matching Sparkle reply.
    • On no update, dismissDetectedAvailableUpdate sends .dismiss once and resets detected/visible state to .idle.
    • Before rechecks, cancelActiveStateForNewCheck cancels the interactive/override state to prevent duplicate dismiss replies.

Written for commit 0ace395. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes

    • Update detection now records and preserves detected-update metadata so the UI shows the latest valid available update and avoids stale installable state.
    • Dismissing or failing update checks reliably clears cached detected/available update info and prevents stale prompts or duplicate replies.
    • Cancelling an active check prevents queued dismissals from triggering extra responses.
  • Tests

    • Added tests validating pill/version visibility, rollbacks, refresh/override behavior, dismissal clearing, and prevention of duplicate replies.

Review Change Stack

@vercel

vercel Bot commented May 10, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment May 12, 2026 0:32am
cmux-staging Building Building Preview, Comment May 12, 2026 0:32am

@coderabbitai

coderabbitai Bot commented May 10, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Route .updateAvailable emissions through UpdateViewModel.recordAvailableUpdate(_) so detected-update metadata is recorded and the visible available item can be replaced on subsequent polls; update driver, controller, delegate, test support, and tests accordingly.

Changes

Update Polling and Version Tracking

Layer / File(s) Summary
ViewModel detected metadata
Sources/Update/UpdateViewModel.swift
Add private recordDetectedUpdateMetadata(_:); recordDetectedUpdate(_:) delegates metadata recording to it.
ViewModel available & dismiss
Sources/Update/UpdateViewModel.swift
recordAvailableUpdate(_:) records detected metadata from the provided appcastItem before updating state/overrideState. dismissDetectedAvailableUpdate() clears cached detected fields then dismisses any .updateAvailable state.
Driver Routing
Sources/Update/UpdateDriver.swift
applyState(_:) routes .updateAvailable through viewModel.recordAvailableUpdate(update) instead of assigning viewModel.state.
Controller cancel
Sources/Update/UpdateController.swift
Use viewModel.cancelActiveStateForNewCheck() before scheduling updater.checkForUpdates() instead of viewModel.state.cancel().
Test Support
Sources/Update/UpdateTestSupport.swift
Mock feed path now calls viewModel.recordAvailableUpdate(...) when an <item> is present instead of setting .updateAvailable directly.
Delegate Dismiss
Sources/Update/UpdateDelegate.swift
updaterDidNotFindUpdate(_:error:) calls viewModel?.dismissDetectedAvailableUpdate() on the main thread instead of clearDetectedUpdate().
Tests / Imports
cmuxTests/UpdatePillReleaseVisibilityTests.swift
Add UpdateViewModelLatestEmissionTests (@MainActor), import Sparkle, relocate conditional @testable import, and add tests verifying pill follows latest emissions, handles rollbacks, and reply semantics.

Sequence Diagram(s)

sequenceDiagram
  participant UpdateDriver
  participant UpdateViewModel
  participant UpdateState
  UpdateDriver->>UpdateViewModel: recordAvailableUpdate(update)
  UpdateViewModel->>UpdateState: recordDetectedUpdateMetadata(update.appcastItem)
  UpdateViewModel->>UpdateState: set state to .updateAvailable(update)
  UpdateState-->>UpdateViewModel: publish effectiveState
  UpdateViewModel-->>Clients: update visible pill / replies
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 I munched appcast crumbs at dawn's light,
Hopped from old v to new in one quick bite,
Polled again, swapped stale for fresh,
Kept the pill showing the very best,
Carrots and updates—always up to date!

🚥 Pre-merge checks | ✅ 14 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (14 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: keeping the update pill polling current after the first update, which directly addresses the linked issue #3829.
Linked Issues check ✅ Passed All coding objectives from #3829 are met: polling continues after first update via recordAvailableUpdate routing, latest version refreshes the pill including rollbacks, and UpdateViewModelLatestEmissionTests provides regression coverage.
Out of Scope Changes check ✅ Passed All changes are scope-aligned: UpdateDriver/UpdateViewModel refactor route updates through recordAvailableUpdate, lifecycle methods call dismissDetectedAvailableUpdate/cancelActiveStateForNewCheck, and tests verify the polling-refresh behavior.
Cmux Swift Actor Isolation ✅ Passed All new public methods on UpdateViewModel are called exclusively from main-thread-dispatched contexts. No new shared mutable Sendable reference types or implicit MainActor issues introduced.
Cmux Swift Blocking Runtime ✅ Passed No new blocking/timing patterns introduced in production code. Pre-existing asyncAfter delay unchanged. Changes are method refactoring and test additions.
Cmux No Hacky Sleeps ✅ Passed Update feature changes are in Swift code, covered by swift-blocking-runtime rule. Non-Swift files are CI/infrastructure, not production app code subject to this check.
Cmux Swift Concurrency ✅ Passed PR introduces no legacy async patterns. New methods use direct property assignments. Pre-existing DispatchQueue patterns are at Sparkle/AppKit boundaries. Test uses @MainActor appropriately.
Cmux Swift @Concurrent ✅ Passed No async functions requiring @concurrent, invalid @concurrent usage, or actor isolation conflicts. All new methods are synchronous with proper main thread dispatch patterns.
Cmux Swift File And Package Boundaries ✅ Passed No oversized files added. UpdateViewModel +39 lines (622 total). Focused bug fix. Test file appropriate. No mixed responsibilities or boundary violations.
Cmux Swift Logging ✅ Passed PR complies with Swift logging rules. All production code uses UpdateLogStore. No forbidden patterns detected. No sensitive data exposure. Test file is exempt.
Cmux Swiftui State Layout ✅ Passed No new @Published, ObservableObject, @StateObject declarations added. All UpdateViewModel properties unchanged. No GeometryReader, LazyVStack/LazyHStack, or render-time mutations introduced.
Cmux Architecture Rethink ✅ Passed Consolidates transitions through recordAvailableUpdate(). No new timing, state owners, or side channels. Structural fix, not symptom patch. Clear invariant.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No auxiliary window code. Updates are limited to state management and control flow. No NSWindow, NSPanel, NSWindowController, or SwiftUI Window/WindowGroup creation.
Description check ✅ Passed The PR description covers the main changes (unit tests, state routing, dismissal logic) but omits the Demo Video section required by the template.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch issue-3829-update-pill-keep-polling

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps

greptile-apps Bot commented May 11, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes #3829 by ensuring the update pill always reflects the latest Sparkle appcast emission rather than staying locked to the first-seen value. The key mechanism is routing all .updateAvailable transitions through a new UpdateViewModel.recordAvailableUpdate method that atomically refreshes both state and overrideState, and replacing clearDetectedUpdate with dismissDetectedAvailableUpdate in the no-update delegate path so stale Sparkle reply callbacks are properly dismissed.

  • recordAvailableUpdate consolidates the previous two-write pattern into a single atomic update of state and overrideState, keeping both in sync with the latest SUAppcastItem and reply closure.
  • dismissDetectedAvailableUpdate (called on updaterDidNotFindUpdate) sends exactly one .dismiss reply before clearing cached detected-update metadata, preventing stale updateAvailable state from persisting across poll cycles.
  • cancelActiveStateForNewCheck replaces the bare state.cancel() call in UpdateController, additionally resetting overrideState to nil so a fresh check always starts from a clean visual state.

Confidence Score: 5/5

Safe to merge; all new state-transition methods are correct for every current production call path.

All three new methods behave correctly for every production call path; the new test suite covers the key emission, rollback, override-refresh, and dismissal scenarios.

No files require special attention; the only nuance is the implicit invariant in UpdateViewModel.dismissDetectedAvailableUpdate described in the inline comment.

Important Files Changed

Filename Overview
Sources/Update/UpdateViewModel.swift Adds recordAvailableUpdate, dismissDetectedAvailableUpdate, and cancelActiveStateForNewCheck; splits recordDetectedUpdateMetadata as a private helper. Core logic is correct for current callers; dismissDetectedAvailableUpdate relies on an unenforced invariant that state.reply === overrideState.reply when both are .updateAvailable.
Sources/Update/UpdateDriver.swift Routes .updateAvailable transitions through viewModel.recordAvailableUpdate in applyState; all other states use direct assignment. Change is minimal and correct.
Sources/Update/UpdateDelegate.swift Switches updaterDidNotFindUpdate from clearDetectedUpdate() to dismissDetectedAvailableUpdate() so any live .updateAvailable reply receives .dismiss before detected metadata is cleared. Change is correct.
Sources/Update/UpdateController.swift Replaces bare viewModel.state.cancel() with viewModel.cancelActiveStateForNewCheck() in performCheckForUpdates, which additionally resets state to .idle and nils overrideState. One-line change; correct behavior.
Sources/Update/UpdateTestSupport.swift Updates mock feed check to call viewModel.recordAvailableUpdate instead of direct state assignment, keeping test scaffolding aligned with production routing. No production behavior change.
cmuxTests/UpdatePillReleaseVisibilityTests.swift Adds UpdateViewModelLatestEmissionTests covering repeated emissions, rollback, override-state refresh, dismissal idempotency, and cancel-before-dismiss sequencing. Tests are @MainActor-isolated and cover the key state paths.

Sequence Diagram

sequenceDiagram
    participant Sparkle
    participant UpdateDriver
    participant UpdateViewModel
    participant UI

    Note over Sparkle,UI: Repeated background poll scenario (bug fix)
    Sparkle->>UpdateDriver: didFindValidUpdate(item v9.9.0)
    UpdateDriver->>UpdateViewModel: recordDetectedUpdate(v9.9.0)
    UpdateViewModel-->>UI: "detectedUpdateVersion = "9.9.0""

    Sparkle->>UpdateDriver: showUpdateFound(v9.9.0, reply1)
    UpdateDriver->>UpdateViewModel: recordAvailableUpdate(v9.9.0, reply1)
    UpdateViewModel-->>UI: "state = .updateAvailable(v9.9.0)"

    Note over Sparkle,UI: Second poll - newer version
    Sparkle->>UpdateDriver: showUpdateFound(v9.9.1, reply2)
    UpdateDriver->>UpdateViewModel: recordAvailableUpdate(v9.9.1, reply2)
    Note over UpdateViewModel: state and overrideState both updated to reply2
    UpdateViewModel-->>UI: pill shows v9.9.1

    Note over Sparkle,UI: No-update path (clears stale state)
    Sparkle->>UpdateDriver: updaterDidNotFindUpdate
    UpdateDriver->>UpdateViewModel: dismissDetectedAvailableUpdate()
    UpdateViewModel->>Sparkle: reply2(.dismiss) [once]
    UpdateViewModel-->>UI: "state = .idle, overrideState = nil"
Loading

Reviews (4): Last reviewed commit: "Prevent duplicate update dismiss replies..." | Re-trigger Greptile

Comment thread Sources/Update/UpdateViewModel.swift
Comment thread Sources/Update/UpdateViewModel.swift
Comment thread Sources/Update/UpdateViewModel.swift Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 `@Sources/Update/UpdateViewModel.swift`:
- Around line 38-40: When updating the available update payload in
recordAvailableUpdate(_:) you update state with the new reply closure but leave
overrideState’s UpdateAvailable case pointing at the old reply; change the
overrideState when it’s .updateAvailable so it not only replaces the item via
replacingAvailableUpdateItem(with:) but also updates the reply/handler to match
the newly assigned state's reply (i.e., construct a new UpdateAvailable value or
add a helper like replacingReply(...) so overrideState =
overrideState.replacingAvailableUpdateItem(with: item).replacingReply(with:
state.reply)). Apply the same synchronous replacement logic in the other
occurrence noted (lines 43–45) so overrideState’s callback is always kept in
sync with state.
🪄 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: f19038fc-fcc5-4105-98e3-beef089ab2c2

📥 Commits

Reviewing files that changed from the base of the PR and between 6c37266 and 8d16cf6.

📒 Files selected for processing (3)
  • Sources/Update/UpdateDriver.swift
  • Sources/Update/UpdateViewModel.swift
  • cmuxTests/UpdatePillReleaseVisibilityTests.swift

Comment thread Sources/Update/UpdateViewModel.swift Outdated
The update pill can be backed by an interactive update state and a background-detected update cache at the same time. This regression test models repeated appcast emissions so CI proves that the visible pill version is not allowed to remain pinned to the first update.

Constraint: Local tests are intentionally not run for this repo; verification is CI-only.

Rejected: XCUITest repro | the model-level ownership bug is lower-risk and faster to validate in the unit target.

Confidence: high

Scope-risk: narrow

Directive: Keep this test focused on visible model behavior rather than source-shape assertions.

Tested: git diff --check

Not-tested: Swift unit suite, per repo instruction to avoid local tests
@austinywang
austinywang force-pushed the issue-3829-update-pill-keep-polling branch from 8d16cf6 to 3d5ac2c Compare May 11, 2026 00:04
The visible update state and the background-detected update cache could diverge after the first available update. Route update-available transitions through the view model and refresh any active updateAvailable state whenever Sparkle reports a valid update item, so later polls replace the item the pill renders instead of being hidden behind the first state.

Constraint: The existing Sparkle probe cadence stays in UpdateController; the fix only changes update item ownership in the view model.

Rejected: Add a second polling loop | would duplicate Sparkle probing and leave the stale state owner split intact.

Rejected: Only remove an early return | no polling early return existed in cmux; the stale item lived in duplicated model state.

Confidence: high

Scope-risk: narrow

Directive: Future update UI should route valid appcast items through UpdateViewModel rather than assigning updateAvailable state directly.

Tested: git diff --check

Not-tested: Swift unit suite, per repo instruction to avoid local tests
@austinywang
austinywang force-pushed the issue-3829-update-pill-keep-polling branch from 3d5ac2c to d99b4eb Compare May 11, 2026 00:06

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
Sources/Update/UpdateViewModel.swift (1)

43-45: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Synchronize overrideState with the new reply closure.

When overrideState is already .updateAvailable, recordAvailableUpdate updates only the appcast item (via recordDetectedUpdate at lines 38-40) but leaves the old reply closure in place. This means effectiveState can route user actions to a stale Sparkle reply handler.

Suggested fix
 func recordAvailableUpdate(_ update: UpdateState.UpdateAvailable) {
     recordDetectedUpdate(update.appcastItem)
     state = .updateAvailable(update)
+    if let overrideState, case .updateAvailable = overrideState {
+        self.overrideState = .updateAvailable(update)
+    }
 }
🤖 Prompt for 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.

In `@Sources/Update/UpdateViewModel.swift` around lines 43 - 45,
recordAvailableUpdate(_:) currently updates the appcast item but doesn't update
overrideState's reply closure when overrideState is already .updateAvailable,
causing effectiveState to use a stale reply; modify recordAvailableUpdate(_
update: UpdateState.UpdateAvailable) so that after calling
recordDetectedUpdate(update.appcastItem) it also sets overrideState =
.updateAvailable(update) (or updates the existing overrideState's reply closure)
to ensure the new reply closure is used; reference overrideState,
recordAvailableUpdate(_:), recordDetectedUpdate(_:) and effectiveState when
making the change.
🤖 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.

Duplicate comments:
In `@Sources/Update/UpdateViewModel.swift`:
- Around line 43-45: recordAvailableUpdate(_:) currently updates the appcast
item but doesn't update overrideState's reply closure when overrideState is
already .updateAvailable, causing effectiveState to use a stale reply; modify
recordAvailableUpdate(_ update: UpdateState.UpdateAvailable) so that after
calling recordDetectedUpdate(update.appcastItem) it also sets overrideState =
.updateAvailable(update) (or updates the existing overrideState's reply closure)
to ensure the new reply closure is used; reference overrideState,
recordAvailableUpdate(_:), recordDetectedUpdate(_:) and effectiveState when
making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 496233dd-1efa-48a9-94b6-2b9104d28dda

📥 Commits

Reviewing files that changed from the base of the PR and between 8d16cf6 and 3d5ac2c.

📒 Files selected for processing (3)
  • Sources/Update/UpdateDriver.swift
  • Sources/Update/UpdateViewModel.swift
  • cmuxTests/UpdatePillReleaseVisibilityTests.swift

Passive appcast detection now records metadata only, while installable update transitions carry the full SUAppcastItem and reply closure through recordAvailableUpdate. This keeps visible update actions tied to the Sparkle callback that produced the installable state, including when an override pill is already visible.

Constraint: Do not run local tests; CI owns test execution for this repo.

Rejected: Replace only the appcast item inside existing updateAvailable state | it preserves stale reply closures and can route Install/Dismiss to the wrong Sparkle callback.

Confidence: high

Scope-risk: narrow

Directive: Do not refresh UpdateAvailable display data without refreshing the matching reply closure from the same Sparkle emission.

Tested: git diff --check

Not-tested: Local XCTest execution per repository policy
The PR branch needs the latest mainline changes before CI can prove the update-pill fix against the current tree. The merge was conflict-free, so the update-state changes remain isolated while CI reruns on the actual merge candidate.

Constraint: Required PR iteration against origin/main before CI handoff.

Confidence: high

Scope-risk: moderate

Tested: conflict-free git merge origin/main

Not-tested: Local tests per repository policy

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 5aff258. Configure here.

Comment thread Sources/Update/UpdateViewModel.swift
A user-triggered update check can cancel the current Sparkle update reply while a queued no-update callback is still waiting on the main queue. The view model now provides one cancellation path that replies once, clears the interactive state, and removes any visible override before the next check starts.

Constraint: Sparkle reply callbacks must be answered exactly once per update interaction
Rejected: Only set state to idle inline in UpdateController | would keep the cancellation semantics split across controller and view model paths
Confidence: high
Scope-risk: narrow
Tested: ./scripts/reload.sh --tag issue-3829-update-pill-keep-polling
Not-tested: Local test execution per repository policy; CI will run the regression test
@austinywang
austinywang merged commit f60b302 into main May 12, 2026
26 checks passed

This branch was successfully deployed

1 active deployment
Preview – cmux — 0ace395c Deployed May 12, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Update pill stops polling after first detected update — should keep polling and show latest available version

1 participant