a73x

1507f375

fix: the keyboard re-arms the wall's mouse capture; a parked pump cannot

a73x   2026-09-02 07:00

Commit message
fix: the keyboard re-arms the wall's mouse capture; a parked pump cannot

A focus move writes session_release — every mouse mode off — and left
the re-arm to the incoming tile's claim. A pane whose host is dark has
no pump to claim (and a pump parked in dial's backoff never gets
there), so one click on an unreachable pane deafened the mouse for the
whole wall, including the click that would have left it. Found live
2026-09-02, a laptop dark overnight.

ptyclient now mirrors the mouse-reporting modes the way it mirrors
bracketed paste, and its click verb sends a press only while reporting
is on — the fixture dimension that was held constant and was blind to
exactly this class.

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

src/tui/interact.zig
Old New
@@ -610,6 +610,17 @@ const terminal_frame_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l";
610 /// move the shell's history instead of the view. 610 /// move the shell's history instead of the view.
611 const session_claim = client_mouse_setup; 611 const session_claim = client_mouse_setup;
612 612
613 /// The wall's own capture, exported for the KEYBOARD to re-arm right after
614 /// it writes `session_release` on a focus move. The release lowers every
615 /// mouse mode — the wall's three included — and the re-arm used to be the
616 /// incoming tile's claim, a pump-side write. A pump parked in `dial`'s
617 /// backoff never runs its claim, so focusing an unreachable tile left the
618 /// terminal deaf to every later click, including the one that would have
619 /// moved focus back off it. The wall hears clicks for as long as it owns
620 /// the screen, no pump required; a claim that follows re-asserts the same
621 /// modes, which is idempotent.
622 pub const wall_mouse_capture = client_mouse_setup;
623
613 /// The mouse modes the client asks its own terminal for when no application 624 /// The mouse modes the client asks its own terminal for when no application
614 /// in the session wants them: presses (1000) and motion with a button down 625 /// in the session wants them: presses (1000) and motion with a button down
615 /// (1002), in SGR (1006). 626 /// (1002), in SGR (1006).
src/tui/wall_test_wall.zig
Old New
@@ -1001,6 +1001,52 @@ test "setFocus writes the outgoing tile's release before the store that lets the
1001 try std.testing.expectEqual(@as(usize, 1), shared.sel); 1001 try std.testing.expectEqual(@as(usize, 1), shared.sel);
1002 } 1002 }
1003 1003
1004 test "setFocus re-arms the wall's own mouse capture itself — a parked pump cannot" {
1005 // Clicking an [unreachable] tile is a designed act (birthing on one is
1006 // too), and that tile's pump is parked in `dial`'s backoff loop: the
1007 // focus claim that used to re-enable mouse reporting never runs there.
1008 // The release alone then left the terminal deaf to every later click —
1009 // including the one that would have moved focus back off the dead tile
1010 // (found live 2026-09-02: one click on a dark laptop's pane killed the
1011 // mouse for the whole wall until a keyboard chord landed on a live
1012 // tile). The re-arm is the KEYBOARD's own write, after the release and
1013 // before the doorbell, so the wall hears clicks with no pump running.
1014 const p = try std.posix.pipe2(.{ .NONBLOCK = true });
1015 defer std.posix.close(p[0]);
1016 defer std.posix.close(p[1]);
1017 var shared = Shared{ .out_fd = p[1], .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
1018 var tiles: [2]Tile = undefined;
1019 tiles[0] = Tile{
1020 .r = .{ .target = .{ .sock = "/s" }, .label = "a", .session = "a" },
1021 .rect = .{ .top = 0, .left = 0, .rows = 12, .cols = 80 },
1022 .shared = &shared,
1023 .idx = 0,
1024 .wake_r = -1,
1025 .wake_w = -1,
1026 };
1027 tiles[1] = Tile{
1028 .r = .{ .target = .{ .sock = "/s" }, .label = "b", .session = "b" },
1029 .rect = .{ .top = 12, .left = 0, .rows = 12, .cols = 80 },
1030 .shared = &shared,
1031 .idx = 1,
1032 .wake_r = -1,
1033 .wake_w = p[1],
1034 };
1035 shared.sel = 0;
1036 var buf: [512]u8 = undefined;
1037
1038 wv.setFocus(&tiles, &shared, 1);
1039 const out = fixture.readAvail(p[0], &buf);
1040 const rel = std.mem.indexOf(u8, out, interact.session_release) orelse
1041 return error.NoReleaseOnFocusMove;
1042 const arm = std.mem.indexOf(u8, out, interact.wall_mouse_capture) orelse
1043 return error.NoWallRearm;
1044 const bell = std.mem.indexOfScalar(u8, out, 0) orelse
1045 return error.NoClaimDoorbell;
1046 try std.testing.expect(rel < arm);
1047 try std.testing.expect(arm < bell);
1048 }
1049
1004 test "a focus move drops what the wall's input filters are holding" { 1050 test "a focus move drops what the wall's input filters are holding" {
1005 // The input-filter state that mattered — the drag — moved off the 1051 // The input-filter state that mattered — the drag — moved off the
1006 // wall's keyboard loop and onto each tile's Core. setFocus drops it the 1052 // wall's keyboard loop and onto each tile's Core. setFocus drops it the
src/tui/wallview.zig
Old New
@@ -792,6 +792,12 @@ fn moveFocusLocked(tiles: []Tile, shared: *Shared, prev: usize, next: usize) voi
792 // lets the next pump claim — and only when there IS a terminal to change. 792 // lets the next pump claim — and only when there IS a terminal to change.
793 if (shared.is_tty and prev != next and prev < tiles.len and tiles[prev].alive.load(.acquire)) { 793 if (shared.is_tty and prev != next and prev < tiles.len and tiles[prev].alive.load(.acquire)) {
794 proto.writeAllFd(shared.out_fd, interact.session_release) catch {}; 794 proto.writeAllFd(shared.out_fd, interact.session_release) catch {};
795 // The wall's own click capture goes straight back on, by THIS hand:
796 // the incoming tile's claim used to be the re-arm, and a pump parked
797 // dialling a dead host never claims — one click on an unreachable
798 // pane then deafened the mouse for the whole wall. See
799 // `interact.wall_mouse_capture`.
800 proto.writeAllFd(shared.out_fd, interact.wall_mouse_capture) catch {};
795 tiles[prev].release_pending.store(true, .release); 801 tiles[prev].release_pending.store(true, .release);
796 ring(&tiles[prev]); 802 ring(&tiles[prev]);
797 } 803 }
test/e2e.sh
Old New
@@ -170,8 +170,8 @@ done
170 # one of those and adds a convergence point would be pinning a fact every 170 # one of those and adds a convergence point would be pinning a fact every
171 # leg above already establishes. 171 # leg above already establishes.
172 172
173 [ "$OK_COUNT" = "106" ] || { 173 [ "$OK_COUNT" = "107" ] || {
174 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 106 —" 174 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 107 —"
175 echo " a scenario was added (update the pin) or silently lost" 175 echo " a scenario was added (update the pin) or silently lost"
176 exit 1 176 exit 1
177 } 177 }
test/e2e_08_mouse.sh
Old New
@@ -567,3 +567,91 @@ assert_stopped "$SOCK42" "$D39PID" "app mouse" "$OUT.zm2stop"
567 D39PID="" 567 D39PID=""
568 ok "an application in the focused tile gets the wheel, and the tile does not" 568 ok "an application in the focused tile gets the wheel, and the tile does not"
569 569
570
571 # ---- a click on a dead host's pane must not deafen the wall -------------
572 #
573 # Found live 2026-09-02: a laptop went dark overnight, its pane came back
574 # [unreachable], and ONE click on it killed the mouse for the whole wall —
575 # the focus move writes `session_release` (every mouse mode off) and the
576 # re-arm was the incoming tile's claim, which a pane with no pump (and a
577 # pump parked in dial) never runs. The keyboard re-arms the wall's own
578 # capture itself now (`interact.wall_mouse_capture`), and this leg walks
579 # the user's exact path: click the dark pane, click BACK, type.
580 #
581 # `click` is the discriminating verb: ptyclient mirrors the mouse modes off
582 # the client's own writes and sends a press only while reporting is ON,
583 # the way a real terminal does. With the re-arm gone, the click back is
584 # bytes a terminal would never send — the verb exits 3 saying so, instead
585 # of this leg green-lighting a wall no mouse can steer.
586 SOCKMC1="${TMPDIR:-/tmp}/muxd-e2e-deadclick1-$$.sock"
587 defer_sock "$SOCKMC1"
588 SOCKMC2="${TMPDIR:-/tmp}/muxd-e2e-deadclick2-$$.sock"
589 defer_sock "$SOCKMC2"
590 MCSTATE="${TMPDIR:-/tmp}/mux-e2e-deadclick-state-$$"
591 defer_rm "$MCSTATE"
592 mkdir -p "$MCSTATE/mux"
593 printf -- '--sock %s\n--sock %s\n' "$SOCKMC1" "$SOCKMC2" > "$MCSTATE/mux/hosts"
594 start_daemon "$SOCKMC1" "$OUT.mc1.d" "dead-click daemon 1 never bound" --shell /bin/sh
595 start_daemon "$SOCKMC2" "$OUT.mc2.d" "dead-click daemon 2 never bound" --shell /bin/sh
596 MC2PID=$DPID
597 # A polled host never CREATES a session, and a fresh daemon has none: host
598 # 2's tile exists only if something attached there first. One attach-and-
599 # detach births its default session; the daemon keeps it.
600 pipe_mux "$OUT.mc0" "$OUT.mc0.err" env XDG_STATE_HOME="$MCSTATE" timeout 40 "$MUX" --sock "$SOCKMC2"
601 pipe_detach
602
603 # Run 1: both hosts up, a wall of two, left on a terminal — the layout
604 # sidecar is what carries host 2's pane into run 2 as a saved pane.
605 set +e
606 XDG_STATE_HOME="$MCSTATE" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
607 --out "$OUT.mc1" --err "$OUT.mc1.err" -- \
608 "$MUX" --sock "$SOCKMC1" > "$OUT.mc1.pc" 2>&1 <<EOF
609 expect [up] 15000
610 settle 2500 25000
611 send \x1cd
612 waitexit 15000
613 EOF
614 RC=$?
615 set -e
616 rc0 "dead-click: the two-tile wall leg" "$OUT.mc1.pc" "$OUT.mc1.err"
617 # Post-hoc and not an expect: the second tile's bar can paint BEFORE the
618 # first [up] the script matched, and expect(1) never looks behind its
619 # cursor. The capture as a whole is what the sidecar saw.
620 grep -aq 'deadclick2.*\[up\]' "$OUT.mc1" || {
621 echo "e2e FAIL: dead-click: host 2's tile never came up on the wall"
622 exit 1; }
623
624 # Host 2 goes dark the way the laptop did: daemon killed, socket gone.
625 kill -9 "$MC2PID" 2>/dev/null || true
626 wait_pid_gone "$MC2PID"
627 rm -f "$SOCKMC2"
628
629 # Run 2: the restored wall. The dark host's pane stays, wearing the word;
630 # the click lands ON it, the click back must still be heard, and the typed
631 # marker proves the mouse alone steered focus there and home again.
632 set +e
633 XDG_STATE_HOME="$MCSTATE" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
634 --out "$OUT.mc2" --err "$OUT.mc2.err" -- \
635 "$MUX" --sock "$SOCKMC1" > "$OUT.mc2.pc" 2>&1 <<'EOF'
636 expect [up] 15000
637 settle 2500 25000
638 click 75 10 8000
639 expect \x201\x20\x20--sock 10000
640 click 25 10 8000
641 send echo CLICKBACK\r
642 expect CLICKBACK 10000
643 send \x1cd
644 waitexit 15000
645 EOF
646 RC=$?
647 set -e
648 rc0 "dead-click: the click-and-return leg" "$OUT.mc2.pc" "$OUT.mc2.err"
649 # The pane wore the word (order-free, like run 1's pin), and the first
650 # click really landed on it: tile 1's bar going to the UNFOCUSED marker is
651 # the in-script expect above, painted only after focus left it. The
652 # marker echo is the verdict for the click back: typed bytes reach a live
653 # shell only if the mouse alone carried focus there and home again.
654 grep -aq '\[unreachable\]' "$OUT.mc2" || {
655 echo "e2e FAIL: dead-click: the dark host's pane never wore unreachable"
656 exit 1; }
657 ok "a click on a dead host's pane leaves the wall's mouse alive"
test/ptyclient.zig
Old New
@@ -36,12 +36,28 @@ const Expecter = struct {
36 /// Whether the client under test has put this "terminal" into bracketed 36 /// Whether the client under test has put this "terminal" into bracketed
37 /// paste — set by the escapes it wrote, never by what a scenario assumes. 37 /// paste — set by the escapes it wrote, never by what a scenario assumes.
38 bracketed_paste: bool = false, 38 bracketed_paste: bool = false,
39 /// How far into `buf` noteBracketedPaste has already looked. 39 /// The mouse-reporting modes, mirrored the same way: a real terminal
40 /// sends a click only while the application holds reporting ON, and a
41 /// fixture that sends regardless is blind to the whole bug class where
42 /// mux tears the modes down and nothing re-arms them — the class that
43 /// shipped (a focus move onto a dead host's tile deafened the wall's
44 /// mouse; see `interact.wall_mouse_capture`).
45 m1000: bool = false,
46 m1002: bool = false,
47 m1006: bool = false,
48 /// How far into `buf` noteModes has already looked.
40 modes_scanned: usize = 0, 49 modes_scanned: usize = 0,
41 50
42 fn feed(self: *Expecter, alloc: std.mem.Allocator, bytes: []const u8) !void { 51 fn feed(self: *Expecter, alloc: std.mem.Allocator, bytes: []const u8) !void {
43 try self.buf.appendSlice(alloc, bytes); 52 try self.buf.appendSlice(alloc, bytes);
44 self.noteBracketedPaste(); 53 self.noteModes();
54 }
55
56 /// What this "terminal" would report a button press under: a press
57 /// mode (1000 or 1002) and the SGR encoding (1006) — the only wire
58 /// format `click` speaks.
59 fn mouseReporting(self: *const Expecter) bool {
60 return (self.m1000 or self.m1002) and self.m1006;
45 } 61 }
46 62
47 /// Track the mode over the WHOLE capture rather than one chunk at a time, 63 /// Track the mode over the WHOLE capture rather than one chunk at a time,
@@ -58,16 +74,25 @@ const Expecter = struct {
58 /// 74 ///
59 /// A window holding neither escape changes nothing: the mode is sticky 75 /// A window holding neither escape changes nothing: the mode is sticky
60 /// until the client says otherwise, which is what a real terminal does. 76 /// until the client says otherwise, which is what a real terminal does.
61 fn noteBracketedPaste(self: *Expecter) void { 77 fn noteModes(self: *Expecter) void {
78 // Every escape tracked here is 8 bytes, so one 7-byte backup
79 // window covers any split for all of them.
62 const win = self.buf.items[self.modes_scanned -| (BP_SET.len - 1)..]; 80 const win = self.buf.items[self.modes_scanned -| (BP_SET.len - 1)..];
63 const set = std.mem.lastIndexOf(u8, win, BP_SET); 81 trackMode(win, BP_SET, BP_RESET, &self.bracketed_paste);
64 const reset = std.mem.lastIndexOf(u8, win, BP_RESET); 82 trackMode(win, "\x1b[?1000h", "\x1b[?1000l", &self.m1000);
83 trackMode(win, "\x1b[?1002h", "\x1b[?1002l", &self.m1002);
84 trackMode(win, "\x1b[?1006h", "\x1b[?1006l", &self.m1006);
85 self.modes_scanned = self.buf.items.len;
86 }
87
88 fn trackMode(win: []const u8, set_esc: []const u8, reset_esc: []const u8, state: *bool) void {
89 const set = std.mem.lastIndexOf(u8, win, set_esc);
90 const reset = std.mem.lastIndexOf(u8, win, reset_esc);
65 if (set) |s| { 91 if (set) |s| {
66 self.bracketed_paste = if (reset) |r| s > r else true; 92 state.* = if (reset) |r| s > r else true;
67 } else if (reset != null) { 93 } else if (reset != null) {
68 self.bracketed_paste = false; 94 state.* = false;
69 } 95 }
70 self.modes_scanned = self.buf.items.len;
71 } 96 }
72 97
73 /// A paste as this "terminal" would deliver it: markers only when the 98 /// A paste as this "terminal" would deliver it: markers only when the
@@ -122,6 +147,13 @@ const Verb = union(enum) {
122 expect: struct { needle: []u8, deadline_ms: u64 }, 147 expect: struct { needle: []u8, deadline_ms: u64 },
123 resize: struct { cols: u16, rows: u16 }, 148 resize: struct { cols: u16, rows: u16 },
124 settle: struct { quiet_ms: u64, deadline_ms: u64 }, 149 settle: struct { quiet_ms: u64, deadline_ms: u64 },
150 /// A button press the way a terminal delivers one: SGR press+release at
151 /// COL;ROW (1-based), sent only once the client has mouse reporting ON.
152 /// The deadline is for the ARMING — a claim is a pump-side write and
153 /// may trail the paint — and a deadline that passes with reporting off
154 /// is this fixture saying what a real terminal says by sending nothing:
155 /// the wall has gone deaf to its user's mouse.
156 click: struct { col: u16, row: u16, deadline_ms: u64 },
125 waitexit: u64, 157 waitexit: u64,
126 158
127 /// Exhaustive on purpose: a future arm that owns memory will not 159 /// Exhaustive on purpose: a future arm that owns memory will not
@@ -131,7 +163,7 @@ const Verb = union(enum) {
131 switch (self) { 163 switch (self) {
132 .send, .paste => |s| alloc.free(s), 164 .send, .paste => |s| alloc.free(s),
133 .expect => |e| alloc.free(e.needle), 165 .expect => |e| alloc.free(e.needle),
134 .resize, .settle, .waitexit => {}, 166 .resize, .settle, .click, .waitexit => {},
135 } 167 }
136 } 168 }
137 }; 169 };
@@ -172,6 +204,13 @@ fn parseLine(alloc: std.mem.Allocator, raw: []const u8) !?Verb {
172 const deadline = std.fmt.parseInt(u64, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb; 204 const deadline = std.fmt.parseInt(u64, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb;
173 if (it.next() != null) return error.BadVerb; 205 if (it.next() != null) return error.BadVerb;
174 return .{ .settle = .{ .quiet_ms = quiet, .deadline_ms = deadline } }; 206 return .{ .settle = .{ .quiet_ms = quiet, .deadline_ms = deadline } };
207 } else if (std.mem.eql(u8, verb, "click")) {
208 var it = std.mem.tokenizeScalar(u8, rest, ' ');
209 const col = std.fmt.parseInt(u16, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb;
210 const row = std.fmt.parseInt(u16, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb;
211 const ms = std.fmt.parseInt(u64, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb;
212 if (it.next() != null) return error.BadVerb;
213 return .{ .click = .{ .col = col, .row = row, .deadline_ms = ms } };
175 } else if (std.mem.eql(u8, verb, "waitexit")) { 214 } else if (std.mem.eql(u8, verb, "waitexit")) {
176 const ms = std.fmt.parseInt(u64, rest, 10) catch return error.BadVerb; 215 const ms = std.fmt.parseInt(u64, rest, 10) catch return error.BadVerb;
177 return .{ .waitexit = ms }; 216 return .{ .waitexit = ms };
@@ -346,6 +385,31 @@ pub fn main() !void {
346 pty.resize(r.cols, r.rows) catch |e| 385 pty.resize(r.cols, r.rows) catch |e|
347 fatal(EXIT_CHILD_DIED, "verb {d}: TIOCSWINSZ failed: {s}", .{ verb_no, @errorName(e) }); 386 fatal(EXIT_CHILD_DIED, "verb {d}: TIOCSWINSZ failed: {s}", .{ verb_no, @errorName(e) });
348 }, 387 },
388 .click => |c| {
389 // Wait for the ARMING, not for bytes: the enables are a
390 // pump's write and may trail whatever paint the script just
391 // matched on.
392 const start = std.time.milliTimestamp();
393 while (!exp.mouseReporting()) {
394 if (std.time.milliTimestamp() - start > c.deadline_ms) {
395 std.debug.print("ptyclient: verb {d}: click {d};{d} — mouse reporting is off, " ++
396 "this terminal has nothing to send (the wall is deaf to its user's mouse)\n", .{ verb_no, c.col, c.row });
397 dumpTail(exp.buf.items);
398 std.process.exit(EXIT_TIMEOUT);
399 }
400 var fds = [_]std.posix.pollfd{
401 .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
402 };
403 _ = try std.posix.poll(&fds, 50);
404 if (!try drain(alloc, &pty, out, &exp)) {
405 std.debug.print("ptyclient: verb {d}: client closed the pty before a click could arm\n", .{verb_no});
406 std.process.exit(EXIT_CHILD_DIED);
407 }
408 }
409 var report: [64]u8 = undefined;
410 const bytes = std.fmt.bufPrint(&report, "\x1b[<0;{d};{d}M\x1b[<0;{d};{d}m", .{ c.col, c.row, c.col, c.row }) catch unreachable;
411 writeWhole(pty.master, bytes, verb_no);
412 },
349 .settle => |s| { 413 .settle => |s| {
350 // Quiesce by CONDITION rather than by needle: succeed once 414 // Quiesce by CONDITION rather than by needle: succeed once
351 // the master has gone quiet_ms without a byte. A needle can 415 // the master has gone quiet_ms without a byte. A needle can