a73x

d890ac97

feat: bytes-on-wire bench gates the M4 kill criterion; docs

a73x   2026-08-08 14:08

Commit message
feat: bytes-on-wire bench gates the M4 kill criterion; docs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Makefile
Old New
@@ -2,7 +2,7 @@
2 # default zig is 0.17-dev. Override with ZIG=... if yours lives elsewhere. 2 # default zig is 0.17-dev. Override with ZIG=... if yours lives elsewhere.
3 ZIG ?= $(HOME)/Downloads/zig-x86_64-linux-0.15.2/zig 3 ZIG ?= $(HOME)/Downloads/zig-x86_64-linux-0.15.2/zig
4 4
5 .PHONY: build test e2e clean 5 .PHONY: build test e2e bench clean
6 6
7 build: 7 build:
8 $(ZIG) build 8 $(ZIG) build
@@ -13,5 +13,8 @@ test:
13 e2e: 13 e2e:
14 $(ZIG) build e2e 14 $(ZIG) build e2e
15 15
16 bench:
17 $(ZIG) build bench
18
16 clean: 19 clean:
17 rm -rf zig-out .zig-cache 20 rm -rf zig-out .zig-cache
README.md
Old New
@@ -5,7 +5,7 @@ authoritatively in a daemon and replicated in the client — state sync
5 instead of escape-sequence replay. See `docs/handoff.md` for the design 5 instead of escape-sequence replay. See `docs/handoff.md` for the design
6 and `docs/decisions.md` for decisions made. 6 and `docs/decisions.md` for decisions made.
7 7
8 Status: **M3 — the promise.** 8 Status: **M4 — deltas.**
9 9
10 Requires Zig 0.15.x (ghostty pin); the Makefile points at the pinned 10 Requires Zig 0.15.x (ghostty pin); the Makefile points at the pinned
11 toolchain, override with `make ZIG=...`. 11 toolchain, override with `make ZIG=...`.
@@ -15,6 +15,8 @@ toolchain, override with `make ZIG=...`.
15 ./zig-out/bin/muxd run & # daemon 15 ./zig-out/bin/muxd run & # daemon
16 ./zig-out/bin/mux # attach (Ctrl-\ detach, Shift+PgUp scroll) 16 ./zig-out/bin/mux # attach (Ctrl-\ detach, Shift+PgUp scroll)
17 ./zig-out/bin/muxd dump [--vt] # debug: print the authoritative grid 17 ./zig-out/bin/muxd dump [--vt] # debug: print the authoritative grid
18 ./zig-out/bin/muxd stats # wire stats: deltas vs snapshot bytes
19 make bench # typing-workload byte-ratio measurement
18 20
19 Detach, or kill the client outright — the session survives and `mux` 21 Detach, or kill the client outright — the session survives and `mux`
20 resumes it from a state snapshot, including TUI screens and the primary 22 resumes it from a state snapshot, including TUI screens and the primary
build.zig
Old New
@@ -92,4 +92,10 @@ pub fn build(b: *std.Build) void {
92 e2e.addArtifactArg(mux_exe); 92 e2e.addArtifactArg(mux_exe);
93 const e2e_step = b.step("e2e", "Run end-to-end test"); 93 const e2e_step = b.step("e2e", "Run end-to-end test");
94 e2e_step.dependOn(&e2e.step); 94 e2e_step.dependOn(&e2e.step);
95
96 const bench = b.addSystemCommand(&.{"test/bench.sh"});
97 bench.addArtifactArg(exe);
98 bench.addArtifactArg(mux_exe);
99 const bench_step = b.step("bench", "Measure delta vs snapshot bytes");
100 bench_step.dependOn(&bench.step);
95 } 101 }
docs/decisions.md
Old New
@@ -94,10 +94,73 @@
94 indexed form (31 -> 38;5;1). Semantically identical; tests assert the 94 indexed form (31 -> 38;5;1). Semantically identical; tests assert the
95 canonical form. 95 canonical form.
96 96
97 ## 2026-08-07 (M4)
98
99 - **Deltas are row-granular.** Damage = set of viewport rows whose styled
100 dump hash (Wyhash) changed, sent as self-contained row repaints
101 (CUP+EL+content composed by protocol.composeDelta on the client). Chosen
102 over cell-level damage rects: rows are the natural unit of the canonical
103 formatter, and the measured win already clears the kill criterion.
104 - **Wire format: still hand-rolled, deliberately.** The handoff says
105 "msgpack or protobuf, do not invent one"; M4's payloads are row-keyed
106 byte blobs plus fixed-width integers, and no serialization library is
107 proven on Zig 0.15.2. The judgment flips when payloads become truly
108 structured (cell runs, multi-rect damage, negotiation) — msgpack is due
109 then, and this entry is the tripwire.
110 - **Snapshot-vs-delta threshold:** a client is served a delta iff its
111 have_seq is >= the tracker's reset_seq (last resize/screen-switch/init
112 discontinuity), <= the current seq, and the viewport size matches;
113 otherwise snapshot. Per-row last-change seqs advance even while
114 detached — once per coalesced PTY read burst; nothing at all while idle
115 — so reattach-after-a-gap resolves by delta when nothing discontinuous
116 happened. All OOM paths in the delta machinery collapse to snapshot
117 resync (retry-until-works, no flags).
118 - **Diffing is content-hash based,** not ghostty RenderState dirty
119 tracking: deterministic, no new engine API surface, O(rows) styled row
120 dumps per coalesced update. RenderState remains the optimization path if
121 hashing ever shows up in a profile.
122 - **row_count is authoritative and validated.** composeDelta rejects any
123 delta whose header count disagrees with the rows present; a client
124 receiving a malformed delta resyncs via re-attach rather than skipping
125 it (a skipped delta would desync the replica permanently).
126 - **DECOM is a known gap.** Origin mode (CSI ?6h) is not treated as a
127 discontinuity, and under it the replica's row addressing would shift
128 relative to the daemon's. Rare in practice — vim/less/htop are
129 unaffected because CUP is absolute under DECSTBM — so it is recorded
130 rather than fixed.
131 - **Measured (bench.sh, 120-char typing workload):** delta bytes were
132 7751 against a snapshot-equivalent of 689053 — 1%. The workload
133 produced exactly one delta per typed character (120 deltas, ~65 bytes
134 each) against one initial snapshot. The M4 kill criterion (delta
135 traffic must scale down meaningfully) is cleared.
136 - **The win survives 100% damage.** Adversarial check: sustained
137 full-screen scrolling (`seq` over 400k lines, all 24 rows changing
138 every update) still measured ~366-byte deltas against ~5775-byte
139 snapshot-equivalents, ~6%. Row repaints beat snapshots even with no
140 unchanged rows to skip, because a snapshot also carries palette and
141 mode state that a row repaint does not.
142 - **No session epoch in the protocol.** have_seq is meaningful only
143 within one daemon instance; a client reconnecting to a restarted daemon
144 with overlapping counters would accept a delta belonging to a different
145 session. Unreachable today — the shipped client always attaches with
146 have_seq=0 — but a generation/epoch field is due before any real
147 network transport.
148 - **Stats counterfactual costs a dumpState per delta send** while a client
149 is attached — deliberate prototype instrumentation; gate behind an
150 option if it ever matters for performance comparisons.
151 - **Banked cleanup (post-M4), from the review cycle:** shared frame
152 applier in protocol.zig so the server fidelity test exercises the real
153 client apply path; Client struct decomposition of client.zig's attach
154 loop; resync_pending bound on the client's malformed-delta re-attach;
155 snapshot_prefix_len constant + read/write helpers; consistent
156 malformed-frame policy (short snapshots currently ignored, bad deltas
157 resync); timer-based test deadlines + generalized poll helper in
158 server tests; DeltaTracker split into src/delta.zig; naming
159 (resyncSnapshot/sendResync, DeltaTracker.update); paintDelta golden
160 test.
161
97 ## Open (owed by later milestones) 162 ## Open (owed by later milestones)
98 163
99 - Resize policy under multiple clients (M5) 164 - Resize policy under multiple clients (M5)
100 - Snapshot-vs-delta threshold (M4) 165 - Scrollback retention/eviction limits (post-prototype)
101 - Scrollback retention/eviction limits (M3/M4)
102 - Daemon lifetime across logout/reboot (M2) 166 - Daemon lifetime across logout/reboot (M2)
103 - Wire format msgpack vs protobuf + versioning (M2/M4)
docs/superpowers/plans/2026-08-07-m4-deltas.md
Old New
@@ -370,7 +370,7 @@ git commit -m "feat: engine per-row styled dumps and alt-screen accessor"
370 370
371 This is the core task. The daemon currently calls `sendSnapshot()` after every engine update (see `pumpOnce`, the `self.sendSnapshot()` call in the PTY branch) and on attach/resize (in `serviceClient`/`serviceObserver`). After this task: per-update it calls `sendUpdate()` (tracker diff → delta or discontinuity snapshot); attach/resize call `sendResync(have_seq)`. 371 This is the core task. The daemon currently calls `sendSnapshot()` after every engine update (see `pumpOnce`, the `self.sendSnapshot()` call in the PTY branch) and on attach/resize (in `serviceClient`/`serviceObserver`). After this task: per-update it calls `sendUpdate()` (tracker diff → delta or discontinuity snapshot); attach/resize call `sendResync(have_seq)`.
372 372
373 - [ ] **Step 1: Add failing tests (append to `src/server.zig`)** 373 - [x] **Step 1: Add failing tests (append to `src/server.zig`)**
374 374
375 ```zig 375 ```zig
376 test "Server: typing produces deltas, not snapshots; stats track both" { 376 test "Server: typing produces deltas, not snapshots; stats track both" {
@@ -574,9 +574,9 @@ Also update the existing fidelity test (`test "Server: replica rebuilt from snap
574 574
575 (Adapt to each loop's structure; the second loop's non-dump frames must apply the same way.) Also change its attach to `proto.encodeAttach(100, 30, 0)`, and the takeover/kill/scrollback tests' attaches to `proto.encodeAttach(80, 24, 0)`. 575 (Adapt to each loop's structure; the second loop's non-dump frames must apply the same way.) Also change its attach to `proto.encodeAttach(100, 30, 0)`, and the takeover/kill/scrollback tests' attaches to `proto.encodeAttach(80, 24, 0)`.
576 576
577 - [ ] **Step 2: Run to verify failure** — `make test`; expected: compile errors (`encodeAttach` used with old decode on the server; new tests reference `sendResync` behavior that doesn't exist). 577 - [x] **Step 2: Run to verify failure** — `make test`; expected: compile errors (`encodeAttach` used with old decode on the server; new tests reference `sendResync` behavior that doesn't exist).
578 578
579 - [ ] **Step 3: Implement** 579 - [x] **Step 3: Implement**
580 580
581 Add near the top of `server.zig` (after `max_observers`): 581 Add near the top of `server.zig` (after `max_observers`):
582 582
@@ -810,9 +810,9 @@ Frame-handling changes:
810 - `serviceObserver` `.attach` arm: decode with `proto.decodeAttach(frame.payload) catch { self.dropObserver(i); return; };` — after promotion, compute `const size_changed = (sz.cols != self.colsNow() or sz.rows != self.rowsNow());`, then `self.applySize(sz.cols, sz.rows);` and `self.sendResync(sz.have_seq, size_changed);` instead of `sendSnapshot`. 810 - `serviceObserver` `.attach` arm: decode with `proto.decodeAttach(frame.payload) catch { self.dropObserver(i); return; };` — after promotion, compute `const size_changed = (sz.cols != self.colsNow() or sz.rows != self.rowsNow());`, then `self.applySize(sz.cols, sz.rows);` and `self.sendResync(sz.have_seq, size_changed);` instead of `sendSnapshot`.
811 - Also in `pumpOnce` — while DETACHED the tracker must keep advancing so reattach-by-delta works: in the PTY branch, `sendUpdate` already runs unconditionally (it no-ops the send when `self.client == null` but still advances the tracker — note the `.delta` arm frees the payload and skips sending; the tracker was advanced inside `update`). Confirm this reading of the code: `tracker.update` advances state regardless of clients; only the socket write is conditional. That is the intended behavior. 811 - Also in `pumpOnce` — while DETACHED the tracker must keep advancing so reattach-by-delta works: in the PTY branch, `sendUpdate` already runs unconditionally (it no-ops the send when `self.client == null` but still advances the tracker — note the `.delta` arm frees the payload and skips sending; the tracker was advanced inside `update`). Confirm this reading of the code: `tracker.update` advances state regardless of clients; only the socket write is conditional. That is the intended behavior.
812 812
813 - [ ] **Step 4: Run tests** — `make test`. All server tests (old and new) must pass. The delta test's `row_count <= 3` may be violated if the shell redraws its whole prompt line region — if observed, print the actual count; up to 5 is acceptable with a comment; more means the differ is broken (investigate, don't relax). 813 - [x] **Step 4: Run tests** — `make test`. All server tests (old and new) must pass. The delta test's `row_count <= 3` may be violated if the shell redraws its whole prompt line region — if observed, print the actual count; up to 5 is acceptable with a comment; more means the differ is broken (investigate, don't relax).
814 814
815 - [ ] **Step 5: Commit** 815 - [x] **Step 5: Commit**
816 816
817 ```bash 817 ```bash
818 git add src/server.zig 818 git add src/server.zig
@@ -826,7 +826,7 @@ git commit -m "feat: row-granular deltas with seq tracking, have_seq resync, wir
826 **Files:** 826 **Files:**
827 - Modify: `src/client.zig`, `src/main.zig` 827 - Modify: `src/client.zig`, `src/main.zig`
828 828
829 - [ ] **Step 1: Client changes (`src/client.zig`)** 829 - [x] **Step 1: Client changes (`src/client.zig`)**
830 830
831 Replace the attach send: 831 Replace the attach send:
832 832
@@ -903,7 +903,7 @@ test "paintDelta wraps composed bytes in sync-output brackets" {
903 } 903 }
904 ``` 904 ```
905 905
906 - [ ] **Step 2: `muxd stats` subcommand (`src/main.zig`)** 906 - [x] **Step 2: `muxd stats` subcommand (`src/main.zig`)**
907 907
908 In `main`, add after the `dump` dispatch: 908 In `main`, add after the `dump` dispatch:
909 909
@@ -932,9 +932,9 @@ fn stats(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
932 } 932 }
933 ``` 933 ```
934 934
935 - [ ] **Step 3: Run everything** — `make test && make build && make e2e`, all green. e2e exercises attach/detach/kill through the new frames end to end. 935 - [x] **Step 3: Run everything** — `make test && make build && make e2e`, all green. e2e exercises attach/detach/kill through the new frames end to end.
936 936
937 - [ ] **Step 4: Commit** 937 - [x] **Step 4: Commit**
938 938
939 ```bash 939 ```bash
940 git add src/client.zig src/main.zig 940 git add src/client.zig src/main.zig
@@ -949,7 +949,7 @@ git commit -m "feat: client applies row deltas with damage-only paints; muxd sta
949 - Create: `test/bench.sh` 949 - Create: `test/bench.sh`
950 - Modify: `test/e2e.sh`, `build.zig`, `docs/decisions.md`, `README.md` 950 - Modify: `test/e2e.sh`, `build.zig`, `docs/decisions.md`, `README.md`
951 951
952 - [ ] **Step 1: Write `test/bench.sh`** 952 - [x] **Step 1: Write `test/bench.sh`**
953 953
954 ```sh 954 ```sh
955 #!/bin/sh 955 #!/bin/sh
@@ -1014,11 +1014,11 @@ bench:
1014 1014
1015 (add `bench` to the `.PHONY` line too). 1015 (add `bench` to the `.PHONY` line too).
1016 1016
1017 - [ ] **Step 2: Run it** — `make bench`. Expected: prints the stats line, the ratio, and `bench OK` with a ratio well under 50% (single-char echoes touch 1–2 rows of 24; expect single-digit percent). **This number is the M4 kill-criterion verdict — record the observed ratio in decisions.md in Step 4.** 1017 - [x] **Step 2: Run it** — `make bench`. Expected: prints the stats line, the ratio, and `bench OK` with a ratio well under 50% (single-char echoes touch 1–2 rows of 24; expect single-digit percent). **This number is the M4 kill-criterion verdict — record the observed ratio in decisions.md in Step 4.**
1018 1018
1019 - [ ] **Step 3: e2e still green** — `make e2e`. The existing scenarios must pass unmodified (they assert content, not frame types). 1019 - [x] **Step 3: e2e still green** — `make e2e`. The existing scenarios must pass unmodified (they assert content, not frame types).
1020 1020
1021 - [ ] **Step 4: Update `docs/decisions.md`** — append (fill the measured ratio in): 1021 - [x] **Step 4: Update `docs/decisions.md`** — append (fill the measured ratio in):
1022 1022
1023 ```markdown 1023 ```markdown
1024 ## 2026-08-07 (M4) 1024 ## 2026-08-07 (M4)
@@ -1052,14 +1052,14 @@ bench:
1052 concern only. 1052 concern only.
1053 ``` 1053 ```
1054 1054
1055 - [ ] **Step 5: Update `README.md`** — status line to `Status: **M4 — deltas.**`, and add to the command block: 1055 - [x] **Step 5: Update `README.md`** — status line to `Status: **M4 — deltas.**`, and add to the command block:
1056 1056
1057 ```markdown 1057 ```markdown
1058 ./zig-out/bin/muxd stats # wire stats: deltas vs snapshot bytes 1058 ./zig-out/bin/muxd stats # wire stats: deltas vs snapshot bytes
1059 make bench # typing-workload byte-ratio measurement 1059 make bench # typing-workload byte-ratio measurement
1060 ``` 1060 ```
1061 1061
1062 - [ ] **Step 6: Commit** 1062 - [x] **Step 6: Commit**
1063 1063
1064 ```bash 1064 ```bash
1065 git add test/bench.sh test/e2e.sh build.zig Makefile docs/decisions.md README.md 1065 git add test/bench.sh test/e2e.sh build.zig Makefile docs/decisions.md README.md
src/server.zig
Old New
@@ -464,6 +464,9 @@ pub const Server = struct {
464 return; 464 return;
465 }; 465 };
466 self.stats.deltas += 1; 466 self.stats.deltas += 1;
467 // Payload bytes only: the 5-byte frame header is excluded here and
468 // in the counterfactual below, so the ratio stays honest even though
469 // it understates delta wire cost by ~8% at a typical delta size.
467 self.stats.delta_bytes += payload.len; 470 self.stats.delta_bytes += payload.len;
468 // Counterfactual: what M2 would have sent for this same update. 471 // Counterfactual: what M2 would have sent for this same update.
469 // This deliberately pays for a full snapshot serialization per 472 // This deliberately pays for a full snapshot serialization per
test/bench.sh
Old New
@@ -0,0 +1,58 @@
1 #!/bin/sh
2 # M4 bytes-on-wire measurement: a typing-heavy workload, then compare
3 # delta bytes actually sent against the measured full-snapshot equivalent.
4 set -eu
5 MUXD="$1"
6 MUX="$2"
7 SOCK="${TMPDIR:-/tmp}/muxd-bench-$$.sock"
8
9 cleanup() { kill "$DPID" 2>/dev/null || true; rm -f "$SOCK"; }
10 trap cleanup EXIT INT TERM
11
12 "$MUXD" run --sock "$SOCK" --shell /bin/sh &
13 DPID=$!
14 i=0
15 while [ ! -S "$SOCK" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done
16 [ -S "$SOCK" ] || { echo "bench FAIL: socket never appeared"; exit 1; }
17
18 # Steady-state typing: 120 single characters with small gaps (no newlines,
19 # so no scroll; this is the workload the handoff's done-criterion names).
20 {
21 sleep 0.5
22 j=0
23 while [ "$j" -lt 120 ]; do printf 'x'; sleep 0.05; j=$((j+1)); done
24 sleep 0.5
25 printf '\034'
26 } | "$MUX" --sock "$SOCK" > /dev/null
27
28 STATS="$("$MUXD" stats --sock "$SOCK")"
29 echo "$STATS"
30
31 DELTA=$(echo "$STATS" | sed -n 's/.*[^_]delta_bytes=\([0-9]*\).*/\1/p')
32 EQUIV=$(echo "$STATS" | sed -n 's/.*snapshot_equiv_bytes=\([0-9]*\).*/\1/p')
33 DELTAS=$(echo "$STATS" | sed -n 's/.*[^_]deltas=\([0-9]*\).*/\1/p')
34 SNAPS=$(echo "$STATS" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
35 [ -n "$DELTA" ] && [ -n "$EQUIV" ] && [ -n "$DELTAS" ] && [ -n "$SNAPS" ] &&
36 [ "$EQUIV" -gt 0 ] || {
37 echo "bench FAIL: could not parse stats"; exit 1;
38 }
39
40 # A delta path that sent nothing would score a perfect 0% ratio. The
41 # workload must actually have produced deltas, and exactly one snapshot
42 # (the attach) — a mid-run resync means the differ fell back, and the
43 # ratio would be measuring the fallback rather than the deltas.
44 [ "$DELTAS" -gt 0 ] || {
45 echo "bench FAIL: no deltas sent; the delta path is dead, not efficient"
46 exit 1
47 }
48 [ "$SNAPS" -eq 1 ] || {
49 echo "bench FAIL: expected exactly 1 snapshot (the attach), got $SNAPS"
50 exit 1
51 }
52 RATIO=$(( DELTA * 100 / EQUIV ))
53 echo "delta bytes: $DELTA snapshot-equivalent: $EQUIV ratio: ${RATIO}%"
54 if [ "$RATIO" -ge 50 ]; then
55 echo "bench FAIL: deltas are not meaningfully smaller (M4 kill criterion)"
56 exit 1
57 fi
58 echo "bench OK"