mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Release the shell from a candidate engine that fails after it attached
The shell's scene part detaches it too, so a candidate failing in a later part (a layer's drawing, Cockpit clouds) leaves the shell free for the working engine instead of refusing every later attach. StyleManager.attachScene releases what it built when it throws, and one detach runs at a time. The controls phase awaits its parts, and a failed attachment no longer forces handoff off for the parts it releases. scripts/qa-app-once.mjs now also rolls back from a Navara candidate whose layer attachment fails.
This commit is contained in:
1 parent
58c8a481ed
commit
d86047fe8f
12 files changed
+229
-76
No files matched your search
@@ -2662,6 +2662,7 @@
|
|||||||
"src/app/tools.js",
|
"src/app/tools.js",
|
||||||
"src/app/sessionVoice.js",
|
"src/app/sessionVoice.js",
|
||||||
"src/app/sceneParts.js",
|
"src/app/sceneParts.js",
|
||||||
|
"src/app/shellPart.js",
|
||||||
"src/app/viewer.js",
|
"src/app/viewer.js",
|
||||||
"src/sky/starrySky.js",
|
"src/sky/starrySky.js",
|
||||||
"src/sky/stars.js",
|
"src/sky/stars.js",
|
||||||
|
|||||||
@@ -320,6 +320,46 @@ try {
|
|||||||
'ids changed after the rollback',
|
'ids changed after the rollback',
|
||||||
idDifference(boot.ids, final.ids),
|
idDifference(boot.ids, final.ids),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A candidate failing after the shell attached to it: a layer's
|
||||||
|
// drawing refuses to attach on Navara. The shell lets go of the failed
|
||||||
|
// globe and Cesium attaches again; the next switch works.
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.__qaDataRefused = false;
|
||||||
|
const module =
|
||||||
|
window.__godsEyeView.dataManager.layers.get('earthquakes').module;
|
||||||
|
const attach = module.attachRenderer;
|
||||||
|
module.attachRenderer = function () {
|
||||||
|
module.attachRenderer = attach;
|
||||||
|
window.__qaDataRefused = true;
|
||||||
|
return Promise.reject(new Error('QA data attach failure'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const beforeDataFailure = await page.evaluate(() => history.length);
|
||||||
|
await requestEngine(page, 'navara');
|
||||||
|
await page.waitForFunction(() => window.__qaDataRefused === true, {
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
await waitReady(page, 'cesium');
|
||||||
|
const afterDataFailure = await readSession(page);
|
||||||
|
report.dataFailure = {
|
||||||
|
ids: idDifference(boot.ids, afterDataFailure.ids),
|
||||||
|
counters: afterDataFailure.counters,
|
||||||
|
history: (await page.evaluate(() => history.length)) - beforeDataFailure,
|
||||||
|
};
|
||||||
|
check(
|
||||||
|
Object.keys(report.dataFailure.ids).length === 0 &&
|
||||||
|
report.dataFailure.history === 0,
|
||||||
|
'a candidate failing in the data part changed the session',
|
||||||
|
report.dataFailure,
|
||||||
|
);
|
||||||
|
await switchEngine(page, 'navara');
|
||||||
|
await switchEngine(page, 'cesium');
|
||||||
|
check(
|
||||||
|
Object.keys(idDifference(boot.ids, (await readSession(page)).ids))
|
||||||
|
.length === 0,
|
||||||
|
'ids changed after the data-part failure',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
report.errors = errors;
|
report.errors = errors;
|
||||||
check(errors.length === 0, 'page errors', errors);
|
check(errors.length === 0, 'page errors', errors);
|
||||||
|
|||||||
Vendored
+5
-23
@@ -4,6 +4,7 @@ import { flyToAustin } from '../camera.js';
|
|||||||
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
|
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
|
||||||
import { t } from '../i18n/index.js';
|
import { t } from '../i18n/index.js';
|
||||||
import { attachScenePart, currentSceneOf } from './sceneParts.js';
|
import { attachScenePart, currentSceneOf } from './sceneParts.js';
|
||||||
|
import { shellScenePart } from './shellPart.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct the existing controls and camera presentation. `initialShareCode`
|
* Construct the existing controls and camera presentation. `initialShareCode`
|
||||||
@@ -15,7 +16,7 @@ import { attachScenePart, currentSceneOf } from './sceneParts.js';
|
|||||||
* engines it attaches to each later one (StyleManager.attachScene) with that
|
* engines it attaches to each later one (StyleManager.attachScene) with that
|
||||||
* engine's catalog, and the Cockpit cloud effects follow the viewer.
|
* engine's catalog, and the Cockpit cloud effects follow the viewer.
|
||||||
*/
|
*/
|
||||||
export function createApplicationControls({
|
export async function createApplicationControls({
|
||||||
scene,
|
scene,
|
||||||
loaderStatus,
|
loaderStatus,
|
||||||
Controls = StyleManager,
|
Controls = StyleManager,
|
||||||
@@ -59,28 +60,9 @@ export function createApplicationControls({
|
|||||||
defer(() => styleManager.hud.destroy());
|
defer(() => styleManager.hud.destroy());
|
||||||
defer(() => styleManager.dispose());
|
defer(() => styleManager.dispose());
|
||||||
// The shell was built on this globe; later globes attach to it.
|
// The shell was built on this globe; later globes attach to it.
|
||||||
let builtOn = currentSceneOf(scene);
|
await attachScenePart(
|
||||||
attachScenePart(
|
|
||||||
scene,
|
scene,
|
||||||
{
|
shellScenePart(styleManager, { builtOn: currentSceneOf(scene), catalog }),
|
||||||
name: 'shell',
|
|
||||||
attachScene(next) {
|
|
||||||
if (next === builtOn) {
|
|
||||||
builtOn = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
builtOn = null;
|
|
||||||
styleManager.attachScene({
|
|
||||||
viewer: next.viewer,
|
|
||||||
engine: next.engine,
|
|
||||||
mapStackController: next.mapStackController,
|
|
||||||
services: catalogControlServices(next.catalog ?? catalog),
|
|
||||||
engineSwitch: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// The shell leaves its globe in 'shell-release' (the tools phase),
|
|
||||||
// before the data layers detach, as its teardown always did.
|
|
||||||
},
|
|
||||||
{ defer },
|
{ defer },
|
||||||
);
|
);
|
||||||
// The previous multi-canvas weather compositor remains disabled. Cockpit
|
// The previous multi-canvas weather compositor remains disabled. Cockpit
|
||||||
@@ -88,7 +70,7 @@ export function createApplicationControls({
|
|||||||
// Cesium fog or post-process stages and is fully stopped in map mode.
|
// Cesium fog or post-process stages and is fully stopped in map mode.
|
||||||
const weatherEffects = null;
|
const weatherEffects = null;
|
||||||
let cockpitCloudEffects = null;
|
let cockpitCloudEffects = null;
|
||||||
attachScenePart(
|
await attachScenePart(
|
||||||
scene,
|
scene,
|
||||||
{
|
{
|
||||||
name: 'cockpit-clouds',
|
name: 'cockpit-clouds',
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ test('the controls pass the scene engine to the shell', () => {
|
|||||||
assert.match(controls, /new Controls\(viewer, \{[\s\S]*?\n\s+engine,\n/);
|
assert.match(controls, /new Controls\(viewer, \{[\s\S]*?\n\s+engine,\n/);
|
||||||
// A later globe reaches the shell with its own engine.
|
// A later globe reaches the shell with its own engine.
|
||||||
assert.match(
|
assert.match(
|
||||||
controls,
|
read('./shellPart.js'),
|
||||||
/styleManager\.attachScene\(\{\s+viewer: next\.viewer,\s+engine: next\.engine,/,
|
/styleManager\.attachScene\(\{\s+viewer: next\.viewer,\s+engine: next\.engine,/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// A candidate engine that fails after the shell attached to it (a later
|
||||||
|
// part: data, Cockpit clouds) must leave the shell detached, so the working
|
||||||
|
// engine attaches again.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { shellScenePart } from './shellPart.js';
|
||||||
|
import { createSceneHost } from './sceneHost.js';
|
||||||
|
|
||||||
|
/** A shell double with the real attach rule: one globe at a time. */
|
||||||
|
class FakeShell {
|
||||||
|
constructor(engine) {
|
||||||
|
this.engine = engine;
|
||||||
|
this.attaches = [];
|
||||||
|
}
|
||||||
|
attachScene({ engine }) {
|
||||||
|
if (this.engine)
|
||||||
|
throw new Error('The shell is attached to a globe already');
|
||||||
|
this.engine = engine;
|
||||||
|
this.attaches.push(engine.kind);
|
||||||
|
}
|
||||||
|
async detachScene() {
|
||||||
|
this.engine = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a candidate failing in a later part leaves the shell ready for the working engine', async () => {
|
||||||
|
const sceneHost = createSceneHost();
|
||||||
|
const catalog = { get: (id) => ({ id }), unavailableMetadata: [] };
|
||||||
|
const cesium = {
|
||||||
|
kind: 'cesium',
|
||||||
|
viewer: null,
|
||||||
|
engine: { kind: 'cesium' },
|
||||||
|
catalog,
|
||||||
|
};
|
||||||
|
await sceneHost.attach(cesium);
|
||||||
|
const styleManager = new FakeShell(cesium.engine);
|
||||||
|
await sceneHost.register(
|
||||||
|
shellScenePart(styleManager, { builtOn: cesium, catalog }),
|
||||||
|
);
|
||||||
|
// The data part: it fails on Navara.
|
||||||
|
await sceneHost.register({
|
||||||
|
name: 'data',
|
||||||
|
attachScene(next) {
|
||||||
|
if (next.kind === 'navara') throw new Error('layer attachment failed');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await sceneHost.detach({ handoff: true });
|
||||||
|
assert.equal(styleManager.engine, null);
|
||||||
|
const navara = {
|
||||||
|
kind: 'navara',
|
||||||
|
viewer: null,
|
||||||
|
engine: { kind: 'navara' },
|
||||||
|
catalog,
|
||||||
|
};
|
||||||
|
await assert.rejects(sceneHost.attach(navara), /layer attachment failed/);
|
||||||
|
assert.equal(styleManager.engine, null, 'the failed candidate let go');
|
||||||
|
await sceneHost.attach(cesium);
|
||||||
|
assert.equal(styleManager.engine.kind, 'cesium');
|
||||||
|
assert.deepEqual(styleManager.attaches, ['navara', 'cesium']);
|
||||||
|
});
|
||||||
@@ -129,10 +129,8 @@ export function createSceneHost({ onError = null } = {}) {
|
|||||||
for (const part of [...parts])
|
for (const part of [...parts])
|
||||||
await attachPart(part, target, current);
|
await attachPart(part, target, current);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errors = await release(current, {
|
// The parts keep their own handoff choice; `failed` names why.
|
||||||
handoff: false,
|
const errors = await release(current, { failed: true });
|
||||||
failed: true,
|
|
||||||
});
|
|
||||||
attachment = null;
|
attachment = null;
|
||||||
scene = null;
|
scene = null;
|
||||||
state = 'detached';
|
state = 'detached';
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { catalogControlServices } from './catalog.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shell's scene part: the shell was built on `builtOn`, and each later
|
||||||
|
* globe attaches to it with that globe's catalog. A switch detaches the
|
||||||
|
* shell in 'shell-release' (the tools phase), before the data layers, as its
|
||||||
|
* teardown always did; this part's detach covers an attachment that failed
|
||||||
|
* before that part attached (a later part of a candidate engine) and is a
|
||||||
|
* no-op once the shell has let go.
|
||||||
|
*/
|
||||||
|
export function shellScenePart(styleManager, { builtOn, catalog }) {
|
||||||
|
let first = builtOn;
|
||||||
|
return {
|
||||||
|
name: 'shell',
|
||||||
|
attachScene(next) {
|
||||||
|
if (next === first) {
|
||||||
|
first = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
first = null;
|
||||||
|
styleManager.attachScene({
|
||||||
|
viewer: next.viewer,
|
||||||
|
engine: next.engine,
|
||||||
|
mapStackController: next.mapStackController,
|
||||||
|
services: catalogControlServices(next.catalog ?? catalog),
|
||||||
|
engineSwitch: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
detachScene: (options) => styleManager.detachScene(options),
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -113,7 +113,7 @@ export async function createApplicationTools({
|
|||||||
scene,
|
scene,
|
||||||
{
|
{
|
||||||
name: 'shell-release',
|
name: 'shell-release',
|
||||||
detachScene: () => styleManager.detachScene?.(),
|
detachScene: (options) => styleManager.detachScene?.(options),
|
||||||
},
|
},
|
||||||
{ defer },
|
{ defer },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ test('newer navigation, reset, Cockpit, and teardown share one generation', () =
|
|||||||
'this._navigation.stop();',
|
'this._navigation.stop();',
|
||||||
], 'dispose invalidation');
|
], 'dispose invalidation');
|
||||||
// An engine switch retires the outgoing globe's navigation the same way.
|
// An engine switch retires the outgoing globe's navigation the same way.
|
||||||
const detach = body(ui, /async detachScene\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/, 'detach');
|
const detach = body(ui, /async _detachScene\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/, 'detach');
|
||||||
ordered(detach, [
|
ordered(detach, [
|
||||||
'this._navigation.setScene(',
|
'this._navigation.setScene(',
|
||||||
'this._layerBindings.stop();',
|
'this._layerBindings.stop();',
|
||||||
@@ -321,7 +321,7 @@ test('teardown synchronously closes immediate camera entry points', () => {
|
|||||||
'this._disposed = true;',
|
'this._disposed = true;',
|
||||||
'await this.detachScene(',
|
'await this.detachScene(',
|
||||||
], 'synchronous teardown barrier');
|
], 'synchronous teardown barrier');
|
||||||
const detach = body(ui, /async detachScene\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/, 'detach');
|
const detach = body(ui, /async _detachScene\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/, 'detach');
|
||||||
ordered(detach, [
|
ordered(detach, [
|
||||||
'this._layerBindings.stop();',
|
'this._layerBindings.stop();',
|
||||||
'await this._contextControls.restoreForDisposal();',
|
'await this._contextControls.restoreForDisposal();',
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ test('universal notice lifecycle clears on dispose and uses the one top-center l
|
|||||||
assert.match(dispose, /this\._feedback\.clearLoadingStatus\(\);/);
|
assert.match(dispose, /this\._feedback\.clearLoadingStatus\(\);/);
|
||||||
// The globe-bound parts, share restoration included, go with the scene.
|
// The globe-bound parts, share restoration included, go with the scene.
|
||||||
assert.match(dispose, /await this\.detachScene\(/);
|
assert.match(dispose, /await this\.detachScene\(/);
|
||||||
const detachStart = ui.indexOf(' async detachScene({');
|
const detachStart = ui.indexOf(' async _detachScene({');
|
||||||
const detach = ui.slice(detachStart, ui.indexOf('\n }\n', detachStart));
|
const detach = ui.slice(detachStart, ui.indexOf('\n }\n', detachStart));
|
||||||
assert.match(detach, /this\._shareRestoration\.destroy\(\)/);
|
assert.match(detach, /this\._shareRestoration\.destroy\(\)/);
|
||||||
assert.match(ShareRestoration.prototype.destroy.toString(), /this\._shareTrackingNoticeGeneration \+= 1;/);
|
assert.match(ShareRestoration.prototype.destroy.toString(), /this\._shareTrackingNoticeGeneration \+= 1;/);
|
||||||
|
|||||||
+64
-44
@@ -708,49 +708,61 @@ export class StyleManager extends ShellFacade {
|
|||||||
if (this._disposed) return;
|
if (this._disposed) return;
|
||||||
if (this._scene.engine)
|
if (this._scene.engine)
|
||||||
throw new Error('The shell is attached to a globe already');
|
throw new Error('The shell is attached to a globe already');
|
||||||
const previousServices = this.services;
|
// An attachment that fails part-way releases what it built: the shell
|
||||||
this.services = { ...this.services, ...services };
|
// stays detached, ready for the working engine.
|
||||||
this._scene = { viewer, engine, mapStackController };
|
try {
|
||||||
const {
|
const previousServices = this.services;
|
||||||
flightsLayer,
|
this.services = { ...this.services, ...services };
|
||||||
militaryFlightsLayer,
|
this._scene = { viewer, engine, mapStackController };
|
||||||
satellitesLayer,
|
const {
|
||||||
aisLiveVesselsLayer,
|
flightsLayer,
|
||||||
militaryAwarenessLayer,
|
militaryFlightsLayer,
|
||||||
rocketLaunchesLayer,
|
satellitesLayer,
|
||||||
} = this.services;
|
aisLiveVesselsLayer,
|
||||||
this._navigation.tracking = {
|
militaryAwarenessLayer,
|
||||||
flightsLayer,
|
rocketLaunchesLayer,
|
||||||
militaryFlightsLayer,
|
} = this.services;
|
||||||
satellitesLayer,
|
this._navigation.tracking = {
|
||||||
aisLiveVesselsLayer,
|
flightsLayer,
|
||||||
militaryAwarenessLayer,
|
militaryFlightsLayer,
|
||||||
rocketLaunchesLayer,
|
satellitesLayer,
|
||||||
};
|
aisLiveVesselsLayer,
|
||||||
this._navigation.setScene({ viewer, engine });
|
militaryAwarenessLayer,
|
||||||
this._locationNavigation.attachScene({ viewer, engine });
|
rocketLaunchesLayer,
|
||||||
this.hud.attachEngine(engine, { viewer });
|
};
|
||||||
this.shareLinkManager.attachEngine(engine);
|
this._navigation.setScene({ viewer, engine });
|
||||||
this._displayBindings.attachViewer(viewer);
|
this._locationNavigation.attachScene({ viewer, engine });
|
||||||
this._buildSceneControls();
|
this.hud.attachEngine(engine, { viewer });
|
||||||
this._buildSceneCockpit();
|
this.shareLinkManager.attachEngine(engine);
|
||||||
this._shareRestoration.attachLinks(this.shareLinkManager);
|
this._displayBindings.attachViewer(viewer);
|
||||||
this._initSceneOverlays();
|
this._buildSceneControls();
|
||||||
this._initMapStackControl();
|
this._buildSceneCockpit();
|
||||||
this._refreshLayerControls(previousServices);
|
this._shareRestoration.attachLinks(this.shareLinkManager);
|
||||||
this._initCameraOrientationControls();
|
this._initSceneOverlays();
|
||||||
this._initHudScene();
|
this._initMapStackControl();
|
||||||
this._initCockpitDisplayPortal();
|
this._refreshLayerControls(previousServices);
|
||||||
this._applyGlobalPostDefaults();
|
this._initCameraOrientationControls();
|
||||||
this._startAnimationLoop();
|
this._initHudScene();
|
||||||
this._publishActiveStyle();
|
this._initCockpitDisplayPortal();
|
||||||
this._shareRestoration.start({
|
this._applyGlobalPostDefaults();
|
||||||
engineSwitch,
|
this._startAnimationLoop();
|
||||||
retainScene: Boolean(this._layerSession?.handoffLayers),
|
this._publishActiveStyle();
|
||||||
});
|
this._shareRestoration.start({
|
||||||
this._layerBindings.observeCamera();
|
engineSwitch,
|
||||||
this._layoutRightPanels();
|
retainScene: Boolean(this._layerSession?.handoffLayers),
|
||||||
this._syncCctvPanelViewport();
|
});
|
||||||
|
this._layerBindings.observeCamera();
|
||||||
|
this._layoutRightPanels();
|
||||||
|
this._syncCctvPanelViewport();
|
||||||
|
} catch (error) {
|
||||||
|
void this.detachScene().catch((cleanupError) =>
|
||||||
|
console.error(
|
||||||
|
'The shell could not leave a failed globe:',
|
||||||
|
cleanupError,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -759,7 +771,15 @@ export class StyleManager extends ShellFacade {
|
|||||||
* the open panels stay. `handoff` keeps the selection and a retainable
|
* the open panels stay. `handoff` keeps the selection and a retainable
|
||||||
* Context mode for the next globe.
|
* Context mode for the next globe.
|
||||||
*/
|
*/
|
||||||
async detachScene({
|
detachScene(options = {}) {
|
||||||
|
// One detach at a time: a second request joins the one under way.
|
||||||
|
this._sceneDetach ??= this._detachScene(options).finally(() => {
|
||||||
|
this._sceneDetach = null;
|
||||||
|
});
|
||||||
|
return this._sceneDetach;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _detachScene({
|
||||||
handoff = Boolean(this._layerSession?.handoffLayers),
|
handoff = Boolean(this._layerSession?.handoffLayers),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (!this._scene.engine && !this._scene.viewer) return;
|
if (!this._scene.engine && !this._scene.viewer) return;
|
||||||
|
|||||||
@@ -195,3 +195,24 @@ test('a Context transition in flight is restored before the globe goes', async (
|
|||||||
assert.equal(session.context._contextModeGeneration, 5);
|
assert.equal(session.context._contextModeGeneration, 5);
|
||||||
assert.equal(session.context._contextModeChanging, false);
|
assert.equal(session.context._contextModeChanging, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an attachment that throws part-way leaves the shell detached and attachable', async () => {
|
||||||
|
const { shell, log } = fakeShell();
|
||||||
|
await shell.detachScene({ handoff: true });
|
||||||
|
shell._initSceneOverlays = () => {
|
||||||
|
throw new Error('overlay host failed');
|
||||||
|
};
|
||||||
|
assert.throws(
|
||||||
|
() => shell.attachScene({ engine: { kind: 'navara' } }),
|
||||||
|
/overlay host failed/,
|
||||||
|
);
|
||||||
|
// The shell releases what it built and is detached when that settles.
|
||||||
|
await shell.detachScene();
|
||||||
|
assert.equal(shell.engine, null);
|
||||||
|
log.length = 0;
|
||||||
|
shell._initSceneOverlays = () => log.push('_initSceneOverlays');
|
||||||
|
const engine = { kind: 'cesium' };
|
||||||
|
shell.attachScene({ engine });
|
||||||
|
assert.equal(shell.engine, engine);
|
||||||
|
assert.ok(log.includes('shareRestoration.start'));
|
||||||
|
});
|
||||||
Reference in new issue
Block a user