a73x

be757517

refactor: a platform layer under src/os with one row per side

a73x   2026-09-03 08:32

Commit message
refactor: a platform layer under src/os with one row per side

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

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
@@ -81,8 +82,9 @@ by number. Rule 4's three remaining debts are the markers in `engine.zig`,
81 `protocol.zig` and `keymap.zig`, each of which produces VT bytes by contract. 82 `protocol.zig` and `keymap.zig`, each of which produces VT bytes by contract.
82 `predict.zig` sits under `src/tui/` with the rest of the wall, so the overlay 83 `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. 84 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 85 `spawn` lives under `src/os/` with the rest of the platform layer: asking
85 terminal, which rule 4 forbids a client module. 86 the OS for a terminal is platform code, and rule 4 forbids a client module
87 from doing it.
86 88
87 ONE binary, `mux`, and the first word picks a mode: `mux d` the daemon, 89 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 90 `mux a` the agent surface (JSON verbs), `mux web` the browser hub, and no
build.zig
Old New
@@ -122,6 +122,14 @@ const mod_table = [_]ModSpec{
122 // driven by a test holding an engine and no socket; and the whole 122 // driven by a test holding an engine and no socket; and the whole
123 // component is platform-free, so `mux_core.wasm` compiles it. 123 // component is platform-free, so `mux_core.wasm` compiles it.
124 .{ .name = "term", .path = "src/engine/term.zig", .wasm = true }, 124 .{ .name = "term", .path = "src/engine/term.zig", .wasm = true },
125 // The platform layer, one row per side (docs/superpowers/specs/
126 // 2026-09-03-macos-port-design.md). Leaves: they import nothing of ours,
127 // and the raw OS spellings are meant to end up here rather than in the
128 // rows that call them, so a second arm is a folder and not a grep.
129 // Two rows rather than one because the client never links a fork or a
130 // pty, and an app that links the engine and a client must not either.
131 .{ .name = "server_os", .path = "src/os/server_os.zig", .link_libc = true },
132 .{ .name = "client_os", .path = "src/os/client_os.zig", .link_libc = true },
125 .{ .name = "pty", .path = "src/server/pty.zig", .link_libc = true }, 133 .{ .name = "pty", .path = "src/server/pty.zig", .link_libc = true },
126 // The QUIC vocabulary both ends share: the one @cImport of the vendored 134 // 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 135 // stack, the key, the wire constants, the egress ring. It has to be ONE
@@ -162,10 +170,11 @@ const mod_table = [_]ModSpec{
162 // Reflection over a caller's options struct, so it imports nothing: the 170 // 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. 171 // struct is the flag table and the parser learns it at comptime.
164 .{ .name = "cliflags", .path = "src/cli/flags.zig" }, 172 .{ .name = "cliflags", .path = "src/cli/flags.zig" },
165 // This image, as a path something can exec. Under src/cli/ because it 173 // 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 174 // of the platform layer because it asks the OS about the process it is
167 // client may spell. 175 // in — a question no headless client may spell, and one whose answer is
168 .{ .name = "spawn", .path = "src/cli/spawn.zig", .link_libc = true }, 176 // spelled differently on every OS.
177 .{ .name = "spawn", .path = "src/os/spawn.zig", .link_libc = true },
169 // ---- single-hop over the leaves ---- 178 // ---- single-hop over the leaves ----
170 // The client side of a daemon's socket: dial it, and say hello. `term` 179 // 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 180 // is the attach encoders, `link` is the round trip's wait — an embedder
@@ -308,7 +317,7 @@ comptime {
308 /// The folders the doc gate walks. Listed rather than globbed: a new folder 317 /// 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 318 /// 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. 319 /// 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" }; 320 const src_dirs = [_][]const u8{ "src", "src/engine", "src/server", "src/client", "src/tui", "src/cli", "src/os" };
312 321
313 /// The source bans, read off the PRODUCTION lines of the files under `src/`. 322 /// 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 323 /// They catch what the import graph cannot: a module needs no import to
@@ -366,7 +375,7 @@ const source_bans = [_]SourceBan{
366 // spelled by a leaf utility runs exactly as well as one spelled by 375 // 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 376 // the daemon, and a rule with a hole in it is a rule that reports
368 // green about the place nobody looked. 377 // green about the place nobody looked.
369 .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli" }, 378 .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli", "src/os" },
370 .needles = &.{ "\"/bin/sh\"", "\"-c\"" }, 379 .needles = &.{ "\"/bin/sh\"", "\"-c\"" },
371 .why = "the only program mux runs is one the user named — the " ++ 380 .why = "the only program mux runs is one the user named — the " ++
372 "session shell, `ssh` from the handoff recipe, or `--via`'s own " ++ 381 "session shell, `ssh` from the handoff recipe, or `--via`'s own " ++
@@ -375,7 +384,7 @@ const source_bans = [_]SourceBan{
375 }, 384 },
376 .{ 385 .{
377 .rule = "6", 386 .rule = "6",
378 .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli" }, 387 .folders = &.{ "src", "src/engine", "src/client", "src/tui", "src/server", "src/cli", "src/os" },
379 .needles = &.{"posix.fork("}, 388 .needles = &.{"posix.fork("},
380 .except = "src/cli/main.zig", 389 .except = "src/cli/main.zig",
381 .why = "the daemon starts itself \u{2014} `mux d start -d` forks, and " ++ 390 .why = "the daemon starts itself \u{2014} `mux d start -d` forks, and " ++
@@ -726,10 +735,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 735 /// 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). 736 /// child files — a test that is never built is not a test (decisions.md).
728 const test_order = [_][]const u8{ 737 const test_order = [_][]const u8{
729 "script", "cliflags", "testtmp", "spawn", "dial", "link", 738 "script", "cliflags", "testtmp", "server_os", "client_os", "spawn",
730 "quic", "webhub", "agent", "term", "rawmode", "delaypipe", 739 "dial", "link", "quic", "webhub", "agent", "term",
731 "render", "wsclient", "ptyclient", "pty", "sockpath", "serve", 740 "rawmode", "delaypipe", "render", "wsclient", "ptyclient", "pty",
732 "xdg", "proxy", "wall", "client", "daemon", "mux", 741 "sockpath", "serve", "xdg", "proxy", "wall", "client",
742 "daemon", "mux",
733 }; 743 };
734 744
735 comptime { 745 comptime {
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/os/client_os.zig
Old New
@@ -0,0 +1,25 @@
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 test "client_os: the arm compiles and answers for the process it is in" {
20 try std.testing.expect(getpid() > 0);
21 }
22
23 test {
24 std.testing.refAllDeclsRecursive(@This());
25 }
src/os/client_os_linux.zig
Old New
@@ -0,0 +1,6 @@
1 //! Linux arm of `client_os`. Spellings only; the contract is in the root.
2 const std = @import("std");
3
4 pub fn getpid() std.posix.pid_t {
5 return std.os.linux.getpid();
6 }
src/os/server_os.zig
Old New
@@ -0,0 +1,32 @@
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 test "server_os: the arm compiles and answers for the process it is in" {
25 try std.testing.expect(getpid() > 0);
26 }
27
28 // Forces semantic analysis of every pub decl under `zig build test`, so an
29 // unreferenced operation must at least compile for this OS.
30 test {
31 std.testing.refAllDeclsRecursive(@This());
32 }
src/os/server_os_linux.zig
Old New
@@ -0,0 +1,6 @@
1 //! Linux arm of `server_os`. Spellings only; the contract is in the root.
2 const std = @import("std");
3
4 pub fn getpid() std.posix.pid_t {
5 return std.os.linux.getpid();
6 }
src/os/spawn.zig
Old New
@@ -0,0 +1,56 @@
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 }