a73x

351e4586

fix: a spawned daemon's comm is mux, not exe — exec the resolved path

a73x   2026-08-29 02:16

Commit message
fix: a spawned daemon's comm is mux, not exe — exec the resolved path

The kernel names a process from the basename of the filename handed to
execve, so exec'ing /proc/self/exe left every daemon this branch starts
called `exe`: `pgrep mux`, `killall mux`, `ps -o comm` and systemd's
MainPID name all missed it, and only the args still said `mux d run`.
`std.fs.selfExePath` resolves the link the way the deleted `startCmd`
did; the link stays as the fallback, because a spawn is worth more than
the name it wears.

The e2e that already read the daemon's exe LINK now reads its `comm` as
well — the link is blind to exactly this, and it saw `exe` before the
fix. That leg's want-path becomes `readlink -f` too, so a checkout
reached through a symlinked directory stops failing a correct spawn.

With the resolved path in hand the refusal lines can name a file an
operator can stat, and `BinaryNotFound` folds into `SpawnFailed`: both
production callers now pass a path they resolved themselves.

src/cli/main.zig
Old New
@@ -1215,14 +1215,16 @@ fn startCmd(alloc: std.mem.Allocator, sock_path: []const u8, forwarded: []const
1215 // Truncating, and this is the only caller that does: `start` is the one 1215 // Truncating, and this is the only caller that does: `start` is the one
1216 // verb whose user asked for a (re)start, so the log they go on to read 1216 // verb whose user asked for a (re)start, so the log they go on to read
1217 // must be about the daemon they just started. 1217 // must be about the daemon they just started.
1218 const r = spawn.ensureDaemon(alloc, spawn.self_exe, forwarded, sock_path, progress, spawn.start_deadline_ms, .{ 1218 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
1219 const exe = spawn.selfExe(&exe_buf);
1220 const r = spawn.ensureDaemon(alloc, exe, forwarded, sock_path, progress, spawn.start_deadline_ms, .{
1219 .truncate = true, 1221 .truncate = true,
1220 }) catch |err| switch (err) { 1222 }) catch |err| switch (err) {
1221 // The failure line, with the log path, was already printed by 1223 // The failure line, with the log path, was already printed by
1222 // Progress — a second line here would say the same thing worse. 1224 // Progress — a second line here would say the same thing worse.
1223 error.NeverAnswered => return 1, 1225 error.NeverAnswered => return 1,
1224 error.BinaryNotFound, error.SpawnFailed => { 1226 error.SpawnFailed => {
1225 std.debug.print("mux d: could not spawn {s}: {s}\n", .{ spawn.self_exe, @errorName(err) }); 1227 std.debug.print("mux d: could not spawn {s}: {s}\n", .{ exe, @errorName(err) });
1226 return 1; 1228 return 1;
1227 }, 1229 },
1228 }; 1230 };
src/client/spawn.zig
Old New
@@ -4,27 +4,32 @@
4 //! one, through `ensureForAttach` below. The remote verbs a client reaches 4 //! one, through `ensureForAttach` below. The remote verbs a client reaches
5 //! over ssh do not: reading a box must never create a session there. 5 //! over ssh do not: reading a box must never create a session there.
6 //! 6 //!
7 //! What it spawns is `self_exe` — this image, by its own /proc link — and 7 //! What it spawns is this image — `selfExe` below, the /proc link read
8 //! never a name resolved against PATH. That is the end of the ambient-PATH 8 //! through to the file it names — and never a name resolved against PATH.
9 //! trap: an `execvp("muxd")` grades whatever release is installed, and did 9 //! That is the end of the ambient-PATH trap: an `execvp("muxd")` grades
10 //! (an e2e leg whose daemon had died ran a v0.0.1-10 with no agent code in 10 //! whatever release is installed, and did (an e2e leg whose daemon had died
11 //! it and passed). 11 //! ran a v0.0.1-10 with no agent code in it and passed).
12 const std = @import("std"); 12 const std = @import("std");
13 const xdg = @import("xdg"); 13 const xdg = @import("xdg");
14 14
15 pub const EnsureError = error{ BinaryNotFound, SpawnFailed, NeverAnswered }; 15 pub const EnsureError = error{ SpawnFailed, NeverAnswered };
16 16
17 /// The binary every production start execs: the running image, named by the 17 /// The kernel's link to the running image. Only the fallback: a spawn is
18 /// kernel rather than looked up. It survives a rename or a delete under the 18 /// worth more than the name it will wear, so a readlink that fails still
19 /// running daemon, because the link is to the inode and not to the path. 19 /// starts a daemon.
20 ///
21 /// The child execs rather than simply running the daemon in the fork, and
22 /// that is not a preference: `std.debug.MemoryAccessor` caches the pid it
23 /// reads memory through, so a forked child's first DebugAllocator stack
24 /// trace calls `process_vm_readv` on the parent, gets ESRCH, and panics on
25 /// `unreachable // own pid is always valid`. Measured, deterministic, and
26 /// invisible until the daemon has been up long enough to allocate.
27 pub const self_exe = "/proc/self/exe"; 20 pub const self_exe = "/proc/self/exe";
21
22 /// The file every production start execs: this image, resolved through
23 /// the /proc link to the path it names.
24 pub fn selfExe(buf: *[std.fs.max_path_bytes]u8) []const u8 {
25 // Resolved, and that is the point. The kernel takes a process's `comm`
26 // from the basename of the FILENAME handed to execve, so exec'ing the
27 // link itself leaves every daemon called `exe` — nothing `pgrep mux`,
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
30 // spawned daemon's `/proc/PID/comm` is what says so.
31 return std.fs.selfExePath(buf) catch self_exe;
32 }
28 pub const Ensured = enum { already_running, started }; 33 pub const Ensured = enum { already_running, started };
29 34
30 /// How long a spawn gets to answer, for every caller. One number because a 35 /// How long a spawn gets to answer, for every caller. One number because a
@@ -91,7 +96,7 @@ pub fn ensureDaemon(
91 ) EnsureError!Ensured { 96 ) EnsureError!Ensured {
92 if (probe(sock_path)) return .already_running; 97 if (probe(sock_path)) return .already_running;
93 98
94 std.posix.access(exe_path, std.posix.X_OK) catch return error.BinaryNotFound; 99 std.posix.access(exe_path, std.posix.X_OK) catch return error.SpawnFailed;
95 100
96 // Owned either way, so one `free` covers both: a caller-supplied path is 101 // Owned either way, so one `free` covers both: a caller-supplied path is
97 // duped rather than borrowed, because the xdg branch must allocate. 102 // duped rather than borrowed, because the xdg branch must allocate.
@@ -167,6 +172,14 @@ pub fn ensureDaemon(
167 std.os.linux.exit_group(127); 172 std.os.linux.exit_group(127);
168 std.posix.dup2(log.handle, std.posix.STDERR_FILENO) catch 173 std.posix.dup2(log.handle, std.posix.STDERR_FILENO) catch
169 std.os.linux.exit_group(127); 174 std.os.linux.exit_group(127);
175 // An exec rather than simply running the daemon in this fork, and
176 // that is not a preference: `std.debug.MemoryAccessor` caches the
177 // pid it reads memory through, so a forked child's first
178 // DebugAllocator stack trace calls `process_vm_readv` on the
179 // parent, gets ESRCH, and panics on `unreachable // own pid is
180 // always valid`. Measured, deterministic, and invisible until the
181 // daemon has been up long enough to allocate.
182 //
170 // execveZ's return type IS an error set — there is no success value, 183 // execveZ's return type IS an error set — there is no success value,
171 // because success does not return. 127 is the shell's "cannot exec", 184 // because success does not return. 127 is the shell's "cannot exec",
172 // and the parent learns the same thing either way: the socket never 185 // and the parent learns the same thing either way: the socket never
@@ -244,6 +257,8 @@ pub fn ensureForAttach(
244 ) error{OutOfMemory}!bool { 257 ) error{OutOfMemory}!bool {
245 const sock_z = try alloc.dupeZ(u8, sock_path); 258 const sock_z = try alloc.dupeZ(u8, sock_path);
246 defer alloc.free(sock_z); 259 defer alloc.free(sock_z);
260 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
261 const exe = selfExe(&exe_buf);
247 // Bare, and the reason is the whole of why this is not `start`: a QUIC 262 // Bare, and the reason is the whole of why this is not `start`: a QUIC
248 // listener must be asked for, never appear because someone attached. 263 // listener must be asked for, never appear because someone attached.
249 const run_args = [_][:0]const u8{ "--sock", sock_z }; 264 const run_args = [_][:0]const u8{ "--sock", sock_z };
@@ -254,7 +269,7 @@ pub fn ensureForAttach(
254 }; 269 };
255 _ = ensureDaemon( 270 _ = ensureDaemon(
256 alloc, 271 alloc,
257 self_exe, 272 exe,
258 &run_args, 273 &run_args,
259 sock_path, 274 sock_path,
260 progress, 275 progress,
@@ -264,11 +279,12 @@ pub fn ensureForAttach(
264 // The failure line, with the log path, was already printed by 279 // The failure line, with the log path, was already printed by
265 // Progress — a second line would say the same thing worse. 280 // Progress — a second line would say the same thing worse.
266 error.NeverAnswered => return false, 281 error.NeverAnswered => return false,
267 error.BinaryNotFound, error.SpawnFailed => { 282 error.SpawnFailed => {
268 // std.debug.print rather than progress.emitFmt: an exe path can 283 // std.debug.print rather than progress.emitFmt: an exe path can
269 // run to max_path_bytes, and emitFmt's fixed buffer would drop 284 // run to max_path_bytes, and emitFmt's fixed buffer would drop
270 // the whole line rather than shorten it. 285 // the whole line rather than shorten it. The RESOLVED path, so
271 std.debug.print("{s}: could not spawn {s}: {s}\n", .{ prefix, self_exe, @errorName(err) }); 286 // the line names a file an operator can stat.
287 std.debug.print("{s}: could not spawn {s}: {s}\n", .{ prefix, exe, @errorName(err) });
272 return false; 288 return false;
273 }, 289 },
274 }; 290 };
@@ -317,12 +333,12 @@ test "ensureDaemon: an answering socket is already_running, nothing spawned" {
317 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &out)); 333 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &out));
318 } 334 }
319 335
320 test "ensureDaemon: missing binary is BinaryNotFound before any fork" { 336 test "ensureDaemon: a missing binary is SpawnFailed before any fork" {
321 var tmp = try testtmp.TmpDir.make(); 337 var tmp = try testtmp.TmpDir.make();
322 defer tmp.cleanup(); 338 defer tmp.cleanup();
323 var buf: [128]u8 = undefined; 339 var buf: [128]u8 = undefined;
324 const sock = try std.fmt.bufPrint(&buf, "{s}/none.sock", .{tmp.path()}); 340 const sock = try std.fmt.bufPrint(&buf, "{s}/none.sock", .{tmp.path()});
325 try std.testing.expectError(error.BinaryNotFound, ensureDaemon( 341 try std.testing.expectError(error.SpawnFailed, ensureDaemon(
326 std.testing.allocator, 342 std.testing.allocator,
327 "/no/such/muxd", 343 "/no/such/muxd",
328 &.{}, 344 &.{},
@@ -333,6 +349,16 @@ test "ensureDaemon: missing binary is BinaryNotFound before any fork" {
333 )); 349 ));
334 } 350 }
335 351
352 test "selfExe: the exec'd name is a real file, not the /proc link" {
353 var buf: [std.fs.max_path_bytes]u8 = undefined;
354 const exe = selfExe(&buf);
355 // Handing execve the link itself is what named every spawned daemon
356 // `exe`: comm is the basename of the filename exec'd, so the resolution
357 // IS the name the daemon wears in ps, pgrep and killall.
358 try std.testing.expect(!std.mem.eql(u8, exe, self_exe));
359 try std.posix.access(exe, std.posix.X_OK);
360 }
361
336 test "ensureDaemon: a child that dies young is reported, not panicked on" { 362 test "ensureDaemon: a child that dies young is reported, not panicked on" {
337 var tmp = try testtmp.TmpDir.make(); 363 var tmp = try testtmp.TmpDir.make();
338 defer tmp.cleanup(); 364 defer tmp.cleanup();
@@ -441,7 +467,7 @@ test "ensureDaemon: the child execs the path it was HANDED, never a name off PAT
441 // Nothing on this box resolves by NAME to a file in a fresh tmp dir, so 467 // Nothing on this box resolves by NAME to a file in a fresh tmp dir, so
442 // a spawn that searched PATH cannot pass this. `$*` pins the other half 468 // a spawn that searched PATH cannot pass this. `$*` pins the other half
443 // — the daemon is asked for `d run`, which is also what makes a daemon 469 // — the daemon is asked for `d run`, which is also what makes a daemon
444 // legible in `ps`. Production hands `self_exe` and nothing else, so the 470 // legible in `ps`. Production hands `selfExe` and nothing else, so the
445 // same mechanism cannot reach a sibling: `mux` used to hunt PATH for a 471 // same mechanism cannot reach a sibling: `mux` used to hunt PATH for a
446 // `muxd`, and an e2e leg graded an installed v0.0.1-10 that way. 472 // `muxd`, and an e2e leg graded an installed v0.0.1-10 that way.
447 var script: [512]u8 = undefined; 473 var script: [512]u8 = undefined;
test/e2e_03_side.sh
Old New
@@ -186,15 +186,27 @@ defer_kill "$PAPID"
186 # process an auto-start brought up is running the binary this suite was 186 # process an auto-start brought up is running the binary this suite was
187 # handed, byte for byte. `mux` used to resolve `muxd` against PATH, and a 187 # handed, byte for byte. `mux` used to resolve `muxd` against PATH, and a
188 # leg whose daemon had died reattached to an INSTALLED v0.0.1-10 with no 188 # leg whose daemon had died reattached to an INSTALLED v0.0.1-10 with no
189 # agent code in it and passed. It now execs /proc/self/exe, so the link 189 # agent code in it and passed. The exec names the running image, so the link
190 # below can only ever be the running image — and this reads the link rather 190 # below can only ever be that image — and this reads the link rather
191 # than the argv, because argv is what a wrong spawn would still get right. 191 # than the argv, because argv is what a wrong spawn would still get right.
192 PAEXE=$(readlink "/proc/$PAPID/exe") 192 # readlink -f on both sides: /proc/PID/exe is fully resolved, so a logical
193 PAWANT=$(cd "$(dirname "$MUX_ELF")" && pwd)/$(basename "$MUX_ELF") 193 # path reached through a symlinked directory would fail a correct spawn.
194 PAEXE=$(readlink -f "/proc/$PAPID/exe")
195 PAWANT=$(readlink -f "$MUX_ELF")
194 [ "$PAEXE" = "$PAWANT" ] || { 196 [ "$PAEXE" = "$PAWANT" ] || {
195 echo "e2e FAIL: the auto-started daemon is running $PAEXE, not the build's $PAWANT" 197 echo "e2e FAIL: the auto-started daemon is running $PAEXE, not the build's $PAWANT"
196 echo " (an auto-start that resolves a NAME grades whatever is installed)" 198 echo " (an auto-start that resolves a NAME grades whatever is installed)"
197 exit 1; } 199 exit 1; }
200 # And the name the kernel gives it, which the link cannot see: `comm` comes
201 # from the basename of the FILENAME handed to execve, so exec'ing the
202 # /proc/self/exe link itself leaves every daemon on the box called `exe` —
203 # invisible to `pgrep mux`, `killall mux`, `ps -o comm` and systemd's
204 # MainPID name, with only the args still saying `mux d run`.
205 PACOMM=$(cat "/proc/$PAPID/comm")
206 [ "$PACOMM" = "mux" ] || {
207 echo "e2e FAIL: the auto-started daemon's comm is '$PACOMM', want 'mux'"
208 echo " (exec the RESOLVED path; the /proc link names the process after itself)"
209 exit 1; }
198 assert_converged "$OUT.pa" "$SOCK15" "local mux auto-start" 210 assert_converged "$OUT.pa" "$SOCK15" "local mux auto-start"
199 # Same teardown, same reasons — and the stderr is captured rather than 211 # Same teardown, same reasons — and the stderr is captured rather than
200 # discarded, so this leg pins the stopped line too. $OUT.stop is reused 212 # discarded, so this leg pins the stopped line too. $OUT.stop is reused