From 5e76f48e8e4d16d45832988bc872718ac18cc93a Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 5 Nov 2026 11:05:43 -0600 Subject: [PATCH 1/4] fix(console): stop the stream transport posting input after viewer teardown BrowserSurface unmounted the Remote Surface viewer on cleanup but left its transport live, so a late input, viewport, selection read or clipboard write still POSTed to the previous stream's endpoints, and late replies still reached subscribers. A paste whose clipboard read finished after a reconnect could land in the replacement stream. Add dispose() to the PDPP transport: terminal, idempotent, drops the queued viewport and subscribers, and turns send, sendClipboardText, subscribe, receiveSseMessage and setPresentationAttachmentReady into no-ops (a late clipboard reply returns false). BrowserSurface cleanup now unmounts the viewer first, then disposes the transport in a finally block. Assisted-by: AI Signed-off-by: Tim Nunamaker --- ...am-viewer-remote-surface-transport.test.ts | 115 ++++++++++++++++++ .../stream-viewer-remote-surface-transport.ts | 28 ++++- .../syncs/[runId]/stream/stream-viewer.tsx | 7 +- 3 files changed, 147 insertions(+), 3 deletions(-) diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-remote-surface-transport.test.ts b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-remote-surface-transport.test.ts index 3ba946c67..56d12f623 100644 --- a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-remote-surface-transport.test.ts +++ b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-remote-surface-transport.test.ts @@ -173,3 +173,118 @@ test("clipboard controls use the dedicated authenticated clipboard endpoint", as { body: { action: "local_to_remote", text: "manual text", type: "clipboard" }, url: "/clipboard" }, ]); }); + +test("a clipboard read finishing after disposal cannot reach the replacement viewer", async () => { + const { clipboardUrlRef, inputUrlRef, presentationAttachmentReadyRef, transport, viewportUrlRef } = makeTransport(); + const clipboardRead = Promise.withResolvers(); + const oldPaste = clipboardRead.promise.then((text) => transport.sendClipboardText(text)); + const calls: FetchCall[] = []; + await withFetch( + (call) => { + calls.push(call); + return new Response("{}", { status: 202 }); + }, + async () => { + transport.dispose(); + clipboardUrlRef.current = "/replacement/clipboard"; + const replacement = createPdppRemoteSurfaceTransport({ + clipboardUrlRef, + inputUrlRef, + logDebug: () => undefined, + presentationAttachmentReadyRef, + viewportUrlRef, + }); + clipboardRead.resolve("old paste"); + assert.equal(await oldPaste, false, "a disposed transport must refuse the old paste"); + assert.equal(await replacement.sendClipboardText("new paste"), true); + replacement.dispose(); + } + ); + assert.deepEqual(calls, [ + { + body: { action: "local_to_remote", text: "new paste", type: "clipboard" }, + url: "/replacement/clipboard", + }, + ]); +}); + +test("disposal is terminal for queued viewports, outbound input and inbound subscriptions", async () => { + const { messages, presentationAttachmentReadyRef, transport } = makeTransport(); + const calls: FetchCall[] = []; + await withFetch( + (call) => { + calls.push(call); + return new Response("{}", { status: 202 }); + }, + async () => { + transport.send({ height: 600, requestId: 1, type: "viewport", width: 800 }); + transport.dispose(); + transport.dispose(); + transport.subscribe((message) => messages.push(message)); + transport.setPresentationAttachmentReady(true); + transport.send({ text: "late input", type: "text" }); + transport.send({ requestId: 2, type: "read_remote_selection" }); + transport.send({ height: 600, requestId: 3, type: "viewport", width: 800 }); + transport.receiveSseMessage("frame", { data_base64: "AA==" }); + transport.receiveSseMessage("keyboard_focus", { focused: true }); + assert.equal(await transport.sendClipboardText("late clipboard"), false); + assert.equal( + presentationAttachmentReadyRef.current, + false, + "disposed transport must not change shared readiness" + ); + } + ); + assert.deepEqual(calls, []); + assert.deepEqual(messages, []); +}); + +test("HTTP replies already in flight cannot notify a disposed viewer", async () => { + const { messages, transport } = makeTransport(); + const viewportResponse = Promise.withResolvers(); + const selectionResponse = Promise.withResolvers(); + const clipboardResponse = Promise.withResolvers(); + await withFetch( + (call) => { + if (call.url === "/viewport") { + return viewportResponse.promise; + } + return call.body.action === "remote_to_local" ? selectionResponse.promise : clipboardResponse.promise; + }, + async () => { + transport.setPresentationAttachmentReady(true); + transport.send({ height: 600, requestId: 1, type: "viewport", width: 800 }); + transport.send({ requestId: 2, type: "read_remote_selection" }); + const clipboardAck = transport.sendClipboardText("already sent"); + transport.dispose(); + transport.subscribe((message) => messages.push(message)); + viewportResponse.resolve(new Response("{}", { status: 202 })); + selectionResponse.resolve(new Response('{"text":"old selection"}', { status: 200 })); + clipboardResponse.resolve(new Response("{}", { status: 202 })); + assert.equal(await clipboardAck, false, "a late response cannot acknowledge a live clipboard operation"); + await new Promise((resolve) => setImmediate(resolve)); + } + ); + assert.deepEqual(messages, [], "late replies must not reach existing or newly attached subscribers"); +}); + +test("a live transport still follows endpoint refreshes", async () => { + const { clipboardUrlRef, inputUrlRef, transport } = makeTransport(); + const calls: FetchCall[] = []; + await withFetch( + (call) => { + calls.push(call); + return new Response("{}", { status: 202 }); + }, + async () => { + inputUrlRef.current = "/refreshed/input"; + clipboardUrlRef.current = "/refreshed/clipboard"; + transport.send({ text: "live input", type: "text" }); + assert.equal(await transport.sendClipboardText("live paste"), true); + } + ); + assert.deepEqual( + calls.map((call) => call.url), + ["/refreshed/input", "/refreshed/clipboard"] + ); +}); diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-remote-surface-transport.ts b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-remote-surface-transport.ts index 5a4095848..a3a4987c5 100644 --- a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-remote-surface-transport.ts +++ b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-remote-surface-transport.ts @@ -7,6 +7,7 @@ type RemoteSurfaceMessage = Record; type TransportLogger = (event: string, payload?: Record) => void; export interface PdppRemoteSurfaceTransport extends RemoteSurfaceTransport { + dispose: () => void; receiveSseMessage: (name: string, payload: Record) => void; sendClipboardText: (text: string) => Promise; setPresentationAttachmentReady: (ready: boolean) => void; @@ -49,10 +50,14 @@ export function createPdppRemoteSurfaceTransport({ viewportUrlRef, }: CreatePdppRemoteSurfaceTransportOptions): PdppRemoteSurfaceTransport { const handlers = new Set<(message: RemoteSurfaceMessage) => void>(); + let disposed = false; let frameSequence = 0; let queuedViewport: RemoteSurfaceMessage | null = null; const emit = (message: RemoteSurfaceMessage): void => { + if (disposed) { + return; + } for (const handler of handlers) { handler(message); } @@ -163,13 +168,16 @@ export function createPdppRemoteSurfaceTransport({ }; const sendClipboardText = async (text: string): Promise => { + if (disposed) { + return false; + } try { const response = await postClipboard({ action: "local_to_remote", text, type: "clipboard" }); const body = await readResponseBody(response); if (!response.ok) { throw new Error(responseMessage(body, `Clipboard rejected by server: ${response.status}`)); } - return true; + return !disposed; } catch (error) { logDebug("stream.remote_surface.clipboard.error", { error: error instanceof Error ? error.message : String(error), @@ -180,6 +188,9 @@ export function createPdppRemoteSurfaceTransport({ }; const receiveSseMessage = (name: string, payload: Record): void => { + if (disposed) { + return; + } if (name === "frame") { frameSequence += 1; emit({ @@ -200,8 +211,16 @@ export function createPdppRemoteSurfaceTransport({ }; return { + dispose() { + disposed = true; + queuedViewport = null; + handlers.clear(); + }, receiveSseMessage, send(message) { + if (disposed) { + return; + } if (message.type === "viewport") { sendViewport(message); return; @@ -220,6 +239,9 @@ export function createPdppRemoteSurfaceTransport({ }, sendClipboardText, setPresentationAttachmentReady(ready) { + if (disposed) { + return; + } presentationAttachmentReadyRef.current = ready; if (!(ready && queuedViewport)) { return; @@ -229,7 +251,9 @@ export function createPdppRemoteSurfaceTransport({ sendViewport(next); }, subscribe(handler) { - handlers.add(handler); + if (!disposed) { + handlers.add(handler); + } return () => handlers.delete(handler); }, }; diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx index c222b2e40..0d39efe13 100644 --- a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx +++ b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx @@ -4855,7 +4855,12 @@ function BrowserSurface({ onTransportMountedRef.current(null); onViewerMountedRef.current(null); } - viewer.unmount(); + try { + // Permit synchronous SDK cleanup before retiring its transport. + viewer.unmount(); + } finally { + transport.dispose(); + } }; }, [ clipboardUrlRef, From 9ccd78216c456ef069d55ea6ce61e4f8aa8ba5e9 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 5 Nov 2026 11:27:10 -0600 Subject: [PATCH 2/4] fix(console): silence a disposed transport and always dispose it on teardown A request that failed after dispose() still reached the host logger, which batches events into POST /api/stream-debug, so a retired transport used the network. Gate the logger and the late catch and response paths on disposal, and skip reading a clipboard reply once disposed. BrowserSurface cleanup ran the readiness reset and mount notifications outside its try/finally, so a throwing callback skipped viewer.unmount and transport.dispose. Move the mount and cleanup into mountBrowserSurfaceViewer, whose cleanup disposes the transport in a nested finally however the notifications or the viewer unmount behave. The page still builds the viewer, so its configuration is unchanged. Add transport regressions (rejected fetch, late non-2xx, clipboard failure, no logger call after disposal) and lifecycle tests under real React: StrictMode mount, cleanup and remount, fast remount, a stale cleanup, and throwing notification or unmount. Assisted-by: AI Signed-off-by: Tim Nunamaker --- ...m-viewer-browser-surface-lifecycle.test.ts | 216 ++++++++++++++++++ ...stream-viewer-browser-surface-lifecycle.ts | 73 ++++++ ...am-viewer-remote-surface-transport.test.ts | 136 +++++++++++ .../stream-viewer-remote-surface-transport.ts | 27 ++- .../syncs/[runId]/stream/stream-viewer.tsx | 61 ++--- 5 files changed, 463 insertions(+), 50 deletions(-) create mode 100644 apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.test.ts create mode 100644 apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.ts diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.test.ts b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.test.ts new file mode 100644 index 000000000..088c445f6 --- /dev/null +++ b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.test.ts @@ -0,0 +1,216 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { JSDOM } from "jsdom"; +import { act, createElement, StrictMode, useEffect, useRef } from "react"; +import { createRoot } from "react-dom/client"; +import { + type MountBrowserSurfaceViewerOptions, + mountBrowserSurfaceViewer, +} from "./stream-viewer-browser-surface-lifecycle.ts"; +import type { PdppRemoteSurfaceTransport } from "./stream-viewer-remote-surface-transport.ts"; + +type FakeViewer = { + id: number; + mount: () => Promise; + unmount: () => void; + unmounts: number; +}; +type CreateViewer = MountBrowserSurfaceViewerOptions["createViewer"]; + +const dom = new JSDOM(""); +Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + Node: dom.window.Node, + window: dom.window, +}); + +function makeHarness() { + const viewers: FakeViewer[] = []; + const mountedTransports: (PdppRemoteSurfaceTransport | null)[] = []; + const mountedViewers: unknown[] = []; + const transportRef: { current: PdppRemoteSurfaceTransport | null } = { current: null }; + const fetches: string[] = []; + const createViewer = ((_options: unknown) => { + const viewer: FakeViewer = { + id: viewers.length + 1, + mount: () => Promise.resolve(), + unmount() { + viewer.unmounts += 1; + }, + unmounts: 0, + }; + viewers.push(viewer); + return viewer; + }) as unknown as CreateViewer; + const options = (overrides: Partial = {}): MountBrowserSurfaceViewerOptions => ({ + clipboardUrlRef: { current: "/clipboard" }, + createViewer, + inputUrlRef: { current: "/input" }, + logDebug: () => undefined, + node: dom.window.document.createElement("div"), + onTransportMounted: (transport) => { + mountedTransports.push(transport); + }, + onViewerMounted: (viewer) => { + mountedViewers.push(viewer); + }, + presentationAttachmentReadyRef: { current: true }, + transportRef, + viewportUrlRef: { current: "/viewport" }, + ...overrides, + }); + return { createViewer, fetches, mountedTransports, mountedViewers, options, transportRef, viewers }; +} + +async function withFetchLog(fetches: string[], work: () => Promise): Promise { + const original = globalThis.fetch; + globalThis.fetch = ((input: unknown) => { + fetches.push(String(input)); + return Promise.resolve(new Response("{}", { status: 202 })); + }) as typeof fetch; + try { + return await work(); + } finally { + globalThis.fetch = original; + } +} + +function postsInput(transport: PdppRemoteSurfaceTransport | null, fetches: string[]): boolean { + assert.ok(transport, "expected a transport"); + const before = fetches.length; + transport.send({ text: "x", type: "text" }); + return fetches.length > before; +} + +test("a throwing mount notification during cleanup still unmounts the viewer and disposes the transport", async () => { + const h = makeHarness(); + let failNotifications = false; + const cleanup = mountBrowserSurfaceViewer( + h.options({ + onTransportMounted: (transport) => { + if (failNotifications) { + throw new Error("callback failed"); + } + h.mountedTransports.push(transport); + }, + }) + ); + const [transport] = h.mountedTransports; + failNotifications = true; + await withFetchLog(h.fetches, async () => { + assert.throws(cleanup, /callback failed/); + assert.equal(h.viewers[0]?.unmounts, 1, "the viewer is unmounted even when a notification throws"); + assert.equal(postsInput(transport ?? null, h.fetches), false, "the transport is disposed"); + }); +}); + +test("a throwing viewer unmount still disposes the transport", async () => { + const h = makeHarness(); + const cleanup = mountBrowserSurfaceViewer(h.options()); + const [transport] = h.mountedTransports; + const [viewer] = h.viewers; + assert.ok(viewer); + viewer.unmount = () => { + throw new Error("unmount failed"); + }; + await withFetchLog(h.fetches, async () => { + assert.throws(cleanup, /unmount failed/); + assert.equal(postsInput(transport ?? null, h.fetches), false); + }); +}); + +test("a stale cleanup disposes only its own mount and leaves the newer mount live", async () => { + const h = makeHarness(); + const cleanupOld = mountBrowserSurfaceViewer(h.options()); + const cleanupNew = mountBrowserSurfaceViewer(h.options()); + const [oldTransport, newTransport] = h.mountedTransports; + assert.equal(h.transportRef.current, newTransport); + await withFetchLog(h.fetches, async () => { + cleanupOld(); + assert.equal(h.transportRef.current, newTransport, "a stale cleanup must not clear the newer mount"); + assert.equal(h.mountedTransports.length, 2, "a stale cleanup must not notify the host"); + assert.equal(postsInput(oldTransport ?? null, h.fetches), false); + assert.equal(postsInput(newTransport ?? null, h.fetches), true, "the newer mount's transport stays live"); + assert.deepEqual( + h.viewers.map((viewer) => viewer.unmounts), + [1, 0] + ); + cleanupNew(); + assert.equal(postsInput(newTransport ?? null, h.fetches), false); + }); +}); + +function Harness({ h, version }: { h: ReturnType; version: number }) { + const nodeRef = useRef(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: `version` stands in for any dependency change that remounts the viewer. + useEffect(() => { + const node = nodeRef.current; + if (!node) { + return; + } + return mountBrowserSurfaceViewer(h.options({ node })); + }, [h, version]); + return createElement("div", { ref: nodeRef }); +} + +test("React StrictMode mount, cleanup and remount leaves exactly one live viewer and transport", async () => { + const h = makeHarness(); + const container = dom.window.document.createElement("div"); + const root = createRoot(container); + await withFetchLog(h.fetches, async () => { + await act(async () => { + root.render(createElement(StrictMode, null, createElement(Harness, { h, version: 1 }))); + }); + assert.equal(h.viewers.length, 2, "StrictMode mounted, cleaned up and remounted the effect"); + assert.deepEqual( + h.viewers.map((viewer) => viewer.unmounts), + [1, 0] + ); + const transports = h.mountedTransports.filter((transport) => transport !== null); + assert.equal(transports.length, 2); + assert.equal(h.mountedTransports.at(-1), transports[1], "the host holds the remounted transport"); + assert.equal(postsInput(transports[0] ?? null, h.fetches), false, "the first mount's transport is disposed"); + assert.equal(postsInput(transports[1] ?? null, h.fetches), true, "the remounted transport is live"); + await act(async () => { + root.unmount(); + }); + assert.equal(h.viewers[1]?.unmounts, 1); + assert.equal(postsInput(transports[1] ?? null, h.fetches), false); + assert.equal(h.mountedTransports.at(-1), null); + }); +}); + +test("a fast remount disposes the old transport and keeps the new mount's transport live", async () => { + const h = makeHarness(); + const container = dom.window.document.createElement("div"); + const root = createRoot(container); + await withFetchLog(h.fetches, async () => { + await act(async () => { + root.render(createElement(Harness, { h, version: 1 })); + }); + await act(async () => { + root.render(createElement(Harness, { h, version: 2 })); + }); + await act(async () => { + root.render(createElement(Harness, { h, version: 3 })); + }); + const transports = h.mountedTransports.filter((transport) => transport !== null); + assert.equal(transports.length, 3); + assert.equal(postsInput(transports[0] ?? null, h.fetches), false); + assert.equal(postsInput(transports[1] ?? null, h.fetches), false); + assert.equal(postsInput(transports[2] ?? null, h.fetches), true, "the latest mount's transport must stay live"); + assert.equal(h.transportRef.current, transports[2]); + assert.deepEqual( + h.viewers.map((viewer) => viewer.unmounts), + [1, 1, 0] + ); + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.ts b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.ts new file mode 100644 index 000000000..053ced588 --- /dev/null +++ b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.ts @@ -0,0 +1,73 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { RemoteSurfaceViewerHandle } from "@opendatalabs/remote-surface/client"; + +import { createPdppRemoteSurfaceTransport, type PdppRemoteSurfaceTransport } from "./stream-viewer-remote-surface-transport.ts"; + +type TransportOptions = Parameters[0]; + +export interface MountBrowserSurfaceViewerOptions extends TransportOptions { + /** Builds the Remote Surface viewer over this mount's transport. */ + createViewer: (transport: PdppRemoteSurfaceTransport) => RemoteSurfaceViewerHandle; + node: HTMLElement; + onTransportMounted: (transport: PdppRemoteSurfaceTransport | null) => void; + onViewerMounted: (viewer: RemoteSurfaceViewerHandle | null) => void; + /** Identifies the live mount; a stale cleanup must not clear a newer mount. */ + transportRef: { current: PdppRemoteSurfaceTransport | null }; +} + +/** + * Mounts one Remote Surface viewer over one PDPP transport and returns its + * cleanup. Each mount owns its transport: cleanup always retires it, even when + * a mount notification or the viewer's own unmount throws, so a retired + * viewer cannot keep posting input to the stream. + */ +export function mountBrowserSurfaceViewer({ + createViewer, + node, + onTransportMounted, + onViewerMounted, + transportRef, + ...transportOptions +}: MountBrowserSurfaceViewerOptions): () => void { + let disposed = false; + const transport = createPdppRemoteSurfaceTransport(transportOptions); + const viewer = createViewer(transport); + transportRef.current = transport; + transport.setPresentationAttachmentReady(transportOptions.presentationAttachmentReadyRef.current); + try { + const mountPromise = viewer.mount(node); + if (!disposed) { + onTransportMounted(transport); + onViewerMounted(viewer); + } + mountPromise.catch((error) => { + transportOptions.logDebug("surface.cdp-frame.viewer_mount_error", { + error: error instanceof Error ? error.message : String(error), + }); + }); + } catch (error) { + transportOptions.logDebug("surface.cdp-frame.viewer_mount_error", { + error: error instanceof Error ? error.message : String(error), + }); + } + return () => { + disposed = true; + try { + transport.setPresentationAttachmentReady(false); + if (transportRef.current === transport) { + transportRef.current = null; + onTransportMounted(null); + onViewerMounted(null); + } + } finally { + try { + // Permit synchronous SDK cleanup before retiring its transport. + viewer.unmount(); + } finally { + transport.dispose(); + } + } + }; +} diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-remote-surface-transport.test.ts b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-remote-surface-transport.test.ts index 56d12f623..363d6baab 100644 --- a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-remote-surface-transport.test.ts +++ b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-remote-surface-transport.test.ts @@ -288,3 +288,139 @@ test("a live transport still follows endpoint refreshes", async () => { ["/refreshed/input", "/refreshed/clipboard"] ); }); + +// The host logger of the stream page batches events into POST /api/stream-debug. +// This stand-in sends that POST synchronously so a late log shows up as network use. +function makeDebugPostingTransport() { + const hostLogs: string[] = []; + const transport = createPdppRemoteSurfaceTransport({ + clipboardUrlRef: { current: "/clipboard" }, + inputUrlRef: { current: "/input" }, + logDebug: (event) => { + hostLogs.push(event); + fetch("/api/stream-debug", { body: JSON.stringify({ events: [event] }), method: "POST" }).catch(() => undefined); + }, + presentationAttachmentReadyRef: { current: true }, + viewportUrlRef: { current: "/viewport" }, + }); + return { hostLogs, transport }; +} + +const settleMacrotask = () => new Promise((resolve) => setImmediate(resolve)); + +test("a live transport logs a failed input POST through the host logger", async () => { + const { hostLogs, transport } = makeDebugPostingTransport(); + const calls: FetchCall[] = []; + await withFetch( + (call) => { + calls.push(call); + return call.url === "/input" ? Promise.reject(new Error("offline")) : Promise.resolve(new Response("{}", { status: 202 })); + }, + async () => { + transport.send({ text: "x", type: "text" }); + await settleMacrotask(); + } + ); + assert.deepEqual(hostLogs, ["stream.remote_surface.input.error"]); + assert.deepEqual( + calls.map((call) => call.url), + ["/input", "/api/stream-debug"] + ); +}); + +test("an input fetch that rejects after disposal does not log or schedule a debug POST", async () => { + const { hostLogs, transport } = makeDebugPostingTransport(); + const pending = Promise.withResolvers(); + const calls: FetchCall[] = []; + await withFetch( + (call) => { + calls.push(call); + return pending.promise; + }, + async () => { + transport.send({ text: "x", type: "text" }); + transport.dispose(); + pending.reject(new Error("socket hang up")); + await settleMacrotask(); + } + ); + assert.deepEqual(hostLogs, []); + assert.deepEqual( + calls.map((call) => call.url), + ["/input"] + ); +}); + +test("a non-2xx input reply after disposal does not log or schedule a debug POST", async () => { + const { hostLogs, transport } = makeDebugPostingTransport(); + const pending = Promise.withResolvers(); + const calls: FetchCall[] = []; + await withFetch( + (call) => { + calls.push(call); + return pending.promise; + }, + async () => { + transport.send({ text: "x", type: "text" }); + transport.dispose(); + pending.resolve(new Response('{"error":{"message":"invalid_input"}}', { status: 400 })); + await settleMacrotask(); + } + ); + assert.deepEqual(hostLogs, []); + assert.deepEqual( + calls.map((call) => call.url), + ["/input"] + ); +}); + +test("a clipboard request that rejects or fails after disposal does not log or schedule a debug POST", async () => { + for (const settle of [ + (pending: PromiseWithResolvers) => pending.reject(new Error("socket hang up")), + (pending: PromiseWithResolvers) => + pending.resolve(new Response('{"error":{"message":"clipboard_denied"}}', { status: 403 })), + ]) { + const { hostLogs, transport } = makeDebugPostingTransport(); + const pending = Promise.withResolvers(); + const calls: FetchCall[] = []; + await withFetch( + (call) => { + calls.push(call); + return pending.promise; + }, + async () => { + const ack = transport.sendClipboardText("secret"); + transport.dispose(); + settle(pending); + assert.equal(await ack, false); + await settleMacrotask(); + } + ); + assert.deepEqual(hostLogs, []); + assert.deepEqual( + calls.map((call) => call.url), + ["/clipboard"] + ); + } +}); + +test("a disposed transport never calls the host logger from its skip and queue paths", async () => { + const hostLogs: string[] = []; + const inputUrlRef = { current: null as string | null }; + const presentationAttachmentReadyRef = { current: false }; + const transport = createPdppRemoteSurfaceTransport({ + clipboardUrlRef: { current: "/clipboard" }, + inputUrlRef, + logDebug: (event) => { + hostLogs.push(event); + }, + presentationAttachmentReadyRef, + viewportUrlRef: { current: "/viewport" }, + }); + transport.send({ text: "before", type: "text" }); + assert.deepEqual(hostLogs, ["stream.remote_surface.input.skip"], "a live transport logs a skipped input"); + transport.dispose(); + transport.send({ text: "after", type: "text" }); + transport.send({ height: 600, requestId: 1, type: "viewport", width: 800 }); + assert.deepEqual(hostLogs, ["stream.remote_surface.input.skip"]); +}); diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-remote-surface-transport.ts b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-remote-surface-transport.ts index a3a4987c5..35bfd5ae0 100644 --- a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-remote-surface-transport.ts +++ b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-remote-surface-transport.ts @@ -45,7 +45,7 @@ async function readResponseBody(response: Response): Promise { export function createPdppRemoteSurfaceTransport({ clipboardUrlRef, inputUrlRef, - logDebug, + logDebug: logDebugToHost, presentationAttachmentReadyRef, viewportUrlRef, }: CreatePdppRemoteSurfaceTransportOptions): PdppRemoteSurfaceTransport { @@ -54,6 +54,15 @@ export function createPdppRemoteSurfaceTransport({ let frameSequence = 0; let queuedViewport: RemoteSurfaceMessage | null = null; + // The host logger can schedule a network POST (stream debug telemetry), so a + // disposed transport must not call it: late request failures are not events + // of the live stream. + const logDebug: TransportLogger = (event, payload) => { + if (!disposed) { + logDebugToHost(event, payload); + } + }; + const emit = (message: RemoteSurfaceMessage): void => { if (disposed) { return; @@ -79,12 +88,16 @@ export function createPdppRemoteSurfaceTransport({ } post(url, payload) .then(async (response) => { - if (!response.ok) { - const body = await readResponseBody(response); - throw new Error(responseMessage(body, `Input rejected by server: ${response.status}`)); + if (disposed || response.ok) { + return; } + const body = await readResponseBody(response); + throw new Error(responseMessage(body, `Input rejected by server: ${response.status}`)); }) .catch((error) => { + if (disposed) { + return; + } logDebug("stream.remote_surface.input.error", { error: error instanceof Error ? error.message : String(error), type: payload.type, @@ -173,12 +186,18 @@ export function createPdppRemoteSurfaceTransport({ } try { const response = await postClipboard({ action: "local_to_remote", text, type: "clipboard" }); + if (disposed) { + return false; + } const body = await readResponseBody(response); if (!response.ok) { throw new Error(responseMessage(body, `Clipboard rejected by server: ${response.status}`)); } return !disposed; } catch (error) { + if (disposed) { + return false; + } logDebug("stream.remote_surface.clipboard.error", { error: error instanceof Error ? error.message : String(error), phase: "local-to-remote", diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx index 0d39efe13..4c5e54b47 100644 --- a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx +++ b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer.tsx @@ -145,10 +145,8 @@ import { } from "./stream-viewer-geometry.ts"; import { normalizedPointerButton, readablePointerInput } from "./stream-viewer-pointer-input.ts"; import { parseAttachedMessage } from "./stream-viewer-protocol.ts"; -import { - createPdppRemoteSurfaceTransport, - type PdppRemoteSurfaceTransport, -} from "./stream-viewer-remote-surface-transport.ts"; +import { mountBrowserSurfaceViewer } from "./stream-viewer-browser-surface-lifecycle.ts"; +import type { PdppRemoteSurfaceTransport } from "./stream-viewer-remote-surface-transport.ts"; import { getMountedNekoViewerSession } from "./stream-viewer-session-readiness.ts"; import { createViewportWriters } from "./stream-viewport-writer.ts"; import { sampleVideoSharpnessTelemetry } from "./stream-visual-quality.ts"; @@ -4814,54 +4812,25 @@ function BrowserSurface({ if (!(node && canvas)) { return; } - let disposed = false; - const transport = createPdppRemoteSurfaceTransport({ + return mountBrowserSurfaceViewer({ clipboardUrlRef, + createViewer: (transport) => + createRemoteSurfaceViewer({ + canvas, + initialViewport: seedViewport, + mediaPlane: "canvas-frames", + touchMode: "native", + transport, + }), inputUrlRef, logDebug, + node, + onTransportMounted: (transport) => onTransportMountedRef.current(transport), + onViewerMounted: (viewer) => onViewerMountedRef.current(viewer), presentationAttachmentReadyRef, + transportRef, viewportUrlRef, }); - const viewer = createRemoteSurfaceViewer({ - canvas, - initialViewport: seedViewport, - mediaPlane: "canvas-frames", - touchMode: "native", - transport, - }); - transportRef.current = transport; - transport.setPresentationAttachmentReady(presentationAttachmentReadyRef.current); - try { - const mountPromise = viewer.mount(node); - if (!disposed) { - onTransportMountedRef.current(transport); - onViewerMountedRef.current(viewer); - } - mountPromise.catch((error) => { - logDebug("surface.cdp-frame.viewer_mount_error", { - error: error instanceof Error ? error.message : String(error), - }); - }); - } catch (error) { - logDebug("surface.cdp-frame.viewer_mount_error", { - error: error instanceof Error ? error.message : String(error), - }); - } - return () => { - disposed = true; - transport.setPresentationAttachmentReady(false); - if (transportRef.current === transport) { - transportRef.current = null; - onTransportMountedRef.current(null); - onViewerMountedRef.current(null); - } - try { - // Permit synchronous SDK cleanup before retiring its transport. - viewer.unmount(); - } finally { - transport.dispose(); - } - }; }, [ clipboardUrlRef, containerRef, From 25b55c798cfe0221f5ff43badd36787af2f06a0c Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 5 Nov 2026 12:44:26 -0600 Subject: [PATCH 3/4] fix(console): keep stream attachment state out of viewer teardown Viewer cleanup wrote `false` into the presentation-attachment readiness ref, which the stream owns and every transport reads. Replacing a viewer (for example when the seed viewport size changes) therefore marked a still-attached stream as detached, and the replacement queued every viewport instead of posting it. Cleanup now retires only its own transport; a mount no longer writes the ref either. Mount failures were also mishandled: - A mount rejection after cleanup reached the host logger directly, which can schedule a stream-debug POST for a retired mount. - The rejection handler was registered after the host notifications, so a throwing notification left a later rejection unhandled and the partially mounted viewer alive. Registration of the rejection handler now follows mount() immediately, and a failed initialization and normal cleanup share one idempotent release. Assisted-by: AI Signed-off-by: Tim Nunamaker --- ...m-viewer-browser-surface-lifecycle.test.ts | 139 +++++++++++++++++- ...stream-viewer-browser-surface-lifecycle.ts | 52 ++++--- 2 files changed, 166 insertions(+), 25 deletions(-) diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.test.ts b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.test.ts index 088c445f6..fd7b19513 100644 --- a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.test.ts +++ b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.test.ts @@ -35,10 +35,16 @@ function makeHarness() { const mountedViewers: unknown[] = []; const transportRef: { current: PdppRemoteSurfaceTransport | null } = { current: null }; const fetches: string[] = []; - const createViewer = ((_options: unknown) => { + const logs: string[] = []; + const transports: PdppRemoteSurfaceTransport[] = []; + // The stream, not any viewer, owns attachment readiness; every mount shares it. + const presentationAttachmentReadyRef = { current: true }; + const mounts: { mount: () => Promise } = { mount: () => Promise.resolve() }; + const createViewer = ((transport: PdppRemoteSurfaceTransport) => { + transports.push(transport); const viewer: FakeViewer = { id: viewers.length + 1, - mount: () => Promise.resolve(), + mount: () => mounts.mount(), unmount() { viewer.unmounts += 1; }, @@ -51,7 +57,9 @@ function makeHarness() { clipboardUrlRef: { current: "/clipboard" }, createViewer, inputUrlRef: { current: "/input" }, - logDebug: () => undefined, + logDebug: (event) => { + logs.push(event); + }, node: dom.window.document.createElement("div"), onTransportMounted: (transport) => { mountedTransports.push(transport); @@ -59,12 +67,24 @@ function makeHarness() { onViewerMounted: (viewer) => { mountedViewers.push(viewer); }, - presentationAttachmentReadyRef: { current: true }, + presentationAttachmentReadyRef, transportRef, viewportUrlRef: { current: "/viewport" }, ...overrides, }); - return { createViewer, fetches, mountedTransports, mountedViewers, options, transportRef, viewers }; + return { + createViewer, + fetches, + logs, + mountedTransports, + mountedViewers, + mounts, + options, + presentationAttachmentReadyRef, + transportRef, + transports, + viewers, + }; } async function withFetchLog(fetches: string[], work: () => Promise): Promise { @@ -87,6 +107,115 @@ function postsInput(transport: PdppRemoteSurfaceTransport | null, fetches: strin return fetches.length > before; } +function deferredMount() { + let reject: (error: Error) => void = () => undefined; + const promise = new Promise((_resolve, rejectPromise) => { + reject = rejectPromise; + }); + return { promise, reject }; +} + +/** Sends a viewport and returns whether it was posted, plus the applied ack. */ +function sendViewport(transport: PdppRemoteSurfaceTransport | null, fetches: string[]) { + assert.ok(transport, "expected a transport"); + const ack = new Promise>((resolve) => { + transport.subscribe((message) => resolve(message)); + }); + const before = fetches.length; + transport.send({ height: 600, requestId: 7, type: "viewport", width: 800 }); + return { ack, posted: fetches.length > before }; +} + +test("a replaced viewer keeps the stream's presentation attachment ready, so the new viewer posts viewports", async () => { + const h = makeHarness(); + await withFetchLog(h.fetches, async () => { + const cleanupOld = mountBrowserSurfaceViewer(h.options()); + cleanupOld(); + assert.equal(h.presentationAttachmentReadyRef.current, true, "cleanup of a viewer must not detach the stream"); + mountBrowserSurfaceViewer(h.options()); + const { ack, posted } = sendViewport(h.transports[1] ?? null, h.fetches); + assert.equal(posted, true, "the replacement viewer posts its viewport instead of queueing it"); + assert.deepEqual(await ack, { requestId: 7, type: "viewport-applied" }); + }); +}); + +test("cleaning up an older viewer does not stop the current viewer from posting viewports", async () => { + const h = makeHarness(); + await withFetchLog(h.fetches, async () => { + const cleanupOld = mountBrowserSurfaceViewer(h.options()); + mountBrowserSurfaceViewer(h.options()); + cleanupOld(); + const { ack, posted } = sendViewport(h.transports[1] ?? null, h.fetches); + assert.equal(posted, true, "the current viewer posts its viewport"); + assert.deepEqual(await ack, { requestId: 7, type: "viewport-applied" }); + }); +}); + +test("a mount failure that arrives after cleanup is not logged to the host", async () => { + const h = makeHarness(); + const { promise, reject } = deferredMount(); + h.mounts.mount = () => promise; + const cleanup = mountBrowserSurfaceViewer(h.options()); + cleanup(); + reject(new Error("late mount failure")); + // Registered after the lifecycle's handler, so it settles after it. + await promise.catch(() => undefined); + assert.deepEqual(h.logs, []); +}); + +test("a mount failure on a live mount is logged to the host", async () => { + const h = makeHarness(); + const { promise, reject } = deferredMount(); + h.mounts.mount = () => promise; + mountBrowserSurfaceViewer(h.options()); + reject(new Error("mount failure")); + await promise.catch(() => undefined); + assert.deepEqual(h.logs, ["surface.cdp-frame.viewer_mount_error"]); +}); + +test("a throwing mount notification during initialization retires the mount and handles its pending mount failure", async () => { + const h = makeHarness(); + const { promise, reject } = deferredMount(); + h.mounts.mount = () => promise; + await withFetchLog(h.fetches, async () => { + const cleanup = mountBrowserSurfaceViewer( + h.options({ + onViewerMounted: (viewer) => { + if (viewer) { + throw new Error("init callback failed"); + } + }, + }) + ); + assert.equal(h.viewers[0]?.unmounts, 1, "the partially mounted viewer is unmounted"); + assert.equal(postsInput(h.transports[0] ?? null, h.fetches), false, "its transport is disposed"); + assert.equal(h.transportRef.current, null); + assert.deepEqual(h.mountedTransports, [h.transports[0], null], "the host is told the mount is gone"); + assert.deepEqual(h.logs, ["surface.cdp-frame.viewer_mount_error"]); + reject(new Error("late mount failure")); + await promise.catch(() => undefined); + assert.deepEqual(h.logs, ["surface.cdp-frame.viewer_mount_error"], "the late failure is not logged"); + cleanup(); + assert.equal(h.viewers[0]?.unmounts, 1, "release is idempotent"); + }); +}); + +test("a failed initialization whose release also throws still retires the mount without throwing", async () => { + const h = makeHarness(); + await withFetchLog(h.fetches, async () => { + mountBrowserSurfaceViewer( + h.options({ + onTransportMounted: () => { + throw new Error("callback failed"); + }, + }) + ); + assert.equal(h.viewers[0]?.unmounts, 1); + assert.equal(postsInput(h.transports[0] ?? null, h.fetches), false); + assert.deepEqual(h.logs, ["surface.cdp-frame.viewer_mount_error", "surface.cdp-frame.viewer_mount_error"]); + }); +}); + test("a throwing mount notification during cleanup still unmounts the viewer and disposes the transport", async () => { const h = makeHarness(); let failNotifications = false; diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.ts b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.ts index 053ced588..3f8ba5a34 100644 --- a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.ts +++ b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.ts @@ -19,9 +19,14 @@ export interface MountBrowserSurfaceViewerOptions extends TransportOptions { /** * Mounts one Remote Surface viewer over one PDPP transport and returns its - * cleanup. Each mount owns its transport: cleanup always retires it, even when - * a mount notification or the viewer's own unmount throws, so a retired + * cleanup. Each mount owns its transport and retires it through one idempotent + * release, used both for normal cleanup and for a failed initialization, even + * when a mount notification or the viewer's own unmount throws, so a retired * viewer cannot keep posting input to the stream. + * + * Presentation-attachment readiness belongs to the stream, not to a viewer: + * a mount never writes it, so replacing a viewer cannot mark a still-attached + * stream as detached. */ export function mountBrowserSurfaceViewer({ createViewer, @@ -31,31 +36,20 @@ export function mountBrowserSurfaceViewer({ transportRef, ...transportOptions }: MountBrowserSurfaceViewerOptions): () => void { - let disposed = false; const transport = createPdppRemoteSurfaceTransport(transportOptions); const viewer = createViewer(transport); transportRef.current = transport; - transport.setPresentationAttachmentReady(transportOptions.presentationAttachmentReadyRef.current); - try { - const mountPromise = viewer.mount(node); - if (!disposed) { - onTransportMounted(transport); - onViewerMounted(viewer); - } - mountPromise.catch((error) => { - transportOptions.logDebug("surface.cdp-frame.viewer_mount_error", { - error: error instanceof Error ? error.message : String(error), - }); - }); - } catch (error) { + let released = false; + const logMountError = (error: unknown) => transportOptions.logDebug("surface.cdp-frame.viewer_mount_error", { error: error instanceof Error ? error.message : String(error), }); - } - return () => { - disposed = true; + const release = () => { + if (released) { + return; + } + released = true; try { - transport.setPresentationAttachmentReady(false); if (transportRef.current === transport) { transportRef.current = null; onTransportMounted(null); @@ -70,4 +64,22 @@ export function mountBrowserSurfaceViewer({ } } }; + try { + viewer.mount(node).catch((error) => { + // A retired mount's late failure is not an event of the live stream. + if (!released) { + logMountError(error); + } + }); + onTransportMounted(transport); + onViewerMounted(viewer); + } catch (error) { + logMountError(error); + try { + release(); + } catch (releaseError) { + logMountError(releaseError); + } + } + return release; } From 553fc01ab1972bfd67c7a18379f30ce031cb4d5c Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 5 Nov 2026 15:46:04 -0600 Subject: [PATCH 4/4] fix(console): keep a failing host logger from skipping mount release The mount's diagnostics logger runs inside the initialization `catch` before `release()`, and inside the mount-rejection handler. If the host logger threw, `release()` never ran and the viewer and transport leaked, and the rejection handler produced an unhandled rejection. The logger call is now wrapped so it can neither skip release nor escape. `release` also notifies the viewer unmount in a `finally` after the transport unmount notification, so a throwing transport notification no longer skips it. Assisted-by: AI Signed-off-by: Tim Nunamaker --- ...m-viewer-browser-surface-lifecycle.test.ts | 58 +++++++++++++++++++ ...stream-viewer-browser-surface-lifecycle.ts | 22 +++++-- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.test.ts b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.test.ts index fd7b19513..25c86a8c4 100644 --- a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.test.ts +++ b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.test.ts @@ -216,6 +216,64 @@ test("a failed initialization whose release also throws still retires the mount }); }); +const throwingLogger = () => { + throw new Error("logger failed"); +}; + +test("a throwing host logger cannot skip releasing a mount whose initialization failed", async () => { + const h = makeHarness(); + await withFetchLog(h.fetches, async () => { + mountBrowserSurfaceViewer( + h.options({ + logDebug: throwingLogger, + onViewerMounted: (viewer) => { + if (viewer) { + throw new Error("init callback failed"); + } + }, + }) + ); + assert.equal(h.viewers[0]?.unmounts, 1, "the viewer is unmounted"); + assert.equal(postsInput(h.transports[0] ?? null, h.fetches), false, "the transport is disposed"); + assert.equal(h.transportRef.current, null); + }); +}); + +test("a throwing host logger does not turn a live mount failure into an unhandled rejection", async () => { + const h = makeHarness(); + const { promise, reject } = deferredMount(); + h.mounts.mount = () => promise; + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", onUnhandled); + try { + mountBrowserSurfaceViewer(h.options({ logDebug: throwingLogger })); + reject(new Error("mount failure")); + await promise.catch(() => undefined); + // Node reports unhandled rejections once the microtask queue drains. + await new Promise((resolve) => setImmediate(resolve)); + } finally { + process.off("unhandledRejection", onUnhandled); + } + assert.deepEqual(unhandled, []); +}); + +test("a throwing transport notification during cleanup still notifies the viewer's release", () => { + const h = makeHarness(); + const cleanup = mountBrowserSurfaceViewer( + h.options({ + onTransportMounted: (transport) => { + if (!transport) { + throw new Error("callback failed"); + } + }, + }) + ); + assert.throws(cleanup, /callback failed/); + assert.deepEqual(h.mountedViewers.at(-1), null, "the viewer notification still runs"); + assert.equal(h.viewers[0]?.unmounts, 1); +}); + test("a throwing mount notification during cleanup still unmounts the viewer and disposes the transport", async () => { const h = makeHarness(); let failNotifications = false; diff --git a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.ts b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.ts index 3f8ba5a34..b2040dc70 100644 --- a/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.ts +++ b/apps/console/src/app/(console)/syncs/[runId]/stream/stream-viewer-browser-surface-lifecycle.ts @@ -40,10 +40,17 @@ export function mountBrowserSurfaceViewer({ const viewer = createViewer(transport); transportRef.current = transport; let released = false; - const logMountError = (error: unknown) => - transportOptions.logDebug("surface.cdp-frame.viewer_mount_error", { - error: error instanceof Error ? error.message : String(error), - }); + // Diagnostics only: a throwing host logger must neither skip release nor + // escape from the mount-rejection handler as an unhandled rejection. + const logMountError = (error: unknown) => { + try { + transportOptions.logDebug("surface.cdp-frame.viewer_mount_error", { + error: error instanceof Error ? error.message : String(error), + }); + } catch { + // Nothing left to report to. + } + }; const release = () => { if (released) { return; @@ -52,8 +59,11 @@ export function mountBrowserSurfaceViewer({ try { if (transportRef.current === transport) { transportRef.current = null; - onTransportMounted(null); - onViewerMounted(null); + try { + onTransportMounted(null); + } finally { + onViewerMounted(null); + } } } finally { try {