0d50d074
fix: a tile paints only from a pass the last relayout has not superseded
a73x 2026-08-29 00:06
Commit message
src/tui/wall_pump.zig
| Old | New | ||
|---|---|---|---|
| @@ -60,6 +60,17 @@ pub fn tilePaintBegin(ctx: ?*anyopaque) bool { | |||
| 60 | t.shared.paint_mu.unlock(); | 60 | t.shared.paint_mu.unlock(); |
| 61 | return false; | 61 | return false; |
| 62 | } | 62 | } |
| 63 | // A pass a relayout has since superseded paints at a rect the screen | ||
| 64 | // no longer has. The relayout cleared the screen and moved this tile's | ||
| 65 | // neighbours' bars under the OLD offsets, and the pump re-takes a | ||
| 66 | // pass only at the top of its loop — so a paint from this pass would | ||
| 67 | // land a session's rows across a neighbour's bar, and nothing repaints | ||
| 68 | // that bar afterwards. The relayout already rang this pump; its next | ||
| 69 | // pass carries the new rect and the generation that repaints. | ||
| 70 | if (t.shared.repaint_gen.load(.acquire) != t.pass_gen) { | ||
| 71 | t.shared.paint_mu.unlock(); | ||
| 72 | return false; | ||
| 73 | } | ||
| 63 | return true; | 74 | return true; |
| 64 | } | 75 | } |
| 65 | 76 | ||
| @@ -424,6 +435,11 @@ const Pass = struct { | |||
| 424 | // A relayout re-cut this tile: the pump owes the daemon THIS pass's | 435 | // A relayout re-cut this tile: the pump owes the daemon THIS pass's |
| 425 | // content size. | 436 | // content size. |
| 426 | resize: bool, | 437 | resize: bool, |
| 438 | // The repaint generation this pass was taken under: the pump's repaint | ||
| 439 | // decision compares THIS, not a later read, so a relayout between the | ||
| 440 | // pass and the decision is repainted by the next pass rather than | ||
| 441 | // consumed by a paint at this pass's stale rect. | ||
| 442 | gen: u64, | ||
| 427 | }; | 443 | }; |
| 428 | 444 | ||
| 429 | pub fn takePass(t: *Tile) Pass { | 445 | pub fn takePass(t: *Tile) Pass { |
| @@ -436,6 +452,8 @@ pub fn takePass(t: *Tile) Pass { | |||
| 436 | // painting at — nothing re-sends, because the claim path does not. | 452 | // painting at — nothing re-sends, because the claim path does not. |
| 437 | const owed = t.resize_pending; | 453 | const owed = t.resize_pending; |
| 438 | t.resize_pending = false; | 454 | t.resize_pending = false; |
| 455 | const gen = t.shared.repaint_gen.load(.acquire); | ||
| 456 | t.pass_gen = gen; | ||
| 439 | return .{ | 457 | return .{ |
| 440 | .top = t.rect.top, | 458 | .top = t.rect.top, |
| 441 | .left = t.rect.left, | 459 | .left = t.rect.left, |
| @@ -445,6 +463,7 @@ pub fn takePass(t: *Tile) Pass { | |||
| 445 | .term_cols = t.shared.size.cols, | 463 | .term_cols = t.shared.size.cols, |
| 446 | .term_rows = t.shared.size.rows, | 464 | .term_rows = t.shared.size.rows, |
| 447 | .resize = owed, | 465 | .resize = owed, |
| 466 | .gen = gen, | ||
| 448 | }; | 467 | }; |
| 449 | } | 468 | } |
| 450 | 469 | ||
| @@ -1049,14 +1068,13 @@ pub fn pumpTile(t: *Tile) void { | |||
| 1049 | // back within ~100ms of a relayout whether or not its session ever | 1068 | // back within ~100ms of a relayout whether or not its session ever |
| 1050 | // speaks again. The drag clears here too: a relayout moved the rect | 1069 | // speaks again. The drag clears here too: a relayout moved the rect |
| 1051 | // a held drag's anchor was resolved against. | 1070 | // a held drag's anchor was resolved against. |
| 1052 | const gen = t.shared.repaint_gen.load(.acquire); | 1071 | if (snap.gen != painted_gen) { |
| 1053 | if (gen != painted_gen) { | ||
| 1054 | core.drag.clear(); | 1072 | core.drag.clear(); |
| 1055 | wv.paintLabel(t, state); | 1073 | wv.paintLabel(t, state); |
| 1056 | // A 0-row rect (fullscreened out) paints nothing: the daemon | 1074 | // A 0-row rect (fullscreened out) paints nothing: the daemon |
| 1057 | // refused the 0×0 resize, so the session keeps its real grid. | 1075 | // refused the 0×0 resize, so the session keeps its real grid. |
| 1058 | if (snap_view_rows > 0) core.repaint() catch {}; | 1076 | if (snap_view_rows > 0) core.repaint() catch {}; |
| 1059 | painted_gen = gen; | 1077 | painted_gen = snap.gen; |
| 1060 | } | 1078 | } |
| 1061 | } | 1079 | } |
| 1062 | } | 1080 | } |
src/tui/wall_test_pump.zig
| Old | New | ||
|---|---|---|---|
| @@ -114,6 +114,46 @@ test "tilePaintBegin: no tile paints while the picker owns the screen" { | |||
| 114 | shared.paint_mu.unlock(); | 114 | shared.paint_mu.unlock(); |
| 115 | } | 115 | } |
| 116 | 116 | ||
| 117 | test "tilePaintBegin: a pass a relayout has superseded paints nothing; the next pass paints" { | ||
| 118 | var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 44 }, .is_tty = true }; | ||
| 119 | var t = Tile{ | ||
| 120 | .r = .{ .target = .{ .sock = "/tmp/x" }, .label = "x", .session = "0" }, | ||
| 121 | .rect = .{ .top = 10, .left = 0, .rows = 8, .cols = 80 }, | ||
| 122 | .shared = &shared, | ||
| 123 | .idx = 3, | ||
| 124 | .wake_r = -1, | ||
| 125 | .wake_w = -1, | ||
| 126 | }; | ||
| 127 | // The pump paints from the pass it took at the top of its loop. Seen | ||
| 128 | // on a wall of five when `x` ended a neighbour: the pump took its pass, | ||
| 129 | // the keyboard's relayout cleared the screen and moved every rect, and | ||
| 130 | // the pump's repaint then put this session's rows at the OLD offsets — | ||
| 131 | // over the bar its own label had just painted at the NEW row. Nothing | ||
| 132 | // repaints a bar after that, so the tile is gone from the final grid. | ||
| 133 | const before = wall_pump.takePass(&t); | ||
| 134 | try std.testing.expect(wall_pump.tilePaintBegin(&t)); | ||
| 135 | wall_pump.tilePaintEnd(&t); | ||
| 136 | // What relayout does after re-cutting: the generation moves. | ||
| 137 | t.shared.paint_mu.lock(); | ||
| 138 | t.rect = .{ .top = 12, .left = 0, .rows = 10, .cols = 80 }; | ||
| 139 | t.resize_pending = true; | ||
| 140 | _ = shared.repaint_gen.fetchAdd(1, .release); | ||
| 141 | t.shared.paint_mu.unlock(); | ||
| 142 | try std.testing.expect(!wall_pump.tilePaintBegin(&t)); | ||
| 143 | // ...and, as above, refused without the lock held. | ||
| 144 | try std.testing.expect(shared.paint_mu.tryLock()); | ||
| 145 | shared.paint_mu.unlock(); | ||
| 146 | // The next pass is the relayout's: new rect, the resize it owes, and a | ||
| 147 | // generation the pump has not painted yet — the repaint that puts the | ||
| 148 | // tile back happens against THIS pass, which the sink admits. | ||
| 149 | const after = wall_pump.takePass(&t); | ||
| 150 | try std.testing.expect(after.gen != before.gen); | ||
| 151 | try std.testing.expect(after.resize); | ||
| 152 | try std.testing.expectEqual(@as(u16, 12), after.top); | ||
| 153 | try std.testing.expect(wall_pump.tilePaintBegin(&t)); | ||
| 154 | wall_pump.tilePaintEnd(&t); | ||
| 155 | } | ||
| 156 | |||
| 117 | test "endRefusal: a refusal is said in this client's words, never in the peer's bytes" { | 157 | test "endRefusal: a refusal is said in this client's words, never in the peer's bytes" { |
| 118 | try std.testing.expectEqualStrings( | 158 | try std.testing.expectEqualStrings( |
| 119 | "[no such session on that daemon]", | 159 | "[no such session on that daemon]", |
src/tui/wallview.zig
| Old | New | ||
|---|---|---|---|
| @@ -367,6 +367,10 @@ pub const Tile = struct { | |||
| 367 | // consumed apart from the rect it describes, and that pair is the whole | 367 | // consumed apart from the rect it describes, and that pair is the whole |
| 368 | // contract. `takePass` is the only reader. | 368 | // contract. `takePass` is the only reader. |
| 369 | resize_pending: bool = false, | 369 | resize_pending: bool = false, |
| 370 | /// The `repaint_gen` the pump's current pass was taken under; the paint | ||
| 371 | /// sink refuses a paint once a relayout has moved past it. Under | ||
| 372 | /// `paint_mu` like the rect, and for the same reason: it describes it. | ||
| 373 | pass_gen: u64 = 0, | ||
| 370 | /// Focus moved onto this tile: its pump owes a `claimTerminal` (the | 374 | /// Focus moved onto this tile: its pump owes a `claimTerminal` (the |
| 371 | /// session's mouse modes and side channels). Doorbell-driven so the | 375 | /// session's mouse modes and side channels). Doorbell-driven so the |
| 372 | /// claim runs on the pump thread, the one that owns the transport and | 376 | /// claim runs on the pump thread, the one that owns the transport and |
test/e2e_09_hosts.sh
| Old | New | ||
|---|---|---|---|
| @@ -415,46 +415,54 @@ ok "x refuses while others are attached, then ends; the other client sees the ex | |||
| 415 | # | 415 | # |
| 416 | # The leg above holds ONE tile: it can say the two-step is a two-step and | 416 | # The leg above holds ONE tile: it can say the two-step is a two-step and |
| 417 | # nothing at all about WHICH session an `x` reaches. Five tiles over two | 417 | # nothing at all about WHICH session an `x` reaches. Five tiles over two |
| 418 | # daemons here, and the one that takes the chord is neither the first nor | 418 | # daemons here — the four the legs above left, plus one this leg births |
| 419 | # the last — an `x` that ended the wall's first tile, the focus's | 419 | # from the picker — and the `x` must end the born one and nothing else. |
| 420 | # neighbour, or the daemon's own default session passes a wall of one and | ||
| 421 | # dies here. | ||
| 422 | # | 420 | # |
| 423 | # The expect AFTER the chord is what makes the final grid an assertion | 421 | # The picker, not a digit, chooses the target. Digits are tile SLOTS, and |
| 424 | # rather than a hope: expect(1) semantics mean it matches only bytes that | 422 | # the first cut fills them in the order the hosts' first lists LAND: one |
| 425 | # arrived since the previous match, so it waits for the relayout to REPAINT | 423 | # poller thread per host, and a local daemon's reply is a race the file |
| 426 | # a surviving tile rather than for a settle to run out. The heredoc is | 424 | # order does not settle (seen: host 2's two sessions at digits 1-2, host |
| 427 | # unquoted for that one line's `$SOCKH2`; `\x1c` survives it, because a | 425 | # 1's at 3-5 — the `x` aimed by digit at "the third session in file |
| 428 | # backslash in a heredoc is special only before $, ` and a newline. | 426 | # order" then ended daemon 1's own default session). The picker's rows ARE |
| 427 | # file order, so `1` there is daemon 1 whichever list won, and the tile it | ||
| 428 | # births is the focused one by construction. | ||
| 429 | # | 429 | # |
| 430 | # `xm` is born LAST on daemon 1, so the daemon reports it third and the | 430 | # The `expect` on the born bar's `5>` is what pins the wall's shape: four |
| 431 | # wall lays it out at digit 3 of 5 (both daemons' lists, in file order). | 431 | # tiles up before the birth, so the born one is the fifth and the `x` has |
| 432 | # The digit is not ASSUMED: the focused bar names the session it landed | 432 | # four neighbours to miss. `-seen` is in the printf's OUTPUT and not in |
| 433 | # on, and the grep below is what makes a reordered fixture a failure | 433 | # the echoed command line, so the expect cannot be satisfied by the |
| 434 | # instead of a leg that ends somebody else's shell and still passes. | 434 | # keystrokes. `$$` is escaped for the heredoc, which is unquoted for |
| 435 | pipe_mux "$OUT.hxm" "$OUT.hxm.err" env XDG_STATE_HOME="$HSTATE" timeout 40 \ | 435 | # `$SOCKH1`; `\x1c` survives it, because a backslash in a heredoc is |
| 436 | "$MUX" --sock "$SOCKH1" --session xm | 436 | # special only before $, ` and a newline. |
| 437 | pipe_send 'echo xmpid=$$\n' | 437 | # |
| 438 | await_out "$OUT.hxm" "xmpid=" "hosts wall x: the shell pid never reached the client" | 438 | # The shells are the OS's witnesses: every session's shell is a child of |
| 439 | pipe_detach "hosts wall x setup" | 439 | # its daemon, listed BEFORE the wall so the leg can ask afterwards whether |
| 440 | wait_grid "$SOCKH1" "xmpid=" "hosts wall x: session xm's shell pid" xm | 440 | # each is still alive. A daemon reporting its own session table cannot |
| 441 | XMPID=$(dump_session "$SOCKH1" xm | sed -n 's/.*xmpid=\([0-9]*\).*/\1/p' | tail -1) | 441 | # catch itself ending the wrong shell. |
| 442 | [ -n "$XMPID" ] || { echo "e2e FAIL: hosts wall x: session xm printed no pid"; exit 1; } | 442 | wait_sessions "$SOCKH1" 2 "hosts wall x: daemon 1 should hold 0 and b" |
| 443 | wait_sessions "$SOCKH1" 3 "hosts wall x: daemon 1 should hold 0, b and xm" | 443 | wait_sessions "$SOCKH2" 2 "hosts wall x: daemon 2 should hold 0 and c" |
| 444 | XKEEP="$(ps -o pid= --ppid "$(real_pid "$DH1PID")") $(ps -o pid= --ppid "$(real_pid "$DH2PID")")" | ||
| 445 | [ "$(echo "$XKEEP" | wc -w)" -eq 4 ] || { | ||
| 446 | echo "e2e FAIL: hosts wall x: expected four session shells under the two daemons, saw:" | ||
| 447 | echo "$XKEEP"; exit 1; } | ||
| 444 | 448 | ||
| 445 | set +e | 449 | set +e |
| 446 | # The default cut over the two hosts' live lists is what makes digit 3 the | 450 | # A tree an earlier wall left here would be somebody else's shape. |
| 447 | # third session in file order; a tree an earlier wall left here would be | ||
| 448 | # somebody else's ordering. | ||
| 449 | no_saved_tree "$HSTATE" | 451 | no_saved_tree "$HSTATE" |
| 450 | XDG_STATE_HOME="$HSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 44 \ | 452 | XDG_STATE_HOME="$HSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 44 \ |
| 451 | --out "$OUT.hxmcap" --err "$OUT.hxmcap.err" -- "$MUX" > "$OUT.hxmpc" 2>&1 <<EOF | 453 | --out "$OUT.hxmcap" --err "$OUT.hxmcap.err" -- "$MUX" > "$OUT.hxmpc" 2>&1 <<EOF |
| 452 | expect xmpid= 25000 | 454 | expect hw1-pin 25000 |
| 453 | settle 1500 25000 | 455 | settle 1500 25000 |
| 454 | send \x1c3 | 456 | send \x1cs |
| 455 | expect 3> --sock 20000 | 457 | expect Enter/c new session 15000 |
| 458 | send 1 | ||
| 459 | settle 400 15000 | ||
| 460 | send \r | ||
| 461 | expect 5> --sock $SOCKH1#1 [up] 25000 | ||
| 462 | send printf 'xmpid=%s-%s\n' \$\$ seen\n | ||
| 463 | expect -seen 25000 | ||
| 456 | send \x1cx | 464 | send \x1cx |
| 457 | expect --sock $SOCKH2#0 25000 | 465 | expect [exited] 25000 |
| 458 | settle 2500 25000 | 466 | settle 2500 25000 |
| 459 | send \x1cd | 467 | send \x1cd |
| 460 | waitexit 15000 | 468 | waitexit 15000 |
| @@ -464,18 +472,18 @@ set -e | |||
| 464 | [ "$RC" -eq 0 ] || { | 472 | [ "$RC" -eq 0 ] || { |
| 465 | echo "e2e FAIL: hosts wall x: the wall leg exited $RC:" | 473 | echo "e2e FAIL: hosts wall x: the wall leg exited $RC:" |
| 466 | cat "$OUT.hxmpc"; echo "--- stderr ---"; cat "$OUT.hxmcap.err"; exit 1; } | 474 | cat "$OUT.hxmpc"; echo "--- stderr ---"; cat "$OUT.hxmcap.err"; exit 1; } |
| 467 | # WHICH tile digit 3 focused, before anything is read into the `x` that | 475 | XMPID=$(grep -ao 'xmpid=[0-9]*-seen' "$OUT.hxmcap" | head -1 | sed 's/xmpid=//; s/-seen//') |
| 468 | # followed it. One press and not two, deliberately: nothing else holds this | 476 | [ -n "$XMPID" ] || { echo "e2e FAIL: hosts wall x: the born session printed no pid"; exit 1; } |
| 469 | # session, so the daemon's refusal has nothing to count and the first press | 477 | # One press and not two, deliberately: nothing else holds the born |
| 470 | # is the accepted one — the leg above owns the two-step. | 478 | # session, so the daemon's refusal has nothing to count and the first |
| 471 | grep -q -- "3> --sock $SOCKH1#xm \[up\]" "$OUT.hxmcap" || { | 479 | # press is the accepted one — the leg above owns the two-step. |
| 472 | echo "e2e FAIL: hosts wall x: digit 3 focused something other than" | ||
| 473 | echo " $SOCKH1#xm, so the x below was aimed at another session:" | ||
| 474 | grep -ao '[0-9]> --sock[^ ]* ' "$OUT.hxmcap" | sort -u | head; exit 1; } | ||
| 475 | # The OS, not the daemon: the shell the focused tile's session held is | ||
| 476 | # gone. A daemon reporting its own session table cannot catch itself | ||
| 477 | # leaving a shell behind. | ||
| 478 | wait_pid_gone "$XMPID" "hosts wall x: the focused tile's shell after x" | 480 | wait_pid_gone "$XMPID" "hosts wall x: the focused tile's shell after x" |
| 481 | for _xk in $XKEEP; do | ||
| 482 | kill -0 "$_xk" 2>/dev/null || { | ||
| 483 | echo "e2e FAIL: hosts wall x: x ended the focused tile's session and ALSO" | ||
| 484 | echo " the shell $_xk of a neighbour:" | ||
| 485 | ps -o pid,ppid,args -p "$_xk" 2>&1; exit 1; } | ||
| 486 | done | ||
| 479 | wait_sessions "$SOCKH1" 2 "hosts wall x: daemon 1 back to 0 and b" | 487 | wait_sessions "$SOCKH1" 2 "hosts wall x: daemon 1 back to 0 and b" |
| 480 | wait_sessions "$SOCKH2" 2 "hosts wall x: the other daemon must be untouched" | 488 | wait_sessions "$SOCKH2" 2 "hosts wall x: the other daemon must be untouched" |
| 481 | # ...and the tile left on the next LIST, judged on the grid: the bar is in | 489 | # ...and the tile left on the next LIST, judged on the grid: the bar is in |
| @@ -483,7 +491,7 @@ wait_sessions "$SOCKH2" 2 "hosts wall x: the other daemon must be untouched" | |||
| 483 | # screen when the wall came down. | 491 | # screen when the wall came down. |
| 484 | "$RENDER" --cols 80 --rows 44 < "$OUT.hxmcap" > "$OUT.hxmgrid" || { | 492 | "$RENDER" --cols 80 --rows 44 < "$OUT.hxmcap" > "$OUT.hxmgrid" || { |
| 485 | echo "e2e FAIL: hosts wall x: render oracle failed"; cat "$OUT.hxmgrid"; exit 1; } | 493 | echo "e2e FAIL: hosts wall x: render oracle failed"; cat "$OUT.hxmgrid"; exit 1; } |
| 486 | grep -q -- "--sock $SOCKH1#xm" "$OUT.hxmgrid" && { | 494 | grep -q -- "--sock $SOCKH1#1" "$OUT.hxmgrid" && { |
| 487 | echo "e2e FAIL: hosts wall x: the ended session still has a tile on the" | 495 | echo "e2e FAIL: hosts wall x: the ended session still has a tile on the" |
| 488 | echo " final wall — a tile leaves on the next list:" | 496 | echo " final wall — a tile leaves on the next list:" |
| 489 | cat "$OUT.hxmgrid"; exit 1; } | 497 | cat "$OUT.hxmgrid"; exit 1; } |