From 5b12f51e9897c7a85c1a799a01cbf1b6b54d5ef8 Mon Sep 17 00:00:00 2001 From: quanticsoul4772 <71096528+quanticsoul4772@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:28:56 -0700 Subject: [PATCH] fix(cctv): warn once per failed background refresh; test the single flight Attach the background-refresh warning where the refresh starts, so a failed refresh logs once instead of once per request served meanwhile. The test now checks that requests during a refresh share it, and turns the hang off before releasing the fetches so a retrying loader can't stall it. Co-Authored-By: Claude Opus 5.5 --- server/providers/cctv/catalog.js | 21 +++++++++++---------- src/data/cctvCatalogCap.test.mjs | 11 +++++++++++ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/server/providers/cctv/catalog.js b/server/providers/cctv/catalog.js index 396b62f8b..840ca9ce1 100644 --- a/server/providers/cctv/catalog.js +++ b/server/providers/cctv/catalog.js @@ -224,21 +224,22 @@ export function createCctvCatalog({ sourceRoot = process.cwd() } = {}) { _cctvSourceInflight = refreshCctvSources().finally(() => { _cctvSourceInflight = null; }); + // A background refresh has no caller waiting on it: report its failure + // here, once per refresh rather than once per request it serves. + if (_cctvSourceCache.length) { + _cctvSourceInflight.catch((error) => { + console.warn( + '[CCTV] background catalog refresh failed:', + error?.message || error, + ); + }); + } } // With a catalog in hand, answer with it while the refresh runs: the // refresh lasts as long as the slowest pack (Tarktee's DATEX feeds take up // to its 30 s timeout), and every /api/cctv request would otherwise wait // for it. Only the first load, with nothing to serve yet, waits. - if (_cctvSourceCache.length) { - _cctvSourceInflight.catch((error) => { - console.warn( - '[CCTV] background catalog refresh failed:', - error?.message || error, - ); - }); - return _cctvSourceCache; - } - return _cctvSourceInflight; + return _cctvSourceCache.length ? _cctvSourceCache : _cctvSourceInflight; } /** diff --git a/src/data/cctvCatalogCap.test.mjs b/src/data/cctvCatalogCap.test.mjs index 46faa43de..26775cb48 100644 --- a/src/data/cctvCatalogCap.test.mjs +++ b/src/data/cctvCatalogCap.test.mjs @@ -217,7 +217,18 @@ test('an expired catalog is served at once while a slow pack refreshes in the ba ['old-0', 'old-1', 'old-2'], ); + // Requests during the refresh share it rather than starting another. + const pause = () => new Promise((resolve) => setTimeout(resolve, 50)); + await pause(); + const inFlight = hung.length; + assert.ok(inFlight > 0, 'the refresh reached the hanging upstreams'); + await getSources(); + await getSources(); + await pause(); + assert.equal(hung.length, inFlight, 'only one refresh ran'); + // When the slow pack gives up, the refreshed catalog replaces the old one. + hang = false; for (const giveUp of hung) giveUp(); for (let i = 0; i < 50; i += 1) { const ids = (await getSources()).map((s) => s.id);