0c516740
Merge branch macos-port-step2: the platform layer (macOS port, step 2)
a73x 2026-09-03 15:10
Commit message
CLAUDE.md
| Old | New | ||
|---|---|---|---|
| @@ -59,7 +59,8 @@ a symbol by its FILE stem (`wall_pump.askOn`) — a file, not a module. | |||
| 59 | | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` | | 59 | | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` | |
| 60 | | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | | 60 | | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | |
| 61 | | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | | 61 | | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | |
| 62 | | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) · `spawn` | | 62 | | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) | |
| 63 | | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` · `client_os`(`client_os.zig`) — `client_os_linux` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) | | ||
| 63 | | `src/` | `xdg` `sockpath` `dial` `link` `serve` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket and `link` the live connection under it whatever reached it (fd, handoff stdio, QUIC), `serve` the right to bind a socket path and the duty to unlink it, `term` and nothing else under them | | 64 | | `src/` | `xdg` `sockpath` `dial` `link` `serve` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket and `link` the live connection under it whatever reached it (fd, handoff stdio, QUIC), `serve` the right to bind a socket path and the duty to unlink it, `term` and nothing else under them | |
| 64 | 65 | ||
| 65 | The grouping itself is a convention now, not a gate: the table wires the | 66 | The grouping itself is a convention now, not a gate: the table wires the |
| @@ -77,12 +78,19 @@ we built; its three markers are `flags.zig`, `main.zig` and | |||
| 77 | rather than a shell the product runs. Rule 6 is the one `posix.fork` site, | 78 | rather than a shell the product runs. Rule 6 is the one `posix.fork` site, |
| 78 | and it names its file in `except` rather than in the file. The three keep | 79 | and it names its file in `except` rather than in the file. The three keep |
| 79 | their numbers because the in-file `folder rule N exemption:` lines cite them | 80 | their numbers because the in-file `folder rule N exemption:` lines cite them |
| 80 | by number. Rule 4's three remaining debts are the markers in `engine.zig`, | 81 | by number. Rule 7 is the platform ban: `std.os.linux`, `/proc`, `memfd`, |
| 81 | `protocol.zig` and `keymap.zig`, each of which produces VT bytes by contract. | 82 | `close_range`, `exit_group`, the `SO_PEERCRED` socket option, the two |
| 83 | Linux-only pty ioctls and `MSG_NOSIGNAL` may appear only under `src/os/`, | ||
| 84 | comments included — | ||
| 85 | the roots `server_os.zig` and `client_os.zig` are the contract and their | ||
| 86 | `_linux` children the spellings (spec 2026-09-03). Rule 4's three remaining | ||
| 87 | debts are the markers in `engine.zig`, `protocol.zig` and `keymap.zig`, each | ||
| 88 | of which produces VT bytes by contract. | ||
| 82 | `predict.zig` sits under `src/tui/` with the rest of the wall, so the overlay | 89 | `predict.zig` sits under `src/tui/` with the rest of the wall, so the overlay |
| 83 | lives beside the code that paints it and nowhere `term` would find it. | 90 | lives beside the code that paints it and nowhere `term` would find it. |
| 84 | `spawn` lives under `src/cli/` because it asks the OS whether it has a | 91 | `spawn` lives under `src/os/` with the rest of the platform layer: asking |
| 85 | terminal, which rule 4 forbids a client module. | 92 | the OS for a terminal is platform code, and rule 4 forbids a client module |
| 93 | from doing it. | ||
| 86 | 94 | ||
| 87 | ONE binary, `mux`, and the first word picks a mode: `mux d` the daemon, | 95 | ONE binary, `mux`, and the first word picks a mode: `mux d` the daemon, |
| 88 | `mux a` the agent surface (JSON verbs), `mux web` the browser hub, and no | 96 | `mux a` the agent surface (JSON verbs), `mux web` the browser hub, and no |
| @@ -270,7 +278,7 @@ own. Test fixtures in `test/`: | |||
| 270 | directories and a wall's `mux-ask-PID.sock` are unlinked by their owner | 278 | directories and a wall's `mux-ask-PID.sock` are unlinked by their owner |
| 271 | on the normal way out; a SIGKILL or a closed terminal window runs | 279 | on the normal way out; a SIGKILL or a closed terminal window runs |
| 272 | nothing, so the next daemon or wall to create one in that directory | 280 | nothing, so the next daemon or wall to create one in that directory |
| 273 | calls `xdg.reapDeadPid` first and removes every entry whose pid `/proc` | 281 | calls `xdg.reapDeadPid` first and removes every entry whose pid the OS |
| 274 | no longer has. A live pid's entry stays even when it is no longer a mux. | 282 | no longer has. A live pid's entry stays even when it is no longer a mux. |
| 275 | - **A daemon lives until `mux d stop`; emptiness is not an exit.** `x` ends a | 283 | - **A daemon lives until `mux d stop`; emptiness is not an exit.** `x` ends a |
| 276 | session, never a box: `reap` and `pumpOnce` answer nothing, a shell's code | 284 | session, never a box: `reap` and `pumpOnce` answer nothing, a shell's code |
| @@ -297,7 +305,8 @@ own. Test fixtures in `test/`: | |||
| 297 | a daemon on someone else's box that nothing reports. | 305 | a daemon on someone else's box that nothing reports. |
| 298 | - **The daemon starts itself, and it execs THIS image.** `mux d start` IS | 306 | - **The daemon starts itself, and it execs THIS image.** `mux d start` IS |
| 299 | the daemon in the foreground; `-d` is the flag that forks one, and | 307 | the daemon in the foreground; `-d` is the flag that forks one, and |
| 300 | `main.forkDaemon` is the only `posix.fork` under `src/` — build.zig's | 308 | `server_os_linux.forkDetached` is the only `posix.fork` under `src/`, and |
| 309 | `main.forkDaemon` is its one caller — build.zig's | ||
| 301 | folder rule 6, whose `except` names the one file. Every other starter | 310 | folder rule 6, whose `except` names the one file. Every other starter |
| 302 | spells the argv and execs: `endpoint --start` calls in-process, the | 311 | spells the argv and execs: `endpoint --start` calls in-process, the |
| 303 | local client's entry runs `mux d start -d --sock PATH` with its own fd 2 | 312 | local client's entry runs `mux d start -d --sock PATH` with its own fd 2 |
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -40,8 +40,12 @@ INSTDIR ?= dist/install | |||
| 40 | # laptop, 2026-09-02). Cost, measured the same day, best-of-5: repaint | 40 | # laptop, 2026-09-02). Cost, measured the same day, best-of-5: repaint |
| 41 | # 92ms -> 114ms (musl memcpy on 15MB of paint bytes, paid once per | 41 | # 92ms -> 114ms (musl memcpy on 15MB of paint bytes, paid once per |
| 42 | # reattach); the solo and client legs moved inside their noise. | 42 | # reattach); the solo and client legs moved inside their noise. |
| 43 | # | ||
| 44 | # The target the installed and released binaries are built for. Static | ||
| 45 | # musl on Linux, for the reason above; another OS names its triple here. | ||
| 46 | MUX_TARGET ?= x86_64-linux-musl | ||
| 43 | install: | 47 | install: |
| 44 | $(ZIG) build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe -p $(INSTDIR) | 48 | $(ZIG) build -Dtarget=$(MUX_TARGET) -Doptimize=ReleaseSafe -p $(INSTDIR) |
| 45 | install -d $(BINDIR) | 49 | install -d $(BINDIR) |
| 46 | install -m755 $(INSTDIR)/bin/mux $(BINDIR)/ | 50 | install -m755 $(INSTDIR)/bin/mux $(BINDIR)/ |
| 47 | rm -f $(BINDIR)/muxd $(BINDIR)/muxa $(BINDIR)/muxweb | 51 | rm -f $(BINDIR)/muxd $(BINDIR)/muxa $(BINDIR)/muxweb |
| @@ -62,12 +66,12 @@ install: | |||
| 62 | VERSION := $(shell sed -n 's/^[[:space:]]*const version = "\(.*\)";/\1/p' build.zig | head -1) | 66 | VERSION := $(shell sed -n 's/^[[:space:]]*const version = "\(.*\)";/\1/p' build.zig | head -1) |
| 63 | RELDIR ?= dist | 67 | RELDIR ?= dist |
| 64 | RELBIN = $(RELDIR)/v$(VERSION) | 68 | RELBIN = $(RELDIR)/v$(VERSION) |
| 65 | RELTAR = $(RELDIR)/mux-v$(VERSION)-x86_64-linux-musl.tar.gz | 69 | RELTAR = $(RELDIR)/mux-v$(VERSION)-$(MUX_TARGET).tar.gz |
| 66 | # The version guard below asks the STRIPPED artifact what it is, so a | 70 | # The version guard below asks the STRIPPED artifact what it is, so a |
| 67 | # stale stage directory cannot ship under a bumped number. | 71 | # stale stage directory cannot ship under a bumped number. |
| 68 | release: | 72 | release: |
| 69 | @test -n "$(VERSION)" || { echo "release: no version found in build.zig"; exit 1; } | 73 | @test -n "$(VERSION)" || { echo "release: no version found in build.zig"; exit 1; } |
| 70 | $(ZIG) build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe -p $(RELDIR)/stage | 74 | $(ZIG) build -Dtarget=$(MUX_TARGET) -Doptimize=ReleaseSafe -p $(RELDIR)/stage |
| 71 | rm -rf $(RELBIN) $(RELTAR) | 75 | rm -rf $(RELBIN) $(RELTAR) |
| 72 | install -d $(RELBIN) | 76 | install -d $(RELBIN) |
| 73 | install -m755 $(RELDIR)/stage/bin/mux $(RELBIN)/ | 77 | install -m755 $(RELDIR)/stage/bin/mux $(RELBIN)/ |
| @@ -135,8 +139,14 @@ throughput: | |||
| 135 | $(ZIG) build -Doptimize=ReleaseSafe -p $(THRUDIR) | 139 | $(ZIG) build -Doptimize=ReleaseSafe -p $(THRUDIR) |
| 136 | ./test/throughput.sh $(THRUDIR)/bin/mux $(THRUDIR)/bin/ptyclient | 140 | ./test/throughput.sh $(THRUDIR)/bin/mux $(THRUDIR)/bin/ptyclient |
| 137 | 141 | ||
| 142 | # `zig build check` grades the tree; bans.sh grades the grader. The folder | ||
| 143 | # rules are the one gate whose failure mode is silence — a needle list | ||
| 144 | # edited down to nothing, or a folder dropped from a rule's list, leaves a | ||
| 145 | # green tree asserting rules that no longer bite — so one planted needle per | ||
| 146 | # rule runs here, after the gate it is checking. Two seconds. | ||
| 138 | check: | 147 | check: |
| 139 | $(ZIG) build check | 148 | $(ZIG) build check |
| 149 | sh test/bans.sh $(ZIG) | ||
| 140 | 150 | ||
| 141 | # Everything a change must pass before delivery, cheapest gate first so a | 151 | # Everything a change must pass before delivery, cheapest gate first so a |
| 142 | # fmt slip fails in seconds rather than after the e2e suites. Sequential | 152 | # fmt slip fails in seconds rather than after the e2e suites. Sequential |
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,4 +1,5 @@ | |||
| 1 | const std = @import("std"); | 1 | const std = @import("std"); |
| 2 | const builtin = @import("builtin"); | ||
| 2 | 3 | ||
| 3 | /// The vendored QUIC stack (deps/quic). Built by a script rather than by | 4 | /// The vendored QUIC stack (deps/quic). Built by a script rather than by |
| 4 | /// addCSourceFiles, and that is a deliberate v1: wolfSSL's build generates | 5 | /// addCSourceFiles, and that is a deliberate v1: wolfSSL's build generates |
| @@ -17,8 +18,27 @@ fn quicDeps(b: *std.Build, target: std.Build.ResolvedTarget) struct { | |||
| 17 | step: *std.Build.Step, | 18 | step: *std.Build.Step, |
| 18 | dir: []const u8, | 19 | dir: []const u8, |
| 19 | } { | 20 | } { |
| 20 | const musl = target.result.abi == .musl; | 21 | // One word per prefix, shared with build-deps.sh, `make deps`, |
| 21 | const name = if (musl) "musl" else "native"; | 22 | // `make clean-deps` and wan.sh's musl cross-build: `native` for the |
| 23 | // host's own libc, `musl` for the static x86_64 release, and the target | ||
| 24 | // triple for any cross target — so a third OS is one more `case` arm | ||
| 25 | // in the script and nothing here. The word follows the TARGET, never | ||
| 26 | // the host: a cross build that reused the host's prefix would link | ||
| 27 | // x86_64 Linux archives into an aarch64 macOS binary. | ||
| 28 | // | ||
| 29 | // `musl` carries an architecture as well as a libc: the script's | ||
| 30 | // zigcc-musl wrapper spells `-target x86_64-linux-musl` outright, so | ||
| 31 | // that word is only ever the x86_64 static release. Any other musl | ||
| 32 | // target falls into the `<arch>-<os>` form, which the script's `case` | ||
| 33 | // refuses with its usage line rather than quietly building x86_64 | ||
| 34 | // archives for an aarch64 binary to fail to link. | ||
| 35 | const t = target.result; | ||
| 36 | const name = if (t.abi == .musl and t.cpu.arch == .x86_64) | ||
| 37 | "musl" | ||
| 38 | else if (t.os.tag == builtin.os.tag and t.cpu.arch == builtin.cpu.arch) | ||
| 39 | "native" | ||
| 40 | else | ||
| 41 | b.fmt("{s}-{s}", .{ @tagName(t.cpu.arch), @tagName(t.os.tag) }); | ||
| 22 | const run = b.addSystemCommand(&.{ "deps/quic/build-deps.sh", name }); | 42 | const run = b.addSystemCommand(&.{ "deps/quic/build-deps.sh", name }); |
| 23 | run.setName(b.fmt("build QUIC deps ({s})", .{name})); | 43 | run.setName(b.fmt("build QUIC deps ({s})", .{name})); |
| 24 | // Never cached by the build graph: the script's own marker file is the | 44 | // Never cached by the build graph: the script's own marker file is the |
| @@ -40,6 +60,15 @@ fn quicDeps(b: *std.Build, target: std.Build.ResolvedTarget) struct { | |||
| 40 | }; | 60 | }; |
| 41 | } | 61 | } |
| 42 | 62 | ||
| 63 | /// Zig 0.15's self-hosted x86_64 ELF linker can't handle the .sframe | ||
| 64 | /// sections gcc >= 16's crt1.o emits, so ELF goes through LLD. LLD does | ||
| 65 | /// not link Mach-O, and Zig's own linker does — so Darwin is the one | ||
| 66 | /// target that must NOT ask for it. | ||
| 67 | fn linkerFor(c: *std.Build.Step.Compile) void { | ||
| 68 | c.use_llvm = true; | ||
| 69 | c.use_lld = !c.rootModuleTarget().os.tag.isDarwin(); | ||
| 70 | } | ||
| 71 | |||
| 43 | /// One wasm-side twin of a native module: same source, the wasm32 target, | 72 | /// One wasm-side twin of a native module: same source, the wasm32 target, |
| 44 | /// and ReleaseSmall — never `optimize`, because the artifact is embedded | 73 | /// and ReleaseSmall — never `optimize`, because the artifact is embedded |
| 45 | /// into the one binary and its Debug build is 3.7MB against ReleaseSmall's 345KB. | 74 | /// into the one binary and its Debug build is 3.7MB against ReleaseSmall's 345KB. |
| @@ -122,7 +151,15 @@ const mod_table = [_]ModSpec{ | |||
| 122 | // driven by a test holding an engine and no socket; and the whole | 151 | // driven by a test holding an engine and no socket; and the whole |
| 123 | // component is platform-free, so `mux_core.wasm` compiles it. | 152 | // component is platform-free, so `mux_core.wasm` compiles it. |
| 124 | .{ .name = "term", .path = "src/engine/term.zig", .wasm = true }, | 153 | .{ .name = "term", .path = "src/engine/term.zig", .wasm = true }, |
| 125 | .{ .name = "pty", .path = "src/server/pty.zig", .link_libc = true }, | 154 | // The platform layer, one row per side (docs/superpowers/specs/ |
| 155 | // 2026-09-03-macos-port-design.md). Leaves: they import nothing of ours, | ||
| 156 | // and the raw OS spellings are meant to end up here rather than in the | ||
| 157 | // rows that call them, so a second arm is a folder and not a grep. | ||
| 158 | // Two rows rather than one because the client never links a fork or a | ||
| 159 | // pty, and an app that links the engine and a client must not either. | ||
| 160 | .{ .name = "server_os", .path = "src/os/server_os.zig", .link_libc = true }, | ||
| 161 | .{ .name = "client_os", .path = "src/os/client_os.zig", .link_libc = true }, | ||
| 162 | .{ .name = "pty", .path = "src/server/pty.zig", .link_libc = true, .imports = &.{"server_os"} }, | ||
| 126 | // The QUIC vocabulary both ends share: the one @cImport of the vendored | 163 | // The QUIC vocabulary both ends share: the one @cImport of the vendored |
| 127 | // stack, the key, the wire constants, the egress ring. It has to be ONE | 164 | // stack, the key, the wire constants, the egress ring. It has to be ONE |
| 128 | // module — two @cImport blocks over the same headers are two distinct | 165 | // module — two @cImport blocks over the same headers are two distinct |
| @@ -162,10 +199,11 @@ const mod_table = [_]ModSpec{ | |||
| 162 | // Reflection over a caller's options struct, so it imports nothing: the | 199 | // Reflection over a caller's options struct, so it imports nothing: the |
| 163 | // struct is the flag table and the parser learns it at comptime. | 200 | // struct is the flag table and the parser learns it at comptime. |
| 164 | .{ .name = "cliflags", .path = "src/cli/flags.zig" }, | 201 | .{ .name = "cliflags", .path = "src/cli/flags.zig" }, |
| 165 | // This image, as a path something can exec. Under src/cli/ because it | 202 | // This image, as a path something can exec. Under src/os/ with the rest |
| 166 | // asks the OS about the process it is in — a question no headless | 203 | // of the platform layer because it asks the OS about the process it is |
| 167 | // client may spell. | 204 | // in — a question no headless client may spell, and one whose answer is |
| 168 | .{ .name = "spawn", .path = "src/cli/spawn.zig", .link_libc = true }, | 205 | // spelled differently on every OS. |
| 206 | .{ .name = "spawn", .path = "src/os/spawn.zig", .link_libc = true }, | ||
| 169 | // ---- single-hop over the leaves ---- | 207 | // ---- single-hop over the leaves ---- |
| 170 | // The client side of a daemon's socket: dial it, and say hello. `term` | 208 | // The client side of a daemon's socket: dial it, and say hello. `term` |
| 171 | // is the attach encoders, `link` is the round trip's wait — an embedder | 209 | // is the attach encoders, `link` is the round trip's wait — an embedder |
| @@ -196,7 +234,7 @@ const mod_table = [_]ModSpec{ | |||
| 196 | // daemon itself when nobody handed it a --key — and for the shim | 234 | // daemon itself when nobody handed it a --key — and for the shim |
| 197 | // directory shell integration writes under the same 0700 policy. | 235 | // directory shell integration writes under the same 0700 policy. |
| 198 | // `pty` stays a row of its own: the ptyclient fixture consumes it. | 236 | // `pty` stays a row of its own: the ptyclient fixture consumes it. |
| 199 | .{ .name = "daemon", .path = "src/server/server.zig", .link_libc = true, .imports = &.{ "term", "pty", "sockpath", "serve", "quic", "xdg", "proxy" }, .test_imports = &.{ "testtmp", "dial", "link" }, .quic_tests = true }, | 237 | .{ .name = "daemon", .path = "src/server/server.zig", .link_libc = true, .imports = &.{ "term", "pty", "sockpath", "serve", "quic", "xdg", "proxy", "server_os" }, .test_imports = &.{ "testtmp", "dial", "link" }, .quic_tests = true }, |
| 200 | // The agent-facing client. It speaks frames and owns no terminal, which | 238 | // The agent-facing client. It speaks frames and owns no terminal, which |
| 201 | // is the whole point — it attaches at 0x0 and never claims the grid. | 239 | // is the whole point — it attaches at 0x0 and never claims the grid. |
| 202 | // The transport modules are the CLI client's, minus everything that | 240 | // The transport modules are the CLI client's, minus everything that |
| @@ -220,7 +258,7 @@ const mod_table = [_]ModSpec{ | |||
| 220 | // sits here rather than in either front so the CLI wall and the browser | 258 | // sits here rather than in either front so the CLI wall and the browser |
| 221 | // hub resolve a host line the same way. Nothing here WRITES that file — | 259 | // hub resolve a host line the same way. Nothing here WRITES that file — |
| 222 | // `wall_host.recordHost` and `webhub_main` do. | 260 | // `wall_host.recordHost` and `webhub_main` do. |
| 223 | .{ .name = "client", .path = "src/client/client.zig", .link_libc = true, .imports = &.{ "term", "quic", "xdg", "sockpath", "serve", "dial", "link" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 261 | .{ .name = "client", .path = "src/client/client.zig", .link_libc = true, .imports = &.{ "term", "quic", "xdg", "serve", "dial", "link", "client_os" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 224 | // ---- the two fronts ---- | 262 | // ---- the two fronts ---- |
| 225 | // The browser hub's HTTP/WebSocket decisions: Origin gate, route table, | 263 | // The browser hub's HTTP/WebSocket decisions: Origin gate, route table, |
| 226 | // WS endpoint naming. Assets are injected (the exe root @embedFiles | 264 | // WS endpoint naming. Assets are injected (the exe root @embedFiles |
| @@ -243,7 +281,7 @@ const mod_table = [_]ModSpec{ | |||
| 243 | // loop's alone, which the root never spells, and the painter takes | 281 | // loop's alone, which the root never spells, and the painter takes |
| 244 | // `term.engine` and `term.protocol`; the decoder and the key table they | 282 | // `term.engine` and `term.protocol`; the decoder and the key table they |
| 245 | // also want reach them through `client`'s seams. | 283 | // also want reach them through `client`'s seams. |
| 246 | .{ .name = "wall", .path = "src/tui/wallview.zig", .link_libc = true, .imports = &.{ "term", "client", "proxy", "spawn" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 284 | .{ .name = "wall", .path = "src/tui/wallview.zig", .link_libc = true, .imports = &.{ "term", "client", "proxy", "spawn", "client_os", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 247 | // ---- the one binary ---- | 285 | // ---- the one binary ---- |
| 248 | // Four words, one image — and one row: the daemon's entrypoint, the | 286 | // Four words, one image — and one row: the daemon's entrypoint, the |
| 249 | // client's and the hub's are CHILD FILES of the dispatcher, so a second | 287 | // client's and the hub's are CHILD FILES of the dispatcher, so a second |
| @@ -266,7 +304,7 @@ const mod_table = [_]ModSpec{ | |||
| 266 | // fourth copy of connect-write-poll-read here. `testtmp` is the keygen | 304 | // fourth copy of connect-write-poll-read here. `testtmp` is the keygen |
| 267 | // round-trip's: it needs a directory to generate into, which the daemon | 305 | // round-trip's: it needs a directory to generate into, which the daemon |
| 268 | // never touches. | 306 | // never touches. |
| 269 | .{ .name = "mux", .path = "src/cli/mux.zig", .link_libc = true, .imports = &.{ "daemon", "client", "wall", "agent", "webhub", "term", "proxy", "quic", "xdg", "spawn", "sockpath", "cliflags", "dial" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, | 307 | .{ .name = "mux", .path = "src/cli/mux.zig", .link_libc = true, .imports = &.{ "daemon", "client", "wall", "agent", "webhub", "term", "proxy", "quic", "xdg", "spawn", "sockpath", "cliflags", "dial", "server_os", "client_os" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, |
| 270 | }; | 308 | }; |
| 271 | 309 | ||
| 272 | /// Comptime row lookup. Every hand-written module name in this file goes | 310 | /// Comptime row lookup. Every hand-written module name in this file goes |
| @@ -308,7 +346,7 @@ comptime { | |||
| 308 | /// The folders the doc gate walks. Listed rather than globbed: a new folder | 346 | /// The folders the doc gate walks. Listed rather than globbed: a new folder |
| 309 | /// under `src/` is a decision about who owns what, and a glob would let one | 347 | /// under `src/` is a decision about who owns what, and a glob would let one |
| 310 | /// appear — with every file in it ungated — as a side effect of a mkdir. | 348 | /// appear — with every file in it ungated — as a side effect of a mkdir. |
| 311 | const src_dirs = [_][]const u8{ "src", "src/engine", "src/server", "src/client", "src/tui", "src/cli" }; | 349 | const src_dirs = [_][]const u8{ "src", "src/engine", "src/server", "src/client", "src/tui", "src/cli", "src/os" }; |
| 312 | 350 | ||
| 313 | /// The source bans, read off the PRODUCTION lines of the files under `src/`. | 351 | /// The source bans, read off the PRODUCTION lines of the files under `src/`. |
| 314 | /// They catch what the import graph cannot: a module needs no import to | 352 | /// They catch what the import graph cannot: a module needs no import to |
| @@ -366,7 +404,7 @@ const source_bans = [_]SourceBan{ | |||
| 366 | // spelled by a leaf utility runs exactly as well as one spelled by | 404 | // spelled by a leaf utility runs exactly as well as one spelled by |
| 367 | // the daemon, and a rule with a hole in it is a rule that reports | 405 | // the daemon, and a rule with a hole in it is a rule that reports |
| 368 | // green about the place nobody looked. | 406 | // green about the place nobody looked. |
| 369 | .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli" }, | 407 | .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli", "src/os" }, |
| 370 | .needles = &.{ "\"/bin/sh\"", "\"-c\"" }, | 408 | .needles = &.{ "\"/bin/sh\"", "\"-c\"" }, |
| 371 | .why = "the only program mux runs is one the user named — the " ++ | 409 | .why = "the only program mux runs is one the user named — the " ++ |
| 372 | "session shell, `ssh` from the handoff recipe, or `--via`'s own " ++ | 410 | "session shell, `ssh` from the handoff recipe, or `--via`'s own " ++ |
| @@ -375,14 +413,39 @@ const source_bans = [_]SourceBan{ | |||
| 375 | }, | 413 | }, |
| 376 | .{ | 414 | .{ |
| 377 | .rule = "6", | 415 | .rule = "6", |
| 378 | .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli" }, | 416 | .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli", "src/os" }, |
| 379 | .needles = &.{"posix.fork("}, | 417 | .needles = &.{"posix.fork("}, |
| 380 | .except = "src/cli/main.zig", | 418 | .except = "src/os/server_os_linux.zig", |
| 381 | .why = "the daemon starts itself \u{2014} `mux d start -d` forks, and " ++ | 419 | .why = "the daemon starts itself \u{2014} `mux d start -d` forks, and " ++ |
| 382 | "every other starter spells that argv and execs this image. A " ++ | 420 | "every other starter spells that argv and execs this image. A " ++ |
| 383 | "client that forked a daemon would be choosing the daemon's " ++ | 421 | "client that forked a daemon would be choosing the daemon's " ++ |
| 384 | "flags, its log and its refusals, none of which it can see", | 422 | "flags, its log and its refusals, none of which it can see", |
| 385 | }, | 423 | }, |
| 424 | .{ | ||
| 425 | .rule = "7", | ||
| 426 | .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli" }, | ||
| 427 | // The raw spellings the platform layer exists to hold. `src/os/` is | ||
| 428 | // absent from the list on purpose: its children may spell anything, | ||
| 429 | // and its roots have no reason to. Comments count, as they do for | ||
| 430 | // rule 4 — a comment naming a Linux mechanism is one that goes | ||
| 431 | // stale the day a second arm exists. | ||
| 432 | // Four of the needles are spelled to catch a name in both the form | ||
| 433 | // Zig writes it and the form C and our own prose do. `so.peercred` | ||
| 434 | // and `so_peercred` are `std.posix.SO.PEERCRED` and `SO_PEERCRED`; a | ||
| 435 | // bare `peercred` would ban `client_os.peerCred`, the very operation | ||
| 436 | // callers are supposed to reach for. `iocsptlck` and `iocgptn` drop | ||
| 437 | // the leading T so they catch `std.posix.T.IOCGPTN` as well as | ||
| 438 | // `TIOCGPTN` — `std.posix.T` exists, so that first spelling names no | ||
| 439 | // `std.os.linux` and would otherwise be a Linux-ism that passes. | ||
| 440 | // `nosignal` catches `std.posix.MSG.NOSIGNAL`, a flag Linux and the | ||
| 441 | // BSDs spell differently and macOS does not have at all: a send that | ||
| 442 | // must not signal goes through `server_os.sendNoSigNoWait` or | ||
| 443 | // `client_os.sendNoSig`, whichever side is asking. | ||
| 444 | .needles = &.{ "std.os.linux", "/proc", "memfd", "close_range", "exit_group", "so.peercred", "so_peercred", "iocsptlck", "iocgptn", "nosignal" }, | ||
| 445 | .why = "a call whose spelling differs by OS belongs in src/os/, behind a " ++ | ||
| 446 | "server_os or client_os operation whose doc names what it guarantees; " ++ | ||
| 447 | "everything else builds for every OS from the same line", | ||
| 448 | }, | ||
| 386 | }; | 449 | }; |
| 387 | 450 | ||
| 388 | fn checkSourceBan(b: *std.Build, ban: SourceBan) void { | 451 | fn checkSourceBan(b: *std.Build, ban: SourceBan) void { |
| @@ -676,8 +739,7 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui | |||
| 676 | .optimize = .Debug, | 739 | .optimize = .Debug, |
| 677 | }); | 740 | }); |
| 678 | const exe = b.addExecutable(.{ .name = "docscheck", .root_module = mod }); | 741 | const exe = b.addExecutable(.{ .name = "docscheck", .root_module = mod }); |
| 679 | exe.use_llvm = true; | 742 | linkerFor(exe); |
| 680 | exe.use_lld = true; | ||
| 681 | 743 | ||
| 682 | // The tool is inside its own corpus: a gate its author is exempt from is | 744 | // The tool is inside its own corpus: a gate its author is exempt from is |
| 683 | // an argument, not a rule. | 745 | // an argument, not a rule. |
| @@ -726,10 +788,11 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui | |||
| 726 | /// of all: it carries every argument parser but muxa's, its mains being | 788 | /// of all: it carries every argument parser but muxa's, its mains being |
| 727 | /// child files — a test that is never built is not a test (decisions.md). | 789 | /// child files — a test that is never built is not a test (decisions.md). |
| 728 | const test_order = [_][]const u8{ | 790 | const test_order = [_][]const u8{ |
| 729 | "script", "cliflags", "testtmp", "spawn", "dial", "link", | 791 | "script", "cliflags", "testtmp", "server_os", "client_os", "spawn", |
| 730 | "quic", "webhub", "agent", "term", "rawmode", "delaypipe", | 792 | "dial", "link", "quic", "webhub", "agent", "term", |
| 731 | "render", "wsclient", "ptyclient", "pty", "sockpath", "serve", | 793 | "rawmode", "delaypipe", "render", "wsclient", "ptyclient", "pty", |
| 732 | "xdg", "proxy", "wall", "client", "daemon", "mux", | 794 | "sockpath", "serve", "xdg", "proxy", "wall", "client", |
| 795 | "daemon", "mux", | ||
| 733 | }; | 796 | }; |
| 734 | 797 | ||
| 735 | comptime { | 798 | comptime { |
| @@ -859,31 +922,24 @@ pub fn build(b: *std.Build) void { | |||
| 859 | // fixtures below stay separate: they stand in for users, not for the | 922 | // fixtures below stay separate: they stand in for users, not for the |
| 860 | // product. | 923 | // product. |
| 861 | const mux_exe = b.addExecutable(.{ .name = "mux", .root_module = mux_mod }); | 924 | const mux_exe = b.addExecutable(.{ .name = "mux", .root_module = mux_mod }); |
| 862 | // Zig 0.15's self-hosted x86_64 linker can't handle the .sframe | 925 | linkerFor(mux_exe); |
| 863 | // sections emitted by gcc >= 16 crt1.o on this system; LLD can. | ||
| 864 | mux_exe.use_llvm = true; | ||
| 865 | mux_exe.use_lld = true; | ||
| 866 | linkQuic(b, mux_exe, quic); | 926 | linkQuic(b, mux_exe, quic); |
| 867 | b.installArtifact(mux_exe); | 927 | b.installArtifact(mux_exe); |
| 868 | 928 | ||
| 869 | const rawmode_exe = b.addExecutable(.{ .name = "rawmode", .root_module = rawmode_mod }); | 929 | const rawmode_exe = b.addExecutable(.{ .name = "rawmode", .root_module = rawmode_mod }); |
| 870 | rawmode_exe.use_llvm = true; | 930 | linkerFor(rawmode_exe); |
| 871 | rawmode_exe.use_lld = true; | ||
| 872 | b.installArtifact(rawmode_exe); | 931 | b.installArtifact(rawmode_exe); |
| 873 | 932 | ||
| 874 | const delaypipe_exe = b.addExecutable(.{ .name = "delaypipe", .root_module = delaypipe_mod }); | 933 | const delaypipe_exe = b.addExecutable(.{ .name = "delaypipe", .root_module = delaypipe_mod }); |
| 875 | delaypipe_exe.use_llvm = true; | 934 | linkerFor(delaypipe_exe); |
| 876 | delaypipe_exe.use_lld = true; | ||
| 877 | b.installArtifact(delaypipe_exe); | 935 | b.installArtifact(delaypipe_exe); |
| 878 | 936 | ||
| 879 | const render_exe = b.addExecutable(.{ .name = "render", .root_module = render_mod }); | 937 | const render_exe = b.addExecutable(.{ .name = "render", .root_module = render_mod }); |
| 880 | render_exe.use_llvm = true; | 938 | linkerFor(render_exe); |
| 881 | render_exe.use_lld = true; | ||
| 882 | b.installArtifact(render_exe); | 939 | b.installArtifact(render_exe); |
| 883 | 940 | ||
| 884 | const ptyclient_exe = b.addExecutable(.{ .name = "ptyclient", .root_module = ptyclient_mod }); | 941 | const ptyclient_exe = b.addExecutable(.{ .name = "ptyclient", .root_module = ptyclient_mod }); |
| 885 | ptyclient_exe.use_llvm = true; | 942 | linkerFor(ptyclient_exe); |
| 886 | ptyclient_exe.use_lld = true; | ||
| 887 | b.installArtifact(ptyclient_exe); | 943 | b.installArtifact(ptyclient_exe); |
| 888 | 944 | ||
| 889 | // ---- The wasm core (M-web Task 4) ---- | 945 | // ---- The wasm core (M-web Task 4) ---- |
| @@ -946,8 +1002,7 @@ pub fn build(b: *std.Build) void { | |||
| 946 | 1002 | ||
| 947 | // ---- the hub's browser stand-in ---- | 1003 | // ---- the hub's browser stand-in ---- |
| 948 | const wsclient_exe = b.addExecutable(.{ .name = "wsclient", .root_module = wsclient_mod }); | 1004 | const wsclient_exe = b.addExecutable(.{ .name = "wsclient", .root_module = wsclient_mod }); |
| 949 | wsclient_exe.use_llvm = true; | 1005 | linkerFor(wsclient_exe); |
| 950 | wsclient_exe.use_lld = true; | ||
| 951 | b.installArtifact(wsclient_exe); | 1006 | b.installArtifact(wsclient_exe); |
| 952 | 1007 | ||
| 953 | // The page's three assets arrive as anonymous imports so @embedFile | 1008 | // The page's three assets arrive as anonymous imports so @embedFile |
| @@ -967,8 +1022,7 @@ pub fn build(b: *std.Build) void { | |||
| 967 | for (test_order) |name| { | 1022 | for (test_order) |name| { |
| 968 | const i = idx.of(name); | 1023 | const i = idx.of(name); |
| 969 | const t = b.addTest(.{ .root_module = test_mods[i] }); | 1024 | const t = b.addTest(.{ .root_module = test_mods[i] }); |
| 970 | t.use_llvm = true; | 1025 | linkerFor(t); |
| 971 | t.use_lld = true; | ||
| 972 | // quic_tests is also what makes `make test` build the QUIC deps on | 1026 | // quic_tests is also what makes `make test` build the QUIC deps on |
| 973 | // a clean checkout — the dependency must reach the test binaries, | 1027 | // a clean checkout — the dependency must reach the test binaries, |
| 974 | // not only the binary (decisions.md, M8). | 1028 | // not only the binary (decisions.md, M8). |
deps/quic/build-deps.sh
| Old | New | ||
|---|---|---|---|
| @@ -2,7 +2,7 @@ | |||
| 2 | # Build the vendored QUIC stack (ngtcp2 + wolfSSL) into static libraries that | 2 | # Build the vendored QUIC stack (ngtcp2 + wolfSSL) into static libraries that |
| 3 | # build.zig links, using ONLY the repo's pinned Zig as the C toolchain. | 3 | # build.zig links, using ONLY the repo's pinned Zig as the C toolchain. |
| 4 | # | 4 | # |
| 5 | # ./build-deps.sh <target> target: native | musl | 5 | # ./build-deps.sh <target> target: native | musl | aarch64-macos |
| 6 | # | 6 | # |
| 7 | # Grown up from spike/quic/build.sh (M8 Task 1), which proved this is | 7 | # Grown up from spike/quic/build.sh (M8 Task 1), which proved this is |
| 8 | # possible; this version is the one the real build depends on. It is | 8 | # possible; this version is the one the real build depends on. It is |
| @@ -24,8 +24,8 @@ WOLFSSL_SHA=2f4ef3d4fd387a9b3191d36a6316d69116c46ff69bb9583b6c82b36d7b8ca114 | |||
| 24 | 24 | ||
| 25 | T="${1:-native}" | 25 | T="${1:-native}" |
| 26 | case "$T" in | 26 | case "$T" in |
| 27 | native | musl) ;; | 27 | native | musl | aarch64-macos) ;; |
| 28 | *) echo "usage: $0 [native|musl]" >&2; exit 2 ;; | 28 | *) echo "usage: $0 [native|musl|aarch64-macos]" >&2; exit 2 ;; |
| 29 | esac | 29 | esac |
| 30 | 30 | ||
| 31 | SELF="$(cd "$(dirname "$0")" && pwd)" | 31 | SELF="$(cd "$(dirname "$0")" && pwd)" |
| @@ -41,6 +41,16 @@ W="$SELF/work" | |||
| 41 | command -v cmake >/dev/null || { echo "deps/quic: cmake is required" >&2; exit 1; } | 41 | command -v cmake >/dev/null || { echo "deps/quic: cmake is required" >&2; exit 1; } |
| 42 | command -v curl >/dev/null || { echo "deps/quic: curl is required" >&2; exit 1; } | 42 | command -v curl >/dev/null || { echo "deps/quic: curl is required" >&2; exit 1; } |
| 43 | 43 | ||
| 44 | # The two host tools whose spelling differs on a Darwin host, named once so | ||
| 45 | # no later line has to ask again. BELOW the marker check on purpose: the | ||
| 46 | # early exit is the path every build takes and build.zig budgets it at one | ||
| 47 | # fork, so these two must not be forked to find out the libs are already | ||
| 48 | # there. | ||
| 49 | case "$(uname)" in | ||
| 50 | Darwin) sha_check() { shasum -a 256 -c - >/dev/null; }; NJOBS=$(sysctl -n hw.ncpu) ;; | ||
| 51 | *) sha_check() { sha256sum -c - >/dev/null; }; NJOBS=$(nproc) ;; | ||
| 52 | esac | ||
| 53 | |||
| 44 | echo "deps/quic: building the QUIC stack for $T (first run: downloads ~30MB, takes a few minutes)" >&2 | 54 | echo "deps/quic: building the QUIC stack for $T (first run: downloads ~30MB, takes a few minutes)" >&2 |
| 45 | mkdir -p "$W/src" "$W/bin" | 55 | mkdir -p "$W/src" "$W/bin" |
| 46 | 56 | ||
| @@ -58,6 +68,10 @@ cat > "$W/bin/zigcc-musl" <<EOF | |||
| 58 | #!/bin/sh | 68 | #!/bin/sh |
| 59 | exec $ZIG cc -target x86_64-linux-musl "\$@" | 69 | exec $ZIG cc -target x86_64-linux-musl "\$@" |
| 60 | EOF | 70 | EOF |
| 71 | cat > "$W/bin/zigcc-aarch64-macos" <<EOF | ||
| 72 | #!/bin/sh | ||
| 73 | exec $ZIG cc -target aarch64-macos "\$@" | ||
| 74 | EOF | ||
| 61 | cat > "$W/bin/zigar" <<EOF | 75 | cat > "$W/bin/zigar" <<EOF |
| 62 | #!/bin/sh | 76 | #!/bin/sh |
| 63 | exec $ZIG ar "\$@" | 77 | exec $ZIG ar "\$@" |
| @@ -70,7 +84,7 @@ chmod +x "$W"/bin/* | |||
| 70 | 84 | ||
| 71 | fetch() { # url sha file | 85 | fetch() { # url sha file |
| 72 | [ -f "$W/src/$3" ] || curl -sSL -o "$W/src/$3" "$1" | 86 | [ -f "$W/src/$3" ] || curl -sSL -o "$W/src/$3" "$1" |
| 73 | echo "$2 $W/src/$3" | sha256sum -c - >/dev/null || { | 87 | echo "$2 $W/src/$3" | sha_check || { |
| 74 | echo "deps/quic: checksum mismatch for $3 — refusing to build" >&2 | 88 | echo "deps/quic: checksum mismatch for $3 — refusing to build" >&2 |
| 75 | rm -f "$W/src/$3" | 89 | rm -f "$W/src/$3" |
| 76 | exit 1 | 90 | exit 1 |
| @@ -85,7 +99,20 @@ fetch "https://github.com/wolfSSL/wolfssl/archive/refs/tags/v$WOLFSSL_VER.tar.gz | |||
| 85 | 99 | ||
| 86 | CC="$W/bin/zigcc-$T" | 100 | CC="$W/bin/zigcc-$T" |
| 87 | XTRA="" | 101 | XTRA="" |
| 88 | [ "$T" = musl ] && XTRA="-DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=x86_64" | 102 | WOLF_XTRA="" |
| 103 | case "$T" in | ||
| 104 | musl) XTRA="-DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=x86_64" ;; | ||
| 105 | aarch64-macos) | ||
| 106 | # Cross to Darwin: find nothing on the host (ngtcp2 found the host's | ||
| 107 | # own libwolfssl.so before this fence and linked a Linux shared | ||
| 108 | # object into a Mach-O build), and no system CA path — mux is | ||
| 109 | # PSK-only and wolfSSL's CA path wants Security.framework, which | ||
| 110 | # this toolchain has no SDK for. Both measured 2026-09-03; the probe | ||
| 111 | # log is in docs/superpowers/specs/2026-09-03-macos-port-design.md, | ||
| 112 | # "Findings the design rests on". | ||
| 113 | XTRA="-DCMAKE_SYSTEM_NAME=Darwin -DCMAKE_SYSTEM_PROCESSOR=arm64 -DCMAKE_FIND_ROOT_PATH=$OUT -DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY -DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=ONLY -DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=ONLY" | ||
| 114 | WOLF_XTRA="-DWOLFSSL_SYS_CA_CERTS=no" ;; | ||
| 115 | esac | ||
| 89 | 116 | ||
| 90 | # wolfSSL. Three flags are load-bearing, all found by link failure in the | 117 | # wolfSSL. Three flags are load-bearing, all found by link failure in the |
| 91 | # spike (spike/quic/README.md records the diagnosis): | 118 | # spike (spike/quic/README.md records the diagnosis): |
| @@ -99,14 +126,14 @@ XTRA="" | |||
| 99 | # It stays off here permanently; flip it only in | 126 | # It stays off here permanently; flip it only in |
| 100 | # a local throwaway build when decrypting your | 127 | # a local throwaway build when decrypting your |
| 101 | # own capture, and never commit that. | 128 | # own capture, and never commit that. |
| 102 | cmake -S "$W/src/wolfssl-$WOLFSSL_VER" -B "$W/build/wolfssl-$T" $XTRA \ | 129 | cmake -S "$W/src/wolfssl-$WOLFSSL_VER" -B "$W/build/wolfssl-$T" $XTRA $WOLF_XTRA \ |
| 103 | -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_FLAGS="-fno-sanitize=undefined -O2" \ | 130 | -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_FLAGS="-fno-sanitize=undefined -O2" \ |
| 104 | -DCMAKE_C_COMPILER="$CC" -DCMAKE_AR="$W/bin/zigar" -DCMAKE_RANLIB="$W/bin/zigranlib" \ | 131 | -DCMAKE_C_COMPILER="$CC" -DCMAKE_AR="$W/bin/zigar" -DCMAKE_RANLIB="$W/bin/zigranlib" \ |
| 105 | -DBUILD_SHARED_LIBS=OFF -DWOLFSSL_QUIC=yes -DWOLFSSL_PSK=yes \ | 132 | -DBUILD_SHARED_LIBS=OFF -DWOLFSSL_QUIC=yes -DWOLFSSL_PSK=yes \ |
| 106 | -DWOLFSSL_AESECB=yes -DWOLFSSL_AESCTR=yes -DWOLFSSL_KEYLOG_EXPORT=no \ | 133 | -DWOLFSSL_AESECB=yes -DWOLFSSL_AESCTR=yes -DWOLFSSL_KEYLOG_EXPORT=no \ |
| 107 | -DWOLFSSL_SESSION_TICKET=yes -DWOLFSSL_EXAMPLES=no -DWOLFSSL_CRYPT_TESTS=no \ | 134 | -DWOLFSSL_SESSION_TICKET=yes -DWOLFSSL_EXAMPLES=no -DWOLFSSL_CRYPT_TESTS=no \ |
| 108 | -DWOLFSSL_INSTALL=yes -DCMAKE_INSTALL_PREFIX="$OUT" >/dev/null | 135 | -DWOLFSSL_INSTALL=yes -DCMAKE_INSTALL_PREFIX="$OUT" >/dev/null |
| 109 | cmake --build "$W/build/wolfssl-$T" -j"$(nproc)" >/dev/null | 136 | cmake --build "$W/build/wolfssl-$T" -j"$NJOBS" >/dev/null |
| 110 | cmake --install "$W/build/wolfssl-$T" >/dev/null | 137 | cmake --install "$W/build/wolfssl-$T" >/dev/null |
| 111 | 138 | ||
| 112 | # ngtcp2 + its wolfSSL crypto backend. ENABLE_LIB_ONLY is what keeps the | 139 | # ngtcp2 + its wolfSSL crypto backend. ENABLE_LIB_ONLY is what keeps the |
| @@ -121,7 +148,7 @@ cmake -S "$W/src/ngtcp2-$NGTCP2_VER" -B "$W/build/ngtcp2-$T" $XTRA \ | |||
| 121 | -DENABLE_STATIC_LIB=ON -DENABLE_SHARED_LIB=OFF -DENABLE_WOLFSSL=ON \ | 148 | -DENABLE_STATIC_LIB=ON -DENABLE_SHARED_LIB=OFF -DENABLE_WOLFSSL=ON \ |
| 122 | -DENABLE_OPENSSL=OFF -DENABLE_GNUTLS=OFF -DENABLE_BORINGSSL=OFF \ | 149 | -DENABLE_OPENSSL=OFF -DENABLE_GNUTLS=OFF -DENABLE_BORINGSSL=OFF \ |
| 123 | -DENABLE_LIB_ONLY=ON -DCMAKE_INSTALL_PREFIX="$OUT" >/dev/null | 150 | -DENABLE_LIB_ONLY=ON -DCMAKE_INSTALL_PREFIX="$OUT" >/dev/null |
| 124 | cmake --build "$W/build/ngtcp2-$T" -j"$(nproc)" >/dev/null | 151 | cmake --build "$W/build/ngtcp2-$T" -j"$NJOBS" >/dev/null |
| 125 | cmake --install "$W/build/ngtcp2-$T" >/dev/null | 152 | cmake --install "$W/build/ngtcp2-$T" >/dev/null |
| 126 | 153 | ||
| 127 | echo "deps/quic: $T ready in $OUT" >&2 | 154 | echo "deps/quic: $T ready in $OUT" >&2 |
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -8187,3 +8187,132 @@ as a goodbye, which is why `fill_sessions` types the detach chord instead of | |||
| 8187 | just closing. One shared fifo cannot carry a chord per holder, so | 8187 | just closing. One shared fifo cannot carry a chord per holder, so |
| 8188 | `release_holds` signals the pids and then waits on `mux d stats` for the slots | 8188 | `release_holds` signals the pids and then waits on `mux d stats` for the slots |
| 8189 | to come back — the daemon's gauge is the witness, never the kill. | 8189 | to come back — the daemon's gauge is the witness, never the kill. |
| 8190 | ## 2026-09-03 — the platform layer (macOS port, step 2) | ||
| 8191 | |||
| 8192 | Every OS-specific spelling under `src/` now lives in `src/os/`, one row per | ||
| 8193 | side. `server_os` holds what only the daemon does — the pty fork, the | ||
| 8194 | detached daemon fork, peer credentials, an anonymous fd for the upgrade | ||
| 8195 | manifest, `exitNow`, `closeFrom`, the pty's mode and foreground pgid, the | ||
| 8196 | window-size ioctl and the stale-image verdict — and `client_os` holds the | ||
| 8197 | few the wall and askpass do: pid, euid, peer credentials, the parent walk, | ||
| 8198 | window size and a pty pair. `spawn` moved in beside them. Each root is the | ||
| 8199 | CONTRACT — a doc comment per operation says what it guarantees and which | ||
| 8200 | failure it prevents — and `impl` switches on `builtin.os.tag`, so a build | ||
| 8201 | for an OS with no arm is a `@compileError` at that switch rather than a | ||
| 8202 | link error or a runtime surprise. The client row is deliberately SEPARATE | ||
| 8203 | from the server's rather than one `os` row: the client never links a fork | ||
| 8204 | or a pty, and an app that links the engine and a client must not either. | ||
| 8205 | |||
| 8206 | **The gate is folder rule 7.** No production line under `src/`, | ||
| 8207 | `src/engine/`, `src/client/`, `src/tui/`, `src/server/` or `src/cli/` may | ||
| 8208 | spell `std.os.linux`, `/proc`, `memfd`, `close_range`, `exit_group`, | ||
| 8209 | `so.peercred`, `so_peercred`, `iocsptlck`, `iocgptn` or `nosignal`. | ||
| 8210 | `src/os/` is absent from that folder list on purpose: its children may | ||
| 8211 | spell anything, and its roots have no reason to. Comments count, as they | ||
| 8212 | do for rule 4, because a comment naming a Linux mechanism is one that goes | ||
| 8213 | stale the day a second arm exists. Four needles are spelled to catch a name in both the form Zig | ||
| 8214 | writes it and the form C and our own prose do — `so.peercred` and | ||
| 8215 | `so_peercred` for `std.posix.SO.PEERCRED` and `SO_PEERCRED`, where a bare | ||
| 8216 | `peercred` would have banned `client_os.peerCred`, the very operation | ||
| 8217 | callers are supposed to reach for; `iocsptlck` and `iocgptn` drop the | ||
| 8218 | leading T so they catch `std.posix.T.IOCGPTN` as well as `TIOCGPTN`. | ||
| 8219 | `nosignal` is the fifth and is there for a different reason: it catches | ||
| 8220 | `std.posix.MSG.NOSIGNAL`, a flag Linux and the BSDs spell differently and | ||
| 8221 | macOS does not have at all, so a send that must not signal goes through | ||
| 8222 | `server_os.sendNoSigNoWait` or `client_os.sendNoSig`, whichever side is | ||
| 8223 | asking. | ||
| 8224 | |||
| 8225 | **Measured before the design, on a Linux host.** zig 0.15.2 cross-compiles | ||
| 8226 | a libc program using `posix_openpt`, `kqueue`, `libproc` and `dyld` to | ||
| 8227 | aarch64-macos with no SDK, and links Mach-O with its own linker — LLD | ||
| 8228 | refuses ("using LLD to link macho files is unsupported") — so `linkerFor` | ||
| 8229 | asks for `use_lld` on every target except Darwin. ngtcp2 1.25.0 and | ||
| 8230 | wolfSSL 5.9.2 cross-build with `WOLFSSL_SYS_CA_CERTS=no` (mux is PSK-only | ||
| 8231 | and the system-CA path wants Security.framework) and a | ||
| 8232 | `CMAKE_FIND_ROOT_PATH` fence at the target prefix, without which ngtcp2 | ||
| 8233 | finds the host's `libwolfssl.so`. `std.posix.socket` and `std.posix.accept` | ||
| 8234 | already emulate `SOCK_CLOEXEC`, `SOCK_NONBLOCK` and `accept4` on Darwin | ||
| 8235 | with a trailing `fcntl`, so no socket site needed a row at all. ghostty-vt | ||
| 8236 | links three C++ libraries whose build.zig files each call ghostty's | ||
| 8237 | `apple_sdk.addPaths`, which resolves the HOST libc on a Linux host and | ||
| 8238 | fails the C++ compile on glibc headers — upstream code build.zig cannot | ||
| 8239 | reach, the cross-compile blocker that leaves the build-host decision open, | ||
| 8240 | and the reason this step stops at the platform layer and writes no | ||
| 8241 | `_macos.zig` arm. | ||
| 8242 | |||
| 8243 | **One amendment to the spec, made while writing the row.** The spec's | ||
| 8244 | `openPty` plus `becomeSession` are one `forkPty` instead: `forkpty(3)` | ||
| 8245 | exists on both OSes, returning pid 0 in the child exactly as `fork` does, | ||
| 8246 | so the child code stays one branch. Zig ships no `<util.h>` for Darwin, | ||
| 8247 | which is a missing HEADER and not a missing symbol — that arm declares it | ||
| 8248 | with one `extern "c" fn forkpty` line rather than reimplementing the pty | ||
| 8249 | open as `posix_openpt` + `grantpt` + `unlockpt` + `ptsname`. | ||
| 8250 | |||
| 8251 | **The QUIC prefix and the linker follow the TARGET, never the host.** | ||
| 8252 | `quicDeps` names one word per prefix — `native` for the host's own libc, | ||
| 8253 | `musl` for the static release, and `<arch>-<os>` for any other cross target | ||
| 8254 | — shared with `build-deps.sh`, `make deps`, `make clean-deps` and wan.sh's | ||
| 8255 | musl cross-build, so a third OS is one more `case` arm in the script and | ||
| 8256 | nothing in build.zig. A cross build that reused the host's prefix would | ||
| 8257 | link x86_64 Linux archives into an aarch64 macOS binary. | ||
| 8258 | |||
| 8259 | **Two behaviours changed shape on Linux without changing outcome.** The | ||
| 8260 | stale-image verdict is an inode compare against what the image's path held | ||
| 8261 | at boot, not the kernel's ` (deleted)` suffix: `noteBootImage` stamps the | ||
| 8262 | path and its inode once, and every later ask re-stats that path, so a | ||
| 8263 | rename-over reads stale and a file that lands there afterwards can never | ||
| 8264 | promote a stale daemon back to current. The two failures are kept apart. A | ||
| 8265 | path this OS will not name at all records NOTHING and answers false — | ||
| 8266 | unknown is not stale, and a wall must not dress a healthy box in a warning | ||
| 8267 | over a refused readlink. A path that IS named but holds nothing records a | ||
| 8268 | BORN-STALE state, the path with no ident, and answers stale for the life of | ||
| 8269 | the process, because that daemon is already executing an image no path | ||
| 8270 | holds. Second, `xdg.reapDeadPid` asks `kill(pid, 0)` rather than | ||
| 8271 | `access(/proc/PID)`: EPERM is alive-but-not-ours and keeps the entry, and | ||
| 8272 | ESRCH is the one answer that means the pid is gone. | ||
| 8273 | |||
| 8274 | **`sockpath.runtimeDir` owns the default socket directory.** One switch — | ||
| 8275 | `$XDG_RUNTIME_DIR` on Linux, and still no fallback, because a guess cannot | ||
| 8276 | make two binaries agree on one daemon — so the daemon, the client and the | ||
| 8277 | askpass listener agree by construction and another OS spells its own | ||
| 8278 | default in one place. | ||
| 8279 | |||
| 8280 | **The shell harness asks the OS through named helpers.** `test/e2e_lib.sh` | ||
| 8281 | holds one name per question a pin asks about a pid, an fd table, a bound | ||
| 8282 | UDP port or a file's mode: `pid_alive`, `pid_exe`, `pid_comm`, `pid_args`, | ||
| 8283 | `pid_children`, `pid_fd_count`, `pid_fd_targets`, `pid_holds_unix_sock`, | ||
| 8284 | `pid_rss_kb`, `udp_local_bound`, `udp_table`, `file_mode`, `file_size`, | ||
| 8285 | `sha256_of`. The SPELLING of the question now lives in one place per OS and | ||
| 8286 | the question itself stays in the group file, so a second OS adds a | ||
| 8287 | `case "$_os"` arm here and changes no group file. `oracle_selftest` runs | ||
| 8288 | once before the first group and is the helpers' own pin: a helper that | ||
| 8289 | quietly stopped answering — a missing binary, a `/proc` a sandbox will not | ||
| 8290 | show — would not fail a pin loudly, it would make every pin that reads it | ||
| 8291 | agree with anything, and the suite would go green having tested nothing. | ||
| 8292 | Its subject is off-origin on every dimension the helpers could accidentally | ||
| 8293 | hold constant: a child that is not pid 1, more open fds than the three any | ||
| 8294 | fixture would hold, an argv worth losing, and a child of its own. The | ||
| 8295 | socket arm is asked in both directions, of a listener that holds the path | ||
| 8296 | and of a shell that does not, because a `pid_holds_unix_sock` answering yes | ||
| 8297 | to everything would pass the one pin that reads it just as happily as a | ||
| 8298 | correct one. | ||
| 8299 | |||
| 8300 | **How the wire claim was graded, and what the cross-version gate could not | ||
| 8301 | say.** `make ci` is green. `test/xversion.sh` is NOT, and was not green | ||
| 8302 | before this branch either: its preflight demands a `muxd` in the old | ||
| 8303 | prefix and it drives that side as `muxd run`, so the branch base — one | ||
| 8304 | `mux` binary since v0.0.1-16 — cannot serve as its old side at all, and | ||
| 8305 | its pins still assert a PRE-M18 old side ("the daemon created session zz — | ||
| 8306 | this is not a pre-M18 daemon", "it decoded frames an old client has no arm | ||
| 8307 | for"). Against the released v0.0.1-15 tarball it answers 5 passed, 7 | ||
| 8308 | failed. So the claim was graded DIFFERENTIALLY instead: the same gate, the | ||
| 8309 | same v0.0.1-15 old side, run once with this branch's binary as the new | ||
| 8310 | side and once with the branch base's, gives the identical verdict pin for | ||
| 8311 | pin — 5 passed, 7 failed, the same seven messages. The four real | ||
| 8312 | compatibility pins are among the passes in both runs: a new client driving | ||
| 8313 | an old daemon's default session over a socket and over QUIC, an old client | ||
| 8314 | driving the new daemon's, and an old `muxa`'s empty `status_req`. The | ||
| 8315 | refactor moved no byte on the wire. The gate itself needs its old side | ||
| 8316 | re-pinned to a version that still exists; that is not this branch's work. | ||
| 8317 | |||
| 8318 | Spec: `docs/superpowers/specs/2026-09-03-macos-port-design.md`. | ||
src/cli/main.zig
| Old | New | ||
|---|---|---|---|
| @@ -14,6 +14,7 @@ const quic_server = @import("daemon").quic_server; | |||
| 14 | const build_options = @import("build_options"); | 14 | const build_options = @import("build_options"); |
| 15 | const xdg = @import("xdg"); | 15 | const xdg = @import("xdg"); |
| 16 | const spawn = @import("spawn"); | 16 | const spawn = @import("spawn"); |
| 17 | const server_os = @import("server_os"); | ||
| 17 | const handoff = @import("client").handoff; | 18 | const handoff = @import("client").handoff; |
| 18 | const sockpath = @import("sockpath"); | 19 | const sockpath = @import("sockpath"); |
| 19 | const upgrade = @import("daemon").upgrade; | 20 | const upgrade = @import("daemon").upgrade; |
| @@ -429,7 +430,7 @@ fn rollbackEnvp(alloc: std.mem.Allocator) ![*:null]const ?[*:0]const u8 { | |||
| 429 | 430 | ||
| 430 | /// Adopt a daemon from the manifest descriptor supplied by an upgrade exec. | 431 | /// Adopt a daemon from the manifest descriptor supplied by an upgrade exec. |
| 431 | /// The process keeps its pid, children, and descriptors. Reading directly from | 432 | /// The process keeps its pid, children, and descriptors. Reading directly from |
| 432 | /// the anonymous memfd also keeps the embedded QUIC key off disk. | 433 | /// the carrier, which no path names, also keeps the embedded QUIC key off disk. |
| 433 | fn resumeRun(alloc: std.mem.Allocator, o: DaemonArguments, resume_fd: std.posix.fd_t) !u8 { | 434 | fn resumeRun(alloc: std.mem.Allocator, o: DaemonArguments, resume_fd: std.posix.fd_t) !u8 { |
| 434 | // The writer left the offset at the end of what it wrote, and a child | 435 | // The writer left the offset at the end of what it wrote, and a child |
| 435 | // shares the file description with it, so the rewind is ours to do. | 436 | // shares the file description with it, so the rewind is ours to do. |
| @@ -691,13 +692,11 @@ fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 { | |||
| 691 | return 1; | 692 | return 1; |
| 692 | } | 693 | } |
| 693 | 694 | ||
| 694 | /// Read the peer pid from the kernel. Return null when the kernel cannot expose | 695 | /// The peer's pid, or null when the kernel cannot expose it; callers then |
| 695 | /// it, such as across a pid namespace; callers then rely on socket shutdown. | 696 | /// rely on socket shutdown. |
| 696 | fn peerPid(fd: std.posix.socket_t) ?std.posix.pid_t { | 697 | fn peerPid(fd: std.posix.socket_t) ?std.posix.pid_t { |
| 697 | const Ucred = extern struct { pid: std.posix.pid_t, uid: std.posix.uid_t, gid: std.posix.gid_t }; | 698 | const cred = server_os.peerCred(fd) orelse return null; |
| 698 | var cred: Ucred = undefined; | 699 | return cred.pid; |
| 699 | std.posix.getsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.PEERCRED, std.mem.asBytes(&cred)) catch return null; | ||
| 700 | return if (cred.pid > 0) cred.pid else null; | ||
| 701 | } | 700 | } |
| 702 | 701 | ||
| 703 | /// Wait for the peer process after its socket disappears. Socket unlink occurs | 702 | /// Wait for the peer process after its socket disappears. Socket unlink occurs |
| @@ -856,11 +855,16 @@ fn remoteUpgradeCmd(alloc: std.mem.Allocator, host: []const u8, allow_same: bool | |||
| 856 | push.stdout_behavior = .Inherit; | 855 | push.stdout_behavior = .Inherit; |
| 857 | push.stderr_behavior = .Inherit; | 856 | push.stderr_behavior = .Inherit; |
| 858 | try push.spawn(); | 857 | try push.spawn(); |
| 859 | // /proc/self/exe rather than a saved path: the bytes streamed are the | 858 | // The running image rather than a saved path: "push this binary" can |
| 860 | // image RUNNING here, the only thing "push this binary" can honestly | 859 | // only honestly mean the bytes executing here. How close the ask lands |
| 861 | // mean, even if the file it was started from has since been replaced. | 860 | // depends on the OS. On Linux `openSelfExe` opens the running INODE, so |
| 861 | // the bytes streamed are what is executing even after the file it was | ||
| 862 | // started from has been renamed over. On an OS that can only open by | ||
| 863 | // path, it streams whatever that path holds now — the same file in the | ||
| 864 | // ordinary case, and a replacement's bytes if something swapped the | ||
| 865 | // binary mid-push. | ||
| 862 | const streamed: bool = blk: { | 866 | const streamed: bool = blk: { |
| 863 | var img = std.fs.openFileAbsolute("/proc/self/exe", .{}) catch break :blk false; | 867 | var img = std.fs.openSelfExe(.{}) catch break :blk false; |
| 864 | defer img.close(); | 868 | defer img.close(); |
| 865 | var buf: [64 * 1024]u8 = undefined; | 869 | var buf: [64 * 1024]u8 = undefined; |
| 866 | while (true) { | 870 | while (true) { |
| @@ -942,7 +946,7 @@ fn parsePreflight(out: []const u8, want_arch: []const u8) Preflight { | |||
| 942 | fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool) !u8 { | 946 | fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool) !u8 { |
| 943 | var exe_buf: [std.fs.max_path_bytes]u8 = undefined; | 947 | var exe_buf: [std.fs.max_path_bytes]u8 = undefined; |
| 944 | const exe = std.fs.selfExePath(&exe_buf) catch { | 948 | const exe = std.fs.selfExePath(&exe_buf) catch { |
| 945 | std.debug.print("mux d upgrade: cannot find own binary via /proc/self/exe\n", .{}); | 949 | std.debug.print("mux d upgrade: cannot find own image\n", .{}); |
| 946 | return 1; | 950 | return 1; |
| 947 | }; | 951 | }; |
| 948 | 952 | ||
| @@ -1277,9 +1281,8 @@ fn forkDaemon( | |||
| 1277 | deadline_ms: u32, | 1281 | deadline_ms: u32, |
| 1278 | log_path_override: ?[]const u8, | 1282 | log_path_override: ?[]const u8, |
| 1279 | ) StartError!StartOutcome { | 1283 | ) StartError!StartOutcome { |
| 1280 | // Daemon-mode code owns the repository's only fork path. `NeverAnswered` | 1284 | // `NeverAnswered` leaves the child running because it may finish startup |
| 1281 | // leaves the child running because it may finish startup after the caller's | 1285 | // after the caller's deadline and be available on retry. |
| 1282 | // deadline and be available on retry. | ||
| 1283 | if (sockpath.answers(sock_path)) return .already_running; | 1286 | if (sockpath.answers(sock_path)) return .already_running; |
| 1284 | 1287 | ||
| 1285 | std.posix.access(exe_path, std.posix.X_OK) catch return error.SpawnFailed; | 1288 | std.posix.access(exe_path, std.posix.X_OK) catch return error.SpawnFailed; |
| @@ -1327,28 +1330,13 @@ fn forkDaemon( | |||
| 1327 | if (!progress.tty) progress.emit("\n"); | 1330 | if (!progress.tty) progress.emit("\n"); |
| 1328 | 1331 | ||
| 1329 | const t0 = std.time.milliTimestamp(); | 1332 | const t0 = std.time.milliTimestamp(); |
| 1330 | const pid = std.posix.fork() catch { | 1333 | // The fork itself is `server_os.forkDetached`; this function owns what |
| 1334 | // goes INTO it — the log, the argv and the deadline — and folder rule 6 | ||
| 1335 | // names that file as the one fork. | ||
| 1336 | const pid = server_os.forkDetached(exe_z.ptr, argv.ptr, devnull.handle, log.handle) catch { | ||
| 1331 | if (progress.tty) progress.emit("\n"); | 1337 | if (progress.tty) progress.emit("\n"); |
| 1332 | return error.SpawnFailed; | 1338 | return error.SpawnFailed; |
| 1333 | }; | 1339 | }; |
| 1334 | if (pid == 0) { | ||
| 1335 | // Child: create a new session, detach stdio, then exec or call | ||
| 1336 | // `exit_group`. Avoid `std.posix.exit` because libc atexit handlers could | ||
| 1337 | // flush buffers inherited from the parent a second time. | ||
| 1338 | _ = std.os.linux.setsid(); | ||
| 1339 | std.posix.dup2(devnull.handle, std.posix.STDIN_FILENO) catch | ||
| 1340 | std.os.linux.exit_group(127); | ||
| 1341 | std.posix.dup2(log.handle, std.posix.STDOUT_FILENO) catch | ||
| 1342 | std.os.linux.exit_group(127); | ||
| 1343 | std.posix.dup2(log.handle, std.posix.STDERR_FILENO) catch | ||
| 1344 | std.os.linux.exit_group(127); | ||
| 1345 | // Exec a fresh image because `std.debug.MemoryAccessor` caches the pid | ||
| 1346 | // used for memory reads; reusing it after fork can make DebugAllocator | ||
| 1347 | // inspect the parent and panic. Exit 127 if exec fails. | ||
| 1348 | switch (std.posix.execveZ(exe_z.ptr, argv.ptr, std.c.environ)) { | ||
| 1349 | else => std.os.linux.exit_group(127), | ||
| 1350 | } | ||
| 1351 | } | ||
| 1352 | last_spawned_pid = pid; | 1340 | last_spawned_pid = pid; |
| 1353 | 1341 | ||
| 1354 | // Parent: poll the socket. Animate dots only on a TTY so scripted output is | 1342 | // Parent: poll the socket. Animate dots only on a TTY so scripted output is |
| @@ -1845,8 +1833,8 @@ test "parseArgs: upgrade is a command, and same-version is a flag it takes" { | |||
| 1845 | test "resumeRun: --check adopts nothing, so --resume-fail-at has nothing to abort" { | 1833 | test "resumeRun: --check adopts nothing, so --resume-fail-at has nothing to abort" { |
| 1846 | const alloc = std.testing.allocator; | 1834 | const alloc = std.testing.allocator; |
| 1847 | 1835 | ||
| 1848 | const memfd = try std.posix.memfd_create("mux-resume-check-test", 0); | 1836 | const carrier = try server_os.anonFd("mux-resume-check-test"); |
| 1849 | defer std.posix.close(memfd); | 1837 | defer std.posix.close(carrier); |
| 1850 | 1838 | ||
| 1851 | var buf: std.ArrayList(u8) = .empty; | 1839 | var buf: std.ArrayList(u8) = .empty; |
| 1852 | defer buf.deinit(alloc); | 1840 | defer buf.deinit(alloc); |
| @@ -1865,15 +1853,15 @@ test "resumeRun: --check adopts nothing, so --resume-fail-at has nothing to abor | |||
| 1865 | .quic = .{}, | 1853 | .quic = .{}, |
| 1866 | .counters = .{}, | 1854 | .counters = .{}, |
| 1867 | }, &.{}); | 1855 | }, &.{}); |
| 1868 | var file = std.fs.File{ .handle = memfd }; | 1856 | var file = std.fs.File{ .handle = carrier }; |
| 1869 | try file.writeAll(buf.items); | 1857 | try file.writeAll(buf.items); |
| 1870 | 1858 | ||
| 1871 | const code = try resumeRun(alloc, .{ | 1859 | const code = try resumeRun(alloc, .{ |
| 1872 | ._cmd = .start, | 1860 | ._cmd = .start, |
| 1873 | .check = true, | 1861 | .check = true, |
| 1874 | .resume_fd = memfd, | 1862 | .resume_fd = carrier, |
| 1875 | .resume_fail_at = "daemon", | 1863 | .resume_fail_at = "daemon", |
| 1876 | }, memfd); | 1864 | }, carrier); |
| 1877 | try std.testing.expectEqual(@as(u8, 0), code); | 1865 | try std.testing.expectEqual(@as(u8, 0), code); |
| 1878 | } | 1866 | } |
| 1879 | 1867 | ||
| @@ -2022,10 +2010,10 @@ test "peerPid: the kernel names the peer" { | |||
| 2022 | // Both socketpair endpoints belong to this process, so kernel credentials | 2010 | // Both socketpair endpoints belong to this process, so kernel credentials |
| 2023 | // must report the current pid. | 2011 | // must report the current pid. |
| 2024 | var sp: [2]i32 = undefined; | 2012 | var sp: [2]i32 = undefined; |
| 2025 | try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp)); | 2013 | try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp)); |
| 2026 | defer std.posix.close(sp[0]); | 2014 | defer std.posix.close(sp[0]); |
| 2027 | defer std.posix.close(sp[1]); | 2015 | defer std.posix.close(sp[1]); |
| 2028 | try std.testing.expectEqual(std.os.linux.getpid(), peerPid(sp[0]).?); | 2016 | try std.testing.expectEqual(server_os.getpid(), peerPid(sp[0]).?); |
| 2029 | } | 2017 | } |
| 2030 | 2018 | ||
| 2031 | test "waitPidGone: returns only once the OS has no such process" { | 2019 | test "waitPidGone: returns only once the OS has no such process" { |
| @@ -2102,7 +2090,7 @@ test "start -d: an answering socket is already_running, nothing spawned" { | |||
| 2102 | test "start -d: a socket this process may not reach is not `already running`" { | 2090 | test "start -d: a socket this process may not reach is not `already running`" { |
| 2103 | // chmod does not bite root; as root the connect succeeds and the | 2091 | // chmod does not bite root; as root the connect succeeds and the |
| 2104 | // premise of the test is gone. | 2092 | // premise of the test is gone. |
| 2105 | if (std.os.linux.geteuid() == 0) return error.SkipZigTest; | 2093 | if (std.posix.geteuid() == 0) return error.SkipZigTest; |
| 2106 | 2094 | ||
| 2107 | const testtmp = @import("testtmp"); | 2095 | const testtmp = @import("testtmp"); |
| 2108 | var tmp = try testtmp.TmpDir.make(); | 2096 | var tmp = try testtmp.TmpDir.make(); |
src/cli/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -21,6 +21,7 @@ const wall = @import("wall"); | |||
| 21 | const hosts = @import("client").hosts; | 21 | const hosts = @import("client").hosts; |
| 22 | const layoutfile = @import("client").layoutfile; | 22 | const layoutfile = @import("client").layoutfile; |
| 23 | const cliflags = @import("cliflags"); | 23 | const cliflags = @import("cliflags"); |
| 24 | const client_os = @import("client_os"); | ||
| 24 | const TmpDir = @import("testtmp").TmpDir; | 25 | const TmpDir = @import("testtmp").TmpDir; |
| 25 | 26 | ||
| 26 | /// Root help page for the binary. It lists the mode words first, then documents | 27 | /// Root help page for the binary. It lists the mode words first, then documents |
| @@ -140,9 +141,11 @@ fn agentReachable(path: []const u8) bool { | |||
| 140 | const fd = client.connectAgent(path) orelse return false; | 141 | const fd = client.connectAgent(path) orelse return false; |
| 141 | defer std.posix.close(fd); | 142 | defer std.posix.close(fd); |
| 142 | 143 | ||
| 143 | // Suppress SIGPIPE because this probe runs before the client installs signal | 144 | // Through `client_os.sendNoSig` because this probe runs before the client |
| 144 | // handling and the peer may already have closed the socket. | 145 | // installs signal handling and the peer may already have closed the socket; |
| 145 | _ = std.posix.send(fd, &agent_request_identities, std.posix.MSG.NOSIGNAL) catch return false; | 146 | // the operation's contract is that a closed peer comes back as an error |
| 147 | // rather than as a signal. | ||
| 148 | _ = client_os.sendNoSig(fd, &agent_request_identities) catch return false; | ||
| 146 | 149 | ||
| 147 | var pfd = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }}; | 150 | var pfd = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }}; |
| 148 | const ready = std.posix.poll(&pfd, agent_probe_ms) catch return true; | 151 | const ready = std.posix.poll(&pfd, agent_probe_ms) catch return true; |
src/cli/muxa.zig
| Old | New | ||
|---|---|---|---|
| @@ -648,11 +648,11 @@ test "a re-attach forgets the snapshot it saw, so a refused reconnect is not an | |||
| 648 | const alloc = std.testing.allocator; | 648 | const alloc = std.testing.allocator; |
| 649 | // A socketpair, not a pipe: this conn has to WRITE the attach as well as | 649 | // A socketpair, not a pipe: this conn has to WRITE the attach as well as |
| 650 | // read down the ONE fd it holds, which is the shape under test. Through | 650 | // read down the ONE fd it holds, which is the shape under test. Through |
| 651 | // `std.os.linux` because `std.posix` has no socketpair on 0.15.2. | 651 | // `std.c` because `std.posix` has no socketpair on 0.15.2. |
| 652 | var sp: [2]i32 = undefined; | 652 | var sp: [2]std.posix.fd_t = undefined; |
| 653 | try std.testing.expectEqual( | 653 | try std.testing.expectEqual( |
| 654 | @as(usize, 0), | 654 | @as(c_int, 0), |
| 655 | std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp), | 655 | std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp), |
| 656 | ); | 656 | ); |
| 657 | defer std.posix.close(sp[0]); | 657 | defer std.posix.close(sp[0]); |
| 658 | defer std.posix.close(sp[1]); | 658 | defer std.posix.close(sp[1]); |
| @@ -682,10 +682,10 @@ test "a refusal that closes the socket before the input write is still reported | |||
| 682 | // Reproduce the race where the daemon's rejection frame and close arrive | 682 | // Reproduce the race where the daemon's rejection frame and close arrive |
| 683 | // before the next write, causing BrokenPipe unless the pending frame is | 683 | // before the next write, causing BrokenPipe unless the pending frame is |
| 684 | // drained and classified. | 684 | // drained and classified. |
| 685 | var sp: [2]i32 = undefined; | 685 | var sp: [2]std.posix.fd_t = undefined; |
| 686 | try std.testing.expectEqual( | 686 | try std.testing.expectEqual( |
| 687 | @as(usize, 0), | 687 | @as(c_int, 0), |
| 688 | std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp), | 688 | std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp), |
| 689 | ); | 689 | ); |
| 690 | defer std.posix.close(sp[0]); | 690 | defer std.posix.close(sp[0]); |
| 691 | 691 | ||
| @@ -699,10 +699,10 @@ test "a refusal that closes the socket before the input write is still reported | |||
| 699 | try std.testing.expectError(error.AttachRefused, conn.sendFrame(.input, "x", deadline)); | 699 | try std.testing.expectError(error.AttachRefused, conn.sendFrame(.input, "x", deadline)); |
| 700 | 700 | ||
| 701 | // And the verb's answer is the refusal's JSON, not the write's. | 701 | // And the verb's answer is the refusal's JSON, not the write's. |
| 702 | var sp2: [2]i32 = undefined; | 702 | var sp2: [2]std.posix.fd_t = undefined; |
| 703 | try std.testing.expectEqual( | 703 | try std.testing.expectEqual( |
| 704 | @as(usize, 0), | 704 | @as(c_int, 0), |
| 705 | std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp2), | 705 | std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp2), |
| 706 | ); | 706 | ); |
| 707 | defer std.posix.close(sp2[0]); | 707 | defer std.posix.close(sp2[0]); |
| 708 | try proto.writeFrame(sp2[1], .exit_status, &[_]u8{1}); | 708 | try proto.writeFrame(sp2[1], .exit_status, &[_]u8{1}); |
src/cli/spawn.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,56 +0,0 @@ | |||
| 1 | //! Resolve the executable used when mux starts a daemon process. Following | ||
| 2 | //! `/proc/self/exe` ensures the new process runs the current binary rather than | ||
| 3 | //! another `mux` found through `PATH`. | ||
| 4 | const std = @import("std"); | ||
| 5 | |||
| 6 | /// Kernel link to the running executable, used when the resolved path is no | ||
| 7 | /// longer executable. | ||
| 8 | pub const self_exe = "/proc/self/exe"; | ||
| 9 | |||
| 10 | /// Return the resolved path of the current executable, falling back to | ||
| 11 | /// `/proc/self/exe`. | ||
| 12 | pub fn selfExe(buf: *[std.fs.max_path_bytes]u8) []const u8 { | ||
| 13 | // Prefer the resolved path because process listings derive `comm` from the | ||
| 14 | // filename passed to execve; executing the link would name every daemon | ||
| 15 | // `exe`. | ||
| 16 | return execOrLink(std.fs.selfExePath(buf) catch return self_exe); | ||
| 17 | } | ||
| 18 | |||
| 19 | /// The resolved path if it can still be exec'd, the /proc link if it cannot. | ||
| 20 | /// Split out so the fallback is assertable without deleting a live binary. | ||
| 21 | fn execOrLink(resolved: []const u8) []const u8 { | ||
| 22 | // After `make install`, the resolved path may end in ` (deleted)` and no | ||
| 23 | // longer be executable even though readlink succeeded. | ||
| 24 | std.posix.access(resolved, std.posix.X_OK) catch return self_exe; | ||
| 25 | return resolved; | ||
| 26 | } | ||
| 27 | |||
| 28 | // --------------------------------------------------------------------------- | ||
| 29 | |||
| 30 | test "selfExe: the exec'd name is a real file, not the /proc link" { | ||
| 31 | var buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 32 | const exe = selfExe(&buf); | ||
| 33 | // The resolved basename becomes the process name shown by tools such as | ||
| 34 | // `ps`, `pgrep`, and `killall`. | ||
| 35 | try std.testing.expect(!std.mem.eql(u8, exe, self_exe)); | ||
| 36 | try std.posix.access(exe, std.posix.X_OK); | ||
| 37 | } | ||
| 38 | |||
| 39 | test "selfExe: a resolved path that is no longer a file falls back to the link" { | ||
| 40 | // What `make install` does to a running wall. Spelled as the suffix the | ||
| 41 | // kernel actually appends, because that is the string this must survive. | ||
| 42 | var buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 43 | const live = try std.fs.selfExePath(&buf); | ||
| 44 | try std.testing.expectEqualStrings(live, execOrLink(live)); | ||
| 45 | |||
| 46 | var gone: [std.fs.max_path_bytes]u8 = undefined; | ||
| 47 | const deleted = try std.fmt.bufPrint(&gone, "{s} (deleted)", .{live}); | ||
| 48 | try std.testing.expectEqualStrings(self_exe, execOrLink(deleted)); | ||
| 49 | } | ||
| 50 | |||
| 51 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 52 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 53 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | ||
| 54 | test { | ||
| 55 | std.testing.refAllDeclsRecursive(@This()); | ||
| 56 | } | ||
src/cli/webhub_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -10,6 +10,7 @@ | |||
| 10 | 10 | ||
| 11 | const std = @import("std"); | 11 | const std = @import("std"); |
| 12 | const client = @import("client"); | 12 | const client = @import("client"); |
| 13 | const client_os = @import("client_os"); | ||
| 13 | const webhub = @import("webhub"); | 14 | const webhub = @import("webhub"); |
| 14 | const hosts = @import("client").hosts; | 15 | const hosts = @import("client").hosts; |
| 15 | const build_options = @import("build_options"); | 16 | const build_options = @import("build_options"); |
| @@ -173,7 +174,7 @@ pub fn main(args: []const [:0]const u8) !u8 { | |||
| 173 | // `Hub.birth` above, one per pane, as the layout was read. | 174 | // `Hub.birth` above, one per pane, as the layout was read. |
| 174 | std.debug.print("mux web: serving http://127.0.0.1:{d} pid={d}\n", .{ | 175 | std.debug.print("mux web: serving http://127.0.0.1:{d} pid={d}\n", .{ |
| 175 | parsed.port, | 176 | parsed.port, |
| 176 | std.os.linux.getpid(), | 177 | client_os.getpid(), |
| 177 | }); | 178 | }); |
| 178 | hub.start(); | 179 | hub.start(); |
| 179 | 180 | ||
src/client/askpass.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,7 @@ const std = @import("std"); | |||
| 12 | // inside the struct the bare name is ambiguous. | 12 | // inside the struct the bare name is ambiguous. |
| 13 | const serve_mod = @import("serve"); | 13 | const serve_mod = @import("serve"); |
| 14 | const xdg = @import("xdg"); | 14 | const xdg = @import("xdg"); |
| 15 | const client_os = @import("client_os"); | ||
| 15 | 16 | ||
| 16 | /// Env var naming the socket. The mode word for the helper, too: ssh execs | 17 | /// Env var naming the socket. The mode word for the helper, too: ssh execs |
| 17 | /// its helper with the prompt as argv[1] and nothing else, so there is no | 18 | /// its helper with the prompt as argv[1] and nothing else, so there is no |
| @@ -152,7 +153,7 @@ pub const Listener = struct { | |||
| 152 | const path = try std.fmt.allocPrint( | 153 | const path = try std.fmt.allocPrint( |
| 153 | alloc, | 154 | alloc, |
| 154 | "{s}/mux-ask-{d}.sock", | 155 | "{s}/mux-ask-{d}.sock", |
| 155 | .{ runtime_dir, std.os.linux.getpid() }, | 156 | .{ runtime_dir, client_os.getpid() }, |
| 156 | ); | 157 | ); |
| 157 | errdefer alloc.free(path); | 158 | errdefer alloc.free(path); |
| 158 | // Every wall that died by signal — a closed terminal window, a kill | 159 | // Every wall that died by signal — a closed terminal window, a kill |
| @@ -296,12 +297,10 @@ pub const Listener = struct { | |||
| 296 | } | 297 | } |
| 297 | 298 | ||
| 298 | fn serve(self: *Listener, c: std.posix.socket_t) void { | 299 | fn serve(self: *Listener, c: std.posix.socket_t) void { |
| 299 | const cred = peerCred(c) orelse return; | 300 | const cred = client_os.peerCred(c) orelse return; |
| 300 | // The 0700 runtime directory is the boundary, and mux takes | 301 | // Both checks are `client_os.peerCred`'s to explain. |
| 301 | // `$XDG_RUNTIME_DIR` as found. Where it is not private, THIS line stops | 302 | if (cred.uid != client_os.geteuid()) return; |
| 302 | // another local user raising a prompt and reading the answer. | 303 | var p: Prompt = .{ .ssh_pid = dialOwner(cred.pid, client_os.getpid(), client_os.parentOf) }; |
| 303 | if (cred.uid != std.os.linux.geteuid()) return; | ||
| 304 | var p: Prompt = .{ .ssh_pid = dialOwner(cred.pid, std.os.linux.getpid(), parentOf) }; | ||
| 305 | var raw: [prompt_max + 1]u8 = undefined; | 304 | var raw: [prompt_max + 1]u8 = undefined; |
| 306 | // Bounded, because an accept thread parked in `read` is every later | 305 | // Bounded, because an accept thread parked in `read` is every later |
| 307 | // prompt of this wall parked behind it — and `stop`'s join with it. | 306 | // prompt of this wall parked behind it — and `stop`'s join with it. |
| @@ -467,23 +466,10 @@ fn writeAll(fd: std.posix.fd_t, bytes: []const u8) bool { | |||
| 467 | return true; | 466 | return true; |
| 468 | } | 467 | } |
| 469 | 468 | ||
| 470 | const Ucred = extern struct { | ||
| 471 | pid: std.posix.pid_t, | ||
| 472 | uid: std.posix.uid_t, | ||
| 473 | gid: std.posix.gid_t, | ||
| 474 | }; | ||
| 475 | |||
| 476 | /// How far up the tree the walk goes. ssh execs its helper directly, so | 469 | /// How far up the tree the walk goes. ssh execs its helper directly, so |
| 477 | /// production is one step; the slack is for a shell in between. | 470 | /// production is one step; the slack is for a shell in between. |
| 478 | const ancestor_max = 8; | 471 | const ancestor_max = 8; |
| 479 | 472 | ||
| 480 | /// Who is on the other end, or null when the kernel will not say. | ||
| 481 | fn peerCred(c: std.posix.socket_t) ?Ucred { | ||
| 482 | var buf: [@sizeOf(Ucred)]u8 = undefined; | ||
| 483 | std.posix.getsockopt(c, std.posix.SOL.SOCKET, std.posix.SO.PEERCRED, &buf) catch return null; | ||
| 484 | return std.mem.bytesToValue(Ucred, &buf); | ||
| 485 | } | ||
| 486 | |||
| 487 | /// The ssh THIS process spawned that is behind `peer`: the ancestor whose | 473 | /// The ssh THIS process spawned that is behind `peer`: the ancestor whose |
| 488 | /// parent is us. | 474 | /// parent is us. |
| 489 | fn dialOwner( | 475 | fn dialOwner( |
| @@ -506,26 +492,6 @@ fn dialOwner( | |||
| 506 | return 0; | 492 | return 0; |
| 507 | } | 493 | } |
| 508 | 494 | ||
| 509 | /// `/proc/<pid>/stat` field 4. Parsed from the LAST ')' rather than by | ||
| 510 | /// counting spaces: field 2 is the executable's name, unquoted, and a | ||
| 511 | /// program free to call itself `a b) c` is a program free to move every | ||
| 512 | /// field after it. | ||
| 513 | fn parentOf(pid: std.posix.pid_t) std.posix.pid_t { | ||
| 514 | if (pid <= 0) return 0; | ||
| 515 | var path_buf: [64]u8 = undefined; | ||
| 516 | const path = std.fmt.bufPrint(&path_buf, "/proc/{d}/stat", .{pid}) catch return 0; | ||
| 517 | var stat_buf: [512]u8 = undefined; | ||
| 518 | const f = std.fs.cwd().openFile(path, .{}) catch return 0; | ||
| 519 | defer f.close(); | ||
| 520 | const n = f.read(&stat_buf) catch return 0; | ||
| 521 | const text = stat_buf[0..n]; | ||
| 522 | const close = std.mem.lastIndexOfScalar(u8, text, ')') orelse return 0; | ||
| 523 | var it = std.mem.tokenizeScalar(u8, text[close + 1 ..], ' '); | ||
| 524 | _ = it.next() orelse return 0; // the run state | ||
| 525 | const ppid = it.next() orelse return 0; | ||
| 526 | return std.fmt.parseInt(std.posix.pid_t, ppid, 10) catch 0; | ||
| 527 | } | ||
| 528 | |||
| 529 | // ---- tests ---- | 495 | // ---- tests ---- |
| 530 | 496 | ||
| 531 | const testtmp = @import("testtmp"); | 497 | const testtmp = @import("testtmp"); |
| @@ -727,14 +693,6 @@ test "askpass: a helper two shells below the ssh we spawned is still that ssh's" | |||
| 727 | try std.testing.expectEqual(@as(std.posix.pid_t, 0), dialOwner(1, 7, FakeTree.ring)); | 693 | try std.testing.expectEqual(@as(std.posix.pid_t, 0), dialOwner(1, 7, FakeTree.ring)); |
| 728 | } | 694 | } |
| 729 | 695 | ||
| 730 | test "askpass.parentOf: the field it reads is the one the OS calls ppid" { | ||
| 731 | // The reader, against the OS itself rather than against a fixture — | ||
| 732 | // `/proc/<pid>/stat` field 4 is positional, and a comment claiming | ||
| 733 | // which field that is cannot fail. | ||
| 734 | try std.testing.expectEqual(std.os.linux.getppid(), parentOf(std.os.linux.getpid())); | ||
| 735 | try std.testing.expectEqual(@as(std.posix.pid_t, 0), parentOf(0)); | ||
| 736 | } | ||
| 737 | |||
| 738 | test "askpass.Listener: a helper that is not a child of ours is attributed to nothing" { | 696 | test "askpass.Listener: a helper that is not a child of ours is attributed to nothing" { |
| 739 | const alloc = std.testing.allocator; | 697 | const alloc = std.testing.allocator; |
| 740 | var tmp = try testtmp.TmpDir.make(); | 698 | var tmp = try testtmp.TmpDir.make(); |
src/client/client.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,7 +12,6 @@ const proto = @import("term").protocol; | |||
| 12 | const TmpDir = @import("testtmp").TmpDir; | 12 | const TmpDir = @import("testtmp").TmpDir; |
| 13 | const quic = @import("quic"); | 13 | const quic = @import("quic"); |
| 14 | const xdg = @import("xdg"); | 14 | const xdg = @import("xdg"); |
| 15 | const sockpath = @import("sockpath"); | ||
| 16 | // The daemon-socket dial, under a name that is not `dial`: `Transport.open` | 15 | // The daemon-socket dial, under a name that is not `dial`: `Transport.open` |
| 17 | // already takes a `dial: ?*handoff.Dial` parameter, and a parameter that | 16 | // already takes a `dial: ?*handoff.Dial` parameter, and a parameter that |
| 18 | // shadows a file-scope declaration does not compile. | 17 | // shadows a file-scope declaration does not compile. |
| @@ -2379,8 +2378,10 @@ test "openHandoff: the handoff ssh's stderr is a pipe, and only `narrate` relays | |||
| 2379 | // A hosts line naming a box that is down used to put ssh's `No route to | 2378 | // A hosts line naming a box that is down used to put ssh's `No route to |
| 2380 | // host` onto the wall's alternate screen every poll, because the child's | 2379 | // host` onto the wall's alternate screen every poll, because the child's |
| 2381 | // stderr was INHERITED. It is a pipe mux reads now, whoever dialled. The | 2380 | // stderr was INHERITED. It is a pipe mux reads now, whoever dialled. The |
| 2382 | // fake records where its stderr POINTED, off `/proc/$$/fd/2`, so "piped" is | 2381 | // fake records where its stderr POINTED, off `/dev/fd/2`, so "piped" is |
| 2383 | // exact. BOTH values of `asked`, because the rule is the spawn's. | 2382 | // exact — `/dev/fd` because every OS this builds for has it and the |
| 2383 | // Linux-only spelling would have to be ported alongside the test. | ||
| 2384 | // BOTH values of `asked`, because the rule is the spawn's. | ||
| 2384 | const alloc = std.testing.allocator; | 2385 | const alloc = std.testing.allocator; |
| 2385 | var stdin = try FakeStdin.install(""); | 2386 | var stdin = try FakeStdin.install(""); |
| 2386 | defer stdin.deinit(); | 2387 | defer stdin.deinit(); |
| @@ -2393,7 +2394,7 @@ test "openHandoff: the handoff ssh's stderr is a pipe, and only `narrate` relays | |||
| 2393 | 2394 | ||
| 2394 | var script_buf: [1024]u8 = undefined; | 2395 | var script_buf: [1024]u8 = undefined; |
| 2395 | const script = try std.fmt.bufPrint(&script_buf, | 2396 | const script = try std.fmt.bufPrint(&script_buf, |
| 2396 | \\readlink /proc/$$/fd/2 > {[d]s}/e | 2397 | \\readlink /dev/fd/2 > {[d]s}/e |
| 2397 | \\printf 'boom: no route\n' >&2 | 2398 | \\printf 'boom: no route\n' >&2 |
| 2398 | \\exit 1 | 2399 | \\exit 1 |
| 2399 | , .{ .d = tmp.path() }); | 2400 | , .{ .d = tmp.path() }); |
src/dial.zig
| Old | New | ||
|---|---|---|---|
| @@ -135,8 +135,8 @@ test "detach writes one empty frame and waits for nothing" { | |||
| 135 | // Pinned on a socketpair because the goodbye has no reply to wait for: | 135 | // Pinned on a socketpair because the goodbye has no reply to wait for: |
| 136 | // what is checkable is that exactly one empty `detach` reaches the peer | 136 | // what is checkable is that exactly one empty `detach` reaches the peer |
| 137 | // and the call returns without reading anything back. | 137 | // and the call returns without reading anything back. |
| 138 | var pair: [2]i32 = undefined; | 138 | var pair: [2]std.posix.fd_t = undefined; |
| 139 | try std.testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); | 139 | try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); |
| 140 | defer std.posix.close(pair[1]); | 140 | defer std.posix.close(pair[1]); |
| 141 | 141 | ||
| 142 | try detach(pair[0]); | 142 | try detach(pair[0]); |
src/engine/protocol.zig
| Old | New | ||
|---|---|---|---|
| @@ -917,7 +917,7 @@ pub const SessionsIter = struct { | |||
| 917 | /// paints as nothing. | 917 | /// paints as nothing. |
| 918 | pub const sessions_meta_prefix = "# mux "; | 918 | pub const sessions_meta_prefix = "# mux "; |
| 919 | /// `stale` is the daemon reporting that the installed binary on its OWN box | 919 | /// `stale` is the daemon reporting that the installed binary on its OWN box |
| 920 | /// was replaced under it (its /proc/self/exe reads deleted): the one drift a | 920 | /// was replaced under it (`server_os.selfImageStale`): the one drift a |
| 921 | /// version string cannot show, because two builds of one dev version spell | 921 | /// version string cannot show, because two builds of one dev version spell |
| 922 | /// the same version. | 922 | /// the same version. |
| 923 | pub const sessions_meta_stale_word = " stale"; | 923 | pub const sessions_meta_stale_word = " stale"; |
| @@ -2569,9 +2569,9 @@ test "agentDataOversize draws the line at one full frame" { | |||
| 2569 | /// std.posix has no socketpair on the pinned 0.15.2, and these two tests | 2569 | /// std.posix has no socketpair on the pinned 0.15.2, and these two tests |
| 2570 | /// need a socket whose peer they can refuse to read. | 2570 | /// need a socket whose peer they can refuse to read. |
| 2571 | fn testSocketPair() ![2]std.posix.fd_t { | 2571 | fn testSocketPair() ![2]std.posix.fd_t { |
| 2572 | var fds: [2]i32 = undefined; | 2572 | var fds: [2]std.posix.fd_t = undefined; |
| 2573 | const rc = std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &fds); | 2573 | const rc = std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &fds); |
| 2574 | if (std.posix.errno(rc) != .SUCCESS) return error.SocketPairFailed; | 2574 | if (rc != 0) return error.SocketPairFailed; |
| 2575 | return fds; | 2575 | return fds; |
| 2576 | } | 2576 | } |
| 2577 | 2577 | ||
src/link.zig
| Old | New | ||
|---|---|---|---|
| @@ -304,8 +304,8 @@ pub const Link = union(enum) { | |||
| 304 | const testing = std.testing; | 304 | const testing = std.testing; |
| 305 | 305 | ||
| 306 | fn mkPair() ![2]std.posix.fd_t { | 306 | fn mkPair() ![2]std.posix.fd_t { |
| 307 | var pair: [2]i32 = undefined; | 307 | var pair: [2]std.posix.fd_t = undefined; |
| 308 | try testing.expectEqual(@as(usize, 0), std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); | 308 | try testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &pair)); |
| 309 | return .{ pair[0], pair[1] }; | 309 | return .{ pair[0], pair[1] }; |
| 310 | } | 310 | } |
| 311 | 311 | ||
src/os/client_os.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,169 @@ | |||
| 1 | //! The wall's and askpass's platform layer: the few calls the client side | ||
| 2 | //! makes that differ by OS. Same shape as `server_os` — this root is the | ||
| 3 | //! contract, a child per OS spells it — and deliberately a SEPARATE row: | ||
| 4 | //! the client never links a fork or a pty, and an app that links the | ||
| 5 | //! engine and a client must not either. | ||
| 6 | const std = @import("std"); | ||
| 7 | const builtin = @import("builtin"); | ||
| 8 | |||
| 9 | pub const impl = switch (builtin.os.tag) { | ||
| 10 | .linux => @import("client_os_linux.zig"), | ||
| 11 | else => @compileError("mux has no client platform arm for " ++ @tagName(builtin.os.tag)), | ||
| 12 | }; | ||
| 13 | |||
| 14 | /// This process's pid, for `mux-ask-PID.sock` and the hub's banner. | ||
| 15 | pub fn getpid() std.posix.pid_t { | ||
| 16 | return impl.getpid(); | ||
| 17 | } | ||
| 18 | |||
| 19 | /// Who is on the other end of the askpass socket. The 0700 runtime | ||
| 20 | /// directory is the boundary and mux takes it as found; where it is not | ||
| 21 | /// private, the uid here is what stops another local user raising a prompt | ||
| 22 | /// and reading the answer, and the pid is what attributes a prompt to the | ||
| 23 | /// ssh THIS wall spawned. | ||
| 24 | /// | ||
| 25 | /// Unlike `server_os.peerCred`, a pid of 0 — what a kernel reports for a | ||
| 26 | /// peer it cannot name — passes through this root unjudged, because the | ||
| 27 | /// consumer already has the rule: `askpass.dialOwner` walks up from the | ||
| 28 | /// peer under `at > 0`, so a 0 ends the walk without matching anything. | ||
| 29 | /// Rejecting it here as well would be a second copy of one rule. | ||
| 30 | pub const PeerCred = struct { uid: std.posix.uid_t, pid: std.posix.pid_t }; | ||
| 31 | pub fn peerCred(fd: std.posix.socket_t) ?PeerCred { | ||
| 32 | return impl.peerCred(fd); | ||
| 33 | } | ||
| 34 | |||
| 35 | /// A BLOCKING send that cannot raise SIGPIPE. `server_os.sendNoSigNoWait` | ||
| 36 | /// is the daemon's twin and is NON-blocking, because a stalled client must | ||
| 37 | /// never stall the pump; this one waits for room, because its caller is the | ||
| 38 | /// agent probe, which writes five bytes and then polls for the answer. The | ||
| 39 | /// daemon's name carries the difference so neither side is reached for by | ||
| 40 | /// habit. | ||
| 41 | /// | ||
| 42 | /// The signal half is the reason the operation exists here at all. The | ||
| 43 | /// probe runs before the client installs any signal handling, and the peer | ||
| 44 | /// may already have closed: the daemon accepts a forwarded agent socket | ||
| 45 | /// and only then closes it when no attached client is offering an agent. | ||
| 46 | /// A send that signalled would end the client outright instead of handing | ||
| 47 | /// back BrokenPipe for the probe to read as "no agent". | ||
| 48 | pub fn sendNoSig(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!usize { | ||
| 49 | return impl.sendNoSig(fd, bytes); | ||
| 50 | } | ||
| 51 | |||
| 52 | /// The parent of `pid`, or 0 when the OS will not say or `pid` is not | ||
| 53 | /// positive. One step of the walk from an askpass helper up to the ssh a | ||
| 54 | /// dial spawned. | ||
| 55 | pub fn parentOf(pid: std.posix.pid_t) std.posix.pid_t { | ||
| 56 | if (pid <= 0) return 0; | ||
| 57 | return impl.parentOf(pid); | ||
| 58 | } | ||
| 59 | |||
| 60 | /// The effective uid, for the askpass caller check above. | ||
| 61 | pub fn geteuid() std.posix.uid_t { | ||
| 62 | return impl.geteuid(); | ||
| 63 | } | ||
| 64 | |||
| 65 | /// This terminal's size, or null when `fd` is not a terminal. The 0x0 case | ||
| 66 | /// and the daemon's floor are the caller's to judge (`interact.ttySize`). | ||
| 67 | pub fn winSize(fd: std.posix.fd_t) ?std.posix.winsize { | ||
| 68 | return impl.winSize(fd); | ||
| 69 | } | ||
| 70 | |||
| 71 | /// Size a pty. Test-only in practice, but a contract because the wall's | ||
| 72 | /// own `ttySize` is judged against it. | ||
| 73 | pub fn setWinSize(fd: std.posix.fd_t, ws: std.posix.winsize) error{Unsupported}!void { | ||
| 74 | return impl.setWinSize(fd, ws); | ||
| 75 | } | ||
| 76 | |||
| 77 | /// A real master/slave pty pair, the OS answering about the OS. Named here | ||
| 78 | /// rather than returned anonymously because an anonymous struct in the root | ||
| 79 | /// and one in an arm are two distinct types, and the arm could then never | ||
| 80 | /// satisfy the contract. | ||
| 81 | pub const PtyPair = struct { master: std.posix.fd_t, slave: std.posix.fd_t }; | ||
| 82 | |||
| 83 | /// Declared ONLY in a test binary, so this row's header stays true of every | ||
| 84 | /// shipped build: the wall never opens a pty, it lives in one, and a client | ||
| 85 | /// that could open one is a client an app might link a pty through. The two | ||
| 86 | /// callers that need a real terminal to size — this file's own test and | ||
| 87 | /// `interact.ptsPair` — are reached only from test blocks, and a test build | ||
| 88 | /// is the compilation where `builtin.is_test` holds and this decl exists. | ||
| 89 | /// A production line that reached for it gets the message below instead. | ||
| 90 | pub const openPtyPair = if (builtin.is_test) impl.openPtyPair else @compileError( | ||
| 91 | "client_os.openPtyPair is test-only: the client side links no pty", | ||
| 92 | ); | ||
| 93 | |||
| 94 | test "client_os: the arm compiles and answers for the process it is in" { | ||
| 95 | try std.testing.expect(getpid() > 0); | ||
| 96 | } | ||
| 97 | |||
| 98 | test "client_os.peerCred and parentOf: asked of the OS, not a fixture" { | ||
| 99 | // Both socketpair ends belong to this process, so the kernel must name | ||
| 100 | // it; and `parentOf` is graded against the ppid the OS itself reports, | ||
| 101 | // because the field it reads is positional and a comment naming that | ||
| 102 | // field cannot fail. | ||
| 103 | var sp: [2]std.posix.fd_t = undefined; | ||
| 104 | try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp)); | ||
| 105 | defer std.posix.close(sp[0]); | ||
| 106 | defer std.posix.close(sp[1]); | ||
| 107 | const cred = peerCred(sp[0]) orelse return error.NoCred; | ||
| 108 | try std.testing.expectEqual(getpid(), cred.pid); | ||
| 109 | try std.testing.expectEqual(geteuid(), cred.uid); | ||
| 110 | try std.testing.expectEqual(std.c.getppid(), parentOf(getpid())); | ||
| 111 | try std.testing.expectEqual(@as(std.posix.pid_t, 0), parentOf(0)); | ||
| 112 | } | ||
| 113 | |||
| 114 | test "client_os.winSize reads what setWinSize wrote, off a real pty" { | ||
| 115 | const p = try openPtyPair(); | ||
| 116 | defer std.posix.close(p.master); | ||
| 117 | defer std.posix.close(p.slave); | ||
| 118 | try setWinSize(p.master, .{ .row = 17, .col = 91, .xpixel = 0, .ypixel = 0 }); | ||
| 119 | const ws = winSize(p.slave) orelse return error.NoSize; | ||
| 120 | try std.testing.expectEqual(@as(u16, 91), ws.col); | ||
| 121 | try std.testing.expectEqual(@as(u16, 17), ws.row); | ||
| 122 | } | ||
| 123 | |||
| 124 | test "client_os.sendNoSig: a closed peer is an error, not a signal" { | ||
| 125 | // Judged in a CHILD, because this process cannot be asked. Zig's own | ||
| 126 | // startup code installs a no-op SIGPIPE handler in every binary it | ||
| 127 | // starts, the test runner included, so a plain send with no | ||
| 128 | // MSG_NOSIGNAL also returns BrokenPipe here — a test written in this | ||
| 129 | // process stays green with the flag deleted, which is the one mistake | ||
| 130 | // it exists to catch. The child puts SIGPIPE back at SIG_DFL first, so | ||
| 131 | // a send that raises the signal DIES and the parent reads a status that | ||
| 132 | // never exited. | ||
| 133 | // | ||
| 134 | // A raw fork rather than the server row's `forkPty`: this row links no | ||
| 135 | // pty and no fork by design, and build.zig's folder rule 6 reads only | ||
| 136 | // production lines, so the call is legal exactly here. The price is | ||
| 137 | // that the child inherits fd 1 and fd 2, and fd 1 is the build runner's | ||
| 138 | // protocol stream, which one stray byte wedges — so pointing both at | ||
| 139 | // /dev/null is the first thing the child does, before anything that | ||
| 140 | // could print. | ||
| 141 | const pid = try std.posix.fork(); | ||
| 142 | if (pid == 0) { | ||
| 143 | const devnull = std.posix.open("/dev/null", .{ .ACCMODE = .RDWR }, 0) catch std.c._exit(2); | ||
| 144 | std.posix.dup2(devnull, std.posix.STDOUT_FILENO) catch std.c._exit(2); | ||
| 145 | std.posix.dup2(devnull, std.posix.STDERR_FILENO) catch std.c._exit(2); | ||
| 146 | var dfl: std.posix.Sigaction = .{ | ||
| 147 | .handler = .{ .handler = std.posix.SIG.DFL }, | ||
| 148 | .mask = std.posix.sigemptyset(), | ||
| 149 | .flags = 0, | ||
| 150 | }; | ||
| 151 | std.posix.sigaction(std.posix.SIG.PIPE, &dfl, null); | ||
| 152 | var csp: [2]std.posix.fd_t = undefined; | ||
| 153 | if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &csp) != 0) std.c._exit(2); | ||
| 154 | std.posix.close(csp[1]); | ||
| 155 | _ = sendNoSig(csp[0], "x") catch |e| std.c._exit(if (e == error.BrokenPipe) 0 else 2); | ||
| 156 | // A send that SUCCEEDED to a closed peer is as wrong as one that | ||
| 157 | // signalled, and neither is 0. `_exit` rather than an exit that runs | ||
| 158 | // atexit handlers: this child is a copy of a test runner mid-run and | ||
| 159 | // must flush nothing of its parent's. | ||
| 160 | std.c._exit(2); | ||
| 161 | } | ||
| 162 | const status = std.posix.waitpid(pid, 0).status; | ||
| 163 | try std.testing.expect(std.posix.W.IFEXITED(status)); | ||
| 164 | try std.testing.expectEqual(@as(u32, 0), std.posix.W.EXITSTATUS(status)); | ||
| 165 | } | ||
| 166 | |||
| 167 | test { | ||
| 168 | std.testing.refAllDeclsRecursive(@This()); | ||
| 169 | } | ||
src/os/client_os_linux.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,64 @@ | |||
| 1 | //! Linux arm of `client_os`. Spellings only; the contract is in the root. | ||
| 2 | const std = @import("std"); | ||
| 3 | const root = @import("client_os.zig"); | ||
| 4 | |||
| 5 | pub fn getpid() std.posix.pid_t { | ||
| 6 | return std.os.linux.getpid(); | ||
| 7 | } | ||
| 8 | |||
| 9 | pub fn peerCred(fd: std.posix.socket_t) ?root.PeerCred { | ||
| 10 | const Ucred = extern struct { pid: std.posix.pid_t, uid: std.posix.uid_t, gid: std.posix.gid_t }; | ||
| 11 | var cred: Ucred = undefined; | ||
| 12 | std.posix.getsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.PEERCRED, std.mem.asBytes(&cred)) catch return null; | ||
| 13 | return .{ .uid = cred.uid, .pid = cred.pid }; | ||
| 14 | } | ||
| 15 | |||
| 16 | pub fn sendNoSig(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!usize { | ||
| 17 | return std.posix.send(fd, bytes, std.posix.MSG.NOSIGNAL); | ||
| 18 | } | ||
| 19 | |||
| 20 | /// `/proc/<pid>/stat` field 4. Parsed from the LAST ')' rather than by | ||
| 21 | /// counting spaces: field 2 is the executable's name, unquoted, and a | ||
| 22 | /// program free to call itself `a b) c` is a program free to move every | ||
| 23 | /// field after it. | ||
| 24 | pub fn parentOf(pid: std.posix.pid_t) std.posix.pid_t { | ||
| 25 | var path_buf: [64]u8 = undefined; | ||
| 26 | const path = std.fmt.bufPrint(&path_buf, "/proc/{d}/stat", .{pid}) catch return 0; | ||
| 27 | var stat_buf: [512]u8 = undefined; | ||
| 28 | const f = std.fs.cwd().openFile(path, .{}) catch return 0; | ||
| 29 | defer f.close(); | ||
| 30 | const n = f.read(&stat_buf) catch return 0; | ||
| 31 | const text = stat_buf[0..n]; | ||
| 32 | const close = std.mem.lastIndexOfScalar(u8, text, ')') orelse return 0; | ||
| 33 | var it = std.mem.tokenizeScalar(u8, text[close + 1 ..], ' '); | ||
| 34 | _ = it.next() orelse return 0; // the run state | ||
| 35 | const ppid = it.next() orelse return 0; | ||
| 36 | return std.fmt.parseInt(std.posix.pid_t, ppid, 10) catch 0; | ||
| 37 | } | ||
| 38 | |||
| 39 | pub fn geteuid() std.posix.uid_t { | ||
| 40 | return std.os.linux.geteuid(); | ||
| 41 | } | ||
| 42 | |||
| 43 | pub fn winSize(fd: std.posix.fd_t) ?std.posix.winsize { | ||
| 44 | var ws: std.posix.winsize = undefined; | ||
| 45 | if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null; | ||
| 46 | return ws; | ||
| 47 | } | ||
| 48 | |||
| 49 | pub fn setWinSize(fd: std.posix.fd_t, ws: std.posix.winsize) error{Unsupported}!void { | ||
| 50 | if (std.os.linux.ioctl(fd, std.os.linux.T.IOCSWINSZ, @intFromPtr(&ws)) != 0) return error.Unsupported; | ||
| 51 | } | ||
| 52 | |||
| 53 | pub fn openPtyPair() error{Unsupported}!root.PtyPair { | ||
| 54 | const master = std.posix.open("/dev/ptmx", .{ .ACCMODE = .RDWR }, 0) catch return error.Unsupported; | ||
| 55 | errdefer std.posix.close(master); | ||
| 56 | var unlock: c_int = 0; | ||
| 57 | if (std.os.linux.ioctl(master, std.os.linux.T.IOCSPTLCK, @intFromPtr(&unlock)) != 0) return error.Unsupported; | ||
| 58 | var idx: c_uint = 0; | ||
| 59 | if (std.os.linux.ioctl(master, std.os.linux.T.IOCGPTN, @intFromPtr(&idx)) != 0) return error.Unsupported; | ||
| 60 | var name_buf: [32]u8 = undefined; | ||
| 61 | const name = std.fmt.bufPrint(&name_buf, "/dev/pts/{d}", .{idx}) catch return error.Unsupported; | ||
| 62 | const slave = std.posix.open(name, .{ .ACCMODE = .RDWR, .NOCTTY = true }, 0) catch return error.Unsupported; | ||
| 63 | return .{ .master = master, .slave = slave }; | ||
| 64 | } | ||
src/os/server_os.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,410 @@ | |||
| 1 | //! The daemon's platform layer: every call whose spelling or existence | ||
| 2 | //! differs by OS, behind one name each. This root is the CONTRACT — a doc | ||
| 3 | //! comment per operation says what it guarantees and which failure it | ||
| 4 | //! prevents — and a child per OS spells the syscalls. A build for an OS | ||
| 5 | //! with no child is a compile error here, never a runtime surprise. | ||
| 6 | //! | ||
| 7 | //! Imports nothing of ours: the daemon, the pty and the CLI entry import | ||
| 8 | //! this, and folder rule 7 (build.zig) bans the raw spellings everywhere | ||
| 9 | //! else, so a new Linux-ism has one place to go. | ||
| 10 | const std = @import("std"); | ||
| 11 | const builtin = @import("builtin"); | ||
| 12 | |||
| 13 | pub const impl = switch (builtin.os.tag) { | ||
| 14 | .linux => @import("server_os_linux.zig"), | ||
| 15 | else => @compileError("mux has no server platform arm for " ++ @tagName(builtin.os.tag)), | ||
| 16 | }; | ||
| 17 | |||
| 18 | /// This process's pid, for the pid-named directories the daemon's | ||
| 19 | /// successor reaps (`xdg.reapDeadPid`). | ||
| 20 | pub fn getpid() std.posix.pid_t { | ||
| 21 | return impl.getpid(); | ||
| 22 | } | ||
| 23 | |||
| 24 | /// Who is on the other end of a unix socket, or null when the kernel will | ||
| 25 | /// not say (across a pid namespace, for one); callers then rely on socket | ||
| 26 | /// shutdown. The daemon uses the pid to wait for a client that vanished. | ||
| 27 | /// "Will not say" is THIS root's rule and not an arm's: a kernel that | ||
| 28 | /// answers at all still reports a pid of 0 for a peer it cannot name, so | ||
| 29 | /// the non-positive pid is rejected here and an arm returns what it read. | ||
| 30 | pub const PeerCred = struct { uid: std.posix.uid_t, pid: std.posix.pid_t }; | ||
| 31 | pub fn peerCred(fd: std.posix.socket_t) ?PeerCred { | ||
| 32 | const cred = impl.peerCred(fd) orelse return null; | ||
| 33 | if (cred.pid <= 0) return null; | ||
| 34 | return cred; | ||
| 35 | } | ||
| 36 | |||
| 37 | /// A NON-BLOCKING send that cannot raise SIGPIPE: a client that hung up | ||
| 38 | /// mid-frame is an error the pump handles, never a signal that ends the | ||
| 39 | /// daemon, and a client that stopped reading must not stall the pump | ||
| 40 | /// either. The daemon also ignores SIGPIPE process-wide; this is the half | ||
| 41 | /// that does not depend on the order of that ignore against a fork. | ||
| 42 | /// The name says NoWait because `client_os.sendNoSig` is the client-side | ||
| 43 | /// operation and it BLOCKS: the two differ in that one respect, and a | ||
| 44 | /// shared name would let a caller that moved between them assume the | ||
| 45 | /// other's behaviour. | ||
| 46 | pub fn sendNoSigNoWait(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!usize { | ||
| 47 | return impl.sendNoSigNoWait(fd, bytes); | ||
| 48 | } | ||
| 49 | |||
| 50 | /// The socket type of an fd, for refusing to adopt a stream fd as the | ||
| 51 | /// QUIC listener across an upgrade: a stream fd would accept a handshake | ||
| 52 | /// and then lose every packet to recvfrom. | ||
| 53 | pub fn sockType(fd: std.posix.fd_t) error{NotASocket}!u32 { | ||
| 54 | return impl.sockType(fd); | ||
| 55 | } | ||
| 56 | |||
| 57 | pub const Winsize = std.posix.winsize; | ||
| 58 | pub const ForkedPty = struct { pid: std.posix.pid_t, master: std.posix.fd_t }; | ||
| 59 | |||
| 60 | /// Fork with a fresh pty as the child's controlling terminal, sized before | ||
| 61 | /// the shell's first read so no program sees a 0x0 grid. Returns pid 0 in | ||
| 62 | /// the child, exactly as forkpty(3) does, so the child code that resets | ||
| 63 | /// signals and injects env stays where the fork is visible (pty.zig). | ||
| 64 | pub fn forkPty(ws: Winsize) error{ForkPtyFailed}!ForkedPty { | ||
| 65 | return impl.forkPty(ws); | ||
| 66 | } | ||
| 67 | |||
| 68 | /// A child's bail-out. Never `std.process.exit`: under link_libc that is | ||
| 69 | /// exit(3), which runs atexit and flushes stdio buffers the child inherited | ||
| 70 | /// from the parent — so the parent's pending bytes would be written twice. | ||
| 71 | pub fn exitNow(code: u8) noreturn { | ||
| 72 | impl.exitNow(code); | ||
| 73 | } | ||
| 74 | |||
| 75 | /// The repository's ONE fork that is not a pty: `mux d start -d`. The child | ||
| 76 | /// becomes a session leader, wires stdin to `stdin_fd` and both stdout and | ||
| 77 | /// stderr to `out_fd`, and execs `exe` with `argv` — a fresh image, because | ||
| 78 | /// `std.debug.MemoryAccessor` caches the pid it reads memory through and a | ||
| 79 | /// Debug child that kept running would inspect the parent and panic | ||
| 80 | /// (decisions.md, 2026-08-28). A failed exec exits 127 with no atexit. | ||
| 81 | /// Returns the child's pid; the parent decides how long to wait for it. | ||
| 82 | pub fn forkDetached( | ||
| 83 | exe: [*:0]const u8, | ||
| 84 | argv: [*:null]const ?[*:0]const u8, | ||
| 85 | stdin_fd: std.posix.fd_t, | ||
| 86 | out_fd: std.posix.fd_t, | ||
| 87 | ) error{ForkFailed}!std.posix.pid_t { | ||
| 88 | return impl.forkDetached(exe, argv, stdin_fd, out_fd); | ||
| 89 | } | ||
| 90 | |||
| 91 | /// The fd barrier: every descriptor at or above `first` is closed in the | ||
| 92 | /// child before exec. CLOEXEC is set fd by fd, and an upgrade clears every | ||
| 93 | /// one and must seal them again — two hand-kept lists that would have to | ||
| 94 | /// agree, or the manifest carrier with the QUIC key bytes rides into the | ||
| 95 | /// shell. This needs no list. | ||
| 96 | pub fn closeFrom(first: std.posix.fd_t) void { | ||
| 97 | impl.closeFrom(first); | ||
| 98 | } | ||
| 99 | |||
| 100 | /// The two line-discipline bits that decide who echoes a keystroke, read | ||
| 101 | /// off the MASTER. Polled — the kernel notifies nobody when a mode changes. | ||
| 102 | pub const PtyMode = struct { icanon: bool, echo: bool }; | ||
| 103 | pub fn ptyMode(master: std.posix.fd_t) std.posix.TermiosGetError!PtyMode { | ||
| 104 | return impl.ptyMode(master); | ||
| 105 | } | ||
| 106 | |||
| 107 | /// Foreground process group of the pty. Equal to the session's child pid | ||
| 108 | /// means no foreground job: the kernel's "command returned" with zero shell | ||
| 109 | /// cooperation, which is `mux a`'s `pgid` mechanism. | ||
| 110 | pub fn ptyFgPgid(master: std.posix.fd_t) error{IoctlFailed}!std.posix.pid_t { | ||
| 111 | return impl.ptyFgPgid(master); | ||
| 112 | } | ||
| 113 | |||
| 114 | /// Resize the pty; the kernel raises SIGWINCH in the session. | ||
| 115 | pub fn setWinsize(master: std.posix.fd_t, ws: Winsize) error{IoctlFailed}!void { | ||
| 116 | return impl.setWinsize(master, ws); | ||
| 117 | } | ||
| 118 | |||
| 119 | /// The upgrade manifest's carrier across `mux d upgrade`'s exec: an fd that | ||
| 120 | /// no path names once this returns, readable only by this uid, and NOT | ||
| 121 | /// CLOEXEC because the candidate must inherit it. It carries the QUIC arm's | ||
| 122 | /// raw key bytes, which is why "no path" is the property and not a nicety — | ||
| 123 | /// and why `closeFrom` seals it away from every session shell. | ||
| 124 | pub fn anonFd(name: [*:0]const u8) error{CarrierFailed}!std.posix.fd_t { | ||
| 125 | return impl.anonFd(name); | ||
| 126 | } | ||
| 127 | |||
| 128 | /// Identity of a file: the pair a rename-over changes and a rebuild in | ||
| 129 | /// place does not. Both halves matter — an inode number is only unique | ||
| 130 | /// within one filesystem, so an install that moved the image onto a | ||
| 131 | /// different mount can hand the new file the old file's inode number, and | ||
| 132 | /// a comparison by inode alone would call that daemon current. | ||
| 133 | const ImageIdent = struct { dev: u64, ino: u64 }; | ||
| 134 | |||
| 135 | fn imageIdent(path: []const u8) !ImageIdent { | ||
| 136 | // stat, not open-then-fstat: a stat needs only search permission on the | ||
| 137 | // directories, so an image installed mode 0111 is still gradeable | ||
| 138 | // rather than reported stale forever. `std.posix.fstatat` rather than | ||
| 139 | // `std.fs.cwd().statFile`, because the posix `Stat` reports the device | ||
| 140 | // and 0.15.2's `std.fs.File.Stat` does not. | ||
| 141 | const st = try std.posix.fstatat(std.posix.AT.FDCWD, path, 0); | ||
| 142 | return .{ .dev = @intCast(st.dev), .ino = @intCast(st.ino) }; | ||
| 143 | } | ||
| 144 | |||
| 145 | /// Stale when the path now names a different inode than `at_boot`, or | ||
| 146 | /// nothing at all: `make install` and `mux d upgrade HOST` both rename a | ||
| 147 | /// new file over the running image, and the daemon keeps executing the | ||
| 148 | /// old one. A null `at_boot` is the born-stale case — the path named | ||
| 149 | /// nothing when this process started — and stays stale whatever the path | ||
| 150 | /// holds now, because a file that landed there afterwards is somebody | ||
| 151 | /// else's image and not the one being executed. | ||
| 152 | fn staleAgainst(at_boot: ?ImageIdent, path: []const u8) bool { | ||
| 153 | const boot = at_boot orelse return true; | ||
| 154 | const now = imageIdent(path) catch return true; | ||
| 155 | return now.ino != boot.ino or now.dev != boot.dev; | ||
| 156 | } | ||
| 157 | |||
| 158 | /// The path this process was started from and what that path held at the | ||
| 159 | /// time. A null `ident` means it held nothing: the record is still kept, | ||
| 160 | /// because knowing WHICH path is what separates "born on an unlinked | ||
| 161 | /// image" from "this OS would not name my path at all". | ||
| 162 | const BootImage = struct { | ||
| 163 | ident: ?ImageIdent, | ||
| 164 | path: [std.fs.max_path_bytes]u8, | ||
| 165 | len: usize, | ||
| 166 | |||
| 167 | fn spelling(self: *const BootImage) []const u8 { | ||
| 168 | return self.path[0..self.len]; | ||
| 169 | } | ||
| 170 | }; | ||
| 171 | |||
| 172 | fn recordImage(path: []const u8) BootImage { | ||
| 173 | var rec: BootImage = .{ .ident = imageIdent(path) catch null, .path = undefined, .len = path.len }; | ||
| 174 | @memcpy(rec.path[0..path.len], path); | ||
| 175 | return rec; | ||
| 176 | } | ||
| 177 | |||
| 178 | var boot_image: ?BootImage = null; | ||
| 179 | |||
| 180 | /// Record the running image's path and identity. Called once at daemon | ||
| 181 | /// start; a later call is a no-op, so the comparison is always against | ||
| 182 | /// boot and a rename that lands a file at the path afterwards can never | ||
| 183 | /// promote a stale daemon back to current. | ||
| 184 | /// | ||
| 185 | /// The two failures are not the same answer. A path this OS will not name | ||
| 186 | /// at all (`selfExePath` fails) records NOTHING and `selfImageStale` then | ||
| 187 | /// answers false — unknown is not stale, because a wall must not dress a | ||
| 188 | /// healthy box in a warning over a refused readlink. A path that IS named | ||
| 189 | /// but holds nothing (Linux spells a deleted image `…/mux (deleted)`, which | ||
| 190 | /// is how `spawn.selfExe`'s `/proc/self/exe` fallback boots a daemon) is a | ||
| 191 | /// daemon already running an image no path holds: that records the path | ||
| 192 | /// with no ident, and every ask answers stale. | ||
| 193 | pub fn noteBootImage() void { | ||
| 194 | if (boot_image != null) return; | ||
| 195 | var buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 196 | const p = std.fs.selfExePath(&buf) catch return; | ||
| 197 | boot_image = recordImage(p); | ||
| 198 | } | ||
| 199 | |||
| 200 | /// Has the file at the running image's path been replaced since boot. | ||
| 201 | /// Read fresh per ask: a rename lands under a running daemon at any moment, | ||
| 202 | /// and one stat per `sessions_req` is nothing. Implemented here and in no | ||
| 203 | /// arm, because it is the same rule on every OS an arm could be written for. | ||
| 204 | pub fn selfImageStale() bool { | ||
| 205 | noteBootImage(); | ||
| 206 | if (boot_image) |*b| return staleAgainst(b.ident, b.spelling()); | ||
| 207 | return false; | ||
| 208 | } | ||
| 209 | |||
| 210 | test "server_os: the arm compiles and answers for the process it is in" { | ||
| 211 | try std.testing.expect(getpid() > 0); | ||
| 212 | } | ||
| 213 | |||
| 214 | test "server_os.peerCred: the kernel names the peer of a socketpair as this process" { | ||
| 215 | var sp: [2]std.posix.fd_t = undefined; | ||
| 216 | try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp)); | ||
| 217 | defer std.posix.close(sp[0]); | ||
| 218 | defer std.posix.close(sp[1]); | ||
| 219 | const cred = peerCred(sp[0]) orelse return error.NoCred; | ||
| 220 | try std.testing.expectEqual(getpid(), cred.pid); | ||
| 221 | try std.testing.expectEqual(std.c.geteuid(), cred.uid); | ||
| 222 | } | ||
| 223 | |||
| 224 | test "server_os.closeFrom: a fd below the floor survives and one above does not" { | ||
| 225 | // pipe(2) sets no CLOEXEC, so a child that did not close would still | ||
| 226 | // hold pipe[1]. Asked through /dev/fd, which both OSes have. | ||
| 227 | const pipe = try std.posix.pipe(); | ||
| 228 | defer std.posix.close(pipe[0]); | ||
| 229 | defer std.posix.close(pipe[1]); | ||
| 230 | var cmd_buf: [96]u8 = undefined; | ||
| 231 | const cmd = try std.fmt.bufPrintZ(&cmd_buf, "test -e /dev/fd/{d} && exit 3; exit 0", .{pipe[1]}); | ||
| 232 | const argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", cmd.ptr }; | ||
| 233 | const ws: Winsize = .{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 }; | ||
| 234 | const f = try forkPty(ws); | ||
| 235 | if (f.pid == 0) { | ||
| 236 | closeFrom(3); | ||
| 237 | std.posix.execveZ(argv[0].?, &argv, std.c.environ) catch {}; | ||
| 238 | exitNow(127); | ||
| 239 | } | ||
| 240 | defer std.posix.close(f.master); | ||
| 241 | const r = std.posix.waitpid(f.pid, 0); | ||
| 242 | try std.testing.expect(std.posix.W.IFEXITED(r.status)); | ||
| 243 | try std.testing.expectEqual(@as(u32, 0), std.posix.W.EXITSTATUS(r.status)); | ||
| 244 | } | ||
| 245 | |||
| 246 | test "server_os.setWinsize then ptyMode: the master answers about the line discipline" { | ||
| 247 | const ws: Winsize = .{ .row = 31, .col = 101, .xpixel = 0, .ypixel = 0 }; | ||
| 248 | const argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "stty -echo; sleep 5" }; | ||
| 249 | const f = try forkPty(ws); | ||
| 250 | if (f.pid == 0) { | ||
| 251 | std.posix.execveZ(argv[0].?, &argv, std.c.environ) catch {}; | ||
| 252 | exitNow(127); | ||
| 253 | } | ||
| 254 | defer { | ||
| 255 | std.posix.kill(f.pid, std.posix.SIG.KILL) catch {}; | ||
| 256 | _ = std.posix.waitpid(f.pid, 0); | ||
| 257 | std.posix.close(f.master); | ||
| 258 | } | ||
| 259 | // A fresh pty echoes; the shell turns it off. Polled, because nothing | ||
| 260 | // notifies a mode change. | ||
| 261 | var waited: usize = 0; | ||
| 262 | while (waited < 100) : (waited += 1) { | ||
| 263 | const m = try ptyMode(f.master); | ||
| 264 | if (!m.echo) break; | ||
| 265 | std.Thread.sleep(50 * std.time.ns_per_ms); | ||
| 266 | } | ||
| 267 | try std.testing.expect(!(try ptyMode(f.master)).echo); | ||
| 268 | // The foreground group is the shell itself while `sleep` is its child | ||
| 269 | // in the same group: fgPgid equals the pid forkPty returned. | ||
| 270 | try std.testing.expectEqual(f.pid, try ptyFgPgid(f.master)); | ||
| 271 | try setWinsize(f.master, .{ .row = 10, .col = 40, .xpixel = 0, .ypixel = 0 }); | ||
| 272 | } | ||
| 273 | |||
| 274 | test "server_os.forkDetached: the child is a session leader writing to the fd it was given" { | ||
| 275 | // Asked of the OS: the child prints its own session id and pid; a | ||
| 276 | // detached daemon is its own session leader, so they are equal. | ||
| 277 | const pipe = try std.posix.pipe(); | ||
| 278 | defer std.posix.close(pipe[0]); | ||
| 279 | const devnull = try std.fs.cwd().openFile("/dev/null", .{}); | ||
| 280 | defer devnull.close(); | ||
| 281 | const argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "ps -o sid= -p $$ | tr -d ' '; echo $$" }; | ||
| 282 | const pid = try forkDetached("/bin/sh", &argv, devnull.handle, pipe[1]); | ||
| 283 | std.posix.close(pipe[1]); | ||
| 284 | var buf: [64]u8 = undefined; | ||
| 285 | var n: usize = 0; | ||
| 286 | while (true) { | ||
| 287 | const got = try std.posix.read(pipe[0], buf[n..]); | ||
| 288 | if (got == 0) break; | ||
| 289 | n += got; | ||
| 290 | } | ||
| 291 | _ = std.posix.waitpid(pid, 0); | ||
| 292 | var lines = std.mem.tokenizeScalar(u8, buf[0..n], '\n'); | ||
| 293 | const sid = lines.next() orelse return error.NoOutput; | ||
| 294 | const shpid = lines.next() orelse return error.NoOutput; | ||
| 295 | try std.testing.expectEqualStrings(shpid, sid); | ||
| 296 | try std.testing.expectEqual(pid, try std.fmt.parseInt(std.posix.pid_t, shpid, 10)); | ||
| 297 | } | ||
| 298 | |||
| 299 | test "server_os.selfImageStale: a rename over the image's path is stale, an untouched path is not" { | ||
| 300 | // The test binary cannot be renamed under itself safely, so the rule is | ||
| 301 | // exercised on a copy in a temp dir through the same two functions with | ||
| 302 | // the path named explicitly. | ||
| 303 | var tmp = std.testing.tmpDir(.{}); | ||
| 304 | defer tmp.cleanup(); | ||
| 305 | try tmp.dir.writeFile(.{ .sub_path = "img", .data = "v1" }); | ||
| 306 | var pbuf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 307 | const path = try tmp.dir.realpath("img", &pbuf); | ||
| 308 | var ident = try imageIdent(path); | ||
| 309 | try std.testing.expect(!staleAgainst(ident, path)); | ||
| 310 | try tmp.dir.writeFile(.{ .sub_path = "img.new", .data = "v2" }); | ||
| 311 | try tmp.dir.rename("img.new", "img"); | ||
| 312 | try std.testing.expect(staleAgainst(ident, path)); | ||
| 313 | ident = try imageIdent(path); | ||
| 314 | try std.testing.expect(!staleAgainst(ident, path)); | ||
| 315 | try tmp.dir.deleteFile("img"); | ||
| 316 | try std.testing.expect(staleAgainst(ident, path)); | ||
| 317 | } | ||
| 318 | |||
| 319 | test "server_os.selfImageStale: a daemon born on an unlinked image never reads healthy" { | ||
| 320 | // The route on Linux: `spawn.selfExe` hands the daemon `/proc/self/exe` | ||
| 321 | // exactly when the resolved path is gone, so the process can boot on an | ||
| 322 | // image no path names. `selfExePath` still SPELLS that path (with the | ||
| 323 | // kernel's suffix), and a spelled path that stats to nothing must read | ||
| 324 | // stale — the old suffix check said so, and a false "healthy" is the one | ||
| 325 | // verdict this check exists to never give. | ||
| 326 | var tmp = std.testing.tmpDir(.{}); | ||
| 327 | defer tmp.cleanup(); | ||
| 328 | try tmp.dir.writeFile(.{ .sub_path = "img", .data = "v1" }); | ||
| 329 | var pbuf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 330 | const path = try tmp.dir.realpath("img", &pbuf); | ||
| 331 | try tmp.dir.deleteFile("img"); | ||
| 332 | const born = recordImage(path); | ||
| 333 | try std.testing.expect(born.ident == null); | ||
| 334 | try std.testing.expect(staleAgainst(born.ident, born.spelling())); | ||
| 335 | // A later file at that path is somebody else's image, not the one this | ||
| 336 | // process is executing, so the verdict does not go back to healthy. | ||
| 337 | try tmp.dir.writeFile(.{ .sub_path = "img", .data = "v2" }); | ||
| 338 | try std.testing.expect(staleAgainst(born.ident, born.spelling())); | ||
| 339 | } | ||
| 340 | |||
| 341 | test "server_os.noteBootImage: the live binary is its own image, and a second call is a no-op" { | ||
| 342 | // The cheap in-process pin, through the PUBLIC pair: this test binary was | ||
| 343 | // not renamed under itself, so it must read healthy however many times | ||
| 344 | // the record is asked for. | ||
| 345 | noteBootImage(); | ||
| 346 | noteBootImage(); | ||
| 347 | try std.testing.expect(!selfImageStale()); | ||
| 348 | } | ||
| 349 | |||
| 350 | test "server_os.anonFd: no path names it, and it is not CLOEXEC" { | ||
| 351 | const fd = try anonFd("mux-test-carrier"); | ||
| 352 | defer std.posix.close(fd); | ||
| 353 | const st = try std.posix.fstat(fd); | ||
| 354 | try std.testing.expectEqual(@as(@TypeOf(st.nlink), 0), st.nlink); | ||
| 355 | const flags = try std.posix.fcntl(fd, std.posix.F.GETFD, 0); | ||
| 356 | try std.testing.expectEqual(@as(usize, 0), flags & std.posix.FD_CLOEXEC); | ||
| 357 | try std.posix.lseek_SET(fd, 0); | ||
| 358 | _ = try std.posix.write(fd, "abc"); | ||
| 359 | try std.posix.lseek_SET(fd, 0); | ||
| 360 | var buf: [3]u8 = undefined; | ||
| 361 | try std.testing.expectEqual(@as(usize, 3), try std.posix.read(fd, &buf)); | ||
| 362 | try std.testing.expectEqualStrings("abc", &buf); | ||
| 363 | } | ||
| 364 | |||
| 365 | test "server_os.sendNoSigNoWait: a closed peer is an error, not a signal" { | ||
| 366 | // Judged in a CHILD, because this process cannot be asked. Zig's own | ||
| 367 | // startup code installs a no-op SIGPIPE handler in every binary it | ||
| 368 | // starts, the test runner included, so a plain send with no | ||
| 369 | // MSG_NOSIGNAL also returns BrokenPipe here — a test written in this | ||
| 370 | // process stays green with the flag deleted, which is the one mistake | ||
| 371 | // it exists to catch. The child puts SIGPIPE back at SIG_DFL first, so | ||
| 372 | // a send that raises the signal DIES and the parent reads a status that | ||
| 373 | // never exited. `forkPty` rather than a raw fork because of the child's | ||
| 374 | // stdio: a raw-forked child shares fd 1 with this process, and fd 1 of | ||
| 375 | // a test binary is the build runner's protocol stream, which one stray | ||
| 376 | // byte wedges. A `forkPty` child gets its own stdio on the slave, so | ||
| 377 | // anything it prints goes to a pty nobody reads. | ||
| 378 | const f = try forkPty(.{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 }); | ||
| 379 | if (f.pid == 0) { | ||
| 380 | var dfl: std.posix.Sigaction = .{ | ||
| 381 | .handler = .{ .handler = std.posix.SIG.DFL }, | ||
| 382 | .mask = std.posix.sigemptyset(), | ||
| 383 | .flags = 0, | ||
| 384 | }; | ||
| 385 | std.posix.sigaction(std.posix.SIG.PIPE, &dfl, null); | ||
| 386 | var csp: [2]std.posix.fd_t = undefined; | ||
| 387 | if (std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &csp) != 0) exitNow(2); | ||
| 388 | std.posix.close(csp[1]); | ||
| 389 | _ = sendNoSigNoWait(csp[0], "x") catch |e| exitNow(if (e == error.BrokenPipe) 0 else 2); | ||
| 390 | // A send that SUCCEEDED to a closed peer is as wrong as one that | ||
| 391 | // signalled, and neither is 0. | ||
| 392 | exitNow(2); | ||
| 393 | } | ||
| 394 | defer std.posix.close(f.master); | ||
| 395 | const status = std.posix.waitpid(f.pid, 0).status; | ||
| 396 | try std.testing.expect(std.posix.W.IFEXITED(status)); | ||
| 397 | try std.testing.expectEqual(@as(u32, 0), std.posix.W.EXITSTATUS(status)); | ||
| 398 | |||
| 399 | var sp: [2]std.posix.fd_t = undefined; | ||
| 400 | try std.testing.expectEqual(@as(c_int, 0), std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &sp)); | ||
| 401 | defer std.posix.close(sp[0]); | ||
| 402 | defer std.posix.close(sp[1]); | ||
| 403 | try std.testing.expectEqual(@as(u32, std.posix.SOCK.STREAM), try sockType(sp[0])); | ||
| 404 | } | ||
| 405 | |||
| 406 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 407 | // unreferenced operation must at least compile for this OS. | ||
| 408 | test { | ||
| 409 | std.testing.refAllDeclsRecursive(@This()); | ||
| 410 | } | ||
src/os/server_os_linux.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,85 @@ | |||
| 1 | //! Linux arm of `server_os`. Spellings only; the contract is in the root. | ||
| 2 | const std = @import("std"); | ||
| 3 | const root = @import("server_os.zig"); | ||
| 4 | const c = @cImport({ | ||
| 5 | @cInclude("pty.h"); | ||
| 6 | @cInclude("sys/ioctl.h"); | ||
| 7 | }); | ||
| 8 | |||
| 9 | pub fn getpid() std.posix.pid_t { | ||
| 10 | return std.os.linux.getpid(); | ||
| 11 | } | ||
| 12 | |||
| 13 | pub fn peerCred(fd: std.posix.socket_t) ?root.PeerCred { | ||
| 14 | const Ucred = extern struct { pid: std.posix.pid_t, uid: std.posix.uid_t, gid: std.posix.gid_t }; | ||
| 15 | var cred: Ucred = undefined; | ||
| 16 | std.posix.getsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.PEERCRED, std.mem.asBytes(&cred)) catch return null; | ||
| 17 | return .{ .uid = cred.uid, .pid = cred.pid }; | ||
| 18 | } | ||
| 19 | |||
| 20 | pub fn sendNoSigNoWait(fd: std.posix.socket_t, bytes: []const u8) std.posix.SendError!usize { | ||
| 21 | return std.posix.send(fd, bytes, std.posix.MSG.DONTWAIT | std.posix.MSG.NOSIGNAL); | ||
| 22 | } | ||
| 23 | |||
| 24 | pub fn sockType(fd: std.posix.fd_t) error{NotASocket}!u32 { | ||
| 25 | var t: i32 = undefined; | ||
| 26 | var len: std.posix.socklen_t = @sizeOf(@TypeOf(t)); | ||
| 27 | const rc = std.os.linux.getsockopt(fd, std.os.linux.SOL.SOCKET, std.os.linux.SO.TYPE, @ptrCast(&t), &len); | ||
| 28 | if (std.os.linux.E.init(rc) != .SUCCESS) return error.NotASocket; | ||
| 29 | return @intCast(t); | ||
| 30 | } | ||
| 31 | |||
| 32 | pub fn forkPty(ws: root.Winsize) error{ForkPtyFailed}!root.ForkedPty { | ||
| 33 | var master: c_int = undefined; | ||
| 34 | var cws: c.struct_winsize = .{ .ws_row = ws.row, .ws_col = ws.col, .ws_xpixel = 0, .ws_ypixel = 0 }; | ||
| 35 | const pid = c.forkpty(&master, null, null, &cws); | ||
| 36 | if (pid < 0) return error.ForkPtyFailed; | ||
| 37 | return .{ .pid = pid, .master = master }; | ||
| 38 | } | ||
| 39 | |||
| 40 | pub fn exitNow(code: u8) noreturn { | ||
| 41 | std.os.linux.exit_group(code); | ||
| 42 | } | ||
| 43 | |||
| 44 | pub fn forkDetached( | ||
| 45 | exe: [*:0]const u8, | ||
| 46 | argv: [*:null]const ?[*:0]const u8, | ||
| 47 | stdin_fd: std.posix.fd_t, | ||
| 48 | out_fd: std.posix.fd_t, | ||
| 49 | ) error{ForkFailed}!std.posix.pid_t { | ||
| 50 | const pid = std.posix.fork() catch return error.ForkFailed; | ||
| 51 | if (pid != 0) return pid; | ||
| 52 | _ = std.os.linux.setsid(); | ||
| 53 | std.posix.dup2(stdin_fd, std.posix.STDIN_FILENO) catch exitNow(127); | ||
| 54 | std.posix.dup2(out_fd, std.posix.STDOUT_FILENO) catch exitNow(127); | ||
| 55 | std.posix.dup2(out_fd, std.posix.STDERR_FILENO) catch exitNow(127); | ||
| 56 | std.posix.execveZ(exe, argv, std.c.environ) catch exitNow(127); | ||
| 57 | unreachable; | ||
| 58 | } | ||
| 59 | |||
| 60 | pub fn closeFrom(first: std.posix.fd_t) void { | ||
| 61 | // ENOSYS (pre-5.9 kernel) leaves the CLOEXEC flags to do the work alone. | ||
| 62 | _ = std.os.linux.syscall3(.close_range, @intCast(first), std.math.maxInt(u32), 0); | ||
| 63 | } | ||
| 64 | |||
| 65 | pub fn ptyMode(master: std.posix.fd_t) std.posix.TermiosGetError!root.PtyMode { | ||
| 66 | // On Linux the master shares one termios with the slave, so what the | ||
| 67 | // session did with tcsetattr is one syscall away. | ||
| 68 | const t = try std.posix.tcgetattr(master); | ||
| 69 | return .{ .icanon = t.lflag.ICANON, .echo = t.lflag.ECHO }; | ||
| 70 | } | ||
| 71 | |||
| 72 | pub fn ptyFgPgid(master: std.posix.fd_t) error{IoctlFailed}!std.posix.pid_t { | ||
| 73 | var pgid: c.pid_t = 0; | ||
| 74 | if (c.ioctl(master, c.TIOCGPGRP, &pgid) < 0) return error.IoctlFailed; | ||
| 75 | return @intCast(pgid); | ||
| 76 | } | ||
| 77 | |||
| 78 | pub fn setWinsize(master: std.posix.fd_t, ws: root.Winsize) error{IoctlFailed}!void { | ||
| 79 | var cws: c.struct_winsize = .{ .ws_row = ws.row, .ws_col = ws.col, .ws_xpixel = 0, .ws_ypixel = 0 }; | ||
| 80 | if (c.ioctl(master, c.TIOCSWINSZ, &cws) < 0) return error.IoctlFailed; | ||
| 81 | } | ||
| 82 | |||
| 83 | pub fn anonFd(name: [*:0]const u8) error{CarrierFailed}!std.posix.fd_t { | ||
| 84 | return std.posix.memfd_create(std.mem.span(name), 0) catch error.CarrierFailed; | ||
| 85 | } | ||
src/os/spawn.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,73 @@ | |||
| 1 | //! Resolve the executable used when mux starts a daemon process. Resolving | ||
| 2 | //! the running image ensures the new process runs the current binary rather | ||
| 3 | //! than another `mux` found through `PATH`. | ||
| 4 | const std = @import("std"); | ||
| 5 | const builtin = @import("builtin"); | ||
| 6 | |||
| 7 | /// The kernel's link to the running image, used only when the resolved | ||
| 8 | /// path is no longer executable — Linux keeps a live link after a rename- | ||
| 9 | /// over, so the exec runs the image already running. On an OS with no such | ||
| 10 | /// link the fallback is the resolved path itself, and an exec after a | ||
| 11 | /// rename-over runs the NEW file at that path rather than the running one. | ||
| 12 | /// That is the ordinary meaning of the path and not a failure, but it does | ||
| 13 | /// mean the started daemon can be a different build from the starter. | ||
| 14 | pub const self_exe: []const u8 = switch (builtin.os.tag) { | ||
| 15 | .linux => "/proc/self/exe", | ||
| 16 | else => "", | ||
| 17 | }; | ||
| 18 | |||
| 19 | /// Return the resolved path of the current executable, falling back to | ||
| 20 | /// this OS's link to the running image. On an OS with no such link an | ||
| 21 | /// unresolvable path comes back empty and the exec fails with it: naming | ||
| 22 | /// `mux` instead would be the PATH walk this module exists to prevent. | ||
| 23 | pub fn selfExe(buf: *[std.fs.max_path_bytes]u8) []const u8 { | ||
| 24 | // Prefer the resolved path because process listings derive `comm` from the | ||
| 25 | // filename passed to execve; executing the link would name every daemon | ||
| 26 | // `exe`. | ||
| 27 | return execOrLink(std.fs.selfExePath(buf) catch return self_exe); | ||
| 28 | } | ||
| 29 | |||
| 30 | /// The resolved path if it can still be exec'd, this OS's link to the | ||
| 31 | /// running image if it cannot and it has one. | ||
| 32 | /// Split out so the fallback is assertable without deleting a live binary. | ||
| 33 | fn execOrLink(resolved: []const u8) []const u8 { | ||
| 34 | // After `make install`, the resolved path may end in ` (deleted)` on | ||
| 35 | // Linux and no longer be executable even though the readlink succeeded. | ||
| 36 | std.posix.access(resolved, std.posix.X_OK) catch | ||
| 37 | return if (self_exe.len == 0) resolved else self_exe; | ||
| 38 | return resolved; | ||
| 39 | } | ||
| 40 | |||
| 41 | // --------------------------------------------------------------------------- | ||
| 42 | |||
| 43 | test "selfExe: the exec'd name is a real file, not the /proc link" { | ||
| 44 | var buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 45 | const exe = selfExe(&buf); | ||
| 46 | // The resolved basename becomes the process name shown by tools such as | ||
| 47 | // `ps`, `pgrep`, and `killall`. | ||
| 48 | try std.testing.expect(!std.mem.eql(u8, exe, self_exe)); | ||
| 49 | try std.posix.access(exe, std.posix.X_OK); | ||
| 50 | } | ||
| 51 | |||
| 52 | test "selfExe: a resolved path that is no longer a file falls back to the link" { | ||
| 53 | // What `make install` does to a running wall. Spelled as the suffix the | ||
| 54 | // kernel actually appends, because that is the string this must survive. | ||
| 55 | var buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 56 | const live = try std.fs.selfExePath(&buf); | ||
| 57 | try std.testing.expectEqualStrings(live, execOrLink(live)); | ||
| 58 | |||
| 59 | // The suffix is Linux's; an OS with no such link has no fallback path | ||
| 60 | // to assert, and `execOrLink` hands back what it was given. | ||
| 61 | if (builtin.os.tag == .linux) { | ||
| 62 | var gone: [std.fs.max_path_bytes]u8 = undefined; | ||
| 63 | const deleted = try std.fmt.bufPrint(&gone, "{s} (deleted)", .{live}); | ||
| 64 | try std.testing.expectEqualStrings(self_exe, execOrLink(deleted)); | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | // Forces semantic analysis of every pub decl under `zig build test`, so an | ||
| 69 | // unreferenced decl must at least compile (the silent-module-loss hazard, | ||
| 70 | // decisions.md). Pub decls only: std.meta.declarations sees nothing private. | ||
| 71 | test { | ||
| 72 | std.testing.refAllDeclsRecursive(@This()); | ||
| 73 | } | ||
src/server/pty.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,12 +1,17 @@ | |||
| 1 | //! PTY lifecycle for a session: forkpty with the user's shell — or with any | 1 | //! PTY lifecycle for a session: a pty forked through `server_os.forkPty` with |
| 2 | //! argv, which is how the e2e fixture drives a real client — blocking master | 2 | //! the user's shell — or with any argv, which is how the e2e fixture drives a |
| 3 | //! fd (the daemon's poll loop drives readiness), exit detection. | 3 | //! real client — blocking master fd (the daemon's poll loop drives readiness), |
| 4 | //! exit detection. | ||
| 4 | const std = @import("std"); | 5 | const std = @import("std"); |
| 5 | const c = @cImport({ | 6 | const server_os = @import("server_os"); |
| 6 | @cInclude("pty.h"); | 7 | |
| 7 | @cInclude("stdlib.h"); | 8 | // Declared rather than @cInclude'd, and not `server_os`'s business either: |
| 8 | @cInclude("sys/ioctl.h"); | 9 | // setenv(3) and unsetenv(3) are POSIX, spelled the same on every OS mux runs |
| 9 | }); | 10 | // on, so there is nothing for a platform arm to choose between. Two externs |
| 11 | // also keep this file's whole C surface visible on two lines, instead of a | ||
| 12 | // header's entire namespace. | ||
| 13 | extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; | ||
| 14 | extern "c" fn unsetenv(name: [*:0]const u8) c_int; | ||
| 10 | 15 | ||
| 11 | pub const Pty = struct { | 16 | pub const Pty = struct { |
| 12 | master: std.posix.fd_t, | 17 | master: std.posix.fd_t, |
| @@ -38,34 +43,29 @@ pub const Pty = struct { | |||
| 38 | /// on the far side of a pty is done here, so `spawn` and the e2e fixture | 43 | /// on the far side of a pty is done here, so `spawn` and the e2e fixture |
| 39 | /// cannot drift apart in what they hand the child. | 44 | /// cannot drift apart in what they hand the child. |
| 40 | pub fn spawnArgv(opts: SpawnArgvOptions) !Pty { | 45 | pub fn spawnArgv(opts: SpawnArgvOptions) !Pty { |
| 41 | var master: c_int = undefined; | 46 | const ws: server_os.Winsize = .{ .row = opts.rows, .col = opts.cols, .xpixel = 0, .ypixel = 0 }; |
| 42 | var ws: c.struct_winsize = .{ | ||
| 43 | .ws_row = opts.rows, | ||
| 44 | .ws_col = opts.cols, | ||
| 45 | .ws_xpixel = 0, | ||
| 46 | .ws_ypixel = 0, | ||
| 47 | }; | ||
| 48 | 47 | ||
| 49 | // Diagnosed in the parent, where it can still be an error: an empty | 48 | // Diagnosed in the parent, where it can still be an error: an empty |
| 50 | // argv exec'd in the child is indistinguishable from a real exec | 49 | // argv exec'd in the child is indistinguishable from a real exec |
| 51 | // failure, and costs a fork to say so. | 50 | // failure, and costs a fork to say so. |
| 52 | if (opts.argv[0] == null) return error.EmptyArgv; | 51 | if (opts.argv[0] == null) return error.EmptyArgv; |
| 53 | 52 | ||
| 54 | const pid = c.forkpty(&master, null, null, &ws); | 53 | const f = try server_os.forkPty(ws); |
| 55 | if (pid < 0) return error.ForkPtyFailed; | 54 | const pid = f.pid; |
| 55 | const master = f.master; | ||
| 56 | 56 | ||
| 57 | if (pid == 0) { | 57 | if (pid == 0) { |
| 58 | // Child. xterm-256color: ghostty-vt understands more, but this | 58 | // Child. xterm-256color: ghostty-vt understands more, but this |
| 59 | // terminfo exists everywhere the shell will look. | 59 | // terminfo exists everywhere the shell will look. |
| 60 | _ = c.setenv("TERM", "xterm-256color", 1); | 60 | _ = setenv("TERM", "xterm-256color", 1); |
| 61 | // Overwrite (1), and a CONTRACT rather than a detail: this is a loop | 61 | // Overwrite (1), and a CONTRACT rather than a detail: this is a loop |
| 62 | // over an ordered slice, so a LATER pair beats an earlier one for the | 62 | // over an ordered slice, so a LATER pair beats an earlier one for the |
| 63 | // same key. That is what lets `extra_env` override a variable the | 63 | // same key. That is what lets `extra_env` override a variable the |
| 64 | // shell-integration injection set, and a reorder would invert it. | 64 | // shell-integration injection set, and a reorder would invert it. |
| 65 | for (opts.env) |kv| _ = if (kv.value) |v| | 65 | for (opts.env) |kv| _ = if (kv.value) |v| |
| 66 | c.setenv(kv.key.ptr, v.ptr, 1) | 66 | setenv(kv.key.ptr, v.ptr, 1) |
| 67 | else | 67 | else |
| 68 | c.unsetenv(kv.key.ptr); | 68 | unsetenv(kv.key.ptr); |
| 69 | 69 | ||
| 70 | // Ctrl-C must work in the session, and without this it does not: a | 70 | // Ctrl-C must work in the session, and without this it does not: a |
| 71 | // non-interactive shell sets SIGINT to SIG_IGN for anything it | 71 | // non-interactive shell sets SIGINT to SIG_IGN for anything it |
| @@ -85,24 +85,19 @@ pub const Pty = struct { | |||
| 85 | // session shell. Resetting here makes it order-independent. | 85 | // session shell. Resetting here makes it order-independent. |
| 86 | std.posix.sigaction(std.posix.SIG.PIPE, &dfl, null); | 86 | std.posix.sigaction(std.posix.SIG.PIPE, &dfl, null); |
| 87 | 87 | ||
| 88 | // exit_group, never std.process.exit — see spawn.zig's fork child | 88 | // `exitNow`, never `std.process.exit` — see `server_os.exitNow` |
| 89 | // for the full reason: under link_libc that is exit(3), which | 89 | // for why. |
| 90 | // flushes stdio buffers inherited from the parent. | ||
| 91 | if (opts.stderr_fd) |fd| { | 90 | if (opts.stderr_fd) |fd| { |
| 92 | std.posix.dup2(fd, 2) catch std.os.linux.exit_group(126); | 91 | std.posix.dup2(fd, 2) catch server_os.exitNow(126); |
| 93 | // The dup left a spare copy at the caller's fd number and | 92 | // The dup left a spare copy at the caller's fd number and |
| 94 | // `pipe()` sets no CLOEXEC, so it would ride through exec into | 93 | // `pipe()` sets no CLOEXEC, so it would ride through exec into |
| 95 | // everything the client spawns. One handle, so the write end dies | 94 | // everything the client spawns. One handle, so the write end dies |
| 96 | // with the child's stderr and not later. | 95 | // with the child's stderr and not later. |
| 97 | if (fd > 2) std.posix.close(fd); | 96 | if (fd > 2) std.posix.close(fd); |
| 98 | } | 97 | } |
| 99 | // The barrier that needs no list: CLOEXEC is set fd by fd, and an | 98 | server_os.closeFrom(3); |
| 100 | // upgrade clears every one and must seal them again — two hand-kept | ||
| 101 | // lists that have to agree, or a key-carrying memfd rides into the | ||
| 102 | // shell. ENOSYS leaves the flags to do the work alone. | ||
| 103 | _ = std.os.linux.syscall3(.close_range, 3, std.math.maxInt(u32), 0); | ||
| 104 | std.posix.execveZ(opts.argv[0].?, opts.argv, std.c.environ) catch {}; | 99 | std.posix.execveZ(opts.argv[0].?, opts.argv, std.c.environ) catch {}; |
| 105 | std.os.linux.exit_group(127); | 100 | server_os.exitNow(127); |
| 106 | } | 101 | } |
| 107 | 102 | ||
| 108 | // Parent. The master is THIS session's private handle and must never | 103 | // Parent. The master is THIS session's private handle and must never |
| @@ -127,34 +122,21 @@ pub const Pty = struct { | |||
| 127 | return .{ .master = master, .child = pid }; | 122 | return .{ .master = master, .child = pid }; |
| 128 | } | 123 | } |
| 129 | 124 | ||
| 130 | /// The two line-discipline bits that decide who echoes a keystroke. Read off | 125 | pub const Mode = server_os.PtyMode; |
| 131 | /// the MASTER, which on Linux shares one termios with the slave, so what the | ||
| 132 | /// session did with tcsetattr is one syscall away. Polled, which is the only | ||
| 133 | /// option — the kernel notifies nobody when a mode changes. | ||
| 134 | pub const Mode = struct { icanon: bool, echo: bool }; | ||
| 135 | 126 | ||
| 136 | pub fn mode(self: *const Pty) !Mode { | 127 | pub fn mode(self: *const Pty) !Mode { |
| 137 | const t = try std.posix.tcgetattr(self.master); | 128 | return server_os.ptyMode(self.master); |
| 138 | return .{ .icanon = t.lflag.ICANON, .echo = t.lflag.ECHO }; | ||
| 139 | } | 129 | } |
| 140 | 130 | ||
| 141 | /// Equal to `child` means no foreground job: the kernel's "command | 131 | /// Equal to `child` means no foreground job: the kernel's "command |
| 142 | /// returned", with zero shell cooperation. No exit code and no output | 132 | /// returned", with zero shell cooperation. No exit code and no output |
| 143 | /// span; marks are for that. | 133 | /// span; marks are for that. |
| 144 | pub fn fgPgid(self: *const Pty) !std.posix.pid_t { | 134 | pub fn fgPgid(self: *const Pty) !std.posix.pid_t { |
| 145 | var pgid: c.pid_t = 0; | 135 | return server_os.ptyFgPgid(self.master); |
| 146 | if (c.ioctl(self.master, c.TIOCGPGRP, &pgid) < 0) return error.IoctlFailed; | ||
| 147 | return @intCast(pgid); | ||
| 148 | } | 136 | } |
| 149 | 137 | ||
| 150 | pub fn resize(self: *Pty, cols: u16, rows: u16) !void { | 138 | pub fn resize(self: *Pty, cols: u16, rows: u16) !void { |
| 151 | var ws: c.struct_winsize = .{ | 139 | return server_os.setWinsize(self.master, .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }); |
| 152 | .ws_row = rows, | ||
| 153 | .ws_col = cols, | ||
| 154 | .ws_xpixel = 0, | ||
| 155 | .ws_ypixel = 0, | ||
| 156 | }; | ||
| 157 | if (c.ioctl(self.master, c.TIOCSWINSZ, &ws) < 0) return error.IoctlFailed; | ||
| 158 | } | 140 | } |
| 159 | 141 | ||
| 160 | // Build a Pty from an fd and pid that already belong to this process. The | 142 | // Build a Pty from an fd and pid that already belong to this process. The |
| @@ -367,13 +349,16 @@ test "Pty: resize is visible via TIOCGWINSZ" { | |||
| 367 | 349 | ||
| 368 | try pty.resize(120, 40); | 350 | try pty.resize(120, 40); |
| 369 | 351 | ||
| 370 | var ws: c.struct_winsize = undefined; | 352 | // Asked of the kernel, not of `server_os`: the ioctl that reads the size |
| 353 | // back has to be a different call from the one that set it, or the test | ||
| 354 | // grades the platform arm against itself. | ||
| 355 | var ws: std.posix.winsize = undefined; | ||
| 371 | try std.testing.expectEqual( | 356 | try std.testing.expectEqual( |
| 372 | @as(c_int, 0), | 357 | @as(c_int, 0), |
| 373 | c.ioctl(pty.master, c.TIOCGWINSZ, &ws), | 358 | std.c.ioctl(pty.master, @intCast(std.c.T.IOCGWINSZ), &ws), |
| 374 | ); | 359 | ); |
| 375 | try std.testing.expectEqual(@as(c_ushort, 120), ws.ws_col); | 360 | try std.testing.expectEqual(@as(u16, 120), ws.col); |
| 376 | try std.testing.expectEqual(@as(c_ushort, 40), ws.ws_row); | 361 | try std.testing.expectEqual(@as(u16, 40), ws.row); |
| 377 | } | 362 | } |
| 378 | 363 | ||
| 379 | test "Pty: mode reads the line discipline off the master" { | 364 | test "Pty: mode reads the line discipline off the master" { |
| @@ -482,12 +467,13 @@ test "Pty: spawnArgv runs an argv and propagates exit status" { | |||
| 482 | 467 | ||
| 483 | test "Pty: a daemon fd without CLOEXEC still does not reach the shell" { | 468 | test "Pty: a daemon fd without CLOEXEC still does not reach the shell" { |
| 484 | // pipe(2) sets no CLOEXEC — exactly the state an upgrade exec leaves | 469 | // pipe(2) sets no CLOEXEC — exactly the state an upgrade exec leaves |
| 485 | // the adopted fds in. The child looks for its own copy. | 470 | // the adopted fds in. The child looks for its own copy: through /dev/fd, |
| 471 | // which every OS mux runs on has. | ||
| 486 | const pipe = try std.posix.pipe(); | 472 | const pipe = try std.posix.pipe(); |
| 487 | defer std.posix.close(pipe[0]); | 473 | defer std.posix.close(pipe[0]); |
| 488 | defer std.posix.close(pipe[1]); | 474 | defer std.posix.close(pipe[1]); |
| 489 | var cmd_buf: [96]u8 = undefined; | 475 | var cmd_buf: [96]u8 = undefined; |
| 490 | const cmd = try std.fmt.bufPrintZ(&cmd_buf, "test -e /proc/self/fd/{d} && exit 3; exit 0", .{pipe[1]}); | 476 | const cmd = try std.fmt.bufPrintZ(&cmd_buf, "test -e /dev/fd/{d} && exit 3; exit 0", .{pipe[1]}); |
| 491 | var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", cmd.ptr }; | 477 | var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", cmd.ptr }; |
| 492 | var pty = try Pty.spawnArgv(.{ .cols = 80, .rows = 24, .argv = &argv }); | 478 | var pty = try Pty.spawnArgv(.{ .cols = 80, .rows = 24, .argv = &argv }); |
| 493 | defer pty.deinit(); | 479 | defer pty.deinit(); |
src/server/quic_server.zig
| Old | New | ||
|---|---|---|---|
| @@ -9,6 +9,7 @@ | |||
| 9 | const std = @import("std"); | 9 | const std = @import("std"); |
| 10 | 10 | ||
| 11 | const quic = @import("quic"); | 11 | const quic = @import("quic"); |
| 12 | const server_os = @import("server_os"); | ||
| 12 | 13 | ||
| 13 | /// The C view of the QUIC stack, imported once in quic.zig and shared: | 14 | /// The C view of the QUIC stack, imported once in quic.zig and shared: |
| 14 | /// two @cImport blocks over the same headers produce two *distinct* Zig | 15 | /// two @cImport blocks over the same headers produce two *distinct* Zig |
| @@ -335,17 +336,7 @@ pub const Listener = struct { | |||
| 335 | ) !*Listener { | 336 | ) !*Listener { |
| 336 | if (g_listener_live) return error.ListenerAlreadyRunning; | 337 | if (g_listener_live) return error.ListenerAlreadyRunning; |
| 337 | 338 | ||
| 338 | var sock_type: i32 = undefined; | 339 | if ((server_os.sockType(fd) catch return error.NotAUdpSocket) != std.posix.SOCK.DGRAM) return error.NotAUdpSocket; |
| 339 | var sock_type_len: std.posix.socklen_t = @sizeOf(@TypeOf(sock_type)); | ||
| 340 | const rc = std.os.linux.getsockopt( | ||
| 341 | fd, | ||
| 342 | std.os.linux.SOL.SOCKET, | ||
| 343 | std.os.linux.SO.TYPE, | ||
| 344 | @ptrCast(&sock_type), | ||
| 345 | &sock_type_len, | ||
| 346 | ); | ||
| 347 | if (std.os.linux.E.init(rc) != .SUCCESS) return error.NotAUdpSocket; | ||
| 348 | if (sock_type != std.os.linux.SOCK.DGRAM) return error.NotAUdpSocket; | ||
| 349 | 340 | ||
| 350 | return finishInit(alloc, fd, key, handler, idle_ms); | 341 | return finishInit(alloc, fd, key, handler, idle_ms); |
| 351 | } | 342 | } |
src/server/server.zig
| Old | New | ||
|---|---|---|---|
| @@ -15,6 +15,7 @@ const sockpath = @import("sockpath"); | |||
| 15 | const serve = @import("serve"); | 15 | const serve = @import("serve"); |
| 16 | const quic = @import("quic"); | 16 | const quic = @import("quic"); |
| 17 | const xdg = @import("xdg"); | 17 | const xdg = @import("xdg"); |
| 18 | const server_os = @import("server_os"); | ||
| 18 | // Re-exported for the daemon's own main (src/cli/main.zig) — the only | 19 | // Re-exported for the daemon's own main (src/cli/main.zig) — the only |
| 19 | // consumer outside this folder; nobody else may know these exist. | 20 | // consumer outside this folder; nobody else may know these exist. |
| 20 | pub const quic_server = @import("quic_server.zig"); | 21 | pub const quic_server = @import("quic_server.zig"); |
| @@ -183,11 +184,7 @@ const Sink = union(enum) { | |||
| 183 | /// unbounded growth just moved into the listener. | 184 | /// unbounded growth just moved into the listener. |
| 184 | fn send(self: Sink, bytes: []const u8) !usize { | 185 | fn send(self: Sink, bytes: []const u8) !usize { |
| 185 | return switch (self) { | 186 | return switch (self) { |
| 186 | .socket => |fd| std.posix.send( | 187 | .socket => |fd| server_os.sendNoSigNoWait(fd, bytes), |
| 187 | fd, | ||
| 188 | bytes, | ||
| 189 | std.posix.MSG.DONTWAIT | std.posix.MSG.NOSIGNAL, | ||
| 190 | ), | ||
| 191 | .quic => |q| q.listener.send(q.id, bytes), | 188 | .quic => |q| q.listener.send(q.id, bytes), |
| 192 | }; | 189 | }; |
| 193 | } | 190 | } |
| @@ -525,11 +522,12 @@ pub const Server = struct { | |||
| 525 | // importing build_options (which would conflict with exe's own import). | 522 | // importing build_options (which would conflict with exe's own import). |
| 526 | version: []const u8 = "", | 523 | version: []const u8 = "", |
| 527 | 524 | ||
| 528 | // What the run loop needs to exec: the candidate's path and the memfd | 525 | // What the run loop needs to exec: the candidate's path and the carrier |
| 529 | // holding the manifest. Set by validateUpgrade + writeManifestTo. | 526 | // holding the manifest (`server_os.anonFd`). Set by validateUpgrade + |
| 527 | // writeManifestTo. | ||
| 530 | const PendingUpgrade = struct { | 528 | const PendingUpgrade = struct { |
| 531 | path: []const u8, | 529 | path: []const u8, |
| 532 | memfd: std.posix.fd_t, | 530 | carrier: std.posix.fd_t, |
| 533 | }; | 531 | }; |
| 534 | 532 | ||
| 535 | pub const Options = struct { | 533 | pub const Options = struct { |
| @@ -550,6 +548,10 @@ pub const Server = struct { | |||
| 550 | }; | 548 | }; |
| 551 | 549 | ||
| 552 | pub fn init(alloc: std.mem.Allocator, opts: Options) !Server { | 550 | pub fn init(alloc: std.mem.Allocator, opts: Options) !Server { |
| 551 | // Before any request can ask: the comparison is against the image | ||
| 552 | // that BOOTED, not the first one asked about. | ||
| 553 | server_os.noteBootImage(); | ||
| 554 | |||
| 553 | // Before the shell is spawned, so refusing costs nobody a fork and | 555 | // Before the shell is spawned, so refusing costs nobody a fork and |
| 554 | // leaves no process to reap. `serve.bind` below runs the same refusal | 556 | // leaves no process to reap. `serve.bind` below runs the same refusal |
| 555 | // again, and the repeat is not redundant: it is the one that decides, | 557 | // again, and the repeat is not redundant: it is the one that decides, |
| @@ -605,12 +607,19 @@ pub const Server = struct { | |||
| 605 | return srv; | 607 | return srv; |
| 606 | } | 608 | } |
| 607 | 609 | ||
| 608 | /// Adopt the manifest an exec-ing daemon left in a memfd. | 610 | /// Adopt the manifest an exec-ing daemon left in its carrier. |
| 609 | pub fn initFromManifest( | 611 | pub fn initFromManifest( |
| 610 | alloc: std.mem.Allocator, | 612 | alloc: std.mem.Allocator, |
| 611 | parsed: *const upgrade.Parsed, | 613 | parsed: *const upgrade.Parsed, |
| 612 | version: []const u8, | 614 | version: []const u8, |
| 613 | ) !Server { | 615 | ) !Server { |
| 616 | // Before any request can ask: the comparison is against the image | ||
| 617 | // that BOOTED, not the first one asked about. `mux d upgrade` keeps | ||
| 618 | // the pid but execs a new image, which zeroes every global — so this | ||
| 619 | // records the CANDIDATE, and the daemon is graded against the file | ||
| 620 | // it is now running rather than the one it started life as. | ||
| 621 | server_os.noteBootImage(); | ||
| 622 | |||
| 614 | // Same pid, same children, same descriptors. No `sockpath.claim`: the | 623 | // Same pid, same children, same descriptors. No `sockpath.claim`: the |
| 615 | // inherited listener fd IS the claim, and claim's probe would find our | 624 | // inherited listener fd IS the claim, and claim's probe would find our |
| 616 | // own socket answering. `version` is THIS binary's, never the | 625 | // own socket answering. `version` is THIS binary's, never the |
| @@ -1092,9 +1101,9 @@ pub const Server = struct { | |||
| 1092 | // An upgrade was accepted: the reply has drained (pumpOnce | 1101 | // An upgrade was accepted: the reply has drained (pumpOnce |
| 1093 | // ran the observer handler), so exec now. Shaped like | 1102 | // ran the observer handler), so exec now. Shaped like |
| 1094 | // shutdown_flag but per-instance because the exec carries the | 1103 | // shutdown_flag but per-instance because the exec carries the |
| 1095 | // candidate's path and memfd. | 1104 | // candidate's path and carrier. |
| 1096 | if (self.pending_upgrade) |up| { | 1105 | if (self.pending_upgrade) |up| { |
| 1097 | self.execUpgrade(up.path, up.memfd); | 1106 | self.execUpgrade(up.path, up.carrier); |
| 1098 | // execUpgrade only returns on failure; the daemon carries on. | 1107 | // execUpgrade only returns on failure; the daemon carries on. |
| 1099 | continue; | 1108 | continue; |
| 1100 | } | 1109 | } |
| @@ -1637,7 +1646,7 @@ pub const Server = struct { | |||
| 1637 | fn cancelUpgrade(self: *Server) void { | 1646 | fn cancelUpgrade(self: *Server) void { |
| 1638 | const up = self.pending_upgrade orelse return; | 1647 | const up = self.pending_upgrade orelse return; |
| 1639 | self.alloc.free(up.path); | 1648 | self.alloc.free(up.path); |
| 1640 | std.posix.close(up.memfd); | 1649 | std.posix.close(up.carrier); |
| 1641 | self.pending_upgrade = null; | 1650 | self.pending_upgrade = null; |
| 1642 | } | 1651 | } |
| 1643 | 1652 | ||
| @@ -1816,7 +1825,7 @@ pub const Server = struct { | |||
| 1816 | const holds: u8 = @intCast(@min(self.clientsInSession(si), std.math.maxInt(u8))); | 1825 | const holds: u8 = @intCast(@min(self.clientsInSession(si), std.math.maxInt(u8))); |
| 1817 | len = proto.appendSessionsHolds(&buf, len, s.name(), holds); | 1826 | len = proto.appendSessionsHolds(&buf, len, s.name(), holds); |
| 1818 | } | 1827 | } |
| 1819 | len = proto.appendSessionsMeta(&buf, len, self.version, selfImageStale()); | 1828 | len = proto.appendSessionsMeta(&buf, len, self.version, server_os.selfImageStale()); |
| 1820 | } | 1829 | } |
| 1821 | self.replyTo(p, .sessions_reply, buf[0..len]); | 1830 | self.replyTo(p, .sessions_reply, buf[0..len]); |
| 1822 | }, | 1831 | }, |
| @@ -2271,18 +2280,18 @@ pub const Server = struct { | |||
| 2271 | defer self.alloc.free(reason); | 2280 | defer self.alloc.free(reason); |
| 2272 | return self.refuseUpgrade(i, reason); | 2281 | return self.refuseUpgrade(i, reason); |
| 2273 | } | 2282 | } |
| 2274 | // Accepted: write the manifest to a memfd (no CLOEXEC — the | 2283 | // Accepted: write the manifest to its carrier (not CLOEXEC — |
| 2275 | // new binary must inherit it), reply, and arm the exec. | 2284 | // the new binary must inherit it), reply, and arm the exec. |
| 2276 | const memfd = std.posix.memfd_create("mux-upgrade", 0) catch return self.refuseUpgrade(i, "memfd"); | 2285 | const carrier = server_os.anonFd("mux-upgrade") catch return self.refuseUpgrade(i, "carrier"); |
| 2277 | self.writeManifestTo(memfd, self.version) catch { | 2286 | self.writeManifestTo(carrier, self.version) catch { |
| 2278 | std.posix.close(memfd); | 2287 | std.posix.close(carrier); |
| 2279 | return self.refuseUpgrade(i, "manifest"); | 2288 | return self.refuseUpgrade(i, "manifest"); |
| 2280 | }; | 2289 | }; |
| 2281 | // Owned, because `req.path` points into the frame payload | 2290 | // Owned, because `req.path` points into the frame payload |
| 2282 | // this handler's caller frees on return, and the exec runs a | 2291 | // this handler's caller frees on return, and the exec runs a |
| 2283 | // pump later. execUpgrade frees it if the exec fails. | 2292 | // pump later. execUpgrade frees it if the exec fails. |
| 2284 | const path = self.alloc.dupe(u8, req.path) catch { | 2293 | const path = self.alloc.dupe(u8, req.path) catch { |
| 2285 | std.posix.close(memfd); | 2294 | std.posix.close(carrier); |
| 2286 | return self.refuseUpgrade(i, "oom"); | 2295 | return self.refuseUpgrade(i, "oom"); |
| 2287 | }; | 2296 | }; |
| 2288 | var accepted: [1]u8 = undefined; | 2297 | var accepted: [1]u8 = undefined; |
| @@ -2291,7 +2300,7 @@ pub const Server = struct { | |||
| 2291 | .reason = "", | 2300 | .reason = "", |
| 2292 | })); | 2301 | })); |
| 2293 | self.dropObserver(i); | 2302 | self.dropObserver(i); |
| 2294 | self.pending_upgrade = .{ .path = path, .memfd = memfd }; | 2303 | self.pending_upgrade = .{ .path = path, .carrier = carrier }; |
| 2295 | }, | 2304 | }, |
| 2296 | // Where `mux a status` actually lands: it asks and exits without | 2305 | // Where `mux a status` actually lands: it asks and exits without |
| 2297 | // ever attaching. Blocking reply for the same reason the stats | 2306 | // ever attaching. Blocking reply for the same reason the stats |
| @@ -2824,7 +2833,7 @@ pub const Server = struct { | |||
| 2824 | const writer_path = try std.fs.selfExePath(&exe_buf); | 2833 | const writer_path = try std.fs.selfExePath(&exe_buf); |
| 2825 | 2834 | ||
| 2826 | // QUIC runtime state: arm, fd, bound address, idle, key. The key | 2835 | // QUIC runtime state: arm, fd, bound address, idle, key. The key |
| 2827 | // crosses as bytes — the memfd is anonymous and the file may have | 2836 | // crosses as bytes — no path names the carrier and the file may have |
| 2828 | // moved. None means no QUIC at all. | 2837 | // moved. None means no QUIC at all. |
| 2829 | var quic_state: upgrade.QuicState = .{}; | 2838 | var quic_state: upgrade.QuicState = .{}; |
| 2830 | switch (self.quic) { | 2839 | switch (self.quic) { |
| @@ -2987,21 +2996,21 @@ pub const Server = struct { | |||
| 2987 | return null; | 2996 | return null; |
| 2988 | } | 2997 | } |
| 2989 | 2998 | ||
| 2990 | // Child-run `path run --resume-fd N --check` must exit 0. The memfd | 2999 | // Child-run `path run --resume-fd N --check` must exit 0. The carrier |
| 2991 | // is written fresh (no CLOEXEC — children must inherit it). --check | 3000 | // is written fresh (not CLOEXEC — children must inherit it). --check |
| 2992 | // does not exist until chunk D; this helper is the structure the e2e | 3001 | // does not exist until chunk D; this helper is the structure the e2e |
| 2993 | // legs will drive. | 3002 | // legs will drive. |
| 2994 | fn checkManifestResume(self: *Server, path: []const u8, my_version: []const u8) ?[]const u8 { | 3003 | fn checkManifestResume(self: *Server, path: []const u8, my_version: []const u8) ?[]const u8 { |
| 2995 | const a = self.alloc; | 3004 | const a = self.alloc; |
| 2996 | const memfd = std.posix.memfd_create("mux-upgrade", 0) catch | 3005 | const carrier = server_os.anonFd("mux-upgrade") catch |
| 2997 | return a.dupe(u8, "check: cannot create memfd") catch null; | 3006 | return a.dupe(u8, "check: cannot create the manifest carrier") catch null; |
| 2998 | defer std.posix.close(memfd); | 3007 | defer std.posix.close(carrier); |
| 2999 | 3008 | ||
| 3000 | self.writeManifestTo(memfd, my_version) catch | 3009 | self.writeManifestTo(carrier, my_version) catch |
| 3001 | return a.dupe(u8, "check: cannot write manifest") catch null; | 3010 | return a.dupe(u8, "check: cannot write manifest") catch null; |
| 3002 | 3011 | ||
| 3003 | var fdbuf: [12]u8 = undefined; | 3012 | var fdbuf: [12]u8 = undefined; |
| 3004 | const fd_str = std.fmt.bufPrint(&fdbuf, "{d}", .{memfd}) catch | 3013 | const fd_str = std.fmt.bufPrint(&fdbuf, "{d}", .{carrier}) catch |
| 3005 | return a.dupe(u8, "check: oom") catch null; | 3014 | return a.dupe(u8, "check: oom") catch null; |
| 3006 | const result = std.process.Child.run(.{ | 3015 | const result = std.process.Child.run(.{ |
| 3007 | .allocator = a, | 3016 | .allocator = a, |
| @@ -3017,7 +3026,11 @@ pub const Server = struct { | |||
| 3017 | 3026 | ||
| 3018 | // Clear FD_CLOEXEC on a descriptor so it survives execve. The upgrade | 3027 | // Clear FD_CLOEXEC on a descriptor so it survives execve. The upgrade |
| 3019 | // exec keeps the listener, QUIC UDP, pty masters, agent listeners and | 3028 | // exec keeps the listener, QUIC UDP, pty masters, agent listeners and |
| 3020 | // the manifest memfd; all are CLOEXEC by default and must be cleared. | 3029 | // the manifest carrier. Most of those are CLOEXEC by default; the |
| 3030 | // carrier is not (`server_os.anonFd` never sets the flag) and is on the | ||
| 3031 | // list because `restoreCloexec` re-seals every fd here after a failed | ||
| 3032 | // exec, so a carrier missing from it would stay inheritable by the | ||
| 3033 | // shells this daemon spawns next. | ||
| 3021 | pub fn clearCloexec(fd: std.posix.fd_t) !void { | 3034 | pub fn clearCloexec(fd: std.posix.fd_t) !void { |
| 3022 | const flags = try std.posix.fcntl(fd, std.posix.F.GETFD, 0); | 3035 | const flags = try std.posix.fcntl(fd, std.posix.F.GETFD, 0); |
| 3023 | _ = try std.posix.fcntl(fd, std.posix.F.SETFD, flags & ~@as(usize, std.posix.FD_CLOEXEC)); | 3036 | _ = try std.posix.fcntl(fd, std.posix.F.SETFD, flags & ~@as(usize, std.posix.FD_CLOEXEC)); |
| @@ -3039,11 +3052,11 @@ pub const Server = struct { | |||
| 3039 | std.debug.print("mux d: seal fd {d}: {t}\n", .{ fd, e }); | 3052 | std.debug.print("mux d: seal fd {d}: {t}\n", .{ fd, e }); |
| 3040 | } | 3053 | } |
| 3041 | 3054 | ||
| 3042 | /// The flag back on every fd `execUpgrade` cleared; the memfd it also | 3055 | /// The flag back on every fd `execUpgrade` cleared; the manifest carrier |
| 3043 | /// cleared is the caller's to close. The caller runs this only once the | 3056 | /// it also cleared is the caller's to close. The caller runs this only |
| 3044 | /// last rollback point is behind it, which is why the fds no session | 3057 | /// once the last rollback point is behind it, which is why the fds no |
| 3045 | /// adopted are CLOSED here rather than sealed: before this point they | 3058 | /// session adopted are CLOSED here rather than sealed: before this point |
| 3046 | /// still belong to the binary a rollback would exec, and after it | 3059 | /// they still belong to the binary a rollback would exec, and after it |
| 3047 | /// nothing will ever name them again. Clearing the count keeps a second | 3060 | /// nothing will ever name them again. Clearing the count keeps a second |
| 3048 | /// call from double-closing. | 3061 | /// call from double-closing. |
| 3049 | pub fn sealAdoptedFds(self: *Server) void { | 3062 | pub fn sealAdoptedFds(self: *Server) void { |
| @@ -3064,7 +3077,7 @@ pub const Server = struct { | |||
| 3064 | // it becomes the new binary. Every sink is bare-closed, NEVER sent | 3077 | // it becomes the new binary. Every sink is bare-closed, NEVER sent |
| 3065 | // `exit_status` — that is a dying shell's word and makes clients exit | 3078 | // `exit_status` — that is a dying shell's word and makes clients exit |
| 3066 | // instead of redial. A failed exec must leave a working daemon. | 3079 | // instead of redial. A failed exec must leave a working daemon. |
| 3067 | fn execUpgrade(self: *Server, path: []const u8, memfd: std.posix.fd_t) void { | 3080 | fn execUpgrade(self: *Server, path: []const u8, carrier: std.posix.fd_t) void { |
| 3068 | const a = self.alloc; | 3081 | const a = self.alloc; |
| 3069 | 3082 | ||
| 3070 | // Every return below is a FAILED exec, so the arming is spent either | 3083 | // Every return below is a FAILED exec, so the arming is spent either |
| @@ -3072,13 +3085,13 @@ pub const Server = struct { | |||
| 3072 | // exec on a loop. | 3085 | // exec on a loop. |
| 3073 | defer { | 3086 | defer { |
| 3074 | a.free(path); | 3087 | a.free(path); |
| 3075 | std.posix.close(memfd); | 3088 | std.posix.close(carrier); |
| 3076 | self.pending_upgrade = null; | 3089 | self.pending_upgrade = null; |
| 3077 | } | 3090 | } |
| 3078 | 3091 | ||
| 3079 | // Collect the fds to clear CLOEXEC on, so they can be restored on | 3092 | // Collect the fds to clear CLOEXEC on, so they can be restored on |
| 3080 | // failure. The listener, QUIC UDP (if any), every pty master, every | 3093 | // failure. The listener, QUIC UDP (if any), every pty master, every |
| 3081 | // agent listener, and the memfd. | 3094 | // agent listener, and the manifest carrier. |
| 3082 | var cleared: std.ArrayList(std.posix.fd_t) = .empty; | 3095 | var cleared: std.ArrayList(std.posix.fd_t) = .empty; |
| 3083 | defer cleared.deinit(a); | 3096 | defer cleared.deinit(a); |
| 3084 | // After the list's own defer, so it runs before it: one restore | 3097 | // After the list's own defer, so it runs before it: one restore |
| @@ -3106,8 +3119,8 @@ pub const Server = struct { | |||
| 3106 | } | 3119 | } |
| 3107 | } | 3120 | } |
| 3108 | 3121 | ||
| 3109 | clearCloexec(memfd) catch return; | 3122 | clearCloexec(carrier) catch return; |
| 3110 | cleared.append(a, memfd) catch return; | 3123 | cleared.append(a, carrier) catch return; |
| 3111 | 3124 | ||
| 3112 | // Bare-close every client sink and observer. NOT exit_status: that | 3125 | // Bare-close every client sink and observer. NOT exit_status: that |
| 3113 | // tells a client its shell died, and it exits instead of redialing. | 3126 | // tells a client its shell died, and it exits instead of redialing. |
| @@ -3131,7 +3144,7 @@ pub const Server = struct { | |||
| 3131 | // candidate is by definition NEWER than this binary, so it reads the | 3144 | // candidate is by definition NEWER than this binary, so it reads the |
| 3132 | // mode word. | 3145 | // mode word. |
| 3133 | var fd_buf: [12]u8 = undefined; | 3146 | var fd_buf: [12]u8 = undefined; |
| 3134 | const fd_str = std.fmt.bufPrintZ(&fd_buf, "{d}", .{memfd}) catch return; | 3147 | const fd_str = std.fmt.bufPrintZ(&fd_buf, "{d}", .{carrier}) catch return; |
| 3135 | const argv = [_:null]?[*:0]const u8{ | 3148 | const argv = [_:null]?[*:0]const u8{ |
| 3136 | "mux", | 3149 | "mux", |
| 3137 | "d", | 3150 | "d", |
| @@ -3201,21 +3214,6 @@ pub const Server = struct { | |||
| 3201 | std.debug.assert(@sizeOf(SessionsBuf) == proto.sessions_text_max); | 3214 | std.debug.assert(@sizeOf(SessionsBuf) == proto.sessions_text_max); |
| 3202 | } | 3215 | } |
| 3203 | 3216 | ||
| 3204 | /// Whether the file this daemon was exec'd from has been replaced or | ||
| 3205 | /// removed since: `/proc/self/exe` keeps resolving THROUGH to the old | ||
| 3206 | /// image, so the daemon runs fine, but the kernel appends " (deleted)" | ||
| 3207 | /// to the link's text. That suffix is the whole staleness check — no | ||
| 3208 | /// version compare can catch it, because a rebuild of the same dev | ||
| 3209 | /// version spells the same string. Read fresh per ask: a rename lands | ||
| 3210 | /// under a running daemon at any moment, and one readlink per | ||
| 3211 | /// `sessions_req` is nothing. Unknown is reported not-stale — a wall | ||
| 3212 | /// must not dress a healthy box in a warning because /proc was coy. | ||
| 3213 | fn selfImageStale() bool { | ||
| 3214 | var buf: [std.fs.max_path_bytes]u8 = undefined; | ||
| 3215 | const p = std.posix.readlink("/proc/self/exe", &buf) catch return false; | ||
| 3216 | return std.mem.endsWith(u8, p, " (deleted)"); | ||
| 3217 | } | ||
| 3218 | |||
| 3219 | /// A gauge: an unattached QUIC handshake holds a slot, unobservably. | 3217 | /// A gauge: an unattached QUIC handshake holds a slot, unobservably. |
| 3220 | fn liveClients(self: *const Server) usize { | 3218 | fn liveClients(self: *const Server) usize { |
| 3221 | return countLive(&self.clients); | 3219 | return countLive(&self.clients); |
src/server/server_agent.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,7 @@ | |||
| 12 | 12 | ||
| 13 | const std = @import("std"); | 13 | const std = @import("std"); |
| 14 | const proto = @import("term").protocol; | 14 | const proto = @import("term").protocol; |
| 15 | const server_os = @import("server_os"); | ||
| 15 | const xdg = @import("xdg"); | 16 | const xdg = @import("xdg"); |
| 16 | const serve = @import("serve"); | 17 | const serve = @import("serve"); |
| 17 | const srv_mod = @import("server.zig"); | 18 | const srv_mod = @import("server.zig"); |
| @@ -131,7 +132,7 @@ pub const AgentRelay = struct { | |||
| 131 | const dir = std.fmt.allocPrint( | 132 | const dir = std.fmt.allocPrint( |
| 132 | alloc, | 133 | alloc, |
| 133 | "{s}/mux-agent-{d}-{x:0>12}", | 134 | "{s}/mux-agent-{d}-{x:0>12}", |
| 134 | .{ parent, std.os.linux.getpid(), std.crypto.random.int(u48) }, | 135 | .{ parent, server_os.getpid(), std.crypto.random.int(u48) }, |
| 135 | ) catch { | 136 | ) catch { |
| 136 | std.debug.print( | 137 | std.debug.print( |
| 137 | "mux d: agent forwarding unavailable (out of memory naming the " ++ | 138 | "mux d: agent forwarding unavailable (out of memory naming the " ++ |
src/server/server_test_await.zig
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,6 @@ | |||
| 1 | const std = @import("std"); | 1 | const std = @import("std"); |
| 2 | const proto = @import("term").protocol; | 2 | const proto = @import("term").protocol; |
| 3 | const server_os = @import("server_os"); | ||
| 3 | const shellint = @import("shellint.zig"); | 4 | const shellint = @import("shellint.zig"); |
| 4 | const TmpDir = @import("testtmp").TmpDir; | 5 | const TmpDir = @import("testtmp").TmpDir; |
| 5 | const h = @import("server_test_harness.zig"); | 6 | const h = @import("server_test_harness.zig"); |
| @@ -371,7 +372,7 @@ test "Server: an entry already at the daemon's pid name costs neither the marks | |||
| 371 | const planted = try std.fmt.allocPrint( | 372 | const planted = try std.fmt.allocPrint( |
| 372 | alloc, | 373 | alloc, |
| 373 | "{s}/mux-shellint-{d}", | 374 | "{s}/mux-shellint-{d}", |
| 374 | .{ td.tmp.path(), std.os.linux.getpid() }, | 375 | .{ td.tmp.path(), server_os.getpid() }, |
| 375 | ); | 376 | ); |
| 376 | defer alloc.free(planted); | 377 | defer alloc.free(planted); |
| 377 | try std.fs.cwd().writeFile(.{ .sub_path = planted, .data = "not a directory" }); | 378 | try std.fs.cwd().writeFile(.{ .sub_path = planted, .data = "not a directory" }); |
src/server/server_test_harness.zig
| Old | New | ||
|---|---|---|---|
| @@ -220,12 +220,9 @@ pub fn applyFrame(alloc: std.mem.Allocator, replica: *Engine, frame: proto.Frame | |||
| 220 | pub const SockPair = struct { daemon: std.posix.fd_t, peer: std.posix.fd_t }; | 220 | pub const SockPair = struct { daemon: std.posix.fd_t, peer: std.posix.fd_t }; |
| 221 | 221 | ||
| 222 | pub fn connectedPair() !SockPair { | 222 | pub fn connectedPair() !SockPair { |
| 223 | var fds: [2]i32 = undefined; | 223 | var fds: [2]std.posix.fd_t = undefined; |
| 224 | const rc = std.os.linux.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &fds); | 224 | const rc = std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &fds); |
| 225 | switch (std.posix.errno(rc)) { | 225 | if (rc != 0) return std.posix.unexpectedErrno(std.posix.errno(rc)); |
| 226 | .SUCCESS => {}, | ||
| 227 | else => |e| return std.posix.unexpectedErrno(e), | ||
| 228 | } | ||
| 229 | return .{ .daemon = fds[0], .peer = fds[1] }; | 226 | return .{ .daemon = fds[0], .peer = fds[1] }; |
| 230 | } | 227 | } |
| 231 | 228 | ||
src/server/server_test_session.zig
| Old | New | ||
|---|---|---|---|
| @@ -780,13 +780,15 @@ test "Server: a table of TERM-ignoring shells costs one grace, not one each" { | |||
| 780 | return error.TeardownGraceNotShared; | 780 | return error.TeardownGraceNotShared; |
| 781 | } | 781 | } |
| 782 | 782 | ||
| 783 | // Ask the OS, not the daemon: a reaped child has no /proc entry, so | 783 | // Ask the OS, not the daemon: a pid that was reaped names no process at |
| 784 | // this catches both a survivor and a zombie the daemon claimed to have | 784 | // all, so `kill(pid, 0)` must fail. That catches a survivor, and it |
| 785 | // waited for. | 785 | // catches a zombie the daemon claimed to have waited for too — a zombie |
| 786 | // is still a process table entry and `kill(pid, 0)` on one SUCCEEDS, | ||
| 787 | // which is the same verdict the /proc read this replaces gave, since a | ||
| 788 | // zombie keeps its /proc directory. Spelled through `kill` rather than | ||
| 789 | // /proc because every OS has it. | ||
| 786 | for (kids[0..n]) |pid| { | 790 | for (kids[0..n]) |pid| { |
| 787 | var pb: [32]u8 = undefined; | 791 | try std.testing.expectError(error.ProcessNotFound, std.posix.kill(pid, 0)); |
| 788 | const proc = try std.fmt.bufPrint(&pb, "/proc/{d}", .{pid}); | ||
| 789 | try std.testing.expectError(error.FileNotFound, std.fs.cwd().access(proc, .{})); | ||
| 790 | } | 792 | } |
| 791 | } | 793 | } |
| 792 | 794 | ||
src/server/server_test_upgrade.zig
| Old | New | ||
|---|---|---|---|
| @@ -2,6 +2,7 @@ const std = @import("std"); | |||
| 2 | const proto = @import("term").protocol; | 2 | const proto = @import("term").protocol; |
| 3 | const upgrade = @import("upgrade.zig"); | 3 | const upgrade = @import("upgrade.zig"); |
| 4 | const TmpDir = @import("testtmp").TmpDir; | 4 | const TmpDir = @import("testtmp").TmpDir; |
| 5 | const server_os = @import("server_os"); | ||
| 5 | const h = @import("server_test_harness.zig"); | 6 | const h = @import("server_test_harness.zig"); |
| 6 | const dial = h.dial; | 7 | const dial = h.dial; |
| 7 | const srv_mod = @import("server.zig"); | 8 | const srv_mod = @import("server.zig"); |
| @@ -22,11 +23,11 @@ test "writeManifestTo: every session's own name crosses, not the last one's" { | |||
| 22 | td.srv.sessions.table[1] = try SessionTable.create(alloc, td.srv.spawn_plan, "work", 80, 24, null); | 23 | td.srv.sessions.table[1] = try SessionTable.create(alloc, td.srv.spawn_plan, "work", 80, 24, null); |
| 23 | td.srv.sessions.table[2] = try SessionTable.create(alloc, td.srv.spawn_plan, "logs", 80, 24, null); | 24 | td.srv.sessions.table[2] = try SessionTable.create(alloc, td.srv.spawn_plan, "logs", 80, 24, null); |
| 24 | 25 | ||
| 25 | const memfd = try std.posix.memfd_create("mux-names-test", 0); | 26 | const carrier = try server_os.anonFd("mux-names-test"); |
| 26 | defer std.posix.close(memfd); | 27 | defer std.posix.close(carrier); |
| 27 | try td.srv.writeManifestTo(memfd, "0.0.1-99"); | 28 | try td.srv.writeManifestTo(carrier, "0.0.1-99"); |
| 28 | 29 | ||
| 29 | var file = std.fs.File{ .handle = memfd }; | 30 | var file = std.fs.File{ .handle = carrier }; |
| 30 | try file.seekTo(0); | 31 | try file.seekTo(0); |
| 31 | const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024); | 32 | const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024); |
| 32 | defer alloc.free(buf); | 33 | defer alloc.free(buf); |
| @@ -56,13 +57,13 @@ test "writeManifestTo: what crosses is what a session cannot rebuild" { | |||
| 56 | // Feed some content so dumpState is non-empty. | 57 | // Feed some content so dumpState is non-empty. |
| 57 | s.eng.feed("hello\r\n"); | 58 | s.eng.feed("hello\r\n"); |
| 58 | 59 | ||
| 59 | const memfd = try std.posix.memfd_create("mux-upgrade-test", 0); | 60 | const carrier = try server_os.anonFd("mux-upgrade-test"); |
| 60 | defer std.posix.close(memfd); | 61 | defer std.posix.close(carrier); |
| 61 | 62 | ||
| 62 | try td.srv.writeManifestTo(memfd, "0.0.1-99"); | 63 | try td.srv.writeManifestTo(carrier, "0.0.1-99"); |
| 63 | 64 | ||
| 64 | // Read back and parse. | 65 | // Read back and parse. |
| 65 | var file = std.fs.File{ .handle = memfd }; | 66 | var file = std.fs.File{ .handle = carrier }; |
| 66 | try file.seekTo(0); | 67 | try file.seekTo(0); |
| 67 | const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024); | 68 | const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024); |
| 68 | defer alloc.free(buf); | 69 | defer alloc.free(buf); |
| @@ -119,9 +120,9 @@ test "initFromManifest: an adopted session answers a status_req without having b | |||
| 119 | const old_epoch = srv.sessions.table[0].?.epoch; | 120 | const old_epoch = srv.sessions.table[0].?.epoch; |
| 120 | const child = srv.sessions.table[0].?.pty.child; | 121 | const child = srv.sessions.table[0].?.pty.child; |
| 121 | 122 | ||
| 122 | const memfd = try std.posix.memfd_create("mux-adopt-test", 0); | 123 | const carrier = try server_os.anonFd("mux-adopt-test"); |
| 123 | defer std.posix.close(memfd); | 124 | defer std.posix.close(carrier); |
| 124 | try srv.writeManifestTo(memfd, "0.0.1-99"); | 125 | try srv.writeManifestTo(carrier, "0.0.1-99"); |
| 125 | 126 | ||
| 126 | // Release the first Server's MEMORY by hand instead of calling deinit: | 127 | // Release the first Server's MEMORY by hand instead of calling deinit: |
| 127 | // deinit is the demolition list — it unlinks the socket, SIGKILLs the | 128 | // deinit is the demolition list — it unlinks the socket, SIGKILLs the |
| @@ -138,7 +139,7 @@ test "initFromManifest: an adopted session answers a status_req without having b | |||
| 138 | if (srv.agents.dir) |d| alloc.free(d); | 139 | if (srv.agents.dir) |d| alloc.free(d); |
| 139 | srv.shellint_arena.deinit(); | 140 | srv.shellint_arena.deinit(); |
| 140 | 141 | ||
| 141 | var file = std.fs.File{ .handle = memfd }; | 142 | var file = std.fs.File{ .handle = carrier }; |
| 142 | try file.seekTo(0); | 143 | try file.seekTo(0); |
| 143 | const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024); | 144 | const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024); |
| 144 | defer alloc.free(buf); | 145 | defer alloc.free(buf); |
| @@ -191,9 +192,9 @@ test "initFromManifest: a session whose agent socket file vanished loses forward | |||
| 191 | // failed adoption nothing on either Server names it any more. | 192 | // failed adoption nothing on either Server names it any more. |
| 192 | const agent_fd = srv.sessions.table[0].?.agentFd(); | 193 | const agent_fd = srv.sessions.table[0].?.agentFd(); |
| 193 | 194 | ||
| 194 | const memfd = try std.posix.memfd_create("mux-goneagent-test", 0); | 195 | const carrier = try server_os.anonFd("mux-goneagent-test"); |
| 195 | defer std.posix.close(memfd); | 196 | defer std.posix.close(carrier); |
| 196 | try srv.writeManifestTo(memfd, "0.0.1-99"); | 197 | try srv.writeManifestTo(carrier, "0.0.1-99"); |
| 197 | 198 | ||
| 198 | // The seam: something outside mux cleaned the runtime directory between | 199 | // The seam: something outside mux cleaned the runtime directory between |
| 199 | // the manifest and the exec. Adoption re-stamps the socket's id from the | 200 | // the manifest and the exec. Adoption re-stamps the socket's id from the |
| @@ -213,7 +214,7 @@ test "initFromManifest: a session whose agent socket file vanished loses forward | |||
| 213 | if (srv.agents.dir) |d| alloc.free(d); | 214 | if (srv.agents.dir) |d| alloc.free(d); |
| 214 | srv.shellint_arena.deinit(); | 215 | srv.shellint_arena.deinit(); |
| 215 | 216 | ||
| 216 | var file = std.fs.File{ .handle = memfd }; | 217 | var file = std.fs.File{ .handle = carrier }; |
| 217 | try file.seekTo(0); | 218 | try file.seekTo(0); |
| 218 | const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024); | 219 | const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024); |
| 219 | defer alloc.free(buf); | 220 | defer alloc.free(buf); |
| @@ -271,9 +272,9 @@ test "initFromManifest: the return watermark is re-stamped, never carried across | |||
| 271 | .seq = 999, | 272 | .seq = 999, |
| 272 | }; | 273 | }; |
| 273 | 274 | ||
| 274 | const memfd = try std.posix.memfd_create("mux-watermark-test", 0); | 275 | const carrier = try server_os.anonFd("mux-watermark-test"); |
| 275 | defer std.posix.close(memfd); | 276 | defer std.posix.close(carrier); |
| 276 | try srv.writeManifestTo(memfd, "0.0.1-99"); | 277 | try srv.writeManifestTo(carrier, "0.0.1-99"); |
| 277 | 278 | ||
| 278 | // Memory only; the descriptors and the child are srv2's to tear down. | 279 | // Memory only; the descriptors and the child are srv2's to tear down. |
| 279 | { | 280 | { |
| @@ -286,7 +287,7 @@ test "initFromManifest: the return watermark is re-stamped, never carried across | |||
| 286 | if (srv.agents.dir) |d| alloc.free(d); | 287 | if (srv.agents.dir) |d| alloc.free(d); |
| 287 | srv.shellint_arena.deinit(); | 288 | srv.shellint_arena.deinit(); |
| 288 | 289 | ||
| 289 | var file = std.fs.File{ .handle = memfd }; | 290 | var file = std.fs.File{ .handle = carrier }; |
| 290 | try file.seekTo(0); | 291 | try file.seekTo(0); |
| 291 | const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024); | 292 | const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024); |
| 292 | defer alloc.free(buf); | 293 | defer alloc.free(buf); |
| @@ -353,10 +354,12 @@ test "validateUpgrade: a non-executable path is refused" { | |||
| 353 | if (reason) |r| alloc.free(r); | 354 | if (reason) |r| alloc.free(r); |
| 354 | } | 355 | } |
| 355 | 356 | ||
| 356 | test "clearCloexec: a CLOEXEC memfd's flag flips" { | 357 | test "clearCloexec: a CLOEXEC carrier's flag flips" { |
| 357 | // Create a memfd WITH CLOEXEC, then clear it and verify via F_GETFD. | 358 | // A carrier is never CLOEXEC, so arm the flag with the production |
| 358 | const fd = try std.posix.memfd_create("mux-clearcloexec-test", 0x0001); | 359 | // inverse of the call under test, then clear it and read F_GETFD. |
| 360 | const fd = try server_os.anonFd("mux-clearcloexec-test"); | ||
| 359 | defer std.posix.close(fd); | 361 | defer std.posix.close(fd); |
| 362 | try Server.setCloexec(fd); | ||
| 360 | 363 | ||
| 361 | // Confirm it starts with CLOEXEC. | 364 | // Confirm it starts with CLOEXEC. |
| 362 | const before = try std.posix.fcntl(fd, std.posix.F.GETFD, 0); | 365 | const before = try std.posix.fcntl(fd, std.posix.F.GETFD, 0); |
| @@ -387,9 +390,9 @@ test "sealAdoptedFds: the adopted fds are CLOEXEC again, and not one step before | |||
| 387 | defer alloc.free(sock_path); | 390 | defer alloc.free(sock_path); |
| 388 | 391 | ||
| 389 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" }); | 392 | var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" }); |
| 390 | const memfd = try std.posix.memfd_create("mux-cloexec-test", 0); | 393 | const carrier = try server_os.anonFd("mux-cloexec-test"); |
| 391 | defer std.posix.close(memfd); | 394 | defer std.posix.close(carrier); |
| 392 | try srv.writeManifestTo(memfd, "0.0.1-99"); | 395 | try srv.writeManifestTo(carrier, "0.0.1-99"); |
| 393 | 396 | ||
| 394 | // What execUpgrade does on the way out: the flag is cleared so the fds | 397 | // What execUpgrade does on the way out: the flag is cleared so the fds |
| 395 | // cross the exec. The adopting side must put it back. | 398 | // cross the exec. The adopting side must put it back. |
| @@ -410,7 +413,7 @@ test "sealAdoptedFds: the adopted fds are CLOEXEC again, and not one step before | |||
| 410 | if (srv.agents.dir) |d| alloc.free(d); | 413 | if (srv.agents.dir) |d| alloc.free(d); |
| 411 | srv.shellint_arena.deinit(); | 414 | srv.shellint_arena.deinit(); |
| 412 | 415 | ||
| 413 | var file = std.fs.File{ .handle = memfd }; | 416 | var file = std.fs.File{ .handle = carrier }; |
| 414 | try file.seekTo(0); | 417 | try file.seekTo(0); |
| 415 | const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024); | 418 | const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024); |
| 416 | defer alloc.free(buf); | 419 | defer alloc.free(buf); |
| @@ -557,9 +560,9 @@ test "Server: an EMPTY daemon upgrades — the manifest names no session and the | |||
| 557 | return error.EmptyDaemonRefusedUpgrade; | 560 | return error.EmptyDaemonRefusedUpgrade; |
| 558 | } | 561 | } |
| 559 | 562 | ||
| 560 | const memfd = try std.posix.memfd_create("mux-empty-upgrade", 0); | 563 | const carrier = try server_os.anonFd("mux-empty-upgrade"); |
| 561 | defer std.posix.close(memfd); | 564 | defer std.posix.close(carrier); |
| 562 | try srv.writeManifestTo(memfd, "0.0.1-1"); | 565 | try srv.writeManifestTo(carrier, "0.0.1-1"); |
| 563 | 566 | ||
| 564 | // The first Server's MEMORY only, never deinit: deinit unlinks the | 567 | // The first Server's MEMORY only, never deinit: deinit unlinks the |
| 565 | // socket and deleteTrees the dirs, and the adopting Server is about to | 568 | // socket and deleteTrees the dirs, and the adopting Server is about to |
| @@ -567,7 +570,7 @@ test "Server: an EMPTY daemon upgrades — the manifest names no session and the | |||
| 567 | if (srv.agents.dir) |d| alloc.free(d); | 570 | if (srv.agents.dir) |d| alloc.free(d); |
| 568 | srv.shellint_arena.deinit(); | 571 | srv.shellint_arena.deinit(); |
| 569 | 572 | ||
| 570 | var file = std.fs.File{ .handle = memfd }; | 573 | var file = std.fs.File{ .handle = carrier }; |
| 571 | try file.seekTo(0); | 574 | try file.seekTo(0); |
| 572 | const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024); | 575 | const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024); |
| 573 | defer alloc.free(buf); | 576 | defer alloc.free(buf); |
src/server/shellint.zig
| Old | New | ||
|---|---|---|---|
| @@ -4,6 +4,7 @@ | |||
| 4 | //! nothing and the session runs on the pgid/settle fallbacks. | 4 | //! nothing and the session runs on the pgid/settle fallbacks. |
| 5 | const std = @import("std"); | 5 | const std = @import("std"); |
| 6 | const xdg = @import("xdg"); | 6 | const xdg = @import("xdg"); |
| 7 | const server_os = @import("server_os"); | ||
| 7 | 8 | ||
| 8 | /// One copy for both shells: a second would drift, silently, in one of | 9 | /// One copy for both shells: a second would drift, silently, in one of |
| 9 | /// them. `local code=$?` must stay FIRST: any line above it clobbers $?. | 10 | /// them. `local code=$?` must stay FIRST: any line above it clobbers $?. |
| @@ -174,7 +175,7 @@ pub fn install( | |||
| 174 | const dir = std.fmt.allocPrint( | 175 | const dir = std.fmt.allocPrint( |
| 175 | arena, | 176 | arena, |
| 176 | "{s}/mux-shellint-{d}-{x:0>12}", | 177 | "{s}/mux-shellint-{d}-{x:0>12}", |
| 177 | .{ parent_dir, std.os.linux.getpid(), std.crypto.random.int(u48) }, | 178 | .{ parent_dir, server_os.getpid(), std.crypto.random.int(u48) }, |
| 178 | ) catch { | 179 | ) catch { |
| 179 | std.debug.print( | 180 | std.debug.print( |
| 180 | "mux d: shell integration unavailable (out of memory naming the shim " ++ | 181 | "mux d: shell integration unavailable (out of memory naming the shim " ++ |
| @@ -443,7 +444,7 @@ test "install names the shim directory after the daemon and degrades in place" { | |||
| 443 | const prefix = try std.fmt.bufPrint( | 444 | const prefix = try std.fmt.bufPrint( |
| 444 | &want, | 445 | &want, |
| 445 | "{s}/mux-shellint-{d}-", | 446 | "{s}/mux-shellint-{d}-", |
| 446 | .{ t.dir, std.os.linux.getpid() }, | 447 | .{ t.dir, server_os.getpid() }, |
| 447 | ); | 448 | ); |
| 448 | // A prefix, not the whole name: the pid is followed by 12 hex digits of | 449 | // A prefix, not the whole name: the pid is followed by 12 hex digits of |
| 449 | // randomness, and the two halves answer different questions — the pid | 450 | // randomness, and the two halves answer different questions — the pid |
src/server/upgrade.zig
| Old | New | ||
|---|---|---|---|
| @@ -50,8 +50,8 @@ pub const QuicArm = enum(u8) { none = 0, borrowed = 1, owned = 2 }; | |||
| 50 | 50 | ||
| 51 | /// Runtime QUIC state, not launch flags: lazyBindQuic can own an | 51 | /// Runtime QUIC state, not launch flags: lazyBindQuic can own an |
| 52 | /// ephemeral-port listener no flag names, so the manifest records what IS | 52 | /// ephemeral-port listener no flag names, so the manifest records what IS |
| 53 | /// bound, arm and all. The key crosses as bytes, never a path — the memfd | 53 | /// bound, arm and all. The key crosses as bytes, never a path — no path |
| 54 | /// is anonymous memory and the file the key came from may have moved. | 54 | /// names the carrier and the file the key came from may have moved. |
| 55 | pub const QuicState = struct { | 55 | pub const QuicState = struct { |
| 56 | arm: QuicArm = .none, | 56 | arm: QuicArm = .none, |
| 57 | fd: i32 = -1, | 57 | fd: i32 = -1, |
src/sockpath.zig
| Old | New | ||
|---|---|---|---|
| @@ -4,12 +4,13 @@ | |||
| 4 | //! other refuses to delete a successor's socket. Nothing here knows a Server | 4 | //! other refuses to delete a successor's socket. Nothing here knows a Server |
| 5 | //! exists; a path is all it takes. | 5 | //! exists; a path is all it takes. |
| 6 | const std = @import("std"); | 6 | const std = @import("std"); |
| 7 | const builtin = @import("builtin"); | ||
| 7 | 8 | ||
| 8 | /// The usable bytes of `sockaddr_un.sun_path`: the field is 108 and the | 9 | /// The usable bytes of `sockaddr_un.sun_path`: the field less the NUL. |
| 9 | /// last one belongs to the NUL. Private, and that is the point — the | 10 | /// Derived from the kernel's own struct rather than spelled — 108 on |
| 10 | /// number is the kernel's, and every binary that once re-compared it | 11 | /// Linux, 104 on the BSDs — and private, because every binary that once |
| 11 | /// grew its own wording for the same refusal. | 12 | /// re-compared it grew its own wording for the same refusal. |
| 12 | const max_sun_path = 107; | 13 | const max_sun_path = @sizeOf(@FieldType(std.posix.sockaddr.un, "path")) - 1; |
| 13 | 14 | ||
| 14 | /// The refusal, for whoever is about to BIND. Everyone else dials and | 15 | /// The refusal, for whoever is about to BIND. Everyone else dials and |
| 15 | /// reads the kernel's own `NameTooLong`. | 16 | /// reads the kernel's own `NameTooLong`. |
| @@ -42,10 +43,21 @@ pub fn defaultOrExplain(alloc: std.mem.Allocator, prefix: []const u8) !?[]const | |||
| 42 | }; | 43 | }; |
| 43 | } | 44 | } |
| 44 | 45 | ||
| 45 | /// No fallback when `$XDG_RUNTIME_DIR` is unset: a guess cannot make two | 46 | /// The directory the default daemon socket and every per-wall socket live |
| 46 | /// binaries agree on one daemon, so the caller names it. | 47 | /// in, or null. On Linux that is `$XDG_RUNTIME_DIR` and there is NO |
| 48 | /// fallback: a guess cannot make two binaries agree on one daemon, so the | ||
| 49 | /// caller names it with --sock. Another OS spells its own default here, | ||
| 50 | /// once, so the daemon, the client and the askpass listener agree by | ||
| 51 | /// construction. | ||
| 52 | pub fn runtimeDir() ?[]const u8 { | ||
| 53 | return switch (builtin.os.tag) { | ||
| 54 | .linux => std.posix.getenv("XDG_RUNTIME_DIR"), | ||
| 55 | else => @compileError("mux has no default runtime directory for " ++ @tagName(builtin.os.tag)), | ||
| 56 | }; | ||
| 57 | } | ||
| 58 | |||
| 47 | pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 { | 59 | pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 { |
| 48 | return sockPathFrom(alloc, std.posix.getenv("XDG_RUNTIME_DIR")); | 60 | return sockPathFrom(alloc, runtimeDir()); |
| 49 | } | 61 | } |
| 50 | 62 | ||
| 51 | /// `defaultSockPath` with its environment named rather than read, because | 63 | /// `defaultSockPath` with its environment named rather than read, because |
| @@ -128,6 +140,16 @@ pub fn claim(path: []const u8) !void { | |||
| 128 | }; | 140 | }; |
| 129 | } | 141 | } |
| 130 | 142 | ||
| 143 | test "sockpath.max_sun_path is the kernel's field less its NUL, not a number of ours" { | ||
| 144 | try std.testing.expectEqual(@sizeOf(@FieldType(std.posix.sockaddr.un, "path")) - 1, max_sun_path); | ||
| 145 | |||
| 146 | // And on the one OS mux runs on today the derivation must still land on | ||
| 147 | // the number the comments and the e2e scripts reason about. Without | ||
| 148 | // this line the assertion above is a tautology, true of any expression | ||
| 149 | // the constant is spelled with. | ||
| 150 | if (builtin.os.tag == .linux) try std.testing.expectEqual(107, max_sun_path); | ||
| 151 | } | ||
| 152 | |||
| 131 | test "default path: an unset XDG_RUNTIME_DIR is refused, never guessed" { | 153 | test "default path: an unset XDG_RUNTIME_DIR is refused, never guessed" { |
| 132 | const alloc = std.testing.allocator; | 154 | const alloc = std.testing.allocator; |
| 133 | 155 | ||
| @@ -172,7 +194,7 @@ test "answers: a live listener, a stale socket file, and a path with nothing on | |||
| 172 | test "`answers` is a read and `claim` is a decision: an unreachable socket is a no to one and an errno to the other" { | 194 | test "`answers` is a read and `claim` is a decision: an unreachable socket is a no to one and an errno to the other" { |
| 173 | // chmod does not bite root, so the connect would succeed and the test | 195 | // chmod does not bite root, so the connect would succeed and the test |
| 174 | // would assert the opposite of what it is named for. | 196 | // would assert the opposite of what it is named for. |
| 175 | if (std.os.linux.geteuid() == 0) return error.SkipZigTest; | 197 | if (std.posix.geteuid() == 0) return error.SkipZigTest; |
| 176 | 198 | ||
| 177 | const testtmp = @import("testtmp"); | 199 | const testtmp = @import("testtmp"); |
| 178 | var tmp = try testtmp.TmpDir.make(); | 200 | var tmp = try testtmp.TmpDir.make(); |
src/tui/interact.zig
| Old | New | ||
|---|---|---|---|
| @@ -17,6 +17,7 @@ const Replica = @import("term").replica.Replica; | |||
| 17 | const proto = @import("term").protocol; | 17 | const proto = @import("term").protocol; |
| 18 | const predict = @import("predict.zig"); | 18 | const predict = @import("predict.zig"); |
| 19 | const client_core = @import("client").core; | 19 | const client_core = @import("client").core; |
| 20 | const client_os = @import("client_os"); | ||
| 20 | // Named `paint_mod` because paintOverlay holds a local ArrayList called | 21 | // Named `paint_mod` because paintOverlay holds a local ArrayList called |
| 21 | // `paint`, which a container-level `paint` would collide with. | 22 | // `paint`, which a container-level `paint` would collide with. |
| 22 | const paint_mod = @import("paint.zig"); | 23 | const paint_mod = @import("paint.zig"); |
| @@ -629,8 +630,7 @@ pub fn winchRaised() bool { | |||
| 629 | /// second copy would drift on exactly the 0x0 case below. | 630 | /// second copy would drift on exactly the 0x0 case below. |
| 630 | pub fn ttySize(fd: std.posix.fd_t) ?proto.Size { | 631 | pub fn ttySize(fd: std.posix.fd_t) ?proto.Size { |
| 631 | if (!std.posix.isatty(fd)) return null; | 632 | if (!std.posix.isatty(fd)) return null; |
| 632 | var ws: std.posix.winsize = undefined; | 633 | const ws = client_os.winSize(fd) orelse return null; |
| 633 | if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null; | ||
| 634 | // A pty can report 0x0 and a zero-sized grid is invalid for the engine, | 634 | // A pty can report 0x0 and a zero-sized grid is invalid for the engine, |
| 635 | // so that is "unknown". The floor is the daemon's own, read rather than | 635 | // so that is "unknown". The floor is the daemon's own, read rather than |
| 636 | // respelled: a size it refuses to move is as unusable as no size at all. | 636 | // respelled: a size it refuses to move is as unusable as no size at all. |
| @@ -3624,23 +3624,16 @@ const SelectionTransport = struct { | |||
| 3624 | } | 3624 | } |
| 3625 | }; | 3625 | }; |
| 3626 | 3626 | ||
| 3627 | /// A real pts pair, sized by ioctl — the OS answering about the OS, which | 3627 | /// A real pts pair — the OS answering about the OS, which is the only |
| 3628 | /// is the only thing `ttySize`'s own ioctl can be judged against. | 3628 | /// thing `ttySize`'s own read of the terminal can be judged against. The |
| 3629 | fn ptsPair() !struct { master: std.posix.fd_t, slave: std.posix.fd_t } { | 3629 | /// opening is `client_os.openPtyPair`'s; this name stays because the tests |
| 3630 | const master = try std.posix.open("/dev/ptmx", .{ .ACCMODE = .RDWR }, 0); | 3630 | /// below read as a story about a pts pair rather than about a platform row. |
| 3631 | errdefer std.posix.close(master); | 3631 | fn ptsPair() !client_os.PtyPair { |
| 3632 | var unlock: c_int = 0; | 3632 | return client_os.openPtyPair(); |
| 3633 | if (std.os.linux.ioctl(master, std.os.linux.T.IOCSPTLCK, @intFromPtr(&unlock)) != 0) return error.Unsupported; | ||
| 3634 | var idx: c_uint = 0; | ||
| 3635 | if (std.os.linux.ioctl(master, std.os.linux.T.IOCGPTN, @intFromPtr(&idx)) != 0) return error.Unsupported; | ||
| 3636 | var name_buf: [32]u8 = undefined; | ||
| 3637 | const name = try std.fmt.bufPrint(&name_buf, "/dev/pts/{d}", .{idx}); | ||
| 3638 | return .{ .master = master, .slave = try std.posix.open(name, .{ .ACCMODE = .RDWR, .NOCTTY = true }, 0) }; | ||
| 3639 | } | 3633 | } |
| 3640 | 3634 | ||
| 3641 | fn setTtySize(master: std.posix.fd_t, cols: u16, rows: u16) !void { | 3635 | fn setTtySize(master: std.posix.fd_t, cols: u16, rows: u16) !void { |
| 3642 | const ws: std.posix.winsize = .{ .col = cols, .row = rows, .xpixel = 0, .ypixel = 0 }; | 3636 | return client_os.setWinSize(master, .{ .col = cols, .row = rows, .xpixel = 0, .ypixel = 0 }); |
| 3643 | if (std.os.linux.ioctl(master, std.os.linux.T.IOCSWINSZ, @intFromPtr(&ws)) != 0) return error.Unsupported; | ||
| 3644 | } | 3637 | } |
| 3645 | 3638 | ||
| 3646 | test "interact: a terminal under the daemon's floor measures as unknown" { | 3639 | test "interact: a terminal under the daemon's floor measures as unknown" { |
src/tui/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -19,6 +19,10 @@ const select = @import("select.zig"); | |||
| 19 | // client: one interaction core, not a second copy (interact.zig). | 19 | // client: one interaction core, not a second copy (interact.zig). |
| 20 | const interact = @import("interact.zig"); | 20 | const interact = @import("interact.zig"); |
| 21 | const layout = @import("client").layout; | 21 | const layout = @import("client").layout; |
| 22 | // Where a per-wall socket lives, asked of the row that owns the answer for | ||
| 23 | // the daemon socket too: one spelling of the runtime directory, not a | ||
| 24 | // second getenv beside it. | ||
| 25 | const sockpath = @import("sockpath"); | ||
| 22 | const TmpDir = @import("testtmp").TmpDir; | 26 | const TmpDir = @import("testtmp").TmpDir; |
| 23 | const wall_host = @import("wall_host.zig"); | 27 | const wall_host = @import("wall_host.zig"); |
| 24 | const wall_layout = @import("wall_layout.zig"); | 28 | const wall_layout = @import("wall_layout.zig"); |
| @@ -180,8 +184,9 @@ pub const Shared = struct { | |||
| 180 | /// arrive with the picker already up. | 184 | /// arrive with the picker already up. |
| 181 | ask_open: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), | 185 | ask_open: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), |
| 182 | /// Where this wall answers ssh's prompts, and the binary ssh execs to | 186 | /// Where this wall answers ssh's prompts, and the binary ssh execs to |
| 183 | /// ask. Null is "ssh keeps its own prompts": without `$XDG_RUNTIME_DIR` | 187 | /// ask. Null is "ssh keeps its own prompts": without the runtime |
| 184 | /// the socket would land on a shared /tmp, answerable by any local user. | 188 | /// directory `sockpath.runtimeDir` names, the socket would land on a |
| 189 | /// shared /tmp, answerable by any local user. | ||
| 185 | prompts: ?*askpass.Listener = null, | 190 | prompts: ?*askpass.Listener = null, |
| 186 | prompt_exe: []const u8 = "", | 191 | prompt_exe: []const u8 = "", |
| 187 | 192 | ||
| @@ -1861,20 +1866,22 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry) | |||
| 1861 | 1866 | ||
| 1862 | // ssh's prompts get somewhere to go, on a TERMINAL only: without one | 1867 | // ssh's prompts get somewhere to go, on a TERMINAL only: without one |
| 1863 | // there is no popup, so ssh keeps its prompts and fails them visibly. | 1868 | // there is no popup, so ssh keeps its prompts and fails them visibly. |
| 1864 | // `$XDG_RUNTIME_DIR` and no fallback: a shared /tmp socket would be a | 1869 | // The runtime directory and no fallback: a shared /tmp socket would be |
| 1865 | // password prompt any local user could answer. | 1870 | // a password prompt any local user could answer. `sockpath.runtimeDir` |
| 1871 | // is the one place that names it, so the popup lands beside the daemon | ||
| 1872 | // socket on whatever OS this is. | ||
| 1866 | var ask_exe_buf: [std.fs.max_path_bytes]u8 = undefined; | 1873 | var ask_exe_buf: [std.fs.max_path_bytes]u8 = undefined; |
| 1867 | if (is_tty) { | 1874 | if (is_tty) { |
| 1868 | if (std.posix.getenv("XDG_RUNTIME_DIR")) |rt| { | 1875 | if (sockpath.runtimeDir()) |rt| { |
| 1869 | if (askpass.Listener.start(alloc, rt, .{ | 1876 | if (askpass.Listener.start(alloc, rt, .{ |
| 1870 | .ctx = &shared, | 1877 | .ctx = &shared, |
| 1871 | .wake = ringKeyboardCtx, | 1878 | .wake = ringKeyboardCtx, |
| 1872 | })) |l| { | 1879 | })) |l| { |
| 1873 | shared.prompts = l; | 1880 | shared.prompts = l; |
| 1874 | // The image that is RUNNING, resolved through /proc, and | 1881 | // The image that is RUNNING, resolved by `spawn.selfExe` |
| 1875 | // never a PATH walk: ssh execs this as its helper, and a | 1882 | // and never a PATH walk: ssh execs this as its helper, and |
| 1876 | // helper found by name is whichever mux the environment | 1883 | // a helper found by name is whichever mux the environment |
| 1877 | // happens to have (`spawn.selfExe`). | 1884 | // happens to have. |
| 1878 | shared.prompt_exe = spawn.selfExe(&ask_exe_buf); | 1885 | shared.prompt_exe = spawn.selfExe(&ask_exe_buf); |
| 1879 | } else |_| {} | 1886 | } else |_| {} |
| 1880 | } | 1887 | } |
src/xdg.zig
| Old | New | ||
|---|---|---|---|
| @@ -176,10 +176,19 @@ pub fn reapDeadPid(parent: []const u8, prefix: []const u8) void { | |||
| 176 | // else's files, not a pid we can ask about. | 176 | // else's files, not a pid we can ask about. |
| 177 | if (n == 0) continue; | 177 | if (n == 0) continue; |
| 178 | if (n < rest.len and rest[n] != '-' and rest[n] != '.') continue; | 178 | if (n < rest.len and rest[n] != '-' and rest[n] != '.') continue; |
| 179 | const pid = std.fmt.parseInt(u32, rest[0..n], 10) catch continue; | 179 | // Parsed as the type `kill` takes, so a digit run too long to BE a |
| 180 | var buf: [32]u8 = undefined; | 180 | // pid leaves with every other name we cannot ask about. Widening |
| 181 | const proc = std.fmt.bufPrint(&buf, "/proc/{d}", .{pid}) catch continue; | 181 | // first and narrowing at the call would panic on the cast instead, |
| 182 | const alive = if (std.fs.cwd().access(proc, .{})) true else |_| false; | 182 | // in a function whose contract is to reap nothing it cannot judge. |
| 183 | const pid = std.fmt.parseInt(std.posix.pid_t, rest[0..n], 10) catch continue; | ||
| 184 | // `kill(pid, 0)`: ESRCH — `error.ProcessNotFound` — is the ONE answer | ||
| 185 | // that means the pid is gone. Every other answer keeps the entry: | ||
| 186 | // EPERM says alive-but-not-ours, and an errno neither this Zig nor | ||
| 187 | // this kernel version has a name for says the OS would not answer, | ||
| 188 | // which is not evidence of death. Deleting on "don't know" would | ||
| 189 | // remove a live daemon's agent socket out from under it. A live | ||
| 190 | // pid's entry stays even when it is no longer a mux. | ||
| 191 | const alive = if (std.posix.kill(pid, 0)) true else |err| err != error.ProcessNotFound; | ||
| 183 | if (alive) continue; | 192 | if (alive) continue; |
| 184 | d.deleteTree(entry.name) catch {}; | 193 | d.deleteTree(entry.name) catch {}; |
| 185 | } | 194 | } |
| @@ -412,7 +421,7 @@ test "reapDeadPid: a dead owner's entry goes; a live owner's, a stranger's and a | |||
| 412 | var b3: [48]u8 = undefined; | 421 | var b3: [48]u8 = undefined; |
| 413 | const dead_dir = try std.fmt.bufPrint(&b0, "mux-agent-{d}-abc", .{dead}); | 422 | const dead_dir = try std.fmt.bufPrint(&b0, "mux-agent-{d}-abc", .{dead}); |
| 414 | const dead_sock = try std.fmt.bufPrint(&b1, "mux-ask-{d}.sock", .{dead}); | 423 | const dead_sock = try std.fmt.bufPrint(&b1, "mux-ask-{d}.sock", .{dead}); |
| 415 | const ours = try std.fmt.bufPrint(&b2, "mux-agent-{d}-abc", .{std.os.linux.getpid()}); | 424 | const ours = try std.fmt.bufPrint(&b2, "mux-agent-{d}-abc", .{std.c.getpid()}); |
| 416 | const not_a_pid = try std.fmt.bufPrint(&b3, "mux-agent-{d}x", .{dead}); | 425 | const not_a_pid = try std.fmt.bufPrint(&b3, "mux-agent-{d}x", .{dead}); |
| 417 | // A directory with something in it, so a plain rmdir would not do. | 426 | // A directory with something in it, so a plain rmdir would not do. |
| 418 | try tmp.dir.makePath(dead_dir); | 427 | try tmp.dir.makePath(dead_dir); |
| @@ -420,8 +429,19 @@ test "reapDeadPid: a dead owner's entry goes; a live owner's, a stranger's and a | |||
| 420 | try tmp.dir.writeFile(.{ .sub_path = try std.fmt.bufPrint(&inner, "{s}/agent-0.sock", .{dead_dir}), .data = "" }); | 429 | try tmp.dir.writeFile(.{ .sub_path = try std.fmt.bufPrint(&inner, "{s}/agent-0.sock", .{dead_dir}), .data = "" }); |
| 421 | try tmp.dir.writeFile(.{ .sub_path = dead_sock, .data = "" }); | 430 | try tmp.dir.writeFile(.{ .sub_path = dead_sock, .data = "" }); |
| 422 | try tmp.dir.makePath(ours); | 431 | try tmp.dir.makePath(ours); |
| 432 | // A pid that is alive but is not a mux — pid 1 — keeps its entry. The | ||
| 433 | // liveness question is `kill(pid, 0)`, which answers for every process | ||
| 434 | // this uid may signal and EPERM for the ones it may not; both are alive, | ||
| 435 | // and pid 1 is the EPERM case for every unprivileged run of this suite. | ||
| 436 | // ESRCH is the only answer that removes anything, so an errno with no | ||
| 437 | // name in this Zig — `error.Unexpected` — keeps the entry too. That case | ||
| 438 | // has no cheap fixture: no signal this test can send produces it. | ||
| 423 | try tmp.dir.makePath("mux-agent-1-abc"); // pid 1 is alive in every pid namespace | 439 | try tmp.dir.makePath("mux-agent-1-abc"); // pid 1 is alive in every pid namespace |
| 424 | try tmp.dir.makePath("mux-agent-abc"); | 440 | try tmp.dir.makePath("mux-agent-abc"); |
| 441 | // Digits that no pid can hold. `kill` takes an i32, so this is a name | ||
| 442 | // to leave alone exactly like `mux-agent-abc`; reading it as a wide | ||
| 443 | // integer and narrowing at the call would abort the whole reap here. | ||
| 444 | try tmp.dir.makePath("mux-agent-3000000000-x"); | ||
| 425 | try tmp.dir.makePath(not_a_pid); | 445 | try tmp.dir.makePath(not_a_pid); |
| 426 | 446 | ||
| 427 | reapDeadPid(tmp.path(), "mux-agent-"); | 447 | reapDeadPid(tmp.path(), "mux-agent-"); |
| @@ -430,6 +450,7 @@ test "reapDeadPid: a dead owner's entry goes; a live owner's, a stranger's and a | |||
| 430 | try tmp.dir.access(ours, .{}); | 450 | try tmp.dir.access(ours, .{}); |
| 431 | try tmp.dir.access("mux-agent-1-abc", .{}); | 451 | try tmp.dir.access("mux-agent-1-abc", .{}); |
| 432 | try tmp.dir.access("mux-agent-abc", .{}); | 452 | try tmp.dir.access("mux-agent-abc", .{}); |
| 453 | try tmp.dir.access("mux-agent-3000000000-x", .{}); | ||
| 433 | try tmp.dir.access(not_a_pid, .{}); | 454 | try tmp.dir.access(not_a_pid, .{}); |
| 434 | 455 | ||
| 435 | reapDeadPid(tmp.path(), "mux-ask-"); | 456 | reapDeadPid(tmp.path(), "mux-ask-"); |
test/bans.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,115 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # bans.sh — the folder rules' own pin. `make check` runs it after | ||
| 3 | # `zig build check`. | ||
| 4 | # | ||
| 5 | # build.zig's `checkSourceBan` reads the production lines of every file | ||
| 6 | # under src/ for bytes no import graph can catch. Nothing asserted that it | ||
| 7 | # still bites: a needle list edited down to nothing, a folder list that | ||
| 8 | # stopped naming a folder, or a `test` skip widened to swallow production | ||
| 9 | # lines would all leave a green tree saying the rules hold. This plants ONE | ||
| 10 | # representative needle per rule in a scratch file under one banned folder | ||
| 11 | # and requires the refusal by name, then plants one INSIDE a test block and | ||
| 12 | # requires no refusal at all — the skip is as load-bearing as the ban, since | ||
| 13 | # a test that drives a VT with escape bytes must stay legal. | ||
| 14 | # | ||
| 15 | # The scratch file is removed by an EXIT trap, so a failing assertion never | ||
| 16 | # leaves a planted needle behind to refuse every later build in the tree. | ||
| 17 | set -u | ||
| 18 | |||
| 19 | ZIG="${1:-deps/zig/zig}" | ||
| 20 | ROOT=$(cd "$(dirname "$0")/.." && pwd) | ||
| 21 | cd "$ROOT" || exit 1 | ||
| 22 | [ -x "$ZIG" ] || { echo "bans FAIL: no zig at $ZIG (pass it as \$1)"; exit 1; } | ||
| 23 | |||
| 24 | PROBE="" | ||
| 25 | cleanup() { [ -z "$PROBE" ] || rm -f "$PROBE"; } | ||
| 26 | trap cleanup EXIT INT TERM | ||
| 27 | |||
| 28 | FAILED=0 | ||
| 29 | |||
| 30 | # plant FOLDER BODY — a fresh .zig file under FOLDER holding BODY. The | ||
| 31 | # basename is this run's pid and carries no prefix any other build check | ||
| 32 | # claims: `server_test_` would trip checkSiblingTestsReached first and the | ||
| 33 | # rule under test would never be reached. | ||
| 34 | plant() { | ||
| 35 | PROBE="$1/zz_bans_probe_$$.zig" | ||
| 36 | printf '%s\n' "$2" > "$PROBE" | ||
| 37 | } | ||
| 38 | |||
| 39 | unplant() { | ||
| 40 | [ -z "$PROBE" ] || rm -f "$PROBE" | ||
| 41 | PROBE="" | ||
| 42 | } | ||
| 43 | |||
| 44 | # must_break RULE FOLDER BODY — the planted needle has to stop the build | ||
| 45 | # with that rule's own fatal. | ||
| 46 | # | ||
| 47 | # `zig build check` is the gate's own spelling and is what runs here. | ||
| 48 | # `checkSourceBans` is called from `build(b)`, so the refusal happens while | ||
| 49 | # the build graph is still being constructed: no step runs, no test is | ||
| 50 | # compiled, and the whole call costs about as long as reading src/. | ||
| 51 | must_break() { | ||
| 52 | _rule="$1" | ||
| 53 | plant "$2" "$3" | ||
| 54 | _out=$("$ZIG" build check 2>&1) | ||
| 55 | _rc=$? | ||
| 56 | unplant | ||
| 57 | if [ "$_rc" -eq 0 ]; then | ||
| 58 | echo "bans FAIL: rule $_rule: a needle planted in $2 built cleanly (rc 0)." | ||
| 59 | echo " The rule is not reading that folder's production lines." | ||
| 60 | FAILED=1 | ||
| 61 | return | ||
| 62 | fi | ||
| 63 | case "$_out" in | ||
| 64 | *"folder rule $_rule broken"*) | ||
| 65 | echo "bans ok: rule $_rule refuses its needle in $2" ;; | ||
| 66 | *) | ||
| 67 | echo "bans FAIL: rule $_rule: the build failed, but not with" | ||
| 68 | echo " 'folder rule $_rule broken'. What it said:" | ||
| 69 | echo "$_out" | tail -20 | ||
| 70 | FAILED=1 ;; | ||
| 71 | esac | ||
| 72 | } | ||
| 73 | |||
| 74 | # must_skip FOLDER BODY — the same needles inside a `test` block are legal, | ||
| 75 | # and the build must not so much as mention a rule. | ||
| 76 | # | ||
| 77 | # `zig build --help` rather than `zig build check`: this case has to reach | ||
| 78 | # the END of `build(b)` to prove nothing fatalled, and `check` would then | ||
| 79 | # go on to run the whole unit suite to answer a question the configure pass | ||
| 80 | # has already answered. `--help` constructs the identical graph — same | ||
| 81 | # `build(b)`, same `checkSourceBans` call — and runs no step. | ||
| 82 | must_skip() { | ||
| 83 | plant "$1" "$2" | ||
| 84 | _out=$("$ZIG" build --help 2>&1) | ||
| 85 | _rc=$? | ||
| 86 | unplant | ||
| 87 | if [ "$_rc" -ne 0 ] || [ "${_out#*folder rule}" != "$_out" ]; then | ||
| 88 | echo "bans FAIL: a needle inside a test block was refused (rc $_rc)." | ||
| 89 | echo " The test skip is what lets a test drive a VT or spawn" | ||
| 90 | echo " a shell; without it those tests cannot be written." | ||
| 91 | echo "$_out" | tail -20 | ||
| 92 | FAILED=1 | ||
| 93 | else | ||
| 94 | echo "bans ok: a needle inside a test block is skipped" | ||
| 95 | fi | ||
| 96 | } | ||
| 97 | |||
| 98 | # One needle per rule, each in a folder that rule names, and each chosen so | ||
| 99 | # no EARLIER rule matches the same line — the checker fatals on the first | ||
| 100 | # hit, so a body that tripped rule 4 would say nothing about rule 7. | ||
| 101 | must_break 4 src/client 'const probe = "isatty";' | ||
| 102 | must_break 5 src/engine 'const probe = "/bin/sh";' | ||
| 103 | must_break 6 src/server 'const probe = std.posix.fork();' | ||
| 104 | must_break 7 src/server 'const probe = std.os.linux.O.RDONLY;' | ||
| 105 | |||
| 106 | # A container-level `test` opens at column 0 and its `}` closes there, | ||
| 107 | # which is the line arithmetic checkSourceBan relies on and `zig fmt | ||
| 108 | # --check` already guarantees. | ||
| 109 | must_skip src/server 'test "bans probe" { | ||
| 110 | const probe = std.os.linux.O.RDONLY; | ||
| 111 | _ = probe; | ||
| 112 | }' | ||
| 113 | |||
| 114 | [ "$FAILED" -eq 0 ] || { echo "bans: FAILED"; exit 1; } | ||
| 115 | echo "bans: every folder rule bit, and the test skip held" | ||
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -105,6 +105,12 @@ command -v python3 > /dev/null 2>&1 || { | |||
| 105 | E2E_DIR=$(dirname "$0") | 105 | E2E_DIR=$(dirname "$0") |
| 106 | . "$E2E_DIR/e2e_lib.sh" | 106 | . "$E2E_DIR/e2e_lib.sh" |
| 107 | 107 | ||
| 108 | # Before any group, and in the E2E_ONLY path too: every pin below asks the | ||
| 109 | # OS through the lib's oracle helpers, and a helper that has stopped | ||
| 110 | # answering makes those pins agree with anything. One scenario here says | ||
| 111 | # the questions still reach the OS, so a later failure is the product's. | ||
| 112 | oracle_selftest | ||
| 113 | |||
| 108 | E2E_GROUPS='01_boot 02_predict 03_side 04_handoff 05_session 06_web 07_wallcli 08_mouse 09_hosts 10_agent 11_select 12_panes 13_birth 14_upgrade 15_askpass 16_push' | 114 | E2E_GROUPS='01_boot 02_predict 03_side 04_handoff 05_session 06_web 07_wallcli 08_mouse 09_hosts 10_agent 11_select 12_panes 13_birth 14_upgrade 15_askpass 16_push' |
| 109 | # The groups that cannot stand alone, and what they need. E2E_ONLY refuses | 115 | # The groups that cannot stand alone, and what they need. E2E_ONLY refuses |
| 110 | # these by name rather than running them: a group whose fixtures are | 116 | # these by name rather than running them: a group whose fixtures are |
| @@ -170,8 +176,8 @@ done | |||
| 170 | # one of those and adds a convergence point would be pinning a fact every | 176 | # one of those and adds a convergence point would be pinning a fact every |
| 171 | # leg above already establishes. | 177 | # leg above already establishes. |
| 172 | 178 | ||
| 173 | [ "$OK_COUNT" = "112" ] || { | 179 | [ "$OK_COUNT" = "113" ] || { |
| 174 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 112 —" | 180 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 113 —" |
| 175 | echo " a scenario was added (update the pin) or silently lost" | 181 | echo " a scenario was added (update the pin) or silently lost" |
| 176 | exit 1 | 182 | exit 1 |
| 177 | } | 183 | } |
test/e2e_01_boot.sh
| Old | New | ||
|---|---|---|---|
| @@ -54,15 +54,15 @@ ok "--version on both binaries" | |||
| 54 | KEYOUT=$("$MUX" d keygen) | 54 | KEYOUT=$("$MUX" d keygen) |
| 55 | [ "$KEYOUT" = "$XDG_CONFIG_HOME/mux/key" ] || { | 55 | [ "$KEYOUT" = "$XDG_CONFIG_HOME/mux/key" ] || { |
| 56 | echo "e2e FAIL: keygen printed '$KEYOUT'"; exit 1; } | 56 | echo "e2e FAIL: keygen printed '$KEYOUT'"; exit 1; } |
| 57 | PERMS=$(stat -c %a "$KEYOUT") | 57 | PERMS=$(file_mode "$KEYOUT") |
| 58 | [ "$PERMS" = "600" ] || { echo "e2e FAIL: keygen perms $PERMS, want 600"; exit 1; } | 58 | [ "$PERMS" = "600" ] || { echo "e2e FAIL: keygen perms $PERMS, want 600"; exit 1; } |
| 59 | DPERMS=$(stat -c %a "$XDG_CONFIG_HOME/mux") | 59 | DPERMS=$(file_mode "$XDG_CONFIG_HOME/mux") |
| 60 | [ "$DPERMS" = "700" ] || { echo "e2e FAIL: key dir perms $DPERMS, want 700"; exit 1; } | 60 | [ "$DPERMS" = "700" ] || { echo "e2e FAIL: key dir perms $DPERMS, want 700"; exit 1; } |
| 61 | SUM1=$(sha256sum "$KEYOUT") | 61 | SUM1=$(sha256_of "$KEYOUT") |
| 62 | if "$MUX" d keygen > /dev/null 2>&1; then | 62 | if "$MUX" d keygen > /dev/null 2>&1; then |
| 63 | echo "e2e FAIL: second keygen did not refuse"; exit 1 | 63 | echo "e2e FAIL: second keygen did not refuse"; exit 1 |
| 64 | fi | 64 | fi |
| 65 | SUM2=$(sha256sum "$KEYOUT") | 65 | SUM2=$(sha256_of "$KEYOUT") |
| 66 | [ "$SUM1" = "$SUM2" ] || { echo "e2e FAIL: refused keygen still changed the key"; exit 1; } | 66 | [ "$SUM1" = "$SUM2" ] || { echo "e2e FAIL: refused keygen still changed the key"; exit 1; } |
| 67 | ok "keygen creates once, 0600 in a 0700 dir, refuses twice" | 67 | ok "keygen creates once, 0600 in a 0700 dir, refuses twice" |
| 68 | 68 | ||
| @@ -532,13 +532,14 @@ start_daemon "$SOCK4" "$OUT.d4.d" "--quic daemon never bound its session socket" | |||
| 532 | --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 3000 | 532 | --quic "127.0.0.1:$QPORT" --key "$QKEY" --quic-idle-ms 3000 |
| 533 | D4PID=$DPID | 533 | D4PID=$DPID |
| 534 | 534 | ||
| 535 | # The UDP port is actually held. /proc/net/udp rather than ss or lsof: it is | 535 | # The UDP port is actually held. Asked through the lib's udp_local_bound |
| 536 | # always there on the platform this daemon runs on, and needs no privileges. | 536 | # rather than ss or lsof: the kernel's own table is always there and needs |
| 537 | # 127.0.0.1 is 0100007F in the little-endian hex the file uses. | 537 | # no privileges, and the spelling of the question is the oracle's business. |
| 538 | # 127.0.0.1 is 0100007F in the little-endian hex that table uses. | ||
| 538 | QHEX=$(printf '0100007F:%04X' "$QPORT") | 539 | QHEX=$(printf '0100007F:%04X' "$QPORT") |
| 539 | grep -qi " $QHEX " /proc/net/udp || { | 540 | udp_local_bound "$QHEX" || { |
| 540 | echo "e2e FAIL: no UDP socket bound at 127.0.0.1:$QPORT ($QHEX)" | 541 | echo "e2e FAIL: no UDP socket bound at 127.0.0.1:$QPORT ($QHEX)" |
| 541 | grep -i "0100007F" /proc/net/udp || true | 542 | udp_table | grep -i "0100007F" || true |
| 542 | exit 1 | 543 | exit 1 |
| 543 | } | 544 | } |
| 544 | 545 | ||
| @@ -783,8 +784,9 @@ D4PID=$DPID | |||
| 783 | # here rather than one, since either alone still passes on an accident. | 784 | # here rather than one, since either alone still passes on an accident. |
| 784 | # | 785 | # |
| 785 | # One expression, used by the wait and the verdict: two spellings of the | 786 | # One expression, used by the wait and the verdict: two spellings of the |
| 786 | # same match are two matches waiting to drift apart. | 787 | # same match are two matches waiting to drift apart. It is the lib's |
| 787 | udp_local_bound() { awk -v h="$1" '$2==h{f=1} END{exit !f}' /proc/net/udp; } | 788 | # udp_local_bound now, so the local-address-only rule this block bought is |
| 789 | # also what the M8 bind check above asks, and neither can drift again. | ||
| 788 | i=0 | 790 | i=0 |
| 789 | # 50 x 0.1s, the same 5s every daemon wait in this file allows — and scaled | 791 | # 50 x 0.1s, the same 5s every daemon wait in this file allows — and scaled |
| 790 | # by TIME_SCALE like every other budget, because under test/coverage.sh the | 792 | # by TIME_SCALE like every other budget, because under test/coverage.sh the |
| @@ -796,8 +798,8 @@ done | |||
| 796 | udp_local_bound "$QHEX" || { | 798 | udp_local_bound "$QHEX" || { |
| 797 | echo "e2e FAIL: restarted daemon did not rebind udp 127.0.0.1:$QPORT ($QHEX) in $(( 5 * TIME_SCALE ))s" | 799 | echo "e2e FAIL: restarted daemon did not rebind udp 127.0.0.1:$QPORT ($QHEX) in $(( 5 * TIME_SCALE ))s" |
| 798 | echo " daemon pid $D4PID: $(ps -o stat=,comm= -p "$D4PID" 2>/dev/null || echo gone)" | 800 | echo " daemon pid $D4PID: $(ps -o stat=,comm= -p "$D4PID" 2>/dev/null || echo gone)" |
| 799 | echo " /proc/net/udp lines mentioning the port:" | 801 | echo " kernel UDP table lines mentioning the port:" |
| 800 | grep -i " $QHEX " /proc/net/udp || echo " (none)" | 802 | udp_table | grep -i "$QHEX" || echo " (none)" |
| 801 | cat "$OUT.q"; exit 1; | 803 | cat "$OUT.q"; exit 1; |
| 802 | } | 804 | } |
| 803 | kill -0 "$D4PID" || { echo "e2e FAIL: restarted --quic daemon died"; cat "$OUT.q"; exit 1; } | 805 | kill -0 "$D4PID" || { echo "e2e FAIL: restarted --quic daemon died"; cat "$OUT.q"; exit 1; } |
| @@ -901,7 +903,7 @@ kill -0 "$SPID" || { echo "e2e FAIL: started daemon not alive"; exit 1; } | |||
| 901 | # way in 03_side), so this holds today by construction — which is exactly | 903 | # way in 03_side), so this holds today by construction — which is exactly |
| 902 | # why it is asserted here: a later split of the two callers would leave | 904 | # why it is asserted here: a later split of the two callers would leave |
| 903 | # `d start` free to hand execve the /proc link and name its daemon `exe`. | 905 | # `d start` free to hand execve the /proc link and name its daemon `exe`. |
| 904 | SCOMM=$(cat "/proc/$SPID/comm") | 906 | SCOMM=$(pid_comm "$SPID") |
| 905 | [ "$SCOMM" = "mux" ] || { | 907 | [ "$SCOMM" = "mux" ] || { |
| 906 | echo "e2e FAIL: the daemon mux d start -d brought up has comm '$SCOMM', want 'mux'" | 908 | echo "e2e FAIL: the daemon mux d start -d brought up has comm '$SCOMM', want 'mux'" |
| 907 | exit 1; } | 909 | exit 1; } |
| @@ -923,7 +925,7 @@ assert_converged "$OUT.s8" "$SOCK8" "started daemon" | |||
| 923 | # than the key is: it carries whatever the daemon says about its own start. | 925 | # than the key is: it carries whatever the daemon says about its own start. |
| 924 | MUXLOG="$XDG_STATE_HOME/mux/muxd.log" | 926 | MUXLOG="$XDG_STATE_HOME/mux/muxd.log" |
| 925 | [ -f "$MUXLOG" ] || { echo "e2e FAIL: no daemon log at $MUXLOG"; exit 1; } | 927 | [ -f "$MUXLOG" ] || { echo "e2e FAIL: no daemon log at $MUXLOG"; exit 1; } |
| 926 | LPERMS=$(stat -c %a "$MUXLOG") | 928 | LPERMS=$(file_mode "$MUXLOG") |
| 927 | [ "$LPERMS" = "600" ] || { echo "e2e FAIL: daemon log perms $LPERMS, want 600"; exit 1; } | 929 | [ "$LPERMS" = "600" ] || { echo "e2e FAIL: daemon log perms $LPERMS, want 600"; exit 1; } |
| 928 | 930 | ||
| 929 | # Rerun: silent no-op beyond the already-running line, exit 0, same daemon. | 931 | # Rerun: silent no-op beyond the already-running line, exit 0, same daemon. |
| @@ -941,12 +943,12 @@ grep -q "already running on $SOCK8 (stop it first with \`mux d stop --sock $SOCK | |||
| 941 | # leave that line where it is and grow the file. | 943 | # leave that line where it is and grow the file. |
| 942 | SEED="a live daemon was writing here" | 944 | SEED="a live daemon was writing here" |
| 943 | echo "$SEED" >> "$MUXLOG" | 945 | echo "$SEED" >> "$MUXLOG" |
| 944 | SEEDED=$(stat -c %s "$MUXLOG") | 946 | SEEDED=$(file_size "$MUXLOG") |
| 945 | "$MUX" d start -d --sock "$SOCK8T" 2> "$OUT.start" | 947 | "$MUX" d start -d --sock "$SOCK8T" 2> "$OUT.start" |
| 946 | TPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.start") | 948 | TPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.start") |
| 947 | defer_kill "$TPID" | 949 | defer_kill "$TPID" |
| 948 | [ -n "$TPID" ] || { echo "e2e FAIL: append spawn reported no pid"; exit 1; } | 950 | [ -n "$TPID" ] || { echo "e2e FAIL: append spawn reported no pid"; exit 1; } |
| 949 | GREW=$(stat -c %s "$MUXLOG") | 951 | GREW=$(file_size "$MUXLOG") |
| 950 | [ "$GREW" -ge "$SEEDED" ] || { | 952 | [ "$GREW" -ge "$SEEDED" ] || { |
| 951 | echo "e2e FAIL: log shrank on spawn ($SEEDED -> $GREW) — a second daemon zeroed the first's" | 953 | echo "e2e FAIL: log shrank on spawn ($SEEDED -> $GREW) — a second daemon zeroed the first's" |
| 952 | exit 1; } | 954 | exit 1; } |
| @@ -1039,9 +1041,10 @@ set -e | |||
| 1039 | echo "e2e FAIL: mux d endpoint --sock ... --start exited $ESRC (want 0)" | 1041 | echo "e2e FAIL: mux d endpoint --sock ... --start exited $ESRC (want 0)" |
| 1040 | cat "$OUT.esa.err"; exit 1; } | 1042 | cat "$OUT.esa.err"; exit 1; } |
| 1041 | # (a) The named socket is bound, and bound by a `mux` — asked of the OS by | 1043 | # (a) The named socket is bound, and bound by a `mux` — asked of the OS by |
| 1042 | # INODE, not by a name. A `mux` running anywhere would satisfy a name check | 1044 | # INODE, not by a name, which is what pid_holds_unix_sock does. A `mux` |
| 1043 | # while binding something else entirely; the listening inode for this path | 1045 | # running anywhere would satisfy a name check while binding something else |
| 1044 | # out of /proc/net/unix, found among that pid's open fds, cannot. | 1046 | # entirely; the kernel's listening inode for this path, found among that |
| 1047 | # pid's own open fds, cannot. | ||
| 1045 | ESPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.esa.err" | head -1) | 1048 | ESPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.esa.err" | head -1) |
| 1046 | defer_kill "$ESPID" | 1049 | defer_kill "$ESPID" |
| 1047 | [ -n "$ESPID" ] || { | 1050 | [ -n "$ESPID" ] || { |
| @@ -1049,14 +1052,12 @@ defer_kill "$ESPID" | |||
| 1049 | [ -S "$ESOWN" ] || { | 1052 | [ -S "$ESOWN" ] || { |
| 1050 | echo "e2e FAIL: endpoint --start bound nothing at the socket it was given" | 1053 | echo "e2e FAIL: endpoint --start bound nothing at the socket it was given" |
| 1051 | ls -la "$ESRUN"; exit 1; } | 1054 | ls -la "$ESRUN"; exit 1; } |
| 1052 | ESINO=$(awk -v p="$ESOWN" '$NF == p {print $7}' /proc/net/unix | head -1) | 1055 | pid_holds_unix_sock "$ESPID" "$ESOWN" || { |
| 1053 | [ -n "$ESINO" ] || { | ||
| 1054 | echo "e2e FAIL: the kernel lists no listening socket at $ESOWN"; exit 1; } | ||
| 1055 | readlink /proc/"$ESPID"/fd/* 2>/dev/null | grep -qx "socket:\[$ESINO\]" || { | ||
| 1056 | echo "e2e FAIL: pid $ESPID does not hold the socket at $ESOWN — the daemon" | 1056 | echo "e2e FAIL: pid $ESPID does not hold the socket at $ESOWN — the daemon" |
| 1057 | echo " that answered is not the daemon --start reported starting" | 1057 | echo " that answered is not the daemon --start reported starting," |
| 1058 | echo " or the kernel lists no listening socket at that path at all" | ||
| 1058 | exit 1; } | 1059 | exit 1; } |
| 1059 | ESCOMM=$(cat "/proc/$ESPID/comm") | 1060 | ESCOMM=$(pid_comm "$ESPID") |
| 1060 | [ "$ESCOMM" = "mux" ] || { | 1061 | [ "$ESCOMM" = "mux" ] || { |
| 1061 | echo "e2e FAIL: the socket at $ESOWN is served by comm '$ESCOMM', want 'mux'"; exit 1; } | 1062 | echo "e2e FAIL: the socket at $ESOWN is served by comm '$ESCOMM', want 'mux'"; exit 1; } |
| 1062 | # (b) ...and the announce came back on stdout, first line, in the grammar | 1063 | # (b) ...and the announce came back on stdout, first line, in the grammar |
| @@ -1149,12 +1150,13 @@ HFMARK="$OUT.hf.sent"; defer_rm "$HFMARK" | |||
| 1149 | # connect() succeeds into the listen backlog, so the peer's marker below | 1150 | # connect() succeeds into the listen backlog, so the peer's marker below |
| 1150 | # says the byte was written and NOT that the daemon has it. What the leg | 1151 | # says the byte was written and NOT that the daemon has it. What the leg |
| 1151 | # needs is an ACCEPTED half frame — an unaccepted one parks nothing — and | 1152 | # needs is an ACCEPTED half frame — an unaccepted one parks nothing — and |
| 1152 | # the accept is a socket appearing in /proc. Ask the OS about the OS. | 1153 | # the accept is one more fd appearing in the daemon's table. Ask the OS |
| 1154 | # about the OS. | ||
| 1153 | HFDPID=$(real_pid "$DHFPID") | 1155 | HFDPID=$(real_pid "$DHFPID") |
| 1154 | HFFD0=$(find "/proc/$HFDPID/fd" -mindepth 1 2>/dev/null | wc -l) | 1156 | HFFD0=$(pid_fd_count "$HFDPID") |
| 1155 | [ "$HFFD0" -gt 0 ] || { | 1157 | [ "$HFFD0" -gt 0 ] || { |
| 1156 | echo "e2e FAIL: half-frame: cannot read /proc/$HFDPID/fd, so the accept" | 1158 | echo "e2e FAIL: half-frame: the OS will not list pid $HFDPID's open fds," |
| 1157 | echo " below could not be witnessed"; exit 1; } | 1159 | echo " so the accept below could not be witnessed"; exit 1; } |
| 1158 | python3 - "$SOCKHF" "$HFMARK" <<'EOF' & | 1160 | python3 - "$SOCKHF" "$HFMARK" <<'EOF' & |
| 1159 | import socket, sys, time | 1161 | import socket, sys, time |
| 1160 | s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) | 1162 | s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) |
| @@ -1175,7 +1177,7 @@ wait_for "$HFMARK" sent 5 || { | |||
| 1175 | # cannot speak for. Nothing else connects in this window, so one more fd is | 1177 | # cannot speak for. Nothing else connects in this window, so one more fd is |
| 1176 | # this peer's. | 1178 | # this peer's. |
| 1177 | _hfi=0 | 1179 | _hfi=0 |
| 1178 | while [ "$(find "/proc/$HFDPID/fd" -mindepth 1 2>/dev/null | wc -l)" -le "$HFFD0" ]; do | 1180 | while [ "$(pid_fd_count "$HFDPID")" -le "$HFFD0" ]; do |
| 1179 | _hfi=$((_hfi + 1)) | 1181 | _hfi=$((_hfi + 1)) |
| 1180 | [ "$_hfi" -lt $(( 100 * TIME_SCALE )) ] || { | 1182 | [ "$_hfi" -lt $(( 100 * TIME_SCALE )) ] || { |
| 1181 | echo "e2e FAIL: half-frame: the daemon never accepted the peer, so the" | 1183 | echo "e2e FAIL: half-frame: the daemon never accepted the peer, so the" |
test/e2e_03_side.sh
| Old | New | ||
|---|---|---|---|
| @@ -193,23 +193,24 @@ defer_kill "$PAPID" | |||
| 193 | # agent code in it and passed. The exec names the running image, so the link | 193 | # agent code in it and passed. The exec names the running image, so the link |
| 194 | # below can only ever be that image — and this reads the link rather | 194 | # below can only ever be that image — and this reads the link rather |
| 195 | # than the argv, because argv is what a wrong spawn would still get right. | 195 | # than the argv, because argv is what a wrong spawn would still get right. |
| 196 | # readlink -f on both sides: /proc/PID/exe is fully resolved, so a logical | 196 | # Fully resolved on both sides: pid_exe answers with the resolved image, so |
| 197 | # path reached through a symlinked directory would fail a correct spawn. | 197 | # a logical path reached through a symlinked directory would fail a correct |
| 198 | PAEXE=$(readlink -f "/proc/$PAPID/exe") | 198 | # spawn unless this side resolves too. |
| 199 | PAEXE=$(pid_exe "$PAPID") | ||
| 199 | PAWANT=$(readlink -f "$MUX_ELF") | 200 | PAWANT=$(readlink -f "$MUX_ELF") |
| 200 | [ "$PAEXE" = "$PAWANT" ] || { | 201 | [ "$PAEXE" = "$PAWANT" ] || { |
| 201 | echo "e2e FAIL: the auto-started daemon is running $PAEXE, not the build's $PAWANT" | 202 | echo "e2e FAIL: the auto-started daemon is running $PAEXE, not the build's $PAWANT" |
| 202 | echo " (an auto-start that resolves a NAME grades whatever is installed)" | 203 | echo " (an auto-start that resolves a NAME grades whatever is installed)" |
| 203 | exit 1; } | 204 | exit 1; } |
| 204 | # And the name the kernel gives it, which the link cannot see: `comm` comes | 205 | # And the OS's own name for the process, which the image path cannot see: |
| 205 | # from the basename of the FILENAME handed to execve, so exec'ing the | 206 | # `comm` comes from the basename of the FILENAME handed to execve, so |
| 206 | # /proc/self/exe link itself leaves every daemon on the box called `exe` — | 207 | # exec'ing the self-exe link itself leaves every daemon on the box called |
| 207 | # invisible to `pgrep mux`, `killall mux`, `ps -o comm` and systemd's | 208 | # `exe` — invisible to `pgrep mux`, `killall mux`, `ps -o comm` and |
| 208 | # MainPID name, with only the args still saying `mux d start`. | 209 | # systemd's MainPID name, with only the args still saying `mux d start`. |
| 209 | PACOMM=$(cat "/proc/$PAPID/comm") | 210 | PACOMM=$(pid_comm "$PAPID") |
| 210 | [ "$PACOMM" = "mux" ] || { | 211 | [ "$PACOMM" = "mux" ] || { |
| 211 | echo "e2e FAIL: the auto-started daemon's comm is '$PACOMM', want 'mux'" | 212 | echo "e2e FAIL: the auto-started daemon's comm is '$PACOMM', want 'mux'" |
| 212 | echo " (exec the RESOLVED path; the /proc link names the process after itself)" | 213 | echo " (exec the RESOLVED path; the self-exe link names the process after itself)" |
| 213 | exit 1; } | 214 | exit 1; } |
| 214 | # And the argv, asked of the kernel for the same reason: the attach ASKS | 215 | # And the argv, asked of the kernel for the same reason: the attach ASKS |
| 215 | # for a daemon in words — `d start -d --sock PATH`, nothing else — and the | 216 | # for a daemon in words — `d start -d --sock PATH`, nothing else — and the |
| @@ -219,7 +220,7 @@ PACOMM=$(cat "/proc/$PAPID/comm") | |||
| 219 | # what this is watching for is an ADDITION. `-d` is absent by the same | 220 | # what this is watching for is an ADDITION. `-d` is absent by the same |
| 220 | # rule the fork obeys — the child is the foreground daemon, and a `-d` | 221 | # rule the fork obeys — the child is the foreground daemon, and a `-d` |
| 221 | # still in its line would fork again. | 222 | # still in its line would fork again. |
| 222 | PACMD=$(tr '\0' ' ' < "/proc/$PAPID/cmdline") | 223 | PACMD=$(pid_args "$PAPID") |
| 223 | [ "$PACMD" = "mux d start --sock $SOCK15 " ] || { | 224 | [ "$PACMD" = "mux d start --sock $SOCK15 " ] || { |
| 224 | echo "e2e FAIL: the auto-started daemon's argv is [$PACMD]," | 225 | echo "e2e FAIL: the auto-started daemon's argv is [$PACMD]," |
| 225 | echo " want [mux d start --sock $SOCK15 ]" | 226 | echo " want [mux d start --sock $SOCK15 ]" |
test/e2e_04_handoff.sh
| Old | New | ||
|---|---|---|---|
| @@ -88,7 +88,11 @@ mkdir -p "$SSHIM_DIR" "$HRUN" "$HRUN2" | |||
| 88 | ssh_shim_head "$SSHIM_DIR/ssh" | 88 | ssh_shim_head "$SSHIM_DIR/ssh" |
| 89 | cat >> "$SSHIM_DIR/ssh" <<'SHIM' | 89 | cat >> "$SSHIM_DIR/ssh" <<'SHIM' |
| 90 | echo $$ >> "${SSHIM_PIDLOG:?}" | 90 | echo $$ >> "${SSHIM_PIDLOG:?}" |
| 91 | printf '%s %s\n' "$(cat /proc/$$/comm)" "$(cat /proc/$PPID/comm)" >> "${SSHIM_COMMLOG:?}" | 91 | # ps rather than the lib's pid_comm: this shim is written to disk and run |
| 92 | # as its own `sh` by the client under test, so nothing the suite sourced | ||
| 93 | # is in scope here. `ps -o comm=` is the same answer the oracle gives and | ||
| 94 | # is spelled the same way on every OS the suite could run on. | ||
| 95 | printf '%s %s\n' "$(ps -o comm= -p $$)" "$(ps -o comm= -p $PPID)" >> "${SSHIM_COMMLOG:?}" | ||
| 92 | shift | 96 | shift |
| 93 | printf '%s\n' "$*" >> "${SSHIM_ARGLOG:?}" | 97 | printf '%s\n' "$*" >> "${SSHIM_ARGLOG:?}" |
| 94 | exec /bin/sh -c "$*" | 98 | exec /bin/sh -c "$*" |
| @@ -199,23 +203,23 @@ kill -0 "$HAPID" 2>/dev/null || { | |||
| 199 | # The cache: present, private (it holds the key), and naming a port that is | 203 | # The cache: present, private (it holds the key), and naming a port that is |
| 200 | # really being listened on. The last one is the assertion that matters — | 204 | # really being listened on. The last one is the assertion that matters — |
| 201 | # a remembered number proves nothing, a remembered number that maps to the | 205 | # a remembered number proves nothing, a remembered number that maps to the |
| 202 | # daemon's actual UDP socket proves the announce was true. /proc/net/udp | 206 | # daemon's actual UDP socket proves the announce was true. Asked through |
| 203 | # for the same reasons the M8 block uses it, and 00000000 because the lazy | 207 | # udp_local_bound for the same reasons the M8 block uses it, and 00000000 |
| 204 | # bind takes 0.0.0.0. | 208 | # because the lazy bind takes 0.0.0.0. |
| 205 | [ -f "$HCACHE" ] || { | 209 | [ -f "$HCACHE" ] || { |
| 206 | echo "e2e FAIL: cold handoff left no cache at $HCACHE" | 210 | echo "e2e FAIL: cold handoff left no cache at $HCACHE" |
| 207 | ls -la "$XDG_CACHE_HOME/mux/hosts" 2>/dev/null; exit 1; } | 211 | ls -la "$XDG_CACHE_HOME/mux/hosts" 2>/dev/null; exit 1; } |
| 208 | HCPERM=$(stat -c %a "$HCACHE") | 212 | HCPERM=$(file_mode "$HCACHE") |
| 209 | [ "$HCPERM" = "600" ] || { | 213 | [ "$HCPERM" = "600" ] || { |
| 210 | echo "e2e FAIL: handoff cache perms $HCPERM, want 600 (the file holds a key)"; exit 1; } | 214 | echo "e2e FAIL: handoff cache perms $HCPERM, want 600 (the file holds a key)"; exit 1; } |
| 211 | HCDPERM=$(stat -c %a "$XDG_CACHE_HOME/mux/hosts") | 215 | HCDPERM=$(file_mode "$XDG_CACHE_HOME/mux/hosts") |
| 212 | [ "$HCDPERM" = "700" ] || { | 216 | [ "$HCDPERM" = "700" ] || { |
| 213 | echo "e2e FAIL: handoff cache dir perms $HCDPERM, want 700"; exit 1; } | 217 | echo "e2e FAIL: handoff cache dir perms $HCDPERM, want 700"; exit 1; } |
| 214 | HPORT=$(sed -n 's/^endpoint \([0-9][0-9]*\) [0-9a-f]*$/\1/p' "$HCACHE") | 218 | HPORT=$(sed -n 's/^endpoint \([0-9][0-9]*\) [0-9a-f]*$/\1/p' "$HCACHE") |
| 215 | [ -n "$HPORT" ] || { | 219 | [ -n "$HPORT" ] || { |
| 216 | echo "e2e FAIL: the cache is not an announce line:"; cat -v "$HCACHE"; exit 1; } | 220 | echo "e2e FAIL: the cache is not an announce line:"; cat -v "$HCACHE"; exit 1; } |
| 217 | HHEXUDP=$(printf '00000000:%04X' "$HPORT") | 221 | HHEXUDP=$(printf '00000000:%04X' "$HPORT") |
| 218 | grep -qi " $HHEXUDP " /proc/net/udp || { | 222 | udp_local_bound "$HHEXUDP" || { |
| 219 | echo "e2e FAIL: the cached port $HPORT names no UDP listener ($HHEXUDP);" | 223 | echo "e2e FAIL: the cached port $HPORT names no UDP listener ($HHEXUDP);" |
| 220 | echo " the announce the client believed was not the daemon's" | 224 | echo " the announce the client believed was not the daemon's" |
| 221 | exit 1; } | 225 | exit 1; } |
| @@ -482,7 +486,7 @@ HWPORT=$(sed -n 's/^endpoint \([0-9][0-9]*\) [0-9a-f]*$/\1/p' "$HCACHE_D") | |||
| 482 | [ "$HWPORT" = "$HQPORT" ] || { | 486 | [ "$HWPORT" = "$HQPORT" ] || { |
| 483 | echo "e2e FAIL: the cache at $HCACHE_D names port '$HWPORT', want the silent $HQPORT" | 487 | echo "e2e FAIL: the cache at $HCACHE_D names port '$HWPORT', want the silent $HQPORT" |
| 484 | cat -v "$HCACHE_D"; exit 1; } | 488 | cat -v "$HCACHE_D"; exit 1; } |
| 485 | HWMODE=$(stat -c %a "$HCACHE_D") | 489 | HWMODE=$(file_mode "$HCACHE_D") |
| 486 | [ "$HWMODE" = "600" ] || { | 490 | [ "$HWMODE" = "600" ] || { |
| 487 | echo "e2e FAIL: the cache at $HCACHE_D is mode $HWMODE; readCache refuses" | 491 | echo "e2e FAIL: the cache at $HCACHE_D is mode $HWMODE; readCache refuses" |
| 488 | echo " anything looser, so this leg would attach COLD" | 492 | echo " anything looser, so this leg would attach COLD" |
test/e2e_06_web.sh
| Old | New | ||
|---|---|---|---|
| @@ -535,10 +535,12 @@ timeout 20 "$MUX" a send 'exit\n' --sock "$SOCK25" --session 1 > /dev/null 2>&1 | |||
| 535 | wait_until 80 "host wall: the ended session's pane never wore gone; the wall holds:" \ | 535 | wait_until 80 "host wall: the ended session's pane never wore gone; the wall holds:" \ |
| 536 | 'curl -s "$DWORIG/tiles" | tr "{" "\n" | grep -q "\"session\":\"1\",\"state\":\"gone\""' \ | 536 | 'curl -s "$DWORIG/tiles" | tr "{" "\n" | grep -q "\"session\":\"1\",\"state\":\"gone\""' \ |
| 537 | 'curl -s "$DWORIG/tiles"' | 537 | 'curl -s "$DWORIG/tiles"' |
| 538 | # Asked of the OS, not of the daemon. | 538 | # Asked of the OS, not of the daemon — through the oracle helpers, so the |
| 539 | [ ! -e "/proc/$DWSHELL" ] || { | 539 | # spelling of "is this pid alive" belongs to e2e_lib.sh and this line asks |
| 540 | echo "e2e FAIL: host wall: the shell pid $DWSHELL outlived its session per /proc" | 540 | # the question rather than naming /proc. |
| 541 | cat "/proc/$DWSHELL/cmdline" 2>/dev/null; exit 1; } | 541 | ! pid_alive "$DWSHELL" || { |
| 542 | echo "e2e FAIL: host wall: the shell pid $DWSHELL outlived its session per the OS" | ||
| 543 | pid_args "$DWSHELL"; echo; exit 1; } | ||
| 542 | # The pane STAYS, on its own id and in its own place: five leaves, and the | 544 | # The pane STAYS, on its own id and in its own place: five leaves, and the |
| 543 | # ended one still among them. A hub that vanished a graded pane would take | 545 | # ended one still among them. A hub that vanished a graded pane would take |
| 544 | # a rect the user authored on the strength of one poll answer. | 546 | # a rect the user authored on the strength of one poll answer. |
test/e2e_09_hosts.sh
| Old | New | ||
|---|---|---|---|
| @@ -273,7 +273,7 @@ wait_sessions "$SOCKH2" 2 "hosts: daemon 2 should hold 0 and c" | |||
| 273 | # a narrow bar drops. | 273 | # a narrow bar drops. |
| 274 | seed_layout "$HSTATE" stacked \ | 274 | seed_layout "$HSTATE" stacked \ |
| 275 | "--sock $SOCKH1#0" "--sock $SOCKH1#b" "--sock $SOCKH2#0" | 275 | "--sock $SOCKH1#0" "--sock $SOCKH1#b" "--sock $SOCKH2#0" |
| 276 | HFHASH=$(sha256sum "$HSTATE/mux/hosts" | cut -d' ' -f1) | 276 | HFHASH=$(sha256_of "$HSTATE/mux/hosts") |
| 277 | set +e | 277 | set +e |
| 278 | XDG_STATE_HOME="$HSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 44 \ | 278 | XDG_STATE_HOME="$HSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 44 \ |
| 279 | --out "$OUT.hwcap" --err "$OUT.hwcap.err" -- "$MUX" > "$OUT.hwpc" 2>&1 <<'EOF' | 279 | --out "$OUT.hwcap" --err "$OUT.hwcap.err" -- "$MUX" > "$OUT.hwpc" 2>&1 <<'EOF' |
| @@ -348,7 +348,7 @@ for _hw in "$HW1" "$HW2" "$HW3"; do | |||
| 348 | done | 348 | done |
| 349 | # The hosts file did not move: a wall is opened FROM these two files and | 349 | # The hosts file did not move: a wall is opened FROM these two files and |
| 350 | # writes back only to the layout. | 350 | # writes back only to the layout. |
| 351 | [ "$HFHASH" = "$(sha256sum "$HSTATE/mux/hosts" | cut -d' ' -f1)" ] || { | 351 | [ "$HFHASH" = "$(sha256_of "$HSTATE/mux/hosts")" ] || { |
| 352 | echo "e2e FAIL: hosts: the wall edited the hosts file:" | 352 | echo "e2e FAIL: hosts: the wall edited the hosts file:" |
| 353 | cat "$HSTATE/mux/hosts"; exit 1; } | 353 | cat "$HSTATE/mux/hosts"; exit 1; } |
| 354 | # ...and the layout still holds the three leaves it was opened on. This run | 354 | # ...and the layout still holds the three leaves it was opened on. This run |
| @@ -1290,7 +1290,8 @@ ok "two walls on the same daemons are two layouts; neither learns of the other's | |||
| 1290 | # the client does with the answer. | 1290 | # the client does with the answer. |
| 1291 | # | 1291 | # |
| 1292 | # The oracle is the OS, never the daemon: the socket INODE on the far side, | 1292 | # The oracle is the OS, never the daemon: the socket INODE on the far side, |
| 1293 | # and a walk of /proc for a `mux d start` born under that box's runtime dir. A | 1293 | # and a walk of every pid on the box for a `mux d start` whose argv names |
| 1294 | # that box's socket, which is to say born under its runtime dir. A | ||
| 1294 | # daemon asked whether it exists cannot answer no. The shim log is the | 1295 | # daemon asked whether it exists cannot answer no. The shim log is the |
| 1295 | # other half — it records the remote command, so "a read started nothing" | 1296 | # other half — it records the remote command, so "a read started nothing" |
| 1296 | # is checked against the word that ran and not only against the outcome. | 1297 | # is checked against the word that ran and not only against the outcome. |
| @@ -1334,24 +1335,29 @@ chmod +x "$NSHIM/ssh" | |||
| 1334 | NPATH="$NSHIM:$(cd "$(dirname "$MUX")" && pwd):$PATH" | 1335 | NPATH="$NSHIM:$(cd "$(dirname "$MUX")" && pwd):$PATH" |
| 1335 | printf 'nostart-a@127.0.0.1\nnostart-b@127.0.0.1\n' > "$NSTATE/mux/hosts" | 1336 | printf 'nostart-a@127.0.0.1\nnostart-b@127.0.0.1\n' > "$NSTATE/mux/hosts" |
| 1336 | 1337 | ||
| 1337 | # The /proc walk. `mux d start` and not any `mux` — the transient `mux d | 1338 | # The process walk: every pid on the box, and its ARGV asked through the |
| 1338 | # endpoint` processes share the runtime dir and are not daemons — and | 1339 | # oracle. `mux d start` and not any `mux` — the transient `mux d endpoint` |
| 1339 | # `environ` rather than the argv's `--sock`: the path the ensure forwards is | 1340 | # processes share the runtime dir and are not daemons — and the box is |
| 1340 | # whatever this variable resolved to, so the variable is the box's identity | 1341 | # identified by the socket path in that argv rather than by |
| 1341 | # and the argv is derived from it. The shim gives each fake host a runtime | 1342 | # `XDG_RUNTIME_DIR` in the environment, because a process's environment |
| 1342 | # dir of its own, which is the whole of what makes them two machines. The | 1343 | # block is not readable on every OS while its argv is. Nothing is lost: |
| 1343 | # daemon spells `d start` because that is the argv spawn.zig | 1344 | # every starter spells `mux d start --sock PATH` and PATH is the runtime |
| 1344 | # execs it with, which is also what makes `ps` legible. | 1345 | # dir this variable holds, resolved by the ensure that forwarded it. The |
| 1346 | # shim gives each fake host a runtime dir of its own, which is the whole of | ||
| 1347 | # what makes them two machines. The daemon spells `d start` because that is | ||
| 1348 | # the argv spawn.zig execs it with, which is also what makes `ps` legible. | ||
| 1349 | # | ||
| 1350 | # Matched with `case` rather than `grep`, so the path is a literal: a tmp | ||
| 1351 | # directory whose name held a regex character would otherwise match a box | ||
| 1352 | # it does not name. | ||
| 1345 | nostart_daemons() { | 1353 | nostart_daemons() { |
| 1346 | _nd=0 | 1354 | _nd=0 |
| 1347 | for _ne in /proc/[0-9]*/environ; do | 1355 | for _np in $(ps -Ao pid= | tr -d ' '); do |
| 1348 | _np=${_ne#/proc/} | 1356 | # A pid that exits mid-walk, or one this uid may not read, answers |
| 1349 | _np=${_np%/environ} | 1357 | # nothing — which is not a daemon under $1 either way. |
| 1350 | # The redirect itself is what fails on another user's process (and | 1358 | _na=$(pid_args "$_np" 2>/dev/null) || continue |
| 1351 | # on one that exits mid-walk), so the silencing has to wrap the | 1359 | case "$_na" in *"mux d start"*) ;; *) continue ;; esac |
| 1352 | # whole command and not just `tr`. | 1360 | case "$_na" in *"$1"/*) ;; *) continue ;; esac |
| 1353 | { tr '\0' '\n' < "$_ne"; } 2>/dev/null | grep -qxF "XDG_RUNTIME_DIR=$1" || continue | ||
| 1354 | { tr '\0' ' ' < "/proc/$_np/cmdline"; } 2>/dev/null | grep -q 'mux d start' || continue | ||
| 1355 | _nd=$((_nd + 1)) | 1361 | _nd=$((_nd + 1)) |
| 1356 | done | 1362 | done |
| 1357 | echo "$_nd" | 1363 | echo "$_nd" |
| @@ -1653,9 +1659,9 @@ ok "the entry dial relays ssh's stderr and fails in ssh's own words" | |||
| 1653 | # running: the daemon keeps serving the old image, every long-lived wall | 1659 | # running: the daemon keeps serving the old image, every long-lived wall |
| 1654 | # keeps painting with it, and for months nothing anywhere said so — the | 1660 | # keeps painting with it, and for months nothing anywhere said so — the |
| 1655 | # night of 2026-09-01 was a wall flooding its screen with a bug that had | 1661 | # night of 2026-09-01 was a wall flooding its screen with a bug that had |
| 1656 | # been FIXED on disk for three days. The daemon now reads its own | 1662 | # been FIXED on disk for three days. The daemon now stats its own image's |
| 1657 | # /proc/self/exe per sessions_req and reports the kernel's ` (deleted)` | 1663 | # path per sessions_req and reports a different inode than the one it |
| 1658 | # suffix as `stale`; the poll carries it; the bar wears it. | 1664 | # booted on as `stale`; the poll carries it; the bar wears it. |
| 1659 | # | 1665 | # |
| 1660 | # Client and daemon here are the SAME build, so the version half of the | 1666 | # Client and daemon here are the SAME build, so the version half of the |
| 1661 | # drift word stays silent and `daemon stale` is the whole of it — which | 1667 | # drift word stays silent and `daemon stale` is the whole of it — which |
| @@ -1692,8 +1698,8 @@ grep -aq "daemon stale" "$OUT.sb1" && { | |||
| 1692 | exit 1; } | 1698 | exit 1; } |
| 1693 | 1699 | ||
| 1694 | # The install, by the same inode dance install(1) does: a new file RENAMED | 1700 | # The install, by the same inode dance install(1) does: a new file RENAMED |
| 1695 | # over the old, so the running daemon's exe link goes ` (deleted)`. A | 1701 | # over the old, so the path the daemon booted on now names a different |
| 1696 | # truncating copy would reuse the inode and prove nothing. | 1702 | # inode. A truncating copy would reuse the inode and prove nothing. |
| 1697 | cp "$MUX" "$SBBIN/mux.new" | 1703 | cp "$MUX" "$SBBIN/mux.new" |
| 1698 | mv -f "$SBBIN/mux.new" "$SBBIN/mux" | 1704 | mv -f "$SBBIN/mux.new" "$SBBIN/mux" |
| 1699 | 1705 | ||
test/e2e_14_upgrade.sh
| Old | New | ||
|---|---|---|---|
| @@ -33,8 +33,12 @@ defer_rm "$UPKEY" | |||
| 33 | UPHOME="${TMPDIR:-/tmp}/mux-e2e-uphome-$$" | 33 | UPHOME="${TMPDIR:-/tmp}/mux-e2e-uphome-$$" |
| 34 | defer_rm "$UPHOME" | 34 | defer_rm "$UPHOME" |
| 35 | # The candidate binary the refusal leg offers: a copy, so its exec bit can | 35 | # The candidate binary the refusal leg offers: a copy, so its exec bit can |
| 36 | # be taken away without touching the one every other scenario runs. | 36 | # be taken away without touching the one every other scenario runs. Fully |
| 37 | UPBIN="${TMPDIR:-/tmp}/mux-e2e-upcand-$$" | 37 | # resolved, because the exec witness below compares it against pid_exe, |
| 38 | # which answers with the resolved image — a $TMPDIR reached through a | ||
| 39 | # symlink would never equal a logical path and the wait would time out on a | ||
| 40 | # copy that had in fact exec'd. | ||
| 41 | UPBIN=$(readlink -f "${TMPDIR:-/tmp}/mux-e2e-upcand-$$") | ||
| 38 | defer_rm "$UPBIN" | 42 | defer_rm "$UPBIN" |
| 39 | UPAGENT="${TMPDIR:-/tmp}/mux-e2e-upagent-$$.sock" | 43 | UPAGENT="${TMPDIR:-/tmp}/mux-e2e-upagent-$$.sock" |
| 40 | defer_rm "$UPAGENT" | 44 | defer_rm "$UPAGENT" |
| @@ -156,10 +160,10 @@ grep -qF "refused: version: $UPVER is not newer than $UPVER" "$OUT.upref1" || { | |||
| 156 | # OWN path (/proc/self/exe), so the only way to spell this refusal is to | 160 | # OWN path (/proc/self/exe), so the only way to spell this refusal is to |
| 157 | # take the exec bit off a copy AFTER it has started — and the ordering is | 161 | # take the exec bit off a copy AFTER it has started — and the ordering is |
| 158 | # made deterministic rather than hoped for. The daemon is held under | 162 | # made deterministic rather than hoped for. The daemon is held under |
| 159 | # SIGSTOP so it cannot read the request until the chmod has landed; | 163 | # SIGSTOP so it cannot read the request until the chmod has landed; the |
| 160 | # `readlink /proc/PID/exe` is the witness that the copy has already | 164 | # image the OS says that pid is running is the witness that the copy has |
| 161 | # exec'd, so the chmod cannot beat it and turn the leg into an exec | 165 | # already exec'd, so the chmod cannot beat it and turn the leg into an |
| 162 | # failure that asserts nothing. | 166 | # exec failure that asserts nothing. |
| 163 | cp "$MUX_ELF" "$UPBIN" | 167 | cp "$MUX_ELF" "$UPBIN" |
| 164 | chmod 755 "$UPBIN" | 168 | chmod 755 "$UPBIN" |
| 165 | kill -STOP "$D69PID" | 169 | kill -STOP "$D69PID" |
| @@ -167,7 +171,7 @@ kill -STOP "$D69PID" | |||
| 167 | UPXPID=$! | 171 | UPXPID=$! |
| 168 | defer_kill "$UPXPID" | 172 | defer_kill "$UPXPID" |
| 169 | _i=0 | 173 | _i=0 |
| 170 | while [ "$(readlink "/proc/$UPXPID/exe" 2>/dev/null)" != "$UPBIN" ]; do | 174 | while [ "$(pid_exe "$UPXPID")" != "$UPBIN" ]; do |
| 171 | _i=$((_i + 1)) | 175 | _i=$((_i + 1)) |
| 172 | [ "$_i" -lt $(( 100 * TIME_SCALE )) ] || { | 176 | [ "$_i" -lt $(( 100 * TIME_SCALE )) ] || { |
| 173 | kill -CONT "$D69PID" 2>/dev/null || true | 177 | kill -CONT "$D69PID" 2>/dev/null || true |
| @@ -211,12 +215,13 @@ grep -qF "mux d: upgraded to $UPVER" "$OUT.upok" || { | |||
| 211 | cat "$OUT.upok"; exit 1; } | 215 | cat "$OUT.upok"; exit 1; } |
| 212 | 216 | ||
| 213 | # The pid is the feature. Not "a daemon is answering" — the same process, | 217 | # The pid is the feature. Not "a daemon is answering" — the same process, |
| 214 | # now running a different image, which /proc/PID/cmdline says in one word: | 218 | # now running a different image, which the OS's account of its argv says in |
| 215 | # a daemon that had restarted would carry the original `run --sock` argv. | 219 | # one word: a daemon that had restarted would carry the original |
| 220 | # `run --sock` argv. | ||
| 216 | kill -0 "$D69PID" 2>/dev/null || { | 221 | kill -0 "$D69PID" 2>/dev/null || { |
| 217 | echo "e2e FAIL: upgrade: the daemon pid $D69PID is gone — that is a restart, not an upgrade" | 222 | echo "e2e FAIL: upgrade: the daemon pid $D69PID is gone — that is a restart, not an upgrade" |
| 218 | cat "$OUT.up.d"; exit 1; } | 223 | cat "$OUT.up.d"; exit 1; } |
| 219 | tr '\0' ' ' < "/proc/$(real_pid "$D69PID")/cmdline" > "$OUT.upst2" 2>/dev/null || true | 224 | pid_args "$(real_pid "$D69PID")" > "$OUT.upst2" 2>/dev/null || true |
| 220 | grep -qF -- "--resume-fd" "$OUT.upst2" || { | 225 | grep -qF -- "--resume-fd" "$OUT.upst2" || { |
| 221 | echo "e2e FAIL: upgrade: pid $D69PID is not running the resumed argv; it holds:" | 226 | echo "e2e FAIL: upgrade: pid $D69PID is not running the resumed argv; it holds:" |
| 222 | cat "$OUT.upst2"; exit 1; } | 227 | cat "$OUT.upst2"; exit 1; } |
| @@ -477,7 +482,7 @@ pipe_detach "agent-upgrade client" | |||
| 477 | 482 | ||
| 478 | # A shell born of the UPGRADED daemon: a fresh named session, so its shell | 483 | # A shell born of the UPGRADED daemon: a fresh named session, so its shell |
| 479 | # was forked by the exec'd image. Its fd table is the witness — asked of | 484 | # was forked by the exec'd image. Its fd table is the witness — asked of |
| 480 | # /proc, because a daemon cannot see its own leak. Before the fix every | 485 | # the OS, because a daemon cannot see its own leak. Before the fix every |
| 481 | # post-exec shell held the daemon's listener and agent sockets. | 486 | # post-exec shell held the daemon's listener and agent sockets. |
| 482 | pipe_mux "$OUT.uagn" "$OUT.uagn.err" timeout 60 "$MUX" --sock "$SOCK71" --session post | 487 | pipe_mux "$OUT.uagn" "$OUT.uagn.err" timeout 60 "$MUX" --sock "$SOCK71" --session post |
| 483 | pipe_send 'echo newsh=$$\n' | 488 | pipe_send 'echo newsh=$$\n' |
| @@ -485,24 +490,32 @@ wait_grid "$SOCK71" "newsh=[0-9]" "agent-upgrade: the post-exec session never pr | |||
| 485 | UPNEWSH=$(dump_session "$SOCK71" post | sed -n 's/.*newsh=\([0-9][0-9]*\).*/\1/p' | head -1) | 490 | UPNEWSH=$(dump_session "$SOCK71" post | sed -n 's/.*newsh=\([0-9][0-9]*\).*/\1/p' | head -1) |
| 486 | [ -n "$UPNEWSH" ] || { | 491 | [ -n "$UPNEWSH" ] || { |
| 487 | echo "e2e FAIL: agent-upgrade: no post-exec shell pid read off the grid"; dump_session "$SOCK71" post; exit 1; } | 492 | echo "e2e FAIL: agent-upgrade: no post-exec shell pid read off the grid"; dump_session "$SOCK71" post; exit 1; } |
| 488 | # `/dev/ptmx` is a class of its own: a shell's OWN terminal is the SLAVE | 493 | # The three fd KINDS a daemon holds and a session shell must not: a |
| 494 | # listening socket, the upgrade manifest's carrier, and a pty master. The | ||
| 495 | # master is a class of its own — a shell's OWN terminal is the SLAVE | ||
| 489 | # (`/dev/pts/N`, dup2'd onto 0/1/2), so a master in a shell's fd table can | 496 | # (`/dev/pts/N`, dup2'd onto 0/1/2), so a master in a shell's fd table can |
| 490 | # only be an adopted session's, inherited. `|| true` because grep -c prints | 497 | # only be an adopted session's, inherited. Asked through the oracle by KIND |
| 491 | # 0 AND exits 1 when nothing matches, and this suite runs under set -e. | 498 | # rather than by the strings Linux happens to print, so a second OS answers |
| 492 | UPLEAK=$(ls -l "/proc/$UPNEWSH/fd" 2>/dev/null | grep -c -E 'socket:|memfd:|/dev/ptmx' || true) | 499 | # the same three questions with whatever names it uses. |
| 500 | UPLEAK=$(( $(pid_holds_fd_kind "$UPNEWSH" socket) \ | ||
| 501 | + $(pid_holds_fd_kind "$UPNEWSH" carrier) \ | ||
| 502 | + $(pid_holds_fd_kind "$UPNEWSH" ptymaster) )) | ||
| 493 | [ "$UPLEAK" -eq 0 ] || { | 503 | [ "$UPLEAK" -eq 0 ] || { |
| 494 | echo "e2e FAIL: agent-upgrade: the post-exec shell holds $UPLEAK daemon fd(s) — listeners, pty" | 504 | echo "e2e FAIL: agent-upgrade: the post-exec shell holds $UPLEAK daemon fd(s) — listeners, pty" |
| 495 | echo " masters or the manifest memfd crossed the exec without FD_CLOEXEC and were inherited:" | 505 | echo " masters or the manifest carrier crossed the exec without FD_CLOEXEC and were inherited:" |
| 496 | ls -l "/proc/$UPNEWSH/fd"; exit 1; } | 506 | pid_fd_targets "$UPNEWSH"; exit 1; } |
| 497 | pipe_detach "post-exec session client" | 507 | pipe_detach "post-exec session client" |
| 498 | 508 | ||
| 499 | # The other half of the same claim, asked of the daemon's own fd table: the | 509 | # The other half of the same claim, asked of the daemon's own fd table: the |
| 500 | # manifest memfd is adoption scaffolding, so once adoption is final nothing | 510 | # manifest carrier is adoption scaffolding, so once adoption is final |
| 501 | # should still hold it open — a survivor accumulates one per upgrade. | 511 | # nothing should still hold it open — a survivor accumulates one per |
| 502 | UPDMEM=$(ls -l "/proc/$(real_pid "$D71PID")/fd" 2>/dev/null | grep -c 'memfd:mux-upgrade' || true) | 512 | # upgrade. `carrier` is the only KIND asked for, because the manifest is |
| 513 | # the only carrier this daemon ever opens. | ||
| 514 | UPDPID=$(real_pid "$D71PID") | ||
| 515 | UPDMEM=$(pid_holds_fd_kind "$UPDPID" carrier) | ||
| 503 | [ "$UPDMEM" -eq 0 ] || { | 516 | [ "$UPDMEM" -eq 0 ] || { |
| 504 | echo "e2e FAIL: agent-upgrade: the daemon still holds $UPDMEM manifest memfd(s) after the exec" | 517 | echo "e2e FAIL: agent-upgrade: the daemon still holds $UPDMEM manifest carrier(s) after the exec" |
| 505 | ls -l "/proc/$(real_pid "$D71PID")/fd"; exit 1; } | 518 | pid_fd_targets "$UPDPID"; exit 1; } |
| 506 | 519 | ||
| 507 | assert_stopped "$SOCK71" "$D71PID" "agent-upgrade" "$OUT.uagstop" | 520 | assert_stopped "$SOCK71" "$D71PID" "agent-upgrade" "$OUT.uagstop" |
| 508 | D71PID="" | 521 | D71PID="" |
test/e2e_16_push.sh
| Old | New | ||
|---|---|---|---|
| @@ -23,6 +23,12 @@ PUSHINST="$PUSHROOT/.local/bin/mux" | |||
| 23 | 23 | ||
| 24 | mkdir -p "$PUSHROOT/.local/bin" "$PUSHROOT/run" "$PUSHROOT/state" \ | 24 | mkdir -p "$PUSHROOT/.local/bin" "$PUSHROOT/run" "$PUSHROOT/state" \ |
| 25 | "$PUSHBARE" "$PUSHIM_DIR/fakebin" | 25 | "$PUSHBARE" "$PUSHIM_DIR/fakebin" |
| 26 | # The path the daemon's image is compared against, resolved ONCE and the | ||
| 27 | # same way the oracle resolves the link: pid_exe is `readlink -f`, so a | ||
| 28 | # $TMPDIR reached through a symlink would fail a correct push if only one | ||
| 29 | # side were canonical. After the mkdir, because `readlink -f` needs every | ||
| 30 | # component but the last to exist. | ||
| 31 | PUSHWANT=$(readlink -f "$PUSHINST") | ||
| 26 | 32 | ||
| 27 | # The shim IS ssh as far as the pusher can tell (e2e_04 explains that | 33 | # The shim IS ssh as far as the pusher can tell (e2e_04 explains that |
| 28 | # contract), and here it also IS the remote box: it swaps in the fake box's | 34 | # contract), and here it also IS the remote box: it swaps in the fake box's |
| @@ -150,15 +156,17 @@ grep -q "upgraded to" "$OUT.push_live" || { | |||
| 150 | cat "$OUT.push_live" | 156 | cat "$OUT.push_live" |
| 151 | exit 1 | 157 | exit 1 |
| 152 | } | 158 | } |
| 153 | # The OS's account, not the daemon's: same pid, and its exe link names the | 159 | # The OS's account, not the daemon's: same pid, and the image the OS says |
| 154 | # installed path with no ' (deleted)' tail — the exec adopted the NEW inode | 160 | # it is running is the installed path with no ' (deleted)' tail — the exec |
| 155 | # at that path, rather than the old one riding out a rename. | 161 | # adopted the NEW inode at that path, rather than the old one riding out a |
| 162 | # rename. Both sides are fully resolved (see $PUSHWANT above). | ||
| 156 | kill -0 "$PUSHDPID" 2>/dev/null || { | 163 | kill -0 "$PUSHDPID" 2>/dev/null || { |
| 157 | echo "e2e FAIL: the daemon pid died across the push upgrade" | 164 | echo "e2e FAIL: the daemon pid died across the push upgrade" |
| 158 | exit 1 | 165 | exit 1 |
| 159 | } | 166 | } |
| 160 | [ "$(readlink "/proc/$PUSHDPID/exe")" = "$PUSHINST" ] || { | 167 | PUSHEXE=$(pid_exe "$PUSHDPID") |
| 161 | echo "e2e FAIL: /proc/$PUSHDPID/exe is $(readlink "/proc/$PUSHDPID/exe"), not $PUSHINST" | 168 | [ "$PUSHEXE" = "$PUSHWANT" ] || { |
| 169 | echo "e2e FAIL: the pushed daemon's image is $PUSHEXE, not $PUSHWANT" | ||
| 162 | exit 1 | 170 | exit 1 |
| 163 | } | 171 | } |
| 164 | cmp -s "$PUSHINST" "$MUX_ELF" || { | 172 | cmp -s "$PUSHINST" "$MUX_ELF" || { |
test/e2e_lib.sh
| Old | New | ||
|---|---|---|---|
| @@ -300,7 +300,7 @@ hardkill() { | |||
| 300 | # wrapper writes no coverage.db at any --output-interval, so a clean | 300 | # wrapper writes no coverage.db at any --output-interval, so a clean |
| 301 | # exit is the only exit that keeps the data. Then wait for it, because | 301 | # exit is the only exit that keeps the data. Then wait for it, because |
| 302 | # killing both at once is the same as never killing the child at all. | 302 | # killing both at once is the same as never killing the child at all. |
| 303 | for _c in $(ps -o pid= --ppid "$1" 2>/dev/null); do | 303 | for _c in $(pid_children "$1"); do |
| 304 | kill -9 "$_c" 2>/dev/null || true | 304 | kill -9 "$_c" 2>/dev/null || true |
| 305 | done | 305 | done |
| 306 | _i=0 | 306 | _i=0 |
| @@ -321,7 +321,7 @@ hardkill() { | |||
| 321 | # cmdline is what a resumed-argv check would otherwise read. | 321 | # cmdline is what a resumed-argv check would otherwise read. |
| 322 | real_pid() { | 322 | real_pid() { |
| 323 | if [ "$(ps -o comm= -p "$1" 2>/dev/null)" = kcov ]; then | 323 | if [ "$(ps -o comm= -p "$1" 2>/dev/null)" = kcov ]; then |
| 324 | ps -o pid= --ppid "$1" 2>/dev/null | head -1 | tr -d ' ' | 324 | pid_children "$1" | head -1 |
| 325 | else | 325 | else |
| 326 | echo "$1" | 326 | echo "$1" |
| 327 | fi | 327 | fi |
| @@ -330,7 +330,7 @@ real_pid() { | |||
| 330 | softkill() { | 330 | softkill() { |
| 331 | if [ "$(ps -o comm= -p "$1" 2>/dev/null)" = kcov ]; then | 331 | if [ "$(ps -o comm= -p "$1" 2>/dev/null)" = kcov ]; then |
| 332 | _rc=1 | 332 | _rc=1 |
| 333 | for _c in $(ps -o pid= --ppid "$1" 2>/dev/null); do | 333 | for _c in $(pid_children "$1"); do |
| 334 | kill "$_c" 2>/dev/null && _rc=0 | 334 | kill "$_c" 2>/dev/null && _rc=0 |
| 335 | done | 335 | done |
| 336 | return $_rc | 336 | return $_rc |
| @@ -338,6 +338,14 @@ softkill() { | |||
| 338 | kill "$1" 2>/dev/null | 338 | kill "$1" 2>/dev/null |
| 339 | } | 339 | } |
| 340 | 340 | ||
| 341 | # ---- the OS oracle ------------------------------------------------------ | ||
| 342 | # The helpers themselves are in os_oracle.sh, sourced here where they used | ||
| 343 | # to be written out. They moved because soak.sh reads two of them as well, | ||
| 344 | # and its copies were a second spelling of a question this repo means to | ||
| 345 | # ask in one place. That file is trap-free on purpose: the registry and the | ||
| 346 | # EXIT trap below are this file's, and it must not bring a second of either. | ||
| 347 | . "$(dirname "$0")/os_oracle.sh" | ||
| 348 | |||
| 341 | # Poll until nothing answers on a socket path (2s). Keyed off the daemon's | 349 | # Poll until nothing answers on a socket path (2s). Keyed off the daemon's |
| 342 | # own liveness rather than a fixed sleep, same reasoning as wait_for. | 350 | # own liveness rather than a fixed sleep, same reasoning as wait_for. |
| 343 | wait_gone() { | 351 | wait_gone() { |
| @@ -1421,3 +1429,146 @@ cleanup() { | |||
| 1421 | fi | 1429 | fi |
| 1422 | } | 1430 | } |
| 1423 | trap cleanup EXIT INT TERM | 1431 | trap cleanup EXIT INT TERM |
| 1432 | |||
| 1433 | # ---- the oracle's own pin ----------------------------------------------- | ||
| 1434 | # The runner calls this once, before the first group. Every helper above is | ||
| 1435 | # a question a pin asks INSTEAD of asking the daemon, so a helper that | ||
| 1436 | # quietly stopped answering would not fail a pin loudly — it would make the | ||
| 1437 | # pin agree with anything, and the suite would go green having tested | ||
| 1438 | # nothing. This asks each helper something the box can independently | ||
| 1439 | # confirm. | ||
| 1440 | # | ||
| 1441 | # Off-origin on every dimension the helpers could accidentally hold | ||
| 1442 | # constant, because a fixture that pins N=1 or offset=0 is blind to N and | ||
| 1443 | # to the offset (CLAUDE.md): the subject is a child that is not pid 1, it | ||
| 1444 | # holds MORE than the three fds any fixture would, it has an argv worth | ||
| 1445 | # losing, and it has a child of its own. The socket arm is asked in both | ||
| 1446 | # directions — a listener that HOLDS the path and a shell that does not — | ||
| 1447 | # because a `pid_holds_unix_sock` that answered yes to everything would | ||
| 1448 | # pass the one pin that reads it just as happily as a correct one. | ||
| 1449 | # | ||
| 1450 | # The `sh -c` body ends in `:` on purpose. With nothing after it, a shell | ||
| 1451 | # execs the last command in place rather than forking it — bash measured | ||
| 1452 | # doing exactly that — and the subject would be `sleep`, wearing sleep's | ||
| 1453 | # comm, exe and argv and having no child at all. That is a different | ||
| 1454 | # process from the one this means to ask about. | ||
| 1455 | oracle_selftest() { | ||
| 1456 | _osock="$OUT.oracle.sock" | ||
| 1457 | rm -f "$_osock" | ||
| 1458 | printf 'hello' > "$OUT.oracle.probe" | ||
| 1459 | # python3 rather than sh: nothing in POSIX sh can bind a unix socket, | ||
| 1460 | # and the runner has already refused a box without python3. | ||
| 1461 | python3 -c 'import socket,sys,time | ||
| 1462 | s = socket.socket(socket.AF_UNIX); s.bind(sys.argv[1]); s.listen(1); time.sleep(300)' \ | ||
| 1463 | "$_osock" & | ||
| 1464 | _olpid=$! | ||
| 1465 | sh -c 'exec 5>/dev/null 6>/dev/null; sleep 300; :' & | ||
| 1466 | _opid=$! | ||
| 1467 | # Registered, not merely killed at the bottom: an assertion below that | ||
| 1468 | # fails exits through the trap, and an unregistered `sleep 300` would | ||
| 1469 | # outlive the run by five minutes — ten of them under `make soak`. | ||
| 1470 | defer_kill "$_olpid" "$_opid" | ||
| 1471 | _oi=0 | ||
| 1472 | while [ ! -S "$_osock" ] && [ "$_oi" -lt $(( 50 * TIME_SCALE )) ]; do | ||
| 1473 | sleep 0.1; _oi=$((_oi + 1)) | ||
| 1474 | done | ||
| 1475 | [ -S "$_osock" ] || { | ||
| 1476 | echo "e2e FAIL: oracle: the listener bound no socket at $_osock"; exit 1; } | ||
| 1477 | |||
| 1478 | # The subject is only the process this means to ask about once it has | ||
| 1479 | # opened fds 5 and 6 and forked `sleep`; until then `pid_fd_count` and | ||
| 1480 | # `pid_children` would be graded against a shell still starting up, and | ||
| 1481 | # the self-test would fail for a reason that is nothing to do with the | ||
| 1482 | # helpers. A child is the LAST of those steps, so waiting for one waits | ||
| 1483 | # for all of them. | ||
| 1484 | wait_until 50 "oracle: the subject shell never forked its sleep" \ | ||
| 1485 | '[ -n "$(pid_children "$_opid")" ]' | ||
| 1486 | |||
| 1487 | pid_alive "$_opid" || { | ||
| 1488 | echo "e2e FAIL: oracle: pid_alive says a live shell is dead"; exit 1; } | ||
| 1489 | [ "$(pid_comm "$_opid")" = sh ] || { | ||
| 1490 | echo "e2e FAIL: oracle: pid_comm of an sh is '$(pid_comm "$_opid")'"; exit 1; } | ||
| 1491 | pid_args "$_opid" | grep -q 'sleep 300' || { | ||
| 1492 | echo "e2e FAIL: oracle: pid_args lost the argv: [$(pid_args "$_opid")]"; exit 1; } | ||
| 1493 | [ "$(pid_fd_count "$_opid")" -ge 5 ] || { | ||
| 1494 | echo "e2e FAIL: oracle: pid_fd_count is $(pid_fd_count "$_opid") for a shell" | ||
| 1495 | echo " holding fd 5 and fd 6 on top of the usual three"; exit 1; } | ||
| 1496 | [ "$(pid_exe "$_opid")" = "$(readlink -f "$(command -v sh)")" ] || { | ||
| 1497 | echo "e2e FAIL: oracle: pid_exe is '$(pid_exe "$_opid")', want the resolved" | ||
| 1498 | echo " $(readlink -f "$(command -v sh)")"; exit 1; } | ||
| 1499 | # head -1 because this is a pid to kill as well as an answer to check, | ||
| 1500 | # and the registry and kill(1) below both take one word. | ||
| 1501 | _okid=$(pid_children "$_opid" | head -1) | ||
| 1502 | [ -n "$_okid" ] || { | ||
| 1503 | echo "e2e FAIL: oracle: pid_children names no child of a shell running sleep" | ||
| 1504 | exit 1; } | ||
| 1505 | defer_kill "$_okid" | ||
| 1506 | # Through a variable and a case rather than straight into test(1): a | ||
| 1507 | # helper that answered with nothing would otherwise abort the run with | ||
| 1508 | # a bare `[: -gt: unary operator expected` and no e2e FAIL line saying | ||
| 1509 | # which helper went quiet. | ||
| 1510 | _orss=$(pid_rss_kb "$_opid") | ||
| 1511 | case "$_orss" in ''|*[!0-9]*) _orss=0 ;; esac | ||
| 1512 | [ "$_orss" -gt 0 ] || { | ||
| 1513 | echo "e2e FAIL: oracle: pid_rss_kb is '$(pid_rss_kb "$_opid")'"; exit 1; } | ||
| 1514 | pid_holds_unix_sock "$_olpid" "$_osock" || { | ||
| 1515 | echo "e2e FAIL: oracle: pid_holds_unix_sock cannot see $_osock among the" | ||
| 1516 | echo " fds of the pid $_olpid that bound it"; exit 1; } | ||
| 1517 | # The `socket` KIND off a real fd: the listener bound one, so its count | ||
| 1518 | # is at least one. Only that KIND is asserted here. `carrier` and | ||
| 1519 | # `ptymaster` would each need a process holding an anonymous memory file | ||
| 1520 | # or a pty master, which is a daemon — and e2e_14_upgrade already asks | ||
| 1521 | # both of a real one, where a wrong answer fails that pin. | ||
| 1522 | _ofdk=$(pid_holds_fd_kind "$_olpid" socket) | ||
| 1523 | case "$_ofdk" in ''|*[!0-9]*) _ofdk=0 ;; esac | ||
| 1524 | [ "$_ofdk" -ge 1 ] || { | ||
| 1525 | echo "e2e FAIL: oracle: pid_holds_fd_kind says $_olpid holds" | ||
| 1526 | echo " '$(pid_holds_fd_kind "$_olpid" socket)' fds of kind socket, and it" | ||
| 1527 | echo " bound $_osock"; exit 1; } | ||
| 1528 | # `! cmd || {}` rather than `cmd && {}`, for the reason spelled out | ||
| 1529 | # below: an AND-list whose left side fails is a failing command under | ||
| 1530 | # `set -e`, so the passing case would end the run. | ||
| 1531 | ! pid_holds_fd_kind "$_opid" nosuchkind 2>/dev/null || { | ||
| 1532 | echo "e2e FAIL: oracle: pid_holds_fd_kind accepted a kind it does not know," | ||
| 1533 | echo " so a typo in a group file would count zero and pass"; exit 1; } | ||
| 1534 | ! pid_holds_unix_sock "$_opid" "$_osock" || { | ||
| 1535 | echo "e2e FAIL: oracle: pid_holds_unix_sock says the shell $_opid holds" | ||
| 1536 | echo " $_osock, which only $_olpid ever opened"; exit 1; } | ||
| 1537 | # `! cmd || {}` rather than `cmd && {}`, for wait_sock's reason: an | ||
| 1538 | # AND-list whose left side fails is a failing command under `set -e`, | ||
| 1539 | # so the passing case would end the run. | ||
| 1540 | ! udp_local_bound 'ZZZZZZZZ:ZZZZ' || { | ||
| 1541 | echo "e2e FAIL: oracle: udp_local_bound found a socket at an address no" | ||
| 1542 | echo " kernel can spell"; exit 1; } | ||
| 1543 | [ -n "$(udp_table)" ] || { | ||
| 1544 | echo "e2e FAIL: oracle: udp_table is empty, so a bind diagnostic would" | ||
| 1545 | echo " print nothing"; exit 1; } | ||
| 1546 | # Against what the probe file IS, never against a second spelling of the | ||
| 1547 | # same command: comparing file_mode with `stat -c %a` would agree with | ||
| 1548 | # itself on any OS and would have to be ported alongside the helper. A | ||
| 1549 | # chmod the kernel performed, five bytes written, and the published | ||
| 1550 | # SHA-256 of "hello" are answers no arm of the oracle can define away. | ||
| 1551 | chmod 640 "$OUT.oracle.probe" | ||
| 1552 | [ "$(file_mode "$OUT.oracle.probe")" = "640" ] || { | ||
| 1553 | echo "e2e FAIL: oracle: file_mode says '$(file_mode "$OUT.oracle.probe")' for a" | ||
| 1554 | echo " file the kernel just chmod'd to 640"; exit 1; } | ||
| 1555 | [ "$(file_size "$OUT.oracle.probe")" = "5" ] || { | ||
| 1556 | echo "e2e FAIL: oracle: file_size says '$(file_size "$OUT.oracle.probe")' for five bytes" | ||
| 1557 | exit 1; } | ||
| 1558 | [ "$(sha256_of "$OUT.oracle.probe")" = \ | ||
| 1559 | "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" ] || { | ||
| 1560 | echo "e2e FAIL: oracle: sha256_of hashes 'hello' to" | ||
| 1561 | echo " '$(sha256_of "$OUT.oracle.probe")'"; exit 1; } | ||
| 1562 | |||
| 1563 | kill "$_olpid" "$_opid" "$_okid" 2>/dev/null || true | ||
| 1564 | wait "$_olpid" 2>/dev/null || true | ||
| 1565 | wait "$_opid" 2>/dev/null || true | ||
| 1566 | _oi=0 | ||
| 1567 | while pid_alive "$_opid" && [ "$_oi" -lt $(( 50 * TIME_SCALE )) ]; do | ||
| 1568 | sleep 0.05; _oi=$((_oi + 1)) | ||
| 1569 | done | ||
| 1570 | ! pid_alive "$_opid" || { | ||
| 1571 | echo "e2e FAIL: oracle: pid_alive says a killed shell still lives"; exit 1; } | ||
| 1572 | rm -f "$_osock" "$OUT.oracle.probe" | ||
| 1573 | ok "oracle: the OS answers the helpers by name" | ||
| 1574 | } | ||
test/os_oracle.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,65 @@ | |||
| 1 | # shellcheck shell=sh | ||
| 2 | # os_oracle.sh — the OS oracle, sourced and never run. | ||
| 3 | # | ||
| 4 | # Sourced by test/e2e_lib.sh, where these helpers used to live, and by | ||
| 5 | # test/soak.sh, which reads two of them between runs. It is deliberately | ||
| 6 | # trap-free and state-free: both readers bring a cleanup registry and an | ||
| 7 | # EXIT trap of their own, and a second trap here would replace one of | ||
| 8 | # theirs. soak.sh used to carry copies of the two it needs, and a copy is a | ||
| 9 | # second spelling to port the day a second OS arrives. | ||
| 10 | # | ||
| 11 | # "Ask the OS about the OS, not the daemon" (CLAUDE.md). Every pin that | ||
| 12 | # reads a pid, an fd table, a bound UDP port or a file's mode asks through | ||
| 13 | # these names, so the SPELLING of the question lives in one place per OS | ||
| 14 | # and the question itself lives in the group file. The Linux arm is /proc, | ||
| 15 | # GNU stat and sha256sum; a second OS adds a `case "$(uname)"` arm here and | ||
| 16 | # nothing in a group file changes. Each prints its answer on stdout and | ||
| 17 | # returns 1 when the OS will not say, so a caller may treat "no answer" | ||
| 18 | # and "the wrong answer" as the same failure. | ||
| 19 | # | ||
| 20 | # oracle_selftest in e2e_lib.sh is these helpers' own pin. A helper that | ||
| 21 | # stopped answering — a missing binary, a /proc the sandbox will not show — | ||
| 22 | # would otherwise turn every pin that reads it into a check that passes | ||
| 23 | # without running, which is the one failure a green tree cannot show. | ||
| 24 | pid_alive() { kill -0 "$1" 2>/dev/null || [ -d "/proc/$1" ]; } | ||
| 25 | pid_exe() { readlink -f "/proc/$1/exe" 2>/dev/null; } | ||
| 26 | pid_comm() { cat "/proc/$1/comm" 2>/dev/null; } | ||
| 27 | pid_args() { tr '\0' ' ' < "/proc/$1/cmdline" 2>/dev/null; } | ||
| 28 | pid_children() { ps -o pid= --ppid "$1" 2>/dev/null | tr -d ' '; } | ||
| 29 | pid_fd_count() { find "/proc/$1/fd" -mindepth 1 2>/dev/null | wc -l | tr -d ' '; } | ||
| 30 | pid_fd_targets() { readlink "/proc/$1"/fd/* 2>/dev/null; } | ||
| 31 | # pid_holds_fd_kind PID KIND — how many of PID's open fds are of KIND, which | ||
| 32 | # is one of `socket`, `carrier` (the upgrade manifest's anonymous memory | ||
| 33 | # file) or `ptymaster`. Prints the count. These are the three KINDS an | ||
| 34 | # upgrade pin asks about, and naming them is the point: a second OS's arm | ||
| 35 | # counts the same three things under whatever that OS calls them, and the | ||
| 36 | # group file keeps asking the same question. `|| true` because grep -c | ||
| 37 | # prints 0 AND exits 1 when nothing matches, and callers run under `set -e`. | ||
| 38 | pid_holds_fd_kind() { | ||
| 39 | case "$2" in | ||
| 40 | socket) _fdk='socket:' ;; | ||
| 41 | carrier) _fdk='memfd:' ;; | ||
| 42 | ptymaster) _fdk='/dev/ptmx' ;; | ||
| 43 | *) echo "pid_holds_fd_kind: no such fd kind '$2'" >&2; return 1 ;; | ||
| 44 | esac | ||
| 45 | pid_fd_targets "$1" | grep -c -F "$_fdk" || true | ||
| 46 | } | ||
| 47 | # By INODE, never by name: a process running the right program while | ||
| 48 | # binding something else entirely would satisfy a name check. The kernel's | ||
| 49 | # listening inode for PATH, found among that pid's open fds, cannot. | ||
| 50 | pid_holds_unix_sock() { | ||
| 51 | _ino=$(awk -v p="$2" '$NF == p {print $7}' /proc/net/unix | head -1) | ||
| 52 | [ -n "$_ino" ] && pid_fd_targets "$1" | grep -qx "socket:\[$_ino\]" | ||
| 53 | } | ||
| 54 | pid_rss_kb() { awk '/VmRSS/{print $2}' "/proc/$1/status" 2>/dev/null || echo 0; } | ||
| 55 | # Matched on the LOCAL address ($2) rather than anywhere on the line: the | ||
| 56 | # remote address of a client dialling this port is the same hex, and a | ||
| 57 | # whole-line match read a reconnecting client as a bound listener. | ||
| 58 | udp_local_bound() { awk -v h="$1" '$2==h{f=1} END{exit !f}' /proc/net/udp; } | ||
| 59 | udp_table() { cat /proc/net/udp 2>/dev/null; } | ||
| 60 | file_mode() { stat -c %a "$1"; } | ||
| 61 | file_size() { stat -c %s "$1"; } | ||
| 62 | sha256_of() { sha256sum "$1" | cut -d' ' -f1; } | ||
| 63 | # GNU timeout is a binary here; a box without one names it gtimeout, and a | ||
| 64 | # group file keeps spelling `timeout` either way. | ||
| 65 | command -v timeout >/dev/null 2>&1 || timeout() { gtimeout "$@"; } | ||
test/soak.sh
| Old | New | ||
|---|---|---|---|
| @@ -11,6 +11,12 @@ set -u | |||
| 11 | # other makes every run abort on an unbound variable. | 11 | # other makes every run abort on an unbound variable. |
| 12 | MUX="$1"; RAWMODE="$2"; DELAYPIPE="$3"; RENDER="$4"; PTYCLIENT="$5"; WSCLIENT="$6" | 12 | MUX="$1"; RAWMODE="$2"; DELAYPIPE="$3"; RENDER="$4"; PTYCLIENT="$5"; WSCLIENT="$6" |
| 13 | E2E="$(dirname "$0")/e2e.sh" | 13 | E2E="$(dirname "$0")/e2e.sh" |
| 14 | # pid_rss_kb and pid_fd_count, from the file that owns every OS-specific | ||
| 15 | # spelling in this suite. Sourced rather than copied: os_oracle.sh brings | ||
| 16 | # no trap and no cleanup registry, so it composes with the ones this script | ||
| 17 | # already has, and a second OS's arm is written once for both readers. | ||
| 18 | # shellcheck source=test/os_oracle.sh | ||
| 19 | . "$(dirname "$0")/os_oracle.sh" | ||
| 14 | N="${SOAK_N:-10}" | 20 | N="${SOAK_N:-10}" |
| 15 | TMP="${TMPDIR:-/tmp}" | 21 | TMP="${TMPDIR:-/tmp}" |
| 16 | FAILDIR="$TMP/mux-soak-$$-failures" | 22 | FAILDIR="$TMP/mux-soak-$$-failures" |
| @@ -100,14 +106,14 @@ else | |||
| 100 | ATTACH_FAILED=1 | 106 | ATTACH_FAILED=1 |
| 101 | break | 107 | break |
| 102 | fi | 108 | fi |
| 103 | RSS=$(awk '/VmRSS/{print $2}' "/proc/$PDPID/status" 2>/dev/null || echo 0) | 109 | RSS=$(pid_rss_kb "$PDPID") |
| 104 | # The daemon pid can be an unreaped zombie by now: /proc/$PDPID/status | 110 | # The daemon pid can be an unreaped zombie by now: the OS still has |
| 105 | # still exists but has no VmRSS line, awk exits 0 with empty output, | 111 | # the pid but reports no resident size for it, so pid_rss_kb prints |
| 106 | # and the `|| echo 0` fallback never fires (awk didn't fail). Guard | 112 | # an empty string rather than failing. Guard it directly so the |
| 107 | # the empty string directly so the died-mid-phase check below isn't | 113 | # died-mid-phase check below isn't skipped by `[ "" -eq 0 ]` |
| 108 | # skipped by `[ "" -eq 0 ]` erroring-as-false. | 114 | # erroring-as-false. |
| 109 | RSS=${RSS:-0} | 115 | RSS=${RSS:-0} |
| 110 | FD=$(ls "/proc/$PDPID/fd" 2>/dev/null | wc -l) | 116 | FD=$(pid_fd_count "$PDPID") |
| 111 | [ "$c" -eq "$WARMUP" ] && { BASE_RSS=$RSS; BASE_FD=$FD; } | 117 | [ "$c" -eq "$WARMUP" ] && { BASE_RSS=$RSS; BASE_FD=$FD; } |
| 112 | c=$((c + 1)) | 118 | c=$((c + 1)) |
| 113 | done | 119 | done |
| @@ -128,7 +134,7 @@ else | |||
| 128 | # settle beat kills the flake without weakening the equality | 134 | # settle beat kills the flake without weakening the equality |
| 129 | # check itself — a real leak won't self-heal in a second. | 135 | # check itself — a real leak won't self-heal in a second. |
| 130 | sleep 1 | 136 | sleep 1 |
| 131 | FD=$(ls "/proc/$PDPID/fd" 2>/dev/null | wc -l) | 137 | FD=$(pid_fd_count "$PDPID") |
| 132 | fi | 138 | fi |
| 133 | if [ "$FD" -ne "$BASE_FD" ]; then | 139 | if [ "$FD" -ne "$BASE_FD" ]; then |
| 134 | echo "soak FAIL: persistence fd count $BASE_FD -> $FD across detached cycles" | 140 | echo "soak FAIL: persistence fd count $BASE_FD -> $FD across detached cycles" |
test/vm.sh
| Old | New | ||
|---|---|---|---|
| @@ -143,6 +143,9 @@ RPID=$(vssh 'mux d dump' | sed -n 's/.*vmj2=\([0-9][0-9]*\).*/\1/p' | head -1) | |||
| 143 | echo "vm FAIL: entry dial: the marker never reached the REMOTE daemon's grid; it holds:" | 143 | echo "vm FAIL: entry dial: the marker never reached the REMOTE daemon's grid; it holds:" |
| 144 | vssh 'mux d dump' | sed 's/^/ /' | 144 | vssh 'mux d dump' | sed 's/^/ /' |
| 145 | exit 1; } | 145 | exit 1; } |
| 146 | # Read on the VM, and the VM is Linux — /proc is the right spelling THERE | ||
| 147 | # whatever this script is run from, so this one stays out of the e2e_lib.sh | ||
| 148 | # oracle (whose helpers answer about the local box). | ||
| 146 | vssh "test -d /proc/$RPID" || { | 149 | vssh "test -d /proc/$RPID" || { |
| 147 | echo "vm FAIL: entry dial: the grid names shell pid $RPID but the VM has no such process" | 150 | echo "vm FAIL: entry dial: the grid names shell pid $RPID but the VM has no such process" |
| 148 | exit 1; } | 151 | exit 1; } |
| @@ -154,6 +157,8 @@ NPROCS=$(vssh 'pgrep -cx mux') | |||
| 154 | echo "vm FAIL: entry dial: after detach the box runs $NPROCS mux processes, want 1 (the daemon):" | 157 | echo "vm FAIL: entry dial: after detach the box runs $NPROCS mux processes, want 1 (the daemon):" |
| 155 | vssh 'ps -o pid,args -C mux' | sed 's/^/ /' | 158 | vssh 'ps -o pid,args -C mux' | sed 's/^/ /' |
| 156 | exit 1; } | 159 | exit 1; } |
| 160 | # Also read on the VM, and for the same reason: the box under test is a | ||
| 161 | # Linux VM, so its own /proc is the oracle here. | ||
| 157 | EXE=$(vssh "readlink /proc/$DPID/exe") | 162 | EXE=$(vssh "readlink /proc/$DPID/exe") |
| 158 | [ "$EXE" = "/home/ubuntu/.local/bin/mux" ] || { | 163 | [ "$EXE" = "/home/ubuntu/.local/bin/mux" ] || { |
| 159 | echo "vm FAIL: entry dial: daemon $DPID execs '$EXE', not the installed image" | 164 | echo "vm FAIL: entry dial: daemon $DPID execs '$EXE', not the installed image" |
test/wan.sh
| Old | New | ||
|---|---|---|---|
| @@ -582,6 +582,10 @@ def transport_pid(client_pid): | |||
| 582 | string, so a pattern kill takes out the very client under test. (That | 582 | string, so a pattern kill takes out the very client under test. (That |
| 583 | mistake has already been made once in this project's e2e work; it is a | 583 | mistake has already been made once in this project's e2e work; it is a |
| 584 | rule now, not a preference.) | 584 | rule now, not a preference.) |
| 585 | |||
| 586 | `ps --ppid` is a GNU procps flag, which is fine here: this rig runs on | ||
| 587 | a Linux dev box against Linux boxes, and it is not part of the e2e | ||
| 588 | suite the platform layer keeps portable. | ||
| 585 | """ | 589 | """ |
| 586 | out = subprocess.run(["ps", "--ppid", str(client_pid), "-o", "pid="], | 590 | out = subprocess.run(["ps", "--ppid", str(client_pid), "-o", "pid="], |
| 587 | capture_output=True, text=True).stdout.split() | 591 | capture_output=True, text=True).stdout.split() |
| @@ -1171,9 +1175,11 @@ if [ "${MUX_WAN_QUIC:-}" = "1" ]; then | |||
| 1171 | QSPEC="quic://$QHOST:$QPORT --key $LKEY --quic-idle-ms $QIDLE" | 1175 | QSPEC="quic://$QHOST:$QPORT --key $LKEY --quic-idle-ms $QIDLE" |
| 1172 | 1176 | ||
| 1173 | # OUR daemon owns that port, not merely somebody's socket. Matching the | 1177 | # OUR daemon owns that port, not merely somebody's socket. Matching the |
| 1174 | # port alone in /proc/net/udp would pass just as happily on a stranger's | 1178 | # port alone in the remote box's /proc/net/udp would pass just as |
| 1175 | # listener, and then every QUIC number below would be measuring a | 1179 | # happily on a stranger's listener, and then every QUIC number below |
| 1176 | # handshake against something else entirely. | 1180 | # would be measuring a handshake against something else entirely. (The |
| 1181 | # read runs over ssh on a Linux box, so its own /proc and `ss` are the | ||
| 1182 | # oracle there; e2e_lib.sh's helpers answer about the local box.) | ||
| 1177 | say "QUIC: checking the daemon owns udp $QPORT" | 1183 | say "QUIC: checking the daemon owns udp $QPORT" |
| 1178 | # Matched on PID, not on name: ss truncates a process name to 15 | 1184 | # Matched on PID, not on name: ss truncates a process name to 15 |
| 1179 | # characters, and a tag this long is exactly the kind of thing that | 1185 | # characters, and a tag this long is exactly the kind of thing that |