a73x

520a8baa

feat: the daemon holds thirty-two sessions, wall-sized again

a73x   2026-08-25 16:50

Commit message
feat: the daemon holds thirty-two sessions, wall-sized again

Multipane grew the wall to wallview.max_tiles = 32 and the daemon's cap
stayed at 4, so a wall could ask for more sessions than the daemon would
ever hold — surfacing as a Ctrl-\ c the client refuses. The 4 was
"purpose bounds the surface" from the multi-session design, never a
measured limit: a session costs one engine, one pty and one shell, and an
empty slot polls fd -1.

stats_text_len is derived from max_sessions now instead of a literal with
a comptime assert beside it, so no future bump can truncate a stats
reply. The refusal tests are generalised off "a fifth name": the unit
test fills max_sessions - 1 generated names (closing each connection as
it lands, because max_clients is smaller than max_sessions), and the two
e2e legs fill their tables through a new fill_sessions helper.

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

README.md
Old New
@@ -124,8 +124,8 @@ nothing — there is nowhere to step to.
124 124
125 To see which sessions are alive without attaching to one, ask the daemon: 125 To see which sessions are alive without attaching to one, ask the daemon:
126 `muxd stats` names every live session with the number of clients watching it. 126 `muxd stats` names every live session with the number of clients watching it.
127 That is the question to ask when `mux` will not let you in — four sessions is 127 That is the question to ask when `mux` will not let you in — thirty-two
128 the table, and a fifth name has nowhere to go. 128 sessions is the table, and the thirty-third name has nowhere to go.
129 129
130 Every session shell is told where it lives: `$MUX_SOCK` is the daemon's 130 Every session shell is told where it lives: `$MUX_SOCK` is the daemon's
131 socket path and `$MUX_SESSION` is the session's name. Scripts and prompts 131 socket path and `$MUX_SESSION` is the session's name. Scripts and prompts
docs/decisions.md
Old New
@@ -6686,3 +6686,31 @@ stays silent. Someone who typed `muxd start` asked about a daemon and is owed a
6686 verdict on one; someone who typed `mux` asked for a session and is about to get 6686 verdict on one; someone who typed `mux` asked for a session and is about to get
6687 it. 6687 it.
6688 6688
6689 ## 2026-08-25 — max_sessions 4 → 32
6690
6691 The 4 was "purpose bounds the surface" from the multi-session design: a number
6692 picked to keep the new thing small, never a measured limit. Multipane then grew
6693 the wall to `wallview.max_tiles = 32` and the daemon cap did not move with it,
6694 so a wall could ask for more sessions than the daemon would ever hold. It
6695 surfaced as a silently refused `Ctrl-\ c` — the client does say "cannot create a
6696 new session", but the relayout repaint that follows eats the notice, which is a
6697 separate follow-up.
6698
6699 Nothing about the 4 was performance. A session costs one engine, one pty and one
6700 shell, slots are filled lazily, and an empty slot polls fd -1. The two places
6701 the count does scale are worst cases that need a misbehaving peer to bite: the
6702 per-death drain is `max_sessions × 250ms` only when the whole table dies into
6703 stalled clients in one pass, and `pty.term_grace_ms` is paid only by a child
6704 that survives the master's close and then ignores SIGTERM.
6705
6706 `stats_text_len` is now derived from `max_sessions` rather than a literal with a
6707 comptime assert beside it, so the next bump cannot truncate a stats reply — the
6708 assert would have caught it, but only by failing the build, and deriving the
6709 number means there is nothing to catch.
6710
6711 Filling a 32-slot table from a shell script found one thing worth writing down:
6712 `acceptConn` parks every new connection in an OBSERVER slot and promotes it to a
6713 client only when its attach frame arrives, so simultaneous dials contend for
6714 `max_observers` (4), not `max_clients` (8) — 7 at once lost 11 of 31 fills to a
6715 closed connection, 3 at once lost none. `muxd stop` on a full table of `/bin/sh`
6716 took 51ms, which is the grace-cost claim above, measured.
src/pty.zig
Old New
@@ -208,10 +208,11 @@ pub const Pty = struct {
208 return self.exit_status; 208 return self.exit_status;
209 } 209 }
210 210
211 /// How long the child gets to honour SIGTERM before SIGKILL. Generous 211 /// How long the child gets to honour SIGTERM before SIGKILL. Only a
212 /// for a shell that is only being asked to exit, and short enough that 212 /// child that survives the master's close (SIGHUP) AND ignores SIGTERM
213 /// four of them in a row (max_sessions) cannot hold a shutdown open for 213 /// — the leaked-fd case this bound exists for — pays it, so a full
214 /// a noticeable time. 214 /// table of well-behaved shells still exits in well under a second;
215 /// max_sessions stubborn ones cost max_sessions × term_grace_ms.
215 const term_grace_ms = 500; 216 const term_grace_ms = 500;
216 217
217 pub fn deinit(self: *Pty) void { 218 pub fn deinit(self: *Pty) void {
src/server.zig
Old New
@@ -254,7 +254,10 @@ const AwaitState = struct {
254 saw_busy: bool = false, 254 saw_busy: bool = false,
255 }; 255 };
256 256
257 pub const max_sessions = 4; 257 // Matches `wallview.max_tiles`: the 4 was surface-bounding (decisions.md,
258 // 2026-08-25), never a measured limit, and a wall of 32 tiles could not
259 // fill it.
260 pub const max_sessions = 32;
258 261
259 /// The smallest grid a session may exist at. Two owners used to decide 262 /// The smallest grid a session may exist at. Two owners used to decide
260 /// this and disagreed: resolveSession created at anything nonzero while 263 /// this and disagreed: resolveSession created at anything nonzero while
@@ -1022,7 +1025,9 @@ pub const Server = struct {
1022 // stalled bystander client of a surviving session spends it 1025 // stalled bystander client of a surviving session spends it
1023 // too, since the drain waits on ALL owed bytes. Both are the 1026 // too, since the drain waits on ALL owed bytes. Both are the
1024 // price of draining per-death, bounded by a compile-time 1027 // price of draining per-death, bounded by a compile-time
1025 // constant of 4. A dropped client's outstanding await needs 1028 // constant of 32 (`max_sessions`) — and only ever paid in
1029 // full when the whole table dies into stalled peers in one
1030 // pass. A dropped client's outstanding await needs
1026 // nothing here: the connection dying IS muxa's answer 1031 // nothing here: the connection dying IS muxa's answer
1027 // (decision 8), which is also why checkAwaits' session-less 1032 // (decision 8), which is also why checkAwaits' session-less
1028 // `continue` stays unreachable — no client survives its 1033 // `continue` stays unreachable — no client survives its
@@ -3208,31 +3213,14 @@ pub const Server = struct {
3208 try proto.writeFrame(fd, .stats_reply, try self.statsText(&buf)); 3213 try proto.writeFrame(fd, .stats_reply, try self.statsText(&buf));
3209 } 3214 }
3210 3215
3211 // 256 was sized for the single-session text; the per-session tail 3216 // Derived, not a literal, so no future max_sessions bump can truncate
3212 // can add several "session NAME clients=N seq=N" segments, one 3217 // somebody's stats reply. The 256 covers the main line, which measures
3213 // per live session up to max_sessions, and 256 stopped being enough 3218 // ~220: six u64 counters (snapshots, snapshot_bytes, deltas,
3214 // headroom for that plus the longest legal names. 3219 // delta_bytes, snapshot_equiv_bytes, attaches) plus two gauges (clients,
3215 // 3220 // sessions), each with its "name=" label and 20 digits for a u64 at its
3216 // The arithmetic the 576 answers to: the main line's six u64 counters 3221 // widest. The 44 is one per-session segment before its name: 23 of
3217 // (snapshots, snapshot_bytes, deltas, delta_bytes, snapshot_equiv_bytes, 3222 // literal text (" session " + " clients=" + " seq=") and 21 of digits.
3218 // attaches) plus the two gauges (clients, sessions), each with its 3223 pub const stats_text_len = 256 + max_sessions * (44 + proto.session_name_max);
3219 // "name=" label and a generous 20 digits for a u64 at its widest, comes
3220 // to roughly 220 bytes. `attaches` is what took it from 190 — and the
3221 // comptime assert below is what SAID so, failing the build the moment
3222 // the field was added rather than truncating somebody's stats reply at
3223 // four sessions. Each per-session segment is 44 bytes before the name: 23
3224 // of literal text (" session " + " clients=" + " seq=", 9 + 9 + 5) and
3225 // 21 of digits — one for clients, which max_clients bounds at 8, and 20
3226 // for seq at a u64's widest — plus up to session_name_max (32) bytes of
3227 // the name itself, so ~76 bytes
3228 // a session. Four sessions (max_sessions): 220 + 4*76 = 524 of 576 —
3229 // headroom, not a coincidence, and pinned below so a future
3230 // max_sessions bump (or another main-line field) fails the build
3231 // instead of silently truncating whoever asks for stats.
3232 pub const stats_text_len = 576;
3233 comptime {
3234 std.debug.assert(stats_text_len >= 220 + max_sessions * (44 + proto.session_name_max));
3235 }
3236 3224
3237 /// Every live name plus one separator each — one more separator than a 3225 /// Every live name plus one separator each — one more separator than a
3238 /// join uses, which is the slack that lets sessionsText be infallible. 3226 /// join uses, which is the slack that lets sessionsText be infallible.
@@ -7598,7 +7586,7 @@ test "Server: a bare 20-byte attach lands in the default session" {
7598 for (srv.sessions[1..]) |slot| try std.testing.expect(slot == null); 7586 for (srv.sessions[1..]) |slot| try std.testing.expect(slot == null);
7599 } 7587 }
7600 7588
7601 test "Server: attach to a fifth name is refused with exit_status, sessions intact" { 7589 test "Server: an attach past max_sessions is refused with exit_status, sessions intact" {
7602 const alloc = std.testing.allocator; 7590 const alloc = std.testing.allocator;
7603 7591
7604 var tmp = try TmpDir.make(); 7592 var tmp = try TmpDir.make();
@@ -7609,33 +7597,40 @@ test "Server: attach to a fifth name is refused with exit_status, sessions intac
7609 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" }); 7597 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/cat" });
7610 defer srv.deinit(); 7598 defer srv.deinit();
7611 7599
7612 // The default session holds slot 0 from init, so three names fill the 7600 // The default session holds slot 0 from init, so max_sessions-1 more
7613 // table. Each attach is confirmed before the next so the refusal below 7601 // names fill the table. Each attach is confirmed by its own snapshot
7614 // is unambiguously "no session slot", not a race. 7602 // before the next, so the refusal below is unambiguously "no session
7615 const names = [_][]const u8{ "b", "c", "d" }; 7603 // slot", not a race — and each connection is closed once confirmed
7616 var conns: [names.len]std.net.Stream = undefined; 7604 // because max_clients (8) is smaller than max_sessions: holding them
7617 var opened: usize = 0; 7605 // all open would fill the CLIENT table first and refuse for the wrong
7618 defer for (conns[0..opened]) |s| s.close(); 7606 // reason. The session outlives its client, which is what makes that
7619 for (names) |nm| { 7607 // safe.
7620 conns[opened] = try std.net.connectUnixSocket(sock_path); 7608 for (1..max_sessions) |i| {
7621 const fd = conns[opened].handle; 7609 var nb: [8]u8 = undefined;
7622 opened += 1; 7610 const nm = try std.fmt.bufPrint(&nb, "s{d}", .{i});
7623 try attachNamed(fd, 80, 24, nm); 7611 const c = try std.net.connectUnixSocket(sock_path);
7624 const f = (try awaitFrame(alloc, &srv, fd, .snapshot, 400)) orelse 7612 defer c.close();
7613 try attachNamed(c.handle, 80, 24, nm);
7614 const f = (try awaitFrame(alloc, &srv, c.handle, .snapshot, 400)) orelse
7625 return error.NoSnapshotFillingTable; 7615 return error.NoSnapshotFillingTable;
7626 f.deinit(alloc); 7616 f.deinit(alloc);
7627 } 7617 }
7618 // The closes above are only visible to the daemon once it polls, and a
7619 // client slot it still believes is live would refuse the probe below
7620 // before the session table ever got asked.
7621 for (0..8) |_| _ = try srv.pumpOnce(1);
7628 7622
7629 // The fifth name gets the same honest no a full client table gives. 7623 // One name past the table gets the same honest no a full client table
7624 // gives.
7630 const extra = try std.net.connectUnixSocket(sock_path); 7625 const extra = try std.net.connectUnixSocket(sock_path);
7631 defer extra.close(); 7626 defer extra.close();
7632 try attachNamed(extra.handle, 80, 24, "e"); 7627 try attachNamed(extra.handle, 80, 24, "extra");
7633 const f = (try awaitFrame(alloc, &srv, extra.handle, .exit_status, 400)) orelse 7628 const f = (try awaitFrame(alloc, &srv, extra.handle, .exit_status, 400)) orelse
7634 return error.NoRefusal; 7629 return error.NoRefusal;
7635 defer f.deinit(alloc); 7630 defer f.deinit(alloc);
7636 try std.testing.expect(f.payload.len == 1 and f.payload[0] == 1); 7631 try std.testing.expect(f.payload.len == 1 and f.payload[0] == 1);
7637 7632
7638 // And it cost nobody anything: four live shells, none exited. 7633 // And it cost nobody anything: every slot's shell is live, none exited.
7639 for (0..max_sessions) |si| { 7634 for (0..max_sessions) |si| {
7640 try std.testing.expect(srv.sessions[si] != null); 7635 try std.testing.expect(srv.sessions[si] != null);
7641 try std.testing.expect(srv.sessions[si].?.pty.checkExited() == null); 7636 try std.testing.expect(srv.sessions[si].?.pty.checkExited() == null);
test/e2e.sh
Old New
@@ -655,6 +655,42 @@ await_out() {
655 } 655 }
656 } 656 }
657 657
658 # fill_sessions SOCK STATE PREFIX FROM TO — one throwaway pipe attach per
659 # name so the daemon holds a slot for each. The session outlives the client
660 # that made it, which is what lets these detach immediately.
661 #
662 # THREE at a time, and the number is `max_observers` minus one, not
663 # `max_clients` minus one: `acceptConn` parks EVERY new connection in an
664 # observer slot and promotes it to a client only when its attach frame
665 # arrives, so simultaneous dials contend for the four observer slots, and
666 # the ones with nowhere to land are closed outright. Measured at batch 7
667 # on a 32-slot daemon: 11 of 31 fills died with "connection to muxd lost"
668 # and the table never filled; at batch 3, 31 of 31 land.
669 #
670 # STATE is a scratch home so the fills never write a wall line the leg
671 # later counts. The last name is asked back through muxa so a fill that
672 # never landed fails here with its own log rather than as the refusal leg
673 # proving nothing.
674 fill_sessions() {
675 _fs_sock="$1"; _fs_state="$2"; _fs_pfx="$3"; _fs_i="$4"; _fs_to="$5"
676 while [ "$_fs_i" -le "$_fs_to" ]; do
677 _fs_pids=""; _fs_j=0
678 while [ "$_fs_j" -lt 3 ] && [ "$_fs_i" -le "$_fs_to" ]; do
679 { sleep 1.5; printf '\034\034'; } | XDG_STATE_HOME="$_fs_state" timeout 40 \
680 "$MUX" --sock "$_fs_sock" --session "$_fs_pfx$_fs_i" \
681 > "$OUT.fill.$_fs_pfx$_fs_i" 2>&1 &
682 _fs_pids="$_fs_pids $!"
683 _fs_i=$((_fs_i + 1)); _fs_j=$((_fs_j + 1))
684 done
685 for _fs_p in $_fs_pids; do wait "$_fs_p" || {
686 echo "e2e FAIL: fill_sessions $_fs_pfx: a fill attach on $_fs_sock exited nonzero:"
687 cat "$OUT".fill."$_fs_pfx"*; exit 1; }; done
688 done
689 "$MUXA" status --sock "$_fs_sock" --session "$_fs_pfx$_fs_to" > "$OUT.fill.$_fs_pfx.st" 2>&1 || {
690 echo "e2e FAIL: fill_sessions: $_fs_pfx$_fs_to was never created, the daemon is not full:"
691 cat "$OUT.fill.$_fs_pfx.st"; exit 1; }
692 }
693
658 # repaints FILE — how many full repaints (ESC[2J) a capture holds. The 694 # repaints FILE — how many full repaints (ESC[2J) a capture holds. The
659 # first paint after a reconnect is always a full one (interact.zig 695 # first paint after a reconnect is always a full one (interact.zig
660 # `repaint_after_resync`), so a count that rose is the resume itself, seen 696 # `repaint_after_resync`), so a count that rose is the resume itself, seen
@@ -1532,6 +1568,12 @@ cleanup() {
1532 "$OUT.whxa" "$OUT.whxa.err" "$OUT.whxb" "$OUT.whxb.err" \ 1568 "$OUT.whxa" "$OUT.whxa.err" "$OUT.whxb" "$OUT.whxb.err" \
1533 "$OUT.whstop" 1569 "$OUT.whstop"
1534 rm -rf "$WHSTATE" "$WHBAD" "$WHXSTATE" 1570 rm -rf "$WHSTATE" "$WHBAD" "$WHXSTATE"
1571 # fill_sessions' captures, one per filled name, and the scratch state
1572 # homes the fills write their own wall lines into. A glob rather than a
1573 # list because the names are generated: 28 per caller, and a list would
1574 # go stale the moment max_sessions moves again.
1575 rm -f "$OUT".fill.*
1576 rm -rf "$OUT.nswfill" "$OUT.whfill"
1535 # ...and the wall-chord leg's own state home, which holds the two-line 1577 # ...and the wall-chord leg's own state home, which holds the two-line
1536 # wall that leg builds and reads back. 1578 # wall that leg builds and reads back.
1537 rm -rf "$M4STATE" 1579 rm -rf "$M4STATE"
@@ -5148,6 +5190,12 @@ ok "Ctrl-\\ prefix: an unknown chord is swallowed, Ctrl-\\ d detaches"
5148 "$MUXD" run --sock "$SOCK28" --shell /bin/sh > "$OUT.nsw.d" 2>&1 & 5190 "$MUXD" run --sock "$SOCK28" --shell /bin/sh > "$OUT.nsw.d" 2>&1 &
5149 D25PID=$! 5191 D25PID=$!
5150 wait_sock "$SOCK28" "$OUT.nsw.d" "new-session daemon never bound" 5192 wait_sock "$SOCK28" "$OUT.nsw.d" "new-session daemon never bound"
5193 # The refusal this leg's fourth chord asserts needs a FULL table. The
5194 # chords make four (0, 1, 2, 3); the other 28 are filled here. `fillN` is
5195 # not an integer, so nextFreeName still hands the chords 1, 2, 3 and
5196 # reaches for "4" on the refused one — which is the name the post-check
5197 # below asks about.
5198 fill_sessions "$SOCK28" "$OUT.nswfill" fill 4 31
5151 5199
5152 set +e 5200 set +e
5153 # tall: Ctrl-\ c adds a second tile at 80x24 (80 >= 48) 5201 # tall: Ctrl-\ c adds a second tile at 80x24 (80 >= 48)
@@ -5199,8 +5247,8 @@ grep -q "m2a-pin" "$OUT.nswcap" || {
5199 grep -q "m2b-pin" "$OUT.nswcap" && { 5247 grep -q "m2b-pin" "$OUT.nswcap" && {
5200 echo "e2e FAIL: new session: the second marker ran in the OLD session" 5248 echo "e2e FAIL: new session: the second marker ran in the OLD session"
5201 cat "$OUT.nswcap"; exit 1; } 5249 cat "$OUT.nswcap"; exit 1; }
5202 # The fourth chord had nowhere to go: max_sessions is 4 and the leg filled 5250 # The fourth chord had nowhere to go: the fill above took 28 slots and the
5203 # the table (0, 1, 2, 3), so the daemon answered the attach with an 5251 # chords the other four (0, 1, 2, 3), so the daemon answered the attach with an
5204 # exit_status before a single frame of state. That refusal is NOT fatal — 5252 # exit_status before a single frame of state. That refusal is NOT fatal —
5205 # the tile the chord created knows which tile it was born from — and the 5253 # the tile the chord created knows which tile it was born from — and the
5206 # three claims below are what "fell back" means, none of which the log 5254 # three claims below are what "fell back" means, none of which the log
@@ -5230,9 +5278,9 @@ grep -q "m2d-pin" "$OUT.nswcap3" || {
5230 echo "e2e FAIL: new session: the refused switch did not come back to session 3:" 5278 echo "e2e FAIL: new session: the refused switch did not come back to session 3:"
5231 cat "$OUT.nswcap3"; exit 1; } 5279 cat "$OUT.nswcap3"; exit 1; }
5232 # * and the name it was reaching for was never created. Without this the 5280 # * and the name it was reaching for was never created. Without this the
5233 # leg would pass on a daemon that quietly grew a fifth slot. 5281 # leg would pass on a daemon that quietly grew one more slot.
5234 "$MUXA" status --sock "$SOCK28" --session 4 > "$OUT.nswst4" 2>&1 && { 5282 "$MUXA" status --sock "$SOCK28" --session 4 > "$OUT.nswst4" 2>&1 && {
5235 echo "e2e FAIL: new session: the daemon created a fifth session:" 5283 echo "e2e FAIL: new session: the daemon created a 33rd session:"
5236 cat "$OUT.nswst4"; exit 1; } 5284 cat "$OUT.nswst4"; exit 1; }
5237 assert_stopped "$SOCK28" "$D25PID" "new session" "$OUT.nswstop" 5285 assert_stopped "$SOCK28" "$D25PID" "new session" "$OUT.nswstop"
5238 D25PID="" 5286 D25PID=""
@@ -6253,13 +6301,16 @@ grep -q "nothing left to show" "$OUT.whxcap2" || {
6253 [ ! -s "$WHXSTATE/mux/wall" ] || { 6301 [ ! -s "$WHXSTATE/mux/wall" ] || {
6254 echo "e2e FAIL: x forgets: the last \\x1cx left lines behind:" 6302 echo "e2e FAIL: x forgets: the last \\x1cx left lines behind:"
6255 cat "$WHXSTATE/mux/wall"; exit 1; } 6303 cat "$WHXSTATE/mux/wall"; exit 1; }
6256 # The phantom tile, while this daemon is FULL (0, 1, xa, xb — max_sessions 6304 # The phantom tile, while this daemon is FULL: this leg's own four (0, 1,
6257 # is 4): a FIRST attach to a fifth name is refused, and a refusal must 6305 # xa, xb) plus the 28 filled here are max_sessions, so a FIRST attach to
6258 # leave no line behind. This is the case that made the seam move off "the 6306 # one more name is refused — and a refusal must leave no line behind. Its
6259 # dial succeeded" — a dial that comes up is not an attach that landed, and 6307 # state home is neither $WHSTATE nor $WHXSTATE, both of whose walls are
6260 # only a SWITCH's refusal ever had somewhere to undo the write from. The 6308 # asserted on. This is the case that made the seam move off "the dial
6261 # leg cannot pass vacuously: a daemon with room would accept the attach and 6309 # succeeded" — a dial that comes up is not an attach that landed, and only
6310 # a SWITCH's refusal ever had somewhere to undo the write from. The leg
6311 # cannot pass vacuously: a daemon with room would accept the attach and
6262 # the rc check below would fail loudly. 6312 # the rc check below would fail loudly.
6313 fill_sessions "$SOCK40" "$OUT.whfill" fill 4 31
6263 WHHASH=$(sha256sum "$WHWALL" | cut -d' ' -f1) 6314 WHHASH=$(sha256sum "$WHWALL" | cut -d' ' -f1)
6264 set +e 6315 set +e
6265 { sleep 1; printf '\034\034'; } | XDG_STATE_HOME="$WHSTATE" timeout 40 \ 6316 { sleep 1; printf '\034\034'; } | XDG_STATE_HOME="$WHSTATE" timeout 40 \
@@ -6267,7 +6318,7 @@ set +e
6267 RC=$? 6318 RC=$?
6268 set -e 6319 set -e
6269 [ "$RC" -ne 0 ] || { 6320 [ "$RC" -ne 0 ] || {
6270 echo "e2e FAIL: x forgets: a fifth session was created on a full daemon," 6321 echo "e2e FAIL: x forgets: a 33rd session was created on a full daemon,"
6271 echo " so the phantom-tile leg proved nothing:"; cat "$OUT.whph"; exit 1; } 6322 echo " so the phantom-tile leg proved nothing:"; cat "$OUT.whph"; exit 1; }
6272 [ "$WHHASH" = "$(sha256sum "$WHWALL" | cut -d' ' -f1)" ] || { 6323 [ "$WHHASH" = "$(sha256sum "$WHWALL" | cut -d' ' -f1)" ] || {
6273 echo "e2e FAIL: x forgets: a REFUSED first attach recorded a phantom tile:" 6324 echo "e2e FAIL: x forgets: a REFUSED first attach recorded a phantom tile:"