a73x

ea9d9ce9

test: 06_web moves to a file of its own

a73x   2026-08-26 18:55

Commit message
test: 06_web moves to a file of its own

the hub, the browser replica, and the wall as runtime state — 4 scenarios, 427 lines. The legs move unedited; the runner
sources the file where they used to stand.

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

test/e2e.sh
Old New
@@ -102,7 +102,7 @@ done
102 E2E_DIR=$(dirname "$0") 102 E2E_DIR=$(dirname "$0")
103 . "$E2E_DIR/e2e_lib.sh" 103 . "$E2E_DIR/e2e_lib.sh"
104 104
105 E2E_GROUPS='01_boot 02_predict 03_side 04_handoff 05_session' 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"