feat(web): resume hosted terminal scrollback from the last byte#136
Open
0xabstracted wants to merge 3 commits into
Open
feat(web): resume hosted terminal scrollback from the last byte#1360xabstracted wants to merge 3 commits into
0xabstracted wants to merge 3 commits into
Conversation
Layer a raw byte-offset resume protocol onto #140's reconnect lifecycle so a dropped /pty socket resumes exactly where it left off instead of replaying the full ring and resetting xterm on every reconnect. - ptyProtocol.ts: provider-neutral parser for the server->client text control frames, adding the attached{base,gap,next,hasReplay} resume ack alongside exit/size. No provider- or transport-specific branching. - terminal.ts: track a per-session raw byte offset, send it as ?offset= on every (re)connect, and snap it authoritatively to attached.next. Live binary frames advance the offset by their raw byteLength; the one display-only replay frame is rendered but never counted (offsets count raw PTY bytes, the replay slice is sanitized/shorter). The streaming UTF-8 decoder now persists across a normal reconnect so a glyph split across the disconnect boundary is completed by the replay delta, and onReattach is deferred to the ack: it fires (and the decoder resets) only on a gap (eviction) or a base-rewind (cross-stream respawn). #140's abnormal-close/backoff, wake re-attach, and logical-end (1011 / exit-frame) behaviors are retained unchanged.
Introduce OutputBuffer: a transport-agnostic buffer that counts every raw
byte a PTY has produced (monotonic totalBytes) and retains a bounded 1 MiB
tail. replayFrom(offset) returns a ReplaySlice{base,gap,next,data} so a
reconnecting client can resume from the exact byte offset it last saw —
delta-only replay, an explicit gap for evicted bytes, and next as the
authoritative raw resume offset (decoupled from data so a higher layer can
sanitize the display bytes without drifting the offset accounting).
Layer byte-accurate scrollback resume onto the durable server-hosted PTY.
PtyHostService now keeps an OutputBuffer per session and exposes
getReplay(sessionId, fromOffset): the replay slice's base/gap/next stay RAW
(same coordinate space as the live stream and the client's receive counter)
while only data is sanitized via stripScrollbackDeviceQueries, so device-query
stripping can neither duplicate nor skip scrollback on the next reconnect.
addSubscriber becomes join-only. onExit gains a stale-exit identity guard so a
killed PTY's late async exit cannot delete a freshly respawned entry or stomp
a status the stop path set. kill(notify=true) sends a terminal exit frame for
an explicit stop; the replace/respawn path passes notify=false so the client
reconnects and resumes instead of finalizing.
PtyWebSocketServer parses ?offset=, calls getReplay first (null -> close 1011
before subscribing), then sends size, an attached{base,gap,next,hasReplay}
handshake, and a single sanitized replay frame, warning on an eviction gap.
The #140 protocol-level heartbeat is untouched.
The /sessions/:id/resume route only spawns when no live PTY exists
(hasSession guard), mirroring /pty/spawn, so resuming a still-alive session
never kills and replaces its PTY under an attached client.
ee4f67a to
7d2cd7f
Compare
This was referenced Jul 13, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Resume a hosted web terminal's scrollback from the exact byte it last showed, so a reconnect
(refresh, laptop wake, dropped socket) picks up mid-stream with no duplicated and no lost output.
User experience
Before: a reconnecting hosted terminal either restarted from a blank screen or re-painted
scrollback it had already shown (duplicated output), and multi-byte glyphs straddling the
reconnect seam could corrupt. After: the client tells the server the raw byte offset it last
consumed; the server replays exactly the bytes after it (or an honest, bounded truncation marker
when history has aged out), and the live stream continues seamlessly from there.
What changed
?offset=<rawBytes>on every(re)connect and snaps its receive counter to the server-authoritative end-of-stream offset.
Offsets are counted in RAW PTY bytes end-to-end, so live output and replay share one coordinate
space — no provider- or transport-specific branching.
attached{base,gap,next,hasReplay}resume handshake. One control frame sent at the start ofevery attach:
nextis the authoritative raw resume offset the client snaps to;baseis thestart of the replay slice (a
basebelow what the client already consumed signals a rewind);gapis raw bytes evicted belowbase(honest bounded loss → terminal reset);hasReplaysayswhether one display-only binary replay frame follows.
OutputBuffer(1 MiB). New offset-tracked scrollback ring per PTY: a bounded tail of recentchunks plus a monotonic count of every byte ever produced, so
replayFrom(offset)returns onlythe bytes after that offset (no duplication) or an explicit
gap. Cap is 1 MiB (4× the old256 KB) to shrink the eviction/gap window. The append hot path is allocation-free.
are stripped from the replayed bytes for display only, in
PtyHostService.getReplay. Theoffset the client counts (raw
next) is deliberately decoupled from the bytes it paints(possibly shortened), so stripping can never duplicate or skip scrollback on the next reconnect.
The live stream is never sanitized.
TextDecoderpersists across a normal resume(a multi-byte glyph split across the reconnect seam still decodes correctly) and is dropped only
on a real discontinuity —
gap > 0, abaserewind, or a logical end/close.kill(sessionId, notify)distinguishes an explicit user stop(
notify=true→ emit exit frame) from an in-place respawn (notify=false→ let the clientreconnect and resume onto the new PTY at offset 0). A stale-
onExitguard ignores theasynchronous
onExitof a PTY entry that has already been replaced or removed, so a respawnnever finalizes the new stream.
Not in scope (owned by #140)
This PR does not re-implement reconnect-on-wake, the WebSocket heartbeat, standalone-terminal
restore, or the initial device-query protection — those are #140's.
webTerminalReconnect.test.tshere actually shrinks (−43 / +12) as the repaint decision moves out of the reconnect path and
into the
attachedack; nothing is duplicated.Blast radius (13 files, +1690 / −205)
OutputBuffer.ts(new ring),PtyHostService.ts(per-PTY buffer,kill(notify), stale-onExit guard, display-onlygetReplay),PtyWebSocketServer.ts(attachhandshake: resolve replay →
size→attached→ replay frame → subscribe),sessionRoutes.ts(offset query wiring); + 4 server test files.ptyProtocol.ts(new provider-neutral control-frame parser),terminal.ts(offset accounting,attachedhandling, decoder persistence, reset-on-gap/rewind);ptyProtocol.test.ts,webTerminalOffsetResume.test.ts, and the slimmedwebTerminalReconnect.test.ts.Test plan
(
output-buffer,pty-host-service,pty-websocket-server,pty-host-scrollback-query-strip).(
ptyProtocol,webTerminalOffsetResume,webTerminalReconnect).ResourcesViewtestunrelated to this change.
webStandaloneRestore: 9 passing (confirms fix: make hosted web terminals survive disconnects and refresh #140's standalone restore is unregressed).bun run build:web(UI web bundle) builds clean.the 1 MiB cap (gap path), and with device-query sequences in history (no duplication,
no drift).
Follow-ups
Independent hardening tracked separately (none block this PR):
/ptyptyProtocolparser beforemaestro-webterminal parity./pty/stopreturns 500 for PTY-only session ids (no backing session).Rollback
Revert the merge. Client stays backward-compatible with a pre-offset server (an absent/invalid
?offset=yields a full replay), so reverting the server alone degrades gracefully to full-replayresume.