a73x

1a21cb38

fix: a refusal the birth cannot fix backs off instead of spinning

a73x   2026-08-26 14:26

Commit message
fix: a refusal the birth cannot fix backs off instead of spinning

A daemon that refuses an attach closes the connection, so the hub's
re-dial opened on its first try, the page re-attached on `up`, and the
same no came back. Nothing in that cycle slept: a connect/attach/close
loop bounded only by round-trip latency, per tile, forever — measured at
147 dials in six seconds against a real daemon.

The dial backoff `dialLoop` already implements is now carried across
re-dials and charged when the dial that just ended never saw a grid; a
grid clears it, so a torn transport still heals at once. `birth_tried`
becomes a timestamp for the same reason: the grid that used to clear it
is the very thing a refused birth prevents, so a table that emptied
later healed no tile until the browser reconnected.

Pinned by an e2e leg whose dial count is a count of PROCESSES the OS
made — each `.hand` dial forks its own ssh, and the shim logs its pid
before exec. A hub counter would be the code under test grading itself.

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

src/webhub.zig
Old New
@@ -707,13 +707,20 @@ fn redial(
707 live: *Liveness, 707 live: *Liveness,
708 restore: *Restore, 708 restore: *Restore,
709 ) bool { 709 ) bool {
710 // A dial that never saw a grid was REFUSED, and a refusal is a state,
711 // not an event: the daemon closes on it, so the next dial opens on its
712 // first try and the page — which re-attaches on `up` — walks back into
713 // the same no. Charging the backoff here is what turns that loop into
714 // a poll. A dial that DID see a grid was torn, and a tear still heals
715 // at once.
716 if (!restore.saw_grid) restore.spin_ms = client.nextBackoffMs(restore.spin_ms);
710 // The per-dial reset belongs here, not at the three call sites: one of 717 // The per-dial reset belongs here, not at the three call sites: one of
711 // them forgot, and a forgotten reset stays silent until a torn 718 // them forgot, and a forgotten reset stays silent until a torn
712 // transport turns the next refusal into an ending. 719 // transport turns the next refusal into an ending.
713 restore.onRedial(); 720 restore.onRedial();
714 ws.writeMessage(controlMessage(.reconnecting), .binary) catch return false; 721 ws.writeMessage(controlMessage(.reconnecting), .binary) catch return false;
715 transport.close(); 722 transport.close();
716 transport.* = dialLoop(alloc, target, ws, ws_fd, live) orelse return false; 723 transport.* = dialLoop(alloc, target, ws, ws_fd, live, restore.spin_ms) orelse return false;
717 ws.writeMessage(controlMessage(.up), .binary) catch return false; 724 ws.writeMessage(controlMessage(.up), .binary) catch return false;
718 return true; 725 return true;
719 } 726 }
@@ -730,10 +737,14 @@ const Restore = struct {
730 // only way to reach it. 737 // only way to reach it.
731 saw_grid: bool = false, 738 saw_grid: bool = false,
732 // Bounds a daemon that refuses the birth too (a full table, a name it 739 // Bounds a daemon that refuses the birth too (a full table, a name it
733 // will not make) to ONE attempt per healthy period: cleared by a grid 740 // will not make) to one attempt per `birth_retry_ms`, so a refuse/
734 // arriving, not by a re-dial, so a second daemon restart still heals 741 // redial spin cannot fork a process per turn. A plain latch was
735 // while a refuse/redial spin cannot fork a process per turn. 742 // cleared only by a grid — the very thing a refused birth prevents —
736 birth_tried: bool = false, 743 // so a table that emptied an hour later healed no tile at all.
744 birth_at_ms: ?i64 = null,
745 // How fast the refusal loop may re-dial. Carried across dials and
746 // cleared by a grid, which is the only evidence the refusal is over.
747 spin_ms: u64 = 0,
737 // wallview's "exited stays exited" (its pump ENDS on an exit_status 748 // wallview's "exited stays exited" (its pump ENDS on an exit_status
738 // rather than redialing). Without it a user typing `exit` gets a new 749 // rather than redialing). Without it a user typing `exit` gets a new
739 // shell: the daemon reaps the session and closes, the hub redials, 750 // shell: the daemon reaps the session and closes, the hub redials,
@@ -753,7 +764,8 @@ const Restore = struct {
753 fn onFrame(self: *Restore, t: proto.MsgType) void { 764 fn onFrame(self: *Restore, t: proto.MsgType) void {
754 if (t == .snapshot or t == .delta) { 765 if (t == .snapshot or t == .delta) {
755 self.saw_grid = true; 766 self.saw_grid = true;
756 self.birth_tried = false; 767 self.birth_at_ms = null;
768 self.spin_ms = 0;
757 } 769 }
758 if (t == .exit_status and self.saw_grid) self.ended = true; 770 if (t == .exit_status and self.saw_grid) self.ended = true;
759 } 771 }
@@ -761,11 +773,20 @@ const Restore = struct {
761 // Read AFTER onFrame: an exit_status this pump has not answered and 773 // Read AFTER onFrame: an exit_status this pump has not answered and
762 // cannot read as an ending. Whether the target may be recreated at all 774 // cannot read as an ending. Whether the target may be recreated at all
763 // is the caller's half (`client.hydratedCreates`). 775 // is the caller's half (`client.hydratedCreates`).
764 fn wantsBirth(self: Restore, t: proto.MsgType) bool { 776 fn wantsBirth(self: Restore, t: proto.MsgType, now_ms: i64) bool {
765 return t == .exit_status and !self.saw_grid and !self.ended and !self.birth_tried; 777 if (t != .exit_status or self.saw_grid or self.ended) return false;
778 const tried = self.birth_at_ms orelse return true;
779 return now_ms - tried >= birth_retry_ms;
766 } 780 }
767 }; 781 };
768 782
783 /// The floor between two births into the same refusal. Long against the
784 /// dial backoff's 2s ceiling, so a daemon that refuses both is polled by
785 /// the cheap half; short against `ping_idle_ms`, so a table that empties
786 /// heals the tile long before the browser would be reaped. Wrong, and
787 /// either a spin forks a shell per turn or a restored wall stays dead.
788 const birth_retry_ms: i64 = 5_000;
789
769 /// One thread per tile: Transport.readFrame's blocking read is 790 /// One thread per tile: Transport.readFrame's blocking read is
770 /// correct here. The hub reconnects; the browser re-attaches. 791 /// correct here. The hub reconnects; the browser re-attaches.
771 pub fn pumpTile( 792 pub fn pumpTile(
@@ -785,7 +806,7 @@ pub fn pumpTile(
785 806
786 var live = Liveness.init(); 807 var live = Liveness.init();
787 ws.writeMessage(controlMessage(.connecting), .binary) catch return; 808 ws.writeMessage(controlMessage(.connecting), .binary) catch return;
788 var transport = dialLoop(alloc, target, ws, ws_fd, &live) orelse return; 809 var transport = dialLoop(alloc, target, ws, ws_fd, &live, 0) orelse return;
789 defer transport.close(); 810 defer transport.close();
790 // `up` is not decoration: the browser re-attaches when it reads this, 811 // `up` is not decoration: the browser re-attaches when it reads this,
791 // and the hub never re-attaches on its behalf. mux.js's ENV_CONTROL 812 // and the hub never re-attaches on its behalf. mux.js's ENV_CONTROL
@@ -834,8 +855,10 @@ pub fn pumpTile(
834 // re-dials. The browser re-attaches on `up` and never 855 // re-dials. The browser re-attaches on `up` and never
835 // learns a refusal happened, which is why nothing in 856 // learns a refusal happened, which is why nothing in
836 // mux.js decides any of this. 857 // mux.js decides any of this.
837 if (restore.wantsBirth(frame.type) and client.hydratedCreates(target)) { 858 if (restore.wantsBirth(frame.type, std.time.milliTimestamp()) and
838 restore.birth_tried = true; 859 client.hydratedCreates(target))
860 {
861 restore.birth_at_ms = std.time.milliTimestamp();
839 if (client.birthSession( 862 if (client.birthSession(
840 alloc, 863 alloc,
841 target, 864 target,
@@ -892,35 +915,43 @@ fn dialLoop(
892 ws: *std.http.Server.WebSocket, 915 ws: *std.http.Server.WebSocket,
893 ws_fd: std.posix.fd_t, 916 ws_fd: std.posix.fd_t,
894 live: *Liveness, 917 live: *Liveness,
918 /// Where the wait resumes. Zero is the ordinary dial — a link that
919 /// just died usually reconnects now — and the wait below is skipped
920 /// on the first pass exactly as it always was. Nonzero is `redial`
921 /// saying the last dial ended in a REFUSAL: the target is reachable,
922 /// so opening it again at once buys nothing but the same no.
923 backoff_start_ms: u64,
895 ) ?client.Transport { 924 ) ?client.Transport {
896 var backoff_ms: u64 = 0; 925 var backoff_ms: u64 = backoff_start_ms;
897 while (true) { 926 while (true) {
927 if (backoff_ms != 0) {
928 // The backoff doubles as the WS liveness window. It caps at 2s
929 // against a 30s ping interval, so the tick below is never more
930 // than one backoff late.
931 var fds = [_]std.posix.pollfd{
932 .{ .fd = ws_fd, .events = std.posix.POLL.IN, .revents = 0 },
933 };
934 // revents is zero-initialised above and poll clears it on a
935 // timeout, so the count it returns says nothing the flags do not.
936 _ = std.posix.poll(&fds, @intCast(backoff_ms)) catch return null;
937 // The pump's drain exactly, minus a transport to forward to: a
938 // partial frame would block this loop too, and this one has no
939 // second leg to notice. A close frame still ends the tile, which
940 // is the whole point of watching the socket during a backoff.
941 switch (drainBrowser(ws, live, fds[0].revents != 0, null)) {
942 .ok => {},
943 .browser_dead => return null,
944 .transport_dead => unreachable, // no transport was handed in
945 }
946 // Same check, same clock as the pump's: a browser that died
947 // during the outage is reaped here rather than after it, and one
948 // that is merely waiting answers the ping and lives.
949 if (!live.tick(ws)) return null;
950 }
898 if (client.Transport.open(alloc, target, null, -1)) |t| { 951 if (client.Transport.open(alloc, target, null, -1)) |t| {
899 return t; 952 return t;
900 } else |_| {} 953 } else |_| {}
901 backoff_ms = client.nextBackoffMs(backoff_ms); 954 backoff_ms = client.nextBackoffMs(backoff_ms);
902 // The backoff doubles as the WS liveness window. It caps at 2s
903 // against a 30s ping interval, so the tick below is never more
904 // than one backoff late.
905 var fds = [_]std.posix.pollfd{
906 .{ .fd = ws_fd, .events = std.posix.POLL.IN, .revents = 0 },
907 };
908 // revents is zero-initialised above and poll clears it on a
909 // timeout, so the count it returns says nothing the flags do not.
910 _ = std.posix.poll(&fds, @intCast(backoff_ms)) catch return null;
911 // The pump's drain exactly, minus a transport to forward to: a
912 // partial frame would block this loop too, and this one has no
913 // second leg to notice. A close frame still ends the tile, which
914 // is the whole point of watching the socket during a backoff.
915 switch (drainBrowser(ws, live, fds[0].revents != 0, null)) {
916 .ok => {},
917 .browser_dead => return null,
918 .transport_dead => unreachable, // no transport was handed in
919 }
920 // Same check, same clock as the pump's: a browser that died during
921 // the outage is reaped here rather than after it, and one that is
922 // merely waiting answers the ping and lives.
923 if (!live.tick(ws)) return null;
924 } 955 }
925 } 956 }
926 957
@@ -1189,16 +1220,28 @@ pub fn serveConn(
1189 test "restore: a refusal before any grid asks for a birth, once" { 1220 test "restore: a refusal before any grid asks for a birth, once" {
1190 var r: Restore = .{}; 1221 var r: Restore = .{};
1191 r.onFrame(.exit_status); 1222 r.onFrame(.exit_status);
1192 try std.testing.expect(r.wantsBirth(.exit_status)); 1223 try std.testing.expect(r.wantsBirth(.exit_status, 1_000));
1193 try std.testing.expect(!r.ended); 1224 try std.testing.expect(!r.ended);
1194 1225
1195 // The pump's own bookkeeping when it acts, then the re-dial. 1226 // The pump's own bookkeeping when it acts, then the re-dial.
1196 r.birth_tried = true; 1227 r.birth_at_ms = 1_000;
1197 r.onRedial(); 1228 r.onRedial();
1198 r.onFrame(.exit_status); 1229 r.onFrame(.exit_status);
1199 // A daemon that refuses the birth too must not be asked again on 1230 // A daemon that refuses the birth too must not be asked again on
1200 // every turn of the refuse/redial spin. 1231 // every turn of the refuse/redial spin.
1201 try std.testing.expect(!r.wantsBirth(.exit_status)); 1232 try std.testing.expect(!r.wantsBirth(.exit_status, 1_000 + birth_retry_ms - 1));
1233 }
1234
1235 test "restore: a birth the daemon refused is retried once the retry floor passes" {
1236 // The latch that only a grid could clear was a deadlock in one
1237 // direction: the grid it waited for is the thing the refused birth
1238 // prevents, so a table that emptied later healed nothing until the
1239 // browser reconnected.
1240 var r: Restore = .{};
1241 r.birth_at_ms = 1_000;
1242 r.onRedial();
1243 r.onFrame(.exit_status);
1244 try std.testing.expect(r.wantsBirth(.exit_status, 1_000 + birth_retry_ms));
1202 } 1245 }
1203 1246
1204 test "restore: a torn transport is not an ending, and the heal survives it" { 1247 test "restore: a torn transport is not an ending, and the heal survives it" {
@@ -1210,7 +1253,7 @@ test "restore: a torn transport is not an ending, and the heal survives it" {
1210 r.onRedial(); 1253 r.onRedial();
1211 r.onFrame(.exit_status); 1254 r.onFrame(.exit_status);
1212 try std.testing.expect(!r.ended); 1255 try std.testing.expect(!r.ended);
1213 try std.testing.expect(r.wantsBirth(.exit_status)); 1256 try std.testing.expect(r.wantsBirth(.exit_status, 0));
1214 } 1257 }
1215 1258
1216 test "restore: a session watched dying is never reborn" { 1259 test "restore: a session watched dying is never reborn" {
@@ -1225,17 +1268,42 @@ test "restore: a session watched dying is never reborn" {
1225 r.onRedial(); 1268 r.onRedial();
1226 r.onFrame(.exit_status); 1269 r.onFrame(.exit_status);
1227 try std.testing.expect(r.ended); 1270 try std.testing.expect(r.ended);
1228 try std.testing.expect(!r.wantsBirth(.exit_status)); 1271 // Even past the retry floor: a session watched dying is not a refusal
1272 // waiting on a daemon to recover.
1273 try std.testing.expect(!r.wantsBirth(.exit_status, birth_retry_ms * 10));
1229 } 1274 }
1230 1275
1231 test "restore: a grid re-arms the birth, so a second daemon restart heals too" { 1276 test "restore: a grid re-arms the birth, so a second daemon restart heals too" {
1232 var r: Restore = .{}; 1277 var r: Restore = .{};
1233 r.birth_tried = true; 1278 r.birth_at_ms = 1_000;
1234 r.onFrame(.snapshot); 1279 r.onFrame(.snapshot);
1235 try std.testing.expect(!r.birth_tried); 1280 try std.testing.expect(r.birth_at_ms == null);
1236 r.onRedial(); 1281 r.onRedial();
1237 r.onFrame(.exit_status); 1282 r.onFrame(.exit_status);
1238 try std.testing.expect(r.wantsBirth(.exit_status)); 1283 // At the same instant the last birth was tried: the grid, not the
1284 // clock, is what re-armed it.
1285 try std.testing.expect(r.wantsBirth(.exit_status, 1_000));
1286 }
1287
1288 test "restore: a refused dial charges the backoff and a grid clears it" {
1289 // The spin this bounds: every refusal closes the connection, so the
1290 // re-dial opens on its first try and the page attaches into the same
1291 // no. Carried across dials, or the tile connect/attach/close-loops at
1292 // round-trip speed for as long as the daemon keeps refusing.
1293 var r: Restore = .{};
1294 var charged: u64 = 0;
1295 for (0..6) |_| {
1296 if (!r.saw_grid) r.spin_ms = client.nextBackoffMs(r.spin_ms);
1297 charged = r.spin_ms;
1298 r.onRedial();
1299 r.onFrame(.exit_status);
1300 }
1301 try std.testing.expect(charged >= 2_000);
1302
1303 // A grid is the only evidence the refusal is over, and a tear after
1304 // one heals at full speed again.
1305 r.onFrame(.snapshot);
1306 try std.testing.expectEqual(@as(u64, 0), r.spin_ms);
1239 } 1307 }
1240 1308
1241 test "restore: only an exit_status asks for a birth" { 1309 test "restore: only an exit_status asks for a birth" {
@@ -1247,7 +1315,7 @@ test "restore: only an exit_status asks for a birth" {
1247 for ([_]proto.MsgType{ .snapshot, .delta, .pty_mode, .term_event, .term_modes }) |t| { 1315 for ([_]proto.MsgType{ .snapshot, .delta, .pty_mode, .term_event, .term_modes }) |t| {
1248 var r: Restore = .{}; 1316 var r: Restore = .{};
1249 r.onFrame(t); 1317 r.onFrame(t);
1250 try std.testing.expect(!r.wantsBirth(t)); 1318 try std.testing.expect(!r.wantsBirth(t, 0));
1251 } 1319 }
1252 } 1320 }
1253 1321
test/e2e.sh
Old New
@@ -418,8 +418,17 @@ WGQPORT=$(( 65250 + ($$ % 250) ))
418 WGKEY="${TMPDIR:-/tmp}/mux-e2e-wgkey-$$" 418 WGKEY="${TMPDIR:-/tmp}/mux-e2e-wgkey-$$"
419 WGSTATE="${TMPDIR:-/tmp}/mux-e2e-wg-state-$$" 419 WGSTATE="${TMPDIR:-/tmp}/mux-e2e-wg-state-$$"
420 WGWALL="$WGSTATE/mux/wall" 420 WGWALL="$WGSTATE/mux/wall"
421 # The refusal-spin leg. Its own everything: a shim dir on PATH, a state
422 # home holding the wall it restores, and a dial log the shim appends to.
423 SOCK68="${TMPDIR:-/tmp}/muxd-e2e-sp-$$.sock"
424 SPPORT=$(( 26000 + ($$ % 4000) ))
425 SPSTATE="${TMPDIR:-/tmp}/mux-e2e-sp-state-$$"
426 SPDIR="${TMPDIR:-/tmp}/mux-e2e-sp-shim-$$"
427 SPINLOG="$SPDIR/dials"
421 D67PID="" 428 D67PID=""
422 W5PID="" 429 W5PID=""
430 D68PID=""
431 W6PID=""
423 D54PID="" 432 D54PID=""
424 D55PID="" 433 D55PID=""
425 D56PID="" 434 D56PID=""
@@ -1343,6 +1352,7 @@ cleanup() {
1343 # one the run died under. 1352 # one the run died under.
1344 [ -n "$W4PID" ] && kill "$W4PID" 2>/dev/null || true 1353 [ -n "$W4PID" ] && kill "$W4PID" 2>/dev/null || true
1345 [ -n "${W5PID:-}" ] && kill "$W5PID" 2>/dev/null || true 1354 [ -n "${W5PID:-}" ] && kill "$W5PID" 2>/dev/null || true
1355 [ -n "${W6PID:-}" ] && kill "$W6PID" 2>/dev/null || true
1346 [ -n "$D22PID" ] && kill "$D22PID" 2>/dev/null || true 1356 [ -n "$D22PID" ] && kill "$D22PID" 2>/dev/null || true
1347 [ -n "$D23PID" ] && kill "$D23PID" 2>/dev/null || true 1357 [ -n "$D23PID" ] && kill "$D23PID" 2>/dev/null || true
1348 [ -n "$D24PID" ] && kill "$D24PID" 2>/dev/null || true 1358 [ -n "$D24PID" ] && kill "$D24PID" 2>/dev/null || true
@@ -1433,6 +1443,7 @@ cleanup() {
1433 [ -S "$SOCK64" ] && "$MUXD" stop --sock "$SOCK64" 2>/dev/null || true 1443 [ -S "$SOCK64" ] && "$MUXD" stop --sock "$SOCK64" 2>/dev/null || true
1434 [ -S "$SOCK66" ] && "$MUXD" stop --sock "$SOCK66" 2>/dev/null || true 1444 [ -S "$SOCK66" ] && "$MUXD" stop --sock "$SOCK66" 2>/dev/null || true
1435 [ -S "$SOCK67" ] && "$MUXD" stop --sock "$SOCK67" 2>/dev/null || true 1445 [ -S "$SOCK67" ] && "$MUXD" stop --sock "$SOCK67" 2>/dev/null || true
1446 [ -S "$SOCK68" ] && "$MUXD" stop --sock "$SOCK68" 2>/dev/null || true
1436 1447
1437 # ---- the leak sweep (hygiene kit, 6a) ---- 1448 # ---- the leak sweep (hygiene kit, 6a) ----
1438 # Here rather than at the bottom of the file, which `set -e` reaches only 1449 # Here rather than at the bottom of the file, which `set -e` reaches only
@@ -1450,7 +1461,7 @@ cleanup() {
1450 "$D38PID" "$D39PID" "$D40PID" "$D41PID" "$D42PID" "$D43PID" "$D54PID" \ 1461 "$D38PID" "$D39PID" "$D40PID" "$D41PID" "$D42PID" "$D43PID" "$D54PID" \
1451 "$D55PID" "$D56PID" "$D57PID" "$D58PID" "$D59PID" \ 1462 "$D55PID" "$D56PID" "$D57PID" "$D58PID" "$D59PID" \
1452 "$D60PID" "$D61PID" "$D62PID" "$D63PID" "$D64PID" "$D65PID" \ 1463 "$D60PID" "$D61PID" "$D62PID" "$D63PID" "$D64PID" "$D65PID" \
1453 "$D66PID" "$D67PID" 1464 "$D66PID" "$D67PID" "$D68PID"
1454 _leak=0 1465 _leak=0
1455 leak_sweep "$_rc" || _leak=1 1466 leak_sweep "$_rc" || _leak=1
1456 1467
@@ -1556,9 +1567,12 @@ cleanup() {
1556 "$OUT.wg.d" "$OUT.wgh" "$OUT.wgws" "$OUT.wgws.err" \ 1567 "$OUT.wg.d" "$OUT.wgh" "$OUT.wgws" "$OUT.wgws.err" \
1557 "$OUT.wgws2" "$OUT.wgws2.err" "$OUT.wgpre" "$OUT.wgsta" \ 1568 "$OUT.wgws2" "$OUT.wgws2.err" "$OUT.wgpre" "$OUT.wgsta" \
1558 "$OUT.wgghost" "$OUT.wgstop" "$OUT.wgsta0" "$OUT.wgexit" \ 1569 "$OUT.wgghost" "$OUT.wgstop" "$OUT.wgsta0" "$OUT.wgexit" \
1559 "$OUT.wgws3" "$OUT.wgws3.err" "$SOCK67" "$WGKEY" 1570 "$OUT.wgws3" "$OUT.wgws3.err" "$SOCK67" "$WGKEY" \
1560 # ...and the two state homes those legs read their walls back out of. 1571 "$OUT.sp.d" "$OUT.sph" "$OUT.spws" "$OUT.spws.err" \
1561 rm -rf "$HYSTATE" "$WGSTATE" 1572 "$OUT.spghost" "$OUT.spstop" "$SOCK68"
1573 # ...and the state homes those legs read their walls back out of, plus
1574 # the spin leg's shim dir (its dial log lives inside it).
1575 rm -rf "$HYSTATE" "$WGSTATE" "$SPSTATE" "$SPDIR"
1562 # ...and the non-tty capture that leg's session feeds. 1576 # ...and the non-tty capture that leg's session feeds.
1563 rm -f "$OUT.nogate" 1577 rm -f "$OUT.nogate"
1564 # ...and its other half: the paste capture and the file nvim wrote, which 1578 # ...and its other half: the paste capture and the file nvim wrote, which
@@ -9015,8 +9029,100 @@ D67PID=""
9015 rm -rf "$WGSTATE" 9029 rm -rf "$WGSTATE"
9016 ok "the browser wall restores a saved local line too, and still joins a remote one only" 9030 ok "the browser wall restores a saved local line too, and still joins a remote one only"
9017 9031
9018 [ "$OK_COUNT" = "76" ] || { 9032 # --- a refusal the birth cannot fix must not spin ----------------------
9019 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 76 —" 9033 #
9034 # The other end of the restore rule: a line the daemon will not attach and
9035 # the hub may not create. `client.hydratedCreates` joins a remote spelling
9036 # and never births it, so nothing the hub can do makes this attach land.
9037 # Every refusal closes the connection (`server.dropObserver`), so the
9038 # re-dial that follows succeeds on its first try, the page re-attaches on
9039 # `up` as it always does, and the same no comes back — a connect/attach/
9040 # close loop bounded by nothing but round-trip latency, on the hub, the
9041 # daemon and the browser at once.
9042 #
9043 # Counted the one way the hub cannot flatter itself: a `.hand` dial forks
9044 # its own ssh, and the shim logs its pid before exec, so `wc -l` is a
9045 # count of PROCESSES the OS made. A hub counter would be the code under
9046 # test grading its own homework.
9047 "$MUXD" run --sock "$SOCK68" --shell /bin/sh > "$OUT.sp.d" 2>&1 &
9048 D68PID=$!
9049 wait_sock "$SOCK68" "$OUT.sp.d" "refusal-spin daemon never bound"
9050
9051 # This shim ignores the remote command on purpose — it is not modelling
9052 # ssh (the M14 shim above does that), it is the dial's process signature
9053 # plus a byte pipe to a REAL daemon. `endpoint none` is what keeps every
9054 # dial on this path: an announced QUIC endpoint would be cached, the
9055 # second dial would skip ssh entirely, and the count with it.
9056 mkdir -p "$SPDIR"
9057 : > "$SPINLOG"
9058 cat > "$SPDIR/ssh" <<SPSHIM
9059 #!/bin/sh
9060 echo \$\$ >> "$SPINLOG"
9061 printf 'endpoint none\n'
9062 exec "$MUXD" proxy --sock "$SOCK68"
9063 SPSHIM
9064 chmod +x "$SPDIR/ssh"
9065
9066 # One line, a HOST spelling, naming a session the daemon does not have:
9067 # the tile attaches at 0x0 (passivity), 0x0 cannot create, and a remote
9068 # line may not be birthed.
9069 mkdir -p "$SPSTATE/mux"
9070 printf 'mux-spin@127.0.0.1#spinghost\n' > "$SPSTATE/mux/wall"
9071
9072 XDG_STATE_HOME="$SPSTATE" XDG_CACHE_HOME="$SPSTATE/cache" PATH="$SPDIR:$PATH" \
9073 "$MUXWEB" --port "$SPPORT" > "$OUT.sph" 2>&1 &
9074 W6PID=$!
9075 wait_for "$OUT.sph" "serving" 10 || {
9076 echo "e2e FAIL: refusal-spin: hub never reported serving"; cat "$OUT.sph"; exit 1; }
9077
9078 # `reattach` is mux.js's ENV_CONTROL handler, which is the half of the
9079 # loop the hub does not own: the page attaches again on every `up`. Six
9080 # seconds of it, and the dial log is read the moment the window closes.
9081 set +e
9082 timeout 90 "$WSCLIENT" --port "$SPPORT" --tile 0 --out "$OUT.spws" --err "$OUT.spws.err" <<'EOF'
9083 expectstate up 25000
9084 reattach 0 0 6000 spinghost
9085 expectrefused 25000
9086 dumpexit
9087 EOF
9088 RC=$?
9089 set -e
9090 SPDIALS=$(wc -l < "$SPINLOG")
9091 [ "$RC" -eq 0 ] || {
9092 echo "e2e FAIL: refusal-spin: the wsclient exited $RC after $SPDIALS dials"
9093 cat -v "$OUT.spws.err" 2>/dev/null; cat "$OUT.sph"; exit 1; }
9094
9095 # The bound, and its vacuity guard. Backoff caps at client.nextBackoffMs's
9096 # 2s, so six seconds of refusal is a handful of dials; unbounded it is one
9097 # per round trip, hundreds. The lower bound is what stops this passing on
9098 # a tile that never re-dialled at all — then the ceiling would be measuring
9099 # nothing.
9100 [ "$SPDIALS" -ge 3 ] || {
9101 echo "e2e FAIL: refusal-spin: only $SPDIALS dials in 6s — the tile never"
9102 echo " re-dialled, so the ceiling below measured nothing"
9103 cat "$OUT.sph"; exit 1; }
9104 [ "$SPDIALS" -le 15 ] || {
9105 echo "e2e FAIL: refusal-spin: $SPDIALS dials in 6s (want <= 15) — a refusal"
9106 echo " the birth cannot fix is spinning with no backoff"
9107 cat "$OUT.sph"; exit 1; }
9108
9109 # ...and it stayed refused: nothing was born behind the hub's back, which
9110 # is what makes the count above a count of REFUSED dials.
9111 timeout 20 "$MUXA" status --sock "$SOCK68" --session spinghost > "$OUT.spghost" 2>&1 && {
9112 echo "e2e FAIL: refusal-spin: a remote wall line created a session:"
9113 cat "$OUT.spghost"; exit 1; }
9114
9115 kill "$W6PID" 2>/dev/null || true
9116 wait_pid_gone "$W6PID" "refusal-spin: hub killed by tracked pid"
9117 W6PID=""
9118 assert_stopped "$SOCK68" "$D68PID" "refusal-spin" "$OUT.spstop"
9119 D68PID=""
9120 rm -rf "$SPSTATE" "$SPDIR"
9121 ok "a refusal the birth cannot fix backs off instead of spinning"
9122
9123
9124 [ "$OK_COUNT" = "77" ] || {
9125 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 77 —"
9020 echo " a scenario was added (update the pin) or silently lost" 9126 echo " a scenario was added (update the pin) or silently lost"
9021 exit 1 9127 exit 1
9022 } 9128 }