f25e121b
docs: describe native viewer usage and verified delivery
a73x 2026-09-04 19:42
Commit message
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 | ||