a73x

test/e2e_08_mouse.sh

Ref:   Size: 37.6 KiB   History

# shellcheck shell=sh
# e2e_08_mouse.sh — sourced by test/e2e.sh after e2e_lib.sh. Scenarios run in
# the order they stand in; see the lib's header for what this file may
# assume and what it must register.
# The wheel pair. Each needs a scrollback of its own to scroll (or to prove
# it did not), which the long-lived /bin/sh daemon has no way to hold still,
# and the second one needs a session that ASKS for the mouse — a mode that
# would follow every other scenario sharing the daemon around.
SOCK33="${TMPDIR:-/tmp}/muxd-e2e-wheel-$$.sock"
defer_sock "$SOCK33"
WHEELSH="${TMPDIR:-/tmp}/mux-e2e-wheel-$$.sh"
defer_rm "$WHEELSH"
SOCK34="${TMPDIR:-/tmp}/muxd-e2e-appmouse-$$.sock"
defer_sock "$SOCK34"
MOUSESH="${TMPDIR:-/tmp}/mux-e2e-appmouse-$$.sh"
defer_rm "$MOUSESH"
# The alternate-screen half of the wheel: its own daemon because the
# session under test is a PAGER holding the alt screen for its whole life,
# which no other scenario could share a grid with. And the no-terminal
# half, whose session is a bare `cat` so that what reaches the pty is
# echoed back into the grid the assertion reads.
SOCK35="${TMPDIR:-/tmp}/muxd-e2e-pager-$$.sock"
defer_sock "$SOCK35"
LESSSH="${TMPDIR:-/tmp}/mux-e2e-pager-$$.sh"
defer_rm "$LESSSH"
LESSDATA="${TMPDIR:-/tmp}/mux-e2e-pager-$$.txt"
defer_rm "$LESSDATA"
SOCK36="${TMPDIR:-/tmp}/muxd-e2e-pipestdin-$$.sock"
defer_sock "$SOCK36"
# The focus-skip leg, on a daemon of its own for the grid-size reason: it
# asserts on grid sizes and on how many clients a session ever had at once,
# and a session another block created — or another block's client sitting
# on this daemon — would make both numbers say nothing.
SOCK37="${TMPDIR:-/tmp}/muxd-e2e-focusskip-$$.sock"
defer_sock "$SOCK37"
# ...and a state home of its own for the same reason one step further out:
# a wall is the HOSTS FILE now, and the shared state home is every daemon
# this suite has ever attached to, each of them a stripe.
ZSSTATE="${TMPDIR:-/tmp}/mux-e2e-focusskip-state-$$"
defer_rm "$ZSSTATE"
# The wheel inside the focused tile. Two daemons for the wheel pair's own
# reason, turned up one notch: each needs a session whose SHELL is fixed for
# the session's whole life — one that scrolls and one that ASKS for the
# mouse — and a mode another scenario's session set would follow the wall's
# terminal around. Both use the daemon's DEFAULT session, so the wall
# spelling is a bare `--sock PATH` and nothing has to create anything.
SOCK41="${TMPDIR:-/tmp}/muxd-e2e-wallwheel-$$.sock"
defer_sock "$SOCK41"
ZWSTATE="${TMPDIR:-/tmp}/mux-e2e-wallwheel-state-$$"
defer_rm "$ZWSTATE"
ZWHEELSH="${TMPDIR:-/tmp}/mux-e2e-wallwheel-$$.sh"
defer_rm "$ZWHEELSH"
SOCK42="${TMPDIR:-/tmp}/muxd-e2e-wallappmouse-$$.sock"
defer_sock "$SOCK42"
ZM2STATE="${TMPDIR:-/tmp}/mux-e2e-wallappmouse-state-$$"
defer_rm "$ZM2STATE"
SOCK77="${TMPDIR:-/tmp}/muxd-e2e-offorigin-$$.sock"
defer_sock "$SOCK77"
ZOSTATE="${TMPDIR:-/tmp}/mux-e2e-offorigin-state-$$"
defer_rm "$ZOSTATE"
ZMOUSESH="${TMPDIR:-/tmp}/mux-e2e-wallappmouse-$$.sh"
defer_rm "$ZMOUSESH"

# ---- the wheel scrolls back, and an app that asks gets it instead -------
#
# The pair is one claim seen from both sides, so the two legs are the same
# session, the same bytes and opposite outcomes: a wheel report belongs to
# the scrollback until an application inside the session asks for the mouse,
# and then it belongs to that application.
#
# The bytes are real ones: `\x1b[<64;10;5M` is what a terminal writes for a
# wheel-up press once the client has asked for SGR reports (1006), which is
# the point — before this the client asked for nothing, so a terminal
# answered the wheel by SYNTHESISING arrow keys on the alternate screen
# (DEC 1007) and the session read them as input.
#
# (a) At the live view, eight notches in ONE write scroll a screenful back.
#     Eight because a notch is three rows: 8*3 = 24, exactly what
#     Shift+PageUp moves on this 80x24 terminal, which is what lets this
#     leg borrow tp1's needle — "60" is in the history page (rows 54..77)
#     and in nothing painted before it. One write rather than eight because
#     that is how a terminal writes a spin, and because a burst that is not
#     SUMMED lands three rows up instead of twenty-four: the needle fails.
cat > "$WHEELSH" <<'EOF'
#!/bin/sh
seq 1 100
exec /bin/cat
EOF
chmod +x "$WHEELSH"
start_daemon "$SOCK33" "$OUT.whl.d" "wheel daemon never bound" --shell "$WHEELSH"
D30PID=$DPID
# Attach only once seq has finished, for tp1's reason: the page the wheel
# fetches has to be content this client was never sent.
wait_grid "$SOCK33" "100" "wheel session never finished seq"
# `\x1b[0m100`, not "100": the client asks its own terminal for mouse
# reporting with `\x1b[?1000h`, so a bare "100" is in this capture before
# any row is. The SGR reset in front of it is the paint's, and nothing else
# writes that pair. (Measured: with the plain needle this leg passes on the
# mode enable and sends the wheel before the snapshot has landed.)
set +e
hostroom wheel
XDG_STATE_HOME="$HOSTROOM" timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.whl" --err "$OUT.whl.err" \
    -- "$MUX" --sock "$SOCK33" > "$OUT.whl.log" 2>&1 <<'EOF'
expect \x1b[0m100 15000
send \x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M
expect 60 15000
send \x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M
expect \x1b[0m100 15000
send \x1b[<64;10;5Mwheel-and-key
settle 600 15000
send \x1cd
waitexit 10000
EOF
RC=$?
set -e
rc0 "wheel: ptyclient leg exited $RC (did the wheel scroll, and come back?):" "$OUT.whl.log"
# The other half of "the wheel is the client's": the bytes are CONSUMED.
# Seventeen mouse reports were typed at a shell that would have echoed every
# one of them, and the session's grid must hold none.
timeout 20 "$MUX" a capture --sock "$SOCK33" > "$OUT.whlcap" 2>&1
grep -qF -- "[<64;" "$OUT.whlcap" && {
    echo "e2e FAIL: wheel: a mouse report reached the pty as input:"
    cat "$OUT.whlcap"; exit 1; }
# ...but a keystroke that shared its read with a notch is NOT consumed. The
# last send was one write holding both, typed at the live view: the wheel
# moved the view and the letters belong to the shell. Swallowing them as
# "the key that leaves scroll mode" loses input to a view the user had not
# seen yet, which is what this needle catches.
grep -qF -- "wheel-and-key" "$OUT.whlcap" || {
    echo "e2e FAIL: wheel: a keystroke sharing a read with a notch never reached the pty:"
    cat "$OUT.whlcap"; exit 1; }
assert_stopped "$SOCK33" "$D30PID" "wheel" "$OUT.whlstop"
D30PID=""
ok "the wheel scrolls back and returns to live, and never reaches the pty"

# (b) The same session with an application holding the mouse. The script
#     asks for tracking (1000), drag (1002) and SGR reports (1006) — vim's
#     `set mouse=a` set — before becoming cat, so every wheel byte is the
#     application's and the client must hand them over untouched.
#
#     1003 is the mode that makes this leg sharp, and it is 1003 rather
#     than 1002 because the client's own capture set is 1000+1002+1006 —
#     a `?1002h` here would be written whether the session asked or not,
#     and asserting on it would be asserting nothing. 1003 (report motion
#     with no button down) is a mode the client never wants for itself, so
#     a `?1003h` on this terminal can only be the session's, mirrored. The
#     script asks for it on top of vim's `set mouse=a` set.
cat > "$MOUSESH" <<'EOF'
#!/bin/sh
seq 1 100 | sed 's/^/app-history-row-/'
printf '\033[?1000h\033[?1002h\033[?1003h\033[?1006h'
printf 'app-holds-the-mouse\n'
exec /bin/cat
EOF
chmod +x "$MOUSESH"
start_daemon "$SOCK34" "$OUT.mse.d" "app-mouse daemon never bound" --shell "$MOUSESH"
D31PID=$DPID
wait_grid "$SOCK34" "app-holds-the-mouse" "app-mouse session never armed"
# The modes are already set when this client attaches, so they arrive in
# the attach's own term_modes and there is no race to settle for. The echo
# is the assertion: cat is in canonical mode with ECHOCTL, so bytes that
# reach the pty come back as `^[[<64;10;4M` (one row up: a wall on a terminal sits under its label bar) and bytes that do not, do not.
set +e
hostroom appmouse
XDG_STATE_HOME="$HOSTROOM" timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.mse" --err "$OUT.mse.err" \
    -- "$MUX" --sock "$SOCK34" > "$OUT.mse.log" 2>&1 <<'EOF'
expect app-holds-the-mouse 15000
send \x1b[<64;10;5M
expect [<64;10;4M 15000
send \x1cd
waitexit 10000
EOF
RC=$?
set -e
rc0 "app mouse: ptyclient leg exited $RC (did the wheel reach the app?):" "$OUT.mse.log"
# The session's own grid, not just the client's screen: the echo is the pty
# saying it received the bytes.
timeout 20 "$MUX" a capture --sock "$SOCK34" > "$OUT.msecap" 2>&1
grep -qF -- "[<64;10;4M" "$OUT.msecap" || {
    echo "e2e FAIL: app mouse: the wheel never reached the application's pty:"
    cat "$OUT.msecap"; exit 1; }
# The mirror: this terminal was asked for the session's modes, not the
# client's own set — and 1003 is outside that set, so this cannot pass on
# the client's own behalf.
grep -qaF "$(printf '\033[?1003h')" "$OUT.mse" || {
    echo "e2e FAIL: app mouse: the client never mirrored the session's any-motion mode"; exit 1; }
# And the negative that makes the pair a pair: this session has the same
# 77 rows of history as (a), and the same wheel byte moved none of it.
# A bare number also matches the PID in the pane's socket label.
grep -qF -- "app-history-row-60" "$OUT.mse" && {
    echo "e2e FAIL: app mouse: the client scrolled back on a wheel the app owned"; exit 1; }
assert_stopped "$SOCK34" "$D31PID" "app mouse" "$OUT.msestop"
D31PID=""
ok "an application that asked for the mouse gets the wheel, and the client does not"

# ---- the wheel on the alternate screen: a pager, not our scrollback ----
#
# The alt screen has no scrollback of ours — `historyRows` is 0 there by
# contract — so a notch that fell through to the scroll arithmetic was
# consumed by the mouse filter and then dropped. Measured on this very
# scenario before the fix: four notches, grid unmoved.
#
# `less +G` on 200 lines: the view starts at the END, so a wheel-UP is the
# direction with somewhere to go. Eight notches at less's default of three
# rows a notch is 24 rows, one screenful, so the top row moves back by 24
# and the row that was at the top leaves the screen entirely — a needle and
# its negative.
#
# The top row is READ off the grid rather than written down here, and the
# read is taken between two attaches so that nothing is racing it. Where a
# pager parks the end of a file is the pager's business and the builds
# disagree: attaching makes less repaint, and how many of the 24 rows the
# build spends on its own status line decides where the repainted view
# starts. less 704 leaves the top at 178 and less 668, the one macOS 26
# ships, at 179 — measured on this scenario, three runs each. So a leg that
# spelled 154 failed on the Mac by one row while the wheel worked
# perfectly, and one that accepted 154 OR 155 stopped being able to fail on
# a wheel that moved 23 rows, which is what a lost keystroke looks like.
#
# The short attach below is what makes 24 an exact number on both. It
# attaches, waits for the paint, and detaches, which leaves the daemon's
# grid holding the REPAINTED view; the dump after it is therefore the same
# top row the wheel is about to move, read while no client is attached and
# nothing can change it. A second attach at the same size repaints to the
# same place, measured three times on each OS.
#
# LESS is cleared in the script rather than trusted: an operator with
# `LESS=--mouse` exported would have a pager that DOES ask for the mouse,
# which is the other leg's story, and this one would fail for a reason that
# has nothing to do with the code.
seq 1 200 > "$LESSDATA"
cat > "$LESSSH" <<EOF
#!/bin/sh
LESS=
export LESS
exec less +G $LESSDATA
EOF
chmod +x "$LESSSH"
start_daemon "$SOCK35" "$OUT.pgr.d" "pager daemon never bound" --shell "$LESSSH"
D32PID=$DPID
# Not wait_grid: the needle is anchored (`-qx`, a row that is EXACTLY one
# of the file's lines, so a half-painted `19` does not pass) and the dump
# is trimmed to the last rows, because a pager's whole grid is 24 rows of
# numbers that say nothing about why it stopped short. 190 rather than the
# file's last line: it is well inside the page either build draws, so the
# needle does not depend on the height question the comment above settles.
i=0
until "$MUX" d dump --sock "$SOCK35" | grep -qx "190"; do
    i=$((i+1)); [ "$i" -lt $(( 100 * TIME_SCALE )) ] || {
        echo "e2e FAIL: pager never painted the page it was sent to:"
        "$MUX" d dump --sock "$SOCK35" | tail -3; exit 1; }
    sleep 0.1
done
# The anchoring attach. Same size as the one that follows it, because the
# thing being fixed is where a repaint at THIS size lands.
set +e
hostroom pager
XDG_STATE_HOME="$HOSTROOM" timeout 40 "$PTYCLIENT" --cols 80 --rows 24 \
    --out "$OUT.pgranchor" --err "$OUT.pgranchor.err" \
    -- "$MUX" --sock "$SOCK35" > "$OUT.pgranchor.log" 2>&1 <<'EOF'
expect 199 15000
settle 800 15000
send \x1cd
waitexit 10000
EOF
RC=$?
set -e
rc0 "pager: the anchoring attach exited $RC:" "$OUT.pgranchor.log"
PGRTOP=$("$MUX" d dump --sock "$SOCK35" | sed -n '1p')
# Refused rather than trusted: an anchor that is not a line number, or one
# with no room for a screenful above it, would make the arithmetic below
# assert nothing. Both would mean the pager is not where this leg thinks.
case "$PGRTOP" in
    ''|*[!0-9]*)
        echo "e2e FAIL: pager: the top row after the anchoring attach is not a line number: '$PGRTOP'"
        "$MUX" d dump --sock "$SOCK35" | head -3; exit 1 ;;
esac
[ "$PGRTOP" -gt 24 ] || {
    echo "e2e FAIL: pager: top row $PGRTOP leaves no room for a screenful of wheel"
    "$MUX" d dump --sock "$SOCK35" | head -3; exit 1; }

set +e
XDG_STATE_HOME="$HOSTROOM" timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pgr" --err "$OUT.pgr.err" \
    -- "$MUX" --sock "$SOCK35" > "$OUT.pgr.log" 2>&1 <<'EOF'
expect 199 15000
send \x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M
settle 800 15000
send \x1cd
waitexit 10000
EOF
RC=$?
set -e
rc0 "pager: ptyclient leg exited $RC:" "$OUT.pgr.log"
# `mux d dump`, not `mux a capture`: the assertions below are WHOLE-LINE ones
# and capture answers in JSON, where the grid is one line with escaped
# newlines in it — a `grep -x` against that can never match. (It did not,
# on the first run of this leg, while the grid underneath was exactly
# right.) dump prints the grid a row per line, which is what -x needs.
"$MUX" d dump --sock "$SOCK35" > "$OUT.pgrcap" 2>&1
# A whole line, because the pager's status line carries the file's PATH —
# which holds this run's pid and could spell any short number.
grep -qx "$(( PGRTOP - 24 ))" "$OUT.pgrcap" || {
    echo "e2e FAIL: pager: the wheel did not move the alternate screen back 24 rows"
    echo "          (the top was $PGRTOP, so row $(( PGRTOP - 24 )) should be on screen):"
    cat "$OUT.pgrcap"; exit 1; }
grep -qx "$PGRTOP" "$OUT.pgrcap" && {
    echo "e2e FAIL: pager: the old top row $PGRTOP is still on screen, so the view did not move a screenful:"
    cat "$OUT.pgrcap"; exit 1; }
# The pager never asked for the mouse, so it must never have SEEN a mouse
# report: what reached it was arrow keys, which leave no text behind.
grep -qF -- "[<64;" "$OUT.pgrcap" && {
    echo "e2e FAIL: pager: a raw mouse report reached an application that never asked for one:"
    cat "$OUT.pgrcap"; exit 1; }
assert_stopped "$SOCK35" "$D32PID" "pager" "$OUT.pgrstop"
D32PID=""
ok "the wheel scrolls a pager on the alternate screen, as arrow keys"

# ---- a client with no terminal filters nothing --------------------------
#
# The mouse filter is gated on this client having TAKEN a terminal over,
# and that gate has to be the alt-screen flag rather than the mode bits:
# a client whose stdin is a pipe never asked anyone for mouse reports, so
# nothing it reads can be one. Filtering there is pure loss — the bytes are
# whatever a script piped in, and an SGR-shaped escape in a heredoc is text
# somebody meant to send.
#
# `cat` as the session, so what arrives is echoed back and the grid IS the
# assertion: the bytes reached the pty or they did not.
start_daemon "$SOCK36" "$OUT.pipe.d" "pipe-stdin daemon never bound" --shell /bin/cat
D33PID=$DPID
# The detach chord is a SEPARATE write behind a sleep, and both halves of
# that are load-bearing. Without it the client stays attached after stdin
# ends — a pipe closing is not a session ending — and the leg would prove
# its point by timing out. And it cannot ride the same write as the
# payload: a chord ENDS its chunk (PrefixFilter), so bytes ahead of it in
# the same read are dropped, which would race the assertion away.
{ printf 'hello \033[<64;10;5M world\n'; sleep 1; printf '\034d'; } |
    timeout 20 "$MUX" --sock "$SOCK36" > "$OUT.pipe" 2>&1
RC=$?
rc0 "pipe stdin: the client exited $RC:" "$OUT.pipe"
# Not wait_grid: this asks `mux a capture`, the agent surface, not
# `mux d dump` — the piped client is the thing under test, so the reading
# has to come down the same road an agent would use.
i=0
until timeout 20 "$MUX" a capture --sock "$SOCK36" 2>&1 | grep -qF -- "world"; do
    i=$((i+1)); [ "$i" -lt $(( 100 * TIME_SCALE )) ] || {
        echo "e2e FAIL: pipe stdin: the piped line never reached the pty:"
        timeout 20 "$MUX" a capture --sock "$SOCK36"; exit 1; }
    sleep 0.1
done
timeout 20 "$MUX" a capture --sock "$SOCK36" > "$OUT.pipecap" 2>&1
# The whole line, escape included. `cat` echoes control bytes as `^[`, so
# what a grid can show of `\x1b[<64;10;5M` is `[<64;10;5M` — and a client
# that ate the report leaves `hello  world` with the middle missing.
grep -qF -- "[<64;10;5M" "$OUT.pipecap" || {
    echo "e2e FAIL: pipe stdin: an SGR-shaped escape was filtered out of a stream nobody was reporting on:"
    cat "$OUT.pipecap"; exit 1; }
assert_stopped "$SOCK36" "$D33PID" "pipe stdin" "$OUT.pipestop"
D33PID=""
ok "a client with no terminal of its own forwards SGR-shaped bytes untouched"

# ---- focus skips between tiles, and the daemon never notices -----------
#
# Every tile's replica is hot whether or not it is the focus, so moving
# the focus between two tiles is a local repaint. No re-dial, no attach,
# no snapshot round trip — a claim about what did NOT happen, witnessed
# daemon-side rather than by a stopwatch.
#
# One ptyclient leg, two sessions' worth of typing on two tiles:
#
#   type         mark 0 with zs-one (the entry tile is focused from the
#                first byte)
#   Ctrl-\ n     move the focus to tile 2 (session b)
#   type         mark b with zs-two
#   Ctrl-\ 1     focus tile 1 (the default session)
#   type         mark 0 again with zs-three
#   Ctrl-\ d     leave
#
# Two claims:
#
#   * every marker landed in the session the focus was on, and in no
#     other. zs-one and zs-three in `0`, zs-two in b, and each absent from
#     the other session — the negatives are what prove the focus MOVED
#     rather than the wall typing on at one connection.
#   * the daemon accepted exactly TWO attaches for the whole run — one per
#     tile: the entry tile's, and the one the host's list grew for `b`. A
#     `Ctrl-\ n` that re-dialled would be three or more, and the cumulative
#     counter says so however briefly the extra connection lived. Focus is
#     client-local and sends nothing on the wire.
start_daemon "$SOCK37" "$OUT.zs.d" "focus-skip daemon never bound" --shell /bin/sh
D34PID=$DPID

# Both sessions exist before the wall does, each with a marker so the wall
# has something to paint and this leg has an anchor to enter on. The first
# of them is the daemon's OWN default session rather than a named one: a
# daemon is born holding `0`, so a wall over it always has that tile, and
# naming a second `a` would make this a three-tile leg pretending to be a
# two-tile one.
pipe_mux "$OUT.zsa" "$OUT.zsa.err" env XDG_STATE_HOME="$ZSSTATE" timeout 40 "$MUX" --sock "$SOCK37"
pipe_send 'printf "zsa-%%s\\n" pin\n'
await_out "$OUT.zsa" "zsa-pin" "zsa-pin never reached the client"
pipe_detach
wait_grid "$SOCK37" "zsa-pin" "focus skip: the default session's marker"
pipe_mux "$OUT.zsb" "$OUT.zsb.err" env XDG_STATE_HOME="$ZSSTATE" timeout 40 "$MUX" --sock "$SOCK37" --session b
pipe_send 'printf "zsb-%%s\\n" pin\n'
await_out "$OUT.zsb" "zsb-pin" "zsb-pin never reached the client"
pipe_detach
wait_grid "$SOCK37" "zsb-pin" "focus skip: session b's marker" b

ZSATT_BEFORE=$(attaches_now "$SOCK37")
watch_clients "$SOCK37" "$OUT.zswatch"
set +e
# tall: two stacked stripes, so a focus step is a step down the screen
seed_layout "$ZSSTATE" stacked "--sock $SOCK37#0" "--sock $SOCK37#b"
XDG_STATE_HOME="$ZSSTATE" timeout 90 "$PTYCLIENT" --cols 40 --rows 30 \
    --out "$OUT.zscap" --err "$OUT.zscap.err" -- \
    "$MUX" --sock "$SOCK37" > "$OUT.zspc" 2>&1 <<'EOF'
expect zsb-pin 20000
settle 700 20000
send printf 'zs-%s\\n' one\n
expect zs-one 15000
settle 400 15000
send \x1cn
settle 700 20000
send printf 'zs-%s\\n' two\n
expect zs-two 15000
settle 400 15000
send \x1c1
settle 700 20000
send printf 'zs-%s\\n' three\n
expect zs-three 15000
settle 400 15000
send \x1cd
waitexit 10000
EOF
RC=$?
set -e
unwatch_clients "$OUT.zswatch"
ZSATT_AFTER=$(attaches_now "$SOCK37")
rc0 "focus skip: ptyclient leg exited $RC (did \\x1cn and \\x1c1 move the focus?):" "$OUT.zspc"
# Where each marker landed. `mux a capture` reads the session's own grid, so
# none of this can be an echo of what this script typed at a terminal.
timeout 20 "$MUX" a capture --sock "$SOCK37" > "$OUT.zsfa" 2>&1
timeout 20 "$MUX" a capture --sock "$SOCK37" --session b > "$OUT.zsfb" 2>&1
#
# The markers are numbered rather than named after their sessions, and
# deliberately so: `zs-b` and `zs-back` were the first spelling, and `zs-b`
# matched inside `zs-back` — a negative grep that could never fail, in the
# leg whose whole content is negative greps. Numbered words share no
# prefix, which is the property this needs.
for _m in zs-one zs-three; do
    grep -q "$_m" "$OUT.zsfa" || {
        echo "e2e FAIL: focus skip: the default session never got $_m:"
        cat "$OUT.zsfa"; exit 1; }
    grep -q "$_m" "$OUT.zsfb" && {
        echo "e2e FAIL: focus skip: $_m reached session b, which the focus had left:"
        cat "$OUT.zsfb"; exit 1; }
done
grep -q "zs-two" "$OUT.zsfb" || {
    echo "e2e FAIL: focus skip: Ctrl-\\ n did not put the focus on session b:"
    cat "$OUT.zsfb"; exit 1; }
grep -q "zs-two" "$OUT.zsfa" && {
    echo "e2e FAIL: focus skip: zs-two reached the default session, so the focus never moved:"
    cat "$OUT.zsfa"; exit 1; }
# The whole point, said by the daemon: two tiles, two attaches, and never a
# third across a focus move each way. This is the leg the counter matters
# most in — the two moves it makes are exactly the ones a gauge could not
# tell from a re-dial, since a connection that closes as another opens
# never shows two at once.
assert_attach_delta "$ZSATT_BEFORE" "$ZSATT_AFTER" 2 "focus skip"
assert_never_two_clients "$OUT.zswatch" 0 "focus skip"
assert_never_two_clients "$OUT.zswatch" b "focus skip"
# Both tiles claimed their rect: 40 cols each (this leg's own width) and 14
# rows each (30 terminal rows cut into two stripes of 15, minus one label
# bar each). A focus move resizes nothing.
for _s in 0 b; do
    timeout 20 "$MUX" a status --sock "$SOCK37" --session "$_s" > "$OUT.zsst$_s" 2>&1
    grep -q '"cols":40' "$OUT.zsst$_s" || {
        echo "e2e FAIL: focus skip: session $_s is not at the width its tile claimed:"
        cat "$OUT.zsst$_s"; exit 1; }
    grep -q '"rows":14' "$OUT.zsst$_s" || {
        echo "e2e FAIL: focus skip: session $_s is not at the height its tile claimed:"
        cat "$OUT.zsst$_s"; exit 1; }
done
assert_stopped "$SOCK37" "$D34PID" "focus skip" "$OUT.zsstop"
D34PID=""
ok "focus skips between tiles with Ctrl-\\ n / Ctrl-\\ l, and the daemon sees no new attach"

# ---- the wheel inside the focused tile ---------------------------------
#
# A tile's input goes through the same `interact.Core` a plain client's
# does, so the wheel arrives with it: the focused tile scrolls its
# session's history on the wheel and never reaches its pty. Same arithmetic
# as the plain client's wheel leg (scenario 42) and deliberately the same
# pty size, so a difference here is the wall's and not the geometry's:
# eight notches in one write are 24 rows, exactly a screenful on 80x24, and
# the page that lands holds `wln60` — a line that is in the history and in
# nothing painted before it.
#
# The lines are `wln1`..`wln100` rather than bare numbers because this
# terminal is a WALL: its label bar carries the socket path, which carries
# this run's pid, and a bare `60` in a pid would make the money assertion
# pass for a reason that has nothing to do with the wheel.
#
# The wall's spelling is a bare `--sock PATH` — the daemon's DEFAULT
# session, so nothing has to be created and the session's shell is fixed for
# its whole life. One tile, so no label bar: the geometry is the plain
# client's.
cat > "$ZWHEELSH" <<'EOF'
#!/bin/sh
seq 1 100 | sed 's/^/wln/'
exec /bin/cat
EOF
chmod +x "$ZWHEELSH"
start_daemon "$SOCK41" "$OUT.zw.d" "wheel daemon never bound" --shell "$ZWHEELSH"
D38PID=$DPID
# The wall attaches only once seq has finished, scenario 42's reason: the
# page the wheel fetches has to be content this tile was never sent.
wait_grid "$SOCK41" "wln100" "wheel: session never finished seq"
set +e
no_saved_tree "$ZWSTATE"
XDG_STATE_HOME="$ZWSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
    --out "$OUT.zwcap" --err "$OUT.zwcap.err" -- \
    "$MUX" --sock "$SOCK41" > "$OUT.zwpc" 2>&1 <<'EOF'
expect wln100 20000
settle 700 20000
send \x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M\x1b[<64;10;5M
expect wln60 15000
send \x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M\x1b[<65;10;5M
expect wln100 15000
settle 500 15000
send zwheelpin\n
expect zwheelpin 15000
settle 500 15000
send \x1cd
waitexit 10000
EOF
RC=$?
set -e
rc0 "wheel: ptyclient leg exited $RC (did the focused wheel scroll?):" "$OUT.zwpc"
# The anchor first, and it is a POSITIVE: a keystroke typed at the focused
# tile reached this session's pty. The silent half below is measured against
# it, so a leg that fell over early can never pass by having done nothing.
timeout 20 "$MUX" a capture --sock "$SOCK41" > "$OUT.zwcapg" 2>&1
grep -q "zwheelpin" "$OUT.zwcapg" || {
    echo "e2e FAIL: wheel: the focus never typed into the session, so the"
    echo "          silent half below proves nothing:"
    cat "$OUT.zwcapg"; cat "$OUT.zwpc"; exit 1; }
# The wheel reports were typed at a shell that echoes everything it is
# given, and the session's grid must hold none: the wheel is the client's
# own, never forwarded to the pty.
grep -qF -- "[<64;" "$OUT.zwcapg" && {
    echo "e2e FAIL: wheel: a mouse report reached the pty as input:"
    cat "$OUT.zwcapg"; exit 1; }
assert_stopped "$SOCK41" "$D38PID" "wheel" "$OUT.zwstop"
D38PID=""
ok "the focused tile scrolls its session's history on the wheel, and the pty sees none of it"

# ---- an application in the focused tile owns the mouse -----------------
#
# Scenario 43 said this about a plain client; this says it about a tile.
# A tile learns the session's modes while it is a STRIPE — `interact.Core`
# decodes every `term_modes` whatever the tile's claim is, and only the
# WRITE to the terminal is gated — so the handover cannot come from a frame:
# it has to come from what the Core already knew, level-set onto the
# terminal at the moment of the claim.
#
# 1003 is what makes the leg sharp, and it has to be 1003: the wall claims
# 1000+1002+1006 for itself and a focused tile claims the same set, so
# either of those would appear on this terminal with no session involved.
# 1003 is in nobody's own set, so a `?1003h` here can only be the session's,
# mirrored.
cat > "$ZMOUSESH" <<'EOF'
#!/bin/sh
seq 1 100 | sed 's/^/mln/'
printf '\033[?1000h\033[?1002h\033[?1003h\033[?1006h'
printf 'mapp-holds-the-mouse\n'
exec /bin/cat
EOF
chmod +x "$ZMOUSESH"
start_daemon "$SOCK42" "$OUT.zm2.d" "app-mouse daemon never bound" --shell "$ZMOUSESH"
D39PID=$DPID
wait_grid "$SOCK42" "mapp-holds-the-mouse" "app mouse: session never armed"
# The echo is the assertion: cat is in canonical mode with ECHOCTL, so bytes
# that reach the pty come back as `^[[<64;10;4M` and bytes that do not, do
# not. The tile claims the terminal on its first pass, so the modes are
# level-set before the wheel is sent. Row 5 on the terminal is row 4 of the
# application: a tile on a terminal sits under its label bar, and the
# report reaches the pty in the application's own numbering, not the
# screen's. The leg below moves the tile further from the origin.
set +e
no_saved_tree "$ZM2STATE"
XDG_STATE_HOME="$ZM2STATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
    --out "$OUT.zm2cap" --err "$OUT.zm2cap.err" -- \
    "$MUX" --sock "$SOCK42" > "$OUT.zm2pc" 2>&1 <<'EOF'
expect mapp-holds-the-mouse 20000
settle 700 20000
send \x1b[<64;10;5M
expect [<64;10;4M 15000
settle 500 15000
send \x1cd
waitexit 10000
EOF
RC=$?
set -e
rc0 "app mouse: ptyclient leg exited $RC (did the wheel reach the app, one row up?):" "$OUT.zm2pc"
# The session's own grid, not just this terminal: the echo is the pty saying
# it received the bytes.
timeout 20 "$MUX" a capture --sock "$SOCK42" > "$OUT.zm2capg" 2>&1
grep -qF -- "[<64;10;4M" "$OUT.zm2capg" || {
    echo "e2e FAIL: app mouse: the wheel never reached the application's pty:"
    cat "$OUT.zm2capg"; exit 1; }
# The mirror: this terminal was asked for the SESSION's modes at the claim,
# not for the tile's own set — and 1003 is outside that set.
grep -qaF "$(printf '\033')[?1003h" "$OUT.zm2cap" || {
    echo "e2e FAIL: app mouse: the claim never mirrored the session's any-motion mode:"
    cat "$OUT.zm2pc"; exit 1; }
# And the negative that makes the pair a pair: this session has the same 77
# rows of history as the leg above, and the same wheel byte moved none of it.
grep -qa "mln60" "$OUT.zm2cap" && {
    echo "e2e FAIL: app mouse: the tile scrolled back on a wheel the app owned:"
    cat "$OUT.zm2pc"; exit 1; }
assert_stopped "$SOCK42" "$D39PID" "app mouse" "$OUT.zm2stop"
D39PID=""
ok "an application in the focused tile gets the wheel, and the tile does not"


# ---- an application in a tile off the origin reads its own rows ---------
#
# The leg above holds the tile one label bar from the origin; this one puts
# it in the LOWER stripe of a stacked wall, where a report passed through
# in the terminal's numbering names a row the application does not have.
# That is how it shipped: on a live wall every press in a bottom tile
# reached vim and Claude Code as a row past their grid, and neither could
# select (2026-09-02). Every earlier app-mouse leg was a wall of one tile,
# which is the fixture holding the offset at zero.
#
# 80x24 cut into two stripes is 12 rows each, one of them the label bar, so
# session b's grid is 11 rows starting at terminal row 14 — `mux a status`
# says the 11, which is what pins the arithmetic to the layout rather than
# to this comment. Terminal row 16 is then the application's row 3.
start_daemon "$SOCK77" "$OUT.zo.d" "off-origin daemon never bound" --shell "$ZMOUSESH"
D77PID=$DPID
wait_grid "$SOCK77" "mapp-holds-the-mouse" "off-origin: session 0 never armed"
pipe_mux "$OUT.zob" "$OUT.zob.err" env XDG_STATE_HOME="$ZOSTATE" timeout 40 "$MUX" --sock "$SOCK77" --session b
await_out "$OUT.zob" "mapp-holds-the-mouse" "off-origin: session b never armed"
pipe_detach
wait_grid "$SOCK77" "mapp-holds-the-mouse" "off-origin: session b's marker" b
set +e
seed_layout "$ZOSTATE" stacked "--sock $SOCK77#0" "--sock $SOCK77#b"
XDG_STATE_HOME="$ZOSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 24 \
    --out "$OUT.zocap" --err "$OUT.zocap.err" -- \
    "$MUX" --sock "$SOCK77" > "$OUT.zopc" 2>&1 <<'EOF'
expect mapp-holds-the-mouse 20000
settle 700 20000
send \x1cn
settle 700 20000
send \x1b[<0;10;16M
expect [<0;10;3M 15000
settle 500 15000
send \x1cd
waitexit 10000
EOF
RC=$?
set -e
rc0 "off-origin: ptyclient leg exited $RC (did the press reach the lower tile's app on its own row?):" "$OUT.zopc"
timeout 20 "$MUX" a status --sock "$SOCK77" --session b > "$OUT.zost" 2>&1
grep -q '"rows":11' "$OUT.zost" || {
    echo "e2e FAIL: off-origin: session b is not the 11-row lower stripe this leg's arithmetic assumes:"
    cat "$OUT.zost"; exit 1; }
# The session's own grid says which pty the bytes reached, and in what
# numbering: the lower tile's, on its row 3, and never the upper tile's.
timeout 20 "$MUX" a capture --sock "$SOCK77" --session b > "$OUT.zocapb" 2>&1
grep -qF -- "[<0;10;3M" "$OUT.zocapb" || {
    echo "e2e FAIL: off-origin: the press never reached the lower tile's pty on its own row 3:"
    cat "$OUT.zocapb"; exit 1; }
timeout 20 "$MUX" a capture --sock "$SOCK77" > "$OUT.zocapa" 2>&1
grep -qF -- "[<0;10;" "$OUT.zocapa" && {
    echo "e2e FAIL: off-origin: the press reached the upper tile, which was not focused:"
    cat "$OUT.zocapa"; exit 1; }
assert_stopped "$SOCK77" "$D77PID" "off-origin" "$OUT.zostop"
D77PID=""
ok "an application in a tile off the origin reads the press on its own row"

# ---- a click on a dead host's pane must not deafen the wall -------------
#
# Found live 2026-09-02: a laptop went dark overnight, its pane came back
# [unreachable], and ONE click on it killed the mouse for the whole wall —
# the focus move writes `session_release` (every mouse mode off) and the
# re-arm was the incoming tile's claim, which a pane with no pump (and a
# pump parked in dial) never runs. The keyboard re-arms the wall's own
# capture itself now (`interact.wall_mouse_capture`), and this leg walks
# the user's exact path: click the dark pane, click BACK, type.
#
# `click` is the discriminating verb: ptyclient mirrors the mouse modes off
# the client's own writes and sends a press only while reporting is ON,
# the way a real terminal does. With the re-arm gone, the click back is
# bytes a terminal would never send — the verb exits 3 saying so, instead
# of this leg green-lighting a wall no mouse can steer.
# /tmp and not $TMPDIR, which is the only place in this suite that says so.
# The assertion below greps a socket path out of a tile's LABEL BAR, and a
# bar on a two-pane 100-column wall is 50 columns: `labelText` keeps the
# state word and cuts the label's tail, which is the half that carries the
# `deadclick2` this leg looks for. macOS puts $TMPDIR in a per-user
# directory whose name alone was 48 characters on the box this was
# measured on, so the bar had nothing of the name left in it. The clicks below are at fixed columns, so widening
# the terminal is not free here the way it is in the hosts group.
SOCKMC1="/tmp/muxd-e2e-deadclick1-$$.sock"
defer_sock "$SOCKMC1"
SOCKMC2="/tmp/muxd-e2e-deadclick2-$$.sock"
defer_sock "$SOCKMC2"
MCSTATE="${TMPDIR:-/tmp}/mux-e2e-deadclick-state-$$"
defer_rm "$MCSTATE"
mkdir -p "$MCSTATE/mux"
printf -- '--sock %s\n--sock %s\n' "$SOCKMC1" "$SOCKMC2" > "$MCSTATE/mux/hosts"
start_daemon "$SOCKMC1" "$OUT.mc1.d" "dead-click daemon 1 never bound" --shell /bin/sh
start_daemon "$SOCKMC2" "$OUT.mc2.d" "dead-click daemon 2 never bound" --shell /bin/sh
MC2PID=$DPID
# A polled host never CREATES a session: host 2's pane binds only if the
# session its leaf names is really there. One attach-and-detach births the
# default session; the daemon keeps it.
pipe_mux "$OUT.mc0" "$OUT.mc0.err" env XDG_STATE_HOME="$MCSTATE" timeout 40 "$MUX" --sock "$SOCKMC2"
pipe_detach
# ...and the wall that shows it, written down: a poll adds no pane, so the
# two-pane cut this leg clicks across is authored here. `beside` because
# the clicks below are at columns 75 and 25 — left pane and right pane, on
# a 100-column terminal.
seed_layout "$MCSTATE" beside "--sock $SOCKMC1#0" "--sock $SOCKMC2#0"

# Run 1: both hosts up, a wall of two, left on a terminal — the layout is
# what carries host 2's pane into run 2 as a saved pane whose daemon has
# gone.
set +e
XDG_STATE_HOME="$MCSTATE" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
    --out "$OUT.mc1" --err "$OUT.mc1.err" -- \
    "$MUX" --sock "$SOCKMC1" > "$OUT.mc1.pc" 2>&1 <<EOF
expect [up] 15000
settle 2500 25000
send \x1cd
waitexit 15000
EOF
RC=$?
set -e
rc0 "dead-click: the two-tile wall leg" "$OUT.mc1.pc" "$OUT.mc1.err"
# Post-hoc and not an expect: the second tile's bar can paint BEFORE the
# first [up] the script matched, and expect(1) never looks behind its
# cursor. The capture as a whole is what the sidecar saw.
grep -aq 'deadclick2.*\[up\]' "$OUT.mc1" || {
    echo "e2e FAIL: dead-click: host 2's tile never came up on the wall"
    exit 1; }

# Host 2 goes dark the way the laptop did: daemon killed, socket gone.
kill -9 "$MC2PID" 2>/dev/null || true
wait_pid_gone "$MC2PID"
rm -f "$SOCKMC2"

# Run 2: the restored wall. The dark host's pane stays, wearing the word;
# the click lands ON it, the click back must still be heard, and the typed
# marker proves the mouse alone steered focus there and home again.
set +e
XDG_STATE_HOME="$MCSTATE" timeout 60 "$PTYCLIENT" --cols 100 --rows 30 \
    --out "$OUT.mc2" --err "$OUT.mc2.err" -- \
    "$MUX" --sock "$SOCKMC1" > "$OUT.mc2.pc" 2>&1 <<'EOF'
expect [up] 15000
settle 2500 25000
click 75 10 8000
expect \x201\x20\x20--sock 10000
click 25 10 8000
send echo CLICKBACK\r
expect CLICKBACK 10000
send \x1cd
waitexit 15000
EOF
RC=$?
set -e
rc0 "dead-click: the click-and-return leg" "$OUT.mc2.pc" "$OUT.mc2.err"
# The pane wore the word (order-free, like run 1's pin), and the first
# click really landed on it: tile 1's bar going to the UNFOCUSED marker is
# the in-script expect above, painted only after focus left it. The
# marker echo is the verdict for the click back: typed bytes reach a live
# shell only if the mouse alone carried focus there and home again.
grep -aq '\[unreachable\]' "$OUT.mc2" || {
    echo "e2e FAIL: dead-click: the dark host's pane never wore unreachable"
    exit 1; }
ok "a click on a dead host's pane leaves the wall's mouse alive"