From 9ce15c044ce095eea19fc0b48d229d3b78c58348 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 26 Jun 2026 14:10:05 -0700 Subject: Throttle Electrum requests so scans don't overwhelm public servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Electrum data path fired every request at once, so a scan burst dozens of calls at a rate-limited public server, which then dropped some (the timeouts). Add a concurrency cap (4 in flight; Electrum pipelines, so it stays fast) with excess queued — mirrors the Esplora scheduler's intent. Verified a full scan via the public jochen server completes in ~4.5s with peak concurrency 4, no timeouts. The throttle queue is drained on disconnect/stop alongside in-flight calls. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wallet.js | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/wallet.js b/src/wallet.js index 550d74d..b0cd963 100644 --- a/src/wallet.js +++ b/src/wallet.js @@ -1583,16 +1583,35 @@ export class Wallet { // when the matching id comes back; queues until the socket is open, and times // out so a wedged call can't hang a scan. ids are monotonic for the session so // they never alias a fire-and-forget _rpcSend id. - _rpcCall(method, params, attempt = 0) { + // Public Electrum entry point. Throttled so a scan doesn't fire a burst that a + // rate-limited public server would drop — Electrum pipelines requests, so a few + // in flight is plenty (and fast). Excess calls queue for a free slot. + _rpcCall(method, params) { + return new Promise((resolve, reject) => { + (this._rpcQueue || (this._rpcQueue = [])).push({ method, params, resolve, reject }); + this._rpcPump(); + }); + } + _rpcPump() { + if (this._rpcActive == null) this._rpcActive = 0; + const MAX = 4; + while (this._rpcActive < MAX && this._rpcQueue && this._rpcQueue.length) { + const job = this._rpcQueue.shift(); + this._rpcActive++; + this._rpcSendCall(job.method, job.params).then(job.resolve, job.reject).finally(() => { this._rpcActive--; this._rpcPump(); }); + } + } + // The actual request: send, await the matching response, time out, and retry a + // couple of times on a fresh id (a public server can drop a response or the + // socket can flap mid-call). + _rpcSendCall(method, params, attempt = 0) { return new Promise((resolve, reject) => { if (this.offline) { reject(new Error('offline')); return; } const id = ++this._rpcId; if (!this._pending) this._pending = new Map(); const timer = setTimeout(() => { if (!this._pending.delete(id)) return; - // A public Electrum server can drop a response, or the socket can flap - // mid-call — retry a couple of times on a fresh id before giving up. - if (attempt < 2 && !this.offline) this._rpcCall(method, params, attempt + 1).then(resolve, reject); + if (attempt < 2 && !this.offline) this._rpcSendCall(method, params, attempt + 1).then(resolve, reject); else reject(new Error('electrum timeout: ' + method)); }, 12000); this._pending.set(id, { resolve, reject, timer }); @@ -1606,9 +1625,15 @@ export class Wallet { } _rejectPending(reason) { - if (!this._pending) return; - for (const { reject, timer } of this._pending.values()) { clearTimeout(timer); try { reject(new Error(reason)); } catch {} } - this._pending.clear(); + if (this._pending) { + for (const { reject, timer } of this._pending.values()) { clearTimeout(timer); try { reject(new Error(reason)); } catch {} } + this._pending.clear(); + } + if (this._rpcQueue) { + for (const job of this._rpcQueue) { try { job.reject(new Error(reason)); } catch {} } + this._rpcQueue = []; + } + this._rpcActive = 0; } // A notification means a tx touched one of our addresses (a deposit, or a -- cgit v1.2.3