test/xversion.sh
Ref: Size: 33.6 KiB History
#!/bin/sh
# 2026-09-04: the cells-on-the-wire break has no old side to grade; XVER_OLD_WORKTREE must be this branch or newer.
#
# Cross-version compatibility gate, containerised.
#
# Runs THIS tree's client against a PREVIOUS version's daemon and the
# reverse, with each daemon in a container of its own. The point is to make
# a check that has always been hand-rolled — build two versions, start one,
# dial it with the other, remember what the answer is supposed to be — into
# something that runs the same way every time and can fail out loud.
#
# It exists because the M18 close-out ran exactly this by hand and the
# answer disagreed with the documentation: `--session` against a pre-M18
# daemon was written up as one failure and is two, split by transport (see
# decisions.md, M18). A hand-rolled check finds that once, by luck, days
# late. This one finds it on the run after someone writes the sentence.
#
# CONTAINERS, NOT VMs, AND THE LIMIT THAT COMES WITH IT: a container shares
# the host kernel, so nothing here validates pty, poll or fd semantics
# against a DIFFERENT kernel — and those are most of what the daemon is. What it
# does buy is a stock userland (the static musl binary has to actually run
# somewhere that is not this box), a clean two-versions-at-once story, and
# a rig that does not depend on a remote machine being powered on. Kernel
# diversity needs VMs and is not claimed here.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
OLD_BIN="${XVER_OLD_BIN:-$ROOT/.xversion/old/bin}"
NEW_BIN="${XVER_NEW_BIN:-$ROOT/.xversion/new/bin}"
IMAGE="${XVER_IMAGE:-docker.io/library/alpine:latest}"
ENGINE="${XVER_ENGINE:-podman}"
# What the two sides are called in the output. Informational only — the
# binaries are whatever the directories hold — but a report that says
# "old" and "new" without saying WHICH is a report nobody can act on.
OLD_LABEL="${XVER_OLD_LABEL:-$OLD_BIN}"
NEW_LABEL="${XVER_NEW_LABEL:-$NEW_BIN}"
PASSES=0
FAILS=0
SKIPS=0
pass() { PASSES=$((PASSES + 1)); echo "xver PASS: $1"; }
fail() { FAILS=$((FAILS + 1)); echo "xver FAIL: $1"; }
skip() { SKIPS=$((SKIPS + 1)); echo "xver SKIP: $1"; }
# ---- preflight ---------------------------------------------------------
# A missing input is a refusal, never a skip: a run that quietly tested
# nothing and exited 0 is the failure mode this whole file exists against.
command -v "$ENGINE" >/dev/null 2>&1 ||
{ echo "xver FAIL: no $ENGINE — this gate cannot run without a container engine"; exit 1; }
# The old side is still four binaries and the new side is one, so each
# prefix is checked for what THAT side ships. A `muxd` demanded of the new
# prefix would refuse a tree that is doing exactly what this gate exists to
# prove is safe.
for _d in "$OLD_BIN:muxd mux" "$NEW_BIN:mux"; do
for _b in ${_d#*:}; do
[ -x "${_d%%:*}/$_b" ] || {
echo "xver FAIL: no $_b in ${_d%%:*}"
echo " build both sides static (they run in a container AND on this host):"
echo " zig build -Dtarget=x86_64-linux-musl -p $ROOT/.xversion/new"
echo " git -C <a checkout of the older ref> ... -p $ROOT/.xversion/old"
exit 1
}
done
done
# The pty fixture legs 7 and 8 need. Both clients in those legs run under
# whatever `$NEW_BIN` names, because the fixture is a HARNESS rather than a
# subject: which version of mux is under test is decided by the argument
# after `--`, not by whose pty it runs on.
#
# That is NOT the same as "always this tree's fixture", and an earlier
# draft of this comment said so and was wrong. Under a falsification run
# with XVER_NEW_BIN pointed at the old prefix, this IS the old tree's
# fixture — it happens to work, because v0.0.1-5's ptyclient speaks the
# same script dialect, and that direction was observed failing for the
# right reason. But it is a coincidence the code does not enforce, so a
# future dialect change would break that direction rather than this line
# holding it up.
#
# Legs 7 and 8 cannot run without a pty at all, because mux writes a side
# channel only to a terminal it has taken over — see the legs' own header.
[ -x "$NEW_BIN/ptyclient" ] || {
echo "xver FAIL: no ptyclient in $NEW_BIN — legs 7 and 8 need the pty fixture"
echo " it is built by this tree's build.zig into the same prefix as mux"
exit 1
}
# Short paths on purpose: these directories hold unix sockets, and a socket
# path is capped near 108 bytes by the kernel, not by anything this script
# can see fail gracefully.
TMP="${TMPDIR:-/tmp}/mux-xver-$$"
# Every named attach records a DAEMON in $XDG_STATE_HOME/mux/hosts; without
# a hermetic home this suite's throwaway sockets pile up on the developer's
# own wall as host lines — tiles for every session while a container is
# still up, and after that a line nothing times out and only `mux hosts rm`
# removes. Legs that take a terminal narrow it further, one dir per leg —
# see `side_channel_session`.
XDG_STATE_HOME="$TMP/state"; XDG_CONFIG_HOME="$TMP/cfg"; XDG_CACHE_HOME="$TMP/cache"
export XDG_STATE_HOME XDG_CONFIG_HOME XDG_CACHE_HOME
RUN_OLD="$TMP/o"
RUN_NEW="$TMP/n"
mkdir -p "$RUN_OLD" "$RUN_NEW" || exit 1
CN_OLD="mux-xver-old-$$"
CN_NEW="mux-xver-new-$$"
PORT_OLD=$(( 40000 + ($$ % 5000) ))
PORT_NEW=$(( 45000 + ($$ % 5000) ))
KEY="$TMP/key"
head -c 32 /dev/urandom > "$KEY"
chmod 600 "$KEY"
cp "$KEY" "$RUN_OLD/key"
cp "$KEY" "$RUN_NEW/key"
cleanup() {
_rc=$?
# By name, and these names carry $$ — a pattern kill would be reaching
# for someone else's container.
"$ENGINE" rm -f "$CN_OLD" >/dev/null 2>&1
"$ENGINE" rm -f "$CN_NEW" >/dev/null 2>&1
rm -rf "$TMP"
exit "$_rc"
}
trap cleanup EXIT INT TERM
# start_daemon NAME BINDIR RUNDIR PORT — a daemon in a container, serving
# both a unix socket (through the bind mount, so the host can dial it) and
# QUIC (through a published UDP port).
#
# --network host rather than a published port: a rootless bridge NATs the
# client's source address, and QUIC validates the path it is answering on.
# Testing the transport through an address translation this product never
# runs behind would be testing pasta, not this program. The container still has its
# own filesystem and process table, which is what this gate is actually
# asking about.
# $5 is how THAT side spells its daemon — `muxd run` on the old prefix,
# `mux d start` on the new one. The old binary cannot answer `mux d`, and
# asking it to would test this gate's own wording instead of the wire.
start_daemon() {
# shellcheck disable=SC2086 # $5 is a command spelling of one or two words
"$ENGINE" run -d --name "$1" --network host \
-v "$2:/mux:ro" -v "$3:/run/mux" \
"$IMAGE" \
$5 --sock /run/mux/muxd.sock --quic "127.0.0.1:$4" \
--key /run/mux/key --quic-idle-ms 15000 --shell /bin/sh \
>/dev/null 2>&1
}
# Poll for the socket the container is about to create in the shared dir.
wait_sock() {
_i=0
while [ ! -S "$1" ] && [ "$_i" -lt 100 ]; do sleep 0.1; _i=$((_i + 1)); done
[ -S "$1" ]
}
# A marker its own shell has to EXPAND: the typed line reads
# `printf "x-%s\n" pin` and only the OUTPUT reads `x-pin`, so a grep that
# hits is the shell's work rather than an echo of our keystrokes.
drive() { # drive BIN MARKER TRANSPORTARGS...
_bin="$1"; _marker="$2"; shift 2
{ printf 'printf "%s-%%s\\n" pin\n' "$_marker"; sleep 2; printf '\034\034'; } | \
timeout 40 "$_bin" "$@" > "$TMP/out" 2> "$TMP/err"
}
echo "xver: old=$OLD_LABEL"
echo "xver: new=$NEW_LABEL"
echo "xver: image=$IMAGE engine=$ENGINE ports=$PORT_OLD/$PORT_NEW"
start_daemon "$CN_OLD" "$OLD_BIN" "$RUN_OLD" "$PORT_OLD" "/mux/muxd run" ||
{ echo "xver FAIL: could not start the old daemon's container"; exit 1; }
start_daemon "$CN_NEW" "$NEW_BIN" "$RUN_NEW" "$PORT_NEW" "/mux/mux d start" ||
{ echo "xver FAIL: could not start the new daemon's container"; exit 1; }
SOCK_OLD="$RUN_OLD/muxd.sock"
SOCK_NEW="$RUN_NEW/muxd.sock"
wait_sock "$SOCK_OLD" || {
echo "xver FAIL: old daemon never bound in its container:"
"$ENGINE" logs "$CN_OLD" 2>&1 | head -20; exit 1; }
wait_sock "$SOCK_NEW" || {
echo "xver FAIL: new daemon never bound in its container:"
"$ENGINE" logs "$CN_NEW" 2>&1 | head -20; exit 1; }
# ---- leg 1: new client -> old daemon, default session, unix socket -----
# The compat claim in one line: no --session means the bare 20-byte attach
# that shipped, so this must be indistinguishable from an old client.
drive "$NEW_BIN/mux" xv1 --sock "$SOCK_OLD"
RC=$?
if [ "$RC" -ne 0 ]; then
fail "new client -> old daemon (socket) exited $RC [$(tr -d '\n' < "$TMP/err")]"
elif "$OLD_BIN/muxd" dump --sock "$SOCK_OLD" | grep -q 'xv1-pin'; then
pass "new client drives an old daemon's default session over a socket"
else
fail "old daemon's grid is missing the new client's output"
fi
# ---- leg 2: new client -> old daemon, default session, QUIC ------------
# The control for leg 3. Without it, "leg 3 failed" is consistent with
# "QUIC to an old daemon never works", which is a different bug.
drive "$NEW_BIN/mux" xv2 "quic://127.0.0.1:$PORT_OLD" --key "$KEY" --quic-idle-ms 15000
RC=$?
if [ "$RC" -ne 0 ]; then
fail "new client -> old daemon (quic) exited $RC [$(tr -d '\n' < "$TMP/err")]"
elif "$OLD_BIN/muxd" dump --sock "$SOCK_OLD" | grep -q 'xv2-pin'; then
pass "new client drives an old daemon's default session over QUIC"
else
fail "old daemon's grid is missing the new client's QUIC output"
fi
# ---- leg 3: the documented limitation, both halves of it ---------------
# `--session` against a daemon that predates it. The two transports fail
# DIFFERENTLY and the difference is the documentation: a socket connection
# is an observer until it attaches and the old serviceObserver closes on a
# payload it cannot parse, while a QUIC connection took a client slot at
# quicOnOpen and reaches handleFrame's silent `decodeAttach catch return`.
# Pinning both means the sentence in decisions.md cannot drift from the
# behaviour without this failing.
# The exit code alone CANNOT decide this, and an earlier draft of this
# file got it wrong: against a daemon that honours the name the client
# attaches and stays attached, so it is killed by the same `timeout` that
# a real hang produces — 124 either way. The falsification run (point
# XVER_OLD_BIN at the new binaries) is what surfaced it, which is the
# argument for having one. The discriminator has to be something only a
# daemon that UNDERSTOOD the name can produce, and that is the session
# itself: `stats` is daemon-global, has no name tail, and every version
# answers it, so "did a session called zz appear?" is askable of both.
# Returns 0 when the name was NOT honoured and nothing has been faulted
# yet — the caller's own verdict is still open. Returns 1 when this
# function already called `fail`, so the caller must stay quiet rather
# than reporting a second time on the same leg.
#
# The verdict travels as the EXIT STATUS, and the stats capture is keyed
# by LABEL. Both matter, and for the same reason: the earlier shape wrote
# one fixed path and left the caller to re-grep it, so the second
# transport's call overwrote the first's evidence. That was correct only
# because of the order the two legs happen to run in — one reorder and a
# leg asserts against the other leg's stats.
assert_name_not_honoured() { # LABEL DAEMON SOCK RC ERRFILE
_lbl="$1"; _daemon="$2"; _sock="$3"; _rc="$4"; _err="$5"
_stats="$TMP/stats-$_lbl"
"$_daemon" stats --sock "$_sock" > "$_stats" 2>&1
if grep -q 'session zz' "$_stats"; then
fail "$_lbl: the daemon created session zz — this is not a pre-M18 daemon [$(cat "$_stats")]"
return 1
fi
case "$_rc" in
124) echo "$_lbl: no answer (client killed at its deadline)" ;;
0) fail "$_lbl: the client exited 0 without a session being created"; return 1 ;;
*) echo "$_lbl: refused (exit $_rc) [$(tr -d '\n' < "$_err")]" ;;
esac
return 0
}
{ printf 'printf "no-%%s\\n" pin\n'; sleep 5; } | \
timeout 8 "$NEW_BIN/mux" --sock "$SOCK_OLD" --session zz > "$TMP/o3" 2> "$TMP/e3"
RC=$?
if assert_name_not_honoured "socket" "$OLD_BIN/muxd" "$SOCK_OLD" "$RC" "$TMP/e3"; then
# Reaching here means no session was created and the client did not
# exit 0, so the only question left is refused-vs-hung.
if [ "$RC" -eq 124 ]; then
fail "socket: --session to an old daemon HUNG; decisions.md says it is refused"
else
pass "socket: --session to an old daemon is refused, not hung (exit $RC)"
fi
fi
{ printf 'printf "no-%%s\\n" pin\n'; sleep 12; } | \
timeout 14 "$NEW_BIN/mux" "quic://127.0.0.1:$PORT_OLD" --key "$KEY" \
--quic-idle-ms 15000 --session zz > "$TMP/o3q" 2> "$TMP/e3q"
RC=$?
if assert_name_not_honoured "quic" "$OLD_BIN/muxd" "$SOCK_OLD" "$RC" "$TMP/e3q"; then
if [ "$RC" -eq 124 ]; then
pass "quic: --session to an old daemon hangs, as documented (no session, no answer)"
else
# Better than documented: somebody fixed it, or the client grew the
# deadline banked in roadmap.md — either way the doc is now wrong.
fail "quic: --session to an old daemon exited $RC rather than hanging — decisions.md needs rewriting [$(tr -d '\n' < "$TMP/e3q")]"
fi
fi
# ---- leg 4: old client -> new daemon, and it must CREATE NOTHING -------
drive "$OLD_BIN/mux" xv4 --sock "$SOCK_NEW"
RC=$?
if [ "$RC" -ne 0 ]; then
fail "old client -> new daemon exited $RC [$(tr -d '\n' < "$TMP/err")]"
else
"$NEW_BIN/mux" d stats --sock "$SOCK_NEW" > "$TMP/s4" 2>&1
if ! "$NEW_BIN/mux" d dump --sock "$SOCK_NEW" | grep -q 'xv4-pin'; then
fail "new daemon's default grid is missing the old client's output"
elif ! grep -q 'sessions=1' "$TMP/s4"; then
fail "an old client's bare attach did not land in the default session [$(cat "$TMP/s4")]"
elif ! grep -q 'session 0 clients=' "$TMP/s4"; then
fail "the new daemon's default session is not named 0 [$(cat "$TMP/s4")]"
else
pass "old client drives the new daemon's default session, creating nothing"
fi
fi
# ---- leg 5: an old muxa's empty status_req still answers ---------------
# status_req's payload was EMPTY and now carries a name tail; an old muxa
# sends the empty one and it has to keep meaning "the default session".
if [ ! -x "$OLD_BIN/muxa" ]; then
skip "old muxa: the old ref predates the agent surface"
else
timeout 20 "$OLD_BIN/muxa" status --sock "$SOCK_NEW" --timeout 5000 > "$TMP/m5" 2>&1
RC=$?
if [ "$RC" -ne 0 ]; then
fail "old muxa status -> new daemon exited $RC [$(tr -d '\n' < "$TMP/m5")]"
elif grep -q '"cols"' "$TMP/m5"; then
pass "an old muxa's empty status_req still answers for the default session"
else
fail "old muxa got no status reply [$(tr -d '\n' < "$TMP/m5")]"
fi
fi
# ---- leg 6: the capability discriminator ------------------------------
# Not a compat check — a check on the thing that would MAKE the compat gap
# reportable. A client cannot ask "do you know about sessions?": MsgType is
# non-exhaustive and both of the old daemon's switch arms end in
# `else => {}`, so an unknown probe frame is answered with the same silence
# that is the bug. What an old daemon DOES answer is stats_req, and the two
# versions' replies differ — `sessions=` is present in one and absent in
# the other. That difference is the only version signal on the wire, so it
# is pinned here: a stats reformat that drops it would quietly retire the
# mitigation banked in roadmap.md before it was ever built.
"$OLD_BIN/muxd" stats --sock "$SOCK_OLD" > "$TMP/s6o" 2>&1
"$NEW_BIN/mux" d stats --sock "$SOCK_NEW" > "$TMP/s6n" 2>&1
if grep -q 'sessions=' "$TMP/s6o"; then
fail "the old daemon's stats already say 'sessions=' — it is not a version signal [$(cat "$TMP/s6o")]"
elif ! grep -q 'sessions=' "$TMP/s6n"; then
fail "the new daemon's stats do not say 'sessions=' — the banked capability probe has no signal [$(cat "$TMP/s6n")]"
else
pass "stats_req discriminates the versions (a probe the old daemon already answers)"
fi
# ---- legs 7 and 8: an old client meets the new side-channel frames -----
# This branch added three daemon->client frame types — term_modes (0x8d),
# term_title (0x8e), term_event (0x8f) — and the new daemon queues them to
# EVERY client in the session. An old client has no arm for any of them and
# the spec ASSERTS it lands in `else => {}` and ignores them. That is the
# shape of claim this rig exists to distrust, and both doors are asked for
# leg 3's reason: one sentence about a pre-M18 daemon was right about the
# socket and wrong about QUIC, and that cost a debugging session.
#
# The claim under test is not "the old client did not crash". It is that it
# KEPT PARSING. A type it mis-framed would make every byte after that frame
# garbage, so the session emits the side channels, waits, and only THEN
# prints a marker: a marker painted on the old client's screen is proof the
# frame boundary survived three frames it had never heard of.
#
# The wait is load-bearing, and the reason is the daemon's own order within
# one pty chunk: `sendUpdate` (the delta) runs FIRST, then drainSideEvents,
# sampleTermModes and sampleTermTitle (server.zig, the pty-readable arm).
# Emitted in a single chunk the marker's delta would reach the client
# BEFORE the three frames and this leg would prove nothing about parsing
# past them. A second apart they are separate reads, separate pumps, and
# the frames strictly precede the marker.
#
# BOTH clients run on a real tty (test/ptyclient), and that is what makes
# the negative half assertable: the client writes a side channel only to a
# terminal it has taken over, so over a pipe a client of EITHER version
# writes none of them — "the old client wrote no title" would then be a
# fact about the pipe rather than about the version, and the leg would
# survive its own falsification saying nothing.
#
# Control and subject share one leg rather than reporting separately,
# because a control that passes while its subject quietly stops being
# tested is exactly the failure mode this file is built against. If this
# tree's own client does not SHOW the side channels over this door, then
# nothing was sent, the old client's clean run is asserting nothing, and
# that is a FAIL here — not a skip and not a green.
# The clipboard needle, and its terminator is the instrument: the session
# writes OSC 52 terminated by ST and this expects BEL. See the note on
# durability above xver_side_channel.
SC_OSC52=$(printf '\033]52;c;aGk=\007')
SC_BP_ON=$(printf '\033[?2004h')
SC_BP_OFF=$(printf '\033[?2004l')
# side_channel_session TAG CLIENTBIN TRANSPORTARGS... — attach one client of
# one version over one transport, make the session emit all three side
# channels and then a marker, and leave the capture at $TMP/sc-TAG.
#
# Bound to the NEW daemon and only to it: the script is written to
# $RUN_NEW but sent as the container path /run/mux/..., which is right for
# either daemon (both mount their rundir there) and would therefore fail a
# leg pointed at $SOCK_OLD with a message about the session rather than
# about the file being on the wrong side.
#
# Driven by a FILE the session runs, never by typed input: the shell echoes
# what is typed, so a marker that could arrive as an echo would pass on a
# client that forwarded nothing (M12's rule). The typed line is the path
# `sh /run/mux/sc-TAG.sh` and the marker is `scTAG-pin`, which that line
# does not contain.
#
# FOUR CHUNKS, A SECOND APART, AND THE SPACING IS THE WHOLE DESIGN. Each
# `printf` is its own write(2) into one daemon read loop, so whether two of
# them arrive as one read is a matter of scheduling and not of anything
# this script can state. That matters because `sampleTermModes` compares
# against the last value SENT and early-returns when it is unchanged: a
# coalesced `h,l,h` against a session already bracketing nets to `true`,
# equals what the attach already reported, and emits NOTHING. Probing that
# and seeing two frames would establish only that it did not happen that
# time. Spaced out, it cannot happen at all:
#
# chunk A `?2004h` + the clipboard + the title. Leaves bracketing ON
# whatever earlier calls of this helper left behind; whether
# that is itself a frame is unknown and does not matter.
# chunk B `?2004l`. ON -> OFF against a known state, so ALWAYS a live
# term_modes(false).
# chunk C `?2004h`. OFF -> ON, so ALWAYS a live term_modes(true).
# chunk D the marker, which must be last for the whole leg to mean
# anything — see the legs' header.
#
# The title takes its own arrangement rather than spacing: it is unique per
# TAG because `sampleTermTitle` early-returns on an unchanged title exactly
# as the modes sampler does, and the attach-time resync has already sent
# the PREVIOUS tag's. The clipboard needs neither — a term_event is an
# occurrence rather than sampled state, so it fires every time.
side_channel_session() {
_sc_tag="$1"; _sc_cli="$2"; shift 2
cat > "$RUN_NEW/sc-$_sc_tag.sh" <<EOF
printf '\033[?2004h'
printf '\033]52;c;aGk=\033\\'
printf '\033]2;xvsc-$_sc_tag\007'
sleep 1
printf '\033[?2004l'
sleep 1
printf '\033[?2004h'
sleep 1
printf 'sc%s-%s\n' $_sc_tag pin
EOF
# 90 and not 60: the script's verbs below sum to 70s of deadline in the
# worst case, so a 60s outer kill would fire FIRST and report a hung
# `expect` as "the control never ran" or "the old client did not
# survive" — a fixture failure attributed to the subject. The outer
# timeout is a backstop and has to sit outside the primary deadlines to
# stay one.
# A state dir per leg. `mux` records the daemon it attached to in
# $XDG_STATE_HOME/mux/hosts and opens the wall of everything in there,
# so a shared dir would put the PREVIOUS leg's daemon on this leg's
# wall — every needle below would then be asserted against a screen cut
# into stripes by a host this leg never mentioned.
mkdir -p "$TMP/st-$_sc_tag"
XDG_STATE_HOME="$TMP/st-$_sc_tag" \
timeout 90 "$NEW_BIN/ptyclient" --cols 80 --rows 24 \
--out "$TMP/sc-$_sc_tag" --err "$TMP/sc-$_sc_tag.err" \
-- "$_sc_cli" "$@" > "$TMP/sc-$_sc_tag.log" 2>&1 <<SCRIPT
expect \x1b[?1049h 15000
settle 400 15000
send sh /run/mux/sc-$_sc_tag.sh\n
expect sc$_sc_tag-pin 15000
settle 600 15000
send \x1c\x1c
waitexit 10000
SCRIPT
}
# xver_side_channel LABEL TRANSPORTARGS... — one leg, one door.
#
# Each needle is chosen so that only a client which DECODED the frame can
# satisfy it. A client that forwarded raw session bytes would put back what
# went in, so every needle differs from what the session wrote:
#
# term_title session writes OSC **2**, needle is OSC **0**
# term_event session terminates OSC 52 with **ST**, needle wants **BEL**
# term_modes no single byte distinguishes it; the ORDER does — below
#
# The two are not equally durable, and it is the reverse of what the weight
# of argument in e2e.sh suggests. The title's asymmetry is a transform mux
# HAPPENS to perform: `Engine.title()` is one field, so OSC 0 and OSC 2
# collapse into it and the client re-emits the form it prefers. A change
# that made the client round-trip the original sequence would be perfectly
# reasonable and would retire that instrument silently. The clipboard's is
# a transform mux CANNOT avoid: `TermEvent.clipboard` carries a target and
# base64 and has no field for a terminator, so the session's ST cannot
# survive the wire, and `appendHostEffect` appends 0x07 unconditionally
# (client.zig). Only a protocol change could break that one. So if one of
# these needles has to be trusted, trust the clipboard's.
#
# (ghostty dispatches OSC on ST as well as BEL — its parser leaves the
# osc_string state on ESC, which fires the same osc_dispatch BEL produces.
# Verified at source rather than assumed, because the whole leg rests on
# the session's ST reaching the engine as an ordinary clipboard event.)
xver_side_channel() {
_lbl="$1"; shift
_sc_title_n=$(printf '\033]0;xvsc-n%s\007' "$_lbl")
_sc_title_o=$(printf '\033]0;xvsc-o%s\007' "$_lbl")
side_channel_session "n$_lbl" "$NEW_BIN/mux" "$@"
_sc_rc=$?
_sc_cap="$TMP/sc-n$_lbl"
if [ "$_sc_rc" -ne 0 ]; then
fail "$_lbl: the control never ran — ptyclient exited $_sc_rc [$(tr -d '\n' < "$TMP/sc-n$_lbl.log")]"
return
elif ! grep -qaF "scn$_lbl-pin" "$_sc_cap"; then
fail "$_lbl: the control's session never ran — no marker on the new client's screen"
return
elif ! grep -qaF "$SC_OSC52" "$_sc_cap"; then
fail "$_lbl: no clipboard reached this tree's own client over this door — the old client below would be ignoring nothing"
return
elif ! grep -qaF "$_sc_title_n" "$_sc_cap"; then
fail "$_lbl: no title reached this tree's own client over this door — the old client below would be ignoring nothing"
return
fi
# The modes channel, asserted by ORDER and not by presence, because
# presence proves almost nothing here: `sendSampledStateTo` queues a
# term_modes on EVERY attach (server.zig), and by this point the
# session has usually been left bracketing by an earlier call, so a
# bare `?2004h` needle is satisfied before the session script runs at
# all — a regression that broke `sampleTermModes` outright would leave
# it green. That was a real finding against the first version of this
# leg, not a hypothetical.
#
# What an attach cannot fake is a SECOND escape: it sends exactly one
# term_modes, so every mode escape after the first is necessarily a
# live sample. Chunks B and C make two of them unconditional, so the
# sequence contains `h` then `l` then `h` whichever value the attach
# reported — `l,h,l,h` when the session was not bracketing, `h,l,h`
# when it was. Both are expected and both must match; the counts differ
# between the two doors for exactly this reason, which is why this is a
# pattern rather than a count.
#
# Extracted with -F needles and a `sed` that keeps only the final
# letter, so nothing here builds a regex out of escape bytes.
_sc_modes=$(grep -oaF -e "$SC_BP_ON" -e "$SC_BP_OFF" "$_sc_cap" |
sed 's/.*4//' | tr -d '\n')
case "$_sc_modes" in
*h*l*h*) ;;
*)
fail "$_lbl: no live term_modes reached this tree's own client over this door — saw '$_sc_modes', want h…l…h (one of those is the attach's; the rest are the sampler's)"
return
;;
esac
side_channel_session "o$_lbl" "$OLD_BIN/mux" "$@"
_sc_rc=$?
_sc_cap="$TMP/sc-o$_lbl"
if [ "$_sc_rc" -ne 0 ]; then
fail "$_lbl: the old client did not survive the side channels — ptyclient exited $_sc_rc [$(tr -d '\n' < "$TMP/sc-o$_lbl.log")]"
elif ! grep -qaF "sco$_lbl-pin" "$_sc_cap"; then
fail "$_lbl: the old client stopped painting after the side-channel frames — the marker never arrived"
else
# All three collected rather than chained on elif, and the reason is
# this file's own history: an assertion that is never evaluated
# passes green forever. Chained, a falsification run trips the first
# one and leaves the other two unexecuted — so two of the three
# needles would have been trusted without ever having been watched
# to fail. Collected, every run executes every grep, and the report
# names ALL the channels the client rendered instead of the first.
#
# `&&` on a grep is safe HERE and would not be in general: an errored
# grep exits 2 and reads as "not found", which is the wrong answer.
# What rules it out is that the marker grep above has already read
# this same file successfully, so it exists and is readable, and the
# needles reach grep as -F bytes in a variable, so no metacharacter
# can turn one into a bad pattern.
_sc_wrote=""
grep -qaF "$SC_OSC52" "$_sc_cap" && _sc_wrote="$_sc_wrote clipboard(term_event)"
grep -qaF "$_sc_title_o" "$_sc_cap" && _sc_wrote="$_sc_wrote title(term_title)"
grep -qaF "$SC_BP_ON" "$_sc_cap" && _sc_wrote="$_sc_wrote bracketed-paste(term_modes)"
if [ -n "$_sc_wrote" ]; then
fail "$_lbl: the client under test rendered$_sc_wrote — it decoded frames an old client has no arm for, so it is not an old client"
else
pass "$_lbl: an old client ignores the new side-channel frames and keeps parsing past them"
fi
fi
}
xver_side_channel socket --sock "$SOCK_NEW"
xver_side_channel quic "quic://127.0.0.1:$PORT_NEW" --key "$KEY" --quic-idle-ms 15000
# ---- leg 9: `Ctrl-\ x` against a daemon with no end_req arm ------------
# This branch made `Ctrl-\ x` END the focused session (end_req 0x11). An old
# daemon's dispatch ends in `else => {}`, so the request is not refused, it
# is never heard — and an intent with no expiry would stay armed for the
# rest of the session, making every later chord a silent no-op. The client
# arms a deadline (client.PendingSwitch.wait_ms, 2s) and banners
# `[daemon too old to end a session]` when it lapses. That banner is the
# whole user-facing compat story for this verb, so it is what is asserted.
#
# On a pty, because the banner is painted into the rail on the alternate
# screen and never written to stderr — over a pipe there is no rail and this
# leg would assert nothing. Its own XDG_STATE_HOME for the reason above
# `side_channel_session`.
#
# The session must SURVIVE, and the witness is the shell's own work rather
# than a session count: the old ref predates M18, so its `mux d stats` has no
# `sessions=` field at all (leg 6 pins exactly that absence). A marker that
# the shell EXPANDED and that reaches the old daemon's grid AFTER the x is
# the stronger fact anyway — it says the pty is still there and still
# running a shell, which is what "the session did not end" means.
mkdir -p "$TMP/st-endold"
XDG_STATE_HOME="$TMP/st-endold" \
timeout 60 "$NEW_BIN/ptyclient" --cols 80 --rows 24 \
--out "$TMP/endold" --err "$TMP/endold.err" \
-- "$NEW_BIN/mux" --sock "$SOCK_OLD" > "$TMP/endold.log" 2>&1 <<'SCRIPT'
expect \x1b[?1049h 15000
settle 400 15000
send \x1cx
expect [daemon too old to end a session] 3000
send printf "xv9-%s\n" pin\n
expect xv9-pin 15000
settle 400 15000
send \x1c\x1c
waitexit 10000
SCRIPT
RC=$?
if [ "$RC" -ne 0 ]; then
fail "x -> old daemon: ptyclient exited $RC [$(tr -d '\n' < "$TMP/endold.log")]"
elif ! grep -qaF -- "[daemon too old to end a session]" "$TMP/endold"; then
fail "x -> old daemon: no banner — the chord went out and nothing said so"
elif ! "$OLD_BIN/muxd" dump --sock "$SOCK_OLD" | grep -q 'xv9-pin'; then
fail "x -> old daemon: the session did not survive the unanswered end_req"
else
pass "x against a daemon with no end_req arm banners and leaves the session running"
fi
# ---- leg 10: an old daemon has no session list, so the wall skips it ---
# The other half of the same silence, and the one a user meets first.
# Listing a daemon's live sessions on a bare OBSERVER connection is new
# here — `sessions_req` existed, but only on the attached client's path — so
# an old daemon answers the wall's poll with nothing, so none of its
# sessions ever reach the wall, while `mux --sock` still attaches to it
# happily. `mux hosts` is where that shows: it says `[unreachable]` for the
# same silence, and it is what this leg asks.
# That is a real cost of taking this version and it is documented
# (decisions.md, 2026-08-28); pinned here so the sentence cannot drift.
#
# Control and subject in one leg, for leg 7's reason: if the NEW daemon does
# not list either, then `mux hosts` is broken rather than the old daemon
# being old, and a bare "[unreachable] appeared" would be green on a rig
# asserting nothing. `mux hosts` needs no terminal — it dials, prints and
# exits — so this costs no fixture.
mkdir -p "$TMP/st-hosts/mux"
printf -- '--sock %s\n--sock %s\n' "$SOCK_NEW" "$SOCK_OLD" > "$TMP/st-hosts/mux/hosts"
XDG_STATE_HOME="$TMP/st-hosts" timeout 30 "$NEW_BIN/mux" hosts > "$TMP/hosts.out" 2>&1
RC=$?
if [ "$RC" -ne 0 ]; then
fail "mux hosts exited $RC [$(tr -d '\n' < "$TMP/hosts.out")]"
elif ! grep -q -- "--sock $SOCK_NEW [0-9]" "$TMP/hosts.out"; then
fail "mux hosts did not count the NEW daemon's sessions — the old one below reads unreachable for no reason [$(cat "$TMP/hosts.out")]"
elif ! grep -qF -- "--sock $SOCK_OLD [unreachable]" "$TMP/hosts.out"; then
fail "an old daemon answered the wall's session list — decisions.md says it cannot [$(cat "$TMP/hosts.out")]"
else
pass "a daemon with no observer sessions_req reads as [unreachable] to mux hosts, as documented"
fi
# ---- teardown, by the sanctioned verb, then OBSERVED ------------------
"$NEW_BIN/mux" d stop --sock "$SOCK_NEW" >/dev/null 2>&1
"$OLD_BIN/muxd" stop --sock "$SOCK_OLD" >/dev/null 2>&1
_i=0
while [ "$_i" -lt 40 ]; do
_live=$("$ENGINE" ps --filter "name=$CN_OLD" --filter "name=$CN_NEW" -q 2>/dev/null | wc -l)
[ "$_live" -eq 0 ] && break
sleep 0.05; _i=$((_i + 1))
done
# Reported as observed, never as the stop command's claim. A container
# still up here means `mux d stop` did not end the process it was aimed at,
# which is a finding and not a cleanup detail.
_live=$("$ENGINE" ps --filter "name=$CN_OLD" --filter "name=$CN_NEW" -q 2>/dev/null | wc -l)
if [ "$_live" -eq 0 ]; then
pass "both daemons ended on mux d stop (observed: no container still running)"
else
fail "$_live container(s) still running after mux d stop"
fi
# The count, pinned against a literal for e2e.sh's reason: a leg that
# silently stops running is the failure mode no assertion inside it can
# catch. Skips count — a skipped leg RAN and decided not to assert.
TOTAL=$((PASSES + FAILS + SKIPS))
if [ "$TOTAL" -ne 12 ]; then
echo "xver FAIL: $TOTAL legs reported, want 12 — one did not run"
FAILS=$((FAILS + 1))
fi
echo
echo "xver: $PASSES passed, $FAILS failed, $SKIPS skipped"
[ "$FAILS" -eq 0 ] || exit 1
echo "xver OK"