diff --git a/apps/cli/src/attach.ts b/apps/cli/src/attach.ts index e80ddda..5deb776 100644 --- a/apps/cli/src/attach.ts +++ b/apps/cli/src/attach.ts @@ -2,16 +2,18 @@ * attach.ts — `fleet client attach`: drive a workspace's webterm terminal from a * real TTY. * - * Output direction: the server streams full grid snapshots, which we repaint with - * `renderGrid`. Input direction is trivial — a TTY in raw mode already emits the - * exact byte sequences a PTY expects (arrows, ctrl chars, …), so we forward raw - * stdin straight through as `input` messages. Ctrl-] detaches without killing the - * shell (the tmux session survives for re-attach). + * Output direction: the server streams snapshots and deltas, which a `GridStream` + * folds back into full grids for `renderGrid` to repaint (the renderer always + * paints the whole screen). Input direction is trivial — a TTY in raw mode + * already emits the exact byte sequences a PTY expects (arrows, ctrl chars, …), + * so we forward raw stdin straight through as `input` messages. Ctrl-] detaches + * without killing the shell (the tmux session survives for re-attach). */ import type { WorkspaceStatus } from "fleet-protocol"; import { decodeServerMessage, + GridStream, splitInput, TERMINAL_CONFLICT_CLOSE_CODE, TERMINAL_TAKEOVER_CLOSE_CODE, @@ -84,6 +86,7 @@ export async function attachToWorkspace(shipUrl: string, repo: string, name: str const ws = new WebSocket(terminalWsUrl(shipUrl, repo, name)); let torn = false; + const stream = new GridStream(); const send = (msg: ClientMsg) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); }; @@ -137,8 +140,13 @@ export async function attachToWorkspace(shipUrl: string, repo: string, name: str ws.onmessage = (event) => { const msg = decodeServerMessage(event.data); - if (msg.type === "grid") process.stdout.write(renderGrid(msg)); - else teardown(msg.code); + if (msg.type === "exit") { + teardown(msg.code); + return; + } + const { grid, reply } = stream.accept(msg); + if (reply) send(reply); + if (grid) process.stdout.write(renderGrid(grid)); }; ws.onerror = () => { diff --git a/apps/cli/tests/render-grid.test.ts b/apps/cli/tests/render-grid.test.ts index f30157f..8c09637 100644 --- a/apps/cli/tests/render-grid.test.ts +++ b/apps/cli/tests/render-grid.test.ts @@ -5,6 +5,7 @@ import { renderGrid } from "../src/render-grid"; function grid(cells: WireCell[][], cursor?: Partial): GridMsg { return { type: "grid", + seq: 0, rows: cells.length, cols: cells[0]?.length ?? 0, cursor: { x: 0, y: 0, visible: true, ...cursor }, diff --git a/apps/docs/src/content/docs/concepts/terminals.md b/apps/docs/src/content/docs/concepts/terminals.md index b7ca0e0..23d8d42 100644 --- a/apps/docs/src/content/docs/concepts/terminals.md +++ b/apps/docs/src/content/docs/concepts/terminals.md @@ -60,12 +60,15 @@ frames are text; a binary frame closes the connection. | `init` | `cols`, `rows` | first message: allocate the emulator and spawn the PTY at this size | | `input` | `data` | keystrokes or pasted bytes to write to the PTY | | `resize` | `cols`, `rows` | resize both the PTY and the emulator | +| `ack` | `seq` | this frame arrived and was painted | +| `resync` | — | the frame sequence broke; send a full snapshot | **Server → client** | Message | Payload | Meaning | |---|---|---| -| `grid` | `cols`, `rows`, `cursor`, `cells` | a full snapshot of the active screen | +| `grid` | `seq`, `cols`, `rows`, `cursor`, `cells` | a full snapshot of the active screen | +| `patch` | `seq`, `cols`, `rows`, `cursor`, `runs` | the cells that changed since frame `seq - 1` | | `exit` | `code` | the process exited; the connection is closing | `init` must be the first message and must be sent exactly once — sending it @@ -77,23 +80,42 @@ Sizes are bounded (1–1024 columns, 1–512 rows) and a single `input` is cappe 256 KiB, which the client-side helper handles by splitting large pastes into chunks on UTF-8 character boundaries. -### Frames are full snapshots - -There is no scrollback protocol, no incremental cell diffing, and no escape -sequences on the wire. When the PTY produces output, the ship feeds the raw -bytes to [`bun-vt`](/packages/bun-vt/) — a pure-TypeScript port of libghostty's -VT emulation — and schedules a frame. Frames are coalesced at roughly 16 ms -(~60 fps), so a burst of output produces one snapshot rather than thousands. - -To keep those snapshots small, cells use a compact encoding: a blank default -cell — a space, default colors, no styling, which is most of a screen — +### Frames are cells, not escape sequences + +There is no scrollback protocol and no escape sequences on the wire. When the PTY +produces output, the ship feeds the raw bytes to [`bun-vt`](/packages/bun-vt/) — +a pure-TypeScript port of libghostty's VT emulation — and schedules a frame. +Frames are coalesced at roughly 16 ms (~60 fps), so a burst of output produces +one frame rather than thousands, and an interval in which nothing changed +produces none at all. + +Two frame types carry the screen. A `grid` is a **complete** snapshot: it can be +applied to any state, which makes it self-syncing. A `patch` carries only the +runs of cells that changed, and is valid only against the frame numbered +`seq - 1`. A connection opens with a `grid`, and so does the first frame after a +resize — a patch cannot cross a size change — or after a client asks to `resync`. +Everything else is a patch. + +That is safe alongside the coalescing above because the diff baseline is the last +frame the ship actually **sent**, not the last one it computed. Frames the ship +chose to skip are simply folded into the next patch. + +Skipping is the other half of the design. The client acks every frame it paints, +and the ship stops producing once too many frames are unacked, so a slow link +makes the terminal coarser rather than putting it further and further behind. +Every WebSocket in the chain also negotiates permessage-deflate, which these +frames — repetitive JSON, mostly blank cells — compress extremely well. + +To keep frames small in the first place, cells use a compact encoding: a blank +default cell — a space, default colors, no styling, which is most of a screen — serializes as the literal number `0`. Anything else is an object carrying only its non-default fields: character, foreground, background, an attribute bitmask, underline style, and cell width. -The upshot is that the browser side is genuinely simple: decode, paint cells to -a canvas, encode key events back. The [webterm reference](/packages/webterm/) -has the full table of attribute bits and color forms. +The upshot is that the browser side stays simple: decode, fold the frame into +the current snapshot with the shared `GridStream` helper, paint cells to a +canvas, encode key events back. The [webterm reference](/packages/webterm/) has +the full table of attribute bits and color forms. ### One terminal per workspace diff --git a/apps/docs/src/content/docs/packages/webterm.md b/apps/docs/src/content/docs/packages/webterm.md index b4e88ad..d39f634 100644 --- a/apps/docs/src/content/docs/packages/webterm.md +++ b/apps/docs/src/content/docs/packages/webterm.md @@ -7,10 +7,11 @@ sidebar: `webterm` carries a live terminal over a WebSocket. Its defining choice is where the emulation happens: **the server is the terminal emulator**. It spawns a PTY, -parses the raw VT bytes with [`bun-vt`](/packages/bun-vt/), and streams full -cell-grid snapshots to the client. The client only paints cells and sends -keystrokes — it never sees an escape sequence, never tracks cursor state, and -never needs a terminal emulator of its own. +parses the raw VT bytes with [`bun-vt`](/packages/bun-vt/), and streams the cell +grid to the client — a full snapshot to open the connection, deltas from then +on. The client only paints cells and sends keystrokes — it never sees an escape +sequence, never tracks cursor state, and never needs a terminal emulator of its +own. The package has two halves: @@ -38,25 +39,43 @@ there is a dedicated close code for them. | `init` | `{ type: "init", cols, rows }` | First message. Allocate a terminal and spawn the PTY at this size. | | `input` | `{ type: "input", data }` | Keystrokes or paste bytes to write to the PTY. | | `resize` | `{ type: "resize", cols, rows }` | Resize both the VT parser and the PTY. | +| `ack` | `{ type: "ack", seq }` | Acknowledge receipt of the server frame with this `seq`. | +| `resync` | `{ type: "resync" }` | Ask for a full `grid` frame — recovery from a sequence gap. | -`cols` must be an integer in `[1, 1024]`, `rows` in `[1, 512]`, and `data` at -most 256 KiB **measured as UTF-8**, not as JavaScript string length. All three -schemas are strict objects: an unknown extra field is a decode failure. +`cols` must be an integer in `[1, 1024]`, `rows` in `[1, 512]`, `data` at most +256 KiB **measured as UTF-8**, not as JavaScript string length, and `seq` a +non-negative integer. Every schema is a strict object: an unknown extra field is +a decode failure. ### Server to client | Message | Shape | Meaning | | --- | --- | --- | -| `grid` | `{ type: "grid", cols, rows, cursor, cells }` | A full snapshot of the active screen. | +| `grid` | `{ type: "grid", seq, cols, rows, cursor, cells }` | A full snapshot of the active screen. | +| `patch` | `{ type: "patch", seq, cols, rows, cursor, runs }` | A delta against the frame with `seq - 1`. | | `exit` | `{ type: "exit", code }` | The shell exited; the connection is closing. | `cells` is indexed `cells[row][col]` and its dimensions must match `rows` and `cols` exactly — the decoder cross-checks this, and also that the cursor lies inside the grid. -Every `grid` message is a **complete** snapshot, never a delta. That makes frame -loss and coalescing trivially safe: if two frames arrive between paints, drawing -only the newest is lossless. +`seq` is a per-connection frame counter, and it is what tells the two frame +types apart in practice. A `grid` is a **complete** snapshot and is therefore +self-syncing: it can be applied to any state, so coalescing is trivially safe — +if two `grid` frames arrive between paints, drawing only the newest is lossless. +A `patch` is only valid applied to the frame numbered `seq - 1`. A client that +sees a gap in `seq` cannot paint the patch and sends `resync` to get a fresh +`grid`. + +```ts +/** A run of consecutive changed cells in one row: [row, col, cells]. */ +type PatchRun = readonly [number, number, readonly WireCell[]]; +``` + +Runs, not per-cell entries: a scroll or a repainted status line changes whole +spans at once. Every run is bounds-checked at decode — `row` inside the grid, +`cells` non-empty, and `col + cells.length <= cols`. A run that would write past +the right edge is a decode failure, not a silent clamp. ```ts interface WireCursor { @@ -146,6 +165,8 @@ Three close reasons are defined so both ends agree on why a socket died: | --- | --- | --- | | `decodeClientMessage` | `(frame: unknown) => ClientMsg` | Parse and strictly validate a client frame. Throws on anything invalid. | | `decodeServerMessage` | `(frame: unknown) => ServerMsg` | Same, for server frames. | +| `applyPatch` | `(prev: GridMsg, patch: PatchMsg) => GridMsg` | Apply a delta to the snapshot it was computed against, returning a new `GridMsg`. Never mutates `prev`; rows no run touches are shared with it. Throws a `TypeError` on a dimension mismatch. | +| `diffGrid` | `(prev: GridMsg, next: GridMsg) => PatchRun[]` | The runs of cells that differ between two same-sized snapshots; empty when nothing changed. Throws a `TypeError` on a dimension mismatch. Exported from `webterm`, not `webterm/protocol`. | | `utf8ByteLength` | `(value: string) => number` | UTF-8 byte length of a string. | | `clampTerminalSize` | `(cols: number, rows: number) => { cols, rows }` | Truncate and clamp into the legal range; `NaN`/`Infinity` become the minimum. | | `splitInput` | `(data: string) => string[]` | Split a paste into chunks of at most `MAX_INPUT_BYTES`, never breaking a multi-byte character. | @@ -161,6 +182,43 @@ outside the grid or a row of the wrong length. The schema's cross-field checks exist precisely so the renderer can assume well-formed input. ::: +### `GridStream` + +Every client needs the same sequencing logic — hold the current snapshot, fold +patches into it, ask for a fresh one after a gap — so it ships as a small state +machine rather than being written twice. + +```ts +import { GridStream } from "webterm/protocol"; + +const stream = new GridStream(); + +const { grid, reply } = stream.accept(msg); // msg: GridMsg | PatchMsg +if (reply) ws.send(JSON.stringify(reply)); // an `ack`, or a `resync` +if (grid) paint(grid); // a complete GridMsg, patch or not +``` + +| Member | Signature | Behavior | +| --- | --- | --- | +| `accept` | `(msg: GridMsg \| PatchMsg) => GridStreamResult` | Take a server frame; returns the grid to paint and the frame to send back, either of which may be `null`. | +| `grid` | `GridMsg \| null` | The most recent complete snapshot; `null` before the first `grid`. | +| `reset` | `() => void` | Drop all state. Call when a socket closes, so the next one starts from a full frame. | + +A `grid` is always accepted and becomes the current snapshot. A `patch` is +applied when its `seq` is exactly one past that snapshot's; the result is a +complete `GridMsg`, so a renderer never has to know a patch existed. Anything +else — a patch before the first snapshot, a `seq` gap, or a patch anchored to +different dimensions — yields `{ grid: null, reply: { type: "resync" } }` and +leaves the stream waiting for a full frame, during which further patches yield +nothing at all. + +That silence is deliberate: exactly **one** `resync` goes out per gap. +Re-requesting on every subsequent patch would pile a burst of requests onto the +congested link that probably caused the gap in the first place. + +Only accepted frames are acked. An `ack` means "I have this frame", and it +carries that frame's `seq`. + ## The server side `TerminalBridge` owns one PTY subprocess plus one `bun-vt` terminal. It is @@ -177,19 +235,73 @@ new TerminalBridge(options: TerminalBridgeOptions) | `send` | `(msg: ServerMsg) => void` | Sink for server → client messages. | | `frameIntervalMs` | `number?` | Frame coalescing interval. Defaults to `16` (~60fps). | | `termName` | `string?` | `TERM` advertised to the child. Defaults to `"xterm-256color"`. | +| `maxUnackedFrames` | `number?` | Frames allowed in flight unacked before the stream pauses. Defaults to `2`. | +| `ackTimeoutMs` | `number?` | How long to wait for an ack before sending a full snapshot anyway. Defaults to `5000`. | +| `congested` | `(() => boolean)?` | Transport-level backpressure signal; while it returns `true` no frame is produced. Defaults to never congested. | | Method | Signature | Behavior | | --- | --- | --- | | `start` | `(cols: number, rows: number) => void` | Allocate the VT parser and spawn the PTY. Idempotent. | | `input` | `(data: string) => void` | Write to the PTY. | | `resize` | `(cols: number, rows: number) => void` | Resize the PTY and the parser, then repaint. | -| `handle` | `(msg: ClientMsg) => void` | Dispatch a decoded client message to the three methods above. | +| `handle` | `(msg: ClientMsg) => void` | Dispatch a decoded client message: `init`/`input`/`resize` to the methods above, `ack`/`resync` to the sequencer. | | `stop` | `() => void` | Kill the PTY and free the parser. Idempotent; does **not** emit `exit`. | Bytes arriving from the PTY are written into the VT parser and schedule a frame; -the frame timer coalesces a burst of output into one snapshot per interval, so a -process spewing megabytes still produces at most ~60 grid messages a second. When -the child exits, the bridge sends `{ type: "exit", code }` and cleans up. +the frame timer coalesces a burst of output into one message per interval, so a +process spewing megabytes still produces at most ~60 frames a second. Each of +those frames is a `patch` against the previous one, except where a full `grid` is +required — see `FrameSequencer` below. A terminal producing no output produces no +frames at all: an interval where nothing changed, cursor included, sends nothing. +When the child exits, the bridge sends `{ type: "exit", code }` and cleans up. + +The stream is also paced. `ack` frames from the client bound how many frames may +be in flight; once `maxUnackedFrames` are outstanding the bridge stops producing, +and the next `ack` restarts it. That is what stops a slow link from accumulating +unbounded lag — without it a build spewing output for four seconds can leave a +400 kbps client tens of seconds behind, and the gap only grows. A `resync` +answers with a full `grid` immediately, whether or not a frame was owed. + +### `FrameSequencer` + +The rules above live in a separate class that touches no PTY and no socket, so +they can be tested on their own; `TerminalBridge` forwards its own +`maxUnackedFrames`/`ackTimeoutMs`/`congested` options to it. + +```ts +import { FrameSequencer, serializeGrid } from "webterm"; + +const sequencer = new FrameSequencer({ maxUnackedFrames: 2 }); + +const decision = sequencer.next(serializeGrid(term)); +if (decision.kind === "send") ws.send(JSON.stringify(decision.msg)); +``` + +| Member | Signature | Behavior | +| --- | --- | --- | +| `next` | `(grid: GridMsg) => FrameDecision` | Decide what to send for the terminal's current state. The argument's `seq` is ignored and restamped. | +| `ack` | `(seq: number) => void` | Advance the acknowledged high-water mark. An ack for an unsent frame, or a stale one, is ignored. | +| `requestResync` | `() => void` | Send a full snapshot next, and reopen the window. | + +A `FrameDecision` is `{ kind: "send", msg }`, `{ kind: "idle" }` (nothing changed +— no `seq` consumed, nothing sent), or `{ kind: "blocked" }` (the window is +closed; retry when an ack arrives). + +A `send` is a full `grid` for the first frame of a connection, after +`requestResync`, and whenever the dimensions differ from the last frame — a +`patch` cannot cross a resize, since `applyPatch` throws on a size mismatch. +Everything else is a `patch` produced by `diffGrid`. The diff baseline is the +last frame **actually sent**, never the last one computed, which is what makes +coalescing and pacing lossless: frames skipped while blocked are simply folded +into the next patch. + +Reopening the window in `requestResync` is not optional. A client that has lost +sequence stops acking, so a window left closed there would never reopen and the +terminal would wedge. `ackTimeoutMs` is a second safety valve for the same class +of failure: if the oldest unacked frame is older than that, one full snapshot +goes out anyway and the window reopens. A client that never acks at all — an +older build, a wedged renderer — therefore degrades to a snapshot every five +seconds rather than hanging. A minimal server: @@ -208,11 +320,15 @@ Bun.serve<{ bridge?: TerminalBridge }>({ }, websocket: { maxPayloadLength: MAX_CLIENT_FRAME_BYTES, + // Frames are repetitive JSON; permessage-deflate is worth an order of + // magnitude on them, and browsers offer the extension by default. + perMessageDeflate: true, open(ws) { ws.data.bridge = new TerminalBridge({ argv: ["bash", "-l"], send: (msg) => ws.send(JSON.stringify(msg)), + congested: () => ws.getBufferedAmount() > 256 * 1024, }); }, @@ -235,9 +351,11 @@ Bun.serve<{ bridge?: TerminalBridge }>({ }); ``` -`serializeGrid(term: Terminal): GridMsg` and `encodeCell(cell: Cell): WireCell` -are exported separately, so a caller driving `bun-vt` itself can produce the same -snapshots without using `TerminalBridge`. +`serializeGrid(term: Terminal, seq?: number): GridMsg` and +`encodeCell(cell: Cell): WireCell` are exported separately, so a caller driving +`bun-vt` itself can produce the same snapshots without using `TerminalBridge`. +`seq` defaults to `0`, since only the caller streaming a connection's frames +knows their numbering. ### How Fleet wires it up @@ -245,7 +363,10 @@ snapshots without using `TerminalBridge`. `argv` attaches to the workspace's tmux session, and adds two policies of its own on top of the protocol: `init` must be the first message and may not be repeated (either violation closes with 1008), and an init that never arrives -times out. [`fleet-bridge`](/concepts/bridge/) does not emulate anything — it +times out. It also passes a `congested` predicate built from the socket's own +buffered amount, so the bridge pauses for the near end as well as the far one, +and every server in the chain negotiates permessage-deflate. +[`fleet-bridge`](/concepts/bridge/) does not emulate anything — it decodes and re-serializes each client frame, forwards it to the owning ship, and buffers up to `MAX_PENDING_BYTES` while the upstream socket is still connecting. See [Terminals](/concepts/terminals/) for the end-to-end path. @@ -253,12 +374,14 @@ See [Terminals](/concepts/terminals/) for the end-to-end path. ## Driving it from a client The client's job is small: send `init` once, then `resize` on every size change, -`input` on every keystroke, and repaint on every `grid`. +`input` on every keystroke, and hand every frame to a `GridStream`, which says +what to paint and what to send back. ```ts import { clampTerminalSize, decodeServerMessage, + GridStream, splitInput, BINARY_MESSAGE_CLOSE_CODE, BINARY_MESSAGE_CLOSE_REASON, INVALID_MESSAGE_CLOSE_CODE, INVALID_MESSAGE_CLOSE_REASON, @@ -266,6 +389,7 @@ import { } from "webterm/protocol"; const ws = new WebSocket(url); +const stream = new GridStream(); let initialized = false; function sendSize(cols: number, rows: number) { @@ -291,8 +415,13 @@ ws.onmessage = (event) => { } try { const msg = decodeServerMessage(event.data); - if (msg.type === "grid") paint(msg); - else console.log("shell exited", msg.code); + if (msg.type === "exit") { + console.log("shell exited", msg.code); + return; + } + const { grid, reply } = stream.accept(msg); + if (reply) ws.send(JSON.stringify(reply)); + if (grid) paint(grid); } catch { ws.close(INVALID_MESSAGE_CLOSE_CODE, INVALID_MESSAGE_CLOSE_REASON); } @@ -315,10 +444,14 @@ this shape and adds the things a real UI needs: - The first `resize` after the socket opens is sent as `init`; every later one is a `resize`. A size reported before the socket is open is buffered and flushed on connect. -- Grid frames are deliberately kept **out** of React state. The newest snapshot - goes into a ref and is painted on the next animation frame — since each frame - is a full snapshot, dropping intermediate ones is lossless, and a 60fps stream - never re-renders the component tree. +- A `GridStream` lives in a ref and every `grid`/`patch` goes through it, so + acks and resyncs are sent without the component knowing. It is reset whenever + the socket effect re-runs: a reconnected socket numbers its frames from + scratch, and the snapshot from the old one is not a baseline for any of them. +- Frames are deliberately kept **out** of React state. The snapshot the stream + hands back goes into a ref and is painted on the next animation frame — since + it is always a full snapshot, dropping intermediate ones is lossless, and a + 60fps stream never re-renders the component tree. - The socket is opened only while the terminal is actually visible, and closed on unmount — which is what releases the ship's single-terminal guard. @@ -334,7 +467,20 @@ bun test The suite covers the decoders (dimension boundaries, UTF-8 input measurement, malformed/unknown/extra-field/scalar/array/binary frames, grid dimension and -cursor cross-checks), the browser helpers (`clampTerminalSize`, `splitInput` -across a multi-byte boundary), and the encoder against a real `bun-vt` terminal — -including that a blank cell serializes to `0` and that the default cursor color -is omitted. +cursor cross-checks, out-of-bounds and empty patch runs, `ack`/`resync`), the +browser helpers (`clampTerminalSize`, `splitInput` across a multi-byte +boundary), and the encoder against a real `bun-vt` terminal — including that a +blank cell serializes to `0` and that the default cursor color is omitted. The +delta path is tested from both ends: `diffGrid`'s run coalescing and structural +cell comparison, `applyPatch`'s copy-on-write, and the round trip — applying the +runs of `diffGrid(a, b)` to `a` reproduces `b`. `GridStream` is covered on top of +that: patches accumulate in order, a gap produces exactly one `resync` and then +silence until a `grid` arrives, and a mismatched patch is a gap rather than a +throw. + +`FrameSequencer` is tested against a hand-built grid fixture and an injected +clock, with no PTY: the first frame is a full snapshot, an unchanged grid is +`idle`, a cursor-only move is still a patch, a resize forces a full grid, the +window closes after `maxUnackedFrames` and reopens on an ack, an unsent or stale +ack changes nothing, `requestResync` both forces a snapshot and reopens a closed +window, and the ack timeout fires exactly one snapshot once the clock passes it. diff --git a/apps/docs/src/content/docs/reference/ship-api.md b/apps/docs/src/content/docs/reference/ship-api.md index 483990c..0d9eec7 100644 --- a/apps/docs/src/content/docs/reference/ship-api.md +++ b/apps/docs/src/content/docs/reference/ship-api.md @@ -376,8 +376,8 @@ replayed immediately after it, so no event is lost on connect. Attaches a terminal to the workspace's tmux session by running `tmux -L fleet-ship attach -t ws-`. The wire format is the webterm -protocol: the server parses the shell's VT bytes and streams full grid -snapshots; the client sends keystrokes. +protocol: the server parses the shell's VT bytes and streams the cell grid; the +client sends keystrokes and acknowledges frames. Client → server messages (JSON text only): @@ -385,13 +385,22 @@ Client → server messages (JSON text only): { type: "init"; cols: number; rows: number } // must be first, exactly once { type: "input"; data: string } { type: "resize"; cols: number; rows: number } +{ type: "ack"; seq: number } // frame `seq` arrived +{ type: "resync" } // sequence lost; send a snapshot ``` -`cols` is 1–1024, `rows` is 1–512, and `input.data` is at most 256 KiB of UTF-8. -The socket's max payload is 1,572,992 bytes. +`cols` is 1–1024, `rows` is 1–512, `input.data` is at most 256 KiB of UTF-8, and +`seq` is a non-negative integer. The socket's max payload is 1,572,992 bytes, and +the socket negotiates permessage-deflate when the client offers it. -Server → client messages: `grid` snapshots and a final -`{ type: "exit", code: number }`. +Server → client messages: a full `grid` snapshot to open the connection and +after every resize or `resync`, a `patch` of changed cell runs for every other +frame, and a final `{ type: "exit", code: number }`. + +Frames are paced by the client's acks: with two frames unacknowledged the server +stops sending until one is acked, or until five seconds pass, after which it +sends one full snapshot and resumes. A client that never acks therefore sees a +snapshot every five seconds rather than a live terminal. Connection rules: diff --git a/packages/fleet-bridge/src/api/index.ts b/packages/fleet-bridge/src/api/index.ts index aff7253..a47f445 100644 --- a/packages/fleet-bridge/src/api/index.ts +++ b/packages/fleet-bridge/src/api/index.ts @@ -19,7 +19,10 @@ import { eventsPlugin } from "./events"; import { Logestic } from "logestic"; export function createApp(manager: FleetManager, _config: BridgeConfig) { - return new Elysia({ websocket: { maxPayloadLength: MAX_CLIENT_FRAME_BYTES } }) + // Terminal frames are highly repetitive JSON; permessage-deflate is worth an + // order of magnitude on them, and Bun's client WebSocket already offers the + // extension, so accepting it here compresses the fleet-client→bridge hop. + return new Elysia({ websocket: { maxPayloadLength: MAX_CLIENT_FRAME_BYTES, perMessageDeflate: true } }) .use(Logestic.preset("commontz")) .use(workspacesPlugin(manager)) .use(shipsPlugin(manager)) diff --git a/packages/fleet-client/src/data/useWebterm.ts b/packages/fleet-client/src/data/useWebterm.ts index e377250..e234112 100644 --- a/packages/fleet-client/src/data/useWebterm.ts +++ b/packages/fleet-client/src/data/useWebterm.ts @@ -4,6 +4,7 @@ import { BINARY_MESSAGE_CLOSE_REASON, clampTerminalSize, decodeServerMessage, + GridStream, INVALID_MESSAGE_CLOSE_CODE, INVALID_MESSAGE_CLOSE_REASON, splitInput, @@ -11,7 +12,7 @@ import { TERMINAL_TAKEOVER_CLOSE_CODE, TERMINAL_TAKEOVER_QUERY, } from "webterm/protocol"; -import type { GridMsg } from "webterm/protocol"; +import type { ClientMsg, GridMsg } from "webterm/protocol"; import { wsBridgeUrl } from "./client"; export type WebtermStatus = @@ -26,15 +27,18 @@ export type WebtermStatus = | "error"; interface UseWebtermOptions { - /** Called on every grid frame. Kept out of React state on purpose — the caller - * paints imperatively so 60fps snapshots don't re-render the tree. */ + /** Called with each complete snapshot, whether the server sent it whole or as a + * patch. Kept out of React state on purpose — the caller paints imperatively + * so 60fps snapshots don't re-render the tree. */ onGrid?: (grid: GridMsg) => void; onExit?: (code: number) => void; } export function handleServerFrame( data: unknown, + stream: GridStream, opts: UseWebtermOptions, + send: (msg: ClientMsg) => void, close: (code: number, reason: string) => void, ): void { if (typeof data !== "string") { @@ -43,8 +47,13 @@ export function handleServerFrame( } try { const msg = decodeServerMessage(data); - if (msg.type === "grid") opts.onGrid?.(msg); - else opts.onExit?.(msg.code); + if (msg.type === "exit") { + opts.onExit?.(msg.code); + return; + } + const { grid, reply } = stream.accept(msg); + if (reply) send(reply); + if (grid) opts.onGrid?.(grid); } catch { close(INVALID_MESSAGE_CLOSE_CODE, INVALID_MESSAGE_CLOSE_REASON); } @@ -102,6 +111,9 @@ export function useWebterm( * replay an `init` rather than a `resize`. */ const lastSizeRef = useRef<{ cols: number; rows: number } | null>(null); + // Frames never go through React state: they arrive at up to 60fps and the + // caller paints imperatively. + const streamRef = useRef(new GridStream()); // Bumping the attempt is what re-runs the socket effect; the flag rides in a // ref that the effect consumes, so eviction applies to that attempt alone and // never to a later reconnect. @@ -132,6 +144,9 @@ export function useWebterm( } // A fresh socket needs `init` before anything else; `lastSizeRef` carries over. initializedRef.current = false; + // The new connection numbers its frames from scratch, and a snapshot left + // over from the previous one is not a baseline any of its patches anchor to. + streamRef.current.reset(); setStatus("connecting"); const ws = new WebSocket(wsBridgeUrl(terminalPath(repo, name, requestTakeover))); @@ -143,7 +158,15 @@ export function useWebterm( if (size) sendSize(ws, size.cols, size.rows); }; ws.onmessage = (ev) => { - handleServerFrame(ev.data, optsRef.current, (code, reason) => ws.close(code, reason)); + handleServerFrame( + ev.data, + streamRef.current, + optsRef.current, + (msg) => { + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg)); + }, + (code, reason) => ws.close(code, reason), + ); }; ws.onclose = (ev) => setStatus(closeStatus(ev.code)); // An error event can trail a close (e.g. the ship refusing the attach), and diff --git a/packages/fleet-client/src/index.ts b/packages/fleet-client/src/index.ts index 3eee547..b284922 100644 --- a/packages/fleet-client/src/index.ts +++ b/packages/fleet-client/src/index.ts @@ -143,6 +143,9 @@ export function startClientServer( // lost (mirrors the bridge→ship proxy in fleet-bridge's workspaces plugin). websocket: { maxPayloadLength: MAX_CLIENT_FRAME_BYTES, + // Terminal frames are highly repetitive JSON, and this is the hop most + // likely to be a slow link — the browser's own connection. + perMessageDeflate: true, open(ws: ServerWebSocket) { const { upstream, buffer, upstreamBuffer } = ws.data; upstream.onopen = () => { diff --git a/packages/fleet-client/tests/useWebterm.test.ts b/packages/fleet-client/tests/useWebterm.test.ts index 3a4bb90..87c0d31 100644 --- a/packages/fleet-client/tests/useWebterm.test.ts +++ b/packages/fleet-client/tests/useWebterm.test.ts @@ -2,11 +2,13 @@ import { describe, expect, test } from "bun:test"; import { BINARY_MESSAGE_CLOSE_CODE, BINARY_MESSAGE_CLOSE_REASON, + GridStream, INVALID_MESSAGE_CLOSE_CODE, INVALID_MESSAGE_CLOSE_REASON, TERMINAL_CONFLICT_CLOSE_CODE, TERMINAL_TAKEOVER_CLOSE_CODE, } from "webterm/protocol"; +import type { ClientMsg, GridMsg } from "webterm/protocol"; import { closeStatus, handleServerFrame, terminalPath } from "../src/data/useWebterm"; describe("browser server-message handling", () => { @@ -30,38 +32,83 @@ describe("browser server-message handling", () => { expect(closeStatus(INVALID_MESSAGE_CLOSE_CODE)).toBe("closed"); }); - test("dispatches valid grid and exit messages", () => { - const grids: unknown[] = []; + const gridFrame = (seq: number, char: string) => + JSON.stringify({ + type: "grid", + seq, + cols: 2, + rows: 1, + cursor: { x: 0, y: 0, visible: true }, + cells: [[{ t: char }, 0]], + }); + const patchFrame = (seq: number, char: string) => + JSON.stringify({ + type: "patch", + seq, + cols: 2, + rows: 1, + cursor: { x: 1, y: 0, visible: true }, + runs: [[0, 1, [{ t: char }]]], + }); + + function harness() { + const grids: GridMsg[] = []; const exits: number[] = []; - const close = () => { - throw new Error("unexpected close"); - }; - handleServerFrame( - JSON.stringify({ - type: "grid", - cols: 1, - rows: 1, - cursor: { x: 0, y: 0, visible: true }, - cells: [[0]], - }), - { onGrid: (grid) => grids.push(grid), onExit: (code) => exits.push(code) }, - close, - ); - handleServerFrame('{"type":"exit","code":7}', { onExit: (code) => exits.push(code) }, close); + const sent: ClientMsg[] = []; + const closes: Array<[number, string]> = []; + const stream = new GridStream(); + const feed = (data: unknown) => + handleServerFrame( + data, + stream, + { onGrid: (grid) => grids.push(grid), onExit: (code) => exits.push(code) }, + (msg) => sent.push(msg), + (code, reason) => closes.push([code, reason]), + ); + return { grids, exits, sent, closes, feed }; + } + + test("paints and acks a grid, and reports an exit", () => { + const { grids, exits, sent, closes, feed } = harness(); + feed(gridFrame(0, "a")); + feed('{"type":"exit","code":7}'); expect(grids).toHaveLength(1); expect(exits).toEqual([7]); + expect(sent).toEqual([{ type: "ack", seq: 0 }]); + expect(closes).toEqual([]); + }); + + test("paints the patched grid and acks the patch", () => { + const { grids, sent, feed } = harness(); + feed(gridFrame(0, "a")); + feed(patchFrame(1, "b")); + expect(grids).toHaveLength(2); + expect(grids[1]!.cells).toEqual([[{ t: "a" }, { t: "b" }]]); + expect(sent).toEqual([ + { type: "ack", seq: 0 }, + { type: "ack", seq: 1 }, + ]); + }); + + test("asks for a snapshot and paints nothing when a frame was missed", () => { + const { grids, sent, feed } = harness(); + feed(gridFrame(0, "a")); + feed(patchFrame(2, "b")); + feed(patchFrame(3, "c")); + expect(grids).toHaveLength(1); + expect(sent).toEqual([{ type: "ack", seq: 0 }, { type: "resync" }]); }); test("closes malformed, unknown, and binary messages with fixed reasons", () => { - const closes: Array<[number, string]> = []; - const close = (code: number, reason: string) => closes.push([code, reason]); - handleServerFrame("{", {}, close); - handleServerFrame('{"type":"unknown"}', {}, close); - handleServerFrame(new Blob(["binary"]), {}, close); + const { sent, closes, feed } = harness(); + feed("{"); + feed('{"type":"unknown"}'); + feed(new Blob(["binary"])); expect(closes).toEqual([ [INVALID_MESSAGE_CLOSE_CODE, INVALID_MESSAGE_CLOSE_REASON], [INVALID_MESSAGE_CLOSE_CODE, INVALID_MESSAGE_CLOSE_REASON], [BINARY_MESSAGE_CLOSE_CODE, BINARY_MESSAGE_CLOSE_REASON], ]); + expect(sent).toEqual([]); }); }); diff --git a/packages/fleet-ship/src/api/index.ts b/packages/fleet-ship/src/api/index.ts index 17a5c2a..1afa7cc 100644 --- a/packages/fleet-ship/src/api/index.ts +++ b/packages/fleet-ship/src/api/index.ts @@ -24,7 +24,11 @@ export function createApp( terminalInitTimeoutMs?: number, armory?: ArmoryCache, ) { - return new Elysia({ websocket: { maxPayloadLength: MAX_CLIENT_FRAME_BYTES } }) + // Terminal frames are JSON whose keys and blank-cell `0`s repeat heavily, so + // permessage-deflate is worth an order of magnitude on them. Bun's client + // WebSocket offers the extension by default, so the bridge→ship hop starts + // compressing purely from the server accepting it here. + return new Elysia({ websocket: { maxPayloadLength: MAX_CLIENT_FRAME_BYTES, perMessageDeflate: true } }) .use(Logestic.preset("commontz")) .use(workspacesPlugin(manager, createTerminal, terminalInitTimeoutMs)) .use(eventsPlugin(manager)) diff --git a/packages/fleet-ship/src/api/workspaces.ts b/packages/fleet-ship/src/api/workspaces.ts index 6ef30cc..5114a31 100644 --- a/packages/fleet-ship/src/api/workspaces.ts +++ b/packages/fleet-ship/src/api/workspaces.ts @@ -11,6 +11,7 @@ import { decodeClientMessage, INVALID_MESSAGE_CLOSE_CODE, INVALID_MESSAGE_CLOSE_REASON, + MAX_PENDING_BYTES, TERMINAL_CONFLICT_CLOSE_CODE, TERMINAL_CONFLICT_CLOSE_REASON, TERMINAL_TAKEOVER_CLOSE_CODE, @@ -28,6 +29,26 @@ import { mapError } from "./http"; // takeover can evict it through its own `finish`. const activeTerminals = new Map(); +/** + * Unflushed bytes on the terminal socket past which the bridge stops producing + * frames. Matches `MAX_PENDING_BYTES`, the bound every other hop in the chain + * uses for the same judgement ("the peer is not draining"), and is several full + * snapshots wide at any sane terminal size, so an ordinary in-flight frame + * never trips it — this is a backstop under the ack window, not a substitute. + */ +const TERMINAL_BACKPRESSURE_BYTES = MAX_PENDING_BYTES; + +/** + * Elysia's `ws` is a wrapper; `ws.raw` is Bun's `ServerWebSocket`, which does + * expose `getBufferedAmount()` even though the `ServerWebSocket` declaration + * Elysia bundles predates it. Read defensively rather than widening the type: + * a wrapper without it just means no congestion signal, not a crash. + */ +function bufferedAmount(ws: unknown): number { + const raw = (ws as { raw?: { getBufferedAmount?: () => number } }).raw; + return raw?.getBufferedAmount?.() ?? 0; +} + export const TERMINAL_INIT_TIMEOUT_MS = 5_000; export const TERMINAL_INIT_TIMEOUT_CLOSE_CODE = 1008; export const TERMINAL_INIT_TIMEOUT_CLOSE_REASON = "terminal init timeout"; @@ -303,6 +324,9 @@ export function workspacesPlugin( try { const bridge = createTerminal({ argv: ["tmux", "-L", WORKSPACE_TMUX_NAMESPACE, "attach", "-t", sessionName], + // The client's acks pace the stream against the far end; this paces + // it against the near one, which the acks cannot see. + congested: () => bufferedAmount(ws) > TERMINAL_BACKPRESSURE_BYTES, send: (msg: ServerMsg) => { if (msg.type === "exit") { try { diff --git a/packages/fleet-ship/tests/terminal-ws.test.ts b/packages/fleet-ship/tests/terminal-ws.test.ts index 6abecb6..cfbbc28 100644 --- a/packages/fleet-ship/tests/terminal-ws.test.ts +++ b/packages/fleet-ship/tests/terminal-ws.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Elysia } from "elysia"; import { BINARY_MESSAGE_CLOSE_CODE, BINARY_MESSAGE_CLOSE_REASON, @@ -37,15 +38,18 @@ describe("ship terminal protocol", () => { let argvs: string[][]; let sendOnCreate: ServerMsg | undefined; let createTerminal: Parameters[2]; + let created: Parameters[2]>>[0][]; beforeEach(() => { handled = []; stops = 0; creates = 0; argvs = []; + created = []; sendOnCreate = undefined; createTerminal = (options) => { creates++; + created.push(options); argvs.push([...options.argv]); if (sendOnCreate) options.send(sendOnCreate); return { @@ -137,6 +141,46 @@ describe("ship terminal protocol", () => { await close; }); + test("forwards flow-control frames to the bridge", async () => { + const socket = new WebSocket(url); + await attached(socket); + socket.send('{"type":"ack","seq":3}'); + socket.send('{"type":"resync"}'); + await Bun.sleep(20); + expect(handled.slice(1)).toEqual([{ type: "ack", seq: 3 }, { type: "resync" }]); + const close = closed(socket); + socket.close(); + await close; + }); + + test("gives the bridge a congestion signal read from the live socket", async () => { + const socket = new WebSocket(url); + await attached(socket); + expect(created[0]?.congested?.()).toBe(false); + const close = closed(socket); + socket.close(); + await close; + }); + + test("exposes getBufferedAmount on the raw socket behind Elysia's wrapper", async () => { + // The congestion signal above reads through a cast, because the + // `ServerWebSocket` declaration Elysia bundles predates the method. Nothing + // in the type system would notice it disappearing, so pin it here: without + // it the signal silently degrades to a constant "not congested". + let reading: unknown; + const probe = new Elysia().ws("/probe", { + open(ws) { + reading = (ws as { raw?: { getBufferedAmount?: () => number } }).raw?.getBufferedAmount?.(); + ws.close(); + }, + }); + probe.listen(0); + const socket = new WebSocket(`ws://localhost:${probe.server?.port}/probe`); + await closed(socket); + probe.server?.stop(true); + expect(reading).toBe(0); + }); + test("times out a missing init and releases the workspace for reconnect", async () => { app.server?.stop(true); app = createApp(stubManager(), stubConfig, createTerminal, 20); diff --git a/packages/webterm/encode.ts b/packages/webterm/encode.ts index 73ef504..9cc9d06 100644 --- a/packages/webterm/encode.ts +++ b/packages/webterm/encode.ts @@ -1,6 +1,7 @@ /** * encode.ts — turn a bun-vt `Terminal`'s current grid into a `GridMsg` - * snapshot, using the compact per-cell encoding from `protocol.ts`. + * snapshot, using the compact per-cell encoding from `protocol.ts`, and diff + * two snapshots into the runs of a `PatchMsg`. */ import type { Cell, CellStyle, Color, Terminal } from "bun-vt"; @@ -9,6 +10,7 @@ import { UNDERLINE, WIDTH, type GridMsg, + type PatchRun, type WireCell, type WireCellObject, type WireColor, @@ -64,8 +66,12 @@ export function encodeCell(cell: Cell): WireCell { return out; } -/** Serialize the terminal's whole active screen into a `GridMsg`. */ -export function serializeGrid(term: Terminal): GridMsg { +/** + * Serialize the terminal's whole active screen into a `GridMsg`. `seq` is the + * connection's frame counter, which only the caller streaming the frames knows; + * a one-off snapshot can leave it at the default. + */ +export function serializeGrid(term: Terminal, seq = 0): GridMsg { const rows = term.rows; const cols = term.cols; const cells: WireCell[][] = new Array(rows); @@ -82,6 +88,7 @@ export function serializeGrid(term: Terminal): GridMsg { const cursorColor = colorToWire(cursor.color); return { type: "grid", + seq, cols, rows, cursor: { @@ -95,3 +102,61 @@ export function serializeGrid(term: Terminal): GridMsg { cells, }; } + +/** Structural color comparison — a palette index only ever equals the same index. */ +export function colorsEqual(a: WireColor | undefined, b: WireColor | undefined): boolean { + if (a === b) return true; + if (a === undefined || b === undefined || typeof a === "number" || typeof b === "number") return false; + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]; +} + +/** + * Structural cell comparison. Deliberately not a `JSON.stringify` comparison: + * key order is an encoder implementation detail, and this runs over every cell + * of every frame. The blank literal `0` only ever equals another `0` — the + * encoder never emits an all-defaults object, so an object is assumed to differ. + */ +function cellsEqual(a: WireCell, b: WireCell): boolean { + if (a === b) return true; + if (a === 0 || b === 0) return false; + return ( + a.t === b.t && + a.a === b.a && + a.u === b.u && + a.w === b.w && + colorsEqual(a.f, b.f) && + colorsEqual(a.b, b.b) + ); +} + +/** + * Diff two same-sized snapshots into the runs of a `PatchMsg` — empty when + * nothing changed. Runs rather than per-cell entries because a scroll or a + * repainted status line changes whole spans at once. + * + * Throws on a dimension mismatch; the caller sends a full snapshot instead. + */ +export function diffGrid(prev: GridMsg, next: GridMsg): PatchRun[] { + if (prev.cols !== next.cols || prev.rows !== next.rows) { + throw new TypeError("cannot diff grids of different sizes"); + } + + const runs: PatchRun[] = []; + for (let r = 0; r < next.rows; r++) { + const before = prev.cells[r]!; + const after = next.cells[r]!; + let start = -1; + for (let c = 0; c < next.cols; c++) { + if (cellsEqual(before[c]!, after[c]!)) { + if (start >= 0) { + runs.push([r, start, after.slice(start, c)]); + start = -1; + } + } else if (start < 0) { + start = c; + } + } + if (start >= 0) runs.push([r, start, after.slice(start, next.cols)]); + } + return runs; +} diff --git a/packages/webterm/index.ts b/packages/webterm/index.ts index 48cd1c0..2840d2e 100644 --- a/packages/webterm/index.ts +++ b/packages/webterm/index.ts @@ -6,8 +6,14 @@ * from `webterm` on the server for the bridge + encoder. */ -export { TerminalBridge, type TerminalBridgeOptions } from "./server"; -export { serializeGrid, encodeCell } from "./encode"; +export { + TerminalBridge, + type TerminalBridgeOptions, + FrameSequencer, + type FrameSequencerOptions, + type FrameDecision, +} from "./server"; +export { serializeGrid, encodeCell, diffGrid } from "./encode"; export { ATTR, @@ -33,6 +39,9 @@ export { TERMINAL_TAKEOVER_QUERY, decodeClientMessage, decodeServerMessage, + applyPatch, + GridStream, + type GridStreamResult, utf8ByteLength, clampTerminalSize, splitInput, @@ -40,8 +49,12 @@ export { type InitMsg, type InputMsg, type ResizeMsg, + type AckMsg, + type ResyncMsg, type ServerMsg, type GridMsg, + type PatchMsg, + type PatchRun, type ExitMsg, type WireCursor, type WireCursorShape, diff --git a/packages/webterm/protocol.ts b/packages/webterm/protocol.ts index 3ff9d35..fb9ed77 100644 --- a/packages/webterm/protocol.ts +++ b/packages/webterm/protocol.ts @@ -5,8 +5,9 @@ * safe to bundle straight into the browser. * * The server is the terminal emulator: it parses the shell's raw VT bytes with - * bun-vt into a cell grid and streams full grid snapshots to the client. - * The client only paints cells and sends keystrokes. + * bun-vt into a cell grid and streams it to the client, either as a full `grid` + * snapshot or as a `patch` delta against the previous frame. The client only + * paints cells and sends keystrokes. */ import { z } from "zod"; @@ -48,10 +49,20 @@ const colsSchema = z.number().int().min(MIN_TERMINAL_COLS).max(MAX_TERMINAL_COLS const rowsSchema = z.number().int().min(MIN_TERMINAL_ROWS).max(MAX_TERMINAL_ROWS); const inputSchema = z.string().refine((data) => utf8.encode(data).byteLength <= MAX_INPUT_BYTES); +const seqSchema = z.number().int().nonnegative(); + const InitMsgSchema = z.strictObject({ type: z.literal("init"), cols: colsSchema, rows: rowsSchema }); const InputMsgSchema = z.strictObject({ type: z.literal("input"), data: inputSchema }); const ResizeMsgSchema = z.strictObject({ type: z.literal("resize"), cols: colsSchema, rows: rowsSchema }); -const ClientMsgSchema = z.discriminatedUnion("type", [InitMsgSchema, InputMsgSchema, ResizeMsgSchema]); +const AckMsgSchema = z.strictObject({ type: z.literal("ack"), seq: seqSchema }); +const ResyncMsgSchema = z.strictObject({ type: z.literal("resync") }); +const ClientMsgSchema = z.discriminatedUnion("type", [ + InitMsgSchema, + InputMsgSchema, + ResizeMsgSchema, + AckMsgSchema, + ResyncMsgSchema, +]); // --------------------------------------------------------------------------- // Client → server @@ -77,7 +88,18 @@ export interface ResizeMsg { readonly rows: number; } -export type ClientMsg = InitMsg | InputMsg | ResizeMsg; +/** Acknowledge receipt of the server frame with this `seq`. */ +export interface AckMsg { + readonly type: "ack"; + readonly seq: number; +} + +/** Ask the server for a full `grid` frame (recovery from a sequence gap). */ +export interface ResyncMsg { + readonly type: "resync"; +} + +export type ClientMsg = InitMsg | InputMsg | ResizeMsg | AckMsg | ResyncMsg; // --------------------------------------------------------------------------- // Server → client @@ -95,22 +117,44 @@ export interface WireCursor { readonly color?: WireColor; } -/** A full active-screen snapshot to paint. `cells` is indexed `cells[row][col]`. */ +/** + * A full active-screen snapshot to paint. `cells` is indexed `cells[row][col]`. + * + * `seq` counts frames on this connection: it is the anchor a `patch` deltas + * against, so a client that misses a frame can detect the gap and `resync`. + */ export interface GridMsg { readonly type: "grid"; + readonly seq: number; readonly cols: number; readonly rows: number; readonly cursor: WireCursor; readonly cells: WireCell[][]; } +/** A run of consecutive changed cells in one row: [row, col, cells]. */ +export type PatchRun = readonly [number, number, readonly WireCell[]]; + +/** + * A delta against the frame with `seq - 1`. Only valid applied to that exact + * frame; a client holding anything else must ask for a full `grid` instead. + */ +export interface PatchMsg { + readonly type: "patch"; + readonly seq: number; + readonly cols: number; + readonly rows: number; + readonly cursor: WireCursor; + readonly runs: readonly PatchRun[]; +} + /** Shell exited; the connection is closing. */ export interface ExitMsg { readonly type: "exit"; readonly code: number; } -export type ServerMsg = GridMsg | ExitMsg; +export type ServerMsg = GridMsg | PatchMsg | ExitMsg; // --------------------------------------------------------------------------- // Compact cell encoding @@ -172,6 +216,7 @@ const WireCursorSchema = z.strictObject({ const GridMsgSchema = z .strictObject({ type: z.literal("grid"), + seq: seqSchema, cols: colsSchema, rows: rowsSchema, cursor: WireCursorSchema, @@ -185,8 +230,34 @@ const GridMsgSchema = z ctx.addIssue({ code: "custom", message: "cursor is outside grid" }); } }); +const PatchRunSchema = z.tuple([ + z.number().int().nonnegative(), + z.number().int().nonnegative(), + z.array(WireCellSchema).min(1), +]); +const PatchMsgSchema = z + .strictObject({ + type: z.literal("patch"), + seq: seqSchema, + cols: colsSchema, + rows: rowsSchema, + cursor: WireCursorSchema, + runs: z.array(PatchRunSchema), + }) + .superRefine((patch, ctx) => { + if (patch.cursor.x >= patch.cols || patch.cursor.y >= patch.rows) { + ctx.addIssue({ code: "custom", message: "cursor is outside grid" }); + } + // A run that would write past the right edge is a decode failure rather + // than a clamp, so the renderer can apply runs without bounds checks. + for (const [row, col, cells] of patch.runs) { + if (row >= patch.rows || col + cells.length > patch.cols) { + ctx.addIssue({ code: "custom", message: "patch run is outside grid" }); + } + } + }); const ExitMsgSchema = z.strictObject({ type: z.literal("exit"), code: z.number().int() }); -const ServerMsgSchema = z.discriminatedUnion("type", [GridMsgSchema, ExitMsgSchema]); +const ServerMsgSchema = z.discriminatedUnion("type", [GridMsgSchema, PatchMsgSchema, ExitMsgSchema]); function parseJsonFrame(frame: unknown): unknown { if (typeof frame === "string") return JSON.parse(frame); @@ -204,6 +275,97 @@ export function decodeServerMessage(frame: unknown): ServerMsg { return ServerMsgSchema.parse(parseJsonFrame(frame)); } +/** + * Apply a delta to the snapshot it was computed against, returning the new + * snapshot. `prev` is never mutated — the client repaints from the object it + * already holds — and rows no run touches are shared with `prev` rather than + * copied. + * + * Throws on a dimension mismatch: the patch anchors to a differently sized + * frame, and the caller's recovery is to ask for a full `grid`. + */ +export function applyPatch(prev: GridMsg, patch: PatchMsg): GridMsg { + if (patch.cols !== prev.cols || patch.rows !== prev.rows) { + throw new TypeError("patch dimensions do not match the previous grid"); + } + + const cells = prev.cells.slice(); + const copied = new Set(); + for (const [row, col, run] of patch.runs) { + if (!copied.has(row)) { + cells[row] = cells[row]!.slice(); + copied.add(row); + } + const target = cells[row]!; + for (let i = 0; i < run.length; i++) { + target[col + i] = run[i]!; + } + } + + return { type: "grid", seq: patch.seq, cols: patch.cols, rows: patch.rows, cursor: patch.cursor, cells }; +} + +export interface GridStreamResult { + /** The grid to paint, when this frame produced a new one. */ + readonly grid: GridMsg | null; + /** What to send back: an ack for an accepted frame, or a request for a full snapshot. */ + readonly reply: AckMsg | ResyncMsg | null; +} + +/** + * The client half of the frame protocol: holds the current snapshot, folds + * patches into it, and decides what to send back. Both clients drive one of + * these so the sequencing rules live in one place. + */ +export class GridStream { + #grid: GridMsg | null = null; + /** + * Set when a frame could not be applied. Only one `resync` goes out per gap: + * re-requesting on every following patch would pile a burst of requests onto + * the congested link that probably caused the gap in the first place. + */ + #awaitingSnapshot = false; + + /** The most recent complete snapshot, or null before the first `grid`. */ + get grid(): GridMsg | null { + return this.#grid; + } + + /** Drop all state — call when a socket closes, so the next one starts from a full frame. */ + reset(): void { + this.#grid = null; + this.#awaitingSnapshot = false; + } + + /** + * Take a server frame. Only frames that were actually applied are acked: an + * ack means "I have this frame", and the server's pacing depends on that. + */ + accept(msg: GridMsg | PatchMsg): GridStreamResult { + if (msg.type === "grid") { + this.#grid = msg; + this.#awaitingSnapshot = false; + return { grid: msg, reply: { type: "ack", seq: msg.seq } }; + } + + if (this.#awaitingSnapshot) return { grid: null, reply: null }; + + const prev = this.#grid; + if (prev !== null && msg.seq === prev.seq + 1) { + try { + const next = applyPatch(prev, msg); + this.#grid = next; + return { grid: next, reply: { type: "ack", seq: next.seq } }; + } catch { + // A dimension mismatch is unrecoverable from here; a full frame fixes it. + } + } + + this.#awaitingSnapshot = true; + return { grid: null, reply: { type: "resync" } }; + } +} + export function utf8ByteLength(value: string): number { return utf8.encode(value).byteLength; } diff --git a/packages/webterm/server.ts b/packages/webterm/server.ts index a6168dd..3dc6844 100644 --- a/packages/webterm/server.ts +++ b/packages/webterm/server.ts @@ -3,22 +3,149 @@ * * A `TerminalBridge` owns one PTY subprocess (e.g. `tmux attach ...`) plus one * bun-vt `Terminal`. Raw bytes from the PTY are fed into the VT parser; - * grid snapshots (coalesced to ~60fps) are pushed to a `send` callback. Client + * frames (coalesced to ~60fps) are pushed to a `send` callback. Client * keystrokes and resizes are forwarded to the PTY. * + * What to actually put on the wire — a full `grid`, a `patch`, nothing at all, + * or nothing *yet* because the client is behind — is decided by `FrameSequencer`, + * which touches no PTY and no socket so the rules can be tested on their own. + * * The bridge is transport-agnostic — the caller owns the WebSocket and just * wires `send` to `ws.send` and dispatches decoded `ClientMsg`s to the methods * here. */ import { Terminal as VtTerminal } from "bun-vt"; -import { serializeGrid } from "./encode"; -import type { ClientMsg, ServerMsg } from "./protocol"; +import { colorsEqual, diffGrid, serializeGrid } from "./encode"; +import type { ClientMsg, GridMsg, PatchMsg, ServerMsg, WireCursor } from "./protocol"; + +export type FrameDecision = + | { readonly kind: "send"; readonly msg: GridMsg | PatchMsg } + /** Nothing changed since the last frame — no `seq` is consumed. */ + | { readonly kind: "idle" } + /** The window is closed; retry when an ack arrives. */ + | { readonly kind: "blocked" }; + +export interface FrameSequencerOptions { + /** Frames that may be in flight unacked before the sequencer stops sending. Default 2. */ + readonly maxUnackedFrames?: number; + /** How long to wait for an ack before sending a full snapshot anyway. Default 5000. */ + readonly ackTimeoutMs?: number; + /** Transport-level congestion signal; while true the sequencer blocks. Default: never. */ + readonly congested?: () => boolean; + /** Clock, injectable for tests. Default `Date.now`. */ + readonly now?: () => number; +} + +/** + * Whole-cursor comparison, every field. A cursor move with no cell change is a + * real change the client must be told about, so this is what separates a + * genuinely idle terminal from one where only the caret moved. + */ +function cursorsEqual(a: WireCursor, b: WireCursor): boolean { + return ( + a.x === b.x && + a.y === b.y && + a.visible === b.visible && + a.shape === b.shape && + a.blinking === b.blinking && + colorsEqual(a.color, b.color) + ); +} + +/** + * Decides what the next frame on a connection should be: a full `grid`, a + * `patch` against the last frame *actually sent*, or nothing. + * + * Two things bound it. The diff baseline is the last sent frame, never the last + * computed one, so coalescing and pacing stay lossless. And an ack window caps + * how many frames may be in flight: without it the sender never asks whether + * the receiver is keeping up, and a slow link accumulates unbounded lag. + */ +export class FrameSequencer { + private readonly maxUnackedFrames: number; + private readonly ackTimeoutMs: number; + private readonly congested: () => boolean; + private readonly now: () => number; + + /** The last frame put on the wire, as a full snapshot — the diff baseline. */ + private lastSent: GridMsg | null = null; + private nextSeq = 0; + /** Frames sent but not yet acked, oldest first. */ + private unacked: { readonly seq: number; readonly at: number }[] = []; + private forceFull = false; + + constructor(options: FrameSequencerOptions = {}) { + this.maxUnackedFrames = options.maxUnackedFrames ?? 2; + this.ackTimeoutMs = options.ackTimeoutMs ?? 5_000; + this.congested = options.congested ?? (() => false); + this.now = options.now ?? Date.now; + } + + /** + * Decide what to send for the terminal's current state. `grid` is whatever + * `serializeGrid` produced; its `seq` is ignored and restamped here, so the + * caller never has to thread a frame counter through the encoder. + */ + next(grid: GridMsg): FrameDecision { + const now = this.now(); + if (this.congested() || this.unacked.length >= this.maxUnackedFrames) { + const oldest = this.unacked[0]; + if (oldest === undefined || now - oldest.at < this.ackTimeoutMs) return { kind: "blocked" }; + // Safety valve, not part of the normal path: a client that never acks (an + // older build, a wedged renderer) would otherwise freeze forever. One full + // snapshot every ackTimeoutMs is the pre-existing behavior degraded, not a + // hang, and a full frame is what a client in an unknown state can use. + this.forceFull = true; + this.unacked = []; + } + + const prev = this.lastSent; + const seq = this.nextSeq; + const snapshot: GridMsg = { ...grid, seq }; + + // A patch cannot cross a resize: `applyPatch` throws on a dimension + // mismatch, so a differently sized grid has to go out whole. + if (prev === null || this.forceFull || prev.cols !== grid.cols || prev.rows !== grid.rows) { + return this.emit(snapshot, snapshot, now); + } + + const runs = diffGrid(prev, grid); + if (runs.length === 0 && cursorsEqual(prev.cursor, grid.cursor)) return { kind: "idle" }; + + const patch: PatchMsg = { type: "patch", seq, cols: grid.cols, rows: grid.rows, cursor: grid.cursor, runs }; + return this.emit(snapshot, patch, now); + } + + /** Advance the acknowledged high-water mark. Unknown and stale `seq`s are ignored. */ + ack(seq: number): void { + if (seq >= this.nextSeq) return; + this.unacked = this.unacked.filter((frame) => frame.seq > seq); + } + + /** + * The client lost sequence: send a full snapshot next, and reopen the window. + * Reopening is not optional — a client that has given up on the stream stops + * acking, so a window left closed here never reopens and the terminal wedges. + */ + requestResync(): void { + this.forceFull = true; + this.unacked = []; + } + + private emit(snapshot: GridMsg, msg: GridMsg | PatchMsg, at: number): FrameDecision { + this.lastSent = snapshot; + this.nextSeq = snapshot.seq + 1; + this.unacked.push({ seq: snapshot.seq, at }); + this.forceFull = false; + return { kind: "send", msg }; + } +} -export interface TerminalBridgeOptions { +export interface TerminalBridgeOptions extends FrameSequencerOptions { /** argv for the PTY process, e.g. `["tmux", "-L", "fleet-ship", "attach", "-t", name]`. */ readonly argv: string[]; - /** Sink for server→client messages (grid snapshots, exit). */ + /** Sink for server→client messages (grid snapshots, patches, exit). */ readonly send: (msg: ServerMsg) => void; /** Frame coalescing interval in ms. Default ~16 (60fps). */ readonly frameIntervalMs?: number; @@ -31,10 +158,13 @@ export class TerminalBridge { private readonly send: (msg: ServerMsg) => void; private readonly frameIntervalMs: number; private readonly termName: string; + private readonly sequencer: FrameSequencer; private vt: VtTerminal | null = null; private proc: Bun.Subprocess | null = null; private frameTimer: ReturnType | null = null; + /** A frame the sequencer refused to send; an `ack` is what releases it. */ + private frameOwed = false; private started = false; private stopped = false; @@ -43,6 +173,7 @@ export class TerminalBridge { this.send = options.send; this.frameIntervalMs = options.frameIntervalMs ?? 16; this.termName = options.termName ?? "xterm-256color"; + this.sequencer = new FrameSequencer(options); } /** Allocate the VT parser and spawn the PTY process at the given size. Idempotent. */ @@ -94,6 +225,17 @@ export class TerminalBridge { case "resize": this.resize(msg.cols, msg.rows); break; + case "ack": + this.sequencer.ack(msg.seq); + if (this.frameOwed) this.scheduleFrame(); + break; + case "resync": + this.sequencer.requestResync(); + // Unconditionally, unlike `ack`: the client is painting nothing until + // the snapshot it asked for arrives, and on a quiet terminal there is + // no next PTY byte to schedule one. + this.scheduleFrame(); + break; } } @@ -114,17 +256,27 @@ export class TerminalBridge { clearTimeout(this.frameTimer); this.frameTimer = null; } + this.frameOwed = false; this.vt?.free(); this.vt = null; this.proc = null; } + /** + * Coalesce a burst of PTY output into one decision per interval. A `blocked` + * decision deliberately leaves the timer disarmed: re-arming it would spin + * against a client that is already behind, so the next `ack` restarts the + * stream instead (and, while blocked, further PTY output re-arms it, which is + * also when the sequencer's ack timeout gets re-examined). + */ private scheduleFrame(): void { if (this.frameTimer !== null || this.stopped) return; this.frameTimer = setTimeout(() => { this.frameTimer = null; if (this.stopped || !this.vt) return; - this.send(serializeGrid(this.vt)); + const decision = this.sequencer.next(serializeGrid(this.vt)); + this.frameOwed = decision.kind === "blocked"; + if (decision.kind === "send") this.send(decision.msg); }, this.frameIntervalMs); } } diff --git a/packages/webterm/tests/encode.test.ts b/packages/webterm/tests/encode.test.ts index 51ae3ab..3818496 100644 --- a/packages/webterm/tests/encode.test.ts +++ b/packages/webterm/tests/encode.test.ts @@ -1,6 +1,18 @@ import { describe, expect, test } from "bun:test"; import { Terminal } from "bun-vt"; -import { encodeCell, serializeGrid } from "webterm"; +import { applyPatch, diffGrid, encodeCell, serializeGrid } from "webterm"; +import type { GridMsg, WireCell } from "webterm"; + +function grid(cells: WireCell[][], seq = 0): GridMsg { + return { + type: "grid", + seq, + cols: cells[0]?.length ?? 0, + rows: cells.length, + cursor: { x: 0, y: 0, visible: true }, + cells, + }; +} describe("encode", () => { test("a blank default cell serializes to 0", () => { @@ -78,3 +90,99 @@ describe("encode", () => { expect("color" in cursor).toBe(false); }); }); + +describe("diffGrid", () => { + test("an unchanged grid produces no runs", () => { + expect(diffGrid(grid([[0, { t: "a" }, 0]]), grid([[0, { t: "a" }, 0]]))).toEqual([]); + }); + + test("one changed cell produces one single-cell run", () => { + expect(diffGrid(grid([[0, 0, 0]]), grid([[0, { t: "a" }, 0]]))).toEqual([[0, 1, [{ t: "a" }]]]); + }); + + test("adjacent changes coalesce into one run, separated changes do not", () => { + expect(diffGrid(grid([[0, 0, 0, 0]]), grid([[{ t: "a" }, { t: "b" }, 0, 0]]))).toEqual([ + [0, 0, [{ t: "a" }, { t: "b" }]], + ]); + expect(diffGrid(grid([[0, 0, 0, 0]]), grid([[{ t: "a" }, 0, { t: "b" }, 0]]))).toEqual([ + [0, 0, [{ t: "a" }]], + [0, 2, [{ t: "b" }]], + ]); + }); + + test("changes at the first and last column are both reported", () => { + expect(diffGrid(grid([[{ t: "a" }, 0, { t: "c" }]]), grid([[0, 0, 0]]))).toEqual([ + [0, 0, [0]], + [0, 2, [0]], + ]); + }); + + test("runs are reported per row", () => { + const before = grid([ + [0, 0], + [0, 0], + ]); + const after = grid([ + [0, { t: "a" }], + [{ t: "b" }, 0], + ]); + expect(diffGrid(before, after)).toEqual([ + [0, 1, [{ t: "a" }]], + [1, 0, [{ t: "b" }]], + ]); + }); + + test("compares cells structurally rather than by identity", () => { + expect(diffGrid(grid([[{ f: 1 }]]), grid([[{ f: 1 }]]))).toEqual([]); + expect(diffGrid(grid([[{ f: [1, 2, 3] }]]), grid([[{ f: [1, 2, 3] }]]))).toEqual([]); + expect(diffGrid(grid([[{ f: [1, 2, 3] }]]), grid([[{ f: [1, 2, 4] }]]))).toEqual([[0, 0, [{ f: [1, 2, 4] }]]]); + expect(diffGrid(grid([[{ f: 1 }]]), grid([[{ f: 1, a: 1 }]]))).toEqual([[0, 0, [{ f: 1, a: 1 }]]]); + expect(diffGrid(grid([[{ f: 1, a: 1 }]]), grid([[{ f: 1 }]]))).toEqual([[0, 0, [{ f: 1 }]]]); + expect(diffGrid(grid([[0]]), grid([[{}]]))).toEqual([[0, 0, [{}]]]); + }); + + test("throws on a dimension mismatch", () => { + expect(() => diffGrid(grid([[0, 0]]), grid([[0]]))).toThrow(TypeError); + expect(() => diffGrid(grid([[0]]), grid([[0], [0]]))).toThrow(TypeError); + }); + + test("a patch of the diff reproduces the next grid exactly", () => { + const before = grid( + [ + [0, { t: "a" }, 0], + [{ t: "b" }, 0, { t: "c" }], + ], + 1, + ); + const cases: WireCell[][][] = [ + [ + [0, { t: "a" }, 0], + [{ t: "b" }, 0, { t: "c" }], + ], + [ + [0, { t: "z" }, 0], + [{ t: "b" }, 0, { t: "c" }], + ], + [ + [{ t: "1" }, { t: "2" }, 0], + [{ t: "b" }, { t: "3" }, { t: "c" }], + ], + [ + [{ t: "q", b: [9, 9, 9] }, 0, { t: "w" }], + [0, { t: "e" }, 0], + ], + ]; + for (const cells of cases) { + const next = grid(cells, 2); + const patched = applyPatch(before, { + type: "patch", + seq: next.seq, + cols: next.cols, + rows: next.rows, + cursor: next.cursor, + runs: diffGrid(before, next), + }); + expect(patched).toEqual(next); + } + }); +}); diff --git a/packages/webterm/tests/protocol.test.ts b/packages/webterm/tests/protocol.test.ts index 3d93f4a..9e2d71c 100644 --- a/packages/webterm/tests/protocol.test.ts +++ b/packages/webterm/tests/protocol.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; import { + applyPatch, clampTerminalSize, decodeClientMessage, decodeServerMessage, + GridStream, MAX_INPUT_BYTES, splitInput, TERMINAL_CONFLICT_CLOSE_CODE, @@ -11,7 +13,7 @@ import { TERMINAL_TAKEOVER_CLOSE_REASON, utf8ByteLength, } from "../protocol"; -import type { GridMsg } from "../protocol"; +import type { GridMsg, PatchMsg } from "../protocol"; describe("terminal protocol decoders", () => { test("accepts dimension boundaries and rejects values outside them", () => { @@ -55,6 +57,7 @@ describe("terminal protocol decoders", () => { test("strictly decodes server grid and exit messages", () => { const grid: GridMsg = { type: "grid", + seq: 3, cols: 2, rows: 1, cursor: { x: 1, y: 0, visible: true }, @@ -66,6 +69,7 @@ describe("terminal protocol decoders", () => { { ...grid, extra: true }, { ...grid, cells: [[0]] }, { ...grid, cursor: { x: 2, y: 0, visible: true } }, + { ...grid, seq: undefined }, { type: "exit", code: 0, extra: true }, { type: "other" }, ]) { @@ -73,6 +77,168 @@ describe("terminal protocol decoders", () => { } expect(() => decodeServerMessage(new ArrayBuffer(1))).toThrow(); }); + + test("strictly decodes patch messages and rejects out-of-bounds runs", () => { + const patch: PatchMsg = { + type: "patch", + seq: 4, + cols: 3, + rows: 2, + cursor: { x: 2, y: 1, visible: true }, + runs: [ + [0, 1, [{ t: "a" }, 0]], + [1, 0, [{ t: "b" }]], + ], + }; + expect(decodeServerMessage(JSON.stringify(patch))).toEqual(patch); + expect(decodeServerMessage(JSON.stringify({ ...patch, runs: [] }))).toEqual({ ...patch, runs: [] }); + for (const invalid of [ + { ...patch, runs: [[0, 2, [0, 0]]] }, + { ...patch, runs: [[2, 0, [0]]] }, + { ...patch, runs: [[0, 0, []]] }, + { ...patch, runs: [[-1, 0, [0]]] }, + { ...patch, runs: [[0, 0]] }, + { ...patch, extra: true }, + ]) { + expect(() => decodeServerMessage(JSON.stringify(invalid))).toThrow(); + } + }); + + test("decodes ack and resync, rejecting non-sequence numbers", () => { + expect(decodeClientMessage('{"type":"ack","seq":0}')).toEqual({ type: "ack", seq: 0 }); + expect(decodeClientMessage('{"type":"resync"}')).toEqual({ type: "resync" }); + for (const frame of [ + '{"type":"ack","seq":-1}', + '{"type":"ack","seq":1.5}', + '{"type":"ack"}', + '{"type":"resync","seq":1}', + ]) { + expect(() => decodeClientMessage(frame)).toThrow(); + } + }); +}); + +describe("applyPatch", () => { + const prev: GridMsg = { + type: "grid", + seq: 1, + cols: 3, + rows: 2, + cursor: { x: 0, y: 0, visible: true }, + cells: [ + [0, 0, 0], + [{ t: "x" }, 0, 0], + ], + }; + + test("writes runs at their coordinates and carries seq and cursor through", () => { + const next = applyPatch(prev, { + type: "patch", + seq: 2, + cols: 3, + rows: 2, + cursor: { x: 2, y: 1, visible: false }, + runs: [[0, 1, [{ t: "a" }, { t: "b" }]]], + }); + expect(next.seq).toBe(2); + expect(next.cursor).toEqual({ x: 2, y: 1, visible: false }); + expect(next.cells[0]).toEqual([0, { t: "a" }, { t: "b" }]); + }); + + test("leaves prev untouched and shares the rows no run wrote to", () => { + const before = structuredClone(prev.cells); + const next = applyPatch(prev, { + type: "patch", + seq: 2, + cols: 3, + rows: 2, + cursor: prev.cursor, + runs: [[1, 2, [{ t: "z" }]]], + }); + expect(prev.cells).toEqual(before); + expect(next.cells[0]).toBe(prev.cells[0]!); + expect(next.cells[1]).not.toBe(prev.cells[1]!); + }); + + test("throws on a dimension mismatch", () => { + expect(() => + applyPatch(prev, { + type: "patch", + seq: 2, + cols: 4, + rows: 2, + cursor: prev.cursor, + runs: [], + }), + ).toThrow(TypeError); + }); +}); + +describe("GridStream", () => { + const grid = (seq: number, char: string): GridMsg => ({ + type: "grid", + seq, + cols: 2, + rows: 1, + cursor: { x: 0, y: 0, visible: true }, + cells: [[{ t: char }, 0]], + }); + const patch = (seq: number, char: string): PatchMsg => ({ + type: "patch", + seq, + cols: 2, + rows: 1, + cursor: { x: 1, y: 0, visible: true }, + runs: [[0, 1, [{ t: char }]]], + }); + + test("cannot start from a patch — it has nothing to anchor to", () => { + const stream = new GridStream(); + expect(stream.grid).toBeNull(); + expect(stream.accept(patch(0, "a"))).toEqual({ grid: null, reply: { type: "resync" } }); + expect(stream.grid).toBeNull(); + }); + + test("accumulates in-order patches onto the snapshot, acking each frame", () => { + const stream = new GridStream(); + expect(stream.accept(grid(4, "a"))).toEqual({ grid: grid(4, "a"), reply: { type: "ack", seq: 4 } }); + const first = stream.accept(patch(5, "b")); + expect(first.reply).toEqual({ type: "ack", seq: 5 }); + expect(first.grid?.cells).toEqual([[{ t: "a" }, { t: "b" }]]); + const second = stream.accept(patch(6, "c")); + expect(second.reply).toEqual({ type: "ack", seq: 6 }); + expect(second.grid?.cells).toEqual([[{ t: "a" }, { t: "c" }]]); + expect(stream.grid).toBe(second.grid!); + }); + + test("asks for a snapshot once per gap, then stays quiet until one arrives", () => { + const stream = new GridStream(); + stream.accept(grid(0, "a")); + expect(stream.accept(patch(2, "b"))).toEqual({ grid: null, reply: { type: "resync" } }); + expect(stream.accept(patch(3, "c"))).toEqual({ grid: null, reply: null }); + expect(stream.accept(patch(4, "d"))).toEqual({ grid: null, reply: null }); + + expect(stream.accept(grid(9, "z"))).toEqual({ grid: grid(9, "z"), reply: { type: "ack", seq: 9 } }); + const resumed = stream.accept(patch(10, "y")); + expect(resumed.reply).toEqual({ type: "ack", seq: 10 }); + expect(resumed.grid?.cells).toEqual([[{ t: "z" }, { t: "y" }]]); + }); + + test("treats a patch against different dimensions as a gap rather than throwing", () => { + const stream = new GridStream(); + stream.accept(grid(0, "a")); + const resized: PatchMsg = { ...patch(1, "b"), cols: 3, runs: [] }; + expect(stream.accept(resized)).toEqual({ grid: null, reply: { type: "resync" } }); + expect(stream.accept(patch(2, "c"))).toEqual({ grid: null, reply: null }); + }); + + test("reset returns the stream to its pre-connection state", () => { + const stream = new GridStream(); + stream.accept(grid(0, "a")); + stream.reset(); + expect(stream.grid).toBeNull(); + expect(stream.accept(patch(1, "b"))).toEqual({ grid: null, reply: { type: "resync" } }); + }); }); describe("browser protocol helpers", () => { diff --git a/packages/webterm/tests/server.test.ts b/packages/webterm/tests/server.test.ts new file mode 100644 index 0000000..dc40223 --- /dev/null +++ b/packages/webterm/tests/server.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "bun:test"; +import { FrameSequencer } from "webterm"; +import type { FrameDecision, GridMsg, PatchMsg, WireCell, WireCursor } from "webterm"; + +function grid(rows: string[], cursor: Partial = {}, cols = rows[0]?.length ?? 0): GridMsg { + const cells: WireCell[][] = rows.map((row) => + Array.from({ length: cols }, (_, c) => { + const char = row[c] ?? " "; + return char === " " ? 0 : { t: char }; + }), + ); + return { + type: "grid", + // Deliberately not 0: the sequencer stamps its own `seq` and must ignore this one. + seq: 99, + cols, + rows: rows.length, + cursor: { x: 0, y: 0, visible: true, ...cursor }, + cells, + }; +} + +function sent(decision: FrameDecision): GridMsg | PatchMsg { + if (decision.kind !== "send") throw new Error(`expected a frame, got "${decision.kind}"`); + return decision.msg; +} + +describe("FrameSequencer", () => { + test("sends the first frame as a full grid, stamped from zero", () => { + const sequencer = new FrameSequencer(); + const msg = sent(sequencer.next(grid(["ab"]))); + expect(msg.type).toBe("grid"); + expect(msg.seq).toBe(0); + }); + + test("an unchanged grid is idle and consumes no seq", () => { + const sequencer = new FrameSequencer(); + sequencer.next(grid(["ab"])); + expect(sequencer.next(grid(["ab"])).kind).toBe("idle"); + + sequencer.ack(0); + const msg = sent(sequencer.next(grid(["ac"]))); + expect(msg.seq).toBe(1); + }); + + test("a changed grid is a patch carrying only the changed run", () => { + const sequencer = new FrameSequencer(); + sequencer.next(grid(["abc", "def"])); + const msg = sent(sequencer.next(grid(["abc", "dXf"]))); + expect(msg).toEqual({ + type: "patch", + seq: 1, + cols: 3, + rows: 2, + cursor: { x: 0, y: 0, visible: true }, + runs: [[1, 1, [{ t: "X" }]]], + }); + }); + + test("a cursor-only move is a patch with no runs, not idle", () => { + const sequencer = new FrameSequencer(); + sequencer.next(grid(["ab"])); + const msg = sent(sequencer.next(grid(["ab"], { x: 1 }))); + expect(msg).toMatchObject({ type: "patch", seq: 1, runs: [], cursor: { x: 1, y: 0, visible: true } }); + }); + + test("a cursor color change is a change even when the position holds", () => { + const sequencer = new FrameSequencer(); + sequencer.next(grid(["ab"], { color: [1, 2, 3] })); + expect(sequencer.next(grid(["ab"], { color: [1, 2, 4] })).kind).toBe("send"); + }); + + test("a dimension change forces a full grid, because applyPatch cannot cross it", () => { + const sequencer = new FrameSequencer(); + sequencer.next(grid(["ab"])); + const wider = sent(sequencer.next(grid(["abc"]))); + expect(wider).toMatchObject({ type: "grid", seq: 1, cols: 3, rows: 1 }); + + sequencer.ack(1); + const taller = sent(sequencer.next(grid(["abc", "def"]))); + expect(taller).toMatchObject({ type: "grid", seq: 2, cols: 3, rows: 2 }); + }); + + test("blocks once maxUnackedFrames are in flight, and an ack releases it", () => { + const sequencer = new FrameSequencer({ maxUnackedFrames: 2 }); + expect(sequencer.next(grid(["a."])).kind).toBe("send"); + expect(sequencer.next(grid(["b."])).kind).toBe("send"); + expect(sequencer.next(grid(["c."])).kind).toBe("blocked"); + expect(sequencer.next(grid(["d."])).kind).toBe("blocked"); + + sequencer.ack(0); + const msg = sent(sequencer.next(grid(["e."]))); + // Blocked frames are skipped, not queued: the client gets the newest state. + expect(msg).toMatchObject({ type: "patch", seq: 2, runs: [[0, 0, [{ t: "e" }]]] }); + }); + + test("ignores an ack for a frame that was never sent, and a stale one", () => { + const sequencer = new FrameSequencer({ maxUnackedFrames: 1 }); + sequencer.next(grid(["a."])); + sequencer.ack(7); + expect(sequencer.next(grid(["b."])).kind).toBe("blocked"); + + sequencer.ack(0); + expect(sequencer.next(grid(["b."])).kind).toBe("send"); + sequencer.ack(0); + expect(sequencer.next(grid(["c."])).kind).toBe("blocked"); + }); + + test("blocks while the transport reports congestion", () => { + let congested = true; + const sequencer = new FrameSequencer({ congested: () => congested }); + expect(sequencer.next(grid(["a."])).kind).toBe("blocked"); + congested = false; + expect(sent(sequencer.next(grid(["a."]))).type).toBe("grid"); + }); + + test("requestResync forces a full grid and reopens a closed window", () => { + const sequencer = new FrameSequencer({ maxUnackedFrames: 1 }); + sequencer.next(grid(["a."])); + expect(sequencer.next(grid(["b."])).kind).toBe("blocked"); + + // Without reopening the window this would stay blocked forever: a client + // that lost sequence has stopped acking. + sequencer.requestResync(); + expect(sent(sequencer.next(grid(["b."])))).toMatchObject({ type: "grid", seq: 1 }); + }); + + test("the ack timeout sends one full snapshot rather than freezing forever", () => { + let clock = 1_000; + const sequencer = new FrameSequencer({ maxUnackedFrames: 1, ackTimeoutMs: 5_000, now: () => clock }); + sequencer.next(grid(["a."])); + + clock += 4_999; + expect(sequencer.next(grid(["b."])).kind).toBe("blocked"); + + clock += 1; + expect(sent(sequencer.next(grid(["b."])))).toMatchObject({ type: "grid", seq: 1 }); + // The valve reopened the window, so the next frame is paced normally again. + expect(sequencer.next(grid(["c."])).kind).toBe("blocked"); + }); +});