7c4367e8
test: e2e — dynamic wall add/remove/reorder/restore; docs
a73x 2026-08-18 18:01
Commit message
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)" |