mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
fix: guard scene director callbacks
This commit is contained in:
1 parent
1df2a272cf
commit
d16620a29f
6 files changed
+149
-7
No files matched your search
@@ -124,7 +124,13 @@ class PlaybackClock {
|
|||||||
subscribe(listener) {
|
subscribe(listener) {
|
||||||
if (this._destroyed || typeof listener !== 'function') return () => {};
|
if (this._destroyed || typeof listener !== 'function') return () => {};
|
||||||
this._sceneClockListeners.add(listener);
|
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);
|
return () => this._sceneClockListeners.delete(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -178,6 +178,61 @@ test('synchronous stop before execution prevents any side effect; rejection unlo
|
|||||||
assert.equal(calls, 2);
|
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 () => {
|
test('settled pack shots cannot take the same-shot seek shortcut after Stop released geometry', async () => {
|
||||||
const { SceneDirector } = await import('../../scenes/director.js');
|
const { SceneDirector } = await import('../../scenes/director.js');
|
||||||
const shot = { id: 'shot', dataPackIds: ['pack'] };
|
const shot = { id: 'shot', dataPackIds: ['pack'] };
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
|||||||
selected = null;
|
selected = null;
|
||||||
let actions = new Map();
|
let actions = new Map();
|
||||||
const state = () => ({ active, busy, selected, count: actions.size });
|
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() {
|
function clear() {
|
||||||
active = false;
|
active = false;
|
||||||
busy = false;
|
busy = false;
|
||||||
@@ -13,7 +20,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
|||||||
actions.clear();
|
actions.clear();
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
controller = null;
|
controller = null;
|
||||||
changed(state());
|
notify();
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
clear,
|
clear,
|
||||||
@@ -22,7 +29,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
|||||||
clear();
|
clear();
|
||||||
actions = new Map(items.map((item) => [item.id, item]));
|
actions = new Map(items.map((item) => [item.id, item]));
|
||||||
active = !!actions.size;
|
active = !!actions.size;
|
||||||
changed(state());
|
notify();
|
||||||
},
|
},
|
||||||
async dispatch(id) {
|
async dispatch(id) {
|
||||||
const item = actions.get(id);
|
const item = actions.get(id);
|
||||||
@@ -31,7 +38,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
|||||||
controller = current;
|
controller = current;
|
||||||
busy = true;
|
busy = true;
|
||||||
selected = id;
|
selected = id;
|
||||||
changed(state());
|
notify();
|
||||||
let abort;
|
let abort;
|
||||||
try {
|
try {
|
||||||
const cancelled = new Promise((resolve) => {
|
const cancelled = new Promise((resolve) => {
|
||||||
@@ -53,7 +60,7 @@ export function createInteractionSession({ execute, changed = () => {} }) {
|
|||||||
if (controller === current) {
|
if (controller === current) {
|
||||||
busy = false;
|
busy = false;
|
||||||
controller = null;
|
controller = null;
|
||||||
changed(state());
|
notify();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export async function playSceneQueue(
|
|||||||
const cancelled = () => Boolean(token.cancelled || token.signal?.aborted);
|
const cancelled = () => Boolean(token.cancelled || token.signal?.aborted);
|
||||||
let activeScene = null;
|
let activeScene = null;
|
||||||
let completedShots = 0;
|
let completedShots = 0;
|
||||||
|
let runError = null;
|
||||||
try {
|
try {
|
||||||
if (!queue.length || cancelled()) {
|
if (!queue.length || cancelled()) {
|
||||||
return {
|
return {
|
||||||
@@ -68,8 +69,9 @@ export async function playSceneQueue(
|
|||||||
for (let index = 0; index < queue.length && !cancelled(); index++) {
|
for (let index = 0; index < queue.length && !cancelled(); index++) {
|
||||||
const { scene, shot } = queue[index];
|
const { scene, shot } = queue[index];
|
||||||
if (activeScene && activeScene.id !== scene.id) {
|
if (activeScene && activeScene.id !== scene.id) {
|
||||||
await adapter.releaseScene(activeScene);
|
const toRelease = activeScene;
|
||||||
activeScene = null;
|
activeScene = null;
|
||||||
|
await adapter.releaseScene(toRelease);
|
||||||
if (cancelled()) break;
|
if (cancelled()) break;
|
||||||
}
|
}
|
||||||
activeScene = scene;
|
activeScene = scene;
|
||||||
@@ -82,8 +84,19 @@ export async function playSceneQueue(
|
|||||||
}
|
}
|
||||||
if (!cancelled()) await adapter.complete?.();
|
if (!cancelled()) await adapter.complete?.();
|
||||||
return { status: cancelled() ? 'cancelled' : 'completed', completedShots };
|
return { status: cancelled() ? 'cancelled' : 'completed', completedShots };
|
||||||
|
} catch (error) {
|
||||||
|
runError = error;
|
||||||
|
throw error;
|
||||||
} finally {
|
} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -196,3 +196,35 @@ test('a cleanup failure rejects for the caller to restore its own controls', asy
|
|||||||
(error) => error === failure,
|
(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']);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user