{"record":{"id":"2dc33f200b826552","repo":"slopus/happy","slug":"rpc-target-disconnected","errorCode":null,"errorMessage":"RPC target disconnected","messagePattern":"RPC target disconnected","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/happy-server/sources/app/api/socket/rpcHandler.ts","lineNumber":232,"sourceCode":"            // cancel pending broadcasts. Polling fetchSockets is the only way\n            // to detect \"the target socket is gone\" and abort fast (~2-4s).\n            //\n            // Requires 2 consecutive empty polls before declaring disconnect\n            // to avoid false positives from transient Redis/adapter timeouts.\n            const ackPromise = target.timeout(RPC_CALL_TIMEOUT_MS)\n                .emitWithAck('rpc-request', { method, params });\n\n            let presenceAlive = true;\n            const presencePoll = (async () => {\n                let consecutiveMisses = 0;\n                while (presenceAlive) {\n                    await sleep(RPC_PRESENCE_POLL_MS);\n                    if (!presenceAlive) return;\n                    const stillThere = await fetchRoomSockets(io, room, RPC_PRESENCE_FETCH_TIMEOUT_MS, 'presence');\n                    if (!stillThere.some(s => s.id === target.id)) {\n                        consecutiveMisses++;\n                        if (consecutiveMisses >= 2) {\n                            throw new Error('RPC target disconnected');\n                        }\n                    } else {\n                        consecutiveMisses = 0;\n                    }\n                }\n            })();\n\n            try {\n                const response = await Promise.race([ackPromise, presencePoll]);\n                finish('success');\n                callback?.({ ok: true, result: response });\n            } catch (error) {\n                const errorMsg = error instanceof Error ? error.message : 'RPC call failed';\n                finish(errorMsg === 'RPC target disconnected' ? 'target_disconnected' : 'timeout');\n                callback?.({ ok: false, error: errorMsg });\n            } finally {\n                presenceAlive = false;\n            }","sourceCodeStart":214,"sourceCodeEnd":250,"githubUrl":"https://github.com/slopus/happy/blob/b824cd0a4681d41af631a8e422a813873e4455b0/packages/happy-server/sources/app/api/socket/rpcHandler.ts#L214-L250","documentation":"During an RPC call, the handler polls room presence to verify the target socket is still connected. If two consecutive presence polls (each RPC_PRESENCE_POLL_MS apart) fail to find the target socket in the room, it concludes the target disconnected and aborts the RPC with 'RPC target disconnected'.","triggerScenarios":"Target socket disconnects (tab close, network drop, server restart of the client) while an RPC is in flight; target left the socket room; presence fetch (fetchRoomSockets) succeeds but the target id is missing twice in a row.","commonSituations":"Mobile/web client going to background and its socket timing out mid-RPC; flaky network causing the client to drop and reconnect under a new socket id; RPC sent to a stale socket id after a reconnect.","solutions":["Retry the RPC; on reconnect the client will have a fresh socket and the server should route to the new one.","Treat it as an expected client-disconnect on the caller side and surface a friendly 'peer unavailable' state.","Resolve the target's current socket id via the presence layer immediately before/instead of caching it.","Reduce RPC_PRESENCE_POLL_MS / miss threshold if false positives occur, or increase tolerance if disconnects are transient."],"exampleFix":"// before\nconst result = await rpc(targetSocketId, 'command', payload);\n// after\ntry {\n  const result = await rpc(targetSocketId, 'command', payload);\n} catch (e) {\n  if (e.message === 'RPC target disconnected') {\n    const fresh = await getFreshSocketId(peerId);\n    if (fresh) return rpc(fresh, 'command', payload);\n  }\n  throw e;\n}","handlingStrategy":"retry","validationCode":"async function assertTargetOnline(io, room, socketId) {\n  const sockets = await fetchRoomSockets(io, room, 2000, 'precheck');\n  if (!sockets.some(s => s.id === socketId)) {\n    throw new Error('Target not currently connected; resolve fresh socket first');\n  }\n}","typeGuard":null,"tryCatchPattern":"try {\n  return await rpcWithPresence(targetSocketId, method, payload);\n} catch (e) {\n  if (e.message === 'RPC target disconnected') {\n    const fresh = await resolveCurrentSocketId(peerId);\n    if (fresh) return rpcWithPresence(fresh, method, payload);\n    markPeerOffline(peerId);\n    return null;\n  }\n  throw e;\n}","preventionTips":["Always resolve the target's socket id from presence immediately before each RPC, never cache it.","Handle client reconnects by keying RPC targets on a stable peer id, not socket id.","Design RPC callers to tolerate peer disappearance (backgrounded mobile clients).","Watch presence churn metrics to distinguish transient drops from real offline peers."],"tags":["websocket","rpc","network","disconnect"],"backgroundTag":"socket-disconnected-mid-rpc","analyzedSha":"b824cd0a4681d41af631a8e422a813873e4455b0","analyzedAt":"2026-08-31T23:12:36.205Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-08T05:18:18.240Z"}