From fbd336a89b43e42d82bb31ceef6023ad5cf37f04 Mon Sep 17 00:00:00 2001 From: saubyk <39208279+saubyk@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:56:15 -0700 Subject: [PATCH] Bound CLN alias resolution on peers and route lookups (#1501) RTL resolves peer aliases by calling listnodes once per peer. A prior fix (1cec7b1) bounded this to 20 concurrent calls plus a cache for the channel list, but the peers list and route lookup still used an unbounded Promise.all, firing one request per peer at once. On nodes with many peers this overwhelms clnrest and fails with 'Resource temporarily unavailable (os error 11)' (EAGAIN), so aliases fall back to raw node IDs. - peers.ts and network.ts getRoute now resolve aliases via runWithConcurrencyLimit(tasks, 20, ...), matching the channel list. - Harden runWithConcurrencyLimit to call done() immediately for an empty task list; otherwise an empty peers/route set would never send a response. - Give the alias cache a 6h TTL and a max size (evicting oldest) so aliases refresh without an RTL restart and the cache can't grow unbounded. --- backend/controllers/cln/network.js | 22 ++++++++++++++++++---- backend/controllers/cln/peers.js | 6 +++++- backend/utils/common.js | 7 ++++++- release-notes/Release-notes-0.15.9.md | 13 +++++++++++++ server/controllers/cln/network.ts | 22 +++++++++++++++++----- server/controllers/cln/peers.ts | 6 +++++- server/utils/common.ts | 5 ++++- 7 files changed, 68 insertions(+), 13 deletions(-) diff --git a/backend/controllers/cln/network.js b/backend/controllers/cln/network.js index b31b1415..89137447 100644 --- a/backend/controllers/cln/network.js +++ b/backend/controllers/cln/network.js @@ -4,6 +4,10 @@ import { Common } from '../../utils/common.js'; let options = null; const logger = Logger; const common = Common; +// Alias cache: peerId -> { alias, ts }. Bounded by a TTL so an updated node alias is picked +// up without an RTL restart, and by a max size so it can't grow unbounded (evicts oldest). +const ALIAS_CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours +const ALIAS_CACHE_MAX = 5000; const aliasCache = new Map(); export const getRoute = (req, res, next) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Network', msg: 'Getting Network Routes..' }); @@ -15,7 +19,10 @@ export const getRoute = (req, res, next) => { options.body = req.body; request.post(options).then((body) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Network', msg: 'Network Routes Received', data: body }); - return Promise.all(body.route?.map((rt) => getAlias(req.session.selectedNode, rt, 'id'))).then((values) => { + // Resolve hop aliases with a bounded number of concurrent listnodes calls, matching the + // peers/channels paths, so a long route can't storm clnrest (#1501). + const getRouteAliasesTasks = (body.route || []).map((rt) => () => getAlias(req.session.selectedNode, rt, 'id')); + common.runWithConcurrencyLimit(getRouteAliasesTasks, 20, () => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Peers', msg: 'Network Routes with Alias Received', data: body }); res.status(200).json(body || []); }); @@ -87,8 +94,9 @@ export const getAlias = (selNode, peer, id) => { peer.alias = ''; return Promise.resolve(peer); } - if (aliasCache.has(peerId)) { - peer.alias = aliasCache.get(peerId); + const cached = aliasCache.get(peerId); + if (cached && (Date.now() - cached.ts) < ALIAS_CACHE_TTL) { + peer.alias = cached.alias; return Promise.resolve(peer); } options.url = selNode.settings.lnServerUrl + '/v1/listnodes'; @@ -96,7 +104,13 @@ export const getAlias = (selNode, peer, id) => { return request.post(options).then((body) => { logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Network', msg: 'Peer Alias Finished', data: body }); const alias = body.nodes?.[0]?.alias || peerId.substring(0, 20); - aliasCache.set(peerId, alias); + // Re-insert so a refreshed entry moves to the most-recent position, then evict the + // oldest if we're over the cap (Map preserves insertion order). + aliasCache.delete(peerId); + aliasCache.set(peerId, { alias, ts: Date.now() }); + if (aliasCache.size > ALIAS_CACHE_MAX) { + aliasCache.delete(aliasCache.keys().next().value); + } peer.alias = alias; return peer; }).catch((errRes) => { diff --git a/backend/controllers/cln/peers.js b/backend/controllers/cln/peers.js index dba0c304..289ef5c6 100644 --- a/backend/controllers/cln/peers.js +++ b/backend/controllers/cln/peers.js @@ -15,7 +15,11 @@ export const getPeers = (req, res, next) => { request.post(options).then((body) => { logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Peers', msg: 'Peers List Received', data: body }); const peers = !body.peers ? [] : body.peers; - return Promise.all(peers?.map((peer) => getAlias(req.session.selectedNode, peer, 'id'))).then((values) => { + // Resolve peer aliases with a bounded number of concurrent listnodes calls. An unbounded + // Promise.all here fires one request per peer at once, which overwhelms clnrest on nodes + // with many peers and fails with "Resource temporarily unavailable (os error 11)" (#1501). + const getPeerAliasesTasks = peers.map((peer) => () => getAlias(req.session.selectedNode, peer, 'id')); + common.runWithConcurrencyLimit(getPeerAliasesTasks, 20, () => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Peers', msg: 'Sorted Peers List Received', data: body.peers }); res.status(200).json(body.peers || []); }); diff --git a/backend/utils/common.js b/backend/utils/common.js index c6e8f569..1a985d92 100644 --- a/backend/utils/common.js +++ b/backend/utils/common.js @@ -609,7 +609,12 @@ export class CommonService { return JSON.parse(dataStr); }; this.runWithConcurrencyLimit = (tasks, limit, done) => { - const results = new Array(tasks.length); + const results = new Array(tasks?.length || 0); + // No tasks: the start loop below never runs, so 'done' would never fire and the + // response would hang. Resolve immediately for empty lists (e.g. a node with no peers). + if (!tasks || tasks.length === 0) { + return done(results); + } let nextIndex = 0; let activeCount = 0; const runNext = () => { diff --git a/release-notes/Release-notes-0.15.9.md b/release-notes/Release-notes-0.15.9.md index d9204092..7ed8cb11 100644 --- a/release-notes/Release-notes-0.15.9.md +++ b/release-notes/Release-notes-0.15.9.md @@ -70,6 +70,19 @@ this release should add its entry under the appropriate section below. (WCAG 2.4.3) that produced an inconsistent keyboard order; these were removed so focus follows natural DOM order across the LND, Core Lightning, Eclair and shared modals. +- **Core Lightning: bound alias resolution on the peers and route lookups to stop clnrest + "Resource temporarily unavailable" errors** ([#XXXX](https://github.com/Ride-The-Lightning/RTL/pull/XXXX), + fixes [#1501](https://github.com/Ride-The-Lightning/RTL/issues/1501)). + RTL resolves peer aliases by calling `listnodes` once per peer. A prior fix bounded this to 20 + concurrent calls (plus a cache) for the channel list, but the **peers list** and **route lookup** + still fired an unbounded `Promise.all` — one request per peer at once — which overwhelms clnrest + on nodes with many peers and fails with `Resource temporarily unavailable (os error 11)` + (`EAGAIN`), leaving raw node IDs instead of aliases. Both paths now use the same 20-way + concurrency limit. The limiter was also hardened to resolve immediately for an empty list (an + empty peers/route set would previously never send a response), and the alias cache gained a + 6-hour TTL and a max size so aliases refresh without an RTL restart and the cache can't grow + unbounded. + ## Enhancements - **Add a Disable Authentication option** diff --git a/server/controllers/cln/network.ts b/server/controllers/cln/network.ts index 03d30f04..a2448c07 100644 --- a/server/controllers/cln/network.ts +++ b/server/controllers/cln/network.ts @@ -6,7 +6,11 @@ import { SelectedNode } from '../../models/config.model.js'; let options = null; const logger: LoggerService = Logger; const common: CommonService = Common; -const aliasCache = new Map(); +// Alias cache: peerId -> { alias, ts }. Bounded by a TTL so an updated node alias is picked +// up without an RTL restart, and by a max size so it can't grow unbounded (evicts oldest). +const ALIAS_CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours +const ALIAS_CACHE_MAX = 5000; +const aliasCache = new Map(); export const getRoute = (req, res, next) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Network', msg: 'Getting Network Routes..' }); @@ -16,7 +20,10 @@ export const getRoute = (req, res, next) => { options.body = req.body; request.post(options).then((body) => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Network', msg: 'Network Routes Received', data: body }); - return Promise.all(body.route?.map((rt) => getAlias(req.session.selectedNode, rt, 'id'))).then((values) => { + // Resolve hop aliases with a bounded number of concurrent listnodes calls, matching the + // peers/channels paths, so a long route can't storm clnrest (#1501). + const getRouteAliasesTasks = (body.route || []).map((rt) => () => getAlias(req.session.selectedNode, rt, 'id')); + common.runWithConcurrencyLimit(getRouteAliasesTasks, 20, () => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Peers', msg: 'Network Routes with Alias Received', data: body }); res.status(200).json(body || []); }); @@ -87,8 +94,9 @@ export const getAlias = (selNode: SelectedNode, peer: any, id: string) => { return Promise.resolve(peer); } - if (aliasCache.has(peerId)) { - peer.alias = aliasCache.get(peerId)!; + const cached = aliasCache.get(peerId); + if (cached && (Date.now() - cached.ts) < ALIAS_CACHE_TTL) { + peer.alias = cached.alias; return Promise.resolve(peer); } @@ -98,7 +106,11 @@ export const getAlias = (selNode: SelectedNode, peer: any, id: string) => { return request.post(options).then((body) => { logger.log({ selectedNode: selNode, level: 'DEBUG', fileName: 'Network', msg: 'Peer Alias Finished', data: body }); const alias = body.nodes?.[0]?.alias || peerId.substring(0, 20); - aliasCache.set(peerId, alias); + // Re-insert so a refreshed entry moves to the most-recent position, then evict the + // oldest if we're over the cap (Map preserves insertion order). + aliasCache.delete(peerId); + aliasCache.set(peerId, { alias, ts: Date.now() }); + if (aliasCache.size > ALIAS_CACHE_MAX) { aliasCache.delete(aliasCache.keys().next().value); } peer.alias = alias; return peer; }).catch((errRes) => { diff --git a/server/controllers/cln/peers.ts b/server/controllers/cln/peers.ts index 2772cf40..da1472b0 100644 --- a/server/controllers/cln/peers.ts +++ b/server/controllers/cln/peers.ts @@ -15,7 +15,11 @@ export const getPeers = (req, res, next) => { request.post(options).then((body) => { logger.log({ selectedNode: req.session.selectedNode, level: 'DEBUG', fileName: 'Peers', msg: 'Peers List Received', data: body }); const peers = !body.peers ? [] : body.peers; - return Promise.all(peers?.map((peer) => getAlias(req.session.selectedNode, peer, 'id'))).then((values) => { + // Resolve peer aliases with a bounded number of concurrent listnodes calls. An unbounded + // Promise.all here fires one request per peer at once, which overwhelms clnrest on nodes + // with many peers and fails with "Resource temporarily unavailable (os error 11)" (#1501). + const getPeerAliasesTasks = peers.map((peer) => () => getAlias(req.session.selectedNode, peer, 'id')); + common.runWithConcurrencyLimit(getPeerAliasesTasks, 20, () => { logger.log({ selectedNode: req.session.selectedNode, level: 'INFO', fileName: 'Peers', msg: 'Sorted Peers List Received', data: body.peers }); res.status(200).json(body.peers || []); }); diff --git a/server/utils/common.ts b/server/utils/common.ts index 0af97283..265a584f 100644 --- a/server/utils/common.ts +++ b/server/utils/common.ts @@ -582,7 +582,10 @@ export class CommonService { }; public runWithConcurrencyLimit = (tasks, limit, done) => { - const results = new Array(tasks.length); + const results = new Array(tasks?.length || 0); + // No tasks: the start loop below never runs, so 'done' would never fire and the + // response would hang. Resolve immediately for empty lists (e.g. a node with no peers). + if (!tasks || tasks.length === 0) { return done(results); } let nextIndex = 0; let activeCount = 0;