a73x

75465356

feat: wall.zig — spelling grammar, wall model, atomic persistence

a73x   2026-08-18 17:58

Commit message
feat: wall.zig — spelling grammar, wall model, atomic persistence

build.zig
Old New
@@ -186,6 +186,11 @@ 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/cmd.zig", .layer = 1, .imports = &.{ "engine", "protocol" } }, 188 .{ .name = "cmd", .path = "src/cmd.zig", .layer = 1, .imports = &.{ "engine", "protocol" } },
189 // The wall: the TARGET spelling grammar, the ordered list, and the
190 // state file behind them. One owner for a grammar the hub, the state
191 // file and later the CLI all have to agree on — protocol and nothing
192 // else, so its tests need no hub, no daemon and no socket.
193 .{ .name = "wall", .path = "src/wall.zig", .layer = 1, .imports = &.{"protocol"}, .test_imports = &.{"testtmp"} },
189 // Shell integration: the OSC 133 mark scripts and what a spawn must add 194 // Shell integration: the OSC 133 mark scripts and what a spawn must add
190 // to hand them to a shell. Near-leaf on purpose — it writes files and 195 // to hand them to a shell. Near-leaf on purpose — it writes files and
191 // reads the environment, and knows nothing of ptys, servers or the 196 // reads the environment, and knows nothing of ptys, servers or the
@@ -393,12 +398,12 @@ fn shellGate(b: *std.Build, step: *std.Build.Step) void {
393 /// escape pins. mux and exe are executable roots but carry the argument 398 /// escape pins. mux and exe are executable roots but carry the argument
394 /// parsers — a test that is never built is not a test (decisions.md). 399 /// parsers — a test that is never built is not a test (decisions.md).
395 const test_order = [_][]const u8{ 400 const test_order = [_][]const u8{
396 "script", "protocol", "client_core", "engine", "pty", "delta", 401 "script", "protocol", "client_core", "engine", "pty", "delta",
397 "cmd", "shellint", "replica", "keymap", "webhub", "sockpath", 402 "cmd", "wall", "shellint", "replica", "keymap", "webhub",
398 "muxa", "server", "client", "proxy", "mux", "quic", 403 "sockpath", "muxa", "server", "client", "proxy", "mux",
399 "quic_server", "exe", "testtmp", "quic_client", "predict", "rawmode", 404 "quic", "quic_server", "exe", "testtmp", "quic_client", "predict",
400 "delaypipe", "xdg", "spawn", "handoff", "paint", "render", 405 "rawmode", "delaypipe", "xdg", "spawn", "handoff", "paint",
401 "ptyclient", "webhub_main", "wsclient", 406 "render", "ptyclient", "webhub_main", "wsclient",
402 }; 407 };
403 408
404 comptime { 409 comptime {
src/wall.zig
Old New
@@ -0,0 +1,275 @@
1 //! The wall: an ordered list of TARGET spellings, shared by muxweb today
2 //! and the mux CLI later — one owner for the spelling grammar, the
3 //! session split, and the persisted file, so the wall built in a browser
4 //! is the wall the CLI sees.
5 //!
6 //! Spelling grammar (one string; also the line format of the state file
7 //! and the body of the hub's POST /tiles):
8 //! HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION]
9 //! The session splits at the LAST '#' because validSessionName refuses
10 //! '#', so any earlier one belongs to the target's own spelling.
11 //!
12 //! The file is `$XDG_STATE_HOME/mux/wall`, one spelling per line, order
13 //! is wall order. Every mutation rewrites it atomically (temp + rename);
14 //! two concurrent writers resolve as last-rename-wins, acceptable for a
15 //! single user's state file. "Atomic" is writer-vs-writer only: `save`
16 //! does not fsync the file or its directory, so a crash at the wrong
17 //! moment can still leave the rename torn on some filesystems — no
18 //! stronger, crash-durability claim is made here.
19 const std = @import("std");
20 const proto = @import("protocol");
21
22 pub const Spec = union(enum) {
23 sock: []const u8,
24 host: []const u8,
25 quic: []const u8,
26 };
27
28 pub const Parsed = struct { spec: Spec, session: []const u8 };
29 pub const ParseError = error{ BadSession, EmptySpec, BadByte };
30
31 /// Splits and classifies one spelling. Refuses here, at usage altitude,
32 /// what would otherwise surface as a rejected attach far from the typo:
33 /// a malformed session name, or a spelling whose target part is empty
34 /// (`#b`, `quic://`, `--sock #b`).
35 ///
36 /// The returned slices BORROW from `line` — nothing is copied, so a
37 /// caller that keeps a Parsed must keep the string it parsed.
38 pub fn parseSpelling(line: []const u8) ParseError!Parsed {
39 // No control byte anywhere, checked before the split so it covers the
40 // target part too (validSessionName already refuses them after '#').
41 // A '\n' is the sharp one: the file is one spelling per line, so such
42 // a spelling would be WRITTEN as one tile and LOADED as two — the
43 // writer/loader identity, and with it the one-grammar contract, gone.
44 // Reachable from argv and from the browser's POST body, so it is
45 // refused in the grammar rather than at either mouth.
46 for (line) |c| if (c < 0x20) return error.BadByte;
47 var spec_str = line;
48 var session: []const u8 = "";
49 if (std.mem.lastIndexOfScalar(u8, line, '#')) |hash| {
50 const name = line[hash + 1 ..];
51 if (!proto.validSessionName(name)) return error.BadSession;
52 spec_str = line[0..hash];
53 session = name;
54 }
55 const sock_prefix = "--sock ";
56 const quic_prefix = "quic://";
57 if (std.mem.startsWith(u8, spec_str, sock_prefix)) {
58 const path = spec_str[sock_prefix.len..];
59 if (path.len == 0) return error.EmptySpec;
60 return .{ .spec = .{ .sock = path }, .session = session };
61 }
62 if (std.mem.startsWith(u8, spec_str, quic_prefix)) {
63 const hp = spec_str[quic_prefix.len..];
64 if (hp.len == 0) return error.EmptySpec;
65 return .{ .spec = .{ .quic = hp }, .session = session };
66 }
67 if (spec_str.len == 0) return error.EmptySpec;
68 return .{ .spec = .{ .host = spec_str }, .session = session };
69 }
70
71 pub const Wall = struct {
72 /// Owned copies, wall order. The spelling IS the label downstream.
73 targets: std.ArrayList([]u8) = .empty,
74
75 pub fn deinit(self: *Wall, alloc: std.mem.Allocator) void {
76 for (self.targets.items) |t| alloc.free(t);
77 self.targets.deinit(alloc);
78 }
79
80 /// Validates, then appends. Returns the new entry's index.
81 pub fn add(self: *Wall, alloc: std.mem.Allocator, spelling: []const u8) !usize {
82 _ = try parseSpelling(spelling);
83 const copy = try alloc.dupe(u8, spelling);
84 errdefer alloc.free(copy);
85 try self.targets.append(alloc, copy);
86 return self.targets.items.len - 1;
87 }
88
89 pub fn remove(self: *Wall, alloc: std.mem.Allocator, idx: usize) void {
90 alloc.free(self.targets.orderedRemove(idx));
91 }
92
93 /// `order` must be an exact permutation of 0..len — anything else is
94 /// the caller working from a stale view, refused so it can refetch.
95 pub fn reorder(self: *Wall, alloc: std.mem.Allocator, order: []const usize) error{ BadOrder, OutOfMemory }!void {
96 const n = self.targets.items.len;
97 if (order.len != n) return error.BadOrder;
98 var seen = try alloc.alloc(bool, n);
99 defer alloc.free(seen);
100 @memset(seen, false);
101 for (order) |i| {
102 if (i >= n or seen[i]) return error.BadOrder;
103 seen[i] = true;
104 }
105 const old = try alloc.dupe([]u8, self.targets.items);
106 defer alloc.free(old);
107 for (order, 0..) |src, dst| self.targets.items[dst] = old[src];
108 }
109 };
110
111 /// Missing file is an empty wall, not an error: first run has no state.
112 /// A line that no longer parses (edited by hand) is refused loudly —
113 /// error, not skip — because silently dropping a tile the user wrote
114 /// down is worse than making them fix the line.
115 pub fn load(alloc: std.mem.Allocator, path: []const u8) !Wall {
116 var w = Wall{};
117 errdefer w.deinit(alloc);
118 const data = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |err| switch (err) {
119 error.FileNotFound => return w,
120 else => return err,
121 };
122 defer alloc.free(data);
123 // tokenize, not split: `save` ends every line with '\n', so a split
124 // would hand the trailing empty string to `add` and make every
125 // round-trip fail EmptySpec. Blank lines are skipped for free.
126 var it = std.mem.tokenizeScalar(u8, data, '\n');
127 while (it.next()) |line| _ = try w.add(alloc, line);
128 return w;
129 }
130
131 pub fn save(w: *const Wall, path: []const u8) !void {
132 var write_buf: [4096]u8 = undefined;
133 var af = try std.fs.cwd().atomicFile(path, .{ .make_path = true, .write_buffer = &write_buf });
134 defer af.deinit();
135 for (w.targets.items) |t| {
136 try af.file_writer.interface.writeAll(t);
137 try af.file_writer.interface.writeAll("\n");
138 }
139 try af.finish();
140 }
141
142 /// `$XDG_STATE_HOME/mux/wall`, defaulting to `~/.local/state/mux/wall`.
143 /// The *From split is xdg.zig's pattern for the same reason: setenv is
144 /// unsafe in-process for Zig tests.
145 pub fn statePath(alloc: std.mem.Allocator) ![]const u8 {
146 return statePathFrom(alloc, std.posix.getenv("XDG_STATE_HOME"), std.posix.getenv("HOME"));
147 }
148
149 pub fn statePathFrom(
150 alloc: std.mem.Allocator,
151 xdg_state_home: ?[]const u8,
152 home: ?[]const u8,
153 ) ![]const u8 {
154 if (xdg_state_home) |d| if (d.len > 0)
155 return std.fmt.allocPrint(alloc, "{s}/mux/wall", .{d});
156 const h = home orelse return error.NoHome;
157 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/wall", .{h});
158 }
159
160 test "parseSpelling: three spellings classify; session splits at the LAST '#'" {
161 try std.testing.expectEqualStrings("box1", (try parseSpelling("box1")).spec.host);
162 try std.testing.expectEqualStrings("", (try parseSpelling("box1")).session);
163 try std.testing.expectEqualStrings("h:4433", (try parseSpelling("quic://h:4433#b")).spec.quic);
164 try std.testing.expectEqualStrings("b", (try parseSpelling("quic://h:4433#b")).session);
165 try std.testing.expectEqualStrings("/tmp/x", (try parseSpelling("--sock /tmp/x#b")).spec.sock);
166 // The LAST '#': earlier ones belong to the target's own spelling.
167 try std.testing.expectEqualStrings("a#b", (try parseSpelling("a#b#c")).spec.host);
168 try std.testing.expectEqualStrings("c", (try parseSpelling("a#b#c")).session);
169 }
170
171 test "parseSpelling: refusals — bad session, empty spec in every spelling" {
172 try std.testing.expectError(error.BadSession, parseSpelling("host#has space"));
173 try std.testing.expectError(error.BadSession, parseSpelling("host#")); // empty name is not typeable
174 try std.testing.expectError(error.EmptySpec, parseSpelling("#b"));
175 try std.testing.expectError(error.EmptySpec, parseSpelling("quic://"));
176 try std.testing.expectError(error.EmptySpec, parseSpelling("--sock #b"));
177 try std.testing.expectError(error.EmptySpec, parseSpelling(""));
178 // Control bytes: '\n' would break the one-spelling-per-line file in
179 // two, and there is no target spelling the rest of them belong in.
180 try std.testing.expectError(error.BadByte, parseSpelling("a\nb"));
181 try std.testing.expectError(error.BadByte, parseSpelling("a\tb"));
182 try std.testing.expectError(error.BadByte, parseSpelling("a\rb"));
183 try std.testing.expectError(error.BadByte, parseSpelling("host#s\nevil"));
184 }
185
186 test "wall: add validates, remove frees, reorder is permutation-or-refused" {
187 const alloc = std.testing.allocator;
188 var w = Wall{};
189 defer w.deinit(alloc);
190 _ = try w.add(alloc, "a");
191 _ = try w.add(alloc, "b#s");
192 _ = try w.add(alloc, "quic://c:1");
193 try std.testing.expectError(error.BadSession, w.add(alloc, "d#bad name"));
194 // The add gate is why `load` can never meet a multi-line entry: a
195 // spelling holding '\n' never reaches the file to be split by it.
196 try std.testing.expectError(error.BadByte, w.add(alloc, "d\ne"));
197 try std.testing.expectEqual(@as(usize, 3), w.targets.items.len);
198
199 try w.reorder(alloc, &.{ 2, 0, 1 });
200 try std.testing.expectEqualStrings("quic://c:1", w.targets.items[0]);
201 try std.testing.expectEqualStrings("a", w.targets.items[1]);
202 // Stale views are refused, not guessed at: wrong length, dup, range.
203 try std.testing.expectError(error.BadOrder, w.reorder(alloc, &.{ 0, 1 }));
204 try std.testing.expectError(error.BadOrder, w.reorder(alloc, &.{ 0, 0, 1 }));
205 try std.testing.expectError(error.BadOrder, w.reorder(alloc, &.{ 0, 1, 3 }));
206
207 w.remove(alloc, 1);
208 try std.testing.expectEqual(@as(usize, 2), w.targets.items.len);
209 try std.testing.expectEqualStrings("b#s", w.targets.items[1]);
210 }
211
212 test "wall: save/load round-trip; missing file loads empty; bad line refuses" {
213 const testtmp = @import("testtmp");
214 const alloc = std.testing.allocator;
215 var tmp = try testtmp.TmpDir.make();
216 defer tmp.cleanup();
217
218 const path = try std.fmt.allocPrint(alloc, "{s}/deep/wall", .{tmp.path()});
219 defer alloc.free(path);
220
221 {
222 var missing = try load(alloc, path);
223 defer missing.deinit(alloc);
224 try std.testing.expectEqual(@as(usize, 0), missing.targets.items.len);
225 }
226 {
227 var w = Wall{};
228 defer w.deinit(alloc);
229 _ = try w.add(alloc, "a#s");
230 _ = try w.add(alloc, "--sock /tmp/x");
231 try save(&w, path); // .make_path: the deep/ parent did not exist
232 }
233 {
234 var r = try load(alloc, path);
235 defer r.deinit(alloc);
236 try std.testing.expectEqual(@as(usize, 2), r.targets.items.len);
237 try std.testing.expectEqualStrings("a#s", r.targets.items[0]);
238 try std.testing.expectEqualStrings("--sock /tmp/x", r.targets.items[1]);
239 }
240 // A hand-edited line that no longer parses refuses the whole load.
241 try tmp.dir.writeFile(.{ .sub_path = "deep/wall", .data = "ok\nbad name#x y\n" });
242 try std.testing.expectError(error.BadSession, load(alloc, path));
243
244 // The BadByte refusal is per-LINE, so it cannot change what the
245 // tokenizer means: blank lines are still skipped, not refused.
246 try tmp.dir.writeFile(.{ .sub_path = "deep/wall", .data = "a\n\n\nb#s\n" });
247 {
248 var r = try load(alloc, path);
249 defer r.deinit(alloc);
250 try std.testing.expectEqual(@as(usize, 2), r.targets.items.len);
251 try std.testing.expectEqualStrings("b#s", r.targets.items[1]);
252 }
253 }
254
255 test "statePathFrom: XDG wins when set and non-empty, HOME default otherwise" {
256 const alloc = std.testing.allocator;
257 {
258 const p = try statePathFrom(alloc, "/xs", "/home/u");
259 defer alloc.free(p);
260 try std.testing.expectEqualStrings("/xs/mux/wall", p);
261 }
262 {
263 const p = try statePathFrom(alloc, "", "/home/u");
264 defer alloc.free(p);
265 try std.testing.expectEqualStrings("/home/u/.local/state/mux/wall", p);
266 }
267 try std.testing.expectError(error.NoHome, statePathFrom(alloc, null, null));
268 }
269
270 // Forces semantic analysis of every pub decl under `zig build test`, so an
271 // unreferenced decl must at least compile (the silent-module-loss hazard,
272 // decisions.md).
273 test {
274 std.testing.refAllDeclsRecursive(@This());
275 }