diff --git a/src/scenes/director.js b/src/scenes/director.js index 98364dfff..a0c61cec1 100644 --- a/src/scenes/director.js +++ b/src/scenes/director.js @@ -153,6 +153,8 @@ export class SceneDirector { this._runAbort = null; /** @type {AbortController|null} Aborts the in-flight LOAD's layer transitions */ this._loadAbort = null; + /** @type {AbortController|null} Aborts a same-shot seek's layer reconcile */ + this._seekAbort = null; this._clock = createPlaybackClock({ isRunning: () => this._running, timingForShot: (scene, shot) => this._sceneTimingForShot(scene, shot), @@ -1516,17 +1518,24 @@ export class SceneDirector { return new Promise((resolve) => this._runIdleResolvers.add(resolve)); } + /** + * Supersede any pending seek. Only a seek's own layer reconcile is aborted: + * a request that is later rejected must not cancel an in-flight LOAD. + */ + _bumpSceneSeekGeneration() { + if (this._seekAbort) { + this._seekAbort.abort(); + if (this._loadAbort === this._seekAbort) this._loadAbort = null; + this._seekAbort = null; + } + return ++this._sceneSeekGeneration; + } + /** * Apply a new time inside the already-loaded shot without rebuilding its stack. * @returns {Promise} Null when the shortcut does not apply and * LOAD must rebuild the shot; otherwise whether the seek settled. */ - _bumpSceneSeekGeneration() { - this._loadAbort?.abort(); - this._loadAbort = null; - return ++this._sceneSeekGeneration; - } - async _seekLoadedShot( scene, seekState, @@ -1578,32 +1587,35 @@ export class SceneDirector { const previousEnabled = new Map( Object.keys(staleStates).map((id) => [ id, - Boolean(this.dataManager.getLayerLifecycleState(id)?.enabled), + this.dataManager.isEffectivelyEnabled(id), ]), ); const controller = new AbortController(); this._loadAbort = controller; + this._seekAbort = controller; const layerResult = await this._applyLayerStates( staleStates, this._loadToken(generation, controller.signal, seekGeneration), { stopOnRefusal: true, exitContextMode: false }, ); if (this._loadAbort === controller) this._loadAbort = null; - if ( - layerResult.cancelled || - layerResult.refused.length || + if (this._seekAbort === controller) this._seekAbort = null; + const superseded = () => this._destroyed || - seekGeneration !== this._sceneSeekGeneration - ) { - if (!this._destroyed && !this._loadAbort && !this._running) { - for (const id of [...layerResult.applied].reverse()) { - if (this._destroyed || this._loadAbort || this._running) break; - await this.dataManager.setEnabled( - id, - previousEnabled.get(id) ?? false, - { origin: 'scene' }, - ); - } + this._loadAbort || + this._running || + seekGeneration !== this._sceneSeekGeneration; + if (layerResult.cancelled || superseded()) return false; + if (layerResult.refused.length) { + // Only a refusal rolls back. A cancelled reconcile leaves layer state + // to whatever superseded it, as a cancelled LOAD does. + for (const id of [...layerResult.applied].reverse()) { + if (superseded()) break; + await this.dataManager.setEnabled( + id, + previousEnabled.get(id) ?? false, + { origin: 'scene' }, + ); } return false; } @@ -1998,6 +2010,8 @@ export class SceneDirector { this._interactions?.clear(); this._dataPacks?.clear(); this._bumpSceneSeekGeneration(); + this._loadAbort?.abort(); + this._loadAbort = null; this._loadGeneration++; this._clock.stopShot(); this._cancelActiveSceneTravel(); diff --git a/src/scenes/director.test.mjs b/src/scenes/director.test.mjs index b2981a68b..fd8db6149 100644 --- a/src/scenes/director.test.mjs +++ b/src/scenes/director.test.mjs @@ -428,6 +428,77 @@ test('same-shot seeking aborts in-flight admission when superseded by an early-r } }); +test('a rejected loadShot does not cancel an in-flight LOAD', async () => { + const { styleManager, viewer, restore } = makeDirector(); + let holdEnable = true; + let releaseEnable; + const enableGate = new Promise((resolve) => { + releaseEnable = resolve; + }); + const lifecycle = new LayerLifecycle(viewer); + lifecycle.register({ + id: 'flights', + name: 'flights', + init() {}, + async enable() { + if (holdEnable) await enableGate; + }, + disable() {}, + update() {}, + }); + const director = new SceneDirector(viewer, styleManager, lifecycle); + try { + const load = director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + for ( + let i = 0; + i < 20 && lifecycle.getLayerLifecycleState('flights').lifecycleState !== 'enabling'; + i++ + ) { + await Promise.resolve(); + } + assert.equal(lifecycle.getLayerLifecycleState('flights').lifecycleState, 'enabling'); + + assert.equal((await director.loadShot('missing', 'nope')).reason, 'shot-not-found'); + holdEnable = false; + releaseEnable(); + assert.equal((await load)?.started, true, 'the valid LOAD still lands'); + assert.equal(lifecycle.isEnabled('flights'), true); + } finally { + releaseEnable?.(); + await director.destroy(); + restore(); + } +}); + +test('refused same-shot seek rollback keeps a manual toggle still in flight', async () => { + let refuse = () => false; + const { director, dataManager, restore } = makeDirector({ + data: { refuse: (id, on) => refuse(id, on) }, + }); + try { + const shot = director._project.scenes[0].shots[0]; + shot.layers = { flights: { enabled: true }, traffic: { enabled: true } }; + await director.loadShot('scene-1', 'shot-a', { flyDuration: 0 }); + assert.equal(await director.seekScene('scene-1', 0.1), true); + await dataManager.setEnabled('flights', false); + await dataManager.setEnabled('traffic', false); + refuse = (id) => id === 'traffic'; + const manualEnable = dataManager.setEnabled('flights', true); + + assert.equal(await director.seekScene('scene-1', 0.25), false); + await manualEnable; + await settle(); + assert.equal( + dataManager.getAll().find(({ id }) => id === 'flights').enabled, + true, + 'rollback restores the operator\'s intended state, not the stale committed one', + ); + } finally { + await director.destroy(); + restore(); + } +}); + test('same-shot seeking keeps the shortcut when declared visibility is unchanged', async () => { const { director, dataManager, styleManager, restore } = makeDirector(); try {