From ec77f50744a09e013dbdc4ce2891f94a72d3dd31 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 20:29:25 +0000 Subject: [PATCH] fix(nostr): prevent count timeouts from disconnecting relays Prevents "count-timeout" errors from tripping the global circuit breaker in NostrClient. This ensures that slow auxiliary requests do not block critical subscription connections, resolving login issues in unstable network environments. Co-authored-by: PR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> --- js/nostr/client.js | 15 ++++-- tests/unit/client-count-resilience.test.mjs | 57 +++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 tests/unit/client-count-resilience.test.mjs diff --git a/js/nostr/client.js b/js/nostr/client.js index a6279725..ff0082e5 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -8608,9 +8608,18 @@ export class NostrClient { this.countUnsupportedRelays.add(url); } else { logRelayCountFailure(url, error); - this.markRelayUnreachable(url, 60000, { - reason: isTimeout ? "count-timeout" : "count-error", - }); + // If the count request timed out, we don't want to kill the entire relay connection + // because it might still be good for subscriptions. + // We only trip the circuit breaker for hard errors. + if (!isTimeout) { + this.markRelayUnreachable(url, 60000, { + reason: "count-error", + }); + } else if (isDevMode) { + devLogger.warn( + `[nostr] Relay ${url} count timed out (ignored for circuit breaker).` + ); + } } return { url, diff --git a/tests/unit/client-count-resilience.test.mjs b/tests/unit/client-count-resilience.test.mjs new file mode 100644 index 00000000..41ccb679 --- /dev/null +++ b/tests/unit/client-count-resilience.test.mjs @@ -0,0 +1,57 @@ + +import { test } from "node:test"; +import assert from "node:assert"; +import { NostrClient } from "../../js/nostr/client.js"; + +// Mock global objects if needed +if (!globalThis.localStorage) { + globalThis.localStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }; +} + +test("NostrClient resilience to COUNT timeouts", async (t) => { + const client = new NostrClient(); + const relayUrl = "wss://slow.relay.example"; + client.relays = [relayUrl]; + + // Mock the pool + client.pool = { + ensureRelay: async (url) => { + return { + url, + count: async () => { + // Simulate timeout by waiting longer than the client's timeout + await new Promise(r => setTimeout(r, 100)); + // In the real code, withRequestTimeout throws the error, the relay just hangs or returns late. + // But here we can simulate the client's wrapper throwing 'count-timeout' + // Actually, client.sendRawCountFrame handles the timeout wrapping. + // So we just need a promise that hangs. + return new Promise(() => {}); + } + }; + } + }; + + // We need to override getRequestTimeoutMs to be very short for the test + client.getRequestTimeoutMs = () => 10; // 10ms timeout + + // Verify initial state + assert.strictEqual(client.unreachableRelays.has(relayUrl), false); + + // Trigger the count + await client.countEventsAcrossRelays([{ kinds: [1] }]); + + // Check if relay was marked unreachable + // logic: + // 1. sendRawCountFrame calls withRequestTimeout + // 2. timeouts after 10ms -> throws 'count-timeout' + // 3. countEventsAcrossRelays catches 'count-timeout' + // 4. currently calls markRelayUnreachable + + const isUnreachable = client.unreachableRelays.has(relayUrl); + + assert.strictEqual(isUnreachable, false, "Relay should NOT be marked unreachable after count timeout"); +});