From d16620a29ff3d8e696c0bada22d32608ca3a21a2 Mon Sep 17 00:00:00 2001 From: dufferdeepu Date: Sun, 11 Oct 2026 17:51:36 +0530 Subject: [PATCH] fix: guard scene director callbacks --- src/director/clock.js | 8 ++- src/director/clock.test.mjs | 29 ++++++++++ .../interactions/interactions.test.mjs | 55 +++++++++++++++++++ src/director/interactions/session.js | 15 +++-- src/director/playback.js | 17 +++++- src/director/playback.test.mjs | 32 +++++++++++ 6 files changed, 149 insertions(+), 7 deletions(-) diff --git a/src/director/clock.js b/src/director/clock.js index fa83047a3..48b1c19c5 100644 --- a/src/director/clock.js +++ b/src/director/clock.js @@ -124,7 +124,13 @@ class PlaybackClock { subscribe(listener) { if (this._destroyed || typeof listener !== 'function') return () => {}; this._sceneClockListeners.add(listener); - if (this._sceneClockSnapshot) listener({ ...this._sceneClockSnapshot }); + if (this._sceneClockSnapshot) { + try { + listener({ ...this._sceneClockSnapshot }); + } catch (error) { + console.warn('[Scenes] Scene clock listener failed:', error); + } + } return () => this._sceneClockListeners.delete(listener); } diff --git a/src/director/clock.test.mjs b/src/director/clock.test.mjs index c259c10ec..479808fc9 100644 --- a/src/director/clock.test.mjs +++ b/src/director/clock.test.mjs @@ -171,3 +171,32 @@ test('a subscriber can Stop or replace the initial clock without the old start a }); assert.equal(f.timers.size, 0); }); + +test('a subscriber throwing on immediate snapshot does not crash subscribe and receives unsubscribe handle', () => { + const f = fixture(); + f.clock.publish(scene, shot, 1); + let calls = 0; + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const unsubscribe = f.clock.subscribe(() => { + calls++; + throw new Error('listener failure'); + }); + assert.equal(typeof unsubscribe, 'function'); + assert.equal(calls, 1); + assert.equal(warnings.length, 1); + assert.equal(warnings[0][0], '[Scenes] Scene clock listener failed:'); + f.clock.publish(scene, shot, 2); + assert.equal(calls, 2); + assert.equal(warnings.length, 2); + unsubscribe(); + f.clock.publish(scene, shot, 3); + assert.equal(calls, 2); + assert.equal(warnings.length, 2); + f.clock.destroy(); + } finally { + console.warn = origWarn; + } +}); diff --git a/src/director/interactions/interactions.test.mjs b/src/director/interactions/interactions.test.mjs index fa0aa10ae..496616516 100644 --- a/src/director/interactions/interactions.test.mjs +++ b/src/director/interactions/interactions.test.mjs @@ -178,6 +178,61 @@ test('synchronous stop before execution prevents any side effect; rejection unlo assert.equal(calls, 2); }); +test('a throwing changed observer does not break session dispatch, activation, or clearing', async () => { + let executed = 0; + let throwOnBusy = true; + let throwOnEnd = true; + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + try { + const session = createInteractionSession({ + execute: () => { + executed++; + return true; + }, + changed: (state) => { + if (throwOnBusy && state.busy) throw new Error('start failed'); + if (throwOnEnd && !state.busy && state.selected === 'a') { + throw new Error('end failed'); + } + }, + }); + session.activate([{ id: 'a' }]); + const result = await session.dispatch('a'); + assert.equal(result, true); + assert.equal(executed, 1); + assert.deepEqual(session.getState(), { + active: true, + busy: false, + selected: 'a', + count: 1, + }); + assert.equal(warnings.length, 2); + assert.equal( + warnings[0][0], + '[Scenes] Interaction session observer failed:', + ); + assert.equal( + warnings[1][0], + '[Scenes] Interaction session observer failed:', + ); + throwOnBusy = false; + throwOnEnd = false; + assert.equal(await session.dispatch('a'), true); + assert.equal(executed, 2); + session.clear(); + assert.deepEqual(session.getState(), { + active: false, + busy: false, + selected: null, + count: 0, + }); + } finally { + console.warn = origWarn; + } +}); + test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => { const { SceneDirector } = await import('../../scenes/director.js'); const shot = { id: 'shot', dataPackIds: ['pack'] }; diff --git a/src/director/interactions/session.js b/src/director/interactions/session.js index 2f305b675..f731492e2 100644 --- a/src/director/interactions/session.js +++ b/src/director/interactions/session.js @@ -6,6 +6,13 @@ export function createInteractionSession({ execute, changed = () => {} }) { selected = null; let actions = new Map(); const state = () => ({ active, busy, selected, count: actions.size }); + function notify() { + try { + changed(state()); + } catch (error) { + console.warn('[Scenes] Interaction session observer failed:', error); + } + } function clear() { active = false; busy = false; @@ -13,7 +20,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { actions.clear(); controller?.abort(); controller = null; - changed(state()); + notify(); } return { clear, @@ -22,7 +29,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { clear(); actions = new Map(items.map((item) => [item.id, item])); active = !!actions.size; - changed(state()); + notify(); }, async dispatch(id) { const item = actions.get(id); @@ -31,7 +38,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { controller = current; busy = true; selected = id; - changed(state()); + notify(); let abort; try { const cancelled = new Promise((resolve) => { @@ -53,7 +60,7 @@ export function createInteractionSession({ execute, changed = () => {} }) { if (controller === current) { busy = false; controller = null; - changed(state()); + notify(); } } }, diff --git a/src/director/playback.js b/src/director/playback.js index 4fa97df1a..29759871e 100644 --- a/src/director/playback.js +++ b/src/director/playback.js @@ -53,6 +53,7 @@ export async function playSceneQueue( const cancelled = () => Boolean(token.cancelled || token.signal?.aborted); let activeScene = null; let completedShots = 0; + let runError = null; try { if (!queue.length || cancelled()) { return { @@ -68,8 +69,9 @@ export async function playSceneQueue( for (let index = 0; index < queue.length && !cancelled(); index++) { const { scene, shot } = queue[index]; if (activeScene && activeScene.id !== scene.id) { - await adapter.releaseScene(activeScene); + const toRelease = activeScene; activeScene = null; + await adapter.releaseScene(toRelease); if (cancelled()) break; } activeScene = scene; @@ -82,8 +84,19 @@ export async function playSceneQueue( } if (!cancelled()) await adapter.complete?.(); return { status: cancelled() ? 'cancelled' : 'completed', completedShots }; + } catch (error) { + runError = error; + throw error; } finally { - if (releaseOnFinish && activeScene) await adapter.releaseScene(activeScene); + if (releaseOnFinish && activeScene) { + const toRelease = activeScene; + activeScene = null; + try { + await adapter.releaseScene(toRelease); + } catch (error) { + if (!runError) throw error; + } + } } } diff --git a/src/director/playback.test.mjs b/src/director/playback.test.mjs index f1d8f0921..d6c0b6451 100644 --- a/src/director/playback.test.mjs +++ b/src/director/playback.test.mjs @@ -196,3 +196,35 @@ test('a cleanup failure rejects for the caller to restore its own controls', asy (error) => error === failure, ); }); + +test('a phase failure is preserved when final scene release also throws', async () => { + const phaseError = new Error('travel failed'); + const releaseError = new Error('release failed'); + const f = fixture({ + travel: async () => { + throw phaseError; + }, + releaseScene: async () => { + throw releaseError; + }, + }); + await assert.rejects( + playSceneQueue(buildPlaybackQueue(scenes, 'a', { single: true }), f), + (error) => error === phaseError, + ); +}); +test('a failing inter-scene release releases the active scene only once', async () => { + const releaseError = new Error('release failed'); + const released = []; + const f = fixture({ + releaseScene: async (scene) => { + released.push(scene.id); + throw releaseError; + }, + }); + await assert.rejects( + playSceneQueue(buildPlaybackQueue(scenes, 'a'), f), + (error) => error === releaseError, + ); + assert.deepEqual(released, ['a']); +}); \ No newline at end of file