Skip to content

fix(web): feed terminals from reconnecting session sockets - #78

Open
dhaern wants to merge 1 commit into
shekohex:mainfrom
dhaern:fix/web-terminal-socket-feed
Open

dhaern wants to merge 1 commit into
shekohex:mainfrom
dhaern:fix/web-terminal-socket-feed

Conversation

@dhaern

@dhaern dhaern commented Oct 6, 2026

Copy link
Copy Markdown

Summary

The web terminal takes its content from two places: an HTTP request for the buffer, and the WebSocket stream for new output. The page also keeps a second copy of everything the session printed in React state, and it never reopens the WebSocket after it drops. This PR makes the WebSocket the only source. A subscribe reply now carries the current buffer, live output follows it on the same connection, and the page reconnects by itself.

In plain terms: the web page asked for a terminal's history in one request and then listened for new output in another, so lines could be missed or shown twice around the moment it started listening. It also stored a full copy of the output in the page, and a dropped connection left the page dead until you reloaded it. Now the page opens one connection per terminal, gets the history and the live output from it in order, and reconnects after a drop.

Cause

  • App appended every chunk to rawOutput (prev + rawData) and passed it down to xterm, which already holds the output. That copy grows with the session for as long as the page is open.
  • The initial content came from api.session.buffer.raw over HTTP, separate from the subscribe message on the WebSocket. Output produced between the two requests could be missed or appear in both.
  • useWebSocket only set connected to false on onclose and never reconnected, so a dropped socket stayed dropped. Its effect also depended on activeSession and three callbacks, so the socket was rebuilt whenever any of them changed.

Changes

Server:

  • handlers/websocket.ts and shared/types.ts: the subscribed reply includes rawData, the buffer at subscribe time. The HTTP raw-buffer endpoints stay.

Client:

  • New hooks/session-socket.ts (connectSessionSocket): opens a socket for one session, sends session_list and subscribe on open, hands subscribed to onSnapshot and the session's raw_data to onRawData, reconnects after RETRY_DELAY on close, and ignores messages from sockets that were closed on purpose.
  • use-web-socket.ts: one socket per selected session id, using the module above. Autoselection of the first running session stays. sendInput returns whether the socket was open.
  • terminal-renderer.tsx: no rawOutput prop and no componentDidUpdate diffing. It exposes write(chunk) and reset(snapshot) through a ref. reset writes ESC c (RIS) followed by the snapshot through xterm's write queue. Terminal.reset() acts outside that queue, so queued writes and a half-parsed escape sequence could corrupt the first characters of a snapshot.
  • app.tsx: writes straight to the terminal through the ref, and no longer fetches the buffer over HTTP.
  • use-session-manager.ts: input goes through the WebSocket and falls back to HTTP POST when the socket is not open, as before. handleSessionClick is gone, since selecting a session only changes activeSession and the socket effect subscribes.

Behavior changes

  • The subscribed WebSocket message gains a rawData field. Existing clients ignore it.
  • After a dropped connection the page reconnects and redraws the terminal from a fresh snapshot.
  • The debug line under the terminal no longer shows the character count (Debug: active: ..., WS raw_data: n, session_updates: n).

Validation

Three new tests:

  • test/websocket.test.ts: the snapshot arrives before later live output, and nothing from the snapshot is replayed. It fails on main, where subscribed has no rawData.
  • test/session-socket.test.ts is new and covers the new module, so it cannot run on main. One test checks that it reconnects and resubscribes after an unexpected close, the other that it ignores output from other sessions and from sockets that were closed.

Four mutations each make one of them fail: removing the reconnect, accepting raw_data from any session, dropping the closed guards, and replying with an empty snapshot.

bun test over test/*.test.ts, without the npm-pack and live suites, gives 187 passing. bun run typecheck, the build typecheck, bun run lint, bunx biome format . and vite build are clean. The CI workflow passes on this commit in my fork, including the Playwright e2e in Chromium and Firefox.

I also subscribed over the WebSocket to a session with about 350 kB of output on a local OpenCode host running this change together with my other PTY fixes. The snapshot arrived complete and live output kept coming after it. I did not reproduce the snapshot ordering problem on main in a browser, so the ordering is covered by the protocol-level test only. I did not drive a reconnect by hand in a browser.

Diff

Production: -99 lines (+129/-228) across src/web/client/**, src/web/server/handlers/websocket.ts and src/web/shared/types.ts, including 73 lines for the new session-socket.ts.
Tests: +119 lines (+120/-1), one new file (test/session-socket.test.ts) and one changed (test/websocket.test.ts).

Add rawData to subscribed replies to order snapshots before live output.
Write directly to xterm, isolate session sockets, and retry after disconnects.
Reset the terminal in band (RIS) so queued writes and a half-parsed escape
sequence cannot corrupt a snapshot. Keep HTTP input fallback and existing
raw-buffer endpoints.
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