From 13b6ec88d10e2505bbc879f315faf89ca0249ffd Mon Sep 17 00:00:00 2001 From: xrplto Date: Sun, 12 Apr 2026 08:50:13 +0000 Subject: [PATCH] Fix promise stability in acquireLease/extendLease MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs that cause process crashes in long-running daemon scenarios: 1. Double-settle race condition: watchAcquireResponse and watchExtendResponse could call both resolve() and reject() on the same promise when the timeout fires after the polling loop already settled. Added settled guard with safeResolve/safeReject helpers. 2. Single error = immediate abort: Any transient error in getAccountTrx (WebSocket reconnect, network hiccup) would immediately reject with reason: 'UNKNOWN' and abort the entire acquire/extend. Changed to retry up to 10 times with 3s backoff before giving up. 3. Malformed tx crashes polling loop: If deserializeMemos or extractEvernodeEvent throws on a single transaction, it would crash the entire polling loop. Added per-transaction try/catch that skips malformed entries and continues scanning. 4. acquireLease wrapper: The .catch() on acquireLeaseSubmit called reject() but execution continued, potentially leading to a second reject() from the watchAcquireResponse path. Replaced with explicit try/catch and early return via safeReject. Tested in production with 30+ concurrent leases over 24 hours — zero process crashes after these fixes. --- src/clients/tenant-client.js | 129 +++++++++++++++++++++-------------- 1 file changed, 78 insertions(+), 51 deletions(-) diff --git a/src/clients/tenant-client.js b/src/clients/tenant-client.js index 55cab8c..9ef3555 100644 --- a/src/clients/tenant-client.js +++ b/src/clients/tenant-client.js @@ -266,49 +266,59 @@ class TenantClient extends BaseEvernodeClient { console.log(`Waiting for acquire response... (txHash: ${tx.id})`); return new Promise(async (resolve, reject) => { - let rejected = false; + let settled = false; + const safeResolve = (v) => { if (!settled) { settled = true; clearTimeout(failTimeout); resolve(v); } }; + const safeReject = (e) => { if (!settled) { settled = true; clearTimeout(failTimeout); reject(e); } }; const failTimeout = setTimeout(() => { - rejected = true; - reject({ error: ErrorCodes.ACQUIRE_ERR, reason: ErrorReasons.TIMEOUT }); + safeReject({ error: ErrorCodes.ACQUIRE_ERR, reason: ErrorReasons.TIMEOUT }); }, options.timeout || DEFAULT_WAIT_TIMEOUT); let relevantTx = null; - while (!rejected && !relevantTx) { + let consecutiveErrors = 0; + while (!settled && !relevantTx) { try { const txList = await this.xrplAcc.getAccountTrx(tx.details.ledger_index); + consecutiveErrors = 0; for (let t of txList) { - t.tx.Memos = TransactionHelper.deserializeMemos(t.tx?.Memos); - t.tx.HookParameters = TransactionHelper.deserializeHookParams(t.tx?.HookParameters); - - if (t.meta?.delivered_amount) - t.tx.DeliveredAmount = t.meta.delivered_amount; - - const res = await this.extractEvernodeEvent(t.tx); - if ((res?.name === EvernodeEvents.AcquireSuccess || res?.name === EvernodeEvents.AcquireError) && res?.data?.acquireRefId === tx.id) { - clearTimeout(failTimeout); - relevantTx = res; - break; + try { + t.tx.Memos = TransactionHelper.deserializeMemos(t.tx?.Memos); + t.tx.HookParameters = TransactionHelper.deserializeHookParams(t.tx?.HookParameters); + + if (t.meta?.delivered_amount) + t.tx.DeliveredAmount = t.meta.delivered_amount; + + const res = await this.extractEvernodeEvent(t.tx); + if ((res?.name === EvernodeEvents.AcquireSuccess || res?.name === EvernodeEvents.AcquireError) && res?.data?.acquireRefId === tx.id) { + relevantTx = res; + break; + } + } catch (innerErr) { + continue; } } } catch (e) { - rejected = true; - clearTimeout(failTimeout); - reject({ error: ErrorCodes.ACQUIRE_ERR, reason: 'UNKNOWN', acquireRefId: tx.id }); - break; + consecutiveErrors++; + if (consecutiveErrors >= 10) { + safeReject({ error: ErrorCodes.ACQUIRE_ERR, reason: 'UNKNOWN', acquireRefId: tx.id, content: e?.message || String(e) }); + break; + } + await new Promise(r => setTimeout(r, 3000)); + continue; } + if (relevantTx) break; await new Promise(resolveSleep => setTimeout(resolveSleep, 2000)); } - if (!rejected) { + if (!settled) { if (relevantTx?.name === TenantEvents.AcquireSuccess) { - resolve({ + safeResolve({ transaction: relevantTx?.data.transaction, instance: relevantTx?.data.payload.content, acquireRefId: relevantTx?.data.acquireRefId }); } else if (relevantTx?.name === TenantEvents.AcquireError) { - reject({ + safeReject({ error: ErrorCodes.ACQUIRE_ERR, transaction: relevantTx?.data.transaction, reason: relevantTx?.data.reason, @@ -453,16 +463,25 @@ class TenantClient extends BaseEvernodeClient { */ acquireLease(hostAddress, requirement, options = {}) { return new Promise(async (resolve, reject) => { - const tx = await this.acquireLeaseSubmit(hostAddress, requirement, options).catch(error => { - reject({ error: ErrorCodes.ACQUIRE_ERR, reason: error.reason || ErrorReasons.TRANSACTION_FAILURE, content: error.error || error }); - }); - if (tx) { + let settled = false; + const safeResolve = (v) => { if (!settled) { settled = true; resolve(v); } }; + const safeReject = (e) => { if (!settled) { settled = true; reject(e); } }; + try { + let tx; + try { + tx = await this.acquireLeaseSubmit(hostAddress, requirement, options); + } catch (error) { + return safeReject({ error: ErrorCodes.ACQUIRE_ERR, reason: error.reason || ErrorReasons.TRANSACTION_FAILURE, content: error.error || error }); + } + if (!tx) return safeReject({ error: ErrorCodes.ACQUIRE_ERR, reason: 'NO_TX' }); try { const response = await this.watchAcquireResponse(tx, options); - resolve(response); + safeResolve(response); } catch (error) { - reject(error); + safeReject(error); } + } catch (outerErr) { + safeReject({ error: ErrorCodes.ACQUIRE_ERR, reason: 'OUTER_ERR', content: outerErr?.message || String(outerErr) }); } }); } @@ -586,49 +605,57 @@ class TenantClient extends BaseEvernodeClient { console.log(`Waiting for extend lease response... (txHash: ${tx.id})`); return new Promise(async (resolve, reject) => { - let rejected = false; + let settled = false; + const safeResolve = (v) => { if (!settled) { settled = true; clearTimeout(failTimeout); resolve(v); } }; + const safeReject = (e) => { if (!settled) { settled = true; clearTimeout(failTimeout); reject(e); } }; const failTimeout = setTimeout(() => { - rejected = true; - reject({ error: ErrorCodes.EXTEND_ERR, reason: ErrorReasons.TIMEOUT }); + safeReject({ error: ErrorCodes.EXTEND_ERR, reason: ErrorReasons.TIMEOUT }); }, options.timeout || DEFAULT_WAIT_TIMEOUT); let relevantTx = null; - while (!rejected && !relevantTx) { + let consecutiveErrors = 0; + while (!settled && !relevantTx) { try { const txList = await this.xrplAcc.getAccountTrx(tx.details.ledger_index); + consecutiveErrors = 0; for (let t of txList) { - t.tx.Memos = TransactionHelper.deserializeMemos(t.tx.Memos); - t.tx.HookParameters = TransactionHelper.deserializeHookParams(t.tx?.HookParameters); - - if (t.meta?.delivered_amount) - t.tx.DeliveredAmount = t.meta.delivered_amount; - - const res = await this.extractEvernodeEvent(t.tx); - if ((res?.name === TenantEvents.ExtendSuccess || res?.name === TenantEvents.ExtendError) && res?.data?.extendRefId === tx.id) { - clearTimeout(failTimeout); - relevantTx = res; - break; - } + try { + t.tx.Memos = TransactionHelper.deserializeMemos(t.tx.Memos); + t.tx.HookParameters = TransactionHelper.deserializeHookParams(t.tx?.HookParameters); + + if (t.meta?.delivered_amount) + t.tx.DeliveredAmount = t.meta.delivered_amount; + + const res = await this.extractEvernodeEvent(t.tx); + if ((res?.name === TenantEvents.ExtendSuccess || res?.name === TenantEvents.ExtendError) && res?.data?.extendRefId === tx.id) { + relevantTx = res; + break; + } + } catch (innerErr) { continue; } } } catch (e) { - rejected = true; - clearTimeout(failTimeout); - reject({ error: ErrorCodes.EXTEND_ERR, reason: 'UNKNOWN', extendRefId: tx.id }); - break; + consecutiveErrors++; + if (consecutiveErrors >= 10) { + safeReject({ error: ErrorCodes.EXTEND_ERR, reason: 'UNKNOWN', extendRefId: tx.id, content: e?.message || String(e) }); + break; + } + await new Promise(r => setTimeout(r, 3000)); + continue; } + if (relevantTx) break; await new Promise(resolveSleep => setTimeout(resolveSleep, 1000)); } - if (!rejected) { + if (!settled) { if (relevantTx?.name === TenantEvents.ExtendSuccess) { - resolve({ + safeResolve({ transaction: relevantTx?.data.transaction, expiryMoment: relevantTx?.data.expiryMoment, extendRefId: relevantTx?.data.extendRefId }); } else if (relevantTx?.name === TenantEvents.ExtendError) { - reject({ + safeReject({ error: ErrorCodes.EXTEND_ERR, transaction: relevantTx?.data.transaction, reason: relevantTx?.data.reason,