76fd930e
refactor: the link layer's docs name the failure, not the incident
a73x 2026-08-30 19:40
Commit message
docscheck.blocks
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,7 @@ | |||
| 1 | askpass.zig 10 | 1 | askpass.zig 10 |
| 2 | client_core_wasm_check.zig 0 | 2 | client_core_wasm_check.zig 0 |
| 3 | client_core.zig 1 | 3 | client_core.zig 1 |
| 4 | client.zig 56 | 4 | client.zig 15 |
| 5 | cmd.zig 2 | 5 | cmd.zig 2 |
| 6 | delta.zig 8 | 6 | delta.zig 8 |
| 7 | docscheck.zig 4 | 7 | docscheck.zig 4 |
src/client/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,17 +1,11 @@ | |||
| 1 | //! The LINK, and nothing above it. | 1 | //! The LINK, and nothing above it: how a client reaches a daemon and what it |
| 2 | //! says on arrival. The `Target` grammar, the dial (unix socket, `--via` pipe, | ||
| 3 | //! direct QUIC, the ssh→QUIC handoff), the words a failed dial exits with, the | ||
| 4 | //! attach frame, the reconnect backoff, and the pure answers a chord needs. | ||
| 2 | //! | 5 | //! |
| 3 | //! How a client reaches a daemon and what it says on arrival: the `Target` | 6 | //! Not a SESSION. There is ONE interaction loop and it is wallview.zig's tile |
| 4 | //! grammar, the dial (unix socket, `--via` pipe, direct QUIC, the ssh→QUIC | 7 | //! pump. What crosses the seam is `Transport` — opened by whoever holds the |
| 5 | //! handoff), the vocabulary a dial that never came up exits with, the attach | 8 | //! tty, `adopt`ed by the thread that will own it — and nothing else. |
| 6 | //! frame, the reconnect backoff, the wall-file record an attach earns, and | ||
| 7 | //! the pure answers a `Ctrl-\ c`/`n`/`p` needs out of a `sessions_reply`. | ||
| 8 | //! | ||
| 9 | //! Not a SESSION. There is ONE interaction loop and it is wallview.zig's | ||
| 10 | //! tile pump, driving the entry tile exactly as every other; a converged | ||
| 11 | //! binary with a second copy of that loop linked here is not converged. What | ||
| 12 | //! crosses the seam is `Transport` — opened by whoever holds the tty, | ||
| 13 | //! `adopt`ed by the thread that will own it — plus the pure helpers the pump | ||
| 14 | //! asks for by name. Everything at the terminal is interact.zig's. | ||
| 15 | const std = @import("std"); | 9 | const std = @import("std"); |
| 16 | const Replica = @import("term").replica.Replica; | 10 | const Replica = @import("term").replica.Replica; |
| 17 | const proto = @import("term").protocol; | 11 | const proto = @import("term").protocol; |
| @@ -59,13 +53,9 @@ pub fn validPick(pick: ?[]const u8) ?SessionName { | |||
| 59 | return .of(name); | 53 | return .of(name); |
| 60 | } | 54 | } |
| 61 | 55 | ||
| 62 | /// The name `Ctrl-\ c` creates: the lowest non-negative integer that is not | 56 | /// The name `Ctrl-\ c` creates: the lowest non-negative integer not already a |
| 63 | /// already a session name. `list` is a `sessions_reply` payload — names | 57 | /// session name, out of a `sessions_reply` payload. Numbering rather than |
| 64 | /// separated by '\n'. Names that are not integers (`work`, `dev`) are | 58 | /// `new-1` because the default session is already called "0". |
| 65 | /// simply names that do not match a candidate, so they narrow nothing. | ||
| 66 | /// | ||
| 67 | /// Numbering rather than `new-1`, `new-2` because the default session is | ||
| 68 | /// already called "0": the series a user sees stays one series. | ||
| 69 | pub fn nextFreeName(out: *[proto.session_name_max]u8, list: []const u8) []const u8 { | 59 | pub fn nextFreeName(out: *[proto.session_name_max]u8, list: []const u8) []const u8 { |
| 70 | // Each existing name can rule out at most one candidate, so the first | 60 | // Each existing name can rule out at most one candidate, so the first |
| 71 | // free number is somewhere in 0..count — no cap constant needed, and in | 61 | // free number is somewhere in 0..count — no cap constant needed, and in |
| @@ -89,24 +79,16 @@ pub fn nextFreeName(out: *[proto.session_name_max]u8, list: []const u8) []const | |||
| 89 | } | 79 | } |
| 90 | 80 | ||
| 91 | /// Which chord is waiting for the daemon to answer. One field rather than a | 81 | /// Which chord is waiting for the daemon to answer. One field rather than a |
| 92 | /// flag per chord: a client can only have one question outstanding, so the | 82 | /// flag per chord, so "one question outstanding" is a fact and not a rule. |
| 93 | /// enum makes that a fact instead of a rule every new chord must obey. | 83 | /// They share the wait because they share the deadline: each is a verb an |
| 94 | /// | 84 | /// older daemon simply does not hear. |
| 95 | /// `.new` waits on a `sessions_reply`; `.end`/`.end_force` on an | ||
| 96 | /// `end_reply`. They share the wait because they share the deadline: each | ||
| 97 | /// is a verb an older daemon simply does not hear. | ||
| 98 | pub const SwitchIntent = enum { none, new, end, end_force }; | 85 | pub const SwitchIntent = enum { none, new, end, end_force }; |
| 99 | 86 | ||
| 100 | /// A chord that has asked the daemon for its session list, and the moment | 87 | /// A chord that has asked the daemon for its session list, and when it stops |
| 101 | /// it stops waiting for the answer. | 88 | /// waiting. The DEADLINE is why this is a struct: a daemon older than |
| 102 | /// | 89 | /// `sessions_req` drops frames it does not recognise, so the question is never |
| 103 | /// The deadline is the whole reason this is a struct rather than the bare | 90 | /// heard — and an intent with no expiry stays armed, making every later chord |
| 104 | /// enum. A daemon older than `sessions_req` (0x0c) drops frames it does not | 91 | /// a silent no-op the user blames on their keyboard. |
| 105 | /// recognise — `else => {}` in its dispatch — so the question is not | ||
| 106 | /// refused, it is never heard. An intent with no expiry then stays armed | ||
| 107 | /// for the rest of the session: every `Ctrl-\ c/n/p` after it is a silent | ||
| 108 | /// no-op, and the user is given no reason to suspect the daemon rather than | ||
| 109 | /// their keyboard. | ||
| 110 | pub const PendingSwitch = struct { | 92 | pub const PendingSwitch = struct { |
| 111 | /// How long a chord waits. Far longer than any round trip a switch is | 93 | /// How long a chord waits. Far longer than any round trip a switch is |
| 112 | /// usable over, and short enough that the keystroke is still in the | 94 | /// usable over, and short enough that the keystroke is still in the |
| @@ -186,11 +168,9 @@ pub const QuicTarget = struct { | |||
| 186 | deadline_ms: u32 = handoff.deadline_ms, | 168 | deadline_ms: u32 = handoff.deadline_ms, |
| 187 | }; | 169 | }; |
| 188 | 170 | ||
| 189 | /// The bare-HOST recipe: everything a (re)connect needs to run the | 171 | /// The bare-HOST recipe: everything a (re)connect needs to run the ssh→QUIC |
| 190 | /// ssh→QUIC handoff again. It lives at the Transport layer, not in | 172 | /// handoff again. At the Transport layer so the reconnect loop re-runs the |
| 191 | /// mux_main, precisely so the reconnect loop re-runs the WHOLE flow — a | 173 | /// WHOLE flow — a daemon restart invalidates the cached port. |
| 192 | /// daemon restart invalidates the cached port, and only a fresh ssh fetch | ||
| 193 | /// can heal that. | ||
| 194 | pub const AskPass = struct { sock: []const u8, exe: []const u8 }; | 174 | pub const AskPass = struct { sock: []const u8, exe: []const u8 }; |
| 195 | 175 | ||
| 196 | pub const HandoffTarget = struct { | 176 | pub const HandoffTarget = struct { |
| @@ -214,61 +194,36 @@ pub const HandoffTarget = struct { | |||
| 214 | deadline_ms: u32 = handoff.deadline_ms, | 194 | deadline_ms: u32 = handoff.deadline_ms, |
| 215 | idle_ms: u32 = quic_idle_ms_default, | 195 | idle_ms: u32 = quic_idle_ms_default, |
| 216 | /// Whether a USER asked for this dial: the entry attach and a picker | 196 | /// Whether a USER asked for this dial: the entry attach and a picker |
| 217 | /// birth, never a poll, a reconnect or a restored tile. The two things | 197 | /// birth, never a poll, a reconnect or a restored tile. It decides two |
| 218 | /// `asked` decides: | 198 | /// things. |
| 219 | /// | 199 | /// |
| 220 | /// SAY SO when the session falls back to ssh. The line reports a | 200 | /// SAY SO when the session falls back to ssh — but only once, because a |
| 221 | /// choice the user has not seen made, and a reconnect re-runs this | 201 | /// reconnect re-runs this recipe forever and a line per retry would scroll |
| 222 | /// recipe forever against dropped UDP: one line per retry would scroll | 202 | /// a live session's stderr onto the alternate screen. |
| 223 | /// a live session's stderr into the alternate screen and corrupt the | ||
| 224 | /// paint, to say what the [reconnecting] banner is already saying. | ||
| 225 | /// | 203 | /// |
| 226 | /// PICK THE ARGV — `asked_argv` rather than `ssh_argv`, unless there is | 204 | /// PICK THE ARGV: `asked_argv` rather than `ssh_argv`, unless there is |
| 227 | /// no `asked_argv`, which is a target nothing can start from. The two | 205 | /// none. The two are one ssh line a flag apart, so the far end decides |
| 228 | /// are the same ssh line one flag apart, so the far end decides whether | 206 | /// whether to start — and a poll's bare word cannot undo a `mux d stop`. |
| 229 | /// to start and answers in the same run; a wall polls every listed host | ||
| 230 | /// once a second, and its bare word cannot start anything to undo a | ||
| 231 | /// `mux d stop` with. | ||
| 232 | /// | 207 | /// |
| 233 | /// The trade, stated plainly: a session that degrades to ssh mid-life | 208 | /// Defaults to the harmless half: a missing start is a `mux HOST` that |
| 234 | /// says nothing at all about it. The user who wants to know can detach | 209 | /// says so, a spurious one is a daemon on someone else's box. |
| 235 | /// and reattach, which reports it on the next attach; the alternative | ||
| 236 | /// is noise on every backoff cycle for as long as the link stays bad. | ||
| 237 | /// | ||
| 238 | /// Defaults to the harmless half: a dial path that forgets the field | ||
| 239 | /// gets it wrong SILENTLY either way, and of the two silences a missing | ||
| 240 | /// start is a `mux HOST` that says so, while a spurious one is a daemon | ||
| 241 | /// on someone else's box that nothing reports at all. | ||
| 242 | asked: bool = false, | 210 | asked: bool = false, |
| 243 | /// Whether the caller is a human at a bare prompt who is owed ssh's | 211 | /// Whether the caller is a human at a bare prompt owed ssh's narration as |
| 244 | /// narration as it happens: relay every stderr byte to mux's own fd 2. | 212 | /// it happens: relay every stderr byte to mux's own fd 2. The remote's |
| 245 | /// The remote's `mux d endpoint: starting\u{2026}` progress, a dot per | 213 | /// progress rides that stderr, and the entry dial's user is sitting |
| 246 | /// interval and an up-line, ride that stderr, as do ssh's own | 214 | /// through the wait it describes. |
| 247 | /// diagnostics, and the entry dial's user is sitting through the wait | ||
| 248 | /// they describe. | ||
| 249 | /// | ||
| 250 | /// Nobody else relays. The bytes are READ either way — that is | ||
| 251 | /// `Reason`, and it is what a picker row quotes — but under a wall's | ||
| 252 | /// alternate screen they would sit over tiles and rails, where the | ||
| 253 | /// tile's own `connecting` label is the narration. | ||
| 254 | /// | 215 | /// |
| 255 | /// Defaults to the harmless half, the way `asked` does: a dial path | 216 | /// Nobody else relays. The bytes are READ either way — that is `Reason` — |
| 256 | /// that forgets this field is silent, and of the two silences a | 217 | /// but under a wall's alternate screen they would sit over tiles and |
| 257 | /// missing line is a wait that says nothing while a spurious one | 218 | /// rails. Defaults to the harmless half, as `asked` does: a spurious line |
| 258 | /// corrupts a paint nobody can repair from. | 219 | /// corrupts a paint nobody can repair from. |
| 259 | narrate: bool = false, | 220 | narrate: bool = false, |
| 260 | /// Where this dial's ssh sends its prompts, and what carries them. | 221 | /// Where this dial's ssh sends its prompts, and what carries them. Null is |
| 261 | /// | 222 | /// "ssh keeps its own": the entry dial's user has a /dev/tty right there, |
| 262 | /// Null is "ssh keeps its own", which is the OLD behaviour and a | 223 | /// and a poll spells `BatchMode` and asks nothing. A wall dial that forgets |
| 263 | /// visible one: the entry dial's user is at a bare prompt with a | 224 | /// these reads /dev/tty under the alternate screen — a wait nobody can see. |
| 264 | /// /dev/tty right there, and a poll spells `BatchMode` and asks | 225 | /// Two fields because `spawn.selfExe` is under `src/cli/`, which a client |
| 265 | /// nothing. A wall dial that forgets these two reads /dev/tty under | 226 | /// module may not name; the wall is tui and fills both in one place. |
| 266 | /// the alternate screen — a wait nobody can see, which is loud in the | ||
| 267 | /// way a wrong default has to be. | ||
| 268 | /// | ||
| 269 | /// Two fields because `spawn.selfExe` lives under `src/cli/`, which a | ||
| 270 | /// client module may not name (folder rule 1). The wall is tui and may, | ||
| 271 | /// so it fills both, together, in one place. | ||
| 272 | ask_sock: ?[]const u8 = null, | 227 | ask_sock: ?[]const u8 = null, |
| 273 | ask_exe: []const u8 = "", | 228 | ask_exe: []const u8 = "", |
| 274 | 229 | ||
| @@ -305,23 +260,15 @@ pub const Target = union(enum) { | |||
| 305 | quic: QuicTarget, | 260 | quic: QuicTarget, |
| 306 | hand: HandoffTarget, | 261 | hand: HandoffTarget, |
| 307 | 262 | ||
| 308 | /// The one road from a spelling's `hosts.Spec` to the dial it names: | 263 | /// The one road from a spelling's `hosts.Spec` to the dial it names. Every |
| 309 | /// the wall's host lines and the hub's tiles resolve here, and the | 264 | /// slice is OWNED by `alloc`: the spelling a caller parsed may be a scratch |
| 310 | /// CLI's `.host` arm shares `fromRecipe`. What can fail is | 265 | /// buffer, and a target outlives the read that made it. `asked` is |
| 311 | /// `SpecError`; the word for it is each door's own. | 266 | /// required, never defaulted — see `HandoffTarget.fromRecipe`. |
| 312 | /// | ||
| 313 | /// Every slice is OWNED by `alloc` — the spelling a caller parsed may | ||
| 314 | /// be a scratch buffer, and a target outlives the read that made it. | ||
| 315 | /// | ||
| 316 | /// `asked` is required, never defaulted: see `HandoffTarget.fromRecipe`. | ||
| 317 | pub fn fromSpec(alloc: std.mem.Allocator, spec: hosts.Spec, key: ?[]const u8, idle_ms: u32, asked: bool) SpecError!Target { | 267 | pub fn fromSpec(alloc: std.mem.Allocator, spec: hosts.Spec, key: ?[]const u8, idle_ms: u32, asked: bool) SpecError!Target { |
| 318 | return switch (spec) { | 268 | return switch (spec) { |
| 319 | // No length guard here, deliberately: `Address.initUnix` | 269 | // No length guard: `Address.initUnix` answers `NameTooLong` and |
| 320 | // answers `NameTooLong` and truncates nothing, so a doomed | 270 | // truncates nothing, so a doomed path fails as a dial like any |
| 321 | // path fails as a dial like any other and the picker row | 271 | // other. The one BINDER is where a path is refused by name. |
| 322 | // quotes the kernel's own word. The one binder is where a | ||
| 323 | // path is refused by name, because it is the only place a | ||
| 324 | // refusal saves anything. | ||
| 325 | .sock => |path| .{ .sock = try alloc.dupe(u8, path) }, | 272 | .sock => |path| .{ .sock = try alloc.dupe(u8, path) }, |
| 326 | .host => |h| blk: { | 273 | .host => |h| blk: { |
| 327 | const hd = try alloc.dupe(u8, h); | 274 | const hd = try alloc.dupe(u8, h); |
| @@ -398,11 +345,9 @@ pub const Transport = struct { | |||
| 398 | /// a tile shows `connecting`, and the row quotes the POLL's copy — so | 345 | /// a tile shows `connecting`, and the row quotes the POLL's copy — so |
| 399 | /// a reader that appears is free to define what it means. | 346 | /// a reader that appears is free to define what it means. |
| 400 | reason: handoff.Reason = .{}, | 347 | reason: handoff.Reason = .{}, |
| 401 | /// `HandoffTarget.narrate`, carried past the dial so a link that came | 348 | /// `HandoffTarget.narrate`, carried past the dial so a link that came up on |
| 402 | /// up on the pipe goes on relaying. The entry dial CLEARS it when the | 349 | /// the pipe goes on relaying. The entry dial CLEARS it when the wall takes |
| 403 | /// wall takes the screen (`wallview.runAttach`): past that point fd 2 | 350 | /// the screen: past that, fd 2 is the alternate screen. |
| 404 | /// is the alternate screen, and a late `Connection to box closed by | ||
| 405 | /// remote host.` would be exactly the foreign writer this removed. | ||
| 406 | narrate: bool = false, | 351 | narrate: bool = false, |
| 407 | 352 | ||
| 408 | /// The handoff's coordination ssh, stderr included. `ask` non-null is | 353 | /// The handoff's coordination ssh, stderr included. `ask` non-null is |
| @@ -424,11 +369,8 @@ pub const Transport = struct { | |||
| 424 | ask: ?AskPass, | 369 | ask: ?AskPass, |
| 425 | ) !std.process.Child { | 370 | ) !std.process.Child { |
| 426 | // The product runs `ssh` and the user's own `--via` program, and | 371 | // The product runs `ssh` and the user's own `--via` program, and |
| 427 | // neither is worth a shell's expansions between us and it. | 372 | // neither is worth a shell's expansions between us and it. `argv` need |
| 428 | // | 373 | // not outlive the call: `std.process.Child` copies it before the fork. |
| 429 | // `argv` need not outlive the call: `std.process.Child` copies it | ||
| 430 | // into its own arena before the fork. "spawnPipe: the child is | ||
| 431 | // exec'd from a copy" is that claim, asserted. | ||
| 432 | var child = std.process.Child.init(argv, alloc); | 374 | var child = std.process.Child.init(argv, alloc); |
| 433 | child.stdin_behavior = .Pipe; | 375 | child.stdin_behavior = .Pipe; |
| 434 | child.stdout_behavior = .Pipe; | 376 | child.stdout_behavior = .Pipe; |
| @@ -508,11 +450,9 @@ pub const Transport = struct { | |||
| 508 | /// waits: stdin in the CLI, -1 (no abort channel) in a hub that | 450 | /// waits: stdin in the CLI, -1 (no abort channel) in a hub that |
| 509 | /// has no terminal — its stray fd 0 must never be read. | 451 | /// has no terminal — its stray fd 0 must never be read. |
| 510 | abort_fd: std.posix.fd_t, | 452 | abort_fd: std.posix.fd_t, |
| 511 | /// What the handoff leaves behind: ssh's last line, so a dial that | 453 | /// What the handoff leaves behind: ssh's last line, so a failed dial is |
| 512 | /// failed is reported in ssh's own words, and the pid of the ssh | 454 | /// reported in ssh's own words, and the pid that said it, so a prompt |
| 513 | /// that said it, so a prompt that ssh raised can be answered — and | 455 | /// can be answered against the one dial it belongs to. |
| 514 | /// a refusal remembered — against the one dial it belongs to. Null | ||
| 515 | /// is every caller that has nowhere to show either. | ||
| 516 | dial: ?*handoff.Dial, | 456 | dial: ?*handoff.Dial, |
| 517 | ) !Transport { | 457 | ) !Transport { |
| 518 | switch (target) { | 458 | switch (target) { |
| @@ -601,69 +541,42 @@ pub const Transport = struct { | |||
| 601 | } | 541 | } |
| 602 | }, | 542 | }, |
| 603 | .spawn_ssh => blk: { | 543 | .spawn_ssh => blk: { |
| 604 | // ONE run, and `asked` is the whole of what picks it. The | 544 | // ONE run, picked by `asked` alone: the asking word ensures |
| 605 | // asking word ensures a daemon on the far side and | 545 | // a daemon and announces on the same stdout, so there is no |
| 606 | // announces on the same stdout, so there is no refusal | 546 | // refusal to read and no second run. `and len > 0` keeps |
| 607 | // for this side to read, no exit code to tell from ssh's | 547 | // `asked_argv`'s doc true — an empty argv is not a no-op at |
| 608 | // own 255, and no second run. A read spells the bare | 548 | // the exec, the child null-unwraps `argv[0]` and dies. |
| 609 | // word, which starts nothing — the rule holds by argv | ||
| 610 | // rather than by a branch. | ||
| 611 | // | ||
| 612 | // `and len > 0` is what keeps `asked_argv`'s doc true: | ||
| 613 | // empty means "nothing to start", and an empty argv is | ||
| 614 | // not a no-op at the exec — the forked child null-unwraps | ||
| 615 | // argv[0] and dies, which arrives here as an announce | ||
| 616 | // that never came. Falling back to the reading word makes | ||
| 617 | // such a target a dial that starts nothing, which is what | ||
| 618 | // the field says it is. | ||
| 619 | const argv = if (h.asked and h.asked_argv.len > 0) h.asked_argv else h.ssh_argv; | 549 | const argv = if (h.asked and h.asked_argv.len > 0) h.asked_argv else h.ssh_argv; |
| 620 | child = spawnPipe(alloc, argv, h.askpassFor()) catch |err| { | 550 | child = spawnPipe(alloc, argv, h.askpassFor()) catch |err| { |
| 621 | last_err = err; | 551 | last_err = err; |
| 622 | break :blk .failed; | 552 | break :blk .failed; |
| 623 | }; | 553 | }; |
| 624 | // Taken OFF the child: `Child.kill` closes `stderr` | 554 | // Taken OFF the child: `Child.kill` closes `stderr` with the |
| 625 | // with the other pipes, and this fd has to outlive the | 555 | // other pipes, and this fd outlives the kill on `use_pipe`. |
| 626 | // kill on the `use_pipe` path, where the session runs | 556 | // `if` rather than an unwrap, so a spawn that stopped piping |
| 627 | // on and ssh goes on talking. | 557 | // stderr FAILS the test named for it instead of panicking. |
| 628 | // | ||
| 629 | // `if` rather than an unwrap `spawnPipe` makes safe: a | ||
| 630 | // spawn that stopped piping stderr would panic here, | ||
| 631 | // and the test named for the pipe would never get to | ||
| 632 | // say so. Leaving the fd at -1 fails that test instead. | ||
| 633 | if (child.?.stderr) |f| { | 558 | if (child.?.stderr) |f| { |
| 634 | errp.fd = f.handle; | 559 | errp.fd = f.handle; |
| 635 | child.?.stderr = null; | 560 | child.?.stderr = null; |
| 636 | } | 561 | } |
| 637 | // Recorded HERE rather than on the way out, because the | 562 | // Recorded HERE, not on the way out: the caller that needs it |
| 638 | // caller that needs it is the one this call is about to | 563 | // is the one this call is about to fail, and a refused |
| 639 | // fail: a dial whose ssh asked for a password the user | 564 | // password leaves no transport to read a pid off. |
| 640 | // refused has no transport to read a pid off, and the | ||
| 641 | // refusal is keyed on exactly this number. | ||
| 642 | out.ssh_pid = child.?.id; | 565 | out.ssh_pid = child.?.id; |
| 643 | break :blk .ok; | 566 | break :blk .ok; |
| 644 | }, | 567 | }, |
| 645 | .read_announce => blk: { | 568 | .read_announce => blk: { |
| 646 | // Bounded by nothing but ssh itself — the same trust | 569 | // ssh owns the terminal while it runs: its prompts read |
| 647 | // `--via` already extends to the command it spawns. | 570 | // /dev/tty, and a competing stdin reader here steals whole |
| 648 | // | 571 | // cooked lines — auth fails on an empty password and the |
| 649 | // ssh owns the terminal while it runs: password and | 572 | // stolen line replays INTO the session via carry. On a first |
| 650 | // host-key prompts read /dev/tty, and a competing stdin | 573 | // attach the tty is still cooked, so Ctrl-C aborts the group; |
| 651 | // reader here steals whole cooked lines from them — auth | 574 | // the byte-read abort is only real on reconnect. |
| 652 | // fails on an empty password, and the stolen line replays | ||
| 653 | // INTO THE SESSION via carry. On a first attach | ||
| 654 | // (carry != null) the tty is still cooked, so Ctrl-C's | ||
| 655 | // SIGINT already aborts this whole foreground group; the | ||
| 656 | // byte-read abort is only real on reconnect, where raw | ||
| 657 | // mode is live and carry is null by policy. Unconsumed | ||
| 658 | // type-ahead is not lost: it waits in the kernel's tty | ||
| 659 | // buffer for the session's first read. | ||
| 660 | const announce_abort_fd: std.posix.fd_t = if (carry != null) -1 else abort_fd; | 575 | const announce_abort_fd: std.posix.fd_t = if (carry != null) -1 else abort_fd; |
| 661 | const got = readAnnounceAbortable(child.?.stdout.?.handle, alloc, null, announce_abort_fd, &errp) catch |err| { | 576 | const got = readAnnounceAbortable(child.?.stdout.?.handle, alloc, null, announce_abort_fd, &errp) catch |err| { |
| 662 | // ssh says why on its way out, and its stdout's | 577 | // ssh says why on its way out, and its stdout's EOF can |
| 663 | // EOF can be the same poll pass as the last of it. | 578 | // be the same poll pass as the last of it: read what is |
| 664 | // Read what is left BEFORE this error becomes the | 579 | // left BEFORE this error becomes the caller's answer. |
| 665 | // caller's answer, or the reason is the sentence | ||
| 666 | // ssh printed one syscall too late. | ||
| 667 | errp.drainReady(); | 580 | errp.drainReady(); |
| 668 | last_err = err; | 581 | last_err = err; |
| 669 | break :blk .announce_failed; | 582 | break :blk .announce_failed; |
| @@ -671,20 +584,11 @@ pub const Transport = struct { | |||
| 671 | break :blk if (got) |ep| .{ .announced = ep } else .none; | 584 | break :blk if (got) |ep| .{ .announced = ep } else .none; |
| 672 | }, | 585 | }, |
| 673 | .write_cache => |ep| blk: { | 586 | .write_cache => |ep| blk: { |
| 674 | // A cache write that fails costs a cold attach next time | 587 | // A failed cache write costs one cold attach and nothing |
| 675 | // and nothing else, so it is not worth a line of the | 588 | // else. Serialized because two tiles naming one host share a |
| 676 | // user's attention. | 589 | // cache path and `writeCache` truncates in place: unserialized |
| 677 | // | 590 | // writers tear the line, and the likelier miss is the loser |
| 678 | // Serialized because openHandoff is no longer | 591 | // writing the STALER endpoint. |
| 679 | // single-threaded: the web hub runs one of these per | ||
| 680 | // tile, and two tiles naming the same host share a cache | ||
| 681 | // path. writeCache truncates in place — there is no | ||
| 682 | // rename — so unserialized writers can tear the line. A | ||
| 683 | // torn one costs an attach, not a host: readCache refuses | ||
| 684 | // it and the next cold path rewrites it. The likelier | ||
| 685 | // miss is the loser writing the STALER endpoint, a cold | ||
| 686 | // attach that looks like a bug — and a mutex is cheaper | ||
| 687 | // than the afternoon. | ||
| 688 | cache_write_mu.lock(); | 592 | cache_write_mu.lock(); |
| 689 | defer cache_write_mu.unlock(); | 593 | defer cache_write_mu.unlock(); |
| 690 | handoff.writeCache(h.cache_path.?, ep) catch {}; | 594 | handoff.writeCache(h.cache_path.?, ep) catch {}; |
| @@ -850,13 +754,10 @@ pub const Transport = struct { | |||
| 850 | } | 754 | } |
| 851 | } | 755 | } |
| 852 | 756 | ||
| 853 | /// Idempotent, and it has to be: a re-dial releases the dead transport | 757 | /// Idempotent, and it has to be: a re-dial releases the dead transport on |
| 854 | /// on entry, and if the user then aborts, the pump's `defer | 758 | /// entry, and an abort then closes the same value again through the pump's |
| 855 | /// transport.close()` closes the very same value again. A second | 759 | /// `defer`. A second `close(2)` on a stale fd is EBADF, which `std.posix` |
| 856 | /// close(2) on a stale fd is EBADF, which std.posix maps to | 760 | /// maps to `unreachable` — a panic that `--sock` does not hide. |
| 857 | /// `unreachable` — a panic, not an error. `--via` hides that (killing a | ||
| 858 | /// reaped child is harmless), `--sock` does not, and `--sock` is exactly | ||
| 859 | /// the locally-killed-daemon case. | ||
| 860 | pub fn close(self: *Transport) void { | 761 | pub fn close(self: *Transport) void { |
| 861 | if (self.conn.r == -1) return; // already released | 762 | if (self.conn.r == -1) return; // already released |
| 862 | defer self.conn = .{ .r = -1, .w = -1 }; | 763 | defer self.conn = .{ .r = -1, .w = -1 }; |
| @@ -922,17 +823,10 @@ fn waitReady( | |||
| 922 | cl.pump(); | 823 | cl.pump(); |
| 923 | if (cl.isReady()) return; | 824 | if (cl.isReady()) return; |
| 924 | if (cl.dead) return error.QuicHandshakeFailed; | 825 | if (cl.dead) return error.QuicHandshakeFailed; |
| 925 | // The abort fd (stdin, in the CLI) is watched alongside the | 826 | // The abort fd is watched alongside the socket, and it has to be: this |
| 926 | // socket, and it has to be. This wait runs INSIDE Transport.open, | 827 | // wait runs INSIDE `Transport.open`, where nothing else is looking for |
| 927 | // where for its whole length nothing else is looking for the | 828 | // the abort key, and a re-dial's terminal is raw so `Ctrl-\` is the |
| 928 | // abort key — and during a re-dial the terminal is in raw mode, so | 829 | // only way out. The uncapped retry loop is justified by that key. |
| 929 | // Ctrl-C is just a byte and Ctrl-\ is the only way out. Watching only | ||
| 930 | // the socket left the user with no way to stop for as long as the | ||
| 931 | // handshake bound allows: measured at 14.6s on the default idle | ||
| 932 | // timeout, against an endpoint that was never going to answer. The | ||
| 933 | // uncapped retry loop is justified by the user having an abort key, | ||
| 934 | // so an abort key that stops working mid-handshake takes the | ||
| 935 | // justification with it. | ||
| 936 | var fds = abortPoll(cl.pollFd(), abort_fd, watch_stdin); | 830 | var fds = abortPoll(cl.pollFd(), abort_fd, watch_stdin); |
| 937 | _ = std.posix.poll(&fds, cl.timeoutMs(50)) catch break; | 831 | _ = std.posix.poll(&fds, cl.timeoutMs(50)) catch break; |
| 938 | // On a first attach the bytes drained here are the user's first | 832 | // On a first attach the bytes drained here are the user's first |
| @@ -954,11 +848,10 @@ fn abortPoll(main_fd: std.posix.fd_t, abort_fd: std.posix.fd_t, watching: bool) | |||
| 954 | }; | 848 | }; |
| 955 | } | 849 | } |
| 956 | 850 | ||
| 957 | /// Drain the abort fd; false means stop watching it, because a closed one | 851 | /// Drain the abort fd; false means stop watching it, since a closed one stays |
| 958 | /// stays readable forever and the caller's wait would spin hot. Bytes that | 852 | /// readable forever and the wait would spin hot. Non-abort bytes are the |
| 959 | /// are not the abort key are the caller's policy: `carry` keeps them, null | 853 | /// caller's policy: `carry` keeps them, null drops them, and a reconnect |
| 960 | /// drops them — a reconnect drops, since replaying a burst of stale | 854 | /// drops — replaying stale keystrokes on resume is worse than losing them. |
| 961 | /// keystrokes on resume is worse than losing them. | ||
| 962 | fn drainAbortFd(abort_fd: std.posix.fd_t, alloc: std.mem.Allocator, carry: ?*std.ArrayList(u8)) error{UserAbort}!bool { | 855 | fn drainAbortFd(abort_fd: std.posix.fd_t, alloc: std.mem.Allocator, carry: ?*std.ArrayList(u8)) error{UserAbort}!bool { |
| 963 | var buf: [1024]u8 = undefined; | 856 | var buf: [1024]u8 = undefined; |
| 964 | const n = std.posix.read(abort_fd, &buf) catch 0; | 857 | const n = std.posix.read(abort_fd, &buf) catch 0; |
| @@ -1002,12 +895,9 @@ const ErrPipe = struct { | |||
| 1002 | 895 | ||
| 1003 | /// Everything the pipe holds NOW, without waiting for more. | 896 | /// Everything the pipe holds NOW, without waiting for more. |
| 1004 | fn drainReady(self: *ErrPipe) void { | 897 | fn drainReady(self: *ErrPipe) void { |
| 1005 | // BYTES, not reads: the bound wants to be "everything a writer | 898 | // BYTES, not reads: the bound is "everything a dead writer can have |
| 1006 | // that has already died can have left behind", which is the pipe's | 899 | // left behind", which is the pipe's capacity and not a syscall count. A |
| 1007 | // capacity and nothing to do with how many syscalls it takes. A | 900 | // reads-shaped cap left the reason a line from the middle. |
| 1008 | // reads-shaped cap read a fixed 8k, so a remote whose ssh logs at | ||
| 1009 | // DEBUG3 and dies fast left the reason a line from the middle. A | ||
| 1010 | // live flooder is still cut off rather than holding a failed dial. | ||
| 1011 | var total: usize = 0; | 901 | var total: usize = 0; |
| 1012 | while (total < pipe_capacity and self.fd >= 0) { | 902 | while (total < pipe_capacity and self.fd >= 0) { |
| 1013 | var fds = [_]std.posix.pollfd{ | 903 | var fds = [_]std.posix.pollfd{ |
| @@ -1098,18 +988,13 @@ fn announceFailed(err: anyerror) bool { | |||
| 1098 | return false; | 988 | return false; |
| 1099 | } | 989 | } |
| 1100 | 990 | ||
| 1101 | /// What `wallview.runAttach` says when the open fails, and what it exits | 991 | /// What `wallview.runAttach` says when the open fails, and what it exits with. |
| 1102 | /// with. A pair | 992 | /// A pair rather than a message, because the abort paths print a line AND exit |
| 1103 | /// rather than a bare message because the abort paths are not silent: they | 993 | /// 0 — two facts that would drift if they lived in two functions. |
| 1104 | /// print a line AND exit 0, and splitting those two facts across two | ||
| 1105 | /// functions would let one drift from the other. | ||
| 1106 | pub const OpenFailure = struct { | 994 | pub const OpenFailure = struct { |
| 1107 | /// Points into the buffer the caller passed `openFailure`, and stays | 995 | /// Points into the buffer passed to `openFailure` and lives only until the |
| 1108 | /// valid only until the next `openFailure` call on that same buffer — | 996 | /// next call on it — EXCEPT the abort paths, whose message is static. Print |
| 1109 | /// EXCEPT on the abort paths, whose message is static and outlives it. | 997 | /// before reusing; hold one across a second call and only some survive. |
| 1110 | /// Print it before reusing the buffer and the non-uniformity cannot | ||
| 1111 | /// bite; hold one across a second call and only some of them survive, | ||
| 1112 | /// which is the worse kind of bug to find. | ||
| 1113 | msg: []const u8, | 998 | msg: []const u8, |
| 1114 | /// 1 everywhere except the abort paths, where the user pressed Ctrl-\ | 999 | /// 1 everywhere except the abort paths, where the user pressed Ctrl-\ |
| 1115 | /// and stopping when asked is not a failure to exit nonzero over. | 1000 | /// and stopping when asked is not a failure to exit nonzero over. |
| @@ -1118,14 +1003,10 @@ pub const OpenFailure = struct { | |||
| 1118 | 1003 | ||
| 1119 | /// The buffer `wallview.runAttach` hands `openFailure`. | 1004 | /// The buffer `wallview.runAttach` hands `openFailure`. |
| 1120 | /// | 1005 | /// |
| 1121 | /// Not derived, and it cannot be: PATH_MAX would bound the key and socket | 1006 | /// Not derived, and it cannot be: the longest operands are argv strings whose |
| 1122 | /// paths, but the longest operands are argv strings — a `--via` command | 1007 | /// only ceiling is ARG_MAX, and sizing for that puts a megabyte on the stack to |
| 1123 | /// line, a handoff's ssh argv, a `host_port` — whose only ceiling is | 1008 | /// print one line. A chosen number, made safe by `failedMsg`'s truncation: |
| 1124 | /// ARG_MAX, and sizing for that would put a megabyte on the stack to | 1009 | /// past this length the message clips instead of anything going wrong. |
| 1125 | /// print one line. So this is a chosen number, picked to put truncation | ||
| 1126 | /// out of reach of any real command, and it is `failedMsg`'s truncation | ||
| 1127 | /// policy that makes choosing rather than deriving safe: past this length | ||
| 1128 | /// the message clips instead of anything going wrong. | ||
| 1129 | pub const open_err_len = 8192; | 1010 | pub const open_err_len = 8192; |
| 1130 | 1011 | ||
| 1131 | /// Truncating, not failing: this is the user's only account of why the | 1012 | /// Truncating, not failing: this is the user's only account of why the |
| @@ -1141,27 +1022,19 @@ fn failedMsg(buf: []u8, comptime fmt: []const u8, args: anytype) OpenFailure { | |||
| 1141 | /// reported as a failure — and the exit says so too. | 1022 | /// reported as a failure — and the exit says so too. |
| 1142 | const open_aborted: OpenFailure = .{ .msg = "mux: aborted before attaching\n", .exit = 0 }; | 1023 | const open_aborted: OpenFailure = .{ .msg = "mux: aborted before attaching\n", .exit = 0 }; |
| 1143 | 1024 | ||
| 1144 | /// What the entry dial prints and exits with when `Transport.open` fails. | 1025 | /// What the entry dial prints and exits with when `Transport.open` fails. Pure, |
| 1145 | /// Pure so the whole error policy can be pinned the way `lostMsg` is; the | 1026 | /// so the whole error policy can be pinned; the caller owns only the printing. |
| 1146 | /// caller owns only the printing. | 1027 | /// `err` is `anyerror` by design: open unions error sets from five sources and |
| 1147 | /// | 1028 | /// this classifies by VALUE. The cost is that a misspelled prong falls to |
| 1148 | /// `err` is `anyerror` by design, not by laziness: open unions error sets | 1029 | /// `else` rather than failing to compile — the literal pins below refuse that. |
| 1149 | /// from the key loader, the resolver, the QUIC client, ssh's spawn and | ||
| 1150 | /// handoff's parser, and this function classifies by VALUE across all of | ||
| 1151 | /// them. The cost is that a misspelled prong is not a compile error — it | ||
| 1152 | /// just falls to the `else` arm and quietly loses its class. What refuses | ||
| 1153 | /// that is the twelve literal pins below, never the signature. | ||
| 1154 | pub fn openFailure(buf: []u8, target: Target, err: anyerror, reason: []const u8) OpenFailure { | 1030 | pub fn openFailure(buf: []u8, target: Target, err: anyerror, reason: []const u8) OpenFailure { |
| 1155 | return switch (target) { | 1031 | return switch (target) { |
| 1156 | // A key the daemon would also have refused, said in the same | 1032 | // A key the daemon would also have refused, said in the same |
| 1157 | // words, because the user's mistake is the same one. | 1033 | // words, because the user's mistake is the same one. |
| 1158 | .quic => |q| switch (err) { | 1034 | .quic => |q| switch (err) { |
| 1159 | // Only the three key classes go through the shared body. The | 1035 | // Only the three key classes take the shared body. The `else` below |
| 1160 | // `else` below is NOT its catch-all and must not become it: | 1036 | // is NOT its catch-all: down here an unclassified error is far more |
| 1161 | // down here an unclassified error is far more often a dial that | 1037 | // often a failed dial than an unreadable file, so it names the endpoint. |
| 1162 | // failed than a file that would not read, so it names the | ||
| 1163 | // endpoint. The daemon-side callers, whose errors can only have | ||
| 1164 | // come from the load, use the body's fourth sentence instead. | ||
| 1165 | error.KeyFileMissing, | 1038 | error.KeyFileMissing, |
| 1166 | error.KeyFilePermissive, | 1039 | error.KeyFilePermissive, |
| 1167 | error.KeyFileMalformed, | 1040 | error.KeyFileMalformed, |
| @@ -1209,21 +1082,11 @@ pub fn openFailure(buf: []u8, target: Target, err: anyerror, reason: []const u8) | |||
| 1209 | "mux: {s} over ssh: {s}\n", | 1082 | "mux: {s} over ssh: {s}\n", |
| 1210 | .{ h.host, reason }, | 1083 | .{ h.host, reason }, |
| 1211 | ) else if (announceFailed(err)) | 1084 | ) else if (announceFailed(err)) |
| 1212 | // All this observes is that we waited for an announce | 1085 | // All this observes is that no announce came. Whether ssh |
| 1213 | // and did not get one. Whether ssh reached the host is | 1086 | // reached the host is NOT knowable here: a clean EOF is equally |
| 1214 | // NOT knowable from here: a parse failure does prove | 1087 | // a refused connection, a rejected key, or a remote with no |
| 1215 | // something spoke, but a clean EOF is equally what a | 1088 | // `mux` on PATH. So the line claims only the observation, which |
| 1216 | // refused connection, a rejected key, and a remote | 1089 | // is weakly true where "cannot reach" was strongly false. |
| 1217 | // without `mux` on its PATH all look like — the | ||
| 1218 | // shell's complaint goes to stderr and its stdout | ||
| 1219 | // closes clean. | ||
| 1220 | // | ||
| 1221 | // So the line claims only the observation, which is | ||
| 1222 | // weakly true in every one of those cases, where | ||
| 1223 | // "cannot reach" was strongly false in some of them. | ||
| 1224 | // | ||
| 1225 | // Reached only when ssh finished no line at all: with one, | ||
| 1226 | // the arm above quotes it instead. | ||
| 1227 | failedMsg( | 1090 | failedMsg( |
| 1228 | buf, | 1091 | buf, |
| 1229 | "mux: no endpoint announce from {s} over ssh ({s})\n", | 1092 | "mux: no endpoint announce from {s} over ssh ({s})\n", |
| @@ -1287,11 +1150,9 @@ pub fn spellingCap(target: Target) usize { | |||
| 1287 | return "--sock ".len + operand + 1 + proto.session_name_max; | 1150 | return "--sock ".len + operand + 1 + proto.session_name_max; |
| 1288 | } | 1151 | } |
| 1289 | 1152 | ||
| 1290 | // The grid a birth asks for. It is `main.Opts`'s own default — the size | 1153 | // The grid a birth asks for: `main.Opts`'s own default, the size `mux d start` |
| 1291 | // `mux d start` gives session 0 — because a session created for a client | 1154 | // gives session 0. A session created for a client that claims no size has to be |
| 1292 | // that claims no size has to be born at SOMETHING, and the daemon's own | 1155 | // born at SOMETHING, and the daemon's own answer needs no explaining. |
| 1293 | // answer to that question is the one nobody has to explain. Wrong, and a | ||
| 1294 | // restored browser tile comes back at a shape no client ever asked for. | ||
| 1295 | pub const birth_cols: u16 = 80; | 1156 | pub const birth_cols: u16 = 80; |
| 1296 | pub const birth_rows: u16 = 24; | 1157 | pub const birth_rows: u16 = 24; |
| 1297 | 1158 | ||
| @@ -1301,11 +1162,10 @@ pub const birth_rows: u16 = 24; | |||
| 1301 | // the tile reads [refused], which is where it stood before. | 1162 | // the tile reads [refused], which is where it stood before. |
| 1302 | const birth_budget_ms: i64 = 3000; | 1163 | const birth_budget_ms: i64 = 3000; |
| 1303 | 1164 | ||
| 1304 | /// One bounded wait on a side connection, shared by every ask that opens | 1165 | /// One bounded wait on a side connection: false means loop again without |
| 1305 | /// its own: false means loop again without reading, `error.Timeout` means | 1166 | /// reading, `error.Timeout` means the budget is spent. No EINTR arm — |
| 1306 | /// the budget is spent. No EINTR arm — `std.posix.poll` retries INTR | 1167 | /// `std.posix.poll` retries INTR itself, and its error set holds only failures |
| 1307 | /// itself, and its error set holds only failures the fd never recovers | 1168 | /// the fd never recovers from. |
| 1308 | /// from, so waiting the deadline out on one would be a stall, not a wait. | ||
| 1309 | fn awaitFrames(tr: *Transport, deadline: i64) !bool { | 1169 | fn awaitFrames(tr: *Transport, deadline: i64) !bool { |
| 1310 | const left = deadline - std.time.milliTimestamp(); | 1170 | const left = deadline - std.time.milliTimestamp(); |
| 1311 | if (left <= 0) return error.Timeout; | 1171 | if (left <= 0) return error.Timeout; |
| @@ -1327,21 +1187,11 @@ pub fn birthSession( | |||
| 1327 | cols: u16, | 1187 | cols: u16, |
| 1328 | rows: u16, | 1188 | rows: u16, |
| 1329 | ) !void { | 1189 | ) !void { |
| 1330 | // `Refused` is the daemon's no — a full table, a name it will not | 1190 | // `Refused` is the daemon's no, `Timeout` a daemon that never answered, |
| 1331 | // make; `Timeout` is a daemon that never answered; `Transport` is a | 1191 | // `Transport` a wire that broke — they differ only in what a log can say. |
| 1332 | // wire that broke under the question. The caller forwards the refusal | 1192 | // The budget below is the ANSWER budget and starts after the dial, since |
| 1333 | // it was already holding in every case, so they differ only in what a | 1193 | // each target bounds its own. A SIDE connection, not the caller's: a tile |
| 1334 | // log can say. | 1194 | // that claimed a size to get its session made would keep claiming it. |
| 1335 | // | ||
| 1336 | // The budget below is the ANSWER budget and starts after the dial: | ||
| 1337 | // `Transport.open` is not interruptible from here, and each target | ||
| 1338 | // bounds its own dial already (`QuicTarget.deadline_ms`, | ||
| 1339 | // `HandoffTarget.deadline_ms`; a `.sock` connect is one syscall). | ||
| 1340 | // | ||
| 1341 | // A SIDE connection, not the caller's: a tile that claimed a size to | ||
| 1342 | // get its session made would keep claiming it, and the grid would | ||
| 1343 | // follow whichever browser last reloaded. The birth pays one attach | ||
| 1344 | // and hands the session back at a size nobody is bound to. | ||
| 1345 | var tr = try Transport.open(alloc, target, null, -1, null); | 1195 | var tr = try Transport.open(alloc, target, null, -1, null); |
| 1346 | defer tr.close(); | 1196 | defer tr.close(); |
| 1347 | var buf: [proto.attach_max_len]u8 = undefined; | 1197 | var buf: [proto.attach_max_len]u8 = undefined; |
| @@ -1399,15 +1249,10 @@ pub fn listSessions( | |||
| 1399 | /// ssh's line, so a box that is down says why instead of `unreachable`. | 1249 | /// ssh's line, so a box that is down says why instead of `unreachable`. |
| 1400 | dial: ?*handoff.Dial, | 1250 | dial: ?*handoff.Dial, |
| 1401 | ) ![]const u8 { | 1251 | ) ![]const u8 { |
| 1402 | // A fresh connection per poll: the observer idle deadline and the | 1252 | // A fresh connection per poll, so the observer idle deadline and the redial |
| 1403 | // redial backoff stay the pump's problem, and a `--via` host — an ssh | 1253 | // backoff stay the pump's problem. Recorded BEFORE the open, because a |
| 1404 | // per open — is the case that will make a kept side connection worth | 1254 | // `hand` target's COST is the target's and not the reply's: an ssh login is |
| 1405 | // its state. Measure before adding one. | 1255 | // spent either way, and only the caller's backoff can stop paying. |
| 1406 | // BEFORE the open, because what a `hand` target COST is decided by the | ||
| 1407 | // target, not by the reply — an ssh login is spent either way, and the | ||
| 1408 | // caller's backoff is the only thing that can stop paying. A failed open | ||
| 1409 | // left this untouched, so a host that refuses the login was re-dialled at | ||
| 1410 | // the reachable interval forever. | ||
| 1411 | if (answered) |a| a.* = switch (target) { | 1256 | if (answered) |a| a.* = switch (target) { |
| 1412 | .hand, .via => .pipe, | 1257 | .hand, .via => .pipe, |
| 1413 | .quic => .quic, | 1258 | .quic => .quic, |
| @@ -1491,15 +1336,10 @@ pub fn pollDelayMs(link: std.meta.Tag(Link)) u64 { | |||
| 1491 | return if (link == .pipe) host_poll_ms * 10 else host_poll_ms; | 1336 | return if (link == .pipe) host_poll_ms * 10 else host_poll_ms; |
| 1492 | } | 1337 | } |
| 1493 | 1338 | ||
| 1494 | /// ONE host's session poll, for every front that shows a wall: the CLI | 1339 | /// ONE host's session poll, for every front that shows a wall, so a tile born |
| 1495 | /// wall's `wall_host.Host` and the browser hub's host row both run this | 1340 | /// in a terminal and one born in a browser come from the same question. Whether |
| 1496 | /// loop, so a tile born in a terminal and a tile born in a browser come | 1341 | /// to keep going and how to wake the reader are the CALLER's; everything else, |
| 1497 | /// from the same question asked the same way. | 1342 | /// including riding out a blip, is the same on both fronts. |
| 1498 | /// | ||
| 1499 | /// Whether to keep going and how to wake the reader are the caller's — | ||
| 1500 | /// the wall answers `shared.running and !forgotten` and rings the keyboard; | ||
| 1501 | /// the hub answers "the hub is serving" and applies the list. Everything | ||
| 1502 | /// else, including riding out a blip, is the same on both fronts. | ||
| 1503 | pub const SessionPoll = struct { | 1343 | pub const SessionPoll = struct { |
| 1504 | list_mu: std.Thread.Mutex = .{}, | 1344 | list_mu: std.Thread.Mutex = .{}, |
| 1505 | list: [proto.sessions_text_max]u8 = undefined, | 1345 | list: [proto.sessions_text_max]u8 = undefined, |
| @@ -1602,11 +1442,9 @@ test "pollDelayMs: a poll that cost an ssh login is asked ten times less often" | |||
| 1602 | } | 1442 | } |
| 1603 | 1443 | ||
| 1604 | test "SessionPoll.run: a keep that says stop is felt within one sleep slice, not one poll interval" { | 1444 | test "SessionPoll.run: a keep that says stop is felt within one sleep slice, not one poll interval" { |
| 1605 | // The wall's teardown and the picker's `x` both end a poller by | 1445 | // The wall's teardown and the picker's `x` both end a poller by answering |
| 1606 | // answering `keep` with false. A loop that only re-read it once per | 1446 | // `keep` false. Re-reading it once per `pollDelayMs` would hold the wall's |
| 1607 | // `pollDelayMs` would hold the wall's exit for a second per host — so | 1447 | // exit for a second per host, so the 50 ms slice is the claim. |
| 1608 | // the 50 ms slice is the claim, and the socket nobody serves is what | ||
| 1609 | // makes the poll itself fail fast enough to reach the sleep. | ||
| 1610 | var tmp = try TmpDir.make(); | 1448 | var tmp = try TmpDir.make(); |
| 1611 | defer tmp.cleanup(); | 1449 | defer tmp.cleanup(); |
| 1612 | const path = try std.fmt.allocPrint(std.testing.allocator, "{s}/nobody.sock", .{tmp.path()}); | 1450 | const path = try std.fmt.allocPrint(std.testing.allocator, "{s}/nobody.sock", .{tmp.path()}); |
| @@ -1715,11 +1553,9 @@ test "Transport.close is idempotent: the abort path closes what a re-dial alread | |||
| 1715 | 1553 | ||
| 1716 | var transport = try Transport.open(alloc, .{ .sock = sock_path }, null, -1, null); | 1554 | var transport = try Transport.open(alloc, .{ .sock = sock_path }, null, -1, null); |
| 1717 | 1555 | ||
| 1718 | // A re-dial closes the dead transport at entry; if the user then aborts, | 1556 | // A re-dial closes the dead transport at entry, and an abort then closes it |
| 1719 | // the pump's `defer transport.close()` closes it a second time. Without a | 1557 | // again through the pump's `defer`. Without a sentinel that is `close(2)` on |
| 1720 | // sentinel that is close(2) on a stale fd — EBADF, which std.posix.close | 1558 | // a stale fd — EBADF, which `std.posix.close` maps to `unreachable`. |
| 1721 | // maps to `unreachable`, i.e. a panic, and on --sock (the locally-killed | ||
| 1722 | // daemon case) nothing masks it. | ||
| 1723 | transport.close(); | 1559 | transport.close(); |
| 1724 | transport.close(); | 1560 | transport.close(); |
| 1725 | transport.close(); | 1561 | transport.close(); |
| @@ -1756,21 +1592,11 @@ test "connectAgent: a live socket connects, a dead path returns null" { | |||
| 1756 | } | 1592 | } |
| 1757 | 1593 | ||
| 1758 | test "Transport.open: a --via target yields a pipe, a --sock target an fd" { | 1594 | test "Transport.open: a --via target yields a pipe, a --sock target an fd" { |
| 1759 | // The dispatch decision itself, which nothing else in this file pinned: | 1595 | // The dispatch decision itself, which nothing else pinned: every other open |
| 1760 | // every other open test asserts what the handoff CHOSE, and the | 1596 | // test asserts what the handoff CHOSE, so swapping open's two arms used to |
| 1761 | // idempotence test above opens a `.sock` but only reads the sentinel — | 1597 | // pass the whole suite. Ordered AFTER the idempotence test, because the |
| 1762 | // which a pipe link satisfies exactly as well as an fd one. So swapping | 1598 | // double close below is safe only while the sentinel holds — and a panic |
| 1763 | // the two arms of open's switch used to pass the whole unit suite. | 1599 | // prints no assertion, so running first would hide the pin that names it. |
| 1764 | // | ||
| 1765 | // Ordered AFTER that idempotence test, and it has to be: the double | ||
| 1766 | // close below is safe only while the `conn.r == -1` sentinel holds, so | ||
| 1767 | // if the sentinel regresses this test panics on close(2) of a stale fd. | ||
| 1768 | // A panic prints no assertion, so running first would hide the very pin | ||
| 1769 | // that names the regression. | ||
| 1770 | // | ||
| 1771 | // Both halves print before they fail. A bare `expect` here would say | ||
| 1772 | // "TestUnexpectedResult" about a decision, and the decision is the | ||
| 1773 | // whole content of the test. | ||
| 1774 | const alloc = std.testing.allocator; | 1600 | const alloc = std.testing.allocator; |
| 1775 | 1601 | ||
| 1776 | // A command that stays alive on stdin, so the link is unambiguously a | 1602 | // A command that stays alive on stdin, so the link is unambiguously a |
| @@ -1954,11 +1780,9 @@ test "spawnPipe: an askpass dial hands ssh the three variables, and a plain dial | |||
| 1954 | } | 1780 | } |
| 1955 | 1781 | ||
| 1956 | test "the announce reader consumes the newline and NOT the byte after it" { | 1782 | test "the announce reader consumes the newline and NOT the byte after it" { |
| 1957 | // The property the announce-then-frames protocol stands on, pinned on | 1783 | // The property the announce-then-frames protocol stands on. A buffered read |
| 1958 | // the one reader the product still has. A buffered read here would take | 1784 | // here would take 'X' — the frame stream's first byte — into a buffer that |
| 1959 | // 'X' — the frame stream's first byte — into a buffer that is then | 1785 | // is thrown away, and the session would hang with nothing to point at. |
| 1960 | // thrown away, and the session would hang or desynchronise with nothing | ||
| 1961 | // to point at. | ||
| 1962 | const alloc = std.testing.allocator; | 1786 | const alloc = std.testing.allocator; |
| 1963 | const fds = try std.posix.pipe(); | 1787 | const fds = try std.posix.pipe(); |
| 1964 | defer std.posix.close(fds[0]); | 1788 | defer std.posix.close(fds[0]); |
| @@ -1967,12 +1791,9 @@ test "the announce reader consumes the newline and NOT the byte after it" { | |||
| 1967 | const ep: handoff.Endpoint = .{ .port = 4433, .key = [_]u8{0xAB} ** 32 }; | 1791 | const ep: handoff.Endpoint = .{ .port = 4433, .key = [_]u8{0xAB} ** 32 }; |
| 1968 | _ = try std.posix.write(fds[1], try handoff.formatAnnounce(&buf, ep)); | 1792 | _ = try std.posix.write(fds[1], try handoff.formatAnnounce(&buf, ep)); |
| 1969 | _ = try std.posix.write(fds[1], "X"); | 1793 | _ = try std.posix.write(fds[1], "X"); |
| 1970 | // Closed before the read, deliberately: with the write end open, a | 1794 | // Closed before the read: with the write end open, a reader that swallowed |
| 1971 | // reader that had already swallowed the 'X' would leave the assertion | 1795 | // the 'X' would block the assertion below forever, and a hung suite names |
| 1972 | // below blocking forever, and this test's whole job is to catch exactly | 1796 | // nothing. EOF turns the catch into a printed "expected 1, found 0". |
| 1973 | // that implementation. A hung suite names nothing; EOF here turns the | ||
| 1974 | // catch into a printed "expected 1, found 0". The bytes are already in | ||
| 1975 | // the pipe, so the close costs the correct implementation nothing. | ||
| 1976 | std.posix.close(fds[1]); | 1797 | std.posix.close(fds[1]); |
| 1977 | 1798 | ||
| 1978 | const got = (try readAnnounceAbortable(fds[0], alloc, null, -1, null)).?; | 1799 | const got = (try readAnnounceAbortable(fds[0], alloc, null, -1, null)).?; |
| @@ -2054,25 +1875,14 @@ test "handoff: endpoint-none rides the open pipe with no deadline paid" { | |||
| 2054 | } | 1875 | } |
| 2055 | 1876 | ||
| 2056 | test "handoff: dead coordinates are a fast no, and the pipe is the fallback" { | 1877 | test "handoff: dead coordinates are a fast no, and the pipe is the fallback" { |
| 2057 | // A well-formed announce naming 127.0.0.1:1, where nothing listens. | 1878 | // A well-formed announce naming 127.0.0.1:1, where nothing listens: the |
| 2058 | // The refusal is REAL — an ICMP unreachable comes back — and both | 1879 | // ICMP refusal is real, so this dial dies in one loopback round trip |
| 2059 | // socket paths in the quic client act on it, so this dial dies | 1880 | // instead of running `deadline_ms` out. The upper bound proves it — a |
| 2060 | // in about one loopback round trip instead of running deadline_ms out. | 1881 | // build that swallowed the refusal spends the whole 300 ms. |
| 2061 | // The upper bound below is what proves that: it sits far under the | ||
| 2062 | // budget, so an implementation that swallowed the refusal again (the | ||
| 2063 | // bug this pins: drain's `catch return` discarding ECONNREFUSED) would | ||
| 2064 | // spend the whole 300ms and fail here. No lower bound — this measures | ||
| 2065 | // 2ms, so any floor would be pinning scheduler noise. | ||
| 2066 | // | 1882 | // |
| 2067 | // Which means this test no longer witnesses that a dial happened AT | 1883 | // With no cache here, this cannot witness that a dial happened at all: a |
| 2068 | // ALL: there is no cache here (cache_path = null), so the build it | 1884 | // build ignoring the coordinates lands on the child link just as fast. |
| 2069 | // cannot tell apart is one that IGNORED the announced coordinates and | 1885 | // That half is e2e's. Pinned HERE: the fallback DECISION and the speed. |
| 2070 | // went straight to the pipe — that lands on the child link just as | ||
| 2071 | // quickly. Dial-happened lives only in e2e now, in scenario (b), where | ||
| 2072 | // a warm attach must spawn no ssh; (c)'s ceiling covers the speed half, | ||
| 2073 | // not that half. What is pinned HERE is the fallback DECISION and the | ||
| 2074 | // speed of the refusal. The stderr line is e2e's too, where stderr is | ||
| 2075 | // capturable. | ||
| 2076 | const alloc = std.testing.allocator; | 1886 | const alloc = std.testing.allocator; |
| 2077 | var carry: std.ArrayList(u8) = .empty; | 1887 | var carry: std.ArrayList(u8) = .empty; |
| 2078 | defer carry.deinit(alloc); | 1888 | defer carry.deinit(alloc); |
| @@ -2094,13 +1904,9 @@ test "handoff: dead coordinates are a fast no, and the pipe is the fallback" { | |||
| 2094 | const elapsed = std.time.milliTimestamp() - t0; | 1904 | const elapsed = std.time.milliTimestamp() - t0; |
| 2095 | try std.testing.expect(t.link != .quic); | 1905 | try std.testing.expect(t.link != .quic); |
| 2096 | try std.testing.expect(t.link == .pipe); | 1906 | try std.testing.expect(t.link == .pipe); |
| 2097 | // The refusal was SEEN, not waited out. 150ms is derived from both | 1907 | // The refusal was SEEN, not waited out. 150 ms is derived from both ends: |
| 2098 | // ends: the failure it catches spends the full 300ms, so half the | 1908 | // the failure spends the full 300 ms, and the 2 ms this measures leaves 75x |
| 2099 | // budget still separates the two by 2x, while the 2ms this measures | 1909 | // of headroom — which it needs, since `elapsed` also covers a shell spawn. |
| 2100 | // leaves 75x of headroom. It needs that headroom — `elapsed` also | ||
| 2101 | // covers spawning /bin/sh and reading the announce out of it, and the | ||
| 2102 | // sibling endpoint-none test budgets 1000ms for that same spawn under | ||
| 2103 | // load. | ||
| 2104 | if (elapsed >= 150) std.debug.print( | 1910 | if (elapsed >= 150) std.debug.print( |
| 2105 | "refused dial took {d}ms of a 300ms budget: the ICMP refusal was swallowed, not acted on\n", | 1911 | "refused dial took {d}ms of a 300ms budget: the ICMP refusal was swallowed, not acted on\n", |
| 2106 | .{elapsed}, | 1912 | .{elapsed}, |
| @@ -2109,14 +1915,9 @@ test "handoff: dead coordinates are a fast no, and the pipe is the fallback" { | |||
| 2109 | } | 1915 | } |
| 2110 | 1916 | ||
| 2111 | /// Put `bytes` on fd 0 for the duration of a test and give back a restorer. | 1917 | /// Put `bytes` on fd 0 for the duration of a test and give back a restorer. |
| 2112 | /// | 1918 | /// The CLI passes STDIN_FILENO as the abort fd, so testing that spelling means |
| 2113 | /// The CLI passes STDIN_FILENO as the abort fd — that is where the abort | 1919 | /// briefly owning fd 0. Zig runs a file's tests one at a time, so this is safe |
| 2114 | /// key arrives in production — so testing that spelling means briefly | 1920 | /// as long as every caller restores — which `deinit` makes a defer. |
| 2115 | /// owning fd 0. (The fd is injectable; the -1 test leans on this same | ||
| 2116 | /// harness to prove fd 0 is NOT read in that case.) Zig runs a file's | ||
| 2117 | /// tests one at a time in one process, so this is safe as long as every | ||
| 2118 | /// caller restores; `deinit` | ||
| 2119 | /// exists to make that a defer rather than a discipline. | ||
| 2120 | const FakeStdin = struct { | 1921 | const FakeStdin = struct { |
| 2121 | saved: std.posix.fd_t, | 1922 | saved: std.posix.fd_t, |
| 2122 | w: std.posix.fd_t, | 1923 | w: std.posix.fd_t, |
| @@ -2143,18 +1944,11 @@ const FakeStdin = struct { | |||
| 2143 | }; | 1944 | }; |
| 2144 | 1945 | ||
| 2145 | test "handoff: the announce wait still answers the abort key" { | 1946 | test "handoff: the announce wait still answers the abort key" { |
| 2146 | // The property the QUIC handshake wait keeps, applied to the ssh half: an | 1947 | // An announce that never comes must not cost the user their way out: a |
| 2147 | // announce that never comes must not cost the user their way out. A | 1948 | // blocking read would sit in `read(2)` with nothing watching stdin, and on |
| 2148 | // blocking read here would sit in read(2) with nothing watching stdin, | 1949 | // the reconnect path `Ctrl-\` is the only way out. RECONNECT spelling on |
| 2149 | // and on the reconnect path — raw mode, ISIG off — Ctrl-\ is the only | 1950 | // purpose. The script exits on its own, so a blocking implementation FAILS |
| 2150 | // way out there is. RECONNECT spelling (carry = null) on purpose: on a | 1951 | // with a printed expectation rather than hanging the suite. |
| 2151 | // first attach the announce wait deliberately leaves stdin to ssh's | ||
| 2152 | // password prompt, and cooked-mode Ctrl-C is the way out instead. | ||
| 2153 | // | ||
| 2154 | // The script exits on its own rather than hanging, deliberately: a | ||
| 2155 | // blocking implementation must FAIL this test with a printed | ||
| 2156 | // "expected error.UserAbort, found error.UnterminatedLine", not hang the | ||
| 2157 | // suite. A test that catches its bug by never finishing names nothing. | ||
| 2158 | const alloc = std.testing.allocator; | 1952 | const alloc = std.testing.allocator; |
| 2159 | 1953 | ||
| 2160 | // The abort byte is already in the pipe when the wait starts, so the | 1954 | // The abort byte is already in the pipe when the wait starts, so the |
| @@ -2175,12 +1969,10 @@ test "handoff: the announce wait still answers the abort key" { | |||
| 2175 | } | 1969 | } |
| 2176 | 1970 | ||
| 2177 | test "handoff: a first attach leaves stdin to ssh while the announce is pending — a typed password must not be swallowed" { | 1971 | test "handoff: a first attach leaves stdin to ssh while the announce is pending — a typed password must not be swallowed" { |
| 2178 | // The competing-reader bug: ssh reads its password prompt from | 1972 | // ssh reads its password prompt from /dev/tty, and a client polling stdin |
| 2179 | // /dev/tty, and a client polling stdin during the announce wait steals | 1973 | // during the announce wait steals whole cooked lines — auth fails, and the |
| 2180 | // whole cooked lines from it — auth fails, and the stolen line later | 1974 | // stolen line replays INTO the session via carry. A first attach must leave |
| 2181 | // replays INTO THE SESSION via carry. A first attach (carry != null) | 1975 | // stdin untouched. |
| 2182 | // must leave stdin untouched; the bytes stay in the kernel's tty | ||
| 2183 | // buffer for whoever legitimately reads next. | ||
| 2184 | const alloc = std.testing.allocator; | 1976 | const alloc = std.testing.allocator; |
| 2185 | var carry: std.ArrayList(u8) = .empty; | 1977 | var carry: std.ArrayList(u8) = .empty; |
| 2186 | defer carry.deinit(alloc); | 1978 | defer carry.deinit(alloc); |
| @@ -2276,11 +2068,10 @@ fn boxScript(buf: []u8, dir: []const u8) ![]const u8 { | |||
| 2276 | , .{ .d = dir }); | 2068 | , .{ .d = dir }); |
| 2277 | } | 2069 | } |
| 2278 | 2070 | ||
| 2279 | /// The dial must not have reached a daemon, said so by `claim`. NOT | 2071 | /// The dial must not have reached a daemon. NOT `expectError`: it renders the |
| 2280 | /// `expectError`: it renders the success value with `{any}`, and a live | 2072 | /// success value with `{any}`, and a live `Transport` holds an allocator vtable |
| 2281 | /// `Transport` holds an allocator vtable formatting FAULTS on — the runner | 2073 | /// that FAULTS on formatting — the runner dies inside the message and names no |
| 2282 | /// dies inside the message and names no test. The session it should not | 2074 | /// test. The session it should not have is closed here; it owns a child. |
| 2283 | /// have is closed here; it owns a child. | ||
| 2284 | fn expectNoSession(claim: []const u8, r: anytype) !void { | 2075 | fn expectNoSession(claim: []const u8, r: anytype) !void { |
| 2285 | if (r) |t| { | 2076 | if (r) |t| { |
| 2286 | var live = t; | 2077 | var live = t; |
| @@ -2304,14 +2095,10 @@ fn refusingBoxScript(buf: []u8, dir: []const u8) ![]const u8 { | |||
| 2304 | } | 2095 | } |
| 2305 | 2096 | ||
| 2306 | test "openHandoff: a HandoffTarget nobody configured runs the reading word, never the asking one" { | 2097 | test "openHandoff: a HandoffTarget nobody configured runs the reading word, never the asking one" { |
| 2307 | // The default is the value a NEW dial path inherits by forgetting the | 2098 | // The default is what a NEW dial path inherits by forgetting the line, and |
| 2308 | // line, and nothing fails loudly when it does: the symptom is a daemon | 2099 | // nothing fails loudly: the symptom is a daemon on someone else's box. So |
| 2309 | // (and a shell in session 0) appearing on someone else's box. So the | 2100 | // the default is the harmless half. `asked` picks the ARGV, so the oracle |
| 2310 | // default is the harmless half, and the ask is what has to be written | 2101 | // is the word the fake was handed — what RAN, not what was meant. |
| 2311 | // down. | ||
| 2312 | // | ||
| 2313 | // `asked` picks the ARGV now, so the oracle is the word the fake was | ||
| 2314 | // handed — a fact about what ran, not about what the client meant. | ||
| 2315 | const alloc = std.testing.allocator; | 2102 | const alloc = std.testing.allocator; |
| 2316 | var carry: std.ArrayList(u8) = .empty; | 2103 | var carry: std.ArrayList(u8) = .empty; |
| 2317 | defer carry.deinit(alloc); | 2104 | defer carry.deinit(alloc); |
| @@ -2338,11 +2125,9 @@ test "openHandoff: a HandoffTarget nobody configured runs the reading word, neve | |||
| 2338 | } | 2125 | } |
| 2339 | 2126 | ||
| 2340 | test "openHandoff: the dial a user ASKED for runs the asking word ONCE and rides the announce that comes back on it" { | 2127 | test "openHandoff: the dial a user ASKED for runs the asking word ONCE and rides the announce that comes back on it" { |
| 2341 | // Three ssh runs became one, and this is the claim that says so. The | 2128 | // Three ssh runs became one: the remote ensures the daemon and announces on |
| 2342 | // remote ensures the daemon and announces on the same stdout, so there | 2129 | // the same stdout, so there is no refusal to read, no exit code to tell from |
| 2343 | // is no refusal for the client to read, no exit code to tell from | 2130 | // ssh's own 255, and no second dial to pay a password prompt for. |
| 2344 | // ssh's own 255, and no second dial to pay a connect timeout or a | ||
| 2345 | // password prompt for. | ||
| 2346 | const alloc = std.testing.allocator; | 2131 | const alloc = std.testing.allocator; |
| 2347 | var carry: std.ArrayList(u8) = .empty; | 2132 | var carry: std.ArrayList(u8) = .empty; |
| 2348 | defer carry.deinit(alloc); | 2133 | defer carry.deinit(alloc); |
| @@ -2408,11 +2193,9 @@ test "openHandoff: an asked dial whose box still announces nothing fails after t | |||
| 2408 | } | 2193 | } |
| 2409 | 2194 | ||
| 2410 | test "openHandoff: a dial nobody asked for, against a box with nothing, reports the failure and ran the bare word only" { | 2195 | test "openHandoff: a dial nobody asked for, against a box with nothing, reports the failure and ran the bare word only" { |
| 2411 | // The regression this exists for: the wall polls every listed host once | 2196 | // The wall polls every listed host once a second, and a poll that started a |
| 2412 | // a second, and a poll that started a daemon gave a listed box one (and | 2197 | // daemon gave a listed box one from a READ — undoing a `mux d stop` a second |
| 2413 | // a shell in session 0) from a READ — undoing a `mux d stop` on the far | 2198 | // after it was typed. The rule is argv's, so the log of what ran IS the proof. |
| 2414 | // end a second after it was typed. The rule is argv's now, so the log | ||
| 2415 | // of what ran IS the proof. | ||
| 2416 | const alloc = std.testing.allocator; | 2199 | const alloc = std.testing.allocator; |
| 2417 | var carry: std.ArrayList(u8) = .empty; | 2200 | var carry: std.ArrayList(u8) = .empty; |
| 2418 | defer carry.deinit(alloc); | 2201 | defer carry.deinit(alloc); |
| @@ -2439,16 +2222,10 @@ test "openHandoff: a dial nobody asked for, against a box with nothing, reports | |||
| 2439 | } | 2222 | } |
| 2440 | 2223 | ||
| 2441 | test "openHandoff: an asked target with no asking argv runs the reading word, and starts nothing" { | 2224 | test "openHandoff: an asked target with no asking argv runs the reading word, and starts nothing" { |
| 2442 | // `asked_argv` defaults to empty and its doc says empty means "nothing | 2225 | // `asked_argv` defaults to empty, and nothing in the type stops a caller |
| 2443 | // to start". Nothing in the type stops a caller setting `asked` on such | 2226 | // setting `asked` on such a target — an empty argv is not a no-op at the |
| 2444 | // a target — and an empty argv is not a no-op at the exec: the forked | 2227 | // exec: the child null-unwraps `argv[0]` and dies, which looks exactly like |
| 2445 | // child null-unwraps `argv[0]` and dies, which reaches the parent as an | 2228 | // a box with no daemon. This pins the DOC, since `fromRecipe` always fills it. |
| 2446 | // announce that never terminated. Indistinguishable, from the outside, | ||
| 2447 | // from a box that had no daemon. | ||
| 2448 | // | ||
| 2449 | // `fromRecipe` is the only production builder and it always fills the | ||
| 2450 | // field, so this pins the DOC rather than a live path: the fallback is | ||
| 2451 | // what keeps the sentence above true as the type is used later. | ||
| 2452 | const alloc = std.testing.allocator; | 2229 | const alloc = std.testing.allocator; |
| 2453 | var carry: std.ArrayList(u8) = .empty; | 2230 | var carry: std.ArrayList(u8) = .empty; |
| 2454 | defer carry.deinit(alloc); | 2231 | defer carry.deinit(alloc); |
| @@ -2475,11 +2252,9 @@ test "openHandoff: an asked target with no asking argv runs the reading word, an | |||
| 2475 | try std.testing.expectEqualStrings(read_word, try shimSaid(tmp.path(), "runs", &buf)); | 2252 | try std.testing.expectEqualStrings(read_word, try shimSaid(tmp.path(), "runs", &buf)); |
| 2476 | } | 2253 | } |
| 2477 | 2254 | ||
| 2478 | /// fd 2, captured into a pipe this test owns and restored on `take`. | 2255 | /// fd 2, captured into a pipe this test owns and restored on `take` — |
| 2479 | /// | 2256 | /// `FakeStdin`'s shape for the other direction. fd 2 and never fd 1: a byte on |
| 2480 | /// `FakeStdin`'s shape for the other direction. fd 2 and never fd 1: a | 2257 | /// the runner's stdout wedges `zig build test` silently, at 0 CPU. |
| 2481 | /// byte on the runner's stdout wedges `zig build test` silently, at 0 CPU, | ||
| 2482 | /// with no output at all. | ||
| 2483 | const CapturedStderr = struct { | 2258 | const CapturedStderr = struct { |
| 2484 | saved: std.posix.fd_t, | 2259 | saved: std.posix.fd_t, |
| 2485 | r: std.posix.fd_t, | 2260 | r: std.posix.fd_t, |
| @@ -2508,21 +2283,11 @@ const CapturedStderr = struct { | |||
| 2508 | }; | 2283 | }; |
| 2509 | 2284 | ||
| 2510 | test "openHandoff: the handoff ssh's stderr is a pipe, and only `narrate` relays it" { | 2285 | test "openHandoff: the handoff ssh's stderr is a pipe, and only `narrate` relays it" { |
| 2511 | // The bug this closes: a hosts line naming a box that is down puts | 2286 | // A hosts line naming a box that is down used to put ssh's `No route to |
| 2512 | // `ssh: connect to host ... No route to host` onto the wall's | 2287 | // host` onto the wall's alternate screen every poll, because the child's |
| 2513 | // alternate screen every poll, over tiles and rails, because the ssh | 2288 | // stderr was INHERITED. It is a pipe mux reads now, whoever dialled. The |
| 2514 | // child's stderr was INHERITED. It is a pipe mux reads now — always, | 2289 | // fake records where its stderr POINTED, off `/proc/$$/fd/2`, so "piped" is |
| 2515 | // whoever dialled — and the one caller that relays the bytes onward is | 2290 | // exact. BOTH values of `asked`, because the rule is the spawn's. |
| 2516 | // the entry dial, where a human is at a bare prompt waiting. | ||
| 2517 | // | ||
| 2518 | // The fake records where its stderr POINTED, off `/proc/$$/fd/2` — the | ||
| 2519 | // shell's own link, not a redirect's — so "piped" is exact rather than | ||
| 2520 | // a guess about what a test runner's stderr happens to be. | ||
| 2521 | // | ||
| 2522 | // BOTH values of `asked`, because the rule is the spawn's and not the | ||
| 2523 | // ask's: a picker-born tile clears `asked` for every redial and keeps | ||
| 2524 | // whatever the spawn does with stderr, so the redial is the half that | ||
| 2525 | // runs for as long as the tile lives. | ||
| 2526 | const alloc = std.testing.allocator; | 2291 | const alloc = std.testing.allocator; |
| 2527 | var stdin = try FakeStdin.install(""); | 2292 | var stdin = try FakeStdin.install(""); |
| 2528 | defer stdin.deinit(); | 2293 | defer stdin.deinit(); |
| @@ -2597,17 +2362,10 @@ test "lostMsg: only a --via transport that never connected gets the new wording" | |||
| 2597 | // `openFailure` is pure, so each class is one call with a literal answer. | 2362 | // `openFailure` is pure, so each class is one call with a literal answer. |
| 2598 | 2363 | ||
| 2599 | test "Transport.drainErr: a session whose ssh floods stderr keeps serving" { | 2364 | test "Transport.drainErr: a session whose ssh floods stderr keeps serving" { |
| 2600 | // The reason the poll set grew a third fd rather than the reason | 2365 | // A pipe holds 64k; past that the writer BLOCKS, and ssh blocked on stderr |
| 2601 | // growing a sentence. A pipe holds 64k; past that the writer BLOCKS, | 2366 | // is ssh not moving the session's bytes either — nothing on screen says so, |
| 2602 | // and ssh blocked on stderr is ssh not moving the session's bytes | 2367 | // the tile simply stops. The fixture floods in the FOREGROUND, before it |
| 2603 | // either. Nothing on screen says so — the tile simply stops. | 2368 | // serves, so an owner that never drains cannot get past it. |
| 2604 | // | ||
| 2605 | // The fixture floods in the FOREGROUND, before it starts serving, so | ||
| 2606 | // an owner that never drains cannot get past it. (Backgrounding the | ||
| 2607 | // flood would let `cat` serve regardless, and the test would pass with | ||
| 2608 | // the drain deleted — which is the shape this claim exists to catch.) | ||
| 2609 | // Verified by deleting the `drainErr` call below: the round trip then | ||
| 2610 | // never completes and the 2 s budget is what ends the test. | ||
| 2611 | const alloc = std.testing.allocator; | 2369 | const alloc = std.testing.allocator; |
| 2612 | var tmp = try TmpDir.make(); | 2370 | var tmp = try TmpDir.make(); |
| 2613 | defer tmp.cleanup(); | 2371 | defer tmp.cleanup(); |
| @@ -2834,24 +2592,17 @@ test "openFailure: --via and --sock say what they know and nothing more" { | |||
| 2834 | } | 2592 | } |
| 2835 | 2593 | ||
| 2836 | test "openFailure: a message too long for the buffer clips, and still fails" { | 2594 | test "openFailure: a message too long for the buffer clips, and still fails" { |
| 2837 | // `open_err_len` is chosen rather than derived — an argv string has no | 2595 | // `open_err_len` is chosen rather than derived, so what makes choosing safe |
| 2838 | // useful ceiling — so the thing that makes choosing safe is this: past | 2596 | // is this: past the buffer the line CLIPS and everything else holds. A |
| 2839 | // the buffer the line clips and everything else holds. Unpinned, a | 2597 | // future `bufPrint` here would answer an empty message or a crash instead. |
| 2840 | // future `bufPrint` here would turn the same input into an empty | ||
| 2841 | // message or a crash, and no other test goes near the boundary. | ||
| 2842 | const cmd = "x" ** (open_err_len + 808); | 2598 | const cmd = "x" ** (open_err_len + 808); |
| 2843 | var buf: [open_err_len]u8 = undefined; | 2599 | var buf: [open_err_len]u8 = undefined; |
| 2844 | const f = openFailure(&buf, .{ .via = cmd }, error.FileNotFound, ""); | 2600 | const f = openFailure(&buf, .{ .via = cmd }, error.FileNotFound, ""); |
| 2845 | 2601 | ||
| 2846 | // Full buffer, and it really is the message's own prefix: the fixed | 2602 | // Full buffer, and really the message's own prefix. The LENGTH goes first |
| 2847 | // text first, then as much of the command as fit. | 2603 | // and the slices below are cut to what came back: an expected slice sized |
| 2848 | // | 2604 | // from the constant is a comptime bounds error the day this input stops |
| 2849 | // The length goes FIRST and the slices below are cut to what came | 2605 | // overflowing, and that fails the whole module instead of one property. |
| 2850 | // back, never to `open_err_len`: an expected slice sized from the | ||
| 2851 | // constant is a COMPTIME bounds error the day this input stops | ||
| 2852 | // overflowing the buffer, and a compile error fails the whole module | ||
| 2853 | // — every other pin in this file included — instead of printing which | ||
| 2854 | // property broke. Checked by shrinking the input, not assumed. | ||
| 2855 | try std.testing.expectEqual(@as(usize, open_err_len), f.msg.len); | 2606 | try std.testing.expectEqual(@as(usize, open_err_len), f.msg.len); |
| 2856 | const head = "mux: cannot start --via command: "; | 2607 | const head = "mux: cannot start --via command: "; |
| 2857 | try std.testing.expectEqualStrings(head, f.msg[0..head.len]); | 2608 | try std.testing.expectEqualStrings(head, f.msg[0..head.len]); |
| @@ -2961,11 +2712,9 @@ test "client: a target spells itself back as one wall argument per session" { | |||
| 2961 | } | 2712 | } |
| 2962 | 2713 | ||
| 2963 | test "client: every spelling this writes, the host grammar reads back the same" { | 2714 | test "client: every spelling this writes, the host grammar reads back the same" { |
| 2964 | // Writer/reader identity, pinned across the module boundary. A tile's | 2715 | // Writer/reader identity across the module boundary: a tile's label and the |
| 2965 | // label and the sidecar leaf keyed by it are this string, and the | 2716 | // sidecar leaf keyed by it are this string, and a drift in either half heals |
| 2966 | // host half of it is what `hosts.parse` answers for — a drift in | 2717 | // a saved layout onto the wrong tile. `refAllDecls` compiles both. |
| 2967 | // either half heals a saved layout onto the wrong tile. | ||
| 2968 | // `refAllDecls` compiles both; only this executes both. | ||
| 2969 | var buf: [256]u8 = undefined; | 2718 | var buf: [256]u8 = undefined; |
| 2970 | const cases = .{ | 2719 | const cases = .{ |
| 2971 | .{ Target{ .sock = "/run/user/1000/muxd.sock" }, "0" }, | 2720 | .{ Target{ .sock = "/run/user/1000/muxd.sock" }, "0" }, |
| @@ -3224,13 +2973,10 @@ test "listSessions: a poll that failed still reports the login it paid for" { | |||
| 3224 | } | 2973 | } |
| 3225 | 2974 | ||
| 3226 | test "Target.fromSpec: asked is the caller's word, never a default" { | 2975 | test "Target.fromSpec: asked is the caller's word, never a default" { |
| 3227 | // The COMPILE-TIME half of this claim cannot be written as a runtime | 2976 | // The COMPILE-TIME half cannot be a runtime assertion: `fromSpec` and |
| 3228 | // assertion: `fromSpec` and `HandoffTarget.fromRecipe` take `asked` as | 2977 | // `fromRecipe` take `asked` with no default, so a call that omits it does |
| 3229 | // a parameter with no default, so a call that omits it does not build. | 2978 | // not build. What IS runtime-checkable is that the word is carried rather |
| 3230 | // That is the pin — the field's `= false` default stays for a road | 2979 | // than dropped and re-defaulted between here and the recipe. |
| 3231 | // that never heard of the field, and this door has no such road. | ||
| 3232 | // What is runtime-checkable is that the word is CARRIED, not dropped | ||
| 3233 | // and re-defaulted somewhere between here and the recipe. | ||
| 3234 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); | 2980 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); |
| 3235 | defer arena.deinit(); | 2981 | defer arena.deinit(); |
| 3236 | const alloc = arena.allocator(); | 2982 | const alloc = arena.allocator(); |
| @@ -3266,11 +3012,9 @@ test "Target.fromSpec: the target owns every slice, so a scratch spelling may be | |||
| 3266 | } | 3012 | } |
| 3267 | 3013 | ||
| 3268 | test "Target.fromSpec: a quic spelling with no key frees the path it refused" { | 3014 | test "Target.fromSpec: a quic spelling with no key frees the path it refused" { |
| 3269 | // `std.testing.allocator` IS the assertion: `xdg.resolveKeyPath`'s | 3015 | // `std.testing.allocator` IS the assertion: `resolveKeyPath`'s `.missing` |
| 3270 | // `.missing` arm hands back an allocated path, and the refusal that | 3016 | // arm hands back an allocated path, and the refusal that does not keep it |
| 3271 | // does not keep it must free it. The two live callers pass arenas or a | 3017 | // must free it. The live callers pass arenas, so nothing else would say so. |
| 3272 | // long-lived allocator, so nothing else in the product would ever say | ||
| 3273 | // so out loud. | ||
| 3274 | var tmp = try TmpDir.make(); | 3018 | var tmp = try TmpDir.make(); |
| 3275 | defer tmp.cleanup(); | 3019 | defer tmp.cleanup(); |
| 3276 | 3020 | ||