cmux-next iPhone: one link per online Mac, so two Macs work at once
With two Macs on one account, the iPhone app kept only one of them connected. Opening a workspace on the second Mac (select(macID:)) retargeted the app's single LinkController and closed the first Mac's link. That Mac's workspaces then said "Can't connect" while presence still counted "2 of 2 Macs online", and its terminals stopped until it was chosen again. The Mac hosts' logs show the same thing: the first Mac logged link down … peer closed the link (or keepalive timed out) each time the phone moved to the other Mac.
The phone now holds one link per Mac at the same time, so workspaces, terminals, chats and the Feed from both Macs work together.
Design
MacSession, one per Mac. Each session has its ownLinkController, so dial, retry, backoff, heartbeat, path, TURN allocation, WireGuard keys and transport are all per Mac. It also has its ownWorkspaceSync,AcpmuxHub,LiveFeed,TerminalFeed, terminal tabs, pairing gate and workspace backend. Nothing in a session ever opens a channel on another Mac's link. That keeps PR #22555's rule (reattach only your own Mac's terminals) true by construction.MacLinkPolicykeeps the number of links bounded. It is pure and unit-tested.- The focused Mac always keeps its link.
- Another Mac gets a link only while its data is on screen (the Workspaces list and the Feed show every Mac; a workspace page shows its own Mac), or for 5 minutes after it was last shown.
- Lazy: only the focused Mac dials at launch, and a Mac nothing has shown is never dialed.
- Idle teardown: a Mac that drops out of the window has its link closed, and its list stays on screen marked as last-known, not "Can't connect".
- At most 4 links. A Mac that presence reports offline is not dialed, and a live link that presence briefly calls offline is never evicted.
- Routing by Mac. Tab ids (
s<surface>) repeat across Macs, so terminal sessions, files, uploads, screen text, agent chat stores and streamed browser tabs are all keyed by Mac and tab. Workspace and tab changes go throughMultiMacWorkspaceBackendto their own Mac. A Feed answer goes only to the Mac whose chat asked. - Focus. Chief and the connection banner follow the focused Mac; every other Mac shows its link state in its own section header. Retry on another Mac opens that Mac's link without moving the focus.
- Signaling. The per-user hub signaling is unchanged: every link shares the phone's one socket, and the hub routes signals by
toandsessionId. The one Rust change is incmux-mobile-link-ffi: a dial that opens a new socket now moves every live WireGuard session of the device onto it (rehome). Before, the first Mac's session stayed on the dead socket until the next network change. PROTOCOL.mdgains a "One link per Mac (phone)" section. The wire format is unchanged.
Tests
The regression test was committed before the fix. It is MultiMacLinkTests.choosingTheSecondMacKeepsTheFirstLinked, run in an iOS 26.5 simulator on mini-cmux11s with cmux-next-ios/verify/termqa/cmux_mobile_kit-test-ios.sh CmuxMobileLiveTests/MultiMacLinkTests (new script; the coordinator tests are iOS-only, so swift test on macOS skips them).
- Base (312bf7e2f89a): 4 failures. After choosing the second Mac,
macConnection["m1"]was nil, its data was.cached(.idle), the first Mac had been dialed twice, and choosing it back dropped the second Mac. - Head: passes.
Rust: cargo test -p cmux-mobile-link-ffi --test ffi a_new_socket_carries_every_live_link, run on rbx. It failed on 03ca876095ea (network_changed still had to rehome the first Mac's session) and passes on the head.
Other tests added:
MacLinkPolicyTests: lazy, idle expiry, cap, offline ordering.- Coordinator tests: a terminal reads its own Mac when both Macs have tab
s1; an idle link closes quietly and opens again when shown; a transport change redials every link; only the focused Mac dials at launch; two Macs' same-id chats and browser tabs stay separate.
Observed / Not run
- Observed:
CmuxMobileLiveTests(229 tests) pass in the iOS simulator on mini-cmux11s.CmuxMobileBrowserStreamTests,CmuxMobileWorkspacesTests,CmuxMobileShellTests,CmuxMobileAgentChatTestsandCmuxMobileChiefTestsalso pass. Running all the bundles at once, the existingMacSelectionTests.choosingAnotherOnlineMacMovesTheLinkflaked once: another bundle's sign-out test clearscmuxMobile.live.mac.*in the sharedUserDefaults. It passes whenCmuxMobileLiveTestsruns alone, and the new suite uses its own team key to stay out of that race. - Observed:
cargo test -p cmux-mobile-link -p cmux-mobile-link-ffi, all green on rbx. - Live check over the real link: see the comment below. It used one simulator and two hidden rig Macs on two cmux minis, signed in to the same account, typing on both terminals alternately for 2 minutes.
- Not run: the user's own phone (no TapKit), or Chief on a non-focused Mac.
- Fixed in passing:
ChiefDirectoryTestsno longer compiled on the base (missingplace:), which kept the iOS test bundle from building.
No capture: no visual change. This is connection behavior; the two-Mac live check video is in the comment below.
Changelog
- Fixed: The iPhone app stays connected to all of your online Macs at once, so workspaces and terminals on two Macs work side by side.
🤖 Generated with Claude Code