a73x

e8d541ee

refactor: the client's start branch folds into one announce read

a73x   2026-08-29 14:38

Commit message
refactor: the client's start branch folds into one announce read

`openHandoff` picks the argv by `asked` and reads ONE announce. Gone with
the branch that used to follow a failed one: `announceOrStart`,
`runStart`, `reapAnnounce`, `termOf`, `announce_reap_ms`,
`announce_reap_step_ms` — and every comment that explained the
exit-1-vs-255 dance, which existed only because the client had to tell
the remote's refusal from ssh's own before it dared spend a start.

`asked` now names one thing: which of the two words runs. `quiet` names
one thing too — whether the ssh's stderr is the caller's — so it moves
onto `spawnPipe`, where the remote's `starting…` progress and ssh's own
diagnostics both ride.

The five rewritten tests hand ONE fake box two argvs that differ only in
the remote word, the way the wire does, and read back the word it was
handed: a client that ran the wrong one cannot pass by running a
different script. Each was mutation-checked — flipping the argv choice
either way, and inverting `quiet`, fails a test named for the claim.

`expectNoSession` replaces `expectError` in those tests. `expectError`
formats the success value with `{any}`, and a live `Transport` holds an
allocator vtable that formatting faults on: the first version of these
tests caught the mutation by killing the runner mid-message, which names
no test and takes every later one in the binary with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wi2HnuF1EK8HgViU11YLV

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;