diff --git a/src/app/sceneHost.js b/src/app/sceneHost.js new file mode 100644 index 000000000..b56842c31 --- /dev/null +++ b/src/app/sceneHost.js @@ -0,0 +1,214 @@ +// The page's scene host: the application is created once per page, and only +// the parts bound to a globe engine (its viewer, canvas, camera and frames) +// detach from the outgoing engine and attach to the incoming one. +// +// A part is `{ name, attachScene(scene, { signal, defer }), detachScene({ handoff }) }`. +// Parts attach in registration order and detach in reverse. `signal` belongs +// to one attachment: it aborts when that attachment detaches, so work a part +// started for one engine never lands on the next. `defer(cleanup)` registers +// a release that runs, in reverse, when the part detaches (before its +// detachScene). + +const STATES = Object.freeze([ + 'detached', + 'attaching', + 'attached', + 'detaching', +]); + +export class SceneSwitchingError extends Error { + constructor(message = 'The globe is switching engines') { + super(message); + this.name = 'SceneSwitchingError'; + this.code = 'ENGINE_SWITCHING'; + } +} + +/** + * @param {{onError?: (error: unknown, part: string) => void}} [options] + */ +export function createSceneHost({ onError = null } = {}) { + const parts = []; + const listeners = new Set(); + let state = 'detached'; + let scene = null; + // The live attachment: its controller and each attached part's cleanups. + let attachment = null; + // The attach or detach under way, so a later call waits for it. + let transition = Promise.resolve(); + + function publish() { + for (const listener of [...listeners]) { + try { + listener({ state, scene }); + } catch (error) { + onError?.(error, 'listener'); + } + } + } + + async function attachPart(part, target, current) { + const defers = []; + const entry = { part, defers }; + current.attached.push(entry); + await part.attachScene?.(target, { + signal: current.controller.signal, + defer(cleanup) { + if (typeof cleanup !== 'function') + throw new TypeError('A scene part defers a function'); + defers.push(cleanup); + }, + }); + current.controller.signal.throwIfAborted(); + } + + async function detachEntry(entry, options, errors) { + while (entry.defers.length) { + const cleanup = entry.defers.pop(); + try { + await cleanup(); + } catch (error) { + errors.push(error); + onError?.(error, entry.part.name); + } + } + try { + await entry.part.detachScene?.(options); + } catch (error) { + errors.push(error); + onError?.(error, entry.part.name); + } + } + + async function release(current, options) { + current.controller.abort(); + const errors = []; + while (current.attached.length) + await detachEntry(current.attached.pop(), options, errors); + return errors; + } + + function subscribe(listener) { + if (typeof listener !== 'function') + throw new TypeError('Expected a scene listener'); + listeners.add(listener); + return () => listeners.delete(listener); + } + + function queue(work) { + const run = transition.then(work, work); + transition = run.catch(() => {}); + return run; + } + + return Object.freeze({ + /** + * Add a part. While a scene is attached the part attaches to it now, and + * the returned promise settles with that attachment. + */ + register(part) { + if (!part || typeof part !== 'object' || typeof part.name !== 'string') + throw new TypeError('A scene part needs a name'); + if (parts.includes(part)) return Promise.resolve(); + parts.push(part); + if (state !== 'attached' || !attachment) return Promise.resolve(); + return attachPart(part, scene, attachment); + }, + /** Attach every part to `target`, in order. A failure detaches the parts it attached. */ + attach(target) { + if (!target) throw new TypeError('Attach needs a scene'); + return queue(async () => { + if (state !== 'detached') + throw new Error(`Cannot attach a scene while ${state}`); + state = 'attaching'; + const current = { controller: new AbortController(), attached: [] }; + attachment = current; + scene = target; + publish(); + try { + for (const part of [...parts]) + await attachPart(part, target, current); + } catch (error) { + const errors = await release(current, { + handoff: false, + failed: true, + }); + attachment = null; + scene = null; + state = 'detached'; + publish(); + if (errors.length) + throw new AggregateError( + [error, ...errors], + 'A scene failed to attach and to release', + ); + throw error; + } + state = 'attached'; + publish(); + return target; + }); + }, + /** + * Detach every attached part, in reverse. Detaching nothing is a no-op; + * a part that fails to detach does not stop the others. + */ + detach(options = {}) { + return queue(async () => { + if (state === 'detached' || !attachment) return; + const current = attachment; + state = 'detaching'; + publish(); + let errors; + try { + errors = await release(current, options); + } finally { + attachment = null; + scene = null; + state = 'detached'; + publish(); + } + if (errors.length) + throw new AggregateError(errors, 'Scene parts could not detach'); + }); + }, + /** The attached scene, or null. */ + current: () => (state === 'attached' ? scene : null), + state: () => state, + parts: () => parts.map(({ name }) => name), + subscribe, + /** + * The attached scene, now or once one attaches. Rejects with a + * SceneSwitchingError when `signal` aborts or `timeoutMs` passes first. + */ + whenAttached({ signal = null, timeoutMs = Infinity } = {}) { + if (state === 'attached') return Promise.resolve(scene); + return new Promise((resolve, reject) => { + let timer = null; + const finish = (error, value) => { + clearTimeout(timer); + unsubscribe(); + signal?.removeEventListener('abort', onAbort); + if (error) reject(error); + else resolve(value); + }; + const onAbort = () => + finish( + new SceneSwitchingError('The wait for the globe was cancelled'), + ); + const unsubscribe = subscribe(({ state: next, scene: value }) => { + if (next === 'attached') finish(null, value); + }); + if (signal?.aborted) return onAbort(); + signal?.addEventListener('abort', onAbort, { once: true }); + if (Number.isFinite(timeoutMs)) + timer = setTimeout( + () => finish(new SceneSwitchingError()), + Math.max(0, timeoutMs), + ); + }); + }, + }); +} + +export const SCENE_HOST_STATES = STATES; diff --git a/src/app/sceneHost.test.mjs b/src/app/sceneHost.test.mjs new file mode 100644 index 000000000..433a1e8e7 --- /dev/null +++ b/src/app/sceneHost.test.mjs @@ -0,0 +1,180 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createSceneHost, SceneSwitchingError } from './sceneHost.js'; + +function recordingPart(name, log, { failAttach = false } = {}) { + const part = { + name, + signals: [], + async attachScene(scene, { signal, defer }) { + log.push(`attach ${name} ${scene.kind}`); + part.signals.push(signal); + defer(() => log.push(`cleanup ${name}`)); + if (failAttach) throw new Error(`${name} failed`); + }, + detachScene(options) { + log.push(`detach ${name}${options?.handoff ? ' handoff' : ''}`); + }, + }; + return part; +} + +test('parts attach in order and detach in reverse, cleanups first', async () => { + const log = []; + const host = createSceneHost(); + const a = recordingPart('a', log); + const b = recordingPart('b', log); + host.register(a); + host.register(b); + const scene = { kind: 'cesium' }; + assert.equal(await host.attach(scene), scene); + assert.equal(host.current(), scene); + assert.equal(host.state(), 'attached'); + await host.detach({ handoff: true }); + assert.deepEqual(log, [ + 'attach a cesium', + 'attach b cesium', + 'cleanup b', + 'detach b handoff', + 'cleanup a', + 'detach a handoff', + ]); + assert.equal(host.current(), null); + assert.ok(a.signals[0].aborted, 'the attachment signal aborts on detach'); +}); + +test('a failed part detaches the parts already attached and aborts only that attachment', async () => { + const log = []; + const host = createSceneHost(); + const a = recordingPart('a', log); + const b = recordingPart('b', log, { failAttach: true }); + const c = recordingPart('c', log); + for (const part of [a, b, c]) host.register(part); + await assert.rejects(host.attach({ kind: 'navara' }), /b failed/); + assert.deepEqual(log, [ + 'attach a navara', + 'attach b navara', + 'cleanup b', + 'detach b', + 'cleanup a', + 'detach a', + ]); + assert.equal(host.state(), 'detached'); + assert.ok(a.signals[0].aborted); + // The working engine attaches again on the same parts. + b.attachScene = async (scene, { defer }) => { + log.push(`attach b ${scene.kind}`); + defer(() => log.push('cleanup b')); + }; + log.length = 0; + await host.attach({ kind: 'cesium' }); + assert.deepEqual(log, [ + 'attach a cesium', + 'attach b cesium', + 'attach c cesium', + ]); + assert.equal(a.signals.length, 2); + assert.equal(a.signals[1].aborted, false); +}); + +test('detach is idempotent and a failing part does not stop the others', async () => { + const log = []; + const host = createSceneHost(); + host.register(recordingPart('a', log)); + host.register({ + name: 'bad', + attachScene() {}, + detachScene() { + throw new Error('bad detach'); + }, + }); + await host.detach(); + assert.deepEqual(log, []); + await host.attach({ kind: 'cesium' }); + await assert.rejects(host.detach(), AggregateError); + assert.ok(log.includes('detach a')); + assert.equal(host.state(), 'detached'); + await host.detach(); + assert.equal(log.filter((line) => line === 'detach a').length, 1); +}); + +test('a part registered while attached attaches at once and detaches with the rest', async () => { + const log = []; + const host = createSceneHost(); + host.register(recordingPart('a', log)); + await host.attach({ kind: 'cesium' }); + await host.register(recordingPart('late', log)); + assert.deepEqual(log, ['attach a cesium', 'attach late cesium']); + await host.detach(); + assert.deepEqual(log.slice(2), [ + 'cleanup late', + 'detach late', + 'cleanup a', + 'detach a', + ]); + assert.deepEqual(host.parts(), ['a', 'late']); +}); + +test('attach refuses while a scene is attached', async () => { + const host = createSceneHost(); + await host.attach({ kind: 'cesium' }); + await assert.rejects(host.attach({ kind: 'navara' }), /while attached/); +}); + +test('whenAttached resolves on the next attachment and rejects on abort or timeout', async () => { + const host = createSceneHost(); + const waiting = host.whenAttached(); + const scene = { kind: 'navara' }; + await host.attach(scene); + assert.equal(await waiting, scene); + assert.equal(await host.whenAttached(), scene); + await host.detach(); + const controller = new AbortController(); + const aborted = host.whenAttached({ signal: controller.signal }); + controller.abort(); + await assert.rejects(aborted, (error) => { + assert.ok(error instanceof SceneSwitchingError); + assert.equal(error.code, 'ENGINE_SWITCHING'); + return true; + }); + await assert.rejects( + host.whenAttached({ timeoutMs: 5 }), + (error) => error.code === 'ENGINE_SWITCHING', + ); +}); + +test('subscribers see every state of a switch', async () => { + const host = createSceneHost(); + const states = []; + const off = host.subscribe(({ state }) => states.push(state)); + await host.attach({ kind: 'cesium' }); + await host.detach(); + off(); + await host.attach({ kind: 'navara' }); + assert.deepEqual(states, ['attaching', 'attached', 'detaching', 'detached']); +}); + +test('a detach requested during an attach waits for it', async () => { + const log = []; + const host = createSceneHost(); + let release; + host.register({ + name: 'slow', + async attachScene() { + log.push('attach start'); + await new Promise((resolve) => (release = resolve)); + log.push('attach end'); + }, + detachScene() { + log.push('detach'); + }, + }); + const attaching = host.attach({ kind: 'cesium' }); + const detaching = host.detach(); + await new Promise((resolve) => setImmediate(resolve)); + release(); + await attaching; + await detaching; + assert.deepEqual(log, ['attach start', 'attach end', 'detach']); + assert.equal(host.state(), 'detached'); +});