From 97630a598a544c647efa49dcbae8ca333f449c20 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 23:15:43 +0100 Subject: [PATCH 1/2] 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; From f0e0ea42ce6025d18af9e8eb195f93444d368d12 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Mon, 19 Oct 2026 23:31:52 +0100 Subject: [PATCH 2/2] test(cctv): verify DEM placement in the keyless globe gate --- scripts/qa-cctv-v2.mjs | 101 +++++++++++++++++++++++++++++++++-------- 1 file changed, 83 insertions(+), 18 deletions(-) diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs index 2c121e0d6..88e0a5a2c 100644 --- a/scripts/qa-cctv-v2.mjs +++ b/scripts/qa-cctv-v2.mjs @@ -11,8 +11,10 @@ * exactly once per camera ACTIVATION (the §9.1 obstruction probe) and * never again in steady state; shared mesh-floor sampling fires at most * once per coarse cell during the staggered geometry-load drain, and - * actually COMPLETES once tiles load (>=1 real sample — the - * ceiling alone would pass trivially at Δ=0 if the snap never finished), + * actually COMPLETES once tiles load in photoreal mode (>=1 real sample + * — the ceiling alone would pass trivially at Δ=0 if the snap never + * finished). Globe mode instead proves the rendered mount uses a real + * terrain height with zero mesh samples. Sampling * stays flat while idle, and stays flat for non-positional pose edits. * 2. Geometry contract: the 5-polyline frustum wireframe + far-cap plane * exist for the active camera, the plane's corners coincide with the @@ -658,24 +660,87 @@ async function main() { // (existence, not just ceiling). The wait below tolerates the update-tick // latency of the completion pass (update interval is 10s). // ----------------------------------------------------------------------- - console.log('Waiting for tiles + the one-shot completion pass...'); - const tilesSeenLoaded = await waitForTilesLoaded(page, 45000); - let sampleFloorOk = false; - if (tilesSeenLoaded) { - sampleFloorOk = await waitForPredicate( - page, - (base) => window.__qaCounters.sampleHeight - base >= 1, - { timeoutMs: 30000, args: [c0.sampleHeight] }, + const globeMode = await page.evaluate( + () => window.__godsEyeView.viewer.scene.globe?.show !== false, + ); + if (globeMode) { + // Globe stacks intentionally perform no scene.sampleHeight query. + // Require independently fetched DEM heights and the actual rendered + // mount, so a catalog/geoid fallback cannot turn zero samples into a pass. + console.log( + 'Checking terrain-globe completion against live DEM heights...', + ); + const terrain = await page.evaluate(async () => { + const camera = window.__godsEyeView.dataManager.layers + .get('cctv') + .module.getUIState().activeCamera; + const exact = `${camera.lon.toFixed(5)},${camera.lat.toFixed(5)}`; + const cell = `${camera.lon.toFixed(3)},${camera.lat.toFixed(3)}`; + const response = await fetch( + `/api/terrain/heights?points=${encodeURIComponent(`${exact};${cell}`)}`, + { signal: AbortSignal.timeout(30000) }, + ); + const body = await response.json(); + return { ok: response.ok, results: body.results }; + }); + const heights = terrain.results?.map((result) => result?.ellipsoid); + const terrainReady = + terrain.ok && heights?.length === 2 && heights.every(Number.isFinite); + const mountResolved = + terrainReady && + (await waitForPredicate( + page, + (expected, base) => { + const { viewer, dataManager } = window.__godsEyeView; + const camera = dataManager.layers + .get('cctv') + .module.getUIState().activeCamera; + const ray = viewer.entities.getById(`cctv-${camera.id}-ray-tl`); + const mount = ray?.polyline?.positions.getValue( + viewer.clock.currentTime, + )?.[0]; + const height = + mount && + viewer.scene.globe.ellipsoid.cartesianToCartographic(mount) + .height; + return ( + Number.isFinite(camera.groundPriorM) && + Math.abs(camera.groundPriorM - expected[0]) < 0.01 && + Number.isFinite(height) && + expected.some( + (ground) => + Math.abs(height - ground - camera.mountHeightM) < 0.05, + ) && + window.__qaCounters.sampleHeight === base + ); + }, + { timeoutMs: 30000, args: [heights, c0.sampleHeight] }, + )); + record( + 'terrain-globe completion uses a real DEM height at the rendered mount without mesh samples', + Boolean(mountResolved), + `terrain=${JSON.stringify(heights)}, mesh sample Δ=${(await readCounters()).sampleHeight - c0.sampleHeight}`, + ); + } else { + console.log('Waiting for tiles + the one-shot completion pass...'); + const tilesSeenLoaded = await waitForTilesLoaded(page, 45000); + let sampleFloorOk = false; + if (tilesSeenLoaded) { + sampleFloorOk = await waitForPredicate( + page, + (base) => window.__qaCounters.sampleHeight - base >= 1, + { timeoutMs: 30000, args: [c0.sampleHeight] }, + ); + } + const cFloor = await readCounters(); + record( + '>=1 real ground sample once tilesLoaded observed (one-shot snap completed)', + tilesSeenLoaded ? sampleFloorOk : null, + tilesSeenLoaded + ? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}` + : 'tilesLoaded never observed within 45s — floor not assessable this run', ); } - const cFloor = await readCounters(); - record( - '>=1 real ground sample once tilesLoaded observed (one-shot snap completed)', - tilesSeenLoaded ? sampleFloorOk : null, - tilesSeenLoaded - ? `total real samples since enable=${cFloor.sampleHeight - c0.sampleHeight}` - : 'tilesLoaded never observed within 45s — floor not assessable this run', - ); // Let the completion pass finish before the steady-state window: require // the sampleHeight counter quiet for 12s (> the 10s update interval, so