Stop scene attachments of Bhote Koshi, GhostMaps, cables and infrastructure waiting once their scene is released

This commit is contained in:
German Khokhlov committed 2026-11-03 15:23:21 +04:00
1 parent cf6d04b7db
commit 9cc34e324f
7 files changed
+209 -14

No files matched your search

+30 -4
View File
@@ -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();
+46
View File
@@ -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;
}
});
+33 -6
View File
@@ -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() {
+9 -2
View File
@@ -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;
@@ -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);
});
+9 -2
View File
@@ -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;
@@ -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();
});