dc42cc6f
feat: spawn.ensureDaemon — probe, detach, poll; the start/auto-start core
a73x 2026-08-09 13:27
Commit message
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 | } | ||