mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 21:02:19 +00:00
Serialize engine and shell transitions through the scene host
Move engine request ordering and the single rollback decision into the scene host. Run handoff, renderer release, candidate acquisition, attachment, restoration and settling in one transaction. Pass that transaction to nested parts so unrelated calls wait without putting nested work behind itself. Keep the engine-switch entry point as a delegating adapter. Route direct shell attachment and detachment through the same host and remove the shell queue. Serialize late part registration with attachment and release. Keep failed-attachment cleanup and renderer release inside the transaction, including failed restoration. Replacement applications share the transition owner with fresh parts, and retired applications release their part registry. A shell used alone creates its own scene host. Preserve Cesium's picker and history ordering, single recovery, retained layer session, cockpit restoration, hidden-tab settling and parked-renderer policy. Navara keeps the same behavior. No user-visible change is intended on either engine. Keep the existing behavioral assertions and cover concurrent operations, failed cleanup followed by recovery, retired transaction handles, replacement startup failure and parked expiry during a switch. All eleven coupling counts remain unchanged; none fell or grew. Renderer lines remain 0 and 189, external Cesium import files 3, viewer lines 891, Navara kind checks 1, engine-sniff calls 0, extension reads 3, ground passes 1, SDK loads 3, screen-handler files 4 and engine-name lines 32. Checked on the keyless production build: the engine, Director and layer handoff switches, app-once, and the rapid, hidden-tab, context-loss, parked-release, failure-matrix and soak scenarios pass.
This commit is contained in:
1 parent
c1e857f798
commit
7f3754d812
23 files changed
+771
-287
No files matched your search
@@ -637,8 +637,13 @@ parking and failed-release retries. Lifecycle keeps visibility intentions and
|
||||
refresh policy. `app/layerSession.js` defines the retained session fields and
|
||||
handoff predicate. `standalone/sceneSwitcher.js` coordinates page handoff;
|
||||
`ui/shellScene.js` owns scene-bound shell construction, while `applicationShell`
|
||||
keeps page controls and policy. Request, host and shell queues are not yet one
|
||||
queue. Pick ownership, selection dispatch and render demand belong to
|
||||
keeps page controls and policy. `app/sceneHost.js` owns the transition queue,
|
||||
request ordering and recovery decision. Application acquisition, attachment,
|
||||
restoration and renderer release run in that queue, and shell calls use it too.
|
||||
Nested work receives an explicit transaction; outside calls wait for the whole
|
||||
transition. A replacement application shares the transition owner with fresh
|
||||
parts, and a shell used alone creates its own host. Pick ownership, selection
|
||||
dispatch and render demand belong to
|
||||
`scene.services`; render demand drives the engine contract, with no dependency
|
||||
from the Cesium render backend to the governor. Overlay and tracked-readout
|
||||
services still include module singletons; debug access still exposes the native
|
||||
|
||||
+10
-2
@@ -270,8 +270,16 @@ one application, and only the globe-bound parts move to the next engine.
|
||||
unknown kind and no native extensions, draws fixtures, hides or releases them,
|
||||
and checks primitives, subscriptions and service records before engine
|
||||
destruction. The three self-drawing entries are named skips. This is a
|
||||
display-level rehearsal; application kind registration, the remaining scene
|
||||
services, queue consolidation and native debug access remain work.
|
||||
display-level rehearsal; application kind registration and native debug
|
||||
access remain work.
|
||||
- **Scene transition ownership.** `src/app/sceneHost.js` serializes engine
|
||||
requests, application attachment and direct shell calls. One transaction
|
||||
covers handoff, renderer release, candidate acquisition, attachment, view
|
||||
restoration and the single recovery attempt. Nested parts receive that
|
||||
transaction explicitly; unrelated calls wait for it. Picker/history ordering,
|
||||
hidden-tab settling and parked-renderer reuse retain their existing behavior.
|
||||
A replacement application registers fresh parts on the same transition owner;
|
||||
a shell used alone creates its own scene host.
|
||||
- **Scene pick ownership.** Each attachment owns `scene.services.picking` and
|
||||
`scene.services.selection` (`src/data/sceneServices.js`). Layers and displays
|
||||
resolve these services with their attached engine; simultaneous engines do
|
||||
|
||||
@@ -1031,6 +1031,8 @@
|
||||
"src/ui/applicationShell.js",
|
||||
"src/app/layerSession.js",
|
||||
"src/ui/shellScene.js",
|
||||
"src/app/sceneHost.js",
|
||||
"src/app/engineChoice.js",
|
||||
"src/ui/localePicker.js",
|
||||
"src/ui/cyberSonarControls.js",
|
||||
"src/ui/locationNavigation.js",
|
||||
@@ -2940,6 +2942,8 @@
|
||||
"src/ui/applicationShell.js",
|
||||
"src/app/layerSession.js",
|
||||
"src/ui/shellScene.js",
|
||||
"src/app/sceneHost.js",
|
||||
"src/app/engineChoice.js",
|
||||
"src/ui/mobileShell.js",
|
||||
"src/ui/localePicker.js",
|
||||
"src/ui/cyberSonarControls.js",
|
||||
|
||||
Vendored
+2
-1
@@ -59,11 +59,12 @@ export async function createApplicationControls({
|
||||
initialShareCode,
|
||||
engineSwitch,
|
||||
layerSession,
|
||||
sceneHost: scene.sceneHost,
|
||||
localePicker,
|
||||
});
|
||||
defer(() => styleManager.orbitController.stop());
|
||||
defer(() => styleManager.hud.destroy());
|
||||
defer(() => styleManager.dispose());
|
||||
defer(() => styleManager.dispose(scene.transaction));
|
||||
// The shell was built on this globe; later globes attach to it.
|
||||
await attachScenePart(
|
||||
scene,
|
||||
|
||||
@@ -25,7 +25,7 @@ test('the controls pass the scene engine to the shell', () => {
|
||||
// A later globe reaches the shell with its own engine.
|
||||
assert.match(
|
||||
read('./shellPart.js'),
|
||||
/styleManager\.attachScene\(\{\s+viewer: next\.viewer,\s+engine: next\.engine,/,
|
||||
/styleManager\.attachScene\(\s*\{\s+viewer: next\.viewer,\s+engine: next\.engine,/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
+277
-25
@@ -1,8 +1,11 @@
|
||||
import { ENGINE_CHOICES } from './engineChoice.js';
|
||||
|
||||
// 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 }) }`.
|
||||
// A part is `{ name, attachScene(scene, { signal, defer, transaction }),
|
||||
// detachScene({ handoff }, transaction) }`.
|
||||
// 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
|
||||
@@ -18,9 +21,14 @@ export class SceneSwitchingError extends Error {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{onError?: (error: unknown, part: string) => void}} [options]
|
||||
* `transitionHost` shares the queue with a replacement application's fresh
|
||||
* parts. Transactions are valid only while their enclosing work runs.
|
||||
* @param {{onError?: (error: unknown, part: string) => void, transitionHost?: object}} [options]
|
||||
*/
|
||||
export function createSceneHost({ onError = null } = {}) {
|
||||
export function createSceneHost({
|
||||
onError = null,
|
||||
transitionHost = null,
|
||||
} = {}) {
|
||||
const parts = [];
|
||||
const listeners = new Set();
|
||||
let state = 'detached';
|
||||
@@ -29,6 +37,8 @@ export function createSceneHost({ onError = null } = {}) {
|
||||
let attachment = null;
|
||||
// The attach or detach under way, so a later call waits for it.
|
||||
let transition = Promise.resolve();
|
||||
const active = new Set();
|
||||
let scheduled = 0;
|
||||
|
||||
function publish() {
|
||||
for (const listener of [...listeners]) {
|
||||
@@ -40,12 +50,13 @@ export function createSceneHost({ onError = null } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function attachPart(part, target, current) {
|
||||
async function attachPart(part, target, current, transaction) {
|
||||
const defers = [];
|
||||
const entry = { part, defers };
|
||||
current.attached.push(entry);
|
||||
await part.attachScene?.(target, {
|
||||
signal: current.controller.signal,
|
||||
transaction,
|
||||
defer(cleanup) {
|
||||
if (typeof cleanup !== 'function')
|
||||
throw new TypeError('A scene part defers a function');
|
||||
@@ -55,7 +66,7 @@ export function createSceneHost({ onError = null } = {}) {
|
||||
current.controller.signal.throwIfAborted();
|
||||
}
|
||||
|
||||
async function detachEntry(entry, options, errors) {
|
||||
async function detachEntry(entry, options, errors, transaction) {
|
||||
while (entry.defers.length) {
|
||||
const cleanup = entry.defers.pop();
|
||||
try {
|
||||
@@ -66,18 +77,18 @@ export function createSceneHost({ onError = null } = {}) {
|
||||
}
|
||||
}
|
||||
try {
|
||||
await entry.part.detachScene?.(options);
|
||||
await entry.part.detachScene?.(options, transaction);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
onError?.(error, entry.part.name);
|
||||
}
|
||||
}
|
||||
|
||||
async function release(current, options) {
|
||||
async function release(current, options, transaction) {
|
||||
current.controller.abort();
|
||||
const errors = [];
|
||||
while (current.attached.length)
|
||||
await detachEntry(current.attached.pop(), options, errors);
|
||||
await detachEntry(current.attached.pop(), options, errors, transaction);
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -88,29 +99,59 @@ export function createSceneHost({ onError = null } = {}) {
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function queue(work) {
|
||||
const run = transition.then(work, work);
|
||||
transition = run.catch(() => {});
|
||||
return run;
|
||||
// Nested steps receive the current transaction explicitly. Unrelated calls
|
||||
// always wait, even while a part is awaiting attachment or release.
|
||||
function run(work, transaction, immediate = false) {
|
||||
if (transitionHost) return transitionHost.run(work, transaction, immediate);
|
||||
if (transaction && active.has(transaction)) return work(transaction);
|
||||
const execute = async () => {
|
||||
const current = Object.freeze({ host });
|
||||
active.add(current);
|
||||
try {
|
||||
return await work(current);
|
||||
} finally {
|
||||
active.delete(current);
|
||||
scheduled--;
|
||||
}
|
||||
};
|
||||
const idle = scheduled++ === 0;
|
||||
const previous = transition;
|
||||
let start;
|
||||
const result = new Promise((resolve, reject) => {
|
||||
start = () => execute().then(resolve, reject);
|
||||
});
|
||||
transition = result.catch(() => {});
|
||||
if (immediate && idle) start();
|
||||
else previous.then(start, start);
|
||||
return result;
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
const host = Object.freeze({
|
||||
run,
|
||||
createSwitcher: (options) =>
|
||||
createSceneRequests({
|
||||
...options,
|
||||
run: (work) => run(work, null, true),
|
||||
}),
|
||||
/**
|
||||
* Add a part. While a scene is attached the part attaches to it now, and
|
||||
* the returned promise settles with that attachment.
|
||||
*/
|
||||
register(part) {
|
||||
register(part, transaction) {
|
||||
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);
|
||||
return run(async (transaction) => {
|
||||
if (state !== 'attached' || !attachment) return;
|
||||
if (attachment.attached.some((entry) => entry.part === part)) return;
|
||||
return attachPart(part, scene, attachment, transaction);
|
||||
}, transaction);
|
||||
},
|
||||
/** Attach every part to `target`, in order. A failure detaches the parts it attached. */
|
||||
attach(target) {
|
||||
attach(target, transaction) {
|
||||
if (!target) throw new TypeError('Attach needs a scene');
|
||||
return queue(async () => {
|
||||
return run(async (transaction) => {
|
||||
if (state !== 'detached')
|
||||
throw new Error(`Cannot attach a scene while ${state}`);
|
||||
state = 'attaching';
|
||||
@@ -120,10 +161,10 @@ export function createSceneHost({ onError = null } = {}) {
|
||||
publish();
|
||||
try {
|
||||
for (const part of [...parts])
|
||||
await attachPart(part, target, current);
|
||||
await attachPart(part, target, current, transaction);
|
||||
} catch (error) {
|
||||
// The parts keep their own handoff choice; `failed` names why.
|
||||
const errors = await release(current, { failed: true });
|
||||
const errors = await release(current, { failed: true }, transaction);
|
||||
attachment = null;
|
||||
scene = null;
|
||||
state = 'detached';
|
||||
@@ -138,21 +179,21 @@ export function createSceneHost({ onError = null } = {}) {
|
||||
state = 'attached';
|
||||
publish();
|
||||
return target;
|
||||
});
|
||||
}, transaction);
|
||||
},
|
||||
/**
|
||||
* 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 () => {
|
||||
detach(options = {}, transaction) {
|
||||
return run(async (transaction) => {
|
||||
if (state === 'detached' || !attachment) return;
|
||||
const current = attachment;
|
||||
state = 'detaching';
|
||||
publish();
|
||||
let errors;
|
||||
try {
|
||||
errors = await release(current, options);
|
||||
errors = await release(current, options, transaction);
|
||||
} finally {
|
||||
attachment = null;
|
||||
scene = null;
|
||||
@@ -161,7 +202,15 @@ export function createSceneHost({ onError = null } = {}) {
|
||||
}
|
||||
if (errors.length)
|
||||
throw new AggregateError(errors, 'Scene parts could not detach');
|
||||
});
|
||||
}, transaction);
|
||||
},
|
||||
/** Release a retired application's part registry after its resources. */
|
||||
dispose(transaction) {
|
||||
return run(async (transaction) => {
|
||||
await host.detach({}, transaction);
|
||||
parts.length = 0;
|
||||
listeners.clear();
|
||||
}, transaction);
|
||||
},
|
||||
/** The attached scene, or null. */
|
||||
current: () => (state === 'attached' ? scene : null),
|
||||
@@ -200,4 +249,207 @@ export function createSceneHost({ onError = null } = {}) {
|
||||
});
|
||||
},
|
||||
});
|
||||
return host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive engine switches for one page session.
|
||||
*
|
||||
* A failed attempt rolls back to the last engine that actually started
|
||||
* (never to the one that just failed) and, when that recovery fails too,
|
||||
* leaves the page without an engine but still able to take the next request. A page lifetime abort (pagehide) stops the
|
||||
* machine: nothing starts after it.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.kind The engine already running.
|
||||
* @param {() => string} options.requested The engine the address asks for now.
|
||||
* @param {AbortSignal} options.signal The page lifetime.
|
||||
* @param {(from: string, to: string, change: {visit: boolean}) => Promise<void>} options.leave
|
||||
* Hand the session off from the running engine and release that engine.
|
||||
* @param {(kind: string, attempt: {recovery: boolean}) => Promise<void>} options.enter
|
||||
* Start an engine on the retained session. It resolves once the engine runs
|
||||
* with the session attached; when it rejects it has already released what
|
||||
* it started.
|
||||
* @param {{select: (from: ?string) => *, commit: (token: *, kind: string) => void, restore: (kind: string) => void}} options.address
|
||||
* @param {(state: {failed: boolean}) => Promise<void>|void} [options.settle] Runs
|
||||
* once the address is reconciled, before the machine reports idle.
|
||||
* @param {(kind: string) => void} [options.remember] Store a picked engine.
|
||||
* @param {(state: object) => void} [options.onChange]
|
||||
* @param {(error: unknown, outcome: {recovered: boolean, kind: ?string}) => void} [options.onFailure]
|
||||
*/
|
||||
function createSceneRequests({
|
||||
run,
|
||||
kind,
|
||||
requested,
|
||||
signal,
|
||||
leave,
|
||||
enter,
|
||||
address,
|
||||
settle,
|
||||
remember,
|
||||
onChange,
|
||||
onFailure,
|
||||
}) {
|
||||
// The engine whose session is live, null while none is.
|
||||
let current = kind;
|
||||
// The engine attached to the application or starting to attach: what a
|
||||
// history visit would leave.
|
||||
let live = kind;
|
||||
// The last engine that started successfully: the rollback target.
|
||||
let working = kind;
|
||||
let failed = false;
|
||||
let busy = false;
|
||||
// Every request (select or address visit) bumps this; a visit also bumps
|
||||
// `visits`.
|
||||
let requests = 0;
|
||||
let visits = 0;
|
||||
let queued = null;
|
||||
// The pick being started, held here rather than in the address: a visit
|
||||
// made meanwhile retires it and leaves every entry as it was.
|
||||
let pending = null;
|
||||
let running = null;
|
||||
|
||||
const state = () =>
|
||||
Object.freeze({
|
||||
kind: current ?? working,
|
||||
engine: live,
|
||||
busy,
|
||||
failed,
|
||||
});
|
||||
const publish = () => onChange?.(state());
|
||||
|
||||
function prepare(next) {
|
||||
pending = { to: next, token: address.select(current) };
|
||||
}
|
||||
|
||||
/** The picker's choice. While a switch runs it is queued, not dropped. */
|
||||
function select(next) {
|
||||
if (!ENGINE_CHOICES.includes(next) || signal.aborted) return null;
|
||||
requests++;
|
||||
if (busy) {
|
||||
queued = next;
|
||||
return running;
|
||||
}
|
||||
if (next === current) return null;
|
||||
prepare(next);
|
||||
return start();
|
||||
}
|
||||
|
||||
/** The address changed (history visit) or should be checked again. */
|
||||
function reconcile() {
|
||||
if (signal.aborted) return Promise.resolve();
|
||||
requests++;
|
||||
visits++;
|
||||
// A later visit supersedes a pick queued or starting before it.
|
||||
queued = null;
|
||||
pending = null;
|
||||
return busy ? running : start();
|
||||
}
|
||||
|
||||
function start() {
|
||||
busy = true;
|
||||
publish();
|
||||
running = run(async (transaction) => {
|
||||
try {
|
||||
await drive(transaction);
|
||||
} catch (error) {
|
||||
onFailure?.(error, { recovered: current !== null, kind: current });
|
||||
} finally {
|
||||
busy = false;
|
||||
running = null;
|
||||
publish();
|
||||
}
|
||||
});
|
||||
return running;
|
||||
}
|
||||
|
||||
async function drive(transaction) {
|
||||
for (;;) {
|
||||
await reconcileAddress(transaction);
|
||||
if (signal.aborted) return;
|
||||
const seen = requests;
|
||||
try {
|
||||
await settle?.({ failed }, transaction);
|
||||
} catch (error) {
|
||||
// Revealing the globe is cosmetic; the engine runs either way.
|
||||
console.error('The globe switch could not settle:', error);
|
||||
}
|
||||
if (signal.aborted || seen === requests) return;
|
||||
}
|
||||
}
|
||||
|
||||
async function reconcileAddress(transaction) {
|
||||
let handled = null;
|
||||
for (;;) {
|
||||
if (signal.aborted) return;
|
||||
if (queued !== null) {
|
||||
const next = queued;
|
||||
queued = null;
|
||||
if (next !== current) prepare(next);
|
||||
else pending = null;
|
||||
}
|
||||
const target = pending?.to ?? requested();
|
||||
if (target === current) return;
|
||||
// After a failed recovery, wait for the person's next request instead
|
||||
// of retrying the same address in a loop.
|
||||
if (failed && handled === requests) return;
|
||||
handled = requests;
|
||||
await attempt(target, transaction);
|
||||
}
|
||||
}
|
||||
|
||||
async function attempt(target, transaction) {
|
||||
const visitsAtStart = visits;
|
||||
const visit = pending?.to !== target;
|
||||
publish();
|
||||
try {
|
||||
if (current !== null) {
|
||||
const from = current;
|
||||
try {
|
||||
await leave(from, target, { visit }, transaction);
|
||||
} finally {
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
if (signal.aborted) return;
|
||||
live = target;
|
||||
await enter(target, { recovery: false }, transaction);
|
||||
if (signal.aborted) return;
|
||||
current = working = target;
|
||||
failed = false;
|
||||
if (!visit && pending?.to === target) {
|
||||
address.commit(pending.token, target);
|
||||
remember?.(target);
|
||||
}
|
||||
pending = null;
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
pending = null;
|
||||
// A failed pick never touched the address. A failed visit points its
|
||||
// entry at the working engine, unless the person has since moved on.
|
||||
if (visit && visits === visitsAtStart && requested() === target)
|
||||
address.restore(working);
|
||||
if (working === target) {
|
||||
live = null;
|
||||
failed = true;
|
||||
onFailure?.(error, { recovered: false, kind: null });
|
||||
return;
|
||||
}
|
||||
live = working;
|
||||
try {
|
||||
await enter(working, { recovery: true }, transaction);
|
||||
if (signal.aborted) return;
|
||||
current = working;
|
||||
failed = false;
|
||||
onFailure?.(error, { recovered: true, kind: working });
|
||||
} catch (recoveryError) {
|
||||
if (signal.aborted) return;
|
||||
live = null;
|
||||
failed = true;
|
||||
onFailure?.(recoveryError, { recovered: false, kind: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({ select, reconcile, state });
|
||||
}
|
||||
@@ -178,3 +178,143 @@ test('a detach requested during an attach waits for it', async () => {
|
||||
assert.deepEqual(log, ['attach start', 'attach end', 'detach']);
|
||||
assert.equal(host.state(), 'detached');
|
||||
});
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
test('a transaction keeps outside work behind nested attachment and release', async () => {
|
||||
const host = createSceneHost();
|
||||
const gate = deferred();
|
||||
const entered = deferred();
|
||||
const log = [];
|
||||
await host.register({
|
||||
name: 'nested',
|
||||
async attachScene(_scene, { transaction }) {
|
||||
await host.run(() => log.push('nested attach'), transaction);
|
||||
entered.resolve();
|
||||
await gate.promise;
|
||||
},
|
||||
detachScene(_options, transaction) {
|
||||
return host.run(() => log.push('nested detach'), transaction);
|
||||
},
|
||||
});
|
||||
const switching = host.run(async (transaction) => {
|
||||
await host.attach({}, transaction);
|
||||
await host.detach({}, transaction);
|
||||
log.push('released');
|
||||
});
|
||||
await entered.promise;
|
||||
const outside = host.run(() => log.push('outside'));
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(log, ['nested attach']);
|
||||
gate.resolve();
|
||||
await Promise.all([switching, outside]);
|
||||
assert.deepEqual(log, ['nested attach', 'nested detach', 'released', 'outside']);
|
||||
});
|
||||
|
||||
test('late registration settles before a concurrent detach releases the part', async () => {
|
||||
const host = createSceneHost();
|
||||
await host.attach({});
|
||||
const gate = deferred();
|
||||
const entered = deferred();
|
||||
const log = [];
|
||||
const registering = host.register({
|
||||
name: 'late',
|
||||
async attachScene(_scene, { defer }) {
|
||||
entered.resolve();
|
||||
await gate.promise;
|
||||
defer(() => log.push('cleanup'));
|
||||
log.push('attached');
|
||||
},
|
||||
detachScene() { log.push('detached'); },
|
||||
});
|
||||
await entered.promise;
|
||||
const leaving = host.detach();
|
||||
gate.resolve();
|
||||
await Promise.all([registering, leaving]);
|
||||
assert.deepEqual(log, ['attached', 'cleanup', 'detached']);
|
||||
});
|
||||
|
||||
test('replacement application parts use the same transition owner', async () => {
|
||||
const owner = createSceneHost();
|
||||
const gate = deferred();
|
||||
const entered = deferred();
|
||||
const log = [];
|
||||
let replacement;
|
||||
let expired;
|
||||
const switching = owner.run(async (transaction) => {
|
||||
expired = transaction;
|
||||
replacement = createSceneHost({ transitionHost: transaction.host });
|
||||
await replacement.register({
|
||||
name: 'replacement',
|
||||
attachScene() { log.push('attached'); },
|
||||
detachScene() { log.push('detached'); },
|
||||
}, transaction);
|
||||
await replacement.attach({}, transaction);
|
||||
entered.resolve();
|
||||
await gate.promise;
|
||||
log.push('restored');
|
||||
});
|
||||
await entered.promise;
|
||||
const leaving = replacement.detach();
|
||||
gate.resolve();
|
||||
await Promise.all([switching, leaving]);
|
||||
assert.deepEqual(log, ['attached', 'restored', 'detached']);
|
||||
const nextGate = deferred();
|
||||
const blocking = owner.run(() => nextGate.promise);
|
||||
const later = replacement.run(() => log.push('later'), expired);
|
||||
await Promise.resolve();
|
||||
assert.equal(log.includes('later'), false, 'an expired transaction cannot bypass the queue');
|
||||
nextGate.resolve();
|
||||
await Promise.all([blocking, later]);
|
||||
assert.equal(log.at(-1), 'later');
|
||||
});
|
||||
|
||||
test('engine requests wait for the host and a newer pick can keep the current engine', async () => {
|
||||
const host = createSceneHost();
|
||||
const gate = deferred();
|
||||
const blocking = host.run(() => gate.promise);
|
||||
const calls = [];
|
||||
const switcher = host.createSwitcher({
|
||||
kind: 'cesium',
|
||||
requested: () => 'cesium',
|
||||
signal: new AbortController().signal,
|
||||
address: { select() {}, commit() {}, restore() {} },
|
||||
leave() { calls.push('leave'); },
|
||||
enter() { calls.push('enter'); },
|
||||
});
|
||||
const switching = switcher.select('navara');
|
||||
assert.equal(switcher.select('cesium'), switching);
|
||||
gate.resolve();
|
||||
await Promise.all([blocking, switching]);
|
||||
assert.deepEqual(calls, []);
|
||||
assert.equal(switcher.state().kind, 'cesium');
|
||||
});
|
||||
|
||||
test('a request arriving in the next host operation starts a new switch', async () => {
|
||||
const host = createSceneHost();
|
||||
let address = 'cesium';
|
||||
const entered = [];
|
||||
const switcher = host.createSwitcher({
|
||||
kind: address,
|
||||
requested: () => address,
|
||||
signal: new AbortController().signal,
|
||||
address: {
|
||||
select() {},
|
||||
commit(_token, kind) { address = kind; },
|
||||
restore(kind) { address = kind; },
|
||||
},
|
||||
leave() {},
|
||||
enter(kind) { entered.push(kind); },
|
||||
});
|
||||
const first = switcher.select('navara');
|
||||
let next;
|
||||
const between = host.run(() => { next = switcher.select('cesium'); });
|
||||
await Promise.all([first, between]);
|
||||
await next;
|
||||
assert.deepEqual(entered, ['navara', 'cesium']);
|
||||
assert.equal(switcher.state().busy, false);
|
||||
});
|
||||
@@ -17,7 +17,8 @@ export function currentSceneOf(scene) {
|
||||
* attachment's result.
|
||||
*/
|
||||
export function attachScenePart(scene, part, { defer }) {
|
||||
if (scene?.sceneHost) return scene.sceneHost.register(part);
|
||||
if (scene?.sceneHost)
|
||||
return scene.sceneHost.register(part, scene.transaction);
|
||||
const controller = new AbortController();
|
||||
const cleanups = [];
|
||||
defer(async () => {
|
||||
@@ -55,5 +56,10 @@ export function attachScenePart(scene, part, { defer }) {
|
||||
export function detachPartsOnTeardown(scene, defer, options) {
|
||||
const host = scene?.sceneHost;
|
||||
if (!host) return;
|
||||
defer(() => host.detach(typeof options === 'function' ? options() : options));
|
||||
defer(() =>
|
||||
host.detach(
|
||||
typeof options === 'function' ? options() : options,
|
||||
scene.transaction,
|
||||
),
|
||||
);
|
||||
}
|
||||
+13
-9
@@ -12,20 +12,24 @@ export function shellScenePart(styleManager, { builtOn, catalog }) {
|
||||
let first = builtOn;
|
||||
return {
|
||||
name: 'shell',
|
||||
attachScene(next) {
|
||||
attachScene(next, { transaction } = {}) {
|
||||
if (next === first) {
|
||||
first = null;
|
||||
return;
|
||||
}
|
||||
first = null;
|
||||
return styleManager.attachScene({
|
||||
viewer: next.viewer,
|
||||
engine: next.engine,
|
||||
mapStackController: next.mapStackController,
|
||||
services: catalogControlServices(next.catalog ?? catalog),
|
||||
engineSwitch: true,
|
||||
});
|
||||
return styleManager.attachScene(
|
||||
{
|
||||
viewer: next.viewer,
|
||||
engine: next.engine,
|
||||
mapStackController: next.mapStackController,
|
||||
services: catalogControlServices(next.catalog ?? catalog),
|
||||
engineSwitch: true,
|
||||
},
|
||||
transaction,
|
||||
);
|
||||
},
|
||||
detachScene: (options) => styleManager.detachScene(options),
|
||||
detachScene: (options, transaction) =>
|
||||
styleManager.detachScene(options, transaction),
|
||||
};
|
||||
}
|
||||
+2
-1
@@ -60,7 +60,8 @@ export async function createApplicationTools({
|
||||
scene,
|
||||
{
|
||||
name: 'shell-release',
|
||||
detachScene: (options) => styleManager.detachScene?.(options),
|
||||
detachScene: (options, transaction) =>
|
||||
styleManager.detachScene?.(options, transaction),
|
||||
},
|
||||
{ defer },
|
||||
);
|
||||
|
||||
@@ -441,7 +441,7 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () =
|
||||
],
|
||||
'reset supersession',
|
||||
);
|
||||
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
|
||||
const dispose = body(ui, /async dispose\([^)]*\) \{([\s\S]*?)\n \}/, 'dispose');
|
||||
ordered(
|
||||
dispose,
|
||||
[
|
||||
@@ -473,7 +473,7 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () =
|
||||
});
|
||||
|
||||
test('teardown synchronously closes immediate camera entry points', () => {
|
||||
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
|
||||
const dispose = body(ui, /async dispose\([^)]*\) \{([\s\S]*?)\n \}/, 'dispose');
|
||||
ordered(
|
||||
dispose,
|
||||
['this._disposed = true;', 'await this.detachScene('],
|
||||
|
||||
@@ -237,7 +237,7 @@ test('Context entry awaits isolation and direct shell routes isolate in the visi
|
||||
// The generation discipline that outlived the reinstatement: dispose still
|
||||
// invalidates in-flight Context transactions so teardown cannot be raced into
|
||||
// publishing a mode.
|
||||
const dispose = src.slice(src.indexOf(' async dispose() {'), src.indexOf('this._stampNavigation();', src.indexOf(' async dispose() {')));
|
||||
const dispose = src.slice(src.indexOf(' async dispose('), src.indexOf('this._stampNavigation();', src.indexOf(' async dispose(')));
|
||||
assert.match(dispose, /this\._contextControls\.stop\(\)/);
|
||||
assert.match(ContextControls.prototype.stop.toString(), /this\._contextModeGeneration\+\+/);
|
||||
|
||||
|
||||
@@ -205,7 +205,7 @@ test('replacement, repetition, and hidden-tab elapsed time use the newest fixed
|
||||
test('universal notice lifecycle clears on dispose and uses the one top-center live region', async () => {
|
||||
const ui = readShellSource();
|
||||
const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
|
||||
const disposeStart = ui.indexOf(' async dispose() {');
|
||||
const disposeStart = ui.indexOf(' async dispose(');
|
||||
const disposeEnd = ui.indexOf('\n }\n', disposeStart);
|
||||
const dispose = ui.slice(disposeStart, disposeEnd);
|
||||
|
||||
|
||||
+4
-2
@@ -80,7 +80,7 @@ window.addEventListener('popstate', () => {
|
||||
void engineSwitch.reconcile();
|
||||
});
|
||||
|
||||
function createApplication(kind, engineSwitch) {
|
||||
function createApplication(kind, engineSwitch, transition) {
|
||||
session.count('applications');
|
||||
layerSession.restoringView = engineSwitch;
|
||||
application = createStandaloneApplication({
|
||||
@@ -92,6 +92,7 @@ function createApplication(kind, engineSwitch) {
|
||||
rendererCache: renderers,
|
||||
layerSession,
|
||||
engineSwitch,
|
||||
transition,
|
||||
});
|
||||
return application;
|
||||
}
|
||||
@@ -186,7 +187,8 @@ createApplication(initialKind, false)
|
||||
requested: pageEngineKind,
|
||||
signal,
|
||||
getApplication: () => application,
|
||||
startApplication: (kind) => createApplication(kind, true),
|
||||
startApplication: (kind, transition) =>
|
||||
createApplication(kind, true, transition),
|
||||
layerSession,
|
||||
renderers,
|
||||
diagnostics: session,
|
||||
|
||||
@@ -806,7 +806,7 @@ test('destroy cancels only a still-owned share flight and ignores delayed comple
|
||||
|
||||
|
||||
test('visual input listeners are revoked before asynchronous UI teardown', () => {
|
||||
const disposal = uiSource.slice(uiSource.indexOf(' async dispose() {'));
|
||||
const disposal = uiSource.slice(uiSource.indexOf(' async dispose('));
|
||||
const firstAwait = disposal.indexOf('await ');
|
||||
assert.ok(firstAwait > 0);
|
||||
const synchronous = disposal.slice(0, firstAwait);
|
||||
|
||||
@@ -62,6 +62,7 @@ export function createStandaloneApplication({
|
||||
rendererCache,
|
||||
layerSession,
|
||||
engineSwitch = false,
|
||||
transition: startupTransition = null,
|
||||
}) {
|
||||
if (owner)
|
||||
throw new Error('The standalone application already owns this page');
|
||||
@@ -82,6 +83,7 @@ export function createStandaloneApplication({
|
||||
let qaSources = {};
|
||||
// The engine-bound parts of every phase attach through this host.
|
||||
const sceneHost = createSceneHost({
|
||||
transitionHost: startupTransition?.host,
|
||||
onError: (error, part) =>
|
||||
console.error(`The ${part} scene part failed:`, error),
|
||||
});
|
||||
@@ -90,6 +92,7 @@ export function createStandaloneApplication({
|
||||
let current = null;
|
||||
let lifetime = null;
|
||||
let sessionContext = null;
|
||||
let cleanupTransition = null;
|
||||
|
||||
/** Acquire `kind`'s renderer and build its catalog: one attachment. */
|
||||
async function openScene(kind) {
|
||||
@@ -196,6 +199,9 @@ export function createStandaloneApplication({
|
||||
get operations() {
|
||||
return layerSession?.operations ?? current?.operations ?? null;
|
||||
},
|
||||
get transaction() {
|
||||
return cleanupTransition ?? startupTransition;
|
||||
},
|
||||
current: () => current,
|
||||
sceneHost,
|
||||
});
|
||||
@@ -251,19 +257,20 @@ export function createStandaloneApplication({
|
||||
// The session's own defer (its logo gaze and key when no layer
|
||||
// session holds them) outlives every globe.
|
||||
sessionContext = { defer: context.defer };
|
||||
current = await openScene(engineKind);
|
||||
// The first globe: the phases build on it and register their parts.
|
||||
await sceneHost.attach(current);
|
||||
// After every part detached (the tools phase detaches them first),
|
||||
// the globe's renderer is parked or released.
|
||||
context.defer(async () => {
|
||||
await sceneHost.detach({
|
||||
handoff: retainsLayerSession(layerSession),
|
||||
});
|
||||
const scene = current;
|
||||
current = null;
|
||||
await scene?.release();
|
||||
});
|
||||
// The renderer is released after its parts, including a failed start.
|
||||
context.defer(() =>
|
||||
detachScene(
|
||||
{
|
||||
handoff: retainsLayerSession(layerSession),
|
||||
},
|
||||
sceneView.transaction,
|
||||
),
|
||||
);
|
||||
await sceneHost.run(async (transaction) => {
|
||||
current = await openScene(engineKind);
|
||||
// The first globe: the phases build on it and register their parts.
|
||||
await sceneHost.attach(current, transaction);
|
||||
}, startupTransition);
|
||||
return sceneView;
|
||||
},
|
||||
createControls: (context) =>
|
||||
@@ -333,13 +340,20 @@ export function createStandaloneApplication({
|
||||
* detaches what attached and releases the candidate; the session stays as
|
||||
* it was, ready for the working engine to attach again.
|
||||
*/
|
||||
async function attachScene(kind, { resume } = {}) {
|
||||
function attachScene(kind, { resume, transaction } = {}) {
|
||||
return sceneHost.run(
|
||||
(transaction) => attach(kind, resume, transaction),
|
||||
transaction,
|
||||
);
|
||||
}
|
||||
|
||||
async function attach(kind, resume, transaction) {
|
||||
if (application.getState().status !== 'ready')
|
||||
throw new Error('The application is not running');
|
||||
if (current) throw new Error('A globe is attached; detach it first');
|
||||
const next = await openScene(kind);
|
||||
try {
|
||||
await sceneHost.attach(next);
|
||||
await sceneHost.attach(next, transaction);
|
||||
current = next;
|
||||
await resume?.();
|
||||
lifetime.throwIfAborted();
|
||||
@@ -349,7 +363,7 @@ export function createStandaloneApplication({
|
||||
if (current === next) {
|
||||
current = null;
|
||||
await sceneHost
|
||||
.detach({ handoff: !lifetime.aborted })
|
||||
.detach({ handoff: !lifetime.aborted }, transaction)
|
||||
.catch((cleanupError) =>
|
||||
console.error(
|
||||
'A failed globe could not be released:',
|
||||
@@ -368,11 +382,18 @@ export function createStandaloneApplication({
|
||||
}
|
||||
|
||||
/** Detach every part from the attached globe and park or release it. */
|
||||
async function detachScene(options = {}) {
|
||||
function detachScene(options = {}, transaction) {
|
||||
return sceneHost.run(
|
||||
(transaction) => detach(options, transaction),
|
||||
transaction,
|
||||
);
|
||||
}
|
||||
|
||||
async function detach(options, transaction) {
|
||||
const scene = current;
|
||||
if (!scene) return;
|
||||
try {
|
||||
await sceneHost.detach(options);
|
||||
await sceneHost.detach(options, transaction);
|
||||
} finally {
|
||||
current = null;
|
||||
await scene.release();
|
||||
@@ -381,6 +402,22 @@ export function createStandaloneApplication({
|
||||
|
||||
return Object.freeze({
|
||||
...application,
|
||||
async start() {
|
||||
try {
|
||||
return await application.start();
|
||||
} finally {
|
||||
startupTransition = null;
|
||||
}
|
||||
},
|
||||
async destroy(transaction) {
|
||||
cleanupTransition ??= transaction;
|
||||
try {
|
||||
await application.destroy();
|
||||
await sceneHost.dispose(transaction);
|
||||
} finally {
|
||||
cleanupTransition = null;
|
||||
}
|
||||
},
|
||||
sceneHost,
|
||||
attachScene,
|
||||
detachScene,
|
||||
|
||||
@@ -8,8 +8,10 @@ import vm from 'node:vm';
|
||||
import { retainsLayerSession } from '../app/layerSession.js';
|
||||
import { createSceneHost } from '../app/sceneHost.js';
|
||||
import { attachScenePart } from '../app/sceneParts.js';
|
||||
import { createApplication } from '../app/application.js';
|
||||
import { createEngineSwitch } from './engineSwitch.js';
|
||||
|
||||
function standalone({ failOpen = null } = {}) {
|
||||
function standalone({ failOpen = null, beforeOpen, beforeRelease, lifecycle = false, transition = null, failControls = false } = {}) {
|
||||
const source = readFileSync(
|
||||
new URL('./application.js', import.meta.url),
|
||||
'utf8',
|
||||
@@ -32,7 +34,7 @@ function standalone({ failOpen = null } = {}) {
|
||||
applyDocumentLanguage() {},
|
||||
applyDocumentTranslations() {},
|
||||
CITY_POIS: [],
|
||||
createApplication: (options) => ({
|
||||
createApplication: lifecycle ? createApplication : (options) => ({
|
||||
...options,
|
||||
subscribe() {},
|
||||
getState: () => ({ status: 'ready' }),
|
||||
@@ -44,12 +46,27 @@ function standalone({ failOpen = null } = {}) {
|
||||
loadLayerRenderers: async () => {},
|
||||
createGoogleTokenSource: () => null,
|
||||
createStandaloneScene: async ({ engineKind, defer }) => {
|
||||
await beforeOpen?.(engineKind);
|
||||
if (failOpen === engineKind) throw new Error(`${engineKind} refused`);
|
||||
const engine = { kind: engineKind, id: opened.length };
|
||||
opened.push(engine);
|
||||
defer(() => released.push(engine));
|
||||
defer(async () => {
|
||||
await beforeRelease?.(engine);
|
||||
released.push(engine);
|
||||
});
|
||||
return { viewer: null, engine, operations: { kind: 'operations' } };
|
||||
},
|
||||
createStandaloneControls: async ({ scene, defer }) => {
|
||||
await attachScenePart(scene, {
|
||||
name: 'controls',
|
||||
attachScene() {},
|
||||
detachScene() {},
|
||||
}, { defer });
|
||||
if (failControls) throw new Error('controls refused');
|
||||
return {};
|
||||
},
|
||||
createStandaloneData: () => ({}),
|
||||
createStandaloneTools: () => ({}),
|
||||
createSceneHost,
|
||||
retainsLayerSession,
|
||||
attachScenePart,
|
||||
@@ -58,7 +75,7 @@ function standalone({ failOpen = null } = {}) {
|
||||
DOMException,
|
||||
});
|
||||
vm.runInContext(source, context);
|
||||
const app = context.createStandaloneApplication({});
|
||||
const app = context.createStandaloneApplication({ transition });
|
||||
return { app, opened, released };
|
||||
}
|
||||
|
||||
@@ -162,3 +179,114 @@ test('page teardown during resume releases the candidate only once', async () =>
|
||||
assert.equal(scene.engine, null);
|
||||
assert.deepEqual(released, opened);
|
||||
});
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
test('concurrent detach and attach wait for renderer release before acquiring the next', async () => {
|
||||
const releasing = deferred();
|
||||
const finishRelease = deferred();
|
||||
const { app, opened, released } = standalone({
|
||||
beforeRelease: async () => {
|
||||
releasing.resolve();
|
||||
await finishRelease.promise;
|
||||
},
|
||||
});
|
||||
const scene = await app.createScene({ signal: new AbortController().signal, defer() {} });
|
||||
const leaving = app.detachScene({ handoff: true });
|
||||
const entering = app.attachScene('navara');
|
||||
await releasing.promise;
|
||||
assert.equal(opened.length, 1);
|
||||
assert.equal(scene.engine, null);
|
||||
finishRelease.resolve();
|
||||
await Promise.all([leaving, entering]);
|
||||
assert.equal(scene.engine, opened[1]);
|
||||
assert.deepEqual(released, [opened[0]]);
|
||||
});
|
||||
|
||||
test('a detach requested during renderer acquisition waits for attachment and resume', async () => {
|
||||
const opening = deferred();
|
||||
const finishOpen = deferred();
|
||||
const { app, opened, released } = standalone({
|
||||
beforeOpen: async (kind) => {
|
||||
if (kind === 'navara') {
|
||||
opening.resolve();
|
||||
await finishOpen.promise;
|
||||
}
|
||||
},
|
||||
});
|
||||
const scene = await app.createScene({ signal: new AbortController().signal, defer() {} });
|
||||
await app.detachScene();
|
||||
let resumed = false;
|
||||
const entering = app.attachScene('navara', { resume: () => { resumed = true; } });
|
||||
await opening.promise;
|
||||
const leaving = app.detachScene();
|
||||
finishOpen.resolve();
|
||||
await Promise.all([entering, leaving]);
|
||||
assert.equal(resumed, true);
|
||||
assert.equal(scene.engine, null);
|
||||
assert.deepEqual(released, opened);
|
||||
});
|
||||
|
||||
test('the host completes one recovery before an outside attachment operation runs', async () => {
|
||||
const failedRelease = deferred();
|
||||
const finishRelease = deferred();
|
||||
const { app, opened, released } = standalone({
|
||||
beforeRelease: async (engine) => {
|
||||
if (engine.kind === 'navara') {
|
||||
failedRelease.resolve();
|
||||
await finishRelease.promise;
|
||||
}
|
||||
},
|
||||
});
|
||||
const scene = await app.createScene({ signal: new AbortController().signal, defer() {} });
|
||||
const failures = [];
|
||||
const switcher = createEngineSwitch({
|
||||
sceneHost: app.sceneHost,
|
||||
kind: 'cesium',
|
||||
requested: () => 'cesium',
|
||||
signal: new AbortController().signal,
|
||||
address: { select() {}, commit() {}, restore() {} },
|
||||
leave: (_from, _to, _change, transaction) => app.detachScene({ handoff: true }, transaction),
|
||||
enter: (kind, _attempt, transaction) => app.attachScene(kind, {
|
||||
transaction,
|
||||
resume() { if (kind === 'navara') throw new Error('restore refused'); },
|
||||
}),
|
||||
onFailure: (error, outcome) => failures.push([error.message, outcome]),
|
||||
});
|
||||
const switching = switcher.select('navara');
|
||||
await failedRelease.promise;
|
||||
const outside = app.detachScene();
|
||||
assert.equal(opened.length, 2);
|
||||
finishRelease.resolve();
|
||||
await Promise.all([switching, outside]);
|
||||
assert.deepEqual(opened.map(({ kind }) => kind), ['cesium', 'navara', 'cesium']);
|
||||
assert.deepEqual(released, opened);
|
||||
assert.deepEqual(failures, [['restore refused', { recovered: true, kind: 'cesium' }]]);
|
||||
assert.equal(scene.engine, null);
|
||||
});
|
||||
|
||||
for (const failControls of [false, true]) {
|
||||
test(`replacement application ${failControls ? 'failure cleanup' : 'startup and teardown'} uses the enclosing host transaction`, async () => {
|
||||
const host = createSceneHost();
|
||||
let fixture;
|
||||
await host.run(async (transaction) => {
|
||||
fixture = standalone({ lifecycle: true, transition: transaction, failControls });
|
||||
if (failControls) {
|
||||
await assert.rejects(fixture.app.start(), /controls refused/);
|
||||
assert.equal(fixture.app.getState().status, 'failed');
|
||||
} else {
|
||||
const { scene } = await fixture.app.start();
|
||||
assert.equal(scene.engine, fixture.opened[0]);
|
||||
assert.equal(scene.transaction, null, 'startup does not retain the enclosing transaction');
|
||||
}
|
||||
await fixture.app.destroy(transaction);
|
||||
});
|
||||
assert.deepEqual(fixture.released, fixture.opened);
|
||||
assert.equal(fixture.app.sceneHost.state(), 'detached');
|
||||
assert.deepEqual(fixture.app.sceneHost.parts(), [], 'retired parts do not pin the old application');
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// The page's engine switch: one layer session, one engine at a time.
|
||||
//
|
||||
// The address (?engine=) is the source of truth, except while a picked
|
||||
// engine starts: that choice lives here until the engine runs, and only then
|
||||
// engine starts: the scene host holds that choice until it runs, and only then
|
||||
// is written to the address as a new history entry. A switch from the picker
|
||||
// (select) and a history visit (Back/Forward, a pasted address) both hand the
|
||||
// session off: the running engine is released, the requested engine starts on
|
||||
@@ -12,207 +12,17 @@
|
||||
// Requests that arrive while a switch or a recovery runs are never dropped:
|
||||
// once the current attempt settles, the address is read again and reconciled.
|
||||
|
||||
import { ENGINE_CHOICES } from '../app/engineChoice.js';
|
||||
import { createSceneHost } from '../app/sceneHost.js';
|
||||
|
||||
/** A started engine whose first frame takes longer than this is shown anyway. */
|
||||
const FIRST_FRAME_WAIT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Drive engine switches for one page session.
|
||||
*
|
||||
* A failed attempt rolls back to the last engine that actually started
|
||||
* (never to the one that just failed) and, when that recovery fails too,
|
||||
* leaves the page without an engine but still able to take the next request. A page lifetime abort (pagehide) stops the
|
||||
* machine: nothing starts after it.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.kind The engine already running.
|
||||
* @param {() => string} options.requested The engine the address asks for now.
|
||||
* @param {AbortSignal} options.signal The page lifetime.
|
||||
* @param {(from: string, to: string, change: {visit: boolean}) => Promise<void>} options.leave
|
||||
* Hand the session off from the running engine and release that engine.
|
||||
* @param {(kind: string, attempt: {recovery: boolean}) => Promise<void>} options.enter
|
||||
* Start an engine on the retained session. It resolves once the engine runs
|
||||
* with the session attached; when it rejects it has already released what
|
||||
* it started.
|
||||
* @param {{select: (from: ?string) => *, commit: (token: *, kind: string) => void, restore: (kind: string) => void}} options.address
|
||||
* @param {(state: {failed: boolean}) => Promise<void>|void} [options.settle] Runs
|
||||
* once the address is reconciled, before the machine reports idle.
|
||||
* @param {(kind: string) => void} [options.remember] Store a picked engine.
|
||||
* @param {(state: object) => void} [options.onChange]
|
||||
* @param {(error: unknown, outcome: {recovered: boolean, kind: ?string}) => void} [options.onFailure]
|
||||
*/
|
||||
/** Drive engine requests through the scene host's transition owner. */
|
||||
export function createEngineSwitch({
|
||||
kind,
|
||||
requested,
|
||||
signal,
|
||||
leave,
|
||||
enter,
|
||||
address,
|
||||
settle,
|
||||
remember,
|
||||
onChange,
|
||||
onFailure,
|
||||
sceneHost = createSceneHost(),
|
||||
...options
|
||||
}) {
|
||||
// The engine whose session is live, null while none is.
|
||||
let current = kind;
|
||||
// The engine attached to the application or starting to attach: what a
|
||||
// history visit would leave.
|
||||
let live = kind;
|
||||
// The last engine that started successfully: the rollback target.
|
||||
let working = kind;
|
||||
let failed = false;
|
||||
let busy = false;
|
||||
// Every request (select or address visit) bumps this; a visit also bumps
|
||||
// `visits`.
|
||||
let requests = 0;
|
||||
let visits = 0;
|
||||
let queued = null;
|
||||
// The pick being started, held here rather than in the address: a visit
|
||||
// made meanwhile retires it and leaves every entry as it was.
|
||||
let pending = null;
|
||||
let running = null;
|
||||
|
||||
const state = () =>
|
||||
Object.freeze({
|
||||
kind: current ?? working,
|
||||
engine: live,
|
||||
busy,
|
||||
failed,
|
||||
});
|
||||
const publish = () => onChange?.(state());
|
||||
|
||||
function prepare(next) {
|
||||
pending = { to: next, token: address.select(current) };
|
||||
}
|
||||
|
||||
/** The picker's choice. While a switch runs it is queued, not dropped. */
|
||||
function select(next) {
|
||||
if (!ENGINE_CHOICES.includes(next) || signal.aborted) return null;
|
||||
requests++;
|
||||
if (busy) {
|
||||
queued = next;
|
||||
return running;
|
||||
}
|
||||
if (next === current) return null;
|
||||
prepare(next);
|
||||
return run();
|
||||
}
|
||||
|
||||
/** The address changed (history visit) or should be checked again. */
|
||||
function reconcile() {
|
||||
if (signal.aborted) return Promise.resolve();
|
||||
requests++;
|
||||
visits++;
|
||||
// A later visit supersedes a pick queued or starting before it.
|
||||
queued = null;
|
||||
pending = null;
|
||||
return busy ? running : run();
|
||||
}
|
||||
|
||||
function run() {
|
||||
busy = true;
|
||||
publish();
|
||||
running = drive()
|
||||
.catch((error) =>
|
||||
onFailure?.(error, { recovered: current !== null, kind: current }),
|
||||
)
|
||||
.finally(() => {
|
||||
busy = false;
|
||||
running = null;
|
||||
publish();
|
||||
});
|
||||
return running;
|
||||
}
|
||||
|
||||
async function drive() {
|
||||
for (;;) {
|
||||
await reconcileAddress();
|
||||
if (signal.aborted) return;
|
||||
const seen = requests;
|
||||
try {
|
||||
await settle?.({ failed });
|
||||
} catch (error) {
|
||||
// Revealing the globe is cosmetic; the engine runs either way.
|
||||
console.error('The globe switch could not settle:', error);
|
||||
}
|
||||
if (signal.aborted || seen === requests) return;
|
||||
}
|
||||
}
|
||||
|
||||
async function reconcileAddress() {
|
||||
let handled = null;
|
||||
for (;;) {
|
||||
if (signal.aborted) return;
|
||||
if (queued !== null) {
|
||||
const next = queued;
|
||||
queued = null;
|
||||
if (next !== current) prepare(next);
|
||||
}
|
||||
const target = pending?.to ?? requested();
|
||||
if (target === current) return;
|
||||
// After a failed recovery, wait for the person's next request instead
|
||||
// of retrying the same address in a loop.
|
||||
if (failed && handled === requests) return;
|
||||
handled = requests;
|
||||
await attempt(target);
|
||||
}
|
||||
}
|
||||
|
||||
async function attempt(target) {
|
||||
const visitsAtStart = visits;
|
||||
const visit = pending?.to !== target;
|
||||
publish();
|
||||
try {
|
||||
if (current !== null) {
|
||||
const from = current;
|
||||
try {
|
||||
await leave(from, target, { visit });
|
||||
} finally {
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
if (signal.aborted) return;
|
||||
live = target;
|
||||
await enter(target, { recovery: false });
|
||||
if (signal.aborted) return;
|
||||
current = working = target;
|
||||
failed = false;
|
||||
if (!visit && pending?.to === target) {
|
||||
address.commit(pending.token, target);
|
||||
remember?.(target);
|
||||
}
|
||||
pending = null;
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
pending = null;
|
||||
// A failed pick never touched the address. A failed visit points its
|
||||
// entry at the working engine, unless the person has since moved on.
|
||||
if (visit && visits === visitsAtStart && requested() === target)
|
||||
address.restore(working);
|
||||
if (working === target) {
|
||||
live = null;
|
||||
failed = true;
|
||||
onFailure?.(error, { recovered: false, kind: null });
|
||||
return;
|
||||
}
|
||||
live = working;
|
||||
try {
|
||||
await enter(working, { recovery: true });
|
||||
if (signal.aborted) return;
|
||||
current = working;
|
||||
failed = false;
|
||||
onFailure?.(error, { recovered: true, kind: working });
|
||||
} catch (recoveryError) {
|
||||
if (signal.aborted) return;
|
||||
live = null;
|
||||
failed = true;
|
||||
onFailure?.(recoveryError, { recovered: false, kind: null });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({ select, reconcile, state });
|
||||
return sceneHost.createSwitcher(options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createRendererCache } from './rendererCache.js';
|
||||
import { createSceneHost } from '../app/sceneHost.js';
|
||||
|
||||
function installDocument(t) {
|
||||
const host = {
|
||||
@@ -224,3 +225,48 @@ test('acquiring one kind of an engine releases the other parked kind of it first
|
||||
assert.equal(hd.context.lost, true);
|
||||
cache.clear();
|
||||
});
|
||||
|
||||
test('parked expiry during a host transition makes the queued return cold', async (t) => {
|
||||
installDocument(t);
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const host = createSceneHost();
|
||||
const cache = createRendererCache({ parkedMs: 1000 });
|
||||
t.after(() => cache.clear());
|
||||
const released = { count: 0 };
|
||||
const releases = new Map();
|
||||
const build = fakeBuild(released);
|
||||
const open = (kind, builder = build) => cache.acquire(kind, builder, (stop) => {
|
||||
releases.set(kind, stop);
|
||||
});
|
||||
const first = await host.run(() => open('cesium'));
|
||||
let finishBuild;
|
||||
let entered;
|
||||
const building = new Promise((resolve) => { entered = resolve; });
|
||||
const away = host.run(async () => {
|
||||
cache.retain('cesium');
|
||||
releases.get('cesium')();
|
||||
return open('navara', async (options) => {
|
||||
entered();
|
||||
await new Promise((resolve) => { finishBuild = resolve; });
|
||||
return build(options);
|
||||
});
|
||||
});
|
||||
await building;
|
||||
const returning = host.run(async () => {
|
||||
cache.retain('navara');
|
||||
releases.get('navara')();
|
||||
return open('cesium');
|
||||
});
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(released.count, 1);
|
||||
assert.equal(first.context.lost, true);
|
||||
finishBuild();
|
||||
await away;
|
||||
const next = await returning;
|
||||
assert.notEqual(next, first);
|
||||
assert.equal(next.context.lost, false);
|
||||
releases.get('cesium')();
|
||||
cache.clear();
|
||||
t.mock.timers.tick(1000);
|
||||
assert.equal(released.count, 3, 'each renderer is released once');
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
/**
|
||||
* One scene-switch transaction: retain the session, detach and park the
|
||||
* outgoing globe, attach and resume the candidate, then uncover its frame.
|
||||
* The request coordinator owns ordering and the single rollback. The
|
||||
* The scene host owns ordering and the single rollback. The
|
||||
* application owns candidate cleanup, including a failure during resume.
|
||||
*
|
||||
* @param {object} options
|
||||
@@ -35,7 +35,7 @@ export function createSceneSwitcher({
|
||||
return components.scene?.engine ? components : null;
|
||||
};
|
||||
|
||||
async function leave(from) {
|
||||
async function leave(from, _to, _change, transaction) {
|
||||
const components = liveComponents();
|
||||
if (components) {
|
||||
cover ??= keepFrame?.(components.scene.engine);
|
||||
@@ -46,7 +46,7 @@ export function createSceneSwitcher({
|
||||
style.shareLinkManager?.currentLayerState?.()?.enabledLayerIds,
|
||||
});
|
||||
renderers.retain(from);
|
||||
await getApplication().detachScene({ handoff: true });
|
||||
await getApplication().detachScene({ handoff: true }, transaction);
|
||||
}
|
||||
count('detaches');
|
||||
}
|
||||
@@ -71,18 +71,18 @@ export function createSceneSwitcher({
|
||||
}
|
||||
}
|
||||
|
||||
async function enter(kind, { recovery = false } = {}) {
|
||||
async function enter(kind, { recovery = false } = {}, transaction) {
|
||||
layerSession.restoringView = true;
|
||||
let app = getApplication();
|
||||
if (!app || app.getState().status !== 'ready') {
|
||||
app = startApplication(kind);
|
||||
app = startApplication(kind, transaction);
|
||||
try {
|
||||
const { controls } = await app.start();
|
||||
await resume(controls.styleManager);
|
||||
} catch (error) {
|
||||
if (!signal.aborted)
|
||||
await app
|
||||
.destroy()
|
||||
.destroy(transaction)
|
||||
.catch((cleanupError) =>
|
||||
console.error(
|
||||
'A failed globe could not be released:',
|
||||
@@ -93,6 +93,7 @@ export function createSceneSwitcher({
|
||||
}
|
||||
} else {
|
||||
await app.attachScene(kind, {
|
||||
transaction,
|
||||
resume: () => resume(app.getComponents().controls.styleManager),
|
||||
});
|
||||
}
|
||||
@@ -114,5 +115,12 @@ export function createSceneSwitcher({
|
||||
components.controls.styleManager.shareLinkManager?.resumeNavigation?.();
|
||||
}
|
||||
|
||||
return createEngineSwitch({ ...options, signal, leave, enter, settle });
|
||||
return createEngineSwitch({
|
||||
...options,
|
||||
sceneHost: getApplication()?.sceneHost,
|
||||
signal,
|
||||
leave,
|
||||
enter,
|
||||
settle,
|
||||
});
|
||||
}
|
||||
@@ -97,12 +97,14 @@ export class StyleManager extends ShellScene {
|
||||
initialShareCode = null,
|
||||
engineSwitch = false,
|
||||
layerSession = null,
|
||||
sceneHost = null,
|
||||
localePicker = true,
|
||||
} = {},
|
||||
) {
|
||||
super();
|
||||
this._localePickerEnabled = localePicker;
|
||||
this._layerSession = layerSession;
|
||||
this._sceneHost = sceneHost;
|
||||
const {
|
||||
IntelHUD,
|
||||
ShareLinkManager,
|
||||
@@ -1610,7 +1612,7 @@ export class StyleManager extends ShellScene {
|
||||
* prevent leaked rAF loops and event listeners.
|
||||
* @returns {Promise<void>} Resolves after focused-session state restoration.
|
||||
*/
|
||||
async dispose() {
|
||||
async dispose(transaction) {
|
||||
if (this._disposed) return;
|
||||
const layerSession = this._layerSession;
|
||||
const handoff = retainsLayerSession(layerSession);
|
||||
@@ -1630,7 +1632,7 @@ export class StyleManager extends ShellScene {
|
||||
this._locationNavigation.destroy();
|
||||
this._shareState.destroy();
|
||||
// The globe-bound parts go before the session's controls and stores.
|
||||
await this.detachScene({ handoff });
|
||||
await this.detachScene({ handoff }, transaction);
|
||||
this._navigation.stop();
|
||||
this._lifetime.destroy();
|
||||
useLocaleStore(applicationPinia()).publishPicker(null);
|
||||
|
||||
+7
-10
@@ -1,3 +1,4 @@
|
||||
import { createSceneHost } from '../app/sceneHost.js';
|
||||
import { useWorldOverlayActionsStore } from './stores/worldOverlayActions.js';
|
||||
import { renderGovernorFor } from '../renderGovernor.js';
|
||||
import { retainsLayerSession } from '../app/layerSession.js';
|
||||
@@ -301,14 +302,9 @@ export class ShellScene extends ShellFacade {
|
||||
* stay; the parts bound to a globe are built for this one and the
|
||||
* address's view is restored on it.
|
||||
*/
|
||||
attachScene(options = {}) {
|
||||
return this._queueSceneTransition(() => this._attachScene(options));
|
||||
}
|
||||
|
||||
_queueSceneTransition(work) {
|
||||
const run = (this._sceneTransition ?? Promise.resolve()).then(work, work);
|
||||
this._sceneTransition = run.catch(() => {});
|
||||
return run;
|
||||
attachScene(options = {}, transaction) {
|
||||
this._sceneHost ??= createSceneHost();
|
||||
return this._sceneHost.run(() => this._attachScene(options), transaction);
|
||||
}
|
||||
|
||||
async _attachScene({
|
||||
@@ -396,8 +392,9 @@ export class ShellScene extends ShellFacade {
|
||||
* the open panels stay. `handoff` keeps the selection and a retainable
|
||||
* Context mode for the next globe.
|
||||
*/
|
||||
detachScene(options = {}) {
|
||||
return this._queueSceneTransition(() => this._detachScene(options));
|
||||
detachScene(options = {}, transaction) {
|
||||
this._sceneHost ??= createSceneHost();
|
||||
return this._sceneHost.run(() => this._detachScene(options), transaction);
|
||||
}
|
||||
|
||||
async _detachScene({
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { StyleManager } from './applicationShell.js';
|
||||
import { createSceneHost } from '../app/sceneHost.js';
|
||||
import { shellScenePart } from '../app/shellPart.js';
|
||||
|
||||
function fakeShell() {
|
||||
const log = [];
|
||||
@@ -305,3 +307,34 @@ test('an engine without Wildlife Sightings gets no card instead of a failed star
|
||||
assert.doesNotThrow(() => shell._initWildlifeSightingCard());
|
||||
assert.equal(shell._wildlifeSightingCard, null);
|
||||
});
|
||||
|
||||
test('a hosted shell uses the enclosing transition while direct calls wait outside it', async () => {
|
||||
const { shell, session } = fakeShell();
|
||||
const host = createSceneHost();
|
||||
shell._sceneHost = host;
|
||||
const first = { engine: shell.engine };
|
||||
await host.attach(first);
|
||||
const catalog = { get: () => ({}), unavailableMetadata: [] };
|
||||
await host.register(shellScenePart(shell, { builtOn: first, catalog }));
|
||||
let release;
|
||||
let entered;
|
||||
const detaching = new Promise((resolve) => { entered = resolve; });
|
||||
session.context.canRetainScene = () => false;
|
||||
session.context.restoreForDisposal = () => {
|
||||
entered();
|
||||
return new Promise((resolve) => { release = resolve; });
|
||||
};
|
||||
const next = { engine: { kind: 'navara' }, catalog };
|
||||
const switching = host.run(async (transaction) => {
|
||||
await host.detach({ handoff: true }, transaction);
|
||||
await host.attach(next, transaction);
|
||||
});
|
||||
await detaching;
|
||||
const outside = shell.detachScene();
|
||||
session.context.canRetainScene = () => true;
|
||||
release();
|
||||
await switching;
|
||||
await outside;
|
||||
assert.equal(shell.engine, null);
|
||||
assert.equal(host.current(), next);
|
||||
});
|
||||
Reference in new issue
Block a user