a73x

0c516740

Merge branch macos-port-step2: the platform layer (macOS port, step 2)

a73x   2026-09-03 15:10

Commit message
Merge branch macos-port-step2: the platform layer (macOS port, step 2)

# Conflicts:
#	docs/decisions.md

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