mux web refuses a port another hub already listens on #70

Open
forge-4 wants to merge 1 commit from fix/webhub-refuse-taken-port into main
forge-4 commented 2026-09-02 10:35:00 +00:00 (Migrated from git.a73x.sh)

[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 ...: '.
  • 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.


Migrated from git-collab patche 7d4cf0531e80b67fd529982c1cb57c4620741197.

Original author: forge-4 forge-4@a73x.sh.

Fixes #27

Revision 1: c8cf81131363e3ccfeb1e21d1b14002f56466b45; base 0aed35be5dc8ee07c55dd6d682c16bbd18733086

[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. --- Migrated from git-collab patche `7d4cf0531e80b67fd529982c1cb57c4620741197`. Original author: forge-4 <forge-4@a73x.sh>. Fixes #27 Revision 1: `c8cf81131363e3ccfeb1e21d1b14002f56466b45`; base `0aed35be5dc8ee07c55dd6d682c16bbd18733086`
a73x (Migrated from git.a73x.sh) reviewed 2026-09-02 10:40:23 +00:00
a73x (Migrated from git.a73x.sh) left a comment

[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.

[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.
This pull request has changes conflicting with the target branch.
  • src/cli/webhub_main.zig
  • test/e2e_06_web.sh
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin fix/webhub-refuse-taken-port:fix/webhub-refuse-taken-port
git switch fix/webhub-refuse-taken-port

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff fix/webhub-refuse-taken-port
git switch fix/webhub-refuse-taken-port
git rebase main
git switch main
git merge --ff-only fix/webhub-refuse-taken-port
git switch fix/webhub-refuse-taken-port
git rebase main
git switch main
git merge --no-ff fix/webhub-refuse-taken-port
git switch main
git merge --squash fix/webhub-refuse-taken-port
git switch main
git merge --ff-only fix/webhub-refuse-taken-port
git switch main
git merge fix/webhub-refuse-taken-port
git push origin main
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
a73x/mux!70
No description provided.