a73x

a89535be

feat: x forgets a tile, and never kills its session

a73x   2026-08-20 00:50

Commit message
feat: x forgets a tile, and never kills its session

The wall's `x`: the line leaves the wall file, the tile's pump ends
(closing its transport, freeing the daemon slot), and the stripes are
re-cut over what is left. The session is untouched — "remove is detach".

Tiles are never compacted, because the pump threads hold pointers into
the tile array; a forgotten tile becomes a hole. So the two pure chord
tables now take a `present` slice instead of a count: motion steps over
holes, the digits renumber with the bars the eye can see, and `Ctrl-\ l`
aimed at a forgotten tile unzooms — which is the fallback phase 1's
comment already predicted. Forget them all and the wall says so rather
than going blank.

e2e gains two scenarios (pin 46 -> 48): the wall as attach history —
mux adds, a second attach does not duplicate, a chord-created session
earns its tile, muxa leaves the file byte-identical, a refused
self-attach writes nothing, an unwritable file warns without blocking —
and `x`, which removes one line, re-cuts the wall, and leaves the
session answering.

src/wallview.zig
Old New
@@ -8,6 +8,15 @@
8 //! One stripe is SELECTED (`j`/`k` or `n`/`p` to move, `1`-`9` to jump); 8 //! One stripe is SELECTED (`j`/`k` or `n`/`p` to move, `1`-`9` to jump);
9 //! its label bar carries a `> ` marker. `Enter` ZOOMS it, in place. 9 //! its label bar carries a `> ` marker. `Enter` ZOOMS it, in place.
10 //! 10 //!
11 //! `x` FORGETS the selected tile: its line leaves the wall file, its pump
12 //! ends (closing the transport, freeing the daemon slot), and the stripes
13 //! are re-cut over what is left. The SESSION is untouched — "remove is
14 //! detach", the dynamic-wall doctrine. Tiles are never compacted, because
15 //! the pump threads hold pointers into the tile array: a forgotten tile
16 //! becomes a hole that every motion steps over (`selectKey`) and that
17 //! nothing may paint (`paintModeLocked`). Forget them all and the wall
18 //! says so rather than going blank.
19 //!
11 //! ZOOM IS A LENS, NOT A MODE. An unzoomed tile claims nothing; zooming 20 //! ZOOM IS A LENS, NOT A MODE. An unzoomed tile claims nothing; zooming
12 //! PROMOTES that tile's existing connection and unzooming DEMOTES it. 21 //! PROMOTES that tile's existing connection and unzooming DEMOTES it.
13 //! 22 //!
@@ -248,6 +257,16 @@ const Tile = struct {
248 /// replica within a poll timeout, and letting the keyboard draw over 257 /// replica within a poll timeout, and letting the keyboard draw over
249 /// that would replace something true with something stale. 258 /// that would replace something true with something stale.
250 alive: std.atomic.Value(bool) = std.atomic.Value(bool).init(true), 259 alive: std.atomic.Value(bool) = std.atomic.Value(bool).init(true),
260 /// Forgotten by `x`: off the wall, and off the wire as soon as the
261 /// pump notices. The pump's answer is to RETURN — which closes its
262 /// transport and frees the daemon slot — and nothing more: "remove is
263 /// detach", so the session itself is untouched and still there for the
264 /// next `mux` that asks for it.
265 ///
266 /// Distinct from `alive`, which says the pump has ENDED (refused,
267 /// exited, never started). A gone tile is one the user removed; a dead
268 /// one is still on the wall, narrating what became of it.
269 gone: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
251 /// The doorbell. The keyboard writes one byte here to wake this tile's 270 /// The doorbell. The keyboard writes one byte here to wake this tile's
252 /// pump; the bytes themselves carry nothing, the mailbox does. Both 271 /// pump; the bytes themselves carry nothing, the mailbox does. Both
253 /// ends are non-blocking, which is what makes a bell that is already 272 /// ends are non-blocking, which is what makes a bell that is already
@@ -274,21 +293,49 @@ const Tile = struct {
274 } 293 }
275 }; 294 };
276 295
277 /// Where a key takes the selection, or null for "not a selection key" — 296 /// The next tile in `present` after `sel`, wrapping, or null when none is
278 /// including a digit past the last tile, which is swallowed rather than 297 /// left. `sel` itself is the answer when it is the only one present, which
279 /// clamped: a jump to a tile that is not there should do nothing, not 298 /// is what makes a one-tile wall's `j` a no-op rather than a null.
280 /// move somewhere the user did not ask for. 299 pub fn stepPresent(present: []const bool, sel: usize, forward: bool) ?usize {
281 pub fn selectKey(sel: usize, n: usize, key: u8) ?usize { 300 const n = present.len;
282 if (n == 0) return null; 301 if (n == 0 or sel >= n) return null;
302 var i = sel;
303 var seen: usize = 0;
304 while (seen < n) : (seen += 1) {
305 i = if (forward) (i + 1) % n else (i + n - 1) % n;
306 if (present[i]) return i;
307 }
308 return null;
309 }
310
311 /// Where a key takes the selection, or null for "not a selection key" or
312 /// "nowhere to go".
313 ///
314 /// `present[i]` is whether tile i is still ON the wall. `x` forgets a tile
315 /// IN PLACE — the pump threads hold pointers into the tile array, so it is
316 /// never compacted — and a forgotten tile has to be invisible to every
317 /// motion: stepped over by `j`/`k`, and not counted by the digits, which
318 /// are read off the bars the eye can actually see.
319 ///
320 /// A digit past the last present tile is swallowed rather than clamped: a
321 /// jump to a tile that is not there should do nothing, not move somewhere
322 /// the user did not ask for.
323 pub fn selectKey(present: []const bool, sel: usize, key: u8) ?usize {
283 return switch (key) { 324 return switch (key) {
284 // `j`/`k` for the vi hands, `n`/`p` for the session ring's 325 // `j`/`k` for the vi hands, `n`/`p` for the session ring's
285 // spelling (client.zig's Ctrl-\ n / Ctrl-\ p) — same motion. 326 // spelling (client.zig's Ctrl-\ n / Ctrl-\ p) — same motion.
286 'j', 'n' => (sel + 1) % n, 327 'j', 'n' => stepPresent(present, sel, true),
287 'k', 'p' => (sel + n - 1) % n, 328 'k', 'p' => stepPresent(present, sel, false),
288 // 1-based: the bars are counted by eye, from the top, from one. 329 // 1-based, counted over what is SHOWN: after `x` the bars renumber
330 // themselves, and `2` means the second bar on the screen.
289 '1'...'9' => blk: { 331 '1'...'9' => blk: {
290 const i: usize = key - '1'; 332 var want: usize = key - '1';
291 break :blk if (i < n) i else null; 333 for (present, 0..) |p, i| {
334 if (!p) continue;
335 if (want == 0) break :blk i;
336 want -= 1;
337 }
338 break :blk null;
292 }, 339 },
293 else => null, 340 else => null,
294 }; 341 };
@@ -308,6 +355,12 @@ const PaintMode = enum {
308 }; 355 };
309 356
310 fn paintModeLocked(t: *const Tile) PaintMode { 357 fn paintModeLocked(t: *const Tile) PaintMode {
358 // A forgotten tile paints NOTHING, ever again. Checked here rather
359 // than only at the keyboard because the pump may already be past its
360 // own `gone` check when `x` lands; this test is under `paint_mu`, the
361 // same lock the re-layout holds, so there is no window in which a
362 // forgotten stripe can land on rows that now belong to a tile below.
363 if (t.gone.load(.acquire)) return .none;
311 const z = t.shared.zoom.load(.acquire); 364 const z = t.shared.zoom.load(.acquire);
312 if (z == no_zoom) return .stripe; 365 if (z == no_zoom) return .stripe;
313 return if (z == t.idx) .full else .none; 366 return if (z == t.idx) .full else .none;
@@ -528,12 +581,17 @@ fn drainWake(t: *const Tile) void {
528 581
529 fn dial(alloc: std.mem.Allocator, t: *Tile, target: client.Target) ?client.Transport { 582 fn dial(alloc: std.mem.Allocator, t: *Tile, target: client.Target) ?client.Transport {
530 var backoff_ms: u64 = 0; 583 var backoff_ms: u64 = 0;
531 while (t.shared.running.load(.acquire)) { 584 // `gone` as well as `running`: a tile forgotten while it is retrying a
585 // dead host must stop retrying, not keep a thread and a backoff alive
586 // for a tile that is no longer on the wall.
587 while (t.shared.running.load(.acquire) and !t.gone.load(.acquire)) {
532 if (client.Transport.open(alloc, target, null, -1)) |tr| return tr else |_| {} 588 if (client.Transport.open(alloc, target, null, -1)) |tr| return tr else |_| {}
533 backoff_ms = client.nextBackoffMs(backoff_ms); 589 backoff_ms = client.nextBackoffMs(backoff_ms);
534 // Sliced sleep so quit is never behind a full backoff. 590 // Sliced sleep so quit is never behind a full backoff.
535 var slept: u64 = 0; 591 var slept: u64 = 0;
536 while (slept < backoff_ms and t.shared.running.load(.acquire)) : (slept += 50) { 592 while (slept < backoff_ms and t.shared.running.load(.acquire) and
593 !t.gone.load(.acquire)) : (slept += 50)
594 {
537 std.Thread.sleep(50 * std.time.ns_per_ms); 595 std.Thread.sleep(50 * std.time.ns_per_ms);
538 } 596 }
539 } 597 }
@@ -597,7 +655,10 @@ fn pumpTile(t: *Tile) void {
597 // What this stripe's paint is worth: while it matches the wall's 655 // What this stripe's paint is worth: while it matches the wall's
598 // generation the terminal still holds what this thread drew. 656 // generation the terminal still holds what this thread drew.
599 var painted_gen = t.shared.repaint_gen.load(.acquire); 657 var painted_gen = t.shared.repaint_gen.load(.acquire);
600 outer: while (t.shared.running.load(.acquire)) { 658 // `gone` ends this thread exactly as `running` does — the defers close
659 // the transport, which frees the daemon slot and NOTHING else. The
660 // session goes on running: "remove is detach".
661 outer: while (t.shared.running.load(.acquire) and !t.gone.load(.acquire)) {
601 var fds = [_]std.posix.pollfd{ 662 var fds = [_]std.posix.pollfd{
602 .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }, 663 .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
603 .{ .fd = t.wake_r, .events = std.posix.POLL.IN, .revents = 0 }, 664 .{ .fd = t.wake_r, .events = std.posix.POLL.IN, .revents = 0 },
@@ -812,27 +873,29 @@ pub const ZoomMove = union(enum) {
812 /// `last` is the tile the zoom last came from, or null when it has not 873 /// `last` is the tile the zoom last came from, or null when it has not
813 /// moved yet. `Ctrl-\ l` with nothing to go back to unzooms rather than 874 /// moved yet. `Ctrl-\ l` with nothing to go back to unzooms rather than
814 /// guessing — the spec's chosen fallback, and the same answer a tile that 875 /// guessing — the spec's chosen fallback, and the same answer a tile that
815 /// has since been forgotten will get once `x` exists. So does `l` aimed at 876 /// has since been FORGOTTEN gets, which is why `present` is consulted and
816 /// the tile already zoomed, which tmux's `prefix-l` treats the same way: 877 /// not just the length. So does `l` aimed at the tile already zoomed,
817 /// "go where I was" cannot mean "stay here", and re-zooming in place would 878 /// which tmux's `prefix-l` treats the same way: "go where I was" cannot
818 /// clear the screen and repaint it to no visible effect. 879 /// mean "stay here", and re-zooming in place would clear the screen and
880 /// repaint it to no visible effect.
819 pub fn zoomChord( 881 pub fn zoomChord(
820 action: client.PrefixFilter.Action, 882 action: client.PrefixFilter.Action,
821 cur: usize, 883 cur: usize,
822 n: usize, 884 present: []const bool,
823 last: ?usize, 885 last: ?usize,
824 ) ZoomMove { 886 ) ZoomMove {
825 return switch (action) { 887 return switch (action) {
826 .detach, .wall => .out, 888 .detach, .wall => .out,
827 .next_session => if (selectKey(cur, n, 'n')) |i| .{ .to = i } else .stay, 889 .next_session => if (selectKey(present, cur, 'n')) |i| .{ .to = i } else .stay,
828 .prev_session => if (selectKey(cur, n, 'p')) |i| .{ .to = i } else .stay, 890 .prev_session => if (selectKey(present, cur, 'p')) |i| .{ .to = i } else .stay,
829 .last_session => blk: { 891 .last_session => blk: {
830 const back = last orelse break :blk .out; 892 const back = last orelse break :blk .out;
831 break :blk if (back < n and back != cur) .{ .to = back } else .out; 893 if (back >= present.len or !present[back] or back == cur) break :blk .out;
894 break :blk .{ .to = back };
832 }, 895 },
833 // `c` (create a session and zoom it) is phase 2's, when the wall 896 // `c` (create a session and zoom it) is a later phase's, when the
834 // learns to grow a tile. Swallowed here exactly as the client 897 // wall learns to grow a tile from inside itself. Swallowed here
835 // swallows a key it has no meaning for. 898 // exactly as the client swallows a key it has no meaning for.
836 .none, .new_session => .stay, 899 .none, .new_session => .stay,
837 }; 900 };
838 } 901 }
@@ -922,6 +985,125 @@ fn paintDeadZoomLocked(t: *Tile) void {
922 proto.writeAllFd(t.shared.out_fd, fbs.getWritten()) catch {}; 985 proto.writeAllFd(t.shared.out_fd, fbs.getWritten()) catch {};
923 } 986 }
924 987
988 /// The whole screen an empty wall gets. Phase 1's dead-zoom line, for the
989 /// same reason: a blank terminal with no cursor reads as hung, and the
990 /// last `x` is precisely when the user needs to be told that what they see
991 /// is the answer and not a crash.
992 fn paintEmptyWallLocked(shared: *Shared) void {
993 var text_buf: [256]u8 = undefined;
994 const shown = labelText(
995 &text_buf,
996 shared.size.cols,
997 "",
998 "the wall is empty",
999 "nothing left to show - q to leave",
1000 );
1001 var out: [512]u8 = undefined;
1002 var fbs = std.io.fixedBufferStream(&out);
1003 fbs.writer().print("\x1b[1;1H{s}\x1b[?25h", .{shown}) catch return;
1004 proto.writeAllFd(shared.out_fd, fbs.getWritten()) catch {};
1005 }
1006
1007 /// Re-cut the stripes over the tiles that are LEFT, and put the whole wall
1008 /// back on the screen.
1009 ///
1010 /// One hold of `paint_mu` for the same reason `setZoom` takes it: between
1011 /// the clear and the new geometry there must be no window in which a pump
1012 /// paints a stripe at rows that have just changed owner. `t.stripe` is
1013 /// written here and read only under this lock (`paintTile`,
1014 /// `paintLabelLocked`), which is what makes moving it safe at all.
1015 ///
1016 /// A failed re-layout keeps the old geometry: forgetting a tile can only
1017 /// give the survivors MORE rows, so `TooSmall` here is unreachable except
1018 /// for the empty wall, which is handled before it.
1019 fn relayout(
1020 alloc: std.mem.Allocator,
1021 tiles: []Tile,
1022 present: []const bool,
1023 shared: *Shared,
1024 sel: usize,
1025 ) void {
1026 shared.paint_mu.lock();
1027 defer shared.paint_mu.unlock();
1028 shared.sel = sel;
1029
1030 var live: usize = 0;
1031 for (present) |p| {
1032 if (p) live += 1;
1033 }
1034 proto.writeAllFd(shared.out_fd, "\x1b[?25l\x1b[H\x1b[2J") catch {};
1035 if (live == 0) {
1036 paintEmptyWallLocked(shared);
1037 return;
1038 }
1039 if (layoutStripes(alloc, live, shared.size.rows)) |stripes| {
1040 defer alloc.free(stripes);
1041 var k: usize = 0;
1042 for (tiles, present) |*t, p| {
1043 if (!p) continue;
1044 t.stripe = stripes[k];
1045 k += 1;
1046 }
1047 } else |_| {}
1048
1049 // The generation bump is what puts the stripes back, exactly as after
1050 // a zoom: every surviving pump repaints from its hot replica at its
1051 // NEW rows, and the doorbell makes that immediate rather than one poll
1052 // timeout away.
1053 _ = shared.repaint_gen.fetchAdd(1, .release);
1054 for (tiles, present) |*t, p| {
1055 if (p) ring(t);
1056 }
1057 // ...except the tiles with no pump left to hear it: their bars are the
1058 // keyboard's, exactly as in `setZoom`.
1059 for (tiles, present) |*t, p| {
1060 if (p and !t.alive.load(.acquire)) paintLabelLocked(t);
1061 }
1062 }
1063
1064 /// `x`: forget the selected tile. Removes its line from the wall file,
1065 /// ends its pump (which closes the transport and frees the daemon slot),
1066 /// and re-cuts the wall around the hole.
1067 ///
1068 /// It NEVER kills the session — "remove is detach", the dynamic-wall
1069 /// doctrine the home-screen spec keeps. Nothing at all is said to the
1070 /// daemon beyond the connection closing.
1071 ///
1072 /// A tile that is not in the wall file (a spelling named on `mux wall`'s
1073 /// own command line) is forgotten from the VIEW just the same, silently:
1074 /// the file had nothing to remove, and the screen is the answer either
1075 /// way. A file error is remembered rather than printed — this terminal is
1076 /// on the alternate screen and a stray line would corrupt the paint — and
1077 /// said once on the way out.
1078 fn forgetTile(
1079 alloc: std.mem.Allocator,
1080 tiles: []Tile,
1081 present: []bool,
1082 shared: *Shared,
1083 sel: usize,
1084 err_out: *?anyerror,
1085 ) void {
1086 const t = &tiles[sel];
1087 // The label IS the spelling, verbatim (see `Resolved.label`), which is
1088 // what makes the file edit a byte-exact match on the line the attach
1089 // (or `mux wall add`) wrote.
1090 if (wall.statePath(alloc)) |path| {
1091 defer alloc.free(path);
1092 _ = wall.forget(alloc, path, t.r.label) catch |err| {
1093 if (err_out.* == null) err_out.* = err;
1094 };
1095 } else |err| if (err_out.* == null) {
1096 err_out.* = err;
1097 }
1098
1099 present[sel] = false;
1100 t.gone.store(true, .release);
1101 ring(t);
1102 // Somewhere sane: the next tile down, wrapping — and the old index
1103 // when nothing is left, which the callers guard on `present`.
1104 relayout(alloc, tiles, present, shared, stepPresent(present, sel, true) orelse sel);
1105 }
1106
925 fn ttySize(fd: std.posix.fd_t) ?proto.Size { 1107 fn ttySize(fd: std.posix.fd_t) ?proto.Size {
926 if (!std.posix.isatty(fd)) return null; 1108 if (!std.posix.isatty(fd)) return null;
927 var ws: std.posix.winsize = undefined; 1109 var ws: std.posix.winsize = undefined;
@@ -1004,6 +1186,17 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved) !u8 {
1004 // Where `Ctrl-\ l` goes back to. Keyboard-thread state: no pump reads 1186 // Where `Ctrl-\ l` goes back to. Keyboard-thread state: no pump reads
1005 // it, and no lock guards it, because nothing else writes it. 1187 // it, and no lock guards it, because nothing else writes it.
1006 var last_zoom: ?usize = null; 1188 var last_zoom: ?usize = null;
1189 // Which tiles are still on the wall. The keyboard's own copy of what
1190 // `Tile.gone` says, so the pure chord tables (`selectKey`, `zoomChord`)
1191 // stay pure — they decide what a key MEANS with no atomics and no
1192 // terminal, which is what lets them be tested at all. Written only
1193 // beside the `gone` store, in `forgetTile`.
1194 const present = try alloc.alloc(bool, tiles.len);
1195 @memset(present, true);
1196 // The first wall-file error `x` hit, said after the terminal is
1197 // restored: a line printed onto the alternate screen would corrupt the
1198 // paint it lands in.
1199 var forget_err: ?anyerror = null;
1007 var b: [mailbox_max]u8 = undefined; 1200 var b: [mailbox_max]u8 = undefined;
1008 keys: while (true) { 1201 keys: while (true) {
1009 const n = std.posix.read(stdin_fd, &b) catch break; 1202 const n = std.posix.read(stdin_fd, &b) catch break;
@@ -1016,7 +1209,7 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved) !u8 {
1016 // it is queued BEFORE the zoom moves — so `Ctrl-\ n` cannot 1209 // it is queued BEFORE the zoom moves — so `Ctrl-\ n` cannot
1017 // deliver the tail of a word to the tile it is jumping to. 1210 // deliver the tail of a word to the tile it is jumping to.
1018 if (cmd.forward.len > 0) sendKeys(&tiles[z], cmd.forward); 1211 if (cmd.forward.len > 0) sendKeys(&tiles[z], cmd.forward);
1019 switch (zoomChord(cmd.action, z, tiles.len, last_zoom)) { 1212 switch (zoomChord(cmd.action, z, present, last_zoom)) {
1020 .stay => {}, 1213 .stay => {},
1021 .out => { 1214 .out => {
1022 last_zoom = z; 1215 last_zoom = z;
@@ -1040,6 +1233,9 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved) !u8 {
1040 // value only this thread can change is not one it can read 1233 // value only this thread can change is not one it can read
1041 // stale. 1234 // stale.
1042 if (key == '\r' or key == '\n') { 1235 if (key == '\r' or key == '\n') {
1236 // An empty wall has nothing to hand the terminal to, and
1237 // `sel` still names the tile `x` just took away.
1238 if (shared.sel >= present.len or !present[shared.sel]) continue :keys;
1043 setZoom(tiles, &shared, shared.sel); 1239 setZoom(tiles, &shared, shared.sel);
1044 // The rest of this read was typed at the WALL, before the 1240 // The rest of this read was typed at the WALL, before the
1045 // terminal changed hands — it is not the session's input. 1241 // terminal changed hands — it is not the session's input.
@@ -1047,7 +1243,14 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved) !u8 {
1047 prefix = .{}; 1243 prefix = .{};
1048 continue :keys; 1244 continue :keys;
1049 } 1245 }
1050 if (selectKey(shared.sel, tiles.len, key)) |next| moveSelection(tiles, &shared, next); 1246 // `x` forgets the selected tile: off the wall file, off this
1247 // screen, and NEVER off the daemon.
1248 if (key == 'x') {
1249 if (shared.sel < present.len and present[shared.sel])
1250 forgetTile(alloc, tiles, present, &shared, shared.sel, &forget_err);
1251 continue;
1252 }
1253 if (selectKey(present, shared.sel, key)) |next| moveSelection(tiles, &shared, next);
1051 } 1254 }
1052 } 1255 }
1053 1256
@@ -1058,6 +1261,11 @@ pub fn run(alloc: std.mem.Allocator, resolved: []const Resolved) !u8 {
1058 shared.paint_mu.lock(); 1261 shared.paint_mu.lock();
1059 proto.writeAllFd(stdout_fd, "\x1b[?7h\x1b[?25h\x1b[?1049l") catch {}; 1262 proto.writeAllFd(stdout_fd, "\x1b[?7h\x1b[?25h\x1b[?1049l") catch {};
1060 std.posix.tcsetattr(stdin_fd, .FLUSH, orig) catch {}; 1263 std.posix.tcsetattr(stdin_fd, .FLUSH, orig) catch {};
1264 // Now that the ordinary screen is back: an `x` whose file edit failed
1265 // still removed the tile from this wall, so the sentence is about the
1266 // RECORD not keeping up, not about the removal not happening.
1267 if (forget_err) |err|
1268 std.debug.print("mux: wall file not updated ({s})\n", .{@errorName(err)});
1061 // exit(2), not return: returning would run the caller's frees and leak 1269 // exit(2), not return: returning would run the caller's frees and leak
1062 // checks while detached pump threads still hold pointers into `tiles` 1270 // checks while detached pump threads still hold pointers into `tiles`
1063 // and their own live transports — the window between here and process 1271 // and their own live transports — the window between here and process
@@ -1116,60 +1324,110 @@ test "labelText: the state word survives truncation at every width" {
1116 try std.testing.expect(widest.len <= buf.len); 1324 try std.testing.expect(widest.len <= buf.len);
1117 } 1325 }
1118 1326
1327 /// A wall of `n` tiles with none forgotten — what every pre-`x` assertion
1328 /// about motion is really about.
1329 fn allPresent(comptime n: usize) [n]bool {
1330 return [_]bool{true} ** n;
1331 }
1332
1119 test "selectKey: j/k and n/p wrap at both ends" { 1333 test "selectKey: j/k and n/p wrap at both ends" {
1120 try std.testing.expectEqual(@as(?usize, 1), selectKey(0, 3, 'j')); 1334 const p3 = allPresent(3);
1121 try std.testing.expectEqual(@as(?usize, 0), selectKey(2, 3, 'j')); // wraps forward 1335 try std.testing.expectEqual(@as(?usize, 1), selectKey(&p3, 0, 'j'));
1122 try std.testing.expectEqual(@as(?usize, 2), selectKey(0, 3, 'k')); // wraps back 1336 try std.testing.expectEqual(@as(?usize, 0), selectKey(&p3, 2, 'j')); // wraps forward
1123 try std.testing.expectEqual(@as(?usize, 1), selectKey(2, 3, 'k')); 1337 try std.testing.expectEqual(@as(?usize, 2), selectKey(&p3, 0, 'k')); // wraps back
1338 try std.testing.expectEqual(@as(?usize, 1), selectKey(&p3, 2, 'k'));
1124 // The ring's spelling moves the same way as the vi hands. 1339 // The ring's spelling moves the same way as the vi hands.
1125 try std.testing.expectEqual(selectKey(2, 3, 'j'), selectKey(2, 3, 'n')); 1340 try std.testing.expectEqual(selectKey(&p3, 2, 'j'), selectKey(&p3, 2, 'n'));
1126 try std.testing.expectEqual(selectKey(0, 3, 'k'), selectKey(0, 3, 'p')); 1341 try std.testing.expectEqual(selectKey(&p3, 0, 'k'), selectKey(&p3, 0, 'p'));
1127 // A one-tile wall has nowhere to go, and says so by not moving. 1342 // A one-tile wall has nowhere to go, and says so by not moving.
1128 try std.testing.expectEqual(@as(?usize, 0), selectKey(0, 1, 'j')); 1343 const p1 = allPresent(1);
1344 try std.testing.expectEqual(@as(?usize, 0), selectKey(&p1, 0, 'j'));
1129 } 1345 }
1130 1346
1131 test "selectKey: digits jump 1-based, and a digit past the wall is ignored" { 1347 test "selectKey: digits jump 1-based, and a digit past the wall is ignored" {
1132 try std.testing.expectEqual(@as(?usize, 0), selectKey(2, 3, '1')); 1348 const p3 = allPresent(3);
1133 try std.testing.expectEqual(@as(?usize, 2), selectKey(0, 3, '3')); 1349 try std.testing.expectEqual(@as(?usize, 0), selectKey(&p3, 2, '1'));
1350 try std.testing.expectEqual(@as(?usize, 2), selectKey(&p3, 0, '3'));
1134 // Out of range moves nothing rather than clamping to the last tile. 1351 // Out of range moves nothing rather than clamping to the last tile.
1135 try std.testing.expectEqual(@as(?usize, null), selectKey(0, 3, '4')); 1352 try std.testing.expectEqual(@as(?usize, null), selectKey(&p3, 0, '4'));
1136 try std.testing.expectEqual(@as(?usize, null), selectKey(0, 3, '9')); 1353 try std.testing.expectEqual(@as(?usize, null), selectKey(&p3, 0, '9'));
1137 // '0' is not a tile number, and neither is a key that means nothing. 1354 // '0' is not a tile number, and neither is a key that means nothing.
1138 try std.testing.expectEqual(@as(?usize, null), selectKey(1, 3, '0')); 1355 try std.testing.expectEqual(@as(?usize, null), selectKey(&p3, 1, '0'));
1139 try std.testing.expectEqual(@as(?usize, null), selectKey(1, 3, 'x')); 1356 try std.testing.expectEqual(@as(?usize, null), selectKey(&p3, 1, 'q'));
1140 try std.testing.expectEqual(@as(?usize, null), selectKey(0, 0, 'j')); 1357 const p0 = allPresent(0);
1358 try std.testing.expectEqual(@as(?usize, null), selectKey(&p0, 0, 'j'));
1359 }
1360
1361 test "selectKey: a forgotten tile is stepped over and never counted" {
1362 // Middle tile forgotten by `x`. Tiles are never compacted — the pumps
1363 // hold pointers into the array — so index 1 stays a hole forever.
1364 const p = [_]bool{ true, false, true };
1365 try std.testing.expectEqual(@as(?usize, 2), selectKey(&p, 0, 'j'));
1366 try std.testing.expectEqual(@as(?usize, 0), selectKey(&p, 2, 'j'));
1367 try std.testing.expectEqual(@as(?usize, 2), selectKey(&p, 0, 'k'));
1368 // The digits renumber with the BARS: `2` is the second one showing,
1369 // which is tile 2, and `3` is now past the end of the wall.
1370 try std.testing.expectEqual(@as(?usize, 0), selectKey(&p, 2, '1'));
1371 try std.testing.expectEqual(@as(?usize, 2), selectKey(&p, 0, '2'));
1372 try std.testing.expectEqual(@as(?usize, null), selectKey(&p, 0, '3'));
1373
1374 // Standing ON the hole (the moment after `x`, before the selection has
1375 // moved) still steps to a real tile.
1376 try std.testing.expectEqual(@as(?usize, 2), selectKey(&p, 1, 'j'));
1377 try std.testing.expectEqual(@as(?usize, 0), selectKey(&p, 1, 'k'));
1378
1379 // The last tile forgotten: nothing is a selection any more, and every
1380 // motion says so rather than picking a tile that is gone.
1381 const none = [_]bool{ false, false };
1382 try std.testing.expectEqual(@as(?usize, null), selectKey(&none, 0, 'j'));
1383 try std.testing.expectEqual(@as(?usize, null), selectKey(&none, 0, 'k'));
1384 try std.testing.expectEqual(@as(?usize, null), selectKey(&none, 0, '1'));
1385
1386 // One survivor is its own answer, exactly as a one-tile wall is.
1387 const one = [_]bool{ false, true, false };
1388 try std.testing.expectEqual(@as(?usize, 1), selectKey(&one, 1, 'j'));
1389 try std.testing.expectEqual(@as(?usize, 1), selectKey(&one, 0, 'k'));
1141 } 1390 }
1142 1391
1143 test "zoomChord: d and w both leave, n/p move the zoom, c is swallowed" { 1392 test "zoomChord: d and w both leave, n/p move the zoom, c is swallowed" {
1393 const p3 = allPresent(3);
1394 const p1 = allPresent(1);
1144 // Both spellings of "give the wall back". `d` is the muscle memory the 1395 // Both spellings of "give the wall back". `d` is the muscle memory the
1145 // child-spawn zoom left behind; `w` is where the model is going. 1396 // child-spawn zoom left behind; `w` is where the model is going.
1146 try std.testing.expectEqual(ZoomMove.out, zoomChord(.detach, 1, 3, null)); 1397 try std.testing.expectEqual(ZoomMove.out, zoomChord(.detach, 1, &p3, null));
1147 try std.testing.expectEqual(ZoomMove.out, zoomChord(.wall, 1, 3, null)); 1398 try std.testing.expectEqual(ZoomMove.out, zoomChord(.wall, 1, &p3, null));
1148 // The zoom moves by the same motion the selection does, wrapping. 1399 // The zoom moves by the same motion the selection does, wrapping.
1149 try std.testing.expectEqual(ZoomMove{ .to = 2 }, zoomChord(.next_session, 1, 3, null)); 1400 try std.testing.expectEqual(ZoomMove{ .to = 2 }, zoomChord(.next_session, 1, &p3, null));
1150 try std.testing.expectEqual(ZoomMove{ .to = 0 }, zoomChord(.next_session, 2, 3, null)); 1401 try std.testing.expectEqual(ZoomMove{ .to = 0 }, zoomChord(.next_session, 2, &p3, null));
1151 try std.testing.expectEqual(ZoomMove{ .to = 2 }, zoomChord(.prev_session, 0, 3, null)); 1402 try std.testing.expectEqual(ZoomMove{ .to = 2 }, zoomChord(.prev_session, 0, &p3, null));
1152 // `c` creates in the client and will create here in phase 2; today it 1403 // `c` creates in the client and will create here in a later phase;
1153 // is swallowed rather than half-implemented. 1404 // today it is swallowed rather than half-implemented.
1154 try std.testing.expectEqual(ZoomMove.stay, zoomChord(.new_session, 1, 3, null)); 1405 try std.testing.expectEqual(ZoomMove.stay, zoomChord(.new_session, 1, &p3, null));
1155 try std.testing.expectEqual(ZoomMove.stay, zoomChord(.none, 1, 3, null)); 1406 try std.testing.expectEqual(ZoomMove.stay, zoomChord(.none, 1, &p3, null));
1156 // A one-tile wall has nowhere to skip to, and says so by not moving. 1407 // A one-tile wall has nowhere to skip to, and says so by not moving.
1157 try std.testing.expectEqual(ZoomMove{ .to = 0 }, zoomChord(.next_session, 0, 1, null)); 1408 try std.testing.expectEqual(ZoomMove{ .to = 0 }, zoomChord(.next_session, 0, &p1, null));
1158 } 1409 }
1159 1410
1160 test "zoomChord: `l` goes back, and unzooms when there is nowhere to go" { 1411 test "zoomChord: `l` goes back, and unzooms when there is nowhere to go" {
1161 try std.testing.expectEqual(ZoomMove{ .to = 0 }, zoomChord(.last_session, 2, 3, 0)); 1412 const p3 = allPresent(3);
1413 const p2 = allPresent(2);
1414 const p1 = allPresent(1);
1415 try std.testing.expectEqual(ZoomMove{ .to = 0 }, zoomChord(.last_session, 2, &p3, 0));
1162 // The spec's chosen fallback: nowhere remembered means the wall, not a 1416 // The spec's chosen fallback: nowhere remembered means the wall, not a
1163 // guess. Once `x` can forget a tile (phase 2) an index past the wall's 1417 // guess. An index past the wall's end takes the same route.
1164 // end takes the same route. 1418 try std.testing.expectEqual(ZoomMove.out, zoomChord(.last_session, 2, &p3, null));
1165 try std.testing.expectEqual(ZoomMove.out, zoomChord(.last_session, 2, 3, null)); 1419 try std.testing.expectEqual(ZoomMove.out, zoomChord(.last_session, 1, &p2, 7));
1166 try std.testing.expectEqual(ZoomMove.out, zoomChord(.last_session, 1, 2, 7)); 1420 // ...and so does a tile that has since been FORGOTTEN by `x`, which is
1421 // the case the spec named and the reason `present` is consulted at all
1422 // rather than just the length.
1423 const forgotten = [_]bool{ false, true, true };
1424 try std.testing.expectEqual(ZoomMove.out, zoomChord(.last_session, 2, &forgotten, 0));
1167 // "Go where I was" pointed at where you ARE is not "stay here": tmux's 1425 // "Go where I was" pointed at where you ARE is not "stay here": tmux's
1168 // prefix-l answers it the same way, and re-zooming in place would 1426 // prefix-l answers it the same way, and re-zooming in place would
1169 // clear the screen and repaint it to no visible effect. 1427 // clear the screen and repaint it to no visible effect.
1170 try std.testing.expectEqual(ZoomMove.out, zoomChord(.last_session, 1, 3, 1)); 1428 try std.testing.expectEqual(ZoomMove.out, zoomChord(.last_session, 1, &p3, 1));
1171 // A one-tile wall is that case always, so `l` there is simply "out". 1429 // A one-tile wall is that case always, so `l` there is simply "out".
1172 try std.testing.expectEqual(ZoomMove.out, zoomChord(.last_session, 0, 1, 0)); 1430 try std.testing.expectEqual(ZoomMove.out, zoomChord(.last_session, 0, &p1, 0));
1173 } 1431 }
1174 1432
1175 test "zoomChord: the chords come out of the client's own table" { 1433 test "zoomChord: the chords come out of the client's own table" {
@@ -1177,26 +1435,27 @@ test "zoomChord: the chords come out of the client's own table" {
1177 // only their MEANING is decided here. Fed as a real client would feed 1435 // only their MEANING is decided here. Fed as a real client would feed
1178 // it — split across reads, because a read boundary is not a chord 1436 // it — split across reads, because a read boundary is not a chord
1179 // boundary — so a drift in either half fails here. 1437 // boundary — so a drift in either half fails here.
1438 const pt = allPresent(2);
1180 var f: client.PrefixFilter = .{}; 1439 var f: client.PrefixFilter = .{};
1181 var first = "vi\x1c".*; 1440 var first = "vi\x1c".*;
1182 const a = f.feed(&first); 1441 const a = f.feed(&first);
1183 try std.testing.expectEqualStrings("vi", a.forward); 1442 try std.testing.expectEqualStrings("vi", a.forward);
1184 try std.testing.expectEqual(ZoomMove.stay, zoomChord(a.action, 0, 2, null)); 1443 try std.testing.expectEqual(ZoomMove.stay, zoomChord(a.action, 0, &pt, null));
1185 var second = "n".*; 1444 var second = "n".*;
1186 const b = f.feed(&second); 1445 const b = f.feed(&second);
1187 try std.testing.expectEqualStrings("", b.forward); 1446 try std.testing.expectEqualStrings("", b.forward);
1188 try std.testing.expectEqual(ZoomMove{ .to = 1 }, zoomChord(b.action, 0, 2, null)); 1447 try std.testing.expectEqual(ZoomMove{ .to = 1 }, zoomChord(b.action, 0, &pt, null));
1189 1448
1190 // A doubled prefix is the client's second spelling of detach, and it 1449 // A doubled prefix is the client's second spelling of detach, and it
1191 // unzooms here for the same reason `d` does. 1450 // unzooms here for the same reason `d` does.
1192 var dbl = "\x1c\x1c".*; 1451 var dbl = "\x1c\x1c".*;
1193 try std.testing.expectEqual(ZoomMove.out, zoomChord(f.feed(&dbl).action, 1, 2, null)); 1452 try std.testing.expectEqual(ZoomMove.out, zoomChord(f.feed(&dbl).action, 1, &pt, null));
1194 // An unknown command key is swallowed with its prefix, and swallowing 1453 // An unknown command key is swallowed with its prefix, and swallowing
1195 // it must not move the zoom. 1454 // it must not move the zoom.
1196 var unk = "a\x1czb".*; 1455 var unk = "a\x1czb".*;
1197 const u = f.feed(&unk); 1456 const u = f.feed(&unk);
1198 try std.testing.expectEqualStrings("ab", u.forward); 1457 try std.testing.expectEqualStrings("ab", u.forward);
1199 try std.testing.expectEqual(ZoomMove.stay, zoomChord(u.action, 1, 2, 0)); 1458 try std.testing.expectEqual(ZoomMove.stay, zoomChord(u.action, 1, &pt, 0));
1200 } 1459 }
1201 1460
1202 // The splice this refuses is the one that matters: keep the head of a chunk 1461 // The splice this refuses is the one that matters: keep the head of a chunk
test/e2e.sh
Old New
@@ -332,6 +332,24 @@ D35PID=""
332 SOCK39="${TMPDIR:-/tmp}/muxd-e2e-zoomdead-$$.sock" 332 SOCK39="${TMPDIR:-/tmp}/muxd-e2e-zoomdead-$$.sock"
333 D36PID="" 333 D36PID=""
334 334
335 # The wall as attach HISTORY (phase 2). A daemon AND a state home of its
336 # own, for the dynamic-wall leg's reason turned up one notch: what these
337 # two blocks read back is the wall FILE, and every `mux` in this suite now
338 # writes a tile into the shared $XDG_STATE_HOME — so a file read out of
339 # that one would be every other block's attaches as much as this one's.
340 # The daemon is separate because the tile spelling contains the socket
341 # path, which is what the assertions grep for.
342 SOCK40="${TMPDIR:-/tmp}/muxd-e2e-wallhist-$$.sock"
343 WHSTATE="${TMPDIR:-/tmp}/mux-e2e-wallhist-state-$$"
344 WHWALL="$WHSTATE/mux/wall"
345 # A state home whose wall FILE is a directory: the unwritable case, which
346 # has to warn and let the attach happen anyway.
347 WHBAD="${TMPDIR:-/tmp}/mux-e2e-wallbad-$$"
348 # And a third, for the `x` block: its wall is built by two attaches and
349 # then eaten by `x`, so it must start empty and stay its own.
350 WHXSTATE="${TMPDIR:-/tmp}/mux-e2e-wallx-state-$$"
351 D37PID=""
352
335 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one 353 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one
336 # such line on exit; every field is a key=value pair, so a rename or reorder 354 # such line on exit; every field is a key=value pair, so a rename or reorder
337 # in the client shows up here as an empty read rather than a wrong number. 355 # in the client shows up here as an empty read rather than a wrong number.
@@ -949,6 +967,7 @@ cleanup() {
949 [ -n "$D34PID" ] && kill "$D34PID" 2>/dev/null || true 967 [ -n "$D34PID" ] && kill "$D34PID" 2>/dev/null || true
950 [ -n "$D35PID" ] && kill "$D35PID" 2>/dev/null || true 968 [ -n "$D35PID" ] && kill "$D35PID" 2>/dev/null || true
951 [ -n "$D36PID" ] && kill "$D36PID" 2>/dev/null || true 969 [ -n "$D36PID" ] && kill "$D36PID" 2>/dev/null || true
970 [ -n "$D37PID" ] && kill "$D37PID" 2>/dev/null || true
952 # The stops still precede the socket rm below, like SOCK14-17 above: 971 # The stops still precede the socket rm below, like SOCK14-17 above:
953 # unlinking a socket first would leave a live daemon nothing could reach 972 # unlinking a socket first would leave a live daemon nothing could reach
954 # by path. 973 # by path.
@@ -970,6 +989,7 @@ cleanup() {
970 [ -S "$SOCK37" ] && "$MUXD" stop --sock "$SOCK37" 2>/dev/null || true 989 [ -S "$SOCK37" ] && "$MUXD" stop --sock "$SOCK37" 2>/dev/null || true
971 [ -S "$SOCK38" ] && "$MUXD" stop --sock "$SOCK38" 2>/dev/null || true 990 [ -S "$SOCK38" ] && "$MUXD" stop --sock "$SOCK38" 2>/dev/null || true
972 [ -S "$SOCK39" ] && "$MUXD" stop --sock "$SOCK39" 2>/dev/null || true 991 [ -S "$SOCK39" ] && "$MUXD" stop --sock "$SOCK39" 2>/dev/null || true
992 [ -S "$SOCK40" ] && "$MUXD" stop --sock "$SOCK40" 2>/dev/null || true
973 993
974 # ---- the leak sweep (hygiene kit, 6a) ---- 994 # ---- the leak sweep (hygiene kit, 6a) ----
975 # Here rather than at the bottom of the file, which `set -e` reaches only 995 # Here rather than at the bottom of the file, which `set -e` reaches only
@@ -983,7 +1003,7 @@ cleanup() {
983 "$D14PID" "$D15PID" "$D16PID" "$D17PID" "$D18PID" "$D19PID" \ 1003 "$D14PID" "$D15PID" "$D16PID" "$D17PID" "$D18PID" "$D19PID" \
984 "$D20PID" "$D21PID" "$D22PID" "$D23PID" "$D24PID" "$D25PID" \ 1004 "$D20PID" "$D21PID" "$D22PID" "$D23PID" "$D24PID" "$D25PID" \
985 "$D26PID" "$D27PID" "$D28PID" "$D29PID" "$D30PID" "$D31PID" \ 1005 "$D26PID" "$D27PID" "$D28PID" "$D29PID" "$D30PID" "$D31PID" \
986 "$D32PID" "$D33PID" "$D34PID" "$D35PID" "$D36PID" 1006 "$D32PID" "$D33PID" "$D34PID" "$D35PID" "$D36PID" "$D37PID"
987 _leak=0 1007 _leak=0
988 leak_sweep "$_rc" || _leak=1 1008 leak_sweep "$_rc" || _leak=1
989 1009
@@ -1110,6 +1130,17 @@ cleanup() {
1110 "$OUT.dwws" "$OUT.dwws.err" "$OUT.dwdead" "$OUT.dwdead.err" \ 1130 "$OUT.dwws" "$OUT.dwws.err" "$OUT.dwdead" "$OUT.dwdead.err" \
1111 "$OUT.dwstop" 1131 "$OUT.dwstop"
1112 rm -rf "$DWSTATE" 1132 rm -rf "$DWSTATE"
1133 # The attach-history block: its daemon capture, the client captures, and
1134 # the state home holding the wall file both its scenarios read back.
1135 rm -f "$SOCK40" "$OUT.wh.d" "$OUT.wh1" "$OUT.wh1.err" "$OUT.wh2" \
1136 "$OUT.wh2.err" "$OUT.whb" "$OUT.whb.err" "$OUT.whc" "$OUT.whc.err" \
1137 "$OUT.whro" "$OUT.whro.err" "$OUT.whadd" "$OUT.whrm" "$OUT.whst" \
1138 "$OUT.whself" "$OUT.whph" \
1139 "$OUT.whxpc" "$OUT.whxcap" "$OUT.whxcap.err" "$OUT.whxst" \
1140 "$OUT.whxpc2" "$OUT.whxcap2" "$OUT.whxcap2.err" \
1141 "$OUT.whxa" "$OUT.whxa.err" "$OUT.whxb" "$OUT.whxb.err" \
1142 "$OUT.whstop"
1143 rm -rf "$WHSTATE" "$WHBAD" "$WHXSTATE"
1113 # The convergence files a FAILING assert_converged leaves behind 1144 # The convergence files a FAILING assert_converged leaves behind
1114 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not 1145 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not
1115 # chased here: on a failing run they are the evidence. 1146 # chased here: on a failing run they are the evidence.
@@ -5733,6 +5764,274 @@ assert_stopped "$SOCK39" "$D36PID" "zoom dead" "$OUT.zdstop"
5733 D36PID="" 5764 D36PID=""
5734 ok "a tile whose pump has died narrates its zoom and gets its stripe back" 5765 ok "a tile whose pump has died narrates its zoom and gets its stripe back"
5735 5766
5767 # ---- the wall is attach HISTORY -----------------------------------------
5768 #
5769 # The mechanical rule (wall-home-screen spec, phase 2): an attach that
5770 # CLAIMS the grid writes its tile; one that does not, does not. Four
5771 # witnesses, in one state home nothing else writes to:
5772 #
5773 # * a `mux` attach adds exactly one line, spelled `#0` — the RESOLVED
5774 # default name, because a wall line has to be a spelling a user could
5775 # type back, not the empty wire name the attach frame carries;
5776 # * a second attach to the same session adds nothing (dedup, byte-exact);
5777 # * `muxa` drives the SAME session and the file is byte-identical after
5778 # — the 0x0 invariant, made testable rather than merely stated. The
5779 # hash is anchored on a marker the agent actually landed, so a muxa
5780 # that did nothing at all cannot pass this leg by doing nothing;
5781 # * a session reached by the `Ctrl-\ c` chord earns its own tile, which
5782 # is the "the wall grows by the truth" clause — those re-dials happen
5783 # inside client.attach's own loop and never come back through main.
5784 #
5785 # Then the two failure faces: an unwritable wall file warns and does NOT
5786 # stop the attach, and 'mux wall add' refuses a bad spelling by name
5787 # without touching the file.
5788 "$MUXD" run --sock "$SOCK40" --shell /bin/sh > "$OUT.wh.d" 2>&1 &
5789 D37PID=$!
5790 wait_sock "$SOCK40" "$OUT.wh.d" "attach-history daemon never bound"
5791
5792 { printf 'printf "wh1-%%s\\n" pin\n'; sleep 2; printf '\034\034'; } | \
5793 XDG_STATE_HOME="$WHSTATE" timeout 40 "$MUX" --sock "$SOCK40" \
5794 > "$OUT.wh1" 2> "$OUT.wh1.err"
5795 wait_grid "$SOCK40" "wh1-pin" "attach history: the default session's marker"
5796 grep -qx -- "--sock $SOCK40#0" "$WHWALL" || {
5797 echo "e2e FAIL: attach history: the attach wrote no tile (or not '#0'):"
5798 cat "$WHWALL" 2>&1; exit 1; }
5799 _wh_n=$(wc -l < "$WHWALL")
5800 [ "$_wh_n" -eq 1 ] || {
5801 echo "e2e FAIL: attach history: one attach wrote $_wh_n lines:"
5802 cat "$WHWALL"; exit 1; }
5803
5804 { printf 'printf "wh2-%%s\\n" pin\n'; sleep 2; printf '\034\034'; } | \
5805 XDG_STATE_HOME="$WHSTATE" timeout 40 "$MUX" --sock "$SOCK40" \
5806 > "$OUT.wh2" 2> "$OUT.wh2.err"
5807 wait_grid "$SOCK40" "wh2-pin" "attach history: the second attach's marker"
5808 _wh_n=$(wc -l < "$WHWALL")
5809 [ "$_wh_n" -eq 1 ] || {
5810 echo "e2e FAIL: attach history: a second attach to the same session made"
5811 echo " $_wh_n lines — dedup is not deduping:"
5812 cat "$WHWALL"; exit 1; }
5813
5814 # muxa: the 0x0 invariant, as a hash. Its marker is the anchor — a muxa
5815 # that never attached would leave the file identical too, and this leg
5816 # would pass while proving nothing.
5817 WHHASH=$(sha256sum "$WHWALL" | cut -d' ' -f1)
5818 XDG_STATE_HOME="$WHSTATE" "$MUXA" send 'printf "whagent-%s\n" pin\n' \
5819 --sock "$SOCK40" > "$OUT.whb" 2> "$OUT.whb.err"
5820 wait_grid "$SOCK40" "whagent-pin" "attach history: the agent's own marker"
5821 [ "$WHHASH" = "$(sha256sum "$WHWALL" | cut -d' ' -f1)" ] || {
5822 echo "e2e FAIL: attach history: muxa attached at 0x0 and still wrote a tile"
5823 echo " — 'muxa never touches the wall' is broken:"
5824 cat "$WHWALL"; exit 1; }
5825
5826 # Ctrl-\ c: the chord re-dials full size, so the session it creates earns a
5827 # tile like any other attach. The chord gets a write of its own — a chord
5828 # ends its chunk, and a piped client reads chunks.
5829 { sleep 1; printf '\034c'; sleep 3; printf 'printf "whc-%%s\\n" pin\n'; \
5830 sleep 2; printf '\034\034'; } | \
5831 XDG_STATE_HOME="$WHSTATE" timeout 40 "$MUX" --sock "$SOCK40" \
5832 > "$OUT.whc" 2> "$OUT.whc.err"
5833 wait_grid "$SOCK40" "whc-pin" "attach history: the chord-created session's marker" 1
5834 grep -qx -- "--sock $SOCK40#1" "$WHWALL" || {
5835 echo "e2e FAIL: attach history: a session visited by Ctrl-\\ c earned no tile:"
5836 cat "$WHWALL"; cat "$OUT.whc.err"; exit 1; }
5837
5838 # An unwritable wall file: a DIRECTORY where the file belongs. One warning,
5839 # and an attach that happened anyway — best effort means the attach is the
5840 # act and the tile is only the record.
5841 mkdir -p "$WHBAD/mux/wall"
5842 { printf 'printf "whro-%%s\\n" pin\n'; sleep 2; printf '\034\034'; } | \
5843 XDG_STATE_HOME="$WHBAD" timeout 40 "$MUX" --sock "$SOCK40" \
5844 > "$OUT.whro" 2> "$OUT.whro.err"
5845 grep -q "wall not updated" "$OUT.whro.err" || {
5846 echo "e2e FAIL: attach history: an unwritable wall file said nothing:"
5847 cat "$OUT.whro.err"; exit 1; }
5848 wait_grid "$SOCK40" "whro-pin" "attach history: the attach an unwritable wall did not block"
5849
5850 # The self-attach refusal exits BEFORE the dial, so it must exit before the
5851 # write too: a tile is the record of an attach, and this one never happened.
5852 # Spelled with the environment rather than a session shell because that is
5853 # all the refusal reads — and it keeps the assertion a hash of one file
5854 # rather than a search for a line other legs also write.
5855 WHHASH=$(sha256sum "$WHWALL" | cut -d' ' -f1)
5856 set +e
5857 MUX_SOCK="$SOCK40" MUX_SESSION=0 XDG_STATE_HOME="$WHSTATE" \
5858 "$MUX" --sock "$SOCK40" > "$OUT.whself" 2>&1
5859 RC=$?
5860 set -e
5861 [ "$RC" -eq 2 ] || {
5862 echo "e2e FAIL: attach history: the self-attach refusal exited $RC, want 2:"
5863 cat "$OUT.whself"; exit 1; }
5864 grep -q "this shell is inside that session" "$OUT.whself" || {
5865 echo "e2e FAIL: attach history: the self-attach leg refused for another reason:"
5866 cat "$OUT.whself"; exit 1; }
5867 [ "$WHHASH" = "$(sha256sum "$WHWALL" | cut -d' ' -f1)" ] || {
5868 echo "e2e FAIL: attach history: a REFUSED self-attach still wrote a tile:"
5869 cat "$WHWALL"; exit 1; }
5870
5871 # 'mux wall add': validation at ADD time, in the grammar's own words, and
5872 # a refused spelling writes nothing at all.
5873 WHHASH=$(sha256sum "$WHWALL" | cut -d' ' -f1)
5874 set +e
5875 XDG_STATE_HOME="$WHSTATE" "$MUX" wall add "box#has space" > "$OUT.whadd" 2>&1
5876 RC=$?
5877 set -e
5878 [ "$RC" -ne 0 ] || {
5879 echo "e2e FAIL: attach history: 'mux wall add' accepted a bad session name"; exit 1; }
5880 grep -q "bad session name" "$OUT.whadd" || {
5881 echo "e2e FAIL: attach history: a refused add never named its reason:"
5882 cat "$OUT.whadd"; exit 1; }
5883 [ "$WHHASH" = "$(sha256sum "$WHWALL" | cut -d' ' -f1)" ] || {
5884 echo "e2e FAIL: attach history: a refused add still edited the file:"
5885 cat "$WHWALL"; exit 1; }
5886
5887 # ...and the pair that works, including `rm` of what is not there.
5888 XDG_STATE_HOME="$WHSTATE" "$MUX" wall add "whbox#work" > "$OUT.whadd" 2>&1
5889 grep -qx "whbox#work" "$WHWALL" || {
5890 echo "e2e FAIL: attach history: 'mux wall add' added nothing:"; cat "$WHWALL"; exit 1; }
5891 set +e
5892 XDG_STATE_HOME="$WHSTATE" "$MUX" wall rm "whbox#work" > "$OUT.whrm" 2>&1
5893 RC=$?
5894 set -e
5895 [ "$RC" -eq 0 ] || {
5896 echo "e2e FAIL: attach history: 'mux wall rm' exited $RC:"; cat "$OUT.whrm"; exit 1; }
5897 if grep -q "whbox#work" "$WHWALL"; then
5898 echo "e2e FAIL: attach history: 'mux wall rm' left the line behind:"
5899 cat "$WHWALL"; exit 1
5900 fi
5901 set +e
5902 XDG_STATE_HOME="$WHSTATE" "$MUX" wall rm "whbox#work" > "$OUT.whrm" 2>&1
5903 RC=$?
5904 set -e
5905 [ "$RC" -ne 0 ] || {
5906 echo "e2e FAIL: attach history: removing an absent tile exited 0"; exit 1; }
5907 grep -q "not on the wall" "$OUT.whrm" || {
5908 echo "e2e FAIL: attach history: an absent rm never said so:"; cat "$OUT.whrm"; exit 1; }
5909 ok "the wall is attach history: mux adds, muxa never does, add/rm edit it"
5910
5911 # ---- `x` forgets a tile, and never kills its session --------------------
5912 #
5913 # The wall this leg puts up is one nothing typed by hand: two attaches
5914 # BUILT it, which is the model's whole claim. Then `x` is asked for the two
5915 # things it must do and the one it must not — remove the line, re-cut the
5916 # wall, and leave the session running ("remove is detach").
5917 #
5918 # Two ptyclient runs rather than one, because the file is the artifact:
5919 # what a run did is only readable after it has exited, so the one-tile
5920 # state between them is the second run's input.
5921 { printf 'printf "whxa-%%s\\n" pin\n'; sleep 2; printf '\034\034'; } | \
5922 XDG_STATE_HOME="$WHXSTATE" timeout 40 "$MUX" --sock "$SOCK40" --session xa \
5923 > "$OUT.whxa" 2> "$OUT.whxa.err"
5924 wait_grid "$SOCK40" "whxa-pin" "x forgets: session xa's marker" xa
5925 { printf 'printf "whxb-%%s\\n" pin\n'; sleep 2; printf '\034\034'; } | \
5926 XDG_STATE_HOME="$WHXSTATE" timeout 40 "$MUX" --sock "$SOCK40" --session xb \
5927 > "$OUT.whxb" 2> "$OUT.whxb.err"
5928 wait_grid "$SOCK40" "whxb-pin" "x forgets: session xb's marker" xb
5929 _whx_n=$(wc -l < "$WHXSTATE/mux/wall")
5930 [ "$_whx_n" -eq 2 ] || {
5931 echo "e2e FAIL: x forgets: two attaches built a wall of $_whx_n tiles:"
5932 cat "$WHXSTATE/mux/wall"; exit 1; }
5933
5934 # No operands: the wall this shows is the file, which is the point. The
5935 # state home rides on PTYCLIENT itself and is inherited by the child —
5936 # ptyclient execs its `--` argv as given, so an `env` wrapper in front of
5937 # `mux` is not a command it runs, it is a program it fails to find.
5938 #
5939 # ONE marker expected, then `settle` — the pattern every other two-tile
5940 # ptyclient leg here uses, and for a reason review reproduced on this
5941 # suite: the tiles are independent threads, `expect` consumes forward, and
5942 # whichever stripe painted second leaves the other one's marker BEHIND the
5943 # cursor. Two consecutive expects against two pumps is a coin flip that
5944 # spends its whole budget waiting for bytes that already went past. What
5945 # the `x` below needs is a wall that is up and settled, which the single
5946 # expect plus `settle` gives; the tile it forgets is the SELECTED one,
5947 # tile 1, whose own marker is asserted from the file afterwards.
5948 set +e
5949 XDG_STATE_HOME="$WHXSTATE" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
5950 --out "$OUT.whxcap" --err "$OUT.whxcap.err" -- \
5951 "$MUX" wall > "$OUT.whxpc" 2>&1 <<'EOF'
5952 expect whxb-pin 20000
5953 settle 800 20000
5954 send x
5955 settle 800 20000
5956 send q
5957 waitexit 10000
5958 EOF
5959 RC=$?
5960 set -e
5961 [ "$RC" -eq 0 ] || {
5962 echo "e2e FAIL: x forgets: ptyclient leg exited $RC (did x forget the tile?):"
5963 cat "$OUT.whxpc"; exit 1; }
5964 # The line is gone and the survivor kept its place — `x` is an ordered
5965 # removal, not a rewrite of the wall.
5966 _whx_n=$(wc -l < "$WHXSTATE/mux/wall")
5967 [ "$_whx_n" -eq 1 ] || {
5968 echo "e2e FAIL: x forgets: the wall file holds $_whx_n lines after one x:"
5969 cat "$WHXSTATE/mux/wall"; exit 1; }
5970 grep -qx -- "--sock $SOCK40#xb" "$WHXSTATE/mux/wall" || {
5971 echo "e2e FAIL: x forgets: the wrong line survived:"
5972 cat "$WHXSTATE/mux/wall"; exit 1; }
5973 # The survivor's stripe came BACK at its new rows: counted, because one
5974 # paint is the wall's first draw and says nothing about the re-cut.
5975 _whx_bars=$(grep -o -- "--sock $SOCK40#xb \[up\]" "$OUT.whxcap" | wc -l)
5976 [ "$_whx_bars" -ge 2 ] || {
5977 echo "e2e FAIL: x forgets: the surviving tile's bar painted $_whx_bars time(s),"
5978 echo " so the wall was never re-cut around the hole:"
5979 cat "$OUT.whxpc"; exit 1; }
5980 # ...and the SESSION is untouched: it still answers, and it still holds
5981 # what it held. "Remove is detach" — the tile went, the session did not.
5982 XDG_STATE_HOME="$WHSTATE" "$MUXA" status --sock "$SOCK40" --session xa > "$OUT.whxst" 2>&1
5983 grep -q '"cols":80' "$OUT.whxst" || {
5984 echo "e2e FAIL: x forgets: session xa stopped answering — x killed it:"
5985 cat "$OUT.whxst"; exit 1; }
5986 wait_grid "$SOCK40" "whxa-pin" "x forgets: xa's grid outlived its tile" xa
5987
5988 # The last tile: an empty wall SAYS so rather than going blank.
5989 set +e
5990 XDG_STATE_HOME="$WHXSTATE" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
5991 --out "$OUT.whxcap2" --err "$OUT.whxcap2.err" -- \
5992 "$MUX" wall > "$OUT.whxpc2" 2>&1 <<'EOF'
5993 expect whxb-pin 20000
5994 settle 800 20000
5995 send x
5996 settle 800 20000
5997 send q
5998 waitexit 10000
5999 EOF
6000 RC=$?
6001 set -e
6002 [ "$RC" -eq 0 ] || {
6003 echo "e2e FAIL: x forgets: the last-tile leg exited $RC:"
6004 cat "$OUT.whxpc2"; exit 1; }
6005 grep -q "nothing left to show" "$OUT.whxcap2" || {
6006 echo "e2e FAIL: x forgets: forgetting the last tile left a blank terminal"
6007 echo " with nothing said:"; cat "$OUT.whxpc2"; exit 1; }
6008 [ ! -s "$WHXSTATE/mux/wall" ] || {
6009 echo "e2e FAIL: x forgets: the last x left lines behind:"
6010 cat "$WHXSTATE/mux/wall"; exit 1; }
6011 # The phantom tile, while this daemon is FULL (0, 1, xa, xb — max_sessions
6012 # is 4): a FIRST attach to a fifth name is refused, and a refusal must
6013 # leave no line behind. This is the case that made the seam move off "the
6014 # dial succeeded" — a dial that comes up is not an attach that landed, and
6015 # only a SWITCH's refusal ever had somewhere to undo the write from. The
6016 # leg cannot pass vacuously: a daemon with room would accept the attach and
6017 # the rc check below would fail loudly.
6018 WHHASH=$(sha256sum "$WHWALL" | cut -d' ' -f1)
6019 set +e
6020 { sleep 1; printf '\034\034'; } | XDG_STATE_HOME="$WHSTATE" timeout 40 \
6021 "$MUX" --sock "$SOCK40" --session phantom > "$OUT.whph" 2>&1
6022 RC=$?
6023 set -e
6024 [ "$RC" -ne 0 ] || {
6025 echo "e2e FAIL: x forgets: a fifth session was created on a full daemon,"
6026 echo " so the phantom-tile leg proved nothing:"; cat "$OUT.whph"; exit 1; }
6027 [ "$WHHASH" = "$(sha256sum "$WHWALL" | cut -d' ' -f1)" ] || {
6028 echo "e2e FAIL: x forgets: a REFUSED first attach recorded a phantom tile:"
6029 cat "$WHWALL"; exit 1; }
6030 assert_stopped "$SOCK40" "$D37PID" "x forgets" "$OUT.whstop"
6031 D37PID=""
6032 ok "x forgets a tile and leaves its session running; a refusal records nothing"
6033
6034
5736 6035
5737 # The long-lived daemon has served every scenario that wanted it; stop it 6036 # The long-lived daemon has served every scenario that wanted it; stop it
5738 # NOW so its allocator verdict is written while the suite is still running 6037 # NOW so its allocator verdict is written while the suite is still running
@@ -5816,9 +6115,15 @@ DPID=""
5816 # the tile whose pump died, and no convergence point because its subject is 6115 # the tile whose pump died, and no convergence point because its subject is
5817 # a session that DOES NOT EXIST: what the leg reads is what a terminal was 6116 # a session that DOES NOT EXIST: what the leg reads is what a terminal was
5818 # told about a refused attach, and there is no grid on either side to 6117 # told about a refused attach, and there is no grid on either side to
5819 # converge. 6118 # converge. The 47th is the wall as attach history, and no convergence
5820 [ "$OK_COUNT" = "46" ] || { 6119 # point because its subject is a FILE — lines a client wrote beside the
5821 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 46 —" 6120 # session, plus a hash that must not move when an agent attaches — and no
6121 # grid records what was written down about it. The 48th is `x`, and no
6122 # convergence point either: what it asserts on is that file shrinking by
6123 # one line while the session it named goes on answering, which is two
6124 # facts a grid comparison speaks to neither of.
6125 [ "$OK_COUNT" = "48" ] || {
6126 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 48 —"
5822 echo " a scenario was added (update the pin) or silently lost" 6127 echo " a scenario was added (update the pin) or silently lost"
5823 exit 1 6128 exit 1
5824 } 6129 }
@@ -5826,4 +6131,4 @@ DPID=""
5826 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" 6131 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35"
5827 exit 1 6132 exit 1
5828 } 6133 }
5829 echo "e2e OK (46 scenarios, 35 convergence points)" 6134 echo "e2e OK (48 scenarios, 35 convergence points)"