c2344ddb
docs: record cells on the wire, its measurements and the clean break
a73x 2026-09-04 18:04
Commit message
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 |