From 2c2ede52feb0346bc292096147b15c8a268cc534 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Mon, 27 Jul 2026 15:08:18 -0500 Subject: [PATCH 1/3] Add patch frames and the delta codec to the webterm protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every webterm frame is a full grid snapshot today, which measures at 10 KB per keystroke on a 120x40 terminal and leaves a client 39 s behind after 4 s of build output on a 400 kbps link. This lands the wire format and the pure codec for sending deltas instead: - `grid` gains `seq`, a per-connection frame counter - `patch` carries runs of consecutive changed cells against frame `seq - 1` - `ack` and `resync` let a client pace the server and recover from a gap - `diffGrid` / `applyPatch` implement the two ends, with runs rather than per-cell entries because a scroll changes whole spans at once Nothing emits or consumes a patch yet — the bridge's client-message switch ignores the new frames, so behavior is unchanged. Teaching the clients to apply patches lands next, and only then does the server start sending them. --- apps/cli/src/attach.ts | 2 +- apps/cli/tests/render-grid.test.ts | 1 + .../docs/src/content/docs/packages/webterm.md | 55 ++++++--- packages/fleet-client/src/data/useWebterm.ts | 2 +- .../fleet-client/tests/useWebterm.test.ts | 1 + packages/webterm/encode.ts | 70 ++++++++++- packages/webterm/index.ts | 7 +- packages/webterm/protocol.ts | 115 ++++++++++++++++-- packages/webterm/tests/encode.test.ts | 110 ++++++++++++++++- packages/webterm/tests/protocol.test.ts | 100 ++++++++++++++- 10 files changed, 433 insertions(+), 30 deletions(-) diff --git a/apps/cli/src/attach.ts b/apps/cli/src/attach.ts index e80ddda..da0398f 100644 --- a/apps/cli/src/attach.ts +++ b/apps/cli/src/attach.ts @@ -138,7 +138,7 @@ 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); + else if (msg.type === "exit") teardown(msg.code); }; 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/packages/webterm.md b/apps/docs/src/content/docs/packages/webterm.md index b4e88ad..bf2c54a 100644 --- a/apps/docs/src/content/docs/packages/webterm.md +++ b/apps/docs/src/content/docs/packages/webterm.md @@ -38,25 +38,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 +164,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. | @@ -235,9 +255,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 @@ -292,7 +314,7 @@ ws.onmessage = (event) => { try { const msg = decodeServerMessage(event.data); if (msg.type === "grid") paint(msg); - else console.log("shell exited", msg.code); + else if (msg.type === "exit") console.log("shell exited", msg.code); } catch { ws.close(INVALID_MESSAGE_CLOSE_CODE, INVALID_MESSAGE_CLOSE_REASON); } @@ -334,7 +356,10 @@ 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`. diff --git a/packages/fleet-client/src/data/useWebterm.ts b/packages/fleet-client/src/data/useWebterm.ts index e377250..9de2789 100644 --- a/packages/fleet-client/src/data/useWebterm.ts +++ b/packages/fleet-client/src/data/useWebterm.ts @@ -44,7 +44,7 @@ export function handleServerFrame( try { const msg = decodeServerMessage(data); if (msg.type === "grid") opts.onGrid?.(msg); - else opts.onExit?.(msg.code); + else if (msg.type === "exit") opts.onExit?.(msg.code); } catch { close(INVALID_MESSAGE_CLOSE_CODE, INVALID_MESSAGE_CLOSE_REASON); } diff --git a/packages/fleet-client/tests/useWebterm.test.ts b/packages/fleet-client/tests/useWebterm.test.ts index 3a4bb90..e54a92a 100644 --- a/packages/fleet-client/tests/useWebterm.test.ts +++ b/packages/fleet-client/tests/useWebterm.test.ts @@ -39,6 +39,7 @@ describe("browser server-message handling", () => { handleServerFrame( JSON.stringify({ type: "grid", + seq: 0, cols: 1, rows: 1, cursor: { x: 0, y: 0, visible: true }, diff --git a/packages/webterm/encode.ts b/packages/webterm/encode.ts index 73ef504..64dea12 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,60 @@ export function serializeGrid(term: Terminal): GridMsg { cells, }; } + +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..78b4033 100644 --- a/packages/webterm/index.ts +++ b/packages/webterm/index.ts @@ -7,7 +7,7 @@ */ export { TerminalBridge, type TerminalBridgeOptions } from "./server"; -export { serializeGrid, encodeCell } from "./encode"; +export { serializeGrid, encodeCell, diffGrid } from "./encode"; export { ATTR, @@ -33,6 +33,7 @@ export { TERMINAL_TAKEOVER_QUERY, decodeClientMessage, decodeServerMessage, + applyPatch, utf8ByteLength, clampTerminalSize, splitInput, @@ -40,8 +41,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..386a09f 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,36 @@ 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 function utf8ByteLength(value: string): number { return utf8.encode(value).byteLength; } 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..9667ac0 100644 --- a/packages/webterm/tests/protocol.test.ts +++ b/packages/webterm/tests/protocol.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + applyPatch, clampTerminalSize, decodeClientMessage, decodeServerMessage, @@ -11,7 +12,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 +56,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 +68,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 +76,101 @@ 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("browser protocol helpers", () => { From 363f88b17f250f8170356fc137d26fd7dfdc94c2 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Mon, 27 Jul 2026 15:26:07 -0500 Subject: [PATCH 2/3] Fold webterm patches back into snapshots on the client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both clients now drive a shared `GridStream`: it holds the current snapshot, applies in-order patches to it, acks every frame it actually applied, and asks for a full `grid` when it sees a sequence gap. Exactly one resync goes out per gap — re-requesting on each following patch would pile a burst of requests onto the congested link that caused the gap. The renderers are untouched: both still receive complete `GridMsg` objects. The server still only sends full snapshots, so nothing changes on the wire yet; this is what has to land before it can start sending deltas. --- apps/cli/src/attach.ts | 22 +++-- .../docs/src/content/docs/packages/webterm.md | 68 ++++++++++++-- packages/fleet-client/src/data/useWebterm.ts | 35 +++++-- .../fleet-client/tests/useWebterm.test.ts | 92 ++++++++++++++----- packages/webterm/index.ts | 2 + packages/webterm/protocol.ts | 61 ++++++++++++ packages/webterm/tests/protocol.test.ts | 68 ++++++++++++++ 7 files changed, 304 insertions(+), 44 deletions(-) diff --git a/apps/cli/src/attach.ts b/apps/cli/src/attach.ts index da0398f..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 if (msg.type === "exit") 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/docs/src/content/docs/packages/webterm.md b/apps/docs/src/content/docs/packages/webterm.md index bf2c54a..76f9776 100644 --- a/apps/docs/src/content/docs/packages/webterm.md +++ b/apps/docs/src/content/docs/packages/webterm.md @@ -181,6 +181,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 @@ -275,12 +312,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, @@ -288,6 +327,7 @@ import { } from "webterm/protocol"; const ws = new WebSocket(url); +const stream = new GridStream(); let initialized = false; function sendSize(cols: number, rows: number) { @@ -313,8 +353,13 @@ ws.onmessage = (event) => { } try { const msg = decodeServerMessage(event.data); - if (msg.type === "grid") paint(msg); - else if (msg.type === "exit") 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); } @@ -337,10 +382,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. @@ -362,4 +411,7 @@ 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`. +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. diff --git a/packages/fleet-client/src/data/useWebterm.ts b/packages/fleet-client/src/data/useWebterm.ts index 9de2789..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 if (msg.type === "exit") 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/tests/useWebterm.test.ts b/packages/fleet-client/tests/useWebterm.test.ts index e54a92a..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,39 +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", - seq: 0, - 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/webterm/index.ts b/packages/webterm/index.ts index 78b4033..f6d29e6 100644 --- a/packages/webterm/index.ts +++ b/packages/webterm/index.ts @@ -34,6 +34,8 @@ export { decodeClientMessage, decodeServerMessage, applyPatch, + GridStream, + type GridStreamResult, utf8ByteLength, clampTerminalSize, splitInput, diff --git a/packages/webterm/protocol.ts b/packages/webterm/protocol.ts index 386a09f..fb9ed77 100644 --- a/packages/webterm/protocol.ts +++ b/packages/webterm/protocol.ts @@ -305,6 +305,67 @@ export function applyPatch(prev: GridMsg, patch: PatchMsg): GridMsg { 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/tests/protocol.test.ts b/packages/webterm/tests/protocol.test.ts index 9667ac0..9e2d71c 100644 --- a/packages/webterm/tests/protocol.test.ts +++ b/packages/webterm/tests/protocol.test.ts @@ -4,6 +4,7 @@ import { clampTerminalSize, decodeClientMessage, decodeServerMessage, + GridStream, MAX_INPUT_BYTES, splitInput, TERMINAL_CONFLICT_CLOSE_CODE, @@ -173,6 +174,73 @@ describe("applyPatch", () => { }); }); +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", () => { test("clamps and truncates generated dimensions", () => { expect(clampTerminalSize(-1, Number.NaN)).toEqual({ cols: 1, rows: 1 }); From 3971ecf00845e8332c7a012bd7007e6c998c0867 Mon Sep 17 00:00:00 2001 From: FireSquid6 Date: Mon, 27 Jul 2026 15:49:58 -0500 Subject: [PATCH 3/3] Send webterm deltas, pace them against the client, and compress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge now runs a `FrameSequencer` that decides what each frame should be: a full `grid` when there is no baseline, after a resize, or on a `resync`, a `patch` against the last frame *actually sent* otherwise, and nothing at all when neither cells nor cursor moved. Pacing is the other half. At most `maxUnackedFrames` (2) frames may be in flight before the sequencer stops producing, and the ship additionally blocks while its own socket is backed up. Because the diff baseline is the last frame sent rather than the last one computed, skipping is lossless — the next patch simply covers more. A `resync` reopens the window as well as forcing a full frame, without which a client that lost sequence would wedge the stream. The three servers also accept permessage-deflate now; Bun's client WebSocket already offers it, so every hop compresses. Measured over a simulated link, 4 s of build output on a 400 kbps connection: the client used to end up 39.2 s behind having received 2.1 MB, and now ends up 0.41 s behind having received 5 KB. Typing 24 characters cost 147 KB and now costs 1 KB. --- .../src/content/docs/concepts/terminals.md | 50 ++++-- .../docs/src/content/docs/packages/webterm.md | 87 +++++++++- .../src/content/docs/reference/ship-api.md | 21 ++- packages/fleet-bridge/src/api/index.ts | 5 +- packages/fleet-client/src/index.ts | 3 + packages/fleet-ship/src/api/index.ts | 6 +- packages/fleet-ship/src/api/workspaces.ts | 24 +++ packages/fleet-ship/tests/terminal-ws.test.ts | 44 +++++ packages/webterm/encode.ts | 3 +- packages/webterm/index.ts | 8 +- packages/webterm/server.ts | 164 +++++++++++++++++- packages/webterm/tests/server.test.ts | 141 +++++++++++++++ 12 files changed, 517 insertions(+), 39 deletions(-) create mode 100644 packages/webterm/tests/server.test.ts 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 76f9776..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: @@ -234,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: @@ -265,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, }); }, @@ -304,7 +363,10 @@ knows their numbering. `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. @@ -415,3 +477,10 @@ runs of `diffGrid(a, b)` to `a` reproduces `b`. `GridStream` is covered on top o 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/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-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 64dea12..9cc9d06 100644 --- a/packages/webterm/encode.ts +++ b/packages/webterm/encode.ts @@ -103,7 +103,8 @@ export function serializeGrid(term: Terminal, seq = 0): GridMsg { }; } -function colorsEqual(a: WireColor | undefined, b: WireColor | undefined): boolean { +/** 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]; diff --git a/packages/webterm/index.ts b/packages/webterm/index.ts index f6d29e6..2840d2e 100644 --- a/packages/webterm/index.ts +++ b/packages/webterm/index.ts @@ -6,7 +6,13 @@ * from `webterm` on the server for the bridge + encoder. */ -export { TerminalBridge, type TerminalBridgeOptions } from "./server"; +export { + TerminalBridge, + type TerminalBridgeOptions, + FrameSequencer, + type FrameSequencerOptions, + type FrameDecision, +} from "./server"; export { serializeGrid, encodeCell, diffGrid } from "./encode"; export { 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/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"); + }); +});