a73x

372411cb

refactor: the handoff ssh's stderr is a pipe, and narrate replaces quiet

a73x   2026-08-30 10:58

Commit message
refactor: the handoff ssh's stderr is a pipe, and narrate replaces quiet

It was INHERITED, so `ssh: connect to host ... No route to host` landed
on the wall's alternate screen every poll — over tiles and rails, from a
writer no repaint could reach. `quiet` was the workaround, and only the
picker's Enter set it.

The pipe is the rule now. mux reads that fd wherever it lives: joined to
the announce wait in `openHandoff`, and carried on the Transport for the
handoff that stayed on the ssh pipe, where the pump and the hub poll it
beside the link. Unread it would fill at 64k and take ssh's whole
session down with it, so every owner drains.

`narrate` is what `quiet` was, inverted so the default is the harmless
half: the bytes reach mux's own fd 2 only for the entry dial, where a
human is at a bare prompt waiting on the ssh they asked for. `--via`
keeps `.Inherit` under its own spawn — a user's own program's stderr is
the user's.

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

src/cli/mux_main.zig
Old New
@@ -336,7 +336,11 @@ pub fn main(args: []const [:0]const u8) !u8 {
336 // to somebody who is sitting there waiting. The other `true` is 336 // to somebody who is sitting there waiting. The other `true` is
337 // the picker's Enter, set on a copy in `wall_picker.pickBirth` — 337 // the picker's Enter, set on a copy in `wall_picker.pickBirth` —
338 // and the resolver makes every caller say which. 338 // and the resolver makes every caller say which.
339 const target = client.HandoffTarget.fromRecipe(h.name, r, h.idle_ms, true); 339 var target = client.HandoffTarget.fromRecipe(h.name, r, h.idle_ms, true);
340 // The one caller that relays ssh's stderr onward. There is no
341 // wall yet and no alternate screen to corrupt, and the user is
342 // sitting in front of the wait those bytes describe.
343 target.narrate = true;
340 return wallview.runAttach(alloc, .{ .hand = target }, h.session, null, h.idle_ms, h.agent); 344 return wallview.runAttach(alloc, .{ .hand = target }, h.session, null, h.idle_ms, h.agent);
341 }, 345 },
342 .attach => |t| { 346 .attach => |t| {
@@ -537,7 +541,7 @@ fn hostsList(arena: std.mem.Allocator, path: []const u8, out_fd: std.posix.fd_t)
537 continue; 541 continue;
538 }; 542 };
539 var out: [proto.sessions_text_max]u8 = undefined; 543 var out: [proto.sessions_text_max]u8 = undefined;
540 const list = client.listSessions(arena, spec.poll_target, &out, hosts_list_ms, null) catch |err| { 544 const list = client.listSessions(arena, spec.poll_target, &out, hosts_list_ms, null, null) catch |err| {
541 // Out of memory is this machine's fault, and printing 545 // Out of memory is this machine's fault, and printing
542 // `[unreachable]` for it would blame a box that is up. 546 // `[unreachable]` for it would blame a box that is up.
543 if (err == error.OutOfMemory) return err; 547 if (err == error.OutOfMemory) return err;
src/client/client.zig
Old New
@@ -231,14 +231,23 @@ pub const HandoffTarget = struct {
231 /// 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
232 /// on someone else's box that nothing reports at all. 232 /// on someone else's box that nothing reports at all.
233 asked: bool = false, 233 asked: bool = false,
234 /// Whether the caller owns a screen the ssh's stderr would land on. The 234 /// Whether the caller is a human at a bare prompt who is owed ssh's
235 /// remote's `mux d endpoint: starting\u{2026}`, a dot per interval and an 235 /// narration as it happens: relay every stderr byte to mux's own fd 2.
236 /// up-line, ride that stderr, as do ssh's own diagnostics; under the 236 /// The remote's `mux d endpoint: starting\u{2026}` progress, a dot per
237 /// wall's alternate screen those bytes sit over tiles and rails, and 237 /// interval and an up-line, ride that stderr, as do ssh's own
238 /// the tile's own `connecting` label is the narration there. False on 238 /// diagnostics, and the entry dial's user is sitting through the wait
239 /// the entry dial, which runs before any wall exists and where the user 239 /// they describe.
240 /// is owed the progress of a wait they are sitting through. 240 ///
241 quiet: bool = false, 241 /// Nobody else relays. The bytes are READ either way — that is
242 /// `Reason`, and it is what a picker row quotes — but under a wall's
243 /// alternate screen they would sit over tiles and rails, where the
244 /// tile's own `connecting` label is the narration.
245 ///
246 /// Defaults to the harmless half, the way `asked` does: a dial path
247 /// that forgets this field is silent, and of the two silences a
248 /// missing line is a wait that says nothing while a spurious one
249 /// corrupts a paint nobody can repair from.
250 narrate: bool = false,
242 251
243 /// The recipe→target literal: a field added above is added here, not 252 /// The recipe→target literal: a field added above is added here, not
244 /// at every dial. `asked` is a parameter with NO default though 253 /// at every dial. `asked` is a parameter with NO default though
@@ -349,29 +358,51 @@ pub const Transport = struct {
349 /// attach frames are all this ever holds. 358 /// attach frames are all this ever holds.
350 qout: std.ArrayList(u8) = .empty, 359 qout: std.ArrayList(u8) = .empty,
351 alloc: std.mem.Allocator = undefined, 360 alloc: std.mem.Allocator = undefined,
361 /// The handoff ssh's stderr for a `.pipe` link born of a handoff; -1
362 /// for every other link and for `--via`, whose stderr is the user's.
363 err_fd: std.posix.fd_t = -1,
364 /// Where `drainErr` puts what it read, so a link that outlives its
365 /// dial still has somewhere to keep a line. Nothing reads it today —
366 /// a tile shows `connecting`, and the row quotes the POLL's copy — so
367 /// a reader that appears is free to define what it means.
368 reason: handoff.Reason = .{},
369 /// `HandoffTarget.narrate`, carried past the dial so a link that came
370 /// up on the pipe goes on relaying. The entry dial CLEARS it when the
371 /// wall takes the screen (`wallview.runAttach`): past that point fd 2
372 /// is the alternate screen, and a late `Connection to box closed by
373 /// remote host.` would be exactly the foreign writer this removed.
374 narrate: bool = false,
375
376 /// The handoff's coordination ssh, stderr included.
377 fn spawnPipe(alloc: std.mem.Allocator, argv: []const []const u8) !std.process.Child {
378 return spawnWithStderr(alloc, argv, .Pipe);
379 }
352 380
353 /// One exec'd child with its stdio piped: what both `--via` and the 381 /// `--via CMD` is the user's OWN program in the user's own terminal.
354 /// handoff's coordination ssh need, spelled once. argv, never a shell 382 fn spawnVia(alloc: std.mem.Allocator, argv: []const []const u8) !std.process.Child {
355 /// line — the product runs `ssh` and the user's own `--via` program, 383 return spawnWithStderr(alloc, argv, .Inherit);
356 /// and neither is worth a shell's expansions between us and it. 384 }
357 /// 385
358 /// `argv` need not outlive the call: `std.process.Child` copies it into 386 /// One exec'd child, argv and never a shell line.
359 /// its own arena before the fork. "spawnPipe: the child is exec'd from 387 fn spawnWithStderr(
360 /// a copy" is that claim, asserted. 388 alloc: std.mem.Allocator,
361 fn spawnPipe(alloc: std.mem.Allocator, argv: []const []const u8, quiet: bool) !std.process.Child { 389 argv: []const []const u8,
390 stderr_behavior: std.process.Child.StdIo,
391 ) !std.process.Child {
392 // The product runs `ssh` and the user's own `--via` program, and
393 // neither is worth a shell's expansions between us and it.
394 //
395 // `argv` need not outlive the call: `std.process.Child` copies it
396 // into its own arena before the fork. "spawnPipe: the child is
397 // exec'd from a copy" is that claim, asserted.
362 var child = std.process.Child.init(argv, alloc); 398 var child = std.process.Child.init(argv, alloc);
363 child.stdin_behavior = .Pipe; 399 child.stdin_behavior = .Pipe;
364 child.stdout_behavior = .Pipe; 400 child.stdout_behavior = .Pipe;
365 // Inherited, not piped: ssh's diagnostics (auth failure, unknown 401 // The handoff's ssh is PIPED: its diagnostics are mux's to keep —
366 // host, connection refused), and `mux d endpoint`'s own one-liners — 402 // the last line becomes the dial's `handoff.Reason`, which a picker
367 // its `starting\u{2026}` progress under `--start` included — are the 403 // row quotes — and a wall's alternate screen admits no foreign
368 // user's only clue when the transport never comes up, and we would 404 // writer. `--via`'s stderr stays the user's, inherited.
369 // otherwise swallow them. 405 child.stderr_behavior = stderr_behavior;
370 //
371 // `quiet` is a caller with a full-screen wall saying it has nowhere
372 // for them to land; see `HandoffTarget.quiet`. stdout stays a pipe
373 // either way: it carries the announce, which is not narration.
374 child.stderr_behavior = if (quiet) .Ignore else .Inherit;
375 try child.spawn(); 406 try child.spawn();
376 return child; 407 return child;
377 } 408 }
@@ -432,11 +463,15 @@ pub const Transport = struct {
432 /// waits: stdin in the CLI, -1 (no abort channel) in a hub that 463 /// waits: stdin in the CLI, -1 (no abort channel) in a hub that
433 /// has no terminal — its stray fd 0 must never be read. 464 /// has no terminal — its stray fd 0 must never be read.
434 abort_fd: std.posix.fd_t, 465 abort_fd: std.posix.fd_t,
466 /// Where the handoff ssh's last line is left for the caller, so a
467 /// dial that failed can be reported in ssh's own words. Null is
468 /// every caller that has nowhere to show it.
469 reason: ?*handoff.Reason,
435 ) !Transport { 470 ) !Transport {
436 switch (target) { 471 switch (target) {
437 // Delegated whole, because the handoff can end up producing 472 // Delegated whole, because the handoff can end up producing
438 // either of the two links below and owns the choice itself. 473 // either of the two links below and owns the choice itself.
439 .hand => |h| return openHandoff(alloc, h, carry, abort_fd), 474 .hand => |h| return openHandoff(alloc, h, carry, abort_fd, reason),
440 .quic => |q| { 475 .quic => |q| {
441 const key = try quic.Key.load(q.key_path); 476 const key = try quic.Key.load(q.key_path);
442 const addr = try quic.parseAddr(alloc, q.host_port); 477 const addr = try quic.parseAddr(alloc, q.host_port);
@@ -445,7 +480,7 @@ pub const Transport = struct {
445 .via => |cmd| { 480 .via => |cmd| {
446 const argv = try viaArgv(alloc, cmd); 481 const argv = try viaArgv(alloc, cmd);
447 defer alloc.free(argv); 482 defer alloc.free(argv);
448 return pipeTransport(try spawnPipe(alloc, argv, false)); 483 return pipeTransport(try spawnVia(alloc, argv));
449 }, 484 },
450 .sock => |path| { 485 .sock => |path| {
451 const stream = try std.net.connectUnixSocket(path); 486 const stream = try std.net.connectUnixSocket(path);
@@ -468,6 +503,7 @@ pub const Transport = struct {
468 h: HandoffTarget, 503 h: HandoffTarget,
469 carry: ?*std.ArrayList(u8), 504 carry: ?*std.ArrayList(u8),
470 abort_fd: std.posix.fd_t, 505 abort_fd: std.posix.fd_t,
506 reason: ?*handoff.Reason,
471 ) !Transport { 507 ) !Transport {
472 // The ORDER is `handoff.next`'s; this loop performs the step it is 508 // The ORDER is `handoff.next`'s; this loop performs the step it is
473 // handed and reports what came of it. 509 // handed and reports what came of it.
@@ -483,6 +519,16 @@ pub const Transport = struct {
483 errdefer if (child) |*c| { 519 errdefer if (child) |*c| {
484 _ = c.kill() catch {}; 520 _ = c.kill() catch {};
485 }; 521 };
522 // A caller with nowhere to show a reason still needs one kept: the
523 // Transport carries it, and a failed dial's last line would
524 // otherwise have to be read twice.
525 var local_reason: handoff.Reason = .{};
526 var errp: ErrPipe = .{
527 .fd = -1,
528 .reason = reason orelse &local_reason,
529 .narrate = h.narrate,
530 };
531 errdefer errp.close();
486 // What the terminal steps need from the steps before them; `dialed` 532 // What the terminal steps need from the steps before them; `dialed`
487 // is the port the fallback line names. 533 // is the port the fallback line names.
488 var dialed: ?handoff.Endpoint = null; 534 var dialed: ?handoff.Endpoint = null;
@@ -523,10 +569,23 @@ pub const Transport = struct {
523 // such a target a dial that starts nothing, which is what 569 // such a target a dial that starts nothing, which is what
524 // the field says it is. 570 // the field says it is.
525 const argv = if (h.asked and h.asked_argv.len > 0) h.asked_argv else h.ssh_argv; 571 const argv = if (h.asked and h.asked_argv.len > 0) h.asked_argv else h.ssh_argv;
526 child = spawnPipe(alloc, argv, h.quiet) catch |err| { 572 child = spawnPipe(alloc, argv) catch |err| {
527 last_err = err; 573 last_err = err;
528 break :blk .failed; 574 break :blk .failed;
529 }; 575 };
576 // Taken OFF the child: `Child.kill` closes `stderr`
577 // with the other pipes, and this fd has to outlive the
578 // kill on the `use_pipe` path, where the session runs
579 // on and ssh goes on talking.
580 //
581 // `if` rather than an unwrap `spawnPipe` makes safe: a
582 // spawn that stopped piping stderr would panic here,
583 // and the test named for the pipe would never get to
584 // say so. Leaving the fd at -1 fails that test instead.
585 if (child.?.stderr) |f| {
586 errp.fd = f.handle;
587 child.?.stderr = null;
588 }
530 break :blk .ok; 589 break :blk .ok;
531 }, 590 },
532 .read_announce => blk: { 591 .read_announce => blk: {
@@ -545,7 +604,13 @@ pub const Transport = struct {
545 // type-ahead is not lost: it waits in the kernel's tty 604 // type-ahead is not lost: it waits in the kernel's tty
546 // buffer for the session's first read. 605 // buffer for the session's first read.
547 const announce_abort_fd: std.posix.fd_t = if (carry != null) -1 else abort_fd; 606 const announce_abort_fd: std.posix.fd_t = if (carry != null) -1 else abort_fd;
548 const got = readAnnounceAbortable(child.?.stdout.?.handle, alloc, null, announce_abort_fd) catch |err| { 607 const got = readAnnounceAbortable(child.?.stdout.?.handle, alloc, null, announce_abort_fd, &errp) catch |err| {
608 // ssh says why on its way out, and its stdout's
609 // EOF can be the same poll pass as the last of it.
610 // Read what is left BEFORE this error becomes the
611 // caller's answer, or the reason is the sentence
612 // ssh printed one syscall too late.
613 errp.drainReady();
549 last_err = err; 614 last_err = err;
550 break :blk .announce_failed; 615 break :blk .announce_failed;
551 }; 616 };
@@ -574,7 +639,14 @@ pub const Transport = struct {
574 .use_quic => { 639 .use_quic => {
575 // QUIC carries the session now, so the coordination ssh — 640 // QUIC carries the session now, so the coordination ssh —
576 // if this handoff ran one at all — is done. kill() 641 // if this handoff ran one at all — is done. kill()
577 // waitpid()s internally and closes the pipes with it. 642 // waitpid()s internally and closes the pipes with it;
643 // stderr is no longer among them, so it is closed here.
644 errp.close();
645 // ...and says nothing. Whatever the coordination ssh
646 // narrated belongs to a handoff that SUCCEEDED; left
647 // standing it would be quoted by the next thing that
648 // fails on this target, blaming a box that is up.
649 errp.reason.clear();
578 if (child) |*c| { 650 if (child) |*c| {
579 _ = c.kill() catch {}; 651 _ = c.kill() catch {};
580 } 652 }
@@ -589,7 +661,14 @@ pub const Transport = struct {
589 "mux: quic://{s}:{d} unreachable, attaching over ssh\n", 661 "mux: quic://{s}:{d} unreachable, attaching over ssh\n",
590 .{ handoff.dialHost(h.host), dialed.?.port }, 662 .{ handoff.dialHost(h.host), dialed.?.port },
591 ); 663 );
592 return pipeTransport(child.?); 664 var t = pipeTransport(child.?);
665 // The pipe IS the session now, and ssh goes on talking
666 // for as long as it lives: the fd, the policy and the
667 // line so far all pass to whoever polls this transport.
668 t.err_fd = errp.fd;
669 t.narrate = errp.narrate;
670 t.reason = errp.reason.*;
671 return t;
593 }, 672 },
594 .fail => return last_err.?, 673 .fail => return last_err.?,
595 }; 674 };
@@ -616,6 +695,22 @@ pub const Transport = struct {
616 return self.conn.r; 695 return self.conn.r;
617 } 696 }
618 697
698 /// The second fd a handoff's owner polls, or null when there is none.
699 pub fn errFd(self: *const Transport) ?std.posix.fd_t {
700 // Skipped, ssh fills a 64k pipe and stops talking to the far end
701 // at all — a tile going silent for a reason no frame can explain.
702 return if (self.err_fd < 0) null else self.err_fd;
703 }
704
705 /// One read of that fd, kept as the reason and relayed if `narrate`.
706 /// Called when the poll says readable; EOF closes the fd for good.
707 pub fn drainErr(self: *Transport) void {
708 if (self.err_fd < 0) return;
709 var e: ErrPipe = .{ .fd = self.err_fd, .reason = &self.reason, .narrate = self.narrate };
710 _ = e.drain();
711 self.err_fd = e.fd;
712 }
713
619 /// One owning thread per `Transport`, but the ENTRY dial runs on main — 714 /// One owning thread per `Transport`, but the ENTRY dial runs on main —
620 /// ssh may need the tty for a password. `qout` is what would otherwise 715 /// ssh may need the tty for a password. `qout` is what would otherwise
621 /// cross: two threads on one non-thread-safe arena. 716 /// cross: two threads on one non-thread-safe arena.
@@ -731,6 +826,13 @@ pub const Transport = struct {
731 in.close(); 826 in.close();
732 c.stdin = null; 827 c.stdin = null;
733 } 828 }
829 // Ours, not the child's: `openHandoff` took stderr off the
830 // Child so `kill` would leave it alone, so nothing else
831 // will close it.
832 if (self.err_fd >= 0) {
833 std.posix.close(self.err_fd);
834 self.err_fd = -1;
835 }
734 _ = c.kill() catch {}; 836 _ = c.kill() catch {};
735 }, 837 },
736 .fd => std.posix.close(self.conn.r), 838 .fd => std.posix.close(self.conn.r),
@@ -812,6 +914,64 @@ fn drainAbortFd(abort_fd: std.posix.fd_t, alloc: std.mem.Allocator, carry: ?*std
812 return true; 914 return true;
813 } 915 }
814 916
917 /// The handoff ssh's stderr, while somebody is waiting on its stdout: the
918 /// fd, the line being kept off it, and whether the user is owed the bytes
919 /// live. One implementation, two owners — this wait and `Transport` — so
920 /// the rule cannot hold on one side of the announce and not the other.
921 const ErrPipe = struct {
922 fd: std.posix.fd_t,
923 reason: *handoff.Reason,
924 narrate: bool,
925
926 /// Linux's default pipe buffer, and `drainReady`'s bound.
927 const pipe_capacity = 64 * 1024;
928
929 /// One read, returning what it took. EOF (or any error) closes the fd
930 /// and forgets it: a closed pipe stays readable forever, and a caller
931 /// that went on polling it would spin hot — the hazard
932 /// `readAnnounceAbortable`'s `watch_stdin` documents, from the same side.
933 fn drain(self: *ErrPipe) usize {
934 if (self.fd < 0) return 0;
935 var buf: [512]u8 = undefined;
936 const n = std.posix.read(self.fd, &buf) catch 0;
937 if (n == 0) {
938 self.close();
939 return 0;
940 }
941 self.reason.feed(buf[0..n]);
942 // Relayed whole and unedited. `Reason`'s filtering is for the
943 // picker row; a user at a bare prompt is owed what ssh actually
944 // wrote, in the order it wrote it.
945 if (self.narrate) _ = std.posix.write(std.posix.STDERR_FILENO, buf[0..n]) catch {};
946 return n;
947 }
948
949 /// Everything the pipe holds NOW, without waiting for more.
950 fn drainReady(self: *ErrPipe) void {
951 // BYTES, not reads: the bound wants to be "everything a writer
952 // that has already died can have left behind", which is the pipe's
953 // capacity and nothing to do with how many syscalls it takes. A
954 // reads-shaped cap read a fixed 8k, so a remote whose ssh logs at
955 // DEBUG3 and dies fast left the reason a line from the middle. A
956 // live flooder is still cut off rather than holding a failed dial.
957 var total: usize = 0;
958 while (total < pipe_capacity and self.fd >= 0) {
959 var fds = [_]std.posix.pollfd{
960 .{ .fd = self.fd, .events = std.posix.POLL.IN, .revents = 0 },
961 };
962 const ready = std.posix.poll(&fds, 0) catch return;
963 if (ready == 0 or fds[0].revents == 0) return;
964 total += self.drain();
965 }
966 }
967
968 fn close(self: *ErrPipe) void {
969 if (self.fd < 0) return;
970 std.posix.close(self.fd);
971 self.fd = -1;
972 }
973 };
974
815 /// Deliberately NO deadline: a timer here races a cold `mux d endpoint` 975 /// Deliberately NO deadline: a timer here races a cold `mux d endpoint`
816 /// spawn, and the abort key already covers a hung ssh. 976 /// spawn, and the abort key already covers a hung ssh.
817 fn readAnnounceAbortable( 977 fn readAnnounceAbortable(
@@ -819,6 +979,10 @@ fn readAnnounceAbortable(
819 alloc: std.mem.Allocator, 979 alloc: std.mem.Allocator,
820 carry: ?*std.ArrayList(u8), 980 carry: ?*std.ArrayList(u8),
821 abort_fd: std.posix.fd_t, 981 abort_fd: std.posix.fd_t,
982 /// ssh's stderr, joined to this wait so a diagnostic printed while we
983 /// block on the announce is kept rather than left to fill a pipe.
984 /// Null in the tests that drive this function off a bare fd.
985 errp: ?*ErrPipe,
822 ) !?handoff.Endpoint { 986 ) !?handoff.Endpoint {
823 var buf: [handoff.announce_max_len]u8 = undefined; 987 var buf: [handoff.announce_max_len]u8 = undefined;
824 var n: usize = 0; 988 var n: usize = 0;
@@ -833,7 +997,19 @@ fn readAnnounceAbortable(
833 // waiting for one that may never come would turn an error into a hang. 997 // waiting for one that may never come would turn an error into a hang.
834 if (n == buf.len) return error.LineTooLong; 998 if (n == buf.len) return error.LineTooLong;
835 999
836 var fds = abortPoll(fd, abort_fd, watch_stdin); 1000 const two = abortPoll(fd, abort_fd, watch_stdin);
1001 // Three descriptors, not two: ssh's stderr is watched alongside,
1002 // because the announce can be minutes away (a password prompt) and
1003 // an unread stderr fills at 64k.
1004 var fds = [_]std.posix.pollfd{
1005 two[0],
1006 two[1],
1007 .{
1008 .fd = if (errp) |e| e.fd else -1,
1009 .events = std.posix.POLL.IN,
1010 .revents = 0,
1011 },
1012 };
837 // No timeout, per the note above. std.posix.poll retries EINTR 1013 // No timeout, per the note above. std.posix.poll retries EINTR
838 // itself, so a SIGWINCH mid-wait is not an error to handle here. 1014 // itself, so a SIGWINCH mid-wait is not an error to handle here.
839 _ = try std.posix.poll(&fds, -1); 1015 _ = try std.posix.poll(&fds, -1);
@@ -842,6 +1018,7 @@ fn readAnnounceAbortable(
842 // fd is -1 and never read — the keystrokes wait in the kernel's tty 1018 // fd is -1 and never read — the keystrokes wait in the kernel's tty
843 // buffer for ssh's prompt, then the shell. 1019 // buffer for ssh's prompt, then the shell.
844 if (fds[1].revents != 0) watch_stdin = try drainAbortFd(abort_fd, alloc, carry); 1020 if (fds[1].revents != 0) watch_stdin = try drainAbortFd(abort_fd, alloc, carry);
1021 if (fds[2].revents != 0) _ = errp.?.drain();
845 1022
846 if (fds[0].revents == 0) continue; 1023 if (fds[0].revents == 0) continue;
847 // One byte, because the frame stream begins at the byte after the 1024 // One byte, because the frame stream begins at the byte after the
@@ -983,8 +1160,8 @@ pub fn openFailure(buf: []u8, target: Target, err: anyerror) OpenFailure {
983 // So the line claims only the observation, which is 1160 // So the line claims only the observation, which is
984 // weakly true in every one of those cases, where 1161 // weakly true in every one of those cases, where
985 // "cannot reach" was strongly false in some of them. 1162 // "cannot reach" was strongly false in some of them.
986 // The cause is left to ssh's own stderr, inherited and 1163 // The cause is left to ssh's own stderr, which mux now
987 // printed directly above this. 1164 // reads rather than inherits.
988 failedMsg( 1165 failedMsg(
989 buf, 1166 buf,
990 "mux: no endpoint announce from {s} over ssh ({s})\n", 1167 "mux: no endpoint announce from {s} over ssh ({s})\n",
@@ -1103,7 +1280,7 @@ pub fn birthSession(
1103 // get its session made would keep claiming it, and the grid would 1280 // get its session made would keep claiming it, and the grid would
1104 // follow whichever browser last reloaded. The birth pays one attach 1281 // follow whichever browser last reloaded. The birth pays one attach
1105 // and hands the session back at a size nobody is bound to. 1282 // and hands the session back at a size nobody is bound to.
1106 var tr = try Transport.open(alloc, target, null, -1); 1283 var tr = try Transport.open(alloc, target, null, -1, null);
1107 defer tr.close(); 1284 defer tr.close();
1108 var buf: [proto.attach_max_len]u8 = undefined; 1285 var buf: [proto.attach_max_len]u8 = undefined;
1109 // Resume args 0/0: this connection holds nothing and wants the 1286 // Resume args 0/0: this connection holds nothing and wants the
@@ -1156,6 +1333,9 @@ pub fn listSessions(
1156 out: *[proto.sessions_text_max]u8, 1333 out: *[proto.sessions_text_max]u8,
1157 budget_ms: i64, 1334 budget_ms: i64,
1158 answered: ?*std.meta.Tag(Link), 1335 answered: ?*std.meta.Tag(Link),
1336 /// Where ssh's last line is left when the dial fails: the picker row
1337 /// quotes it, so a box that is down says why instead of `unreachable`.
1338 reason: ?*handoff.Reason,
1159 ) ![]const u8 { 1339 ) ![]const u8 {
1160 // A fresh connection per poll: the observer idle deadline and the 1340 // A fresh connection per poll: the observer idle deadline and the
1161 // redial backoff stay the pump's problem, and a `--via` host — an ssh 1341 // redial backoff stay the pump's problem, and a `--via` host — an ssh
@@ -1171,7 +1351,7 @@ pub fn listSessions(
1171 .quic => .quic, 1351 .quic => .quic,
1172 .sock => .fd, 1352 .sock => .fd,
1173 }; 1353 };
1174 var tr = Transport.open(alloc, target, null, -1) catch |e| return oomOrTransport(e); 1354 var tr = Transport.open(alloc, target, null, -1, reason) catch |e| return oomOrTransport(e);
1175 defer tr.close(); 1355 defer tr.close();
1176 // The handoff picks its own link, so only the success case knows it. 1356 // The handoff picks its own link, so only the success case knows it.
1177 if (answered) |a| a.* = tr.link; 1357 if (answered) |a| a.* = tr.link;
@@ -1297,7 +1477,7 @@ pub const SessionPoll = struct {
1297 // thread owns no transport between polls that a teardown would 1477 // thread owns no transport between polls that a teardown would
1298 // have to reach. 1478 // have to reach.
1299 var link: std.meta.Tag(Link) = .fd; 1479 var link: std.meta.Tag(Link) = .fd;
1300 const got = listSessions(std.heap.page_allocator, target, &out, 2000, &link) catch null; 1480 const got = listSessions(std.heap.page_allocator, target, &out, 2000, &link, null) catch null;
1301 if (got) |list| { 1481 if (got) |list| {
1302 self.list_mu.lock(); 1482 self.list_mu.lock();
1303 @memcpy(self.list[0..list.len], list); 1483 @memcpy(self.list[0..list.len], list);
@@ -1396,7 +1576,7 @@ test "Transport.close is idempotent: the abort path closes what a re-dial alread
1396 var listener = try addr.listen(.{}); 1576 var listener = try addr.listen(.{});
1397 defer listener.deinit(); 1577 defer listener.deinit();
1398 1578
1399 var transport = try Transport.open(alloc, .{ .sock = sock_path }, null, -1); 1579 var transport = try Transport.open(alloc, .{ .sock = sock_path }, null, -1, null);
1400 1580
1401 // A re-dial closes the dead transport at entry; if the user then aborts, 1581 // A re-dial closes the dead transport at entry; if the user then aborts,
1402 // the pump's `defer transport.close()` closes it a second time. Without a 1582 // the pump's `defer transport.close()` closes it a second time. Without a
@@ -1458,7 +1638,7 @@ test "Transport.open: a --via target yields a pipe, a --sock target an fd" {
1458 1638
1459 // A command that stays alive on stdin, so the link is unambiguously a 1639 // A command that stays alive on stdin, so the link is unambiguously a
1460 // live child rather than one that raced us to exit. 1640 // live child rather than one that raced us to exit.
1461 var v = Transport.open(alloc, .{ .via = "cat" }, null, -1) catch |err| { 1641 var v = Transport.open(alloc, .{ .via = "cat" }, null, -1, null) catch |err| {
1462 std.debug.print( 1642 std.debug.print(
1463 "a --via target must spawn a command, not connect a socket: open failed with {s}\n", 1643 "a --via target must spawn a command, not connect a socket: open failed with {s}\n",
1464 .{@errorName(err)}, 1644 .{@errorName(err)},
@@ -1505,7 +1685,7 @@ test "Transport.open: a --via target yields a pipe, a --sock target an fd" {
1505 var listener = try a.listen(.{}); 1685 var listener = try a.listen(.{});
1506 defer listener.deinit(); 1686 defer listener.deinit();
1507 1687
1508 var s = Transport.open(alloc, .{ .sock = sp }, null, -1) catch |err| { 1688 var s = Transport.open(alloc, .{ .sock = sp }, null, -1, null) catch |err| {
1509 std.debug.print( 1689 std.debug.print(
1510 "a --sock target must connect the socket, not spawn a command: open failed with {s}\n", 1690 "a --sock target must connect the socket, not spawn a command: open failed with {s}\n",
1511 .{@errorName(err)}, 1691 .{@errorName(err)},
@@ -1538,7 +1718,7 @@ test "--via: the words reach the program verbatim — no shell splits, expands o
1538 1718
1539 const cmd = try std.fmt.allocPrint(alloc, "{s} a;b $HOME 'q'", .{prog}); 1719 const cmd = try std.fmt.allocPrint(alloc, "{s} a;b $HOME 'q'", .{prog});
1540 defer alloc.free(cmd); 1720 defer alloc.free(cmd);
1541 var v = try Transport.open(alloc, .{ .via = cmd }, null, -1); 1721 var v = try Transport.open(alloc, .{ .via = cmd }, null, -1, null);
1542 defer v.close(); 1722 defer v.close();
1543 1723
1544 // To EOF: the child writes one arg per printf, so a single read sees 1724 // To EOF: the child writes one arg per printf, so a single read sees
@@ -1573,7 +1753,7 @@ test "spawnPipe: the child is exec'd from a copy — an argv freed after spawn s
1573 const argv = try alloc.alloc([]const u8, 2); 1753 const argv = try alloc.alloc([]const u8, 2);
1574 argv[0] = try alloc.dupe(u8, "/bin/echo"); 1754 argv[0] = try alloc.dupe(u8, "/bin/echo");
1575 argv[1] = try alloc.dupe(u8, "copied"); 1755 argv[1] = try alloc.dupe(u8, "copied");
1576 var child = try Transport.spawnPipe(alloc, argv, false); 1756 var child = try Transport.spawnPipe(alloc, argv);
1577 for (argv) |w| alloc.free(w); 1757 for (argv) |w| alloc.free(w);
1578 alloc.free(argv); 1758 alloc.free(argv);
1579 // Reuse the freed pages before reading, so a std that kept the pointer 1759 // Reuse the freed pages before reading, so a std that kept the pointer
@@ -1613,7 +1793,7 @@ test "the announce reader consumes the newline and NOT the byte after it" {
1613 // the pipe, so the close costs the correct implementation nothing. 1793 // the pipe, so the close costs the correct implementation nothing.
1614 std.posix.close(fds[1]); 1794 std.posix.close(fds[1]);
1615 1795
1616 const got = (try readAnnounceAbortable(fds[0], alloc, null, -1)).?; 1796 const got = (try readAnnounceAbortable(fds[0], alloc, null, -1, null)).?;
1617 try std.testing.expectEqual(ep.port, got.port); 1797 try std.testing.expectEqual(ep.port, got.port);
1618 try std.testing.expectEqualSlices(u8, &ep.key, &got.key); 1798 try std.testing.expectEqualSlices(u8, &ep.key, &got.key);
1619 1799
@@ -1631,7 +1811,7 @@ test "the announce reader: `endpoint none` is null, EOF and an over-long line ar
1631 std.posix.close(fds[1]); 1811 std.posix.close(fds[1]);
1632 try std.testing.expectEqual( 1812 try std.testing.expectEqual(
1633 @as(?handoff.Endpoint, null), 1813 @as(?handoff.Endpoint, null),
1634 try readAnnounceAbortable(fds[0], alloc, null, -1), 1814 try readAnnounceAbortable(fds[0], alloc, null, -1, null),
1635 ); 1815 );
1636 } 1816 }
1637 { 1817 {
@@ -1641,7 +1821,7 @@ test "the announce reader: `endpoint none` is null, EOF and an over-long line ar
1641 std.posix.close(fds[1]); 1821 std.posix.close(fds[1]);
1642 try std.testing.expectError( 1822 try std.testing.expectError(
1643 handoff.ReadLineError.UnterminatedLine, 1823 handoff.ReadLineError.UnterminatedLine,
1644 readAnnounceAbortable(fds[0], alloc, null, -1), 1824 readAnnounceAbortable(fds[0], alloc, null, -1, null),
1645 ); 1825 );
1646 } 1826 }
1647 { 1827 {
@@ -1655,7 +1835,7 @@ test "the announce reader: `endpoint none` is null, EOF and an over-long line ar
1655 _ = try std.posix.write(fds[1], &long); 1835 _ = try std.posix.write(fds[1], &long);
1656 try std.testing.expectError( 1836 try std.testing.expectError(
1657 handoff.ReadLineError.LineTooLong, 1837 handoff.ReadLineError.LineTooLong,
1658 readAnnounceAbortable(fds[0], alloc, null, -1), 1838 readAnnounceAbortable(fds[0], alloc, null, -1, null),
1659 ); 1839 );
1660 } 1840 }
1661 } 1841 }
@@ -1680,7 +1860,7 @@ test "handoff: endpoint-none rides the open pipe with no deadline paid" {
1680 .ssh_argv = &.{ "/bin/sh", "-c", "printf 'endpoint none\\n'; cat >/dev/null" }, 1860 .ssh_argv = &.{ "/bin/sh", "-c", "printf 'endpoint none\\n'; cat >/dev/null" },
1681 .cache_path = null, 1861 .cache_path = null,
1682 .deadline_ms = 200, 1862 .deadline_ms = 200,
1683 } }, &carry, std.posix.STDIN_FILENO); 1863 } }, &carry, std.posix.STDIN_FILENO, null);
1684 defer t.close(); 1864 defer t.close();
1685 1865
1686 try std.testing.expect(t.link != .quic); 1866 try std.testing.expect(t.link != .quic);
@@ -1726,7 +1906,7 @@ test "handoff: dead coordinates are a fast no, and the pipe is the fallback" {
1726 .ssh_argv = &.{ "/bin/sh", "-c", "printf 'endpoint 1 " ++ ("ab" ** 32) ++ "\\n'; cat >/dev/null" }, 1906 .ssh_argv = &.{ "/bin/sh", "-c", "printf 'endpoint 1 " ++ ("ab" ** 32) ++ "\\n'; cat >/dev/null" },
1727 .cache_path = null, 1907 .cache_path = null,
1728 .deadline_ms = 300, 1908 .deadline_ms = 300,
1729 } }, &carry, std.posix.STDIN_FILENO); 1909 } }, &carry, std.posix.STDIN_FILENO, null);
1730 defer t.close(); 1910 defer t.close();
1731 1911
1732 const elapsed = std.time.milliTimestamp() - t0; 1912 const elapsed = std.time.milliTimestamp() - t0;
@@ -1806,7 +1986,7 @@ test "handoff: the announce wait still answers the abort key" {
1806 .ssh_argv = &.{ "/bin/sh", "-c", "sleep 2" }, 1986 .ssh_argv = &.{ "/bin/sh", "-c", "sleep 2" },
1807 .cache_path = null, 1987 .cache_path = null,
1808 .deadline_ms = 200, 1988 .deadline_ms = 200,
1809 } }, null, std.posix.STDIN_FILENO)); 1989 } }, null, std.posix.STDIN_FILENO, null));
1810 // Well inside the script's own 2s, so this passing cannot mean "waited 1990 // Well inside the script's own 2s, so this passing cannot mean "waited
1811 // for the child to die and called that an abort". 1991 // for the child to die and called that an abort".
1812 try std.testing.expect(std.time.milliTimestamp() - t0 < 1000); 1992 try std.testing.expect(std.time.milliTimestamp() - t0 < 1000);
@@ -1831,7 +2011,7 @@ test "handoff: a first attach leaves stdin to ssh while the announce is pending
1831 .ssh_argv = &.{ "/bin/sh", "-c", "printf 'endpoint none\\n'; cat >/dev/null" }, 2011 .ssh_argv = &.{ "/bin/sh", "-c", "printf 'endpoint none\\n'; cat >/dev/null" },
1832 .cache_path = null, 2012 .cache_path = null,
1833 .deadline_ms = 200, 2013 .deadline_ms = 200,
1834 } }, &carry, std.posix.STDIN_FILENO); 2014 } }, &carry, std.posix.STDIN_FILENO, null);
1835 t.close(); 2015 t.close();
1836 2016
1837 try std.testing.expectEqual(@as(usize, 0), carry.items.len); 2017 try std.testing.expectEqual(@as(usize, 0), carry.items.len);
@@ -1860,7 +2040,7 @@ test "handoff: abort_fd -1 means no abort channel — fd 0 is never read" {
1860 .ssh_argv = &.{ "/bin/sh", "-c", "printf 'endpoint none\\n'; cat >/dev/null" }, 2040 .ssh_argv = &.{ "/bin/sh", "-c", "printf 'endpoint none\\n'; cat >/dev/null" },
1861 .cache_path = null, 2041 .cache_path = null,
1862 .deadline_ms = 200, 2042 .deadline_ms = 200,
1863 } }, &carry, -1); 2043 } }, &carry, -1, null);
1864 defer t.close(); 2044 defer t.close();
1865 2045
1866 try std.testing.expect(t.link == .pipe); 2046 try std.testing.expect(t.link == .pipe);
@@ -1967,7 +2147,7 @@ test "openHandoff: a HandoffTarget nobody configured runs the reading word, neve
1967 .asked_argv = &.{ "/bin/sh", "-c", script, "sh", asked_word }, 2147 .asked_argv = &.{ "/bin/sh", "-c", script, "sh", asked_word },
1968 .cache_path = null, 2148 .cache_path = null,
1969 .deadline_ms = 200, 2149 .deadline_ms = 200,
1970 } }, &carry, std.posix.STDIN_FILENO)); 2150 } }, &carry, std.posix.STDIN_FILENO, null));
1971 2151
1972 try std.testing.expect(!try shimMade(tmp.path(), "started")); 2152 try std.testing.expect(!try shimMade(tmp.path(), "started"));
1973 // The whole log, not a count: one line, and it is the reading word. 2153 // The whole log, not a count: one line, and it is the reading word.
@@ -1999,7 +2179,7 @@ test "openHandoff: the dial a user ASKED for runs the asking word ONCE and rides
1999 .cache_path = null, 2179 .cache_path = null,
2000 .deadline_ms = 200, 2180 .deadline_ms = 200,
2001 .asked = true, 2181 .asked = true,
2002 } }, &carry, std.posix.STDIN_FILENO); 2182 } }, &carry, std.posix.STDIN_FILENO, null);
2003 defer t.close(); 2183 defer t.close();
2004 2184
2005 // `endpoint none` is a real announce: the remote says ssh IS the 2185 // `endpoint none` is a real announce: the remote says ssh IS the
@@ -2035,7 +2215,7 @@ test "openHandoff: an asked dial whose box still announces nothing fails after t
2035 .cache_path = null, 2215 .cache_path = null,
2036 .deadline_ms = 200, 2216 .deadline_ms = 200,
2037 .asked = true, 2217 .asked = true,
2038 } }, &carry, std.posix.STDIN_FILENO)); 2218 } }, &carry, std.posix.STDIN_FILENO, null));
2039 2219
2040 // ONE line, and the asking word: a client that kept a retry would show 2220 // ONE line, and the asking word: a client that kept a retry would show
2041 // two, and a client that fell back to the reading word would show the 2221 // two, and a client that fell back to the reading word would show the
@@ -2069,7 +2249,7 @@ test "openHandoff: a dial nobody asked for, against a box with nothing, reports
2069 .cache_path = null, 2249 .cache_path = null,
2070 .deadline_ms = 200, 2250 .deadline_ms = 200,
2071 .asked = false, 2251 .asked = false,
2072 } }, &carry, std.posix.STDIN_FILENO)); 2252 } }, &carry, std.posix.STDIN_FILENO, null));
2073 2253
2074 try std.testing.expect(!try shimMade(tmp.path(), "started")); 2254 try std.testing.expect(!try shimMade(tmp.path(), "started"));
2075 var buf: [512]u8 = undefined; 2255 var buf: [512]u8 = undefined;
@@ -2104,7 +2284,7 @@ test "openHandoff: an asked target with no asking argv runs the reading word, an
2104 .cache_path = null, 2284 .cache_path = null,
2105 .deadline_ms = 200, 2285 .deadline_ms = 200,
2106 .asked = true, 2286 .asked = true,
2107 } }, &carry, std.posix.STDIN_FILENO)); 2287 } }, &carry, std.posix.STDIN_FILENO, null));
2108 2288
2109 // The reading word ran, once, and no daemon came of it: an exec that 2289 // The reading word ran, once, and no daemon came of it: an exec that
2110 // died on a null argv[0] would have left this log EMPTY. 2290 // died on a null argv[0] would have left this log EMPTY.
@@ -2113,31 +2293,59 @@ test "openHandoff: an asked target with no asking argv runs the reading word, an
2113 try std.testing.expectEqualStrings(read_word, try shimSaid(tmp.path(), "runs", &buf)); 2293 try std.testing.expectEqualStrings(read_word, try shimSaid(tmp.path(), "runs", &buf));
2114 } 2294 }
2115 2295
2116 test "openHandoff: `quiet` keeps every handoff ssh's stderr off a caller that owns the screen" { 2296 /// fd 2, captured into a pipe this test owns and restored on `take`.
2117 // The remote's `mux d endpoint: starting\u{2026}` progress, and ssh's own 2297 ///
2118 // diagnostics, are a wait the entry dial's user is sitting through and 2298 /// `FakeStdin`'s shape for the other direction. fd 2 and never fd 1: a
2119 // is owed. Under the wall's alternate screen the same bytes land over 2299 /// byte on the runner's stdout wedges `zig build test` silently, at 0 CPU,
2120 // tiles and rails, and the tile's `connecting` label is the narration 2300 /// with no output at all.
2121 // there. 2301 const CapturedStderr = struct {
2302 saved: std.posix.fd_t,
2303 r: std.posix.fd_t,
2304
2305 fn install() !CapturedStderr {
2306 const p = try std.posix.pipe();
2307 errdefer {
2308 std.posix.close(p[0]);
2309 std.posix.close(p[1]);
2310 }
2311 const saved = try std.posix.dup(std.posix.STDERR_FILENO);
2312 try std.posix.dup2(p[1], std.posix.STDERR_FILENO);
2313 // fd 2 is the surviving copy of the write end.
2314 std.posix.close(p[1]);
2315 return .{ .saved = saved, .r = p[0] };
2316 }
2317
2318 /// Restore FIRST, then read: while fd 2 still holds a write end the
2319 /// read below would block instead of seeing the end of the capture.
2320 fn take(self: *CapturedStderr, buf: []u8) ![]const u8 {
2321 std.posix.dup2(self.saved, std.posix.STDERR_FILENO) catch {};
2322 std.posix.close(self.saved);
2323 defer std.posix.close(self.r);
2324 return buf[0..try std.posix.read(self.r, buf)];
2325 }
2326 };
2327
2328 test "openHandoff: the handoff ssh's stderr is a pipe, and only `narrate` relays it" {
2329 // The bug this closes: a hosts line naming a box that is down puts
2330 // `ssh: connect to host ... No route to host` onto the wall's
2331 // alternate screen every poll, over tiles and rails, because the ssh
2332 // child's stderr was INHERITED. It is a pipe mux reads now — always,
2333 // whoever dialled — and the one caller that relays the bytes onward is
2334 // the entry dial, where a human is at a bare prompt waiting.
2122 // 2335 //
2123 // The fake records where its stderr POINTED, off `/proc/$$/fd/2` — the 2336 // The fake records where its stderr POINTED, off `/proc/$$/fd/2` — the
2124 // shell's own link, not the redirect's — so "inherited" is exact rather 2337 // shell's own link, not a redirect's — so "piped" is exact rather than
2125 // than a guess about what a test runner's stderr happens to be. fd 2 2338 // a guess about what a test runner's stderr happens to be.
2126 // and never fd 1: a byte on the runner's stdout wedges `zig build test`
2127 // silently, at 0 CPU, with no output at all.
2128 // 2339 //
2129 // BOTH values of `asked`, because the rule is the spawn's and not the 2340 // BOTH values of `asked`, because the rule is the spawn's and not the
2130 // ask's: a picker-born tile clears `asked` for every redial and keeps 2341 // ask's: a picker-born tile clears `asked` for every redial and keeps
2131 // `quiet`, so the redial is the half that runs for as long as the tile 2342 // whatever the spawn does with stderr, so the redial is the half that
2132 // lives. A rule pinned only for the asked run would leave that half 2343 // runs for as long as the tile lives.
2133 // free to scroll the wall.
2134 const alloc = std.testing.allocator; 2344 const alloc = std.testing.allocator;
2135 var stdin = try FakeStdin.install(""); 2345 var stdin = try FakeStdin.install("");
2136 defer stdin.deinit(); 2346 defer stdin.deinit();
2137 var mine_buf: [std.fs.max_path_bytes]u8 = undefined;
2138 const mine = try std.fs.readLinkAbsolute("/proc/self/fd/2", &mine_buf);
2139 2347
2140 for ([_]bool{ false, true }) |quiet| for ([_]bool{ false, true }) |asked| { 2348 for ([_]bool{ false, true }) |narrate| for ([_]bool{ false, true }) |asked| {
2141 var carry: std.ArrayList(u8) = .empty; 2349 var carry: std.ArrayList(u8) = .empty;
2142 defer carry.deinit(alloc); 2350 defer carry.deinit(alloc);
2143 var tmp = try TmpDir.make(); 2351 var tmp = try TmpDir.make();
@@ -2146,25 +2354,36 @@ test "openHandoff: `quiet` keeps every handoff ssh's stderr off a caller that ow
2146 var script_buf: [1024]u8 = undefined; 2354 var script_buf: [1024]u8 = undefined;
2147 const script = try std.fmt.bufPrint(&script_buf, 2355 const script = try std.fmt.bufPrint(&script_buf,
2148 \\readlink /proc/$$/fd/2 > {[d]s}/e 2356 \\readlink /proc/$$/fd/2 > {[d]s}/e
2357 \\printf 'boom: no route\n' >&2
2149 \\exit 1 2358 \\exit 1
2150 , .{ .d = tmp.path() }); 2359 , .{ .d = tmp.path() });
2151 2360
2152 try expectNoSession("the stderr fixture's box announced a session it has no daemon for", Transport.open(alloc, .{ .hand = .{ 2361 var reason: handoff.Reason = .{};
2362 var cap = try CapturedStderr.install();
2363 const opened = Transport.open(alloc, .{ .hand = .{
2153 .host = "fake", 2364 .host = "fake",
2154 .ssh_argv = &.{ "/bin/sh", "-c", script, "sh", read_word }, 2365 .ssh_argv = &.{ "/bin/sh", "-c", script, "sh", read_word },
2155 .asked_argv = &.{ "/bin/sh", "-c", script, "sh", asked_word }, 2366 .asked_argv = &.{ "/bin/sh", "-c", script, "sh", asked_word },
2156 .cache_path = null, 2367 .cache_path = null,
2157 .deadline_ms = 200, 2368 .deadline_ms = 200,
2158 .asked = asked, 2369 .asked = asked,
2159 .quiet = quiet, 2370 .narrate = narrate,
2160 } }, &carry, std.posix.STDIN_FILENO)); 2371 } }, &carry, std.posix.STDIN_FILENO, &reason);
2372 var relayed_buf: [256]u8 = undefined;
2373 const relayed = try cap.take(&relayed_buf);
2374 try expectNoSession("the stderr fixture's box announced a session it has no daemon for", opened);
2161 2375
2162 var err_buf: [std.fs.max_path_bytes]u8 = undefined; 2376 var err_buf: [std.fs.max_path_bytes]u8 = undefined;
2163 const on_err = try shimSaid(tmp.path(), "e", &err_buf); 2377 const on_err = try shimSaid(tmp.path(), "e", &err_buf);
2164 if (quiet) { 2378 try std.testing.expect(std.mem.startsWith(u8, on_err, "pipe:"));
2165 try std.testing.expectEqualStrings("/dev/null", on_err); 2379 // Kept in every case: the picker row is painted from a dial
2380 // nobody narrated, which is the whole point of keeping it here
2381 // rather than letting the bytes fall out onto a screen.
2382 try std.testing.expectEqualStrings("boom: no route", reason.slice());
2383 if (narrate) {
2384 try std.testing.expectEqualStrings("boom: no route\n", relayed);
2166 } else { 2385 } else {
2167 try std.testing.expectEqualStrings(mine, on_err); 2386 try std.testing.expectEqualStrings("", relayed);
2168 } 2387 }
2169 }; 2388 };
2170 } 2389 }
@@ -2637,7 +2856,7 @@ test "listSessions: answers with the daemon's whole list, and a socket nobody li
2637 2856
2638 var out: [proto.sessions_text_max]u8 = undefined; 2857 var out: [proto.sessions_text_max]u8 = undefined;
2639 var link: std.meta.Tag(Link) = .quic; 2858 var link: std.meta.Tag(Link) = .quic;
2640 const list = try listSessions(alloc, .{ .sock = sp }, &out, 2000, &link); 2859 const list = try listSessions(alloc, .{ .sock = sp }, &out, 2000, &link, null);
2641 th.join(); 2860 th.join();
2642 // Which link answered is what the wall's poll interval is chosen from, 2861 // Which link answered is what the wall's poll interval is chosen from,
2643 // so a socket must say `fd` and not merely "not an error". 2862 // so a socket must say `fd` and not merely "not an error".
@@ -2648,7 +2867,7 @@ test "listSessions: answers with the daemon's whole list, and a socket nobody li
2648 2867
2649 const dead = try std.fmt.allocPrint(alloc, "{s}/nobody.sock", .{tmp.path()}); 2868 const dead = try std.fmt.allocPrint(alloc, "{s}/nobody.sock", .{tmp.path()});
2650 defer alloc.free(dead); 2869 defer alloc.free(dead);
2651 try std.testing.expectError(error.Transport, listSessions(alloc, .{ .sock = dead }, &out, 200, null)); 2870 try std.testing.expectError(error.Transport, listSessions(alloc, .{ .sock = dead }, &out, 200, null, null));
2652 } 2871 }
2653 2872
2654 test "listSessions: an allocation failure is not a host that is down" { 2873 test "listSessions: an allocation failure is not a host that is down" {
@@ -2668,7 +2887,7 @@ test "listSessions: an allocation failure is not a host that is down" {
2668 var out: [proto.sessions_text_max]u8 = undefined; 2887 var out: [proto.sessions_text_max]u8 = undefined;
2669 try std.testing.expectError( 2888 try std.testing.expectError(
2670 error.OutOfMemory, 2889 error.OutOfMemory,
2671 listSessions(std.testing.failing_allocator, .{ .sock = sp }, &out, 2000, null), 2890 listSessions(std.testing.failing_allocator, .{ .sock = sp }, &out, 2000, null, null),
2672 ); 2891 );
2673 th.join(); 2892 th.join();
2674 } 2893 }
@@ -2687,14 +2906,14 @@ test "listSessions: a poll that failed still reports the login it paid for" {
2687 .ssh_argv = &.{ "/bin/sh", "-c", "exit 255" }, 2906 .ssh_argv = &.{ "/bin/sh", "-c", "exit 255" },
2688 .cache_path = null, 2907 .cache_path = null,
2689 .asked = false, 2908 .asked = false,
2690 } }, &out, 200, &link)); 2909 } }, &out, 200, &link, null));
2691 try std.testing.expectEqual(std.meta.Tag(Link).pipe, link); 2910 try std.testing.expectEqual(std.meta.Tag(Link).pipe, link);
2692 2911
2693 // A `--sock` open that fails cost a connect(2) and nothing else, so the 2912 // A `--sock` open that fails cost a connect(2) and nothing else, so the
2694 // backoff must not follow it: the local daemon a user just stopped is 2913 // backoff must not follow it: the local daemon a user just stopped is
2695 // back a second later, not ten. 2914 // back a second later, not ten.
2696 link = .quic; 2915 link = .quic;
2697 try std.testing.expectError(error.Transport, listSessions(alloc, .{ .sock = "/nonexistent/mux.sock" }, &out, 200, &link)); 2916 try std.testing.expectError(error.Transport, listSessions(alloc, .{ .sock = "/nonexistent/mux.sock" }, &out, 200, &link, null));
2698 try std.testing.expectEqual(std.meta.Tag(Link).fd, link); 2917 try std.testing.expectEqual(std.meta.Tag(Link).fd, link);
2699 } 2918 }
2700 2919
src/client/webhub.zig
Old New
@@ -726,9 +726,18 @@ pub fn pumpTile(
726 var fds = [_]std.posix.pollfd{ 726 var fds = [_]std.posix.pollfd{
727 .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, 727 .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
728 .{ .fd = ws_fd, .events = std.posix.POLL.IN, .revents = 0 }, 728 .{ .fd = ws_fd, .events = std.posix.POLL.IN, .revents = 0 },
729 // ssh's stderr when this tile stayed on the pipe. Nothing here
730 // shows it, but an unread pipe fills at 64k and takes ssh's
731 // whole session down with it — the pump's reason, verbatim.
732 .{
733 .fd = transport.errFd() orelse -1,
734 .events = std.posix.POLL.IN,
735 .revents = 0,
736 },
729 }; 737 };
730 _ = std.posix.poll(&fds, transport.timeoutMs(100)) catch return; 738 _ = std.posix.poll(&fds, transport.timeoutMs(100)) catch return;
731 transport.service(); 739 transport.service();
740 if (fds[2].revents != 0) transport.drainErr();
732 741
733 // Daemon → browser. The `.quic` disjunct is the CLI's lesson 742 // Daemon → browser. The `.quic` disjunct is the CLI's lesson
734 // verbatim: there frames can arrive from the stream layer with 743 // verbatim: there frames can arrive from the stream layer with
@@ -827,7 +836,9 @@ fn dialLoop(
827 // that is merely waiting answers the ping and lives. 836 // that is merely waiting answers the ping and lives.
828 if (!live.tick(ws)) return null; 837 if (!live.tick(ws)) return null;
829 } 838 }
830 if (client.Transport.open(alloc, target, null, -1)) |t| { 839 // No reason kept: the page has a control channel that already says
840 // `connecting`, and nothing in the browser paints an ssh sentence.
841 if (client.Transport.open(alloc, target, null, -1, null)) |t| {
831 return t; 842 return t;
832 } else |_| {} 843 } else |_| {}
833 backoff_ms = client.nextBackoffMs(backoff_ms); 844 backoff_ms = client.nextBackoffMs(backoff_ms);
src/tui/wall_picker.zig
Old New
@@ -191,15 +191,7 @@ pub fn pickBirth(w: Wall, sel: usize) ?usize {
191 // is untouched — the poller re-dials off it every second, and a wall 191 // is untouched — the poller re-dials off it every second, and a wall
192 // must not resurrect a daemon whose owner just stopped it. 192 // must not resurrect a daemon whose owner just stopped it.
193 var target = h.spec.target; 193 var target = h.spec.target;
194 if (target == .hand) { 194 if (target == .hand) target.hand.asked = true;
195 target.hand.asked = true;
196 // ...and quietly. This dial happens on a tile thread, under the
197 // wall's alternate screen: the remote `mux d endpoint --start`'s
198 // progress rides the ssh's stderr and would land over tiles and
199 // rails, where the tile's own `connecting` label is already saying
200 // the only thing there is to say.
201 target.hand.quiet = true;
202 }
203 var list_buf: [proto.sessions_text_max]u8 = undefined; 195 var list_buf: [proto.sessions_text_max]u8 = undefined;
204 const list = h.poll.snapshot(&list_buf); 196 const list = h.poll.snapshot(&list_buf);
205 // The daemon's own naming, off the daemon's own list: the name the 197 // The daemon's own naming, off the daemon's own list: the name the
src/tui/wall_pump.zig
Old New
@@ -254,7 +254,12 @@ fn dial(alloc: std.mem.Allocator, t: *Tile, target_in: client.Target) ?client.Tr
254 // dead host must stop retrying, not keep a thread and a backoff alive 254 // dead host must stop retrying, not keep a thread and a backoff alive
255 // for a tile that is no longer on the wall. 255 // for a tile that is no longer on the wall.
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 // No reason kept: a tile that cannot dial says `connecting` and
258 // keeps trying, and the sentence ssh gave belongs to the picker
259 // row, which polls the same host on its own interval — ten seconds
260 // for exactly this host, since a failed `.hand` poll answers
261 // `.pipe` and `pollDelayMs` stretches those.
262 if (client.Transport.open(alloc, target, null, -1, null)) |tr| return tr else |_| {}
258 // An ask buys ONE attempt. Every retry below is the wall's own 263 // An ask buys ONE attempt. Every retry below is the wall's own
259 // idea: the asking word per backoff would restart a daemon for as 264 // 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 265 // long as the tile lives, and a fallback line per backoff would
@@ -608,19 +613,32 @@ pub fn pumpTile(t: *Tile) void {
608 // leave through it. `at` remembers which table slot each of those 613 // leave through it. `at` remembers which table slot each of those
609 // trailing fds came from, so a readable one can be traced back to 614 // trailing fds came from, so a readable one can be traced back to
610 // its channel without a second search. 615 // its channel without a second search.
611 var fdbuf: [2 + agent_locals.len]std.posix.pollfd = undefined; 616 var fdbuf: [3 + agent_locals.len]std.posix.pollfd = undefined;
612 fdbuf[0] = .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }; 617 fdbuf[0] = .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 };
613 fdbuf[1] = .{ .fd = t.wake_r, .events = std.posix.POLL.IN, .revents = 0 }; 618 fdbuf[1] = .{ .fd = t.wake_r, .events = std.posix.POLL.IN, .revents = 0 };
614 var nfds: usize = 2; 619 var nfds: usize = 2;
620 // A handoff that stayed on the ssh pipe has a third fd: ssh's
621 // stderr. Unread it fills at 64k and ssh stops talking to the far
622 // end at all, which is this tile going silent for a reason no
623 // frame can explain.
624 const err_slot: ?usize = if (transport.errFd()) |efd| blk: {
625 fdbuf[nfds] = .{ .fd = efd, .events = std.posix.POLL.IN, .revents = 0 };
626 nfds += 1;
627 break :blk nfds - 1;
628 } else null;
629 // Where the agent fds start, since the stderr slot may or may not
630 // be there: `at` maps a readable trailing fd back to its channel.
631 const agent_base = nfds;
615 var at: [agent_locals.len]usize = undefined; 632 var at: [agent_locals.len]usize = undefined;
616 for (agent_locals, 0..) |c, s| if (c) |ch| { 633 for (agent_locals, 0..) |c, s| if (c) |ch| {
617 at[nfds - 2] = s; 634 at[nfds - agent_base] = s;
618 fdbuf[nfds] = .{ .fd = ch.fd, .events = std.posix.POLL.IN, .revents = 0 }; 635 fdbuf[nfds] = .{ .fd = ch.fd, .events = std.posix.POLL.IN, .revents = 0 };
619 nfds += 1; 636 nfds += 1;
620 }; 637 };
621 _ = std.posix.poll(fdbuf[0..nfds], transport.timeoutMs(100)) catch return; 638 _ = std.posix.poll(fdbuf[0..nfds], transport.timeoutMs(100)) catch return;
622 transport.service(); 639 transport.service();
623 if (fdbuf[1].revents != 0) drainWake(t); 640 if (fdbuf[1].revents != 0) drainWake(t);
641 if (err_slot) |s| if (fdbuf[s].revents != 0) transport.drainErr();
624 642
625 // The paint offset for this pass: a relayout may have re-cut this 643 // The paint offset for this pass: a relayout may have re-cut this
626 // tile's rect, and every paint the pass drives through the Core 644 // tile's rect, and every paint the pass drives through the Core
@@ -988,9 +1006,9 @@ pub fn pumpTile(t: *Tile) void {
988 // this walks is then the one the daemon's latest word left behind, 1006 // this walks is then the one the daemon's latest word left behind,
989 // so a channel it has just closed is already gone rather than read 1007 // so a channel it has just closed is already gone rather than read
990 // once more on its way out. 1008 // once more on its way out.
991 for (2..nfds) |i| { 1009 for (agent_base..nfds) |i| {
992 if (fdbuf[i].revents == 0) continue; 1010 if (fdbuf[i].revents == 0) continue;
993 const s = at[i - 2]; 1011 const s = at[i - agent_base];
994 const ch = agent_locals[s] orelse continue; 1012 const ch = agent_locals[s] orelse continue;
995 // The id is written into the head of the very buffer the read 1013 // The id is written into the head of the very buffer the read
996 // fills, so a frame costs no second copy. The read is capped at 1014 // fills, so a frame costs no second copy. The read is capped at
src/tui/wallview.zig
Old New
@@ -1381,7 +1381,7 @@ pub fn runAttach(
1381 // mailbox once there is a tile to hand it to. 1381 // mailbox once there is a tile to hand it to.
1382 var carry: std.ArrayList(u8) = .empty; 1382 var carry: std.ArrayList(u8) = .empty;
1383 defer carry.deinit(alloc); 1383 defer carry.deinit(alloc);
1384 var transport = client.Transport.open(alloc, target, &carry, std.posix.STDIN_FILENO) catch |err| { 1384 var transport = client.Transport.open(alloc, target, &carry, std.posix.STDIN_FILENO, null) catch |err| {
1385 var buf: [client.open_err_len]u8 = undefined; 1385 var buf: [client.open_err_len]u8 = undefined;
1386 const f = client.openFailure(&buf, target, err); 1386 const f = client.openFailure(&buf, target, err);
1387 std.debug.print("{s}", .{f.msg}); 1387 std.debug.print("{s}", .{f.msg});
@@ -1424,6 +1424,13 @@ pub fn runAttach(
1424 if (!headless(std.posix.STDOUT_FILENO)) 1424 if (!headless(std.posix.STDOUT_FILENO))
1425 wall_host.otherHosts(arena, &specs, spelling, path, key, idle_ms); 1425 wall_host.otherHosts(arena, &specs, spelling, path, key, idle_ms);
1426 } 1426 }
1427 // The narration was owed to a person at a BARE prompt, and the wall is
1428 // about to take the screen. Past here fd 2 is the alternate screen, so
1429 // a late line from this ssh — `Connection to box closed by remote
1430 // host.` at the link's death — would be the foreign writer this whole
1431 // rule removes. A piped `mux` goes on relaying: a script reads stderr
1432 // and has no paint to corrupt.
1433 if (!headless(std.posix.STDOUT_FILENO)) transport.narrate = false;
1427 return run(alloc, specs.items, .{ 1434 return run(alloc, specs.items, .{
1428 .focus0 = true, 1435 .focus0 = true,
1429 .pre = transport, 1436 .pre = transport,