e2756961
feat: a stderr pipe's last line, kept
a73x 2026-08-30 10:58
Commit message
src/client/handoff.zig
| Old | New | ||
|---|---|---|---|
| @@ -82,6 +82,76 @@ pub const ReadLineError = error{ | |||
| 82 | LineTooLong, | 82 | LineTooLong, |
| 83 | }; | 83 | }; |
| 84 | 84 | ||
| 85 | /// How much of one stderr line is kept. Wide enough for ssh's longest | ||
| 86 | /// ordinary complaint (`ssh: connect to host <name> port <n>: No route to | ||
| 87 | /// host`) and narrow enough to sit on a picker row beside a spelling. | ||
| 88 | pub const reason_max = 120; | ||
| 89 | |||
| 90 | /// The last COMPLETE line a stderr pipe carried, kept across any | ||
| 91 | /// fragmentation the pipe imposes. | ||
| 92 | /// | ||
| 93 | /// Why a struct and not a buffer the reader appends to: ssh says several | ||
| 94 | /// things and dies on the last one, the reader sees arbitrary chunks, and | ||
| 95 | /// what a caller wants is one sentence. Pure — no fd, no allocator — so | ||
| 96 | /// the whole policy is testable without a process. | ||
| 97 | pub const Reason = struct { | ||
| 98 | buf: [reason_max]u8 = undefined, | ||
| 99 | len: usize = 0, | ||
| 100 | /// Bytes still accumulating toward the next line end. Separate from | ||
| 101 | /// `buf` so a dial that fails mid-sentence quotes the last finished | ||
| 102 | /// line rather than half of the one ssh was still writing. | ||
| 103 | tail: [reason_max]u8 = undefined, | ||
| 104 | tail_len: usize = 0, | ||
| 105 | |||
| 106 | pub fn feed(self: *Reason, bytes: []const u8) void { | ||
| 107 | for (bytes) |c| { | ||
| 108 | // '\n' and '\r' EACH end a line, and an empty one ends | ||
| 109 | // nothing: a remote on a pty writes `\r\n`, and treating the | ||
| 110 | // pair as two line ends would clear the reason on exactly the | ||
| 111 | // hosts whose ssh allocated a tty. | ||
| 112 | if (c == '\n' or c == '\r') { | ||
| 113 | if (self.tail_len > 0) { | ||
| 114 | @memcpy(self.buf[0..self.tail_len], self.tail[0..self.tail_len]); | ||
| 115 | self.len = self.tail_len; | ||
| 116 | self.tail_len = 0; | ||
| 117 | } | ||
| 118 | continue; | ||
| 119 | } | ||
| 120 | // PRINTABLE ASCII, and nothing else. This string is painted | ||
| 121 | // into a picker row inside a wall's alternate screen, RAW — | ||
| 122 | // it never goes through the VT engine that makes a session's | ||
| 123 | // remote bytes safe — so a byte that reached the terminal | ||
| 124 | // could move a cursor or open a sequence in somebody else's | ||
| 125 | // tile, which is the bug this whole change closes. | ||
| 126 | // | ||
| 127 | // The high half goes too, not just C0: UTF-8-encoded C1 is | ||
| 128 | // two bytes (`\xc2\x9b` is CSI) and xterm honours it in UTF-8 | ||
| 129 | // mode, so a rule that stopped at 0x7f would let an escape | ||
| 130 | // through in a second spelling. Dropping the whole half also | ||
| 131 | // ends the split-codepoint question at `reason_max`. The | ||
| 132 | // picker's own spelling editor already takes printable ASCII | ||
| 133 | // only; ssh's diagnostics are ASCII, and a hostname with | ||
| 134 | // other bytes in it loses them from this row alone. | ||
| 135 | if (c < 0x20 or c > 0x7e) continue; | ||
| 136 | // Cut, not wrapped: the head of an ssh diagnostic is the part | ||
| 137 | // that names the cause. | ||
| 138 | if (self.tail_len == reason_max) continue; | ||
| 139 | self.tail[self.tail_len] = c; | ||
| 140 | self.tail_len += 1; | ||
| 141 | } | ||
| 142 | } | ||
| 143 | |||
| 144 | /// "" until a line has finished; the caller's own wording stands. | ||
| 145 | pub fn slice(self: *const Reason) []const u8 { | ||
| 146 | return self.buf[0..self.len]; | ||
| 147 | } | ||
| 148 | |||
| 149 | pub fn clear(self: *Reason) void { | ||
| 150 | self.len = 0; | ||
| 151 | self.tail_len = 0; | ||
| 152 | } | ||
| 153 | }; | ||
| 154 | |||
| 85 | pub const CacheError = error{ | 155 | pub const CacheError = error{ |
| 86 | CacheMissing, | 156 | CacheMissing, |
| 87 | CachePermissive, | 157 | CachePermissive, |
| @@ -491,6 +561,109 @@ fn afterAnnounce(s: *State, ep: Endpoint) Step { | |||
| 491 | return .{ .dial_quic = ep }; | 561 | return .{ .dial_quic = ep }; |
| 492 | } | 562 | } |
| 493 | 563 | ||
| 564 | test "Reason: a line split across three feeds is still one line" { | ||
| 565 | // The pipe is what fragments: ssh writes a sentence and the reader | ||
| 566 | // sees whatever one `read` happened to hold. A reason that only | ||
| 567 | // survived a whole-line read would be the truncated half of a real | ||
| 568 | // diagnostic, which reads as mux's own words. | ||
| 569 | var r: Reason = .{}; | ||
| 570 | r.feed("ssh: connect to host "); | ||
| 571 | r.feed("10.255.255.1 port 22: "); | ||
| 572 | r.feed("No route to host\n"); | ||
| 573 | try std.testing.expectEqualStrings( | ||
| 574 | "ssh: connect to host 10.255.255.1 port 22: No route to host", | ||
| 575 | r.slice(), | ||
| 576 | ); | ||
| 577 | } | ||
| 578 | |||
| 579 | test "Reason: two lines keep the LAST one" { | ||
| 580 | // ssh narrates before it fails ("Warning: Permanently added..."), so | ||
| 581 | // the first line it says is rarely the reason it died. | ||
| 582 | var r: Reason = .{}; | ||
| 583 | r.feed("Warning: Permanently added 'box' to the list of known hosts.\n"); | ||
| 584 | r.feed("box: Permission denied (publickey).\n"); | ||
| 585 | try std.testing.expectEqualStrings("box: Permission denied (publickey).", r.slice()); | ||
| 586 | } | ||
| 587 | |||
| 588 | test "Reason: a CRLF pair does not put an empty line between the two halves" { | ||
| 589 | // A remote on a pty ends its lines `\r\n`. Taking `\n` as a second | ||
| 590 | // line end would leave the reason empty on exactly the hosts whose | ||
| 591 | // ssh allocated a tty. | ||
| 592 | var r: Reason = .{}; | ||
| 593 | r.feed("bad host\r\n"); | ||
| 594 | try std.testing.expectEqualStrings("bad host", r.slice()); | ||
| 595 | } | ||
| 596 | |||
| 597 | test "Reason: a bare CR ends a line too, so an overwritten one is not glued to the next" { | ||
| 598 | // `mux d endpoint: starting` narrates with a CR and no LF — a dot per | ||
| 599 | // interval, then an up-line. Counting only `\n` would hand the picker | ||
| 600 | // one run-on line built out of every progress step, with the sentence | ||
| 601 | // that actually failed buried in the middle of it. | ||
| 602 | var r: Reason = .{}; | ||
| 603 | r.feed("starting\rgave up\n"); | ||
| 604 | try std.testing.expectEqualStrings("gave up", r.slice()); | ||
| 605 | } | ||
| 606 | |||
| 607 | test "Reason: a partial line is not the reason until its newline arrives" { | ||
| 608 | // The dial can fail with bytes still unterminated in the pipe. Half a | ||
| 609 | // sentence quoted as the cause is worse than the error name it | ||
| 610 | // replaces, so it is not the reason until ssh has finished saying it. | ||
| 611 | var r: Reason = .{}; | ||
| 612 | r.feed("still typ"); | ||
| 613 | try std.testing.expectEqualStrings("", r.slice()); | ||
| 614 | r.feed("ing\n"); | ||
| 615 | try std.testing.expectEqualStrings("still typing", r.slice()); | ||
| 616 | } | ||
| 617 | |||
| 618 | test "Reason: control bytes are dropped, so nothing ssh says can move a cursor" { | ||
| 619 | // This string is painted into a picker row inside a full-screen wall. | ||
| 620 | // A byte below 0x20 (or DEL) that reached the terminal would move the | ||
| 621 | // cursor or start a sequence in the middle of somebody else's tile. | ||
| 622 | var r: Reason = .{}; | ||
| 623 | r.feed("a\tb\x07c\x7fd\n"); | ||
| 624 | try std.testing.expectEqualStrings("abcd", r.slice()); | ||
| 625 | } | ||
| 626 | |||
| 627 | test "Reason: the high half is dropped, so a C1 escape has no second spelling" { | ||
| 628 | // The row is painted RAW — no VT engine between this string and the | ||
| 629 | // terminal — and C1 has a two-byte UTF-8 form that xterm honours in | ||
| 630 | // UTF-8 mode: `\xc2\x9b` is CSI, which would open a sequence inside | ||
| 631 | // somebody else's tile from a machine the user merely listed. | ||
| 632 | var r: Reason = .{}; | ||
| 633 | r.feed("down \xc2\x9b31mred\xc2\x9b0m now\n"); | ||
| 634 | try std.testing.expectEqualStrings("down 31mred0m now", r.slice()); | ||
| 635 | } | ||
| 636 | |||
| 637 | test "Reason: an over-long line keeps its first reason_max bytes" { | ||
| 638 | // A remote can print anything. The cap is the struct's whole storage, | ||
| 639 | // so the row's width — not the far side — decides how much is kept. | ||
| 640 | var r: Reason = .{}; | ||
| 641 | r.feed("x" ** (reason_max + 40)); | ||
| 642 | r.feed("\n"); | ||
| 643 | try std.testing.expectEqual(@as(usize, reason_max), r.slice().len); | ||
| 644 | try std.testing.expectEqualStrings("x" ** reason_max, r.slice()); | ||
| 645 | } | ||
| 646 | |||
| 647 | test "Reason: an empty feed changes nothing" { | ||
| 648 | // poll can report readable on a pipe whose write end just closed, and | ||
| 649 | // the read that follows returns 0. That is EOF, not a new reason. | ||
| 650 | var r: Reason = .{}; | ||
| 651 | r.feed("kept\n"); | ||
| 652 | r.feed(""); | ||
| 653 | try std.testing.expectEqualStrings("kept", r.slice()); | ||
| 654 | } | ||
| 655 | |||
| 656 | test "Reason: clear forgets the last line and the partial one behind it" { | ||
| 657 | // A poll that succeeded says the host is reachable; the sentence that | ||
| 658 | // explained the last failure must not outlive it on the row. | ||
| 659 | var r: Reason = .{}; | ||
| 660 | r.feed("gone\nhalf"); | ||
| 661 | r.clear(); | ||
| 662 | try std.testing.expectEqualStrings("", r.slice()); | ||
| 663 | r.feed(" a line\n"); | ||
| 664 | try std.testing.expectEqualStrings(" a line", r.slice()); | ||
| 665 | } | ||
| 666 | |||
| 494 | test "announce: format → parse round-trip, with and without the newline" { | 667 | test "announce: format → parse round-trip, with and without the newline" { |
| 495 | var buf: [announce_max_len]u8 = undefined; | 668 | var buf: [announce_max_len]u8 = undefined; |
| 496 | 669 | ||