076d28eb
feat: muxd run/dump wired through poll loop; e2e passes
a73x 2026-08-08 14:08
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,22 @@ | |||
| 1 | # mux | ||
| 2 | |||
| 3 | Prototype terminal multiplexer: the terminal engine (ghostty-vt) runs | ||
| 4 | authoritatively in a daemon and replicated in the client — state sync | ||
| 5 | instead of escape-sequence replay. See `docs/handoff.md` for the design | ||
| 6 | and `docs/decisions.md` for decisions made. | ||
| 7 | |||
| 8 | Status: **M1 — headless engine.** | ||
| 9 | |||
| 10 | Requires Zig 0.15.x (ghostty pin); the Makefile points at the pinned | ||
| 11 | toolchain, override with `make ZIG=...`. | ||
| 12 | |||
| 13 | make test && make e2e # verify | ||
| 14 | make build | ||
| 15 | ./zig-out/bin/muxd run # daemon, forwards stdin to the PTY | ||
| 16 | ./zig-out/bin/muxd dump [--vt] # print the authoritative grid | ||
| 17 | |||
| 18 | M1 demo: run `muxd run` in one terminal (keystrokes are forwarded blind — | ||
| 19 | the grid lives only in the daemon), then `muxd dump` from another terminal | ||
| 20 | while `nvim`, `top`, or `less` is running inside. The dump matches what a | ||
| 21 | real terminal would show, including CJK, ZWJ emoji, and SGR styling | ||
| 22 | (`--vt`). | ||
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,38 @@ | |||
| 1 | # Decision log | ||
| 2 | |||
| 3 | ## 2026-08-07 (M1) | ||
| 4 | |||
| 5 | - **Language: Zig.** The engine dependency (ghostty-vt) is a Zig module; a C | ||
| 6 | shim would add surface without adding capability. | ||
| 7 | - **Engine: upstream ghostty package, not a fork.** Pinned at commit | ||
| 8 | `853183e9` (1.3.2-dev), module `ghostty-vt`. The M1 kill criterion | ||
| 9 | ("cannot extract grid without invasive forking") is moot: upstream ships | ||
| 10 | a headless VT library with plain/VT/HTML formatters and a RenderState | ||
| 11 | dirty-tracking API (relevant for M4 deltas). API is documented unstable; | ||
| 12 | the pin is load-bearing. | ||
| 13 | - **Toolchain: Zig 0.15.2, pinned in the Makefile.** ghostty's build hard- | ||
| 14 | requires 0.15.x (`requireZig` checks major.minor); the system default zig | ||
| 15 | is 0.17-dev and 0.16.0 also fails (ghostty uses pre-0.16 std.Build APIs). | ||
| 16 | Builds use LLVM+LLD (`use_llvm`/`use_lld` in build.zig) because Zig 0.15's | ||
| 17 | self-hosted x86_64 linker can't handle the `.sframe` sections gcc >= 16 | ||
| 18 | emits in this system's crt1.o. | ||
| 19 | - **No code reuse from waystty** (user decision: not performant). ghostty-vt | ||
| 20 | API knowledge only. muxd uses blocking fds + a single-threaded poll loop. | ||
| 21 | - **M1 debug protocol:** one LF-terminated command per connection on | ||
| 22 | `$XDG_RUNTIME_DIR/muxd-debug.sock`, EOF-delimited reply. Throwaway; | ||
| 23 | M2 replaces it and claims `muxd.sock` for the real protocol. | ||
| 24 | - **TERM=xterm-256color** in the child, not xterm-ghostty: terminfo | ||
| 25 | availability beats capability advertising for a prototype. | ||
| 26 | - **Scrollback: engine-native.** ghostty-vt's max_scrollback (10k lines) | ||
| 27 | is the ring buffer; no separate structure in muxd. | ||
| 28 | - **Poll-loop lesson:** a pipe/FIFO stdin at EOF reports POLLHUP without | ||
| 29 | POLLIN; the loop must attempt a read on any revents or it busy-loops at | ||
| 30 | 100% CPU. Found during the M1 demo, fixed and verified at 0% idle. | ||
| 31 | |||
| 32 | ## Open (owed by later milestones) | ||
| 33 | |||
| 34 | - Resize policy under multiple clients (M5) | ||
| 35 | - Snapshot-vs-delta threshold (M4) | ||
| 36 | - Scrollback retention/eviction limits (M3/M4) | ||
| 37 | - Daemon lifetime across logout/reboot (M2) | ||
| 38 | - Wire format msgpack vs protobuf + versioning (M2/M4) | ||
docs/superpowers/plans/2026-08-07-m1-headless-engine.md
| Old | New | ||
|---|---|---|---|
| @@ -52,7 +52,7 @@ The M1 kill criterion in miniature: if this task's one test compiles and passes, | |||
| 52 | **Files:** | 52 | **Files:** |
| 53 | - Create: `.gitignore`, `build.zig.zon`, `build.zig`, `src/engine.zig` (test only, minimal impl), `src/main.zig` (stub) | 53 | - Create: `.gitignore`, `build.zig.zon`, `build.zig`, `src/engine.zig` (test only, minimal impl), `src/main.zig` (stub) |
| 54 | 54 | ||
| 55 | - [ ] **Step 1: Init repo** | 55 | - [x] **Step 1: Init repo** |
| 56 | 56 | ||
| 57 | ```bash | 57 | ```bash |
| 58 | cd /home/xanderle/code/rad/mux | 58 | cd /home/xanderle/code/rad/mux |
| @@ -62,7 +62,7 @@ git add .gitignore docs/ | |||
| 62 | git commit -m "docs: add design handoff and M1 plan" | 62 | git commit -m "docs: add design handoff and M1 plan" |
| 63 | ``` | 63 | ``` |
| 64 | 64 | ||
| 65 | - [ ] **Step 2: Write `build.zig.zon`** | 65 | - [x] **Step 2: Write `build.zig.zon`** |
| 66 | 66 | ||
| 67 | ```zig | 67 | ```zig |
| 68 | .{ | 68 | .{ |
| @@ -87,7 +87,7 @@ git commit -m "docs: add design handoff and M1 plan" | |||
| 87 | 87 | ||
| 88 | The hash matches `~/.cache/zig/p/`, so no network fetch happens. | 88 | The hash matches `~/.cache/zig/p/`, so no network fetch happens. |
| 89 | 89 | ||
| 90 | - [ ] **Step 3: Write `build.zig`** | 90 | - [x] **Step 3: Write `build.zig`** |
| 91 | 91 | ||
| 92 | ```zig | 92 | ```zig |
| 93 | const std = @import("std"); | 93 | const std = @import("std"); |
| @@ -156,7 +156,7 @@ Note: `src/pty.zig` and `src/debug.zig` don't exist until Tasks 3–4. For this | |||
| 156 | pub fn main() !void {} | 156 | pub fn main() !void {} |
| 157 | ``` | 157 | ``` |
| 158 | 158 | ||
| 159 | - [ ] **Step 4: Write the failing smoke test in `src/engine.zig`** | 159 | - [x] **Step 4: Write the failing smoke test in `src/engine.zig`** |
| 160 | 160 | ||
| 161 | ```zig | 161 | ```zig |
| 162 | //! Authoritative headless terminal engine. Wraps ghostty-vt's Terminal | 162 | //! Authoritative headless terminal engine. Wraps ghostty-vt's Terminal |
| @@ -180,7 +180,7 @@ test "ghostty-vt boots headless and text lands in the grid" { | |||
| 180 | } | 180 | } |
| 181 | ``` | 181 | ``` |
| 182 | 182 | ||
| 183 | - [ ] **Step 5: Run and fix the fingerprint, then verify the test passes** | 183 | - [x] **Step 5: Run and fix the fingerprint, then verify the test passes** |
| 184 | 184 | ||
| 185 | Run: `zig build test` | 185 | Run: `zig build test` |
| 186 | Expected: first invocation errors with `invalid fingerprint: 0x0; if this is a new package, use "0x..."` — copy the suggested value into `build.zig.zon` and re-run. | 186 | Expected: first invocation errors with `invalid fingerprint: 0x0; if this is a new package, use "0x..."` — copy the suggested value into `build.zig.zon` and re-run. |
| @@ -188,7 +188,7 @@ Expected: `zig build test` exits 0 (test passed silently). | |||
| 188 | 188 | ||
| 189 | If instead the ghostty package itself fails to compile under Zig 0.17-dev, STOP: this is the M1 kill-criterion path. Check what zig version `~/code/rad/waystty` pins before concluding anything (a version mismatch is an environment problem, not a thesis failure). | 189 | If instead the ghostty package itself fails to compile under Zig 0.17-dev, STOP: this is the M1 kill-criterion path. Check what zig version `~/code/rad/waystty` pins before concluding anything (a version mismatch is an environment problem, not a thesis failure). |
| 190 | 190 | ||
| 191 | - [ ] **Step 6: Commit** | 191 | - [x] **Step 6: Commit** |
| 192 | 192 | ||
| 193 | ```bash | 193 | ```bash |
| 194 | git add build.zig build.zig.zon src/ | 194 | git add build.zig build.zig.zon src/ |
| @@ -202,7 +202,7 @@ git commit -m "feat: scaffold muxd; prove ghostty-vt drives headless" | |||
| 202 | **Files:** | 202 | **Files:** |
| 203 | - Modify: `src/engine.zig` | 203 | - Modify: `src/engine.zig` |
| 204 | 204 | ||
| 205 | - [ ] **Step 1: Write the failing tests (append to `src/engine.zig`)** | 205 | - [x] **Step 1: Write the failing tests (append to `src/engine.zig`)** |
| 206 | 206 | ||
| 207 | ```zig | 207 | ```zig |
| 208 | test "Engine: wide CJK chars dump byte-correct" { | 208 | test "Engine: wide CJK chars dump byte-correct" { |
| @@ -273,12 +273,12 @@ test "Engine: resize" { | |||
| 273 | } | 273 | } |
| 274 | ``` | 274 | ``` |
| 275 | 275 | ||
| 276 | - [ ] **Step 2: Run tests to verify they fail** | 276 | - [x] **Step 2: Run tests to verify they fail** |
| 277 | 277 | ||
| 278 | Run: `zig build test` | 278 | Run: `zig build test` |
| 279 | Expected: compile error — `Engine` not defined. | 279 | Expected: compile error — `Engine` not defined. |
| 280 | 280 | ||
| 281 | - [ ] **Step 3: Implement `Engine` (above the tests in `src/engine.zig`)** | 281 | - [x] **Step 3: Implement `Engine` (above the tests in `src/engine.zig`)** |
| 282 | 282 | ||
| 283 | ```zig | 283 | ```zig |
| 284 | pub const Engine = struct { | 284 | pub const Engine = struct { |
| @@ -366,7 +366,7 @@ pub const Engine = struct { | |||
| 366 | 366 | ||
| 367 | API-drift notes for the implementer: if `TerminalFormatter.format` needs a mutable formatter, make `f` a `var`. If the fixed-point assertion in the SGR test fails on `extra`-emitted state (palette OSC 4 lines are expected and deterministic — they should be identical in both dumps), diagnose by printing both dumps with `std.testing.expectEqualStrings`'s diff output before weakening the test; only fall back to `plain_a == plain_b` plus substring checks for `[1m`/`[31m`-family sequences if the formatter output is genuinely non-idempotent, and record that in `docs/decisions.md`. | 367 | API-drift notes for the implementer: if `TerminalFormatter.format` needs a mutable formatter, make `f` a `var`. If the fixed-point assertion in the SGR test fails on `extra`-emitted state (palette OSC 4 lines are expected and deterministic — they should be identical in both dumps), diagnose by printing both dumps with `std.testing.expectEqualStrings`'s diff output before weakening the test; only fall back to `plain_a == plain_b` plus substring checks for `[1m`/`[31m`-family sequences if the formatter output is genuinely non-idempotent, and record that in `docs/decisions.md`. |
| 368 | 368 | ||
| 369 | - [ ] **Step 4: Update the Task 1 smoke test to use Engine** | 369 | - [x] **Step 4: Update the Task 1 smoke test to use Engine** |
| 370 | 370 | ||
| 371 | Replace the Task 1 test body with: | 371 | Replace the Task 1 test body with: |
| 372 | 372 | ||
| @@ -382,12 +382,12 @@ test "ghostty-vt boots headless and text lands in the grid" { | |||
| 382 | } | 382 | } |
| 383 | ``` | 383 | ``` |
| 384 | 384 | ||
| 385 | - [ ] **Step 5: Run tests to verify they pass** | 385 | - [x] **Step 5: Run tests to verify they pass** |
| 386 | 386 | ||
| 387 | Run: `zig build test` | 387 | Run: `zig build test` |
| 388 | Expected: exit 0. | 388 | Expected: exit 0. |
| 389 | 389 | ||
| 390 | - [ ] **Step 6: Commit** | 390 | - [x] **Step 6: Commit** |
| 391 | 391 | ||
| 392 | ```bash | 392 | ```bash |
| 393 | git add src/engine.zig | 393 | git add src/engine.zig |
| @@ -403,7 +403,7 @@ Fresh implementation (not waystty's): blocking master fd, `poll`-driven by the c | |||
| 403 | **Files:** | 403 | **Files:** |
| 404 | - Modify: `src/pty.zig` (currently empty) | 404 | - Modify: `src/pty.zig` (currently empty) |
| 405 | 405 | ||
| 406 | - [ ] **Step 1: Write the failing tests** | 406 | - [x] **Step 1: Write the failing tests** |
| 407 | 407 | ||
| 408 | ```zig | 408 | ```zig |
| 409 | const std = @import("std"); | 409 | const std = @import("std"); |
| @@ -473,12 +473,12 @@ test "Pty: checkExited reports shell exit" { | |||
| 473 | } | 473 | } |
| 474 | ``` | 474 | ``` |
| 475 | 475 | ||
| 476 | - [ ] **Step 2: Run tests to verify they fail** | 476 | - [x] **Step 2: Run tests to verify they fail** |
| 477 | 477 | ||
| 478 | Run: `zig build test` | 478 | Run: `zig build test` |
| 479 | Expected: compile error — `Pty` not defined. | 479 | Expected: compile error — `Pty` not defined. |
| 480 | 480 | ||
| 481 | - [ ] **Step 3: Implement `Pty` (above the tests)** | 481 | - [x] **Step 3: Implement `Pty` (above the tests)** |
| 482 | 482 | ||
| 483 | ```zig | 483 | ```zig |
| 484 | pub const Pty = struct { | 484 | pub const Pty = struct { |
| @@ -558,12 +558,12 @@ pub const Pty = struct { | |||
| 558 | 558 | ||
| 559 | API-drift note: `std.posix.waitpid(pid, W.NOHANG)` returns a `WaitPidResult` with `.pid` and `.status`; on "still running" the returned pid is 0. If the shape differs on this std version, check `std.posix` source under `~/.local/bin/../lib/zig` (or `zig std`). | 559 | API-drift note: `std.posix.waitpid(pid, W.NOHANG)` returns a `WaitPidResult` with `.pid` and `.status`; on "still running" the returned pid is 0. If the shape differs on this std version, check `std.posix` source under `~/.local/bin/../lib/zig` (or `zig std`). |
| 560 | 560 | ||
| 561 | - [ ] **Step 4: Run tests to verify they pass** | 561 | - [x] **Step 4: Run tests to verify they pass** |
| 562 | 562 | ||
| 563 | Run: `zig build test` | 563 | Run: `zig build test` |
| 564 | Expected: exit 0. | 564 | Expected: exit 0. |
| 565 | 565 | ||
| 566 | - [ ] **Step 5: Commit** | 566 | - [x] **Step 5: Commit** |
| 567 | 567 | ||
| 568 | ```bash | 568 | ```bash |
| 569 | git add src/pty.zig | 569 | git add src/pty.zig |
| @@ -579,7 +579,7 @@ M1-only, replaced wholesale in M2. Protocol: client sends one LF-terminated comm | |||
| 579 | **Files:** | 579 | **Files:** |
| 580 | - Modify: `src/debug.zig` (currently empty) | 580 | - Modify: `src/debug.zig` (currently empty) |
| 581 | 581 | ||
| 582 | - [ ] **Step 1: Write the failing test** | 582 | - [x] **Step 1: Write the failing test** |
| 583 | 583 | ||
| 584 | ```zig | 584 | ```zig |
| 585 | const std = @import("std"); | 585 | const std = @import("std"); |
| @@ -629,12 +629,12 @@ test "DebugServer: dump plain round trip over unix socket" { | |||
| 629 | } | 629 | } |
| 630 | ``` | 630 | ``` |
| 631 | 631 | ||
| 632 | - [ ] **Step 2: Run tests to verify they fail** | 632 | - [x] **Step 2: Run tests to verify they fail** |
| 633 | 633 | ||
| 634 | Run: `zig build test` | 634 | Run: `zig build test` |
| 635 | Expected: compile error — `DebugServer` not defined. | 635 | Expected: compile error — `DebugServer` not defined. |
| 636 | 636 | ||
| 637 | - [ ] **Step 3: Implement `DebugServer` (above the test)** | 637 | - [x] **Step 3: Implement `DebugServer` (above the test)** |
| 638 | 638 | ||
| 639 | ```zig | 639 | ```zig |
| 640 | /// M1-only debug listener. One LF-terminated command per connection: | 640 | /// M1-only debug listener. One LF-terminated command per connection: |
| @@ -688,12 +688,12 @@ pub const DebugServer = struct { | |||
| 688 | }; | 688 | }; |
| 689 | ``` | 689 | ``` |
| 690 | 690 | ||
| 691 | - [ ] **Step 4: Run tests to verify they pass** | 691 | - [x] **Step 4: Run tests to verify they pass** |
| 692 | 692 | ||
| 693 | Run: `zig build test` | 693 | Run: `zig build test` |
| 694 | Expected: exit 0. | 694 | Expected: exit 0. |
| 695 | 695 | ||
| 696 | - [ ] **Step 5: Commit** | 696 | - [x] **Step 5: Commit** |
| 697 | 697 | ||
| 698 | ```bash | 698 | ```bash |
| 699 | git add src/debug.zig | 699 | git add src/debug.zig |
| @@ -708,7 +708,7 @@ git commit -m "feat: M1 debug dump socket (line command, EOF-delimited reply)" | |||
| 708 | - Modify: `src/main.zig` (replace stub) | 708 | - Modify: `src/main.zig` (replace stub) |
| 709 | - Create: `test/e2e.sh` | 709 | - Create: `test/e2e.sh` |
| 710 | 710 | ||
| 711 | - [ ] **Step 1: Write the failing e2e test `test/e2e.sh`** | 711 | - [x] **Step 1: Write the failing e2e test `test/e2e.sh`** |
| 712 | 712 | ||
| 713 | ```sh | 713 | ```sh |
| 714 | #!/bin/sh | 714 | #!/bin/sh |
| @@ -740,12 +740,12 @@ esac | |||
| 740 | 740 | ||
| 741 | Then: `chmod +x test/e2e.sh` | 741 | Then: `chmod +x test/e2e.sh` |
| 742 | 742 | ||
| 743 | - [ ] **Step 2: Run it to verify it fails** | 743 | - [x] **Step 2: Run it to verify it fails** |
| 744 | 744 | ||
| 745 | Run: `zig build e2e` | 745 | Run: `zig build e2e` |
| 746 | Expected: failure — `muxd run` is still a stub (unknown args, no socket). | 746 | Expected: failure — `muxd run` is still a stub (unknown args, no socket). |
| 747 | 747 | ||
| 748 | - [ ] **Step 3: Implement `src/main.zig`** | 748 | - [x] **Step 3: Implement `src/main.zig`** |
| 749 | 749 | ||
| 750 | ```zig | 750 | ```zig |
| 751 | const std = @import("std"); | 751 | const std = @import("std"); |
| @@ -931,14 +931,14 @@ fn writeAll(fd: std.posix.fd_t, data: []const u8) void { | |||
| 931 | 931 | ||
| 932 | API-drift notes: `std.posix.winsize` field names are `row`/`col`/`xpixel`/`ypixel` on current std (older: `ws_row`/`ws_col`). `poll` error set may not include `SignalInterrupt` (std retries EINTR internally on some versions) — if the compiler says the switch arm is unreachable, drop the switch. `main` returning `!u8` sets the process exit code. | 932 | API-drift notes: `std.posix.winsize` field names are `row`/`col`/`xpixel`/`ypixel` on current std (older: `ws_row`/`ws_col`). `poll` error set may not include `SignalInterrupt` (std retries EINTR internally on some versions) — if the compiler says the switch arm is unreachable, drop the switch. `main` returning `!u8` sets the process exit code. |
| 933 | 933 | ||
| 934 | - [ ] **Step 4: Run unit tests, then e2e** | 934 | - [x] **Step 4: Run unit tests, then e2e** |
| 935 | 935 | ||
| 936 | Run: `zig build test` | 936 | Run: `zig build test` |
| 937 | Expected: exit 0. | 937 | Expected: exit 0. |
| 938 | Run: `zig build e2e` | 938 | Run: `zig build e2e` |
| 939 | Expected: prints `e2e OK`. | 939 | Expected: prints `e2e OK`. |
| 940 | 940 | ||
| 941 | - [ ] **Step 5: Commit** | 941 | - [x] **Step 5: Commit** |
| 942 | 942 | ||
| 943 | ```bash | 943 | ```bash |
| 944 | git add src/main.zig test/e2e.sh | 944 | git add src/main.zig test/e2e.sh |
| @@ -952,7 +952,7 @@ git commit -m "feat: muxd run/dump wired through poll loop; e2e passes" | |||
| 952 | **Files:** | 952 | **Files:** |
| 953 | - Create: `docs/decisions.md`, `README.md` | 953 | - Create: `docs/decisions.md`, `README.md` |
| 954 | 954 | ||
| 955 | - [ ] **Step 1: Manual demo (the M1 acceptance run)** | 955 | - [x] **Step 1: Manual demo (the M1 acceptance run)** |
| 956 | 956 | ||
| 957 | In terminal A: | 957 | In terminal A: |
| 958 | ```bash | 958 | ```bash |
| @@ -968,7 +968,7 @@ In terminal B, after typing each of the following in A, run `./zig-out/bin/muxd | |||
| 968 | 968 | ||
| 969 | Record any mismatch as a bug before declaring M1 done. If a TUI hangs waiting for a terminal query response, the missing piece is an `effects` callback in `engine.zig` (likely `device_attributes` — wire it to return defaults `.{}`; see `stream_terminal.zig` in the pinned package for the exact signature). | 969 | Record any mismatch as a bug before declaring M1 done. If a TUI hangs waiting for a terminal query response, the missing piece is an `effects` callback in `engine.zig` (likely `device_attributes` — wire it to return defaults `.{}`; see `stream_terminal.zig` in the pinned package for the exact signature). |
| 970 | 970 | ||
| 971 | - [ ] **Step 2: Write `docs/decisions.md`** | 971 | - [x] **Step 2: Write `docs/decisions.md`** |
| 972 | 972 | ||
| 973 | ```markdown | 973 | ```markdown |
| 974 | # Decision log | 974 | # Decision log |
| @@ -1002,7 +1002,7 @@ Record any mismatch as a bug before declaring M1 done. If a TUI hangs waiting fo | |||
| 1002 | - Wire format msgpack vs protobuf + versioning (M2/M4) | 1002 | - Wire format msgpack vs protobuf + versioning (M2/M4) |
| 1003 | ``` | 1003 | ``` |
| 1004 | 1004 | ||
| 1005 | - [ ] **Step 3: Write `README.md`** | 1005 | - [x] **Step 3: Write `README.md`** |
| 1006 | 1006 | ||
| 1007 | ```markdown | 1007 | ```markdown |
| 1008 | # mux | 1008 | # mux |
| @@ -1019,7 +1019,7 @@ Status: **M1 — headless engine.** | |||
| 1019 | ./zig-out/bin/muxd dump [--vt] # print the authoritative grid | 1019 | ./zig-out/bin/muxd dump [--vt] # print the authoritative grid |
| 1020 | ``` | 1020 | ``` |
| 1021 | 1021 | ||
| 1022 | - [ ] **Step 4: Commit** | 1022 | - [x] **Step 4: Commit** |
| 1023 | 1023 | ||
| 1024 | ```bash | 1024 | ```bash |
| 1025 | git add docs/decisions.md README.md | 1025 | git add docs/decisions.md README.md |
src/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -1 +1,181 @@ | |||
| 1 | pub fn main() !void {} | 1 | //! muxd — M1 prototype daemon. One session, foreground, single-threaded |
| 2 | //! poll loop. `run` hosts $SHELL on a PTY feeding the headless engine; | ||
| 3 | //! `dump` prints the authoritative grid over the debug socket. | ||
| 4 | const std = @import("std"); | ||
| 5 | const Engine = @import("engine").Engine; | ||
| 6 | const Pty = @import("pty").Pty; | ||
| 7 | const debug = @import("debug"); | ||
| 8 | |||
| 9 | const usage = | ||
| 10 | \\usage: | ||
| 11 | \\ muxd run [--sock PATH] [--shell PATH] run daemon in foreground; | ||
| 12 | \\ stdin is forwarded to the PTY | ||
| 13 | \\ muxd dump [--vt] [--sock PATH] print the current grid | ||
| 14 | \\ | ||
| 15 | ; | ||
| 16 | |||
| 17 | pub fn main() !u8 { | ||
| 18 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 19 | defer _ = gpa.deinit(); | ||
| 20 | const alloc = gpa.allocator(); | ||
| 21 | |||
| 22 | const args = try std.process.argsAlloc(alloc); | ||
| 23 | defer std.process.argsFree(alloc, args); | ||
| 24 | |||
| 25 | if (args.len < 2) { | ||
| 26 | std.debug.print("{s}", .{usage}); | ||
| 27 | return 2; | ||
| 28 | } | ||
| 29 | |||
| 30 | var sock_arg: ?[]const u8 = null; | ||
| 31 | var shell_arg: ?[]const u8 = null; | ||
| 32 | var vt_mode = false; | ||
| 33 | var i: usize = 2; | ||
| 34 | while (i < args.len) : (i += 1) { | ||
| 35 | const a = args[i]; | ||
| 36 | if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) { | ||
| 37 | i += 1; | ||
| 38 | sock_arg = args[i]; | ||
| 39 | } else if (std.mem.eql(u8, a, "--shell") and i + 1 < args.len) { | ||
| 40 | i += 1; | ||
| 41 | shell_arg = args[i]; | ||
| 42 | } else if (std.mem.eql(u8, a, "--vt")) { | ||
| 43 | vt_mode = true; | ||
| 44 | } else { | ||
| 45 | std.debug.print("unknown argument: {s}\n{s}", .{ a, usage }); | ||
| 46 | return 2; | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | const sock_path = if (sock_arg) |s| | ||
| 51 | try alloc.dupe(u8, s) | ||
| 52 | else | ||
| 53 | try defaultSockPath(alloc); | ||
| 54 | defer alloc.free(sock_path); | ||
| 55 | |||
| 56 | if (std.mem.eql(u8, args[1], "run")) return run(alloc, sock_path, shell_arg); | ||
| 57 | if (std.mem.eql(u8, args[1], "dump")) return dump(sock_path, vt_mode); | ||
| 58 | std.debug.print("{s}", .{usage}); | ||
| 59 | return 2; | ||
| 60 | } | ||
| 61 | |||
| 62 | fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 { | ||
| 63 | if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir| { | ||
| 64 | return std.fmt.allocPrint(alloc, "{s}/muxd-debug.sock", .{dir}); | ||
| 65 | } | ||
| 66 | return std.fmt.allocPrint(alloc, "/tmp/muxd-debug-{d}.sock", .{std.os.linux.getuid()}); | ||
| 67 | } | ||
| 68 | |||
| 69 | fn run(alloc: std.mem.Allocator, sock_path: []const u8, shell_arg: ?[]const u8) !u8 { | ||
| 70 | const shell_z: [:0]const u8 = if (shell_arg) |s| | ||
| 71 | try alloc.dupeZ(u8, s) | ||
| 72 | else | ||
| 73 | try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh"); | ||
| 74 | defer alloc.free(shell_z); | ||
| 75 | |||
| 76 | // Grid size: the controlling tty's size if we have one, else 80x24. | ||
| 77 | var cols: u16 = 80; | ||
| 78 | var rows: u16 = 24; | ||
| 79 | if (std.posix.isatty(std.posix.STDIN_FILENO)) { | ||
| 80 | var ws: std.posix.winsize = undefined; | ||
| 81 | if (std.os.linux.ioctl( | ||
| 82 | std.posix.STDIN_FILENO, | ||
| 83 | std.os.linux.T.IOCGWINSZ, | ||
| 84 | @intFromPtr(&ws), | ||
| 85 | ) == 0) { | ||
| 86 | cols = ws.col; | ||
| 87 | rows = ws.row; | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | const eng = try Engine.init(alloc, .{ .cols = cols, .rows = rows }); | ||
| 92 | defer eng.deinit(); | ||
| 93 | |||
| 94 | var pty = try Pty.spawn(.{ .cols = cols, .rows = rows, .shell = shell_z }); | ||
| 95 | defer pty.deinit(); | ||
| 96 | |||
| 97 | var srv = try debug.DebugServer.init(sock_path); | ||
| 98 | defer srv.deinit(); | ||
| 99 | |||
| 100 | // Raw mode so keystrokes (arrows, ^C) pass through to the PTY. | ||
| 101 | const stdin_fd = std.posix.STDIN_FILENO; | ||
| 102 | var orig_termios: ?std.posix.termios = null; | ||
| 103 | if (std.posix.isatty(stdin_fd)) { | ||
| 104 | const orig = try std.posix.tcgetattr(stdin_fd); | ||
| 105 | orig_termios = orig; | ||
| 106 | var raw = orig; | ||
| 107 | raw.lflag.ICANON = false; | ||
| 108 | raw.lflag.ECHO = false; | ||
| 109 | raw.lflag.ISIG = false; | ||
| 110 | raw.iflag.IXON = false; | ||
| 111 | raw.iflag.ICRNL = false; | ||
| 112 | try std.posix.tcsetattr(stdin_fd, .FLUSH, raw); | ||
| 113 | } | ||
| 114 | defer if (orig_termios) |t| std.posix.tcsetattr(stdin_fd, .FLUSH, t) catch {}; | ||
| 115 | |||
| 116 | var stdin_open = true; | ||
| 117 | var buf: [64 * 1024]u8 = undefined; | ||
| 118 | while (true) { | ||
| 119 | if (pty.checkExited()) |code| return @intCast(code & 0xff); | ||
| 120 | |||
| 121 | var fds = [_]std.posix.pollfd{ | ||
| 122 | .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 123 | .{ .fd = srv.fd(), .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 124 | .{ .fd = if (stdin_open) stdin_fd else -1, .events = std.posix.POLL.IN, .revents = 0 }, | ||
| 125 | }; | ||
| 126 | // 100ms timeout so child exit is noticed even with no fd activity. | ||
| 127 | // (std.posix.poll retries EINTR internally.) | ||
| 128 | _ = try std.posix.poll(&fds, 100); | ||
| 129 | |||
| 130 | if (fds[0].revents & std.posix.POLL.IN != 0) { | ||
| 131 | const n = std.posix.read(pty.master, &buf) catch 0; | ||
| 132 | if (n > 0) { | ||
| 133 | eng.feed(buf[0..n]); | ||
| 134 | const resp = eng.ptyOutput(); | ||
| 135 | if (resp.len > 0) { | ||
| 136 | writeAll(pty.master, resp); | ||
| 137 | eng.clearPtyOutput(); | ||
| 138 | } | ||
| 139 | } | ||
| 140 | } | ||
| 141 | |||
| 142 | if (fds[1].revents & std.posix.POLL.IN != 0) srv.serviceOne(alloc, eng); | ||
| 143 | |||
| 144 | // A pipe/FIFO at EOF reports POLLHUP without POLLIN; treat any | ||
| 145 | // revents as "try a read" or an idle stdin busy-loops the daemon. | ||
| 146 | if (stdin_open and fds[2].revents != 0) { | ||
| 147 | const n = std.posix.read(stdin_fd, &buf) catch 0; | ||
| 148 | if (n == 0) { | ||
| 149 | stdin_open = false; // stdin closed; keep running headless | ||
| 150 | } else { | ||
| 151 | writeAll(pty.master, buf[0..n]); | ||
| 152 | } | ||
| 153 | } | ||
| 154 | } | ||
| 155 | } | ||
| 156 | |||
| 157 | fn dump(sock_path: []const u8, vt_mode: bool) !u8 { | ||
| 158 | const stream = std.net.connectUnixSocket(sock_path) catch { | ||
| 159 | std.debug.print("muxd dump: cannot connect to {s} (is `muxd run` running?)\n", .{sock_path}); | ||
| 160 | return 1; | ||
| 161 | }; | ||
| 162 | defer stream.close(); | ||
| 163 | |||
| 164 | writeAll(stream.handle, if (vt_mode) "dump vt\n" else "dump plain\n"); | ||
| 165 | |||
| 166 | var buf: [4096]u8 = undefined; | ||
| 167 | while (true) { | ||
| 168 | const n = std.posix.read(stream.handle, &buf) catch break; | ||
| 169 | if (n == 0) break; | ||
| 170 | writeAll(std.posix.STDOUT_FILENO, buf[0..n]); | ||
| 171 | } | ||
| 172 | writeAll(std.posix.STDOUT_FILENO, "\n"); | ||
| 173 | return 0; | ||
| 174 | } | ||
| 175 | |||
| 176 | fn writeAll(fd: std.posix.fd_t, data: []const u8) void { | ||
| 177 | var idx: usize = 0; | ||
| 178 | while (idx < data.len) { | ||
| 179 | idx += std.posix.write(fd, data[idx..]) catch return; | ||
| 180 | } | ||
| 181 | } | ||
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,25 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # End-to-end: run muxd headless with piped stdin, dump the grid from a | ||
| 3 | # second process, verify shell output landed in the ghostty-vt grid. | ||
| 4 | set -eu | ||
| 5 | MUXD="$1" | ||
| 6 | SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock" | ||
| 7 | |||
| 8 | cleanup() { kill "$DPID" 2>/dev/null || true; rm -f "$SOCK"; } | ||
| 9 | trap cleanup EXIT INT TERM | ||
| 10 | |||
| 11 | { printf 'printf "e2e-%%s\\n" works\n'; sleep 3; } | \ | ||
| 12 | "$MUXD" run --sock "$SOCK" --shell /bin/sh & | ||
| 13 | DPID=$! | ||
| 14 | |||
| 15 | # Wait for the socket, then give the shell a moment to run the command. | ||
| 16 | i=0 | ||
| 17 | while [ ! -S "$SOCK" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done | ||
| 18 | [ -S "$SOCK" ] || { echo "e2e FAIL: socket never appeared"; exit 1; } | ||
| 19 | sleep 1 | ||
| 20 | |||
| 21 | OUT="$("$MUXD" dump --sock "$SOCK")" | ||
| 22 | case "$OUT" in | ||
| 23 | *e2e-works*) echo "e2e OK" ;; | ||
| 24 | *) echo "e2e FAIL: grid was:"; echo "$OUT"; exit 1 ;; | ||
| 25 | esac | ||