a73x

c2344ddb

docs: record cells on the wire, its measurements and the clean break

a73x   2026-09-04 18:04

Commit message
docs: record cells on the wire, its measurements and the clean break

decisions.md gains the full entry beside the size measurement: the three
parses a byte used to take and the two that remain, the verbatim-paint
finding that makes this a trade for the terminal client, the CellRow format
and why its run header is a delta, the renumbered frames and why an unknown
type dropping is worth a blank tile, the clean break and the daemon-first
upgrade, the bench pair against main, the wasm size, the trailing-space rule
and the three places it is stated, and the readers the break exposed --
selection's missing pin, `mux a`'s SGR stripper, the clamped scrollback echo,
the chunk skipped across a narrowing resize. It closes on why ghostty's own
page-memcpy protocol is right for `mux d upgrade` and wrong for the wire.

The bench pair is the part worth reading twice. Steady-state traffic went
7751 bytes to 7860 on the same workload, while an attach to a fresh session
went 5644 bytes to 86, so the ratio the bench prints got worse only because
its denominator collapsed.

CLAUDE.md: the summary says the wire carries cells, the one-replay-core
invariant says it replays over a grid, a new invariant states the
dump-compare rule and names the three places it lives, rule 4 has two debts
now rather than three, and every file figure this branch moved is re-measured.
README: the client is no longer described as running an engine of its own.
xversion.sh: a line saying there is no old side to grade until the next
release.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsWfuJFQbTfGtKZLS5qw4q

CLAUDE.md
Old New
@@ -1,8 +1,9 @@
1 # mux 1 # mux
2 2
3 Terminal multiplexer: ghostty-vt engine runs authoritatively in `mux d`, replicated 3 Terminal multiplexer: ghostty-vt engine runs authoritatively in `mux d`; the wire
4 in the client. Attach = one snapshot, then row deltas. Zig; Linux and 4 carries its grid as cells and the client copies them into a grid of its own.
5 Apple-silicon macOS; prototype. 5 Attach = one snapshot, then row deltas. Zig; Linux and Apple-silicon macOS;
6 prototype.
6 7
7 ## Toolchain (pinned — system zig will NOT build this) 8 ## Toolchain (pinned — system zig will NOT build this)
8 9
@@ -45,7 +46,7 @@ costs ~800k tokens; every token stays in context and is re-billed each turn.
45 - **Never `cat` these:** `src/server/server.zig` (3.3k lines, ~31k tok; its tests are 46 - **Never `cat` these:** `src/server/server.zig` (3.3k lines, ~31k tok; its tests are
46 siblings in `src/server/`, none over 1.8k; the agent relay and the 47 siblings in `src/server/`, none over 1.8k; the agent relay and the
47 session table are `src/server/server_agent.zig` and `src/server/server_sessions.zig`), 48 session table are `src/server/server_agent.zig` and `src/server/server_sessions.zig`),
48 `src/tui/interact.zig` (4.8k), 49 `src/tui/interact.zig` (4.8k), `src/engine/protocol.zig` (3.0k),
49 `docs/decisions.md` (8.8k). `test/e2e.sh` is a 188-line runner now; the 50 `docs/decisions.md` (8.8k). `test/e2e.sh` is a 188-line runner now; the
50 bodies are `test/e2e_NN_*.sh` (`E2E_ONLY=<group>` runs one). 51 bodies are `test/e2e_NN_*.sh` (`E2E_ONLY=<group>` runs one).
51 Use `grep -n` for the symbol, then `sed -n 'A,Bp'` for a window. 52 Use `grep -n` for the symbol, then `sed -n 'A,Bp'` for a window.
@@ -54,7 +55,8 @@ costs ~800k tokens; every token stays in context and is re-billed each turn.
54 endings, the keyboard loop) with `wall_pump.zig` (0.9k, one tile's 55 endings, the keyboard loop) with `wall_pump.zig` (0.9k, one tile's
55 thread), `wall_host.zig` (0.5k), `wall_picker.zig` (0.9k), 56 thread), `wall_host.zig` (0.5k), `wall_picker.zig` (0.9k),
56 `wall_layout.zig` (0.5k) and its tests in `src/tui/wall_test_*.zig` 57 `wall_layout.zig` (0.5k) and its tests in `src/tui/wall_test_*.zig`
57 (none over 1.4k). 58 (none over 1.4k). The wire side is small: `src/engine/grid.zig` is 0.3k,
59 `replica.zig` 0.6k, `delta.zig` 0.5k, `engine.zig` 1.8k, `paint.zig` 1.3k.
58 Every figure here is `wc -l` on 2026-09-04; re-measure before trusting one. 60 Every figure here is `wc -l` on 2026-09-04; re-measure before trusting one.
59 - Every module has a `//!` header stating its contract. `head -12` on it 61 - Every module has a `//!` header stating its contract. `head -12` on it
60 answers most "what is this" questions for ~200 tokens. 62 answers most "what is this" questions for ~200 tokens.
@@ -101,9 +103,11 @@ by number. Rule 7 is the platform ban: `std.os.linux`, `/proc`, `memfd`,
101 Linux-only pty ioctls and `MSG_NOSIGNAL` may appear only under `src/os/`, 103 Linux-only pty ioctls and `MSG_NOSIGNAL` may appear only under `src/os/`,
102 comments included — 104 comments included —
103 the roots `server_os.zig` and `client_os.zig` are the contract and their 105 the roots `server_os.zig` and `client_os.zig` are the contract and their
104 per-OS children the spellings (spec 2026-09-03). Rule 4's three remaining 106 per-OS children the spellings (spec 2026-09-03). Rule 4's two remaining
105 debts are the markers in `engine.zig`, `protocol.zig` and `keymap.zig`, each 107 debts are the markers in `engine.zig` and `keymap.zig`, each of which
106 of which produces VT bytes by contract. 108 produces VT bytes by contract. `protocol.zig`'s marker went with the cells
109 flip: it existed because `composeDelta` stamped CUP and EL2 around a rendered
110 row, and there is no rendered row on the wire any more (2026-09-04).
107 `predict.zig` sits under `src/tui/` with the rest of the wall, so the overlay 111 `predict.zig` sits under `src/tui/` with the rest of the wall, so the overlay
108 lives beside the code that paints it and nowhere `term` would find it. 112 lives beside the code that paints it and nowhere `term` would find it.
109 `spawn` lives under `src/os/` with the rest of the platform layer: asking 113 `spawn` lives under `src/os/` with the rest of the platform layer: asking
@@ -126,8 +130,16 @@ own. Test fixtures in `test/`:
126 - **`proxy.zig` and the QUIC modules carry opaque bytes and know nothing of 130 - **`proxy.zig` and the QUIC modules carry opaque bytes and know nothing of
127 frames.** Keep the wire contract — `term` — out of `proxy.zig`'s imports. 131 frames.** Keep the wire contract — `term` — out of `proxy.zig`'s imports.
128 - **`predict.zig` output never enters the replica.** It is an overlay. 132 - **`predict.zig` output never enters the replica.** It is an overlay.
129 - **One replay core.** CLI, wasm, and test fixtures all go through `replica.zig`. 133 - **One replay core.** CLI, wasm, and test fixtures all go through `replica.zig`,
134 over a `grid`, never an engine — the client parses no VT.
130 Do not hand-roll a second applier. 135 Do not hand-roll a second applier.
136 - **A client dump equals a daemon dump byte for byte, modulo trailing spaces
137 per row.** The rule is stated in three places, and all three must keep
138 saying the same thing: the header on `grid.dumpRowsPlain` (which
139 `Grid.dumpPlain` calls), the server harness's `trimRowTails`, and the e2e
140 `converged_quiet` strip. Ghostty's VT formatter
141 trimmed trailing whitespace on the old wire, so no replica ever held a typed
142 trailing space; the cell encoder's default-space cutoff is parity with that.
131 - **Latest wins.** The grid follows the most recently active client. 143 - **Latest wins.** The grid follows the most recently active client.
132 - **Every tile claims its rect.** The layout tree (`client.layout.Tree`) is 144 - **Every tile claims its rect.** The layout tree (`client.layout.Tree`) is
133 client-local, owned by the keyboard thread under `paint_mu`; `relayout` 145 client-local, owned by the keyboard thread under `paint_mu`; `relayout`
README.md
Old New
@@ -1,10 +1,11 @@
1 # mux 1 # mux
2 2
3 Terminal multiplexer where detach/reattach is **state sync, not replay**: 3 Terminal multiplexer where detach/reattach is **state sync, not replay**:
4 the terminal engine (ghostty-vt) runs authoritatively in a daemon (`mux d`) 4 the terminal engine (ghostty-vt) runs authoritatively in a daemon (`mux d`),
5 and replicated in the client (`mux`). Attach costs one snapshot; everything 5 and the wire carries its grid as cells that the client (`mux`) copies into a
6 after is row deltas; a dropped connection is a non-event. Linux and 6 grid of its own. Attach costs one snapshot; everything after is row deltas;
7 Apple-silicon macOS, prototype (see [Status](#status)). 7 a dropped connection is a non-event. Linux and Apple-silicon macOS, prototype
8 (see [Status](#status)).
8 9
9 ## Build 10 ## Build
10 11
@@ -834,14 +835,17 @@ one dials the other from the inside.
834 835
835 ## How it works 836 ## How it works
836 837
837 Both ends run a real terminal engine. The daemon's is authoritative: shell 838 One real terminal engine runs, and it is the daemon's: shell output mutates
838 output mutates it, and clients receive a sequence-numbered stream of row 839 it, and clients receive a sequence-numbered stream of row deltas. A delta row
839 deltas. A client that attaches — or *re*attaches after a kill, a network 840 is the daemon's own cells — the glyph, the style, the wide flag — which the
840 tear, or a laptop sleep — tells the daemon what sequence number it already 841 client copies into a grid and paints from. No client parses a terminal escape
841 holds, and the daemon answers with a delta instead of a repaint whenever it 842 sequence of its own. A client that attaches — or *re*attaches after a kill,
842 can. Transport is deliberately dumb: the same frames ride a unix socket, an 843 a network tear, or a laptop sleep — tells the daemon what sequence number it
843 ssh pipe (`mux d proxy` is a byte pump with zero protocol knowledge), or a 844 already holds, and the daemon answers with a delta instead of a repaint
844 QUIC stream, and the wire protocol has survived all three without changing. 845 whenever it can. Transport is deliberately dumb: the same frames ride a unix
846 socket, an ssh pipe (`mux d proxy` is a byte pump with zero protocol
847 knowledge), or a QUIC stream, and the wire protocol has survived all three
848 without changing.
845 849
846 Every decision and measurement: `docs/decisions.md`. What's next: `git-collab 850 Every decision and measurement: `docs/decisions.md`. What's next: `git-collab
847 issue list` (`docs/roadmap.md` is the older ranked queue). 851 issue list` (`docs/roadmap.md` is the older ranked queue).
docs/decisions.md
Old New
@@ -8970,3 +8970,199 @@ text. Prose is a third spaces, so blanking them cost 36% of the screen
8970 space is also the better answer for copy: an interior space is a space. 8970 space is also the better answer for copy: an interior space is a space.
8971 Trailing blanks were never the question — the row already ends at the last 8971 Trailing blanks were never the question — the row already ends at the last
8972 cell that is not one. 8972 cell that is not one.
8973
8974 ## 2026-09-04 — cells on the wire
8975
8976 The client stopped parsing VT. The daemon's ghostty-vt is now the only
8977 terminal emulator in the system: the wire carries the cells its grid holds,
8978 the client copies them into a grid of its own (`src/engine/grid.zig`), and
8979 every painter reads that grid. The design is
8980 `docs/superpowers/specs/2026-09-04-cells-on-the-wire-design.md`.
8981
8982 ### The two findings the change rests on
8983
8984 A byte of session output used to be parsed three times before anyone saw it.
8985 The daemon's ghostty-vt parsed it into the authoritative grid; `composeDelta`
8986 rendered a changed row back into VT — CUP, EL2 and SGR around
8987 `dumpVtRow`'s bytes — and the client fed that to a SECOND ghostty-vt to keep
8988 its replica; the host terminal then parsed the client's paint. The middle
8989 parse was the only reason a client linked an emulator at all, and the
8990 rule-4 exemption on `protocol.zig` said so in its own words.
8991
8992 The second finding is why this is a trade rather than a cost. On the common
8993 path the terminal client never used its replica: `paintDeltaClipped` wrote
8994 the daemon's row bytes to the tty verbatim and re-dumped from the replica
8995 only for a row under a selection or a pane narrower than the grid. So for the
8996 terminal client the emulator was carried to serve two special cases, and one
8997 of those cases — the over-wide pane, where a grid-wide row's surplus landed
8998 on the neighbour — disappears entirely, because every row now paints from the
8999 grid clipped to the pane.
9000
9001 ### The wire
9002
9003 A row is a `CellRow`: `u16 LE ncells`, then runs until `ncells` cells have
9004 been read. Cells past `ncells` are default-style blanks, so the encoder stops
9005 at the last cell that is not one; a cell holding a bare background colour is
9006 NOT blank, or a coloured `EL` would vanish. A run is `u16 LE count` ++ `u8
9007 mask` ++ only the fields the mask names — bit 0 flags, bit 1 fg, bit 2 bg,
9008 bit 3 ul, bit 7 ascii — and a present colour is a tag byte plus the bytes
9009 that tag needs: none, a palette index, or three. Absent fields are unchanged
9010 from the previous run of the SAME row, and every row starts again from the
9011 default style, so a row is self-contained and no reader needs the row before
9012 it. A non-ascii cell is `u8 head` = `wide << 6 | text_len` then that many
9013 bytes of UTF-8, the whole grapheme cluster, capped at 63 bytes.
9014
9015 The header is a DELTA because SGR is one. The first cut was absolute — 16
9016 bytes of flags, fg, bg and ul on every run — and the measurement gate refused
9017 it: a syntax-highlighted line or an htop row changes style eight to ten times
9018 and paid 130 to 160 bytes of header against roughly 70 bytes of text, where
9019 VT spends four to seven bytes on the same change. The two measurement tables
9020 are in the entry above; the delta header is the whole difference between
9021 them.
9022
9023 ### The frames took new numbers
9024
9025 `snapshot` is `0x95`, `delta` `0x96`, `scrollback_chunk` `0x97`. The old
9026 `0x81`, `0x85` and `0x87` are retired with a comment and never reused. The
9027 reason is what a mixed pair does: an unknown frame type is DROPPED by both
9028 readers, so a client of this build meeting a daemon of `v0.0.1-16` shows a
9029 blank tile and nothing else. Keeping the numbers would have fed VT bytes to a
9030 cell reader and cell bytes to a VT reader, and both paint noise on a screen a
9031 person is reading. A blank tile is a bug report; a screen of garbage is a
9032 support call.
9033
9034 So this is a clean break, stated as policy: a client of this build and a
9035 daemon of `v0.0.1-16` exchange no replay frames at all. The migration is
9036 `mux d upgrade` on every box, daemon-first — a wall of upgraded clients
9037 against an old daemon shows blank tiles until the daemon follows, and the
9038 release note has to say that in those words. `make xversion` has no old side
9039 to grade until the next release
9040 is cut, so it is out of this branch's gate; the gate re-enters the moment a
9041 release exists that speaks these frames, and `test/xversion.sh` carries a
9042 comment saying `XVER_OLD_WORKTREE` must be this branch or newer.
9043
9044 ### The real bytes
9045
9046 `make bench` types 120 characters into a session with small gaps and compares
9047 the delta bytes actually sent against the measured full-snapshot equivalent.
9048 Both runs are the same workload on the same machine, in an isolated
9049 `XDG_STATE_HOME`, each tree building in its own cache:
9050
9051 ```
9052 main b2cc0a99 snapshot_bytes=5644 deltas=120 delta_bytes=7751 snapshot_equiv_bytes=690505 ratio 1%
9053 cells 4b471f1 snapshot_bytes=86 deltas=120 delta_bytes=7860 snapshot_equiv_bytes=17930 ratio 43%
9054 ```
9055
9056 The number that matters is the one that barely moved. Steady-state traffic is
9057 7751 bytes against 7860, one and a half percent more for 120 single-character
9058 deltas, which is what the synthetic screens predicted: a cell row costs about
9059 what the VT row it replaces cost.
9060
9061 Everything else in the table moved because the SNAPSHOT collapsed. An attach
9062 to a fresh 80x24 session was 5644 bytes of VT and is 86 bytes of cells: a
9063 blank row is `ncells = 0`, two bytes, where the VT snapshot spent a CUP, an
9064 EL and a style reset on each of the 24 rows whether anything was on them or
9065 not. `snapshot_equiv_bytes` accrues that same figure once per delta, so it
9066 fell by the same factor, and the ratio the bench prints is a fraction whose
9067 DENOMINATOR shrank 38-fold. Reading the 1% and the 43% as a regression gets
9068 it exactly backwards; both runs sent the same bytes, and the second one made
9069 a cold attach to that session sixty-five times cheaper.
9070
9071 That does leave the bench's own kill criterion sitting seven points away from
9072 tripping — it fails at 50% — on a change that only improved things. The
9073 criterion was written when a snapshot was expensive and a delta had to prove
9074 it was worth having. It is worth restating against absolute bytes per
9075 keystroke before it fails for the wrong reason.
9076
9077 `mux_core.wasm` is 19080 bytes, from 356344. That is the whole terminal
9078 emulator leaving the browser client: the wasm build has no ghostty dependency
9079 at all now, and what remains is the wire contract, the grid and the replay
9080 core. The 356344 figure is main's artefact as built on 2026-09-03 from a tree
9081 close to this branch's base, read off disk rather than rebuilt.
9082
9083 ### Trailing spaces are the one difference two correct grids may have
9084
9085 `Grid.dumpPlain` must equal `Engine.dumpPlain` for the same screen byte for
9086 byte, MODULO trailing spaces per row. That qualifier is not a concession, it
9087 is the old behaviour written down. Ghostty's VT formatter trimmed trailing
9088 whitespace, so the VT rows the old wire carried never held a typed trailing
9089 space and no replica ever had one either; the e2e convergence check has
9090 always stripped them as a formatting difference between two correct grids.
9091 The cell encoder's default-space cutoff — the row ends at the last cell that
9092 is not a default blank — reproduces exactly that, and it is also what keeps a
9093 space-padded curses row off the wire.
9094
9095 The rule is now stated in three places and all three must keep saying the
9096 same thing: the header on `grid.dumpRowsPlain`, which `Grid.dumpPlain` is one
9097 caller of; `server_test_harness.trimRowTails`, which the attach tests compare
9098 through; and `converged_quiet` in `test/e2e_lib.sh`. The cost, unchanged from
9099 the VT path, is that selecting a line that ends in a typed space loses that
9100 space.
9101
9102 ### What the break exposed and what it left behind
9103
9104 Selection had no pin. `paint.rowToVt` paints a span inverted and PLAIN,
9105 snapped outward to whole glyphs, the way `Engine.dumpVtRowSpan` did — and
9106 nothing anywhere asserted that a selected cell drops the style it carries.
9107 The test now exists (`test: pin that a selection paints its cells plain,
9108 whatever style they carry`), written because the flip needed it, not because
9109 anything failed.
9110
9111 `mux a` was a reader of the wire that nobody had counted as one. It attaches
9112 at 0x0, holds no grid and draws nothing, so it looked like a pure frame
9113 client — but `mux a run` reports what a command printed, and it got that by
9114 fetching the rows between the OSC 133 marks and stripping the SGR out of
9115 them. Cells went through that stripper untouched, so `output` came back with
9116 a NUL and a 0x80 ahead of every line. `make ci` caught it on the one agent
9117 leg that compares a transcript exactly rather than with `contains`. The whole
9118 e2e suite ran green past it, because no e2e leg reads `mux a`'s output field
9119 at all. The fix decodes the chunk through `grid.decodeRows`, the same call the
9120 terminal client's scrollback page makes, and renders it with
9121 `grid.dumpRowsPlain`, which is `Grid.dumpPlain`'s body lifted out for a caller
9122 that owns rows without a grid. The lesson for the next wire change is the
9123 module table: every row that imports `term.protocol` is a reader of the wire,
9124 whether or not it paints.
9125
9126 `onFetchScrollback` now echoes the CLAMPED start and count rather than the
9127 request's own. `web/mux.js` drops a reply whose echo differs from the
9128 outstanding request and rolls the scroll position back after its timeout, so
9129 in the one window where the daemon clamps — the client's history count stale
9130 AHEAD of the daemon's, after a `\e[3J` or an alt-screen flip, with a wheel
9131 request already in flight — the browser now waits out that timeout where it
9132 used to paint a short page labelled with the request's start. The old page
9133 was mislabelled, which is the thing the JS check exists to prevent, so this
9134 is the better of the two. If the wait is ever unwanted the fix is JS-side:
9135 accept a reply whose start matches the request and whose count is no larger.
9136 The CLI ignores the echoed start entirely.
9137
9138 A scrollback chunk in flight across a NARROWING resize is skipped rather than
9139 painted. `interact.scrollbackPage` decodes at the replica's current width, so
9140 a chunk encoded at the old wider grid fails to decode and the scroll view
9141 stays unpainted until the next key. The old path blitted whatever VT arrived.
9142 The refusal is correct — those rows would not fit the pane — and the race is
9143 rare; it is written down here so nobody later reads the `.skip` as a bug.
9144
9145 `term` links libc for two of its own TESTS. Two `protocol.zig` tests need a
9146 socket whose peer refuses to read, and the pinned Zig 0.15.2 has no
9147 `socketpair` in `std.posix`, so they call `std.c`'s. The flag reaches only
9148 the native module graph: `mux_core.wasm` and the wasm check modules are built
9149 by a separate path that passes no `link_libc`, and no client binary gains a
9150 link it did not already declare. The row used to get libc for free through
9151 ghostty-vt; dropping the emulator made the dependency visible rather than
9152 new. If "term is platform-free" should be stated by the table rather than by
9153 a comment, the fix is a `test_link_libc` column applied to the test module
9154 only, or moving those two tests into `link`.
9155
9156 ### Why not ghostty's own protocol
9157
9158 Mitchell Hashimoto has said ghostty's binary protocol between its client and
9159 its server is "mostly just libghostty's" — a page memcpy. That is the right
9160 shape for one thing mux already does and the wrong shape for the wire. It is
9161 right for `mux d upgrade`, where the same binary hands its own state across
9162 an exec and the struct layout is identical on both sides by construction;
9163 `dumpState` and the manifest memfd stay exactly that. It is wrong for the
9164 wire, where the two ends are separately built, separately versioned binaries
9165 and a client must not depend on ghostty's page layout to paint a row. So mux
9166 keeps `dumpState` for the exec and cells for the wire, and the cell format is
9167 mux's own: a client can be written against it without ghostty in the picture,
9168 which is the point of the change.
test/xversion.sh
Old New
@@ -1,4 +1,6 @@
1 #!/bin/sh 1 #!/bin/sh
2 # 2026-09-04: the cells-on-the-wire break has no old side to grade; XVER_OLD_WORKTREE must be this branch or newer.
3 #
2 # Cross-version compatibility gate, containerised. 4 # Cross-version compatibility gate, containerised.
3 # 5 #
4 # Runs THIS tree's client against a PREVIOUS version's daemon and the 6 # Runs THIS tree's client against a PREVIOUS version's daemon and the