diff --git a/changelog.d/1264-overpass-request-admission.md b/changelog.d/1264-overpass-request-admission.md new file mode 100644 index 000000000..c5e763e30 --- /dev/null +++ b/changelog.d/1264-overpass-request-admission.md @@ -0,0 +1,9 @@ +- Prevent malformed Overpass filters from blocking the local server, and reject + cross-site or excessive query requests before reading their bodies + (asturksever, #1264; fixes #1248). +- Release unread responses from the optional Google-token probe so keyless + browser validation can settle; the Overpass gate recognizes only that probe's + expected 404 as an unavailable capability (asturksever, #1264). +- Make tracking arrival checks respect the aircraft update cadence while checking + orientation on the first reveal, avoiding false failures at faster render + rates (asturksever, #1264). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3b33c0561..cc7734067 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2799,11 +2799,16 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > first later successful fetch. Smaller chunks reduce timeout risk; complete > camera batches still wait for their sequential requests, and unresolved > placement continues to use the existing prior until real heights arrive. -> - **Overpass cache admission:** `/api/overpass` parses and sanitizes requests, -> then checks fresh memory, identical in-flight work, and fresh disk entries -> before invoking its local 90/min limiter. Cache and single-flight responses -> therefore do not spend quota; upstream-bound misses retain the existing -> limiter, configured-upstream, stale, and sanitization behavior. An unconfigured transport serves dated stale data or a non-retryable capability error. +> - **Overpass cache admission:** `/api/overpass` refuses cross-site and forwarded +> POSTs through the shared local-request gate, then limits requests to 300/min +> per socket peer and 900/min globally before reading or validating bodies. +> Tag-filter brackets are scanned once; nested or unmatched brackets return 400. +> It then checks fresh memory, identical in-flight work, and fresh disk entries +> before invoking its separate local 90/min upstream limiter. Cache and +> single-flight responses therefore do not spend upstream quota; upstream-bound +> misses retain the existing limiter, configured-upstream, stale, and sanitization +> behavior. An unconfigured transport serves dated stale data or a non-retryable +> capability error. > - **CCTV world-click focus:** clicking an in-world CCTV icon or ambient card > activates it and routes the camera flight through the panel FOCUS policy. > Aircraft/satellite tracking releases outside cockpit; cockpit retains the @@ -4619,6 +4624,12 @@ Retry-After; absent delays back off from 30 seconds to five minutes. Empty remain. Malformed capability-probe responses remain unknown and are retried after the existing 30-second backoff; valid configured decisions remain memoized. Consumer capability misses never schedule automatic retries. +Keyless browser validation also probes the optional Google tiles-token route. +The token client cancels rejected response bodies before returning unavailable, +so they do not remain pending in browser network observation. The Overpass gate +accepts only that same-origin route's expected 404 console diagnostic; other +HTTP and JavaScript errors still fail acceptance. + Operator-configured Overpass area and focus-footprint queries use body-level `out geom` (with `center` for focus), preserving relation member outlines. Member geometry is printed only inside a box around the point (6 km for @@ -5067,7 +5078,7 @@ pins and share links already supply complete boxes. - Fleet model eligibility is distance-based with on-screen priority and hard caps (`MODEL_MAX`, `MODEL_MAX_ALL`) to avoid draw-call explosions. Each model owns its own `modelMatrix`; shared scratch matrices are forbidden because they caused stacking/flicker. - Tracked aircraft use standalone model primitives driven from the already-settled dead-reckoned display position, while the tracked Cesium entity remains billboard-backed so `viewer.trackedEntity` always has a ready bounding sphere. - Flights and military layers mirror the same tracking invariants: no warm-up freeze/jump, altitude-scaled framing, trail head glued to the displayed plane, no pull-out when switching targets, and no cross-layer orphan when switching between commercial and military tracks. -- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers. +- Regression surface: `npm run test:track` drives the real app headless with synthetic aircraft feeds and asserts the tracking invariants without depending on live OpenSky/adsb data. Arrival-rotation checks observe the first reveal within a bounded wall-clock window before the one-second idle fallback, rather than assuming the 80 ms fleet cadence fits inside four rendered frames; an incorrect first orientation fails immediately. `src/data/trackedModelRegime.test.mjs` pins the tracked contact's threshold math, the enter/exit asymmetry, and the default-on / cockpit / TR-3B / deselect wiring in both layers. ### TR-3B conversion Easter egg (August 2026) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index a941b1d8f..636970678 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1067,6 +1067,7 @@ "scripts/assemble-changelog.mjs", "src/tooling/changelogFragments.test.mjs", "src/data/gbfsProxyRateLimit.test.mjs", - "src/data/routeSegment.test.mjs", - "src/terrainHeightsBounds.test.mjs" + "src/terrainHeightsBounds.test.mjs", + "server/providers/overpass/requestAdmission.test.mjs", + "src/tooling/trackArrivalObservation.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 3a9950f39..963ddc6ee 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -252,6 +252,7 @@ "server/providers/common/http.js", "server/providers/common/rate-limit.js", "server/providers/common/request.js", + "server/providers/common/same-site.js", "server/providers/overpass.js", "server/providers/overpass/cache.js", "server/providers/overpass/constants.js", @@ -261,6 +262,7 @@ "server/providers/places/routes.js", "src/data/placeProviderPayloads.js", "src/data/routeSteps.js", + "src/localRequestGate.mjs", "src/sources/httpBody.js", "src/i18n/marker.js", "src/sources/overpass.js", diff --git a/scripts/qa-overpass-offload.mjs b/scripts/qa-overpass-offload.mjs index e04a05b86..8f6594011 100644 --- a/scripts/qa-overpass-offload.mjs +++ b/scripts/qa-overpass-offload.mjs @@ -306,7 +306,16 @@ try { await observeNetwork(page); page.on('pageerror', (error) => result.errors.push(error.message)); page.on('console', (message) => { - if (message.type() === 'error' && result.consoleErrors.length < 30) + if (message.type() !== 'error') return; + // A keyless server does not offer optional Google tokens. Keep every other + // HTTP or JavaScript error visible, including failures of this route other + // than its expected capability-miss 404. + const optionalTokenMiss = + message.location().url === new URL('/api/google/tiles-token', url).href && + /^Failed to load resource: the server responded with a status of 404\b/.test( + message.text(), + ); + if (!optionalTokenMiss && result.consoleErrors.length < 30) result.consoleErrors.push(message.text()); }); page.on('requestfailed', (request) => { diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index 756daccfc..044fdf74f 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -94,6 +94,7 @@ import fs from 'node:fs'; import puppeteer from 'puppeteer'; +import { arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs'; import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js'; import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js'; @@ -2695,16 +2696,17 @@ async function main() { // reappear wearing its stale/creation-north nose for up to 1 s). // Staged deterministically: park the camera nadir over the synthetic // cluster, let rotations settle, TAMPER a billboard's rotation, then - // trigger each path with the camera idle. The idle-drift catch-up - // (ROTATION_REFRESH_MS = 1 s) can coincidentally mask a regression in a - // ~300 ms SwiftShader frame window, so a regression shows as an - // intermittent (not guaranteed) failure — never a false failure. + // trigger each path with the camera idle. The fleet runs every 80 ms, + // independently of rendered FPS. Observe the first completed settle and + // reveal frames within 500 ms of forcing moveEnd, before the 1 s idle + // rotation refresh can repair an incorrect first reveal. // ============================================================ console.log('\nChange 4 — arrival rotation: moveEnd settle pass + horizon-reveal pass'); const arrival = await evalPage(async () => { const v = window.__godsEyeView.viewer; const dm = window.__godsEyeView.dataManager; const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`); + const { observeArrivalRotation } = await import(`${window.__gevQaSourceBase || '/src'}/tooling/trackArrivalObservation.mjs`); // 3D models OFF for this phase: a model-handed-off billboard is hidden // and skips rotation updates entirely — the probes need live billboards // (this is also the app's default state the field report came from). @@ -2766,39 +2768,29 @@ async function main() { const bb = findBB(id); if (!bb || !bb.show) return { error: `${id} missing/hidden` }; const r0 = bb.rotation; // settled tamper origin; correctness uses a fresh projection below - // (a) settle pass: tamper, then raise moveEnd with the camera IDLE — - // the pose signature is unchanged, so only the moveEnd hook can fix - // this before the 1 s catch-up. - bb.rotation = r0 + 1.5; - v.camera.moveEnd.raiseEvent(); - await nextFrames(2); - const afterMoveEnd = bb.rotation; - // A real-GPU fleet tick can land near the edge of the frame budget and - // advance the contact before the probe reads it. Compare with the - // production projection at the CURRENT position, not the now-stale r0. - const expectedMoveEnd = screenProjectedRotation(v.scene, bb.position, course, null); - // (b) reveal pass: tamper + hide — the next fleet tick must flip it - // visible AND correct the nose in that same tick (camera still idle, - // no moveEnd raised). Diagnostic fields (round 5): record WHICH frame - // the flip landed on (flipFrame; -1 = never within the extended - // window) and whether a 3D model owns this id — distinguishes "tick - // slow under load" from "occluder says hidden" from "model handoff". - bb.rotation = r0 + 1.5; - bb.show = false; - let flipFrame = -1; - for (let f = 1; f <= 6; f++) { - await nextFrames(1); - if (bb.show && flipFrame === -1) flipFrame = f; - if (f >= 2 && flipFrame !== -1) break; - } - const expectedReveal = screenProjectedRotation(v.scene, bb.position, course, null); + const observed = await observeArrivalRotation({ + scene: v.scene, + startMoveEnd() { + // Only moveEnd can repair the tampered angle at the idle pose. + bb.rotation = r0 + 1.5; + v.camera.moveEnd.raiseEvent(); + }, + startReveal() { + // Hide immediately after the confirmed settle frame. The next + // eligible fleet tick must reveal AND re-aim in that same tick. + bb.rotation = r0 + 1.5; + bb.show = false; + }, + read() { + return { + shown: bb.show, + rotationError: angDiff(bb.rotation, + screenProjectedRotation(v.scene, bb.position, course, null)), + }; + }, + }); const hasModel = !!(window.__g3dFindModel && window.__g3dFindModel(id)); - return { - r0, - dMoveEnd: angDiff(afterMoveEnd, expectedMoveEnd), - dReveal: angDiff(bb.rotation, expectedReveal), - shown: bb.show, flipFrame, hasModel, - }; + return { r0, ...observed, hasModel }; }; const flightCourse = window.__SYNTH.flights.find((f) => f.icao === 'aaa002')?.track ?? 0; const militaryCourse = window.__SYNTH.military.find((m) => m.hex === 'bbb101')?.track ?? 0; @@ -2807,22 +2799,13 @@ async function main() { military: await probe('bbb101', militaryCourse), }; }); - const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? a.error - : `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} hasModel=${a.hasModel}`); + const fmtArr = (a) => (!a ? `no result (${arrival?.error || 'phase error'})` : a.error ? `${a.error}; elapsed=${Number(a.elapsedMs).toFixed(1)} ms` + : `settle-err=${(a.dMoveEnd * 180 / Math.PI).toFixed(1)}° reveal-err=${(a.dReveal * 180 / Math.PI).toFixed(1)}° shown=${a.shown} flipFrame=${a.flipFrame} settle=${a.moveEndMs.toFixed(1)} ms reveal=${a.revealMs.toFixed(1)} ms total=${a.elapsedMs.toFixed(1)} ms hasModel=${a.hasModel}`); const TAMPER_DEG = 1.5 * 180 / Math.PI; // ≈ 86° — an uncorrected nose stays this far off - // Reveal budget re-pinned 2 → 4 frames (round 5): the diagnostic probe - // shows the military flip consistently lands on frame 2 — the exact edge - // of the old budget — so any load hiccup flaked it (flights lands frame - // 1). The regression this protects against is a reveal that waits for - // the 1 s idle catch-up (~30-60 frames) or never re-aims; 4 frames still - // catches that with a wide margin, with zero-headroom flakes gone. - const arrOk = (a) => !!a && !a.error && a.shown - && (a.flipFrame === -1 ? false : a.flipFrame <= 4) - && a.dMoveEnd * 180 / Math.PI < 10 && a.dReveal * 180 / Math.PI < 10; - record(`arrival: moveEnd + reveal both re-aim within 4 frames, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`, - arrOk(arrival.flights), fmtArr(arrival.flights)); - record(`arrival: moveEnd + reveal both re-aim within 4 frames, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`, - arrOk(arrival.military), fmtArr(arrival.military)); + record(`arrival: moveEnd + first reveal both re-aim before idle refresh, flights (tamper was ${TAMPER_DEG.toFixed(0)}°)`, + arrivalRotationPassed(arrival.flights), fmtArr(arrival.flights)); + record(`arrival: moveEnd + first reveal both re-aim before idle refresh, military (tamper was ${TAMPER_DEG.toFixed(0)}°)`, + arrivalRotationPassed(arrival.military), fmtArr(arrival.military)); // ============================================================ // DISPLAY FLOOR (2026-08-19): a grounded contact must never render below diff --git a/server/providers/overpass.js b/server/providers/overpass.js index e6631e889..3b5a2e575 100644 --- a/server/providers/overpass.js +++ b/server/providers/overpass.js @@ -1,5 +1,6 @@ import { makeRateLimiter, clientKey } from './common/rate-limit.js'; import { readRequestBodyCapped } from './common/request.js'; +import { admitSameSite } from './common/same-site.js'; import { OVERPASS_MAX_BODY_BYTES, OVERPASS_MAX_CONCURRENT, @@ -27,6 +28,14 @@ const _overpassInFlight = new Map(); let _overpassConcurrent = 0; +// Bound body-reading and validation work, including malformed requests and +// cache hits. Keep the tighter upstream budget below independent of this gate. +const _overpassRequestLimiter = makeRateLimiter({ + windowMs: 60_000, + max: 300, + globalMax: 900, +}); + const _overpassRateLimiter = makeRateLimiter({ windowMs: 60_000, max: 90, @@ -94,6 +103,17 @@ function overpassProxy({ routing = {} } = {}) { return; } + if (admitSameSite(req, res)) return; + if (!_overpassRequestLimiter(clientKey(req))) { + res.writeHead(429, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + 'Retry-After': '60', + }); + res.end(JSON.stringify({ error: 'Rate limit exceeded' })); + return; + } + // Collect POST body with a hard byte cap (Overpass QL queries are small) let body; try { diff --git a/server/providers/overpass/query.js b/server/providers/overpass/query.js index cee444d3a..4091c9efd 100644 --- a/server/providers/overpass/query.js +++ b/server/providers/overpass/query.js @@ -18,6 +18,29 @@ function isOverpassBoundaryQuery(cacheKey) { return /is_in\s*\(|\bpivot\b/i.test(String(cacheKey || '')); } +// Input has already had quoted literals and comments removed. Consume each +// character once: a regex that searches for a closing bracket from every `[` is +// quadratic on an unterminated filter. Nested or unmatched brackets are invalid. +function stripTagFilters(text) { + const parts = []; + let start = 0; + let inFilter = false; + for (let i = 0; i < text.length; i += 1) { + if (text[i] === '[') { + if (inFilter) return null; + parts.push(text.slice(start, i), ' '); + inFilter = true; + } else if (text[i] === ']') { + if (!inFilter) return null; + inFilter = false; + start = i + 1; + } + } + if (inFilter) return null; + parts.push(text.slice(start)); + return parts.join(''); +} + /** * Validate + clamp an Overpass form body. Defends the generic proxy against * planet-scale abuse: requires exactly one `data` query in which EVERY element @@ -50,6 +73,10 @@ function sanitizeOverpassBody(rawBody) { // Blank quoted literals + strip comments in one lexer pass so a fake bound or a // `//` inside a string can't hide an unbounded selector (or satisfy a bound). const stripped = stripOverpassNoise(data); + const withoutTags = stripTagFilters(stripped); + if (withoutTags === null) { + return { ok: false, error: 'Malformed Overpass tag filter' }; + } // Reject oversized radii in EVERY around form — point `around:r,lat,lon` AND the // input-set form `around.set:r` — and parse the full numeric token so scientific @@ -93,20 +120,19 @@ function sanitizeOverpassBody(rawBody) { // statement. So `way[...]->.a` (global assigned to a set) is rejected, while the // app's `is_in(...)->.a; area.a[...]` and `area(id)->.x; rel(pivot.x)` validate. const boundedSets = new Set(); - for (let stmt of stripped.split(';')) { + for (let stmt of withoutTags.split(';')) { stmt = stmt.trim(); if (!stmt || stmt.startsWith('[') || /^out\b/.test(stmt)) continue; - // Strip output-set assignments (NOT input bounds), then strip bracket tag - // filters so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) can never be + // Strip output-set assignments (NOT input bounds). Bracket tag filters were + // removed above so a tag KEY/value (e.g. `way[is_in]`, `node[around]`) cannot be // misread as a spatial bound. Bounds live in (...) / function calls / set // refs, never inside [...], so the probe loses nothing real. const outSets = []; - const body = stmt.replace(/->\s*\.(\w+)/g, (_, name) => { + const probe = stmt.replace(/->\s*\.(\w+)/g, (_, name) => { outSets.push(name); return ' '; }); - const probe = body.replace(/\[[^\]]*\]/g, ' '); // Reject element-in-area scans on the TAG-STRIPPED probe, so a tag filter // between the selector and the area filter (way["highway"](area.a)) can't hide diff --git a/server/providers/overpass/requestAdmission.test.mjs b/server/providers/overpass/requestAdmission.test.mjs new file mode 100644 index 000000000..ecd642320 --- /dev/null +++ b/server/providers/overpass/requestAdmission.test.mjs @@ -0,0 +1,178 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Readable } from 'node:stream'; +import { spawnSync } from 'node:child_process'; +import { overpassProxy } from '../overpass.js'; +import { sanitizeOverpassBody } from './query.js'; + +const form = (query) => `data=${encodeURIComponent(query)}`; + +test('bracket validation rejects malformed filters without trusting their contents as bounds', () => { + for (const query of [ + 'node[', + 'node]', + 'node[[name]](around:20,1,1);out;', + 'node[name=around:20];out;', + 'node[is_in];out;', + 'node[name="(around:20,1,1)"];out;', + 'node(around:20,1,1);way[name];out;', + 'way[highway](area.a);out;', + 'node[->.a](around:20,1,1);way.a;out;', + ]) { + assert.equal(sanitizeOverpassBody(form(query)).ok, false, query); + } +}); + +test('bounded queries preserve quoted brackets, comments, multiple tags and timeout clamping', () => { + for (const query of [ + '[out:json][timeout:999];node[name="[Town]; ]"](around:20,1,1);out;', + '/* [ */way[highway][name](30.1,-97.9,30.5,-97.5);out geom;', + 'is_in(30.2,-97.7)->.a;area.a[boundary="administrative"];out tags;', + 'area(3600039152)->.x;rel(pivot.x);out geom;', + ]) { + const result = sanitizeOverpassBody(form(query)); + assert.equal(result.ok, true, query); + assert.equal( + new URLSearchParams(result.body).get('data'), + query.replace('[timeout:999]', '[timeout:30]'), + ); + } +}); + +test('a burst of maximum-size unterminated filters completes within a bounded process deadline', () => { + // A child deadline can stop synchronous regex backtracking; a test timeout in + // the same event loop cannot. The old parser takes several seconds for this. + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + `import { sanitizeOverpassBody } from ${JSON.stringify(new URL('./query.js', import.meta.url).href)}; + for (let i = 0; i < 20; i++) { + if (sanitizeOverpassBody('data=node' + '['.repeat(24000)).ok) process.exit(1); + }`, + ], + { timeout: 2000, encoding: 'utf8' }, + ); + assert.ifError(result.error); + assert.equal(result.status, 0, result.stderr); +}); + +function handler(hook = 'configureServer') { + let route; + overpassProxy()[hook]({ + middlewares: { + use(path, fn) { + if (path === '/api/overpass') route = fn; + }, + }, + }); + return route; +} + +async function call( + route, + { + headers = {}, + peer = '127.0.0.1', + body = 'data=node[', + forbidRead = false, + method = 'POST', + url = '/', + } = {}, +) { + let reads = 0; + const req = new Readable({ + read() { + reads++; + assert.equal(forbidRead, false, 'refused request must not read its body'); + this.push(Buffer.from(body)); + this.push(null); + }, + }); + Object.assign(req, { + method, + url, + headers: { host: 'localhost:4173', ...headers }, + socket: { remoteAddress: peer }, + }); + const res = { + statusCode: 200, + headers: {}, + setHeader(key, value) { + this.headers[key.toLowerCase()] = value; + }, + writeHead(status, values) { + this.statusCode = status; + for (const [key, value] of Object.entries(values)) + this.setHeader(key, value); + }, + end(value) { + this.body = value; + }, + }; + await route(req, res); + return { ...res, reads }; +} + +for (const hook of ['configureServer', 'configurePreviewServer']) { + test(`${hook}: cross-site requests are rejected before consuming a body or fetching`, async (t) => { + t.mock.method(globalThis, 'fetch', () => + assert.fail('unexpected upstream fetch'), + ); + const route = handler(hook); + for (const headers of [ + { origin: 'https://foreign.example', 'content-type': 'text/plain' }, + { 'sec-fetch-site': 'cross-site' }, + { 'sec-fetch-site': 'same-site' }, + { origin: 'null' }, + { 'x-forwarded-for': '127.0.0.1' }, + ]) { + const response = await call(route, { headers, forbidRead: true }); + assert.equal(response.statusCode, 403); + assert.equal(response.reads, 0); + assert.equal(response.headers['cache-control'], 'no-store'); + } + }); +} + +test('same-origin, LAN and non-browser requests still reach validation; GET status stays available', async () => { + const route = handler(); + for (const headers of [ + {}, + { origin: 'http://localhost:4173', 'sec-fetch-site': 'same-origin' }, + { host: '192.168.1.10:4173', origin: 'http://192.168.1.10:4173' }, + ]) { + const response = await call(route, { headers }); + assert.equal(response.statusCode, 400); + assert.equal(response.reads, 1); + assert.match(response.body, /Malformed Overpass tag filter/); + } + const response = await call(route, { + method: 'GET', + url: '/status', + forbidRead: true, + }); + assert.equal(response.statusCode, 200); + assert.equal(typeof JSON.parse(response.body).configured, 'boolean'); +}); + +test('invalid requests spend admission quota before parsing, including on an unconfigured server', async (t) => { + let now = Date.now() + 120000; + t.mock.method(Date, 'now', () => now); + t.mock.method(globalThis, 'fetch', () => + assert.fail('unexpected upstream fetch'), + ); + const route = handler(); + const peer = 'admission-limit-test'; + for (let i = 0; i < 300; i++) { + assert.equal((await call(route, { peer })).statusCode, 400); + } + const denied = await call(route, { peer, forbidRead: true }); + assert.equal(denied.statusCode, 429); + assert.equal(denied.reads, 0); + assert.equal(denied.headers['retry-after'], '60'); + assert.equal((await call(route, { peer: 'other-peer' })).statusCode, 400); + now += 60001; + assert.equal((await call(route, { peer })).statusCode, 400); +}); diff --git a/src/maps/googleTokens.js b/src/maps/googleTokens.js index 7896c8f5c..884c5b199 100644 --- a/src/maps/googleTokens.js +++ b/src/maps/googleTokens.js @@ -38,7 +38,10 @@ export function createGoogleTokenSource({ headers: { Accept: 'application/json' }, cache: 'no-store', }); - if (!response.ok) return null; + if (!response.ok) { + await response.body?.cancel(); + return null; + } return parseToken(await response.json(), now()); } catch { return null; diff --git a/src/maps/googleTokens.test.mjs b/src/maps/googleTokens.test.mjs index 87b7cf8dc..d8a1e84ed 100644 --- a/src/maps/googleTokens.test.mjs +++ b/src/maps/googleTokens.test.mjs @@ -50,6 +50,23 @@ test('requests refused for the same token renew it once', async () => { assert.equal(calls.length, 2); }); +test('a refused token response releases its unread body before returning', async () => { + for (const status of [404, 429, 500]) { + let cancelled = false; + const response = new Response( + new ReadableStream({ + cancel() { + cancelled = true; + }, + }), + { status }, + ); + const tokens = createGoogleTokenSource({ fetchImpl: async () => response }); + assert.equal(await tokens.token(), null); + assert.equal(cancelled, true, `HTTP ${status} body released`); + } +}); + test('a server that does not offer tokens yields none', async () => { for (const answer of [ new Response('Not found', { status: 404 }), diff --git a/src/tooling/trackArrivalObservation.mjs b/src/tooling/trackArrivalObservation.mjs new file mode 100644 index 000000000..370624891 --- /dev/null +++ b/src/tooling/trackArrivalObservation.mjs @@ -0,0 +1,100 @@ +/** A reveal must beat the one-second idle rotation refresh, regardless of FPS. */ +export const ARRIVAL_OBSERVATION_MS = 500; + +/** + * Observe the first completed moveEnd frame and the first subsequent reveal. + * The deadline starts when moveEnd is forced, before its rotation baseline, so + * waiting for either frame cannot let the one-second idle refresh mask a bug. + * `read` snapshots visibility and rotation error against the current position. + */ +export function observeArrivalRotation({ + scene, + startMoveEnd, + startReveal, + read, + now = () => performance.now(), + setTimer = setTimeout, + clearTimer = clearTimeout, +}) { + return new Promise((resolve, reject) => { + const startedAt = now(); + let moveEnd = null; + let moveEndMs = null; + let revealFrames = 0; + let done = false; + let remove; + let timer; + const cleanup = () => { + done = true; + remove?.(); + clearTimer(timer); + }; + const finish = (result) => { + if (done) return; + cleanup(); + resolve(result); + }; + const expired = () => ({ + error: `arrival ${moveEnd ? 'reveal' : 'moveEnd'} observation exceeded ${ARRIVAL_OBSERVATION_MS} ms`, + elapsedMs: now() - startedAt, + moveEndMs, + flipFrame: -1, + }); + remove = scene.postRender.addEventListener(() => { + if (done) return; + try { + const elapsedMs = now() - startedAt; + if (elapsedMs >= ARRIVAL_OBSERVATION_MS) { + finish(expired()); + return; + } + const sample = read(); + if (moveEnd === null) { + moveEnd = sample; + moveEndMs = elapsedMs; + startReveal(); + } else { + revealFrames++; + if (sample.shown) { + // Keep the FIRST visible result, including a wrong angle. Never + // wait for a subsequent tick to repair a failed reveal. + finish({ + dMoveEnd: moveEnd.rotationError, + dReveal: sample.rotationError, + shown: sample.shown, + flipFrame: revealFrames, + moveEndMs, + revealMs: elapsedMs - moveEndMs, + elapsedMs, + }); + return; + } + } + scene.requestRender(); + } catch (error) { + cleanup(); + reject(error); + } + }); + timer = setTimer(() => finish(expired()), ARRIVAL_OBSERVATION_MS); + try { + startMoveEnd(); + scene.requestRender(); + } catch (error) { + cleanup(); + reject(error); + } + }); +} + +/** Test the captured frames without retrying a bad first observation. */ +export function arrivalRotationPassed(observation) { + return ( + !!observation && + !observation.error && + observation.shown === true && + observation.elapsedMs < ARRIVAL_OBSERVATION_MS && + (observation.dMoveEnd * 180) / Math.PI < 10 && + (observation.dReveal * 180) / Math.PI < 10 + ); +} diff --git a/src/tooling/trackArrivalObservation.test.mjs b/src/tooling/trackArrivalObservation.test.mjs new file mode 100644 index 000000000..7c140bf37 --- /dev/null +++ b/src/tooling/trackArrivalObservation.test.mjs @@ -0,0 +1,151 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + ARRIVAL_OBSERVATION_MS, + arrivalRotationPassed, + observeArrivalRotation, +} from './trackArrivalObservation.mjs'; + +function fixture() { + let time = 0; + let state = { shown: true, rotationError: 0 }; + const listeners = new Set(); + const timers = new Map(); + let renders = 0; + let moveEnds = 0; + const observation = observeArrivalRotation({ + scene: { + postRender: { + addEventListener(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + requestRender() { + renders++; + }, + }, + startMoveEnd() { + moveEnds++; + state.rotationError = 1.5; + }, + startReveal() { + state = { shown: false, rotationError: 1.5 }; + }, + read: () => ({ ...state }), + now: () => time, + setTimer(callback, delay) { + const handle = {}; + timers.set(handle, { callback, at: time + delay }); + return handle; + }, + clearTimer(handle) { + timers.delete(handle); + }, + }); + return { + observation, + frame(at, nextState) { + time = at; + state = { ...state, ...nextState }; + for (const listener of [...listeners]) listener(); + }, + expire(at) { + time = at; + for (const timer of [...timers.values()]) + if (timer.at <= at) timer.callback(); + }, + get resources() { + return { + listeners: listeners.size, + timers: timers.size, + renders, + moveEnds, + }; + }, + }; +} + +test('80 ms fleet scheduling passes after more than four fast rendered frames', async () => { + const f = fixture(); + f.frame(8, { shown: true, rotationError: 0 }); + // The forced fleet tick was at 8 ms. A 125 FPS renderer can complete nine + // more frames before the next 80 ms fleet interval becomes eligible. + for (let at = 16; at < 88; at += 8) f.frame(at); + f.frame(88, { shown: true, rotationError: 0 }); + const result = await f.observation; + assert.equal(result.flipFrame, 10); + assert.equal(result.moveEndMs, 8); + assert.equal(result.revealMs, 80); + assert.equal(result.elapsedMs, 88); + assert.equal(arrivalRotationPassed(result), true); + assert.equal(f.resources.listeners, 0); + assert.equal(f.resources.timers, 0); + assert.equal(f.resources.moveEnds, 1); +}); + +test('a wrong first visible angle fails even if the next frame corrects it', async () => { + const f = fixture(); + f.frame(8, { shown: true, rotationError: 0 }); + f.frame(88, { shown: true, rotationError: 1.5 }); + f.frame(96, { shown: true, rotationError: 0 }); + const result = await f.observation; + assert.equal(result.dReveal, 1.5); + assert.equal(result.flipFrame, 1); + assert.equal(result.elapsedMs, 88); + assert.equal(arrivalRotationPassed(result), false); +}); + +test('a wrong first moveEnd angle is retained rather than repaired by reveal', async () => { + const f = fixture(); + f.frame(8, { shown: true, rotationError: 1.5 }); + f.frame(88, { shown: true, rotationError: 0 }); + const result = await f.observation; + assert.equal(result.dMoveEnd, 1.5); + assert.equal(arrivalRotationPassed(result), false); +}); + +test('no reveal fails at a deadline before the one-second idle catch-up', async () => { + const f = fixture(); + assert.equal(ARRIVAL_OBSERVATION_MS, 500); + f.frame(8, { shown: true, rotationError: 0 }); + f.frame(480); + f.expire(500); + const result = await f.observation; + assert.match(result.error, /reveal.*500 ms/); + assert.equal(arrivalRotationPassed(result), false); + assert.equal(f.resources.listeners, 0); + assert.equal(f.resources.timers, 0); + f.frame(1000, { shown: true, rotationError: 0 }); + assert.equal(result.flipFrame, -1); +}); + +test('a late visible frame fails even if the timeout callback is delayed', async () => { + const f = fixture(); + f.frame(8, { shown: true, rotationError: 0 }); + f.frame(501, { shown: true, rotationError: 0 }); + const result = await f.observation; + assert.match(result.error, /reveal.*500 ms/); + assert.equal(result.elapsedMs, 501); + assert.equal(arrivalRotationPassed(result), false); +}); + +test('the deadline includes moveEnd setup rather than restarting when hidden', async () => { + const f = fixture(); + f.frame(400, { shown: true, rotationError: 0 }); + f.frame(600, { shown: true, rotationError: 0 }); + const result = await f.observation; + assert.equal(result.moveEndMs, 400); + assert.equal(result.elapsedMs, 600); + assert.equal(arrivalRotationPassed(result), false); +}); + +test('missing moveEnd frames fail without waiting for a render callback', async () => { + const f = fixture(); + f.expire(500); + const result = await f.observation; + assert.match(result.error, /moveEnd.*500 ms/); + assert.equal(arrivalRotationPassed(result), false); + assert.equal(f.resources.listeners, 0); + assert.equal(f.resources.timers, 0); +});