c8264948
refactor: the two CLI modes state their contract in two lines
a73x 2026-08-30 19:53
Commit message
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); |