fix(mobile): remove 30s idle-close timer

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 <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-05 23:35:19 -04:00
co-authored by Orca
parent 300ec58c20
commit 84c8f4c9cd
+17 -32
View File
@@ -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<typeof setTimeout> | 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)