aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-26 13:50:23 -0700
committerAdam Soltys <[email protected]>2026-06-26 13:50:23 -0700
commit9f4bc29025287e8e12a7ef31c9918c1adfdfff5d (patch)
treecd78a1521769844a6b4c8acc82ee7634f6302099 /src
parent445a2e772eba5377f74a1b68fe7f80508c05fa9c (diff)
downloadcoinosv3-9f4bc29025287e8e12a7ef31c9918c1adfdfff5d.tar.gz
coinosv3-9f4bc29025287e8e12a7ef31c9918c1adfdfff5d.zip
Fix broadcast hanging forever on a stalled response
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) <[email protected]>
Diffstat (limited to 'src')
-rw-r--r--src/api.js19
-rw-r--r--src/app.js5
-rw-r--r--src/i18n.js1
3 files changed, 15 insertions, 10 deletions
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.',