a73x

ac555d05

fix: the pane-removal notice is set before the vanish that hands the focus on

a73x   2026-09-03 05:20

Commit message
fix: the pane-removal notice is set before the vanish that hands the focus on

`removePane` set the sentence AFTER `vanishTile`, and a vanish arms the
incoming pump's claim — whose first act is `takeNotice`. About one press in
four the claim ran on an empty notice and `[pane removed - the session is
still on its daemon]` waited in `Shared` for a focus move the user has no
reason to make. Two statements swapped, so the claim the vanish arms is the
claim that paints it.

The e2e leg stops working around it: the `Ctrl-\ n` that forced a claim is
gone and the sentence is expected with no keystroke between the `x` and it,
which is what would catch a reordering. Four consecutive `E2E_ONLY=09_hosts`
runs are the evidence.

Beside it, three review findings in the suite: the picker-`x` leg records
that its `2 others` count is the ruling of 2026-09-02 rather than an
artefact, the forcing run names the session before it forces an end by row
position, and `mux web`'s `+` onto a pane the layout already names is pinned
at 409 `duplicate` in the shape that does not race a poll.

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

src/tui/wall_test_wall.zig
Old New
@@ -681,8 +681,12 @@ test "endAction: a birth the picker made into a standing wall is refused ON it,
681 // The entry tile of `mux TARGET` IS this mux, so its refusal is the 681 // The entry tile of `mux TARGET` IS this mux, so its refusal is the
682 // program's: the sentence and the code. Byte for byte the inputs of the 682 // program's: the sentence and the code. Byte for byte the inputs of the
683 // first block above — same reason, same terminal, same lone tile, same 683 // first block above — same reason, same terminal, same lone tile, same
684 // null `born_from` — and only the flag separates the two answers, which 684 // null `born_from` — and `keeps_wall` is what separates the two answers,
685 // is the whole argument for keeping it. 685 // which is the whole argument for keeping it. (The entry tile is also
686 // the one tile with `retry_cold` false, so a reader hunting for another
687 // distinguishing field will find that one. It is not the field to read:
688 // it answers whether a cold-lost link is worth redialling, and
689 // `endAction` must not change meaning when that rule does.)
686 fixture.endBench(&tiles, &shared, 0, .refused, 0, null); 690 fixture.endBench(&tiles, &shared, 0, .refused, 0, null);
687 try std.testing.expectEqual( 691 try std.testing.expectEqual(
688 EndAction{ .finish = .{ .code = 1, .msg = "mux: attach refused or no state received (session full?)" } }, 692 EndAction{ .finish = .{ .code = 1, .msg = "mux: attach refused or no state received (session full?)" } },
src/tui/wallview.zig
Old New
@@ -370,8 +370,14 @@ pub const Tile = struct {
370 /// case, the tail of `endAction`: the LAST pane of a wall on a terminal 370 /// case, the tail of `endAction`: the LAST pane of a wall on a terminal
371 /// ending for something other than a clean exit — a refused, lost or 371 /// ending for something other than a clean exit — a refused, lost or
372 /// thread-less dial. Two panes reach that line for opposite reasons and 372 /// thread-less dial. Two panes reach that line for opposite reasons and
373 /// `born_from` is null for both, so nothing else on the tile separates 373 /// `born_from` is null for both. The entry tile is also the one tile
374 /// them. A picker birth onto an empty wall is one session saying no, and 374 /// whose `retry_cold` is false, so that field COULD tell the two apart
375 /// today — but it answers a different question (is a link that died
376 /// before any state arrived worth redialling), and hanging the wall's
377 /// survival off it would mean one edit to the retry rule silently
378 /// changes what a refused `mux TARGET` does. This flag says the one
379 /// thing it is named for and nothing else reads it.
380 /// A picker birth onto an empty wall is one session saying no, and
375 /// the wall the user still has hosts on stands. The ENTRY tile is this 381 /// the wall the user still has hosts on stands. The ENTRY tile is this
376 /// `mux`: its refusal is the program's, message and exit code and all, 382 /// `mux`: its refusal is the program's, message and exit code and all,
377 /// which is what `mux TARGET` on a full daemon has to say. Deleting the 383 /// which is what `mux TARGET` on a full daemon has to say. Deleting the
@@ -944,13 +950,21 @@ pub fn vanishTile(tiles: []Tile, present: []bool, shared: *Shared, i: usize, to:
944 /// goodbye rather than at a timeout, and the transport close behind it is 950 /// goodbye rather than at a timeout, and the transport close behind it is
945 /// the fallback for a pump that ended some other way. Then the tile is 951 /// the fallback for a pump that ended some other way. Then the tile is
946 /// vanished and the layout written without it. 952 /// vanished and the layout written without it.
953 ///
954 /// The sentence is set BEFORE the vanish, and the order is the whole of
955 /// whether the user ever reads it. `vanishTile` hands the focus on, which
956 /// arms the incoming pump's claim, and a claim's first act is `takeNotice`:
957 /// set afterwards, the notice loses that race about one press in four
958 /// (measured 2026-09-02) and then waits for a focus move the user has no
959 /// reason to make. Set first, the claim that the vanish arms is the claim
960 /// that paints it.
947 pub fn removePane(w: Wall, z: usize) void { 961 pub fn removePane(w: Wall, z: usize) void {
948 if (z >= w.live.* or !w.present[z]) return; 962 if (z >= w.live.* or !w.present[z]) return;
949 const t = &w.tiles[z]; 963 const t = &w.tiles[z];
950 t.detach_req.store(true, .release); 964 t.detach_req.store(true, .release);
951 ring(t); 965 ring(t);
952 vanishTile(w.liveTiles(), w.livePresent(), w.shared, z, null);
953 setNotice(w.shared, "[pane removed - the session is still on its daemon]"); 966 setNotice(w.shared, "[pane removed - the session is still on its daemon]");
967 vanishTile(w.liveTiles(), w.livePresent(), w.shared, z, null);
954 wall_layout.relayout(w, w.shared.sel); 968 wall_layout.relayout(w, w.shared.sel);
955 wall_layout.persist(w); 969 wall_layout.persist(w);
956 } 970 }
test/e2e_06_web.sh
Old New
@@ -367,7 +367,11 @@ curl -s "$DWORIG/tiles" | grep -q '"state":"gone"' && {
367 # A session born ANYWHERE ELSE is not on this wall. It is a live session on 367 # A session born ANYWHERE ELSE is not on this wall. It is a live session on
368 # a listed daemon that the hub is polling once a second, and the only 368 # a listed daemon that the hub is polling once a second, and the only
369 # reason it gets no tile is that no leaf names it — which is the whole of 369 # reason it gets no tile is that no leaf names it — which is the whole of
370 # the model. Two and a half seconds is several poll answers. 370 # the model. Three seconds is several poll answers, and it is SCALED like
371 # every other budget in this suite (`TIME_SCALE`): under test/coverage.sh a
372 # traced hub is half again slower, and a wait that did not scale would make
373 # "no tile appeared" a claim about how little the hub got done rather than
374 # about what it decided.
371 hostroom dwoff 375 hostroom dwoff
372 pipe_mux "$OUT.dwoff" "$OUT.dwoff.err" env XDG_STATE_HOME="$HOSTROOM" timeout 40 \ 376 pipe_mux "$OUT.dwoff" "$OUT.dwoff.err" env XDG_STATE_HOME="$HOSTROOM" timeout 40 \
373 "$MUX" --sock "$SOCK25" --session offwall 377 "$MUX" --sock "$SOCK25" --session offwall
@@ -375,7 +379,7 @@ pipe_send 'printf "dwoff-%%s\\n" mark\n'
375 await_out "$OUT.dwoff" "dwoff-mark" "host wall: the off-wall session never came up" 379 await_out "$OUT.dwoff" "dwoff-mark" "host wall: the off-wall session never came up"
376 pipe_detach "host wall: the off-wall session's maker" 380 pipe_detach "host wall: the off-wall session's maker"
377 wait_grid "$SOCK25" "dwoff-mark" "host wall: the off-wall session's marker" offwall 381 wait_grid "$SOCK25" "dwoff-mark" "host wall: the off-wall session's marker" offwall
378 sleep 2.5 382 sleep "$(( 3 * TIME_SCALE ))"
379 [ "$(dw_shape)" = "$DWWANT" ] || { 383 [ "$(dw_shape)" = "$DWWANT" ] || {
380 echo "e2e FAIL: host wall: a session born off the wall became a tile; /tiles holds:" 384 echo "e2e FAIL: host wall: a session born off the wall became a tile; /tiles holds:"
381 curl -s "$DWORIG/tiles"; exit 1; } 385 curl -s "$DWORIG/tiles"; exit 1; }
@@ -473,6 +477,13 @@ DWID_A1=$(dw_id_of "--sock $SOCK25" 1)
473 # hub's, not each host's, and a browser holds `/ws/<id>` across every 477 # hub's, not each host's, and a browser holds `/ws/<id>` across every
474 # change it did not make, so an id handed out twice would silently 478 # change it did not make, so an id handed out twice would silently
475 # re-point that socket at a different shell on a different machine. 479 # re-point that socket at a different shell on a different machine.
480 #
481 # The other daemon and not the same anchor twice, because the same anchor
482 # twice inside one poll interval is a DUPLICATE now and answers 409
483 # (aed8e90a) — both presses read one `sessions_reply` and ask for one
484 # name. That refusal has a pin of its own further down, in the shape that
485 # does not race a poll; what this pair needs is two births that really
486 # happen, which is what two daemons give it.
476 R=$(curl -s -H "Origin: $DWORIG" -X POST "$DWORIG/tiles/$DWID_B0") 487 R=$(curl -s -H "Origin: $DWORIG" -X POST "$DWORIG/tiles/$DWID_B0")
477 [ "$R" = '{"session":"1"}' ] || { 488 [ "$R" = '{"session":"1"}' ] || {
478 echo "e2e FAIL: host wall: spawn on B's tile returned $R, want session 1"; exit 1; } 489 echo "e2e FAIL: host wall: spawn on B's tile returned $R, want session 1"; exit 1; }
@@ -535,6 +546,30 @@ wait_until 80 "host wall: the ended session's pane never wore gone; the wall hol
535 echo "e2e FAIL: host wall: the ended session's pane left or changed id;" 546 echo "e2e FAIL: host wall: the ended session's pane left or changed id;"
536 echo " the wall holds:"; curl -s "$DWORIG/tiles"; exit 1; } 547 echo " the wall holds:"; curl -s "$DWORIG/tiles"; exit 1; }
537 548
549 # ...and a `+` that would land on a pane the file ALREADY names is refused
550 # rather than served. This is the deterministic shape of it: session 1 has
551 # ended, so `nextFreeName` hands `1` straight back, while the pane for `1`
552 # is still a leaf — press `+` and the hub would write `--sock A#1` twice.
553 # `wall_layout.seedLayout` refuses a repeated leaf by refusing the WHOLE
554 # file, so before aed8e90a one press here cost the next terminal `mux` on
555 # this machine its entire wall. 409 with `duplicate`, and nothing born:
556 # the page refetches onto the tile that is already there.
557 DWDUPRC=$(curl -s -o "$OUT.dwdup" -w '%{http_code}' -H "Origin: $DWORIG" -X POST "$DWORIG/tiles/$DWID_A0")
558 [ "$DWDUPRC" = "409" ] || {
559 echo "e2e FAIL: host wall: a + onto a pane the file already names got $DWDUPRC, want 409"
560 cat "$OUT.dwdup"; curl -s "$DWORIG/tiles"; exit 1; }
561 grep -q "duplicate" "$OUT.dwdup" || {
562 echo "e2e FAIL: host wall: the refused + did not say why:"; cat "$OUT.dwdup"; exit 1; }
563 # Nothing born and nothing written: the daemon's count and the file are
564 # both what they were, which is what separates a refusal from a 502 after
565 # a session had already been made.
566 [ "$(dw_shape)" = "$DWWANT" ] || {
567 echo "e2e FAIL: host wall: the refused + still changed the wall; /tiles holds:"
568 curl -s "$DWORIG/tiles"; exit 1; }
569 [ "$(sed -n 's/^ *leaf [0-9][0-9]* //p' "$DWSTATE/mux/layout" | tr '\n' ' ')" = "$DWLWANT" ] || {
570 echo "e2e FAIL: host wall: the refused + still wrote a leaf:"
571 cat "$DWSTATE/mux/layout"; exit 1; }
572
538 # An id the wall does not have is answered in HTTP: a 404 the page can 573 # An id the wall does not have is answered in HTTP: a 404 the page can
539 # read, rather than an upgrade followed by a silent close it can only 574 # read, rather than an upgrade followed by a silent close it can only
540 # guess at. A page holding one is not hypothetical — a hub restarted on a 575 # guess at. A page holding one is not hypothetical — a hub restarted on a
test/e2e_09_hosts.sh
Old New
@@ -228,9 +228,12 @@ ok "the first mux on a machine starts the local daemon and writes it down"
228 # once-a-second-polled daemon — is NOWHERE. That is the whole change: 228 # once-a-second-polled daemon — is NOWHERE. That is the whole change:
229 # until 2026-09-02 a host's list was where tiles came from, so every 229 # until 2026-09-02 a host's list was where tiles came from, so every
230 # session on every listed box was on every wall over it. 230 # session on every listed box was on every wall over it.
231 # * a session born while the wall is up is nowhere either, and a wall 231 # * a session born BETWEEN the two walls is nowhere either. `c` covers
232 # opened AFTER it still does not have it. The poll grades panes; it 232 # the live case — it is on daemon 2's list for every poll of run 1 —
233 # does not add them. 233 # and `d` covers the other order: born after run 1 detached, on a
234 # daemon run 2 then polls for two and a half seconds, and still not a
235 # pane. The poll grades panes; it does not add them, whenever the
236 # session appeared.
234 # * `Ctrl-\ n` walks exactly the three and wraps to the first. Where each 237 # * `Ctrl-\ n` walks exactly the three and wraps to the first. Where each
235 # marker LANDED is read off the daemons, so a step that only moved a 238 # marker LANDED is read off the daemons, so a step that only moved a
236 # `>` on screen fails here, and a fourth session quietly on the ring 239 # `>` on screen fails here, and a fourth session quietly on the ring
@@ -409,12 +412,22 @@ ok "a wall shows its layout's panes and no more; a session born elsewhere never
409 # 412 #
410 # * the first `x` is REFUSED, by count, and the shell is still there — 413 # * the first `x` is REFUSED, by count, and the shell is still there —
411 # asked of the OS with `kill -0` on the pid the shell itself printed, 414 # asked of the OS with `kill -0` on the pid the shell itself printed,
412 # not of the daemon that would be reporting on its own refusal. The 415 # not of the daemon that would be reporting on its own refusal.
413 # count is TWO: `pickEnd` asks over a side connection on the poller's 416 #
414 # recipe, so the daemon counts every holder other than that connection 417 # The count is TWO, and that is BY DESIGN rather than an artefact this
415 # — this wall's own pane for `xb` as much as the pipe client. Pinned as 418 # leg is working around (ruled 2026-09-02). `pickEnd` asks over a side
416 # the number the user actually reads, and the fixture is built so both 419 # connection on the poller's recipe, and the daemon answers honestly
417 # holders are real and distinguishable. 420 # about every client holding the session other than the connection
421 # asking — which includes this wall's own pane for `xb`, because that
422 # pane IS a client of that session and losing it is a thing the user
423 # is entitled to be warned about. So the pin is the number the user
424 # reads, and the fixture is built so both holders are real and
425 # distinguishable.
426 #
427 # A kinder wording — auto-forcing when the only other holder is this
428 # wall's own pane — is a deferred collab issue, not a bug. If that
429 # lands, this pin moves with it; it is the ruling that is pinned here,
430 # not an accident.
418 # * the second `x` ends it. The shell is gone from the process table. 431 # * the second `x` ends it. The shell is gone from the process table.
419 # * the OTHER client, which typed nothing at all, comes back with the 432 # * the OTHER client, which typed nothing at all, comes back with the
420 # session's exit status. A hangup is not a detach: it reaches everyone 433 # session's exit status. A hangup is not a detach: it reaches everyone
@@ -495,7 +508,12 @@ expect Enter add to wall 15000
495 send 3 508 send 3
496 settle 400 15000 509 settle 400 15000
497 send x 510 send x
498 expect attached - x again to end 15000 511 # NAMED, unlike a bare "attached - x again to end": the second `x` below
512 # forces an end by ROW POSITION, so this is the only thing standing
513 # between a list that came back in another order and somebody else's
514 # shell. Run 1 makes the same claim in a grep after the fact; run 2 has
515 # to make it before it presses again.
516 expect [xb: 2 others attached - x again to end] 15000
499 settle 600 15000 517 settle 600 15000
500 send x 518 send x
501 settle 2500 25000 519 settle 2500 25000
@@ -534,7 +552,12 @@ ok "the picker's x refuses while others hold the session, ends on the second pre
534 # "the wall went": the two neighbours have to still be there afterwards, 552 # "the wall went": the two neighbours have to still be there afterwards,
535 # and the two REMAINING leaves are what the detach writes. 553 # and the two REMAINING leaves are what the detach writes.
536 # 554 #
537 # Four oracles, none of them the wall's own word for it: 555 # Five oracles, four of them outside the wall's own word for it:
556 #
557 # * the notice, which IS the wall's word and is here because the ORDER
558 # inside `removePane` is what makes it reliable — the sentence is set
559 # before the vanish arms the incoming pump's claim, so the claim that
560 # takes the terminal is the claim that paints it.
538 # 561 #
539 # * the screen: the removed pane's bar is gone from the final grid and 562 # * the screen: the removed pane's bar is gone from the final grid and
540 # its neighbours' are not (render, so a bar that merely scrolled past 563 # its neighbours' are not (render, so a bar that merely scrolled past
@@ -573,14 +596,12 @@ settle 1500 25000
573 send \x1c2 596 send \x1c2
574 settle 700 20000 597 settle 700 20000
575 send \x1cx 598 send \x1cx
576 settle 700 20000 599 # The sentence, with no keystroke between the x and it. removePane sets
577 # The sentence reaches the eyes that earned it on the next CLAIM, and 600 # the notice BEFORE the vanish that hands the focus on, so the claim the
578 # `removePane` sets it AFTER the vanish that hands the focus on — so the 601 # vanish arms is the claim that paints it; the other way round the notice
579 # incoming pump can take the terminal a beat before there is anything to 602 # lost that race about one press in four (2026-09-02), and this expect is
580 # say, and the notice then waits for a focus move that may never come 603 # what would catch a reordering that brought it back. (No backticks: this
581 # (seen 2026-09-02, one run in four). `n` is that move, and it is what 604 # heredoc is unquoted, so a backtick would be a command substitution.)
582 # makes the sentence speakable rather than likely.
583 send \x1cn
584 expect the session is still on its daemon 20000 605 expect the session is still on its daemon 20000
585 settle 2500 25000 606 settle 2500 25000
586 send cp $HSTATE/mux/layout $OUT.hxlive; printf 'xsn%s' ap; echo\n 607 send cp $HSTATE/mux/layout $OUT.hxlive; printf 'xsn%s' ap; echo\n
@@ -1012,6 +1033,19 @@ expect x forget 15000
1012 send 2 1033 send 2
1013 settle 400 15000 1034 settle 400 15000
1014 send \r 1035 send \r
1036 # The BORN session's row, named. Daemon 2's list is 0 then the session c
1037 # just made, so row 2 is the new one, and a bare "on this wall" would be
1038 # satisfied by row 1 -- the pane the layout seeded -- whatever the birth
1039 # did. The needle is pickerRow's selected marker at a fixed-width head, so
1040 # it is that row and no other. (No backticks in here: this heredoc is
1041 # unquoted, so a backtick would be a command substitution the shell runs.)
1042 send 2
1043 settle 400 15000
1044 expect 2> 1 15000
1045 # ...and the mark and the daemon's own holder count are painted at all.
1046 # Read apart from the row above deliberately: the padding between a name
1047 # and its state column is as wide as the box, so no one needle can carry
1048 # both, and pretending otherwise would be a claim about spaces.
1015 expect on this wall, 1 client 15000 1049 expect on this wall, 1 client 15000
1016 settle 400 15000 1050 settle 400 15000
1017 send \x1b 1051 send \x1b
test/e2e_13_birth.sh
Old New
@@ -125,7 +125,8 @@ waitexit 10000
125 EOF 125 EOF
126 RC=$? 126 RC=$?
127 set -e 127 set -e
128 rc0 "prompt: ptyclient leg exited $RC (did the picker's a add the tile?):" "$OUT.prpc" "$OUT.prcap.err" 128 rc0 "prompt: run 1 exited $RC (did the picker's a record the host, and did the refusals narrate?):" \
129 "$OUT.prpc" "$OUT.prcap.err"
129 # The oracle, not the byte stream: a banner LEFT on the screen is invisible 130 # The oracle, not the byte stream: a banner LEFT on the screen is invisible
130 # to a grep of the emitted bytes — the `: zzz` that painted is in the 131 # to a grep of the emitted bytes — the `: zzz` that painted is in the
131 # capture either way. Only the render's final grid says what the human was 132 # capture either way. Only the render's final grid says what the human was