diff options
| author | Adam Soltys <[email protected]> | 2026-06-14 12:44:19 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-06-17 07:47:50 -0700 |
| commit | 3d2a980971b59cdd2a4ea41b2da51f370eee6b75 (patch) | |
| tree | 186f51d56493d7ae8c6c19aff859d7b336fa11e0 | |
| parent | b83d5dfc6dc27b7af1fbc723b61e017fd9caf7d7 (diff) | |
| download | coinos-server-3d2a980971b59cdd2a4ea41b2da51f370eee6b75.tar.gz coinos-server-3d2a980971b59cdd2a4ea41b2da51f370eee6b75.zip | |
ln: time out short-lived RPCs so a hung cl reconnects without restart
Port of master a664af99. A unix-domain RPC socket has no TCP keepalive, so
if cl goes unresponsive while the socket stays open (zombie/half-open) an
awaited call hangs forever — nothing throws, the reconnect-on-error path
never fires, and only an app restart recovers. Wrap short-lived calls in a
30s Promise.race timeout (matching lib/health.ts); a timeout rejects with
ETIMEDOUT, now part of isSocketDied, so the client is dropped and the next
call reconnects. Long-polls, in-flight payments (pay/xpay/sendpay/keysend)
and bolt12 round-trips are exempt to avoid leaked debits.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
| -rw-r--r-- | lib/ln.ts | 66 |
1 files changed, 64 insertions, 2 deletions
@@ -44,7 +44,52 @@ function isUnavailable(e: any) { function isSocketDied(e: any) { const code = e?.code ?? e?.errno; - return code === "EPIPE" || code === "ECONNRESET" || code === "ENOENT" || code === 2; + return ( + code === "EPIPE" || + code === "ECONNRESET" || + code === "ENOENT" || + code === 2 || + // A timed-out RPC (see RPC_TIMEOUT below) means the socket is open but cl + // stopped responding (zombie/half-open). Treat it as dead so the client is + // dropped and the next call reconnects — without restarting the process. + code === "ETIMEDOUT" + ); +} + +// Wall-clock cap for short-lived RPCs. A unix-domain socket has no TCP +// keepalive, so a hung cl would otherwise leave the await pending forever +// (the "stuck spinner past /pay/bob, no error" symptom). 30s matches the +// health-check RPC_TIMEOUT in lib/health.ts. +const RPC_TIMEOUT = 30_000; + +// Methods that MUST NOT get a client-side timeout: +// - waitanyinvoice/waitinvoice/waitsendpay/waitblockheight: long-polls that +// are designed to block until something happens. +// - pay/xpay/sendpay/keysend: in-flight payments. Rejecting the caller early +// while the HTLC may still settle is exactly the leaked-debit hazard the +// sendLightning guards exist to prevent — let these run to completion. +// - fetchinvoice/sendinvoice/offer: bolt12 flows that round-trip to a remote +// node over the network and can legitimately take a long time. +const NO_TIMEOUT = new Set([ + "waitanyinvoice", + "waitinvoice", + "waitsendpay", + "waitblockheight", + "pay", + "xpay", + "sendpay", + "keysend", + "fetchinvoice", + "sendinvoice", + "offer", +]); + +class RpcTimeoutError extends Error { + code = "ETIMEDOUT" as const; + constructor(method: string, ms: number) { + super(`Lightning RPC ${method} timed out after ${ms}ms`); + this.name = "RpcTimeoutError"; + } } function lightningProxy(rpcPath: string): any { @@ -106,9 +151,26 @@ function lightningProxy(rpcPath: string): any { if (typeof v !== "function") return v; + const method = String(prop); try { - return await v.apply(c, args); + const call = v.apply(c, args); + if (NO_TIMEOUT.has(method)) return await call; + + let timer: ReturnType<typeof setTimeout> | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new RpcTimeoutError(method, RPC_TIMEOUT)), + RPC_TIMEOUT, + ); + }); + try { + return await Promise.race([call, timeout]); + } finally { + clearTimeout(timer); + } } catch (e: any) { + // ETIMEDOUT (from RpcTimeoutError) is included in isSocketDied, so a + // hung RPC drops the client and the next call reconnects. if (isSocketDied(e)) client = null; throw e; } |
