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;