From 84c8f4c9cd4977f09b13bb6dfb026fbd845f2788 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 5 May 2026 23:12:25 -0400 Subject: [PATCH] fix(mobile): remove 30s idle-close timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The timer existed to clean up sockets when the user navigated away from a host for 30+ seconds. In practice it produced false 'Disconnected' flashes when the user navigated home → host → home faster than React could re-acquire on the home side: refcount drops to 0 between the host-detail unmount and the home re-render's useEffect, the timer fires 30s later, the WebSocket gets torn down, and the home card flips to 'Disconnected' with no user action. Connections still close on: - Explicit user Disconnect (closeEntry) - Host removal (closeEntry) - App backgrounding (OS-level socket suspension) - Provider unmount (app shutdown) - Real WebSocket failure (handled by rpc-client reconnect path) A foregrounded app with an idle authenticated WebSocket costs near-zero battery / data; the 'don't leak sockets' problem the timer was trying to solve is already handled by the OS, the desktop reaper, and the cleanup paths above. Co-authored-by: Orca --- mobile/src/transport/client-context.tsx | 49 +++++++++---------------- 1 file changed, 17 insertions(+), 32 deletions(-) diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index 88dd424e577..176b80355e8 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -23,13 +23,10 @@ import { connect, type RpcClient } from './rpc-client' import { loadHosts } from './host-store' import type { ConnectionState, HostProfile } from './types' -const IDLE_CLOSE_MS = 30_000 - type StoreEntry = { client: RpcClient state: ConnectionState refCount: number - idleTimer: ReturnType | null unsubState: () => void } @@ -83,7 +80,6 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { const closeEntry = useCallback((hostId: string) => { const entry = storeRef.current.get(hostId) if (!entry) return - if (entry.idleTimer) clearTimeout(entry.idleTimer) entry.unsubState() entry.client.close() storeRef.current.delete(hostId) @@ -150,7 +146,6 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { client, state: client.getState(), refCount: 0, - idleTimer: null, unsubState } storeRef.current.set(hostId, entry) @@ -175,10 +170,6 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { const existing = storeRef.current.get(hostId) if (existing) { existing.refCount += 1 - if (existing.idleTimer) { - clearTimeout(existing.idleTimer) - existing.idleTimer = null - } return existing.client } // Trigger async open. The acquire-side will return null this tick and @@ -197,36 +188,30 @@ export function RpcClientProvider({ children }: { children: ReactNode }) { for (const host of hosts) primedHostsRef.current.set(host.id, host) }, []) - const release = useCallback( - (hostId: string) => { - const entry = storeRef.current.get(hostId) - if (!entry) return - entry.refCount = Math.max(0, entry.refCount - 1) - if (entry.refCount > 0) return - if (entry.idleTimer) clearTimeout(entry.idleTimer) - entry.idleTimer = setTimeout(() => { - // Why: only close if still idle when the timer fires. A late acquire - // would have cleared the timer. - const cur = storeRef.current.get(hostId) - if (!cur || cur.refCount > 0) return - closeEntry(hostId) - }, IDLE_CLOSE_MS) - }, - [closeEntry] - ) + // Why: refcount dropping to 0 no longer triggers an idle-close. The + // app deliberately keeps live WebSockets open while the app itself is + // foregrounded — closing on transient navigation gaps was producing + // false 'disconnected' flashes when the user navigated home → host → + // back to home faster than React could re-acquire on the home side. + // Connections still close on: explicit user Disconnect, host removal, + // app backgrounding (OS-level socket suspension), and provider + // unmount (app shutdown). + const release = useCallback((hostId: string) => { + const entry = storeRef.current.get(hostId) + if (!entry) return + entry.refCount = Math.max(0, entry.refCount - 1) + }, []) const forceReconnect = useCallback( async (hostId: string) => { const entry = storeRef.current.get(hostId) - // Why: if the entry was previously closed (e.g. user tapped - // Disconnect), refCount is lost. Fall back to the number of active - // state listeners as a proxy for "screens currently watching this - // host," so the freshly-opened entry doesn't trip the idle-close - // timer immediately. + // Why: preserve refcount across the swap. If the user reaches + // forceReconnect via the Disconnect → Reconnect path, the entry + // was already closed and refCount=0; fall back to active listener + // count as a proxy for "screens still watching this host." const listenerCount = stateListenersRef.current.get(hostId)?.size ?? 0 const savedRefCount = entry?.refCount ?? Math.max(1, listenerCount) if (entry) { - if (entry.idleTimer) clearTimeout(entry.idleTimer) entry.unsubState() entry.client.close() storeRef.current.delete(hostId)