e26e5494
test: the browser wall brings a saved local line back
a73x 2026-08-26 06:51
Commit message
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -403,6 +403,23 @@ D65PID="" | |||
| 403 | SOCK66="${TMPDIR:-/tmp}/muxd-e2e-refuse-$$.sock" | 403 | SOCK66="${TMPDIR:-/tmp}/muxd-e2e-refuse-$$.sock" |
| 404 | REFSTATE="${TMPDIR:-/tmp}/mux-e2e-refuse-state-$$" | 404 | REFSTATE="${TMPDIR:-/tmp}/mux-e2e-refuse-state-$$" |
| 405 | D66PID="" | 405 | D66PID="" |
| 406 | # The browser half of the same ruling: one daemon serving a socket AND a | ||
| 407 | # QUIC listener, one hub, a state home of its own. Two ports, and the | ||
| 408 | # 5000-spaced bands are exhausted — 61000 is the last one that fits under | ||
| 409 | # 65535 — so this leg takes the tail ABOVE the ephemeral range | ||
| 410 | # (ip_local_port_range tops out at 60999 by default), halved. The narrow | ||
| 411 | # `%250` is the price of that tail: two runs whose pids differ by 250 | ||
| 412 | # collide where the older bands need 4000. A collision reads as this leg's | ||
| 413 | # daemon failing to bind, which is also why the two halves cannot share a | ||
| 414 | # base. | ||
| 415 | SOCK67="${TMPDIR:-/tmp}/muxd-e2e-wg-$$.sock" | ||
| 416 | WGPORT=$(( 65000 + ($$ % 250) )) | ||
| 417 | WGQPORT=$(( 65250 + ($$ % 250) )) | ||
| 418 | WGKEY="${TMPDIR:-/tmp}/mux-e2e-wgkey-$$" | ||
| 419 | WGSTATE="${TMPDIR:-/tmp}/mux-e2e-wg-state-$$" | ||
| 420 | WGWALL="$WGSTATE/mux/wall" | ||
| 421 | D67PID="" | ||
| 422 | W5PID="" | ||
| 406 | D54PID="" | 423 | D54PID="" |
| 407 | D55PID="" | 424 | D55PID="" |
| 408 | D56PID="" | 425 | D56PID="" |
| @@ -1325,6 +1342,7 @@ cleanup() { | |||
| 1325 | # across three incarnations on the same port, so a pid here is whichever | 1342 | # across three incarnations on the same port, so a pid here is whichever |
| 1326 | # one the run died under. | 1343 | # one the run died under. |
| 1327 | [ -n "$W4PID" ] && kill "$W4PID" 2>/dev/null || true | 1344 | [ -n "$W4PID" ] && kill "$W4PID" 2>/dev/null || true |
| 1345 | [ -n "${W5PID:-}" ] && kill "$W5PID" 2>/dev/null || true | ||
| 1328 | [ -n "$D22PID" ] && kill "$D22PID" 2>/dev/null || true | 1346 | [ -n "$D22PID" ] && kill "$D22PID" 2>/dev/null || true |
| 1329 | [ -n "$D23PID" ] && kill "$D23PID" 2>/dev/null || true | 1347 | [ -n "$D23PID" ] && kill "$D23PID" 2>/dev/null || true |
| 1330 | [ -n "$D24PID" ] && kill "$D24PID" 2>/dev/null || true | 1348 | [ -n "$D24PID" ] && kill "$D24PID" 2>/dev/null || true |
| @@ -1414,6 +1432,7 @@ cleanup() { | |||
| 1414 | [ -S "$SOCK61" ] && "$MUXD" stop --sock "$SOCK61" 2>/dev/null || true | 1432 | [ -S "$SOCK61" ] && "$MUXD" stop --sock "$SOCK61" 2>/dev/null || true |
| 1415 | [ -S "$SOCK64" ] && "$MUXD" stop --sock "$SOCK64" 2>/dev/null || true | 1433 | [ -S "$SOCK64" ] && "$MUXD" stop --sock "$SOCK64" 2>/dev/null || true |
| 1416 | [ -S "$SOCK66" ] && "$MUXD" stop --sock "$SOCK66" 2>/dev/null || true | 1434 | [ -S "$SOCK66" ] && "$MUXD" stop --sock "$SOCK66" 2>/dev/null || true |
| 1435 | [ -S "$SOCK67" ] && "$MUXD" stop --sock "$SOCK67" 2>/dev/null || true | ||
| 1417 | 1436 | ||
| 1418 | # ---- the leak sweep (hygiene kit, 6a) ---- | 1437 | # ---- the leak sweep (hygiene kit, 6a) ---- |
| 1419 | # Here rather than at the bottom of the file, which `set -e` reaches only | 1438 | # Here rather than at the bottom of the file, which `set -e` reaches only |
| @@ -1431,7 +1450,7 @@ cleanup() { | |||
| 1431 | "$D38PID" "$D39PID" "$D40PID" "$D41PID" "$D42PID" "$D43PID" "$D54PID" \ | 1450 | "$D38PID" "$D39PID" "$D40PID" "$D41PID" "$D42PID" "$D43PID" "$D54PID" \ |
| 1432 | "$D55PID" "$D56PID" "$D57PID" "$D58PID" "$D59PID" \ | 1451 | "$D55PID" "$D56PID" "$D57PID" "$D58PID" "$D59PID" \ |
| 1433 | "$D60PID" "$D61PID" "$D62PID" "$D63PID" "$D64PID" "$D65PID" \ | 1452 | "$D60PID" "$D61PID" "$D62PID" "$D63PID" "$D64PID" "$D65PID" \ |
| 1434 | "$D66PID" | 1453 | "$D66PID" "$D67PID" |
| 1435 | _leak=0 | 1454 | _leak=0 |
| 1436 | leak_sweep "$_rc" || _leak=1 | 1455 | leak_sweep "$_rc" || _leak=1 |
| 1437 | 1456 | ||
| @@ -1533,7 +1552,13 @@ cleanup() { | |||
| 1533 | "$OUT.hydstop" "$OUT.hydfcap" "$OUT.hydfcap.err" "$OUT.hydfpc" \ | 1552 | "$OUT.hydstop" "$OUT.hydfcap" "$OUT.hydfcap.err" "$OUT.hydfpc" \ |
| 1534 | "$OUT.hydfold" "$OUT.hydfgrid" "$SOCK64" "$HYKEY" \ | 1553 | "$OUT.hydfold" "$OUT.hydfgrid" "$SOCK64" "$HYKEY" \ |
| 1535 | "$OUT.ref.d" "$OUT.refrun" "$OUT.refstatus" "$OUT.refsend" \ | 1554 | "$OUT.ref.d" "$OUT.refrun" "$OUT.refstatus" "$OUT.refsend" \ |
| 1536 | "$OUT.refalive" "$OUT.refstop" "$SOCK66" | 1555 | "$OUT.refalive" "$OUT.refstop" "$SOCK66" \ |
| 1556 | "$OUT.wg.d" "$OUT.wgh" "$OUT.wgws" "$OUT.wgws.err" \ | ||
| 1557 | "$OUT.wgws2" "$OUT.wgws2.err" "$OUT.wgpre" "$OUT.wgsta" \ | ||
| 1558 | "$OUT.wgghost" "$OUT.wgstop" "$OUT.wgsta0" "$OUT.wgexit" \ | ||
| 1559 | "$OUT.wgws3" "$OUT.wgws3.err" "$SOCK67" "$WGKEY" | ||
| 1560 | # ...and the two state homes those legs read their walls back out of. | ||
| 1561 | rm -rf "$HYSTATE" "$WGSTATE" | ||
| 1537 | # ...and the non-tty capture that leg's session feeds. | 1562 | # ...and the non-tty capture that leg's session feeds. |
| 1538 | rm -f "$OUT.nogate" | 1563 | rm -f "$OUT.nogate" |
| 1539 | # ...and its other half: the paste capture and the file nvim wrote, which | 1564 | # ...and its other half: the paste capture and the file nvim wrote, which |
| @@ -8801,8 +8826,186 @@ D66PID="" | |||
| 8801 | rm -rf "$REFSTATE" | 8826 | rm -rf "$REFSTATE" |
| 8802 | ok "muxa names a refused attach instead of a shell that never ran" | 8827 | ok "muxa names a refused attach instead of a shell that never ran" |
| 8803 | 8828 | ||
| 8804 | [ "$OK_COUNT" = "75" ] || { | 8829 | # --- the same ruling through the browser ------------------------------- |
| 8805 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 75 —" | 8830 | # |
| 8831 | # The hub's tiles attach at 0x0 — the passivity contract, so a browser can | ||
| 8832 | # never move a shared grid — and the daemon reads 0x0 as join-only. A saved | ||
| 8833 | # wall whose daemon has restarted was therefore a grid of dead tiles that | ||
| 8834 | # the CLI, reading the same file, brought back. The hub now births the | ||
| 8835 | # session on a connection of its own and re-dials; the browser re-attaches | ||
| 8836 | # on `up` as it does after any tear and is never told a refusal happened. | ||
| 8837 | # | ||
| 8838 | # The split is the hydrate leg's, asserted the other side of the wire: the | ||
| 8839 | # LOCAL line comes back, the `quic://` one still says refused. Same daemon, | ||
| 8840 | # same run, reached both ways, so the comparison is between two kinds of | ||
| 8841 | # line and not between two rigs. | ||
| 8842 | # | ||
| 8843 | # Daemon truth answers "was it created" — `muxa status` on a name the | ||
| 8844 | # daemon does not have exits non-zero, and the same probe runs BEFORE the | ||
| 8845 | # hub so the leg cannot pass vacuously. `sessions=1` at the end is the | ||
| 8846 | # other half: one birth, not two, and nothing born for the remote line. | ||
| 8847 | head -c 32 /dev/urandom > "$WGKEY" | ||
| 8848 | chmod 600 "$WGKEY" | ||
| 8849 | "$MUXD" run --sock "$SOCK67" --shell /bin/sh \ | ||
| 8850 | --quic "127.0.0.1:$WGQPORT" --key "$WGKEY" --quic-idle-ms 15000 > "$OUT.wg.d" 2>&1 & | ||
| 8851 | D67PID=$! | ||
| 8852 | wait_sock "$SOCK67" "$OUT.wg.d" "web-restore daemon never bound" | ||
| 8853 | |||
| 8854 | # Written by hand for the hydrate leg's reason: a line the hub had earned | ||
| 8855 | # by attaching would name a session that already exists, and there would be | ||
| 8856 | # nothing left for the restore to create. | ||
| 8857 | mkdir -p "$WGSTATE/mux" | ||
| 8858 | printf -- '--sock %s#wghost\nquic://127.0.0.1:%s#wghost2\n' \ | ||
| 8859 | "$SOCK67" "$WGQPORT" > "$WGWALL" | ||
| 8860 | |||
| 8861 | # Vacuity guard: neither exists yet. | ||
| 8862 | for _wg in wghost wghost2; do | ||
| 8863 | timeout 20 "$MUXA" status --sock "$SOCK67" --session "$_wg" > "$OUT.wgpre" 2>&1 && { | ||
| 8864 | echo "e2e FAIL: web-restore: session $_wg existed before the hub ran:" | ||
| 8865 | cat "$OUT.wgpre"; exit 1; } | ||
| 8866 | done | ||
| 8867 | |||
| 8868 | # No argv: the hub restores the wall from the last run, which is the file | ||
| 8869 | # written above. Its own state home, never the developer's. | ||
| 8870 | XDG_STATE_HOME="$WGSTATE" "$MUXWEB" --port "$WGPORT" --key "$WGKEY" \ | ||
| 8871 | --quic-idle-ms 15000 > "$OUT.wgh" 2>&1 & | ||
| 8872 | W5PID=$! | ||
| 8873 | wait_for "$OUT.wgh" "serving" 10 || { | ||
| 8874 | echo "e2e FAIL: web-restore: hub never reported serving"; cat "$OUT.wgh"; exit 1; } | ||
| 8875 | |||
| 8876 | # Tile 0, the LOCAL line. `expectups 2` is the assertion that the hub | ||
| 8877 | # re-dialed: one `up` for the first dial, a second for the one after the | ||
| 8878 | # birth. Counted rather than read off the last state, because a | ||
| 8879 | # `reconnecting` → `up` pair can arrive inside one read and leave the last | ||
| 8880 | # state saying exactly what it said before. The second attach is the | ||
| 8881 | # browser's own — mux.js re-attaches on `up`, and nothing in the page | ||
| 8882 | # decides anything about creation. Both scripts wait for the first `up` | ||
| 8883 | # before attaching, for mux.js's reason: a message sent while the hub is | ||
| 8884 | # still dialing is dropped, and an attach nobody read is a refusal that | ||
| 8885 | # never arrives. | ||
| 8886 | set +e | ||
| 8887 | timeout 60 "$WSCLIENT" --port "$WGPORT" --tile 0 --out "$OUT.wgws" --err "$OUT.wgws.err" <<'EOF' | ||
| 8888 | expectstate up 25000 | ||
| 8889 | attach 0 0 wghost | ||
| 8890 | expectups 2 30000 | ||
| 8891 | attach 0 0 wghost | ||
| 8892 | settle 500 20000 | ||
| 8893 | send printf 'wg-%s\n' born\n | ||
| 8894 | expectgrid wg-born 25000 | ||
| 8895 | dumpexit | ||
| 8896 | EOF | ||
| 8897 | RC=$? | ||
| 8898 | set -e | ||
| 8899 | [ "$RC" -eq 0 ] || { | ||
| 8900 | echo "e2e FAIL: web-restore: the local tile's wsclient exited $RC" | ||
| 8901 | cat -v "$OUT.wgws.err" 2>/dev/null; cat "$OUT.wgh"; exit 1; } | ||
| 8902 | |||
| 8903 | # Tile 1, the REMOTE line: the refusal is forwarded untouched, and the | ||
| 8904 | # fixture tells it from a shell exiting exactly as the page does — an | ||
| 8905 | # exit_status before any grid. | ||
| 8906 | set +e | ||
| 8907 | timeout 60 "$WSCLIENT" --port "$WGPORT" --tile 1 --out "$OUT.wgws2" --err "$OUT.wgws2.err" <<'EOF' | ||
| 8908 | expectstate up 25000 | ||
| 8909 | attach 0 0 wghost2 | ||
| 8910 | expectrefused 25000 | ||
| 8911 | dumpexit | ||
| 8912 | EOF | ||
| 8913 | RC=$? | ||
| 8914 | set -e | ||
| 8915 | [ "$RC" -eq 0 ] || { | ||
| 8916 | echo "e2e FAIL: web-restore: the remote tile's wsclient exited $RC" | ||
| 8917 | cat -v "$OUT.wgws2.err" 2>/dev/null; cat "$OUT.wgh"; exit 1; } | ||
| 8918 | |||
| 8919 | # Daemon truth: the local line's session exists, and at the size the birth | ||
| 8920 | # claimed — a browser tile claims none, so a session born at the tile's 0x0 | ||
| 8921 | # would not have been born at all. | ||
| 8922 | timeout 20 "$MUXA" status --sock "$SOCK67" --session wghost > "$OUT.wgsta" 2>&1 || { | ||
| 8923 | echo "e2e FAIL: web-restore: the saved local line did not come back:" | ||
| 8924 | cat "$OUT.wgsta"; cat "$OUT.wgh"; exit 1; } | ||
| 8925 | |||
| 8926 | # The size is asserted against the DAEMON's own default, not against a | ||
| 8927 | # literal: session 0 is seeded from `main.Opts` and this daemon was given | ||
| 8928 | # no --cols/--rows, so it holds exactly the figure `client.birth_cols` and | ||
| 8929 | # `client.birth_rows` claim to mirror. Grepping for 80x24 would pass just | ||
| 8930 | # as happily if both sides drifted together, which is the drift that | ||
| 8931 | # matters — nothing else links the two files. | ||
| 8932 | timeout 20 "$MUXA" status --sock "$SOCK67" --session 0 > "$OUT.wgsta0" 2>&1 || { | ||
| 8933 | echo "e2e FAIL: web-restore: the daemon's own default session did not answer:" | ||
| 8934 | cat "$OUT.wgsta0"; exit 1; } | ||
| 8935 | _wg_size() { sed -n 's/^{\("cols":[0-9]*,"rows":[0-9]*\).*/\1/p' "$1"; } | ||
| 8936 | WG_BORN=$(_wg_size "$OUT.wgsta") | ||
| 8937 | WG_DEFAULT=$(_wg_size "$OUT.wgsta0") | ||
| 8938 | [ -n "$WG_BORN" ] && [ -n "$WG_DEFAULT" ] || { | ||
| 8939 | echo "e2e FAIL: web-restore: no size read out of muxa status" | ||
| 8940 | echo " born='$WG_BORN' default='$WG_DEFAULT' — the pin would be vacuous" | ||
| 8941 | cat "$OUT.wgsta" "$OUT.wgsta0"; exit 1; } | ||
| 8942 | [ "$WG_BORN" = "$WG_DEFAULT" ] || { | ||
| 8943 | echo "e2e FAIL: web-restore: the birth size drifted from muxd's own default" | ||
| 8944 | echo " wghost $WG_BORN, session 0 $WG_DEFAULT" | ||
| 8945 | echo " — client.birth_cols/birth_rows must mirror main.Opts" | ||
| 8946 | exit 1; } | ||
| 8947 | |||
| 8948 | # ...and it is a real shell: the marker the browser stand-in typed is the | ||
| 8949 | # created session's own work. | ||
| 8950 | grep -qF "wg-born" "$OUT.wgws" || { | ||
| 8951 | echo "e2e FAIL: web-restore: the marker never reached the created session:" | ||
| 8952 | cat "$OUT.wgws"; exit 1; } | ||
| 8953 | |||
| 8954 | # The remote line created NOTHING. Same daemon, same file, same run. | ||
| 8955 | timeout 20 "$MUXA" status --sock "$SOCK67" --session wghost2 > "$OUT.wgghost" 2>&1 && { | ||
| 8956 | echo "e2e FAIL: web-restore: a quic:// wall line created a session:" | ||
| 8957 | cat "$OUT.wgghost"; exit 1; } | ||
| 8958 | |||
| 8959 | # One birth, not two: a hub that re-birthed on every refused re-dial would | ||
| 8960 | # still pass every assertion above. Two, not one — `muxd run` makes the | ||
| 8961 | # default session at startup, so the birth is the SECOND. | ||
| 8962 | wait_sessions "$SOCK67" 2 "web-restore: exactly one session was born" | ||
| 8963 | |||
| 8964 | # ...and a shell the user ENDS stays ended. Every refusal closes the hub's | ||
| 8965 | # connection (`server.dropObserver` closes the fd), so `exit` reaches the | ||
| 8966 | # same refuse-after-redial shape the restore does: the daemon reaps the | ||
| 8967 | # session, the hub redials, the page re-attaches on `up`, and the attach is | ||
| 8968 | # refused because the session is gone. A hub that could not tell those two | ||
| 8969 | # apart hands the user a NEW shell every time they type `exit` — and with a | ||
| 8970 | # short-lived shell, one forked process per turn, forever. The CLI's answer | ||
| 8971 | # is `wallview`'s: its pump ends on an exit_status and the tile stays dead. | ||
| 8972 | # | ||
| 8973 | # The re-attach is spelled out here because it is the browser's, not the | ||
| 8974 | # hub's: mux.js sends it on `up`, and the fixture stands in for that. | ||
| 8975 | set +e | ||
| 8976 | timeout 60 "$WSCLIENT" --port "$WGPORT" --tile 0 --out "$OUT.wgws3" --err "$OUT.wgws3.err" <<'EOF' | ||
| 8977 | expectstate up 25000 | ||
| 8978 | attach 0 0 wghost | ||
| 8979 | settle 500 20000 | ||
| 8980 | send exit\n | ||
| 8981 | expectups 2 30000 | ||
| 8982 | attach 0 0 wghost | ||
| 8983 | expectrefused 25000 | ||
| 8984 | dumpexit | ||
| 8985 | EOF | ||
| 8986 | RC=$? | ||
| 8987 | set -e | ||
| 8988 | [ "$RC" -eq 0 ] || { | ||
| 8989 | echo "e2e FAIL: web-restore: the exiting tile's wsclient exited $RC" | ||
| 8990 | cat -v "$OUT.wgws3.err" 2>/dev/null; cat "$OUT.wgh"; exit 1; } | ||
| 8991 | |||
| 8992 | # Daemon truth for the same claim: the session the user ended is gone and | ||
| 8993 | # stayed gone, and the daemon is back to the one session it started with. | ||
| 8994 | timeout 20 "$MUXA" status --sock "$SOCK67" --session wghost > "$OUT.wgexit" 2>&1 && { | ||
| 8995 | echo "e2e FAIL: web-restore: typing exit got the user a new shell:" | ||
| 8996 | cat "$OUT.wgexit"; exit 1; } | ||
| 8997 | wait_sessions "$SOCK67" 1 "web-restore: the ended session was not resurrected" | ||
| 8998 | |||
| 8999 | kill "$W5PID" 2>/dev/null || true | ||
| 9000 | wait_pid_gone "$W5PID" "web-restore: hub killed by tracked pid" | ||
| 9001 | W5PID="" | ||
| 9002 | assert_stopped "$SOCK67" "$D67PID" "web-restore" "$OUT.wgstop" | ||
| 9003 | D67PID="" | ||
| 9004 | rm -rf "$WGSTATE" | ||
| 9005 | ok "the browser wall restores a saved local line too, and still joins a remote one only" | ||
| 9006 | |||
| 9007 | [ "$OK_COUNT" = "76" ] || { | ||
| 9008 | echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 76 —" | ||
| 8806 | echo " a scenario was added (update the pin) or silently lost" | 9009 | echo " a scenario was added (update the pin) or silently lost" |
| 8807 | exit 1 | 9010 | exit 1 |
| 8808 | } | 9011 | } |
test/wsclient.zig
| Old | New | ||
|---|---|---|---|
| @@ -14,6 +14,9 @@ | |||
| 14 | //! resize C R resize frame | 14 | //! resize C R resize frame |
| 15 | //! expectgrid NEEDLE MS poll the replica's plain dump for NEEDLE | 15 | //! expectgrid NEEDLE MS poll the replica's plain dump for NEEDLE |
| 16 | //! expectstate STATE MS wait for a control message naming STATE | 16 | //! expectstate STATE MS wait for a control message naming STATE |
| 17 | //! expectrefused MS wait for an exit_status that arrived before any | ||
| 18 | //! grid — the browser's own refusal discriminator | ||
| 19 | //! expectups N MS wait until N `up` control messages have arrived | ||
| 17 | //! settle QUIET MS drain frames until QUIET ms of silence | 20 | //! settle QUIET MS drain frames until QUIET ms of silence |
| 18 | //! dumpexit write the replica grid (muxd dump format) to | 21 | //! dumpexit write the replica grid (muxd dump format) to |
| 19 | //! --out and exit 0 | 22 | //! --out and exit 0 |
| @@ -132,6 +135,18 @@ const Client = struct { | |||
| 132 | /// scenario would silently start diffing a different terminal. | 135 | /// scenario would silently start diffing a different terminal. |
| 133 | att_name: [proto.session_name_max]u8 = @splat(0), | 136 | att_name: [proto.session_name_max]u8 = @splat(0), |
| 134 | att_name_len: usize = 0, | 137 | att_name_len: usize = 0, |
| 138 | /// mux.js's discriminator, mirrored so a scenario can assert on it: | ||
| 139 | /// an exit_status BEFORE any grid is the daemon refusing the attach; | ||
| 140 | /// after one it is the shell exiting. A fixture that could not tell | ||
| 141 | /// them apart would pass on either. | ||
| 142 | got_state: bool = false, | ||
| 143 | refused: bool = false, | ||
| 144 | /// COUNTED, not sampled: `last_state` is overwritten by whatever | ||
| 145 | /// arrives next, and one pump call drains every buffered message — | ||
| 146 | /// so a `reconnecting` → `up` pair narrating a re-dial can pass | ||
| 147 | /// through leaving `last_state` reading `up` exactly as it did | ||
| 148 | /// before. A scenario that must see the re-dial happen counts. | ||
| 149 | ups: usize = 0, | ||
| 135 | 150 | ||
| 136 | fn sendMessage(self: *Client, payload: []const u8) void { | 151 | fn sendMessage(self: *Client, payload: []const u8) void { |
| 137 | self.sendRaw(0x82, payload); // FIN | binary | 152 | self.sendRaw(0x82, payload); // FIN | binary |
| @@ -184,6 +199,14 @@ const Client = struct { | |||
| 184 | // the destination undefined behaviour (it panics on it in Debug). | 199 | // the destination undefined behaviour (it panics on it in Debug). |
| 185 | std.mem.copyForwards(u8, self.att_name[0..name.len], name); | 200 | std.mem.copyForwards(u8, self.att_name[0..name.len], name); |
| 186 | self.att_name_len = name.len; | 201 | self.att_name_len = name.len; |
| 202 | // Both facts are ABOUT the attach now in flight, not about the | ||
| 203 | // connection — mux.js clears its own `gotState` on every attach | ||
| 204 | // for the same reason. A tile that held a grid, re-attached, and | ||
| 205 | // was then refused must read as REFUSED; leaving either latched | ||
| 206 | // makes the refusal look like a shell exiting, or lets a stale | ||
| 207 | // one answer a later wait. | ||
| 208 | self.got_state = false; | ||
| 209 | self.refused = false; | ||
| 187 | const q = if (fresh) Replica.AttachArgs{ .have_seq = 0, .have_epoch = 0 } else self.rep.attachArgs(); | 210 | const q = if (fresh) Replica.AttachArgs{ .have_seq = 0, .have_epoch = 0 } else self.rep.attachArgs(); |
| 188 | var buf: [proto.attach_max_len]u8 = undefined; | 211 | var buf: [proto.attach_max_len]u8 = undefined; |
| 189 | const att = proto.encodeAttachNamed(&buf, cols, rows, q.have_seq, q.have_epoch, name); | 212 | const att = proto.encodeAttachNamed(&buf, cols, rows, q.have_seq, q.have_epoch, name); |
| @@ -241,6 +264,7 @@ const Client = struct { | |||
| 241 | const n = @min(state.len, self.last_state.len); | 264 | const n = @min(state.len, self.last_state.len); |
| 242 | @memcpy(self.last_state[0..n], state[0..n]); | 265 | @memcpy(self.last_state[0..n], state[0..n]); |
| 243 | self.last_state_len = n; | 266 | self.last_state_len = n; |
| 267 | if (std.mem.eql(u8, state, "up")) self.ups += 1; | ||
| 244 | } | 268 | } |
| 245 | return; | 269 | return; |
| 246 | } | 270 | } |
| @@ -251,8 +275,17 @@ const Client = struct { | |||
| 251 | const payload = data[6..]; | 275 | const payload = data[6..]; |
| 252 | const snapshot: u8 = @intFromEnum(proto.MsgType.snapshot); | 276 | const snapshot: u8 = @intFromEnum(proto.MsgType.snapshot); |
| 253 | const delta: u8 = @intFromEnum(proto.MsgType.delta); | 277 | const delta: u8 = @intFromEnum(proto.MsgType.delta); |
| 278 | if (t == @intFromEnum(proto.MsgType.exit_status)) { | ||
| 279 | if (!self.got_state) self.refused = true; | ||
| 280 | return; | ||
| 281 | } | ||
| 254 | if (t == snapshot or t == delta) { | 282 | if (t == snapshot or t == delta) { |
| 283 | // replica.zig's pinned subtlety, as mux.js mirrors it: a | ||
| 284 | // DELTA's arrival alone proves the attach was admitted, | ||
| 285 | // decodable or not. | ||
| 286 | if (t == delta) self.got_state = true; | ||
| 255 | const applied = self.rep.apply(@enumFromInt(t), payload) catch return; | 287 | const applied = self.rep.apply(@enumFromInt(t), payload) catch return; |
| 288 | if (applied == .painted) self.got_state = true; | ||
| 256 | if (applied == .resync) { | 289 | if (applied == .resync) { |
| 257 | // Mirror the browser: a garbled delta re-attaches fresh, | 290 | // Mirror the browser: a garbled delta re-attaches fresh, |
| 258 | // at the TILE's size — never at rep.grid, which is the | 291 | // at the TILE's size — never at rep.grid, which is the |
| @@ -261,7 +294,7 @@ const Client = struct { | |||
| 261 | self.sendAttach(self.att_cols, self.att_rows, true, self.att_name[0..self.att_name_len]); | 294 | self.sendAttach(self.att_cols, self.att_rows, true, self.att_name[0..self.att_name_len]); |
| 262 | } | 295 | } |
| 263 | } | 296 | } |
| 264 | // Everything else (exit_status, pty_mode, scrollback) is visible | 297 | // Everything else (pty_mode, scrollback) is visible |
| 265 | // in --err via the state machinery when a scenario needs it. | 298 | // in --err via the state machinery when a scenario needs it. |
| 266 | } | 299 | } |
| 267 | 300 | ||
| @@ -474,6 +507,22 @@ pub fn main() !void { | |||
| 474 | if (nowMs() >= deadline) fatal(EXIT_TIMEOUT, "expectstate '{s}' timed out (at '{s}')", .{ want, cl.last_state[0..cl.last_state_len] }); | 507 | if (nowMs() >= deadline) fatal(EXIT_TIMEOUT, "expectstate '{s}' timed out (at '{s}')", .{ want, cl.last_state[0..cl.last_state_len] }); |
| 475 | if (!cl.pump(50)) fatal(EXIT_DIED, "hub hung up during expectstate", .{}); | 508 | if (!cl.pump(50)) fatal(EXIT_DIED, "hub hung up during expectstate", .{}); |
| 476 | } | 509 | } |
| 510 | } else if (std.mem.eql(u8, verb, "expectrefused")) { | ||
| 511 | const ms = std.fmt.parseInt(i64, rest, 10) catch fatal(EXIT_USAGE, "expectrefused MS", .{}); | ||
| 512 | const deadline = nowMs() + ms; | ||
| 513 | while (!cl.refused) { | ||
| 514 | if (nowMs() >= deadline) fatal(EXIT_TIMEOUT, "expectrefused timed out (state '{s}')", .{cl.last_state[0..cl.last_state_len]}); | ||
| 515 | if (!cl.pump(50)) fatal(EXIT_DIED, "hub hung up during expectrefused", .{}); | ||
| 516 | } | ||
| 517 | } else if (std.mem.eql(u8, verb, "expectups")) { | ||
| 518 | var it = std.mem.tokenizeScalar(u8, rest, ' '); | ||
| 519 | const want = std.fmt.parseInt(usize, it.next() orelse fatal(EXIT_USAGE, "expectups N MS", .{}), 10) catch fatal(EXIT_USAGE, "expectups N MS", .{}); | ||
| 520 | const ms = std.fmt.parseInt(i64, it.next() orelse fatal(EXIT_USAGE, "expectups N MS", .{}), 10) catch fatal(EXIT_USAGE, "expectups N MS", .{}); | ||
| 521 | const deadline = nowMs() + ms; | ||
| 522 | while (cl.ups < want) { | ||
| 523 | if (nowMs() >= deadline) fatal(EXIT_TIMEOUT, "expectups {d} timed out at {d}", .{ want, cl.ups }); | ||
| 524 | if (!cl.pump(50)) fatal(EXIT_DIED, "hub hung up during expectups", .{}); | ||
| 525 | } | ||
| 477 | } else if (std.mem.eql(u8, verb, "settle")) { | 526 | } else if (std.mem.eql(u8, verb, "settle")) { |
| 478 | var it = std.mem.tokenizeScalar(u8, rest, ' '); | 527 | var it = std.mem.tokenizeScalar(u8, rest, ' '); |
| 479 | const quiet = std.fmt.parseInt(i64, it.next() orelse fatal(EXIT_USAGE, "settle QUIET MS", .{}), 10) catch fatal(EXIT_USAGE, "settle QUIET MS", .{}); | 528 | const quiet = std.fmt.parseInt(i64, it.next() orelse fatal(EXIT_USAGE, "settle QUIET MS", .{}), 10) catch fatal(EXIT_USAGE, "settle QUIET MS", .{}); |