a73x

e144f97c

refactor: a dial leaves ssh's last line AND the pid that said it

a73x   2026-08-30 10:58

Commit message
refactor: a dial leaves ssh's last line AND the pid that said it

`handoff.Dial` replaces the bare `Reason` out-param on `Transport.open`,
`openHandoff` and `listSessions`. The second fact is what a prompt needs:
attribution. A dial that failed because its user refused a password has no
Transport to read a pid off, so the pid has to survive the failure — and a
caller free to thread two out-params is a caller free to thread one.

Recorded at the spawn rather than on the way out, because the caller that
needs it is the one the call is about to fail.

src/client/client.zig
Old New
@@ -463,15 +463,17 @@ pub const Transport = struct {
463 /// waits: stdin in the CLI, -1 (no abort channel) in a hub that 463 /// waits: stdin in the CLI, -1 (no abort channel) in a hub that
464 /// has no terminal — its stray fd 0 must never be read. 464 /// has no terminal — its stray fd 0 must never be read.
465 abort_fd: std.posix.fd_t, 465 abort_fd: std.posix.fd_t,
466 /// Where the handoff ssh's last line is left for the caller, so a 466 /// What the handoff leaves behind: ssh's last line, so a dial that
467 /// dial that failed can be reported in ssh's own words. Null is 467 /// failed is reported in ssh's own words, and the pid of the ssh
468 /// every caller that has nowhere to show it. 468 /// that said it, so a prompt that ssh raised can be answered — and
469 reason: ?*handoff.Reason, 469 /// a refusal remembered — against the one dial it belongs to. Null
470 /// is every caller that has nowhere to show either.
471 dial: ?*handoff.Dial,
470 ) !Transport { 472 ) !Transport {
471 switch (target) { 473 switch (target) {
472 // Delegated whole, because the handoff can end up producing 474 // Delegated whole, because the handoff can end up producing
473 // either of the two links below and owns the choice itself. 475 // either of the two links below and owns the choice itself.
474 .hand => |h| return openHandoff(alloc, h, carry, abort_fd, reason), 476 .hand => |h| return openHandoff(alloc, h, carry, abort_fd, dial),
475 .quic => |q| { 477 .quic => |q| {
476 const key = try quic.Key.load(q.key_path); 478 const key = try quic.Key.load(q.key_path);
477 const addr = try quic.parseAddr(alloc, q.host_port); 479 const addr = try quic.parseAddr(alloc, q.host_port);
@@ -503,7 +505,7 @@ pub const Transport = struct {
503 h: HandoffTarget, 505 h: HandoffTarget,
504 carry: ?*std.ArrayList(u8), 506 carry: ?*std.ArrayList(u8),
505 abort_fd: std.posix.fd_t, 507 abort_fd: std.posix.fd_t,
506 reason: ?*handoff.Reason, 508 dial: ?*handoff.Dial,
507 ) !Transport { 509 ) !Transport {
508 // The ORDER is `handoff.next`'s; this loop performs the step it is 510 // The ORDER is `handoff.next`'s; this loop performs the step it is
509 // handed and reports what came of it. 511 // handed and reports what came of it.
@@ -522,10 +524,11 @@ pub const Transport = struct {
522 // A caller with nowhere to show a reason still needs one kept: the 524 // A caller with nowhere to show a reason still needs one kept: the
523 // Transport carries it, and a failed dial's last line would 525 // Transport carries it, and a failed dial's last line would
524 // otherwise have to be read twice. 526 // otherwise have to be read twice.
525 var local_reason: handoff.Reason = .{}; 527 var local_dial: handoff.Dial = .{};
528 const out = dial orelse &local_dial;
526 var errp: ErrPipe = .{ 529 var errp: ErrPipe = .{
527 .fd = -1, 530 .fd = -1,
528 .reason = reason orelse &local_reason, 531 .reason = &out.reason,
529 .narrate = h.narrate, 532 .narrate = h.narrate,
530 }; 533 };
531 errdefer errp.close(); 534 errdefer errp.close();
@@ -586,6 +589,12 @@ pub const Transport = struct {
586 errp.fd = f.handle; 589 errp.fd = f.handle;
587 child.?.stderr = null; 590 child.?.stderr = null;
588 } 591 }
592 // Recorded HERE rather than on the way out, because the
593 // caller that needs it is the one this call is about to
594 // fail: a dial whose ssh asked for a password the user
595 // refused has no transport to read a pid off, and the
596 // refusal is keyed on exactly this number.
597 out.ssh_pid = child.?.id;
589 break :blk .ok; 598 break :blk .ok;
590 }, 599 },
591 .read_announce => blk: { 600 .read_announce => blk: {
@@ -1341,9 +1350,9 @@ pub fn listSessions(
1341 out: *[proto.sessions_text_max]u8, 1350 out: *[proto.sessions_text_max]u8,
1342 budget_ms: i64, 1351 budget_ms: i64,
1343 answered: ?*std.meta.Tag(Link), 1352 answered: ?*std.meta.Tag(Link),
1344 /// Where ssh's last line is left when the dial fails: the picker row 1353 /// Where the dial's leavings go when it fails: the picker row quotes
1345 /// quotes it, so a box that is down says why instead of `unreachable`. 1354 /// ssh's line, so a box that is down says why instead of `unreachable`.
1346 reason: ?*handoff.Reason, 1355 dial: ?*handoff.Dial,
1347 ) ![]const u8 { 1356 ) ![]const u8 {
1348 // A fresh connection per poll: the observer idle deadline and the 1357 // A fresh connection per poll: the observer idle deadline and the
1349 // redial backoff stay the pump's problem, and a `--via` host — an ssh 1358 // redial backoff stay the pump's problem, and a `--via` host — an ssh
@@ -1359,7 +1368,7 @@ pub fn listSessions(
1359 .quic => .quic, 1368 .quic => .quic,
1360 .sock => .fd, 1369 .sock => .fd,
1361 }; 1370 };
1362 var tr = Transport.open(alloc, target, null, -1, reason) catch |e| return oomOrTransport(e); 1371 var tr = Transport.open(alloc, target, null, -1, dial) catch |e| return oomOrTransport(e);
1363 defer tr.close(); 1372 defer tr.close();
1364 // The handoff picks its own link, so only the success case knows it. 1373 // The handoff picks its own link, so only the success case knows it.
1365 if (answered) |a| a.* = tr.link; 1374 if (answered) |a| a.* = tr.link;
@@ -1502,7 +1511,7 @@ pub const SessionPoll = struct {
1502 // The poll's OWN reason, copied in under the lock below: this 1511 // 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 1512 // thread is the only writer, and a row must never read a
1504 // sentence being written into it. 1513 // sentence being written into it.
1505 var said: handoff.Reason = .{}; 1514 var said: handoff.Dial = .{};
1506 const got = listSessions(std.heap.page_allocator, target, &out, 2000, &link, &said) catch null; 1515 const got = listSessions(std.heap.page_allocator, target, &out, 2000, &link, &said) catch null;
1507 if (got) |list| { 1516 if (got) |list| {
1508 self.list_mu.lock(); 1517 self.list_mu.lock();
@@ -1515,7 +1524,7 @@ pub const SessionPoll = struct {
1515 self.reachable.store(true, .release); 1524 self.reachable.store(true, .release);
1516 } else { 1525 } else {
1517 self.list_mu.lock(); 1526 self.list_mu.lock();
1518 self.reason = said; 1527 self.reason = said.reason;
1519 self.list_mu.unlock(); 1528 self.list_mu.unlock();
1520 self.reachable.store(false, .release); 1529 self.reachable.store(false, .release);
1521 } 1530 }
@@ -2441,7 +2450,7 @@ test "openHandoff: the handoff ssh's stderr is a pipe, and only `narrate` relays
2441 \\exit 1 2450 \\exit 1
2442 , .{ .d = tmp.path() }); 2451 , .{ .d = tmp.path() });
2443 2452
2444 var reason: handoff.Reason = .{}; 2453 var dial: handoff.Dial = .{};
2445 var cap = try CapturedStderr.install(); 2454 var cap = try CapturedStderr.install();
2446 const opened = Transport.open(alloc, .{ .hand = .{ 2455 const opened = Transport.open(alloc, .{ .hand = .{
2447 .host = "fake", 2456 .host = "fake",
@@ -2451,7 +2460,7 @@ test "openHandoff: the handoff ssh's stderr is a pipe, and only `narrate` relays
2451 .deadline_ms = 200, 2460 .deadline_ms = 200,
2452 .asked = asked, 2461 .asked = asked,
2453 .narrate = narrate, 2462 .narrate = narrate,
2454 } }, &carry, std.posix.STDIN_FILENO, &reason); 2463 } }, &carry, std.posix.STDIN_FILENO, &dial);
2455 var relayed_buf: [256]u8 = undefined; 2464 var relayed_buf: [256]u8 = undefined;
2456 const relayed = try cap.take(&relayed_buf); 2465 const relayed = try cap.take(&relayed_buf);
2457 try expectNoSession("the stderr fixture's box announced a session it has no daemon for", opened); 2466 try expectNoSession("the stderr fixture's box announced a session it has no daemon for", opened);
@@ -2462,7 +2471,11 @@ test "openHandoff: the handoff ssh's stderr is a pipe, and only `narrate` relays
2462 // Kept in every case: the picker row is painted from a dial 2471 // Kept in every case: the picker row is painted from a dial
2463 // nobody narrated, which is the whole point of keeping it here 2472 // nobody narrated, which is the whole point of keeping it here
2464 // rather than letting the bytes fall out onto a screen. 2473 // rather than letting the bytes fall out onto a screen.
2465 try std.testing.expectEqualStrings("boom: no route", reason.slice()); 2474 try std.testing.expectEqualStrings("boom: no route", dial.reason.slice());
2475 // A dial that spawned an ssh knows which one, whatever became of
2476 // it: the refusal a prompt earns is keyed on this pid, and the
2477 // dial it belongs to is the one that just failed.
2478 try std.testing.expect(dial.ssh_pid > 0);
2466 if (narrate) { 2479 if (narrate) {
2467 try std.testing.expectEqualStrings("boom: no route\n", relayed); 2480 try std.testing.expectEqualStrings("boom: no route\n", relayed);
2468 } else { 2481 } else {
src/client/handoff.zig
Old New
@@ -152,6 +152,19 @@ pub const Reason = struct {
152 } 152 }
153 }; 153 };
154 154
155 /// What one dial left behind for whoever has somewhere to show it.
156 ///
157 /// Two facts with one lifetime: a failed dial is REPORTED in ssh's own
158 /// words and ATTRIBUTED to the ssh that said them, and a caller that had
159 /// to thread two out-params would be a caller free to thread one.
160 pub const Dial = struct {
161 reason: Reason = .{},
162 /// The coordination ssh this dial spawned, 0 when it spawned none.
163 /// Whoever answers that ssh's prompts is keyed on it: a prompt the
164 /// user refused belongs to one dial, not to the host forever.
165 ssh_pid: std.posix.pid_t = 0,
166 };
167
155 pub const CacheError = error{ 168 pub const CacheError = error{
156 CacheMissing, 169 CacheMissing,
157 CachePermissive, 170 CachePermissive,
src/tui/wallview.zig
Old New
@@ -1384,10 +1384,10 @@ pub fn runAttach(
1384 // The one dial with a person waiting on it, so the one dial that keeps 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 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. 1386 // had any, and in mux's only when it did not.
1387 var reason: handoff.Reason = .{}; 1387 var dial: handoff.Dial = .{};
1388 var transport = client.Transport.open(alloc, target, &carry, std.posix.STDIN_FILENO, &reason) catch |err| { 1388 var transport = client.Transport.open(alloc, target, &carry, std.posix.STDIN_FILENO, &dial) catch |err| {
1389 var buf: [client.open_err_len]u8 = undefined; 1389 var buf: [client.open_err_len]u8 = undefined;
1390 const f = client.openFailure(&buf, target, err, reason.slice()); 1390 const f = client.openFailure(&buf, target, err, dial.reason.slice());
1391 std.debug.print("{s}", .{f.msg}); 1391 std.debug.print("{s}", .{f.msg});
1392 return f.exit; 1392 return f.exit;
1393 }; 1393 };