Keep the debug handle off window between engines and recover the page after a failed recovery

The application's debug handle is one object, present on window only while a globe is attached, so scripts reading __godsEyeView?.engine.kind see no null engine mid-switch. A detached application has no live components: settle no longer waits for a frame of a missing engine, and an engine that runs again after a failed recovery hides the loading screen the error showed.
This commit is contained in:
German Khokhlov committed 2026-11-03 19:10:47 +04:00
1 parent a99b862580
commit 8e1a3bab3e
3 files changed
+104 -6

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
// The debug handle is one object for the page; it is on window only while a
// globe is attached, as when every engine built its own application, so
// scripts reading `__godsEyeView?.engine.kind` never see a null engine.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApplicationTools } from './tools.js';
import { createSceneHost } from './sceneHost.js';
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
import { _resetRenderGovernorForTest } from '../renderGovernor.js';
test('the debug handle leaves window between engines and comes back the same', async (t) => {
const saved = {
window: globalThis.window,
document: globalThis.document,
localStorage: globalThis.localStorage,
};
globalThis.window = {};
globalThis.localStorage = {
getItem: () => null,
setItem() {},
removeItem() {},
};
globalThis.document = {
hidden: false,
getElementById: () => null,
querySelector: () => null,
body: {
classList: { add() {}, remove() {}, toggle() {}, contains: () => false },
appendChild() {},
},
addEventListener() {},
removeEventListener() {},
};
t.after(() => {
_resetRenderGovernorForTest();
for (const [name, value] of Object.entries(saved)) {
if (value === undefined) delete globalThis[name];
else globalThis[name] = value;
}
});
const sceneHost = createSceneHost();
const navara = (kind) => ({
kind,
viewer: null,
engine: createFakeEngine({ kind, canvas: new EventTarget() }),
tileset: null,
mapStackController: { kind: 'maps' },
operations: { surface: {} },
});
let current = navara('navara');
await sceneHost.attach(current);
const scene = {
operations: { surface: {} },
sceneHost,
current: () => current,
};
const cleanups = [];
await createApplicationTools({
scene,
voice: {
createControl: () => ({ update() {}, on: () => () => {}, remove() {} }),
createCard: () => null,
createSession: () => ({
async start() {},
stop() {},
sendText() {},
sendMapEvent() {},
}),
},
controls: { styleManager: {}, weatherEffects: null },
data: {
dataManager: { layers: new Map(), getAll: () => [] },
presentation: { flushVisible() {} },
},
signal: new AbortController().signal,
defer: (dispose) => cleanups.push(dispose),
});
const handle = window.__godsEyeView;
assert.equal(handle.engine, current.engine);
await sceneHost.detach({ handoff: true });
assert.equal(window.__godsEyeView, undefined);
assert.equal(handle.engine, null);
current = navara('navara');
await sceneHost.attach(current);
assert.equal(window.__godsEyeView, handle);
assert.equal(handle.engine, current.engine);
while (cleanups.length) await cleanups.pop()();
assert.equal(window.__godsEyeView, undefined);
});
+6 -2
View File
@@ -162,7 +162,8 @@ export async function createApplicationTools({
{ defer },
);
// The application's debug handle; its globe fields follow the engine.
// The application's debug handle, the same object on every globe; its
// globe fields follow the engine.
const debug = {
viewer: null,
engine: null,
@@ -179,7 +180,6 @@ export async function createApplicationTools({
surfaceServices: operations.surface,
requestRender: governorRequestRender,
};
window.__godsEyeView = debug;
defer(() => {
if (window.__godsEyeView === debug) delete window.__godsEyeView;
});
@@ -232,8 +232,12 @@ export async function createApplicationTools({
cockpitCloudEffects: controls.cockpitCloudEffects ?? null,
voiceCommands: attached.voiceCommands,
});
// Present only while a globe is attached, as when every engine
// built its own application: scripts read `engine.kind` from it.
window.__godsEyeView = debug;
},
detachScene() {
if (window.__godsEyeView === debug) delete window.__godsEyeView;
Object.assign(debug, {
viewer: null,
engine: null,
+9 -4
View File
@@ -91,10 +91,12 @@ function createApplication(kind, engineSwitch) {
return application;
}
// The running application while a globe is attached to it; null while the
// page has no engine (between engines, or after a failed recovery).
function liveComponents() {
return application?.getState().status === 'ready'
? application.getComponents()
: null;
if (application?.getState().status !== 'ready') return null;
const components = application.getComponents();
return components.scene?.engine ? components : null;
}
// The page's application is created once; a switch detaches and attaches
@@ -108,7 +110,7 @@ const legacySwitch =
// parked for a later switch back.
async function leave(from) {
const components = liveComponents();
const attached = Boolean(components?.scene.engine);
const attached = Boolean(components);
if (attached) {
cover ??= keepGlobeFrame(components.scene.engine);
cockpit = components.controls.styleManager.getCockpitState();
@@ -206,6 +208,9 @@ async function settle() {
layerSession.handoffLayers = null;
cockpit = null;
withdrawEngineRetry();
// An engine runs again after a failed recovery showed its error: the
// application, built once, does not start its loading chrome again.
loading.classList.add('hidden');
// The running engine owns the address again, including an entry visited
// before its share link listened or while it was held.
components.controls.styleManager.shareLinkManager?.resumeNavigation?.();