a73x

7c4367e8

test: e2e — dynamic wall add/remove/reorder/restore; docs

a73x   2026-08-18 18:01

Commit message
test: e2e — dynamic wall add/remove/reorder/restore; docs

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

README.md
Old New
@@ -94,6 +94,26 @@ to that session, so it is exactly as sensitive as an ssh private key —
94 `muxd` and `mux` both refuse one that is readable by group or other, and 94 `muxd` and `mux` both refuse one that is readable by group or other, and
95 there is no unauthenticated mode to fall back to. 95 there is no unauthenticated mode to fall back to.
96 96
97 ## Quick start, a wall of sessions in a browser
98
99 ```sh
100 muxweb # restores the wall you left; opens on 127.0.0.1:7681
101 muxweb HOST quic://OTHER --sock /tmp/s.sock#b # ...or state it, which replaces the saved wall
102 ```
103
104 Each tile is a live session, dialed the way `mux` dials it — the same
105 `HOST`, `quic://HOST[:PORT]` and `--sock PATH` spellings, with `#SESSION`
106 naming the session on that daemon. Click a tile to zoom it and type in it;
107 click the shade to come back. The wall itself is editable from the page: the
108 box adds a tile, `×` removes one (which detaches — the session and
109 everything in it keep running), `+` starts a new session on that tile's host,
110 and tiles drag into whatever order you want. The result is saved to
111 `$XDG_STATE_HOME/mux/wall` (`~/.local/state/mux/wall`), one spelling per
112 line, so the next bare `muxweb` comes back to the same wall.
113
114 Localhost only, deliberately: to see it from another machine, forward it —
115 `ssh -L 7681:127.0.0.1:7681 HOST`.
116
97 ## Everything else 117 ## Everything else
98 118
99 ```sh 119 ```sh
docs/decisions.md
Old New
@@ -3830,3 +3830,79 @@ them, because they are length-prefixed and its dispatch ends in
3830 `else => {}`. It could not be made to measure `selection_reply` itself, 3830 `else => {}`. It could not be made to measure `selection_reply` itself,
3831 and that is a fact about the protocol rather than a gap in the rig: nothing 3831 and that is a fact about the protocol rather than a gap in the rig: nothing
3832 can make a daemon send one to a client that never asks. 3832 can make a daemon send one to a client that never asks.
3833
3834 ## 2026-08-18 (the wall becomes runtime state)
3835
3836 **argv seeds, the file restores.** `muxweb` with targets means those targets:
3837 argv replaces the wall and is saved as it. `muxweb` with none restores the
3838 last run's wall from `$XDG_STATE_HOME/mux/wall`, one spelling per line, order
3839 being wall order. The standing non-goal — no config file — survives intact,
3840 because that file is written by the program and never by hand; what changed is
3841 that the wall is now something a session PRODUCES rather than something the
3842 command line has to re-type. A restore deliberately does not re-save what it
3843 just read: a read that failed would otherwise overwrite the wall with the
3844 nothing it managed to parse.
3845
3846 **Ids are per-run; the file holds spellings.** A tile's id is the hub's own
3847 handle for it — what `/ws/<id>` names and what the reorder CSV addresses — and
3848 it is minted at birth, so ids are birth order within one run and are
3849 reassigned 0..n-1 in wall order at the next start. Persisting them would have
3850 been persisting a coincidence. The spelling is the durable thing: it is the
3851 label on screen, the line in the file, and the resolver's input, all the same
3852 string.
3853
3854 **Mutating verbs are Origin-gated; GET is not.** A `text/plain` POST is a CORS
3855 "simple request", which means any page in the browser can fire one at
3856 `127.0.0.1` with no preflight to warn us. So POST, DELETE and PUT on `/tiles`
3857 check Origin before they touch anything, and refuse with 403 — the same gate,
3858 in the same place, as the WebSocket upgrade's. GET stays open on the opposite
3859 argument: we send no CORS headers, so a hostile page can make the request and
3860 can never read the answer.
3861
3862 **Remove is detach.** Deleting a tile takes it off the wall and shuts down its
3863 pump; the daemon keeps the session, every process in it, and its scrollback.
3864 The button is on a browser page in a room somebody walked out of — the
3865 destructive reading of it would be the wrong default even if it were the
3866 convenient one, and `muxd stop` remains the way to end things.
3867
3868 **`--sock ` joined the spelling grammar.** A tile is now ONE string —
3869 `HOST[#SESSION]`, `quic://HOST[:PORT][#SESSION]`, or `--sock PATH[#SESSION]` —
3870 because the page has one text box and the file has one line, neither of which
3871 can hold a flag and its value as two argv words. `wall.zig` owns that grammar
3872 for both binaries, so what the page POSTs and what the command line takes are
3873 the same language, and a refusal (bad session name, empty target, a socket
3874 path past `sun_path`) happens at ADD time rather than surfacing later as a
3875 tile that will never attach.
3876
3877 **A dead id is answered in HTTP, not in WebSocket.** `/ws/<unknown>` is a 404
3878 before any upgrade, which took moving the tile checkout ahead of the 101. The
3879 alternative — upgrade, then close — tells a page that its request succeeded
3880 and then leaves it to infer why the socket went away, which is exactly the
3881 shape the reconnect loop cannot distinguish from a tear.
3882
3883 **A tile added by the box zooms itself.** Not decoration: a wall tile attaches
3884 at 1×1, and `resolveSession` refuses to CREATE a session below the minimum
3885 size (the passivity contract that keeps a browser wall from resizing a human's
3886 grid). So `HOST#newname` for a name that does not exist yet sits at `session
3887 full` until something claims a real size for it — zooming the tile the user
3888 just asked for is what makes the add mean what it looks like it means. A tile
3889 restored from the file does not auto-zoom; only the one typed into the box.
3890
3891 **The add box grants nothing the page did not already have.** A `HOST` spelling
3892 typed into the browser reaches `ssh <host> …` through a shell, exactly as one
3893 typed on the command line always has, and the page holding that box is the same
3894 page holding a live terminal on the other side of the wall — so the box hands an
3895 allowed origin no authority its own keystrokes do not already hand it by design,
3896 and the Origin gate, not the grammar, is where the boundary actually sits.
3897 Proper per-arg quoting in the handoff recipe is follow-up material, worth doing
3898 for the spelling that contains a space rather than for the attacker who is
3899 already typing into a shell.
3900
3901 **DNS rebinding reads the names and stops there.** An attacker who rebinds their
3902 own hostname to `127.0.0.1` does get a same-origin `GET /tiles` it can read back
3903 — tile labels and session names, the wall's table of contents — but cannot
3904 mutate the wall or open a WebSocket to any of it, because `originAllowed`
3905 compares the Origin header against the fixed strings `http://127.0.0.1:<port>`
3906 and `http://localhost:<port>`, and every mutating verb and every upgrade is
3907 gated on that comparison. Leaking the names is the accepted cost of leaving GET
3908 ungated; leaking a session is not, and does not happen.
test/e2e.sh
Old New
@@ -32,6 +32,16 @@ command -v nvim > /dev/null 2>&1 || {
32 echo " a real editor wrote); install it, or lose the only check that" 32 echo " a real editor wrote); install it, or lose the only check that"
33 echo " proves bracketed paste works rather than merely arrives" 33 echo " proves bracketed paste works rather than merely arrives"
34 exit 1; } 34 exit 1; }
35 # The same trade for the second thing this suite does not build: the hub's
36 # HTTP verbs are asserted through curl, and a `command -v` SKIP around them
37 # would let a box quietly not test the wall's whole mutation surface and
38 # still print a green line. Required, and said here rather than 4000 lines
39 # down, for the reason nvim is.
40 command -v curl > /dev/null 2>&1 || {
41 echo "e2e FAIL: this suite needs curl (the dynamic-wall leg asserts on the"
42 echo " hub's POST/DELETE/PUT answers); install it, or lose every"
43 echo " check that the wall can be edited at all"
44 exit 1; }
35 45
36 SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock" 46 SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock"
37 OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$" 47 OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$"
@@ -218,6 +228,20 @@ D20PID=""
218 SOCK24="${TMPDIR:-/tmp}/muxd-e2e-bell-$$.sock" 228 SOCK24="${TMPDIR:-/tmp}/muxd-e2e-bell-$$.sock"
219 D21PID="" 229 D21PID=""
220 230
231 # The dynamic-wall leg: one daemon, one hub restarted three times, and a
232 # STATE HOME of its own on the 61000 band — the last 5000-spaced one that
233 # fits under 65535, the QUIC block's 56000 being the one before. The state
234 # home is the load-bearing part: this leg reads the wall file back as an
235 # artifact, and $XDG_STATE_HOME above is shared with every other scenario —
236 # the M-web and M18 hubs write their argv walls there, so a wall read out of
237 # it would be some other block's. Never the developer's ~/.local/state either; that is
238 # why every muxweb here is spawned with the override in front of it.
239 SOCK25="${TMPDIR:-/tmp}/muxd-e2e-dynwall-$$.sock"
240 WPORT4=$(( 61000 + ($$ % 4000) ))
241 DWSTATE="${TMPDIR:-/tmp}/mux-e2e-dynwall-state-$$"
242 D22PID=""
243 W4PID=""
244
221 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one 245 # One counter out of a MUX_PREDICT_STATS line. The client prints exactly one
222 # such line on exit; every field is a key=value pair, so a rename or reorder 246 # such line on exit; every field is a key=value pair, so a rename or reorder
223 # in the client shows up here as an empty read rather than a wrong number. 247 # in the client shows up here as an empty read rather than a wrong number.
@@ -732,12 +756,18 @@ cleanup() {
732 [ -n "$D19PID" ] && kill "$D19PID" 2>/dev/null || true 756 [ -n "$D19PID" ] && kill "$D19PID" 2>/dev/null || true
733 [ -n "$D20PID" ] && kill "$D20PID" 2>/dev/null || true 757 [ -n "$D20PID" ] && kill "$D20PID" 2>/dev/null || true
734 [ -n "$D21PID" ] && kill "$D21PID" 2>/dev/null || true 758 [ -n "$D21PID" ] && kill "$D21PID" 2>/dev/null || true
759 # The dynamic-wall leg's hub and daemon. The hub variable is re-assigned
760 # across three incarnations on the same port, so a pid here is whichever
761 # one the run died under.
762 [ -n "$W4PID" ] && kill "$W4PID" 2>/dev/null || true
763 [ -n "$D22PID" ] && kill "$D22PID" 2>/dev/null || true
735 # The stops still precede the socket rm below, like SOCK14-17 above: 764 # The stops still precede the socket rm below, like SOCK14-17 above:
736 # unlinking a socket first would leave a live daemon nothing could reach 765 # unlinking a socket first would leave a live daemon nothing could reach
737 # by path. 766 # by path.
738 [ -S "$SOCK18" ] && "$MUXD" stop --sock "$SOCK18" 2>/dev/null || true 767 [ -S "$SOCK18" ] && "$MUXD" stop --sock "$SOCK18" 2>/dev/null || true
739 [ -S "$SOCK19" ] && "$MUXD" stop --sock "$SOCK19" 2>/dev/null || true 768 [ -S "$SOCK19" ] && "$MUXD" stop --sock "$SOCK19" 2>/dev/null || true
740 [ -S "$SOCK20" ] && "$MUXD" stop --sock "$SOCK20" 2>/dev/null || true 769 [ -S "$SOCK20" ] && "$MUXD" stop --sock "$SOCK20" 2>/dev/null || true
770 [ -S "$SOCK25" ] && "$MUXD" stop --sock "$SOCK25" 2>/dev/null || true
741 771
742 # ---- the leak sweep (hygiene kit, 6a) ---- 772 # ---- the leak sweep (hygiene kit, 6a) ----
743 # Here rather than at the bottom of the file, which `set -e` reaches only 773 # Here rather than at the bottom of the file, which `set -e` reaches only
@@ -749,7 +779,7 @@ cleanup() {
749 "$D7PID" "$D9PID" "$D10PID" "$D12PID" "$D13PID" "$SPID" "$TPID" \ 779 "$D7PID" "$D9PID" "$D10PID" "$D12PID" "$D13PID" "$SPID" "$TPID" \
750 "$GPID" "$APID" "$PAPID" "$HAPID" "$HDPID" \ 780 "$GPID" "$APID" "$PAPID" "$HAPID" "$HDPID" \
751 "$D14PID" "$D15PID" "$D16PID" "$D17PID" "$D18PID" "$D19PID" \ 781 "$D14PID" "$D15PID" "$D16PID" "$D17PID" "$D18PID" "$D19PID" \
752 "$D20PID" "$D21PID" 782 "$D20PID" "$D21PID" "$D22PID"
753 _leak=0 783 _leak=0
754 leak_sweep "$_rc" || _leak=1 784 leak_sweep "$_rc" || _leak=1
755 785
@@ -858,6 +888,15 @@ cleanup() {
858 "$SOCK23" "$M18KEY" "$OUT.m18q.d" "$OUT.m18qa" "$OUT.m18qa.err" \ 888 "$SOCK23" "$M18KEY" "$OUT.m18q.d" "$OUT.m18qa" "$OUT.m18qa.err" \
859 "$OUT.m18qb" "$OUT.m18qb.err" "$OUT.m18qx" "$OUT.m18qx.err" \ 889 "$OUT.m18qb" "$OUT.m18qb.err" "$OUT.m18qx" "$OUT.m18qx.err" \
860 "$OUT.m18qstop" 890 "$OUT.m18qstop"
891 # The dynamic-wall leg: its daemon capture, the three hub captures (one
892 # per incarnation, each kept apart so a failure names WHICH start went
893 # wrong), the CLI and stand-in captures, and the state home holding the
894 # wall file this leg reads back.
895 rm -f "$SOCK25" "$OUT.dw.d" "$OUT.dwh" "$OUT.dwh2" "$OUT.dwh3" \
896 "$OUT.dwcli" "$OUT.dwcli.err" \
897 "$OUT.dwws" "$OUT.dwws.err" "$OUT.dwdead" "$OUT.dwdead.err" \
898 "$OUT.dwstop"
899 rm -rf "$DWSTATE"
861 # The convergence files a FAILING assert_converged leaves behind 900 # The convergence files a FAILING assert_converged leaves behind
862 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not 901 # (.render/.dump/.rvt/.dvt/.diff for that capture) are deliberately not
863 # chased here: on a failing run they are the evidence. 902 # chased here: on a failing run they are the evidence.
@@ -4114,6 +4153,185 @@ assert_stopped "$SOCK20" "$D17PID" "web tear: the restarted daemon" "$OUT.websto
4114 D17PID="" 4153 D17PID=""
4115 ok "hub narrates the tear; the replica re-attaches across an epoch" 4154 ok "hub narrates the tear; the replica re-attaches across an epoch"
4116 4155
4156 # --- M-wall: the wall is RUNTIME state. The page adds, removes and
4157 # reorders tiles over HTTP, and the file is what makes that survive a
4158 # restart. Every muxweb here runs with a state home of its own (see
4159 # $DWSTATE), so what this leg reads back is the wall this leg wrote — and
4160 # never the developer's real one.
4161 "$MUXD" run --sock "$SOCK25" --shell /bin/sh > "$OUT.dw.d" 2>&1 &
4162 D22PID=$!
4163 wait_sock "$SOCK25" "$OUT.dw.d" "dyn wall daemon never bound"
4164
4165 XDG_STATE_HOME="$DWSTATE" "$MUXWEB" --port "$WPORT4" > "$OUT.dwh" 2>&1 &
4166 W4PID=$!
4167 wait_for "$OUT.dwh" "serving" 10 || {
4168 echo "e2e FAIL: dyn wall: hub never reported serving"; cat "$OUT.dwh"; exit 1; }
4169 DWORIG="http://127.0.0.1:$WPORT4"
4170
4171 # No argv and no file: the hub serves an EMPTY wall rather than refusing to
4172 # start. That is the whole premise of a page that can build its own wall.
4173 [ "$(curl -s "$DWORIG/tiles")" = "[]" ] || {
4174 echo "e2e FAIL: dyn wall: fresh hub wall not empty; it holds:"
4175 curl -s "$DWORIG/tiles"; exit 1; }
4176
4177 # No Origin: refused before anything mutates. A text/plain POST is a CSRF
4178 # "simple request" any page can fire at localhost, so the gate is what keeps
4179 # this page's power this page's — and the wall must be untouched after it.
4180 RC=$(curl -s -o /dev/null -w '%{http_code}' -X POST --data "--sock $SOCK25" "$DWORIG/tiles")
4181 [ "$RC" = "403" ] || { echo "e2e FAIL: dyn wall: originless POST got $RC, want 403"; exit 1; }
4182 [ "$(curl -s "$DWORIG/tiles")" = "[]" ] || {
4183 echo "e2e FAIL: dyn wall: the refused POST still reached the wall:"
4184 curl -s "$DWORIG/tiles"; exit 1; }
4185
4186 # Route precision: /tiles is an EXACT match, not a prefix — `/tilesgarbage`
4187 # must not fall into the mutation block and add a tile.
4188 RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X POST --data "--sock $SOCK25" "$DWORIG/tilesgarbage")
4189 [ "$RC" = "404" ] || { echo "e2e FAIL: dyn wall: /tilesgarbage POST got $RC, want 404"; exit 1; }
4190 [ "$(curl -s "$DWORIG/tiles")" = "[]" ] || {
4191 echo "e2e FAIL: dyn wall: /tilesgarbage POST still added a tile:"
4192 curl -s "$DWORIG/tiles"; exit 1; }
4193
4194 # Two tiles on one socket: the default session and session b, the M18
4195 # one-name-one-session doctrine. Ids are birth order and are the hub's own —
4196 # `/ws/<id>`, not a position — which is what the next assertions ride on.
4197 R=$(curl -s -H "Origin: $DWORIG" -X POST --data "--sock $SOCK25" "$DWORIG/tiles")
4198 [ "$R" = '{"id":0}' ] || { echo "e2e FAIL: dyn wall: first add returned $R, want id 0"; exit 1; }
4199 R=$(curl -s -H "Origin: $DWORIG" -X POST --data "--sock $SOCK25#b" "$DWORIG/tiles")
4200 [ "$R" = '{"id":1}' ] || { echo "e2e FAIL: dyn wall: second add returned $R, want id 1"; exit 1; }
4201
4202 # The two refusals that happen at ADD time rather than at dial time, which
4203 # is the point of both: a malformed session name, and a socket path longer
4204 # than sun_path. A tile that can never attach must not reach the wall.
4205 RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X POST --data 'h#bad name' "$DWORIG/tiles")
4206 [ "$RC" = "400" ] || { echo "e2e FAIL: dyn wall: bad session name got $RC, want 400"; exit 1; }
4207 RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X POST \
4208 --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' \
4209 "$DWORIG/tiles")
4210 [ "$RC" = "400" ] || { echo "e2e FAIL: dyn wall: over-long socket path got $RC, want 400"; exit 1; }
4211
4212 # A POSTed tile is a REAL tile: a marker typed through the CLI door is read
4213 # back through the WebSocket door of the tile the page just created. The
4214 # stand-in attaches at 1x1 and the CLI client has already detached, which is
4215 # the M-web (a) contract in passing — a 1x1 attach never claims the grid, so
4216 # the 80-wide marker is still one contiguous string to expect.
4217 set +e
4218 { printf 'printf "dyn-%%s\\n" w1\n'; sleep 4; printf '\034'; } | \
4219 timeout 40 "$MUX" --sock "$SOCK25" > "$OUT.dwcli" 2> "$OUT.dwcli.err"
4220 RC=$?
4221 set -e
4222 [ "$RC" -eq 0 ] || {
4223 echo "e2e FAIL: dyn wall: CLI client exited $RC"; cat "$OUT.dwcli.err"; exit 1; }
4224 wait_grid "$SOCK25" "dyn-w1" "dyn wall: CLI marker"
4225 set +e
4226 timeout 40 "$WSCLIENT" --port "$WPORT4" --tile 0 --out "$OUT.dwws" --err "$OUT.dwws.err" <<'EOF'
4227 attach 1 1
4228 expectstate up 10000
4229 expectgrid dyn-w1 15000
4230 dumpexit
4231 EOF
4232 RC=$?
4233 set -e
4234 [ "$RC" -eq 0 ] || {
4235 echo "e2e FAIL: dyn wall: wsclient exited $RC"
4236 cat -v "$OUT.dwws.err" 2>/dev/null; cat "$OUT.dwh"; exit 1; }
4237
4238 # Reorder is the FULL new order by id, and the 204 is BODYLESS — a reorder
4239 # that took is read back with a GET, never inferred from the status.
4240 RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X PUT --data '1,0' "$DWORIG/tiles")
4241 [ "$RC" = "204" ] || { echo "e2e FAIL: dyn wall: reorder got $RC, want 204"; exit 1; }
4242 curl -s "$DWORIG/tiles" | grep -q '^\[{"id":1,' || {
4243 echo "e2e FAIL: dyn wall: reorder not reflected; the wall holds:"
4244 curl -s "$DWORIG/tiles"; exit 1; }
4245 # ...and it reached the FILE, which is the half a restart will read back.
4246 # Asserted here rather than after the delete, because this is the only
4247 # moment the wall has two lines and "order" means anything on disk at all.
4248 [ "$(head -1 "$DWSTATE/mux/wall")" = "--sock $SOCK25#b" ] || {
4249 echo "e2e FAIL: dyn wall: reorder did not reach the file; it holds:"
4250 cat "$DWSTATE/mux/wall"; exit 1; }
4251 # An order naming an id the hub does not have is a 409 carrying a copy of
4252 # the truth — never a guess at what the stale page meant.
4253 RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X PUT --data '0,7' "$DWORIG/tiles")
4254 [ "$RC" = "409" ] || { echo "e2e FAIL: dyn wall: stale reorder got $RC, want 409"; exit 1; }
4255 curl -s -H "Origin: $DWORIG" -X PUT --data '0,7' "$DWORIG/tiles" | grep -q '^\[{"id":1,' || {
4256 echo "e2e FAIL: dyn wall: the 409 did not carry the current wall"; exit 1; }
4257
4258 # Remove is DETACH, not kill: the tile leaves the wall, the daemon keeps
4259 # every session it had. `sessions=` is stats' own word for that — and the
4260 # reading is asserted NON-EMPTY before it is compared, because both sides
4261 # come out of the same sed: a stats line that renamed or dropped the field
4262 # would make this `[ "" = "" ]` and pass forever, which is the one way this
4263 # check could fail green (want_stat, above, guards its own the same way).
4264 SESS_BEFORE=$("$MUXD" stats --sock "$SOCK25" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p')
4265 [ -n "$SESS_BEFORE" ] || {
4266 echo "e2e FAIL: dyn wall: no sessions= in stats (the field moved?); it says:"
4267 "$MUXD" stats --sock "$SOCK25"; exit 1; }
4268 RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X DELETE "$DWORIG/tiles/0")
4269 [ "$RC" = "204" ] || { echo "e2e FAIL: dyn wall: delete got $RC, want 204"; exit 1; }
4270 RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X DELETE "$DWORIG/tiles/0")
4271 [ "$RC" = "404" ] || { echo "e2e FAIL: dyn wall: second delete got $RC, want 404"; exit 1; }
4272 SESS_AFTER=$("$MUXD" stats --sock "$SOCK25" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p')
4273 [ "$SESS_BEFORE" = "$SESS_AFTER" ] || {
4274 echo "e2e FAIL: dyn wall: DELETE killed a session ($SESS_BEFORE -> $SESS_AFTER)"; exit 1; }
4275
4276 # ...and the id it took with it is answered in HTTP: a 404 the page can
4277 # read, rather than an upgrade followed by a silent close it can only guess
4278 # at. The stand-in sees a non-101 and exits 4, the wrong-Origin scenario's
4279 # shape.
4280 set +e
4281 timeout 20 "$WSCLIENT" --port "$WPORT4" --tile 0 \
4282 --out "$OUT.dwdead" --err "$OUT.dwdead.err" < /dev/null
4283 RC=$?
4284 set -e
4285 [ "$RC" -eq 4 ] || {
4286 echo "e2e FAIL: dyn wall: wsclient on a removed tile exited $RC, want 4"
4287 cat -v "$OUT.dwdead.err" 2>/dev/null; exit 1; }
4288 grep -q "upgrade refused.*404" "$OUT.dwdead.err" || {
4289 echo "e2e FAIL: dyn wall: a dead id was not refused with a 404:"
4290 cat -v "$OUT.dwdead.err"; exit 1; }
4291
4292 # The file carries SPELLINGS, one per line, in wall order — no ids, because
4293 # ids are per-run. The surviving tile is the `#b` one the delete spared.
4294 grep -qxF -- "--sock $SOCK25#b" "$DWSTATE/mux/wall" || {
4295 echo "e2e FAIL: dyn wall: state file missing the surviving tile; it holds:"
4296 cat "$DWSTATE/mux/wall"; exit 1; }
4297 [ "$(wc -l < "$DWSTATE/mux/wall")" = "1" ] || {
4298 echo "e2e FAIL: dyn wall: state file is not the one surviving line:"
4299 cat "$DWSTATE/mux/wall"; exit 1; }
4300
4301 # Restart with NO argv: the file is the wall. The id is fresh — a restarted
4302 # hub numbers from 0 in wall order — and the SPELLING is what persisted.
4303 kill "$W4PID" 2>/dev/null || true
4304 wait_pid_gone "$W4PID" "dyn wall: first hub killed by tracked pid"
4305 XDG_STATE_HOME="$DWSTATE" "$MUXWEB" --port "$WPORT4" > "$OUT.dwh2" 2>&1 &
4306 W4PID=$!
4307 wait_for "$OUT.dwh2" "serving" 10 || {
4308 echo "e2e FAIL: dyn wall: restored hub never reported serving"; cat "$OUT.dwh2"; exit 1; }
4309 curl -s "$DWORIG/tiles" | grep -q "^\[{\"id\":0,\"label\":\"--sock $SOCK25#b\"" || {
4310 echo "e2e FAIL: dyn wall: restart lost the persisted wall; it holds:"
4311 curl -s "$DWORIG/tiles"; exit 1; }
4312
4313 # Restart WITH argv: the explicit override replaces the file rather than
4314 # appending to it, and is itself saved as the next run's wall.
4315 kill "$W4PID" 2>/dev/null || true
4316 wait_pid_gone "$W4PID" "dyn wall: restored hub killed by tracked pid"
4317 XDG_STATE_HOME="$DWSTATE" "$MUXWEB" --sock "$SOCK25" --port "$WPORT4" > "$OUT.dwh3" 2>&1 &
4318 W4PID=$!
4319 wait_for "$OUT.dwh3" "serving" 10 || {
4320 echo "e2e FAIL: dyn wall: overriding hub never reported serving"; cat "$OUT.dwh3"; exit 1; }
4321 grep -qxF -- "--sock $SOCK25" "$DWSTATE/mux/wall" || {
4322 echo "e2e FAIL: dyn wall: argv did not replace the wall file; it holds:"
4323 cat "$DWSTATE/mux/wall"; exit 1; }
4324 [ "$(wc -l < "$DWSTATE/mux/wall")" = "1" ] || {
4325 echo "e2e FAIL: dyn wall: argv appended to the wall instead of replacing it:"
4326 cat "$DWSTATE/mux/wall"; exit 1; }
4327
4328 kill "$W4PID" 2>/dev/null || true
4329 wait_pid_gone "$W4PID" "dyn wall: overriding hub killed by tracked pid"
4330 W4PID=""
4331 assert_stopped "$SOCK25" "$D22PID" "dyn wall" "$OUT.dwstop"
4332 D22PID=""
4333 ok "the wall is runtime state: add, remove, reorder, restore, argv overrides"
4334
4117 # The long-lived daemon has served every scenario that wanted it; stop it 4335 # The long-lived daemon has served every scenario that wanted it; stop it
4118 # NOW so its allocator verdict is written while the suite is still running 4336 # NOW so its allocator verdict is written while the suite is still running
4119 # and can say so. SIGTERM runs the clean-shutdown path, so the defer chain 4337 # and can say so. SIGTERM runs the clean-shutdown path, so the defer chain
@@ -4129,7 +4347,7 @@ DPID=""
4129 4347
4130 # The pins. Literals, not variables set from counting something else — 4348 # The pins. Literals, not variables set from counting something else —
4131 # "assert the literal, never the constant the code under test reads" 4349 # "assert the literal, never the constant the code under test reads"
4132 # (decisions.md, M10). 31 scenario checkpoints; 35 convergence points. 4350 # (decisions.md, M10). 32 scenario checkpoints; 35 convergence points.
4133 # Anyone adding a scenario updates these by hand, on purpose. 4351 # Anyone adding a scenario updates these by hand, on purpose.
4134 # 4352 #
4135 # M18 added three checkpoints and no convergence points: its wall block 4353 # M18 added three checkpoints and no convergence points: its wall block
@@ -4146,9 +4364,13 @@ DPID=""
4146 # that took no terminal over — and carries no convergence point either. The 4364 # that took no terminal over — and carries no convergence point either. The
4147 # 31st is the bell burst, and no convergence point for the side-channel 4365 # 31st is the bell burst, and no convergence point for the side-channel
4148 # reason a third time: what it asserts on is a COUNT of bytes in a capture, 4366 # reason a third time: what it asserts on is a COUNT of bytes in a capture,
4149 # and a grid carries neither the bytes nor the number of them. 4367 # and a grid carries neither the bytes nor the number of them. The 32nd is
4150 [ "$OK_COUNT" = "31" ] || { 4368 # the dynamic wall, and no convergence point either: what it asserts on is
4151 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 31 —" 4369 # the hub's HTTP answers and the wall FILE — its one grid check rides on
4370 # `expectgrid` inside the stand-in, which no more counts here than the M18
4371 # wall block's convergence does.
4372 [ "$OK_COUNT" = "32" ] || {
4373 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 32 —"
4152 echo " a scenario was added (update the pin) or silently lost" 4374 echo " a scenario was added (update the pin) or silently lost"
4153 exit 1 4375 exit 1
4154 } 4376 }
@@ -4156,4 +4378,4 @@ DPID=""
4156 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35" 4378 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 35"
4157 exit 1 4379 exit 1
4158 } 4380 }
4159 echo "e2e OK (31 scenarios, 35 convergence points)" 4381 echo "e2e OK (32 scenarios, 35 convergence points)"