ccd8f101
feat: hosts.zig — the wall file lists daemons, never sessions
a73x 2026-08-28 19:53
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -238,6 +238,11 @@ const mod_table = [_]ModSpec{ | |||
| 238 | // under it. | 238 | // under it. |
| 239 | .{ .name = "ptyclient", .path = "test/ptyclient.zig", .layer = 1, .link_libc = true, .imports = &.{ "pty", "script" } }, | 239 | .{ .name = "ptyclient", .path = "test/ptyclient.zig", .layer = 1, .link_libc = true, .imports = &.{ "pty", "script" } }, |
| 240 | // ---- layer 2 ---- | 240 | // ---- layer 2 ---- |
| 241 | // The host file: the wall as a list of DAEMONS. It borrows wall's argv | ||
| 242 | // grammar rather than forking a second one, and wall is layer 1 — so | ||
| 243 | // this sits at 2, above its lender and below every consumer, instead | ||
| 244 | // of flattening the stratum it depends on. | ||
| 245 | .{ .name = "hosts", .path = "src/hosts.zig", .layer = 2, .imports = &.{"wall"}, .test_imports = &.{"testtmp"} }, | ||
| 241 | // Everything that happens between a user at a terminal and one already | 246 | // Everything that happens between a user at a terminal and one already |
| 242 | // open session: the chord table, the wheel splitter, prediction, the | 247 | // open session: the chord table, the wheel splitter, prediction, the |
| 243 | // side channels, terminal ownership. It sits BELOW client because it | 248 | // side channels, terminal ownership. It sits BELOW client because it |
| @@ -618,12 +623,12 @@ fn docGate(b: *std.Build, target: std.Build.ResolvedTarget, check_step: *std.Bui | |||
| 618 | /// escape pins. mux and exe are executable roots but carry the argument | 623 | /// escape pins. mux and exe are executable roots but carry the argument |
| 619 | /// parsers — a test that is never built is not a test (decisions.md). | 624 | /// parsers — a test that is never built is not a test (decisions.md). |
| 620 | const test_order = [_][]const u8{ | 625 | const test_order = [_][]const u8{ |
| 621 | "script", "select", "protocol", "client_core", "interact", "engine", "pty", | 626 | "script", "select", "protocol", "client_core", "interact", "engine", "pty", |
| 622 | "delta", "cmd", "wall", "upgrade", "shellint", "replica", "keymap", | 627 | "delta", "cmd", "wall", "hosts", "upgrade", "shellint", "replica", |
| 623 | "webhub", "wallview", "sockpath", "muxa", "server", "client", "proxy", | 628 | "keymap", "webhub", "wallview", "sockpath", "muxa", "server", "client", |
| 624 | "mux", "quic", "quic_server", "exe", "testtmp", "quic_client", "predict", | 629 | "proxy", "mux", "quic", "quic_server", "exe", "testtmp", "quic_client", |
| 625 | "rawmode", "delaypipe", "xdg", "spawn", "handoff", "paint", "layout", | 630 | "predict", "rawmode", "delaypipe", "xdg", "spawn", "handoff", "paint", |
| 626 | "render", "ptyclient", "webhub_main", "wsclient", "cliflags", | 631 | "layout", "render", "ptyclient", "webhub_main", "wsclient", "cliflags", |
| 627 | }; | 632 | }; |
| 628 | 633 | ||
| 629 | comptime { | 634 | comptime { |
docscheck.budget
| Old | New | ||
|---|---|---|---|
| @@ -7,6 +7,7 @@ docscheck.zig 0 | |||
| 7 | engine.zig 0 | 7 | engine.zig 0 |
| 8 | flags.zig 0 | 8 | flags.zig 0 |
| 9 | handoff.zig 0 | 9 | handoff.zig 0 |
| 10 | hosts.zig 0 | ||
| 10 | interact.zig 0 | 11 | interact.zig 0 |
| 11 | keymap.zig 0 | 12 | keymap.zig 0 |
| 12 | layout.zig 761 | 13 | layout.zig 761 |
src/hosts.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,231 @@ | |||
| 1 | //! The wall: an ordered list of DAEMONS, one per line of | ||
| 2 | //! `$XDG_STATE_HOME/mux/hosts` — `--sock PATH` | `HOST` | | ||
| 3 | //! `quic://HOST[:PORT]`. Tiles are whatever those daemons have live, so | ||
| 4 | //! nothing here names a session and nothing here can resurrect one. | ||
| 5 | //! Strict on load: a host line is authored intent. | ||
| 6 | const std = @import("std"); | ||
| 7 | const wall = @import("wall"); | ||
| 8 | |||
| 9 | pub const Spec = union(enum) { sock: []const u8, host: []const u8, quic: []const u8 }; | ||
| 10 | pub const ParseError = error{ HasSession, EmptySpec, BadByte }; | ||
| 11 | |||
| 12 | pub fn parse(line: []const u8) ParseError!Spec { | ||
| 13 | for (line) |b| if (b < 0x20 or b == 0x7f) return error.BadByte; | ||
| 14 | if (std.mem.indexOfScalar(u8, line, '#') != null) return error.HasSession; | ||
| 15 | if (std.mem.startsWith(u8, line, "--sock ")) { | ||
| 16 | const p = line["--sock ".len..]; | ||
| 17 | return if (p.len == 0) error.EmptySpec else .{ .sock = p }; | ||
| 18 | } | ||
| 19 | if (std.mem.startsWith(u8, line, "quic://")) { | ||
| 20 | const h = line["quic://".len..]; | ||
| 21 | return if (h.len == 0) error.EmptySpec else .{ .quic = h }; | ||
| 22 | } | ||
| 23 | return if (line.len == 0) error.EmptySpec else .{ .host = line }; | ||
| 24 | } | ||
| 25 | |||
| 26 | pub fn reason(err: anyerror) []const u8 { | ||
| 27 | return switch (err) { | ||
| 28 | error.HasSession => "names a session after '#': the wall lists daemons and shows every session they have", | ||
| 29 | error.EmptySpec => "empty host", | ||
| 30 | error.BadByte => "control byte in host", | ||
| 31 | error.MissingSockPath => "names no path", | ||
| 32 | error.SockPathTooLong => "socket path too long to bind", | ||
| 33 | else => @errorName(err), | ||
| 34 | }; | ||
| 35 | } | ||
| 36 | |||
| 37 | pub const Hosts = struct { | ||
| 38 | lines: std.ArrayList([]u8) = .empty, | ||
| 39 | |||
| 40 | pub fn deinit(self: *Hosts, alloc: std.mem.Allocator) void { | ||
| 41 | for (self.lines.items) |l| alloc.free(l); | ||
| 42 | self.lines.deinit(alloc); | ||
| 43 | } | ||
| 44 | |||
| 45 | pub fn has(self: *const Hosts, spelling: []const u8) bool { | ||
| 46 | for (self.lines.items) |l| if (std.mem.eql(u8, l, spelling)) return true; | ||
| 47 | return false; | ||
| 48 | } | ||
| 49 | |||
| 50 | /// False when it was already listed; the file is a set in list order. | ||
| 51 | pub fn add(self: *Hosts, alloc: std.mem.Allocator, spelling: []const u8) !bool { | ||
| 52 | _ = try parse(spelling); | ||
| 53 | if (self.has(spelling)) return false; | ||
| 54 | try self.lines.append(alloc, try alloc.dupe(u8, spelling)); | ||
| 55 | return true; | ||
| 56 | } | ||
| 57 | |||
| 58 | pub fn remove(self: *Hosts, alloc: std.mem.Allocator, spelling: []const u8) bool { | ||
| 59 | for (self.lines.items, 0..) |l, i| { | ||
| 60 | if (std.mem.eql(u8, l, spelling)) { | ||
| 61 | alloc.free(self.lines.orderedRemove(i)); | ||
| 62 | return true; | ||
| 63 | } | ||
| 64 | } | ||
| 65 | return false; | ||
| 66 | } | ||
| 67 | }; | ||
| 68 | |||
| 69 | pub fn load(alloc: std.mem.Allocator, path: []const u8) !Hosts { | ||
| 70 | var h: Hosts = .{}; | ||
| 71 | errdefer h.deinit(alloc); | ||
| 72 | const bytes = std.fs.cwd().readFileAlloc(alloc, path, 1 << 20) catch |e| switch (e) { | ||
| 73 | error.FileNotFound => return h, | ||
| 74 | else => return e, | ||
| 75 | }; | ||
| 76 | defer alloc.free(bytes); | ||
| 77 | var it = std.mem.splitScalar(u8, bytes, '\n'); | ||
| 78 | while (it.next()) |line| { | ||
| 79 | if (line.len == 0) continue; | ||
| 80 | _ = try h.add(alloc, line); | ||
| 81 | } | ||
| 82 | return h; | ||
| 83 | } | ||
| 84 | |||
| 85 | pub fn save(h: *const Hosts, path: []const u8) !void { | ||
| 86 | var buf: std.ArrayList(u8) = .empty; | ||
| 87 | defer buf.deinit(std.heap.page_allocator); | ||
| 88 | for (h.lines.items) |l| { | ||
| 89 | try buf.appendSlice(std.heap.page_allocator, l); | ||
| 90 | try buf.append(std.heap.page_allocator, '\n'); | ||
| 91 | } | ||
| 92 | try wall.saveBytes(path, buf.items); | ||
| 93 | } | ||
| 94 | |||
| 95 | pub fn record(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool { | ||
| 96 | var h = try load(alloc, path); | ||
| 97 | defer h.deinit(alloc); | ||
| 98 | if (!try h.add(alloc, spelling)) return false; | ||
| 99 | try save(&h, path); | ||
| 100 | return true; | ||
| 101 | } | ||
| 102 | |||
| 103 | pub fn forget(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool { | ||
| 104 | var h = try load(alloc, path); | ||
| 105 | defer h.deinit(alloc); | ||
| 106 | if (!h.remove(alloc, spelling)) return false; | ||
| 107 | try save(&h, path); | ||
| 108 | return true; | ||
| 109 | } | ||
| 110 | |||
| 111 | pub fn statePath(alloc: std.mem.Allocator) ![]const u8 { | ||
| 112 | return statePathFrom(alloc, std.posix.getenv("XDG_STATE_HOME"), std.posix.getenv("HOME")); | ||
| 113 | } | ||
| 114 | |||
| 115 | pub fn statePathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8 { | ||
| 116 | if (xdg_state_home) |x| if (x.len > 0) return std.fmt.allocPrint(alloc, "{s}/mux/hosts", .{x}); | ||
| 117 | const h = home orelse return error.NoHome; | ||
| 118 | return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/hosts", .{h}); | ||
| 119 | } | ||
| 120 | |||
| 121 | /// cliflags hooks for `mux hosts add|rm SPELLING...`, each word validated | ||
| 122 | /// here at usage altitude rather than downstream as a host that will not dial. | ||
| 123 | pub const Argv = struct { | ||
| 124 | alloc: std.mem.Allocator, | ||
| 125 | list: std.ArrayList([]const u8) = .empty, | ||
| 126 | /// A hook answers yes or no, so one that refused for a REASON leaves | ||
| 127 | /// the word and the why for the caller's message. | ||
| 128 | err: ?struct { word: []const u8, err: (wall.ArgvError || ParseError) } = null, | ||
| 129 | |||
| 130 | pub fn deinit(self: *Argv) void { | ||
| 131 | for (self.list.items) |t| self.alloc.free(t); | ||
| 132 | self.list.deinit(self.alloc); | ||
| 133 | } | ||
| 134 | pub fn positional(self: *Argv, word: []const u8) bool { | ||
| 135 | return self.take(word); | ||
| 136 | } | ||
| 137 | pub fn extra(self: *Argv, rest: []const [:0]const u8) usize { | ||
| 138 | const n = wall.spellingFromArgv(self.alloc, rest, 0) catch |e| { | ||
| 139 | if (e != error.FlagLikeTarget) _ = self.refuse(rest[0], e); | ||
| 140 | return 0; | ||
| 141 | }; | ||
| 142 | defer self.alloc.free(n.spelling); | ||
| 143 | return if (self.take(n.spelling)) n.consumed else 0; | ||
| 144 | } | ||
| 145 | fn take(self: *Argv, spelling: []const u8) bool { | ||
| 146 | const copy = self.alloc.dupe(u8, spelling) catch return self.refuse("", error.OutOfMemory); | ||
| 147 | self.list.append(self.alloc, copy) catch { | ||
| 148 | self.alloc.free(copy); | ||
| 149 | return self.refuse("", error.OutOfMemory); | ||
| 150 | }; | ||
| 151 | _ = parse(copy) catch |e| return self.refuse(copy, e); | ||
| 152 | return true; | ||
| 153 | } | ||
| 154 | fn refuse(self: *Argv, word: []const u8, e: (wall.ArgvError || ParseError)) bool { | ||
| 155 | self.err = .{ .word = word, .err = e }; | ||
| 156 | return false; | ||
| 157 | } | ||
| 158 | }; | ||
| 159 | |||
| 160 | test "hosts.parse: three spellings classify; a '#' is refused by name" { | ||
| 161 | try std.testing.expectEqualStrings("/tmp/a.sock", (try parse("--sock /tmp/a.sock")).sock); | ||
| 162 | try std.testing.expectEqualStrings("box", (try parse("box")).host); | ||
| 163 | try std.testing.expectEqualStrings("10.0.0.2:4433", (try parse("quic://10.0.0.2:4433")).quic); | ||
| 164 | try std.testing.expectError(error.HasSession, parse("box#build")); | ||
| 165 | try std.testing.expectError(error.HasSession, parse("--sock /tmp/a.sock#0")); | ||
| 166 | try std.testing.expectError(error.EmptySpec, parse("")); | ||
| 167 | try std.testing.expectError(error.EmptySpec, parse("--sock ")); | ||
| 168 | try std.testing.expectError(error.BadByte, parse("bo\x01x")); | ||
| 169 | try std.testing.expect(std.mem.indexOf(u8, reason(error.HasSession), "daemons") != null); | ||
| 170 | } | ||
| 171 | |||
| 172 | test "hosts: add dedups, remove reports, load/save round-trip two hosts in order" { | ||
| 173 | const alloc = std.testing.allocator; | ||
| 174 | var tmp = try TmpDir.make(); | ||
| 175 | defer tmp.cleanup(); | ||
| 176 | const path = try std.fmt.allocPrint(alloc, "{s}/mux/hosts", .{tmp.path()}); | ||
| 177 | defer alloc.free(path); | ||
| 178 | |||
| 179 | var h = try load(alloc, path); // absent file = empty | ||
| 180 | defer h.deinit(alloc); | ||
| 181 | try std.testing.expectEqual(@as(usize, 0), h.lines.items.len); | ||
| 182 | try std.testing.expect(try h.add(alloc, "--sock /tmp/a.sock")); | ||
| 183 | try std.testing.expect(try h.add(alloc, "box")); | ||
| 184 | try std.testing.expect(!try h.add(alloc, "box")); | ||
| 185 | try std.testing.expectError(error.HasSession, h.add(alloc, "box#x")); | ||
| 186 | try save(&h, path); | ||
| 187 | |||
| 188 | var back = try load(alloc, path); | ||
| 189 | defer back.deinit(alloc); | ||
| 190 | try std.testing.expectEqual(@as(usize, 2), back.lines.items.len); | ||
| 191 | try std.testing.expectEqualStrings("--sock /tmp/a.sock", back.lines.items[0]); | ||
| 192 | try std.testing.expectEqualStrings("box", back.lines.items[1]); | ||
| 193 | try std.testing.expect(back.remove(alloc, "box")); | ||
| 194 | try std.testing.expect(!back.remove(alloc, "box")); | ||
| 195 | |||
| 196 | try std.testing.expect(try record(alloc, path, "quic://h:1")); | ||
| 197 | try std.testing.expect(!try record(alloc, path, "quic://h:1")); | ||
| 198 | try std.testing.expect(try forget(alloc, path, "quic://h:1")); | ||
| 199 | try std.testing.expect(!try forget(alloc, path, "quic://h:1")); | ||
| 200 | } | ||
| 201 | |||
| 202 | test "hosts.load is strict: a session line in the file is an error, not a skipped line" { | ||
| 203 | const alloc = std.testing.allocator; | ||
| 204 | var tmp = try TmpDir.make(); | ||
| 205 | defer tmp.cleanup(); | ||
| 206 | const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()}); | ||
| 207 | defer alloc.free(path); | ||
| 208 | try wall.saveBytes(path, "box\nbox#old\n"); | ||
| 209 | try std.testing.expectError(error.HasSession, load(alloc, path)); | ||
| 210 | } | ||
| 211 | |||
| 212 | test "hosts.statePathFrom: XDG_STATE_HOME wins, HOME falls back, file is mux/hosts" { | ||
| 213 | const alloc = std.testing.allocator; | ||
| 214 | const a = try statePathFrom(alloc, "/x", "/h"); | ||
| 215 | defer alloc.free(a); | ||
| 216 | try std.testing.expectEqualStrings("/x/mux/hosts", a); | ||
| 217 | const b = try statePathFrom(alloc, null, "/h"); | ||
| 218 | defer alloc.free(b); | ||
| 219 | try std.testing.expectEqualStrings("/h/.local/state/mux/hosts", b); | ||
| 220 | } | ||
| 221 | |||
| 222 | const TmpDir = @import("testtmp").TmpDir; | ||
| 223 | |||
| 224 | test "hosts.Argv: a word is validated as it is taken, and --sock eats its path" { | ||
| 225 | var a: Argv = .{ .alloc = std.testing.allocator }; | ||
| 226 | defer a.deinit(); | ||
| 227 | try std.testing.expect(a.positional("box")); | ||
| 228 | try std.testing.expect(!a.positional("box#x")); | ||
| 229 | try std.testing.expectEqual(@as(usize, 2), a.extra(&[_][:0]const u8{ "--sock", "/tmp/a.sock" })); | ||
| 230 | try std.testing.expectEqual(@as(usize, 0), a.extra(&[_][:0]const u8{"-A"})); | ||
| 231 | } | ||