a73x

ab09f763

test(e2e): two sessions on one daemon, on the wall and over QUIC

a73x   2026-08-15 12:22

Commit message
test(e2e): two sessions on one daemon, on the wall and over QUIC

Three blocks, 23 -> 26 checkpoints. The first runs two named sessions on
one socket and asks every instrument to PROVE the separation rather than
assume it: a positive AND a negative grep per session (one shared shell
satisfies the positive alone), the default session empty because nobody
typed into it, `stats sessions=3` plus a segment per name, an unknown
name answered in words, and `muxa status`/`capture --session` addressing
a session without attaching to it. Then lifetime: a client joins the
session that already exists and types the exit that ends it, and the
daemon serves on with its neighbour intact.

Each client DETACHES before the assertions run, which is stronger than
holding them open: a session ends when its shell exits, not when its last
client leaves, so no answer here can be explained by a live client
holding something open.

The second block is what the milestone was for — two tiles, two sessions,
one socket — and the third is the operator's question rather than the
protocol's: two QUIC dials to one port are two shells, and then a socket
client joins one of them and finds it. Both clients' markers on one grid
with the session count unmoved is the assertion that a name means one
session whichever transport carried the attach. QUIC is where that claim
is least obvious, since a QUIC client takes its slot at handshake, before
any attach, so the name is the only thing that ever binds it.

Ordered ahead of the M-web block on the legible-catch rule: those tiles
now attach BY NAME, and a broken resolver would hang one of them with
nothing printed, fifteen seconds into a scenario labelled "WebSocket".

The QUIC block's port is an OBSERVATION, not a derivation: the band sits
inside ip_local_port_range and the scenarios above open plenty of
outbound QUIC sockets, so it steps on the daemon's own already-listening
refusal until one binds. Anything else still fails immediately.

muxa becomes e2e.sh's $9 — and soak.sh's, since soak IS this suite run N
times and an argument added to one and not the other aborts every soak
run on an unbound variable before its first scenario.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Makefile
Old New
@@ -2,7 +2,7 @@
2 # default zig is 0.17-dev. Override with ZIG=... if yours lives elsewhere. 2 # default zig is 0.17-dev. Override with ZIG=... if yours lives elsewhere.
3 ZIG ?= $(HOME)/Downloads/zig-x86_64-linux-0.15.2/zig 3 ZIG ?= $(HOME)/Downloads/zig-x86_64-linux-0.15.2/zig
4 4
5 .PHONY: build test e2e soak bench deps clean clean-deps 5 .PHONY: build test e2e soak bench agent deps clean clean-deps xversion xversion-build
6 6
7 # The QUIC stack (deps/quic) is built on demand by build.zig, so no target 7 # The QUIC stack (deps/quic) is built on demand by build.zig, so no target
8 # here needs to depend on this one. It exists to make the one-time cost 8 # here needs to depend on this one. It exists to make the one-time cost
@@ -28,6 +28,29 @@ soak:
28 bench: 28 bench:
29 $(ZIG) build bench 29 $(ZIG) build bench
30 30
31 agent:
32 $(ZIG) build agent
33
34 # Cross-version gate (test/xversion.sh): this tree's client against a
35 # previous version's daemon and back, each daemon in a container.
36 #
37 # Two steps because only you know what "previous" means. XVER_OLD_REF is
38 # any ref with a checkout to build from — set XVER_OLD_WORKTREE to that
39 # checkout's path. Both sides build STATIC musl: the same binary then runs
40 # in the container AND on this host, so the rig needs one artifact per
41 # version rather than two.
42 #
43 # Installed with `-p` into .xversion/ rather than the default prefix on
44 # purpose: a bare `-Dtarget=` build overwrites zig-out/bin with binaries
45 # for the wrong libc, and the next `make e2e` would silently run those.
46 XVER_OLD_WORKTREE ?= ..
47 xversion-build:
48 $(ZIG) build -Dtarget=x86_64-linux-musl -p $(CURDIR)/.xversion/new
49 cd $(XVER_OLD_WORKTREE) && $(ZIG) build -Dtarget=x86_64-linux-musl -p $(CURDIR)/.xversion/old
50
51 xversion:
52 ./test/xversion.sh
53
31 # Deliberately leaves deps/quic alone: rebuilding it needs the network and 54 # Deliberately leaves deps/quic alone: rebuilding it needs the network and
32 # several minutes, and it is pinned by checksum, so a routine clean should 55 # several minutes, and it is pinned by checksum, so a routine clean should
33 # not cost that. `clean-deps` is the one that does. 56 # not cost that. `clean-deps` is the one that does.
build.zig
Old New
@@ -671,6 +671,13 @@ pub fn build(b: *std.Build) void {
671 e2e.addArtifactArg(ptyclient_exe); 671 e2e.addArtifactArg(ptyclient_exe);
672 e2e.addArtifactArg(wsclient_exe); 672 e2e.addArtifactArg(wsclient_exe);
673 e2e.addArtifactArg(webhub_exe); 673 e2e.addArtifactArg(webhub_exe);
674 // muxa, for the M18 block alone: it is the one verb family that can ask
675 // about a session WITHOUT attaching to it, and the sessions it asks
676 // about have to be created by `mux` first — which is why the question
677 // is asked here rather than in the agent suite, where there is no `mux`
678 // to create a named session with. test/agent.sh remains muxa's own home
679 // for everything that does not need two sessions to exist.
680 e2e.addArtifactArg(muxa_exe);
674 const e2e_step = b.step("e2e", "Run end-to-end test"); 681 const e2e_step = b.step("e2e", "Run end-to-end test");
675 e2e_step.dependOn(&e2e.step); 682 e2e_step.dependOn(&e2e.step);
676 683
@@ -696,6 +703,10 @@ pub fn build(b: *std.Build) void {
696 soak.addArtifactArg(ptyclient_exe); 703 soak.addArtifactArg(ptyclient_exe);
697 soak.addArtifactArg(wsclient_exe); 704 soak.addArtifactArg(wsclient_exe);
698 soak.addArtifactArg(webhub_exe); 705 soak.addArtifactArg(webhub_exe);
706 // Same list as the e2e step, muxa included: soak IS the e2e suite run N
707 // times, so an argument added to one and not the other makes every soak
708 // run abort on an unbound variable before its first scenario.
709 soak.addArtifactArg(muxa_exe);
699 const soak_step = b.step("soak", "Run the e2e suite SOAK_N times (default 10)"); 710 const soak_step = b.step("soak", "Run the e2e suite SOAK_N times (default 10)");
700 soak_step.dependOn(&soak.step); 711 soak_step.dependOn(&soak.step);
701 712
test/e2e.sh
Old New
@@ -16,6 +16,10 @@ PTYCLIENT="$6"
16 # (test/wsclient.zig, src/webhub_main.zig). 16 # (test/wsclient.zig, src/webhub_main.zig).
17 WSCLIENT="$7" 17 WSCLIENT="$7"
18 MUXWEB="$8" 18 MUXWEB="$8"
19 # M18: the agent client, used by one block — the out-of-band verbs that
20 # address a session by name without attaching to it. Everything else muxa
21 # does is test/agent.sh's business.
22 MUXA="$9"
19 SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock" 23 SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock"
20 OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$" 24 OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$"
21 # M10: hermetic XDG homes. Key-default scenarios must see OUR key or none, 25 # M10: hermetic XDG homes. Key-default scenarios must see OUR key or none,
@@ -168,6 +172,29 @@ D17PID=""
168 W1PID="" 172 W1PID=""
169 W2PID="" 173 W2PID=""
170 WCLIPID="" 174 WCLIPID=""
175 # M18. Two daemons — the CLI multi-session block's and the wall's — and one
176 # more hub on the next 5000-spaced port. Only two sockets for four-and-some
177 # sessions, which is the whole point of the milestone: sessions are named on
178 # one socket, not spread across one socket each.
179 SOCK21="${TMPDIR:-/tmp}/muxd-e2e-m18-$$.sock"
180 SOCK22="${TMPDIR:-/tmp}/muxd-e2e-m18web-$$.sock"
181 WPORT3=$(( 51000 + ($$ % 4000) ))
182 D18PID=""
183 D19PID=""
184 W3PID=""
185 # ...and a third, the only M18 daemon holding a UDP port: it serves the
186 # session socket AND a QUIC listener at once, which is what lets one block
187 # ask whether a session name means the same session on either transport.
188 # Next 5000-spaced band after WPORT3.
189 SOCK23="${TMPDIR:-/tmp}/muxd-e2e-m18quic-$$.sock"
190 QPORT5=$(( 56000 + ($$ % 4000) ))
191 # A key of its own rather than the M8 block's $QKEY: that one is rm_swept
192 # the moment the M8 scenarios finish (see the sweep after the key-source
193 # block), which is hundreds of lines before this one runs. Reaching for a
194 # deleted file would make this block's daemon fail to bind for a reason
195 # that has nothing to do with sessions.
196 M18KEY="${TMPDIR:-/tmp}/mux-e2e-m18key-$$"
197 D20PID=""
171 198
172 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one 199 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one
173 # such line on exit; every field is a key=value pair, so a rename or reorder 200 # such line on exit; every field is a key=value pair, so a rename or reorder
@@ -247,20 +274,57 @@ wait_sock() {
247 } 274 }
248 } 275 }
249 276
250 # wait_grid SOCK NEEDLE LABEL — poll the daemon's own grid for NEEDLE (10s) 277 # dump_session SOCK [NAME] — `muxd dump` against one session (M18). An
251 # and ASSERT it landed. The assert is the whole point. The bare form of this 278 # EMPTY or absent NAME passes no --session flag AT ALL rather than an empty
252 # loop — poll, `&& break`, carry on — falls out silently when the marker 279 # one, and that is the load-bearing part: no tail is the wire's own
253 # never arrives, so a session that died at birth is discovered by whatever 280 # default-session spelling and the pre-M18 compat path (decision 3), so
254 # runs next, 15s later and in the wrong layer. Three M-web scenarios had 281 # every call site here that never heard of sessions keeps sending exactly
255 # grown exactly that shape. 282 # the bytes it always did. One helper, so a scenario asking about a named
283 # session and one asking about the default cannot drift into two spellings.
284 dump_session() {
285 if [ -n "${2:-}" ]; then
286 "$MUXD" dump --sock "$1" --session "$2"
287 else
288 "$MUXD" dump --sock "$1"
289 fi
290 }
291
292 # wait_grid SOCK NEEDLE LABEL [SESSION] — poll the daemon's own grid for
293 # NEEDLE (10s) and ASSERT it landed. The assert is the whole point. The bare
294 # form of this loop — poll, `&& break`, carry on — falls out silently when
295 # the marker never arrives, so a session that died at birth is discovered by
296 # whatever runs next, 15s later and in the wrong layer. Three M-web
297 # scenarios had grown exactly that shape.
298 #
299 # SESSION is optional and defaults to the default session: a marker typed
300 # into session `a` is not on the default session's grid, so a named-session
301 # scenario that forgot the argument would poll a grid the marker can never
302 # reach and fail 10s later blaming the wrong thing.
256 wait_grid() { 303 wait_grid() {
257 _i=0 304 _i=0
258 while [ "$_i" -lt 100 ]; do 305 while [ "$_i" -lt 100 ]; do
259 "$MUXD" dump --sock "$1" 2>/dev/null | grep -q "$2" && return 0 306 dump_session "$1" "${4:-}" 2>/dev/null | grep -q "$2" && return 0
260 sleep 0.1; _i=$((_i+1)) 307 sleep 0.1; _i=$((_i+1))
261 done 308 done
262 echo "e2e FAIL: $3: '$2' never reached the daemon's grid; it holds:" 309 echo "e2e FAIL: $3: '$2' never reached the daemon's grid; it holds:"
263 "$MUXD" dump --sock "$1" || echo "(nothing answers on $1)" 310 dump_session "$1" "${4:-}" || echo "(nothing answers on $1)"
311 exit 1
312 }
313
314 # wait_sessions SOCK N LABEL — poll until `muxd stats` reports N live
315 # sessions (10s) and ASSERT it. wait_grid's shape for wait_grid's reason: a
316 # session is freed asynchronously — its shell exits, the daemon reaps on a
317 # later pump — so "it died" has to be waited FOR, and a wait that falls out
318 # silently turns "the session never died" into a puzzling failure two
319 # assertions further down.
320 wait_sessions() {
321 _i=0
322 while [ "$_i" -lt 100 ]; do
323 "$MUXD" stats --sock "$1" 2>/dev/null | grep -q "sessions=$2" && return 0
324 sleep 0.1; _i=$((_i+1))
325 done
326 echo "e2e FAIL: $3: stats never reported sessions=$2; it holds:"
327 "$MUXD" stats --sock "$1" || echo "(nothing answers on $1)"
264 exit 1 328 exit 1
265 } 329 }
266 330
@@ -391,19 +455,24 @@ assert_converged() {
391 } 455 }
392 } 456 }
393 457
394 # assert_ws_converged WSOUT SOCK LABEL — the WebSocket leg's convergence 458 # assert_ws_converged WSOUT SOCK LABEL [SESSION] — the WebSocket leg's
395 # check. WSOUT is the stand-in's `dumpexit` file: its replica's grid, in 459 # convergence check. WSOUT is the stand-in's `dumpexit` file: its replica's
396 # `muxd dump`'s own format, built from frames that crossed the hub. Diff it 460 # grid, in `muxd dump`'s own format, built from frames that crossed the hub.
397 # against the daemon's grid, then doctor a copy of that grid and assert the 461 # Diff it against the daemon's grid, then doctor a copy of that grid and
398 # SAME diff catches the doctored one — the wan.sh rule (M9): a convergence 462 # assert the SAME diff catches the doctored one — the wan.sh rule (M9): a
399 # check that cannot fail proves nothing. 463 # convergence check that cannot fail proves nothing.
464 #
465 # SESSION (M18) names which grid the daemon side of that diff comes from. A
466 # tile attached to session `a` must be diffed against `a`, and pointing it
467 # at the default session instead would compare two unrelated grids — the
468 # check would fail, loudly and for the wrong reason.
400 # 469 #
401 # Deliberately NOT counted in CONV_COUNT. That pin counts assert_converged 470 # Deliberately NOT counted in CONV_COUNT. That pin counts assert_converged
402 # call sites — render-replays-the-client-stream — and this is different 471 # call sites — render-replays-the-client-stream — and this is different
403 # machinery answering a different question. Folding the two counts together 472 # machinery answering a different question. Folding the two counts together
404 # would make either pin's number stop meaning anything. 473 # would make either pin's number stop meaning anything.
405 assert_ws_converged() { 474 assert_ws_converged() {
406 "$MUXD" dump --sock "$2" > "$1.dump" 475 dump_session "$2" "${4:-}" > "$1.dump"
407 # Trailing whitespace is a formatting difference between two correct 476 # Trailing whitespace is a formatting difference between two correct
408 # grids, exactly as in converged_quiet. 477 # grids, exactly as in converged_quiet.
409 sed 's/[[:space:]]*$//' "$1" > "$1.n" 478 sed 's/[[:space:]]*$//' "$1" > "$1.n"
@@ -632,6 +701,14 @@ cleanup() {
632 [ -n "$D15PID" ] && kill "$D15PID" 2>/dev/null || true 701 [ -n "$D15PID" ] && kill "$D15PID" 2>/dev/null || true
633 [ -n "$D16PID" ] && kill "$D16PID" 2>/dev/null || true 702 [ -n "$D16PID" ] && kill "$D16PID" 2>/dev/null || true
634 [ -n "$D17PID" ] && kill "$D17PID" 2>/dev/null || true 703 [ -n "$D17PID" ] && kill "$D17PID" 2>/dev/null || true
704 # M18's two daemons and its hub. A multi-session daemon holds N shells,
705 # so one leaked pid here is N leaked processes — the kill is by the
706 # tracked pid all the same, and killing the daemon closes every pty
707 # master it owns, which is what ends the shells on the other side.
708 [ -n "$W3PID" ] && kill "$W3PID" 2>/dev/null || true
709 [ -n "$D18PID" ] && kill "$D18PID" 2>/dev/null || true
710 [ -n "$D19PID" ] && kill "$D19PID" 2>/dev/null || true
711 [ -n "$D20PID" ] && kill "$D20PID" 2>/dev/null || true
635 # The stops still precede the socket rm below, like SOCK14-17 above: 712 # The stops still precede the socket rm below, like SOCK14-17 above:
636 # unlinking a socket first would leave a live daemon nothing could reach 713 # unlinking a socket first would leave a live daemon nothing could reach
637 # by path. 714 # by path.
@@ -648,7 +725,8 @@ cleanup() {
648 reap_briefly "${DPID:-}" "$D2PID" "$D3PID" "$D4PID" "$D5PID" "$D6PID" \ 725 reap_briefly "${DPID:-}" "$D2PID" "$D3PID" "$D4PID" "$D5PID" "$D6PID" \
649 "$D7PID" "$D9PID" "$D10PID" "$D12PID" "$D13PID" "$SPID" "$TPID" \ 726 "$D7PID" "$D9PID" "$D10PID" "$D12PID" "$D13PID" "$SPID" "$TPID" \
650 "$GPID" "$APID" "$PAPID" "$HAPID" "$HDPID" \ 727 "$GPID" "$APID" "$PAPID" "$HAPID" "$HDPID" \
651 "$D14PID" "$D15PID" "$D16PID" "$D17PID" 728 "$D14PID" "$D15PID" "$D16PID" "$D17PID" "$D18PID" "$D19PID" \
729 "$D20PID"
652 _leak=0 730 _leak=0
653 leak_sweep "$_rc" || _leak=1 731 leak_sweep "$_rc" || _leak=1
654 732
@@ -715,6 +793,22 @@ cleanup() {
715 "$OUT.webws2.n" "$OUT.webws2.dump" "$OUT.webws2.dump.n" \ 793 "$OUT.webws2.n" "$OUT.webws2.dump" "$OUT.webws2.dump.n" \
716 "$OUT.webws2.diff" "$OUT.webws2.doc" \ 794 "$OUT.webws2.diff" "$OUT.webws2.doc" \
717 "$OUT.webstop" "$OUT.webstop2" "$OUT.webstop3" "$OUT.webstop4" 795 "$OUT.webstop" "$OUT.webstop2" "$OUT.webstop3" "$OUT.webstop4"
796 # M18 captures, both blocks, including the files assert_ws_converged
797 # derives from each stand-in's dump (.n/.dump/.dump.n/.diff/.doc).
798 rm -f "$SOCK21" "$SOCK22" \
799 "$OUT.m18.d" "$OUT.m18a" "$OUT.m18a.err" "$OUT.m18b" "$OUT.m18b.err" \
800 "$OUT.m18ax" "$OUT.m18ax.err" "$OUT.m18stats" "$OUT.m18zz" \
801 "$OUT.m18mst" "$OUT.m18mcap" \
802 "$OUT.m18stop" "$OUT.m18w.d" "$OUT.m18wh" \
803 "$OUT.m18wa" "$OUT.m18wa.err" "$OUT.m18wb" "$OUT.m18wb.err" \
804 "$OUT.m18ws0" "$OUT.m18ws0.err" "$OUT.m18ws0.n" "$OUT.m18ws0.dump" \
805 "$OUT.m18ws0.dump.n" "$OUT.m18ws0.diff" "$OUT.m18ws0.doc" \
806 "$OUT.m18ws1" "$OUT.m18ws1.err" "$OUT.m18ws1.n" "$OUT.m18ws1.dump" \
807 "$OUT.m18ws1.dump.n" "$OUT.m18ws1.diff" "$OUT.m18ws1.doc" \
808 "$OUT.m18wstop" \
809 "$SOCK23" "$M18KEY" "$OUT.m18q.d" "$OUT.m18qa" "$OUT.m18qa.err" \
810 "$OUT.m18qb" "$OUT.m18qb.err" "$OUT.m18qx" "$OUT.m18qx.err" \
811 "$OUT.m18qstop"
718 # The convergence files a FAILING assert_converged leaves behind 812 # The convergence files a FAILING assert_converged leaves behind
719 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not 813 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not
720 # chased here: on a failing run they are the evidence. 814 # chased here: on a failing run they are the evidence.
@@ -3055,6 +3149,327 @@ rm_swept "$OUT.h1" "$OUT.h1.err" "$OUT.h2" "$OUT.h2.err" "$OUT.h3" "$OUT.h3.err"
3055 "$OUT.h4" "$OUT.h4.err" "$OUT.h5" "$OUT.h5.err" "$OUT.stop" "$HKEY" "$HCFGBAD" 3149 "$OUT.h4" "$OUT.h4.err" "$OUT.h5" "$OUT.h5.err" "$OUT.stop" "$HKEY" "$HCFGBAD"
3056 rm -rf "$SSHIM_DIR" "$HRUN" "$HRUN2" 3150 rm -rf "$SSHIM_DIR" "$HRUN" "$HRUN2"
3057 3151
3152 # --- M18: two sessions on one daemon are two shells ---------------------
3153 # The milestone, observed from outside: ONE daemon, one socket path, one
3154 # key — and two sessions that share nothing. A connection IS a session
3155 # (decision 6), so the two clients below differ in exactly one flag, and
3156 # every instrument here is asked to PROVE the separation rather than
3157 # assumed to have it: for each session both a positive grep (its own
3158 # marker is here) and a negative one (the other's is not), because "a
3159 # holds a's marker" is satisfied just as well by a single shared shell.
3160 #
3161 # Ordered ahead of the M-web block on the legible-catch rule. The wall
3162 # scenarios below now attach BY NAME, and a regression in name resolution
3163 # would hang one of them with nothing printed — fifteen seconds into a
3164 # scenario whose label says "WebSocket". This block reaches the same
3165 # resolver with a CLI in front of it, and fails there first.
3166 "$MUXD" run --sock "$SOCK21" --shell /bin/sh > "$OUT.m18.d" 2>&1 &
3167 D18PID=$!
3168 wait_sock "$SOCK21" "$OUT.m18.d" "M18 multi-session daemon never bound"
3169
3170 # Two clients, two names, one socket. Each plants a marker its own shell
3171 # has to EXPAND — the typed line reads `printf "m18a-%s\n" pin` and only
3172 # the output reads `m18a-pin`, so a grep that hits is the shell's work and
3173 # not an echo of our keystrokes (the M-web passivity block's trick).
3174 #
3175 # Both run in the foreground and DETACH (\034, Ctrl-\) rather than staying
3176 # up: the session has to outlive its client for every assertion below to
3177 # mean anything. A session ends when its shell exits, not when its last
3178 # client leaves (decision 8) — so these greps run against two sessions
3179 # with nobody attached, which is also why none of their answers can be
3180 # explained by a live client holding something open.
3181 { printf 'printf "m18a-%%s\\n" pin\n'; sleep 2; printf '\034'; } | \
3182 timeout 40 "$MUX" --sock "$SOCK21" --session a > "$OUT.m18a" 2> "$OUT.m18a.err"
3183 wait_grid "$SOCK21" "m18a-pin" "M18: session a's marker" a
3184
3185 { printf 'printf "m18b-%%s\\n" pin\n'; sleep 2; printf '\034'; } | \
3186 timeout 40 "$MUX" --sock "$SOCK21" --session b > "$OUT.m18b" 2> "$OUT.m18b.err"
3187 wait_grid "$SOCK21" "m18b-pin" "M18: session b's marker" b
3188
3189 # Each session's grid holds its own marker and NOT the other's.
3190 if dump_session "$SOCK21" a | grep -q "m18b-pin"; then
3191 echo "e2e FAIL: M18: b's marker is on a's grid — one shell, not two:"
3192 dump_session "$SOCK21" a; exit 1
3193 fi
3194 if dump_session "$SOCK21" b | grep -q "m18a-pin"; then
3195 echo "e2e FAIL: M18: a's marker is on b's grid — one shell, not two:"
3196 dump_session "$SOCK21" b; exit 1
3197 fi
3198
3199 # The default session was created at daemon init and nobody ever typed
3200 # into it. It is the third session, and its emptiness is the assertion
3201 # that `--session` ROUTES rather than decorating one shared grid.
3202 if dump_session "$SOCK21" | grep -q -e "m18a-pin" -e "m18b-pin"; then
3203 echo "e2e FAIL: M18: a named session's marker reached the default grid:"
3204 dump_session "$SOCK21"; exit 1
3205 fi
3206
3207 # stats names all three, and names them one segment each. `sessions=3` on
3208 # its own would survive a daemon that counted right and routed wrong, so
3209 # the per-session segments are grepped by name too.
3210 "$MUXD" stats --sock "$SOCK21" > "$OUT.m18stats" 2>&1
3211 grep -q 'sessions=3' "$OUT.m18stats" || {
3212 echo "e2e FAIL: M18: stats does not see three sessions:"
3213 cat "$OUT.m18stats"; exit 1; }
3214 for _s in a b; do
3215 grep -q "session $_s clients=" "$OUT.m18stats" || {
3216 echo "e2e FAIL: M18: stats has no segment for session $_s:"
3217 cat "$OUT.m18stats"; exit 1; }
3218 done
3219
3220 # An unknown name is ANSWERED, in words, on the dump's own channel — not a
3221 # hang and not a fabricated "connection lost" (decision 9). The exit code
3222 # is 0 because the daemon replied; the text is the whole point.
3223 dump_session "$SOCK21" zz > "$OUT.m18zz" 2>&1
3224 grep -q 'muxd: no such session: zz' "$OUT.m18zz" || {
3225 echo "e2e FAIL: M18: an unknown session did not say so; dump answered:"
3226 cat "$OUT.m18zz"; exit 1; }
3227
3228 # muxa addresses a session by name on the verbs that never attach at all.
3229 # status_req and debug_dump grew the same name tail attach did (decision
3230 # 14), and that is what lets an agent ask about one session of several
3231 # without claiming a grid — muxa attaches at 0x0 or not at all, so without
3232 # the tail it could only ever have answered for the default session.
3233 set +e
3234 timeout 20 "$MUXA" status --sock "$SOCK21" --session b > "$OUT.m18mst" 2>&1
3235 RC=$?
3236 set -e
3237 [ "$RC" -eq 0 ] || {
3238 echo "e2e FAIL: M18: muxa status --session b exited $RC:"
3239 cat "$OUT.m18mst"; exit 1; }
3240 # Not the exit code alone: a silently empty object would pass that. `cols`
3241 # is a field only a decoded StatusReply can put there (agent.sh's shape).
3242 grep -q '"cols"' "$OUT.m18mst" || {
3243 echo "e2e FAIL: M18: muxa status --session b answered without a status reply:"
3244 cat "$OUT.m18mst"; exit 1; }
3245
3246 set +e
3247 timeout 20 "$MUXA" capture --sock "$SOCK21" --session a > "$OUT.m18mcap" 2>&1
3248 RC=$?
3249 set -e
3250 [ "$RC" -eq 0 ] || {
3251 echo "e2e FAIL: M18: muxa capture --session a exited $RC:"
3252 cat "$OUT.m18mcap"; exit 1; }
3253 grep -q 'm18a-pin' "$OUT.m18mcap" || {
3254 echo "e2e FAIL: M18: muxa capture --session a did not return a's grid:"
3255 cat "$OUT.m18mcap"; exit 1; }
3256 if grep -q 'm18b-pin' "$OUT.m18mcap"; then
3257 echo "e2e FAIL: M18: muxa capture --session a returned b's grid too:"
3258 cat "$OUT.m18mcap"; exit 1
3259 fi
3260
3261 # Lifetime: a session ends when ITS shell exits, and the daemon outlives
3262 # every death but the last (decision 8). This client attaches to a session
3263 # that ALREADY EXISTS — attach-or-create's join arm, the other half of the
3264 # two creations above — and types the exit that ends it.
3265 set +e
3266 { printf 'exit\n'; sleep 3; } | \
3267 timeout 40 "$MUX" --sock "$SOCK21" --session a > "$OUT.m18ax" 2> "$OUT.m18ax.err"
3268 RC=$?
3269 set -e
3270 [ "$RC" -eq 0 ] || {
3271 echo "e2e FAIL: M18: the client of an exiting session exited $RC, want 0"
3272 cat "$OUT.m18ax.err"; exit 1; }
3273
3274 wait_sessions "$SOCK21" 2 "M18: session a's shell exited"
3275
3276 # The daemon is still serving, and b is untouched by its neighbour's
3277 # death: the death freed ONE session, which at N=1 would have been the
3278 # daemon's own exit.
3279 if dump_session "$SOCK21" a | grep -q "m18a-pin"; then
3280 echo "e2e FAIL: M18: session a still answers after its shell exited:"
3281 dump_session "$SOCK21" a; exit 1
3282 fi
3283 dump_session "$SOCK21" b | grep -q "m18b-pin" || {
3284 echo "e2e FAIL: M18: session b lost its grid when session a died:"
3285 dump_session "$SOCK21" b; exit 1; }
3286
3287 assert_stopped "$SOCK21" "$D18PID" "M18 multi-session" "$OUT.m18stop"
3288 D18PID=""
3289 ok "two sessions on one daemon are two shells; one dies without the other"
3290
3291 # --- M18 on the wall: one socket, two tiles, two sessions ---------------
3292 # What the milestone was FOR (decision 2): the wall showing the same host
3293 # twice. Both tiles name the same socket path and differ only in the
3294 # `#NAME` suffix, so the hub dials it twice and the two connections are
3295 # two sessions — the browser's spelling of everything the block above
3296 # proved with a CLI.
3297 "$MUXD" run --sock "$SOCK22" --shell /bin/sh > "$OUT.m18w.d" 2>&1 &
3298 D19PID=$!
3299 wait_sock "$SOCK22" "$OUT.m18w.d" "M18 wall daemon never bound"
3300 "$MUXWEB" --sock "$SOCK22#a" --sock "$SOCK22#b" --port "$WPORT3" > "$OUT.m18wh" 2>&1 &
3301 W3PID=$!
3302 wait_for "$OUT.m18wh" "serving" 10 || {
3303 echo "e2e FAIL: M18 wall hub never reported serving"; cat "$OUT.m18wh"; exit 1; }
3304
3305 # Content for each session, planted by a CLI client and left behind: the
3306 # tiles are passive 1x1 wall tiles and can never type anything themselves.
3307 { printf 'printf "wall-a-%%s\\n" pin\n'; sleep 2; printf '\034'; } | \
3308 timeout 40 "$MUX" --sock "$SOCK22" --session a > "$OUT.m18wa" 2> "$OUT.m18wa.err"
3309 wait_grid "$SOCK22" "wall-a-pin" "M18 wall: session a's marker" a
3310 { printf 'printf "wall-b-%%s\\n" pin\n'; sleep 2; printf '\034'; } | \
3311 timeout 40 "$MUX" --sock "$SOCK22" --session b > "$OUT.m18wb" 2> "$OUT.m18wb.err"
3312 wait_grid "$SOCK22" "wall-b-pin" "M18 wall: session b's marker" b
3313
3314 # Tile 0 is session a's; tile 1 is session b's. The stand-in spells the
3315 # name the way mux.js does — bytes appended after the fixed 20 — and
3316 # attaches at 1x1, the passivity contract: both sessions already exist, so
3317 # these are joins, and applySize refuses to move an 80-wide grid to 1.
3318 for _t in 0 1; do
3319 case "$_t" in
3320 0) _sn=a; _mine=wall-a-pin; _theirs=wall-b-pin ;;
3321 *) _sn=b; _mine=wall-b-pin; _theirs=wall-a-pin ;;
3322 esac
3323 set +e
3324 timeout 40 "$WSCLIENT" --port "$WPORT3" --tile "$_t" \
3325 --out "$OUT.m18ws$_t" --err "$OUT.m18ws$_t.err" <<EOF
3326 attach 1 1 $_sn
3327 expectstate up 10000
3328 expectgrid $_mine 15000
3329 settle 500 10000
3330 dumpexit
3331 EOF
3332 RC=$?
3333 set -e
3334 [ "$RC" -eq 0 ] || {
3335 echo "e2e FAIL: M18 wall: tile $_t (session $_sn) wsclient exited $RC"
3336 cat -v "$OUT.m18ws$_t.err" 2>/dev/null; cat "$OUT.m18wh"; exit 1; }
3337
3338 # The replica converges on ITS session's grid...
3339 assert_ws_converged "$OUT.m18ws$_t" "$SOCK22" "M18 wall: tile $_t" "$_sn"
3340 # ...and the other session's content never crossed into it. The
3341 # convergence check above is a diff against one grid and would pass on
3342 # a hub that fanned every session's frames to every tile only if that
3343 # grid happened to match; this grep is the direct question.
3344 if grep -q "$_theirs" "$OUT.m18ws$_t"; then
3345 echo "e2e FAIL: M18 wall: tile $_t (session $_sn) received the other session's content:"
3346 cat "$OUT.m18ws$_t"; exit 1
3347 fi
3348 done
3349
3350 kill "$W3PID" 2>/dev/null || true
3351 wait_pid_gone "$W3PID" "M18 wall: hub killed by tracked pid"
3352 W3PID=""
3353 assert_stopped "$SOCK22" "$D19PID" "M18 wall" "$OUT.m18wstop"
3354 D19PID=""
3355 ok "the wall shows one host twice: two tiles, two sessions, one socket"
3356
3357 # --- M18 over QUIC: two dials are two sessions, and a name means one -----
3358 # --- session whichever transport carried the attach ----------------------
3359 # Decision 6 — a connection IS a session on EVERY transport — and QUIC is
3360 # where that claim is least obvious. A QUIC client is promoted to a client
3361 # slot when its HANDSHAKE completes, before its attach has arrived, so its
3362 # slot starts session-less and the name on the attach is the only thing
3363 # that ever binds it. Two dials to one port therefore have to come out as
3364 # two shells, and the block above proves nothing about this one: it went
3365 # through a unix socket, where the slot and the attach arrive together.
3366 #
3367 # The second half is the operator's question rather than the protocol's.
3368 # The session table belongs to the DAEMON, not to the listener that
3369 # accepted a connection — so `a` must name the same shell whether it was
3370 # reached over QUIC or over the socket. Nothing else in this suite asks a
3371 # daemon anything with two transports open at once.
3372 head -c 32 /dev/urandom > "$M18KEY"
3373 chmod 600 "$M18KEY"
3374 # The port is an OBSERVATION, not a derivation (decisions.md, hygiene kit).
3375 # This band sits INSIDE /proc/sys/net/ipv4/ip_local_port_range, and the
3376 # scenarios above open plenty of outbound QUIC sockets — one of them can
3377 # already hold the candidate by the time this daemon asks for it. Soak
3378 # caught exactly that, once in ten runs: `muxd: a daemon is already
3379 # listening on udp 127.0.0.1:57943`, from a client socket, not a daemon.
3380 #
3381 # So step on the daemon's own refusal until one binds, the way agent.sh's
3382 # start_quic does. Anything OTHER than that refusal is a real failure and
3383 # is reported with the daemon's own words; only the taken-port case walks.
3384 M18_TRIES=0
3385 while : ; do
3386 "$MUXD" run --sock "$SOCK23" --quic "127.0.0.1:$QPORT5" --key "$M18KEY" \
3387 --quic-idle-ms 15000 --shell /bin/sh > "$OUT.m18q.d" 2>&1 &
3388 D20PID=$!
3389 _i=0
3390 while [ ! -S "$SOCK23" ] && kill -0 "$D20PID" 2>/dev/null && [ "$_i" -lt 60 ]; do
3391 sleep 0.1; _i=$((_i + 1))
3392 done
3393 [ -S "$SOCK23" ] && break
3394 grep -q 'already listening on udp' "$OUT.m18q.d" || {
3395 echo "e2e FAIL: M18 quic daemon never bound its session socket"
3396 cat "$OUT.m18q.d"; exit 1; }
3397 wait "$D20PID" 2>/dev/null || true
3398 D20PID=""
3399 M18_TRIES=$((M18_TRIES + 1))
3400 [ "$M18_TRIES" -lt 8 ] || {
3401 echo "e2e FAIL: M18 quic: 8 candidate udp ports were taken, last $QPORT5"
3402 exit 1; }
3403 # A step that is not a multiple of any other band's spacing, so a walk
3404 # from here cannot march into the ports another scenario reserved.
3405 QPORT5=$((QPORT5 + 137))
3406 done
3407
3408 set +e
3409 { printf 'printf "q18a-%%s\\n" pin\n'; sleep 2; printf '\034'; } | \
3410 timeout 40 "$MUX" "quic://127.0.0.1:$QPORT5" --key "$M18KEY" \
3411 --quic-idle-ms 15000 --session a > "$OUT.m18qa" 2> "$OUT.m18qa.err"
3412 RC=$?
3413 set -e
3414 [ "$RC" -eq 0 ] || {
3415 echo "e2e FAIL: M18 quic: the --session a dial exited $RC"
3416 cat "$OUT.m18qa" "$OUT.m18qa.err" 2>/dev/null; exit 1; }
3417 wait_grid "$SOCK23" "q18a-pin" "M18 quic: session a's marker" a
3418
3419 set +e
3420 { printf 'printf "q18b-%%s\\n" pin\n'; sleep 2; printf '\034'; } | \
3421 timeout 40 "$MUX" "quic://127.0.0.1:$QPORT5" --key "$M18KEY" \
3422 --quic-idle-ms 15000 --session b > "$OUT.m18qb" 2> "$OUT.m18qb.err"
3423 RC=$?
3424 set -e
3425 [ "$RC" -eq 0 ] || {
3426 echo "e2e FAIL: M18 quic: the --session b dial exited $RC"
3427 cat "$OUT.m18qb" "$OUT.m18qb.err" 2>/dev/null; exit 1; }
3428 wait_grid "$SOCK23" "q18b-pin" "M18 quic: session b's marker" b
3429
3430 if dump_session "$SOCK23" a | grep -q "q18b-pin"; then
3431 echo "e2e FAIL: M18 quic: b's marker is on a's grid — one session, not two:"
3432 dump_session "$SOCK23" a; exit 1
3433 fi
3434 if dump_session "$SOCK23" b | grep -q "q18a-pin"; then
3435 echo "e2e FAIL: M18 quic: a's marker is on b's grid — one session, not two:"
3436 dump_session "$SOCK23" b; exit 1
3437 fi
3438 "$MUXD" stats --sock "$SOCK23" | grep -q 'sessions=3' || {
3439 echo "e2e FAIL: M18 quic: two dials did not make two sessions beside the default:"
3440 "$MUXD" stats --sock "$SOCK23"; exit 1; }
3441
3442 # The cross-transport join. Same name, different door: this must land in
3443 # the shell the QUIC client left behind, not spawn a second one.
3444 set +e
3445 { printf 'printf "x18a-%%s\\n" pin\n'; sleep 2; printf '\034'; } | \
3446 timeout 40 "$MUX" --sock "$SOCK23" --session a > "$OUT.m18qx" 2> "$OUT.m18qx.err"
3447 RC=$?
3448 set -e
3449 [ "$RC" -eq 0 ] || {
3450 echo "e2e FAIL: M18 quic: the socket client of session a exited $RC"
3451 cat "$OUT.m18qx" "$OUT.m18qx.err" 2>/dev/null; exit 1; }
3452 wait_grid "$SOCK23" "x18a-pin" "M18 quic: the socket client's marker in a" a
3453
3454 # Both markers on one grid is the whole assertion: the QUIC client's
3455 # output and the socket client's, in the session they both named.
3456 dump_session "$SOCK23" a | grep -q "q18a-pin" || {
3457 echo "e2e FAIL: M18 quic: joining 'a' over the socket did not find the QUIC dial's shell:"
3458 dump_session "$SOCK23" a; exit 1; }
3459 # ...and it JOINED rather than created: a fourth session here would mean
3460 # the name resolved per-transport, which is the bug this pair exists for.
3461 "$MUXD" stats --sock "$SOCK23" | grep -q 'sessions=3' || {
3462 echo "e2e FAIL: M18 quic: a socket join of an existing name made a new session:"
3463 "$MUXD" stats --sock "$SOCK23"; exit 1; }
3464 if dump_session "$SOCK23" b | grep -q "x18a-pin"; then
3465 echo "e2e FAIL: M18 quic: the socket client's marker leaked into b:"
3466 dump_session "$SOCK23" b; exit 1
3467 fi
3468
3469 assert_stopped "$SOCK23" "$D20PID" "M18 quic multi-session" "$OUT.m18qstop"
3470 D20PID=""
3471 ok "quic: two dials are two sessions, and one name is one session on either transport"
3472
3058 # --- M-web (a): a 1x1 attach is refused the grid and can never claim it. 3473 # --- M-web (a): a 1x1 attach is refused the grid and can never claim it.
3059 # The wall tile's passivity contract, pinned DAEMON-SIDE and hub-free on 3474 # The wall tile's passivity contract, pinned DAEMON-SIDE and hub-free on
3060 # purpose: the mechanism is applySize's cols<2 refusal plus claimGrid's 3475 # purpose: the mechanism is applySize's cols<2 refusal plus claimGrid's
@@ -3263,10 +3678,16 @@ DPID=""
3263 3678
3264 # The pins. Literals, not variables set from counting something else — 3679 # The pins. Literals, not variables set from counting something else —
3265 # "assert the literal, never the constant the code under test reads" 3680 # "assert the literal, never the constant the code under test reads"
3266 # (decisions.md, M10). 20 scenario checkpoints; 33 convergence points. 3681 # (decisions.md, M10). 26 scenario checkpoints; 35 convergence points.
3267 # Anyone adding a scenario updates these by hand, on purpose. 3682 # Anyone adding a scenario updates these by hand, on purpose.
3268 [ "$OK_COUNT" = "23" ] || { 3683 #
3269 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 23 —" 3684 # M18 added three checkpoints and no convergence points: its wall block
3685 # asserts through assert_ws_converged, which is deliberately outside
3686 # CONV_COUNT (see its comment) — that pin counts assert_converged call
3687 # sites, and folding the two together would make either number stop
3688 # meaning anything.
3689 [ "$OK_COUNT" = "26" ] || {
3690 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 26 —"
3270 echo " a scenario was added (update the pin) or silently lost" 3691 echo " a scenario was added (update the pin) or silently lost"
3271 exit 1 3692 exit 1
3272 } 3693 }
@@ -3274,4 +3695,4 @@ DPID=""
3274 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" 3695 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35"
3275 exit 1 3696 exit 1
3276 } 3697 }
3277 echo "e2e OK (23 scenarios, 35 convergence points)" 3698 echo "e2e OK (26 scenarios, 35 convergence points)"
test/soak.sh
Old New
@@ -7,6 +7,9 @@
7 # would read as a leak). 7 # would read as a leak).
8 set -u 8 set -u
9 MUXD="$1"; MUX="$2"; RAWMODE="$3"; DELAYPIPE="$4"; RENDER="$5"; PTYCLIENT="$6"; WSCLIENT="$7"; MUXWEB="$8" 9 MUXD="$1"; MUX="$2"; RAWMODE="$3"; DELAYPIPE="$4"; RENDER="$5"; PTYCLIENT="$6"; WSCLIENT="$7"; MUXWEB="$8"
10 # M18: e2e.sh takes muxa as $9. This list is that list — see the note in
11 # build.zig; the two must be handed the same binaries in the same order.
12 MUXA="$9"
10 E2E="$(dirname "$0")/e2e.sh" 13 E2E="$(dirname "$0")/e2e.sh"
11 N="${SOAK_N:-10}" 14 N="${SOAK_N:-10}"
12 TMP="${TMPDIR:-/tmp}" 15 TMP="${TMPDIR:-/tmp}"
@@ -30,7 +33,7 @@ BASE_STRAYS=$(find "$TMP" -maxdepth 1 \( -name 'muxd-e2e-*' -o -name 'mux-e2e-*'
30 FAILED=0 33 FAILED=0
31 i=1 34 i=1
32 while [ "$i" -le "$N" ]; do 35 while [ "$i" -le "$N" ]; do
33 if "$E2E" "$MUXD" "$MUX" "$RAWMODE" "$DELAYPIPE" "$RENDER" "$PTYCLIENT" "$WSCLIENT" "$MUXWEB" > "$LOG" 2>&1; then 36 if "$E2E" "$MUXD" "$MUX" "$RAWMODE" "$DELAYPIPE" "$RENDER" "$PTYCLIENT" "$WSCLIENT" "$MUXWEB" "$MUXA" > "$LOG" 2>&1; then
34 echo "soak run $i/$N: PASS" 37 echo "soak run $i/$N: PASS"
35 else 38 else
36 FAILED=$((FAILED + 1)) 39 FAILED=$((FAILED + 1))