Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions shared/render-surface.ts
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;
}
121 changes: 121 additions & 0 deletions src/terminal/surfaceRegistry.ts
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard disposer from removing a replacement surface

registerSurface can replace an existing entry with the same id, but the returned cleanup always calls unregisterSurface(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 👍 / 👎.

}

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();
}
91 changes: 81 additions & 10 deletions src/terminal/terminalRuntimeStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recreate surface handle when xterm instance is recreated

createTerminalSurface binds onPaint once at construction time, but createTerminalRenderer skips rebuilding the surface whenever runtime.surfaceHandle already exists. In the evict/reattach path (detachTerminalRenderer sets runtime.xterm = null, later attachTerminalContainer calls createTerminalRenderer), the new xterm instance reuses an old surface handle whose paint subscription is tied to the disposed xterm, so lastPaintAt (and context-loss clearing) stop updating after reattach.

Useful? React with 👍 / 👎.

}

registerTerminal(runtime.meta.terminal.id, xterm, serializeAddon);
if (runtime.previewAnsi) {
xterm.write(runtime.previewAnsi, () => {
Expand Down Expand Up @@ -1515,6 +1577,7 @@ function buildTerminalRuntime(
selectionPointerCleanup: null,
serializeAddon: null,
sessionCancel: null,
surfaceHandle: null,
started: false,
telemetryTimer: null,
usesAgentRenderer: false,
Expand Down Expand Up @@ -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);
});
}

Expand Down Expand Up @@ -2086,6 +2147,7 @@ export function focusTerminalRuntime(terminalId: string): boolean {

recordRuntimeDiagnostic(runtime, "terminal_runtime_focus");
runtime.xterm.focus();
runtime.surfaceHandle?.setVisibleHint(true);
return true;
}

Expand All @@ -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;
}

Expand Down Expand Up @@ -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);
Expand Down
Loading