From 97630a598a544c647efa49dcbae8ca333f449c20 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 23:15:43 +0100 Subject: [PATCH] fix(cctv): release Tarktee sibling on fetch rejection --- docs/CURRENT-STATE.md | 5 ++++ server/providers/cctv/sources.js | 10 +++++++- src/data/cctvEstonia.test.mjs | 40 ++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 60937dd05..4b68e7030 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4457,6 +4457,11 @@ silently demoting every later lookup for the session. empty keeps the old catalog and is retried after another 15 minutes, not on the next request. Estonia's Tarktee DATEX feeds, which answer in 2 to 16.6 s, get 30 s (`TARKTEE_FETCH_TIMEOUT_MS`) where other packs get 15 s. + Both Tarktee XML catalogs refuse redirects and have a 4 MiB per-document + limit, enforced against declared length and streamed bytes. A rejected fetch, + unsuccessful response, or body-read failure releases both available response + bodies; read failures also cancel an active sibling reader. Cameras sharing + an image number are deduplicated before the Tarktee pack cap is applied. - **CCTV v3 UX — viewshed + calibration gizmo** (built 2026-07-05 and field validated 2026-07-21): the COVERAGE toggle is a tri-state cycle `OFF → ON → VIEWSHED`; viewshed mode renders each visible camera's frustum diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 7ccbf3aad..1e13df302 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -1200,7 +1200,7 @@ export function parseTarkteeDatexImages(xml) { */ export async function loadTarkteeSourcesFromDatex() { try { - const [locResp, imgResp] = await Promise.all([ + const responses = await Promise.allSettled([ fetch(TARKTEE_LOCATIONS_URL, { headers: { Accept: 'application/xml,text/xml,*/*' }, redirect: 'manual', @@ -1212,6 +1212,9 @@ export async function loadTarkteeSourcesFromDatex() { signal: AbortSignal.timeout(TARKTEE_FETCH_TIMEOUT_MS), }), ]); + const [locResp, imgResp] = responses.map((result) => + result.status === 'fulfilled' ? result.value : undefined, + ); // Both responses are fetched in parallel; if either is rejected or overflows // its byte cap, both bodies must be released so neither transport leaks. const discardBoth = async () => { @@ -1221,6 +1224,11 @@ export async function loadTarkteeSourcesFromDatex() { ]); return []; }; + const rejected = responses.find((result) => result.status === 'rejected'); + if (rejected) { + await discardBoth(); + throw rejected.reason; + } if ( (locResp.status >= 300 && locResp.status < 400) || (imgResp.status >= 300 && imgResp.status < 400) diff --git a/src/data/cctvEstonia.test.mjs b/src/data/cctvEstonia.test.mjs index 4c2a0f686..670432315 100644 --- a/src/data/cctvEstonia.test.mjs +++ b/src/data/cctvEstonia.test.mjs @@ -168,6 +168,46 @@ test('Tarktee loader refuses a redirect and cancels both response bodies', async assert.equal(cancelled.images, true, 'sibling 200 body is also cancelled'); }); +test('Tarktee loader cancels fulfilled bodies when either fetch rejects', async (t) => { + for (const failedUrl of [TARKTEE_LOCATIONS_URL, TARKTEE_IMAGES_URL]) { + for (const delayedSibling of [false, true]) { + await t.test( + `${failedUrl}, delayed sibling: ${delayedSibling}`, + async (t) => { + quiet(t); + let cancelled = false; + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('')); + }, + cancel() { + cancelled = true; + }, + }), + ); + t.mock.method(globalThis, 'fetch', async (url) => { + if (String(url) === failedUrl) throw new TypeError('fetch failed'); + if (delayedSibling) await new Promise(setImmediate); + return response; + }); + + try { + assert.deepEqual(await loadTarkteeSourcesFromDatex(), []); + assert.equal( + cancelled, + true, + 'fulfilled sibling body is cancelled', + ); + } finally { + await response.body.cancel(); + } + }, + ); + } + } +}); + test('Tarktee loader cancels the sibling body when one feed fails or overflows the byte cap', async (t) => { quiet(t); let siblingCancelled = false;