Skip to content

feat(render): RenderableSurface registry as recovery dispatch hub - #181

Open
blueberrycongee wants to merge 1 commit into
mainfrom
feat/render-recovery-pr3-surface-registry
Open

feat(render): RenderableSurface registry as recovery dispatch hub#181
blueberrycongee wants to merge 1 commit into
mainfrom
feat/render-recovery-pr3-surface-registry

Conversation

@blueberrycongee

Copy link
Copy Markdown
Owner

Why

PR 1 (#162) centralized recovery triggers in `VisibilityObserver`, but the listener still walked `runtimeRegistry` directly — only terminals participated. Monaco / canvas-backed graph layer / Pet are also GPU surfaces that lose framebuffers on `visibility hidden→visible`, sleep/wake, and the macOS Space-switch IPC. They had no path back.

This is render-recovery v2 PR 3, the foundation for PR 5 (paint heartbeat) and PR 7 (non-terminal surfaces). Independent of PR 6 (#180).

What

  • `shared/render-surface.ts` — `RenderableSurface` interface (`setVisible`, `forceRepaint(reason, severity)`, `getHealth`) + `SurfaceHealth` shape (`visible`, `lastPaintAt`, `contextLost`, `rendererMode`).
  • `src/terminal/surfaceRegistry.ts` — registry with `registerSurface` / `unregisterSurface` / `dispatchSurfaceRecovery(reason, severity)`.
  • `src/terminal/terminalSurface.ts` — per-terminal adapter. `forceRepaint` does what the old recovery listener did (xterm refresh + WebGL reset on heavy). `getHealth` gates `visible` on `live && attached && visibleHint`.
  • `terminalRuntimeStore` — surface adapter created at the end of `createTerminalRenderer` (when xterm is ready), disposed in `destroyTerminalRuntime`. Recovery listener now does `dispatchSurfaceRecovery(reason, severity)` instead of walking `runtimeRegistry`.

`xterm.onRender` feeds `lastPaintAt` automatically, so PR 5's heartbeat will read accurate paint timestamps without any new instrumentation in the runtime.

Files

  • `shared/render-surface.ts` — new
  • `src/terminal/surfaceRegistry.ts` — new
  • `src/terminal/terminalSurface.ts` — new
  • `src/terminal/terminalRuntimeStore.ts` — wire surface lifecycle + switch recovery dispatch
  • `tests/surface-registry.test.ts` — 6 tests (registration, dispatch tally, error isolation, duplicate replace, kind filter)
  • `tests/terminal-surface.test.ts` — 5 tests (visibility gating, paint subscription, contextLost flag, rendererMode passthrough, dispose)

Verification

  • `tsc --noEmit` clean
  • `tsx --test tests/surface-registry.test.ts tests/terminal-surface.test.ts` → 11/11 pass

macOS QA (cannot run here)

  • After Space switch, a registered terminal surface still recovers (pre-existing behavior preserved).
  • With diagnostics enabled, ledger shows `surface_recovery_dispatched` events with `{total, refreshed, errors}` after each visibility transition.
  • No regression in the "Cmd+Tab dedup" QA from PR feat(render): conditional bg throttling + lifecycle IPC recovery #162 — `visibility_observer_skipped` still fires for coalesced events.

Risks

  • `refreshAllTerminalRenderers` is no longer called from the recovery listener (it's still exported for any external caller; `grep` finds none in this PR's tree). If something downstream depends on it being called on visibility changes, that path breaks. The new surface dispatch covers the same work.
  • The terminal adapter's `contextLost` flag is wired but not yet populated — webglContextPool's `onContextLoss` doesn't push to surfaces in this PR. PR 8 will close that loop. Field stays `false` until then; documented in code.
  • Surface visibility is gated on `attachedContainer !== null`, not on the actual `IntersectionObserver`. Off-screen-but-attached terminals still report `visible: true`. Acceptable for PR 3; the heartbeat (PR 5) tolerates it because it only fires on `document.visibilityState === "visible"` anyway.

🤖 Generated with Claude Code

…walk

Recovery dispatch was hardwired to runtimeRegistry — only terminals got
refreshed/reset on `visibility_change_to_visible`, `page_lifecycle_resume`,
and the lifecycle IPC. Other GPU surfaces (the canvas graph layer,
Monaco editor, the Pet sprite) lost their framebuffers in the same
events but had no path to recover.

Introduce `RenderableSurface` (`setVisible`, `forceRepaint`, `getHealth`)
and a `surfaceRegistry`. Recovery now calls `dispatchSurfaceRecovery`,
which iterates every registered surface — terminal-specific repaint
logic moved into the per-terminal adapter (`terminalSurface.ts`).
Non-terminal surfaces wire in via PR 7.

Per-terminal lifecycle:
- `createTerminalRenderer` constructs the surface adapter once xterm is
  ready and registers it.
- `destroyTerminalRuntime` disposes the adapter and unregisters.
- `focusTerminalRuntime` flips the surface's visibility hint to true.
  Blur intentionally does not — a focused-elsewhere terminal can still
  be visible in its tile and should keep painting.

The adapter subscribes to `xterm.onRender` so `getHealth().lastPaintAt`
is accurate without callers having to mark paints. PR 5's heartbeat
watchdog will read this field to detect renderers that stopped
producing frames while their surface is still considered visible.

Tests cover the registry contract (dispatch tally, error isolation,
duplicate-id replacement) and the terminal adapter (visibility gating,
paint subscription, dispose teardown).

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
@vercel

vercel Bot commented May 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
website Ready Ready Preview, Comment May 10, 2026 7:07am

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e44a982b97

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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 👍 / 👎.

Comment on lines +1262 to +1265
if (!runtime.surfaceHandle) {
const view = createTerminalSurfaceRuntimeView(runtime);
runtime.surfaceHandle = createTerminalSurface(view);
registerSurface(runtime.surfaceHandle.surface);

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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants