From 9f4bc29025287e8e12a7ef31c9918c1adfdfff5d Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 26 Jun 2026 13:50:23 -0700 Subject: Fix broadcast hanging forever on a stalled response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Esplora broadcast cleared its abort timer right after fetch() resolved, so the subsequent res.text() body read had no timeout — a stalled response body hung the whole broadcast (and the send spinner) indefinitely, with the tx never reaching the network. Keep the abort timer active through the body read, and add a hard 30s outer timeout in the send handler so a wedged broadcast always surfaces as 'timed out — try again' instead of spinning forever. (The silent-payment tx itself is valid — verified structure + BIP143 signature over the real outputs.) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api.js | 19 ++++++++++--------- src/app.js | 5 ++++- src/i18n.js | 1 + 3 files changed, 15 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/api.js b/src/api.js index 1ab8c0a..6f010cb 100644 --- a/src/api.js +++ b/src/api.js @@ -336,19 +336,20 @@ export class Api { const attempts = this._hosts.map(async (host) => { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), this._timeoutMs); - let res; try { - res = await fetch(host.base + '/tx', { method: 'POST', body: hexTx, signal: ctrl.signal }); + // Keep the abort timer active across BOTH the request and the body read — + // a stalled response body must not hang the broadcast forever. + const res = await fetch(host.base + '/tx', { method: 'POST', body: hexTx, signal: ctrl.signal }); + const body = await res.text(); + if (res.status === 429) { + host.cooldownUntil = Date.now() + 30000; + this.#penalize(); + } + if (!res.ok) throw new Error(body || `${res.status} broadcast failed`); + return body.trim(); // txid } finally { clearTimeout(timer); } - const body = await res.text(); - if (res.status === 429) { - host.cooldownUntil = Date.now() + 30000; - this.#penalize(); - } - if (!res.ok) throw new Error(body || `${res.status} broadcast failed`); - return body.trim(); // txid }); const results = await Promise.allSettled(attempts); const ok = results.find((r) => r.status === 'fulfilled'); diff --git a/src/app.js b/src/app.js index d5452ec..cba23ba 100644 --- a/src/app.js +++ b/src/app.js @@ -2764,7 +2764,10 @@ async function broadcast() { render(); try { const hexTx = wallet.sign(ui.draft.tx); - const txid = await wallet.broadcast(hexTx); + const txid = await Promise.race([ + wallet.broadcast(hexTx), + new Promise((_, rej) => setTimeout(() => rej(new Error(t('broadcastTimeout'))), 30000)), + ]); ui.sendResult = { txid }; ui.draft = null; ui.send = blankSend(); diff --git a/src/i18n.js b/src/i18n.js index 2752ea8..1a85014 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -179,6 +179,7 @@ const STR = { bumpInsufficient: 'Not enough funds to bump at this rate. Try a lower rate.', scanOffline: 'You’re offline — reconnect to broadcast this transaction.', broadcastFailed: 'Broadcast failed: {msg}', + broadcastTimeout: 'Timed out reaching the network. The payment was not sent — try again.', signingFailed: 'Signing failed: {msg}', done: 'Done', txSignedNote: 'Signed, but not sent yet. To actually send it, broadcast the code below from any online device (e.g. mempool.space → Broadcast). It will not send on its own.', -- cgit v1.2.3