Files
buzz/desktop/src/shared/api/relayClosedRecovery.test.mjs
Will Pfleger 9b1c4f98e2 🤖 perf: bound long-thread aux reads and make query deadlines terminal (#7854)
Long threads (over ~100 replies) time out in desktop. The aux hop that
loads reactions and other e-tag references builds its filter as one
`tags @> …` term per id, joined with `OR`. Past about 100 ids the
planner switches to walking each partition's primary key backward,
expecting to stop early. When few rows actually match, it scans the
whole community. With a dirty GIN pending list, a 188-reply thread never
finishes.

### What changes

- **Set-based e-tag reads (`buzz-db`).** The aux e-tag query now puts
the id set behind a `MATERIALIZED` CTE and matches with `ANY`. Each
e-tag read also runs under a transaction-local `statement_timeout` of 20
s.
- **Terminal timeout contract (`buzz-relay`).** When a read hits the
deadline, the client gets a terminal timeout (`CLOSED` for REQ, the
timeout error for COUNT and search), not a retryable failure.
- **Subscription lifecycle (`buzz-relay`).** The per-connection
subscription map lock is now the lifecycle lock. Claims, replacement,
client `CLOSE`, owner-checked timeout and search cleanup, revoke, and
connection teardown all change the map, the registry, topic refcounts
and terminal frames while holding it. As a result, a timed-out
subscription's `CLOSED` goes out before any replacement with the same id
can claim it, and claims made after teardown are refused.
- **Dropped terminal frame disconnects (`buzz-relay`).** If the `CLOSED`
for a retired subscription (owner-checked timeout or channel eviction)
can't be queued, the relay cancels the connection instead of leaving the
client holding a subscription that will never receive fan-out. A
client-initiated `CLOSE` ack is exempt.
- **COUNT under the same deadline (`buzz-db`).** The fast COUNT path
runs e-tag counts under the same transaction-local 20 s cap as SELECT,
so WS COUNT and HTTP `/count` produce the terminal timeout too.
- **Replica reads surface the deadline (`buzz-db`).** When a
replica-routed read is cancelled by `statement_timeout` (SQLSTATE
`57014`), the error is returned instead of re-running the query on the
writer, which would run the same slow statement. Other replica errors
still fall back to the writer.
`PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md` records the exception.
- **Deadline handling (desktop).** Only the relay's `query timed out`
counts as a query deadline. A client-side `request timed out` stays
retryable. For one-shot reads a deadline is terminal: the global query
retry (budget 1), thread-replies retry and reaction hydration don't
re-send the same slow query. Reaction ids held back by a deadline are
released when the channel is opened again, so reopening retries them
while same-channel re-renders don't. Live subscriptions, in both the
renderer and the native archive-sync socket, keep treating a deadline
`CLOSED` as transient: they back off and resubscribe with the same
filter instead of being dropped.
- **Deadline handling (mobile).** Mobile recognizes the deadline on both
transports: HTTP `503 {"error":"query timed out"}` and the WS `CLOSED`
reason.
- The root `ProviderScope` retry policy returns no retry for deadline
errors and keeps Riverpod's default backoff for everything else.
  - Background thread-summary recounts don't retry a deadline.
- Forum posts polling pauses while the last load settled on a deadline;
reopening the forum retries.
- A channel's newest-window load surfaces a deadline instead of falling
back to legacy WS history, and the channel list's batched queries return
"unavailable" on a deadline instead of fanning out to per-filter
fallbacks. An unavailable batch is not treated as empty. Known
last-message timestamps and unread state are kept, and unread catch-up
moves `lastMessageAt` forward (never backward) the same way live events
do, so a cold start with no known timestamp still gets one.
- A live subscription that gets a deadline `CLOSED` backs off and
resubscribes with the same filter. History requests still fail on it.
- Settled load failures show a Retry control, matching desktop's
thread-replies error card: a shared `LoadErrorView` on the channel
timeline, forum thread and forum posts list, and an icon button beside
the thread replies summary. While the thread query has no value, the
thread page shows the route snapshot plus live, cached and optimistic
replies, with the loading or error status still visible.
- Other re-sends (reconnect, foreground resume, live events, the unread
backstop, scroll) are not suppressed; each is bounded by the relay's 20
s statement deadline.
- **Long-thread seeder.** `just seed-long-thread [replies]` posts one
thread (default 187 replies) with some reactions to the local dev relay,
for reproducing long threads in the apps. It uses a throwaway key,
refuses non-loopback relays and ignores ambient `BUZZ_*` credentials.
Writes are paced to fit the stock relay rate limit.

**Lifecycle concurrency.** The lock serializes one connection's
subscription changes. Other connections can still be delayed by pressure
on the shared pubsub command queue, and by serial eviction loops waiting
behind a blocked connection. This PR does not claim strict isolation
between connections.

**Recovery vs. load.** Clients stop automatic retries of the same
one-shot read, but they don't remember deadlines across other triggers:
reconnect, resume, live events and the unread backstop can each send the
read again later, bounded by the 20 s statement deadline. A live
subscription that hits the deadline resubscribes with its original
filter under the existing 1–30 s backoff, and against a filter that
keeps timing out it retries at the 30 s cap indefinitely, so the worst
case is one bounded query per subscription every 30 s. `since` is
deliberately not advanced to now on resubscribe, because that would skip
events missed while the subscription was closed. Unchanged from `main`:
when mobile's channel history sync fails with cached messages available,
the cached messages are shown without the error or Retry control.


### Benchmark

Local reproduction on an isolated bench stack (not committed):
- PG16, migrations applied, one 188-reply thread plus 5M background
events in the same community.
- Each request replays the desktop thread-replies filter through the
relay, with `include_aux` on. Times are total ms for 3 runs, with a 60 s
client cap.

GIN pending-list state is controlled with `VACUUM` (clean) or by
refilling the pending list (dirty), and recorded per partition with
`pgstatginindex`. Dirty runs held 402 pending pages on every partition's
tags index at the start and end of each matrix. Clean runs held 0–1
pages (relay startup writes).

| build | GIN pending state | limit 10 | 50 | 100 | 200 (188 ids) |
|---|---|---|---|---|---|
| `main` | clean | 26 / 16 / 13 | 56 / 51 / 52 | 92 / 93 / 95 | 662 /
639 / 641 |
| `main` | dirty | 315 / 298 / 327 | 22141 / 15888 / 16769 | 28992 /
27819 / 28080 | timeout ×3 (>60 s) |
| this PR | clean | 35 / 21 / 14 | 57 / 49 / 48 | 82 / 80 / 75 | 153 /
150 / 135 |
| this PR | dirty | 290 / 254 / 251 | 1257 / 1204 / 1206 | 2432 / 2378 /
2382 | 4333 / 4706 / 5104 |

Baseline `origin/main` `b37e47721`; this PR at `5f2650f5f`, whose e-tag
read builders are identical to the current head. At head `1ad81d868`
against `origin/main` `15d44dcce`, limit 200 measured 145–179 ms clean
(`main` 683–715) and 4.41–4.51 s dirty (`main` timeout ×3).

Every completed cell returns the same signed event set: 209 events, 187
replies at limit 200. With `include_aux` off, every cell is ≤66 ms. The
thread-window loader (`select_aux`) is unchanged from `main`, and its
timings and captured plans match `main`'s.

Actual aux-hop statements captured from each relay binary with
`auto_explain` (analyze + buffers), execution ms:

| build | clean, 100 ids | clean, 188 ids | dirty, 100 ids | dirty, 188
ids |
|---|---|---|---|---|
| `main` (N-way `OR`) | 40 | 298 | 28785 | 62892 |
| this PR (`MATERIALIZED` fence + `ANY`) | 23 | 45 | 1992 | 3539 |

**What the fence changes**
- On `main`, the planner combines `ORDER BY created_at … LIMIT` with an
overestimated match count and walks each partition's primary key
backward, expecting to stop early. With few real matches it scans the
whole community: the dirty 188-id statement touches ~4.9M shared buffers
on `main` vs ~0.6M with the fence.
- The fence prevents that early-stop plan. Across the measured cases
(1–188 ids, both pending states), the planner used the GIN tags index.
The fence doesn't force GIN in general; a broad enough filter could
still get a different plan.

**Tradeoff:** the fence materializes every match before sorting. In this
bench small limits stayed within run-to-run noise of `main`. A single
hot id (5,000 reactions, limit 100) measured 30–34 ms unfenced vs 35 ms
fenced.

**Deadline:** each e-tag read (SELECT and COUNT) runs under a
transaction-local `statement_timeout` of 20 s. It only tightens: a
shorter operator timeout (`BUZZ_DB_STATEMENT_TIMEOUT_MS`) is kept. It's
a per-statement limit, not a per-request one, so a request that runs
several pages or hops can take longer than 20 s in total. The benchmark
does not force the deadline.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
2026-09-28 16:36:40 -04:00

791 lines
22 KiB
JavaScript

import assert from "node:assert/strict";
import test from "node:test";
import {
handleRelayClosed,
handleSubscriptionEose,
} from "./relayClosedRecovery.ts";
import {
requestFirstEventGated,
requestHistoryGated,
} from "./relayGateBoundary.ts";
// ── Fake-timer setup ──────────────────────────────────────────────────────────
// The rate-limit gate and closed-retry logic use window.setTimeout/clearTimeout.
let fakeNow = 0;
const pendingTimers = new Map();
let nextTimerId = 1;
function fakeSetTimeout(fn, ms) {
const id = nextTimerId++;
pendingTimers.set(id, { fn, fireAt: fakeNow + ms });
return id;
}
function fakeClearTimeout(id) {
pendingTimers.delete(id);
}
function tickTo(ms) {
fakeNow = ms;
for (const [id, { fn, fireAt }] of Array.from(pendingTimers.entries())) {
if (fireAt <= fakeNow) {
pendingTimers.delete(id);
fn();
}
}
}
const origDateNow = Date.now;
function setFakeNow(ms) {
fakeNow = ms;
Date.now = () => fakeNow;
}
globalThis.window = {
setTimeout: fakeSetTimeout,
clearTimeout: fakeClearTimeout,
};
// Import gate after window shim is installed.
const { activateRateLimit, isRateLimited, resetRateLimitGate } = await import(
"./relayRateLimitGate.ts"
);
function resetAll(startMs = 0) {
pendingTimers.clear();
nextTimerId = 1;
setFakeNow(startMs);
resetRateLimitGate();
}
test("production CLOSED handler rejects non-rate-limited history immediately and clears its timeout", () => {
const originalWindow = globalThis.window;
const clearedTimeouts = [];
globalThis.window = {
setTimeout: (_fn, _ms) => 0,
clearTimeout: (timeout) => clearedTimeouts.push(timeout),
};
try {
const errors = [];
const subscriptions = new Map([
[
"history-1",
{
mode: "history",
filter: { kinds: [0], authors: ["pubkey-1"], limit: 10 },
events: [],
resolve: () => assert.fail("CLOSED must not resolve history"),
reject: (error) => errors.push(error),
timeout: 42,
timeoutMs: 25_000,
},
],
]);
const input = {
subscriptions,
subId: "history-1",
sendReq: () => Promise.resolve(),
};
// Non-rate-limited CLOSED should reject immediately.
handleRelayClosed({ ...input, message: "error: database unavailable" });
handleRelayClosed({ ...input, message: "late CLOSED" });
assert.equal(subscriptions.has("history-1"), false);
assert.deepEqual(clearedTimeouts, [42]);
assert.equal(errors.length, 1);
assert.equal(errors[0].message, "error: database unavailable");
} finally {
globalThis.window = originalWindow;
}
});
test("rate-limited history CLOSED schedules retry instead of rejecting immediately", () => {
resetAll(0);
const errors = [];
const reqsSent = [];
const subscriptions = new Map([
[
"history-rl",
{
mode: "history",
filter: { kinds: [0], authors: ["pubkey-1"], limit: 10 },
events: [],
resolve: () => assert.fail("must not resolve before retry"),
reject: (error) => errors.push(error),
timeout: 99,
timeoutMs: 25_000,
},
],
]);
handleRelayClosed({
subscriptions,
subId: "history-rl",
message: "rate-limited: quota exceeded; retry in 5s",
sendReq: (id, filter) => {
reqsSent.push({ id, filter });
return Promise.resolve();
},
});
// Subscription should NOT have been rejected immediately.
assert.equal(
errors.length,
0,
"must not reject immediately on first attempt",
);
// Original subId evicted, a new one registered.
assert.equal(subscriptions.has("history-rl"), false);
// No retry fired yet at t=0.
assert.equal(reqsSent.length, 0, "retry must not fire before delay");
// Fire the retry timer (hint=5s → delayMs=5000).
tickTo(5_001);
assert.equal(
reqsSent.length,
1,
"retry REQ must fire after rate-limit window",
);
});
test("rate-limited history CLOSED exhausts 3 retries then rejects permanently", () => {
resetAll(0);
const errors = [];
const reqsSent = [];
const filter = { kinds: [0], authors: ["pk"], limit: 1 };
const subscription = {
mode: "history",
filter,
events: [],
resolve: () => assert.fail("must not resolve"),
reject: (error) => errors.push(error),
timeout: 0,
timeoutMs: 25_000,
};
const subscriptions = new Map([["history-rl", subscription]]);
const sendReq = (id, _filter) => {
reqsSent.push(id);
return Promise.resolve();
};
// Simulate 3 rate-limited CLOSEDs (exhausts the attempt budget).
for (let i = 0; i < 3; i++) {
// The current live subId rotates on each retry; find it.
const currentSubId = [...subscriptions.keys()].find((k) =>
k.startsWith("history"),
);
assert.ok(currentSubId, `must have a live history sub on attempt ${i}`);
handleRelayClosed({
subscriptions,
subId: currentSubId,
message: "rate-limited: quota exceeded; retry in 1s",
sendReq,
});
assert.equal(
errors.length,
0,
`must not reject before attempt ${i + 1} fires`,
);
tickTo((i + 1) * 1_001);
assert.equal(reqsSent.length, i + 1, `sendReq call ${i + 1} must fire`);
}
// 4th CLOSED — attempt budget exhausted → permanent reject.
const currentSubId = [...subscriptions.keys()].find((k) =>
k.startsWith("history"),
);
assert.ok(
currentSubId,
"must still have a live history sub before last CLOSED",
);
handleRelayClosed({
subscriptions,
subId: currentSubId,
message: "rate-limited: quota exceeded; retry in 1s",
sendReq,
});
assert.equal(errors.length, 1, "must reject after 3 failed retries");
assert.equal(subscriptions.size, 0, "must evict sub after permanent reject");
});
test("rate-limited history retry op-timeout sends CLOSE and rejects without leaking the sub", () => {
// Regression for: retry-path op-timeout deleted the sub and rejected but
// never sent CLOSE for the rotated subId — leaving the relay holding a slot
// against its per-connection cap.
//
// Also validates the late-EOSE non-regression: once the sub is evicted and
// CLOSE sent, a late EOSE cannot double-resolve the promise because
// handleSubscriptionEose guards on subscriptions.has(subId).
resetAll(0);
const errors = [];
const closedSubIds = [];
const filter = { kinds: [9], "#h": ["ch-close"], limit: 50 };
const subscription = {
mode: "history",
filter,
events: [],
resolve: () => assert.fail("must not resolve"),
reject: (error) => errors.push(error),
timeout: 0,
timeoutMs: 5_000,
};
const subscriptions = new Map([["history-close", subscription]]);
// Trigger a rate-limited CLOSED — subscription rotates to a new subId.
handleRelayClosed({
subscriptions,
subId: "history-close",
message: "rate-limited: quota exceeded; retry in 1s",
sendReq: () => Promise.resolve(),
closeSubscription: async (id) => {
closedSubIds.push(id);
},
});
assert.equal(errors.length, 0, "must not reject before delay fires");
// Find the rotated subId.
const newSubId = [...subscriptions.keys()].find((k) =>
k.startsWith("history"),
);
assert.ok(newSubId, "rotated sub must be registered");
assert.notEqual(newSubId, "history-close", "sub must have been rotated");
// The retry-delay timer fires: sendReq runs and arms a new op-timeout.
tickTo(1_001);
assert.equal(errors.length, 0, "still must not reject immediately after REQ");
// The op-timeout fires (delay + timeoutMs = 1001 + 5000 = 6001 ms).
tickTo(6_002);
assert.equal(
errors.length,
1,
"must reject after op-timeout on the retried sub",
);
assert.ok(
closedSubIds.includes(newSubId),
`CLOSE must be sent for the rotated sub ${newSubId} (got: ${JSON.stringify(closedSubIds)})`,
);
assert.equal(
subscriptions.has(newSubId),
false,
"rotated sub must be evicted after op-timeout",
);
// Late-EOSE non-regression: arriving after eviction must be a no-op.
const prevErrors = errors.length;
handleSubscriptionEose({
subscriptions,
subId: newSubId,
closeSubscription: async () => {},
});
assert.equal(
errors.length,
prevErrors,
"late EOSE after eviction must not alter error state",
);
});
test("rate-limited history CLOSED arms the shared gate for concurrent ops", () => {
resetAll(0);
const subscriptions = new Map([
[
"history-1",
{
mode: "history",
filter: { kinds: [0], authors: ["pubkey-1"], limit: 10 },
events: [],
resolve: () => {},
reject: () => {},
timeout: 0,
timeoutMs: 25_000,
},
],
]);
handleRelayClosed({
subscriptions,
subId: "history-1",
message: "rate-limited: quota exceeded; retry in 5s",
sendReq: () => Promise.resolve(),
});
assert.equal(
isRateLimited(),
true,
"gate must be active after rate-limited history CLOSED",
);
// Gate expires after the hint duration.
tickTo(5_001);
assert.equal(isRateLimited(), false, "gate must clear after hint duration");
});
test("non-rate-limited history CLOSED does not arm the gate", () => {
resetAll(0);
const subscriptions = new Map([
[
"history-2",
{
mode: "history",
filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
events: [],
resolve: () => {},
reject: () => {},
timeout: 0,
timeoutMs: 25_000,
},
],
]);
handleRelayClosed({
subscriptions,
subId: "history-2",
message: "error: database unavailable",
sendReq: () => Promise.resolve(),
});
assert.equal(
isRateLimited(),
false,
"gate must remain inactive for non-rate-limited history CLOSED",
);
});
test("gate armed by rate-limited history CLOSED defers the next REQ until expiry then resumes", async () => {
// Simulate: rate-limited CLOSED arrives on a history sub → gate arms for 5s.
// A concurrent requestHistoryGated call must not issue the REQ before 5s,
// and must issue it (and resolve) once the gate clears.
resetAll(0);
const subscriptions = new Map([
[
"history-gate",
{
mode: "history",
filter: { kinds: [9], "#h": ["ch-gate"], limit: 50 },
events: [],
resolve: () => {},
reject: () => {},
timeout: 0,
timeoutMs: 25_000,
},
],
]);
// Arm the gate via a rate-limited history CLOSED.
handleRelayClosed({
subscriptions,
subId: "history-gate",
message: "rate-limited: quota exceeded; retry in 5s",
sendReq: () => Promise.resolve(),
});
assert.equal(isRateLimited(), true, "gate must be armed before the test");
const sentAt = [];
const reqSubscriptions = new Map();
// requestHistoryGated will await the gate, so the REQ must not fire at t=0.
const historyPromise = requestHistoryGated(
reqSubscriptions,
async (payload) => {
// Record when the REQ fires. The test harness sets up the EOSE path by
// adding a history subscription to reqSubscriptions immediately after
// the REQ is recorded, then resolving it.
sentAt.push(fakeNow);
// Resolve the returned promise by completing the sub synchronously.
const subId = payload[1];
const sub = reqSubscriptions.get(subId);
if (sub) {
window.clearTimeout(sub.timeout);
reqSubscriptions.delete(subId);
sub.resolve([]);
}
},
async () => {},
{ kinds: [9], "#h": ["ch-test"], limit: 50 },
25_000,
);
// REQ must not have fired yet — gate is still active at t=0.
await Promise.resolve();
assert.equal(sentAt.length, 0, "REQ must not fire while gate is active");
// Expire the gate — the deferred REQ should fire.
tickTo(5_001);
await historyPromise;
assert.equal(
sentAt.length,
1,
"REQ must fire exactly once after gate clears",
);
assert.ok(sentAt[0] >= 5_001, "REQ must fire only after gate expiry");
});
test("first-event request resolves null when EOSE arrives without an event", async () => {
resetAll(0);
const subscriptions = new Map();
let requestedSubId = "";
const firstEventPromise = requestFirstEventGated(
subscriptions,
async (payload) => {
requestedSubId = payload[1];
},
async () => {},
{ kinds: [13_534], limit: 1 },
25_000,
);
await Promise.resolve();
assert.match(requestedSubId, /^first-/);
handleSubscriptionEose({
subscriptions,
subId: requestedSubId,
closeSubscription: async () => {},
});
assert.equal(await firstEventPromise, null);
assert.equal(subscriptions.has(requestedSubId), false);
});
test("live readiness distinguishes EOSE from CLOSED", () => {
const readiness = [];
const subscriptions = new Map([
[
"live-eose",
{
mode: "live",
filter: { kinds: [9], limit: 0 },
onEvent: () => {},
resolveReady: (result) => readiness.push(result),
},
],
[
"live-closed",
{
mode: "live",
filter: { kinds: [9], limit: 0 },
onEvent: () => {},
resolveReady: (result) => readiness.push(result),
},
],
]);
handleSubscriptionEose({
subscriptions,
subId: "live-eose",
closeSubscription: async () => {},
});
handleRelayClosed({
subscriptions,
subId: "live-closed",
message: "restricted: access revoked",
sendReq: () => Promise.resolve(),
});
assert.deepEqual(readiness, ["eose", "closed"]);
});
test("production CLOSED handler removes terminal live subscriptions", () => {
let readyCalls = 0;
const subscriptions = new Map([
[
"live-1",
{
mode: "live",
filter: { kinds: [9], limit: 50 },
onEvent: () => {},
resolveReady: () => {
readyCalls += 1;
},
},
],
]);
handleRelayClosed({
subscriptions,
subId: "live-1",
message: "restricted: access revoked",
sendReq: () => Promise.resolve(),
});
assert.equal(subscriptions.has("live-1"), false);
assert.equal(readyCalls, 1);
});
// ── Rate-limited CLOSED core behaviour (F5) ───────────────────────────────────
test("rate-limited CLOSED keeps live subscription in the map", () => {
resetAll(0);
const subscriptions = new Map([
[
"live-1",
{
mode: "live",
filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
onEvent: () => {},
resolveReady: () => {},
},
],
]);
handleRelayClosed({
subscriptions,
subId: "live-1",
message: "rate-limited: quota exceeded; retry in 5s",
sendReq: () => Promise.resolve(),
});
assert.equal(
subscriptions.has("live-1"),
true,
"subscription must survive rate-limited CLOSED",
);
});
test("rate-limited CLOSED activates the rate-limit gate with the parsed hint", () => {
resetAll(0);
const subscriptions = new Map([
[
"live-1",
{
mode: "live",
filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
onEvent: () => {},
resolveReady: () => {},
},
],
]);
handleRelayClosed({
subscriptions,
subId: "live-1",
message: "rate-limited: quota exceeded; retry in 5s",
sendReq: () => Promise.resolve(),
});
assert.equal(
isRateLimited(),
true,
"gate must be active after rate-limited CLOSED",
);
// Gate should expire at 5s.
tickTo(5_001);
assert.equal(isRateLimited(), false);
});
test("rate-limited CLOSED retry delay is max(backoff, gate remaining), not just hint", () => {
resetAll(0);
// Activate a long gate first (20s), then send a shorter-hint CLOSED (5s).
// The retry delay must use the gate remaining time (20s), not the hint (5s).
activateRateLimit(20); // gate expires at 20_000 ms
const firedAt = [];
const subscriptions = new Map([
[
"live-1",
{
mode: "live",
filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
onEvent: () => {},
resolveReady: () => {},
},
],
]);
handleRelayClosed({
subscriptions,
subId: "live-1",
message: "rate-limited: quota exceeded; retry in 5s",
sendReq: () => {
firedAt.push(fakeNow);
return Promise.resolve();
},
});
// Retry should NOT fire at 5s (hint) — the gate remaining is 20s.
tickTo(5_001);
assert.equal(
firedAt.length,
0,
"retry must not fire before gate remaining time",
);
// Should fire at 20s.
tickTo(20_001);
assert.equal(firedAt.length, 1, "retry must fire after gate remaining time");
});
test("non-rate-limited retryable CLOSED still schedules a retry", () => {
resetAll(0);
const firedAt = [];
const subscriptions = new Map([
[
"live-1",
{
mode: "live",
filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
onEvent: () => {},
resolveReady: () => {},
},
],
]);
handleRelayClosed({
subscriptions,
subId: "live-1",
message: "error: database error",
sendReq: () => {
firedAt.push(fakeNow);
return Promise.resolve();
},
});
// Base delay is 1s for first attempt.
tickTo(1_001);
assert.equal(firedAt.length, 1, "retryable CLOSED must schedule a retry");
assert.equal(
subscriptions.has("live-1"),
true,
"subscription must survive retryable CLOSED",
);
});
test("terminal CLOSED deletes subscription and does not retry", () => {
resetAll(0);
const firedAt = [];
const subscriptions = new Map([
[
"live-1",
{
mode: "live",
filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
onEvent: () => {},
resolveReady: () => {},
},
],
]);
handleRelayClosed({
subscriptions,
subId: "live-1",
message: "restricted: not a member",
sendReq: () => {
firedAt.push(fakeNow);
return Promise.resolve();
},
});
assert.equal(
subscriptions.has("live-1"),
false,
"terminal CLOSED must delete subscription",
);
tickTo(10_000);
assert.equal(firedAt.length, 0, "terminal CLOSED must not retry");
});
test("live CLOSED query deadline retries with backoff instead of removing", () => {
resetAll(0);
const firedAt = [];
const subscriptions = new Map([
[
"live-1",
{
mode: "live",
filter: { kinds: [9], "#h": ["ch-1"], limit: 1000 },
onEvent: () => {},
resolveReady: () => {},
},
],
]);
handleRelayClosed({
subscriptions,
subId: "live-1",
message: "error: query timed out",
sendReq: () => {
firedAt.push(fakeNow);
return Promise.resolve();
},
});
assert.equal(subscriptions.has("live-1"), true, "live sub must survive");
assert.equal(firedAt.length, 0, "retry waits for backoff");
tickTo(10_000);
assert.equal(firedAt.length, 1, "live sub must be re-sent after backoff");
});
// ── Test: rejecting closeSubscription does not produce an unhandled rejection ─
//
// Load-bearing for the `.catch(() => {})` guard on the op-timeout CLOSE send.
//
// When the IPC/socket call inside closeSubscription throws (e.g. the connection
// was already torn down), the rejection must be swallowed — the history promise
// is already being rejected by the line below the CLOSE send. Without the catch,
// a Node/browser unhandled-rejection event fires and surfaces as a test failure.
test("rejecting closeSubscription on op-timeout does not produce an unhandled rejection", async () => {
resetAll(0);
const errors = [];
const filter = { kinds: [9], "#h": ["ch-reject"], limit: 50 };
const subscription = {
mode: "history",
filter,
events: [],
resolve: () => assert.fail("must not resolve"),
reject: (error) => errors.push(error),
timeout: 0,
timeoutMs: 5_000,
};
const subscriptions = new Map([["history-reject", subscription]]);
// closeSubscription that rejects — simulates a broken socket.
handleRelayClosed({
subscriptions,
subId: "history-reject",
message: "rate-limited: quota exceeded; retry in 1s",
sendReq: () => Promise.resolve(),
closeSubscription: async () => {
throw new Error("socket already closed");
},
});
// Fire the retry delay and op-timeout.
tickTo(1_001);
tickTo(6_002);
// Let any async promise chains settle.
await Promise.resolve();
await Promise.resolve();
// The history promise must have been rejected exactly once despite the
// closeSubscription rejection — no unhandled rejection from the CLOSE send.
assert.equal(
errors.length,
1,
"history promise must be rejected exactly once; the CLOSE rejection is swallowed",
);
assert.ok(
errors[0].message.includes("Relay closed"),
`rejection must be the history error, not the CLOSE error (got: ${errors[0].message})`,
);
});
// ── Test: CLOSE wiring at the production call site ────────────────────────────
//
// Falsifiable guard: asserts that relayClientSession.ts passes closeSubscription
// to handleRelayClosed. Removing the wiring line makes this test promptly red —
// closing the gap where the helper behavioral test passes without the actual
// production CLOSE being sent.
test("relayClientSession wires closeSubscription into handleRelayClosed", async () => {
const { readFile } = await import("node:fs/promises");
const source = await readFile(
new URL("./relayClientSession.ts", import.meta.url),
"utf8",
);
// The handleRelayClosed call must pass a closeSubscription callback.
assert.match(
source,
/handleRelayClosed\(\{[^}]*closeSubscription[^}]*\}\)/,
"relayClientSession.ts must pass closeSubscription to handleRelayClosed",
);
// The callback must reach this.closeSubscription so it sends a real CLOSE.
assert.match(
source,
/closeSubscription:\s*\([^)]+\)\s*=>\s*this\.closeSubscription\(/,
"closeSubscription callback must delegate to this.closeSubscription()",
);
});
// ── Teardown ──────────────────────────────────────────────────────────────────
test("teardown — restore Date.now", () => {
Date.now = origDateNow;
assert.ok(true);
});