a73x

c9980623

build: folder rule 7 keeps every OS-specific spelling under src/os

a73x   2026-09-03 15:10

Commit message
build: folder rule 7 keeps every OS-specific spelling under src/os

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SakwJEwD9dXBoRP5kWbemW

CLAUDE.md
Old New
@@ -78,8 +78,13 @@ we built; its three markers are `flags.zig`, `main.zig` and
78 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,
79 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
80 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
81 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`,
82 `protocol.zig` and `keymap.zig`, each of which produces VT bytes by contract. 82 `close_range`, `exit_group`, the `SO_PEERCRED` socket option and the two
83 Linux-only pty ioctls may appear only under `src/os/`, comments included —
84 the roots `server_os.zig` and `client_os.zig` are the contract and their
85 `_linux` children the spellings (spec 2026-09-03). Rule 4's three remaining
86 debts are the markers in `engine.zig`, `protocol.zig` and `keymap.zig`, each
87 of which produces VT bytes by contract.
83 `predict.zig` sits under `src/tui/` with the rest of the wall, so the overlay 88 `predict.zig` sits under `src/tui/` with the rest of the wall, so the overlay
84 lives beside the code that paints it and nowhere `term` would find it. 89 lives beside the code that paints it and nowhere `term` would find it.
85 `spawn` lives under `src/os/` with the rest of the platform layer: asking 90 `spawn` lives under `src/os/` with the rest of the platform layer: asking
Makefile
Old New
@@ -135,8 +135,14 @@ throughput:
135 $(ZIG) build -Doptimize=ReleaseSafe -p $(THRUDIR) 135 $(ZIG) build -Doptimize=ReleaseSafe -p $(THRUDIR)
136 ./test/throughput.sh $(THRUDIR)/bin/mux $(THRUDIR)/bin/ptyclient 136 ./test/throughput.sh $(THRUDIR)/bin/mux $(THRUDIR)/bin/ptyclient
137 137
138 # `zig build check` grades the tree; bans.sh grades the grader. The folder
139 # rules are the one gate whose failure mode is silence — a needle list
140 # edited down to nothing, or a folder dropped from a rule's list, leaves a
141 # green tree asserting rules that no longer bite — so one planted needle per
142 # rule runs here, after the gate it is checking. Two seconds.
138 check: 143 check:
139 $(ZIG) build check 144 $(ZIG) build check
145 sh test/bans.sh $(ZIG)
140 146
141 # Everything a change must pass before delivery, cheapest gate first so a 147 # 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 148 # fmt slip fails in seconds rather than after the e2e suites. Sequential
build.zig
Old New
@@ -392,6 +392,27 @@ const source_bans = [_]SourceBan{
392 "client that forked a daemon would be choosing the daemon's " ++ 392 "client that forked a daemon would be choosing the daemon's " ++
393 "flags, its log and its refusals, none of which it can see", 393 "flags, its log and its refusals, none of which it can see",
394 }, 394 },
395 .{
396 .rule = "7",
397 .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli" },
398 // The raw spellings the platform layer exists to hold. `src/os/` is
399 // absent from the list on purpose: its children may spell anything,
400 // and its roots have no reason to. Comments count, as they do for
401 // rule 4 — a comment naming a Linux mechanism is one that goes
402 // stale the day a second arm exists.
403 // Four of the needles are spelled to catch a name in both the form
404 // Zig writes it and the form C and our own prose do. `so.peercred`
405 // and `so_peercred` are `std.posix.SO.PEERCRED` and `SO_PEERCRED`; a
406 // bare `peercred` would ban `client_os.peerCred`, the very operation
407 // callers are supposed to reach for. `iocsptlck` and `iocgptn` drop
408 // the leading T so they catch `std.posix.T.IOCGPTN` as well as
409 // `TIOCGPTN` — `std.posix.T` exists, so that first spelling names no
410 // `std.os.linux` and would otherwise be a Linux-ism that passes.
411 .needles = &.{ "std.os.linux", "/proc", "memfd", "close_range", "exit_group", "so.peercred", "so_peercred", "iocsptlck", "iocgptn" },
412 .why = "a call whose spelling differs by OS belongs in src/os/, behind a " ++
413 "server_os or client_os operation whose doc names what it guarantees; " ++
414 "everything else builds for every OS from the same line",
415 },
395 }; 416 };
396 417
397 fn checkSourceBan(b: *std.Build, ban: SourceBan) void { 418 fn checkSourceBan(b: *std.Build, ban: SourceBan) void {
src/cli/main.zig
Old New
@@ -855,11 +855,12 @@ fn remoteUpgradeCmd(alloc: std.mem.Allocator, host: []const u8, allow_same: bool
855 push.stdout_behavior = .Inherit; 855 push.stdout_behavior = .Inherit;
856 push.stderr_behavior = .Inherit; 856 push.stderr_behavior = .Inherit;
857 try push.spawn(); 857 try push.spawn();
858 // /proc/self/exe rather than a saved path: the bytes streamed are the 858 // The RUNNING image rather than a saved path: the bytes streamed are
859 // image RUNNING here, the only thing "push this binary" can honestly 859 // what is executing here, the only thing "push this binary" can honestly
860 // mean, even if the file it was started from has since been replaced. 860 // mean, even if the file it was started from has since been replaced.
861 // `openSelfExe` is the portable spelling of that ask.
861 const streamed: bool = blk: { 862 const streamed: bool = blk: {
862 var img = std.fs.openFileAbsolute("/proc/self/exe", .{}) catch break :blk false; 863 var img = std.fs.openSelfExe(.{}) catch break :blk false;
863 defer img.close(); 864 defer img.close();
864 var buf: [64 * 1024]u8 = undefined; 865 var buf: [64 * 1024]u8 = undefined;
865 while (true) { 866 while (true) {
@@ -941,7 +942,7 @@ fn parsePreflight(out: []const u8, want_arch: []const u8) Preflight {
941 fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool) !u8 { 942 fn upgradeCmd(alloc: std.mem.Allocator, sock_path: []const u8, allow_same: bool) !u8 {
942 var exe_buf: [std.fs.max_path_bytes]u8 = undefined; 943 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
943 const exe = std.fs.selfExePath(&exe_buf) catch { 944 const exe = std.fs.selfExePath(&exe_buf) catch {
944 std.debug.print("mux d upgrade: cannot find own binary via /proc/self/exe\n", .{}); 945 std.debug.print("mux d upgrade: cannot find own image\n", .{});
945 return 1; 946 return 1;
946 }; 947 };
947 948
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";
src/tui/wallview.zig
Old New
@@ -1878,10 +1878,10 @@ pub fn run(alloc: std.mem.Allocator, host_specs: []const HostSpec, entry: Entry)
1878 .wake = ringKeyboardCtx, 1878 .wake = ringKeyboardCtx,
1879 })) |l| { 1879 })) |l| {
1880 shared.prompts = l; 1880 shared.prompts = l;
1881 // The image that is RUNNING, resolved through /proc, and 1881 // The image that is RUNNING, resolved by `spawn.selfExe`
1882 // 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
1883 // helper found by name is whichever mux the environment 1883 // a helper found by name is whichever mux the environment
1884 // happens to have (`spawn.selfExe`). 1884 // happens to have.
1885 shared.prompt_exe = spawn.selfExe(&ask_exe_buf); 1885 shared.prompt_exe = spawn.selfExe(&ask_exe_buf);
1886 } else |_| {} 1886 } else |_| {}
1887 } 1887 }
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"