a73x

e3f2d1ec

refactor: the one binary's mains are child files of the dispatcher

a73x   2026-08-30 12:35

Commit message
refactor: the one binary's mains are child files of the dispatcher

The hub's HTTP surface stays a row: a module's root directory is its root
file's dirname, so webhub_main.zig cannot reach ../client/webhub.zig.

build.zig
Old New
@@ -192,7 +192,7 @@ const mod_table = [_]ModSpec{
192 // renders: `quic` for the remote arm and `xdg` for the one 192 // renders: `quic` for the remote arm and `xdg` for the one
193 // key-resolution rule all three binaries obey. Deliberately still no 193 // key-resolution rule all three binaries obey. Deliberately still no
194 // engine and no replica — muxa has nothing to draw. 194 // engine and no replica — muxa has nothing to draw.
195 .{ .name = "agent_main", .path = "src/cli/muxa.zig", .layer = 2, .link_libc = true, .imports = &.{ "protocol", "sockpath", "quic", "xdg", "cliflags" }, .quic_tests = true }, 195 .{ .name = "agent", .path = "src/cli/muxa.zig", .layer = 2, .link_libc = true, .imports = &.{ "protocol", "sockpath", "quic", "xdg", "cliflags" }, .quic_tests = true },
196 .{ .name = "wsclient", .path = "test/wsclient.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "replica", "protocol", "script" } }, 196 .{ .name = "wsclient", .path = "test/wsclient.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "replica", "protocol", "script" } },
197 // ---- layer 3 ---- 197 // ---- layer 3 ----
198 // Dialling, and what a chord means. The client is the only thing that 198 // Dialling, and what a chord means. The client is the only thing that
@@ -207,23 +207,16 @@ const mod_table = [_]ModSpec{
207 // those files is a file-in-multiple-modules compile error. `resolveHost` 207 // those files is a file-in-multiple-modules compile error. `resolveHost`
208 // sits here rather than in either front so the CLI wall and the browser 208 // sits here rather than in either front so the CLI wall and the browser
209 // hub resolve a host line the same way. Nothing here WRITES that file — 209 // hub resolve a host line the same way. Nothing here WRITES that file —
210 // `wall_host.recordHost` and `hub_main` do. 210 // `wall_host.recordHost` and `webhub_main` do.
211 .{ .name = "client", .path = "src/client/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "replica", "quic", "xdg", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 211 .{ .name = "client", .path = "src/client/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "replica", "quic", "xdg", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
212 // ---- layer 4 ---- 212 // ---- layer 4 ----
213 // The daemon entrypoint loads the key and constructs the listener, so it 213 // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route table,
214 // needs `quic` directly; the listener type and the upgrade vocabulary it 214 // WS endpoint naming. Assets are injected (the exe root @embedFiles
215 // reaches through the daemon root's re-exports, being that root's own 215 // them), so its tests build no artifacts. It stays a row rather than
216 // child files now. `mux d endpoint` prints the announce line, whose 216 // becoming a child file of the dispatcher that @embedFiles for it: a
217 // vocabulary is `client.handoff` — the daemon writes the line and the 217 // module's root directory is the dirname of its root file, and
218 // client reads it, so one owner or the two spell it differently; naming 218 // `@import("../client/webhub.zig")` from src/cli is "import of file
219 // `client` for it is what puts this entrypoint above the link rather 219 // outside module path" — a compiler rule, not a table choice.
220 // than beside it. sockpath is the sun_path bound, checked before any
221 // verb acts on the path; and the keygen round-trip test needs a
222 // directory to generate into, which the daemon itself never touches.
223 .{ .name = "daemon_main", .path = "src/cli/main.zig", .layer = 4, .link_libc = true, .imports = &.{ "daemon", "protocol", "proxy", "quic", "xdg", "spawn", "client", "sockpath", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
224 // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route
225 // table, WS endpoint naming. Assets are injected (the exe root
226 // @embedFiles them), so its tests build no artifacts.
227 .{ .name = "webhub", .path = "src/client/webhub.zig", .layer = 4, .imports = &.{ "protocol", "client" }, .quic_tests = true }, 220 .{ .name = "webhub", .path = "src/client/webhub.zig", .layer = 4, .imports = &.{ "protocol", "client" }, .quic_tests = true },
228 // The CLI wall (`mux wall`): multiattach stripes in one terminal, one of 221 // The CLI wall (`mux wall`): multiattach stripes in one terminal, one of
229 // which can be ZOOMED — promoted to the terminal's size and typed 222 // which can be ZOOMED — promoted to the terminal's size and typed
@@ -238,23 +231,21 @@ const mod_table = [_]ModSpec{
238 // spells; the decoder and the key table they also want reach them 231 // spells; the decoder and the key table they also want reach them
239 // through `client`'s seams. 232 // through `client`'s seams.
240 .{ .name = "wall", .path = "src/tui/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "proxy", "engine", "replica", "spawn" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 233 .{ .name = "wall", .path = "src/tui/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "proxy", "engine", "replica", "spawn" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
241 // ---- layer 5 ----
242 // hosts owns the host grammar and the state file, so argv is parsed by
243 // the SAME rules the CLI's own `mux hosts add` is — one grammar, not
244 // two. Resolution is `client.resolveHost`, shared with the CLI wall;
245 // sockpath stays for the one startup message that names the sun_path
246 // bound.
247 .{ .name = "hub_main", .path = "src/cli/webhub_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "webhub", "xdg", "sockpath", "cliflags" }, .quic_tests = true },
248 // sockpath is the sun_path bound only; the client binds no socket itself.
249 // protocol is the session-name validator alone (validSessionName): a bad
250 // --session has to be a usage error here, at parse, not bytes some
251 // daemon downstream has to notice and refuse. Layer 5 since the no-arg
252 // wall pulled in wallview (layer 4).
253 .{ .name = "client_main", .path = "src/cli/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "sockpath", "wall", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
254 // ---- layer 6: the one binary ---- 234 // ---- layer 6: the one binary ----
255 // Four words, one image. Nothing but the mode letter lives here, which 235 // Four words, one image — and one row: the daemon's entrypoint, the
256 // is why it may sit above every other main without owning any of them. 236 // client's and the hub's are CHILD FILES of the dispatcher, so a second
257 .{ .name = "mux", .path = "src/cli/mux.zig", .layer = 6, .link_libc = true, .imports = &.{ "daemon_main", "agent_main", "hub_main", "client_main", "client" } }, 237 // module claiming any of them is a file-in-multiple-modules compile
238 // error. `agent` stays a row because muxa's suite must be able to fail on
239 // its own. The grants are the four mains' union: `webhub` for the hub
240 // surface the entrypoint serves, `daemon` for the Server it constructs
241 // and the `quic_server`/`upgrade` seams it reaches through it, `quic` for the key
242 // it loads before that listener exists, `client` for the announce
243 // vocabulary the daemon writes and the client reads — one owner, or the
244 // two spell it differently — plus the wall the no-arg client opens,
245 // `protocol` for validSessionName (a bad --session is a usage error at
246 // parse, not bytes a daemon downstream must refuse) and `sockpath` for
247 // the sun_path bound every verb checks before acting on a path.
248 .{ .name = "mux", .path = "src/cli/mux.zig", .layer = 6, .link_libc = true, .imports = &.{ "daemon", "client", "wall", "agent", "webhub", "protocol", "proxy", "quic", "xdg", "spawn", "sockpath", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
258 }; 249 };
259 250
260 /// Comptime row lookup. Every hand-written module name in this file goes 251 /// Comptime row lookup. Every hand-written module name in this file goes
@@ -792,16 +783,15 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui
792 /// can wedge a daemon test that waits on a client forever — and a wedged 783 /// can wedge a daemon test that waits on a client forever — and a wedged
793 /// step prints nothing at all. Failing first is what makes the catch 784 /// step prints nothing at all. Failing first is what makes the catch
794 /// legible. script leads for the same reason: instant, allocation-only, 785 /// legible. script leads for the same reason: instant, allocation-only,
795 /// and both fixtures inherit its escape pins. The mains are roots of 786 /// and both fixtures inherit its escape pins. `mux` runs last and carries
796 /// nothing but the one binary, and they carry the argument parsers — a 787 /// every argument parser but muxa's, its mains being child files — a test
797 /// test that is never built is not a test (decisions.md). 788 /// that is never built is not a test (decisions.md).
798 const test_order = [_][]const u8{ 789 const test_order = [_][]const u8{
799 "script", "protocol", "engine", "pty", "delta", 790 "script", "protocol", "engine", "pty", "delta",
800 "replica", "webhub", "wall", "sockpath", "agent_main", 791 "replica", "webhub", "wall", "sockpath", "agent",
801 "daemon", "client", "proxy", "client_main", "quic", 792 "daemon", "client", "proxy", "quic", "testtmp",
802 "daemon_main", "testtmp", "rawmode", "delaypipe", "xdg", 793 "rawmode", "delaypipe", "xdg", "spawn", "render",
803 "spawn", "render", "ptyclient", "hub_main", "wsclient", 794 "ptyclient", "wsclient", "cliflags", "mux",
804 "cliflags", "mux",
805 }; 795 };
806 796
807 comptime { 797 comptime {
@@ -899,34 +889,28 @@ pub fn build(b: *std.Build) void {
899 // jurisdiction, explicit. 889 // jurisdiction, explicit.
900 const engine_mod = mods[comptime idxOf("engine")]; 890 const engine_mod = mods[comptime idxOf("engine")];
901 const mux_mod = mods[comptime idxOf("mux")]; 891 const mux_mod = mods[comptime idxOf("mux")];
902 const client_main_mod = mods[comptime idxOf("client_main")]; 892 const agent_mod = mods[comptime idxOf("agent")];
903 const daemon_main_mod = mods[comptime idxOf("daemon_main")];
904 const agent_main_mod = mods[comptime idxOf("agent_main")];
905 const rawmode_mod = mods[comptime idxOf("rawmode")]; 893 const rawmode_mod = mods[comptime idxOf("rawmode")];
906 const delaypipe_mod = mods[comptime idxOf("delaypipe")]; 894 const delaypipe_mod = mods[comptime idxOf("delaypipe")];
907 const render_mod = mods[comptime idxOf("render")]; 895 const render_mod = mods[comptime idxOf("render")];
908 const ptyclient_mod = mods[comptime idxOf("ptyclient")]; 896 const ptyclient_mod = mods[comptime idxOf("ptyclient")];
909 const wsclient_mod = mods[comptime idxOf("wsclient")]; 897 const wsclient_mod = mods[comptime idxOf("wsclient")];
910 const hub_main_mod = mods[comptime idxOf("hub_main")];
911 898
912 if (ghostty_dep) |dep| { 899 if (ghostty_dep) |dep| {
913 engine_mod.addImport("ghostty-vt", dep.module("ghostty-vt")); 900 engine_mod.addImport("ghostty-vt", dep.module("ghostty-vt"));
914 } 901 }
915 // ONE module object, shared: the four mains link into one binary now, and 902 // ONE module object, shared: the two rows link into one binary, and a
916 // a second instance of the same root file in one compilation is "file 903 // second instance of the same root file in one compilation is "file
917 // exists in modules 'build_options' and 'build_options0'". 904 // exists in modules 'build_options' and 'build_options0'".
918 const build_opts_mod = version_opts.createModule(); 905 const build_opts_mod = version_opts.createModule();
919 client_main_mod.addImport("build_options", build_opts_mod); 906 mux_mod.addImport("build_options", build_opts_mod);
920 daemon_main_mod.addImport("build_options", build_opts_mod); 907 agent_mod.addImport("build_options", build_opts_mod);
921 hub_main_mod.addImport("build_options", build_opts_mod);
922 agent_main_mod.addImport("build_options", build_opts_mod);
923 // A row with test_imports gets a SEPARATE module for its test twin, and 908 // A row with test_imports gets a SEPARATE module for its test twin, and
924 // build_options is outside the table's jurisdiction — so every such twin 909 // build_options is outside the table's jurisdiction — so every such twin
925 // needs it by hand, or the argument parsers lose the version they print. 910 // needs it by hand, or the argument parsers lose the version they print.
926 test_mods[comptime idxOf("daemon_main")].addImport("build_options", build_opts_mod); 911 test_mods[comptime idxOf("mux")].addImport("build_options", build_opts_mod);
927 test_mods[comptime idxOf("client_main")].addImport("build_options", build_opts_mod);
928 912
929 // ONE product binary. The four mains are modules under it, reached by 913 // ONE product binary. The four mains are files under it, reached by
930 // the mode word rather than by four names on PATH — which is also what 914 // the mode word rather than by four names on PATH — which is also what
931 // lets the client's auto-start exec this same image with argv 915 // lets the client's auto-start exec this same image with argv
932 // `mux d run …` (spawn.zig) instead of hunting PATH for a sibling. The 916 // `mux d run …` (spawn.zig) instead of hunting PATH for a sibling. The
@@ -1029,9 +1013,14 @@ pub fn build(b: *std.Build) void {
1029 // The page's three assets arrive as anonymous imports so @embedFile 1013 // The page's three assets arrive as anonymous imports so @embedFile
1030 // can name them; the wasm one is the artifact itself, which also 1014 // can name them; the wasm one is the artifact itself, which also
1031 // sequences the wasm build before the hub's. 1015 // sequences the wasm build before the hub's.
1032 hub_main_mod.addAnonymousImport("index.html", .{ .root_source_file = b.path("web/index.html") }); 1016 // webhub_main.zig is a child file of the dispatcher, so the assets attach
1033 hub_main_mod.addAnonymousImport("mux.js", .{ .root_source_file = b.path("web/mux.js") }); 1017 // to that module and to its test twin both — an @embedFile the twin
1034 hub_main_mod.addAnonymousImport("mux_core.wasm", .{ .root_source_file = wasm_exe.getEmittedBin() }); 1018 // cannot name is a test binary that will not compile.
1019 for ([_]*std.Build.Module{ mux_mod, test_mods[comptime idxOf("mux")] }) |m| {
1020 m.addAnonymousImport("index.html", .{ .root_source_file = b.path("web/index.html") });
1021 m.addAnonymousImport("mux.js", .{ .root_source_file = b.path("web/mux.js") });
1022 m.addAnonymousImport("mux_core.wasm", .{ .root_source_file = wasm_exe.getEmittedBin() });
1023 }
1035 1024
1036 const test_step = b.step("test", "Run unit tests"); 1025 const test_step = b.step("test", "Run unit tests");
1037 test_step.dependOn(&client_core_wasm_guard.step); 1026 test_step.dependOn(&client_core_wasm_guard.step);
src/cli/mux.zig
Old New
@@ -10,10 +10,10 @@
10 //! exec'd with the prompt as argv[1] and nothing else. Its variable is the 10 //! exec'd with the prompt as argv[1] and nothing else. Its variable is the
11 //! word instead, and it is set in exactly one process tree. 11 //! word instead, and it is set in exactly one process tree.
12 const std = @import("std"); 12 const std = @import("std");
13 const daemon = @import("daemon_main"); 13 const daemon = @import("main.zig");
14 const agent = @import("agent_main"); 14 const agent = @import("agent");
15 const hub = @import("hub_main"); 15 const hub = @import("webhub_main.zig");
16 const client = @import("client_main"); 16 const client = @import("mux_main.zig");
17 const askpass = @import("client").askpass; 17 const askpass = @import("client").askpass;
18 18
19 /// The whole grammar, as a value, so the one decision this file makes can be 19 /// The whole grammar, as a value, so the one decision this file makes can be
@@ -104,4 +104,11 @@ test "modeOf: `run` is a host, and no daemon verb has a top-level alias" {
104 104
105 test { 105 test {
106 std.testing.refAllDeclsRecursive(@This()); 106 std.testing.refAllDeclsRecursive(@This());
107 // Three of the four mains are child FILES of this root, so their suites
108 // reach the runner through these lines and nothing else; drop one and
109 // that main's argument parser goes untested against a green tree.
110 // `agent` and `webhub` are rows of their own and run as ones.
111 _ = @import("main.zig");
112 _ = @import("mux_main.zig");
113 _ = @import("webhub_main.zig");
107 } 114 }
src/client/webhub.zig
Old New
@@ -125,7 +125,7 @@ pub const Hub = struct {
125 /// by `deinit` alone, and both run on the thread that built the Hub. 125 /// by `deinit` alone, and both run on the thread that built the Hub.
126 polling: bool = false, 126 polling: bool = false,
127 127
128 /// `specs` is borrowed: hub_main resolves the hosts file into an arena 128 /// `specs` is borrowed: webhub_main resolves the hosts file into an arena
129 /// that outlives the process's accept loop. 129 /// that outlives the process's accept loop.
130 pub fn init(alloc: std.mem.Allocator, specs: []const client.HostSpec) !Hub { 130 pub fn init(alloc: std.mem.Allocator, specs: []const client.HostSpec) !Hub {
131 const rows = try alloc.alloc(HubHost, specs.len); 131 const rows = try alloc.alloc(HubHost, specs.len);
@@ -1266,7 +1266,7 @@ test "hub: two pumps on one tile — the first fd stays tracked, the second's re
1266 1266
1267 test "hub: a HOST tile is never the ask" { 1267 test "hub: a HOST tile is never the ask" {
1268 const alloc = std.testing.allocator; 1268 const alloc = std.testing.allocator;
1269 // Resolved the way hub_main resolves the file, because that is the one 1269 // Resolved the way webhub_main resolves the file, because that is the one
1270 // road onto this wall and the permission is set on it. 1270 // road onto this wall and the permission is set on it.
1271 var arena = std.heap.ArenaAllocator.init(alloc); 1271 var arena = std.heap.ArenaAllocator.init(alloc);
1272 defer arena.deinit(); 1272 defer arena.deinit();