diff --git a/scripts/qcm/README.md b/scripts/qcm/README.md index fd015a3..e9870b7 100644 --- a/scripts/qcm/README.md +++ b/scripts/qcm/README.md @@ -294,9 +294,11 @@ switch-off, an older decode landing after a newer ask, and an uninterrupted ask `quiet-corners-maps/request.js` is the one way the map reads from the network. `fetchWithDeadline(url, { fetchImpl, signal, timeoutMs, init, read, anyStatus })` (`read` swaps the body reader, e.g. `r => r.blob()`; `anyStatus` also reads the body of a non-2xx for services whose error answer carries the reason) answers `{ ok, status: 'ok' | 'timeout' | 'aborted' | 'error' }` and never throws. Its timer is a plain `AbortController`, so it works without `AbortSignal.any`, it ends a fetch that ignores its signal, and it clears the timer and the listener on the caller's signal when the call settles. A caller's cancel is `aborted`, never `timeout`. `orThrow(result, message)` is for the few adapters that throw on failure: the body of an ok result, else an Error carrying the result's `status` and `httpStatus` (a caller's cancel keeps the name `AbortError`). `createLatest()` is latest-wins: a new call aborts the previous one and the older answer comes back `stale`. `source-terrain-names.js` re-exports `fetchJson` and `createSearchGuard` from it for the callers that already import them. -The deadline is per source: `SOURCES..timeoutMs` in `source-registry.js`, `DEFAULT_SOURCE_TIMEOUT_MS` (10 s) unless the source cites a slower measurement in `timeoutMeasured`. GNIS place names keep 20 s because their latency is measured up to 31 s, and the wetlands and government-units layers keep the 20 s page their measurements were taken against. Every module in that directory except `main.js` and the seven listed next reads through the helper at its own source's value (the paged boundary and site callers pass it to `pageThrough`; a module with no registry entry, such as the ecoregions export, uses the default). `main.js` still has raw reads until PR-B2. The seven that reached master after this work was written (`address-geocode.js`, `source-avalanche.js`, `source-nrhp.js`, `source-ntad-rail-airports.js`, `source-nws-observations.js`, `source-snotel.js`, `source-stream-gauges.js`) still make their own raw calls, with no deadline from the helper, and are on the `ALLOW` list until a follow-up moves them. `REQUEST_POLICY.timeoutMs` is no longer a request deadline. The quad index pages at most `MAX_PAGES` (20) times and stops on a page that adds no new cell, ending `coverage: 'partial'`; its `timeoutMs` bounds each attempt, including `searchByName`, and a timeout is not retried. +The deadline is per source: `SOURCES..timeoutMs` in `source-registry.js`, `DEFAULT_SOURCE_TIMEOUT_MS` (10 s) unless the source cites a slower measurement in `timeoutMeasured`. GNIS place names keep 20 s because their latency is measured up to 31 s, and the wetlands and government-units layers keep the 20 s page their measurements were taken against. Every module in that directory except the seven listed next reads through the helper at its own source's value (the paged boundary and site callers pass it to `pageThrough`; a module with no registry entry, such as the ecoregions export, uses the default). The seven that reached master after this work was written (`address-geocode.js`, `source-avalanche.js`, `source-nrhp.js`, `source-ntad-rail-airports.js`, `source-nws-observations.js`, `source-snotel.js`, `source-stream-gauges.js`) still make their own raw calls, with no deadline from the helper, and are on the `ALLOW` list until a follow-up moves them. `REQUEST_POLICY.timeoutMs` is no longer a request deadline. The quad index pages at most `MAX_PAGES` (20) times and stops on a page that adds no new cell, ending `coverage: 'partial'`; its `timeoutMs` bounds each attempt, including `searchByName`, and a timeout is not retried. -`request.test.mjs` covers the helper (timeout, abort, cleanup, no `AbortSignal.any`, `anyStatus`, `orThrow`, latest-wins); `no-raw-fetch.test.mjs` fails on any raw `fetch(` or `fetchImpl(` call in that directory outside `request.js` and its `ALLOW` list (`main.js`, removed in PR-B2, and the seven named above; it also fails if an entry stops holding a raw call, so the list cannot rot); `adapter-deadlines.test.mjs` hands every adapter that takes `fetchImpl` a fetch that never answers and asserts it settles at its deadline, and that a caller's abort ends it as a cancel, not a timeout (a new `source-*.js` module that takes `fetchImpl` must be added to its table); `build.test.mjs` holds that the build's cancel reaches the aerial, relief and elevation requests; `map-index.test.mjs`, `arcgis-pager.test.mjs` and `registry.test.mjs` hold the quad paging cap, the pager deadline and the per-source `timeoutMs` rule. +`request.test.mjs` covers the helper (timeout, abort, cleanup, no `AbortSignal.any`, `anyStatus`, `orThrow`, latest-wins); `no-raw-fetch.test.mjs` fails on any raw `fetch(` or `fetchImpl(` call in that directory outside `request.js` and its `ALLOW` list (the seven modules named above; an entry fails once it stops holding a raw call, so the list cannot rot); `adapter-deadlines.test.mjs` hands every adapter that takes `fetchImpl` a fetch that never answers and asserts it settles at its deadline, and that a caller's abort ends it as a cancel, not a timeout (a new `source-*.js` module that takes `fetchImpl` must be added to its table); `build.test.mjs` holds that the build's cancel reaches the aerial, relief and elevation requests; `map-index.test.mjs`, `arcgis-pager.test.mjs` and `registry.test.mjs` hold the quad paging cap, the pager deadline and the per-source `timeoutMs` rule. + +`main.js` makes no raw `fetch` call: the camera index and files, the camera still and the base-map tile probe go through `fetchWithDeadline`, and the PDF build is handed `globalThis.fetch` bound. A source-check pass owns one `AbortController` (`state.passAbort`) that `setArea` and `clearArea` abort, and every adapter call in the pass gets its signal, so replacing the area cancels the pass's requests; a pass that was cancelled returns before its catch blocks write source state. The quad-name search is latest-wins (`createLatest`) and says "Searching…" the moment it starts. The aurora and radar answers are checked against their switch after the await, and two quick toggles of the ecoregions layer share one request. `request-races-browser.test.mjs` proves the three that need a real network: it holds requests with `Fetch.requestPaused` and checks (a) two quad searches answered in reverse order show the second, (b) aurora switched on and off before its answer is never drawn (with an on-the-whole-way control), (c) clearing the area mid-pass ends the pass's request as `Network.loadingFailed` with `canceled: true`. Every wait is a condition, never a fixed sleep. It needs the built site (`bash scripts/build-site.sh --configDir site-config --minify`) and skips without Chromium. ## Accessibility checks diff --git a/scripts/qcm/no-raw-fetch.test.mjs b/scripts/qcm/no-raw-fetch.test.mjs index 4ba5de9..e81832b 100644 --- a/scripts/qcm/no-raw-fetch.test.mjs +++ b/scripts/qcm/no-raw-fetch.test.mjs @@ -10,16 +10,13 @@ import { join } from 'node:path'; const DIR = new URL('../../themes/headlessmode/static/js/quiet-corners-maps/', import.meta.url); const HELPER = 'request.js'; -// PR-B2 moves main.js onto request.js and removes this entry (and the list). Until then the -// page's own call sites (camera snapshot, the tile probe, the print adapters) are the only raw calls. -// -// The other seven entries are modules that reached master after QQA-13 was written (the E-P1..E-P6 -// field-map layers and the finder's street-address lookup). Each still owns a raw call and its own -// signal, so none has a deadline from request.js yet. Moving them onto fetchWithDeadline is a -// follow-up; until then they are listed so this test guards every other module, and the stale-entry -// test below drops an entry the day its raw call goes. +// main.js was the last module of the original set with raw calls and now goes through request.js +// too. The seven entries below reached master after QQA-13 was written (the E-P1..E-P6 field-map +// layers and the finder's street-address lookup). Each still owns a raw call and its own signal, +// so none has a deadline from request.js yet. Moving them onto fetchWithDeadline is a follow-up; +// until then they are listed so this test guards every other module. An entry is a debt with a +// reason beside it, and the stale-entry test below fails once it is paid. const ALLOW = [ - 'main.js', 'address-geocode.js', 'source-avalanche.js', 'source-nrhp.js', 'source-ntad-rail-airports.js', 'source-nws-observations.js', 'source-snotel.js', 'source-stream-gauges.js', diff --git a/scripts/qcm/request-races-browser.test.mjs b/scripts/qcm/request-races-browser.test.mjs new file mode 100644 index 0000000..8960a02 --- /dev/null +++ b/scripts/qcm/request-races-browser.test.mjs @@ -0,0 +1,220 @@ +// QQA-13 PR-B part 2: the map page's own remote reads, held at the network layer in a real +// Chromium, so a slow answer is a state the test controls and not a race it hopes to win. +// +// Fetch.requestPaused parks a request until the test answers it (the pattern +// camera-requests.test.mjs and dashboard-load-failure.test.mjs use; helpers/cdp-session.mjs +// is the persistent-subscription session). Three things are proved: +// (a) two quad searches, the first answered LAST: the list shows the second, and the +// search says "Searching..." the moment it starts; +// (b) a layer switched off before its answer arrives never reaches the map (aurora); +// (c) the area changing mid-pass cancels the pass's requests (Network.loadingFailed with +// canceled: true), instead of leaving them to finish and write to a replaced area. +// Every wait is a condition on an event or on the page; nothing here sleeps for a fixed time. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { findChromium } from './helpers/browser.mjs'; +import { connectSession } from './helpers/cdp-session.mjs'; +import { startSiteServer, siteIsBuilt } from './helpers/site-server.mjs'; +import { readyDashboard, waitInPage, chooseExampleArea } from './helpers/waits.mjs'; +import { settlePaused } from './helpers/paused-request.mjs'; +import { SOURCES } from '../../themes/headlessmode/static/js/quiet-corners-maps/source-registry.js'; + +const CHROMIUM = findChromium(); +const skip = !CHROMIUM ? 'no Chromium on this box (set CHROME_PATH to run this proof)' + : !siteIsBuilt('quiet-corners') ? 'public/quiet-corners/ not built — run `bash scripts/build-site.sh --configDir site-config --minify` first' + : false; + +const QUAD_QUERY = SOURCES.usgsQuadIndex.layers[0].queryUrl; +const AURORA_URL = SOURCES.swpcAurora.serviceUrl; +// auroraLayer draws one green rectangle per visible cell. The map prefers canvas, so they are +// not in the DOM: they are counted on the map itself. +const AURORA_CELLS = `(() => { let n = 0; window.qcMap.eachLayer((l) => { if (l.options && l.options.color === '#7ee081') n++; }); return n; })()`; + +const quadFeature = (id, name) => ({ + attributes: { CELL_ID: id, CELL_NAME: name, PRIMARY_STATE: 'CO', STATE_ALPHA: 'CO', CELL_MAPCODE: 'X' }, + geometry: { rings: [[[-105.125, 39.875], [-105, 39.875], [-105, 40], [-105.125, 40], [-105.125, 39.875]]] }, +}); +// One visible cell at the centre of the view the test sets. The grid's longitudes run 0..359. +const AURORA_BODY = { + 'Observation Time': '2026-10-08T10:00:00Z', 'Forecast Time': '2026-10-08T11:06:00Z', + coordinates: [[260, 40, 12]], +}; + +const until = async (what, ok, ms = 20000) => { + const end = performance.now() + ms; + for (;;) { + const got = await ok(); + if (got) return got; + if (performance.now() >= end) throw new Error(`waited ${ms} ms and ${what} never came true`); + await new Promise((r) => setTimeout(r, 25)); + } +}; + +/** Chromium is asked to quit before the session removes its profile: killed mid-write it can + * still be filling the cache directory when the profile is deleted, and the removal then + * fails with ENOTEMPTY. That one failure is a teardown race and says nothing about the test. */ +async function closePage(session, site) { + try { await session.send('Browser.close'); } catch { /* already gone, or a page target that cannot */ } + try { session.close(); } catch (err) { if (err?.code !== 'ENOTEMPTY') throw err; } + await site.close(); +} + +/** Opens the page with every cross-origin fetch the page makes under the test's control. + * `holds` lists the URL prefixes whose requests are parked; `holdAllFetches()` parks every + * cross-origin fetch from then on (the source-check pass). Anything not parked goes on to + * the local site server, or fails offline like every other browser proof here. */ +async function openPage() { + const site = await startSiteServer(); + const docUrl = `${site.origin}/quiet-corners/`; + const session = await connectSession(); + const held = []; // { url, requestId, answered } + const urls = new Map(); // network request id -> url + const ended = new Map(); // url -> 'finished' | 'canceled' | 'failed' + let prefixes = [], everything = false; + try { + await session.send('Page.enable'); + await session.send('Network.enable'); + await session.send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false }); + session.on('Network.requestWillBeSent', (p) => { urls.set(p.requestId, p.request.url); }); + session.on('Network.loadingFinished', (p) => { if (urls.has(p.requestId)) ended.set(urls.get(p.requestId), 'finished'); }); + session.on('Network.loadingFailed', (p) => { if (urls.has(p.requestId)) ended.set(urls.get(p.requestId), p.canceled ? 'canceled' : 'failed'); }); + session.on('Fetch.requestPaused', async (p) => { + const { requestId, request, responseStatusCode, responseHeaders } = p; + if (responseStatusCode !== undefined) { + // The shipped page carries its policy as a alone; drop the header the server adds. + const headers = (responseHeaders || []).filter((h) => h.name.toLowerCase() !== 'content-security-policy'); + await settlePaused(session.send, 'Fetch.continueResponse', { requestId, responseCode: responseStatusCode, responseHeaders: headers }); + return; + } + const foreign = !request.url.startsWith(site.origin); + const isFetch = p.resourceType === 'Fetch' || p.resourceType === 'XHR'; + if (foreign && (prefixes.some((x) => request.url.startsWith(x)) || (everything && isFetch))) { + held.push({ url: request.url, requestId, answered: false }); + return; + } + await settlePaused(session.send, 'Fetch.continueRequest', { requestId }); + }); + await session.send('Fetch.enable', { patterns: [{ urlPattern: '*', requestStage: 'Request' }, { urlPattern: docUrl, requestStage: 'Response' }] }); + await session.navigate(docUrl); + await readyDashboard(session, { ms: 60000 }); + } catch (err) { await closePage(session, site); throw err; } + return { + session, held, ended, + hold: (...list) => { prefixes = list; }, + holdAllFetches: () => { everything = true; }, + release: () => { prefixes = []; everything = false; }, + /** Answers one parked request with a JSON body (a no-op when the page already cancelled it). */ + answer: async (entry, body) => { + entry.answered = true; + await settlePaused(session.send, 'Fetch.fulfillRequest', { + requestId: entry.requestId, responseCode: 200, + responseHeaders: [{ name: 'content-type', value: 'application/json' }, { name: 'access-control-allow-origin', value: '*' }], + body: Buffer.from(JSON.stringify(body)).toString('base64'), + }); + }, + /** Counts, in the page, every body read (`Response.json()`) that completes for a URL under + * `prefix`. Everything the page does with an answer after that read is promise + * continuations, which run before the next event-loop turn, so once the count moves and + * `settle()` has returned the continuation has run: a "nothing was drawn" assertion made + * then is a statement about the finished continuation, not about a slow box. */ + watchBodyReads: (prefix) => session.evaluate(`(() => { + window.__bodyReads = 0; + const orig = Response.prototype.json; + Response.prototype.json = function () { + const match = this.url.startsWith(${JSON.stringify(prefix)}); + return orig.call(this).then((v) => { if (match) window.__bodyReads++; return v; }); + }; + return true; + })()`), + bodyReads: () => session.evaluate('window.__bodyReads'), + /** One full turn of the page's event loop, twice over: whatever a landed answer was going + * to do on screen has been done by the time this returns. */ + settle: () => session.evaluate(`(async () => { + for (let i = 0; i < 2; i++) await new Promise((r) => { const c = new MessageChannel(); c.port1.onmessage = () => setTimeout(r, 0); c.port2.postMessage(0); }); + return true; + })()`), + close: () => closePage(session, site), + }; +} + +const heldFor = (page, prefix) => page.held.filter((h) => h.url.startsWith(prefix)); +const searchFor = (page, name) => page.session.evaluate(`(() => { + document.getElementById('qcm-quad-name').value = ${JSON.stringify(name)}; + document.getElementById('qcm-quad').click(); + return document.getElementById('qcm-status').textContent; +})()`); +const quadList = (page) => page.session.evaluate(`[...document.querySelectorAll('#qcm-quad-results li')].map((li) => li.textContent)`); + +test('quad search: the first answer arriving last never overwrites the second, and the search says it is searching', { skip }, async () => { + const page = await openPage(); + try { + page.hold(QUAD_QUERY); + const statusAfterFirst = await searchFor(page, 'Alpha Peak'); + const first = await until('the first search request', () => heldFor(page, QUAD_QUERY)[0]); + await searchFor(page, 'Bravo Peak'); + const second = await until('the second search request', () => heldFor(page, QUAD_QUERY)[1]); + assert.notEqual(first.url, second.url); + + // The second answer lands first and is drawn; the first lands after it. + await page.answer(second, { features: [quadFeature('2', 'Bravo Peak')] }); + await waitInPage(page.session, 'the second search to be listed', `document.querySelectorAll('#qcm-quad-results li').length === 1`, 20000, null); + await page.answer(first, { features: [quadFeature('1', 'Alpha Peak')] }); + await until('the first request to end', () => page.ended.get(first.url)); + await page.settle(); + + assert.deepEqual(await quadList(page), ['Bravo Peak (CO)'], 'the list must show the latest search only'); + assert.equal(await page.session.evaluate(`document.getElementById('qcm-status').textContent`), '1 matching quadrangles; choose one.'); + assert.equal(page.ended.get(first.url), 'canceled', 'the replaced search must be cancelled, not left to finish'); + assert.match(statusAfterFirst, /Searching/, 'the status must say "Searching…" the moment the search starts'); + } finally { await page.close(); } +}); + +test('aurora: switched on and off before its answer arrives, the layer is never added', { skip }, async () => { + const page = await openPage(); + try { + const { session } = page; + // A view over the lower 48 with the fixture's one visible cell in it. + await session.evaluate(`(async () => { const s = qcMoves(); window.qcMap.setView([40, -100], 6, { animate: false }); await s('the map to settle'); return true; })()`); + await page.watchBodyReads(AURORA_URL); + page.hold(AURORA_URL); + await session.evaluate(`document.getElementById('qcm-context-swpcAurora').click(); void 0;`); + const asked = await until('the aurora request', () => heldFor(page, AURORA_URL)[0]); + await session.evaluate(`document.getElementById('qcm-context-none').click(); void 0;`); + assert.equal(await session.evaluate(`document.getElementById('qcm-context-swpcAurora').checked`), false); + + await page.answer(asked, AURORA_BODY); + await until('the aurora request to end', () => page.ended.get(asked.url)); + // The positive signal: the page has read the answer's body, so what is left of renderAurora's + // continuation (the guard, or the draw it guards) is promise work that settle() lets finish. + await until('the page to finish reading the aurora answer', async () => (await page.bodyReads()) > 0); + await page.settle(); + assert.equal(await session.evaluate(AURORA_CELLS), 0, 'a layer switched off before its answer must not be drawn'); + assert.equal(await session.evaluate(`document.getElementById('qcm-layer-swpcAurora-report').hidden`), true, 'nor its report row shown'); + + // The control: the same answer, with the layer left on, does draw -- or the zero above proves nothing. + page.release(); + await session.evaluate(`document.getElementById('qcm-context-swpcAurora').click(); void 0;`); + await until('the aurora cell to be drawn', async () => { + for (const h of heldFor(page, AURORA_URL)) if (!h.answered) await page.answer(h, AURORA_BODY); + return (await session.evaluate(AURORA_CELLS)) > 0; + }); + } finally { await page.close(); } +}); + +test('source check: changing the area mid-pass cancels the pass\'s requests', { skip }, async () => { + const page = await openPage(); + try { + const { session } = page; + await chooseExampleArea(session); + await waitInPage(session, 'Check sources to enable', `!document.getElementById('qcm-check').disabled`, 20000, null); + page.holdAllFetches(); + await session.evaluate(`document.getElementById('qcm-check').click(); void 0;`); + const inFlight = await until('the pass to ask for something', () => page.held[0]); + + // The reader clears the area while the pass is still waiting on its first answer. + await session.evaluate(`document.getElementById('qcm-area-clear').click(); void 0;`); + await until('the pass\'s request to be cancelled', () => page.ended.has(inFlight.url)); + assert.equal(page.ended.get(inFlight.url), 'canceled', `the first pass's request must be cancelled, not left to finish: ${inFlight.url}`); + page.release(); + } finally { await page.close(); } +}); diff --git a/scripts/qcm/site-coupling.json b/scripts/qcm/site-coupling.json index 519cd61..80fb89c 100644 --- a/scripts/qcm/site-coupling.json +++ b/scripts/qcm/site-coupling.json @@ -75,6 +75,7 @@ "scripts/qcm/print-clear-journeys-browser.test.mjs", "scripts/qcm/print-options.test.mjs", "scripts/qcm/redactcam-fallback.test.mjs", + "scripts/qcm/request-races-browser.test.mjs", "scripts/qcm/road-labels-browser.test.mjs", "scripts/qcm/route-measure-browser.test.mjs", "scripts/qcm/saved-places-browser.test.mjs", diff --git a/themes/headlessmode/static/js/quiet-corners-maps/main.js b/themes/headlessmode/static/js/quiet-corners-maps/main.js index ec1edf9..6a85e7e 100644 --- a/themes/headlessmode/static/js/quiet-corners-maps/main.js +++ b/themes/headlessmode/static/js/quiet-corners-maps/main.js @@ -36,7 +36,8 @@ import { fetchSurfaceOwnership } from './source-surface-ownership.js'; import { LAYERS, LAYER_IDS, LAYER_BY_ID, GROUPS, layerControlSpecs, baseControlSpecs, contextControlSpecs, CONTEXT_LAYER_IDS, DEFAULT_CONTEXT, surfaceCaption, enabledLayers, LAYERS_WITH_DRAPE, groupHint } from './layer-registry.js'; import { filterLayerSpecs, layerBadge, layerCountLine, selectedScreenRows, layerAboutText, rowSummaryLine, groupIsCollapsedByDefault } from './layer-panel.js'; -import { SOURCES } from './source-registry.js'; +import { SOURCES, DEFAULT_SOURCE_TIMEOUT_MS } from './source-registry.js'; +import { fetchWithDeadline, createLatest } from './request.js'; import { floodZonesLayer, floodReader, readZones as readFloodZones, areaReport as floodAreaReport } from './source-flood-zones.js'; import { PRESETS, presetApplied, presetPreviewText, presetLeavesAlone, opensOn } from './layer-presets.js'; import { DEFAULT_BASE, baseLayer, zoomStatus as baseZoomStatus } from './source-basemaps.js'; @@ -135,7 +136,7 @@ let lastSave = Promise.resolve(); const savedStore = createSavedPlacesStore({ storage: typeof localStorage === 'undefined' ? null : localStorage }); let savedPanel = null; const CORNERS = { 'min-lat': 'minLat', 'max-lat': 'maxLat', 'min-lon': 'minLon', 'max-lon': 'maxLon' }; -const state = { routes: [], roadsPlaced: [], bbox: null, area: null, quadNames: new Map(), areaUndo: null, areaGeneration: 0, recipe: null, plan: null, states: { usgsTopo: 'unchecked', mvum: 'unchecked' }, counts: {}, reports: {}, areaStates: [], mvum: {}, roadQuality: {}, recSites: [], trails: null, land: [], fetchedFor: null, abort: null, names: null, namesFor: null, namesGeneration: 0, namesRetrievedAt: null, namesPlaced: [], selectedNameId: null, retrievedAt: {}, +const state = { routes: [], roadsPlaced: [], bbox: null, area: null, quadNames: new Map(), areaUndo: null, areaGeneration: 0, recipe: null, plan: null, states: { usgsTopo: 'unchecked', mvum: 'unchecked' }, counts: {}, reports: {}, areaStates: [], mvum: {}, roadQuality: {}, recSites: [], trails: null, land: [], fetchedFor: null, abort: null, names: null, namesFor: null, namesGeneration: 0, namesRetrievedAt: null, passAbort: null, namesPlaced: [], selectedNameId: null, retrievedAt: {}, // QUX-05c: the full GNIS hit chosen through the "Search places" finder // (quiet-corners.js's selectNameHit -> window.qcSelectName), independent of // the toggleable "Named places & landmarks" layer -- see @@ -229,7 +230,7 @@ let radarFrameMs = null; /** The Level III ecoregions, held from the one fetch that got them. 949,718 bytes for the * whole country, so the export is asked for when the switch goes on and never again: a pan * is not a new question when the answer is national. `null` until then. */ -let ecoregions = null; +let ecoregions = null, ecoregionsPending = null; /** The SURFACE's own time, per surface, held so the caption can be rewritten without asking * the service again. Each is the time that surface's `claim` says is the honest one: the * aurora's forecast time, the air quality's observed hour. Null means the service did not @@ -659,6 +660,7 @@ function setArea(area, { title, fit = false, recordUndo = true, announce } = {}) state.area = area; state.bbox = areaEnvelope(area); // the envelope every existing reader (terrain getter, sourcePass, canBuild) keeps reading state.areaGeneration = (state.areaGeneration ?? 0) + 1; + state.passAbort?.abort(); // the source pass for the area just replaced stops asking if (title) $('qcm-title').value = title; const b = state.bbox; for (const [k, n] of Object.entries(CORNERS)) $(`qcm-${k}`).value = b[n].toFixed(4); @@ -804,6 +806,7 @@ function clearArea() { state.area = null; state.bbox = null; state.areaGeneration = (state.areaGeneration ?? 0) + 1; + state.passAbort?.abort(); selection.remove(); for (const h of handles) h.remove(); cells.clearLayers(); quadCells.clearLayers(); quadRing.clearLayers(); sheets.clearLayers(); @@ -1723,6 +1726,7 @@ async function renderRadar() { const el = $('qcm-layer-nwsRadar-report'); if (!el) return; radarFrameMs = await fetchFrameTime({}); + if (!layersFromControls().nwsRadar) return; // switched off while the frame time was in flight renderSurfaceCaption(); // the frame's own time is what the caption shows, not when we asked const vintage = frameVintage(radarFrameMs); el.textContent = vintage.text; @@ -1741,9 +1745,9 @@ function loadCameraIndex() { if (cameraIndex) return Promise.resolve(cameraIndex); if (!cameraIndexPending) { cameraIndexPending = (async () => { - const res = await fetch(`${CAMERA_DIR}index.json`, { cache: 'no-cache' }); - if (!res.ok) throw new Error(`The camera index returned ${res.status}.`); - const body = await res.json(); + const got = await fetchWithDeadline(`${CAMERA_DIR}index.json`, { init: { cache: 'no-cache' }, timeoutMs: DEFAULT_SOURCE_TIMEOUT_MS }); + if (!got.ok) throw new Error(got.httpStatus ? `The camera index returned ${got.httpStatus}.` : got.error); + const body = got.body; if (!body || !Array.isArray(body.files)) throw new Error('The camera index is not in the expected shape.'); cameraIndex = body; return body; @@ -1758,9 +1762,9 @@ function loadCameraFile(row) { if (hit) return Promise.resolve(hit); if (!cameraFilePending.has(row.file)) { cameraFilePending.set(row.file, (async () => { - const res = await fetch(`${CAMERA_DIR}${row.file}`, { cache: 'no-cache' }); - if (!res.ok) throw new Error(`${row.file} returned ${res.status}.`); - const body = await res.json(); + const got = await fetchWithDeadline(`${CAMERA_DIR}${row.file}`, { init: { cache: 'no-cache' }, timeoutMs: DEFAULT_SOURCE_TIMEOUT_MS }); + if (!got.ok) throw new Error(got.httpStatus ? `${row.file} returned ${got.httpStatus}.` : got.error); + const body = got.body; const entry = { row, cameras: expandSnapshot(body), generatedAt: body.generatedAt, group: null }; cameraFiles.set(row.file, entry); return entry; @@ -2030,12 +2034,14 @@ function wireCameraPin(pin, camera, meta = {}) { let timedOut = false; const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, PICTURE_TIMEOUT_MS); try { - const res = await fetch(camera.media.still, { cache: 'no-cache', signal: ctrl.signal }); - if (ctrl.signal.aborted) return; - if (!res.ok) throw new Error(`${row.operator}'s server returned ${res.status}.`); - const lastModified = res.headers.get('Last-Modified'); - const blob = await res.blob(); - if (ctrl.signal.aborted) return; + // The still is read as a blob inside the card's own picture limit (the same 20 s as the timer above, not the 10 s a data source gets); Last-Modified rides along for the age line. + const got = await fetchWithDeadline(camera.media.still, { + init: { cache: 'no-cache' }, signal: ctrl.signal, timeoutMs: PICTURE_TIMEOUT_MS, + read: async (res) => ({ lastModified: res.headers.get('Last-Modified'), blob: await res.blob() }), + }); + if (ctrl.signal.aborted && !timedOut) return; // closed by the reader; the picture limit still reports below + if (!got.ok) throw new Error(got.httpStatus ? `${row.operator}'s server returned ${got.httpStatus}.` : got.error); + const { lastModified, blob } = got.body; cameraOpenUrl = URL.createObjectURL(blob); const img = document.createElement('img'); img.src = cameraOpenUrl; @@ -2129,6 +2135,9 @@ async function renderAurora() { } // Fetched only on the way in. A pan redraws what we already hold. if (!auroraBody) auroraBody = await fetchAurora({}); + // The answer may land after the reader switched the layer off (or picked another surface): + // it is kept for the next time, but nothing is drawn for a row that is no longer on. + if (!layersFromControls().swpcAurora) return; // The forecast time, never the observation time 66 minutes behind it and never the // moment we fetched: this surface's claim is 'forecast', so that is the time it owns. auroraForecastMs = readForecast(auroraBody).forecastMs ?? null; @@ -2699,7 +2708,11 @@ async function renderEcoregions() { const layers = layersFromControls(); const el = readingRow('qcm-layer-epaEcoregions-report', !!layers.epaEcoregions); if (!el) return; - if (!ecoregions) ecoregions = await loadEcoregions({}); + if (!ecoregions) { + // Two quick toggles share the one request instead of starting a second 950 KB read. + ecoregionsPending ??= loadEcoregions({}).finally(() => { ecoregionsPending = null; }); + ecoregions = await ecoregionsPending; + } if (!layersFromControls().epaEcoregions || !state.bbox) return; const b = state.bbox; const at = { lat: (b.minLat + b.maxLat) / 2, lon: (b.minLon + b.maxLon) / 2 }; @@ -4053,6 +4066,10 @@ async function sourcePass({ bboxLonLat = null, browse = false } = {}) { const { plan, recipe } = state; const box = bboxLonLat || recipe?.bboxLonLat; if (!box || (!browse && !plan)) return; + // One controller per pass: setArea/clearArea abort it, so the requests of a pass whose area has + // been replaced are cancelled at the network instead of finishing for nothing. + const ctrl = new AbortController(), signal = ctrl.signal; + state.passAbort = ctrl; const layers = bboxLonLat ? layersFromControls() : recipe.layers; // The base map's answer is a question about the SHEET — which tile level the // scale picks — so browsing must not throw it away and must not re-ask it. Read @@ -4061,31 +4078,37 @@ async function sourcePass({ bboxLonLat = null, browse = false } = {}) { let topo = state.states.usgsTopo; if (!browse) { $('qcm-check').disabled = true; say('Checking sources…'); resetSources(); } else clearFetched(); if (!browse) { - let ok = false; - try { ok = (await fetch(tilePlan(plan).tiles.unique[0].url)).ok; } catch { /* unreachable is "unavailable" */ } + // An unreachable server, a timeout and an HTTP error all read as "unavailable". So does a + // plan with no tile to ask for: a throw here would leave Check sources disabled for good. + let url = null; + try { url = tilePlan(plan).tiles.unique[0].url; } catch { /* no tile is "unavailable" */ } + const ok = url !== null && (await fetchWithDeadline(url, { + signal, timeoutMs: SOURCES.usgsTopo.timeoutMs, read: async () => null, + })).ok; + if (stale()) return; topo = ok ? 'ready' : 'unavailable'; } // Which states the area covers, from the quad index this page already uses. // A failure here costs the state land layers, never the base map. - try { state.areaStates = statesForCells((await fetchCellsForBbox({ bboxLonLat: box })).cells); } - catch { state.areaStates = []; } + try { state.areaStates = statesForCells((await fetchCellsForBbox({ bboxLonLat: box, signal })).cells); } + catch { if (stale()) return; state.areaStates = []; } if (stale()) return; renderLayerControls(); state.states.usgsTopo = topo; state.counts.usgsTopo = null; // a raster base has no feature count to report if (layers.mvum) { try { - const [roads, trails] = await Promise.all(['roads', 'trails'].map((layer) => fetchLayer({ layer, bboxLonLat: box }))); + const [roads, trails] = await Promise.all(['roads', 'trails'].map((layer) => fetchLayer({ layer, bboxLonLat: box, signal }))); if (stale()) return; state.mvum = { roads, trails }; state.counts.mvum = roads.features.length + trails.features.length; // Road quality is detail, not a gate: if the inventory is down the roads still // print, at their plain width. Never let it fail the source check. - try { state.roadQuality = await fetchRoadQuality({ bboxLonLat: box }); } - catch { state.roadQuality = {}; } + try { state.roadQuality = await fetchRoadQuality({ bboxLonLat: box, signal }); } + catch { if (stale()) return; state.roadQuality = {}; } const cov = [roads.coverage, trails.coverage]; state.states.mvum = cov.every((c) => c === 'complete') ? 'ready' : cov.includes('unavailable') ? 'unavailable' : cov.includes('unknown') ? 'unknown' : 'partial'; - } catch (err) { state.states.mvum = 'unknown'; fail(err); } + } catch (err) { if (stale()) return; state.states.mvum = 'unknown'; fail(err); } } if (stale()) return; if (layers.recSites) { @@ -4093,11 +4116,11 @@ async function sourcePass({ bboxLonLat = null, browse = false } = {}) { // whenever the fetch did not throw, so a box past the transfer limit printed a // sheet missing sites while the source panel called the layer ready (F14). try { - const answer = await fetchRecSites({ bboxLonLat: box }); + const answer = await fetchRecSites({ bboxLonLat: box, signal }); if (stale()) return; state.recSites = answer.sites; state.states.recSites = answer.coverage === 'complete' ? 'ready' : 'partial'; - } catch { state.recSites = []; state.states.recSites = 'unavailable'; } + } catch { if (stale()) return; state.recSites = []; state.states.recSites = 'unavailable'; } state.counts.recSites = state.recSites.length; } if (stale()) return; @@ -4105,22 +4128,22 @@ async function sourcePass({ bboxLonLat = null, browse = false } = {}) { // designation families share `state.land`: downstream both are a point with a symbol. if (layers.blmRecSites) { try { - const answer = await fetchBlmRecSites({ bboxLonLat: box }); + const answer = await fetchBlmRecSites({ bboxLonLat: box, signal }); if (stale()) return; state.recSites.push(...answer.sites); state.counts.blmRecSites = answer.sites.length; state.states.blmRecSites = answer.coverage === 'complete' ? 'ready' : 'partial'; - } catch { state.counts.blmRecSites = 0; state.states.blmRecSites = 'unavailable'; } + } catch { if (stale()) return; state.counts.blmRecSites = 0; state.states.blmRecSites = 'unavailable'; } } if (stale()) return; if (layers.trails) { try { - const result = await fetchTrails({ bboxLonLat: box }); + const result = await fetchTrails({ bboxLonLat: box, signal }); if (stale()) return; state.trails = result; state.counts.trails = state.trails.features.length; state.states.trails = state.trails.coverage === 'complete' ? 'ready' : 'partial'; - } catch (err) { state.states.trails = 'unavailable'; fail(err); } + } catch (err) { if (stale()) return; state.states.trails = 'unavailable'; fail(err); } } if (stale()) return; // Federal designations. One national service, so no per-state fan-out: the four @@ -4130,7 +4153,7 @@ async function sourcePass({ bboxLonLat = null, browse = false } = {}) { if (layers.federalLand) { let count = 0, worst = null; const answers = await Promise.allSettled(FEDERAL_IDS.map( - (family) => fetchFederalLand({ family, bboxLonLat: box }))); + (family) => fetchFederalLand({ family, bboxLonLat: box, signal }))); if (stale()) return; for (const a of answers) { if (a.status === 'rejected') { worst = 'unavailable'; fail(a.reason); continue; } @@ -4148,12 +4171,12 @@ async function sourcePass({ bboxLonLat = null, browse = false } = {}) { // registry says emptyMeans 'unknown' for exactly that reason. if (layers.countyLines) { try { - const answer = await fetchCountyLines({ bboxLonLat: box }); + const answer = await fetchCountyLines({ bboxLonLat: box, signal }); if (stale()) return; state.land.push(...answer.features); state.counts.countyLines = answer.features.length; state.states.countyLines = answer.coverage === 'complete' ? 'ready' : 'partial'; - } catch (err) { state.states.countyLines = 'unavailable'; fail(err); } + } catch (err) { if (stale()) return; state.states.countyLines = 'unavailable'; fail(err); } } if (stale()) return; // Surface ownership rides the same channel: boundaries with a symbolKey, into @@ -4161,12 +4184,12 @@ async function sourcePass({ bboxLonLat = null, browse = false } = {}) { // never prints as though it were the whole forest. if (recipe.layers.surfaceOwnership) { try { - const answer = await fetchSurfaceOwnership({ bboxLonLat: recipe.bboxLonLat }); + const answer = await fetchSurfaceOwnership({ bboxLonLat: recipe.bboxLonLat, signal }); if (stale()) return; state.land.push(...answer.features); state.counts.surfaceOwnership = answer.features.length; state.states.surfaceOwnership = answer.coverage === 'complete' ? 'ready' : 'partial'; - } catch (err) { state.states.surfaceOwnership = 'unavailable'; fail(err); } + } catch (err) { if (stale()) return; state.states.surfaceOwnership = 'unavailable'; fail(err); } } if (stale()) return; // Wetlands ride the same channel -- shapes with a symbolKey, into `state.land`. @@ -4178,14 +4201,14 @@ async function sourcePass({ bboxLonLat = null, browse = false } = {}) { // mapped") is kept for the layer's row. if (layers.wetlands) { try { - const answer = await fetchWetlands({ bboxLonLat: box }); + const answer = await fetchWetlands({ bboxLonLat: box, signal }); if (stale()) return; state.land.push(...answer.features); state.counts.wetlands = answer.features.length; state.reports.wetlands = answer.report.text; state.states.wetlands = answer.refused ? 'unavailable' : answer.coverage === 'complete' ? 'ready' : 'partial'; - } catch (err) { state.states.wetlands = 'unavailable'; fail(err); } + } catch (err) { if (stale()) return; state.states.wetlands = 'unavailable'; fail(err); } } if (stale()) return; // Named terrain & places (GNIS). A browse pass skips them: runNames asks for the view on its own @@ -4193,7 +4216,7 @@ async function sourcePass({ bboxLonLat = null, browse = false } = {}) { if (layers.names && !browse) { const token = ++state.namesGeneration; try { - const result = await fetchNamesFor(box, PRINT_NAMES_ZOOM); + const result = await fetchNamesFor(box, PRINT_NAMES_ZOOM, signal); if (isStaleGeneration(token, state.namesGeneration) || stale()) { /* superseded; drop it */ } else { applyNamesResult(result, box); namesFetched = null; // the held view answer is gone; the next move asks the view again @@ -4213,7 +4236,7 @@ async function sourcePass({ bboxLonLat = null, browse = false } = {}) { let count = 0, worst = null; for (const code of codes) { try { - const r = await fetchLandBoundaries({ state: code, family, bboxLonLat: box }); + const r = await fetchLandBoundaries({ state: code, family, bboxLonLat: box, signal }); if (stale()) return; state.land.push(...r.features); count += r.features.length; @@ -4221,7 +4244,7 @@ async function sourcePass({ bboxLonLat = null, browse = false } = {}) { // and stopped. canBuild refuses it, so a truncated boundary set never prints. if (r.skipped) worst = 'unavailable'; else if (r.coverage !== 'complete' && worst !== 'unavailable') worst = 'partial'; - } catch (err) { worst = 'unavailable'; fail(err); } + } catch (err) { if (stale()) return; worst = 'unavailable'; fail(err); } } state.counts[family] = count; state.states[family] = worst || 'ready'; @@ -4283,7 +4306,7 @@ async function build() { const { pdfBytes, stats } = await runBuild({ recipe: state.recipe, plan: state.plan, PDFLib, signal: ctrl.signal, adapters: { - fetchImpl: (u, o) => fetch(u, o), createImageBitmap: (b) => createImageBitmap(b), + fetchImpl: globalThis.fetch.bind(globalThis), createImageBitmap: (b) => createImageBitmap(b), mvum: state.mvum, roadQuality: state.roadQuality, trails: state.trails, land: state.land, sites: state.recSites, names: state.recipe.layers.names ? namesAdapterPayload(state.names, state.namesRetrievedAt) : null, @@ -4298,9 +4321,15 @@ async function build() { } catch (err) { fail(err); } finally { busy(false); state.abort = null; renderSources(); } } +// Latest wins: a slower answer to an earlier name must not replace the list for the name typed since. +// The earlier request is cancelled the moment the next search begins, and the status says so at once. +const quadSearch = createLatest(); async function findQuad() { const ul = $('qcm-quad-results'); ul.replaceChildren(); ul.hidden = true; - const r = await searchByName({ name: $('qcm-quad-name').value }); + say('Searching…'); + const name = $('qcm-quad-name').value; + const { stale, value: r } = await quadSearch.run((signal) => searchByName({ name, signal })); + if (stale) return; if (!r.ok) { say(r.error); return; } if (!r.cells.length) { say('No quadrangle matches that name.'); return; } for (const c of r.cells) {