28780537
fix: mux web refuses a port another hub already listens on
forge-4 2026-09-04 13:22
Commit message
src/cli/webhub_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -163,10 +163,24 @@ pub fn main(args: []const [:0]const u8) !u8 { | |||
| 163 | defer hub.deinit(); | 163 | defer hub.deinit(); |
| 164 | hub.layout_path = layout_path; | 164 | hub.layout_path = layout_path; |
| 165 | 165 | ||
| 166 | const addr = std.net.Address.parseIp("127.0.0.1", parsed.port) catch unreachable; | 166 | var listener = webhub.listenLocal(parsed.port) catch |err| switch (err) { |
| 167 | var listener = addr.listen(.{ .reuse_address = true }) catch |err| { | 167 | // `mux d`'s refusal, in the hub's own words: a port another hub owns |
| 168 | std.debug.print("mux web: cannot bind 127.0.0.1:{d}: {s}\n", .{ parsed.port, @errorName(err) }); | 168 | // is not this hub's to take, and sharing it strands both — they each |
| 169 | return 1; | 169 | // keep serving a wall and a browser reaches whichever the kernel |
| 170 | // hands the connection to. Same one-liner and same rc as | ||
| 171 | // `mux d: a daemon is already running on PATH`, and knowingly the | ||
| 172 | // same wrong-ish message when the port belongs to some other | ||
| 173 | // program entirely: the advice is right either way, which is the | ||
| 174 | // trade decisions.md already records for the daemon's own | ||
| 175 | // AddressInUse. | ||
| 176 | error.AddressInUse => { | ||
| 177 | std.debug.print("mux web: a hub is already running on 127.0.0.1:{d} (--port N serves elsewhere)\n", .{parsed.port}); | ||
| 178 | return 1; | ||
| 179 | }, | ||
| 180 | else => { | ||
| 181 | std.debug.print("mux web: cannot bind 127.0.0.1:{d}: {s}\n", .{ parsed.port, @errorName(err) }); | ||
| 182 | return 1; | ||
| 183 | }, | ||
| 170 | }; | 184 | }; |
| 171 | defer listener.deinit(); | 185 | defer listener.deinit(); |
| 172 | 186 | ||
src/client/webhub.zig
| Old | New | ||
|---|---|---|---|
| @@ -17,6 +17,52 @@ const hosts = client.hosts; | |||
| 17 | 17 | ||
| 18 | pub const default_port: u16 = 7681; | 18 | pub const default_port: u16 = 7681; |
| 19 | 19 | ||
| 20 | /// Take 127.0.0.1:`port` for this hub, or hand back the kernel's refusal. A | ||
| 21 | /// port is to a hub what the socket path is to a daemon, so the rule is | ||
| 22 | /// `sockpath.claim`'s: never share an address a live peer already answers on. | ||
| 23 | /// Spelled here rather than left to `std.net.Address.listen` because that | ||
| 24 | /// function's one `reuse_address` bit sets SO_REUSEPORT alongside SO_REUSEADDR | ||
| 25 | /// for every family but unix, and SO_REUSEPORT is not the permissive-restart | ||
| 26 | /// flag its name suggests: it lets any number of processes bind one address | ||
| 27 | /// and port at once and has the kernel spread incoming connections across all | ||
| 28 | /// of them. Two `mux web` runs on 7681 therefore both bound, `ss` showed two | ||
| 29 | /// LISTEN rows, and a browser reached whichever the kernel picked — half the | ||
| 30 | /// tabs got a stale hub's wall, with nothing on screen saying so. | ||
| 31 | /// | ||
| 32 | /// SO_REUSEADDR alone stays on, for the one job it actually does here: a hub | ||
| 33 | /// restarted while the connections its predecessor accepted are still in | ||
| 34 | /// TIME_WAIT gets its port back instead of a spurious refusal. A port a LIVE | ||
| 35 | /// listener holds is `error.AddressInUse` with or without it — measured, not | ||
| 36 | /// assumed — and that error is what `mux web` refuses on. | ||
| 37 | /// | ||
| 38 | /// Port 0 asks the kernel for a free port. `mux web` rejects it at parse, | ||
| 39 | /// because the announced address would not be the bound one; the tests below | ||
| 40 | /// use it to take a port nothing else on the box owns. | ||
| 41 | pub fn listenLocal(port: u16) !std.net.Server { | ||
| 42 | const addr = std.net.Address.parseIp("127.0.0.1", port) catch unreachable; | ||
| 43 | const fd = try std.posix.socket( | ||
| 44 | addr.any.family, | ||
| 45 | std.posix.SOCK.STREAM | std.posix.SOCK.CLOEXEC, | ||
| 46 | std.posix.IPPROTO.TCP, | ||
| 47 | ); | ||
| 48 | var server: std.net.Server = .{ .listen_address = undefined, .stream = .{ .handle = fd } }; | ||
| 49 | errdefer server.stream.close(); | ||
| 50 | try std.posix.setsockopt( | ||
| 51 | fd, | ||
| 52 | std.posix.SOL.SOCKET, | ||
| 53 | std.posix.SO.REUSEADDR, | ||
| 54 | &std.mem.toBytes(@as(c_int, 1)), | ||
| 55 | ); | ||
| 56 | var len = addr.getOsSockLen(); | ||
| 57 | try std.posix.bind(fd, &addr.any, len); | ||
| 58 | try std.posix.listen(fd, 128); | ||
| 59 | // The address as BOUND, read back from the kernel: with port 0 it is not | ||
| 60 | // the one asked for, and `listen_address` is where a caller reads the | ||
| 61 | // port it actually got. | ||
| 62 | try std.posix.getsockname(fd, &server.listen_address.any, &len); | ||
| 63 | return server; | ||
| 64 | } | ||
| 65 | |||
| 20 | /// ONE number bounds two things, a property of std.http.Server: the connection | 66 | /// ONE number bounds two things, a property of std.http.Server: the connection |
| 21 | /// Reader's buffer is both the max HTTP header size and the max inbound | 67 | /// Reader's buffer is both the max HTTP header size and the max inbound |
| 22 | /// WebSocket message. 64 KiB — the browser chunks pastes at 32 KiB, so nothing | 68 | /// WebSocket message. 64 KiB — the browser chunks pastes at 32 KiB, so nothing |
| @@ -1218,6 +1264,50 @@ test "origin: exactly our two spellings pass, everything else refuses" { | |||
| 1218 | } | 1264 | } |
| 1219 | } | 1265 | } |
| 1220 | 1266 | ||
| 1267 | test "listenLocal: a port a live hub holds is refused, and its flags are REUSEADDR without REUSEPORT" { | ||
| 1268 | // Port 0 for the first bind, so the port under test is one the kernel | ||
| 1269 | // just said was free rather than a number this file hopes nothing on the | ||
| 1270 | // box is using. The second bind then asks for that exact port: two hubs, | ||
| 1271 | // one `--port`, which is the bug this refusal exists for. | ||
| 1272 | const port = port: { | ||
| 1273 | var first = try listenLocal(0); | ||
| 1274 | defer first.deinit(); | ||
| 1275 | const p = first.listen_address.getPort(); | ||
| 1276 | try std.testing.expect(p != 0); | ||
| 1277 | try std.testing.expectError(error.AddressInUse, listenLocal(p)); | ||
| 1278 | |||
| 1279 | // Asked of the DESCRIPTOR, not read off the call that made it — the | ||
| 1280 | // two flags are the whole of this fix, and the call that used to set | ||
| 1281 | // them set both from one `reuse_address = true` (serve.zig's cloexec | ||
| 1282 | // test is the same shape, for the same reason: a default going | ||
| 1283 | // quietly wrong is invisible at the call site). | ||
| 1284 | var v: c_int = undefined; | ||
| 1285 | try std.posix.getsockopt( | ||
| 1286 | first.stream.handle, | ||
| 1287 | std.posix.SOL.SOCKET, | ||
| 1288 | std.posix.SO.REUSEADDR, | ||
| 1289 | std.mem.asBytes(&v), | ||
| 1290 | ); | ||
| 1291 | try std.testing.expect(v != 0); | ||
| 1292 | try std.posix.getsockopt( | ||
| 1293 | first.stream.handle, | ||
| 1294 | std.posix.SOL.SOCKET, | ||
| 1295 | std.posix.SO.REUSEPORT, | ||
| 1296 | std.mem.asBytes(&v), | ||
| 1297 | ); | ||
| 1298 | try std.testing.expectEqual(@as(c_int, 0), v); | ||
| 1299 | break :port p; | ||
| 1300 | }; | ||
| 1301 | |||
| 1302 | // The hub that held it is gone, so the port is takeable again: the | ||
| 1303 | // refusal is about a LIVE listener, not about the number having been | ||
| 1304 | // used once. Without SO_REUSEADDR this is where a restart would start | ||
| 1305 | // failing as soon as a browser had ever connected. | ||
| 1306 | var second = try listenLocal(port); | ||
| 1307 | defer second.deinit(); | ||
| 1308 | try std.testing.expectEqual(port, second.listen_address.getPort()); | ||
| 1309 | } | ||
| 1310 | |||
| 1221 | test "ws path by id: parses, no range opinion" { | 1311 | test "ws path by id: parses, no range opinion" { |
| 1222 | try std.testing.expectEqual(@as(?u32, 0), wsTileId("/ws/0")); | 1312 | try std.testing.expectEqual(@as(?u32, 0), wsTileId("/ws/0")); |
| 1223 | try std.testing.expectEqual(@as(?u32, 41), wsTileId("/ws/41")); | 1313 | try std.testing.expectEqual(@as(?u32, 41), wsTileId("/ws/41")); |
test/e2e_06_web.sh
| Old | New | ||
|---|---|---|---|
| @@ -609,6 +609,40 @@ sleep 3 | |||
| 609 | echo "e2e FAIL: host wall: B's tile did not heal onto its own id $DWID_B0:" | 609 | echo "e2e FAIL: host wall: B's tile did not heal onto its own id $DWID_B0:" |
| 610 | curl -s "$DWORIG/tiles"; exit 1; } | 610 | curl -s "$DWORIG/tiles"; exit 1; } |
| 611 | 611 | ||
| 612 | # A port a live hub owns is refused, in `mux d`'s words. Asked of a REAL | ||
| 613 | # second process while the first is serving, because the bug was one the | ||
| 614 | # kernel allowed: SO_REUSEPORT let both bind, `ss` showed two LISTEN rows, | ||
| 615 | # and a browser reached whichever the kernel handed the connection to. | ||
| 616 | set +e | ||
| 617 | # Under timeout, like the QUIC edition in e2e_01_boot.sh and for the same | ||
| 618 | # reason: a second hub that WINS the port serves until killed, so the | ||
| 619 | # regression has to show up as 124 rather than as a hung suite. | ||
| 620 | XDG_STATE_HOME="$DWSTATE" timeout 10 "$MUX" web --port "$WPORT4" > "$OUT.dwtaken" 2>&1 | ||
| 621 | RC=$? | ||
| 622 | set -e | ||
| 623 | [ "$RC" -eq 1 ] || { | ||
| 624 | echo "e2e FAIL: host wall: a second hub on $WPORT4 exited $RC (want 1; 124 means it bound and served)" | ||
| 625 | cat "$OUT.dwtaken"; exit 1; } | ||
| 626 | grep -q "already running" "$OUT.dwtaken" || { | ||
| 627 | echo "e2e FAIL: host wall: the taken-port refusal did not say already running:" | ||
| 628 | cat "$OUT.dwtaken"; exit 1; } | ||
| 629 | grep -q "serving" "$OUT.dwtaken" && { | ||
| 630 | echo "e2e FAIL: host wall: the second hub announced itself on a taken port:" | ||
| 631 | cat "$OUT.dwtaken"; exit 1; } | ||
| 632 | # Asked of the OS, not of either hub: exactly one socket LISTENs there. | ||
| 633 | # /proc/net/tcp rather than ss or lsof, for e2e_01_boot.sh's reason — always | ||
| 634 | # present, no privileges — with 0A the LISTEN state and 0100007F the | ||
| 635 | # little-endian hex the file spells 127.0.0.1 in. | ||
| 636 | WHEX=$(printf '0100007F:%04X' "$WPORT4") | ||
| 637 | LISTENERS=$(awk -v a="$WHEX" '$2 == a && $4 == "0A" { n++ } END { print n+0 }' /proc/net/tcp) | ||
| 638 | [ "$LISTENERS" -eq 1 ] || { | ||
| 639 | echo "e2e FAIL: host wall: $LISTENERS listeners on $WPORT4, want 1:" | ||
| 640 | grep -i "$WHEX" /proc/net/tcp || true; exit 1; } | ||
| 641 | # ...and the survivor is the first hub, still serving the wall it built: | ||
| 642 | # the refusal happens in the newcomer, and the running hub never hears it. | ||
| 643 | [ -n "$(curl -s "$DWORIG/tiles")" ] || { | ||
| 644 | echo "e2e FAIL: host wall: the refused hub disturbed the live one"; exit 1; } | ||
| 645 | |||
| 612 | softkill "$W4PID" || true | 646 | softkill "$W4PID" || true |
| 613 | wait_pid_gone "$W4PID" "host wall: hub killed by tracked pid" | 647 | wait_pid_gone "$W4PID" "host wall: hub killed by tracked pid" |
| 614 | W4PID="" | 648 | W4PID="" |