fix(desktop): harden profile batch and thread-reply fetches against relay slowness (#7188)

Three client gaps turn transient relay failures into permanent UI
degradation. Under a slow or rate-limited relay:

1. A cold channel's profile batch exhausts its single retry and leaves
raw npubs + broken mention chips until the user manually kicks the
channel.
2. A thread opened from a notification trusts a successful-but-empty
reply read as authoritative and never retries.
3. A rate-limited `CLOSED` on a history subscription immediately rejects
the caller rather than retrying after the rate-limit window.

All three are addressed without changing global query defaults or the
happy-path behavior.

## Changes

**Fix 1 — cold profile batch resilience** (`useUsersBatchQuery`,
`desktop/src/features/profile/hooks.ts`)

Override `retry: 3` with exponential backoff and error-gated
`refetchOnWindowFocus: (query) => query.state.status === "error"`,
scoped to this query only. The global defaults (`retry: 1`,
`refetchOnWindowFocus: false`) are intentional for other queries and are
unchanged. After the retry budget exhausts, a window-focus event (e.g.
channel-switch) recovers the query automatically — but only when it is
already in an error state, preventing unnecessary refetches for
successful batches.

**Fix 2 — stale-empty thread reads** (`useThreadReplies.ts`,
`ChannelScreen.tsx`)

Add optional `expectedEventId` parameter. When a completed paged fetch
does not contain the expected event, throw
`ThreadExpectedEventMissingError` so React Query's built-in retry
machinery handles it rather than caching an authoritative empty.
`ChannelScreen` passes `threadScrollTargetId` (the notification-linked
reply ID) as `expectedEventId`.

When notification routing changes `expectedEventId` while the same
thread root is already mounted (same query key), an explicit
`invalidateQueries` in a `useEffect` triggers a fresh validation pass.
The `useEffect` is declared after `useQuery` so TanStack's internal
options-update effect installs the new `queryFn` closure first; the
refetch therefore uses the current `expectedEventId` rather than the
previous null closure. For the cold-start race (target arrives before
the first page returns), the effect detects `fetchStatus === "fetching"
&& status === "pending"` and calls
`cancelQueries().then(invalidateQueries)` so the obsolete in-flight
response cannot settle as authoritative before the new target's
validation closure is active.

The query-fn tracks consecutive fetch attempts per target. On attempt 3,
it adds the target to `exhaustedTargetsRef` before calling
`loadThreadReplies`. `loadThreadReplies` sees the target in the
exhausted set and returns the fetched replies directly rather than
throwing — the terminal attempt always resolves to success. No
re-entrant scheduling: the resolution is synchronous inside the query
function itself. Deleted/moderated targets never lock the thread in a
terminal error surface.

**Fix 3 — CLOSED recovery for history subscriptions**
(`relayClosedRecovery.ts`, `relayClientSession.ts`,
`relayClientShared.ts`, `relayGateBoundary.ts`)

On a rate-limited `CLOSED` the subscription previously rejected the
caller immediately. Store `filter` and `timeoutMs` on
`HistorySubscription`, then on rate-limited `CLOSED` re-register under a
fresh `subId` and defer `sendReq` until the rate-limit window clears —
matching the live-sub recovery design already present in
`relayClosedRecovery.ts`. Bounded to 3 attempts; exhausted retries
reject immediately so callers are never left waiting indefinitely. A new
op-timeout guards the retry REQ against a non-responding relay; when the
op-timeout fires it sends `CLOSE` for the rotated `subId` (matching the
behavior of the original timeout path) so the relay releases the slot
rather than counting it against the per-connection cap.

## Tests

- `relayClosedRecovery.test.mjs`: behavioral fake-clock tests for
history-sub retry, 3-attempt exhaustion, op-timeout CLOSE send +
late-EOSE non-regression, rejecting-`closeSubscription` swallowed
without unhandled rejection, wiring source assertion (fails if
`relayClientSession.ts` drops the `closeSubscription` callback) — 18
tests
- `useThreadReplies.test.mjs`: `loadThreadReplies` unit tests
(throw/exhaustion-guard); behavioral hook tests via real
`QueryClientProvider` + `renderHook`: exhaustion-resolves-to-data,
settled null→target change retries on missing-target page and lands
target data (fails if `invalidateQueries` is removed OR if `useQuery` is
moved after the `useEffect`), cold-fetch cancel-then-invalidate (gated
fetcher — released after rerender, stale empty discarded, replacement
fetch settles with target); ChannelScreen wiring source assertion — 9
tests
- `profileBatchResilience.test.mjs`: source assertions for `retry: 3`,
`retryDelay`, error-gated `refetchOnWindowFocus`, and unchanged global
defaults — 2 tests

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-09-02 17:42:45 -04:00
committed by GitHub
parent 187df22252
commit 6f6093243e
10 changed files with 1209 additions and 23 deletions

View File

@@ -205,6 +205,7 @@ export function ChannelScreen({
const threadRepliesQuery = useThreadReplies(
activeChannel,
effectiveOpenThreadHeadId,
threadScrollTargetId,
);
useChannelSubscription(activeChannel);
const { fetchOlder, hasOlderMessages, historyExhausted, isFetchingOlder } =
@@ -992,4 +993,4 @@ export function ChannelScreen({
</ProfilePanelProvider>
</AgentSessionProvider>
);
}
}

View File

@@ -1,8 +1,662 @@
/**
* Behavioral tests for loadThreadReplies / useThreadReplies validation contract.
*
* These tests exercise the real fetch logic and terminal-policy paths through
* an injected fake fetcher — no Tauri bridge required. Each test is
* load-bearing: removing the throw in loadThreadReplies OR removing the
* exhaustion-set check causes a specific test to fail.
*
* Hook-level tests (mounted-thread target change and exhaustion terminal state)
* use a real QueryClientProvider and renderHook so mutations to the production
* wiring — the useEffect invalidation and the query-fn exhaustion write —
* turn the suite red.
*/
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { mock } from "node:test";
import test from "node:test";
import { registerHooks } from "node:module";
import { JSDOM } from "jsdom";
// ── Tauri stub ────────────────────────────────────────────────────────────────
// Stub @/shared/api/tauri before any module that imports it loads.
// Hook-level tests set globalThis.__tauriGetThreadReplies before each run;
// the stub delegates to that global so tests control the fetcher without
// changing the production hook signature.
globalThis.__tauriGetThreadReplies = async () => ({
events: [],
nextCursor: null,
});
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === "@/shared/api/tauri") {
return { shortCircuit: true, url: "buzz-thread-stub:tauri" };
}
if (specifier.startsWith("buzz-thread-stub:")) {
return { shortCircuit: true, url: specifier };
}
return nextResolve(specifier, context);
},
load(url, context, nextLoad) {
if (url === "buzz-thread-stub:tauri") {
return {
format: "module",
shortCircuit: true,
// Delegate to test-controlled global so each hook test can swap the
// fetcher without reloading the cached module.
source: `
export async function getThreadReplies(rootId, channelId, options) {
return globalThis.__tauriGetThreadReplies(rootId, channelId, options);
}
export default {};
`,
};
}
return nextLoad(url, context);
},
});
// ── DOM setup ─────────────────────────────────────────────────────────────────
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
Object.assign(globalThis, {
IS_REACT_ACT_ENVIRONMENT: true,
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
window: dom.window,
});
// ── Fake event builder ────────────────────────────────────────────────────────
let seq = 0;
function fakeEvent(id) {
return {
id,
pubkey: "a".repeat(64),
kind: 9,
created_at: ++seq,
content: "test",
tags: [],
sig: "sig",
};
}
function singlePage(events) {
return { events, nextCursor: null };
}
// ── Fake QueryClient (for loadThreadReplies unit tests) ───────────────────────
function makeQueryClient(initial = undefined) {
let store = initial;
return {
getQueryData: () => store,
setQueryData: (_key, value) => {
store = value;
},
invalidateQueries: () => Promise.resolve(),
};
}
// ── Test: throws ThreadExpectedEventMissingError when target is absent ────────
//
// Load-bearing: if the `throw new ThreadExpectedEventMissingError(...)` line in
// loadThreadReplies is removed, this test FAILS because no error is thrown.
test("loadThreadReplies throws ThreadExpectedEventMissingError when expected event is absent", async () => {
const { loadThreadReplies, ThreadExpectedEventMissingError } = await import(
"./useThreadReplies.ts"
);
const qc = makeQueryClient();
const reply = fakeEvent("reply-1");
const fetcher = async () => singlePage([reply]);
await assert.rejects(
() =>
loadThreadReplies(
qc,
"chan-1",
"root-1",
"evt-missing",
new Set(),
fetcher,
),
(err) => {
assert.ok(
err instanceof ThreadExpectedEventMissingError,
`Expected ThreadExpectedEventMissingError, got ${err.constructor.name}`,
);
assert.equal(err.expectedEventId, "evt-missing");
return true;
},
);
});
// ── Test: does NOT throw when expected event is present ───────────────────────
test("loadThreadReplies resolves normally when expected event is present", async () => {
const { loadThreadReplies } = await import("./useThreadReplies.ts");
const qc = makeQueryClient();
const target = fakeEvent("evt-present");
const fetcher = async () => singlePage([target]);
const result = await loadThreadReplies(
qc,
"chan-2",
"root-2",
"evt-present",
new Set(),
fetcher,
);
assert.ok(
result.some((e) => e.id === "evt-present"),
"result must contain the expected event",
);
});
// ── Test: exhaustedTargets suppresses throw — returns available replies ────────
//
// Load-bearing: if the `exhaustedTargets?.has(expectedEventId)` guard is removed
// from loadThreadReplies, this test FAILS because the function throws instead of
// resolving.
test("loadThreadReplies returns fetched replies when target is in exhaustedTargets", async () => {
const { loadThreadReplies } = await import("./useThreadReplies.ts");
const qc = makeQueryClient();
const reply = fakeEvent("reply-2");
const fetcher = async () => singlePage([reply]);
const exhausted = new Set(["missing-target"]);
const result = await loadThreadReplies(
qc,
"chan-3",
"root-3",
"missing-target",
exhausted,
fetcher,
);
assert.ok(Array.isArray(result), "must return an array");
assert.ok(
result.some((e) => e.id === "reply-2"),
"must include fetched replies",
);
});
// ── Test: no expectedEventId — always resolves without throw ─────────────────
test("loadThreadReplies resolves normally with no expectedEventId", async () => {
const { loadThreadReplies } = await import("./useThreadReplies.ts");
const qc = makeQueryClient();
const reply = fakeEvent("reply-3");
const fetcher = async () => singlePage([reply]);
const result = await loadThreadReplies(
qc,
"chan-4",
"root-4",
null,
new Set(),
fetcher,
);
assert.ok(result.some((e) => e.id === "reply-3"));
});
// ── Test: multi-page fetch assembles results from all pages ──────────────────
test("loadThreadReplies aggregates events across multiple pages", async () => {
const { loadThreadReplies } = await import("./useThreadReplies.ts");
const qc = makeQueryClient();
const page1Events = [fakeEvent("p1-a"), fakeEvent("p1-b")];
const page2Events = [fakeEvent("p2-a"), fakeEvent("p2-b")];
let calls = 0;
const fetcher = async (_rootId, _channelId, { cursor }) => {
calls += 1;
if (cursor === null) {
return {
events: page1Events,
nextCursor: { createdAt: 1, eventId: "p1-b" },
};
}
return singlePage(page2Events);
};
const result = await loadThreadReplies(
qc,
"chan-5",
"root-5",
null,
new Set(),
fetcher,
);
assert.equal(calls, 2, "fetcher must be called for each page");
const ids = new Set(result.map((e) => e.id));
for (const evt of [...page1Events, ...page2Events]) {
assert.ok(ids.has(evt.id), `result must include ${evt.id}`);
}
});
// ── Test: query-fn writes exhaustion on the terminal attempt ──────────────────
//
// Load-bearing for the query-fn exhaustion write in useThreadReplies.
//
// The query-fn adds expectedEventId to exhaustedTargetsRef when attemptCount
// reaches the threshold (>= 3). loadThreadReplies then sees the target in
// exhaustedTargets and returns data instead of throwing. If the exhaustion
// write (`exhaustedTargetsRef.current.add(expectedEventId)`) is removed from
// the query-fn, the terminal attempt still throws and the hook stays in error.
//
// This test exercises the complete production path: real QueryClientProvider,
// real useThreadReplies hook, fake fetcher via globalThis stub, timer-driven
// backoff (retry: 3, retryDelay 1s/2s/4s). After the two retries, attempt 3
// writes exhaustion and loadThreadReplies resolves data synchronously.
test("useThreadReplies resolves to data after exhausting missing-event retries", async () => {
mock.timers.enable({ apis: ["setTimeout"] });
let queryClient;
let unmount;
let cleanup;
try {
const imported = await import("@testing-library/react");
const act = imported.act;
cleanup = imported.cleanup;
const renderHook = imported.renderHook;
const { createElement } = await import("react");
const { QueryClient, QueryClientProvider } = await import(
"@tanstack/react-query"
);
const { useThreadReplies } = await import("./useThreadReplies.ts");
const reply = fakeEvent("reply-exhaustion");
// Fetcher returns a reply but never the expected event — permanently absent.
globalThis.__tauriGetThreadReplies = async () => singlePage([reply]);
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const channel = { id: "chan-ex", channelType: "group" };
const wrapper = ({ children }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
const hook = renderHook(
() => useThreadReplies(channel, "root-ex", "evt-permanently-absent"),
{ wrapper },
);
unmount = hook.unmount;
const { result } = hook;
// Drive through the two backoff delays for attempts 1 and 2.
// Attempt 1 throws → retryDelay 1s. Attempt 2 throws → retryDelay 2s.
// Attempt 3 writes exhaustion pre-fetch → loadThreadReplies returns data.
// Tick 5s per iteration (covers each delay); flush microtasks between
// ticks so React and TanStack Query advance their state machines.
for (let i = 0; i < 4; i++) {
mock.timers.tick(5_000);
await act(async () => {
await new Promise((resolve) => setImmediate(resolve));
});
}
// After the terminal attempt the query must be in success (data) state.
assert.ok(
!result.current.isError,
`hook must not be in error state after exhaustion (isError=${result.current.isError})`,
);
assert.ok(
Array.isArray(result.current.data),
`hook must expose reply data after exhaustion (data=${JSON.stringify(result.current.data)})`,
);
assert.ok(
result.current.data?.some((e) => e.id === reply.id),
"data must include the fetched replies rather than being empty or errored",
);
} finally {
// Always dispose — a failure before this point must not leak timers,
// mounted hooks, or QueryClient cache timers that hold the event loop.
try {
unmount?.();
} catch (_) {}
try {
cleanup?.();
} catch (_) {}
try {
queryClient?.clear();
} catch (_) {}
mock.timers.reset();
}
});
// ── Test: settled null→target change triggers retry and lands target data ──────
//
// Load-bearing for the useEffect invalidation seam AND for the TanStack effect-
// ordering fix in useThreadReplies.
//
// Required shape (Carl's requirement, P2 finding on PR #7188):
// 1. Mount with expectedEventId=null. Wait for the initial fetch to settle
// with a non-target reply — query is success with data, no target active.
// 2. Rerender with expectedEventId="target-evt-settled" (same root, same key).
// The useEffect must invalidate; the refetch must use the new queryFn
// closure (capturing "target-evt-settled"), not the stale null closure.
// 3. First post-invalidation fetch returns a page WITHOUT the target —
// loadThreadReplies throws ThreadExpectedEventMissingError. This is the
// failure path the pre-fix code silently passed: old null closure
// validated against null, settled success([]) without retrying.
// 4. TanStack retries; second post-invalidation fetch returns the target.
// Hook settles success with the target event in data.
//
// Mutation checks:
// - Remove invalidateQueries from the useEffect → hook never re-fetches,
// data stays at initial non-target reply → target-bearing assertion red.
// - Restore effect but revert useQuery back to before the effect (pre-fix
// ordering) → refetch uses old null closure, settles success([]) without
// the target or a retry → target-bearing assertion red.
//
// Also load-bearing for the ChannelScreen wiring: the source assertion verifies
// that ChannelScreen.tsx passes threadScrollTargetId as the third argument to
// useThreadReplies. Removing that argument fails the source check, catching the
// exact bypass that allowed notification routing to skip the missing-event check.
test("useThreadReplies null-to-target change retries on missing-target page and lands target data", async () => {
let queryClient;
let unmount;
let cleanup;
try {
const imported = await import("@testing-library/react");
const act = imported.act;
cleanup = imported.cleanup;
const renderHook = imported.renderHook;
const { createElement } = await import("react");
const { QueryClient, QueryClientProvider } = await import(
"@tanstack/react-query"
);
const { useThreadReplies } = await import("./useThreadReplies.ts");
const { readFile } = await import("node:fs/promises");
const targetEvent = fakeEvent("target-evt-settled");
const otherReply = fakeEvent("other-reply-settled");
// Phase tracking:
// fetch 1 — initial null-target fetch: returns otherReply (no target active)
// fetch 2 — first post-invalidation fetch: returns otherReply only (target
// absent), triggering ThreadExpectedEventMissingError under the
// new closure (captures "target-evt-settled"). Under the old
// null closure this would silently settle without throwing.
// fetch 3+ — retry fetch: returns targetEvent (relay caught up)
let fetchCount = 0;
globalThis.__tauriGetThreadReplies = async () => {
fetchCount += 1;
if (fetchCount === 1) {
// Initial null-target settle — no target validation needed.
return singlePage([otherReply]);
}
if (fetchCount === 2) {
// First post-invalidation fetch — target absent. Under the fixed code
// the closure captures "target-evt-settled" and loadThreadReplies
// throws ThreadExpectedEventMissingError → TanStack retries.
// Under the pre-fix code (null closure) → settles success without retry.
return singlePage([otherReply]);
}
// Retry fetch — relay has caught up, target available.
return singlePage([targetEvent, otherReply]);
};
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const channel = { id: "chan-settled", channelType: "group" };
const wrapper = ({ children }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
const hook = renderHook(
({ expectedEventId }) =>
useThreadReplies(channel, "root-settled", expectedEventId),
{ wrapper, initialProps: { expectedEventId: null } },
);
unmount = hook.unmount;
const { rerender, result } = hook;
// Wait for the initial null-target fetch to settle.
for (let i = 0; i < 20; i++) {
await act(async () => {
await new Promise((resolve) => setImmediate(resolve));
});
if (!result.current.isPending) break;
}
assert.ok(fetchCount >= 1, "initial fetch must have occurred");
assert.equal(
result.current.isError,
false,
"hook must be in success state after initial null-target settle",
);
// Supply the target — hook must invalidate, refetch with current closure,
// detect missing target, retry, and land target-bearing data.
// The hook uses retryDelay: (attempt) => Math.min(1_000 * 2 ** attempt, 30_000).
// Attempt 0 retries after 1_000ms (real time). Wait up to 3s for the retry.
await act(async () => {
rerender({ expectedEventId: "target-evt-settled" });
});
// Poll for target-bearing success, waiting up to 3 seconds total.
// The retry delay is 1s (attempt 0), so the hook should settle in ~1.1s.
let settled = false;
for (let i = 0; i < 40; i++) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
if (
!result.current.isPending &&
!result.current.isError &&
result.current.data?.some((e) => e.id === targetEvent.id)
) {
settled = true;
break;
}
}
assert.ok(
settled,
`hook must settle with target data after retry within 4s ` +
`(isError=${result.current.isError}, ` +
`data ids: ${result.current.data?.map((e) => e.id).join(",")}, ` +
`fetchCount=${fetchCount})`,
);
// Additional assertions on the settled state.
assert.ok(
!result.current.isError,
`hook must not be in error state (error=${result.current.error})`,
);
assert.ok(
Array.isArray(result.current.data),
`hook must expose reply data (data=${JSON.stringify(result.current.data)})`,
);
assert.ok(
result.current.data?.some((e) => e.id === targetEvent.id),
`data must include the target event after retry ` +
`(got ids: ${result.current.data?.map((e) => e.id).join(",")})`,
);
// At minimum two more fetches must have occurred: the invalidation fetch
// (fetch 2, missing target → throw) and the retry (fetch 3+, target present).
assert.ok(
fetchCount >= 3,
`missing-target retry must have fired (fetchCount=${fetchCount})`,
);
// ── ChannelScreen wiring assertion ────────────────────────────────────────
// Fails if ChannelScreen stops passing threadScrollTargetId as the third
// argument to useThreadReplies.
const channelScreenSource = await readFile(
new URL("../channels/ui/ChannelScreen.tsx", import.meta.url),
"utf8",
);
assert.match(
channelScreenSource,
/useThreadReplies\s*\([^)]*threadScrollTargetId/s,
"ChannelScreen.tsx must pass threadScrollTargetId as the third argument to useThreadReplies",
);
} finally {
// Always dispose — a failure before this point must not leak mounted
// hooks or QueryClient cache timers (gcTime: 1h) that hold the event loop.
try {
unmount?.();
} catch (_) {}
try {
cleanup?.();
} catch (_) {}
try {
queryClient?.clear();
} catch (_) {}
}
});
// ── Test: cold-fetch cancel-then-invalidate behavioral regression ─────────────
//
// Load-bearing real-hook test for the cancel-then-invalidate branch.
//
// Race: thread is cold (no cached data), a fetch is in-flight, then
// expectedEventId arrives from notification routing before the first page
// returns. The effect detects fetchStatus=fetching + status=pending and must
// cancel the in-flight fetch before invalidating, so the obsolete empty-page
// response cannot settle as authoritative before the new target's validation
// closure is active.
//
// Test shape (Thufir's deterministic probe):
// 1. Mount with expectedEventId=null and a gated fetcher (blocks until
// released). Query is cold — fetchStatus=fetching, status=pending.
// 2. Rerender with expectedEventId="target-evt". Effect fires, sees pending
// cold fetch, calls cancelQueries().then(invalidateQueries).
// 3. Release the gated fetcher returning [] (stale empty result). TanStack
// discards the cancelled retryer — [] must not settle as success.
// 4. The replacement fetch (new closure, target active) runs; its fetcher
// returns [targetEvent]. Hook settles success with the target.
//
// Mutation: replacing the cancel+invalidate branch with plain invalidateQueries
// causes the stale [] to settle before the replacement runs → hook is either
// stuck retrying (with retry:3) or resolves without the target → assertion red.
test("useThreadReplies cancels stale in-flight cold fetch when target arrives", async () => {
let queryClient;
let unmount;
let cleanup;
try {
const imported = await import("@testing-library/react");
const act = imported.act;
cleanup = imported.cleanup;
const renderHook = imported.renderHook;
const { createElement } = await import("react");
const { QueryClient, QueryClientProvider } = await import(
"@tanstack/react-query"
);
const { useThreadReplies } = await import("./useThreadReplies.ts");
// Gated fetcher: first call blocks until released; subsequent calls return
// the target event immediately.
const targetEvent = fakeEvent("target-evt");
let releaseFirstFetch;
let fetchCount = 0;
globalThis.__tauriGetThreadReplies = async () => {
fetchCount += 1;
if (fetchCount === 1) {
// First fetch blocks until the test releases it, representing the
// relay being slow — target arrives before it returns.
await new Promise((resolve) => {
releaseFirstFetch = resolve;
});
// Return stale empty: this is what the relay delivers before the
// target event has replicated. Without cancelQueries the hook would
// settle success([]) here.
return singlePage([]);
}
// Replacement fetch: relay has caught up, target is now available.
return singlePage([targetEvent]);
};
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const channel = { id: "chan-cancel", channelType: "group" };
const wrapper = ({ children }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
const hook = renderHook(
({ expectedEventId }) =>
useThreadReplies(channel, "root-cancel", expectedEventId),
{ wrapper, initialProps: { expectedEventId: null } },
);
unmount = hook.unmount;
const { rerender, result } = hook;
// Let the first fetch start — it is now in-flight (fetchStatus=fetching,
// status=pending). Yield the microtask queue without releasing the gate.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Supply the target: the effect must detect the cold-fetch in-flight state
// and call cancelQueries before invalidating.
await act(async () => {
rerender({ expectedEventId: "target-evt" });
});
// Release the stale first fetch AFTER the effect fires. With the fix the
// cancelled retryer discards its result. Without the fix, [] settles first
// and the hook either stays in error (the new closure sees [] and throws
// ThreadExpectedEventMissingError with retry:false) or resolves [] —
// neither is the target-bearing success state we require.
await act(async () => {
releaseFirstFetch?.();
await new Promise((resolve) => setTimeout(resolve, 50));
});
// Hook must settle success with the target event.
assert.ok(
!result.current.isError,
`hook must not be in error state (isError=${result.current.isError}, error=${result.current.error})`,
);
assert.ok(
Array.isArray(result.current.data),
`hook must expose reply data (data=${JSON.stringify(result.current.data)})`,
);
assert.ok(
result.current.data?.some((e) => e.id === targetEvent.id),
`data must include the target event (got ids: ${result.current.data?.map((e) => e.id).join(",")})`,
);
} finally {
try {
unmount?.();
} catch (_) {}
try {
cleanup?.();
} catch (_) {}
try {
queryClient?.clear();
} catch (_) {}
}
});
// ── Test: thread aux closure is not fetched (anti-regression) ─────────────────
test("thread replies trust the relay-provided aux closure", async () => {
const { readFile } = await import("node:fs/promises");
const source = await readFile(
new URL("./useThreadReplies.ts", import.meta.url),
"utf8",

View File

@@ -4,21 +4,49 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import * as React from "react";
import {
threadRepliesKey,
sortMessages,
} from "@/features/messages/lib/messageQueryKeys";
import { getThreadReplies } from "@/shared/api/tauri";
import type { Channel, RelayEvent, ThreadCursor } from "@/shared/api/types";
import type {
Channel,
RelayEvent,
ThreadCursor,
ThreadRepliesResponse,
} from "@/shared/api/types";
const THREAD_PAGE_LIMIT = 200;
const MAX_THREAD_PAGES = 500;
async function loadThreadReplies(
/** Error thrown when a paged fetch completed but a caller-expected event was absent. */
export class ThreadExpectedEventMissingError extends Error {
readonly expectedEventId: string;
constructor(expectedEventId: string) {
super(
`Thread fetch completed but expected event ${expectedEventId} is absent.`,
);
this.name = "ThreadExpectedEventMissingError";
this.expectedEventId = expectedEventId;
}
}
/** Shape of the per-page fetcher — matches `getThreadReplies` exactly. */
export type ThreadRepliesFetcher = (
rootId: string,
channelId: string,
options: { limit: number; cursor: ThreadCursor | null },
) => Promise<ThreadRepliesResponse>;
export async function loadThreadReplies(
queryClient: QueryClient,
channelId: string,
rootId: string,
expectedEventId?: string | null,
exhaustedTargets?: Set<string>,
fetcher: ThreadRepliesFetcher = getThreadReplies,
): Promise<RelayEvent[]> {
const queryKey = threadRepliesKey(channelId, rootId);
const cacheAtStart = queryClient.getQueryData<RelayEvent[]>(queryKey) ?? [];
@@ -26,7 +54,7 @@ async function loadThreadReplies(
const replies: RelayEvent[] = [];
let cursor: ThreadCursor | null = null;
for (let page = 0; page < MAX_THREAD_PAGES; page += 1) {
const response = await getThreadReplies(rootId, channelId, {
const response = await fetcher(rootId, channelId, {
limit: THREAD_PAGE_LIMIT,
cursor,
});
@@ -36,7 +64,25 @@ async function loadThreadReplies(
const receivedInFlight = current.filter(
(event) => !idsAtStart.has(event.id),
);
return sortMessages([...replies, ...receivedInFlight]);
const result = sortMessages([...replies, ...receivedInFlight]);
// When the caller expects a specific reply event (e.g. opened from a
// notification) and the completed fetch does not contain it, the relay
// likely delivered an empty result before the event was replicated. Throw
// so React Query's retry-with-backoff re-attempts instead of caching an
// authoritative empty — heals automatically once the relay catches up.
//
// Once the target has been declared permanently absent (exhaustedTargets
// contains it), stop throwing: the successfully fetched replies are
// rendered and the unreachable target is quietly retired rather than
// painting the whole thread as a terminal load error.
if (
expectedEventId &&
!result.some((e) => e.id === expectedEventId) &&
!exhaustedTargets?.has(expectedEventId)
) {
throw new ThreadExpectedEventMissingError(expectedEventId);
}
return result;
}
cursor = response.nextCursor;
}
@@ -47,12 +93,38 @@ async function loadThreadReplies(
export function useThreadReplies(
activeChannel: Channel | null,
openThreadRootId: string | null,
expectedEventId?: string | null,
) {
const channelId = activeChannel?.id ?? "none";
const rootId = openThreadRootId ?? "none";
const queryClient = useQueryClient();
const queryKey = threadRepliesKey(channelId, rootId);
return useQuery({
// Tracks expected-event IDs that have permanently failed validation so that
// a deleted/moderated target doesn't lock the thread in a terminal error
// state. On the terminal attempt the query-fn writes the target here; the
// next call to loadThreadReplies sees it and returns the fetched replies
// directly instead of throwing.
const exhaustedTargetsRef = React.useRef(new Set<string>());
// Counts consecutive fetch attempts for the current expectedEventId.
// When the count reaches the exhaustion threshold the query-fn declares
// the target permanently absent before calling loadThreadReplies so the
// terminal attempt resolves to data rather than throwing.
const attemptCountRef = React.useRef<{ id: string; count: number } | null>(
null,
);
// useQuery is declared before the target-change invalidation effect so that
// TanStack's internal options-update effect (useBaseQuery.ts, registered
// inside useQuery) runs first on each render cycle. React runs effects in
// declaration order; placing our invalidation effect after useQuery ensures
// the observer has installed the new queryFn closure — capturing the current
// expectedEventId — before invalidateQueries triggers query.fetch(). Without
// this ordering, a settled null→target transition refetches with the previous
// (null) queryFn, loadThreadReplies validates against null, and the missing
// reply stays absent with no Retry error surface.
const queryResult = useQuery({
queryKey,
enabled:
activeChannel !== null &&
@@ -60,11 +132,82 @@ export function useThreadReplies(
openThreadRootId !== null,
queryFn: async (): Promise<RelayEvent[]> => {
if (!activeChannel || !openThreadRootId) return [];
return loadThreadReplies(queryClient, activeChannel.id, openThreadRootId);
if (expectedEventId) {
// Reset the counter when the target changes.
if (attemptCountRef.current?.id !== expectedEventId) {
attemptCountRef.current = { id: expectedEventId, count: 0 };
}
attemptCountRef.current.count += 1;
// On the terminal attempt, declare the target permanently absent
// before fetching so loadThreadReplies returns the fetched replies
// directly instead of throwing. This ensures the query resolves to
// success rather than landing in terminal error state — no re-entrant
// scheduling is needed because the resolution happens synchronously
// inside the query function itself.
if (attemptCountRef.current.count >= 3) {
exhaustedTargetsRef.current.add(expectedEventId);
}
}
return loadThreadReplies(
queryClient,
activeChannel.id,
openThreadRootId,
expectedEventId,
exhaustedTargetsRef.current,
);
},
staleTime: 0,
gcTime: 60 * 60 * 1_000,
// Override global defaults: a missing reply should retry rather than
// being permanently hidden. The ThreadExpectedEventMissingError path
// throws on attempts 1 and 2; attempt 3 records exhaustion first so
// loadThreadReplies returns data directly — the terminal attempt always
// resolves to success.
retry: 3,
retryDelay: (attempt) => Math.min(1_000 * 2 ** attempt, 30_000),
});
// Notification routing can change expectedEventId while the same thread root
// is already mounted and the query is settled. Because the query key
// (channelId + rootId) stays the same, the query fn closure does not re-run
// automatically. Invalidate explicitly so the new target gets a fresh fetch
// and validation pass.
//
// This effect is declared AFTER useQuery so that TanStack's options-update
// effect (which installs the new queryFn closure) always runs first within
// the same render cycle. The invalidation therefore reaches query.fetch()
// with the current expectedEventId already captured — not the previous one.
//
// If the query is still in-flight with no data yet (cold start race: target
// arrives before the first page returns), cancel the in-flight fetch first.
// Without the cancel the obsolete empty-page response can settle before the
// new target's validation closure is active, producing a false authoritative
// [] result.
const prevExpectedEventIdRef = React.useRef(expectedEventId);
React.useEffect(() => {
const prev = prevExpectedEventIdRef.current;
prevExpectedEventIdRef.current = expectedEventId;
if (
expectedEventId !== null &&
expectedEventId !== undefined &&
expectedEventId !== prev &&
!exhaustedTargetsRef.current.has(expectedEventId)
) {
const state = queryClient.getQueryState(queryKey);
if (state?.fetchStatus === "fetching" && state.status === "pending") {
// Cold fetch in-flight: cancel and re-fetch atomically so the stale
// empty-page response cannot settle as authoritative data before the
// new target's validation closure is active.
void queryClient.cancelQueries({ queryKey }).then(() => {
void queryClient.invalidateQueries({ queryKey });
});
} else {
void queryClient.invalidateQueries({ queryKey });
}
}
}, [expectedEventId, queryClient, queryKey]);
return queryResult;
}
/**

View File

@@ -387,6 +387,19 @@ export function useUsersBatchQuery(
),
staleTime: 10 * 60_000,
gcTime: 5 * 60 * 1_000,
// Override the global defaults: a cold channel needs profiles to render
// correctly, so a single failed attempt must not leave raw npubs/broken
// mention chips until the user manually kicks the channel. Three attempts
// with exponential backoff cover the common transient-relay case.
//
// After the retry budget exhausts, a window-focus event recovers the
// query — but only when it is already in an error state. Gating on
// query.state.status === "error" prevents unnecessary refetches for
// successful batches on every focus event, which broke the profile-hover
// E2E smoke test when unconditional focus-refetch was set.
retry: 3,
retryDelay: (attempt) => Math.min(1_000 * 2 ** attempt, 30_000),
refetchOnWindowFocus: (query) => query.state.status === "error",
});
// Seed individual "user-profile" cache entries so avatar clicks are instant

View File

@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("useUsersBatchQuery: retry/backoff resilience overrides are present", async () => {
const source = await readFile(new URL("./hooks.ts", import.meta.url), "utf8");
// Scoped retry override (not the global default of 1) must be present in
// the useUsersBatchQuery useQuery call. A cold channel fetch that exhausts
// retry: 1 would leave raw npubs permanently; retry: 3 gives 3 attempts
// before the caller needs to kick the channel.
assert.match(
source,
/retry:\s*3/,
"useUsersBatchQuery must override retry to 3",
);
// Exponential backoff capped at 30s must be present.
assert.match(
source,
/retryDelay/,
"useUsersBatchQuery must specify retryDelay",
);
// refetchOnWindowFocus must be set to the error-only predicate: unconditional
// focus-refetch fires on every Playwright focus event and breaks the
// profile-hover E2E smoke test; the error-gated form restores post-exhaustion
// auto-heal without that side-effect.
assert.match(
source,
/refetchOnWindowFocus:\s*\(query\)\s*=>/,
"useUsersBatchQuery must use error-gated refetchOnWindowFocus, not unconditional true or omit it",
);
assert.doesNotMatch(
source,
/refetchOnWindowFocus:\s*true/,
"useUsersBatchQuery must not override refetchOnWindowFocus to unconditional true (breaks E2E hover tests)",
);
});
test("useUsersBatchQuery: global queryClient defaults are unchanged", async () => {
const source = await readFile(
new URL("../../shared/api/queryClient.ts", import.meta.url),
"utf8",
);
// Global retry must remain 1 — other queries chose that semantics.
assert.match(source, /retry:\s*1/, "global retry default must remain 1");
// Global refetchOnWindowFocus must remain false.
assert.match(
source,
/refetchOnWindowFocus:\s*false/,
"global refetchOnWindowFocus must remain false",
);
});

View File

@@ -799,7 +799,6 @@ export class RelayClient {
this.handleEose(rest[0], generation);
return;
}
if (type === "CLOSED" && typeof rest[0] === "string") {
handleRelayClosed({
subscriptions: this.subscriptions,
@@ -810,6 +809,7 @@ export class RelayClient {
["REQ", subId, filter],
"Failed to restore relay subscription after CLOSED.",
),
closeSubscription: (subId) => this.closeSubscription(subId),
});
return;
}

View File

@@ -49,10 +49,13 @@ export type RelaySubscriptionFilter = {
type HistorySubscription = {
mode: "history";
filter: RelaySubscriptionFilter;
events: RelayEvent[];
resolve: (events: RelayEvent[]) => void;
reject: (error: Error) => void;
timeout: number;
timeoutMs: number;
closedRetryAttempt?: number;
};
type FirstEventSubscription = {

View File

@@ -60,12 +60,10 @@ function resetAll(startMs = 0) {
resetRateLimitGate();
}
test("production CLOSED handler rejects history once and clears its timeout", () => {
test("production CLOSED handler rejects non-rate-limited history immediately and clears its timeout", () => {
const originalWindow = globalThis.window;
const clearedTimeouts = [];
globalThis.window = {
// Provide both setTimeout (needed by activateRateLimit in the F1 fix) and
// clearTimeout (the existing assertion target).
setTimeout: (_fn, _ms) => 0,
clearTimeout: (timeout) => clearedTimeouts.push(timeout),
};
@@ -76,10 +74,12 @@ test("production CLOSED handler rejects history once and clears its timeout", ()
"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,
},
],
]);
@@ -88,23 +88,201 @@ test("production CLOSED handler rejects history once and clears its timeout", ()
subId: "history-1",
sendReq: () => Promise.resolve(),
};
handleRelayClosed({
...input,
message: "rate-limited: too many concurrent requests",
});
// 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,
"rate-limited: too many concurrent requests",
);
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([
@@ -112,10 +290,12 @@ test("rate-limited history CLOSED arms the shared gate for concurrent ops", () =
"history-1",
{
mode: "history",
filter: { kinds: [0], authors: ["pubkey-1"], limit: 10 },
events: [],
resolve: () => {},
reject: () => {},
timeout: 0,
timeoutMs: 25_000,
},
],
]);
@@ -142,10 +322,12 @@ test("non-rate-limited history CLOSED does not arm the gate", () => {
"history-2",
{
mode: "history",
filter: { kinds: [9], "#h": ["ch-1"], limit: 50 },
events: [],
resolve: () => {},
reject: () => {},
timeout: 0,
timeoutMs: 25_000,
},
],
]);
@@ -173,10 +355,12 @@ test("gate armed by rate-limited history CLOSED defers the next REQ until expiry
"history-gate",
{
mode: "history",
filter: { kinds: [9], "#h": ["ch-gate"], limit: 50 },
events: [],
resolve: () => {},
reject: () => {},
timeout: 0,
timeoutMs: 25_000,
},
],
]);
@@ -486,6 +670,89 @@ test("terminal CLOSED deletes subscription and does not retry", () => {
assert.equal(firedAt.length, 0, "terminal CLOSED must not retry");
});
// ── 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", () => {

View File

@@ -28,23 +28,72 @@ export function handleRelayClosed({
subId,
message,
sendReq,
closeSubscription,
}: {
subscriptions: Map<string, RelaySubscription>;
subId: string;
message: string;
sendReq: (subId: string, filter: RelaySubscriptionFilter) => Promise<void>;
closeSubscription?: (subId: string) => Promise<void>;
}) {
const subscription = subscriptions.get(subId);
if (!subscription) return;
if (subscription.mode !== "live") {
// Classify before rejecting so a `rate-limited:` history CLOSED arms the
// gate for concurrent ops. A history sub can't be retried (the caller holds
// the promise), so we still reject immediately after arming.
// Classify before acting so a `rate-limited:` CLOSED arms the gate for
// concurrent ops regardless of whether this specific sub can be retried.
const closedClass = classifyRelayClosed(message);
if (closedClass === "rate-limited") {
const hintSeconds = parseRateLimitHint(message);
activateRateLimit(hintSeconds);
// History subs hold a promise the caller is awaiting. Rather than
// rejecting immediately, defer and re-issue the REQ after the
// rate-limit window so the caller transparently receives their result.
// Bounded to 3 attempts — if the relay keeps refusing, fall through
// to the permanent reject so the caller's promise resolves with an error
// rather than waiting forever.
if (subscription.mode === "history") {
const attempt = subscription.closedRetryAttempt ?? 0;
if (attempt < 3) {
subscription.closedRetryAttempt = attempt + 1;
// Clear the existing op-timeout so it doesn't fire while waiting for
// the rate-limit window. The setTimeout delay covers this interval.
window.clearTimeout(subscription.timeout);
const hintMs = (hintSeconds ?? 10) * 1_000;
const delayMs = Math.max(rateLimitRemainingMs() || hintMs, hintMs);
// Re-register under a new id so the old subId can be evicted cleanly.
// Accumulated events are preserved on the subscription object.
const newSubId = `history-${crypto.randomUUID()}`;
subscriptions.delete(subId);
subscriptions.set(newSubId, subscription);
// Use the rate-limit delay as the new timeout budget so the
// subscription is not left open indefinitely while waiting.
subscription.timeout = window.setTimeout(() => {
if (!subscriptions.has(newSubId)) return; // cancelled while waiting
void sendReq(newSubId, subscription.filter).catch(() => {
subscriptions.delete(newSubId);
subscription.reject(
new Error(message || "Relay closed the history subscription."),
);
});
// Set a new op-timeout for the retry REQ so the subscription
// cannot hang indefinitely if the relay stops responding.
subscription.timeout = window.setTimeout(() => {
subscriptions.delete(newSubId);
// History promise is already being rejected below; swallow any
// IPC/socket error from the CLOSE send so it does not surface as
// an unhandled rejection on a path that has no live error handler.
closeSubscription?.(newSubId)?.catch(() => {});
subscription.reject(
new Error("Relay closed the history subscription."),
);
}, subscription.timeoutMs);
}, delayMs);
return;
}
}
}
window.clearTimeout(subscription.timeout);
subscriptions.delete(subId);
subscription.reject(

View File

@@ -41,10 +41,12 @@ export async function requestHistoryGated(
subscriptions.set(subId, {
mode: "history",
filter,
events: [],
resolve,
reject,
timeout,
timeoutMs: historyTimeoutMs,
});
void sendRaw(["REQ", subId, filter]).catch((error) => {