Skip to content

fix(console): stop the stream transport posting input after viewer teardown - #621

Open
tnunamak wants to merge 4 commits into
mainfrom
fix/stream-transport-dispose
Open

tnunamak wants to merge 4 commits into
mainfrom
fix/stream-transport-dispose

Conversation

@tnunamak

@tnunamak tnunamak commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

When the streaming browser view closes or reconnects, BrowserSurface unmounts the Remote Surface viewer but leaves its PDPP transport live. A late keystroke, viewport update, selection read or clipboard write then POSTs to the old stream's endpoints, and late replies still reach subscribers. A paste whose clipboard read finishes after a reconnect can reach the replacement stream through the shared endpoint refs.

This adds dispose() to createPdppRemoteSurfaceTransport (stream-viewer-remote-surface-transport.ts). After it runs, the transport is inert and idempotent:

  • send, subscribe, receiveSseMessage and setPresentationAttachmentReady do nothing, and emit drops replies already in flight.
  • The host logger is never called again. That logger batches events into POST /api/stream-debug, so a request failing after disposal would otherwise use the network. Late rejections and non-2xx replies for input and clipboard are dropped before they are logged.
  • sendClipboardText returns false, including for a request that was already sent when disposal happened.
  • The queued viewport and subscribers are cleared.

The viewer mount and cleanup moved from the BrowserSurface effect into mountBrowserSurfaceViewer (stream-viewer-browser-surface-lifecycle.ts). The page still builds the viewer, so its configuration is unchanged. Cleanup unmounts the viewer and then disposes the transport in a nested finally, so a throwing mount notification or a throwing viewer.unmount() still retires the transport. Failed initialization and normal cleanup share one idempotent release. Diagnostics logging cannot throw out of the mount or skip release, and a throwing transport notification no longer skips the viewer notification.

The mount does not write presentation-attachment readiness: the stream owns that fact (the page sets it as the SSE attachment comes and goes) and every transport only reads it. Before, cleanup wrote false, so replacing a viewer (for example on a seed-viewport size change, which remounts BrowserSurface without a new attachment) left the replacement queueing every viewport. A mount rejection now registers its handler right after mount() and is not logged once the mount is retired.

Not changed: requests already on the wire cannot be recalled; only their effects on the viewer are dropped. The /input schema is untouched (that is #338).

Tests: stream-viewer-remote-surface-transport.test.ts (14) and stream-viewer-browser-surface-lifecycle.test.ts (14). The lifecycle tests mount the real mountBrowserSurfaceViewer from a React component under jsdom: StrictMode mount, cleanup and remount; a fast remount that must not dispose the new mount's transport; a stale cleanup that must not clear the newer mount; a throwing notification or unmount; the readiness ref shared across sequential and overlapping mounts, asserting the replacement's viewport POST and ack; a mount rejection after cleanup with a recording host logger; a throwing notification during initialization with a pending mount rejection; and a throwing host logger on the initialization-failure and live-rejection paths. They do not mount BrowserSurface itself: it is a private component inside a 6,000-line file that pulls in CSS and the whole stream page, so the tests drive the extracted function through the same useEffect shape.

Against the previous head (5e76f48), 4 of the new tests fail: rejected input fetch after dispose, late non-2xx input after dispose, clipboard failure after dispose (each posts to /api/stream-debug), and a throwing notification skipping unmount and dispose. The remaining new tests are regression guards that pass on both. The 5 new readiness, late-rejection and initialization-failure tests fail against the earlier head (9ccd782) and pass now. All 14 lifecycle tests pass; the 3 added in the last commit fail against 25b55c7. The other stream-directory tests are unaffected by that commit (293 passed in the full-directory run before it); npm run types:check in apps/console passes. ultracite check shows the same findings on stream-viewer.tsx as main, minus one useExhaustiveDependencies (the extracted effect).

stream-viewer.tsx is also edited by #620 (connector icons) at other lines (1577-1661, ~5954, ~6001); the hunks are disjoint.

Assisted-by: AI

…ardown

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 <tnunamak@gmail.com>
…eardown

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 <tnunamak@gmail.com>
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 <tnunamak@gmail.com>
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 <tnunamak@gmail.com>
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.

1 participant