a73x

c8cf8113

fix: mux web refuses a port another hub already listens on

forge-4   2026-09-02 10:34

Commit message
fix: mux web refuses a port another hub already listens on

`std.net.Address.listen(.{ .reuse_address = true })` sets SO_REUSEPORT
alongside SO_REUSEADDR for every family but unix, and SO_REUSEPORT is
not the permissive-restart flag it reads as: it lets any number of
processes bind one address and port at once and has the kernel spread
incoming connections across all of them. Two `mux web` runs on 7681
therefore both bound and both announced themselves, and a browser
reached whichever the kernel handed the connection to — often a stale
hub's wall, with nothing on screen saying so. Measured on two real hubs
before and after: two LISTEN rows on the port and both processes alive,
against one row and the newcomer refused.

webhub.listenLocal takes the socket by hand with SO_REUSEADDR only, so
that flag keeps doing the one job it is for here — a hub restarted while
its predecessor's accepted connections sit in TIME_WAIT still gets its
port back — while a port a live listener holds comes back AddressInUse.
`mux web` answers that with `mux d`'s own refusal, one line and rc 1: a
port is to a hub what the socket path is to a daemon.

Pinned by a unit test that asks the descriptor for its two flags rather
than the call that set them, and by an e2e leg that starts a second real
hub on the live one's port and asks /proc/net/tcp — not either hub — how
many sockets LISTEN there.

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=""