479b7d97
feat: an attach that claims the grid records its tile
a73x 2026-08-20 00:50
Commit message
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 | { |