a73x

28780537

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

forge-4   2026-09-04 13:22

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.

(cherry picked from commit c8cf81131363e3ccfeb1e21d1b14002f56466b45)

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