fix(publish): handle nostr-tools array-return; stop uncaught rejection flood

publishEventToRelay expected the old nostr-tools publish handle (.on/.then), but
nostr-tools 2.17 SimplePool.publish(relays, event) returns an ARRAY of per-relay
promises. The array has neither .on nor .then, so the code (a) fell through to
the "optimistic success" path (publishes were never truly confirmed) and (b)
never attached a .catch to the inner promise — so every relay that rejected
("blocked", "auth-required", "publish timed out", websocket error, …) leaked an
unhandled promise rejection. With a large/flaky relay list (e.g. a 35-relay
account hitting many paid/restricted relays) that floods the event loop and can
freeze the tab — observed when republishing the relay list and when publishing a
video.

Fix: detect the array return and Promise.allSettled it — any fulfilled = success,
otherwise a failure result; every rejection is consumed. Confirmed publishes now
resolve from real acks instead of the optimistic fallback.

New tests assert a rejecting array-promise becomes a failure result with NO
unhandled rejection leak, a fulfilling one succeeds, and an all-rejecting publish
still resolves. Mutation-verified; existing publish tests pass under the suite.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-20 08:27:21 -04:00
1 parent 0031d68b67
commit 6984fb2ef9
2 files changed
+92

No files matched your search

+28
View File
@@ -131,6 +131,34 @@ export function publishEventToRelay(pool, url, event, options = {}) {
}
}
// nostr-tools SimplePool.publish(relays, event) returns an ARRAY of
// per-relay promises (one per url). Each MUST be consumed or a relay that
// rejects ("blocked", "auth-required", "publish timed out", websocket
// error, …) surfaces as an uncaught promise rejection. With a large/flaky
// relay list that floods the event loop and can freeze the tab. We pass a
// single [url], so the array has one promise; allSettled never rejects, so
// every rejection is consumed here.
if (Array.isArray(pub)) {
Promise.allSettled(pub).then((settled) => {
clearTimeout(timeoutId);
const anyOk = settled.some((entry) => entry.status === "fulfilled");
if (anyOk) {
finalize(true);
return;
}
const firstRejection = settled.find(
(entry) => entry.status === "rejected",
);
finalize(
false,
firstRejection?.reason instanceof Error
? firstRejection.reason
: new Error(String(firstRejection?.reason || "publish failed")),
);
});
return;
}
if (pub && typeof pub.then === "function") {
pub
.then(() => {
+64
View File
@@ -0,0 +1,64 @@
// nostr-tools SimplePool.publish(relays, event) returns an ARRAY of per-relay
// promises. publishEventToRelay must consume them: a relay that rejects
// ("blocked", "auth-required", "publish timed out", websocket error, …) must be
// turned into a {success:false} result, NOT leak as an unhandled promise
// rejection. With a large/flaky relay list, leaked rejections flood the event
// loop and can freeze the tab (observed when republishing a 35-relay list).
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import { publishEventToRelay } from "../js/nostrPublish.js";
const EVENT = { id: "e1", kind: 1, content: "hi" };
// A pool whose publish() returns an array of promises, like nostr-tools 2.17.
function arrayReturningPool(makePromise) {
return {
publish(urls) {
return urls.map(() => makePromise());
},
};
}
test("a rejecting array-promise becomes a failure result (no unhandled rejection)", async () => {
const unhandled = [];
const onUnhandled = (reason) => unhandled.push(reason);
process.on("unhandledRejection", onUnhandled);
try {
const pool = arrayReturningPool(() =>
Promise.reject(new Error("blocked: pubkey is not allowed to publish")),
);
const result = await publishEventToRelay(pool, "wss://relay.example", EVENT);
assert.equal(result.success, false, "a rejecting publish is a failure result");
assert.equal(result.url, "wss://relay.example");
assert.ok(result.error, "the rejection reason is captured on the result");
// Let any leaked microtask rejections surface before asserting.
await new Promise((r) => setTimeout(r, 10));
assert.equal(
unhandled.length,
0,
`no unhandled rejections should leak (saw ${unhandled.length})`,
);
} finally {
process.off("unhandledRejection", onUnhandled);
}
});
test("a fulfilling array-promise becomes a success result", async () => {
const pool = arrayReturningPool(() => Promise.resolve("ok"));
const result = await publishEventToRelay(pool, "wss://relay.example", EVENT);
assert.equal(result.success, true);
assert.equal(result.url, "wss://relay.example");
});
test("all-rejecting publish still resolves (does not hang) with a failure", async () => {
const pool = arrayReturningPool(() => Promise.reject(new Error("auth-required")));
const result = await publishEventToRelay(pool, "wss://relay.example", EVENT, {
timeoutMs: 2000,
});
assert.equal(result.success, false);
});