4d363861
refactor: the daemon fork is server_os.forkDetached, and rule 6 names it
a73x 2026-09-03 15:10
Commit message
CLAUDE.md
| Old | New | ||
|---|---|---|---|
| @@ -288,7 +288,8 @@ own. Test fixtures in `test/`: | |||
| 288 | a daemon on someone else's box that nothing reports. | 288 | a daemon on someone else's box that nothing reports. |
| 289 | - **The daemon starts itself, and it execs THIS image.** `mux d start` IS | 289 | - **The daemon starts itself, and it execs THIS image.** `mux d start` IS |
| 290 | the daemon in the foreground; `-d` is the flag that forks one, and | 290 | the daemon in the foreground; `-d` is the flag that forks one, and |
| 291 | `main.forkDaemon` is the only `posix.fork` under `src/` — build.zig's | 291 | `server_os_linux.forkDetached` is the only `posix.fork` under `src/`, and |
| 292 | `main.forkDaemon` is its one caller — build.zig's | ||
| 292 | folder rule 6, whose `except` names the one file. Every other starter | 293 | folder rule 6, whose `except` names the one file. Every other starter |
| 293 | spells the argv and execs: `endpoint --start` calls in-process, the | 294 | spells the argv and execs: `endpoint --start` calls in-process, the |
| 294 | local client's entry runs `mux d start -d --sock PATH` with its own fd 2 | 295 | local client's entry runs `mux d start -d --sock PATH` with its own fd 2 |
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -275,7 +275,7 @@ const mod_table = [_]ModSpec{ | |||
| 275 | // fourth copy of connect-write-poll-read here. `testtmp` is the keygen | 275 | // fourth copy of connect-write-poll-read here. `testtmp` is the keygen |
| 276 | // round-trip's: it needs a directory to generate into, which the daemon | 276 | // round-trip's: it needs a directory to generate into, which the daemon |
| 277 | // never touches. | 277 | // never touches. |
| 278 | .{ .name = "mux", .path = "src/cli/mux.zig", .link_libc = true, .imports = &.{ "daemon", "client", "wall", "agent", "webhub", "term", "proxy", "quic", "xdg", "spawn", "sockpath", "cliflags", "dial" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 278 | .{ .name = "mux", .path = "src/cli/mux.zig", .link_libc = true, .imports = &.{ "daemon", "client", "wall", "agent", "webhub", "term", "proxy", "quic", "xdg", "spawn", "sockpath", "cliflags", "dial", "server_os" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 279 | }; | 279 | }; |
| 280 | 280 | ||
| 281 | /// Comptime row lookup. Every hand-written module name in this file goes | 281 | /// Comptime row lookup. Every hand-written module name in this file goes |
| @@ -386,7 +386,7 @@ const source_bans = [_]SourceBan{ | |||
| 386 | .rule = "6", | 386 | .rule = "6", |
| 387 | .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli", "src/os" }, | 387 | .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli", "src/os" }, |
| 388 | .needles = &.{"posix.fork("}, | 388 | .needles = &.{"posix.fork("}, |
| 389 | .except = "src/cli/main.zig", | 389 | .except = "src/os/server_os_linux.zig", |
| 390 | .why = "the daemon starts itself \u{2014} `mux d start -d` forks, and " ++ | 390 | .why = "the daemon starts itself \u{2014} `mux d start -d` forks, and " ++ |
| 391 | "every other starter spells that argv and execs this image. A " ++ | 391 | "every other starter spells that argv and execs this image. A " ++ |
| 392 | "client that forked a daemon would be choosing the daemon's " ++ | 392 | "client that forked a daemon would be choosing the daemon's " ++ |
src/cli/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -14,6 +14,7 @@ const quic_server = @import("daemon").quic_server; | |||
| 14 | const build_options = @import("build_options"); | 14 | const build_options = @import("build_options"); |
| 15 | const xdg = @import("xdg"); | 15 | const xdg = @import("xdg"); |
| 16 | const spawn = @import("spawn"); | 16 | const spawn = @import("spawn"); |
| 17 | const server_os = @import("server_os"); | ||
| 17 | const handoff = @import("client").handoff; | 18 | const handoff = @import("client").handoff; |
| 18 | const sockpath = @import("sockpath"); | 19 | const sockpath = @import("sockpath"); |
| 19 | const upgrade = @import("daemon").upgrade; | 20 | const upgrade = @import("daemon").upgrade; |
| @@ -1277,9 +1278,8 @@ fn forkDaemon( | |||
| 1277 | deadline_ms: u32, | 1278 | deadline_ms: u32, |
| 1278 | log_path_override: ?[]const u8, | 1279 | log_path_override: ?[]const u8, |
| 1279 | ) StartError!StartOutcome { | 1280 | ) StartError!StartOutcome { |
| 1280 | // Daemon-mode code owns the repository's only fork path. `NeverAnswered` | 1281 | // `NeverAnswered` leaves the child running because it may finish startup |
| 1281 | // leaves the child running because it may finish startup after the caller's | 1282 | // after the caller's deadline and be available on retry. |
| 1282 | // deadline and be available on retry. | ||
| 1283 | if (sockpath.answers(sock_path)) return .already_running; | 1283 | if (sockpath.answers(sock_path)) return .already_running; |
| 1284 | 1284 | ||
| 1285 | std.posix.access(exe_path, std.posix.X_OK) catch return error.SpawnFailed; | 1285 | std.posix.access(exe_path, std.posix.X_OK) catch return error.SpawnFailed; |
| @@ -1327,28 +1327,13 @@ fn forkDaemon( | |||
| 1327 | if (!progress.tty) progress.emit("\n"); | 1327 | if (!progress.tty) progress.emit("\n"); |
| 1328 | 1328 | ||
| 1329 | const t0 = std.time.milliTimestamp(); | 1329 | const t0 = std.time.milliTimestamp(); |
| 1330 | const pid = std.posix.fork() catch { | 1330 | // The fork itself is `server_os.forkDetached`; this function owns what |
| 1331 | // goes INTO it — the log, the argv and the deadline — and folder rule 6 | ||
| 1332 | // names that file as the one fork. | ||
| 1333 | const pid = server_os.forkDetached(exe_z.ptr, argv.ptr, devnull.handle, log.handle) catch { | ||
| 1331 | if (progress.tty) progress.emit("\n"); | 1334 | if (progress.tty) progress.emit("\n"); |
| 1332 | return error.SpawnFailed; | 1335 | return error.SpawnFailed; |
| 1333 | }; | 1336 | }; |
| 1334 | if (pid == 0) { | ||
| 1335 | // Child: create a new session, detach stdio, then exec or call | ||
| 1336 | // `exit_group`. Avoid `std.posix.exit` because libc atexit handlers could | ||
| 1337 | // flush buffers inherited from the parent a second time. | ||
| 1338 | _ = std.os.linux.setsid(); | ||
| 1339 | std.posix.dup2(devnull.handle, std.posix.STDIN_FILENO) catch | ||
| 1340 | std.os.linux.exit_group(127); | ||
| 1341 | std.posix.dup2(log.handle, std.posix.STDOUT_FILENO) catch | ||
| 1342 | std.os.linux.exit_group(127); | ||
| 1343 | std.posix.dup2(log.handle, std.posix.STDERR_FILENO) catch | ||
| 1344 | std.os.linux.exit_group(127); | ||
| 1345 | // Exec a fresh image because `std.debug.MemoryAccessor` caches the pid | ||
| 1346 | // used for memory reads; reusing it after fork can make DebugAllocator | ||
| 1347 | // inspect the parent and panic. Exit 127 if exec fails. | ||
| 1348 | switch (std.posix.execveZ(exe_z.ptr, argv.ptr, std.c.environ)) { | ||
| 1349 | else => std.os.linux.exit_group(127), | ||
| 1350 | } | ||
| 1351 | } | ||
| 1352 | last_spawned_pid = pid; | 1337 | last_spawned_pid = pid; |
| 1353 | 1338 | ||
| 1354 | // Parent: poll the socket. Animate dots only on a TTY so scripted output is | 1339 | // Parent: poll the socket. Animate dots only on a TTY so scripted output is |
src/os/server_os.zig
| Old | New | ||
|---|---|---|---|
| @@ -39,6 +39,22 @@ pub fn exitNow(code: u8) noreturn { | |||
| 39 | impl.exitNow(code); | 39 | impl.exitNow(code); |
| 40 | } | 40 | } |
| 41 | 41 | ||
| 42 | /// The repository's ONE fork that is not a pty: `mux d start -d`. The child | ||
| 43 | /// becomes a session leader, wires stdin to `stdin_fd` and both stdout and | ||
| 44 | /// stderr to `out_fd`, and execs `exe` with `argv` — a fresh image, because | ||
| 45 | /// `std.debug.MemoryAccessor` caches the pid it reads memory through and a | ||
| 46 | /// Debug child that kept running would inspect the parent and panic | ||
| 47 | /// (decisions.md, 2026-08-28). A failed exec exits 127 with no atexit. | ||
| 48 | /// Returns the child's pid; the parent decides how long to wait for it. | ||
| 49 | pub fn forkDetached( | ||
| 50 | exe: [*:0]const u8, | ||
| 51 | argv: [*:null]const ?[*:0]const u8, | ||
| 52 | stdin_fd: std.posix.fd_t, | ||
| 53 | out_fd: std.posix.fd_t, | ||
| 54 | ) error{ForkFailed}!std.posix.pid_t { | ||
| 55 | return impl.forkDetached(exe, argv, stdin_fd, out_fd); | ||
| 56 | } | ||
| 57 | |||
| 42 | /// The fd barrier: every descriptor at or above `first` is closed in the | 58 | /// The fd barrier: every descriptor at or above `first` is closed in the |
| 43 | /// child before exec. CLOEXEC is set fd by fd, and an upgrade clears every | 59 | /// child before exec. CLOEXEC is set fd by fd, and an upgrade clears every |
| 44 | /// one and must seal them again — two hand-kept lists that would have to | 60 | /// one and must seal them again — two hand-kept lists that would have to |
| @@ -121,6 +137,31 @@ test "server_os.setWinsize then ptyMode: the master answers about the line disci | |||
| 121 | try setWinsize(f.master, .{ .row = 10, .col = 40, .xpixel = 0, .ypixel = 0 }); | 137 | try setWinsize(f.master, .{ .row = 10, .col = 40, .xpixel = 0, .ypixel = 0 }); |
| 122 | } | 138 | } |
| 123 | 139 | ||
| 140 | test "server_os.forkDetached: the child is a session leader writing to the fd it was given" { | ||
| 141 | // Asked of the OS: the child prints its own session id and pid; a | ||
| 142 | // detached daemon is its own session leader, so they are equal. | ||
| 143 | const pipe = try std.posix.pipe(); | ||
| 144 | defer std.posix.close(pipe[0]); | ||
| 145 | const devnull = try std.fs.cwd().openFile("/dev/null", .{}); | ||
| 146 | defer devnull.close(); | ||
| 147 | const argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "ps -o sid= -p $$ | tr -d ' '; echo $$" }; | ||
| 148 | const pid = try forkDetached("/bin/sh", &argv, devnull.handle, pipe[1]); | ||
| 149 | std.posix.close(pipe[1]); | ||
| 150 | var buf: [64]u8 = undefined; | ||
| 151 | var n: usize = 0; | ||
| 152 | while (true) { | ||
| 153 | const got = try std.posix.read(pipe[0], buf[n..]); | ||
| 154 | if (got == 0) break; | ||
| 155 | n += got; | ||
| 156 | } | ||
| 157 | _ = std.posix.waitpid(pid, 0); | ||
| 158 | var lines = std.mem.tokenizeScalar(u8, buf[0..n], '\n'); | ||
| 159 | const sid = lines.next() orelse return error.NoOutput; | ||
| 160 | const shpid = lines.next() orelse return error.NoOutput; | ||
| 161 | try std.testing.expectEqualStrings(shpid, sid); | ||
| 162 | try std.testing.expectEqual(pid, try std.fmt.parseInt(std.posix.pid_t, shpid, 10)); | ||
| 163 | } | ||
| 164 | |||
| 124 | // Forces semantic analysis of every pub decl under `zig build test`, so an | 165 | // Forces semantic analysis of every pub decl under `zig build test`, so an |
| 125 | // unreferenced operation must at least compile for this OS. | 166 | // unreferenced operation must at least compile for this OS. |
| 126 | test { | 167 | test { |
src/os/server_os_linux.zig
| Old | New | ||
|---|---|---|---|
| @@ -22,6 +22,22 @@ pub fn exitNow(code: u8) noreturn { | |||
| 22 | std.os.linux.exit_group(code); | 22 | std.os.linux.exit_group(code); |
| 23 | } | 23 | } |
| 24 | 24 | ||
| 25 | pub fn forkDetached( | ||
| 26 | exe: [*:0]const u8, | ||
| 27 | argv: [*:null]const ?[*:0]const u8, | ||
| 28 | stdin_fd: std.posix.fd_t, | ||
| 29 | out_fd: std.posix.fd_t, | ||
| 30 | ) error{ForkFailed}!std.posix.pid_t { | ||
| 31 | const pid = std.posix.fork() catch return error.ForkFailed; | ||
| 32 | if (pid != 0) return pid; | ||
| 33 | _ = std.os.linux.setsid(); | ||
| 34 | std.posix.dup2(stdin_fd, std.posix.STDIN_FILENO) catch exitNow(127); | ||
| 35 | std.posix.dup2(out_fd, std.posix.STDOUT_FILENO) catch exitNow(127); | ||
| 36 | std.posix.dup2(out_fd, std.posix.STDERR_FILENO) catch exitNow(127); | ||
| 37 | std.posix.execveZ(exe, argv, std.c.environ) catch exitNow(127); | ||
| 38 | unreachable; | ||
| 39 | } | ||
| 40 | |||
| 25 | pub fn closeFrom(first: std.posix.fd_t) void { | 41 | pub fn closeFrom(first: std.posix.fd_t) void { |
| 26 | // ENOSYS (pre-5.9 kernel) leaves the CLOEXEC flags to do the work alone. | 42 | // ENOSYS (pre-5.9 kernel) leaves the CLOEXEC flags to do the work alone. |
| 27 | _ = std.os.linux.syscall3(.close_range, @intCast(first), std.math.maxInt(u32), 0); | 43 | _ = std.os.linux.syscall3(.close_range, @intCast(first), std.math.maxInt(u32), 0); |