ff52fa6d
test: 13_birth moves to a file of its own
a73x 2026-08-26 19:18
Commit message
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" | ||