Skip to content

feat(listener): bind a TCP listener on port 0 and report what it got - #302

Merged
EdmondDantes merged 2 commits into
mainfrom
listener-port-zero
Aug 25, 2026
Merged

EdmondDantes merged 2 commits into
mainfrom
listener-port-zero

Conversation

@EdmondDantes

Copy link
Copy Markdown
Contributor

A caller that names a free port before binding it leaves a window in which the port belongs to nobody. The phpt suite picks ports that way, and under -j4 it loses the race: core/015 starts a second server after the first has run its whole lifecycle, and start() answers Failed to acquire TCP listener for 127.0.0.1:58864 (bind). Over thirty tests take ports from that helper, so narrowing the window only makes the failure rarer.

Port 0 removes it. addListener(), addHttp1Listener() and addHttp2Listener() accept 0, the kernel assigns at bind time, and HttpServer::getBoundListeners() reports what the server holds — one entry per configured listener, in configuration order, empty while the server is not running. A TCP entry carries host, port and tls; a UNIX entry carries path and no port key at all, so an absent answer cannot be mistaken for a number.

The answer lives on the server rather than in the config: the point of port 0 is that one config can be handed to a second server, and a config that recorded the first server's port would send the second one to bind it — the very bug this fixes. A restarted server rebinds and reports the new port for the same reason.

Across threads. Such a listener is bound once into the shared set and every thread adopts a duplicate, SO_REUSEPORT or not — a per-thread bind would give each thread a different port. The set keys its entries on the listener's position in the configuration instead of on host and port, since two listeners asking for 0 are indistinguishable until the kernel answers, and it records the bound port beside the requested one: a pool parent builds no listen event of its own, so the set holds its only answer. Two listeners naming the same host:port still share one socket, as before.

HTTP/3 keeps requiring an explicit port. It binds through ZEND_ASYNC_UDP_BIND, which reports no local address, so 0 is refused there rather than answered with the number that was asked for.

An old expectation changed. core/032-config-validation asserted that port 0 throws on the three TCP adders — that is the behaviour this changes. It now expects acceptance for TCP and a throw for H3, and a -1 case keeps the lower bound covered.

Evidence: the extension builds clean on Windows (MSVC, /W4, no new warnings); core/078-listener-port-zero covers a single server and a two-worker pool, each serving a request on the assigned port, and passes 5 of 5 runs; the Windows suite reads 380 tests, 169 passed, 0 warned, 0 failed, 211 skipped.

Open, and named rather than hidden: the arginfo header was hand-edited because gen_stub.php needs the tokenizer extension, which this build lacks — a reviewer regenerating it should get a byte-identical file.

Naming a free port before binding it leaves a window in which the port
belongs to nobody, and a parallel suite walks into it: a second server's
start() answers `Failed to acquire TCP listener for 127.0.0.1:58864
(bind)`. Port 0 removes the window instead of narrowing it — the kernel
assigns at bind time — and HttpServer::getBoundListeners() is where the
assignment becomes readable: one entry per configured listener, in
configuration order, empty while the server is not running.

Across threads such a listener is bound once into the shared set and
every thread adopts a duplicate, SO_REUSEPORT or not; binding per thread
would hand each a different port. The set now keys its entries on the
listener's position in the configuration rather than on host and port,
because two listeners asking for 0 look alike until the kernel answers,
and it records the bound port beside the requested one — a pool parent
builds no listen event of its own, so the set holds its only answer.

HTTP/3 keeps requiring an explicit port: it binds through the UDP path,
which reports no local address, so 0 is refused there rather than
answered with a guess.

`core/032-config-validation` asserted that 0 throws on the three TCP
adders. That expectation is the behaviour this changes; it now expects
acceptance for TCP, a throw for H3, and covers -1 for the lower bound.

Evidence: `core/078-listener-port-zero` covers the single server and a
two-worker pool, both serving a request on the assigned port, and passes
5 of 5 runs; the Windows suite reads 380 tests, 169 passed, 0 warned,
0 failed, 211 skipped.
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Coverage

Total lines: 82.98% → 83.13% (+0.15 pp)

File Baseline Current Δ Touched
src/core/http_connection_tls.c 73.83% 73.55% -0.28 pp
src/http3/http3_callbacks.c 83.44% 84.18% +0.74 pp
src/http3/http3_listener.c 77.36% 76.76% -0.60 pp
src/http3/http3_packet.c 90.43% 85.22% -5.22 pp
src/http_server_class.c 74.09% 75.97% +1.88 pp
src/http_server_config.c 92.48% 92.58% +0.10 pp
src/websocket/ws_session.c 90.41% 89.67% -0.74 pp

@EdmondDantes
EdmondDantes merged commit 81c0437 into main Aug 25, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant