From 11bc2ec0ae848091c6ec16c74ecbd7980a89f91d Mon Sep 17 00:00:00 2001 From: cj-vana Date: Sun, 11 Oct 2026 20:58:02 -0600 Subject: [PATCH 1/6] feat(search): resolve two-dimensional geo URIs offline --- CHANGELOG.md | 4 ++ README.md | 2 +- docs/CURRENT-STATE.md | 9 +++- scripts/qa-location-controls.mjs | 59 +++++++++++++++++++++++++ src/search/coordinateParser.js | 16 +++++-- src/search/coordinateParser.test.mjs | 64 ++++++++++++++++++++++++++++ src/search/offlineGeocoders.test.mjs | 50 ++++++++++++++++++++++ 7 files changed, 198 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68ab1c52d..09646fc49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +- Location search accepts two-dimensional WGS84 `geo:` URIs offline, with + the existing coordinate framing and label. Unsupported URI fields are passed + to the remaining search providers unchanged. + - Treat explicitly current, complete empty vessel coverage as a successful update, clearing obsolete contacts while retaining stale or incomplete snapshots. diff --git a/README.md b/README.md index 5d97dfb2f..0bbb84abb 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ Start with the included data sources, then add your own. Each layer is a separat - **🔭 Satellite passes:** Ask by voice when any loaded satellite next rises over you: rise, peak and set times, and whether you can see it. - **🔎 Analyst answers:** Count, filter and rank satellites, datacenters and dams by voice, alongside flights, ships, fires and quakes; answers say when a feed is stale or on a fallback. - **🧭 Tilt and North Up:** Switch between straight-down and a 35° oblique, or put north at the top, while a tracked target stays centered. -- **🔍 Keyless search:** Type coordinates or a bundled city or landmark to fly there with no network request; other names use Google when configured, then Photon and Nominatim. +- **🔍 Keyless search:** Type decimal coordinates, a two-dimensional WGS84 `geo:` URI (for example, `geo:39.7392,-104.9903`), or a bundled city or landmark to fly there with no geocoder request; other names use Google when configured, then Photon and Nominatim. - **📦 Shareable scenes:** Import, preview and share Director scenes as files or bundles, with camera anchors, authored moves and data packs. - **🌊 Nepal flood scene:** Replay the Bhote Koshi flood: flood path, witness sources and before-and-after imagery (bundled data is non-commercial; see [DATA_SOURCES.md](DATA_SOURCES.md)). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 274294b5d..190223dd3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4141,12 +4141,17 @@ and unreachable upstream (502/504) separately from road geometry. Location search/fly-to, annotations and Radio location lookup receive one `placeSearch.geocode(query, { bias, signal })` service. `src/standalone` composes -two offline providers first — a strict decimal-degree coordinate reader and an +two offline providers first — a strict decimal-degree/geo-URI coordinate reader and an exact-match reader over the bundled city and landmark names — then Google when configured and Photon/OpenStreetMap as fallback, including Google transport failures or declined requests. The offline pair answer with no request and no key, and decline everything they are not certain of: a coordinate must be a -decimal-degree pair (either order when N/S/E/W fix the axes), and a bundled name +decimal-degree pair (either order when N/S/E/W fix the axes), or a two-dimensional +WGS84 `geo:latitude,longitude` URI with an optional `;crs=wgs84`. URI scheme and +CRS names are case-insensitive; internal whitespace, altitude, uncertainty, other +parameters, query strings and fragments decline rather than silently dropping +information. For example, `geo:39.7392,-104.9903` resolves offline to the same exact +point, decimal label and framing box as `39.7392, -104.9903`. A bundled name must match exactly, so `12junk, 34oops` and `austin, tx` both reach the geocoders instead of being answered from the bundle. Degrees/minutes/seconds and grid references are not parsed. An answer either provider gives is exact, so diff --git a/scripts/qa-location-controls.mjs b/scripts/qa-location-controls.mjs index 606d67b83..842a433a4 100644 --- a/scripts/qa-location-controls.mjs +++ b/scripts/qa-location-controls.mjs @@ -84,6 +84,64 @@ try { () => window.__godsEyeView.styleManager._activePoiIndex === 1, ), ); + const geocoderRequests = []; + const recordGeocoderRequest = (request) => { + const url = new URL(request.url()); + if ( + url.pathname === '/api/geocode' || + (url.hostname === 'photon.komoot.io' && url.pathname === '/api/') || + (url.hostname === 'maps.googleapis.com' && + url.pathname === '/maps/api/geocode/json') + ) + geocoderRequests.push(request.url()); + }; + page.on('request', recordGeocoderRequest); + await page.$eval('#location-search', (input) => { + input.value = 'geo:39.7392,-104.9903'; + input.focus(); + }); + await page.keyboard.press('Enter'); + await page.waitForFunction( + () => { + const state = + window.__godsEyeView.styleManager._locationLookup.getState(); + return ( + state.query === 'geo:39.7392,-104.9903' && state.status === 'found' + ); + }, + { timeout: 30000 }, + ); + await page.waitForFunction( + () => !window.__godsEyeView.viewer.camera._currentFlight, + { timeout: 10000 }, + ); + check( + 'native geo URI search shows the decimal coordinate readout', + await page.evaluate(() => { + const ui = window.__godsEyeView.styleManager; + return ( + ui._searchedLocationLabel === '39.7392° N, 104.9903° W' && + ui._locationMiniCity.textContent.includes('39.7392° N') && + ui._locationMiniPoi.textContent === '104.9903° W' + ); + }), + ); + check( + 'geo URI navigation settles near the requested coordinate', + await page.evaluate(() => { + const position = window.__godsEyeView.viewer.camera.positionCartographic; + const degrees = 180 / Math.PI; + return ( + Math.abs(position.latitude * degrees - 39.7392) < 0.05 && + Math.abs(position.longitude * degrees + 104.9903) < 0.05 + ); + }), + ); + check( + 'geo URI search makes no geocoder request', + geocoderRequests.length === 0, + ); + page.off('request', recordGeocoderRequest); await page.evaluate(() => { const lookup = window.__godsEyeView.styleManager._locationLookup; window.__qaSearchRequests = []; @@ -96,6 +154,7 @@ try { window.__qaSearchRequests.push({ query, options, resolve }), ); document.getElementById('location-search').value = 'First'; + document.getElementById('location-search').focus(); }); await page.keyboard.press('Enter'); await page.waitForFunction(() => window.__qaSearchRequests.length === 1); diff --git a/src/search/coordinateParser.js b/src/search/coordinateParser.js index 261e2394c..ebb4aee3f 100644 --- a/src/search/coordinateParser.js +++ b/src/search/coordinateParser.js @@ -5,7 +5,8 @@ * as 12, 34 flies the operator somewhere they did not ask for and looks like a * successful search. So every rule here is exact — a component is a decimal * number, optionally signed, optionally marked with one hemisphere letter, and - * nothing else. Degrees/minutes/seconds and grid references are not handled; + * nothing else. Two-dimensional WGS84 geo URIs use their own strict grammar. + * Degrees/minutes/seconds and grid references are not handled; * they fall through to the geocoders, which is what happens today. */ @@ -13,6 +14,10 @@ const COMPONENT = /^([NSEW])?\s*([+-]?(?:\d+(?:\.\d+)?|\.\d+))\s*°?\s*([NSEW])?$/i; +/** RFC 5870's 2D WGS84 subset; never discard altitude or uncertainty. */ +const GEO_URI = + /^geo:(-?\d{1,2}(?:\.\d+)?),(-?\d{1,3}(?:\.\d+)?)(?:;crs=wgs84)?$/i; + /** Hemisphere letters, by the axis each one can name. */ const LATITUDE_LETTERS = new Set(['N', 'S']); @@ -61,20 +66,25 @@ export function formatCoordinateLabel(lat, lon) { } /** - * Parse a decimal-degree query, or return null. + * Parse a decimal-degree pair or a two-dimensional WGS84 geo URI, or return null. * * Both orders are accepted when the hemisphere letters say which is which * (`W 40, N 74` is longitude then latitude); with no letters the conventional * latitude-then-longitude order applies. Two letters naming the same axis, a * letter that fixes nothing, an out-of-range latitude or longitude, and any * trailing text all decline. + * Geo URIs accept only latitude/longitude and an optional `;crs=wgs84`; + * altitude, uncertainty, other parameters and query/fragment suffixes decline. * * @param {string} query * @returns {{ lat: number, lon: number, label: string } | null} */ export function parseCoordinateQuery(query) { if (typeof query !== 'string') return null; - const parts = splitComponents(query); + const text = query.trim(); + const parts = /^geo:/i.test(text) + ? GEO_URI.exec(text)?.slice(1, 3) + : splitComponents(text); if (!parts) return null; const first = readComponent(parts[0]); diff --git a/src/search/coordinateParser.test.mjs b/src/search/coordinateParser.test.mjs index 393de37a4..7007a9b61 100644 --- a/src/search/coordinateParser.test.mjs +++ b/src/search/coordinateParser.test.mjs @@ -132,3 +132,67 @@ test('the poles and the dateline are inside the accepted range', () => { assert.deepEqual(parseCoordinateQuery('90, 180')?.lat, 90); assert.deepEqual(parseCoordinateQuery('-90, -180')?.lon, -180); }); + +test('two-dimensional WGS84 geo URIs resolve to the same decimal coordinate', () => { + for (const query of [ + 'geo:39.7392,-104.9903', + 'geo:39.7392,-104.9903;crs=wgs84', + ' GEO:39.7392,-104.9903;CRS=WGS84 ', + ]) { + assert.deepEqual(parseCoordinateQuery(query), { + lat: 39.7392, + lon: -104.9903, + label: '39.7392° N, 104.9903° W', + }); + } + for (const [query, decimal] of [ + ['geo:0,0', '0,0'], + ['geo:-33.8568,151.2153', '-33.8568,151.2153'], + ['geo:90,180', '90,180'], + ['geo:-90,-180;crs=wgs84', '-90,-180'], + ['geo:09,007', '9,7'], + ['geo:-0,-0', '-0,-0'], + ]) { + assert.deepEqual( + parseCoordinateQuery(query), + parseCoordinateQuery(decimal), + ); + } +}); + +test('malformed or unsupported geo URIs are refused without losing their meaning', () => { + for (const query of [ + 'geo:', + 'geo:39.7392', + 'geo:,104.9903', + 'geo:39.7392,,104.9903', + 'geo:39.7392,-104.9903,', + 'geo:91,0', + 'geo:-90.1,0', + 'geo:0,181', + 'geo:0,-180.1', + 'geo:000,0', + 'geo:0,0000', + 'geo:+39.7392,-104.9903', + 'geo:.5,-.25', + 'geo:1e1,2', + 'geo:NaN,0', + 'geo:Infinity,0', + 'geo:39.7392, -104.9903', + 'geo:39.7392;-104.9903', + 'geo:39.7392N,104.9903W', + 'geo:39.7392,-104.9903,1600', + 'geo:39.7392,-104.9903;u=10', + 'geo:39.7392,-104.9903;crs=unknown', + 'geo:39.7392,-104.9903;crs=wgs84;crs=wgs84', + 'geo:39.7392,-104.9903;crs=wgs84;u=0', + 'geo:39.7392,-104.9903;custom=value', + 'geo:0,0?q=39.7392,-104.9903(Denver)', + 'geo:39.7392,-104.9903#Denver', + 'geo:39.7392,-104.9903 trailing text', + 'geo://39.7392,-104.9903', + 'https://example.com/geo:39.7392,-104.9903', + ]) { + assert.equal(parseCoordinateQuery(query), null, query); + } +}); diff --git a/src/search/offlineGeocoders.test.mjs b/src/search/offlineGeocoders.test.mjs index f0d6a507a..2a01eac69 100644 --- a/src/search/offlineGeocoders.test.mjs +++ b/src/search/offlineGeocoders.test.mjs @@ -9,6 +9,7 @@ import assert from 'node:assert/strict'; import { createCoordinateGeocoder } from './coordinateGeocoder.js'; import { createPresetGeocoder } from './presetGeocoder.js'; import { createPlaceSearch } from './placeSearch.js'; +import { createDefaultPlaceSearch } from './defaults.js'; const PRESETS = { austin: { @@ -189,3 +190,52 @@ test('a cancelled search stops the offline providers too', async () => { ); } }); + +test('default search resolves a geo URI exactly without requesting a geocoder', async () => { + const requests = []; + const search = createDefaultPlaceSearch({ + resolveApiKey: () => 'test-key', + fetchImpl: async (url) => { + requests.push(url); + throw new Error('No network request should be needed'); + }, + }); + const { place, fallbackUsed } = await search.geocode( + 'geo:39.7392,-104.9903;crs=wgs84', + ); + assert.deepEqual(requests, []); + assert.equal(fallbackUsed, false); + assert.equal(place.lat, 39.7392); + assert.equal(place.lng, -104.9903); + assert.equal(place.exact, true); + assert.deepEqual(place.types, ['coordinate']); + assert.ok(place.viewport.southwest.lat < place.lat); + assert.ok(place.viewport.northeast.lat > place.lat); + assert.ok(place.viewport.southwest.lng < place.lng); + assert.ok(place.viewport.northeast.lng > place.lng); +}); + +test('unsupported geo URIs reach the next search provider unchanged', async () => { + const queries = [ + 'geo:39.7392,-104.9903,1600', + 'geo:39.7392,-104.9903;u=10', + 'geo:39.7392,-104.9903;crs=unknown', + 'geo:0,0?q=Denver', + ]; + const received = []; + const search = createPlaceSearch({ + providers: [ + createCoordinateGeocoder(), + { + async geocode(query) { + received.push(query); + return { place: null, answered: true }; + }, + }, + ], + }); + for (const query of queries) { + assert.equal((await search.geocode(query)).place, null); + } + assert.deepEqual(received, queries); +}); From d6c1ea0f4bec04524389a70d1699328934724ace Mon Sep 17 00:00:00 2001 From: Aditya <205600203+Rohilalala@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:31:11 +0530 Subject: [PATCH 2/6] fix(maps): retry the real imagery and terrain provider after a fallback In the keyless setup a single transient failure while loading the default map layers locked in the degraded fallback until reload. _getImageryProvider cached the construction-fallback resolution (OSM) under the Esri stack id, so every later "Esri Satellite" selection showed OSM. createKeylessTerrain turns any Re:Earth failure into a flat EllipsoidTerrainProvider; the controller cached it and set _terrainMode to 'keyless', so a boot-time network blip left the globe flat. Fallbacks are still cached and shown at once, and no switch waits on a retry. One controller timer now retries the real provider behind whichever fallback is on screen (Esri's OSM stand-in, or flat terrain on a shown globe), starting at 30 seconds and doubling to a 10-minute cap while it keeps failing. A failed retry leaves the cached fallback and the installed terrain untouched. A success replaces the cache entry and applies it live: terrain is installed in place with a render request (or, behind a 3D tileset, picked up by the next globe activation), and Esri is reselected automatically, after any in-flight switch settles. A switch that read the flat fallback re-reads the terrain cache before installing, and destroy() cancels the timer and releases a provider built after it. --- CHANGELOG.md | 6 + docs/CURRENT-STATE.md | 8 +- src/maps/controller.js | 117 ++++++++++++- src/maps/controller.test.mjs | 263 +++++++++++++++++++++++++++++- src/maps/sourceFactories.test.mjs | 10 ++ src/maps/terrain.js | 2 +- 6 files changed, 393 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd76d7a25..16bdcf39e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +- A failed Esri Satellite or keyless terrain load still shows OSM or flat terrain + at once, but no longer lasts the whole session: while the fallback is on + screen, the real provider is retried in the background (30 seconds, doubling + to a 10-minute cap) and replaces it live once it loads. Map switches never + wait for a retry. + - Add Barcelona's bike share to the GBFS registry: Bicing (543 stations, the city's PBSC system) and AMBici (237 e-bike stations across the metropolitan area, run by nextbike). Both are keyless GBFS 2.3 feeds listed in diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 4c06dd3cb..b8699b4c8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -369,7 +369,11 @@ doubling to a 15 s cap, with every retry waiting behind one shared cooldown plus up to 1.5 s of jitter, and `Retry-After` extending the cooldown when the upstream exposes it. One console warning is logged per cooldown window. Other failures keep Cesium's handling and the flat `EllipsoidTerrainProvider` -fallback. `scripts/qa-terrain-429.mjs` reproduces the burst against a keyless +fallback; while that flat terrain is on a shown globe the map controller +retries Re:Earth in the background (30 s, doubling to a 10-minute cap) and +installs the real terrain without waiting for a map switch. Behind a 3D tileset +it neither retries nor installs; the next globe activation does. +`scripts/qa-terrain-429.mjs` reproduces the burst against a keyless dev server, counts real throttles with their headers as evidence, synthesises throttles with `--inject N` when the upstream is not throttling, and fails if a throttled tile never loads. @@ -4692,7 +4696,7 @@ are omitted rather than framing the wrong part of the globe. - The bottom Visual Presets tray presents a **five-tile MAP SOURCE row** (`#map-stack-chips`, `src/mapStackChips.js`): Google 3D, Esri Satellite, Bing Aerial, Bing Labels, and OSM. The duplicate left `#stack-panel` is retired. The five tiles share one row on desktop and two rows on narrow screens, carry `aria-pressed` on the active source, and remain keyboard-reachable with a visible focus outline. - The lit tile follows controller state, not the click: a rejected switch (no ion token) or a superseded one (rapid A→B) leaves the genuinely active source lit, and the tray heading keeps its short-label status readout (`...` while switching, amber on `lastError`). - Ion stacks remain visible and keyboard-focusable when no ion token is configured, but expose `aria-disabled="true"` and do not switch. Their accessible label and tooltip quote `getStacks().unavailableReason` — the same string `setStack()` puts in the toast. OSM works keyless. The `ION` badge is gated on the stack's own `requiresIon`, so a `photoreal` chip unavailable because the Google tileset failed says so instead of falsely demanding an ion token. -- Stack choice participates in share links (`src/sharelink.js`) and falls back to the best available stack when the requested one is unavailable (keyless boots land on Esri; OSM takes over automatically if Esri is unreachable). Share-link restore, the `set_map_stack` voice tool, and the chip row all land on the same `_setMapStack()` path. +- Stack choice participates in share links (`src/sharelink.js`) and falls back to the best available stack when the requested one is unavailable (keyless boots land on Esri; OSM takes over automatically if Esri is unreachable; while that fallback is on screen Esri is retried in the background, 30 s doubling to a 10-minute cap, and replaces OSM once it loads). Share-link restore, the `set_map_stack` voice tool, and the chip row all land on the same `_setMapStack()` path. ### Voice Map Whiteboard / Annotations (June 2026) diff --git a/src/maps/controller.js b/src/maps/controller.js index 2b2ea462f..445e689f2 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -9,6 +9,16 @@ import { acquireImageryComparison } from './imageryComparison.js'; */ const AUTOMATIC = Symbol('automatic switch'); +/** Background retries of a fallback in use back off from 30 s to 10 min. */ +const FALLBACK_RETRY_MS = 30_000; +const FALLBACK_RETRY_MAX_MS = 600_000; + +const resolved = (provider, effectiveStackId) => ({ + provider, + effectiveStackId, + fallbackMessage: null, +}); + /** Coordinate source lifetimes and scene changes; the registry owns provider choices. */ export class MapSourceController { constructor( @@ -46,8 +56,12 @@ export class MapSourceController { this._lastError = null; this._imageryLayer = null; this._activeImageryProvider = null; + this._activeResolution = null; this._removeImageryErrorListener = null; this._terrainMode = null; + this._terrainFallback = null; + this._retryTimer = null; + this._retryDelay = FALLBACK_RETRY_MS; this._subscribers = new Set(); this._destroyed = false; } @@ -285,21 +299,102 @@ export class MapSourceController { this._showTileset(null); this.viewer.scene.globe.show = true; this._activeId = resolution.effectiveStackId; + this._activeResolution = resolution; if (source?.terrain && source.terrain.id !== this._terrainMode) { const terrain = source.terrain; - const result = await this._cached( - this._terrainProviders, - terrain.id, - () => terrain.create({ signal: this._abort.signal }), + const pending = this._cached(this._terrainProviders, terrain.id, () => + terrain.create({ signal: this._abort.signal }), ); + let result = await pending; if (gen !== this._switchGen) return; - if (result.terrain) this.viewer.scene.setTerrain(result.terrain); - else this.viewer.terrainProvider = result.provider; + // A background retry may have replaced a fallback while this waited. + const latest = this._terrainProviders.get(terrain.id); + if (latest !== pending) result = await latest; + if (gen !== this._switchGen) return; + this._applyTerrain(result); this._terrainMode = terrain.id; } + if (resolution.fallbackFor || this._terrainFallback) this._scheduleRetry(); return resolution; } + _applyTerrain(result) { + if (result.terrain) this.viewer.scene.setTerrain(result.terrain); + else this.viewer.terrainProvider = result.provider; + this._terrainFallback = result.fallback ? result : null; + } + + /** + * One timer retries the real provider behind whichever fallback is on + * screen: Esri's OSM stand-in, or flat terrain on a shown globe. Fallbacks + * stay cached and shown meanwhile, so no switch waits on a retry; a + * fallback out of use is left alone until an activation shows it again. + */ + _scheduleRetry() { + if (this._retryTimer) return; + this._retryTimer = setTimeout(() => { + this._retryTimer = null; + void this._retryFallbacks(); + }, this._retryDelay); + } + + async _retryFallbacks() { + const resolution = this._activeResolution; + const flat = this.viewer.scene.globe.show ? this._terrainFallback : null; + const id = resolution?.fallbackFor; + let terrain = flat && this._sources.get(this._activeId)?.terrain; + if (terrain?.id !== this._terrainMode) terrain = null; + if (!id && !terrain) return; + const attempt = async (create) => { + try { + return await create?.({ signal: this._abort.signal }); + } catch { + return null; // Still unavailable: keep the fallback. + } + }; + const [provider, real] = await Promise.all([ + id && attempt(this._sources.get(id).imagery), + terrain && attempt(terrain.create), + ]); + if (this._destroyed) { + this._dispose(provider); + this._dispose(real?.provider); + return; + } + let failed = (id && !provider) || (terrain && (!real || real.fallback)); + try { + if (provider) { + this._imageryProviders.set(id, Promise.resolve(resolved(provider, id))); + // Still showing the fallback once any switch settles: use the real one. + const recover = () => { + if (this._activeResolution === resolution) + void this.setStack(id, { [AUTOMATIC]: true }); + }; + if (!this._isSwitching) recover(); + else { + const stop = this.subscribe(() => { + stop(); + recover(); + }); + } + } + if (terrain && real && !real.fallback) { + this._terrainProviders.set(terrain.id, Promise.resolve(real)); + if (this._terrainFallback === flat && this.viewer.scene.globe.show) { + this._applyTerrain(real); + this._requestRender('map-stack'); + } else this._terrainMode = null; // The next globe activation installs it. + } + } catch (error) { + console.warn('[MapSourceController] fallback recovery failed:', error); + failed = true; + } + this._retryDelay = failed + ? Math.min(this._retryDelay * 2, FALLBACK_RETRY_MAX_MS) + : FALLBACK_RETRY_MS; + if (failed) this._scheduleRetry(); + } + _cached(cache, id, create) { if (cache.has(id)) return cache.get(id); const promise = Promise.resolve() @@ -323,7 +418,7 @@ export class MapSourceController { try { const provider = await source.imagery({ signal: this._abort.signal }); if (this._destroyed) this._dispose(provider); - return { provider, effectiveStackId: stack.id, fallbackMessage: null }; + return resolved(provider, stack.id); } catch (error) { const fallback = source.constructionFallback; if (this._destroyed || !fallback || !this.isStackAvailable(fallback.id)) @@ -333,7 +428,11 @@ export class MapSourceController { this.getStack(fallback.id), next, ); - return { ...resolution, fallbackMessage: fallback.message }; + return { + ...resolution, + fallbackMessage: fallback.message, + fallbackFor: stack.id, + }; } }); } @@ -388,6 +487,7 @@ export class MapSourceController { this.viewer.imageryLayers.remove(this._imageryLayer, true); this._imageryLayer = null; this._activeImageryProvider = null; + this._activeResolution = null; } _dispose(value) { if (!value || this._disposed.has(value)) return; @@ -401,6 +501,7 @@ export class MapSourceController { this._subscribers.clear(); this._switchGen++; this._abort.abort(); + clearTimeout(this._retryTimer); this._isSwitching = false; this._removeImageryLayer(); this._credits.destroy(); diff --git a/src/maps/controller.test.mjs b/src/maps/controller.test.mjs index 1e6c1413f..8dcead487 100644 --- a/src/maps/controller.test.mjs +++ b/src/maps/controller.test.mjs @@ -70,7 +70,7 @@ const settle = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); }; const descriptor = (id) => ({ id, label: id, kind: 'imagery' }); -function publicFixture() { +function publicFixture(options) { const tileset = { show: true }; const registry = createDefaultMapSources({ googleTileset: tileset }); const providers = new Map(); @@ -84,7 +84,7 @@ function publicFixture() { create: async () => ({ provider: { id: 'terrain' } }), }; } - return { ...fixture(registry), registry, providers, tileset }; + return { ...fixture(registry, options), registry, providers, tileset }; } test('an additional imagery source needs no controller branch and owns its cached resources', async () => { @@ -839,3 +839,262 @@ test('each switch generation reports its origin: an outside setStack is manual, ]); recovering.controller.destroy(); }); + +function failingEsri(env) { + const esri = { failing: true, attempts: 0, next: null }; + env.registry.sources.find( + (source) => source.descriptor.id === 'esri-imagery', + ).imagery = async () => { + esri.attempts++; + if (esri.next) await esri.next; + if (esri.failing) throw new Error('Esri unreachable'); + return env.providers.get('esri-imagery'); + }; + return esri; +} +const deferred = () => { + let resolve; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve }; +}; + +test('a failed Esri construction shows OSM at once, backs off while retries fail, and recovers in place', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const env = publicFixture(); + const esri = failingEsri(env); + await env.controller.setStack('esri-imagery'); + assert.equal(env.controller.getActiveId(), 'osm'); + const osmLayer = env.imagery.at(-1); + t.mock.timers.tick(30_000); + await settle(); + assert.equal(esri.attempts, 2, 'one background retry'); + assert.equal(env.imagery.at(-1), osmLayer, 'a failed retry keeps the layer'); + await env.controller.setStack('osm'); + await env.controller.setStack('esri-imagery'); + assert.equal(esri.attempts, 2, 'a reselection does not retry at once'); + t.mock.timers.tick(30_000); + await settle(); + assert.equal(esri.attempts, 2, 'the second retry waits twice as long'); + t.mock.timers.tick(30_000); + await settle(); + assert.equal(esri.attempts, 3); + esri.failing = false; + t.mock.timers.tick(120_000); + await settle(); + assert.equal(esri.attempts, 4); + assert.equal(env.controller.getActiveId(), 'esri-imagery'); + assert.equal(env.controller.getSwitchOrigin(), 'automatic'); + assert.equal(env.controller.getState().lastError, null); + assert.equal(env.imagery.at(-1).provider, env.providers.get('esri-imagery')); + t.mock.timers.tick(600_000); + await settle(); + assert.equal(esri.attempts, 4, 'the real provider ends the retries'); + env.controller.destroy(); +}); + +test('retries of a fallback that keeps failing back off to a 10 minute cap', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const env = publicFixture(); + const esri = failingEsri(env); + await env.controller.setStack('esri-imagery'); + for (const delay of [30, 60, 120, 240, 480, 600, 600]) { + t.mock.timers.tick(delay * 1000); + await settle(); + } + assert.equal(esri.attempts, 8); + env.controller.destroy(); +}); + +test('Esri is retried only while its fallback is shown', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const env = publicFixture(); + const esri = failingEsri(env); + await env.controller.setStack('esri-imagery'); + await env.controller.setStack('osm'); + t.mock.timers.tick(30_000); + await settle(); + assert.equal(esri.attempts, 1, 'no retry while OSM was chosen'); + await env.controller.setStack('esri-imagery'); + assert.equal(esri.attempts, 1, 'the cached fallback shows at once'); + assert.equal(env.controller.getActiveId(), 'osm'); + esri.failing = false; + t.mock.timers.tick(30_000); + await settle(); + assert.equal(esri.attempts, 2); + assert.equal(env.controller.getActiveId(), 'esri-imagery'); + env.controller.destroy(); +}); + +test('an Esri recovery that lands mid-switch applies once the switch settles', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const env = publicFixture(); + const esri = failingEsri(env); + await env.controller.setStack('esri-imagery'); + const retry = deferred(); + esri.next = retry.promise; + esri.failing = false; + t.mock.timers.tick(30_000); + await settle(); + // The fallback activates OSM's terrain; make that switch slow. + const slow = deferred(); + env.registry.sources.find( + (source) => source.descriptor.id === 'osm', + ).terrain = { id: 'slow', create: () => slow.promise }; + const switching = env.controller.setStack('esri-imagery'); + retry.resolve(); + await settle(); + assert.equal(env.controller.getActiveId(), 'osm', 'the switch is still open'); + slow.resolve({ provider: { id: 'slow' } }); + await switching; + await settle(); + assert.equal(env.controller.getActiveId(), 'esri-imagery'); + assert.equal(env.imagery.at(-1).provider, env.providers.get('esri-imagery')); + env.controller.destroy(); +}); + +function terrainFixture(t, create) { + t.mock.timers.enable({ apis: ['setTimeout'] }); + let renders = 0; + const env = publicFixture({ requestRender: () => renders++ }); + const installed = []; + Object.defineProperty(env.viewer, 'terrainProvider', { + get: () => installed.at(-1), + set: (provider) => installed.push(provider), + }); + const terrain = { id: 'keyless', calls: 0 }; + terrain.create = () => create(++terrain.calls); + for (const source of env.registry.sources) + if (source.imagery) source.terrain = terrain; + return { env, installed, terrain, renders: () => renders }; +} +const flatTerrain = (n) => ({ provider: { id: `flat-${n}` }, fallback: true }); + +test('flat fallback terrain stays installed while retries run, and real terrain replaces it live', async (t) => { + const real = { id: 'real' }; + let working = false; + const { env, installed, terrain, renders } = terrainFixture(t, async (n) => + working ? { provider: real } : flatTerrain(n), + ); + await env.controller.setStack('esri-imagery'); + const [flat] = installed; + t.mock.timers.tick(30_000); + await settle(); + assert.equal(terrain.calls, 2, 'one background retry'); + await env.controller.setStack('osm'); + assert.deepEqual(installed, [flat], 'failed retries and switches keep it'); + working = true; + const before = renders(); + t.mock.timers.tick(60_000); + await settle(); + assert.equal(terrain.calls, 3); + assert.deepEqual(installed, [flat, real]); + assert.equal(renders(), before + 1, 'the live swap requests a render'); + t.mock.timers.tick(600_000); + await settle(); + assert.equal(terrain.calls, 3, 'the real terrain ends the retries'); + env.controller.destroy(); +}); + +test('terrain is not retried or installed while a 3D tileset hides the globe', async (t) => { + const real = { id: 'real' }; + let working = false; + const { env, installed, terrain } = terrainFixture(t, async (n) => + working ? { provider: real } : flatTerrain(n), + ); + await env.controller.setStack('esri-imagery'); + await env.controller.setStack('photoreal'); + t.mock.timers.tick(30_000); + await settle(); + assert.equal(terrain.calls, 1, 'no retry behind a hidden globe'); + await env.controller.setStack('esri-imagery'); + const hidden = deferred(); + terrain.create = async () => { + terrain.calls++; + await hidden.promise; + return { provider: real }; + }; + t.mock.timers.tick(30_000); + await settle(); + assert.equal(terrain.calls, 2, 'the globe activation re-arms the retry'); + await env.controller.setStack('photoreal'); + hidden.resolve(); + await settle(); + assert.equal(installed.length, 1, 'nothing is installed behind a tileset'); + await env.controller.setStack('osm'); + assert.equal(installed.at(-1), real, 'the next globe activation installs it'); + assert.equal(terrain.calls, 2); + env.controller.destroy(); +}); + +test('a switch that read the flat fallback installs the terrain a retry just cached', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + // Sweep the microtask distance between the retry landing and the switch. + for (let hops = 0; hops < 12; hops++) { + const real = { id: 'real' }, + gate = deferred(); + const keyless = { + id: 'keyless', + create: async () => { + if (!keyless.retried) return flatTerrain(1); + await gate.promise; + return { provider: real }; + }, + }; + const other = { id: 'other', create: async () => ({ provider: {} }) }; + const source = (id, terrain) => ({ + descriptor: descriptor(id), + imagery: async () => ({ id }), + terrain, + }); + const env = fixture({ + defaultId: 'a', + sources: [source('a', keyless), source('c', other)], + }); + const installed = []; + Object.defineProperty(env.viewer, 'terrainProvider', { + get: () => installed.at(-1), + set: (provider) => installed.push(provider), + }); + await env.controller.setStack('a'); + keyless.retried = true; + t.mock.timers.tick(30_000); + await settle(); + await env.controller.setStack('c'); + gate.resolve(); + for (let i = 0; i < hops; i++) await null; + await env.controller.setStack('a'); + await settle(); + assert.equal(installed.at(-1), real, `hops ${hops}`); + env.controller.destroy(); + } +}); + +test('destroy cancels a pending retry and releases one in flight', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const env = publicFixture(); + const esri = failingEsri(env); + await env.controller.setStack('esri-imagery'); + env.controller.destroy(); + t.mock.timers.tick(30_000); + await settle(); + assert.equal(esri.attempts, 1, 'a pending retry is cancelled'); + + const late = publicFixture(); + const lateEsri = failingEsri(late); + await late.controller.setStack('esri-imagery'); + const retry = deferred(); + lateEsri.next = retry.promise; + lateEsri.failing = false; + let destroyed = 0; + late.providers.get('esri-imagery').destroy = () => destroyed++; + t.mock.timers.tick(30_000); + await settle(); + late.controller.destroy(); + retry.resolve(); + await settle(); + assert.equal(destroyed, 1, 'a late provider is released'); + assert.equal(late.imagery.length, 0); + t.mock.timers.tick(600_000); + await settle(); + assert.equal(lateEsri.attempts, 2); +}); diff --git a/src/maps/sourceFactories.test.mjs b/src/maps/sourceFactories.test.mjs index d3aaa5068..3f1edc7ba 100644 --- a/src/maps/sourceFactories.test.mjs +++ b/src/maps/sourceFactories.test.mjs @@ -317,3 +317,13 @@ test('both Google 3D routes keep tiles drawing while draped imagery loads', asyn assert.equal(calls[1][1].asynchronouslyLoadImagery, true); assert.equal(calls[1][1].enableCollision, true); }); + +test('unreachable keyless terrain falls back to flat terrain marked as a fallback', async (t) => { + t.mock.method(Cesium.CesiumTerrainProvider, 'fromUrl', async () => { + throw new Error('layer.json unreachable'); + }); + t.mock.method(console, 'warn', () => {}); + const result = await createKeylessTerrain(); + assert.ok(result.provider instanceof Cesium.EllipsoidTerrainProvider); + assert.equal(result.fallback, true); +}); diff --git a/src/maps/terrain.js b/src/maps/terrain.js index f58c1c755..d2f214700 100644 --- a/src/maps/terrain.js +++ b/src/maps/terrain.js @@ -59,6 +59,6 @@ export async function createKeylessTerrain() { '[MapStack] Re:Earth terrain unavailable, falling back to flat ellipsoid terrain:', error, ); - return { provider: new Cesium.EllipsoidTerrainProvider() }; + return { provider: new Cesium.EllipsoidTerrainProvider(), fallback: true }; } } From f0a04fbbaa9ab5ae0ef5be80055c67bff35ba372 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 16 Oct 2026 16:39:28 -0700 Subject: [PATCH 3/6] Move the changelog entry into changelog.d and watch the Google geocode proxy in QA Main now takes release notes as one fragment per pull request (#787). The browser asks Google through /api/google/geocode, so the no-geocoder-request check now watches that route. --- changelog.d/1071-geo-uri-search.md | 3 +++ scripts/qa-location-controls.mjs | 5 ++--- src/search/coordinateGeocoder.js | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 changelog.d/1071-geo-uri-search.md diff --git a/changelog.d/1071-geo-uri-search.md b/changelog.d/1071-geo-uri-search.md new file mode 100644 index 000000000..eba251948 --- /dev/null +++ b/changelog.d/1071-geo-uri-search.md @@ -0,0 +1,3 @@ +- Location search accepts two-dimensional WGS84 `geo:` URIs offline, with + the existing coordinate framing and label. Unsupported URI fields are passed + to the remaining search providers unchanged (cj-vana, #1071). diff --git a/scripts/qa-location-controls.mjs b/scripts/qa-location-controls.mjs index 842a433a4..46da4fe37 100644 --- a/scripts/qa-location-controls.mjs +++ b/scripts/qa-location-controls.mjs @@ -89,9 +89,8 @@ try { const url = new URL(request.url()); if ( url.pathname === '/api/geocode' || - (url.hostname === 'photon.komoot.io' && url.pathname === '/api/') || - (url.hostname === 'maps.googleapis.com' && - url.pathname === '/maps/api/geocode/json') + url.pathname === '/api/google/geocode' || + (url.hostname === 'photon.komoot.io' && url.pathname === '/api/') ) geocoderRequests.push(request.url()); }; diff --git a/src/search/coordinateGeocoder.js b/src/search/coordinateGeocoder.js index 265a0e12c..aa145bb08 100644 --- a/src/search/coordinateGeocoder.js +++ b/src/search/coordinateGeocoder.js @@ -22,7 +22,7 @@ function boundedBox(lat, lon, halfSpan) { } /** - * Answer a decimal-degree query without asking anyone. + * Answer a decimal-degree or geo-URI query without asking anyone. * * It sits ahead of the network geocoders, so a coordinate costs no request and * works with no key. Anything that is not exactly a coordinate is passed on From f0d43a62e89b98a671989da5a6578f8f2eba68a6 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 16 Oct 2026 16:40:34 -0700 Subject: [PATCH 4/6] Keep fallback retries single-flight and recover imagery and terrain independently An activation that re-showed a fallback while a retry was in flight armed a second timer, so two retries could run at once and land out of order, leaving the cache and the screen on different providers. Retries now run one at a time; a request made meanwhile re-arms once the current one ends, and a result that is obsolete or no longer wanted is disposed. Imagery and terrain retries are now handled as each settles, so a stalled terrain attempt no longer keeps recovered imagery off screen, or the reverse. --- changelog.d/1111-map-fallback-retry.md | 5 ++ docs/CURRENT-STATE.md | 5 +- src/maps/controller.js | 106 ++++++++++++++++--------- src/maps/controller.test.mjs | 82 +++++++++++++++++++ 4 files changed, 159 insertions(+), 39 deletions(-) create mode 100644 changelog.d/1111-map-fallback-retry.md diff --git a/changelog.d/1111-map-fallback-retry.md b/changelog.d/1111-map-fallback-retry.md new file mode 100644 index 000000000..45086eab1 --- /dev/null +++ b/changelog.d/1111-map-fallback-retry.md @@ -0,0 +1,5 @@ +- A failed Esri Satellite or keyless terrain load still shows OSM or flat terrain + at once, but no longer lasts the whole session: while the fallback is on + screen, the real provider is retried in the background (30 seconds, doubling + to a 10-minute cap) and replaces it live once it loads. Map switches never + wait for a retry (Aditya, @Rohilalala, #1111). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 782d8455e..727f17c87 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -386,8 +386,9 @@ upstream exposes it. One console warning is logged per cooldown window. Other failures keep Cesium's handling and the flat `EllipsoidTerrainProvider` fallback; while that flat terrain is on a shown globe the map controller retries Re:Earth in the background (30 s, doubling to a 10-minute cap) and -installs the real terrain without waiting for a map switch. Behind a 3D tileset -it neither retries nor installs; the next globe activation does. +installs the real terrain without waiting for a map switch or an imagery +retry. Behind a 3D tileset it neither retries nor installs; the next globe +activation does. `scripts/qa-terrain-429.mjs` reproduces the burst against a keyless dev server, counts real throttles with their headers as evidence, synthesises throttles with `--inject N` when the upstream is not throttling, and fails if diff --git a/src/maps/controller.js b/src/maps/controller.js index 445e689f2..ce9cc6e94 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -61,6 +61,8 @@ export class MapSourceController { this._terrainMode = null; this._terrainFallback = null; this._retryTimer = null; + this._retrying = false; + this._retryRequested = false; this._retryDelay = FALLBACK_RETRY_MS; this._subscribers = new Set(); this._destroyed = false; @@ -329,9 +331,14 @@ export class MapSourceController { * screen: Esri's OSM stand-in, or flat terrain on a shown globe. Fallbacks * stay cached and shown meanwhile, so no switch waits on a retry; a * fallback out of use is left alone until an activation shows it again. + * One retry runs at a time; a request made meanwhile re-arms once it ends. */ _scheduleRetry() { - if (this._retryTimer) return; + if (this._retryTimer || this._destroyed) return; + if (this._retrying) { + this._retryRequested = true; + return; + } this._retryTimer = setTimeout(() => { this._retryTimer = null; void this._retryFallbacks(); @@ -345,6 +352,8 @@ export class MapSourceController { let terrain = flat && this._sources.get(this._activeId)?.terrain; if (terrain?.id !== this._terrainMode) terrain = null; if (!id && !terrain) return; + this._retrying = true; + this._retryRequested = false; const attempt = async (create) => { try { return await create?.({ signal: this._abort.signal }); @@ -352,47 +361,70 @@ export class MapSourceController { return null; // Still unavailable: keep the fallback. } }; - const [provider, real] = await Promise.all([ - id && attempt(this._sources.get(id).imagery), - terrain && attempt(terrain.create), - ]); - if (this._destroyed) { - this._dispose(provider); - this._dispose(real?.provider); - return; - } - let failed = (id && !provider) || (terrain && (!real || real.fallback)); + // Each recovers as soon as it loads, never waiting on the other. + const retryImagery = async () => { + const cached = this._imageryProviders.get(id); + const provider = await attempt(this._sources.get(id).imagery); + // Obsolete: destroyed, or the cache moved on while this was in flight. + if (this._destroyed || this._imageryProviders.get(id) !== cached) { + this._dispose(provider); + return true; + } + if (!provider) return false; + this._imageryProviders.set(id, Promise.resolve(resolved(provider, id))); + // Still showing the fallback once any switch settles: use the real one. + const recover = () => { + if (this._activeResolution === resolution) + void this.setStack(id, { [AUTOMATIC]: true }); + }; + if (!this._isSwitching) recover(); + else { + const stop = this.subscribe(() => { + stop(); + recover(); + }); + } + return true; + }; + const retryTerrain = async () => { + const cached = this._terrainProviders.get(terrain.id); + const real = await attempt(terrain.create); + if ( + this._destroyed || + this._terrainProviders.get(terrain.id) !== cached || + !real || + real.fallback + ) { + this._dispose(real?.provider); + return Boolean(real && !real.fallback); + } + this._terrainProviders.set(terrain.id, Promise.resolve(real)); + if (this._terrainFallback === flat && this.viewer.scene.globe.show) { + this._applyTerrain(real); + this._requestRender('map-stack'); + } else this._terrainMode = null; // The next globe activation installs it. + return true; + }; + const settled = (retry) => + retry().catch((error) => { + console.warn('[MapSourceController] fallback recovery failed:', error); + return false; + }); + let failed = false; try { - if (provider) { - this._imageryProviders.set(id, Promise.resolve(resolved(provider, id))); - // Still showing the fallback once any switch settles: use the real one. - const recover = () => { - if (this._activeResolution === resolution) - void this.setStack(id, { [AUTOMATIC]: true }); - }; - if (!this._isSwitching) recover(); - else { - const stop = this.subscribe(() => { - stop(); - recover(); - }); - } - } - if (terrain && real && !real.fallback) { - this._terrainProviders.set(terrain.id, Promise.resolve(real)); - if (this._terrainFallback === flat && this.viewer.scene.globe.show) { - this._applyTerrain(real); - this._requestRender('map-stack'); - } else this._terrainMode = null; // The next globe activation installs it. - } - } catch (error) { - console.warn('[MapSourceController] fallback recovery failed:', error); - failed = true; + const results = await Promise.all([ + id && settled(retryImagery), + terrain && settled(retryTerrain), + ]); + failed = results.includes(false); + } finally { + this._retrying = false; } + if (this._destroyed) return; this._retryDelay = failed ? Math.min(this._retryDelay * 2, FALLBACK_RETRY_MAX_MS) : FALLBACK_RETRY_MS; - if (failed) this._scheduleRetry(); + if (failed || this._retryRequested) this._scheduleRetry(); } _cached(cache, id, create) { diff --git a/src/maps/controller.test.mjs b/src/maps/controller.test.mjs index 8dcead487..da7bec8d8 100644 --- a/src/maps/controller.test.mjs +++ b/src/maps/controller.test.mjs @@ -1098,3 +1098,85 @@ test('destroy cancels a pending retry and releases one in flight', async (t) => await settle(); assert.equal(lateEsri.attempts, 2); }); + +test('retries stay single-flight when an activation re-shows the fallback mid-retry', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const env = publicFixture(); + const created = [], + pending = []; + let attempts = 0; + env.registry.sources.find( + (source) => source.descriptor.id === 'esri-imagery', + ).imagery = async () => { + if (++attempts === 1) throw new Error('Esri unreachable'); + const gate = deferred(); + pending.push(gate); + await gate.promise; + const provider = { id: `esri-${attempts}`, errorEvent: event() }; + provider.destroy = () => (provider.destroyed = true); + created.push(provider); + return provider; + }; + await env.controller.setStack('esri-imagery'); + t.mock.timers.tick(30_000); + await settle(); + assert.equal(attempts, 2, 'retry #1 is in flight'); + await env.controller.setStack('osm'); + await env.controller.setStack('esri-imagery'); + assert.equal(env.controller.getActiveId(), 'osm', 'the fallback is shown'); + t.mock.timers.tick(30_000); + await settle(); + assert.equal(attempts, 2, 'no second retry while one is in flight'); + for (const gate of pending.reverse()) { + gate.resolve(); + await settle(); + } + const shown = env.imagery.at(-1).provider; + assert.equal(env.controller.getActiveId(), 'esri-imagery'); + await env.controller.setStack('osm'); + await env.controller.setStack('esri-imagery'); + assert.equal(env.imagery.at(-1).provider, shown, 'the cached one is shown'); + env.controller.destroy(); + await settle(); + assert.ok(created.length > 0); + for (const provider of created) + assert.equal(provider.destroyed, true, `${provider.id} is disposed`); +}); + +test('a stalled terrain retry does not hold back recovered imagery', async (t) => { + const stalled = deferred(); + const { env, terrain } = terrainFixture(t, async (n) => { + if (n > 1) await stalled.promise; + return flatTerrain(n); + }); + const esri = failingEsri(env); + await env.controller.setStack('esri-imagery'); + assert.equal(env.controller.getActiveId(), 'osm'); + esri.failing = false; + t.mock.timers.tick(30_000); + await settle(); + assert.equal(terrain.calls, 2, 'the terrain retry is in flight'); + assert.equal(env.controller.getActiveId(), 'esri-imagery'); + assert.equal(env.imagery.at(-1).provider, env.providers.get('esri-imagery')); + stalled.resolve(); + await settle(); + env.controller.destroy(); +}); + +test('a stalled imagery retry does not hold back recovered terrain', async (t) => { + const real = { id: 'real' }; + const { env, installed } = terrainFixture(t, async (n) => + n > 1 ? { provider: real } : flatTerrain(n), + ); + const esri = failingEsri(env); + await env.controller.setStack('esri-imagery'); + const stalled = deferred(); + esri.next = stalled.promise; + t.mock.timers.tick(30_000); + await settle(); + assert.equal(esri.attempts, 2, 'the imagery retry is in flight'); + assert.equal(installed.at(-1), real, 'real terrain is installed'); + stalled.resolve(); + await settle(); + env.controller.destroy(); +}); From 759c7964c722e8bf3e6cc3b7450c686ad0db8853 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 16 Oct 2026 16:47:11 -0700 Subject: [PATCH 5/6] Bound fallback retry attempts with a deadline An Esri or keyless terrain attempt that never settled kept the single-flight retry open forever, so neither provider was retried again. Each attempt now counts as failed after 60 seconds and the retry backs off as usual; its signal is aborted, and a provider that lands after the deadline is released rather than installed or cached. CURRENT-STATE now says only the construction fallback to OSM is retried, not the switch made after repeated Esri tile failures. --- docs/CURRENT-STATE.md | 10 ++--- src/maps/controller.js | 33 ++++++++++++++-- src/maps/controller.test.mjs | 76 ++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 9 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 727f17c87..9c43416f2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -385,10 +385,10 @@ plus up to 1.5 s of jitter, and `Retry-After` extending the cooldown when the upstream exposes it. One console warning is logged per cooldown window. Other failures keep Cesium's handling and the flat `EllipsoidTerrainProvider` fallback; while that flat terrain is on a shown globe the map controller -retries Re:Earth in the background (30 s, doubling to a 10-minute cap) and -installs the real terrain without waiting for a map switch or an imagery -retry. Behind a 3D tileset it neither retries nor installs; the next globe -activation does. +retries Re:Earth in the background (30 s, doubling to a 10-minute cap, each +attempt abandoned after 60 s) and installs the real terrain without waiting +for a map switch or an imagery retry. Behind a 3D tileset it neither retries +nor installs; the next globe activation does. `scripts/qa-terrain-429.mjs` reproduces the burst against a keyless dev server, counts real throttles with their headers as evidence, synthesises throttles with `--inject N` when the upstream is not throttling, and fails if @@ -4776,7 +4776,7 @@ are omitted rather than framing the wrong part of the globe. - The bottom Visual Presets tray presents a **five-tile MAP SOURCE row** (`#map-stack-chips`, `src/mapStackChips.js`): Google 3D, Esri Satellite, Bing Aerial, Bing Labels, and OSM. The duplicate left `#stack-panel` is retired. The five tiles share one row on desktop and two rows on narrow screens, carry `aria-pressed` on the active source, and remain keyboard-reachable with a visible focus outline. - The lit tile follows controller state, not the click: a rejected switch (no ion token) or a superseded one (rapid A→B) leaves the genuinely active source lit, and the tray heading keeps its short-label status readout (`...` while switching, amber on `lastError`). - Ion stacks remain visible and keyboard-focusable when no ion token is configured, but expose `aria-disabled="true"` and do not switch. Their accessible label and tooltip quote `getStacks().unavailableReason` — the same string `setStack()` puts in the toast. OSM works keyless. The `ION` badge is gated on the stack's own `requiresIon`, so a `photoreal` chip unavailable because the Google tileset failed says so instead of falsely demanding an ion token. -- Stack choice participates in share links (`src/sharelink.js`) and falls back to the best available stack when the requested one is unavailable (keyless boots land on Esri; OSM takes over automatically if Esri is unreachable; while that fallback is on screen Esri is retried in the background, 30 s doubling to a 10-minute cap, and replaces OSM once it loads). Share-link restore, the `set_map_stack` voice tool, and the chip row all land on the same `_setMapStack()` path. +- Stack choice participates in share links (`src/sharelink.js`) and falls back to the best available stack when the requested one is unavailable (keyless boots land on Esri; OSM takes over automatically if Esri is unreachable; while that construction fallback is on screen Esri is retried in the background, 30 s doubling to a 10-minute cap with each attempt abandoned after 60 s, and replaces OSM once it loads. The switch to OSM after repeated Esri tile failures is not retried; selecting Esri again tries it). Share-link restore, the `set_map_stack` voice tool, and the chip row all land on the same `_setMapStack()` path. ### Voice Map Whiteboard / Annotations (June 2026) diff --git a/src/maps/controller.js b/src/maps/controller.js index ce9cc6e94..9fb2d8f22 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -12,6 +12,8 @@ const AUTOMATIC = Symbol('automatic switch'); /** Background retries of a fallback in use back off from 30 s to 10 min. */ const FALLBACK_RETRY_MS = 30_000; const FALLBACK_RETRY_MAX_MS = 600_000; +/** A retry attempt still unsettled after a minute counts as failed. */ +const FALLBACK_ATTEMPT_MS = 60_000; const resolved = (provider, effectiveStackId) => ({ provider, @@ -354,17 +356,38 @@ export class MapSourceController { if (!id && !terrain) return; this._retrying = true; this._retryRequested = false; - const attempt = async (create) => { + // An attempt that outlives its deadline counts as a failure. Its signal is + // aborted, but the Esri and keyless factories ignore it and Cesium's + // requests have no timeout, so a late result is released, never used. + const attempt = async (create, release) => { + const expiry = new AbortController(); + let timer; + const pending = Promise.resolve().then(() => + create?.({ + signal: AbortSignal.any([this._abort.signal, expiry.signal]), + }), + ); + const deadline = new Promise((resolve) => { + timer = setTimeout(resolve, FALLBACK_ATTEMPT_MS, expiry); + }); try { - return await create?.({ signal: this._abort.signal }); + const result = await Promise.race([pending, deadline]); + if (result !== expiry) return result; + expiry.abort(); + pending.then(release, () => {}); + return null; } catch { return null; // Still unavailable: keep the fallback. + } finally { + clearTimeout(timer); } }; // Each recovers as soon as it loads, never waiting on the other. const retryImagery = async () => { const cached = this._imageryProviders.get(id); - const provider = await attempt(this._sources.get(id).imagery); + const provider = await attempt(this._sources.get(id).imagery, (late) => + this._dispose(late), + ); // Obsolete: destroyed, or the cache moved on while this was in flight. if (this._destroyed || this._imageryProviders.get(id) !== cached) { this._dispose(provider); @@ -388,7 +411,9 @@ export class MapSourceController { }; const retryTerrain = async () => { const cached = this._terrainProviders.get(terrain.id); - const real = await attempt(terrain.create); + const real = await attempt(terrain.create, (late) => + this._dispose(late?.provider), + ); if ( this._destroyed || this._terrainProviders.get(terrain.id) !== cached || diff --git a/src/maps/controller.test.mjs b/src/maps/controller.test.mjs index da7bec8d8..1ff2ceac1 100644 --- a/src/maps/controller.test.mjs +++ b/src/maps/controller.test.mjs @@ -1180,3 +1180,79 @@ test('a stalled imagery retry does not hold back recovered terrain', async (t) = await settle(); env.controller.destroy(); }); + +test('a retry attempt that never settles is abandoned at its deadline and released if it lands late', async (t) => { + const hung = deferred(); + const late = { id: 'late', destroy: () => (late.destroyed = true) }; + const { env, installed, terrain } = terrainFixture(t, async (n) => { + if (n === 2) await hung.promise; + return n === 2 ? { provider: late } : flatTerrain(n); + }); + const esri = failingEsri(env); + await env.controller.setStack('esri-imagery'); + t.mock.timers.tick(30_000); + await settle(); + assert.equal(esri.attempts, 2, 'imagery failed'); + assert.equal(terrain.calls, 2, 'terrain hangs'); + t.mock.timers.tick(60_000); // The hung attempt's deadline. + await settle(); + t.mock.timers.tick(60_000); // The doubled backoff. + await settle(); + assert.equal(esri.attempts, 3, 'imagery is retried again'); + assert.equal(terrain.calls, 3, 'terrain is retried again'); + hung.resolve(); + await settle(); + assert.equal(late.destroyed, true, 'the late provider is released'); + assert.ok(!installed.includes(late), 'and never installed'); + env.controller.destroy(); +}); + +test('a failed retry after a mid-retry re-show re-arms once, after the doubled delay', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const env = publicFixture(); + const esri = failingEsri(env); + await env.controller.setStack('esri-imagery'); + const gate = deferred(); + esri.next = gate.promise; + t.mock.timers.tick(30_000); + await settle(); + await env.controller.setStack('osm'); + await env.controller.setStack('esri-imagery'); + gate.resolve(); + await settle(); + assert.equal(esri.attempts, 2); + t.mock.timers.tick(59_999); + await settle(); + assert.equal(esri.attempts, 2, 'not before the doubled delay'); + t.mock.timers.tick(1); + await settle(); + assert.equal(esri.attempts, 3, 'one retry after it'); + t.mock.timers.tick(119_999); + await settle(); + assert.equal(esri.attempts, 3, 'no second timer was armed'); + env.controller.destroy(); +}); + +test('destroy releases recovered imagery and terrain that lands afterwards', async (t) => { + const stalled = deferred(); + const real = { id: 'real', destroy: () => (real.destroyed = true) }; + const { env, installed } = terrainFixture(t, async (n) => { + if (n === 1) return flatTerrain(n); + await stalled.promise; + return { provider: real }; + }); + const esri = failingEsri(env); + const imagery = env.providers.get('esri-imagery'); + imagery.destroy = () => (imagery.destroyed = true); + await env.controller.setStack('esri-imagery'); + esri.failing = false; + t.mock.timers.tick(30_000); + await settle(); + assert.equal(env.imagery.at(-1).provider, imagery, 'imagery has landed'); + env.controller.destroy(); + stalled.resolve(); + await settle(); + assert.equal(imagery.destroyed, true); + assert.equal(real.destroyed, true); + assert.ok(!installed.includes(real)); +}); From d9aa2bd06d308a41a07fb47e2acc8d45c5e40961 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 16 Oct 2026 16:53:42 -0700 Subject: [PATCH 6/6] Release attempt timers on destroy and the replaced fallback terrain destroy() now expires every retry attempt in flight, clearing its deadline timer and aborting its signal at once rather than when the attempt settles, and leaves no retry marked as running. A successful terrain retry overwrote the cached fallback without releasing it. The replaced fallback is now disposed as soon as the real terrain takes its place on screen, or, when it stayed installed behind a 3D tileset, once the next globe activation replaces it. --- src/maps/controller.js | 35 ++++++++-- src/maps/controller.test.mjs | 126 +++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 5 deletions(-) diff --git a/src/maps/controller.js b/src/maps/controller.js index 9fb2d8f22..bfdd82033 100644 --- a/src/maps/controller.js +++ b/src/maps/controller.js @@ -66,6 +66,10 @@ export class MapSourceController { this._retrying = false; this._retryRequested = false; this._retryDelay = FALLBACK_RETRY_MS; + /** Expire callbacks of retry attempts in flight; destroy() runs them. */ + this._retryDeadlines = new Set(); + /** A fallback terrain a retry replaced while it stayed installed. */ + this._replacedTerrain = null; this._subscribers = new Set(); this._destroyed = false; } @@ -326,6 +330,10 @@ export class MapSourceController { if (result.terrain) this.viewer.scene.setTerrain(result.terrain); else this.viewer.terrainProvider = result.provider; this._terrainFallback = result.fallback ? result : null; + if (this._replacedTerrain && this._replacedTerrain !== result) { + this._dispose(this._replacedTerrain.provider); + this._replacedTerrain = null; + } } /** @@ -361,15 +369,20 @@ export class MapSourceController { // requests have no timeout, so a late result is released, never used. const attempt = async (create, release) => { const expiry = new AbortController(); - let timer; + let expire; + const deadline = new Promise((resolve) => { + expire = () => { + clearTimeout(timer); + resolve(expiry); + }; + }); + const timer = setTimeout(expire, FALLBACK_ATTEMPT_MS); + this._retryDeadlines.add(expire); const pending = Promise.resolve().then(() => create?.({ signal: AbortSignal.any([this._abort.signal, expiry.signal]), }), ); - const deadline = new Promise((resolve) => { - timer = setTimeout(resolve, FALLBACK_ATTEMPT_MS, expiry); - }); try { const result = await Promise.race([pending, deadline]); if (result !== expiry) return result; @@ -380,6 +393,7 @@ export class MapSourceController { return null; // Still unavailable: keep the fallback. } finally { clearTimeout(timer); + this._retryDeadlines.delete(expire); } }; // Each recovers as soon as it loads, never waiting on the other. @@ -426,8 +440,14 @@ export class MapSourceController { this._terrainProviders.set(terrain.id, Promise.resolve(real)); if (this._terrainFallback === flat && this.viewer.scene.globe.show) { this._applyTerrain(real); + this._dispose(flat.provider); this._requestRender('map-stack'); - } else this._terrainMode = null; // The next globe activation installs it. + } else { + this._terrainMode = null; // The next globe activation installs it. + // Release the replaced fallback once nothing shows it. + if (this._terrainFallback === flat) this._replacedTerrain = flat; + else this._dispose(flat.provider); + } return true; }; const settled = (retry) => @@ -559,6 +579,11 @@ export class MapSourceController { this._switchGen++; this._abort.abort(); clearTimeout(this._retryTimer); + for (const expire of this._retryDeadlines) expire(); + this._retryDeadlines.clear(); + this._retrying = false; + this._dispose(this._replacedTerrain?.provider); + this._replacedTerrain = null; this._isSwitching = false; this._removeImageryLayer(); this._credits.destroy(); diff --git a/src/maps/controller.test.mjs b/src/maps/controller.test.mjs index 1ff2ceac1..f7e764754 100644 --- a/src/maps/controller.test.mjs +++ b/src/maps/controller.test.mjs @@ -1256,3 +1256,129 @@ test('destroy releases recovered imagery and terrain that lands afterwards', asy assert.equal(real.destroyed, true); assert.ok(!installed.includes(real)); }); + +function liveTimers(t) { + const live = new Set(); + const { setTimeout: set, clearTimeout: clear } = globalThis; + globalThis.setTimeout = (fn, ms, ...args) => { + const id = set( + (...values) => { + live.delete(id); + fn(...values); + }, + ms, + ...args, + ); + live.add(id); + return id; + }; + globalThis.clearTimeout = (id) => { + live.delete(id); + clear(id); + }; + t.after(() => + Object.assign(globalThis, { setTimeout: set, clearTimeout: clear }), + ); + return live; +} + +test('destroy during a hung retry attempt clears its deadline at once', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const live = liveTimers(t); + const env = publicFixture(); + const esri = failingEsri(env); + await env.controller.setStack('esri-imagery'); + esri.next = new Promise(() => {}); + t.mock.timers.tick(30_000); + await settle(); + assert.equal(esri.attempts, 2, 'the attempt hangs'); + assert.equal(live.size, 1, 'only its deadline is armed'); + env.controller.destroy(); + assert.equal(live.size, 0, 'destroy clears the deadline'); + assert.equal(env.controller._retrying, false); + await settle(); + assert.equal(live.size, 0, 'and nothing re-arms'); +}); + +function destroyableFlat(n) { + const provider = { id: `flat-${n}` }; + provider.destroy = () => (provider.destroyed = true); + return { provider, fallback: true }; +} + +test('terrain recovered on screen releases the fallback it replaced', async (t) => { + const real = { id: 'real' }; + let flat; + const { env, installed } = terrainFixture(t, async (n) => + n === 1 ? (flat = destroyableFlat(n)) : { provider: real }, + ); + await env.controller.setStack('esri-imagery'); + t.mock.timers.tick(30_000); + await settle(); + assert.equal(installed.at(-1), real); + assert.equal(flat.provider.destroyed, true); + env.controller.destroy(); +}); + +test('terrain recovered behind a tileset releases the fallback once a globe replaces it', async (t) => { + const real = { id: 'real' }; + const gate = deferred(); + let flat; + const { env, installed } = terrainFixture(t, async (n) => { + if (n === 1) return (flat = destroyableFlat(n)); + await gate.promise; + return { provider: real }; + }); + await env.controller.setStack('esri-imagery'); + t.mock.timers.tick(30_000); + await settle(); + await env.controller.setStack('photoreal'); + gate.resolve(); + await settle(); + assert.equal(installed.at(-1), flat.provider); + assert.ok(!flat.provider.destroyed, 'still installed behind the tileset'); + await env.controller.setStack('osm'); + assert.equal(installed.at(-1), real); + assert.equal(flat.provider.destroyed, true); + env.controller.destroy(); +}); + +test('a hung imagery attempt that lands after its deadline is released, never cached or shown', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const env = publicFixture(); + const hung = deferred(); + const late = { id: 'late', errorEvent: event() }; + const good = { id: 'good', errorEvent: event() }; + for (const provider of [late, good]) + provider.destroy = () => (provider.destroyed = true); + let attempts = 0; + env.registry.sources.find( + (source) => source.descriptor.id === 'esri-imagery', + ).imagery = async () => { + if (++attempts === 1) throw new Error('Esri unreachable'); + if (attempts === 2) { + await hung.promise; + return late; + } + return good; + }; + await env.controller.setStack('esri-imagery'); + t.mock.timers.tick(30_000); + await settle(); + t.mock.timers.tick(60_000); // The deadline. + await settle(); + hung.resolve(); + await settle(); + assert.equal(late.destroyed, true, 'the late provider is released'); + assert.equal(env.controller.getActiveId(), 'osm', 'and not shown'); + t.mock.timers.tick(60_000); + await settle(); + assert.equal(attempts, 3); + assert.equal(env.imagery.at(-1).provider, good, 'the next retry installs'); + await env.controller.setStack('osm'); + await env.controller.setStack('esri-imagery'); + assert.equal(env.imagery.at(-1).provider, good, 'only its own is cached'); + env.controller.destroy(); + await settle(); + assert.equal(good.destroyed, true); +});