a73x

mux web refuses a port another hub already listens on

open   by forge-4   fix/webhub-refuse-taken-port → main

Files changed

[claude 2026-09-02] Branch fix/webhub-refuse-taken-port, one commit on top of 0aed35b.

The mechanism was SO_REUSEPORT, not SO_REUSEADDR. Zig's
std.net.Address.listen sets both when reuse_address is true for every
family but unix (deps/zig/lib/std/net.zig:279-294), and SO_REUSEPORT is
the option that lets several processes hold one addr:port at once while
the kernel spreads new connections across them. Two plain SO_REUSEADDR
binds of the same 127.0.0.1:P are refused EADDRINUSE on this box, so the
sharing was entirely the REUSEPORT half.

Reproduced and then re-measured with two real hubs on one port:

  before (main):  both printed 'serving http://127.0.0.1:P', both alive,
                  2 LISTEN rows for P in /proc/net/tcp
  after:          first serves, second exits 1 with
                  'mux web: a hub is already running on 127.0.0.1:P
                  (--port N serves elsewhere)', 1 LISTEN row, and the
                  first hub still answers /tiles

The change:

- src/client/webhub.zig: new listenLocal(port) binds the socket by hand
  -- socket + SO_REUSEADDR + bind + listen + getsockname, wrapped in the
  same std.net.Server the accept loop already used. SO_REUSEADDR stays
  on deliberately and only for what it is for: a hub restarted while its
  predecessor's accepted connections are in TIME_WAIT gets its port back
  rather than a spurious refusal. A port a LIVE listener holds is
  AddressInUse either way.
- src/cli/webhub_main.zig: AddressInUse gets muxd's refusal in the hub's
  words -- one line, rc 1, the shape of 'mux d: a daemon is already
  running on PATH'. Knowingly the same wrong-ish message when some other
  program owns the port, which is the trade docs/decisions.md already
  records for the daemon's own AddressInUse; the advice is right either
  way, and --port N is named in the line. Every other bind error keeps
  the old 'cannot bind ...: <errname>'.
- src/client/webhub.zig tests: the second bind of a port a live listener
  holds is refused, the descriptor is asked for its own two flags rather
  than the call that set them (serve.zig's cloexec test is the same
  shape), and the port is takeable again once that listener is gone.
- test/e2e_06_web.sh: a leg that starts a second REAL hub on the live
  hub's port while it is serving -- rc 1, 'already running', no
  'serving', exactly one LISTEN row read from /proc/net/tcp rather than
  from either hub, and the first hub still serving its wall. Under
  `timeout` for e2e_01_boot.sh's reason: a regressed second hub would
  serve until killed, so it has to show up as 124 rather than as a hung
  suite.

Gate, honestly: `make check` FAILS in this VM, and it fails identically
on unmodified main -- 'server_test_upgrade.test.Server: an upgrade asked
for during a session's hangup is refused, not attempted', with the
allocation leak that follows it. Verified by stashing this diff and
re-running: main gives 1114/1116 tests passed with that one failure,
this branch gives 1115/1117 with the same one failure -- the added test
passes and nothing else moved. It looks timing-bound (Pty.term_grace_ms
is 500 ms and the test writes a candidate file between the end_reply and
the upgrade_req); under -j2 a second timing test,
'main.test.waitPidGone', joins it. Nothing in this diff touches those
paths. The e2e suite could not be run here at all: it hard-requires nvim
and this VM has none, so the new leg was validated by running the same
assertions by hand against the built binary, with an isolated
XDG_STATE_HOME and XDG_RUNTIME_DIR.

Revisions

# Commit Date Notes
1 c8cf8113 2026-09-02 10:35

Reviews

a73x   comment   2026-09-02 10:40   rev 1

[claude 2026-09-02] Checkpoint review of the guest's patch (forge-4, software-factory trial 2). Diff read in full. Root cause holds: std.net.Address.listen sets SO_REUSEPORT from the one reuse_address bit, and REUSEPORT is what let two hubs bind. listenLocal keeps REUSEADDR only; the unit test asks the descriptor for both flags and proves the second bind refuses and the port is takeable once the first listener is gone. The port-0 claim in the doc comment is true (webhub_main returns error.Usage on port 0). The e2e leg counts LISTEN rows from /proc/net/tcp and checks the survivor still serves.

`make check` on this box: exit 0 on the branch. The single failure the guest reported (server_test_upgrade hangup case + leak) did not reproduce here; it is the known slow-host flake, not this diff.

Branch is 1 behind local main (6932ec23, unpushed). Approve/merge is the human's call.