From 468590485bc270a61bc6f47eb0be5bc2ad897e0a Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 5 Nov 2026 18:18:28 -0800 Subject: [PATCH] Give the Cesium renderer the contract's listener rules and error channel Frame-phase and draw-set ready listeners now run in subscription order, skip one removed during delivery, defer one added during it, and hand a throw to the renderer's error listeners (or reportError) instead of into Cesium's render loop. The renderer gains on('error' | 'contextLost' | 'contextRestored'); Cesium cannot restore a lost context, so it never sends contextRestored. Dispose clears every list. renderer.listeners joins the Cesium checks. --- src/render/cesium/appRenderer.js | 3 +- src/render/cesium/appRenderer.test.mjs | 1 - src/render/cesium/harness.js | 1 + src/render/cesium/ports.js | 109 +++++++++++++++++++++---- src/render/cesium/ports.test.mjs | 48 ++++++++++- src/render/cesium/sceneRenderer.js | 1 + 6 files changed, 144 insertions(+), 19 deletions(-) diff --git a/src/render/cesium/appRenderer.js b/src/render/cesium/appRenderer.js index 4c1d1bacc..17d164ec9 100644 --- a/src/render/cesium/appRenderer.js +++ b/src/render/cesium/appRenderer.js @@ -24,7 +24,7 @@ import { cesiumCapabilities, createCesiumPorts } from './ports.js'; * The members a transitional renderer has today. * @typedef {Pick} + * | 'camera' | 'query' | 'capture' | 'on' | 'resize' | 'destroy'>} * TransitionalRenderer */ @@ -103,6 +103,7 @@ export function createCesiumRendererPlugin({ camera: ports.camera, query: ports.query, capture: ports.capture, + on: ports.on, resize() { viewer.resize(); }, diff --git a/src/render/cesium/appRenderer.test.mjs b/src/render/cesium/appRenderer.test.mjs index a80abd67a..f6da249cb 100644 --- a/src/render/cesium/appRenderer.test.mjs +++ b/src/render/cesium/appRenderer.test.mjs @@ -347,7 +347,6 @@ const EXPECTED_MISSING = [ 'renderer.effects', 'renderer.globe', 'renderer.input', - 'renderer.on', 'renderer.quality', 'renderer.readPixels', 'renderer.releaseUnused', diff --git a/src/render/cesium/harness.js b/src/render/cesium/harness.js index d796e715d..71b64f88d 100644 --- a/src/render/cesium/harness.js +++ b/src/render/cesium/harness.js @@ -31,6 +31,7 @@ export const CESIUM_CHECKS = new Set([ 'query.projectMany', 'query.rays', 'query.withoutCapability', + 'renderer.listeners', 'renderer.whenSettled', ]); diff --git a/src/render/cesium/ports.js b/src/render/cesium/ports.js index 0240b5d8e..626f42c1e 100644 --- a/src/render/cesium/ports.js +++ b/src/render/cesium/ports.js @@ -21,6 +21,61 @@ import { renderPending, } from './internals.js'; +/** + * Where a listener's throw goes with no `error` listener: the platform's + * `reportError` (or a rethrow on a microtask), as a browser reports an event + * listener's exception, loud, while the frame and the other listeners carry + * on. + */ +function uncaught(error) { + if (typeof globalThis.reportError === 'function') + globalThis.reportError(error); + else + queueMicrotask(() => { + throw error; + }); +} + +/** + * One subscription list under the contract's listener rules: subscription + * order, one removed during delivery does not run, one added during + * delivery waits for the next event, and a throw goes to `onThrow` while + * the rest still run. Each subscription is its own entry, so a callback + * subscribed twice runs twice. + * @param {(error: unknown) => void} onThrow + */ +export function listenerList(onThrow) { + const entries = []; + return { + get size() { + return entries.length; + }, + on(cb) { + const entry = { cb, live: true }; + entries.push(entry); + return () => { + entry.live = false; + const i = entries.indexOf(entry); + if (i >= 0) entries.splice(i, 1); + }; + }, + fire(...args) { + for (const entry of entries.slice()) { + if (!entry.live) continue; + try { + entry.cb(...args); + } catch (error) { + onThrow(error); + } + } + }, + clear() { + for (const entry of entries) entry.live = false; + entries.length = 0; + }, + }; +} + /** The contract's colour as Cesium's. */ export function toColor(color, alpha = 1) { if (color === undefined || color === null) @@ -127,7 +182,27 @@ export function createCesiumPorts(widget, { capabilities }) { const camera = scene.camera; const sets = new Map(); - const frameListeners = { preUpdate: [], preRender: [], postRender: [] }; + // An error listener's own throw is uncaught, never reported again. + const rendererListeners = { + error: listenerList(uncaught), + contextLost: listenerList(report), + contextRestored: listenerList(report), + }; + /** Hands a listener's throw to the renderer's `error` listeners. */ + function report(error) { + if (rendererListeners.error.size === 0) uncaught(error); + else rendererListeners.error.fire(error); + } + // Cesium cannot bring a lost context back, so it never sends + // `contextRestored`. + const contextLost = () => rendererListeners.contextLost.fire(); + scene.canvas?.addEventListener?.('webglcontextlost', contextLost); + + const frameListeners = { + preUpdate: listenerList(report), + preRender: listenerList(report), + postRender: listenerList(report), + }; let frames = 0; let rendered = 0; // Every tick raises preUpdate, drawn or not; the phases of a tick share @@ -156,7 +231,7 @@ export function createCesiumPorts(widget, { capabilities }) { frames += 1; }; const onPhase = (phase) => (_scene, time) => { - if (frameListeners[phase].length === 0) return; + if (frameListeners[phase].size === 0) return; const t = { frame: frames, time: Cesium.JulianDate.toDate( @@ -177,7 +252,7 @@ export function createCesiumPorts(widget, { capabilities }) { }); liveTick = t; } - for (const cb of frameListeners[phase].slice()) cb(t); + frameListeners[phase].fire(t); }; // Reasons given before a frame starts drawing are answered by it; one // given while it draws waits for the next, as Cesium's own request does. @@ -270,11 +345,9 @@ export function createCesiumPorts(widget, { capabilities }) { const frame = { on(phase, cb) { - frameListeners[phase].push(cb); - return () => { - const i = frameListeners[phase].indexOf(cb); - if (i >= 0) frameListeners[phase].splice(i, 1); - }; + const list = frameListeners[phase]; + if (!list) throw new TypeError(`Unknown frame phase: ${phase}`); + return list.on(cb); }, requestRender(reason = 'unspecified') { // During a draw the reasons it answers are spoken for: a reason given @@ -333,7 +406,7 @@ export function createCesiumPorts(widget, { capabilities }) { function makeSet(kind, spec, build) { if (sets.has(spec.id)) throw new Error(`a set with id ${spec.id} is live`); const items = new Map(); - const listeners = { ready: [] }; + const listeners = { ready: listenerList(report) }; const record = { kind, spec, items, listeners, host: null, pickIds: [] }; // Each build records its items' pick ids, for height queries to see // through. @@ -381,12 +454,7 @@ export function createCesiumPorts(widget, { capabilities }) { scene.requestRender(); }, on(event, cb) { - listeners[event]?.push(cb); - return () => { - const list = listeners[event]; - const i = list?.indexOf(cb) ?? -1; - if (i >= 0) list.splice(i, 1); - }; + return listeners[event]?.on(cb) ?? (() => {}); }, dispose() { if (record.host && !record.host.isDestroyed?.()) @@ -702,7 +770,7 @@ export function createCesiumPorts(widget, { capabilities }) { host.add(model); model.readyEvent.addEventListener(() => { settle(false); - for (const cb of record.listeners.ready.slice()) cb(item.id); + record.listeners.ready.fire(item.id); }); model.errorEvent.addEventListener(() => settle(true)); scene.requestRender(); @@ -1092,6 +1160,11 @@ export function createCesiumPorts(widget, { capabilities }) { } return { + /** The renderer's own events: `error`, `contextLost`, `contextRestored`. */ + on(event, cb) { + return rendererListeners[event]?.on(cb) ?? (() => {}); + }, + report, frame, draw, camera: cameraPort, @@ -1106,6 +1179,10 @@ export function createCesiumPorts(widget, { capabilities }) { // The tick under way is no longer seen through: it is not claimed. settleTick(false); for (const off of removers) off(); + scene.canvas?.removeEventListener?.('webglcontextlost', contextLost); + // A listener that disposes the renderer stops the rest of its event. + for (const list of Object.values(rendererListeners)) list.clear(); + for (const list of Object.values(frameListeners)) list.clear(); for (const done of [...pendingRenders]) done(); // Late model loads find no host and destroy themselves. for (const record of sets.values()) record.host = null; diff --git a/src/render/cesium/ports.test.mjs b/src/render/cesium/ports.test.mjs index 549217258..e333c9b3b 100644 --- a/src/render/cesium/ports.test.mjs +++ b/src/render/cesium/ports.test.mjs @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import test from 'node:test'; -import { cesiumCapabilities, createCesiumPorts } from './ports.js'; +import { + cesiumCapabilities, + createCesiumPorts, + listenerList, +} from './ports.js'; test('capabilities follow what the scene supports', () => { assert.deepEqual(cesiumCapabilities({}), ['models']); @@ -27,6 +31,48 @@ test('a caller can ask for fewer capabilities, never more', () => { assert.deepEqual(cesiumCapabilities({}, new Set(['sampleHeight'])), []); }); +test('a listener list keeps the listener rules', () => { + const errors = []; + const list = listenerList((error) => errors.push(error)); + const seen = []; + let offB = () => {}; + list.on(() => { + seen.push('a'); + offB(); + list.on(() => seen.push('late')); + }); + offB = list.on(() => seen.push('b')); + list.on(() => { + throw new Error('listener'); + }); + const c = () => seen.push('c'); + list.on(c); + const offC2 = list.on(c); + list.fire(); + assert.deepEqual(seen, ['a', 'c', 'c']); + assert.equal(errors.length, 1); + offC2(); + seen.length = 0; + list.fire(); + assert.deepEqual(seen, ['a', 'c', 'late']); + list.clear(); + assert.equal(list.size, 0); +}); + +test('a listener that clears its list stops the rest of the event', () => { + const list = listenerList((error) => { + throw error; + }); + const seen = []; + list.on(() => { + seen.push('a'); + list.clear(); + }); + list.on(() => seen.push('b')); + list.fire(); + assert.deepEqual(seen, ['a']); +}); + /** A widget whose scene records what the query port asks of it. */ function fakeWidget(overrides = {}) { const listeners = new Set(); diff --git a/src/render/cesium/sceneRenderer.js b/src/render/cesium/sceneRenderer.js index 4d3f91c01..60e6b2c8f 100644 --- a/src/render/cesium/sceneRenderer.js +++ b/src/render/cesium/sceneRenderer.js @@ -265,6 +265,7 @@ export async function createCesiumSceneRenderer({ capture: ports.capture, whenSettled, resize: () => widget.resize(), + on: ports.on, destroy() { destroyed = true; for (const finish of [...watching]) finish('ended');