a73x

43533342

fix: a resolved path can name nothing — fall back to the link, pin the other spawn

a73x   2026-08-29 02:51

Commit message
fix: a resolved path can name nothing — fall back to the link, pin the other spawn

`std.fs.selfExePath` is a bare readlink on Linux, and the kernel answers
`/…/mux (deleted)` for a binary replaced under a running process — which
`make install` does to a live wall. `access(X_OK)` then refused it and
nothing started, so the fallback the doc promises never fired on the one
path that needed it. `execOrLink` is split out so the branch is asserted
without deleting a binary out from under a test: a daemon wearing `exe`
for the length of one install beats an auto-start that will not start.

`mux d start` is the other production spawn and shares `selfExe`, so
e2e_01 reads its daemon's comm beside the pid it already parses — the
guard holds by construction today, and a later split of the two callers
would leave that side unpinned.

collect.sh's remote pgrep is bracketed and double-quoted. The whole
script travels as one `sh -c` argument, so the unbracketed pattern
listed the remote shell itself as a daemon; the single quotes were
worse — they closed this argument's own quoting, and the remote ran
`pgrep -af mux d run`, three operands pgrep refuses.

src/cli/spawn.zig
Old New
@@ -28,7 +28,21 @@ pub fn selfExe(buf: *[std.fs.max_path_bytes]u8) []const u8 {
28 // `killall mux`, `ps -o comm` or systemd's MainPID name can find — 28 // `killall mux`, `ps -o comm` or systemd's MainPID name can find —
29 // while only the args still read `mux d run`. The e2e that reads a 29 // while only the args still read `mux d run`. The e2e that reads a
30 // spawned daemon's `/proc/PID/comm` is what says so. 30 // spawned daemon's `/proc/PID/comm` is what says so.
31 return std.fs.selfExePath(buf) catch self_exe; 31 return execOrLink(std.fs.selfExePath(buf) catch return self_exe);
32 }
33
34 /// The resolved path if it can still be exec'd, the /proc link if it cannot.
35 /// Split from `selfExe` so the fallback is assertable without deleting a
36 /// binary out from under a running process.
37 fn execOrLink(resolved: []const u8) []const u8 {
38 // A readlink that SUCCEEDS can still name nothing: `make install`
39 // replaces the file under a running wall and the kernel then answers
40 // `/…/mux (deleted)`, which is a description and not a path. The link
41 // still opens the old inode, so the daemon starts wearing `exe` — the
42 // name is worth less than the start, and a refusal here would be an
43 // auto-start that fails for the length of one install.
44 std.posix.access(resolved, std.posix.X_OK) catch return self_exe;
45 return resolved;
32 } 46 }
33 pub const Ensured = enum { already_running, started }; 47 pub const Ensured = enum { already_running, started };
34 48
@@ -359,6 +373,20 @@ test "selfExe: the exec'd name is a real file, not the /proc link" {
359 try std.posix.access(exe, std.posix.X_OK); 373 try std.posix.access(exe, std.posix.X_OK);
360 } 374 }
361 375
376 test "selfExe: a resolved path that is no longer a file falls back to the link" {
377 // What `make install` does to a running wall: the readlink SUCCEEDS and
378 // answers `/…/mux (deleted)`, so the only thing that can tell is asking
379 // whether the answer is still executable. Spelled as the suffix the
380 // kernel actually appends, because that is the string this must survive.
381 var buf: [std.fs.max_path_bytes]u8 = undefined;
382 const live = try std.fs.selfExePath(&buf);
383 try std.testing.expectEqualStrings(live, execOrLink(live));
384
385 var gone: [std.fs.max_path_bytes]u8 = undefined;
386 const deleted = try std.fmt.bufPrint(&gone, "{s} (deleted)", .{live});
387 try std.testing.expectEqualStrings(self_exe, execOrLink(deleted));
388 }
389
362 test "ensureDaemon: a child that dies young is reported, not panicked on" { 390 test "ensureDaemon: a child that dies young is reported, not panicked on" {
363 var tmp = try testtmp.TmpDir.make(); 391 var tmp = try testtmp.TmpDir.make();
364 defer tmp.cleanup(); 392 defer tmp.cleanup();
test/e2e_01_boot.sh
Old New
@@ -827,6 +827,15 @@ SPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.start")
827 defer_kill "$SPID" 827 defer_kill "$SPID"
828 [ -n "$SPID" ] || { echo "e2e FAIL: up line carries no pid"; exit 1; } 828 [ -n "$SPID" ] || { echo "e2e FAIL: up line carries no pid"; exit 1; }
829 kill -0 "$SPID" || { echo "e2e FAIL: started daemon not alive"; exit 1; } 829 kill -0 "$SPID" || { echo "e2e FAIL: started daemon not alive"; exit 1; }
830 # `mux d start` is the OTHER production spawn, and it wears the name too.
831 # It shares `spawn.selfExe` with the client's auto-start (pinned the same
832 # way in 03_side), so this holds today by construction — which is exactly
833 # why it is asserted here: a later split of the two callers would leave
834 # `d start` free to hand execve the /proc link and name its daemon `exe`.
835 SCOMM=$(cat "/proc/$SPID/comm")
836 [ "$SCOMM" = "mux" ] || {
837 echo "e2e FAIL: the daemon mux d start brought up has comm '$SCOMM', want 'mux'"
838 exit 1; }
830 # Non-tty stderr: exactly two lines, no dots. 839 # Non-tty stderr: exactly two lines, no dots.
831 [ "$(wc -l < "$OUT.start")" = "2" ] || { 840 [ "$(wc -l < "$OUT.start")" = "2" ] || {
832 echo "e2e FAIL: non-tty start not exactly two lines:"; cat "$OUT.start"; exit 1; } 841 echo "e2e FAIL: non-tty start not exactly two lines:"; cat "$OUT.start"; exit 1; }
tools/collect.sh
Old New
@@ -85,6 +85,13 @@ for h in $HOSTS; do
85 section "box: $h" 85 section "box: $h"
86 # One ssh round trip per box, everything in one remote shell. BatchMode 86 # One ssh round trip per box, everything in one remote shell. BatchMode
87 # so an interactive prompt fails fast instead of hanging the bundle. 87 # so an interactive prompt fails fast instead of hanging the bundle.
88 #
89 # The pattern is BRACKETED and double-quoted, both for the same reason:
90 # this whole script arrives on the remote as one `sh -c` argument, so the
91 # remote shell's own cmdline contains the pattern and an unbracketed
92 # `mux d run` lists that shell as a daemon. Single quotes would be worse
93 # than wrong — they close this argument's own quoting, and the remote
94 # would run `pgrep -af mux d run`, three operands pgrep refuses.
88 ssh -o BatchMode=yes -o ConnectTimeout=10 "$h" ' 95 ssh -o BatchMode=yes -o ConnectTimeout=10 "$h" '
89 printf "version: %s\n" "$(mux --version 2>&1)" 96 printf "version: %s\n" "$(mux --version 2>&1)"
90 k=~/.config/mux/key 97 k=~/.config/mux/key
@@ -93,7 +100,7 @@ for h in $HOSTS; do
93 else 100 else
94 echo "key: none" 101 echo "key: none"
95 fi 102 fi
96 pgrep -af 'mux d run' 2>/dev/null || echo "no mux daemons" 103 pgrep -af "[m]ux d run" 2>/dev/null || echo "no mux daemons"
97 echo "stats: $(mux d stats 2>&1)" 104 echo "stats: $(mux d stats 2>&1)"
98 l=${XDG_STATE_HOME:-$HOME/.local/state}/mux/muxd.log 105 l=${XDG_STATE_HOME:-$HOME/.local/state}/mux/muxd.log
99 if [ -f "$l" ]; then 106 if [ -f "$l" ]; then