Skip to content

feat(web): resume hosted terminal scrollback from the last byte#136

Open
0xabstracted wants to merge 3 commits into
fix/web-pty-reconnect-on-wakefrom
feat/durable-web-terminal
Open

feat(web): resume hosted terminal scrollback from the last byte#136
0xabstracted wants to merge 3 commits into
fix/web-pty-reconnect-on-wakefrom
feat/durable-web-terminal

Conversation

@0xabstracted

@0xabstracted 0xabstracted commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

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.

Stacked on #140. This PR is based on fix/web-pty-reconnect-on-wake and shows only its own
3 commits / 13 files. #140 is the foundation (reconnect on wake, heartbeat, standalone restore,
device-query protection) and must merge first; once it lands this PR will be retargeted to
main.

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

  • Provider-neutral raw byte offsets. The client sends ?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 of
    every attach: next is the authoritative raw resume offset the client snaps to; base is the
    start of the replay slice (a base below what the client already consumed signals a rewind);
    gap is raw bytes evicted below base (honest bounded loss → terminal reset); hasReplay says
    whether one display-only binary replay frame follows.
  • OutputBuffer (1 MiB). New offset-tracked scrollback ring per PTY: a bounded tail of recent
    chunks plus a monotonic count of every byte ever produced, so replayFrom(offset) returns only
    the bytes after that offset (no duplication) or an explicit gap. Cap is 1 MiB (4× the old
    256 KB) to shrink the eviction/gap window. The append hot path is allocation-free.
  • Sanitized replay is display-only — no offset drift. Device QUERY/REPORT sequences (CPR/DA)
    are stripped from the replayed bytes for display only, in PtyHostService.getReplay. The
    offset 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.
  • Streaming decoder persistence. The client's TextDecoder persists 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, a base rewind, or a logical end/close.
  • Respawn / exit correctness. kill(sessionId, notify) distinguishes an explicit user stop
    (notify=true → emit exit frame) from an in-place respawn (notify=false → let the client
    reconnect and resume onto the new PTY at offset 0). A stale-onExit guard ignores the
    asynchronous onExit of a PTY entry that has already been replaced or removed, so a respawn
    never 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.ts
here actually shrinks (−43 / +12) as the repaint decision moves out of the reconnect path and
into the attached ack; nothing is duplicated.

Blast radius (13 files, +1690 / −205)

  • maestro-server: OutputBuffer.ts (new ring), PtyHostService.ts (per-PTY buffer,
    kill(notify), stale-onExit guard, display-only getReplay), PtyWebSocketServer.ts (attach
    handshake: resolve replay → sizeattached → replay frame → subscribe),
    sessionRoutes.ts (offset query wiring); + 4 server test files.
  • maestro-ui: ptyProtocol.ts (new provider-neutral control-frame parser),
    terminal.ts (offset accounting, attached handling, decoder persistence, reset-on-gap/rewind);
    • ptyProtocol.test.ts, webTerminalOffsetResume.test.ts, and the slimmed
      webTerminalReconnect.test.ts.
  • maestro-cli: none.
  • maestro-web: none.

Test plan

  • Server (Jest) — focused suites for this change: 50 passing
    (output-buffer, pty-host-service, pty-websocket-server, pty-host-scrollback-query-strip).
  • Server full suite: 388 passing + 8 todo.
  • UI (Vitest) — focused terminal/protocol suites: 23 passing
    (ptyProtocol, webTerminalOffsetResume, webTerminalReconnect).
  • UI full suite: 432 passing; the only failure is 1 pre-existing ResourcesView test
    unrelated 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.
  • Manual: plain-shell hosted terminal — resume verified across >512 KB of scrollback, at
    the 1 MiB cap (gap path), and with device-query sequences in history (no duplication,
    no drift).
  • Codex review pass: 5/5 with no live model call.

Follow-ups

Independent hardening tracked separately (none block this PR):

Now implemented by stacked follow-up PR #155 (fix/web-terminal-followup-hardening
feat/durable-web-terminal), which builds directly on this branch and lands all five
(#150#154). Merge order: #140#136#155.

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-replay
resume.

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.
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