Files
bitvid/tests/channel-relay-timeout.test.mjs
thePR0M3TH3ANandClaude Opus 4.8 a4549f967f [channel] Fix slow/incomplete channel video grid: coalesce loads, bound relays, parity source
Three fixes for "the channel grid loads slowly and not all videos come in even
when they show on other grids":

1. Race condition (primary "won't load" cause). loadUserVideos() bumps a shared
   currentVideoLoadToken and only renders if its token is still current. A burst
   of refresh triggers — e.g. the NIP-71 ingest flushing repeatedly right after
   a channel opens (RELAY STORM in the logs) — supersedes every in-flight load
   before it paints, so the grid stays on "Loading videos…". Add
   requestChannelVideoLoad(): while a load for the same channel is in flight, a
   refresh flags a single trailing rerun instead of starting a competing load;
   navigation still starts immediately. All three call sites routed through it.

2. Slowness from hung relays. The per-relay pool.list() fan-out had no timeout,
   so one relay that never sends EOSE stalled the merged render. Add
   raceListWithTimeout() (exported, pure) bounding each list() to 8s, resolving
   to [] on timeout/rejection.

3. Completeness/parity. The channel grid sourced only nostrClient.allEvents + a
   scoped relay fetch, which can diverge from the shared feed cache the other
   grids render from. getCachedChannelVideoEvents() now also unions the author's
   videos from nostrService.getActiveVideosByAuthors() (same videosMap-backed
   author index), so a video shown on another grid is present here by construction.

Tests: tests/channel-relay-timeout.test.mjs (5).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-01 08:47:54 -04:00

66 lines
2.8 KiB
JavaScript

// Channel-grid relay fetch is bounded (raceListWithTimeout): a hung relay (one
// that never sends EOSE) must resolve to "no events" instead of stalling the
// whole grid render. A failing relay likewise contributes [] rather than
// throwing, and a late rejection after the timeout can't surface as unhandled.
//
// test_integrity_note:
// change_type: ["new_tests"]
// scenarios:
// - id: SCN-channel-relay-timeout
// given: "a relay list() that resolves / hangs / rejects / returns non-array"
// when: "raceListWithTimeout wraps it with a short timeout"
// then: "success returns the array; hang/reject/garbage resolve to []"
// observable_outcomes:
// - "a fast success passes the array through unchanged"
// - "a hang resolves to [] within the timeout bound (does not stall)"
// - "a rejection resolves to [] (does not throw)"
// determinism_controls:
// - "explicit short timeoutMs; hang modeled as a never-resolving promise"
// anti_cheat_rationale:
// prevents: ["retry/sleep-based flake masking", "hard-coded return value"]
// relaxation:
// did_relax_any_assertion: false
import "./test-helpers/setup-localstorage.mjs";
import test from "node:test";
import { strict as assert } from "node:assert";
import { raceListWithTimeout } from "../js/channelProfile.js";
test("passes a fast successful result through unchanged", async () => {
const events = [{ id: "a" }, { id: "b" }];
const result = await raceListWithTimeout(async () => events, 1000);
assert.deepEqual(result, events);
});
test("a hung relay resolves to [] within the timeout instead of stalling", async () => {
const started = Date.now();
// Never resolves — models a relay that never sends EOSE.
const result = await raceListWithTimeout(() => new Promise(() => {}), 30);
assert.deepEqual(result, []);
assert.ok(Date.now() - started < 500, "must resolve promptly via the timeout");
});
test("a rejecting relay resolves to [] rather than throwing", async () => {
const result = await raceListWithTimeout(async () => {
throw new Error("relay exploded");
}, 1000);
assert.deepEqual(result, []);
});
test("a non-array result is normalized to []", async () => {
const result = await raceListWithTimeout(async () => null, 1000);
assert.deepEqual(result, []);
});
test("a late rejection after the timeout wins does not throw (no unhandled)", async () => {
// The list rejects AFTER the timeout has already resolved to []. The helper
// must swallow it so it can't surface as an unhandled rejection.
const result = await raceListWithTimeout(
() => new Promise((_, reject) => setTimeout(() => reject(new Error("late")), 40)),
10,
);
assert.deepEqual(result, []);
// Give the late rejection a tick to (not) blow up.
await new Promise((resolve) => setTimeout(resolve, 60));
});