16 KiB
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:
- Stuck-connecting / reconnecting for minutes. The desktop's
MAX_WS_CONNECTIONS = 32is 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 code1013 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. - Tab create/delete hangs.
client.sendRequest('terminal.create')awaitswaitForConnected(). If the session-screen client is inconnecting/reconnectingstate because its socket lost the cap race, the await blocks until the 30sREQUEST_TIMEOUT_MSfires. The user sees nothing happen. - 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<RpcClient | null> 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:
- 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. - 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.
- 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 (<RpcClientProvider>)
└── routes
└── <HostScopedClientGate hostId={...}> // mounts when route has hostId
├── h/[hostId]/ // host detail
├── h/[hostId]/session/[worktreeId]/ // session
└── h/[hostId]/accounts/ // accounts
Two providers, layered:
RpcClientProvider(root) — owns oneRpcClientper host, keyed byhostId. 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 existingloadHosts()cache from the recently-mergedhost-store.tswork.HostScopedClientGate(per host) — a thin route-layout component placed atapp/h/_layout.tsx. ReadshostIdfrom 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 reachesconnected. Guarantees every descendant screen sees the same client instance for that host — no per-screenconnect()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
// 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):
type StoreEntry = {
client: RpcClient
state: ConnectionState
refCount: number // number of active screens holding this client
closeTimer: NodeJS.Timeout | null
}
const store = useRef(new Map<string, StoreEntry>())
Lifecycle rules
- Open on first read. First
getClient(hostId)call for a host reads the host record (uses cachedloadHosts()), then callsconnect()and stores the client. Subsequent calls return the cached entry. - Idle close timer. When
refCountdrops 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.
- Forced close on host removal.
removeHost(hostId)fromhost-store.tscalls into the provider to close the client immediately and delete the store entry. - App backgrounded. No special action — let TCP keepalive and server-side reaping handle it. Reconnect happens on foreground.
- App foregrounded. Trigger a
getState()poll on every non-closed entry; if any are indisconnected(TCP died while backgrounded), the existing reconnect loop handles it. No new client allocations.
Public surface for screens
Each screen replaces:
// Before
const [client, setClient] = useState<RpcClient | null>(null)
const [connState, setConnState] = useState<ConnectionState>('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:
// 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:
- Add
RpcClientProvideranduseHostClient. No callers yet. Wire intoapp/_layout.tsx. Existing screens unchanged. - Migrate session screen (highest-risk, most-used). Replace
per-screen
connect()withuseHostClient. Test connection behavior, terminal create/delete, scrollback hydration. - Migrate host detail and accounts screens. Same pattern.
- Migrate home screen. Replace
clientsRefwithuseAllClients(). The home screen's per-host streaming subscriptions move into a hook that runs per-host. - Add
HostScopedClientGateatapp/h/_layout.tsxto centralize the gate and remove duplicated loading-state logic. - Delete legacy code. Remove the dead
connect()import paths from each screen. Codepoint reduction. - Remove server-side
hasOtherConnectionscomplexity in a follow-up: with one socket per token, the multi-socket reconciliation inruntime-rpc.tswsTransport.onConnectionClosesimplifies. 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
useHostClientreturns the same client instance across multiple consumers for the same hostId.removeHost(hostId)immediately closes the client (assert viaclient.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.mdinvariants 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— existingconnect()implementation; reconnect loop, E2EE handshake, subscription multiplexing all preserved as-is.src/main/runtime/rpc/ws-transport.ts— server-sideMAX_WS_CONNECTIONS = 32,hasOtherConnectionsreconciliation that becomes simpler post-migration.