mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
54
desktop/src/features/profile/profileBatchResilience.test.mjs
Normal file
54
desktop/src/features/profile/profileBatchResilience.test.mjs
Normal 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",
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user