From 5d1f55a9c65ce2eee3f3c63b71f3e0b030496001 Mon Sep 17 00:00:00 2001 From: saubyk <39208279+saubyk@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:15:18 -0700 Subject: [PATCH] Address review: exempt LND subscription streams from the request timeout The 10-minute timeout added for review feedback would have aborted LND's long-poll subscription streams (/v2/invoices/subscribe and /v2/router/track), which legitimately stay open until an invoice settles or a payment resolves - breaking real-time notifications for any invoice paid more than 10 minutes after creation. The wrapper now honors a per-call options.timeout (0 disables the bound, axios semantics; the 10-minute default still applies everywhere else), and both subscription calls pass timeout: 0. They also copy the options object instead of mutating it: addInvoice hands the session-cached options to subscribeToInvoice, so setting the timeout in place would have leaked an unbounded timeout to every subsequent request for that node (getOptions resets form/body/qs but not timeout). Verified on the regtest fixture: with a websocket client connected as alice's frontend, creating an invoice opens the subscription stream, it survives idle, and paying it from the CLN node delivers the SETTLED event over the websocket in real time. The per-call override was also verified directly (timeout: 1000 aborts a slow upstream with ECONNABORTED; timeout: 0 waits it out). Both API suites (31 read + 12 write checks) re-pass. --- backend/controllers/lnd/webSocketClient.js | 8 ++++++-- backend/utils/request.js | 4 +++- server/controllers/lnd/webSocketClient.ts | 8 ++++++-- server/utils/request.ts | 4 +++- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/backend/controllers/lnd/webSocketClient.js b/backend/controllers/lnd/webSocketClient.js index 3271546a..943a1b56 100644 --- a/backend/controllers/lnd/webSocketClient.js +++ b/backend/controllers/lnd/webSocketClient.js @@ -44,7 +44,9 @@ export class LNDWebSocketClient { this.subscribeToInvoice = (options, selectedNode, rHash) => { rHash = rHash?.replace(/\+/g, '-')?.replace(/[/]/g, '_'); this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Subscribing to Invoice ' + rHash + ' ..' }); - options.url = selectedNode.settings.lnServerUrl + '/v2/invoices/subscribe/' + rHash; + // Copy the options: the caller may pass the session-cached object, and the + // long poll needs an unbounded timeout without leaking it to other calls. + options = { ...options, url: selectedNode.settings.lnServerUrl + '/v2/invoices/subscribe/' + rHash, timeout: 0 }; request(options).then((msg) => { this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Invoice Information Received for ' + rHash }); if (typeof msg === 'string') { @@ -67,7 +69,9 @@ export class LNDWebSocketClient { }; this.subscribeToPayment = (options, selectedNode, paymentHash) => { this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Subscribing to Payment ' + paymentHash + ' ..' }); - options.url = selectedNode.settings.lnServerUrl + '/v2/router/track/' + paymentHash; + // Copy the options: the long poll needs an unbounded timeout without + // leaking it to other calls sharing the object. + options = { ...options, url: selectedNode.settings.lnServerUrl + '/v2/router/track/' + paymentHash, timeout: 0 }; request(options).then((msg) => { this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Payment Information Received for ' + paymentHash }); msg['type'] = 'payment'; diff --git a/backend/utils/request.js b/backend/utils/request.js index 9506a90e..72a776f4 100644 --- a/backend/utils/request.js +++ b/backend/utils/request.js @@ -16,7 +16,9 @@ const buildConfig = (options, method) => { // Bound hung upstreams; 10 minutes accommodates the slowest legitimate // operations (LND's /v2/router/send streams up to timeout_seconds=600 and // slow CLN channel operations get req.setTimeout(600000) upstream). - timeout: 600000 + // Callers can override per request; 0 disables the bound entirely, which + // the LND invoice/payment subscription streams need (open until settled). + timeout: options.timeout !== null && options.timeout !== undefined ? options.timeout : 600000 }; if (options.baseUrl) { config.baseURL = options.baseUrl; diff --git a/server/controllers/lnd/webSocketClient.ts b/server/controllers/lnd/webSocketClient.ts index c4434d3e..ef3a791b 100644 --- a/server/controllers/lnd/webSocketClient.ts +++ b/server/controllers/lnd/webSocketClient.ts @@ -58,7 +58,9 @@ export class LNDWebSocketClient { public subscribeToInvoice = (options: any, selectedNode: SelectedNode, rHash: string) => { rHash = rHash?.replace(/\+/g, '-')?.replace(/[/]/g, '_'); this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Subscribing to Invoice ' + rHash + ' ..' }); - options.url = selectedNode.settings.lnServerUrl + '/v2/invoices/subscribe/' + rHash; + // Copy the options: the caller may pass the session-cached object, and the + // long poll needs an unbounded timeout without leaking it to other calls. + options = { ...options, url: selectedNode.settings.lnServerUrl + '/v2/invoices/subscribe/' + rHash, timeout: 0 }; request(options).then((msg) => { this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Invoice Information Received for ' + rHash }); if (typeof msg === 'string') { @@ -82,7 +84,9 @@ export class LNDWebSocketClient { public subscribeToPayment = (options: any, selectedNode: SelectedNode, paymentHash: string) => { this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Subscribing to Payment ' + paymentHash + ' ..' }); - options.url = selectedNode.settings.lnServerUrl + '/v2/router/track/' + paymentHash; + // Copy the options: the long poll needs an unbounded timeout without + // leaking it to other calls sharing the object. + options = { ...options, url: selectedNode.settings.lnServerUrl + '/v2/router/track/' + paymentHash, timeout: 0 }; request(options).then((msg) => { this.logger.log({ selectedNode: selectedNode, level: 'INFO', fileName: 'WebSocketClient', msg: 'Payment Information Received for ' + paymentHash }); msg['type'] = 'payment'; diff --git a/server/utils/request.ts b/server/utils/request.ts index 9114aa90..38ea1cdd 100644 --- a/server/utils/request.ts +++ b/server/utils/request.ts @@ -19,7 +19,9 @@ const buildConfig = (options, method: string) => { // Bound hung upstreams; 10 minutes accommodates the slowest legitimate // operations (LND's /v2/router/send streams up to timeout_seconds=600 and // slow CLN channel operations get req.setTimeout(600000) upstream). - timeout: 600000 + // Callers can override per request; 0 disables the bound entirely, which + // the LND invoice/payment subscription streams need (open until settled). + timeout: options.timeout !== null && options.timeout !== undefined ? options.timeout : 600000 }; if (options.baseUrl) { config.baseURL = options.baseUrl; } if (options.qs && Object.keys(options.qs).length > 0) { config.params = options.qs; }