ac555d05
fix: the pane-removal notice is set before the vanish that hands the focus on
a73x 2026-09-03 05:20
Commit message
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 |