Clean up the panel after host testing

Drop the render restarts added while chasing the black overlays; keep
the full render-error report and stop the timer after an error, as
Cesium does. Correct comments the investigation outran, share the
panel_request name, and document the host differences the panel works
around.
This commit is contained in:
Sameh Khamis committed 2026-10-02 18:29:42 -07:00
1 parent d0da4b0722
commit efdee8d255
6 files changed
+39 -49

No files matched your search

+9
View File
@@ -136,6 +136,15 @@ stylesheet, Cesium's script, and Cesium's workers combined into one script
that Cesium runs from memory (`CESIUM_WORKERS`). Rebuild it after changing that Cesium runs from memory (`CESIUM_WORKERS`). Rebuild it after changing
the app. the app.
Hosts differ in ways the panel works around, all inside the panel only:
images and stylesheet files arrive as `data:` URLs, since some hosts refuse
`blob:` images; code that needs an https address for the app gets
`GEV_APP_BASE_URL`, since some hosts serve the page from their own scheme;
the globe keeps drawing from a timer when the host reports the panel hidden
and stops animation frames; and 2D canvases are kept in memory
(`willReadFrequently`), since a host that treats the panel as off screen may
drop their GPU contents and show the overlays as black over the globe.
Tools declare a UI resource with `defineTool({ ui: { resourceUri } })`, and Tools declare a UI resource with `defineTool({ ui: { resourceUri } })`, and
an app-only tool with `ui: { visibility: ['app'] }`; an app-only tool with `ui: { visibility: ['app'] }`;
`createMcpServer({ resources })` serves `resources/list` and `createMcpServer({ resources })` serves `resources/list` and
+3 -13
View File
@@ -49,8 +49,6 @@ export function isEmbeddedInline() {
// frames; see keepPanelRendering. // frames; see keepPanelRendering.
const PANEL_FRAME_MS = 33; const PANEL_FRAME_MS = 33;
const MISSED_FRAMES_MS = 250; const MISSED_FRAMES_MS = 250;
const RENDER_RESTARTS = 3;
const RENDER_RESTART_MS = 1000;
/** A render error's details, including the plain objects workers report. */ /** A render error's details, including the plain objects workers report. */
function describeError(error) { function describeError(error) {
@@ -80,11 +78,10 @@ export function keepPanelRendering(
frameRequest = windowRef.requestAnimationFrame(onFrame); frameRequest = windowRef.requestAnimationFrame(onFrame);
}; };
frameRequest = windowRef.requestAnimationFrame(onFrame); frameRequest = windowRef.requestAnimationFrame(onFrame);
// Cesium stops drawing after a render error. Report it in full, and // Cesium stops drawing after a render error, and so does the timer.
// start again a few times in case it does not repeat. // Report the error in full: workers report plain objects, which Cesium
// prints as [object Object].
let failed = false; let failed = false;
let restarts = 0;
let restart = null;
const removeErrorListener = viewer.scene?.renderError?.addEventListener( const removeErrorListener = viewer.scene?.renderError?.addEventListener(
(_scene, error) => { (_scene, error) => {
failed = true; failed = true;
@@ -92,12 +89,6 @@ export function keepPanelRendering(
"[God's Eye View panel] render error:", "[God's Eye View panel] render error:",
describeError(error), describeError(error),
); );
if (restarts >= RENDER_RESTARTS) return;
restarts += 1;
restart = windowRef.setTimeout(() => {
failed = false;
viewer.useDefaultRenderLoop = true;
}, RENDER_RESTART_MS);
}, },
); );
const timer = windowRef.setInterval(() => { const timer = windowRef.setInterval(() => {
@@ -112,7 +103,6 @@ export function keepPanelRendering(
}, PANEL_FRAME_MS); }, PANEL_FRAME_MS);
return () => { return () => {
windowRef.clearInterval(timer); windowRef.clearInterval(timer);
windowRef.clearTimeout?.(restart);
windowRef.cancelAnimationFrame(frameRequest); windowRef.cancelAnimationFrame(frameRequest);
removeErrorListener?.(); removeErrorListener?.();
}; };
+12 -16
View File
@@ -247,19 +247,19 @@ test('a panel keeps drawing from a timer while animation frames stop', () => {
assert.equal(frame, null); assert.equal(frame, null);
}); });
test('a panel reports a render error in full and starts drawing again', () => { test('a panel reports a render error in full and stops drawing', () => {
let listener = null; let listener = null;
const timeouts = []; let tick = null;
const windowRef = { const windowRef = {
requestAnimationFrame: () => 1, requestAnimationFrame: () => 1,
cancelAnimationFrame() {}, cancelAnimationFrame() {},
setInterval: () => 1, setInterval: (fn) => ((tick = fn), 1),
clearInterval() {}, clearInterval() {},
setTimeout: (fn) => (timeouts.push(fn), timeouts.length),
clearTimeout() {},
}; };
const drawn = [];
const viewer = { const viewer = {
useDefaultRenderLoop: false, resize() {},
render: () => drawn.push('render'),
scene: { scene: {
renderError: { renderError: {
addEventListener: (fn) => ((listener = fn), () => (listener = null)), addEventListener: (fn) => ((listener = fn), () => (listener = null)),
@@ -270,17 +270,13 @@ test('a panel reports a render error in full and starts drawing again', () => {
const original = console.error; const original = console.error;
console.error = (...args) => logged.push(args.join(' ')); console.error = (...args) => logged.push(args.join(' '));
try { try {
const stop = keepPanelRendering(viewer, { windowRef, now: () => 0 }); let clock = 0;
for (let attempt = 0; attempt < 5; attempt++) const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
listener(viewer.scene, { listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' });
name: 'RuntimeError',
message: 'worker failed',
});
assert.match(logged[0], /render error: .*"message":"worker failed"/); assert.match(logged[0], /render error: .*"message":"worker failed"/);
// Only a few restarts, so an error that repeats does not spin. clock = 1000;
assert.equal(timeouts.length, 3); tick();
timeouts[0](); assert.deepEqual(drawn, []);
assert.equal(viewer.useDefaultRenderLoop, true);
stop(); stop();
assert.equal(listener, null); assert.equal(listener, null);
} finally { } finally {
+12 -18
View File
@@ -154,7 +154,7 @@ export function panelRuntime(config) {
* A data: URL holding an app file, for elements that load by URL. Hosts * A data: URL holding an app file, for elements that load by URL. Hosts
* allow data: images where some refuse blob: ones. * allow data: images where some refuse blob: ones.
*/ */
function appObjectUrl(path) { function appFileUrl(path) {
if (!fileUrls.has(path)) if (!fileUrls.has(path))
fileUrls.set( fileUrls.set(
path, path,
@@ -293,7 +293,7 @@ export function panelRuntime(config) {
return true; return true;
}; };
// Images name app files by URL; give them the file as a blob: URL. // Images name app files by URL; give them the file as a data: URL.
const image = Object.getOwnPropertyDescriptor( const image = Object.getOwnPropertyDescriptor(
HTMLImageElement.prototype, HTMLImageElement.prototype,
'src', 'src',
@@ -303,7 +303,7 @@ export function panelRuntime(config) {
set(value) { set(value) {
const path = appPath(value); const path = appPath(value);
if (path === null) return image.set.call(this, value); if (path === null) return image.set.call(this, value);
appObjectUrl(path).then( appFileUrl(path).then(
(url) => image.set.call(this, url), (url) => image.set.call(this, url),
() => this.dispatchEvent(new Event('error')), () => this.dispatchEvent(new Event('error')),
); );
@@ -365,7 +365,7 @@ export function panelRuntime(config) {
for (const element of root?.querySelectorAll?.('[data-gev-src]') ?? []) { for (const element of root?.querySelectorAll?.('[data-gev-src]') ?? []) {
const path = appPath(element.getAttribute('data-gev-src')); const path = appPath(element.getAttribute('data-gev-src'));
element.removeAttribute('data-gev-src'); element.removeAttribute('data-gev-src');
appObjectUrl(path).then((url) => element.setAttribute('src', url)); appFileUrl(path).then((url) => element.setAttribute('src', url));
} }
}; };
const innerHtml = Object.getOwnPropertyDescriptor( const innerHtml = Object.getOwnPropertyDescriptor(
@@ -395,15 +395,13 @@ export function panelRuntime(config) {
if (element.nodeName !== 'IMG') continue; if (element.nodeName !== 'IMG') continue;
const path = appPath(element.getAttribute('src')); const path = appPath(element.getAttribute('src'));
if (path !== null) if (path !== null)
appObjectUrl(path).then((url) => appFileUrl(path).then((url) => element.setAttribute('src', url));
element.setAttribute('src', url),
);
} }
} }
}).observe(document.documentElement, { childList: true, subtree: true }); }).observe(document.documentElement, { childList: true, subtree: true });
} }
/** Replace url() references to app files in CSS with blob: URLs. */ /** Replace url() references to app files in CSS with data: URLs. */
async function cssWithFiles(text, baseUrl) { async function cssWithFiles(text, baseUrl) {
const found = new Map(); const found = new Map();
for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)) { for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)) {
@@ -413,7 +411,7 @@ export function panelRuntime(config) {
if (path !== null && !found.has(reference)) if (path !== null && !found.has(reference))
found.set( found.set(
reference, reference,
appObjectUrl(path).catch(() => null), appFileUrl(path).catch(() => null),
); );
} }
let result = text; let result = text;
@@ -447,13 +445,10 @@ export function panelRuntime(config) {
} }
function runScript(text, type) { function runScript(text, type) {
return new Promise((resolve) => {
const script = document.createElement('script'); const script = document.createElement('script');
if (type) script.type = type; if (type) script.type = type;
script.textContent = text; script.textContent = text;
document.body.appendChild(script); document.body.appendChild(script);
resolve();
});
} }
/** /**
@@ -507,7 +502,7 @@ export function panelRuntime(config) {
document.body.insertBefore(document.adoptNode(node), status); document.body.insertBefore(document.adoptNode(node), status);
} }
for (const [image, path] of images) for (const [image, path] of images)
appObjectUrl(path).then((url) => image.setAttribute('src', url)); appFileUrl(path).then((url) => image.setAttribute('src', url));
const scripts = await Promise.all( const scripts = await Promise.all(
[...built.querySelectorAll('script')].map(async (script) => ({ [...built.querySelectorAll('script')].map(async (script) => ({
type: script.type, type: script.type,
@@ -516,9 +511,9 @@ export function panelRuntime(config) {
: script.textContent, : script.textContent,
})), })),
); );
for (const { type, text } of scripts) await runScript(text, type); for (const { type, text } of scripts) runScript(text, type);
// Browsers cap live 3D contexts across a conversation's panels and take // Browsers cap live 3D contexts across pages and take one back when
// one back when there are too many; say so rather than show no map. // there are too many; say so rather than show no map.
document.addEventListener( document.addEventListener(
'webglcontextlost', 'webglcontextlost',
() => { () => {
@@ -594,8 +589,7 @@ export function panelRuntime(config) {
showDisplayMode(message.params); showDisplayMode(message.params);
}); });
// Fullscreen, where the host offers it: the globe gets the room, and some // Fullscreen, where the host offers it, gives the globe the room.
// hosts only keep a panel on screen up to date outside the conversation.
let displayMode = 'inline'; let displayMode = 'inline';
const showDisplayMode = (context) => { const showDisplayMode = (context) => {
if (context?.displayMode) displayMode = context.displayMode; if (context?.displayMode) displayMode = context.displayMode;
+1 -1
View File
@@ -48,7 +48,7 @@ const LOAD_TIMEOUT_MS = 90_000;
*/ */
const PANEL_APP_BASE_URL = 'https://app.gods-eye-view.invalid/'; const PANEL_APP_BASE_URL = 'https://app.gods-eye-view.invalid/';
/** The panel-only tool the panel loads the app through. */ /** The panel-only tool the panel loads the app through. */
const PANEL_REQUEST_TOOL = 'panel_request'; export const PANEL_REQUEST_TOOL = 'panel_request';
/** /**
* The panel page: its status line, the Open in God's Eye View button, and * The panel page: its status line, the Open in God's Eye View button, and
+2 -1
View File
@@ -7,6 +7,7 @@
*/ */
import { defineTool, ToolError } from '../catalog.js'; import { defineTool, ToolError } from '../catalog.js';
import { PANEL_REQUEST_TOOL } from '../globePanel.js';
/** Bytes of response body per call, before base64. */ /** Bytes of response body per call, before base64. */
export const PANEL_PART_BYTES = 512 * 1024; export const PANEL_PART_BYTES = 512 * 1024;
@@ -83,7 +84,7 @@ function checkedPath(path) {
} }
export const panelRequest = defineTool({ export const panelRequest = defineTool({
name: 'panel_request', name: PANEL_REQUEST_TOOL,
title: "God's Eye View panel request", title: "God's Eye View panel request",
description: description:
"Loads a file or data for the God's Eye View panel from the app's " + "Loads a file or data for the God's Eye View panel from the app's " +