2b8a4186
fix: muxd endpoint and muxd proxy read a box, they do not start one
a73x 2026-08-28 19:53
Commit message
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, |