diff --git a/js/channelProfile.js b/js/channelProfile.js index b6e59fe4..26f47a4c 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -76,6 +76,36 @@ import { RELAY_BACKGROUND_CONCURRENCY } from "./nostr/relayConstants.js"; const getApp = () => getApplication(); +// A single hung relay (one that never sends EOSE) used to stall the whole +// channel-grid fetch because `pool.list` has no built-in timeout. Bound each +// relay's list() so a slow relay resolves to "no events" instead of holding up +// the merged render — the other relays + the shared cache still fill the grid. +export const CHANNEL_RELAY_LIST_TIMEOUT_MS = 8000; + +// Pure, testable core: race a list() call (via a factory) against a timeout. +// Resolves to the list's array on success, or [] on timeout OR rejection — a +// failing/hung relay contributes nothing rather than stalling or throwing (and +// a late rejection after the timeout wins can't surface as unhandled). +export function raceListWithTimeout(listFn, timeoutMs = CHANNEL_RELAY_LIST_TIMEOUT_MS) { + let timer = null; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve([]), timeoutMs); + }); + const listPromise = Promise.resolve() + .then(() => listFn()) + .then((result) => (Array.isArray(result) ? result : [])) + .catch(() => []); + return Promise.race([listPromise, timeout]).finally(() => { + if (timer) { + clearTimeout(timer); + } + }); +} + +function listRelayWithTimeout(relays, filters, timeoutMs = CHANNEL_RELAY_LIST_TIMEOUT_MS) { + return raceListWithTimeout(() => nostrClient.pool.list(relays, filters), timeoutMs); +} + const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; const CHANNEL_MODERATION_BADGE_ID = "channel-moderation-badge"; @@ -1310,6 +1340,17 @@ let zapPopoverOpenPromise = null; let channelMenuPopover = null; let channelMenuOpen = false; let currentVideoLoadToken = 0; +// Coalescing state for channel-grid loads. loadUserVideos() bumps +// currentVideoLoadToken and only renders if its token is still current, so a +// burst of refresh triggers (e.g. the NIP-71 ingest flushing repeatedly right +// after open) can supersede every in-flight load before it paints — the grid +// gets stuck on "Loading videos…". requestChannelVideoLoad() collapses those: +// while a load for the same channel is in flight, a refresh flags a single +// trailing rerun instead of starting a competing load. Navigation to a channel +// (isRefresh:false) still starts immediately (and supersedes a stale channel). +let channelVideoLoadInFlight = null; +let channelVideoLoadPubkey = null; +let channelVideoRerunPending = false; let currentProfileLoadToken = 0; const FALLBACK_CHANNEL_BANNER = "assets/jpg/bitvid.jpg"; @@ -3821,7 +3862,7 @@ export async function initChannelProfileView() { syncChannelShareButtonState(); }); - const videosPromise = loadUserVideos(hexPub).catch((error) => { + const videosPromise = requestChannelVideoLoad(hexPub).catch((error) => { userLogger.error("Failed to load channel videos:", error); }); @@ -4448,7 +4489,26 @@ function getCachedChannelVideoEvents(pubkey) { } ); - return [...rawMatches, ...processedMatches]; + // Parity with the other grids: also pull the author's videos from the shared + // feed cache (nostrService videosMap → author index) — the SAME source the + // main/subscriptions grids render from. The channel grid otherwise sources + // only nostrClient.allEvents + a scoped relay fetch, which can DIVERGE from + // that cache, so a video shown on another grid could be missing here. Appended + // last so it only fills gaps (richer raw/processed copies win the id-dedupe). + let parityMatches = []; + try { + const service = getApp()?.nostrService; + if (service && typeof service.getActiveVideosByAuthors === "function") { + const parity = service.getActiveVideosByAuthors([normalized]); + if (Array.isArray(parity)) { + parityMatches = parity; + } + } + } catch (error) { + // Best-effort; the allEvents scan + scoped relay fetch still populate the grid. + } + + return [...rawMatches, ...processedMatches, ...parityMatches]; } function normalizeRenderableChannelVideo(entry) { @@ -5315,7 +5375,7 @@ export function refreshActiveChannelVideoGrid({ reason } = {}) { } try { - const result = loadUserVideos(currentChannelHex); + const result = requestChannelVideoLoad(currentChannelHex, { isRefresh: true }); return result instanceof Promise ? result : Promise.resolve(result); } catch (error) { userLogger.warn( @@ -5328,6 +5388,49 @@ export function refreshActiveChannelVideoGrid({ reason } = {}) { } } +// Entry point for channel-grid loads. Coalesces refresh-driven loads so a +// storm of refreshes can't perpetually cancel each other before rendering. +// isRefresh:true → if a load for the same channel is in flight, flag one +// trailing rerun and reuse the in-flight promise. +// isRefresh:false → navigation; always start (supersedes a stale channel). +function requestChannelVideoLoad(pubkey, { isRefresh = false } = {}) { + const normalized = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + if ( + isRefresh && + channelVideoLoadInFlight && + channelVideoLoadPubkey === normalized + ) { + channelVideoRerunPending = true; + return channelVideoLoadInFlight; + } + return startChannelVideoLoad(pubkey); +} + +function startChannelVideoLoad(pubkey) { + const normalized = + typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + channelVideoLoadPubkey = normalized; + const promise = Promise.resolve(loadUserVideos(pubkey)).finally(() => { + if (channelVideoLoadInFlight === promise) { + channelVideoLoadInFlight = null; + } + // Fire at most one trailing reload, and only if we're still on this + // channel (navigation elsewhere will have started its own load). + if (channelVideoRerunPending) { + channelVideoRerunPending = false; + const stillHere = + typeof currentChannelHex === "string" && + currentChannelHex.trim().toLowerCase() === normalized; + if (stillHere) { + startChannelVideoLoad(currentChannelHex); + } + } + }); + channelVideoLoadInFlight = promise; + return promise; +} + async function loadUserVideos(pubkey) { const app = getApp(); const container = document.getElementById("channelVideoList"); @@ -5398,7 +5501,7 @@ async function loadUserVideos(pubkey) { if (relayList.length === 0) { try { - const fallbackEvents = await nostrClient.pool.list( + const fallbackEvents = await listRelayWithTimeout( Array.from(DEFAULT_RELAY_URLS), filters ); @@ -5411,7 +5514,7 @@ async function loadUserVideos(pubkey) { } else { const settled = await pMapSettled( relayList, - (url) => nostrClient.pool.list([url], filters), + (url) => listRelayWithTimeout([url], filters), { concurrency: RELAY_BACKGROUND_CONCURRENCY } ); settled.forEach((result, index) => { @@ -5493,7 +5596,7 @@ if (typeof window !== "undefined" && typeof window.addEventListener === "functio return; } - loadUserVideos(currentChannelHex).catch((error) => { + requestChannelVideoLoad(currentChannelHex, { isRefresh: true }).catch((error) => { userLogger.error( "Failed to refresh channel videos after admin update:", error diff --git a/tests/channel-relay-timeout.test.mjs b/tests/channel-relay-timeout.test.mjs new file mode 100644 index 00000000..2892cd3e --- /dev/null +++ b/tests/channel-relay-timeout.test.mjs @@ -0,0 +1,65 @@ +// 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)); +});