Skip to content

Mobile state sync v2: per-record deltas replace the invalidate-and-refetch loop - #8284

Merged
azooz2003-bit merged 24 commits into
mainfrom
feat-mobile-sync-v2
Jul 21, 2026
Merged

azooz2003-bit merged 24 commits into
mainfrom
feat-mobile-sync-v2

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Jul 16, 2026 •

Copy link
Copy Markdown
Collaborator

Any workspace change today emits workspace.updated with an empty payload (80ms throttle) and every subscribed phone re-fetches the whole mobile.workspace.list. Each row is ~0.5-1KB of JSON, so at 100+ workspaces one changed field costs ~100KB of main-actor serialization plus ~100KB on the wire, per phone, per change burst, and the event carries no ordering, so a missed edge is silent staleness.

This adds mobile state sync v2 (design: docs/mobile-state-sync-v2.md). The Mac keeps an epoch + per-collection revision store of typed workspace/group records (Sources/Mobile/MobileStateSync.swift, store in CMUXMobileCore). Each observer tick diffs typed rows into the store and emits one mobile.sync.delta event carrying only the rows that changed. mobile.sync.fetch answers a cursor with the exact missing span, or a snapshot when the cursor is cold, from another epoch, or older than the retained tombstones. The phone mirrors records with a cursor and projects them through the existing applyRemoteWorkspaceList path, so everything downstream (per-Mac state, group collapse, selection) is shared. A gapped delta self-heals with a cursor fetch. While v2 is active, workspace.updated no longer schedules refetches.

Compatibility is the method itself: a legacy Mac answers method_not_found and the phone stays on the refetch loop; legacy phones never call it and the empty event still fires. No settings flag. Transport-agnostic: rides the existing framed RPC + event subscription on both the Tailscale TCP path and Iroh; no dependency on the Iroh work in flight.

Verification:

  • swift test --package-path Packages/Shared/CMUXMobileCore — 238 tests passed (30 new: store diff/revs/tombstones, mirror apply/gap/idempotent overlap, wire coding).
  • swift test --package-path Packages/iOS/CmuxMobileShell — 538 tests passed (3 new behavior tests through the scripted host: negotiation applies snapshot and suppresses legacy refetch, gapped delta repairs via cursor fetch, legacy Mac keeps the refetch loop).
  • swift test --package-path Packages/iOS/CmuxMobileRPC — 84 tests passed.
  • Tagged macOS build + live sim dogfood: in progress, will report on the PR.

Localization audit: no user-facing strings added or changed; the payloads are wire data only.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Mobile state sync v2 replaces the invalidate-and-refetch loop with cursor-based per-record deltas for the workspace list. It scopes authority to the owning client, repairs gaps, bounds recovery redials with a per-attempt deadline, suppresses legacy reloads while v2 is active, stays transport-agnostic (proven on the independent Iroh server-events lane), adds DocC docs plus an injectable frame coder with ContinuousClock-based deadlines, and falls back to a bounded legacy reload on transient negotiation failure.

  • New Features

    • Mac tracks an epoch and per-collection revisions for typed workspaces and groups in CMUXMobileCore.
    • Adds mobile.sync.fetch (snapshot or delta from a cursor) and mobile.sync.delta; iOS mirrors via a cursor, applies deltas or repairs via fetch, and projects through applyRemoteWorkspaceList.
    • workspace.updated stops full-list refetches while v2 is active; legacy Macs return method_not_found and phones fall back.
  • Bug Fixes

    • Integrity: no tombstones for ids that go live again; removals apply before upserts; stale same-epoch snapshots are ignored; undecodable deltas trigger a cursor repair; fetch responses missing a requested collection fail.
    • Robustness/recovery: single-flight mobile.sync.fetch runner with a trailing sweep; recovery-owner reconnects run under a per-attempt deadline with abandoned-dial caps; manual reconnect/pull clears transient backoff; sign-out wipes the mirror and deactivates v2.
    • Negotiation/refresh: start after subscribe ACK; watchdog repairs the v2 cursor; pull-to-refresh waits for the cursor fetch; while v2 is active the legacy full-list request is never sent (cursor fetch doubles as the liveness probe); if v2 gains authority while a legacy list is in flight, its response is ignored and only liveness is reported; per-waiter fetch deadlines return on time and forward caller cancellation without cancelling the shared runner; transient negotiation fetch failure now unconditionally runs a bounded legacy reload to recover missed events.

Written for commit 11a7e9e. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Rolled out mobile state sync v2 for workspaces and groups with cursor-based incremental updates and typed delta events.
    • Added end-to-end delta application so workspace lists update from deltas (reducing redundant full refreshes).
    • Introduced automatic gap repair that re-syncs from the correct cursor, with fallback to legacy full refresh when necessary.
  • Bug Fixes
    • Improved sync negotiation/repair recovery, including safer behavior when repair fails.
    • Connection recovery no longer stalls indefinitely on hung dial attempts.
  • Documentation
    • Added mobile state sync v2 delta protocol documentation.

Adds a versioned delta protocol for the iOS workspace list. The Mac keeps
an epoch + per-collection revision store of typed workspace/group records,
answers mobile.sync.fetch with a snapshot or the exact missing span, and
pushes mobile.sync.delta events carrying only the rows a change touched.
The phone mirrors records with a cursor, projects them through the same
applyRemoteWorkspaceList path the legacy full list uses, and stops
re-fetching the entire list on every workspace.updated push. Legacy phones
and Macs keep today's behavior via method_not_found negotiation.

Design doc: docs/mobile-state-sync-v2.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jul 16, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the invalidate-and-refetch workspace sync loop with a cursor-based delta protocol (mobile state sync v2). The Mac tracks per-collection revisions in a new MobileStateSyncStore, diffs typed rows on every observer tick, and emits mobile.sync.delta events carrying only changed records; the phone mirrors state through MobileSyncCollectionMirror and projects the mirror through the existing applyRemoteWorkspaceList path. A legacy Mac returns method_not_found and the phone falls back to the old loop, so rollout is seamless.

  • Mac side (Sources/Mobile/MobileStateSync.swift, CMUXMobileCore): new epoch-scoped store with tombstone-bounded delta covering, typed WorkspaceSyncRecord/GroupSyncRecord rows, and a mobile.sync.fetch handler that refreshes then answers the cursor request — snapshot on cold start, delta when coverable.
  • iOS side (MobileShellComposite+StateSync.swift): subscription-ACK-triggered negotiation, single-flight scheduleStateSyncFetch with trailing sweep, delta event handler with gap-detect-and-repair, applyStateSyncProjection projecting the mirror into the legacy response shape, and fallback to a bounded legacy reload on fetch failure.
  • Connection recovery (MobileShellComposite+ConnectionRecovery.swift): adds a per-attempt hard deadline via raceAgainstDeadline + RaceContinuationOnce, abandoned-dial ceiling, and a manual-reconnect backoff clear to fix a wedged-dial freeze (issue iOS Iroh session flaps every 1-5 min sim↔Mac; auto-redial hangs silently ≥15 min while iroh churns relay DNS (manual Reconnect required) #8531).

Confidence Score: 4/5

Safe to merge with the fallback retry timing issue addressed; the delta protocol, mirror logic, and deadline race are well-designed and covered by 33 new tests.

The fallback retry loop in fallBackToLegacyListAfterFetchFailure waits up to 14 seconds between reload attempts using fixed clock sleeps, without interrupting on a workspace.updated signal. This means a fetch failure that triggers the fallback can leave the workspace list stale for the full backoff window even though the Mac may have already emitted corrected state. The rest of the PR — tombstone-bounded store, epoch/cursor protocol, gap-repair single-flight, and deadline race for hung dials — is architecturally sound and thoroughly tested.

Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+StateSync.swift — specifically the fallBackToLegacyListAfterFetchFailure retry loop.

Important Files Changed

Filename Overview
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+StateSync.swift New iOS-side v2 state sync: delta event handler, single-flight cursor fetch, gap repair, and fallback to legacy. Contains a timing-based sleep in the fallback retry loop.
Sources/Mobile/MobileStateSync.swift New Mac-side sync host: builds typed rows, diffs into the store, broadcasts delta events, and serves mobile.sync.fetch. Uses MobileStateSyncHost.shared singleton (previously flagged).
Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncStore.swift Mac-side versioned collection store with tombstone-bounded delta covering, epoch isolation, and snapshot fallback. Logic for canCover and discardedTombstoneRevBound is correct.
Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncMirror.swift Client-side collection mirror: snapshot/delta apply, stale-ignored guard, gap detection. Conforms correctly to MainActor isolation.
Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncFrames.swift Wire-type definitions for fetch request/response and delta events; injectable MobileSyncFrameCoder struct. Clean and well-typed.
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift Adds per-attempt deadline via raceAgainstDeadline + RaceContinuationOnce, abandoned-dial ceiling, and clearTransientAutomaticReconnectBackoff on manual reconnect. Deadline mechanism is well-reasoned and unstructured-task abandonment is correctly bounded.
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Wires delta topic subscription, stateSyncActive computed property, and resetStateSyncForAccountBoundary into the composite. Changes are narrow and isolated.
Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift Adds memberwise initializers to Workspace, Group, and Terminal response types so v2 projection can assemble a response without JSON decoding.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Phone as iOS Phone
    participant Mac as Mac MobileStateSyncHost
    participant Store as MobileStateSyncStore

    Phone->>Mac: mobile.events.subscribe
    Mac-->>Phone: subscribe ACK
    Phone->>Phone: beginStateSyncNegotiation

    Phone->>Mac: mobile.sync.fetch cold start
    Mac->>Store: apply rows
    Mac-->>Phone: mobile.sync.delta broadcast
    Mac-->>Phone: fetch response snapshot rev N
    Phone->>Phone: "stateSyncActive = true"
    Phone->>Phone: applyStateSyncProjection

    loop workspace change 80ms throttle
        Mac->>Store: apply rows
        Mac-->>Phone: mobile.sync.delta fromRev N toRev N+1
        alt delta applied
            Phone->>Phone: applyStateSyncProjection
        else gap detected
            Phone->>Mac: mobile.sync.fetch cursor repair
            Mac-->>Phone: fetch response delta or snapshot
            Phone->>Phone: applyStateSyncProjection
        end
    end

    note over Phone,Mac: workspace.updated still fires but v2 phone ignores it
    note over Phone,Mac: Legacy Mac returns method_not_found and phone stays on refetch loop
Loading
%%{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 Phone as iOS Phone
    participant Mac as Mac MobileStateSyncHost
    participant Store as MobileStateSyncStore

    Phone->>Mac: mobile.events.subscribe
    Mac-->>Phone: subscribe ACK
    Phone->>Phone: beginStateSyncNegotiation

    Phone->>Mac: mobile.sync.fetch cold start
    Mac->>Store: apply rows
    Mac-->>Phone: mobile.sync.delta broadcast
    Mac-->>Phone: fetch response snapshot rev N
    Phone->>Phone: "stateSyncActive = true"
    Phone->>Phone: applyStateSyncProjection

    loop workspace change 80ms throttle
        Mac->>Store: apply rows
        Mac-->>Phone: mobile.sync.delta fromRev N toRev N+1
        alt delta applied
            Phone->>Phone: applyStateSyncProjection
        else gap detected
            Phone->>Mac: mobile.sync.fetch cursor repair
            Mac-->>Phone: fetch response delta or snapshot
            Phone->>Phone: applyStateSyncProjection
        end
    end

    note over Phone,Mac: workspace.updated still fires but v2 phone ignores it
    note over Phone,Mac: Legacy Mac returns method_not_found and phone stays on refetch loop
Loading

Reviews (16): Last reviewed commit: "Re-check v2 authority after the legacy l..." | Re-trigger Greptile

Comment on lines +144 to +185
/// `MobileSyncDeltaEvent` record type for a `mobile.sync.delta` payload.
public struct MobileSyncDeltaEventHeader: Codable, Equatable, Sendable {
public let collection: MobileSyncCollectionID

public init(collection: MobileSyncCollectionID) {
self.collection = collection
}
}

/// JSON bridging between the typed frames and the `[String: Any]` payloads the
/// mobile RPC envelope carries. One round-trip through `JSONSerialization` per
/// frame; frames are small (changed rows only), so this stays off every hot
/// path that matters.
public enum MobileSyncFrameJSON {
public static func jsonObject(from value: some Encodable) throws -> [String: Any] {
let data = try JSONEncoder().encode(value)
guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw MobileSyncFrameJSONError.notAnObject
}
return object
}

public static func decode<Value: Decodable>(
_ type: Value.Type,
fromJSONObject object: [String: Any]
) throws -> Value {
let data = try JSONSerialization.data(withJSONObject: object)
return try JSONDecoder().decode(type, from: data)
}

public static func decode<Value: Decodable>(
_ type: Value.Type,
fromJSONString string: String
) throws -> Value {
try JSONDecoder().decode(type, from: Data(string.utf8))
}
}

/// Failure bridging a sync frame to or from the RPC envelope's JSON container.
public enum MobileSyncFrameJSONError: Error, Equatable, Sendable {
case notAnObject
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Caseless enum used purely as a static-function namespace

MobileSyncFrameJSON has no cases and exposes only static funcs — this is the static-namespace anti-pattern the cmux-no-ambient-global-state rule flags. The preferred shape is a private/fileprivate file-scope helper (not exported) or, since this needs to be public, a struct with a private init() to prevent accidental instantiation while keeping the type system honest. As a public enum with no cases Swift will warn callers that exhaustive switches are trivially satisfied, and the enum form gives no semantic benefit over a namespace struct.

Rule Used: Flag new ambient global state in production Swift:... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +13 to +14
final class MobileStateSyncHost {
static let shared = MobileStateSyncHost()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 New singleton for runtime state

MobileStateSyncHost introduces static let shared = MobileStateSyncHost() for runtime state that varies per app run (the epoch, the store, the preview cache). Per the cmux-no-ambient-global-state rule this should be owned by a constructable, injectable type and wired in at the app seam. The custom-learning carve-out for "inherently process-global singletons with injectable seams" likely applies here (the store and mirror are both constructable and tested independently), so this may be intentional — just worth a confirmation that the injectable-seam path is sufficient for the Mac-side integration tests being deferred.

Rule Used: Flag new ambient global state in production Swift:... (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

cmux reload-cloud and others added 2 commits July 17, 2026 00:04
…etches

Merges origin/main to pull in the dev-build flakiness fix from
#8299. The negotiation fetch now
checks client currency and cancellation before sending, so a fetch task
from a replaced listener generation can never redial its stale client's
route underneath the replacement connection (caught by
manualReconnectRedialsWhenLiveStreamIsUnavailableButRPCStateIsConnected
after the merge).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 17, 2026 •

Copy link
Copy Markdown

Review Change Stack

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

Changes

Mobile state sync v2

Layer / File(s) Summary
Shared records and wire frames
Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncRecords.swift, MobileStateSyncFrames.swift
Adds typed workspace/group records, cursors, snapshot and delta payloads, fetch frames, and JSON bridging utilities.
Revisioned store and client mirror
Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncStore.swift, MobileStateSyncMirror.swift
Adds revision tracking, tombstones, snapshot fallback, contiguous delta application, gap detection, ordering, and reset behavior.
Mac sync production and RPC wiring
Sources/Mobile/MobileStateSync.swift, Sources/Mobile/MobileWorkspaceListObserver.swift, Sources/TerminalController.swift, Sources/Mobile/MobileHostService+TicketAuthorization.swift, cmux.xcodeproj/project.pbxproj
Builds synchronized rows, serves cursor fetches, emits subscribed delta events, and registers the new RPC and source file.
iOS sync negotiation and projection
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+StateSync.swift, MobileShellComposite.swift, Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift
Negotiates cursor fetches, consumes and repairs delta gaps, projects mirrored records into legacy response models, and suppresses redundant refreshes while active.
Validation, recovery, and documentation
Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/*StateSync*, Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/*StateSync*, docs/mobile-state-sync-v2.md
Tests frame coding, store and mirror semantics, negotiation, gap repair, fallback behavior, and documents the protocol.

Reconnect deadline handling

Layer / File(s) Summary
Deadline-bounded recovery
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift
Adds timeout races for redials, abandoned dial tracking, timeout recovery, and bounded retry backoff.
Deadline regression tests
Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectAttemptDeadlineTests.swift
Tests deadline resolution, hung dial abandonment, recovery state changes, backoff, and manual reconnection.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant iOS MobileShellComposite
  participant Mac MobileStateSyncHost
  participant MobileStateSyncStore
  participant MobileStateSyncMirror
  participant applyRemoteWorkspaceList
  iOS MobileShellComposite->>Mac MobileStateSyncHost: mobile.sync.fetch(cursor)
  Mac MobileStateSyncHost->>MobileStateSyncStore: fetchResponse(for:)
  MobileStateSyncStore-->>Mac MobileStateSyncHost: snapshot or delta response
  Mac MobileStateSyncHost-->>iOS MobileShellComposite: fetch response
  iOS MobileShellComposite->>MobileStateSyncMirror: apply(response:)
  MobileStateSyncMirror-->>iOS MobileShellComposite: applied, staleIgnored, or gap
  Mac MobileStateSyncHost-->>iOS MobileShellComposite: mobile.sync.delta
  iOS MobileShellComposite->>MobileStateSyncMirror: apply(delta:)
  MobileStateSyncMirror-->>iOS MobileShellComposite: gap triggers repair fetch
Loading

Possibly related PRs

Suggested reviewers: lawrencecchen


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (7 errors, 2 warnings)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error FAIL: raceAgainstDeadline adds production Task.sleep and an NSLock-guarded continuation in MobileShellComposite+ConnectionRecovery.swift, both banned by the blocking-runtime rule. Replace the deadline sleep with a cancellable timer/clock or explicit completion signal, and swap the lock for actor- or atomic-based one-shot coordination.
Cmux Algorithmic Complexity ❌ Error FAIL: MobileSyncCollectionMirror.orderedRecords (MobileStateSyncMirror.swift:29-32) sorts all records on every delta projection in the socket/UI path, with no cache or bound. Cache the ordered snapshot or update order incrementally; avoid full sorted on every mobile.sync.delta/fetch projection.
Cmux Swift Concurrency ❌ Error The diff adds several unstructured, fire-and-forget Tasks for fallback retry, reconnect-dial tracking, and deadline racing outside required callback boundaries. Refactor those flows into stored/cancelled tasks or structured async calls; avoid anonymous Task lifecycles unless bridging an OS/third-party callback.
Cmux Swift Logging ❌ Error FAIL: MobileShellComposite+StateSync.swift adds a top-level private let mobileStateSyncLog = Logger(...) in a MainActor runtime file, but it isn’t nonisolated private let as required. Change it to nonisolated private let (or move the logger into a nonisolated context); the new error logs already redact values with .private.
Cmux Full Internationalization ❌ Error MobileStateSync.swift adds raw English API error messages with no localization API or catalog entries. Use String(localized:defaultValue:) (or equivalent) for these sync errors and add matching xcstrings translations for every supported locale.
Cmux Architecture Rethink ❌ Error Production reconnect now uses a Task.sleep deadline race plus abandoned-task/NSLock bookkeeping to mask hung dials; the rule forbids timing repair paths for socket races. Move timeout ownership into the dialer/connection state machine: surface explicit completion/cancel/failure from the transport and let recovery react to that, instead of timing out with a race.
Cmux No Ambient Global State ❌ Error FAIL: Sources/Mobile/MobileStateSync.swift:13-14 adds MobileStateSyncHost.shared, a new runtime singleton for sync state. Make sync ownership injectable: construct MobileStateSyncHost at the app root and pass it into TerminalController and MobileWorkspaceListObserver instead of using .shared.
Docstring Coverage ⚠️ Warning Docstring coverage is 23.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The description covers the change and testing, but it misses the template's required sections for Demo Video, Review Trigger, and Checklist. Add the missing ## Demo Video, ## Review Trigger, and ## Checklist sections, and align the testing section with the template.
✅ Passed checks (16 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed PASS: new sync records/frames are plain value types, while the new store/host/mirror are explicitly @MainActor and their call sites stay on the main actor.
Cmux Browser Automation Off-Main ✅ Passed Diff is confined to mobile sync files; no browser.* routing, socketWorkerMethods, or processV2Command changes were introduced.
Cmux Expensive Synchronous Load ✅ Passed No agent-history load was added or moved; the diff only adds mobile.sync frame decode/encode and main-actor sync state handling, not RestorableAgentSessionIndex.load() or transcript parsing.
Cmux Cache Substitution Correctness ✅ Passed Freshness checks are built in: cold/stale cursors snapshot or ignore, epoch mismatches force snapshot, and previewCache is a transient UI hint.
Cmux No Hacky Sleeps ✅ Passed PR diff only touches Swift sources/tests/docs; no TS/JS/shell or non-Swift runtime scripts add fixed sleeps, polling, or wall-clock waits.
Cmux Swift @Concurrent ✅ Passed No new nonisolated async work needs @concurrent; added helpers are @MainActor or run inside explicit Task hops.
Cmux Swift Package Boundaries ✅ Passed Reusable sync models/store were extracted to CMUXMobileCore; app-target changes are app-state wiring around AppDelegate/TerminalController, which fits the glue exception.
Cmux Swiftpm Lockfiles ✅ Passed PR changes only iOS source/tests; no Package.swift, Package.resolved, Xcode project, or .gitignore files changed, so the SwiftPM lockfile rule isn’t triggered.
Cmux User-Facing Error Privacy ✅ Passed Production-facing sync/RPC errors are generic; no vendor names, raw upstream messages, or sensitive payloads are exposed.
Cmux Swiftui State Layout ✅ Passed No forbidden SwiftUI state/layout patterns were introduced; changed iOS files show no new ObservableObject/@published, GeometryReader, lazy row store refs, or render-time state writes.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No standalone NSWindow/NSPanel/WindowGroup changes or cmux.* identifier assignments were added; the touched files are mobile sync and allowed main-window/terminal code.
Cmux Source Artifacts ✅ Passed All changed paths are source/docs/tests (.swift, .pbxproj, .md); none match artifact/scratch patterns like tmp, DerivedData, logs, or binaries.
Cmux No Test Or Debug Seam In Production Source ✅ Passed Changed production sources add only production sync APIs; no new DEBUG/test-only seam names or guards appear in the diff, and the new helpers have production callers.
Title check ✅ Passed The title clearly summarizes the main change: mobile state sync v2 replacing invalidate-and-refetch with per-record deltas.
✨ 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 feat-mobile-sync-v2

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.

@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: 7

🤖 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 `@docs/mobile-state-sync-v2.md`:
- Around line 101-103: Update the outbox coalescing statement in the mobile
state sync documentation by removing the unsupported “1000x smaller” claim or
replacing it with a measured, defensible worst-case ratio. Ensure the
justification for relying on the bounded per-connection event queue does not
depend on an unverified reduction figure.

In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 6353-6358: Update the mobile state synchronization flow around
beginTerminalEventSubscriptionStart and beginStateSyncNegotiation so negotiation
begins only after ack.isSubscribed succeeds. Keep stream consumption immediate,
but move the negotiation call into the successful subscription-acknowledgement
path and remove the earlier concurrent invocation, ensuring the snapshot fetch
cannot race subscription activation.

In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite`+StateSync.swift:
- Around line 32-48: Update handleStateSyncDeltaEvent so known workspace and
group collections schedule a mobile.sync.fetch repair whenever their delta
payload cannot be decoded, instead of silently returning. Preserve ignoring
unknown collections, and use the existing synchronization-fetch mechanism while
retaining normal stateSyncMirror application for valid deltas.

In
`@Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncMirror.swift`:
- Around line 29-32: Update MobileStateSyncMirror’s orderedRecords projection to
avoid sorting recordsByID.values on every read. Maintain a cached ordered-ID
snapshot, rebuild it only when records are added, removed, or their
syncSortIndex changes affect ordering, and have orderedRecords resolve records
through that snapshot while preserving syncSortIndex/syncID ordering.
- Around line 48-55: Update the .snapshot handling in MobileStateSyncMirror to
reject snapshots from the current epoch when payload.rev is older than the
mirror’s current rev, preserving recordsByID, epoch, and rev without applying
the stale response. Keep snapshot application for newer or otherwise valid
revisions, and add a test covering a newer delta arriving before an older
same-epoch snapshot while ensuring the authoritative revision remains current
immediately.

In
`@Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncStore.swift`:
- Around line 134-141: Update the delta construction in MobileStateSyncStore so
removals exclude IDs present in stampedByID, ensuring a record re-added after
rev appears only in upserts and remains live when MobileSyncCollectionMirror
applies the payload. Add a regression test covering remove-then-readd behavior
and verifying the ID is not included in removedIDs.

In `@Sources/Mobile/MobileStateSync.swift`:
- Around line 12-19: Remove the ambient singleton declaration from
MobileStateSyncHost and make the host constructable by an owning process
component. Have MobileHostService or AppDelegate create and retain one instance,
then pass that instance through the observer and RPC paths instead of accessing
MobileStateSyncHost.shared; preserve deltaTopic as a type-level constant.
🪄 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: 5a90e5d2-3f4e-4f0a-8dce-91f09d73a6a0

📥 Commits

Reviewing files that changed from the base of the PR and between 01cf217 and f5d513d.

📒 Files selected for processing (18)
  • Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncFrames.swift
  • Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncMirror.swift
  • Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncRecords.swift
  • Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncStore.swift
  • Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileStateSyncFrameCodingTests.swift
  • Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileStateSyncMirrorTests.swift
  • Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileStateSyncStoreTests.swift
  • Packages/iOS/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileSyncWorkspaceListResponse.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+StateSync.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellStateSyncTests.swift
  • Sources/Mobile/MobileHostService+TicketAuthorization.swift
  • Sources/Mobile/MobileStateSync.swift
  • Sources/Mobile/MobileWorkspaceListObserver.swift
  • Sources/TerminalController.swift
  • cmux.xcodeproj/project.pbxproj
  • docs/mobile-state-sync-v2.md

Comment thread docs/mobile-state-sync-v2.md Outdated
Comment on lines +101 to +103
- Per-client outbox coalescing for slow phones (today's bounded per-connection
event queue suffices because delta frames are ~1000x smaller than the
refetches they replace).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct or qualify the “1000x smaller” claim.

The earlier estimates imply roughly a 50–200x reduction for a single changed row (0.5–1KB versus a 50–100KB full list), not 1000x. Replace this with a measured worst-case figure, or remove the ratio and avoid using it as justification that the bounded per-connection queue is sufficient.

🤖 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 `@docs/mobile-state-sync-v2.md` around lines 101 - 103, Update the outbox
coalescing statement in the mobile state sync documentation by removing the
unsupported “1000x smaller” claim or replacing it with a measured, defensible
worst-case ratio. Ensure the justification for relying on the bounded
per-connection event queue does not depend on an unverified reduction figure.

Comment thread Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Outdated
Comment on lines +29 to +32
public var orderedRecords: [Record] {
recordsByID.values.sorted {
($0.syncSortIndex, $0.syncID) < ($1.syncSortIndex, $1.syncID)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Avoid sorting the full mirror on every projection read.

This performs an O(n log n) sort after sync updates over a collection expected to scale toward roughly 1000 workspaces. Maintain an ordered ID snapshot and only rebuild it when additions, removals, or syncSortIndex changes affect ordering.

As per path instructions, production paths over scalable user data must avoid repeated hot-path sorting.

🤖 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
`@Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncMirror.swift`
around lines 29 - 32, Update MobileStateSyncMirror’s orderedRecords projection
to avoid sorting recordsByID.values on every read. Maintain a cached ordered-ID
snapshot, rebuild it only when records are added, removed, or their
syncSortIndex changes affect ordering, and have orderedRecords resolve records
through that snapshot while preserving syncSortIndex/syncID ordering.

Source: Path instructions

Comment on lines +12 to +19
@MainActor
final class MobileStateSyncHost {
static let shared = MobileStateSyncHost()

/// Event topic v2 phones subscribe to through `mobile.events.subscribe`.
static let deltaTopic = "mobile.sync.delta"

let store = MobileStateSyncStore()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Move the sync host under an injectable process owner.

static let shared introduces ambient mutable runtime state. Have MobileHostService or AppDelegate construct and own one MobileStateSyncHost, then inject that instance into the observer and RPC path.

As per coding guidelines, “Avoid new ambient global runtime state … and runtime singletons. Prefer constructable injectable owners and private/fileprivate helpers.” <coding_guidelines>

🧰 Tools
🪛 SwiftLint (0.65.0)

[Warning] 13-13: Classes should have an explicit deinit method

(required_deinit)

🤖 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/Mobile/MobileStateSync.swift` around lines 12 - 19, Remove the
ambient singleton declaration from MobileStateSyncHost and make the host
constructable by an owning process component. Have MobileHostService or
AppDelegate create and retain one instance, then pass that instance through the
observer and RPC paths instead of accessing MobileStateSyncHost.shared; preserve
deltaTopic as a type-level constant.

Source: Coding guidelines

main's SidebarWorkspaceRowSlotViews.swift fails to compile on the
Blacksmith macos-26 runners (escaping-closure capture of 'color' needs
explicit self there). Out of this PR's feature scope, but required to
produce any dev build of a branch containing current main on that
toolchain; behavior unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment on lines +99 to +107
request,
timeoutNanoseconds: runtime?.rpcRequestTimeoutNanoseconds
)
guard remoteClient === client, connectionState == .connected, !Task.isCancelled else { return }
let response = try JSONDecoder().decode(MobileSyncFetchResponse.self, from: data)
let result = stateSyncMirror.apply(response: response)
stateSyncActive = true
switch result {
case .applied:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Stale legacy refetch can overwrite v2 state after activation

scheduleWorkspaceListRefreshFromEvent correctly suppresses new tasks once stateSyncActive == true, but any workspaceListRefreshTask already in-flight from the negotiation window keeps running. If the Mac changes state between when that legacy mobile.workspace.list request was sent and when mobile.sync.fetch returned its snapshot, the legacy response carries the older state; when it completes it calls applyRemoteWorkspaceList and overwrites the v2 snapshot. The delta stream won't self-correct until the next workspace change on the Mac.

The fix is to cancel workspaceListRefreshTask (currently private var in MobileShellComposite.swift) at the point stateSyncActive = true is assigned. Because the property is file-private, the cleanest path is to add a small internal func cancelLegacyRefetchTask() helper in the main file and call it here before applyStateSyncProjection().

cmux reload-cloud and others added 3 commits July 20, 2026 13:11
Main fixed the Blacksmith toolchain compile with a [color] capture; drop
this branch's interim explicit-self variant so the file matches main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a session-level test: a typed state-sync delta pushed through the
independent event stream (the lane an admitted Iroh connection negotiates
via iroh_server_events_v1) reaches a mobile.sync.delta topic listener and
decodes to the typed frame. Locks in that sync v2 is lane-agnostic on the
Iroh transport.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 20, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

@azooz2003-bit

Copy link
Copy Markdown
Collaborator Author

Rebased onto the Iroh-integrated main (merge of PR 8484 et al.) and verified the protocol against it:

  • All suites green post-merge: CmuxMobileShell 624, CMUXMobileCore 263, CmuxMobileRPC 107 (one new test).
  • New session-level test proves a typed mobile.sync.delta pushed through the independent Iroh server-events lane reaches the topic listener and decodes: the sync protocol is lane-agnostic by construction (free-form topics, per-subscription transport, RPC over the byte-transport seam) and now by test.
  • Live on an iroh-only ticket: the sim connected to the tagged Mac over QUIC (lsof: zero TCP to the host listener) and subscribed with mobile.sync.delta in its topic set. The full UI delta round-trip was verified over loopback earlier; over iroh it was interrupted by a session-flap unrelated to this PR (the whole session, all 7 topics, dies every 1-5 min in the sim environment and the bounded auto-redial can hang) — filed with evidence as iOS Iroh session flaps every 1-5 min sim↔Mac; auto-redial hangs silently ≥15 min while iroh churns relay DNS (manual Reconnect required) #8531.
  • One post-merge interaction fixed earlier on this branch: the v2 negotiation fetch now checks client currency before sending so it can never redial a replaced client's route (caught by the merged reconnect-route tests).

🤖 Generated with Claude Code

Comment on lines +111 to +114
case .gap:
// A fetch section can only gap if the store moved between
// building the response's sections; one repair round covers it.
scheduleStateSyncFetch(client: client)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Gap-repair fetch loses its cancel handle due to the outer task's defer

scheduleStateSyncFetch creates a new Task T2 and assigns it to stateSyncFetchTask, but the currently-running fetch task T1 has defer { self?.stateSyncFetchTask = nil } that fires as soon as runStateSyncFetch returns — clearing T2's handle before it can be cancelled. A subsequent beginStateSyncNegotiation sees stateSyncFetchTask == nil, skips the cancel, and creates T3. Now T2 and T3 race: both pass remoteClient === client (connection unchanged) and send independent fetch requests. Whichever applies its snapshot last wins — if T2's older snapshot lands after T3's newer one the mirror regresses. Calling runStateSyncFetch directly keeps the repair inside T1's lifetime, avoids creating T2 entirely, and preserves cancel semantics.

Suggested change
case .gap:
// A fetch section can only gap if the store moved between
// building the response's sections; one repair round covers it.
scheduleStateSyncFetch(client: client)
case .gap:
// A fetch section can only gap if the store moved between
// building the response's sections; one repair round covers it.
// Call directly (not scheduleStateSyncFetch) so we stay inside
// this task's lifetime: the outer Task's defer clears
// stateSyncFetchTask when runStateSyncFetch returns, which would
// drop the new task's cancel handle before it could run.
await runStateSyncFetch(client: client)

cmux reload-cloud and others added 6 commits July 20, 2026 19:54
A transport dial that parks forever (wedged Iroh dial, issue 8531) holds
the recovery owner's in-flight claim indefinitely: no failure settles, no
backoff retry is scheduled, and every other trigger defers forever. Fails
without the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ckoff

Every automatic stored-Mac redial now races a per-attempt deadline
(runtime-injectable, default 30s). At expiry the attempt is abandoned
(generation guards make late completion harmless), settled as timedOut,
and transient backoff schedules the next automatic try, so a hung Iroh
dial can no longer freeze the recovery machine into a permanent
"Disconnected - Tap Reconnect" dead end. The user's explicit
reconnect/pull gesture now clears transient backoff the same way
recoverMobileConnection(.manual) does, so a recorded cooldown can never
swallow a manual tap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
#	cmux.xcodeproj/project.pbxproj
- Store payloads no longer emit tombstones for ids that are live again
  (remove-then-readd inside a cursor span would have deleted the re-added
  record on the client); mirror applies removals before upserts as
  belt-and-braces.
- Same-epoch snapshots older than the mirror cursor are ignored (a stale
  in-flight fetch response can no longer roll the mirror back).
- An undecodable delta for a known collection now schedules a cursor
  repair fetch instead of leaving the mirror silently stale.
- While v2 owns the list, the legacy full-list reload path keeps its
  liveness-probe role but re-bases the mirror through a cursor fetch
  instead of overwriting projected state.
- The single-flight fetch handle is generation-guarded so a cancelled
  predecessor's deferred cleanup cannot erase its replacement's handle.
- Qualify the payload-reduction claim in the design doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ck, tombstone rev bound

- raceAgainstDeadline no longer uses a task group (which structurally
  awaits a cancellation-ignoring dial); the operation runs unstructured
  and a lock-guarded once resumes whichever side finishes first. Direct
  tests cover an operation that never completes and ignores cancellation.
- A transiently failed gap-repair fetch now drops back to legacy list
  semantics (stateSyncActive off + one authoritative reload) instead of
  stranding the mirror behind a suppressed refetch loop; v2 re-negotiates
  on the next listener generation.
- Tombstone pruning tracks the highest discarded revision; coverability is
  judged against that bound so a same-revision batch split by the ring cap
  can never produce a delta that omits a removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… retries

- Deadline races hand back the abandoned operation task; the composite
  counts unresolved abandoned dials, pauses automatic retries above a small
  ceiling, and re-arms the retry loop when an abandoned dial finally
  resolves while still disconnected. A persistently wedged transport can
  no longer accumulate unbounded retained reconnect tasks.
- The legacy fallback reload after a failed gap-repair fetch retries up to
  three times with short pauses instead of fire-and-forget, and reports
  exhaustion; connection-death cases remain owned by the recovery paths.

Rejected (named boundary): moving MobileStateSyncHost off `shared` — the
Mac mobile plane is currently rooted in TerminalController.shared /
MobileHostService.shared at every call site, the host's epoch is
process-lifetime by design, and the testable sync logic lives in the
injected CMUXMobileCore classes; the injectable-owner move rides the
de-singletonizing follow-up rather than this PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+StateSync.swift (1)

150-167: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Skip legacy fallback on cancelled state-sync fetches
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+StateSync.swift:150-167: MobileCoreRPCClient.sendRequest(...) can surface CancellationError for an aborted request, so a superseded fetch still falls into these catches and incorrectly disables stateSyncActive / triggers the legacy reload. Guard !Task.isCancelled before falling back so restart-on-newest stays benign.

🤖 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
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite`+StateSync.swift
around lines 150 - 167, Update the error handling around the state-sync fetch
catches in the state-sync flow to call fallBackToLegacyListAfterFetchFailure
only when !Task.isCancelled. Continue logging the error as appropriate, but
ensure cancelled or superseded requests do not disable stateSyncActive or
trigger the legacy reload; preserve the existing method_not_found handling.

Source: Coding guidelines

🤖 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/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite`+StateSync.swift:
- Around line 175-182: Make fallBackToLegacyListAfterFetchFailure(client:) async
and replace its fire-and-forget MainActor Task with a direct await of
reloadWorkspaceListFromMac(). Update both call sites in runStateSyncFetch to
await the fallback method, keeping the existing guards and stateSyncActive
handling unchanged so the reload remains tied to the caller-owned fetch
lifecycle.

---

Outside diff comments:
In
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite`+StateSync.swift:
- Around line 150-167: Update the error handling around the state-sync fetch
catches in the state-sync flow to call fallBackToLegacyListAfterFetchFailure
only when !Task.isCancelled. Continue logging the error as appropriate, but
ensure cancelled or superseded requests do not disable stateSyncActive or
trigger the legacy reload; preserve the existing method_not_found handling.
🪄 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: 37ed0b5d-9eed-44ee-b3e2-7c51117d56d3

📥 Commits

Reviewing files that changed from the base of the PR and between f7bcc0d and defc6eb.

📒 Files selected for processing (7)
  • Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileStateSyncStore.swift
  • Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/MobileStateSyncStoreTests.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+StateSync.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellStateSyncTests.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectAttemptDeadlineTests.swift

@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
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite`+ConnectionRecovery.swift:
- Around line 908-916: Update the timeout coordination around operationTask and
once so the timeout Task is captured and cancelled when operationTask completes
successfully. Replace try? around Task.sleep with do-catch, and ensure the
timeout path calls once.finish(nil) only when the sleep reaches its deadline,
not when cancellation throws.
🪄 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: 539d2939-5242-4325-82f1-03a15d9f370b

📥 Commits

Reviewing files that changed from the base of the PR and between defc6eb and ffdc47e.

📒 Files selected for processing (4)
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+ConnectionRecovery.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+StateSync.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ReconnectAttemptDeadlineTests.swift

Comment on lines +908 to +916
Task {
once.finish(await operationTask.value)
}
Task {
try? await Task.sleep(nanoseconds: nanoseconds)
operationTask.cancel()
once.finish(nil)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Cancel the timeout task on success to avoid a 30-second resource leak.

When the operation completes before the deadline, the timeout Task is currently never cancelled. It continues sleeping in the background for the full deadline duration (up to 30 seconds). This keeps operationTask and anything captured by it retained in memory long after a successful reconnect.

You can prevent this by capturing the timeout task and cancelling it when the operation completes. Note that you must use do-catch instead of try? around the sleep; otherwise, the cancelled sleep would wake up, swallow the cancellation error, and incorrectly invoke once.finish(nil).

♻️ Proposed fix to cancel the timeout task
-            Task {
-                once.finish(await operationTask.value)
-            }
-            Task {
-                try? await Task.sleep(nanoseconds: nanoseconds)
-                operationTask.cancel()
-                once.finish(nil)
-            }
+            let timeoutTask = Task {
+                do {
+                    try await Task.sleep(nanoseconds: nanoseconds)
+                    operationTask.cancel()
+                    once.finish(nil)
+                } catch {
+                    // Cancelled because the operation finished first; do nothing.
+                }
+            }
+            Task {
+                let result = await operationTask.value
+                timeoutTask.cancel()
+                once.finish(result)
+            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Task {
once.finish(await operationTask.value)
}
Task {
try? await Task.sleep(nanoseconds: nanoseconds)
operationTask.cancel()
once.finish(nil)
}
}
let timeoutTask = Task {
do {
try await Task.sleep(nanoseconds: nanoseconds)
operationTask.cancel()
once.finish(nil)
} catch {
// Cancelled because the operation finished first; do nothing.
}
}
Task {
let result = await operationTask.value
timeoutTask.cancel()
once.finish(result)
}
🤖 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
`@Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite`+ConnectionRecovery.swift
around lines 908 - 916, Update the timeout coordination around operationTask and
once so the timeout Task is captured and cancelled when operationTask completes
successfully. Replace try? around Task.sleep with do-catch, and ensure the
timeout path calls once.finish(nil) only when the sleep reaches its deadline,
not when cancellation throws.

cmux reload-cloud and others added 6 commits July 20, 2026 21:40
…antics

- v2 negotiation now starts from the mobile.events.subscribe
  ACKNOWLEDGEMENT instead of racing the handshake, so a fetch snapshot can
  never miss a change emitted before the Mac registered this connection.
- The watchdog's lost-registration recovery repairs the v2 cursor (missed
  events include missed deltas) instead of no-opping under v2.
- The pull-to-refresh/Computers refresh path awaits the v2 cursor fetch
  and returns its outcome, so the spinner ends with authoritative state
  and a failed fetch is not reported as success.
- Fetch failure handling is gated by owning generation + cancellation, so
  a cancelled predecessor surfacing as a timeout can no longer disable v2
  underneath its successful replacement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…w superseded fetches

- The abandoned-dial handle is registered immediately after the race
  returns, before cancellation/supersession guards can drop it, so wedged
  dials from replaced attempts stay inside the accounting ceiling.
- performStateSyncFetch follows cancel-and-replace supersessions (bounded)
  so a user refresh reports the authoritative replacement's outcome instead
  of a superseded cancel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…reset, no legacy list under v2

- A fetch response missing a requested collection can no longer activate
  v2 or project an empty mirror over a valid list; it is treated as a
  fetch failure.
- signOut tears down state sync: mirror wiped (previous account's titles,
  directories, previews), v2 deactivated, in-flight fetch invalidated by
  generation so late completions cannot write into the next session.
- While v2 is active the legacy full-list request is never built or sent;
  the cursor fetch is both the liveness probe (caller timeout honored) and
  the authoritative refresh. Also widens a load-flaky poll window in the
  hung-redial test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Ordinary workspace.updated events are suppressed under v2 (each pairs
  with a delta); only the watchdog's lost-registration branch repairs,
  through the dedicated repairMissedEventWindow (cursor fetch under v2,
  full refetch under legacy). Removes the per-event fetch RPC and the
  cancel-storm that could starve a genuine gap repair.
- Abandoned-dial janitors no longer record transient backoff on
  resolution: that write could land mid-manual-retry and re-block the dial
  the user just requested. They now kick the coalesced recovery entry
  directly, only when no attempt or scheduled retry is active. (Found via
  deterministic full-suite reproduction of the hung-redial test.)
- The hung-redial test releases parked dials when lifting the hang
  (eternal hangs were a test artifact racing auto-retry state) and uses
  starvation-proof poll windows. Full shell suite green 3x consecutively;
  the suite's residual flakiness on loaded machines reproduces on
  origin/main (6 issues/run) and predates this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…client

Replaces the bare stateSyncActive flag with stateSyncAuthorityClientID:
v2 is active only while the client that earned authority (successful
mobile.sync.fetch) IS the current remoteClient. This fixes the class the
three findings shared: client promotion/replacement implicitly demotes to
legacy (no suppressed-invalidation window on the new Mac), deltas are
ignored outside the authoritative window (no legacy/v2 concurrent writers
after a fallback), and a superseded fetch waiter consults the last settled
generation's outcome (a liveness caller can no longer read a replacement's
success as failure and tear down a healthy session). Regression test:
replacingTheForegroundClientDemotesStateSyncAuthority.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sweep

Replaces the fetch slot's cancel-and-replace semantics with the codebase's
proven single-flight + follow-up pattern (same shape as
scheduleSecondaryRefresh): same-client demand coalesces onto the in-flight
runner and requests one trailing sweep; only a different client's demand
replaces the runner. This resolves the round-8 class at its root:
- gap repairs can no longer be starved by sustained 80ms churn (deltas
  coalesce instead of cancelling the repair they need);
- negotiation-window deltas request a trailing sweep instead of being
  dropped (a change postdating the fetch snapshot is swept, not lost);
- no concurrent fetch generations exist, so a superseded task can neither
  misreport nor overwrite a replacement's success (the settled-outcome
  bookkeeping is deleted, not patched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cmux reload-cloud and others added 2 commits July 21, 2026 11:44
…fetch deadlines

- The per-attempt deadline and abandoned-dial accounting move inside
  reconnectActiveMacOutcome itself, so startup restore, team-scope
  restore, and the manual workspace-list fallback are bounded identically
  to the recovery owner (whose special-case deadline branch is deleted).
  The raw dial is reconnectActiveMacOutcomeUnbounded.
- performStateSyncFetch bounds each WAITER by its own timeout when one is
  provided (a 3s liveness probe joining a slow fetch reports within its
  contract); the shared runner is never cancelled by a waiter's deadline
  and keeps converging in the background.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l forwarding

The round-9 blanket wrapper detached every reconnect's synchronous prefix,
breaking reconnect serialization semantics (registry-snapshot reuse and
initial-connect tests). The per-attempt deadline returns to the recovery
owner path (round-8 shape, proven 3x-green); bounding the remaining
lifecycle callers (startup restore, team restore, manual fallback) is a
consciously deferred follow-up needing per-call-site deadline policy.
Kept from round 9: per-waiter timeout bounds on coalesced state-sync
fetches, and explicit caller-cancellation forwarding in raceAgainstDeadline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cmux reload-cloud and others added 2 commits July 21, 2026 12:23
…ock deadline

- Every public symbol in the state-sync package surface carries DocC.
- MobileSyncFrameJSON (caseless namespace enum) becomes the injectable
  MobileSyncFrameCoder instance type; error renamed accordingly.
- The deadline race's timer uses ContinuousClock (intentional bounded
  deadline, cancellation-wired).

Remaining P2 policy findings are rejected with named boundaries recorded
on the PR: the protocol files each own a closed set of tightly coupled
wire DTOs; DeadlineRaceOutcome/RaceContinuationOnce are private helpers
co-located with their sole consumer; the once-guard must be callable from
the synchronous onCancel handler, which an actor cannot be; the static
race helper lives on a heavily stateful owning type, not a namespace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ent failure

Renegotiation clears authority before the fetch, so the fallback's guard
on current authority skipped exactly the recovery it exists for: a
transient negotiation-fetch failure after a same-client resubscribe left
events missed in the subscription gap unrecovered. The bounded legacy
reload now runs on every transient fetch failure regardless of authority
(currency-guarded per iteration). Adds trace lines on the fallback path
and a regression test (transientNegotiationFailureStillRunsTheLegacyReload).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 21, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

Negotiation can grant v2 while a legacy full-list request is in flight;
applying the captured response then would overwrite newer mirror state.
The legacy path now re-checks authority after the await and reports
liveness success without applying when v2 took ownership.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant