a73x

2b8a4186

fix: muxd endpoint and muxd proxy read a box, they do not start one

a73x   2026-08-28 19:53

Commit message
fix: muxd endpoint and muxd proxy read a box, they do not start one

src/cli/main.zig
Old New
@@ -352,20 +352,12 @@ pub fn main() !u8 {
352 .stats => return stats(alloc, sock_path), 352 .stats => return stats(alloc, sock_path),
353 .stop => return stopCmd(alloc, sock_path), 353 .stop => return stopCmd(alloc, sock_path),
354 .upgrade => return upgradeCmd(alloc, sock_path, o.allow_same_version), 354 .upgrade => return upgradeCmd(alloc, sock_path, o.allow_same_version),
355 .proxy => { 355 // A pump, and only a pump: a daemon starts when someone asks for
356 // Attach auto-start: the user asked for a session, not a 356 // one. `proxy.run` names the socket it could not reach, which is
357 // daemon. Same helper and deadline as `muxd start`. Unlike it, a 357 // what `mux --via 'ssh HOST muxd proxy'` shows a user whose remote
358 // daemon that was already there is silent: the user asked for a 358 // has none — README's `ssh HOST 'muxd start'` is the answer.
359 // session and is about to get one. 359 .proxy => return proxy.run(sock_path),
360 var exe_buf: [std.fs.max_path_bytes]u8 = undefined; 360 .endpoint => return endpointCmd(alloc, sock_path, std.posix.STDOUT_FILENO),
361 const exe = std.fs.selfExePath(&exe_buf) catch {
362 std.debug.print("muxd proxy: cannot find own binary via /proc/self/exe\n", .{});
363 return 1;
364 };
365 if (!try spawn.ensureForAttach(alloc, exe, sock_path, "muxd proxy")) return 1;
366 return proxy.run(sock_path);
367 },
368 .endpoint => return endpointCmd(alloc, sock_path),
369 } 361 }
370 } 362 }
371 363
@@ -948,10 +940,11 @@ fn logHint(alloc: std.mem.Allocator, buf: []u8) []const u8 {
948 ) catch ""; 940 ) catch "";
949 } 941 }
950 942
951 /// `muxd proxy` with a one-line preamble: ensure a daemon, ensure a key, 943 /// `muxd proxy` with a one-line preamble: ensure a key, ask the daemon for
952 /// ask the daemon for its QUIC port, print `endpoint <port> <hex-key>` (or 944 /// its QUIC port, print `endpoint <port> <hex-key>` (or `endpoint none`) as
953 /// `endpoint none`) as the FIRST bytes on stdout, then become exactly the 945 /// the FIRST bytes on stdout, then become exactly the proxy byte pump.
954 /// proxy byte pump. This is what `mux HOST` runs over ssh. 946 /// This is what `mux HOST` runs over ssh — and what its wall polls, which
947 /// is why no daemon means exit 1 and not a daemon.
955 /// 948 ///
956 /// The announce is mandatory in both directions. The client blocks on one 949 /// The announce is mandatory in both directions. The client blocks on one
957 /// newline-terminated line, and the daemon side of the frame protocol 950 /// newline-terminated line, and the daemon side of the frame protocol
@@ -969,29 +962,25 @@ fn logHint(alloc: std.mem.Allocator, buf: []u8) []const u8 {
969 /// 962 ///
970 /// stdout carries the announce and then frames, nothing else: every 963 /// stdout carries the announce and then frames, nothing else: every
971 /// human-facing word here goes to stderr, which ssh already carries to the 964 /// human-facing word here goes to stderr, which ssh already carries to the
972 /// user's terminal. That includes `ensureForAttach`'s progress, whose 965 /// user's terminal. A stray stdout byte ahead of the announce would land
973 /// Progress is pinned to STDERR_FILENO in spawn.zig — a stray stdout byte 966 /// in the middle of the client's parse.
974 /// ahead of the announce would land in the middle of the client's parse.
975 /// 967 ///
976 /// The pump that follows keeps speaking in its own name: a socket that 968 /// The pump that follows keeps speaking in its own name: a socket that
977 /// disappears between the ask and the attach is reported by proxy.zig as 969 /// disappears between the ask and the attach is reported by proxy.zig as
978 /// `muxd proxy: cannot connect to …`. That is deliberate — reusing 970 /// `muxd proxy: cannot connect to …`. That is deliberate — reusing
979 /// `proxy.run` is the whole design, and the line names the code that 971 /// `proxy.run` is the whole design, and the line names the code that
980 /// failed rather than the verb that was typed. 972 /// failed rather than the verb that was typed.
981 fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { 973 fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8, out_fd: std.posix.fd_t) !u8 {
982 var exe_buf: [std.fs.max_path_bytes]u8 = undefined; 974 // Silent, and that is the whole of the verb's new manners: the wall
983 const exe = std.fs.selfExePath(&exe_buf) catch { 975 // runs this over ssh once a second per listed host, and a word on
984 std.debug.print("muxd endpoint: cannot find own binary via /proc/self/exe\n", .{}); 976 // stderr here is a word on the wall's alternate screen. The client
985 return 1; 977 // that ASKED for the session narrates instead, and runs `muxd start`.
986 }; 978 if (!spawn.probe(sock_path)) return 1;
987 if (!try spawn.ensureForAttach(alloc, exe, sock_path, "muxd endpoint")) return 1;
988 979
989 // The announce goes out on the same stdout the pump is about to use, 980 // The announce goes out on the same stdout the pump is about to use,
990 // so it wants the same EPIPE-not-SIGPIPE treatment — and it wants it 981 // so it wants the same EPIPE-not-SIGPIPE treatment — and it wants it
991 // from proxy.zig's installer rather than from a std default this file 982 // from proxy.zig's installer rather than from a std default this file
992 // would be leaning on. AFTER the auto-start above, never before: the 983 // would be leaning on.
993 // ignore is SIG_IGN, which survives exec, so installing it first would
994 // hand the spawned daemon an inherited disposition it never chose.
995 proxy.ignoreSigpipe(); 984 proxy.ignoreSigpipe();
996 985
997 // Key first, then the ask, and the order is load-bearing: the daemon's 986 // Key first, then the ask, and the order is load-bearing: the daemon's
@@ -1025,7 +1014,7 @@ fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
1025 break :blk handoff.formatAnnounce(&line_buf, .{ .port = port, .key = k.bytes }) catch 1014 break :blk handoff.formatAnnounce(&line_buf, .{ .port = port, .key = k.bytes }) catch
1026 handoff.announce_none; 1015 handoff.announce_none;
1027 }; 1016 };
1028 proto.writeAllFd(std.posix.STDOUT_FILENO, line) catch |err| { 1017 proto.writeAllFd(out_fd, line) catch |err| {
1029 // stdout is the pipe the pump is about to need, so there is no 1018 // stdout is the pipe the pump is about to need, so there is no
1030 // session left to fall back to — only a line about why. The error 1019 // session left to fall back to — only a line about why. The error
1031 // is named rather than guessed at: EPIPE (the ssh client gave up 1020 // is named rather than guessed at: EPIPE (the ssh client gave up
@@ -1729,6 +1718,31 @@ test "askEndpointPort: a socket nobody serves answers 0, quickly" {
1729 try std.testing.expect(std.time.milliTimestamp() - t0 < 500); 1718 try std.testing.expect(std.time.milliTimestamp() - t0 < 500);
1730 } 1719 }
1731 1720
1721 test "endpointCmd: a box with no daemon is refused, never started — the wall polls this verb once a second per host" {
1722 const testtmp = @import("testtmp");
1723 var tmp = try testtmp.TmpDir.make();
1724 defer tmp.cleanup();
1725 var buf: [280]u8 = undefined;
1726 const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()});
1727 // A file rather than the runner's stdout, so "wrote no announce" is a
1728 // fact this can read back: the announce is the first bytes of a
1729 // session, and there is no session here to have any.
1730 var out_buf: [280]u8 = undefined;
1731 const out_path = try std.fmt.bufPrint(&out_buf, "{s}/announce", .{tmp.path()});
1732 const out = try std.fs.cwd().createFile(out_path, .{});
1733 defer out.close();
1734
1735 try std.testing.expectEqual(
1736 @as(u8, 1),
1737 try endpointCmd(std.testing.allocator, sock, out.handle),
1738 );
1739 // The verb READS a box. Starting a daemon here gave a listed machine
1740 // one (and a shell in session 0) from a poll, and undid a `muxd stop`
1741 // on the next cycle a second later.
1742 try std.testing.expect(!spawn.probe(sock));
1743 try std.testing.expectEqual(@as(u64, 0), (try out.stat()).size);
1744 }
1745
1732 test "oneShotQuery: a socket nobody serves is exit 1" { 1746 test "oneShotQuery: a socket nobody serves is exit 1" {
1733 const testtmp = @import("testtmp"); 1747 const testtmp = @import("testtmp");
1734 var tmp = try testtmp.TmpDir.make(); 1748 var tmp = try testtmp.TmpDir.make();
src/spawn.zig
Old New
@@ -1,9 +1,8 @@
1 //! Get a daemon onto a socket path: probe, spawn detached, poll until it 1 //! Get a daemon onto a socket path: probe, spawn detached, poll until it
2 //! answers. `muxd start` is explicit and spelled out; the attach 2 //! answers. `muxd start` is explicit and spelled out; the LOCAL client's
3 //! auto-starts (`muxd proxy`, `muxd endpoint`, `mux`) share 3 //! own entry (`mux` with no host) is the one attach that may still start
4 //! `ensureForAttach` below. The prediction made when the QUIC 4 //! one, through `ensureForAttach` below. The remote verbs a client reaches
5 //! ergonomics landed held — auto-start arrived as call sites, not as a 5 //! over ssh do not: reading a box must never create a session there.
6 //! rewrite.
7 const std = @import("std"); 6 const std = @import("std");
8 const xdg = @import("xdg"); 7 const xdg = @import("xdg");
9 8
@@ -214,8 +213,8 @@ pub fn probe(sock_path: []const u8) bool {
214 return true; 213 return true;
215 } 214 }
216 215
217 /// Auto-start's one shape. `muxd start` is deliberately not this: it owes 216 /// The local entry's auto-start alone; `muxd start` owes a verdict and is
218 /// the user a verdict on a daemon. 217 /// deliberately not this.
219 pub fn ensureForAttach( 218 pub fn ensureForAttach(
220 alloc: std.mem.Allocator, 219 alloc: std.mem.Allocator,
221 exe: []const u8, 220 exe: []const u8,