4e6962dd
Merge branch 'feat/agent-surface'
a73x 2026-08-14 05:04
Commit message
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 | ||