Detach every layer from a scene even when one fails, and let in-flight attachments stop first

This commit is contained in:
German Khokhlov committed 2026-11-03 14:47:35 +04:00
1 parent dd040b4f87
commit 2853938df7
2 files changed
+433 -16

No files matched your search

+129 -16
View File
@@ -107,6 +107,12 @@ export class LayerLifecycle {
this._registrationDispositions = null; this._registrationDispositions = null;
this._allowQaRegistration = allowQaRegistration === true; this._allowQaRegistration = allowQaRegistration === true;
this._qaLayerIds = new Set(); this._qaLayerIds = new Set();
// The bound scene: its signal reaches every attachRenderer() call and is
// aborted when the scene detaches. Asynchronous attachments still running
// are kept here so the detach releases them only after they stopped.
this._sceneController = null;
this._sceneDetached = false;
this._pendingAttachments = new Set();
} }
/** The second argument of every layer module call: `{ signal, engine }`. */ /** The second argument of every layer module call: `{ signal, engine }`. */
@@ -125,20 +131,71 @@ export class LayerLifecycle {
* Release the scene only. Models with an explicit renderer lifetime keep * Release the scene only. Models with an explicit renderer lifetime keep
* their entry, polling interval, visibility intents and in-flight source. * their entry, polling interval, visibility intents and in-flight source.
* Legacy modules use their ordinary teardown until they gain that contract. * Legacy modules use their ordinary teardown until they gain that contract.
*
* Every layer is released even when another one fails, and the manager is
* always left unbound, so the next bindCatalog() can attach a scene. The
* failures are reported together. A layer whose release failed keeps it
* pending: a repeated detachScene(), or the next bindCatalog(), retries it.
* Periodic refreshes due while no scene is bound wait for the next one.
*/ */
async detachScene() { async detachScene() {
for (const [id, entry] of [...this.layers]) { // Attachments still in flight stop at their next checkpoint; their
await entry.toggleChain.catch(() => {}); // partial work is released below like a finished attachment.
if (entry.module.attachRenderer && entry.module.detachRenderer) { this._sceneController?.abort();
if (entry.initialized) await entry.module.detachRenderer(this.viewer); this._sceneController = null;
} else if (!(await this.destroyLayer(id))) { this._sceneDetached = true;
throw new Error(`Cannot detach layer ${id}`); const viewer = this.viewer;
const errors = [];
const droppedQaLayers = [];
try {
await Promise.allSettled([...this._pendingAttachments]);
for (const [id, entry] of [...this.layers]) {
try {
await entry.toggleChain.catch(() => {});
if (entry.module.attachRenderer && entry.module.detachRenderer) {
await this._detachEntryRenderer(entry, viewer);
} else {
const qaLayer = this._qaLayerIds.has(id);
if (!(await this.destroyLayer(id))) {
// The next catalog brings a fresh module for this id. A stale
// entry would refuse it, so the failed one is dropped here.
this._stopUpdateLoop(entry);
this.layers.delete(id);
this._qaLayerIds.delete(id);
throw new Error(`Cannot detach layer ${id}`);
}
if (qaLayer) droppedQaLayers.push(id);
}
} catch (error) {
errors.push(error);
}
} }
} finally {
this.viewer = null;
this.engine = null;
this._registrationsFinalized = false;
this._registrationDispositions = null;
} }
this.viewer = null; if (droppedQaLayers.length)
this.engine = null; console.warn(
this._registrationsFinalized = false; `[Data] QA layers do not survive a scene switch; register them again: ${droppedQaLayers.join(', ')}`,
this._registrationDispositions = null; );
if (errors.length) {
for (const error of errors)
console.warn('[Data] scene detach error:', error);
throw new AggregateError(errors, 'Data layers could not detach');
}
}
/** Release one retained layer's renderer, keeping a failure for a retry. */
async _detachEntryRenderer(entry, viewer) {
const pending = entry.pendingSceneDetach;
if (!pending && (!entry.initialized || entry.sceneDetached)) return;
const target = pending ? pending.viewer : viewer;
entry.pendingSceneDetach = { viewer: target };
await entry.module.detachRenderer(target);
entry.pendingSceneDetach = null;
entry.sceneDetached = true;
} }
/** Bind the next catalog only after the preceding scene released its entries. */ /** Bind the next catalog only after the preceding scene released its entries. */
@@ -147,6 +204,12 @@ export class LayerLifecycle {
this.viewer = viewer; this.viewer = viewer;
this.engine = engine ?? engineFromViewer(viewer) ?? null; this.engine = engine ?? engineFromViewer(viewer) ?? null;
this._catalogSourceAvailability = catalog.getSourceAvailability; this._catalogSourceAvailability = catalog.getSourceAvailability;
const scene = new AbortController();
this._sceneController = scene;
this._sceneDetached = false;
// An attachment that is still running when the scene detaches sees this
// signal aborted and must stop touching the scene.
const context = { ...this._moduleContext(scene.signal), ...options };
const attachments = []; const attachments = [];
for (const layer of catalog.layers) { for (const layer of catalog.layers) {
const entry = this.layers.get(layer.id); const entry = this.layers.get(layer.id);
@@ -155,11 +218,7 @@ export class LayerLifecycle {
if (entry.module !== layer) if (entry.module !== layer)
throw new Error(`Layer owner changed: ${layer.id}`); throw new Error(`Layer owner changed: ${layer.id}`);
if (entry.initialized) { if (entry.initialized) {
const ready = layer.attachRenderer(viewer, { attachments.push(this._attachEntryRenderer(entry, viewer, context));
...this._moduleContext(),
...options,
});
if (ready?.then) attachments.push(ready);
} }
} }
} }
@@ -171,7 +230,50 @@ export class LayerLifecycle {
this.layers.set(id, entry); this.layers.set(id, entry);
} }
this.finalizeRegistrations(catalog.metadata); this.finalizeRegistrations(catalog.metadata);
if (attachments.length) return Promise.all(attachments); const pending = attachments.filter((ready) => ready?.then);
const settled = Promise.allSettled(pending).then(() => {
if (this._sceneController === scene) this._runDeferredRefreshes();
});
if (pending.length) return settled.then(() => Promise.all(pending));
}
/**
* Attach one retained layer to the bound scene. A release that failed on
* the previous scene is retried first. Asynchronous attachments are tracked
* so detachScene() releases them only after they stopped.
*/
_attachEntryRenderer(entry, viewer, context) {
let ready;
try {
const pending = entry.pendingSceneDetach;
entry.sceneDetached = false;
if (pending) {
ready = Promise.resolve()
.then(() => entry.module.detachRenderer(pending.viewer))
.then(() => {
entry.pendingSceneDetach = null;
if (context.signal.aborted) return undefined;
return entry.module.attachRenderer(viewer, context);
});
} else ready = entry.module.attachRenderer(viewer, context);
} catch (error) {
ready = Promise.reject(error);
}
if (!ready?.then) return ready;
const tracked = Promise.resolve(ready);
this._pendingAttachments.add(tracked);
const untrack = () => this._pendingAttachments.delete(tracked);
tracked.then(untrack, untrack);
return tracked;
}
/** Run the periodic refreshes that came due while no scene was bound. */
_runDeferredRefreshes() {
for (const [layerId, entry] of this.layers) {
if (!entry.refreshDeferred) continue;
entry.refreshDeferred = false;
void this._runPeriodicUpdate(layerId, entry);
}
} }
/** Register a synthetic layer after sealing in an explicitly dev-enabled manager. */ /** Register a synthetic layer after sealing in an explicitly dev-enabled manager. */
@@ -251,6 +353,12 @@ export class LayerLifecycle {
// interval is armed after the user already turned the layer off, and a // interval is armed after the user already turned the layer off, and a
// subsequent enable arms a SECOND interval → 2× poll → OpenSky 429 (M1). // subsequent enable arms a SECOND interval → 2× poll → OpenSky 429 (M1).
toggleChain: Promise.resolve(), toggleChain: Promise.resolve(),
// Scene handoff (detachScene/bindCatalog): whether this entry's
// renderer is released, a release that failed and is retried, and a
// periodic refresh that came due while no scene was bound.
sceneDetached: false,
pendingSceneDetach: null,
refreshDeferred: false,
}); });
} }
@@ -346,6 +454,11 @@ export class LayerLifecycle {
} }
async _runPeriodicUpdate(layerId, entry, { signal = null } = {}) { async _runPeriodicUpdate(layerId, entry, { signal = null } = {}) {
if (this._sceneDetached) {
// No renderer to draw into: refresh as soon as the next scene binds.
if (entry.enabled && !entry.destroying) entry.refreshDeferred = true;
return false;
}
if ( if (
!entry.enabled || !entry.enabled ||
entry.lifecycleState !== 'enabled' || entry.lifecycleState !== 'enabled' ||
+304
View File
@@ -0,0 +1,304 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { LayerLifecycle } from './lifecycle.js';
// A layer with a renderer lifetime: it outlives scenes through
// detachRenderer()/attachRenderer(). Hooks replace the default behaviour.
function sceneLayer(id, hooks = {}) {
const calls = [];
const module = {
id,
name: id,
icon: '',
source: 'test',
init(viewer, context) {
calls.push('init');
module.attachRenderer(viewer, context);
return true;
},
attachRenderer(viewer, context) {
calls.push(['attach', viewer]);
return hooks.attach?.(viewer, context);
},
detachRenderer(viewer) {
calls.push(['detach', viewer]);
return hooks.detach?.(viewer);
},
enable: () => true,
update(viewer) {
calls.push(['update', viewer]);
return true;
},
disable: () => true,
destroy: () => true,
};
return { module, calls };
}
function catalog(...modules) {
return {
layers: modules,
metadata: modules.map(({ id }) => ({
id,
disposition: 'enabled-only',
token: id,
})),
};
}
const engine = (name) => ({ kind: 'test', name });
const firstViewer = { name: 'first viewer' };
const secondViewer = { name: 'second viewer' };
async function boundManager(...layers) {
const manager = new LayerLifecycle(firstViewer, {
engine: engine('first'),
});
const modules = layers.map(({ module }) => module);
await manager.bindCatalog(firstViewer, engine('first'), catalog(...modules));
for (const { module } of layers)
assert.equal(
await manager.setEnabled(module.id, true, { origin: 'user' }),
true,
);
return { manager, modules };
}
// Detach failures are also logged; keep the test output to the assertions.
async function quietly(run) {
const warn = console.warn;
console.warn = () => {};
try {
return await run();
} finally {
console.warn = warn;
}
}
const detaches = (calls) => calls.filter((call) => call[0] === 'detach');
const attaches = (calls) => calls.filter((call) => call[0] === 'attach');
test('one failing detachRenderer leaves the others released and the manager unbound', async () => {
let failures = 1;
const broken = sceneLayer('broken', {
detach() {
if (failures-- > 0) throw new Error('release failed');
},
});
const after = sceneLayer('after');
const { manager, modules } = await boundManager(broken, after);
await assert.rejects(
quietly(() => manager.detachScene()),
(error) => {
assert.ok(error instanceof AggregateError);
assert.deepEqual(
error.errors.map(({ message }) => message),
['release failed'],
);
return true;
},
);
assert.deepEqual(detaches(after.calls), [['detach', firstViewer]]);
assert.equal(manager.viewer, null);
assert.equal(manager.engine, null);
assert.equal(manager.registrationsFinalized, false);
// A repeated detach retries only the failed release, on its own viewer.
await manager.detachScene();
assert.deepEqual(detaches(broken.calls), [
['detach', firstViewer],
['detach', firstViewer],
]);
assert.equal(detaches(after.calls).length, 1);
// The next scene binds and both retained layers attach to it.
await manager.bindCatalog(
secondViewer,
engine('second'),
catalog(...modules),
);
assert.equal(manager.viewer, secondViewer);
assert.deepEqual(attaches(broken.calls).at(-1), ['attach', secondViewer]);
assert.deepEqual(attaches(after.calls).at(-1), ['attach', secondViewer]);
assert.equal(manager.isEnabled('broken'), true);
});
test('bindCatalog after a failed detach retries the release before attaching', async () => {
let failures = 1;
const broken = sceneLayer('broken', {
detach() {
if (failures-- > 0) throw new Error('release failed');
},
});
const { manager, modules } = await boundManager(broken);
await assert.rejects(
quietly(() => manager.detachScene()),
AggregateError,
);
await manager.bindCatalog(
secondViewer,
engine('second'),
catalog(...modules),
);
assert.deepEqual(broken.calls.slice(-3), [
['detach', firstViewer],
['detach', firstViewer],
['attach', secondViewer],
]);
// The scene is settled: the next detach releases the second viewer once.
await manager.detachScene();
assert.deepEqual(broken.calls.at(-1), ['detach', secondViewer]);
});
test('an asynchronous attach detached mid-flight stops before the release', async () => {
let resume;
let attachSignal;
let attachFinished = false;
let sawAbortBeforeFinishing = null;
const slow = sceneLayer('slow', {
async attach(viewer, { signal }) {
if (viewer !== secondViewer) return;
attachSignal = signal;
await new Promise((resolve) => {
resume = resolve;
});
sawAbortBeforeFinishing = signal.aborted;
attachFinished = true;
},
detach() {
assert.equal(
attachFinished || attachSignal === undefined,
true,
'the release waits for the attachment to stop',
);
},
});
const { manager, modules } = await boundManager(slow);
await manager.detachScene();
const bound = manager.bindCatalog(
secondViewer,
engine('second'),
catalog(...modules),
);
assert.ok(bound?.then, 'an asynchronous attachment is awaited');
assert.equal(attachSignal.aborted, false);
const detached = manager.detachScene();
assert.equal(attachSignal.aborted, true);
let released = false;
void detached.then(() => {
released = true;
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(released, false, 'the detach waits for the attachment');
assert.equal(detaches(slow.calls).length, 1);
resume();
await detached;
await bound;
assert.equal(sawAbortBeforeFinishing, true);
assert.deepEqual(detaches(slow.calls).at(-1), ['detach', secondViewer]);
});
test('periodic refreshes due while no scene is bound run after the next bind', async () => {
const layer = sceneLayer('feed');
const { manager, modules } = await boundManager(layer);
const entry = manager.layers.get('feed');
const updates = () =>
layer.calls.filter((call) => call[0] === 'update').length;
const before = updates();
await manager.detachScene();
assert.equal(await manager._runPeriodicUpdate('feed', entry), false);
assert.equal(updates(), before, 'no refresh against a detached scene');
assert.equal(entry.refreshDeferred, true);
await manager.bindCatalog(
secondViewer,
engine('second'),
catalog(...modules),
);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(updates(), before + 1);
assert.deepEqual(layer.calls.at(-1), ['update', secondViewer]);
assert.equal(entry.refreshDeferred, false);
});
test('a legacy layer that cannot be destroyed is dropped so the next catalog binds', async () => {
const legacy = {
id: 'legacy',
name: 'legacy',
icon: '',
source: 'test',
init: () => true,
enable: () => true,
update: () => true,
disable() {
throw new Error('disable failed');
},
destroy: () => true,
};
const retained = sceneLayer('retained');
const manager = new LayerLifecycle(firstViewer, { engine: engine('first') });
await manager.bindCatalog(
firstViewer,
engine('first'),
catalog(legacy, retained.module),
);
await manager.setEnabled('legacy', true, { origin: 'user' });
await manager.setEnabled('retained', true, { origin: 'user' });
const warn = console.warn;
console.warn = () => {};
try {
await assert.rejects(manager.detachScene(), /could not detach/);
} finally {
console.warn = warn;
}
assert.equal(manager.layers.has('legacy'), false);
assert.deepEqual(detaches(retained.calls), [['detach', firstViewer]]);
const fresh = { ...legacy, disable: () => true };
await manager.bindCatalog(
secondViewer,
engine('second'),
catalog(fresh, retained.module),
);
assert.equal(manager.layers.get('legacy').module, fresh);
});
test('QA layers are reported when a scene switch drops them', async () => {
const retained = sceneLayer('retained');
const manager = new LayerLifecycle(firstViewer, {
engine: engine('first'),
allowQaRegistration: true,
});
await manager.bindCatalog(
firstViewer,
engine('first'),
catalog(retained.module),
);
manager.registerForQa({
id: 'qa-probe',
name: 'QA',
icon: '',
source: 'test',
init: () => true,
enable: () => true,
update: () => true,
disable: () => true,
destroy: () => true,
});
const warnings = [];
const warn = console.warn;
console.warn = (...args) => warnings.push(args.join(' '));
try {
await manager.detachScene();
} finally {
console.warn = warn;
}
assert.equal(manager.layers.has('qa-probe'), false);
assert.ok(warnings.some((line) => line.includes('qa-probe')));
});