From bd1ece38e42751575ac2e03a5c795b5b109653ac Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 4 Jun 2026 18:17:47 -0700 Subject: ln/lightning: watchdog recycles zombie receive-listener socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On 2026-06-04 cl restarted (autoheal, after its 15s healthcheck timed out), and the app's waitanyinvoice long-poll hung on the now-dead listen socket. listenerActive stayed true forever, so ensureListenerAlive never restarted it, and incoming lightning payments stopped crediting for ~2h until a manual app restart. The per-call RPC timeout in ln.ts can't catch this: waitanyinvoice is intentionally in NO_TIMEOUT (it's meant to block). Fix: ensureListenerAlive (runs every 120s) now also detects a stalled listener — active but blocked in waitanyinvoice for >5min. It probes cl via the main ln socket (getinfo); only if cl is actually alive does it treat the listen socket as a zombie, call lnListen.reset() (new: drops the proxy's socket so the next call reconnects), and re-arm. A listenerEpoch guard makes the wedged invocation bow out when its waitanyinvoice finally rejects, so we never run two listeners concurrently and never double-credit. Verified with a state-machine test covering normal receive, quiet-but-healthy (left alone), stall+cl-alive (recycled), stall+cl-down (left alone), and the no-double-listener race. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/ln.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) (limited to 'lib/ln.ts') diff --git a/lib/ln.ts b/lib/ln.ts index de01ea81..4215731e 100644 --- a/lib/ln.ts +++ b/lib/ln.ts @@ -144,6 +144,22 @@ function lightningProxy(rpcPath: string) { if (prop === "toString") return () => "[LightningProxy]"; if (prop === Symbol.toStringTag) return "LightningProxy"; + // Force the underlying socket to be dropped so the next call reconnects. + // Used by the lightning-listener watchdog to recover a zombie long-poll + // (waitanyinvoice blocked on a dead socket while cl is actually alive — + // the per-call timeout can't catch it because long-polls are exempt). + if (prop === "reset") { + return () => { + const had = !!client; + try { + client?.removeAllListeners?.(); + client?.destroy?.(); + } catch (_) {} + client = null; + return had; + }; + } + return async (...args: any[]) => { const c = ensure(); const v = c[prop as any]; -- cgit v1.2.3