test/e2e_13_birth.sh
Ref: Size: 31.2 KiB History
# shellcheck shell=sh
# e2e_13_birth.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 refused-attach leg: its own daemon, because its assertions are about
# what a daemon says to a name it does NOT have, and a socket shared with
# another leg would let that leg's sessions decide what "does not have"
# means.
SOCK66="${TMPDIR:-/tmp}/muxd-e2e-refuse-$$.sock"
defer_sock "$SOCK66"
REFSTATE="${TMPDIR:-/tmp}/mux-e2e-refuse-state-$$"
defer_rm "$REFSTATE"
# The browser half of the same ruling: one daemon serving a socket AND a
# The refusal-spin leg. Its own everything: a shim dir on PATH, a state
# home holding the wall it restores, and a dial log the shim appends to.
SOCK68="${TMPDIR:-/tmp}/muxd-e2e-sp-$$.sock"
defer_sock "$SOCK68"
SPPORT=$(( 26000 + ($$ % 4000) ))
SPSTATE="${TMPDIR:-/tmp}/mux-e2e-sp-state-$$"
defer_rm "$SPSTATE"
SPDIR="${TMPDIR:-/tmp}/mux-e2e-sp-shim-$$"
defer_rm "$SPDIR"
SPINLOG="$SPDIR/dials"
# The lowest-free-digit leg: one daemon of its own, because the claim is
# about which digits a WALL has spare and a socket shared with another leg
# would let that leg's sessions decide how many tiles there are.
SOCK74="${TMPDIR:-/tmp}/muxd-e2e-lfdigit-$$.sock"
defer_sock "$SOCK74"
LFSTATE="${TMPDIR:-/tmp}/mux-e2e-lfdigit-state-$$"
defer_rm "$LFSTATE"
# ---- the picker's `a` adds a HOST by spelling --------------------------
#
# `mux hosts add` typed from inside. A spelling names a DAEMON: the line
# goes into the hosts file and a poller starts — and that is ALL it does.
# The daemon's sessions become ROWS, not panes: the wall is the layout
# file, so nothing reaches it that the user did not put there. Enter on the
# host opens its sessions and Enter on a session is what adds one. A bad
# spelling is a notice and nothing else (the file does not grow). Esc eats
# the line: the shell never sees it, and after the popup closes the next
# keys reach the session again.
#
# The editor lives INSIDE the picker (`Ctrl-\ s`, then `a`), so its Esc
# goes back to the rows and a second Esc is what closes the popup. That is
# also why the whole spelling round happens under ONE `\x1c s`: an `s`
# typed at an open picker closes it.
#
# Two runs, and the split is the claim. The prompt echoes what is typed, so
# neither the added host's label on the capture nor its row proves
# anything about panes; what does is daemon B's OWN SHELL's output — a
# marker B printed before either client existed, which only a real attach
# to B can paint. Run 1 adds the host and must NOT hold that marker; run 2
# walks the picker down to the session and must. One run could not say it:
# ptyclient's expect cursor only moves forward, so a capture holding the
# marker cannot be asked whether it held it before the Enter.
PRSTATE="${TMPDIR:-/tmp}/mux-e2e-prompt-state-$$"
defer_rm "$PRSTATE"
PRHOSTS="$PRSTATE/mux/hosts"
PRLAYOUT="$PRSTATE/mux/layout"
# Two daemons, because the assertion is that the birth landed on the one
# the spelling NAMED: one socket could only ever say a session was created,
# not which daemon created it.
SOCK62="${TMPDIR:-/tmp}/muxd-e2e-promptA-$$.sock"
defer_sock "$SOCK62"
SOCK63="${TMPDIR:-/tmp}/muxd-e2e-promptB-$$.sock"
defer_sock "$SOCK63"
start_daemon "$SOCK62" "$OUT.pra.d" "prompt daemon A never bound" --shell /bin/sh
D63PID=$DPID
start_daemon "$SOCK63" "$OUT.prb.d" "prompt daemon B never bound" --shell /bin/sh
D64PID=$DPID
pipe_mux "$OUT.pra" "$OUT.pra.err" env XDG_STATE_HOME="$PRSTATE" timeout 40 "$MUX" --sock "$SOCK62"
pipe_send 'printf "pr-%%s\\n" origin\n'
await_out "$OUT.pra" "pr-origin" "prompt: daemon A's marker never reached the client"
pipe_detach
wait_grid "$SOCK62" "pr-origin" "prompt: daemon A's marker"
# Daemon B's shell prints its own marker BEFORE this leg's wall exists.
# That is the witness: nothing on the wall can paint it unless the prompt
# really put B on the wall and B's session really became a tile.
pipe_mux "$OUT.prb" "$OUT.prb.err" env XDG_STATE_HOME="$PRSTATE" timeout 40 "$MUX" --sock "$SOCK63"
pipe_send 'printf "pr-%%s\\n" bmark\n'
await_out "$OUT.prb" "pr-bmark" "prompt: daemon B's marker never reached the client"
pipe_detach
wait_grid "$SOCK63" "pr-bmark" "prompt: daemon B's marker"
mkdir -p "$PRSTATE/mux"
printf -- '--sock %s\n' "$SOCK62" > "$PRHOSTS"
# One line and one leaf: the wall this run starts on is daemon A's session
# 0 and nothing else, so every pane that appears after it is one the
# picker put there.
seed_layout "$PRSTATE" beside "--sock $SOCK62#0"
# The spelling goes in TWO sends with a settle between: the prompt paints
# its echo when a read ENDS with the prompt still open, so a spelling that
# arrives in the same read as its own \r paints nothing to assert on. The
# separating space is \x20 because parseLine trims a payload's trailing one.
set +e
XDG_STATE_HOME="$PRSTATE" timeout 90 "$PTYCLIENT" --cols 100 --rows 30 \
--out "$OUT.prcap" --err "$OUT.prcap.err" -- \
"$MUX" > "$OUT.prpc" 2>&1 <<EOF
expect pr-origin 20000
settle 700 20000
send \x1csa--sock\x20
settle 300 5000
send $SOCK63\r
settle 400 15000
send ax#bad name\r
expect [bad host 10000
settle 400 15000
send a-A nosuchhost.invalid\r
expect FlagLikeTarget 10000
settle 400 15000
send azzz\x1b
settle 400 15000
send \x1b
# Several poll answers with daemon B listed and no leaf naming any of its
# sessions. A wall that took its panes from a host's list would cut a
# second stripe in here, and B's marker would be on the screen below.
settle 2500 25000
send printf 'pr-after-%s\n' esc\n
expect pr-after-esc 10000
settle 500 15000
send \x1cd
waitexit 10000
EOF
RC=$?
set -e
rc0 "prompt: run 1 exited $RC (did the picker's a record the host, and did the refusals narrate?):" \
"$OUT.prpc" "$OUT.prcap.err"
# The oracle, not the byte stream: a banner LEFT on the screen is invisible
# to a grep of the emitted bytes — the `: zzz` that painted is in the
# capture either way. Only the render's final grid says what the human was
# still looking at when the wall came down.
"$RENDER" --cols 100 --rows 30 < "$OUT.prcap" > "$OUT.prgrid" || {
echo "e2e FAIL: prompt: render oracle failed"
cat "$OUT.prgrid"; exit 1; }
grep -q ": zzz" "$OUT.prgrid" && {
echo "e2e FAIL: prompt: the Esc'd prompt is still on the screen:"
cat "$OUT.prgrid"; exit 1; }
grep -q "pr-after-esc" "$OUT.prgrid" || {
echo "e2e FAIL: prompt: the tile's content did not come back after Esc:"
cat "$OUT.prgrid"; exit 1; }
# argv's refusal, at argv's other mouth: `-A host` is a mistyped flag and
# never becomes a tile for a host named `-A`. The bar is the witness — a
# born tile paints its spelling and a state word beside it, and the host
# here resolves nowhere, so the tile would sit there saying [refused].
grep -q -- "-A nosuchhost.invalid \[" "$OUT.prgrid" && {
echo "e2e FAIL: prompt: a flag-like spelling became a tile:"
cat "$OUT.prgrid"; exit 1; }
# The prompt is a line the typist can read back: -a because the capture is
# a terminal stream, escape bytes and all.
grep -aq ": --sock " "$OUT.prcap" || {
echo "e2e FAIL: prompt: the prompt line never painted its echo:"
cat "$OUT.prpc"; exit 1; }
# Daemon B is on the WALL FILE and not on the wall: adding a host starts a
# poller and nothing else. B's own shell's output is the witness and it
# must be absent — the marker cannot be an echo, B printed it before this
# process existed, so a hit here is a pane. `wc -l` on the matches rather
# than `grep -c`, the hosts group's reason: a pty capture is escape bytes
# with almost no newlines in it.
[ "$(grep -aoF "pr-bmark" "$OUT.prcap" | wc -l)" -eq 0 ] || {
echo "e2e FAIL: prompt: adding a host put its session on the wall — the"
echo " picker's a is a hosts-file edit, not a pane:"
cat "$OUT.prpc"; exit 1; }
timeout 20 "$MUX" a capture --sock "$SOCK62" > "$OUT.prfa" 2>&1
grep -q "pr-after-esc" "$OUT.prfa" || {
echo "e2e FAIL: prompt: keys after Esc never reached the focused session (prompt did not close):"
cat "$OUT.prfa"; exit 1; }
grep -q "zzz" "$OUT.prfa" && {
echo "e2e FAIL: prompt: Esc leaked the line into the shell:"
cat "$OUT.prfa"; exit 1; }
timeout 20 "$MUX" a capture --sock "$SOCK63" > "$OUT.prfb" 2>&1
grep -q "pr-after-esc" "$OUT.prfb" && {
echo "e2e FAIL: prompt: adding a host moved the focus onto it:"
cat "$OUT.prfb"; exit 1; }
# Recorded, and only the accepted spelling: two lines, the added one among
# them. The file is the wall the NEXT `mux` opens, so a host the user is
# looking at and a host they get back are the same host.
grep -qxF -- "--sock $SOCK63" "$PRHOSTS" || {
echo "e2e FAIL: prompt: the added host was not recorded:"
cat "$PRHOSTS"; exit 1; }
[ "$(grep -c . "$PRHOSTS")" = "2" ] || {
echo "e2e FAIL: prompt: hosts file is not exactly two lines (a refused spelling recorded?):"
cat "$PRHOSTS"; exit 1; }
# Run 2: the two Enters that DO put B's session on this wall. The wall run
# 1 detached from is the file it saved — one leaf on daemon A — so the
# picker is where the second pane has to come from. `2` selects the row
# the `a` above added, the first Enter opens that host's sessions, and the
# second adds the row under the cursor. B's marker is the witness again,
# and now it must be there.
set +e
XDG_STATE_HOME="$PRSTATE" timeout 90 "$PTYCLIENT" --cols 100 --rows 30 \
--out "$OUT.pr2cap" --err "$OUT.pr2cap.err" -- \
"$MUX" > "$OUT.pr2pc" 2>&1 <<EOF
expect pr-origin 20000
settle 700 20000
send \x1cs
expect x forget 15000
send 2
settle 400 15000
send \r
expect Enter add to wall 15000
send \r
expect pr-bmark 25000
settle 1000 20000
send \x1cd
waitexit 15000
EOF
RC=$?
set -e
rc0 "prompt: the add-a-session leg exited $RC (did Enter on a session row make a pane?):" \
"$OUT.pr2pc" "$OUT.pr2cap.err"
# The pane is in the FILE, which is the wall: a pane that painted and was
# never written down is one the next `mux` loses.
grep -q -- "leaf [0-9][0-9]* --sock $SOCK63#0\$" "$PRLAYOUT" || {
echo "e2e FAIL: prompt: the added session is not a leaf of the saved wall:"
cat "$PRLAYOUT"; exit 1; }
grep -q -- "leaf [0-9][0-9]* --sock $SOCK62#0\$" "$PRLAYOUT" || {
echo "e2e FAIL: prompt: adding a pane lost the one that was already there:"
cat "$PRLAYOUT"; exit 1; }
assert_stopped "$SOCK62" "$D63PID" "prompt A" "$OUT.prastop"
D63PID=""
assert_stopped "$SOCK63" "$D64PID" "prompt B" "$OUT.prbstop"
D64PID=""
rm -rf "$PRSTATE"
ok "the picker's a adds a host by spelling: its sessions are listed in the picker, not put on the wall; Enter on one adds it"
# ---------------------------------------------------------------------------
# A refused attach is not a shell that exited.
#
# mux a attaches at 0x0, and a 0x0 attach is join-only: the daemon refuses a
# name it does not hold by sending exit_status 1 and closing — the same frame
# a real shell's exit sends. mux a used to report that as
# `{"reason":"session_ended","exit_code":1}` and `{"error":"session ended"}`,
# telling an agent a command had run and failed when no shell ever existed,
# while `status` got no answer at all and blamed the daemon (`DaemonGone`).
# The snapshot tells the two apart — a served attach always sends one first
# — and what this leg pins is the words an agent actually parses.
mkdir -p "$REFSTATE"
XDG_STATE_HOME="$REFSTATE" "$MUX" d start --sock "$SOCK66" --shell /bin/sh > "$OUT.ref.d" 2>&1 &
D66PID=$!
defer_kill "$D66PID"
wait_sock "$SOCK66" "$OUT.ref.d" "refused-attach daemon never bound"
# Each verb twice over: the words it must say, and the words it must not.
# The negative half is the whole regression — a reply that added "attach
# refused" while still claiming a session ended would pass a one-sided grep.
refuse_leg() {
_out="$OUT.ref$1"
_kind="$2"
shift 2
set +e
XDG_STATE_HOME="$REFSTATE" timeout 20 "$MUX" a "$@" --sock "$SOCK66" \
--session nosuch --timeout 5000 > "$_out" 2>&1
_rc=$?
set -e
[ "$_rc" -eq 1 ] || {
echo "e2e FAIL: refused-attach: mux a $1 on a missing session exited $_rc, want 1:"
cat "$_out"; exit 1; }
grep -qF '"error":"attach refused"' "$_out" || {
echo "e2e FAIL: refused-attach: mux a $1 did not name the refusal:"
cat "$_out"; exit 1; }
grep -qF 'so nosuch must already exist' "$_out" || {
echo "e2e FAIL: refused-attach: mux a $1 did not name the session asked for:"
cat "$_out"; exit 1; }
# Only the verbs that attach have two causes. status and capture never
# attach, so no client slot was ever asked for and none can be blamed:
# an agent sent to read `mux d stats` over a name it mistyped burns a
# round trip on a table that is not full.
if [ "$_kind" = attach ]; then
grep -qF 'room for one more client' "$_out" || {
echo "e2e FAIL: refused-attach: mux a $1 blamed absence for a refusal that has two causes:"
cat "$_out"; exit 1; }
else
grep -qF 'max_clients' "$_out" && {
echo "e2e FAIL: refused-attach: mux a $1 never attached, so it must not blame the client table:"
cat "$_out"; exit 1; }
fi
grep -qF 'session_ended' "$_out" && {
echo "e2e FAIL: refused-attach: mux a $1 still reports a session that ended:"
cat "$_out"; exit 1; }
grep -qF 'session ended' "$_out" && {
echo "e2e FAIL: refused-attach: mux a $1 still reports a session that ended:"
cat "$_out"; exit 1; }
grep -qF 'DaemonGone' "$_out" && {
echo "e2e FAIL: refused-attach: mux a $1 blamed the daemon for a missing name:"
cat "$_out"; exit 1; }
return 0
}
refuse_leg run attach run true
refuse_leg status query status
refuse_leg send attach send x
# ...and the daemon that said no three times is still serving. Refusing is
# an answer, not a fault: a daemon that died on a typed `--session` would
# turn one bad name into everyone else's outage.
XDG_STATE_HOME="$REFSTATE" timeout 20 "$MUX" a status --sock "$SOCK66" \
--session 0 > "$OUT.refalive" 2>&1 || {
echo "e2e FAIL: refused-attach: the daemon stopped answering after refusing:"
cat "$OUT.refalive"; exit 1; }
grep -qF '"cols":' "$OUT.refalive" || {
echo "e2e FAIL: refused-attach: status on the live session is not a status reply:"
cat "$OUT.refalive"; exit 1; }
assert_stopped "$SOCK66" "$D66PID" "refused-attach" "$OUT.refstop"
D66PID=""
rm -rf "$REFSTATE"
ok "mux a names a refused attach instead of a shell that never ran"
# --- a refusal the birth cannot fix must not spin ----------------------
#
# A browser attaching to a session that is not there — a page whose tile
# list is one poll stale, which is the only way to reach a refusal now that
# the hub births nothing on a tile's behalf. Nothing the hub can do makes
# the attach land.
# Every refusal closes the connection (`server.dropObserver`), so the
# re-dial that follows succeeds on its first try, the page re-attaches on
# `up` as it always does, and the same no comes back — a connect/attach/
# close loop bounded by nothing but round-trip latency, on the hub, the
# daemon and the browser at once.
#
# Counted the one way the hub cannot flatter itself: a `.hand` dial forks
# its own ssh, and the shim logs its pid before exec, so `wc -l` is a
# count of PROCESSES the OS made. A hub counter would be the code under
# test grading its own homework.
start_daemon "$SOCK68" "$OUT.sp.d" "refusal-spin daemon never bound" --shell /bin/sh
D68PID=$DPID
# This shim ignores the remote command on purpose — it is not modelling
# ssh (the M14 shim above does that), it is the dial's process signature
# plus a byte pipe to a REAL daemon. `endpoint none` is what keeps every
# dial on this path: an announced QUIC endpoint would be cached, the
# second dial would skip ssh entirely, and the count with it.
mkdir -p "$SPDIR"
: > "$SPINLOG"
cat > "$SPDIR/ssh" <<SPSHIM
#!/bin/sh
echo \$\$ >> "$SPINLOG"
printf 'endpoint none\n'
exec "$MUX" d proxy --sock "$SOCK68"
SPSHIM
chmod +x "$SPDIR/ssh"
# One HOST line — a daemon, never a session, which is the whole of what a
# host line may say — and one LEAF naming that daemon's own session `0`,
# because the hub serves the layout's panes and a hosts file alone is a
# wall with nothing on it. The attach the stand-in then sends names
# `spinghost`, which the daemon does not have, and the hub forwards the
# refusal untouched.
mkdir -p "$SPSTATE/mux"
printf 'mux-spin@127.0.0.1\n' > "$SPSTATE/mux/hosts"
seed_layout "$SPSTATE" stacked "mux-spin@127.0.0.1#0"
XDG_STATE_HOME="$SPSTATE" XDG_CACHE_HOME="$SPSTATE/cache" PATH="$SPDIR:$PATH" \
"$MUX" web --port "$SPPORT" > "$OUT.sph" 2>&1 &
W6PID=$!
defer_kill "$W6PID"
wait_for "$OUT.sph" "tile 0:" 15 || {
echo "e2e FAIL: refusal-spin: hub never announced a tile"; cat "$OUT.sph"; exit 1; }
# Two other things dial through this shim and land on the same log: the
# tile's own first dials, and the HOST POLLER, which redials every 10 s (a
# pipe link — `client.pollDelayMs`) for as long as the hub runs. The
# baseline takes the first out; the poller contributes at most one dial
# inside the 6 s window below, which the 3..15 bounds absorb.
SPBASE=$(wc -l < "$SPINLOG")
# `reattach` is mux.js's ENV_CONTROL handler, which is the half of the
# loop the hub does not own: the page attaches again on every `up`. Six
# seconds of it, and the dial log is read the moment the window closes.
set +e
timeout 90 "$WSCLIENT" --port "$SPPORT" --tile 0 --out "$OUT.spws" --err "$OUT.spws.err" <<'EOF'
expectstate up 25000
reattach 0 0 6000 spinghost
expectrefused 25000
dumpexit
EOF
RC=$?
set -e
SPDIALS=$(( $(wc -l < "$SPINLOG") - SPBASE ))
[ "$RC" -eq 0 ] || {
echo "e2e FAIL: refusal-spin: the wsclient exited $RC after $SPDIALS dials"
cat -v "$OUT.spws.err" 2>/dev/null; cat "$OUT.sph"; exit 1; }
# The bound, and its vacuity guard. Backoff caps at client.nextBackoffMs's
# 2s, so six seconds of refusal is a handful of dials; unbounded it is one
# per round trip, hundreds. The lower bound is what stops this passing on
# a tile that never re-dialled at all — then the ceiling would be measuring
# nothing.
[ "$SPDIALS" -ge 3 ] || {
echo "e2e FAIL: refusal-spin: only $SPDIALS dials in 6s — the tile never"
echo " re-dialled, so the ceiling below measured nothing"
cat "$OUT.sph"; exit 1; }
[ "$SPDIALS" -le 15 ] || {
echo "e2e FAIL: refusal-spin: $SPDIALS dials in 6s (want <= 15) — a refusal"
echo " the birth cannot fix is spinning with no backoff"
cat "$OUT.sph"; exit 1; }
# ...and it stayed refused: nothing was born behind the hub's back, which
# is what makes the count above a count of REFUSED dials. A hub that
# birthed for a tile would show it here.
timeout 20 "$MUX" a status --sock "$SOCK68" --session spinghost > "$OUT.spghost" 2>&1 && {
echo "e2e FAIL: refusal-spin: a remote wall line created a session:"
cat "$OUT.spghost"; exit 1; }
softkill "$W6PID" || true
wait_pid_gone "$W6PID" "refusal-spin: hub killed by tracked pid"
W6PID=""
assert_stopped "$SOCK68" "$D68PID" "refusal-spin" "$OUT.spstop"
D68PID=""
rm -rf "$SPSTATE" "$SPDIR"
ok "a refusal the birth cannot fix backs off instead of spinning"
# ---- a new tile takes the lowest free digit -----------------------------
#
# Create 1 2 3, end the session under 2 and take its pane off the wall,
# create again: the wall says 2, not 4. The digits are what `Ctrl-\ 1-9`
# addresses, so a digit a removed pane kept forever would push every later
# birth out of the keyboard's reach — and the daemon has taken the NAME
# back (`client.nextFreeName`), so a wall that appended would be showing
# tile 4 holding session 1.
#
# Reuse, not renumbering: the tiles that stayed keep the digits their user
# learned, which is why this leg asserts 2 came back rather than that the
# wall reads 1 2 3 from the left.
#
# The SHELL is what ends it now, not a chord: `Ctrl-\ x` takes a pane off
# the wall and leaves the session running, so it can free a digit but
# never a name. `exit` typed into the focused pane frees both — the
# daemon drops the session and the tile's own pump reads the exit_status
# and vanishes the pane (`endAction`'s `.exited` branch, which keeps the
# wall up because there are other tiles on it). One key, two holes.
#
# ONE ptyclient run, because the claim is about a LIVE wall taking a hole
# back: a second client would seed its panes from the file this one saved
# and land on 1 2 by reading, testing nothing at all.
#
# The convergence points are relayout's screen clear — `\x1cc` re-cuts the
# wall, and so does the vanish the shell's exit causes. `\x1c2` emits none
# of its own (`setFocus` repaints bars, it does not clear), which is what
# makes the clear after the exit the vanished pane's and not the focus
# move's.
#
# The settle after the exit is a DURATION and not a convergence point: what
# it waits out is the POLL that takes the freed name out of the host's
# list, and a `c` pressed before it would find the daemon still listing the
# name it has freed and birth session 3. That reads here as the session-1
# assertions below failing, never as a green run, which is what makes a
# duration acceptable.
#
# The clear after the LAST `\x1cc` is the other half of the claim: a birth
# that takes a digit back leaves the slot high-water mark where it was, so
# a wall that read "did it grow?" off that mark would seat the new tile on
# a screen nothing re-cut.
start_daemon "$SOCK74" "$OUT.lfd.d" "lowest-free-digit daemon never bound" --shell /bin/sh
D74PID=$DPID
set +e
XDG_STATE_HOME="$LFSTATE" timeout 90 "$PTYCLIENT" --cols 100 --rows 30 \
--out "$OUT.lfdcap" --err "$OUT.lfdcap.err" -- \
"$MUX" --sock "$SOCK74" > "$OUT.lfdpc" 2>&1 <<'EOF'
expect \x1b[?1049h 15000
settle 500 15000
send printf 'lfd-%s\n' one\n
expect lfd-one 15000
settle 400 15000
send \x1cc
expect \x1b[2J 15000
settle 500 15000
send printf 'lfd-%s\n' two\n
expect lfd-two 15000
settle 400 15000
send \x1cc
expect \x1b[2J 15000
settle 500 15000
send printf 'lfd-%s\n' three\n
expect lfd-three 15000
settle 400 15000
send \x1c2
settle 700 15000
send exit\n
expect \x1b[2J 25000
settle 3000 25000
send \x1cc
expect \x1b[2J 20000
settle 500 15000
send printf 'lfd-%s\n' back\n
expect lfd-back 15000
settle 700 15000
send \x1cd
waitexit 10000
EOF
RC=$?
set -e
[ "$RC" -eq 0 ] || {
echo "e2e FAIL: lowest-free-digit: ptyclient leg exited $RC (did the middle"
echo " pane's shell exit take its pane, and did the birth land?):"
cat "$OUT.lfdpc"; exit 1; }
# The oracle, not the byte stream: every digit this leg typed its way
# through is somewhere in the capture, so only the final grid says which
# ones a human was still looking at.
"$RENDER" --cols 100 --rows 30 < "$OUT.lfdcap" > "$OUT.lfdgrid" || {
echo "e2e FAIL: lowest-free-digit: render oracle failed"
cat "$OUT.lfdgrid"; exit 1; }
grep -q -- " 2> --sock" "$OUT.lfdgrid" || {
echo "e2e FAIL: lowest-free-digit: no tile on the final wall wears digit 2:"
cat "$OUT.lfdgrid"; exit 1; }
grep -q -- " 4>" "$OUT.lfdgrid" && {
echo "e2e FAIL: lowest-free-digit: a three-tile wall wearing digit 4 — the"
echo " birth appended past the hole instead of taking it back:"
cat "$OUT.lfdgrid"; exit 1; }
wait_sessions "$SOCK74" 3 "lowest-free-digit: the daemon should hold 0, 1 and 2"
# The digit and the name agree, asked of the DAEMON rather than of the wall
# that would be reporting on its own arithmetic: session 1 is back, and it
# is a NEW one — the ended session's output is not in it.
timeout 20 "$MUX" a capture --sock "$SOCK74" --session 1 > "$OUT.lfdsess1" 2>&1
grep -q "lfd-back" "$OUT.lfdsess1" || {
echo "e2e FAIL: lowest-free-digit: session 1 does not hold what was typed at"
echo " the reborn tile:"
cat "$OUT.lfdsess1"; exit 1; }
grep -q "lfd-two" "$OUT.lfdsess1" && {
echo "e2e FAIL: lowest-free-digit: session 1 still holds the ENDED session's"
echo " output, so nothing was ended and nothing was reborn:"
cat "$OUT.lfdsess1"; exit 1; }
assert_stopped "$SOCK74" "$D74PID" "lowest-free-digit" "$OUT.lfdstop"
D74PID=""
ok "a new tile takes the lowest free digit, and the daemon takes the name back"
# ---- a birth the DAEMON refuses narrates, and the wall stays up ---------
#
# `.refused` is a live tile state and the only one with no CLI spelling
# left to reach it: `mux TARGET#session` is gone, so what used to drive
# this leg went with it and the state word rested on a unit test of
# `labelText` — which pins the STRING, not that a refused pump ever
# reaches it.
#
# The refusal is the client TABLE, not the session table, and the two are
# the same size now — `max_clients` and `max_sessions` are both 32 — so the
# leg has to fill one of them without filling the other. A slot is spent per
# ATTACH, which is what makes that possible: eight sessions carry the
# eight-pane wall, and twenty-four HOLDERS attached to those same eight
# names spend the remaining twenty-four slots. 24 + 8 = 32 and the client
# table is full, while the session table is still at eight of thirty-two —
# so the picker's `c` has a name it may create and no slot to attach it
# with. It dials, the daemon refuses that attach before any replay frame,
# and the pump paints `[refused]` on that tile's bar on its way out.
#
# Filling the table out of sessions alone is what the old shape did, back
# when eight slots and thirty-two names meant the client table ran out
# first. It cannot work at 32: thirty-two sessions would saturate both
# tables at once and `c` would be refused for want of a NAME, which is a
# different refusal reaching a different tile state.
#
# Two claims, and the second is the newer one: the wall STAYS. A refused
# picker birth used to take mux down with it (rc 1 on a terminal); a tile
# that keeps the wall (`Tile.keeps_wall`) leaves the eight standing, which
# is what the marker typed after the refusal proves — it reaches a session
# because there is still a wall to type into.
SOCK75="${TMPDIR:-/tmp}/muxd-e2e-reftile-$$.sock"
defer_sock "$SOCK75"
RTSTATE="${TMPDIR:-/tmp}/mux-e2e-reftile-state-$$"
defer_rm "$RTSTATE"
start_daemon "$SOCK75" "$OUT.rt.d" "refused-tile daemon never bound" --shell /bin/sh
D75PID=$DPID
# Seven more, so the daemon holds eight sessions with its own `0` — one per
# pane of the wall below. The fills detach as they go and leave the slots
# free, and the holders take them next.
fill_sessions "$SOCK75" "$OUT.rtfill" f 1 7
wait_sessions "$SOCK75" 8 "refused-tile: the daemon should hold eight sessions"
# The holders count UP from an empty table, so the fills must have let go
# before the first one dials — otherwise a slot still draining shifts every
# target by one and the leg waits out its budget for a table that is
# already where it was asked to be.
wait_until 300 "refused-tile: the fill attaches never released their client slots" \
"[ \"\$(clients_now $SOCK75)\" = 0 ]" \
"\"\$MUX\" d stats --sock $SOCK75"
# Twenty-four holders over the eight names, three deep each. They attach
# BEFORE the wall so the wall's own eight tiles are the last eight slots in
# the table: latest-wins means the tiles set each session's size after the
# holders did, so no holder can resize a pane out from under the grid this
# leg reads.
hold_clients "$SOCK75" "$OUT.rthold" 24 "0 f1 f2 f3 f4 f5 f6 f7"
RTCL=$(clients_now "$SOCK75")
[ "$RTCL" = 24 ] || {
echo "e2e FAIL: refused-tile: the daemon holds $RTCL client slots, want 24"
echo " — the wall's eight tiles are what must fill the last eight"
"$MUX" d stats --sock "$SOCK75"; exit 1; }
# Bracket the wall: every tile attaches once, so the delta is the pane
# count and nothing else. It is the other half of the arithmetic above —
# 24 held plus 8 tiles is exactly `max_clients` — and it also says the
# refused birth seated NOBODY, because `seatClient` counts past every
# refusal and a refused attach never reaches it.
RTATT0=$(attaches_now "$SOCK75")
mkdir -p "$RTSTATE/mux"
printf -- '--sock %s\n' "$SOCK75" > "$RTSTATE/mux/hosts"
# Eight leaves, one per session: the wall is the file, so the eight panes
# that spend the last eight slots are eight somebody wrote down and not
# eight the poll went and found.
seed_layout "$RTSTATE" stacked \
"--sock $SOCK75#0" "--sock $SOCK75#f1" "--sock $SOCK75#f2" "--sock $SOCK75#f3" \
"--sock $SOCK75#f4" "--sock $SOCK75#f5" "--sock $SOCK75#f6" "--sock $SOCK75#f7"
set +e
# Tall and not wide, the hosts group's shape and for its reason: 80 columns
# is under the aspect rule's `cols >= 2 * rows`, so the cut is stacked and
# every bar gets the full width — beside, eight stripes are nine columns
# each and `labelText` spends them all on the state word. Forty-four rows
# over eight stripes leaves room for a ninth (three rows each, two of
# content under a bar): the WALL is not what refuses here, and a terminal
# that ran out of stripes first would say `[no room on the wall for another
# tile]` and never dial at all.
XDG_STATE_HOME="$RTSTATE" timeout 90 "$PTYCLIENT" --cols 80 --rows 44 \
--out "$OUT.rtcap" --err "$OUT.rtcap.err" -- "$MUX" > "$OUT.rtpc" 2>&1 <<'EOF'
expect [up] 25000
settle 2000 25000
send \x1cs
expect x forget 15000
send c
expect [refused] 25000
settle 1500 20000
send printf 'rt-%s\n' alive\n
expect rt-alive 20000
settle 500 15000
send \x1cd
waitexit 15000
EOF
RC=$?
set -e
# The exit code IS the second claim: a wall that ended on the refusal would
# be gone before the marker, and this is where that shows.
[ "$RC" -eq 0 ] || {
echo "e2e FAIL: refused-tile: the wall exited $RC — a refused birth must"
echo " leave the tiles that were already up standing:"
cat "$OUT.rtpc"; echo "--- stderr ---"; cat "$OUT.rtcap.err"; exit 1; }
# The narration, off the STREAM: the refused tile vanishes on the pass
# after it paints, so the final grid is not where the word lives.
grep -qF -- "[refused]" "$OUT.rtcap" || {
echo "e2e FAIL: refused-tile: no tile ever wore the refused state word:"
grep -ao '\[[a-z ]*\]' "$OUT.rtcap" | sort -u | head; exit 1; }
# ...and the refusal created nothing. A daemon that refused the attach but
# kept the session would leave a session nobody can see — the worse of the
# two failures, and invisible to the screen.
wait_sessions "$SOCK75" 8 "refused-tile: a refused birth must leave no session behind"
# The marker landed in a session on the wall, asked of the DAEMON: the
# eight tiles outlived the ninth. Which one holds it is the focus's
# business — the refusal puts the focus back on the tile it was born from
# — so every session is asked and one hit is the claim.
RTWHERE=""
for _rt in 0 f1 f2 f3 f4 f5 f6 f7; do
if dump_session "$SOCK75" "$_rt" 2>/dev/null | grep -q "rt-alive"; then RTWHERE="$_rt"; fi
done
[ -n "$RTWHERE" ] || {
echo "e2e FAIL: refused-tile: the marker typed after the refusal reached no"
echo " session — the wall did not survive its own refused birth:"
cat "$OUT.rtpc"; exit 1; }
RTATT1=$(attaches_now "$SOCK75")
assert_attach_delta "$RTATT0" "$RTATT1" 8 "refused-tile"
release_holds
# Read the gauge back HERE too, not only inside the helper: the holders are
# the whole reason the table was full, so a release that quietly released
# nothing would leave every later reader of this leg believing an arithmetic
# that had stopped being true. The wall is gone by now, so nothing but the
# holders can be holding a slot.
RTCL_END=$(clients_now "$SOCK75")
[ "$RTCL_END" = 0 ] || {
echo "e2e FAIL: refused-tile: $RTCL_END client slots still held after release_holds"
"$MUX" d stats --sock "$SOCK75"; exit 1; }
assert_stopped "$SOCK75" "$D75PID" "refused-tile" "$OUT.rtstop"
D75PID=""
ok "a birth the daemon refuses paints [refused] and leaves the wall standing"