# Single Shared RPC Client per Host (Mobile) Design doc for collapsing the per-screen WebSocket connection model into a single shared `RpcClient` per paired host, owned by a React context that sits above the route tree. ## Problem The mobile app today opens **one WebSocket per screen per host**: | Screen | Connections per host | |---|---| | Home (`app/index.tsx`) | 1 (with persistent `accounts.subscribe` stream) | | Host detail (`app/h/[hostId]/index.tsx`) | +1 | | Worktree session (`app/h/[hostId]/session/[worktreeId].tsx`) | +1 | | Accounts (`app/h/[hostId]/accounts.tsx`) | +1 | | Pair confirm (briefly) | +1 | A user actively browsing one host typically holds **3–4 simultaneous sockets** to the desktop runtime. Each call to `connect()` runs its own E2EE handshake, allocates an ephemeral keypair, and runs an independent reconnect loop with exponential backoff. This causes three observable problems: 1. **Stuck-connecting / reconnecting for minutes.** The desktop's `MAX_WS_CONNECTIONS = 32` is shared across all clients. A user who navigates rapidly accumulates stale sockets faster than they can be reaped by TCP keepalive (which can take 60–300s on default systems for half-open connections from a phone leaving Wi-Fi range or backgrounding). Once the cap is hit, new sockets are rejected with WS close code `1013 Maximum connections reached`. The mobile reconnect loop does not recognize 1013 as terminal — it retries with backoff, each retry also dropped, until enough stale sockets are reaped. Result: a screen that should connect in <1s is stuck for 1–5 minutes. 2. **Tab create/delete hangs.** `client.sendRequest('terminal.create')` awaits `waitForConnected()`. If the session-screen client is in `connecting`/`reconnecting` state because its socket lost the cap race, the await blocks until the 30s `REQUEST_TIMEOUT_MS` fires. The user sees nothing happen. 3. **Triple cost on every cold-start.** Three E2EE handshakes, three Curve25519 keypair generations, three subscription re-registrations on every app launch. On low-end Android, this visibly delays first paint by hundreds of milliseconds. The architecture also wastes server resources: each socket carries its own E2EE channel, its own subscription set, its own driver-state-machine client identity (cf. `docs/mobile-presence-lock.md`). The server already has logic to reconcile multi-socket-per-token tear-down (`hasOtherConnections` in `ws-transport.ts`) — that logic exists *because* this design forced the question; with a single client per host, it becomes unnecessary. ## Today's transport ownership Five files independently call `connect(endpoint, deviceToken, publicKeyB64)`: ``` mobile/app/pair-confirm.tsx # one-shot during pairing mobile/app/pair-scan.tsx # one-shot during pairing mobile/app/index.tsx # N (one per paired host) mobile/app/h/[hostId]/index.tsx # 1 (host detail) mobile/app/h/[hostId]/session/[worktreeId].tsx # 1 (session) mobile/app/h/[hostId]/accounts.tsx # 1 (accounts) ``` Each owns a `useRef` and calls `client.close()` from a cleanup function. The home screen additionally maintains a `clientsRef: Array<{ hostId, client }>` so its own usage of the per-host client survives across navigation events. This pattern works for *correctness* — every cleanup eventually closes its socket — but it breaks under three real-world conditions: 1. **Rapid navigation.** Mounts spawn before unmounts complete; cleanup `client.close()` runs after a new screen has already opened a fresh socket to the same host. Two sockets briefly coexist for the same token, multiplied across screens. 2. **Network drops.** A backgrounded/locked phone on a flaky network leaves sockets half-open. The server doesn't get a FIN; cleanup relies on TCP keepalive timing. Meanwhile the foreground app, on resume, opens fresh sockets. The half-open ones eat the cap until reaped. 3. **Hot reload during dev.** Each Metro hot reload fires a new render tree without unmounting the old, so connections leak. ## Goal > A paired host has at most **one active WebSocket** at any time, owned > by a context provider above the route tree. All screens for that host > share that client. The pair flows are the only places that create > short-lived clients (and they explicitly close those after pairing > completes). This is the architectural fix to the symptoms above. Combined with the two recently-shipped hotfixes (token cache + stable `useEffect` dependency on home screen), this completes the connection-lifecycle work. ## Design ### Layered ownership ``` RootLayout () └── routes └── // mounts when route has hostId ├── h/[hostId]/ // host detail ├── h/[hostId]/session/[worktreeId]/ // session └── h/[hostId]/accounts/ // accounts ``` Two providers, layered: 1. **`RpcClientProvider` (root)** — owns one `RpcClient` per host, keyed by `hostId`. Lifecycle: opens on first request for that host's client, holds open until app shutdown OR until the host is removed (`removeHost(hostId)` triggers explicit close). Reuses existing `loadHosts()` cache from the recently-merged `host-store.ts` work. 2. **`HostScopedClientGate` (per host)** — a thin route-layout component placed at `app/h/_layout.tsx`. Reads `hostId` from route params, requests the client for that host from the root provider, exposes it via context to descendants, and renders a loading state until the client reaches `connected`. Guarantees every descendant screen sees the same client instance for that host — no per-screen `connect()` calls. The home screen (`app/index.tsx`) lives outside `HostScopedClientGate` since it spans all hosts; it consumes the root provider directly via a multi-host hook (see API below). ### API ```ts // New file: mobile/src/transport/client-context.tsx type RpcClientContext = { // Get-or-open. Returns the singleton client for hostId; opens it // lazily on first call, reuses it for all subsequent callers. Never // returns null (returns a placeholder client in 'connecting' state // if open hasn't completed). getClient: (hostId: string) => RpcClient // Connection state for a given host (driven by client.onStateChange). useHostState: (hostId: string) => ConnectionState // Useful for the home screen which renders all hosts at once. useAllClients: () => Array<{ hostId: string; client: RpcClient }> } export const RpcClientProvider: React.FC<{ children: React.ReactNode }> export const useHostClient: (hostId: string) => { client: RpcClient state: ConnectionState } ``` Internal store (single `useRef` in the provider): ```ts type StoreEntry = { client: RpcClient state: ConnectionState refCount: number // number of active screens holding this client closeTimer: NodeJS.Timeout | null } const store = useRef(new Map()) ``` ### Lifecycle rules 1. **Open on first read.** First `getClient(hostId)` call for a host reads the host record (uses cached `loadHosts()`), then calls `connect()` and stores the client. Subsequent calls return the cached entry. 2. **Idle close timer.** When `refCount` drops to 0 (all screens for that host unmounted), schedule a 30-second close timer. If a screen for the same host mounts within 30s, cancel the timer. Otherwise, close the client and remove from the store. - Why 30s: covers fast tab-switching and back-navigation without keeping idle sockets forever. Tunable based on observed behavior. 3. **Forced close on host removal.** `removeHost(hostId)` from `host-store.ts` calls into the provider to close the client immediately and delete the store entry. 4. **App backgrounded.** No special action — let TCP keepalive and server-side reaping handle it. Reconnect happens on foreground. 5. **App foregrounded.** Trigger a `getState()` poll on every non-closed entry; if any are in `disconnected` (TCP died while backgrounded), the existing reconnect loop handles it. No new client allocations. ### Public surface for screens Each screen replaces: ```ts // Before const [client, setClient] = useState(null) const [connState, setConnState] = useState('disconnected') useEffect(() => { let rpcClient: RpcClient | null = null void (async () => { const hosts = await loadHosts() const host = hosts.find((h) => h.id === hostId) if (!host) return rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, setConnState) setClient(rpcClient) })() return () => { rpcClient?.close() } }, [hostId]) ``` with: ```ts // After const { client, state } = useHostClient(hostId) ``` Total LoC reduction across screens: ~150 lines. ### Pair flow exception `app/pair-confirm.tsx` and `app/pair-scan.tsx` continue to call `connect()` directly with **explicit `client.close()`** after the test request returns. Reason: the host record doesn't yet exist in `loadHosts()` during pairing, so the provider has nothing to look up. The pair flow's client is a short-lived transient that delivers `getStatus()` once and then dies. After `saveHost()` succeeds, the user is navigated away; the next time they enter `/h/[hostId]/...`, the provider opens a fresh client through the normal path. ### Streaming subscription handling The home screen's `accounts.subscribe` stream and the session screen's terminal subscriptions remain owned by their respective screens — the provider doesn't manage subscriptions, only the underlying transport. Each screen's effect calls `client.subscribe(...)` and stores the returned unsubscribe function. On unmount, the screen unsubscribes (returns to the existing per-screen pattern, just over a shared transport). The transport's `subscribe()` already correctly multiplexes multiple listeners on one WebSocket via the `id` field. ### State propagation `useHostState(hostId)` returns the live `ConnectionState`. The provider maintains a per-host `useState` keyed by hostId; the `client.onStateChange` listener is wired once at client creation and updates the corresponding state slot. `useHostState` reads from this state via `useSyncExternalStore` or a context selector — the choice is mostly preference; in this codebase, given the small state shape, a simple `useContext + useMemo` of the matching slot is fine. ## Migration Step-by-step, each step independently shippable: 1. **Add `RpcClientProvider` and `useHostClient`.** No callers yet. Wire into `app/_layout.tsx`. Existing screens unchanged. 2. **Migrate session screen** (highest-risk, most-used). Replace per-screen `connect()` with `useHostClient`. Test connection behavior, terminal create/delete, scrollback hydration. 3. **Migrate host detail and accounts screens.** Same pattern. 4. **Migrate home screen.** Replace `clientsRef` with `useAllClients()`. The home screen's per-host streaming subscriptions move into a hook that runs per-host. 5. **Add `HostScopedClientGate`** at `app/h/_layout.tsx` to centralize the gate and remove duplicated loading-state logic. 6. **Delete legacy code.** Remove the dead `connect()` import paths from each screen. Codepoint reduction. 7. **Remove server-side `hasOtherConnections` complexity** in a follow-up: with one socket per token, the multi-socket reconciliation in `runtime-rpc.ts` `wsTransport.onConnectionClose` simplifies. This is a desktop-side cleanup PR done after mobile rolls out. Each step is tested in isolation; rollback per step is trivial. ## Risks ### R1: Connection loss while screens are mounted **Risk.** Today, when a screen unmounts due to network loss, its client closes and reopens on remount. Under the new design, a network-loss-during-screen-mounted means the client lives but is in `reconnecting` state. **Mitigation.** The existing `RpcClient` already handles this — its internal reconnect loop runs invisibly. Screens already render based on `connState === 'connected'`, so they stay in their `connecting` UI until the loop succeeds. No regression. ### R2: One bad host poisons the singleton **Risk.** If the client for one host is wedged in `reconnecting` due to a desktop-side issue, all screens for that host inherit the wedged state. Under the per-screen design, navigating to a different screen gave a fresh client with a chance to connect cleanly. **Mitigation.** "Force reconnect" affordance: a button on the host detail "Connection issues" UI calls `provider.forceReconnect(hostId)` — close + reopen the client. Users who hit a stuck state get a one-tap recovery without uninstalling. Implemented as part of step 2. ### R3: Idle close timer races **Risk.** A user navigates from session → home → back to session within 35 seconds. The 30s idle timer fires between hops and closes the client; the back-navigation has to wait for a fresh handshake. **Mitigation.** Cancel the timer at `getClient(hostId)` time, not at mount time. As long as the consumer holds a reference to the client, the timer is paused. Standard refcount pattern. ### R4: Memory / state leak on rapid host removal **Risk.** User removes a host while a screen for it is mounted. The screen's reference is now dangling. **Mitigation.** `removeHost(hostId)` triggers an explicit close + delete from the store. Screens already handle `auth-failed` / `disconnected` states (the client transitions to one of them on forced close). Add a navigation-bounce in those states so the screen returns to the host list. ### R5: Pair-flow socket leaks through provider **Risk.** If pair-confirm crashes mid-handshake before its explicit `close()`, the socket leaks. **Mitigation.** Independent of the provider — same risk exists today. Add a try/finally + cleanup useEffect in pair-confirm. ### R6: Provider re-initialization on hot reload (dev only) **Risk.** Metro hot reload re-runs `RpcClientProvider`, possibly spawning new clients while old ones are still in the store. **Mitigation.** On provider mount, scan the store for entries whose clients report `closed` state and prune. Acceptable dev-only friction. ## Test Plan ### Unit / hook tests - `useHostClient` returns the same client instance across multiple consumers for the same hostId. - `removeHost(hostId)` immediately closes the client (assert via `client.getState()`). - Idle close timer: zero refcount → 30s wait → client closed. - Idle close timer cancellation: zero refcount → 15s wait → consumer subscribes → no close. ### Integration / manual - Create one host; navigate home → host detail → session → back ×10 rapidly. Single socket on desktop (verify via desktop debug log). - Background app for 5 min; foreground; verify reconnect uses same client instance, no leak. - Remove host while session screen mounted; screen bounces back to home; client closed. - Force-reconnect button on host detail; client closes and reopens cleanly. - Hot reload during development; no socket leak (verify desktop active-connection count). ### Regression - Terminal create/delete works during normal browse (Bug A from initial reports — should never recur once cap pressure is removed). - Scrollback hydration unchanged. - Phone-fit / driver-lock state machine unchanged (`docs/mobile-presence-lock.md` invariants hold). ## Out of scope - **Desktop-side connection LRU eviction.** Useful as a defense-in-depth but not needed once mobile self-limits to one socket per host. - **Application-level ping/pong.** Worth adding but separate concern; helps server reap dead sockets faster regardless of how many a single client opens. - **iOS share extension or system-wide deep-link integration.** Future product work. ## Effort estimate 3–4 hours including tests and incremental migration. Each step independently mergeable. ## References - `docs/mobile-presence-lock.md` — driver-state-machine that depends on per-client identity. Single-client-per-host simplifies but doesn't break this contract. - `docs/mobile-prefer-renderer-scrollback.md` — scrollback hydration flow. Subscriptions remain per-screen; transport changes are transparent. - `mobile/src/transport/rpc-client.ts` — existing `connect()` implementation; reconnect loop, E2EE handshake, subscription multiplexing all preserved as-is. - `src/main/runtime/rpc/ws-transport.ts` — server-side `MAX_WS_CONNECTIONS = 32`, `hasOtherConnections` reconciliation that becomes simpler post-migration.