372411cb
refactor: the handoff ssh's stderr is a pipe, and narrate replaces quiet
a73x 2026-08-30 10:58
Commit message
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, |