mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 13:52:19 +00:00
Let Retry finish a frame event a throwing listener cut short
Cesium's Event.raiseEvent leaves an event mid-raise when a listener throws, so listeners added or removed afterwards stay queued. Retry now applies that queue for the frame and clock events: the notice's frame watchers see the next frame, and a listener removed since, such as a layer turned off, is not called again.
This commit is contained in:
1 parent
628de8386d
commit
02a8442e6e
4 files changed
+175
No files matched your search
@@ -1599,6 +1599,11 @@ render error before Cesium's loop handles it as before.
|
||||
- **Retry** after a render error restarts the render loop. The notice stays
|
||||
until a frame starts and ends without a render error (Cesium raises
|
||||
`postRender` after a failed frame too), and a loop that fails again keeps it.
|
||||
A listener that throws leaves its Cesium event mid-raise, with listeners
|
||||
added or removed since only queued, so Retry first applies the queue of the
|
||||
frame and clock events (`finishRaise()`): the notice's frame watchers start
|
||||
with the next frame, and a listener removed since, for a layer turned off,
|
||||
is not called again.
|
||||
- **Reload** after a lost context reloads the page: Cesium cannot draw into a
|
||||
context it has lost, and the address keeps the view.
|
||||
- **Reset view** opens the app's root address (`appRootUrl()`), without the
|
||||
|
||||
@@ -548,6 +548,7 @@
|
||||
"src/ui/renderFailure.test.mjs",
|
||||
"src/ui/renderFailureView.test.mjs",
|
||||
"src/ui/renderFailureLoopErrors.test.mjs",
|
||||
"src/ui/renderFailureEventRetry.test.mjs",
|
||||
"src/app/renderFailureInlineRetry.test.mjs",
|
||||
"src/layers/cctv/lifecycle.js",
|
||||
"src/layers/cctv/controls.js",
|
||||
|
||||
@@ -42,6 +42,17 @@ export function installRenderFailureNotice(
|
||||
locationRef.reload();
|
||||
return;
|
||||
}
|
||||
[
|
||||
scene.preUpdate,
|
||||
scene.postUpdate,
|
||||
scene.preRender,
|
||||
scene.postRender,
|
||||
scene.camera?.changed,
|
||||
scene.camera?.moveStart,
|
||||
scene.camera?.moveEnd,
|
||||
viewer.clock?.onTick,
|
||||
viewer.clock?.onStop,
|
||||
].forEach(finishRaise);
|
||||
viewer.useDefaultRenderLoop = true;
|
||||
scene.requestRender?.();
|
||||
}
|
||||
@@ -124,6 +135,30 @@ export function installRenderFailureNotice(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish the raise a throwing listener cut short. Cesium's `Event.raiseEvent`
|
||||
* marks the event as raising until every listener returns, and until then it
|
||||
* only queues the listeners added or removed: the frame watchers the notice
|
||||
* adds, or the throwing listener of a layer turned off since. Apply the queue
|
||||
* as the raise would have.
|
||||
*/
|
||||
function finishRaise(event) {
|
||||
if (!event?._invokingListeners) return;
|
||||
event._invokingListeners = false;
|
||||
for (const [listener, scopes] of event._toAdd) {
|
||||
const added = event._listeners.get(listener) ?? new Set();
|
||||
scopes.forEach((scope) => added.add(scope));
|
||||
event._listeners.set(listener, added);
|
||||
}
|
||||
event._toAdd.clear();
|
||||
for (const [listener, scopes] of event._toRemove) {
|
||||
const left = event._listeners.get(listener);
|
||||
scopes.forEach((scope) => left?.delete(scope));
|
||||
if (left?.size === 0) event._listeners.delete(listener);
|
||||
}
|
||||
event._toRemove.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `target[name]` so an error it throws is reported and thrown on, for
|
||||
* the caller to handle as before. Returns a function that unwraps it.
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Retry after an error thrown by a scene event listener. Cesium's
|
||||
// Event.raiseEvent has no try/finally: a throwing listener leaves the event
|
||||
// with _invokingListeners = true, so listeners added or removed afterwards are
|
||||
// only queued (_toAdd/_toRemove) until the next raise of that event completes.
|
||||
// Drives Cesium's real Event and startRenderLoop (CesiumWidget.prototype).
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { installRenderFailureNotice } from './renderFailure.js';
|
||||
|
||||
function setup() {
|
||||
const frames = [];
|
||||
const previousRaf = globalThis.requestAnimationFrame;
|
||||
globalThis.requestAnimationFrame = (fn) => frames.push(fn);
|
||||
// Scene.render as in Scene.js:4369: preUpdate always, preRender/postRender
|
||||
// only when a frame is rendered (requestRenderMode idle skips them).
|
||||
const scene = {
|
||||
renderError: new Cesium.Event(),
|
||||
preUpdate: new Cesium.Event(),
|
||||
preRender: new Cesium.Event(),
|
||||
postRender: new Cesium.Event(),
|
||||
requestRenderMode: true,
|
||||
_renderRequested: false,
|
||||
rendered: 0,
|
||||
requestRender() {
|
||||
this._renderRequested = true;
|
||||
},
|
||||
initializeFrame() {},
|
||||
render(time) {
|
||||
this.preUpdate.raiseEvent(this, time);
|
||||
const shouldRender = !this.requestRenderMode || this._renderRequested;
|
||||
if (!shouldRender) return;
|
||||
this._renderRequested = false;
|
||||
this.preRender.raiseEvent(this, time);
|
||||
this.rendered += 1;
|
||||
this.postRender.raiseEvent(this, time);
|
||||
},
|
||||
};
|
||||
const widget = Object.create(Cesium.CesiumWidget.prototype);
|
||||
Object.assign(widget, {
|
||||
_scene: scene,
|
||||
_clock: { tick() {} },
|
||||
_canRender: true,
|
||||
_useDefaultRenderLoop: false,
|
||||
_renderLoopRunning: false,
|
||||
_showRenderLoopErrors: false,
|
||||
_targetFrameRate: undefined,
|
||||
isDestroyed: () => false,
|
||||
resize() {},
|
||||
});
|
||||
scene.renderError.addEventListener(() => {
|
||||
widget._useDefaultRenderLoop = false;
|
||||
widget._renderLoopRunning = false;
|
||||
});
|
||||
const viewer = {
|
||||
scene,
|
||||
cesiumWidget: widget,
|
||||
canvas: { addEventListener() {}, removeEventListener() {} },
|
||||
get useDefaultRenderLoop() {
|
||||
return widget.useDefaultRenderLoop;
|
||||
},
|
||||
set useDefaultRenderLoop(value) {
|
||||
widget.useDefaultRenderLoop = value;
|
||||
},
|
||||
};
|
||||
const store = {
|
||||
renderFailure: null,
|
||||
publishRenderFailure(next) {
|
||||
this.renderFailure = next;
|
||||
},
|
||||
};
|
||||
const uninstall = installRenderFailureNotice(viewer, {
|
||||
store,
|
||||
locationRef: { href: 'https://gev.example/', reload() {}, assign() {} },
|
||||
logError() {},
|
||||
});
|
||||
const runFrames = (n) => {
|
||||
for (let i = 0; i < n; i += 1)
|
||||
for (const fn of frames.splice(0)) fn(16 * (i + 1));
|
||||
};
|
||||
const restore = () => {
|
||||
uninstall();
|
||||
globalThis.requestAnimationFrame = previousRaf;
|
||||
};
|
||||
return { frames, scene, widget, viewer, store, runFrames, restore };
|
||||
}
|
||||
|
||||
for (const eventName of ['postRender', 'preUpdate']) {
|
||||
test(`idle: a ${eventName} listener that threw once; a successful Retry clears the notice`, () => {
|
||||
const { scene, viewer, store, runFrames, restore } = setup();
|
||||
try {
|
||||
let broken = true;
|
||||
scene[eventName].addEventListener(() => {
|
||||
if (broken) throw new TypeError('listener failed');
|
||||
});
|
||||
scene.requestRender();
|
||||
viewer.useDefaultRenderLoop = true;
|
||||
runFrames(1);
|
||||
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||
assert.equal(viewer.useDefaultRenderLoop, false);
|
||||
broken = false; // the cause goes away
|
||||
store.renderFailure.retry();
|
||||
const before = scene.rendered;
|
||||
runFrames(10); // idle: no camera move, no render request
|
||||
assert.ok(scene.rendered > before, 'the globe drew again');
|
||||
assert.equal(store.renderFailure, null, 'the notice is cleared');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('a throwing postRender listener removed before Retry is not called again', () => {
|
||||
const { scene, viewer, store, runFrames, restore } = setup();
|
||||
try {
|
||||
let calls = 0;
|
||||
const remove = scene.postRender.addEventListener(() => {
|
||||
calls += 1;
|
||||
throw new TypeError('layer listener failed');
|
||||
});
|
||||
scene.requestRender();
|
||||
viewer.useDefaultRenderLoop = true;
|
||||
runFrames(1);
|
||||
assert.equal(store.renderFailure?.reason, 'render-error');
|
||||
remove(); // the user turns the layer off
|
||||
store.renderFailure.retry();
|
||||
runFrames(5);
|
||||
assert.equal(calls, 1, 'the removed listener ran again after Retry');
|
||||
assert.equal(viewer.useDefaultRenderLoop, true, 'the loop keeps running');
|
||||
assert.equal(store.renderFailure, null, 'the notice is cleared');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user