a73x

6e57f5f4

refactor: the host grammar and the one atomic writer live in hosts.zig

a73x   2026-08-29 12:14

Commit message
refactor: the host grammar and the one atomic writer live in hosts.zig

wall.zig owned a grammar hosts.zig already borrowed. The survivors —
Spec, hasBadSpelling, flagLike, spellingFromArgv, the line IO and the
atomic writer — move to their borrower, which drops to layer 1 and
imports xdg alone. wall.zig keeps only the #SESSION half the browser hub
has not yet stopped reading, and sits above it.

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

build.zig
Old New
@@ -186,11 +186,12 @@ const mod_table = [_]ModSpec{
186 // Engine plus protocol and nothing else, same shape as delta — pure, 186 // Engine plus protocol and nothing else, same shape as delta — pure,
187 // socket-free, and its own tests drive it with no daemon in sight. 187 // socket-free, and its own tests drive it with no daemon in sight.
188 .{ .name = "cmd", .path = "src/server/cmd.zig", .layer = 1, .imports = &.{ "engine", "protocol" } }, 188 .{ .name = "cmd", .path = "src/server/cmd.zig", .layer = 1, .imports = &.{ "engine", "protocol" } },
189 // The wall: the TARGET spelling grammar, the ordered list, and the 189 // The wall: the host spelling grammar, the daemon list, and the state
190 // state file behind them. One owner for a grammar the hub, the state 190 // file behind them, plus the one atomic writer every state file uses.
191 // file and later the CLI all have to agree on — protocol plus xdg for 191 // One owner for a grammar the hub, the file and the CLI all have to
192 // where that file lives, so its tests need no hub, no daemon and no socket. 192 // agree on — xdg for where that file lives, so its tests need no hub,
193 .{ .name = "wall", .path = "src/client/wall.zig", .layer = 1, .imports = &.{ "protocol", "xdg" }, .test_imports = &.{"testtmp"} }, 193 // no daemon and no socket.
194 .{ .name = "hosts", .path = "src/client/hosts.zig", .layer = 1, .imports = &.{"xdg"}, .test_imports = &.{"testtmp"} },
194 // The upgrade vocabulary: the version skew rule and the manifest an 195 // The upgrade vocabulary: the version skew rule and the manifest an
195 // exec-ing daemon leaves for its replacement. Protocol only — the 196 // exec-ing daemon leaves for its replacement. Protocol only — the
196 // manifest is a stranger's format (length-prefixed sections, unknown 197 // manifest is a stranger's format (length-prefixed sections, unknown
@@ -233,11 +234,10 @@ const mod_table = [_]ModSpec{
233 // under it. 234 // under it.
234 .{ .name = "ptyclient", .path = "test/ptyclient.zig", .layer = 1, .link_libc = true, .imports = &.{ "pty", "script" } }, 235 .{ .name = "ptyclient", .path = "test/ptyclient.zig", .layer = 1, .link_libc = true, .imports = &.{ "pty", "script" } },
235 // ---- layer 2 ---- 236 // ---- layer 2 ----
236 // The host file: the wall as a list of DAEMONS. It borrows wall's argv 237 // The browser hub's private wall file, being retired: the `#SESSION`
237 // grammar rather than forking a second one, and wall is layer 1 — so 238 // spelling half the hub has not yet stopped reading. It borrows the
238 // this sits at 2, above its lender and below every consumer, instead 239 // host grammar rather than forking a second one, so it sits above it.
239 // of flattening the stratum it depends on. 240 .{ .name = "wall", .path = "src/client/wall.zig", .layer = 2, .imports = &.{ "protocol", "hosts", "xdg" }, .test_imports = &.{"testtmp"} },
240 .{ .name = "hosts", .path = "src/client/hosts.zig", .layer = 2, .imports = &.{ "wall", "xdg" }, .test_imports = &.{"testtmp"} },
241 // Everything that happens between a user at a terminal and one already 241 // Everything that happens between a user at a terminal and one already
242 // open session: the chord table, the wheel splitter, prediction, the 242 // open session: the chord table, the wheel splitter, prediction, the
243 // side channels, terminal ownership. It sits BELOW client because it 243 // side channels, terminal ownership. It sits BELOW client because it
@@ -268,7 +268,7 @@ const mod_table = [_]ModSpec{
268 // attach records its own tile (the wall is attach history), and the 268 // attach records its own tile (the wall is attach history), and the
269 // chord switches that re-dial from inside client.attach have to record 269 // chord switches that re-dial from inside client.attach have to record
270 // theirs too, so the writer cannot live up in mux_main. 270 // theirs too, so the writer cannot live up in mux_main.
271 .{ .name = "client", .path = "src/client/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "replica", "keymap", "quic", "handoff", "wall", "xdg", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 271 .{ .name = "client", .path = "src/client/client.zig", .layer = 3, .link_libc = true, .imports = &.{ "protocol", "replica", "keymap", "quic", "handoff", "hosts", "xdg", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
272 // The daemon entrypoint loads the key and constructs the listener, so 272 // The daemon entrypoint loads the key and constructs the listener, so
273 // it needs quic/quic_server directly rather than through the server. 273 // it needs quic/quic_server directly rather than through the server.
274 // `muxd endpoint` prints the announce line handoff spells; sockpath is 274 // `muxd endpoint` prints the announce line handoff spells; sockpath is
@@ -290,7 +290,7 @@ const mod_table = [_]ModSpec{
290 // typed-at session; the overlay machinery itself is interact's, shared — 290 // typed-at session; the overlay machinery itself is interact's, shared —
291 // and phase 3 promotes the tile into that core rather than growing a 291 // and phase 3 promotes the tile into that core rather than growing a
292 // second copy of it. 292 // second copy of it.
293 .{ .name = "wallview", .path = "src/tui/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "wall", "hosts", "handoff", "proxy", "engine", "paint", "select", "layout" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 293 .{ .name = "wallview", .path = "src/tui/wallview.zig", .layer = 4, .link_libc = true, .imports = &.{ "protocol", "client", "interact", "hosts", "handoff", "proxy", "engine", "paint", "select", "layout" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
294 // ---- layer 5 ---- 294 // ---- layer 5 ----
295 // wall owns the spelling grammar and the state file, so argv is parsed 295 // wall owns the spelling grammar and the state file, so argv is parsed
296 // by the SAME rules the page's POST /tiles and the restored file are — 296 // by the SAME rules the page's POST /tiles and the restored file are —
@@ -304,7 +304,7 @@ const mod_table = [_]ModSpec{
304 // daemon downstream has to notice and refuse. Layer 5 since `mux wall` 304 // daemon downstream has to notice and refuse. Layer 5 since `mux wall`
305 // pulled in wallview (layer 4); wall rides along for the no-arg wall 305 // pulled in wallview (layer 4); wall rides along for the no-arg wall
306 // (the state file the browser hub builds). 306 // (the state file the browser hub builds).
307 .{ .name = "client_main", .path = "src/cli/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "wall", "hosts", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 307 .{ .name = "client_main", .path = "src/cli/mux_main.zig", .layer = 5, .link_libc = true, .imports = &.{ "client", "protocol", "xdg", "spawn", "handoff", "sockpath", "wallview", "hosts", "cliflags" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
308 // ---- layer 6: the one binary ---- 308 // ---- layer 6: the one binary ----
309 // Four words, one image. Nothing but the mode letter lives here, which 309 // Four words, one image. Nothing but the mode letter lives here, which
310 // is why it may sit above every other main without owning any of them. 310 // is why it may sit above every other main without owning any of them.
docscheck.budget
Old New
@@ -14,6 +14,7 @@ layout.zig 761
14 main.zig 0 14 main.zig 0
15 muxa.zig 0 15 muxa.zig 0
16 mux_main.zig 0 16 mux_main.zig 0
17 mux.zig 0
17 paint.zig 0 18 paint.zig 0
18 predict.zig 0 19 predict.zig 0
19 protocol.zig 0 20 protocol.zig 0
@@ -24,38 +25,37 @@ quic_server.zig 0
24 quic.zig 0 25 quic.zig 0
25 replica.zig 0 26 replica.zig 0
26 select.zig 0 27 select.zig 0
28 server_agent.zig 0
29 server_sessions.zig 0
30 server_test_agent.zig 0
31 server_test_attach.zig 0
32 server_test_await.zig 0
33 server_test_clipboard.zig 0
34 server_test_deliver.zig 0
35 server_test_harness.zig 0
36 server_test_modes.zig 0
37 server_test_quic.zig 0
38 server_test_session.zig 0
39 server_test_upgrade.zig 0
27 server.zig 0 40 server.zig 0
28 shellint.zig 0 41 shellint.zig 0
29 sockpath.zig 0 42 sockpath.zig 0
30 spawn.zig 0 43 spawn.zig 0
31 testtmp.zig 0 44 testtmp.zig 0
32 upgrade.zig 269 45 upgrade.zig 269
33 wallview.zig 0
34 wall.zig 0
35 wasm_core.zig 0
36 webhub_main.zig 0
37 webhub.zig 0
38 xdg.zig 0
39 server_test_harness.zig 0
40 server_test_attach.zig 0
41 server_test_deliver.zig 0
42 server_test_modes.zig 0
43 server_test_session.zig 0
44 server_test_quic.zig 0
45 server_test_await.zig 0
46 server_test_clipboard.zig 0
47 server_test_agent.zig 0
48 server_test_upgrade.zig 0
49 server_agent.zig 0
50 server_sessions.zig 0
51 wall_host.zig 0 46 wall_host.zig 0
47 wall_layout.zig 1432
52 wall_picker.zig 0 48 wall_picker.zig 0
53 wall_pump.zig 0 49 wall_pump.zig 0
54 wall_layout.zig 1432
55 wall_test_harness.zig 0 50 wall_test_harness.zig 0
56 wall_test_host.zig 0 51 wall_test_host.zig 0
52 wall_test_layout.zig 0
57 wall_test_picker.zig 0 53 wall_test_picker.zig 0
58 wall_test_pump.zig 0 54 wall_test_pump.zig 0
59 wall_test_layout.zig 0
60 wall_test_wall.zig 0 55 wall_test_wall.zig 0
61 mux.zig 0 56 wallview.zig 0
57 wall.zig 0
58 wasm_core.zig 0
59 webhub_main.zig 0
60 webhub.zig 0
61 xdg.zig 0
src/cli/mux_main.zig
Old New
@@ -23,7 +23,6 @@ const handoff = @import("handoff");
23 const sockpath = @import("sockpath"); 23 const sockpath = @import("sockpath");
24 const wallview = @import("wallview"); 24 const wallview = @import("wallview");
25 const hosts = @import("hosts"); 25 const hosts = @import("hosts");
26 const wall = @import("wall");
27 const cliflags = @import("cliflags"); 26 const cliflags = @import("cliflags");
28 const TmpDir = @import("testtmp").TmpDir; 27 const TmpDir = @import("testtmp").TmpDir;
29 28
@@ -518,7 +517,7 @@ fn hostsList(arena: std.mem.Allocator, path: []const u8, out_fd: std.posix.fd_t)
518 // to SEE a line they have to fix, and a strict read would refuse to 517 // to SEE a line they have to fix, and a strict read would refuse to
519 // show it to them. Nothing here writes the file, so there is no 518 // show it to them. Nothing here writes the file, so there is no
520 // half-understood content to protect. 519 // half-understood content to protect.
521 const lines = wall.loadLines(arena, path) catch |err| return refuseFile(arena, "mux hosts", path, err); 520 const lines = hosts.loadLines(arena, path) catch |err| return refuseFile(arena, "mux hosts", path, err);
522 const key = std.posix.getenv(xdg.key_env); 521 const key = std.posix.getenv(xdg.key_env);
523 for (lines.items) |line| { 522 for (lines.items) |line| {
524 const spec = wallview.resolveHost(arena, line, key, client.quic_idle_ms_default) catch |err| { 523 const spec = wallview.resolveHost(arena, line, key, client.quic_idle_ms_default) catch |err| {
@@ -541,7 +540,7 @@ fn hostsList(arena: std.mem.Allocator, path: []const u8, out_fd: std.posix.fd_t)
541 /// One listing row: the file's line verbatim, then a formatted verdict. 540 /// One listing row: the file's line verbatim, then a formatted verdict.
542 fn printRow(fd: std.posix.fd_t, line: []const u8, comptime fmt: []const u8, args: anytype) void { 541 fn printRow(fd: std.posix.fd_t, line: []const u8, comptime fmt: []const u8, args: anytype) void {
543 // The line is written straight through rather than formatted into a 542 // The line is written straight through rather than formatted into a
544 // buffer with the verdict. `wall.loadLines` accepts lines up to a MiB 543 // buffer with the verdict. `hosts.loadLines` accepts lines up to a MiB
545 // and `mux hosts rm` matches byte for byte, so a row that overflowed a 544 // and `mux hosts rm` matches byte for byte, so a row that overflowed a
546 // fixed buffer used to vanish from the one command whose job is showing 545 // fixed buffer used to vanish from the one command whose job is showing
547 // the user a line they then have to type back. 546 // the user a line they then have to type back.
@@ -569,7 +568,7 @@ fn refuseFile(arena: std.mem.Allocator, who: []const u8, path: []const u8, err:
569 // file: an `OutOfMemory` on a file that also holds one stale line used 568 // file: an `OutOfMemory` on a file that also holds one stale line used
570 // to print "OutOfMemory" and then exit 2 with a list of lines to fix. 569 // to print "OutOfMemory" and then exit 2 with a list of lines to fix.
571 if (!hosts.isParse(err)) return 1; 570 if (!hosts.isParse(err)) return 1;
572 const lines = wall.loadLines(arena, path) catch return 2; 571 const lines = hosts.loadLines(arena, path) catch return 2;
573 for (lines.items) |l| { 572 for (lines.items) |l| {
574 _ = hosts.parse(l) catch std.debug.print(" {s}\n", .{l}); 573 _ = hosts.parse(l) catch std.debug.print(" {s}\n", .{l});
575 } 574 }
@@ -630,7 +629,7 @@ fn hostsEdit(
630 // way too. 629 // way too.
631 var i: usize = 0; 630 var i: usize = 0;
632 while (i < args.len) : (i += 1) { 631 while (i < args.len) : (i += 1) {
633 const n = wall.spellingFromArgv(arena, args, i) catch |err| switch (err) { 632 const n = hosts.spellingFromArgv(arena, args, i) catch |err| switch (err) {
634 error.MissingSockPath => { 633 error.MissingSockPath => {
635 std.debug.print("mux hosts rm: '--sock' names no path\n", .{}); 634 std.debug.print("mux hosts rm: '--sock' names no path\n", .{});
636 return 2; 635 return 2;
@@ -680,7 +679,7 @@ fn hostsEdit(
680 /// The file verbatim, when the next thing the user must do is name one of 679 /// The file verbatim, when the next thing the user must do is name one of
681 /// its lines back at the program. 680 /// its lines back at the program.
682 fn showFile(arena: std.mem.Allocator, path: []const u8) void { 681 fn showFile(arena: std.mem.Allocator, path: []const u8) void {
683 const lines = wall.loadLines(arena, path) catch return; 682 const lines = hosts.loadLines(arena, path) catch return;
684 for (lines.items) |l| std.debug.print(" {s}\n", .{l}); 683 for (lines.items) |l| std.debug.print(" {s}\n", .{l});
685 } 684 }
686 685
@@ -835,7 +834,7 @@ test "hosts: a line the file cannot hold exits 2 wherever the file is read" {
835 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()}); 834 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
836 // Two hosts, one of them hand-edited into a spelling the grammar 835 // Two hosts, one of them hand-edited into a spelling the grammar
837 // refuses — the state a user is actually in when they reach for `rm`. 836 // refuses — the state a user is actually in when they reach for `rm`.
838 try wall.saveBytes(path, "--sock /tmp/x.sock\nbox#old\n"); 837 try hosts.saveBytes(path, "--sock /tmp/x.sock\nbox#old\n");
839 838
840 // The write path and the wall agree: 2, the user's file to fix. `rm` 839 // The write path and the wall agree: 2, the user's file to fix. `rm`
841 // reads verbatim and repairs it, and only then does `add` grow it. 840 // reads verbatim and repairs it, and only then does `add` grow it.
@@ -853,7 +852,7 @@ test "hosts list: a line the grammar refuses is named, not a refusal of the list
853 var buf2: [512]u8 = undefined; 852 var buf2: [512]u8 = undefined;
854 const dead = try std.fmt.bufPrint(&buf2, "{s}/absent.sock", .{tmp.path()}); 853 const dead = try std.fmt.bufPrint(&buf2, "{s}/absent.sock", .{tmp.path()});
855 var line_buf: [1024]u8 = undefined; 854 var line_buf: [1024]u8 = undefined;
856 try wall.saveBytes(path, try std.fmt.bufPrint(&line_buf, "box#old\n--sock {s}\n", .{dead})); 855 try hosts.saveBytes(path, try std.fmt.bufPrint(&line_buf, "box#old\n--sock {s}\n", .{dead}));
857 856
858 var arena_state = std.heap.ArenaAllocator.init(alloc); 857 var arena_state = std.heap.ArenaAllocator.init(alloc);
859 defer arena_state.deinit(); 858 defer arena_state.deinit();
@@ -883,7 +882,7 @@ test "refuseFile: the exit code names whose fault it was, and a bad line in the
883 defer tmp.cleanup(); 882 defer tmp.cleanup();
884 var buf: [512]u8 = undefined; 883 var buf: [512]u8 = undefined;
885 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()}); 884 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
886 try wall.saveBytes(path, "box#old\nbox\n"); 885 try hosts.saveBytes(path, "box#old\nbox\n");
887 886
888 var arena_state = std.heap.ArenaAllocator.init(alloc); 887 var arena_state = std.heap.ArenaAllocator.init(alloc);
889 defer arena_state.deinit(); 888 defer arena_state.deinit();
@@ -894,7 +893,7 @@ test "refuseFile: the exit code names whose fault it was, and a bad line in the
894 } 893 }
895 894
896 test "hosts list: a line longer than the row buffer is still shown, because rm matches what was shown" { 895 test "hosts list: a line longer than the row buffer is still shown, because rm matches what was shown" {
897 // `wall.loadLines` takes lines up to a MiB and `mux hosts rm` matches 896 // `hosts.loadLines` takes lines up to a MiB and `mux hosts rm` matches
898 // byte for byte, so a row this command drops is a line the user can 897 // byte for byte, so a row this command drops is a line the user can
899 // neither see nor type back — the two halves of the repair loop have to 898 // neither see nor type back — the two halves of the repair loop have to
900 // agree about which lines exist. 899 // agree about which lines exist.
@@ -911,7 +910,7 @@ test "hosts list: a line longer than the row buffer is still shown, because rm m
911 @memcpy(long[0..8], "--sock /"); 910 @memcpy(long[0..8], "--sock /");
912 @memset(long[8..], 'h'); 911 @memset(long[8..], 'h');
913 var line_buf: [1024]u8 = undefined; 912 var line_buf: [1024]u8 = undefined;
914 try wall.saveBytes(path, try std.fmt.bufPrint(&line_buf, "{s}\n", .{long})); 913 try hosts.saveBytes(path, try std.fmt.bufPrint(&line_buf, "{s}\n", .{long}));
915 914
916 var arena_state = std.heap.ArenaAllocator.init(alloc); 915 var arena_state = std.heap.ArenaAllocator.init(alloc);
917 defer arena_state.deinit(); 916 defer arena_state.deinit();
src/client/client.zig
Old New
@@ -22,7 +22,7 @@ const proto = @import("protocol");
22 const TmpDir = @import("testtmp").TmpDir; 22 const TmpDir = @import("testtmp").TmpDir;
23 const quic = @import("quic"); 23 const quic = @import("quic");
24 const handoff = @import("handoff"); 24 const handoff = @import("handoff");
25 const wall = @import("wall"); 25 const hosts = @import("hosts");
26 const xdg = @import("xdg"); 26 const xdg = @import("xdg");
27 const sockpath = @import("sockpath"); 27 const sockpath = @import("sockpath");
28 28
@@ -261,7 +261,7 @@ pub const Target = union(enum) {
261 quic: QuicTarget, 261 quic: QuicTarget,
262 hand: HandoffTarget, 262 hand: HandoffTarget,
263 263
264 /// The one road from a spelling's `wall.Spec` to the dial it names: 264 /// The one road from a spelling's `hosts.Spec` to the dial it names:
265 /// the wall's host lines and the hub's tiles resolve here, and the 265 /// the wall's host lines and the hub's tiles resolve here, and the
266 /// CLI's `.host` arm shares `fromRecipe`. What can fail is 266 /// CLI's `.host` arm shares `fromRecipe`. What can fail is
267 /// `SpecError`; the word for it is each door's own. 267 /// `SpecError`; the word for it is each door's own.
@@ -270,7 +270,7 @@ pub const Target = union(enum) {
270 /// be a scratch buffer, and a target outlives the read that made it. 270 /// be a scratch buffer, and a target outlives the read that made it.
271 /// 271 ///
272 /// `asked` is required, never defaulted: see `HandoffTarget.fromRecipe`. 272 /// `asked` is required, never defaulted: see `HandoffTarget.fromRecipe`.
273 pub fn fromSpec(alloc: std.mem.Allocator, spec: wall.Spec, key: ?[]const u8, idle_ms: u32, asked: bool) SpecError!Target { 273 pub fn fromSpec(alloc: std.mem.Allocator, spec: hosts.Spec, key: ?[]const u8, idle_ms: u32, asked: bool) SpecError!Target {
274 return switch (spec) { 274 return switch (spec) {
275 // sun_path is a fixed array in the kernel's struct: a longer 275 // sun_path is a fixed array in the kernel's struct: a longer
276 // path cannot be dialed at all, so it is refused here, at 276 // path cannot be dialed at all, so it is refused here, at
@@ -1061,8 +1061,8 @@ pub fn openFailure(buf: []u8, target: Target, err: anyerror) OpenFailure {
1061 1061
1062 /// What turning a `Target` back into a spelling can fail with — a tile's 1062 /// What turning a `Target` back into a spelling can fail with — a tile's
1063 /// label bar and the layout sidecar's leaf key. `NoSpelling` is not a 1063 /// label bar and the layout sidecar's leaf key. `NoSpelling` is not a
1064 /// defect: the grammar shared by wall.zig and hosts.zig has no form for 1064 /// defect: the host grammar (hosts.zig) has no form for `--via`, so for
1065 /// `--via`, so for that transport there is nothing truthful to write down. 1065 /// that transport there is nothing truthful to write down.
1066 pub const SpellingError = error{ NoSpelling, NoSpace }; 1066 pub const SpellingError = error{ NoSpelling, NoSpace };
1067 1067
1068 /// One session's tile as a user could have typed it: a single argv string 1068 /// One session's tile as a user could have typed it: a single argv string
@@ -1099,7 +1099,7 @@ pub fn spellingCap(target: Target) usize {
1099 1099
1100 // Whether a line RESTORED from the wall file may create the session it 1100 // Whether a line RESTORED from the wall file may create the session it
1101 // names. The browser hub's per-tile pump is the door that asks; the CLI 1101 // names. The browser hub's per-tile pump is the door that asks; the CLI
1102 // wall no longer restores sessions at all. It sits on `Target` rather than on `wall.Spec` because 1102 // wall no longer restores sessions at all. It sits on `Target` rather than on `hosts.Spec` because
1103 // that is what both doors still hold at the moment they decide — the 1103 // that is what both doors still hold at the moment they decide — the
1104 // spelling is resolved away long before. 1104 // spelling is resolved away long before.
1105 // 1105 //
@@ -2338,12 +2338,12 @@ test "client: a target spells itself back as one wall argument per session" {
2338 ); 2338 );
2339 } 2339 }
2340 2340
2341 test "client: every spelling this writes, the wall grammar reads back the same" { 2341 test "client: every spelling this writes, the host grammar reads back the same" {
2342 // Writer/reader identity, pinned across the module boundary. A tile's 2342 // Writer/reader identity, pinned across the module boundary. A tile's
2343 // label and the sidecar leaf keyed by it are this string, and the 2343 // label and the sidecar leaf keyed by it are this string, and the
2344 // sidecar's restore parses it back — a drift in either half heals a 2344 // host half of it is what `hosts.parse` answers for — a drift in
2345 // saved layout onto the wrong tile. `refAllDecls` compiles both; only 2345 // either half heals a saved layout onto the wrong tile.
2346 // this executes both. 2346 // `refAllDecls` compiles both; only this executes both.
2347 var buf: [256]u8 = undefined; 2347 var buf: [256]u8 = undefined;
2348 const cases = .{ 2348 const cases = .{
2349 .{ Target{ .sock = "/run/user/1000/muxd.sock" }, "0" }, 2349 .{ Target{ .sock = "/run/user/1000/muxd.sock" }, "0" },
@@ -2357,12 +2357,15 @@ test "client: every spelling this writes, the wall grammar reads back the same"
2357 }; 2357 };
2358 inline for (cases) |c| { 2358 inline for (cases) |c| {
2359 const spelling = try wallSpelling(&buf, c[0], c[1]); 2359 const spelling = try wallSpelling(&buf, c[0], c[1]);
2360 const p = try wall.parseSpelling(spelling); 2360 // `#NAME` is last and a session name may not hold a '#', so the
2361 try std.testing.expectEqualStrings(c[1], p.session); 2361 // split is the last one — the same rule the label bar reads by.
2362 const hash = std.mem.lastIndexOfScalar(u8, spelling, '#').?;
2363 try std.testing.expectEqualStrings(c[1], spelling[hash + 1 ..]);
2364 const spec = try hosts.parse(spelling[0..hash]);
2362 switch (c[0]) { 2365 switch (c[0]) {
2363 .sock => |path| try std.testing.expectEqualStrings(path, p.spec.sock), 2366 .sock => |path| try std.testing.expectEqualStrings(path, spec.sock),
2364 .hand => |h| try std.testing.expectEqualStrings(h.host, p.spec.host), 2367 .hand => |h| try std.testing.expectEqualStrings(h.host, spec.host),
2365 .quic => |q| try std.testing.expectEqualStrings(q.host_port, p.spec.quic), 2368 .quic => |q| try std.testing.expectEqualStrings(q.host_port, spec.quic),
2366 .via => unreachable, 2369 .via => unreachable,
2367 } 2370 }
2368 } 2371 }
src/client/hosts.zig
Old New
@@ -4,20 +4,41 @@
4 //! nothing here names a session and nothing here can resurrect one. 4 //! nothing here names a session and nothing here can resurrect one.
5 //! Strict on load: a host line is authored intent. 5 //! Strict on load: a host line is authored intent.
6 //! 6 //!
7 //! ONE grammar for every mouth — argv, the file, the picker's `a` — so a
8 //! user who learns a refusal in one reads it in the others, and one atomic
9 //! writer for every state file mux keeps.
10 //!
7 //! Every write is an unlocked read-modify-write over an atomic rename, so 11 //! Every write is an unlocked read-modify-write over an atomic rename, so
8 //! no reader tears — but two writers lose one update, and every attach now 12 //! no reader tears — but two writers lose one update, and every attach now
9 //! writes. What fails: an attach recording its daemon at the same moment as 13 //! writes. What fails: an attach recording its daemon at the same moment as
10 //! a `mux hosts add` elsewhere drops one of the two lines. 14 //! a `mux hosts add` elsewhere drops one of the two lines. "Atomic" is
15 //! writer-vs-writer only: nothing here fsyncs, so a crash can still leave
16 //! the rename torn. No crash-durability claim is made.
11 const std = @import("std"); 17 const std = @import("std");
12 const wall = @import("wall");
13 const xdg = @import("xdg"); 18 const xdg = @import("xdg");
14 19
15 /// The wall grammar's, not a second copy of it: `hosts.parse` is the 20 /// What a host line names. `client.Target.fromSpec` dials it.
16 /// strict door onto the same three spellings `wall.parseSpelling` reads, 21 pub const Spec = union(enum) {
17 /// and one type is what lets `client.Target.fromSpec` answer both. 22 sock: []const u8,
18 pub const Spec = wall.Spec; 23 host: []const u8,
24 quic: []const u8,
25 };
26
19 pub const ParseError = error{ HasSession, EmptySpec, BadByte, BadSpelling }; 27 pub const ParseError = error{ HasSession, EmptySpec, BadByte, BadSpelling };
20 28
29 /// The bytes a HOST spelling may not hold. The word is ONE argv element —
30 /// `handoff.recipeFor` hands it to a `ssh` the client execs — and the wall
31 /// grammar is whitespace-separated, so a space here is a second host and
32 /// the rest are punctuation no resolver will ever answer. `[user@]host`
33 /// needs none of them; `--via` is where an arbitrary command is the
34 /// contract, and it is not spellable as a host.
35 const unspellable = " \t;&|`$()<>'\"\\*?{}[]!~";
36
37 pub fn hasBadSpelling(word: []const u8) bool {
38 for (word) |b| if (std.mem.indexOfScalar(u8, unspellable, b) != null) return true;
39 return false;
40 }
41
21 pub fn parse(line: []const u8) ParseError!Spec { 42 pub fn parse(line: []const u8) ParseError!Spec {
22 for (line) |b| if (b < 0x20 or b == 0x7f) return error.BadByte; 43 for (line) |b| if (b < 0x20 or b == 0x7f) return error.BadByte;
23 if (std.mem.indexOfScalar(u8, line, '#') != null) return error.HasSession; 44 if (std.mem.indexOfScalar(u8, line, '#') != null) return error.HasSession;
@@ -30,12 +51,39 @@ pub fn parse(line: []const u8) ParseError!Spec {
30 return if (h.len == 0) error.EmptySpec else .{ .quic = h }; 51 return if (h.len == 0) error.EmptySpec else .{ .quic = h };
31 } 52 }
32 if (line.len == 0) return error.EmptySpec; 53 if (line.len == 0) return error.EmptySpec;
33 // wall.zig owns the byte set: the hub's POST body reaches the same ssh 54 if (hasBadSpelling(line)) return error.BadSpelling;
34 // argv this file's poll does, so one refusal serves both mouths.
35 if (wall.hasBadSpelling(line)) return error.BadSpelling;
36 return .{ .host = line }; 55 return .{ .host = line };
37 } 56 }
38 57
58 pub const ArgvError = error{ MissingSockPath, FlagLikeTarget } || std.mem.Allocator.Error;
59
60 /// Every mouth's rule: a host starting with a dash is a mistyped flag.
61 pub fn flagLike(target: []const u8) bool {
62 return target.len > 0 and target[0] == '-' and !std.mem.startsWith(u8, target, "--sock ");
63 }
64
65 /// Both `--sock` dialects reach one spelling: one parser, one line format.
66 pub fn spellingFromArgv(
67 alloc: std.mem.Allocator,
68 args: []const [:0]const u8,
69 i: usize,
70 ) ArgvError!struct { spelling: []u8, consumed: usize } {
71 if (std.mem.eql(u8, args[i], "--sock")) {
72 // A trailing `--sock` names no path: a usage mistake, reported as
73 // one rather than read off the end of argv.
74 if (i + 1 >= args.len) return error.MissingSockPath;
75 return .{ .spelling = try std.fmt.allocPrint(alloc, "--sock {s}", .{args[i + 1]}), .consumed = 2 };
76 }
77 // A wall takes hosts, and no host starts with a dash: bare `--sock`
78 // took its path and returned above, and the one-piece `--sock PATH`
79 // dialect is `flagLike`'s exemption. Left to fall through, `mux hosts
80 // add -A box` became a host named `-A` that failed to resolve somewhere
81 // far from the typo — and a wall has no per-host agent flag to have
82 // meant, `-A` belonging to a single attach.
83 if (flagLike(args[i])) return error.FlagLikeTarget;
84 return .{ .spelling = try alloc.dupe(u8, args[i]), .consumed = 1 };
85 }
86
39 /// Whether the grammar refused a line — the user's spelling to fix — as 87 /// Whether the grammar refused a line — the user's spelling to fix — as
40 /// opposed to the file not being readable at all, which is not. 88 /// opposed to the file not being readable at all, which is not.
41 pub fn isParse(err: anyerror) bool { 89 pub fn isParse(err: anyerror) bool {
@@ -96,7 +144,7 @@ pub fn load(alloc: std.mem.Allocator, path: []const u8) !Hosts {
96 } 144 }
97 145
98 pub fn save(h: *const Hosts, path: []const u8) !void { 146 pub fn save(h: *const Hosts, path: []const u8) !void {
99 return wall.saveLines(h.lines.items, path); 147 return saveLines(h.lines.items, path);
100 } 148 }
101 149
102 pub fn record(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool { 150 pub fn record(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool {
@@ -133,8 +181,8 @@ pub fn forgetMany(
133 // applied. EVERY copy, because `load` folds duplicate lines into one 181 // applied. EVERY copy, because `load` folds duplicate lines into one
134 // wall entry — stopping at the first match let a `rm` exit 0, print 182 // wall entry — stopping at the first match let a `rm` exit 0, print
135 // nothing, and leave the host still polled. 183 // nothing, and leave the host still polled.
136 var lines = try wall.loadLines(alloc, path); 184 var lines = try loadLines(alloc, path);
137 defer wall.freeLines(alloc, &lines); 185 defer freeLines(alloc, &lines);
138 var removed = false; 186 var removed = false;
139 var i: usize = 0; 187 var i: usize = 0;
140 while (i < lines.items.len) { 188 while (i < lines.items.len) {
@@ -154,7 +202,7 @@ pub fn forgetMany(
154 // A save that changed nothing is still a write another writer can lose 202 // A save that changed nothing is still a write another writer can lose
155 // an update to (see the header), so a `rm` that matched nothing does not 203 // an update to (see the header), so a `rm` that matched nothing does not
156 // make one. 204 // make one.
157 if (removed) try wall.saveLines(lines.items, path); 205 if (removed) try saveLines(lines.items, path);
158 } 206 }
159 207
160 pub fn statePath(alloc: std.mem.Allocator) ![]const u8 { 208 pub fn statePath(alloc: std.mem.Allocator) ![]const u8 {
@@ -165,6 +213,65 @@ pub fn statePathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home
165 return xdg.pathFrom(alloc, xdg_state_home, home, ".local/state", "hosts"); 213 return xdg.pathFrom(alloc, xdg_state_home, home, ".local/state", "hosts");
166 } 214 }
167 215
216 /// Beside the hosts file. What the sidecar HOLDS is `wall_layout`'s.
217 pub fn layoutPath(alloc: std.mem.Allocator) ![]const u8 {
218 return xdg.statePath(alloc, "layout");
219 }
220
221 pub fn layoutPathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8 {
222 return xdg.pathFrom(alloc, xdg_state_home, home, ".local/state", "layout");
223 }
224
225 /// One atomic-write idiom in this module: join then `saveBytes`.
226 pub fn saveLines(lines: []const []const u8, path: []const u8) !void {
227 const ga = std.heap.page_allocator;
228 var joined: std.ArrayList(u8) = .empty;
229 defer joined.deinit(ga);
230 for (lines) |t| {
231 try joined.appendSlice(ga, t);
232 try joined.append(ga, '\n');
233 }
234 try saveBytes(path, joined.items);
235 }
236
237 /// Every line of the file, verbatim, with NO grammar applied.
238 ///
239 /// `load` refuses a line that no longer parses: silently dropping a host
240 /// the user wrote down is worse. But that left one hand-edited line
241 /// unrepairable by the command whose whole job is removing a line, so
242 /// removal reads with this instead.
243 ///
244 /// NOT for `record` or `Hosts.add`: growing a file whose content is not
245 /// understood builds on garbage and re-saves it as if it had been read.
246 pub fn loadLines(alloc: std.mem.Allocator, path: []const u8) !std.ArrayList([]u8) {
247 var lines: std.ArrayList([]u8) = .empty;
248 errdefer freeLines(alloc, &lines);
249 const data = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |err| switch (err) {
250 error.FileNotFound => return lines,
251 else => return err,
252 };
253 defer alloc.free(data);
254 var it = std.mem.tokenizeScalar(u8, data, '\n');
255 while (it.next()) |line| try lines.append(alloc, try alloc.dupe(u8, line));
256 return lines;
257 }
258
259 pub fn freeLines(alloc: std.mem.Allocator, lines: *std.ArrayList([]u8)) void {
260 for (lines.items) |l| alloc.free(l);
261 lines.deinit(alloc);
262 }
263
264 /// One atomic writer for every state file mux keeps: the hosts file here
265 /// and the layout sidecar in `wall_layout`, so there is one temp+rename
266 /// idiom and not two.
267 pub fn saveBytes(path: []const u8, bytes: []const u8) !void {
268 var write_buf: [4096]u8 = undefined;
269 var af = try std.fs.cwd().atomicFile(path, .{ .make_path = true, .write_buffer = &write_buf });
270 defer af.deinit();
271 try af.file_writer.interface.writeAll(bytes);
272 try af.finish();
273 }
274
168 /// cliflags hooks for `mux hosts add|rm SPELLING...`, each word validated 275 /// cliflags hooks for `mux hosts add|rm SPELLING...`, each word validated
169 /// here at usage altitude rather than downstream as a host that will not dial. 276 /// here at usage altitude rather than downstream as a host that will not dial.
170 pub const Argv = struct { 277 pub const Argv = struct {
@@ -172,7 +279,7 @@ pub const Argv = struct {
172 list: std.ArrayList([]const u8) = .empty, 279 list: std.ArrayList([]const u8) = .empty,
173 /// A hook answers yes or no, so one that refused for a REASON leaves 280 /// A hook answers yes or no, so one that refused for a REASON leaves
174 /// the word and the why for the caller's message. 281 /// the word and the why for the caller's message.
175 err: ?struct { word: []const u8, err: (wall.ArgvError || ParseError) } = null, 282 err: ?struct { word: []const u8, err: (ArgvError || ParseError) } = null,
176 283
177 pub fn deinit(self: *Argv) void { 284 pub fn deinit(self: *Argv) void {
178 for (self.list.items) |t| self.alloc.free(t); 285 for (self.list.items) |t| self.alloc.free(t);
@@ -182,7 +289,7 @@ pub const Argv = struct {
182 return self.take(word); 289 return self.take(word);
183 } 290 }
184 pub fn extra(self: *Argv, rest: []const [:0]const u8) usize { 291 pub fn extra(self: *Argv, rest: []const [:0]const u8) usize {
185 const n = wall.spellingFromArgv(self.alloc, rest, 0) catch |e| { 292 const n = spellingFromArgv(self.alloc, rest, 0) catch |e| {
186 if (e != error.FlagLikeTarget) _ = self.refuse(rest[0], e); 293 if (e != error.FlagLikeTarget) _ = self.refuse(rest[0], e);
187 return 0; 294 return 0;
188 }; 295 };
@@ -201,7 +308,7 @@ pub const Argv = struct {
201 _ = parse(copy) catch |e| return self.refuse(copy, e); 308 _ = parse(copy) catch |e| return self.refuse(copy, e);
202 return true; 309 return true;
203 } 310 }
204 fn refuse(self: *Argv, word: []const u8, e: (wall.ArgvError || ParseError)) bool { 311 fn refuse(self: *Argv, word: []const u8, e: (ArgvError || ParseError)) bool {
205 self.err = .{ .word = word, .err = e }; 312 self.err = .{ .word = word, .err = e };
206 return false; 313 return false;
207 } 314 }
@@ -289,7 +396,7 @@ test "hosts: add dedups, load/save round-trip two hosts in order, and a doubled
289 // A hand-edited file that lists a daemon twice loads as one host. The 396 // A hand-edited file that lists a daemon twice loads as one host. The
290 // wall would otherwise poll it twice and show every session of it 397 // wall would otherwise poll it twice and show every session of it
291 // twice, and `add` would then report a line it did not write. 398 // twice, and `add` would then report a line it did not write.
292 try wall.saveBytes(path, "box\n--sock /tmp/a.sock\nbox\n"); 399 try saveBytes(path, "box\n--sock /tmp/a.sock\nbox\n");
293 var doubled = try load(alloc, path); 400 var doubled = try load(alloc, path);
294 defer doubled.deinit(alloc); 401 defer doubled.deinit(alloc);
295 try std.testing.expectEqual(@as(usize, 2), doubled.lines.items.len); 402 try std.testing.expectEqual(@as(usize, 2), doubled.lines.items.len);
@@ -303,7 +410,7 @@ test "hosts.load is strict: a session line in the file is an error, not a skippe
303 defer tmp.cleanup(); 410 defer tmp.cleanup();
304 const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()}); 411 const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()});
305 defer alloc.free(path); 412 defer alloc.free(path);
306 try wall.saveBytes(path, "box\nbox#old\n"); 413 try saveBytes(path, "box\nbox#old\n");
307 try std.testing.expectError(error.HasSession, load(alloc, path)); 414 try std.testing.expectError(error.HasSession, load(alloc, path));
308 } 415 }
309 416
@@ -313,7 +420,7 @@ test "hosts.forget removes a line strict load refuses; record still will not gro
313 defer tmp.cleanup(); 420 defer tmp.cleanup();
314 const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()}); 421 const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()});
315 defer alloc.free(path); 422 defer alloc.free(path);
316 try wall.saveBytes(path, "box\nbox#old\n"); 423 try saveBytes(path, "box\nbox#old\n");
317 424
318 // Growing a file whose content is not understood re-saves the garbage 425 // Growing a file whose content is not understood re-saves the garbage
319 // as if it had been read, so record is refused while the bad line sits. 426 // as if it had been read, so record is refused while the bad line sits.
@@ -336,11 +443,11 @@ test "hosts.forget removes EVERY copy, so a rm cannot report success and change
336 defer tmp.cleanup(); 443 defer tmp.cleanup();
337 const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()}); 444 const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()});
338 defer alloc.free(path); 445 defer alloc.free(path);
339 try wall.saveBytes(path, "box\nkeep\nbox\n"); 446 try saveBytes(path, "box\nkeep\nbox\n");
340 447
341 try std.testing.expect(try forget(alloc, path, "box")); 448 try std.testing.expect(try forget(alloc, path, "box"));
342 var lines = try wall.loadLines(alloc, path); 449 var lines = try loadLines(alloc, path);
343 defer wall.freeLines(alloc, &lines); 450 defer freeLines(alloc, &lines);
344 try std.testing.expectEqual(@as(usize, 1), lines.items.len); 451 try std.testing.expectEqual(@as(usize, 1), lines.items.len);
345 try std.testing.expectEqualStrings("keep", lines.items[0]); 452 try std.testing.expectEqualStrings("keep", lines.items[0]);
346 try std.testing.expect(!try forget(alloc, path, "box")); 453 try std.testing.expect(!try forget(alloc, path, "box"));
@@ -360,14 +467,14 @@ test "hosts.forgetMany: several names leave in ONE read-modify-write, and the su
360 defer tmp.cleanup(); 467 defer tmp.cleanup();
361 const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()}); 468 const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()});
362 defer alloc.free(path); 469 defer alloc.free(path);
363 try wall.saveBytes(path, "a\nb\nkeep\na\nlast\n"); 470 try saveBytes(path, "a\nb\nkeep\na\nlast\n");
364 471
365 var gone = [_]bool{ false, false, false }; 472 var gone = [_]bool{ false, false, false };
366 try forgetMany(alloc, path, &.{ "a", "absent", "b" }, &gone); 473 try forgetMany(alloc, path, &.{ "a", "absent", "b" }, &gone);
367 try std.testing.expectEqualSlices(bool, &.{ true, false, true }, &gone); 474 try std.testing.expectEqualSlices(bool, &.{ true, false, true }, &gone);
368 475
369 var lines = try wall.loadLines(alloc, path); 476 var lines = try loadLines(alloc, path);
370 defer wall.freeLines(alloc, &lines); 477 defer freeLines(alloc, &lines);
371 try std.testing.expectEqual(@as(usize, 2), lines.items.len); 478 try std.testing.expectEqual(@as(usize, 2), lines.items.len);
372 try std.testing.expectEqualStrings("keep", lines.items[0]); 479 try std.testing.expectEqualStrings("keep", lines.items[0]);
373 try std.testing.expectEqualStrings("last", lines.items[1]); 480 try std.testing.expectEqualStrings("last", lines.items[1]);
@@ -405,3 +512,82 @@ test "hosts.Argv: a taken word is validated, a refusal names it, a flag leaves n
405 try std.testing.expect(a.err.?.err == error.HasSession); 512 try std.testing.expect(a.err.?.err == error.HasSession);
406 try std.testing.expectEqualStrings("box#x", a.err.?.word); 513 try std.testing.expectEqualStrings("box#x", a.err.?.word);
407 } 514 }
515
516 test "spellingFromArgv: both --sock dialects reach the same spelling" {
517 const alloc = std.testing.allocator;
518 const argv = [_][:0]const u8{ "--sock", "/tmp/x.sock", "--sock /tmp/x.sock", "box", "quic://h:4433" };
519
520 // Two arguments joined, and one argument passed through: same string,
521 // which is the point — the file only ever holds this one.
522 const joined = try spellingFromArgv(alloc, &argv, 0);
523 defer alloc.free(joined.spelling);
524 try std.testing.expectEqualStrings("--sock /tmp/x.sock", joined.spelling);
525 try std.testing.expectEqual(@as(usize, 2), joined.consumed);
526
527 const whole = try spellingFromArgv(alloc, &argv, 2);
528 defer alloc.free(whole.spelling);
529 try std.testing.expectEqualStrings("--sock /tmp/x.sock", whole.spelling);
530 try std.testing.expectEqual(@as(usize, 1), whole.consumed);
531 try std.testing.expectEqualStrings("/tmp/x.sock", (try parse(whole.spelling)).sock);
532
533 // Host and quic spellings are already whole; nothing is consumed after.
534 const host = try spellingFromArgv(alloc, &argv, 3);
535 defer alloc.free(host.spelling);
536 try std.testing.expectEqualStrings("box", host.spelling);
537 try std.testing.expectEqual(@as(usize, 1), host.consumed);
538 const q = try spellingFromArgv(alloc, &argv, 4);
539 defer alloc.free(q.spelling);
540 try std.testing.expectEqualStrings("quic://h:4433", q.spelling);
541 try std.testing.expectEqual(@as(usize, 1), q.consumed);
542 }
543
544 test "spellingFromArgv: a trailing --sock is a usage error, not a read off the end" {
545 const argv = [_][:0]const u8{ "box", "--sock" };
546 try std.testing.expectError(error.MissingSockPath, spellingFromArgv(std.testing.allocator, &argv, 1));
547 }
548
549 test "spellingFromArgv: a flag is not a host" {
550 // `mux hosts add -A box` used to make a host named `-A`, which then
551 // failed to resolve somewhere far from the typo. A wall has no per-host
552 // agent flag at all — `-A` belongs to a single attach — so every
553 // flag-shaped argument here is the same mistake.
554 const argv = [_][:0]const u8{ "-A", "box", "--sock /tmp/x", "quic://h:1" };
555 try std.testing.expectError(error.FlagLikeTarget, spellingFromArgv(std.testing.allocator, &argv, 0));
556
557 // The one spelling that legitimately starts with a dash still passes.
558 const whole = try spellingFromArgv(std.testing.allocator, &argv, 2);
559 defer std.testing.allocator.free(whole.spelling);
560 try std.testing.expectEqualStrings("--sock /tmp/x", whole.spelling);
561 }
562
563 test "saveLines: a hosts file bigger than any stack buffer still round-trips" {
564 const alloc = std.testing.allocator;
565 var tmp = try TmpDir.make();
566 defer tmp.cleanup();
567 const path = try std.fmt.allocPrint(alloc, "{s}/bighosts", .{tmp.path()});
568 defer alloc.free(path);
569
570 // 200 lines of ~100 bytes each = ~20 KiB, well past an 8 KiB stack buffer.
571 var lines: std.ArrayList([]const u8) = .empty;
572 defer {
573 for (lines.items) |l| alloc.free(l);
574 lines.deinit(alloc);
575 }
576 for (0..200) |i| {
577 const line = try std.fmt.allocPrint(alloc, "host-{d:0>3}.example.com-{d:0>3}", .{ i, i });
578 try lines.append(alloc, line);
579 }
580 try saveLines(lines.items, path);
581
582 var back = try loadLines(alloc, path);
583 defer freeLines(alloc, &back);
584 try std.testing.expectEqual(@as(usize, 200), back.items.len);
585 try std.testing.expectEqualStrings("host-199.example.com-199", back.items[199]);
586 }
587
588 // Forces semantic analysis of every pub decl under `zig build test`, so an
589 // unreferenced decl must at least compile (the silent-module-loss hazard,
590 // decisions.md).
591 test {
592 std.testing.refAllDeclsRecursive(@This());
593 }
src/client/wall.zig
Old New
@@ -1,47 +1,18 @@
1 //! The wall: an ordered list of TARGET spellings — attach history. mux 1 //! The browser hub's private wall file, being retired: an ordered list of
2 //! records a grid-claiming attach on its first state; the hub records its 2 //! TARGET spellings, one per line of `$XDG_STATE_HOME/mux/wall`. The
3 //! wall mutations (argv tiles, POST /tiles), never an attach. One owner 3 //! grammar, the atomic writer and the argv collector all live in
4 //! for the spelling grammar, the session split, and the persisted file, 4 //! `hosts.zig` now; what is left here is the `#SESSION` half the hub has
5 //! so the wall built in a browser is the wall the CLI sees. 5 //! not yet stopped reading.
6 //!
7 //! Spelling grammar (one string; also the line format of the state file
8 //! and the body of the hub's POST /tiles):
9 //! HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION]
10 //! The session splits at the LAST '#' because validSessionName refuses
11 //! '#', so any earlier one belongs to the target's own spelling.
12 //!
13 //! The file is `$XDG_STATE_HOME/mux/wall`, one spelling per line, in wall
14 //! order. Every mutation rewrites it atomically (temp + rename); two
15 //! concurrent writers resolve as last-rename-wins, acceptable for a single
16 //! user's state file. "Atomic" is writer-vs-writer only: `save` fsyncs
17 //! nothing, so a crash can still leave the rename torn. No crash-durability
18 //! claim is made here.
19 const std = @import("std"); 6 const std = @import("std");
20 const proto = @import("protocol"); 7 const proto = @import("protocol");
8 const hosts = @import("hosts");
21 const xdg = @import("xdg"); 9 const xdg = @import("xdg");
22 10
23 pub const Spec = union(enum) { 11 pub const Spec = hosts.Spec;
24 sock: []const u8,
25 host: []const u8,
26 quic: []const u8,
27 };
28 12
29 pub const Parsed = struct { spec: Spec, session: []const u8 }; 13 pub const Parsed = struct { spec: Spec, session: []const u8 };
30 pub const ParseError = error{ BadSession, EmptySpec, BadByte, BadSpelling }; 14 pub const ParseError = error{ BadSession, EmptySpec, BadByte, BadSpelling };
31 15
32 /// The bytes a HOST spelling may not hold. The word is ONE argv element —
33 /// `handoff.recipeFor` hands it to a `ssh` the client execs — and the wall
34 /// grammar is whitespace-separated, so a space here is a second tile and
35 /// the rest are punctuation no resolver will ever answer. `[user@]host`
36 /// needs none of them; `--via` is where an arbitrary command is the
37 /// contract, and it is not spellable as a target.
38 const unspellable = " \t;&|`$()<>'\"\\*?{}[]!~";
39
40 pub fn hasBadSpelling(word: []const u8) bool {
41 for (word) |b| if (std.mem.indexOfScalar(u8, unspellable, b) != null) return true;
42 return false;
43 }
44
45 /// Splits and classifies one spelling. Refuses here, at usage altitude, 16 /// Splits and classifies one spelling. Refuses here, at usage altitude,
46 /// what would otherwise surface as a rejected attach far from the typo: 17 /// what would otherwise surface as a rejected attach far from the typo:
47 /// a malformed session name, or a spelling whose target part is empty 18 /// a malformed session name, or a spelling whose target part is empty
@@ -79,54 +50,17 @@ pub fn parseSpelling(line: []const u8) ParseError!Parsed {
79 return .{ .spec = .{ .quic = hp }, .session = session }; 50 return .{ .spec = .{ .quic = hp }, .session = session };
80 } 51 }
81 if (spec_str.len == 0) return error.EmptySpec; 52 if (spec_str.len == 0) return error.EmptySpec;
82 if (hasBadSpelling(spec_str)) return error.BadSpelling; 53 if (hosts.hasBadSpelling(spec_str)) return error.BadSpelling;
83 return .{ .spec = .{ .host = spec_str }, .session = session }; 54 return .{ .spec = .{ .host = spec_str }, .session = session };
84 } 55 }
85 56
86 pub const ArgvError = error{ MissingSockPath, FlagLikeTarget } || std.mem.Allocator.Error; 57 /// The one refusal this grammar has that the host grammar does not: a
87 58 /// `#SESSION` it will not take. Everything else is `hosts.reason`'s, so
88 /// Every mouth's rule: a target starting with a dash is a mistyped flag. 59 /// the two mouths refuse in the same words.
89 pub fn flagLike(target: []const u8) bool {
90 return target.len > 0 and target[0] == '-' and !std.mem.startsWith(u8, target, "--sock ");
91 }
92
93 /// Both binaries accept both `--sock` dialects: one parser, one spelling.
94 pub fn spellingFromArgv(
95 alloc: std.mem.Allocator,
96 args: []const [:0]const u8,
97 i: usize,
98 ) ArgvError!struct { spelling: []u8, consumed: usize } {
99 if (std.mem.eql(u8, args[i], "--sock")) {
100 // A trailing `--sock` names no path: a usage mistake, reported as
101 // one rather than read off the end of argv.
102 if (i + 1 >= args.len) return error.MissingSockPath;
103 return .{ .spelling = try std.fmt.allocPrint(alloc, "--sock {s}", .{args[i + 1]}), .consumed = 2 };
104 }
105 // A wall takes targets, and no target starts with a dash: bare `--sock`
106 // took its path and returned above, and the one-piece `--sock PATH`
107 // dialect is `flagLike`'s exemption. Left to fall through, `mux wall
108 // -A host` became a tile for a host named `-A` and failed to resolve
109 // somewhere far from the typo — and a wall has no per-tile agent flag
110 // to have meant, `-A` belonging to a single attach. Shared with the
111 // prompt, which is this argv typed from inside a running wall.
112 if (flagLike(args[i])) return error.FlagLikeTarget;
113 return .{ .spelling = try alloc.dupe(u8, args[i]), .consumed = 1 };
114 }
115
116 /// Why one spelling cannot be a tile, in ONE vocabulary: argv, the state
117 /// file and the hub's POST body are refused in the same words, so a user
118 /// who learns the message in one mouth reads it in the others. Errors
119 /// from outside this grammar (the resolver's) keep an arm here for the
120 /// same reason: the caller printing it does not care which layer said no.
121 pub fn reason(err: anyerror) []const u8 { 60 pub fn reason(err: anyerror) []const u8 {
122 return switch (err) { 61 return switch (err) {
123 error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')", 62 error.BadSession => "bad session name after '#' (printable ASCII, no space, no '/')",
124 error.EmptySpec => "empty target", 63 else => hosts.reason(err),
125 error.BadByte => "control byte in target",
126 error.BadSpelling => "punctuation in host: a tile spelling names a machine, not a command",
127 error.MissingSockPath => "names no path",
128 error.SockPathTooLong => "socket path too long to bind",
129 else => @errorName(err),
130 }; 64 };
131 } 65 }
132 66
@@ -141,7 +75,7 @@ pub const Argv = struct {
141 /// A hook answers yes or no, so one that refused for a REASON has 75 /// A hook answers yes or no, so one that refused for a REASON has
142 /// nowhere to say so: it leaves the word and the why for the caller, 76 /// nowhere to say so: it leaves the word and the why for the caller,
143 /// whose message can then name WHICH tile of several was refused. 77 /// whose message can then name WHICH tile of several was refused.
144 err: ?struct { word: []const u8, err: (ArgvError || ParseError) } = null, 78 err: ?struct { word: []const u8, err: (hosts.ArgvError || ParseError) } = null,
145 79
146 pub fn deinit(self: *Argv) void { 80 pub fn deinit(self: *Argv) void {
147 for (self.tiles.items) |t| self.alloc.free(t); 81 for (self.tiles.items) |t| self.alloc.free(t);
@@ -158,7 +92,7 @@ pub const Argv = struct {
158 /// target this program rejects but one it never saw, left for cliflags 92 /// target this program rejects but one it never saw, left for cliflags
159 /// to name as the unknown flag it is. 93 /// to name as the unknown flag it is.
160 pub fn extra(self: *Argv, rest: []const [:0]const u8) usize { 94 pub fn extra(self: *Argv, rest: []const [:0]const u8) usize {
161 const n = spellingFromArgv(self.alloc, rest, 0) catch |e| { 95 const n = hosts.spellingFromArgv(self.alloc, rest, 0) catch |e| {
162 if (e != error.FlagLikeTarget) _ = self.refuse(rest[0], e); 96 if (e != error.FlagLikeTarget) _ = self.refuse(rest[0], e);
163 return 0; 97 return 0;
164 }; 98 };
@@ -179,7 +113,7 @@ pub const Argv = struct {
179 return true; 113 return true;
180 } 114 }
181 115
182 fn refuse(self: *Argv, word: []const u8, e: (ArgvError || ParseError)) bool { 116 fn refuse(self: *Argv, word: []const u8, e: (hosts.ArgvError || ParseError)) bool {
183 self.err = .{ .word = word, .err = e }; 117 self.err = .{ .word = word, .err = e };
184 return false; 118 return false;
185 } 119 }
@@ -246,49 +180,7 @@ pub fn load(alloc: std.mem.Allocator, path: []const u8) !Wall {
246 } 180 }
247 181
248 pub fn save(w: *const Wall, path: []const u8) !void { 182 pub fn save(w: *const Wall, path: []const u8) !void {
249 return saveLines(w.targets.items, path); 183 return hosts.saveLines(w.targets.items, path);
250 }
251
252 /// One atomic-write idiom in this module: join then `saveBytes`.
253 pub fn saveLines(lines: []const []const u8, path: []const u8) !void {
254 const ga = std.heap.page_allocator;
255 var joined: std.ArrayList(u8) = .empty;
256 defer joined.deinit(ga);
257 for (lines) |t| {
258 try joined.appendSlice(ga, t);
259 try joined.append(ga, '\n');
260 }
261 try saveBytes(path, joined.items);
262 }
263
264 /// Every line of the wall file, verbatim, with NO grammar applied.
265 ///
266 /// `load` refuses a line that no longer parses: silently dropping a tile
267 /// the user wrote down is worse. But that left one hand-edited line
268 /// unrepairable by the command whose whole job is removing a line, so
269 /// removal reads with this instead.
270 ///
271 /// NOT for `record` or `Wall.add`: growing a file whose content is not
272 /// understood builds on garbage and re-saves it as if it had been read.
273 pub fn loadLines(alloc: std.mem.Allocator, path: []const u8) !std.ArrayList([]u8) {
274 var lines: std.ArrayList([]u8) = .empty;
275 errdefer {
276 for (lines.items) |l| alloc.free(l);
277 lines.deinit(alloc);
278 }
279 const data = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |err| switch (err) {
280 error.FileNotFound => return lines,
281 else => return err,
282 };
283 defer alloc.free(data);
284 var it = std.mem.tokenizeScalar(u8, data, '\n');
285 while (it.next()) |line| try lines.append(alloc, try alloc.dupe(u8, line));
286 return lines;
287 }
288
289 pub fn freeLines(alloc: std.mem.Allocator, lines: *std.ArrayList([]u8)) void {
290 for (lines.items) |l| alloc.free(l);
291 lines.deinit(alloc);
292 } 184 }
293 185
294 /// Dedup is on the SPELLING, never on session identity: the same session 186 /// Dedup is on the SPELLING, never on session identity: the same session
@@ -303,40 +195,10 @@ pub fn record(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8)
303 return true; 195 return true;
304 } 196 }
305 197
306 /// The *From split is xdg.zig's pattern: setenv is unsafe in Zig tests.
307 pub fn statePath(alloc: std.mem.Allocator) ![]const u8 { 198 pub fn statePath(alloc: std.mem.Allocator) ![]const u8 {
308 return xdg.statePath(alloc, "wall"); 199 return xdg.statePath(alloc, "wall");
309 } 200 }
310 201
311 pub fn statePathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8 {
312 return xdg.pathFrom(alloc, xdg_state_home, home, ".local/state", "wall");
313 }
314
315 /// The layout sidecar sits beside the wall file; same *From split.
316 pub fn layoutPath(alloc: std.mem.Allocator) ![]const u8 {
317 return xdg.statePath(alloc, "layout");
318 }
319
320 pub fn layoutPathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8 {
321 return xdg.pathFrom(alloc, xdg_state_home, home, ".local/state", "layout");
322 }
323
324 /// One atomic writer for state files: `saveLines` and the layout sidecar
325 /// both go through it, so there is one temp+rename idiom and not two.
326 pub fn saveBytes(path: []const u8, bytes: []const u8) !void {
327 var write_buf: [4096]u8 = undefined;
328 var af = try std.fs.cwd().atomicFile(path, .{ .make_path = true, .write_buffer = &write_buf });
329 defer af.deinit();
330 try af.file_writer.interface.writeAll(bytes);
331 try af.finish();
332 }
333
334 /// Every failure is the same null: a caller degrades identically regardless
335 /// of why the layout was absent.
336 pub fn loadLayout(alloc: std.mem.Allocator, path: []const u8) ?[]u8 {
337 return std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch null;
338 }
339
340 test "Argv: bare words and both --sock dialects all become owned spellings" { 202 test "Argv: bare words and both --sock dialects all become owned spellings" {
341 var a = Argv{ .alloc = std.testing.allocator }; 203 var a = Argv{ .alloc = std.testing.allocator };
342 defer a.deinit(); 204 defer a.deinit();
@@ -384,42 +246,6 @@ test "Argv: a refusal records WHICH word and why; a flag-shaped word records not
384 try std.testing.expectEqual(@as(usize, 0), d.tiles.items.len); 246 try std.testing.expectEqual(@as(usize, 0), d.tiles.items.len);
385 } 247 }
386 248
387 test "spellingFromArgv: both --sock dialects reach the same spelling" {
388 const alloc = std.testing.allocator;
389 const argv = [_][:0]const u8{ "--sock", "/tmp/x.sock#b", "--sock /tmp/x.sock#b", "host#b", "quic://h:4433#b" };
390
391 // Two arguments joined, and one argument passed through: same string,
392 // which is the point — the state file only ever holds this one.
393 const joined = try spellingFromArgv(alloc, &argv, 0);
394 defer alloc.free(joined.spelling);
395 try std.testing.expectEqualStrings("--sock /tmp/x.sock#b", joined.spelling);
396 try std.testing.expectEqual(@as(usize, 2), joined.consumed);
397
398 const whole = try spellingFromArgv(alloc, &argv, 2);
399 defer alloc.free(whole.spelling);
400 try std.testing.expectEqualStrings("--sock /tmp/x.sock#b", whole.spelling);
401 try std.testing.expectEqual(@as(usize, 1), whole.consumed);
402
403 // #SESSION rides both paths intact, down to the split.
404 try std.testing.expectEqualStrings("b", (try parseSpelling(joined.spelling)).session);
405 try std.testing.expectEqualStrings("/tmp/x.sock", (try parseSpelling(whole.spelling)).spec.sock);
406
407 // Host and quic spellings are already whole; nothing is consumed after.
408 const host = try spellingFromArgv(alloc, &argv, 3);
409 defer alloc.free(host.spelling);
410 try std.testing.expectEqualStrings("host#b", host.spelling);
411 try std.testing.expectEqual(@as(usize, 1), host.consumed);
412 const q = try spellingFromArgv(alloc, &argv, 4);
413 defer alloc.free(q.spelling);
414 try std.testing.expectEqualStrings("quic://h:4433#b", q.spelling);
415 try std.testing.expectEqual(@as(usize, 1), q.consumed);
416 }
417
418 test "spellingFromArgv: a trailing --sock is a usage error, not a read off the end" {
419 const argv = [_][:0]const u8{ "host", "--sock" };
420 try std.testing.expectError(error.MissingSockPath, spellingFromArgv(std.testing.allocator, &argv, 1));
421 }
422
423 test "parseSpelling: three spellings classify; session splits at the LAST '#'" { 249 test "parseSpelling: three spellings classify; session splits at the LAST '#'" {
424 try std.testing.expectEqualStrings("box1", (try parseSpelling("box1")).spec.host); 250 try std.testing.expectEqualStrings("box1", (try parseSpelling("box1")).spec.host);
425 try std.testing.expectEqualStrings("", (try parseSpelling("box1")).session); 251 try std.testing.expectEqualStrings("", (try parseSpelling("box1")).session);
@@ -515,35 +341,6 @@ test "wall: save/load round-trip; missing file loads empty; bad line refuses" {
515 } 341 }
516 } 342 }
517 343
518 test "saveLines: a wall bigger than any stack buffer still round-trips" {
519 const testtmp = @import("testtmp");
520 const alloc = std.testing.allocator;
521 var tmp = try testtmp.TmpDir.make();
522 defer tmp.cleanup();
523 const path = try std.fmt.allocPrint(alloc, "{s}/bigwall", .{tmp.path()});
524 defer alloc.free(path);
525
526 // 200 lines of ~100 bytes each = ~20 KiB, well past an 8 KiB stack buffer.
527 var lines: std.ArrayList([]const u8) = .empty;
528 defer {
529 for (lines.items) |l| alloc.free(l);
530 lines.deinit(alloc);
531 }
532 for (0..200) |i| {
533 const line = try std.fmt.allocPrint(alloc, "host-{d:0>3}.example.com#session-{d:0>3}", .{ i, i });
534 try lines.append(alloc, line);
535 }
536 try saveLines(lines.items, path);
537
538 const back = try std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024);
539 defer alloc.free(back);
540 var it = std.mem.tokenizeScalar(u8, back, '\n');
541 var count: usize = 0;
542 while (it.next()) |_| count += 1;
543 try std.testing.expectEqual(@as(usize, 200), count);
544 try std.testing.expect(std.mem.indexOf(u8, back, "host-199.example.com#session-199") != null);
545 }
546
547 test "record: appends once per spelling, dedups byte-exactly, keeps order" { 344 test "record: appends once per spelling, dedups byte-exactly, keeps order" {
548 const testtmp = @import("testtmp"); 345 const testtmp = @import("testtmp");
549 const alloc = std.testing.allocator; 346 const alloc = std.testing.allocator;
@@ -586,45 +383,6 @@ test "record: an unwritable wall file is an error the caller may swallow" {
586 try std.testing.expectError(error.IsDir, record(alloc, path, "a#0")); 383 try std.testing.expectError(error.IsDir, record(alloc, path, "a#0"));
587 } 384 }
588 385
589 test "statePathFrom: XDG wins when set and non-empty, HOME default otherwise" {
590 const alloc = std.testing.allocator;
591 {
592 const p = try statePathFrom(alloc, "/xs", "/home/u");
593 defer alloc.free(p);
594 try std.testing.expectEqualStrings("/xs/mux/wall", p);
595 }
596 {
597 const p = try statePathFrom(alloc, "", "/home/u");
598 defer alloc.free(p);
599 try std.testing.expectEqualStrings("/home/u/.local/state/mux/wall", p);
600 }
601 try std.testing.expectError(error.NoHome, statePathFrom(alloc, null, null));
602 }
603
604 test "layout sidecar: save round-trips through load; a missing file is null" {
605 const testtmp = @import("testtmp");
606 const alloc = std.testing.allocator;
607 var tmp = try testtmp.TmpDir.make();
608 defer tmp.cleanup();
609 const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
610 defer alloc.free(path);
611 try std.testing.expect(loadLayout(alloc, path) == null);
612 try saveBytes(path, "mux-layout 1\nleaf 0 x\n");
613 const got = loadLayout(alloc, path) orelse return error.TestUnexpectedResult;
614 defer alloc.free(got);
615 try std.testing.expectEqualStrings("mux-layout 1\nleaf 0 x\n", got);
616 }
617
618 test "layoutPathFrom: sits beside the wall file" {
619 const alloc = std.testing.allocator;
620 const p = try layoutPathFrom(alloc, "/xdg", null);
621 defer alloc.free(p);
622 try std.testing.expectEqualStrings("/xdg/mux/layout", p);
623 const q = try layoutPathFrom(alloc, null, "/home/u");
624 defer alloc.free(q);
625 try std.testing.expectEqualStrings("/home/u/.local/state/mux/layout", q);
626 }
627
628 // Forces semantic analysis of every pub decl under `zig build test`, so an 386 // Forces semantic analysis of every pub decl under `zig build test`, so an
629 // unreferenced decl must at least compile (the silent-module-loss hazard, 387 // unreferenced decl must at least compile (the silent-module-loss hazard,
630 // decisions.md). 388 // decisions.md).
@@ -632,20 +390,6 @@ test {
632 std.testing.refAllDeclsRecursive(@This()); 390 std.testing.refAllDeclsRecursive(@This());
633 } 391 }
634 392
635 test "spellingFromArgv: a flag is not a target" {
636 // `mux wall -A host` used to make a tile for a host named `-A`, which
637 // then failed to resolve somewhere far from the typo. A wall has no
638 // per-tile agent flag at all — `-A` belongs to a single attach — so
639 // every flag-shaped argument here is the same mistake.
640 const argv = [_][:0]const u8{ "-A", "host", "--sock /tmp/x", "quic://h:1" };
641 try std.testing.expectError(error.FlagLikeTarget, spellingFromArgv(std.testing.allocator, &argv, 0));
642
643 // The two spellings that legitimately start with a dash still pass.
644 const whole = try spellingFromArgv(std.testing.allocator, &argv, 2);
645 defer std.testing.allocator.free(whole.spelling);
646 try std.testing.expectEqualStrings("--sock /tmp/x", whole.spelling);
647 }
648
649 test "parseSpelling: a HOST spelling is one word — the hub's POST body cannot smuggle a second" { 393 test "parseSpelling: a HOST spelling is one word — the hub's POST body cannot smuggle a second" {
650 // The hub's POST /tiles body is a spelling, and a HOST spelling becomes 394 // The hub's POST /tiles body is a spelling, and a HOST spelling becomes
651 // one argv word of `handoff.recipeFor`'s ssh line. hosts.zig refuses 395 // one argv word of `handoff.recipeFor`'s ssh line. hosts.zig refuses
src/tui/wall_host.zig
Old New
@@ -6,7 +6,6 @@
6 const std = @import("std"); 6 const std = @import("std");
7 const proto = @import("protocol"); 7 const proto = @import("protocol");
8 const client = @import("client"); 8 const client = @import("client");
9 const wall = @import("wall");
10 const hosts = @import("hosts"); 9 const hosts = @import("hosts");
11 const handoff = @import("handoff"); 10 const handoff = @import("handoff");
12 const wall_layout = @import("wall_layout.zig"); 11 const wall_layout = @import("wall_layout.zig");
@@ -129,7 +128,7 @@ pub fn addHost(
129 // The prompt is `mux hosts add` typed from inside, so it refuses what 128 // The prompt is `mux hosts add` typed from inside, so it refuses what
130 // that refuses: `-A box` is a mistyped flag, not a host. Ahead of the 129 // that refuses: `-A box` is a mistyped flag, not a host. Ahead of the
131 // dupe, so the answer to a typo allocates nothing. 130 // dupe, so the answer to a typo allocates nothing.
132 if (wall.flagLike(spelling)) { 131 if (hosts.flagLike(spelling)) {
133 badHost(shared, error.FlagLikeTarget); 132 badHost(shared, error.FlagLikeTarget);
134 return .refused; 133 return .refused;
135 } 134 }
src/tui/wall_layout.zig
Old New
@@ -4,7 +4,7 @@
4 //! per leaf against the hosts' live lists. 4 //! per leaf against the hosts' live lists.
5 const std = @import("std"); 5 const std = @import("std");
6 const proto = @import("protocol"); 6 const proto = @import("protocol");
7 const wall = @import("wall"); 7 const hosts = @import("hosts");
8 const interact = @import("interact"); 8 const interact = @import("interact");
9 const layout = @import("layout"); 9 const layout = @import("layout");
10 const wall_host = @import("wall_host.zig"); 10 const wall_host = @import("wall_host.zig");
@@ -284,6 +284,12 @@ pub fn restoreLayout(
284 return true; 284 return true;
285 } 285 }
286 286
287 /// Every failure is the same null: a caller degrades the same way whatever
288 /// kept the layout from arriving.
289 pub fn loadLayout(alloc: std.mem.Allocator, path: []const u8) ?[]u8 {
290 return std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch null;
291 }
292
287 /// `serialize` indexes spellings by leaf ID (tile index), so the array is 293 /// `serialize` indexes spellings by leaf ID (tile index), so the array is
288 /// tile-indexed: holes get "" and are never serialized (the tree dropped 294 /// tile-indexed: holes get "" and are never serialized (the tree dropped
289 /// them). A failed write prints one stderr line and returns. 295 /// them). A failed write prints one stderr line and returns.
@@ -308,7 +314,7 @@ pub fn saveLayoutTo(
308 std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)}); 314 std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)});
309 return; 315 return;
310 }; 316 };
311 wall.saveBytes(path, buf.items) catch |err| { 317 hosts.saveBytes(path, buf.items) catch |err| {
312 std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)}); 318 std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)});
313 }; 319 };
314 } 320 }
@@ -319,7 +325,7 @@ pub fn saveSidecar(w: Wall) void {
319 // a tree it saved would be a tree the next TERMINAL restores over the 325 // a tree it saved would be a tree the next TERMINAL restores over the
320 // aspect rule. See `restoreSidecar` for the cost of the other half. 326 // aspect rule. See `restoreSidecar` for the cost of the other half.
321 if (!w.shared.is_tty) return; 327 if (!w.shared.is_tty) return;
322 const path = wall.layoutPath(w.alloc) catch |err| { 328 const path = hosts.layoutPath(w.alloc) catch |err| {
323 std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)}); 329 std.debug.print("mux: wall layout not saved: {s}\n", .{@errorName(err)});
324 return; 330 return;
325 }; 331 };
@@ -340,9 +346,9 @@ pub fn restoreSidecar(w: Wall, focus_out: *?usize) ?void {
340 // that was already the right shape. The CLAIM is innocent; it stopped 346 // that was already the right shape. The CLAIM is innocent; it stopped
341 // sending a resize (see the pump's focus-claim block). 347 // sending a resize (see the pump's focus-claim block).
342 if (!w.shared.is_tty) return null; 348 if (!w.shared.is_tty) return null;
343 const path = wall.layoutPath(w.alloc) catch return null; 349 const path = hosts.layoutPath(w.alloc) catch return null;
344 defer w.alloc.free(path); 350 defer w.alloc.free(path);
345 const bytes = wall.loadLayout(w.alloc, path) orelse return null; 351 const bytes = loadLayout(w.alloc, path) orelse return null;
346 defer w.alloc.free(bytes); 352 defer w.alloc.free(bytes);
347 return restoreLayoutFrom(w, bytes, focus_out); 353 return restoreLayoutFrom(w, bytes, focus_out);
348 } 354 }
src/tui/wall_test_host.zig
Old New
@@ -174,7 +174,7 @@ test "recordHost: a file it cannot write comes BACK — the wall may be on the a
174 // out loud, and WHERE it may say it depends on which caller it is: 174 // out loud, and WHERE it may say it depends on which caller it is:
175 // stderr before the screen is taken, a notice after. 175 // stderr before the screen is taken, a notice after.
176 const blocker = try std.fmt.bufPrint(&blocker_buf, "{s}/notadir", .{tmp.path()}); 176 const blocker = try std.fmt.bufPrint(&blocker_buf, "{s}/notadir", .{tmp.path()});
177 try wall.saveBytes(blocker, "x"); 177 try hosts.saveBytes(blocker, "x");
178 const bad = try std.fmt.bufPrint(&bad_buf, "{s}/notadir/hosts", .{tmp.path()}); 178 const bad = try std.fmt.bufPrint(&bad_buf, "{s}/notadir/hosts", .{tmp.path()});
179 const ok = try std.fmt.bufPrint(&ok_buf, "{s}/hosts", .{tmp.path()}); 179 const ok = try std.fmt.bufPrint(&ok_buf, "{s}/hosts", .{tmp.path()});
180 try std.testing.expect(wall_host.recordHost(alloc, .{ .sock = "/a" }, "--sock /a", bad) != null); 180 try std.testing.expect(wall_host.recordHost(alloc, .{ .sock = "/a" }, "--sock /a", bad) != null);
@@ -614,7 +614,7 @@ test "otherHosts: the dialled host is not tiled twice, and the rest follow in fi
614 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()}); 614 const path = try std.fmt.bufPrint(&buf, "{s}/hosts", .{tmp.path()});
615 // Off-origin: the host the user dialled is the file's SECOND line, so a 615 // Off-origin: the host the user dialled is the file's SECOND line, so a
616 // skip that only ever worked on line 0 is caught here. 616 // skip that only ever worked on line 0 is caught here.
617 try wall.saveBytes(path, "--sock /a\n--sock /b\n"); 617 try hosts.saveBytes(path, "--sock /a\n--sock /b\n");
618 618
619 var arena_state = std.heap.ArenaAllocator.init(alloc); 619 var arena_state = std.heap.ArenaAllocator.init(alloc);
620 defer arena_state.deinit(); 620 defer arena_state.deinit();
@@ -629,7 +629,7 @@ test "otherHosts: the dialled host is not tiled twice, and the rest follow in fi
629 629
630 // A file the grammar refuses costs the user the WALL, never the session 630 // A file the grammar refuses costs the user the WALL, never the session
631 // they asked for: the entry host stands alone and the attach goes on. 631 // they asked for: the entry host stands alone and the attach goes on.
632 try wall.saveBytes(path, "--sock /a\nbox#old\n"); 632 try hosts.saveBytes(path, "--sock /a\nbox#old\n");
633 var one: std.ArrayList(HostSpec) = .empty; 633 var one: std.ArrayList(HostSpec) = .empty;
634 try one.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } }); 634 try one.append(arena, .{ .spelling = "--sock /b", .target = .{ .sock = "/b" }, .poll_target = .{ .sock = "/b" } });
635 wall_host.otherHosts(arena, &one, "--sock /b", path, null, 0); 635 wall_host.otherHosts(arena, &one, "--sock /b", path, null, 0);
src/tui/wall_test_layout.zig
Old New
@@ -1,7 +1,7 @@
1 //! The pane tree, the rects it cuts and the layout sidecar (wall_layout.zig). 1 //! The pane tree, the rects it cuts and the layout sidecar (wall_layout.zig).
2 const std = @import("std"); 2 const std = @import("std");
3 const proto = @import("protocol"); 3 const proto = @import("protocol");
4 const wall = @import("wall"); 4 const hosts = @import("hosts");
5 const layout = @import("layout"); 5 const layout = @import("layout");
6 const TmpDir = @import("testtmp").TmpDir; 6 const TmpDir = @import("testtmp").TmpDir;
7 const fixture = @import("wall_test_harness.zig"); 7 const fixture = @import("wall_test_harness.zig");
@@ -648,7 +648,7 @@ test "saveLayoutTo writes the sidecar for the present tiles, spellings verbatim"
648 } 648 }
649 var present = [_]bool{ true, true }; 649 var present = [_]bool{ true, true };
650 wall_layout.saveLayoutTo(alloc, path, tiles, &present, &shared); 650 wall_layout.saveLayoutTo(alloc, path, tiles, &present, &shared);
651 const got = wall.loadLayout(alloc, path) orelse return error.TestUnexpectedResult; 651 const got = wall_layout.loadLayout(alloc, path) orelse return error.TestUnexpectedResult;
652 defer alloc.free(got); 652 defer alloc.free(got);
653 try std.testing.expect(std.mem.startsWith(u8, got, "mux-layout 1\nbeside 0\n")); 653 try std.testing.expect(std.mem.startsWith(u8, got, "mux-layout 1\nbeside 0\n"));
654 try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#b\n") != null); 654 try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#b\n") != null);
@@ -698,7 +698,7 @@ test "saveLayoutTo handles a vanished middle tile without panicking" {
698 } 698 }
699 var present = [_]bool{ true, false, true }; 699 var present = [_]bool{ true, false, true };
700 wall_layout.saveLayoutTo(alloc, path, tiles, &present, &shared); 700 wall_layout.saveLayoutTo(alloc, path, tiles, &present, &shared);
701 const got = wall.loadLayout(alloc, path) orelse return error.TestUnexpectedResult; 701 const got = wall_layout.loadLayout(alloc, path) orelse return error.TestUnexpectedResult;
702 defer alloc.free(got); 702 defer alloc.free(got);
703 // Tile 2's label is verbatim; the hole (tile 1) is never serialized. 703 // Tile 2's label is verbatim; the hole (tile 1) is never serialized.
704 try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#c\n") != null); 704 try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#c\n") != null);
@@ -914,3 +914,26 @@ test "restore: focus_out maps the saved focus through the spelling match" {
914 try std.testing.expectEqual(@as(?u8, null), focus_out); 914 try std.testing.expectEqual(@as(?u8, null), focus_out);
915 } 915 }
916 } 916 }
917
918 test "layout sidecar: save round-trips through load; a missing file is null" {
919 const alloc = std.testing.allocator;
920 var tmp = try TmpDir.make();
921 defer tmp.cleanup();
922 const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path()});
923 defer alloc.free(path);
924 try std.testing.expect(wall_layout.loadLayout(alloc, path) == null);
925 try hosts.saveBytes(path, "mux-layout 1\nleaf 0 x\n");
926 const got = wall_layout.loadLayout(alloc, path) orelse return error.TestUnexpectedResult;
927 defer alloc.free(got);
928 try std.testing.expectEqualStrings("mux-layout 1\nleaf 0 x\n", got);
929 }
930
931 test "layoutPathFrom: the sidecar sits beside the hosts file" {
932 const alloc = std.testing.allocator;
933 const p = try hosts.layoutPathFrom(alloc, "/xdg", null);
934 defer alloc.free(p);
935 try std.testing.expectEqualStrings("/xdg/mux/layout", p);
936 const q = try hosts.layoutPathFrom(alloc, null, "/home/u");
937 defer alloc.free(q);
938 try std.testing.expectEqualStrings("/home/u/.local/state/mux/layout", q);
939 }
src/tui/wallview.zig
Old New
@@ -20,7 +20,6 @@
20 const std = @import("std"); 20 const std = @import("std");
21 const proto = @import("protocol"); 21 const proto = @import("protocol");
22 const client = @import("client"); 22 const client = @import("client");
23 const wall = @import("wall");
24 const hosts = @import("hosts"); 23 const hosts = @import("hosts");
25 const handoff = @import("handoff"); 24 const handoff = @import("handoff");
26 const proxy = @import("proxy"); 25 const proxy = @import("proxy");