a73x

1a22f560

refactor: one owner for the default-socket sentence and the sun_path guard

a73x   2026-08-29 10:01

Commit message
refactor: one owner for the default-socket sentence and the sun_path guard

`mux d` and `mux` each spelled out "XDG_RUNTIME_DIR is unset, so there is
no default socket path (name one with --sock)" and "socket path too long
(N bytes, max M): PATH". The two spellings were byte-identical but for
the program word, so the program word is now the only parameter:
`sockpath.defaultOrExplain(alloc, prefix)` and
`sockpath.tooLong(prefix, path)`. `mux_main.defaultSock` is gone.

`mux a`'s twin STAYS: an agent reads replies, not stderr, so it answers
the same two decisions through its JSON `fail` shape and keeps its own
wording. `mux web`'s and `mux hosts add`'s refusals are their own
sentences too and are untouched.

Both messages verified byte for byte off the built binary, not the exit
code: `env -u XDG_RUNTIME_DIR mux d stats` and a 125-byte `--sock`
against both binaries print exactly what they printed before.

sockpath.zig's header claim that each binary owns its own wording was
true of the number and no longer of these two sentences; it now says
what is left of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wi2HnuF1EK8HgViU11YLV

src/cli/main.zig
Old New
@@ -305,21 +305,10 @@ pub fn main(args: []const [:0]const u8) !u8 {
305 else if (!uses_socket) 305 else if (!uses_socket)
306 try alloc.dupe(u8, "") 306 try alloc.dupe(u8, "")
307 else 307 else
308 sockpath.defaultSockPath(alloc) catch |err| switch (err) { 308 try sockpath.defaultOrExplain(alloc, "mux d") orelse return 1;
309 // Named in this binary's own words, like the sun_path bound
310 // below: sockpath owns the rule, each binary owns the wording.
311 error.NoRuntimeDir => {
312 std.debug.print(
313 "mux d: XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)\n",
314 .{},
315 );
316 return 1;
317 },
318 else => |e| return e,
319 };
320 defer alloc.free(sock_path); 309 defer alloc.free(sock_path);
321 310
322 // The sun_path bound (sockpath.max_sun_path). Checked here, once, 311 // The sun_path bound (sockpath.tooLong). Checked here, once,
323 // before any command acts: the alternative is a spawned daemon that can 312 // before any command acts: the alternative is a spawned daemon that can
324 // never answer and a 2s timeout story about a path doomed at parse. 313 // never answer and a 2s timeout story about a path doomed at parse.
325 // `--version` and `keygen` are dispatched from the switch below, i.e. 314 // `--version` and `keygen` are dispatched from the switch below, i.e.
@@ -327,13 +316,7 @@ pub fn main(args: []const [:0]const u8) !u8 {
327 // order — neither touches the socket, and neither should be refused 316 // order — neither touches the socket, and neither should be refused
328 // over it. Which verbs those are is their rows' business, not this 317 // over it. Which verbs those are is their rows' business, not this
329 // line's. 318 // line's.
330 if (uses_socket and sock_path.len > sockpath.max_sun_path) { 319 if (uses_socket and sockpath.tooLong("mux d", sock_path)) return 1;
331 std.debug.print(
332 "mux d: socket path too long ({d} bytes, max {d}): {s}\n",
333 .{ sock_path.len, sockpath.max_sun_path, sock_path },
334 );
335 return 1;
336 }
337 320
338 switch (o._cmd) { 321 switch (o._cmd) {
339 // The socket path resolved above is unused here and unchecked (see 322 // The socket path resolved above is unused here and unchecked (see
src/cli/mux_main.zig
Old New
@@ -369,28 +369,13 @@ pub fn main(args: []const [:0]const u8) !u8 {
369 const sock_path = if (t.sock) |s| 369 const sock_path = if (t.sock) |s|
370 try alloc.dupe(u8, s) 370 try alloc.dupe(u8, s)
371 else 371 else
372 try defaultSock(alloc) orelse return 1; 372 try sockpath.defaultOrExplain(alloc, "mux") orelse return 1;
373 defer alloc.free(sock_path); 373 defer alloc.free(sock_path);
374 return attachLocal(alloc, sock_path, t.session, t.agent); 374 return attachLocal(alloc, sock_path, t.session, t.agent);
375 }, 375 },
376 } 376 }
377 } 377 }
378 378
379 /// The default socket path, or the one refusal that names `--sock` as the
380 /// way out.
381 fn defaultSock(alloc: std.mem.Allocator) !?[]const u8 {
382 return sockpath.defaultSockPath(alloc) catch |err| switch (err) {
383 error.NoRuntimeDir => {
384 std.debug.print(
385 "mux: XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)\n",
386 .{},
387 );
388 return null;
389 },
390 else => |e| return e,
391 };
392 }
393
394 /// The ONLY auto-start left, and it starts this image by its own /proc 379 /// The ONLY auto-start left, and it starts this image by its own /proc
395 /// link — no name to look up, so none to get wrong. 380 /// link — no name to look up, so none to get wrong.
396 fn ensureLocalDaemon(alloc: std.mem.Allocator, sock_path: []const u8) !bool { 381 fn ensureLocalDaemon(alloc: std.mem.Allocator, sock_path: []const u8) !bool {
@@ -431,20 +416,12 @@ fn attachLocal(
431 return 2; 416 return 2;
432 } 417 }
433 418
434 // The same `sun_path` guard the daemon applies (main.zig), for the same 419 // WHERE the shared guard (sockpath.tooLong) sits is this binary's
435 // reason and off the same constant. It sits before the PATH search 420 // decision: before the auto-start, not at the connect. Otherwise mux
436 // rather than at the connect because auto-start would otherwise reach 421 // forks a daemon that refuses the path instantly, then polls the full
437 // it first: mux forks a daemon that refuses the path 422 // 2s into "daemon did not answer" — a timeout story about a path that
438 // instantly, and polls the full 2s into "daemon did not answer" — a 423 // was doomed at parse.
439 // timeout story about a path that was doomed at parse. Refusing here 424 if (sockpath.tooLong("mux", sock_path)) return 1;
440 // costs nothing and says the real thing.
441 if (sock_path.len > sockpath.max_sun_path) {
442 std.debug.print(
443 "mux: socket path too long ({d} bytes, max {d}): {s}\n",
444 .{ sock_path.len, sockpath.max_sun_path, sock_path },
445 );
446 return 1;
447 }
448 425
449 if (!try ensureLocalDaemon(alloc, sock_path)) return 1; 426 if (!try ensureLocalDaemon(alloc, sock_path)) return 1;
450 return wallview.runAttach( 427 return wallview.runAttach(
@@ -493,7 +470,7 @@ fn wallOfHosts(alloc: std.mem.Allocator) !u8 {
493 // it is only that `mux` reaches for it when nothing is listed. The 470 // it is only that `mux` reaches for it when nothing is listed. The
494 // line itself is written once the attach answers, by 471 // line itself is written once the attach answers, by
495 // `wallview.runAttach`. 472 // `wallview.runAttach`.
496 const sock_path = try defaultSock(arena) orelse return 1; 473 const sock_path = try sockpath.defaultOrExplain(arena, "mux") orelse return 1;
497 return attachLocal(alloc, sock_path, "", false); 474 return attachLocal(alloc, sock_path, "", false);
498 } 475 }
499 476
src/sockpath.zig
Old New
@@ -11,10 +11,39 @@ const std = @import("std");
11 11
12 /// The usable bytes of `sockaddr_un.sun_path`: the field is 108 and the 12 /// The usable bytes of `sockaddr_un.sun_path`: the field is 108 and the
13 /// last one belongs to the NUL. Every binary refuses a longer path by 13 /// last one belongs to the NUL. Every binary refuses a longer path by
14 /// name before acting on it, each in its own words — the number is the 14 /// name before acting on it — the number is the kernel's and belongs in
15 /// kernel's and belongs in one place, the wording is theirs. 15 /// one place.
16 pub const max_sun_path = 107; 16 pub const max_sun_path = 107;
17 17
18 /// Checked before anything acts on the path: connect would bounce a
19 /// too-long one off the kernel with a generic error, and the path is the
20 /// whole story. `mux a` answers in JSON and keeps its own wording.
21 pub fn tooLong(prefix: []const u8, path: []const u8) bool {
22 if (path.len <= max_sun_path) return false;
23 std.debug.print(
24 "{s}: socket path too long ({d} bytes, max {d}): {s}\n",
25 .{ prefix, path.len, max_sun_path, path },
26 );
27 return true;
28 }
29
30 /// `defaultSockPath`, or null having printed the ONE sentence that names
31 /// `--sock` as the way out. The daemon and the client printed it byte for
32 /// byte in two places; only the program word differs, so only that is a
33 /// parameter.
34 pub fn defaultOrExplain(alloc: std.mem.Allocator, prefix: []const u8) !?[]const u8 {
35 return defaultSockPath(alloc) catch |err| switch (err) {
36 error.NoRuntimeDir => {
37 std.debug.print(
38 "{s}: XDG_RUNTIME_DIR is unset, so there is no default socket path (name one with --sock)\n",
39 .{prefix},
40 );
41 return null;
42 },
43 else => |e| return e,
44 };
45 }
46
18 /// No fallback when `$XDG_RUNTIME_DIR` is unset: a guess cannot make two 47 /// No fallback when `$XDG_RUNTIME_DIR` is unset: a guess cannot make two
19 /// binaries agree on one daemon, so the caller names it. 48 /// binaries agree on one daemon, so the caller names it.
20 pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 { 49 pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 {
test/e2e_11_select.sh
Old New
@@ -318,16 +318,21 @@ ok "a resize is reported in-band to an app that turned on mode 2048"
318 # An EMPTY hosts file, because that is the case that needs the default 318 # An EMPTY hosts file, because that is the case that needs the default
319 # path: bare `mux` reaches for the local daemon only when nothing is 319 # path: bare `mux` reaches for the local daemon only when nothing is
320 # listed, and the suite's shared file is every daemon it has attached to. 320 # listed, and the suite's shared file is every daemon it has attached to.
321 # WORD:ARGS — the refusal names the program that refused, so a prefix
322 # crossed between the two binaries (or dropped to "") is caught here rather
323 # than sending an operator to the wrong command.
321 hostroom nort 324 hostroom nort
322 for BIN in "$MUX d stats" "$MUX"; do 325 for SPEC in "mux d:d stats" "mux:"; do
326 WORD=${SPEC%%:*}; ARGS=${SPEC#*:}
323 # shellcheck disable=SC2086 # the two-word `mux d` form is deliberate 327 # shellcheck disable=SC2086 # the two-word `mux d` form is deliberate
324 if env -u XDG_RUNTIME_DIR XDG_STATE_HOME="$HOSTROOM" $BIN > "$OUT.nort" 2>&1; then 328 if env -u XDG_RUNTIME_DIR XDG_STATE_HOME="$HOSTROOM" "$MUX" $ARGS > "$OUT.nort" 2>&1; then
325 echo "e2e FAIL: '$BIN' with no XDG_RUNTIME_DIR did not refuse"; exit 1 329 echo "e2e FAIL: 'mux $ARGS' with no XDG_RUNTIME_DIR did not refuse"; exit 1
326 fi 330 fi
327 grep -q 'XDG_RUNTIME_DIR is unset' "$OUT.nort" || { 331 grep -q "^$WORD: XDG_RUNTIME_DIR is unset" "$OUT.nort" || {
328 echo "e2e FAIL: '$BIN' refusal does not name the variable:"; cat "$OUT.nort"; exit 1; } 332 echo "e2e FAIL: 'mux $ARGS' refusal is not '$WORD:' naming the variable:"
333 cat "$OUT.nort"; exit 1; }
329 grep -q -- '--sock' "$OUT.nort" || { 334 grep -q -- '--sock' "$OUT.nort" || {
330 echo "e2e FAIL: '$BIN' refusal does not say how to fix it:"; cat "$OUT.nort"; exit 1; } 335 echo "e2e FAIL: 'mux $ARGS' refusal does not say how to fix it:"; cat "$OUT.nort"; exit 1; }
331 done 336 done
332 # mux a answers agents in JSON, so its refusal has to arrive as a reply. 337 # mux a answers agents in JSON, so its refusal has to arrive as a reply.
333 env -u XDG_RUNTIME_DIR "$MUX" a status > "$OUT.nort.a" 2>&1 && { 338 env -u XDG_RUNTIME_DIR "$MUX" a status > "$OUT.nort.a" 2>&1 && {