1a21cb38
fix: a refusal the birth cannot fix backs off instead of spinning
a73x 2026-08-26 14:26
Commit message
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 | } |