a73x

4a4fbf20

feat: the dial a user asked for starts the daemon it did not find

a73x   2026-08-28 19:53

Commit message
feat: the dial a user asked for starts the daemon it did not find

src/cli/mux_main.zig
Old New
@@ -359,6 +359,7 @@ pub fn main() !u8 {
359 return wallview.runAttach(alloc, .{ .hand = .{ 359 return wallview.runAttach(alloc, .{ .hand = .{
360 .host = h.name, 360 .host = h.name,
361 .ssh_cmd = r.ssh_cmd, 361 .ssh_cmd = r.ssh_cmd,
362 .start_cmd = r.start_cmd,
362 .cache_path = r.cache_path, 363 .cache_path = r.cache_path,
363 .idle_ms = h.idle_ms, 364 .idle_ms = h.idle_ms,
364 } }, h.session, null, h.idle_ms, h.agent); 365 } }, h.session, null, h.idle_ms, h.agent);
src/client.zig
Old New
@@ -189,6 +189,10 @@ pub const HandoffTarget = struct {
189 /// `ssh <host> muxd endpoint`, prebuilt by mux_main — it has the 189 /// `ssh <host> muxd endpoint`, prebuilt by mux_main — it has the
190 /// allocator, and it builds this once for the whole session. 190 /// allocator, and it builds this once for the whole session.
191 ssh_cmd: []const u8, 191 ssh_cmd: []const u8,
192 /// `ssh <host> muxd start`, from the same `handoff.recipeFor` call.
193 /// Empty is "nothing to start": the dial either finds a daemon or does
194 /// not.
195 start_cmd: []const u8 = "",
192 /// Where the last announce is remembered. Null means never cache (an 196 /// Where the last announce is remembered. Null means never cache (an
193 /// uncacheable host, or no resolvable cache directory): every attach is 197 /// uncacheable host, or no resolvable cache directory): every attach is
194 /// then cold, which costs time and stays correct. 198 /// then cold, which costs time and stays correct.
@@ -197,19 +201,26 @@ pub const HandoffTarget = struct {
197 /// tests can shrink it; production passes `handoff.deadline_ms`. 201 /// tests can shrink it; production passes `handoff.deadline_ms`.
198 deadline_ms: u32 = handoff.deadline_ms, 202 deadline_ms: u32 = handoff.deadline_ms,
199 idle_ms: u32 = quic_idle_ms_default, 203 idle_ms: u32 = quic_idle_ms_default,
200 /// Whether a fallback to ssh may say so. True on the ENTRY dial, where 204 /// Whether a USER asked for this dial: the entry attach and a picker
201 /// the line reports a choice the user has not seen made; false for 205 /// birth, never a poll, a reconnect or a restored tile. It gates the
202 /// every pump-side dial — a stripe's bar narrates state, and a 206 /// two things a dial may do beyond dialling.
203 /// reconnect re-runs this recipe forever against dropped UDP. 207 ///
204 /// One line per retry would scroll a live session's stderr into the 208 /// SAY SO when the session falls back to ssh. The line reports a
205 /// alternate screen and corrupt the paint, to say what the 209 /// choice the user has not seen made, and a reconnect re-runs this
206 /// [reconnecting] banner is already saying. 210 /// recipe forever against dropped UDP: one line per retry would scroll
211 /// a live session's stderr into the alternate screen and corrupt the
212 /// paint, to say what the [reconnecting] banner is already saying.
213 ///
214 /// START a daemon that was not there, via `start_cmd`. `muxd endpoint`
215 /// no longer does it: a wall polls every listed host once a second, and
216 /// a poll that starts daemons undoes a `muxd stop` a second after it is
217 /// typed.
207 /// 218 ///
208 /// The trade, stated plainly: a session that degrades to ssh mid-life 219 /// The trade, stated plainly: a session that degrades to ssh mid-life
209 /// says nothing at all about it. The user who wants to know can detach 220 /// says nothing at all about it. The user who wants to know can detach
210 /// and reattach, which reports it on the next attach; the alternative 221 /// and reattach, which reports it on the next attach; the alternative
211 /// is noise on every backoff cycle for as long as the link stays bad. 222 /// is noise on every backoff cycle for as long as the link stays bad.
212 report_fallback: bool = true, 223 asked: bool = true,
213 }; 224 };
214 225
215 /// A union, not four nullable fields — "exactly one is set" stops being a 226 /// A union, not four nullable fields — "exactly one is set" stops being a
@@ -380,7 +391,7 @@ pub const Transport = struct {
380 // carry is null by policy. Unconsumed type-ahead is not lost: it 391 // carry is null by policy. Unconsumed type-ahead is not lost: it
381 // waits in the kernel's tty buffer for the session's first read. 392 // waits in the kernel's tty buffer for the session's first read.
382 const announce_abort_fd: std.posix.fd_t = if (carry != null) -1 else abort_fd; 393 const announce_abort_fd: std.posix.fd_t = if (carry != null) -1 else abort_fd;
383 const ep = (try readAnnounceAbortable(child.stdout.?.handle, alloc, null, announce_abort_fd)) orelse { 394 const ep = (try announceOrStart(alloc, h, &child, announce_abort_fd)) orelse {
384 // `endpoint none`: the remote said, explicitly, that ssh is the 395 // `endpoint none`: the remote said, explicitly, that ssh is the
385 // session. Silent by design — no coordinates were ever in play, 396 // session. Silent by design — no coordinates were ever in play,
386 // so there is nothing here to report as having failed. 397 // so there is nothing here to report as having failed.
@@ -420,7 +431,7 @@ pub const Transport = struct {
420 // no cause: the client cannot tell a blocked port from a 431 // no cause: the client cannot tell a blocked port from a
421 // wrong key (both are silence), and guessing would be worse 432 // wrong key (both are silence), and guessing would be worse
422 // than the deadline it just spent. 433 // than the deadline it just spent.
423 if (h.report_fallback) std.debug.print( 434 if (h.asked) std.debug.print(
424 "mux: quic://{s}:{d} unreachable, attaching over ssh\n", 435 "mux: quic://{s}:{d} unreachable, attaching over ssh\n",
425 .{ handoff.dialHost(h.host), ep.port }, 436 .{ handoff.dialHost(h.host), ep.port },
426 ); 437 );
@@ -429,6 +440,45 @@ pub const Transport = struct {
429 } 440 }
430 } 441 }
431 442
443 /// One announce, or — for a dial the user ASKED for — a `muxd start`
444 /// on the far end and one more.
445 fn announceOrStart(
446 alloc: std.mem.Allocator,
447 h: HandoffTarget,
448 child: *std.process.Child,
449 abort_fd: std.posix.fd_t,
450 ) !?handoff.Endpoint {
451 return readAnnounceAbortable(child.stdout.?.handle, alloc, null, abort_fd) catch |err| {
452 // A remote with no daemon writes nothing and exits, which
453 // arrives here as an announce that never terminated.
454 // `announceFailed` is that "ssh worked, the announce did not"
455 // shape: a start is worth trying only when the far end was
456 // reached and had nothing to say.
457 if (!h.asked or h.start_cmd.len == 0 or !announceFailed(err)) return err;
458 _ = child.kill() catch {};
459 // A start that did not take is reported as the announce failure
460 // it began as: `muxd start` has already said its own piece on
461 // stderr, and a second guess over the top of it would be worse.
462 if (!runStart(alloc, h.start_cmd)) return err;
463 child.* = try spawnPipe(alloc, h.ssh_cmd);
464 // ONCE. A box that cannot hold a daemon costs this attach one
465 // extra round trip; a loop would cost it the afternoon.
466 return readAnnounceAbortable(child.stdout.?.handle, alloc, null, abort_fd);
467 };
468 }
469
470 /// Waits: nothing may dial the far end again until it has a verdict.
471 fn runStart(alloc: std.mem.Allocator, cmd: []const u8) bool {
472 var c = std.process.Child.init(&.{ "/bin/sh", "-c", cmd }, alloc);
473 // stdout and stderr inherited, like `spawnPipe`'s ssh: the progress
474 // and the verdict are the user's only account of a wait they are
475 // sitting through. stdin is NOT — ssh would eat the keystrokes the
476 // session is about to want, and its prompts read /dev/tty anyway.
477 c.stdin_behavior = .Ignore;
478 const term = c.spawnAndWait() catch return false;
479 return term == .Exited and term.Exited == 0;
480 }
481
432 /// The dial's budget is the handoff deadline while the CONNECTION keeps 482 /// The dial's budget is the handoff deadline while the CONNECTION keeps
433 /// the ordinary `idle_ms`; `quicTransport` spells that split. 483 /// the ordinary `idle_ms`; `quicTransport` spells that split.
434 pub fn openQuicEndpoint( 484 pub fn openQuicEndpoint(
@@ -1426,6 +1476,121 @@ test "handoff: abort_fd -1 means no abort channel — fd 0 is never read" {
1426 try std.testing.expectEqualStrings("", carry.items); 1476 try std.testing.expectEqualStrings("", carry.items);
1427 } 1477 }
1428 1478
1479 /// How many times the ssh shim below ran: it appends one two-byte line per
1480 /// run, so the file's size is the count.
1481 fn shimRuns(dir: []const u8, name: []const u8) !u64 {
1482 var buf: [512]u8 = undefined;
1483 const path = try std.fmt.bufPrint(&buf, "{s}/{s}", .{ dir, name });
1484 const f = std.fs.cwd().openFile(path, .{}) catch return 0;
1485 defer f.close();
1486 return (try f.stat()).size / 2;
1487 }
1488
1489 fn shimMade(dir: []const u8, name: []const u8) !bool {
1490 var buf: [512]u8 = undefined;
1491 const path = try std.fmt.bufPrint(&buf, "{s}/{s}", .{ dir, name });
1492 std.fs.cwd().access(path, .{}) catch return false;
1493 return true;
1494 }
1495
1496 test "openHandoff: the dial a user ASKED for starts the daemon it did not find, and re-runs the ssh line exactly once" {
1497 // `muxd endpoint` on a box with no daemon exits 1 having written
1498 // nothing, which arrives here as an announce that never terminated.
1499 // The shim is that box: the first run finds no `started` flag and
1500 // fails the same way; the start command drops the flag; the second run
1501 // announces. Asked of the FILESYSTEM, never of the client, because
1502 // "was a daemon started" is a question about the world.
1503 const alloc = std.testing.allocator;
1504 var carry: std.ArrayList(u8) = .empty;
1505 defer carry.deinit(alloc);
1506 var stdin = try FakeStdin.install("");
1507 defer stdin.deinit();
1508 var tmp = try TmpDir.make();
1509 defer tmp.cleanup();
1510
1511 var ssh_buf: [512]u8 = undefined;
1512 const ssh_cmd = try std.fmt.bufPrint(
1513 &ssh_buf,
1514 "echo x >>{s}/runs; test -e {s}/started || exit 1; printf 'endpoint none\n'; cat >/dev/null",
1515 .{ tmp.path(), tmp.path() },
1516 );
1517 var start_buf: [512]u8 = undefined;
1518 const start_cmd = try std.fmt.bufPrint(&start_buf, "touch {s}/started", .{tmp.path()});
1519
1520 var t = try Transport.open(alloc, .{ .hand = .{
1521 .host = "fake",
1522 .ssh_cmd = ssh_cmd,
1523 .start_cmd = start_cmd,
1524 .cache_path = null,
1525 .deadline_ms = 200,
1526 } }, &carry, std.posix.STDIN_FILENO);
1527 defer t.close();
1528
1529 try std.testing.expect(t.link == .pipe);
1530 try std.testing.expect(try shimMade(tmp.path(), "started"));
1531 try std.testing.expectEqual(@as(u64, 2), try shimRuns(tmp.path(), "runs"));
1532 }
1533
1534 test "openHandoff: a dial nobody asked for reports the failure and starts nothing — the wall polls every listed host once a second" {
1535 // The regression this exists for: a poll that starts a daemon gives a
1536 // listed box one (and a shell in session 0) from a READ, and undoes a
1537 // `muxd stop` on the far end a second after it is typed.
1538 const alloc = std.testing.allocator;
1539 var carry: std.ArrayList(u8) = .empty;
1540 defer carry.deinit(alloc);
1541 var stdin = try FakeStdin.install("");
1542 defer stdin.deinit();
1543 var tmp = try TmpDir.make();
1544 defer tmp.cleanup();
1545
1546 var ssh_buf: [512]u8 = undefined;
1547 const ssh_cmd = try std.fmt.bufPrint(&ssh_buf, "echo x >>{s}/runs; exit 1", .{tmp.path()});
1548 var start_buf: [512]u8 = undefined;
1549 const start_cmd = try std.fmt.bufPrint(&start_buf, "touch {s}/started", .{tmp.path()});
1550
1551 try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{
1552 .host = "fake",
1553 .ssh_cmd = ssh_cmd,
1554 .start_cmd = start_cmd,
1555 .cache_path = null,
1556 .deadline_ms = 200,
1557 .asked = false,
1558 } }, &carry, std.posix.STDIN_FILENO));
1559
1560 try std.testing.expect(!try shimMade(tmp.path(), "started"));
1561 try std.testing.expectEqual(@as(u64, 1), try shimRuns(tmp.path(), "runs"));
1562 }
1563
1564 test "openHandoff: a start that does not help is tried once — a second announce, never a third" {
1565 // A box that refuses to hold a daemon (a bad shell, a full disk) must
1566 // cost the attach one extra round trip, not a loop of them: the ask
1567 // buys ONE start and ONE retry, and the original announce error is
1568 // what the user is told about.
1569 const alloc = std.testing.allocator;
1570 var carry: std.ArrayList(u8) = .empty;
1571 defer carry.deinit(alloc);
1572 var stdin = try FakeStdin.install("");
1573 defer stdin.deinit();
1574 var tmp = try TmpDir.make();
1575 defer tmp.cleanup();
1576
1577 var ssh_buf: [512]u8 = undefined;
1578 const ssh_cmd = try std.fmt.bufPrint(&ssh_buf, "echo x >>{s}/runs; exit 1", .{tmp.path()});
1579 var start_buf: [512]u8 = undefined;
1580 const start_cmd = try std.fmt.bufPrint(&start_buf, "touch {s}/started", .{tmp.path()});
1581
1582 try std.testing.expectError(error.UnterminatedLine, Transport.open(alloc, .{ .hand = .{
1583 .host = "fake",
1584 .ssh_cmd = ssh_cmd,
1585 .start_cmd = start_cmd,
1586 .cache_path = null,
1587 .deadline_ms = 200,
1588 } }, &carry, std.posix.STDIN_FILENO));
1589
1590 try std.testing.expect(try shimMade(tmp.path(), "started"));
1591 try std.testing.expectEqual(@as(u64, 2), try shimRuns(tmp.path(), "runs"));
1592 }
1593
1429 test "lostMsg: only a --via transport that never connected gets the new wording" { 1594 test "lostMsg: only a --via transport that never connected gets the new wording" {
1430 // The case the message exists for: a command that failed to start. It 1595 // The case the message exists for: a command that failed to start. It
1431 // names what happened and guesses no cause — ssh's own stderr passes 1596 // names what happened and guesses no cause — ssh's own stderr passes
src/wallview.zig
Old New
@@ -77,13 +77,15 @@ pub fn resolveHost(
77 .hand = .{ 77 .hand = .{
78 .host = h, 78 .host = h,
79 .ssh_cmd = r.ssh_cmd, 79 .ssh_cmd = r.ssh_cmd,
80 .start_cmd = r.start_cmd,
80 .cache_path = r.cache_path, 81 .cache_path = r.cache_path,
81 .idle_ms = idle_ms, 82 .idle_ms = idle_ms,
82 // No door off this spec is the attach that made the choice: 83 // A host line is a listing, not an attach anyone waited
83 // the pump clears its own copy, and the POLLER's would 84 // for. The POLLER runs off this spec once a second: an
84 // otherwise print the fallback line onto the wall's 85 // asked copy would print the fallback line onto the
85 // alternate screen once per cycle, forever. 86 // wall's alternate screen every cycle, and would start
86 .report_fallback = false, 87 // a daemon on a box whose owner just stopped one.
88 .asked = false,
87 }, 89 },
88 }; 90 };
89 }, 91 },
@@ -121,9 +123,10 @@ fn pollTargetFor(alloc: std.mem.Allocator, target: client.Target) !client.Target
121 return .{ .hand = .{ 123 return .{ .hand = .{
122 .host = h.host, 124 .host = h.host,
123 .ssh_cmd = r.ssh_cmd, 125 .ssh_cmd = r.ssh_cmd,
126 .start_cmd = r.start_cmd,
124 .cache_path = r.cache_path, 127 .cache_path = r.cache_path,
125 .idle_ms = h.idle_ms, 128 .idle_ms = h.idle_ms,
126 .report_fallback = false, 129 .asked = false,
127 } }; 130 } };
128 } 131 }
129 132
@@ -1302,7 +1305,7 @@ fn pumpTile(t: *Tile) void {
1302 var target = t.r.target; 1305 var target = t.r.target;
1303 // The stripe's label already narrates state; a fallback line on 1306 // The stripe's label already narrates state; a fallback line on
1304 // stderr would corrupt the alternate screen. Same as the hub's pump. 1307 // stderr would corrupt the alternate screen. Same as the hub's pump.
1305 if (target == .hand) target.hand.report_fallback = false; 1308 if (target == .hand) target.hand.asked = false;
1306 1309
1307 // ONE Core per tile, from birth. It owns this tile's replica, its 1310 // ONE Core per tile, from birth. It owns this tile's replica, its
1308 // prediction overlay and its drag for the tile's whole life: the tile 1311 // prediction overlay and its drag for the tile's whole life: the tile
@@ -6182,8 +6185,8 @@ test "resolveHost: an ssh host is polled by a recipe that cannot prompt and cann
6182 try std.testing.expect(std.mem.indexOf(u8, spec.target.hand.ssh_cmd, "BatchMode") == null); 6185 try std.testing.expect(std.mem.indexOf(u8, spec.target.hand.ssh_cmd, "BatchMode") == null);
6183 // Neither door narrates a fallback: both are dialled under a wall 6186 // Neither door narrates a fallback: both are dialled under a wall
6184 // that owns the alternate screen. 6187 // that owns the alternate screen.
6185 try std.testing.expect(!spec.target.hand.report_fallback); 6188 try std.testing.expect(!spec.target.hand.asked);
6186 try std.testing.expect(!spec.poll_target.hand.report_fallback); 6189 try std.testing.expect(!spec.poll_target.hand.asked);
6187 } 6190 }
6188 6191
6189 // A transport that cannot prompt has one target, not a copy of one. 6192 // A transport that cannot prompt has one target, not a copy of one.
src/webhub.zig
Old New
@@ -114,6 +114,7 @@ fn resolveTile(
114 break :blk .{ .hand = .{ 114 break :blk .{ .hand = .{
115 .host = hd, 115 .host = hd,
116 .ssh_cmd = r.ssh_cmd, 116 .ssh_cmd = r.ssh_cmd,
117 .start_cmd = r.start_cmd,
117 .cache_path = r.cache_path, 118 .cache_path = r.cache_path,
118 .idle_ms = idle_ms, 119 .idle_ms = idle_ms,
119 } }; 120 } };
@@ -154,7 +155,7 @@ fn copyTarget(arena: std.mem.Allocator, t: client.Target) !client.Target {
154 comptime { 155 comptime {
155 std.debug.assert(@typeInfo(client.Target).@"union".fields.len == 4); 156 std.debug.assert(@typeInfo(client.Target).@"union".fields.len == 4);
156 std.debug.assert(@typeInfo(client.QuicTarget).@"struct".fields.len == 4); 157 std.debug.assert(@typeInfo(client.QuicTarget).@"struct".fields.len == 4);
157 std.debug.assert(@typeInfo(client.HandoffTarget).@"struct".fields.len == 6); 158 std.debug.assert(@typeInfo(client.HandoffTarget).@"struct".fields.len == 7);
158 } 159 }
159 return switch (t) { 160 return switch (t) {
160 .sock => |s| .{ .sock = try arena.dupe(u8, s) }, 161 .sock => |s| .{ .sock = try arena.dupe(u8, s) },
@@ -168,10 +169,11 @@ fn copyTarget(arena: std.mem.Allocator, t: client.Target) !client.Target {
168 .hand => |h| .{ .hand = .{ 169 .hand => |h| .{ .hand = .{
169 .host = try arena.dupe(u8, h.host), 170 .host = try arena.dupe(u8, h.host),
170 .ssh_cmd = try arena.dupe(u8, h.ssh_cmd), 171 .ssh_cmd = try arena.dupe(u8, h.ssh_cmd),
172 .start_cmd = try arena.dupe(u8, h.start_cmd),
171 .cache_path = if (h.cache_path) |c| try arena.dupe(u8, c) else null, 173 .cache_path = if (h.cache_path) |c| try arena.dupe(u8, c) else null,
172 .deadline_ms = h.deadline_ms, 174 .deadline_ms = h.deadline_ms,
173 .idle_ms = h.idle_ms, 175 .idle_ms = h.idle_ms,
174 .report_fallback = h.report_fallback, 176 .asked = h.asked,
175 } }, 177 } },
176 }; 178 };
177 } 179 }
@@ -799,8 +801,10 @@ pub fn pumpTile(
799 var target = target_in; 801 var target = target_in;
800 // No terminal to spam and a control channel that already narrates: 802 // No terminal to spam and a control channel that already narrates:
801 // the one fallback line the CLI allows itself is quieted here, the 803 // the one fallback line the CLI allows itself is quieted here, the
802 // same way reconnect() quiets retries. 804 // same way reconnect() quiets retries. It is also what keeps a browser
803 if (target == .hand) target.hand.report_fallback = false; 805 // from starting daemons: a hub tile redials for as long as the page is
806 // open, and nobody is watching it do so.
807 if (target == .hand) target.hand.asked = false;
804 808
805 var restore: Restore = .{}; 809 var restore: Restore = .{};
806 810