a73x

c8264948

refactor: the two CLI modes state their contract in two lines

a73x   2026-08-30 19:53

Commit message
refactor: the two CLI modes state their contract in two lines

38 essays to 2 across muxa.zig and mux_main.zig. The agent surface's exit
codes and the client's -A preflight still say what breaks; the paragraphs
retelling which ptrace tracer flipped a race and which layer a failure
used to surface at are gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XSUFuYHqU9wr4J5NC8EkWV

docscheck.blocks
Old New
@@ -13,8 +13,8 @@ interact.zig 24
13 keymap.zig 1 13 keymap.zig 1
14 layout.zig 8 14 layout.zig 8
15 main.zig 7 15 main.zig 7
16 muxa.zig 22 16 muxa.zig 1
17 mux_main.zig 16 17 mux_main.zig 1
18 mux.zig 2 18 mux.zig 2
19 paint.zig 11 19 paint.zig 11
20 predict.zig 15 20 predict.zig 15
src/cli/mux_main.zig
Old New
@@ -1,18 +1,11 @@
1 //! `mux` with no mode letter — the client. `mux [--sock PATH]` attaches 1 //! `mux` with no mode letter — the client. `--sock PATH` attaches to a local
2 //! to the local daemon; 2 //! daemon, `--via CMD` over CMD's stdio (argv words, exec'd, no shell), and a
3 //! `mux --via CMD` attaches over CMD's stdio instead (any command that 3 //! bare HOST runs the ssh→QUIC handoff.
4 //! exposes a session socket as a byte pipe, e.g. `ssh host mux d proxy`,
5 //! split on whitespace and exec'd — no shell, so no quoting or pipelines);
6 //! `mux HOST` runs the ssh→QUIC handoff — ssh fetches the daemon's QUIC
7 //! coordinates and carries the session only if the QUIC dial does not.
8 //! 4 //!
9 //! Bare `mux` is the WALL: every session every daemon on 5 //! Bare `mux` is the WALL: every session every listed daemon has live. Naming a
10 //! `$XDG_STATE_HOME/mux/hosts` has live. Naming a transport is the same 6 //! transport is the same wall, entered zoomed on that daemon. What lives up
11 //! wall, entered zoomed on that daemon's session (`wall.runAttach`). 7 //! here is argv, the refusals that must happen before a dial, the auto-start
12 //! What lives up here is argv, the refusals that must happen before a dial 8 //! and the `hosts` subcommand — the command line rather than the session.
13 //! (self-attach, an unbindable socket path), the auto-start, and the
14 //! `hosts` subcommand — everything about the command line rather than
15 //! about a session.
16 const std = @import("std"); 9 const std = @import("std");
17 const client = @import("client"); 10 const client = @import("client");
18 const proto = @import("term").protocol; 11 const proto = @import("term").protocol;
@@ -87,12 +80,9 @@ const ParseResult = union(enum) {
87 /// `session` defaults to "" (empty), the wire-compatible name that puts 80 /// `session` defaults to "" (empty), the wire-compatible name that puts
88 /// exactly the old bytes on the wire — see encodeAttachNamed. 81 /// exactly the old bytes on the wire — see encodeAttachNamed.
89 attach: struct { sock: ?[]const u8 = null, via: ?[]const u8 = null, session: []const u8 = "", agent: bool = false }, 82 attach: struct { sock: ?[]const u8 = null, via: ?[]const u8 = null, session: []const u8 = "", agent: bool = false },
90 /// A bare hostname: the ssh recipe is built from it in main, where there 83 /// A bare hostname; the ssh recipe is built from it in main, where there is
91 /// is an allocator to build it with. `idle_ms` rides along because the 84 /// an allocator. `idle_ms` rides along because the handoff ends in a QUIC
92 /// handoff ends in a QUIC link like any other — the hub's HOST tiles 85 /// link like any other, and dropping it makes `--quic-idle-ms` a no-op.
93 /// have always carried it, and mux dropping it on the floor made
94 /// `--quic-idle-ms` silently do nothing on exactly the spelling most
95 /// people use.
96 host: struct { name: []const u8, idle_ms: u32, session: []const u8 = "", agent: bool = false }, 86 host: struct { name: []const u8, idle_ms: u32, session: []const u8 = "", agent: bool = false },
97 /// A direct QUIC attach. The key is resolved in main, where the 87 /// A direct QUIC attach. The key is resolved in main, where the
98 /// environment can be consulted. 88 /// environment can be consulted.
@@ -104,41 +94,24 @@ const ParseResult = union(enum) {
104 /// three are `cliflags.exitFor`'s. 94 /// three are `cliflags.exitFor`'s.
105 const ParseError = cliflags.ParseError || error{Conflict}; 95 const ParseError = cliflags.ParseError || error{Conflict};
106 96
107 /// `SSH_AGENTC_REQUEST_IDENTITIES` in the ssh-agent framing: a 4-byte 97 /// `SSH_AGENTC_REQUEST_IDENTITIES` in the ssh-agent framing. `ssh-add -l` sends
108 /// big-endian length, then the message type. `ssh-add -l` sends exactly 98 /// exactly this, which is why every agent answers it — with a list or a failure,
109 /// this, which is why every agent implementation answers it — with an 99 /// and either is proof of an agent. The one place mux knows any ssh-agent bytes,
110 /// identities list, or a failure if it holds no keys. Either is proof of 100 /// and it belongs to the CLIENT: the forwarding path stays opaque end to end.
111 /// an agent; the preflight never reads past the type.
112 ///
113 /// This is the one place mux knows any ssh-agent bytes, and it belongs
114 /// here rather than in `protocol`, `proxy` or the QUIC modules: the
115 /// forwarding path stays opaque end to end (the daemon pumps blind, by
116 /// design), and what speaks here is the client, which is the agent's own
117 /// peer and the process that made the `-A` promise.
118 const agent_request_identities = [_]u8{ 0, 0, 0, 1, 11 }; 101 const agent_request_identities = [_]u8{ 0, 0, 0, 1, 11 };
119 102
120 /// How long a probe waits for an answer before deciding it cannot tell. 103 /// How long a probe waits before deciding it cannot tell. A refusal is a hangup
121 /// The asymmetry below is what sets it: a refusal is a hangup on an 104 /// on an already-accepted connection and arrives in microseconds, so this is not
122 /// already-accepted connection and arrives in microseconds, so this bound 105 /// asked to separate refused from slow — only to outlast a real agent's round
123 /// is not asked to separate refused from slow — it only has to outlast a 106 /// trip, including one forwarded out of an outer session over a link.
124 /// real agent's round trip, including one forwarded back out of an outer
125 /// session over a link with an RTT.
126 const agent_probe_ms = 500; 107 const agent_probe_ms = 500;
127 108
128 /// Whether an ssh-agent is actually there to forward. A request and a 109 /// Whether an ssh-agent is actually there to forward: a REQUEST and a reply,
129 /// reply, not a dial: inside a mux session `SSH_AUTH_SOCK` names the 110 /// not a dial. Inside a mux session `SSH_AUTH_SOCK` names the daemon's own
130 /// DAEMON's per-session socket, which accepts every connection and only 111 /// socket, which accepts every connection and only then looks for a client — so
131 /// then looks for a client to route it to. A bare connect passes there 112 /// a bare connect passes even when nobody is offering, waving through exactly
132 /// even when nobody is offering — and the client it waves through is 113 /// the silent offerer this refuses. Fails open on silence, closed on a hangup:
133 /// precisely the silent offerer this check exists to refuse, one that can 114 /// a hardware token is slow and is still an agent.
134 /// out-rank a working `-A` client because offering is a declaration and
135 /// not a capability. The nested case where the outer session DOES have an
136 /// answerer is the one that must keep working, and it does: the request is
137 /// forwarded out and the real agent's reply comes back.
138 ///
139 /// Fails open on silence, closed on a hangup. A refusal is immediate, so
140 /// slowness is not the discriminator: a hardware token or a cold-started
141 /// gpg-agent is slow and is still an agent.
142 fn agentReachable(path: []const u8) bool { 115 fn agentReachable(path: []const u8) bool {
143 const fd = client.connectAgent(path) orelse return false; 116 const fd = client.connectAgent(path) orelse return false;
144 defer std.posix.close(fd); 117 defer std.posix.close(fd);
@@ -213,11 +186,9 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseError!ParseR
213 @intFromBool(o.via != null) + o._targets; 186 @intFromBool(o.via != null) + o._targets;
214 if (named > 1) return error.Conflict; 187 if (named > 1) return error.Conflict;
215 188
216 // Rides every transport below, unlike --key: a session name is not 189 // Rides every transport, unlike `--key`: a session name authenticates
217 // authenticating anything, so there is no "no quic:// means ignore it" 190 // nothing, so it applies whichever spelling wins. So does `agent` — an offer
218 // escape hatch — it applies whichever spelling wins. So does `agent`, 191 // to answer for this client's agent is about the client, not the wire.
219 // and for the same reason: an offer to answer for this client's agent is
220 // about the client, not the wire it reached the daemon over.
221 const session = if (o.session) |n| n.name else ""; 192 const session = if (o.session) |n| n.name else "";
222 193
223 if (o._quic) |host_port| { 194 if (o._quic) |host_port| {
@@ -231,11 +202,9 @@ fn parseArgs(args: []const [:0]const u8, env_key: ?[]const u8) ParseError!ParseR
231 .agent = o.agent, 202 .agent = o.agent,
232 } }; 203 } };
233 } 204 }
234 // A key with no quic:// has nothing to authenticate and is ignored 205 // A key with no `quic://` has nothing to authenticate and is IGNORED rather
235 // rather than refused: unlike the daemon, where --key without --quic means a 206 // than refused: it is one env var away from being set for every invocation,
236 // listener was meant, here it is one env var away from being set for 207 // and refusing `mux --sock ...` over an exported MUX_KEY_FILE is absurd.
237 // every invocation in a shell, and refusing `mux --sock ...` because
238 // MUX_KEY_FILE happens to be exported would be absurd.
239 if (o._host) |h| return .{ .host = .{ .name = h, .idle_ms = o.quic_idle_ms.ms, .session = session, .agent = o.agent } }; 208 if (o._host) |h| return .{ .host = .{ .name = h, .idle_ms = o.quic_idle_ms.ms, .session = session, .agent = o.agent } };
240 return .{ .attach = .{ .sock = o.sock, .via = o.via, .session = session, .agent = o.agent } }; 209 return .{ .attach = .{ .sock = o.sock, .via = o.via, .session = session, .agent = o.agent } };
241 } 210 }
@@ -270,13 +239,10 @@ pub fn main(args: []const [:0]const u8) !u8 {
270 else => |pe| return cliflags.exitFor(pe, usage, "mux", build_options.version), 239 else => |pe| return cliflags.exitFor(pe, usage, "mux", build_options.version),
271 }; 240 };
272 241
273 // `-A` is a promise, and a client with no agent behind it cannot keep 242 // `-A` is a promise a client with no agent cannot keep. Left to attach it
274 // one. Left to attach, it offers anyway — the offer is a declaration, 243 // offers anyway — an offer is a declaration, not a capability — so every
275 // not a capability — so every dial the session makes is refused in 244 // dial is refused in silence, and it can out-rank a client that WOULD have
276 // silence, and on a session with a second `-A` client it can out-rank 245 // answered. Refused here, at the altitude the flag was typed at.
277 // one that WOULD have answered. Refusing here says so once, at the
278 // altitude the flag was typed at, instead of surfacing three layers
279 // down as `permission denied (publickey)` from a git remote.
280 const wants_agent = switch (parsed) { 246 const wants_agent = switch (parsed) {
281 .host => |h| h.agent, 247 .host => |h| h.agent,
282 .quic => |q| q.agent, 248 .quic => |q| q.agent,
@@ -324,18 +290,14 @@ pub fn main(args: []const [:0]const u8) !u8 {
324 } }, q.session, q.key, q.idle_ms, q.agent); 290 } }, q.session, q.key, q.idle_ms, q.agent);
325 }, 291 },
326 .host => |h| { 292 .host => |h| {
327 // The handoff recipe: ssh fetches the coordinates (and, on a 293 // The handoff recipe: ssh fetches the coordinates and carries the
328 // cold attach, carries the session if QUIC cannot), while a 294 // session if QUIC cannot, while a warm attach dials from the cache
329 // warm attach dials from the cache and never spawns ssh at all. 295 // and spawns no ssh. The hub builds its HOST tiles from this call.
330 // handoff.recipeFor owns both pieces; the hub builds its HOST
331 // tiles from the same call.
332 const r = try handoff.recipeFor(alloc, h.name, false); 296 const r = try handoff.recipeFor(alloc, h.name, false);
333 defer r.deinit(alloc); 297 defer r.deinit(alloc);
334 // The entry dial: `mux HOST` is the user asking, in person, for 298 // The entry dial: `mux HOST` is the user asking in person for that
335 // that box — the one place a start and a fallback line are owed 299 // box — the one place a start and a fallback line are owed to
336 // to somebody who is sitting there waiting. The other `true` is 300 // somebody sitting there. The picker's Enter is the other `true`.
337 // the picker's Enter, set on a copy in `wall_picker.pickBirth` —
338 // and the resolver makes every caller say which.
339 var target = client.HandoffTarget.fromRecipe(h.name, r, h.idle_ms, true); 301 var target = client.HandoffTarget.fromRecipe(h.name, r, h.idle_ms, true);
340 // The one caller that relays ssh's stderr onward. There is no 302 // The one caller that relays ssh's stderr onward. There is no
341 // wall yet and no alternate screen to corrupt, and the user is 303 // wall yet and no alternate screen to corrupt, and the user is
@@ -377,15 +339,10 @@ fn attachLocal(
377 session: []const u8, 339 session: []const u8,
378 agent: bool, 340 agent: bool,
379 ) !u8 { 341 ) !u8 {
380 // Before the PATH search, before auto-start, before the dial: the 342 // Before the dial: the refusal is about where this process is STANDING, and
381 // refusal is about where this process is standing, and none of those 343 // nothing below changes the answer. Here rather than `parseArgs` because the
382 // three change the answer. Placed here rather than in parseArgs because 344 // default socket path resolves here. The USER's attach only — the chords
383 // the default socket path is resolved here, and a bare `mux` typed in a 345 // grow tiles from inside the wall and never come back through this.
384 // session shell is exactly the mistake this catches. It sits on the
385 // USER's attach only — the Ctrl-\ chords grow their tiles from inside
386 // the wall and never come back through here, so focusing from session 0
387 // to session 1 keeps working. The picker's `a` is the one chord that
388 // takes a spelling, and it runs `wall.showsSelf` itself.
389 if (wall.showsSelf( 346 if (wall.showsSelf(
390 .{ .sock = sock_path }, 347 .{ .sock = sock_path },
391 session, 348 session,
@@ -407,14 +364,10 @@ fn attachLocal(
407 ); 364 );
408 } 365 }
409 366
410 /// Ask the daemon to start itself: `mux d start -d --sock PATH`, this 367 /// Ask the daemon to start itself: `mux d start -d --sock PATH`, this image,
411 /// image, stdio inherited. An ASK, not a fork — the daemon owns its own 368 /// stdio inherited. An ASK, not a fork — the daemon owns its flags, its log and
412 /// flags, its own log and its own refusals, and every one of them reaches 369 /// its refusals, and they reach the user because this process lent it fd 2.
413 /// the user's terminal because this process lent the child its fd 2. 370 /// Bare beyond `--sock`: a listener must be asked for, never appear.
414 ///
415 /// Bare beyond `--sock`, and the reason is the whole of why an attach is
416 /// not a `--quic` start: a listener must be asked for, never appear
417 /// because someone attached.
418 fn startLocalDaemon(alloc: std.mem.Allocator, sock_path: []const u8) !bool { 371 fn startLocalDaemon(alloc: std.mem.Allocator, sock_path: []const u8) !bool {
419 var exe_buf: [std.fs.max_path_bytes]u8 = undefined; 372 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
420 const argv = [_][]const u8{ spawn.selfExe(&exe_buf), "d", "start", "-d", "--sock", sock_path }; 373 const argv = [_][]const u8{ spawn.selfExe(&exe_buf), "d", "start", "-d", "--sock", sock_path };
@@ -444,12 +397,9 @@ comptime {
444 cliflags.assertDocumented(HostsOpts, usage, &.{}); 397 cliflags.assertDocumented(HostsOpts, usage, &.{});
445 } 398 }
446 399
447 /// `mux`: the wall of daemons. An empty file is the local one, so a first 400 /// `mux`: the wall of daemons. An empty file is the local one, so a first run
448 /// run is still just a shell. 401 /// is still just a shell. Resolution allocates into an arena because `wall.run`
449 /// 402 /// never returns on the success path.
450 /// Resolution allocates into an arena because `wall.run` never returns
451 /// on the success path; only the early refusals come back through the
452 /// defers here.
453 fn wallOfHosts(alloc: std.mem.Allocator) !u8 { 403 fn wallOfHosts(alloc: std.mem.Allocator) !u8 {
454 var arena_state = std.heap.ArenaAllocator.init(alloc); 404 var arena_state = std.heap.ArenaAllocator.init(alloc);
455 defer arena_state.deinit(); 405 defer arena_state.deinit();
@@ -466,13 +416,10 @@ fn wallOfHosts(alloc: std.mem.Allocator) !u8 {
466 return attachLocal(alloc, sock_path, "", false); 416 return attachLocal(alloc, sock_path, "", false);
467 } 417 }
468 418
469 // A LISTED local daemon is auto-started too. It dies on every reboot 419 // A LISTED local daemon is auto-started too: it dies on every reboot while
470 // while its line lives on, and the wall shows live sessions only — so 420 // its line lives on, and the wall shows live sessions only — so its owner
471 // a user whose only host is their own stopped daemon opens on an 421 // opens on an entirely empty wall. Failure is not a refusal; the poller
472 // ENTIRELY empty wall until they find some other shell to start one 422 // keeps redialling, so a daemon started elsewhere shows up.
473 // in. That is the empty-file case with one line in front of it.
474 // Failure is not a refusal: the wall still opens and that host's
475 // poller keeps redialling, so a daemon started elsewhere shows up.
476 if (sockpath.defaultSockPath(arena) catch null) |sock| { 423 if (sockpath.defaultSockPath(arena) catch null) |sock| {
477 if (localNeedsStart(&h, sock)) _ = try startLocalDaemon(alloc, sock); 424 if (localNeedsStart(&h, sock)) _ = try startLocalDaemon(alloc, sock);
478 } 425 }
@@ -555,11 +502,10 @@ fn hostsList(arena: std.mem.Allocator, path: []const u8, out_fd: std.posix.fd_t)
555 502
556 /// One listing row: the file's line verbatim, then a formatted verdict. 503 /// One listing row: the file's line verbatim, then a formatted verdict.
557 fn printRow(fd: std.posix.fd_t, line: []const u8, comptime fmt: []const u8, args: anytype) void { 504 fn printRow(fd: std.posix.fd_t, line: []const u8, comptime fmt: []const u8, args: anytype) void {
558 // The line is written straight through rather than formatted into a 505 // The line is written straight through rather than formatted into a buffer
559 // buffer with the verdict. `hosts.loadLines` accepts lines up to a MiB 506 // with the verdict: `loadLines` accepts lines up to a MiB and `hosts rm`
560 // and `mux hosts rm` matches byte for byte, so a row that overflowed a 507 // matches byte for byte, so an overflowed row vanishes from the one command
561 // fixed buffer used to vanish from the one command whose job is showing 508 // whose job is showing the user a line to type back.
562 // the user a line they then have to type back.
563 proto.writeAllFd(fd, line) catch return; 509 proto.writeAllFd(fd, line) catch return;
564 printOut(fd, fmt, args); 510 printOut(fd, fmt, args);
565 } 511 }
@@ -708,11 +654,9 @@ fn hostsAdd(arena: std.mem.Allocator, spellings: []const []const u8, path: []con
708 std.debug.print("mux hosts add: {s}: {s}\n", .{ s, hosts.reason(err) }); 654 std.debug.print("mux hosts add: {s}: {s}\n", .{ s, hosts.reason(err) });
709 return 2; 655 return 2;
710 }; 656 };
711 // The one refusal that belongs to the transport rather than the 657 // The one refusal that belongs to the transport rather than the grammar:
712 // grammar: a sun_path that cannot be bound is a host that could 658 // a sun_path that cannot be bound is a host that could never dial, and
713 // never dial, and add time is the only moment the user is still 659 // add time is the only moment the user still sees what they typed.
714 // looking at what they typed. Asked of the owner, in the owner's
715 // words — the bound is the kernel's and lives in one place.
716 if (spec == .sock and sockpath.tooLong("mux hosts add", spec.sock)) return 2; 660 if (spec == .sock and sockpath.tooLong("mux hosts add", spec.sock)) return 2;
717 } 661 }
718 // Strict: growing a file whose existing content is not understood would 662 // Strict: growing a file whose existing content is not understood would
@@ -1213,11 +1157,9 @@ test "agentReachable: an agent answers; a socket that hangs up is not one" {
1213 try std.testing.expect(agentReachable(sock)); 1157 try std.testing.expect(agentReachable(sock));
1214 } 1158 }
1215 1159
1216 // The stale case, and the reason this dials at all rather than reading 1160 // The stale case, and why this DIALS rather than reading the variable: the
1217 // the variable: the agent is gone but its socket FILE is still there, 1161 // agent is gone but its socket file is still there, so the path stats fine
1218 // so the path stats fine and the connect is refused. `deinit` closed 1162 // and the connect is refused — what a killed agent leaves behind.
1219 // the listener above without unlinking, which is exactly what a killed
1220 // agent leaves behind.
1221 try std.fs.accessAbsolute(sock, .{}); 1163 try std.fs.accessAbsolute(sock, .{});
1222 try std.testing.expect(!agentReachable(sock)); 1164 try std.testing.expect(!agentReachable(sock));
1223 1165
src/cli/muxa.zig
Old New
@@ -1,19 +1,11 @@
1 //! `mux a`: the agent-facing mode. Every verb prints one JSON object on 1 //! `mux a`: the agent-facing mode. Every verb prints one JSON object on stdout;
2 //! stdout and exits 0 on success; failures print {"error": "..."} and exit 2 //! failures print `{"error": "..."}` and exit nonzero. Attaches at 0x0 always —
3 //! nonzero. Attaches at 0x0 always — an agent must never claim the grid 3 //! an agent must never claim the grid out from under the human's size.
4 //! out from under the human's size (load-bearing spec rule).
5 //! 4 //!
6 //! The exit codes, and there are only these five: 5 //! Five exit codes: 0 the answer, 1 an error object, 2 argv did not parse,
7 //! 6 //! 3 the wait timed out, 4 the object could not be written at all. A command's
8 //! 0 the object on stdout is the answer 7 //! own code is never this mode's — it is the `exit_code` FIELD, so a `run`
9 //! 1 the object on stdout is an `{"error":...,"detail":...}` 8 //! whose command failed still exits 0 because the question was answered.
10 //! 2 the arguments did not parse — usage on stderr, nothing on stdout
11 //! 3 the wait timed out; the object says `"reason":"timeout"`
12 //! 4 the object could not be written at all (see `emit`)
13 //!
14 //! A command's own exit code is never this mode's: it is the `exit_code` field,
15 //! and a `run` whose command failed still exits 0 because the question was
16 //! answered.
17 const std = @import("std"); 9 const std = @import("std");
18 const proto = @import("term").protocol; 10 const proto = @import("term").protocol;
19 const sockpath = @import("sockpath"); 11 const sockpath = @import("sockpath");
@@ -299,12 +291,10 @@ test "parseArgs: --quic and --key, and the pairs that make no sense" {
299 try std.testing.expectEqual(@as(?[]const u8, null), (try parseArgs(&neither)).quic); 291 try std.testing.expectEqual(@as(?[]const u8, null), (try parseArgs(&neither)).quic);
300 } 292 }
301 293
302 /// A live QUIC connection plus everything a REDIAL of it needs. The dial 294 /// A live QUIC connection plus everything a REDIAL of it needs. The
303 /// coordinates are kept rather than re-derived because the reconnect below 295 /// coordinates are kept rather than re-derived: a reconnect happens mid-verb,
304 /// happens mid-verb, long after argv and the key file have been read: a 296 /// and a second resolution could pick a rotated key and fail the handshake for
305 /// second resolution could pick a different key (the file having been 297 /// a reason that has nothing to do with why the first connection died.
306 /// rotated under us) and would then fail the handshake for a reason that
307 /// has nothing to do with why the first connection died.
308 const Quic = struct { 298 const Quic = struct {
309 cl: *quic.Client, 299 cl: *quic.Client,
310 addr: std.net.Address, 300 addr: std.net.Address,
@@ -314,12 +304,9 @@ const Quic = struct {
314 /// client's only measurement of how far away the daemon is. `graceMs` 304 /// client's only measurement of how far away the daemon is. `graceMs`
315 /// turns it into the await grace window; see there. 305 /// turns it into the await grace window; see there.
316 connect_ms: i64, 306 connect_ms: i64,
317 /// The one reconnect, spent or not. It lives HERE rather than on Conn 307 /// The one reconnect, spent or not. Here rather than on `Conn` because only
318 /// because only this arm can reconnect: a socket Conn carrying a 308 /// this arm can reconnect: a socket `Conn` carrying the flag would have no
319 /// `reconnected` flag would be a field with no reachable true, and the 309 /// reachable true, and the guard would restate in code what the type says.
320 /// guard reading it would be re-establishing in code what the type can
321 /// state outright. (`session_exit` stays on Conn for the mirror
322 /// reason: both arms genuinely set it.)
323 reconnected: bool = false, 310 reconnected: bool = false,
324 }; 311 };
325 312
@@ -329,36 +316,23 @@ const Conn = struct {
329 fd: std.posix.fd_t, 316 fd: std.posix.fd_t,
330 quic: Quic, 317 quic: Quic,
331 }, 318 },
332 /// The allocator the transport itself works with: the QUIC arm's frame 319 /// The allocator the transport works with: the QUIC arm's frame staging and
333 /// staging and its redials. Distinct from the `alloc` awaitFrame takes, 320 /// its redials. Distinct in the signature from the one `awaitFrame` takes,
334 /// which owns the frame handed BACK to the caller — one process, one 321 /// which owns the frame handed BACK — different owners, same arena today.
335 /// arena, so they are the same allocator today and separate in the
336 /// signature because they answer to different owners.
337 alloc: std.mem.Allocator, 322 alloc: std.mem.Allocator,
338 /// Whether a snapshot has arrived since the last attach, which is the 323 /// Whether a snapshot has arrived since the last attach — the ONLY thing on
339 /// ONLY thing on the wire that tells a refused attach from a session 324 /// the wire that tells a refused attach from a session that ended, since the
340 /// that ended: the daemon spells both as `exit_status 1` and closes 325 /// daemon spells both as `exit_status 1` and closes. A served attach always
341 /// (server.zig's attach arm refuses a 0x0 join of a name it does not 326 /// sends the snapshot first, so an `exit_status` before one is a refusal.
342 /// hold). A served attach always sends the snapshot first, so an
343 /// `exit_status` before one cannot be an ending. Same rule the CLI wall
344 /// reads (wallview.zig's `state_since_attach`), and no wire change.
345 saw_snapshot: bool = false, 327 saw_snapshot: bool = false,
346 /// The code from the `exit_status` frame that ended a wait, set the 328 /// The code from the `exit_status` frame that ended a wait. That frame is
347 /// moment awaitFrame returns error.SessionExited. The frame is the 329 /// the session's last word and carries the only copy of the code, so it is
348 /// session's last word and carries the only copy of the code, so it is 330 /// captured here rather than thrown away with the frame.
349 /// captured here rather than thrown away with the frame; callers read
350 /// it to turn the error into an answer.
351 session_exit: ?u8 = null, 331 session_exit: ?u8 = null,
352 /// Why the reconnect could not be made, set the moment a redial fails 332 /// Why the reconnect could not be made. The error that ends the verb is
353 /// — and set for the same reason `session_exit` is: the error that 333 /// `ConnectionLost`, which is the story's beginning; THIS is how it
354 /// ends the verb is `ConnectionLost`, which is the story's beginning, 334 /// finished. An agent told only `QuicHandshakeFailed` goes and checks its
355 /// while THIS is how it finished. An agent told only 335 /// key. An `@errorName`, so it borrows a static string and owns no storage.
356 /// `QuicHandshakeFailed` goes and checks its key; an agent told
357 /// `connection lost; reconnect failed: QuicHandshakeFailed` knows the
358 /// path tore mid-wait and the redial could not complete.
359 ///
360 /// An `@errorName`, so this borrows a static string and owns no
361 /// storage. See `waitFailDetail`, which composes the line.
362 reconnect_failure: ?[]const u8 = null, 336 reconnect_failure: ?[]const u8 = null,
363 337
364 fn open(alloc: std.mem.Allocator, sock_path: []const u8) !Conn { 338 fn open(alloc: std.mem.Allocator, sock_path: []const u8) !Conn {
@@ -608,14 +582,10 @@ test "graceMs: flat over a socket, RTT-derived over QUIC, and capped" {
608 try std.testing.expectEqual(@as(i64, 30_000), far.graceMs()); 582 try std.testing.expectEqual(@as(i64, 30_000), far.graceMs());
609 } 583 }
610 584
611 /// Drive a fresh connection until it can carry bytes, or give up. 585 /// Drive a fresh connection until it can carry bytes, or give up. A refused
612 /// 586 /// port ends this early, so the common mistake costs milliseconds; a blackholed
613 /// A refused port ends this early — quic.Client turns the ICMP unreachable 587 /// one produces no error at all and the deadline is the only thing that ends
614 /// into `dead` — so the common mistake (no daemon on that port) costs 588 /// it. Even `--timeout 0` terminates, on the connection's own idle timeout.
615 /// milliseconds. A blackholed one produces no error at all, and there the
616 /// deadline is the only thing that ends the wait; even an unbounded one
617 /// (`--timeout 0`) terminates, because the connection's own idle timeout
618 /// kills it after `idle_ms`.
619 fn waitReady(cl: *quic.Client, deadline_ms: i64) !void { 589 fn waitReady(cl: *quic.Client, deadline_ms: i64) !void {
620 while (true) { 590 while (true) {
621 cl.pump(); 591 cl.pump();
@@ -766,13 +736,9 @@ test "awaitFrame ends a wait on exit_status, keeping the code" {
766 736
767 test "an exit_status before any snapshot is a refused attach, not a session that ended" { 737 test "an exit_status before any snapshot is a refused attach, not a session that ended" {
768 const alloc = std.testing.allocator; 738 const alloc = std.testing.allocator;
769 // The daemon's whole vocabulary for "no": resolveSession refuses a 0x0 739 // The daemon's whole vocabulary for "no": a refused 0x0 attach is
770 // attach of a name it does not hold by sending exit_status 1 and 740 // `exit_status 1` and a close, byte-identical to a shell's real exit. The
771 // closing (server.zig's attach arm), which is byte-identical to a 741 // SNAPSHOT tells them apart, since a served attach always sends one first.
772 // shell's real exit. The snapshot is what tells them apart — a served
773 // attach always sends one first, so nothing before one can be an
774 // ending. Reporting this as "session ended" told an agent a shell had
775 // run and failed when no shell ever existed.
776 const pipe = try std.posix.pipe(); 742 const pipe = try std.posix.pipe();
777 defer std.posix.close(pipe[0]); 743 defer std.posix.close(pipe[0]);
778 defer std.posix.close(pipe[1]); 744 defer std.posix.close(pipe[1]);
@@ -787,11 +753,9 @@ test "an exit_status before any snapshot is a refused attach, not a session that
787 753
788 test "a re-attach forgets the snapshot it saw, so a refused reconnect is not an ending" { 754 test "a re-attach forgets the snapshot it saw, so a refused reconnect is not an ending" {
789 const alloc = std.testing.allocator; 755 const alloc = std.testing.allocator;
790 // A socketpair, not a pipe: this conn has to WRITE (the attach) as well 756 // A socketpair, not a pipe: this conn has to WRITE the attach as well as
791 // as read down the ONE fd it holds, which is the whole shape under test 757 // read down the ONE fd it holds, which is the shape under test. Through
792 // — awaitReissuing attaches a second time on a connection that has 758 // `std.os.linux` because `std.posix` has no socketpair on 0.15.2.
793 // already been served. Through std.os.linux because std.posix has no
794 // socketpair on 0.15.2 (server.zig's connectedPair says the same).
795 var sp: [2]i32 = undefined; 759 var sp: [2]i32 = undefined;
796 try std.testing.expectEqual( 760 try std.testing.expectEqual(
797 @as(usize, 0), 761 @as(usize, 0),
@@ -823,13 +787,10 @@ test "a re-attach forgets the snapshot it saw, so a refused reconnect is not an
823 787
824 test "a refusal that closes the socket before the input write is still reported as the refusal" { 788 test "a refusal that closes the socket before the input write is still reported as the refusal" {
825 const alloc = std.testing.allocator; 789 const alloc = std.testing.allocator;
826 // The refusal is `exit_status` + close (server.zig's attach arm), and 790 // The refusal is `exit_status` + close, and only timing makes our next
827 // nothing makes our next write lose to that close except timing: the 791 // write lose to that close: the socket buffer usually takes the bytes first.
828 // socket buffer usually takes the bytes first, so the refusal is read 792 // Under a ptrace tracer the close wins every time, and BrokenPipe is
829 // where it is expected. Under a ptrace tracer (kcov) the close wins 793 // reported INSTEAD — a daemon's "no such session" as a transport failure.
830 // every time, and the write's BrokenPipe was reported INSTEAD of the
831 // refusal — a daemon saying "no such session" spelled as a transport
832 // failure.
833 var sp: [2]i32 = undefined; 794 var sp: [2]i32 = undefined;
834 try std.testing.expectEqual( 795 try std.testing.expectEqual(
835 @as(usize, 0), 796 @as(usize, 0),
@@ -1018,11 +979,9 @@ fn failSessionEnded(code: ?u8) u8 {
1018 return emit(fbs.getWritten(), 1); 979 return emit(fbs.getWritten(), 1);
1019 } 980 }
1020 981
1021 /// `detail` is here because every other failure has one: an agent that 982 /// `detail` is here because every other failure has one: an agent reading
1022 /// reads `.detail` on any exit-1 must never meet a missing key, and one 983 /// `.detail` on any exit-1 must never meet a missing key. `exit_code` is the
1023 /// verb quietly dropping it is exactly the shape a driver hits in the 984 /// machine field; the detail says the same in the other failures' prose.
1024 /// field and not in a test. `exit_code` is the machine field; the detail
1025 /// says the same thing in the prose the other failures use.
1026 fn writeSessionEndedError(writer: anytype, code: ?u8) !void { 985 fn writeSessionEndedError(writer: anytype, code: ?u8) !void {
1027 try writer.writeAll("{\"error\":\"session ended\",\"detail\":"); 986 try writer.writeAll("{\"error\":\"session ended\",\"detail\":");
1028 if (code) |c| { 987 if (code) |c| {
@@ -1057,13 +1016,9 @@ fn failSend(e: anyerror, name: []const u8, ask: Refused, who: []const u8, msg: [
1057 } 1016 }
1058 1017
1059 /// An attach's `exit_status 1` before a snapshot has two producers and the 1018 /// An attach's `exit_status 1` before a snapshot has two producers and the
1060 /// frame does not say which: `resolveSession` refused the name (absent, 1019 /// frame does not say which: the name was refused, or no client slot was free.
1061 /// since a 0x0 attach cannot create, or unspelled), or `freeClientSlot` had 1020 /// Naming only absence would print "no session 0" at a daemon that HOLDS
1062 /// no seat left. Naming only absence would print "no session 0" at a daemon 1021 /// session 0 and is merely full. A name too long to fit still leaves a detail.
1063 /// that holds session 0 and is merely full — so the attach detail names
1064 /// both, and the query detail names only the one that can reach it. A name
1065 /// too long to fit still leaves a detail: losing the whole reply to a long
1066 /// argv is worse than losing the name from it.
1067 fn refusedDetail(buf: *[768]u8, name: []const u8, ask: Refused) []const u8 { 1022 fn refusedDetail(buf: *[768]u8, name: []const u8, ask: Refused) []const u8 {
1068 const why, const need = switch (ask) { 1023 const why, const need = switch (ask) {
1069 .attach => .{ 1024 .attach => .{
@@ -1110,11 +1065,9 @@ pub fn main(args: []const [:0]const u8) !u8 {
1110 // JSON object per invocation, argument errors included. 1065 // JSON object per invocation, argument errors included.
1111 const o = parseArgs(args) catch |e| return cliflags.exitFor(e, usage, "mux", build_options.version); 1066 const o = parseArgs(args) catch |e| return cliflags.exitFor(e, usage, "mux", build_options.version);
1112 1067
1113 // Started BEFORE the connect, not after: over QUIC the handshake is 1068 // Started BEFORE the connect: over QUIC the handshake is part of the round
1114 // part of the round trip the caller bounded, and a `--timeout` that 1069 // trip the caller bounded, and a `--timeout` that began once the connection
1115 // began counting only once the connection was up would promise 1070 // was up would promise different things on the two transports.
1116 // something different on the two transports. Over a unix socket the
1117 // connect is a syscall, so this moves the instant by microseconds.
1118 const deadline = deadlineFor(o.timeout); 1071 const deadline = deadlineFor(o.timeout);
1119 1072
1120 if (o.quic) |host_port| { 1073 if (o.quic) |host_port| {
@@ -1180,11 +1133,9 @@ fn openQuicConn(
1180 host_port: []const u8, 1133 host_port: []const u8,
1181 deadline: i64, 1134 deadline: i64,
1182 ) Opened { 1135 ) Opened {
1183 // `--key`, then `$MUX_KEY_FILE`, then the XDG default if it exists. 1136 // `--key`, then `$MUX_KEY_FILE`, then the XDG default if it exists. The
1184 // The order is not spelled here on purpose: xdg owns it, mux reads the 1137 // order is not spelled here on purpose: xdg owns it and mux reads the same
1185 // same two functions, and a copy that drifted is how two binaries end 1138 // two functions, since a drifted copy authenticates with a different key.
1186 // up authenticating with different keys. (the daemon's pickKey stands apart
1187 // deliberately: it takes the default only when the file exists.)
1188 const res = xdg.resolveKeyPath(alloc, xdg.pickKey(o.key, std.posix.getenv(xdg.key_env))) catch |e| 1139 const res = xdg.resolveKeyPath(alloc, xdg.pickKey(o.key, std.posix.getenv(xdg.key_env))) catch |e|
1189 return .{ .exit = fail("quic: cannot resolve a key path", @errorName(e)) }; 1140 return .{ .exit = fail("quic: cannot resolve a key path", @errorName(e)) };
1190 const key_path = switch (res) { 1141 const key_path = switch (res) {
@@ -1251,12 +1202,9 @@ fn writeExitCode(writer: anytype, code: ?u8) !void {
1251 } 1202 }
1252 } 1203 }
1253 1204
1254 /// The five CmdState fields `status` and `await`/`run` both publish. 1205 /// The five `CmdState` fields `status` and `await`/`run` both publish. A bare
1255 /// Written as a bare fragment — no braces, no leading or trailing 1206 /// fragment — no braces, no commas — because the two verbs nest it differently:
1256 /// comma — because the two verbs nest it differently: `status` puts 1207 /// `status` puts it inside a `"cmd"` object, `await` inlines it at the top.
1257 /// it inside a `"cmd"` object and follows it with the seq, while
1258 /// `await` inlines it at the top level and follows it with the
1259 /// duration.
1260 fn writeCmdFields(writer: anytype, st: proto.CmdState) !void { 1208 fn writeCmdFields(writer: anytype, st: proto.CmdState) !void {
1261 try writer.writeAll("\"phase\":"); 1209 try writer.writeAll("\"phase\":");
1262 try jsonEscape(writer, @tagName(st.phase)); 1210 try jsonEscape(writer, @tagName(st.phase));
@@ -1352,23 +1300,13 @@ fn verbSend(alloc: std.mem.Allocator, conn: *Conn, arg: ?[]const u8, session: []
1352 conn.sendFrame(.input, bytes, deadline) catch |e| 1300 conn.sendFrame(.input, bytes, deadline) catch |e|
1353 return failSend(e, session, .attach, "send", "input failed"); 1301 return failSend(e, session, .attach, "send", "input failed");
1354 1302
1355 // Write-and-close LOSES the input, and not as a rare race: attaching 1303 // Write-and-close LOSES the input, and not rarely: the daemon flushes a
1356 // queues a snapshot, and the daemon flushes a client's pending bytes 1304 // client's pending bytes BEFORE it reads that client, so closing straight
1357 // BEFORE it reads that client (server.zig's poll arm). Closing straight 1305 // after the write means the flush hits EPIPE and the input frame is
1358 // after the write means the flush hits EPIPE, the daemon drops us, and 1306 // discarded unread. The round trip IS the acknowledgement — frames are
1359 // the input frame is discarded still unread. Measured: closing at once 1307 // served in stream order, so a `status_reply` proves the daemon read past
1360 // never lands, while any delay or drain always does. 1308 // the input. The same `session` as the attach, not "": the daemon answers
1361 // 1309 // only a tail that names the slot's own session.
1362 // So the round trip is the acknowledgement. Frames are served in stream
1363 // order, so a status_reply is proof the daemon has already read PAST the
1364 // input frame and fed it to the pty; awaitFrame skips the snapshot and
1365 // the pushes on the way, which is what keeps the socket drained enough
1366 // for that flush to succeed. Nothing is done with the reply — its
1367 // arrival is the whole content.
1368 //
1369 // The same `session` as the attach above, not "" — the daemon's
1370 // attached-tail rule (server.zig) answers only a tail that names the
1371 // slot's own session, and this connection attached to `session`.
1372 conn.sendFrame(.status_req, session, deadline) catch |e| 1310 conn.sendFrame(.status_req, session, deadline) catch |e|
1373 return failSend(e, session, .attach, "send", "ack request failed"); 1311 return failSend(e, session, .attach, "send", "ack request failed");
1374 const ack = conn.awaitFrame(.status_reply, deadline) catch |e| switch (e) { 1312 const ack = conn.awaitFrame(.status_reply, deadline) catch |e| switch (e) {
@@ -1387,14 +1325,10 @@ fn verbSend(alloc: std.mem.Allocator, conn: *Conn, arg: ?[]const u8, session: []
1387 return emit("{\"sent\":true}\n", 0); 1325 return emit("{\"sent\":true}\n", 0);
1388 } 1326 }
1389 1327
1390 /// How much longer than the daemon this client is willing to wait. 1328 /// How much longer than the daemon this client is willing to wait. The daemon
1391 /// 1329 /// starts its own window when it READS the `await_req`, later than this process
1392 /// Load-bearing: the daemon starts its own `timeout_ms` window when it 1330 /// started counting — so waiting exactly `timeout_ms` loses that race every
1393 /// READS the await_req, which is already later than the instant this 1331 /// time, and every timeout surfaces as "no reply" instead of exit 3.
1394 /// process started counting. Waiting exactly `timeout_ms` here would lose
1395 /// that race every single time, and every timeout would surface as
1396 /// `{"error":"await: no reply"}` instead of the structured
1397 /// `{"reason":"timeout"}` with exit 3 that the agent is meant to read.
1398 const await_grace_ms = 2_000; 1332 const await_grace_ms = 2_000;
1399 1333
1400 /// The ceiling on the QUIC arm's derived grace (Conn.graceMs), and the 1334 /// The ceiling on the QUIC arm's derived grace (Conn.graceMs), and the
@@ -1402,11 +1336,10 @@ const await_grace_ms = 2_000;
1402 /// multiplying by four. 1336 /// multiplying by four.
1403 const grace_cap_ms = 30_000; 1337 const grace_cap_ms = 30_000;
1404 1338
1405 /// How long `sendFrameQuic` will keep offering a frame's tail to a full 1339 /// How long `sendFrameQuic` keeps offering a frame's tail to a full egress ring.
1406 /// egress ring before giving up on it. Reaching this means the peer has 1340 /// Reaching it means the peer stopped acknowledging 256KB of backlog — a dead
1407 /// stopped acknowledging 256KB of backlog, which is a dead connection 1341 /// connection in a different hat — but the bound is what keeps this call, which
1408 /// wearing a different hat — but a bound is what keeps it from being an 1342 /// has no deadline of its own, from waiting forever.
1409 /// unbounded wait inside a call that has no deadline of its own.
1410 const send_flush_ms = 5_000; 1343 const send_flush_ms = 5_000;
1411 1344
1412 /// How long a write that died of a closed peer will look for the refusal 1345 /// How long a write that died of a closed peer will look for the refusal
@@ -1503,15 +1436,10 @@ fn awaitReissuing(
1503 }; 1436 };
1504 } 1437 }
1505 1438
1506 /// What a wait that ended without a reply says past the verb's own 1439 /// What a wait that ended without a reply says past the verb's own "no reply".
1507 /// "no reply". Every error but one is its own name — the socket 1440 /// Every error but one is its own name, because `ConnectionLost` is the only one
1508 /// arm's failures are untouched — because `ConnectionLost` is the 1441 /// whose name is half the story: the redial failed, the redial was already
1509 /// only one whose name is half the story. 1442 /// spent, or nothing tried to redial.
1510 ///
1511 /// The three endings a lost connection has, and they are worth
1512 /// telling apart: the redial failed (why), the redial had already
1513 /// been spent (so this is the second tear of the same wait), or
1514 /// nothing tried to redial.
1515 fn waitFailDetail(buf: []u8, conn: *const Conn, e: anyerror) []const u8 { 1443 fn waitFailDetail(buf: []u8, conn: *const Conn, e: anyerror) []const u8 {
1516 if (e != error.ConnectionLost) return @errorName(e); 1444 if (e != error.ConnectionLost) return @errorName(e);
1517 if (conn.reconnect_failure) |why| { 1445 if (conn.reconnect_failure) |why| {
@@ -1539,13 +1467,10 @@ fn currentSeq(alloc: std.mem.Allocator, conn: *Conn, session: []const u8, deadli
1539 return s.cmd.seq; 1467 return s.cmd.seq;
1540 } 1468 }
1541 1469
1542 /// Strip the styling out of scrollback rows: an agent reading `output` 1470 /// Strip the styling out of scrollback rows: an agent reading `output` wants
1543 /// wants what the command printed, not how it was coloured. 1471 /// what the command printed, not how it was coloured. CSI and OSC go, as does
1544 /// 1472 /// any other two-byte escape. Deliberately not a VT parser — these rows come
1545 /// CSI (ESC [ … final byte) and OSC (ESC ] … BEL or ST) go, as does any 1473 /// from our own formatter, which emits SGR and nothing more exotic.
1546 /// other two-byte escape; text and newlines stay. Deliberately not a VT
1547 /// parser — these rows come from our own formatter, which emits SGR and
1548 /// nothing more exotic. Caller frees.
1549 fn stripSgr(alloc: std.mem.Allocator, s: []const u8) ![]u8 { 1474 fn stripSgr(alloc: std.mem.Allocator, s: []const u8) ![]u8 {
1550 var out: std.ArrayList(u8) = .empty; 1475 var out: std.ArrayList(u8) = .empty;
1551 errdefer out.deinit(alloc); 1476 errdefer out.deinit(alloc);
@@ -1707,11 +1632,9 @@ fn awaitVerb(
1707 attachZero(conn, o.sessionName(), deadline) catch |e| 1632 attachZero(conn, o.sessionName(), deadline) catch |e|
1708 return failSend(e, o.sessionName(), .attach, who, "attach failed"); 1633 return failSend(e, o.sessionName(), .attach, who, "attach failed");
1709 1634
1710 // BEFORE the input, not after: the watermark has to be the one this 1635 // BEFORE the input: the watermark has to be the one this command must beat.
1711 // command must beat. Read afterwards, a command fast enough to return 1636 // Read afterwards, a command fast enough to return between the two moves the
1712 // between the two would have already moved the seq past a value we 1637 // seq past a value we never recorded, and the await waits for a past return.
1713 // never recorded, and the await would sit waiting for a return that
1714 // had happened.
1715 const since = currentSeq(alloc, conn, o.sessionName(), deadline) catch |e| switch (e) { 1638 const since = currentSeq(alloc, conn, o.sessionName(), deadline) catch |e| switch (e) {
1716 error.AttachRefused => return failAttachRefused(o.sessionName(), .attach), 1639 error.AttachRefused => return failAttachRefused(o.sessionName(), .attach),
1717 error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)), 1640 error.SessionExited => return reportSessionEnded(alloc, conn.session_exit, elapsed(started)),
@@ -1719,11 +1642,9 @@ fn awaitVerb(
1719 }; 1642 };
1720 1643
1721 if (cmdline) |cmd| { 1644 if (cmdline) |cmd| {
1722 // The cmdline goes to the pty verbatim — escapes are `send`'s 1645 // The cmdline goes to the pty verbatim, plus the newline that submits
1723 // business — plus the newline that submits it. No ack round-trip is 1646 // it. No ack round trip: the `await_req` that follows is itself the read
1724 // needed the way `send` needs one: the await_req that follows is 1647 // proving the daemon got past this frame.
1725 // itself the read that proves the daemon got past this frame, and
1726 // this process stays connected until the reply lands.
1727 const line = std.fmt.allocPrint(alloc, "{s}\n", .{cmd}) catch |e| 1648 const line = std.fmt.allocPrint(alloc, "{s}\n", .{cmd}) catch |e|
1728 return failAs(who, "cannot build the command line", @errorName(e)); 1649 return failAs(who, "cannot build the command line", @errorName(e));
1729 defer alloc.free(line); 1650 defer alloc.free(line);