a73x

ff52fa6d

test: 13_birth moves to a file of its own

a73x   2026-08-26 19:18

Commit message
test: 13_birth moves to a file of its own

a tile born by spelling, and the saved line that comes back — 5 scenarios, 710 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 05_session 06_web 07_wallcli 08_mouse 09_wallhist 10_agent 11_select 12_panes' 105 E2E_GROUPS='01_boot 02_predict 03_side 04_handoff 05_session 06_web 07_wallcli 08_mouse 09_wallhist 10_agent 11_select 12_panes 13_birth'
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,713 +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 # The hydrated-create leg. One daemon, and it serves QUIC as well as its
149 # socket: the leg's whole point is the LOCAL/remote split, so both halves
150 # have to be the same run of the same daemon or the comparison is between
151 # two rigs rather than between two kinds of line. Short socket name on
152 # purpose — the assertion reads a label bar back off a 70-column render.
153 SOCK64="${TMPDIR:-/tmp}/muxd-e2e-hyd-$$.sock"
154 defer_sock "$SOCK64"
155 # Base 6000, below the 5000-spaced band the other QUIC ports share: every
156 # base from 11000 up is already taken, and a DUPLICATED base passes only
157 # for as long as the other leg's daemon happens to be dead by the time
158 # this one binds. A collision reads as this daemon's failure, not the
159 # suite's, which is why the base has to be the leg's own.
160 HYPORT=$(( 6000 + ($$ % 4000) ))
161 HYKEY="${TMPDIR:-/tmp}/mux-e2e-hydkey-$$"
162 defer_rm "$HYKEY"
163 HYSTATE="${TMPDIR:-/tmp}/mux-e2e-hyd-state-$$"
164 defer_rm "$HYSTATE"
165 HYWALL="$HYSTATE/mux/wall"
166 # The refused-attach leg: its own daemon, because its assertions are about
167 # what a daemon says to a name it does NOT have, and a socket shared with
168 # another leg would let that leg's sessions decide what "does not have"
169 # means.
170 SOCK66="${TMPDIR:-/tmp}/muxd-e2e-refuse-$$.sock"
171 defer_sock "$SOCK66"
172 REFSTATE="${TMPDIR:-/tmp}/mux-e2e-refuse-state-$$"
173 defer_rm "$REFSTATE"
174 # The browser half of the same ruling: one daemon serving a socket AND a
175 # QUIC listener, one hub, a state home of its own. Two ports, and the
176 # 5000-spaced bands are exhausted — 61000 is the last one that fits under
177 # 65535 — so this leg takes the tail ABOVE the ephemeral range
178 # (ip_local_port_range tops out at 60999 by default), halved. The narrow
179 # `%250` is the price of that tail: two runs whose pids differ by 250
180 # collide where the older bands need 4000. A collision reads as this leg's
181 # daemon failing to bind, which is also why the two halves cannot share a
182 # base.
183 SOCK67="${TMPDIR:-/tmp}/muxd-e2e-wg-$$.sock"
184 defer_sock "$SOCK67"
185 WGPORT=$(( 65000 + ($$ % 250) ))
186 WGQPORT=$(( 65250 + ($$ % 250) ))
187 WGKEY="${TMPDIR:-/tmp}/mux-e2e-wgkey-$$"
188 defer_rm "$WGKEY"
189 WGSTATE="${TMPDIR:-/tmp}/mux-e2e-wg-state-$$"
190 defer_rm "$WGSTATE"
191 WGWALL="$WGSTATE/mux/wall"
192 # The refusal-spin leg. Its own everything: a shim dir on PATH, a state
193 # home holding the wall it restores, and a dial log the shim appends to.
194 SOCK68="${TMPDIR:-/tmp}/muxd-e2e-sp-$$.sock"
195 defer_sock "$SOCK68"
196 SPPORT=$(( 26000 + ($$ % 4000) ))
197 SPSTATE="${TMPDIR:-/tmp}/mux-e2e-sp-state-$$"
198 defer_rm "$SPSTATE"
199 SPDIR="${TMPDIR:-/tmp}/mux-e2e-sp-shim-$$"
200 defer_rm "$SPDIR"
201 SPINLOG="$SPDIR/dials"
202
203 # ---- Ctrl-\ : adds a tile by spelling ---------------------------------
204 #
205 # Argv typed from inside. A spelling naming a session on ANOTHER daemon
206 # is born beside the focus, creates that session there, takes the focus,
207 # and is recorded into the wall file — mux TARGET's row of the birth
208 # table, without leaving the wall. A bad spelling is a notice and
209 # nothing else (the file does not grow). Esc eats the line: the shell
210 # never sees it, and the next keys reach the session again.
211 #
212 # The prompt echoes what is typed, so the born tile's witness is not its
213 # label on the capture (the echo would match) but a marker typed AFTER
214 # the birth landing in daemon B's session b — a hit is B's shell's work.
215 PRSTATE="${TMPDIR:-/tmp}/mux-e2e-prompt-state-$$"
216 defer_rm "$PRSTATE"
217 PRWALL="$PRSTATE/mux/wall"
218 start_daemon "$SOCK62" "$OUT.pra.d" "prompt daemon A never bound" --shell /bin/sh
219 D63PID=$DPID
220 start_daemon "$SOCK63" "$OUT.prb.d" "prompt daemon B never bound" --shell /bin/sh
221 D64PID=$DPID
222
223 pipe_mux "$OUT.pra" "$OUT.pra.err" timeout 40 "$MUX" --sock "$SOCK62" --session a
224 pipe_send 'printf "pr-%%s\\n" origin\n'
225 await_out "$OUT.pra" "pr-origin" "prompt: session a marker never reached the client"
226 pipe_detach
227 wait_grid "$SOCK62" "pr-origin" "prompt: session a marker" a
228
229 mkdir -p "$PRSTATE/mux"
230 printf -- '--sock %s#a\n' "$SOCK62" > "$PRWALL"
231
232 # The spelling goes in TWO sends with a settle between: the prompt paints
233 # its echo when a read ENDS with the prompt still open, so a spelling that
234 # arrives in the same read as its own \r paints nothing to assert on. The
235 # separating space is \x20 because parseLine trims a payload's trailing one.
236 set +e
237 XDG_STATE_HOME="$PRSTATE" timeout 90 "$PTYCLIENT" --cols 100 --rows 30 \
238 --out "$OUT.prcap" --err "$OUT.prcap.err" -- \
239 "$MUX" wall > "$OUT.prpc" 2>&1 <<EOF
240 expect pr-origin 20000
241 settle 700 20000
242 send \x1c:--sock\x20
243 settle 300 5000
244 send $SOCK63#b\r
245 settle 1000 20000
246 send printf 'pr-born-%s\n' marker\n
247 expect pr-born-marker 15000
248 send \x1c:x#bad name\r
249 expect [bad target 10000
250 settle 500 15000
251 send \x1c:-A nosuchhost.invalid\r
252 expect FlagLikeTarget 10000
253 settle 500 15000
254 send \x1c:zzz\x1b
255 settle 500 15000
256 send printf 'pr-after-%s\n' esc\n
257 expect pr-after-esc 10000
258 settle 500 15000
259 send \x1cd
260 waitexit 10000
261 EOF
262 RC=$?
263 set -e
264 [ "$RC" -eq 0 ] || {
265 echo "e2e FAIL: prompt: ptyclient leg exited $RC (did \\x1c: add the tile?):"
266 cat "$OUT.prpc" "$OUT.prcap.err"; exit 1; }
267 # The oracle, not the byte stream: a banner LEFT on the screen is invisible
268 # to a grep of the emitted bytes — the `: zzz` that painted is in the
269 # capture either way. Only the render's final grid says what the human was
270 # still looking at when the wall came down.
271 "$RENDER" --cols 100 --rows 30 < "$OUT.prcap" > "$OUT.prgrid" || {
272 echo "e2e FAIL: prompt: render oracle failed"
273 cat "$OUT.prgrid"; exit 1; }
274 grep -q ": zzz" "$OUT.prgrid" && {
275 echo "e2e FAIL: prompt: the Esc'd prompt is still on the screen:"
276 cat "$OUT.prgrid"; exit 1; }
277 grep -q "pr-after-esc" "$OUT.prgrid" || {
278 echo "e2e FAIL: prompt: the tile's content did not come back after Esc:"
279 cat "$OUT.prgrid"; exit 1; }
280 # argv's refusal, at argv's other mouth: `-A host` is a mistyped flag and
281 # never becomes a tile for a host named `-A`. The bar is the witness — a
282 # born tile paints its spelling and a state word beside it, and the host
283 # here resolves nowhere, so the tile would sit there saying [refused].
284 grep -q -- "-A nosuchhost.invalid \[" "$OUT.prgrid" && {
285 echo "e2e FAIL: prompt: a flag-like spelling became a tile:"
286 cat "$OUT.prgrid"; exit 1; }
287 # The prompt is a line the typist can read back: -a because the capture is
288 # a terminal stream, escape bytes and all.
289 grep -aq ": --sock " "$OUT.prcap" || {
290 echo "e2e FAIL: prompt: the prompt line never painted its echo:"
291 cat "$OUT.prpc"; exit 1; }
292 # Born on daemon B, focused: the marker typed after the birth is in B's
293 # session b, and so are the keys typed after the Esc.
294 timeout 20 "$MUXA" capture --sock "$SOCK63" --session b > "$OUT.prfb" 2>&1
295 grep -q "pr-born-marker" "$OUT.prfb" || {
296 echo "e2e FAIL: prompt: marker not in daemon B's session b (tile not born there, or not focused):"
297 cat "$OUT.prfb"; exit 1; }
298 grep -q "pr-after-esc" "$OUT.prfb" || {
299 echo "e2e FAIL: prompt: keys after Esc never reached session b (prompt did not close):"
300 cat "$OUT.prfb"; exit 1; }
301 grep -q "zzz" "$OUT.prfb" && {
302 echo "e2e FAIL: prompt: Esc leaked the line into the shell:"
303 cat "$OUT.prfb"; exit 1; }
304 # Recorded, and only the accepted spelling: two lines, the born one among them.
305 grep -qF -- "--sock $SOCK63#b" "$PRWALL" || {
306 echo "e2e FAIL: prompt: the born tile was not recorded:"
307 cat "$PRWALL"; exit 1; }
308 [ "$(grep -c . "$PRWALL")" = "2" ] || {
309 echo "e2e FAIL: prompt: wall file is not exactly two lines (a refused spelling recorded?):"
310 cat "$PRWALL"; exit 1; }
311 assert_stopped "$SOCK62" "$D63PID" "prompt A" "$OUT.prastop"
312 D63PID=""
313 assert_stopped "$SOCK63" "$D64PID" "prompt B" "$OUT.prbstop"
314 D64PID=""
315 rm -rf "$PRSTATE"
316 ok "Ctrl-\\ : adds a tile by spelling: born on another daemon, recorded, refusals narrated, Esc eats the line"
317
318 # ---- a saved local line comes back as a fresh session -------------------
319 #
320 # The wall file is attach history, and the daemon that held those sessions
321 # dies on every reboot. Joining only meant the wall came back as a grid of
322 # [refused] tiles nobody could do anything with but forget them by hand.
323 # A restored line naming the LOCAL socket now attaches-or-creates.
324 #
325 # The remote half is the same wall, the same run and the same daemon,
326 # reached the other way: a `quic://` line for a session that does not
327 # exist stays [refused] and creates nothing. Recreating a shell on your
328 # own box is cheap and expected; spawning one on another host out of a
329 # saved file is not, and the split is the whole ruling.
330 #
331 # Daemon truth, not paint, answers "was it created": `muxa status` on a
332 # name the daemon does not have exits non-zero, which is the same probe
333 # the leg runs BEFORE the wall so it cannot pass vacuously. The render
334 # oracle answers the other question — what the human was looking at —
335 # because a bar left saying [refused] is invisible to a grep of the
336 # emitted bytes.
337 #
338 # Both roads a restored line can take are here, because they are two call
339 # sites of the one rule: the no-argv `mux wall` below runs the startup tile
340 # loop, and the `Ctrl-\ w` half at the end of this leg runs `hydrate()`.
341 head -c 32 /dev/urandom > "$HYKEY"
342 chmod 600 "$HYKEY"
343 start_daemon "$SOCK64" "$OUT.hyd.d" "hydrate-create daemon never bound" --shell /bin/sh \
344 --quic "127.0.0.1:$HYPORT" --key "$HYKEY" --quic-idle-ms 15000
345 D65PID=$DPID
346
347 # The wall file names three sessions this daemon does not have. Written by
348 # hand rather than earned by an attach, because an attach that earned the
349 # line would also have created the session — and then there would be
350 # nothing for the restore to create.
351 mkdir -p "$HYSTATE/mux"
352 printf -- '--sock %s#hya\n--sock %s#hyb\nquic://127.0.0.1:%s#hyghost\n' \
353 "$SOCK64" "$SOCK64" "$HYPORT" > "$HYWALL"
354
355 # Vacuity guard: none of the three exists yet. A leg whose daemon already
356 # had them would pass without the restore creating anything.
357 for _hy in hya hyb hyghost; do
358 timeout 20 "$MUXA" status --sock "$SOCK64" --session "$_hy" > "$OUT.hydpre" 2>&1 && {
359 echo "e2e FAIL: hydrate-create: session $_hy existed before the wall ran:"
360 cat "$OUT.hydpre"; exit 1; }
361 done
362
363 # 70x36: under the aspect rule (70 < 2*36) the cut is stacked, so each of
364 # the three tiles gets the full width and its label bar survives the
365 # render intact — the socket path is in that label.
366 set +e
367 XDG_STATE_HOME="$HYSTATE" timeout 90 "$PTYCLIENT" --cols 70 --rows 36 \
368 --out "$OUT.hydcap" --err "$OUT.hydcap.err" -- \
369 "$MUX" wall --key "$HYKEY" > "$OUT.hydpc" 2>&1 <<'EOF'
370 expect [up] 25000
371 settle 1000 25000
372 send printf 'hyd-%s\n' marker\n
373 expect hyd-marker 15000
374 settle 600 15000
375 send \x1cd
376 waitexit 10000
377 EOF
378 RC=$?
379 set -e
380 [ "$RC" -eq 0 ] || {
381 echo "e2e FAIL: hydrate-create: ptyclient exited $RC:"
382 cat "$OUT.hydpc" "$OUT.hydcap.err"; exit 1; }
383
384 # The local lines created their sessions, at the width of the stripe that
385 # created them. The width does not discriminate create from join on its own
386 # (a join's resize doorbell lands at 70 too, and the daemon refuses a create
387 # under min_session_cols) — the muxa exit code above is what says created.
388 # It is here to catch a session that came up at some OTHER client's size.
389 timeout 20 "$MUXA" status --sock "$SOCK64" --session hya > "$OUT.hydsta" 2>&1 || {
390 echo "e2e FAIL: hydrate-create: the saved line for hya did not create it:"
391 cat "$OUT.hydsta"; exit 1; }
392 grep -q '"cols":70' "$OUT.hydsta" || {
393 echo "e2e FAIL: hydrate-create: hya exists but not at the tile's width:"
394 cat "$OUT.hydsta"; exit 1; }
395 timeout 20 "$MUXA" status --sock "$SOCK64" --session hyb > "$OUT.hydstb" 2>&1 || {
396 echo "e2e FAIL: hydrate-create: the saved line for hyb did not create it:"
397 cat "$OUT.hydstb"; exit 1; }
398
399 # ...and the created session runs a real shell: the marker typed into the
400 # focused tile (tile 0, the first wall line) is the shell's own work.
401 timeout 20 "$MUXA" capture --sock "$SOCK64" --session hya > "$OUT.hydfa" 2>&1
402 grep -q "hyd-marker" "$OUT.hydfa" || {
403 echo "e2e FAIL: hydrate-create: the marker never reached the created session:"
404 cat "$OUT.hydfa"; exit 1; }
405
406 # The remote line created NOTHING. Same daemon, same run, same file — only
407 # the spelling differs.
408 timeout 20 "$MUXA" status --sock "$SOCK64" --session hyghost > "$OUT.hydghost" 2>&1 && {
409 echo "e2e FAIL: hydrate-create: a quic:// wall line created a session:"
410 cat "$OUT.hydghost"; exit 1; }
411
412 # What the human saw: two tiles up, the remote one still refused.
413 "$RENDER" --cols 70 --rows 36 < "$OUT.hydcap" > "$OUT.hydgrid" || {
414 echo "e2e FAIL: hydrate-create: render oracle failed"
415 cat "$OUT.hydgrid"; exit 1; }
416 grep -q -- "#hya \[up\]" "$OUT.hydgrid" || {
417 echo "e2e FAIL: hydrate-create: the hya tile never said [up]:"
418 cat "$OUT.hydgrid"; exit 1; }
419 grep -q -- "#hyb \[up\]" "$OUT.hydgrid" || {
420 echo "e2e FAIL: hydrate-create: the hyb tile never said [up]:"
421 cat "$OUT.hydgrid"; exit 1; }
422 grep -q -- "#hyghost \[refused\]" "$OUT.hydgrid" || {
423 echo "e2e FAIL: hydrate-create: the remote tile did not say [refused]:"
424 cat "$OUT.hydgrid"; exit 1; }
425 # ...and each bar carries the chord that reaches it. Ctrl-\ 1-9 focuses by
426 # POSITION while the bar named the session, and the two series disagree the
427 # moment a tile is forgotten or a session dies. Tile 1 has the focus here,
428 # so its digit carries the `>` and the other two the same-width blank.
429 grep -q -- " 1> .*#hya \[up\]" "$OUT.hydgrid" || {
430 echo "e2e FAIL: hydrate-create: the focused bar does not read \` 1> \`:"
431 cat "$OUT.hydgrid"; exit 1; }
432 grep -q -- " 2 .*#hyb \[up\]" "$OUT.hydgrid" || {
433 echo "e2e FAIL: hydrate-create: the second tile's bar does not read \` 2 \`:"
434 cat "$OUT.hydgrid"; exit 1; }
435 grep -q -- " 3 .*#hyghost \[refused\]" "$OUT.hydgrid" || {
436 echo "e2e FAIL: hydrate-create: a refused tile lost its chord digit:"
437 cat "$OUT.hydgrid"; exit 1; }
438 # The OTHER road a restored line takes: `Ctrl-\ w`. A no-argv `mux wall`
439 # runs the startup tile loop; the fold runs `hydrate()`, a different call
440 # site of the same rule, and the ruling covers both. Same daemon, same
441 # state home, one more ptyclient — a `mux TARGET` is the entry tile, and
442 # the fold brings the rest of the file in around it.
443 #
444 # The file is rewritten to name a session this daemon does not have. The
445 # entry attach then records its OWN line into the same file (attach
446 # history), which the fold dedupes by spelling — so what the fold has left
447 # to add is exactly the absent one, and creating it is the whole claim.
448 printf -- '--sock %s#hyfold\n' "$SOCK64" > "$HYWALL"
449 timeout 20 "$MUXA" status --sock "$SOCK64" --session hyfold > "$OUT.hydpre" 2>&1 && {
450 echo "e2e FAIL: hydrate-create: session hyfold existed before the fold ran:"
451 cat "$OUT.hydpre"; exit 1; }
452
453 set +e
454 XDG_STATE_HOME="$HYSTATE" timeout 90 "$PTYCLIENT" --cols 70 --rows 36 \
455 --out "$OUT.hydfcap" --err "$OUT.hydfcap.err" -- \
456 "$MUX" --sock "$SOCK64" --session hya > "$OUT.hydfpc" 2>&1 <<'EOF'
457 settle 1000 25000
458 send printf 'hydf-%s\n' pre\n
459 expect hydf-pre 20000
460 settle 600 20000
461 send \x1cw
462 settle 1500 25000
463 send \x1cd
464 waitexit 10000
465 EOF
466 RC=$?
467 set -e
468 [ "$RC" -eq 0 ] || {
469 echo "e2e FAIL: hydrate-create: fold ptyclient exited $RC:"
470 cat "$OUT.hydfpc" "$OUT.hydfcap.err"; exit 1; }
471
472 # Daemon truth again: the fold-born tile created the session its line named.
473 timeout 20 "$MUXA" status --sock "$SOCK64" --session hyfold > "$OUT.hydfold" 2>&1 || {
474 echo "e2e FAIL: hydrate-create: the folded line for hyfold did not create it:"
475 cat "$OUT.hydfold"; exit 1; }
476 # ...and it is a tile on the screen, not merely a session on the daemon.
477 "$RENDER" --cols 70 --rows 36 < "$OUT.hydfcap" > "$OUT.hydfgrid" || {
478 echo "e2e FAIL: hydrate-create: fold render oracle failed"
479 cat "$OUT.hydfgrid"; exit 1; }
480 grep -q -- "#hyfold \[up\]" "$OUT.hydfgrid" || {
481 echo "e2e FAIL: hydrate-create: the fold-born tile never said [up]:"
482 cat "$OUT.hydfgrid"; exit 1; }
483
484 assert_stopped "$SOCK64" "$D65PID" "hydrate-create" "$OUT.hydstop"
485 D65PID=""
486 rm -rf "$HYSTATE"
487 ok "a saved local line comes back as a fresh session, by wall and by fold; a remote one joins only"
488
489 # ---------------------------------------------------------------------------
490 # A refused attach is not a shell that exited.
491 #
492 # muxa attaches at 0x0, and a 0x0 attach is join-only: the daemon refuses a
493 # name it does not hold by sending exit_status 1 and closing — the same frame
494 # a real shell's exit sends. muxa used to report that as
495 # `{"reason":"session_ended","exit_code":1}` and `{"error":"session ended"}`,
496 # telling an agent a command had run and failed when no shell ever existed,
497 # while `status` got no answer at all and blamed the daemon (`DaemonGone`).
498 # The snapshot tells the two apart — a served attach always sends one first
499 # — and what this leg pins is the words an agent actually parses.
500 mkdir -p "$REFSTATE"
501 XDG_STATE_HOME="$REFSTATE" "$MUXD" run --sock "$SOCK66" --shell /bin/sh > "$OUT.ref.d" 2>&1 &
502 D66PID=$!
503 defer_kill "$D66PID"
504 wait_sock "$SOCK66" "$OUT.ref.d" "refused-attach daemon never bound"
505
506 # Each verb twice over: the words it must say, and the words it must not.
507 # The negative half is the whole regression — a reply that added "attach
508 # refused" while still claiming a session ended would pass a one-sided grep.
509 refuse_leg() {
510 _out="$OUT.ref$1"
511 _kind="$2"
512 shift 2
513 set +e
514 XDG_STATE_HOME="$REFSTATE" timeout 20 "$MUXA" "$@" --sock "$SOCK66" \
515 --session nosuch --timeout 5000 > "$_out" 2>&1
516 _rc=$?
517 set -e
518 [ "$_rc" -eq 1 ] || {
519 echo "e2e FAIL: refused-attach: muxa $1 on a missing session exited $_rc, want 1:"
520 cat "$_out"; exit 1; }
521 grep -qF '"error":"attach refused"' "$_out" || {
522 echo "e2e FAIL: refused-attach: muxa $1 did not name the refusal:"
523 cat "$_out"; exit 1; }
524 grep -qF 'so nosuch must already exist' "$_out" || {
525 echo "e2e FAIL: refused-attach: muxa $1 did not name the session asked for:"
526 cat "$_out"; exit 1; }
527 # Only the verbs that attach have two causes. status and capture never
528 # attach, so no client slot was ever asked for and none can be blamed:
529 # an agent sent to read `muxd stats` over a name it mistyped burns a
530 # round trip on a table that is not full.
531 if [ "$_kind" = attach ]; then
532 grep -qF 'room for one more client' "$_out" || {
533 echo "e2e FAIL: refused-attach: muxa $1 blamed absence for a refusal that has two causes:"
534 cat "$_out"; exit 1; }
535 else
536 grep -qF 'max_clients' "$_out" && {
537 echo "e2e FAIL: refused-attach: muxa $1 never attached, so it must not blame the client table:"
538 cat "$_out"; exit 1; }
539 fi
540 grep -qF 'session_ended' "$_out" && {
541 echo "e2e FAIL: refused-attach: muxa $1 still reports a session that ended:"
542 cat "$_out"; exit 1; }
543 grep -qF 'session ended' "$_out" && {
544 echo "e2e FAIL: refused-attach: muxa $1 still reports a session that ended:"
545 cat "$_out"; exit 1; }
546 grep -qF 'DaemonGone' "$_out" && {
547 echo "e2e FAIL: refused-attach: muxa $1 blamed the daemon for a missing name:"
548 cat "$_out"; exit 1; }
549 return 0
550 }
551 refuse_leg run attach run true
552 refuse_leg status query status
553 refuse_leg send attach send x
554
555 # ...and the daemon that said no three times is still serving. Refusing is
556 # an answer, not a fault: a daemon that died on a typed `--session` would
557 # turn one bad name into everyone else's outage.
558 XDG_STATE_HOME="$REFSTATE" timeout 20 "$MUXA" status --sock "$SOCK66" \
559 --session 0 > "$OUT.refalive" 2>&1 || {
560 echo "e2e FAIL: refused-attach: the daemon stopped answering after refusing:"
561 cat "$OUT.refalive"; exit 1; }
562 grep -qF '"cols":' "$OUT.refalive" || {
563 echo "e2e FAIL: refused-attach: status on the live session is not a status reply:"
564 cat "$OUT.refalive"; exit 1; }
565
566 assert_stopped "$SOCK66" "$D66PID" "refused-attach" "$OUT.refstop"
567 D66PID=""
568 rm -rf "$REFSTATE"
569 ok "muxa names a refused attach instead of a shell that never ran"
570
571 # --- the same ruling through the browser -------------------------------
572 #
573 # The hub's tiles attach at 0x0 — the passivity contract, so a browser can
574 # never move a shared grid — and the daemon reads 0x0 as join-only. A saved
575 # wall whose daemon has restarted was therefore a grid of dead tiles that
576 # the CLI, reading the same file, brought back. The hub now births the
577 # session on a connection of its own and re-dials; the browser re-attaches
578 # on `up` as it does after any tear and is never told a refusal happened.
579 #
580 # The split is the hydrate leg's, asserted the other side of the wire: the
581 # LOCAL line comes back, the `quic://` one still says refused. Same daemon,
582 # same run, reached both ways, so the comparison is between two kinds of
583 # line and not between two rigs.
584 #
585 # Daemon truth answers "was it created" — `muxa status` on a name the
586 # daemon does not have exits non-zero, and the same probe runs BEFORE the
587 # hub so the leg cannot pass vacuously. `sessions=1` at the end is the
588 # other half: one birth, not two, and nothing born for the remote line.
589 head -c 32 /dev/urandom > "$WGKEY"
590 chmod 600 "$WGKEY"
591 start_daemon "$SOCK67" "$OUT.wg.d" "web-restore daemon never bound" --shell /bin/sh \
592 --quic "127.0.0.1:$WGQPORT" --key "$WGKEY" --quic-idle-ms 15000
593 D67PID=$DPID
594
595 # Written by hand for the hydrate leg's reason: a line the hub had earned
596 # by attaching would name a session that already exists, and there would be
597 # nothing left for the restore to create.
598 mkdir -p "$WGSTATE/mux"
599 printf -- '--sock %s#wghost\nquic://127.0.0.1:%s#wghost2\n' \
600 "$SOCK67" "$WGQPORT" > "$WGWALL"
601
602 # Vacuity guard: neither exists yet.
603 for _wg in wghost wghost2; do
604 timeout 20 "$MUXA" status --sock "$SOCK67" --session "$_wg" > "$OUT.wgpre" 2>&1 && {
605 echo "e2e FAIL: web-restore: session $_wg existed before the hub ran:"
606 cat "$OUT.wgpre"; exit 1; }
607 done
608
609 # No argv: the hub restores the wall from the last run, which is the file
610 # written above. Its own state home, never the developer's.
611 XDG_STATE_HOME="$WGSTATE" "$MUXWEB" --port "$WGPORT" --key "$WGKEY" \
612 --quic-idle-ms 15000 > "$OUT.wgh" 2>&1 &
613 W5PID=$!
614 defer_kill "$W5PID"
615 wait_for "$OUT.wgh" "serving" 10 || {
616 echo "e2e FAIL: web-restore: hub never reported serving"; cat "$OUT.wgh"; exit 1; }
617
618 # Tile 0, the LOCAL line. `expectups 2` is the assertion that the hub
619 # re-dialed: one `up` for the first dial, a second for the one after the
620 # birth. Counted rather than read off the last state, because a
621 # `reconnecting` → `up` pair can arrive inside one read and leave the last
622 # state saying exactly what it said before. The second attach is the
623 # browser's own — mux.js re-attaches on `up`, and nothing in the page
624 # decides anything about creation. Both scripts wait for the first `up`
625 # before attaching, for mux.js's reason: a message sent while the hub is
626 # still dialing is dropped, and an attach nobody read is a refusal that
627 # never arrives.
628 # Bracketing the script below is what makes "one birth" measurable at all
629 # — see the delta assertion after it.
630 WGATT_A=$(attaches_now "$SOCK67")
631 set +e
632 timeout 60 "$WSCLIENT" --port "$WGPORT" --tile 0 --out "$OUT.wgws" --err "$OUT.wgws.err" <<'EOF'
633 expectstate up 25000
634 attach 0 0 wghost
635 expectups 2 30000
636 attach 0 0 wghost
637 settle 500 20000
638 send printf 'wg-%s\n' born\n
639 expectgrid wg-born 25000
640 dumpexit
641 EOF
642 RC=$?
643 set -e
644 [ "$RC" -eq 0 ] || {
645 echo "e2e FAIL: web-restore: the local tile's wsclient exited $RC"
646 cat -v "$OUT.wgws.err" 2>/dev/null; cat "$OUT.wgh"; exit 1; }
647 WGATT_B=$(attaches_now "$SOCK67")
648
649 # ONE birth. Counting sessions cannot say this: `resolveSession` is
650 # attach-or-create, so a second birth would find `wghost` and join, and
651 # the table would read 2 whether the hub birthed once or twenty times.
652 # `attaches` can, because a REFUSED attach deliberately does not move it:
653 # the two counted here are the birth's own and the browser's second one
654 # (the first was refused). A hub that re-birthed on every refused re-dial
655 # lands one extra per turn.
656 assert_attach_delta "$WGATT_A" "$WGATT_B" 2 \
657 "web-restore: the hub birthed once for one refused tile"
658
659 # Tile 1, the REMOTE line: the refusal is forwarded untouched, and the
660 # fixture tells it from a shell exiting exactly as the page does — an
661 # exit_status before any grid.
662 set +e
663 timeout 60 "$WSCLIENT" --port "$WGPORT" --tile 1 --out "$OUT.wgws2" --err "$OUT.wgws2.err" <<'EOF'
664 expectstate up 25000
665 attach 0 0 wghost2
666 expectrefused 25000
667 dumpexit
668 EOF
669 RC=$?
670 set -e
671 [ "$RC" -eq 0 ] || {
672 echo "e2e FAIL: web-restore: the remote tile's wsclient exited $RC"
673 cat -v "$OUT.wgws2.err" 2>/dev/null; cat "$OUT.wgh"; exit 1; }
674
675 # Daemon truth: the local line's session exists, and at the size the birth
676 # claimed — a browser tile claims none, so a session born at the tile's 0x0
677 # would not have been born at all.
678 timeout 20 "$MUXA" status --sock "$SOCK67" --session wghost > "$OUT.wgsta" 2>&1 || {
679 echo "e2e FAIL: web-restore: the saved local line did not come back:"
680 cat "$OUT.wgsta"; cat "$OUT.wgh"; exit 1; }
681
682 # The size is asserted against the DAEMON's own default, not against a
683 # literal: session 0 is seeded from `main.Opts` and this daemon was given
684 # no --cols/--rows, so it holds exactly the figure `client.birth_cols` and
685 # `client.birth_rows` claim to mirror. Grepping for 80x24 would pass just
686 # as happily if both sides drifted together, which is the drift that
687 # matters — nothing else links the two files.
688 timeout 20 "$MUXA" status --sock "$SOCK67" --session 0 > "$OUT.wgsta0" 2>&1 || {
689 echo "e2e FAIL: web-restore: the daemon's own default session did not answer:"
690 cat "$OUT.wgsta0"; exit 1; }
691 _wg_size() { sed -n 's/^{\("cols":[0-9]*,"rows":[0-9]*\).*/\1/p' "$1"; }
692 WG_BORN=$(_wg_size "$OUT.wgsta")
693 WG_DEFAULT=$(_wg_size "$OUT.wgsta0")
694 [ -n "$WG_BORN" ] && [ -n "$WG_DEFAULT" ] || {
695 echo "e2e FAIL: web-restore: no size read out of muxa status"
696 echo " born='$WG_BORN' default='$WG_DEFAULT' — the pin would be vacuous"
697 cat "$OUT.wgsta" "$OUT.wgsta0"; exit 1; }
698 [ "$WG_BORN" = "$WG_DEFAULT" ] || {
699 echo "e2e FAIL: web-restore: the birth size drifted from muxd's own default"
700 echo " wghost $WG_BORN, session 0 $WG_DEFAULT"
701 echo " — client.birth_cols/birth_rows must mirror main.Opts"
702 exit 1; }
703
704 # ...and it is a real shell: the marker the browser stand-in typed is the
705 # created session's own work.
706 grep -qF "wg-born" "$OUT.wgws" || {
707 echo "e2e FAIL: web-restore: the marker never reached the created session:"
708 cat "$OUT.wgws"; exit 1; }
709
710 # The remote line created NOTHING. Same daemon, same file, same run.
711 timeout 20 "$MUXA" status --sock "$SOCK67" --session wghost2 > "$OUT.wgghost" 2>&1 && {
712 echo "e2e FAIL: web-restore: a quic:// wall line created a session:"
713 cat "$OUT.wgghost"; exit 1; }
714
715 # ...and nothing else was created behind either tile. Two, not one —
716 # `muxd run` makes the default session at startup, so the birth is the
717 # SECOND. How MANY births happened is the attach delta's claim above; this
718 # one is that the table holds exactly the expected set.
719 wait_sessions "$SOCK67" 2 "web-restore: the table holds the default session and wghost, nothing else"
720
721 # ...and a shell the user ENDS stays ended. Every refusal closes the hub's
722 # connection (`server.dropObserver` closes the fd), so `exit` reaches the
723 # same refuse-after-redial shape the restore does: the daemon reaps the
724 # session, the hub redials, the page re-attaches on `up`, and the attach is
725 # refused because the session is gone. A hub that could not tell those two
726 # apart hands the user a NEW shell every time they type `exit` — and with a
727 # short-lived shell, one forked process per turn, forever. The CLI's answer
728 # is `wallview`'s: its pump ends on an exit_status and the tile stays dead.
729 #
730 # The re-attach is spelled out here because it is the browser's, not the
731 # hub's: mux.js sends it on `up`, and the fixture stands in for that.
732 set +e
733 timeout 60 "$WSCLIENT" --port "$WGPORT" --tile 0 --out "$OUT.wgws3" --err "$OUT.wgws3.err" <<'EOF'
734 expectstate up 25000
735 attach 0 0 wghost
736 settle 500 20000
737 send exit\n
738 expectups 2 30000
739 attach 0 0 wghost
740 expectrefused 25000
741 dumpexit
742 EOF
743 RC=$?
744 set -e
745 [ "$RC" -eq 0 ] || {
746 echo "e2e FAIL: web-restore: the exiting tile's wsclient exited $RC"
747 cat -v "$OUT.wgws3.err" 2>/dev/null; cat "$OUT.wgh"; exit 1; }
748
749 # Daemon truth for the same claim: the session the user ended is gone and
750 # stayed gone, and the daemon is back to the one session it started with.
751 timeout 20 "$MUXA" status --sock "$SOCK67" --session wghost > "$OUT.wgexit" 2>&1 && {
752 echo "e2e FAIL: web-restore: typing exit got the user a new shell:"
753 cat "$OUT.wgexit"; exit 1; }
754 wait_sessions "$SOCK67" 1 "web-restore: the ended session was not resurrected"
755
756 softkill "$W5PID" || true
757 wait_pid_gone "$W5PID" "web-restore: hub killed by tracked pid"
758 W5PID=""
759 assert_stopped "$SOCK67" "$D67PID" "web-restore" "$OUT.wgstop"
760 D67PID=""
761 rm -rf "$WGSTATE"
762 ok "the browser wall restores a saved local line too, and still joins a remote one only"
763
764 # --- a refusal the birth cannot fix must not spin ----------------------
765 #
766 # The other end of the restore rule: a line the daemon will not attach and
767 # the hub may not create. `client.hydratedCreates` joins a remote spelling
768 # and never births it, so nothing the hub can do makes this attach land.
769 # Every refusal closes the connection (`server.dropObserver`), so the
770 # re-dial that follows succeeds on its first try, the page re-attaches on
771 # `up` as it always does, and the same no comes back — a connect/attach/
772 # close loop bounded by nothing but round-trip latency, on the hub, the
773 # daemon and the browser at once.
774 #
775 # Counted the one way the hub cannot flatter itself: a `.hand` dial forks
776 # its own ssh, and the shim logs its pid before exec, so `wc -l` is a
777 # count of PROCESSES the OS made. A hub counter would be the code under
778 # test grading its own homework.
779 start_daemon "$SOCK68" "$OUT.sp.d" "refusal-spin daemon never bound" --shell /bin/sh
780 D68PID=$DPID
781
782 # This shim ignores the remote command on purpose — it is not modelling
783 # ssh (the M14 shim above does that), it is the dial's process signature
784 # plus a byte pipe to a REAL daemon. `endpoint none` is what keeps every
785 # dial on this path: an announced QUIC endpoint would be cached, the
786 # second dial would skip ssh entirely, and the count with it.
787 mkdir -p "$SPDIR"
788 : > "$SPINLOG"
789 cat > "$SPDIR/ssh" <<SPSHIM
790 #!/bin/sh
791 echo \$\$ >> "$SPINLOG"
792 printf 'endpoint none\n'
793 exec "$MUXD" proxy --sock "$SOCK68"
794 SPSHIM
795 chmod +x "$SPDIR/ssh"
796
797 # One line, a HOST spelling, naming a session the daemon does not have:
798 # the tile attaches at 0x0 (passivity), 0x0 cannot create, and a remote
799 # line may not be birthed.
800 mkdir -p "$SPSTATE/mux"
801 printf 'mux-spin@127.0.0.1#spinghost\n' > "$SPSTATE/mux/wall"
802
803 XDG_STATE_HOME="$SPSTATE" XDG_CACHE_HOME="$SPSTATE/cache" PATH="$SPDIR:$PATH" \
804 "$MUXWEB" --port "$SPPORT" > "$OUT.sph" 2>&1 &
805 W6PID=$!
806 defer_kill "$W6PID"
807 wait_for "$OUT.sph" "serving" 10 || {
808 echo "e2e FAIL: refusal-spin: hub never reported serving"; cat "$OUT.sph"; exit 1; }
809
810 # `reattach` is mux.js's ENV_CONTROL handler, which is the half of the
811 # loop the hub does not own: the page attaches again on every `up`. Six
812 # seconds of it, and the dial log is read the moment the window closes.
813 set +e
814 timeout 90 "$WSCLIENT" --port "$SPPORT" --tile 0 --out "$OUT.spws" --err "$OUT.spws.err" <<'EOF'
815 expectstate up 25000
816 reattach 0 0 6000 spinghost
817 expectrefused 25000
818 dumpexit
819 EOF
820 RC=$?
821 set -e
822 SPDIALS=$(wc -l < "$SPINLOG")
823 [ "$RC" -eq 0 ] || {
824 echo "e2e FAIL: refusal-spin: the wsclient exited $RC after $SPDIALS dials"
825 cat -v "$OUT.spws.err" 2>/dev/null; cat "$OUT.sph"; exit 1; }
826
827 # The bound, and its vacuity guard. Backoff caps at client.nextBackoffMs's
828 # 2s, so six seconds of refusal is a handful of dials; unbounded it is one
829 # per round trip, hundreds. The lower bound is what stops this passing on
830 # a tile that never re-dialled at all — then the ceiling would be measuring
831 # nothing.
832 [ "$SPDIALS" -ge 3 ] || {
833 echo "e2e FAIL: refusal-spin: only $SPDIALS dials in 6s — the tile never"
834 echo " re-dialled, so the ceiling below measured nothing"
835 cat "$OUT.sph"; exit 1; }
836 [ "$SPDIALS" -le 15 ] || {
837 echo "e2e FAIL: refusal-spin: $SPDIALS dials in 6s (want <= 15) — a refusal"
838 echo " the birth cannot fix is spinning with no backoff"
839 cat "$OUT.sph"; exit 1; }
840
841 # ...and it stayed refused: nothing was born behind the hub's back, which
842 # is what makes the count above a count of REFUSED dials.
843 timeout 20 "$MUXA" status --sock "$SOCK68" --session spinghost > "$OUT.spghost" 2>&1 && {
844 echo "e2e FAIL: refusal-spin: a remote wall line created a session:"
845 cat "$OUT.spghost"; exit 1; }
846
847 softkill "$W6PID" || true
848 wait_pid_gone "$W6PID" "refusal-spin: hub killed by tracked pid"
849 W6PID=""
850 assert_stopped "$SOCK68" "$D68PID" "refusal-spin" "$OUT.spstop"
851 D68PID=""
852 rm -rf "$SPSTATE" "$SPDIR"
853 ok "a refusal the birth cannot fix backs off instead of spinning"
854
855 # `muxd upgrade` — four daemons, because each one is a different daemon 148 # `muxd upgrade` — four daemons, because each one is a different daemon
856 # LIFECYCLE and no two can share a process: the same-binary leg's daemon 149 # LIFECYCLE and no two can share a process: the same-binary leg's daemon
857 # ends up running a second image, the rollback leg's is born with the abort 150 # ends up running a second image, the rollback leg's is born with the abort
test/e2e_13_birth.sh
Old New
@@ -0,0 +1,710 @@
1 # shellcheck shell=sh
2 # e2e_13_birth.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 # The hydrated-create leg. One daemon, and it serves QUIC as well as its
6 # socket: the leg's whole point is the LOCAL/remote split, so both halves
7 # have to be the same run of the same daemon or the comparison is between
8 # two rigs rather than between two kinds of line. Short socket name on
9 # purpose — the assertion reads a label bar back off a 70-column render.
10 SOCK64="${TMPDIR:-/tmp}/muxd-e2e-hyd-$$.sock"
11 defer_sock "$SOCK64"
12 # Base 6000, below the 5000-spaced band the other QUIC ports share: every
13 # base from 11000 up is already taken, and a DUPLICATED base passes only
14 # for as long as the other leg's daemon happens to be dead by the time
15 # this one binds. A collision reads as this daemon's failure, not the
16 # suite's, which is why the base has to be the leg's own.
17 HYPORT=$(( 6000 + ($$ % 4000) ))
18 HYKEY="${TMPDIR:-/tmp}/mux-e2e-hydkey-$$"
19 defer_rm "$HYKEY"
20 HYSTATE="${TMPDIR:-/tmp}/mux-e2e-hyd-state-$$"
21 defer_rm "$HYSTATE"
22 HYWALL="$HYSTATE/mux/wall"
23 # The refused-attach leg: its own daemon, because its assertions are about
24 # what a daemon says to a name it does NOT have, and a socket shared with
25 # another leg would let that leg's sessions decide what "does not have"
26 # means.
27 SOCK66="${TMPDIR:-/tmp}/muxd-e2e-refuse-$$.sock"
28 defer_sock "$SOCK66"
29 REFSTATE="${TMPDIR:-/tmp}/mux-e2e-refuse-state-$$"
30 defer_rm "$REFSTATE"
31 # The browser half of the same ruling: one daemon serving a socket AND a
32 # QUIC listener, one hub, a state home of its own. Two ports, and the
33 # 5000-spaced bands are exhausted — 61000 is the last one that fits under
34 # 65535 — so this leg takes the tail ABOVE the ephemeral range
35 # (ip_local_port_range tops out at 60999 by default), halved. The narrow
36 # `%250` is the price of that tail: two runs whose pids differ by 250
37 # collide where the older bands need 4000. A collision reads as this leg's
38 # daemon failing to bind, which is also why the two halves cannot share a
39 # base.
40 SOCK67="${TMPDIR:-/tmp}/muxd-e2e-wg-$$.sock"
41 defer_sock "$SOCK67"
42 WGPORT=$(( 65000 + ($$ % 250) ))
43 WGQPORT=$(( 65250 + ($$ % 250) ))
44 WGKEY="${TMPDIR:-/tmp}/mux-e2e-wgkey-$$"
45 defer_rm "$WGKEY"
46 WGSTATE="${TMPDIR:-/tmp}/mux-e2e-wg-state-$$"
47 defer_rm "$WGSTATE"
48 WGWALL="$WGSTATE/mux/wall"
49 # The refusal-spin leg. Its own everything: a shim dir on PATH, a state
50 # home holding the wall it restores, and a dial log the shim appends to.
51 SOCK68="${TMPDIR:-/tmp}/muxd-e2e-sp-$$.sock"
52 defer_sock "$SOCK68"
53 SPPORT=$(( 26000 + ($$ % 4000) ))
54 SPSTATE="${TMPDIR:-/tmp}/mux-e2e-sp-state-$$"
55 defer_rm "$SPSTATE"
56 SPDIR="${TMPDIR:-/tmp}/mux-e2e-sp-shim-$$"
57 defer_rm "$SPDIR"
58 SPINLOG="$SPDIR/dials"
59
60 # ---- Ctrl-\ : adds a tile by spelling ---------------------------------
61 #
62 # Argv typed from inside. A spelling naming a session on ANOTHER daemon
63 # is born beside the focus, creates that session there, takes the focus,
64 # and is recorded into the wall file — mux TARGET's row of the birth
65 # table, without leaving the wall. A bad spelling is a notice and
66 # nothing else (the file does not grow). Esc eats the line: the shell
67 # never sees it, and the next keys reach the session again.
68 #
69 # The prompt echoes what is typed, so the born tile's witness is not its
70 # label on the capture (the echo would match) but a marker typed AFTER
71 # the birth landing in daemon B's session b — a hit is B's shell's work.
72 PRSTATE="${TMPDIR:-/tmp}/mux-e2e-prompt-state-$$"
73 defer_rm "$PRSTATE"
74 PRWALL="$PRSTATE/mux/wall"
75 start_daemon "$SOCK62" "$OUT.pra.d" "prompt daemon A never bound" --shell /bin/sh
76 D63PID=$DPID
77 start_daemon "$SOCK63" "$OUT.prb.d" "prompt daemon B never bound" --shell /bin/sh
78 D64PID=$DPID
79
80 pipe_mux "$OUT.pra" "$OUT.pra.err" timeout 40 "$MUX" --sock "$SOCK62" --session a
81 pipe_send 'printf "pr-%%s\\n" origin\n'
82 await_out "$OUT.pra" "pr-origin" "prompt: session a marker never reached the client"
83 pipe_detach
84 wait_grid "$SOCK62" "pr-origin" "prompt: session a marker" a
85
86 mkdir -p "$PRSTATE/mux"
87 printf -- '--sock %s#a\n' "$SOCK62" > "$PRWALL"
88
89 # The spelling goes in TWO sends with a settle between: the prompt paints
90 # its echo when a read ENDS with the prompt still open, so a spelling that
91 # arrives in the same read as its own \r paints nothing to assert on. The
92 # separating space is \x20 because parseLine trims a payload's trailing one.
93 set +e
94 XDG_STATE_HOME="$PRSTATE" timeout 90 "$PTYCLIENT" --cols 100 --rows 30 \
95 --out "$OUT.prcap" --err "$OUT.prcap.err" -- \
96 "$MUX" wall > "$OUT.prpc" 2>&1 <<EOF
97 expect pr-origin 20000
98 settle 700 20000
99 send \x1c:--sock\x20
100 settle 300 5000
101 send $SOCK63#b\r
102 settle 1000 20000
103 send printf 'pr-born-%s\n' marker\n
104 expect pr-born-marker 15000
105 send \x1c:x#bad name\r
106 expect [bad target 10000
107 settle 500 15000
108 send \x1c:-A nosuchhost.invalid\r
109 expect FlagLikeTarget 10000
110 settle 500 15000
111 send \x1c:zzz\x1b
112 settle 500 15000
113 send printf 'pr-after-%s\n' esc\n
114 expect pr-after-esc 10000
115 settle 500 15000
116 send \x1cd
117 waitexit 10000
118 EOF
119 RC=$?
120 set -e
121 [ "$RC" -eq 0 ] || {
122 echo "e2e FAIL: prompt: ptyclient leg exited $RC (did \\x1c: add the tile?):"
123 cat "$OUT.prpc" "$OUT.prcap.err"; exit 1; }
124 # The oracle, not the byte stream: a banner LEFT on the screen is invisible
125 # to a grep of the emitted bytes — the `: zzz` that painted is in the
126 # capture either way. Only the render's final grid says what the human was
127 # still looking at when the wall came down.
128 "$RENDER" --cols 100 --rows 30 < "$OUT.prcap" > "$OUT.prgrid" || {
129 echo "e2e FAIL: prompt: render oracle failed"
130 cat "$OUT.prgrid"; exit 1; }
131 grep -q ": zzz" "$OUT.prgrid" && {
132 echo "e2e FAIL: prompt: the Esc'd prompt is still on the screen:"
133 cat "$OUT.prgrid"; exit 1; }
134 grep -q "pr-after-esc" "$OUT.prgrid" || {
135 echo "e2e FAIL: prompt: the tile's content did not come back after Esc:"
136 cat "$OUT.prgrid"; exit 1; }
137 # argv's refusal, at argv's other mouth: `-A host` is a mistyped flag and
138 # never becomes a tile for a host named `-A`. The bar is the witness — a
139 # born tile paints its spelling and a state word beside it, and the host
140 # here resolves nowhere, so the tile would sit there saying [refused].
141 grep -q -- "-A nosuchhost.invalid \[" "$OUT.prgrid" && {
142 echo "e2e FAIL: prompt: a flag-like spelling became a tile:"
143 cat "$OUT.prgrid"; exit 1; }
144 # The prompt is a line the typist can read back: -a because the capture is
145 # a terminal stream, escape bytes and all.
146 grep -aq ": --sock " "$OUT.prcap" || {
147 echo "e2e FAIL: prompt: the prompt line never painted its echo:"
148 cat "$OUT.prpc"; exit 1; }
149 # Born on daemon B, focused: the marker typed after the birth is in B's
150 # session b, and so are the keys typed after the Esc.
151 timeout 20 "$MUXA" capture --sock "$SOCK63" --session b > "$OUT.prfb" 2>&1
152 grep -q "pr-born-marker" "$OUT.prfb" || {
153 echo "e2e FAIL: prompt: marker not in daemon B's session b (tile not born there, or not focused):"
154 cat "$OUT.prfb"; exit 1; }
155 grep -q "pr-after-esc" "$OUT.prfb" || {
156 echo "e2e FAIL: prompt: keys after Esc never reached session b (prompt did not close):"
157 cat "$OUT.prfb"; exit 1; }
158 grep -q "zzz" "$OUT.prfb" && {
159 echo "e2e FAIL: prompt: Esc leaked the line into the shell:"
160 cat "$OUT.prfb"; exit 1; }
161 # Recorded, and only the accepted spelling: two lines, the born one among them.
162 grep -qF -- "--sock $SOCK63#b" "$PRWALL" || {
163 echo "e2e FAIL: prompt: the born tile was not recorded:"
164 cat "$PRWALL"; exit 1; }
165 [ "$(grep -c . "$PRWALL")" = "2" ] || {
166 echo "e2e FAIL: prompt: wall file is not exactly two lines (a refused spelling recorded?):"
167 cat "$PRWALL"; exit 1; }
168 assert_stopped "$SOCK62" "$D63PID" "prompt A" "$OUT.prastop"
169 D63PID=""
170 assert_stopped "$SOCK63" "$D64PID" "prompt B" "$OUT.prbstop"
171 D64PID=""
172 rm -rf "$PRSTATE"
173 ok "Ctrl-\\ : adds a tile by spelling: born on another daemon, recorded, refusals narrated, Esc eats the line"
174
175 # ---- a saved local line comes back as a fresh session -------------------
176 #
177 # The wall file is attach history, and the daemon that held those sessions
178 # dies on every reboot. Joining only meant the wall came back as a grid of
179 # [refused] tiles nobody could do anything with but forget them by hand.
180 # A restored line naming the LOCAL socket now attaches-or-creates.
181 #
182 # The remote half is the same wall, the same run and the same daemon,
183 # reached the other way: a `quic://` line for a session that does not
184 # exist stays [refused] and creates nothing. Recreating a shell on your
185 # own box is cheap and expected; spawning one on another host out of a
186 # saved file is not, and the split is the whole ruling.
187 #
188 # Daemon truth, not paint, answers "was it created": `muxa status` on a
189 # name the daemon does not have exits non-zero, which is the same probe
190 # the leg runs BEFORE the wall so it cannot pass vacuously. The render
191 # oracle answers the other question — what the human was looking at —
192 # because a bar left saying [refused] is invisible to a grep of the
193 # emitted bytes.
194 #
195 # Both roads a restored line can take are here, because they are two call
196 # sites of the one rule: the no-argv `mux wall` below runs the startup tile
197 # loop, and the `Ctrl-\ w` half at the end of this leg runs `hydrate()`.
198 head -c 32 /dev/urandom > "$HYKEY"
199 chmod 600 "$HYKEY"
200 start_daemon "$SOCK64" "$OUT.hyd.d" "hydrate-create daemon never bound" --shell /bin/sh \
201 --quic "127.0.0.1:$HYPORT" --key "$HYKEY" --quic-idle-ms 15000
202 D65PID=$DPID
203
204 # The wall file names three sessions this daemon does not have. Written by
205 # hand rather than earned by an attach, because an attach that earned the
206 # line would also have created the session — and then there would be
207 # nothing for the restore to create.
208 mkdir -p "$HYSTATE/mux"
209 printf -- '--sock %s#hya\n--sock %s#hyb\nquic://127.0.0.1:%s#hyghost\n' \
210 "$SOCK64" "$SOCK64" "$HYPORT" > "$HYWALL"
211
212 # Vacuity guard: none of the three exists yet. A leg whose daemon already
213 # had them would pass without the restore creating anything.
214 for _hy in hya hyb hyghost; do
215 timeout 20 "$MUXA" status --sock "$SOCK64" --session "$_hy" > "$OUT.hydpre" 2>&1 && {
216 echo "e2e FAIL: hydrate-create: session $_hy existed before the wall ran:"
217 cat "$OUT.hydpre"; exit 1; }
218 done
219
220 # 70x36: under the aspect rule (70 < 2*36) the cut is stacked, so each of
221 # the three tiles gets the full width and its label bar survives the
222 # render intact — the socket path is in that label.
223 set +e
224 XDG_STATE_HOME="$HYSTATE" timeout 90 "$PTYCLIENT" --cols 70 --rows 36 \
225 --out "$OUT.hydcap" --err "$OUT.hydcap.err" -- \
226 "$MUX" wall --key "$HYKEY" > "$OUT.hydpc" 2>&1 <<'EOF'
227 expect [up] 25000
228 settle 1000 25000
229 send printf 'hyd-%s\n' marker\n
230 expect hyd-marker 15000
231 settle 600 15000
232 send \x1cd
233 waitexit 10000
234 EOF
235 RC=$?
236 set -e
237 [ "$RC" -eq 0 ] || {
238 echo "e2e FAIL: hydrate-create: ptyclient exited $RC:"
239 cat "$OUT.hydpc" "$OUT.hydcap.err"; exit 1; }
240
241 # The local lines created their sessions, at the width of the stripe that
242 # created them. The width does not discriminate create from join on its own
243 # (a join's resize doorbell lands at 70 too, and the daemon refuses a create
244 # under min_session_cols) — the muxa exit code above is what says created.
245 # It is here to catch a session that came up at some OTHER client's size.
246 timeout 20 "$MUXA" status --sock "$SOCK64" --session hya > "$OUT.hydsta" 2>&1 || {
247 echo "e2e FAIL: hydrate-create: the saved line for hya did not create it:"
248 cat "$OUT.hydsta"; exit 1; }
249 grep -q '"cols":70' "$OUT.hydsta" || {
250 echo "e2e FAIL: hydrate-create: hya exists but not at the tile's width:"
251 cat "$OUT.hydsta"; exit 1; }
252 timeout 20 "$MUXA" status --sock "$SOCK64" --session hyb > "$OUT.hydstb" 2>&1 || {
253 echo "e2e FAIL: hydrate-create: the saved line for hyb did not create it:"
254 cat "$OUT.hydstb"; exit 1; }
255
256 # ...and the created session runs a real shell: the marker typed into the
257 # focused tile (tile 0, the first wall line) is the shell's own work.
258 timeout 20 "$MUXA" capture --sock "$SOCK64" --session hya > "$OUT.hydfa" 2>&1
259 grep -q "hyd-marker" "$OUT.hydfa" || {
260 echo "e2e FAIL: hydrate-create: the marker never reached the created session:"
261 cat "$OUT.hydfa"; exit 1; }
262
263 # The remote line created NOTHING. Same daemon, same run, same file — only
264 # the spelling differs.
265 timeout 20 "$MUXA" status --sock "$SOCK64" --session hyghost > "$OUT.hydghost" 2>&1 && {
266 echo "e2e FAIL: hydrate-create: a quic:// wall line created a session:"
267 cat "$OUT.hydghost"; exit 1; }
268
269 # What the human saw: two tiles up, the remote one still refused.
270 "$RENDER" --cols 70 --rows 36 < "$OUT.hydcap" > "$OUT.hydgrid" || {
271 echo "e2e FAIL: hydrate-create: render oracle failed"
272 cat "$OUT.hydgrid"; exit 1; }
273 grep -q -- "#hya \[up\]" "$OUT.hydgrid" || {
274 echo "e2e FAIL: hydrate-create: the hya tile never said [up]:"
275 cat "$OUT.hydgrid"; exit 1; }
276 grep -q -- "#hyb \[up\]" "$OUT.hydgrid" || {
277 echo "e2e FAIL: hydrate-create: the hyb tile never said [up]:"
278 cat "$OUT.hydgrid"; exit 1; }
279 grep -q -- "#hyghost \[refused\]" "$OUT.hydgrid" || {
280 echo "e2e FAIL: hydrate-create: the remote tile did not say [refused]:"
281 cat "$OUT.hydgrid"; exit 1; }
282 # ...and each bar carries the chord that reaches it. Ctrl-\ 1-9 focuses by
283 # POSITION while the bar named the session, and the two series disagree the
284 # moment a tile is forgotten or a session dies. Tile 1 has the focus here,
285 # so its digit carries the `>` and the other two the same-width blank.
286 grep -q -- " 1> .*#hya \[up\]" "$OUT.hydgrid" || {
287 echo "e2e FAIL: hydrate-create: the focused bar does not read \` 1> \`:"
288 cat "$OUT.hydgrid"; exit 1; }
289 grep -q -- " 2 .*#hyb \[up\]" "$OUT.hydgrid" || {
290 echo "e2e FAIL: hydrate-create: the second tile's bar does not read \` 2 \`:"
291 cat "$OUT.hydgrid"; exit 1; }
292 grep -q -- " 3 .*#hyghost \[refused\]" "$OUT.hydgrid" || {
293 echo "e2e FAIL: hydrate-create: a refused tile lost its chord digit:"
294 cat "$OUT.hydgrid"; exit 1; }
295 # The OTHER road a restored line takes: `Ctrl-\ w`. A no-argv `mux wall`
296 # runs the startup tile loop; the fold runs `hydrate()`, a different call
297 # site of the same rule, and the ruling covers both. Same daemon, same
298 # state home, one more ptyclient — a `mux TARGET` is the entry tile, and
299 # the fold brings the rest of the file in around it.
300 #
301 # The file is rewritten to name a session this daemon does not have. The
302 # entry attach then records its OWN line into the same file (attach
303 # history), which the fold dedupes by spelling — so what the fold has left
304 # to add is exactly the absent one, and creating it is the whole claim.
305 printf -- '--sock %s#hyfold\n' "$SOCK64" > "$HYWALL"
306 timeout 20 "$MUXA" status --sock "$SOCK64" --session hyfold > "$OUT.hydpre" 2>&1 && {
307 echo "e2e FAIL: hydrate-create: session hyfold existed before the fold ran:"
308 cat "$OUT.hydpre"; exit 1; }
309
310 set +e
311 XDG_STATE_HOME="$HYSTATE" timeout 90 "$PTYCLIENT" --cols 70 --rows 36 \
312 --out "$OUT.hydfcap" --err "$OUT.hydfcap.err" -- \
313 "$MUX" --sock "$SOCK64" --session hya > "$OUT.hydfpc" 2>&1 <<'EOF'
314 settle 1000 25000
315 send printf 'hydf-%s\n' pre\n
316 expect hydf-pre 20000
317 settle 600 20000
318 send \x1cw
319 settle 1500 25000
320 send \x1cd
321 waitexit 10000
322 EOF
323 RC=$?
324 set -e
325 [ "$RC" -eq 0 ] || {
326 echo "e2e FAIL: hydrate-create: fold ptyclient exited $RC:"
327 cat "$OUT.hydfpc" "$OUT.hydfcap.err"; exit 1; }
328
329 # Daemon truth again: the fold-born tile created the session its line named.
330 timeout 20 "$MUXA" status --sock "$SOCK64" --session hyfold > "$OUT.hydfold" 2>&1 || {
331 echo "e2e FAIL: hydrate-create: the folded line for hyfold did not create it:"
332 cat "$OUT.hydfold"; exit 1; }
333 # ...and it is a tile on the screen, not merely a session on the daemon.
334 "$RENDER" --cols 70 --rows 36 < "$OUT.hydfcap" > "$OUT.hydfgrid" || {
335 echo "e2e FAIL: hydrate-create: fold render oracle failed"
336 cat "$OUT.hydfgrid"; exit 1; }
337 grep -q -- "#hyfold \[up\]" "$OUT.hydfgrid" || {
338 echo "e2e FAIL: hydrate-create: the fold-born tile never said [up]:"
339 cat "$OUT.hydfgrid"; exit 1; }
340
341 assert_stopped "$SOCK64" "$D65PID" "hydrate-create" "$OUT.hydstop"
342 D65PID=""
343 rm -rf "$HYSTATE"
344 ok "a saved local line comes back as a fresh session, by wall and by fold; a remote one joins only"
345
346 # ---------------------------------------------------------------------------
347 # A refused attach is not a shell that exited.
348 #
349 # muxa attaches at 0x0, and a 0x0 attach is join-only: the daemon refuses a
350 # name it does not hold by sending exit_status 1 and closing — the same frame
351 # a real shell's exit sends. muxa used to report that as
352 # `{"reason":"session_ended","exit_code":1}` and `{"error":"session ended"}`,
353 # telling an agent a command had run and failed when no shell ever existed,
354 # while `status` got no answer at all and blamed the daemon (`DaemonGone`).
355 # The snapshot tells the two apart — a served attach always sends one first
356 # — and what this leg pins is the words an agent actually parses.
357 mkdir -p "$REFSTATE"
358 XDG_STATE_HOME="$REFSTATE" "$MUXD" run --sock "$SOCK66" --shell /bin/sh > "$OUT.ref.d" 2>&1 &
359 D66PID=$!
360 defer_kill "$D66PID"
361 wait_sock "$SOCK66" "$OUT.ref.d" "refused-attach daemon never bound"
362
363 # Each verb twice over: the words it must say, and the words it must not.
364 # The negative half is the whole regression — a reply that added "attach
365 # refused" while still claiming a session ended would pass a one-sided grep.
366 refuse_leg() {
367 _out="$OUT.ref$1"
368 _kind="$2"
369 shift 2
370 set +e
371 XDG_STATE_HOME="$REFSTATE" timeout 20 "$MUXA" "$@" --sock "$SOCK66" \
372 --session nosuch --timeout 5000 > "$_out" 2>&1
373 _rc=$?
374 set -e
375 [ "$_rc" -eq 1 ] || {
376 echo "e2e FAIL: refused-attach: muxa $1 on a missing session exited $_rc, want 1:"
377 cat "$_out"; exit 1; }
378 grep -qF '"error":"attach refused"' "$_out" || {
379 echo "e2e FAIL: refused-attach: muxa $1 did not name the refusal:"
380 cat "$_out"; exit 1; }
381 grep -qF 'so nosuch must already exist' "$_out" || {
382 echo "e2e FAIL: refused-attach: muxa $1 did not name the session asked for:"
383 cat "$_out"; exit 1; }
384 # Only the verbs that attach have two causes. status and capture never
385 # attach, so no client slot was ever asked for and none can be blamed:
386 # an agent sent to read `muxd stats` over a name it mistyped burns a
387 # round trip on a table that is not full.
388 if [ "$_kind" = attach ]; then
389 grep -qF 'room for one more client' "$_out" || {
390 echo "e2e FAIL: refused-attach: muxa $1 blamed absence for a refusal that has two causes:"
391 cat "$_out"; exit 1; }
392 else
393 grep -qF 'max_clients' "$_out" && {
394 echo "e2e FAIL: refused-attach: muxa $1 never attached, so it must not blame the client table:"
395 cat "$_out"; exit 1; }
396 fi
397 grep -qF 'session_ended' "$_out" && {
398 echo "e2e FAIL: refused-attach: muxa $1 still reports a session that ended:"
399 cat "$_out"; exit 1; }
400 grep -qF 'session ended' "$_out" && {
401 echo "e2e FAIL: refused-attach: muxa $1 still reports a session that ended:"
402 cat "$_out"; exit 1; }
403 grep -qF 'DaemonGone' "$_out" && {
404 echo "e2e FAIL: refused-attach: muxa $1 blamed the daemon for a missing name:"
405 cat "$_out"; exit 1; }
406 return 0
407 }
408 refuse_leg run attach run true
409 refuse_leg status query status
410 refuse_leg send attach send x
411
412 # ...and the daemon that said no three times is still serving. Refusing is
413 # an answer, not a fault: a daemon that died on a typed `--session` would
414 # turn one bad name into everyone else's outage.
415 XDG_STATE_HOME="$REFSTATE" timeout 20 "$MUXA" status --sock "$SOCK66" \
416 --session 0 > "$OUT.refalive" 2>&1 || {
417 echo "e2e FAIL: refused-attach: the daemon stopped answering after refusing:"
418 cat "$OUT.refalive"; exit 1; }
419 grep -qF '"cols":' "$OUT.refalive" || {
420 echo "e2e FAIL: refused-attach: status on the live session is not a status reply:"
421 cat "$OUT.refalive"; exit 1; }
422
423 assert_stopped "$SOCK66" "$D66PID" "refused-attach" "$OUT.refstop"
424 D66PID=""
425 rm -rf "$REFSTATE"
426 ok "muxa names a refused attach instead of a shell that never ran"
427
428 # --- the same ruling through the browser -------------------------------
429 #
430 # The hub's tiles attach at 0x0 — the passivity contract, so a browser can
431 # never move a shared grid — and the daemon reads 0x0 as join-only. A saved
432 # wall whose daemon has restarted was therefore a grid of dead tiles that
433 # the CLI, reading the same file, brought back. The hub now births the
434 # session on a connection of its own and re-dials; the browser re-attaches
435 # on `up` as it does after any tear and is never told a refusal happened.
436 #
437 # The split is the hydrate leg's, asserted the other side of the wire: the
438 # LOCAL line comes back, the `quic://` one still says refused. Same daemon,
439 # same run, reached both ways, so the comparison is between two kinds of
440 # line and not between two rigs.
441 #
442 # Daemon truth answers "was it created" — `muxa status` on a name the
443 # daemon does not have exits non-zero, and the same probe runs BEFORE the
444 # hub so the leg cannot pass vacuously. `sessions=1` at the end is the
445 # other half: one birth, not two, and nothing born for the remote line.
446 head -c 32 /dev/urandom > "$WGKEY"
447 chmod 600 "$WGKEY"
448 start_daemon "$SOCK67" "$OUT.wg.d" "web-restore daemon never bound" --shell /bin/sh \
449 --quic "127.0.0.1:$WGQPORT" --key "$WGKEY" --quic-idle-ms 15000
450 D67PID=$DPID
451
452 # Written by hand for the hydrate leg's reason: a line the hub had earned
453 # by attaching would name a session that already exists, and there would be
454 # nothing left for the restore to create.
455 mkdir -p "$WGSTATE/mux"
456 printf -- '--sock %s#wghost\nquic://127.0.0.1:%s#wghost2\n' \
457 "$SOCK67" "$WGQPORT" > "$WGWALL"
458
459 # Vacuity guard: neither exists yet.
460 for _wg in wghost wghost2; do
461 timeout 20 "$MUXA" status --sock "$SOCK67" --session "$_wg" > "$OUT.wgpre" 2>&1 && {
462 echo "e2e FAIL: web-restore: session $_wg existed before the hub ran:"
463 cat "$OUT.wgpre"; exit 1; }
464 done
465
466 # No argv: the hub restores the wall from the last run, which is the file
467 # written above. Its own state home, never the developer's.
468 XDG_STATE_HOME="$WGSTATE" "$MUXWEB" --port "$WGPORT" --key "$WGKEY" \
469 --quic-idle-ms 15000 > "$OUT.wgh" 2>&1 &
470 W5PID=$!
471 defer_kill "$W5PID"
472 wait_for "$OUT.wgh" "serving" 10 || {
473 echo "e2e FAIL: web-restore: hub never reported serving"; cat "$OUT.wgh"; exit 1; }
474
475 # Tile 0, the LOCAL line. `expectups 2` is the assertion that the hub
476 # re-dialed: one `up` for the first dial, a second for the one after the
477 # birth. Counted rather than read off the last state, because a
478 # `reconnecting` → `up` pair can arrive inside one read and leave the last
479 # state saying exactly what it said before. The second attach is the
480 # browser's own — mux.js re-attaches on `up`, and nothing in the page
481 # decides anything about creation. Both scripts wait for the first `up`
482 # before attaching, for mux.js's reason: a message sent while the hub is
483 # still dialing is dropped, and an attach nobody read is a refusal that
484 # never arrives.
485 # Bracketing the script below is what makes "one birth" measurable at all
486 # — see the delta assertion after it.
487 WGATT_A=$(attaches_now "$SOCK67")
488 set +e
489 timeout 60 "$WSCLIENT" --port "$WGPORT" --tile 0 --out "$OUT.wgws" --err "$OUT.wgws.err" <<'EOF'
490 expectstate up 25000
491 attach 0 0 wghost
492 expectups 2 30000
493 attach 0 0 wghost
494 settle 500 20000
495 send printf 'wg-%s\n' born\n
496 expectgrid wg-born 25000
497 dumpexit
498 EOF
499 RC=$?
500 set -e
501 [ "$RC" -eq 0 ] || {
502 echo "e2e FAIL: web-restore: the local tile's wsclient exited $RC"
503 cat -v "$OUT.wgws.err" 2>/dev/null; cat "$OUT.wgh"; exit 1; }
504 WGATT_B=$(attaches_now "$SOCK67")
505
506 # ONE birth. Counting sessions cannot say this: `resolveSession` is
507 # attach-or-create, so a second birth would find `wghost` and join, and
508 # the table would read 2 whether the hub birthed once or twenty times.
509 # `attaches` can, because a REFUSED attach deliberately does not move it:
510 # the two counted here are the birth's own and the browser's second one
511 # (the first was refused). A hub that re-birthed on every refused re-dial
512 # lands one extra per turn.
513 assert_attach_delta "$WGATT_A" "$WGATT_B" 2 \
514 "web-restore: the hub birthed once for one refused tile"
515
516 # Tile 1, the REMOTE line: the refusal is forwarded untouched, and the
517 # fixture tells it from a shell exiting exactly as the page does — an
518 # exit_status before any grid.
519 set +e
520 timeout 60 "$WSCLIENT" --port "$WGPORT" --tile 1 --out "$OUT.wgws2" --err "$OUT.wgws2.err" <<'EOF'
521 expectstate up 25000
522 attach 0 0 wghost2
523 expectrefused 25000
524 dumpexit
525 EOF
526 RC=$?
527 set -e
528 [ "$RC" -eq 0 ] || {
529 echo "e2e FAIL: web-restore: the remote tile's wsclient exited $RC"
530 cat -v "$OUT.wgws2.err" 2>/dev/null; cat "$OUT.wgh"; exit 1; }
531
532 # Daemon truth: the local line's session exists, and at the size the birth
533 # claimed — a browser tile claims none, so a session born at the tile's 0x0
534 # would not have been born at all.
535 timeout 20 "$MUXA" status --sock "$SOCK67" --session wghost > "$OUT.wgsta" 2>&1 || {
536 echo "e2e FAIL: web-restore: the saved local line did not come back:"
537 cat "$OUT.wgsta"; cat "$OUT.wgh"; exit 1; }
538
539 # The size is asserted against the DAEMON's own default, not against a
540 # literal: session 0 is seeded from `main.Opts` and this daemon was given
541 # no --cols/--rows, so it holds exactly the figure `client.birth_cols` and
542 # `client.birth_rows` claim to mirror. Grepping for 80x24 would pass just
543 # as happily if both sides drifted together, which is the drift that
544 # matters — nothing else links the two files.
545 timeout 20 "$MUXA" status --sock "$SOCK67" --session 0 > "$OUT.wgsta0" 2>&1 || {
546 echo "e2e FAIL: web-restore: the daemon's own default session did not answer:"
547 cat "$OUT.wgsta0"; exit 1; }
548 _wg_size() { sed -n 's/^{\("cols":[0-9]*,"rows":[0-9]*\).*/\1/p' "$1"; }
549 WG_BORN=$(_wg_size "$OUT.wgsta")
550 WG_DEFAULT=$(_wg_size "$OUT.wgsta0")
551 [ -n "$WG_BORN" ] && [ -n "$WG_DEFAULT" ] || {
552 echo "e2e FAIL: web-restore: no size read out of muxa status"
553 echo " born='$WG_BORN' default='$WG_DEFAULT' — the pin would be vacuous"
554 cat "$OUT.wgsta" "$OUT.wgsta0"; exit 1; }
555 [ "$WG_BORN" = "$WG_DEFAULT" ] || {
556 echo "e2e FAIL: web-restore: the birth size drifted from muxd's own default"
557 echo " wghost $WG_BORN, session 0 $WG_DEFAULT"
558 echo " — client.birth_cols/birth_rows must mirror main.Opts"
559 exit 1; }
560
561 # ...and it is a real shell: the marker the browser stand-in typed is the
562 # created session's own work.
563 grep -qF "wg-born" "$OUT.wgws" || {
564 echo "e2e FAIL: web-restore: the marker never reached the created session:"
565 cat "$OUT.wgws"; exit 1; }
566
567 # The remote line created NOTHING. Same daemon, same file, same run.
568 timeout 20 "$MUXA" status --sock "$SOCK67" --session wghost2 > "$OUT.wgghost" 2>&1 && {
569 echo "e2e FAIL: web-restore: a quic:// wall line created a session:"
570 cat "$OUT.wgghost"; exit 1; }
571
572 # ...and nothing else was created behind either tile. Two, not one —
573 # `muxd run` makes the default session at startup, so the birth is the
574 # SECOND. How MANY births happened is the attach delta's claim above; this
575 # one is that the table holds exactly the expected set.
576 wait_sessions "$SOCK67" 2 "web-restore: the table holds the default session and wghost, nothing else"
577
578 # ...and a shell the user ENDS stays ended. Every refusal closes the hub's
579 # connection (`server.dropObserver` closes the fd), so `exit` reaches the
580 # same refuse-after-redial shape the restore does: the daemon reaps the
581 # session, the hub redials, the page re-attaches on `up`, and the attach is
582 # refused because the session is gone. A hub that could not tell those two
583 # apart hands the user a NEW shell every time they type `exit` — and with a
584 # short-lived shell, one forked process per turn, forever. The CLI's answer
585 # is `wallview`'s: its pump ends on an exit_status and the tile stays dead.
586 #
587 # The re-attach is spelled out here because it is the browser's, not the
588 # hub's: mux.js sends it on `up`, and the fixture stands in for that.
589 set +e
590 timeout 60 "$WSCLIENT" --port "$WGPORT" --tile 0 --out "$OUT.wgws3" --err "$OUT.wgws3.err" <<'EOF'
591 expectstate up 25000
592 attach 0 0 wghost
593 settle 500 20000
594 send exit\n
595 expectups 2 30000
596 attach 0 0 wghost
597 expectrefused 25000
598 dumpexit
599 EOF
600 RC=$?
601 set -e
602 [ "$RC" -eq 0 ] || {
603 echo "e2e FAIL: web-restore: the exiting tile's wsclient exited $RC"
604 cat -v "$OUT.wgws3.err" 2>/dev/null; cat "$OUT.wgh"; exit 1; }
605
606 # Daemon truth for the same claim: the session the user ended is gone and
607 # stayed gone, and the daemon is back to the one session it started with.
608 timeout 20 "$MUXA" status --sock "$SOCK67" --session wghost > "$OUT.wgexit" 2>&1 && {
609 echo "e2e FAIL: web-restore: typing exit got the user a new shell:"
610 cat "$OUT.wgexit"; exit 1; }
611 wait_sessions "$SOCK67" 1 "web-restore: the ended session was not resurrected"
612
613 softkill "$W5PID" || true
614 wait_pid_gone "$W5PID" "web-restore: hub killed by tracked pid"
615 W5PID=""
616 assert_stopped "$SOCK67" "$D67PID" "web-restore" "$OUT.wgstop"
617 D67PID=""
618 rm -rf "$WGSTATE"
619 ok "the browser wall restores a saved local line too, and still joins a remote one only"
620
621 # --- a refusal the birth cannot fix must not spin ----------------------
622 #
623 # The other end of the restore rule: a line the daemon will not attach and
624 # the hub may not create. `client.hydratedCreates` joins a remote spelling
625 # and never births it, so nothing the hub can do makes this attach land.
626 # Every refusal closes the connection (`server.dropObserver`), so the
627 # re-dial that follows succeeds on its first try, the page re-attaches on
628 # `up` as it always does, and the same no comes back — a connect/attach/
629 # close loop bounded by nothing but round-trip latency, on the hub, the
630 # daemon and the browser at once.
631 #
632 # Counted the one way the hub cannot flatter itself: a `.hand` dial forks
633 # its own ssh, and the shim logs its pid before exec, so `wc -l` is a
634 # count of PROCESSES the OS made. A hub counter would be the code under
635 # test grading its own homework.
636 start_daemon "$SOCK68" "$OUT.sp.d" "refusal-spin daemon never bound" --shell /bin/sh
637 D68PID=$DPID
638
639 # This shim ignores the remote command on purpose — it is not modelling
640 # ssh (the M14 shim above does that), it is the dial's process signature
641 # plus a byte pipe to a REAL daemon. `endpoint none` is what keeps every
642 # dial on this path: an announced QUIC endpoint would be cached, the
643 # second dial would skip ssh entirely, and the count with it.
644 mkdir -p "$SPDIR"
645 : > "$SPINLOG"
646 cat > "$SPDIR/ssh" <<SPSHIM
647 #!/bin/sh
648 echo \$\$ >> "$SPINLOG"
649 printf 'endpoint none\n'
650 exec "$MUXD" proxy --sock "$SOCK68"
651 SPSHIM
652 chmod +x "$SPDIR/ssh"
653
654 # One line, a HOST spelling, naming a session the daemon does not have:
655 # the tile attaches at 0x0 (passivity), 0x0 cannot create, and a remote
656 # line may not be birthed.
657 mkdir -p "$SPSTATE/mux"
658 printf 'mux-spin@127.0.0.1#spinghost\n' > "$SPSTATE/mux/wall"
659
660 XDG_STATE_HOME="$SPSTATE" XDG_CACHE_HOME="$SPSTATE/cache" PATH="$SPDIR:$PATH" \
661 "$MUXWEB" --port "$SPPORT" > "$OUT.sph" 2>&1 &
662 W6PID=$!
663 defer_kill "$W6PID"
664 wait_for "$OUT.sph" "serving" 10 || {
665 echo "e2e FAIL: refusal-spin: hub never reported serving"; cat "$OUT.sph"; exit 1; }
666
667 # `reattach` is mux.js's ENV_CONTROL handler, which is the half of the
668 # loop the hub does not own: the page attaches again on every `up`. Six
669 # seconds of it, and the dial log is read the moment the window closes.
670 set +e
671 timeout 90 "$WSCLIENT" --port "$SPPORT" --tile 0 --out "$OUT.spws" --err "$OUT.spws.err" <<'EOF'
672 expectstate up 25000
673 reattach 0 0 6000 spinghost
674 expectrefused 25000
675 dumpexit
676 EOF
677 RC=$?
678 set -e
679 SPDIALS=$(wc -l < "$SPINLOG")
680 [ "$RC" -eq 0 ] || {
681 echo "e2e FAIL: refusal-spin: the wsclient exited $RC after $SPDIALS dials"
682 cat -v "$OUT.spws.err" 2>/dev/null; cat "$OUT.sph"; exit 1; }
683
684 # The bound, and its vacuity guard. Backoff caps at client.nextBackoffMs's
685 # 2s, so six seconds of refusal is a handful of dials; unbounded it is one
686 # per round trip, hundreds. The lower bound is what stops this passing on
687 # a tile that never re-dialled at all — then the ceiling would be measuring
688 # nothing.
689 [ "$SPDIALS" -ge 3 ] || {
690 echo "e2e FAIL: refusal-spin: only $SPDIALS dials in 6s — the tile never"
691 echo " re-dialled, so the ceiling below measured nothing"
692 cat "$OUT.sph"; exit 1; }
693 [ "$SPDIALS" -le 15 ] || {
694 echo "e2e FAIL: refusal-spin: $SPDIALS dials in 6s (want <= 15) — a refusal"
695 echo " the birth cannot fix is spinning with no backoff"
696 cat "$OUT.sph"; exit 1; }
697
698 # ...and it stayed refused: nothing was born behind the hub's back, which
699 # is what makes the count above a count of REFUSED dials.
700 timeout 20 "$MUXA" status --sock "$SOCK68" --session spinghost > "$OUT.spghost" 2>&1 && {
701 echo "e2e FAIL: refusal-spin: a remote wall line created a session:"
702 cat "$OUT.spghost"; exit 1; }
703
704 softkill "$W6PID" || true
705 wait_pid_gone "$W6PID" "refusal-spin: hub killed by tracked pid"
706 W6PID=""
707 assert_stopped "$SOCK68" "$D68PID" "refusal-spin" "$OUT.spstop"
708 D68PID=""
709 rm -rf "$SPSTATE" "$SPDIR"
710 ok "a refusal the birth cannot fix backs off instead of spinning"