a73x

f25e121b

docs: describe native viewer usage and verified delivery

a73x   2026-09-04 19:42

Commit message
docs: describe native viewer usage and verified delivery

CLAUDE.md
Old New
@@ -17,6 +17,7 @@ make build test e2e # Makefile already points at it
17 make check # fmt + unit tests + shell syntax + comment-claim refs — pre-commit gate 17 make check # fmt + unit tests + shell syntax + comment-claim refs — pre-commit gate
18 make ci # check + e2e + agent + throughput — the delivery gate 18 make ci # check + e2e + agent + throughput — the delivery gate
19 make agent soak bench throughput 19 make agent soak bench throughput
20 make native native-e2e # opt-in muxg viewer; system SDL3/freetype/fontconfig/HarfBuzz + GL headers, never in ci
20 make vm # real user journeys against the mux-e2e VM (test/vm.sh) 21 make vm # real user journeys against the mux-e2e VM (test/vm.sh)
21 make mac xos # macOS journeys (test/mac.sh); the cross-OS gate (test/xos.sh). 22 make mac xos # macOS journeys (test/mac.sh); the cross-OS gate (test/xos.sh).
22 # Both take their boxes BY NAME and have no default 23 # Both take their boxes BY NAME and have no default
@@ -77,9 +78,10 @@ a symbol by its FILE stem (`wall_pump.askOn`) — a file, not a module.
77 |---|---| 78 |---|---|
78 | `src/engine/` | `term`(`term.zig`) — `protocol` `replica` `grid` · `engine`(`engine.zig`) — `delta` — the daemon's ghostty-vt; no client row imports it outside a test | 79 | `src/engine/` | `term`(`term.zig`) — `protocol` `replica` `grid` · `engine`(`engine.zig`) — `delta` — the daemon's ghostty-vt; no client row imports it outside a test |
79 | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` | 80 | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` |
80 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | 81 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` `session_pump` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) |
82 | `src/gui/` | `native`(`native.zig`) — `font` `atlas` `quads` `gl` `frame` `bench` |
81 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | 83 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` |
82 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) | 84 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `muxg`(native viewer) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) |
83 | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` `server_os_macos` · `client_os`(`client_os.zig`) — `client_os_linux` `client_os_macos` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) | 85 | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` `server_os_macos` · `client_os`(`client_os.zig`) — `client_os_linux` `client_os_macos` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) |
84 | `src/` | `xdg` `sockpath` `dial` `link` `serve` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket and `link` the live connection under it whatever reached it (fd, handoff stdio, QUIC), `serve` the right to bind a socket path and the duty to unlink it, `term` and nothing else under them | 86 | `src/` | `xdg` `sockpath` `dial` `link` `serve` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket and `link` the live connection under it whatever reached it (fd, handoff stdio, QUIC), `serve` the right to bind a socket path and the duty to unlink it, `term` and nothing else under them |
85 87
@@ -114,7 +116,13 @@ lives beside the code that paints it and nowhere `term` would find it.
114 the OS for a terminal is platform code, and rule 4 forbids a client module 116 the OS for a terminal is platform code, and rule 4 forbids a client module
115 from doing it. 117 from doing it.
116 118
117 ONE binary, `mux`, and the first word picks a mode: `mux d` the daemon, 119 Rules 5, 6 and 7 also cover `src/gui/`. Rule 8 keeps multi-session policy
120 out of that folder. Rule 9 confines SDL to `src/gui/frame.zig`; the separate
121 entry `src/cli/muxg.zig` may also use it. Everything else in the painter is
122 unit-tested without opening a window.
123
124 The default product and install remain ONE binary, `mux`, and the first word
125 picks a mode: `mux d` the daemon,
118 `mux a` the agent surface (JSON verbs), `mux web` the browser hub, and no 126 `mux a` the agent surface (JSON verbs), `mux web` the browser hub, and no
119 letter at all the client. There is NO alias: a word that is not a mode is a 127 letter at all the client. There is NO alias: a word that is not a mode is a
120 transport, so `mux run` names a host called "run" (pinned in `mux.zig`'s 128 transport, so `mux run` names a host called "run" (pinned in `mux.zig`'s
@@ -124,6 +132,9 @@ daemon); `agent` stays a row of its own so muxa's suite can fail on its
124 own. Test fixtures in `test/`: 132 own. Test fixtures in `test/`:
125 `ptyclient` (real client on a real pty), `wsclient` (browser stand-in), 133 `ptyclient` (real client on a real pty), `wsclient` (browser stand-in),
126 `rawmode`, `delaypipe`, `render` — those stay separate binaries. 134 `rawmode`, `delaypipe`, `render` — those stay separate binaries.
135 The opt-in second product binary is `muxg`, a dynamically linked window on
136 one session; it is built only by the native steps and is never installed by
137 `make install` or included in `ci`.
127 138
128 ## Invariants — do not break, they are load-bearing 139 ## Invariants — do not break, they are load-bearing
129 140
README.md
Old New
@@ -19,6 +19,30 @@ make test && make e2e # verify
19 make install # the one binary to ~/.local/bin (override BINDIR) 19 make install # the one binary to ~/.local/bin (override BINDIR)
20 ``` 20 ```
21 21
22 The native one-session viewer is an opt-in, dynamically linked second
23 binary. It needs SDL3, freetype2, fontconfig, HarfBuzz and OpenGL development
24 packages; OpenGL functions are loaded through SDL, with no direct libGL
25 link. It is outside the default build and CI gates:
26
27 ```sh
28 make native # build muxg and run its no-window unit tests
29 make native-e2e # ReleaseSafe real-daemon, input, render, flood, resize and detach leg
30 ./zig-out/bin/muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N]
31 ```
32
33 `muxg` displays and types into one daemon session. With no target it uses
34 the local socket, whose daemon must already be running (`mux d start -d`);
35 it never starts a local daemon itself. A remote `HOST` still uses mux's SSH
36 handoff, including that handoff's own remote-start behavior. `--via` uses a
37 command's stdio, and `quic://HOST[:PORT]` uses `--key` or `MUX_KEY_FILE`.
38 Closing the window detaches; the session stays on its daemon. When the shell
39 exits, the window closes with its exit code. `--font-px` sets the font size
40 in pixels (default 16). `kill -USR1 PID` prints the frame timing table to
41 stderr, as does exit.
42 The end-to-end leg checks the real build mode, reads rendered pixels back
43 from OpenGL, and measures a 20 ms p99 window-side budget under concurrent
44 output; pump apply time is reported separately.
45
22 Binaries land in `zig-out/bin/`. For remote machines, build a static binary 46 Binaries land in `zig-out/bin/`. For remote machines, build a static binary
23 that runs on any x86_64 Linux: 47 that runs on any x86_64 Linux:
24 48
docs/superpowers/plans/2026-09-04-native-client.md
Old New
@@ -6,10 +6,58 @@
6 6
7 **Architecture:** A pump thread under `src/client/` owns the transport (dial, attach, read frames, apply to the `Replica` under a mutex, redial) and wakes the window thread through a callback; the window thread (`src/gui/`) rebuilds instanced quads from the whole grid on each wake and draws them in one call. SDL3 is confined to `frame.zig` and the entry file; every other file under `src/gui/` is plain Zig over `term.grid`, freetype and GL function pointers. No wall policy exists in `src/gui/`: no tiles, no layout file, no picker, no host grade. 7 **Architecture:** A pump thread under `src/client/` owns the transport (dial, attach, read frames, apply to the `Replica` under a mutex, redial) and wakes the window thread through a callback; the window thread (`src/gui/`) rebuilds instanced quads from the whole grid on each wake and draws them in one call. SDL3 is confined to `frame.zig` and the entry file; every other file under `src/gui/` is plain Zig over `term.grid`, freetype and GL function pointers. No wall policy exists in `src/gui/`: no tiles, no layout file, no picker, no host grade.
8 8
9 **Tech Stack:** Zig 0.15.2 (`deps/zig/zig`), SDL3 3.4, OpenGL 3.3 core loaded through `SDL_GL_GetProcAddress`, freetype2, fontconfig — all system libraries through pkg-config, dynamically linked, opt-in under `make native`. 9 **Tech Stack:** Zig 0.15.2 (`deps/zig/zig`), SDL3 3.4, OpenGL 3.3 core loaded through `SDL_GL_GetProcAddress`, freetype2, fontconfig, HarfBuzz — all system libraries through pkg-config, dynamically linked, opt-in under `make native`. HarfBuzz shapes each cell's complete text cluster; it does not change the daemon's cell placement.
10 10
11 **Spec:** `docs/superpowers/specs/2026-09-04-native-client-design.md` 11 **Spec:** `docs/superpowers/specs/2026-09-04-native-client-design.md`
12 12
13 ## Delivery record — 2026-09-04
14
15 Implemented and reviewed in working sprints. The original checklist and code
16 examples below are retained as design history; the delivered code is authoritative.
17 Existing edits to this plan were preserved.
18
19 | Scope | Result |
20 |---|---|
21 | Task 1: opt-in build and GL probe | Complete (`b7dbcda`) |
22 | Tasks 2–6, including 5b: timing, atlas, full-cluster/style painter, GL | Complete |
23 | Task 7: cancellable session pump and reconnect | Complete |
24 | Task 8: real GUI, input, target parsing and lifecycle | Complete |
25 | Task 9: isolated native end-to-end journey | Complete; nine checkpoints |
26 | Task 10: README, module rules and reconciled design | Complete |
27
28 The integrated implementation is committed as `c49a06e`. Implementation and
29 adversarial review agents resolved their findings directly; an additional final
30 review found and verified a fix for glyphs overflowing their cell spans. No
31 blocking review findings remain.
32
33 Validation:
34
35 - `make check` passed on the final source.
36 - `zig build native native-test -Doptimize=ReleaseSafe` passed; 12 native tests.
37 - 18 focused pump tests passed, including partial frames, silent handoff,
38 reconnect/refusal, idle sockets/pipes, and bounded QUIC batches.
39 - `make native-e2e` passed all nine checkpoints: input, live flood painting,
40 real framebuffer pixels, resize, detach, terminal-client interoperability,
41 and named-session shell exit status. Final window-total p99 was 110 us
42 on this development machine against the 20,000 us budget.
43 - Real Wayland window and isolated local socket, QUIC and `--via` pipe GUI
44 smoke tests rendered styled text and accepted input; framebuffer captures
45 were inspected. The mux-e2e VM and macOS were not tested.
46 - The aggregate `make ci` invocation was terminated with exit 143 during
47 `12_panes`, with no reported test failure. Groups 01–11 had completed;
48 groups 12–16 were then run separately and passed. `make agent` passed
49 (10 scenarios), and `make throughput` passed. The aggregate count pin
50 was therefore not reached in one uninterrupted run.
51
52 Implementation corrections to the illustrative listings include incremental
53 nonblocking stream reads and cancellable dials, a main-thread test FIFO,
54 coalesced bounded window events, layout-translated modifier characters,
55 nominal monospace cell metrics, complete HarfBuzz runs, clipped glyph UVs,
56 real drawable-size queries, and build-mode verification from `--version`.
57 The timing total excludes independently sampled pump work.
58
59 ---
60
13 ## Global Constraints 61 ## Global Constraints
14 62
15 - **Prerequisite: MET.** Cells-on-the-wire landed on main 2026-09-04 (`f07a56c8` through `406628a2`) and this plan was rebased onto it. Every type it names is present as written: `term.grid.Grid` with `cursor`/`lines`/`row(y)`, `grid.Row` with `cells` and `text: ArrayListUnmanaged(u8)` read through `textOf`, `grid.Cell{style,wide,text_off,text_len}`, `proto.CellStyle`, `proto.Wide`, `Replica.apply` returning the enum `.painted | .resync`, `Replica.attachArgs`, `Replica.state_since_attach`. 63 - **Prerequisite: MET.** Cells-on-the-wire landed on main 2026-09-04 (`f07a56c8` through `406628a2`) and this plan was rebased onto it. Every type it names is present as written: `term.grid.Grid` with `cursor`/`lines`/`row(y)`, `grid.Row` with `cells` and `text: ArrayListUnmanaged(u8)` read through `textOf`, `grid.Cell{style,wide,text_off,text_len}`, `proto.CellStyle`, `proto.Wide`, `Replica.apply` returning the enum `.painted | .resync`, `Replica.attachArgs`, `Replica.state_since_attach`.
@@ -206,7 +254,7 @@ In `build()`, after the `agent_step` block:
206 254
207 ```zig 255 ```zig
208 // The native client: opt-in, dynamically linked against the system's 256 // The native client: opt-in, dynamically linked against the system's
209 // SDL3, freetype and fontconfig through pkg-config. GL is loaded by 257 // SDL3, freetype, fontconfig and HarfBuzz through pkg-config. GL is loaded by
210 // name through SDL, so no -lGL. Nothing on the default path depends on 258 // name through SDL, so no -lGL. Nothing on the default path depends on
211 // these, so a box without SDL3 builds and gates mux as before. 259 // these, so a box without SDL3 builds and gates mux as before.
212 const native_mod = mods[comptime idxOf("native")]; 260 const native_mod = mods[comptime idxOf("native")];
@@ -216,8 +264,9 @@ In `build()`, after the `agent_step` block:
216 muxg_exe.linkSystemLibrary2("sdl3", .{}); 264 muxg_exe.linkSystemLibrary2("sdl3", .{});
217 muxg_exe.linkSystemLibrary2("freetype2", .{}); 265 muxg_exe.linkSystemLibrary2("freetype2", .{});
218 muxg_exe.linkSystemLibrary2("fontconfig", .{}); 266 muxg_exe.linkSystemLibrary2("fontconfig", .{});
267 muxg_exe.linkSystemLibrary2("harfbuzz", .{});
219 linkQuic(b, muxg_exe, quic); 268 linkQuic(b, muxg_exe, quic);
220 const native_step = b.step("native", "Build the native client, muxg (opt-in; needs SDL3, freetype, fontconfig)"); 269 const native_step = b.step("native", "Build the native client, muxg (opt-in; needs SDL3, freetype, fontconfig, HarfBuzz)");
221 native_step.dependOn(&b.addInstallArtifact(muxg_exe, .{}).step); 270 native_step.dependOn(&b.addInstallArtifact(muxg_exe, .{}).step);
222 271
223 // Unit tests open no window but link the libraries: frame.zig's key 272 // Unit tests open no window but link the libraries: frame.zig's key
@@ -227,6 +276,7 @@ In `build()`, after the `agent_step` block:
227 native_tests.linkSystemLibrary2("sdl3", .{}); 276 native_tests.linkSystemLibrary2("sdl3", .{});
228 native_tests.linkSystemLibrary2("freetype2", .{}); 277 native_tests.linkSystemLibrary2("freetype2", .{});
229 native_tests.linkSystemLibrary2("fontconfig", .{}); 278 native_tests.linkSystemLibrary2("fontconfig", .{});
279 native_tests.linkSystemLibrary2("harfbuzz", .{});
230 linkQuic(b, native_tests, quic); 280 linkQuic(b, native_tests, quic);
231 const native_test_step = b.step("native-test", "Run the native client's unit tests (opt-in)"); 281 const native_test_step = b.step("native-test", "Run the native client's unit tests (opt-in)");
232 native_test_step.dependOn(&b.addRunArtifact(native_tests).step); 282 native_test_step.dependOn(&b.addRunArtifact(native_tests).step);
@@ -246,7 +296,7 @@ After the `agent:` recipe in `Makefile`:
246 296
247 ```makefile 297 ```makefile
248 # The native client (docs/superpowers/specs/2026-09-04-native-client-design.md): 298 # The native client (docs/superpowers/specs/2026-09-04-native-client-design.md):
249 # opt-in, because it links the system's SDL3, freetype and fontconfig and 299 # opt-in, because it links system SDL3, freetype, fontconfig and HarfBuzz and
250 # a box without them must still build and gate mux. Not part of ci. 300 # a box without them must still build and gate mux. Not part of ci.
251 native: mac-sdk 301 native: mac-sdk
252 $(ZIG) build native native-test 302 $(ZIG) build native native-test
@@ -271,7 +321,7 @@ If the offscreen run prints `SDL_GL_CreateContext: ...` and rc 2, the offscreen
271 - [ ] **Step 8: Amend the spec's three stale lines** 321 - [ ] **Step 8: Amend the spec's three stale lines**
272 322
273 In `docs/superpowers/specs/2026-09-04-native-client-design.md`: 323 In `docs/superpowers/specs/2026-09-04-native-client-design.md`:
274 - Section 1, the pkg-config sentence: replace `SDL3, libGL, freetype and fontconfig` with `SDL3, freetype and fontconfig (GL functions are loaded by name through SDL, so no libGL link)`. 324 - Section 1, the pkg-config sentence: replace `SDL3, libGL, freetype and fontconfig` with `SDL3, freetype, fontconfig and HarfBuzz (GL functions are loaded by name through SDL, so no libGL link)`.
275 - Section 6, `native-test builds src/gui/ without SDL and runs:` → `native-test links the same libraries, opens no window, and runs:`. 325 - Section 6, `native-test builds src/gui/ without SDL and runs:` → `native-test links the same libraries, opens no window, and runs:`.
276 - Section 5, add a bullet: `A local dial to a socket nobody answers exits 2 with \`muxg: no daemon at PATH (run: mux d start -d --sock PATH)\`. muxg never starts a daemon: the self-exec rule says an auto-start may only run the image already running, and this image is not the daemon's.` 326 - Section 5, add a bullet: `A local dial to a socket nobody answers exits 2 with \`muxg: no daemon at PATH (run: mux d start -d --sock PATH)\`. muxg never starts a daemon: the self-exec rule says an auto-start may only run the image already running, and this image is not the daemon's.`
277 - Section 6, step 1 of the leg: record the offscreen or Xvfb outcome from step 7. 327 - Section 6, step 1 of the leg: record the offscreen or Xvfb outcome from step 7.
@@ -1206,6 +1256,20 @@ git commit -m "feat: the native client's face and glyph rasteriser"
1206 1256
1207 --- 1257 ---
1208 1258
1259 ### Task 5b: Complete cell text and style rendering (required before integration)
1260
1261 **Files:** Modify `src/gui/font.zig`, `atlas.zig`, `quads.zig`, `native.zig`, and the Task 8 `GlyphCache`/rebuild integration; amend the spec's font and quads descriptions to name HarfBuzz shaping and variable instance counts. Update dependency documentation to include HarfBuzz. This implements existing text/style promises; these are not deferrals.
1262
1263 The Tasks 3–5 listings establish packing, metrics and positioning, but their codepoint-only cache and `firstCodepoint` helper are scaffolding. Replace them before Task 8 is considered complete:
1264
1265 - [ ] Replace the codepoint-only glyph lookup with a callback taking the complete `row.textOf(cell)` slice and its font style, returning a positioned glyph run. Shape the entire cell cluster with HarfBuzz using the selected FreeType face. Apply the returned offsets and advances within the cell; the daemon's narrow/wide span remains authoritative. Do not shape across cell boundaries. Remove `firstCodepoint`; combining marks must not disappear.
1266 - [ ] Cache shaped runs by owned UTF-8 text plus font variant, and atlas bitmaps by face variant plus glyph ID. Distinguish regular, bold, italic and bold-italic. Match the appropriate monospace variants through fontconfig; if unavailable, use FreeType synthesis for the missing style. Rasterize glyph IDs rather than interpreting them as codepoints. Keep missing-glyph behavior, fallback-face and colour-emoji deferrals unchanged.
1267 - [ ] Read every defined `CellStyle.flags` field from `proto.CellStyle`. Preserve inverse/invisible handling; render faint, bold and italic; add solid decoration instances for strikethrough, overline and the encoded underline variants (single, double, curly, dotted, dashed), with underline colour from `style.ul` and effective foreground as its default. Invisible suppresses glyphs and decorations. Add a blink phase and a timed repaint only while visible blinking cells exist, so ordinary idle windows still draw nothing.
1268 - [ ] Replace the two-instance-per-cell assumption with background, positioned glyph run and decoration emission. Emit all backgrounds before all foreground instances for the frame. Extend the callback/atlas interfaces and their stubs in Tasks 3–5 and Task 8 to the run and glyph-key contracts above; do not retain the single-codepoint `GlyphCache.get` listing at integration.
1269 - [ ] Add deterministic quad tests for every style flag, underline variant/colour, combined styles, invisible decorations, and the blink phases. Use a fake positioned glyph run for `e` plus a combining acute accent and verify every glyph and offset survives, including at a nonzero column origin and in a wide cell. Add a font integration check that compares the rendered run with shaping the complete cluster, without assuming a particular glyph count (composition may produce one glyph).
1270 - [ ] Verify that differently styled copies of the same text cannot share an incorrect atlas entry, and that atlas growth preserves the whole run's coordinates. Rebuild normalized UVs before drawing after growth; do not knowingly display a frame using the old atlas dimensions.
1271 - [ ] Run `make native` and `deps/zig/zig build native-test`, then `make check` before committing.
1272
1209 ### Task 6: `gl.zig` — the GL 3.3 renderer 1273 ### Task 6: `gl.zig` — the GL 3.3 renderer
1210 1274
1211 **Files:** 1275 **Files:**
@@ -1519,7 +1583,7 @@ git commit -m "feat: the native client's GL 3.3 instanced renderer"
1519 - `pub const Pump = struct { mu: std.Thread.Mutex, replica: Replica, grid: *Grid, last_apply_us: u32, ... pub fn start(alloc, opts: Options) !*Pump; pub fn say(self: *Pump, s: Say) !void; pub fn state(self: *Pump) State; pub fn stop(self: *Pump) void; }` 1583 - `pub const Pump = struct { mu: std.Thread.Mutex, replica: Replica, grid: *Grid, last_apply_us: u32, ... pub fn start(alloc, opts: Options) !*Pump; pub fn say(self: *Pump, s: Say) !void; pub fn state(self: *Pump) State; pub fn stop(self: *Pump) void; }`
1520 - Reading the grid: lock `pump.mu`, read `pump.grid` and `pump.last_apply_us`, unlock. `stop` posts `.quit`, joins the thread and frees the pump; it is the ONLY way to end it. 1584 - Reading the grid: lock `pump.mu`, read `pump.grid` and `pump.last_apply_us`, unlock. `stop` posts `.quit`, joins the thread and frees the pump; it is the ONLY way to end it.
1521 1585
1522 - [ ] **Step 1: Write the failing tests (mailbox and doorbell only; the thread is graded end to end)** 1586 - [ ] **Step 1: Write the failing tests (mailbox, replay failures, and pump lifecycle)**
1523 1587
1524 Create `src/client/session_pump.zig` with the imports and, at the bottom: 1588 Create `src/client/session_pump.zig` with the imports and, at the bottom:
1525 1589
@@ -1559,6 +1623,14 @@ test "state is read as a copy under the lock" {
1559 } 1623 }
1560 ``` 1624 ```
1561 1625
1626 Add pump regression fixtures using a local socket pair and a scripted peer, with bounded waits and captured thread lifetimes:
1627
1628 - Send one snapshot, then remain silent: the wake must arrive before any second frame; input, resize and detach must still reach the peer, and `stop` must join. Repeat for the pipe transport. The fd/pipe `readFrame` blocks; `.incomplete` is QUIC-only.
1629 - Feed more than one QUIC batch of complete frames: each batch wakes the painter and services pending mailbox work without waiting for the receive buffer to empty.
1630 - A short snapshot preserves the existing grid and produces no terminal failure; an aborted snapshot publishes `.failed` with a reason and wakes the window. The window must print the reason and exit 1, never 0.
1631 - Inject poll failure and attach-write failure during reconnect: each unexpected thread exit publishes `.failed` and wakes the window. Intentional detach/quit must not become a failure.
1632 - Admit one attach, reconnect, then send `exit_status` before any replay on the new attach: classify it as `.refused`. Verify the resume sequence and epoch were retained in the new attach.
1633
1562 - [ ] **Step 2: Run to verify they fail** 1634 - [ ] **Step 2: Run to verify they fail**
1563 1635
1564 Run: `deps/zig/zig build test 2>&1 | tail -5` — `client` is in `test_order`, so `make test` runs this file's tests. 1636 Run: `deps/zig/zig build test 2>&1 | tail -5` — `client` is in `test_order`, so `make test` runs this file's tests.
@@ -1802,14 +1874,14 @@ pub const Pump = struct {
1802 1874
1803 /// Every pending mailbox item onto the wire. Returns false on quit or 1875 /// Every pending mailbox item onto the wire. Returns false on quit or
1804 /// detach: the loop ends either way. 1876 /// detach: the loop ends either way.
1805 fn deliver(self: *Pump, tr: *client.Transport) bool { 1877 fn deliver(self: *Pump, tr: *client.Transport) !bool {
1806 self.mailbox.drainBell(); 1878 self.mailbox.drainBell();
1807 var items: std.ArrayListUnmanaged(Say) = .empty; 1879 var items: std.ArrayListUnmanaged(Say) = .empty;
1808 defer { 1880 defer {
1809 for (items.items) |it| self.mailbox.free(it); 1881 for (items.items) |it| self.mailbox.free(it);
1810 items.deinit(self.alloc); 1882 items.deinit(self.alloc);
1811 } 1883 }
1812 self.mailbox.drain(&items) catch return false; 1884 try self.mailbox.drain(&items);
1813 for (items.items) |it| { 1885 for (items.items) |it| {
1814 switch (it) { 1886 switch (it) {
1815 .input => |b| tr.writeFrame(.input, b) catch return true, // the read side reports the loss 1887 .input => |b| tr.writeFrame(.input, b) catch return true, // the read side reports the loss
@@ -1853,7 +1925,7 @@ pub const Pump = struct {
1853 return false; 1925 return false;
1854 }, 1926 },
1855 error.SnapshotAborted => { 1927 error.SnapshotAborted => {
1856 self.setPhase(.exited, "the daemon's snapshot could not be decoded"); 1928 self.setPhase(.failed, "the daemon's snapshot could not be decoded");
1857 return error.SessionOver; 1929 return error.SessionOver;
1858 }, 1930 },
1859 else => .resync, 1931 else => .resync,
@@ -1906,6 +1978,18 @@ pub const Pump = struct {
1906 } 1978 }
1907 1979
1908 fn run(self: *Pump) void { 1980 fn run(self: *Pump) void {
1981 var intentional_stop = false;
1982 // No silent thread exits: the window observes state, not the thread.
1983 defer {
1984 const phase = self.state().phase;
1985 const terminal = switch (phase) {
1986 .exited, .refused, .taken, .failed => true,
1987 else => false,
1988 };
1989 if (!intentional_stop and !self.quitRequested() and !terminal)
1990 self.setPhase(.failed, "the session pump stopped unexpectedly");
1991 self.wake();
1992 }
1909 var tr = self.dial() orelse return; 1993 var tr = self.dial() orelse return;
1910 self.link_open = true; 1994 self.link_open = true;
1911 // `reconnect` closes and reopens; a redial that never came back 1995 // `reconnect` closes and reopens; a redial that never came back
@@ -1918,21 +2002,37 @@ pub const Pump = struct {
1918 return; 2002 return;
1919 }; 2003 };
1920 2004
2005 var buffered_quic = false;
1921 while (true) { 2006 while (true) {
1922 var fds = [2]std.posix.pollfd{ 2007 var fds = [2]std.posix.pollfd{
1923 .{ .fd = tr.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, 2008 .{ .fd = tr.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
1924 .{ .fd = self.mailbox.wake_r, .events = std.posix.POLL.IN, .revents = 0 }, 2009 .{ .fd = self.mailbox.wake_r, .events = std.posix.POLL.IN, .revents = 0 },
1925 }; 2010 };
1926 _ = std.posix.poll(&fds, tr.timeoutMs(100)) catch return; 2011 _ = std.posix.poll(&fds, if (buffered_quic) 0 else tr.timeoutMs(100)) catch {
2012 self.setPhase(.failed, "transport poll failed");
2013 return;
2014 };
2015 buffered_quic = false;
1927 tr.service(); 2016 tr.service();
1928 2017
1929 if (fds[1].revents != 0) { 2018 if (fds[1].revents != 0) {
1930 if (!self.deliver(&tr)) return; 2019 const keep_running = self.deliver(&tr) catch {
2020 self.setPhase(.failed, "mailbox could not be drained");
2021 return;
2022 };
2023 if (!keep_running) {
2024 intentional_stop = true;
2025 return;
2026 }
1931 } 2027 }
1932 2028
1933 var changed = false; 2029 var changed = false;
1934 if (fds[0].revents != 0 or tr.link == .quic) frames: { 2030 if (fds[0].revents != 0 or tr.link == .quic) frames: {
1935 while (true) { 2031 // Socket/pipe reads block: consume ONE frame per poll.
2032 // QUIC reads a userspace buffer, but must also yield fairly.
2033 const budget: usize = if (tr.link == .quic) 64 else 1;
2034 var consumed: usize = 0;
2035 while (consumed < budget) : (consumed += 1) {
1936 const incoming = tr.readFrame(self.alloc) catch { 2036 const incoming = tr.readFrame(self.alloc) catch {
1937 if (!self.reconnect(&tr)) return; 2037 if (!self.reconnect(&tr)) return;
1938 break :frames; 2038 break :frames;
@@ -1959,6 +2059,9 @@ pub const Pump = struct {
1959 }, 2059 },
1960 } 2060 }
1961 } 2061 }
2062 // A full QUIC batch may leave buffered frames. Poll without
2063 // sleeping next pass, still servicing the mailbox first.
2064 buffered_quic = tr.link == .quic;
1962 } 2065 }
1963 if (changed) self.wake(); 2066 if (changed) self.wake();
1964 } 2067 }
@@ -1974,7 +2077,13 @@ pub const Pump = struct {
1974 tr.* = self.dial() orelse return false; 2077 tr.* = self.dial() orelse return false;
1975 self.link_open = true; 2078 self.link_open = true;
1976 tr.adopt(self.alloc); 2079 tr.adopt(self.alloc);
1977 self.sendAttach(tr, false) catch return false; 2080 self.mu.lock();
2081 self.replica.state_since_attach = false;
2082 self.mu.unlock();
2083 self.sendAttach(tr, false) catch {
2084 self.setPhase(.failed, "reconnect attach could not be sent");
2085 return false;
2086 };
1978 return true; 2087 return true;
1979 } 2088 }
1980 }; 2089 };
@@ -2482,7 +2591,7 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
2482 } 2591 }
2483 ``` 2592 ```
2484 2593
2485 The atlas-growth note: because `Ctx.atlas_w/h` are read once per rebuild and a `put` inside `GlyphCache.get` can grow the atlas mid-rebuild, a frame where growth happened paints a few glyphs with stale uv. To keep v1 simple and correct: after `uploadAtlas`, if the atlas size changed from what `qctx` used, set `dirty = true` so the NEXT loop pass repaints with the right uv. Implement that by comparing `renderer.tex_h` before and after the upload and setting `dirty = true` when it changed. 2594 Before integrating this listing, apply Task 5b's complete-text/style callback and cache contracts. Resolve all glyph runs and atlas insertions first, then build quads against the final atlas dimensions and upload/draw. Never present stale normalized UVs and rely on a later loop pass to repair them. The event wait also uses Task 5b's blink deadline when blinking cells are visible.
2486 2595
2487 - [ ] **Step 4: Replace the probe with the real entry** 2596 - [ ] **Step 4: Replace the probe with the real entry**
2488 2597
@@ -2688,19 +2797,36 @@ printf 'key:enter\n' >&8
2688 wait_grid "$SOCK" "native-ok-$$" "typed text landed on the daemon's grid" 2797 wait_grid "$SOCK" "native-ok-$$" "typed text landed on the daemon's grid"
2689 ok "text and Enter cross the keymap, the mailbox, the pump and the daemon" 2798 ok "text and Enter cross the keymap, the mailbox, the pump and the daemon"
2690 2799
2691 # 3. A bounded flood, and the frame count read twice while it runs. 2800 # 3. A bounded producer with explicit readiness, progress and completion.
2692 frames_now() { kill -USR1 "$GPID"; sleep 0.3; grep -o 'timing ([0-9]* frames)' "$GLOG" | tail -1 | tr -dc '0-9'; } 2801 # Fixed /tmp template yields a shell-safe path for the session command.
2693 before=$(frames_now) 2802 FLOOD_DIR=$(mktemp -d /tmp/mux-native-flood.XXXXXXXX)
2694 "$MUX" a run --sock "$SOCK" --timeout 60000 'head -c 30000000 /dev/urandom | base64 | head -c 20000000; echo flood-done' >/dev/null 2>&1 & 2803 defer_rm "$FLOOD_DIR"
2804 reports_now() { grep -c '^=== muxg frame timing (' "$GLOG" || true; }
2805 frames_now() {
2806 frame_reports_before=$(reports_now)
2807 kill -USR1 "$GPID" || return 1
2808 # Wait for a NEW, COMPLETE report, rather than rereading an old table.
2809 # The total row is printed last, so its count acknowledges the report.
2810 wait_until 5 "fresh frame report" '[ "$(grep -c "^total " "$GLOG" || true)" -gt "$frame_reports_before" ]' >&2
2811 grep -o 'timing ([0-9]* frames)' "$GLOG" | tail -1 | tr -dc '0-9'
2812 }
2813 # At most 4096 x 256 KiB of source bytes; normal completion is requested
2814 # after the two samples. The producer writes progress only after output.
2815 "$MUX" a run --sock "$SOCK" --timeout 60000 "i=0; : > '$FLOOD_DIR/ready'; while [ \"\$i\" -lt 4096 ] && [ ! -e '$FLOOD_DIR/stop' ]; do head -c 262144 /dev/urandom | base64; i=\$((i + 1)); printf '%s\\n' \"\$i\" > '$FLOOD_DIR/progress.tmp'; mv '$FLOOD_DIR/progress.tmp' '$FLOOD_DIR/progress'; done; : > '$FLOOD_DIR/done'; echo flood-done" >/dev/null 2>&1 &
2695 RUNPID=$! 2816 RUNPID=$!
2696 defer_kill "$RUNPID" 2817 defer_kill "$RUNPID"
2697 sleep 1.5 2818 wait_until 10 "flood producer ready" '[ -s "$FLOOD_DIR/progress" ]'
2819 [ ! -e "$FLOOD_DIR/done" ] || { echo "native FAIL: producer finished before sampling"; exit 1; }
2820 before=$(frames_now)
2821 progress_before=$(cat "$FLOOD_DIR/progress")
2822 wait_until 5 "producer output advanced" '[ -s "$FLOOD_DIR/progress" ] && [ "$(cat "$FLOOD_DIR/progress")" != "$progress_before" ]'
2698 mid=$(frames_now) 2823 mid=$(frames_now)
2699 wait "$RUNPID" 2824 [ ! -e "$FLOOD_DIR/done" ] || { echo "native FAIL: producer finished during sampling"; exit 1; }
2825 [ "${mid:-0}" -gt "${before:-0}" ] || { echo "native FAIL: no frame painted during active output (before=$before mid=$mid)"; exit 1; }
2826 : > "$FLOOD_DIR/stop"
2827 wait "$RUNPID" || { echo "native FAIL: producer command failed"; exit 1; }
2700 wait_grid "$SOCK" "flood-done" "the flood ended" 2828 wait_grid "$SOCK" "flood-done" "the flood ended"
2701 after=$(frames_now) 2829 after=$(frames_now)
2702 [ "${mid:-0}" -gt "${before:-0}" ] || { echo "native FAIL: no frame painted during the flood (before=$before mid=$mid)"; exit 1; }
2703 [ "${after:-0}" -gt "${mid:-0}" ] || { echo "native FAIL: no frame painted late in the flood (mid=$mid after=$after)"; exit 1; }
2704 ok "frames were painted while the session flooded: before=$before mid=$mid after=$after" 2830 ok "frames were painted while the session flooded: before=$before mid=$mid after=$after"
2705 2831
2706 # 4. The window-side p99 sits under the budget (ReleaseSafe/Fast only). 2832 # 4. The window-side p99 sits under the budget (ReleaseSafe/Fast only).
@@ -2785,7 +2911,7 @@ the session follows; close it and the session stays on its daemon. A
2785 wall inside a window is `mux` run inside a `muxg` session. 2911 wall inside a window is `mux` run inside a `muxg` session.
2786 2912
2787 It is opt-in: `make native` builds it against the system's SDL3, freetype 2913 It is opt-in: `make native` builds it against the system's SDL3, freetype
2788 and fontconfig, and `make native-e2e` runs its leg. `muxg` never starts a 2914 fontconfig and HarfBuzz, and `make native-e2e` runs its leg. `muxg` never starts a
2789 daemon; a silent local socket is refused with the `mux d start` line to 2915 daemon; a silent local socket is refused with the `mux d start` line to
2790 run. `kill -USR1` on a running `muxg` prints its per-stage frame-timing 2916 run. `kill -USR1` on a running `muxg` prints its per-stage frame-timing
2791 table to stderr, as does exit. 2917 table to stderr, as does exit.
@@ -2793,7 +2919,7 @@ table to stderr, as does exit.
2793 2919
2794 - [ ] **Step 2: CLAUDE.md** 2920 - [ ] **Step 2: CLAUDE.md**
2795 2921
2796 - Toolchain block: add `make native native-e2e # opt-in: the muxg viewer against system SDL3/freetype/fontconfig; never in ci`. 2922 - Toolchain block: add `make native native-e2e # opt-in: the muxg viewer against system SDL3/freetype/fontconfig/HarfBuzz; never in ci`.
2797 - Layout table: add the row `| \`src/gui/\` | \`native\`(\`native.zig\`) — \`font\` \`atlas\` \`quads\` \`gl\` \`frame\` \`bench\` · the session VIEWER's painter; imports \`client\` and \`term\` only; no wall policy (rule 8), SDL confined to \`frame.zig\` (rule 9) |` and, under the `src/client/` row's child list, `session_pump`. 2923 - Layout table: add the row `| \`src/gui/\` | \`native\`(\`native.zig\`) — \`font\` \`atlas\` \`quads\` \`gl\` \`frame\` \`bench\` · the session VIEWER's painter; imports \`client\` and \`term\` only; no wall policy (rule 8), SDL confined to \`frame.zig\` (rule 9) |` and, under the `src/client/` row's child list, `session_pump`.
2798 - The folder-rule paragraph: after rule 7, add: `Rule 8 keeps wall policy out of \`src/gui/\` (no \`wall_host\`, \`wall_layout\`, \`wall_picker\`, \`layoutfile\`, \`SessionPoll\`); rule 9 confines the needle \`sdl\` to \`src/gui/frame.zig\`. Rules 5, 6 and 7 list \`src/gui\` too.` 2924 - The folder-rule paragraph: after rule 7, add: `Rule 8 keeps wall policy out of \`src/gui/\` (no \`wall_host\`, \`wall_layout\`, \`wall_picker\`, \`layoutfile\`, \`SessionPoll\`); rule 9 confines the needle \`sdl\` to \`src/gui/frame.zig\`. Rules 5, 6 and 7 list \`src/gui\` too.`
2799 - The `ONE binary` paragraph: add one sentence: `\`muxg\` is a second, opt-in binary — the native viewer — because it links system libraries the static \`mux\` cannot.` 2925 - The `ONE binary` paragraph: add one sentence: `\`muxg\` is a second, opt-in binary — the native viewer — because it links system libraries the static \`mux\` cannot.`
@@ -2811,6 +2937,6 @@ git commit -m "docs: muxg, the native session viewer, in README and CLAUDE.md"
2811 2937
2812 **Spec coverage.** Section 1 (build root, target, two rules): Task 1. Section 2 (pump, mailbox, doorbell, state, frame arms, redial, the named debt): Task 7. Section 3 (font, atlas, quads, gl, frame loop, bench table): Tasks 2–6 and 8. Section 4 (input, no chord, close = detach, bell in title, clipboard ignored, mouse dropped): Task 8 — mouse events fall to the `else => dirty = true` arm and send nothing. Section 5 (dial failure words, exit code, refused, taken, reconnecting title, missing lib, face/GL failure, pump death): Task 8's phase switch and `muxg.zig`; the no-auto-start bullet is Task 1 step 8's spec amendment. Section 6 (unit tests: quads off-origin, flooring, key table both ways, atlas packer; the leg's six steps; Debug refusal; offscreen spike): Tasks 3, 4, 8, 9 and Task 1 step 7. Deferred items: none implemented, none needed. 2938 **Spec coverage.** Section 1 (build root, target, two rules): Task 1. Section 2 (pump, mailbox, doorbell, state, frame arms, redial, the named debt): Task 7. Section 3 (font, atlas, quads, gl, frame loop, bench table): Tasks 2–6 and 8. Section 4 (input, no chord, close = detach, bell in title, clipboard ignored, mouse dropped): Task 8 — mouse events fall to the `else => dirty = true` arm and send nothing. Section 5 (dial failure words, exit code, refused, taken, reconnecting title, missing lib, face/GL failure, pump death): Task 8's phase switch and `muxg.zig`; the no-auto-start bullet is Task 1 step 8's spec amendment. Section 6 (unit tests: quads off-origin, flooring, key table both ways, atlas packer; the leg's six steps; Debug refusal; offscreen spike): Tasks 3, 4, 8, 9 and Task 1 step 7. Deferred items: none implemented, none needed.
2813 2939
2814 **Type consistency.** `atlas.Entry {x,y,w,h,left,top}` is produced by Task 3 and consumed by Task 4's stub and Task 8's cache. `quads.Instance` field order (`x y w h u0 v0 u1 v1 rgba kind`) matches Task 6's attribute offsets. `bench.Frame` fields match the `frame_t` assignments in Task 8. `session_pump.Say` is posted by Task 8 as `.detach`, `.{ .input = ... }`, `.{ .resize = ... }`. `Pump.state()` returns a `State` with `phase`, `exit_code`, `bell`, `reasonText()`. `frame.Options.test_fifo` is read from `MUXG_TEST_FIFO` in `muxg.zig`. 2940 **Type consistency.** `atlas.Entry {x,y,w,h,left,top}` remains the bitmap rectangle. Task 5b replaces the earlier codepoint-only callback and cache with complete-text shaped runs and face-variant/glyph-ID bitmap keys; update the Task 8 scaffold to those contracts before integration. `quads.Instance` field order (`x y w h u0 v0 u1 v1 rgba kind`) matches Task 6's attribute offsets; decorations use solid instances. `bench.Frame` fields match the `frame_t` assignments in Task 8. `session_pump.Say` is posted by Task 8 as `.detach`, `.{ .input = ... }`, `.{ .resize = ... }`. `Pump.state()` returns a `State` with `phase`, `exit_code`, `bell`, `reasonText()`. `frame.Options.test_fifo` is read from `MUXG_TEST_FIFO` in `muxg.zig`.
2815 2941
2816 **Known verification points for the implementer** (named at the step, not placeholders): the `OpenFailure` text field's name (Task 7), `Transport` reassignment on redial (Task 7), freetype translated field names (Task 5), `cliflags` parsing a `u16` (Task 8), the lib helper signatures (Task 9), and the `{s:<16}` column widths (Task 2). Each says what to read and what to do. 2942 **Known verification points for the implementer** (named at the step, not placeholders): the `OpenFailure` text field's name (Task 7), `Transport` reassignment on redial (Task 7), freetype translated field names (Task 5), `cliflags` parsing a `u16` (Task 8), the lib helper signatures (Task 9), and the `{s:<16}` column widths (Task 2). Each says what to read and what to do.
docs/superpowers/specs/2026-09-04-native-client-design.md
Old New
@@ -53,7 +53,7 @@ is measured working.
53 until `WouldBlock` — which never comes under a flood — so no frame is 53 until `WouldBlock` — which never comes under a flood — so no frame is
54 ever painted. A mux client has no such loop: the daemon owns the pty and 54 ever painted. A mux client has no such loop: the daemon owns the pty and
55 the client receives rows at whatever rate it applies them. Its 55 the client receives rows at whatever rate it applies them. Its
56 `cell_instance.zig` (a cell becomes zero, one or two instanced quads) and 56 `cell_instance.zig` and
57 `font.zig` (fontconfig chooses the face, freetype rasterises) are the two 57 `font.zig` (fontconfig chooses the face, freetype rasterises) are the two
58 pieces worth carrying, by reading and rewriting against `term.grid`, and 58 pieces worth carrying, by reading and rewriting against `term.grid`, and
59 its per-stage frame-timing ring is the habit that found the real fault. 59 its per-stage frame-timing ring is the habit that found the real fault.
@@ -79,10 +79,12 @@ Its entry is the separate `muxg` row rooted at `src/cli/muxg.zig`. The
79 binary is `muxg`, separate from `mux`: the Linux release of `mux` is static 79 binary is `muxg`, separate from `mux`: the Linux release of `mux` is static
80 musl, and this program links the system's SDL3, freetype, fontconfig and 80 musl, and this program links the system's SDL3, freetype, fontconfig and
81 HarfBuzz dynamically through pkg-config, against native glibc. GL functions 81 HarfBuzz dynamically through pkg-config, against native glibc. GL functions
82 are loaded by name through SDL, so there is no libGL link. `zig build native` 82 are loaded by name through SDL, so there is no libGL link; compilation still
83 requires the OpenGL development headers for the 3.3 declarations. `zig build native`
83 is an opt-in step that builds `muxg` and 84 is an opt-in step that builds `muxg` and
84 `native-test` runs its unit tests; `make native` is the only caller of 85 `native-test` runs its unit tests; `make native` calls both. `make native-e2e`
85 either. `make build`, `make check` and `make ci` never touch it, so a box 86 builds ReleaseSafe and checks the binary's reported build mode before the
87 real journey. `make build`, `make check` and `make ci` never touch it, so a box
86 without SDL3 builds and gates mux as before. 88 without SDL3 builds and gates mux as before.
87 89
88 `src/gui/` imports `client` and `term` and nothing else of ours. 90 `src/gui/` imports `client` and `term` and nothing else of ours.
@@ -129,9 +131,12 @@ before painting, so continuous arrivals cannot starve a frame.
129 131
130 The mailbox carries the four things a viewer can say — input bytes, a 132 The mailbox carries the four things a viewer can say — input bytes, a
131 resize, a detach and quit — and a nonblocking pipe doorbell rings the pump 133 resize, a detach and quit — and a nonblocking pipe doorbell rings the pump
132 out of its poll to send them. The window thread never touches the transport. 134 out of its poll to send them. A full doorbell never blocks a post: the queued
135 mailbox remains authoritative. The window thread never touches the transport.
133 136
134 Frame handling mirrors the wall pump arm for arm and no further: 137 Frame handling mirrors the wall pump arm for arm and no further. A fresh
138 attach requests seq 0; a reconnect resumes from `Replica.attachArgs`' retained
139 sequence and epoch:
135 140
136 - `BadPayload` from a short snapshot leaves the grid untouched and is 141 - `BadPayload` from a short snapshot leaves the grid untouched and is
137 skipped. `SnapshotAborted` makes the replica unusable: publish a failure, 142 skipped. `SnapshotAborted` makes the replica unusable: publish a failure,
@@ -208,9 +213,12 @@ expose, plus timed repainting while blinking cells are visible. An ordinary
208 idle window draws nothing. Bell-title restoration also has a deadline. 213 idle window draws nothing. Bell-title restoration also has a deadline.
209 214
210 **`bench.zig`** is a ring of per-frame stage times in microseconds: `apply` 215 **`bench.zig`** is a ring of per-frame stage times in microseconds: `apply`
211 (pump side, the time inside `Replica.apply` per pass), and on the window 216 (pump side, the most recent successful `Replica.apply` duration sampled when
217 the window rebuilds), and on the window
212 side `rebuild`, `atlas_upload`, `instance_upload` and `draw_swap`. Idle 218 side `rebuild`, `atlas_upload`, `instance_upload` and `draw_swap`. Idle
213 passes are not recorded. The table — min, average, p99 and max per stage 219 passes are not recorded. `total` sums only the four window-side stages;
220 `apply` is reported separately and never counted as window latency. The
221 table — min, average, p99 and max per stage
214 and in total — prints to stderr on exit and on SIGUSR1, always compiled 222 and in total — prints to stderr on exit and on SIGUSR1, always compiled
215 in, in the shape waystty's did: 223 in, in the shape waystty's did:
216 224
@@ -259,11 +267,13 @@ latest-wins follows input, not focus.
259 - A missing library is a link error in pkg-config's own words at 267 - A missing library is a link error in pkg-config's own words at
260 `make native`; nothing falls back. 268 `make native`; nothing falls back.
261 - A local dial to a socket nobody answers exits 2 with `muxg: no daemon at 269 - A local dial to a socket nobody answers exits 2 with `muxg: no daemon at
262 PATH (run: mux d start -d --sock PATH)`. `muxg` never starts a daemon: the 270 PATH (run: mux d start -d --sock PATH)`. `muxg` never starts a local daemon:
271 remote SSH handoff retains its own remote-start behavior. The
263 self-exec rule says an auto-start may only run the image already running, 272 self-exec rule says an auto-start may only run the image already running,
264 and this image is not the daemon's. 273 and this image is not the daemon's.
265 - A face freetype cannot load, or a GL context SDL cannot create, exits 2 274 - A font operation that fails exits 2 with its named error (for example
266 with that library's error string on stderr. 275 `FaceLoad` or `GlyphLoad`); a GL context SDL cannot create exits 2 with
276 SDL's error string on stderr.
267 - The pump thread ending for any reason the state does not name ends the 277 - The pump thread ending for any reason the state does not name ends the
268 window with exit 1 and the frame table on stderr. 278 window with exit 1 and the frame table on stderr.
269 279
@@ -293,14 +303,15 @@ window, and runs:
293 daemon on an isolated `XDG_RUNTIME_DIR` and `XDG_STATE_HOME`, in the 303 daemon on an isolated `XDG_RUNTIME_DIR` and `XDG_STATE_HOME`, in the
294 harness's shape (`test/os_oracle.sh` for anything about a pid): 304 harness's shape (`test/os_oracle.sh` for anything about a pid):
295 305
296 1. Start a daemon; start `muxg --sock PATH` with `SDL_VIDEODRIVER=offscreen` 306 1. Refuse a Debug binary by checking `muxg --version`, start a daemon, then
297 and a fixed window size, and capture its pid. The 2026-09-04 probe on the 307 start `muxg --sock PATH` with `SDL_VIDEODRIVER=offscreen` and a fixed
298 development box succeeded with the offscreen driver and an OpenGL 4.6 308 window size. The 2026-09-04 development-box probe succeeded with the
299 core context, so this is the primary test path. 309 offscreen driver and an OpenGL 4.6 core context. The VM has not been tested.
300 2. Type `echo native-ok` through the real event path — SDL's own event 310 2. Type a split `printf` command through the real event path — structured so
301 injection from a test hook in `muxg`, gated on an environment variable 311 the shell's command echo cannot contain the expected `native-ok-PID` result
302 the leg sets — and read the shell's reply through `mux a`. The bytes 312 — using SDL's event injection hook, gated on an environment variable the
303 crossed the keymap, the mailbox, the pump and the daemon. 313 leg sets. Read the result through `mux a`; the bytes crossed the keymap,
314 mailbox, pump and daemon.
304 3. Flood the session with a bounded stream — a fixed byte count of base64 315 3. Flood the session with a bounded stream — a fixed byte count of base64
305 from `/dev/urandom` through `mux a run`, never an unbounded `cat` — and 316 from `/dev/urandom` through `mux a run`, never an unbounded `cat` — and
306 send SIGUSR1 when it ends. 317 send SIGUSR1 when it ends.
@@ -308,10 +319,16 @@ harness's shape (`test/os_oracle.sh` for anything about a pid):
308 grew between two SIGUSR1 reads taken during it), and the window-side 319 grew between two SIGUSR1 reads taken during it), and the window-side
309 stages' p99 sat under the budget below. This is the waystty failure, 320 stages' p99 sat under the budget below. This is the waystty failure,
310 pinned. 321 pinned.
311 5. Resize the window through the test hook and read the session's size 322 5. Ask the real GL renderer for a framebuffer capture after drawing coloured
323 session text, then inspect the pixels for the expected red glyphs.
324 6. Resize the window through the test hook and read the session's size
312 back through `mux a status`: the daemon followed the window. 325 back through `mux a status`: the daemon followed the window.
313 6. Close the window; the process exits 0 and the daemon still lists the 326 7. Close the window; the process exits 0 and the daemon still lists the
314 session. 327 session.
328 8. Attach a terminal client to the same session and verify its input reaches
329 the daemon.
330 9. Attach a named native session, exit its shell with code 7, and verify the
331 GUI process returns 7.
315 332
316 At the fixed 960×600 test window, the window-side total p99 budget is 333 At the fixed 960×600 test window, the window-side total p99 budget is
317 20,000 microseconds over the timing ring, including draw/swap and any 334 20,000 microseconds over the timing ring, including draw/swap and any
@@ -323,10 +340,12 @@ frame progress fails, regardless of its percentiles.
323 The budget is stated for ReleaseSafe or ReleaseFast only, and the leg 340 The budget is stated for ReleaseSafe or ReleaseFast only, and the leg
324 refuses to grade a Debug binary, because a Debug ghostty runs its 341 refuses to grade a Debug binary, because a Debug ghostty runs its
325 page-integrity check on every mutation and a number measured there means 342 page-integrity check on every mutation and a number measured there means
326 nothing. The first plan step is a spike: whether SDL3's offscreen driver 343 nothing. SDL3's offscreen driver yields a GL context on the development box.
327 yields a GL context on the dev box and on the mux-e2e VM. If it does not, 344 A real Wayland window also rendered an isolated session and produced a
328 the leg runs under Xvfb with SDL's X11 driver, and the spec is amended 345 framebuffer capture successfully on 2026-09-04. Local QUIC and `--via`
329 with which. 346 pipe smoke tests also rendered and accepted input;
347 the mux-e2e VM remains to be measured. If it does not there, that environment
348 may run the same leg under Xvfb with SDL's X11 driver.
330 349
331 ## What this adds to the tree 350 ## What this adds to the tree
332 351