a73x

f6013e14

fix: the default socket path refuses to be guessed

a73x   2026-08-23 05:56

Commit message
fix: the default socket path refuses to be guessed

$XDG_RUNTIME_DIR is not stable across shells on one box. A tmux server
started before logind exported it hands every pane an environment
without it, and the /tmp/muxd-<uid>.sock fallback then sent those panes
to one path while the daemon a pane had started owned another — one
uid, one box, two daemons, and `muxd stop` reporting nothing listening
on a socket the operator could see in ps.

A default that cannot make two binaries agree is not a default, so it
refuses and the caller names the path. All three binaries say which
variable is missing in their own words; muxa answers in JSON, because
an agent reads replies and not stderr. `--version` and `keygen` are
unaffected: muxd now resolves no path at all for a verb whose row says
it touches no socket, rather than a path it must first survive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

src/main.zig
Old New
@@ -306,10 +306,29 @@ pub fn main() !u8 {
306 .ok => |o| o, 306 .ok => |o| o,
307 }; 307 };
308 308
309 // The same row that exempts `--version` and `keygen` from the sun_path
310 // bound below exempts them from HAVING a path: the default can now
311 // refuse (sockpath.defaultSockPath), and a version string must never
312 // fail on the environment. So a verb that touches no socket gets no
313 // path rather than a path it must first survive resolving.
314 const uses_socket = specForCmd(o.cmd).uses_socket;
309 const sock_path = if (o.sock) |s| 315 const sock_path = if (o.sock) |s|
310 try alloc.dupe(u8, s) 316 try alloc.dupe(u8, s)
317 else if (!uses_socket)
318 try alloc.dupe(u8, "")
311 else 319 else
312 try sockpath.defaultSockPath(alloc); 320 sockpath.defaultSockPath(alloc) catch |err| switch (err) {
321 // Named in this binary's own words, like the sun_path bound
322 // below: sockpath owns the rule, each binary owns the wording.
323 error.NoRuntimeDir => {
324 std.debug.print(
325 "muxd: XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)\n",
326 .{},
327 );
328 return 1;
329 },
330 else => |e| return e,
331 };
313 defer alloc.free(sock_path); 332 defer alloc.free(sock_path);
314 333
315 // The sun_path bound (sockpath.max_sun_path). Checked here, once, 334 // The sun_path bound (sockpath.max_sun_path). Checked here, once,
@@ -320,7 +339,6 @@ pub fn main() !u8 {
320 // order — neither touches the socket, and neither should be refused 339 // order — neither touches the socket, and neither should be refused
321 // over it. Which verbs those are is their rows' business, not this 340 // over it. Which verbs those are is their rows' business, not this
322 // line's. 341 // line's.
323 const uses_socket = specForCmd(o.cmd).uses_socket;
324 if (uses_socket and sock_path.len > sockpath.max_sun_path) { 342 if (uses_socket and sock_path.len > sockpath.max_sun_path) {
325 std.debug.print( 343 std.debug.print(
326 "muxd: socket path too long ({d} bytes, max {d}): {s}\n", 344 "muxd: socket path too long ({d} bytes, max {d}): {s}\n",
src/mux_main.zig
Old New
@@ -392,7 +392,16 @@ pub fn main() !u8 {
392 const sock_path = if (t.sock) |s| 392 const sock_path = if (t.sock) |s|
393 try alloc.dupe(u8, s) 393 try alloc.dupe(u8, s)
394 else 394 else
395 try sockpath.defaultSockPath(alloc); 395 sockpath.defaultSockPath(alloc) catch |err| switch (err) {
396 error.NoRuntimeDir => {
397 std.debug.print(
398 "mux: XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)\n",
399 .{},
400 );
401 return 1;
402 },
403 else => |e| return e,
404 };
396 defer alloc.free(sock_path); 405 defer alloc.free(sock_path);
397 406
398 // Before the PATH search, before auto-start, before the dial: 407 // Before the PATH search, before auto-start, before the dial:
src/muxa.zig
Old New
@@ -1034,7 +1034,15 @@ pub fn main() !u8 {
1034 return dispatch(alloc, &conn, o, deadline); 1034 return dispatch(alloc, &conn, o, deadline);
1035 } 1035 }
1036 1036
1037 const sock_path = if (o.sock) |s| s else try sockpath.defaultSockPath(alloc); 1037 const sock_path = if (o.sock) |s| s else sockpath.defaultSockPath(alloc) catch |err| switch (err) {
1038 // An agent reads replies, not stderr, so this one refuses through
1039 // the same JSON shape as every other muxa failure.
1040 error.NoRuntimeDir => return fail(
1041 "no default socket path",
1042 "XDG_RUNTIME_DIR is unset; name the socket with --sock",
1043 ),
1044 else => |e| return e,
1045 };
1038 1046
1039 // Refused by name, before connecting: connect would bounce a too-long 1047 // Refused by name, before connecting: connect would bounce a too-long
1040 // path off the kernel with a generic error, and the path is the whole 1048 // path off the kernel with a generic error, and the path is the whole
src/sockpath.zig
Old New
@@ -19,11 +19,25 @@ pub const max_sun_path = 107;
19 /// part of a socket path's identity too: it is what makes two binaries 19 /// part of a socket path's identity too: it is what makes two binaries
20 /// started with no `--sock` land on the SAME daemon, so it lives here with 20 /// started with no `--sock` land on the SAME daemon, so it lives here with
21 /// the bound rather than once per binary. 21 /// the bound rather than once per binary.
22 ///
23 /// Which is why there is no fallback when `$XDG_RUNTIME_DIR` is unset.
24 /// A guessed `/tmp/muxd-<uid>.sock` used to stand in, and it broke the
25 /// one property above: a tmux server started before logind exported the
26 /// variable hands every pane an environment without it, so panes went to
27 /// /tmp while the daemon a pane had started owned the runtime dir — one
28 /// uid, one box, two daemons, and `muxd stop` reporting nothing there.
29 /// A default that cannot make two binaries agree is not a default, so
30 /// this refuses and the caller names the path.
22 pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 { 31 pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 {
23 if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir| { 32 return sockPathFrom(alloc, std.posix.getenv("XDG_RUNTIME_DIR"));
24 return std.fmt.allocPrint(alloc, "{s}/muxd.sock", .{dir}); 33 }
25 } 34
26 return std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()}); 35 /// `defaultSockPath` with its environment named rather than read, because
36 /// Zig tests cannot setenv and this is the one decision here a test has to
37 /// be able to state.
38 fn sockPathFrom(alloc: std.mem.Allocator, xdg_runtime: ?[]const u8) ![]const u8 {
39 const dir = xdg_runtime orelse return error.NoRuntimeDir;
40 return std.fmt.allocPrint(alloc, "{s}/muxd.sock", .{dir});
27 } 41 }
28 42
29 /// A socket file's identity at the moment it was bound, so teardown can 43 /// A socket file's identity at the moment it was bound, so teardown can
@@ -102,6 +116,17 @@ pub fn claim(path: []const u8) !void {
102 }; 116 };
103 } 117 }
104 118
119 test "default path: an unset XDG_RUNTIME_DIR is refused, never guessed" {
120 const alloc = std.testing.allocator;
121
122 // The refusal, not a guess — the field incident is on defaultSockPath.
123 try std.testing.expectError(error.NoRuntimeDir, sockPathFrom(alloc, null));
124
125 const found = try sockPathFrom(alloc, "/run/user/1000");
126 defer alloc.free(found);
127 try std.testing.expectEqualStrings("/run/user/1000/muxd.sock", found);
128 }
129
105 test "PathId: names the file it was taken from, not the path, and not a successor" { 130 test "PathId: names the file it was taken from, not the path, and not a successor" {
106 const testtmp = @import("testtmp"); 131 const testtmp = @import("testtmp");
107 var tmp = try testtmp.TmpDir.make(); 132 var tmp = try testtmp.TmpDir.make();
src/spawn.zig
Old New
@@ -485,10 +485,10 @@ test "ensureDaemon: an attach appends to the log, `muxd start` truncates it" {
485 try std.fs.cwd().makePath(std.fs.path.dirname(log).?); 485 try std.fs.cwd().makePath(std.fs.path.dirname(log).?);
486 try std.fs.cwd().writeFile(.{ .sub_path = log, .data = seed }); 486 try std.fs.cwd().writeFile(.{ .sub_path = log, .data = seed });
487 487
488 // The attach shape must leave it alone. Without this, one `mux` over 488 // The attach shape must leave it alone. Without this, one `mux --sock`
489 // ssh — no XDG_RUNTIME_DIR, so a /tmp socket nobody happens to be 489 // naming a path nobody happens to be serving zeroes the log of the
490 // serving — zeroes the log of the interactive daemon still writing to 490 // interactive daemon still writing to it, and the operator reads a
491 // it, and the operator reads a hole where the crash was. 491 // hole where the crash was.
492 try std.testing.expectError(error.NeverAnswered, ensureDaemon( 492 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
493 std.testing.allocator, 493 std.testing.allocator,
494 stub, 494 stub,
test/e2e.sh
Old New
@@ -7873,6 +7873,37 @@ assert_stopped "$SOCK51" "$D51PID" "in-band size" "$OUT.inbstop"
7873 D51PID="" 7873 D51PID=""
7874 ok "a resize is reported in-band to an app that turned on mode 2048" 7874 ok "a resize is reported in-band to an app that turned on mode 2048"
7875 7875
7876 # --- The default socket path refuses to be guessed. Field incident: a tmux
7877 # server started before logind exported XDG_RUNTIME_DIR handed every pane an
7878 # environment without it, so a pane's `muxd stop` looked at the /tmp guess
7879 # while the daemon a pane had started owned $XDG_RUNTIME_DIR/muxd.sock — one
7880 # uid, one box, two daemons. `env -u` rather than the harness environment:
7881 # on a box that HAS the variable the unset case is otherwise never taken.
7882 # No convergence: nothing gets far enough to attach.
7883 for BIN in "$MUXD stats" "$MUX"; do
7884 # shellcheck disable=SC2086 # the two-word muxd form is deliberate
7885 if env -u XDG_RUNTIME_DIR $BIN > "$OUT.nort" 2>&1; then
7886 echo "e2e FAIL: '$BIN' with no XDG_RUNTIME_DIR did not refuse"; exit 1
7887 fi
7888 grep -q 'XDG_RUNTIME_DIR is unset' "$OUT.nort" || {
7889 echo "e2e FAIL: '$BIN' refusal does not name the variable:"; cat "$OUT.nort"; exit 1; }
7890 grep -q -- '--sock' "$OUT.nort" || {
7891 echo "e2e FAIL: '$BIN' refusal does not say how to fix it:"; cat "$OUT.nort"; exit 1; }
7892 done
7893 # muxa answers agents in JSON, so its refusal has to arrive as a reply.
7894 env -u XDG_RUNTIME_DIR "$MUXA" status > "$OUT.nort.a" 2>&1 && {
7895 echo "e2e FAIL: muxa with no XDG_RUNTIME_DIR did not refuse"; exit 1; }
7896 grep -q '"error":"no default socket path"' "$OUT.nort.a" || {
7897 echo "e2e FAIL: muxa refusal is not a JSON error:"; cat "$OUT.nort.a"; exit 1; }
7898 # The socketless verbs must survive the same environment: --version is how an
7899 # operator checks whether the scp landed, and it owes nothing to a socket.
7900 env -u XDG_RUNTIME_DIR "$MUXD" --version | grep -q '^muxd 0\.' || {
7901 echo "e2e FAIL: muxd --version needs XDG_RUNTIME_DIR"; exit 1; }
7902 env -u XDG_RUNTIME_DIR "$MUX" --version | grep -q '^mux 0\.' || {
7903 echo "e2e FAIL: mux --version needs XDG_RUNTIME_DIR"; exit 1; }
7904 rm -f "$OUT.nort" "$OUT.nort.a"
7905 ok "no XDG_RUNTIME_DIR: the default path is refused by name, --version is not"
7906
7876 # --- an offerer that cannot answer is hung up on, and ssh falls through ---- 7907 # --- an offerer that cannot answer is hung up on, and ssh falls through ----
7877 # 7908 #
7878 # An agent_offer is a declaration, not a capability (decisions.md 7909 # An agent_offer is a declaration, not a capability (decisions.md
@@ -8089,14 +8120,19 @@ DPID=""
8089 # grid as the text `cat -v` made of it; the client's replica carries the 8120 # grid as the text `cat -v` made of it; the client's replica carries the
8090 # same rows and would add nothing. 8121 # same rows and would add nothing.
8091 # 8122 #
8092 # The 66th is the mute offerer, and no convergence point for the 58th's 8123 # The 66th is the refused default socket path, and no convergence point:
8124 # nothing attaches, so there is no grid on either side — it asserts on an
8125 # exit code and on a message naming the variable, because the guess it
8126 # replaced also exited nonzero and only the wording tells them apart.
8127 #
8128 # The 67th is the mute offerer, and no convergence point for the 58th's
8093 # reason: its subject is whether ssh in the session got an answer, and how 8129 # reason: its subject is whether ssh in the session got an answer, and how
8094 # fast, which is a side channel no grid carries. Last in the file because 8130 # fast, which is a side channel no grid carries. Last in the file because
8095 # it is the one leg that holds an ssh-agent under SIGSTOP, and a trap that 8131 # it is the one leg that holds an ssh-agent under SIGSTOP, and a trap that
8096 # has to CONT before it kills is cheaper to reason about with nothing 8132 # has to CONT before it kills is cheaper to reason about with nothing
8097 # after it. 8133 # after it.
8098 [ "$OK_COUNT" = "66" ] || { 8134 [ "$OK_COUNT" = "67" ] || {
8099 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 66 —" 8135 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 67 —"
8100 echo " a scenario was added (update the pin) or silently lost" 8136 echo " a scenario was added (update the pin) or silently lost"
8101 exit 1 8137 exit 1
8102 } 8138 }