a73x

dc42cc6f

feat: spawn.ensureDaemon — probe, detach, poll; the start/auto-start core

a73x   2026-08-09 13:27

Commit message
feat: spawn.ensureDaemon — probe, detach, poll; the start/auto-start core

build.zig
Old New
@@ -124,6 +124,17 @@ pub fn build(b: *std.Build) void {
124 }); 124 });
125 xdg_mod.addImport("testtmp", testtmp_mod); 125 xdg_mod.addImport("testtmp", testtmp_mod);
126 126
127 // Daemon spawning (probe / detach / poll). muxd start today; attach
128 // auto-start is a banked second call site.
129 const spawn_mod = b.createModule(.{
130 .root_source_file = b.path("src/spawn.zig"),
131 .target = target,
132 .optimize = optimize,
133 .link_libc = true,
134 });
135 spawn_mod.addImport("xdg", xdg_mod);
136 spawn_mod.addImport("testtmp", testtmp_mod);
137
127 const server_mod = b.createModule(.{ 138 const server_mod = b.createModule(.{
128 .root_source_file = b.path("src/server.zig"), 139 .root_source_file = b.path("src/server.zig"),
129 .target = target, 140 .target = target,
@@ -217,6 +228,7 @@ pub fn build(b: *std.Build) void {
217 // The keygen round-trip test needs a directory to generate into; the 228 // The keygen round-trip test needs a directory to generate into; the
218 // daemon itself never touches this. 229 // daemon itself never touches this.
219 exe_mod.addImport("testtmp", testtmp_mod); 230 exe_mod.addImport("testtmp", testtmp_mod);
231 exe_mod.addImport("spawn", spawn_mod);
220 232
221 const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod }); 233 const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod });
222 // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe 234 // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe
@@ -251,7 +263,7 @@ pub fn build(b: *std.Build) void {
251 // absence here was a live hazard recorded in decisions.md — muxd's 263 // absence here was a live hazard recorded in decisions.md — muxd's
252 // entrypoint could grow tests that silently never ran, exactly as 264 // entrypoint could grow tests that silently never ran, exactly as
253 // mux_main.zig's five did before it was added. 265 // mux_main.zig's five did before it was added.
254 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod }) |mod| { 266 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod }) |mod| {
255 const t = b.addTest(.{ .root_module = mod }); 267 const t = b.addTest(.{ .root_module = mod });
256 t.use_llvm = true; 268 t.use_llvm = true;
257 t.use_lld = true; 269 t.use_lld = true;
docs/superpowers/plans/2026-08-09-m10-quic-ergonomics.md
Old New
@@ -856,7 +856,7 @@ git commit -m "feat: key resolution --key > MUX_KEY_FILE > ~/.config/mux/key, bo
856 - Create: `src/spawn.zig` 856 - Create: `src/spawn.zig`
857 - Modify: `build.zig` (create `spawn_mod` with `xdg` + `testtmp` imports, `link_libc = true`; import into `exe_mod` as `"spawn"`; **add to test loop**) 857 - Modify: `build.zig` (create `spawn_mod` with `xdg` + `testtmp` imports, `link_libc = true`; import into `exe_mod` as `"spawn"`; **add to test loop**)
858 858
859 - [ ] **Step 1: Create `src/spawn.zig`** 859 - [x] **Step 1: Create `src/spawn.zig`**
860 860
861 ```zig 861 ```zig
862 //! Get a daemon onto a socket path: probe, spawn detached, poll until it 862 //! Get a daemon onto a socket path: probe, spawn detached, poll until it
@@ -1090,7 +1090,7 @@ test "ensureDaemon: a binary that never binds is NeverAnswered, pid left alive"
1090 } 1090 }
1091 ``` 1091 ```
1092 1092
1093 - [ ] **Step 2: The pid test hook** 1093 - [x] **Step 2: The pid test hook**
1094 1094
1095 The last test needs the spawned pid (kill-by-tracked-pid rule: the test must clean up its sleeper, and only by a pid it was handed). Add to spawn.zig, and set it in `ensureDaemon` right after the fork (parent side): 1095 The last test needs the spawned pid (kill-by-tracked-pid rule: the test must clean up its sleeper, and only by a pid it was handed). Add to spawn.zig, and set it in `ensureDaemon` right after the fork (parent side):
1096 1096
@@ -1106,7 +1106,7 @@ pub var last_spawned_pid: std.posix.pid_t = 0;
1106 last_spawned_pid = pid; 1106 last_spawned_pid = pid;
1107 ``` 1107 ```
1108 1108
1109 - [ ] **Step 3: Wire spawn_mod in build.zig** 1109 - [x] **Step 3: Wire spawn_mod in build.zig**
1110 1110
1111 After the `xdg_mod` block: 1111 After the `xdg_mod` block:
1112 1112
@@ -1125,7 +1125,7 @@ After the `xdg_mod` block:
1125 1125
1126 `exe_mod.addImport("spawn", spawn_mod);` next to its other imports. **Add `spawn_mod` to the test loop array.** 1126 `exe_mod.addImport("spawn", spawn_mod);` next to its other imports. **Add `spawn_mod` to the test loop array.**
1127 1127
1128 - [ ] **Step 4: Run tests, expect pass** 1128 - [x] **Step 4: Run tests, expect pass**
1129 1129
1130 Run: `make test` 1130 Run: `make test`
1131 Expected: pass. The three spawn tests run (verify with a quick deliberate break: flip `probe`'s `return true` to `return false`; the already_running test must fail; restore). 1131 Expected: pass. The three spawn tests run (verify with a quick deliberate break: flip `probe`'s `return true` to `return false`; the already_running test must fail; restore).
@@ -1138,7 +1138,7 @@ know the artifact path). Both cases live in Task 7's e2e instead, against
1138 the real daemon, which is stronger evidence anyway. The unit layer keeps 1138 the real daemon, which is stronger evidence anyway. The unit layer keeps
1139 what is honestly unit-testable: probe/no-spawn, missing binary, never-binds. 1139 what is honestly unit-testable: probe/no-spawn, missing binary, never-binds.
1140 1140
1141 - [ ] **Step 5: Commit** 1141 - [x] **Step 5: Commit**
1142 1142
1143 ```bash 1143 ```bash
1144 git add src/spawn.zig build.zig 1144 git add src/spawn.zig build.zig
src/spawn.zig
Old New
@@ -0,0 +1,238 @@
1 //! Get a daemon onto a socket path: probe, spawn detached, poll until it
2 //! answers. `muxd start` is the explicit caller today; attach auto-start
3 //! (banked) becomes a second call site, not a rewrite.
4 const std = @import("std");
5 const xdg = @import("xdg");
6
7 pub const EnsureError = error{ BinaryNotFound, SpawnFailed, NeverAnswered };
8 pub const Ensured = enum { already_running, started };
9
10 /// Test hook: the pid of the most recent spawn. Tests use it to reap the
11 /// deliberately-orphaned stub; muxd start reads it for the up-line. Not
12 /// synchronized — single-threaded callers only, which both callers are.
13 pub var last_spawned_pid: std.posix.pid_t = 0;
14
15 /// All stderr output belongs to this struct: the caller decides the prefix
16 /// ("muxd" today, "mux" when auto-start lands) and whether dots animate.
17 /// Silence on the already-running path is part of the contract — any
18 /// output at all means something unusual happened.
19 pub const Progress = struct {
20 fd: std.posix.fd_t,
21 prefix: []const u8,
22 tty: bool,
23
24 fn emit(self: Progress, s: []const u8) void {
25 _ = std.posix.write(self.fd, s) catch {};
26 }
27
28 fn emitFmt(self: Progress, comptime fmt: []const u8, args: anytype) void {
29 var buf: [256]u8 = undefined;
30 const s = std.fmt.bufPrint(&buf, fmt, args) catch return;
31 self.emit(s);
32 }
33 };
34
35 /// Probe `sock_path`; if nothing answers, exec `exe_path run <run_args...>`
36 /// detached (setsid, stdin /dev/null, stdout+stderr truncating the xdg log)
37 /// and poll every 50ms until the socket accepts or `deadline_ms` passes.
38 ///
39 /// On `NeverAnswered` the spawned pid is deliberately NOT killed: a daemon
40 /// that comes up at 2.5s should be there for the retry, not murdered for
41 /// tardiness. The failure line names the log, which holds its stderr.
42 ///
43 /// Two racers both spawning is handled by the daemon itself: the loser
44 /// exits on DaemonAlreadyRunning (server.zig claimSockPath) and the
45 /// loser's poll connects to the winner.
46 pub fn ensureDaemon(
47 alloc: std.mem.Allocator,
48 exe_path: []const u8,
49 run_args: []const [:0]const u8,
50 sock_path: []const u8,
51 progress: Progress,
52 deadline_ms: u32,
53 ) EnsureError!Ensured {
54 if (probe(sock_path)) return .already_running;
55
56 std.posix.access(exe_path, std.posix.X_OK) catch return error.BinaryNotFound;
57
58 const log_path = xdg.logPath(alloc) catch return error.SpawnFailed;
59 defer alloc.free(log_path);
60 if (std.fs.path.dirname(log_path)) |dir|
61 std.fs.cwd().makePath(dir) catch return error.SpawnFailed;
62 const log = std.fs.cwd().createFile(log_path, .{ .truncate = true, .mode = 0o600 }) catch
63 return error.SpawnFailed;
64 defer log.close();
65 const devnull = std.fs.cwd().openFile("/dev/null", .{}) catch return error.SpawnFailed;
66 defer devnull.close();
67
68 // argv for the child: exe run <forwarded...>, all null-terminated.
69 const exe_z = alloc.dupeZ(u8, exe_path) catch return error.SpawnFailed;
70 defer alloc.free(exe_z);
71 const argv = alloc.allocSentinel(?[*:0]const u8, run_args.len + 2, null) catch
72 return error.SpawnFailed;
73 defer alloc.free(argv);
74 argv[0] = exe_z.ptr;
75 argv[1] = "run";
76 for (run_args, 0..) |a, i| argv[i + 2] = a.ptr;
77
78 progress.emitFmt("{s}: starting\u{2026}", .{progress.prefix});
79 if (!progress.tty) progress.emit("\n");
80
81 const t0 = std.time.milliTimestamp();
82 const pid = std.posix.fork() catch {
83 if (progress.tty) progress.emit("\n");
84 return error.SpawnFailed;
85 };
86 if (pid == 0) {
87 // Child: its own session, no controlling terminal, stdio detached.
88 // Nothing here may allocate or return — only exec or _exit.
89 _ = std.os.linux.setsid();
90 std.posix.dup2(devnull.handle, std.posix.STDIN_FILENO) catch std.posix.exit(127);
91 std.posix.dup2(log.handle, std.posix.STDOUT_FILENO) catch std.posix.exit(127);
92 std.posix.dup2(log.handle, std.posix.STDERR_FILENO) catch std.posix.exit(127);
93 // execveZ's return type IS an error set — there is no success value,
94 // because success does not return. 127 is the shell's "cannot exec",
95 // and the parent learns the same thing either way: the socket never
96 // answers, and the log names what happened.
97 switch (std.posix.execveZ(exe_z.ptr, argv.ptr, std.c.environ)) {
98 else => std.posix.exit(127),
99 }
100 }
101 last_spawned_pid = pid;
102
103 // Parent: poll. Dots only on a tty so scripted output stays pinnable.
104 var next_dot: i64 = t0 + 250;
105 while (true) {
106 if (probe(sock_path)) {
107 const secs = @as(f64, @floatFromInt(std.time.milliTimestamp() - t0)) / 1000.0;
108 progress.emitFmt(" up ({d:.1}s) pid={d}\n", .{ secs, pid });
109 return .started;
110 }
111 const now = std.time.milliTimestamp();
112 if (now - t0 >= deadline_ms) {
113 progress.emitFmt(
114 "\n{s}: muxd did not answer within {d}s \u{2014} log: {s}\n",
115 .{ progress.prefix, deadline_ms / 1000, log_path },
116 );
117 return error.NeverAnswered;
118 }
119 if (progress.tty and now >= next_dot) {
120 progress.emit(".");
121 next_dot = now + 250;
122 }
123 // Reap if the child exited (loser of a start race, or a refused
124 // flag): its socket-owner sibling answers the next probe either
125 // way, and an unreaped child would sit as a zombie until we exit.
126 _ = std.posix.waitpid(pid, std.posix.W.NOHANG);
127 std.Thread.sleep(50 * std.time.ns_per_ms);
128 }
129 }
130
131 fn probe(sock_path: []const u8) bool {
132 const s = std.net.connectUnixSocket(sock_path) catch return false;
133 s.close();
134 return true;
135 }
136
137 // ---------------------------------------------------------------------------
138
139 const testtmp = @import("testtmp");
140
141 fn silentProgress() Progress {
142 // Progress that writes to /dev/null keeps test output clean while the
143 // pinned-output cases below capture a pipe instead.
144 const f = std.fs.cwd().openFile("/dev/null", .{ .mode = .write_only }) catch unreachable;
145 return .{ .fd = f.handle, .prefix = "test", .tty = false };
146 }
147
148 test "ensureDaemon: an answering socket is already_running, nothing spawned" {
149 var tmp = try testtmp.TmpDir.make();
150 defer tmp.cleanup();
151 var buf: [128]u8 = undefined;
152 const sock = try std.fmt.bufPrint(&buf, "{s}/live.sock", .{tmp.path()});
153
154 const addr = try std.net.Address.initUnix(sock);
155 var server = try addr.listen(.{});
156 defer server.deinit();
157
158 // Progress captured through a pipe: already_running must print NOTHING.
159 const pipe = try std.posix.pipe();
160 defer std.posix.close(pipe[0]);
161 const progress: Progress = .{ .fd = pipe[1], .prefix = "test", .tty = false };
162
163 const r = try ensureDaemon(
164 std.testing.allocator,
165 "/definitely/not/consulted",
166 &.{},
167 sock,
168 progress,
169 200,
170 );
171 try std.testing.expectEqual(Ensured.already_running, r);
172
173 std.posix.close(pipe[1]);
174 var out: [64]u8 = undefined;
175 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &out));
176 }
177
178 test "ensureDaemon: missing binary is BinaryNotFound before any fork" {
179 var tmp = try testtmp.TmpDir.make();
180 defer tmp.cleanup();
181 var buf: [128]u8 = undefined;
182 const sock = try std.fmt.bufPrint(&buf, "{s}/none.sock", .{tmp.path()});
183 try std.testing.expectError(error.BinaryNotFound, ensureDaemon(
184 std.testing.allocator,
185 "/no/such/muxd",
186 &.{},
187 sock,
188 silentProgress(),
189 200,
190 ));
191 }
192
193 test "ensureDaemon: a binary that never binds is NeverAnswered, pid left alive" {
194 var tmp = try testtmp.TmpDir.make();
195 defer tmp.cleanup();
196 var pbuf: [128]u8 = undefined;
197 var sbuf: [128]u8 = undefined;
198 const stub = try std.fmt.bufPrint(&pbuf, "{s}/stub.sh", .{tmp.path()});
199 const sock = try std.fmt.bufPrint(&sbuf, "{s}/never.sock", .{tmp.path()});
200
201 // A stand-in daemon that stays alive and binds nothing. `exec` so the
202 // pid ensureDaemon tracked IS the sleeper, not a parent shell of it.
203 try tmp.dir.writeFile(.{ .sub_path = "stub.sh", .data = "#!/bin/sh\nexec sleep 30\n" });
204 const f = try tmp.dir.openFile("stub.sh", .{});
205 try f.chmod(0o755);
206 f.close();
207
208 // xdg.logPath reads XDG_STATE_HOME at call time and Zig tests cannot
209 // setenv, so this spawn truncates the real log path on the machine
210 // running the suite. That is exactly what a real spawn does to it, and
211 // the log holds only the current daemon's output, never history.
212 const t0 = std.time.milliTimestamp();
213 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
214 std.testing.allocator,
215 stub,
216 &.{},
217 sock,
218 silentProgress(),
219 300,
220 ));
221 // It waited the deadline out rather than bailing early...
222 try std.testing.expect(std.time.milliTimestamp() - t0 >= 300);
223
224 // ...and did NOT kill the spawned process. `last_spawned_pid` is how
225 // the test learns which pid that is: killing by anything else — a name
226 // match, a process sweep — could take out a bystander, so the pid the
227 // spawner tracked is the only handle allowed.
228 try std.testing.expect(last_spawned_pid != 0);
229 // waitpid with NOHANG returning pid 0 means "child exists, still
230 // running", which is the assertion; a reaped or dead child returns its
231 // own pid instead.
232 try std.testing.expectEqual(
233 @as(std.posix.pid_t, 0),
234 std.posix.waitpid(last_spawned_pid, std.posix.W.NOHANG).pid,
235 );
236 std.posix.kill(last_spawned_pid, std.posix.SIG.KILL) catch {};
237 _ = std.posix.waitpid(last_spawned_pid, 0);
238 }