-
Notifications
You must be signed in to change notification settings - Fork 30
feat(render): RenderableSurface registry as recovery dispatch hub #181
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| // Render-surface contract. | ||
| // | ||
| // A "surface" is anything on screen whose pixels are produced by GPU work | ||
| // that Chromium might pause when the page is occluded — terminals (xterm), | ||
| // the canvas-backed graph layer, code editors (Monaco), the Pet sprite, | ||
| // etc. PR 1 wired recovery only for terminals because that's where the | ||
| // macOS Space-switch bug surfaced first; PR 7 will register the rest. | ||
| // | ||
| // Why a registry instead of growing the recovery callback to know about | ||
| // every surface kind: each surface owns its own paint primitive and each | ||
| // kind's recovery is a slightly different ritual (xterm calls | ||
| // `refresh()`, Monaco calls `editor.render(true)`, raw canvas redraws its | ||
| // scene, etc.). A registry lets recovery dispatch stay a one-liner — | ||
| // `for surface in registry: surface.forceRepaint(...)` — while the | ||
| // per-surface implementation lives next to the surface itself. | ||
|
|
||
| export type SurfaceKind = "terminal" | "monaco" | "canvas" | "pet"; | ||
|
|
||
| export type SurfaceRendererMode = "webgl" | "canvas" | "dom" | "unknown"; | ||
|
|
||
| export type SurfaceRecoverySeverity = "light" | "heavy"; | ||
|
|
||
| // Health snapshot returned by a surface for diagnostics + (in PR 5) the | ||
| // paint heartbeat watchdog. `lastPaintAt` is `Date.now()`-based; surfaces | ||
| // stamp it from inside their paint loop or `forceRepaint` callback. | ||
| export interface SurfaceHealth { | ||
| // Whether this surface is currently considered visible by its owner. | ||
| // For terminals: focused or recently touched. | ||
| visible: boolean; | ||
| // Last paint timestamp (ms since epoch). `null` if the surface has never | ||
| // painted (just-mounted, off-screen, etc.). | ||
| lastPaintAt: number | null; | ||
| // GPU resource state. `true` for WebGL surfaces that have logged a | ||
| // `webgl_context_lost` event since their last successful repaint. | ||
| contextLost: boolean; | ||
| // Active renderer pipeline. Surfaces that swap renderers (xterm | ||
| // WebGL→Canvas2D fallback) update this on swap. | ||
| rendererMode: SurfaceRendererMode; | ||
| } | ||
|
|
||
| export interface RenderableSurface { | ||
| readonly id: string; | ||
| readonly kind: SurfaceKind; | ||
| // Visibility transition. Surfaces use this to gate their internal paint | ||
| // loops (e.g. cancel an idle RAF when going hidden) and to align their | ||
| // `visible` health field. Idempotent — repeated calls with the same | ||
| // value must be cheap. | ||
| setVisible(visible: boolean): void; | ||
| // Synchronously schedule a repaint. `severity = heavy` implies the | ||
| // framebuffer was almost certainly lost (visibility hidden→visible, | ||
| // sleep/wake) and the surface should additionally rebuild any GPU | ||
| // resources (xterm: WebGL atlas; canvas: cached glyphs; etc.). | ||
| forceRepaint(reason: string, severity: SurfaceRecoverySeverity): void; | ||
| // Diagnostic snapshot. Called by the paint heartbeat watchdog and the | ||
| // Help → Report Issue snapshot collector. | ||
| getHealth(): SurfaceHealth; | ||
| } | ||
|
|
||
| export interface SurfaceDispatchResult { | ||
| total: number; | ||
| refreshed: number; | ||
| errors: number; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| // Renderer-process registry of every `RenderableSurface` that wants to | ||
| // participate in render recovery (and, in PR 5, the paint heartbeat | ||
| // watchdog). | ||
| // | ||
| // Surfaces register on mount, unregister on unmount. The recovery | ||
| // listener in `terminalRuntimeStore.installRenderRecoveryListeners` calls | ||
| // `dispatchSurfaceRecovery` instead of walking the terminal-only | ||
| // runtimeRegistry — that lets non-terminal GPU surfaces (Monaco, the | ||
| // canvas graph layer, the Pet) get the same recovery treatment without | ||
| // terminalRuntimeStore needing to know about them. | ||
|
|
||
| import type { | ||
| RenderableSurface, | ||
| SurfaceDispatchResult, | ||
| SurfaceHealth, | ||
| SurfaceKind, | ||
| SurfaceRecoverySeverity, | ||
| } from "../../shared/render-surface"; | ||
| import { recordRenderDiagnostic } from "./renderDiagnostics"; | ||
|
|
||
| const surfaces = new Map<string, RenderableSurface>(); | ||
|
|
||
| export function registerSurface(surface: RenderableSurface): () => void { | ||
| if (surfaces.has(surface.id)) { | ||
| // A double-register usually indicates a forgotten unregister on the | ||
| // previous mount. Replace silently — keeping the new surface — and | ||
| // record so the divergence is visible in diagnostics. | ||
| recordRenderDiagnostic({ | ||
| kind: "surface_register_replaced", | ||
| data: { surface_id: surface.id, surface_kind: surface.kind }, | ||
| }); | ||
| } | ||
| surfaces.set(surface.id, surface); | ||
| recordRenderDiagnostic({ | ||
| kind: "surface_register", | ||
| data: { | ||
| surface_id: surface.id, | ||
| surface_kind: surface.kind, | ||
| registry_size: surfaces.size, | ||
| }, | ||
| }); | ||
| return () => unregisterSurface(surface.id); | ||
| } | ||
|
|
||
| export function unregisterSurface(id: string): void { | ||
| if (!surfaces.delete(id)) return; | ||
| recordRenderDiagnostic({ | ||
| kind: "surface_unregister", | ||
| data: { surface_id: id, registry_size: surfaces.size }, | ||
| }); | ||
| } | ||
|
|
||
| export function getSurface(id: string): RenderableSurface | null { | ||
| return surfaces.get(id) ?? null; | ||
| } | ||
|
|
||
| export function listSurfaces(): RenderableSurface[] { | ||
| return [...surfaces.values()]; | ||
| } | ||
|
|
||
| export function listSurfacesByKind(kind: SurfaceKind): RenderableSurface[] { | ||
| const out: RenderableSurface[] = []; | ||
| for (const surface of surfaces.values()) { | ||
| if (surface.kind === kind) out.push(surface); | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| export function getSurfaceHealth(id: string): SurfaceHealth | null { | ||
| const surface = surfaces.get(id); | ||
| if (!surface) return null; | ||
| try { | ||
| return surface.getHealth(); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| // Recovery entry point for `VisibilityObserver.onRecovery`. Walks every | ||
| // registered surface, calls `forceRepaint`, and tallies result counters. | ||
| // Surface-level errors are caught so one bad surface can't block recovery | ||
| // for the rest. | ||
| export function dispatchSurfaceRecovery( | ||
| reason: string, | ||
| severity: SurfaceRecoverySeverity, | ||
| ): SurfaceDispatchResult { | ||
| let refreshed = 0; | ||
| let errors = 0; | ||
| const total = surfaces.size; | ||
|
|
||
| for (const surface of surfaces.values()) { | ||
| try { | ||
| surface.forceRepaint(reason, severity); | ||
| refreshed += 1; | ||
| } catch (error) { | ||
| errors += 1; | ||
| recordRenderDiagnostic({ | ||
| kind: "surface_force_repaint_failed", | ||
| data: { | ||
| surface_id: surface.id, | ||
| surface_kind: surface.kind, | ||
| reason, | ||
| severity, | ||
| error: error instanceof Error ? error.message : String(error), | ||
| }, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| recordRenderDiagnostic({ | ||
| kind: "surface_recovery_dispatched", | ||
| data: { reason, severity, total, refreshed, errors }, | ||
| }); | ||
|
|
||
| return { total, refreshed, errors }; | ||
| } | ||
|
|
||
| // Test-only: clear the registry between cases. | ||
| export function __resetSurfaceRegistryForTesting(): void { | ||
| surfaces.clear(); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,16 @@ import { | |
| recordRenderDiagnostic, | ||
| } from "./renderDiagnostics"; | ||
| import { getVisibilityObserver } from "./visibilityObserver"; | ||
| import { | ||
| registerSurface, | ||
| unregisterSurface, | ||
| dispatchSurfaceRecovery, | ||
| } from "./surfaceRegistry"; | ||
| import { | ||
| createTerminalSurface, | ||
| type TerminalSurfaceHandle, | ||
| type TerminalSurfaceRuntimeView, | ||
| } from "./terminalSurface"; | ||
| import { | ||
| registerTerminal, | ||
| serializeTerminal, | ||
|
|
@@ -148,6 +158,7 @@ interface ManagedTerminalRuntime { | |
| selectionPointerCleanup: (() => void) | null; | ||
| serializeAddon: SerializeAddon | null; | ||
| sessionCancel: (() => void) | null; | ||
| surfaceHandle: TerminalSurfaceHandle | null; | ||
| started: boolean; | ||
| telemetryTimer: ReturnType<typeof setInterval> | null; | ||
| usesAgentRenderer: boolean; | ||
|
|
@@ -1131,6 +1142,51 @@ function registerModifierAwareLinkProvider( | |
| }; | ||
| } | ||
|
|
||
| function createTerminalSurfaceRuntimeView( | ||
| runtime: ManagedTerminalRuntime, | ||
| ): TerminalSurfaceRuntimeView { | ||
| return { | ||
| id: runtime.meta.terminal.id, | ||
| isLive: () => !runtime.disposed && runtime.xterm !== null, | ||
| isAttached: () => runtime.attachedContainer !== null, | ||
| rendererMode: () => { | ||
| // Map the runtime's preferred renderer to the surface health enum. | ||
| // The actual active renderer can diverge (xterm WebGL fallback to | ||
| // Canvas2D on context loss) — PR 8 will surface that distinction | ||
| // by reading the WebGL pool. For now use the runtime preference. | ||
| const mode = runtime.rendererMode; | ||
| if (mode === "webgl" || mode === "dom") return mode; | ||
| return "unknown"; | ||
| }, | ||
| refreshXterm: () => { | ||
| const xterm = runtime.xterm; | ||
| if (!xterm || runtime.disposed) return false; | ||
| try { | ||
| xterm.refresh(0, Math.max(0, xterm.rows - 1)); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| }, | ||
| onPaint: (callback) => { | ||
| // xterm's `onRender` fires on every render (initial + on every | ||
| // `refresh` / pty write). Subscribe lazily — if xterm isn't ready | ||
| // yet we return a no-op disposer; PR 5's heartbeat will tolerate | ||
| // null `lastPaintAt` until the first real render. | ||
| const xterm = runtime.xterm; | ||
| if (!xterm || typeof xterm.onRender !== "function") return () => {}; | ||
| const disposable = xterm.onRender(() => callback()); | ||
| return () => { | ||
| try { | ||
| disposable.dispose(); | ||
| } catch { | ||
| // best-effort; xterm may already be disposed | ||
| } | ||
| }; | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| function createTerminalRenderer( | ||
| runtime: ManagedTerminalRuntime, | ||
| container: HTMLDivElement, | ||
|
|
@@ -1203,6 +1259,12 @@ function createTerminalRenderer( | |
| runtime.searchAddon = searchAddon; | ||
| syncRuntimeRenderer(runtime); | ||
|
|
||
| if (!runtime.surfaceHandle) { | ||
| const view = createTerminalSurfaceRuntimeView(runtime); | ||
| runtime.surfaceHandle = createTerminalSurface(view); | ||
| registerSurface(runtime.surfaceHandle.surface); | ||
|
Comment on lines
+1262
to
+1265
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| registerTerminal(runtime.meta.terminal.id, xterm, serializeAddon); | ||
| if (runtime.previewAnsi) { | ||
| xterm.write(runtime.previewAnsi, () => { | ||
|
|
@@ -1515,6 +1577,7 @@ function buildTerminalRuntime( | |
| selectionPointerCleanup: null, | ||
| serializeAddon: null, | ||
| sessionCancel: null, | ||
| surfaceHandle: null, | ||
| started: false, | ||
| telemetryTimer: null, | ||
| usesAgentRenderer: false, | ||
|
|
@@ -1704,16 +1767,14 @@ function installRenderRecoveryListeners() { | |
| }); | ||
| observer.install(); | ||
| observer.onRecovery(({ reason, severity }) => { | ||
| refreshAllTerminalRenderers(reason); | ||
| if (severity === "heavy") { | ||
| // WebGL canvases lose their framebuffer when the page is genuinely | ||
| // hidden (sleep/wake, minimize, OS Space switch). For light triggers | ||
| // (bare window.focus where the page may have just briefly lost focus) | ||
| // a refresh is enough. If WebGL's renderer state is corrupted, | ||
| // clearing the atlas can still leave new glyphs wrong; cycling the | ||
| // addon matches the user-visible DOM -> WebGL recovery path. | ||
| resetWebGL(undefined, reason); | ||
| } | ||
| // Recovery now routes through the surface registry instead of walking | ||
| // `runtimeRegistry` directly. Each registered surface implements its | ||
| // own paint primitive — terminals refresh xterm + cycle WebGL on | ||
| // heavy, the canvas graph layer redraws its scene, Monaco calls | ||
| // `editor.render(true)`, etc. (Non-terminal surfaces are wired in | ||
| // PR 7.) Recording the result counters here makes diagnostics show | ||
| // how many surfaces participated and how many failed. | ||
| dispatchSurfaceRecovery(reason, severity); | ||
| }); | ||
| } | ||
|
|
||
|
|
@@ -2086,6 +2147,7 @@ export function focusTerminalRuntime(terminalId: string): boolean { | |
|
|
||
| recordRuntimeDiagnostic(runtime, "terminal_runtime_focus"); | ||
| runtime.xterm.focus(); | ||
| runtime.surfaceHandle?.setVisibleHint(true); | ||
| return true; | ||
| } | ||
|
|
||
|
|
@@ -2096,6 +2158,9 @@ export function blurTerminalRuntime(terminalId: string): boolean { | |
| } | ||
|
|
||
| runtime.xterm.blur(); | ||
| // Don't flip surface visibility on blur — a terminal can be visible | ||
| // (rendered in its tile) without being keyboard-focused. PR 7 will | ||
| // wire visibility transitions from the actual mount/unmount path. | ||
| return true; | ||
| } | ||
|
|
||
|
|
@@ -2198,6 +2263,12 @@ export function destroyTerminalRuntime( | |
| }); | ||
| } | ||
|
|
||
| if (runtime.surfaceHandle) { | ||
| unregisterSurface(terminalId); | ||
| runtime.surfaceHandle.dispose(); | ||
| runtime.surfaceHandle = null; | ||
| } | ||
|
|
||
| runtimeRegistry.delete(terminalId); | ||
| removeRuntimeSnapshot(terminalId); | ||
| clearTerminalActivity(terminalId); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
registerSurfacecan replace an existing entry with the sameid, but the returned cleanup always callsunregisterSurface(surface.id)unconditionally. If the old cleanup runs after a replacement registration (the exact duplicate-registration scenario this function handles), it will delete the newer surface and remove it from recovery/health dispatch unexpectedly. The cleanup should only unregister when the map still points to the same surface instance.Useful? React with 👍 / 👎.