a89535be
feat: x forgets a tile, and never kills its session
a73x 2026-08-20 00:50
Commit message
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)" |