a73x

100cc396

feat: the picker row and the failure line carry ssh's last line

a73x   2026-08-30 10:58

Commit message
feat: the picker row and the failure line carry ssh's last line

A row that says only `unreachable` cannot be acted on: a box that is
off, a key that was refused and a name that does not resolve all read
the same. ssh said which, and now that mux reads its stderr the sentence
is here to show.

The poller keeps its failure's last line beside its list, under the same
lock, and clears it on any poll that answers — a reason that outlived
its failure would blame a box that is up, which is where a poller spends
most of its life. `unreachable: <reason>` is cut to the row's own
buffer, by hand: `bufPrint` leaves the buffer unwritten when it fails,
and truncating is this call's whole contract.

The entry dial's failure line takes the same sentence over both of its
old wordings. `UnterminatedLine` named what mux observed; `No route to
host` names what happened. With ssh silent the two old lines stand byte
for byte, which is the case they were written for.

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

src/client/client.zig
Old New
@@ -1097,7 +1097,7 @@ const open_aborted: OpenFailure = .{ .msg = "mux: aborted before attaching\n", .
1097 /// them. The cost is that a misspelled prong is not a compile error — it 1097 /// them. The cost is that a misspelled prong is not a compile error — it
1098 /// just falls to the `else` arm and quietly loses its class. What refuses 1098 /// just falls to the `else` arm and quietly loses its class. What refuses
1099 /// that is the twelve literal pins below, never the signature. 1099 /// that is the twelve literal pins below, never the signature.
1100 pub fn openFailure(buf: []u8, target: Target, err: anyerror) OpenFailure { 1100 pub fn openFailure(buf: []u8, target: Target, err: anyerror, reason: []const u8) OpenFailure {
1101 return switch (target) { 1101 return switch (target) {
1102 // A key the daemon would also have refused, said in the same 1102 // A key the daemon would also have refused, said in the same
1103 // words, because the user's mistake is the same one. 1103 // words, because the user's mistake is the same one.
@@ -1147,7 +1147,14 @@ pub fn openFailure(buf: []u8, target: Target, err: anyerror) OpenFailure {
1147 // Ctrl-\ while dialling or while waiting for the announce: the 1147 // Ctrl-\ while dialling or while waiting for the announce: the
1148 // same answer the quic:// arm gives, for the same keystroke. 1148 // same answer the quic:// arm gives, for the same keystroke.
1149 error.UserAbort => open_aborted, 1149 error.UserAbort => open_aborted,
1150 else => if (announceFailed(err)) 1150 // ssh, or the remote, said why in a whole sentence. It beats
1151 // both of the lines below: `UnterminatedLine` names what mux
1152 // observed, while `No route to host` names what happened.
1153 else => if (reason.len > 0) failedMsg(
1154 buf,
1155 "mux: {s} over ssh: {s}\n",
1156 .{ h.host, reason },
1157 ) else if (announceFailed(err))
1151 // All this observes is that we waited for an announce 1158 // All this observes is that we waited for an announce
1152 // and did not get one. Whether ssh reached the host is 1159 // and did not get one. Whether ssh reached the host is
1153 // NOT knowable from here: a parse failure does prove 1160 // NOT knowable from here: a parse failure does prove
@@ -1160,8 +1167,9 @@ pub fn openFailure(buf: []u8, target: Target, err: anyerror) OpenFailure {
1160 // So the line claims only the observation, which is 1167 // So the line claims only the observation, which is
1161 // weakly true in every one of those cases, where 1168 // weakly true in every one of those cases, where
1162 // "cannot reach" was strongly false in some of them. 1169 // "cannot reach" was strongly false in some of them.
1163 // The cause is left to ssh's own stderr, which mux now 1170 //
1164 // reads rather than inherits. 1171 // Reached only when ssh finished no line at all: with one,
1172 // the arm above quotes it instead.
1165 failedMsg( 1173 failedMsg(
1166 buf, 1174 buf,
1167 "mux: no endpoint announce from {s} over ssh ({s})\n", 1175 "mux: no endpoint announce from {s} over ssh ({s})\n",
@@ -1445,6 +1453,9 @@ pub const SessionPoll = struct {
1445 /// News for the reader: a poll finished, well or badly. 1453 /// News for the reader: a poll finished, well or badly.
1446 list_ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), 1454 list_ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
1447 reachable: std.atomic.Value(bool) = std.atomic.Value(bool).init(true), 1455 reachable: std.atomic.Value(bool) = std.atomic.Value(bool).init(true),
1456 /// Why the last poll failed, in ssh's own words. Under `list_mu` with
1457 /// the list, because a row paints both in one pass.
1458 reason: handoff.Reason = .{},
1448 /// A birth asks for the next poll NOW rather than in a second — no 1459 /// A birth asks for the next poll NOW rather than in a second — no
1449 /// wall may lag the session the user just made. 1460 /// wall may lag the session the user just made.
1450 poke: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), 1461 poke: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
@@ -1460,6 +1471,17 @@ pub const SessionPoll = struct {
1460 return buf[0..self.list_len]; 1471 return buf[0..self.list_len];
1461 } 1472 }
1462 1473
1474 /// `snapshot`'s shape for the other half of a row: copied out from
1475 /// under the lock, because the poller overwrites its own on every
1476 /// cycle.
1477 pub fn reasonSnapshot(self: *SessionPoll, buf: *[handoff.reason_max]u8) []const u8 {
1478 self.list_mu.lock();
1479 defer self.list_mu.unlock();
1480 const said = self.reason.slice();
1481 @memcpy(buf[0..said.len], said);
1482 return buf[0..said.len];
1483 }
1484
1463 /// Runtime hooks, not a comptime context: one compiled loop, two 1485 /// Runtime hooks, not a comptime context: one compiled loop, two
1464 /// fronts, and a test may hand it a counter. 1486 /// fronts, and a test may hand it a counter.
1465 pub const Hooks = struct { 1487 pub const Hooks = struct {
@@ -1477,14 +1499,26 @@ pub const SessionPoll = struct {
1477 // thread owns no transport between polls that a teardown would 1499 // thread owns no transport between polls that a teardown would
1478 // have to reach. 1500 // have to reach.
1479 var link: std.meta.Tag(Link) = .fd; 1501 var link: std.meta.Tag(Link) = .fd;
1480 const got = listSessions(std.heap.page_allocator, target, &out, 2000, &link, null) catch null; 1502 // The poll's OWN reason, copied in under the lock below: this
1503 // thread is the only writer, and a row must never read a
1504 // sentence being written into it.
1505 var said: handoff.Reason = .{};
1506 const got = listSessions(std.heap.page_allocator, target, &out, 2000, &link, &said) catch null;
1481 if (got) |list| { 1507 if (got) |list| {
1482 self.list_mu.lock(); 1508 self.list_mu.lock();
1483 @memcpy(self.list[0..list.len], list); 1509 @memcpy(self.list[0..list.len], list);
1484 self.list_len = list.len; 1510 self.list_len = list.len;
1511 // A host that answered has no reason to give, and last
1512 // cycle's would sit on a reachable row forever.
1513 self.reason.clear();
1485 self.list_mu.unlock(); 1514 self.list_mu.unlock();
1486 self.reachable.store(true, .release); 1515 self.reachable.store(true, .release);
1487 } else self.reachable.store(false, .release); 1516 } else {
1517 self.list_mu.lock();
1518 self.reason = said;
1519 self.list_mu.unlock();
1520 self.reachable.store(false, .release);
1521 }
1488 self.list_ready.store(true, .release); 1522 self.list_ready.store(true, .release);
1489 hooks.wake(hooks.ctx); 1523 hooks.wake(hooks.ctx);
1490 var slept: u64 = 0; 1524 var slept: u64 = 0;
@@ -1554,6 +1588,55 @@ test "SessionPoll.run: a keep that says stop is felt within one sleep slice, not
1554 try std.testing.expect(elapsed < 1_000); 1588 try std.testing.expect(elapsed < 1_000);
1555 } 1589 }
1556 1590
1591 test "SessionPoll: a failed poll keeps ssh's last line, and a good one clears it" {
1592 // The picker row's other half. `unreachable` alone tells the user
1593 // nothing they can act on; `No route to host` tells them the box is
1594 // off and `Permission denied` tells them it is not.
1595 var tmp = try TmpDir.make();
1596 defer tmp.cleanup();
1597
1598 // One pass of the loop per run: `keep` says yes once, then no.
1599 const Ctx = struct {
1600 left: u32,
1601 fn keep(p: *anyopaque) bool {
1602 const self: *@This() = @ptrCast(@alignCast(p));
1603 if (self.left == 0) return false;
1604 self.left -= 1;
1605 return true;
1606 }
1607 fn wake(_: *anyopaque) void {}
1608 };
1609 var poll: SessionPoll = .{};
1610
1611 // A host whose ssh dies with a sentence, which is what a box that is
1612 // off the network looks like from here.
1613 var ctx = Ctx{ .left = 1 };
1614 poll.run(.{ .hand = .{
1615 .host = "nowhere",
1616 .ssh_argv = &.{ "/bin/sh", "-c", "printf 'ssh: no route\n' >&2; exit 255" },
1617 .cache_path = null,
1618 .deadline_ms = 200,
1619 } }, .{ .ctx = &ctx, .keep = Ctx.keep, .wake = Ctx.wake });
1620 try std.testing.expect(!poll.reachable.load(.acquire));
1621 var said_buf: [handoff.reason_max]u8 = undefined;
1622 try std.testing.expectEqualStrings("ssh: no route", poll.reasonSnapshot(&said_buf));
1623
1624 // The SAME poller then reaches a daemon. A reason that outlived its
1625 // failure would sit on a row that is answering, blaming a box that is
1626 // up — which is the state a poller spends most of its life in.
1627 const sp = try std.fmt.allocPrint(std.testing.allocator, "{s}/heal.sock", .{tmp.path()});
1628 defer std.testing.allocator.free(sp);
1629 const addr = try std.net.Address.initUnix(sp);
1630 var fake = ListFake{ .listener = try addr.listen(.{}), .reply = "0\n" };
1631 defer fake.listener.deinit();
1632 const th = try std.Thread.spawn(.{}, ListFake.serve, .{&fake});
1633 ctx = Ctx{ .left = 1 };
1634 poll.run(.{ .sock = sp }, .{ .ctx = &ctx, .keep = Ctx.keep, .wake = Ctx.wake });
1635 th.join();
1636 try std.testing.expect(poll.reachable.load(.acquire));
1637 try std.testing.expectEqualStrings("", poll.reasonSnapshot(&said_buf));
1638 }
1639
1557 test "reconnect backoff: 0 then 200 doubling to the 2s cap, never beyond" { 1640 test "reconnect backoff: 0 then 200 doubling to the 2s cap, never beyond" {
1558 try std.testing.expectEqual(@as(u64, 200), nextBackoffMs(0)); 1641 try std.testing.expectEqual(@as(u64, 200), nextBackoffMs(0));
1559 try std.testing.expectEqual(@as(u64, 400), nextBackoffMs(200)); 1642 try std.testing.expectEqual(@as(u64, 400), nextBackoffMs(200));
@@ -2419,26 +2502,26 @@ test "openFailure: a quic:// target names the key or the address, and only an ab
2419 // catches it at. 2502 // catches it at.
2420 try std.testing.expectEqualStrings( 2503 try std.testing.expectEqualStrings(
2421 "mux: no such key file: /etc/mux/key\n", 2504 "mux: no such key file: /etc/mux/key\n",
2422 openFailure(&buf, q, error.KeyFileMissing).msg, 2505 openFailure(&buf, q, error.KeyFileMissing, "").msg,
2423 ); 2506 );
2424 try std.testing.expectEqualStrings( 2507 try std.testing.expectEqualStrings(
2425 "mux: /etc/mux/key is readable by group or other; chmod 600 it\n", 2508 "mux: /etc/mux/key is readable by group or other; chmod 600 it\n",
2426 openFailure(&buf, q, error.KeyFilePermissive).msg, 2509 openFailure(&buf, q, error.KeyFilePermissive, "").msg,
2427 ); 2510 );
2428 try std.testing.expectEqualStrings( 2511 try std.testing.expectEqualStrings(
2429 "mux: /etc/mux/key is not a key: want 32 raw bytes or 64 hex characters\n", 2512 "mux: /etc/mux/key is not a key: want 32 raw bytes or 64 hex characters\n",
2430 openFailure(&buf, q, error.KeyFileMalformed).msg, 2513 openFailure(&buf, q, error.KeyFileMalformed, "").msg,
2431 ); 2514 );
2432 2515
2433 // Both address failures are one class: a name that will not resolve and 2516 // Both address failures are one class: a name that will not resolve and
2434 // a string that will not parse are the same fact to the user. 2517 // a string that will not parse are the same fact to the user.
2435 try std.testing.expectEqualStrings( 2518 try std.testing.expectEqualStrings(
2436 "mux: cannot resolve quic://box:4433\n", 2519 "mux: cannot resolve quic://box:4433\n",
2437 openFailure(&buf, q, error.MalformedAddress).msg, 2520 openFailure(&buf, q, error.MalformedAddress, "").msg,
2438 ); 2521 );
2439 try std.testing.expectEqualStrings( 2522 try std.testing.expectEqualStrings(
2440 "mux: cannot resolve quic://box:4433\n", 2523 "mux: cannot resolve quic://box:4433\n",
2441 openFailure(&buf, q, error.UnknownHostName).msg, 2524 openFailure(&buf, q, error.UnknownHostName, "").msg,
2442 ); 2525 );
2443 2526
2444 // The handshake line must keep naming both causes: a wrong key and an 2527 // The handshake line must keep naming both causes: a wrong key and an
@@ -2446,23 +2529,23 @@ test "openFailure: a quic:// target names the key or the address, and only an ab
2446 // half would turn an honest ambiguity into a wrong guess. 2529 // half would turn an honest ambiguity into a wrong guess.
2447 try std.testing.expectEqualStrings( 2530 try std.testing.expectEqualStrings(
2448 "mux: quic://box:4433 did not answer (wrong key, or no mux d --quic there)\n", 2531 "mux: quic://box:4433 did not answer (wrong key, or no mux d --quic there)\n",
2449 openFailure(&buf, q, error.QuicHandshakeFailed).msg, 2532 openFailure(&buf, q, error.QuicHandshakeFailed, "").msg,
2450 ); 2533 );
2451 2534
2452 // Anything unclassified still reports the error's name rather than 2535 // Anything unclassified still reports the error's name rather than
2453 // inventing a cause for it. 2536 // inventing a cause for it.
2454 try std.testing.expectEqualStrings( 2537 try std.testing.expectEqualStrings(
2455 "mux: cannot reach quic://box:4433: ConnectionRefused\n", 2538 "mux: cannot reach quic://box:4433: ConnectionRefused\n",
2456 openFailure(&buf, q, error.ConnectionRefused).msg, 2539 openFailure(&buf, q, error.ConnectionRefused, "").msg,
2457 ); 2540 );
2458 2541
2459 // Every one of those is a failure and exits 1. 2542 // Every one of those is a failure and exits 1.
2460 try std.testing.expectEqual(@as(u8, 1), openFailure(&buf, q, error.KeyFileMissing).exit); 2543 try std.testing.expectEqual(@as(u8, 1), openFailure(&buf, q, error.KeyFileMissing, "").exit);
2461 try std.testing.expectEqual(@as(u8, 1), openFailure(&buf, q, error.QuicHandshakeFailed).exit); 2544 try std.testing.expectEqual(@as(u8, 1), openFailure(&buf, q, error.QuicHandshakeFailed, "").exit);
2462 2545
2463 // The abort is not one of them: the user asked to stop, so the line 2546 // The abort is not one of them: the user asked to stop, so the line
2464 // says the attach ended and the status says nothing went wrong. 2547 // says the attach ended and the status says nothing went wrong.
2465 const ab = openFailure(&buf, q, error.UserAbort); 2548 const ab = openFailure(&buf, q, error.UserAbort, "");
2466 try std.testing.expectEqualStrings("mux: aborted before attaching\n", ab.msg); 2549 try std.testing.expectEqualStrings("mux: aborted before attaching\n", ab.msg);
2467 try std.testing.expectEqual(@as(u8, 0), ab.exit); 2550 try std.testing.expectEqual(@as(u8, 0), ab.exit);
2468 } 2551 }
@@ -2480,11 +2563,11 @@ test "openFailure: a handoff separates a missing announce from an ssh that never
2480 // read error — because the classification is what decides the wording. 2563 // read error — because the classification is what decides the wording.
2481 try std.testing.expectEqualStrings( 2564 try std.testing.expectEqualStrings(
2482 "mux: no endpoint announce from box over ssh (AnnounceMissingPrefix)\n", 2565 "mux: no endpoint announce from box over ssh (AnnounceMissingPrefix)\n",
2483 openFailure(&buf, h, error.AnnounceMissingPrefix).msg, 2566 openFailure(&buf, h, error.AnnounceMissingPrefix, "").msg,
2484 ); 2567 );
2485 try std.testing.expectEqualStrings( 2568 try std.testing.expectEqualStrings(
2486 "mux: no endpoint announce from box over ssh (UnterminatedLine)\n", 2569 "mux: no endpoint announce from box over ssh (UnterminatedLine)\n",
2487 openFailure(&buf, h, error.UnterminatedLine).msg, 2570 openFailure(&buf, h, error.UnterminatedLine, "").msg,
2488 ); 2571 );
2489 2572
2490 // Never got that far: the command would not spawn, or the pipe failed. 2573 // Never got that far: the command would not spawn, or the pipe failed.
@@ -2493,16 +2576,57 @@ test "openFailure: a handoff separates a missing announce from an ssh that never
2493 // not claim we waited for a line we never got to wait for. 2576 // not claim we waited for a line we never got to wait for.
2494 try std.testing.expectEqualStrings( 2577 try std.testing.expectEqualStrings(
2495 "mux: cannot reach box over ssh: AccessDenied\n", 2578 "mux: cannot reach box over ssh: AccessDenied\n",
2496 openFailure(&buf, h, error.AccessDenied).msg, 2579 openFailure(&buf, h, error.AccessDenied, "").msg,
2497 ); 2580 );
2498 try std.testing.expectEqual(@as(u8, 1), openFailure(&buf, h, error.AccessDenied).exit); 2581 try std.testing.expectEqual(@as(u8, 1), openFailure(&buf, h, error.AccessDenied, "").exit);
2499 2582
2500 // The abort answers the same as the quic:// arm, for the same keystroke. 2583 // The abort answers the same as the quic:// arm, for the same keystroke.
2501 const ab = openFailure(&buf, h, error.UserAbort); 2584 const ab = openFailure(&buf, h, error.UserAbort, "");
2502 try std.testing.expectEqualStrings("mux: aborted before attaching\n", ab.msg); 2585 try std.testing.expectEqualStrings("mux: aborted before attaching\n", ab.msg);
2503 try std.testing.expectEqual(@as(u8, 0), ab.exit); 2586 try std.testing.expectEqual(@as(u8, 0), ab.exit);
2504 } 2587 }
2505 2588
2589 test "openFailure: a reason replaces the error name for a hand target" {
2590 // What the user actually needs is the sentence ssh printed. Before the
2591 // stderr pipe it was not mux's to have — it went straight to the
2592 // terminal, and this line could only say what mux had OBSERVED, which
2593 // is that no announce arrived.
2594 var buf: [open_err_len]u8 = undefined;
2595 const h: Target = .{ .hand = .{ .host = "box", .ssh_argv = &.{"ssh"}, .cache_path = null } };
2596 const said = "ssh: connect to host box port 22: No route to host";
2597
2598 // Both arms of the hand target — the one that read a broken announce
2599 // and the one that could not spawn ssh at all — because a reason is a
2600 // better answer than either error name, and one arm keeping its name
2601 // would be the half nobody notices.
2602 try std.testing.expectEqualStrings(
2603 "mux: box over ssh: ssh: connect to host box port 22: No route to host\n",
2604 openFailure(&buf, h, error.UnterminatedLine, said).msg,
2605 );
2606 try std.testing.expectEqualStrings(
2607 "mux: box over ssh: ssh: connect to host box port 22: No route to host\n",
2608 openFailure(&buf, h, error.AccessDenied, said).msg,
2609 );
2610 // ...and with nothing said, the two old lines stand byte for byte: a
2611 // silent ssh is exactly the case they were written for.
2612 try std.testing.expectEqualStrings(
2613 "mux: no endpoint announce from box over ssh (UnterminatedLine)\n",
2614 openFailure(&buf, h, error.UnterminatedLine, "").msg,
2615 );
2616 try std.testing.expectEqualStrings(
2617 "mux: cannot reach box over ssh: AccessDenied\n",
2618 openFailure(&buf, h, error.AccessDenied, "").msg,
2619 );
2620 // An abort is still not a failure, whatever ssh had been saying.
2621 try std.testing.expectEqual(@as(u8, 0), openFailure(&buf, h, error.UserAbort, said).exit);
2622 // Other transports have no ssh to quote, so the reason is not theirs
2623 // to print even when a caller passes one.
2624 try std.testing.expectEqualStrings(
2625 "mux: cannot connect to /run/muxd.sock\n",
2626 openFailure(&buf, .{ .sock = "/run/muxd.sock" }, error.ConnectionRefused, said).msg,
2627 );
2628 }
2629
2506 test "openFailure: --via and --sock say what they know and nothing more" { 2630 test "openFailure: --via and --sock say what they know and nothing more" {
2507 var buf: [open_err_len]u8 = undefined; 2631 var buf: [open_err_len]u8 = undefined;
2508 2632
@@ -2511,11 +2635,11 @@ test "openFailure: --via and --sock say what they know and nothing more" {
2511 const v: Target = .{ .via = "ssh box mux d proxy" }; 2635 const v: Target = .{ .via = "ssh box mux d proxy" };
2512 try std.testing.expectEqualStrings( 2636 try std.testing.expectEqualStrings(
2513 "mux: cannot start --via command: ssh box mux d proxy\n", 2637 "mux: cannot start --via command: ssh box mux d proxy\n",
2514 openFailure(&buf, v, error.FileNotFound).msg, 2638 openFailure(&buf, v, error.FileNotFound, "").msg,
2515 ); 2639 );
2516 try std.testing.expectEqualStrings( 2640 try std.testing.expectEqualStrings(
2517 "mux: cannot start --via command: ssh box mux d proxy\n", 2641 "mux: cannot start --via command: ssh box mux d proxy\n",
2518 openFailure(&buf, v, error.AccessDenied).msg, 2642 openFailure(&buf, v, error.AccessDenied, "").msg,
2519 ); 2643 );
2520 2644
2521 // No "is the daemon running?" — auto-start checked that moments ago, so the 2645 // No "is the daemon running?" — auto-start checked that moments ago, so the
@@ -2523,14 +2647,14 @@ test "openFailure: --via and --sock say what they know and nothing more" {
2523 const s: Target = .{ .sock = "/run/muxd.sock" }; 2647 const s: Target = .{ .sock = "/run/muxd.sock" };
2524 try std.testing.expectEqualStrings( 2648 try std.testing.expectEqualStrings(
2525 "mux: cannot connect to /run/muxd.sock\n", 2649 "mux: cannot connect to /run/muxd.sock\n",
2526 openFailure(&buf, s, error.ConnectionRefused).msg, 2650 openFailure(&buf, s, error.ConnectionRefused, "").msg,
2527 ); 2651 );
2528 2652
2529 // Neither transport can abort — there is no dial to interrupt — so 2653 // Neither transport can abort — there is no dial to interrupt — so
2530 // both exit 1 for every error, the abort key's included. 2654 // both exit 1 for every error, the abort key's included.
2531 try std.testing.expectEqual(@as(u8, 1), openFailure(&buf, v, error.FileNotFound).exit); 2655 try std.testing.expectEqual(@as(u8, 1), openFailure(&buf, v, error.FileNotFound, "").exit);
2532 try std.testing.expectEqual(@as(u8, 1), openFailure(&buf, s, error.ConnectionRefused).exit); 2656 try std.testing.expectEqual(@as(u8, 1), openFailure(&buf, s, error.ConnectionRefused, "").exit);
2533 try std.testing.expectEqual(@as(u8, 1), openFailure(&buf, v, error.UserAbort).exit); 2657 try std.testing.expectEqual(@as(u8, 1), openFailure(&buf, v, error.UserAbort, "").exit);
2534 } 2658 }
2535 2659
2536 test "openFailure: a message too long for the buffer clips, and still fails" { 2660 test "openFailure: a message too long for the buffer clips, and still fails" {
@@ -2541,7 +2665,7 @@ test "openFailure: a message too long for the buffer clips, and still fails" {
2541 // message or a crash, and no other test goes near the boundary. 2665 // message or a crash, and no other test goes near the boundary.
2542 const cmd = "x" ** (open_err_len + 808); 2666 const cmd = "x" ** (open_err_len + 808);
2543 var buf: [open_err_len]u8 = undefined; 2667 var buf: [open_err_len]u8 = undefined;
2544 const f = openFailure(&buf, .{ .via = cmd }, error.FileNotFound); 2668 const f = openFailure(&buf, .{ .via = cmd }, error.FileNotFound, "");
2545 2669
2546 // Full buffer, and it really is the message's own prefix: the fixed 2670 // Full buffer, and it really is the message's own prefix: the fixed
2547 // text first, then as much of the command as fit. 2671 // text first, then as much of the command as fit.
src/tui/wall_picker.zig
Old New
@@ -8,6 +8,7 @@ const std = @import("std");
8 const proto = @import("protocol"); 8 const proto = @import("protocol");
9 const client = @import("client"); 9 const client = @import("client");
10 const hosts = @import("hosts"); 10 const hosts = @import("hosts");
11 const handoff = @import("handoff");
11 const interact = @import("interact"); 12 const interact = @import("interact");
12 const wall_host = @import("wall_host.zig"); 13 const wall_host = @import("wall_host.zig");
13 const wall_layout = @import("wall_layout.zig"); 14 const wall_layout = @import("wall_layout.zig");
@@ -53,7 +54,23 @@ pub fn hostState(buf: []u8, h: *Host) []const u8 {
53 // so a host that has never reported is `connecting` — not `no 54 // so a host that has never reported is `connecting` — not `no
54 // sessions`, which would send the user to birth on a dead machine. 55 // sessions`, which would send the user to birth on a dead machine.
55 if (!h.applied) return "connecting"; 56 if (!h.applied) return "connecting";
56 if (!h.poll.reachable.load(.acquire)) return "unreachable"; 57 if (!h.poll.reachable.load(.acquire)) {
58 // ssh's own sentence when there is one — `No route to host`,
59 // `Permission denied (publickey)` — because `unreachable` alone
60 // tells the user nothing they can act on. Cut to `buf`, which is
61 // the row's, so a long line shortens rather than overflowing.
62 var said_buf: [handoff.reason_max]u8 = undefined;
63 const said = h.poll.reasonSnapshot(&said_buf);
64 const head = "unreachable: ";
65 // Written by hand rather than by `bufPrint`, whose failure leaves
66 // `buf` UNWRITTEN: a caller with a narrow buffer would then paint
67 // whatever the stack held. Truncating is the whole contract here.
68 if (said.len == 0 or buf.len <= head.len) return "unreachable";
69 const n = @min(said.len, buf.len - head.len);
70 @memcpy(buf[0..head.len], head);
71 @memcpy(buf[head.len..][0..n], said[0..n]);
72 return buf[0 .. head.len + n];
73 }
57 var n: usize = 0; 74 var n: usize = 0;
58 h.poll.list_mu.lock(); 75 h.poll.list_mu.lock();
59 var it = std.mem.splitScalar(u8, h.poll.list[0..h.poll.list_len], '\n'); 76 var it = std.mem.splitScalar(u8, h.poll.list[0..h.poll.list_len], '\n');
@@ -69,6 +86,11 @@ pub fn hostState(buf: []u8, h: *Host) []const u8 {
69 return std.fmt.bufPrint(buf, "{d} sessions", .{n}) catch "sessions"; 86 return std.fmt.bufPrint(buf, "{d} sessions", .{n}) catch "sessions";
70 } 87 }
71 88
89 /// The row's fixed left column: ` N` or ` NN`, plus the marker.
90 pub fn rowHeadLen(wide: bool) usize {
91 return 1 + @as(usize, if (wide) 2 else 1) + 2;
92 }
93
72 /// One row: ` N> SPELLING STATE `, padded to `cols` so the box has an 94 /// One row: ` N> SPELLING STATE `, padded to `cols` so the box has an
73 /// edge. The spelling is cut from the LEFT, because the head of a socket 95 /// edge. The spelling is cut from the LEFT, because the head of a socket
74 /// path is what every daemon on one machine has in common. 96 /// path is what every daemon on one machine has in common.
@@ -83,6 +105,10 @@ pub fn pickerRow(out: []u8, n: usize, wide: bool, spelling: []const u8, state: [
83 std.fmt.bufPrint(&head_buf, " {d: >2}{s}", .{ n, marker }) 105 std.fmt.bufPrint(&head_buf, " {d: >2}{s}", .{ n, marker })
84 else 106 else
85 std.fmt.bufPrint(&head_buf, " {d}{s}", .{ n, marker })) catch return out[0..0]; 107 std.fmt.bufPrint(&head_buf, " {d}{s}", .{ n, marker })) catch return out[0..0];
108 // The two spellings of one width, tied together: `pickerRows` budgets
109 // the state against `rowHeadLen`, and a head that grew here without it
110 // would hand the state columns this row does not have.
111 std.debug.assert(head.len == rowHeadLen(wide));
86 const room = @min(@as(usize, cols), out.len); 112 const room = @min(@as(usize, cols), out.len);
87 const field = room -| head.len -| state.len -| 2; 113 const field = room -| head.len -| state.len -| 2;
88 const cut = if (spelling.len > field) spelling[spelling.len - field ..] else spelling; 114 const cut = if (spelling.len > field) spelling[spelling.len - field ..] else spelling;
@@ -435,8 +461,23 @@ pub fn pickerRows(body: *PickerBody, host_table: []Host, sel: usize, cols: u16)
435 const wide = listed >= 10; 461 const wide = listed >= 10;
436 for (host_table, 0..) |*h, hi| { 462 for (host_table, 0..) |*h, hi| {
437 if (h.forgotten.load(.acquire)) continue; 463 if (h.forgotten.load(.acquire)) continue;
438 var state_buf: [24]u8 = undefined; 464 // Wide enough for the longest thing `hostState` can say: an
439 const state = hostState(&state_buf, h); 465 // `unreachable` carrying a whole `handoff.Reason`.
466 // The state gets what the row can SPARE, never all it could fill.
467 // `pickerRow` cuts the spelling and never the state, and the state
468 // now carries a whole ssh sentence where it used to carry a word:
469 // unbudgeted, a 71-byte `unreachable: ...` leaves three columns of
470 // the host name on an 80-column terminal — evicting the one column
471 // that says which machine Enter would start a session on.
472 //
473 // The floor is the longest word the state could say BEFORE this
474 // change, so a row too narrow for a reason is exactly the row it
475 // always was: `hostState` answers a buffer that small with the
476 // bare `unreachable`.
477 var state_buf: ["unreachable: ".len + handoff.reason_max]u8 = undefined;
478 const spare = @as(usize, cols) -| rowHeadLen(wide) -| h.spec.spelling.len -| 2;
479 const room = @max("unreachable".len, @min(spare, state_buf.len));
480 const state = hostState(state_buf[0..room], h);
440 const at = body.n; 481 const at = body.n;
441 body.host[at] = hi; 482 body.host[at] = hi;
442 body.lens[at] = pickerRow(&body.text[at], at + 1, wide, h.spec.spelling, state, hi == sel, cols).len; 483 body.lens[at] = pickerRow(&body.text[at], at + 1, wide, h.spec.spelling, state, hi == sel, cols).len;
src/tui/wall_test_picker.zig
Old New
@@ -361,6 +361,39 @@ test "pickerRows: one session is not 1 sessions" {
361 try std.testing.expectEqualStrings(" 1> box 1 session ", body.row(0)); 361 try std.testing.expectEqualStrings(" 1> box 1 session ", body.row(0));
362 } 362 }
363 363
364 test "pickerRows: a reason never costs the row the host it names" {
365 // The regression a long state introduces. `pickerRow` cuts the
366 // SPELLING and never the state, so a state that grew from a word to a
367 // whole ssh sentence would take the host name's columns: at 80 with
368 // this 17-byte spelling and the 71-byte sentence below, the row had
369 // three characters of `noroute@127.0.0.1` left — and the spelling is
370 // the one column that says which machine Enter would act on.
371 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
372 var table = [_]Host{fixture.testHost(&shared, "noroute@127.0.0.1", "/tmp/b.sock")};
373 table[0].applied = true;
374 table[0].poll.reachable.store(false, .release);
375 table[0].poll.reason.feed("ssh: connect to host 10.255.255.1 port 22: No route to host\n");
376
377 var body: PickerBody = .{};
378 wall_picker.pickerRows(&body, &table, 0, 80);
379 const row = body.row(0);
380 try std.testing.expectEqual(@as(usize, 80), row.len);
381 // Whole, and where a row with a one-word state would have put it.
382 try std.testing.expectEqualStrings(" 1> noroute@127.0.0.1", row[0..21]);
383 // ...and the reason took the rest, cut rather than dropped: the head
384 // of an ssh diagnostic is the part that names the cause.
385 try std.testing.expect(std.mem.indexOf(u8, row, "unreachable: ssh: connect to host") != null);
386 try std.testing.expect(std.mem.indexOf(u8, row, "No route to host") == null);
387
388 // A row with no room for a reason says what it always said, in the
389 // same width, and cuts the spelling by exactly as much as it always
390 // did: the floor is the longest word the state could carry before
391 // reasons existed, so this row is byte-identical to the one this
392 // fixture drew at ad3765ca.
393 wall_picker.pickerRows(&body, &table, 0, 32);
394 try std.testing.expectEqualStrings(" 1> route@127.0.0.1 unreachable ", body.row(0));
395 }
396
364 test "pickerRows: a spelling wider than the box keeps its tail" { 397 test "pickerRows: a spelling wider than the box keeps its tail" {
365 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true }; 398 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
366 var table = [_]Host{fixture.testHost( 399 var table = [_]Host{fixture.testHost(
@@ -524,3 +557,37 @@ test "hostState: the row counts the sessions the wall could show, not the runs i
524 h.poll.reachable.store(false, .release); 557 h.poll.reachable.store(false, .release);
525 try std.testing.expectEqualStrings("unreachable", wall_picker.hostState(&buf, &h)); 558 try std.testing.expectEqualStrings("unreachable", wall_picker.hostState(&buf, &h));
526 } 559 }
560
561 test "hostState: an unreachable host says what ssh said, cut to the row's buffer" {
562 // `unreachable` alone is a row the user cannot act on: a box that is
563 // off, a key that was refused and a host name that does not resolve
564 // all read the same. ssh said which, and the poll kept the line.
565 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
566 var h = Host{
567 .spec = .{ .spelling = "box", .target = .{ .sock = "/a" }, .poll_target = .{ .sock = "/a" } },
568 .shared = &shared,
569 };
570 h.applied = true;
571 h.poll.reachable.store(false, .release);
572 h.poll.reason.feed("ssh: connect to host box port 22: No route to host\n");
573
574 var wide: [wall_picker.picker_row_max]u8 = undefined;
575 try std.testing.expectEqualStrings(
576 "unreachable: ssh: connect to host box port 22: No route to host",
577 wall_picker.hostState(&wide, &h),
578 );
579
580 // The buffer is the ROW's, and a row is as wide as the terminal is.
581 // Truncation is the contract, not an error: a narrow buffer shortens
582 // the sentence rather than losing it or painting past its rect.
583 var narrow: [20]u8 = undefined;
584 try std.testing.expectEqualStrings("unreachable: ssh: co", wall_picker.hostState(&narrow, &h));
585
586 // No room for even the prefix: the old word, never a clipped one.
587 var tiny: [8]u8 = undefined;
588 try std.testing.expectEqualStrings("unreachable", wall_picker.hostState(&tiny, &h));
589
590 // A host that came back has nothing to explain.
591 h.poll.reachable.store(true, .release);
592 try std.testing.expectEqualStrings("no sessions", wall_picker.hostState(&wide, &h));
593 }
src/tui/wallview.zig
Old New
@@ -1381,9 +1381,13 @@ 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, null) catch |err| { 1384 // The one dial with a person waiting on it, so the one dial that keeps
1385 // ssh's last line: the failure below is said in ssh's words when ssh
1386 // had any, and in mux's only when it did not.
1387 var reason: handoff.Reason = .{};
1388 var transport = client.Transport.open(alloc, target, &carry, std.posix.STDIN_FILENO, &reason) catch |err| {
1385 var buf: [client.open_err_len]u8 = undefined; 1389 var buf: [client.open_err_len]u8 = undefined;
1386 const f = client.openFailure(&buf, target, err); 1390 const f = client.openFailure(&buf, target, err, reason.slice());
1387 std.debug.print("{s}", .{f.msg}); 1391 std.debug.print("{s}", .{f.msg});
1388 return f.exit; 1392 return f.exit;
1389 }; 1393 };