mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[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]>
This commit is contained in:
1 parent
61e4b25de7
commit
a4549f967f
2 files changed
+174
-6
No files matched your search
+109
-6
@@ -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
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
Reference in new issue
Block a user