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