Skip to content

iOS: registry-driven auto-attach (sign in → connected)#6044

Open
lawrencecchen wants to merge 1 commit into
mainfrom
feat-ios-auto-attach
Open

iOS: registry-driven auto-attach (sign in → connected)#6044
lawrencecchen wants to merge 1 commit into
mainfrom
feat-ios-auto-attach

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

The gap

A fresh install (or a re-install with no SQLite paired-Mac row) lands on the pair/QR screen after sign-in. reconnectStoredMacIfNeeded() only reconnects when MobilePairedMacStore already has an active row, which only exists after a prior QR/manual pairing. The team-scoped device registry already knows every Mac the signed-in user's team owns and their current attach routes, but nothing consults it on the cold-start path. So a brand-new phone with a perfectly reachable team Mac still dead-ends on "scan a QR".

This adds registry-driven auto-attach: a signed-in phone whose team has one reachable Mac connects with no QR scan or manual host entry. Onboarding becomes "sign in → connected".

Key finding: can the phone mint from a registry route without prior QR pairing?

Yes. The attach ticket is route-discovery + workspace-selection only; it carries no authorization secret. The Mac authorizes the mobile data plane solely on same-Stack-account matching (MobileHostAuthorizationPolicy.authorizeStackUser in Sources/Mobile/MobileHostService.swift: guard localUserID == remoteUserID else { throw .accountMismatch }), not on any pairing record. mobile.attach_ticket.create mints from routes + a bearer token with no prior pairing. So a signed-in phone can mint a valid ticket from a registry route purely because it owns the same Stack account that owns the Mac. The registry is team-scoped, so a different-account Mac never appears, and a stray cross-account route would be rejected at mint by account_mismatch. Auto-attach therefore reuses the existing connectToRegistryInstance path (pick route, mint Stack-authenticated, persist into MobilePairedMacStore).

Mechanism

Registry + presence + same-account mint. On the no-stored-Mac branch of reconnectActiveMacIfAvailable, auto-attach loads a freshly-confirmed .ok registry list, picks the single obvious Mac via the pure MobileAutoAttachTargetSelector (presence-online preferred, else the single most-recently-seen with a reachable route; any ambiguity → fall through), and connects via connectToRegistryInstance, persisting the pairing so the next launch takes the normal reconnect path.

A presence seam (MobileAutoAttachPresenceProviding) is optional and degrades to recency until presence (#5792) lands, which is correct for the dominant single-Mac team.

Flag + default

mobileAutoAttach via MobileAutoAttachFlag (key cmux.mobile.autoAttach.enabled): DEBUG on, Release off until dogfooded. Read once at the composition root and injected as a Bool, so the shell stays testable. An explicit UserDefaults/settings override wins over the build default.

Fallback / multi-Mac

No candidate, registry/presence outage (transient failure degrades to manual rather than connecting off a stale cache), connect failure, or ambiguous multi-online → fall through to today's pair screen. Bounded and cancellable: the gate-owning runner caps the restoring window with the same 6s deadline the stored-Mac path uses, and one attempt runs at a time. Never auto-attaches to a different-account Mac.

Concurrency is handled with a per-attempt generation (not a shared boolean) plus explicit per-call supersede intent: every user-initiated pairing entry point (manual host top before validation, QR/code, device-tree tap top before the no-route guard) supersedes any in-flight auto-attach before its own early-returns, invalidating the pairing attempt and bumping the connection generation so an auto-attach already inside its own connect() discards its result. Auto-attach's own connect passes supersedeAutoAttach: false so it never cancels itself. Sign-out supersedes the in-flight attempt before resetting the restoring-gate flags so the next account's sign-in starts clean.

On a physical phone, loopback routes are rejected (a 127.0.0.1 route names the phone itself, not the Mac, and loopback is Stack-auth-trusted): selectTarget and firstReachableHostPort skip them, and connectToRegistryInstance strips loopback from the routes it persists, so the next stored-Mac reconnect can never pick localhost. The simulator (where 127.0.0.1 is the host Mac) keeps loopback.

Tests

  • Pure selector (MobileAutoAttachTargetSelectorTests, 13): online > recency, no-candidate, ambiguity (multi-online / equally-recent), per-device instance tie, loopback rejection for physical phones.
  • Composite behavior (MobileAutoAttachTests, 23 via injected registry/paired-store/identity doubles + scripted transport): connects + persists, fall-through on no candidate / transient registry failure, one-attempt dedupe, flag off, already-connected, same-account guard (stale attempt after account switch), manual/QR/no-route-tap/invalid-host supersession (including auto-attach mid-connect), sign-out-during-gate clean handoff, loopback not persisted.
  • swift test green for CmuxMobileShellModel (60) and CmuxMobileShell (138); iOS arm64 simulator build (cmux-ios) succeeds. Structured autoreview clean.

Localization audit

No new user-facing strings. Auto-attach reuses the existing RestoringSessionView copy and pairing error strings; no Resources/Localizable.xcstrings changes. The only L10n.string lines touched are pre-existing validation strings I reordered.

🤖 Generated with Claude Code


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


Note

Medium Risk
Touches core mobile connection, pairing persistence, and restoring UI gates with many async race paths; mitigated by conservative target selection, fresh registry reads, and extensive tests, but onboarding/connect regressions are still plausible.

Overview
Registry-driven auto-attach lets a signed-in phone with no stored paired Mac try to connect automatically: load a fresh .ok device registry list (not the UI cache), pick one Mac via MobileAutoAttachTargetSelector (optional presence, else recency; ambiguous multi-Mac → manual pair), then reuse connectToRegistryInstance and persist pairing on success.

New pieces: MobileAutoAttachFlag (DEBUG on / Release off), shared MobileAttachRoutePriority, optional MobileAutoAttachPresenceProviding, and orchestration in MobileShellComposite chained from the no-stored-Mac branch of reconnectActiveMacIfAvailable with a restoring-gate runner and bounded deadline.

Safety/concurrency: per-attempt autoAttachGeneration, one in-flight attempt, cancelAutoAttach on sign-out, and user-initiated manual/QR/device-tree flows superseding in-flight auto-attach (including mid-connect() via connectionGeneration). Physical devices reject and do not persist loopback routes.

Broad unit/composite test coverage plus a design doc under plans/feat-ios-auto-attach/.

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


Summary by cubic

Adds registry-driven auto-attach on iOS so a signed-in phone auto-connects to its team’s single reachable Mac without QR or manual entry; if ambiguous or unreachable, it falls back to the pair screen.

  • New Features

    • Auto-attach on cold start when no stored Mac; reuses connectToRegistryInstance and persists to MobilePairedMacStore.
    • MobileAutoAttachTargetSelector: prefers online; else the single most-recent reachable; ambiguity → no attach.
    • MobileAttachRoutePriority: shared route-ordering used by reconnect, switcher, device-tree tap, and auto-attach.
    • Optional MobileAutoAttachPresenceProviding; degrades to recency until presence lands.
    • Safety/UX: same-account only, loopback skipped on devices, single bounded attempt with dedupe and cancellation.
  • Migration

    • Behind mobileAutoAttach (MobileAutoAttachFlag): DEBUG on, Release off; override with UserDefaults key cmux.mobile.autoAttach.enabled.
    • To dogfood in Release, set cmux.mobile.autoAttach.enabled = true.

Written for commit 59bc671. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added automatic Mac discovery and attachment when signing in without a previously paired Mac. The app intelligently selects the best available Mac using presence information and recent connection history, with graceful fallback to manual pairing when needed.

A signed-in phone whose team has one reachable Mac now connects on the
cold-start path with no QR scan or manual host entry. The attach ticket is
route-discovery only; the Mac authorizes the mint purely on matching Stack
account, so a registry route + Stack token is sufficient (no prior pairing).

- MobileAutoAttachTargetSelector: pure target picker (online-preferred, else
  single most-recently-seen with a reachable route; ambiguity → nil → manual).
- MobileAttachRoutePriority: shared route-priority helper (single source of
  truth for reconnect, switch, device-tree tap, auto-attach).
- MobileAutoAttachFlag: mobileAutoAttach flag, DEBUG on / Release off, override
  via cmux.mobile.autoAttach.enabled.
- attemptAutoAttachIfEligible chains from the no-stored-mac reconnect branch;
  bounded, cancellable, one attempt per generation; reuses connectToRegistryInstance
  (Stack-authenticated mint + paired-mac persist) so the next launch takes the
  normal reconnect path.
- Presence seam optional (MobileAutoAttachPresenceProviding); degrades to
  recency until presence (#5792) lands.

Tests: pure selector (online>recency, no-candidate, ambiguity) + composite
behavior (connects+persists, fall-through, one-attempt-per-generation, flag off,
already-connected). swift test green for CmuxMobileShellModel and CmuxMobileShell;
iOS simulator arm64 build succeeds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 13, 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 Jun 13, 2026 12:37pm
cmux-staging Building Building Preview, Comment Jun 13, 2026 12:37pm

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements registry-driven auto-attach for iOS: when a user signs in without a previously stored paired Mac, the system automatically selects and connects to a reachable Mac from the device registry using deterministic rules (presence-based filtering, recency-based selection, ambiguity detection) protected by generation-based cancellation to prevent stale attempts from interfering with user pairing actions.

Changes

iOS Registry-Driven Auto-Attach Feature

Layer / File(s) Summary
Presence Protocol & Feature Flag
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileAutoAttachPresenceProviding.swift, Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachFlag.swift
New MobileAutoAttachPresenceProviding protocol supplies async online device IDs with nil fallback semantics. New MobileAutoAttachFlag enum provides UserDefaults-overridable build-time default (true in DEBUG, false in Release).
Route Priority & Selection Utilities
Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAttachRoutePriority.swift
New MobileAttachRoutePriority enum adds sortsBefore for deterministic route ordering by priority/ID and firstReachableHostPort for priority-ordered iteration with transport-kind filtering and optional loopback rejection.
Target Selection Policy & Tests
Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachTargetSelector.swift, Packages/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileAutoAttachTargetSelectorTests.swift
New MobileAutoAttachTargetSelector enum implements pure selection: uses presence-online filtering when available (exactly-one rule), otherwise strict recency-based selection (nil on ties). Per-device candidate selection prefers freshest reachable instance, rejects non-controllable hosts, and handles loopback rejection. Comprehensive test suite validates recency, presence, ambiguity, and loopback scenarios.
Auto-Attach State Machine & Reconnect Integration
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
Adds generation-based state tracking (autoAttachGeneration, autoAttachRunningGeneration, autoAttachOwnsRestoringGate) and injection points (autoAttachEnabled, optional autoAttachPresence). Sign-out calls cancelAutoAttach() to reset in-flight attempts. reconnectActiveMacIfAvailable attempts registry-driven auto-attach when no stored Mac exists, owned by the restoring gate and bounded by the session deadline; falls through to add-device UI on failure. connectToRegistryInstance extended with rejectLoopback and supersedeAutoAttach parameters; user taps supersede auto-attach, and loopback routes are filtered before persistence. beginPairingAttempt and connectManualHost gain supersession parameters to cancel in-flight auto-attach on user pairing. Route selection refactored to delegate to MobileAttachRoutePriority helpers.
End-to-End Auto-Attach Test Coverage
Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutoAttachTests.swift
Comprehensive @Suite with in-memory test doubles (identity, paired-Mac store, registry with forced outcomes and async gate, reachability stub) and fixture builder. Covers: single-candidate success with persistence; no-candidate/disabled-flag/already-connected short-circuits; concurrent deduping to one attempt; sequential retry; reconnect chaining with gate safety under concurrent overlap; sign-out clearing suppression for next sign-in; account-switch isolation; manual pairing/host supersession without hint clobbering; registry failures forcing manual fallback; loopback handling; and ambiguous device selection fallthrough.
Test Harness: Workspace List Hold & Ticket Error
Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift
Extended LivenessHostRouter with holdWorkspaceList flag and setHoldWorkspaceList(_:) to park workspace.list responses mid-handshake, plus workspaceListRequestSeen() to detect endpoint calls. Added mobile.attach_ticket.create RPC case returning method_not_found error. Updated errorFrame helper to accept optional code field in error envelope.
Design Documentation
plans/feat-ios-auto-attach/DESIGN.md
Specification of registry-driven auto-attach goals, target-selection rules (presence/recency/ambiguity), composite orchestration with generation-based supersession, lifecycle wiring from reconnect path, ambiguity/outage fallbacks (manual pairing), and test/localization strategy.

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly Related PRs

  • manaflow-ai/cmux#5564: The main PR's registry-driven auto-attach logic relies on the "restoring session" gate/deadline behavior and modifies reconnectActiveMacIfAvailable similarly to prevent stale stored-Mac hangs.
  • manaflow-ai/cmux#5543: Both PRs modify MobileShellComposite's reconnect/sign-out generation and "restoring session" gating flow, with the main PR extending the no-stored-Mac path to run registry-driven auto-attach while the retrieved PR adds persisted known-paired-Mac restoring-session control.

🐰 A new Mac appears from the ether,
No pairing? No problem—we find it together!
Generation guards stand watch through the night,
While presence and recency guide us aright.


Important

Pre-merge checks failed

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

❌ Failed checks (6 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error MobileShellComposite.swift adds an auto-attach restoringDeadline Task that calls ContinuousClock().sleep(for:) to cap the restoring gate window. Refactor auto-attach restoring-gate timeout to reuse an existing non-expanded timeout mechanism; remove the new ContinuousClock().sleep(for:) timer used only for auto-attach.
Cmux Algorithmic Complexity ❌ Error Nested filter/sorted scans: MobileAutoAttachTargetSelector filters+sorts device.instances and calls firstReachableHostPort, which sorts routes per instance (lines 66-121, 34-52). Precompute/cache priority-sorted routes (or avoid per-call sorting) and reuse supportedKinds Set; add documented bounds/benchmark for the ~1000-device auto-attach path.
Cmux Swift Concurrency ❌ Error MobileShellComposite.swift adds a meaningful best-effort registry refresh via an untracked fire-and-forget Task { ... } in refreshRoutesFromRegistry (called without await). Refactor refreshRoutesFromRegistry to be async and awaited from the owning recovery flow, or store the Task in a cancellable property keyed to the attempt/generation and cancel on supersede/sign-out.
Cmux Swift File And Package Boundaries ❌ Error MobileShellComposite.swift is 5,146 lines; PR adds 443 lines (466 changes) and mixes UI + persistence + networking, violating swift-file-package-boundaries thresholds. Extract the auto-attach orchestration/state machine (gate/gen/connect/selection) into a dedicated SwiftPM module/coordinator so MobileShellComposite stays UI/composition-only, and reduce/undo the large in-file responsibility growth.
Cmux Swift Logging ❌ Error MobileShellComposite.swift has a file-scoped private let mobileShellLog = Logger(...) without nonisolated private let, violating .github/review-bot-rules/swift-logging.md. Change the logger to nonisolated private let mobileShellLog = Logger(...) (and any similar file-scoped Logger constants) so it’s safe under MainActor-by-default isolation.
Cmux Architecture Rethink ❌ Error MobileShellComposite’s new auto-attach flow resolves the restoring gate via ContinuousClock().sleep timeout, violating swift-architectural-rethink.md’s rule against timing/blocking repair paths. Make restoring-gate resolution fully state-driven (generation + attach/connect outcome) and remove ContinuousClock sleep/delayed task; gate should clear only via deterministic state transitions.
Docstring Coverage ⚠️ Warning Docstring coverage is 24.14% 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 and specifically summarizes the main feature: registry-driven auto-attach enabling signed-in phones to connect automatically, addressing the core problem statement.
Description check ✅ Passed The description is comprehensive and well-structured, covering the gap being addressed, key technical findings, mechanism, flagging strategy, fallback behavior, concurrency handling, loopback safety, testing, and localization; all major required sections from the template are addressed.
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 Checked production Swift diffs (new presence protocol, auto-attach selector/flag/priority, and MobileShellComposite MainActor logic). No Swift 6 actor-isolation red flags found (no Task.detached/UI...
Cmux Expensive Synchronous Load ✅ Passed Scanned the PR’s Swift files; none reference RestorableAgentSessionIndex.load() or other cached index accessors, so no expensive sync index load was added/moved onto main/interactive paths.
Cmux Cache Substitution Correctness ✅ Passed Auto-attach uses fresh deviceRegistry.listDevices() for connections and only upserts pairing/routes after a successful connect; the persisted hasKnownPairedMac hint has cold fallback (key-absen...
Cmux No Hacky Sleeps ✅ Passed PR-related changed files provided are all Swift (.swift) and a design doc (.md); no TS/JS/shell/build/runtime scripts or workflow/runtime files to violate runtime-no-hacky-sleeps.
Cmux Swift @Concurrent ✅ Passed In PR Swift files, there’s no @concurrent usage and no nonisolated async declarations. Auto-attach’s heavy work awaits actor-isolated DeviceRegistryService.listDevices().
Cmux User-Facing Error Privacy ✅ Passed Production diffs add no new L10n/UIAlert/Recovery/user-visible error copy; added string literals are internal (UserDefaults key, analytics event, method="manual").
Cmux Full Internationalization ✅ Passed PR only adds new Swift protocols/logic and tests; no touched localization catalogs/resources, and no Swift localization keys/string literals that require catalog updates found in production files.
Cmux Swiftui State Layout ✅ Passed SwiftUI layout rules not violated: MobileShellComposite uses @Observable (no ObservableObject/@published); CMUXMobileRootView/OnboardingFlow have no GeometryReader, lazy/list store row subtrees, or...
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR-related Swift files contain no NSWindow/NSPanel/NSWindowController/SwiftUI Window/WindowGroup or close-shortcut identifier/ownership code; no violations of swift-auxiliary-window-close-shortcuts...
Cmux Source Artifacts ✅ Passed PR #6044 adds/modifies only Swift source/tests and plans/feat-ios-auto-attach/DESIGN.md; none of the forbidden artifact dirs/files (e.g., DerivedData/tmp/.swiftpm/.iter-logs) appear in the changed...
✨ 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-ios-auto-attach

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 Jun 13, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds registry-driven auto-attach to the iOS app: a signed-in phone with no stored pairing now queries the team-scoped device registry, picks the single unambiguous Mac (presence-online first, else the strictly most-recently-seen with a reachable route), and connects via the existing connectToRegistryInstance path — so onboarding becomes "sign in → connected" with no QR scan. The flag defaults to DEBUG-on / Release-off for safe dogfood rollout.

  • Concurrency and safety are handled through a monotonic per-attempt generation (not a boolean), explicit supersession at every user-initiated entry point, loopback rejection on physical devices, and per-await account guards — all backed by 23 composite behavior tests and 13 pure-selector tests.
  • New model types (MobileAutoAttachTargetSelector, MobileAttachRoutePriority, MobileAutoAttachFlag, MobileAutoAttachPresenceProviding) are correctly scoped to a separate package for independent testability; the shell composite receives them as injected values.
  • Minor issues: the now parameter on selectTarget is declared and passed at every call site but never used internally; the ios_auto_attach_attempt analytics event captures the stale UI-cache count instead of the freshly-fetched device count; and one test comment contradicts its own assertion.

Confidence Score: 4/5

Safe to merge; the new auto-attach path defaults off in Release builds and all concurrency, cross-account, and loopback-rejection scenarios are covered by tests.

The orchestration is thoughtfully designed with generation-based supersession, explicit account guards after every suspension, and a comprehensive test suite. The three issues found are all non-blocking: a dead now parameter that misleads callers, a stale device count in one analytics event, and a copy-pasted comment that contradicts its test. None affect correctness of the connection path.

MobileAutoAttachTargetSelector.swift (dead now parameter in public API) and MobileShellComposite.swift (stale registryDevices.count in analytics).

Important Files Changed

Filename Overview
Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachTargetSelector.swift New pure selector for picking the auto-attach target; logic is sound and well-tested, but the now parameter is declared in the public API and passed by every call site yet never forwarded to either internal helper, leaving it silently inert.
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Core orchestration of registry-driven auto-attach: generation-based concurrency, loopback rejection, supersession, and gate lifecycle are carefully handled; analytics event captures stale registryDevices.count rather than the fresh list used for the decision.
Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAttachRoutePriority.swift New shared route-priority helper extracted from the shell; straightforward promotion of existing logic with the new rejectLoopback flag, no issues found.
Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachFlag.swift Clean feature-flag helper: DEBUG-on / Release-off default with UserDefaults override; no issues.
Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutoAttachTests.swift Comprehensive 23-case test suite covering concurrency, supersession, loopback, and gate lifecycle; one test carries a copy-pasted comment that contradicts its own assertion.
Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift Test support: adds holdWorkspaceList gate and method_not_found fallback for mobile.attach_ticket.create; the pollUntil helper's Task.sleep is test-only scaffolding and passes the no-sleep rule.
Packages/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileAutoAttachTargetSelectorTests.swift Pure-selector tests cover the key cases (online preference, recency tie, loopback rejection, ambiguity); no issues.
plans/feat-ios-auto-attach/DESIGN.md Design document committed as a durable artifact under plans/; passes the source-control-artifacts rule as an intentional design doc.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[reconnectActiveMacIfAvailable] --> B{Stored paired Mac?}
    B -- yes --> C[reconnect stored Mac path]
    B -- no --> D{autoAttachEnabled?}
    D -- no --> E[setHasKnownPairedMac false\nfinishStoredMacReconnect\n→ pair screen]
    D -- yes --> F{autoAttachInFlight?}
    F -- yes --> G[return false\nsecond trigger deduped]
    F -- no --> H[runAutoAttachOwningRestoringGate\nholds RestoringSessionView up]
    H --> I[beginAutoAttachGeneration\nclaim generation token]
    I --> J[deviceRegistry.listDevices\nfresh authoritative query]
    J --> K{outcome == .ok?}
    K -- no --> L[loadRegistryDevices for UI\nfall through → pair screen]
    K -- yes --> M[loadRegistryDevices for UI\ncheck stillCurrent]
    M --> N[autoAttachPresence.onlineDeviceIDs]
    N --> O[MobileAutoAttachTargetSelector.selectTarget\npure, deterministic]
    O --> P{single obvious Mac?}
    P -- no --> Q[fall through → pair screen]
    P -- yes --> R{stillCurrent?}
    R -- no --> S[discard: account switch\nor user pairing]
    R -- yes --> T[connectToRegistryInstance\nsupersedeAutoAttach=false]
    T --> U{connected?}
    U -- yes --> V[persist to MobilePairedMacStore\nnext launch = stored-mac path]
    U -- no --> Q
    V --> W[return true\nRestoringSessionView → connected]
Loading

Reviews (1): Last reviewed commit: "iOS: registry-driven auto-attach (sign i..." | Re-trigger Greptile

Comment on lines +58 to +65
public static func selectTarget(
devices: [RegistryDevice],
supportedRouteKinds: [CmxAttachTransportKind],
presenceOnlineDeviceIDs: Set<String> = [],
presenceAvailable: Bool = false,
rejectLoopback: Bool = false,
now: Date = Date()
) -> Candidate? {

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 The now parameter is declared in the public signature and passed by every call site (including performAutoAttach, which supplies runtime?.now() ?? Date()), but it is never forwarded to candidate(for:supportedRouteKinds:rejectLoopback:) or mostRecentUnambiguous(_:). Callers therefore believe they are influencing recency-based selection — e.g. an age cutoff for stale devices — when the value has no effect at all. If absolute-recency filtering is deferred, the parameter should either be removed now or annotated clearly so future implementors know it is intentionally a no-op.

Suggested change
public static func selectTarget(
devices: [RegistryDevice],
supportedRouteKinds: [CmxAttachTransportKind],
presenceOnlineDeviceIDs: Set<String> = [],
presenceAvailable: Bool = false,
rejectLoopback: Bool = false,
now: Date = Date()
) -> Candidate? {
public static func selectTarget(
devices: [RegistryDevice],
supportedRouteKinds: [CmxAttachTransportKind],
presenceOnlineDeviceIDs: Set<String> = [],
presenceAvailable: Bool = false,
rejectLoopback: Bool = false
) -> Candidate? {

Comment on lines +1691 to +1694
analytics.capture("ios_auto_attach_attempt", [
"candidate_device_count": .int(registryDevices.count),
"presence_available": .bool(presenceOnline != nil),
])

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 The analytics event fires before loadRegistryDevices() has been called, so registryDevices.count reflects the UI cache from the previous load, not the freshDevices list that selectTarget just used to find a candidate. On a cold-start with no prior cache this reports 0; after a prior refresh it may report a count that doesn't match the set actually evaluated. Using freshDevices.count gives the accurate cardinality for this event.

Suggested change
analytics.capture("ios_auto_attach_attempt", [
"candidate_device_count": .int(registryDevices.count),
"presence_available": .bool(presenceOnline != nil),
])
analytics.capture("ios_auto_attach_attempt", [
"candidate_device_count": .int(freshDevices.count),
"presence_available": .bool(presenceOnline != nil),
])

Comment on lines +290 to +294
@Test func sequentialRetryAfterFailureIsAllowed() async throws {
// After an attempt finishes without connecting, the in-flight flag is
// cleared, so a later trigger may retry (the flag is not a permanent
// one-shot latch). First call: empty registry → no candidate → false.
// Then the same store, given a candidate registry, connects on retry.

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 The block comment says "First call: empty registry → no candidate → false" and "Then the same store, given a candidate registry, connects on retry", but the FakeRegistry is constructed with mac-A from the start and #expect(first) asserts true — the first call connects successfully. The comment appears to be left over from an earlier test design and now contradicts both the fixture and the assertion.

Suggested change
@Test func sequentialRetryAfterFailureIsAllowed() async throws {
// After an attempt finishes without connecting, the in-flight flag is
// cleared, so a later trigger may retry (the flag is not a permanent
// one-shot latch). First call: empty registry → no candidate → false.
// Then the same store, given a candidate registry, connects on retry.
@Test func sequentialRetryAfterFailureIsAllowed() async throws {
// After an attempt finishes (with or without connecting), the in-flight
// flag is cleared, so a later trigger may retry (the flag is not a
// permanent one-shot latch). First call: registry has mac-A → connects
// → true. Second call: already connected → short-circuits via the top
// guard → false, proving the flag did not latch permanently.

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!

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 59bc671610

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Registry unreachable/unauthorized: degrade to manual. Still refresh
// the UI cache (it honors the same outcome semantics) so the device
// tree reflects the latest known state.
await loadRegistryDevices()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fall through immediately after registry failure

In the .authRejected / .transientFailure branch this awaits loadRegistryDevices(), which calls deviceRegistry.listDevices() again. When /api/devices is down or timing out, a fresh-install auto-attach pays the failed registry request twice before returning to the pair flow, keeping the auto-attach attempt in-flight and deduping other triggers after this code has already decided it will not connect. Refresh the device tree asynchronously or reuse the failed outcome instead of blocking the fallback path.

Useful? React with 👍 / 👎.

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

🤖 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/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 1448-1711: Extract the entire auto-attach state machine (the logic
and state around
autoAttachGeneration/autoAttachRunningGeneration/autoAttachOwnsRestoringGate/autoAttachInFlight,
and the methods supersedeInFlightAutoAttach, cancelAutoAttach,
runAutoAttachOwningRestoringGate, attemptAutoAttachIfEligible,
beginAutoAttachGeneration, endAutoAttachGeneration, and performAutoAttach) into
a new helper class/struct AutoAttachCoordinator inside Packages/CmuxMobileShell;
move all generation/gate/deadline/connect orchestration there and keep
MobileShellComposite as a thin delegate that forwards calls and exposes only
simple lifecycle APIs (e.g. coordinator.supersedeInFlightAutoAttach(),
coordinator.attemptAutoAttachIfEligible(stackUserID:),
coordinator.runOwningRestoringGate(stackUserID:)). Ensure the new coordinator
receives required dependencies via initializer (deviceRegistry, analytics,
identityProvider, runtime, autoAttachPresence, functions or closures for
connectToRegistryInstance, cancelRemoteOperationTasks, loadRegistryDevices, and
accessors/mutators for
connectionState/connectionGeneration/hasKnownPairedMac/isSignedIn/isReconnectingStoredMac/didFinishStoredMacReconnectAttempt)
and preserves main-actor guarantees, generation guards, task cancellation, and
behavior (including restoringDeadline, presence handling, rejectLoopback logic,
and analytics events); update MobileShellComposite to call into the coordinator
and remove the moved state and methods from the composite.
- Around line 1592-1640: The auto-attach flow can start while a user-initiated
pairing/connect is already in flight, which lets the background attach overwrite
pairingAttemptID/connectionGeneration; fix by refusing to start auto-attach when
a user pairing is active: add a guard in attemptAutoAttachIfEligible (and/or at
the top of performAutoAttach's stillCurrent) that returns false if a user
pairing is in progress (check the existing pairingAttemptID or the
pairing-in-flight boolean used by connect/connectManualHost), so auto-attach
only proceeds when pairingAttemptID == nil (or pairing-in-flight == false) and
thus cannot supersede a user pairing attempt.

In
`@Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutoAttachTests.swift`:
- Around line 290-314: The test comment is incorrect about the first attempt:
the registry is initialized with a device (registry = FakeRegistry(devices:
[device(id: "mac-A", ...)])), so the first call to
sequentialRetryAfterFailureIsAllowed's store.attemptAutoAttachIfEligible(...)
actually succeeds (first == true). Update the comment above the test (the
paragraph starting "After an attempt finishes..." / the line "First call: ...")
to state that the registry contains a candidate so the first attempt connects
(expect true), and that the second sequential call is a no-op when already
connected (expect false); no code logic changes needed.
- Line 79: The count() method accesses the actor-isolated property macs but
isn’t marked async; change the signature func count() -> Int to func count()
async -> Int (or func count() async -> Int { macs.count }) and then update all
call sites (e.g., tests or helpers that invoke count()) to await the call and
make those callers async or wrap the call in Task/await, ensuring actor
isolation is preserved; reference: count() and the actor-isolated macs property.
🪄 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: 8cafceaf-8d85-43d4-bb59-4f8a38eddcc0

📥 Commits

Reviewing files that changed from the base of the PR and between 0b82dfe and 59bc671.

📒 Files selected for processing (9)
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileAutoAttachPresenceProviding.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutoAttachTests.swift
  • Packages/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift
  • Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAttachRoutePriority.swift
  • Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachFlag.swift
  • Packages/CmuxMobileShellModel/Sources/CmuxMobileShellModel/MobileAutoAttachTargetSelector.swift
  • Packages/CmuxMobileShellModel/Tests/CmuxMobileShellModelTests/MobileAutoAttachTargetSelectorTests.swift
  • plans/feat-ios-auto-attach/DESIGN.md

Comment on lines +1448 to +1711
// MARK: - Registry-driven auto-attach

/// Registry-driven auto-attach: the "sign in → connected" onboarding path.
///
/// When a signed-in phone has no stored pairing and is disconnected, this
/// loads the team-scoped device registry, picks the single obvious Mac (the
/// one online Mac, else the single most-recently-active Mac with a reachable
/// route), and connects to it via the same path the device-tree tap uses
/// (`connectToRegistryInstance`), which mints the attach ticket
/// Stack-authenticated on the Mac and persists the pairing. The persist means
/// the *next* launch takes the ordinary stored-mac reconnect path and never
/// re-runs auto-attach.
///
/// Conservative and bounded by construction:
/// - Same-account only: the registry is scoped to the signed-in user's team,
/// and the Mac authorizes the mint on matching Stack account, so a
/// different-account Mac never appears and could never be connected.
/// Cancel any in-flight auto-attach attempt and supersede it by bumping the
/// generation, so its still-alive awaits discard their results and its
/// runner/deadline can no longer resolve the restoring gate or drive a
/// connect. Called on sign-out and on every user-initiated pairing, so a stale
/// background auto-attach never overrides the new account or the user's manual
/// pairing.
///
/// When the superseded attempt was holding the launch restoring gate, this
/// resolves the gate here: the superseded runner's own cleanup is generation-
/// guarded and now a no-op, so without this a manual pairing that later fails
/// would leave RestoringSessionView stuck up.
/// Supersede any in-flight auto-attach on a USER-initiated pairing.
///
/// Beyond `cancelAutoAttach` (which bumps the generation so a parked attempt's
/// awaits discard their results), this also invalidates the active pairing
/// attempt when an auto-attach is in flight. That covers the case where
/// auto-attach has ALREADY entered its own `connectManualHost` and minted a
/// `pairingAttemptID`: bumping the generation alone would not stop that
/// in-flight connect (its `isCurrentPairingAttempt` checks key off the
/// attempt id, not the generation), so a user's invalid manual submission that
/// returns before its own `beginPairingAttempt` could still be overridden by
/// the background connect. Regenerating the attempt id makes auto-attach's
/// in-flight connect bail at its next `isCurrentPairingAttempt` check.
///
/// Guarded on `autoAttachInFlight` so it never clobbers an unrelated live
/// pairing attempt when no auto-attach is running.
private func supersedeInFlightAutoAttach() {
let wasInFlight = autoAttachInFlight
cancelAutoAttach()
guard wasInFlight else { return }
// Auto-attach may already be inside its own `connect()`, which installs the
// live client guarded by `connectionGeneration` (not `pairingAttemptID`),
// and whose `isCurrentPairingAttempt` check runs only AFTER it has set
// `connectionState = .connected`. Invalidating the pairing attempt alone
// would not stop that in-flight connect. Bump the connection generation and
// cancel in-flight remote work — the same supersession `beginPairingAttempt`
// performs — so auto-attach's running connect discards its result at its
// `generation == connectionGeneration` guard instead of landing over the
// user's explicit pairing. This runs BEFORE the validation guards in
// `connectManualHost`, so even an invalid user submission supersedes it.
invalidatePairingAttempt()
connectionGeneration = UUID()
cancelRemoteOperationTasks()
}

private func cancelAutoAttach() {
autoAttachGeneration &+= 1
autoAttachRunningGeneration = nil
if autoAttachOwnsRestoringGate {
autoAttachOwnsRestoringGate = false
isReconnectingStoredMac = false
didFinishStoredMacReconnectAttempt = true
}
}

/// Runs an auto-attach attempt that owns the launch restoring gate
/// (``RestoringSessionView``) for its whole lifetime, then resolves it.
///
/// Used from the no-stored-Mac reconnect branch. It holds the gate up while
/// auto-attach runs (so onboarding shows "Restoring session…" instead of a QR
/// flash), caps that window with the same bounded deadline the stored-Mac path
/// uses, and resolves the gate based on the per-attempt generation it owns —
/// not the stored-Mac reconnect generation, which a concurrent trigger may
/// bump. A newer auto-attach (or `cancelAutoAttach`) supersedes this one by
/// advancing ``autoAttachGeneration``, so a stale runner/deadline becomes a
/// no-op and cannot strand the gate or run a connect.
///
/// - Returns: `true` only when a live connection landed under this attempt.
private func runAutoAttachOwningRestoringGate(stackUserID: String?) async -> Bool {
let generation = beginAutoAttachGeneration()
isReconnectingStoredMac = true
// Mark that an auto-attach runner holds the gate, so a user-initiated
// pairing that supersedes this attempt via `cancelAutoAttach` resolves the
// gate instead of leaving it stranded.
autoAttachOwnsRestoringGate = true
// Cap the restoring-gate window: a single stale/offline registry candidate
// makes the manual connect hang on its timeout, so without this deadline
// the gate would stay up for the whole registry + connect timeout and the
// fresh-install path would look hung instead of falling through to the
// pair sheet. The connect keeps running in the background, so a later
// success still flips to the workspaces; this only resolves the visible
// gate. Guarded by the per-attempt generation, so a superseded attempt's
// deadline can never clear a newer attempt's gate. Bounded and cancellable
// (not a poll) — cancelled the instant the attempt returns below.
let restoringDeadline = Task { [weak self] in
try? await ContinuousClock().sleep(
for: .seconds(Self.storedMacReconnectRestoringDeadlineSeconds)
)
guard let self, !Task.isCancelled,
generation == self.autoAttachGeneration,
self.connectionState != .connected else { return }
self.isReconnectingStoredMac = false
self.didFinishStoredMacReconnectAttempt = true
}
let attached = await performAutoAttach(stackUserID: stackUserID, generation: generation)
restoringDeadline.cancel()
endAutoAttachGeneration(generation)
// Resolve the gate only if we are still the current attempt: a superseding
// attempt (or cancel) now owns the gate and will resolve it itself (the
// cancel path already did so via `cancelAutoAttach`).
if generation == autoAttachGeneration {
autoAttachOwnsRestoringGate = false
isReconnectingStoredMac = false
didFinishStoredMacReconnectAttempt = true
// This runner is the authoritative determiner for the no-stored-Mac
// path: it definitively found no stored Mac AND no auto-attach target.
// Clear the negative hint HERE (keyed on the auto-attach generation,
// which we still own) rather than relying on the caller's
// stored-mac-generation-guarded write, which a concurrent duplicate
// reconnect trigger can supersede — leaving `pairedMacHintUndetermined`
// unresolved and the restoring gate stuck on a fresh install.
if !attached, isSignedIn, connectionState != .connected {
hasKnownPairedMac = false
}
}
return attached
}

/// Public/test entry point for a one-shot auto-attach attempt that does NOT
/// own the restoring gate.
///
/// Dedupes against any attempt already in flight: if one is running, returns
/// `false` immediately rather than racing a second registry load / destructive
/// connect, satisfying the one-attempt-at-a-time contract for every caller.
///
/// - Returns: `true` only when a live connection landed under this attempt.
@discardableResult
func attemptAutoAttachIfEligible(stackUserID: String?) async -> Bool {
guard autoAttachEnabled, isSignedIn, connectionState != .connected else { return false }
guard !autoAttachInFlight else { return false }
let generation = beginAutoAttachGeneration()
defer { endAutoAttachGeneration(generation) }
return await performAutoAttach(stackUserID: stackUserID, generation: generation)
}

/// Claim the next auto-attach generation and mark it the in-flight attempt.
/// Synchronous (no await), so a concurrent caller observes `autoAttachInFlight`
/// true at its next main-actor hop and dedupes.
private func beginAutoAttachGeneration() -> Int {
autoAttachGeneration &+= 1
let generation = autoAttachGeneration
autoAttachRunningGeneration = generation
return generation
}

/// Clear the in-flight marker when `generation` is still current, so a
/// superseded attempt does not erase a newer attempt's running marker.
private func endAutoAttachGeneration(_ generation: Int) {
guard generation == autoAttachGeneration else { return }
autoAttachRunningGeneration = nil
}

/// The shared auto-attach flow: load the registry, pick the single obvious
/// Mac, and connect to it via the proven registry connect path.
///
/// Guarded by the per-attempt `generation`: after every suspension it bails
/// unless this is still the current attempt AND the same signed-in account, so
/// a task left alive by a sign-out/account switch or superseded by a newer
/// attempt can never resume and drive a destructive connect. This is the
/// equivalent of the stored-Mac path owning a pairing attempt id before its
/// connect, and mirrors the account-switch guard in ``loadPairedMacs`` /
/// ``loadRegistryDevices``.
///
/// - Returns: `true` only when a live connection landed under this attempt.
private func performAutoAttach(stackUserID: String?, generation: Int) async -> Bool {
// Capture the requesting account; after any suspension the result is
// discarded unless this is still the same signed-in user and the current
// attempt.
let requestingUserID = stackUserID ?? identityProvider?.currentUserID
func stillCurrent() -> Bool {
generation == autoAttachGeneration
&& isSignedIn
&& connectionState != .connected
&& identityProvider?.currentUserID == requestingUserID
}
guard autoAttachEnabled, stillCurrent(), let deviceRegistry else { return false }
// Auto-attach decides from a FRESHLY-confirmed registry list, not the
// store-wide `registryDevices` cache. `loadRegistryDevices()` deliberately
// keeps the prior cache on a transient failure (so a UI blip never blanks
// the device tree), but auto-attach must NOT connect off a stale list
// during a registry outage — the contract is to degrade to manual. So we
// read the outcome directly and proceed only on `.ok`; `.authRejected` /
// `.transientFailure` fall through to the pair screen. The UI cache is
// refreshed separately below so the tree still benefits from this load.
let outcome = await deviceRegistry.listDevices()
// The load suspended the main actor; bail if the user connected, signed
// out, switched accounts, or a newer attempt superseded this one.
guard stillCurrent() else { return false }
guard case let .ok(freshDevices) = outcome else {
// Registry unreachable/unauthorized: degrade to manual. Still refresh
// the UI cache (it honors the same outcome semantics) so the device
// tree reflects the latest known state.
await loadRegistryDevices()
return false
}
// Keep the UI device tree in sync with this fresh list.
await loadRegistryDevices()
guard stillCurrent() else { return false }

let presenceOnline = await autoAttachPresence?.onlineDeviceIDs()
guard stillCurrent() else { return false }

// On a physical phone, reject loopback routes: a `127.0.0.1` route names
// the phone itself, not the Mac, and loopback is Stack-auth-trusted, so
// auto-dialing it would fail and could hand the bearer to a phone-local
// listener. The simulator (where 127.0.0.1 IS the host Mac) keeps loopback.
let rejectLoopback = Self.isPhysicalDevice
guard let target = MobileAutoAttachTargetSelector.selectTarget(
devices: freshDevices,
supportedRouteKinds: runtime?.supportedRouteKinds ?? [],
presenceOnlineDeviceIDs: presenceOnline ?? [],
presenceAvailable: presenceOnline != nil,
rejectLoopback: rejectLoopback,
now: runtime?.now() ?? Date()
) else {
return false
}

// Final guard immediately before the destructive connect: if a manual
// pairing began (which calls `cancelAutoAttach`), the user connected, or
// the account changed since the last await, do NOT start
// connectToRegistryInstance — that would invalidate the user's manual
// pairing attempt. `selectTarget` is synchronous, so this covers the
// window up to the connect.
guard stillCurrent() else { return false }

analytics.capture("ios_auto_attach_attempt", [
"candidate_device_count": .int(registryDevices.count),
"presence_available": .bool(presenceOnline != nil),
])
// Reuse the proven registry connect path: it mints Stack-authenticated,
// rolls back to the previous active Mac on failure, and persists the
// pairing into the store on success. `supersedeAutoAttach: false` marks
// this as auto-attach's OWN connect (an explicit per-call parameter, not a
// shared marker), so the `beginPairingAttempt` inside it does not cancel
// auto-attach. A concurrent USER pairing during this connect carries
// `supersedeAutoAttach: true` on its own call and still supersedes us.
await connectToRegistryInstance(
device: target.device,
instance: target.instance,
rejectLoopback: rejectLoopback,
supersedeAutoAttach: false
)
let connected = connectionState == .connected
analytics.capture("ios_auto_attach_result", ["connected": .bool(connected)])
return connected
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Extract the new auto-attach state machine out of this store before it grows further.

This PR adds another large orchestration block to a production Swift file that is already far past the repo’s size threshold and already mixes connection lifecycle, registry caching, pairing, drafts, notifications, and terminal liveness. Please move the auto-attach generation/gate/connect flow into a dedicated helper/coordinator inside Packages/CmuxMobileShell and keep MobileShellComposite as the composition surface.

As per coding guidelines, production Swift files over 800 lines should be flagged when a PR adds more than 250 lines without extracting mixed responsibilities.

🤖 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/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 1448 - 1711, Extract the entire auto-attach state machine (the
logic and state around
autoAttachGeneration/autoAttachRunningGeneration/autoAttachOwnsRestoringGate/autoAttachInFlight,
and the methods supersedeInFlightAutoAttach, cancelAutoAttach,
runAutoAttachOwningRestoringGate, attemptAutoAttachIfEligible,
beginAutoAttachGeneration, endAutoAttachGeneration, and performAutoAttach) into
a new helper class/struct AutoAttachCoordinator inside Packages/CmuxMobileShell;
move all generation/gate/deadline/connect orchestration there and keep
MobileShellComposite as a thin delegate that forwards calls and exposes only
simple lifecycle APIs (e.g. coordinator.supersedeInFlightAutoAttach(),
coordinator.attemptAutoAttachIfEligible(stackUserID:),
coordinator.runOwningRestoringGate(stackUserID:)). Ensure the new coordinator
receives required dependencies via initializer (deviceRegistry, analytics,
identityProvider, runtime, autoAttachPresence, functions or closures for
connectToRegistryInstance, cancelRemoteOperationTasks, loadRegistryDevices, and
accessors/mutators for
connectionState/connectionGeneration/hasKnownPairedMac/isSignedIn/isReconnectingStoredMac/didFinishStoredMacReconnectAttempt)
and preserves main-actor guarantees, generation guards, task cancellation, and
behavior (including restoringDeadline, presence handling, rejectLoopback logic,
and analytics events); update MobileShellComposite to call into the coordinator
and remove the moved state and methods from the composite.

Source: Coding guidelines

Comment on lines +1592 to +1640
func attemptAutoAttachIfEligible(stackUserID: String?) async -> Bool {
guard autoAttachEnabled, isSignedIn, connectionState != .connected else { return false }
guard !autoAttachInFlight else { return false }
let generation = beginAutoAttachGeneration()
defer { endAutoAttachGeneration(generation) }
return await performAutoAttach(stackUserID: stackUserID, generation: generation)
}

/// Claim the next auto-attach generation and mark it the in-flight attempt.
/// Synchronous (no await), so a concurrent caller observes `autoAttachInFlight`
/// true at its next main-actor hop and dedupes.
private func beginAutoAttachGeneration() -> Int {
autoAttachGeneration &+= 1
let generation = autoAttachGeneration
autoAttachRunningGeneration = generation
return generation
}

/// Clear the in-flight marker when `generation` is still current, so a
/// superseded attempt does not erase a newer attempt's running marker.
private func endAutoAttachGeneration(_ generation: Int) {
guard generation == autoAttachGeneration else { return }
autoAttachRunningGeneration = nil
}

/// The shared auto-attach flow: load the registry, pick the single obvious
/// Mac, and connect to it via the proven registry connect path.
///
/// Guarded by the per-attempt `generation`: after every suspension it bails
/// unless this is still the current attempt AND the same signed-in account, so
/// a task left alive by a sign-out/account switch or superseded by a newer
/// attempt can never resume and drive a destructive connect. This is the
/// equivalent of the stored-Mac path owning a pairing attempt id before its
/// connect, and mirrors the account-switch guard in ``loadPairedMacs`` /
/// ``loadRegistryDevices``.
///
/// - Returns: `true` only when a live connection landed under this attempt.
private func performAutoAttach(stackUserID: String?, generation: Int) async -> Bool {
// Capture the requesting account; after any suspension the result is
// discarded unless this is still the same signed-in user and the current
// attempt.
let requestingUserID = stackUserID ?? identityProvider?.currentUserID
func stillCurrent() -> Bool {
generation == autoAttachGeneration
&& isSignedIn
&& connectionState != .connected
&& identityProvider?.currentUserID == requestingUserID
}
guard autoAttachEnabled, stillCurrent(), let deviceRegistry else { return false }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Block auto-attach while a user pairing attempt is already in flight.

This entrypoint only gates on sign-in/connection/auto-attach state. If a QR/manual pairing is already awaiting connect(), a later reconnect/foreground trigger can still enter auto-attach. Once that background flow reaches its own connectManualHost(... supersedeAutoAttach: false), it overwrites pairingAttemptID and connectionGeneration, so the user-started pairing is deterministically superseded by the background attach.

Suggested fix
     `@discardableResult`
     func attemptAutoAttachIfEligible(stackUserID: String?) async -> Bool {
-        guard autoAttachEnabled, isSignedIn, connectionState != .connected else { return false }
+        guard autoAttachEnabled,
+              isSignedIn,
+              connectionState != .connected,
+              pairingAttemptMethod == nil else { return false }
         guard !autoAttachInFlight else { return false }
         let generation = beginAutoAttachGeneration()
         defer { endAutoAttachGeneration(generation) }
         return await performAutoAttach(stackUserID: stackUserID, generation: generation)
     }
@@
         func stillCurrent() -> Bool {
             generation == autoAttachGeneration
                 && isSignedIn
                 && connectionState != .connected
+                && pairingAttemptMethod == nil
                 && identityProvider?.currentUserID == requestingUserID
         }
📝 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
func attemptAutoAttachIfEligible(stackUserID: String?) async -> Bool {
guard autoAttachEnabled, isSignedIn, connectionState != .connected else { return false }
guard !autoAttachInFlight else { return false }
let generation = beginAutoAttachGeneration()
defer { endAutoAttachGeneration(generation) }
return await performAutoAttach(stackUserID: stackUserID, generation: generation)
}
/// Claim the next auto-attach generation and mark it the in-flight attempt.
/// Synchronous (no await), so a concurrent caller observes `autoAttachInFlight`
/// true at its next main-actor hop and dedupes.
private func beginAutoAttachGeneration() -> Int {
autoAttachGeneration &+= 1
let generation = autoAttachGeneration
autoAttachRunningGeneration = generation
return generation
}
/// Clear the in-flight marker when `generation` is still current, so a
/// superseded attempt does not erase a newer attempt's running marker.
private func endAutoAttachGeneration(_ generation: Int) {
guard generation == autoAttachGeneration else { return }
autoAttachRunningGeneration = nil
}
/// The shared auto-attach flow: load the registry, pick the single obvious
/// Mac, and connect to it via the proven registry connect path.
///
/// Guarded by the per-attempt `generation`: after every suspension it bails
/// unless this is still the current attempt AND the same signed-in account, so
/// a task left alive by a sign-out/account switch or superseded by a newer
/// attempt can never resume and drive a destructive connect. This is the
/// equivalent of the stored-Mac path owning a pairing attempt id before its
/// connect, and mirrors the account-switch guard in ``loadPairedMacs`` /
/// ``loadRegistryDevices``.
///
/// - Returns: `true` only when a live connection landed under this attempt.
private func performAutoAttach(stackUserID: String?, generation: Int) async -> Bool {
// Capture the requesting account; after any suspension the result is
// discarded unless this is still the same signed-in user and the current
// attempt.
let requestingUserID = stackUserID ?? identityProvider?.currentUserID
func stillCurrent() -> Bool {
generation == autoAttachGeneration
&& isSignedIn
&& connectionState != .connected
&& identityProvider?.currentUserID == requestingUserID
}
guard autoAttachEnabled, stillCurrent(), let deviceRegistry else { return false }
func attemptAutoAttachIfEligible(stackUserID: String?) async -> Bool {
guard autoAttachEnabled,
isSignedIn,
connectionState != .connected,
pairingAttemptMethod == nil else { return false }
guard !autoAttachInFlight else { return false }
let generation = beginAutoAttachGeneration()
defer { endAutoAttachGeneration(generation) }
return await performAutoAttach(stackUserID: stackUserID, generation: generation)
}
/// Claim the next auto-attach generation and mark it the in-flight attempt.
/// Synchronous (no await), so a concurrent caller observes `autoAttachInFlight`
/// true at its next main-actor hop and dedupes.
private func beginAutoAttachGeneration() -> Int {
autoAttachGeneration &+= 1
let generation = autoAttachGeneration
autoAttachRunningGeneration = generation
return generation
}
/// Clear the in-flight marker when `generation` is still current, so a
/// superseded attempt does not erase a newer attempt's running marker.
private func endAutoAttachGeneration(_ generation: Int) {
guard generation == autoAttachGeneration else { return }
autoAttachRunningGeneration = nil
}
/// The shared auto-attach flow: load the registry, pick the single obvious
/// Mac, and connect to it via the proven registry connect path.
///
/// Guarded by the per-attempt `generation`: after every suspension it bails
/// unless this is still the current attempt AND the same signed-in account, so
/// a task left alive by a sign-out/account switch or superseded by a newer
/// attempt can never resume and drive a destructive connect. This is the
/// equivalent of the stored-Mac path owning a pairing attempt id before its
/// connect, and mirrors the account-switch guard in ``loadPairedMacs`` /
/// ``loadRegistryDevices``.
///
/// - Returns: `true` only when a live connection landed under this attempt.
private func performAutoAttach(stackUserID: String?, generation: Int) async -> Bool {
// Capture the requesting account; after any suspension the result is
// discarded unless this is still the same signed-in user and the current
// attempt.
let requestingUserID = stackUserID ?? identityProvider?.currentUserID
func stillCurrent() -> Bool {
generation == autoAttachGeneration
&& isSignedIn
&& connectionState != .connected
&& pairingAttemptMethod == nil
&& identityProvider?.currentUserID == requestingUserID
}
guard autoAttachEnabled, stillCurrent(), let deviceRegistry else { return false }
🤖 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/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 1592 - 1640, The auto-attach flow can start while a user-initiated
pairing/connect is already in flight, which lets the background attach overwrite
pairingAttemptID/connectionGeneration; fix by refusing to start auto-attach when
a user pairing is active: add a guard in attemptAutoAttachIfEligible (and/or at
the top of performAutoAttach's stillCurrent) that returns false if a user
pairing is in progress (check the existing pairingAttemptID or the
pairing-in-flight boolean used by connect/connectManualHost), so auto-attach
only proceeds when pairingAttemptID == nil (or pairing-in-flight == false) and
thus cannot supersede a user pairing attempt.


func removeAll() async throws { macs.removeAll() }

func count() -> Int { macs.count }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make count() async to match actor isolation.

The count() method accesses the actor-isolated macs property but is not marked async. Actor-isolated methods must be async to maintain thread safety.

🔧 Proposed fix
-        func count() -> Int { macs.count }
+        func count() async -> Int { macs.count }
🤖 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/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutoAttachTests.swift`
at line 79, The count() method accesses the actor-isolated property macs but
isn’t marked async; change the signature func count() -> Int to func count()
async -> Int (or func count() async -> Int { macs.count }) and then update all
call sites (e.g., tests or helpers that invoke count()) to await the call and
make those callers async or wrap the call in Task/await, ensuring actor
isolation is preserved; reference: count() and the actor-isolated macs property.

Comment on lines +290 to +314
@Test func sequentialRetryAfterFailureIsAllowed() async throws {
// After an attempt finishes without connecting, the in-flight flag is
// cleared, so a later trigger may retry (the flag is not a permanent
// one-shot latch). First call: empty registry → no candidate → false.
// Then the same store, given a candidate registry, connects on retry.
let clock = TestClock()
let route = try loopbackRoute()
let pairedStore = InMemoryPairedMacStore()
let registry = FakeRegistry(devices: [device(id: "mac-A", lastSeen: clock.now, route: route)])
let store = makeStore(
devices: [],
pairedStore: pairedStore,
registry: registry,
clock: clock,
router: LivenessHostRouter(),
box: TransportBox()
)

let first = await store.attemptAutoAttachIfEligible(stackUserID: "user-1")
#expect(first)
// A second sequential call when already connected is a no-op via the top
// guard, proving the in-flight flag did not latch permanently.
let second = await store.attemptAutoAttachIfEligible(stackUserID: "user-1")
#expect(!second)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Comment doesn't match test logic.

The comment at Line 293 states "First call: empty registry → no candidate → false", but the registry is initialized with mac-A at Line 298, so the first attempt succeeds (Line 309 expects first == true).

The test appears to validate that sequential calls are allowed when already connected (not retry-after-failure). Either update the comment to match the actual behavior, or change the test to match the comment's intent (create registry with empty devices, call once expecting false, then populate it and retry expecting true).

🤖 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/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileAutoAttachTests.swift`
around lines 290 - 314, The test comment is incorrect about the first attempt:
the registry is initialized with a device (registry = FakeRegistry(devices:
[device(id: "mac-A", ...)])), so the first call to
sequentialRetryAfterFailureIsAllowed's store.attemptAutoAttachIfEligible(...)
actually succeeds (first == true). Update the comment above the test (the
paragraph starting "After an attempt finishes..." / the line "First call: ...")
to state that the registry contains a candidate so the first attempt connects
(expect true), and that the second sequential call is a no-op when already
connected (expect false); no code logic changes needed.

lawrencecchen added a commit that referenced this pull request Jun 13, 2026
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