a73x

e26e5494

test: the browser wall brings a saved local line back

a73x   2026-08-26 06:51

Commit message
test: the browser wall brings a saved local line back

One e2e scenario, both halves of the ruling through the hub: a saved
--sock line whose session is gone comes back as a real shell, a quic://
line still says refused. The wsclient fixture learns to count `up`
messages (a re-dial narration can arrive whole inside one read) and to
tell a refusal from a shell exiting, which is mux.js's own
discriminator.

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", .{});