351e4586
fix: a spawned daemon's comm is mux, not exe — exec the resolved path
a73x 2026-08-29 02:16
Commit message
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 |