diff --git a/changelog.d/1579-render-failure-notice.md b/changelog.d/1579-render-failure-notice.md new file mode 100644 index 000000000..2045ae823 --- /dev/null +++ b/changelog.d/1579-render-failure-notice.md @@ -0,0 +1,6 @@ +- When the globe stops drawing after a render error or a lost WebGL context, + an alert dialog says so instead of leaving a frozen or black globe behind + working controls. Retry restarts drawing (Reload after a lost context), and + Reset view reopens the app without the view restored from the address bar. + Cesium's "Rendering has stopped" panel no longer appears in the app + (German, @gekh, #1579). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9a530406c..80e7b8860 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2443,6 +2443,53 @@ future driver fix restores the effect with no code change; iOS/iPadOS platform detection is the backstop for when no probe context can be created. Applied during viewer construction, before any tile builds a draw command. +## Render failure notice + +When the globe stops drawing, the scene chrome says so instead of leaving a +black globe behind working controls. `installRenderFailureNotice()` +(`src/ui/renderFailure.js`) listens for Cesium's `renderError` and the canvas' +`webglcontextlost`, logs the error, and publishes a notice that +`RenderFailure.vue` shows as an alert dialog with focus on its first action. +The app's viewer uses `showRenderLoopErrors: false`, so Cesium's dead-end +"Rendering has stopped" panel no longer appears; `applicationViewerOptions()` +keeps it by default for other callers. + +- **Retry** restarts the render loop. The notice stays until a frame starts + and ends without an error: Cesium raises `postRender` after a failed frame + too. +- **Reload**, after a lost context, reloads the page; the address keeps the + view. +- **Reset view** opens `appRootUrl()`, without the hash or `/share/` + path; the query, such as `?lang=`, is kept. + +Pitfalls: + +- An error thrown outside Scene's error handling (a frame listener, `onTick`, + a resize) stops the loop without a `renderError`. The notice wraps the + widget's `render()` and `resize()` and raises it as one, once even with + `rethrowRenderErrors`. +- A throwing listener leaves its Cesium event mid-raise, with listener changes + only queued. The wrapper finishes those raises first (`finishRaise()` over + `frameEvents()`), so a layer turned off or on meanwhile behaves. +- A render error marks the loop stopped while its next frame is still queued. + The wrapper keeps the loop marked running, so a Retry or a shown tab + (`src/app/tools.js`) before that frame starts no second loop. +- After a lost context, later render errors are only logged, so the notice + keeps saying why. The listener neither prevents nor restores the loss. +- When the globe draws again, focus goes back where it was, or to the globe's + canvas, which the map's keys read as the map; the canvas is focusable only + until focus leaves or the globe is pressed. Focus the user moved stays put. +- The notice sits above floating panels such as the Québec camera viewer, and + its buttons are 44 px tall on touch screens. + +An inline conversation panel never reloads or navigates its host's page: it +offers Retry after a render error only, and no notice after a lost context, +where the panel runtime's own notice applies (`src/app/globePanelRuntime.js`). +Its timer (`keepPanelRendering()`) draws only while the render loop is on and +logs nothing itself, so each render error is logged once, by the notice. An +error a timer frame throws is caught there: it has already been raised as a +render error. + ## Map Source control ownership Map Source controls own chip listeners, source-state subscriptions and selection diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 5ddbbcf34..42cc132d7 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -557,6 +557,13 @@ "src/ui/frameRateMonitor.js", "src/ui/frameRateMonitor.test.mjs", "src/ui/frameRateView.test.mjs", + "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/ui/renderFailureSecondLoop.test.mjs", + "src/ui/renderLoopFixture.mjs", "src/layers/cctv/lifecycle.js", "src/layers/cctv/controls.js", "src/layers/cctv/geometry.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 918e3ea61..c1dc35c25 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -163,6 +163,7 @@ "src/ui/kit/UiBadge.vue", "src/ui/stores/globeActions.js", "src/ui/views/panels/GlobeActions.vue", + "src/ui/views/panels/RenderFailure.vue", "src/ui/views/panels/GlobeCompass.vue", "src/ui/stores/recording.js", "src/ui/styleParameters.js", @@ -3132,6 +3133,7 @@ "src/ui/displayControls.js", "src/ui/effects.js", "src/ui/frameRateMonitor.js", + "src/ui/renderFailure.js", "src/ui/layerPanel.js", "src/ui/weatherPanel.js", "src/ui/railCards.js", diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs index 4cef4d4fe..b435fdf24 100644 --- a/src/annotations/drawToolBehaviour.test.mjs +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -400,6 +400,27 @@ test('Enter finishes from the canvas and the label field, never from a focused b } }); +test('Enter on the globe canvas finishes, as after the render failure notice hands focus back', async () => { + const h = harness(); + try { + h.act('toggleDraw'); + h.tool.setShape('area'); + h.tool.addVertex(-97.75, 30.26); + h.tool.addVertex(-97.74, 30.26); + h.tool.addVertex(-97.74, 30.27); + + // src/ui/views/panels/RenderFailure.vue focuses the canvas when the globe + // draws again and the control that had focus is gone. + h.documentDouble.emit('keydown', { key: 'Enter', target: h.viewer.scene.canvas }); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(h.annotations.calls.annotate.length, 1, 'Enter on the canvas finishes'); + assert.equal(h.annotations.calls.annotate[0].specs[0].type, 'area'); + } finally { + h.restore(); + } +}); + test('the live preview hangs at the height the click landed on', async () => { const h = harness(); try { diff --git a/src/app/embed.js b/src/app/embed.js index 85c4f0752..32541f85a 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -94,17 +94,6 @@ export function panelAwareVisibility(doc = globalThis.document) { const PANEL_FRAME_MS = 33; const MISSED_FRAMES_MS = 250; -/** A render error's details, including the plain objects workers report. */ -function describeError(error) { - if (error instanceof Error) - return `${error.name}: ${error.message}\n${error.stack}`; - try { - return JSON.stringify(error, Object.getOwnPropertyNames(error ?? {})); - } catch { - return String(error); - } -} - /** * Keep the globe drawing in an inline panel. Some hosts report a panel on * screen as hidden, which stops the browser's animation frames and with them @@ -122,33 +111,27 @@ export function keepPanelRendering( frameRequest = windowRef.requestAnimationFrame(onFrame); }; frameRequest = windowRef.requestAnimationFrame(onFrame); - // Cesium stops drawing after a render error, and so does the timer. - // Report the error in full: workers report plain objects, which Cesium - // prints as [object Object]. - let failed = false; - const removeErrorListener = viewer.scene?.renderError?.addEventListener( - (_scene, error) => { - failed = true; - console.error( - "[God's Eye View panel] render error:", - describeError(error), - ); - }, - ); + // Cesium stops its render loop after a render error, and the timer stops + // with it; Retry restarts both. The render failure notice logs the error + // (src/ui/renderFailure.js). const timer = windowRef.setInterval(() => { if ( - failed || + viewer.useDefaultRenderLoop === false || now() - lastFrame < MISSED_FRAMES_MS || viewer.isDestroyed?.() ) return; - viewer.resize(); - viewer.render(); + try { + viewer.resize(); + viewer.render(); + } catch { + // Raised as a render error by now (src/ui/renderFailure.js), which + // stops this timer; thrown on, it would only reach the host page. + } }, PANEL_FRAME_MS); return () => { windowRef.clearInterval(timer); windowRef.cancelAnimationFrame(frameRequest); - removeErrorListener?.(); }; } diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index bcc5ada9b..e4e8a1d22 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -457,8 +457,7 @@ test('a panel keeps drawing from a timer while animation frames stop', () => { assert.equal(frame, null); }); -test('a panel reports a render error in full and stops drawing', () => { - let listener = null; +test('a panel stops drawing while its render loop is stopped', () => { let tick = null; const windowRef = { requestAnimationFrame: () => 1, @@ -467,31 +466,19 @@ test('a panel reports a render error in full and stops drawing', () => { clearInterval() {}, }; const drawn = []; - const viewer = { - resize() {}, - render: () => drawn.push('render'), - scene: { - renderError: { - addEventListener: (fn) => ((listener = fn), () => (listener = null)), - }, - }, - }; - const logged = []; - const original = console.error; - console.error = (...args) => logged.push(args.join(' ')); - try { - let clock = 0; - const stop = keepPanelRendering(viewer, { windowRef, now: () => clock }); - listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' }); - assert.match(logged[0], /render error: .*"message":"worker failed"/); - clock = 1000; - tick(); - assert.deepEqual(drawn, []); - stop(); - assert.equal(listener, null); - } finally { - console.error = original; - } + const viewer = { resize() {}, render: () => drawn.push('render') }; + let clock = 0; + const stop = keepPanelRendering(viewer, { windowRef, now: () => clock }); + // Cesium stops its render loop after a render error. + viewer.useDefaultRenderLoop = false; + clock = 1000; + tick(); + assert.deepEqual(drawn, []); + // Retry restarts it, and the timer draws again. + viewer.useDefaultRenderLoop = true; + tick(); + assert.deepEqual(drawn, ['render']); + stop(); }); test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => { diff --git a/src/app/renderFailureInlineRetry.test.mjs b/src/app/renderFailureInlineRetry.test.mjs new file mode 100644 index 000000000..fa84e880f --- /dev/null +++ b/src/app/renderFailureInlineRetry.test.mjs @@ -0,0 +1,120 @@ +// Retry in an inline panel whose host stops animation frames, so the panel +// draws from keepPanelRendering's timer. Drives Cesium's real startRenderLoop +// and useDefaultRenderLoop setter (CesiumWidget.prototype). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { keepPanelRendering } from './embed.js'; +import { renderLoopFixture } from '../ui/renderLoopFixture.mjs'; + +function setup() { + let tick = null; + let clock = 0; + // The host reports the panel hidden: its own animation frames never arrive. + const windowRef = { + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + setInterval: (fn) => ((tick = fn), 1), + clearInterval() {}, + }; + const previousError = console.error; + console.error = () => {}; + const state = { failure: null, drawn: 0 }; + const scene = { + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + postRender: new Cesium.Event(), + requestRender() {}, + initializeFrame() {}, + // Scene.render: a failing primitive update raises renderError + // (tryAndCatchError) and returns; a failing listener throws. + render() { + state.drawn += 1; + this.preUpdate.raiseEvent(this); + if (state.failure === 'caught') + this.renderError.raiseEvent(this, new Error('boom')); + if (state.failure === 'thrown') throw new Error('boom'); + this.postRender.raiseEvent(this); + }, + }; + const { frames, widget, viewer, store, runFrames, restore } = + renderLoopFixture(scene, { + viewer: { + isDestroyed: () => false, + render: () => widget.render(), + resize: () => widget.resize(), + }, + notice: { allowReload: false }, + }); + const stop = keepPanelRendering(viewer, { windowRef, now: () => clock }); + viewer.useDefaultRenderLoop = true; + const timerFrame = () => { + clock += 400; + tick(); + }; + return { + frames, + state, + store, + timerFrame, + animationFrames: runFrames, + restore: () => { + stop(); + restore(); + console.error = previousError; + }, + }; +} + +for (const failure of ['caught', 'thrown']) { + test(`an error ${failure} in a frame the timer draws: Retry draws again with one render loop`, () => { + const { frames, state, store, timerFrame, animationFrames, restore } = + setup(); + try { + // The host stops animation frames with the loop's next one queued. + assert.equal(frames.length, 1); + state.failure = failure; + timerFrame(); // an error thrown here does not reach the host page + assert.equal(store.renderFailure?.reason, 'render-error'); + assert.equal(typeof store.renderFailure.retry, 'function'); + state.failure = null; + store.renderFailure.retry(); + const before = state.drawn; + timerFrame(); + assert.ok(state.drawn > before, 'the panel draws again after Retry'); + assert.equal( + store.renderFailure, + null, + 'a clean frame clears the notice', + ); + assert.equal(frames.length, 1, 'Retry starts no second loop'); + // The host resumes frames: the globe draws once a frame. + state.drawn = 0; + animationFrames(3); + assert.equal(state.drawn, 3); + } finally { + restore(); + } + }); +} + +test('an error Scene caught in an animation frame right before the host stopped frames: Retry keeps one render loop', () => { + const { frames, state, store, timerFrame, animationFrames, restore } = + setup(); + try { + state.failure = 'caught'; + animationFrames(1); + assert.equal(store.renderFailure?.reason, 'render-error'); + assert.equal(frames.length, 1); + state.failure = null; + store.renderFailure.retry(); + timerFrame(); + assert.equal(store.renderFailure, null); + assert.equal(frames.length, 1, 'Retry starts no second loop'); + state.drawn = 0; + animationFrames(3); + assert.equal(state.drawn, 3); + } finally { + restore(); + } +}); diff --git a/src/app/scene.js b/src/app/scene.js index b9799b8ac..a1afb0b60 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -15,6 +15,8 @@ import { governorRequestRender, } from '../renderGovernor.js'; import { describeError } from './errors.js'; +import { isEmbeddedInline } from './embed.js'; +import { installRenderFailureNotice } from '../ui/renderFailure.js'; import { worldClock, worldTimeFor } from '../time/worldClock.js'; import { followWorldTime } from '../time/viewerClock.js'; import { onLocaleChange, t } from '../i18n/index.js'; @@ -55,11 +57,17 @@ export async function createApplicationScene({ const viewer = createApplicationViewer({ container: 'cesiumContainer', creditContainer, + // The render failure notice below replaces Cesium's dead-end panel. + showRenderLoopErrors: false, }); defer(() => { uninstallRenderGovernor(viewer); if (!viewer.isDestroyed()) viewer.destroy(); }); + // An inline panel must not reload or navigate its host's page. + defer( + installRenderFailureNotice(viewer, { allowReload: !isEmbeddedInline() }), + ); defer(installTrackpadPinchZoom(viewer)); defer( followWorldTime(viewer, worldTimeFor('sky'), { diff --git a/src/app/viewer.js b/src/app/viewer.js index e4288e8a3..adaa06406 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -375,8 +375,15 @@ export function installGlobeInViewRecovery( }; } -/** Cesium.Viewer options of the standard globe viewer. */ -export function applicationViewerOptions(creditContainer) { +/** + * Cesium.Viewer options of the standard globe viewer. `showRenderLoopErrors` + * false leaves render errors to the caller's own notice instead of Cesium's + * panel. + */ +export function applicationViewerOptions( + creditContainer, + { showRenderLoopErrors = true } = {}, +) { return { timeline: false, animation: false, @@ -396,16 +403,21 @@ export function applicationViewerOptions(creditContainer) { creditContainer, msaaSamples: 4, contextOptions: { webgl: { preserveDrawingBuffer: true } }, + showRenderLoopErrors, }; } /** Create the standard globe viewer in caller-owned, visible containers. */ -export function createApplicationViewer({ container, creditContainer }) { +export function createApplicationViewer({ + container, + creditContainer, + showRenderLoopErrors, +}) { if (!container || !creditContainer) throw new TypeError('Viewer and credit containers are required'); const viewer = new Cesium.Viewer( container, - applicationViewerOptions(creditContainer), + applicationViewerOptions(creditContainer, { showRenderLoopErrors }), ); try { viewer.targetFrameRate = 60; diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 46a29dc2f..8adc01b0e 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3953,5 +3953,12 @@ "Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات", "Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات", "Showing {shown} of {total}": "عرض {shown} من {total}", - "BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات" + "BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات", + "The globe stopped drawing": "توقفت الكرة الأرضية عن الرسم", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "استعاد المتصفح رسومات الكرة الأرضية ثلاثية الأبعاد. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لن تُرسم حتى تُعاد تحميل الصفحة.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "توقف العرض بعد خطأ. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لا يتم تحديثها.", + "Reset view reloads the app without the view restored from the address bar.": "تعيد «إعادة ضبط العرض» تحميل التطبيق دون العرض المستعاد من شريط العنوان.", + "Reload": "إعادة التحميل", + "Retry": "إعادة المحاولة", + "Reset view": "إعادة ضبط العرض" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index ca668a74e..4753051cc 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3605,5 +3605,12 @@ "Confirm: remove every added entry from Data Layers": "Bestätigen: alle hinzugefügten Einträge aus Datenebenen entfernen", "Remove every added entry from Data Layers": "Alle hinzugefügten Einträge aus Datenebenen entfernen", "Showing {shown} of {total}": "{shown} von {total} angezeigt", - "BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN" + "BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN", + "The globe stopped drawing": "Der Globus zeichnet nicht mehr", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Der Browser hat dem Globus die 3D-Grafik entzogen. Ebenen und Bedienelemente funktionieren weiter, aber die Karte kann erst nach dem Neuladen der Seite wieder zeichnen.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Das Rendern wurde nach einem Fehler angehalten. Ebenen und Bedienelemente funktionieren weiter, aber die Karte wird nicht aktualisiert.", + "Reset view reloads the app without the view restored from the address bar.": "Ansicht zurücksetzen lädt die App ohne die aus der Adressleiste wiederhergestellte Ansicht neu.", + "Reload": "Neu laden", + "Retry": "Erneut versuchen", + "Reset view": "Ansicht zurücksetzen" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 2a374d7ec..1c3fd8247 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3692,5 +3692,12 @@ "500–1,000 m": "500–1000 m", "1,000–2,000 m": "1000–2000 m", "2,000–3,000 m": "2000–3000 m", - "3,000 m and above": "3000 m o más" + "3,000 m and above": "3000 m o más", + "The globe stopped drawing": "El globo dejó de dibujarse", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "El navegador retiró los gráficos 3D del globo. Las capas y los controles siguen funcionando, pero el mapa no se dibujará hasta que se recargue la página.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "El renderizado se detuvo tras un error. Las capas y los controles siguen funcionando, pero el mapa no se actualiza.", + "Reset view reloads the app without the view restored from the address bar.": "Restablecer la vista recarga la app sin la vista restaurada desde la barra de direcciones.", + "Reload": "Recargar", + "Retry": "Reintentar", + "Reset view": "Restablecer la vista" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 98633538b..23fd088a3 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3692,5 +3692,12 @@ "Confirm: remove every added entry from Data Layers": "Confirmer : retirer du panneau Couches toutes les entrées ajoutées", "Remove every added entry from Data Layers": "Retirer du panneau Couches toutes les entrées ajoutées", "Showing {shown} of {total}": "Affichées : {shown} sur {total}", - "BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES" + "BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES", + "The globe stopped drawing": "Le globe ne s’affiche plus", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Le navigateur a retiré au globe ses graphismes 3D. Les calques et les commandes fonctionnent toujours, mais la carte ne pourra s’afficher qu’après le rechargement de la page.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Le rendu s’est arrêté après une erreur. Les calques et les commandes fonctionnent toujours, mais la carte ne se met plus à jour.", + "Reset view reloads the app without the view restored from the address bar.": "Réinitialiser la vue recharge l’app sans la vue restaurée depuis la barre d’adresse.", + "Reload": "Recharger", + "Retry": "Réessayer", + "Reset view": "Réinitialiser la vue" } diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index 335515fb4..2140be34e 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -3692,5 +3692,12 @@ "Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים", "Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים", "Showing {shown} of {total}": "מוצגים {shown} מתוך {total}", - "BROWSE DATA LIBRARY": "עיון בספריית הנתונים" + "BROWSE DATA LIBRARY": "עיון בספריית הנתונים", + "The globe stopped drawing": "הגלובוס הפסיק להצטייר", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "הדפדפן לקח בחזרה את הגרפיקה התלת-ממדית של הגלובוס. השכבות והפקדים עדיין פועלים, אבל המפה לא תוכל להצטייר עד שהעמוד ייטען מחדש.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "הרינדור נעצר בעקבות שגיאה. השכבות והפקדים עדיין פועלים, אבל המפה אינה מתעדכנת.", + "Reset view reloads the app without the view restored from the address bar.": "\"איפוס תצוגה\" טוען מחדש את האפליקציה בלי התצוגה ששוחזרה משורת הכתובת.", + "Reload": "טען מחדש", + "Retry": "נסה שוב", + "Reset view": "איפוס תצוגה" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index c6127b3fb..6f3fef7dc 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3692,5 +3692,12 @@ "Confirm: remove every added entry from Data Layers": "Conferma: rimuovi da Livelli dati tutte le voci aggiunte", "Remove every added entry from Data Layers": "Rimuovi da Livelli dati tutte le voci aggiunte", "Showing {shown} of {total}": "Mostrati: {shown} su {total}", - "BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI" + "BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI", + "The globe stopped drawing": "Il globo ha smesso di disegnare", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Il browser ha ritirato la grafica 3D del globo. Livelli e controlli funzionano ancora, ma la mappa non può essere disegnata finché la pagina non viene ricaricata.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Il rendering si è fermato dopo un errore. Livelli e controlli funzionano ancora, ma la mappa non si aggiorna.", + "Reset view reloads the app without the view restored from the address bar.": "Ripristina vista ricarica l’app senza la vista ripristinata dalla barra degli indirizzi.", + "Reload": "Ricarica", + "Retry": "Riprova", + "Reset view": "Ripristina vista" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 62c517f21..6aeb580ac 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3518,5 +3518,12 @@ "Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除", "Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除", "Showing {shown} of {total}": "{total}件中{shown}件を表示", - "BROWSE DATA LIBRARY": "データライブラリを見る" + "BROWSE DATA LIBRARY": "データライブラリを見る", + "The globe stopped drawing": "地球儀の描画が止まりました", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "ブラウザーが地球儀の 3D グラフィックスを解放しました。レイヤーと操作は引き続き使えますが、ページを再読み込みするまで地図は描画されません。", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "エラーのため描画が停止しました。レイヤーと操作は引き続き使えますが、地図は更新されません。", + "Reset view reloads the app without the view restored from the address bar.": "「ビューをリセット」は、アドレスバーから復元したビューを使わずにアプリを再読み込みします。", + "Reload": "再読み込み", + "Retry": "再試行", + "Reset view": "ビューをリセット" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 5d3cfb253..3cfde1eea 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3692,5 +3692,12 @@ "Confirm: remove every added entry from Data Layers": "Confirmar: remover de \"Camadas de dados\" todas as entradas adicionadas", "Remove every added entry from Data Layers": "Remover de \"Camadas de dados\" todas as entradas adicionadas", "Showing {shown} of {total}": "Mostrando {shown} de {total}", - "BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS" + "BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS", + "The globe stopped drawing": "O globo parou de desenhar", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "O navegador retirou os gráficos 3D do globo. As camadas e os controles continuam funcionando, mas o mapa só volta a ser desenhado depois de recarregar a página.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "A renderização parou após um erro. As camadas e os controles continuam funcionando, mas o mapa não está sendo atualizado.", + "Reset view reloads the app without the view restored from the address bar.": "Redefinir vista recarrega o app sem a vista restaurada da barra de endereços.", + "Reload": "Recarregar", + "Retry": "Tentar novamente", + "Reset view": "Redefinir vista" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index ef8b144e5..b7f2b42d7 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3779,5 +3779,12 @@ "500–1,000 m": "500–1000 м", "1,000–2,000 m": "1000–2000 м", "2,000–3,000 m": "2000–3000 м", - "3,000 m and above": "3000 м и выше" + "3,000 m and above": "3000 м и выше", + "The globe stopped drawing": "Глобус перестал отрисовываться", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер отключил 3D-графику глобуса. Слои и элементы управления работают, но карта не отрисуется, пока страница не будет перезагружена.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Отрисовка остановилась из-за ошибки. Слои и элементы управления работают, но карта не обновляется.", + "Reset view reloads the app without the view restored from the address bar.": "«Сбросить вид» перезагружает приложение без вида, восстановленного из адресной строки.", + "Reload": "Перезагрузить", + "Retry": "Повторить", + "Reset view": "Сбросить вид" } diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 87c66ef3f..acfd2916d 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -3605,5 +3605,12 @@ "Confirm: remove every added entry from Data Layers": "Bekräfta: ta bort alla tillagda poster från Datalager", "Remove every added entry from Data Layers": "Ta bort alla tillagda poster från Datalager", "Showing {shown} of {total}": "Visar {shown} av {total}", - "BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET" + "BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET", + "The globe stopped drawing": "Globen ritas inte längre", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Webbläsaren tog tillbaka globens 3D-grafik. Lager och reglage fungerar fortfarande, men kartan kan inte ritas förrän sidan laddas om.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Renderingen stoppades efter ett fel. Lager och reglage fungerar fortfarande, men kartan uppdateras inte.", + "Reset view reloads the app without the view restored from the address bar.": "”Återställ vy” laddar om appen utan vyn som återställdes från adressfältet.", + "Reload": "Ladda om", + "Retry": "Försök igen", + "Reset view": "Återställ vy" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 4b079ac0a..81fb172f5 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3779,5 +3779,12 @@ "Confirm: remove every added entry from Data Layers": "Підтвердити: видалити всі додані записи з «Шарів даних»", "Remove every added entry from Data Layers": "Видалити всі додані записи з «Шарів даних»", "Showing {shown} of {total}": "Показано {shown} з {total}", - "BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ" + "BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ", + "The globe stopped drawing": "Глобус перестав відмальовуватися", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер вимкнув 3D-графіку глобуса. Шари та елементи керування працюють, але карта не відмалюється, доки сторінку не буде перезавантажено.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Відмальовування зупинилося через помилку. Шари та елементи керування працюють, але карта не оновлюється.", + "Reset view reloads the app without the view restored from the address bar.": "«Скинути вигляд» перезавантажує застосунок без вигляду, відновленого з адресного рядка.", + "Reload": "Перезавантажити", + "Retry": "Повторити", + "Reset view": "Скинути вигляд" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 8311259cd..715c25ace 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3518,5 +3518,12 @@ "Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目", "Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目", "Showing {shown} of {total}": "显示 {shown} / {total}", - "BROWSE DATA LIBRARY": "浏览数据资源库" + "BROWSE DATA LIBRARY": "浏览数据资源库", + "The globe stopped drawing": "地球已停止绘制", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "浏览器收回了地球的 3D 图形。图层和控件仍可使用,但在重新加载页面之前地图无法绘制。", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因错误而停止。图层和控件仍可使用,但地图不再更新。", + "Reset view reloads the app without the view restored from the address bar.": "“重置视图”会重新加载应用,不使用从地址栏恢复的视图。", + "Reload": "重新加载", + "Retry": "重试", + "Reset view": "重置视图" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index fd201af36..bd2fa477f 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3518,5 +3518,12 @@ "Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已添加條目", "Remove every added entry from Data Layers": "從數據圖層中移除所有已添加條目", "Showing {shown} of {total}": "顯示 {shown} / {total}", - "BROWSE DATA LIBRARY": "瀏覽數據資源庫" + "BROWSE DATA LIBRARY": "瀏覽數據資源庫", + "The globe stopped drawing": "地球已停止繪製", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "瀏覽器收回了地球的 3D 圖形。圖層和控件仍可使用,但在重新加載頁面之前地圖無法繪製。", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因錯誤而停止。圖層和控件仍可使用,但地圖不再更新。", + "Reset view reloads the app without the view restored from the address bar.": "「重置視圖」會重新加載應用,不使用從地址欄恢復的視圖。", + "Reload": "重新加載", + "Retry": "重試", + "Reset view": "重置視圖" } diff --git a/src/ui/renderFailure.js b/src/ui/renderFailure.js new file mode 100644 index 000000000..b593f57f4 --- /dev/null +++ b/src/ui/renderFailure.js @@ -0,0 +1,216 @@ +import { useStatusStore } from './stores/status.js'; +import { applicationPinia } from './vueRuntime.js'; +import { describeError } from '../app/errors.js'; +import { appRootUrl } from '../share/shareLinkPath.js'; + +/** + * Show a recoverable notice when the globe stops drawing, instead of a black + * globe behind working controls. Retry restarts the render loop, or reloads + * after a lost WebGL context; Reset opens the app's root address. With + * `allowReload: false`, Reset is dropped and a lost context shows no notice: + * only a reload draws again, and the page says so itself. + * Returns a function that removes the notice. + */ +export function installRenderFailureNotice( + viewer, + { + store = useStatusStore(applicationPinia()), + locationRef = globalThis.location, + allowReload = true, + logError = (error) => + console.error('[Render] The globe stopped drawing:', error), + } = {}, +) { + const { scene } = viewer; + const { canvas } = viewer; + let notice = null; + let contextLost = false; + // With rethrowRenderErrors, Scene throws the error it has just raised; + // the render() wrapper must not raise it again. + let raisedByScene = null; + let frameFailed = false; + let removeFrameListeners = null; + + function retry() { + if (contextLost) { + locationRef.reload(); + return; + } + viewer.useDefaultRenderLoop = true; + scene.requestRender(); + } + + function reset() { + locationRef.assign(appRootUrl(locationRef.href)); + } + + function stopWatchingFrames() { + removeFrameListeners?.(); + removeFrameListeners = null; + } + + function clear() { + stopWatchingFrames(); + if (store.renderFailure === notice) store.publishRenderFailure(null); + notice = null; + } + + // Cesium raises postRender after a failed frame too, so only a frame that + // starts and ends without a render error clears the notice. + function watchFrames() { + if (removeFrameListeners || contextLost) return; + const removeStart = scene.preUpdate.addEventListener(() => { + frameFailed = false; + raisedByScene = null; + }); + const removeEnd = scene.postRender.addEventListener(() => { + if (!frameFailed) clear(); + }); + removeFrameListeners = () => { + removeStart(); + removeEnd(); + }; + } + + function present(detail) { + const reason = contextLost ? 'context-lost' : 'render-error'; + // A loop restarted into the same error keeps the notice as it was. + if (notice?.reason === reason && notice.detail === detail) return; + notice = { + reason, + detail, + retry, + reset: allowReload ? reset : null, + }; + store.publishRenderFailure(notice); + watchFrames(); + } + + const onRenderError = (_scene, error) => { + raisedByScene = { error }; + frameFailed = true; + logError(error); + // A lost context fails every frame after it; the notice keeps saying why. + if (contextLost) return; + present(error ? describeError(error) : ''); + }; + const onContextLost = () => { + contextLost = true; + // No frame can clear this one; only a reload draws again. + stopWatchingFrames(); + // A page that must not reload has nothing to offer: an inline panel + // shows its own lost-context notice (src/app/globePanelRuntime.js). + if (!allowReload) { + clear(); + return; + } + present(''); + }; + + const removeRenderError = scene.renderError.addEventListener(onRenderError); + // An error thrown outside Scene's error handling (a frame listener, onTick, + // resize) stops the loop without a renderError. Raise one, so Cesium, an + // inline panel's timer and this notice all see it. First finish the event + // raises it cut short, so listeners added or removed meanwhile apply. + const unguard = ['resize', 'render'].map((name) => + reportThrown(viewer.cesiumWidget, name, (error) => { + frameEvents(viewer).forEach(finishRaise); + if (!raisedByScene || raisedByScene.error !== error) + scene.renderError.raiseEvent(scene, error); + raisedByScene = null; + }), + ); + canvas.addEventListener('webglcontextlost', onContextLost); + return () => { + removeRenderError(); + unguard.forEach((restore) => restore()); + canvas.removeEventListener('webglcontextlost', onContextLost); + clear(); + }; +} + +/** + * Events Cesium raises in a frame outside Scene's error handling, which a + * listener's error can leave mid-raise. Only top-level tilesets are reached. + */ +function frameEvents(viewer) { + const { scene } = viewer; + const events = [ + scene.preUpdate, + scene.postUpdate, + scene.preRender, + scene.postRender, + scene.camera?.changed, + scene.camera?.moveStart, + scene.camera?.moveEnd, + viewer.clock?.onTick, + viewer.clock?.onStop, + scene.globe?.tileLoadProgressEvent, + ]; + const primitives = scene.primitives; + for (let i = 0; i < (primitives?.length ?? 0); i += 1) { + const tileset = primitives.get(i); + events.push( + tileset?.loadProgress, + tileset?.allTilesLoaded, + tileset?.initialTilesLoaded, + ); + } + return events; +} + +/** + * Finish a raise a throwing listener cut short. Until a raise ends, Cesium's + * `Event` only queues added and removed listeners; apply that queue. + * These are Cesium's private fields (`_invokingListeners`, `_toAdd`, + * `_toRemove`, `_listeners`, and the widget's `_renderLoopRunning`), checked + * against cesium 1.138 / @cesium/engine 22.3.0; the render loop fixture tests + * drive the real classes and fail when an upgrade renames them. + */ +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(); +} + +/** + * Report an error the widget's `name` method throws, and rethrow it. + * + * A render error marks the loop stopped, but a call made while it ran leaves + * its next frame queued, and that frame stops it. Keep the loop marked + * running, so a Retry or a shown tab before that frame starts no second loop. + * After a throw, the loop's own catch still marks it stopped. + */ +function reportThrown(widget, name, report) { + const original = widget?.[name]; + if (typeof original !== 'function') return () => {}; + const own = Object.hasOwn(widget, name); + const guarded = function (...args) { + const looping = widget._renderLoopRunning; + try { + return original.apply(this, args); + } catch (error) { + report(error); + throw error; + } finally { + if (looping) widget._renderLoopRunning = true; + } + }; + widget[name] = guarded; + return () => { + if (widget[name] !== guarded) return; + if (own) widget[name] = original; + else delete widget[name]; + }; +} diff --git a/src/ui/renderFailure.test.mjs b/src/ui/renderFailure.test.mjs new file mode 100644 index 000000000..913348740 --- /dev/null +++ b/src/ui/renderFailure.test.mjs @@ -0,0 +1,177 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { installRenderFailureNotice } from './renderFailure.js'; + +function fixture(t, options = {}) { + const canvasListeners = new Map(); + const scene = { + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + postRender: new Cesium.Event(), + requestRender() { + this.requests = (this.requests ?? 0) + 1; + }, + }; + const viewer = { + scene, + useDefaultRenderLoop: true, + canvas: { + addEventListener: (name, listener) => canvasListeners.set(name, listener), + removeEventListener: (name, listener) => { + if (canvasListeners.get(name) === listener) + canvasListeners.delete(name); + }, + }, + }; + const store = { + renderFailure: null, + published: [], + publishRenderFailure(next) { + this.renderFailure = next; + this.published.push(next); + }, + }; + const visits = []; + const locationRef = { + href: 'https://gev.example/share/AQc?lang=ru#lat=48.8584&lon=2.2945', + reload: () => visits.push('reload'), + assign: (url) => visits.push(String(url)), + }; + const logged = []; + const uninstall = installRenderFailureNotice(viewer, { + store, + locationRef, + logError: (error) => logged.push(error), + ...options, + }); + t.after(uninstall); + // One Cesium frame: preUpdate, an optional render error, then postRender, + // which Cesium raises after a failed frame too. + const frame = (error) => { + scene.preUpdate.raiseEvent(scene); + if (error) { + viewer.useDefaultRenderLoop = false; + scene.renderError.raiseEvent(scene, error); + } + scene.postRender.raiseEvent(scene); + }; + return { + viewer, + scene, + store, + visits, + logged, + uninstall, + frame, + loseContext: () => canvasListeners.get('webglcontextlost')?.({}), + canvasListeners, + }; +} + +test('a render error shows a notice that survives the failed frame', (t) => { + const f = fixture(t); + assert.equal(f.store.renderFailure, null); + const error = new Error('Expected width to be greater than 0'); + f.frame(error); + const notice = f.store.renderFailure; + assert.equal(notice.reason, 'render-error'); + assert.equal(notice.detail, 'Expected width to be greater than 0'); + assert.equal(typeof notice.retry, 'function'); + assert.equal(typeof notice.reset, 'function'); + assert.deepEqual(f.logged, [error]); +}); + +test('Retry restarts the render loop and a clean frame clears the notice', (t) => { + const f = fixture(t); + f.frame(new Error('boom')); + f.store.renderFailure.retry(); + assert.equal(f.viewer.useDefaultRenderLoop, true); + assert.equal(f.scene.requests, 1); + assert.ok(f.store.renderFailure, 'the notice waits for a frame'); + f.frame(); + assert.equal(f.store.renderFailure, null); + // Clearing stops the per-frame listeners. + assert.equal(f.scene.preUpdate.numberOfListeners, 0); + assert.equal(f.scene.postRender.numberOfListeners, 0); +}); + +test('a loop restarted into the same error keeps one notice', (t) => { + const f = fixture(t); + f.frame(new Error('boom')); + const first = f.store.renderFailure; + f.store.renderFailure.retry(); + f.frame(new Error('boom')); + assert.equal(f.store.renderFailure, first); + assert.equal(f.store.published.length, 1); + f.frame(new Error('other')); + assert.equal(f.store.renderFailure.detail, 'other'); + assert.ok(!f.store.published.includes(null), 'no new appearance'); +}); + +test('a new failure after recovery is a new appearance', (t) => { + const f = fixture(t); + f.frame(new Error('boom')); + f.store.renderFailure.retry(); + f.frame(); + f.frame(new Error('boom')); + assert.deepEqual( + f.store.published.map((notice) => notice?.reason ?? null), + ['render-error', null, 'render-error'], + ); +}); + +test('a render error without a value shows no detail', (t) => { + const f = fixture(t); + f.viewer.useDefaultRenderLoop = false; + f.scene.renderError.raiseEvent(f.scene, undefined); + assert.equal(f.store.renderFailure.reason, 'render-error'); + assert.equal(f.store.renderFailure.detail, ''); +}); + +test('Reset opens the app root without the restored view', (t) => { + const f = fixture(t); + f.frame(new Error('boom')); + f.store.renderFailure.reset(); + assert.deepEqual(f.visits, ['https://gev.example/?lang=ru']); +}); + +test('a lost context offers a reload and no frame clears it', (t) => { + const f = fixture(t); + f.loseContext(); + const notice = f.store.renderFailure; + assert.equal(notice.reason, 'context-lost'); + assert.equal(notice.detail, ''); + // Cesium's next frame then fails on the dead drawing buffer. + f.frame(new Error('Expected width to be greater than 0')); + assert.equal(f.store.renderFailure, notice, 'the notice keeps saying why'); + f.frame(); + assert.ok(f.store.renderFailure, 'a lost context never draws again'); + f.store.renderFailure.retry(); + assert.deepEqual(f.visits, ['reload']); +}); + +test('a page that must not reload offers neither reload nor reset', (t) => { + const f = fixture(t, { allowReload: false }); + f.frame(new Error('boom')); + assert.equal(typeof f.store.renderFailure.retry, 'function'); + assert.equal(f.store.renderFailure.reset, null); + // Only a reload draws again, which this page must not offer: the notice + // goes, and the page's own lost-context notice speaks instead. + f.loseContext(); + assert.equal(f.store.renderFailure, null); + f.frame(new Error('Expected width to be greater than 0')); + assert.equal(f.store.renderFailure, null); +}); + +test('uninstall removes the listeners and the notice', (t) => { + const f = fixture(t); + f.frame(new Error('boom')); + f.uninstall(); + assert.equal(f.store.renderFailure, null); + assert.equal(f.scene.renderError.numberOfListeners, 0); + assert.equal(f.scene.postRender.numberOfListeners, 0); + assert.equal(f.canvasListeners.size, 0); + f.frame(new Error('later')); + assert.equal(f.store.renderFailure, null); +}); diff --git a/src/ui/renderFailureEventRetry.test.mjs b/src/ui/renderFailureEventRetry.test.mjs new file mode 100644 index 000000000..a41697493 --- /dev/null +++ b/src/ui/renderFailureEventRetry.test.mjs @@ -0,0 +1,207 @@ +// Retry after a scene event listener throws. Cesium's Event.raiseEvent has no +// try/finally, so the event stays mid-raise and only queues listener changes. +// Drives Cesium's real Event and startRenderLoop. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { installRenderFailureNotice } from './renderFailure.js'; +import { renderLoopFixture } from './renderLoopFixture.mjs'; + +function setup() { + // 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); + }, + }; + return { scene, ...renderLoopFixture(scene) }; +} + +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(); + } +}); + +const listenerCount = (event) => + [...event._listeners.values()].reduce((sum, scopes) => sum + scopes.size, 0); + +// While the loop is stopped, a layer turned off and on again, or a ground +// task in worldAnnotationRenderer that starts, times out and starts again. +const RESUBSCRIBE = { + 'turned off and on': (event, listener) => { + event.removeEventListener(listener); + event.addEventListener(listener); + }, + 'added, removed and added again': (event, listener) => { + event.addEventListener(listener); + event.removeEventListener(listener); + event.addEventListener(listener); + }, +}; + +for (const [name, resubscribe] of Object.entries(RESUBSCRIBE)) { + test(`a listener ${name} while the loop is stopped runs after Retry`, () => { + const { scene, viewer, store, runFrames, restore } = setup(); + try { + let broken = true; + scene.postRender.addEventListener(() => { + if (broken) throw new TypeError('layer listener failed'); + }); + let calls = 0; + const listener = () => { + calls += 1; + }; + if (name === 'turned off and on') + scene.postRender.addEventListener(listener); + scene.requestRender(); + viewer.useDefaultRenderLoop = true; + runFrames(1); + assert.equal(store.renderFailure?.reason, 'render-error'); + resubscribe(scene.postRender, listener); + broken = false; + store.renderFailure.retry(); + calls = 0; + scene.requestRender(); + runFrames(3); + assert.ok(calls > 0, 'the re-added listener runs after Retry'); + assert.equal( + scene.postRender.numberOfListeners, + listenerCount(scene.postRender), + ); + } finally { + restore(); + } + }); +} + +// Every event a listener's error can cut short is finished when the error +// surfaces, so a listener added at once is subscribed. +const FRAME_EVENTS = { + 'scene.preUpdate': (v) => v.scene.preUpdate, + 'scene.postUpdate': (v) => v.scene.postUpdate, + 'scene.preRender': (v) => v.scene.preRender, + 'scene.postRender': (v) => v.scene.postRender, + 'camera.changed': (v) => v.scene.camera.changed, + 'camera.moveStart': (v) => v.scene.camera.moveStart, + 'camera.moveEnd': (v) => v.scene.camera.moveEnd, + 'clock.onTick': (v) => v.clock.onTick, + 'clock.onStop': (v) => v.clock.onStop, + 'globe.tileLoadProgressEvent': (v) => v.scene.globe.tileLoadProgressEvent, + 'tileset.loadProgress': (v) => v.scene.primitives.get(0).loadProgress, + 'tileset.allTilesLoaded': (v) => v.scene.primitives.get(0).allTilesLoaded, + 'tileset.initialTilesLoaded': (v) => + v.scene.primitives.get(0).initialTilesLoaded, +}; + +for (const [name, pick] of Object.entries(FRAME_EVENTS)) { + test(`a listener that throws in ${name} leaves it finished`, () => { + const events = () => ({ + changed: new Cesium.Event(), + moveStart: new Cesium.Event(), + moveEnd: new Cesium.Event(), + }); + const tileset = { + loadProgress: new Cesium.Event(), + allTilesLoaded: new Cesium.Event(), + initialTilesLoaded: new Cesium.Event(), + }; + const viewer = { + scene: { + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + postUpdate: new Cesium.Event(), + preRender: new Cesium.Event(), + postRender: new Cesium.Event(), + camera: events(), + globe: { tileLoadProgressEvent: new Cesium.Event() }, + primitives: { length: 1, get: () => tileset }, + }, + clock: { onTick: new Cesium.Event(), onStop: new Cesium.Event() }, + canvas: { addEventListener() {}, removeEventListener() {} }, + cesiumWidget: { + _renderLoopRunning: false, + render: () => pick(viewer).raiseEvent(), + }, + }; + const store = { publishRenderFailure() {} }; + const uninstall = installRenderFailureNotice(viewer, { + store, + locationRef: { href: 'https://gev.example/', reload() {}, assign() {} }, + logError() {}, + }); + try { + const event = pick(viewer); + event.addEventListener(() => { + throw new TypeError('listener failed'); + }); + assert.throws(() => viewer.cesiumWidget.render(), TypeError); + assert.equal(event._invokingListeners, false); + const listener = () => {}; + event.addEventListener(listener); + assert.ok(event._listeners.has(listener)); + } finally { + uninstall(); + } + }); +} diff --git a/src/ui/renderFailureLoopErrors.test.mjs b/src/ui/renderFailureLoopErrors.test.mjs new file mode 100644 index 000000000..1446c149f --- /dev/null +++ b/src/ui/renderFailureLoopErrors.test.mjs @@ -0,0 +1,46 @@ +// Errors thrown in the render loop outside Scene's tryAndCatchError. Drives +// Cesium's real startRenderLoop through the useDefaultRenderLoop setter. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { renderLoopFixture } from './renderLoopFixture.mjs'; + +function runLoop(render) { + const scene = { + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + postRender: new Cesium.Event(), + requestRender() {}, + initializeFrame() {}, + render, + }; + const fixture = renderLoopFixture(scene); + fixture.viewer.useDefaultRenderLoop = true; + fixture.runFrames(3); + return { ...fixture, uninstall: fixture.restore }; +} + +test('an error thrown outside tryAndCatchError still shows the notice', () => { + // Scene.render raises preRender outside tryAndCatchError (Scene.js:4439), + // so a listener's exception propagates. + const { widget, store, logged, uninstall } = runLoop(function () { + this.preUpdate.raiseEvent(this); + throw new TypeError("Cannot read properties of undefined (reading 'x')"); + }); + assert.equal(widget.useDefaultRenderLoop, false); + assert.equal(store.renderFailure?.reason, 'render-error'); + assert.equal(logged.length, 1); + uninstall(); +}); + +test('an error Scene raised and threw on with rethrowRenderErrors is reported once', () => { + const { store, logged, uninstall } = runLoop(function () { + this.preUpdate.raiseEvent(this); + const error = new Error('boom'); + this.renderError.raiseEvent(this, error); + throw error; + }); + assert.equal(store.renderFailure?.detail, 'boom'); + assert.equal(logged.length, 1); + uninstall(); +}); diff --git a/src/ui/renderFailureSecondLoop.test.mjs b/src/ui/renderFailureSecondLoop.test.mjs new file mode 100644 index 000000000..2b5c6196f --- /dev/null +++ b/src/ui/renderFailureSecondLoop.test.mjs @@ -0,0 +1,95 @@ +// Retry after an error Scene catches itself: the loop is marked stopped but +// its next frame is still queued, so an early Retry must not start a second +// loop. Drives Cesium's real startRenderLoop and useDefaultRenderLoop setter. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { renderLoopFixture } from './renderLoopFixture.mjs'; + +function setup({ targetFrameRate } = {}) { + const scene = { + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + preRender: new Cesium.Event(), + postRender: new Cesium.Event(), + broken: false, + drawn: 0, + requestRender() {}, + initializeFrame() {}, + // Scene.render (Scene.js:4369): an update error is caught by + // tryAndCatchError, which raises renderError and returns normally. + render(time) { + this.drawn += 1; + this.preUpdate.raiseEvent(this, time); + if (this.broken) this.renderError.raiseEvent(this, new Error('boom')); + this.preRender.raiseEvent(this, time); + this.postRender.raiseEvent(this, time); + }, + }; + return { scene, ...renderLoopFixture(scene, { targetFrameRate }) }; +} + +for (const targetFrameRate of [undefined, 60]) { + test(`targetFrameRate ${targetFrameRate}: Retry before the next frame after an error Scene caught keeps one render loop`, () => { + const { frames, scene, viewer, store, runFrames, restore } = setup({ + targetFrameRate, + }); + try { + viewer.useDefaultRenderLoop = true; + runFrames(2); + scene.broken = true; + runFrames(1); + assert.equal(store.renderFailure?.reason, 'render-error'); + // The cause is gone; Retry runs (a queued Enter on the focused Retry) + // before the loop's next animation frame. + scene.broken = false; + store.renderFailure.retry(); + assert.equal(frames.length, 1, 'one animation frame queued after Retry'); + scene.drawn = 0; + runFrames(3); + assert.equal(store.renderFailure, null); + assert.equal(scene.drawn, 3, 'the globe draws once a frame'); + } finally { + restore(); + } + }); +} + +test('control: Retry after the next frame ran keeps one render loop', () => { + const { frames, scene, viewer, store, runFrames, restore } = setup(); + try { + viewer.useDefaultRenderLoop = true; + runFrames(2); + scene.broken = true; + runFrames(1); + scene.broken = false; + runFrames(1); + assert.equal(frames.length, 0, 'the stopped loop queued nothing'); + store.renderFailure.retry(); + scene.drawn = 0; + runFrames(3); + assert.equal(scene.drawn, 3); + } finally { + restore(); + } +}); + +test('a tab hidden and shown again before the next frame after an error Scene caught keeps one render loop', () => { + const { scene, viewer, runFrames, restore } = setup(); + try { + viewer.useDefaultRenderLoop = true; + runFrames(2); + scene.broken = true; + runFrames(1); + scene.broken = false; + // src/app/tools.js turns the loop off while the tab is hidden and back on + // when it is shown, before the queued frame runs. + viewer.useDefaultRenderLoop = false; + viewer.useDefaultRenderLoop = true; + scene.drawn = 0; + runFrames(3); + assert.equal(scene.drawn, 3, 'the globe draws once a frame'); + } finally { + restore(); + } +}); diff --git a/src/ui/renderFailureView.test.mjs b/src/ui/renderFailureView.test.mjs new file mode 100644 index 000000000..89213bffa --- /dev/null +++ b/src/ui/renderFailureView.test.mjs @@ -0,0 +1,173 @@ +// The scene chrome renders the render failure notice and its actions. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, nextTick } from 'vue'; +import { createPinia } from 'pinia'; +import { useStatusStore } from './stores/status.js'; +import SceneChromeView from './views/SceneChromeView.vue'; + +function mount(t) { + document.body.replaceChildren(); + const pinia = createPinia(); + const app = createApp(SceneChromeView).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => app.unmount()); + return useStatusStore(pinia); +} + +const notice = () => document.getElementById('render-failure'); +const buttons = () => + [...(notice()?.querySelectorAll('button') ?? [])].map((button) => + button.textContent.trim(), + ); + +test('a render failure shows an alert dialog that moves focus to Retry', async (t) => { + const store = mount(t); + assert.equal(notice(), null, 'no notice while the globe draws'); + const calls = []; + store.publishRenderFailure({ + reason: 'render-error', + detail: 'Expected width to be greater than 0', + retry: () => calls.push('retry'), + reset: () => calls.push('reset'), + }); + await nextTick(); + await nextTick(); + const element = notice(); + assert.equal(element.getAttribute('role'), 'alertdialog'); + assert.equal( + document.getElementById(element.getAttribute('aria-labelledby')) + .textContent, + 'The globe stopped drawing', + ); + assert.match( + document.getElementById(element.getAttribute('aria-describedby')) + .textContent, + /^Rendering stopped after an error\./, + ); + assert.equal( + element.querySelector('.render-failure-detail').textContent, + 'Expected width to be greater than 0', + ); + assert.deepEqual(buttons(), ['Retry', 'Reset view']); + assert.equal(document.activeElement?.textContent.trim(), 'Retry'); + for (const button of element.querySelectorAll('button')) button.click(); + assert.deepEqual(calls, ['retry', 'reset']); + store.publishRenderFailure(null); + await nextTick(); + assert.equal(notice(), null); +}); + +test('a lost context offers Reload, and a panel that cannot reload offers nothing', async (t) => { + const store = mount(t); + store.publishRenderFailure({ + reason: 'context-lost', + detail: '', + retry: () => {}, + reset: () => {}, + }); + await nextTick(); + assert.match( + document.getElementById('render-failure-message').textContent, + /3D graphics/, + ); + assert.equal(notice().querySelector('.render-failure-detail'), null); + assert.deepEqual(buttons(), ['Reload', 'Reset view']); + store.publishRenderFailure({ + reason: 'context-lost', + detail: '', + retry: null, + reset: null, + }); + await nextTick(); + assert.deepEqual(buttons(), []); + assert.equal(notice().querySelector('.render-failure-hint'), null); +}); + +const failure = () => ({ + reason: 'render-error', + detail: '', + retry: () => {}, + reset: null, +}); +const settle = async () => { + await nextTick(); + await nextTick(); +}; + +test('focus goes back where it was when the globe draws again', async (t) => { + const store = mount(t); + const search = document.body.appendChild(document.createElement('input')); + search.focus(); + store.publishRenderFailure(failure()); + await settle(); + assert.equal(document.activeElement?.textContent.trim(), 'Retry'); + store.publishRenderFailure(null); + await settle(); + assert.equal(document.activeElement, search); +}); + +// The globe's canvas, as CesiumWidget builds it inside the container. +const addGlobeCanvas = () => { + const widget = document.createElement('div'); + widget.className = 'cesium-widget'; + document.getElementById('cesiumContainer').appendChild(widget); + return widget.appendChild(document.createElement('canvas')); +}; + +test('focus goes to the globe canvas when the element it came from is gone', async (t) => { + const store = mount(t); + const canvas = addGlobeCanvas(); + const search = document.body.appendChild(document.createElement('input')); + search.focus(); + store.publishRenderFailure(failure()); + await settle(); + search.remove(); + store.publishRenderFailure(null); + await settle(); + // The map's keys, Enter to finish a drawing among them, read the canvas + // as the map (src/annotations/drawTool.js). + assert.equal(document.activeElement, canvas); + assert.equal(canvas.getAttribute('tabindex'), '-1'); + canvas.blur(); + assert.equal(canvas.hasAttribute('tabindex'), false, 'only this once'); +}); + +test('focus goes to the globe canvas when the element it came from cannot take it', async (t) => { + const store = mount(t); + const canvas = addGlobeCanvas(); + const button = document.body.appendChild(document.createElement('button')); + button.focus(); + store.publishRenderFailure(failure()); + await settle(); + button.disabled = true; + store.publishRenderFailure(null); + await settle(); + assert.equal(document.activeElement, canvas); + // A press on the globe leaves focus on the page again, as Cesium has it. + canvas.dispatchEvent(new Event('pointerdown')); + assert.equal(document.activeElement, document.body); + assert.equal(canvas.hasAttribute('tabindex'), false); +}); + +test('focus the user moved while the notice was up stays put', async (t) => { + const store = mount(t); + store.publishRenderFailure(failure()); + await settle(); + const chat = document.body.appendChild(document.createElement('textarea')); + chat.focus(); + store.publishRenderFailure(null); + await settle(); + assert.equal(document.activeElement, chat); +}); + +test('the globe drawing from the start leaves focus alone', async (t) => { + mount(t); + await settle(); + assert.equal(document.activeElement, document.body); + assert.equal( + document.getElementById('cesiumContainer').hasAttribute('tabindex'), + false, + ); +}); diff --git a/src/ui/renderLoopFixture.mjs b/src/ui/renderLoopFixture.mjs new file mode 100644 index 000000000..562e2d3f7 --- /dev/null +++ b/src/ui/renderLoopFixture.mjs @@ -0,0 +1,72 @@ +// A widget on Cesium's real CesiumWidget.prototype (startRenderLoop, the +// useDefaultRenderLoop setter, render) around a scene double, with animation +// frames queued by hand and the render failure notice installed. +import * as Cesium from 'cesium'; +import { installRenderFailureNotice } from './renderFailure.js'; + +export function renderLoopFixture( + scene, + { targetFrameRate, viewer: viewerExtras = {}, notice = {} } = {}, +) { + const frames = []; + const previousRaf = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = (fn) => frames.push(fn); + const widget = Object.create(Cesium.CesiumWidget.prototype); + Object.assign(widget, { + _scene: scene, + _clock: { tick() {} }, + _canRender: true, + _useDefaultRenderLoop: false, + _renderLoopRunning: false, + // What src/app/scene.js passes to the viewer. + _showRenderLoopErrors: false, + _targetFrameRate: targetFrameRate, + isDestroyed: () => false, + resize() {}, + }); + // CesiumWidget's own renderError handler, which marks the loop stopped. + scene.renderError.addEventListener(() => { + widget._useDefaultRenderLoop = false; + widget._renderLoopRunning = false; + }); + const viewer = Object.assign( + { + scene, + cesiumWidget: widget, + canvas: { addEventListener() {}, removeEventListener() {} }, + get useDefaultRenderLoop() { + return widget.useDefaultRenderLoop; + }, + set useDefaultRenderLoop(value) { + widget.useDefaultRenderLoop = value; + }, + }, + viewerExtras, + ); + const store = { + renderFailure: null, + publishRenderFailure(next) { + this.renderFailure = next; + }, + }; + const logged = []; + const uninstall = installRenderFailureNotice(viewer, { + store, + locationRef: { href: 'https://gev.example/', reload() {}, assign() {} }, + logError: (error) => logged.push(error), + ...notice, + }); + let time = 0; + const runFrames = (n) => { + for (let i = 0; i < n; i += 1) { + // Above a 60 fps target's interval, so every frame draws. + time += 17; + for (const fn of frames.splice(0)) fn(time); + } + }; + const restore = () => { + uninstall(); + globalThis.requestAnimationFrame = previousRaf; + }; + return { frames, widget, viewer, store, logged, runFrames, restore }; +} diff --git a/src/ui/stores/status.js b/src/ui/stores/status.js index 34f66233d..d51fefd03 100644 --- a/src/ui/stores/status.js +++ b/src/ui/stores/status.js @@ -7,7 +7,9 @@ import { markRaw, shallowRef } from 'vue'; * road network chip, CctvControls the camera grid chip; the toast shows * what any owner's createToast() (src/ui/toast.js) publishes, and the title * bar the FPS readout createFrameRateMonitor() publishes. Each label keeps its - * last text while hidden, so the next change flaps from what was shown. + * last text while hidden, so the next change flaps from what was shown. The + * render failure notice is what installRenderFailureNotice() + * (src/ui/renderFailure.js) publishes while the globe cannot draw. */ export const useStatusStore = defineStore('gev-status', () => { /** @@ -32,6 +34,13 @@ export const useStatusStore = defineStore('gev-status', () => { */ const fps = shallowRef(null); + /** + * The globe's render failure, `{reason, detail, retry, reset}`; null while + * the globe draws. `reason` is 'render-error' or 'context-lost', and `retry` + * and `reset` are null where that action is not offered. + */ + const renderFailure = shallowRef(null); + /** @param {?object} next A fresh presentation of the loading status. */ function publishLoading(next) { loading.value = next && markRaw(next); @@ -57,6 +66,11 @@ export const useStatusStore = defineStore('gev-status', () => { fps.value = next && markRaw(next); } + /** @param {?object} next A fresh presentation of the render failure. */ + function publishRenderFailure(next) { + renderFailure.value = next && markRaw(next); + } + return { fps, publishFps, @@ -68,5 +82,7 @@ export const useStatusStore = defineStore('gev-status', () => { publishTraffic, cctv, publishCctv, + renderFailure, + publishRenderFailure, }; }); diff --git a/src/ui/styles/status.css b/src/ui/styles/status.css index eebb4e785..ce212a46d 100644 --- a/src/ui/styles/status.css +++ b/src/ui/styles/status.css @@ -405,6 +405,76 @@ html:not([lang='en']) #global-loading-detail { } } +/* ── Render failure notice ───────────────────── */ +/* Above the loading screen, so a globe that fails while it loads says so too, + and above floating panels such as the Québec camera viewer (2000). */ +#render-failure { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 2001; + box-sizing: border-box; + width: min(420px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); + overflow-y: auto; + padding: 20px 22px; + background: var(--glass-bg); + border: 1px solid #ff7272; + border-radius: 12px; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + box-shadow: 0 0 24px rgba(255, 88, 88, 0.18); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 13px; + line-height: 1.5; +} + +#render-failure-title { + margin: 0 0 8px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + letter-spacing: 2px; + text-transform: uppercase; + color: #ff7272; +} + +#render-failure p { + margin: 0 0 10px; +} + +#render-failure .render-failure-detail { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + overflow-wrap: anywhere; +} + +#render-failure .render-failure-hint { + font-size: 12px; + color: var(--text-secondary); +} + +#render-failure .render-failure-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +#render-failure .gev-action-button { + font-size: 11px; + padding: 8px 14px; +} + +@media (pointer: coarse) { + #render-failure .gev-action-button { + min-height: 44px; + } +} + /* ── Toast Notification ──────────────────────── */ #toast { position: fixed; diff --git a/src/ui/views/SceneChromeView.vue b/src/ui/views/SceneChromeView.vue index 32900f42a..dea28933d 100644 --- a/src/ui/views/SceneChromeView.vue +++ b/src/ui/views/SceneChromeView.vue @@ -18,6 +18,7 @@ import ScreenAnnotations from './panels/ScreenAnnotations.vue'; import AwarenessDirection from './panels/AwarenessDirection.vue'; import UiButton from '../kit/UiButton.vue'; import GlobeActions from './panels/GlobeActions.vue'; +import RenderFailure from './panels/RenderFailure.vue'; import WorldTimeline from './panels/WorldTimeline.vue'; // The ACTIVE STYLE corner shows the style VisualSettings publishes. @@ -61,6 +62,7 @@ const vToastText = {