mux web refuses a port another hub already listens on
7d4cf053 fix/webhub-refuse-taken-port → main
3 files changed
src/cli/webhub_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -143,10 +143,24 @@ pub fn main(args: []const [:0]const u8) !u8 { | |||
| 143 | var hub = try webhub.Hub.init(arena, specs); | 143 | var hub = try webhub.Hub.init(arena, specs); |
| 144 | defer hub.deinit(); | 144 | defer hub.deinit(); |
| 145 | 145 | ||
| 146 | const addr = std.net.Address.parseIp("127.0.0.1", parsed.port) catch unreachable; | 146 | var listener = webhub.listenLocal(parsed.port) catch |err| switch (err) { |
| 147 | var listener = addr.listen(.{ .reuse_address = true }) catch |err| { | 147 | // `mux d`'s refusal, in the hub's own words: a port another hub owns |
| 148 | std.debug.print("mux web: cannot bind 127.0.0.1:{d}: {s}\n", .{ parsed.port, @errorName(err) }); | 148 | // is not this hub's to take, and sharing it strands both — they each |
| 149 | return 1; | 149 | // keep serving a wall and a browser reaches whichever the kernel |
| 150 | // hands the connection to. Same one-liner and same rc as | ||
| 151 | // `mux d: a daemon is already running on PATH`, and knowingly the | ||
| 152 | // same wrong-ish message when the port belongs to some other | ||
| 153 | // program entirely: the advice is right either way, which is the | ||
| 154 | // trade decisions.md already records for the daemon's own | ||
| 155 | // AddressInUse. | ||
| 156 | error.AddressInUse => { | ||
| 157 | std.debug.print("mux web: a hub is already running on 127.0.0.1:{d} (--port N serves elsewhere)\n", .{parsed.port}); | ||
| 158 | return 1; | ||
| 159 | }, | ||
| 160 | else => { | ||
| 161 | std.debug.print("mux web: cannot bind 127.0.0.1:{d}: {s}\n", .{ parsed.port, @errorName(err) }); | ||
| 162 | return 1; | ||
| 163 | }, | ||
| 150 | }; | 164 | }; |
| 151 | defer listener.deinit(); | 165 | defer listener.deinit(); |
| 152 | 166 | ||
src/client/webhub.zig
| Old | New | ||
|---|---|---|---|
| @@ -12,6 +12,52 @@ const client = @import("client"); | |||
| 12 | 12 | ||
| 13 | pub const default_port: u16 = 7681; | 13 | pub const default_port: u16 = 7681; |
| 14 | 14 | ||
| 15 | /// Take 127.0.0.1:`port` for this hub, or hand back the kernel's refusal. A | ||
| 16 | /// port is to a hub what the socket path is to a daemon, so the rule is | ||
| 17 | /// `sockpath.claim`'s: never share an address a live peer already answers on. | ||
| 18 | /// Spelled here rather than left to `std.net.Address.listen` because that | ||
| 19 | /// function's one `reuse_address` bit sets SO_REUSEPORT alongside SO_REUSEADDR | ||
| 20 | /// for every family but unix, and SO_REUSEPORT is not the permissive-restart | ||
| 21 | /// flag its name suggests: it lets any number of processes bind one address | ||
| 22 | /// and port at once and has the kernel spread incoming connections across all | ||
| 23 | /// of them. Two `mux web` runs on 7681 therefore both bound, `ss` showed two | ||
| 24 | /// LISTEN rows, and a browser reached whichever the kernel picked — half the | ||
| 25 | /// tabs got a stale hub's wall, with nothing on screen saying so. | ||
| 26 | /// | ||
| 27 | /// SO_REUSEADDR alone stays on, for the one job it actually does here: a hub | ||
| 28 | /// restarted while the connections its predecessor accepted are still in | ||
| 29 | /// TIME_WAIT gets its port back instead of a spurious refusal. A port a LIVE | ||
| 30 | /// listener holds is `error.AddressInUse` with or without it — measured, not | ||
| 31 | /// assumed — and that error is what `mux web` refuses on. | ||
| 32 | /// | ||
| 33 | /// Port 0 asks the kernel for a free port. `mux web` rejects it at parse, | ||
| 34 | /// because the announced address would not be the bound one; the tests below | ||
| 35 | /// use it to take a port nothing else on the box owns. | ||
| 36 | pub fn listenLocal(port: u16) !std.net.Server { | ||
| 37 | const addr = std.net.Address.parseIp("127.0.0.1", port) catch unreachable; | ||
| 38 | const fd = try std.posix.socket( | ||
| 39 | addr.any.family, | ||
| 40 | std.posix.SOCK.STREAM | std.posix.SOCK.CLOEXEC, | ||
| 41 | std.posix.IPPROTO.TCP, | ||
| 42 | ); | ||
| 43 | var server: std.net.Server = .{ .listen_address = undefined, .stream = .{ .handle = fd } }; | ||
| 44 | errdefer server.stream.close(); | ||
| 45 | try std.posix.setsockopt( | ||
| 46 | fd, | ||
| 47 | std.posix.SOL.SOCKET, | ||
| 48 | std.posix.SO.REUSEADDR, | ||
| 49 | &std.mem.toBytes(@as(c_int, 1)), | ||
| 50 | ); | ||
| 51 | var len = addr.getOsSockLen(); | ||
| 52 | try std.posix.bind(fd, &addr.any, len); | ||
| 53 | try std.posix.listen(fd, 128); | ||
| 54 | // The address as BOUND, read back from the kernel: with port 0 it is not | ||
| 55 | // the one asked for, and `listen_address` is where a caller reads the | ||
| 56 | // port it actually got. | ||
| 57 | try std.posix.getsockname(fd, &server.listen_address.any, &len); | ||
| 58 | return server; | ||
| 59 | } | ||
| 60 | |||
| 15 | /// ONE number bounds two things, a property of std.http.Server: the connection | 61 | /// ONE number bounds two things, a property of std.http.Server: the connection |
| 16 | /// Reader's buffer is both the max HTTP header size and the max inbound | 62 | /// Reader's buffer is both the max HTTP header size and the max inbound |
| 17 | /// WebSocket message. 64 KiB — the browser chunks pastes at 32 KiB, so nothing | 63 | /// WebSocket message. 64 KiB — the browser chunks pastes at 32 KiB, so nothing |
| @@ -998,6 +1044,50 @@ test "origin: exactly our two spellings pass, everything else refuses" { | |||
| 998 | } | 1044 | } |
| 999 | } | 1045 | } |
| 1000 | 1046 | ||
| 1047 | test "listenLocal: a port a live hub holds is refused, and its flags are REUSEADDR without REUSEPORT" { | ||
| 1048 | // Port 0 for the first bind, so the port under test is one the kernel | ||
| 1049 | // just said was free rather than a number this file hopes nothing on the | ||
| 1050 | // box is using. The second bind then asks for that exact port: two hubs, | ||
| 1051 | // one `--port`, which is the bug this refusal exists for. | ||
| 1052 | const port = port: { | ||
| 1053 | var first = try listenLocal(0); | ||
| 1054 | defer first.deinit(); | ||
| 1055 | const p = first.listen_address.getPort(); | ||
| 1056 | try std.testing.expect(p != 0); | ||
| 1057 | try std.testing.expectError(error.AddressInUse, listenLocal(p)); | ||
| 1058 | |||
| 1059 | // Asked of the DESCRIPTOR, not read off the call that made it — the | ||
| 1060 | // two flags are the whole of this fix, and the call that used to set | ||
| 1061 | // them set both from one `reuse_address = true` (serve.zig's cloexec | ||
| 1062 | // test is the same shape, for the same reason: a default going | ||
| 1063 | // quietly wrong is invisible at the call site). | ||
| 1064 | var v: c_int = undefined; | ||
| 1065 | try std.posix.getsockopt( | ||
| 1066 | first.stream.handle, | ||
| 1067 | std.posix.SOL.SOCKET, | ||
| 1068 | std.posix.SO.REUSEADDR, | ||
| 1069 | std.mem.asBytes(&v), | ||
| 1070 | ); | ||
| 1071 | try std.testing.expect(v != 0); | ||
| 1072 | try std.posix.getsockopt( | ||
| 1073 | first.stream.handle, | ||
| 1074 | std.posix.SOL.SOCKET, | ||
| 1075 | std.posix.SO.REUSEPORT, | ||
| 1076 | std.mem.asBytes(&v), | ||
| 1077 | ); | ||
| 1078 | try std.testing.expectEqual(@as(c_int, 0), v); | ||
| 1079 | break :port p; | ||
| 1080 | }; | ||
| 1081 | |||
| 1082 | // The hub that held it is gone, so the port is takeable again: the | ||
| 1083 | // refusal is about a LIVE listener, not about the number having been | ||
| 1084 | // used once. Without SO_REUSEADDR this is where a restart would start | ||
| 1085 | // failing as soon as a browser had ever connected. | ||
| 1086 | var second = try listenLocal(port); | ||
| 1087 | defer second.deinit(); | ||
| 1088 | try std.testing.expectEqual(port, second.listen_address.getPort()); | ||
| 1089 | } | ||
| 1090 | |||
| 1001 | test "ws path by id: parses, no range opinion" { | 1091 | test "ws path by id: parses, no range opinion" { |
| 1002 | try std.testing.expectEqual(@as(?u32, 0), wsTileId("/ws/0")); | 1092 | try std.testing.expectEqual(@as(?u32, 0), wsTileId("/ws/0")); |
| 1003 | try std.testing.expectEqual(@as(?u32, 41), wsTileId("/ws/41")); | 1093 | try std.testing.expectEqual(@as(?u32, 41), wsTileId("/ws/41")); |
test/e2e_06_web.sh
| Old | New | ||
|---|---|---|---|
| @@ -496,6 +496,40 @@ sleep 3 | |||
| 496 | echo "e2e FAIL: host wall: B's tile did not heal onto its own id $DWID_B0:" | 496 | echo "e2e FAIL: host wall: B's tile did not heal onto its own id $DWID_B0:" |
| 497 | curl -s "$DWORIG/tiles"; exit 1; } | 497 | curl -s "$DWORIG/tiles"; exit 1; } |
| 498 | 498 | ||
| 499 | # A port a live hub owns is refused, in `mux d`'s words. Asked of a REAL | ||
| 500 | # second process while the first is serving, because the bug was one the | ||
| 501 | # kernel allowed: SO_REUSEPORT let both bind, `ss` showed two LISTEN rows, | ||
| 502 | # and a browser reached whichever the kernel handed the connection to. | ||
| 503 | set +e | ||
| 504 | # Under timeout, like the QUIC edition in e2e_01_boot.sh and for the same | ||
| 505 | # reason: a second hub that WINS the port serves until killed, so the | ||
| 506 | # regression has to show up as 124 rather than as a hung suite. | ||
| 507 | XDG_STATE_HOME="$DWSTATE" timeout 10 "$MUX" web --port "$WPORT4" > "$OUT.dwtaken" 2>&1 | ||
| 508 | RC=$? | ||
| 509 | set -e | ||
| 510 | [ "$RC" -eq 1 ] || { | ||
| 511 | echo "e2e FAIL: host wall: a second hub on $WPORT4 exited $RC (want 1; 124 means it bound and served)" | ||
| 512 | cat "$OUT.dwtaken"; exit 1; } | ||
| 513 | grep -q "already running" "$OUT.dwtaken" || { | ||
| 514 | echo "e2e FAIL: host wall: the taken-port refusal did not say already running:" | ||
| 515 | cat "$OUT.dwtaken"; exit 1; } | ||
| 516 | grep -q "serving" "$OUT.dwtaken" && { | ||
| 517 | echo "e2e FAIL: host wall: the second hub announced itself on a taken port:" | ||
| 518 | cat "$OUT.dwtaken"; exit 1; } | ||
| 519 | # Asked of the OS, not of either hub: exactly one socket LISTENs there. | ||
| 520 | # /proc/net/tcp rather than ss or lsof, for e2e_01_boot.sh's reason — always | ||
| 521 | # present, no privileges — with 0A the LISTEN state and 0100007F the | ||
| 522 | # little-endian hex the file spells 127.0.0.1 in. | ||
| 523 | WHEX=$(printf '0100007F:%04X' "$WPORT4") | ||
| 524 | LISTENERS=$(awk -v a="$WHEX" '$2 == a && $4 == "0A" { n++ } END { print n+0 }' /proc/net/tcp) | ||
| 525 | [ "$LISTENERS" -eq 1 ] || { | ||
| 526 | echo "e2e FAIL: host wall: $LISTENERS listeners on $WPORT4, want 1:" | ||
| 527 | grep -i "$WHEX" /proc/net/tcp || true; exit 1; } | ||
| 528 | # ...and the survivor is the first hub, still serving the wall it built: | ||
| 529 | # the refusal happens in the newcomer, and the running hub never hears it. | ||
| 530 | [ -n "$(curl -s "$DWORIG/tiles")" ] || { | ||
| 531 | echo "e2e FAIL: host wall: the refused hub disturbed the live one"; exit 1; } | ||
| 532 | |||
| 499 | softkill "$W4PID" || true | 533 | softkill "$W4PID" || true |
| 500 | wait_pid_gone "$W4PID" "host wall: hub killed by tracked pid" | 534 | wait_pid_gone "$W4PID" "host wall: hub killed by tracked pid" |
| 501 | W4PID="" | 535 | W4PID="" |