diff --git a/scripts/qa-engine-switch.mjs b/scripts/qa-engine-switch.mjs index 3d51bf19e..b1f886651 100644 --- a/scripts/qa-engine-switch.mjs +++ b/scripts/qa-engine-switch.mjs @@ -255,12 +255,91 @@ try { ), ); assert.equal(await stored(), 'navara'); - // Back/Forward must hand the session off like the picker: the Contacts - // subject and its navigation history, the layers it owns and the Director - // survive, although the earlier entry carries an older view. + await page.goBack({ waitUntil: 'domcontentloaded' }); + await ready('cesium'); + assert.equal((await sharpen()).intensityPct, 49); + await page.goForward(); + await ready('navara'); + assert.equal((await sharpen()).intensityPct, 80); + await page.evaluate(() => + window.__godsEyeView.styleManager.setSharpen({ + enabled: false, + intensityPct: 49, + }), + ); + await switchTo('cesium'); + await switchTo('navara'); + assert.deepEqual(await sharpen(), { enabled: false, intensityPct: 49 }); + // The address carries the running layer set once its debounced write lands. + const settledAddress = async () => { + let previous; + for (;;) { + await new Promise((resolve) => setTimeout(resolve, 1200)); + const current = await page.evaluate(() => + new URLSearchParams(location.hash.slice(1)).get('l'), + ); + if (current === previous) return current; + previous = current; + } + }; + const pick = async (kind) => { + await page.select('#globe-engine', kind); + await ready(kind); + await page.waitForFunction( + () => !document.querySelector('.engine-switch-frame'), + ); + }; + const visit = async (direction, kind) => { + await (direction === 'back' + ? page.goBack({ waitUntil: 'domcontentloaded' }) + : page.goForward()); + await ready(kind); + await page.waitForFunction( + () => !document.querySelector('.engine-switch-frame'), + ); + }; + const quakes = () => + page.evaluate(() => { + const { dataManager } = window.__godsEyeView; + return { + model: window.__quakeModel === dataManager.layers.get('earthquakes'), + enabled: dataManager.getEnabledLayerIds().has('earthquakes'), + }; + }); + // Like a same-engine Back, each entry brings back its own layer set, on + // the same layer models. + await page.evaluate(() => { + window.__quakeModel = + window.__godsEyeView.dataManager.layers.get('earthquakes'); + }); + const navaraLayers = await settledAddress(); + await pick('cesium'); + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled('earthquakes', false, { + origin: 'user', + }), + ); + const cesiumLayers = await settledAddress(); + assert.notEqual(cesiumLayers, navaraLayers); + await visit('back', 'navara'); + assert.deepEqual(await quakes(), { model: true, enabled: true }); + assert.equal(await settledAddress(), navaraLayers, 'Back keeps its entry'); + await visit('forward', 'cesium'); + assert.deepEqual(await quakes(), { model: true, enabled: false }); + assert.equal(await settledAddress(), cesiumLayers, 'Forward keeps its entry'); + await page.evaluate(() => + window.__godsEyeView.dataManager.setEnabled('earthquakes', true, { + origin: 'user', + }), + ); + await pick('navara'); + console.log( + "PASS Back/Forward restore each entry's layers on the same models", + ); + // Back/Forward hand the session off like the picker: the Contacts subject + // and its navigation history, its layers and the Director survive. await page.evaluate(async () => { const { dataManager, styleManager, sceneDirector } = window.__godsEyeView; - window.qaLayersBefore = [...dataManager.getEnabledLayerIds()].sort(); await dataManager.setEnabled('ais-live-vessels', true, { origin: 'user' }); const outcome = await styleManager.setContextMode('contacts'); if (!outcome.ok) throw Error(JSON.stringify(outcome)); @@ -297,20 +376,16 @@ try { assert.equal(live.mode, 'flights'); assert.equal(live.subject, '223456789'); assert.ok(live.history.includes('ais-live-vessels:123456789')); - await page.goBack({ waitUntil: 'domcontentloaded' }); - await ready('cesium'); - await page.waitForFunction( - () => !document.querySelector('.engine-switch-frame'), - ); - assert.equal((await sharpen()).intensityPct, 49); + await settledAddress(); + const historyLength = await page.evaluate(() => history.length); + await pick('cesium'); + assert.deepEqual(await session(), live, 'a pick keeps the session'); + await settledAddress(); + await visit('back', 'navara'); assert.deepEqual(await session(), live, 'Back keeps the session'); - await page.goForward(); - await ready('navara'); - await page.waitForFunction( - () => !document.querySelector('.engine-switch-frame'), - ); - assert.equal((await sharpen()).intensityPct, 80); + await visit('forward', 'cesium'); assert.deepEqual(await session(), live, 'Forward keeps the session'); + assert.equal(await page.evaluate(() => history.length), historyLength + 1); assert.ok( await page.evaluate(async () => { const { dataManager, styleManager } = window.__godsEyeView; @@ -318,26 +393,13 @@ try { await dataManager.setEnabled('ais-live-vessels', false, { origin: 'user', }); - return ( - result.ok && - JSON.stringify([...dataManager.getEnabledLayerIds()].sort()) === - JSON.stringify(window.qaLayersBefore) - ); + return result.ok; }), - 'leaving Contacts after Back/Forward restores the layers it took', ); + await pick('navara'); console.log( 'PASS Back/Forward keep Contacts subject, history, layers and Director', ); - await page.evaluate(() => - window.__godsEyeView.styleManager.setSharpen({ - enabled: false, - intensityPct: 49, - }), - ); - await switchTo('cesium'); - await switchTo('navara'); - assert.deepEqual(await sharpen(), { enabled: false, intensityPct: 49 }); await page.goto(base.href, { waitUntil: 'domcontentloaded' }); await ready('navara'); await switchTo('cesium'); diff --git a/scripts/qa-layer-handoff.mjs b/scripts/qa-layer-handoff.mjs index ea059b397..4cd412fe0 100644 --- a/scripts/qa-layer-handoff.mjs +++ b/scripts/qa-layer-handoff.mjs @@ -300,6 +300,9 @@ try { console.log( 'PASS warm Navara failure rolled back to Cesium with the same flight and cockpit', ); + await page.evaluate(() => { + window.__parkedCesium = window.__godsEyeView.engine; + }); await page.select('#globe-engine', 'navara'); await ready('navara'); assert.ok( @@ -315,6 +318,73 @@ try { 'the retry starts a new Navara renderer on the same session', ); console.log('PASS retry after the warm failure started Navara cold'); + + // Both engines fail: the warm Cesium resume and the warm Navara recovery. + // No engine runs, but the error screen offers the engine picker, and a + // retry starts Cesium cold on the same session without a reload. + const lengthBeforeDoubleFailure = await page.evaluate(() => { + window.__failResume = true; + for (const engine of [window.__godsEyeView.engine, window.__parkedCesium]) { + const request = engine.render.request; + engine.render.request = function (reason, ...args) { + if (reason === 'engine-resume' && window.__failResume) { + window.__refusedResumes = (window.__refusedResumes ?? 0) + 1; + throw new Error('QA parked renderer failure'); + } + return request.call(this, reason, ...args); + }; + } + window.__startDocument = document; + return history.length; + }); + await page.select('#globe-engine', 'cesium'); + await page.waitForSelector('#loading-screen .engine-switch-retry button', { + visible: true, + timeout: 90000, + }); + assert.ok( + await page.evaluate( + (length) => + window.__refusedResumes === 2 && + !window.__godsEyeView && + history.length === length && + !document + .querySelector('#loading-screen') + .classList.contains('hidden') && + /QA parked renderer failure/.test( + document.querySelector('#loading-screen .loader-status').textContent, + ), + lengthBeforeDoubleFailure, + ), + 'a failed recovery shows its error and the engine picker', + ); + await page.evaluate(() => { + window.__failResume = false; + }); + await page.click( + '#loading-screen .engine-switch-retry [data-engine="cesium"]', + ); + await ready('cesium'); + assert.ok( + await page.evaluate(() => { + const { dataManager, styleManager } = window.__godsEyeView; + return ( + window.__startDocument === document && + !document.querySelector('.engine-switch-retry') && + dataManager === window.__handoff.dataManager && + dataManager.layers.get(window.__qaAircraft.layerId) === + window.__handoff.entry && + styleManager.getCockpitState().active && + styleManager.getAircraftTrackingTarget()?.id === + window.__qaAircraft.aircraftId && + new URL(location.href).searchParams.get('engine') === 'cesium' + ); + }), + 'the retry restores the same session on Cesium', + ); + console.log( + 'PASS failed recovery offered the picker; the retry restored the session on Cesium', + ); await page.evaluate(() => window.__godsEyeView.styleManager.controlCockpit('exit'), ); diff --git a/src/data/layerStateCoordinator.js b/src/data/layerStateCoordinator.js index 351162895..2af5963f9 100644 --- a/src/data/layerStateCoordinator.js +++ b/src/data/layerStateCoordinator.js @@ -45,6 +45,24 @@ function currentLayerOutcome(dataManager, layerId) { }; } +/** + * Whether a layer handed off by an engine switch runs on the incoming engine. + * The handoff records the layer's running state and whether the outgoing + * address listed it. Where the incoming address agrees with that, the layer + * keeps running as it was, operational intents included (a Contacts session + * runs layers its address never lists). Where it differs, the person visited + * an entry with another layer set (Back/Forward), and the entry wins, as it + * does for a same-engine Back. + * @param {{enabled?: boolean, durable?: boolean}|undefined} live + * @param {boolean} addressEnabled + */ +export function handoffTargetEnabled(live, addressEnabled) { + if (typeof live?.enabled !== 'boolean') return addressEnabled; + if (typeof live.durable === 'boolean' && live.durable !== addressEnabled) + return addressEnabled; + return live.enabled; +} + /** * Owns durable user layer preferences independently from transient runtime * choreography, and coordinates passive post-registration restoration. @@ -382,7 +400,10 @@ export class LayerStateCoordinator { LAYER_STATE_REGISTRY.map(async (entry) => { const controller = controllers.get(entry.id); const live = handoffLayers?.get(entry.id); - const targetEnabled = live?.enabled ?? enabled.has(entry.id); + const targetEnabled = handoffTargetEnabled( + live, + enabled.has(entry.id), + ); // A same-document engine choice retains operational layer intents. // The share still owns durable preferences and later history visits. if (live && this.dataManager.layers.get(entry.id) === live.entry && diff --git a/src/data/layerStateHandoff.test.mjs b/src/data/layerStateHandoff.test.mjs new file mode 100644 index 000000000..6a3867998 --- /dev/null +++ b/src/data/layerStateHandoff.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { handoffTargetEnabled } from './layerStateCoordinator.js'; + +test('a pick keeps every handed-off layer as it runs', () => { + // The incoming address is the outgoing one: it agrees everywhere. + assert.equal( + handoffTargetEnabled({ enabled: true, durable: true }, true), + true, + ); + // A Contacts session runs layers its address never lists. + assert.equal( + handoffTargetEnabled({ enabled: true, durable: false }, false), + true, + ); + assert.equal( + handoffTargetEnabled({ enabled: false, durable: true }, true), + false, + ); +}); + +test('a visited entry with another layer set wins where it differs', () => { + assert.equal( + handoffTargetEnabled({ enabled: false, durable: false }, true), + true, + ); + assert.equal( + handoffTargetEnabled({ enabled: true, durable: true }, false), + false, + ); +}); + +test('without a handoff record the address decides', () => { + assert.equal(handoffTargetEnabled(undefined, true), true); + assert.equal(handoffTargetEnabled({ entry: null }, false), false); + // A record without the outgoing address keeps the running state. + assert.equal(handoffTargetEnabled({ enabled: true }, false), true); +}); diff --git a/src/main.js b/src/main.js index 0af27e092..793e4e9df 100644 --- a/src/main.js +++ b/src/main.js @@ -34,27 +34,20 @@ window.addEventListener('pagehide', (event) => { .finally(() => renderers.clear()); }); -// Registered before any engine starts, so these run ahead of the running -// session's own share-link listeners. A history visit that changes the -// engine belongs to the switch: the outgoing engine must not start restoring -// that entry's share (and release the followed subject) just before it hands -// the session off. The incoming engine reads the entry's view as it starts. -let swallowedHash = null; -window.addEventListener('popstate', (event) => { +// Registered before any engine starts, so it runs ahead of the session's own +// share-link listeners. A history visit that changes the engine belongs to +// the switch: the outgoing engine's share link neither restores that entry +// (releasing the followed subject just before the handoff) nor writes its +// own view onto it. The incoming engine reads the entry as it starts, and +// settle() hands the address back to whichever engine then runs. +window.addEventListener('popstate', () => { if (!engineSwitch) return; - swallowedHash = null; - if (pageEngineKind() !== engineSwitch.state().engine) { - event.stopImmediatePropagation(); - swallowedHash = location.hash; - } + if (pageEngineKind() !== engineSwitch.state().engine) + application + ?.getComponents() + .controls?.styleManager?.shareLinkManager?.holdNavigation?.(); void engineSwitch.reconcile(); }); -window.addEventListener('hashchange', (event) => { - // Back/Forward fires hashchange after popstate for the same entry. - if (swallowedHash !== null && location.hash === swallowedHash) - event.stopImmediatePropagation(); - swallowedHash = null; -}); function createApplication(kind, engineSwitch) { layerSession.restoringView = engineSwitch; @@ -83,7 +76,11 @@ async function leave(from) { if (components) { cover ??= keepGlobeFrame(components.scene.engine); cockpit = components.controls.styleManager.getCockpitState(); - handOffSession(layerSession); + handOffSession(layerSession, { + addressLayerIds: + components.controls.styleManager.shareLinkManager?.currentLayerState?.() + ?.enabledLayerIds, + }); renderers.retain(from); } await application?.destroy(); @@ -127,20 +124,56 @@ async function enter(kind) { } async function settle() { - const engine = liveComponents()?.scene.engine; - if (cover && engine) await waitForFirstFrame(engine); - // A page left without an engine keeps the last handoff for the next try. - if (engine) { - layerSession.handoffLayers = null; - cockpit = null; + const components = liveComponents(); + try { + if (cover && components) await waitForFirstFrame(components.scene.engine); + } finally { + cover?.remove(); + cover = null; } - cover?.remove(); - cover = null; + // A page left without an engine keeps the last handoff for the next try. + if (!components) return; + layerSession.handoffLayers = null; + cockpit = null; + withdrawEngineRetry(); + // 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?.(); } function present() { const { kind, busy } = engineSwitch.state(); display.presentGlobeEngine({ kind, busy, select }); + for (const button of loading.querySelectorAll('.engine-switch-retry button')) + button.disabled = busy; +} + +// No engine runs after a failed recovery. The error stays on the loading +// screen with an engine picker, so a new attempt needs no reload. +function offerEngineRetry() { + const content = loading.querySelector('.loader-content') ?? loading; + if (content.querySelector('.engine-switch-retry')) return; + const group = document.createElement('div'); + group.className = 'engine-switch-retry'; + group.setAttribute('role', 'group'); + group.setAttribute('aria-label', t('Globe Engine')); + for (const [kind, name] of [ + ['cesium', 'Cesium'], + ['navara', 'Navara'], + ]) { + const button = document.createElement('button'); + button.type = 'button'; + button.dataset.engine = kind; + button.textContent = name; + button.setAttribute('translate', 'no'); + button.addEventListener('click', () => select(kind)); + group.append(button); + } + content.append(group); +} + +function withdrawEngineRetry() { + loading.querySelector('.engine-switch-retry')?.remove(); } function report(error) { @@ -198,8 +231,9 @@ createApplication(initialKind, false) onChange: present, onFailure(error, { recovered }) { const style = recovered && liveComponents()?.controls.styleManager; - if (style) style._showToast(describeError(error)); - else report(error); + if (style) return style._showToast(describeError(error)); + report(error); + offerEngineRetry(); }, }); present(); diff --git a/src/sharelink.js b/src/sharelink.js index 34cad5787..9006daf5c 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -122,6 +122,9 @@ export class ShareLinkManager { // the app's own state, never an incoming share. this._knownHash = null; this._unwatchNavigation = null; + this._onNavigate = null; + // Held while the page leaves this engine for another (holdNavigation). + this._navigationHeld = false; this._restoreAuthority = { visual: 0, map: 0, @@ -308,6 +311,7 @@ export class ShareLinkManager { if (this._destroyed || typeof onNavigate !== 'function') return; const target = globalThis.window; if (typeof target?.addEventListener !== 'function') return; + this._onNavigate = onNavigate; const handler = () => this._handleLocationChange(onNavigate); target.addEventListener('hashchange', handler); target.addEventListener('popstate', handler); @@ -318,8 +322,31 @@ export class ShareLinkManager { }; } - _handleLocationChange(onNavigate) { + /** + * The page is leaving this engine: a history visit asked for another one. + * The address now belongs to the visited entry, so this manager neither + * writes its view there (a pending debounced write included) nor restores + * that entry itself; the incoming engine reads it as it starts. + */ + holdNavigation() { + this._navigationHeld = true; + clearTimeout(this._debounceTimer); + this._debounceTimer = null; + } + + /** + * Take the address back (the engine was kept after all, or it has just + * started): end a hold and restore an entry visited while this manager was + * held or not yet watching. + */ + resumeNavigation() { if (this._destroyed) return; + this._navigationHeld = false; + if (this._onNavigate) this._handleLocationChange(this._onNavigate); + } + + _handleLocationChange(onNavigate) { + if (this._destroyed || this._navigationHeld) return; const hash = window.location.hash.slice(1); // Back/Forward fires both popstate and hashchange for one entry. if (hash === this._knownHash) return; @@ -483,6 +510,11 @@ export class ShareLinkManager { this._layerStateProvider = typeof provider === 'function' ? provider : null; } + /** The durable layer state this address carries, or null without one. */ + currentLayerState() { + return this._layerStateProvider?.() ?? null; + } + /** * Install the source of the entity the camera follows, used for share links: * `({ nowMs }) => ({ kind, id, offset, lastKnown }) | null`, in the share @@ -643,13 +675,15 @@ export class ShareLinkManager { } _scheduleUpdate() { - if (this._destroyed || this._initialRestorePending) return; + if (this._destroyed || this._initialRestorePending || this._navigationHeld) + return; clearTimeout(this._debounceTimer); this._debounceTimer = setTimeout(() => this._updateHash(), DEBOUNCE_MS); } _updateHash() { - if (this._destroyed || this._initialRestorePending) return; + if (this._destroyed || this._initialRestorePending || this._navigationHeld) + return; const params = this._buildHashParams(); if (!params) return; // The hash belongs to this page's address, not the document base the diff --git a/src/sharelink.navigation.test.mjs b/src/sharelink.navigation.test.mjs index 9f01d4a6e..15d143589 100644 --- a/src/sharelink.navigation.test.mjs +++ b/src/sharelink.navigation.test.mjs @@ -424,3 +424,55 @@ test('the layer coordinator replaces in-flight share layers with the navigated s ); coordinator.destroy(); }); + +test('a manager held for an engine-changing visit neither stamps nor restores that entry', (t) => { + const tab = installTab(t, SHARE_A); + const timers = installManualTimers(t); + const manager = new ShareLinkManager(fakeViewer()); + manager.completeInitialRestore(manager.parseInitialHash().restoreToken); + // The page's own popstate listener runs first (src/main.js): the visit + // asks for another engine, so it holds this manager. + let engineChanges = false; + window.addEventListener('popstate', () => { + if (engineChanges) manager.holdNavigation(); + }); + const navigated = []; + manager.watchNavigation((state) => navigated.push(state)); + tab.navigate(SHARE_B); + navigated.length = 0; + manager.completeInitialRestore(manager._restoreToken); + // A camera move has armed a debounced write of the outgoing view. + manager._scheduleUpdate(); + assert.equal(timers.size, 1); + engineChanges = true; + tab.back(); + assert.equal(timers.size, 0); + manager._scheduleUpdate(); + manager._updateHash(); + manager.flush(); + window.dispatchEvent(new Event('hashchange')); + assert.equal(timers.size, 0); + assert.equal(window.location.hash, `#${SHARE_A}`); + assert.equal(navigated.length, 0); + // Kept after all (the next visit returned to this engine): it restores + // the entry it was held on and writes again. + manager.resumeNavigation(); + assert.equal(navigated.length, 1); + assert.equal(navigated[0].lat, 10); + manager.destroy(); +}); + +test('resuming navigation restores an entry visited before the manager watched', (t) => { + installTab(t, SHARE_A); + const manager = new ShareLinkManager(fakeViewer()); + manager.completeInitialRestore(manager.parseInitialHash().restoreToken); + const navigated = []; + manager.watchNavigation((state) => navigated.push(state)); + manager.resumeNavigation(); + assert.equal(navigated.length, 0, 'the same entry is not restored again'); + window.location.hash = SHARE_B; + manager.resumeNavigation(); + assert.equal(navigated.length, 1); + assert.equal(navigated[0].lat, 48.8584); + manager.destroy(); +}); diff --git a/src/standalone/engineSwitch.js b/src/standalone/engineSwitch.js index 808c1d0b5..707b9e22e 100644 --- a/src/standalone/engineSwitch.js +++ b/src/standalone/engineSwitch.js @@ -1,11 +1,16 @@ // The page's engine switch: one layer session, one engine at a time. // -// The address (?engine=) is the source of truth. A switch from the picker -// (select) and a history visit (Back/Forward, a pasted address) are the same -// operation: the running engine hands the session off and is released, the -// requested engine starts on the retained session. 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. +// 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 +// 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 +// the retained session. They differ in what the incoming engine shows: a pick +// keeps the running view and layers, a visit restores the entry's view and +// the layers its entry changed (as a same-engine Back does) on the retained +// layer models. +// 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. const ENGINES = Object.freeze(['cesium', 'navara']); @@ -25,13 +30,13 @@ export const FIRST_FRAME_WAIT_MS = 10_000; * @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) => Promise} options.leave Hand the - * session off from the running engine and release that engine. + * @param {(from: string, to: string, change: {visit: boolean}) => Promise} options.leave + * Hand the session off from the running engine and release that engine. * @param {(kind: string, attempt: {generation: number, recovery: boolean}) => Promise} 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, to: string) => *, commit: (token: *) => void, restore: (kind: string) => void}} options.address + * @param {{select: (from: ?string) => *, commit: (token: *, kind: string) => void, restore: (kind: string) => void}} options.address * @param {(state: {failed: boolean}) => Promise|void} [options.settle] Runs * once the address is reconciled, before the machine reports idle. * @param {(kind: string) => void} [options.remember] Store a picked engine. @@ -61,10 +66,12 @@ export function createEngineSwitch({ let busy = false; let generation = 0; // Every request (select or address visit) bumps this; a visit also bumps - // `visits`, which retires a select's pending history entry. + // `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; @@ -79,7 +86,7 @@ export function createEngineSwitch({ const publish = () => onChange?.(state()); function prepare(next) { - pending = { to: next, visits, token: address.select(current, next) }; + pending = { to: next, token: address.select(current) }; } /** The picker's choice. While a switch runs it is queued, not dropped. */ @@ -100,8 +107,9 @@ export function createEngineSwitch({ if (signal.aborted) return Promise.resolve(); requests++; visits++; - // A later visit supersedes a select queued before it. + // A later visit supersedes a pick queued or starting before it. queued = null; + pending = null; return busy ? running : run(); } @@ -109,7 +117,9 @@ export function createEngineSwitch({ busy = true; publish(); running = drive() - .catch((error) => onFailure?.(error, { recovered: false, kind: null })) + .catch((error) => + onFailure?.(error, { recovered: current !== null, kind: current }), + ) .finally(() => { busy = false; running = null; @@ -123,7 +133,12 @@ export function createEngineSwitch({ await reconcileAddress(); if (signal.aborted) return; const seen = requests; - await settle?.({ failed }); + 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; } } @@ -137,7 +152,7 @@ export function createEngineSwitch({ queued = null; if (next !== current) prepare(next); } - const target = requested(); + 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. @@ -150,12 +165,13 @@ export function createEngineSwitch({ async function attempt(target) { const id = ++generation; const visitsAtStart = visits; + const visit = pending?.to !== target; publish(); try { if (current !== null) { const from = current; try { - await leave(from, target); + await leave(from, target, { visit }); } finally { current = null; } @@ -166,18 +182,17 @@ export function createEngineSwitch({ if (signal.aborted) return; current = working = target; failed = false; - if (pending?.to === target && pending.visits === visits) { - address.commit(pending.token); + if (!visit && pending?.to === target) { + address.commit(pending.token, target); remember?.(target); } pending = null; } catch (error) { if (signal.aborted) return; pending = null; - // Point this history entry back at the working engine unless the - // person has since moved to another entry. Nothing is pushed, so a - // failed switch leaves no extra entry behind. - if (visits === visitsAtStart && requested() === target) + // 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; @@ -205,10 +220,10 @@ export function createEngineSwitch({ } /** - * The engine choice in the page address. A picked engine replaces the - * current entry while it starts; only a successful switch adds the history - * entry (the earlier entry keeps the outgoing engine and view), so Back - * returns to the previous engine and a failed switch adds nothing. + * The engine choice in the page address. A pick leaves the address alone + * while its engine starts; a successful one adds the history entry (the + * earlier entry keeps the outgoing engine and view), so Back returns to the + * previous engine, and a failed or superseded pick changes no entry. */ export function createEngineAddress({ location, history, flush = () => {} }) { const withEngine = (engine) => { @@ -217,15 +232,15 @@ export function createEngineAddress({ location, history, flush = () => {} }) { return url.href; }; return Object.freeze({ - select(from, to) { + /** Snapshot the outgoing entry: its engine and its latest view. */ + select(from) { flush(); - const previous = from ? withEngine(from) : null; - history.replaceState(history.state, '', withEngine(to)); - return previous; + return from ? withEngine(from) : null; }, - commit(previous) { - if (!previous) return; - const next = location.href; + commit(previous, engine) { + const next = withEngine(engine); + // Without a running engine to return to, the pick names this entry. + if (!previous) return history.replaceState(history.state, '', next); history.replaceState(history.state, '', previous); history.pushState(null, '', next); }, @@ -236,21 +251,26 @@ export function createEngineAddress({ location, history, flush = () => {} }) { } /** - * Hand the session off before the running engine is released: record which - * layers run so the next engine retains them, and park the cameras and the + * Hand the session off before the running engine is released: record the + * layer models so the next engine retains them, and park the cameras and the * Director scene instead of ending them. Every engine change in this page - * hands off, whatever started it; the incoming engine reads the view - * (camera, style, map) from the address it starts with, which for a history - * visit may differ from the view that was showing. + * hands off, whatever started it. The incoming engine reads the view + * (camera, style, map) from the address it starts with. Each layer records + * whether it runs and whether the outgoing address listed it + * (`addressLayerIds`): the layer-state coordinator keeps it running as it + * was where the incoming address agrees (always after a pick) and follows a + * visited entry where it differs (handoffTargetEnabled). */ -export function handOffSession(session) { +export function handOffSession(session, { addressLayerIds = null } = {}) { const manager = session.manager; + const listed = addressLayerIds ? new Set(addressLayerIds) : null; session.handoffLayers = new Map( [...manager.layers].map(([id, entry]) => [ id, { entry: entry.module.attachRenderer ? entry : null, enabled: manager.isEffectivelyEnabled(id), + durable: listed ? listed.has(id) : undefined, }, ]), ); diff --git a/src/standalone/engineSwitch.test.mjs b/src/standalone/engineSwitch.test.mjs index 67928fff3..b26b983bf 100644 --- a/src/standalone/engineSwitch.test.mjs +++ b/src/standalone/engineSwitch.test.mjs @@ -127,7 +127,7 @@ function harness({ signal: lifetime.signal, async leave(from, to) { log.push(`leave:${from}->${to}`); - handOffSession(session); + handOffSession(session, { addressLayerIds: session.addressLayerIds }); parked.add(from); running = null; }, @@ -189,13 +189,25 @@ test('a picked engine hands the session off and adds one history entry', async ( test('Back and Forward hand the session off exactly like the picker', async () => { const h = harness(); - await h.machine.select('navara'); - const picked = h.log.splice(0).filter((line) => line !== 'flush'); + // The outgoing address lists flights; directions run off the record. + h.session.addressLayerIds = ['flights']; let captured; h.session.director.suspendScene = () => { captured = h.session.handoffLayers; h.log.push('suspend:director'); }; + await h.machine.select('navara'); + const picked = h.log.splice(0).filter((line) => line !== 'flush'); + const record = (handoff) => + Object.fromEntries( + [...handoff].map(([id, { enabled, durable }]) => [ + id, + { enabled, durable }, + ]), + ); + const afterPick = record(captured); + assert.deepEqual(afterPick.flights, { enabled: true, durable: true }); + assert.deepEqual(afterPick.directions, { enabled: false, durable: false }); h.browser.go(-1); await h.machine.reconcile(); assert.equal(h.machine.state().kind, 'cesium'); @@ -210,7 +222,9 @@ test('Back and Forward hand the session off exactly like the picker', async () = ), ); assert.ok(!h.log.includes('destroy:director')); - assert.deepEqual(captured.get('flights').enabled, true); + // The visit records the same running state and outgoing address; the + // layer-state coordinator compares that with the visited entry. + assert.deepEqual(record(captured), afterPick); assert.equal( captured.get('flights').entry, h.session.manager.layers.get('flights'), @@ -249,11 +263,32 @@ test('a visit during a switch is reconciled once the attempt settles', async () 'enter:cesium:warm', ], ); - // The pick was superseded before it settled: no entry was pushed for it. - assert.equal(h.browser.entries.length, 2); + // The pick was superseded before it settled: no entry was pushed for it, + // and neither entry was rewritten. + assert.deepEqual(h.browser.engines(), ['cesium', 'cesium']); assert.deepEqual(h.remembered, []); }); +test('a pick that fails after Back rewrites no entry', async () => { + const gate = deferred(); + const h = harness({ + urls: ['/?engine=cesium#a', '/?engine=cesium#b'], + outcome: (kind) => (kind === 'navara' ? gate.promise : undefined), + }); + const switching = h.machine.select('navara'); + h.browser.go(-1); + void h.machine.reconcile(); + gate.reject(new Error('Navara refused')); + await switching; + assert.equal(h.machine.state().kind, 'cesium'); + assert.equal(h.browser.index, 0); + assert.deepEqual(h.browser.engines(), ['cesium', 'cesium']); + assert.deepEqual( + h.browser.entries.map(({ url }) => new URL(url).hash), + ['#a', '#b'], + ); +}); + test('rollback goes to the last engine that worked, not the one that just failed', async () => { const gate = deferred(); const h = harness({ @@ -279,7 +314,7 @@ test('rollback goes to the last engine that worked, not the one that just failed ]); // The visited entry now names the engine that runs; no entry was added. assert.equal(h.browser.index, 0); - assert.deepEqual(h.browser.engines(), ['navara', 'navara']); + assert.deepEqual(h.browser.engines(), ['navara', 'cesium']); }); test('a pick made during a switch is queued, not dropped', async () => { @@ -388,6 +423,8 @@ test('after a failed recovery the page stays usable and the next pick starts an assert.equal(h.machine.state().kind, 'navara'); assert.equal(h.machine.state().failed, false); assert.equal(h.log.filter((line) => line.startsWith('leave')).length, 1); + // Nothing ran to return to: the pick names the current entry. + assert.deepEqual(h.browser.engines(), ['navara']); }); test('pagehide during a start neither rolls back nor starts another engine', async () => { @@ -537,15 +574,16 @@ test('the address keeps the outgoing view on the earlier entry', () => { location: browser.location, history: browser.history, }); - const token = address.select('cesium', 'navara'); - assert.deepEqual(browser.engines(), ['navara']); + const token = address.select('cesium'); + // The address stays as it is while the picked engine starts. + assert.deepEqual(browser.engines(), [null]); // The incoming engine writes its own view while it starts. browser.history.replaceState( null, '', '/?look=cinematic&engine=navara#lat=2', ); - address.commit(token); + address.commit(token, 'navara'); assert.deepEqual( browser.entries.map(({ url }) => url.replace(ORIGIN, '/')), [ @@ -554,3 +592,40 @@ test('the address keeps the outgoing view on the earlier entry', () => { ], ); }); + +test('a settle failure is not fatal and later requests are still reconciled', async () => { + const h = harness(); + let settles = 0; + const errors = []; + const original = console.error; + console.error = (...args) => errors.push(args); + try { + const machine = createEngineSwitch({ + kind: 'cesium', + requested: h.browser.requested, + signal: h.lifetime.signal, + leave: async () => {}, + enter: async () => {}, + settle: async () => { + settles += 1; + if (settles === 1) { + // A pick arrives while the first reveal fails. + void machine.select('cesium'); + throw new Error('cover failed'); + } + }, + address: createEngineAddress({ + location: h.browser.location, + history: h.browser.history, + }), + onFailure: () => assert.fail('a settle error must not report a failure'), + }); + await machine.select('navara'); + assert.equal(machine.state().kind, 'cesium'); + assert.equal(machine.state().failed, false); + assert.equal(settles, 2); + assert.equal(errors.length, 1); + } finally { + console.error = original; + } +}); diff --git a/src/ui/styles/controls.css b/src/ui/styles/controls.css index 1f836d397..b3a2a47c6 100644 --- a/src/ui/styles/controls.css +++ b/src/ui/styles/controls.css @@ -434,6 +434,26 @@ html:not([lang='en']):not(:lang(zh)):not(:lang(ja)) animation: pulse 2s ease-in-out infinite; } +/* After a failed globe switch: start an engine again without a reload. */ +.engine-switch-retry { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 16px; +} + +.engine-switch-retry button { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 1px; + padding: 6px 12px; + color: var(--text-primary); + background: transparent; + border: 1px solid var(--text-dim); + border-radius: 4px; + cursor: pointer; +} + @keyframes pulse { 0%, 100% {