a73x

33b17560

fix: folder rules 4 and 5 had holes where a module could ask for a terminal

a73x   2026-08-29 02:23

Commit message
fix: folder rules 4 and 5 had holes where a module could ask for a terminal

Rule 4 banned the word `termios` and not the calls: a client module
could ask `isatty` or `tcgetattr` and pass, and `spawn.zig` did. Rule 5
skipped `src/` root and `src/engine/` entirely, so a leaf utility could
spell a shell where the daemon could not. Both are matched against a
lower-cased line now, because `\X1B[` and `\x1b[` are the same escape
and only case stood between them.

`spawn` moves to `src/cli/`: it asks the OS whether it has a terminal,
which is what rule 4 forbids a client module, and its only callers are
the two mains. predict.zig's header loses a shouted TERMIOS to the same
rule — the ban is word-blunt by design, comments included.

CLAUDE.md
Old New
@@ -51,25 +51,33 @@ engine and a client can link those folders and paint its own way:
51 |---|---| 51 |---|---|
52 | `src/engine/` | `protocol` `engine` `delta` `replica` `predict` | 52 | `src/engine/` | `protocol` `engine` `delta` `replica` `predict` |
53 | `src/server/` | `server`(+`_agent` `_sessions` `_test_*`) `pty` `quic_server` `cmd` `shellint` `upgrade` | 53 | `src/server/` | `server`(+`_agent` `_sessions` `_test_*`) `pty` `quic_server` `cmd` `shellint` `upgrade` |
54 | `src/client/` | `client` `client_core` `hosts` `handoff` `spawn` `quic_client` `layout` `wall` `webhub` `keymap` `wasm_core` | 54 | `src/client/` | `client` `client_core` `hosts` `handoff` `quic_client` `layout` `wall` `webhub` `keymap` `wasm_core` |
55 | `src/tui/` | `wallview`(+`wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*`) `interact` `paint` `select` | 55 | `src/tui/` | `wallview`(+`wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*`) `interact` `paint` `select` |
56 | `src/cli/` | `mux`(dispatch) `main`(daemon) `mux_main`(client) `muxa`(agent) `webhub_main`(hub) `flags` | 56 | `src/cli/` | `mux`(dispatch) `main`(daemon) `mux_main`(client) `muxa`(agent) `webhub_main`(hub) `flags` `spawn` |
57 | `src/` | `xdg` `sockpath` `proxy` `quic` `testtmp` — what both sides link | 57 | `src/` | `xdg` `sockpath` `proxy` `quic` `testtmp` — what both sides link |
58 58
59 `build.zig`'s `checkFolderRules` enforces it: engine and client name no tui, 59 `build.zig`'s `checkFolderRules` enforces it: engine and client name no tui,
60 server or cli module; server names no client and no terminal; tui imports 60 server or cli module; server names no client and no terminal; tui imports
61 client and never the reverse; and no line outside a `test` block under 61 client and never the reverse; and no line outside a `test` block under
62 `src/engine/` or `src/client/` spells `termios` or an escape byte without a 62 `src/engine/` or `src/client/` spells `termios`, `isatty`, `tcgetattr`,
63 `// folder rule 4 exemption:` line saying why. `folder_exemptions` is empty and 63 `tcsetattr` or an escape byte without a `// folder rule 4 exemption:` line
64 kept so; the three remaining debts are the markers in `engine.zig`, 64 saying why — matched against a lower-cased line, so `\X1B[` is the same
65 `protocol.zig` and `keymap.zig`, each of which produces VT bytes by contract. 65 needle as `\x1b[`. Rule 5 is the product-wide one: no file under `src/` at
66 all may spell `"/bin/sh"` or `"-c"`, because every program mux runs is
67 exec'd as argv and no shell of ours parses a line we built; its three
68 markers are `flags.zig`, `main.zig` and `server_test_session.zig`, and two
69 of the three cover prose and a fixture rather than a shell the product runs.
70 `folder_exemptions` is empty and kept so; rule 4's three remaining debts are
71 the markers in `engine.zig`, `protocol.zig` and `keymap.zig`, each of which
72 produces VT bytes by contract. `spawn` lives under `src/cli/` because it
73 asks the OS whether it has a terminal, which rule 4 forbids a client module.
66 74
67 Layers are enforced in the same module table (grep `.layer =` for the graph). 75 Layers are enforced in the same module table (grep `.layer =` for the graph).
68 76
69 | Layer | Modules | 77 | Layer | Modules |
70 |---|---| 78 |---|---|
71 | 0 | `protocol` `engine` `pty` `quic` `keymap` `xdg` `sockpath` `proxy` `cliflags` `testtmp` | 79 | 0 | `protocol` `engine` `pty` `quic` `keymap` `xdg` `sockpath` `proxy` `cliflags` `testtmp` |
72 | 1 | `client_core` `quic_server` `quic_client` `predict` `spawn` `handoff` `delta` `cmd` `wall` `shellint` `replica` `paint` `layout` `select` `upgrade` | 80 | 1 | `client_core` `quic_server` `quic_client` `predict` `spawn`(cli) `handoff` `delta` `cmd` `wall` `shellint` `replica` `paint` `layout` `select` `upgrade` |
73 | 2 | `server` `agent_main` `interact` `hosts` | 81 | 2 | `server` `agent_main` `interact` `hosts` |
74 | 3 | `client` `daemon_main` | 82 | 3 | `client` `daemon_main` |
75 | 4 | `webhub` `wallview` | 83 | 4 | `webhub` `wallview` |
@@ -187,12 +195,15 @@ paths (`src/cli/main.zig` is the daemon). Test fixtures in `test/`:
187 nothing, because a missing start says so on screen while a spurious one is 195 nothing, because a missing start says so on screen while a spurious one is
188 a daemon on someone else's box that nothing reports. 196 a daemon on someone else's box that nothing reports.
189 - **A local start execs THIS image; nothing looks a daemon up by name.** 197 - **A local start execs THIS image; nothing looks a daemon up by name.**
190 `spawn.ensureDaemon` execs `spawn.self_exe` — `/proc/self/exe`, the running 198 `spawn.ensureDaemon` execs `spawn.selfExe` — the running image, `/proc/self/exe`
191 image by its own kernel link — with argv `mux d run …`. No `execvp`, no 199 read THROUGH to the file it names — with argv `mux d run …`. No `execvp`, no
192 PATH walk, so an auto-start can only run the binary that is already 200 PATH walk, so an auto-start can only run the binary that is already
193 running. (It used to exec a `muxd` off PATH, and an e2e leg whose daemon 201 running. (It used to exec a `muxd` off PATH, and an e2e leg whose daemon
194 had died graded an installed v0.0.1-10 with no agent code in it.) The pin 202 had died graded an installed v0.0.1-10 with no agent code in it.) The
195 is `readlink /proc/PID/exe` on the started daemon, in `e2e_03_side`. 203 resolution is not cosmetic: `comm` is the basename of the filename handed
204 to execve, so exec'ing the link itself names every daemon `exe` and hides
205 it from `pgrep mux` and `killall mux`. Both pins are in `e2e_03_side` —
206 `readlink /proc/PID/exe` for the image, `/proc/PID/comm` for the name.
196 Running the daemon IN the fork instead was tried and crashes every Debug 207 Running the daemon IN the fork instead was tried and crashes every Debug
197 build: `std.debug.MemoryAccessor` caches the pid it reads memory through, 208 build: `std.debug.MemoryAccessor` caches the pid it reads memory through,
198 so the child's first DebugAllocator stack trace calls `process_vm_readv` 209 so the child's first DebugAllocator stack trace calls `process_vm_readv`
build.zig
Old New
@@ -174,9 +174,10 @@ const mod_table = [_]ModSpec{
174 // machine be exercised without a terminal — or a daemon — anywhere in 174 // machine be exercised without a terminal — or a daemon — anywhere in
175 // the picture; reconcile takes its grid duck-typed instead. 175 // the picture; reconcile takes its grid duck-typed instead.
176 .{ .name = "predict", .path = "src/engine/predict.zig", .layer = 1, .imports = &.{"protocol"} }, 176 .{ .name = "predict", .path = "src/engine/predict.zig", .layer = 1, .imports = &.{"protocol"} },
177 // Daemon spawning (probe / detach / poll). muxd start today; attach 177 // Daemon spawning (probe / detach / poll). Under src/cli/ because its
178 // auto-start is a banked second call site. 178 // only callers are the two mains, and because it asks the OS whether it
179 .{ .name = "spawn", .path = "src/client/spawn.zig", .layer = 1, .link_libc = true, .imports = &.{"xdg"}, .test_imports = &.{"testtmp"} }, 179 // has a terminal — a question no headless client may spell.
180 .{ .name = "spawn", .path = "src/cli/spawn.zig", .layer = 1, .link_libc = true, .imports = &.{"xdg"}, .test_imports = &.{"testtmp"} },
180 // The ssh→QUIC handoff's shared vocabulary: the announce line, the 181 // The ssh→QUIC handoff's shared vocabulary: the announce line, the
181 // per-host cache, the dial-host strip. Both binaries import it — 182 // per-host cache, the dial-host strip. Both binaries import it —
182 // `muxd endpoint` writes the line, `mux HOST` reads it. xdg is for 183 // `muxd endpoint` writes the line, `mux HOST` reads it. xdg is for
@@ -466,13 +467,21 @@ const source_bans = [_]SourceBan{
466 .{ 467 .{
467 .rule = "4", 468 .rule = "4",
468 .folders = &.{ "src/engine", "src/client" }, 469 .folders = &.{ "src/engine", "src/client" },
469 .needles = &.{ "termios", "\\x1b[", "\\x1b]" }, 470 // The termios CALLS as well as the header's name: a module can ask
471 // the OS about a terminal without ever spelling `termios`, and
472 // `isatty` is how it starts. Matched against a lower-cased line, so
473 // `\X1B[` is the same needle as `\x1b[`.
474 .needles = &.{ "termios", "isatty", "tcgetattr", "tcsetattr", "\\x1b[", "\\x1b]", "\\u{1b}" },
470 .why = "driving a terminal is src/tui/'s job, and this module must " ++ 475 .why = "driving a terminal is src/tui/'s job, and this module must " ++
471 "link into an app that paints its own way", 476 "link into an app that paints its own way",
472 }, 477 },
473 .{ 478 .{
474 .rule = "5", 479 .rule = "5",
475 .folders = &.{ "src/client", "src/tui", "src/server", "src/cli" }, 480 // Every folder, `src/` root and `src/engine/` included: a shell
481 // spelled by a leaf utility runs exactly as well as one spelled by
482 // the daemon, and a rule with a hole in it is a rule that reports
483 // green about the place nobody looked.
484 .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli" },
476 .needles = &.{ "\"/bin/sh\"", "\"-c\"" }, 485 .needles = &.{ "\"/bin/sh\"", "\"-c\"" },
477 .why = "the only program mux runs is one the user named — the " ++ 486 .why = "the only program mux runs is one the user named — the " ++
478 "session shell, `ssh` from the handoff recipe, or `--via`'s own " ++ 487 "session shell, `ssh` from the handoff recipe, or `--via`'s own " ++
@@ -505,8 +514,13 @@ fn checkSourceBan(b: *std.Build, ban: SourceBan) void {
505 in_test = true; 514 in_test = true;
506 continue; 515 continue;
507 } 516 }
517 // Lower-cased once per line, because the bytes a rule bans
518 // have more than one spelling: `\X1B[` is the escape the
519 // needle names, and case is the only thing between them.
520 const lower = std.ascii.allocLowerString(b.allocator, line) catch @panic("OOM");
521 defer b.allocator.free(lower);
508 for (ban.needles) |n| { 522 for (ban.needles) |n| {
509 if (std.mem.indexOf(u8, line, n) != null) fatal( 523 if (std.mem.indexOf(u8, lower, n) != null) fatal(
510 "folder rule {s} broken: {s}:{d} spells `{s}` outside a test " ++ 524 "folder rule {s} broken: {s}:{d} spells `{s}` outside a test " ++
511 "block — {s}. Move it, or write one line `// folder rule " ++ 525 "block — {s}. Move it, or write one line `// folder rule " ++
512 "{s} exemption: <why>` in the file", 526 "{s} exemption: <why>` in the file",
src/cli/spawn.zig
Old New
@@ -0,0 +1,578 @@
1 //! Get a daemon onto a socket path: probe, spawn detached, poll until it
2 //! answers. `mux d start` is explicit and spelled out; the LOCAL client's
3 //! own entry (`mux` with no host) is the one attach that may still start
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.
6 //!
7 //! What it spawns is this image — `selfExe` below, the /proc link read
8 //! through to the file it names — and never a name resolved against PATH.
9 //! That is the end of the ambient-PATH trap: an `execvp("muxd")` grades
10 //! whatever release is installed, and did (an e2e leg whose daemon had died
11 //! ran a v0.0.1-10 with no agent code in it and passed).
12 const std = @import("std");
13 const xdg = @import("xdg");
14
15 pub const EnsureError = error{ SpawnFailed, NeverAnswered };
16
17 /// The kernel's link to the running image. Only the fallback: a spawn is
18 /// worth more than the name it will wear, so a readlink that fails still
19 /// starts a daemon.
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 }
33 pub const Ensured = enum { already_running, started };
34
35 /// How long a spawn gets to answer, for every caller. One number because a
36 /// user who waited two seconds for `muxd start` must not wait a different
37 /// two seconds for an attach that starts the same daemon the same way: the
38 /// deadline describes how long muxd takes to bind, which is not a fact
39 /// about which verb asked for it.
40 pub const start_deadline_ms: u32 = 2000;
41
42 /// Where a spawned daemon's stdout+stderr go, and what becomes of whatever
43 /// is already there. `truncate` has no default on purpose — the callers
44 /// want opposite answers, so each one has to say which it means.
45 pub const LogSpec = struct {
46 /// null means the xdg default, which is what every real caller wants.
47 /// It is settable at all so tests can point it somewhere disposable:
48 /// the default path belongs to whatever daemon is running on the
49 /// machine, and writing over it from a unit test would punch a hole in
50 /// a live daemon's log.
51 path: ?[]const u8 = null,
52 /// True only for `muxd start`, the one caller whose user asked for a
53 /// (re)start and is owed a log about the daemon they just started
54 /// rather than a stale one. Auto-start appends instead: an attach is
55 /// not a restart, and one `mux` over ssh must not zero the log of an
56 /// interactive daemon that is still writing into it.
57 truncate: bool,
58 };
59
60 /// Test hook: the pid of the most recent spawn; this file's tests are its
61 /// only readers, reaping the deliberately-orphaned stub by it. Not
62 /// synchronized — every caller is single-threaded.
63 pub var last_spawned_pid: std.posix.pid_t = 0;
64
65 /// All of ensureDaemon's stderr output belongs to this struct: the caller
66 /// decides the prefix (the verb the user typed) and whether dots animate.
67 /// Silence on the already-running path is part of the contract — any
68 /// output at all means something unusual happened.
69 pub const Progress = struct {
70 fd: std.posix.fd_t,
71 prefix: []const u8,
72 tty: bool,
73
74 fn emit(self: Progress, s: []const u8) void {
75 _ = std.posix.write(self.fd, s) catch {};
76 }
77
78 fn emitFmt(self: Progress, comptime fmt: []const u8, args: anytype) void {
79 var buf: [256]u8 = undefined;
80 const s = std.fmt.bufPrint(&buf, fmt, args) catch return;
81 self.emit(s);
82 }
83 };
84
85 /// `NeverAnswered` does not kill the spawned pid: a daemon up at 2.5s is
86 /// there for the retry. Racers sort themselves out: the loser exits on
87 /// DaemonAlreadyRunning.
88 pub fn ensureDaemon(
89 alloc: std.mem.Allocator,
90 exe_path: []const u8,
91 run_args: []const [:0]const u8,
92 sock_path: []const u8,
93 progress: Progress,
94 deadline_ms: u32,
95 log_spec: LogSpec,
96 ) EnsureError!Ensured {
97 if (probe(sock_path)) return .already_running;
98
99 std.posix.access(exe_path, std.posix.X_OK) catch return error.SpawnFailed;
100
101 // Owned either way, so one `free` covers both: a caller-supplied path is
102 // duped rather than borrowed, because the xdg branch must allocate.
103 const log_path = if (log_spec.path) |p|
104 alloc.dupe(u8, p) catch return error.SpawnFailed
105 else
106 xdg.logPath(alloc) catch return error.SpawnFailed;
107 defer alloc.free(log_path);
108 if (std.fs.path.dirname(log_path)) |dir|
109 std.fs.cwd().makePath(dir) catch return error.SpawnFailed;
110 // Opened by hand rather than through createFile because the non-
111 // truncating case needs O_APPEND specifically, which createFile cannot
112 // ask for. "Do not truncate" alone would be worse than truncating: the
113 // child's fd would start at offset zero and overwrite the log from the
114 // front, and two daemons sharing the path would overwrite each other.
115 // The kernel's atomic seek-to-end is the whole of what makes appending
116 // safe for a file someone else may be writing.
117 const log: std.fs.File = .{
118 .handle = std.posix.open(log_path, .{
119 .ACCMODE = .WRONLY,
120 .CREAT = true,
121 .TRUNC = log_spec.truncate,
122 .APPEND = !log_spec.truncate,
123 // createFile set this for free and posix.O does not, which is
124 // exactly how hand-rolling the open lost it. Without it the
125 // original fd survives the exec below into the long-lived daemon
126 // and rides a second exec into the user's shell; fds 1 and 2 are
127 // safe only because dup2 clears FD_CLOEXEC on its targets.
128 .CLOEXEC = true,
129 }, 0o600) catch return error.SpawnFailed,
130 };
131 defer log.close();
132 const devnull = std.fs.cwd().openFile("/dev/null", .{}) catch return error.SpawnFailed;
133 defer devnull.close();
134
135 // argv for the child: mux d run <forwarded...>, all null-terminated.
136 // The mode word is spelled out so `ps` shows a daemon as a daemon —
137 // it is the only thing separating the long-lived process from the
138 // `mux d start` that spawned it.
139 const exe_z = alloc.dupeZ(u8, exe_path) catch return error.SpawnFailed;
140 defer alloc.free(exe_z);
141 const argv = alloc.allocSentinel(?[*:0]const u8, run_args.len + 3, null) catch
142 return error.SpawnFailed;
143 defer alloc.free(argv);
144 argv[0] = "mux";
145 argv[1] = "d";
146 argv[2] = "run";
147 for (run_args, 0..) |a, i| argv[i + 3] = a.ptr;
148
149 progress.emitFmt("{s}: starting\u{2026}", .{progress.prefix});
150 if (!progress.tty) progress.emit("\n");
151
152 const t0 = std.time.milliTimestamp();
153 const pid = std.posix.fork() catch {
154 if (progress.tty) progress.emit("\n");
155 return error.SpawnFailed;
156 };
157 if (pid == 0) {
158 // Child: its own session, no controlling terminal, stdio detached.
159 // Nothing here may allocate or return — only exec or _exit.
160 //
161 // exit_group, never std.posix.exit: we link libc, so the latter is
162 // exit(3), which runs atexit handlers (Zig's runtime, wolfSSL's)
163 // and flushes stdio buffers — buffers this process inherited from
164 // the PARENT at fork, so the parent's pending output would be
165 // written a second time by its own child. exit_group is the raw
166 // syscall and skips all of it. Only reachable if dup2 or exec
167 // fails, which is exactly when the least machinery should run.
168 _ = std.os.linux.setsid();
169 std.posix.dup2(devnull.handle, std.posix.STDIN_FILENO) catch
170 std.os.linux.exit_group(127);
171 std.posix.dup2(log.handle, std.posix.STDOUT_FILENO) catch
172 std.os.linux.exit_group(127);
173 std.posix.dup2(log.handle, std.posix.STDERR_FILENO) catch
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 //
183 // execveZ's return type IS an error set — there is no success value,
184 // because success does not return. 127 is the shell's "cannot exec",
185 // and the parent learns the same thing either way: the socket never
186 // answers, and the log names what happened.
187 switch (std.posix.execveZ(exe_z.ptr, argv.ptr, std.c.environ)) {
188 else => std.os.linux.exit_group(127),
189 }
190 }
191 last_spawned_pid = pid;
192
193 // Parent: poll. Dots only on a tty so scripted output stays pinnable.
194 var next_dot: i64 = t0 + 250;
195 // A pid owes us exactly one reap. Calling waitpid again after it has
196 // been reaped gets ECHILD, which std.posix.waitpid answers with
197 // `unreachable` — so the second call is not an error to handle but a
198 // panic, and the panic lands precisely on the path that exists to
199 // report a child that died young (a missing key file, a bad bind
200 // address). Tracking the reap is what keeps that path a message.
201 var reaped = false;
202 while (true) {
203 if (probe(sock_path)) {
204 const secs = @as(f64, @floatFromInt(std.time.milliTimestamp() - t0)) / 1000.0;
205 // The leading space exists to follow the dots on a tty. There
206 // are no dots on a non-tty — the ssh proxy's case, and now the
207 // common one — where it would only be a stray space at the
208 // start of a scripted line.
209 if (progress.tty) progress.emit(" ");
210 progress.emitFmt("up ({d:.1}s) pid={d}\n", .{ secs, pid });
211 return .started;
212 }
213 const now = std.time.milliTimestamp();
214 if (now - t0 >= deadline_ms) {
215 // The newline terminates the dot line, so it belongs to the
216 // same condition the dots do: on a non-tty there are no dots
217 // and it would only put a blank line into scripted output.
218 if (progress.tty) progress.emit("\n");
219 // "daemon", not "muxd", in the body: the prefix is the program
220 // speaking, and the same string serves the mux-side callers —
221 // `mux: daemon did not answer` reads correctly, `mux: muxd did
222 // not answer` would not.
223 progress.emitFmt(
224 "{s}: daemon did not answer within {d}s \u{2014} log: {s}\n",
225 .{ progress.prefix, deadline_ms / 1000, log_path },
226 );
227 return error.NeverAnswered;
228 }
229 if (progress.tty and now >= next_dot) {
230 progress.emit(".");
231 next_dot = now + 250;
232 }
233 // Reap if the child exited (loser of a start race, or a refused
234 // flag): its socket-owner sibling answers the next probe either
235 // way, and an unreaped child would sit as a zombie until we exit.
236 // Once is enough, and once is all that is safe — see `reaped`.
237 if (!reaped and std.posix.waitpid(pid, std.posix.W.NOHANG).pid == pid)
238 reaped = true;
239 std.Thread.sleep(50 * std.time.ns_per_ms);
240 }
241 }
242
243 /// False covers both "no daemon" and "a stale socket file": nothing
244 /// answered either way, and the caller's next move is the same.
245 pub fn probe(sock_path: []const u8) bool {
246 const s = std.net.connectUnixSocket(sock_path) catch return false;
247 s.close();
248 return true;
249 }
250
251 /// The local entry's auto-start alone, naming no binary: there is one,
252 /// and it is this.
253 pub fn ensureForAttach(
254 alloc: std.mem.Allocator,
255 sock_path: []const u8,
256 prefix: []const u8,
257 ) error{OutOfMemory}!bool {
258 const sock_z = try alloc.dupeZ(u8, sock_path);
259 defer alloc.free(sock_z);
260 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
261 const exe = selfExe(&exe_buf);
262 // Bare, and the reason is the whole of why this is not `start`: a QUIC
263 // listener must be asked for, never appear because someone attached.
264 const run_args = [_][:0]const u8{ "--sock", sock_z };
265 const progress: Progress = .{
266 .fd = std.posix.STDERR_FILENO,
267 .prefix = prefix,
268 .tty = std.posix.isatty(std.posix.STDERR_FILENO),
269 };
270 _ = ensureDaemon(
271 alloc,
272 exe,
273 &run_args,
274 sock_path,
275 progress,
276 start_deadline_ms,
277 .{ .truncate = false },
278 ) catch |err| switch (err) {
279 // The failure line, with the log path, was already printed by
280 // Progress — a second line would say the same thing worse.
281 error.NeverAnswered => return false,
282 error.SpawnFailed => {
283 // std.debug.print rather than progress.emitFmt: an exe path can
284 // run to max_path_bytes, and emitFmt's fixed buffer would drop
285 // the whole line rather than shorten it. The RESOLVED path, so
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) });
288 return false;
289 },
290 };
291 return true;
292 }
293
294 // ---------------------------------------------------------------------------
295
296 const testtmp = @import("testtmp");
297
298 fn silentProgress() Progress {
299 // Progress that writes to /dev/null keeps test output clean while the
300 // pinned-output cases below capture a pipe instead.
301 const f = std.fs.cwd().openFile("/dev/null", .{ .mode = .write_only }) catch unreachable;
302 return .{ .fd = f.handle, .prefix = "test", .tty = false };
303 }
304
305 test "ensureDaemon: an answering socket is already_running, nothing spawned" {
306 var tmp = try testtmp.TmpDir.make();
307 defer tmp.cleanup();
308 var buf: [128]u8 = undefined;
309 const sock = try std.fmt.bufPrint(&buf, "{s}/live.sock", .{tmp.path()});
310
311 const addr = try std.net.Address.initUnix(sock);
312 var server = try addr.listen(.{});
313 defer server.deinit();
314
315 // Progress captured through a pipe: already_running must print NOTHING.
316 const pipe = try std.posix.pipe();
317 defer std.posix.close(pipe[0]);
318 const progress: Progress = .{ .fd = pipe[1], .prefix = "test", .tty = false };
319
320 const r = try ensureDaemon(
321 std.testing.allocator,
322 "/definitely/not/consulted",
323 &.{},
324 sock,
325 progress,
326 200,
327 .{ .truncate = true },
328 );
329 try std.testing.expectEqual(Ensured.already_running, r);
330
331 std.posix.close(pipe[1]);
332 var out: [64]u8 = undefined;
333 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &out));
334 }
335
336 test "ensureDaemon: a missing binary is SpawnFailed before any fork" {
337 var tmp = try testtmp.TmpDir.make();
338 defer tmp.cleanup();
339 var buf: [128]u8 = undefined;
340 const sock = try std.fmt.bufPrint(&buf, "{s}/none.sock", .{tmp.path()});
341 try std.testing.expectError(error.SpawnFailed, ensureDaemon(
342 std.testing.allocator,
343 "/no/such/muxd",
344 &.{},
345 sock,
346 silentProgress(),
347 200,
348 .{ .truncate = true },
349 ));
350 }
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
362 test "ensureDaemon: a child that dies young is reported, not panicked on" {
363 var tmp = try testtmp.TmpDir.make();
364 defer tmp.cleanup();
365 var pbuf: [128]u8 = undefined;
366 var sbuf: [128]u8 = undefined;
367 var lbuf: [128]u8 = undefined;
368 const stub = try std.fmt.bufPrint(&pbuf, "{s}/dies.sh", .{tmp.path()});
369 const sock = try std.fmt.bufPrint(&sbuf, "{s}/dead.sock", .{tmp.path()});
370 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()});
371
372 // Exits at once, binding nothing — a daemon refusing a flag, or one
373 // whose key file is missing. The poll loop therefore reaps it on an
374 // early pass and keeps polling to the deadline, which is where a
375 // second waitpid would get ECHILD and panic.
376 try tmp.dir.writeFile(.{ .sub_path = "dies.sh", .data = "#!/bin/sh\nexit 3\n" });
377 const f = try tmp.dir.openFile("dies.sh", .{});
378 try f.chmod(0o755);
379 f.close();
380
381 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
382 std.testing.allocator,
383 stub,
384 &.{},
385 sock,
386 silentProgress(),
387 300,
388 .{ .path = log, .truncate = true },
389 ));
390 // The log is still there to be named by the failure line: a child that
391 // died is exactly when an operator goes looking for it.
392 const log_st = try std.fs.cwd().statFile(log);
393 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(log_st.mode & 0o777)));
394 }
395
396 test "ensureDaemon: a binary that never binds is NeverAnswered, pid left alive" {
397 var tmp = try testtmp.TmpDir.make();
398 defer tmp.cleanup();
399 var pbuf: [128]u8 = undefined;
400 var sbuf: [128]u8 = undefined;
401 var lbuf: [128]u8 = undefined;
402 const stub = try std.fmt.bufPrint(&pbuf, "{s}/stub.sh", .{tmp.path()});
403 const sock = try std.fmt.bufPrint(&sbuf, "{s}/never.sock", .{tmp.path()});
404 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()});
405
406 // A stand-in daemon that stays alive and binds nothing. `exec` so the
407 // pid ensureDaemon tracked IS the sleeper, not a parent shell of it.
408 try tmp.dir.writeFile(.{ .sub_path = "stub.sh", .data = "#!/bin/sh\nexec sleep 30\n" });
409 const f = try tmp.dir.openFile("stub.sh", .{});
410 try f.chmod(0o755);
411 f.close();
412
413 // The log goes somewhere disposable. xdg.logPath reads XDG_STATE_HOME
414 // at call time and Zig tests cannot setenv, so leaving this null would
415 // truncate the real `~/.local/state/mux/muxd.log` — which, once `muxd
416 // start` exists, is a LIVE daemon's log being zeroed by `make test`.
417 // The nested `logs/` component also proves the parent directory is
418 // created rather than assumed.
419 const t0 = std.time.milliTimestamp();
420 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
421 std.testing.allocator,
422 stub,
423 &.{},
424 sock,
425 silentProgress(),
426 300,
427 .{ .path = log, .truncate = true },
428 ));
429 // The log is created before the fork, so it exists even when the child
430 // never writes to it — that is what makes it the place to look when a
431 // spawn fails.
432 const log_st = try std.fs.cwd().statFile(log);
433 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(log_st.mode & 0o777)));
434 // It waited the deadline out rather than bailing early...
435 try std.testing.expect(std.time.milliTimestamp() - t0 >= 300);
436
437 // ...and did NOT kill the spawned process. `last_spawned_pid` is how
438 // the test learns which pid that is: killing by anything else — a name
439 // match, a process sweep — could take out a bystander, so the pid the
440 // spawner tracked is the only handle allowed.
441 try std.testing.expect(last_spawned_pid != 0);
442 // waitpid with NOHANG returning pid 0 means "child exists, still
443 // running", which is the assertion; a reaped or dead child returns its
444 // own pid instead.
445 try std.testing.expectEqual(
446 @as(std.posix.pid_t, 0),
447 std.posix.waitpid(last_spawned_pid, std.posix.W.NOHANG).pid,
448 );
449 std.posix.kill(last_spawned_pid, std.posix.SIG.KILL) catch {};
450 _ = std.posix.waitpid(last_spawned_pid, 0);
451 }
452
453 test "ensureDaemon: the child execs the path it was HANDED, never a name off PATH" {
454 var tmp = try testtmp.TmpDir.make();
455 defer tmp.cleanup();
456 var pbuf: [160]u8 = undefined;
457 var sbuf: [160]u8 = undefined;
458 var lbuf: [160]u8 = undefined;
459 var rbuf: [160]u8 = undefined;
460 const stub = try std.fmt.bufPrint(&pbuf, "{s}/stub.sh", .{tmp.path()});
461 const sock = try std.fmt.bufPrint(&sbuf, "{s}/self.sock", .{tmp.path()});
462 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/mux.log", .{tmp.path()});
463 const seen = try std.fmt.bufPrint(&rbuf, "{s}/child.exe", .{tmp.path()});
464
465 // `$0` is the kernel's answer to "which file did you exec": for a
466 // shebang script it is the script, whatever argv[0] the caller wrote.
467 // Nothing on this box resolves by NAME to a file in a fresh tmp dir, so
468 // a spawn that searched PATH cannot pass this. `$*` pins the other half
469 // — the daemon is asked for `d run`, which is also what makes a daemon
470 // legible in `ps`. Production hands `selfExe` and nothing else, so the
471 // same mechanism cannot reach a sibling: `mux` used to hunt PATH for a
472 // `muxd`, and an e2e leg graded an installed v0.0.1-10 that way.
473 var script: [512]u8 = undefined;
474 try tmp.dir.writeFile(.{
475 .sub_path = "stub.sh",
476 .data = try std.fmt.bufPrint(&script,
477 \\#!/bin/sh
478 \\printf '%s|%s' "$0" "$*" > "{s}"
479 \\exec sleep 30
480 \\
481 , .{seen}),
482 });
483 const f = try tmp.dir.openFile("stub.sh", .{});
484 try f.chmod(0o755);
485 f.close();
486
487 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
488 std.testing.allocator,
489 stub,
490 &.{},
491 sock,
492 silentProgress(),
493 400,
494 .{ .path = log, .truncate = true },
495 ));
496 defer {
497 std.posix.kill(last_spawned_pid, std.posix.SIG.KILL) catch {};
498 _ = std.posix.waitpid(last_spawned_pid, 0);
499 }
500
501 var got_buf: [std.fs.max_path_bytes]u8 = undefined;
502 // Read through the failure rather than around it: a child that never
503 // ran the stub at all must fail as a MISMATCH naming what it became,
504 // not as a FileNotFound three frames inside std.
505 const got = std.fs.cwd().readFile(seen, &got_buf) catch "<the child ran something else>";
506 var want_buf: [200]u8 = undefined;
507 const want = try std.fmt.bufPrint(&want_buf, "{s}|d run", .{stub});
508 try std.testing.expectEqualStrings(want, std.mem.trimRight(u8, got, "\n"));
509 }
510
511 test "ensureDaemon: an attach appends to the log, `muxd start` truncates it" {
512 var tmp = try testtmp.TmpDir.make();
513 defer tmp.cleanup();
514 var pbuf: [128]u8 = undefined;
515 var sbuf: [128]u8 = undefined;
516 var lbuf: [128]u8 = undefined;
517 const stub = try std.fmt.bufPrint(&pbuf, "{s}/dies.sh", .{tmp.path()});
518 const sock = try std.fmt.bufPrint(&sbuf, "{s}/dead.sock", .{tmp.path()});
519 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()});
520
521 // Exits at once and binds nothing, so both spawns below end in
522 // NeverAnswered — which is beside the point. The log is opened BEFORE
523 // the fork, so what happened to the bytes already in it is settled
524 // whether the daemon ever comes up or not, and a stub that writes
525 // nothing keeps the two file contents readable as pure evidence of the
526 // open mode.
527 try tmp.dir.writeFile(.{ .sub_path = "dies.sh", .data = "#!/bin/sh\nexit 3\n" });
528 const f = try tmp.dir.openFile("dies.sh", .{});
529 try f.chmod(0o755);
530 f.close();
531
532 const seed = "a live daemon was writing here\n";
533 try std.fs.cwd().makePath(std.fs.path.dirname(log).?);
534 try std.fs.cwd().writeFile(.{ .sub_path = log, .data = seed });
535
536 // The attach shape must leave it alone. Without this, one `mux --sock`
537 // naming a path nobody happens to be serving zeroes the log of the
538 // interactive daemon still writing to it, and the operator reads a
539 // hole where the crash was.
540 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
541 std.testing.allocator,
542 stub,
543 &.{},
544 sock,
545 silentProgress(),
546 100,
547 .{ .path = log, .truncate = false },
548 ));
549 var rbuf: [256]u8 = undefined;
550 // Equality, not "contains": appending must add at the END, so a mode
551 // that wrote over the front and happened to be shorter still fails.
552 try std.testing.expectEqualStrings(seed, try std.fs.cwd().readFile(log, &rbuf));
553
554 // `muxd start` still truncates, and that half is asserted here rather
555 // than assumed: the two verbs differ only in this flag, so a change that
556 // made everything append would otherwise pass unnoticed until an
557 // operator read a restarted daemon's log and found the old one's.
558 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
559 std.testing.allocator,
560 stub,
561 &.{},
562 sock,
563 silentProgress(),
564 100,
565 .{ .path = log, .truncate = true },
566 ));
567 try std.testing.expectEqual(
568 @as(usize, 0),
569 (try std.fs.cwd().readFile(log, &rbuf)).len,
570 );
571 }
572
573 // Forces semantic analysis of every pub decl under `zig build test`, so an
574 // unreferenced decl must at least compile (the silent-module-loss hazard,
575 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
576 test {
577 std.testing.refAllDeclsRecursive(@This());
578 }
src/client/spawn.zig
Old New
@@ -1,578 +0,0 @@
1 //! Get a daemon onto a socket path: probe, spawn detached, poll until it
2 //! answers. `mux d start` is explicit and spelled out; the LOCAL client's
3 //! own entry (`mux` with no host) is the one attach that may still start
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.
6 //!
7 //! What it spawns is this image — `selfExe` below, the /proc link read
8 //! through to the file it names — and never a name resolved against PATH.
9 //! That is the end of the ambient-PATH trap: an `execvp("muxd")` grades
10 //! whatever release is installed, and did (an e2e leg whose daemon had died
11 //! ran a v0.0.1-10 with no agent code in it and passed).
12 const std = @import("std");
13 const xdg = @import("xdg");
14
15 pub const EnsureError = error{ SpawnFailed, NeverAnswered };
16
17 /// The kernel's link to the running image. Only the fallback: a spawn is
18 /// worth more than the name it will wear, so a readlink that fails still
19 /// starts a daemon.
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 }
33 pub const Ensured = enum { already_running, started };
34
35 /// How long a spawn gets to answer, for every caller. One number because a
36 /// user who waited two seconds for `muxd start` must not wait a different
37 /// two seconds for an attach that starts the same daemon the same way: the
38 /// deadline describes how long muxd takes to bind, which is not a fact
39 /// about which verb asked for it.
40 pub const start_deadline_ms: u32 = 2000;
41
42 /// Where a spawned daemon's stdout+stderr go, and what becomes of whatever
43 /// is already there. `truncate` has no default on purpose — the callers
44 /// want opposite answers, so each one has to say which it means.
45 pub const LogSpec = struct {
46 /// null means the xdg default, which is what every real caller wants.
47 /// It is settable at all so tests can point it somewhere disposable:
48 /// the default path belongs to whatever daemon is running on the
49 /// machine, and writing over it from a unit test would punch a hole in
50 /// a live daemon's log.
51 path: ?[]const u8 = null,
52 /// True only for `muxd start`, the one caller whose user asked for a
53 /// (re)start and is owed a log about the daemon they just started
54 /// rather than a stale one. Auto-start appends instead: an attach is
55 /// not a restart, and one `mux` over ssh must not zero the log of an
56 /// interactive daemon that is still writing into it.
57 truncate: bool,
58 };
59
60 /// Test hook: the pid of the most recent spawn; this file's tests are its
61 /// only readers, reaping the deliberately-orphaned stub by it. Not
62 /// synchronized — every caller is single-threaded.
63 pub var last_spawned_pid: std.posix.pid_t = 0;
64
65 /// All of ensureDaemon's stderr output belongs to this struct: the caller
66 /// decides the prefix (the verb the user typed) and whether dots animate.
67 /// Silence on the already-running path is part of the contract — any
68 /// output at all means something unusual happened.
69 pub const Progress = struct {
70 fd: std.posix.fd_t,
71 prefix: []const u8,
72 tty: bool,
73
74 fn emit(self: Progress, s: []const u8) void {
75 _ = std.posix.write(self.fd, s) catch {};
76 }
77
78 fn emitFmt(self: Progress, comptime fmt: []const u8, args: anytype) void {
79 var buf: [256]u8 = undefined;
80 const s = std.fmt.bufPrint(&buf, fmt, args) catch return;
81 self.emit(s);
82 }
83 };
84
85 /// `NeverAnswered` does not kill the spawned pid: a daemon up at 2.5s is
86 /// there for the retry. Racers sort themselves out: the loser exits on
87 /// DaemonAlreadyRunning.
88 pub fn ensureDaemon(
89 alloc: std.mem.Allocator,
90 exe_path: []const u8,
91 run_args: []const [:0]const u8,
92 sock_path: []const u8,
93 progress: Progress,
94 deadline_ms: u32,
95 log_spec: LogSpec,
96 ) EnsureError!Ensured {
97 if (probe(sock_path)) return .already_running;
98
99 std.posix.access(exe_path, std.posix.X_OK) catch return error.SpawnFailed;
100
101 // Owned either way, so one `free` covers both: a caller-supplied path is
102 // duped rather than borrowed, because the xdg branch must allocate.
103 const log_path = if (log_spec.path) |p|
104 alloc.dupe(u8, p) catch return error.SpawnFailed
105 else
106 xdg.logPath(alloc) catch return error.SpawnFailed;
107 defer alloc.free(log_path);
108 if (std.fs.path.dirname(log_path)) |dir|
109 std.fs.cwd().makePath(dir) catch return error.SpawnFailed;
110 // Opened by hand rather than through createFile because the non-
111 // truncating case needs O_APPEND specifically, which createFile cannot
112 // ask for. "Do not truncate" alone would be worse than truncating: the
113 // child's fd would start at offset zero and overwrite the log from the
114 // front, and two daemons sharing the path would overwrite each other.
115 // The kernel's atomic seek-to-end is the whole of what makes appending
116 // safe for a file someone else may be writing.
117 const log: std.fs.File = .{
118 .handle = std.posix.open(log_path, .{
119 .ACCMODE = .WRONLY,
120 .CREAT = true,
121 .TRUNC = log_spec.truncate,
122 .APPEND = !log_spec.truncate,
123 // createFile set this for free and posix.O does not, which is
124 // exactly how hand-rolling the open lost it. Without it the
125 // original fd survives the exec below into the long-lived daemon
126 // and rides a second exec into the user's shell; fds 1 and 2 are
127 // safe only because dup2 clears FD_CLOEXEC on its targets.
128 .CLOEXEC = true,
129 }, 0o600) catch return error.SpawnFailed,
130 };
131 defer log.close();
132 const devnull = std.fs.cwd().openFile("/dev/null", .{}) catch return error.SpawnFailed;
133 defer devnull.close();
134
135 // argv for the child: mux d run <forwarded...>, all null-terminated.
136 // The mode word is spelled out so `ps` shows a daemon as a daemon —
137 // it is the only thing separating the long-lived process from the
138 // `mux d start` that spawned it.
139 const exe_z = alloc.dupeZ(u8, exe_path) catch return error.SpawnFailed;
140 defer alloc.free(exe_z);
141 const argv = alloc.allocSentinel(?[*:0]const u8, run_args.len + 3, null) catch
142 return error.SpawnFailed;
143 defer alloc.free(argv);
144 argv[0] = "mux";
145 argv[1] = "d";
146 argv[2] = "run";
147 for (run_args, 0..) |a, i| argv[i + 3] = a.ptr;
148
149 progress.emitFmt("{s}: starting\u{2026}", .{progress.prefix});
150 if (!progress.tty) progress.emit("\n");
151
152 const t0 = std.time.milliTimestamp();
153 const pid = std.posix.fork() catch {
154 if (progress.tty) progress.emit("\n");
155 return error.SpawnFailed;
156 };
157 if (pid == 0) {
158 // Child: its own session, no controlling terminal, stdio detached.
159 // Nothing here may allocate or return — only exec or _exit.
160 //
161 // exit_group, never std.posix.exit: we link libc, so the latter is
162 // exit(3), which runs atexit handlers (Zig's runtime, wolfSSL's)
163 // and flushes stdio buffers — buffers this process inherited from
164 // the PARENT at fork, so the parent's pending output would be
165 // written a second time by its own child. exit_group is the raw
166 // syscall and skips all of it. Only reachable if dup2 or exec
167 // fails, which is exactly when the least machinery should run.
168 _ = std.os.linux.setsid();
169 std.posix.dup2(devnull.handle, std.posix.STDIN_FILENO) catch
170 std.os.linux.exit_group(127);
171 std.posix.dup2(log.handle, std.posix.STDOUT_FILENO) catch
172 std.os.linux.exit_group(127);
173 std.posix.dup2(log.handle, std.posix.STDERR_FILENO) catch
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 //
183 // execveZ's return type IS an error set — there is no success value,
184 // because success does not return. 127 is the shell's "cannot exec",
185 // and the parent learns the same thing either way: the socket never
186 // answers, and the log names what happened.
187 switch (std.posix.execveZ(exe_z.ptr, argv.ptr, std.c.environ)) {
188 else => std.os.linux.exit_group(127),
189 }
190 }
191 last_spawned_pid = pid;
192
193 // Parent: poll. Dots only on a tty so scripted output stays pinnable.
194 var next_dot: i64 = t0 + 250;
195 // A pid owes us exactly one reap. Calling waitpid again after it has
196 // been reaped gets ECHILD, which std.posix.waitpid answers with
197 // `unreachable` — so the second call is not an error to handle but a
198 // panic, and the panic lands precisely on the path that exists to
199 // report a child that died young (a missing key file, a bad bind
200 // address). Tracking the reap is what keeps that path a message.
201 var reaped = false;
202 while (true) {
203 if (probe(sock_path)) {
204 const secs = @as(f64, @floatFromInt(std.time.milliTimestamp() - t0)) / 1000.0;
205 // The leading space exists to follow the dots on a tty. There
206 // are no dots on a non-tty — the ssh proxy's case, and now the
207 // common one — where it would only be a stray space at the
208 // start of a scripted line.
209 if (progress.tty) progress.emit(" ");
210 progress.emitFmt("up ({d:.1}s) pid={d}\n", .{ secs, pid });
211 return .started;
212 }
213 const now = std.time.milliTimestamp();
214 if (now - t0 >= deadline_ms) {
215 // The newline terminates the dot line, so it belongs to the
216 // same condition the dots do: on a non-tty there are no dots
217 // and it would only put a blank line into scripted output.
218 if (progress.tty) progress.emit("\n");
219 // "daemon", not "muxd", in the body: the prefix is the program
220 // speaking, and the same string serves the mux-side callers —
221 // `mux: daemon did not answer` reads correctly, `mux: muxd did
222 // not answer` would not.
223 progress.emitFmt(
224 "{s}: daemon did not answer within {d}s \u{2014} log: {s}\n",
225 .{ progress.prefix, deadline_ms / 1000, log_path },
226 );
227 return error.NeverAnswered;
228 }
229 if (progress.tty and now >= next_dot) {
230 progress.emit(".");
231 next_dot = now + 250;
232 }
233 // Reap if the child exited (loser of a start race, or a refused
234 // flag): its socket-owner sibling answers the next probe either
235 // way, and an unreaped child would sit as a zombie until we exit.
236 // Once is enough, and once is all that is safe — see `reaped`.
237 if (!reaped and std.posix.waitpid(pid, std.posix.W.NOHANG).pid == pid)
238 reaped = true;
239 std.Thread.sleep(50 * std.time.ns_per_ms);
240 }
241 }
242
243 /// False covers both "no daemon" and "a stale socket file": nothing
244 /// answered either way, and the caller's next move is the same.
245 pub fn probe(sock_path: []const u8) bool {
246 const s = std.net.connectUnixSocket(sock_path) catch return false;
247 s.close();
248 return true;
249 }
250
251 /// The local entry's auto-start alone, naming no binary: there is one,
252 /// and it is this.
253 pub fn ensureForAttach(
254 alloc: std.mem.Allocator,
255 sock_path: []const u8,
256 prefix: []const u8,
257 ) error{OutOfMemory}!bool {
258 const sock_z = try alloc.dupeZ(u8, sock_path);
259 defer alloc.free(sock_z);
260 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
261 const exe = selfExe(&exe_buf);
262 // Bare, and the reason is the whole of why this is not `start`: a QUIC
263 // listener must be asked for, never appear because someone attached.
264 const run_args = [_][:0]const u8{ "--sock", sock_z };
265 const progress: Progress = .{
266 .fd = std.posix.STDERR_FILENO,
267 .prefix = prefix,
268 .tty = std.posix.isatty(std.posix.STDERR_FILENO),
269 };
270 _ = ensureDaemon(
271 alloc,
272 exe,
273 &run_args,
274 sock_path,
275 progress,
276 start_deadline_ms,
277 .{ .truncate = false },
278 ) catch |err| switch (err) {
279 // The failure line, with the log path, was already printed by
280 // Progress — a second line would say the same thing worse.
281 error.NeverAnswered => return false,
282 error.SpawnFailed => {
283 // std.debug.print rather than progress.emitFmt: an exe path can
284 // run to max_path_bytes, and emitFmt's fixed buffer would drop
285 // the whole line rather than shorten it. The RESOLVED path, so
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) });
288 return false;
289 },
290 };
291 return true;
292 }
293
294 // ---------------------------------------------------------------------------
295
296 const testtmp = @import("testtmp");
297
298 fn silentProgress() Progress {
299 // Progress that writes to /dev/null keeps test output clean while the
300 // pinned-output cases below capture a pipe instead.
301 const f = std.fs.cwd().openFile("/dev/null", .{ .mode = .write_only }) catch unreachable;
302 return .{ .fd = f.handle, .prefix = "test", .tty = false };
303 }
304
305 test "ensureDaemon: an answering socket is already_running, nothing spawned" {
306 var tmp = try testtmp.TmpDir.make();
307 defer tmp.cleanup();
308 var buf: [128]u8 = undefined;
309 const sock = try std.fmt.bufPrint(&buf, "{s}/live.sock", .{tmp.path()});
310
311 const addr = try std.net.Address.initUnix(sock);
312 var server = try addr.listen(.{});
313 defer server.deinit();
314
315 // Progress captured through a pipe: already_running must print NOTHING.
316 const pipe = try std.posix.pipe();
317 defer std.posix.close(pipe[0]);
318 const progress: Progress = .{ .fd = pipe[1], .prefix = "test", .tty = false };
319
320 const r = try ensureDaemon(
321 std.testing.allocator,
322 "/definitely/not/consulted",
323 &.{},
324 sock,
325 progress,
326 200,
327 .{ .truncate = true },
328 );
329 try std.testing.expectEqual(Ensured.already_running, r);
330
331 std.posix.close(pipe[1]);
332 var out: [64]u8 = undefined;
333 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &out));
334 }
335
336 test "ensureDaemon: a missing binary is SpawnFailed before any fork" {
337 var tmp = try testtmp.TmpDir.make();
338 defer tmp.cleanup();
339 var buf: [128]u8 = undefined;
340 const sock = try std.fmt.bufPrint(&buf, "{s}/none.sock", .{tmp.path()});
341 try std.testing.expectError(error.SpawnFailed, ensureDaemon(
342 std.testing.allocator,
343 "/no/such/muxd",
344 &.{},
345 sock,
346 silentProgress(),
347 200,
348 .{ .truncate = true },
349 ));
350 }
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
362 test "ensureDaemon: a child that dies young is reported, not panicked on" {
363 var tmp = try testtmp.TmpDir.make();
364 defer tmp.cleanup();
365 var pbuf: [128]u8 = undefined;
366 var sbuf: [128]u8 = undefined;
367 var lbuf: [128]u8 = undefined;
368 const stub = try std.fmt.bufPrint(&pbuf, "{s}/dies.sh", .{tmp.path()});
369 const sock = try std.fmt.bufPrint(&sbuf, "{s}/dead.sock", .{tmp.path()});
370 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()});
371
372 // Exits at once, binding nothing — a daemon refusing a flag, or one
373 // whose key file is missing. The poll loop therefore reaps it on an
374 // early pass and keeps polling to the deadline, which is where a
375 // second waitpid would get ECHILD and panic.
376 try tmp.dir.writeFile(.{ .sub_path = "dies.sh", .data = "#!/bin/sh\nexit 3\n" });
377 const f = try tmp.dir.openFile("dies.sh", .{});
378 try f.chmod(0o755);
379 f.close();
380
381 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
382 std.testing.allocator,
383 stub,
384 &.{},
385 sock,
386 silentProgress(),
387 300,
388 .{ .path = log, .truncate = true },
389 ));
390 // The log is still there to be named by the failure line: a child that
391 // died is exactly when an operator goes looking for it.
392 const log_st = try std.fs.cwd().statFile(log);
393 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(log_st.mode & 0o777)));
394 }
395
396 test "ensureDaemon: a binary that never binds is NeverAnswered, pid left alive" {
397 var tmp = try testtmp.TmpDir.make();
398 defer tmp.cleanup();
399 var pbuf: [128]u8 = undefined;
400 var sbuf: [128]u8 = undefined;
401 var lbuf: [128]u8 = undefined;
402 const stub = try std.fmt.bufPrint(&pbuf, "{s}/stub.sh", .{tmp.path()});
403 const sock = try std.fmt.bufPrint(&sbuf, "{s}/never.sock", .{tmp.path()});
404 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()});
405
406 // A stand-in daemon that stays alive and binds nothing. `exec` so the
407 // pid ensureDaemon tracked IS the sleeper, not a parent shell of it.
408 try tmp.dir.writeFile(.{ .sub_path = "stub.sh", .data = "#!/bin/sh\nexec sleep 30\n" });
409 const f = try tmp.dir.openFile("stub.sh", .{});
410 try f.chmod(0o755);
411 f.close();
412
413 // The log goes somewhere disposable. xdg.logPath reads XDG_STATE_HOME
414 // at call time and Zig tests cannot setenv, so leaving this null would
415 // truncate the real `~/.local/state/mux/muxd.log` — which, once `muxd
416 // start` exists, is a LIVE daemon's log being zeroed by `make test`.
417 // The nested `logs/` component also proves the parent directory is
418 // created rather than assumed.
419 const t0 = std.time.milliTimestamp();
420 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
421 std.testing.allocator,
422 stub,
423 &.{},
424 sock,
425 silentProgress(),
426 300,
427 .{ .path = log, .truncate = true },
428 ));
429 // The log is created before the fork, so it exists even when the child
430 // never writes to it — that is what makes it the place to look when a
431 // spawn fails.
432 const log_st = try std.fs.cwd().statFile(log);
433 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(log_st.mode & 0o777)));
434 // It waited the deadline out rather than bailing early...
435 try std.testing.expect(std.time.milliTimestamp() - t0 >= 300);
436
437 // ...and did NOT kill the spawned process. `last_spawned_pid` is how
438 // the test learns which pid that is: killing by anything else — a name
439 // match, a process sweep — could take out a bystander, so the pid the
440 // spawner tracked is the only handle allowed.
441 try std.testing.expect(last_spawned_pid != 0);
442 // waitpid with NOHANG returning pid 0 means "child exists, still
443 // running", which is the assertion; a reaped or dead child returns its
444 // own pid instead.
445 try std.testing.expectEqual(
446 @as(std.posix.pid_t, 0),
447 std.posix.waitpid(last_spawned_pid, std.posix.W.NOHANG).pid,
448 );
449 std.posix.kill(last_spawned_pid, std.posix.SIG.KILL) catch {};
450 _ = std.posix.waitpid(last_spawned_pid, 0);
451 }
452
453 test "ensureDaemon: the child execs the path it was HANDED, never a name off PATH" {
454 var tmp = try testtmp.TmpDir.make();
455 defer tmp.cleanup();
456 var pbuf: [160]u8 = undefined;
457 var sbuf: [160]u8 = undefined;
458 var lbuf: [160]u8 = undefined;
459 var rbuf: [160]u8 = undefined;
460 const stub = try std.fmt.bufPrint(&pbuf, "{s}/stub.sh", .{tmp.path()});
461 const sock = try std.fmt.bufPrint(&sbuf, "{s}/self.sock", .{tmp.path()});
462 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/mux.log", .{tmp.path()});
463 const seen = try std.fmt.bufPrint(&rbuf, "{s}/child.exe", .{tmp.path()});
464
465 // `$0` is the kernel's answer to "which file did you exec": for a
466 // shebang script it is the script, whatever argv[0] the caller wrote.
467 // Nothing on this box resolves by NAME to a file in a fresh tmp dir, so
468 // a spawn that searched PATH cannot pass this. `$*` pins the other half
469 // — the daemon is asked for `d run`, which is also what makes a daemon
470 // legible in `ps`. Production hands `selfExe` and nothing else, so the
471 // same mechanism cannot reach a sibling: `mux` used to hunt PATH for a
472 // `muxd`, and an e2e leg graded an installed v0.0.1-10 that way.
473 var script: [512]u8 = undefined;
474 try tmp.dir.writeFile(.{
475 .sub_path = "stub.sh",
476 .data = try std.fmt.bufPrint(&script,
477 \\#!/bin/sh
478 \\printf '%s|%s' "$0" "$*" > "{s}"
479 \\exec sleep 30
480 \\
481 , .{seen}),
482 });
483 const f = try tmp.dir.openFile("stub.sh", .{});
484 try f.chmod(0o755);
485 f.close();
486
487 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
488 std.testing.allocator,
489 stub,
490 &.{},
491 sock,
492 silentProgress(),
493 400,
494 .{ .path = log, .truncate = true },
495 ));
496 defer {
497 std.posix.kill(last_spawned_pid, std.posix.SIG.KILL) catch {};
498 _ = std.posix.waitpid(last_spawned_pid, 0);
499 }
500
501 var got_buf: [std.fs.max_path_bytes]u8 = undefined;
502 // Read through the failure rather than around it: a child that never
503 // ran the stub at all must fail as a MISMATCH naming what it became,
504 // not as a FileNotFound three frames inside std.
505 const got = std.fs.cwd().readFile(seen, &got_buf) catch "<the child ran something else>";
506 var want_buf: [200]u8 = undefined;
507 const want = try std.fmt.bufPrint(&want_buf, "{s}|d run", .{stub});
508 try std.testing.expectEqualStrings(want, std.mem.trimRight(u8, got, "\n"));
509 }
510
511 test "ensureDaemon: an attach appends to the log, `muxd start` truncates it" {
512 var tmp = try testtmp.TmpDir.make();
513 defer tmp.cleanup();
514 var pbuf: [128]u8 = undefined;
515 var sbuf: [128]u8 = undefined;
516 var lbuf: [128]u8 = undefined;
517 const stub = try std.fmt.bufPrint(&pbuf, "{s}/dies.sh", .{tmp.path()});
518 const sock = try std.fmt.bufPrint(&sbuf, "{s}/dead.sock", .{tmp.path()});
519 const log = try std.fmt.bufPrint(&lbuf, "{s}/logs/muxd.log", .{tmp.path()});
520
521 // Exits at once and binds nothing, so both spawns below end in
522 // NeverAnswered — which is beside the point. The log is opened BEFORE
523 // the fork, so what happened to the bytes already in it is settled
524 // whether the daemon ever comes up or not, and a stub that writes
525 // nothing keeps the two file contents readable as pure evidence of the
526 // open mode.
527 try tmp.dir.writeFile(.{ .sub_path = "dies.sh", .data = "#!/bin/sh\nexit 3\n" });
528 const f = try tmp.dir.openFile("dies.sh", .{});
529 try f.chmod(0o755);
530 f.close();
531
532 const seed = "a live daemon was writing here\n";
533 try std.fs.cwd().makePath(std.fs.path.dirname(log).?);
534 try std.fs.cwd().writeFile(.{ .sub_path = log, .data = seed });
535
536 // The attach shape must leave it alone. Without this, one `mux --sock`
537 // naming a path nobody happens to be serving zeroes the log of the
538 // interactive daemon still writing to it, and the operator reads a
539 // hole where the crash was.
540 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
541 std.testing.allocator,
542 stub,
543 &.{},
544 sock,
545 silentProgress(),
546 100,
547 .{ .path = log, .truncate = false },
548 ));
549 var rbuf: [256]u8 = undefined;
550 // Equality, not "contains": appending must add at the END, so a mode
551 // that wrote over the front and happened to be shorter still fails.
552 try std.testing.expectEqualStrings(seed, try std.fs.cwd().readFile(log, &rbuf));
553
554 // `muxd start` still truncates, and that half is asserted here rather
555 // than assumed: the two verbs differ only in this flag, so a change that
556 // made everything append would otherwise pass unnoticed until an
557 // operator read a restarted daemon's log and found the old one's.
558 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
559 std.testing.allocator,
560 stub,
561 &.{},
562 sock,
563 silentProgress(),
564 100,
565 .{ .path = log, .truncate = true },
566 ));
567 try std.testing.expectEqual(
568 @as(usize, 0),
569 (try std.fs.cwd().readFile(log, &rbuf)).len,
570 );
571 }
572
573 // Forces semantic analysis of every pub decl under `zig build test`, so an
574 // unreferenced decl must at least compile (the silent-module-loss hazard,
575 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
576 test {
577 std.testing.refAllDeclsRecursive(@This());
578 }
src/engine/predict.zig
Old New
@@ -10,7 +10,7 @@
10 //! there, so the prediction stays pending, and only a cell that moved to 10 //! there, so the prediction stays pending, and only a cell that moved to
11 //! something neither our guess nor what was there before refutes. 11 //! something neither our guess nor what was there before refutes.
12 //! 12 //!
13 //! The tiers describe TERMIOS and invite the wrong reading: readline 13 //! The tiers describe ECHO bits and invite the wrong reading: readline
14 //! echoes itself, so a bash or zsh prompt is `.adaptive` and never 14 //! echoes itself, so a bash or zsh prompt is `.adaptive` and never
15 //! `.always`, which covers `cat`, a shell's `read`, dash. The bits move 15 //! `.always`, which covers `cat`, a shell's `read`, dash. The bits move
16 //! once or twice per command, and every move re-earns display, so the 16 //! once or twice per command, and every move re-earns display, so the