a73x

13e13cc1

test: 05_session moves to a file of its own

a73x   2026-08-26 18:54

Commit message
test: 05_session moves to a file of its own

named sessions on one daemon, over both transports — 3 scenarios, 337 lines. The legs move unedited; the runner
sources the file where they used to stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

test/e2e.sh
Old New
@@ -102,7 +102,7 @@ done
102 E2E_DIR=$(dirname "$0") 102 E2E_DIR=$(dirname "$0")
103 . "$E2E_DIR/e2e_lib.sh" 103 . "$E2E_DIR/e2e_lib.sh"
104 104
105 E2E_GROUPS='01_boot 02_predict 03_side 04_handoff' 105 E2E_GROUPS='01_boot 02_predict 03_side 04_handoff 05_session'
106 # The groups that cannot stand alone, and what they need. E2E_ONLY refuses 106 # The groups that cannot stand alone, and what they need. E2E_ONLY refuses
107 # these by name rather than running them: a group whose fixtures are 107 # these by name rather than running them: a group whose fixtures are
108 # missing does not fail, it passes having tested something else — and a 108 # missing does not fail, it passes having tested something else — and a
@@ -145,340 +145,6 @@ for _g in $E2E_GROUPS; do
145 . "$E2E_DIR/e2e_$_g.sh" 145 . "$E2E_DIR/e2e_$_g.sh"
146 done 146 done
147 147
148 # M18. Two daemons — the CLI multi-session block's and the wall's — and one
149 # more hub on the next 5000-spaced port. Only two sockets for four-and-some
150 # sessions, which is the whole point of the milestone: sessions are named on
151 # one socket, not spread across one socket each.
152 SOCK21="${TMPDIR:-/tmp}/muxd-e2e-m18-$$.sock"
153 defer_sock "$SOCK21"
154 SOCK22="${TMPDIR:-/tmp}/muxd-e2e-m18web-$$.sock"
155 defer_sock "$SOCK22"
156 WPORT3=$(( 51000 + ($$ % 4000) ))
157 # ...and a third, the only M18 daemon holding a UDP port: it serves the
158 # session socket AND a QUIC listener at once, which is what lets one block
159 # ask whether a session name means the same session on either transport.
160 # Next 5000-spaced band after WPORT3.
161 SOCK23="${TMPDIR:-/tmp}/muxd-e2e-m18quic-$$.sock"
162 defer_sock "$SOCK23"
163 QPORT5=$(( 56000 + ($$ % 4000) ))
164 # A key of its own rather than the M8 block's $QKEY: that one is rm_swept
165 # the moment the M8 scenarios finish (see the sweep after the key-source
166 # block), which is hundreds of lines before this one runs. Reaching for a
167 # deleted file would make this block's daemon fail to bind for a reason
168 # that has nothing to do with sessions.
169 M18KEY="${TMPDIR:-/tmp}/mux-e2e-m18key-$$"
170 defer_rm "$M18KEY"
171
172 # --- M18: two sessions on one daemon are two shells ---------------------
173 # The milestone, observed from outside: ONE daemon, one socket path, one
174 # key — and two sessions that share nothing. A connection IS a session
175 # (decision 6), so the two clients below differ in exactly one flag, and
176 # every instrument here is asked to PROVE the separation rather than
177 # assumed to have it: for each session both a positive grep (its own
178 # marker is here) and a negative one (the other's is not), because "a
179 # holds a's marker" is satisfied just as well by a single shared shell.
180 #
181 # Ordered ahead of the M-web block on the legible-catch rule. The wall
182 # scenarios below now attach BY NAME, and a regression in name resolution
183 # would hang one of them with nothing printed — fifteen seconds into a
184 # scenario whose label says "WebSocket". This block reaches the same
185 # resolver with a CLI in front of it, and fails there first.
186 start_daemon "$SOCK21" "$OUT.m18.d" "M18 multi-session daemon never bound" --shell /bin/sh
187 D18PID=$DPID
188
189 # Two clients, two names, one socket. Each plants a marker its own shell
190 # has to EXPAND — the typed line reads `printf "m18a-%s\n" pin` and only
191 # the output reads `m18a-pin`, so a grep that hits is the shell's work and
192 # not an echo of our keystrokes (the tiny-attach block's trick).
193 #
194 # Both run in the foreground and DETACH (\034\034, the Ctrl-\ chord) rather than staying
195 # up: the session has to outlive its client for every assertion below to
196 # mean anything. A session ends when its shell exits, not when its last
197 # client leaves (decision 8) — so these greps run against two sessions
198 # with nobody attached, which is also why none of their answers can be
199 # explained by a live client holding something open.
200 pipe_mux "$OUT.m18a" "$OUT.m18a.err" timeout 40 "$MUX" --sock "$SOCK21" --session a
201 pipe_send 'printf "m18a-%%s\\n" pin\n'
202 await_out "$OUT.m18a" "m18a-pin" "m18a-pin never reached the client"
203 pipe_detach
204 wait_grid "$SOCK21" "m18a-pin" "M18: session a's marker" a
205
206 pipe_mux "$OUT.m18b" "$OUT.m18b.err" timeout 40 "$MUX" --sock "$SOCK21" --session b
207 pipe_send 'printf "m18b-%%s\\n" pin\n'
208 await_out "$OUT.m18b" "m18b-pin" "m18b-pin never reached the client"
209 pipe_detach
210 wait_grid "$SOCK21" "m18b-pin" "M18: session b's marker" b
211
212 # Each session's grid holds its own marker and NOT the other's.
213 if dump_session "$SOCK21" a | grep -q "m18b-pin"; then
214 echo "e2e FAIL: M18: b's marker is on a's grid — one shell, not two:"
215 dump_session "$SOCK21" a; exit 1
216 fi
217 if dump_session "$SOCK21" b | grep -q "m18a-pin"; then
218 echo "e2e FAIL: M18: a's marker is on b's grid — one shell, not two:"
219 dump_session "$SOCK21" b; exit 1
220 fi
221
222 # The default session was created at daemon init and nobody ever typed
223 # into it. It is the third session, and its emptiness is the assertion
224 # that `--session` ROUTES rather than decorating one shared grid.
225 if dump_session "$SOCK21" | grep -q -e "m18a-pin" -e "m18b-pin"; then
226 echo "e2e FAIL: M18: a named session's marker reached the default grid:"
227 dump_session "$SOCK21"; exit 1
228 fi
229
230 # stats names all three, and names them one segment each. `sessions=3` on
231 # its own would survive a daemon that counted right and routed wrong, so
232 # the per-session segments are grepped by name too.
233 "$MUXD" stats --sock "$SOCK21" > "$OUT.m18stats" 2>&1
234 grep -q 'sessions=3' "$OUT.m18stats" || {
235 echo "e2e FAIL: M18: stats does not see three sessions:"
236 cat "$OUT.m18stats"; exit 1; }
237 for _s in a b; do
238 grep -q "session $_s clients=" "$OUT.m18stats" || {
239 echo "e2e FAIL: M18: stats has no segment for session $_s:"
240 cat "$OUT.m18stats"; exit 1; }
241 done
242
243 # An unknown name is ANSWERED, in words, on the dump's own channel — not a
244 # hang and not a fabricated "connection lost" (decision 9). The exit code
245 # is 0 because the daemon replied; the text is the whole point.
246 dump_session "$SOCK21" zz > "$OUT.m18zz" 2>&1
247 grep -q 'muxd: no such session: zz' "$OUT.m18zz" || {
248 echo "e2e FAIL: M18: an unknown session did not say so; dump answered:"
249 cat "$OUT.m18zz"; exit 1; }
250
251 # muxa addresses a session by name on the verbs that never attach at all.
252 # status_req and debug_dump grew the same name tail attach did (decision
253 # 14), and that is what lets an agent ask about one session of several
254 # without claiming a grid — muxa attaches at 0x0 or not at all, so without
255 # the tail it could only ever have answered for the default session.
256 set +e
257 timeout 20 "$MUXA" status --sock "$SOCK21" --session b > "$OUT.m18mst" 2>&1
258 RC=$?
259 set -e
260 [ "$RC" -eq 0 ] || {
261 echo "e2e FAIL: M18: muxa status --session b exited $RC:"
262 cat "$OUT.m18mst"; exit 1; }
263 # Not the exit code alone: a silently empty object would pass that. `cols`
264 # is a field only a decoded StatusReply can put there (agent.sh's shape).
265 grep -q '"cols"' "$OUT.m18mst" || {
266 echo "e2e FAIL: M18: muxa status --session b answered without a status reply:"
267 cat "$OUT.m18mst"; exit 1; }
268
269 set +e
270 timeout 20 "$MUXA" capture --sock "$SOCK21" --session a > "$OUT.m18mcap" 2>&1
271 RC=$?
272 set -e
273 [ "$RC" -eq 0 ] || {
274 echo "e2e FAIL: M18: muxa capture --session a exited $RC:"
275 cat "$OUT.m18mcap"; exit 1; }
276 grep -q 'm18a-pin' "$OUT.m18mcap" || {
277 echo "e2e FAIL: M18: muxa capture --session a did not return a's grid:"
278 cat "$OUT.m18mcap"; exit 1; }
279 if grep -q 'm18b-pin' "$OUT.m18mcap"; then
280 echo "e2e FAIL: M18: muxa capture --session a returned b's grid too:"
281 cat "$OUT.m18mcap"; exit 1
282 fi
283
284 # Lifetime: a session ends when ITS shell exits, and the daemon outlives
285 # every death but the last (decision 8). This client attaches to a session
286 # that ALREADY EXISTS — attach-or-create's join arm, the other half of the
287 # two creations above — and types the exit that ends it.
288 pipe_mux "$OUT.m18ax" "$OUT.m18ax.err" timeout 40 "$MUX" --sock "$SOCK21" --session a
289 pipe_send 'exit\n'
290 pipe_waitexit "M18: the client of an exiting session"
291
292 wait_sessions "$SOCK21" 2 "M18: session a's shell exited"
293
294 # The daemon is still serving, and b is untouched by its neighbour's
295 # death: the death freed ONE session, which at N=1 would have been the
296 # daemon's own exit.
297 if dump_session "$SOCK21" a | grep -q "m18a-pin"; then
298 echo "e2e FAIL: M18: session a still answers after its shell exited:"
299 dump_session "$SOCK21" a; exit 1
300 fi
301 dump_session "$SOCK21" b | grep -q "m18b-pin" || {
302 echo "e2e FAIL: M18: session b lost its grid when session a died:"
303 dump_session "$SOCK21" b; exit 1; }
304
305 assert_stopped "$SOCK21" "$D18PID" "M18 multi-session" "$OUT.m18stop"
306 D18PID=""
307 ok "two sessions on one daemon are two shells; one dies without the other"
308
309 # --- M18 on the wall: one socket, two tiles, two sessions ---------------
310 # What the milestone was FOR (decision 2): the wall showing the same host
311 # twice. Both tiles name the same socket path and differ only in the
312 # `#NAME` suffix, so the hub dials it twice and the two connections are
313 # two sessions — the browser's spelling of everything the block above
314 # proved with a CLI.
315 start_daemon "$SOCK22" "$OUT.m18w.d" "M18 wall daemon never bound" --shell /bin/sh
316 D19PID=$DPID
317 "$MUXWEB" --sock "$SOCK22#a" --sock "$SOCK22#b" --port "$WPORT3" > "$OUT.m18wh" 2>&1 &
318 W3PID=$!
319 defer_kill "$W3PID"
320 wait_for "$OUT.m18wh" "serving" 10 || {
321 echo "e2e FAIL: M18 wall hub never reported serving"; cat "$OUT.m18wh"; exit 1; }
322
323 # Content for each session, planted by a CLI client and left behind: the
324 # tiles are passive 0x0 wall tiles and can never type anything themselves.
325 pipe_mux "$OUT.m18wa" "$OUT.m18wa.err" timeout 40 "$MUX" --sock "$SOCK22" --session a
326 pipe_send 'printf "wall-a-%%s\\n" pin\n'
327 await_out "$OUT.m18wa" "wall-a-pin" "M18 wall: session a's marker never reached the client"
328 pipe_detach
329 wait_grid "$SOCK22" "wall-a-pin" "M18 wall: session a's marker" a
330 pipe_mux "$OUT.m18wb" "$OUT.m18wb.err" timeout 40 "$MUX" --sock "$SOCK22" --session b
331 pipe_send 'printf "wall-b-%%s\\n" pin\n'
332 await_out "$OUT.m18wb" "wall-b-pin" "M18 wall: session b's marker never reached the client"
333 pipe_detach
334 wait_grid "$SOCK22" "wall-b-pin" "M18 wall: session b's marker" b
335
336 # Tile 0 is session a's; tile 1 is session b's. The stand-in spells the
337 # name the way mux.js does — bytes appended after the fixed 20 — and
338 # attaches at 0x0, the passivity contract: both sessions already exist, so
339 # these are joins, and a slot that claimed no size can never move the grid.
340 for _t in 0 1; do
341 case "$_t" in
342 0) _sn=a; _mine=wall-a-pin; _theirs=wall-b-pin ;;
343 *) _sn=b; _mine=wall-b-pin; _theirs=wall-a-pin ;;
344 esac
345 set +e
346 timeout 40 "$WSCLIENT" --port "$WPORT3" --tile "$_t" \
347 --out "$OUT.m18ws$_t" --err "$OUT.m18ws$_t.err" <<EOF
348 attach 0 0 $_sn
349 expectstate up 10000
350 expectgrid $_mine 15000
351 settle 500 10000
352 dumpexit
353 EOF
354 RC=$?
355 set -e
356 [ "$RC" -eq 0 ] || {
357 echo "e2e FAIL: M18 wall: tile $_t (session $_sn) wsclient exited $RC"
358 cat -v "$OUT.m18ws$_t.err" 2>/dev/null; cat "$OUT.m18wh"; exit 1; }
359
360 # The replica converges on ITS session's grid...
361 assert_ws_converged "$OUT.m18ws$_t" "$SOCK22" "M18 wall: tile $_t" "$_sn"
362 # ...and the other session's content never crossed into it. The
363 # convergence check above is a diff against one grid and would pass on
364 # a hub that fanned every session's frames to every tile only if that
365 # grid happened to match; this grep is the direct question.
366 if grep -q "$_theirs" "$OUT.m18ws$_t"; then
367 echo "e2e FAIL: M18 wall: tile $_t (session $_sn) received the other session's content:"
368 cat "$OUT.m18ws$_t"; exit 1
369 fi
370 done
371
372 softkill "$W3PID" || true
373 wait_pid_gone "$W3PID" "M18 wall: hub killed by tracked pid"
374 W3PID=""
375 assert_stopped "$SOCK22" "$D19PID" "M18 wall" "$OUT.m18wstop"
376 D19PID=""
377 ok "the wall shows one host twice: two tiles, two sessions, one socket"
378
379 # --- M18 over QUIC: two dials are two sessions, and a name means one -----
380 # --- session whichever transport carried the attach ----------------------
381 # Decision 6 — a connection IS a session on EVERY transport — and QUIC is
382 # where that claim is least obvious. A QUIC client is promoted to a client
383 # slot when its HANDSHAKE completes, before its attach has arrived, so its
384 # slot starts session-less and the name on the attach is the only thing
385 # that ever binds it. Two dials to one port therefore have to come out as
386 # two shells, and the block above proves nothing about this one: it went
387 # through a unix socket, where the slot and the attach arrive together.
388 #
389 # The second half is the operator's question rather than the protocol's.
390 # The session table belongs to the DAEMON, not to the listener that
391 # accepted a connection — so `a` must name the same shell whether it was
392 # reached over QUIC or over the socket. Nothing else in this suite asks a
393 # daemon anything with two transports open at once.
394 head -c 32 /dev/urandom > "$M18KEY"
395 chmod 600 "$M18KEY"
396 # The port is an OBSERVATION, not a derivation (decisions.md, hygiene kit).
397 # This band sits INSIDE /proc/sys/net/ipv4/ip_local_port_range, and the
398 # scenarios above open plenty of outbound QUIC sockets — one of them can
399 # already hold the candidate by the time this daemon asks for it. Soak
400 # caught exactly that, once in ten runs: `muxd: a daemon is already
401 # listening on udp 127.0.0.1:57943`, from a client socket, not a daemon.
402 #
403 # So step on the daemon's own refusal until one binds, the way agent.sh's
404 # start_quic does. Anything OTHER than that refusal is a real failure and
405 # is reported with the daemon's own words; only the taken-port case walks.
406 M18_TRIES=0
407 while : ; do
408 "$MUXD" run --sock "$SOCK23" --quic "127.0.0.1:$QPORT5" --key "$M18KEY" \
409 --quic-idle-ms 15000 --shell /bin/sh > "$OUT.m18q.d" 2>&1 &
410 D20PID=$!
411 defer_kill "$D20PID"
412 _i=0
413 while [ ! -S "$SOCK23" ] && kill -0 "$D20PID" 2>/dev/null && [ "$_i" -lt 60 ]; do
414 sleep 0.1; _i=$((_i + 1))
415 done
416 [ -S "$SOCK23" ] && break
417 grep -q 'already listening on udp' "$OUT.m18q.d" || {
418 echo "e2e FAIL: M18 quic daemon never bound its session socket"
419 cat "$OUT.m18q.d"; exit 1; }
420 wait "$D20PID" 2>/dev/null || true
421 D20PID=""
422 M18_TRIES=$((M18_TRIES + 1))
423 [ "$M18_TRIES" -lt 8 ] || {
424 echo "e2e FAIL: M18 quic: 8 candidate udp ports were taken, last $QPORT5"
425 exit 1; }
426 # A step that is not a multiple of any other band's spacing, so a walk
427 # from here cannot march into the ports another scenario reserved.
428 QPORT5=$((QPORT5 + 137))
429 done
430
431 pipe_mux "$OUT.m18qa" "$OUT.m18qa.err" timeout 40 "$MUX" "quic://127.0.0.1:$QPORT5" --key "$M18KEY" --quic-idle-ms 15000 --session a
432 pipe_send 'printf "q18a-%%s\\n" pin\n'
433 await_out "$OUT.m18qa" "q18a-pin" "q18a-pin never reached the client"
434 pipe_detach "M18 quic: the --session a dial"
435 wait_grid "$SOCK23" "q18a-pin" "M18 quic: session a's marker" a
436
437 pipe_mux "$OUT.m18qb" "$OUT.m18qb.err" timeout 40 "$MUX" "quic://127.0.0.1:$QPORT5" --key "$M18KEY" --quic-idle-ms 15000 --session b
438 pipe_send 'printf "q18b-%%s\\n" pin\n'
439 await_out "$OUT.m18qb" "q18b-pin" "q18b-pin never reached the client"
440 pipe_detach "M18 quic: the --session b dial"
441 wait_grid "$SOCK23" "q18b-pin" "M18 quic: session b's marker" b
442
443 if dump_session "$SOCK23" a | grep -q "q18b-pin"; then
444 echo "e2e FAIL: M18 quic: b's marker is on a's grid — one session, not two:"
445 dump_session "$SOCK23" a; exit 1
446 fi
447 if dump_session "$SOCK23" b | grep -q "q18a-pin"; then
448 echo "e2e FAIL: M18 quic: a's marker is on b's grid — one session, not two:"
449 dump_session "$SOCK23" b; exit 1
450 fi
451 "$MUXD" stats --sock "$SOCK23" | grep -q 'sessions=3' || {
452 echo "e2e FAIL: M18 quic: two dials did not make two sessions beside the default:"
453 "$MUXD" stats --sock "$SOCK23"; exit 1; }
454
455 # The cross-transport join. Same name, different door: this must land in
456 # the shell the QUIC client left behind, not spawn a second one.
457 pipe_mux "$OUT.m18qx" "$OUT.m18qx.err" timeout 40 "$MUX" --sock "$SOCK23" --session a
458 pipe_send 'printf "x18a-%%s\\n" pin\n'
459 await_out "$OUT.m18qx" "x18a-pin" "x18a-pin never reached the client"
460 pipe_detach "M18 quic: the socket client of session a"
461 wait_grid "$SOCK23" "x18a-pin" "M18 quic: the socket client's marker in a" a
462
463 # Both markers on one grid is the whole assertion: the QUIC client's
464 # output and the socket client's, in the session they both named.
465 dump_session "$SOCK23" a | grep -q "q18a-pin" || {
466 echo "e2e FAIL: M18 quic: joining 'a' over the socket did not find the QUIC dial's shell:"
467 dump_session "$SOCK23" a; exit 1; }
468 # ...and it JOINED rather than created: a fourth session here would mean
469 # the name resolved per-transport, which is the bug this pair exists for.
470 "$MUXD" stats --sock "$SOCK23" | grep -q 'sessions=3' || {
471 echo "e2e FAIL: M18 quic: a socket join of an existing name made a new session:"
472 "$MUXD" stats --sock "$SOCK23"; exit 1; }
473 if dump_session "$SOCK23" b | grep -q "x18a-pin"; then
474 echo "e2e FAIL: M18 quic: the socket client's marker leaked into b:"
475 dump_session "$SOCK23" b; exit 1
476 fi
477
478 assert_stopped "$SOCK23" "$D20PID" "M18 quic multi-session" "$OUT.m18qstop"
479 D20PID=""
480 ok "quic: two dials are two sessions, and one name is one session on either transport"
481
482 # The pipe_mux client in flight, and its FIFO. Both are registered by 148 # The pipe_mux client in flight, and its FIFO. Both are registered by
483 # pipe_mux itself; its `timeout` stays the primary guarantee and the trap 149 # pipe_mux itself; its `timeout` stays the primary guarantee and the trap
484 # is the backstop for a scenario that exits between pipe_mux and 150 # is the backstop for a scenario that exits between pipe_mux and
test/e2e_05_session.sh
Old New
@@ -0,0 +1,337 @@
1 # shellcheck shell=sh
2 # e2e_05_session.sh — sourced by test/e2e.sh after e2e_lib.sh. Scenarios run in
3 # the order they stand in; see the lib's header for what this file may
4 # assume and what it must register.
5 # M18. Two daemons — the CLI multi-session block's and the wall's — and one
6 # more hub on the next 5000-spaced port. Only two sockets for four-and-some
7 # sessions, which is the whole point of the milestone: sessions are named on
8 # one socket, not spread across one socket each.
9 SOCK21="${TMPDIR:-/tmp}/muxd-e2e-m18-$$.sock"
10 defer_sock "$SOCK21"
11 SOCK22="${TMPDIR:-/tmp}/muxd-e2e-m18web-$$.sock"
12 defer_sock "$SOCK22"
13 WPORT3=$(( 51000 + ($$ % 4000) ))
14 # ...and a third, the only M18 daemon holding a UDP port: it serves the
15 # session socket AND a QUIC listener at once, which is what lets one block
16 # ask whether a session name means the same session on either transport.
17 # Next 5000-spaced band after WPORT3.
18 SOCK23="${TMPDIR:-/tmp}/muxd-e2e-m18quic-$$.sock"
19 defer_sock "$SOCK23"
20 QPORT5=$(( 56000 + ($$ % 4000) ))
21 # A key of its own rather than the M8 block's $QKEY: that one is rm_swept
22 # the moment the M8 scenarios finish (see the sweep after the key-source
23 # block), which is hundreds of lines before this one runs. Reaching for a
24 # deleted file would make this block's daemon fail to bind for a reason
25 # that has nothing to do with sessions.
26 M18KEY="${TMPDIR:-/tmp}/mux-e2e-m18key-$$"
27 defer_rm "$M18KEY"
28
29 # --- M18: two sessions on one daemon are two shells ---------------------
30 # The milestone, observed from outside: ONE daemon, one socket path, one
31 # key — and two sessions that share nothing. A connection IS a session
32 # (decision 6), so the two clients below differ in exactly one flag, and
33 # every instrument here is asked to PROVE the separation rather than
34 # assumed to have it: for each session both a positive grep (its own
35 # marker is here) and a negative one (the other's is not), because "a
36 # holds a's marker" is satisfied just as well by a single shared shell.
37 #
38 # Ordered ahead of the M-web block on the legible-catch rule. The wall
39 # scenarios below now attach BY NAME, and a regression in name resolution
40 # would hang one of them with nothing printed — fifteen seconds into a
41 # scenario whose label says "WebSocket". This block reaches the same
42 # resolver with a CLI in front of it, and fails there first.
43 start_daemon "$SOCK21" "$OUT.m18.d" "M18 multi-session daemon never bound" --shell /bin/sh
44 D18PID=$DPID
45
46 # Two clients, two names, one socket. Each plants a marker its own shell
47 # has to EXPAND — the typed line reads `printf "m18a-%s\n" pin` and only
48 # the output reads `m18a-pin`, so a grep that hits is the shell's work and
49 # not an echo of our keystrokes (the tiny-attach block's trick).
50 #
51 # Both run in the foreground and DETACH (\034\034, the Ctrl-\ chord) rather than staying
52 # up: the session has to outlive its client for every assertion below to
53 # mean anything. A session ends when its shell exits, not when its last
54 # client leaves (decision 8) — so these greps run against two sessions
55 # with nobody attached, which is also why none of their answers can be
56 # explained by a live client holding something open.
57 pipe_mux "$OUT.m18a" "$OUT.m18a.err" timeout 40 "$MUX" --sock "$SOCK21" --session a
58 pipe_send 'printf "m18a-%%s\\n" pin\n'
59 await_out "$OUT.m18a" "m18a-pin" "m18a-pin never reached the client"
60 pipe_detach
61 wait_grid "$SOCK21" "m18a-pin" "M18: session a's marker" a
62
63 pipe_mux "$OUT.m18b" "$OUT.m18b.err" timeout 40 "$MUX" --sock "$SOCK21" --session b
64 pipe_send 'printf "m18b-%%s\\n" pin\n'
65 await_out "$OUT.m18b" "m18b-pin" "m18b-pin never reached the client"
66 pipe_detach
67 wait_grid "$SOCK21" "m18b-pin" "M18: session b's marker" b
68
69 # Each session's grid holds its own marker and NOT the other's.
70 if dump_session "$SOCK21" a | grep -q "m18b-pin"; then
71 echo "e2e FAIL: M18: b's marker is on a's grid — one shell, not two:"
72 dump_session "$SOCK21" a; exit 1
73 fi
74 if dump_session "$SOCK21" b | grep -q "m18a-pin"; then
75 echo "e2e FAIL: M18: a's marker is on b's grid — one shell, not two:"
76 dump_session "$SOCK21" b; exit 1
77 fi
78
79 # The default session was created at daemon init and nobody ever typed
80 # into it. It is the third session, and its emptiness is the assertion
81 # that `--session` ROUTES rather than decorating one shared grid.
82 if dump_session "$SOCK21" | grep -q -e "m18a-pin" -e "m18b-pin"; then
83 echo "e2e FAIL: M18: a named session's marker reached the default grid:"
84 dump_session "$SOCK21"; exit 1
85 fi
86
87 # stats names all three, and names them one segment each. `sessions=3` on
88 # its own would survive a daemon that counted right and routed wrong, so
89 # the per-session segments are grepped by name too.
90 "$MUXD" stats --sock "$SOCK21" > "$OUT.m18stats" 2>&1
91 grep -q 'sessions=3' "$OUT.m18stats" || {
92 echo "e2e FAIL: M18: stats does not see three sessions:"
93 cat "$OUT.m18stats"; exit 1; }
94 for _s in a b; do
95 grep -q "session $_s clients=" "$OUT.m18stats" || {
96 echo "e2e FAIL: M18: stats has no segment for session $_s:"
97 cat "$OUT.m18stats"; exit 1; }
98 done
99
100 # An unknown name is ANSWERED, in words, on the dump's own channel — not a
101 # hang and not a fabricated "connection lost" (decision 9). The exit code
102 # is 0 because the daemon replied; the text is the whole point.
103 dump_session "$SOCK21" zz > "$OUT.m18zz" 2>&1
104 grep -q 'muxd: no such session: zz' "$OUT.m18zz" || {
105 echo "e2e FAIL: M18: an unknown session did not say so; dump answered:"
106 cat "$OUT.m18zz"; exit 1; }
107
108 # muxa addresses a session by name on the verbs that never attach at all.
109 # status_req and debug_dump grew the same name tail attach did (decision
110 # 14), and that is what lets an agent ask about one session of several
111 # without claiming a grid — muxa attaches at 0x0 or not at all, so without
112 # the tail it could only ever have answered for the default session.
113 set +e
114 timeout 20 "$MUXA" status --sock "$SOCK21" --session b > "$OUT.m18mst" 2>&1
115 RC=$?
116 set -e
117 [ "$RC" -eq 0 ] || {
118 echo "e2e FAIL: M18: muxa status --session b exited $RC:"
119 cat "$OUT.m18mst"; exit 1; }
120 # Not the exit code alone: a silently empty object would pass that. `cols`
121 # is a field only a decoded StatusReply can put there (agent.sh's shape).
122 grep -q '"cols"' "$OUT.m18mst" || {
123 echo "e2e FAIL: M18: muxa status --session b answered without a status reply:"
124 cat "$OUT.m18mst"; exit 1; }
125
126 set +e
127 timeout 20 "$MUXA" capture --sock "$SOCK21" --session a > "$OUT.m18mcap" 2>&1
128 RC=$?
129 set -e
130 [ "$RC" -eq 0 ] || {
131 echo "e2e FAIL: M18: muxa capture --session a exited $RC:"
132 cat "$OUT.m18mcap"; exit 1; }
133 grep -q 'm18a-pin' "$OUT.m18mcap" || {
134 echo "e2e FAIL: M18: muxa capture --session a did not return a's grid:"
135 cat "$OUT.m18mcap"; exit 1; }
136 if grep -q 'm18b-pin' "$OUT.m18mcap"; then
137 echo "e2e FAIL: M18: muxa capture --session a returned b's grid too:"
138 cat "$OUT.m18mcap"; exit 1
139 fi
140
141 # Lifetime: a session ends when ITS shell exits, and the daemon outlives
142 # every death but the last (decision 8). This client attaches to a session
143 # that ALREADY EXISTS — attach-or-create's join arm, the other half of the
144 # two creations above — and types the exit that ends it.
145 pipe_mux "$OUT.m18ax" "$OUT.m18ax.err" timeout 40 "$MUX" --sock "$SOCK21" --session a
146 pipe_send 'exit\n'
147 pipe_waitexit "M18: the client of an exiting session"
148
149 wait_sessions "$SOCK21" 2 "M18: session a's shell exited"
150
151 # The daemon is still serving, and b is untouched by its neighbour's
152 # death: the death freed ONE session, which at N=1 would have been the
153 # daemon's own exit.
154 if dump_session "$SOCK21" a | grep -q "m18a-pin"; then
155 echo "e2e FAIL: M18: session a still answers after its shell exited:"
156 dump_session "$SOCK21" a; exit 1
157 fi
158 dump_session "$SOCK21" b | grep -q "m18b-pin" || {
159 echo "e2e FAIL: M18: session b lost its grid when session a died:"
160 dump_session "$SOCK21" b; exit 1; }
161
162 assert_stopped "$SOCK21" "$D18PID" "M18 multi-session" "$OUT.m18stop"
163 D18PID=""
164 ok "two sessions on one daemon are two shells; one dies without the other"
165
166 # --- M18 on the wall: one socket, two tiles, two sessions ---------------
167 # What the milestone was FOR (decision 2): the wall showing the same host
168 # twice. Both tiles name the same socket path and differ only in the
169 # `#NAME` suffix, so the hub dials it twice and the two connections are
170 # two sessions — the browser's spelling of everything the block above
171 # proved with a CLI.
172 start_daemon "$SOCK22" "$OUT.m18w.d" "M18 wall daemon never bound" --shell /bin/sh
173 D19PID=$DPID
174 "$MUXWEB" --sock "$SOCK22#a" --sock "$SOCK22#b" --port "$WPORT3" > "$OUT.m18wh" 2>&1 &
175 W3PID=$!
176 defer_kill "$W3PID"
177 wait_for "$OUT.m18wh" "serving" 10 || {
178 echo "e2e FAIL: M18 wall hub never reported serving"; cat "$OUT.m18wh"; exit 1; }
179
180 # Content for each session, planted by a CLI client and left behind: the
181 # tiles are passive 0x0 wall tiles and can never type anything themselves.
182 pipe_mux "$OUT.m18wa" "$OUT.m18wa.err" timeout 40 "$MUX" --sock "$SOCK22" --session a
183 pipe_send 'printf "wall-a-%%s\\n" pin\n'
184 await_out "$OUT.m18wa" "wall-a-pin" "M18 wall: session a's marker never reached the client"
185 pipe_detach
186 wait_grid "$SOCK22" "wall-a-pin" "M18 wall: session a's marker" a
187 pipe_mux "$OUT.m18wb" "$OUT.m18wb.err" timeout 40 "$MUX" --sock "$SOCK22" --session b
188 pipe_send 'printf "wall-b-%%s\\n" pin\n'
189 await_out "$OUT.m18wb" "wall-b-pin" "M18 wall: session b's marker never reached the client"
190 pipe_detach
191 wait_grid "$SOCK22" "wall-b-pin" "M18 wall: session b's marker" b
192
193 # Tile 0 is session a's; tile 1 is session b's. The stand-in spells the
194 # name the way mux.js does — bytes appended after the fixed 20 — and
195 # attaches at 0x0, the passivity contract: both sessions already exist, so
196 # these are joins, and a slot that claimed no size can never move the grid.
197 for _t in 0 1; do
198 case "$_t" in
199 0) _sn=a; _mine=wall-a-pin; _theirs=wall-b-pin ;;
200 *) _sn=b; _mine=wall-b-pin; _theirs=wall-a-pin ;;
201 esac
202 set +e
203 timeout 40 "$WSCLIENT" --port "$WPORT3" --tile "$_t" \
204 --out "$OUT.m18ws$_t" --err "$OUT.m18ws$_t.err" <<EOF
205 attach 0 0 $_sn
206 expectstate up 10000
207 expectgrid $_mine 15000
208 settle 500 10000
209 dumpexit
210 EOF
211 RC=$?
212 set -e
213 [ "$RC" -eq 0 ] || {
214 echo "e2e FAIL: M18 wall: tile $_t (session $_sn) wsclient exited $RC"
215 cat -v "$OUT.m18ws$_t.err" 2>/dev/null; cat "$OUT.m18wh"; exit 1; }
216
217 # The replica converges on ITS session's grid...
218 assert_ws_converged "$OUT.m18ws$_t" "$SOCK22" "M18 wall: tile $_t" "$_sn"
219 # ...and the other session's content never crossed into it. The
220 # convergence check above is a diff against one grid and would pass on
221 # a hub that fanned every session's frames to every tile only if that
222 # grid happened to match; this grep is the direct question.
223 if grep -q "$_theirs" "$OUT.m18ws$_t"; then
224 echo "e2e FAIL: M18 wall: tile $_t (session $_sn) received the other session's content:"
225 cat "$OUT.m18ws$_t"; exit 1
226 fi
227 done
228
229 softkill "$W3PID" || true
230 wait_pid_gone "$W3PID" "M18 wall: hub killed by tracked pid"
231 W3PID=""
232 assert_stopped "$SOCK22" "$D19PID" "M18 wall" "$OUT.m18wstop"
233 D19PID=""
234 ok "the wall shows one host twice: two tiles, two sessions, one socket"
235
236 # --- M18 over QUIC: two dials are two sessions, and a name means one -----
237 # --- session whichever transport carried the attach ----------------------
238 # Decision 6 — a connection IS a session on EVERY transport — and QUIC is
239 # where that claim is least obvious. A QUIC client is promoted to a client
240 # slot when its HANDSHAKE completes, before its attach has arrived, so its
241 # slot starts session-less and the name on the attach is the only thing
242 # that ever binds it. Two dials to one port therefore have to come out as
243 # two shells, and the block above proves nothing about this one: it went
244 # through a unix socket, where the slot and the attach arrive together.
245 #
246 # The second half is the operator's question rather than the protocol's.
247 # The session table belongs to the DAEMON, not to the listener that
248 # accepted a connection — so `a` must name the same shell whether it was
249 # reached over QUIC or over the socket. Nothing else in this suite asks a
250 # daemon anything with two transports open at once.
251 head -c 32 /dev/urandom > "$M18KEY"
252 chmod 600 "$M18KEY"
253 # The port is an OBSERVATION, not a derivation (decisions.md, hygiene kit).
254 # This band sits INSIDE /proc/sys/net/ipv4/ip_local_port_range, and the
255 # scenarios above open plenty of outbound QUIC sockets — one of them can
256 # already hold the candidate by the time this daemon asks for it. Soak
257 # caught exactly that, once in ten runs: `muxd: a daemon is already
258 # listening on udp 127.0.0.1:57943`, from a client socket, not a daemon.
259 #
260 # So step on the daemon's own refusal until one binds, the way agent.sh's
261 # start_quic does. Anything OTHER than that refusal is a real failure and
262 # is reported with the daemon's own words; only the taken-port case walks.
263 M18_TRIES=0
264 while : ; do
265 "$MUXD" run --sock "$SOCK23" --quic "127.0.0.1:$QPORT5" --key "$M18KEY" \
266 --quic-idle-ms 15000 --shell /bin/sh > "$OUT.m18q.d" 2>&1 &
267 D20PID=$!
268 defer_kill "$D20PID"
269 _i=0
270 while [ ! -S "$SOCK23" ] && kill -0 "$D20PID" 2>/dev/null && [ "$_i" -lt 60 ]; do
271 sleep 0.1; _i=$((_i + 1))
272 done
273 [ -S "$SOCK23" ] && break
274 grep -q 'already listening on udp' "$OUT.m18q.d" || {
275 echo "e2e FAIL: M18 quic daemon never bound its session socket"
276 cat "$OUT.m18q.d"; exit 1; }
277 wait "$D20PID" 2>/dev/null || true
278 D20PID=""
279 M18_TRIES=$((M18_TRIES + 1))
280 [ "$M18_TRIES" -lt 8 ] || {
281 echo "e2e FAIL: M18 quic: 8 candidate udp ports were taken, last $QPORT5"
282 exit 1; }
283 # A step that is not a multiple of any other band's spacing, so a walk
284 # from here cannot march into the ports another scenario reserved.
285 QPORT5=$((QPORT5 + 137))
286 done
287
288 pipe_mux "$OUT.m18qa" "$OUT.m18qa.err" timeout 40 "$MUX" "quic://127.0.0.1:$QPORT5" --key "$M18KEY" --quic-idle-ms 15000 --session a
289 pipe_send 'printf "q18a-%%s\\n" pin\n'
290 await_out "$OUT.m18qa" "q18a-pin" "q18a-pin never reached the client"
291 pipe_detach "M18 quic: the --session a dial"
292 wait_grid "$SOCK23" "q18a-pin" "M18 quic: session a's marker" a
293
294 pipe_mux "$OUT.m18qb" "$OUT.m18qb.err" timeout 40 "$MUX" "quic://127.0.0.1:$QPORT5" --key "$M18KEY" --quic-idle-ms 15000 --session b
295 pipe_send 'printf "q18b-%%s\\n" pin\n'
296 await_out "$OUT.m18qb" "q18b-pin" "q18b-pin never reached the client"
297 pipe_detach "M18 quic: the --session b dial"
298 wait_grid "$SOCK23" "q18b-pin" "M18 quic: session b's marker" b
299
300 if dump_session "$SOCK23" a | grep -q "q18b-pin"; then
301 echo "e2e FAIL: M18 quic: b's marker is on a's grid — one session, not two:"
302 dump_session "$SOCK23" a; exit 1
303 fi
304 if dump_session "$SOCK23" b | grep -q "q18a-pin"; then
305 echo "e2e FAIL: M18 quic: a's marker is on b's grid — one session, not two:"
306 dump_session "$SOCK23" b; exit 1
307 fi
308 "$MUXD" stats --sock "$SOCK23" | grep -q 'sessions=3' || {
309 echo "e2e FAIL: M18 quic: two dials did not make two sessions beside the default:"
310 "$MUXD" stats --sock "$SOCK23"; exit 1; }
311
312 # The cross-transport join. Same name, different door: this must land in
313 # the shell the QUIC client left behind, not spawn a second one.
314 pipe_mux "$OUT.m18qx" "$OUT.m18qx.err" timeout 40 "$MUX" --sock "$SOCK23" --session a
315 pipe_send 'printf "x18a-%%s\\n" pin\n'
316 await_out "$OUT.m18qx" "x18a-pin" "x18a-pin never reached the client"
317 pipe_detach "M18 quic: the socket client of session a"
318 wait_grid "$SOCK23" "x18a-pin" "M18 quic: the socket client's marker in a" a
319
320 # Both markers on one grid is the whole assertion: the QUIC client's
321 # output and the socket client's, in the session they both named.
322 dump_session "$SOCK23" a | grep -q "q18a-pin" || {
323 echo "e2e FAIL: M18 quic: joining 'a' over the socket did not find the QUIC dial's shell:"
324 dump_session "$SOCK23" a; exit 1; }
325 # ...and it JOINED rather than created: a fourth session here would mean
326 # the name resolved per-transport, which is the bug this pair exists for.
327 "$MUXD" stats --sock "$SOCK23" | grep -q 'sessions=3' || {
328 echo "e2e FAIL: M18 quic: a socket join of an existing name made a new session:"
329 "$MUXD" stats --sock "$SOCK23"; exit 1; }
330 if dump_session "$SOCK23" b | grep -q "x18a-pin"; then
331 echo "e2e FAIL: M18 quic: the socket client's marker leaked into b:"
332 dump_session "$SOCK23" b; exit 1
333 fi
334
335 assert_stopped "$SOCK23" "$D20PID" "M18 quic multi-session" "$OUT.m18qstop"
336 D20PID=""
337 ok "quic: two dials are two sessions, and one name is one session on either transport"