aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-05-31 23:28:12 -0700
committerAdam Soltys <[email protected]>2026-05-31 23:28:12 -0700
commita664af99dfee8267a960fd22cbab852681198de2 (patch)
tree31daf8030a9cb8929dab4d561d579f6c042afb5f
parent7841b2d61830f13260e46d24ce69f24b2dbcea29 (diff)
downloadcoinos-server-a664af99dfee8267a960fd22cbab852681198de2.tar.gz
coinos-server-a664af99dfee8267a960fd22cbab852681198de2.zip
ln: time out short-lived RPCs so a hung cl reconnects without restart
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 — the "stuck spinner past /pay/bob, no error" symptom. Nothing threw, so the existing reconnect-on-error path never fired and only an app restart recovered it. Wrap short-lived calls in a 30s Promise.race timeout (matching health.ts). A timeout rejects with ETIMEDOUT, now part of isSocketDied, so the client is dropped and the next call reconnects — no process restart. Long-polls (waitanyinvoice et al), in-flight payments (pay/xpay/sendpay/keysend), and bolt12 round-trips (fetchinvoice/ sendinvoice/offer) are exempt: timing those out client-side would risk leaked debits or break intentional blocking. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
-rw-r--r--lib/ln.ts66
1 files changed, 64 insertions, 2 deletions
diff --git a/lib/ln.ts b/lib/ln.ts
index a1e4bd5c..de01ea81 100644
--- a/lib/ln.ts
+++ b/lib/ln.ts
@@ -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) {
@@ -105,9 +150,26 @@ function lightningProxy(rpcPath: string) {
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;
}