be757517
refactor: a platform layer under src/os with one row per side
a73x 2026-09-03 08:32
Commit message
CLAUDE.md
| Old | New | ||
|---|---|---|---|
| @@ -59,7 +59,8 @@ a symbol by its FILE stem (`wall_pump.askOn`) — a file, not a module. | |||
| 59 | | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` | | 59 | | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` | |
| 60 | | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | | 60 | | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | |
| 61 | | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | | 61 | | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | |
| 62 | | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) · `spawn` | | 62 | | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) | |
| 63 | | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` · `client_os`(`client_os.zig`) — `client_os_linux` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) | | ||
| 63 | | `src/` | `xdg` `sockpath` `dial` `link` `serve` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket and `link` the live connection under it whatever reached it (fd, handoff stdio, QUIC), `serve` the right to bind a socket path and the duty to unlink it, `term` and nothing else under them | | 64 | | `src/` | `xdg` `sockpath` `dial` `link` `serve` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket and `link` the live connection under it whatever reached it (fd, handoff stdio, QUIC), `serve` the right to bind a socket path and the duty to unlink it, `term` and nothing else under them | |
| 64 | 65 | ||
| 65 | The grouping itself is a convention now, not a gate: the table wires the | 66 | The grouping itself is a convention now, not a gate: the table wires the |
| @@ -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 | } | ||