100cc396
feat: the picker row and the failure line carry ssh's last line
a73x 2026-08-30 10:58
Commit message
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 | }; |