a73x

4e6962dd

Merge branch 'feat/agent-surface'

a73x   2026-08-14 05:04

Commit message
Merge branch 'feat/agent-surface'

# Conflicts:
#	build.zig
#	docs/decisions.md

README.md
Old New
@@ -102,6 +102,16 @@ muxd stats # wire stats: deltas vs snapshot bytes, attached clients
102 make bench # typing-workload byte-ratio measurement 102 make bench # typing-workload byte-ratio measurement
103 ``` 103 ```
104 104
105 `muxa` is the agent-facing client — verbs `status`, `capture`, `send`,
106 `run`, `await`, one JSON object each, against a local socket or
107 `--quic HOST`. `muxa run "make test"` sends the command line, waits for the
108 shell to return it, and prints the exit code with the output rows; no
109 polling and no sleeps, because the daemon holds the wait. It attaches at
110 0×0 so it never resizes the session a human is using, and every reply names
111 the `mechanism` that answered — an exit code is real only under `marks`
112 (OSC 133 shell integration, injected at spawn), absent under the `pgid` and
113 `settle` fallbacks.
114
105 Multiple clients may attach to one session; the grid follows the most 115 Multiple clients may attach to one session; the grid follows the most
106 recently active client — typing, attaching, or resizing claims it (latest 116 recently active client — typing, attaching, or resizing claims it (latest
107 wins). A session survives logout (this assumes systemd-logind's default 117 wins). A session survives logout (this assumes systemd-logind's default
build.zig
Old New
@@ -197,6 +197,30 @@ pub fn build(b: *std.Build) void {
197 delta_mod.addImport("engine", engine_mod); 197 delta_mod.addImport("engine", engine_mod);
198 delta_mod.addImport("protocol", protocol_mod); 198 delta_mod.addImport("protocol", protocol_mod);
199 199
200 // The session's command state machine: MarkEvents in, transitions out.
201 // Engine plus protocol and nothing else, same shape as delta_mod — pure,
202 // socket-free, and its own tests drive it with no daemon in sight.
203 const cmd_mod = b.createModule(.{
204 .root_source_file = b.path("src/cmd.zig"),
205 .target = target,
206 .optimize = optimize,
207 });
208 cmd_mod.addImport("engine", engine_mod);
209 cmd_mod.addImport("protocol", protocol_mod);
210
211 // Shell integration: the OSC 133 mark scripts and what a spawn must add
212 // to hand them to a shell. Near-leaf on purpose — it writes files and
213 // reads the environment, and knows nothing of ptys, servers or the
214 // protocol, so its tests need no daemon and no socket. The one import
215 // is xdg, for the private-directory policy the shim directory shares
216 // with the key file's parent; xdg is itself a leaf, so no cycle.
217 const shellint_mod = b.createModule(.{
218 .root_source_file = b.path("src/shellint.zig"),
219 .target = target,
220 .optimize = optimize,
221 });
222 shellint_mod.addImport("xdg", xdg_mod);
223
200 // The replay core: snapshot/delta application and the resume 224 // The replay core: snapshot/delta application and the resume
201 // coordinates, shared by the CLI client, the wasm core, and the 225 // coordinates, shared by the CLI client, the wasm core, and the
202 // server's test fixtures. Engine plus protocol and nothing else, and 226 // server's test fixtures. Engine plus protocol and nothing else, and
@@ -248,6 +272,8 @@ pub fn build(b: *std.Build) void {
248 server_mod.addImport("pty", pty_mod); 272 server_mod.addImport("pty", pty_mod);
249 server_mod.addImport("protocol", protocol_mod); 273 server_mod.addImport("protocol", protocol_mod);
250 server_mod.addImport("delta", delta_mod); 274 server_mod.addImport("delta", delta_mod);
275 server_mod.addImport("cmd", cmd_mod);
276 server_mod.addImport("shellint", shellint_mod);
251 server_mod.addImport("replica", replica_mod); 277 server_mod.addImport("replica", replica_mod);
252 server_mod.addImport("sockpath", sockpath_mod); 278 server_mod.addImport("sockpath", sockpath_mod);
253 // Both: the listener it owns, and the vocabulary it names directly 279 // Both: the listener it owns, and the vocabulary it names directly
@@ -306,6 +332,7 @@ pub fn build(b: *std.Build) void {
306 client_mod.addImport("replica", replica_mod); 332 client_mod.addImport("replica", replica_mod);
307 client_mod.addImport("testtmp", testtmp_mod); 333 client_mod.addImport("testtmp", testtmp_mod);
308 client_mod.addImport("quic_client", quic_client_mod); 334 client_mod.addImport("quic_client", quic_client_mod);
335 client_mod.addImport("quic", quic_mod);
309 // The client is the only thing that predicts: the overlay is a local 336 // The client is the only thing that predicts: the overlay is a local
310 // display decision and never becomes state anybody else can see. 337 // display decision and never becomes state anybody else can see.
311 client_mod.addImport("predict", predict_mod); 338 client_mod.addImport("predict", predict_mod);
@@ -389,6 +416,7 @@ pub fn build(b: *std.Build) void {
389 }); 416 });
390 exe_mod.addImport("server", server_mod); 417 exe_mod.addImport("server", server_mod);
391 exe_mod.addImport("protocol", protocol_mod); 418 exe_mod.addImport("protocol", protocol_mod);
419 exe_mod.addImport("cmd", cmd_mod);
392 exe_mod.addImport("proxy", proxy_mod); 420 exe_mod.addImport("proxy", proxy_mod);
393 // The daemon entrypoint loads the key and constructs the listener, so it 421 // The daemon entrypoint loads the key and constructs the listener, so it
394 // needs the modules directly rather than through the server. 422 // needs the modules directly rather than through the server.
@@ -415,6 +443,24 @@ pub fn build(b: *std.Build) void {
415 linkQuic(b, exe, quic); 443 linkQuic(b, exe, quic);
416 b.installArtifact(exe); 444 b.installArtifact(exe);
417 445
446 // The agent-facing client. It speaks frames and owns no terminal, which
447 // is the whole point — it attaches at 0x0 and never claims the grid.
448 // The transport modules are the CLI client's, minus everything that
449 // renders: `quic_client` for the remote arm and `xdg` for the one
450 // key-resolution rule all three binaries obey. Deliberately still no
451 // engine and no replica — muxa has nothing to draw.
452 const muxa_mod = b.createModule(.{
453 .root_source_file = b.path("src/muxa.zig"),
454 .target = target,
455 .optimize = optimize,
456 .link_libc = true,
457 });
458 muxa_mod.addImport("protocol", protocol_mod);
459 muxa_mod.addImport("sockpath", sockpath_mod);
460 muxa_mod.addImport("quic_client", quic_client_mod);
461 muxa_mod.addImport("quic", quic_mod);
462 muxa_mod.addImport("xdg", xdg_mod);
463
418 const mux_exe = b.addExecutable(.{ .name = "mux", .root_module = mux_mod }); 464 const mux_exe = b.addExecutable(.{ .name = "mux", .root_module = mux_mod });
419 mux_exe.use_llvm = true; 465 mux_exe.use_llvm = true;
420 mux_exe.use_lld = true; 466 mux_exe.use_lld = true;
@@ -422,6 +468,13 @@ pub fn build(b: *std.Build) void {
422 linkQuic(b, mux_exe, quic); 468 linkQuic(b, mux_exe, quic);
423 b.installArtifact(mux_exe); 469 b.installArtifact(mux_exe);
424 470
471 const muxa_exe = b.addExecutable(.{ .name = "muxa", .root_module = muxa_mod });
472 muxa_exe.use_llvm = true;
473 muxa_exe.use_lld = true;
474 // The agent client dials remote daemons now, so it carries the stack too.
475 linkQuic(b, muxa_exe, quic);
476 b.installArtifact(muxa_exe);
477
425 const rawmode_exe = b.addExecutable(.{ .name = "rawmode", .root_module = rawmode_mod }); 478 const rawmode_exe = b.addExecutable(.{ .name = "rawmode", .root_module = rawmode_mod });
426 rawmode_exe.use_llvm = true; 479 rawmode_exe.use_llvm = true;
427 rawmode_exe.use_lld = true; 480 rawmode_exe.use_lld = true;
@@ -524,11 +577,11 @@ pub fn build(b: *std.Build) void {
524 b.installArtifact(webhub_exe); 577 b.installArtifact(webhub_exe);
525 578
526 const test_step = b.step("test", "Run unit tests"); 579 const test_step = b.step("test", "Run unit tests");
527 // delta_mod and sockpath_mod sit BEFORE server_mod, deliberately: their 580 // delta_mod, cmd_mod, shellint_mod and sockpath_mod sit BEFORE server_mod,
528 // tests are seconds-long and socket-free, while a regression in either 581 // deliberately: their tests are seconds-long and socket-free, while a
529 // can wedge a server test that waits on a client forever — and a wedged 582 // regression in any of them can wedge a server test that waits on a
530 // step prints nothing at all. Failing first is what makes the catch 583 // client forever — and a wedged step prints nothing at all. Failing
531 // legible. 584 // first is what makes the catch legible.
532 // 585 //
533 // mux_mod and exe_mod are executable roots, but they carry the argument 586 // mux_mod and exe_mod are executable roots, but they carry the argument
534 // parsers, and a test that is never built is not a test. exe_mod's 587 // parsers, and a test that is never built is not a test. exe_mod's
@@ -539,7 +592,7 @@ pub fn build(b: *std.Build) void {
539 // script_mod leads for the same order-is-legibility reason: its tests 592 // script_mod leads for the same order-is-legibility reason: its tests
540 // are instant and allocation-only, and the escape pins they carry are 593 // are instant and allocation-only, and the escape pins they carry are
541 // the ones both fixtures inherit. 594 // the ones both fixtures inherit.
542 for ([_]*std.Build.Module{ script_mod, protocol_mod, engine_mod, pty_mod, delta_mod, replica_mod, keymap_mod, webhub_mod, sockpath_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod, webhub_main_mod, wsclient_mod }) |mod| { 595 for ([_]*std.Build.Module{ script_mod, protocol_mod, engine_mod, pty_mod, delta_mod, cmd_mod, shellint_mod, replica_mod, keymap_mod, webhub_mod, sockpath_mod, muxa_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod, webhub_main_mod, wsclient_mod }) |mod| {
543 const t = b.addTest(.{ .root_module = mod }); 596 const t = b.addTest(.{ .root_module = mod });
544 t.use_llvm = true; 597 t.use_llvm = true;
545 t.use_lld = true; 598 t.use_lld = true;
@@ -552,7 +605,7 @@ pub fn build(b: *std.Build) void {
552 if (mod == server_mod or mod == quic_mod or mod == quic_server_mod or 605 if (mod == server_mod or mod == quic_mod or mod == quic_server_mod or
553 mod == exe_mod or mod == client_mod or mod == mux_mod or 606 mod == exe_mod or mod == client_mod or mod == mux_mod or
554 mod == quic_client_mod or mod == webhub_mod or 607 mod == quic_client_mod or mod == webhub_mod or
555 mod == webhub_main_mod) linkQuic(b, t, quic); 608 mod == muxa_mod or mod == webhub_main_mod) linkQuic(b, t, quic);
556 test_step.dependOn(&b.addRunArtifact(t).step); 609 test_step.dependOn(&b.addRunArtifact(t).step);
557 } 610 }
558 611
@@ -597,6 +650,19 @@ pub fn build(b: *std.Build) void {
597 const e2e_step = b.step("e2e", "Run end-to-end test"); 650 const e2e_step = b.step("e2e", "Run end-to-end test");
598 e2e_step.dependOn(&e2e.step); 651 e2e_step.dependOn(&e2e.step);
599 652
653 // The agent surface gets a step of its own rather than a place in e2e:
654 // its nine scenarios spend ~51s mostly waiting on real idle timeouts and
655 // a 20s quiet await, which is a cost the paint-and-convergence suite
656 // should not have to carry on every run. A step is what makes it a gate
657 // at all — M7's rule, paid for twice: an end-to-end property that only
658 // runs when somebody types the script is the shape a regression ships
659 // through.
660 const agent = b.addSystemCommand(&.{"test/agent.sh"});
661 agent.addArtifactArg(exe);
662 agent.addArtifactArg(muxa_exe);
663 const agent_step = b.step("agent", "Run the agent-surface end-to-end suite");
664 agent_step.dependOn(&agent.step);
665
600 const soak = b.addSystemCommand(&.{"test/soak.sh"}); 666 const soak = b.addSystemCommand(&.{"test/soak.sh"});
601 soak.addArtifactArg(exe); 667 soak.addArtifactArg(exe);
602 soak.addArtifactArg(mux_exe); 668 soak.addArtifactArg(mux_exe);
docs/decisions.md
Old New
@@ -3024,3 +3024,171 @@ Owed cheaply later, filed as observations not blockers: a
3024 handTarget() seam so mux_main's idle_ms threading gets a pin; 3024 handTarget() seam so mux_main's idle_ms threading gets a pin;
3025 verify.js's wall-attach sequence covers the shell's call set but the 3025 verify.js's wall-attach sequence covers the shell's call set but the
3026 page's boot stays browser-only (the banked headless item). 3026 page's boot stays browser-only (the banked headless item).
3027
3028
3029 ## 2026-08-13 (agent surface — native LLM integration)
3030
3031 Twelve tasks, shipped the same day as M17: OSC 133 command boundaries in
3032 the daemon, three new frame pairs, and `muxa`, a fourth binary that speaks
3033 JSON to an agent's shell tool. An agent now *knows* when a command
3034 returned, with its exit code and its output span, locally or over QUIC —
3035 the thing `tmux send-keys` + `capture-pane` structurally cannot say. The
3036 spec is `docs/superpowers/specs/2026-08-13-agent-surface-design.md`; what
3037 follows is what the implementation decided, which is not always what the
3038 spec guessed.
3039
3040 **Output spans are rows, not seqs — and rows are locators, not anchors.**
3041 The tracker's `seq` is a viewport delta generation: a whole command's
3042 output can share one, and a row loses its seq the moment it scrolls into
3043 history. So a mark records the absolute screen row (`historyRows() +
3044 cursor.y`) at mark time and output recovery is the existing
3045 `fetch_scrollback`, unchanged. The honesty is on the field, not in prose:
3046 `Engine.MarkEvent.row` says that pruning past `max_scrollback` shifts the
3047 origin (a command longer than the scrollback can leave `end_row <
3048 start_row`), that resize reflow renumbers history, and that alt-screen
3049 marks live in a coordinate space where `historyRows()` is 0. Point a human
3050 at output with it; never key durable state on it.
3051
3052 **An await is answered from a persisted snapshot, never from live tracker
3053 state.** Real shell integration emits `D;code` and `A` in one burst, so
3054 both fold in a single pty read and the phase is back to `at_prompt` before
3055 any await ever looks. A gate on `cmd.phase == .returned` therefore loses
3056 deterministically — it was written that way first, and the test that
3057 caught it drives a real shell rather than a hand-built mark sequence.
3058 `last_return` is the fix and the one owner: stamped at the `D`, carrying
3059 the exit code, the span AND the seq that qualifies it, so the answer and
3060 the reason it qualifies are the same record and no later reading of live
3061 state can drift out from under it. It also answers correctly once the
3062 *next* command is already running, which is the general rule worth
3063 keeping: **an await asks about a past event; live state describes now.**
3064
3065 **OSC 133 interception wraps the dependency's handler rather than patching
3066 it.** ghostty-vt already parses semantic prompts including the `err` code,
3067 but its stock handler drops the code and exposes no callback. `vt.Stream(H)`
3068 is generic over the handler, so `MuxHandler` intercepts `.semantic_prompt`
3069 and forwards every other action verbatim: terminal state stays identical,
3070 the pinned dep stays unmodified, and there is no byte-stream scanning
3071 anywhere. The engine's hardcoded `vt.TerminalStream` became
3072 `vt.Stream(MuxHandler)` and nothing else moved.
3073
3074 **Awaits resolve at the run loop's 100ms tick, with no deadline folding
3075 into poll.** The spec expected settle/timeout/pgid deadlines to fold into
3076 `wait_ms` the way QUIC's `timeoutMs` does. They do not need to: nothing in
3077 `checkAwaits` blocks, the granularity bound is the tick the daemon already
3078 beats at, and an agent's cheapest verb costs a round trip anyway. The pass
3079 sits after every arm that can move the session on — so it sees this pump's
3080 marks, pgid and silence — and *before* the QUIC `drainAll`, because a
3081 resolved await queues a frame and `drainAll` is what puts it on the wire.
3082 The other order costs every remote await a whole extra poll cycle.
3083
3084 **Session death is an answer on the lifecycle verbs and an error on the
3085 rest.** `muxa run` and `muxa await` print
3086 `{"reason":"session_ended","exit_code":N}` and exit 0 — a command that
3087 killed its own shell answered the question that was asked. `status`,
3088 `capture` and `send` have no answer to give, so they print the house's
3089 `{"error":…,"detail":…,"exit_code":…}` shape and exit 1. **Field note that
3090 cost a test:** `send`'s ack round-trip reliably WINS the race against a pty
3091 death, so `send` cannot be relied on to report a session that is dying —
3092 the next call is what sees it. That is a property of the ordering, not a
3093 flake.
3094
3095 **`--timeout` is the daemon's window; the client adds bounded grace.** The
3096 daemon's clock starts when it reads the request, so a client that waits
3097 exactly `--timeout` always loses the race to the reply and reports a
3098 timeout the daemon never had. The grace is `min(30s, max(2s, 4 ×
3099 connect_ms))`: flat 2s over a unix socket, RTT-derived over QUIC from the
3100 one measurement the client already has (its own handshake), capped so a
3101 handshake that took a minute cannot buy a minute of grace. The cost is
3102 named because agents budget wall clock: a `--timeout N` wait can overshoot
3103 N by the grace window.
3104
3105 **Reconnect is at-most-once for the wire and exactly-never for input.**
3106 One redial per process, on `ConnectionLost` and on nothing else (a
3107 `SendStalled` peer is still there and has already spent the flush bound
3108 proving it). What is re-sent is the attach and the `await_req` with the
3109 ORIGINAL `since_seq` — idempotent by construction, and the reason a return
3110 that landed inside the gap is answered instead of missed — and never
3111 `run`'s input. A command line lost with the connection surfaces as an
3112 honest timeout, not as `make deploy` running twice: **a wait may be
3113 repeated because asking twice changes nothing; an input may not, because it
3114 changes everything.** A redial that fails is recorded rather than
3115 swallowed, so the verb reports both halves — `connection lost; reconnect
3116 failed: <err>` — and a second tear after a spent redial says so in its own
3117 words.
3118
3119 **`settled` means output went quiet, not that the process exited.** The
3120 settle and pgid resolutions carry `exit_code: null` and a `phase` derived
3121 from marks, which on a markless shell reads `at_prompt` — the tracker
3122 never saw a `C`, so it is telling the truth about what it knows. Only
3123 `mechanism == "marks"` carries a trustworthy exit code, which is why every
3124 reply names its mechanism. An agent that reads `exit_code` without reading
3125 `mechanism` is reading a guess.
3126
3127 ### Field limitations, shipped knowingly
3128
3129 - **zsh under the `ZDOTDIR` shim never sources `~/.zshenv`.** zsh looks for
3130 `.zshenv` under `$ZDOTDIR`, and the shim directory has none. The `.zshrc`
3131 is handed back (the common case); a `.zshenv` shim that restores
3132 `ZDOTDIR` the way ghostty's does is the roadmap fix.
3133 - **The bash shim's DEBUG trap displaces a user's own DEBUG trap** —
3134 silently, and that is bash-preexec, atuin and iTerm2's integration. The
3135 roadmap fix is coexistence via bash-preexec detection. Two limits of the
3136 membership guard that keeps `PROMPT_COMMAND`'s own members from being
3137 counted as commands: a typed command textually identical to a
3138 `PROMPT_COMMAND` member loses its marks (degrading to pgid/settle, not to
3139 a wrong answer), and a compound string member (`a; b`) can re-arm the
3140 guard. Arrays — bash 5.1's default and what the shim prefers — are exact.
3141 - **A SIGKILLed daemon orphans its `mux-shellint-{pid}` directory.**
3142 Bounded and per-pid, cleaned on every ordinary exit; a startup sweep of
3143 dead-pid directories is the roadmap item. `prepare()` failing is degraded
3144 and never fatal: the session runs on pgid and settle, and says so on
3145 stderr, because refusing to start a daemon over an optional enhancement
3146 would invert the module's premise.
3147 - **`muxa` has no idle-timeout flag, by design.** It takes
3148 `quic.default_idle_ms` and lets `--timeout` be the only bound on a wait;
3149 `muxd`, `mux` and `muxweb` keep `--quic-idle-ms`.
3150
3151 ### Deferred, and named so the deferral reads as a choice
3152
3153 The spec's non-goals stand: **no MCP server** (a wrapper over `muxa` needs
3154 no protocol change, so it can be layered whenever someone wants it), **no
3155 read-only or capability-scoped auth** (one key is still full control, which
3156 is the same posture every other transport has), **no input attribution**
3157 (an attached human cannot tell which keystrokes were the agent's), **no
3158 semantic event subscriptions** beyond the `cmd_state` push, and no
3159 single-shot `muxa drive` (`send` + `await` + `capture` composes it). Added
3160 by the reviews: the `.zshenv` shim, bash-preexec coexistence, and the
3161 shim-directory startup sweep.
3162
3163 **`capture --diff-since` was promised and not built, and this is the
3164 record.** The spec names it three times — the verb list, the TUI-driving
3165 composition ("`send` + `status` + `capture --diff-since`"), and the testing
3166 plan — and execution dropped it with no code and no deferral written
3167 anywhere; the whole-branch review is the only reason it is written down
3168 now. `muxa capture` ships as the whole grid, `--vt` or plain. What a future
3169 one owes is more than the flag: `status_reply`'s `seq` carries the RETURN
3170 WATERMARK (`last_return`'s seq, 0 when nothing has returned this session),
3171 which is exactly what an await's `since_seq` wants and useless as the SEQ a
3172 diff quotes — after this milestone the live stream seq is on no reply an
3173 agent can read. So `--diff-since` needs a protocol field of its own, and
3174 must not be built by re-reading a field that already means something else.
3175
3176 **Two smaller narrowings against the spec, recorded rather than left to be
3177 rediscovered.** (1) The **fish** injection arm is unit-tested only:
3178 `prepare` writes `fish/vendor_conf.d` and prepends `XDG_DATA_DIRS`, and
3179 that is pinned, but no test anywhere runs fish — there is none on this box.
3180 zsh and bash are both driven live against a real session. (2) The spec's
3181 **version-skew test** — `muxa status` against an old-protocol daemon
3182 reports the structured error — was not written. The behaviour is real and
3183 not unpinned (a daemon that drops the frame and a daemon that never answers
3184 are the same wait and the same `{"error":"status: no reply"}`, which the
3185 dead-daemon paths do exercise), but the named fixture, an actual old
3186 binary, does not exist.
3187
3188 **Paid in a separate commit, and only after the e2e pinned the spellings:**
3189 `parseQuicAddr`/`resolveHost` now live in `quic.zig` as one owner for the
3190 dial-address grammar, taking an allocator (`client.zig`'s copy hardcoded
3191 `std.heap.page_allocator`, so the fold is also a fix). A human typing
3192 `mux quic://HOST:PORT` and an agent typing `muxa --quic HOST:PORT` were
3193 parsing the same grammar through two copies, which is how one flag becomes
3194 two dialects.
docs/roadmap.md
Old New
@@ -5,7 +5,8 @@ The forward view, one item per line, ranked. History and evidence live in
5 this file at each milestone close and whenever the queue reorders; the 5 this file at each milestone close and whenever the queue reorders; the
6 queue's order is set by the user, not by this file. 6 queue's order is set by the user, not by this file.
7 7
8 **Now:** M1–M15 complete; `v0.0.1-2` published as a Linux tarball; in 8 **Now:** M1–M15 and M17 complete, plus the agent surface; published as a
9 Linux tarball, tagged through `v0.0.1-5`; in
9 field trial on real VMs, and the trial is now producing the queue. 10 field trial on real VMs, and the trial is now producing the queue.
10 Trial feedback outranks everything below — what actually hurts in use is 11 Trial feedback outranks everything below — what actually hurts in use is
11 better data than any of this ranking, and the proof is that the three 12 better data than any of this ranking, and the proof is that the three
@@ -220,6 +221,69 @@ u64 overflow — 14 bytes could panic the hub). Banked follow-ups, led
220 by the headless browser-boot automation ("the page never ran" was the 221 by the headless browser-boot automation ("the page never ran" was the
221 milestone's defining defect), are listed there. 222 milestone's defining defect), are listed there.
222 223
224 ## Agent surface — native LLM integration — complete
225
226 Shipped 2026-08-13 (ac9373f..cbd7007 + close-out), twelve tasks. An agent
227 driving a mux session now *knows* when a command returned, with its exit
228 code and the rows its output occupies, over a unix socket or over QUIC —
229 which is the signal `tmux send-keys` + `capture-pane` structurally cannot
230 give and the reason this exists. What shipped: OSC 133 marks injected at
231 spawn (`shellint.zig` — zsh `ZDOTDIR` shim, bash `--init-file`, fish
232 `vendor_conf.d`), a mux-owned ghostty-vt stream handler that surfaces them
233 as row-stamped events, a pure command state machine (`cmd.zig`), three
234 frame pairs (`cmd_state`, `await_req`/`await_reply`,
235 `status_req`/`status_reply`) that ride `muxd proxy` untouched, server-held
236 awaits with a pgid edge and a settle floor under the marks path, and
237 `muxa` — a fourth binary, five verbs, one JSON object per verb, attaching
238 at 0×0 so an agent never claims the human's grid. e2e is `test/agent.sh`,
239 **9 scenarios**, including a QUIC tear healed mid-await with the command
240 proven to have run exactly once. The decisions worth reading before
241 touching it are in decisions.md: rows are locators and not anchors, an
242 await is answered from `last_return` and never from live tracker state,
243 and only `mechanism == "marks"` carries a real exit code.
244
245 **Field limitations, shipped knowingly** (each is a roadmap item, none is a
246 blocker):
247
248 - **zsh loses `~/.zshenv`** under the `ZDOTDIR` shim — the shim directory
249 has none. A ghostty-style `.zshenv` shim is the fix.
250 - **The bash shim's DEBUG trap displaces the user's**, silently: that is
251 bash-preexec, atuin and iTerm2. Coexistence via bash-preexec detection is
252 the fix. Two membership-guard limits stand meanwhile: a typed command
253 textually identical to a `PROMPT_COMMAND` member loses its marks
254 (degrading to pgid/settle), and a compound string member (`a; b`) can
255 re-arm the guard — arrays, the modern default, are exact.
256 - **A SIGKILLed daemon orphans its `mux-shellint-{pid}` directory**; a
257 startup sweep of dead-pid directories is owed. Injection failing is
258 always degraded and never fatal.
259 - **`--timeout N` can overshoot N** by the client's grace window (2s over a
260 socket, up to 30s over a slow QUIC handshake): an agent budgeting wall
261 clock must add it.
262 - **`muxa` has no idle-timeout flag**, deliberately — `--timeout` is the
263 only bound on a wait.
264
265 **Deferred, and named** (the spec's non-goals plus what review added): an
266 MCP server wrapping `muxa`; read-only or capability-scoped auth (one key =
267 full control still); input attribution; semantic event subscriptions
268 beyond the `cmd_state` push; `muxa drive` as single-shot sugar; the
269 `.zshenv` shim; bash-preexec coexistence; the shim-directory startup
270 sweep. The `parseQuicAddr`/`resolveHost` dedup that review endorsed is
271 **paid**, not deferred — one owner in `quic.zig`, landed after the e2e had
272 pinned the spellings.
273
274 **Dropped in execution, not decided — the one item here that nobody chose:**
275 `muxa capture --diff-since SEQ`. The spec promises it three times and no
276 code was written; the whole-branch review found it. A future one needs a
277 protocol field of its own for the live stream seq, because `status_reply`'s
278 seq now carries the return watermark — right for awaits, wrong for a diff
279 to quote (decisions.md). Two coverage narrowings ride with it: the **fish**
280 injection arm is unit-tested only (no fish on this box; zsh and bash are
281 driven live), and the spec's **version-skew fixture** was never built (the
282 behaviour is pinned through the dead-daemon paths, an old binary is not).
283 And `muxa capture` is **the one verb with no automated coverage at all** —
284 `test/agent.sh` drives run, await, send and status; capture is where
285 `--diff-since` would land, so the test and the flag are owed together.
286
223 ## M16 candidates — and still outranked by trial feedback 287 ## M16 candidates — and still outranked by trial feedback
224 288
225 What remains is what was ranked behind M13 and M14 and survived M15, 289 What remains is what was ranked behind M13 and M14 and survived M15,
docs/superpowers/plans/2026-08-13-agent-surface.md
Old New
@@ -0,0 +1,1953 @@
1 # Agent Surface Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5 **Goal:** Native LLM/agent integration for mux: OSC 133 command boundaries with exit codes, server-side await, structured status, and a standalone `muxa` binary speaking unix socket and QUIC.
6
7 **Architecture:** The daemon's ghostty-vt stream is wrapped in a mux-owned handler that intercepts semantic-prompt (OSC 133) actions and queues mark events (engine = mechanism). The server folds those events into a per-session command state machine, pushes `cmd_state`, and holds `await_req` open server-side with pgid/settle fallbacks (server = policy). Shell integration is injected at spawn because muxd forks the shell itself. `muxa` is a new thin client binary emitting JSON.
8
9 **Tech Stack:** Zig 0.15.2 (pinned: `~/Downloads/zig-x86_64-linux-0.15.2/zig`, LLVM+LLD — see docs/decisions.md), ghostty-vt vendored dep, existing `protocol.zig` frame style.
10
11 **Spec:** `docs/superpowers/specs/2026-08-13-agent-surface-design.md` — read it first.
12
13 > **Status (2026-08-13, all twelve tasks shipped):** this file is the
14 > historical execution record — what was planned and in what order — and it
15 > is deliberately not updated to match what landed. Where the two disagree,
16 > the code and `docs/decisions.md` are the truth. The divergences worth
17 > knowing before reading below: `returned_seq` was deleted in favour of one
18 > owner, `last_return`, which carries the watermark inside the answer;
19 > `checkAwaits` runs before the QUIC `drainAll` at the end of the pump, not
20 > after it; `muxa status` nests the cursor in its JSON; and the bash shim's
21 > final shape (array-aware `PROMPT_COMMAND`, membership guard, trap
22 > installed last) postdates the text in Task 7.
23
24 **Conventions for every task:**
25 - `ZIG=~/Downloads/zig-x86_64-linux-0.15.2/zig` — run from the worktree root (`.worktrees/agent-surface`).
26 - Full suite: `$ZIG build test`. It must pass before every commit.
27 - House doctrine: pinned regression tests go BEFORE any test the same hang could wedge (see MEMORY: a wedged zig test step prints nothing).
28 - Commit messages follow the repo style: lowercase type prefix, sentence explaining the why. Every commit ends with the `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` trailer.
29
30 ---
31
32 ## File structure (locked in)
33
34 | File | Responsibility |
35 |---|---|
36 | `src/protocol.zig` (modify) | New MsgTypes + `CmdState`/`AwaitReq`/`StatusReply` encode/decode. Wire format only. |
37 | `src/engine.zig` (modify) | `MuxHandler` wrapping the stock ghostty handler; queues `MarkEvent`s with rows. No policy. |
38 | `src/cmd.zig` (create) | Pure command state machine: MarkEvents in, transitions out. No I/O. |
39 | `src/pty.zig` (modify) | `fgPgid()` (TIOCGPGRP on the master) + optional child env pairs for injection. |
40 | `src/shellint.zig` (create) | Shell detection, shim/script file creation, env pairs. No daemon knowledge. |
41 | `src/server.zig` (modify) | Integration: drain mark events, stamp seq, push `cmd_state`, hold awaits, answer `status_req`. |
42 | `src/main.zig` (modify) | Injection wiring + `MUX_SHELL_INTEGRATION` opt-out for `muxd run`/`start`. |
43 | `src/muxa.zig` (create) | Standalone agent binary: verbs `status`/`capture`/`send`/`run`/`await`, JSON out, unix+QUIC. |
44 | `test/agent.sh` (create) | Binary-level e2e: shell session marks, TUI ephemeral session, QUIC. |
45 | `build.zig` (modify) | `muxa` executable + module wiring + `cmd`/`shellint` test steps. |
46
47 ---
48
49 ### Task 1: Protocol frames (`cmd_state`, `await_req`/`await_reply`, `status_req`/`status_reply`)
50
51 **Files:**
52 - Modify: `src/protocol.zig`
53
54 - [ ] **Step 1: Write the failing tests** — append to `src/protocol.zig`:
55
56 ```zig
57 test "cmd_state encode/decode round trip and golden bytes" {
58 const s = CmdState{
59 .phase = .returned,
60 .mechanism = .marks,
61 .exit_code = 1,
62 .start_row = 80,
63 .end_row = 92,
64 .seq = 258,
65 };
66 const buf = encodeCmdState(s);
67 try std.testing.expectEqualSlices(u8, &[_]u8{
68 2, // phase returned
69 0, // mechanism marks
70 1, // has_exit
71 1, // exit_code
72 0x50, 0, 0, 0, // start_row 80
73 0x5C, 0, 0, 0, // end_row 92
74 0x02, 0x01, 0, 0, 0, 0, 0, 0, // seq 258
75 }, &buf);
76 const back = try decodeCmdState(&buf);
77 try std.testing.expectEqual(s, back);
78 }
79
80 test "cmd_state with no exit code round-trips null, not zero" {
81 const s = CmdState{ .phase = .running, .mechanism = .pgid, .exit_code = null, .start_row = 5, .end_row = 0, .seq = 9 };
82 const back = try decodeCmdState(&encodeCmdState(s));
83 try std.testing.expectEqual(@as(?u8, null), back.exit_code);
84 }
85
86 test "cmd_state rejects wrong length and unknown enum bytes" {
87 try std.testing.expectError(error.BadPayload, decodeCmdState(&[_]u8{0} ** (cmd_state_len - 1)));
88 try std.testing.expectError(error.BadPayload, decodeCmdState(&[_]u8{0} ** (cmd_state_len + 1)));
89 var bad = encodeCmdState(.{ .phase = .at_prompt, .mechanism = .settle, .exit_code = null, .start_row = 0, .end_row = 0, .seq = 0 });
90 bad[0] = 9; // phase out of range
91 try std.testing.expectError(error.BadPayload, decodeCmdState(&bad));
92 bad[0] = 0;
93 bad[1] = 9; // mechanism out of range
94 try std.testing.expectError(error.BadPayload, decodeCmdState(&bad));
95 }
96
97 test "await_req encode/decode round trip" {
98 const r = try decodeAwaitReq(&encodeAwaitReq(.{ .since_seq = 77, .settle_ms = 500, .timeout_ms = 30_000 }));
99 try std.testing.expectEqual(@as(u64, 77), r.since_seq);
100 try std.testing.expectEqual(@as(u32, 500), r.settle_ms);
101 try std.testing.expectEqual(@as(u32, 30_000), r.timeout_ms);
102 try std.testing.expectError(error.BadPayload, decodeAwaitReq(&[_]u8{0} ** 15));
103 }
104
105 test "await_reply is a CmdState plus a reason byte" {
106 const s = CmdState{ .phase = .returned, .mechanism = .settle, .exit_code = null, .start_row = 0, .end_row = 3, .seq = 4 };
107 const buf = encodeAwaitReply(s, .settled);
108 try std.testing.expectEqual(@as(usize, await_reply_len), buf.len);
109 const back = try decodeAwaitReply(&buf);
110 try std.testing.expectEqual(AwaitReason.settled, back.reason);
111 try std.testing.expectEqual(s, back.state);
112 var bad = buf;
113 bad[cmd_state_len] = 9;
114 try std.testing.expectError(error.BadPayload, decodeAwaitReply(&bad));
115 }
116
117 test "status_reply encode/decode round trip" {
118 const s = StatusReply{
119 .cols = 120,
120 .rows = 40,
121 .cursor_x = 3,
122 .cursor_y = 5,
123 .history_rows = 77,
124 .alt_screen = true,
125 .mode = .{ .icanon = true, .echo = true },
126 .cmd = .{ .phase = .at_prompt, .mechanism = .marks, .exit_code = 0, .start_row = 1, .end_row = 2, .seq = 6 },
127 };
128 const back = try decodeStatusReply(&encodeStatusReply(s));
129 try std.testing.expectEqual(s, back);
130 try std.testing.expectError(error.BadPayload, decodeStatusReply(&[_]u8{0} ** (status_reply_len - 1)));
131 }
132 ```
133
134 - [ ] **Step 2: Run to verify failure**
135
136 Run: `$ZIG build test 2>&1 | tail -5`
137 Expected: compile error — `CmdState` not defined.
138
139 - [ ] **Step 3: Implement.** In the `MsgType` enum, add after `endpoint_req = 0x08`:
140
141 ```zig
142 await_req = 0x09, // payload: u64 LE since_seq, u32 LE settle_ms, u32 LE timeout_ms
143 status_req = 0x0a, // payload: empty
144 ```
145
146 and after `endpoint_reply = 0x89` (the 0x83 hole stays untouched — it is historical):
147
148 ```zig
149 cmd_state = 0x8a, // payload: CmdState (see encodeCmdState); pushed on marks-regime transitions
150 await_reply = 0x8b, // payload: CmdState ++ 1 byte AwaitReason
151 status_reply = 0x8c, // payload: StatusReply (see encodeStatusReply)
152 ```
153
154 Then add below `decodeEndpointReply` (house style: explicit encode/decode over fixed LE buffers):
155
156 ```zig
157 /// Where the command-boundary signal came from, weakest-last. `marks` is the
158 /// only mechanism that can carry an exit code; consumers must check it
159 /// before trusting one.
160 pub const Mechanism = enum(u8) { marks = 0, pgid = 1, settle = 2 };
161
162 pub const CmdPhase = enum(u8) { at_prompt = 0, running = 1, returned = 2 };
163
164 /// One snapshot of the session's command state machine. Rows are absolute
165 /// screen-space rows (0 = oldest retained history row) — meaningless while
166 /// the alt screen is active, and shifted once the scrollback ring prunes,
167 /// so spans should be fetched promptly. `seq` is the delta-tracker seq
168 /// stamped after the post-feed update (await ordering, nothing else).
169 pub const CmdState = struct {
170 phase: CmdPhase,
171 mechanism: Mechanism,
172 exit_code: ?u8,
173 start_row: u32,
174 end_row: u32,
175 seq: u64,
176 };
177
178 pub const cmd_state_len = 20;
179
180 pub fn encodeCmdState(s: CmdState) [cmd_state_len]u8 {
181 var buf: [cmd_state_len]u8 = undefined;
182 buf[0] = @intFromEnum(s.phase);
183 buf[1] = @intFromEnum(s.mechanism);
184 buf[2] = @intFromBool(s.exit_code != null);
185 buf[3] = s.exit_code orelse 0;
186 std.mem.writeInt(u32, buf[4..8], s.start_row, .little);
187 std.mem.writeInt(u32, buf[8..12], s.end_row, .little);
188 std.mem.writeInt(u64, buf[12..20], s.seq, .little);
189 return buf;
190 }
191
192 fn enumFromByte(comptime E: type, b: u8) !E {
193 return std.meta.intToEnum(E, b) catch error.BadPayload;
194 }
195
196 pub fn decodeCmdState(payload: []const u8) !CmdState {
197 if (payload.len != cmd_state_len) return error.BadPayload;
198 return .{
199 .phase = try enumFromByte(CmdPhase, payload[0]),
200 .mechanism = try enumFromByte(Mechanism, payload[1]),
201 .exit_code = if (payload[2] != 0) payload[3] else null,
202 .start_row = std.mem.readInt(u32, payload[4..8], .little),
203 .end_row = std.mem.readInt(u32, payload[8..12], .little),
204 .seq = std.mem.readInt(u64, payload[12..20], .little),
205 };
206 }
207
208 pub const AwaitReq = struct { since_seq: u64, settle_ms: u32, timeout_ms: u32 };
209
210 pub const await_req_len = 16;
211
212 pub fn encodeAwaitReq(r: AwaitReq) [await_req_len]u8 {
213 var buf: [await_req_len]u8 = undefined;
214 std.mem.writeInt(u64, buf[0..8], r.since_seq, .little);
215 std.mem.writeInt(u32, buf[8..12], r.settle_ms, .little);
216 std.mem.writeInt(u32, buf[12..16], r.timeout_ms, .little);
217 return buf;
218 }
219
220 pub fn decodeAwaitReq(payload: []const u8) !AwaitReq {
221 if (payload.len != await_req_len) return error.BadPayload;
222 return .{
223 .since_seq = std.mem.readInt(u64, payload[0..8], .little),
224 .settle_ms = std.mem.readInt(u32, payload[8..12], .little),
225 .timeout_ms = std.mem.readInt(u32, payload[12..16], .little),
226 };
227 }
228
229 pub const AwaitReason = enum(u8) { returned = 0, settled = 1, timeout = 2 };
230
231 pub const await_reply_len = cmd_state_len + 1;
232
233 pub fn encodeAwaitReply(s: CmdState, reason: AwaitReason) [await_reply_len]u8 {
234 var buf: [await_reply_len]u8 = undefined;
235 buf[0..cmd_state_len].* = encodeCmdState(s);
236 buf[cmd_state_len] = @intFromEnum(reason);
237 return buf;
238 }
239
240 pub const AwaitReply = struct { state: CmdState, reason: AwaitReason };
241
242 pub fn decodeAwaitReply(payload: []const u8) !AwaitReply {
243 if (payload.len != await_reply_len) return error.BadPayload;
244 return .{
245 .state = try decodeCmdState(payload[0..cmd_state_len]),
246 .reason = try enumFromByte(AwaitReason, payload[cmd_state_len]),
247 };
248 }
249
250 /// One structured snapshot for `muxa status`. The grid facts a driving
251 /// agent needs before deciding how to interact: size, cursor, whether a
252 /// TUI holds the screen, who echoes keystrokes, and the command state.
253 pub const StatusReply = struct {
254 cols: u16,
255 rows: u16,
256 cursor_x: u16,
257 cursor_y: u16,
258 history_rows: u32,
259 alt_screen: bool,
260 mode: PtyModeFlags,
261 cmd: CmdState,
262 };
263
264 pub const status_reply_len = 14 + cmd_state_len;
265
266 pub fn encodeStatusReply(s: StatusReply) [status_reply_len]u8 {
267 var buf: [status_reply_len]u8 = undefined;
268 std.mem.writeInt(u16, buf[0..2], s.cols, .little);
269 std.mem.writeInt(u16, buf[2..4], s.rows, .little);
270 std.mem.writeInt(u16, buf[4..6], s.cursor_x, .little);
271 std.mem.writeInt(u16, buf[6..8], s.cursor_y, .little);
272 std.mem.writeInt(u32, buf[8..12], s.history_rows, .little);
273 buf[12] = @intFromBool(s.alt_screen);
274 buf[13] = @bitCast(s.mode);
275 buf[14..].* = encodeCmdState(s.cmd);
276 return buf;
277 }
278
279 pub fn decodeStatusReply(payload: []const u8) !StatusReply {
280 if (payload.len != status_reply_len) return error.BadPayload;
281 return .{
282 .cols = std.mem.readInt(u16, payload[0..2], .little),
283 .rows = std.mem.readInt(u16, payload[2..4], .little),
284 .cursor_x = std.mem.readInt(u16, payload[4..6], .little),
285 .cursor_y = std.mem.readInt(u16, payload[6..8], .little),
286 .history_rows = std.mem.readInt(u32, payload[8..12], .little),
287 .alt_screen = payload[12] != 0,
288 .mode = try decodePtyMode(payload[13..14]),
289 .cmd = try decodeCmdState(payload[14..]),
290 };
291 }
292 ```
293
294 Note: `decodePtyMode` takes a slice; passing `payload[13..14]` reuses its length check. If the compiler wants `payload[14..]` as `*const [cmd_state_len]u8` for `decodeCmdState`, pass `payload[14..][0..cmd_state_len]`.
295
296 - [ ] **Step 4: Run tests**
297
298 Run: `$ZIG build test 2>&1 | tail -5`
299 Expected: PASS (exit 0, no failures reported).
300
301 - [ ] **Step 5: Commit**
302
303 ```bash
304 git add src/protocol.zig
305 git commit -m "feat(protocol): cmd_state, await, and status frames for the agent surface"
306 ```
307
308 ---
309
310 ### Task 2: `Pty.fgPgid` — the kernel's foreground answer
311
312 **Files:**
313 - Modify: `src/pty.zig`
314
315 - [ ] **Step 1: Write the failing test** — append to `src/pty.zig`:
316
317 ```zig
318 test "Pty: fgPgid tracks the foreground job" {
319 const alloc = std.testing.allocator;
320 var pty = try Pty.spawn(.{ .cols = 80, .rows = 24, .shell = "/bin/sh" });
321 defer pty.deinit();
322
323 // Prove the shell is up before asking anything of the pgid.
324 _ = try std.posix.write(pty.master, "printf 'ready-%s\\n' PGID\n");
325 var ready = try readUntil(alloc, &pty, "ready-PGID", 5000);
326 defer ready.deinit(alloc);
327 try std.testing.expect(std.mem.indexOf(u8, ready.items, "ready-PGID") != null);
328
329 // At the prompt, the foreground pgid is the shell's own process group.
330 // sh is the session leader post-forkpty, so its pgid == its pid.
331 try std.testing.expectEqual(pty.child, try pty.fgPgid());
332
333 // A foreground job moves the fg pgid off the shell... eventually: an
334 // interactive sh creates a new process group for the job. Poll for the
335 // change rather than racing it.
336 _ = try std.posix.write(pty.master, "sleep 2\n");
337 var moved = false;
338 var waited_ms: u64 = 0;
339 while (waited_ms < 3000) : (waited_ms += 50) {
340 if (try pty.fgPgid() != pty.child) {
341 moved = true;
342 break;
343 }
344 std.Thread.sleep(50 * std.time.ns_per_ms);
345 }
346 // Dash and busybox sh run foreground jobs in the shell's own group when
347 // job control is off (non-interactive stdin heuristics differ), so a
348 // never-moved pgid is a legal outcome for the fallback design — but on
349 // a pty, POSIX shells enable job control. Assert movement; if this
350 // flakes on some /bin/sh, relax to a log + skip, not a green lie.
351 try std.testing.expect(moved);
352
353 // ...and returns to the shell when the job ends.
354 waited_ms = 0;
355 while (waited_ms < 5000) : (waited_ms += 100) {
356 if (try pty.fgPgid() == pty.child) break;
357 std.Thread.sleep(100 * std.time.ns_per_ms);
358 }
359 try std.testing.expectEqual(pty.child, try pty.fgPgid());
360 }
361 ```
362
363 - [ ] **Step 2: Run to verify failure**
364
365 Run: `$ZIG build test 2>&1 | tail -5`
366 Expected: compile error — `fgPgid` not defined.
367
368 - [ ] **Step 3: Implement** — add to `Pty` after `mode()`:
369
370 ```zig
371 /// The foreground process group of the session, read off the master
372 /// with TIOCGPGRP. When it equals `child` (the shell, session leader
373 /// post-forkpty), no foreground job is running — the kernel's own
374 /// "the command returned", available with zero shell cooperation.
375 /// No exit code and no output span; that is what marks are for.
376 pub fn fgPgid(self: *const Pty) !std.posix.pid_t {
377 var pgid: c.pid_t = 0;
378 if (c.ioctl(self.master, c.TIOCGPGRP, &pgid) < 0) return error.IoctlFailed;
379 return @intCast(pgid);
380 }
381 ```
382
383 (`c` already cImports `sys/ioctl.h`; TIOCGPGRP comes with it via termios/ioctls. If the compile misses `c.pid_t`, use `c_int` for the local.)
384
385 - [ ] **Step 4: Run tests**
386
387 Run: `$ZIG build test 2>&1 | tail -5`
388 Expected: PASS.
389
390 - [ ] **Step 5: Commit**
391
392 ```bash
393 git add src/pty.zig
394 git commit -m "feat(pty): fgPgid reads the foreground job off the master"
395 ```
396
397 ---
398
399 ### Task 3: Engine — `MuxHandler` wrapper, mark events with rows
400
401 **Files:**
402 - Modify: `src/engine.zig`
403
404 Background (verified against the vendored dep, `~/.cache/zig/p/ghostty-1.3.2-dev-5UdBC7VOBgVv0iA*/src/terminal/`): ghostty-vt parses OSC 133 into a `semantic_prompt` action whose value has `.action` (enum incl. `fresh_line_new_prompt`='A', `end_input_start_output`='C', `end_command`='D') and `readOption(.exit_code)` → `?i32`. The stock `Handler.vt` forwards it to `terminal.semanticPrompt` and drops the code. `vt.Stream(H)` is generic over the handler (exported as `vt.Stream`); the handler needs `deinit` and a `vt(comptime action, value)` method.
405
406 - [ ] **Step 1: Write the failing tests** — append to `src/engine.zig`:
407
408 ```zig
409 test "Engine: OSC 133 marks surface as events with rows and exit codes" {
410 const alloc = std.testing.allocator;
411 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
412 defer e.deinit();
413
414 e.feed("$ "); // a prompt on row 0
415 e.feed("\x1b]133;C\x07"); // command starts
416 e.feed("output line\r\n");
417 e.feed("\x1b]133;D;1\x07"); // command returns, exit 1
418 e.feed("\x1b]133;A\x07"); // next prompt
419
420 const evs = e.markEvents();
421 try std.testing.expectEqual(@as(usize, 3), evs.len);
422
423 try std.testing.expectEqual(Engine.MarkEvent.Kind.command_start, evs[0].kind);
424 try std.testing.expectEqual(@as(u32, 0), evs[0].row);
425 try std.testing.expectEqual(@as(?u8, null), evs[0].exit_code);
426
427 try std.testing.expectEqual(Engine.MarkEvent.Kind.command_end, evs[1].kind);
428 try std.testing.expectEqual(@as(u32, 1), evs[1].row); // cursor moved past the output line
429 try std.testing.expectEqual(@as(?u8, 1), evs[1].exit_code);
430
431 try std.testing.expectEqual(Engine.MarkEvent.Kind.prompt_start, evs[2].kind);
432
433 e.clearMarkEvents();
434 try std.testing.expectEqual(@as(usize, 0), e.markEvents().len);
435 }
436
437 test "Engine: mark rows are absolute screen rows, not viewport rows" {
438 const alloc = std.testing.allocator;
439 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
440 defer e.deinit();
441
442 // Scroll 100 lines into history, then mark: the row must include them.
443 var i: usize = 1;
444 while (i <= 100) : (i += 1) {
445 var line: [32]u8 = undefined;
446 e.feed(std.fmt.bufPrint(&line, "line-{d}\r\n", .{i}) catch unreachable);
447 }
448 const hist = e.historyRows(); // 77 per the historyRows test
449 e.feed("\x1b]133;C\x07");
450 const evs = e.markEvents();
451 try std.testing.expectEqual(@as(usize, 1), evs.len);
452 try std.testing.expectEqual(hist + e.cursorPos().y, evs[0].row);
453 }
454
455 test "Engine: a D mark with no exit code yields a null code" {
456 const alloc = std.testing.allocator;
457 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
458 defer e.deinit();
459 e.feed("\x1b]133;C\x07\x1b]133;D\x07");
460 const evs = e.markEvents();
461 try std.testing.expectEqual(@as(usize, 2), evs.len);
462 try std.testing.expectEqual(@as(?u8, null), evs[1].exit_code);
463 }
464
465 test "Engine: non-133 OSC and the ignored 133 subcommands emit no events" {
466 const alloc = std.testing.allocator;
467 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
468 defer e.deinit();
469 e.feed("\x1b]0;a title\x07"); // OSC 0, not semantic
470 e.feed("\x1b]133;B\x07\x1b]133;P\x07\x1b]133;L\x07"); // B/P/L: not ours
471 try std.testing.expectEqual(@as(usize, 0), e.markEvents().len);
472 }
473 ```
474
475 - [ ] **Step 2: Run to verify failure**
476
477 Run: `$ZIG build test 2>&1 | tail -5`
478 Expected: compile error — `markEvents` not defined.
479
480 - [ ] **Step 3: Implement.** In `src/engine.zig`:
481
482 Replace the `stream: vt.TerminalStream` field declaration with:
483
484 ```zig
485 stream: MuxStream,
486 ```
487
488 Add above the `Engine` struct:
489
490 ```zig
491 /// The stock ghostty-vt handler forwards OSC 133 into the terminal and
492 /// drops the exit code on the floor; there is no semantic-prompt callback
493 /// in its Effects. So mux brings its own handler: intercept the one action
494 /// we care about, forward everything (including that one) to the stock
495 /// handler so terminal state stays identical.
496 pub const MuxHandler = struct {
497 inner: vt.TerminalStream.Handler,
498
499 pub fn deinit(self: *MuxHandler) void {
500 self.inner.deinit();
501 }
502
503 pub fn vt(
504 self: *MuxHandler,
505 comptime action: vt.StreamAction.Tag,
506 value: vt.StreamAction.Value(action),
507 ) void {
508 if (comptime action == .semantic_prompt) self.onSemanticPrompt(value);
509 self.inner.vt(action, value);
510 }
511
512 fn engineOf(self: *MuxHandler) *Engine {
513 const stream_ptr: *MuxStream = @fieldParentPtr("handler", self);
514 return @alignCast(@fieldParentPtr("stream", stream_ptr));
515 }
516
517 fn onSemanticPrompt(self: *MuxHandler, value: anytype) void {
518 const kind: Engine.MarkEvent.Kind = switch (value.action) {
519 .fresh_line_new_prompt => .prompt_start, // 'A'
520 .end_input_start_output => .command_start, // 'C'
521 .end_command => .command_end, // 'D'
522 else => return, // L/N/P/B/I: prompt furniture, not boundaries
523 };
524 const eng = self.engineOf();
525 const exit_code: ?u8 = if (kind == .command_end)
526 if (value.readOption(.exit_code)) |code|
527 @intCast(@as(u32, @bitCast(code)) & 0xff)
528 else
529 null
530 else
531 null;
532 eng.mark_events.append(eng.alloc, .{
533 .kind = kind,
534 .row = eng.historyRows() + eng.cursorPos().y,
535 .exit_code = exit_code,
536 }) catch {};
537 }
538 };
539
540 pub const MuxStream = vt.Stream(MuxHandler);
541 ```
542
543 In `Engine`, add the field and the event type:
544
545 ```zig
546 /// OSC 133 mark events observed since the last clear. Drained by the
547 /// server after each feed, exactly like pty_out.
548 mark_events: std.ArrayList(MarkEvent),
549
550 pub const MarkEvent = struct {
551 pub const Kind = enum(u8) { prompt_start, command_start, command_end };
552 kind: Kind,
553 /// Absolute screen-space row (historyRows + cursor.y) at mark time.
554 row: u32,
555 /// Only ever set on command_end, and only when the mark carried one.
556 exit_code: ?u8,
557 };
558 ```
559
560 In `init`, add `.mark_events = .empty,` to the struct literal, and replace the two stream lines with:
561
562 ```zig
563 self.stream = .initAlloc(alloc, .{ .inner = .{ .terminal = &self.term } });
564 self.stream.handler.inner.effects.write_pty = &onWritePty;
565 ```
566
567 In `deinit`, add `self.mark_events.deinit(self.alloc);` alongside `pty_out`.
568
569 Add the accessors next to `ptyOutput`/`clearPtyOutput`:
570
571 ```zig
572 pub fn markEvents(self: *const Engine) []const MarkEvent {
573 return self.mark_events.items;
574 }
575
576 pub fn clearMarkEvents(self: *Engine) void {
577 self.mark_events.clearRetainingCapacity();
578 }
579 ```
580
581 Update `onWritePty` — the callback now receives the inner handler:
582
583 ```zig
584 fn onWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void {
585 const mh: *MuxHandler = @fieldParentPtr("inner", handler);
586 const stream_ptr: *MuxStream = @fieldParentPtr("handler", mh);
587 // @alignCast for wasm32 — same reasoning as before this change.
588 const self: *Engine = @alignCast(@fieldParentPtr("stream", stream_ptr));
589 self.pty_out.appendSlice(self.alloc, data) catch {};
590 }
591 ```
592
593 Compile notes for the executor: (a) `vt.StreamAction` is exported by the dep's `lib_vt.zig`; if `StreamAction.Tag`/`Value` names differ, mirror the stock handler's signature in `stream_terminal.zig` (`Action.Tag`, `Action.Value(action)`) via `vt.TerminalStream.Action`. (b) The exit code option is `i32`; shells send 0–255 and 128+signal, so the `& 0xff` clamp matches the existing `exit_status` frame's clamp. (c) The wasm build (`src/wasm_core.zig`) links the same engine module — run `$ZIG build` (not just test) to catch a wasm-side breakage; the reviewer's alignment note in `onWritePty` is exactly the wasm case.
594
595 - [ ] **Step 4: Run tests and full build**
596
597 Run: `$ZIG build test 2>&1 | tail -5 && $ZIG build 2>&1 | tail -3`
598 Expected: both PASS (the second builds the wasm core too).
599
600 - [ ] **Step 5: Commit**
601
602 ```bash
603 git add src/engine.zig
604 git commit -m "feat(engine): mux-owned stream handler surfaces OSC 133 marks as row-stamped events"
605 ```
606
607 ---
608
609 ### Task 4: `src/cmd.zig` — the pure command state machine
610
611 **Files:**
612 - Create: `src/cmd.zig`
613 - Modify: `build.zig` (module + test wiring)
614
615 - [ ] **Step 1: Write the file with failing tests.** Create `src/cmd.zig`:
616
617 ```zig
618 //! The session's command state machine: MarkEvents in, transitions out.
619 //! Pure — no I/O, no clock, no seq. The server stamps seqs and decides who
620 //! hears about a transition; this module only decides what the marks mean.
621 //! Trust rule (spec): a D only counts if it closes a seen C; stray marks
622 //! reset to at_prompt rather than being believed.
623 const std = @import("std");
624 const proto = @import("protocol");
625 const Engine = @import("engine").Engine;
626
627 pub const Tracker = struct {
628 phase: proto.CmdPhase = .at_prompt,
629 /// Sticky: once any C has been seen, this session speaks marks and the
630 /// pgid fallback stops being consulted while a command is open.
631 marks_seen: bool = false,
632 start_row: u32 = 0,
633 end_row: u32 = 0,
634 exit_code: ?u8 = null,
635
636 pub const Transition = enum { running, returned, reset };
637
638 pub fn apply(self: *Tracker, ev: Engine.MarkEvent) ?Transition {
639 switch (ev.kind) {
640 .command_start => {
641 self.marks_seen = true;
642 self.phase = .running;
643 self.start_row = ev.row;
644 self.exit_code = null;
645 return .running;
646 },
647 .command_end => {
648 if (self.phase != .running) {
649 // A D with no open C: a nested program echoing marks it
650 // has no business emitting. Reset, believe nothing.
651 self.phase = .at_prompt;
652 return .reset;
653 }
654 self.phase = .returned;
655 self.end_row = ev.row;
656 self.exit_code = ev.exit_code;
657 return .returned;
658 },
659 .prompt_start => {
660 // 'A' after a return is the prompt redrawing: back to rest.
661 // 'A' mid-run (Ctrl-C redraw) also lands here — the shell
662 // is telling us the command is over even without a D.
663 if (self.phase == .running) {
664 self.phase = .returned;
665 self.end_row = ev.row;
666 self.exit_code = null; // interrupted: no honest code
667 return .returned;
668 }
669 self.phase = .at_prompt;
670 return null;
671 },
672 }
673 }
674
675 /// True while marks say a command is open — the window in which the
676 /// pgid fallback must NOT race the marks to a verdict.
677 pub fn marksOpen(self: *const Tracker) bool {
678 return self.marks_seen and self.phase == .running;
679 }
680 };
681
682 test "C then D is running then returned, with rows and code" {
683 var t = Tracker{};
684 try std.testing.expectEqual(@as(?Tracker.Transition, .running), t.apply(.{ .kind = .command_start, .row = 10, .exit_code = null }));
685 try std.testing.expectEqual(proto.CmdPhase.running, t.phase);
686 try std.testing.expectEqual(@as(?Tracker.Transition, .returned), t.apply(.{ .kind = .command_end, .row = 14, .exit_code = 1 }));
687 try std.testing.expectEqual(proto.CmdPhase.returned, t.phase);
688 try std.testing.expectEqual(@as(u32, 10), t.start_row);
689 try std.testing.expectEqual(@as(u32, 14), t.end_row);
690 try std.testing.expectEqual(@as(?u8, 1), t.exit_code);
691 }
692
693 test "a stray D resets and is not believed" {
694 var t = Tracker{};
695 try std.testing.expectEqual(@as(?Tracker.Transition, .reset), t.apply(.{ .kind = .command_end, .row = 3, .exit_code = 0 }));
696 try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase);
697 try std.testing.expectEqual(@as(?u8, null), t.exit_code);
698 }
699
700 test "A closes an open command without a code (Ctrl-C at a prompt redraw)" {
701 var t = Tracker{};
702 _ = t.apply(.{ .kind = .command_start, .row = 5, .exit_code = null });
703 try std.testing.expectEqual(@as(?Tracker.Transition, .returned), t.apply(.{ .kind = .prompt_start, .row = 6, .exit_code = null }));
704 try std.testing.expectEqual(@as(?u8, null), t.exit_code);
705 try std.testing.expectEqual(proto.CmdPhase.returned, t.phase);
706 // The next A settles back to rest with no transition.
707 try std.testing.expectEqual(@as(?Tracker.Transition, null), t.apply(.{ .kind = .prompt_start, .row = 6, .exit_code = null }));
708 try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase);
709 }
710
711 test "marksOpen guards the pgid race window" {
712 var t = Tracker{};
713 try std.testing.expect(!t.marksOpen());
714 _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null });
715 try std.testing.expect(t.marksOpen());
716 _ = t.apply(.{ .kind = .command_end, .row = 1, .exit_code = 0 });
717 try std.testing.expect(!t.marksOpen());
718 // Sticky across the next prompt: the session still speaks marks.
719 _ = t.apply(.{ .kind = .prompt_start, .row = 1, .exit_code = null });
720 try std.testing.expect(t.marks_seen);
721 }
722
723 test "back-to-back commands: second C reopens cleanly" {
724 var t = Tracker{};
725 _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null });
726 _ = t.apply(.{ .kind = .command_end, .row = 2, .exit_code = 0 });
727 try std.testing.expectEqual(@as(?Tracker.Transition, .running), t.apply(.{ .kind = .command_start, .row = 3, .exit_code = null }));
728 try std.testing.expectEqual(@as(u32, 3), t.start_row);
729 try std.testing.expectEqual(@as(?u8, null), t.exit_code);
730 }
731 ```
732
733 - [ ] **Step 2: Wire the module in `build.zig`.** Find where `delta_mod` is created (grep `delta_mod`) and mirror it: create `cmd_mod` from `src/cmd.zig` with imports `protocol` and `engine`; add `exe_mod.addImport("cmd", cmd_mod);` next to the server's other imports (the server module imports it in Task 5 — adding the import now is harmless); add a test step for it exactly where `delta` tests are registered (grep for the existing `addTest`/test-step pattern and copy it; ORDER: put the cmd test registration BEFORE the server/e2e tests per house doctrine — a wedged later test must not hide this one).
734
735 - [ ] **Step 3: Run tests**
736
737 Run: `$ZIG build test 2>&1 | tail -5`
738 Expected: PASS, with the new cmd tests included.
739
740 - [ ] **Step 4: Commit**
741
742 ```bash
743 git add src/cmd.zig build.zig
744 git commit -m "feat(cmd): pure command state machine over OSC 133 mark events"
745 ```
746
747 ---
748
749 ### Task 5: Server integration — drain events, stamp seq, push `cmd_state`, answer `status_req`
750
751 **Files:**
752 - Modify: `src/server.zig`
753
754 The server's existing test harness (in-file tests around lines 2300–4400 drive `pumpOnce` with fake clients over socketpairs — read two of them first and copy their setup pattern exactly).
755
756 - [ ] **Step 1: Write failing tests** in `src/server.zig`'s test section, using the harness pattern found there (a started `Server` on a temp sock path, a connected client fd that attaches, `srv.pumpOnce(0)` driven in a loop). The behaviors to pin:
757
758 ```zig
759 // Test A: feeding marks through the pty produces a cmd_state push.
760 // - write "\x1b]133;C\x07" then "\x1b]133;D;0\x07" into the pty slave side
761 // (the harness's existing way of making session output — grep for how
762 // existing tests inject pty bytes; several do it via the spawned shell,
763 // the cleanest is the harness that uses /bin/cat as the shell and writes
764 // through the master's slave pair).
765 // - pump until the attached client's fd yields a frame of type .cmd_state
766 // with phase running, then one with phase returned and exit_code 0.
767 // - assert the returned frame's seq is > the running frame's seq is NOT
768 // required (same feed can share a seq); assert returned.seq >= running.seq
769 // and returned.seq <= srv.tracker.seq (stamped after the post-feed update).
770
771 // Test B: status_req round-trips on an attached client AND on an observer.
772 // - client sends .status_req, reads .status_reply; decode; assert cols/rows
773 // match the attach size, alt_screen false, cmd.phase at_prompt initially.
774 // - a fresh un-attached connection (observer) sends .status_req and gets a
775 // .status_reply via blocking writeFrame, same as stats_req does.
776 ```
777
778 Write these as real Zig tests by copying the harness helpers the neighboring tests use (the executor has the file in front of them; the helpers already exist — do not invent new scaffolding).
779
780 - [ ] **Step 2: Run to verify failure**
781
782 Run: `$ZIG build test 2>&1 | tail -5`
783 Expected: FAIL — no `.cmd_state` frame arrives / `.status_req` is ignored (falls into `else => {}`).
784
785 - [ ] **Step 3: Implement.** In `src/server.zig`:
786
787 Add imports/fields:
788
789 ```zig
790 const cmdmod = @import("cmd");
791 ```
792
793 In `Server` struct, next to `tracker`:
794
795 ```zig
796 /// The session's command state machine (OSC 133). Seq-stamped copies of
797 /// its transitions are what cmd_state/await_reply/status_reply carry.
798 cmd: cmdmod.Tracker = .{},
799 /// tracker.seq at the moment of the last `returned` transition —
800 /// "a return happened at or before this seq". Awaits compare their
801 /// since_seq against this.
802 returned_seq: u64 = 0,
803 ```
804
805 In `pumpOnce`, immediately after the `self.sendUpdate();` line inside the pty-read arm, add:
806
807 ```zig
808 self.drainMarkEvents();
809 ```
810
811 Add the method (near `sendUpdate`):
812
813 ```zig
814 /// Fold the engine's OSC 133 events into the command tracker and tell
815 /// attached clients about transitions. Runs after sendUpdate so
816 /// tracker.seq already covers the same pty chunk — the off-by-one the
817 /// spec pins (seq sampled post-feed).
818 fn drainMarkEvents(self: *Server) void {
819 for (self.eng.markEvents()) |ev| {
820 const tr = self.cmd.apply(ev) orelse continue;
821 if (tr == .returned) self.returned_seq = self.tracker.seq;
822 if (tr == .reset) continue; // believed nothing, tell no one
823 const payload = proto.encodeCmdState(self.cmdState(.marks));
824 for (0..max_clients) |i| {
825 _ = self.queueFrame(i, .cmd_state, &payload);
826 }
827 }
828 self.eng.clearMarkEvents();
829 }
830
831 /// The current command state as a wire struct. `mechanism` is the
832 /// caller's claim about how the verdict was reached: marks pushes say
833 /// .marks; await resolutions say what actually resolved them.
834 fn cmdState(self: *Server, mechanism: proto.Mechanism) proto.CmdState {
835 return .{
836 .phase = self.cmd.phase,
837 .mechanism = mechanism,
838 .exit_code = self.cmd.exit_code,
839 .start_row = self.cmd.start_row,
840 .end_row = self.cmd.end_row,
841 .seq = self.returned_seq,
842 };
843 }
844
845 fn buildStatusReply(self: *Server) proto.StatusReply {
846 const cur = self.eng.cursorPos();
847 return .{
848 .cols = self.colsNow(),
849 .rows = self.rowsNow(),
850 .cursor_x = cur.x,
851 .cursor_y = cur.y,
852 .history_rows = self.eng.historyRows(),
853 .alt_screen = self.eng.onAltScreen(),
854 .mode = self.readPtyMode() orelse .{ .icanon = true, .echo = true },
855 .cmd = self.cmdState(if (self.cmd.marks_seen) .marks else .pgid),
856 };
857 }
858 ```
859
860 In `handleFrame`'s switch (attached clients), add before `else => {}`:
861
862 ```zig
863 .status_req => {
864 const payload = proto.encodeStatusReply(self.buildStatusReply());
865 _ = self.queueFrame(i, .status_reply, &payload);
866 },
867 ```
868
869 In `serviceObserver`'s switch, add before `else => {}` (blocking reply, same rationale as the stats/endpoint observer arms):
870
871 ```zig
872 .status_req => {
873 const payload = proto.encodeStatusReply(self.buildStatusReply());
874 proto.writeFrame(fd, .status_reply, &payload) catch self.dropObserver(i);
875 },
876 ```
877
878 - [ ] **Step 4: Run tests**
879
880 Run: `$ZIG build test 2>&1 | tail -5`
881 Expected: PASS.
882
883 - [ ] **Step 5: Commit**
884
885 ```bash
886 git add src/server.zig
887 git commit -m "feat(server): command tracker integration — cmd_state pushes and status_req"
888 ```
889
890 ---
891
892 ### Task 6: Server awaits — `await_req` held open with marks/pgid/settle resolution
893
894 **Files:**
895 - Modify: `src/server.zig`
896
897 - [ ] **Step 1: Write failing tests** (same harness pattern as Task 5):
898
899 ```zig
900 // Test A (marks): client attaches, sends .await_req{since_seq = srv.tracker.seq,
901 // settle_ms = 0, timeout_ms = 5000}. Pump. No reply yet. Feed
902 // "\x1b]133;C\x07out\r\n\x1b]133;D;3\x07" through the pty. Pump until the
903 // client reads .await_reply; decode; assert reason .returned, exit_code 3,
904 // mechanism .marks.
905
906 // Test B (immediate answer): after Test A's return, a second .await_req with
907 // since_seq = 0 (older than returned_seq) is answered on the SAME pump —
908 // the reconnect-idempotency contract.
909
910 // Test C (settle): with a shell that emits no marks (/bin/cat harness),
911 // send .await_req{since_seq = current, settle_ms = 200, timeout_ms = 5000};
912 // write "quiet\r\n" into the pty; pump for ~600ms of wall time; assert
913 // .await_reply arrives with reason .settled, mechanism .settle, null exit.
914
915 // Test D (timeout): .await_req{settle_ms = 0, timeout_ms = 150} against a
916 // silent session; pump ~500ms; assert reason .timeout.
917
918 // Test E (pgid): /bin/sh harness without integration. Send input "sleep 1\n"
919 // via .input, then .await_req{since_seq = current, settle_ms = 0,
920 // timeout_ms = 10_000}. Pump; assert .await_reply reason .returned,
921 // mechanism .pgid, exit_code null, within ~3s.
922 ```
923
924 - [ ] **Step 2: Run to verify failure**
925
926 Run: `$ZIG build test 2>&1 | tail -5`
927 Expected: FAIL — await_req falls into `else => {}`, no reply ever comes (tests bound their pumping, so they fail rather than hang).
928
929 - [ ] **Step 3: Implement.** In `ClientSlot`, add:
930
931 ```zig
932 /// An await_req held open. At most one per client: a second one
933 /// replaces the first (the client is a serial CLI; queueing two would
934 /// be inventing a use case).
935 await_state: ?AwaitState = null,
936 ```
937
938 Add near `ClientSlot`:
939
940 ```zig
941 const AwaitState = struct {
942 since_seq: u64,
943 settle_ms: u32,
944 timeout_ms: u32,
945 /// milliTimestamp at acceptance; timeout measures from here.
946 started_ms: i64,
947 /// pgid fallback edge detector: set once the fg pgid has been seen off
948 /// the shell, so "back on the shell" means returned, not never-left.
949 saw_busy: bool = false,
950 };
951 ```
952
953 In `Server`, add a field (next to `mode_sent`):
954
955 ```zig
956 /// milliTimestamp of the last byte the pty produced; the settle floor.
957 last_pty_ms: i64 = 0,
958 ```
959
960 In `pumpOnce`, in the pty-read arm (where `n > 0`), add `self.last_pty_ms = std.time.milliTimestamp();` before `self.eng.feed(...)`. At the end of `pumpOnce`, just before `return null;`, add `self.checkAwaits();`.
961
962 In `handleFrame`, add:
963
964 ```zig
965 .await_req => {
966 const req = proto.decodeAwaitReq(frame.payload) catch return;
967 if (self.clients[i] == null) return;
968 self.clients[i].?.await_state = .{
969 .since_seq = req.since_seq,
970 .settle_ms = req.settle_ms,
971 .timeout_ms = req.timeout_ms,
972 .started_ms = std.time.milliTimestamp(),
973 };
974 // A return that already happened answers immediately —
975 // this is what makes a reconnect re-issue safe.
976 self.checkAwaits();
977 },
978 ```
979
980 Add the resolution method:
981
982 ```zig
983 /// Resolve any awaits that can be answered this pump. Granularity is
984 /// the run loop's 100ms tick — nothing here blocks, and no deadline
985 /// folding into poll is needed at that resolution.
986 fn checkAwaits(self: *Server) void {
987 const now = std.time.milliTimestamp();
988 for (0..max_clients) |i| {
989 if (self.clients[i] == null) continue;
990 const a = &(self.clients[i].?.await_state orelse continue);
991
992 // 1. Marks: a return newer than since_seq answers with the
993 // full story. Strictly greater: since_seq is "what I have".
994 if (self.cmd.phase == .returned and self.returned_seq > a.since_seq) {
995 self.answerAwait(i, self.cmdState(.marks), .returned);
996 continue;
997 }
998
999 // 2. pgid: only when marks do not hold the floor. The shell is
1000 // the session leader, so its pid is the resting pgid.
1001 if (!self.cmd.marksOpen()) {
1002 if (self.pty.fgPgid()) |pg| {
1003 if (pg != self.pty.child) {
1004 a.saw_busy = true;
1005 } else if (a.saw_busy) {
1006 var st = self.cmdState(.pgid);
1007 st.phase = .returned;
1008 st.exit_code = null;
1009 st.seq = self.tracker.seq;
1010 self.answerAwait(i, st, .returned);
1011 continue;
1012 }
1013 } else |_| {}
1014 }
1015
1016 // 3. Settle: output silence, if the caller asked for a floor.
1017 if (a.settle_ms > 0 and self.last_pty_ms > 0 and
1018 now - self.last_pty_ms >= a.settle_ms and
1019 now - a.started_ms >= a.settle_ms)
1020 {
1021 var st = self.cmdState(.settle);
1022 st.exit_code = null;
1023 st.seq = self.tracker.seq;
1024 self.answerAwait(i, st, .settled);
1025 continue;
1026 }
1027
1028 // 4. Timeout: the bound the client set on the whole wait.
1029 if (a.timeout_ms > 0 and now - a.started_ms >= a.timeout_ms) {
1030 var st = self.cmdState(if (self.cmd.marks_seen) .marks else .pgid);
1031 st.seq = self.tracker.seq;
1032 self.answerAwait(i, st, .timeout);
1033 }
1034 }
1035 }
1036
1037 fn answerAwait(self: *Server, i: usize, st: proto.CmdState, reason: proto.AwaitReason) void {
1038 if (self.clients[i] == null) return;
1039 self.clients[i].?.await_state = null;
1040 const payload = proto.encodeAwaitReply(st, reason);
1041 _ = self.queueFrame(i, .await_reply, &payload);
1042 }
1043 ```
1044
1045 Note on the optional-pointer idiom in `checkAwaits`: `&(x orelse continue)` does not produce a pointer into the optional in Zig — write it as:
1046
1047 ```zig
1048 const slot = &self.clients[i].?;
1049 if (slot.await_state == null) continue;
1050 const a = &slot.await_state.?;
1051 ```
1052
1053 - [ ] **Step 4: Run tests**
1054
1055 Run: `$ZIG build test 2>&1 | tail -5`
1056 Expected: PASS. The settle/pgid tests take a few wall-clock seconds; that is the test, not a hang — but keep each bounded under 15s.
1057
1058 - [ ] **Step 5: Commit**
1059
1060 ```bash
1061 git add src/server.zig
1062 git commit -m "feat(server): server-side awaits — marks first, pgid edge, settle floor, timeout bound"
1063 ```
1064
1065 ---
1066
1067 ### Task 7: Shell integration — scripts, shims, spawn-time injection
1068
1069 **Files:**
1070 - Create: `src/shellint.zig`
1071 - Modify: `src/pty.zig` (child env pairs), `src/server.zig` (Options + spawn), `src/main.zig` (opt-out wiring)
1072 - Modify: `build.zig` (module + tests)
1073
1074 - [ ] **Step 1: Create `src/shellint.zig` with its tests.** The scripts are marks-only — the OSC 133 subset mux consumes, derived from ghostty's shell-integration approach (guarded D so a bare first prompt emits no code, BASH_COMMAND guard so the DEBUG trap skips the prompt hook itself):
1075
1076 ```zig
1077 //! Shell integration: OSC 133 marks injected at spawn. muxd forks the
1078 //! session shell itself, so injection is env + argv at spawn time — no
1079 //! rc-file edits, ever. Detection is by shell basename; unknown shells get
1080 //! nothing and the session runs on the pgid/settle fallbacks.
1081 const std = @import("std");
1082
1083 pub const zsh_zshrc =
1084 \\# mux shell integration (zsh): OSC 133 marks. Sourced via a ZDOTDIR
1085 \\# shim; restores the user's ZDOTDIR (or unsets it) then runs their rc.
1086 \\if [[ -n "$MUX_ORIG_ZDOTDIR" ]]; then
1087 \\ export ZDOTDIR="$MUX_ORIG_ZDOTDIR"
1088 \\ unset MUX_ORIG_ZDOTDIR
1089 \\else
1090 \\ unset ZDOTDIR
1091 \\fi
1092 \\[[ -f "${ZDOTDIR:-$HOME}/.zshrc" ]] && source "${ZDOTDIR:-$HOME}/.zshrc"
1093 \\autoload -Uz add-zsh-hook
1094 \\_mux_preexec() { _mux_ran=1; printf '\e]133;C\a'; }
1095 \\_mux_precmd() {
1096 \\ local code=$?
1097 \\ [[ -n "$_mux_ran" ]] && printf '\e]133;D;%s\a' "$code"
1098 \\ _mux_ran=""
1099 \\ printf '\e]133;A\a'
1100 \\}
1101 \\add-zsh-hook preexec _mux_preexec
1102 \\add-zsh-hook precmd _mux_precmd
1103 \\
1104 ;
1105
1106 pub const bash_init =
1107 \\# mux shell integration (bash): OSC 133 marks. Passed via --init-file;
1108 \\# sources the user's normal rc first so their config still runs.
1109 \\[[ -f "$HOME/.bashrc" ]] && source "$HOME/.bashrc"
1110 \\_mux_ran=""
1111 \\_mux_preexec() {
1112 \\ [[ -n "$COMP_LINE" ]] && return
1113 \\ [[ "$BASH_COMMAND" == _mux_precmd* ]] && return
1114 \\ _mux_ran=1
1115 \\ printf '\e]133;C\a'
1116 \\}
1117 \\_mux_precmd() {
1118 \\ local code=$?
1119 \\ [[ -n "$_mux_ran" ]] && printf '\e]133;D;%s\a' "$code"
1120 \\ _mux_ran=""
1121 \\ printf '\e]133;A\a'
1122 \\}
1123 \\trap '_mux_preexec' DEBUG
1124 \\PROMPT_COMMAND="_mux_precmd${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
1125 \\
1126 ;
1127
1128 pub const fish_conf =
1129 \\# mux shell integration (fish): OSC 133 marks, via vendor_conf.d.
1130 \\function _mux_preexec --on-event fish_preexec
1131 \\ printf '\e]133;C\a'
1132 \\end
1133 \\function _mux_postexec --on-event fish_postexec
1134 \\ printf '\e]133;D;%s\a' $status
1135 \\end
1136 \\function _mux_prompt --on-event fish_prompt
1137 \\ printf '\e]133;A\a'
1138 \\end
1139 \\
1140 ;
1141
1142 pub const Kind = enum { zsh, bash, fish, other };
1143
1144 pub fn detect(shell_path: []const u8) Kind {
1145 const base = std.fs.path.basename(shell_path);
1146 if (std.mem.eql(u8, base, "zsh")) return .zsh;
1147 if (std.mem.eql(u8, base, "bash")) return .bash;
1148 if (std.mem.eql(u8, base, "fish")) return .fish;
1149 return .other;
1150 }
1151
1152 pub const EnvPair = struct { key: [:0]const u8, value: [:0]const u8 };
1153
1154 /// Everything the spawn needs: the argv to exec and env pairs to set in
1155 /// the child. `dir` must outlive the spawn (paths point into it).
1156 pub const Injection = struct {
1157 /// Extra argv AFTER the shell path (bash --init-file <shim>); empty
1158 /// for env-only injections (zsh, fish) and for .other.
1159 extra_argv: []const [:0]const u8,
1160 env: []const EnvPair,
1161 };
1162
1163 /// Prepare shim files under `dir` (created private, 0700) for `shell_path`
1164 /// and return what spawn must add. All returned slices are allocated from
1165 /// `arena` — hand it an arena that lives as long as the daemon.
1166 pub fn prepare(
1167 arena: std.mem.Allocator,
1168 dir: []const u8,
1169 shell_path: []const u8,
1170 ) !Injection {
1171 switch (detect(shell_path)) {
1172 .zsh => {
1173 try std.fs.cwd().makePath(dir);
1174 const rc_path = try std.fs.path.join(arena, &.{ dir, ".zshrc" });
1175 try writeFilePrivate(rc_path, zsh_zshrc);
1176 var env: std.ArrayList(EnvPair) = .empty;
1177 const dir_z = try arena.dupeZ(u8, dir);
1178 try env.append(arena, .{ .key = "ZDOTDIR", .value = dir_z });
1179 // Only when the daemon itself carried one: exporting an empty
1180 // ZDOTDIR would break zsh's fallback to $HOME (spec footnote).
1181 if (std.posix.getenv("ZDOTDIR")) |orig| {
1182 try env.append(arena, .{
1183 .key = "MUX_ORIG_ZDOTDIR",
1184 .value = try arena.dupeZ(u8, orig),
1185 });
1186 }
1187 return .{ .extra_argv = &.{}, .env = try env.toOwnedSlice(arena) };
1188 },
1189 .bash => {
1190 try std.fs.cwd().makePath(dir);
1191 const init_path = try std.fs.path.join(arena, &.{ dir, "bash-init.sh" });
1192 try writeFilePrivate(init_path, bash_init);
1193 const init_z = try arena.dupeZ(u8, init_path);
1194 const argv = try arena.alloc([:0]const u8, 2);
1195 argv[0] = "--init-file";
1196 argv[1] = init_z;
1197 return .{ .extra_argv = argv, .env = &.{} };
1198 },
1199 .fish => {
1200 const vendor = try std.fs.path.join(arena, &.{ dir, "fish", "vendor_conf.d" });
1201 try std.fs.cwd().makePath(vendor);
1202 const conf_path = try std.fs.path.join(arena, &.{ vendor, "mux.fish" });
1203 try writeFilePrivate(conf_path, fish_conf);
1204 const orig = std.posix.getenv("XDG_DATA_DIRS") orelse "/usr/local/share:/usr/share";
1205 const merged = try std.fmt.allocPrintSentinel(arena, "{s}:{s}", .{ dir, orig }, 0);
1206 return .{
1207 .extra_argv = &.{},
1208 .env = &.{.{ .key = "XDG_DATA_DIRS", .value = merged }},
1209 };
1210 },
1211 .other => return .{ .extra_argv = &.{}, .env = &.{} },
1212 }
1213 }
1214
1215 fn writeFilePrivate(path: []const u8, contents: []const u8) !void {
1216 const f = try std.fs.cwd().createFile(path, .{ .mode = 0o600 });
1217 defer f.close();
1218 try f.writeAll(contents);
1219 }
1220
1221 test "detect goes by basename" {
1222 try std.testing.expectEqual(Kind.zsh, detect("/usr/bin/zsh"));
1223 try std.testing.expectEqual(Kind.bash, detect("/bin/bash"));
1224 try std.testing.expectEqual(Kind.fish, detect("/opt/homebrew/bin/fish"));
1225 try std.testing.expectEqual(Kind.other, detect("/bin/sh"));
1226 try std.testing.expectEqual(Kind.other, detect("/usr/bin/nu"));
1227 }
1228
1229 test "prepare zsh writes the shim and sets ZDOTDIR" {
1230 var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
1231 defer arena_state.deinit();
1232 const arena = arena_state.allocator();
1233 var tmp = std.testing.tmpDir(.{});
1234 defer tmp.cleanup();
1235 const dir = try tmp.dir.realpathAlloc(arena, ".");
1236
1237 const inj = try prepare(arena, dir, "/usr/bin/zsh");
1238 try std.testing.expectEqual(@as(usize, 0), inj.extra_argv.len);
1239 try std.testing.expect(inj.env.len >= 1);
1240 try std.testing.expectEqualStrings("ZDOTDIR", inj.env[0].key);
1241 const rc = try tmp.dir.readFileAlloc(arena, ".zshrc", 64 * 1024);
1242 try std.testing.expect(std.mem.indexOf(u8, rc, "133;D;%s") != null);
1243 try std.testing.expect(std.mem.indexOf(u8, rc, "add-zsh-hook") != null);
1244 }
1245
1246 test "prepare bash returns --init-file argv" {
1247 var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
1248 defer arena_state.deinit();
1249 const arena = arena_state.allocator();
1250 var tmp = std.testing.tmpDir(.{});
1251 defer tmp.cleanup();
1252 const dir = try tmp.dir.realpathAlloc(arena, ".");
1253
1254 const inj = try prepare(arena, dir, "/bin/bash");
1255 try std.testing.expectEqual(@as(usize, 2), inj.extra_argv.len);
1256 try std.testing.expectEqualStrings("--init-file", inj.extra_argv[0]);
1257 const script = try std.fs.cwd().readFileAlloc(arena, inj.extra_argv[1], 64 * 1024);
1258 try std.testing.expect(std.mem.indexOf(u8, script, "PROMPT_COMMAND") != null);
1259 try std.testing.expect(std.mem.indexOf(u8, script, "trap '_mux_preexec' DEBUG") != null);
1260 }
1261
1262 test "prepare other injects nothing" {
1263 var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
1264 defer arena_state.deinit();
1265 const arena = arena_state.allocator();
1266 const inj = try prepare(arena, "/nonexistent-never-created", "/bin/sh");
1267 try std.testing.expectEqual(@as(usize, 0), inj.extra_argv.len);
1268 try std.testing.expectEqual(@as(usize, 0), inj.env.len);
1269 }
1270 ```
1271
1272 (API check for the executor: `allocPrintSentinel` is the 0.15 name for sentinel-terminated allocPrint; if absent, use `std.fmt.allocPrintZ`. `readFileAlloc` argument order changed across 0.15 — follow whatever `src/xdg.zig` or neighboring code uses.)
1273
1274 - [ ] **Step 2: Extend `Pty` for env pairs.** In `src/pty.zig`, add to `SpawnArgvOptions`:
1275
1276 ```zig
1277 /// Set in the child between fork and exec, after TERM. Injection's
1278 /// door: the daemon's env is the only source of a child's env.
1279 env: []const struct { key: [:0]const u8, value: [:0]const u8 } = &.{},
1280 ```
1281
1282 and in the child block, right after the TERM `setenv`:
1283
1284 ```zig
1285 for (opts.env) |kv| _ = c.setenv(kv.key.ptr, kv.value.ptr, 1);
1286 ```
1287
1288 Add a test pinning it:
1289
1290 ```zig
1291 test "Pty: spawnArgv env pairs reach the child" {
1292 var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "printf 'env-%s' \"$MUX_T\"" };
1293 var pty = try Pty.spawnArgv(.{
1294 .cols = 80,
1295 .rows = 24,
1296 .argv = &argv,
1297 .env = &.{.{ .key = "MUX_T", .value = "ok" }},
1298 });
1299 defer pty.deinit();
1300 var out = try readUntil(std.testing.allocator, &pty, "env-ok", 5000);
1301 defer out.deinit(std.testing.allocator);
1302 try std.testing.expect(std.mem.indexOf(u8, out.items, "env-ok") != null);
1303 }
1304 ```
1305
1306 - [ ] **Step 3: Wire into the daemon.** In `src/server.zig`:
1307 - `Server.Options` gains `shell_integration: bool = true`.
1308 - In `Server.init`, replace the plain `Pty.spawn` with: when `opts.shell_integration`, build the shim dir path `{dirname(sock_path)}/mux-shellint-{pid}` (the sock dir is already private, runtime-appropriate, and per-user; pid keeps two daemons apart), call `shellint.prepare` with an arena owned by the Server (add a `shellint_arena: std.heap.ArenaAllocator` field, deinit'd in `deinit`, plus a `shellint_dir: ?[]const u8` field so `deinit` can `std.fs.cwd().deleteTree` it best-effort), assemble argv `[shell] ++ extra_argv` as `[*:null]` and call `Pty.spawnArgv` with the env pairs. `.other` injections are empty — the call is then equivalent to today's spawn.
1309 - In `src/main.zig`'s `run` path, thread the option: `shell_integration` is true unless the daemon's env says `MUX_SHELL_INTEGRATION=0` (`std.posix.getenv`) — the spec is explicit that the opt-out is read from the daemon's env.
1310 - `build.zig`: create `shellint_mod`, import into `server` deps and `exe_mod`; register its tests BEFORE server tests.
1311
1312 - [ ] **Step 4: End-to-end injection test** (in `src/server.zig` tests, guarded): spawn a real Server with `shell = "/bin/bash"` (skip with `error.SkipZigTest` if `/bin/bash` is absent), attach a fake client, write `"true\n"` via `.input`, pump, and assert a `.cmd_state` frame with phase `.returned` and exit_code 0 arrives within 10s of pumping. This proves marks flow end-to-end: injection → bash → pty → engine → tracker → wire. Repeat for zsh if `/usr/bin/zsh` exists (skip otherwise).
1313
1314 - [ ] **Step 5: Run tests**
1315
1316 Run: `$ZIG build test 2>&1 | tail -5`
1317 Expected: PASS (bash test live on this box; zsh/fish arms skip where absent).
1318
1319 - [ ] **Step 6: Commit**
1320
1321 ```bash
1322 git add src/shellint.zig src/pty.zig src/server.zig src/main.zig build.zig
1323 git commit -m "feat(shellint): OSC 133 marks injected at spawn — ZDOTDIR shim, --init-file, vendor_conf.d"
1324 ```
1325
1326 ---
1327
1328 ### Task 8: `muxa` skeleton — arg parsing, unix transport, `status` and `capture`
1329
1330 **Files:**
1331 - Create: `src/muxa.zig`
1332 - Modify: `build.zig`
1333
1334 - [ ] **Step 1: Create `src/muxa.zig`.** Structure (complete file; JSON is hand-escaped — no std.json dependency to fight 0.15 API drift over):
1335
1336 ```zig
1337 //! muxa: the agent-facing mux client. Every verb prints one JSON object on
1338 //! stdout and exits 0 on success; failures print {"error": "..."} and exit
1339 //! nonzero. Attaches at 0x0 always — an agent must never claim the grid
1340 //! out from under the human's size (load-bearing spec rule).
1341 const std = @import("std");
1342 const proto = @import("protocol");
1343
1344 const usage =
1345 \\usage: muxa <verb> [--sock PATH] [--settle MS] [--timeout MS] [--vt] [args]
1346 \\verbs:
1347 \\ status session snapshot as JSON
1348 \\ capture current grid as text (--vt for styled)
1349 \\ send BYTES raw bytes to the pty (C-style escapes: \n \r \t \e \xNN)
1350 \\ run CMDLINE send CMDLINE + newline, await return, report exit/output
1351 \\ await wait for the current/next command to return
1352 \\
1353 ;
1354
1355 const Opts = struct {
1356 verb: enum { status, capture, send, run, @"await" },
1357 sock: ?[]const u8 = null,
1358 settle_ms: u32 = 0,
1359 timeout_ms: u32 = 30_000,
1360 vt: bool = false,
1361 arg: ?[]const u8 = null,
1362 };
1363
1364 fn parseArgs(args: []const [:0]const u8) ?Opts {
1365 if (args.len < 2) return null;
1366 const verb = std.meta.stringToEnum(@FieldType(Opts, "verb"), args[1]) orelse return null;
1367 var o: Opts = .{ .verb = verb };
1368 var i: usize = 2;
1369 while (i < args.len) : (i += 1) {
1370 const a = args[i];
1371 if (std.mem.eql(u8, a, "--sock")) {
1372 i += 1;
1373 if (i >= args.len) return null;
1374 o.sock = args[i];
1375 } else if (std.mem.eql(u8, a, "--settle")) {
1376 i += 1;
1377 if (i >= args.len) return null;
1378 o.settle_ms = std.fmt.parseInt(u32, args[i], 10) catch return null;
1379 } else if (std.mem.eql(u8, a, "--timeout")) {
1380 i += 1;
1381 if (i >= args.len) return null;
1382 o.timeout_ms = std.fmt.parseInt(u32, args[i], 10) catch return null;
1383 } else if (std.mem.eql(u8, a, "--vt")) {
1384 o.vt = true;
1385 } else if (o.arg == null and a.len > 0 and a[0] != '-') {
1386 o.arg = a;
1387 } else return null;
1388 }
1389 return o;
1390 }
1391
1392 /// JSON string escape, the six mandatory escapes + control bytes as \u00XX.
1393 fn jsonEscape(writer: anytype, s: []const u8) !void {
1394 try writer.writeByte('"');
1395 for (s) |b| switch (b) {
1396 '"' => try writer.writeAll("\\\""),
1397 '\\' => try writer.writeAll("\\\\"),
1398 '\n' => try writer.writeAll("\\n"),
1399 '\r' => try writer.writeAll("\\r"),
1400 '\t' => try writer.writeAll("\\t"),
1401 0x00...0x08, 0x0b, 0x0c, 0x0e...0x1f => try writer.print("\\u{x:0>4}", .{b}),
1402 else => try writer.writeByte(b),
1403 };
1404 try writer.writeByte('"');
1405 }
1406
1407 test "jsonEscape pins the escapes" {
1408 var buf: [128]u8 = undefined;
1409 var fbs = std.io.fixedBufferStream(&buf);
1410 try jsonEscape(fbs.writer(), "a\"b\\c\nd\x1be");
1411 try std.testing.expectEqualStrings("\"a\\\"b\\\\c\\nd\\u001be\"", fbs.getWritten());
1412 }
1413
1414 /// Decode C-style escapes for `send`. Caller frees.
1415 fn decodeEscapes(alloc: std.mem.Allocator, s: []const u8) ![]u8 {
1416 var out: std.ArrayList(u8) = .empty;
1417 errdefer out.deinit(alloc);
1418 var i: usize = 0;
1419 while (i < s.len) : (i += 1) {
1420 if (s[i] != '\\' or i + 1 >= s.len) {
1421 try out.append(alloc, s[i]);
1422 continue;
1423 }
1424 i += 1;
1425 switch (s[i]) {
1426 'n' => try out.append(alloc, '\n'),
1427 'r' => try out.append(alloc, '\r'),
1428 't' => try out.append(alloc, '\t'),
1429 'e' => try out.append(alloc, 0x1b),
1430 '\\' => try out.append(alloc, '\\'),
1431 'x' => {
1432 if (i + 2 >= s.len) return error.BadEscape;
1433 try out.append(alloc, try std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16));
1434 i += 2;
1435 },
1436 else => return error.BadEscape,
1437 }
1438 }
1439 return out.toOwnedSlice(alloc);
1440 }
1441
1442 test "decodeEscapes covers the sequences send needs" {
1443 const alloc = std.testing.allocator;
1444 const got = try decodeEscapes(alloc, "q\\n\\e[A\\x03");
1445 defer alloc.free(got);
1446 try std.testing.expectEqualSlices(u8, "q\n\x1b[A\x03", got);
1447 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "\\q"));
1448 }
1449
1450 test "parseArgs verbs and flags" {
1451 const a1 = [_][:0]const u8{ "muxa", "status" };
1452 try std.testing.expectEqual(@FieldType(Opts, "verb").status, parseArgs(&a1).?.verb);
1453 const a2 = [_][:0]const u8{ "muxa", "run", "--timeout", "5000", "make test" };
1454 const o2 = parseArgs(&a2).?;
1455 try std.testing.expectEqual(@as(u32, 5000), o2.timeout_ms);
1456 try std.testing.expectEqualStrings("make test", o2.arg.?);
1457 const a3 = [_][:0]const u8{ "muxa", "bogus" };
1458 try std.testing.expectEqual(@as(?Opts, null), parseArgs(&a3));
1459 }
1460 ```
1461
1462 Then the connection + main. Unix transport first (QUIC is Task 10):
1463
1464 ```zig
1465 const Conn = struct {
1466 fd: std.posix.fd_t,
1467
1468 fn open(sock_path: []const u8) !Conn {
1469 const addr = try std.net.Address.initUnix(sock_path);
1470 const s = try std.net.connectUnixSocket(sock_path);
1471 _ = addr;
1472 return .{ .fd = s.handle };
1473 }
1474
1475 fn close(self: *Conn) void {
1476 std.posix.close(self.fd);
1477 }
1478
1479 fn sendFrame(self: *Conn, t: proto.MsgType, payload: []const u8) !void {
1480 try proto.writeFrame(self.fd, t, payload);
1481 }
1482
1483 /// Read frames until one of type `want` arrives (snapshots, deltas and
1484 /// pushes stream past an attached client; skip what we did not ask
1485 /// for). Bounded by `deadline_ms` wall time via poll.
1486 fn awaitFrame(
1487 self: *Conn,
1488 alloc: std.mem.Allocator,
1489 want: proto.MsgType,
1490 deadline_ms: i64,
1491 ) !proto.Frame {
1492 while (true) {
1493 const now = std.time.milliTimestamp();
1494 if (now >= deadline_ms) return error.Timeout;
1495 var fds = [_]std.posix.pollfd{
1496 .{ .fd = self.fd, .events = std.posix.POLL.IN, .revents = 0 },
1497 };
1498 const n = try std.posix.poll(&fds, @intCast(@min(deadline_ms - now, 250)));
1499 if (n == 0) continue;
1500 const frame = try proto.readFrame(alloc, self.fd) orelse return error.DaemonGone;
1501 if (frame.type == want) return frame;
1502 frame.deinit(alloc);
1503 }
1504 }
1505 };
1506
1507 fn fail(msg: []const u8, detail: []const u8) u8 {
1508 const err = std.fs.File.stderr();
1509 _ = err;
1510 var buf: [512]u8 = undefined;
1511 var fbs = std.io.fixedBufferStream(&buf);
1512 const w = fbs.writer();
1513 w.writeAll("{\"error\":") catch {};
1514 jsonEscape(w, msg) catch {};
1515 if (detail.len > 0) {
1516 w.writeAll(",\"detail\":") catch {};
1517 jsonEscape(w, detail) catch {};
1518 }
1519 w.writeAll("}\n") catch {};
1520 std.fs.File.stdout().writeAll(fbs.getWritten()) catch {};
1521 return 1;
1522 }
1523
1524 pub fn main() !u8 {
1525 var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator);
1526 defer arena_state.deinit();
1527 const alloc = arena_state.allocator();
1528
1529 const args = try std.process.argsAlloc(alloc);
1530 const o = parseArgs(args) orelse {
1531 std.fs.File.stderr().writeAll(usage) catch {};
1532 return 2;
1533 };
1534
1535 const sock_path = o.sock orelse blk: {
1536 if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir|
1537 break :blk try std.fmt.allocPrint(alloc, "{s}/muxd.sock", .{dir});
1538 break :blk try std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()});
1539 };
1540
1541 var conn = Conn.open(sock_path) catch
1542 return fail("cannot connect", sock_path);
1543 defer conn.close();
1544
1545 const deadline = std.time.milliTimestamp() + o.timeout_ms;
1546 return switch (o.verb) {
1547 .status => verbStatus(alloc, &conn, deadline),
1548 .capture => verbCapture(alloc, &conn, o.vt, deadline),
1549 .send => verbSend(alloc, &conn, o.arg orelse return fail("send needs BYTES", "")),
1550 .run => verbRun(alloc, &conn, o, deadline),
1551 .@"await" => verbAwait(alloc, &conn, o, deadline),
1552 };
1553 }
1554
1555 fn verbStatus(alloc: std.mem.Allocator, conn: *Conn, deadline: i64) u8 {
1556 conn.sendFrame(.status_req, "") catch return fail("send failed", "");
1557 const frame = conn.awaitFrame(alloc, .status_reply, deadline) catch
1558 return fail("no reply — daemon too old or hung", "");
1559 defer frame.deinit(alloc);
1560 const s = proto.decodeStatusReply(frame.payload) catch return fail("bad status_reply", "");
1561 printStatus(s) catch return 1;
1562 return 0;
1563 }
1564
1565 fn printStatus(s: proto.StatusReply) !void {
1566 var buf: [512]u8 = undefined;
1567 var fbs = std.io.fixedBufferStream(&buf);
1568 const w = fbs.writer();
1569 try w.print(
1570 "{{\"cols\":{d},\"rows\":{d},\"cursor_x\":{d},\"cursor_y\":{d}," ++
1571 "\"history_rows\":{d},\"alt_screen\":{},\"icanon\":{},\"echo\":{}," ++
1572 "\"cmd\":{{\"phase\":\"{s}\",\"mechanism\":\"{s}\",\"exit_code\":",
1573 .{
1574 s.cols, s.rows, s.cursor_x,
1575 s.cursor_y, s.history_rows, s.alt_screen,
1576 s.mode.icanon, s.mode.echo,
1577 @tagName(s.cmd.phase), @tagName(s.cmd.mechanism),
1578 },
1579 );
1580 if (s.cmd.exit_code) |c| try w.print("{d}", .{c}) else try w.writeAll("null");
1581 try w.print(
1582 ",\"start_row\":{d},\"end_row\":{d},\"seq\":{d}}}}}\n",
1583 .{ s.cmd.start_row, s.cmd.end_row, s.cmd.seq },
1584 );
1585 try std.fs.File.stdout().writeAll(fbs.getWritten());
1586 }
1587
1588 fn verbCapture(alloc: std.mem.Allocator, conn: *Conn, vt: bool, deadline: i64) u8 {
1589 conn.sendFrame(.debug_dump, &.{@intFromBool(vt)}) catch return fail("send failed", "");
1590 const frame = conn.awaitFrame(alloc, .dump_reply, deadline) catch
1591 return fail("no reply — daemon too old or hung", "");
1592 defer frame.deinit(alloc);
1593 var out: std.ArrayList(u8) = .empty;
1594 defer out.deinit(alloc);
1595 const w = out.writer(alloc);
1596 w.writeAll("{\"grid\":") catch return 1;
1597 jsonEscape(w, frame.payload) catch return 1;
1598 w.writeAll("}\n") catch return 1;
1599 std.fs.File.stdout().writeAll(out.items) catch return 1;
1600 return 0;
1601 }
1602
1603 fn attachZero(conn: *Conn) !void {
1604 // cols=rows=0: applySize refuses <2, the slot stays 0x0, claimGrid
1605 // reads that as "makes no claim" — the human's grid never moves.
1606 try conn.sendFrame(.attach, &proto.encodeAttach(0, 0, 0, 0));
1607 }
1608
1609 fn verbSend(alloc: std.mem.Allocator, conn: *Conn, arg: []const u8) u8 {
1610 const bytes = decodeEscapes(alloc, arg) catch return fail("bad escape in BYTES", arg);
1611 defer alloc.free(bytes);
1612 attachZero(conn) catch return fail("attach failed", "");
1613 conn.sendFrame(.input, bytes) catch return fail("send failed", "");
1614 conn.sendFrame(.detach, "") catch {};
1615 std.fs.File.stdout().writeAll("{\"sent\":true}\n") catch return 1;
1616 return 0;
1617 }
1618 ```
1619
1620 `verbRun`/`verbAwait` are Task 9 — for this task, stub them honestly:
1621
1622 ```zig
1623 fn verbRun(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) u8 {
1624 _ = alloc;
1625 _ = conn;
1626 _ = o;
1627 _ = deadline;
1628 return fail("run: not implemented yet", "");
1629 }
1630
1631 fn verbAwait(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) u8 {
1632 _ = alloc;
1633 _ = conn;
1634 _ = o;
1635 _ = deadline;
1636 return fail("await: not implemented yet", "");
1637 }
1638 ```
1639
1640 (0.15 I/O note for the executor: `std.fs.File.stdout()` / `.stderr()` and `std.io.fixedBufferStream` — follow whatever `src/main.zig` uses for its printing if these names differ; the repo compiles against 0.15.2, so its own idiom is the API truth.)
1641
1642 - [ ] **Step 2: Wire the binary in `build.zig`** next to `mux_exe`: `muxa_mod` (root `src/muxa.zig`, imports `protocol`), `b.addExecutable(.{ .name = "muxa", .root_module = muxa_mod })`, `use_llvm`/`use_lld` true, `b.installArtifact`. Register `muxa_mod` tests before the e2e tests.
1643
1644 - [ ] **Step 3: Run unit tests, then a live smoke.**
1645
1646 Run: `$ZIG build test 2>&1 | tail -5` — expected PASS.
1647
1648 Live smoke:
1649
1650 ```bash
1651 $ZIG build
1652 SOCK=/tmp/muxa-smoke-$$.sock
1653 ./zig-out/bin/muxd start --sock $SOCK
1654 sleep 0.5
1655 ./zig-out/bin/muxa status --sock $SOCK
1656 ./zig-out/bin/muxa capture --sock $SOCK
1657 ./zig-out/bin/muxa send 'echo hi\n' --sock $SOCK
1658 sleep 0.5
1659 ./zig-out/bin/muxa capture --sock $SOCK # grid JSON must contain "hi"
1660 ./zig-out/bin/muxd stop --sock $SOCK
1661 ```
1662
1663 Expected: valid single-line JSON from each verb; the second capture contains `hi`.
1664
1665 - [ ] **Step 4: Commit**
1666
1667 ```bash
1668 git add src/muxa.zig build.zig
1669 git commit -m "feat(muxa): agent client skeleton — status, capture, send over the unix socket"
1670 ```
1671
1672 ---
1673
1674 ### Task 9: `muxa run` and `muxa await`
1675
1676 **Files:**
1677 - Modify: `src/muxa.zig`
1678
1679 - [ ] **Step 1: Implement `verbAwait` and `verbRun`** (replace the stubs):
1680
1681 ```zig
1682 fn doAwait(
1683 alloc: std.mem.Allocator,
1684 conn: *Conn,
1685 o: Opts,
1686 since_seq: u64,
1687 deadline: i64,
1688 ) !proto.AwaitReply {
1689 try conn.sendFrame(.await_req, &proto.encodeAwaitReq(.{
1690 .since_seq = since_seq,
1691 .settle_ms = o.settle_ms,
1692 .timeout_ms = o.timeout_ms,
1693 }));
1694 const frame = try conn.awaitFrame(alloc, .await_reply, deadline);
1695 defer frame.deinit(alloc);
1696 return try proto.decodeAwaitReply(frame.payload);
1697 }
1698
1699 fn currentSeq(alloc: std.mem.Allocator, conn: *Conn, deadline: i64) !u64 {
1700 try conn.sendFrame(.status_req, "");
1701 const frame = try conn.awaitFrame(alloc, .status_reply, deadline);
1702 defer frame.deinit(alloc);
1703 const s = try proto.decodeStatusReply(frame.payload);
1704 return s.cmd.seq;
1705 }
1706
1707 fn printAwaitReply(r: proto.AwaitReply, output: ?[]const u8, duration_ms: i64) !void {
1708 var out: std.ArrayList(u8) = .empty;
1709 var alloc_state = std.heap.ArenaAllocator.init(std.heap.page_allocator);
1710 defer alloc_state.deinit();
1711 const alloc = alloc_state.allocator();
1712 defer out.deinit(alloc);
1713 const w = out.writer(alloc);
1714 try w.print(
1715 "{{\"reason\":\"{s}\",\"phase\":\"{s}\",\"mechanism\":\"{s}\",\"exit_code\":",
1716 .{ @tagName(r.reason), @tagName(r.state.phase), @tagName(r.state.mechanism) },
1717 );
1718 if (r.state.exit_code) |c| try w.print("{d}", .{c}) else try w.writeAll("null");
1719 try w.print(
1720 ",\"start_row\":{d},\"end_row\":{d},\"duration_ms\":{d}",
1721 .{ r.state.start_row, r.state.end_row, duration_ms },
1722 );
1723 if (output) |text| {
1724 try w.writeAll(",\"output\":");
1725 try jsonEscape(w, text);
1726 }
1727 try w.writeAll("}\n");
1728 try std.fs.File.stdout().writeAll(out.items);
1729 }
1730
1731 fn verbAwait(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) u8 {
1732 attachZero(conn) catch return fail("attach failed", "");
1733 const started = std.time.milliTimestamp();
1734 const since = currentSeq(alloc, conn, deadline) catch
1735 return fail("no reply — daemon too old or hung", "");
1736 const r = doAwait(alloc, conn, o, since, deadline) catch
1737 return fail("no reply — daemon too old or hung", "");
1738 printAwaitReply(r, null, std.time.milliTimestamp() - started) catch return 1;
1739 return if (r.reason == .timeout) 3 else 0;
1740 }
1741
1742 /// Fetch the output span [start_row, end_row) as plain rows. Only valid in
1743 /// the marks regime; other mechanisms have no honest span (spec).
1744 fn fetchSpan(
1745 alloc: std.mem.Allocator,
1746 conn: *Conn,
1747 start_row: u32,
1748 end_row: u32,
1749 deadline: i64,
1750 ) ![]const u8 {
1751 if (end_row <= start_row) return "";
1752 const count: u16 = @intCast(@min(end_row - start_row, std.math.maxInt(u16)));
1753 try conn.sendFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start_row, count));
1754 const frame = try conn.awaitFrame(alloc, .scrollback_chunk, deadline);
1755 defer frame.deinit(alloc);
1756 if (frame.payload.len < 6) return error.BadPayload;
1757 // Styled rows follow the 6-byte echo of the request; strip SGR down to
1758 // text so the agent gets what a human read, not escape soup.
1759 return stripSgr(alloc, frame.payload[6..]);
1760 }
1761
1762 /// Remove ESC-[...m/ESC-]...BEL/ESC-\ sequences, keep text and newlines.
1763 fn stripSgr(alloc: std.mem.Allocator, styled: []const u8) ![]const u8 {
1764 var out: std.ArrayList(u8) = .empty;
1765 errdefer out.deinit(alloc);
1766 var i: usize = 0;
1767 while (i < styled.len) {
1768 const b = styled[i];
1769 if (b == 0x1b and i + 1 < styled.len) {
1770 const kind = styled[i + 1];
1771 if (kind == '[') {
1772 i += 2;
1773 while (i < styled.len and !isCsiFinal(styled[i])) i += 1;
1774 i += 1; // the final byte
1775 continue;
1776 } else if (kind == ']') {
1777 i += 2;
1778 while (i < styled.len and styled[i] != 0x07) : (i += 1) {
1779 if (styled[i] == 0x1b and i + 1 < styled.len and styled[i + 1] == '\\') {
1780 i += 1;
1781 break;
1782 }
1783 }
1784 i += 1;
1785 continue;
1786 }
1787 i += 2;
1788 continue;
1789 }
1790 try out.append(alloc, b);
1791 i += 1;
1792 }
1793 return out.toOwnedSlice(alloc);
1794 }
1795
1796 fn isCsiFinal(b: u8) bool {
1797 return b >= 0x40 and b <= 0x7e;
1798 }
1799
1800 test "stripSgr leaves text, drops SGR and OSC" {
1801 const alloc = std.testing.allocator;
1802 const got = try stripSgr(alloc, "\x1b[0m\x1b[1;31mred\x1b[0m ok\n\x1b]0;title\x07plain");
1803 defer alloc.free(got);
1804 try std.testing.expectEqualStrings("red ok\nplain", got);
1805 }
1806
1807 fn verbRun(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) u8 {
1808 const cmdline = o.arg orelse return fail("run needs CMDLINE", "");
1809 attachZero(conn) catch return fail("attach failed", "");
1810 const started = std.time.milliTimestamp();
1811 const since = currentSeq(alloc, conn, deadline) catch
1812 return fail("no reply — daemon too old or hung", "");
1813
1814 const line = std.fmt.allocPrint(alloc, "{s}\n", .{cmdline}) catch return 1;
1815 defer alloc.free(line);
1816 conn.sendFrame(.input, line) catch return fail("send failed", "");
1817
1818 const r = doAwait(alloc, conn, o, since, deadline) catch
1819 return fail("no reply — daemon too old or hung", "");
1820
1821 // Output span only in the marks regime — pgid/settle have no rows.
1822 var output: ?[]const u8 = null;
1823 if (r.state.mechanism == .marks and r.reason == .returned) {
1824 output = fetchSpan(alloc, conn, r.state.start_row, r.state.end_row, deadline) catch null;
1825 }
1826 defer if (output) |text| if (text.len > 0) alloc.free(text);
1827 printAwaitReply(r, output, std.time.milliTimestamp() - started) catch return 1;
1828 return if (r.reason == .timeout) 3 else 0;
1829 }
1830 ```
1831
1832 Executor note on `run`'s default settle: an agent calling `run` against a marks-less session with `--settle 0` would ride to the timeout. That is per spec (settle is opt-in), and `status` tells the agent which regime it is in first. Do not silently default settle on.
1833
1834 - [ ] **Step 2: Run unit tests + live smoke**
1835
1836 Run: `$ZIG build test 2>&1 | tail -5` — PASS.
1837
1838 ```bash
1839 $ZIG build
1840 SOCK=/tmp/muxa-run-$$.sock
1841 MUX_SHELL_INTEGRATION=1 ./zig-out/bin/muxd start --sock $SOCK --shell /bin/bash
1842 sleep 1
1843 ./zig-out/bin/muxa run 'false' --sock $SOCK # {"reason":"returned",...,"exit_code":1,...,"mechanism":"marks"}
1844 ./zig-out/bin/muxa run 'echo span-test' --sock $SOCK # output contains "span-test"
1845 ./zig-out/bin/muxa await --settle 300 --sock $SOCK # settles quickly at an idle prompt... reason "settled"
1846 ./zig-out/bin/muxd stop --sock $SOCK
1847 ```
1848
1849 Expected: exit codes and mechanisms as annotated.
1850
1851 - [ ] **Step 3: Commit**
1852
1853 ```bash
1854 git add src/muxa.zig
1855 git commit -m "feat(muxa): run and await — exit codes over marks, spans from scrollback"
1856 ```
1857
1858 ---
1859
1860 ### Task 10: `muxa` over QUIC
1861
1862 **Files:**
1863 - Modify: `src/muxa.zig`, `build.zig`
1864
1865 - [ ] **Step 1: Extend `Conn` to a tagged union** over the existing socket arm and a QUIC arm using `quic_client.Client` (`connect/send/pump/pollFd/inbound/consume/isReady` — a cleanly reusable API; do NOT borrow `client.zig`'s reconnect loop, which is entangled with the attach replica). Add flags `--quic HOST:PORT` and `--key PATH` to `parseArgs` (key resolution via `xdg.resolveKeyPath`/`pickKey` exactly as `src/main.zig` does — copy its order: `--key` > `$MUX_KEY_FILE` > XDG default). The QUIC arm's `sendFrame` wraps `proto.appendFrame` into a buffer then `client.send`; `awaitFrame` pumps + polls `pollFd` with the same deadline discipline, delimiting frames out of `inbound()` with `proto.frame_header_len` exactly the way `server.zig`'s `pushInbound` does (copy that loop). On `error.ConnectionLost` mid-await: reconnect once, re-attach 0x0, re-issue the await with the ORIGINAL `since_seq` (the idempotency contract), and continue the deadline — not reset it.
1866
1867 - [ ] **Step 2: build.zig**: add `quic_client` + `xdg` + `quic` imports to `muxa_mod`, and `linkQuic(b, muxa_exe, quic)` like `mux_exe` has.
1868
1869 - [ ] **Step 3: Live QUIC smoke**
1870
1871 ```bash
1872 $ZIG build
1873 KEYDIR=$(mktemp -d)
1874 MUX_KEY_FILE=$KEYDIR/key ./zig-out/bin/muxd keygen 2>/dev/null || ./zig-out/bin/muxd keygen
1875 SOCK=/tmp/muxa-quic-$$.sock
1876 ./zig-out/bin/muxd start --sock $SOCK --quic 127.0.0.1:14433 --shell /bin/bash
1877 sleep 1
1878 ./zig-out/bin/muxa run 'echo quic-ok' --quic 127.0.0.1:14433
1879 ./zig-out/bin/muxd stop --sock $SOCK
1880 ```
1881
1882 Expected: the run JSON reports exit 0 and output containing `quic-ok`. (Key path plumbing: use whatever `muxd keygen` wrote — check `muxd keygen`'s output line for the path and pass `--key` if the default resolution does not find it.)
1883
1884 - [ ] **Step 4: Run full suite, commit**
1885
1886 ```bash
1887 $ZIG build test 2>&1 | tail -5
1888 git add src/muxa.zig build.zig
1889 git commit -m "feat(muxa): QUIC transport — same verbs, remote daemons, one reconnect re-issue"
1890 ```
1891
1892 ---
1893
1894 ### Task 11: End-to-end script — shell marks, ephemeral TUI, version-skew honesty
1895
1896 **Files:**
1897 - Create: `test/agent.sh` (executable)
1898
1899 - [ ] **Step 1: Write `test/agent.sh`** modeled on `test/e2e.sh`'s conventions (read its header first: how it finds binaries, traps cleanup, counts failures). Scenarios, each against `./zig-out/bin`:
1900
1901 1. **Marks session**: `muxd start --sock $S --shell /bin/bash` → `muxa run 'exit 3'`-style checks: `run 'true'` exit_code 0, `run 'false'` exit_code 1, `run 'echo out-$$'` output contains the marker, mechanism `marks` on all three.
1902 2. **Ephemeral TUI (the spec's field specimen)**: `muxd start --sock $S -- <TUI>` — use `vi` if present else `less /etc/hostname`: `muxa status` shows `alt_screen true` and mechanism not `marks`; `muxa send 'q'` (or `:q\n` for vi); then `muxa status` fails with connection refused OR the daemon exits — assert the session ended and `muxa` printed a JSON error object, not a stack trace. (Note: `muxd start`'s `--` argv support — if `muxd start` cannot take an argv today, spawn via `--shell /usr/bin/vi`; the shell flag execs any binary, which is exactly what `Pty.spawn` does with it.)
1903 3. **Settle**: `run 'sleep 1' --settle 300 --timeout 10000` against `--shell /bin/sh` (no marks): reason `settled` or `returned` (pgid may win the race — both are honest; assert NOT `timeout`).
1904 4. **Alt-screen guard**: in the TUI session, `muxa run 'true' --timeout 1500` must come back reason `timeout` (exit 3) rather than fabricating a return.
1905
1906 - [ ] **Step 2: Run it**
1907
1908 ```bash
1909 $ZIG build && bash test/agent.sh
1910 ```
1911
1912 Expected: all scenarios green, script exits 0.
1913
1914 - [ ] **Step 3: Commit**
1915
1916 ```bash
1917 git add test/agent.sh
1918 git commit -m "test(agent): e2e — marks exit codes, ephemeral TUI drive, settle vs timeout honesty"
1919 ```
1920
1921 ---
1922
1923 ### Task 12: Docs and close-out
1924
1925 **Files:**
1926 - Modify: `docs/roadmap.md` (agent surface entry: shipped, what is deferred — MCP wrapper, scoped auth, input attribution, event subscriptions)
1927 - Modify: `docs/decisions.md` (three decisions worth recording: rows-not-seqs for spans; the MuxHandler wrap instead of patching the dep; awaits at 100ms tick granularity instead of poll folding)
1928 - Modify: `README.md` if it lists binaries (add `muxa` one-liner)
1929
1930 - [ ] **Step 1: Write the entries** — follow each file's existing voice and format; state what was deliberately NOT built and why (the spec's Non-goals section is the source).
1931
1932 - [ ] **Step 2: Full suite + build one last time**
1933
1934 ```bash
1935 $ZIG build test 2>&1 | tail -5 && $ZIG build 2>&1 | tail -3 && bash test/agent.sh
1936 ```
1937
1938 Expected: everything green.
1939
1940 - [ ] **Step 3: Commit**
1941
1942 ```bash
1943 git add docs/roadmap.md docs/decisions.md README.md
1944 git commit -m "docs: agent surface shipped — decisions recorded, deferrals named"
1945 ```
1946
1947 ---
1948
1949 ## Plan self-review notes (already applied)
1950
1951 - **Spec coverage**: injection (T7), interception (T3), state machine (T4), cmd_state/status (T1/T5), awaits with all three mechanisms + since_seq idempotency (T6), muxa verbs incl. 0x0 attach (T8/T9), QUIC + own reconnect loop (T10), ephemeral TUI as first-class (T11), version-skew timeout-is-the-detection (muxa's "no reply — daemon too old or hung", T8), docs (T12). Alt-screen row caveat is enforced by T11 scenario 4 rather than a row check — rows are simply absent outside marks.
1952 - **Known soft spots the executor must resolve against the live code, not guess**: the server test harness helpers (T5/T6 — copy neighbors), 0.15 stdout/ArrayList-writer idioms in muxa (follow `src/main.zig`), `muxd start -- argv` support in T11 (fallback given inline).
1953 - **Type consistency**: `proto.CmdState`/`CmdPhase`/`Mechanism` defined once in T1 and used by that name in T4–T9; `Engine.MarkEvent` defined in T3, consumed in T4/T5; `AwaitState` lives only in server.zig.
docs/superpowers/specs/2026-08-13-agent-surface-design.md
Old New
@@ -0,0 +1,242 @@
1 # Agent surface: native LLM integration for mux
2
3 **Date:** 2026-08-13
4 **Status:** approved (design), pending implementation plan
5 **Branch:** feat/agent-surface
6
7 ## Problem
8
9 LLM agents drive terminals today by shelling out to `tmux send-keys` /
10 `capture-pane`: blind polling, arbitrary sleeps, no signal for "the command
11 returned", no exit codes, no mode awareness, full-screen captures on every
12 poll. mux owns the PTY and runs an authoritative server-side ghostty-vt
13 emulator, so it can hand an agent the signals tmux structurally cannot:
14 command boundaries with exit codes, wait-for-return semantics, structured
15 screen state, and token-efficient diffs — locally and over QUIC.
16
17 Goal: an agent (Claude via Bash, or any LLM harness) drives interactive
18 programs in a mux session and *knows* when a program has returned, with its
19 exit code, without polling or guessing.
20
21 ## Non-goals (v1)
22
23 - No MCP server. Agents drive the standalone CLI via their shell tool; an
24 MCP wrapper can be layered later without protocol changes.
25 - No read-only / scoped auth. The existing model stands: unix socket gated
26 by filesystem permissions, QUIC by the shared PSK key, one key = full
27 control. A capability-scoped or observer-only role is a noted follow-up.
28 - No multi-session listing or management. One daemon = one session stands.
29 - No semantic event *subscriptions* beyond `cmd_state` push (no "prompt
30 detected" pub/sub surface).
31
32 ## Design
33
34 Three components: command-boundary detection in the daemon, three new
35 protocol frame pairs, and a standalone agent binary `muxa`.
36
37 ### 1. Command-boundary detection (daemon-side)
38
39 **Shell integration, auto-injected.** muxd forks the session shell itself,
40 so it injects OSC 133 integration at spawn with no rc-file edits:
41
42 - zsh: point `ZDOTDIR` at a mux-owned shim directory whose `.zshrc` sources
43 the integration, restores the user's original `ZDOTDIR`, then sources the
44 user's real `.zshrc`.
45 - bash: launch with `--init-file <shim>`; the shim sources the user's normal
46 startup files first, then the integration.
47 - fish: prepend a mux directory to `XDG_DATA_DIRS`; fish auto-sources
48 `fish/vendor_conf.d/*.fish`.
49
50 Scripts are vendored from ghostty's shell-integration (battle-tested against
51 multi-line prompts, Ctrl-C at an empty prompt, prompt redraws), trimmed to
52 the OSC 133 marks mux consumes. Opt-out: `MUX_SHELL_INTEGRATION=0`,
53 necessarily read from the *daemon's* environment (the daemon sets the
54 child's env). Detection is by basename of the shell being exec'd (from
55 `--shell`/`$SHELL`; mux execs the shell directly, no login `-` prefix, so
56 bash `--init-file` is safe); unknown shells get no injection and rely on
57 fallbacks. Two mechanics: `Pty.spawn` builds a one-element argv today —
58 injection extends it via the existing `spawnArgv`; and "restore the user's
59 original `ZDOTDIR`" means the shim exports the `ZDOTDIR` captured at spawn
60 if one was set, else *unsets* it — a daemon started from ssh/scripts often
61 carries no user `ZDOTDIR` at all, and exporting an empty one would break
62 zsh's fallback to `$HOME`.
63
64 **Mark interception.** ghostty-vt already parses OSC 133 including the
65 `err` exit code, but its stock `stream_terminal.Handler` discards the code
66 and exposes no semantic-prompt callback. The mechanism: `vt.Stream(H)` is
67 generic over the handler, so mux defines its own handler that wraps the
68 stock one, intercepts `.semantic_prompt` in `vt()`, and forwards everything
69 else — no byte-stream scanning, no dep patch. The engine's hardcoded
70 `vt.TerminalStream` becomes `vt.Stream(MuxHandler)`. The handler drives a
71 per-session command state machine:
72
73 at_prompt --(133;C, record start_row)--> running
74 running --(133;D;code, record end_row)--> returned(code)
75 returned --(133;A or 133;C)--> at_prompt / running
76
77 **Output spans are rows, not seqs.** The codebase's `seq` is a viewport
78 delta generation (bumped once per diff pass over the viewport), not a byte
79 or row offset — a whole command's output can share one seq, and rows lose
80 their seq once scrolled into history. So marks record absolute screen-space
81 rows (`historyRows() + cursor.y` at mark time), and output recovery is the
82 existing `fetch_scrollback` (start row + count), unchanged. Caveats stated
83 plainly: row spans are meaningless while the alt screen is active, and row
84 indices shift once the scrollback ring prunes — consumers should fetch
85 spans promptly after `returned`. `seq` is still used, but only for await
86 ordering ("a return at seq >= since_seq"), defined as the tracker seq
87 after the post-feed update so it can't be off-by-one against the rows of
88 the same PTY chunk.
89
90 **Fallback stack.** The daemon always knows its detection regime and reports
91 it in every reply:
92
93 1. `marks` — OSC 133 seen recently: exit codes + exact output spans.
94 2. `pgid` — no marks (nested ssh, docker exec, unknown shell): the daemon
95 polls `tcgetpgrp` on the PTY master only while an await is outstanding;
96 fg pgid returning to the shell's pgid means the foreground job returned.
97 No exit code, no exact span.
98 3. `settle` — last resort: no output for N ms (N from the await request).
99
100 Regime selection: marks are trusted if the current command was opened by a
101 `133;C`; otherwise pgid; settle only if explicitly requested as a floor or
102 pgid is unavailable.
103
104 ### 2. Protocol additions
105
106 Three frame pairs on the existing non-exhaustive `MsgType` enum(u8) — they
107 transit `muxd proxy` untouched (`proxy` is genuinely frame-agnostic), and
108 old clients tolerate an unexpected push. Values: client→daemon `await_req`
109 0x09, `status_req` 0x0a; daemon→client `cmd_state` 0x8a, `await_reply`
110 0x8b, `status_reply` 0x8c (leaving the existing 0x83 hole alone). Payloads
111 follow house style: explicit `encode*/decode*` helpers over fixed
112 little-endian buffers, `packed struct(u8)` only for flag bytes.
113
114 - `cmd_state` (daemon→client, pushed): emitted on every state-machine
115 transition to attached clients. Carries: state (running | returned),
116 exit code (when known), start_row/end_row, seq at transition, detection
117 mechanism. Pushes exist only in the marks regime — pgid/settle
118 transitions are observed only while an await is outstanding and surface
119 via `await_reply`.
120 - `await_req` (client→daemon) / `await_reply` (daemon→client): "wake me
121 when a command returns after `since_seq`; if `settle_ms` is nonzero,
122 also resolve after that much output silence." If a return already
123 happened past `since_seq`, the daemon replies immediately — this is
124 what makes re-issuing an await after a reconnect safe. The daemon holds
125 the await server-side — no client polling, identical behaviour over
126 unix socket and QUIC. Awaits fit the existing single poll pump without
127 blocking: their deadlines (settle, timeout, pgid poll cadence) fold into
128 the loop's `wait_ms` computation the same way the QUIC `timeoutMs`
129 already does. Reply carries the same payload as `cmd_state` plus a
130 reason (returned | settled | timeout).
131 - `status_req` / `status_reply`: one structured snapshot — cols/rows,
132 cursor position, alt-screen flag, pty_mode bits (icanon/echo), current
133 command state, detection regime, current stream seq.
134
135 Attach, input, resize, snapshot, delta are unchanged; an agent is a normal
136 attached client — with one load-bearing rule: **`muxa` attaches with
137 cols=rows=0**. Attaching at a real size would claim the grid and resize the
138 human's session, and every `input` frame re-claims at the sender's size.
139 The 0x0 escape hatch already exists (`applySize` refuses cols<2, and a 0x0
140 slot "makes no claim" in `claimGrid`); the spec makes it a contract.
141 Multiple outstanding awaits (e.g. agent + test harness) are each answered.
142
143 ### 3. `muxa` — standalone agent binary
144
145 A separate binary in this repo, reusing `protocol.zig`, `quic_client.zig`,
146 and `xdg.zig` key resolution. Same connection flags as `mux`: `--sock PATH`,
147 `quic://host:port` targets, `--key` / `$MUX_KEY_FILE` / XDG default.
148 Remote-over-QUIC is in scope from day one; every verb works identically
149 against a local socket and a WAN daemon.
150
151 All verbs print a single JSON object on stdout; exit code 0 on success,
152 nonzero with a JSON error object on failure.
153
154 - `muxa run "make test"` — flagship. Sends the command line (plus newline),
155 awaits return, prints `{exit_code, output, mechanism, duration_ms,
156 start_row, end_row}`. `--settle N` sets the fallback floor; `--timeout N`
157 bounds the wait (nonzero exit, state reported, on expiry).
158 - `muxa send "keys"` — raw bytes to the PTY (escapes for control keys),
159 no waiting. The TUI-driving path.
160 - `muxa capture [--diff-since SEQ] [--vt]` — current grid as text (or with
161 SGR), or the delta rows since SEQ with the new seq. Token-efficient
162 re-reads for TUI driving.
163 - `muxa status` — the status_reply as JSON.
164 - `muxa await [--settle N] [--timeout N]` — wait without sending, for
165 commands typed by a human or another agent.
166
167 TUI driving composes `send` + `status` (alt-screen flag answers "am I in a
168 TUI") + `capture --diff-since`. `run` is for shell command lines only; its
169 JSON reports `mechanism` so the agent knows whether `exit_code` is real
170 (`marks`) or absent (`pgid` / `settle`).
171
172 ### Ephemeral sessions (first-class use case)
173
174 Agents often want a throwaway session, not an attachment to a standing one:
175 spawn a TUI fresh, poke it, read the screen, quit, tear down. (Field
176 specimen: a Claude session hand-rolled a Python `pty.fork` driver with
177 sleep-based drains writing raw VT bytes to a file — a worse muxd in 30
178 lines.) The workflow composes from existing verbs and is blessed and tested
179 as such, not given new machinery:
180
181 muxd start --sock <tmp> -- <program> # throwaway daemon
182 muxa send/await/capture ... # drive it
183 muxd stop --sock <tmp> # reap it
184
185 Against a session whose root process is a TUI rather than a shell, no marks
186 ever appear: the regime is pgid/settle from frame one, `status` says so,
187 and the program quitting surfaces as the existing `exit_status` frame —
188 `muxa` verbs report it as a structured "session ended" result rather than a
189 transport error. A single-shot `muxa drive -- CMD` wrapper is deferred; the
190 composition covers it.
191
192 ## Error handling
193
194 - Daemon without the new frames (version skew): old daemons silently drop
195 unknown frames, so there is no faster signal than the client-side
196 timeout — the timeout *is* the detection, and `muxa` reports it as a
197 structured "no reply — daemon too old or hung" error. (House doctrine is
198 lockstep binaries, not wire compatibility; this is a courtesy error, not
199 a compat promise.)
200 - Injection failure (shim unwritable, unknown shell): session starts
201 normally without marks; regime degrades to pgid and `status` says so.
202 - Marks from a lying/nested program: the state machine only trusts `133;D`
203 that closes a seen `133;C`; stray marks reset to at_prompt.
204 - QUIC drop mid-await: `muxa` carries its own small reconnect loop over
205 `quic_client.Client` (whose connect/send/pump API is cleanly reusable;
206 the *existing* reconnect path is entangled with the attach client's
207 replica/raw-terminal loop and is not reused). An await outstanding
208 across a reconnect is re-issued with the original `since_seq`, so a
209 return that landed during the gap is answered immediately, not missed.
210
211 ## Testing
212
213 House fixture style (`test/wsclient.zig` precedent):
214
215 - State-machine unit tests: scripted byte streams with OSC 133 marks
216 (normal exit, signal death 128+n, Ctrl-C at empty prompt, nested/stray
217 marks, interleaved output).
218 - Injection tests per shell: spawn zsh/bash/fish under the daemon, assert
219 marks appear and user rc still runs (PATH/prompt sentinel).
220 - Fallback tests: shell with integration disabled — `run` resolves via
221 pgid; raw `cat` session — resolves via settle.
222 - `muxa` end-to-end over unix socket: `run` returns real exit codes
223 (`true`/`false`/`sleep`), `capture --diff-since` matches `expectgrid`
224 fixtures.
225 - One QUIC end-to-end: `muxa run` against a daemon on `quic://127.0.0.1`.
226 - Ephemeral TUI end-to-end: `muxd start -- <tui fixture>`, `muxa send` a
227 key, `await --settle`, `capture` asserts the rendered grid, quit,
228 `muxa` reports session ended via exit_status.
229 - Version-skew test: `muxa status` against an old-protocol daemon reports
230 the structured error.
231
232 Per house doctrine, pinned regression tests go before any test the same
233 hang could wedge.
234
235 ## Follow-ups (explicitly deferred)
236
237 - MCP server wrapping `muxa` semantics.
238 - Read-only / capability-scoped auth (observer role for watching agents).
239 - Input attribution (marking agent-injected input so attached humans can
240 see who typed).
241 - Semantic event subscriptions (prompt-detected, alt-screen-entered) for
242 observer agents.
src/client.zig
Old New
@@ -11,6 +11,7 @@ const Replica = @import("replica").Replica;
11 const proto = @import("protocol"); 11 const proto = @import("protocol");
12 const TmpDir = @import("testtmp").TmpDir; 12 const TmpDir = @import("testtmp").TmpDir;
13 const quic_client = @import("quic_client"); 13 const quic_client = @import("quic_client");
14 const quic = @import("quic");
14 const predict = @import("predict"); 15 const predict = @import("predict");
15 const handoff = @import("handoff"); 16 const handoff = @import("handoff");
16 // Named `paint_mod` because paintOverlay holds a local ArrayList called 17 // Named `paint_mod` because paintOverlay holds a local ArrayList called
@@ -257,7 +258,7 @@ pub const Transport = struct {
257 .hand => |h| return openHandoff(alloc, h, carry, abort_fd), 258 .hand => |h| return openHandoff(alloc, h, carry, abort_fd),
258 .quic => |q| { 259 .quic => |q| {
259 const key = try quic_client.Key.load(q.key_path); 260 const key = try quic_client.Key.load(q.key_path);
260 const addr = try parseQuicAddr(q.host_port); 261 const addr = try quic.parseAddr(alloc, q.host_port);
261 return quicTransport(alloc, addr, key, q.idle_ms, q.deadline_ms, carry, abort_fd); 262 return quicTransport(alloc, addr, key, q.idle_ms, q.deadline_ms, carry, abort_fd);
262 }, 263 },
263 .via => |cmd| return pipeTransport(try spawnPipe(alloc, cmd)), 264 .via => |cmd| return pipeTransport(try spawnPipe(alloc, cmd)),
@@ -372,7 +373,7 @@ pub const Transport = struct {
372 carry: ?*std.ArrayList(u8), 373 carry: ?*std.ArrayList(u8),
373 abort_fd: std.posix.fd_t, 374 abort_fd: std.posix.fd_t,
374 ) !Transport { 375 ) !Transport {
375 const addr = try resolveHost(handoff.dialHost(h.host), ep.port); 376 const addr = try quic.resolveHost(alloc, handoff.dialHost(h.host), ep.port);
376 const key = quic_client.Key{ .bytes = ep.key }; 377 const key = quic_client.Key{ .bytes = ep.key };
377 return quicTransport(alloc, addr, key, h.idle_ms, h.deadline_ms, carry, abort_fd); 378 return quicTransport(alloc, addr, key, h.idle_ms, h.deadline_ms, carry, abort_fd);
378 } 379 }
@@ -677,43 +678,6 @@ fn readAnnounceAbortable(
677 } 678 }
678 } 679 }
679 680
680 /// `HOST:PORT` for a `quic://` target. Literal addresses only on the muxd
681 /// side because a bind address that resolves to several is a question; here
682 /// a NAME is exactly what a user types, so this one does resolve.
683 fn parseQuicAddr(host_port: []const u8) !std.net.Address {
684 // `[::1]` — bracketed, portless: the brackets say where the address
685 // stops, so the port can default.
686 if (host_port.len >= 2 and host_port[0] == '[' and host_port[host_port.len - 1] == ']')
687 return resolveHost(host_port[1 .. host_port.len - 1], quic_client.default_port);
688 const colon = std.mem.lastIndexOfScalar(u8, host_port, ':') orelse
689 return resolveHost(host_port, quic_client.default_port);
690 var host = host_port[0..colon];
691 const port_s = host_port[colon + 1 ..];
692 // `[::1]:4433` — brackets are how an IPv6 literal says where it stops.
693 if (host.len >= 2 and host[0] == '[' and host[host.len - 1] == ']') {
694 host = host[1 .. host.len - 1];
695 } else if (std.mem.indexOfScalar(u8, host, ':') != null) {
696 // Unbracketed and full of colons: an IPv6 literal missing its
697 // brackets, which would otherwise have its last group taken as a
698 // port. Refused rather than guessed at.
699 return error.MalformedAddress;
700 }
701 const port = std.fmt.parseInt(u16, port_s, 10) catch return error.MalformedAddress;
702 return resolveHost(host, port);
703 }
704
705 /// A host that is already known to be unambiguous, plus the port it goes
706 /// with: literal if it parses as one, resolved if it does not.
707 fn resolveHost(host: []const u8, port: u16) !std.net.Address {
708 if (host.len == 0) return error.MalformedAddress;
709 if (std.net.Address.parseIp(host, port)) |addr| return addr else |_| {}
710 // Not a literal: resolve it. A remote host is normally a name.
711 const list = try std.net.getAddressList(std.heap.page_allocator, host, port);
712 defer list.deinit();
713 if (list.addrs.len == 0) return error.UnknownHostName;
714 return list.addrs[0];
715 }
716
717 /// Whether a failed handoff failed at the announce — meaning ssh itself 681 /// Whether a failed handoff failed at the announce — meaning ssh itself
718 /// worked and what came back was not the line we needed — rather than 682 /// worked and what came back was not the line we needed — rather than
719 /// before it. 683 /// before it.
@@ -2360,21 +2324,6 @@ test "drainStdinForQuit: closed stdin still paces the retry instead of spinning"
2360 try std.testing.expect(elapsed_ms >= 150); 2324 try std.testing.expect(elapsed_ms >= 150);
2361 } 2325 }
2362 2326
2363 test "parseQuicAddr: no port means 4433, explicit port wins" {
2364 // 4433 spelled out, not `quic_client.default_port`: asserting against
2365 // the constant the code under test reads would hold for any value, so
2366 // it could never catch the number changing — and this is precisely the
2367 // number the daemon must agree with.
2368 const d = try parseQuicAddr("127.0.0.1");
2369 try std.testing.expectEqual(@as(u16, 4433), d.getPort());
2370 const e = try parseQuicAddr("127.0.0.1:9");
2371 try std.testing.expectEqual(@as(u16, 9), e.getPort());
2372 const b = try parseQuicAddr("[::1]");
2373 try std.testing.expectEqual(@as(u16, 4433), b.getPort());
2374 // Unbracketed IPv6 stays ambiguous and refused, with or without ports.
2375 try std.testing.expectError(error.MalformedAddress, parseQuicAddr("fe80::1:4433"));
2376 }
2377
2378 test "lostMsg: only a --via transport that never connected gets the new wording" { 2327 test "lostMsg: only a --via transport that never connected gets the new wording" {
2379 // The case the message exists for: a command that failed to start. It 2328 // The case the message exists for: a command that failed to start. It
2380 // names what happened and guesses no cause — ssh's own stderr passes 2329 // names what happened and guesses no cause — ssh's own stderr passes
src/cmd.zig
Old New
@@ -0,0 +1,153 @@
1 //! The session's command state machine: MarkEvents in, transitions out.
2 //! Pure — no I/O, no clock, no seq. The server stamps seqs and decides who
3 //! hears about a transition; this module only decides what the marks mean.
4 //! Trust rule (spec): a D only counts if it closes a seen C; a stray D
5 //! resets to at_prompt rather than being believed. A nested C is believed
6 //! wholesale — latest wins, no attempt to detect the nesting — and a stray
7 //! A at idle is a no-op.
8 const std = @import("std");
9 const proto = @import("protocol");
10 const Engine = @import("engine").Engine;
11
12 pub const Tracker = struct {
13 phase: proto.CmdPhase = .at_prompt,
14 /// Sticky: once any C has been seen, this session speaks marks and the
15 /// pgid fallback stops being consulted while a command is open.
16 marks_seen: bool = false,
17 start_row: u32 = 0,
18 end_row: u32 = 0,
19 exit_code: ?u8 = null,
20
21 /// `.reset` means the tracker forcibly settled to `at_prompt`; it is a
22 /// state change to observe, never a transition to broadcast.
23 pub const Transition = enum { running, returned, reset };
24
25 pub fn apply(self: *Tracker, ev: Engine.MarkEvent) ?Transition {
26 switch (ev.kind) {
27 .command_start => {
28 self.marks_seen = true;
29 self.phase = .running;
30 self.start_row = ev.row;
31 // The new command has not ended, so it has no end row — and
32 // the PREVIOUS command's would be a lie about this one,
33 // read as a span running backwards (end < start) by anyone
34 // who fetched it mid-run. Collapsing it to the start row
35 // says what is true under the end<=start convention: the
36 // span is empty until a D closes it.
37 self.end_row = ev.row;
38 self.exit_code = null;
39 return .running;
40 },
41 .command_end => {
42 if (self.phase != .running) {
43 // A D with no open C: a nested program echoing marks it
44 // has no business emitting. Reset, believe nothing.
45 self.phase = .at_prompt;
46 return .reset;
47 }
48 self.phase = .returned;
49 self.end_row = ev.row;
50 self.exit_code = ev.exit_code;
51 return .returned;
52 },
53 .prompt_start => {
54 // 'A' after a return is the prompt redrawing: back to rest.
55 // 'A' mid-run (Ctrl-C redraw) also lands here — the shell
56 // is telling us the command is over even without a D.
57 if (self.phase == .running) {
58 self.phase = .returned;
59 self.end_row = ev.row;
60 self.exit_code = null; // interrupted: no honest code
61 return .returned;
62 }
63 self.phase = .at_prompt;
64 return null;
65 },
66 }
67 }
68
69 /// True while marks say a command is open — the window in which the
70 /// pgid fallback must NOT race the marks to a verdict.
71 pub fn marksOpen(self: *const Tracker) bool {
72 return self.marks_seen and self.phase == .running;
73 }
74 };
75
76 test "C then D is running then returned, with rows and code" {
77 var t = Tracker{};
78 try std.testing.expectEqual(@as(?Tracker.Transition, .running), t.apply(.{ .kind = .command_start, .row = 10, .exit_code = null }));
79 try std.testing.expectEqual(proto.CmdPhase.running, t.phase);
80 try std.testing.expectEqual(@as(?Tracker.Transition, .returned), t.apply(.{ .kind = .command_end, .row = 14, .exit_code = 1 }));
81 try std.testing.expectEqual(proto.CmdPhase.returned, t.phase);
82 try std.testing.expectEqual(@as(u32, 10), t.start_row);
83 try std.testing.expectEqual(@as(u32, 14), t.end_row);
84 try std.testing.expectEqual(@as(?u8, 1), t.exit_code);
85 }
86
87 test "a stray D resets and is not believed" {
88 var t = Tracker{};
89 try std.testing.expectEqual(@as(?Tracker.Transition, .reset), t.apply(.{ .kind = .command_end, .row = 3, .exit_code = 0 }));
90 try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase);
91 try std.testing.expectEqual(@as(?u8, null), t.exit_code);
92 }
93
94 test "A closes an open command without a code (Ctrl-C at a prompt redraw)" {
95 var t = Tracker{};
96 _ = t.apply(.{ .kind = .command_start, .row = 5, .exit_code = null });
97 try std.testing.expectEqual(@as(?Tracker.Transition, .returned), t.apply(.{ .kind = .prompt_start, .row = 6, .exit_code = null }));
98 try std.testing.expectEqual(@as(?u8, null), t.exit_code);
99 try std.testing.expectEqual(proto.CmdPhase.returned, t.phase);
100 try std.testing.expectEqual(@as(u32, 6), t.end_row);
101 // The next A settles back to rest with no transition.
102 try std.testing.expectEqual(@as(?Tracker.Transition, null), t.apply(.{ .kind = .prompt_start, .row = 6, .exit_code = null }));
103 try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase);
104 }
105
106 test "marksOpen guards the pgid race window" {
107 var t = Tracker{};
108 try std.testing.expect(!t.marksOpen());
109 _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null });
110 try std.testing.expect(t.marksOpen());
111 _ = t.apply(.{ .kind = .command_end, .row = 1, .exit_code = 0 });
112 try std.testing.expect(!t.marksOpen());
113 // Sticky across the next prompt: the session still speaks marks.
114 _ = t.apply(.{ .kind = .prompt_start, .row = 1, .exit_code = null });
115 try std.testing.expect(t.marks_seen);
116 }
117
118 test "back-to-back commands: second C reopens cleanly" {
119 var t = Tracker{};
120 _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null });
121 _ = t.apply(.{ .kind = .command_end, .row = 2, .exit_code = 0 });
122 try std.testing.expectEqual(@as(?Tracker.Transition, .running), t.apply(.{ .kind = .command_start, .row = 4, .exit_code = null }));
123 try std.testing.expectEqual(@as(u32, 4), t.start_row);
124 try std.testing.expectEqual(@as(?u8, null), t.exit_code);
125 // Nothing of the finished command survives into the running one. The
126 // end row especially: left at 2 it would describe a span ending BEFORE
127 // it starts, which is what a mid-command status_reply hands an agent.
128 // Equal rows are the empty span every consumer already reads as "no
129 // output yet".
130 try std.testing.expectEqual(@as(u32, 4), t.end_row);
131 try std.testing.expect(t.end_row <= t.start_row);
132 }
133
134 test "a nested C while running is believed wholesale: latest wins" {
135 var t = Tracker{};
136 _ = t.apply(.{ .kind = .command_start, .row = 5, .exit_code = null });
137 try std.testing.expectEqual(@as(?Tracker.Transition, .running), t.apply(.{ .kind = .command_start, .row = 9, .exit_code = null }));
138 try std.testing.expectEqual(@as(u32, 9), t.start_row);
139 try std.testing.expectEqual(@as(?u8, null), t.exit_code);
140 }
141
142 test "a stray D after a completed command resets phase but never absorbs its payload" {
143 var t = Tracker{};
144 _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null });
145 _ = t.apply(.{ .kind = .command_end, .row = 2, .exit_code = 1 });
146 try std.testing.expectEqual(@as(?Tracker.Transition, .reset), t.apply(.{ .kind = .command_end, .row = 9, .exit_code = 7 }));
147 try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase);
148 // The stray's row and exit code never land: the finished command's
149 // fields are left exactly as the real D set them.
150 try std.testing.expectEqual(@as(?u8, 1), t.exit_code);
151 try std.testing.expectEqual(@as(u32, 0), t.start_row);
152 try std.testing.expectEqual(@as(u32, 2), t.end_row);
153 }
src/engine.zig
Old New
@@ -3,14 +3,95 @@
3 const std = @import("std"); 3 const std = @import("std");
4 const vt = @import("ghostty-vt"); 4 const vt = @import("ghostty-vt");
5 5
6 /// MuxHandler's own `vt` method shadows the `vt` import inside its body,
7 /// so the dep types it names are spelled through these aliases.
8 const StockHandler = vt.TerminalStream.Handler;
9 const StreamAction = vt.StreamAction;
10
11 /// The stock ghostty-vt handler forwards OSC 133 into the terminal and
12 /// drops the exit code on the floor; there is no semantic-prompt callback
13 /// in its Effects. So mux brings its own handler: intercept the one action
14 /// we care about, forward everything (including that one) to the stock
15 /// handler so terminal state stays identical.
16 pub const MuxHandler = struct {
17 inner: StockHandler,
18
19 pub fn deinit(self: *MuxHandler) void {
20 self.inner.deinit();
21 }
22
23 pub fn vt(
24 self: *MuxHandler,
25 comptime action: StreamAction.Tag,
26 value: StreamAction.Value(action),
27 ) void {
28 if (comptime action == .semantic_prompt) self.onSemanticPrompt(value);
29 self.inner.vt(action, value);
30 }
31
32 fn engineOf(self: *MuxHandler) *Engine {
33 const stream_ptr: *MuxStream = @fieldParentPtr("handler", self);
34 // @alignCast for wasm32, for the reason spelled out on onWritePty.
35 return @alignCast(@fieldParentPtr("stream", stream_ptr));
36 }
37
38 fn onSemanticPrompt(
39 self: *MuxHandler,
40 value: StreamAction.Value(.semantic_prompt),
41 ) void {
42 const kind: Engine.MarkEvent.Kind = switch (value.action) {
43 .fresh_line_new_prompt => .prompt_start, // 'A'
44 .end_input_start_output => .command_start, // 'C'
45 .end_command => .command_end, // 'D'
46 else => return, // L/N/P/B/I: prompt furniture, not boundaries
47 };
48 const eng = self.engineOf();
49 // Only `D` carries a code, and even then only when the shell put one
50 // in the mark; everything else has none to read.
51 const raw = if (kind == .command_end) value.readOption(.exit_code) else null;
52 // Masked to the low byte, which is what waitpid would have reported:
53 // a shell is free to spell `D;300`, and truncating is the same answer
54 // the kernel gives rather than a refusal to parse.
55 const exit_code: ?u8 = if (raw) |code| @intCast(@as(u32, @bitCast(code)) & 0xff) else null;
56 // Load-bearing catch: under OOM we drop the mark rather than fail
57 // the feed. A dropped mark costs precision, not correctness —
58 // await falls back to pgid/settle when no boundary arrives.
59 eng.mark_events.append(eng.alloc, .{
60 .kind = kind,
61 .row = eng.historyRows() + eng.cursorPos().y,
62 .exit_code = exit_code,
63 }) catch {};
64 }
65 };
66
67 pub const MuxStream = vt.Stream(MuxHandler);
68
6 pub const Engine = struct { 69 pub const Engine = struct {
7 alloc: std.mem.Allocator, 70 alloc: std.mem.Allocator,
8 term: vt.Terminal, 71 term: vt.Terminal,
9 stream: vt.TerminalStream, 72 stream: MuxStream,
10 /// Response bytes the terminal wants written back to the PTY 73 /// Response bytes the terminal wants written back to the PTY
11 /// (cursor position reports, device attributes, ...). Owner drains 74 /// (cursor position reports, device attributes, ...). Owner drains
12 /// via ptyOutput()/clearPtyOutput(). 75 /// via ptyOutput()/clearPtyOutput().
13 pty_out: std.ArrayList(u8), 76 pty_out: std.ArrayList(u8),
77 /// OSC 133 mark events observed since the last clear. Drained by the
78 /// server after each feed, exactly like pty_out.
79 mark_events: std.ArrayList(MarkEvent),
80
81 pub const MarkEvent = struct {
82 pub const Kind = enum(u8) { prompt_start, command_start, command_end };
83 kind: Kind,
84 /// Absolute screen-space row (historyRows + cursor.y) at mark time.
85 /// Best-effort locator, not a durable anchor: pruning past
86 /// max_scrollback shifts the origin (a command longer than the
87 /// scrollback can even leave end_row < start_row), resize reflow
88 /// renumbers history, and alt-screen marks live in a different
89 /// coordinate space entirely (historyRows() is 0 there). Use it to
90 /// point a human at output, never to key durable state.
91 row: u32,
92 /// Only ever set on command_end, and only when the mark carried one.
93 exit_code: ?u8,
94 };
14 95
15 pub const Options = struct { 96 pub const Options = struct {
16 cols: u16, 97 cols: u16,
@@ -33,16 +114,18 @@ pub const Engine = struct {
33 }), 114 }),
34 .stream = undefined, 115 .stream = undefined,
35 .pty_out = .empty, 116 .pty_out = .empty,
117 .mark_events = .empty,
36 }; 118 };
37 errdefer self.term.deinit(alloc); 119 errdefer self.term.deinit(alloc);
38 120
39 self.stream = .initAlloc(alloc, .{ .terminal = &self.term }); 121 self.stream = .initAlloc(alloc, .{ .inner = .{ .terminal = &self.term } });
40 self.stream.handler.effects.write_pty = &onWritePty; 122 self.stream.handler.inner.effects.write_pty = &onWritePty;
41 return self; 123 return self;
42 } 124 }
43 125
44 pub fn deinit(self: *Engine) void { 126 pub fn deinit(self: *Engine) void {
45 self.pty_out.deinit(self.alloc); 127 self.pty_out.deinit(self.alloc);
128 self.mark_events.deinit(self.alloc);
46 self.stream.deinit(); 129 self.stream.deinit();
47 self.term.deinit(self.alloc); 130 self.term.deinit(self.alloc);
48 self.alloc.destroy(self); 131 self.alloc.destroy(self);
@@ -60,6 +143,14 @@ pub const Engine = struct {
60 self.pty_out.clearRetainingCapacity(); 143 self.pty_out.clearRetainingCapacity();
61 } 144 }
62 145
146 pub fn markEvents(self: *const Engine) []const MarkEvent {
147 return self.mark_events.items;
148 }
149
150 pub fn clearMarkEvents(self: *Engine) void {
151 self.mark_events.clearRetainingCapacity();
152 }
153
63 /// Visible screen as plain UTF-8 text. Caller frees. 154 /// Visible screen as plain UTF-8 text. Caller frees.
64 pub fn dumpPlain(self: *Engine, alloc: std.mem.Allocator) ![]const u8 { 155 pub fn dumpPlain(self: *Engine, alloc: std.mem.Allocator) ![]const u8 {
65 return self.term.plainString(alloc); 156 return self.term.plainString(alloc);
@@ -224,7 +315,8 @@ pub const Engine = struct {
224 } 315 }
225 316
226 fn onWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void { 317 fn onWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void {
227 const stream_ptr: *vt.TerminalStream = @fieldParentPtr("handler", handler); 318 const mh: *MuxHandler = @fieldParentPtr("inner", handler);
319 const stream_ptr: *MuxStream = @fieldParentPtr("handler", mh);
228 // The @alignCast is for wasm32, where pointers default to 4-byte 320 // The @alignCast is for wasm32, where pointers default to 4-byte
229 // alignment while Engine needs 8. Sound: the parent really is 321 // alignment while Engine needs 8. Sound: the parent really is
230 // 8-aligned — every Engine comes from alloc.create (init's 322 // 8-aligned — every Engine comes from alloc.create (init's
@@ -490,3 +582,112 @@ test "Engine: resize" {
490 defer e.deinit(); 582 defer e.deinit();
491 try e.resize(120, 40); 583 try e.resize(120, 40);
492 } 584 }
585
586 test "Engine: OSC 133 marks surface as events with rows and exit codes" {
587 const alloc = std.testing.allocator;
588 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
589 defer e.deinit();
590
591 e.feed("$ "); // a prompt on row 0
592 e.feed("\x1b]133;C\x07"); // command starts
593 e.feed("output line\r\n");
594 e.feed("\x1b]133;D;1\x07"); // command returns, exit 1
595 e.feed("\x1b]133;A\x07"); // next prompt
596
597 const evs = e.markEvents();
598 try std.testing.expectEqual(@as(usize, 3), evs.len);
599
600 try std.testing.expectEqual(Engine.MarkEvent.Kind.command_start, evs[0].kind);
601 try std.testing.expectEqual(@as(u32, 0), evs[0].row);
602 try std.testing.expectEqual(@as(?u8, null), evs[0].exit_code);
603
604 try std.testing.expectEqual(Engine.MarkEvent.Kind.command_end, evs[1].kind);
605 try std.testing.expectEqual(@as(u32, 1), evs[1].row); // cursor moved past the output line
606 try std.testing.expectEqual(@as(?u8, 1), evs[1].exit_code);
607
608 try std.testing.expectEqual(Engine.MarkEvent.Kind.prompt_start, evs[2].kind);
609
610 e.clearMarkEvents();
611 try std.testing.expectEqual(@as(usize, 0), e.markEvents().len);
612 }
613
614 test "Engine: mark rows are absolute screen rows, not viewport rows" {
615 const alloc = std.testing.allocator;
616 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
617 defer e.deinit();
618
619 // Scroll 100 lines into history, then mark: the row must include them.
620 var i: usize = 1;
621 while (i <= 100) : (i += 1) {
622 var line: [32]u8 = undefined;
623 e.feed(std.fmt.bufPrint(&line, "line-{d}\r\n", .{i}) catch unreachable);
624 }
625 const hist = e.historyRows(); // 77 per the historyRows test
626 e.feed("\x1b]133;C\x07");
627 const evs = e.markEvents();
628 try std.testing.expectEqual(@as(usize, 1), evs.len);
629 try std.testing.expectEqual(hist + e.cursorPos().y, evs[0].row);
630 }
631
632 test "Engine: a D mark with no exit code yields a null code" {
633 const alloc = std.testing.allocator;
634 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
635 defer e.deinit();
636 e.feed("\x1b]133;C\x07\x1b]133;D\x07");
637 const evs = e.markEvents();
638 try std.testing.expectEqual(@as(usize, 2), evs.len);
639 try std.testing.expectEqual(@as(?u8, null), evs[1].exit_code);
640 }
641
642 test "Engine: intercepting a mark still forwards it to the stock handler" {
643 const alloc = std.testing.allocator;
644 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
645 defer e.deinit();
646
647 // The row the cursor sits on carries no prompt state yet.
648 try std.testing.expectEqual(
649 vt.page.Row.SemanticPrompt.none,
650 e.term.screens.active.cursor.page_row.semantic_prompt,
651 );
652
653 e.feed("\x1b]133;A\x07");
654
655 // MuxHandler.vt must forward every action it intercepts: the stock
656 // handler is what runs Terminal.semanticPrompt, and that is what marks
657 // the cursor's row as a prompt row. Swallowing the action instead of
658 // forwarding it leaves this .none while the event still lands.
659 try std.testing.expectEqual(
660 vt.page.Row.SemanticPrompt.prompt,
661 e.term.screens.active.cursor.page_row.semantic_prompt,
662 );
663 try std.testing.expectEqual(@as(usize, 1), e.markEvents().len);
664 }
665
666 test "Engine: exit codes truncate to a byte and malformed ones are null" {
667 const alloc = std.testing.allocator;
668 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
669 defer e.deinit();
670
671 // The mark's exit code is whatever i32 the shell wrote, and PTY bytes
672 // are attacker-influenced: without the mask, 256 is a Debug-mode
673 // @intCast panic rather than a wrapped byte.
674 e.feed("\x1b]133;C\x07\x1b]133;D;256\x07");
675 try std.testing.expectEqual(@as(?u8, 0), e.markEvents()[1].exit_code);
676 e.clearMarkEvents();
677
678 e.feed("\x1b]133;C\x07\x1b]133;D;-1\x07");
679 try std.testing.expectEqual(@as(?u8, 255), e.markEvents()[1].exit_code);
680 e.clearMarkEvents();
681
682 e.feed("\x1b]133;C\x07\x1b]133;D;notanumber\x07");
683 try std.testing.expectEqual(@as(?u8, null), e.markEvents()[1].exit_code);
684 }
685
686 test "Engine: non-133 OSC and the ignored 133 subcommands emit no events" {
687 const alloc = std.testing.allocator;
688 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
689 defer e.deinit();
690 e.feed("\x1b]0;a title\x07"); // OSC 0, not semantic
691 e.feed("\x1b]133;B\x07\x1b]133;P\x07\x1b]133;L\x07"); // B/P/L: not ours
692 try std.testing.expectEqual(@as(usize, 0), e.markEvents().len);
693 }
src/main.zig
Old New
@@ -293,7 +293,7 @@ pub fn main() !u8 {
293 const sock_path = if (o.sock) |s| 293 const sock_path = if (o.sock) |s|
294 try alloc.dupe(u8, s) 294 try alloc.dupe(u8, s)
295 else 295 else
296 try defaultSockPath(alloc); 296 try sockpath.defaultSockPath(alloc);
297 defer alloc.free(sock_path); 297 defer alloc.free(sock_path);
298 298
299 // The sun_path bound (sockpath.max_sun_path). Checked here, once, 299 // The sun_path bound (sockpath.max_sun_path). Checked here, once,
@@ -436,11 +436,22 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
436 try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh"); 436 try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh");
437 defer alloc.free(shell_z); 437 defer alloc.free(shell_z);
438 438
439 // Read from the DAEMON's environment, necessarily: muxd forks the
440 // session shell, so by the time anyone could pass a flag through a
441 // client the shell has been running for a while. `MUX_SHELL_INTEGRATION=0`
442 // and nothing else — any other value, including unset, means on.
443 const shell_integration = !std.mem.eql(
444 u8,
445 std.posix.getenv("MUX_SHELL_INTEGRATION") orelse "",
446 "0",
447 );
448
439 var srv = Server.init(alloc, .{ 449 var srv = Server.init(alloc, .{
440 .sock_path = sock_path, 450 .sock_path = sock_path,
441 .shell = shell_z, 451 .shell = shell_z,
442 .cols = o.cols, 452 .cols = o.cols,
443 .rows = o.rows, 453 .rows = o.rows,
454 .shell_integration = shell_integration,
444 }) catch |err| switch (err) { 455 }) catch |err| switch (err) {
445 // All of these mean "that path is not ours to take", and all 456 // All of these mean "that path is not ours to take", and all
446 // are ordinary operator mistakes rather than daemon bugs: say 457 // are ordinary operator mistakes rather than daemon bugs: say
@@ -476,13 +487,6 @@ fn run(alloc: std.mem.Allocator, o: Opts, sock_path: []const u8) !u8 {
476 return try srv.run(); 487 return try srv.run();
477 } 488 }
478 489
479 pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 {
480 if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir| {
481 return std.fmt.allocPrint(alloc, "{s}/muxd.sock", .{dir});
482 }
483 return std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()});
484 }
485
486 /// Ask once, print the first reply of the type asked for, exit. `dump` and 490 /// Ask once, print the first reply of the type asked for, exit. `dump` and
487 /// `stats` are this same round-trip and differed only in the verb they 491 /// `stats` are this same round-trip and differed only in the verb they
488 /// name, the frame they send and the frame they wait for. 492 /// name, the frame they send and the frame they wait for.
src/muxa.zig
Old New
@@ -0,0 +1,1574 @@
1 //! muxa: the agent-facing mux client. Every verb prints one JSON object on
2 //! stdout and exits 0 on success; failures print {"error": "..."} and exit
3 //! nonzero. Attaches at 0x0 always — an agent must never claim the grid
4 //! out from under the human's size (load-bearing spec rule).
5 const std = @import("std");
6 const proto = @import("protocol");
7 const sockpath = @import("sockpath");
8 const quic_client = @import("quic_client");
9 const quic = @import("quic");
10 const xdg = @import("xdg");
11
12 const usage =
13 \\usage: muxa <verb> [--sock PATH | --quic HOST[:PORT] [--key PATH]]
14 \\ [--settle MS] [--timeout MS] [--vt] [args]
15 \\verbs:
16 \\ status session snapshot as JSON
17 \\ capture current grid as text (--vt for styled)
18 \\ send BYTES raw bytes to the pty (C-style escapes: \n \r \t \e \xNN)
19 \\ run CMDLINE send CMDLINE + newline, await return, report exit/output
20 \\ await wait for the current/next command to return
21 \\
22 ;
23
24 const Opts = struct {
25 verb: enum { status, capture, send, run, @"await" },
26 sock: ?[]const u8 = null,
27 /// `HOST[:PORT]` of a remote daemon's QUIC listener. The verbs are
28 /// identical over it — same frames, same JSON — which is the whole
29 /// claim: an agent driving a session over a WAN types one more flag.
30 quic: ?[]const u8 = null,
31 /// `--key PATH`, the highest-priority spelling of the QUIC key. Null
32 /// does NOT mean "no key": `$MUX_KEY_FILE` and the XDG default are
33 /// still to be tried, and neither is parse's to look at (xdg.pickKey
34 /// and xdg.resolveKeyPath own that order, as they do for muxd and mux).
35 key: ?[]const u8 = null,
36 settle_ms: u32 = 0,
37 // Never 0 by default: the daemon reads a 0 timeout on await_req as "no
38 // bound at all" (documented on AwaitReq), so a muxa that defaulted to 0
39 // would turn every await into an unbounded wait.
40 timeout_ms: u32 = 30_000,
41 vt: bool = false,
42 arg: ?[]const u8 = null,
43 };
44
45 fn parseArgs(args: []const [:0]const u8) ?Opts {
46 if (args.len < 2) return null;
47 const verb = std.meta.stringToEnum(@FieldType(Opts, "verb"), args[1]) orelse return null;
48 var o: Opts = .{ .verb = verb };
49 var i: usize = 2;
50 // Everything after a bare `--` is the positional argument, whatever it
51 // looks like. Agents send byte-strings for their own reasons, and
52 // `muxa send -- '-n foo\n'` must reach the pty rather than be read as
53 // a flag this binary does not have.
54 var end_of_flags = false;
55 while (i < args.len) : (i += 1) {
56 const a = args[i];
57 if (end_of_flags) {
58 if (o.arg != null) return null;
59 o.arg = a;
60 } else if (std.mem.eql(u8, a, "--")) {
61 end_of_flags = true;
62 } else if (std.mem.eql(u8, a, "--sock")) {
63 i += 1;
64 if (i >= args.len) return null;
65 o.sock = args[i];
66 } else if (std.mem.eql(u8, a, "--quic")) {
67 i += 1;
68 if (i >= args.len) return null;
69 o.quic = args[i];
70 } else if (std.mem.eql(u8, a, "--key")) {
71 i += 1;
72 if (i >= args.len) return null;
73 o.key = args[i];
74 } else if (std.mem.eql(u8, a, "--settle")) {
75 i += 1;
76 if (i >= args.len) return null;
77 o.settle_ms = std.fmt.parseInt(u32, args[i], 10) catch return null;
78 } else if (std.mem.eql(u8, a, "--timeout")) {
79 i += 1;
80 if (i >= args.len) return null;
81 o.timeout_ms = std.fmt.parseInt(u32, args[i], 10) catch return null;
82 } else if (std.mem.eql(u8, a, "--vt")) {
83 o.vt = true;
84 } else if (o.arg == null and a.len > 0 and a[0] != '-') {
85 o.arg = a;
86 } else return null;
87 }
88 // Name ONE transport. A `--sock` silently ignored beside a `--quic`
89 // would send an agent's frames somewhere other than the socket it
90 // named, and the two answers differ — this is the mistake `mux`
91 // refuses as `.conflict` for the same reason.
92 if (o.quic != null and o.sock != null) return null;
93 // A key with nothing to authenticate to, refused exactly where muxd
94 // refuses it: there is no reading of `--key` without `--quic` that
95 // makes it sensible, and the unix socket has no key at all.
96 if (o.key != null and o.quic == null) return null;
97 return o;
98 }
99
100 /// JSON string escape, the six mandatory escapes + control bytes as \u00XX.
101 fn jsonEscape(writer: anytype, s: []const u8) !void {
102 try writer.writeByte('"');
103 for (s) |b| switch (b) {
104 '"' => try writer.writeAll("\\\""),
105 '\\' => try writer.writeAll("\\\\"),
106 '\n' => try writer.writeAll("\\n"),
107 '\r' => try writer.writeAll("\\r"),
108 '\t' => try writer.writeAll("\\t"),
109 0x00...0x08, 0x0b, 0x0c, 0x0e...0x1f => try writer.print("\\u{x:0>4}", .{b}),
110 else => try writer.writeByte(b),
111 };
112 try writer.writeByte('"');
113 }
114
115 test "jsonEscape pins the escapes" {
116 var buf: [128]u8 = undefined;
117 var fbs = std.io.fixedBufferStream(&buf);
118 try jsonEscape(fbs.writer(), "a\"b\\c\nd\x1be");
119 try std.testing.expectEqualStrings("\"a\\\"b\\\\c\\nd\\u001be\"", fbs.getWritten());
120 }
121
122 /// Decode C-style escapes for `send`. Caller frees.
123 fn decodeEscapes(alloc: std.mem.Allocator, s: []const u8) ![]u8 {
124 var out: std.ArrayList(u8) = .empty;
125 errdefer out.deinit(alloc);
126 var i: usize = 0;
127 while (i < s.len) : (i += 1) {
128 if (s[i] != '\\') {
129 try out.append(alloc, s[i]);
130 continue;
131 }
132 // A backslash with nothing after it is an unfinished escape, and it
133 // is refused like any other one we cannot read (\q). Passing it
134 // through as a literal would be the single case where a typo in an
135 // escape reaches the pty instead of being reported.
136 if (i + 1 >= s.len) return error.BadEscape;
137 i += 1;
138 switch (s[i]) {
139 'n' => try out.append(alloc, '\n'),
140 'r' => try out.append(alloc, '\r'),
141 't' => try out.append(alloc, '\t'),
142 'e' => try out.append(alloc, 0x1b),
143 '\\' => try out.append(alloc, '\\'),
144 'x' => {
145 if (i + 2 >= s.len) return error.BadEscape;
146 try out.append(alloc, try std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16));
147 i += 2;
148 },
149 else => return error.BadEscape,
150 }
151 }
152 return out.toOwnedSlice(alloc);
153 }
154
155 test "decodeEscapes covers the sequences send needs" {
156 const alloc = std.testing.allocator;
157 const got = try decodeEscapes(alloc, "q\\n\\e[A\\x03");
158 defer alloc.free(got);
159 try std.testing.expectEqualSlices(u8, "q\n\x1b[A\x03", got);
160 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "\\q"));
161 // A dangling backslash is an escape the caller did not finish writing,
162 // and it is refused rather than passed through as a literal.
163 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "ok\\"));
164 }
165
166 test "parseArgs verbs and flags" {
167 const a1 = [_][:0]const u8{ "muxa", "status" };
168 try std.testing.expectEqual(@FieldType(Opts, "verb").status, parseArgs(&a1).?.verb);
169 const a2 = [_][:0]const u8{ "muxa", "run", "--timeout", "5000", "make test" };
170 const o2 = parseArgs(&a2).?;
171 try std.testing.expectEqual(@as(u32, 5000), o2.timeout_ms);
172 try std.testing.expectEqualStrings("make test", o2.arg.?);
173 const a3 = [_][:0]const u8{ "muxa", "bogus" };
174 try std.testing.expectEqual(@as(?Opts, null), parseArgs(&a3));
175 }
176
177 test "parseArgs: -- hands the rest to the verb, flags and all" {
178 // Without the end-of-flags marker this is an unknown flag and the whole
179 // invocation is refused — the exact shape an agent sends when a key
180 // sequence starts with a dash.
181 const dashed = [_][:0]const u8{ "muxa", "send", "-n foo" };
182 try std.testing.expectEqual(@as(?Opts, null), parseArgs(&dashed));
183
184 const a = [_][:0]const u8{ "muxa", "send", "--settle", "50", "--", "-n foo" };
185 const o = parseArgs(&a).?;
186 try std.testing.expectEqual(@as(u32, 50), o.settle_ms);
187 try std.testing.expectEqualStrings("-n foo", o.arg.?);
188
189 // Past the marker, a flag spelling is just text — and a second
190 // positional is still one too many.
191 const flagish = [_][:0]const u8{ "muxa", "run", "--", "--timeout" };
192 try std.testing.expectEqualStrings("--timeout", parseArgs(&flagish).?.arg.?);
193 const two = [_][:0]const u8{ "muxa", "run", "--", "a", "b" };
194 try std.testing.expectEqual(@as(?Opts, null), parseArgs(&two));
195 }
196
197 test "parseArgs: --quic and --key, and the pairs that make no sense" {
198 const q = [_][:0]const u8{ "muxa", "status", "--quic", "10.0.0.2:4433" };
199 const oq = parseArgs(&q).?;
200 try std.testing.expectEqualStrings("10.0.0.2:4433", oq.quic.?);
201 // Not naming a key is not an error here: MUX_KEY_FILE and the XDG
202 // default are still to be tried, and parse may look at neither.
203 try std.testing.expectEqual(@as(?[]const u8, null), oq.key);
204
205 const k = [_][:0]const u8{ "muxa", "run", "--quic", "box:4433", "--key", "/k", "make test" };
206 const ok = parseArgs(&k).?;
207 try std.testing.expectEqualStrings("box:4433", ok.quic.?);
208 try std.testing.expectEqualStrings("/k", ok.key.?);
209 try std.testing.expectEqualStrings("make test", ok.arg.?);
210
211 // A flag at the end of argv with no value is refused, like every other
212 // value-taking flag this parser has.
213 const dangling_q = [_][:0]const u8{ "muxa", "status", "--quic" };
214 try std.testing.expectEqual(@as(?Opts, null), parseArgs(&dangling_q));
215 const dangling_k = [_][:0]const u8{ "muxa", "status", "--quic", "b:1", "--key" };
216 try std.testing.expectEqual(@as(?Opts, null), parseArgs(&dangling_k));
217
218 // Two transports named at once: which one an agent's frames went to
219 // would be this parser's private business, and it is not entitled to
220 // one — the same refusal `mux` spells as `.conflict`.
221 const both = [_][:0]const u8{ "muxa", "status", "--sock", "/tmp/s", "--quic", "b:1" };
222 try std.testing.expectEqual(@as(?Opts, null), parseArgs(&both));
223
224 // A key with nothing to authenticate to, refused exactly where muxd
225 // refuses it.
226 const lonely_key = [_][:0]const u8{ "muxa", "status", "--key", "/k" };
227 try std.testing.expectEqual(@as(?Opts, null), parseArgs(&lonely_key));
228
229 // Neither named is the ordinary local case and stays silent.
230 const neither = [_][:0]const u8{ "muxa", "status" };
231 try std.testing.expectEqual(@as(?[]const u8, null), parseArgs(&neither).?.quic);
232 }
233
234 /// A live QUIC connection plus everything a REDIAL of it needs. The dial
235 /// coordinates are kept rather than re-derived because the reconnect below
236 /// happens mid-verb, long after argv and the key file have been read: a
237 /// second resolution could pick a different key (the file having been
238 /// rotated under us) and would then fail the handshake for a reason that
239 /// has nothing to do with why the first connection died.
240 const Quic = struct {
241 cl: *quic_client.Client,
242 addr: std.net.Address,
243 key: quic_client.Key,
244 idle_ms: u32,
245 /// Wall-clock milliseconds the FIRST handshake took, which is this
246 /// client's only measurement of how far away the daemon is. `graceMs`
247 /// turns it into the await grace window; see there.
248 connect_ms: i64,
249 /// The one reconnect, spent or not. It lives HERE rather than on Conn
250 /// because only this arm can reconnect: a socket Conn carrying a
251 /// `reconnected` flag would be a field with no reachable true, and the
252 /// guard reading it would be re-establishing in code what the type can
253 /// state outright. (`session_exit` stays on Conn for the mirror
254 /// reason: both arms genuinely set it.)
255 reconnected: bool = false,
256 };
257
258 const Conn = struct {
259 /// Which transport carries the frames. The verbs above this line are
260 /// written once and know nothing about the difference — that is the
261 /// claim `--quic` makes, and this union is where it is kept.
262 link: union(enum) {
263 fd: std.posix.fd_t,
264 quic: Quic,
265 },
266 /// The allocator the transport itself works with: the QUIC arm's frame
267 /// staging and its redials. Distinct from the `alloc` awaitFrame takes,
268 /// which owns the frame handed BACK to the caller — one process, one
269 /// arena, so they are the same allocator today and separate in the
270 /// signature because they answer to different owners.
271 alloc: std.mem.Allocator,
272 /// The code from the `exit_status` frame that ended a wait, set the
273 /// moment awaitFrame returns error.SessionExited. The frame is the
274 /// session's last word and carries the only copy of the code, so it is
275 /// captured here rather than thrown away with the frame; callers read
276 /// it to turn the error into an answer.
277 session_exit: ?u8 = null,
278 /// Why the reconnect could not be made, set the moment a redial fails
279 /// — and set for the same reason `session_exit` is: the error that
280 /// ends the verb is `ConnectionLost`, which is the story's beginning,
281 /// while THIS is how it finished. An agent told only
282 /// `QuicHandshakeFailed` goes and checks its key; an agent told
283 /// `connection lost; reconnect failed: QuicHandshakeFailed` knows the
284 /// path tore mid-wait and the redial could not complete.
285 ///
286 /// An `@errorName`, so this borrows a static string and owns no
287 /// storage. See `waitFailDetail`, which composes the line.
288 reconnect_failure: ?[]const u8 = null,
289
290 fn open(alloc: std.mem.Allocator, sock_path: []const u8) !Conn {
291 const s = try std.net.connectUnixSocket(sock_path);
292 return .{ .link = .{ .fd = s.handle }, .alloc = alloc };
293 }
294
295 /// Dial a daemon's QUIC listener and wait out the handshake before
296 /// returning. The wait is not optional and not the caller's: `connect`
297 /// only creates state — the first flight has not been answered — and a
298 /// `send` on a connection with no stream yet accepts zero bytes and
299 /// says so by returning 0, which would surface as a frame that
300 /// silently never left. Same reason client.zig's quicTransport waits.
301 fn openQuic(
302 alloc: std.mem.Allocator,
303 addr: std.net.Address,
304 key: quic_client.Key,
305 idle_ms: u32,
306 deadline_ms: i64,
307 ) !Conn {
308 const started = std.time.milliTimestamp();
309 const cl = try quic_client.Client.connect(alloc, addr, key, idle_ms);
310 errdefer cl.deinit();
311 try waitReady(cl, deadline_ms);
312 return .{
313 .link = .{ .quic = .{
314 .cl = cl,
315 .addr = addr,
316 .key = key,
317 .idle_ms = idle_ms,
318 .connect_ms = elapsed(started),
319 } },
320 .alloc = alloc,
321 };
322 }
323
324 fn close(self: *Conn) void {
325 switch (self.link) {
326 .fd => |fd| std.posix.close(fd),
327 .quic => |q| q.cl.deinit(),
328 }
329 }
330
331 /// How much longer than the daemon this client waits for an await,
332 /// which over a network is a function of how far away the daemon is.
333 ///
334 /// The unix arm keeps the flat 2s (see await_grace_ms). The QUIC arm
335 /// adds nothing until four round trips of its own handshake exceed
336 /// that, which on a LAN or loopback is never and on a 200ms link is
337 /// most of a second: the daemon's timeout window opens when it READS
338 /// the request, a whole flight after this process started counting,
339 /// and closes a flight before the reply lands. Four, not two, because
340 /// the request and the reply are not the only flights in the trip —
341 /// the daemon may be settling a command when the timeout fires.
342 ///
343 /// Capped, because `connect_ms` is bounded only by the handshake wait:
344 /// a connection that took fifteen seconds to come up would otherwise
345 /// buy a minute of grace, and past this cap we are no longer waiting
346 /// for the daemon's answer but for a network that has already shown it
347 /// cannot carry one.
348 fn graceMs(self: *const Conn) i64 {
349 return switch (self.link) {
350 .fd => await_grace_ms,
351 .quic => |q| @min(grace_cap_ms, @max(await_grace_ms, 4 * q.connect_ms)),
352 };
353 }
354
355 /// `deadline_ms` is the caller's own bound, the same one it will wait
356 /// for the answer under. The socket arm ignores it — a local write
357 /// either takes the bytes or fails — and the QUIC arm gives it
358 /// precedence over `send_flush_ms`, so a verb asked for a 100ms answer
359 /// cannot spend five seconds getting its question out.
360 fn sendFrame(self: *Conn, t: proto.MsgType, payload: []const u8, deadline_ms: i64) !void {
361 switch (self.link) {
362 .fd => |fd| try proto.writeFrame(fd, t, payload),
363 .quic => try self.sendFrameQuic(t, payload, deadline_ms),
364 }
365 }
366
367 /// The frame's wire bytes into the egress ring, all of them.
368 ///
369 /// `send` takes what fits and reports how much (a bounded ring: the
370 /// caller holds the backlog), so a short take is not a failure and not
371 /// ignorable either — the tail is offered again once acks have made
372 /// room. muxa's frames are a handful of bytes against a 256KB ring, so
373 /// this loop is expected never to turn twice; it is here because the
374 /// alternative to looping is a frame that leaves half-written, which
375 /// the peer reads as a corrupt stream rather than as an error.
376 ///
377 /// Bounded by whichever comes first, the caller's deadline or
378 /// `send_flush_ms`: the caller's, so a send cannot overshoot the answer
379 /// it is part of, and the flush cap so that an UNBOUNDED caller
380 /// (`--timeout 0`) still cannot wait here forever.
381 fn sendFrameQuic(
382 self: *Conn,
383 t: proto.MsgType,
384 payload: []const u8,
385 deadline_ms: i64,
386 ) !void {
387 var buf: std.ArrayList(u8) = .empty;
388 defer buf.deinit(self.alloc);
389 try proto.appendFrame(&buf, self.alloc, t, payload);
390
391 const q = &self.link.quic;
392 const deadline = @min(deadline_ms, std.time.milliTimestamp() + send_flush_ms);
393 var off: usize = 0;
394 while (off < buf.items.len) {
395 if (q.cl.dead) return error.ConnectionLost;
396 off += q.cl.send(buf.items[off..]);
397 if (off == buf.items.len) return;
398 if (std.time.milliTimestamp() >= deadline) return error.SendStalled;
399 // The ring is full: only the peer's acks can empty it, and they
400 // arrive through pump. Polling first keeps this from spinning.
401 var fds = [_]std.posix.pollfd{
402 .{ .fd = q.cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
403 };
404 _ = std.posix.poll(&fds, q.cl.timeoutMs(50)) catch return error.ConnectionLost;
405 q.cl.pump();
406 }
407 }
408
409 /// Read frames until one of type `want` arrives (snapshots, deltas and
410 /// pushes stream past an attached client; skip what we did not ask
411 /// for). Bounded by `deadline_ms` wall time via poll.
412 ///
413 /// `exit_status` is the one skipped frame that ends the wait instead:
414 /// the reply we are waiting for is never coming, and the reason is an
415 /// answer — the session ran its last command — not a transport
416 /// failure. Callers get error.SessionExited plus `session_exit`.
417 ///
418 /// The returned frame is allocated from this Conn's own allocator, so
419 /// `frame.deinit` takes that one. Every caller was already passing it —
420 /// there is one allocator in this process — and asking for it made the
421 /// pairing look like a choice.
422 fn awaitFrame(self: *Conn, want: proto.MsgType, deadline_ms: i64) !proto.Frame {
423 return switch (self.link) {
424 .fd => self.awaitFrameFd(self.alloc, want, deadline_ms),
425 .quic => self.awaitFrameQuic(self.alloc, want, deadline_ms),
426 };
427 }
428
429 /// Debt, deliberately retained on THIS arm: only the wait is
430 /// deadline-bounded, not the read. Once poll says a frame has begun,
431 /// readFrame's readExact blocks until the whole payload lands, so a
432 /// peer that stalls mid-frame outlives the deadline. That is harmless
433 /// over a local socket, where the daemon writes whole frames at once
434 /// and a stall means a daemon that has stopped running rather than a
435 /// path that has stopped delivering — and buying it off would mean a
436 /// second partial-frame buffer for a case that cannot happen here.
437 /// The QUIC arm below, where a network IS under the transport, does
438 /// not have the luxury and does not take it.
439 fn awaitFrameFd(
440 self: *Conn,
441 alloc: std.mem.Allocator,
442 want: proto.MsgType,
443 deadline_ms: i64,
444 ) !proto.Frame {
445 const fd = self.link.fd;
446 while (true) {
447 const now = std.time.milliTimestamp();
448 if (now >= deadline_ms) return error.Timeout;
449 var fds = [_]std.posix.pollfd{
450 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 },
451 };
452 const n = try std.posix.poll(&fds, @intCast(@min(deadline_ms - now, 250)));
453 if (n == 0) continue;
454 const frame = try proto.readFrame(alloc, fd) orelse return error.DaemonGone;
455 if (frame.type == want) return frame;
456 defer frame.deinit(alloc);
457 if (frame.type == .exit_status) {
458 // A daemon that spelled the frame without a code still ends
459 // the session; null is the honest code, not 0.
460 self.session_exit = if (frame.payload.len >= 1) frame.payload[0] else null;
461 return error.SessionExited;
462 }
463 }
464 }
465
466 /// The same wait with a network under it, and the difference is that
467 /// NOTHING here blocks on the transport: a datagram carries whatever
468 /// arrived, whole frames or a third of one, so the frames are
469 /// delimited out of the client's inbound buffer and a partial tail
470 /// simply stays there until the rest lands. A daemon that stops
471 /// mid-frame costs this loop the deadline it was given and not a
472 /// second more.
473 ///
474 /// Every buffered frame is taken before the next poll — a datagram
475 /// routinely carries several, and the reply may be the second — and
476 /// `dead` is checked only once the buffer is empty, so bytes that
477 /// arrived before the connection died are still delivered.
478 fn awaitFrameQuic(
479 self: *Conn,
480 alloc: std.mem.Allocator,
481 want: proto.MsgType,
482 deadline_ms: i64,
483 ) !proto.Frame {
484 const q = &self.link.quic;
485 while (true) {
486 q.cl.pump();
487 while (try frameFrom(alloc, q.cl.inbound())) |got| {
488 q.cl.consume(got.consumed);
489 if (got.frame.type == want) return got.frame;
490 defer got.frame.deinit(alloc);
491 if (got.frame.type == .exit_status) {
492 self.session_exit = if (got.frame.payload.len >= 1) got.frame.payload[0] else null;
493 return error.SessionExited;
494 }
495 }
496 // Not `DaemonGone`: over a network the difference between "the
497 // daemon exited" and "the path to it went away" is not ours to
498 // claim, and the reconnect above only fires on this one.
499 if (q.cl.dead) return error.ConnectionLost;
500 const now = std.time.milliTimestamp();
501 if (now >= deadline_ms) return error.Timeout;
502 var fds = [_]std.posix.pollfd{
503 .{ .fd = q.cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
504 };
505 // Through timeoutMs, so ngtcp2's own timers — loss detection
506 // and, on a quiet await, the keepalive that keeps the idle
507 // timeout from firing under us — are serviced on schedule
508 // rather than whenever the daemon happens to say something.
509 const cap: i32 = @intCast(@min(deadline_ms - now, 250));
510 _ = std.posix.poll(&fds, q.cl.timeoutMs(cap)) catch return error.ConnectionLost;
511 }
512 }
513
514 /// Redial the same coordinates and hand the connection over. The old
515 /// client is torn down only once the new one is up, so a redial that
516 /// fails leaves this Conn holding a live (if dead-ended) client rather
517 /// than a freed one — `close` runs either way.
518 ///
519 /// `connect_ms` deliberately keeps the FIRST handshake's measurement
520 /// rather than taking this one's. Nothing reads `graceMs` after this
521 /// point — the await it belongs to already has its deadline, and the
522 /// re-issue continues that same deadline — so refreshing it would be
523 /// bookkeeping with no reader, and the reader it might one day have
524 /// wants the distance to the daemon, not the cost of a redial made
525 /// while the path was still coming back.
526 fn reconnect(self: *Conn, deadline_ms: i64) !void {
527 const q = &self.link.quic;
528 const cl = try quic_client.Client.connect(self.alloc, q.addr, q.key, q.idle_ms);
529 errdefer cl.deinit();
530 try waitReady(cl, deadline_ms);
531 q.cl.deinit();
532 q.cl = cl;
533 q.reconnected = true;
534 }
535 };
536
537 /// One OWNED frame delimited out of `buf`, and how many bytes of it that
538 /// took. The arithmetic is `proto.delimitFrame`'s — the same walk the
539 /// daemon does over the same wire from the other end — and what this adds
540 /// is the copy: the caller consumes the bytes out of the client's inbound
541 /// buffer immediately, so a payload still pointing into it would be a
542 /// slice into memory about to be shifted.
543 fn frameFrom(
544 alloc: std.mem.Allocator,
545 buf: []const u8,
546 ) !?struct { frame: proto.Frame, consumed: usize } {
547 const d = try proto.delimitFrame(buf) orelse return null;
548 const payload = try alloc.alloc(u8, d.payload.len);
549 errdefer alloc.free(payload);
550 @memcpy(payload, d.payload);
551 return .{
552 .frame = .{ .type = d.type, .payload = payload },
553 .consumed = d.consumed,
554 };
555 }
556
557 test "frameFrom: a partial tail is not a frame and not an error" {
558 const alloc = std.testing.allocator;
559
560 // Nothing, and less than a header: the two shapes a datagram that
561 // carried the start of a frame leaves behind.
562 try std.testing.expect(try frameFrom(alloc, "") == null);
563 try std.testing.expect(try frameFrom(alloc, &[_]u8{ 0x0a, 1, 0 }) == null);
564
565 // A whole header whose payload is still in flight. This is the case a
566 // blocking read would have sat on: the length is known, the bytes are
567 // not here, and the answer is to wait rather than to read.
568 const partial = [_]u8{ @intFromEnum(proto.MsgType.input), 4, 0, 0, 0, 'a', 'b' };
569 try std.testing.expect(try frameFrom(alloc, &partial) == null);
570
571 // The same bytes, completed.
572 const whole = [_]u8{ @intFromEnum(proto.MsgType.input), 4, 0, 0, 0, 'a', 'b', 'c', 'd' };
573 const got = (try frameFrom(alloc, &whole)).?;
574 defer got.frame.deinit(alloc);
575 try std.testing.expectEqual(proto.MsgType.input, got.frame.type);
576 try std.testing.expectEqualStrings("abcd", got.frame.payload);
577 try std.testing.expectEqual(@as(usize, 9), got.consumed);
578 }
579
580 test "frameFrom: two frames in one buffer, walked by consumed" {
581 const alloc = std.testing.allocator;
582 // What a single datagram routinely carries: the push we skip and the
583 // reply we asked for. A walk that stopped after one would leave the
584 // answer sitting in the buffer while the deadline ran out.
585 var buf: std.ArrayList(u8) = .empty;
586 defer buf.deinit(alloc);
587 try proto.appendFrame(&buf, alloc, .pty_mode, &[_]u8{0});
588 try proto.appendFrame(&buf, alloc, .status_reply, "xy");
589
590 const first = (try frameFrom(alloc, buf.items)).?;
591 defer first.frame.deinit(alloc);
592 try std.testing.expectEqual(proto.MsgType.pty_mode, first.frame.type);
593
594 const second = (try frameFrom(alloc, buf.items[first.consumed..])).?;
595 defer second.frame.deinit(alloc);
596 try std.testing.expectEqual(proto.MsgType.status_reply, second.frame.type);
597 try std.testing.expectEqualStrings("xy", second.frame.payload);
598 try std.testing.expectEqual(buf.items.len, first.consumed + second.consumed);
599
600 // An empty payload is a frame like any other — `status_req` and
601 // `detach` are nothing else — and must not read as "nothing yet".
602 var empty: std.ArrayList(u8) = .empty;
603 defer empty.deinit(alloc);
604 try proto.appendFrame(&empty, alloc, .detach, "");
605 const none = (try frameFrom(alloc, empty.items)).?;
606 defer none.frame.deinit(alloc);
607 try std.testing.expectEqual(@as(usize, proto.frame_header_len), none.consumed);
608 }
609
610 test "frameFrom: a length no frame can carry is refused, not allocated" {
611 const alloc = std.testing.allocator;
612 // The peer chose this number. Reading on would mean allocating against
613 // it; the daemon's own walk refuses the same bound the same way.
614 var hdr: [proto.frame_header_len]u8 = undefined;
615 hdr[0] = @intFromEnum(proto.MsgType.input);
616 std.mem.writeInt(u32, hdr[1..5], proto.max_payload + 1, .little);
617 try std.testing.expectError(error.FrameTooLarge, frameFrom(alloc, &hdr));
618 }
619
620 test "graceMs: flat over a socket, RTT-derived over QUIC, and capped" {
621 const alloc = std.testing.allocator;
622 const local = Conn{ .link = .{ .fd = -1 }, .alloc = alloc };
623 try std.testing.expectEqual(@as(i64, 2_000), local.graceMs());
624
625 // The derivation is 4x the handshake, and it only ever WIDENS the
626 // window: a loopback or LAN daemon keeps the flat 2s.
627 // No client: the window is a function of the measurement, not of the
628 // connection, and nothing here may touch one.
629 var far = Conn{
630 .link = .{ .quic = .{
631 .cl = undefined,
632 .addr = undefined,
633 .key = undefined,
634 .idle_ms = 0,
635 .connect_ms = 1,
636 } },
637 .alloc = alloc,
638 };
639 try std.testing.expectEqual(@as(i64, 2_000), far.graceMs());
640
641 // A 300ms handshake — a real intercontinental link — buys 1.2s, which
642 // is still under the floor, so the first number that moves it is a
643 // handshake past half a second.
644 far.link.quic.connect_ms = 300;
645 try std.testing.expectEqual(@as(i64, 2_000), far.graceMs());
646 far.link.quic.connect_ms = 900;
647 try std.testing.expectEqual(@as(i64, 3_600), far.graceMs());
648
649 // And it stops widening: past the cap we are no longer waiting on a
650 // daemon, we are waiting on a network that has already failed to carry
651 // an answer.
652 far.link.quic.connect_ms = 60_000;
653 try std.testing.expectEqual(@as(i64, 30_000), far.graceMs());
654 }
655
656 /// Drive a fresh connection until it can carry bytes, or give up.
657 ///
658 /// A refused port ends this early — quic_client turns the ICMP unreachable
659 /// into `dead` — so the common mistake (no daemon on that port) costs
660 /// milliseconds. A blackholed one produces no error at all, and there the
661 /// deadline is the only thing that ends the wait; even an unbounded one
662 /// (`--timeout 0`) terminates, because the connection's own idle timeout
663 /// kills it after `idle_ms`.
664 fn waitReady(cl: *quic_client.Client, deadline_ms: i64) !void {
665 while (true) {
666 cl.pump();
667 if (cl.isReady()) return;
668 if (cl.dead) return error.QuicHandshakeFailed;
669 const now = std.time.milliTimestamp();
670 if (now >= deadline_ms) return error.Timeout;
671 var fds = [_]std.posix.pollfd{
672 .{ .fd = cl.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
673 };
674 const cap: i32 = @intCast(@min(deadline_ms - now, 50));
675 _ = std.posix.poll(&fds, cl.timeoutMs(cap)) catch return error.QuicHandshakeFailed;
676 }
677 }
678
679 test "reconnect: redials the same coordinates, and a dead port is a fast no" {
680 const alloc = std.testing.allocator;
681 // 127.0.0.1:1, where nothing listens: the refusal is REAL — an ICMP
682 // unreachable comes back and quic_client acts on it — which is what
683 // lets this exercise the whole redial path (dial, handshake wait,
684 // verdict) in a couple of loopback round trips instead of a timeout.
685 const addr = try std.net.Address.parseIp("127.0.0.1", 1);
686 const key: quic_client.Key = .{ .bytes = [_]u8{7} ** quic_client.key_len };
687
688 // The dial that stands in for the connection this client had before
689 // the network went away. It dies for the same reason the redial will,
690 // which is fine: what is under test is what `reconnect` DOES, and it
691 // does the same thing to a connection that died at second 30.
692 const deadline = std.time.milliTimestamp() + 2_000;
693 var conn = Conn{
694 .link = .{ .quic = .{
695 .cl = try quic_client.Client.connect(alloc, addr, key, 1_000),
696 .addr = addr,
697 .key = key,
698 .idle_ms = 1_000,
699 .connect_ms = 0,
700 } },
701 .alloc = alloc,
702 };
703 defer conn.close();
704
705 const t0 = std.time.milliTimestamp();
706 if (conn.reconnect(deadline)) |_| {
707 // Nothing listens there; a redial that reported success would mean
708 // the handshake wait had stopped being a wait for a handshake.
709 return error.TestUnexpectedResult;
710 } else |redial| {
711 try std.testing.expectEqual(error.QuicHandshakeFailed, redial);
712 // The bookkeeping awaitReissuing does, done here with the real
713 // error the real redial produced, so the sentence below is the one
714 // an agent gets rather than one this test made up.
715 conn.reconnect_failure = @errorName(redial);
716 }
717 // Fast, because the port refused rather than went quiet. A redial that
718 // swallowed the refusal would spend the whole 2s here — and in the
719 // field it would spend the agent's remaining deadline.
720 try std.testing.expect(std.time.milliTimestamp() - t0 < 1_000);
721
722 // The whole story, in the order it happened: the wait died because the
723 // path tore, and it stayed dead because the redial could not complete.
724 // An agent told only the second half goes and checks its key.
725 var buf: [128]u8 = undefined;
726 try std.testing.expectEqualStrings(
727 "connection lost; reconnect failed: QuicHandshakeFailed",
728 waitFailDetail(&buf, &conn, error.ConnectionLost),
729 );
730
731 // A redial that failed is not a reconnect spent — but it is also not a
732 // Conn holding a freed client: the old one is torn down only once a
733 // new one is up, so the close above is safe on this path.
734 try std.testing.expect(!conn.link.quic.reconnected);
735 }
736
737 test "waitFailDetail: only a lost connection gets a sentence; the rest keep their names" {
738 const alloc = std.testing.allocator;
739 var buf: [128]u8 = undefined;
740
741 // Every other failure is untouched — the socket arm's reports must
742 // read exactly as they did before there was a QUIC arm.
743 const local = Conn{ .link = .{ .fd = -1 }, .alloc = alloc };
744 try std.testing.expectEqualStrings("Timeout", waitFailDetail(&buf, &local, error.Timeout));
745 try std.testing.expectEqualStrings("DaemonGone", waitFailDetail(&buf, &local, error.DaemonGone));
746
747 // A tear with the one reconnect still unspent (nothing tried yet).
748 var far = Conn{
749 .link = .{ .quic = .{
750 .cl = undefined,
751 .addr = undefined,
752 .key = undefined,
753 .idle_ms = 0,
754 .connect_ms = 0,
755 } },
756 .alloc = alloc,
757 };
758 try std.testing.expectEqualStrings("connection lost", waitFailDetail(&buf, &far, error.ConnectionLost));
759
760 // A tear AFTER a reconnect that worked: the second one inside a single
761 // wait, which is a different thing to be told than the first — the
762 // client did reconnect, and the path tore again anyway.
763 far.link.quic.reconnected = true;
764 try std.testing.expectEqualStrings(
765 "connection lost again, after the one reconnect",
766 waitFailDetail(&buf, &far, error.ConnectionLost),
767 );
768
769 // A buffer too small to hold the composed line drops the reason rather
770 // than the finding: the detail is the agent's only account of this.
771 far.reconnect_failure = "QuicHandshakeFailed";
772 var tiny: [8]u8 = undefined;
773 try std.testing.expectEqualStrings(
774 "connection lost; reconnect failed",
775 waitFailDetail(&tiny, &far, error.ConnectionLost),
776 );
777 }
778
779 test "awaitFrame ends a wait on exit_status, keeping the code" {
780 const alloc = std.testing.allocator;
781 // A pipe stands in for the daemon: awaitFrame polls and reads an fd and
782 // asks nothing else of it.
783 const pipe = try std.posix.pipe();
784 defer std.posix.close(pipe[0]);
785 defer std.posix.close(pipe[1]);
786
787 var conn = Conn{ .link = .{ .fd = pipe[0] }, .alloc = alloc };
788 // A push to skip on the way, then the session's last word. The reply
789 // this wait asked for is never coming, and the code is the answer.
790 try proto.writeFrame(pipe[1], .pty_mode, &[_]u8{0});
791 try proto.writeFrame(pipe[1], .exit_status, &[_]u8{5});
792 try std.testing.expectError(
793 error.SessionExited,
794 conn.awaitFrame(.status_reply, std.time.milliTimestamp() + 2000),
795 );
796 try std.testing.expectEqual(@as(?u8, 5), conn.session_exit);
797
798 // ...and it is spelled as a session ending, not as a command's code.
799 var out: std.ArrayList(u8) = .empty;
800 defer out.deinit(alloc);
801 try printSessionEnded(out.writer(alloc), conn.session_exit, 42);
802 try std.testing.expectEqualStrings(
803 "{\"reason\":\"session_ended\",\"exit_code\":5,\"duration_ms\":42}\n",
804 out.items,
805 );
806 }
807
808 /// Every failure exit goes through here, so stdout carries one JSON object
809 /// whatever went wrong — a driving agent parses the same shape on both
810 /// paths instead of switching on exit code first.
811 fn fail(msg: []const u8, detail: []const u8) u8 {
812 var buf: [2048]u8 = undefined;
813 var fbs = std.io.fixedBufferStream(&buf);
814 writeError(fbs.writer(), msg, detail) catch {
815 // The message did not fit. Still JSON, still one line.
816 proto.writeAllFd(std.posix.STDOUT_FILENO, "{\"error\":\"failure too long to report\"}\n") catch {};
817 return 1;
818 };
819 proto.writeAllFd(std.posix.STDOUT_FILENO, fbs.getWritten()) catch {};
820 return 1;
821 }
822
823 /// `fail` for a message whose verb prefix is only known at runtime, which
824 /// is every failure in the shared await/run pipeline. Produces exactly the
825 /// `"<verb>: <what>"` the two verbs printed when they were written out
826 /// separately; a message too long to prefix falls back to the unprefixed
827 /// one rather than losing the failure.
828 fn failAs(who: []const u8, msg: []const u8, detail: []const u8) u8 {
829 var buf: [256]u8 = undefined;
830 const joined = std.fmt.bufPrint(&buf, "{s}: {s}", .{ who, msg }) catch msg;
831 return fail(joined, detail);
832 }
833
834 fn writeError(writer: anytype, msg: []const u8, detail: []const u8) !void {
835 try writer.writeAll("{\"error\":");
836 try jsonEscape(writer, msg);
837 try writer.writeAll(",\"detail\":");
838 try jsonEscape(writer, detail);
839 try writer.writeAll("}\n");
840 }
841
842 /// The wall-clock instant a round trip gives up at. `--timeout 0` means
843 /// "no bound at all" everywhere else in this protocol (AwaitReq spells it
844 /// out), so it means that here too — the alternative reading, a deadline
845 /// already in the past, would make `--timeout 0` fail instantly instead of
846 /// waiting forever, which is the opposite of what it asks for.
847 fn deadlineFor(timeout_ms: u32) i64 {
848 if (timeout_ms == 0) return std.math.maxInt(i64);
849 return std.time.milliTimestamp() + timeout_ms;
850 }
851
852 /// The session ended under us: JSON, like every other outcome, but on the
853 /// failure path — the verb that asked (status, capture, send) has no answer
854 /// to give. `run` and `await` do have one and print it themselves.
855 fn failSessionEnded(code: ?u8) u8 {
856 var buf: [192]u8 = undefined;
857 var fbs = std.io.fixedBufferStream(&buf);
858 writeSessionEndedError(fbs.writer(), code) catch return 1;
859 proto.writeAllFd(std.posix.STDOUT_FILENO, fbs.getWritten()) catch {};
860 return 1;
861 }
862
863 /// `detail` is here because every other failure has one: an agent that
864 /// reads `.detail` on any exit-1 must never meet a missing key, and one
865 /// verb quietly dropping it is exactly the shape a driver hits in the
866 /// field and not in a test. `exit_code` is the machine field; the detail
867 /// says the same thing in the prose the other failures use.
868 fn writeSessionEndedError(writer: anytype, code: ?u8) !void {
869 try writer.writeAll("{\"error\":\"session ended\",\"detail\":");
870 if (code) |c| {
871 var buf: [40]u8 = undefined;
872 try jsonEscape(writer, try std.fmt.bufPrint(&buf, "shell exited with {d}", .{c}));
873 } else {
874 try jsonEscape(writer, "shell exited without reporting a code");
875 }
876 try writer.writeAll(",\"exit_code\":");
877 try writeExitCode(writer, code);
878 try writer.writeAll("}\n");
879 }
880
881 test "the session-ended failure keeps the error+detail shape every failure has" {
882 var buf: [192]u8 = undefined;
883
884 var fbs = std.io.fixedBufferStream(&buf);
885 try writeSessionEndedError(fbs.writer(), 5);
886 try std.testing.expectEqualStrings(
887 "{\"error\":\"session ended\",\"detail\":\"shell exited with 5\",\"exit_code\":5}\n",
888 fbs.getWritten(),
889 );
890
891 // No code is still a detail, never a missing key.
892 var none = std.io.fixedBufferStream(&buf);
893 try writeSessionEndedError(none.writer(), null);
894 try std.testing.expect(std.mem.indexOf(u8, none.getWritten(), "\"detail\":\"shell exited without") != null);
895 try std.testing.expect(std.mem.indexOf(u8, none.getWritten(), "\"exit_code\":null") != null);
896 }
897
898 pub fn main() !u8 {
899 var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator);
900 defer arena_state.deinit();
901 const alloc = arena_state.allocator();
902
903 const args = try std.process.argsAlloc(alloc);
904 const o = parseArgs(args) orelse {
905 // Usage is diagnostic, so it goes to stderr: stdout stays strictly
906 // one JSON object per invocation, even on the argument-error path.
907 proto.writeAllFd(std.posix.STDERR_FILENO, usage) catch {};
908 return 2;
909 };
910
911 // Started BEFORE the connect, not after: over QUIC the handshake is
912 // part of the round trip the caller bounded, and a `--timeout` that
913 // began counting only once the connection was up would promise
914 // something different on the two transports. Over a unix socket the
915 // connect is a syscall, so this moves the instant by microseconds.
916 const deadline = deadlineFor(o.timeout_ms);
917
918 if (o.quic) |host_port| {
919 var conn = switch (openQuicConn(alloc, o, host_port, deadline)) {
920 .conn => |c| c,
921 .exit => |code| return code,
922 };
923 defer conn.close();
924 return dispatch(alloc, &conn, o, deadline);
925 }
926
927 const sock_path = if (o.sock) |s| s else try sockpath.defaultSockPath(alloc);
928
929 // Refused by name, before connecting: connect would bounce a too-long
930 // path off the kernel with a generic error, and the path is the whole
931 // story. Every binary owes this check in its own words (sockpath).
932 if (sock_path.len > sockpath.max_sun_path) {
933 var buf: [64]u8 = undefined;
934 const detail = std.fmt.bufPrint(
935 &buf,
936 "{d} bytes, max {d}",
937 .{ sock_path.len, sockpath.max_sun_path },
938 ) catch "too long";
939 return fail("socket path too long", detail);
940 }
941
942 var conn = Conn.open(alloc, sock_path) catch |e| {
943 // The path goes in the detail: a muxa pointed at the wrong socket
944 // is this binary's likeliest field failure, and an agent reading
945 // "FileNotFound" alone cannot tell which path it was that missed.
946 var buf: [256]u8 = undefined;
947 const detail = std.fmt.bufPrint(&buf, "{s}: {s}", .{ sock_path, @errorName(e) }) catch
948 @errorName(e);
949 return fail("cannot connect to the daemon", detail);
950 };
951 defer conn.close();
952
953 return dispatch(alloc, &conn, o, deadline);
954 }
955
956 /// The verbs, once. Both transports arrive here with a Conn and nothing
957 /// else that distinguishes them, which is the property `--quic` is selling.
958 fn dispatch(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) !u8 {
959 return switch (o.verb) {
960 .status => verbStatus(alloc, conn, deadline),
961 .capture => verbCapture(alloc, conn, o.vt, deadline),
962 .send => verbSend(alloc, conn, o.arg, deadline),
963 // The one thing `run` needs that `await` does not, checked here so
964 // the shared pipeline below can read `cmdline == null` as "this is
965 // an await" rather than as "a run that was spelled wrong".
966 .run => if (o.arg) |cmdline|
967 awaitVerb(alloc, conn, o, deadline, cmdline)
968 else
969 fail("run: needs CMDLINE", ""),
970 .@"await" => awaitVerb(alloc, conn, o, deadline, null),
971 };
972 }
973
974 /// A QUIC transport, or the exit code standing in for the reason there is
975 /// not one. Every refusal here goes through `fail`, so a dial that never
976 /// happened prints the same one-JSON-object-on-stdout shape as a verb that
977 /// ran — an agent parses one thing whatever went wrong.
978 const Opened = union(enum) { conn: Conn, exit: u8 };
979
980 fn openQuicConn(
981 alloc: std.mem.Allocator,
982 o: Opts,
983 host_port: []const u8,
984 deadline: i64,
985 ) Opened {
986 // `--key`, then `$MUX_KEY_FILE`, then the XDG default if it exists.
987 // The order is not spelled here on purpose: xdg owns it, muxd and mux
988 // read it from the same two functions, and a third copy is how two
989 // binaries end up authenticating with different keys.
990 const res = xdg.resolveKeyPath(alloc, xdg.pickKey(o.key, std.posix.getenv("MUX_KEY_FILE"))) catch |e|
991 return .{ .exit = fail("quic: cannot resolve a key path", @errorName(e)) };
992 const key_path = switch (res) {
993 .given, .default => |p| p,
994 // The path is the detail because it is the actionable half: the
995 // agent (or the human reading its log) needs to know which file
996 // `muxd keygen` was supposed to have written.
997 .missing => |p| return .{ .exit = fail(
998 "quic: no key: pass --key, set MUX_KEY_FILE, or run `muxd keygen`",
999 p,
1000 ) },
1001 };
1002 const key = quic_client.Key.load(key_path) catch |e| {
1003 // The daemon's words for a key the daemon would also refuse —
1004 // including the group/other-readable refusal, which this binary
1005 // gets for free by loading the key the same way.
1006 var buf: [quic_client.key_refusal_len]u8 = undefined;
1007 return .{ .exit = fail("quic: unusable key", quic_client.keyRefusalBody(&buf, e, key_path)) };
1008 };
1009 const addr = quic.parseAddr(alloc, host_port) catch |e| {
1010 var buf: [512]u8 = undefined;
1011 const detail = std.fmt.bufPrint(&buf, "{s}: {s}", .{ host_port, @errorName(e) }) catch
1012 @errorName(e);
1013 return .{ .exit = fail("quic: cannot read HOST:PORT", detail) };
1014 };
1015 const conn = Conn.openQuic(alloc, addr, key, quic_client.default_idle_ms, deadline) catch |e| {
1016 var buf: [512]u8 = undefined;
1017 const detail = std.fmt.bufPrint(&buf, "{s}: {s}", .{ host_port, @errorName(e) }) catch
1018 @errorName(e);
1019 return .{ .exit = fail("cannot connect to the daemon", detail) };
1020 };
1021 return .{ .conn = conn };
1022 }
1023
1024 fn verbStatus(alloc: std.mem.Allocator, conn: *Conn, deadline: i64) !u8 {
1025 conn.sendFrame(.status_req, "", deadline) catch |e| return fail("status: send failed", @errorName(e));
1026 const frame = conn.awaitFrame(.status_reply, deadline) catch |e| switch (e) {
1027 error.SessionExited => return failSessionEnded(conn.session_exit),
1028 else => return fail("status: no reply", @errorName(e)),
1029 };
1030 defer frame.deinit(alloc);
1031 const st = proto.decodeStatusReply(frame.payload) catch |e|
1032 return fail("status: bad reply", @errorName(e));
1033
1034 var out: std.ArrayList(u8) = .empty;
1035 defer out.deinit(alloc);
1036 try printStatus(out.writer(alloc), st);
1037 proto.writeAllFd(std.posix.STDOUT_FILENO, out.items) catch {};
1038 return 0;
1039 }
1040
1041 /// A command that has not returned — or one whose mechanism cannot know a
1042 /// code — has no exit code, and JSON null is the honest spelling: 0 would
1043 /// read as "succeeded".
1044 fn writeExitCode(writer: anytype, code: ?u8) !void {
1045 if (code) |c| {
1046 try writer.print("{d}", .{c});
1047 } else {
1048 try writer.writeAll("null");
1049 }
1050 }
1051
1052 /// The five CmdState fields `status` and `await`/`run` both publish, in the
1053 /// one order both have always used. Written as a bare fragment — no braces,
1054 /// no leading or trailing comma — because the two verbs nest it
1055 /// differently: `status` puts it inside a `"cmd"` object and follows it
1056 /// with the seq, while `await` inlines it at the top level and follows it
1057 /// with the duration. Each verb keeps its own envelope; what they stopped
1058 /// keeping is a second spelling of the fields inside it.
1059 fn writeCmdFields(writer: anytype, st: proto.CmdState) !void {
1060 try writer.writeAll("\"phase\":");
1061 try jsonEscape(writer, @tagName(st.phase));
1062 try writer.writeAll(",\"mechanism\":");
1063 try jsonEscape(writer, @tagName(st.mechanism));
1064 try writer.writeAll(",\"exit_code\":");
1065 try writeExitCode(writer, st.exit_code);
1066 try writer.print(",\"start_row\":{d},\"end_row\":{d}", .{ st.start_row, st.end_row });
1067 }
1068
1069 fn printStatus(writer: anytype, st: proto.StatusReply) !void {
1070 try writer.print(
1071 "{{\"cols\":{d},\"rows\":{d},\"cursor\":{{\"x\":{d},\"y\":{d}}}," ++
1072 "\"history_rows\":{d},\"alt_screen\":{},\"icanon\":{},\"echo\":{},\"cmd\":{{",
1073 .{ st.cols, st.rows, st.cursor_x, st.cursor_y, st.history_rows, st.alt_screen, st.mode.icanon, st.mode.echo },
1074 );
1075 try writeCmdFields(writer, st.cmd);
1076 // The watermark, and only `status` carries it: this is the number an
1077 // agent feeds back as `since_seq`, which is why `await` does not print
1078 // one (see proto.CmdState.seq).
1079 try writer.print(",\"seq\":{d}}}}}\n", .{st.cmd.seq});
1080 }
1081
1082 test "printStatus spells a pending exit code as JSON null" {
1083 var buf: [512]u8 = undefined;
1084 var fbs = std.io.fixedBufferStream(&buf);
1085 try printStatus(fbs.writer(), .{
1086 .cols = 80,
1087 .rows = 24,
1088 .cursor_x = 1,
1089 .cursor_y = 2,
1090 .history_rows = 7,
1091 .alt_screen = false,
1092 .mode = .{ .icanon = true, .echo = true },
1093 .cmd = .{ .phase = .running, .mechanism = .marks, .exit_code = null, .start_row = 3, .end_row = 4, .seq = 9 },
1094 });
1095 // The whole object, byte for byte, not a handful of substrings: this is
1096 // muxa's published contract with an agent's JSON parser, and the fields
1097 // it shares with `await` are written by a helper both verbs call — a
1098 // pin on the parts cannot see a comma or a nesting level move.
1099 try std.testing.expectEqualStrings(
1100 "{\"cols\":80,\"rows\":24,\"cursor\":{\"x\":1,\"y\":2},\"history_rows\":7," ++
1101 "\"alt_screen\":false,\"icanon\":true,\"echo\":true," ++
1102 "\"cmd\":{\"phase\":\"running\",\"mechanism\":\"marks\",\"exit_code\":null," ++
1103 "\"start_row\":3,\"end_row\":4,\"seq\":9}}\n",
1104 fbs.getWritten(),
1105 );
1106 }
1107
1108 fn verbCapture(alloc: std.mem.Allocator, conn: *Conn, vt: bool, deadline: i64) !u8 {
1109 const payload = [_]u8{if (vt) 1 else 0};
1110 conn.sendFrame(.debug_dump, &payload, deadline) catch |e| return fail("capture: send failed", @errorName(e));
1111 const frame = conn.awaitFrame(.dump_reply, deadline) catch |e| switch (e) {
1112 error.SessionExited => return failSessionEnded(conn.session_exit),
1113 else => return fail("capture: no reply", @errorName(e)),
1114 };
1115 defer frame.deinit(alloc);
1116
1117 var out: std.ArrayList(u8) = .empty;
1118 defer out.deinit(alloc);
1119 const writer = out.writer(alloc);
1120 try writer.writeAll("{\"grid\":");
1121 try jsonEscape(writer, frame.payload);
1122 try writer.writeAll("}\n");
1123 proto.writeAllFd(std.posix.STDOUT_FILENO, out.items) catch {};
1124 return 0;
1125 }
1126
1127 /// Join the session claiming NO grid. applySize refuses anything under 2,
1128 /// so the slot stays 0x0 and makes no claim in claimGrid: the human's
1129 /// terminal must never be resized because an agent connected.
1130 fn attachZero(conn: *Conn, deadline: i64) !void {
1131 try conn.sendFrame(.attach, &proto.encodeAttach(0, 0, 0, 0), deadline);
1132 }
1133
1134 fn verbSend(alloc: std.mem.Allocator, conn: *Conn, arg: ?[]const u8, deadline: i64) !u8 {
1135 const spec = arg orelse return fail("send: needs BYTES", "");
1136 const bytes = decodeEscapes(alloc, spec) catch |e| return fail("send: bad escape", @errorName(e));
1137 defer alloc.free(bytes);
1138
1139 attachZero(conn, deadline) catch |e| return fail("send: attach failed", @errorName(e));
1140 conn.sendFrame(.input, bytes, deadline) catch |e| return fail("send: input failed", @errorName(e));
1141
1142 // Write-and-close LOSES the input, and not as a rare race: attaching
1143 // queues a snapshot, and the daemon flushes a client's pending bytes
1144 // BEFORE it reads that client (server.zig's poll arm). Closing straight
1145 // after the write means the flush hits EPIPE, the daemon drops us, and
1146 // the input frame is discarded still unread. Measured: closing at once
1147 // never lands, while any delay or drain always does.
1148 //
1149 // So the round trip is the acknowledgement. Frames are served in stream
1150 // order, so a status_reply is proof the daemon has already read PAST the
1151 // input frame and fed it to the pty; awaitFrame skips the snapshot and
1152 // the pushes on the way, which is what keeps the socket drained enough
1153 // for that flush to succeed. Nothing is done with the reply — its
1154 // arrival is the whole content.
1155 conn.sendFrame(.status_req, "", deadline) catch |e| return fail("send: ack request failed", @errorName(e));
1156 const ack = conn.awaitFrame(.status_reply, deadline) catch |e| switch (e) {
1157 // The bytes we sent ended the session (`exit\n`). Reported as the
1158 // session's death rather than as "sent", because this verb's answer
1159 // is about the send and there is no longer a session to have sent
1160 // to — an agent that wants the death to be an ANSWER runs `run`.
1161 error.SessionExited => return failSessionEnded(conn.session_exit),
1162 else => return fail("send: daemon never acknowledged the input", @errorName(e)),
1163 };
1164 ack.deinit(alloc);
1165
1166 conn.sendFrame(.detach, "", deadline) catch |e| return fail("send: detach failed", @errorName(e));
1167
1168 proto.writeAllFd(std.posix.STDOUT_FILENO, "{\"sent\":true}\n") catch {};
1169 return 0;
1170 }
1171
1172 /// How much longer than the daemon this client is willing to wait.
1173 ///
1174 /// Load-bearing: the daemon starts its own `timeout_ms` window when it
1175 /// READS the await_req, which is already later than the instant this
1176 /// process started counting. Waiting exactly `timeout_ms` here would lose
1177 /// that race every single time, and every timeout would surface as
1178 /// `{"error":"await: no reply"}` instead of the structured
1179 /// `{"reason":"timeout"}` with exit 3 that the agent is meant to read.
1180 const await_grace_ms = 2_000;
1181
1182 /// The ceiling on the QUIC arm's derived grace (Conn.graceMs), and the
1183 /// reason it has one is that `connect_ms` has no bound of its own worth
1184 /// multiplying by four.
1185 const grace_cap_ms = 30_000;
1186
1187 /// How long `sendFrameQuic` will keep offering a frame's tail to a full
1188 /// egress ring before giving up on it. Reaching this means the peer has
1189 /// stopped acknowledging 256KB of backlog, which is a dead connection
1190 /// wearing a different hat — but a bound is what keeps it from being an
1191 /// unbounded wait inside a call that has no deadline of its own.
1192 const send_flush_ms = 5_000;
1193
1194 /// The span fetch gets its own window rather than the tail of the run's: a
1195 /// command that returned in the last millisecond of `--timeout` still has a
1196 /// transcript worth having, and this round trip is a local read that either
1197 /// answers promptly or is not coming.
1198 const span_fetch_ms = 2_000;
1199
1200 /// Ask to be told when the session next comes to rest, and wait for it.
1201 fn doAwait(
1202 alloc: std.mem.Allocator,
1203 conn: *Conn,
1204 o: Opts,
1205 since_seq: u64,
1206 deadline: i64,
1207 ) !proto.AwaitReply {
1208 try conn.sendFrame(.await_req, &proto.encodeAwaitReq(.{
1209 .since_seq = since_seq,
1210 .settle_ms = o.settle_ms,
1211 .timeout_ms = o.timeout_ms,
1212 }), deadline);
1213 const frame = try conn.awaitFrame(.await_reply, deadline);
1214 defer frame.deinit(alloc);
1215 return try proto.decodeAwaitReply(frame.payload);
1216 }
1217
1218 /// The await, plus the ONE reconnect this client is willing to spend on it.
1219 ///
1220 /// A wait is the only round trip long enough for a network to die under —
1221 /// a status round trip is over in a millisecond, a `run` on a build is not
1222 /// — and losing it costs an agent the whole command it was watching, so
1223 /// this is the one place a transport failure is retried rather than
1224 /// reported. What makes the retry safe rather than a second command is
1225 /// `since_seq`: the request is a question about a watermark ("tell me
1226 /// about a return newer than this"), so re-asking it after a reconnect is
1227 /// the SAME question and the daemon answers it identically whether or not
1228 /// it saw the first one. The server's own tests pin that idempotency.
1229 ///
1230 /// Three things are deliberately not reset:
1231 ///
1232 /// * the deadline, which is the caller's whole bound and continues
1233 /// across the reconnect — a redial that ate four seconds has spent
1234 /// four seconds of the wait, not bought a fresh one;
1235 /// * `since_seq`, for the reason above — re-reading the watermark from
1236 /// the new connection would move it past a return that had happened
1237 /// while we were disconnected, and the await would then sit waiting
1238 /// for one that already went by;
1239 /// * the attach, which IS re-sent, at 0x0 like every other attach this
1240 /// binary makes: the daemon dropped our old client slot with the
1241 /// connection and would have no session to answer about otherwise.
1242 ///
1243 /// Once, and once per process rather than per await: a loop here would be
1244 /// a client that hides a daemon that is gone, and the agent driving it
1245 /// asked a question that deserves an answer within the deadline it named.
1246 ///
1247 /// What is re-sent is the attach and the await_req, and NOTHING else —
1248 /// specifically never `run`'s input. That is what keeps this at-most-once
1249 /// rather than at-least-once: if the command line was lost with the
1250 /// connection, the re-issued await finds no return, and the agent is told
1251 /// `timeout` — which is true and checkable — instead of the shell running
1252 /// `make deploy` a second time because a client decided to be helpful.
1253 /// A wait may be repeated because asking twice changes nothing; an input
1254 /// may not, because it changes everything.
1255 ///
1256 /// `ConnectionLost` is the ONLY error that reconnects, and the asymmetry
1257 /// with `SendStalled` is deliberate. A stall means the peer is still there
1258 /// but has stopped acknowledging a quarter-megabyte of backlog — it has
1259 /// already spent the flush bound proving that, and at muxa's frame sizes
1260 /// it is very nearly unreachable — so a redial would be a second guess
1261 /// about a connection that never said it was gone.
1262 ///
1263 /// A redial that fails does not swallow the reason: it is recorded on the
1264 /// Conn and `ConnectionLost` is re-raised, so the verb reports what went
1265 /// wrong FIRST (the path tore) and what went wrong second (the redial),
1266 /// rather than only the second. See `Conn.reconnect_failure`.
1267 fn awaitReissuing(
1268 alloc: std.mem.Allocator,
1269 conn: *Conn,
1270 o: Opts,
1271 since_seq: u64,
1272 deadline: i64,
1273 ) !proto.AwaitReply {
1274 return doAwait(alloc, conn, o, since_seq, deadline) catch |e| switch (e) {
1275 error.ConnectionLost => {
1276 if (conn.link != .quic or conn.link.quic.reconnected) return e;
1277 conn.reconnect(deadline) catch |redial| {
1278 conn.reconnect_failure = @errorName(redial);
1279 return e;
1280 };
1281 // The attach is part of the reconnect, not a separate step: a
1282 // daemon that lost our connection lost the client slot with
1283 // it, so an await_req arriving unattached asks about nothing.
1284 // A failure here is still the reconnect failing.
1285 attachZero(conn, deadline) catch |reattach| {
1286 conn.reconnect_failure = @errorName(reattach);
1287 return e;
1288 };
1289 return doAwait(alloc, conn, o, since_seq, deadline);
1290 },
1291 else => e,
1292 };
1293 }
1294
1295 /// What a wait that ended without a reply says past the verb's own
1296 /// "no reply". Every error but one is its own name, exactly as before —
1297 /// the socket arm's failures are untouched — because `ConnectionLost` is
1298 /// the only one whose name is half the story.
1299 ///
1300 /// The three endings a lost connection has, and they are worth telling
1301 /// apart: the redial failed (why), the redial had already been spent (so
1302 /// this is the second tear of the same wait), or nothing tried to redial.
1303 fn waitFailDetail(buf: []u8, conn: *const Conn, e: anyerror) []const u8 {
1304 if (e != error.ConnectionLost) return @errorName(e);
1305 if (conn.reconnect_failure) |why| {
1306 return std.fmt.bufPrint(buf, "connection lost; reconnect failed: {s}", .{why}) catch
1307 "connection lost; reconnect failed";
1308 }
1309 const spent = switch (conn.link) {
1310 .quic => |q| q.reconnected,
1311 .fd => false,
1312 };
1313 if (spent) return "connection lost again, after the one reconnect";
1314 return "connection lost";
1315 }
1316
1317 /// The session's RETURN WATERMARK: the seq of the last command return, 0 if
1318 /// none. Handed straight to `since_seq`, where it means "only a return
1319 /// newer than this may answer me".
1320 fn currentSeq(alloc: std.mem.Allocator, conn: *Conn, deadline: i64) !u64 {
1321 try conn.sendFrame(.status_req, "", deadline);
1322 const frame = try conn.awaitFrame(.status_reply, deadline);
1323 defer frame.deinit(alloc);
1324 const s = try proto.decodeStatusReply(frame.payload);
1325 return s.cmd.seq;
1326 }
1327
1328 /// Strip the styling out of scrollback rows: an agent reading `output`
1329 /// wants what the command printed, not how it was coloured.
1330 ///
1331 /// CSI (ESC [ … final byte) and OSC (ESC ] … BEL or ST) go, as does any
1332 /// other two-byte escape; text and newlines stay. Deliberately not a VT
1333 /// parser — these rows come from our own formatter, which emits SGR and
1334 /// nothing more exotic. Caller frees.
1335 fn stripSgr(alloc: std.mem.Allocator, s: []const u8) ![]u8 {
1336 var out: std.ArrayList(u8) = .empty;
1337 errdefer out.deinit(alloc);
1338 var i: usize = 0;
1339 while (i < s.len) {
1340 if (s[i] != 0x1b or i + 1 >= s.len) {
1341 try out.append(alloc, s[i]);
1342 i += 1;
1343 continue;
1344 }
1345 switch (s[i + 1]) {
1346 '[' => {
1347 i += 2;
1348 // Parameter and intermediate bytes, then one final byte in
1349 // 0x40..0x7e that ends the sequence.
1350 while (i < s.len and (s[i] < 0x40 or s[i] > 0x7e)) i += 1;
1351 if (i < s.len) i += 1;
1352 },
1353 ']' => {
1354 i += 2;
1355 while (i < s.len) : (i += 1) {
1356 if (s[i] == 0x07) {
1357 i += 1;
1358 break;
1359 }
1360 if (s[i] == 0x1b and i + 1 < s.len and s[i + 1] == '\\') {
1361 i += 2;
1362 break;
1363 }
1364 }
1365 },
1366 // ESC 7, ESC M and friends: two bytes, both dropped.
1367 else => i += 2,
1368 }
1369 }
1370 return out.toOwnedSlice(alloc);
1371 }
1372
1373 test "stripSgr leaves text, drops SGR and OSC" {
1374 const alloc = std.testing.allocator;
1375 const got = try stripSgr(alloc, "\x1b[0m\x1b[1;31mred\x1b[0m ok\n\x1b]0;title\x07plain");
1376 defer alloc.free(got);
1377 try std.testing.expectEqualStrings("red ok\nplain", got);
1378 }
1379
1380 /// The rows a command occupied, as plain text. `end_row` is the row the D
1381 /// mark landed on — the prompt redraw — so the span is [start_row, end_row)
1382 /// and an end at or before the start is simply no output.
1383 ///
1384 /// Rows are absolute screen rows and best-effort by construction (see
1385 /// MarkEvent.row): the alt screen and scrollback pruning can invalidate
1386 /// them between the reply and this fetch. Every failure mode here is
1387 /// therefore a null output, never a failed run — the exit code is the
1388 /// answer, and the transcript is the bonus.
1389 fn fetchSpan(
1390 alloc: std.mem.Allocator,
1391 conn: *Conn,
1392 start_row: u32,
1393 end_row: u32,
1394 deadline: i64,
1395 ) !?[]u8 {
1396 if (end_row <= start_row) return null;
1397 const count: u16 = @intCast(@min(end_row - start_row, std.math.maxInt(u16)));
1398 try conn.sendFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start_row, count), deadline);
1399 const frame = try conn.awaitFrame(.scrollback_chunk, deadline);
1400 defer frame.deinit(alloc);
1401 // The chunk leads with the request it answers; the rows follow.
1402 if (frame.payload.len <= 6) return null;
1403 return try stripSgr(alloc, frame.payload[6..]);
1404 }
1405
1406 /// One JSON object: what ended the wait, what the session's command state
1407 /// was when it ended, and how long we waited. `output` is present only when
1408 /// there is a transcript to give — an absent key and an empty string are
1409 /// different answers.
1410 fn printAwaitReply(
1411 writer: anytype,
1412 r: proto.AwaitReply,
1413 output: ?[]const u8,
1414 duration_ms: i64,
1415 ) !void {
1416 try writer.writeAll("{\"reason\":");
1417 try jsonEscape(writer, @tagName(r.reason));
1418 try writer.writeAll(",");
1419 try writeCmdFields(writer, r.state);
1420 try writer.print(",\"duration_ms\":{d}", .{duration_ms});
1421 if (output) |text| {
1422 try writer.writeAll(",\"output\":");
1423 try jsonEscape(writer, text);
1424 }
1425 try writer.writeAll("}\n");
1426 }
1427
1428 test "printAwaitReply omits output when there is none and spells a missing code null" {
1429 const alloc = std.testing.allocator;
1430 const r: proto.AwaitReply = .{
1431 .state = .{
1432 .phase = .returned,
1433 .mechanism = .settle,
1434 .exit_code = null,
1435 .start_row = 3,
1436 .end_row = 9,
1437 .seq = 12,
1438 },
1439 .reason = .settled,
1440 };
1441
1442 var bare: std.ArrayList(u8) = .empty;
1443 defer bare.deinit(alloc);
1444 try printAwaitReply(bare.writer(alloc), r, null, 250);
1445 try std.testing.expectEqualStrings(
1446 "{\"reason\":\"settled\",\"phase\":\"returned\",\"mechanism\":\"settle\"," ++
1447 "\"exit_code\":null,\"start_row\":3,\"end_row\":9,\"duration_ms\":250}\n",
1448 bare.items,
1449 );
1450
1451 var with: std.ArrayList(u8) = .empty;
1452 defer with.deinit(alloc);
1453 try printAwaitReply(with.writer(alloc), r, "a\nb", 250);
1454 try std.testing.expect(std.mem.indexOf(u8, with.items, "\"output\":\"a\\nb\"") != null);
1455 }
1456
1457 /// The session ran its last command. An ANSWER for `run` and `await` — the
1458 /// command is over and this is how — so it prints on stdout and exits 0,
1459 /// unlike the other verbs, which have nothing to report and fail.
1460 fn printSessionEnded(writer: anytype, code: ?u8, duration_ms: i64) !void {
1461 try writer.writeAll("{\"reason\":\"session_ended\",\"exit_code\":");
1462 try writeExitCode(writer, code);
1463 try writer.print(",\"duration_ms\":{d}}}\n", .{duration_ms});
1464 }
1465
1466 /// Print an await outcome and choose the exit code for it. A timeout is the
1467 /// only nonzero one: the agent asked a question and got "still running",
1468 /// which is a distinct thing to branch on, while `returned` and `settled`
1469 /// are both answers — including a command that returned nonzero, whose
1470 /// failure is in `exit_code`, not in muxa's.
1471 fn reportAwait(
1472 alloc: std.mem.Allocator,
1473 r: proto.AwaitReply,
1474 output: ?[]const u8,
1475 duration_ms: i64,
1476 ) !u8 {
1477 var out: std.ArrayList(u8) = .empty;
1478 defer out.deinit(alloc);
1479 try printAwaitReply(out.writer(alloc), r, output, duration_ms);
1480 proto.writeAllFd(std.posix.STDOUT_FILENO, out.items) catch {};
1481 return if (r.reason == .timeout) 3 else 0;
1482 }
1483
1484 fn reportSessionEnded(alloc: std.mem.Allocator, code: ?u8, duration_ms: i64) !u8 {
1485 var out: std.ArrayList(u8) = .empty;
1486 defer out.deinit(alloc);
1487 try printSessionEnded(out.writer(alloc), code, duration_ms);
1488 proto.writeAllFd(std.posix.STDOUT_FILENO, out.items) catch {};
1489 return 0;
1490 }
1491
1492 /// `await` and `run` are one pipeline: attach claiming no grid, read the
1493 /// watermark, wait for the session to come to rest, report. `run` is that
1494 /// pipeline with a command line put in — the line is sent between the
1495 /// watermark and the wait, and the marks span is fetched at the end — so
1496 /// `cmdline` being non-null IS the difference between the two verbs, and
1497 /// they are written once rather than twice with the middle diverging.
1498 fn awaitVerb(
1499 alloc: std.mem.Allocator,
1500 conn: *Conn,
1501 o: Opts,
1502 deadline: i64,
1503 cmdline: ?[]const u8,
1504 ) !u8 {
1505 // Every error string this function can print names the verb the user
1506 // typed, because "attach failed" from the wrong verb sends an agent
1507 // looking in the wrong place.
1508 const who = if (cmdline == null) "await" else "run";
1509 const started = std.time.milliTimestamp();
1510
1511 attachZero(conn, deadline) catch |e| return failAs(who, "attach failed", @errorName(e));
1512
1513 // BEFORE the input, not after: the watermark has to be the one this
1514 // command must beat. Read afterwards, a command fast enough to return
1515 // between the two would have already moved the seq past a value we
1516 // never recorded, and the await would sit waiting for a return that
1517 // had happened.
1518 const since = currentSeq(alloc, conn, deadline) catch |e| switch (e) {
1519 error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)),
1520 else => return failAs(who, "status failed", @errorName(e)),
1521 };
1522
1523 if (cmdline) |cmd| {
1524 // The cmdline goes to the pty verbatim — escapes are `send`'s
1525 // business — plus the newline that submits it. No ack round-trip is
1526 // needed the way `send` needs one: the await_req that follows is
1527 // itself the read that proves the daemon got past this frame, and
1528 // this process stays connected until the reply lands.
1529 const line = std.fmt.allocPrint(alloc, "{s}\n", .{cmd}) catch |e|
1530 return failAs(who, "cannot build the command line", @errorName(e));
1531 defer alloc.free(line);
1532 conn.sendFrame(.input, line, deadline) catch |e|
1533 return failAs(who, "input failed", @errorName(e));
1534 }
1535
1536 const r = awaitReissuing(alloc, conn, o, since, awaitDeadline(o, conn)) catch |e| switch (e) {
1537 error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)),
1538 else => {
1539 var detail: [128]u8 = undefined;
1540 return failAs(who, "no reply", waitFailDetail(&detail, conn, e));
1541 },
1542 };
1543
1544 // Only the marks regime knows where the command's rows are; pgid and
1545 // settle answer WHEN, never WHERE, and a span from them would be a
1546 // guess dressed as a transcript. `await` never fetches one at all: it
1547 // did not start the command, so the span it would name is not its own.
1548 var output: ?[]u8 = null;
1549 defer if (output) |text| alloc.free(text);
1550 if (cmdline != null and r.state.mechanism == .marks and r.reason == .returned) {
1551 output = fetchSpan(
1552 alloc,
1553 conn,
1554 r.state.start_row,
1555 r.state.end_row,
1556 @max(deadline, deadlineFor(span_fetch_ms)),
1557 ) catch null;
1558 }
1559
1560 return reportAwait(alloc, r, output, elapsed(started));
1561 }
1562
1563 fn elapsed(started: i64) i64 {
1564 return std.time.milliTimestamp() - started;
1565 }
1566
1567 /// This client's deadline for the await itself — the daemon's own bound
1568 /// plus the grace window (see await_grace_ms and Conn.graceMs, which is
1569 /// where the transport gets to widen it). An unbounded request stays
1570 /// unbounded here too.
1571 fn awaitDeadline(o: Opts, conn: *const Conn) i64 {
1572 if (o.timeout_ms == 0) return std.math.maxInt(i64);
1573 return std.time.milliTimestamp() + o.timeout_ms + conn.graceMs();
1574 }
src/protocol.zig
Old New
@@ -18,6 +18,8 @@ pub const MsgType = enum(u8) {
18 stats_req = 0x06, // payload: empty 18 stats_req = 0x06, // payload: empty
19 stop_req = 0x07, // payload: empty; daemon shuts down as if signalled 19 stop_req = 0x07, // payload: empty; daemon shuts down as if signalled
20 endpoint_req = 0x08, // payload: empty; asks for the QUIC port, binding a listener lazily if needed 20 endpoint_req = 0x08, // payload: empty; asks for the QUIC port, binding a listener lazily if needed
21 await_req = 0x09, // payload: u64 LE since_seq, u32 LE settle_ms, u32 LE timeout_ms; either duration 0 = that mechanism off
22 status_req = 0x0a, // payload: empty
21 debug_dump = 0x7f, // payload: 1 byte: 0 = plain, 1 = vt 23 debug_dump = 0x7f, // payload: 1 byte: 0 = plain, 1 = vt
22 // daemon -> client 24 // daemon -> client
23 snapshot = 0x81, // payload: SnapshotPrefix ++ full-state vt dump 25 snapshot = 0x81, // payload: SnapshotPrefix ++ full-state vt dump
@@ -30,6 +32,9 @@ pub const MsgType = enum(u8) {
30 delta = 0x87, // payload: see DeltaHeader + rows 32 delta = 0x87, // payload: see DeltaHeader + rows
31 pty_mode = 0x88, // payload: 1 byte flags: bit0 icanon, bit1 echo 33 pty_mode = 0x88, // payload: 1 byte flags: bit0 icanon, bit1 echo
32 endpoint_reply = 0x89, // payload: u16 LE port; 0 = no listener could be produced (reason in daemon log) 34 endpoint_reply = 0x89, // payload: u16 LE port; 0 = no listener could be produced (reason in daemon log)
35 cmd_state = 0x8a, // payload: CmdState (see encodeCmdState); pushed on marks-regime transitions
36 await_reply = 0x8b, // payload: CmdState ++ 1 byte AwaitReason
37 status_reply = 0x8c, // payload: StatusReply (see encodeStatusReply)
33 dump_reply = 0xff, // payload: requested dump bytes 38 dump_reply = 0xff, // payload: requested dump bytes
34 _, 39 _,
35 }; 40 };
@@ -51,6 +56,44 @@ pub const Frame = struct {
51 } 56 }
52 }; 57 };
53 58
59 /// One frame's boundaries inside a buffer somebody else filled. `payload`
60 /// BORROWS from that buffer and is valid only until it is written to or
61 /// shifted, which is why this type is separate from `Frame`: the callers
62 /// that need to keep a payload copy it out themselves, and the ones that
63 /// only need to read it never allocate at all.
64 pub const Delimited = struct {
65 type: MsgType,
66 payload: []const u8,
67 /// Header plus payload — what the caller must drop off the front of
68 /// its buffer before looking for the next frame.
69 consumed: usize,
70 };
71
72 /// Delimit the frame at the front of `buf`, without copying.
73 ///
74 /// Null means the tail is still partial — a header that has not all
75 /// arrived, or a payload still in flight — which is the ordinary state of
76 /// a byte stream and never an error. `error.FrameTooLarge` means a length
77 /// no frame can legitimately carry: the stream is not what we think it is,
78 /// and reading on would size an allocation from a number the peer chose.
79 ///
80 /// Pure, and takes a plain slice rather than any connection, so both ends
81 /// of the wire delimit with the same arithmetic and it can be exercised
82 /// against a canned buffer. The type byte is read through a non-exhaustive
83 /// enum on purpose: an unknown message type is the peer's business to have
84 /// sent and the caller's to ignore, not a reason to refuse the stream.
85 pub fn delimitFrame(buf: []const u8) !?Delimited {
86 if (buf.len < frame_header_len) return null;
87 const len = std.mem.readInt(u32, buf[1..5], .little);
88 if (len > max_payload) return error.FrameTooLarge;
89 if (buf.len < frame_header_len + len) return null;
90 return .{
91 .type = @enumFromInt(buf[0]),
92 .payload = buf[frame_header_len..][0..len],
93 .consumed = frame_header_len + len,
94 };
95 }
96
54 pub fn writeFrame(fd: std.posix.fd_t, t: MsgType, payload: []const u8) !void { 97 pub fn writeFrame(fd: std.posix.fd_t, t: MsgType, payload: []const u8) !void {
55 var hdr: [5]u8 = undefined; 98 var hdr: [5]u8 = undefined;
56 hdr[0] = @intFromEnum(t); 99 hdr[0] = @intFromEnum(t);
@@ -161,6 +204,177 @@ pub fn decodeEndpointReply(payload: []const u8) !u16 {
161 return std.mem.readInt(u16, payload[0..2], .little); 204 return std.mem.readInt(u16, payload[0..2], .little);
162 } 205 }
163 206
207 /// Where the command-boundary signal came from, weakest-last. `marks` is the
208 /// only mechanism that can carry an exit code; consumers must check it
209 /// before trusting one.
210 pub const Mechanism = enum(u8) { marks = 0, pgid = 1, settle = 2 };
211
212 pub const CmdPhase = enum(u8) { at_prompt = 0, running = 1, returned = 2 };
213
214 /// One snapshot of the session's command state machine. Rows are absolute
215 /// screen-space rows (0 = oldest retained history row) — meaningless while
216 /// the alt screen is active, and shifted once the scrollback ring prunes,
217 /// so spans should be fetched promptly.
218 pub const CmdState = struct {
219 phase: CmdPhase,
220 mechanism: Mechanism,
221 exit_code: ?u8,
222 start_row: u32,
223 end_row: u32,
224 /// Two different numbers travel in this field, and which one it is
225 /// depends on the frame carrying it:
226 ///
227 /// * In `status_reply` and in the `cmd_state` pushes the marks stream
228 /// produces, it is the RETURN WATERMARK — the seq stamped when a
229 /// command last returned, 0 if none has this session. This is the
230 /// series `AwaitReq.since_seq` is compared against.
231 /// * In an `await_reply` resolved by anything other than marks (the
232 /// pgid, settle and timeout fallbacks), it is instead the delta
233 /// tracker's CURRENT seq — a grid-content ordering, so the answer
234 /// can be placed against the deltas the client holds.
235 ///
236 /// So: an agent must take its next `since_seq` from a status or marks
237 /// reply, never from a fallback one. Feeding a tracker seq back as a
238 /// watermark compares across two series, and a client that did it would
239 /// wait out its next await for a return that had already happened.
240 ///
241 /// OPEN DESIGN NOTE, recorded rather than acted on: it is not settled
242 /// that the fallback arms should stamp anything into this field, since
243 /// they have no return to watermark. Making them send the watermark
244 /// instead would collapse the two meanings into one — and would change
245 /// the bytes on the wire, so it is a protocol decision, not a cleanup.
246 seq: u64,
247 };
248
249 pub const cmd_state_len = 20;
250
251 pub fn encodeCmdState(s: CmdState) [cmd_state_len]u8 {
252 var buf: [cmd_state_len]u8 = undefined;
253 buf[0] = @intFromEnum(s.phase);
254 buf[1] = @intFromEnum(s.mechanism);
255 buf[2] = @intFromBool(s.exit_code != null);
256 buf[3] = s.exit_code orelse 0;
257 std.mem.writeInt(u32, buf[4..8], s.start_row, .little);
258 std.mem.writeInt(u32, buf[8..12], s.end_row, .little);
259 std.mem.writeInt(u64, buf[12..20], s.seq, .little);
260 return buf;
261 }
262
263 fn enumFromByte(comptime E: type, b: u8) !E {
264 return std.meta.intToEnum(E, b) catch error.BadPayload;
265 }
266
267 pub fn decodeCmdState(payload: []const u8) !CmdState {
268 if (payload.len != cmd_state_len) return error.BadPayload;
269 return .{
270 .phase = try enumFromByte(CmdPhase, payload[0]),
271 .mechanism = try enumFromByte(Mechanism, payload[1]),
272 // byte 2 is a presence flag, not a bool-strict 0/1 check: any
273 // nonzero value reads as "present" deliberately, so a peer that
274 // ever widens what it writes there doesn't silently lose the code.
275 .exit_code = if (payload[2] != 0) payload[3] else null,
276 .start_row = std.mem.readInt(u32, payload[4..8], .little),
277 .end_row = std.mem.readInt(u32, payload[8..12], .little),
278 .seq = std.mem.readInt(u64, payload[12..20], .little),
279 };
280 }
281
282 /// A request to be told when the session next returns to rest.
283 ///
284 /// `since_seq` is what the caller already knows about: only a return strictly
285 /// newer than it may answer, which is what makes re-issuing after a dropped
286 /// connection safe rather than a second wait.
287 ///
288 /// Both durations treat 0 as "off", not as "immediately": `settle_ms = 0`
289 /// declines the output-silence mechanism altogether, and `timeout_ms = 0` is
290 /// an await with no deadline, ending only when marks, the pgid edge or settle
291 /// end it. A caller that wants to poll rather than wait asks for a small
292 /// timeout, never a zero one.
293 pub const AwaitReq = struct { since_seq: u64, settle_ms: u32, timeout_ms: u32 };
294
295 pub const await_req_len = 16;
296
297 pub fn encodeAwaitReq(r: AwaitReq) [await_req_len]u8 {
298 var buf: [await_req_len]u8 = undefined;
299 std.mem.writeInt(u64, buf[0..8], r.since_seq, .little);
300 std.mem.writeInt(u32, buf[8..12], r.settle_ms, .little);
301 std.mem.writeInt(u32, buf[12..16], r.timeout_ms, .little);
302 return buf;
303 }
304
305 pub fn decodeAwaitReq(payload: []const u8) !AwaitReq {
306 if (payload.len != await_req_len) return error.BadPayload;
307 return .{
308 .since_seq = std.mem.readInt(u64, payload[0..8], .little),
309 .settle_ms = std.mem.readInt(u32, payload[8..12], .little),
310 .timeout_ms = std.mem.readInt(u32, payload[12..16], .little),
311 };
312 }
313
314 pub const AwaitReason = enum(u8) { returned = 0, settled = 1, timeout = 2 };
315
316 pub const await_reply_len = cmd_state_len + 1;
317
318 pub fn encodeAwaitReply(s: CmdState, reason: AwaitReason) [await_reply_len]u8 {
319 var buf: [await_reply_len]u8 = undefined;
320 buf[0..cmd_state_len].* = encodeCmdState(s);
321 buf[cmd_state_len] = @intFromEnum(reason);
322 return buf;
323 }
324
325 pub const AwaitReply = struct { state: CmdState, reason: AwaitReason };
326
327 pub fn decodeAwaitReply(payload: []const u8) !AwaitReply {
328 if (payload.len != await_reply_len) return error.BadPayload;
329 return .{
330 .state = try decodeCmdState(payload[0..cmd_state_len]),
331 .reason = try enumFromByte(AwaitReason, payload[cmd_state_len]),
332 };
333 }
334
335 /// One structured snapshot for `muxa status`. The grid facts a driving
336 /// agent needs before deciding how to interact: size, cursor, whether a
337 /// TUI holds the screen, who echoes keystrokes, and the command state.
338 pub const StatusReply = struct {
339 cols: u16,
340 rows: u16,
341 cursor_x: u16,
342 cursor_y: u16,
343 history_rows: u32,
344 alt_screen: bool,
345 mode: PtyModeFlags,
346 cmd: CmdState,
347 };
348
349 pub const status_reply_len = 14 + cmd_state_len;
350
351 pub fn encodeStatusReply(s: StatusReply) [status_reply_len]u8 {
352 var buf: [status_reply_len]u8 = undefined;
353 std.mem.writeInt(u16, buf[0..2], s.cols, .little);
354 std.mem.writeInt(u16, buf[2..4], s.rows, .little);
355 std.mem.writeInt(u16, buf[4..6], s.cursor_x, .little);
356 std.mem.writeInt(u16, buf[6..8], s.cursor_y, .little);
357 std.mem.writeInt(u32, buf[8..12], s.history_rows, .little);
358 buf[12] = @intFromBool(s.alt_screen);
359 buf[13] = @bitCast(s.mode);
360 buf[14..][0..cmd_state_len].* = encodeCmdState(s.cmd);
361 return buf;
362 }
363
364 pub fn decodeStatusReply(payload: []const u8) !StatusReply {
365 if (payload.len != status_reply_len) return error.BadPayload;
366 return .{
367 .cols = std.mem.readInt(u16, payload[0..2], .little),
368 .rows = std.mem.readInt(u16, payload[2..4], .little),
369 .cursor_x = std.mem.readInt(u16, payload[4..6], .little),
370 .cursor_y = std.mem.readInt(u16, payload[6..8], .little),
371 .history_rows = std.mem.readInt(u32, payload[8..12], .little),
372 .alt_screen = payload[12] != 0,
373 .mode = try decodePtyMode(payload[13..14]),
374 .cmd = try decodeCmdState(payload[14..][0..cmd_state_len]),
375 };
376 }
377
164 /// The two line-discipline bits that decide who is going to echo a 378 /// The two line-discipline bits that decide who is going to echo a
165 /// keystroke, and therefore whether a client may echo it early. Read off the 379 /// keystroke, and therefore whether a client may echo it early. Read off the
166 /// session's pty by the daemon and shipped verbatim: the client is told what 380 /// session's pty by the daemon and shipped verbatim: the client is told what
@@ -763,3 +977,120 @@ test "endpoint frames match golden bytes" {
763 0x89, 0x02, 0x00, 0x00, 0x00, 0xCA, 0xA8, 977 0x89, 0x02, 0x00, 0x00, 0x00, 0xCA, 0xA8,
764 }, buf.items); 978 }, buf.items);
765 } 979 }
980
981 test "cmd_state encode/decode round trip and golden bytes" {
982 const s = CmdState{
983 .phase = .returned,
984 .mechanism = .marks,
985 .exit_code = 1,
986 .start_row = 80,
987 .end_row = 92,
988 .seq = 258,
989 };
990 const buf = encodeCmdState(s);
991 try std.testing.expectEqualSlices(u8, &[_]u8{
992 2, // phase returned
993 0, // mechanism marks
994 1, // has_exit
995 1, // exit_code
996 0x50, 0, 0, 0, // start_row 80
997 0x5C, 0, 0, 0, // end_row 92
998 0x02, 0x01, 0, 0, 0, 0, 0, 0, // seq 258
999 }, &buf);
1000 const back = try decodeCmdState(&buf);
1001 try std.testing.expectEqual(s, back);
1002 }
1003
1004 test "cmd_state with no exit code round-trips null, not zero" {
1005 const s = CmdState{ .phase = .running, .mechanism = .pgid, .exit_code = null, .start_row = 5, .end_row = 0, .seq = 9 };
1006 const buf = encodeCmdState(s);
1007 // No exit code means byte 3 is unused, but it still goes out as a clean
1008 // zero rather than whatever the encoder happened to have lying around.
1009 try std.testing.expectEqualSlices(u8, &.{ 0, 0 }, buf[2..4]);
1010 const back = try decodeCmdState(&buf);
1011 try std.testing.expectEqual(@as(?u8, null), back.exit_code);
1012 }
1013
1014 test "cmd_state rejects wrong length and unknown enum bytes" {
1015 try std.testing.expectError(error.BadPayload, decodeCmdState(&[_]u8{0} ** (cmd_state_len - 1)));
1016 try std.testing.expectError(error.BadPayload, decodeCmdState(&[_]u8{0} ** (cmd_state_len + 1)));
1017 var bad = encodeCmdState(.{ .phase = .at_prompt, .mechanism = .settle, .exit_code = null, .start_row = 0, .end_row = 0, .seq = 0 });
1018 bad[0] = 9; // phase out of range
1019 try std.testing.expectError(error.BadPayload, decodeCmdState(&bad));
1020 bad[0] = 0;
1021 bad[1] = 9; // mechanism out of range
1022 try std.testing.expectError(error.BadPayload, decodeCmdState(&bad));
1023 }
1024
1025 test "await_req encode/decode round trip" {
1026 const r = try decodeAwaitReq(&encodeAwaitReq(.{ .since_seq = 77, .settle_ms = 500, .timeout_ms = 30_000 }));
1027 try std.testing.expectEqual(@as(u64, 77), r.since_seq);
1028 try std.testing.expectEqual(@as(u32, 500), r.settle_ms);
1029 try std.testing.expectEqual(@as(u32, 30_000), r.timeout_ms);
1030 try std.testing.expectError(error.BadPayload, decodeAwaitReq(&[_]u8{0} ** 15));
1031 }
1032
1033 test "encodeAwaitReq golden bytes" {
1034 try std.testing.expectEqualSlices(u8, &[_]u8{
1035 0x02, 0x01, 0, 0, 0, 0, 0, 0, // since_seq 258
1036 0xF4, 0x01, 0, 0, // settle_ms 500
1037 0x30, 0x75, 0, 0, // timeout_ms 30000
1038 }, &encodeAwaitReq(.{ .since_seq = 258, .settle_ms = 500, .timeout_ms = 30_000 }));
1039 }
1040
1041 test "await_reply is a CmdState plus a reason byte" {
1042 const s = CmdState{ .phase = .returned, .mechanism = .settle, .exit_code = null, .start_row = 0, .end_row = 3, .seq = 4 };
1043 const buf = encodeAwaitReply(s, .settled);
1044 try std.testing.expectEqual(@as(usize, await_reply_len), buf.len);
1045 const back = try decodeAwaitReply(&buf);
1046 try std.testing.expectEqual(AwaitReason.settled, back.reason);
1047 try std.testing.expectEqual(s, back.state);
1048 try std.testing.expectError(error.BadPayload, decodeAwaitReply(&[_]u8{0} ** (await_reply_len - 1)));
1049 try std.testing.expectError(error.BadPayload, decodeAwaitReply(&[_]u8{0} ** (await_reply_len + 1)));
1050 var bad = buf;
1051 bad[cmd_state_len] = 9;
1052 try std.testing.expectError(error.BadPayload, decodeAwaitReply(&bad));
1053 }
1054
1055 test "status_reply encode/decode round trip" {
1056 const s = StatusReply{
1057 .cols = 120,
1058 .rows = 40,
1059 .cursor_x = 3,
1060 .cursor_y = 5,
1061 .history_rows = 77,
1062 .alt_screen = true,
1063 .mode = .{ .icanon = true, .echo = true },
1064 .cmd = .{ .phase = .at_prompt, .mechanism = .marks, .exit_code = 0, .start_row = 1, .end_row = 2, .seq = 6 },
1065 };
1066 const back = try decodeStatusReply(&encodeStatusReply(s));
1067 try std.testing.expectEqual(s, back);
1068 try std.testing.expectError(error.BadPayload, decodeStatusReply(&[_]u8{0} ** (status_reply_len - 1)));
1069 try std.testing.expectError(error.BadPayload, decodeStatusReply(&[_]u8{0} ** (status_reply_len + 1)));
1070 }
1071
1072 test "encodeStatusReply pins the 14-byte prefix layout" {
1073 const s = StatusReply{
1074 .cols = 120,
1075 .rows = 40,
1076 .cursor_x = 3,
1077 .cursor_y = 5,
1078 .history_rows = 77,
1079 .alt_screen = true,
1080 .mode = .{ .icanon = true, .echo = true },
1081 .cmd = .{ .phase = .at_prompt, .mechanism = .marks, .exit_code = 0, .start_row = 1, .end_row = 2, .seq = 6 },
1082 };
1083 const buf = encodeStatusReply(s);
1084 try std.testing.expectEqual(@as(usize, status_reply_len), buf.len);
1085 // The trailing CmdState is already golden-tested via encodeCmdState;
1086 // pinning the 14-byte prefix plus total length is enough here.
1087 try std.testing.expectEqualSlices(u8, &[_]u8{
1088 0x78, 0x00, // cols 120
1089 0x28, 0x00, // rows 40
1090 0x03, 0x00, // cursor_x 3
1091 0x05, 0x00, // cursor_y 5
1092 0x4D, 0x00, 0x00, 0x00, // history_rows 77
1093 1, // alt_screen
1094 0b11, // mode: icanon + echo
1095 }, buf[0..14]);
1096 }
src/pty.zig
Old New
@@ -24,6 +24,13 @@ pub const Pty = struct {
24 return spawnArgv(.{ .cols = opts.cols, .rows = opts.rows, .argv = &argv }); 24 return spawnArgv(.{ .cols = opts.cols, .rows = opts.rows, .argv = &argv });
25 } 25 }
26 26
27 /// One variable to set in the child. Spelled here rather than imported
28 /// so this module stays a leaf: a pty knows how to hand a child an
29 /// environment, and deliberately does not know that shell integration
30 /// is what currently wants one. The daemon maps its own pairs onto
31 /// these — one loop, and the layering stays the right way up.
32 pub const EnvPair = struct { key: [:0]const u8, value: [:0]const u8 };
33
27 pub const SpawnArgvOptions = struct { 34 pub const SpawnArgvOptions = struct {
28 cols: u16, 35 cols: u16,
29 rows: u16, 36 rows: u16,
@@ -34,6 +41,9 @@ pub const Pty = struct {
34 /// The e2e fixture uses this to keep predict stats out of the 41 /// The e2e fixture uses this to keep predict stats out of the
35 /// capture, matching the suite's `.err` sibling convention. 42 /// capture, matching the suite's `.err` sibling convention.
36 stderr_fd: ?std.posix.fd_t = null, 43 stderr_fd: ?std.posix.fd_t = null,
44 /// Set in the child between fork and exec, after TERM. Injection's
45 /// door: the daemon's env is the only source of a child's env.
46 env: []const EnvPair = &.{},
37 }; 47 };
38 48
39 /// The one child-setup path: everything that has to be true of a process 49 /// The one child-setup path: everything that has to be true of a process
@@ -60,6 +70,20 @@ pub const Pty = struct {
60 // Child. xterm-256color: ghostty-vt understands more, but this 70 // Child. xterm-256color: ghostty-vt understands more, but this
61 // terminfo exists everywhere the shell will look. 71 // terminfo exists everywhere the shell will look.
62 _ = c.setenv("TERM", "xterm-256color", 1); 72 _ = c.setenv("TERM", "xterm-256color", 1);
73 // After TERM so a caller could override it, and before the
74 // signal work so the environment is settled whatever follows.
75 //
76 // Overwrite (1), and that is a contract rather than a detail:
77 // the daemon's own value for a name it was handed is not the
78 // one it means the child to see, and — because this is a loop
79 // over an ordered slice — a LATER pair beats an earlier one for
80 // the same key. That is what lets Server.Options.extra_env
81 // override a variable the shell-integration injection set,
82 // which is exactly how the integration tests point HOME at a
83 // temp directory. The rule was pinned only by that usage; it is
84 // spelled out here so a reorder of the slice cannot quietly
85 // invert it.
86 for (opts.env) |kv| _ = c.setenv(kv.key.ptr, kv.value.ptr, 1);
63 87
64 // Ctrl-C must work in the session, and without this it does not. 88 // Ctrl-C must work in the session, and without this it does not.
65 // A non-interactive shell sets SIGINT and SIGQUIT to SIG_IGN for 89 // A non-interactive shell sets SIGINT and SIGQUIT to SIG_IGN for
@@ -118,6 +142,17 @@ pub const Pty = struct {
118 return .{ .icanon = t.lflag.ICANON, .echo = t.lflag.ECHO }; 142 return .{ .icanon = t.lflag.ICANON, .echo = t.lflag.ECHO };
119 } 143 }
120 144
145 /// The foreground process group of the session, read off the master
146 /// with TIOCGPGRP. When it equals `child` (the shell, session leader
147 /// post-forkpty), no foreground job is running — the kernel's own
148 /// "the command returned", available with zero shell cooperation.
149 /// No exit code and no output span; that is what marks are for.
150 pub fn fgPgid(self: *const Pty) !std.posix.pid_t {
151 var pgid: c.pid_t = 0;
152 if (c.ioctl(self.master, c.TIOCGPGRP, &pgid) < 0) return error.IoctlFailed;
153 return @intCast(pgid);
154 }
155
121 pub fn resize(self: *Pty, cols: u16, rows: u16) !void { 156 pub fn resize(self: *Pty, cols: u16, rows: u16) !void {
122 var ws: c.struct_winsize = .{ 157 var ws: c.struct_winsize = .{
123 .ws_row = rows, 158 .ws_row = rows,
@@ -348,6 +383,26 @@ test "Pty: spawnArgv applies the requested winsize" {
348 try std.testing.expect(std.mem.indexOf(u8, out.items, "31 101") != null); 383 try std.testing.expect(std.mem.indexOf(u8, out.items, "31 101") != null);
349 } 384 }
350 385
386 test "Pty: spawnArgv env pairs reach the child" {
387 // The child prints the variable rather than being asked about it: an
388 // exported name that the exec'd process cannot read is the failure
389 // this guards, so the assertion has to come from inside the child.
390 var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "printf 'env-%s' \"$MUX_T\"" };
391 var pty = try Pty.spawnArgv(.{
392 .cols = 80,
393 .rows = 24,
394 .argv = &argv,
395 .env = &.{.{ .key = "MUX_T", .value = "ok" }},
396 });
397 defer pty.deinit();
398
399 var out = try readUntil(std.testing.allocator, &pty, "env-ok", 5000);
400 defer out.deinit(std.testing.allocator);
401 // "env-" alone would appear for an unset variable too, which is exactly
402 // the broken case; the value is what makes this an assertion.
403 try std.testing.expect(std.mem.indexOf(u8, out.items, "env-ok") != null);
404 }
405
351 test "Pty: spawnArgv redirects stderr off the pty when asked" { 406 test "Pty: spawnArgv redirects stderr off the pty when asked" {
352 const pipe = try std.posix.pipe(); 407 const pipe = try std.posix.pipe();
353 defer std.posix.close(pipe[0]); 408 defer std.posix.close(pipe[0]);
@@ -373,3 +428,47 @@ test "Pty: spawnArgv redirects stderr off the pty when asked" {
373 const n = try std.posix.read(pipe[0], &errbuf); 428 const n = try std.posix.read(pipe[0], &errbuf);
374 try std.testing.expect(std.mem.indexOf(u8, errbuf[0..n], "to-err") != null); 429 try std.testing.expect(std.mem.indexOf(u8, errbuf[0..n], "to-err") != null);
375 } 430 }
431
432 test "Pty: fgPgid tracks the foreground job" {
433 const alloc = std.testing.allocator;
434 var pty = try Pty.spawn(.{ .cols = 80, .rows = 24, .shell = "/bin/sh" });
435 defer pty.deinit();
436
437 // Prove the shell is up before asking anything of the pgid.
438 _ = try std.posix.write(pty.master, "printf 'ready-%s\\n' PGID\n");
439 var ready = try readUntil(alloc, &pty, "ready-PGID", 5000);
440 defer ready.deinit(alloc);
441 try std.testing.expect(std.mem.indexOf(u8, ready.items, "ready-PGID") != null);
442
443 // At the prompt, the foreground pgid is the shell's own process group.
444 // sh is the session leader post-forkpty, so its pgid == its pid.
445 try std.testing.expectEqual(pty.child, try pty.fgPgid());
446
447 // A foreground job moves the fg pgid off the shell... eventually: an
448 // interactive sh creates a new process group for the job. Poll for the
449 // change rather than racing it.
450 _ = try std.posix.write(pty.master, "sleep 2\n");
451 var moved = false;
452 var waited_ms: u64 = 0;
453 while (waited_ms < 3000) : (waited_ms += 50) {
454 if (try pty.fgPgid() != pty.child) {
455 moved = true;
456 break;
457 }
458 std.Thread.sleep(50 * std.time.ns_per_ms);
459 }
460 // Dash and busybox sh run foreground jobs in the shell's own group when
461 // job control is off (non-interactive stdin heuristics differ), so a
462 // never-moved pgid is a legal outcome for the fallback design — but on
463 // a pty, POSIX shells enable job control. Assert movement; if this
464 // flakes on some /bin/sh, relax to a log + skip, not a green lie.
465 try std.testing.expect(moved);
466
467 // ...and returns to the shell when the job ends.
468 waited_ms = 0;
469 while (waited_ms < 5000) : (waited_ms += 100) {
470 if (try pty.fgPgid() == pty.child) break;
471 std.Thread.sleep(100 * std.time.ns_per_ms);
472 }
473 try std.testing.expectEqual(pty.child, try pty.fgPgid());
474 }
src/quic.zig
Old New
@@ -39,9 +39,13 @@ pub const default_port: u16 = 4433;
39 /// gone. Long enough that a quiet terminal is not a suspicious one, short 39 /// gone. Long enough that a quiet terminal is not a suspicious one, short
40 /// enough that a client which has genuinely vanished stops being served 40 /// enough that a client which has genuinely vanished stops being served
41 /// within a few seconds of keepalives failing — keepalives run at a third 41 /// within a few seconds of keepalives failing — keepalives run at a third
42 /// of it, so an idle session is never the thing that trips it. Tunable on 42 /// of it, so an idle session is never the thing that trips it.
43 /// both binaries because the reconnect tests need death declared on a 43 /// `--quic-idle-ms` tunes it on the three binaries that dial or listen for
44 /// schedule they can wait for. 44 /// a human — `muxd`, `mux`, `muxweb` — because the reconnect tests need
45 /// death declared on a schedule they can wait for. `muxa` deliberately has
46 /// no such flag and always takes this default: an agent's wait is bounded
47 /// by `--timeout` already, and a second knob over the same wait is one
48 /// more thing for a driver to get wrong.
45 /// 49 ///
46 /// Here for the same reason as `default_port`: two copies of a number both 50 /// Here for the same reason as `default_port`: two copies of a number both
47 /// binaries default to are two numbers, and they drift in silence. 51 /// binaries default to are two numbers, and they drift in silence.
@@ -54,6 +58,87 @@ pub const psk_ciphersuite: [*:0]const u8 = "TLS13-AES128-GCM-SHA256";
54 pub const alpn = "\x03mux"; 58 pub const alpn = "\x03mux";
55 59
56 // --------------------------------------------------------------------------- 60 // ---------------------------------------------------------------------------
61 // The dial address grammar
62 // ---------------------------------------------------------------------------
63
64 /// `HOST[:PORT]`, as a client types it: `mux quic://HOST:PORT` and
65 /// `muxa --quic HOST:PORT` accept exactly these spellings, brackets and
66 /// all. One owner for the same reason `default_port` has one — an agent
67 /// and a human pointing at the same daemon must be able to type the same
68 /// thing, and two copies of a grammar drift into two dialects of one flag.
69 ///
70 /// A name is resolved rather than refused: unlike muxd's `--quic`, which
71 /// names an address to BIND, this one names a box to reach, and a box is
72 /// normally spelled with a name.
73 pub fn parseAddr(alloc: std.mem.Allocator, host_port: []const u8) !std.net.Address {
74 // `[::1]` — bracketed and portless: the brackets say where the address
75 // stops, so the port can default.
76 if (host_port.len >= 2 and host_port[0] == '[' and host_port[host_port.len - 1] == ']')
77 return resolveHost(alloc, host_port[1 .. host_port.len - 1], default_port);
78 const colon = std.mem.lastIndexOfScalar(u8, host_port, ':') orelse
79 return resolveHost(alloc, host_port, default_port);
80 var host = host_port[0..colon];
81 const port_s = host_port[colon + 1 ..];
82 // `[::1]:4433` — brackets are how an IPv6 literal says where it stops.
83 if (host.len >= 2 and host[0] == '[' and host[host.len - 1] == ']') {
84 host = host[1 .. host.len - 1];
85 } else if (std.mem.indexOfScalar(u8, host, ':') != null) {
86 // Unbracketed and full of colons: an IPv6 literal missing its
87 // brackets, which would otherwise have its last group taken as a
88 // port. Refused rather than guessed at.
89 return error.MalformedAddress;
90 }
91 const port = std.fmt.parseInt(u16, port_s, 10) catch return error.MalformedAddress;
92 return resolveHost(alloc, host, port);
93 }
94
95 /// A host that is already known to be unambiguous, plus the port it goes
96 /// with: literal if it parses as one, resolved if it does not.
97 pub fn resolveHost(alloc: std.mem.Allocator, host: []const u8, port: u16) !std.net.Address {
98 if (host.len == 0) return error.MalformedAddress;
99 if (std.net.Address.parseIp(host, port)) |addr| return addr else |_| {}
100 // Not a literal: resolve it. A remote host is normally a name.
101 const list = try std.net.getAddressList(alloc, host, port);
102 defer list.deinit();
103 if (list.addrs.len == 0) return error.UnknownHostName;
104 return list.addrs[0];
105 }
106
107 test "parseAddr: literals, brackets, and the spellings that are refused" {
108 const alloc = std.testing.allocator;
109 // Literals only here: a name would send this test to a resolver, and
110 // what it answered would depend on the machine running it.
111 try std.testing.expectEqual(
112 @as(u16, 4433),
113 (try parseAddr(alloc, "127.0.0.1:4433")).getPort(),
114 );
115 try std.testing.expectEqual(@as(u16, 9), (try parseAddr(alloc, "127.0.0.1:9")).getPort());
116
117 // An omitted port means mux's own. Spelled out rather than written
118 // `default_port`, because comparing the parse's answer against the
119 // constant the parse reads would hold for any value and say nothing
120 // about the port — and this is the number the daemon at the other end
121 // has to agree on.
122 try std.testing.expectEqual(@as(u16, 4433), (try parseAddr(alloc, "10.0.0.2")).getPort());
123
124 const six = try parseAddr(alloc, "[::1]:9999");
125 try std.testing.expectEqual(@as(u16, 9999), six.getPort());
126 try std.testing.expect(six.any.family == std.posix.AF.INET6);
127 // Bracketed and portless: the brackets say where the address stops, so
128 // the port can default.
129 try std.testing.expectEqual(@as(u16, 4433), (try parseAddr(alloc, "[::1]")).getPort());
130
131 // An unbracketed IPv6 literal would have its last group read as a
132 // port. Refused rather than guessed at — the same refusal muxd's
133 // splitHostPort makes about its bind address.
134 try std.testing.expectError(error.MalformedAddress, parseAddr(alloc, "fe80::1:4433"));
135 try std.testing.expectError(error.MalformedAddress, parseAddr(alloc, "127.0.0.1:"));
136 try std.testing.expectError(error.MalformedAddress, parseAddr(alloc, "127.0.0.1:99999"));
137 try std.testing.expectError(error.MalformedAddress, parseAddr(alloc, ""));
138 try std.testing.expectError(error.MalformedAddress, parseAddr(alloc, ":4433"));
139 }
140
141 // ---------------------------------------------------------------------------
57 // The pre-shared key 142 // The pre-shared key
58 // --------------------------------------------------------------------------- 143 // ---------------------------------------------------------------------------
59 144
src/server.zig
Old New
@@ -10,6 +10,8 @@ const Engine = @import("engine").Engine;
10 const Pty = @import("pty").Pty; 10 const Pty = @import("pty").Pty;
11 const proto = @import("protocol"); 11 const proto = @import("protocol");
12 const DeltaTracker = @import("delta").DeltaTracker; 12 const DeltaTracker = @import("delta").DeltaTracker;
13 const cmdmod = @import("cmd");
14 const shellint = @import("shellint");
13 // Test-only consumer (applyFrame): the tests replay daemon frames through 15 // Test-only consumer (applyFrame): the tests replay daemon frames through
14 // the production client's replay core rather than a hand-rolled twin. 16 // the production client's replay core rather than a hand-rolled twin.
15 const replica_mod = @import("replica"); 17 const replica_mod = @import("replica");
@@ -212,6 +214,30 @@ const ClientSlot = struct {
212 /// Server.pending_cap; a peer that stops reading gets dropped, never 214 /// Server.pending_cap; a peer that stops reading gets dropped, never
213 /// waited on — one slow WAN client must not stall the session. 215 /// waited on — one slow WAN client must not stall the session.
214 pending: std.ArrayList(u8) = .empty, 216 pending: std.ArrayList(u8) = .empty,
217 /// An await_req held open. At most one per client: a second one
218 /// replaces the first (the client is a serial CLI; queueing two would
219 /// be inventing a use case).
220 await_state: ?AwaitState = null,
221 };
222
223 /// One client's outstanding await: the request as asked, plus the two pieces
224 /// of state resolving it needs to carry across pumps.
225 const AwaitState = struct {
226 since_seq: u64,
227 settle_ms: u32,
228 timeout_ms: u32,
229 /// milliTimestamp at acceptance; timeout measures from here.
230 started_ms: i64,
231 /// pgid fallback edge detector: set once the fg pgid has been seen off
232 /// the shell, so "back on the shell" means returned, not never-left.
233 ///
234 /// Note what this does NOT distinguish: an await issued while some
235 /// earlier command was already running answers on that command's tail,
236 /// not on a command the client started. That is still honest against
237 /// what the pgid can actually claim — "a foreground job has returned
238 /// since you asked" — and the null exit code keeps it from being read
239 /// as more. A client that needs "the command I started" needs marks.
240 saw_busy: bool = false,
215 }; 241 };
216 242
217 pub const Server = struct { 243 pub const Server = struct {
@@ -266,6 +292,39 @@ pub const Server = struct {
266 mode_sent: ?proto.PtyModeFlags = null, 292 mode_sent: ?proto.PtyModeFlags = null,
267 /// Row-level change tracking behind the delta stream. 293 /// Row-level change tracking behind the delta stream.
268 tracker: DeltaTracker = .{}, 294 tracker: DeltaTracker = .{},
295 /// The session's command state machine (OSC 133). Seq-stamped copies of
296 /// its transitions are what cmd_state/await_reply/status_reply carry.
297 cmd: cmdmod.Tracker = .{},
298 /// The last completed command, frozen as it stood the moment it
299 /// returned, and the one owner of the return watermark: `.seq` is
300 /// tracker.seq as of that return, so "a return happened at or before
301 /// this seq" and "here is what it was" are one fact rather than two
302 /// fields that have to be kept in step. Null until a command returns,
303 /// which is what every reader spells as a watermark of 0.
304 ///
305 /// It exists because an await answers a question about a PAST event, and
306 /// the live tracker stops being able to describe that event almost
307 /// immediately. A real integrated shell emits `D;code` and the next
308 /// prompt's `A` in a single precmd write, so both fold into the tracker
309 /// before any await is looked at: phase is already back to `at_prompt`,
310 /// and the following command's `C` then clears the exit code outright.
311 /// Answering from live state therefore lost the verdict a fraction of a
312 /// pump after earning it — fast commands rode to their timeout, slow
313 /// ones degraded to pgid and threw the exit code away.
314 last_return: ?proto.CmdState = null,
315 /// milliTimestamp of the last byte the pty produced; the settle floor.
316 /// 0 means the session has never said anything, which no amount of
317 /// elapsed silence should be read as a command having finished.
318 last_pty_ms: i64 = 0,
319 /// Holds every string the shell-integration injection handed the spawn:
320 /// the shim directory's path, the argv, the env pairs. An arena because
321 /// they are allocated once, in init, and freed once, together.
322 shellint_arena: std.heap.ArenaAllocator,
323 /// The shim directory to remove at teardown, or null when nothing was
324 /// written — integration off, or a shell this daemon has no scripts for.
325 /// Distinct from "the arena is empty": only a directory that exists is
326 /// one we are responsible for deleting.
327 shellint_dir: ?[]const u8 = null,
269 stats: Stats = .{}, 328 stats: Stats = .{},
270 329
271 pub const Options = struct { 330 pub const Options = struct {
@@ -273,6 +332,19 @@ pub const Server = struct {
273 shell: [:0]const u8, 332 shell: [:0]const u8,
274 cols: u16 = 80, 333 cols: u16 = 80,
275 rows: u16 = 24, 334 rows: u16 = 24,
335 /// Inject the OSC 133 mark scripts into the session shell. On by
336 /// default: marks are what make an exit code knowable, and every
337 /// fallback below them is a guess. `muxd run` turns it off for
338 /// `MUX_SHELL_INTEGRATION=0`, and a shell shellint has no scripts
339 /// for is unaffected either way.
340 shell_integration: bool = true,
341 /// Extra variables for the session shell, set after the injection's
342 /// own so a caller can override one. The shell-integration tests
343 /// point HOME at a temp directory with it: a shim that sources the
344 /// box's real rc files is a test whose verdict depends on whose box
345 /// it ran on, and this feature's whole job is to be right about
346 /// shells it did not configure.
347 extra_env: []const Pty.EnvPair = &.{},
276 }; 348 };
277 349
278 pub fn init(alloc: std.mem.Allocator, opts: Options) !Server { 350 pub fn init(alloc: std.mem.Allocator, opts: Options) !Server {
@@ -283,7 +355,19 @@ pub const Server = struct {
283 const eng = try Engine.init(alloc, .{ .cols = opts.cols, .rows = opts.rows }); 355 const eng = try Engine.init(alloc, .{ .cols = opts.cols, .rows = opts.rows });
284 errdefer eng.deinit(); 356 errdefer eng.deinit();
285 357
286 var pty = try Pty.spawn(.{ .cols = opts.cols, .rows = opts.rows, .shell = opts.shell }); 358 // Shell integration, decided and written before the fork: whatever
359 // the child is going to be told has to exist on disk by the time it
360 // execs, and a failure here is still cheap — no process yet.
361 var shellint_arena = std.heap.ArenaAllocator.init(alloc);
362 errdefer shellint_arena.deinit();
363 const plan = try prepareSpawn(shellint_arena.allocator(), opts);
364
365 var pty = try Pty.spawnArgv(.{
366 .cols = opts.cols,
367 .rows = opts.rows,
368 .argv = plan.argv,
369 .env = plan.env,
370 });
287 errdefer pty.deinit(); 371 errdefer pty.deinit();
288 372
289 // Random rather than a counter or a timestamp: nothing on disk 373 // Random rather than a counter or a timestamp: nothing on disk
@@ -303,9 +387,61 @@ pub const Server = struct {
303 .sock_path = opts.sock_path, 387 .sock_path = opts.sock_path,
304 .path_id = path_id, 388 .path_id = path_id,
305 .epoch = epoch, 389 .epoch = epoch,
390 .shellint_arena = shellint_arena,
391 .shellint_dir = plan.shellint_dir,
306 }; 392 };
307 } 393 }
308 394
395 /// What `Pty.spawnArgv` has to be handed, once shell integration has
396 /// had its say. Every slice points into the arena `prepareSpawn` was
397 /// given, which must outlive the spawn — spawnArgv reads all of it in
398 /// the child, after the fork.
399 const SpawnPlan = struct {
400 argv: [*:null]const ?[*:0]const u8,
401 env: []const Pty.EnvPair,
402 /// Straight from the injection: the shim directory to delete at
403 /// teardown, or null when nothing was written.
404 shellint_dir: ?[]const u8,
405 };
406
407 /// Turn the session options into that plan. Split out of `init` because
408 /// it is the one part of starting a daemon that is neither the engine,
409 /// the pty nor the listener, and inlining it buried those three.
410 fn prepareSpawn(a: std.mem.Allocator, opts: Options) !SpawnPlan {
411 // Beside the socket: that directory is already private, already
412 // runtime-appropriate and already per-user, which is three
413 // properties the shims need and none of them are ours to re-derive.
414 // What the directory under it is CALLED, whether one was created,
415 // and what to say when the attempt fails are all shellint's —
416 // `install` reports the directory it made, so nothing here has to
417 // re-derive from the shell what that call already knew.
418 const injection: shellint.Injection = if (opts.shell_integration)
419 shellint.install(a, std.fs.path.dirname(opts.sock_path) orelse ".", opts.shell)
420 else
421 shellint.no_injection;
422
423 // shellint speaks its own EnvPair so it can stay a leaf, and so can
424 // pty; the daemon is the one place that knows about both, so the
425 // mapping lives here.
426 const env = try a.alloc(Pty.EnvPair, injection.env.len + opts.extra_env.len);
427 for (injection.env, env[0..injection.env.len]) |src, *dst| {
428 dst.* = .{ .key = src.key, .value = src.value };
429 }
430 // Last, so setenv's overwrite makes the caller's spelling the one
431 // the child sees (see the loop in Pty.spawnArgv).
432 @memcpy(env[injection.env.len..], opts.extra_env);
433
434 // argv is the shell plus whatever the injection adds, null-terminated
435 // for execve. With no extra argv and no env this is byte-identical to
436 // the old `Pty.spawn` call, which is what keeps a /bin/sh session
437 // exactly the session it was before shell integration existed.
438 const argv = try a.allocSentinel(?[*:0]const u8, 1 + injection.extra_argv.len, null);
439 argv[0] = opts.shell.ptr;
440 for (injection.extra_argv, argv[1..]) |src, *dst| dst.* = src.ptr;
441
442 return .{ .argv = argv.ptr, .env = env, .shellint_dir = injection.dir };
443 }
444
309 pub fn deinit(self: *Server) void { 445 pub fn deinit(self: *Server) void {
310 for (&self.clients) |*slot| { 446 for (&self.clients) |*slot| {
311 if (slot.*) |*c| { 447 if (slot.*) |*c| {
@@ -354,6 +490,12 @@ pub const Server = struct {
354 } 490 }
355 self.tracker.deinit(self.alloc); 491 self.tracker.deinit(self.alloc);
356 self.pty.deinit(); 492 self.pty.deinit();
493 // After the pty, so the shell is gone before the files it was
494 // reading are: a shim removed out from under a live shell would be
495 // a session that half-sourced its own integration. Best-effort —
496 // a daemon that cannot tidy /tmp must still exit.
497 if (self.shellint_dir) |dir| std.fs.cwd().deleteTree(dir) catch {};
498 self.shellint_arena.deinit();
357 self.eng.deinit(); 499 self.eng.deinit();
358 } 500 }
359 501
@@ -427,6 +569,10 @@ pub const Server = struct {
427 var buf: [64 * 1024]u8 = undefined; 569 var buf: [64 * 1024]u8 = undefined;
428 const n = std.posix.read(self.pty.master, &buf) catch 0; 570 const n = std.posix.read(self.pty.master, &buf) catch 0;
429 if (n > 0) { 571 if (n > 0) {
572 // Stamped on arrival, before anything is made of the bytes:
573 // the settle floor measures silence on the wire, not how
574 // long the engine took to digest what broke it.
575 self.last_pty_ms = std.time.milliTimestamp();
430 self.eng.feed(buf[0..n]); 576 self.eng.feed(buf[0..n]);
431 const resp = self.eng.ptyOutput(); 577 const resp = self.eng.ptyOutput();
432 if (resp.len > 0) { 578 if (resp.len > 0) {
@@ -434,6 +580,7 @@ pub const Server = struct {
434 self.eng.clearPtyOutput(); 580 self.eng.clearPtyOutput();
435 } 581 }
436 self.sendUpdate(); 582 self.sendUpdate();
583 self.drainMarkEvents();
437 } 584 }
438 } 585 }
439 586
@@ -473,6 +620,14 @@ pub const Server = struct {
473 } 620 }
474 } 621 }
475 622
623 // After every arm that can move the session on, so an await sees the
624 // marks, the pgid and the output silence that this pump produced
625 // rather than last pump's — and before the QUIC drain below, because
626 // an await resolving here queues a frame and drainAll is what puts a
627 // QUIC client's queued bytes on the wire. The other order would cost
628 // every remote await a whole extra poll cycle.
629 self.checkAwaits();
630
476 // Last, and deliberately at the very end of the pump: the listener's 631 // Last, and deliberately at the very end of the pump: the listener's
477 // send only QUEUES now (draining from inside an ngtcp2 callback is 632 // send only QUEUES now (draining from inside an ngtcp2 callback is
478 // the defect that change exists to remove), so this is where a QUIC 633 // the defect that change exists to remove), so this is where a QUIC
@@ -939,6 +1094,27 @@ pub const Server = struct {
939 _ = self.queueFrame(i, .pty_mode, &proto.encodePtyMode(flags)); 1094 _ = self.queueFrame(i, .pty_mode, &proto.encodePtyMode(flags));
940 } 1095 }
941 1096
1097 /// Tell one client the command state it arrived too late to witness.
1098 /// Same gap sendPtyModeTo closes, one level up: cmd_state is only ever
1099 /// pushed on a transition, so a client attaching between two commands
1100 /// would otherwise know nothing until the next one happened.
1101 ///
1102 /// Gated on marks_seen, which keeps the rule that a cmd_state push means
1103 /// a mark was read and never that a heuristic guessed — a session that
1104 /// has never spoken marks has nothing honest to say here, and says
1105 /// nothing. (`muxa status` is the way to ask about such a session; it
1106 /// reports the regime rather than claiming a transition.)
1107 ///
1108 /// Sent after the resync rather than before it, which is the opposite of
1109 /// sendPtyModeTo's ordering and for the same underlying reason: start_row
1110 /// and end_row point into the grid, so the client must already hold the
1111 /// grid they point into. Mode bits describe how to read bytes that have
1112 /// not arrived yet; rows describe bytes that have.
1113 fn sendCmdStateTo(self: *Server, i: usize) void {
1114 if (!self.cmd.marks_seen) return;
1115 _ = self.queueFrame(i, .cmd_state, &proto.encodeCmdState(self.cmdState(.marks)));
1116 }
1117
942 fn hasClients(self: *const Server) bool { 1118 fn hasClients(self: *const Server) bool {
943 for (self.clients) |slot| { 1119 for (self.clients) |slot| {
944 if (slot != null) return true; 1120 if (slot != null) return true;
@@ -1014,30 +1190,27 @@ pub const Server = struct {
1014 1190
1015 while (true) { 1191 while (true) {
1016 if (self.clients[i] == null) return; // a handler dropped it 1192 if (self.clients[i] == null) return; // a handler dropped it
1017 const buf = self.clients[i].?.inbound.items; 1193 // Same walk as the client's, out of proto: a length the peer
1018 if (buf.len < 5) return; // not yet a header 1194 // chose is gated there, and a partial tail is null rather than
1019 const len = std.mem.readInt(u32, buf[1..5], .little); 1195 // an error. What differs is the verdict on a bad length — this
1020 if (len > proto.max_payload) { 1196 // side has a connection it can drop, and does.
1197 const d = proto.delimitFrame(self.clients[i].?.inbound.items) catch {
1021 self.dropClient(i); 1198 self.dropClient(i);
1022 return; 1199 return;
1023 } 1200 } orelse return; // header or payload still coming
1024 if (buf.len < 5 + len) return; // header known, payload still coming
1025 1201
1026 // Copied out before handling: a handler may queue sends, and 1202 // Copied out before handling: a handler may queue sends, and
1027 // anything that touches this slot could reallocate `inbound` 1203 // anything that touches this slot could reallocate `inbound`
1028 // underneath a slice into it. 1204 // underneath a slice into it.
1029 const payload = self.alloc.alloc(u8, len) catch { 1205 const payload = self.alloc.alloc(u8, d.payload.len) catch {
1030 self.dropClient(i); 1206 self.dropClient(i);
1031 return; 1207 return;
1032 }; 1208 };
1033 defer self.alloc.free(payload); 1209 defer self.alloc.free(payload);
1034 @memcpy(payload, buf[5 .. 5 + len]); 1210 @memcpy(payload, d.payload);
1035 const frame: proto.Frame = .{ 1211 const frame: proto.Frame = .{ .type = d.type, .payload = payload };
1036 .type = @enumFromInt(buf[0]),
1037 .payload = payload,
1038 };
1039 1212
1040 const consumed = 5 + len; 1213 const consumed = d.consumed;
1041 const slot = &self.clients[i].?; 1214 const slot = &self.clients[i].?;
1042 const rest = slot.inbound.items.len - consumed; 1215 const rest = slot.inbound.items.len - consumed;
1043 std.mem.copyForwards(u8, slot.inbound.items[0..rest], slot.inbound.items[consumed..]); 1216 std.mem.copyForwards(u8, slot.inbound.items[0..rest], slot.inbound.items[consumed..]);
@@ -1073,6 +1246,7 @@ pub const Server = struct {
1073 // holding grid content it has no mode for. 1246 // holding grid content it has no mode for.
1074 self.sendPtyModeTo(i); 1247 self.sendPtyModeTo(i);
1075 self.sendResync(i, req.have_seq, req.have_epoch, size_changed and applied); 1248 self.sendResync(i, req.have_seq, req.have_epoch, size_changed and applied);
1249 self.sendCmdStateTo(i);
1076 }, 1250 },
1077 .resize => { 1251 .resize => {
1078 // Latest wins: whoever resized last sets the grid, and the 1252 // Latest wins: whoever resized last sets the grid, and the
@@ -1150,10 +1324,128 @@ pub const Server = struct {
1150 defer self.alloc.free(dump); 1324 defer self.alloc.free(dump);
1151 _ = self.queueFrame(i, .dump_reply, dump); 1325 _ = self.queueFrame(i, .dump_reply, dump);
1152 }, 1326 },
1327 .status_req => {
1328 const payload = proto.encodeStatusReply(self.buildStatusReply());
1329 _ = self.queueFrame(i, .status_reply, &payload);
1330 },
1331 .await_req => {
1332 const req = proto.decodeAwaitReq(frame.payload) catch return;
1333 if (self.clients[i] == null) return;
1334 self.clients[i].?.await_state = .{
1335 .since_seq = req.since_seq,
1336 .settle_ms = req.settle_ms,
1337 .timeout_ms = req.timeout_ms,
1338 .started_ms = std.time.milliTimestamp(),
1339 };
1340 // A return that already happened answers immediately — this
1341 // is what makes a reconnect re-issue safe. The request is
1342 // answered inside this pump either way, since the pump-end
1343 // pass runs after frame handling; this call keeps that true
1344 // independent of pump-end ordering.
1345 self.checkAwaits();
1346 },
1153 else => {}, 1347 else => {},
1154 } 1348 }
1155 } 1349 }
1156 1350
1351 /// Resolve any awaits that can be answered this pump. Granularity is
1352 /// the run loop's 100ms tick — nothing here blocks, and no deadline
1353 /// folding into poll is needed at that resolution.
1354 fn checkAwaits(self: *Server) void {
1355 const now = std.time.milliTimestamp();
1356 // One ioctl for the whole pump, not one per waiting client: the
1357 // foreground process group is a property of the pty, so every client
1358 // in this loop would read the same number back. Null covers both
1359 // "marks hold the floor, so nobody asked" — the probe is skipped
1360 // entirely then, exactly as before — and "the ioctl failed", which
1361 // has always been silently ignored. What stays per-client is
1362 // `saw_busy`: the transition each await is watching for is its own.
1363 const fg_pgid: ?std.posix.pid_t = if (self.cmd.marksOpen())
1364 null
1365 else
1366 self.pty.fgPgid() catch null;
1367
1368 for (0..max_clients) |i| {
1369 if (self.clients[i] == null) continue;
1370 const slot = &self.clients[i].?;
1371 if (slot.await_state == null) continue;
1372 const a = &slot.await_state.?;
1373
1374 // 1. Marks: a return newer than since_seq answers with the full
1375 // story. Strictly greater: since_seq is "what I have".
1376 //
1377 // The watermark alone decides, and it is the snapshot's own
1378 // seq — the answer and the reason it qualifies are the same
1379 // record, so no reading of live tracker state can drift out
1380 // from under this test. Gating on
1381 // `cmd.phase == .returned` here was a defect: the shell's own
1382 // `D;code`+`A` burst lands in one pty read, so phase is back
1383 // to at_prompt before this line ever runs. Keying on the
1384 // watermark also answers correctly once the NEXT command is
1385 // already running — the client asked what had returned since
1386 // its seq, not what is happening now.
1387 if (self.last_return) |st| {
1388 if (st.seq > a.since_seq) {
1389 self.answerAwait(i, st, .returned);
1390 continue;
1391 }
1392 }
1393
1394 // 2. pgid: only when marks do not hold the floor (folded into
1395 // fg_pgid above). The shell is the session leader, so its pid
1396 // is the resting pgid.
1397 if (fg_pgid) |pg| {
1398 if (pg != self.pty.child) {
1399 a.saw_busy = true;
1400 } else if (a.saw_busy) {
1401 // The pgid went out and came back: something ran and is
1402 // over. WHAT its code was, this mechanism cannot say.
1403 const st = self.fallbackState(.pgid, .{
1404 .phase = .returned,
1405 .clear_exit_code = true,
1406 });
1407 self.answerAwait(i, st, .returned);
1408 continue;
1409 }
1410 }
1411
1412 // 3. Settle: output silence, if the caller asked for a floor.
1413 if (a.settle_ms > 0 and self.last_pty_ms > 0 and
1414 now - self.last_pty_ms >= a.settle_ms and
1415 now - a.started_ms >= a.settle_ms)
1416 {
1417 // Phase is left as the session's own: silence says the
1418 // output stopped, never that a command returned.
1419 const st = self.fallbackState(.settle, .{ .clear_exit_code = true });
1420 self.answerAwait(i, st, .settled);
1421 continue;
1422 }
1423
1424 // 4. Timeout: the bound the client set on the whole wait. 0 is
1425 // not a zero-length deadline but the absence of one — such an
1426 // await ends only when marks, the pgid or settle end it.
1427 if (a.timeout_ms > 0 and now - a.started_ms >= a.timeout_ms) {
1428 // Nothing is overridden but the seq: a timeout reports the
1429 // session exactly as it stands, mid-command and all, and on
1430 // a marks session that includes an exit code still standing
1431 // from the command before this one.
1432 const st = self.fallbackState(
1433 if (self.cmd.marks_seen) .marks else .pgid,
1434 .{},
1435 );
1436 self.answerAwait(i, st, .timeout);
1437 continue;
1438 }
1439 }
1440 }
1441
1442 fn answerAwait(self: *Server, i: usize, st: proto.CmdState, reason: proto.AwaitReason) void {
1443 if (self.clients[i] == null) return;
1444 self.clients[i].?.await_state = null;
1445 const payload = proto.encodeAwaitReply(st, reason);
1446 _ = self.queueFrame(i, .await_reply, &payload);
1447 }
1448
1157 fn serviceObserver(self: *Server, i: usize) void { 1449 fn serviceObserver(self: *Server, i: usize) void {
1158 const fd = self.observers[i].?; 1450 const fd = self.observers[i].?;
1159 const frame = proto.readFrame(self.alloc, fd) catch { 1451 const frame = proto.readFrame(self.alloc, fd) catch {
@@ -1197,6 +1489,7 @@ pub const Server = struct {
1197 // refused one: the size differs but the grid never moved, 1489 // refused one: the size differs but the grid never moved,
1198 // so there is nothing to repaint anyone else for. 1490 // so there is nothing to repaint anyone else for.
1199 self.sendResync(slot, sz.have_seq, sz.have_epoch, size_changed and applied); 1491 self.sendResync(slot, sz.have_seq, sz.have_epoch, size_changed and applied);
1492 self.sendCmdStateTo(slot);
1200 }, 1493 },
1201 .debug_dump => self.replyDumpObserver(fd, frame.payload) catch self.dropObserver(i), 1494 .debug_dump => self.replyDumpObserver(fd, frame.payload) catch self.dropObserver(i),
1202 .stats_req => self.replyStatsObserver(fd) catch self.dropObserver(i), 1495 .stats_req => self.replyStatsObserver(fd) catch self.dropObserver(i),
@@ -1209,6 +1502,13 @@ pub const Server = struct {
1209 const payload = proto.encodeEndpointReply(self.endpointPort()); 1502 const payload = proto.encodeEndpointReply(self.endpointPort());
1210 proto.writeFrame(fd, .endpoint_reply, &payload) catch self.dropObserver(i); 1503 proto.writeFrame(fd, .endpoint_reply, &payload) catch self.dropObserver(i);
1211 }, 1504 },
1505 // Where `muxa status` actually lands: it asks and exits without
1506 // ever attaching. Blocking reply for the same reason the stats
1507 // and endpoint arms use one — an observer has no send queue.
1508 .status_req => {
1509 const payload = proto.encodeStatusReply(self.buildStatusReply());
1510 proto.writeFrame(fd, .status_reply, &payload) catch self.dropObserver(i);
1511 },
1212 else => {}, 1512 else => {},
1213 } 1513 }
1214 } 1514 }
@@ -1344,6 +1644,114 @@ pub const Server = struct {
1344 } 1644 }
1345 } 1645 }
1346 1646
1647 /// Fold the engine's OSC 133 events into the command tracker and tell
1648 /// attached clients about transitions. Runs after sendUpdate so
1649 /// tracker.seq already covers the same pty chunk — the off-by-one the
1650 /// spec pins (seq sampled post-feed).
1651 ///
1652 /// The pump's pty-read arm is the only caller, so marks produced by a
1653 /// test that feeds the engine directly (srv.eng.feed) sit pending until
1654 /// a read arm runs — they are not lost, but they are not folded yet.
1655 fn drainMarkEvents(self: *Server) void {
1656 for (self.eng.markEvents()) |ev| {
1657 const tr = self.cmd.apply(ev) orelse continue;
1658 if (tr == .returned) {
1659 // Copied out of the tracker while it still describes this
1660 // command — the `A` that ends the burst is usually the very
1661 // next event in this same loop, and the next command's `C`
1662 // clears the exit code outright.
1663 self.last_return = .{
1664 .phase = .returned,
1665 .mechanism = .marks,
1666 .exit_code = self.cmd.exit_code,
1667 .start_row = self.cmd.start_row,
1668 .end_row = self.cmd.end_row,
1669 .seq = self.tracker.seq,
1670 };
1671 }
1672 if (tr == .reset) continue; // believed nothing, tell no one
1673 const payload = proto.encodeCmdState(self.cmdState(.marks));
1674 for (0..max_clients) |i| {
1675 _ = self.queueFrame(i, .cmd_state, &payload);
1676 }
1677 }
1678 self.eng.clearMarkEvents();
1679 }
1680
1681 /// The current command state as a wire struct. `mechanism` is the
1682 /// caller's claim about how the verdict was reached: marks pushes say
1683 /// .marks; await resolutions say what actually resolved them.
1684 fn cmdState(self: *Server, mechanism: proto.Mechanism) proto.CmdState {
1685 return .{
1686 .phase = self.cmd.phase,
1687 .mechanism = mechanism,
1688 .exit_code = self.cmd.exit_code,
1689 .start_row = self.cmd.start_row,
1690 .end_row = self.cmd.end_row,
1691 // The return watermark, read off its one owner. Absent means no
1692 // command has returned this session, which is exactly what 0
1693 // has always meant on the wire.
1694 .seq = if (self.last_return) |lr| lr.seq else 0,
1695 };
1696 }
1697
1698 /// The state an await resolved by something OTHER than the marks stream
1699 /// answers with: `cmdState` for the live picture, then the overrides
1700 /// that mechanism is entitled to make. One owner for all of them,
1701 /// because the three fallback arms in `checkAwaits` differ only in
1702 /// which overrides they take, and hand-patching the struct at each site
1703 /// made a set of deliberate differences look like three drifting copies.
1704 ///
1705 /// The seq override is unconditional and is the subtle one: it replaces
1706 /// cmdState's RETURN watermark with the delta tracker's current seq.
1707 /// That is deliberate — it orders the reply against the grid content
1708 /// the client has, which is what a fallback answer is actually about —
1709 /// and it is why proto.CmdState.seq documents two meanings. A client
1710 /// that took its next `since_seq` from here would be using a number
1711 /// from the wrong series; see that doc comment for the rule and for the
1712 /// open question of whether these arms should move the watermark at all.
1713 ///
1714 /// `phase` and `clear_exit_code` default to leaving what cmdState built:
1715 /// a mechanism overrides only what it can actually claim to know.
1716 fn fallbackState(
1717 self: *Server,
1718 mechanism: proto.Mechanism,
1719 overrides: struct {
1720 phase: ?proto.CmdPhase = null,
1721 clear_exit_code: bool = false,
1722 },
1723 ) proto.CmdState {
1724 var st = self.cmdState(mechanism);
1725 if (overrides.phase) |p| st.phase = p;
1726 // Only marks can know a code (proto.Mechanism says so); a fallback
1727 // that passed one through would be attributing the PREVIOUS
1728 // command's verdict to this one.
1729 if (overrides.clear_exit_code) st.exit_code = null;
1730 st.seq = self.tracker.seq;
1731 return st;
1732 }
1733
1734 fn buildStatusReply(self: *Server) proto.StatusReply {
1735 const cur = self.eng.cursorPos();
1736 return .{
1737 .cols = self.colsNow(),
1738 .rows = self.rowsNow(),
1739 .cursor_x = cur.x,
1740 .cursor_y = cur.y,
1741 .history_rows = self.eng.historyRows(),
1742 .alt_screen = self.eng.onAltScreen(),
1743 // Live read, then the last one that worked, then a default. The
1744 // middle term is the one worth having: mode_sent is refreshed
1745 // from a real tcgetattr every pump, so if this read is the one
1746 // that fails there is a remembered truth to report instead of a
1747 // fabricated "canonical and echoing" that may be the opposite of
1748 // what the session is doing.
1749 .mode = self.readPtyMode() orelse
1750 (self.mode_sent orelse .{ .icanon = true, .echo = true }),
1751 .cmd = self.cmdState(if (self.cmd.marks_seen) .marks else .pgid),
1752 };
1753 }
1754
1347 /// The snapshot payload for the grid as it stands: the fixed prefix ++ 1755 /// The snapshot payload for the grid as it stands: the fixed prefix ++
1348 /// full-state dump. Caller owns the result. Reads tracker.seq, so it 1756 /// full-state dump. Caller owns the result. Reads tracker.seq, so it
1349 /// must be called after the rebuild that stamps it. 1757 /// must be called after the rebuild that stamps it.
@@ -4477,3 +4885,815 @@ test "Server: endpointPortFrom refuses without a key, survives it, and prefers a
4477 // resolution is never reached — all-null env would otherwise return 0. 4885 // resolution is never reached — all-null env would otherwise return 0.
4478 try std.testing.expectEqual(port, srv.endpointPortFrom(null, null, null)); 4886 try std.testing.expectEqual(port, srv.endpointPortFrom(null, null, null));
4479 } 4887 }
4888
4889 test "Server: OSC 133 marks reach attached clients as cmd_state pushes" {
4890 const alloc = std.testing.allocator;
4891
4892 var tmp = try TmpDir.make();
4893 defer tmp.cleanup();
4894 const dir_path = tmp.path();
4895 const sock_path = try std.fmt.allocPrint(alloc, "{s}/marks.sock", .{dir_path});
4896 defer alloc.free(sock_path);
4897
4898 // A scripted session rather than an interactive shell, for the same
4899 // reason the pty-mode test uses one: an interactive shell emits marks
4900 // only once somebody has installed shell integration into it, and then
4901 // emits them continuously. This child emits one mark when told to and
4902 // none otherwise, so "which push came from which mark" is not a guess.
4903 // It ends on a blocking read so deinit's SIGTERM lands on the shell
4904 // itself and leaves nothing running behind the test.
4905 try tmp.dir.writeFile(.{
4906 .sub_path = "marks.sh",
4907 .data =
4908 \\#!/bin/sh
4909 \\read -r start
4910 \\printf '\033]133;C\007'
4911 \\read -r second
4912 \\printf '\033]133;D;0\007'
4913 \\read -r stop
4914 \\
4915 ,
4916 .flags = .{ .mode = 0o755 },
4917 });
4918 const script = try std.fmt.allocPrintSentinel(alloc, "{s}/marks.sh", .{dir_path}, 0);
4919 defer alloc.free(script);
4920
4921 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script });
4922 defer srv.deinit();
4923
4924 const c = try std.net.connectUnixSocket(sock_path);
4925 defer c.close();
4926 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
4927
4928 // Release the script into its C. The keystroke is echoed by the line
4929 // discipline, so this pty chunk carries grid content as well as the
4930 // mark — which is the point: the push has to survive sharing a read
4931 // with ordinary output.
4932 try proto.writeFrame(c.handle, .input, "go\n");
4933 const f1 = (try awaitFrame(alloc, &srv, c.handle, .cmd_state, 500)) orelse
4934 return error.NoRunningPush;
4935 defer f1.deinit(alloc);
4936 const running = try proto.decodeCmdState(f1.payload);
4937 try std.testing.expectEqual(proto.CmdPhase.running, running.phase);
4938 // Marks are the only mechanism that ever pushes: a push means a mark was
4939 // read, never that a heuristic guessed.
4940 try std.testing.expectEqual(proto.Mechanism.marks, running.mechanism);
4941
4942 // And into its D, which closes the command with a code.
4943 try proto.writeFrame(c.handle, .input, "go\n");
4944 const f2 = (try awaitFrame(alloc, &srv, c.handle, .cmd_state, 500)) orelse
4945 return error.NoReturnedPush;
4946 defer f2.deinit(alloc);
4947 const returned = try proto.decodeCmdState(f2.payload);
4948 try std.testing.expectEqual(proto.CmdPhase.returned, returned.phase);
4949 try std.testing.expectEqual(proto.Mechanism.marks, returned.mechanism);
4950 try std.testing.expectEqual(@as(?u8, 0), returned.exit_code);
4951
4952 // The seq field is a last-return watermark, not a stamp on the frame
4953 // carrying it: it answers "a return happened at or before this seq".
4954 // So the running push of the first command this session ever ran says
4955 // 0 — nothing has returned yet — and a consumer reading it as "when
4956 // this command started" would be reading about a different event.
4957 try std.testing.expectEqual(@as(u64, 0), running.seq);
4958 // The return moves the watermark to the tracker as sampled after the
4959 // update for its own pty chunk, so it covers the command's own output.
4960 // Ordering only: a C and a D arriving in one read legitimately share a
4961 // seq, so this is >= and not >, and the upper bound is the tracker as
4962 // it stands now.
4963 try std.testing.expect(returned.seq >= running.seq);
4964 try std.testing.expect(returned.seq <= srv.tracker.seq);
4965 try std.testing.expect(returned.seq > 0);
4966 }
4967
4968 // ---------------------------------------------------------------------------
4969 // Shell integration, end to end. Everything above this line proves the daemon
4970 // can read marks; these two prove a real shell EMITS them, which is the only
4971 // version of the claim that matters in a session. The chain under test is
4972 // injection -> shell -> pty -> engine -> tracker -> wire, and no part of it is
4973 // stubbed.
4974 // ---------------------------------------------------------------------------
4975
4976 /// Pump for `budget_ms` and report whether any `returned` push turned up.
4977 /// The absence half of the phantom-mark assertions: a session that has been
4978 /// told to run nothing must claim nothing returned.
4979 fn anyReturnWithin(
4980 alloc: std.mem.Allocator,
4981 srv: *Server,
4982 fd: std.posix.fd_t,
4983 budget_ms: i64,
4984 ) !?proto.CmdState {
4985 const deadline = std.time.milliTimestamp() + budget_ms;
4986 while (std.time.milliTimestamp() < deadline) {
4987 _ = try srv.pumpOnce(5);
4988 var pfd = [_]std.posix.pollfd{
4989 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 },
4990 };
4991 if ((std.posix.poll(&pfd, 1) catch 0) == 0) continue;
4992 const frame = (try proto.readFrame(alloc, fd)) orelse return null;
4993 defer frame.deinit(alloc);
4994 if (frame.type != .cmd_state) continue;
4995 const st = try proto.decodeCmdState(frame.payload);
4996 if (st.phase == .returned) return st;
4997 }
4998 return null;
4999 }
5000
5001 /// Pump until the session's grid contains `needle`. The liveness half: an
5002 /// assertion that no mark arrived is worthless against a shell that never
5003 /// started, so every absence check below waits for the shell to say hello
5004 /// on the grid first.
5005 fn awaitGridText(
5006 alloc: std.mem.Allocator,
5007 srv: *Server,
5008 needle: []const u8,
5009 budget_ms: i64,
5010 ) !bool {
5011 const deadline = std.time.milliTimestamp() + budget_ms;
5012 while (std.time.milliTimestamp() < deadline) {
5013 _ = try srv.pumpOnce(5);
5014 const grid = try srv.eng.dumpPlain(alloc);
5015 defer alloc.free(grid);
5016 if (std.mem.indexOf(u8, grid, needle) != null) return true;
5017 }
5018 return false;
5019 }
5020
5021 /// A session whose HOME is a directory this test wrote, so the verdict does
5022 /// not depend on whose rc files the box carries. The rc it plants is not
5023 /// empty: it adds a PROMPT_COMMAND member of its own, which is the shape
5024 /// that broke the bash shim (a member firing the DEBUG trap on every idle
5025 /// prompt cycle), and an unmistakable PS1 to wait on.
5026 const IntegratedSession = struct {
5027 tmp: TmpDir,
5028 home: [:0]const u8,
5029 sock_path: []const u8,
5030 srv: Server,
5031 conn: std.net.Stream,
5032
5033 const prompt = "MUXPROMPT>";
5034
5035 fn start(alloc: std.mem.Allocator, shell: [:0]const u8, tag: []const u8, rc: []const u8) !*IntegratedSession {
5036 const self = try alloc.create(IntegratedSession);
5037 errdefer alloc.destroy(self);
5038 self.tmp = try TmpDir.make();
5039 errdefer self.tmp.cleanup();
5040
5041 try self.tmp.dir.makePath("home");
5042 var home_dir = try self.tmp.dir.openDir("home", .{});
5043 defer home_dir.close();
5044 try home_dir.writeFile(.{ .sub_path = rcName(shell), .data = rc });
5045
5046 self.home = try std.fmt.allocPrintSentinel(alloc, "{s}/home", .{self.tmp.path()}, 0);
5047 errdefer alloc.free(self.home);
5048 self.sock_path = try std.fmt.allocPrint(alloc, "{s}/{s}.sock", .{ self.tmp.path(), tag });
5049 errdefer alloc.free(self.sock_path);
5050
5051 self.srv = try Server.init(alloc, .{
5052 .sock_path = self.sock_path,
5053 .shell = shell,
5054 .extra_env = &.{
5055 .{ .key = "HOME", .value = self.home },
5056 // Emptied, and that is not cosmetic. shellint.prepare copies
5057 // the DAEMON's ZDOTDIR into MUX_ORIG_ZDOTDIR, and here the
5058 // daemon is the test runner — so on a box whose runner has
5059 // ZDOTDIR set, the zsh shim would restore it and source THAT
5060 // .zshrc, sailing straight past the HOME planted above. The
5061 // shim reads an empty value as "there was none" and falls
5062 // back to $HOME, which is the hermetic answer. extra_env is
5063 // applied after the injection's own pairs, so this wins.
5064 .{ .key = "MUX_ORIG_ZDOTDIR", .value = "" },
5065 },
5066 });
5067 errdefer self.srv.deinit();
5068
5069 // The shim really was written where the daemon says it was. Without
5070 // this, a missing directory would present as "no marks ever arrived"
5071 // and the timeout would never say why.
5072 try std.testing.expect(self.srv.shellint_dir != null);
5073 try std.fs.cwd().access(self.srv.shellint_dir.?, .{});
5074
5075 self.conn = try std.net.connectUnixSocket(self.sock_path);
5076 errdefer self.conn.close();
5077 try proto.writeFrame(self.conn.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
5078 return self;
5079 }
5080
5081 fn rcName(shell: [:0]const u8) []const u8 {
5082 return if (shellint.detect(shell) == .zsh) ".zshrc" else ".bashrc";
5083 }
5084
5085 fn deinit(self: *IntegratedSession, alloc: std.mem.Allocator) void {
5086 self.conn.close();
5087 self.srv.deinit();
5088 alloc.free(self.sock_path);
5089 alloc.free(self.home);
5090 self.tmp.cleanup();
5091 alloc.destroy(self);
5092 }
5093 };
5094
5095 /// The bash rc a real box hands the shim: Arch's /etc/bash.bashrc appends a
5096 /// PROMPT_COMMAND member under any xterm* TERM, and muxd sets exactly that.
5097 /// This plants the same shape explicitly rather than relying on the system
5098 /// file being there, so the test states its own premise.
5099 const bash_rc_with_prompt_member =
5100 \\PS1='MUXPROMPT>'
5101 \\PROMPT_COMMAND+=(': mux-test-member')
5102 \\
5103 ;
5104
5105 test "Server: bash emits one mark pair per command, and none at an idle prompt" {
5106 const alloc = std.testing.allocator;
5107 std.fs.cwd().access("/bin/bash", .{}) catch return error.SkipZigTest;
5108
5109 const s = try IntegratedSession.start(alloc, "/bin/bash", "bash", bash_rc_with_prompt_member);
5110 defer s.deinit(alloc);
5111
5112 // Wait for the first prompt, so what follows is an assertion about a
5113 // live shell rather than about a slow one.
5114 try std.testing.expect(try awaitGridText(alloc, &s.srv, IntegratedSession.prompt, 10_000));
5115
5116 // NOTHING has been asked to run, so nothing may claim to have returned.
5117 //
5118 // This is the regression that shipped: the DEBUG trap was armed one line
5119 // before the PROMPT_COMMAND assignment, so it fired ON that assignment
5120 // and the very first prompt reported `D;0` for a command that never
5121 // existed. Measured on a real bash before the fix, the session opened
5122 // {C:PROMPT_COMMAND=...}{D;0}{A} — and the earlier version of this test
5123 // was satisfied by that phantom rather than by the command it sent.
5124 if (try anyReturnWithin(alloc, &s.srv, s.conn.handle, 400)) |ghost| {
5125 std.debug.print("phantom return at the opening prompt: {any}\n", .{ghost});
5126 return error.PhantomReturnBeforeAnyCommand;
5127 }
5128
5129 // Now a real command, with a code no fallback could invent. `false`
5130 // rather than `true`: an exit code of 1 cannot be confused with the 0
5131 // that a phantom D carries.
5132 try proto.writeFrame(s.conn.handle, .input, "false\n");
5133 const st = (try anyReturnWithin(alloc, &s.srv, s.conn.handle, 10_000)) orelse
5134 return error.NoReturnedPush;
5135 try std.testing.expectEqual(proto.Mechanism.marks, st.mechanism);
5136 try std.testing.expectEqual(@as(?u8, 1), st.exit_code);
5137
5138 // A bare Enter runs PROMPT_COMMAND again and no command at all. Its
5139 // members fire the DEBUG trap, and before the membership check they
5140 // re-armed the shim — so an untouched prompt reported a successful
5141 // command on every cycle. Three Enters produced four phantom `D;0`s on
5142 // a real bash; the correct answer is none.
5143 try proto.writeFrame(s.conn.handle, .input, "\n\n\n");
5144 if (try anyReturnWithin(alloc, &s.srv, s.conn.handle, 600)) |ghost| {
5145 std.debug.print("phantom return at an idle prompt: {any}\n", .{ghost});
5146 return error.PhantomReturnAtIdlePrompt;
5147 }
5148 }
5149
5150 /// zsh's equivalent premise: a precmd hook of the user's own, registered
5151 /// BEFORE mux's — the shim sources the user's rc first, so theirs is first
5152 /// in precmd_functions and runs first.
5153 ///
5154 /// It returns 3 deliberately. A hook returning 0 would prove nothing: the
5155 /// commands under test exit 0 or 1, and a shim reading the wrong $? would
5156 /// still look right half the time. 3 is a value only the hook can produce,
5157 /// so the reported code names its own source. (zsh hands each precmd hook
5158 /// the original command's status rather than the previous hook's, which is
5159 /// what makes mux's reading safe; measured on this box before it was
5160 /// asserted here.)
5161 const zsh_rc_with_precmd_hook =
5162 \\PS1='MUXPROMPT>'
5163 \\autoload -Uz add-zsh-hook
5164 \\_user_precmd() { return 3 }
5165 \\add-zsh-hook precmd _user_precmd
5166 \\
5167 ;
5168
5169 test "Server: zsh emits one mark pair per command, and none at an idle prompt" {
5170 const alloc = std.testing.allocator;
5171 // Both spellings, because the box that has zsh does not always agree
5172 // with the box that had it last.
5173 const shell: [:0]const u8 = blk: {
5174 for ([_][:0]const u8{ "/usr/bin/zsh", "/bin/zsh" }) |p| {
5175 std.fs.cwd().access(p, .{}) catch continue;
5176 break :blk p;
5177 }
5178 return error.SkipZigTest;
5179 };
5180
5181 const s = try IntegratedSession.start(alloc, shell, "zsh", zsh_rc_with_precmd_hook);
5182 defer s.deinit(alloc);
5183
5184 try std.testing.expect(try awaitGridText(alloc, &s.srv, IntegratedSession.prompt, 10_000));
5185
5186 // The guarded D earns its keep here: zsh's precmd runs at the opening
5187 // prompt too, and without the `_mux_ran` guard it would report a return
5188 // before the session had run anything.
5189 if (try anyReturnWithin(alloc, &s.srv, s.conn.handle, 400)) |ghost| {
5190 std.debug.print("phantom return at the opening prompt: {any}\n", .{ghost});
5191 return error.PhantomReturnBeforeAnyCommand;
5192 }
5193
5194 try proto.writeFrame(s.conn.handle, .input, "false\n");
5195 const st = (try anyReturnWithin(alloc, &s.srv, s.conn.handle, 10_000)) orelse
5196 return error.NoReturnedPush;
5197 try std.testing.expectEqual(proto.Mechanism.marks, st.mechanism);
5198 // 1, not 0: mux's precmd reads $? before the user's hook can overwrite
5199 // it, which is the property the shim's hook ordering exists to keep.
5200 try std.testing.expectEqual(@as(?u8, 1), st.exit_code);
5201
5202 try proto.writeFrame(s.conn.handle, .input, "\n\n\n");
5203 if (try anyReturnWithin(alloc, &s.srv, s.conn.handle, 600)) |ghost| {
5204 std.debug.print("phantom return at an idle prompt: {any}\n", .{ghost});
5205 return error.PhantomReturnAtIdlePrompt;
5206 }
5207 }
5208
5209 test "Server: the shim directory is private, and teardown takes it with it" {
5210 const alloc = std.testing.allocator;
5211 std.fs.cwd().access("/bin/bash", .{}) catch return error.SkipZigTest;
5212
5213 var tmp = try TmpDir.make();
5214 defer tmp.cleanup();
5215 const sock_path = try std.fmt.allocPrint(alloc, "{s}/shim.sock", .{tmp.path()});
5216 defer alloc.free(sock_path);
5217
5218 // Copied out of the server's arena before deinit frees it: the whole
5219 // point of this test is to ask a question after the server is gone.
5220 var dir_buf: [256]u8 = undefined;
5221 var dir: []const u8 = undefined;
5222 {
5223 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/bash" });
5224 defer srv.deinit();
5225 dir = try std.fmt.bufPrint(&dir_buf, "{s}", .{srv.shellint_dir.?});
5226
5227 // Beside the socket, not somewhere world-readable: the file the
5228 // session shell is about to source is a file that runs code as this
5229 // user, so 0700 on the directory is part of the contract.
5230 try std.testing.expectEqualStrings(std.fs.path.dirname(sock_path).?, std.fs.path.dirname(dir).?);
5231 var d = try std.fs.cwd().openDir(dir, .{ .iterate = true });
5232 defer d.close();
5233 const st = try d.stat();
5234 try std.testing.expectEqual(@as(u32, 0o700), @as(u32, @intCast(st.mode & 0o777)));
5235 }
5236
5237 // A daemon that left its shims behind would litter the runtime directory
5238 // once per session, and nothing else in the system would ever notice.
5239 try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(dir, .{}));
5240 }
5241
5242 test "Server: a shim directory that cannot be created costs the marks, not the session" {
5243 const alloc = std.testing.allocator;
5244 std.fs.cwd().access("/bin/bash", .{}) catch return error.SkipZigTest;
5245
5246 var tmp = try TmpDir.make();
5247 defer tmp.cleanup();
5248 const sock_path = try std.fmt.allocPrint(alloc, "{s}/degrade.sock", .{tmp.path()});
5249 defer alloc.free(sock_path);
5250
5251 // Plant a regular FILE exactly where init will want its shim directory.
5252 // The path is knowable in advance because init names it after the
5253 // daemon's pid, and in a test the daemon IS this process — which is also
5254 // the realistic field version of this: a SIGKILLed daemon leaves an entry
5255 // behind and a later daemon draws the same pid.
5256 const planted = try std.fmt.allocPrint(
5257 alloc,
5258 "{s}/mux-shellint-{d}",
5259 .{ tmp.path(), std.os.linux.getpid() },
5260 );
5261 defer alloc.free(planted);
5262 try std.fs.cwd().writeFile(.{ .sub_path = planted, .data = "not a directory" });
5263
5264 // The daemon still starts. This is the whole claim: marks are an
5265 // enhancement, and a session that cannot have them is still a session.
5266 // (init prints one line to stderr on the way past; that is the point of
5267 // it not being silent.)
5268 // Scoped so deinit runs before the survival check below.
5269 {
5270 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/bash" });
5271 defer srv.deinit();
5272
5273 // Nothing to tear down later, because nothing was created.
5274 try std.testing.expectEqual(@as(?[]const u8, null), srv.shellint_dir);
5275
5276 // ...and the session actually works. A daemon that starts and then
5277 // cannot answer would satisfy every assertion above.
5278 const c = try std.net.connectUnixSocket(sock_path);
5279 defer c.close();
5280 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
5281 try proto.writeFrame(c.handle, .status_req, "");
5282 const f = (try awaitFrame(alloc, &srv, c.handle, .status_reply, 400)) orelse
5283 return error.NoStatusReply;
5284 defer f.deinit(alloc);
5285 const st = try proto.decodeStatusReply(f.payload);
5286 try std.testing.expectEqual(@as(u16, 80), st.cols);
5287 // pgid, not marks: the session is honestly running on the fallbacks.
5288 try std.testing.expectEqual(proto.Mechanism.pgid, st.cmd.mechanism);
5289 }
5290
5291 // The planted file is still there. Teardown deletes the shim tree by
5292 // path, so a version that recorded the directory even when prepare
5293 // failed would delete something this daemon never created — and in the
5294 // field that path belongs to whatever else drew the pid.
5295 const kept = try std.fs.cwd().readFileAlloc(alloc, planted, 64);
5296 defer alloc.free(kept);
5297 try std.testing.expectEqualStrings("not a directory", kept);
5298 }
5299
5300 test "Server: an unknown shell is not injected into at all — no directory, no shim" {
5301 const alloc = std.testing.allocator;
5302
5303 var tmp = try TmpDir.make();
5304 defer tmp.cleanup();
5305 const sock_path = try std.fmt.allocPrint(alloc, "{s}/plain.sock", .{tmp.path()});
5306 defer alloc.free(sock_path);
5307
5308 // Integration is ON, and /bin/sh still gets nothing: this is what keeps
5309 // every other test in this file describing the session it always did.
5310 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
5311 defer srv.deinit();
5312 try std.testing.expectEqual(@as(?[]const u8, null), srv.shellint_dir);
5313
5314 // Nothing was written beside the socket either — the absence is on disk,
5315 // not merely in a field the teardown consults.
5316 var d = try std.fs.cwd().openDir(tmp.path(), .{ .iterate = true });
5317 defer d.close();
5318 var it = d.iterate();
5319 while (try it.next()) |entry| {
5320 try std.testing.expect(!std.mem.startsWith(u8, entry.name, "mux-shellint-"));
5321 }
5322 }
5323
5324 test "Server: status_req is answered on an attached client and on a bare observer" {
5325 const alloc = std.testing.allocator;
5326
5327 var tmp = try TmpDir.make();
5328 defer tmp.cleanup();
5329 const dir_path = tmp.path();
5330 const sock_path = try std.fmt.allocPrint(alloc, "{s}/status.sock", .{dir_path});
5331 defer alloc.free(sock_path);
5332
5333 // /bin/cat: a session that produces no output of its own, so nothing
5334 // moves the state these assertions describe.
5335 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" });
5336 defer srv.deinit();
5337
5338 const c = try std.net.connectUnixSocket(sock_path);
5339 defer c.close();
5340 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
5341 try proto.writeFrame(c.handle, .status_req, "");
5342 const f = (try awaitFrame(alloc, &srv, c.handle, .status_reply, 400)) orelse
5343 return error.NoAttachedStatusReply;
5344 defer f.deinit(alloc);
5345 const st = try proto.decodeStatusReply(f.payload);
5346 try std.testing.expectEqual(@as(u16, 80), st.cols);
5347 try std.testing.expectEqual(@as(u16, 24), st.rows);
5348 try std.testing.expect(!st.alt_screen);
5349 try std.testing.expectEqual(proto.CmdPhase.at_prompt, st.cmd.phase);
5350 // No C has ever been seen on this session, so the reply reports the
5351 // regime it is actually in — pgid, not marks. That field is a report of
5352 // which mechanism would decide, not a claim that one just did.
5353 try std.testing.expectEqual(proto.Mechanism.pgid, st.cmd.mechanism);
5354
5355 // `muxa status` never attaches, so the observer arm is the load-bearing
5356 // one — same reasoning as endpoint_req's, and the same failure if it is
5357 // missing: the frame falls into `else => {}` and the caller hangs.
5358 const obs = try std.net.connectUnixSocket(sock_path);
5359 defer obs.close();
5360 try proto.writeFrame(obs.handle, .status_req, "");
5361 const f2 = (try awaitFrame(alloc, &srv, obs.handle, .status_reply, 400)) orelse
5362 return error.NoObserverStatusReply;
5363 defer f2.deinit(alloc);
5364 const st2 = try proto.decodeStatusReply(f2.payload);
5365 try std.testing.expectEqual(@as(u16, 80), st2.cols);
5366 try std.testing.expectEqual(@as(u16, 24), st2.rows);
5367 try std.testing.expectEqual(proto.CmdPhase.at_prompt, st2.cmd.phase);
5368 }
5369
5370 // ---------------------------------------------------------------------------
5371 // Awaits: a request the daemon holds open until something answers it.
5372 //
5373 // Every test below asserts the REASON an await ended, never how long it took.
5374 // Latency here is the run loop's sampling granularity crossed with whatever
5375 // else the machine is doing, so a test that pinned it would be pinning the
5376 // box. The iteration budgets are outer bounds — generous enough that a loaded
5377 // box does not read as a regression, finite so a regression fails by name
5378 // instead of hanging the suite.
5379 // ---------------------------------------------------------------------------
5380
5381 /// Read frames already sitting on `fd` — deliberately without pumping — until
5382 /// one of `want` turns up or the socket goes quiet. This is the "did the
5383 /// daemon answer inside that one pump" observable: an answer that needs
5384 /// another pump to appear reads as absent here, which is the distinction the
5385 /// immediate-answer test exists to make.
5386 fn readQueued(alloc: std.mem.Allocator, fd: std.posix.fd_t, want: proto.MsgType) !?proto.Frame {
5387 var guard: usize = 0;
5388 while (guard < 16) : (guard += 1) {
5389 var pfd = [_]std.posix.pollfd{
5390 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 },
5391 };
5392 if ((std.posix.poll(&pfd, 1) catch 0) == 0) return null;
5393 const frame = (try proto.readFrame(alloc, fd)) orelse return null;
5394 if (frame.type == want) return frame;
5395 frame.deinit(alloc);
5396 }
5397 return null;
5398 }
5399
5400 test "Server: an await is held open, answered by a mark, and re-answered immediately after" {
5401 const alloc = std.testing.allocator;
5402
5403 var tmp = try TmpDir.make();
5404 defer tmp.cleanup();
5405 const dir_path = tmp.path();
5406 const sock_path = try std.fmt.allocPrint(alloc, "{s}/awaitmark.sock", .{dir_path});
5407 defer alloc.free(sock_path);
5408
5409 // Script-gated for the same reason the cmd_state push test is: the child
5410 // emits its marks when told to and never before, so there is a moment at
5411 // which "no reply yet" is a claim worth making. Against a real integrated
5412 // shell there would not be.
5413 //
5414 // The burst is the whole point of its shape. Real integration writes
5415 // `D;code` and the next prompt's `A` together from precmd, so both fold
5416 // into the tracker inside one pty read and the phase is back at at_prompt
5417 // before any await is examined. An earlier version of this test stopped
5418 // at the D and passed against an implementation that could only answer in
5419 // the sliver between the two — which is to say, never, in production.
5420 try tmp.dir.writeFile(.{
5421 .sub_path = "await.sh",
5422 .data =
5423 \\#!/bin/sh
5424 \\read -r go
5425 \\printf '\033]133;C\007out\r\n\033]133;D;3\007\033]133;A\007'
5426 \\read -r stop
5427 \\
5428 ,
5429 .flags = .{ .mode = 0o755 },
5430 });
5431 const script = try std.fmt.allocPrintSentinel(alloc, "{s}/await.sh", .{dir_path}, 0);
5432 defer alloc.free(script);
5433
5434 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script });
5435 defer srv.deinit();
5436
5437 const c = try std.net.connectUnixSocket(sock_path);
5438 defer c.close();
5439 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
5440
5441 // since_seq is "what I already know about": only a return NEWER than this
5442 // may answer. Nothing has returned on this session at all.
5443 try proto.writeFrame(c.handle, .await_req, &proto.encodeAwaitReq(.{
5444 .since_seq = srv.tracker.seq,
5445 .settle_ms = 0,
5446 .timeout_ms = 5000,
5447 }));
5448
5449 // Held open: no mark has landed, no settle floor was asked for, the
5450 // timeout is seconds away, and this shell never moves its fg pgid. There
5451 // is nothing that may honestly answer yet, so nothing must.
5452 if (try awaitFrame(alloc, &srv, c.handle, .await_reply, 40)) |early| {
5453 early.deinit(alloc);
5454 return error.AwaitAnsweredBeforeAnythingHappened;
5455 }
5456
5457 // Release the script into its C-output-D. All three land in one pty read,
5458 // so the await resolves in the same pump that pushed the transitions.
5459 try proto.writeFrame(c.handle, .input, "go\n");
5460 const f = (try awaitFrame(alloc, &srv, c.handle, .await_reply, 500)) orelse
5461 return error.NoAwaitReplyFromMark;
5462 defer f.deinit(alloc);
5463 const rep = try proto.decodeAwaitReply(f.payload);
5464 try std.testing.expectEqual(proto.AwaitReason.returned, rep.reason);
5465 try std.testing.expectEqual(proto.CmdPhase.returned, rep.state.phase);
5466 // Marks won the race, and only marks carry a code.
5467 try std.testing.expectEqual(proto.Mechanism.marks, rep.state.mechanism);
5468 try std.testing.expectEqual(@as(?u8, 3), rep.state.exit_code);
5469
5470 // Reconnect idempotency, asked after the burst's A has already landed and
5471 // put the session back at a prompt: an agent whose answer died with its
5472 // connection re-asks with the seq it last held. That return has already
5473 // happened, so the daemon must not make it wait for a second one — it
5474 // answers inline, in the very pump that read the request. Reading without
5475 // pumping again is what makes "inline" the thing being asserted, and the
5476 // window in which this can be answered is the rest of the session rather
5477 // than the sliver between a D and the A that follows it.
5478 try proto.writeFrame(c.handle, .await_req, &proto.encodeAwaitReq(.{
5479 .since_seq = 0,
5480 .settle_ms = 0,
5481 .timeout_ms = 5000,
5482 }));
5483 _ = try srv.pumpOnce(5);
5484 const f2 = (try readQueued(alloc, c.handle, .await_reply)) orelse
5485 return error.AwaitNotAnsweredOnTheSamePump;
5486 defer f2.deinit(alloc);
5487 const rep2 = try proto.decodeAwaitReply(f2.payload);
5488 try std.testing.expectEqual(proto.AwaitReason.returned, rep2.reason);
5489 try std.testing.expectEqual(proto.Mechanism.marks, rep2.state.mechanism);
5490 try std.testing.expectEqual(@as(?u8, 3), rep2.state.exit_code);
5491
5492 // And a client attaching only now is told the state it could not have
5493 // witnessed, rather than learning nothing until the next transition.
5494 // That state is the LIVE one, which after the burst's A really is
5495 // at_prompt — a push describes where the session is, and the session is
5496 // at a prompt. The exit code is what carries the news, and it is the one
5497 // a fresh session could not produce: null is what an untouched tracker
5498 // reports, so 3 here means this push came from the command that ran.
5499 const late = try std.net.connectUnixSocket(sock_path);
5500 defer late.close();
5501 try proto.writeFrame(late.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
5502 const f3 = (try awaitFrame(alloc, &srv, late.handle, .cmd_state, 200)) orelse
5503 return error.NoCmdStateOnAttach;
5504 defer f3.deinit(alloc);
5505 const st3 = try proto.decodeCmdState(f3.payload);
5506 try std.testing.expectEqual(proto.CmdPhase.at_prompt, st3.phase);
5507 try std.testing.expectEqual(@as(?u8, 3), st3.exit_code);
5508 }
5509
5510 test "Server: a return is still answerable once the next command is running" {
5511 const alloc = std.testing.allocator;
5512
5513 var tmp = try TmpDir.make();
5514 defer tmp.cleanup();
5515 const dir_path = tmp.path();
5516 const sock_path = try std.fmt.allocPrint(alloc, "{s}/awaitnext.sock", .{dir_path});
5517 defer alloc.free(sock_path);
5518
5519 // One command that finishes with code 3 and redraws its prompt, then a
5520 // second that starts and stays running. The live tracker is describing
5521 // the second command by the time the await is asked — running, no exit
5522 // code, a different start row — so anything answered out of it would be
5523 // answering about the wrong command.
5524 try tmp.dir.writeFile(.{
5525 .sub_path = "next.sh",
5526 .data =
5527 \\#!/bin/sh
5528 \\read -r first
5529 \\printf '\033]133;C\007out\r\n\033]133;D;3\007\033]133;A\007'
5530 \\read -r second
5531 \\printf '\033]133;C\007working\r\n'
5532 \\read -r stop
5533 \\
5534 ,
5535 .flags = .{ .mode = 0o755 },
5536 });
5537 const script = try std.fmt.allocPrintSentinel(alloc, "{s}/next.sh", .{dir_path}, 0);
5538 defer alloc.free(script);
5539
5540 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script });
5541 defer srv.deinit();
5542
5543 const c = try std.net.connectUnixSocket(sock_path);
5544 defer c.close();
5545 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
5546
5547 // Run the first command to completion, then start the second and wait
5548 // until the daemon has actually seen it open.
5549 try proto.writeFrame(c.handle, .input, "go\n");
5550 try proto.writeFrame(c.handle, .input, "go\n");
5551 var spun: usize = 0;
5552 while (spun < 500 and srv.cmd.phase != .running) : (spun += 1) {
5553 _ = try srv.pumpOnce(5);
5554 }
5555 try std.testing.expectEqual(proto.CmdPhase.running, srv.cmd.phase);
5556
5557 // Now ask about everything since the beginning of time. The honest answer
5558 // is the FIRST command's return — the client asked what had returned
5559 // since its seq, not what the session is doing at this instant.
5560 try proto.writeFrame(c.handle, .await_req, &proto.encodeAwaitReq(.{
5561 .since_seq = 0,
5562 .settle_ms = 0,
5563 .timeout_ms = 5000,
5564 }));
5565 const f = (try awaitFrame(alloc, &srv, c.handle, .await_reply, 200)) orelse
5566 return error.NoAwaitReplyWhileNextCommandRuns;
5567 defer f.deinit(alloc);
5568 const rep = try proto.decodeAwaitReply(f.payload);
5569 try std.testing.expectEqual(proto.AwaitReason.returned, rep.reason);
5570 try std.testing.expectEqual(proto.Mechanism.marks, rep.state.mechanism);
5571 try std.testing.expectEqual(proto.CmdPhase.returned, rep.state.phase);
5572 // The snapshot's code, not the running command's absent one.
5573 try std.testing.expectEqual(@as(?u8, 3), rep.state.exit_code);
5574 // And the live tracker really has moved on, so the assertions above came
5575 // from the snapshot and could not have come from reading it.
5576 try std.testing.expectEqual(proto.CmdPhase.running, srv.cmd.phase);
5577 try std.testing.expectEqual(@as(?u8, null), srv.cmd.exit_code);
5578 }
5579
5580 test "Server: an await with a settle floor is answered by output going quiet" {
5581 const alloc = std.testing.allocator;
5582
5583 var tmp = try TmpDir.make();
5584 defer tmp.cleanup();
5585 const sock_path = try std.fmt.allocPrint(alloc, "{s}/settle.sock", .{tmp.path()});
5586 defer alloc.free(sock_path);
5587
5588 // /bin/cat echoes once and then says nothing, which is the exact shape
5589 // settle exists for: no shell integration, no job leaving the shell's
5590 // process group, and so no evidence available beyond "it stopped
5591 // talking".
5592 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" });
5593 defer srv.deinit();
5594
5595 const c = try std.net.connectUnixSocket(sock_path);
5596 defer c.close();
5597 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
5598 try proto.writeFrame(c.handle, .await_req, &proto.encodeAwaitReq(.{
5599 .since_seq = srv.tracker.seq,
5600 .settle_ms = 200,
5601 .timeout_ms = 5000,
5602 }));
5603 try proto.writeFrame(c.handle, .input, "quiet\r\n");
5604
5605 // Budget ~1.2s of wall clock against a 200ms floor and a 5s timeout, so
5606 // the only reason that can legally arrive in the window is the settle.
5607 const f = (try awaitFrame(alloc, &srv, c.handle, .await_reply, 200)) orelse
5608 return error.NoSettleReply;
5609 defer f.deinit(alloc);
5610 const rep = try proto.decodeAwaitReply(f.payload);
5611 try std.testing.expectEqual(proto.AwaitReason.settled, rep.reason);
5612 try std.testing.expectEqual(proto.Mechanism.settle, rep.state.mechanism);
5613 // Silence is evidence that something finished, never evidence of how.
5614 try std.testing.expectEqual(@as(?u8, null), rep.state.exit_code);
5615 // Deliberately the live phase, not a manufactured `.returned`: settle
5616 // never learned that a command ran, so it has no standing to claim one
5617 // returned. The reason field carries the verdict; the phase keeps
5618 // reporting what the session is actually known to be doing.
5619 try std.testing.expectEqual(proto.CmdPhase.at_prompt, rep.state.phase);
5620 }
5621
5622 test "Server: an await with nothing to answer it ends at the bound the caller set" {
5623 const alloc = std.testing.allocator;
5624
5625 var tmp = try TmpDir.make();
5626 defer tmp.cleanup();
5627 const sock_path = try std.fmt.allocPrint(alloc, "{s}/timeout.sock", .{tmp.path()});
5628 defer alloc.free(sock_path);
5629
5630 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" });
5631 defer srv.deinit();
5632
5633 const c = try std.net.connectUnixSocket(sock_path);
5634 defer c.close();
5635 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
5636 // Nothing is typed at this session, no settle floor is asked for and no
5637 // mark will ever come: the timeout is the only thing left that can end
5638 // this wait, which is the point.
5639 try proto.writeFrame(c.handle, .await_req, &proto.encodeAwaitReq(.{
5640 .since_seq = srv.tracker.seq,
5641 .settle_ms = 0,
5642 .timeout_ms = 150,
5643 }));
5644
5645 const f = (try awaitFrame(alloc, &srv, c.handle, .await_reply, 120)) orelse
5646 return error.NoTimeoutReply;
5647 defer f.deinit(alloc);
5648 const rep = try proto.decodeAwaitReply(f.payload);
5649 try std.testing.expectEqual(proto.AwaitReason.timeout, rep.reason);
5650 // A timed-out await still reports which regime the session is in, the
5651 // same claim status_reply makes: this one has never spoken marks.
5652 try std.testing.expectEqual(proto.Mechanism.pgid, rep.state.mechanism);
5653 try std.testing.expectEqual(proto.CmdPhase.at_prompt, rep.state.phase);
5654 }
5655
5656 test "Server: without shell integration a foreground job's end is caught by the pgid edge" {
5657 const alloc = std.testing.allocator;
5658
5659 var tmp = try TmpDir.make();
5660 defer tmp.cleanup();
5661 const sock_path = try std.fmt.allocPrint(alloc, "{s}/pgid.sock", .{tmp.path()});
5662 defer alloc.free(sock_path);
5663
5664 // A real interactive /bin/sh with no integration installed. No mark will
5665 // ever arrive, so the only evidence that a command ran and finished is
5666 // that the terminal's foreground process group left the shell and came
5667 // back — see the fgPgid test in pty.zig for that same movement observed
5668 // directly.
5669 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
5670 defer srv.deinit();
5671
5672 const c = try std.net.connectUnixSocket(sock_path);
5673 defer c.close();
5674 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
5675 // sleep 2, not sleep 1: the edge has to SEE the pgid off the shell before
5676 // "back on the shell" can mean anything, so the busy window must be wide
5677 // enough to sample. A job too short to observe is one the settle floor is
5678 // for, not this.
5679 try proto.writeFrame(c.handle, .input, "sleep 2\n");
5680 try proto.writeFrame(c.handle, .await_req, &proto.encodeAwaitReq(.{
5681 .since_seq = srv.tracker.seq,
5682 .settle_ms = 0,
5683 .timeout_ms = 10_000,
5684 }));
5685
5686 // ~9s of budget for a 2s job: bounded, and slack enough that a loaded box
5687 // does not read as a regression.
5688 const f = (try awaitFrame(alloc, &srv, c.handle, .await_reply, 1500)) orelse
5689 return error.NoPgidReply;
5690 defer f.deinit(alloc);
5691 const rep = try proto.decodeAwaitReply(f.payload);
5692 try std.testing.expectEqual(proto.AwaitReason.returned, rep.reason);
5693 try std.testing.expectEqual(proto.Mechanism.pgid, rep.state.mechanism);
5694 try std.testing.expectEqual(proto.CmdPhase.returned, rep.state.phase);
5695 // The pgid can say THAT a command ended, never with what code — no mark
5696 // carried one, and inventing a 0 here would be the whole failure mode
5697 // this mechanism has to avoid.
5698 try std.testing.expectEqual(@as(?u8, null), rep.state.exit_code);
5699 }
src/shellint.zig
Old New
@@ -0,0 +1,475 @@
1 //! Shell integration: OSC 133 marks injected at spawn. muxd forks the
2 //! session shell itself, so injection is env + argv at spawn time — no
3 //! rc-file edits, ever. Detection is by shell basename; unknown shells get
4 //! nothing and the session runs on the pgid/settle fallbacks.
5 const std = @import("std");
6 const xdg = @import("xdg");
7
8 /// The precmd hook, character for character the same in zsh and bash: both
9 /// shells spell `$?`, `local` and `printf` alike, and the mark it emits is
10 /// the protocol's, not either shell's. Spliced into both scripts below
11 /// rather than written twice — two copies of the hook that decides whether
12 /// a command's exit code is knowable is two places for that decision to
13 /// drift, and a drift would be silent in exactly one shell.
14 ///
15 /// `local code=$?` is the FIRST line for a reason that outlives any edit:
16 /// $? is clobbered by the next command to run, and every line added above
17 /// this one would be that command.
18 const precmd_fn =
19 \\_mux_precmd() {
20 \\ local code=$?
21 \\ [[ -n "$_mux_ran" ]] && printf '\e]133;D;%s\a' "$code"
22 \\ _mux_ran=""
23 \\ printf '\e]133;A\a'
24 \\}
25 \\
26 ;
27
28 /// Pointing ZDOTDIR at the shim silently costs the user their ~/.zshenv:
29 /// zsh looks for .zshenv under $ZDOTDIR, and the shim directory has none,
30 /// so a config kept there (PATH edits, and anything else zsh is expected to
31 /// read for non-interactive shells too) stops being read for the session.
32 /// The .zshrc is handed back below, which is the common case; a .zshenv
33 /// shim that restores ZDOTDIR the way ghostty's does is the roadmap fix.
34 pub const zsh_zshrc =
35 \\# mux shell integration (zsh): OSC 133 marks. Sourced via a ZDOTDIR
36 \\# shim; restores the user's ZDOTDIR (or unsets it) then runs their rc.
37 \\if [[ -n "$MUX_ORIG_ZDOTDIR" ]]; then
38 \\ export ZDOTDIR="$MUX_ORIG_ZDOTDIR"
39 \\ unset MUX_ORIG_ZDOTDIR
40 \\else
41 \\ unset ZDOTDIR
42 \\fi
43 \\[[ -f "${ZDOTDIR:-$HOME}/.zshrc" ]] && source "${ZDOTDIR:-$HOME}/.zshrc"
44 \\autoload -Uz add-zsh-hook
45 \\_mux_preexec() { _mux_ran=1; printf '\e]133;C\a'; }
46 \\
47 ++ precmd_fn ++
48 \\add-zsh-hook preexec _mux_preexec
49 \\add-zsh-hook precmd _mux_precmd
50 \\
51 ;
52
53 /// The DEBUG trap here silently REPLACES any DEBUG trap the session already
54 /// had — bash-preexec, atuin and iTerm2's integration each install one, and
55 /// bash allows exactly one. mux wins and the other goes quiet, with no
56 /// diagnostic anywhere. Coexisting by detecting bash-preexec and registering
57 /// with it instead is a roadmap item, not a thing this version does.
58 pub const bash_init =
59 \\# mux shell integration (bash): OSC 133 marks. Passed via --init-file;
60 \\# sources the user's normal rc first so their config still runs.
61 \\[[ -f "$HOME/.bashrc" ]] && source "$HOME/.bashrc"
62 \\_mux_ran=""
63 \\_mux_preexec() {
64 \\ [[ -n "$COMP_LINE" ]] && return
65 \\ [[ "$BASH_COMMAND" == _mux_precmd* ]] && return
66 \\ # PROMPT_COMMAND's own members run between the command and the next
67 \\ # prompt, and the DEBUG trap fires for every one of them. Counting
68 \\ # them as commands re-arms _mux_ran on every idle cycle, so the next
69 \\ # prompt reports `D;0` for a command nobody ran — which is how a
70 \\ # session on this box reported a successful command roughly once a
71 \\ # second while sitting at an untouched prompt.
72 \\ #
73 \\ # Membership is bash-preexec's check and is exact. "${PROMPT_COMMAND[@]}"
74 \\ # deliberately covers both spellings: a scalar expands as the single
75 \\ # word it is, so this needs no type test and, unlike `declare -p`, no
76 \\ # subshell in a path that runs before every command. A member that is
77 \\ # itself compound (`a; b`) fires DEBUG once per simple command and so
78 \\ # will not match, and a typed command whose text is character-for-
79 \\ # character a member gets suppressed. Both are bash-preexec's
80 \\ # limitations too, and both are quieter than the bug they replace.
81 \\ local _mux_c
82 \\ for _mux_c in "${PROMPT_COMMAND[@]}"; do
83 \\ [[ "$BASH_COMMAND" == "$_mux_c" ]] && return
84 \\ done
85 \\ _mux_ran=1
86 \\ printf '\e]133;C\a'
87 \\}
88 \\
89 ++ precmd_fn ++
90 \\# Prepended, never appended: _mux_precmd has to see the command's own
91 \\# $?, and any member running ahead of it would have overwritten it.
92 \\#
93 \\# Type-aware because bash 5.1 made PROMPT_COMMAND an array and the
94 \\# distributions took it up — Arch's /etc/bash.bashrc appends one under
95 \\# any xterm* TERM, which is exactly what muxd sets. A string assignment
96 \\# onto an array lands on element 0 and folds a member into a compound,
97 \\# which is precisely the shape the membership check above cannot match.
98 \\if [[ "$(declare -p PROMPT_COMMAND 2>/dev/null)" == "declare -a"* ]]; then
99 \\ PROMPT_COMMAND=(_mux_precmd "${PROMPT_COMMAND[@]}")
100 \\else
101 \\ PROMPT_COMMAND="_mux_precmd${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
102 \\fi
103 \\# LAST, and the ordering is load-bearing rather than tidy: a trap armed
104 \\# before the assignment above fires ON that assignment, so _mux_ran was
105 \\# already set when the first prompt ran and every session opened by
106 \\# reporting a command that never ran. Measured, not reasoned about —
107 \\# {C:PROMPT_COMMAND=...}{D;0}{A} was the first thing bash ever said.
108 \\trap '_mux_preexec' DEBUG
109 \\
110 ;
111
112 pub const fish_conf =
113 \\# mux shell integration (fish): OSC 133 marks, via vendor_conf.d.
114 \\function _mux_preexec --on-event fish_preexec
115 \\ printf '\e]133;C\a'
116 \\end
117 \\function _mux_postexec --on-event fish_postexec
118 \\ printf '\e]133;D;%s\a' $status
119 \\end
120 \\function _mux_prompt --on-event fish_prompt
121 \\ printf '\e]133;A\a'
122 \\end
123 \\
124 ;
125
126 pub const Kind = enum { zsh, bash, fish, other };
127
128 pub fn detect(shell_path: []const u8) Kind {
129 const base = std.fs.path.basename(shell_path);
130 if (std.mem.eql(u8, base, "zsh")) return .zsh;
131 if (std.mem.eql(u8, base, "bash")) return .bash;
132 if (std.mem.eql(u8, base, "fish")) return .fish;
133 return .other;
134 }
135
136 pub const EnvPair = struct { key: [:0]const u8, value: [:0]const u8 };
137
138 /// Everything the spawn needs: the argv to exec and env pairs to set in
139 /// the child. The shim directory must outlive the spawn (paths point into
140 /// it).
141 pub const Injection = struct {
142 /// Extra argv AFTER the shell path (bash --init-file <shim>); empty
143 /// for env-only injections (zsh, fish) and for .other.
144 extra_argv: []const [:0]const u8,
145 env: []const EnvPair,
146 /// The shim directory, set EXACTLY when this call created one — an
147 /// unknown shell writes nothing and reports null. The caller deletes
148 /// it at teardown, so a path reported here that was never created
149 /// would be a cleanup claiming work it did not do, and a path created
150 /// but not reported would be litter left in the runtime directory.
151 /// Reported from the one place that knows, rather than re-derived by
152 /// the caller from a second `detect` of the same shell.
153 dir: ?[]const u8 = null,
154 };
155
156 /// The empty injection: what a shell with no scripts gets, and what a
157 /// failed `install` degrades to. Nothing to exec, nothing to export,
158 /// nothing on disk to remove.
159 pub const no_injection: Injection = .{ .extra_argv = &.{}, .env = &.{}, .dir = null };
160
161 /// Prepare the shim under `parent_dir` and hand back what the spawn must
162 /// add, degrading to `no_injection` rather than failing.
163 ///
164 /// Degraded, never fatal. A session without marks is a working session —
165 /// it runs on the pgid and settle fallbacks, which is what every unknown
166 /// shell does — so refusing to start the daemon over an optional
167 /// enhancement would invert this module's premise. Said out loud, because
168 /// a silent fallback here would look exactly like a shell that ignores
169 /// its rc.
170 ///
171 /// The `mux-shellint-<pid>` naming lives here rather than at the call
172 /// site: it is the same fact as what `prepare` writes and what the
173 /// returned `dir` promises to delete, and the pid is what keeps two
174 /// daemons sharing one runtime directory out of each other's shims.
175 pub fn install(
176 arena: std.mem.Allocator,
177 parent_dir: []const u8,
178 shell_path: []const u8,
179 ) Injection {
180 const dir = std.fmt.allocPrint(
181 arena,
182 "{s}/mux-shellint-{d}",
183 .{ parent_dir, std.os.linux.getpid() },
184 ) catch {
185 std.debug.print(
186 "muxd: shell integration unavailable (out of memory naming the shim " ++
187 "directory under {s}); the session runs without command marks\n",
188 .{parent_dir},
189 );
190 return no_injection;
191 };
192 return prepare(arena, dir, shell_path) catch |err| {
193 std.debug.print(
194 "muxd: shell integration unavailable ({s}: {t}); " ++
195 "the session runs without command marks\n",
196 .{ dir, err },
197 );
198 return no_injection;
199 };
200 }
201
202 /// Prepare shim files under `dir` (created private, 0700) for `shell_path`
203 /// and return what spawn must add. All returned slices are allocated from
204 /// `arena` — hand it an arena that lives as long as the daemon.
205 ///
206 /// `install` is what the daemon calls; this stays public for the tests,
207 /// which need to name their own directory.
208 pub fn prepare(
209 arena: std.mem.Allocator,
210 dir: []const u8,
211 shell_path: []const u8,
212 ) !Injection {
213 switch (detect(shell_path)) {
214 .zsh => {
215 try xdg.makePrivateDir(dir);
216 const rc_path = try std.fs.path.join(arena, &.{ dir, ".zshrc" });
217 try writeFilePrivate(rc_path, zsh_zshrc);
218 var env: std.ArrayList(EnvPair) = .empty;
219 const dir_z = try arena.dupeZ(u8, dir);
220 try env.append(arena, .{ .key = "ZDOTDIR", .value = dir_z });
221 // Only when the daemon itself carried one: exporting an empty
222 // ZDOTDIR would break zsh's fallback to $HOME (spec footnote).
223 if (std.posix.getenv("ZDOTDIR")) |orig| {
224 try env.append(arena, .{
225 .key = "MUX_ORIG_ZDOTDIR",
226 .value = try arena.dupeZ(u8, orig),
227 });
228 }
229 return .{ .extra_argv = &.{}, .env = try env.toOwnedSlice(arena), .dir = dir };
230 },
231 .bash => {
232 try xdg.makePrivateDir(dir);
233 const init_path = try std.fs.path.join(arena, &.{ dir, "bash-init.sh" });
234 try writeFilePrivate(init_path, bash_init);
235 const init_z = try arena.dupeZ(u8, init_path);
236 const argv = try arena.alloc([:0]const u8, 2);
237 argv[0] = "--init-file";
238 argv[1] = init_z;
239 return .{ .extra_argv = argv, .env = &.{}, .dir = dir };
240 },
241 .fish => {
242 const vendor = try std.fs.path.join(arena, &.{ dir, "fish", "vendor_conf.d" });
243 try xdg.makePrivateDir(vendor);
244 const conf_path = try std.fs.path.join(arena, &.{ vendor, "mux.fish" });
245 try writeFilePrivate(conf_path, fish_conf);
246 const orig = std.posix.getenv("XDG_DATA_DIRS") orelse "/usr/local/share:/usr/share";
247 const merged = try std.fmt.allocPrintSentinel(arena, "{s}:{s}", .{ dir, orig }, 0);
248 return .{
249 .extra_argv = &.{},
250 .env = try arena.dupe(EnvPair, &.{.{ .key = "XDG_DATA_DIRS", .value = merged }}),
251 // The vendor directory nests UNDER `dir`, and `dir` is what
252 // teardown removes: deleting the leaf would leave the two
253 // directories above it behind.
254 .dir = dir,
255 };
256 },
257 .other => return no_injection,
258 }
259 }
260
261 /// The shim's contents are 0600 either way; the 0700 on the directory is
262 /// what keeps it from publishing that this daemon exists and what it named
263 /// its files — the same reason, and now the same code, as the key file's
264 /// parent (see xdg.makePrivateDir).
265 fn writeFilePrivate(path: []const u8, contents: []const u8) !void {
266 const f = try std.fs.cwd().createFile(path, .{ .mode = 0o600 });
267 defer f.close();
268 try f.writeAll(contents);
269 }
270
271 test "detect goes by basename" {
272 try std.testing.expectEqual(Kind.zsh, detect("/usr/bin/zsh"));
273 try std.testing.expectEqual(Kind.zsh, detect("zsh"));
274 try std.testing.expectEqual(Kind.bash, detect("/bin/bash"));
275 try std.testing.expectEqual(Kind.fish, detect("/usr/local/bin/fish"));
276 // The fallbacks: a POSIX sh and anything exotic get no injection at
277 // all, and the session runs on pgid + settle exactly as before.
278 try std.testing.expectEqual(Kind.other, detect("/bin/sh"));
279 try std.testing.expectEqual(Kind.other, detect("/usr/bin/nu"));
280 // Only the last component decides. A "bash" directory on the way there
281 // must not make a dash session look like a bash one and get handed a
282 // --init-file it does not understand.
283 try std.testing.expectEqual(Kind.other, detect("/opt/bash/bin/dash"));
284 }
285
286 /// The three prepare tests all want a real, writable, disposable directory
287 /// and the string naming it. No socket is bound here, so std's tmpDir (and
288 /// its long .zig-cache path) is fine — testtmp exists for sun_path, which
289 /// this module never touches.
290 const TmpPath = struct {
291 tmp: std.testing.TmpDir,
292 dir: []const u8,
293
294 fn make() !TmpPath {
295 var tmp = std.testing.tmpDir(.{});
296 errdefer tmp.cleanup();
297 const dir = try tmp.dir.realpathAlloc(std.testing.allocator, ".");
298 return .{ .tmp = tmp, .dir = dir };
299 }
300
301 fn deinit(self: *TmpPath) void {
302 std.testing.allocator.free(self.dir);
303 self.tmp.cleanup();
304 }
305 };
306
307 test "prepare zsh writes the shim and sets ZDOTDIR" {
308 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
309 defer arena.deinit();
310 var t = try TmpPath.make();
311 defer t.deinit();
312
313 const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" });
314 const inj = try prepare(arena.allocator(), shim, "/usr/bin/zsh");
315
316 // zsh is an env-only injection: the shell is exec'd with no extra argv
317 // and finds the shim because ZDOTDIR points at it.
318 try std.testing.expectEqual(@as(usize, 0), inj.extra_argv.len);
319 try std.testing.expect(inj.env.len >= 1);
320 try std.testing.expectEqualStrings("ZDOTDIR", inj.env[0].key);
321 try std.testing.expectEqualStrings(shim, inj.env[0].value);
322 // A directory was created, so it is reported — this is the value the
323 // daemon deletes at teardown, and nothing else tells it what to delete.
324 try std.testing.expectEqualStrings(shim, inj.dir.?);
325
326 // ZDOTDIR names the directory; the file zsh will source is the .zshrc
327 // inside it, which is the artifact worth asserting on.
328 const rc_path = try std.fs.path.join(arena.allocator(), &.{ inj.env[0].value, ".zshrc" });
329 const rc = try std.fs.cwd().readFileAlloc(std.testing.allocator, rc_path, 8192);
330 defer std.testing.allocator.free(rc);
331 // The two halves that make it work: the D mark carries the exit code,
332 // and the hooks are actually registered.
333 try std.testing.expect(std.mem.indexOf(u8, rc, "133;D;%s") != null);
334 try std.testing.expect(std.mem.indexOf(u8, rc, "add-zsh-hook") != null);
335 // ...and the shim hands control back to the user's own rc, which is the
336 // difference between integration and hijacking their shell.
337 try std.testing.expect(std.mem.indexOf(u8, rc, ".zshrc\"") != null);
338 }
339
340 test "prepare bash returns --init-file argv" {
341 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
342 defer arena.deinit();
343 var t = try TmpPath.make();
344 defer t.deinit();
345
346 const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" });
347 const inj = try prepare(arena.allocator(), shim, "/bin/bash");
348
349 // bash has no ZDOTDIR equivalent, so the shim arrives on the command
350 // line instead — and nothing goes into the environment.
351 try std.testing.expectEqual(@as(usize, 2), inj.extra_argv.len);
352 try std.testing.expectEqualStrings("--init-file", inj.extra_argv[0]);
353 try std.testing.expectEqual(@as(usize, 0), inj.env.len);
354 try std.testing.expectEqualStrings(shim, inj.dir.?);
355
356 const script = try std.fs.cwd().readFileAlloc(std.testing.allocator, inj.extra_argv[1], 8192);
357 defer std.testing.allocator.free(script);
358 try std.testing.expect(std.mem.indexOf(u8, script, "PROMPT_COMMAND") != null);
359 try std.testing.expect(std.mem.indexOf(u8, script, "trap '_mux_preexec' DEBUG") != null);
360 // --init-file REPLACES ~/.bashrc, so the shim sourcing it is what keeps
361 // the user's shell theirs. Its absence would be silent.
362 try std.testing.expect(std.mem.indexOf(u8, script, "$HOME/.bashrc") != null);
363 }
364
365 test "prepare fish writes vendor_conf.d and prepends to XDG_DATA_DIRS" {
366 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
367 defer arena.deinit();
368 var t = try TmpPath.make();
369 defer t.deinit();
370
371 // No fish binary needed: what `prepare` owes fish is a file at the path
372 // fish looks in and a data dir pointing there, and both are checkable
373 // on any box. The e2e that needs the real shell skips where it is absent.
374 const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" });
375 const inj = try prepare(arena.allocator(), shim, "/usr/bin/fish");
376
377 try std.testing.expectEqual(@as(usize, 0), inj.extra_argv.len);
378 try std.testing.expectEqual(@as(usize, 1), inj.env.len);
379 try std.testing.expectEqualStrings("XDG_DATA_DIRS", inj.env[0].key);
380 // The shim root, not the vendor directory nested inside it: teardown
381 // removes what it is given, and the leaf would strand two levels.
382 try std.testing.expectEqualStrings(shim, inj.dir.?);
383 // Prepended, not replaced: fish still has to find its own completions
384 // and functions, so clobbering the list would break the shell to
385 // integrate with it.
386 try std.testing.expect(std.mem.startsWith(u8, inj.env[0].value, shim));
387 try std.testing.expect(inj.env[0].value.len > shim.len + 1);
388 try std.testing.expectEqual(@as(u8, ':'), inj.env[0].value[shim.len]);
389
390 // fish reads vendor_conf.d from `$XDG_DATA_DIRS/fish/vendor_conf.d`, so
391 // the nesting under the shim directory is the part that has to be right.
392 const conf_path = try std.fs.path.join(
393 arena.allocator(),
394 &.{ shim, "fish", "vendor_conf.d", "mux.fish" },
395 );
396 const conf = try std.fs.cwd().readFileAlloc(std.testing.allocator, conf_path, 8192);
397 defer std.testing.allocator.free(conf);
398 try std.testing.expect(std.mem.indexOf(u8, conf, "--on-event fish_preexec") != null);
399 try std.testing.expect(std.mem.indexOf(u8, conf, "--on-event fish_postexec") != null);
400 try std.testing.expect(std.mem.indexOf(u8, conf, "133;D;%s") != null);
401 }
402
403 test "prepare other injects nothing" {
404 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
405 defer arena.deinit();
406 var t = try TmpPath.make();
407 defer t.deinit();
408
409 const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" });
410 const inj = try prepare(arena.allocator(), shim, "/bin/sh");
411 try std.testing.expectEqual(@as(usize, 0), inj.extra_argv.len);
412 try std.testing.expectEqual(@as(usize, 0), inj.env.len);
413 // Not merely empty: an unknown shell must leave no trace on disk, so a
414 // /bin/sh session is byte-identical to one from before this module.
415 try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(shim, .{}));
416 // And it says so, which is the half teardown reads: a reported path
417 // here would have the daemon delete-tree a directory nothing created.
418 try std.testing.expectEqual(@as(?[]const u8, null), inj.dir);
419 }
420
421 test "install names the shim directory after the daemon and degrades in place" {
422 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
423 defer arena.deinit();
424 var t = try TmpPath.make();
425 defer t.deinit();
426
427 // The naming rule, asserted where it now lives: the caller hands over a
428 // parent and gets back a per-pid directory under it, which is what keeps
429 // two daemons sharing one runtime directory out of each other's shims.
430 const inj = install(arena.allocator(), t.dir, "/bin/bash");
431 var want: [512]u8 = undefined;
432 const expect = try std.fmt.bufPrint(
433 &want,
434 "{s}/mux-shellint-{d}",
435 .{ t.dir, std.os.linux.getpid() },
436 );
437 try std.testing.expectEqualStrings(expect, inj.dir.?);
438 try std.fs.cwd().access(expect, .{});
439
440 // An unwritable parent is the degraded path, and it is NOT an error: a
441 // session without marks still runs, so `install` returns the empty
442 // injection and the daemon starts. (The diagnostic goes to stderr; what
443 // is asserted here is that nothing propagates and nothing is claimed.)
444 const blocked = try std.fs.path.join(arena.allocator(), &.{ t.dir, "file-not-a-dir" });
445 const f = try std.fs.cwd().createFile(blocked, .{});
446 f.close();
447 const degraded = install(arena.allocator(), blocked, "/bin/bash");
448 try std.testing.expectEqual(@as(usize, 0), degraded.extra_argv.len);
449 try std.testing.expectEqual(@as(usize, 0), degraded.env.len);
450 try std.testing.expectEqual(@as(?[]const u8, null), degraded.dir);
451 }
452
453 test "prepare zsh: the shim directory is 0700 and the rc file 0600" {
454 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
455 defer arena.deinit();
456 var t = try TmpPath.make();
457 defer t.deinit();
458
459 const shim = try std.fs.path.join(arena.allocator(), &.{ t.dir, "shim" });
460 _ = try prepare(arena.allocator(), shim, "/usr/bin/zsh");
461
462 // makePath alone leaves 0755. What lands here is a file the session
463 // shell sources — anyone who can write it can run code as this user —
464 // so the permissions are part of the contract, not decoration.
465 var d = try std.fs.cwd().openDir(shim, .{ .iterate = true });
466 defer d.close();
467 const dst = try d.stat();
468 try std.testing.expectEqual(@as(u32, 0o700), @as(u32, @intCast(dst.mode & 0o777)));
469
470 const rc_path = try std.fs.path.join(arena.allocator(), &.{ shim, ".zshrc" });
471 const f = try std.fs.cwd().openFile(rc_path, .{});
472 defer f.close();
473 const fst = try f.stat();
474 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(fst.mode & 0o777)));
475 }
src/sockpath.zig
Old New
@@ -15,6 +15,17 @@ const std = @import("std");
15 /// the kernel's and belongs in one place, the wording is theirs. 15 /// the kernel's and belongs in one place, the wording is theirs.
16 pub const max_sun_path = 107; 16 pub const max_sun_path = 107;
17 17
18 /// Where a binary looks when nobody named a socket. The default path is
19 /// part of a socket path's identity too: it is what makes two binaries
20 /// started with no `--sock` land on the SAME daemon, so it lives here with
21 /// the bound rather than once per binary.
22 pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 {
23 if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir| {
24 return std.fmt.allocPrint(alloc, "{s}/muxd.sock", .{dir});
25 }
26 return std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()});
27 }
28
18 /// A socket file's identity at the moment it was bound, so teardown can 29 /// A socket file's identity at the moment it was bound, so teardown can
19 /// tell our socket from one that replaced it. 30 /// tell our socket from one that replaced it.
20 /// 31 ///
src/webhub.zig
Old New
@@ -508,6 +508,12 @@ fn dialLoop(
508 /// bytes JSON cannot carry raw in a string plus control chars; labels 508 /// bytes JSON cannot carry raw in a string plus control chars; labels
509 /// are argv (hosts, paths), not hostile input, but a path with a quote 509 /// are argv (hosts, paths), not hostile input, but a path with a quote
510 /// in it must not break the page. 510 /// in it must not break the page.
511 ///
512 /// Deliberately NOT muxa's jsonEscape, though the two look alike: this one
513 /// sends every control byte to `\u00XX` (one rule, no table to get wrong)
514 /// while muxa spells the short forms `\n`, `\r`, `\t`. Both are valid JSON
515 /// and parse identically, but the bytes differ, and each is pinned by its
516 /// own test. Sharing one would rewrite one side's output for no gain.
511 pub fn tilesJson(alloc: std.mem.Allocator, labels: []const []const u8) ![]u8 { 517 pub fn tilesJson(alloc: std.mem.Allocator, labels: []const []const u8) ![]u8 {
512 var out: std.ArrayList(u8) = .empty; 518 var out: std.ArrayList(u8) = .empty;
513 errdefer out.deinit(alloc); 519 errdefer out.deinit(alloc);
src/xdg.zig
Old New
@@ -105,20 +105,19 @@ pub fn hostCachePathFrom(
105 return std.fmt.allocPrint(alloc, "{s}/.cache/mux/hosts/{s}", .{ h, host }); 105 return std.fmt.allocPrint(alloc, "{s}/.cache/mux/hosts/{s}", .{ h, host });
106 } 106 }
107 107
108 /// Create `path`'s parent directories and tighten the immediate parent to 108 /// Create `dir` and everything above it, then tighten `dir` itself to
109 /// 0700. Both files this project writes under a home directory — the key 109 /// 0700. Every directory this project creates to hold something private —
110 /// and the handoff cache — hold a credential and want exactly this, so the 110 /// the key file's parent, the handoff cache's, and the shell-integration
111 /// policy and the reason it is subtle live here rather than in two copies. 111 /// shim directory — wants exactly this, so the policy and the two subtle
112 /// A `path` with no directory component is a no-op. 112 /// parts of it live here rather than in a copy per caller.
113 pub fn makePrivateParent(path: []const u8) !void { 113 pub fn makePrivateDir(dir: []const u8) !void {
114 const dir = std.fs.path.dirname(path) orelse return;
115 try std.fs.cwd().makePath(dir); 114 try std.fs.cwd().makePath(dir);
116 // makePath leaves 0755, which does not expose the file's contents — 115 // makePath leaves 0755, which does not expose a contained file's
117 // that is 0600 — but does expose that it exists and what it is called. 116 // contents — that is 0600 — but does expose that it exists and what it
118 // ssh's answer for the analogous directory is 0700 and there is no 117 // is called. ssh's answer for the analogous directory is 0700 and
119 // reason to be looser. Only the LAST component is tightened: the 118 // there is no reason to be looser. Only THIS component is tightened:
120 // parents on the way (`~`, `~/.config`) are the user's own business 119 // the parents on the way (`~`, `~/.config`, the runtime directory) are
121 // and are not ours to re-permission. 120 // the user's own business and are not ours to re-permission.
122 // `.iterate = true` is not optional here: Dir.chmod fchmods the 121 // `.iterate = true` is not optional here: Dir.chmod fchmods the
123 // directory's own fd, and without it the fd is opened O_PATH, which 122 // directory's own fd, and without it the fd is opened O_PATH, which
124 // fchmod refuses. 123 // fchmod refuses.
@@ -127,6 +126,13 @@ pub fn makePrivateParent(path: []const u8) !void {
127 try d.chmod(0o700); 126 try d.chmod(0o700);
128 } 127 }
129 128
129 /// The same, for callers holding the path of the FILE that is going to
130 /// live there. A `path` with no directory component is a no-op.
131 pub fn makePrivateParent(path: []const u8) !void {
132 const dir = std.fs.path.dirname(path) orelse return;
133 try makePrivateDir(dir);
134 }
135
130 /// 32 random bytes at `path`, mode 0600, parent directories created and 136 /// 32 random bytes at `path`, mode 0600, parent directories created and
131 /// the immediate parent tightened to 0700. 137 /// the immediate parent tightened to 0700.
132 /// Refuses to overwrite: rotation is `rm` + `keygen`, deliberate on both 138 /// Refuses to overwrite: rotation is `rm` + `keygen`, deliberate on both
test/agent.sh
Old New
@@ -0,0 +1,746 @@
1 #!/bin/sh
2 # End-to-end for the agent surface: muxd daemon + muxa client, binary level.
3 # Every verb muxa has prints one JSON object, so every assertion here is made
4 # against a PARSED object rather than a grep of the line — a field that got
5 # renamed, or a number that became a string, is a failure this suite can see.
6 #
7 # Unlike test/e2e.sh this one does NOT exit on the first failure: each
8 # scenario is bounded on its own and reports PASS/FAIL/SKIP, and the suite
9 # exits nonzero at the end if any failed. A run that finds two defects should
10 # report two, not the first one and a silence.
11 set -u
12
13 # Binaries: the two under test, defaulting to the build output next to this
14 # script's repo. Positional overrides keep e2e.sh's convention for a caller
15 # (build.zig, a packaging check) that wants to name them explicitly.
16 ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
17 MUXD="${1:-$ROOT/zig-out/bin/muxd}"
18 MUXA="${2:-$ROOT/zig-out/bin/muxa}"
19 [ -x "$MUXD" ] || { echo "agent FAIL: no muxd at $MUXD (run: zig build)"; exit 1; }
20 [ -x "$MUXA" ] || { echo "agent FAIL: no muxa at $MUXA (run: zig build)"; exit 1; }
21
22 # The two tools every scenario needs, checked here rather than per-scenario
23 # because a box without them cannot run ANY of this: python3 parses every
24 # assertion (see jget) and builds the relay, and `timeout` is what makes a hung
25 # muxa a failure instead of a wedged suite. Missing either is a refusal to run,
26 # not a skip — nine SKIP lines and exit 0 would be this suite reporting success
27 # for work it did not do.
28 for _tool in python3 timeout; do
29 command -v "$_tool" >/dev/null 2>&1 ||
30 { echo "agent FAIL: no $_tool — this suite cannot assert or bound anything without it"; exit 1; }
31 done
32
33 # One directory for everything this run writes: sockets, keys, captures, the
34 # relay, the TUI's HOME. Removed by the trap, so a failing run leaves nothing
35 # behind but its output.
36 TMP="${TMPDIR:-/tmp}/mux-agent-$$"
37 mkdir -p "$TMP" || exit 1
38
39 # Hermetic homes, for e2e.sh's reasons: the key-bearing scenarios must see OUR
40 # key and never the developer's ~/.config/mux/key, and $SHELL is read the same
41 # way — a daemon started without --shell would otherwise run the operator's
42 # login shell and its whole rc on the session under test.
43 XDG_CONFIG_HOME="$TMP/cfg"
44 XDG_STATE_HOME="$TMP/state"
45 XDG_CACHE_HOME="$TMP/cache"
46 export XDG_CONFIG_HOME XDG_STATE_HOME XDG_CACHE_HOME
47 SHELL=/bin/sh
48 export SHELL
49 # muxa reads MUX_KEY_FILE BEFORE the default key path, so an operator who
50 # happens to have one exported would silently change which key every QUIC
51 # scenario below presents — and they would still pass, against the wrong key.
52 unset MUX_KEY_FILE
53
54 # Unix-socket daemons, one per scenario that owns its session's shell.
55 SOCK_MARKS="$TMP/marks.sock"
56 SOCK_TUI="$TMP/tui.sock"
57 SOCK_SETTLE="$TMP/settle.sock"
58 # QUIC daemons: the one behind the tearable relay, and the one the quiet-await
59 # scenario dials directly (it must keep the DEFAULT idle timeout, which is the
60 # very thing it is pinning, so it cannot share the reduced-idle daemon).
61 SOCK_TEAR="$TMP/tear.sock"
62 SOCK_QUIET="$TMP/quiet.sock"
63 # Ports in a band of their own so a concurrent test/e2e.sh (11000..46000, in
64 # 5000-wide slots) cannot collide, and per-run so two agent suites can overlap.
65 # All four are ports we BIND, which is what makes the ephemeral range safe here.
66 PORT_TEAR=$((51000 + ($$ % 900)))
67 PORT_RELAY=$((52000 + ($$ % 900)))
68 PORT_QUIET=$((53000 + ($$ % 900)))
69 PORT_SINK=$((54000 + ($$ % 900)))
70
71 KEY="$TMP/key"
72 RELAY="$TMP/relay.py"
73 RELAY_LOG="$TMP/relay.log"
74 SINK_LOG="$TMP/sink.log"
75 # The relay's two control files. Creating one is the tear; the relay removes it
76 # and narrates what it did, so the log is evidence rather than the test's own
77 # claim about what it asked for.
78 CTL_FLOW="$TMP/ctl.flow"
79 CTL_ALL="$TMP/ctl.all"
80 TUISH="$TMP/tui.sh"
81
82 # Every pid the trap may have to kill, declared before the trap is installed:
83 # under `set -u` a bare $VAR the trap reads would abort the trap itself on a
84 # failure that happened before the assignment, and the tmpdir would survive.
85 D_MARKS=""
86 D_TUI=""
87 D_SETTLE=""
88 D_TEAR=""
89 D_QUIET=""
90 RELAY_PID=""
91 SINK_PID=""
92 CLI_PID=""
93
94 cleanup() {
95 # Every pid this run started, including the ones already dead: a kill that
96 # finds nothing is not a problem here, which is why the status of each one
97 # is discarded rather than tested. The `return 0` at the bottom is the load-
98 # bearing part — without it the trap would exit with the status of whatever
99 # ran last, and a cleanup that fired on a PASSING run could fail the suite.
100 for p in "$D_MARKS" "$D_TUI" "$D_SETTLE" "$D_TEAR" "$D_QUIET" \
101 "$RELAY_PID" "$SINK_PID" "$CLI_PID"; do
102 [ -n "$p" ] && kill "$p" 2>/dev/null
103 done
104 # ...and by socket, for the window between `muxd start`'s fork and the
105 # up-line this script reads its pid from. `muxd stop` on a path nobody
106 # serves is a no-op. These must precede the rm -rf: unlinking the sockets
107 # first would leave a live daemon nothing could reach by path.
108 for s in "$SOCK_MARKS" "$SOCK_TUI" "$SOCK_SETTLE" "$SOCK_TEAR" "$SOCK_QUIET"; do
109 [ -S "$s" ] && "$MUXD" stop --sock "$s" >/dev/null 2>&1
110 done
111 rm -rf "$TMP"
112 return 0
113 }
114 trap 'cleanup' EXIT INT TERM
115
116 PASSES=0
117 FAILS=0
118 SKIPS=0
119 pass() { PASSES=$((PASSES + 1)); echo "agent PASS: $1"; }
120 fail() { FAILS=$((FAILS + 1)); echo "agent FAIL: $1"; }
121 skip() { SKIPS=$((SKIPS + 1)); echo "agent SKIP: $1"; }
122
123 # The reason a scenario function gave for stopping. Set by `why`, read by the
124 # caller: a scenario reports ONE line, and this is how the first failed
125 # assertion inside it gets into that line.
126 #
127 # It also reaps the scenario's in-flight client, because this is the ONLY path
128 # out of a QUIC scenario that skips the `wait` below it. Without this a failing
129 # tear scenario leaves a muxa still awaiting on the relay, and the scenario
130 # after it counts that stranger's flows as its own — one failure would read as
131 # two, and the second one would be fiction.
132 WHY=""
133 why() {
134 WHY="$1"
135 [ -n "$CLI_PID" ] && kill "$CLI_PID" 2>/dev/null
136 CLI_PID=""
137 return 1
138 }
139
140 # Run a scenario function: 0 passes, 1 fails with $WHY, 2 skips with $WHY.
141 # One line per scenario, which is what makes the count pin at the bottom mean
142 # "every scenario ran" rather than "some number of assertions ran".
143 run_scenario() {
144 _name="$1"
145 shift
146 WHY=""
147 "$@"
148 case $? in
149 0) pass "$_name" ;;
150 2) skip "$_name: $WHY" ;;
151 *) fail "$_name: $WHY" ;;
152 esac
153 }
154
155 # One field out of a JSON object, re-encoded as JSON: `null`, `true`, `0`, and
156 # `"marks"` WITH its quotes. The quotes are the point. muxa's contract is a
157 # typed one — exit_code is a number, alt_screen a boolean, mechanism a string —
158 # and a `str(v)` here would print all three the same way, so a daemon that
159 # started spelling exit_code as "0" or alt_screen as "true" would sail past
160 # every assertion below. Re-encoding makes the type part of the comparison, and
161 # the cost is that string expectations at the call sites carry their quotes too.
162 #
163 # The three sentinels cannot collide with any of that: a field whose value were
164 # literally the text `<missing>` re-encodes to `"<missing>"`, quotes and all. A
165 # muxa that printed a stack trace fails as `<unparseable>`, not as a mismatch.
166 jget() {
167 python3 - "$1" "$2" <<'PY'
168 import json, sys
169 try:
170 obj = json.load(open(sys.argv[1]))
171 except Exception:
172 print("<unparseable>"); raise SystemExit(0)
173 if not isinstance(obj, dict):
174 print("<not-an-object>"); raise SystemExit(0)
175 if sys.argv[2] not in obj:
176 print("<missing>"); raise SystemExit(0)
177 print(json.dumps(obj[sys.argv[2]]))
178 PY
179 }
180
181 # want FILE FIELD VALUE — assert one field, naming the whole body on a miss so
182 # a wrong answer is read in context rather than alone. VALUE is JSON: bare for
183 # null/true/false/numbers, quoted for strings.
184 want() {
185 _got=$(jget "$1" "$2")
186 [ "$_got" = "$3" ] && return 0
187 why "$2=$_got, want $3 [$(tr -d '\n' < "$1")]"
188 }
189
190 # Wait until PATTERN shows up in FILE. Keyed off the process's own output
191 # rather than a fixed sleep, e2e.sh's convention.
192 wait_for() {
193 _i=0
194 while [ "$_i" -lt $((${3:-10} * 20)) ]; do
195 [ -f "$1" ] && grep -q "$2" "$1" 2>/dev/null && return 0
196 sleep 0.05
197 _i=$((_i + 1))
198 done
199 return 1
200 }
201
202 # Start a detached daemon and hand back the pid IT reported. Never a pid this
203 # script guessed from a process name: the suite kills what it started, and a
204 # name match can only ever name a bystander.
205 start_daemon() {
206 _log="$1"
207 shift
208 "$MUXD" start "$@" >"$_log" 2>&1
209 sed -n 's/^up .*pid=\([0-9]*\).*/\1/p' "$_log" | head -1
210 }
211
212 # Poll until muxa can answer on a socket: the session's shell has to have been
213 # exec'd and the daemon's listener accepted before any assertion means
214 # anything, and how long that takes is the box's business, not a constant here.
215 wait_ready() {
216 _i=0
217 while [ "$_i" -lt 100 ]; do
218 "$MUXA" status --sock "$1" --timeout 1000 >/dev/null 2>&1 && return 0
219 sleep 0.05
220 _i=$((_i + 1))
221 done
222 return 1
223 }
224
225 # start_ready PIDVAR LOG SOCK ARGS... — bring a daemon up and wait for it to
226 # answer, storing its pid in the named variable and calling `why` with the
227 # whole story if either half fails. The bring-up is identical in every scenario
228 # that needs a plain daemon, and a scenario that got only PART of it right — a
229 # pid but no readiness wait — would fail later, somewhere else, as a flake.
230 #
231 # PIDVAR is set the INSTANT the pid is known, before the readiness wait and
232 # whatever that wait decides. That ordering is the load-bearing part: a daemon
233 # that came up and then never answered is still a daemon this run started, and
234 # a version that only assigned on success would leave it running past cleanup.
235 #
236 # The two scenarios that do NOT use this (the TUI and the QUIC tear) want a
237 # daemon that failed to come up to be a stashed message rather than an
238 # immediate return, and folding that in would cost more than it saves.
239 start_ready() {
240 _var="$1"
241 _log="$2"
242 _sock="$3"
243 shift 3
244 eval "$_var=\$(start_daemon \"\$_log\" \"\$@\")"
245 eval "_pid=\$$_var"
246 [ -n "$_pid" ] || why "daemon never printed an up-line [$(cat "$_log")]" || return 1
247 wait_ready "$_sock" || why "daemon never answered on $_sock" || return 1
248 return 0
249 }
250
251 now_ms() { python3 -c 'import time; print(int(time.time() * 1000))'; }
252
253 echo "agent: ports ${PORT_TEAR}/${PORT_RELAY}/${PORT_QUIET}/${PORT_SINK}, tmp $TMP"
254
255 # --- 1: marks. A shell with OSC 133 injected knows its own exit codes -------
256 # The only mechanism that can report an exit code at all, so all three
257 # assertions here are really one: `mechanism=marks` is what makes the number
258 # in `exit_code` the command's rather than a guess.
259 scen_marks() {
260 [ -x /bin/bash ] || { WHY="no /bin/bash to inject marks into"; return 2; }
261 start_ready D_MARKS "$TMP/marks.log" "$SOCK_MARKS" --sock "$SOCK_MARKS" --shell /bin/bash || return 1
262
263 timeout 20 "$MUXA" run --sock "$SOCK_MARKS" --timeout 8000 'true' >"$TMP/m1" 2>&1
264 _rc=$?
265 [ "$_rc" -eq 0 ] || why "run 'true' exited $_rc [$(tr -d '\n' < "$TMP/m1")]" || return 1
266 want "$TMP/m1" reason '"returned"' || return 1
267 want "$TMP/m1" mechanism '"marks"' || return 1
268 want "$TMP/m1" exit_code 0 || return 1
269
270 timeout 20 "$MUXA" run --sock "$SOCK_MARKS" --timeout 8000 'false' >"$TMP/m2" 2>&1
271 want "$TMP/m2" reason '"returned"' || return 1
272 want "$TMP/m2" mechanism '"marks"' || return 1
273 # The command failed; muxa did not. A nonzero exit_code is an ANSWER, and
274 # an agent that branches on muxa's own status must not see it as an error.
275 want "$TMP/m2" exit_code 1 || return 1
276
277 # A marker the session's shell cannot expand differently than we spell it,
278 # and one that is unique per run so a stale grid can never satisfy it.
279 _mark="out-$$"
280 timeout 20 "$MUXA" run --sock "$SOCK_MARKS" --timeout 8000 "echo $_mark" >"$TMP/m3" 2>&1
281 want "$TMP/m3" reason '"returned"' || return 1
282 want "$TMP/m3" mechanism '"marks"' || return 1
283 want "$TMP/m3" exit_code 0 || return 1
284 # Exactly the output, not "contains": the span between the two marks is
285 # the command's transcript, and a prompt or an echoed command line leaking
286 # into it is the bug this equality is here to catch.
287 want "$TMP/m3" output "\"$_mark\"" || return 1
288
289 "$MUXD" stop --sock "$SOCK_MARKS" >/dev/null 2>&1
290 D_MARKS=""
291 return 0
292 }
293 run_scenario "marks: exit codes and output come back from a bash session" scen_marks
294
295 # --- The ephemeral TUI, which two scenarios share --------------------------
296 # The spec's field specimen: a daemon whose session is not a shell at all but a
297 # throwaway full-screen program. `muxd start` has no `--` argv, and --shell
298 # execs whatever path it is given, so a one-line wrapper carries the argument.
299 # HOME points into the tmpdir for e2e.sh's $SHELL reason: ~/.lesskey and
300 # ~/.vimrc are arbitrary code on the session under test.
301 #
302 # The outcome splits three ways, and the split is the point: no TUI on the box
303 # is the environment's business and skips, but a TUI that is here and whose
304 # daemon did not come up is a DEFECT and must fail. Blanking D_TUI for both
305 # would report a broken muxd as a skip — and would also lose the pid, orphaning
306 # a daemon that is merely unresponsive rather than dead.
307 TUI_BIN=""
308 TUI_QUIT=""
309 TUI_SKIP=""
310 TUI_FAIL=""
311 TUI_OK=""
312 if command -v vi >/dev/null 2>&1; then
313 TUI_BIN=$(command -v vi)
314 TUI_QUIT=':q!\n'
315 elif command -v less >/dev/null 2>&1; then
316 TUI_BIN=$(command -v less)
317 TUI_QUIT='q'
318 fi
319 if [ -z "$TUI_BIN" ]; then
320 TUI_SKIP="neither vi nor less on this box"
321 else
322 mkdir -p "$TMP/home"
323 # Single-quoted in the generated script, both of them: $TMP contains $$ and
324 # is usually tame, but a TMPDIR with a space in it would otherwise split
325 # HOME in half and hand `exec` an argument it never meant to have.
326 {
327 echo '#!/bin/sh'
328 echo "HOME='$TMP/home'; export HOME"
329 # less reads its own switches out of the environment; a developer with
330 # -F exported would make the session exit before it was ever driven.
331 echo 'LESS=; export LESS'
332 echo 'unset LESSOPEN LESSCLOSE'
333 echo "exec '$TUI_BIN' /etc/hostname"
334 } > "$TUISH"
335 chmod +x "$TUISH"
336 # D_TUI keeps the pid whatever happens next, so cleanup can always reach a
337 # daemon that came up but never answered.
338 D_TUI=$(start_daemon "$TMP/tui.log" --sock "$SOCK_TUI" --shell "$TUISH")
339 if [ -z "$D_TUI" ]; then
340 TUI_FAIL="the TUI daemon printed no up-line [$(tr -d '\n' < "$TMP/tui.log")]"
341 elif wait_ready "$SOCK_TUI"; then
342 TUI_OK=1
343 else
344 TUI_FAIL="the TUI daemon (pid $D_TUI) never answered on $SOCK_TUI"
345 fi
346 fi
347
348 # The gate both TUI scenarios open with: 1 for a defect, 2 for a box that has
349 # no TUI to drive. `return $?` propagates whichever it was.
350 tui_gate() {
351 [ -z "$TUI_FAIL" ] || { WHY="$TUI_FAIL"; return 1; }
352 [ -n "$TUI_OK" ] || { WHY="$TUI_SKIP"; return 2; }
353 return 0
354 }
355
356 # --- 2: the alt-screen guard ------------------------------------------------
357 # Ordered before the drive below because that one ENDS this session. Nothing
358 # on a full-screen program's grid can mean "the command returned" — there are
359 # no marks, no prompt, and no rows to attribute — so the honest answer to
360 # `run` is that the wait timed out. A fabricated `returned` here would be the
361 # worst failure in the surface: an agent would read an exit code that no
362 # command ever produced.
363 scen_altguard() {
364 tui_gate || return $?
365 timeout 20 "$MUXA" run --sock "$SOCK_TUI" --timeout 1500 'true' >"$TMP/g1" 2>&1
366 _rc=$?
367 want "$TMP/g1" reason '"timeout"' || return 1
368 # Exit 3 is the whole point of having a code for it: `returned` and
369 # `settled` are answers and exit 0, a timeout is a question still open.
370 [ "$_rc" -eq 3 ] || why "exit $_rc, want 3 [$(tr -d '\n' < "$TMP/g1")]" || return 1
371 return 0
372 }
373 run_scenario "alt-screen: run times out rather than fabricating a return" scen_altguard
374
375 # --- 3: the ephemeral TUI, driven and quit ---------------------------------
376 scen_tui() {
377 tui_gate || return $?
378 timeout 20 "$MUXA" status --sock "$SOCK_TUI" --timeout 5000 >"$TMP/t1" 2>&1
379 want "$TMP/t1" alt_screen true || return 1
380 # Marks are a shell's doing. A program that is not a shell cannot have
381 # them, and claiming otherwise is what scenario 2 would then read.
382 _mech=$(jget "$TMP/t1" mechanism)
383 [ "$_mech" != '"marks"' ] || why "mechanism=marks on a TUI that no shell started" || return 1
384
385 timeout 10 "$MUXA" send --sock "$SOCK_TUI" -- "$TUI_QUIT" >"$TMP/t2" 2>&1
386 want "$TMP/t2" sent true || return 1
387
388 # The session ended: the pid the daemon reported for itself is gone. Not
389 # the socket, which is unlinked a moment before the process is actually
390 # down — only a pid can answer this.
391 _i=0
392 while kill -0 "$D_TUI" 2>/dev/null; do
393 _i=$((_i + 1))
394 [ "$_i" -lt 100 ] || why "the TUI quit but its daemon (pid $D_TUI) is still up 5s later" || return 1
395 sleep 0.05
396 done
397 D_TUI=""
398
399 # ...and the next call against the corpse is a JSON OBJECT, which is the
400 # contract an agent depends on: there is no reply muxa can give that an
401 # agent has to parse as prose, and no path here that panics.
402 timeout 10 "$MUXA" status --sock "$SOCK_TUI" --timeout 2000 >"$TMP/t3" 2>&1
403 _rc=$?
404 [ "$_rc" -ne 0 ] || why "status against a dead daemon exited 0 [$(tr -d '\n' < "$TMP/t3")]" || return 1
405 _err=$(jget "$TMP/t3" error)
406 case "$_err" in
407 "<unparseable>"|"<missing>"|"<not-an-object>")
408 why "no JSON error object after the session ended: [$(tr -d '\n' < "$TMP/t3")]" || return 1 ;;
409 esac
410 # A panic prints a trace and an error message; the grep is what tells the
411 # two apart when the object above happens to parse anyway.
412 ! grep -qi 'panic\|segmentation\|\.zig:[0-9]' "$TMP/t3" ||
413 why "a stack trace, not an error object: [$(cat "$TMP/t3")]" || return 1
414 return 0
415 }
416 run_scenario "ephemeral TUI: alt_screen seen, quit driven, death reported as JSON" scen_tui
417
418 # --- 4: settle, on a shell with no marks -----------------------------------
419 # `settled` and `returned` are BOTH honest here and which one arrives is a
420 # race: --settle 300 accepts 300ms of quiet as the end of the command, and the
421 # pgid probe sees the foreground group go back to the shell at the second the
422 # sleep exits. What must never come back is `timeout` — the client asked a
423 # question a markless session can answer two different ways, and "I don't
424 # know" is not one of them.
425 scen_settle() {
426 start_ready D_SETTLE "$TMP/settle.log" "$SOCK_SETTLE" --sock "$SOCK_SETTLE" --shell /bin/sh || return 1
427
428 timeout 20 "$MUXA" run --sock "$SOCK_SETTLE" --settle 300 --timeout 10000 'sleep 1' >"$TMP/s1" 2>&1
429 _rc=$?
430 [ "$_rc" -eq 0 ] || why "run exited $_rc [$(tr -d '\n' < "$TMP/s1")]" || return 1
431 _reason=$(jget "$TMP/s1" reason)
432 case "$_reason" in
433 '"settled"'|'"returned"') ;;
434 *) why "reason=$_reason, want \"settled\" or \"returned\" [$(tr -d '\n' < "$TMP/s1")]" || return 1 ;;
435 esac
436 # No marks means no exit code, and muxa says so with a null rather than a
437 # zero — an agent must never read "it worked" out of a mechanism that
438 # cannot know.
439 want "$TMP/s1" exit_code null || return 1
440
441 "$MUXD" stop --sock "$SOCK_SETTLE" >/dev/null 2>&1
442 D_SETTLE=""
443 return 0
444 }
445 run_scenario "settle: a markless sleep returns an answer, never a timeout" scen_settle
446
447 # --- The QUIC half ----------------------------------------------------------
448 # Everything below needs a key, a daemon holding a UDP port, and — for the two
449 # tear scenarios — a path this suite can break on purpose WITHOUT root. The
450 # relay is that path: a UDP forwarder in front of the daemon's port, which
451 # muxa dials instead. Tearing is a control file, not a signal and not a kill,
452 # because the flow has to keep being ABSORBED after it breaks: a relay that
453 # died would have the kernel answer with ICMP port-unreachable, and a refusal
454 # is the fast path, not the loss this is modelling.
455 #
456 # The tear blackholes the flow it is told about and keeps forwarding NEW ones.
457 # That is exactly a path that went away and a client that came back on another
458 # one, and it is what makes the heal deterministic: no timing window to hit,
459 # because the redial's fresh source port is never the torn one.
460 cat > "$RELAY" <<'PY'
461 import os, socket, select, sys
462
463 listen_port, target_port, ctl_flow, ctl_all = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3], sys.argv[4]
464 front = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
465 front.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
466 front.bind(("127.0.0.1", listen_port))
467 backs, owner, blocked, block_all = {}, {}, set(), False
468 sys.stderr.write("relay up %d -> %d\n" % (listen_port, target_port)); sys.stderr.flush()
469
470 while True:
471 if os.path.exists(ctl_flow):
472 blocked |= set(backs.keys()); os.remove(ctl_flow)
473 sys.stderr.write("relay: tore %d flow(s)\n" % len(blocked)); sys.stderr.flush()
474 if os.path.exists(ctl_all):
475 block_all = True; os.remove(ctl_all)
476 sys.stderr.write("relay: blackholed everything\n"); sys.stderr.flush()
477 ready, _, _ = select.select([front] + list(backs.values()), [], [], 0.05)
478 for s in ready:
479 if s is front:
480 data, addr = front.recvfrom(65535)
481 if block_all or addr in blocked:
482 continue
483 b = backs.get(addr)
484 if b is None:
485 b = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
486 b.connect(("127.0.0.1", target_port))
487 backs[addr], owner[b.fileno()] = b, addr
488 sys.stderr.write("relay: flow %d\n" % len(backs)); sys.stderr.flush()
489 try:
490 b.send(data)
491 except OSError:
492 pass
493 else:
494 try:
495 data = s.recv(65535)
496 except OSError:
497 continue
498 addr = owner.get(s.fileno())
499 if addr is not None and not block_all and addr not in blocked:
500 front.sendto(data, addr)
501 PY
502
503 # Every way this setup can go wrong is a DEFECT, and every one of them fails
504 # rather than skips. There is no environmental escape hatch left at this point:
505 # python3 and `timeout` were made hard prerequisites at the top of the file, so
506 # what remains — keygen refusing, the key not landing where keygen said it did,
507 # the QUIC daemon not coming up, the relay not binding — is either muxd
508 # misbehaving or this box handing out a port twice. A skip here would be the
509 # one door in the suite wide enough for a real regression to walk through
510 # wearing green: `muxd --quic` breaking outright would have reported "4 passed,
511 # 5 skipped" and exited 0, which is CI saying yes to a broken binary.
512 QUIC_FAIL=""
513 if ! "$MUXD" keygen >"$TMP/keygen.log" 2>&1; then
514 QUIC_FAIL="muxd keygen failed [$(tr -d '\n' < "$TMP/keygen.log")]"
515 elif ! cp "$XDG_CONFIG_HOME/mux/key" "$KEY" 2>/dev/null; then
516 QUIC_FAIL="keygen wrote no key where it said it did [$(tr -d '\n' < "$TMP/keygen.log")]"
517 fi
518
519 if [ -z "$QUIC_FAIL" ]; then
520 # The reduced idle is the schedule the tear scenarios wait for. It is a
521 # transport parameter, so the NEGOTIATED value is the min of the two ends
522 # and this daemon's 4s governs both — muxa has no idle flag of its own,
523 # and waiting out its 15s default twice would be most of this suite's
524 # runtime. The quiet-await scenario below deliberately does not use it.
525 D_TEAR=$(start_daemon "$TMP/tear.log" --sock "$SOCK_TEAR" --shell /bin/bash \
526 --quic "127.0.0.1:$PORT_TEAR" --key "$KEY" --quic-idle-ms 4000)
527 if [ -z "$D_TEAR" ]; then
528 QUIC_FAIL="the QUIC daemon printed no up-line [$(tr -d '\n' < "$TMP/tear.log")]"
529 elif ! wait_ready "$SOCK_TEAR"; then
530 QUIC_FAIL="the QUIC daemon (pid $D_TEAR) never answered on $SOCK_TEAR"
531 fi
532 fi
533 if [ -z "$QUIC_FAIL" ]; then
534 python3 "$RELAY" "$PORT_RELAY" "$PORT_TEAR" "$CTL_FLOW" "$CTL_ALL" >"$RELAY_LOG" 2>&1 &
535 RELAY_PID=$!
536 wait_for "$RELAY_LOG" "relay up" 5 || QUIC_FAIL="the relay never bound $PORT_RELAY [$(tr -d '\n' < "$RELAY_LOG")]"
537 fi
538
539 # The gate every QUIC scenario opens with. One outcome only — there is nothing
540 # left here that a box could legitimately be excused from.
541 quic_gate() {
542 [ -z "$QUIC_FAIL" ] || { WHY="$QUIC_FAIL"; return 1; }
543 return 0
544 }
545
546 # --- 5: a tear mid-await heals, and the command still ran exactly once ------
547 # The two claims an agent's whole reconnect story rests on. The reply that
548 # arrives after the heal carries the ORIGINAL command's return — muxa re-issued
549 # its await from the watermark it already held, so the daemon answered about
550 # the same command rather than starting a new wait — and the command ran once,
551 # which is the no-input-resend rule: a client that re-sent its cmdline on
552 # reconnect would have run it twice, and on anything but `sleep` that is a
553 # second deploy, not a second read.
554 scen_tear_heal() {
555 quic_gate || return $?
556 _tally="$TMP/tally"
557 rm -f "$_tally"
558 # Counted in the FILESYSTEM, not in the grid: a grid count would also see
559 # the echoed command line, and a wrapped row would make it a guess.
560 timeout 40 "$MUXA" run --quic "127.0.0.1:$PORT_RELAY" --key "$KEY" --timeout 25000 \
561 "sleep 12; echo ran >> $_tally" >"$TMP/q1" 2>&1 &
562 CLI_PID=$!
563 # Tear once the await is genuinely in flight — proved by the relay having
564 # opened the flow, not by a sleep that hopes it has.
565 wait_for "$RELAY_LOG" "relay: flow 1" 10 || why "the client never reached the relay [$(cat "$RELAY_LOG")]" || return 1
566 sleep 1
567 : > "$CTL_FLOW"
568 wait_for "$RELAY_LOG" "relay: tore" 5 || why "the relay never acted on the tear [$(cat "$RELAY_LOG")]" || return 1
569
570 wait "$CLI_PID"
571 _rc=$?
572 CLI_PID=""
573 [ "$_rc" -eq 0 ] || why "run exited $_rc after the heal [$(tr -d '\n' < "$TMP/q1")]" || return 1
574 want "$TMP/q1" reason '"returned"' || return 1
575 want "$TMP/q1" mechanism '"marks"' || return 1
576 want "$TMP/q1" exit_code 0 || return 1
577
578 # A second flow through the relay is the reconnect, observed from outside
579 # muxa. Without it the reply could only mean the tear never landed, and
580 # this scenario would be asserting nothing at all.
581 grep -q "relay: flow 2" "$RELAY_LOG" ||
582 why "no second flow: the reply came back without a redial [$(cat "$RELAY_LOG")]" || return 1
583
584 _ran=$(wc -l < "$_tally" 2>/dev/null || echo 0)
585 [ "$_ran" -eq 1 ] || why "the command ran $_ran time(s), want exactly 1" || return 1
586 return 0
587 }
588 run_scenario "quic: a tear mid-await heals, and the command ran exactly once" scen_tear_heal
589
590 # --- 6: a tear with nothing to come back to is fatal, and says so ----------
591 # The reconnect is spent once. When the redial cannot complete, the failure an
592 # agent reads must be the WHOLE story: the wait died because the path tore, and
593 # it stayed dead because the redial could not finish. An agent told only
594 # `Timeout` goes and checks its own command; an agent told `connection lost;
595 # reconnect failed: Timeout` knows to check the network.
596 scen_tear_fatal() {
597 quic_gate || return $?
598 # A hang is the failure mode here, so the deadline is asserted twice: the
599 # outer `timeout` makes one impossible to sit through, and the wall clock
600 # below makes one impossible to pass with.
601 # The flow number this client will be given, read off the log rather than
602 # assumed: a scenario above that failed before its redial would leave a
603 # different count, and a hardcoded 3 would then wait for a flow that never
604 # comes and report THAT as this scenario's failure.
605 _flow=$(( $(grep -c "relay: flow" "$RELAY_LOG") + 1 ))
606 _t0=$(now_ms)
607 timeout 30 "$MUXA" run --quic "127.0.0.1:$PORT_RELAY" --key "$KEY" --timeout 12000 \
608 'sleep 20' >"$TMP/q2" 2>&1 &
609 CLI_PID=$!
610 wait_for "$RELAY_LOG" "relay: flow $_flow" 10 || why "the client never opened a new flow [$(cat "$RELAY_LOG")]" || return 1
611 sleep 1
612 : > "$CTL_ALL"
613 wait_for "$RELAY_LOG" "blackholed" 5 || why "the relay never blackholed [$(cat "$RELAY_LOG")]" || return 1
614
615 wait "$CLI_PID"
616 _rc=$?
617 CLI_PID=""
618 _spent=$(( $(now_ms) - _t0 ))
619 [ "$_rc" -ne 0 ] || why "run exited 0 with the path gone [$(tr -d '\n' < "$TMP/q2")]" || return 1
620 [ "$_rc" -ne 124 ] || why "muxa hung past the outer 30s bound" || return 1
621 # --timeout plus muxa's 2s grace over the daemon's own window, plus room
622 # for the box. Anything near 30s means the deadline was not honoured.
623 [ "$_spent" -lt 20000 ] || why "took ${_spent}ms for a 12000ms timeout" || return 1
624 # The COMPOSED narrative, not merely a prefix of it. muxa has three endings
625 # for a lost connection and only one of them is honest here: this client
626 # redialled and the redial could not complete. A bare `connection lost`
627 # means nothing tried to redial, and `connection lost again, after the one
628 # reconnect` means the redial was already spent — both would be regressions
629 # in this setup, and a `"connection lost"*` glob would pass for either.
630 _detail=$(jget "$TMP/q2" detail)
631 case "$_detail" in
632 '"connection lost; reconnect failed: '*) ;;
633 *) why "detail=$_detail, want '\"connection lost; reconnect failed: ...' [$(tr -d '\n' < "$TMP/q2")]" || return 1 ;;
634 esac
635 return 0
636 }
637 run_scenario "quic: a tear with no path back fails with the whole story" scen_tear_fatal
638
639 # --- 7: a quiet await outlives the idle timeout ----------------------------
640 # A DIFFERENT daemon, with the default 15s idle: the connection has to be kept
641 # alive by keepalives across a wait during which neither end has anything to
642 # say. The failure this pins is a timeout at ~15s reported as a lost
643 # connection — an agent would go looking for a network fault that never
644 # happened, and the honest answer (still running) would have been one field.
645 scen_keepalive() {
646 quic_gate || return $?
647 start_ready D_QUIET "$TMP/quiet.log" "$SOCK_QUIET" --sock "$SOCK_QUIET" --shell /bin/bash \
648 --quic "127.0.0.1:$PORT_QUIET" --key "$KEY" || return 1
649
650 timeout 40 "$MUXA" await --quic "127.0.0.1:$PORT_QUIET" --key "$KEY" --timeout 20000 >"$TMP/q3" 2>&1
651 _rc=$?
652 [ "$_rc" -eq 3 ] || why "await exited $_rc, want 3 [$(tr -d '\n' < "$TMP/q3")]" || return 1
653 want "$TMP/q3" reason '"timeout"' || return 1
654 # The number is the assertion: 15000 would be the idle timeout wearing a
655 # timeout's clothes, and only a duration past it proves the keepalives ran.
656 _dur=$(jget "$TMP/q3" duration_ms)
657 [ "$_dur" -ge 18000 ] 2>/dev/null ||
658 why "duration_ms=$_dur — the wait did not survive the 15s idle timeout" || return 1
659
660 "$MUXD" stop --sock "$SOCK_QUIET" >/dev/null 2>&1
661 D_QUIET=""
662 return 0
663 }
664 run_scenario "quic: a quiet 20s await outlives the 15s idle timeout" scen_keepalive
665
666 # --- 8: the session's death beats the connection's ------------------------
667 # Both ends of this race end the wait, and only one of them is the truth. The
668 # shell exited 5; the connection then closed BECAUSE it did. Reporting the
669 # close is reporting the consequence and losing the cause, and the exit code
670 # is the one thing the agent came for.
671 #
672 # Coupling worth naming: this dials the tear daemon DIRECTLY, but it is the
673 # third scenario to drive that one bash session — 5 and 6 reached it through
674 # the relay. Scenario 6 leaves a `sleep 20` running there whether it passes or
675 # fails: killing the client does not kill what the session was already typed.
676 # What covers it is scenario 7, which spends 20s of its own in between, so the
677 # sleep is long finished before `exit 5` is ever sent. If 7 itself fails fast
678 # that margin narrows, hence the 12s bound rather than a snug one — the queued
679 # `exit 5` still lands, just late. Never a hang either way: the outer `timeout`
680 # is the backstop.
681 #
682 # Not given its own daemon because the session's death IS the assertion — this
683 # scenario destroys what it runs on, so it goes last among the three regardless.
684 scen_session_exit() {
685 quic_gate || return $?
686 timeout 30 "$MUXA" run --quic "127.0.0.1:$PORT_TEAR" --key "$KEY" --timeout 12000 \
687 'exit 5' >"$TMP/q4" 2>&1
688 _rc=$?
689 # An ANSWER, not a failure: the command is over and this is how.
690 [ "$_rc" -eq 0 ] || why "run exited $_rc [$(tr -d '\n' < "$TMP/q4")]" || return 1
691 want "$TMP/q4" reason '"session_ended"' || return 1
692 want "$TMP/q4" exit_code 5 || return 1
693 return 0
694 }
695 run_scenario "quic: a session that exits 5 reports 5, not a lost connection" scen_session_exit
696 # The tear daemon's session is gone with it; drop the pid so cleanup does not
697 # chase one, and let the socket backstop cover the rest.
698 D_TEAR=""
699
700 # --- 9: a destination that swallows still honours --timeout ----------------
701 # Not a refusal: a never-listening port answers with ICMP and muxa fails
702 # instantly, which proves nothing about the deadline. A UDP listener that reads
703 # and never replies makes the HANDSHAKE hang, and the only thing that can end
704 # it is muxa's own clock.
705 scen_blackhole() {
706 quic_gate || return $?
707 # The relay, blackholing from birth: its control file exists before it
708 # starts, so it swallows the first packet it ever sees.
709 : > "$TMP/sink.all"
710 python3 "$RELAY" "$PORT_SINK" "$PORT_TEAR" "$TMP/sink.flow" "$TMP/sink.all" >"$SINK_LOG" 2>&1 &
711 SINK_PID=$!
712 wait_for "$SINK_LOG" "relay up" 5 || why "the sink never bound $PORT_SINK [$(cat "$SINK_LOG")]" || return 1
713
714 _t0=$(now_ms)
715 timeout 20 "$MUXA" status --quic "127.0.0.1:$PORT_SINK" --key "$KEY" --timeout 2000 >"$TMP/q5" 2>&1
716 _rc=$?
717 _spent=$(( $(now_ms) - _t0 ))
718 kill "$SINK_PID" 2>/dev/null
719 SINK_PID=""
720
721 [ "$_rc" -ne 0 ] || why "status exited 0 against a blackhole [$(tr -d '\n' < "$TMP/q5")]" || return 1
722 [ "$_rc" -ne 124 ] || why "muxa hung past the outer 20s bound" || return 1
723 # The ceiling that must NOT be hit is the 15s handshake idle timeout: a
724 # muxa that ignored --timeout would land there, and this bound is under it
725 # by enough that only the flag can explain the number.
726 [ "$_spent" -lt 6000 ] || why "took ${_spent}ms for a 2000ms timeout — the flag was not honoured" || return 1
727 _err=$(jget "$TMP/q5" error)
728 case "$_err" in
729 "<unparseable>"|"<missing>"|"<not-an-object>")
730 why "no JSON error object [$(tr -d '\n' < "$TMP/q5")]" || return 1 ;;
731 esac
732 return 0
733 }
734 run_scenario "quic: a blackholed destination fails on --timeout, not on the idle ceiling" scen_blackhole
735
736 # The count, pinned against a literal for e2e.sh's reason: a scenario that
737 # silently stops running is the failure mode no assertion inside it can catch.
738 TOTAL=$((PASSES + FAILS + SKIPS))
739 if [ "$TOTAL" -ne 9 ]; then
740 echo "agent FAIL: $TOTAL scenarios reported, want 9 — one did not run"
741 FAILS=$((FAILS + 1))
742 fi
743
744 echo "agent: $PASSES passed, $FAILS failed, $SKIPS skipped"
745 [ "$FAILS" -eq 0 ] || exit 1
746 exit 0