a73x

076d28eb

feat: muxd run/dump wired through poll loop; e2e passes

a73x   2026-08-08 14:08

Commit message
feat: muxd run/dump wired through poll loop; e2e passes

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