a73x

4d363861

refactor: the daemon fork is server_os.forkDetached, and rule 6 names it

a73x   2026-09-03 15:10

Commit message
refactor: the daemon fork is server_os.forkDetached, and rule 6 names it

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

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);