e8d541ee
refactor: the client's start branch folds into one announce read
a73x 2026-08-29 14:38
Commit message
src/client/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -193,7 +193,8 @@ pub const HandoffTarget = struct { | |||
| 193 | ssh_argv: []const []const u8, | 193 | ssh_argv: []const []const u8, |
| 194 | /// `ssh <host> mux d endpoint --start`, from the same | 194 | /// `ssh <host> mux d endpoint --start`, from the same |
| 195 | /// `handoff.recipeFor` call. Empty is "nothing to start": the dial | 195 | /// `handoff.recipeFor` call. Empty is "nothing to start": the dial |
| 196 | /// either finds a daemon or does not. | 196 | /// either finds a daemon or does not, and `openHandoff` reads the |
| 197 | /// bare word instead rather than exec'ing an argv with no argv[0]. | ||
| 197 | asked_argv: []const []const u8 = &.{}, | 198 | asked_argv: []const []const u8 = &.{}, |
| 198 | /// Where the last announce is remembered. Null means never cache (an | 199 | /// Where the last announce is remembered. Null means never cache (an |
| 199 | /// uncacheable host, or no resolvable cache directory): every attach is | 200 | /// uncacheable host, or no resolvable cache directory): every attach is |
| @@ -204,8 +205,8 @@ pub const HandoffTarget = struct { | |||
| 204 | deadline_ms: u32 = handoff.deadline_ms, | 205 | deadline_ms: u32 = handoff.deadline_ms, |
| 205 | idle_ms: u32 = quic_idle_ms_default, | 206 | idle_ms: u32 = quic_idle_ms_default, |
| 206 | /// Whether a USER asked for this dial: the entry attach and a picker | 207 | /// Whether a USER asked for this dial: the entry attach and a picker |
| 207 | /// birth, never a poll, a reconnect or a restored tile. It gates the | 208 | /// birth, never a poll, a reconnect or a restored tile. The two things |
| 208 | /// two things a dial may do beyond dialling. | 209 | /// `asked` decides: |
| 209 | /// | 210 | /// |
| 210 | /// SAY SO when the session falls back to ssh. The line reports a | 211 | /// SAY SO when the session falls back to ssh. The line reports a |
| 211 | /// choice the user has not seen made, and a reconnect re-runs this | 212 | /// choice the user has not seen made, and a reconnect re-runs this |
| @@ -213,10 +214,12 @@ pub const HandoffTarget = struct { | |||
| 213 | /// a live session's stderr into the alternate screen and corrupt the | 214 | /// a live session's stderr into the alternate screen and corrupt the |
| 214 | /// paint, to say what the [reconnecting] banner is already saying. | 215 | /// paint, to say what the [reconnecting] banner is already saying. |
| 215 | /// | 216 | /// |
| 216 | /// START a daemon that was not there, via `asked_argv`. `mux d endpoint` | 217 | /// PICK THE ARGV — `asked_argv` rather than `ssh_argv`, unless there is |
| 217 | /// no longer does it: a wall polls every listed host once a second, and | 218 | /// no `asked_argv`, which is a target nothing can start from. The two |
| 218 | /// a poll that starts daemons undoes a `mux d stop` a second after it is | 219 | /// are the same ssh line one flag apart, so the far end decides whether |
| 219 | /// typed. | 220 | /// to start and answers in the same run; a wall polls every listed host |
| 221 | /// once a second, and its bare word cannot start anything to undo a | ||
| 222 | /// `mux d stop` with. | ||
| 220 | /// | 223 | /// |
| 221 | /// The trade, stated plainly: a session that degrades to ssh mid-life | 224 | /// The trade, stated plainly: a session that degrades to ssh mid-life |
| 222 | /// says nothing at all about it. The user who wants to know can detach | 225 | /// says nothing at all about it. The user who wants to know can detach |
| @@ -228,13 +231,13 @@ pub const HandoffTarget = struct { | |||
| 228 | /// start is a `mux HOST` that says so, while a spurious one is a daemon | 231 | /// start is a `mux HOST` that says so, while a spurious one is a daemon |
| 229 | /// on someone else's box that nothing reports at all. | 232 | /// on someone else's box that nothing reports at all. |
| 230 | asked: bool = false, | 233 | asked: bool = false, |
| 231 | /// Whether the caller owns a screen the start's progress would land on. | 234 | /// Whether the caller owns a screen the ssh's stderr would land on. The |
| 232 | /// `mux d start` writes `starting\u{2026}`, a dot per interval and an | 235 | /// remote's `mux d endpoint: starting\u{2026}`, a dot per interval and an |
| 233 | /// up-line to stderr; under the wall's alternate screen those bytes sit | 236 | /// up-line, ride that stderr, as do ssh's own diagnostics; under the |
| 234 | /// over tiles and rails, and the tile's own `connecting` label is the | 237 | /// wall's alternate screen those bytes sit over tiles and rails, and |
| 235 | /// narration there. False on the entry dial, which runs before any wall | 238 | /// the tile's own `connecting` label is the narration there. False on |
| 236 | /// exists and where the user is owed the progress of a wait they are | 239 | /// the entry dial, which runs before any wall exists and where the user |
| 237 | /// sitting through. | 240 | /// is owed the progress of a wait they are sitting through. |
| 238 | quiet: bool = false, | 241 | quiet: bool = false, |
| 239 | 242 | ||
| 240 | /// The recipe→target literal: a field added above is added here, not | 243 | /// The recipe→target literal: a field added above is added here, not |
| @@ -356,15 +359,20 @@ pub const Transport = struct { | |||
| 356 | /// `argv` need not outlive the call: `std.process.Child` copies it into | 359 | /// `argv` need not outlive the call: `std.process.Child` copies it into |
| 357 | /// its own arena before the fork. "spawnPipe: the child is exec'd from | 360 | /// its own arena before the fork. "spawnPipe: the child is exec'd from |
| 358 | /// a copy" is that claim, asserted. | 361 | /// a copy" is that claim, asserted. |
| 359 | fn spawnPipe(alloc: std.mem.Allocator, argv: []const []const u8) !std.process.Child { | 362 | fn spawnPipe(alloc: std.mem.Allocator, argv: []const []const u8, quiet: bool) !std.process.Child { |
| 360 | var child = std.process.Child.init(argv, alloc); | 363 | var child = std.process.Child.init(argv, alloc); |
| 361 | child.stdin_behavior = .Pipe; | 364 | child.stdin_behavior = .Pipe; |
| 362 | child.stdout_behavior = .Pipe; | 365 | child.stdout_behavior = .Pipe; |
| 363 | // Inherited, not piped: ssh's diagnostics (auth failure, unknown | 366 | // Inherited, not piped: ssh's diagnostics (auth failure, unknown |
| 364 | // host, connection refused), and `mux d endpoint`'s own one-liners, | 367 | // host, connection refused), and `mux d endpoint`'s own one-liners — |
| 365 | // are the user's only clue when the transport never comes up, and we | 368 | // its `starting\u{2026}` progress under `--start` included — are the |
| 366 | // would otherwise swallow them. | 369 | // user's only clue when the transport never comes up, and we would |
| 367 | child.stderr_behavior = .Inherit; | 370 | // otherwise swallow them. |
| 371 | // | ||
| 372 | // `quiet` is a caller with a full-screen wall saying it has nowhere | ||
| 373 | // for them to land; see `HandoffTarget.quiet`. stdout stays a pipe | ||
| 374 | // either way: it carries the announce, which is not narration. | ||
| 375 | child.stderr_behavior = if (quiet) .Ignore else .Inherit; | ||
| 368 | try child.spawn(); | 376 | try child.spawn(); |
| 369 | return child; | 377 | return child; |
| 370 | } | 378 | } |
| @@ -438,7 +446,7 @@ pub const Transport = struct { | |||
| 438 | .via => |cmd| { | 446 | .via => |cmd| { |
| 439 | const argv = try viaArgv(alloc, cmd); | 447 | const argv = try viaArgv(alloc, cmd); |
| 440 | defer alloc.free(argv); | 448 | defer alloc.free(argv); |
| 441 | return pipeTransport(try spawnPipe(alloc, argv)); | 449 | return pipeTransport(try spawnPipe(alloc, argv, false)); |
| 442 | }, | 450 | }, |
| 443 | .sock => |path| { | 451 | .sock => |path| { |
| 444 | const stream = try std.net.connectUnixSocket(path); | 452 | const stream = try std.net.connectUnixSocket(path); |
| @@ -483,7 +491,20 @@ pub const Transport = struct { | |||
| 483 | } else |_| {} // no cache yet, or one we cannot use: cold path | 491 | } else |_| {} // no cache yet, or one we cannot use: cold path |
| 484 | } | 492 | } |
| 485 | 493 | ||
| 486 | var child = try spawnPipe(alloc, h.ssh_argv); | 494 | // ONE run, and `asked` is the whole of what picks it. The asking |
| 495 | // word ensures a daemon on the far side and announces on the same | ||
| 496 | // stdout, so there is no refusal for this side to read, no exit | ||
| 497 | // code to tell from ssh's own 255, and no second dial. A read | ||
| 498 | // spells the bare word, which starts nothing — the rule now holds | ||
| 499 | // by argv rather than by a branch here. | ||
| 500 | // `and len > 0` is what keeps `asked_argv`'s doc true: empty means | ||
| 501 | // "nothing to start", and an empty argv is not a no-op at the exec | ||
| 502 | // — the forked child null-unwraps argv[0] and dies, which arrives | ||
| 503 | // here as an announce that never came. Falling back to the reading | ||
| 504 | // word makes such a target a dial that starts nothing, which is | ||
| 505 | // what the field says it is. | ||
| 506 | const argv = if (h.asked and h.asked_argv.len > 0) h.asked_argv else h.ssh_argv; | ||
| 507 | var child = try spawnPipe(alloc, argv, h.quiet); | ||
| 487 | errdefer { | 508 | errdefer { |
| 488 | _ = child.kill() catch {}; | 509 | _ = child.kill() catch {}; |
| 489 | } | 510 | } |
| @@ -501,7 +522,7 @@ pub const Transport = struct { | |||
| 501 | // carry is null by policy. Unconsumed type-ahead is not lost: it | 522 | // carry is null by policy. Unconsumed type-ahead is not lost: it |
| 502 | // waits in the kernel's tty buffer for the session's first read. | 523 | // waits in the kernel's tty buffer for the session's first read. |
| 503 | const announce_abort_fd: std.posix.fd_t = if (carry != null) -1 else abort_fd; | 524 | const announce_abort_fd: std.posix.fd_t = if (carry != null) -1 else abort_fd; |
| 504 | const ep = (try announceOrStart(alloc, h, &child, announce_abort_fd)) orelse { | 525 | const ep = (try readAnnounceAbortable(child.stdout.?.handle, alloc, null, announce_abort_fd)) orelse { |
| 505 | // `endpoint none`: the remote said, explicitly, that ssh is the | 526 | // `endpoint none`: the remote said, explicitly, that ssh is the |
| 506 | // session. Silent by design — no coordinates were ever in play, | 527 | // session. Silent by design — no coordinates were ever in play, |
| 507 | // so there is nothing here to report as having failed. | 528 | // so there is nothing here to report as having failed. |
| @@ -550,102 +571,6 @@ pub const Transport = struct { | |||
| 550 | } | 571 | } |
| 551 | } | 572 | } |
| 552 | 573 | ||
| 553 | /// One announce, or — for a dial the user ASKED for — a `mux d start` | ||
| 554 | /// on the far end and one more. | ||
| 555 | fn announceOrStart( | ||
| 556 | alloc: std.mem.Allocator, | ||
| 557 | h: HandoffTarget, | ||
| 558 | child: *std.process.Child, | ||
| 559 | abort_fd: std.posix.fd_t, | ||
| 560 | ) !?handoff.Endpoint { | ||
| 561 | return readAnnounceAbortable(child.stdout.?.handle, alloc, null, abort_fd) catch |err| { | ||
| 562 | // A remote with no daemon writes nothing and exits, which | ||
| 563 | // arrives here as an announce that never terminated. | ||
| 564 | // `announceFailed` is that "ssh worked, the announce did not" | ||
| 565 | // shape: a start is worth trying only when the far end was | ||
| 566 | // reached and had nothing to say. | ||
| 567 | if (!h.asked or h.asked_argv.len == 0 or !announceFailed(err)) return err; | ||
| 568 | // The remote's refusal, told apart from ssh's own by the code | ||
| 569 | // it exited with: `mux d endpoint` refuses an empty box with 1, | ||
| 570 | // and ssh reports its OWN failures as 255 while passing a | ||
| 571 | // remote code through. Both look identical from the announce — | ||
| 572 | // nothing arrived either way — so without this a typo'd host or | ||
| 573 | // a down box pays the connect timeout, or asks for the | ||
| 574 | // password, twice on the entry dial. `reapAnnounce` waits, which | ||
| 575 | // is also what reaps the child before the respawn below. | ||
| 576 | switch (reapAnnounce(child) catch return err) { | ||
| 577 | .Exited => |code| if (code != 1) return err, | ||
| 578 | else => return err, | ||
| 579 | } | ||
| 580 | // A start that did not take is reported as the announce failure | ||
| 581 | // it began as: `mux d start` has already said its own piece on | ||
| 582 | // stderr, and a second guess over the top of it would be worse. | ||
| 583 | if (!runStart(alloc, h.asked_argv, h.quiet)) return err; | ||
| 584 | child.* = try spawnPipe(alloc, h.ssh_argv); | ||
| 585 | // ONCE. A box that cannot hold a daemon costs this attach one | ||
| 586 | // extra round trip; a loop would cost it the afternoon. | ||
| 587 | return readAnnounceAbortable(child.stdout.?.handle, alloc, null, abort_fd); | ||
| 588 | }; | ||
| 589 | } | ||
| 590 | |||
| 591 | /// How long the coordination ssh may take to finish on its own after the | ||
| 592 | /// announce failed, before a TERM ends it. Not the announce's own budget: | ||
| 593 | /// this begins once the read has already given up. | ||
| 594 | const announce_reap_ms: u64 = 1000; | ||
| 595 | const announce_reap_step_ms: u64 = 5; | ||
| 596 | |||
| 597 | /// The ssh's OWN verdict — waited for, not forced. | ||
| 598 | fn reapAnnounce(child: *std.process.Child) !std.process.Child.Term { | ||
| 599 | // `Child.kill` signals whenever the child is unreaped, so reading an | ||
| 600 | // exit code with it kills the ssh that is merely still tearing down — | ||
| 601 | // stdout hits EOF when the channel closes, and the exit-status and | ||
| 602 | // any ControlMaster or ProxyJump hop come after. The verdict is then | ||
| 603 | // `.Signal`, which the caller reads as "do not start": `mux HOST` | ||
| 604 | // failing on precisely the box `mux d start` exists for. | ||
| 605 | var waited: u64 = 0; | ||
| 606 | while (waited < announce_reap_ms) : (waited += announce_reap_step_ms) { | ||
| 607 | const res = std.posix.waitpid(child.id, std.posix.W.NOHANG); | ||
| 608 | if (res.pid == child.id) { | ||
| 609 | // Recorded so `kill` takes its already-terminated path: a | ||
| 610 | // second waitpid on a reaped pid is ECHILD at best, and a | ||
| 611 | // TERM at a recycled pid at worst. | ||
| 612 | child.term = termOf(res.status); | ||
| 613 | break; | ||
| 614 | } | ||
| 615 | std.Thread.sleep(announce_reap_step_ms * std.time.ns_per_ms); | ||
| 616 | } | ||
| 617 | // Capped, not a bare `wait()`: the announce also fails with the child | ||
| 618 | // still genuinely running and chatty — a line too long, a reply that | ||
| 619 | // never parsed — and TERM is the only bound those have. | ||
| 620 | return child.kill(); | ||
| 621 | } | ||
| 622 | |||
| 623 | /// `std.process.Child`'s own status decoder is private, and `waitpid` | ||
| 624 | /// hands back the raw status. | ||
| 625 | fn termOf(status: u32) std.process.Child.Term { | ||
| 626 | if (std.posix.W.IFEXITED(status)) return .{ .Exited = std.posix.W.EXITSTATUS(status) }; | ||
| 627 | if (std.posix.W.IFSIGNALED(status)) return .{ .Signal = std.posix.W.TERMSIG(status) }; | ||
| 628 | if (std.posix.W.IFSTOPPED(status)) return .{ .Stopped = std.posix.W.STOPSIG(status) }; | ||
| 629 | return .{ .Unknown = status }; | ||
| 630 | } | ||
| 631 | |||
| 632 | /// Waits: nothing may dial the far end again until it has a verdict. | ||
| 633 | fn runStart(alloc: std.mem.Allocator, argv: []const []const u8, quiet: bool) bool { | ||
| 634 | var c = std.process.Child.init(argv, alloc); | ||
| 635 | // stdout and stderr inherited, like `spawnPipe`'s ssh: the progress | ||
| 636 | // and the verdict are the user's only account of a wait they are | ||
| 637 | // sitting through. stdin is NOT — ssh would eat the keystrokes the | ||
| 638 | // session is about to want, and its prompts read /dev/tty anyway. | ||
| 639 | c.stdin_behavior = .Ignore; | ||
| 640 | // ...unless the caller has a screen those bytes would land on. | ||
| 641 | if (quiet) { | ||
| 642 | c.stdout_behavior = .Ignore; | ||
| 643 | c.stderr_behavior = .Ignore; | ||
| 644 | } | ||
| 645 | const term = c.spawnAndWait() catch return false; | ||
| 646 | return term == .Exited and term.Exited == 0; | ||
| 647 | } | ||
| 648 | |||
| 649 | /// The dial's budget is the handoff deadline while the CONNECTION keeps | 574 | /// The dial's budget is the handoff deadline while the CONNECTION keeps |
| 650 | /// the ordinary `idle_ms`; `quicTransport` spells that split. | 575 | /// the ordinary `idle_ms`; `quicTransport` spells that split. |
| 651 | pub fn openQuicEndpoint( | 576 | pub fn openQuicEndpoint( |
| @@ -1622,7 +1547,7 @@ test "spawnPipe: the child is exec'd from a copy — an argv freed after spawn s | |||
| 1622 | const argv = try alloc.alloc([]const u8, 2); | 1547 | const argv = try alloc.alloc([]const u8, 2); |
| 1623 | argv[0] = try alloc.dupe(u8, "/bin/echo"); | 1548 | argv[0] = try alloc.dupe(u8, "/bin/echo"); |
| 1624 | argv[1] = try alloc.dupe(u8, "copied"); | 1549 | argv[1] = try alloc.dupe(u8, "copied"); |
| 1625 | var child = try Transport.spawnPipe(alloc, argv); | 1550 | var child = try Transport.spawnPipe(alloc, argv, false); |
| 1626 | for (argv) |w| alloc.free(w); | 1551 | for (argv) |w| alloc.free(w); |
| 1627 | alloc.free(argv); | 1552 | alloc.free(argv); |
| 1628 | // Reuse the freed pages before reading, so a std that kept the pointer | 1553 | // Reuse the freed pages before reading, so a std that kept the pointer |
| @@ -1917,14 +1842,13 @@ test "handoff: abort_fd -1 means no abort channel — fd 0 is never read" { | |||
| 1917 | try std.testing.expectEqualStrings("", carry.items); | 1842 | try std.testing.expectEqualStrings("", carry.items); |
| 1918 | } | 1843 | } |
| 1919 | 1844 | ||
| 1920 | /// How many times the ssh shim below ran: it appends one two-byte line per | 1845 | /// How many times the ssh fake below ran. By NEWLINES, not by size: the |
| 1921 | /// run, so the file's size is the count. | 1846 | /// fake logs the WORD it was handed, so its lines have two lengths. |
| 1922 | fn shimRuns(dir: []const u8, name: []const u8) !u64 { | 1847 | fn shimRuns(dir: []const u8, name: []const u8) !u64 { |
| 1923 | var buf: [512]u8 = undefined; | 1848 | var buf: [4096]u8 = undefined; |
| 1924 | const path = try std.fmt.bufPrint(&buf, "{s}/{s}", .{ dir, name }); | 1849 | const said = shimSaid(dir, name, &buf) catch return 0; |
| 1925 | const f = std.fs.cwd().openFile(path, .{}) catch return 0; | 1850 | if (said.len == 0) return 0; |
| 1926 | defer f.close(); | 1851 | return std.mem.count(u8, said, "\n") + 1; |
| 1927 | return (try f.stat()).size / 2; | ||
| 1928 | } | 1852 | } |
| 1929 | 1853 | ||
| 1930 | /// One line the shim wrote, newline trimmed. | 1854 | /// One line the shim wrote, newline trimmed. |
| @@ -1944,11 +1868,62 @@ fn shimMade(dir: []const u8, name: []const u8) !bool { | |||
| 1944 | return true; | 1868 | return true; |
| 1945 | } | 1869 | } |
| 1946 | 1870 | ||
| 1947 | test "openHandoff: a HandoffTarget nobody configured starts nothing" { | 1871 | /// The two remote words `handoff.recipeFor` builds, as the fakes below are |
| 1948 | // The default is the value a NEW path inherits by forgetting the line, | 1872 | /// handed them. Spelled out rather than imported so that a test which |
| 1949 | // and nothing fails loudly when it does: the symptom is a daemon (and a | 1873 | /// believes the client ran the asking word is reading a literal, not the |
| 1950 | // shell in session 0) appearing on someone else's box. So the default | 1874 | /// same expression the product built it from. |
| 1951 | // is the harmless half, and the ask is what has to be written down. | 1875 | const read_word = "mux d endpoint"; |
| 1876 | const asked_word = "mux d endpoint --start"; | ||
| 1877 | |||
| 1878 | /// The fake box: ONE script behind both argvs, so only the WORD differs, | ||
| 1879 | /// as on the wire. `--start` leaves a daemon; without one there is | ||
| 1880 | /// nothing to announce and it exits 1 in silence. | ||
| 1881 | fn boxScript(buf: []u8, dir: []const u8) ![]const u8 { | ||
| 1882 | return std.fmt.bufPrint(buf, | ||
| 1883 | \\echo "$*" >> {[d]s}/runs | ||
| 1884 | \\case "$*" in *--start*) : > {[d]s}/started ;; esac | ||
| 1885 | \\test -e {[d]s}/started || exit 1 | ||
| 1886 | \\printf 'endpoint none\n' | ||
| 1887 | \\cat >/dev/null | ||
| 1888 | , .{ .d = dir }); | ||
| 1889 | } | ||
| 1890 | |||
| 1891 | /// The dial must not have reached a daemon, said so by `claim`. NOT | ||
| 1892 | /// `expectError`: it renders the success value with `{any}`, and a live | ||
| 1893 | /// `Transport` holds an allocator vtable formatting FAULTS on — the runner | ||
| 1894 | /// dies inside the message and names no test. The session it should not | ||
| 1895 | /// have is closed here; it owns a child. | ||
| 1896 | fn expectNoSession(claim: []const u8, r: anytype) !void { | ||
| 1897 | if (r) |t| { | ||
| 1898 | var live = t; | ||
| 1899 | live.close(); | ||
| 1900 | std.debug.print("{s}\n", .{claim}); | ||
| 1901 | return error.TestUnexpectedResult; | ||
| 1902 | } else |err| { | ||
| 1903 | if (err == error.UnterminatedLine) return; | ||
| 1904 | std.debug.print("{s} — got {s}, want UnterminatedLine\n", .{ claim, @errorName(err) }); | ||
| 1905 | return err; | ||
| 1906 | } | ||
| 1907 | } | ||
| 1908 | |||
| 1909 | /// A box that cannot hold a daemon — a full disk, a broken shell, a `mux` | ||
| 1910 | /// too old to know the flag. It records the word and refuses either way. | ||
| 1911 | fn refusingBoxScript(buf: []u8, dir: []const u8) ![]const u8 { | ||
| 1912 | return std.fmt.bufPrint(buf, | ||
| 1913 | \\echo "$*" >> {[d]s}/runs | ||
| 1914 | \\exit 1 | ||
| 1915 | , .{ .d = dir }); | ||
| 1916 | } | ||
| 1917 | |||
| 1918 | test "openHandoff: a HandoffTarget nobody configured runs the reading word, never the asking one" { | ||
| 1919 | // The default is the value a NEW dial path inherits by forgetting the | ||
| 1920 | // line, and nothing fails loudly when it does: the symptom is a daemon | ||
| 1921 | // (and a shell in session 0) appearing on someone else's box. So the | ||
| 1922 | // default is the harmless half, and the ask is what has to be written | ||
| 1923 | // down. | ||
| 1924 | // | ||
| 1925 | // `asked` picks the ARGV now, so the oracle is the word the fake was | ||
| 1926 | // handed — a fact about what ran, not about what the client meant. | ||
| 1952 | const alloc = std.testing.allocator; | 1927 | const alloc = std.testing.allocator; |
| 1953 | var carry: std.ArrayList(u8) = .empty; | 1928 | var carry: std.ArrayList(u8) = .empty; |
| 1954 | defer carry.deinit(alloc); | 1929 | defer carry.deinit(alloc); |
| @@ -1957,30 +1932,29 @@ test "openHandoff: a HandoffTarget nobody configured starts nothing" { | |||
| 1957 | var tmp = try TmpDir.make(); | 1932 | var tmp = try TmpDir.make(); |
| 1958 | defer tmp.cleanup(); | 1933 | defer tmp.cleanup(); |
| 1959 | 1934 | ||
| 1960 | var ssh_buf: [512]u8 = undefined; | 1935 | var script_buf: [1024]u8 = undefined; |
| 1961 | const ssh_cmd = try std.fmt.bufPrint(&ssh_buf, "echo x >>{s}/runs; exit 1", .{tmp.path()}); | 1936 | const script = try boxScript(&script_buf, tmp.path()); |
| 1962 | var start_buf: [512]u8 = undefined; | ||
| 1963 | const start_cmd = try std.fmt.bufPrint(&start_buf, "touch {s}/started", .{tmp.path()}); | ||
| 1964 | 1937 | ||
| 1965 | try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ | 1938 | try expectNoSession("a dial nobody configured reached a daemon: it ran the asking word", Transport.open(alloc, .{ .hand = .{ |
| 1966 | .host = "fake", | 1939 | .host = "fake", |
| 1967 | .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, | 1940 | .ssh_argv = &.{ "/bin/sh", "-c", script, "sh", read_word }, |
| 1968 | .asked_argv = &.{ "/bin/sh", "-c", start_cmd }, | 1941 | .asked_argv = &.{ "/bin/sh", "-c", script, "sh", asked_word }, |
| 1969 | .cache_path = null, | 1942 | .cache_path = null, |
| 1970 | .deadline_ms = 200, | 1943 | .deadline_ms = 200, |
| 1971 | } }, &carry, std.posix.STDIN_FILENO)); | 1944 | } }, &carry, std.posix.STDIN_FILENO)); |
| 1972 | 1945 | ||
| 1973 | try std.testing.expect(!try shimMade(tmp.path(), "started")); | 1946 | try std.testing.expect(!try shimMade(tmp.path(), "started")); |
| 1974 | try std.testing.expectEqual(@as(u64, 1), try shimRuns(tmp.path(), "runs")); | 1947 | // The whole log, not a count: one line, and it is the reading word. |
| 1948 | var buf: [512]u8 = undefined; | ||
| 1949 | try std.testing.expectEqualStrings(read_word, try shimSaid(tmp.path(), "runs", &buf)); | ||
| 1975 | } | 1950 | } |
| 1976 | 1951 | ||
| 1977 | test "openHandoff: the dial a user ASKED for starts the daemon it did not find, and re-runs the ssh line exactly once" { | 1952 | test "openHandoff: the dial a user ASKED for runs the asking word ONCE and rides the announce that comes back on it" { |
| 1978 | // `mux d endpoint` on a box with no daemon exits 1 having written | 1953 | // Three ssh runs became one, and this is the claim that says so. The |
| 1979 | // nothing, which arrives here as an announce that never terminated. | 1954 | // remote ensures the daemon and announces on the same stdout, so there |
| 1980 | // The shim is that box: the first run finds no `started` flag and | 1955 | // is no refusal for the client to read, no exit code to tell from |
| 1981 | // fails the same way; the start command drops the flag; the second run | 1956 | // ssh's own 255, and no second dial to pay a connect timeout or a |
| 1982 | // announces. Asked of the FILESYSTEM, never of the client, because | 1957 | // password prompt for. |
| 1983 | // "was a daemon started" is a question about the world. | ||
| 1984 | const alloc = std.testing.allocator; | 1958 | const alloc = std.testing.allocator; |
| 1985 | var carry: std.ArrayList(u8) = .empty; | 1959 | var carry: std.ArrayList(u8) = .empty; |
| 1986 | defer carry.deinit(alloc); | 1960 | defer carry.deinit(alloc); |
| @@ -1989,34 +1963,34 @@ test "openHandoff: the dial a user ASKED for starts the daemon it did not find, | |||
| 1989 | var tmp = try TmpDir.make(); | 1963 | var tmp = try TmpDir.make(); |
| 1990 | defer tmp.cleanup(); | 1964 | defer tmp.cleanup(); |
| 1991 | 1965 | ||
| 1992 | var ssh_buf: [512]u8 = undefined; | 1966 | var script_buf: [1024]u8 = undefined; |
| 1993 | const ssh_cmd = try std.fmt.bufPrint( | 1967 | const script = try boxScript(&script_buf, tmp.path()); |
| 1994 | &ssh_buf, | ||
| 1995 | "echo x >>{s}/runs; test -e {s}/started || exit 1; printf 'endpoint none\n'; cat >/dev/null", | ||
| 1996 | .{ tmp.path(), tmp.path() }, | ||
| 1997 | ); | ||
| 1998 | var start_buf: [512]u8 = undefined; | ||
| 1999 | const start_cmd = try std.fmt.bufPrint(&start_buf, "touch {s}/started", .{tmp.path()}); | ||
| 2000 | 1968 | ||
| 2001 | var t = try Transport.open(alloc, .{ .hand = .{ | 1969 | var t = try Transport.open(alloc, .{ .hand = .{ |
| 2002 | .host = "fake", | 1970 | .host = "fake", |
| 2003 | .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, | 1971 | .ssh_argv = &.{ "/bin/sh", "-c", script, "sh", read_word }, |
| 2004 | .asked_argv = &.{ "/bin/sh", "-c", start_cmd }, | 1972 | .asked_argv = &.{ "/bin/sh", "-c", script, "sh", asked_word }, |
| 2005 | .cache_path = null, | 1973 | .cache_path = null, |
| 2006 | .deadline_ms = 200, | 1974 | .deadline_ms = 200, |
| 2007 | .asked = true, | 1975 | .asked = true, |
| 2008 | } }, &carry, std.posix.STDIN_FILENO); | 1976 | } }, &carry, std.posix.STDIN_FILENO); |
| 2009 | defer t.close(); | 1977 | defer t.close(); |
| 2010 | 1978 | ||
| 1979 | // `endpoint none` is a real announce: the remote says ssh IS the | ||
| 1980 | // session, and the run that started the daemon is the run carrying it. | ||
| 2011 | try std.testing.expect(t.link == .pipe); | 1981 | try std.testing.expect(t.link == .pipe); |
| 1982 | // Asked of the FILESYSTEM. "Was a daemon started" is a question about | ||
| 1983 | // the world, and the client is not a witness to it. | ||
| 2012 | try std.testing.expect(try shimMade(tmp.path(), "started")); | 1984 | try std.testing.expect(try shimMade(tmp.path(), "started")); |
| 2013 | try std.testing.expectEqual(@as(u64, 2), try shimRuns(tmp.path(), "runs")); | 1985 | var buf: [512]u8 = undefined; |
| 1986 | try std.testing.expectEqualStrings(asked_word, try shimSaid(tmp.path(), "runs", &buf)); | ||
| 2014 | } | 1987 | } |
| 2015 | 1988 | ||
| 2016 | test "openHandoff: a dial nobody asked for reports the failure and starts nothing — the wall polls every listed host once a second" { | 1989 | test "openHandoff: an asked dial whose box still announces nothing fails after that ONE run" { |
| 2017 | // The regression this exists for: a poll that starts a daemon gives a | 1990 | // A box that refuses to hold a daemon used to cost the attach an extra |
| 2018 | // listed box one (and a shell in session 0) from a READ, and undoes a | 1991 | // round trip — the ask bought one start and one retry. It buys neither |
| 2019 | // `mux d stop` on the far end a second after it is typed. | 1992 | // now: there is nothing left for a second run to learn, because the |
| 1993 | // run that could have started a daemon already tried. | ||
| 2020 | const alloc = std.testing.allocator; | 1994 | const alloc = std.testing.allocator; |
| 2021 | var carry: std.ArrayList(u8) = .empty; | 1995 | var carry: std.ArrayList(u8) = .empty; |
| 2022 | defer carry.deinit(alloc); | 1996 | defer carry.deinit(alloc); |
| @@ -2025,66 +1999,32 @@ test "openHandoff: a dial nobody asked for reports the failure and starts nothin | |||
| 2025 | var tmp = try TmpDir.make(); | 1999 | var tmp = try TmpDir.make(); |
| 2026 | defer tmp.cleanup(); | 2000 | defer tmp.cleanup(); |
| 2027 | 2001 | ||
| 2028 | var ssh_buf: [512]u8 = undefined; | 2002 | var script_buf: [1024]u8 = undefined; |
| 2029 | const ssh_cmd = try std.fmt.bufPrint(&ssh_buf, "echo x >>{s}/runs; exit 1", .{tmp.path()}); | 2003 | const script = try refusingBoxScript(&script_buf, tmp.path()); |
| 2030 | var start_buf: [512]u8 = undefined; | ||
| 2031 | const start_cmd = try std.fmt.bufPrint(&start_buf, "touch {s}/started", .{tmp.path()}); | ||
| 2032 | 2004 | ||
| 2033 | try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ | 2005 | try expectNoSession("a box that announces nothing gave the asked dial a session", Transport.open(alloc, .{ .hand = .{ |
| 2034 | .host = "fake", | 2006 | .host = "fake", |
| 2035 | .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, | 2007 | .ssh_argv = &.{ "/bin/sh", "-c", script, "sh", read_word }, |
| 2036 | .asked_argv = &.{ "/bin/sh", "-c", start_cmd }, | 2008 | .asked_argv = &.{ "/bin/sh", "-c", script, "sh", asked_word }, |
| 2037 | .cache_path = null, | 2009 | .cache_path = null, |
| 2038 | .deadline_ms = 200, | 2010 | .deadline_ms = 200, |
| 2039 | .asked = false, | 2011 | .asked = true, |
| 2040 | } }, &carry, std.posix.STDIN_FILENO)); | 2012 | } }, &carry, std.posix.STDIN_FILENO)); |
| 2041 | 2013 | ||
| 2042 | try std.testing.expect(!try shimMade(tmp.path(), "started")); | 2014 | // ONE line, and the asking word: a client that kept a retry would show |
| 2015 | // two, and a client that fell back to the reading word would show the | ||
| 2016 | // wrong one. | ||
| 2043 | try std.testing.expectEqual(@as(u64, 1), try shimRuns(tmp.path(), "runs")); | 2017 | try std.testing.expectEqual(@as(u64, 1), try shimRuns(tmp.path(), "runs")); |
| 2018 | var buf: [512]u8 = undefined; | ||
| 2019 | try std.testing.expectEqualStrings(asked_word, try shimSaid(tmp.path(), "runs", &buf)); | ||
| 2044 | } | 2020 | } |
| 2045 | 2021 | ||
| 2046 | test "openHandoff: an ssh still alive after its stdout closed keeps its own exit code" { | 2022 | test "openHandoff: a dial nobody asked for, against a box with nothing, reports the failure and ran the bare word only" { |
| 2047 | // The dimension the other three hold constant: their shim is dead by the | 2023 | // The regression this exists for: the wall polls every listed host once |
| 2048 | // time the announce fails. A real ssh is not — the channel closes, then | 2024 | // a second, and a poll that started a daemon gave a listed box one (and |
| 2049 | // the exit-status arrives, then a ControlMaster or ProxyJump hop tears | 2025 | // a shell in session 0) from a READ — undoing a `mux d stop` on the far |
| 2050 | // down — and a SIGTERM into that window makes the verdict `.Signal`, | 2026 | // end a second after it was typed. The rule is argv's now, so the log |
| 2051 | // which cancels the start on exactly the box the start exists for. | 2027 | // of what ran IS the proof. |
| 2052 | const alloc = std.testing.allocator; | ||
| 2053 | var carry: std.ArrayList(u8) = .empty; | ||
| 2054 | defer carry.deinit(alloc); | ||
| 2055 | var stdin = try FakeStdin.install(""); | ||
| 2056 | defer stdin.deinit(); | ||
| 2057 | var tmp = try TmpDir.make(); | ||
| 2058 | defer tmp.cleanup(); | ||
| 2059 | |||
| 2060 | var ssh_buf: [512]u8 = undefined; | ||
| 2061 | const ssh_cmd = try std.fmt.bufPrint( | ||
| 2062 | &ssh_buf, | ||
| 2063 | "echo x >>{s}/runs; test -e {s}/started || {{ exec 1>&-; sleep 0.2; exit 1; }}; printf 'endpoint none\n'; cat >/dev/null", | ||
| 2064 | .{ tmp.path(), tmp.path() }, | ||
| 2065 | ); | ||
| 2066 | var start_buf: [512]u8 = undefined; | ||
| 2067 | const start_cmd = try std.fmt.bufPrint(&start_buf, "touch {s}/started", .{tmp.path()}); | ||
| 2068 | |||
| 2069 | var t = try Transport.open(alloc, .{ .hand = .{ | ||
| 2070 | .host = "fake", | ||
| 2071 | .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, | ||
| 2072 | .asked_argv = &.{ "/bin/sh", "-c", start_cmd }, | ||
| 2073 | .cache_path = null, | ||
| 2074 | .deadline_ms = 200, | ||
| 2075 | .asked = true, | ||
| 2076 | } }, &carry, std.posix.STDIN_FILENO); | ||
| 2077 | defer t.close(); | ||
| 2078 | |||
| 2079 | try std.testing.expect(try shimMade(tmp.path(), "started")); | ||
| 2080 | try std.testing.expectEqual(@as(u64, 2), try shimRuns(tmp.path(), "runs")); | ||
| 2081 | } | ||
| 2082 | |||
| 2083 | test "openHandoff: a start that does not help is tried once — a second announce, never a third" { | ||
| 2084 | // A box that refuses to hold a daemon (a bad shell, a full disk) must | ||
| 2085 | // cost the attach one extra round trip, not a loop of them: the ask | ||
| 2086 | // buys ONE start and ONE retry, and the original announce error is | ||
| 2087 | // what the user is told about. | ||
| 2088 | const alloc = std.testing.allocator; | 2028 | const alloc = std.testing.allocator; |
| 2089 | var carry: std.ArrayList(u8) = .empty; | 2029 | var carry: std.ArrayList(u8) = .empty; |
| 2090 | defer carry.deinit(alloc); | 2030 | defer carry.deinit(alloc); |
| @@ -2093,52 +2033,34 @@ test "openHandoff: a start that does not help is tried once — a second announc | |||
| 2093 | var tmp = try TmpDir.make(); | 2033 | var tmp = try TmpDir.make(); |
| 2094 | defer tmp.cleanup(); | 2034 | defer tmp.cleanup(); |
| 2095 | 2035 | ||
| 2096 | var ssh_buf: [512]u8 = undefined; | 2036 | var script_buf: [1024]u8 = undefined; |
| 2097 | const ssh_cmd = try std.fmt.bufPrint(&ssh_buf, "echo x >>{s}/runs; exit 1", .{tmp.path()}); | 2037 | const script = try boxScript(&script_buf, tmp.path()); |
| 2098 | var start_buf: [512]u8 = undefined; | ||
| 2099 | const start_cmd = try std.fmt.bufPrint(&start_buf, "touch {s}/started", .{tmp.path()}); | ||
| 2100 | 2038 | ||
| 2101 | const h: HandoffTarget = .{ | 2039 | try expectNoSession("a poll reached a daemon on an empty box: it ran the asking word", Transport.open(alloc, .{ .hand = .{ |
| 2102 | .host = "fake", | 2040 | .host = "fake", |
| 2103 | .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, | 2041 | .ssh_argv = &.{ "/bin/sh", "-c", script, "sh", read_word }, |
| 2104 | .asked_argv = &.{ "/bin/sh", "-c", start_cmd }, | 2042 | .asked_argv = &.{ "/bin/sh", "-c", script, "sh", asked_word }, |
| 2105 | .cache_path = null, | 2043 | .cache_path = null, |
| 2106 | .deadline_ms = 200, | 2044 | .deadline_ms = 200, |
| 2107 | .asked = true, | 2045 | .asked = false, |
| 2108 | }; | 2046 | } }, &carry, std.posix.STDIN_FILENO)); |
| 2109 | try std.testing.expectError( | ||
| 2110 | error.UnterminatedLine, | ||
| 2111 | Transport.open(alloc, .{ .hand = h }, &carry, std.posix.STDIN_FILENO), | ||
| 2112 | ); | ||
| 2113 | 2047 | ||
| 2114 | try std.testing.expect(try shimMade(tmp.path(), "started")); | ||
| 2115 | try std.testing.expectEqual(@as(u64, 2), try shimRuns(tmp.path(), "runs")); | ||
| 2116 | |||
| 2117 | // The SECOND dial, with the value a pump holds once it has spent the | ||
| 2118 | // ask — `wall_pump.pumpTile` clears this bit after the dial that got the | ||
| 2119 | // link, and hands the cleared copy to every `redial`. Without that, a | ||
| 2120 | // tile whose box was stopped would restart the daemon on every backoff | ||
| 2121 | // for as long as it lived: the poll's bug, moved onto a tile. | ||
| 2122 | var again = h; | ||
| 2123 | again.asked = false; | ||
| 2124 | var start_path: [512]u8 = undefined; | ||
| 2125 | try std.fs.cwd().deleteFile(try std.fmt.bufPrint(&start_path, "{s}/started", .{tmp.path()})); | ||
| 2126 | try std.testing.expectError( | ||
| 2127 | error.UnterminatedLine, | ||
| 2128 | Transport.open(alloc, .{ .hand = again }, &carry, std.posix.STDIN_FILENO), | ||
| 2129 | ); | ||
| 2130 | try std.testing.expect(!try shimMade(tmp.path(), "started")); | 2048 | try std.testing.expect(!try shimMade(tmp.path(), "started")); |
| 2131 | // Three, not four: the second dial ran the ssh line and stopped. | 2049 | var buf: [512]u8 = undefined; |
| 2132 | try std.testing.expectEqual(@as(u64, 3), try shimRuns(tmp.path(), "runs")); | 2050 | try std.testing.expectEqualStrings(read_word, try shimSaid(tmp.path(), "runs", &buf)); |
| 2133 | } | 2051 | } |
| 2134 | 2052 | ||
| 2135 | test "openHandoff: ssh's own failure is not a box without a daemon — no start, and the timeout is paid once" { | 2053 | test "openHandoff: an asked target with no asking argv runs the reading word, and starts nothing" { |
| 2136 | // `announceFailed` cannot tell the two apart: a `mux d endpoint` that | 2054 | // `asked_argv` defaults to empty and its doc says empty means "nothing |
| 2137 | // refused (exit 1, no output) and an ssh that never got there (exit | 2055 | // to start". Nothing in the type stops a caller setting `asked` on such |
| 2138 | // 255, no output) both arrive as an announce that never terminated. | 2056 | // a target — and an empty argv is not a no-op at the exec: the forked |
| 2139 | // The exit code can, and it has to: a typo'd host, a down box or a | 2057 | // child null-unwraps `argv[0]` and dies, which reaches the parent as an |
| 2140 | // wrong password would otherwise pay the connect timeout — or ask for | 2058 | // announce that never terminated. Indistinguishable, from the outside, |
| 2141 | // the password — TWICE on the entry dial. | 2059 | // from a box that had no daemon. |
| 2060 | // | ||
| 2061 | // `fromRecipe` is the only production builder and it always fills the | ||
| 2062 | // field, so this pins the DOC rather than a live path: the fallback is | ||
| 2063 | // what keeps the sentence above true as the type is used later. | ||
| 2142 | const alloc = std.testing.allocator; | 2064 | const alloc = std.testing.allocator; |
| 2143 | var carry: std.ArrayList(u8) = .empty; | 2065 | var carry: std.ArrayList(u8) = .empty; |
| 2144 | defer carry.deinit(alloc); | 2066 | defer carry.deinit(alloc); |
| @@ -2147,78 +2069,78 @@ test "openHandoff: ssh's own failure is not a box without a daemon — no start, | |||
| 2147 | var tmp = try TmpDir.make(); | 2069 | var tmp = try TmpDir.make(); |
| 2148 | defer tmp.cleanup(); | 2070 | defer tmp.cleanup(); |
| 2149 | 2071 | ||
| 2150 | var ssh_buf: [512]u8 = undefined; | 2072 | var script_buf: [1024]u8 = undefined; |
| 2151 | const ssh_cmd = try std.fmt.bufPrint(&ssh_buf, "echo x >>{s}/runs; exit 255", .{tmp.path()}); | 2073 | const script = try boxScript(&script_buf, tmp.path()); |
| 2152 | var start_buf: [512]u8 = undefined; | ||
| 2153 | const start_cmd = try std.fmt.bufPrint(&start_buf, "touch {s}/started", .{tmp.path()}); | ||
| 2154 | 2074 | ||
| 2155 | try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ | 2075 | try expectNoSession("an asked target with no asking argv did not fall back to the reading word", Transport.open(alloc, .{ .hand = .{ |
| 2156 | .host = "fake", | 2076 | .host = "fake", |
| 2157 | .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, | 2077 | .ssh_argv = &.{ "/bin/sh", "-c", script, "sh", read_word }, |
| 2158 | .asked_argv = &.{ "/bin/sh", "-c", start_cmd }, | ||
| 2159 | .cache_path = null, | 2078 | .cache_path = null, |
| 2160 | .deadline_ms = 200, | 2079 | .deadline_ms = 200, |
| 2161 | .asked = true, | 2080 | .asked = true, |
| 2162 | } }, &carry, std.posix.STDIN_FILENO)); | 2081 | } }, &carry, std.posix.STDIN_FILENO)); |
| 2163 | 2082 | ||
| 2083 | // The reading word ran, once, and no daemon came of it: an exec that | ||
| 2084 | // died on a null argv[0] would have left this log EMPTY. | ||
| 2164 | try std.testing.expect(!try shimMade(tmp.path(), "started")); | 2085 | try std.testing.expect(!try shimMade(tmp.path(), "started")); |
| 2165 | try std.testing.expectEqual(@as(u64, 1), try shimRuns(tmp.path(), "runs")); | 2086 | var buf: [512]u8 = undefined; |
| 2087 | try std.testing.expectEqualStrings(read_word, try shimSaid(tmp.path(), "runs", &buf)); | ||
| 2166 | } | 2088 | } |
| 2167 | 2089 | ||
| 2168 | test "runStart: a caller that owns a screen gets a quiet start; one that does not sees the progress" { | 2090 | test "openHandoff: `quiet` keeps every handoff ssh's stderr off a caller that owns the screen" { |
| 2169 | // `mux d start` writes `starting…`, a dot per interval and an up-line to | 2091 | // The remote's `mux d endpoint: starting\u{2026}` progress, and ssh's own |
| 2170 | // stderr. On the entry dial that is a wait the user is sitting through | 2092 | // diagnostics, are a wait the entry dial's user is sitting through and |
| 2171 | // and is owed; under the wall's alternate screen the same bytes land | 2093 | // is owed. Under the wall's alternate screen the same bytes land over |
| 2172 | // over tiles and rails, and the tile's own `connecting` label is the | 2094 | // tiles and rails, and the tile's `connecting` label is the narration |
| 2173 | // narration there. | 2095 | // there. |
| 2096 | // | ||
| 2097 | // The fake records where its stderr POINTED, off `/proc/$$/fd/2` — the | ||
| 2098 | // shell's own link, not the redirect's — so "inherited" is exact rather | ||
| 2099 | // than a guess about what a test runner's stderr happens to be. fd 2 | ||
| 2100 | // and never fd 1: a byte on the runner's stdout wedges `zig build test` | ||
| 2101 | // silently, at 0 CPU, with no output at all. | ||
| 2174 | // | 2102 | // |
| 2175 | // The shim records where its stdout and stderr POINTED, read off | 2103 | // BOTH values of `asked`, because the rule is the spawn's and not the |
| 2176 | // `/proc/$$/fd/N` — the shell's own, not the redirect's — so the | 2104 | // ask's: a picker-born tile clears `asked` for every redial and keeps |
| 2177 | // control is exact rather than a guess about what a test runner's | 2105 | // `quiet`, so the redial is the half that runs for as long as the tile |
| 2178 | // stdout happens to be: inherited means "the same link this process | 2106 | // lives. A rule pinned only for the asked run would leave that half |
| 2179 | // has". | 2107 | // free to scroll the wall. |
| 2180 | const alloc = std.testing.allocator; | 2108 | const alloc = std.testing.allocator; |
| 2181 | var stdin = try FakeStdin.install(""); | 2109 | var stdin = try FakeStdin.install(""); |
| 2182 | defer stdin.deinit(); | 2110 | defer stdin.deinit(); |
| 2183 | var mine_buf: [std.fs.max_path_bytes]u8 = undefined; | 2111 | var mine_buf: [std.fs.max_path_bytes]u8 = undefined; |
| 2184 | const mine = try std.fs.readLinkAbsolute("/proc/self/fd/1", &mine_buf); | 2112 | const mine = try std.fs.readLinkAbsolute("/proc/self/fd/2", &mine_buf); |
| 2185 | 2113 | ||
| 2186 | for ([_]bool{ false, true }) |quiet| { | 2114 | for ([_]bool{ false, true }) |quiet| for ([_]bool{ false, true }) |asked| { |
| 2187 | var carry: std.ArrayList(u8) = .empty; | 2115 | var carry: std.ArrayList(u8) = .empty; |
| 2188 | defer carry.deinit(alloc); | 2116 | defer carry.deinit(alloc); |
| 2189 | var tmp = try TmpDir.make(); | 2117 | var tmp = try TmpDir.make(); |
| 2190 | defer tmp.cleanup(); | 2118 | defer tmp.cleanup(); |
| 2191 | 2119 | ||
| 2192 | var ssh_buf: [512]u8 = undefined; | 2120 | var script_buf: [1024]u8 = undefined; |
| 2193 | const ssh_cmd = try std.fmt.bufPrint(&ssh_buf, "exit 1", .{}); | 2121 | const script = try std.fmt.bufPrint(&script_buf, |
| 2194 | var start_buf: [512]u8 = undefined; | 2122 | \\readlink /proc/$$/fd/2 > {[d]s}/e |
| 2195 | const start_cmd = try std.fmt.bufPrint( | 2123 | \\exit 1 |
| 2196 | &start_buf, | 2124 | , .{ .d = tmp.path() }); |
| 2197 | "readlink /proc/$$/fd/1 > {s}/o; readlink /proc/$$/fd/2 > {s}/e; exit 1", | ||
| 2198 | .{ tmp.path(), tmp.path() }, | ||
| 2199 | ); | ||
| 2200 | 2125 | ||
| 2201 | try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{ | 2126 | try expectNoSession("the stderr fixture's box announced a session it has no daemon for", Transport.open(alloc, .{ .hand = .{ |
| 2202 | .host = "fake", | 2127 | .host = "fake", |
| 2203 | .ssh_argv = &.{ "/bin/sh", "-c", ssh_cmd }, | 2128 | .ssh_argv = &.{ "/bin/sh", "-c", script, "sh", read_word }, |
| 2204 | .asked_argv = &.{ "/bin/sh", "-c", start_cmd }, | 2129 | .asked_argv = &.{ "/bin/sh", "-c", script, "sh", asked_word }, |
| 2205 | .cache_path = null, | 2130 | .cache_path = null, |
| 2206 | .deadline_ms = 200, | 2131 | .deadline_ms = 200, |
| 2207 | .asked = true, | 2132 | .asked = asked, |
| 2208 | .quiet = quiet, | 2133 | .quiet = quiet, |
| 2209 | } }, &carry, std.posix.STDIN_FILENO)); | 2134 | } }, &carry, std.posix.STDIN_FILENO)); |
| 2210 | 2135 | ||
| 2211 | var out_buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 2212 | var err_buf: [std.fs.max_path_bytes]u8 = undefined; | 2136 | var err_buf: [std.fs.max_path_bytes]u8 = undefined; |
| 2213 | const on_out = try shimSaid(tmp.path(), "o", &out_buf); | ||
| 2214 | const on_err = try shimSaid(tmp.path(), "e", &err_buf); | 2137 | const on_err = try shimSaid(tmp.path(), "e", &err_buf); |
| 2215 | if (quiet) { | 2138 | if (quiet) { |
| 2216 | try std.testing.expectEqualStrings("/dev/null", on_out); | ||
| 2217 | try std.testing.expectEqualStrings("/dev/null", on_err); | 2139 | try std.testing.expectEqualStrings("/dev/null", on_err); |
| 2218 | } else { | 2140 | } else { |
| 2219 | try std.testing.expectEqualStrings(mine, on_out); | 2141 | try std.testing.expectEqualStrings(mine, on_err); |
| 2220 | } | 2142 | } |
| 2221 | } | 2143 | }; |
| 2222 | } | 2144 | } |
| 2223 | 2145 | ||
| 2224 | test "lostMsg: only a --via transport that never connected gets the new wording" { | 2146 | test "lostMsg: only a --via transport that never connected gets the new wording" { |
| @@ -2768,7 +2690,7 @@ test "Target.fromSpec: asked is the caller's word, never a default" { | |||
| 2768 | const t = try Target.fromSpec(alloc, .{ .host = "box" }, null, 30_000, asked); | 2690 | const t = try Target.fromSpec(alloc, .{ .host = "box" }, null, 30_000, asked); |
| 2769 | try std.testing.expectEqual(asked, t.hand.asked); | 2691 | try std.testing.expectEqual(asked, t.hand.asked); |
| 2770 | try std.testing.expectEqual(@as(u32, 30_000), t.hand.idle_ms); | 2692 | try std.testing.expectEqual(@as(u32, 30_000), t.hand.idle_ms); |
| 2771 | // The start line is the recipe's, and it is what `asked` gates. | 2693 | // The asking line is the recipe's, and it is what `asked` selects. |
| 2772 | try std.testing.expect(t.hand.asked_argv.len > 0); | 2694 | try std.testing.expect(t.hand.asked_argv.len > 0); |
| 2773 | } | 2695 | } |
| 2774 | } | 2696 | } |
src/tui/wall_picker.zig
| Old | New | ||
|---|---|---|---|
| @@ -194,9 +194,10 @@ pub fn pickBirth(w: Wall, sel: usize) ?usize { | |||
| 194 | if (target == .hand) { | 194 | if (target == .hand) { |
| 195 | target.hand.asked = true; | 195 | target.hand.asked = true; |
| 196 | // ...and quietly. This dial happens on a tile thread, under the | 196 | // ...and quietly. This dial happens on a tile thread, under the |
| 197 | // wall's alternate screen: `mux d start`'s progress would land over | 197 | // wall's alternate screen: the remote `mux d endpoint --start`'s |
| 198 | // tiles and rails, where the tile's own `connecting` label is | 198 | // progress rides the ssh's stderr and would land over tiles and |
| 199 | // already saying the only thing there is to say. | 199 | // rails, where the tile's own `connecting` label is already saying |
| 200 | // the only thing there is to say. | ||
| 200 | target.hand.quiet = true; | 201 | target.hand.quiet = true; |
| 201 | } | 202 | } |
| 202 | var list_buf: [proto.sessions_text_max]u8 = undefined; | 203 | var list_buf: [proto.sessions_text_max]u8 = undefined; |
src/tui/wall_pump.zig
| Old | New | ||
|---|---|---|---|
| @@ -256,7 +256,7 @@ fn dial(alloc: std.mem.Allocator, t: *Tile, target_in: client.Target) ?client.Tr | |||
| 256 | while (t.shared.running.load(.acquire) and !t.gone.load(.acquire)) { | 256 | while (t.shared.running.load(.acquire) and !t.gone.load(.acquire)) { |
| 257 | if (client.Transport.open(alloc, target, null, -1)) |tr| return tr else |_| {} | 257 | if (client.Transport.open(alloc, target, null, -1)) |tr| return tr else |_| {} |
| 258 | // An ask buys ONE attempt. Every retry below is the wall's own | 258 | // An ask buys ONE attempt. Every retry below is the wall's own |
| 259 | // idea: a `mux d start` per backoff would restart a daemon for as | 259 | // idea: the asking word per backoff would restart a daemon for as |
| 260 | // long as the tile lives, and a fallback line per backoff would | 260 | // long as the tile lives, and a fallback line per backoff would |
| 261 | // scroll the alternate screen the tiles are painted on. | 261 | // scroll the alternate screen the tiles are painted on. |
| 262 | if (target == .hand) target.hand.asked = false; | 262 | if (target == .hand) target.hand.asked = false; |