a73x

0d50d074

fix: a tile paints only from a pass the last relayout has not superseded

a73x   2026-08-29 00:06

Commit message
fix: a tile paints only from a pass the last relayout has not superseded

The pump takes ONE pass per loop iteration (the rect it paints at) and
reads repaint_gen at the bottom of that iteration. A vanish rings the
newly focused pump BEFORE the relayout that clears the screen and moves
every rect, so the pump could take its pass before the relayout and
repaint after it: the label went to the new row, the content to the old
offsets — across the bar its own label had just painted — and nothing
repaints a bar after that. On a wall of five with `x` on the middle
tile, the tile beside it was gone from the final grid (09_hosts, 2/12
runs on the one-binary tree; the same byte sequence is in a base-tree
capture, repaired there by a later bar paint landing on the smear).

A pass now carries the repaint generation it was taken under. The paint
sink refuses a paint once a relayout has moved past that generation —
the relayout already rang this pump, and its next pass is fresh — and
the repaint decision compares the pass's generation, never a later read,
so painted_gen cannot advance past the pass the repaint was made with.

The `hosts wall x` leg also assumed the digit a session lands on: digits
are slots filled in the order the hosts' first lists LAND, one poller
per host, and host 2's list winning put daemon 1's default session at
digit 3 (base tree, 1/12). The leg births its target from the picker,
whose rows are file order, reads the pid from the tile's own output with
a needle the echoed keystrokes cannot satisfy, and asks the OS about
every session shell under both daemons before and after the `x`.

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

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; }