a73x

ccd8f101

feat: hosts.zig — the wall file lists daemons, never sessions

a73x   2026-08-28 19:53

Commit message
feat: hosts.zig — the wall file lists daemons, never sessions

A '#' in a host line is refused by name: the wall names daemons and
shows whatever sessions they have live, so nothing here can resurrect
one. Strict on load, like wall.zig — a host line is authored intent.

Borrows wall's atomic writer and argv grammar rather than forking a
second copy, which puts it at layer 2: its lender is layer 1 and the
table refuses a flat edge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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 }