Measure session object identity and listener counts across engine switches

window.__gevSession names each session object by a stable instance id and counts applications, attachments and detaches. scripts/qa-app-once.mjs (report-only with --report for now) runs three round trips and a parked-renderer rollback on a keyless preview with paid map, model and voice endpoints blocked.
This commit is contained in:
German Khokhlov committed 2026-11-03 17:08:22 +04:00
1 parent 5564156c09
commit b2cc00833d
5 files changed
+716

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
// Identity and attachment counters for the page session, read by the switch
// acceptance scripts (scripts/qa-app-once.mjs, qa-voice-engine-switch.mjs):
// an engine switch keeps every session object and only re-attaches the parts
// bound to an engine, so each id below stays the same across switches.
const ids = new WeakMap();
let nextId = 0;
/**
* A small number naming one object for this page: the same object always
* reads the same id, a different object a different one. Null for values
* that are not objects.
* @param {unknown} value
* @returns {number|null}
*/
export function instanceId(value) {
if (
value === null ||
(typeof value !== 'object' && typeof value !== 'function')
)
return null;
let id = ids.get(value);
if (id === undefined) {
id = ++nextId;
ids.set(value, id);
}
return id;
}
/**
* The page's diagnostic handle (`window.__gevSession`).
* `read()` returns the session objects by name; `ids` maps each name to its
* {@link instanceId}. `count(name)` bumps a counter such as `attachments`.
* @param {() => Record<string, unknown>} read
*/
export function createSessionDiagnostics(read) {
const counters = {
applications: 0,
attachments: 0,
detaches: 0,
rollbacks: 0,
};
return Object.freeze({
get ids() {
return Object.fromEntries(
Object.entries(read() ?? {}).map(([name, value]) => [
name,
instanceId(value),
]),
);
},
get counters() {
return { ...counters };
},
get attachments() {
return counters.attachments;
},
get detaches() {
return counters.detaches;
},
count(name) {
counters[name] = (counters[name] ?? 0) + 1;
return counters[name];
},
});
}
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSessionDiagnostics, instanceId } from './sessionDiagnostics.js';
test('an object keeps its id and another object gets another one', () => {
const a = {};
const b = {};
const fn = () => {};
assert.equal(instanceId(a), instanceId(a));
assert.notEqual(instanceId(a), instanceId(b));
assert.equal(typeof instanceId(fn), 'number');
assert.equal(instanceId(null), null);
assert.equal(instanceId(undefined), null);
assert.equal(instanceId(3), null);
});
test('the diagnostics read ids at the time they are asked for', () => {
let shell = {};
const voice = {};
const diagnostics = createSessionDiagnostics(() => ({ shell, voice }));
const first = diagnostics.ids;
assert.equal(first.shell, instanceId(shell));
assert.deepEqual(diagnostics.ids, first);
shell = {};
assert.notEqual(diagnostics.ids.shell, first.shell);
assert.equal(diagnostics.ids.voice, first.voice);
});
test('counters start at zero and count each event', () => {
const diagnostics = createSessionDiagnostics(() => ({}));
assert.equal(diagnostics.attachments, 0);
diagnostics.count('attachments');
diagnostics.count('attachments');
diagnostics.count('detaches');
assert.equal(diagnostics.attachments, 2);
assert.equal(diagnostics.detaches, 1);
assert.deepEqual(diagnostics.counters, {
applications: 0,
attachments: 2,
detaches: 1,
rollbacks: 0,
});
});
+24
View File
@@ -12,6 +12,7 @@ import {
waitForFirstFrame,
} from './standalone/engineSwitch.js';
import { ENGINE_STORAGE_KEY } from './app/engineChoice.js';
import { createSessionDiagnostics } from './app/sessionDiagnostics.js';
import { useDisplayStore } from './ui/stores/display.js';
import { applicationPinia } from './ui/vueRuntime.js';
import { t } from './i18n/index.js';
@@ -31,6 +32,25 @@ let cockpit = null;
const renderers = createRendererCache();
const layerSession = { lifetime: new AbortController() };
const { signal } = layerSession.lifetime;
// Which session objects this page holds now (scripts/qa-app-once.mjs).
const session = createSessionDiagnostics(() => {
const components = application?.getComponents?.() ?? {};
const style = components.controls?.styleManager;
return {
application,
shell: style,
shareLink: style?.shareLinkManager,
hud: style?.hud,
navigation: style?._navigation,
placeSearch: style?.placeSearch,
dataManager: components.data?.dataManager,
voice: components.tools?.voiceCommands,
annotations: components.tools?.annotations,
geoImport: window.__gevGeoImport,
drawTool: window.__gevDrawTool,
};
});
window.__gevSession = session;
window.addEventListener('pagehide', (event) => {
if (event.persisted) return;
layerSession.lifetime.abort();
@@ -56,6 +76,7 @@ window.addEventListener('popstate', () => {
});
function createApplication(kind, engineSwitch) {
session.count('applications');
layerSession.restoringView = engineSwitch;
application = createStandaloneApplication({
googleApiKey: import.meta.env.GOOGLE_MAPS_API_KEY,
@@ -91,6 +112,7 @@ async function leave(from) {
renderers.retain(from);
}
await application?.destroy();
session.count('detaches');
}
// Start an engine on the retained session. Success means the engine runs
@@ -105,6 +127,7 @@ async function enter(kind) {
await style.initialRestorePromise;
signal.throwIfAborted();
layerSession.restoringView = false;
session.count('attachments');
resumeSession(layerSession);
// Only resume the aircraft that actually survived the handoff. A history
// entry may select a different subject, and a stale track may disappear.
@@ -215,6 +238,7 @@ const initialKind = pageEngineKind();
createApplication(initialKind, false)
.start()
.then(() => {
session.count('attachments');
engineSwitch = createEngineSwitch({
kind: initialKind,
requested: pageEngineKind,