a73x

test/e2e_03_side.sh

Ref:   Size: 34.3 KiB   History

# shellcheck shell=sh
# e2e_03_side.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.
# tp1 runs its fixture in the BACKGROUND — the tear has to happen while the
# script is mid-flight — so its pid is tracked and killed like a daemon's.
# M13 auto-start + stop. Two paths, and they no longer answer the same
# way: the proxy arc's daemon is started BY HAND, because `mux d proxy`
# reads a box and may not create a session on it, while the pty leg's is
# still spawned by local mux under the M12 fixture. Both are torn down by
# `mux d stop` — the verb under test is also the cleanup, and the trap only
# backstops it. Neither daemon is a child of this shell, so each pid is
# read off the up-line its own spawner printed, which is the only handle
# that can never name a bystander.
SOCK14="${TMPDIR:-/tmp}/muxd-e2e-astart-$$.sock"
defer_sock "$SOCK14"
SOCK15="${TMPDIR:-/tmp}/muxd-e2e-aspty-$$.sock"
defer_sock "$SOCK15"
# The bell leg's own daemon. A socket of its own is load-bearing rather than
# tidiness, and the argument is at the leg itself: that scenario counts BEL
# BYTES in a host capture, and a BEL is also what terminates an OSC. $SOCK's
# session has a window title set on it by the title leg, and every client
# attaching there is told so as `ESC]0;...BEL` — which would land in the
# capture and make the count assert something other than what it says.
SOCK24="${TMPDIR:-/tmp}/muxd-e2e-bell-$$.sock"
defer_sock "$SOCK24"

# --- M13: proxy reaches a daemon, `mux d start -d` makes one, mux d stop ----
#
# Nothing is serving SOCK14, and `mux d proxy` no longer answers that by
# starting a daemon: it is the verb a client reaches over ssh, and a wall
# runs its sibling `mux d endpoint` once a second per listed host — a read
# that creates a session (and a shell) is the regression this refuses.
# So the cold attach FAILS, in proxy.zig's own words, and the daemon the
# rest of this leg attaches to is one the user started by hand, which is
# what README tells them to do.
#
# SHELL pinned because the daemon gets no --shell flag of its own and
# resolves $SHELL — the suite must not inherit the developer's.
set +e
env SHELL=/bin/sh timeout 30 "$MUX" --via "$MUX d proxy --sock $SOCK14" \
    > "$OUT.ascold" 2>&1 < /dev/null
RC_COLD=$?
set -e
[ "$RC_COLD" -ne 0 ] || {
    echo "e2e FAIL: a --via proxy against a socket nobody serves exited 0:"
    cat "$OUT.ascold"; exit 1; }
grep -q "mux d proxy: cannot connect to $SOCK14" "$OUT.ascold" || {
    echo "e2e FAIL: the cold --via proxy did not name the socket it could not reach:"
    cat "$OUT.ascold"; exit 1; }
# Asked of the filesystem, not of the client: a refusal that had started a
# daemon anyway would have left the socket behind.
[ ! -e "$SOCK14" ] || {
    echo "e2e FAIL: a refused --via proxy started a daemon on $SOCK14"; exit 1; }

# The pid is the whole point of the up-line: it is the only handle the
# suite has on a daemon that is nobody's child. Known gap, accepted: an
# assertion below exits before this capture runs, so a failure there
# leaves a daemon with no pid tracked — and hoisting the capture would not
# close it, since the window from the spawn to this sed cannot be made
# zero. Unlike the M10 start block's version of this gap (:1150) it is
# covered anyway: the trap also stops SOCK14/SOCK15 by PATH, which needs
# no pid.
"$MUX" d start -d --sock "$SOCK14" --shell /bin/sh 2> "$OUT.as.start" || {
    echo "e2e FAIL: mux d start -d refused $SOCK14:"; cat "$OUT.as.start"; exit 1; }
APID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.as.start" | head -1)
defer_kill "$APID"
[ -n "$APID" ] || {
    echo "e2e FAIL: mux d start -d's up-line carries no pid"; cat "$OUT.as.start"; exit 1; }
kill -0 "$APID" || { echo "e2e FAIL: the started daemon is not alive"; exit 1; }

pipe_mux "$OUT.as" "$OUT.as.err" env SHELL=/bin/sh timeout 30 "$MUX" --via "$MUX d proxy --sock $SOCK14"
pipe_send 'printf "auto-%%s\\n" start\n'
await_out "$OUT.as" "auto-start" "proxy attach: marker never reached the client"
pipe_detach
"$MUX" d dump --sock "$SOCK14" | grep -q "auto-start" || {
    echo "e2e FAIL: proxy attach: the daemon lost the marker"
    cat "$OUT.as" "$OUT.as.err" 2>/dev/null; exit 1; }
# Nothing on this path may print spawn progress any more — the daemon was
# already up, and neither the proxy nor the client is allowed to make one.
grep -q 'starting' "$OUT.as.err" && {
    echo "e2e FAIL: the proxy attach printed spawn progress"
    cat "$OUT.as.err"; exit 1; }
assert_converged "$OUT.as" "$SOCK14" "attach over proxy"

# Silence is now BOTH paths: nothing on this road starts a daemon, so no
# attach to it may print spawn progress.
pipe_mux "$OUT.as2" "$OUT.as2.err" env SHELL=/bin/sh timeout 30 "$MUX" --via "$MUX d proxy --sock $SOCK14"
pipe_send 'printf "auto-%%s\\n" again\n'
await_out "$OUT.as2" "auto-again" "warm attach: marker never reached the client"
pipe_detach
grep -q 'starting' "$OUT.as2.err" && {
    echo "e2e FAIL: warm attach printed spawn progress"
    cat "$OUT.as2.err"; exit 1; }
"$MUX" d dump --sock "$SOCK14" | grep -q "auto-again" || {
    echo "e2e FAIL: warm attach did not reach the same daemon"; exit 1; }
# The same daemon, asserted by pid rather than by the marker alone: a
# second daemon on the path would have started a fresh shell, but a
# reader should not have to reason that out from a missing string.
kill -0 "$APID" || {
    echo "e2e FAIL: warm attach replaced the daemon (pid $APID is gone)"
    cat "$OUT.as2.err"; exit 1; }
assert_converged "$OUT.as2" "$SOCK14" "second attach over proxy"

# The verb under test is the teardown: exit 0, stopped line, socket gone,
# and the daemon OBSERVED dead by pid — never the command's claim alone.
#
# The exit code is checked through `set +e` rather than left to `set -e`,
# and that is the difference between a caught regression and a mystery: a
# stop that fails exits 1, which would abort the suite with no line of its
# own, and the trap would then rm the one file saying why. The regrade
# resurrects exactly that failure, so it has to PRINT.
set +e
"$MUX" d stop --sock "$SOCK14" 2> "$OUT.stop"
RC_STOP=$?
set -e
[ "$RC_STOP" = "0" ] || {
    echo "e2e FAIL: stop exited $RC_STOP, want 0"; cat "$OUT.stop"; exit 1; }
grep -q '^mux d: stopped' "$OUT.stop" || {
    echo "e2e FAIL: stop did not report stopped"; cat "$OUT.stop"; exit 1; }
[ ! -S "$SOCK14" ] || {
    echo "e2e FAIL: stop left the socket behind"; ls -l "$SOCK14"; exit 1; }
wait_pid_gone "$APID" "proxy auto-start: stop reported stopped"
APID=""

# Idempotence control: stop with nothing there is exit 0 and says so.
set +e
"$MUX" d stop --sock "$SOCK14" 2> "$OUT.stop2"
RC_STOP=$?
set -e
[ "$RC_STOP" = "0" ] || {
    echo "e2e FAIL: stop-when-nothing exited $RC_STOP, want 0"; cat "$OUT.stop2"; exit 1; }
grep -q "nothing listening on $SOCK14" "$OUT.stop2" || {
    echo "e2e FAIL: stop-when-nothing said the wrong thing"; cat "$OUT.stop2"; exit 1; }
rm_swept "$OUT.ascold" "$OUT.as.start" "$OUT.as" "$OUT.as.err" "$OUT.as2" \
    "$OUT.as2.err" "$OUT.stop" "$OUT.stop2"
ok "a cold --via proxy refuses and starts nothing; mux d start -d makes the daemon and mux d stop tears it down"

# --- M13: local mux auto-start, under the pty fixture ------------------
#
# A local attach is tty-gated territory, so it runs on the M12 fixture.
# PATH is prefixed with the build dir: findInPath must resolve exactly
# the binary under test, and the suite proves the resolution by the daemon
# existing afterwards.
#
# The script keeps the two M12 rules that every pty leg is written to:
#   it opens on the alt-screen enter rather than on a `send`. The client
#   takes the tty raw with TCSAFLUSH (client.zig), which DISCARDS input
#   queued before it got there — and here that window is a whole cold
#   spawn wide, so typing into it is typing into /dev/null;
#   the marker text never appears in the line that produces it, and the
#   detach waits on `settle` rather than on the marker. A shell writes its
#   output and its next prompt as two separate writes, so a script that
#   detaches on the output stops one frame short of the grid the daemon
#   holds, and the convergence check below would blame the product for it.
set +e
hostroom autostart
SHELL=/bin/sh PATH="$(dirname "$MUX"):$PATH" XDG_STATE_HOME="$HOSTROOM" timeout 40 \
    "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pa" --err "$OUT.pa.err" \
    -- "$MUX" --sock "$SOCK15" > "$OUT.pa.log" 2>&1 <<'EOF'
expect \x1b[?1049h 15000
send printf 'pty-%s\\n' auto\n
expect pty-auto 15000
settle 500 15000
send \x1c\x1c
waitexit 10000
EOF
RC=$?
set -e
[ "$RC" -eq 0 ] || {
    echo "e2e FAIL: local auto-start ptyclient exited $RC:"
    cat "$OUT.pa.log"; cat -v "$OUT.pa.err" 2>/dev/null; exit 1; }
"$MUX" d dump --sock "$SOCK15" | grep -q "pty-auto" || {
    echo "e2e FAIL: local auto-start lost the marker"
    cat "$OUT.pa.log" "$OUT.pa.err" 2>/dev/null; exit 1; }
# The fixture hands the client a FILE for stderr, so the progress lines
# are non-tty ones and land here rather than on the pty — which is also
# why the capture the convergence check reads is undisturbed by them.
# `mux d`, not `mux`: the client no longer forks anything. It runs
# `mux d start -d` with its own fd 2 lent to it, so the words on this file
# are the DAEMON's, in the daemon's own voice, and a refusal the client
# could not have worded reaches the user unchanged.
grep -q '^mux d: starting' "$OUT.pa.err" || {
    echo "e2e FAIL: local auto-start printed no 'mux d'-prefixed starting line"
    cat "$OUT.pa.err"; exit 1; }
# And the wall wrote WHY into the daemon log before it started one, naming
# what the dial answered: a path with nothing at it is FileNotFound, and
# that word beside the daemon's own bind line is the whole difference
# between "a fresh box" and "a live daemon's socket file was deleted"
# (issue 04b3019d). The log lives under the leg's own XDG_STATE_HOME.
grep -qF "mux: auto-starting a daemon on $SOCK15: the dial said FileNotFound" "$HOSTROOM/mux/muxd.log" || {
    echo "e2e FAIL: the auto-start left no 'the dial said' line in $HOSTROOM/mux/muxd.log:"
    cat "$HOSTROOM/mux/muxd.log" 2>/dev/null; exit 1; }
PAPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.pa.err" | head -1)
defer_kill "$PAPID"
[ -n "$PAPID" ] || { echo "e2e FAIL: mux up-line carries no pid"; cat "$OUT.pa.err"; exit 1; }
# THE ambient-PATH pin, asked of the kernel and not of the daemon: the
# process an auto-start brought up is running the binary this suite was
# handed, byte for byte. `mux` used to resolve `muxd` against PATH, and a
# leg whose daemon had died reattached to an INSTALLED v0.0.1-10 with no
# agent code in it and passed. The exec names the running image, so the link
# below can only ever be that image — and this reads the link rather
# than the argv, because argv is what a wrong spawn would still get right.
# Fully resolved on both sides: pid_exe answers with the resolved image, so
# a logical path reached through a symlinked directory would fail a correct
# spawn unless this side resolves too.
PAEXE=$(pid_exe "$PAPID")
PAWANT=$(real_path "$MUX_ELF")
[ "$PAEXE" = "$PAWANT" ] || {
    echo "e2e FAIL: the auto-started daemon is running $PAEXE, not the build's $PAWANT"
    echo "          (an auto-start that resolves a NAME grades whatever is installed)"
    exit 1; }
# And the OS's own name for the process, which the image path cannot see:
# `comm` comes from the basename of the FILENAME handed to execve, so
# exec'ing the self-exe link itself leaves every daemon on the box called
# `exe` — invisible to `pgrep mux`, `killall mux`, `ps -o comm` and
# systemd's MainPID name, with only the args still saying `mux d start`.
PACOMM=$(pid_comm "$PAPID")
[ "$PACOMM" = "mux" ] || {
    echo "e2e FAIL: the auto-started daemon's comm is '$PACOMM', want 'mux'"
    echo "          (exec the RESOLVED path; the self-exe link names the process after itself)"
    exit 1; }
# And the argv, asked of the kernel for the same reason: the attach ASKS
# for a daemon in words — `d start -d --sock PATH`, nothing else — and the
# words are all it may say. Anything extra here is the client having an
# opinion about the daemon's flags: a `--quic` nobody typed, a `--shell`
# read out of the client's own environment. Exactly, not `grep`, because
# what this is watching for is an ADDITION. `-d` is absent by the same
# rule the fork obeys — the child is the foreground daemon, and a `-d`
# still in its line would fork again.
PACMD=$(pid_args "$PAPID")
[ "$PACMD" = "mux d start --sock $SOCK15" ] || {
    echo "e2e FAIL: the auto-started daemon's argv is [$PACMD],"
    echo "          want [mux d start --sock $SOCK15]"
    exit 1; }
assert_converged_pty "$OUT.pa" "$SOCK15" "local mux auto-start" 80 24
# Same teardown, same reasons — and the stderr is captured rather than
# discarded, so this leg pins the stopped line too. $OUT.stop is reused
# deliberately: the proxy arc removed it above, and it is in the trap's
# rm list either way.
set +e
"$MUX" d stop --sock "$SOCK15" 2> "$OUT.stop"
RC_STOP=$?
set -e
[ "$RC_STOP" = "0" ] || {
    echo "e2e FAIL: pty leg stop exited $RC_STOP, want 0"; cat "$OUT.stop"; exit 1; }
grep -q '^mux d: stopped' "$OUT.stop" || {
    echo "e2e FAIL: pty leg stop did not report stopped"; cat "$OUT.stop"; exit 1; }
[ ! -S "$SOCK15" ] || {
    echo "e2e FAIL: stop left the pty leg's socket"; ls -l "$SOCK15"; exit 1; }
wait_pid_gone "$PAPID" "pty leg: stop reported stopped"
PAPID=""
rm_swept "$OUT.pa" "$OUT.pa.err" "$OUT.pa.log" "$OUT.stop"
ok "local mux auto-start under the pty fixture"

# --- side channel: an OSC 52 written by the session reaches the HOST tty --
#
# Measured ABSENT on 2026-08-15 (decisions.md, that date): a session emitted
# this exact escape, and the bytes the client wrote to its host terminal
# carried one \x1b[?1049h, one \x1b[?2026h, one marker — and zero of the
# clipboard escape. The daemon's engine had parsed all of it; a client that
# paints a grid forwards nothing that is not grid state. This is the same
# instrument (the fixture's capture is what a host terminal would have
# received) taking the same reading with the path built, and it is the first
# end-to-end evidence that any of it does anything.
#
# The payload is emitted by a FILE the session runs, never typed. The shell
# echoes what is typed, so a needle that could arrive as an echo would pass
# on a client that forwards nothing at all — the M12 rule (tp2), and here it
# is the difference between an assertion and a decoration. The typed line
# names the script and cannot spell the escape.
cat > "$OUT.clip.sh" <<'CLIPSH'
printf '\033]52;c;aGVsbG8gZnJvbSB0aGUgc2Vzc2lvbg==\007'
printf 'CLIPDONE\n'
CLIPSH
# Unquoted heredoc, unlike every other pty script in this file: the send
# verb has to carry $OUT's per-run path. Nothing else in it needs expanding
# — `\n` and `\x1c` are not escapes the shell touches.
set +e
hostroom clip
XDG_STATE_HOME="$HOSTROOM" timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.clip" --err "$OUT.clip.err" \
    -- "$MUX" --sock "$SOCK" > "$OUT.clip.log" 2>&1 <<EOF
expect \x1b[?1049h 15000
settle 400 15000
send sh $OUT.clip.sh\n
expect CLIPDONE 15000
settle 400 15000
send \x1c\x1c
waitexit 10000
EOF
RC=$?
set -e
[ "$RC" -eq 0 ] || {
    echo "e2e FAIL: clipboard scenario did not run: ptyclient exited $RC"
    cat "$OUT.clip.log"; cat -v "$OUT.clip.err" 2>/dev/null; exit 1; }
# The positive control, and it is not redundant with the check below: the
# marker travels the ordinary grid path, so a capture holding it is a
# capture of a session that RAN. Without it, "no escape" and "no session"
# are the same failure line.
grep -qa 'CLIPDONE' "$OUT.clip" || {
    echo "e2e FAIL: the session never ran (no marker on the host)"; exit 1; }
# Terminator included: appendHostEffect builds into writeSideChannel's private
# buffer, which is written whole or not at all; a needle stopping at the payload
# could not tell a complete escape from half of one left painting on the
# user's tty. -F because the needle is bytes, not a pattern.
CLIPESC=$(printf '\033]52;c;aGVsbG8gZnJvbSB0aGUgc2Vzc2lvbg==\007')
grep -qaF "$CLIPESC" "$OUT.clip" || {
    echo "e2e FAIL: OSC 52 never reached the host tty"; exit 1; }
rm_swept "$OUT.clip" "$OUT.clip.err" "$OUT.clip.log" "$OUT.clip.sh"
ok "the session's OSC 52 reaches the host terminal"

# --- side channel: the session's window title reaches the host tty -------
#
# The session sets its title with OSC **2** and the assertion below looks
# for OSC **0**. That asymmetry is the instrument: a client that forwarded
# session bytes would put back exactly what went in, so a capture holding
# `ESC]0;` for a session that only ever wrote `ESC]2;` can only be the
# client re-rendering sampled state (client.zig, appendTermTitle). Both
# forms reach the engine as one window-title operation, which is why the
# session is free to pick the one mux does not emit.
#
# Emitted by a FILE, never typed, for the M12 reason spelled out on the
# clipboard leg above: the shell echoes what is typed, so a needle that
# could arrive as an echo would pass on a client that forwards nothing.
#
# The `?2004h` is not this leg's business — it is the NEXT leg's control.
# It leaves the session armed for bracketed paste as well as titled, so
# that when the non-tty client below attaches to this same daemon there are
# two side channels the daemon is definitely emitting, and "neither
# arrived" cannot be confused with "neither was sent".
cat > "$OUT.title.sh" <<'TITLESH'
printf '\033]2;mux-e2e-title\007'
printf '\033[?2004h'
printf 'TITLEDONE\n'
TITLESH
set +e
hostroom title
XDG_STATE_HOME="$HOSTROOM" timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.title" --err "$OUT.title.err" \
    -- "$MUX" --sock "$SOCK" > "$OUT.title.log" 2>&1 <<EOF
expect \x1b[?1049h 15000
settle 400 15000
send sh $OUT.title.sh\n
expect TITLEDONE 15000
settle 400 15000
send \x1c\x1c
waitexit 10000
EOF
RC=$?
set -e
[ "$RC" -eq 0 ] || {
    echo "e2e FAIL: title scenario did not run: ptyclient exited $RC"
    cat "$OUT.title.log"; cat -v "$OUT.title.err" 2>/dev/null; exit 1; }
# Positive control: the marker travels the ordinary grid path, so without it
# "no title" and "no session" would be the same failure line.
grep -qa 'TITLEDONE' "$OUT.title" || {
    echo "e2e FAIL: the session never ran (no marker on the host)"; exit 1; }
# Terminator included, for the same reason as the clipboard needle: the
# builder writes the escape whole or writes nothing, and a needle stopping
# at the payload could not tell one from half of one.
# OSC **0**, deliberately, against a fixture that wrote OSC **2** — the
# asymmetry is the instrument, and it is argued at the head of this leg.
# Do not "fix" either side to match the other without reading that.
TITLEESC=$(printf '\033]0;mux-e2e-title\007')
grep -qaF "$TITLEESC" "$OUT.title" || {
    echo "e2e FAIL: the window title never reached the host tty"; exit 1; }
# The restore pair, asserted by ORDER and not merely by presence. Presence
# alone would pass on a client that popped before it pushed, or pushed twice
# and popped once — and an unmatched pop does not restore a title, it pops
# whatever the terminal had underneath, which is somebody else's. The
# offsets say: pushed before mux set anything, popped after.
first_offset() {
    grep -aboF "$2" "$1" | head -1 | cut -d: -f1
}
occurrences() {
    grep -aoF "$2" "$1" | wc -l
}
PUSH_ESC=$(printf '\033[22;0t')
POP_ESC=$(printf '\033[23;0t')
PUSH_AT=$(first_offset "$OUT.title" "$PUSH_ESC")
POP_AT=$(first_offset "$OUT.title" "$POP_ESC")
SET_AT=$(first_offset "$OUT.title" "$TITLEESC")
[ -n "$PUSH_AT" ] || { echo "e2e FAIL: no title push on the host tty"; exit 1; }
[ -n "$POP_AT" ] || { echo "e2e FAIL: no title pop on the host tty (exit left it set)"; exit 1; }
[ "$PUSH_AT" -lt "$SET_AT" ] || {
    echo "e2e FAIL: mux set the title at $SET_AT before pushing at $PUSH_AT"; exit 1; }
[ "$SET_AT" -lt "$POP_AT" ] || {
    echo "e2e FAIL: the title pop at $POP_AT came before the set at $SET_AT"; exit 1; }
# Exactly one of each, which order alone does not catch. A second push is a
# stack this run leaves one deeper than it found it, and the argument that
# it cannot happen — the reconnect path re-enters with alt_screen already
# true (client.zig) — was prose with no test behind it until here.
PUSH_N=$(occurrences "$OUT.title" "$PUSH_ESC")
POP_N=$(occurrences "$OUT.title" "$POP_ESC")
[ "$PUSH_N" -eq 1 ] || {
    echo "e2e FAIL: $PUSH_N title pushes on one run, want exactly 1"; exit 1; }
[ "$POP_N" -eq 1 ] || {
    echo "e2e FAIL: $POP_N title pops on one run, want exactly 1"; exit 1; }
# The control for the leg that follows, asserted on THIS capture because
# this is the run that had a pty: the daemon emits both side channels for
# this session, right now. Without it, "neither reached the non-tty client"
# would pass just as well on a daemon that had stopped sending either.
grep -qaF "$(printf '\033[?2004h')" "$OUT.title" || {
    echo "e2e FAIL: the session armed bracketed paste but the tty client never heard"
    cat -v "$OUT.title"; exit 1; }
rm_swept "$OUT.title" "$OUT.title.err" "$OUT.title.log" "$OUT.title.sh"
ok "the window title reaches the host as OSC 0, pushed before and popped after"

# --- side channel: no terminal taken over, no terminal state changed -----
#
# The bug this pins shipped and was fixed in c18c54b. `is_tty` is isatty of
# STDIN — it gates raw mode and the alt-screen entry, both input concerns —
# while the side-channel arms write to STDOUT. A client with stdin
# redirected therefore never set `alt_screen`, never ran the teardown, and
# yet happily wrote the session's title and `?2004h` to whatever stdout
# was. Measured on the parent commit, against this same daemon: the capture
# ended `...ESC[?2004h ESC]0;mux-e2e-title BEL` — the title set and the
# paste mode set as the final bytes, and nothing anywhere to undo either.
#
# Same session as the leg above, which is what makes it meaningful: that
# leg just proved with a pty that the daemon is sending both of these.
#
# stdin from /dev/null and stdout to a FILE, so mux takes nothing over.
timeout 4 "$MUX" --sock "$SOCK" < /dev/null > "$OUT.nogate" 2>&1 || true
# Positive control first: this capture must be of a client that ATTACHED
# and painted, or every negative below it is vacuous. TITLEDONE is on that
# session's screen from the leg above, so it arrives by the ordinary grid
# path — the path that is supposed to keep working here.
grep -qaF "TITLEDONE" "$OUT.nogate" || {
    echo "e2e FAIL: the non-tty client never painted the session (nothing to conclude)"
    cat -v "$OUT.nogate"; exit 1; }
# -F on every needle, for the reason documented at the dead-transport leg:
# without it these are malformed bracket expressions, grep exits 2, and
# `grep && { fail }` reads an error as "not found".
#
# `ESC]0;` is the bare prefix rather than the full title: ANY title write is
# the failure here, not just that one session's.
for NEEDLE in "$(printf '\033]0;')" "$(printf '\033[?2004h')" "$(printf '\033[22;0t')"; do
    grep -qaF "$NEEDLE" "$OUT.nogate" && {
        echo "e2e FAIL: side channel written to a terminal mux never took over"
        cat -v "$OUT.nogate"; exit 1; }
done
rm_swept "$OUT.nogate"
ok "a client that takes no terminal over changes no terminal state"

# --- side channel: a paste into a real editor keeps its indentation ------
#
# The byte-level pin (?2004h in a host capture) proves the FRAME arrived;
# this proves pasting WORKS. Measured on 2026-08-15 before the fix: the
# third line of this exact block came out with 8 spaces instead of 4 — the
# classic autoindent staircase — because the host terminal was never told
# the application had asked for bracketed paste, and the host terminal is
# the only thing that can actually bracket one.
#
# ptyclient's `paste` verb brackets only when it has SEEN ?2004h go past on
# the capture, which is what a real terminal does and is why this scenario
# cannot pass on a mirror that stopped working: with the mirror neutered the
# same bytes arrive unbracketed, nvim autoindents them, and the file below
# holds the staircase. The assertion is that file, not an escape — a grep
# for the escape would go on passing while paste itself broke.
#
# nvim is REQUIRED, not guarded with `command -v`. A guard would let a box
# without nvim skip this quietly, and a silently-skipped scenario is exactly
# what the OK_COUNT pin at the bottom exists to catch — so the dependency
# fails loudly. The check itself is HOISTED to the head of this file, next to
# the argument binding: a box without nvim should hear about it before it
# spends minutes getting here.
#
# That reverses M9's standing rule (2026-08-08-m9-prediction.md: "rawmode
# helper, not nvim, in CI"), which gave two reasons. Its PRESENCE half is
# answered by the check above — refusing to run beats running less. Its
# NONDETERMINISM half — nvim's redraw timing — is answered by construction:
# every wait below is on a CONDITION (a needle that must arrive) or on
# SILENCE (settle), and neither is a bet on when a redraw happens. If this
# scenario ever flakes, that is the property to check first; a wait added
# here that keys off redraw timing would be the regression.
#
# Unquoted heredoc, like the clipboard leg above: the send verb carries
# $OUT's per-run path. `\x1b`, `\r`, `\n` and `\x1c` are the fixture's
# escapes and the shell leaves every one of them alone.
#
# -i NONE keeps nvim off the operator's shada file; -u NONE keeps it off
# their config, so 'autoindent' is set here and nowhere else — the staircase
# this measures has to come from the option this line spells.
#
# `timeout 60` is under the heredoc's own deadlines (120s if every one of
# them ran long), and that is deliberate rather than copied from the leg
# above: those deadlines are per-verb ceilings for a fixture that is still
# talking, and a run that genuinely needs more than 60s wall has something
# wrong with it that a bare `exited 124` describes about as well as a
# fixture dump would. The cost is real — 124 loses the "last N bytes
# received" line — so anything raised here should raise this too.
set +e
hostroom paste
XDG_STATE_HOME="$HOSTROOM" timeout 60 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.paste" --err "$OUT.paste.err" \
    -- "$MUX" --sock "$SOCK" > "$OUT.paste.log" 2>&1 <<EOF
expect \x1b[?1049h 15000
settle 400 15000
send nvim -u NONE -i NONE -c "set autoindent" -c startinsert $OUT.pasted.txt\n
# The mode indicator, whole. The line above echoes "startinsert", so a bare
# INSERT needle would differ from the echo by capitalisation alone (the M12
# tp2 rule passing on a technicality); the dashes are what that echo cannot
# spell.
#
# And NOT an expect for the 2004h escape, deliberately, however much this
# reads like the place for one. The fixture watches for that escape without
# waiting on it: it brackets a paste when it has seen one and sends bare
# bytes when it has not. A needle for it here would wait, and that changes
# what a broken mirror looks like — instead of the file below holding the
# staircase, the run would time out on the needle and never reach the
# assertion at all. The escape ARRIVING is already pinned, in client.zig's
# unit tests. What THIS leg measures is pasting working, and it can only
# measure that if the escape's absence is allowed through to the editor.
expect -- INSERT -- 20000
settle 500 20000
paste if x:\r    a = 1\r    b = 2\r
settle 500 20000
# Two sends, not one. Delivered in the same read as the colon, nvim's input
# parser is entitled to read the ESC as the Alt- prefix of Alt-colon rather
# than as leaving insert mode, and the file would never be written. The
# settle between them is not a timer for a repaint: what the split needs is
# the ESC and the colon landing in different reads, and this verb cannot
# return in under its quiet window whatever the screen does, which is that
# separation — by far outlasting the 50ms ttimeoutlen the ambiguity turns
# on. (settle proves silence, never arrival; see its docstring.)
send \x1b
settle 300 15000
send :wq\r
# What makes the file safe to read after this is the ORDERING, not the
# number: nvim writes the buffer before it exits, so everything that follows
# on the master — its exit repaint, then the shell's prompt — comes after
# the write, and silence measured past all of that cannot precede it. The
# 800 is slack on top of a guarantee, not a measurement of how long nvim
# takes.
settle 800 20000
send \x1c\x1c
waitexit 10000
EOF
RC=$?
set -e
[ "$RC" -eq 0 ] || {
    echo "e2e FAIL: paste scenario did not run: ptyclient exited $RC"
    cat "$OUT.paste.log"; cat -v "$OUT.paste.err" 2>/dev/null; exit 1; }
# Separate from the indentation check on purpose: "nvim never wrote" and
# "nvim wrote the wrong thing" are different failures, and one message for
# both would send the reader looking at the wrong half.
[ -s "$OUT.pasted.txt" ] || {
    echo "e2e FAIL: nvim wrote no file — the paste never reached the editor"
    cat "$OUT.paste.log"; exit 1; }
grep -q '^    b = 2$' "$OUT.pasted.txt" || {
    echo "e2e FAIL: pasted block lost its indentation (bracketed paste not mirrored)"
    cat -A "$OUT.pasted.txt"; exit 1; }
rm_swept "$OUT.paste" "$OUT.paste.err" "$OUT.paste.log" "$OUT.pasted.txt"
ok "a paste into nvim keeps its indentation"

# --- side channel: a burst of bells reaches the host as exactly one ------
#
# This leg exists for the COALESCING, not for the bell. The shared client core
# decodes every term_event, then every accepted effect follows the same
# writeSideChannel → appendHostEffect path, so the clipboard leg above already
# proves frame → decode → adapter → host tty for the whole frame type. A bell
# that merely ARRIVED would add no coverage: the bell-specific adapter line is
# pinned directly in client.zig. What only e2e can see is the NUMBER. Five rings
# inside one pty chunk must reach
# the host as one (server.zig, drainSideEvents), and a regression there is
# silent — every assertion anyone would think to write about a bell still
# passes while 256 frames per chunk go out.
#
# So the assertion is a count, and a count of BEL BYTES is only unambiguous
# if nothing else in the capture can contribute one. Two things make that
# true here, and both are asserted rather than assumed:
#
#   1. A daemon of its own. The client writes exactly two OSCs, ever —
#      `ESC]52;` and `ESC]0;` (client.zig), each BEL-terminated — and $SOCK's
#      session has had a title set on it by the leg above, which every client
#      attaching there is then told about. On a fresh daemon nothing sets a
#      title and nothing copies, so neither OSC has an occasion to be built.
#   2. The zero-OSC check below, which is what turns that argument into a
#      measurement. With no `ESC]` anywhere in the capture, no BEL in it can
#      be terminating a sequence.
#
# That leaves one way a BEL could arrive with no `ESC]` in front of it, which
# check 2 would NOT catch: as painted cell content, since paint.zig writes
# row.bytes verbatim. It cannot arise here — ghostty-vt executes C0 rather
# than storing it, and this session paints a prompt, an echo and BELLDONE —
# but the two checks above are what make the count unambiguous, not a proof
# that no other path to a BEL exists.
start_daemon "$SOCK24" "$OUT.d21.d" "bell daemon socket never appeared" --shell /bin/sh
D21PID=$DPID

# Five rings in ONE printf, which is one write and so in practice one pty read
# and one drain — the line discipline is not going to split five bytes short of
# a full output buffer, but that is a property of the read side rather than
# anything the single write guarantees. Split across two writes they would be
# two chunks and two frames, correctly, since coalescing is per drain; the
# single printf is what makes "one" the right answer here. A split would show
# up as `2 bells ... want exactly 1`, so read that as a chunking accident
# before reading it as lost coalescing.
#
# Emitted by a FILE, never typed, for the M12 reason spelled out on the
# clipboard leg: the shell echoes what is typed, so a needle that could
# arrive as an echo would pass on a client that forwards nothing at all.
cat > "$OUT.bell.sh" <<'BELLSH'
printf '\007\007\007\007\007'
printf 'BELLDONE\n'
BELLSH
set +e
hostroom bell
XDG_STATE_HOME="$HOSTROOM" timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.bell" --err "$OUT.bell.err" \
    -- "$MUX" --sock "$SOCK24" > "$OUT.bell.log" 2>&1 <<EOF
expect \x1b[?1049h 15000
settle 400 15000
send sh $OUT.bell.sh\n
expect BELLDONE 15000
settle 400 15000
send \x1c\x1c
waitexit 10000
EOF
RC=$?
set -e
[ "$RC" -eq 0 ] || {
    echo "e2e FAIL: bell scenario did not run: ptyclient exited $RC"
    cat "$OUT.bell.log"; cat -v "$OUT.bell.err" 2>/dev/null; exit 1; }
# The positive control, as on every leg here: the marker travels the ordinary
# grid path, so without it "one bell" and "no session" are the same reading —
# and "zero bells" would pass the zero-OSC check below just as well.
grep -qa 'BELLDONE' "$OUT.bell" || {
    echo "e2e FAIL: the session never ran (no marker on the host)"; exit 1; }
# -F, and it is not decoration: the needle is `ESC]`, and `]` unescaped makes
# this a malformed bracket expression — grep exits 2 and `grep && { fail }`
# reads the error as "not found". That exact defect has already shipped in
# this file once (see the dead-transport leg).
OSCINTRO=$(printf '\033]')
grep -qaF "$OSCINTRO" "$OUT.bell" && {
    echo "e2e FAIL: an OSC reached the bell capture, so its BELs cannot be counted"
    cat -v "$OUT.bell"; exit 1; }
# ...and now the count means what it says. `occurrences` is the title leg's
# helper, defined above and reused here on the same kind of byte needle.
BELBYTE=$(printf '\007')
BELLN=$(occurrences "$OUT.bell" "$BELBYTE")
[ "$BELLN" -eq 1 ] || {
    echo "e2e FAIL: $BELLN bells on the host tty for one burst of five, want exactly 1"
    cat -v "$OUT.bell"; exit 1; }
"$MUX" d stop --sock "$SOCK24" > /dev/null 2>&1 || true
wait_pid_gone "$D21PID" "bell leg: stop reported stopped"
D21PID=""
rm_swept "$OUT.bell" "$OUT.bell.err" "$OUT.bell.log" "$OUT.bell.sh" "$OUT.d21.d"
ok "a burst of bells in one chunk reaches the host as exactly one"

# The long-lived daemon has served every scenario that wanted it; stop it
# NOW so its allocator verdict is written while the suite is still running
# and can say so. SIGTERM runs the clean-shutdown path, so the defer chain
# (and the verdict) runs.
#
# The sweep that READS that verdict is in the EXIT trap, not here: this line
# is only reached by a run that passed, and a leak deserves reporting on the
# runs that did not (see leak_sweep). Everything below it is a pin, and the
# trap fires after all of them either way.
softkill "$D1PID" || true
wait "$D1PID" 2>/dev/null || true
D1PID=""