Repository navigation
ios: remove iroh entirely; the relay is the default connection method - #11177
lawrencecchen wants to merge 57 commits into
Conversation
Deletes the iroh/irx runtime compositions, release-gate scenes, and lanes from the app layer. Legacy iroh install markers stay readable via MobileSameDeviceEvidenceProbe so device-registry ids survive upgrades.
…o feat-ios-remove-iroh
…ault method MobileConnectionMethod loses automatic (iroh) and direct (an iroh dial allowlist); persisted values for both no longer decode, which migrates those pairings to the relay default. Explicit tailscale persists. The relay method dials one synthesized route, relay pairings dial with no persisted route (the ticket carries the synthesized route), and the reconnect gates treat relay pairings as always dialable. Deleted: zero-touch iroh discovery, the iroh broker Forget revoke (Forget now cleans local rows and backup tombstones only), the iroh warm-focus fast path, iroh settings UI, direct-address UI, the iroh release-gate target, and the iroh plist/xcconfig config keys. Onboarding offers Relay or Tailscale Only. Computers group Relay first. Copy that taught iroh discovery now teaches the relay.
Store tests cover the relay default and the automatic/direct raw-value migration. Deleted the test suites whose subjects are gone (zero-touch discovery, forget revoke pinning/scope, discovery invalidation); backup team-routing and pending-delete replay keep their local-cleanup coverage without the revoke fake. The Mac's Relay Remote Access setting defaults on, because the relay is now the phone's default method and a fresh pairing must work out of the box; the toggle still turns it off.
…n action, settings body split for the type checker)
…w and balanced across #if DEBUG
…ix; drop iroh reconnect suites The relay method's route set now includes supported debug-loopback routes ahead of the synthesized relay route, exactly as loopback rode alongside iroh: it is the same-machine dev lane simulator auto-pair depends on, not a cross-method fallback, and production route sets compile it out. Without this, relay-as-default excluded every scripted loopback dial (and would have broken simulator pairing). MobileSettingsView's body is split into settingsContent plus two section helpers inside the struct, because the single inline expression exceeded the archive-time type-checker budget.
For a relay-method pairing the stored reconnect dial set is debug loopback (the same-machine dev lane) plus the synthesized relay route, appended so a pairing with no persisted route still dials; persisted Tailscale and host/port rows never dial under the relay method. The stored-ticket dial branch keys on the first route's kind, so a loopback-first set keeps the legacy dev dial and its bearer handling.
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Test attribution complete: the CmuxMobileShell local-suite failures (MobileMacConnectionPool, SecondaryInstanceAuthority, DismissSync, Coalescing, ReconnectRouteSelection) reproduce identically on origin/main (control test verified) and on the merge base — chronic, not from this branch. This branch's suite total is LOWER than the baseline (161 vs 205+ issue lines) with zero suite-level regressions. Known follow-up filed by this PR: the relay object name (v2::) carries no instance tag, so two tagged dev Mac builds on one machine contend for the host slot (last-wins); production single-build Macs are unaffected. |
The connection report captured transport-level relay steps but every shell-level route rejection (exchange error, incompatible build, tag mismatch, undecodable status) logged only to os_log, which the report cannot include. Each decision point in the pairing route loop now also writes one line to the in-app debug ring, so a copied report names the exact step that abandoned a route.
Root cause of the phone's relay connect failure: mobile.host.status is credential-free, so the phone's status exchange overtook its own in-flight admission and was answered with the identity-free public status; the phone then abandoned the route for missing identity (over iroh this race cannot happen because admission completes at the handshake). The relay arm now awaits admission settlement (the bounded wait in MobileHostRelayAdmission) and serves the same identity-bearing status an admitted iroh session gets; an unadmitted session still gets only the public status.
…ribe A compressed frame is [0x01][zlib(envelope)]; the magic byte can never begin a JSON envelope, so decoders handle old and new senders alike. The phone opts in with event_compression=deflate at subscribe; the Mac compresses each anchor's delta/full-frame envelope once (>=256 bytes, only when it shrinks) and picks per connection, so a subscriber that never asked keeps plain frames. Inflation is capped at the codec frame limit (zip-bomb guard) and a frame that fails to inflate is dropped like an unparseable envelope. Compression stays on the event fan-out path, so the bounded queues, coalescing, and replay semantics that keep reconnects lossless are untouched; a dedicated relay terminal channel remains a follow-up since its residual win after compression is one small JSON parse.
Stack access tokens are ES256 JWTs; both relay hops now verify them locally instead of calling the Stack API. The worker fetches and caches the project's JWKS per isolate (24h TTL, cooldown-limited refetch on an unknown kid for key rotation) and only falls back to /users/me for opaque non-JWT tokens. The Mac's verifier does the same with CryptoKit (P-256, JOSE raw signatures), taking session admission from a 110-290ms Stack round trip to about a millisecond once the key set is cached. Audience must equal the project id exactly: the :anon and :restricted suffixes are user classes a relay host rejects. Measured after the worker deploy: relay connect fell from ~390ms to ~150-180ms with fresh tokens.
The 1.5s between launch and the first relay dial needs attribution before restructuring the startup claim ordering; these marks split it into UI mount, awaitBootstrapped, and reconnect-claim spans in the copyable debug log.
|
I have read the CLA Document and I hereby sign the CLA You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot. |
…ache iss must equal <stackBase>/api/v1/projects/<projectId> (real-token verified), or its stack-auth<->hexclave rebrand alias host; the allow-set is computed from the configured base URL. A mismatch is a definitive invalid_token. The Mac's fetched JWKS (public keys only, never tokens or verdicts) now persists to UserDefaults with its fetch time and source URL, so the first admission after app launch skips the ~110ms key fetch. 24h TTL and refetch cooldown semantics unchanged.
…tion gating The roaming tests scripted a connect fate PER TRANSPORT INSTANCE. The pipelined connect-time subscribe added a second dial per candidate (its beginSend's ensureConnected precedes the workspace-list exchange), so the scripted .fail was consumed by the subscribe dial and the exchange re-dialed an unscripted default-success transport: the replacement exchange genuinely succeeded and the swap commit was CORRECT production behavior. The fakes now model the NETWORK PATH (up / down / parked): a dead path refuses every dial of the candidate, including the exchange retry, exactly like the real wire, so the failure scenarios mean what they say regardless of how many dials a candidate makes. New pinned invariant test: a host that rejects the pipelined subscribe must not gate (or fail) the roaming adoption exchange; the swap commits and the fallback sequential subscribe completes validation.
…hannel Replicates both wire paths for render-grid frames end to end: today's JSON event envelope (emitRenderGridEvent splice + phone dispatch's JSONSerialization parse + payload re-serialize + typed decode) against a hypothetical channelTerminal binary framing ([version][flags][surfaceID][anchor][seq][payload]) that hands the payload straight to the typed decoder. Measured on an M-series Mac, release build, p50 of 300 runs: 8KB delta win 115-120us/frame, 40KB full frame win 508-564us/frame (plain and zlib wire). Both are under the 1ms/delta threshold, so the dedicated channel is not built; the envelope tax is dwarfed by the typed JSONDecoder decode both paths share.
Cold-launch device logs show the reconnect claim at 0.14s but the first relay dial only at 1.09s. The ~0.9s hole was the awaited per-user backup fetch (network round trip + LWW merge) on the pre-dial path, pure waste for a relay-method pairing whose dial route is synthesized at connect time. The launch reconnect now dials immediately from persisted state while refreshFromBackup runs as a concurrent task. The refresh outcome still feeds the existing freshReconnectRoutesAfterLocalFailure retry: loadRefreshSnapshotIfNeeded awaits the task before its store read, so a non-relay pairing whose local routes are unusable or stale waits for the merge exactly as before, on the same generation/claim machinery. No second reconnect path is introduced. Tests pin both halves: a dialable pairing connects while the refresh is still blocked, and a failed local dial's refreshed-route retry waits for the blocked refresh and dials the route the merge produced.
…ubscribe The connect-time pipelined mobile.events.subscribe only fires with a learned capability snapshot, which lived solely in the in-process connection pool. Every cold launch therefore paid a sequential post-adoption subscribe (welcome 1.31s -> subscribe_ok 1.69s measured). Store: paired-mac schema v12 adds a device-local learned_capabilities column (same additive column-add style as v10 connection_method / v11 direct_addresses), setLearnedCapabilities never bumps LWW freshness, and the value is excluded from Codable state so it never rides the account backup. Decorators (build-scoped, team-scoped, backing-up, compatible) forward it like setDirectAddresses. Shell: capabilities learned post-adoption (and on mid-session host-status changes) update an in-memory per-device index and persist to the store; the connect-time snapshot read falls back to that index when the live pool has no entry, so the first connect of a process pipelines the exact last-known request. The live pool still wins when present, and a stale persisted set costs only the ordinary idempotent corrective re-subscribe. Tests: store round trip without LWW bump, v11->v12 migration on a seeded v11 database, and a cold-launch shell test proving a stale persisted set pipelines, corrects within one round trip, and persists the refreshed set (mirroring staleLearnedCapabilitiesReassertAndNextReconnectPipelinesExactly).
The first wss dial paid DNS + TCP + TLS on the connect path. At composition start the app now fires ONE fire-and-forget HEAD to the resolved relay origin's /healthz (RelayConnectAuth.resolvedRelayURL, wss swapped to https, unauthenticated worker route) so that state is already pooled when the dial starts. Session identity is the whole point and is now explicit: cmuxApp pins relayURLSession = URLSession.shared, passes it to RelayConnection.factory(urlSession:) (the session whose webSocketTask the relay transport dials with), and fires the pre-warm on that same instance, since URLSession pools connection state per session. No retries, no error surfacing, 5s request timeout. Privacy: the request carries no token, cookie, or identifying header; it reveals to the relay origin, which the app dials anyway, only that a launch happened. Tests pin the healthz URL derivation (wss->https, ws->http for loopback dev, explicit port kept, query/fragment dropped, unknown scheme fails closed).
The iOS app never wires independentEventByteStreamProvider or artifactLaneProvider (cmuxApp.swift constructs CMUXMobileRuntime without them), and both factories were additionally gated on route.kind == .iroh, which production supportedRouteKinds no longer contains. Deletes the session's optional-lane negotiation (prepareIndependentServerEvents, the event_transport advertisement, supportsPipelinedInitialEventSubscribe), openArtifactLane and the chat artifact lane fetch path (which could only mint a descriptor RPC and then always fall back), the lifecycle-gate admissions for both lanes, the runtime slots, and the irohDirectOnlyDialCandidates parameter no caller passed. dispatch(frame:) moves to MobileCoreRPCSession+EventDispatch.swift because the control reader still uses it. The CmxByteTransportRequest core field stays; the Mac still hosts these lanes for old phones. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…overy automaticIrohReconnectIsBlocked guards the shared automatic-reconnect backoff owner that relay and Tailscale redials use too, so it becomes automaticReconnectIsBlocked. secondaryIrohDiscoveryPending is the generic one-shot account-backup discovery intent, so it becomes secondaryPeerDiscoveryPending; its caller-less preserve helper is deleted. The terminal-lane resync reasons drop their iroh_ prefix (debug-log only; no test or analytics pins them), and supportedRoutes' unreachable trailing iroh/loopback filter goes away by switching exhaustively over the two-case connection method. No behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Persisted iroh rows still decode and display as inert entries, but the kind label read "Iroh", naming a transport this app can no longer dial. The per-Computer route diagnostics and the Settings active-transport row now label the .iroh kind "Legacy" (mobile.connections.method.legacy / mobile.settings.activeTransport.legacy, en + ja). Removes the 119 app and 103 CmuxMobileShellUI catalog keys that belonged to deleted iroh settings/diagnostics views (verified unreferenced by grep over Packages and ios; mobile.iroh.private.custom.unnamedMac stays, LocalizedMacBuildLabel still uses it), and rewrites comments that described iroh runtime behavior that no longer exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The in-app simulator release-gate runner was deleted earlier on this branch, so SIMCTL_CHILD_CMUX_IROH_RELEASE_GATE_MODE/_SCENARIO and SIMCTL_CHILD_CMUX_IROH_DISABLE_RELAY_CREDENTIAL_REFRESH had no reader (verified by grep over Packages, ios, and Sources), and --iroh-release-gate launched an app that silently ignored the gate. Removes the flag, its env threading, and the harness that existed only to drive that runner (run-iroh-release-gate.sh, its targets/verify libs, its .mjs suite, and the workflow's simulator-e2e job). The workflow keeps the Mac-side Tailscale version-skew job: the Mac still hosts iroh for old phones. mobile-attach.sh's legacy iroh acceptance is untouched; mobile-attach tests keep the launcher/ios-reload assertions that were not gate-specific and drop only the gate-driven ones (including the simulator test that used the flag to force the physical-device ticket policy). bash -n, actionlint, and node --test mobile-attach.test.mjs (40/40) pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
With --iroh-release-gate gone the readiness gate runs for every cursor, so the elif that deferred verification to the in-app release-gate runner could never fire. bash -n and node --test mobile-attach.test.mjs pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pass-level transient reconnect backoff was gated on having dialed an IROH route, so it silently stopped firing when iroh dialing was removed. Any automatic candidate dial now sets the flag (the relay is the automatic transport), restoring the backoff record on a fully failed pass. The AutoConnectMigration flow is deliberately KEPT: its consumers are live and iroh-era users updating to this build are exactly who needs the connection-changed explanation.
…→dial headlessly On a cold launch the reconnect claim lands at ~0.1s but the first relay dial starts at ~1.4s, and no existing mark explained the middle. Every preamble stage now records its offset from the claim instant into storedMacReconnectPreambleStages (mirrored to the device debug log), so one launch names the stage that absorbs the gap. A headless test drives the real preamble against a SQLite paired-Mac store and a scripted transport and bounds call→transport-dial at 250ms; it measures ~4ms, so a regression in the structural path is caught per commit and the remaining device gap is known to be environmental. Claude-Session: https://claude.ai/code/session_016U5GVUPKNK56M5Ca1kXFxk
…de loadAll BackingUpPairedMacStore.loadAll runs restoreIfNeeded first, and on a fresh process that awaits the same restore the concurrent backup refresh just started. The reconnect preamble therefore blocks at store-reads for the full backup round trip; the headless timeline stops at backup-refresh-spawned until the fetch is released. Claude-Session: https://claude.ai/code/session_016U5GVUPKNK56M5Ca1kXFxk
…estore BackingUpPairedMacStore.restoreIfNeeded awaited the in-flight restore on every first read of a scope, so on a cold launch the reconnect preamble, the instance-authority check, and the ticket persist before .connected each paid the backup round trip, measured as ~1.3s of the claim→dial gap on the phone. The concurrent refresh landed in wave 3 never helped because the store awaited the same restore two calls later. A scope with rows on disk now returns them at once and settles the restore in a background task (same bookkeeping, idempotent); a scope with nothing persisted still waits, since the backup is its only source. The headless cold-launch test goes green, and the fallback test pins the empty-scope wait. Claude-Session: https://claude.ai/code/session_016U5GVUPKNK56M5Ca1kXFxk
Reads no longer wait for the restore, so a Mac paired or forgotten on another device reached the published list only on the next list load. The cold-launch refresh now reports whether the merge wrote rows and the reconnect reloads the list only then, so stores without change accounting (and every existing fake) add no read. The restore-gate test asserts the backup-only Mac appears without a pull; the preamble bound is a 1s tripwire (710ms observed under a loaded parallel run), the gated test is the gate. Claude-Session: https://claude.ai/code/session_016U5GVUPKNK56M5Ca1kXFxk
Stacked on #10963. iOS no longer links IrohLib or dials, discovers, or configures iroh anywhere.
Method model.
MobileConnectionMethodis nowrelay(default) andtailscale. The legacyautomatic(iroh) anddirect(an iroh dial allowlist, transportless without iroh) raw values no longer decode, which migrates those pairings to the relay default; explicit Tailscale choices persist. Debug loopback rides alongside the relay exactly as it rode alongside iroh: it is the same-machine dev lane simulator auto-pair depends on, not a fallback. The relay method is exclusive otherwise: persisted Tailscale/host-port rows never dial under it, and a relay pairing with no persisted route still dials via the synthesized route.Deleted. The iroh/irx runtime compositions and release-gate target, zero-touch discovery, the broker Forget revoke (Forget now cleans local rows and backup tombstones only), the iroh warm-focus fast path, iroh settings/network UI, Direct-address UI, iroh plist/xcconfig keys, and the iroh test suites whose subjects are gone.
Mac side. Relay Remote Access defaults ON so a relay-default phone works out of the box; the toggle still provably closes the relay socket. The Mac's iroh HOSTING is untouched (old phone builds keep working); removing it is a later wave, as is deleting the iroh wire vocabulary from CMUXMobileCore, which the Mac still uses.
Copy. Onboarding offers Relay or Tailscale Only; Computers group Relay first; discovery copy teaches the relay; en+ja entries updated.
Test status. Model 339/339, relay transport 19/19, RPC compiles, iOS app archives green on the fleet. The CmuxMobileShell suite has failures that reproduce on the merge base (pre-sweep, relay-v2 head) — a baseline run is in flight to separate pre-existing failures from this branch's delta; the delta will be fixed on this branch before it leaves draft.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Summary by cubic
iOS removes iroh entirely; the relay is the default connection method. Render-grid event frames gain negotiated zlib compression at subscribe, relay hops verify Stack access tokens locally against the project's JWKS (fresh-token connects drop from ~390ms to ~150-180ms), and startup dials the stored Mac before the auth bootstrap finishes (~0.25s vs ~1.24s launch-to-dial; a bootstrap resolving a different account or team supersedes the early dial).
Migration
MobileConnectionMethodshrinks torelay(default) andtailscale; storedautomatic/directvalues migrate to relay, explicit Tailscale persists.d), so fresh relay pairing on physical devices dials instead of ending at no-supported-route; old phones ignore the new item.Liveness, diagnostics, and relay internals
/healthzHEAD pre-warms the relay TLS session at composition start.cmux.debug.echo-predictiontoggle (default OFF) runs the full predict/confirm/back-off loop headlessly.CmuxMobileShellModelso its claim-ordering tests run in CI; iroh-era reconnect tests were rewritten against the relay default, and the two remainingCmuxMobileShellsuite reds reproduce identically on origin/main.Written for commit 99ca7e8. Summary will update on new commits.