7b31b7c4
test: the e2e wall leg is two daemons, live sessions and a browser that authors nothing
a73x 2026-08-29 12:14
Commit message
src/client/webhub.zig
| Old | New | ||
|---|---|---|---|
| @@ -65,9 +65,9 @@ pub const HubHost = struct { | |||
| 65 | hub: ?*Hub = null, | 65 | hub: ?*Hub = null, |
| 66 | idx: usize = 0, | 66 | idx: usize = 0, |
| 67 | 67 | ||
| 68 | fn keep(p: *anyopaque) bool { | 68 | /// Never stops; `Hub.deinit` says why. |
| 69 | const self: *HubHost = @ptrCast(@alignCast(p)); | 69 | fn keep(_: *anyopaque) bool { |
| 70 | return self.hub.?.serving.load(.acquire); | 70 | return true; |
| 71 | } | 71 | } |
| 72 | 72 | ||
| 73 | fn wake(p: *anyopaque) void { | 73 | fn wake(p: *anyopaque) void { |
| @@ -117,7 +117,9 @@ pub const Hub = struct { | |||
| 117 | hosts: []HubHost, | 117 | hosts: []HubHost, |
| 118 | tiles: std.ArrayList(HubTile) = .empty, | 118 | tiles: std.ArrayList(HubTile) = .empty, |
| 119 | next_id: u32 = 0, | 119 | next_id: u32 = 0, |
| 120 | serving: std.atomic.Value(bool) = std.atomic.Value(bool).init(true), | 120 | /// Whether `start` has handed `hosts` to threads that never stop. Read |
| 121 | /// by `deinit` alone, and both run on the thread that built the Hub. | ||
| 122 | polling: bool = false, | ||
| 121 | 123 | ||
| 122 | /// `specs` is borrowed: hub_main resolves the hosts file into an arena | 124 | /// `specs` is borrowed: hub_main resolves the hosts file into an arena |
| 123 | /// that outlives the process's accept loop. | 125 | /// that outlives the process's accept loop. |
| @@ -128,6 +130,16 @@ pub const Hub = struct { | |||
| 128 | } | 130 | } |
| 129 | 131 | ||
| 130 | pub fn deinit(self: *Hub) void { | 132 | pub fn deinit(self: *Hub) void { |
| 133 | // A started hub cannot be torn down. Its pollers are detached, hold | ||
| 134 | // `*HubHost` into the array freed below, and have no stop to be | ||
| 135 | // asked for — a hub polls until the process leaves the accept loop, | ||
| 136 | // which is why `HubHost.keep` is a constant where the CLI wall's | ||
| 137 | // answers `shared.running` and the picker's `forgotten`. `mux web` | ||
| 138 | // never reaches here; the tests that do never `start`. An assert | ||
| 139 | // rather than a comment because the day someone adds a shutdown | ||
| 140 | // path, the free below is a use-after-free in as many threads as | ||
| 141 | // there are hosts, and nothing else would say so. | ||
| 142 | std.debug.assert(!self.polling); | ||
| 131 | for (self.tiles.items) |t| self.alloc.free(t.session); | 143 | for (self.tiles.items) |t| self.alloc.free(t.session); |
| 132 | self.tiles.deinit(self.alloc); | 144 | self.tiles.deinit(self.alloc); |
| 133 | self.alloc.free(self.hosts); | 145 | self.alloc.free(self.hosts); |
| @@ -137,6 +149,7 @@ pub const Hub = struct { | |||
| 137 | /// shows what an unreachable one shows — nothing — rather than taking | 149 | /// shows what an unreachable one shows — nothing — rather than taking |
| 138 | /// the hub down with it. | 150 | /// the hub down with it. |
| 139 | pub fn start(self: *Hub) void { | 151 | pub fn start(self: *Hub) void { |
| 152 | self.polling = true; | ||
| 140 | for (self.hosts, 0..) |*h, i| { | 153 | for (self.hosts, 0..) |*h, i| { |
| 141 | h.hub = self; | 154 | h.hub = self; |
| 142 | h.idx = i; | 155 | h.idx = i; |
| @@ -869,21 +882,32 @@ pub fn serveConn( | |||
| 869 | if (std.ascii.eqlIgnoreCase(h.name, "origin")) origin = h.value; | 882 | if (std.ascii.eqlIgnoreCase(h.name, "origin")) origin = h.value; |
| 870 | } | 883 | } |
| 871 | 884 | ||
| 885 | // std's `discardBody` ASSERTS that a kept-alive request whose method | ||
| 886 | // may carry a body declared a length — and `POST /anything` with | ||
| 887 | // neither, which is exactly what `curl -X POST` sends, reached that | ||
| 888 | // assert and aborted the whole hub, every tile with it. Answering | ||
| 889 | // such a request and closing is the one fix that covers every route | ||
| 890 | // below, including the asset router's 404. The page's own POST | ||
| 891 | // carries `content-length: 0` and keeps its connection. | ||
| 892 | const keep = !(method.requestHasBody() and | ||
| 893 | req.head.content_length == null and | ||
| 894 | req.head.transfer_encoding == .none); | ||
| 895 | |||
| 872 | if (wsTileId(path)) |id| { | 896 | if (wsTileId(path)) |id| { |
| 873 | // Origin BEFORE upgrade, always: the refusal must happen while | 897 | // Origin BEFORE upgrade, always: the refusal must happen while |
| 874 | // this is still HTTP, so a hostile page gets a 403 and never a | 898 | // this is still HTTP, so a hostile page gets a 403 and never a |
| 875 | // socket. std's upgradeRequested does not look at Origin. | 899 | // socket. std's upgradeRequested does not look at Origin. |
| 876 | if (!originAllowed(origin, port)) { | 900 | if (!originAllowed(origin, port)) { |
| 877 | req.respond("forbidden\n", .{ .status = .forbidden }) catch {}; | 901 | req.respond("forbidden\n", .{ .status = .forbidden, .keep_alive = keep }) catch {}; |
| 878 | return; | 902 | return; |
| 879 | } | 903 | } |
| 880 | const key = switch (req.upgradeRequested()) { | 904 | const key = switch (req.upgradeRequested()) { |
| 881 | .websocket => |k| k orelse { | 905 | .websocket => |k| k orelse { |
| 882 | req.respond("bad upgrade\n", .{ .status = .bad_request }) catch {}; | 906 | req.respond("bad upgrade\n", .{ .status = .bad_request, .keep_alive = keep }) catch {}; |
| 883 | return; | 907 | return; |
| 884 | }, | 908 | }, |
| 885 | else => { | 909 | else => { |
| 886 | req.respond("websocket only\n", .{ .status = .bad_request }) catch {}; | 910 | req.respond("websocket only\n", .{ .status = .bad_request, .keep_alive = keep }) catch {}; |
| 887 | return; | 911 | return; |
| 888 | }, | 912 | }, |
| 889 | }; | 913 | }; |
| @@ -903,14 +927,14 @@ pub fn serveConn( | |||
| 903 | // Removed between the page's GET and this dial: the browser | 927 | // Removed between the page's GET and this dial: the browser |
| 904 | // refetches /tiles and stops asking for it. | 928 | // refetches /tiles and stops asking for it. |
| 905 | error.UnknownId => { | 929 | error.UnknownId => { |
| 906 | req.respond("no such tile\n", .{ .status = .not_found }) catch {}; | 930 | req.respond("no such tile\n", .{ .status = .not_found, .keep_alive = keep }) catch {}; |
| 907 | return; | 931 | return; |
| 908 | }, | 932 | }, |
| 909 | }; | 933 | }; |
| 910 | // Deferred, not called after pumpTile: an upgrade that fails | 934 | // Deferred, not called after pumpTile: an upgrade that fails |
| 911 | // must unregister too. Registered AFTER `defer stream.close()`, | 935 | // must unregister too. Registered AFTER `defer stream.close()`, |
| 912 | // so it runs BEFORE it — the ordering that keeps a concurrent | 936 | // so it runs BEFORE it — the ordering that keeps a concurrent |
| 913 | // removeTile from shutting down an fd the kernel has already | 937 | // a vanishing from shutting down an fd the kernel has already |
| 914 | // handed to somebody else. | 938 | // handed to somebody else. |
| 915 | defer hub.releaseTile(id, ws_fd); | 939 | defer hub.releaseTile(id, ws_fd); |
| 916 | var ws = req.respondWebSocket(.{ .key = key }) catch return; | 940 | var ws = req.respondWebSocket(.{ .key = key }) catch return; |
| @@ -923,6 +947,7 @@ pub fn serveConn( | |||
| 923 | const json = hub.json(alloc) catch return; | 947 | const json = hub.json(alloc) catch return; |
| 924 | defer alloc.free(json); | 948 | defer alloc.free(json); |
| 925 | req.respond(json, .{ | 949 | req.respond(json, .{ |
| 950 | .keep_alive = keep, | ||
| 926 | .extra_headers = &.{ | 951 | .extra_headers = &.{ |
| 927 | .{ .name = "content-type", .value = "application/json" }, | 952 | .{ .name = "content-type", .value = "application/json" }, |
| 928 | .{ .name = "cache-control", .value = "no-cache" }, | 953 | .{ .name = "cache-control", .value = "no-cache" }, |
| @@ -948,47 +973,54 @@ pub fn serveConn( | |||
| 948 | else | 973 | else |
| 949 | null; | 974 | null; |
| 950 | if (tiles_root or tile_id_suffix != null) { | 975 | if (tiles_root or tile_id_suffix != null) { |
| 976 | // Every answer here ends the connection, and SAYS so: a | ||
| 977 | // mutation is one request, the page fires one and reads one. | ||
| 978 | // `keep` would be true for the page's own `content-length: 0` | ||
| 979 | // POST, so the header would promise a connection this route | ||
| 980 | // then closes anyway. | ||
| 951 | if (!originAllowed(origin, port)) { | 981 | if (!originAllowed(origin, port)) { |
| 952 | req.respond("forbidden\n", .{ .status = .forbidden }) catch {}; | 982 | req.respond("forbidden\n", .{ .status = .forbidden, .keep_alive = false }) catch {}; |
| 953 | return; | 983 | return; |
| 954 | } | 984 | } |
| 955 | // Authoring a tile is gone with the wall file: the page shows | 985 | // Authoring a tile is gone with the wall file: the page shows |
| 956 | // what the listed daemons have, so POST /tiles, PUT and DELETE | 986 | // what the listed daemons have, so POST /tiles, PUT and DELETE |
| 957 | // name nothing this hub can do. One answer for all of them. | 987 | // name nothing this hub can do. One answer for all of them. |
| 958 | const id_str = tile_id_suffix orelse { | 988 | const id_str = tile_id_suffix orelse { |
| 959 | req.respond("bad method\n", .{ .status = .method_not_allowed }) catch return; | 989 | req.respond("bad method\n", .{ .status = .method_not_allowed, .keep_alive = false }) catch {}; |
| 960 | continue; | 990 | return; |
| 961 | }; | 991 | }; |
| 962 | if (method != .POST) { | 992 | if (method != .POST) { |
| 963 | req.respond("bad method\n", .{ .status = .method_not_allowed }) catch return; | 993 | req.respond("bad method\n", .{ .status = .method_not_allowed, .keep_alive = false }) catch {}; |
| 964 | continue; | 994 | return; |
| 965 | } | 995 | } |
| 966 | const id = std.fmt.parseInt(u32, id_str, 10) catch { | 996 | const id = std.fmt.parseInt(u32, id_str, 10) catch { |
| 967 | req.respond("not found\n", .{ .status = .not_found }) catch return; | 997 | req.respond("not found\n", .{ .status = .not_found, .keep_alive = false }) catch {}; |
| 968 | continue; | 998 | return; |
| 969 | }; | 999 | }; |
| 970 | const name = hub.spawn(id) catch |err| { | 1000 | const name = hub.spawn(id) catch |err| { |
| 971 | const status: std.http.Status, const msg: []const u8 = switch (err) { | 1001 | const status: std.http.Status, const msg: []const u8 = switch (err) { |
| 972 | error.UnknownId => .{ .not_found, "not found\n" }, | 1002 | error.UnknownId => .{ .not_found, "not found" }, |
| 973 | // The daemon's no, or a box that did not answer: the | 1003 | // The daemon's no, or a box that did not answer: the hub |
| 974 | // hub is up and the birth is not, which is a gateway | 1004 | // is up and the birth is not, which is a gateway failure |
| 975 | // failure and not this server's own. | 1005 | // and not this server's own. |
| 976 | else => .{ .bad_gateway, @errorName(err) }, | 1006 | else => .{ .bad_gateway, @errorName(err) }, |
| 977 | }; | 1007 | }; |
| 978 | var buf: [64]u8 = undefined; | 1008 | var buf: [64]u8 = undefined; |
| 979 | req.respond(std.fmt.bufPrint(&buf, "{s}\n", .{msg}) catch msg, .{ .status = status }) catch return; | 1009 | const body = std.fmt.bufPrint(&buf, "{s}\n", .{msg}) catch msg; |
| 980 | continue; | 1010 | req.respond(body, .{ .status = status, .keep_alive = false }) catch {}; |
| 1011 | return; | ||
| 981 | }; | 1012 | }; |
| 982 | var buf: [64]u8 = undefined; | 1013 | var buf: [64]u8 = undefined; |
| 983 | const resp = std.fmt.bufPrint(&buf, "{{\"session\":\"{s}\"}}", .{name.slice()}) catch unreachable; | 1014 | const resp = std.fmt.bufPrint(&buf, "{{\"session\":\"{s}\"}}", .{name.slice()}) catch unreachable; |
| 984 | req.respond(resp, .{ .status = .created, .extra_headers = &.{ | 1015 | req.respond(resp, .{ .status = .created, .keep_alive = false, .extra_headers = &.{ |
| 985 | .{ .name = "content-type", .value = "application/json" }, | 1016 | .{ .name = "content-type", .value = "application/json" }, |
| 986 | } }) catch return; | 1017 | } }) catch {}; |
| 987 | continue; | 1018 | return; |
| 988 | } | 1019 | } |
| 989 | 1020 | ||
| 990 | if (route(assets, path)) |asset| { | 1021 | if (route(assets, path)) |asset| { |
| 991 | req.respond(asset.body, .{ | 1022 | req.respond(asset.body, .{ |
| 1023 | .keep_alive = keep, | ||
| 992 | .extra_headers = &.{ | 1024 | .extra_headers = &.{ |
| 993 | .{ .name = "content-type", .value = asset.content_type }, | 1025 | .{ .name = "content-type", .value = asset.content_type }, |
| 994 | // `no-cache` is revalidate-every-time, not don't-store: | 1026 | // `no-cache` is revalidate-every-time, not don't-store: |
| @@ -999,7 +1031,7 @@ pub fn serveConn( | |||
| 999 | }, | 1031 | }, |
| 1000 | }) catch return; | 1032 | }) catch return; |
| 1001 | } else { | 1033 | } else { |
| 1002 | req.respond("not found\n", .{ .status = .not_found }) catch return; | 1034 | req.respond("not found\n", .{ .status = .not_found, .keep_alive = keep }) catch return; |
| 1003 | } | 1035 | } |
| 1004 | } | 1036 | } |
| 1005 | } | 1037 | } |
test/e2e_06_web.sh
| Old | New | ||
|---|---|---|---|
| @@ -19,18 +19,22 @@ SOCK20="${TMPDIR:-/tmp}/muxd-e2e-web-c-$$.sock" | |||
| 19 | defer_sock "$SOCK20" | 19 | defer_sock "$SOCK20" |
| 20 | WPORT=$(( 41000 + ($$ % 4000) )) | 20 | WPORT=$(( 41000 + ($$ % 4000) )) |
| 21 | WPORT2=$(( 46000 + ($$ % 4000) )) | 21 | WPORT2=$(( 46000 + ($$ % 4000) )) |
| 22 | # The dynamic-wall leg: one daemon, one hub restarted three times, and a | 22 | # The hosts-wall leg: TWO daemons — a wall of one host is blind to host |
| 23 | # STATE HOME of its own on the 61000 band — the last 5000-spaced one that | 23 | # order and to "only its own list may drop a tile" — a hub restarted once, |
| 24 | # fits under 65535, the QUIC block's 56000 being the one before. The state | 24 | # and a STATE HOME of its own on the 61000 band, the last 5000-spaced one |
| 25 | # home is the load-bearing part: this leg reads the wall file back as an | 25 | # that fits under 65535 (the QUIC block's 56000 being the one before). The |
| 26 | # artifact, and $XDG_STATE_HOME above is shared with every other scenario — | 26 | # state home is the load-bearing part: this leg reads the HOSTS file back |
| 27 | # the M-web and M18 hubs write their argv walls there, so a wall read out of | 27 | # as an artifact, and $XDG_STATE_HOME above is shared with every other |
| 28 | # it would be some other block's. Never the developer's ~/.local/state either; that is | 28 | # scenario — every `mux` attach records its daemon there, so a file read |
| 29 | # why every hub here is spawned with the override in front of it. | 29 | # out of it would hold some other block's sockets. Never the developer's |
| 30 | SOCK25="${TMPDIR:-/tmp}/muxd-e2e-dynwall-$$.sock" | 30 | # ~/.local/state either; that is why every hub here is spawned with the |
| 31 | # override in front of it. | ||
| 32 | SOCK25="${TMPDIR:-/tmp}/muxd-e2e-hostwall-a-$$.sock" | ||
| 31 | defer_sock "$SOCK25" | 33 | defer_sock "$SOCK25" |
| 34 | SOCK76="${TMPDIR:-/tmp}/muxd-e2e-hostwall-b-$$.sock" | ||
| 35 | defer_sock "$SOCK76" | ||
| 32 | WPORT4=$(( 61000 + ($$ % 4000) )) | 36 | WPORT4=$(( 61000 + ($$ % 4000) )) |
| 33 | DWSTATE="${TMPDIR:-/tmp}/mux-e2e-dynwall-state-$$" | 37 | DWSTATE="${TMPDIR:-/tmp}/mux-e2e-hostwall-state-$$" |
| 34 | defer_rm "$DWSTATE" | 38 | defer_rm "$DWSTATE" |
| 35 | 39 | ||
| 36 | # --- a 1x1 attach is refused the grid and can never claim it. | 40 | # --- a 1x1 attach is refused the grid and can never claim it. |
| @@ -105,11 +109,18 @@ ok "a 1x1 attach is refused the grid and can never claim it" | |||
| 105 | # --- M-web (b): the hub pumps a real session; wrong Origin refused. | 109 | # --- M-web (b): the hub pumps a real session; wrong Origin refused. |
| 106 | start_daemon "$SOCK19" "$OUT.webb.d" "web hub daemon never bound" --shell /bin/sh | 110 | start_daemon "$SOCK19" "$OUT.webb.d" "web hub daemon never bound" --shell /bin/sh |
| 107 | D15PID=$DPID | 111 | D15PID=$DPID |
| 108 | "$MUX" web --sock "$SOCK19" --port "$WPORT" > "$OUT.webh" 2>&1 & | 112 | # A state home of its own: `mux web --sock S` RECORDS S in the hosts file |
| 113 | # it then serves, so a shared home would give this hub every other leg's | ||
| 114 | # daemons as tiles too. | ||
| 115 | hostroom webb | ||
| 116 | XDG_STATE_HOME="$HOSTROOM" "$MUX" web --sock "$SOCK19" --port "$WPORT" > "$OUT.webh" 2>&1 & | ||
| 109 | W1PID=$! | 117 | W1PID=$! |
| 110 | defer_kill "$W1PID" | 118 | defer_kill "$W1PID" |
| 111 | wait_for "$OUT.webh" "serving" 10 || { | 119 | # `serving` is the door; a TILE is a live session on a listed daemon, which |
| 112 | echo "e2e FAIL: hub never reported serving"; cat "$OUT.webh"; exit 1; } | 120 | # the hub does not know until its first poll comes back. The stand-in dials |
| 121 | # `/ws/0`, so wait for the id to exist. | ||
| 122 | wait_for "$OUT.webh" "tile 0:" 10 || { | ||
| 123 | echo "e2e FAIL: hub never announced a tile"; cat "$OUT.webh"; exit 1; } | ||
| 113 | 124 | ||
| 114 | # The typing client stays attached until after the browser leg below, so | 125 | # The typing client stays attached until after the browser leg below, so |
| 115 | # the hub pumps a session that has a live CLI client on it too. | 126 | # the hub pumps a session that has a live CLI client on it too. |
| @@ -172,11 +183,12 @@ ok "hub pumps a real session; wrong origin refused" | |||
| 172 | # that converges on the new session's content. | 183 | # that converges on the new session's content. |
| 173 | start_daemon "$SOCK20" "$OUT.webc.d" "web tear daemon never bound" --shell /bin/sh | 184 | start_daemon "$SOCK20" "$OUT.webc.d" "web tear daemon never bound" --shell /bin/sh |
| 174 | D16PID=$DPID | 185 | D16PID=$DPID |
| 175 | "$MUX" web --sock "$SOCK20" --port "$WPORT2" > "$OUT.webh2" 2>&1 & | 186 | hostroom webc |
| 187 | XDG_STATE_HOME="$HOSTROOM" "$MUX" web --sock "$SOCK20" --port "$WPORT2" > "$OUT.webh2" 2>&1 & | ||
| 176 | W2PID=$! | 188 | W2PID=$! |
| 177 | defer_kill "$W2PID" | 189 | defer_kill "$W2PID" |
| 178 | wait_for "$OUT.webh2" "serving" 10 || { | 190 | wait_for "$OUT.webh2" "tile 0:" 10 || { |
| 179 | echo "e2e FAIL: tear hub never reported serving"; cat "$OUT.webh2"; exit 1; } | 191 | echo "e2e FAIL: tear hub never announced a tile"; cat "$OUT.webh2"; exit 1; } |
| 180 | 192 | ||
| 181 | pipe_mux "$OUT.webc" "$OUT.webc.err" timeout 30 "$MUX" --sock "$SOCK20" | 193 | pipe_mux "$OUT.webc" "$OUT.webc.err" timeout 30 "$MUX" --sock "$SOCK20" |
| 182 | pipe_send 'printf "web-%%s\\n" c1\n' | 194 | pipe_send 'printf "web-%%s\\n" c1\n' |
| @@ -233,76 +245,100 @@ assert_stopped "$SOCK20" "$D17PID" "web tear: the restarted daemon" "$OUT.websto | |||
| 233 | D17PID="" | 245 | D17PID="" |
| 234 | ok "hub narrates the tear; the replica re-attaches across an epoch" | 246 | ok "hub narrates the tear; the replica re-attaches across an epoch" |
| 235 | 247 | ||
| 236 | # --- M-wall: the wall is RUNTIME state. The page adds, removes and | 248 | # --- M-wall: the hub's wall is the HOSTS FILE, and its tiles are those |
| 237 | # reorders tiles over HTTP, and the file is what makes that survive a | 249 | # daemons' live sessions. Nothing here is authored in the browser: the |
| 238 | # restart. Every mux web here runs with a state home of its own (see | 250 | # page shows what the daemons have, `+` births one, and the routes that |
| 239 | # $DWSTATE), so what this leg reads back is the wall this leg wrote — and | 251 | # used to add, remove and reorder answer 405. Every mux web here runs with |
| 240 | # never the developer's real one. | 252 | # a state home of its own (see $DWSTATE), so the file this leg reads back |
| 241 | start_daemon "$SOCK25" "$OUT.dw.d" "dyn wall daemon never bound" --shell /bin/sh | 253 | # is the file this leg wrote — and never the developer's real one. |
| 254 | # | ||
| 255 | # TWO daemons and an off-origin session on the first: a wall of one host | ||
| 256 | # is blind to host order, and a leg whose only session is `0` is blind to | ||
| 257 | # which session a tile names. | ||
| 258 | start_daemon "$SOCK25" "$OUT.dwa.d" "host wall daemon A never bound" --shell /bin/sh | ||
| 242 | D22PID=$DPID | 259 | D22PID=$DPID |
| 243 | 260 | start_daemon "$SOCK76" "$OUT.dwb.d" "host wall daemon B never bound" --shell /bin/sh | |
| 244 | XDG_STATE_HOME="$DWSTATE" "$MUX" web --port "$WPORT4" > "$OUT.dwh" 2>&1 & | 261 | D23PID=$DPID |
| 262 | |||
| 263 | # A's second session, made the way a user makes one. It has to outlive its | ||
| 264 | # client: a session ends when its shell exits, not when a client leaves. | ||
| 265 | pipe_mux "$OUT.dwmk" "$OUT.dwmk.err" timeout 40 "$MUX" --sock "$SOCK25" --session b | ||
| 266 | pipe_send 'printf "host-%%s\\n" mk\n' | ||
| 267 | wait_grid "$SOCK25" "host-mk" "host wall: session b exists" b | ||
| 268 | pipe_detach "host wall: the session-b maker" | ||
| 269 | |||
| 270 | # Argv is RECORDED, not a view of its own: `mux web "--sock A" "--sock B"` | ||
| 271 | # is `mux hosts add` twice and then a hub on the file. | ||
| 272 | XDG_STATE_HOME="$DWSTATE" "$MUX" web "--sock $SOCK25" "--sock $SOCK76" --port "$WPORT4" > "$OUT.dwh" 2>&1 & | ||
| 245 | W4PID=$! | 273 | W4PID=$! |
| 246 | defer_kill "$W4PID" | 274 | defer_kill "$W4PID" |
| 247 | wait_for "$OUT.dwh" "serving" 10 || { | ||
| 248 | echo "e2e FAIL: dyn wall: hub never reported serving"; cat "$OUT.dwh"; exit 1; } | ||
| 249 | DWORIG="http://127.0.0.1:$WPORT4" | 275 | DWORIG="http://127.0.0.1:$WPORT4" |
| 276 | # `serving` first: the hub records its argv and reads the file back before | ||
| 277 | # it binds, so the door being open is what says the write has happened. | ||
| 278 | wait_for "$OUT.dwh" "serving" 10 || { | ||
| 279 | echo "e2e FAIL: host wall: hub never reported serving"; cat "$OUT.dwh"; exit 1; } | ||
| 280 | grep -qxF -- "--sock $SOCK25" "$DWSTATE/mux/hosts" 2>/dev/null || { | ||
| 281 | echo "e2e FAIL: host wall: argv did not reach the hosts file; it holds:" | ||
| 282 | cat "$DWSTATE/mux/hosts" 2>/dev/null; exit 1; } | ||
| 283 | grep -qxF -- "--sock $SOCK76" "$DWSTATE/mux/hosts" || { | ||
| 284 | echo "e2e FAIL: host wall: the second host did not reach the file; it holds:" | ||
| 285 | cat "$DWSTATE/mux/hosts"; exit 1; } | ||
| 286 | # The retired file is retired: a hub that still wrote one would keep two | ||
| 287 | # answers to "what is on the wall" alive, which is the whole point of D2. | ||
| 288 | [ ! -e "$DWSTATE/mux/wall" ] || { | ||
| 289 | echo "e2e FAIL: host wall: the hub wrote a wall file; it holds:" | ||
| 290 | cat "$DWSTATE/mux/wall"; exit 1; } | ||
| 250 | 291 | ||
| 251 | # No argv and no file: the hub serves an EMPTY wall rather than refusing to | 292 | # Three tiles, announced by the pollers as their hosts answer: A's `0` and |
| 252 | # start. That is the whole premise of a page that can build its own wall. | 293 | # `b`, and B's `0`. Ids are BIRTH order and one poller thread per host |
| 253 | [ "$(curl -s "$DWORIG/tiles")" = "[]" ] || { | 294 | # races the other, so the ids are not this leg's to predict — the wall's |
| 254 | echo "e2e FAIL: dyn wall: fresh hub wall not empty; it holds:" | 295 | # ORDER is (host order, then the daemon's own list order inside a host), |
| 255 | curl -s "$DWORIG/tiles"; exit 1; } | 296 | # and `/ws/<id>` is read out of the answer rather than assumed. |
| 256 | 297 | for _t in 0 1 2; do | |
| 257 | # No Origin: refused before anything mutates. A text/plain POST is a CSRF | 298 | wait_for "$OUT.dwh" "tile $_t:" 15 || { |
| 258 | # "simple request" any page can fire at localhost, so the gate is what keeps | 299 | echo "e2e FAIL: host wall: tile $_t never appeared; the hub said:" |
| 259 | # this page's power this page's — and the wall must be untouched after it. | 300 | cat "$OUT.dwh"; exit 1; } |
| 260 | RC=$(curl -s -o /dev/null -w '%{http_code}' -X POST --data "--sock $SOCK25" "$DWORIG/tiles") | 301 | done |
| 261 | [ "$RC" = "403" ] || { echo "e2e FAIL: dyn wall: originless POST got $RC, want 403"; exit 1; } | 302 | # One string, not three greps: which tile sits where is the claim, and |
| 262 | [ "$(curl -s "$DWORIG/tiles")" = "[]" ] || { | 303 | # separate greps would pass on a wall holding them in any order at all. |
| 263 | echo "e2e FAIL: dyn wall: the refused POST still reached the wall:" | 304 | dw_shape() { curl -s "$DWORIG/tiles" | sed 's/"id":[0-9]*,//g'; } |
| 264 | curl -s "$DWORIG/tiles"; exit 1; } | 305 | dw_id_of() { # LABEL SESSION -> the hub's id for that tile, or empty |
| 265 | 306 | # One line per tile object, then the id off the line that carries BOTH | |
| 266 | # Route precision: /tiles is an EXACT match, not a prefix — `/tilesgarbage` | 307 | # this label and this session: a label alone names two tiles whenever a |
| 267 | # must not fall into the mutation block and add a tile. | 308 | # daemon has two sessions, which is the case this leg is built on. |
| 268 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X POST --data "--sock $SOCK25" "$DWORIG/tilesgarbage") | 309 | curl -s "$DWORIG/tiles" | tr '{' '\n' | |
| 269 | [ "$RC" = "404" ] || { echo "e2e FAIL: dyn wall: /tilesgarbage POST got $RC, want 404"; exit 1; } | 310 | grep -F "\"label\":\"$1\",\"session\":\"$2\"}" | |
| 270 | [ "$(curl -s "$DWORIG/tiles")" = "[]" ] || { | 311 | sed -n 's/^"id":\([0-9][0-9]*\),.*/\1/p' | head -1 |
| 271 | echo "e2e FAIL: dyn wall: /tilesgarbage POST still added a tile:" | 312 | } |
| 313 | DWWANT="[{\"label\":\"--sock $SOCK25\",\"session\":\"0\"}," | ||
| 314 | DWWANT="$DWWANT{\"label\":\"--sock $SOCK25\",\"session\":\"b\"}," | ||
| 315 | DWWANT="$DWWANT{\"label\":\"--sock $SOCK76\",\"session\":\"0\"}]" | ||
| 316 | [ "$(dw_shape)" = "$DWWANT" ] || { | ||
| 317 | echo "e2e FAIL: host wall: /tiles is not host order then daemon order" | ||
| 318 | echo " got: $(dw_shape)" | ||
| 319 | echo " want: $DWWANT"; exit 1; } | ||
| 320 | # The ids this leg then names. Read from the answer, so a hub that numbered | ||
| 321 | # differently is still asserted against — and a missing id fails here. | ||
| 322 | DWID_A0=$(dw_id_of "--sock $SOCK25" 0) | ||
| 323 | DWID_AB=$(dw_id_of "--sock $SOCK25" b) | ||
| 324 | DWID_B0=$(dw_id_of "--sock $SOCK76" 0) | ||
| 325 | [ -n "$DWID_A0" ] && [ -n "$DWID_AB" ] && [ -n "$DWID_B0" ] || { | ||
| 326 | echo "e2e FAIL: host wall: could not read the three tile ids out of:" | ||
| 272 | curl -s "$DWORIG/tiles"; exit 1; } | 327 | curl -s "$DWORIG/tiles"; exit 1; } |
| 273 | 328 | ||
| 274 | # Two tiles on one socket: the default session and session b, the M18 | 329 | # A tile is a REAL session: a marker typed through the CLI door on A#b is |
| 275 | # one-name-one-session doctrine. Ids are birth order and are the hub's own — | 330 | # read back through the WebSocket door of the tile that names it — A#b's |
| 276 | # `/ws/<id>`, not a position — which is what the next assertions ride on. | 331 | # id, not A#0's, which is the same daemon and a different session. The stand-in |
| 277 | R=$(curl -s -H "Origin: $DWORIG" -X POST --data "--sock $SOCK25" "$DWORIG/tiles") | 332 | # attaches at 0x0 and the CLI client has already detached, so the passivity |
| 278 | [ "$R" = '{"id":0}' ] || { echo "e2e FAIL: dyn wall: first add returned $R, want id 0"; exit 1; } | 333 | # contract is in play in passing. |
| 279 | R=$(curl -s -H "Origin: $DWORIG" -X POST --data "--sock $SOCK25#b" "$DWORIG/tiles") | 334 | pipe_mux "$OUT.dwcli" "$OUT.dwcli.err" timeout 40 "$MUX" --sock "$SOCK25" --session b |
| 280 | [ "$R" = '{"id":1}' ] || { echo "e2e FAIL: dyn wall: second add returned $R, want id 1"; exit 1; } | ||
| 281 | |||
| 282 | # The two refusals that happen at ADD time rather than at dial time, which | ||
| 283 | # is the point of both: a malformed session name, and a socket path longer | ||
| 284 | # than sun_path. A tile that can never attach must not reach the wall. | ||
| 285 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X POST --data 'h#bad name' "$DWORIG/tiles") | ||
| 286 | [ "$RC" = "400" ] || { echo "e2e FAIL: dyn wall: bad session name got $RC, want 400"; exit 1; } | ||
| 287 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X POST \ | ||
| 288 | --data '--sock /tmp/dyn-wall-socket-path-far-longer-than-the-108-byte-sun_path-limit-so-the-add-refuses-here-not-at-dial-time.sock' \ | ||
| 289 | "$DWORIG/tiles") | ||
| 290 | [ "$RC" = "400" ] || { echo "e2e FAIL: dyn wall: over-long socket path got $RC, want 400"; exit 1; } | ||
| 291 | |||
| 292 | # A POSTed tile is a REAL tile: a marker typed through the CLI door is read | ||
| 293 | # back through the WebSocket door of the tile the page just created. The | ||
| 294 | # stand-in attaches at 0x0 and the CLI client has already detached, so the | ||
| 295 | # passivity contract is in play in passing — a tile that claimed no size | ||
| 296 | # never moves the grid, and the 80-wide marker is still one contiguous | ||
| 297 | # string to expect. | ||
| 298 | pipe_mux "$OUT.dwcli" "$OUT.dwcli.err" timeout 40 "$MUX" --sock "$SOCK25" | ||
| 299 | pipe_send 'printf "dyn-%%s\\n" w1\n' | 335 | pipe_send 'printf "dyn-%%s\\n" w1\n' |
| 300 | await_out "$OUT.dwcli" "dyn-w1" "dyn-w1 never reached the client" | 336 | await_out "$OUT.dwcli" "dyn-w1" "dyn-w1 never reached the client" |
| 301 | pipe_detach "dyn wall: CLI client" | 337 | pipe_detach "host wall: CLI client" |
| 302 | wait_grid "$SOCK25" "dyn-w1" "dyn wall: CLI marker" | 338 | wait_grid "$SOCK25" "dyn-w1" "host wall: CLI marker" b |
| 303 | set +e | 339 | set +e |
| 304 | timeout 40 "$WSCLIENT" --port "$WPORT4" --tile 0 --out "$OUT.dwws" --err "$OUT.dwws.err" <<'EOF' | 340 | timeout 40 "$WSCLIENT" --port "$WPORT4" --tile "$DWID_AB" --out "$OUT.dwws" --err "$OUT.dwws.err" <<'EOF' |
| 305 | attach 0 0 | 341 | attach 0 0 b |
| 306 | expectstate up 10000 | 342 | expectstate up 10000 |
| 307 | expectgrid dyn-w1 15000 | 343 | expectgrid dyn-w1 15000 |
| 308 | dumpexit | 344 | dumpexit |
| @@ -310,119 +346,197 @@ EOF | |||
| 310 | RC=$? | 346 | RC=$? |
| 311 | set -e | 347 | set -e |
| 312 | [ "$RC" -eq 0 ] || { | 348 | [ "$RC" -eq 0 ] || { |
| 313 | echo "e2e FAIL: dyn wall: wsclient exited $RC" | 349 | echo "e2e FAIL: host wall: wsclient exited $RC" |
| 314 | cat -v "$OUT.dwws.err" 2>/dev/null; cat "$OUT.dwh"; exit 1; } | 350 | cat -v "$OUT.dwws.err" 2>/dev/null; cat "$OUT.dwh"; exit 1; } |
| 315 | 351 | ||
| 316 | # Reorder is the FULL new order by id, and the 204 is BODYLESS — a reorder | 352 | # No Origin: refused before anything happens. A text/plain POST is a CSRF |
| 317 | # that took is read back with a GET, never inferred from the status. | 353 | # "simple request" any page can fire at localhost, so the gate is what keeps |
| 318 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X PUT --data '1,0' "$DWORIG/tiles") | 354 | # this page's power this page's — and the wall must be untouched after it. |
| 319 | [ "$RC" = "204" ] || { echo "e2e FAIL: dyn wall: reorder got $RC, want 204"; exit 1; } | 355 | RC=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$DWORIG/tiles/$DWID_A0") |
| 320 | curl -s "$DWORIG/tiles" | grep -q '^\[{"id":1,' || { | 356 | [ "$RC" = "403" ] || { echo "e2e FAIL: host wall: originless POST got $RC, want 403"; exit 1; } |
| 321 | echo "e2e FAIL: dyn wall: reorder not reflected; the wall holds:" | 357 | # The wall unchanged AND the hub still answering: a bodyless POST is what |
| 358 | # `curl -X POST` sends, and std's keep-alive body discard asserts on one — | ||
| 359 | # which aborted the whole hub, every tile with it, until every answer on | ||
| 360 | # this route started closing its connection. | ||
| 361 | [ "$(dw_shape)" = "$DWWANT" ] || { | ||
| 362 | echo "e2e FAIL: host wall: the refused POST still changed the wall (or killed the hub):" | ||
| 322 | curl -s "$DWORIG/tiles"; exit 1; } | 363 | curl -s "$DWORIG/tiles"; exit 1; } |
| 323 | # ...and it reached the FILE, which is the half a restart will read back. | 364 | |
| 324 | # Asserted here rather than after the delete, because this is the only | 365 | # Route precision: /tiles is an EXACT match, not a prefix — `/tilesgarbage` |
| 325 | # moment the wall has two lines and "order" means anything on disk at all. | 366 | # must fall through to the asset router's plain 404, never into the block |
| 326 | [ "$(head -1 "$DWSTATE/mux/wall")" = "--sock $SOCK25#b" ] || { | 367 | # that spawns. |
| 327 | echo "e2e FAIL: dyn wall: reorder did not reach the file; it holds:" | 368 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X POST "$DWORIG/tilesgarbage") |
| 328 | cat "$DWSTATE/mux/wall"; exit 1; } | 369 | [ "$RC" = "404" ] || { echo "e2e FAIL: host wall: /tilesgarbage POST got $RC, want 404"; exit 1; } |
| 329 | # An order naming an id the hub does not have is a 409 carrying a copy of | 370 | |
| 330 | # the truth — never a guess at what the stale page meant. | 371 | # The three verbs the browser used to author the wall with. They are gone |
| 331 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X PUT --data '0,7' "$DWORIG/tiles") | 372 | # with the wall file, and a 405 is how a page built against the old hub |
| 332 | [ "$RC" = "409" ] || { echo "e2e FAIL: dyn wall: stale reorder got $RC, want 409"; exit 1; } | 373 | # learns that rather than silently appearing to work. |
| 333 | curl -s -H "Origin: $DWORIG" -X PUT --data '0,7' "$DWORIG/tiles" | grep -q '^\[{"id":1,' || { | 374 | for _m in POST PUT; do |
| 334 | echo "e2e FAIL: dyn wall: the 409 did not carry the current wall"; exit 1; } | 375 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X "$_m" --data "--sock $SOCK25" "$DWORIG/tiles") |
| 335 | 376 | [ "$RC" = "405" ] || { echo "e2e FAIL: host wall: $_m /tiles got $RC, want 405"; exit 1; } | |
| 336 | # Remove is DETACH, not kill: the tile leaves the wall, the daemon keeps | 377 | done |
| 337 | # every session it had. `sessions=` is stats' own word for that — and the | 378 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X DELETE "$DWORIG/tiles/$DWID_A0") |
| 338 | # reading is asserted NON-EMPTY before it is compared, because both sides | 379 | [ "$RC" = "405" ] || { echo "e2e FAIL: host wall: DELETE /tiles/<id> got $RC, want 405"; exit 1; } |
| 339 | # come out of the same sed: a stats line that renamed or dropped the field | 380 | [ "$(dw_shape)" = "$DWWANT" ] || { |
| 340 | # would make this `[ "" = "" ]` and pass forever, which is the one way this | 381 | echo "e2e FAIL: host wall: a refused verb still changed the wall:" |
| 341 | # check could fail green (want_stat, above, guards its own the same way). | 382 | curl -s "$DWORIG/tiles"; exit 1; } |
| 383 | |||
| 384 | # `+`: a new session on THAT tile's daemon, named by the daemon — | ||
| 385 | # `nextFreeName` off A's own list of `0` and `b`, so `1`. The tile reaches | ||
| 386 | # the wall through the POLL, never through this reply: one road on. | ||
| 342 | SESS_BEFORE=$("$MUX" d stats --sock "$SOCK25" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p') | 387 | SESS_BEFORE=$("$MUX" d stats --sock "$SOCK25" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p') |
| 343 | [ -n "$SESS_BEFORE" ] || { | 388 | [ -n "$SESS_BEFORE" ] || { |
| 344 | echo "e2e FAIL: dyn wall: no sessions= in stats (the field moved?); it says:" | 389 | echo "e2e FAIL: host wall: no sessions= in stats (the field moved?); it says:" |
| 345 | "$MUX" d stats --sock "$SOCK25"; exit 1; } | 390 | "$MUX" d stats --sock "$SOCK25"; exit 1; } |
| 346 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X DELETE "$DWORIG/tiles/0") | 391 | R=$(curl -s -H "Origin: $DWORIG" -X POST "$DWORIG/tiles/$DWID_A0") |
| 347 | [ "$RC" = "204" ] || { echo "e2e FAIL: dyn wall: delete got $RC, want 204"; exit 1; } | 392 | [ "$R" = '{"session":"1"}' ] || { |
| 348 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X DELETE "$DWORIG/tiles/0") | 393 | echo "e2e FAIL: host wall: spawn on A's tile returned $R, want session 1"; exit 1; } |
| 349 | [ "$RC" = "404" ] || { echo "e2e FAIL: dyn wall: second delete got $RC, want 404"; exit 1; } | 394 | _i=0 |
| 395 | while [ "$_i" -lt 60 ]; do | ||
| 396 | [ -n "$(dw_id_of "--sock $SOCK25" 1)" ] && break | ||
| 397 | sleep 0.1; _i=$((_i+1)) | ||
| 398 | done | ||
| 399 | [ "$_i" -lt 60 ] || { | ||
| 400 | echo "e2e FAIL: host wall: the born session never became a tile; the wall holds:" | ||
| 401 | curl -s "$DWORIG/tiles"; exit 1; } | ||
| 402 | # Asked of the DAEMON, not of the hub: a hub reporting on a birth it made | ||
| 403 | # cannot catch itself being wrong about whether a shell exists. | ||
| 350 | SESS_AFTER=$("$MUX" d stats --sock "$SOCK25" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p') | 404 | SESS_AFTER=$("$MUX" d stats --sock "$SOCK25" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p') |
| 351 | [ "$SESS_BEFORE" = "$SESS_AFTER" ] || { | 405 | [ "$SESS_AFTER" = "$(( SESS_BEFORE + 1 ))" ] || { |
| 352 | echo "e2e FAIL: dyn wall: DELETE killed a session ($SESS_BEFORE -> $SESS_AFTER)"; exit 1; } | 406 | echo "e2e FAIL: host wall: spawn did not make a session ($SESS_BEFORE -> $SESS_AFTER)"; exit 1; } |
| 407 | # An id nothing names is a 404, not a birth on some other host. | ||
| 408 | RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $DWORIG" -X POST "$DWORIG/tiles/999") | ||
| 409 | [ "$RC" = "404" ] || { echo "e2e FAIL: host wall: spawn on an unknown id got $RC, want 404"; exit 1; } | ||
| 410 | |||
| 411 | # The id the born tile is wearing now, so "never reused" can be asserted | ||
| 412 | # against a number rather than against a hope. | ||
| 413 | DWID_A1=$(dw_id_of "--sock $SOCK25" 1) | ||
| 414 | [ -n "$DWID_A1" ] || { echo "e2e FAIL: host wall: the born tile has no id"; exit 1; } | ||
| 415 | |||
| 416 | # A session that ENDS leaves the wall, and the shell is gone per the OS | ||
| 417 | # rather than per the hub. The pid is read off the GRID — the shell's own | ||
| 418 | # `$$` — because a daemon reporting on its own children cannot catch itself | ||
| 419 | # being wrong about whether one is still there. | ||
| 420 | timeout 20 "$MUX" a send 'printf "bornpid-%s\n" $$\n' --sock "$SOCK25" --session 1 > /dev/null 2>&1 | ||
| 421 | _i=0 | ||
| 422 | while [ "$_i" -lt 80 ]; do | ||
| 423 | timeout 20 "$MUX" a capture --sock "$SOCK25" --session 1 > "$OUT.dwpid" 2>&1 | ||
| 424 | grep -q "bornpid-[0-9]" "$OUT.dwpid" && break | ||
| 425 | sleep 0.1; _i=$((_i+1)) | ||
| 426 | done | ||
| 427 | DWSHELL=$(sed -n 's/.*bornpid-\([0-9][0-9]*\).*/\1/p' "$OUT.dwpid" | head -1) | ||
| 428 | [ -n "$DWSHELL" ] || { | ||
| 429 | echo "e2e FAIL: host wall: the born session never said its own pid; it holds:" | ||
| 430 | cat "$OUT.dwpid"; exit 1; } | ||
| 431 | timeout 20 "$MUX" a send 'exit\n' --sock "$SOCK25" --session 1 > /dev/null 2>&1 || true | ||
| 432 | _i=0 | ||
| 433 | while [ "$_i" -lt 80 ]; do | ||
| 434 | curl -s "$DWORIG/tiles" | grep -q '"session":"1"' || break | ||
| 435 | sleep 0.1; _i=$((_i+1)) | ||
| 436 | done | ||
| 437 | [ "$_i" -lt 80 ] || { | ||
| 438 | echo "e2e FAIL: host wall: the ended session's tile never left; the wall holds:" | ||
| 439 | curl -s "$DWORIG/tiles"; exit 1; } | ||
| 440 | # Asked of the OS, not of the daemon. | ||
| 441 | [ ! -e "/proc/$DWSHELL" ] || { | ||
| 442 | echo "e2e FAIL: host wall: the shell pid $DWSHELL outlived its session per /proc" | ||
| 443 | cat "/proc/$DWSHELL/cmdline" 2>/dev/null; exit 1; } | ||
| 444 | |||
| 445 | # The same name again, and it must NOT be the same tile. The browser holds | ||
| 446 | # `/ws/<id>` across a vanishing it did not make, so an id handed out twice | ||
| 447 | # would silently re-point that socket at a different shell — a different | ||
| 448 | # shell that happens to have the name the first one had is exactly the case | ||
| 449 | # a per-name id would get wrong. | ||
| 450 | R=$(curl -s -H "Origin: $DWORIG" -X POST "$DWORIG/tiles/$DWID_A0") | ||
| 451 | [ "$R" = '{"session":"1"}' ] || { | ||
| 452 | echo "e2e FAIL: host wall: the second spawn returned $R, want session 1 again"; exit 1; } | ||
| 453 | _i=0 | ||
| 454 | while [ "$_i" -lt 60 ]; do | ||
| 455 | [ -n "$(dw_id_of "--sock $SOCK25" 1)" ] && break | ||
| 456 | sleep 0.1; _i=$((_i+1)) | ||
| 457 | done | ||
| 458 | [ "$_i" -lt 60 ] || { | ||
| 459 | echo "e2e FAIL: host wall: the second birth never became a tile; the wall holds:" | ||
| 460 | curl -s "$DWORIG/tiles"; exit 1; } | ||
| 461 | [ "$(dw_id_of "--sock $SOCK25" 1)" != "$DWID_A1" ] || { | ||
| 462 | echo "e2e FAIL: host wall: session 1 came back on the SAME id $DWID_A1" | ||
| 463 | curl -s "$DWORIG/tiles"; exit 1; } | ||
| 353 | 464 | ||
| 354 | # ...and the id it took with it is answered in HTTP: a 404 the page can | 465 | # ...and the id that left is answered in HTTP: a 404 the page can read, |
| 355 | # read, rather than an upgrade followed by a silent close it can only guess | 466 | # rather than an upgrade followed by a silent close it can only guess at. |
| 356 | # at. The stand-in sees a non-101 and exits 4, the wrong-Origin scenario's | 467 | # The stand-in sees a non-101 and exits 4, the wrong-Origin shape. |
| 357 | # shape. | ||
| 358 | set +e | 468 | set +e |
| 359 | timeout 20 "$WSCLIENT" --port "$WPORT4" --tile 0 \ | 469 | timeout 20 "$WSCLIENT" --port "$WPORT4" --tile "$DWID_A1" \ |
| 360 | --out "$OUT.dwdead" --err "$OUT.dwdead.err" < /dev/null | 470 | --out "$OUT.dwdead" --err "$OUT.dwdead.err" < /dev/null |
| 361 | RC=$? | 471 | RC=$? |
| 362 | set -e | 472 | set -e |
| 363 | [ "$RC" -eq 4 ] || { | 473 | [ "$RC" -eq 4 ] || { |
| 364 | echo "e2e FAIL: dyn wall: wsclient on a removed tile exited $RC, want 4" | 474 | echo "e2e FAIL: host wall: wsclient on a departed tile exited $RC, want 4" |
| 365 | cat -v "$OUT.dwdead.err" 2>/dev/null; exit 1; } | 475 | cat -v "$OUT.dwdead.err" 2>/dev/null; exit 1; } |
| 366 | grep -q "upgrade refused.*404" "$OUT.dwdead.err" || { | 476 | grep -q "upgrade refused.*404" "$OUT.dwdead.err" || { |
| 367 | echo "e2e FAIL: dyn wall: a dead id was not refused with a 404:" | 477 | echo "e2e FAIL: host wall: a dead id was not refused with a 404:" |
| 368 | cat -v "$OUT.dwdead.err"; exit 1; } | 478 | cat -v "$OUT.dwdead.err"; exit 1; } |
| 369 | 479 | ||
| 370 | # The file carries SPELLINGS, one per line, in wall order — no ids, because | 480 | # A daemon that goes AWAY is not a session that ended: an unreachable poll |
| 371 | # ids are per-run. The surviving tile is the `#b` one the delete spared. | 481 | # is no evidence either way, so B's tile rides it out and comes back when B |
| 372 | grep -qxF -- "--sock $SOCK25#b" "$DWSTATE/mux/wall" || { | 482 | # does — same tile, same id. A wall that vanished tiles on a failed poll |
| 373 | echo "e2e FAIL: dyn wall: state file missing the surviving tile; it holds:" | 483 | # would tear itself down over one dropped packet, and the browser would |
| 374 | cat "$DWSTATE/mux/wall"; exit 1; } | 484 | # lose a socket to every network hiccup. |
| 375 | [ "$(wc -l < "$DWSTATE/mux/wall")" = "1" ] || { | 485 | DWSHAPE_OUT=$(dw_shape) |
| 376 | echo "e2e FAIL: dyn wall: state file is not the one surviving line:" | 486 | assert_stopped "$SOCK76" "$D23PID" "host wall: daemon B" "$OUT.dwstopb" |
| 377 | cat "$DWSTATE/mux/wall"; exit 1; } | 487 | D23PID="" |
| 378 | 488 | sleep 3 | |
| 379 | # Restart with NO argv: the file is the wall. The id is fresh — a restarted | 489 | [ "$(dw_shape)" = "$DWSHAPE_OUT" ] || { |
| 380 | # hub numbers from 0 in wall order — and the SPELLING is what persisted. | 490 | echo "e2e FAIL: host wall: a stopped daemon's tiles vanished; the wall holds:" |
| 381 | softkill "$W4PID" || true | 491 | curl -s "$DWORIG/tiles" |
| 382 | wait_pid_gone "$W4PID" "dyn wall: first hub killed by tracked pid" | 492 | echo " was: $DWSHAPE_OUT"; exit 1; } |
| 383 | XDG_STATE_HOME="$DWSTATE" "$MUX" web --port "$WPORT4" > "$OUT.dwh2" 2>&1 & | 493 | start_daemon "$SOCK76" "$OUT.dwb2.d" "host wall daemon B restart never bound" --shell /bin/sh |
| 384 | W4PID=$! | 494 | D23PID=$DPID |
| 385 | defer_kill "$W4PID" | 495 | sleep 3 |
| 386 | wait_for "$OUT.dwh2" "serving" 10 || { | 496 | [ "$(dw_id_of "--sock $SOCK76" 0)" = "$DWID_B0" ] || { |
| 387 | echo "e2e FAIL: dyn wall: restored hub never reported serving"; cat "$OUT.dwh2"; exit 1; } | 497 | echo "e2e FAIL: host wall: B's tile did not heal onto its own id $DWID_B0:" |
| 388 | curl -s "$DWORIG/tiles" | grep -q "^\[{\"id\":0,\"label\":\"--sock $SOCK25#b\"" || { | ||
| 389 | echo "e2e FAIL: dyn wall: restart lost the persisted wall; it holds:" | ||
| 390 | curl -s "$DWORIG/tiles"; exit 1; } | 498 | curl -s "$DWORIG/tiles"; exit 1; } |
| 391 | 499 | ||
| 392 | # Restart WITH argv: the override is the VIEW's, not the file's. This | ||
| 393 | # assertion INVERTED with the attach-history phase and did not merely move. | ||
| 394 | # It used to read "argv appended to the wall instead of replacing it" — a | ||
| 395 | # pin on mux web overwriting the state file with whatever it was told to | ||
| 396 | # show. That file is the user's attach history now, written by every `mux` | ||
| 397 | # attach, so one `mux web HOST` overwriting it would silently erase the lot. | ||
| 398 | # Argv tiles are ADDED (deduped by spelling) and nothing is removed; | ||
| 399 | # forgetting stays explicit — the page's `x`, the wall's `x`, `mux wall rm`. | ||
| 400 | # The tile is spelled as ONE quoted argument — the wall file's own | ||
| 401 | # spelling, handed back to the binary that wrote it. | ||
| 402 | softkill "$W4PID" || true | 500 | softkill "$W4PID" || true |
| 403 | wait_pid_gone "$W4PID" "dyn wall: restored hub killed by tracked pid" | 501 | wait_pid_gone "$W4PID" "host wall: hub killed by tracked pid" |
| 404 | XDG_STATE_HOME="$DWSTATE" "$MUX" web "--sock $SOCK25" --port "$WPORT4" > "$OUT.dwh3" 2>&1 & | ||
| 405 | W4PID=$! | ||
| 406 | defer_kill "$W4PID" | ||
| 407 | wait_for "$OUT.dwh3" "serving" 10 || { | ||
| 408 | echo "e2e FAIL: dyn wall: overriding hub never reported serving"; cat "$OUT.dwh3"; exit 1; } | ||
| 409 | grep -qxF -- "--sock $SOCK25" "$DWSTATE/mux/wall" || { | ||
| 410 | echo "e2e FAIL: dyn wall: argv did not reach the wall file; it holds:" | ||
| 411 | cat "$DWSTATE/mux/wall"; exit 1; } | ||
| 412 | # ...and the line that was already there SURVIVED it. Both halves, because | ||
| 413 | # either alone is satisfied by the wrong behaviour: the grep above passes | ||
| 414 | # on an overwrite, and a count alone would not say which lines are which. | ||
| 415 | grep -qxF -- "--sock $SOCK25#b" "$DWSTATE/mux/wall" || { | ||
| 416 | echo "e2e FAIL: dyn wall: argv ERASED the wall it was added to — an" | ||
| 417 | echo " attach history overwritten by one mux web invocation:" | ||
| 418 | cat "$DWSTATE/mux/wall"; exit 1; } | ||
| 419 | [ "$(wc -l < "$DWSTATE/mux/wall")" = "2" ] || { | ||
| 420 | echo "e2e FAIL: dyn wall: the wall file is not the two lines argv added to:" | ||
| 421 | cat "$DWSTATE/mux/wall"; exit 1; } | ||
| 422 | |||
| 423 | softkill "$W4PID" || true | ||
| 424 | wait_pid_gone "$W4PID" "dyn wall: overriding hub killed by tracked pid" | ||
| 425 | W4PID="" | 502 | W4PID="" |
| 426 | assert_stopped "$SOCK25" "$D22PID" "dyn wall" "$OUT.dwstop" | 503 | |
| 504 | # `#SESSION` is refused at both mouths, in the same words, and neither | ||
| 505 | # refusal binds a port: a hub that started and then complained would be a | ||
| 506 | # daemon list with a session pinned into it. | ||
| 507 | set +e | ||
| 508 | XDG_STATE_HOME="$DWSTATE" "$MUX" web "--sock $SOCK25#b" --port "$WPORT4" > "$OUT.dwrefuse" 2>&1 | ||
| 509 | RC=$? | ||
| 510 | set -e | ||
| 511 | [ "$RC" -eq 2 ] || { | ||
| 512 | echo "e2e FAIL: host wall: mux web with a #SESSION exited $RC, want 2" | ||
| 513 | cat "$OUT.dwrefuse"; exit 1; } | ||
| 514 | grep -q "#" "$OUT.dwrefuse" && grep -qi "session" "$OUT.dwrefuse" || { | ||
| 515 | echo "e2e FAIL: host wall: the refusal never named the '#'; it said:" | ||
| 516 | cat "$OUT.dwrefuse"; exit 1; } | ||
| 517 | grep -q "serving" "$OUT.dwrefuse" && { | ||
| 518 | echo "e2e FAIL: host wall: a refused argv still bound a port:" | ||
| 519 | cat "$OUT.dwrefuse"; exit 1; } | ||
| 520 | |||
| 521 | # The same refusal from the FILE, and the offending line printed back: the | ||
| 522 | # fix is in the file, so the message has to name what to fix. | ||
| 523 | printf 'box#old\n' >> "$DWSTATE/mux/hosts" | ||
| 524 | set +e | ||
| 525 | XDG_STATE_HOME="$DWSTATE" "$MUX" web --port "$WPORT4" > "$OUT.dwrefuse2" 2>&1 | ||
| 526 | RC=$? | ||
| 527 | set -e | ||
| 528 | [ "$RC" -eq 2 ] || { | ||
| 529 | echo "e2e FAIL: host wall: a '#' line in the file exited $RC, want 2" | ||
| 530 | cat "$OUT.dwrefuse2"; exit 1; } | ||
| 531 | grep -q "box#old" "$OUT.dwrefuse2" || { | ||
| 532 | echo "e2e FAIL: host wall: the refusal never printed the bad line; it said:" | ||
| 533 | cat "$OUT.dwrefuse2"; exit 1; } | ||
| 534 | grep -q "serving" "$OUT.dwrefuse2" && { | ||
| 535 | echo "e2e FAIL: host wall: a refused file still bound a port:" | ||
| 536 | cat "$OUT.dwrefuse2"; exit 1; } | ||
| 537 | |||
| 538 | assert_stopped "$SOCK76" "$D23PID" "host wall: daemon B" "$OUT.dwstopb2" | ||
| 539 | D23PID="" | ||
| 540 | assert_stopped "$SOCK25" "$D22PID" "host wall: daemon A" "$OUT.dwstop" | ||
| 427 | D22PID="" | 541 | D22PID="" |
| 428 | ok "the wall is runtime state: add, remove, reorder, restore, argv adds" | 542 | ok "the hub's wall is the hosts file; tiles are those daemons' live sessions" |
test/e2e_13_birth.sh
| Old | New | ||
|---|---|---|---|
| @@ -11,23 +11,6 @@ defer_sock "$SOCK66" | |||
| 11 | REFSTATE="${TMPDIR:-/tmp}/mux-e2e-refuse-state-$$" | 11 | REFSTATE="${TMPDIR:-/tmp}/mux-e2e-refuse-state-$$" |
| 12 | defer_rm "$REFSTATE" | 12 | defer_rm "$REFSTATE" |
| 13 | # The browser half of the same ruling: one daemon serving a socket AND a | 13 | # The browser half of the same ruling: one daemon serving a socket AND a |
| 14 | # QUIC listener, one hub, a state home of its own. Two ports, and the | ||
| 15 | # 5000-spaced bands are exhausted — 61000 is the last one that fits under | ||
| 16 | # 65535 — so this leg takes the tail ABOVE the ephemeral range | ||
| 17 | # (ip_local_port_range tops out at 60999 by default), halved. The narrow | ||
| 18 | # `%250` is the price of that tail: two runs whose pids differ by 250 | ||
| 19 | # collide where the older bands need 4000. A collision reads as this leg's | ||
| 20 | # daemon failing to bind, which is also why the two halves cannot share a | ||
| 21 | # base. | ||
| 22 | SOCK67="${TMPDIR:-/tmp}/muxd-e2e-wg-$$.sock" | ||
| 23 | defer_sock "$SOCK67" | ||
| 24 | WGPORT=$(( 65000 + ($$ % 250) )) | ||
| 25 | WGQPORT=$(( 65250 + ($$ % 250) )) | ||
| 26 | WGKEY="${TMPDIR:-/tmp}/mux-e2e-wgkey-$$" | ||
| 27 | defer_rm "$WGKEY" | ||
| 28 | WGSTATE="${TMPDIR:-/tmp}/mux-e2e-wg-state-$$" | ||
| 29 | defer_rm "$WGSTATE" | ||
| 30 | WGWALL="$WGSTATE/mux/wall" | ||
| 31 | # The refusal-spin leg. Its own everything: a shim dir on PATH, a state | 14 | # The refusal-spin leg. Its own everything: a shim dir on PATH, a state |
| 32 | # home holding the wall it restores, and a dial log the shim appends to. | 15 | # home holding the wall it restores, and a dial log the shim appends to. |
| 33 | SOCK68="${TMPDIR:-/tmp}/muxd-e2e-sp-$$.sock" | 16 | SOCK68="${TMPDIR:-/tmp}/muxd-e2e-sp-$$.sock" |
| @@ -278,204 +261,12 @@ D66PID="" | |||
| 278 | rm -rf "$REFSTATE" | 261 | rm -rf "$REFSTATE" |
| 279 | ok "mux a names a refused attach instead of a shell that never ran" | 262 | ok "mux a names a refused attach instead of a shell that never ran" |
| 280 | 263 | ||
| 281 | # --- the same ruling through the browser ------------------------------- | ||
| 282 | # | ||
| 283 | # The hub's tiles attach at 0x0 — the passivity contract, so a browser can | ||
| 284 | # never move a shared grid — and the daemon reads 0x0 as join-only. A saved | ||
| 285 | # wall whose daemon has restarted was therefore a grid of dead tiles that | ||
| 286 | # the CLI, reading the same file, brought back. The hub now births the | ||
| 287 | # session on a connection of its own and re-dials; the browser re-attaches | ||
| 288 | # on `up` as it does after any tear and is never told a refusal happened. | ||
| 289 | # | ||
| 290 | # The split is the hydrate leg's, asserted the other side of the wire: the | ||
| 291 | # LOCAL line comes back, the `quic://` one still says refused. Same daemon, | ||
| 292 | # same run, reached both ways, so the comparison is between two kinds of | ||
| 293 | # line and not between two rigs. | ||
| 294 | # | ||
| 295 | # Daemon truth answers "was it created" — `mux a status` on a name the | ||
| 296 | # daemon does not have exits non-zero, and the same probe runs BEFORE the | ||
| 297 | # hub so the leg cannot pass vacuously. `sessions=1` at the end is the | ||
| 298 | # other half: one birth, not two, and nothing born for the remote line. | ||
| 299 | head -c 32 /dev/urandom > "$WGKEY" | ||
| 300 | chmod 600 "$WGKEY" | ||
| 301 | start_daemon "$SOCK67" "$OUT.wg.d" "web-restore daemon never bound" --shell /bin/sh \ | ||
| 302 | --quic "127.0.0.1:$WGQPORT" --key "$WGKEY" --quic-idle-ms 15000 | ||
| 303 | D67PID=$DPID | ||
| 304 | |||
| 305 | # Written by hand for the hydrate leg's reason: a line the hub had earned | ||
| 306 | # by attaching would name a session that already exists, and there would be | ||
| 307 | # nothing left for the restore to create. | ||
| 308 | mkdir -p "$WGSTATE/mux" | ||
| 309 | printf -- '--sock %s#wghost\nquic://127.0.0.1:%s#wghost2\n' \ | ||
| 310 | "$SOCK67" "$WGQPORT" > "$WGWALL" | ||
| 311 | |||
| 312 | # Vacuity guard: neither exists yet. | ||
| 313 | for _wg in wghost wghost2; do | ||
| 314 | timeout 20 "$MUX" a status --sock "$SOCK67" --session "$_wg" > "$OUT.wgpre" 2>&1 && { | ||
| 315 | echo "e2e FAIL: web-restore: session $_wg existed before the hub ran:" | ||
| 316 | cat "$OUT.wgpre"; exit 1; } | ||
| 317 | done | ||
| 318 | |||
| 319 | # No argv: the hub restores the wall from the last run, which is the file | ||
| 320 | # written above. Its own state home, never the developer's. | ||
| 321 | XDG_STATE_HOME="$WGSTATE" "$MUX" web --port "$WGPORT" --key "$WGKEY" \ | ||
| 322 | --quic-idle-ms 15000 > "$OUT.wgh" 2>&1 & | ||
| 323 | W5PID=$! | ||
| 324 | defer_kill "$W5PID" | ||
| 325 | wait_for "$OUT.wgh" "serving" 10 || { | ||
| 326 | echo "e2e FAIL: web-restore: hub never reported serving"; cat "$OUT.wgh"; exit 1; } | ||
| 327 | |||
| 328 | # Tile 0, the LOCAL line. `expectups 2` is the assertion that the hub | ||
| 329 | # re-dialed: one `up` for the first dial, a second for the one after the | ||
| 330 | # birth. Counted rather than read off the last state, because a | ||
| 331 | # `reconnecting` → `up` pair can arrive inside one read and leave the last | ||
| 332 | # state saying exactly what it said before. The second attach is the | ||
| 333 | # browser's own — mux.js re-attaches on `up`, and nothing in the page | ||
| 334 | # decides anything about creation. Both scripts wait for the first `up` | ||
| 335 | # before attaching, for mux.js's reason: a message sent while the hub is | ||
| 336 | # still dialing is dropped, and an attach nobody read is a refusal that | ||
| 337 | # never arrives. | ||
| 338 | # Bracketing the script below is what makes "one birth" measurable at all | ||
| 339 | # — see the delta assertion after it. | ||
| 340 | WGATT_A=$(attaches_now "$SOCK67") | ||
| 341 | set +e | ||
| 342 | timeout 60 "$WSCLIENT" --port "$WGPORT" --tile 0 --out "$OUT.wgws" --err "$OUT.wgws.err" <<'EOF' | ||
| 343 | expectstate up 25000 | ||
| 344 | attach 0 0 wghost | ||
| 345 | expectups 2 30000 | ||
| 346 | attach 0 0 wghost | ||
| 347 | settle 500 20000 | ||
| 348 | send printf 'wg-%s\n' born\n | ||
| 349 | expectgrid wg-born 25000 | ||
| 350 | dumpexit | ||
| 351 | EOF | ||
| 352 | RC=$? | ||
| 353 | set -e | ||
| 354 | [ "$RC" -eq 0 ] || { | ||
| 355 | echo "e2e FAIL: web-restore: the local tile's wsclient exited $RC" | ||
| 356 | cat -v "$OUT.wgws.err" 2>/dev/null; cat "$OUT.wgh"; exit 1; } | ||
| 357 | WGATT_B=$(attaches_now "$SOCK67") | ||
| 358 | |||
| 359 | # ONE birth. Counting sessions cannot say this: `resolveSession` is | ||
| 360 | # attach-or-create, so a second birth would find `wghost` and join, and | ||
| 361 | # the table would read 2 whether the hub birthed once or twenty times. | ||
| 362 | # `attaches` can, because a REFUSED attach deliberately does not move it: | ||
| 363 | # the two counted here are the birth's own and the browser's second one | ||
| 364 | # (the first was refused). A hub that re-birthed on every refused re-dial | ||
| 365 | # lands one extra per turn. | ||
| 366 | assert_attach_delta "$WGATT_A" "$WGATT_B" 2 \ | ||
| 367 | "web-restore: the hub birthed once for one refused tile" | ||
| 368 | |||
| 369 | # Tile 1, the REMOTE line: the refusal is forwarded untouched, and the | ||
| 370 | # fixture tells it from a shell exiting exactly as the page does — an | ||
| 371 | # exit_status before any grid. | ||
| 372 | set +e | ||
| 373 | timeout 60 "$WSCLIENT" --port "$WGPORT" --tile 1 --out "$OUT.wgws2" --err "$OUT.wgws2.err" <<'EOF' | ||
| 374 | expectstate up 25000 | ||
| 375 | attach 0 0 wghost2 | ||
| 376 | expectrefused 25000 | ||
| 377 | dumpexit | ||
| 378 | EOF | ||
| 379 | RC=$? | ||
| 380 | set -e | ||
| 381 | [ "$RC" -eq 0 ] || { | ||
| 382 | echo "e2e FAIL: web-restore: the remote tile's wsclient exited $RC" | ||
| 383 | cat -v "$OUT.wgws2.err" 2>/dev/null; cat "$OUT.wgh"; exit 1; } | ||
| 384 | |||
| 385 | # Daemon truth: the local line's session exists, and at the size the birth | ||
| 386 | # claimed — a browser tile claims none, so a session born at the tile's 0x0 | ||
| 387 | # would not have been born at all. | ||
| 388 | timeout 20 "$MUX" a status --sock "$SOCK67" --session wghost > "$OUT.wgsta" 2>&1 || { | ||
| 389 | echo "e2e FAIL: web-restore: the saved local line did not come back:" | ||
| 390 | cat "$OUT.wgsta"; cat "$OUT.wgh"; exit 1; } | ||
| 391 | |||
| 392 | # The size is asserted against the DAEMON's own default, not against a | ||
| 393 | # literal: session 0 is seeded from `main.Opts` and this daemon was given | ||
| 394 | # no --cols/--rows, so it holds exactly the figure `client.birth_cols` and | ||
| 395 | # `client.birth_rows` claim to mirror. Grepping for 80x24 would pass just | ||
| 396 | # as happily if both sides drifted together, which is the drift that | ||
| 397 | # matters — nothing else links the two files. | ||
| 398 | timeout 20 "$MUX" a status --sock "$SOCK67" --session 0 > "$OUT.wgsta0" 2>&1 || { | ||
| 399 | echo "e2e FAIL: web-restore: the daemon's own default session did not answer:" | ||
| 400 | cat "$OUT.wgsta0"; exit 1; } | ||
| 401 | _wg_size() { sed -n 's/^{\("cols":[0-9]*,"rows":[0-9]*\).*/\1/p' "$1"; } | ||
| 402 | WG_BORN=$(_wg_size "$OUT.wgsta") | ||
| 403 | WG_DEFAULT=$(_wg_size "$OUT.wgsta0") | ||
| 404 | [ -n "$WG_BORN" ] && [ -n "$WG_DEFAULT" ] || { | ||
| 405 | echo "e2e FAIL: web-restore: no size read out of mux a status" | ||
| 406 | echo " born='$WG_BORN' default='$WG_DEFAULT' — the pin would be vacuous" | ||
| 407 | cat "$OUT.wgsta" "$OUT.wgsta0"; exit 1; } | ||
| 408 | [ "$WG_BORN" = "$WG_DEFAULT" ] || { | ||
| 409 | echo "e2e FAIL: web-restore: the birth size drifted from the daemon's own default" | ||
| 410 | echo " wghost $WG_BORN, session 0 $WG_DEFAULT" | ||
| 411 | echo " — client.birth_cols/birth_rows must mirror main.Opts" | ||
| 412 | exit 1; } | ||
| 413 | |||
| 414 | # ...and it is a real shell: the marker the browser stand-in typed is the | ||
| 415 | # created session's own work. | ||
| 416 | grep -qF "wg-born" "$OUT.wgws" || { | ||
| 417 | echo "e2e FAIL: web-restore: the marker never reached the created session:" | ||
| 418 | cat "$OUT.wgws"; exit 1; } | ||
| 419 | |||
| 420 | # The remote line created NOTHING. Same daemon, same file, same run. | ||
| 421 | timeout 20 "$MUX" a status --sock "$SOCK67" --session wghost2 > "$OUT.wgghost" 2>&1 && { | ||
| 422 | echo "e2e FAIL: web-restore: a quic:// wall line created a session:" | ||
| 423 | cat "$OUT.wgghost"; exit 1; } | ||
| 424 | |||
| 425 | # ...and nothing else was created behind either tile. Two, not one — | ||
| 426 | # `mux d run` makes the default session at startup, so the birth is the | ||
| 427 | # SECOND. How MANY births happened is the attach delta's claim above; this | ||
| 428 | # one is that the table holds exactly the expected set. | ||
| 429 | wait_sessions "$SOCK67" 2 "web-restore: the table holds the default session and wghost, nothing else" | ||
| 430 | |||
| 431 | # ...and a shell the user ENDS stays ended. Every refusal closes the hub's | ||
| 432 | # connection (`server.dropObserver` closes the fd), so `exit` reaches the | ||
| 433 | # same refuse-after-redial shape the restore does: the daemon reaps the | ||
| 434 | # session, the hub redials, the page re-attaches on `up`, and the attach is | ||
| 435 | # refused because the session is gone. A hub that could not tell those two | ||
| 436 | # apart hands the user a NEW shell every time they type `exit` — and with a | ||
| 437 | # short-lived shell, one forked process per turn, forever. The CLI's answer | ||
| 438 | # is `wallview`'s: its pump ends on an exit_status and the tile stays dead. | ||
| 439 | # | ||
| 440 | # The re-attach is spelled out here because it is the browser's, not the | ||
| 441 | # hub's: mux.js sends it on `up`, and the fixture stands in for that. | ||
| 442 | set +e | ||
| 443 | timeout 60 "$WSCLIENT" --port "$WGPORT" --tile 0 --out "$OUT.wgws3" --err "$OUT.wgws3.err" <<'EOF' | ||
| 444 | expectstate up 25000 | ||
| 445 | attach 0 0 wghost | ||
| 446 | settle 500 20000 | ||
| 447 | send exit\n | ||
| 448 | expectups 2 30000 | ||
| 449 | attach 0 0 wghost | ||
| 450 | expectrefused 25000 | ||
| 451 | dumpexit | ||
| 452 | EOF | ||
| 453 | RC=$? | ||
| 454 | set -e | ||
| 455 | [ "$RC" -eq 0 ] || { | ||
| 456 | echo "e2e FAIL: web-restore: the exiting tile's wsclient exited $RC" | ||
| 457 | cat -v "$OUT.wgws3.err" 2>/dev/null; cat "$OUT.wgh"; exit 1; } | ||
| 458 | |||
| 459 | # Daemon truth for the same claim: the session the user ended is gone and | ||
| 460 | # stayed gone, and the daemon is back to the one session it started with. | ||
| 461 | timeout 20 "$MUX" a status --sock "$SOCK67" --session wghost > "$OUT.wgexit" 2>&1 && { | ||
| 462 | echo "e2e FAIL: web-restore: typing exit got the user a new shell:" | ||
| 463 | cat "$OUT.wgexit"; exit 1; } | ||
| 464 | wait_sessions "$SOCK67" 1 "web-restore: the ended session was not resurrected" | ||
| 465 | |||
| 466 | softkill "$W5PID" || true | ||
| 467 | wait_pid_gone "$W5PID" "web-restore: hub killed by tracked pid" | ||
| 468 | W5PID="" | ||
| 469 | assert_stopped "$SOCK67" "$D67PID" "web-restore" "$OUT.wgstop" | ||
| 470 | D67PID="" | ||
| 471 | rm -rf "$WGSTATE" | ||
| 472 | ok "the browser wall restores a saved local line too, and still joins a remote one only" | ||
| 473 | |||
| 474 | # --- a refusal the birth cannot fix must not spin ---------------------- | 264 | # --- a refusal the birth cannot fix must not spin ---------------------- |
| 475 | # | 265 | # |
| 476 | # The other end of the restore rule: a line the daemon will not attach and | 266 | # A browser attaching to a session that is not there — a page whose tile |
| 477 | # the hub may not create. `client.hydratedCreates` joins a remote spelling | 267 | # list is one poll stale, which is the only way to reach a refusal now that |
| 478 | # and never births it, so nothing the hub can do makes this attach land. | 268 | # the hub births nothing on a tile's behalf. Nothing the hub can do makes |
| 269 | # the attach land. | ||
| 479 | # Every refusal closes the connection (`server.dropObserver`), so the | 270 | # Every refusal closes the connection (`server.dropObserver`), so the |
| 480 | # re-dial that follows succeeds on its first try, the page re-attaches on | 271 | # re-dial that follows succeeds on its first try, the page re-attaches on |
| 481 | # `up` as it always does, and the same no comes back — a connect/attach/ | 272 | # `up` as it always does, and the same no comes back — a connect/attach/ |
| @@ -504,18 +295,25 @@ exec "$MUX" d proxy --sock "$SOCK68" | |||
| 504 | SPSHIM | 295 | SPSHIM |
| 505 | chmod +x "$SPDIR/ssh" | 296 | chmod +x "$SPDIR/ssh" |
| 506 | 297 | ||
| 507 | # One line, a HOST spelling, naming a session the daemon does not have: | 298 | # One HOST line — a daemon, never a session, which is the whole of what a |
| 508 | # the tile attaches at 0x0 (passivity), 0x0 cannot create, and a remote | 299 | # host line may say. Its tile is that daemon's own session `0`; the attach |
| 509 | # line may not be birthed. | 300 | # the stand-in then sends names `spinghost`, which the daemon does not |
| 301 | # have, and the hub forwards the refusal untouched. | ||
| 510 | mkdir -p "$SPSTATE/mux" | 302 | mkdir -p "$SPSTATE/mux" |
| 511 | printf 'mux-spin@127.0.0.1#spinghost\n' > "$SPSTATE/mux/wall" | 303 | printf 'mux-spin@127.0.0.1\n' > "$SPSTATE/mux/hosts" |
| 512 | 304 | ||
| 513 | XDG_STATE_HOME="$SPSTATE" XDG_CACHE_HOME="$SPSTATE/cache" PATH="$SPDIR:$PATH" \ | 305 | XDG_STATE_HOME="$SPSTATE" XDG_CACHE_HOME="$SPSTATE/cache" PATH="$SPDIR:$PATH" \ |
| 514 | "$MUX" web --port "$SPPORT" > "$OUT.sph" 2>&1 & | 306 | "$MUX" web --port "$SPPORT" > "$OUT.sph" 2>&1 & |
| 515 | W6PID=$! | 307 | W6PID=$! |
| 516 | defer_kill "$W6PID" | 308 | defer_kill "$W6PID" |
| 517 | wait_for "$OUT.sph" "serving" 10 || { | 309 | wait_for "$OUT.sph" "tile 0:" 15 || { |
| 518 | echo "e2e FAIL: refusal-spin: hub never reported serving"; cat "$OUT.sph"; exit 1; } | 310 | echo "e2e FAIL: refusal-spin: hub never announced a tile"; cat "$OUT.sph"; exit 1; } |
| 311 | # Two other things dial through this shim and land on the same log: the | ||
| 312 | # tile's own first dials, and the HOST POLLER, which redials every 10 s (a | ||
| 313 | # pipe link — `client.pollDelayMs`) for as long as the hub runs. The | ||
| 314 | # baseline takes the first out; the poller contributes at most one dial | ||
| 315 | # inside the 6 s window below, which the 3..15 bounds absorb. | ||
| 316 | SPBASE=$(wc -l < "$SPINLOG") | ||
| 519 | 317 | ||
| 520 | # `reattach` is mux.js's ENV_CONTROL handler, which is the half of the | 318 | # `reattach` is mux.js's ENV_CONTROL handler, which is the half of the |
| 521 | # loop the hub does not own: the page attaches again on every `up`. Six | 319 | # loop the hub does not own: the page attaches again on every `up`. Six |
| @@ -529,7 +327,7 @@ dumpexit | |||
| 529 | EOF | 327 | EOF |
| 530 | RC=$? | 328 | RC=$? |
| 531 | set -e | 329 | set -e |
| 532 | SPDIALS=$(wc -l < "$SPINLOG") | 330 | SPDIALS=$(( $(wc -l < "$SPINLOG") - SPBASE )) |
| 533 | [ "$RC" -eq 0 ] || { | 331 | [ "$RC" -eq 0 ] || { |
| 534 | echo "e2e FAIL: refusal-spin: the wsclient exited $RC after $SPDIALS dials" | 332 | echo "e2e FAIL: refusal-spin: the wsclient exited $RC after $SPDIALS dials" |
| 535 | cat -v "$OUT.spws.err" 2>/dev/null; cat "$OUT.sph"; exit 1; } | 333 | cat -v "$OUT.spws.err" 2>/dev/null; cat "$OUT.sph"; exit 1; } |
| @@ -549,7 +347,8 @@ SPDIALS=$(wc -l < "$SPINLOG") | |||
| 549 | cat "$OUT.sph"; exit 1; } | 347 | cat "$OUT.sph"; exit 1; } |
| 550 | 348 | ||
| 551 | # ...and it stayed refused: nothing was born behind the hub's back, which | 349 | # ...and it stayed refused: nothing was born behind the hub's back, which |
| 552 | # is what makes the count above a count of REFUSED dials. | 350 | # is what makes the count above a count of REFUSED dials. A hub that |
| 351 | # birthed for a tile would show it here. | ||
| 553 | timeout 20 "$MUX" a status --sock "$SOCK68" --session spinghost > "$OUT.spghost" 2>&1 && { | 352 | timeout 20 "$MUX" a status --sock "$SOCK68" --session spinghost > "$OUT.spghost" 2>&1 && { |
| 554 | echo "e2e FAIL: refusal-spin: a remote wall line created a session:" | 353 | echo "e2e FAIL: refusal-spin: a remote wall line created a session:" |
| 555 | cat "$OUT.spghost"; exit 1; } | 354 | cat "$OUT.spghost"; exit 1; } |