a73x

479b7d97

feat: an attach that claims the grid records its tile

a73x   2026-08-20 00:50

Commit message
feat: an attach that claims the grid records its tile

The wall file becomes attach history (wall-home-screen spec, phase 2).
wall.zig grows the two file edits everything else will share — record
(append unless the spelling is already there, byte-exactly) and forget
(orderedRemove so untouched lines keep their positions) — and
client.attach calls record once per turn of its loop, so every session a
prefix chord visits earns a tile too.

Best effort by construction: one warning line per attach, never an exit
code, never a blocked attach. A switch the daemon refuses takes its line
back, but only if this run is what appended it.

build.zig
Old New
@@ -226,7 +226,11 @@ const mod_table = [_]ModSpec{
226 // display decision and never becomes state anybody else can see. It 226 // display decision and never becomes state anybody else can see. It
227 // also borrows ignoreSigpipe, which proxy owns — proxy is a leaf, so 227 // also borrows ignoreSigpipe, which proxy owns — proxy is a leaf, so
228 // this adds no cycle and teaches the proxy nothing. 228 // this adds no cycle and teaches the proxy nothing.
229 .{ .name = "client", .path = "src/client.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "protocol", "replica", "client_core", "quic_client", "quic", "predict", "handoff", "proxy", "paint" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 229 // `wall` is the spelling grammar AND the state file: a grid-claiming
230 // attach records its own tile (the wall is attach history), and the
231 // chord switches that re-dial from inside client.attach have to record
232 // theirs too, so the writer cannot live up in mux_main.
233 .{ .name = "client", .path = "src/client.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "protocol", "replica", "client_core", "quic_client", "quic", "predict", "handoff", "proxy", "paint", "wall" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
230 // The agent-facing client. It speaks frames and owns no terminal, which 234 // The agent-facing client. It speaks frames and owns no terminal, which
231 // is the whole point — it attaches at 0x0 and never claims the grid. 235 // is the whole point — it attaches at 0x0 and never claims the grid.
232 // The transport modules are the CLI client's, minus everything that 236 // The transport modules are the CLI client's, minus everything that
src/client.zig
Old New
@@ -21,6 +21,9 @@ const quic_client = @import("quic_client");
21 const quic = @import("quic"); 21 const quic = @import("quic");
22 const predict = @import("predict"); 22 const predict = @import("predict");
23 const handoff = @import("handoff"); 23 const handoff = @import("handoff");
24 // The wall file: attach history. See `recordTile` for why the writer of a
25 // user's tile is this module and not mux_main.
26 const wall = @import("wall");
24 // Named `paint_mod` because paintOverlay holds a local ArrayList called 27 // Named `paint_mod` because paintOverlay holds a local ArrayList called
25 // `paint`, which a container-level `paint` would collide with. 28 // `paint`, which a container-level `paint` would collide with.
26 const paint_mod = @import("paint"); 29 const paint_mod = @import("paint");
@@ -1275,6 +1278,97 @@ fn spellingCap(target: Target) usize {
1275 return "--sock ".len + operand + 1 + proto.session_name_max; 1278 return "--sock ".len + operand + 1 + proto.session_name_max;
1276 } 1279 }
1277 1280
1281 /// One warning line per `attach()` call, whatever went wrong and however
1282 /// many times. A `Ctrl-\ c`/`n`/`p` loop comes back through the writer once
1283 /// per session visited, and a wall file that cannot be written cannot be
1284 /// written for any of them — repeating the sentence would scroll a working
1285 /// session for a record nobody is reading.
1286 fn warnWall(warned: *bool, what: []const u8, err: anyerror) void {
1287 if (warned.*) return;
1288 warned.* = true;
1289 std.debug.print("mux: wall not updated ({s}): {s}\n", .{ what, @errorName(err) });
1290 }
1291
1292 /// The wall file is attach HISTORY: an attach that claims the grid writes
1293 /// its tile there (the wall-home-screen spec, phase 2).
1294 ///
1295 /// The rule the spec states is mechanical — "attaches at nonzero size",
1296 /// not "is a human" — and this call site IS the enforcement, by where it
1297 /// sits rather than by a size test. `attach` is the only grid-claiming
1298 /// attach in the tree (it attaches at the tty's size, or 80x24 when stdin
1299 /// is a pipe — never zero), while the two passive attachers never reach
1300 /// here at all: muxa does not link this module, and the wall's tile pumps
1301 /// build their own `Transport` from `wallview.zig`. A tty check is
1302 /// deliberately NOT added — a test fixture on a real pty is a human attach
1303 /// by this rule, which is fine because the suite runs under an isolated
1304 /// `XDG_STATE_HOME`.
1305 ///
1306 /// Why in this module and not in `mux_main`: the chord switches
1307 /// (`Ctrl-\ c`/`n`/`p`) re-dial full-size from inside `attach`'s own loop
1308 /// and never return through main, so a seam up there would record the
1309 /// session the user started at and none of the ones they actually visited.
1310 /// The spec wants every visited session to earn its tile — "the wall grows
1311 /// by the truth".
1312 ///
1313 /// Best effort, always: the attach is the act, the tile is the record. An
1314 /// unwritable wall file costs one warning line and nothing else — never a
1315 /// blocked attach, never an exit code.
1316 ///
1317 /// KNOWN GAP, deliberate: a `mux wall` or muxweb that is ALREADY RUNNING
1318 /// reads the file once at startup, so it does not see a line added here
1319 /// until it restarts. The file is state, not a channel; a change feed is
1320 /// out of scope for phase 2 (decisions.md).
1321 fn recordTile(alloc: std.mem.Allocator, target: Target, name: []const u8, warned: *bool) void {
1322 const buf = alloc.alloc(u8, spellingCap(target)) catch return;
1323 defer alloc.free(buf);
1324 // `.via` has no spelling in the wall grammar — an arbitrary command is
1325 // not an address — so that transport records nothing, silently. Same
1326 // refusal `Ctrl-\ w` already makes, for the same reason.
1327 const spelling = wallSpelling(buf, target, proto.resolveName(name)) catch return;
1328 const path = wall.statePath(alloc) catch |err| {
1329 warnWall(warned, "no state directory", err);
1330 return;
1331 };
1332 defer alloc.free(path);
1333 _ = wall.record(alloc, path, spelling) catch |err| warnWall(warned, path, err);
1334 }
1335
1336 /// WHEN an attach becomes history: the first moment state arrives under
1337 /// it, and exactly once per `session()` run.
1338 ///
1339 /// The seam was "the dial succeeded" until review reproduced what that
1340 /// costs. A dial that succeeds is not an attach that landed: the daemon
1341 /// can still refuse (its session table is full at four), and only a
1342 /// SWITCH's refusal had somewhere to fall back to and so a place to undo
1343 /// the write. A FIRST attach refused the same way exited 1 with the line
1344 /// stranded — a tile naming a session that never existed. So did an error
1345 /// out of `session()` before any state.
1346 ///
1347 /// `state_since_attach` is the daemon's own answer to "did this attach
1348 /// land", and it is the very flag both refusal paths read to decide there
1349 /// was none. Keying the write off it means a refusal cannot record, on any
1350 /// path, present or future — no case analysis to keep exhaustive, and
1351 /// nothing left to take back, which is why there is no `unrecordTile`.
1352 /// "Visible while attached" survives intact: the snapshot is milliseconds
1353 /// behind the dial, not a session's lifetime.
1354 ///
1355 /// A reconnect clears the flag and it turns true again; `done` is what
1356 /// stops that being a second write. The write would be a no-op anyway
1357 /// (dedup), but a resync should not pay for a read-modify-write to learn
1358 /// that.
1359 fn recordOnState(
1360 done: *bool,
1361 rep: *const Replica,
1362 alloc: std.mem.Allocator,
1363 target: Target,
1364 name: []const u8,
1365 warned: *bool,
1366 ) void {
1367 if (done.* or !rep.state_since_attach) return;
1368 done.* = true;
1369 recordTile(alloc, target, name, warned);
1370 }
1371
1278 /// Would this wall tile be the session the walling shell is standing in? 1372 /// Would this wall tile be the session the walling shell is standing in?
1279 /// 1373 ///
1280 /// It happens whenever the client was launched from inside another session 1374 /// It happens whenever the client was launched from inside another session
@@ -1409,6 +1503,9 @@ pub fn attach(alloc: std.mem.Allocator, target: Target, session_name: []const u8
1409 // can be answered by going back rather than by dying. Null on a first 1503 // can be answered by going back rather than by dying. Null on a first
1410 // attach, which keeps that run's messages and exit code untouched. 1504 // attach, which keeps that run's messages and exit code untouched.
1411 var came_from: ?SessionName = null; 1505 var came_from: ?SessionName = null;
1506 // Latched by `warnWall`: one wall-file complaint per attach, however
1507 // many sessions the chords visit.
1508 var wall_warned = false;
1412 1509
1413 while (true) { 1510 while (true) {
1414 var transport = Transport.open(alloc, target, &carry, std.posix.STDIN_FILENO) catch |err| { 1511 var transport = Transport.open(alloc, target, &carry, std.posix.STDIN_FILENO) catch |err| {
@@ -1417,7 +1514,18 @@ pub fn attach(alloc: std.mem.Allocator, target: Target, session_name: []const u8
1417 std.debug.print("{s}", .{f.msg}); 1514 std.debug.print("{s}", .{f.msg});
1418 return f.exit; 1515 return f.exit;
1419 }; 1516 };
1420 const out = session(alloc, &transport, target, &carry, name.slice(), came_from != null) catch |err| { 1517 // The tile is NOT written here. A dial that came up is not an
1518 // attach that landed — see `recordOnState`, which writes it from
1519 // inside the session the moment the daemon's first state arrives.
1520 const out = session(
1521 alloc,
1522 &transport,
1523 target,
1524 &carry,
1525 name.slice(),
1526 came_from != null,
1527 &wall_warned,
1528 ) catch |err| {
1421 transport.close(); 1529 transport.close();
1422 return err; 1530 return err;
1423 }; 1531 };
@@ -1429,6 +1537,10 @@ pub fn attach(alloc: std.mem.Allocator, target: Target, session_name: []const u8
1429 name = to; 1537 name = to;
1430 }, 1538 },
1431 .refused => { 1539 .refused => {
1540 // Nothing to take off the wall: a refusal is precisely "no
1541 // state since attach", and no state is precisely what
1542 // `recordOnState` waits for.
1543 //
1432 // Said here rather than in session(), which cannot know 1544 // Said here rather than in session(), which cannot know
1433 // whether the refusal is fatal: it is not, precisely 1545 // whether the refusal is fatal: it is not, precisely
1434 // because there is somewhere to go back to. 1546 // because there is somewhere to go back to.
@@ -1524,6 +1636,10 @@ fn session(
1524 /// the end of the process. The arrival only, not the whole run: it is 1636 /// the end of the process. The arrival only, not the whole run: it is
1525 /// copied into `arrived_from_switch`, which a reconnect clears. 1637 /// copied into `arrived_from_switch`, which a reconnect clears.
1526 from_switch: bool, 1638 from_switch: bool,
1639 /// The caller's one-warning-per-attach latch, carried in because the
1640 /// wall write happens HERE now (`recordOnState`) while the chord loop
1641 /// that would repeat the complaint lives up there.
1642 wall_warned: *bool,
1527 ) !Outcome { 1643 ) !Outcome {
1528 // A daemon that dies mid-write must surface as an error return from 1644 // A daemon that dies mid-write must surface as an error return from
1529 // write(), not a fatal SIGPIPE. SIG_IGN survives exec while a handler 1645 // write(), not a fatal SIGPIPE. SIG_IGN survives exec while a handler
@@ -1543,6 +1659,10 @@ fn session(
1543 // The replay core, extracted to replica.zig; the engine stays owned 1659 // The replay core, extracted to replica.zig; the engine stays owned
1544 // here (the Replica borrows it), so the deinit above is the one owner. 1660 // here (the Replica borrows it), so the deinit above is the one owner.
1545 var rep = Replica.init(alloc, eng); 1661 var rep = Replica.init(alloc, eng);
1662 // Whether this run has already written its tile. Latched rather than
1663 // re-derived because a reconnect clears `state_since_attach` and it
1664 // turns true again — see `recordOnState`.
1665 var tile_recorded = false;
1546 // Semantic terminal state and host effects are decoded once here, then 1666 // Semantic terminal state and host effects are decoded once here, then
1547 // handed to the native adapter below. The web client owns another 1667 // handed to the native adapter below. The web client owns another
1548 // instance of this same platform-neutral state machine. 1668 // instance of this same platform-neutral state machine.
@@ -1901,6 +2021,12 @@ fn session(
1901 error.BadPayload => continue, 2021 error.BadPayload => continue,
1902 else => |e| return e, 2022 else => |e| return e,
1903 }; 2023 };
2024 // The attach landed. Recorded at the two `apply` sites
2025 // rather than once per poll turn because either kind of
2026 // state proves it, and a shell that exits in the same
2027 // read as its snapshot must not lose its tile to a loop
2028 // turn that never comes.
2029 recordOnState(&tile_recorded, &rep, alloc, target, session_name, wall_warned);
1904 reconnect_grace_until = null; 2030 reconnect_grace_until = null;
1905 // A snapshot answers a resize, ends a reconnect, and 2031 // A snapshot answers a resize, ends a reconnect, and
1906 // rebuilds the screen under anything outstanding. None 2032 // rebuilds the screen under anything outstanding. None
@@ -1918,7 +2044,12 @@ fn session(
1918 }, 2044 },
1919 .delta => { 2045 .delta => {
1920 reconnect_grace_until = null; 2046 reconnect_grace_until = null;
1921 if (try rep.apply(.delta, frame.payload) == .resync) { 2047 const applied = try rep.apply(.delta, frame.payload);
2048 // Bound before the `.resync` branch, which can leave
2049 // this arm: a delta the replica accepted is state, and
2050 // state is what makes the attach history.
2051 recordOnState(&tile_recorded, &rep, alloc, target, session_name, wall_warned);
2052 if (applied == .resync) {
1922 // A rejected delta means the replica can no longer be 2053 // A rejected delta means the replica can no longer be
1923 // trusted; ask for a fresh snapshot rather than 2054 // trusted; ask for a fresh snapshot rather than
1924 // silently skipping it and desyncing for good. By 2055 // silently skipping it and desyncing for good. By
@@ -4550,6 +4681,49 @@ test "client: a target spells itself back as one wall argument per session" {
4550 ); 4681 );
4551 } 4682 }
4552 4683
4684 test "client: every spelling this writes, the wall grammar reads back the same" {
4685 // Writer/reader identity, pinned across the module boundary. What
4686 // `recordTile` writes into the wall file is what `mux wall`, muxweb and
4687 // `wall.load` have to parse back — the tile's label, its `#SESSION`
4688 // split and its transport all come from re-reading this string, and a
4689 // drift in either half is a tile that dials somewhere else or refuses
4690 // to load at all. `refAllDecls` compiles both; only this executes both.
4691 var buf: [256]u8 = undefined;
4692 const cases = .{
4693 .{ Target{ .sock = "/run/user/1000/muxd.sock" }, "0" },
4694 // `user@host`: nothing inside it parses, which is what keeps ssh's
4695 // own config working — and the wall must not start parsing it now.
4696 .{ Target{ .hand = .{ .host = "ubuntu@sandbox-a609d8", .ssh_cmd = "x", .cache_path = null } }, "build" },
4697 .{ Target{ .hand = .{ .host = "vm1", .ssh_cmd = "x", .cache_path = null } }, "0" },
4698 // The port rides through untouched, and so does its absence.
4699 .{ Target{ .quic = .{ .host_port = "box:8787", .key_path = "/k" } }, "work" },
4700 .{ Target{ .quic = .{ .host_port = "box", .key_path = "/k" } }, "0" },
4701 };
4702 inline for (cases) |c| {
4703 const spelling = try wallSpelling(&buf, c[0], c[1]);
4704 const p = try @import("wall").parseSpelling(spelling);
4705 try std.testing.expectEqualStrings(c[1], p.session);
4706 switch (c[0]) {
4707 .sock => |path| try std.testing.expectEqualStrings(path, p.spec.sock),
4708 .hand => |h| try std.testing.expectEqualStrings(h.host, p.spec.host),
4709 .quic => |q| try std.testing.expectEqualStrings(q.host_port, p.spec.quic),
4710 .via => unreachable,
4711 }
4712 }
4713 }
4714
4715 test "client: the default session records as #0, not as an empty name" {
4716 var buf: [256]u8 = undefined;
4717 // Exactly what `recordTile` hands `wallSpelling`. Bare `mux` attaches
4718 // under the empty WIRE name (older-daemon compat), and a wall line has
4719 // to be a spelling the user could type back — so the tile everyone
4720 // gains on first use is `--sock <default>#0`, per the spec.
4721 try std.testing.expectEqualStrings(
4722 "--sock /run/muxd.sock#0",
4723 try wallSpelling(&buf, .{ .sock = "/run/muxd.sock" }, proto.resolveName("")),
4724 );
4725 }
4726
4553 test "client: a --via target has no wall spelling at all" { 4727 test "client: a --via target has no wall spelling at all" {
4554 var buf: [256]u8 = undefined; 4728 var buf: [256]u8 = undefined;
4555 // Not a formatting failure to be worked around: the wall grammar has 4729 // Not a formatting failure to be worked around: the wall grammar has
src/wall.zig
Old New
@@ -161,16 +161,100 @@ pub fn load(alloc: std.mem.Allocator, path: []const u8) !Wall {
161 } 161 }
162 162
163 pub fn save(w: *const Wall, path: []const u8) !void { 163 pub fn save(w: *const Wall, path: []const u8) !void {
164 return saveLines(w.targets.items, path);
165 }
166
167 /// The file format, with no opinion about what a line means: one string
168 /// per line, temp file, rename. `save` and the lenient remove path below
169 /// share it so there is one writer of this file and not two.
170 pub fn saveLines(lines: []const []const u8, path: []const u8) !void {
164 var write_buf: [4096]u8 = undefined; 171 var write_buf: [4096]u8 = undefined;
165 var af = try std.fs.cwd().atomicFile(path, .{ .make_path = true, .write_buffer = &write_buf }); 172 var af = try std.fs.cwd().atomicFile(path, .{ .make_path = true, .write_buffer = &write_buf });
166 defer af.deinit(); 173 defer af.deinit();
167 for (w.targets.items) |t| { 174 for (lines) |t| {
168 try af.file_writer.interface.writeAll(t); 175 try af.file_writer.interface.writeAll(t);
169 try af.file_writer.interface.writeAll("\n"); 176 try af.file_writer.interface.writeAll("\n");
170 } 177 }
171 try af.finish(); 178 try af.finish();
172 } 179 }
173 180
181 /// Every line of the wall file, verbatim, with NO grammar applied.
182 ///
183 /// `load` refuses a file holding a line that no longer parses, loudly and
184 /// on purpose — silently dropping a tile the user wrote down is worse than
185 /// making them fix the line. But when EVERY path went through `load`, one
186 /// hand-edited line made the file unrepairable by the tool that owns it,
187 /// including the command whose entire job is removing a line. So removal
188 /// reads with this instead: it can delete the broken line, and it preserves
189 /// every line it did not touch byte for byte.
190 ///
191 /// Deliberately NOT used by `record` or `Wall.add`: those GROW the wall,
192 /// and growing a file whose existing content is not understood would build
193 /// on garbage and re-save it as if it had been read.
194 pub fn loadLines(alloc: std.mem.Allocator, path: []const u8) !std.ArrayList([]u8) {
195 var lines: std.ArrayList([]u8) = .empty;
196 errdefer {
197 for (lines.items) |l| alloc.free(l);
198 lines.deinit(alloc);
199 }
200 const data = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |err| switch (err) {
201 error.FileNotFound => return lines,
202 else => return err,
203 };
204 defer alloc.free(data);
205 var it = std.mem.tokenizeScalar(u8, data, '\n');
206 while (it.next()) |line| try lines.append(alloc, try alloc.dupe(u8, line));
207 return lines;
208 }
209
210 pub fn freeLines(alloc: std.mem.Allocator, lines: *std.ArrayList([]u8)) void {
211 for (lines.items) |l| alloc.free(l);
212 lines.deinit(alloc);
213 }
214
215 /// Append `spelling` to the wall file unless it is already there, byte
216 /// for byte. Returns whether the file changed.
217 ///
218 /// Dedup is on the SPELLING, never on the session's identity: the same
219 /// session reached as `HOST#S` and as `quic://…#S` is two tiles,
220 /// deliberately — identity dedup would need an endpoint handshake the
221 /// wall does not have and does not want (the home-screen spec).
222 ///
223 /// Read-modify-write against a file two processes may hold at once. The
224 /// resolution is `save`'s: last rename wins, acceptable for one user's
225 /// state file. A reader never sees a torn file (temp + rename); a writer
226 /// can lose a concurrent writer's line.
227 pub fn record(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool {
228 var w = try load(alloc, path);
229 defer w.deinit(alloc);
230 for (w.targets.items) |t| if (std.mem.eql(u8, t, spelling)) return false;
231 _ = try w.add(alloc, spelling);
232 try save(&w, path);
233 return true;
234 }
235
236 /// Remove `spelling` from the wall file. Returns whether it was there —
237 /// absent is a fact for the caller to report, not an error here.
238 ///
239 /// `orderedRemove`, so the lines that stay keep their order: the wall is
240 /// a list the user reads (and jumps into with `1`-`9`) by position.
241 ///
242 /// Reads with `loadLines`, not `load`: removal is the one operation that
243 /// must work on a file the grammar cannot fully read, or a single
244 /// hand-edited line would be unrepairable with the tool that owns it. The
245 /// broken lines it does not match are written back untouched.
246 pub fn forget(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool {
247 var lines = try loadLines(alloc, path);
248 defer freeLines(alloc, &lines);
249 for (lines.items, 0..) |t, i| {
250 if (!std.mem.eql(u8, t, spelling)) continue;
251 alloc.free(lines.orderedRemove(i));
252 try saveLines(lines.items, path);
253 return true;
254 }
255 return false;
256 }
257
174 /// `$XDG_STATE_HOME/mux/wall`, defaulting to `~/.local/state/mux/wall`. 258 /// `$XDG_STATE_HOME/mux/wall`, defaulting to `~/.local/state/mux/wall`.
175 /// The *From split is xdg.zig's pattern for the same reason: setenv is 259 /// The *From split is xdg.zig's pattern for the same reason: setenv is
176 /// unsafe in-process for Zig tests. 260 /// unsafe in-process for Zig tests.
@@ -320,6 +404,110 @@ test "wall: save/load round-trip; missing file loads empty; bad line refuses" {
320 } 404 }
321 } 405 }
322 406
407 test "record: appends once per spelling, dedups byte-exactly, keeps order" {
408 const testtmp = @import("testtmp");
409 const alloc = std.testing.allocator;
410 var tmp = try testtmp.TmpDir.make();
411 defer tmp.cleanup();
412
413 // A path whose parent does not exist yet: the first attach of a fresh
414 // install has no `mux/` directory, and must not be the one that fails.
415 const path = try std.fmt.allocPrint(alloc, "{s}/deep/wall", .{tmp.path()});
416 defer alloc.free(path);
417
418 try std.testing.expect(try record(alloc, path, "--sock /run/muxd.sock#0"));
419 try std.testing.expect(try record(alloc, path, "box#build"));
420 // The same spelling again is a no-op write, and says so.
421 try std.testing.expect(!try record(alloc, path, "--sock /run/muxd.sock#0"));
422
423 // The same SESSION under two spellings is two tiles: dedup is on the
424 // string, not on the identity behind it.
425 try std.testing.expect(try record(alloc, path, "quic://box:4433#build"));
426
427 var w = try load(alloc, path);
428 defer w.deinit(alloc);
429 try std.testing.expectEqual(@as(usize, 3), w.targets.items.len);
430 try std.testing.expectEqualStrings("--sock /run/muxd.sock#0", w.targets.items[0]);
431 try std.testing.expectEqualStrings("box#build", w.targets.items[1]);
432 try std.testing.expectEqualStrings("quic://box:4433#build", w.targets.items[2]);
433 }
434
435 test "forget: removes one line, leaves the rest in order, absent says so" {
436 const testtmp = @import("testtmp");
437 const alloc = std.testing.allocator;
438 var tmp = try testtmp.TmpDir.make();
439 defer tmp.cleanup();
440 const path = try std.fmt.allocPrint(alloc, "{s}/wall", .{tmp.path()});
441 defer alloc.free(path);
442
443 inline for (.{ "a#0", "b#0", "c#0" }) |s| try std.testing.expect(try record(alloc, path, s));
444
445 try std.testing.expect(try forget(alloc, path, "b#0"));
446 // Absent is false, not an error — `mux wall rm` turns it into a
447 // message and an exit code, and `x` on an argv-only tile ignores it.
448 try std.testing.expect(!try forget(alloc, path, "b#0"));
449 try std.testing.expect(!try forget(alloc, path, "nothing#0"));
450
451 var w = try load(alloc, path);
452 defer w.deinit(alloc);
453 try std.testing.expectEqual(@as(usize, 2), w.targets.items.len);
454 try std.testing.expectEqualStrings("a#0", w.targets.items[0]);
455 try std.testing.expectEqualStrings("c#0", w.targets.items[1]);
456 }
457
458 test "forget: a hand-edited file is repairable; add still refuses to build on it" {
459 const testtmp = @import("testtmp");
460 const alloc = std.testing.allocator;
461 var tmp = try testtmp.TmpDir.make();
462 defer tmp.cleanup();
463 const path = try std.fmt.allocPrint(alloc, "{s}/wall", .{tmp.path()});
464 defer alloc.free(path);
465
466 // A line no grammar reads, between two that parse. Before removal read
467 // leniently, EVERY path went through `load` — so one such line made the
468 // file unfixable with the tool that owns it.
469 try tmp.dir.writeFile(.{ .sub_path = "wall", .data = "a#0\nbad name#x y\nb#1\n" });
470
471 // Growing the wall still refuses: `record` must not re-save content it
472 // could not read as though it had.
473 try std.testing.expectError(error.BadSession, record(alloc, path, "c#2"));
474
475 // Removing the broken line works, and is the escape hatch.
476 try std.testing.expect(try forget(alloc, path, "bad name#x y"));
477 {
478 var w = try load(alloc, path);
479 defer w.deinit(alloc);
480 try std.testing.expectEqual(@as(usize, 2), w.targets.items.len);
481 try std.testing.expectEqualStrings("a#0", w.targets.items[0]);
482 try std.testing.expectEqualStrings("b#1", w.targets.items[1]);
483 }
484 // ...and now that the file parses again, growing it does too.
485 try std.testing.expect(try record(alloc, path, "c#2"));
486
487 // The other half of lenient: removing a GOOD line out of a file that
488 // still holds a bad one leaves the bad one byte for byte, rather than
489 // dropping what it could not read.
490 try tmp.dir.writeFile(.{ .sub_path = "wall", .data = "a#0\nbad name#x y\nb#1\n" });
491 try std.testing.expect(try forget(alloc, path, "a#0"));
492 const back = try std.fs.cwd().readFileAlloc(alloc, path, 4096);
493 defer alloc.free(back);
494 try std.testing.expectEqualStrings("bad name#x y\nb#1\n", back);
495 }
496
497 test "record: an unwritable wall file is an error the caller may swallow" {
498 const testtmp = @import("testtmp");
499 const alloc = std.testing.allocator;
500 var tmp = try testtmp.TmpDir.make();
501 defer tmp.cleanup();
502 // A directory where the file should be: the write cannot land, and the
503 // attach that called this must still happen (client.zig warns and goes
504 // on). Asserted here so "best effort" is a caught error, not a hope.
505 try tmp.dir.makePath("wall");
506 const path = try std.fmt.allocPrint(alloc, "{s}/wall", .{tmp.path()});
507 defer alloc.free(path);
508 try std.testing.expectError(error.IsDir, record(alloc, path, "a#0"));
509 }
510
323 test "statePathFrom: XDG wins when set and non-empty, HOME default otherwise" { 511 test "statePathFrom: XDG wins when set and non-empty, HOME default otherwise" {
324 const alloc = std.testing.allocator; 512 const alloc = std.testing.allocator;
325 { 513 {