a73x

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
test: the e2e wall leg is two daemons, live sessions and a browser that authors nothing

The M-wall leg is rewritten, not patched: two daemons and an off-origin
session, the hosts file written by argv, tiles polled into being, `+`
births a session the daemon names, and POST/PUT/DELETE answer 405. Ids
are read out of /tiles rather than assumed — one poller thread per host
races the other, so birth order is not the leg to predict.

Two real bugs the leg found: a bodyless POST (what `curl -X POST` sends)
tripped std discardBody assert and ABORTED the hub, every tile with it;
and legs (b) and (c) shared a state home, so each hub served every other
leg daemons. The web-restore leg goes with its subject — a saved line
that births a session — and refusal-spin now rides a host line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017wi2HnuF1EK8HgViU11YLV

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; }