diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7d4595fc8..dbf5e9b05 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -101,6 +101,10 @@ lists at 25 rows by default. Voice offers the same queries next to its app actions: the session lists them, and the browser runs them through the same catalog, loaded on first use. See [tools and the MCP server](TOOLS.md). +Catalog calls check their cancellation signal immediately before executing a +tool. Already-cancelled queries do not start; cancellation after execution starts +continues to follow the existing tool and caller behavior. + ## Cyber HUD — September 30, 2026 Display > HUD > Layout includes Cyber, also available through the HUD voice @@ -1222,6 +1226,10 @@ The browser layers and their rendering remain in their existing modules. ## Terrain height cache bound +The terrain proxy parser rejects finite longitudes outside [-180, 180] and +latitudes outside [-90, 90] for every client before upstream resolution. One +out-of-range point rejects the whole batch; inclusive WGS84 bounds remain valid. + The client terrain-height resolver's in-memory cache is bounded at 20 000 entries (`DEFAULT_MAX_CACHE_ENTRIES`, overridable per instance through `createTerrainHeights({ maxCacheEntries })`). Coordinate rounding makes repeated @@ -4128,7 +4136,8 @@ when its own signal aborts. Configured endpoints have per-instance cooldowns for 406/429, honoring Retry-After; absent delays back off from 30 seconds to five minutes. Empty `elements` is a valid answer. Existing byte, query, concurrency and cache caps -remain. Consumer capability misses never schedule automatic retries. +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. Operator-configured Overpass area and focus-footprint queries use body-level `out geom` (with `center` for focus), preserving relation member outlines. diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 813163d8c..3c4b9165b 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -313,7 +313,9 @@ test('retry: a plain HTTP 500 still exhausts the existing 8s/25s transient ladde t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 }); globalThis.window = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout }; const requestTimes = []; - globalThis.fetch = async () => { + globalThis.fetch = async (url) => { + if (String(url).endsWith('/status')) + return Response.json({ configured: true }); requestTimes.push(Date.now()); return httpFailure(500); }; diff --git a/src/data/terrainHeightsProxy.js b/src/data/terrainHeightsProxy.js index bfb058693..9134f9ec6 100644 --- a/src/data/terrainHeightsProxy.js +++ b/src/data/terrainHeightsProxy.js @@ -35,7 +35,13 @@ export function parseTerrainPoints(raw) { if (parts.length !== 2) return null; const lon = Number(parts[0]); const lat = Number(parts[1]); - if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null; + if ( + !Number.isFinite(lon) || + !Number.isFinite(lat) || + Math.abs(lon) > 180 || + Math.abs(lat) > 90 + ) + return null; points.push([lon, lat]); } return points; diff --git a/src/data/terrainHeightsProxy.test.mjs b/src/data/terrainHeightsProxy.test.mjs index f6b53de9a..50e45b732 100644 --- a/src/data/terrainHeightsProxy.test.mjs +++ b/src/data/terrainHeightsProxy.test.mjs @@ -3,6 +3,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + parseTerrainPoints, fetchTerrainChunkWithRetry, resolveTerrainHeightRequest, terrainPointKey, @@ -227,3 +228,13 @@ test('malformed height objects remain refresh failures, not absent readings', as assert.equal(response.cacheChanged, false); } }); + +test('terrain parser rejects out-of-range WGS84 points and accepts inclusive bounds', () => { + for (const value of ['181,0', '-181,0', '0,91', '0,-91', '1,2;0,91']) + assert.equal(parseTerrainPoints(value), null, value); + assert.deepEqual(parseTerrainPoints('-180,-90;180,90;0,0'), [ + [-180, -90], + [180, 90], + [0, 0], + ]); +}); diff --git a/src/proxyErrorResponses.test.mjs b/src/proxyErrorResponses.test.mjs index 2b45a3b9e..c6a7269ab 100644 --- a/src/proxyErrorResponses.test.mjs +++ b/src/proxyErrorResponses.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import path from 'node:path'; +import { parseTerrainPoints } from './data/terrainHeightsProxy.js'; import { readResponseTextCapped, coalesceProxyRequest } from './sources/httpBody.js'; const source = ['local.js', 'common/http.js', 'aircraft/enrichment.js', 'terrain.js', 'space/celestrak.js', 'space/launch-library.js', '../../src/data/spaceProviderRequests.js'] @@ -172,3 +173,18 @@ test('ADSBDB retains validation and missing-aircraft semantics', async () => { assert.equal(absent.status, 200); assert.deepEqual(JSON.parse(absent.body), { found: false }); }); + +for (const preview of [false, true]) { + test(`terrain server rejects out-of-range points before upstream resolution in ${preview ? 'preview' : 'development'}`, async () => { + let calls = 0; + const app = fixture('terrainHeightsProxy', { parseTerrainPoints, + resolveTerrainHeightRequest: async () => { calls++; return { status: 200, body: { results: [] } }; }, + }, preview); + for (const points of ['181,0', '-181,0', '0,91', '0,-91', '1,2;0,91']) { + assert.equal((await app.request('/?points=' + encodeURIComponent(points))).status, 400); + } + assert.equal(calls, 0); + assert.equal((await app.request('/?points=-180,-90;180,90')).status, 200); + assert.equal(calls, 1); + }); +} diff --git a/src/services/boundary-probe-recovery.test.mjs b/src/services/boundary-probe-recovery.test.mjs new file mode 100644 index 000000000..cc0c47c08 --- /dev/null +++ b/src/services/boundary-probe-recovery.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApplicationRequestServices } from './requests.js'; +test('unknown capability replies recover after backoff', async (t) => { + t.mock.timers.enable({ apis: ['Date'], now: 100_000 }); + for (const first of [ + () => Response.json({ wrong: true }), + () => new Response('bad', { status: 503 }), + ]) { + let probes = 0; + let queries = 0; + const services = createApplicationRequestServices({ + boundaryProbe: { timeoutMs: 1000, retryMs: 1000 }, + fetchImpl: async (url) => { + if (url.endsWith('/status')) + return ++probes === 1 + ? first() + : Response.json({ configured: false }); + queries++; + return Response.json({ elements: [] }); + }, + }); + assert.deepEqual(await services.boundaries.query('fixture'), []); + assert.deepEqual(await services.boundaries.query('fixture'), []); + assert.equal(probes, 1, 'unknown reply is not re-probed inside backoff'); + t.mock.timers.tick(1000); + assert.equal( + (await services.boundaries.query('fixture')).code, + 'OVERPASS_NOT_CONFIGURED', + ); + assert.equal( + (await services.boundaries.query('fixture')).code, + 'OVERPASS_NOT_CONFIGURED', + ); + assert.equal(probes, 2); + assert.equal(queries, 2); + } +}); diff --git a/src/services/requests.js b/src/services/requests.js index bb0b4cefd..3e3a819eb 100644 --- a/src/services/requests.js +++ b/src/services/requests.js @@ -66,9 +66,9 @@ export function createApplicationRequestServices({ throw new Error(`${label} unavailable (${response.status})`); return response.data; } - // Learn once per page whether the server has an Overpass instance, so an + // Remember valid capability decisions for the page, so an // unconfigured server is never asked (and never answers with an error). - // Discovery is bounded; a failed or timed-out probe is forgotten and not + // Discovery is bounded; failed, malformed or timed-out probes are forgotten and not // retried for a short backoff, during which queries go to the server as // they would against an older server without the probe. let boundaryCapability = null; @@ -79,10 +79,13 @@ export function createApplicationRequestServices({ boundaryCapability = request(`${urls.boundaries}/status`, { signal: AbortSignal.timeout(boundaryProbe.timeoutMs), }).then( - (response) => - response.ok && typeof response.data?.configured === 'boolean' - ? response.data.configured - : null, // an older server: fall back to asking per query + (response) => { + if (response.ok && typeof response.data?.configured === 'boolean') + return response.data.configured; + boundaryCapability = null; + boundaryProbeRetryAt = Date.now() + boundaryProbe.retryMs; + return null; // capability unknown: let this query ask the server + }, (error) => { boundaryCapability = null; if (lifetime?.aborted) throw error; diff --git a/src/tools/catalog-cancellation.test.mjs b/src/tools/catalog-cancellation.test.mjs new file mode 100644 index 000000000..0f666b262 --- /dev/null +++ b/src/tools/catalog-cancellation.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { composeCatalog, defineTool } from './catalog.js'; + +for (const intercept of [false, true]) { + test(`cancelled query refuses execution${intercept ? ' after an awaited interceptor' : ''}`, async () => { + let calls = 0; + const controller = new AbortController(); + const query = defineTool({ + name: 'fixture_query', + kind: 'query', + title: 'Fixture', + description: 'Offline fixture', + inputSchema: { type: 'object' }, + run: async () => { + calls++; + return { summary: 'fixture', data: {} }; + }, + }); + const catalog = composeCatalog({ + tools: [query], + interceptors: intercept + ? [ + async (call, next) => { + await Promise.resolve(); + controller.abort(); + return next(call); + }, + ] + : [], + }); + if (!intercept) controller.abort(); + await assert.rejects( + catalog.call('fixture_query', {}, { signal: controller.signal }), + { name: 'AbortError' }, + ); + assert.equal(calls, 0); + const fresh = composeCatalog({ tools: [query] }); + assert.equal( + ( + await fresh.call( + 'fixture_query', + {}, + { signal: new AbortController().signal }, + ) + ).summary, + 'fixture', + ); + assert.equal(calls, 1); + }); +} diff --git a/src/tools/catalog.js b/src/tools/catalog.js index 4d5106b6e..d617a9f28 100644 --- a/src/tools/catalog.js +++ b/src/tools/catalog.js @@ -178,6 +178,7 @@ export function composeCatalog({ return chain({ tool, args, signal, ...(parent ? { parent } : {}) }); }; const execute = async ({ tool, args, signal }) => { + signal?.throwIfAborted(); const problems = validateValue(tool.inputSchema, args); if (problems.length) throw new ToolError('invalid_arguments', problems.join('; '));