a73x

e2756961

feat: a stderr pipe's last line, kept

a73x   2026-08-30 10:58

Commit message
feat: a stderr pipe's last line, kept

`Reason` is what an ssh diagnostic becomes once mux reads it instead of
letting it land on a screen: fed arbitrary chunks, it keeps the last
COMPLETE line, drops control bytes so nothing the far side prints can
move a cursor in a wall, and caps at `reason_max`.

Pure, in handoff.zig beside the announce grammar, so the whole policy
tests without a process.

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

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