diff --git a/src/data/bhoteKoshiEvent.js b/src/data/bhoteKoshiEvent.js index 58667094c..25d29d99b 100644 --- a/src/data/bhoteKoshiEvent.js +++ b/src/data/bhoteKoshiEvent.js @@ -1,3 +1,4 @@ +import { waitForSignal } from '../sources/requestDeadline.js'; import * as Cesium from 'cesium'; import { isPointerFree } from './inputOwnership.js'; import { @@ -3521,10 +3522,19 @@ export function createBhoteKoshiEventLayer({ // the release that follows it owns whatever it had already created. let _attachGeneration = 0; + // Settle when `promise` does, or as soon as the scene signal aborts. The + // promise keeps running; the stale checks then ignore what it brings. + function untilSceneReleased(promise, signal) { + return waitForSignal(promise, signal).catch((error) => { + if (!signal?.aborted) throw error; + }); + } + async function attachRenderer(viewer, { engine = null, signal = null } = {}) { const generation = ++_attachGeneration; const current = () => generation === _attachGeneration && signal?.aborted !== true; + const stack = _mapStackController; _viewer = viewer; _engine = engine; if (!_viewer && _engine) _native = createNativeRenderer(engine); @@ -3539,10 +3549,13 @@ export function createBhoteKoshiEventLayer({ ? 'preserve' : 'esri', }); - await _comparisonLease.ready; + // The incoming map can take long to switch; a released scene stops + // waiting (its release returns the lease). + await untilSceneReleased(_comparisonLease.ready, signal); if (!current()) return; } - await createImagery(_event, current); + // createImagery checks `current` itself before it adds anything. + await untilSceneReleased(createImagery(_event, current), signal); if (!current()) return; if (_floodDataSource) { if (_native) _nativeFlood = _native.draw(_floodDataSource); @@ -3552,8 +3565,21 @@ export function createBhoteKoshiEventLayer({ registerDynamicCredit(engine ?? viewer, BHOTE_KOSHI_CREDIT); _scenePathPositionCache.clear(); _lastCinematicProgress = null; - await syncTerrainComparison(); - if (!current()) return; + const terrain = syncTerrainComparison(); + await untilSceneReleased(terrain, signal); + if (!current()) { + // The release already cleared the comparison terrain; one this + // attachment set afterwards is removed when it lands, unless a newer + // attachment owns the same map stack again. + terrain.then( + () => { + if (stack && stack !== _mapStackController) + stack.clearTerrainComparison?.(); + }, + () => {}, + ); + return; + } createSplitControl(_event); syncPresentationMode(); attachCameraInputRelease(); diff --git a/src/data/bhoteKoshiEvent.test.mjs b/src/data/bhoteKoshiEvent.test.mjs index 9bc84fa40..9d22ff9ae 100644 --- a/src/data/bhoteKoshiEvent.test.mjs +++ b/src/data/bhoteKoshiEvent.test.mjs @@ -3051,3 +3051,49 @@ for (const stop of ['detach', 'scene-abort']) { } }); } + +test('Bhote Koshi stops waiting for a stalled map switch when its scene is released', async () => { + const event = JSON.parse(await readFile(eventUrl, 'utf8')); + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + installEventDom(); + globalThis.window = { setTimeout: () => 1, clearTimeout() {} }; + const first = eventViewer(); + const second = eventViewer(); + const layer = createBhoteKoshiEventLayer({ + eventLoader: async () => event, + imageryProviderFactory: async (url) => ({ url }), + embeddedMediaFactory: () => ({ show() {}, hide() {}, pause() {}, destroy() {} }), + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + renderHost: { request() {}, hold() {}, release() {} }, + }); + layer.attachMapStackController({ + getActiveId: () => 'esri-imagery', + getSwitchGeneration: () => 1, + async setStack() {}, + }); + const manager = new LayerLifecycle(first); + manager.register(layer); + try { + assert.equal(await manager.setEnabled(layer.id, true, { origin: 'local-restore' }), true); + await layer.detachRenderer(); + // The incoming map's switch to Esri never finishes; restoring returns. + let active = 'photoreal'; + layer.attachMapStackController({ + getActiveId: () => active, + getSwitchGeneration: () => 1, + setStack: (id) => (id === 'esri-imagery' ? new Promise(() => {}) : Promise.resolve()), + }); + const scene = new AbortController(); + const attaching = layer.attachRenderer(second, { signal: scene.signal }); + scene.abort(); + await attaching; + await layer.detachRenderer(); + assert.deepEqual(second._test.imagery, []); + assert.deepEqual(second._test.dataSources, []); + } finally { + await manager.destroyLayer(layer.id); + globalThis.document = originalDocument; + globalThis.window = originalWindow; + } +}); diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js index 7459a5791..4886e3e8c 100644 --- a/src/layers/ghostMaps/index.js +++ b/src/layers/ghostMaps/index.js @@ -159,7 +159,10 @@ export function createGhostMapsLayer({ const notifyRow = () => _rowListener?.(); const render = () => _display?.requestRender(); - const canSelect = () => overlayHost && picking && (screenSpaceEventHandlerFactory || createRenderer); + const canSelect = () => + overlayHost && + picking && + (screenSpaceEventHandlerFactory || createRenderer); function wanted(product) { return _selection ? _selection.has(product.id) : product.defaultOn; @@ -207,7 +210,8 @@ export function createGhostMapsLayer({ entries.clear(); signal.throwIfAborted(); const features = parsed.features.map((feature) => ({ - ...feature, anchor: anchorOf(feature.geometries[0]), + ...feature, + anchor: anchorOf(feature.geometries[0]), })); return { version: product.version, urls, features, paint: null }; } catch (error) { @@ -219,7 +223,12 @@ export function createGhostMapsLayer({ async function paint(id, item, signal) { while (_display && !signal.aborted && !item.removed) { const owner = _display; - const next = owner.draw(id, item.features, PRODUCT_COLORS[id] || ACCENT, markerImage); + const next = owner.draw( + id, + item.features, + PRODUCT_COLORS[id] || ACCENT, + markerImage, + ); try { await next.ready; if (owner === _display && !signal.aborted && !item.removed) { @@ -477,15 +486,33 @@ export function createGhostMapsLayer({ if (viewer || options.engine) return this.attachRenderer(viewer, options); }, - attachRenderer(viewer, { engine = null } = {}) { + attachRenderer(viewer, { engine = null, signal = null } = {}) { if (_display) throw new Error('GhostMaps display is already attached'); _display = viewer - ? createGhostMapsCesiumRenderer(viewer, screenSpaceEventHandlerFactory, markerImage) + ? createGhostMapsCesiumRenderer( + viewer, + screenSpaceEventHandlerFactory, + markerImage, + ) : createRenderer(engine); if (!_enabled) return; installClickHandler(); publishCard(); - return Promise.all([..._loaded].map(([id, item]) => paint(id, item, _lifetime.signal))); + // A paint that outlives its scene destroys what it drew (see paint). + // The parsed products stay loaded for the next scene. + const paintSignal = signal + ? AbortSignal.any([_lifetime.signal, signal]) + : _lifetime.signal; + const painted = Promise.all( + [..._loaded].map(([id, item]) => paint(id, item, paintSignal)), + ); + if (!signal) return painted; + // Released scene: stop waiting; the paints keep their own clean-up. + const released = new Promise((resolve) => { + if (signal.aborted) resolve(); + else signal.addEventListener('abort', resolve, { once: true }); + }); + return Promise.race([painted, released]); }, detachRenderer() { diff --git a/src/layers/infrastructure/index.js b/src/layers/infrastructure/index.js index 0ff43354e..a25b8f5ef 100644 --- a/src/layers/infrastructure/index.js +++ b/src/layers/infrastructure/index.js @@ -1,3 +1,4 @@ +import { waitForSignal } from '../../sources/requestDeadline.js'; import * as geo from '../../geo/index.js'; import { mapAnalystRecord, @@ -131,7 +132,7 @@ export function createInfrastructureSessionLayer( init(viewer, options = {}) { if (viewer || options.engine) return this.attachRenderer(viewer, options); }, - attachRenderer(viewer, { engine = null } = {}) { + attachRenderer(viewer, { engine = null, signal = null } = {}) { if (display) throw Error('Infrastructure display already attached'); if (viewer) { const legacy = createLocalGeoJsonLayer( @@ -149,7 +150,13 @@ export function createInfrastructureSessionLayer( lod: legacy.getLodDiagnostics, }; } else display = nativeFactory(engine, config, services); - if (enabled) return show(); + // The dataset load belongs to the session and keeps running; only this + // scene's wait for it ends when the scene is released. A released + // display is destroyed and ignores what arrives later. + if (enabled) + return waitForSignal(show(), signal).catch((error) => { + if (!signal?.aborted) throw error; + }); }, detachRenderer() { if (!display) return; diff --git a/src/layers/infrastructure/session.test.mjs b/src/layers/infrastructure/session.test.mjs index 3280d33a9..a7d1be453 100644 --- a/src/layers/infrastructure/session.test.mjs +++ b/src/layers/infrastructure/session.test.mjs @@ -61,3 +61,48 @@ test('a bundled file delivered between planets keeps multipart IDs and is read o assert.equal(reads, 1); layer.destroy(); }); + +test('a released scene stops waiting for a stalled dataset load, which stays in the session', async (t) => { + let deliver; + t.mock.method(globalThis, 'fetch', async () => ({ + ok: true, + text: () => + new Promise((resolve) => { + deliver = resolve; + }), + })); + const captures = []; + const layer = createInfrastructureSessionLayer( + { id: 'local-dams', name: 'Dams', url: '/dams' }, + {}, + () => ({ + show: (data) => captures.push(data), + hide() {}, + destroy() {}, + lod() {}, + }), + ); + layer.init(null, { engine: {} }); + const enabled = layer.enable(); + await new Promise(setImmediate); + layer.detachRenderer(); + const scene = new AbortController(); + const attaching = layer.attachRenderer(null, { + engine: {}, + signal: scene.signal, + }); + scene.abort(); + await attaching; + layer.detachRenderer(); + deliver( + JSON.stringify({ + type: 'Feature', + id: 'd', + properties: { name: 'Dam' }, + geometry: { type: 'Point', coordinates: [10, 20] }, + }), + ); + await enabled; + assert.equal(captures.length, 0); + assert.equal(layer.getAnalystRecords().length, 1); +}); diff --git a/src/layers/submarineCables/session.js b/src/layers/submarineCables/session.js index 12a132f0c..62a9aab20 100644 --- a/src/layers/submarineCables/session.js +++ b/src/layers/submarineCables/session.js @@ -1,3 +1,4 @@ +import { waitForSignal } from '../../sources/requestDeadline.js'; /** The dataset is acquired once per activation, independently of scene attachment. */ export function createCableSessionLayer({ source, @@ -75,7 +76,7 @@ export function createCableSessionLayer({ init(viewer, ctx) { return this.attachRenderer(viewer, ctx); }, - attachRenderer(viewer, { engine } = {}) { + attachRenderer(viewer, { engine, signal = null } = {}) { if (viewer) { const legacy = createCesium({ label: source.label, fetch: read }); legacy.init(viewer); @@ -95,7 +96,13 @@ export function createCableSessionLayer({ stats: () => legacy.getStats(), }; } else display = createNavara(engine); - if (enabled) return show(); + // The cable data load belongs to the session and keeps running; only + // this scene's wait for it ends when the scene is released. show() + // draws nothing into a display that was released meanwhile. + if (enabled) + return waitForSignal(show(), signal).catch((error) => { + if (!signal?.aborted) throw error; + }); }, detachRenderer() { const owner = display; diff --git a/src/layers/submarineCables/session.test.mjs b/src/layers/submarineCables/session.test.mjs index 6aa7c56d9..890ac13b7 100644 --- a/src/layers/submarineCables/session.test.mjs +++ b/src/layers/submarineCables/session.test.mjs @@ -33,3 +33,40 @@ test('cables arriving during handoff belong to the incoming display', async () = assert.equal(reads, 1); layer.destroy(); }); + +test('a released scene stops waiting for a stalled cable load, which stays in the session', async () => { + let resolve; + const shown = []; + const layer = createCableSessionLayer({ + source: { + label: 'fixture', + fetch: () => + new Promise((done) => { + resolve = done; + }), + }, + createNavara: (engine) => ({ + show: (dataset) => shown.push([engine, dataset]), + destroy() {}, + hide() {}, + }), + }); + layer.init(null, { engine: 'outgoing' }); + const pending = layer.enable(); + layer.detachRenderer(); + const scene = new AbortController(); + const attaching = layer.attachRenderer(null, { + engine: 'released', + signal: scene.signal, + }); + scene.abort(); + await attaching; + layer.detachRenderer(); + const dataset = { cables: { features: [] }, landingPoints: { features: [] } }; + resolve(dataset); + await pending; + assert.deepEqual(shown, []); + await layer.attachRenderer(null, { engine: 'next' }); + assert.deepEqual(shown, [['next', dataset]]); + layer.destroy(); +});