ea9d9ce9
test: 06_web moves to a file of its own
a73x 2026-08-26 18:55
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' | 105 | E2E_GROUPS='01_boot 02_predict 03_side 04_handoff 05_session 06_web' |
| 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,430 +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 pipe_mux client in flight, and its FIFO. Both are registered by | ||
| 149 | # pipe_mux itself; its `timeout` stays the primary guarantee and the trap | ||
| 150 | # is the backstop for a scenario that exits between pipe_mux and | ||
| 151 | # pipe_detach, leaving a client the shell would otherwise orphan. | ||
| 152 | # M-web. Three daemons (the passivity pin's, the hub-basic one, and the | ||
| 153 | # tear scenario's two incarnations share SOCK20), two hub processes, and | ||
| 154 | # a port band of its own following the 5000-spacing convention — INSIDE | ||
| 155 | # the ephemeral range, which is fine for ports we bind (only provably-DEAD | ||
| 156 | # ports must stay below 32768; see HDEADPORT above). | ||
| 157 | SOCK18="${TMPDIR:-/tmp}/muxd-e2e-web-a-$$.sock" | ||
| 158 | defer_sock "$SOCK18" | ||
| 159 | SOCK19="${TMPDIR:-/tmp}/muxd-e2e-web-b-$$.sock" | ||
| 160 | defer_sock "$SOCK19" | ||
| 161 | SOCK20="${TMPDIR:-/tmp}/muxd-e2e-web-c-$$.sock" | ||
| 162 | defer_sock "$SOCK20" | ||
| 163 | WPORT=$(( 41000 + ($$ % 4000) )) | ||
| 164 | WPORT2=$(( 46000 + ($$ % 4000) )) | ||
| 165 | # The dynamic-wall leg: one daemon, one hub restarted three times, and a | ||
| 166 | # STATE HOME of its own on the 61000 band — the last 5000-spaced one that | ||
| 167 | # fits under 65535, the QUIC block's 56000 being the one before. The state | ||
| 168 | # home is the load-bearing part: this leg reads the wall file back as an | ||
| 169 | # artifact, and $XDG_STATE_HOME above is shared with every other scenario — | ||
| 170 | # the M-web and M18 hubs write their argv walls there, so a wall read out of | ||
| 171 | # it would be some other block's. Never the developer's ~/.local/state either; that is | ||
| 172 | # why every muxweb here is spawned with the override in front of it. | ||
| 173 | SOCK25="${TMPDIR:-/tmp}/muxd-e2e-dynwall-$$.sock" | ||
| 174 | defer_sock "$SOCK25" | ||
| 175 | WPORT4=$(( 61000 + ($$ % 4000) )) | ||
| 176 | DWSTATE="${TMPDIR:-/tmp}/mux-e2e-dynwall-state-$$" | ||
| 177 | defer_rm "$DWSTATE" | ||
| 178 | |||
| 179 | # --- a 1x1 attach is refused the grid and can never claim it. | ||
| 180 | # The degenerate size a client can genuinely arrive with — a terminal one | ||
| 181 | # column wide — driven here on a real 1x1 pty. Nothing spells passivity | ||
| 182 | # this way (that is 0x0, and it is refused by contract); this leg is the | ||
| 183 | # daemon staying unclaimable on the size threshold ALONE. Pinned | ||
| 184 | # DAEMON-SIDE and hub-free on purpose: the mechanism is applySize's cols<2 | ||
| 185 | # refusal plus claimGrid's 0x0-slot refusal (server.zig), and it must hold | ||
| 186 | # for any client that sends a size nobody can live at. The probe for "the grid did not move" is textual: a marker | ||
| 187 | # string typed at 80 wide can only appear CONTIGUOUS in `muxd dump` if | ||
| 188 | # the grid is still 80 wide — a 1-column grid puts every glyph on its | ||
| 189 | # own row, so grep itself is the geometry assertion. | ||
| 190 | start_daemon "$SOCK18" "$OUT.weba.d" "tiny attach daemon never bound" --shell /bin/sh | ||
| 191 | D14PID=$DPID | ||
| 192 | |||
| 193 | # The 80x24 client: types the first marker, holds the session open while | ||
| 194 | # the 1x1 attacher comes and goes, types the second marker (the geometry | ||
| 195 | # probe) once the attacher is provably on, detaches. | ||
| 196 | pipe_mux "$OUT.weba" "$OUT.weba.err" timeout 40 "$MUX" --sock "$SOCK18" | ||
| 197 | pipe_send 'printf "wall-%%s\\n" pin\n' | ||
| 198 | wait_grid "$SOCK18" "wall-pin" "tiny attach: the first marker" | ||
| 199 | |||
| 200 | # The 1x1 attacher, on a real 1x1 pty. Its attach is answered with a | ||
| 201 | # unicast snapshot (the alt-screen enter proves the first frame came), | ||
| 202 | # its keystroke is the claim attempt, and the 'z' it waits for is the | ||
| 203 | # second marker echoing — proof it was STILL ATTACHED while the marker | ||
| 204 | # landed, so the grid it could have wrecked was live the whole time. The | ||
| 205 | # marker is typed only after the fixture reports its claim sent (`done 2`), | ||
| 206 | # so the ordering is witnessed rather than paced. | ||
| 207 | timeout 40 "$PTYCLIENT" --cols 1 --rows 1 --out "$OUT.web1x1" --err "$OUT.web1x1.err" \ | ||
| 208 | -- "$MUX" --sock "$SOCK18" > "$OUT.web1x1.log" 2>&1 <<'EOF' & | ||
| 209 | expect \x1b[?1049h 15000 | ||
| 210 | send \x20 | ||
| 211 | expect z 20000 | ||
| 212 | settle 300 10000 | ||
| 213 | send \x1c\x1c | ||
| 214 | waitexit 10000 | ||
| 215 | EOF | ||
| 216 | W1X1PID=$! | ||
| 217 | defer_kill "$W1X1PID" | ||
| 218 | wait_for "$OUT.web1x1.log" "ptyclient: done 2" 20 || { | ||
| 219 | echo "e2e FAIL: tiny attach: the 1x1 client never got as far as its claim" | ||
| 220 | cat "$OUT.web1x1.log"; cat -v "$OUT.web1x1.err" 2>/dev/null; exit 1; } | ||
| 221 | pipe_send 'printf "zz-%%s\\n" web\n' | ||
| 222 | set +e | ||
| 223 | wait "$W1X1PID" | ||
| 224 | RC=$? | ||
| 225 | set -e | ||
| 226 | [ "$RC" -eq 0 ] || { | ||
| 227 | echo "e2e FAIL: tiny attach: 1x1 client exited $RC" | ||
| 228 | cat "$OUT.web1x1.log"; cat -v "$OUT.web1x1.err" 2>/dev/null; exit 1; } | ||
| 229 | |||
| 230 | # The geometry assertion: both markers contiguous AFTER the 1x1 client | ||
| 231 | # attached, typed, and left. A grid moved to 1 wide cannot hold either. | ||
| 232 | "$MUXD" dump --sock "$SOCK18" | grep -q "wall-pin" || { | ||
| 233 | echo "e2e FAIL: tiny attach: first marker lost after 1x1 attach:" | ||
| 234 | "$MUXD" dump --sock "$SOCK18"; exit 1; } | ||
| 235 | "$MUXD" dump --sock "$SOCK18" | grep -q "zz-web" || { | ||
| 236 | echo "e2e FAIL: tiny attach: the 1x1 attacher moved the grid (marker not contiguous):" | ||
| 237 | "$MUXD" dump --sock "$SOCK18"; exit 1; } | ||
| 238 | |||
| 239 | await_out "$OUT.weba" "zz-web" "tiny attach: the second marker never reached the 80x24 client" | ||
| 240 | pipe_detach "tiny attach: 80x24 client" | ||
| 241 | assert_converged "$OUT.weba" "$SOCK18" "tiny attach: the 80x24 client never glitched" | ||
| 242 | |||
| 243 | assert_stopped "$SOCK18" "$D14PID" "tiny attach" "$OUT.webstop" | ||
| 244 | D14PID="" | ||
| 245 | ok "a 1x1 attach is refused the grid and can never claim it" | ||
| 246 | |||
| 247 | # --- M-web (b): the hub pumps a real session; wrong Origin refused. | ||
| 248 | start_daemon "$SOCK19" "$OUT.webb.d" "web hub daemon never bound" --shell /bin/sh | ||
| 249 | D15PID=$DPID | ||
| 250 | "$MUXWEB" --sock "$SOCK19" --port "$WPORT" > "$OUT.webh" 2>&1 & | ||
| 251 | W1PID=$! | ||
| 252 | defer_kill "$W1PID" | ||
| 253 | wait_for "$OUT.webh" "serving" 10 || { | ||
| 254 | echo "e2e FAIL: hub never reported serving"; cat "$OUT.webh"; exit 1; } | ||
| 255 | |||
| 256 | # The typing client stays attached until after the browser leg below, so | ||
| 257 | # the hub pumps a session that has a live CLI client on it too. | ||
| 258 | pipe_mux "$OUT.webb" "$OUT.webb.err" timeout 40 "$MUX" --sock "$SOCK19" | ||
| 259 | pipe_send 'printf "web-%%s\\n" b1\n' | ||
| 260 | wait_grid "$SOCK19" "web-b1" "web hub: the typing client's marker" | ||
| 261 | |||
| 262 | # The browser stand-in: a passive 0x0 wall tile through the hub. Its | ||
| 263 | # final grid is the daemon's replica as REPLAYED THROUGH the WebSocket | ||
| 264 | # leg, dumped in muxd dump's own format. | ||
| 265 | set +e | ||
| 266 | timeout 40 "$WSCLIENT" --port "$WPORT" --tile 0 --out "$OUT.webws" --err "$OUT.webws.err" <<'EOF' | ||
| 267 | attach 0 0 | ||
| 268 | expectstate up 10000 | ||
| 269 | expectgrid web-b1 15000 | ||
| 270 | settle 500 10000 | ||
| 271 | dumpexit | ||
| 272 | EOF | ||
| 273 | RC=$? | ||
| 274 | set -e | ||
| 275 | [ "$RC" -eq 0 ] || { | ||
| 276 | echo "e2e FAIL: web hub: wsclient exited $RC" | ||
| 277 | cat -v "$OUT.webws.err" 2>/dev/null; cat "$OUT.webh"; exit 1; } | ||
| 278 | |||
| 279 | assert_ws_converged "$OUT.webws" "$SOCK19" "web hub" | ||
| 280 | |||
| 281 | # Wrong Origin: refused at HTTP, before any upgrade — the wsclient sees | ||
| 282 | # a non-101 and exits 4 — and the daemon never sees a client for it. | ||
| 283 | CLIENTS_BEFORE=$("$MUXD" stats --sock "$SOCK19" | sed -n 's/.*clients=\([0-9]*\).*/\1/p') | ||
| 284 | set +e | ||
| 285 | timeout 20 "$WSCLIENT" --port "$WPORT" --tile 0 --origin http://evil.example \ | ||
| 286 | --out "$OUT.webevil" --err "$OUT.webevil.err" < /dev/null | ||
| 287 | RC=$? | ||
| 288 | set -e | ||
| 289 | [ "$RC" -eq 4 ] || { | ||
| 290 | echo "e2e FAIL: web hub: evil-origin wsclient exited $RC, want 4" | ||
| 291 | cat -v "$OUT.webevil.err" 2>/dev/null; exit 1; } | ||
| 292 | grep -q "upgrade refused.*403" "$OUT.webevil.err" || { | ||
| 293 | echo "e2e FAIL: web hub: refusal was not the 403 it must be:" | ||
| 294 | cat -v "$OUT.webevil.err"; exit 1; } | ||
| 295 | CLIENTS_AFTER=$("$MUXD" stats --sock "$SOCK19" | sed -n 's/.*clients=\([0-9]*\).*/\1/p') | ||
| 296 | [ "$CLIENTS_BEFORE" = "$CLIENTS_AFTER" ] || { | ||
| 297 | echo "e2e FAIL: web hub: evil origin reached the daemon (clients $CLIENTS_BEFORE -> $CLIENTS_AFTER)"; exit 1; } | ||
| 298 | |||
| 299 | pipe_detach "web hub: typing client" | ||
| 300 | assert_converged "$OUT.webb" "$SOCK19" "web hub: the typing client" | ||
| 301 | |||
| 302 | softkill "$W1PID" || true | ||
| 303 | wait_pid_gone "$W1PID" "web hub: killed by tracked pid" | ||
| 304 | W1PID="" | ||
| 305 | assert_stopped "$SOCK19" "$D15PID" "web hub" "$OUT.webstop2" | ||
| 306 | D15PID="" | ||
| 307 | ok "hub pumps a real session; wrong origin refused" | ||
| 308 | |||
| 309 | # --- M-web (c): the hub narrates the tear; the replica re-attaches | ||
| 310 | # across an epoch. muxd stop kills the daemon under a live tile; the hub | ||
| 311 | # must say `reconnecting`, redial the restarted daemon (same socket, new | ||
| 312 | # epoch), say `up`, and the stand-in's re-attach — quoting coordinates | ||
| 313 | # the NEW daemon has never issued — must be answered with a snapshot | ||
| 314 | # that converges on the new session's content. | ||
| 315 | start_daemon "$SOCK20" "$OUT.webc.d" "web tear daemon never bound" --shell /bin/sh | ||
| 316 | D16PID=$DPID | ||
| 317 | "$MUXWEB" --sock "$SOCK20" --port "$WPORT2" > "$OUT.webh2" 2>&1 & | ||
| 318 | W2PID=$! | ||
| 319 | defer_kill "$W2PID" | ||
| 320 | wait_for "$OUT.webh2" "serving" 10 || { | ||
| 321 | echo "e2e FAIL: tear hub never reported serving"; cat "$OUT.webh2"; exit 1; } | ||
| 322 | |||
| 323 | pipe_mux "$OUT.webc" "$OUT.webc.err" timeout 30 "$MUX" --sock "$SOCK20" | ||
| 324 | pipe_send 'printf "web-%%s\\n" c1\n' | ||
| 325 | await_out "$OUT.webc" "web-c1" "web-c1 never reached the client" | ||
| 326 | pipe_detach | ||
| 327 | wait_grid "$SOCK20" "web-c1" "web tear: the first session's marker" | ||
| 328 | |||
| 329 | timeout 90 "$WSCLIENT" --port "$WPORT2" --tile 0 --out "$OUT.webws2" --err "$OUT.webws2.err" <<'EOF' & | ||
| 330 | attach 0 0 | ||
| 331 | expectstate up 10000 | ||
| 332 | expectgrid web-c1 15000 | ||
| 333 | expectstate reconnecting 25000 | ||
| 334 | expectstate up 25000 | ||
| 335 | attach 0 0 | ||
| 336 | expectgrid web-c2 30000 | ||
| 337 | settle 500 10000 | ||
| 338 | dumpexit | ||
| 339 | EOF | ||
| 340 | WCLIPID=$! | ||
| 341 | defer_kill "$WCLIPID" | ||
| 342 | # Let the stand-in reach its first expectgrid before the tear; the marker | ||
| 343 | # it waits for is already on the grid, so one settle-length is plenty. | ||
| 344 | sleep 1 | ||
| 345 | |||
| 346 | assert_stopped "$SOCK20" "$D16PID" "web tear: the first daemon, under a live tile" "$OUT.webstop3" | ||
| 347 | D16PID="" | ||
| 348 | |||
| 349 | start_daemon "$SOCK20" "$OUT.webc2.d" "web tear restart never bound" --shell /bin/sh | ||
| 350 | D17PID=$DPID | ||
| 351 | |||
| 352 | pipe_mux "$OUT.webc2" "$OUT.webc2.err" timeout 30 "$MUX" --sock "$SOCK20" | ||
| 353 | pipe_send 'printf "web-%%s\\n" c2\n' | ||
| 354 | await_out "$OUT.webc2" "web-c2" "web-c2 never reached the client" | ||
| 355 | pipe_detach | ||
| 356 | |||
| 357 | set +e | ||
| 358 | wait "$WCLIPID" | ||
| 359 | RC=$? | ||
| 360 | set -e | ||
| 361 | WCLIPID="" | ||
| 362 | [ "$RC" -eq 0 ] || { | ||
| 363 | echo "e2e FAIL: web tear: wsclient exited $RC" | ||
| 364 | cat -v "$OUT.webws2.err" 2>/dev/null; cat "$OUT.webh2"; exit 1; } | ||
| 365 | |||
| 366 | assert_ws_converged "$OUT.webws2" "$SOCK20" "web tear: after the epoch crossing" | ||
| 367 | |||
| 368 | softkill "$W2PID" || true | ||
| 369 | wait_pid_gone "$W2PID" "web tear: hub killed by tracked pid" | ||
| 370 | W2PID="" | ||
| 371 | # Its own capture, not a second write to $OUT.webstop3: that file still | ||
| 372 | # holds the FIRST stop's stderr, and overwriting it before anything reads | ||
| 373 | # it is how the evidence for a failure up there disappears. | ||
| 374 | assert_stopped "$SOCK20" "$D17PID" "web tear: the restarted daemon" "$OUT.webstop4" | ||
| 375 | D17PID="" | ||
| 376 | ok "hub narrates the tear; the replica re-attaches across an epoch" | ||
| 377 | |||
| 378 | # --- M-wall: the wall is RUNTIME state. The page adds, removes and | ||
| 379 | # reorders tiles over HTTP, and the file is what makes that survive a | ||
| 380 | # restart. Every muxweb here runs with a state home of its own (see | ||
| 381 | # $DWSTATE), so what this leg reads back is the wall this leg wrote — and | ||
| 382 | # never the developer's real one. | ||
| 383 | start_daemon "$SOCK25" "$OUT.dw.d" "dyn wall daemon never bound" --shell /bin/sh | ||
| 384 | D22PID=$DPID | ||
| 385 | |||
| 386 | XDG_STATE_HOME="$DWSTATE" "$MUXWEB" --port "$WPORT4" > "$OUT.dwh" 2>&1 & | ||
| 387 | W4PID=$! | ||
| 388 | defer_kill "$W4PID" | ||
| 389 | wait_for "$OUT.dwh" "serving" 10 || { | ||
| 390 | echo "e2e FAIL: dyn wall: hub never reported serving"; cat "$OUT.dwh"; exit 1; } | ||
| 391 | DWORIG="http://127.0.0.1:$WPORT4" | ||
| 392 | |||
| 393 | # No argv and no file: the hub serves an EMPTY wall rather than refusing to | ||
| 394 | # start. That is the whole premise of a page that can build its own wall. | ||
| 395 | [ "$(curl -s "$DWORIG/tiles")" = "[]" ] || { | ||
| 396 | echo "e2e FAIL: dyn wall: fresh hub wall not empty; it holds:" | ||
| 397 | curl -s "$DWORIG/tiles"; exit 1; } | ||
| 398 | |||
| 399 | # No Origin: refused before anything mutates. A text/plain POST is a CSRF | ||
| 400 | # "simple request" any page can fire at localhost, so the gate is what keeps | ||
| 401 | # this page's power this page's — and the wall must be untouched after it. | ||
| 402 | RC=$(curl -s -o /dev/null -w '%{http_code}' -X POST --data "--sock $SOCK25" "$DWORIG/tiles") | ||
| 403 | [ "$RC" = "403" ] || { echo "e2e FAIL: dyn wall: originless POST got $RC, want 403"; exit 1; } | ||
| 404 | [ "$(curl -s "$DWORIG/tiles")" = "[]" ] || { | ||
| 405 | echo "e2e FAIL: dyn wall: the refused POST still reached the wall:" | ||
| 406 | curl -s "$DWORIG/tiles"; exit 1; } | ||
| 407 | |||
| 408 | # Route precision: /tiles is an EXACT match, not a prefix — `/tilesgarbage` | ||
| 409 | # must not fall into the mutation block and add a tile. | ||
| 410 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X POST --data "--sock $SOCK25" "$DWORIG/tilesgarbage") | ||
| 411 | [ "$RC" = "404" ] || { echo "e2e FAIL: dyn wall: /tilesgarbage POST got $RC, want 404"; exit 1; } | ||
| 412 | [ "$(curl -s "$DWORIG/tiles")" = "[]" ] || { | ||
| 413 | echo "e2e FAIL: dyn wall: /tilesgarbage POST still added a tile:" | ||
| 414 | curl -s "$DWORIG/tiles"; exit 1; } | ||
| 415 | |||
| 416 | # Two tiles on one socket: the default session and session b, the M18 | ||
| 417 | # one-name-one-session doctrine. Ids are birth order and are the hub's own — | ||
| 418 | # `/ws/<id>`, not a position — which is what the next assertions ride on. | ||
| 419 | R=$(curl -s -H "Origin: $DWORIG" -X POST --data "--sock $SOCK25" "$DWORIG/tiles") | ||
| 420 | [ "$R" = '{"id":0}' ] || { echo "e2e FAIL: dyn wall: first add returned $R, want id 0"; exit 1; } | ||
| 421 | R=$(curl -s -H "Origin: $DWORIG" -X POST --data "--sock $SOCK25#b" "$DWORIG/tiles") | ||
| 422 | [ "$R" = '{"id":1}' ] || { echo "e2e FAIL: dyn wall: second add returned $R, want id 1"; exit 1; } | ||
| 423 | |||
| 424 | # The two refusals that happen at ADD time rather than at dial time, which | ||
| 425 | # is the point of both: a malformed session name, and a socket path longer | ||
| 426 | # than sun_path. A tile that can never attach must not reach the wall. | ||
| 427 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X POST --data 'h#bad name' "$DWORIG/tiles") | ||
| 428 | [ "$RC" = "400" ] || { echo "e2e FAIL: dyn wall: bad session name got $RC, want 400"; exit 1; } | ||
| 429 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X POST \ | ||
| 430 | --data '--sock /tmp/dyn-wall-socket-path-far-longer-than-the-108-byte-sun_path-limit-so-the-add-refuses-here-not-at-dial-time.sock' \ | ||
| 431 | "$DWORIG/tiles") | ||
| 432 | [ "$RC" = "400" ] || { echo "e2e FAIL: dyn wall: over-long socket path got $RC, want 400"; exit 1; } | ||
| 433 | |||
| 434 | # A POSTed tile is a REAL tile: a marker typed through the CLI door is read | ||
| 435 | # back through the WebSocket door of the tile the page just created. The | ||
| 436 | # stand-in attaches at 0x0 and the CLI client has already detached, so the | ||
| 437 | # passivity contract is in play in passing — a tile that claimed no size | ||
| 438 | # never moves the grid, and the 80-wide marker is still one contiguous | ||
| 439 | # string to expect. | ||
| 440 | pipe_mux "$OUT.dwcli" "$OUT.dwcli.err" timeout 40 "$MUX" --sock "$SOCK25" | ||
| 441 | pipe_send 'printf "dyn-%%s\\n" w1\n' | ||
| 442 | await_out "$OUT.dwcli" "dyn-w1" "dyn-w1 never reached the client" | ||
| 443 | pipe_detach "dyn wall: CLI client" | ||
| 444 | wait_grid "$SOCK25" "dyn-w1" "dyn wall: CLI marker" | ||
| 445 | set +e | ||
| 446 | timeout 40 "$WSCLIENT" --port "$WPORT4" --tile 0 --out "$OUT.dwws" --err "$OUT.dwws.err" <<'EOF' | ||
| 447 | attach 0 0 | ||
| 448 | expectstate up 10000 | ||
| 449 | expectgrid dyn-w1 15000 | ||
| 450 | dumpexit | ||
| 451 | EOF | ||
| 452 | RC=$? | ||
| 453 | set -e | ||
| 454 | [ "$RC" -eq 0 ] || { | ||
| 455 | echo "e2e FAIL: dyn wall: wsclient exited $RC" | ||
| 456 | cat -v "$OUT.dwws.err" 2>/dev/null; cat "$OUT.dwh"; exit 1; } | ||
| 457 | |||
| 458 | # Reorder is the FULL new order by id, and the 204 is BODYLESS — a reorder | ||
| 459 | # that took is read back with a GET, never inferred from the status. | ||
| 460 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X PUT --data '1,0' "$DWORIG/tiles") | ||
| 461 | [ "$RC" = "204" ] || { echo "e2e FAIL: dyn wall: reorder got $RC, want 204"; exit 1; } | ||
| 462 | curl -s "$DWORIG/tiles" | grep -q '^\[{"id":1,' || { | ||
| 463 | echo "e2e FAIL: dyn wall: reorder not reflected; the wall holds:" | ||
| 464 | curl -s "$DWORIG/tiles"; exit 1; } | ||
| 465 | # ...and it reached the FILE, which is the half a restart will read back. | ||
| 466 | # Asserted here rather than after the delete, because this is the only | ||
| 467 | # moment the wall has two lines and "order" means anything on disk at all. | ||
| 468 | [ "$(head -1 "$DWSTATE/mux/wall")" = "--sock $SOCK25#b" ] || { | ||
| 469 | echo "e2e FAIL: dyn wall: reorder did not reach the file; it holds:" | ||
| 470 | cat "$DWSTATE/mux/wall"; exit 1; } | ||
| 471 | # An order naming an id the hub does not have is a 409 carrying a copy of | ||
| 472 | # the truth — never a guess at what the stale page meant. | ||
| 473 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X PUT --data '0,7' "$DWORIG/tiles") | ||
| 474 | [ "$RC" = "409" ] || { echo "e2e FAIL: dyn wall: stale reorder got $RC, want 409"; exit 1; } | ||
| 475 | curl -s -H "Origin: $DWORIG" -X PUT --data '0,7' "$DWORIG/tiles" | grep -q '^\[{"id":1,' || { | ||
| 476 | echo "e2e FAIL: dyn wall: the 409 did not carry the current wall"; exit 1; } | ||
| 477 | |||
| 478 | # Remove is DETACH, not kill: the tile leaves the wall, the daemon keeps | ||
| 479 | # every session it had. `sessions=` is stats' own word for that — and the | ||
| 480 | # reading is asserted NON-EMPTY before it is compared, because both sides | ||
| 481 | # come out of the same sed: a stats line that renamed or dropped the field | ||
| 482 | # would make this `[ "" = "" ]` and pass forever, which is the one way this | ||
| 483 | # check could fail green (want_stat, above, guards its own the same way). | ||
| 484 | SESS_BEFORE=$("$MUXD" stats --sock "$SOCK25" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p') | ||
| 485 | [ -n "$SESS_BEFORE" ] || { | ||
| 486 | echo "e2e FAIL: dyn wall: no sessions= in stats (the field moved?); it says:" | ||
| 487 | "$MUXD" stats --sock "$SOCK25"; exit 1; } | ||
| 488 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X DELETE "$DWORIG/tiles/0") | ||
| 489 | [ "$RC" = "204" ] || { echo "e2e FAIL: dyn wall: delete got $RC, want 204"; exit 1; } | ||
| 490 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X DELETE "$DWORIG/tiles/0") | ||
| 491 | [ "$RC" = "404" ] || { echo "e2e FAIL: dyn wall: second delete got $RC, want 404"; exit 1; } | ||
| 492 | SESS_AFTER=$("$MUXD" stats --sock "$SOCK25" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p') | ||
| 493 | [ "$SESS_BEFORE" = "$SESS_AFTER" ] || { | ||
| 494 | echo "e2e FAIL: dyn wall: DELETE killed a session ($SESS_BEFORE -> $SESS_AFTER)"; exit 1; } | ||
| 495 | |||
| 496 | # ...and the id it took with it is answered in HTTP: a 404 the page can | ||
| 497 | # read, rather than an upgrade followed by a silent close it can only guess | ||
| 498 | # at. The stand-in sees a non-101 and exits 4, the wrong-Origin scenario's | ||
| 499 | # shape. | ||
| 500 | set +e | ||
| 501 | timeout 20 "$WSCLIENT" --port "$WPORT4" --tile 0 \ | ||
| 502 | --out "$OUT.dwdead" --err "$OUT.dwdead.err" < /dev/null | ||
| 503 | RC=$? | ||
| 504 | set -e | ||
| 505 | [ "$RC" -eq 4 ] || { | ||
| 506 | echo "e2e FAIL: dyn wall: wsclient on a removed tile exited $RC, want 4" | ||
| 507 | cat -v "$OUT.dwdead.err" 2>/dev/null; exit 1; } | ||
| 508 | grep -q "upgrade refused.*404" "$OUT.dwdead.err" || { | ||
| 509 | echo "e2e FAIL: dyn wall: a dead id was not refused with a 404:" | ||
| 510 | cat -v "$OUT.dwdead.err"; exit 1; } | ||
| 511 | |||
| 512 | # The file carries SPELLINGS, one per line, in wall order — no ids, because | ||
| 513 | # ids are per-run. The surviving tile is the `#b` one the delete spared. | ||
| 514 | grep -qxF -- "--sock $SOCK25#b" "$DWSTATE/mux/wall" || { | ||
| 515 | echo "e2e FAIL: dyn wall: state file missing the surviving tile; it holds:" | ||
| 516 | cat "$DWSTATE/mux/wall"; exit 1; } | ||
| 517 | [ "$(wc -l < "$DWSTATE/mux/wall")" = "1" ] || { | ||
| 518 | echo "e2e FAIL: dyn wall: state file is not the one surviving line:" | ||
| 519 | cat "$DWSTATE/mux/wall"; exit 1; } | ||
| 520 | |||
| 521 | # Restart with NO argv: the file is the wall. The id is fresh — a restarted | ||
| 522 | # hub numbers from 0 in wall order — and the SPELLING is what persisted. | ||
| 523 | softkill "$W4PID" || true | ||
| 524 | wait_pid_gone "$W4PID" "dyn wall: first hub killed by tracked pid" | ||
| 525 | XDG_STATE_HOME="$DWSTATE" "$MUXWEB" --port "$WPORT4" > "$OUT.dwh2" 2>&1 & | ||
| 526 | W4PID=$! | ||
| 527 | defer_kill "$W4PID" | ||
| 528 | wait_for "$OUT.dwh2" "serving" 10 || { | ||
| 529 | echo "e2e FAIL: dyn wall: restored hub never reported serving"; cat "$OUT.dwh2"; exit 1; } | ||
| 530 | curl -s "$DWORIG/tiles" | grep -q "^\[{\"id\":0,\"label\":\"--sock $SOCK25#b\"" || { | ||
| 531 | echo "e2e FAIL: dyn wall: restart lost the persisted wall; it holds:" | ||
| 532 | curl -s "$DWORIG/tiles"; exit 1; } | ||
| 533 | |||
| 534 | # Restart WITH argv: the override is the VIEW's, not the file's. This | ||
| 535 | # assertion INVERTED with the attach-history phase and did not merely move. | ||
| 536 | # It used to read "argv appended to the wall instead of replacing it" — a | ||
| 537 | # pin on muxweb overwriting the state file with whatever it was told to | ||
| 538 | # show. That file is the user's attach history now, written by every `mux` | ||
| 539 | # attach, so one `muxweb HOST` overwriting it would silently erase the lot. | ||
| 540 | # Argv tiles are ADDED (deduped by spelling) and nothing is removed; | ||
| 541 | # forgetting stays explicit — the page's `x`, the wall's `x`, `mux wall rm`. | ||
| 542 | # The tile is spelled as ONE quoted argument — the wall file's own | ||
| 543 | # spelling, handed back to the binary that wrote it. | ||
| 544 | softkill "$W4PID" || true | ||
| 545 | wait_pid_gone "$W4PID" "dyn wall: restored hub killed by tracked pid" | ||
| 546 | XDG_STATE_HOME="$DWSTATE" "$MUXWEB" "--sock $SOCK25" --port "$WPORT4" > "$OUT.dwh3" 2>&1 & | ||
| 547 | W4PID=$! | ||
| 548 | defer_kill "$W4PID" | ||
| 549 | wait_for "$OUT.dwh3" "serving" 10 || { | ||
| 550 | echo "e2e FAIL: dyn wall: overriding hub never reported serving"; cat "$OUT.dwh3"; exit 1; } | ||
| 551 | grep -qxF -- "--sock $SOCK25" "$DWSTATE/mux/wall" || { | ||
| 552 | echo "e2e FAIL: dyn wall: argv did not reach the wall file; it holds:" | ||
| 553 | cat "$DWSTATE/mux/wall"; exit 1; } | ||
| 554 | # ...and the line that was already there SURVIVED it. Both halves, because | ||
| 555 | # either alone is satisfied by the wrong behaviour: the grep above passes | ||
| 556 | # on an overwrite, and a count alone would not say which lines are which. | ||
| 557 | grep -qxF -- "--sock $SOCK25#b" "$DWSTATE/mux/wall" || { | ||
| 558 | echo "e2e FAIL: dyn wall: argv ERASED the wall it was added to — an" | ||
| 559 | echo " attach history overwritten by one muxweb invocation:" | ||
| 560 | cat "$DWSTATE/mux/wall"; exit 1; } | ||
| 561 | [ "$(wc -l < "$DWSTATE/mux/wall")" = "2" ] || { | ||
| 562 | echo "e2e FAIL: dyn wall: the wall file is not the two lines argv added to:" | ||
| 563 | cat "$DWSTATE/mux/wall"; exit 1; } | ||
| 564 | |||
| 565 | softkill "$W4PID" || true | ||
| 566 | wait_pid_gone "$W4PID" "dyn wall: overriding hub killed by tracked pid" | ||
| 567 | W4PID="" | ||
| 568 | assert_stopped "$SOCK25" "$D22PID" "dyn wall" "$OUT.dwstop" | ||
| 569 | D22PID="" | ||
| 570 | ok "the wall is runtime state: add, remove, reorder, restore, argv adds" | ||
| 571 | |||
| 572 | # The CLI wall (`mux wall`): its own daemon, so its two sessions can't be | 148 | # The CLI wall (`mux wall`): its own daemon, so its two sessions can't be |
| 573 | # confused with any other block's. | 149 | # confused with any other block's. |
| 574 | SOCK26="${TMPDIR:-/tmp}/muxd-e2e-cliwall-$$.sock" | 150 | SOCK26="${TMPDIR:-/tmp}/muxd-e2e-cliwall-$$.sock" |
test/e2e_06_web.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,427 @@ | |||
| 1 | # shellcheck shell=sh | ||
| 2 | # e2e_06_web.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 pipe_mux client in flight, and its FIFO. Both are registered by | ||
| 6 | # pipe_mux itself; its `timeout` stays the primary guarantee and the trap | ||
| 7 | # is the backstop for a scenario that exits between pipe_mux and | ||
| 8 | # pipe_detach, leaving a client the shell would otherwise orphan. | ||
| 9 | # M-web. Three daemons (the passivity pin's, the hub-basic one, and the | ||
| 10 | # tear scenario's two incarnations share SOCK20), two hub processes, and | ||
| 11 | # a port band of its own following the 5000-spacing convention — INSIDE | ||
| 12 | # the ephemeral range, which is fine for ports we bind (only provably-DEAD | ||
| 13 | # ports must stay below 32768; see HDEADPORT above). | ||
| 14 | SOCK18="${TMPDIR:-/tmp}/muxd-e2e-web-a-$$.sock" | ||
| 15 | defer_sock "$SOCK18" | ||
| 16 | SOCK19="${TMPDIR:-/tmp}/muxd-e2e-web-b-$$.sock" | ||
| 17 | defer_sock "$SOCK19" | ||
| 18 | SOCK20="${TMPDIR:-/tmp}/muxd-e2e-web-c-$$.sock" | ||
| 19 | defer_sock "$SOCK20" | ||
| 20 | WPORT=$(( 41000 + ($$ % 4000) )) | ||
| 21 | WPORT2=$(( 46000 + ($$ % 4000) )) | ||
| 22 | # The dynamic-wall leg: one daemon, one hub restarted three times, and a | ||
| 23 | # STATE HOME of its own on the 61000 band — the last 5000-spaced one that | ||
| 24 | # fits under 65535, the QUIC block's 56000 being the one before. The state | ||
| 25 | # home is the load-bearing part: this leg reads the wall file back as an | ||
| 26 | # artifact, and $XDG_STATE_HOME above is shared with every other scenario — | ||
| 27 | # the M-web and M18 hubs write their argv walls there, so a wall read out of | ||
| 28 | # it would be some other block's. Never the developer's ~/.local/state either; that is | ||
| 29 | # why every muxweb here is spawned with the override in front of it. | ||
| 30 | SOCK25="${TMPDIR:-/tmp}/muxd-e2e-dynwall-$$.sock" | ||
| 31 | defer_sock "$SOCK25" | ||
| 32 | WPORT4=$(( 61000 + ($$ % 4000) )) | ||
| 33 | DWSTATE="${TMPDIR:-/tmp}/mux-e2e-dynwall-state-$$" | ||
| 34 | defer_rm "$DWSTATE" | ||
| 35 | |||
| 36 | # --- a 1x1 attach is refused the grid and can never claim it. | ||
| 37 | # The degenerate size a client can genuinely arrive with — a terminal one | ||
| 38 | # column wide — driven here on a real 1x1 pty. Nothing spells passivity | ||
| 39 | # this way (that is 0x0, and it is refused by contract); this leg is the | ||
| 40 | # daemon staying unclaimable on the size threshold ALONE. Pinned | ||
| 41 | # DAEMON-SIDE and hub-free on purpose: the mechanism is applySize's cols<2 | ||
| 42 | # refusal plus claimGrid's 0x0-slot refusal (server.zig), and it must hold | ||
| 43 | # for any client that sends a size nobody can live at. The probe for "the grid did not move" is textual: a marker | ||
| 44 | # string typed at 80 wide can only appear CONTIGUOUS in `muxd dump` if | ||
| 45 | # the grid is still 80 wide — a 1-column grid puts every glyph on its | ||
| 46 | # own row, so grep itself is the geometry assertion. | ||
| 47 | start_daemon "$SOCK18" "$OUT.weba.d" "tiny attach daemon never bound" --shell /bin/sh | ||
| 48 | D14PID=$DPID | ||
| 49 | |||
| 50 | # The 80x24 client: types the first marker, holds the session open while | ||
| 51 | # the 1x1 attacher comes and goes, types the second marker (the geometry | ||
| 52 | # probe) once the attacher is provably on, detaches. | ||
| 53 | pipe_mux "$OUT.weba" "$OUT.weba.err" timeout 40 "$MUX" --sock "$SOCK18" | ||
| 54 | pipe_send 'printf "wall-%%s\\n" pin\n' | ||
| 55 | wait_grid "$SOCK18" "wall-pin" "tiny attach: the first marker" | ||
| 56 | |||
| 57 | # The 1x1 attacher, on a real 1x1 pty. Its attach is answered with a | ||
| 58 | # unicast snapshot (the alt-screen enter proves the first frame came), | ||
| 59 | # its keystroke is the claim attempt, and the 'z' it waits for is the | ||
| 60 | # second marker echoing — proof it was STILL ATTACHED while the marker | ||
| 61 | # landed, so the grid it could have wrecked was live the whole time. The | ||
| 62 | # marker is typed only after the fixture reports its claim sent (`done 2`), | ||
| 63 | # so the ordering is witnessed rather than paced. | ||
| 64 | timeout 40 "$PTYCLIENT" --cols 1 --rows 1 --out "$OUT.web1x1" --err "$OUT.web1x1.err" \ | ||
| 65 | -- "$MUX" --sock "$SOCK18" > "$OUT.web1x1.log" 2>&1 <<'EOF' & | ||
| 66 | expect \x1b[?1049h 15000 | ||
| 67 | send \x20 | ||
| 68 | expect z 20000 | ||
| 69 | settle 300 10000 | ||
| 70 | send \x1c\x1c | ||
| 71 | waitexit 10000 | ||
| 72 | EOF | ||
| 73 | W1X1PID=$! | ||
| 74 | defer_kill "$W1X1PID" | ||
| 75 | wait_for "$OUT.web1x1.log" "ptyclient: done 2" 20 || { | ||
| 76 | echo "e2e FAIL: tiny attach: the 1x1 client never got as far as its claim" | ||
| 77 | cat "$OUT.web1x1.log"; cat -v "$OUT.web1x1.err" 2>/dev/null; exit 1; } | ||
| 78 | pipe_send 'printf "zz-%%s\\n" web\n' | ||
| 79 | set +e | ||
| 80 | wait "$W1X1PID" | ||
| 81 | RC=$? | ||
| 82 | set -e | ||
| 83 | [ "$RC" -eq 0 ] || { | ||
| 84 | echo "e2e FAIL: tiny attach: 1x1 client exited $RC" | ||
| 85 | cat "$OUT.web1x1.log"; cat -v "$OUT.web1x1.err" 2>/dev/null; exit 1; } | ||
| 86 | |||
| 87 | # The geometry assertion: both markers contiguous AFTER the 1x1 client | ||
| 88 | # attached, typed, and left. A grid moved to 1 wide cannot hold either. | ||
| 89 | "$MUXD" dump --sock "$SOCK18" | grep -q "wall-pin" || { | ||
| 90 | echo "e2e FAIL: tiny attach: first marker lost after 1x1 attach:" | ||
| 91 | "$MUXD" dump --sock "$SOCK18"; exit 1; } | ||
| 92 | "$MUXD" dump --sock "$SOCK18" | grep -q "zz-web" || { | ||
| 93 | echo "e2e FAIL: tiny attach: the 1x1 attacher moved the grid (marker not contiguous):" | ||
| 94 | "$MUXD" dump --sock "$SOCK18"; exit 1; } | ||
| 95 | |||
| 96 | await_out "$OUT.weba" "zz-web" "tiny attach: the second marker never reached the 80x24 client" | ||
| 97 | pipe_detach "tiny attach: 80x24 client" | ||
| 98 | assert_converged "$OUT.weba" "$SOCK18" "tiny attach: the 80x24 client never glitched" | ||
| 99 | |||
| 100 | assert_stopped "$SOCK18" "$D14PID" "tiny attach" "$OUT.webstop" | ||
| 101 | D14PID="" | ||
| 102 | ok "a 1x1 attach is refused the grid and can never claim it" | ||
| 103 | |||
| 104 | # --- M-web (b): the hub pumps a real session; wrong Origin refused. | ||
| 105 | start_daemon "$SOCK19" "$OUT.webb.d" "web hub daemon never bound" --shell /bin/sh | ||
| 106 | D15PID=$DPID | ||
| 107 | "$MUXWEB" --sock "$SOCK19" --port "$WPORT" > "$OUT.webh" 2>&1 & | ||
| 108 | W1PID=$! | ||
| 109 | defer_kill "$W1PID" | ||
| 110 | wait_for "$OUT.webh" "serving" 10 || { | ||
| 111 | echo "e2e FAIL: hub never reported serving"; cat "$OUT.webh"; exit 1; } | ||
| 112 | |||
| 113 | # The typing client stays attached until after the browser leg below, so | ||
| 114 | # the hub pumps a session that has a live CLI client on it too. | ||
| 115 | pipe_mux "$OUT.webb" "$OUT.webb.err" timeout 40 "$MUX" --sock "$SOCK19" | ||
| 116 | pipe_send 'printf "web-%%s\\n" b1\n' | ||
| 117 | wait_grid "$SOCK19" "web-b1" "web hub: the typing client's marker" | ||
| 118 | |||
| 119 | # The browser stand-in: a passive 0x0 wall tile through the hub. Its | ||
| 120 | # final grid is the daemon's replica as REPLAYED THROUGH the WebSocket | ||
| 121 | # leg, dumped in muxd dump's own format. | ||
| 122 | set +e | ||
| 123 | timeout 40 "$WSCLIENT" --port "$WPORT" --tile 0 --out "$OUT.webws" --err "$OUT.webws.err" <<'EOF' | ||
| 124 | attach 0 0 | ||
| 125 | expectstate up 10000 | ||
| 126 | expectgrid web-b1 15000 | ||
| 127 | settle 500 10000 | ||
| 128 | dumpexit | ||
| 129 | EOF | ||
| 130 | RC=$? | ||
| 131 | set -e | ||
| 132 | [ "$RC" -eq 0 ] || { | ||
| 133 | echo "e2e FAIL: web hub: wsclient exited $RC" | ||
| 134 | cat -v "$OUT.webws.err" 2>/dev/null; cat "$OUT.webh"; exit 1; } | ||
| 135 | |||
| 136 | assert_ws_converged "$OUT.webws" "$SOCK19" "web hub" | ||
| 137 | |||
| 138 | # Wrong Origin: refused at HTTP, before any upgrade — the wsclient sees | ||
| 139 | # a non-101 and exits 4 — and the daemon never sees a client for it. | ||
| 140 | CLIENTS_BEFORE=$("$MUXD" stats --sock "$SOCK19" | sed -n 's/.*clients=\([0-9]*\).*/\1/p') | ||
| 141 | set +e | ||
| 142 | timeout 20 "$WSCLIENT" --port "$WPORT" --tile 0 --origin http://evil.example \ | ||
| 143 | --out "$OUT.webevil" --err "$OUT.webevil.err" < /dev/null | ||
| 144 | RC=$? | ||
| 145 | set -e | ||
| 146 | [ "$RC" -eq 4 ] || { | ||
| 147 | echo "e2e FAIL: web hub: evil-origin wsclient exited $RC, want 4" | ||
| 148 | cat -v "$OUT.webevil.err" 2>/dev/null; exit 1; } | ||
| 149 | grep -q "upgrade refused.*403" "$OUT.webevil.err" || { | ||
| 150 | echo "e2e FAIL: web hub: refusal was not the 403 it must be:" | ||
| 151 | cat -v "$OUT.webevil.err"; exit 1; } | ||
| 152 | CLIENTS_AFTER=$("$MUXD" stats --sock "$SOCK19" | sed -n 's/.*clients=\([0-9]*\).*/\1/p') | ||
| 153 | [ "$CLIENTS_BEFORE" = "$CLIENTS_AFTER" ] || { | ||
| 154 | echo "e2e FAIL: web hub: evil origin reached the daemon (clients $CLIENTS_BEFORE -> $CLIENTS_AFTER)"; exit 1; } | ||
| 155 | |||
| 156 | pipe_detach "web hub: typing client" | ||
| 157 | assert_converged "$OUT.webb" "$SOCK19" "web hub: the typing client" | ||
| 158 | |||
| 159 | softkill "$W1PID" || true | ||
| 160 | wait_pid_gone "$W1PID" "web hub: killed by tracked pid" | ||
| 161 | W1PID="" | ||
| 162 | assert_stopped "$SOCK19" "$D15PID" "web hub" "$OUT.webstop2" | ||
| 163 | D15PID="" | ||
| 164 | ok "hub pumps a real session; wrong origin refused" | ||
| 165 | |||
| 166 | # --- M-web (c): the hub narrates the tear; the replica re-attaches | ||
| 167 | # across an epoch. muxd stop kills the daemon under a live tile; the hub | ||
| 168 | # must say `reconnecting`, redial the restarted daemon (same socket, new | ||
| 169 | # epoch), say `up`, and the stand-in's re-attach — quoting coordinates | ||
| 170 | # the NEW daemon has never issued — must be answered with a snapshot | ||
| 171 | # that converges on the new session's content. | ||
| 172 | start_daemon "$SOCK20" "$OUT.webc.d" "web tear daemon never bound" --shell /bin/sh | ||
| 173 | D16PID=$DPID | ||
| 174 | "$MUXWEB" --sock "$SOCK20" --port "$WPORT2" > "$OUT.webh2" 2>&1 & | ||
| 175 | W2PID=$! | ||
| 176 | defer_kill "$W2PID" | ||
| 177 | wait_for "$OUT.webh2" "serving" 10 || { | ||
| 178 | echo "e2e FAIL: tear hub never reported serving"; cat "$OUT.webh2"; exit 1; } | ||
| 179 | |||
| 180 | pipe_mux "$OUT.webc" "$OUT.webc.err" timeout 30 "$MUX" --sock "$SOCK20" | ||
| 181 | pipe_send 'printf "web-%%s\\n" c1\n' | ||
| 182 | await_out "$OUT.webc" "web-c1" "web-c1 never reached the client" | ||
| 183 | pipe_detach | ||
| 184 | wait_grid "$SOCK20" "web-c1" "web tear: the first session's marker" | ||
| 185 | |||
| 186 | timeout 90 "$WSCLIENT" --port "$WPORT2" --tile 0 --out "$OUT.webws2" --err "$OUT.webws2.err" <<'EOF' & | ||
| 187 | attach 0 0 | ||
| 188 | expectstate up 10000 | ||
| 189 | expectgrid web-c1 15000 | ||
| 190 | expectstate reconnecting 25000 | ||
| 191 | expectstate up 25000 | ||
| 192 | attach 0 0 | ||
| 193 | expectgrid web-c2 30000 | ||
| 194 | settle 500 10000 | ||
| 195 | dumpexit | ||
| 196 | EOF | ||
| 197 | WCLIPID=$! | ||
| 198 | defer_kill "$WCLIPID" | ||
| 199 | # Let the stand-in reach its first expectgrid before the tear; the marker | ||
| 200 | # it waits for is already on the grid, so one settle-length is plenty. | ||
| 201 | sleep 1 | ||
| 202 | |||
| 203 | assert_stopped "$SOCK20" "$D16PID" "web tear: the first daemon, under a live tile" "$OUT.webstop3" | ||
| 204 | D16PID="" | ||
| 205 | |||
| 206 | start_daemon "$SOCK20" "$OUT.webc2.d" "web tear restart never bound" --shell /bin/sh | ||
| 207 | D17PID=$DPID | ||
| 208 | |||
| 209 | pipe_mux "$OUT.webc2" "$OUT.webc2.err" timeout 30 "$MUX" --sock "$SOCK20" | ||
| 210 | pipe_send 'printf "web-%%s\\n" c2\n' | ||
| 211 | await_out "$OUT.webc2" "web-c2" "web-c2 never reached the client" | ||
| 212 | pipe_detach | ||
| 213 | |||
| 214 | set +e | ||
| 215 | wait "$WCLIPID" | ||
| 216 | RC=$? | ||
| 217 | set -e | ||
| 218 | WCLIPID="" | ||
| 219 | [ "$RC" -eq 0 ] || { | ||
| 220 | echo "e2e FAIL: web tear: wsclient exited $RC" | ||
| 221 | cat -v "$OUT.webws2.err" 2>/dev/null; cat "$OUT.webh2"; exit 1; } | ||
| 222 | |||
| 223 | assert_ws_converged "$OUT.webws2" "$SOCK20" "web tear: after the epoch crossing" | ||
| 224 | |||
| 225 | softkill "$W2PID" || true | ||
| 226 | wait_pid_gone "$W2PID" "web tear: hub killed by tracked pid" | ||
| 227 | W2PID="" | ||
| 228 | # Its own capture, not a second write to $OUT.webstop3: that file still | ||
| 229 | # holds the FIRST stop's stderr, and overwriting it before anything reads | ||
| 230 | # it is how the evidence for a failure up there disappears. | ||
| 231 | assert_stopped "$SOCK20" "$D17PID" "web tear: the restarted daemon" "$OUT.webstop4" | ||
| 232 | D17PID="" | ||
| 233 | ok "hub narrates the tear; the replica re-attaches across an epoch" | ||
| 234 | |||
| 235 | # --- M-wall: the wall is RUNTIME state. The page adds, removes and | ||
| 236 | # reorders tiles over HTTP, and the file is what makes that survive a | ||
| 237 | # restart. Every muxweb here runs with a state home of its own (see | ||
| 238 | # $DWSTATE), so what this leg reads back is the wall this leg wrote — and | ||
| 239 | # never the developer's real one. | ||
| 240 | start_daemon "$SOCK25" "$OUT.dw.d" "dyn wall daemon never bound" --shell /bin/sh | ||
| 241 | D22PID=$DPID | ||
| 242 | |||
| 243 | XDG_STATE_HOME="$DWSTATE" "$MUXWEB" --port "$WPORT4" > "$OUT.dwh" 2>&1 & | ||
| 244 | W4PID=$! | ||
| 245 | defer_kill "$W4PID" | ||
| 246 | wait_for "$OUT.dwh" "serving" 10 || { | ||
| 247 | echo "e2e FAIL: dyn wall: hub never reported serving"; cat "$OUT.dwh"; exit 1; } | ||
| 248 | DWORIG="http://127.0.0.1:$WPORT4" | ||
| 249 | |||
| 250 | # No argv and no file: the hub serves an EMPTY wall rather than refusing to | ||
| 251 | # start. That is the whole premise of a page that can build its own wall. | ||
| 252 | [ "$(curl -s "$DWORIG/tiles")" = "[]" ] || { | ||
| 253 | echo "e2e FAIL: dyn wall: fresh hub wall not empty; it holds:" | ||
| 254 | curl -s "$DWORIG/tiles"; exit 1; } | ||
| 255 | |||
| 256 | # No Origin: refused before anything mutates. A text/plain POST is a CSRF | ||
| 257 | # "simple request" any page can fire at localhost, so the gate is what keeps | ||
| 258 | # this page's power this page's — and the wall must be untouched after it. | ||
| 259 | RC=$(curl -s -o /dev/null -w '%{http_code}' -X POST --data "--sock $SOCK25" "$DWORIG/tiles") | ||
| 260 | [ "$RC" = "403" ] || { echo "e2e FAIL: dyn wall: originless POST got $RC, want 403"; exit 1; } | ||
| 261 | [ "$(curl -s "$DWORIG/tiles")" = "[]" ] || { | ||
| 262 | echo "e2e FAIL: dyn wall: the refused POST still reached the wall:" | ||
| 263 | curl -s "$DWORIG/tiles"; exit 1; } | ||
| 264 | |||
| 265 | # Route precision: /tiles is an EXACT match, not a prefix — `/tilesgarbage` | ||
| 266 | # must not fall into the mutation block and add a tile. | ||
| 267 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X POST --data "--sock $SOCK25" "$DWORIG/tilesgarbage") | ||
| 268 | [ "$RC" = "404" ] || { echo "e2e FAIL: dyn wall: /tilesgarbage POST got $RC, want 404"; exit 1; } | ||
| 269 | [ "$(curl -s "$DWORIG/tiles")" = "[]" ] || { | ||
| 270 | echo "e2e FAIL: dyn wall: /tilesgarbage POST still added a tile:" | ||
| 271 | curl -s "$DWORIG/tiles"; exit 1; } | ||
| 272 | |||
| 273 | # Two tiles on one socket: the default session and session b, the M18 | ||
| 274 | # one-name-one-session doctrine. Ids are birth order and are the hub's own — | ||
| 275 | # `/ws/<id>`, not a position — which is what the next assertions ride on. | ||
| 276 | R=$(curl -s -H "Origin: $DWORIG" -X POST --data "--sock $SOCK25" "$DWORIG/tiles") | ||
| 277 | [ "$R" = '{"id":0}' ] || { echo "e2e FAIL: dyn wall: first add returned $R, want id 0"; exit 1; } | ||
| 278 | R=$(curl -s -H "Origin: $DWORIG" -X POST --data "--sock $SOCK25#b" "$DWORIG/tiles") | ||
| 279 | [ "$R" = '{"id":1}' ] || { echo "e2e FAIL: dyn wall: second add returned $R, want id 1"; exit 1; } | ||
| 280 | |||
| 281 | # The two refusals that happen at ADD time rather than at dial time, which | ||
| 282 | # is the point of both: a malformed session name, and a socket path longer | ||
| 283 | # than sun_path. A tile that can never attach must not reach the wall. | ||
| 284 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X POST --data 'h#bad name' "$DWORIG/tiles") | ||
| 285 | [ "$RC" = "400" ] || { echo "e2e FAIL: dyn wall: bad session name got $RC, want 400"; exit 1; } | ||
| 286 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X POST \ | ||
| 287 | --data '--sock /tmp/dyn-wall-socket-path-far-longer-than-the-108-byte-sun_path-limit-so-the-add-refuses-here-not-at-dial-time.sock' \ | ||
| 288 | "$DWORIG/tiles") | ||
| 289 | [ "$RC" = "400" ] || { echo "e2e FAIL: dyn wall: over-long socket path got $RC, want 400"; exit 1; } | ||
| 290 | |||
| 291 | # A POSTed tile is a REAL tile: a marker typed through the CLI door is read | ||
| 292 | # back through the WebSocket door of the tile the page just created. The | ||
| 293 | # stand-in attaches at 0x0 and the CLI client has already detached, so the | ||
| 294 | # passivity contract is in play in passing — a tile that claimed no size | ||
| 295 | # never moves the grid, and the 80-wide marker is still one contiguous | ||
| 296 | # string to expect. | ||
| 297 | pipe_mux "$OUT.dwcli" "$OUT.dwcli.err" timeout 40 "$MUX" --sock "$SOCK25" | ||
| 298 | pipe_send 'printf "dyn-%%s\\n" w1\n' | ||
| 299 | await_out "$OUT.dwcli" "dyn-w1" "dyn-w1 never reached the client" | ||
| 300 | pipe_detach "dyn wall: CLI client" | ||
| 301 | wait_grid "$SOCK25" "dyn-w1" "dyn wall: CLI marker" | ||
| 302 | set +e | ||
| 303 | timeout 40 "$WSCLIENT" --port "$WPORT4" --tile 0 --out "$OUT.dwws" --err "$OUT.dwws.err" <<'EOF' | ||
| 304 | attach 0 0 | ||
| 305 | expectstate up 10000 | ||
| 306 | expectgrid dyn-w1 15000 | ||
| 307 | dumpexit | ||
| 308 | EOF | ||
| 309 | RC=$? | ||
| 310 | set -e | ||
| 311 | [ "$RC" -eq 0 ] || { | ||
| 312 | echo "e2e FAIL: dyn wall: wsclient exited $RC" | ||
| 313 | cat -v "$OUT.dwws.err" 2>/dev/null; cat "$OUT.dwh"; exit 1; } | ||
| 314 | |||
| 315 | # Reorder is the FULL new order by id, and the 204 is BODYLESS — a reorder | ||
| 316 | # that took is read back with a GET, never inferred from the status. | ||
| 317 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X PUT --data '1,0' "$DWORIG/tiles") | ||
| 318 | [ "$RC" = "204" ] || { echo "e2e FAIL: dyn wall: reorder got $RC, want 204"; exit 1; } | ||
| 319 | curl -s "$DWORIG/tiles" | grep -q '^\[{"id":1,' || { | ||
| 320 | echo "e2e FAIL: dyn wall: reorder not reflected; the wall holds:" | ||
| 321 | curl -s "$DWORIG/tiles"; exit 1; } | ||
| 322 | # ...and it reached the FILE, which is the half a restart will read back. | ||
| 323 | # Asserted here rather than after the delete, because this is the only | ||
| 324 | # moment the wall has two lines and "order" means anything on disk at all. | ||
| 325 | [ "$(head -1 "$DWSTATE/mux/wall")" = "--sock $SOCK25#b" ] || { | ||
| 326 | echo "e2e FAIL: dyn wall: reorder did not reach the file; it holds:" | ||
| 327 | cat "$DWSTATE/mux/wall"; exit 1; } | ||
| 328 | # An order naming an id the hub does not have is a 409 carrying a copy of | ||
| 329 | # the truth — never a guess at what the stale page meant. | ||
| 330 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X PUT --data '0,7' "$DWORIG/tiles") | ||
| 331 | [ "$RC" = "409" ] || { echo "e2e FAIL: dyn wall: stale reorder got $RC, want 409"; exit 1; } | ||
| 332 | curl -s -H "Origin: $DWORIG" -X PUT --data '0,7' "$DWORIG/tiles" | grep -q '^\[{"id":1,' || { | ||
| 333 | echo "e2e FAIL: dyn wall: the 409 did not carry the current wall"; exit 1; } | ||
| 334 | |||
| 335 | # Remove is DETACH, not kill: the tile leaves the wall, the daemon keeps | ||
| 336 | # every session it had. `sessions=` is stats' own word for that — and the | ||
| 337 | # reading is asserted NON-EMPTY before it is compared, because both sides | ||
| 338 | # come out of the same sed: a stats line that renamed or dropped the field | ||
| 339 | # would make this `[ "" = "" ]` and pass forever, which is the one way this | ||
| 340 | # check could fail green (want_stat, above, guards its own the same way). | ||
| 341 | SESS_BEFORE=$("$MUXD" stats --sock "$SOCK25" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p') | ||
| 342 | [ -n "$SESS_BEFORE" ] || { | ||
| 343 | echo "e2e FAIL: dyn wall: no sessions= in stats (the field moved?); it says:" | ||
| 344 | "$MUXD" stats --sock "$SOCK25"; exit 1; } | ||
| 345 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X DELETE "$DWORIG/tiles/0") | ||
| 346 | [ "$RC" = "204" ] || { echo "e2e FAIL: dyn wall: delete got $RC, want 204"; exit 1; } | ||
| 347 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X DELETE "$DWORIG/tiles/0") | ||
| 348 | [ "$RC" = "404" ] || { echo "e2e FAIL: dyn wall: second delete got $RC, want 404"; exit 1; } | ||
| 349 | SESS_AFTER=$("$MUXD" stats --sock "$SOCK25" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p') | ||
| 350 | [ "$SESS_BEFORE" = "$SESS_AFTER" ] || { | ||
| 351 | echo "e2e FAIL: dyn wall: DELETE killed a session ($SESS_BEFORE -> $SESS_AFTER)"; exit 1; } | ||
| 352 | |||
| 353 | # ...and the id it took with it is answered in HTTP: a 404 the page can | ||
| 354 | # read, rather than an upgrade followed by a silent close it can only guess | ||
| 355 | # at. The stand-in sees a non-101 and exits 4, the wrong-Origin scenario's | ||
| 356 | # shape. | ||
| 357 | set +e | ||
| 358 | timeout 20 "$WSCLIENT" --port "$WPORT4" --tile 0 \ | ||
| 359 | --out "$OUT.dwdead" --err "$OUT.dwdead.err" < /dev/null | ||
| 360 | RC=$? | ||
| 361 | set -e | ||
| 362 | [ "$RC" -eq 4 ] || { | ||
| 363 | echo "e2e FAIL: dyn wall: wsclient on a removed tile exited $RC, want 4" | ||
| 364 | cat -v "$OUT.dwdead.err" 2>/dev/null; exit 1; } | ||
| 365 | grep -q "upgrade refused.*404" "$OUT.dwdead.err" || { | ||
| 366 | echo "e2e FAIL: dyn wall: a dead id was not refused with a 404:" | ||
| 367 | cat -v "$OUT.dwdead.err"; exit 1; } | ||
| 368 | |||
| 369 | # The file carries SPELLINGS, one per line, in wall order — no ids, because | ||
| 370 | # ids are per-run. The surviving tile is the `#b` one the delete spared. | ||
| 371 | grep -qxF -- "--sock $SOCK25#b" "$DWSTATE/mux/wall" || { | ||
| 372 | echo "e2e FAIL: dyn wall: state file missing the surviving tile; it holds:" | ||
| 373 | cat "$DWSTATE/mux/wall"; exit 1; } | ||
| 374 | [ "$(wc -l < "$DWSTATE/mux/wall")" = "1" ] || { | ||
| 375 | echo "e2e FAIL: dyn wall: state file is not the one surviving line:" | ||
| 376 | cat "$DWSTATE/mux/wall"; exit 1; } | ||
| 377 | |||
| 378 | # Restart with NO argv: the file is the wall. The id is fresh — a restarted | ||
| 379 | # hub numbers from 0 in wall order — and the SPELLING is what persisted. | ||
| 380 | softkill "$W4PID" || true | ||
| 381 | wait_pid_gone "$W4PID" "dyn wall: first hub killed by tracked pid" | ||
| 382 | XDG_STATE_HOME="$DWSTATE" "$MUXWEB" --port "$WPORT4" > "$OUT.dwh2" 2>&1 & | ||
| 383 | W4PID=$! | ||
| 384 | defer_kill "$W4PID" | ||
| 385 | wait_for "$OUT.dwh2" "serving" 10 || { | ||
| 386 | echo "e2e FAIL: dyn wall: restored hub never reported serving"; cat "$OUT.dwh2"; exit 1; } | ||
| 387 | curl -s "$DWORIG/tiles" | grep -q "^\[{\"id\":0,\"label\":\"--sock $SOCK25#b\"" || { | ||
| 388 | echo "e2e FAIL: dyn wall: restart lost the persisted wall; it holds:" | ||
| 389 | curl -s "$DWORIG/tiles"; exit 1; } | ||
| 390 | |||
| 391 | # Restart WITH argv: the override is the VIEW's, not the file's. This | ||
| 392 | # assertion INVERTED with the attach-history phase and did not merely move. | ||
| 393 | # It used to read "argv appended to the wall instead of replacing it" — a | ||
| 394 | # pin on muxweb overwriting the state file with whatever it was told to | ||
| 395 | # show. That file is the user's attach history now, written by every `mux` | ||
| 396 | # attach, so one `muxweb HOST` overwriting it would silently erase the lot. | ||
| 397 | # Argv tiles are ADDED (deduped by spelling) and nothing is removed; | ||
| 398 | # forgetting stays explicit — the page's `x`, the wall's `x`, `mux wall rm`. | ||
| 399 | # The tile is spelled as ONE quoted argument — the wall file's own | ||
| 400 | # spelling, handed back to the binary that wrote it. | ||
| 401 | softkill "$W4PID" || true | ||
| 402 | wait_pid_gone "$W4PID" "dyn wall: restored hub killed by tracked pid" | ||
| 403 | XDG_STATE_HOME="$DWSTATE" "$MUXWEB" "--sock $SOCK25" --port "$WPORT4" > "$OUT.dwh3" 2>&1 & | ||
| 404 | W4PID=$! | ||
| 405 | defer_kill "$W4PID" | ||
| 406 | wait_for "$OUT.dwh3" "serving" 10 || { | ||
| 407 | echo "e2e FAIL: dyn wall: overriding hub never reported serving"; cat "$OUT.dwh3"; exit 1; } | ||
| 408 | grep -qxF -- "--sock $SOCK25" "$DWSTATE/mux/wall" || { | ||
| 409 | echo "e2e FAIL: dyn wall: argv did not reach the wall file; it holds:" | ||
| 410 | cat "$DWSTATE/mux/wall"; exit 1; } | ||
| 411 | # ...and the line that was already there SURVIVED it. Both halves, because | ||
| 412 | # either alone is satisfied by the wrong behaviour: the grep above passes | ||
| 413 | # on an overwrite, and a count alone would not say which lines are which. | ||
| 414 | grep -qxF -- "--sock $SOCK25#b" "$DWSTATE/mux/wall" || { | ||
| 415 | echo "e2e FAIL: dyn wall: argv ERASED the wall it was added to — an" | ||
| 416 | echo " attach history overwritten by one muxweb invocation:" | ||
| 417 | cat "$DWSTATE/mux/wall"; exit 1; } | ||
| 418 | [ "$(wc -l < "$DWSTATE/mux/wall")" = "2" ] || { | ||
| 419 | echo "e2e FAIL: dyn wall: the wall file is not the two lines argv added to:" | ||
| 420 | cat "$DWSTATE/mux/wall"; exit 1; } | ||
| 421 | |||
| 422 | softkill "$W4PID" || true | ||
| 423 | wait_pid_gone "$W4PID" "dyn wall: overriding hub killed by tracked pid" | ||
| 424 | W4PID="" | ||
| 425 | assert_stopped "$SOCK25" "$D22PID" "dyn wall" "$OUT.dwstop" | ||
| 426 | D22PID="" | ||
| 427 | ok "the wall is runtime state: add, remove, reorder, restore, argv adds" | ||