75465356
feat: wall.zig — spelling grammar, wall model, atomic persistence
a73x 2026-08-18 17:58
Commit message
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 | } | ||