From e57c526d4e800e8054907d264d36ab57c1070593 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:50:13 +0300 Subject: [PATCH 1/2] feat(listener): bind a TCP listener on port 0 and report what it got MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 4 + src/http_server_class.c | 170 ++++++++++++++++-- src/http_server_config.c | 31 ++-- stubs/HttpServer.php | 17 ++ stubs/HttpServer.php_arginfo.h | 4 + .../server/core/032-config-validation.phpt | 13 +- .../server/core/078-listener-port-zero.phpt | 158 ++++++++++++++++ 7 files changed, 372 insertions(+), 25 deletions(-) create mode 100644 tests/phpt/server/core/078-listener-port-zero.phpt diff --git a/CHANGELOG.md b/CHANGELOG.md index 963004c..8c0f707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **A TCP listener can ask the kernel for a port, and say which one it got.** `addListener()`, `addHttp1Listener()` and `addHttp2Listener()` take port 0, which binds to whatever the kernel assigns, and `HttpServer::getBoundListeners()` reports what the server actually holds — one entry per configured listener, in configuration order, empty while the server is not running. Naming a free port before binding it leaves a window in which the port belongs to nobody: the phpt suite picks ports that way and loses the race under `-j4`, where a second server's `start()` answers `Failed to acquire TCP listener for 127.0.0.1:58864 (bind)`. Port 0 removes the window rather than narrowing it. Across several 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. HTTP/3 still requires 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. + ### Security - **`sendFile()` and `StaticHandler` walked around `open_basedir` (#280).** The server opens response files itself rather than through PHP streams, and never consulted the interpreter's policy, so a handler could hand out any file the process could read: with `open_basedir` set, `fopen()` refused the path and `sendFile()` answered `200 OK` with its contents. `open_basedir` is the operator's boundary, and the handler is exactly the code it exists to contain — the trust `sendFile()` places in handler code is a contract with the application, not a licence to overrule the operator. All three entry points now call `php_check_open_basedir_ex()`: `sendFile()` throws, the send-file engine answers 500, and a mount rooted outside the boundary is refused at construction. Scope: the mount root is validated once, at construction, so narrowing `open_basedir` at runtime below an existing mount does not retroactively close it; `sendFile()` is checked per call. Evidence: `sendfile/007-sendfile-open-basedir` serves the running binary against `main` and answers 500 here. diff --git a/src/http_server_class.c b/src/http_server_class.c index 9d61afa..8cd8764 100644 --- a/src/http_server_class.c +++ b/src/http_server_class.c @@ -159,11 +159,15 @@ typedef struct { } http_pool_unix_fd_t; /* A TCP listener bound once and shared the same way, for every platform - * whose kernel has no load-balanced SO_REUSEPORT. host/port identify the - * entry so a thread can match its config against what is already bound. */ + * whose kernel has no load-balanced SO_REUSEPORT. The entry is identified by + * the listener's position in the configuration, which every thread walks in + * the same order: host and port cannot identify it, because two listeners + * asking for port 0 look alike until the kernel has answered. */ typedef struct { char host[64]; - int port; + int requested_port; /* what the configuration asked for; 0 means "the kernel picks" */ + int bound_port; /* what the socket is bound to — the answer for a requested 0 */ + size_t listener_index; zend_socket_t fd; } http_pool_tcp_fd_t; @@ -2561,8 +2565,57 @@ static int http_server_prebind_unix(http_server_object *server) * * error_out receives a short reason for the caller to phrase; the lock is * never held across a throw. */ +/* The port under a bound socket. `fallback` is answered when the address + * cannot be read, which leaves the caller with what it asked for rather than + * with a zero that reads like a valid answer. */ +static int http_socket_local_port(zend_socket_t fd, int fallback) +{ + struct sockaddr_storage addr; + socklen_t len = sizeof(addr); + + if (getsockname(fd, (struct sockaddr *) &addr, &len) != 0) { + return fallback; + } + + if (addr.ss_family == AF_INET) { + return ntohs(((struct sockaddr_in *) &addr)->sin_port); + } + + if (addr.ss_family == AF_INET6) { + return ntohs(((struct sockaddr_in6 *) &addr)->sin6_port); + } + + return fallback; +} + +/* The port the shared set holds for one listener, or 0 when the set has no + * entry for it. A pool parent binds through the set and never builds a listen + * event of its own, so this is the only place its answer lives. */ +static int http_listen_set_bound_port(http_listen_set_t *set, size_t listener_index) +{ + if (set == NULL) { + return 0; + } + + int port = 0; + + tsrm_mutex_lock(set->lock); + + for (size_t i = 0; i < set->tcp_count; i++) { + if (set->tcp[i].listener_index == listener_index) { + port = set->tcp[i].bound_port; + break; + } + } + + tsrm_mutex_unlock(set->lock); + + return port; +} + static zend_socket_t http_listen_set_acquire_tcp(http_listen_set_t *set, const char *host, int port, + size_t listener_index, int backlog, const char **error_out) { @@ -2574,7 +2627,11 @@ static zend_socket_t http_listen_set_acquire_tcp(http_listen_set_t *set, bool found = false; for (size_t i = 0; i < set->tcp_count; i++) { - if (set->tcp[i].port == port && strcmp(set->tcp[i].host, host) == 0) { + const bool same_listener = set->tcp[i].listener_index == listener_index; + const bool same_address = port != 0 && set->tcp[i].requested_port == port + && strcmp(set->tcp[i].host, host) == 0; + + if (same_listener || same_address) { master = set->tcp[i].fd; found = true; break; @@ -2639,9 +2696,11 @@ static zend_socket_t http_listen_set_acquire_tcp(http_listen_set_t *set, if (host_len >= sizeof(slot->host)) host_len = sizeof(slot->host) - 1; memcpy(slot->host, host, host_len); slot->host[host_len] = '\0'; - slot->port = port; - slot->fd = fd; - master = fd; + slot->requested_port = port; + slot->bound_port = http_socket_local_port(fd, port); + slot->listener_index = listener_index; + slot->fd = fd; + master = fd; } const zend_socket_t copy = http_socket_dup(master); @@ -2682,7 +2741,7 @@ static int http_server_prebind_tcp(http_server_object *server) const char *error = NULL; const zend_socket_t copy = http_listen_set_acquire_tcp( - server->listen_set, ZSTR_VAL(lc->host), lc->port, cfg->backlog, &error); + server->listen_set, ZSTR_VAL(lc->host), lc->port, i, cfg->backlog, &error); if (!http_socket_valid(copy)) { http_listen_set_clear_tcp(server->listen_set); @@ -4248,9 +4307,10 @@ ZEND_METHOD(TrueAsync_HttpServer, start) /* Create listen sockets for each listener */ zval *listener; + zend_ulong listener_index; server->listener_count = 0; - ZEND_HASH_FOREACH_VAL(Z_ARRVAL(listeners_zval), listener) { + ZEND_HASH_FOREACH_NUM_KEY_VAL(Z_ARRVAL(listeners_zval), listener_index, listener) { if (server->listener_count >= MAX_LISTENERS) { break; } @@ -4291,12 +4351,15 @@ ZEND_METHOD(TrueAsync_HttpServer, start) * adopts a duplicate of that one socket. The duplicate is not * cosmetic: the reactor closes what it adopts, so a descriptor * handed out verbatim would die with the first thread to stop. */ - if (http_server_use_reuseport()) { + if (http_server_use_reuseport() && port != 0) { listen_flags |= ZEND_ASYNC_LISTEN_F_REUSEPORT; } else if (server->listen_set != NULL) { + /* Port 0 always comes here, REUSEPORT or not: the address is + * bound once and every thread adopts a duplicate, so the whole + * server answers on the one port the kernel gave. */ const char *error = NULL; const zend_socket_t adopted = http_listen_set_acquire_tcp( - server->listen_set, host, port, server->backlog, &error); + server->listen_set, host, port, listener_index, server->backlog, &error); if (!http_socket_valid(adopted)) { zend_throw_exception_ex(http_server_runtime_exception_ce, 0, @@ -5815,6 +5878,91 @@ ZEND_METHOD(TrueAsync_HttpServer, getConfig) } /* }}} */ +/* {{{ proto HttpServer::getBoundListeners(): array + * + * The addresses the server holds, one entry per configured listener and in + * configuration order. A TCP entry carries what the socket is bound to, which + * is the only place the answer exists when the listener asked for port 0. */ +ZEND_METHOD(TrueAsync_HttpServer, getBoundListeners) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + const http_server_object *const server = Z_HTTP_SERVER_P(ZEND_THIS); + + array_init(return_value); + + if (!server->running) { + return; + } + + const http_server_config_t *const cfg = http_server_get_config((http_server_object *)server); + + if (UNEXPECTED(cfg == NULL)) { + return; + } + + /* server->listeners holds the TCP and AF_UNIX listen events in config + * order; the HTTP/3 ones live in http3_listeners and are skipped here. */ + size_t socket_index = 0; + + for (size_t i = 0; i < cfg->listener_count; i++) { + const http_listener_config_t *const lc = &cfg->listeners[i]; + zval entry; + + array_init(&entry); + + if (lc->type == LISTENER_TYPE_UNIX) { + add_assoc_string(&entry, "type", "unix"); + add_assoc_str(&entry, "path", zend_string_copy(lc->host)); + socket_index++; + } else if (lc->type == LISTENER_TYPE_UDP_H3) { + /* A UDP bind reports no local address through the async API, so an + * HTTP/3 listener answers with the port it was given — + * addHttp3Listener refuses 0 for that reason. */ + add_assoc_string(&entry, "type", "udp_h3"); + add_assoc_str(&entry, "host", zend_string_copy(lc->host)); + add_assoc_long(&entry, "port", lc->port); + add_assoc_bool(&entry, "tls", lc->tls); + } else { + char host[64]; + int port = lc->port; + bool from_socket = false; + + if (socket_index < server->listener_count) { + zend_async_listen_event_t *const listen_event = + server->listeners[socket_index].listen_event; + + if (listen_event != NULL && listen_event->get_local_address != NULL + && listen_event->get_local_address(listen_event, host, sizeof(host), &port) + == SUCCESS) { + from_socket = true; + } + } + + if (!from_socket) { + /* A pool parent holds no listen event of its own: its threads + * do, and what they adopted was bound through the shared set. */ + const int shared_port = http_listen_set_bound_port(server->listen_set, i); + + if (shared_port != 0) { + port = shared_port; + } + } + + add_assoc_string(&entry, "type", "tcp"); + add_assoc_str(&entry, "host", + from_socket ? zend_string_init(host, strlen(host), 0) + : zend_string_copy(lc->host)); + add_assoc_long(&entry, "port", port); + add_assoc_bool(&entry, "tls", lc->tls); + socket_index++; + } + + add_next_index_zval(return_value, &entry); + } +} +/* }}} */ + #ifdef HAVE_HTTP_SERVER_HTTP3 /* Append one listener's stats snapshot to the result array. Factored out so * both the single-thread listeners (server->http3_listeners) and the reactor- diff --git a/src/http_server_config.c b/src/http_server_config.c index 36b2601..6601751 100644 --- a/src/http_server_config.c +++ b/src/http_server_config.c @@ -412,6 +412,21 @@ static void config_add_listener(http_server_config_t *config, http_listener_type listener->protocol_mask = protocol_mask; } +/* A TCP port the caller may name, or 0 to let the kernel pick one at bind + * time. Zero closes the window between choosing a port and owning it, which + * is why the server reports the assigned port through + * HttpServer::getBoundListeners() rather than the caller guessing it. */ +static bool config_check_tcp_port(zend_long port) +{ + if (port < 0 || port > 65535) { + zend_throw_exception(http_server_invalid_argument_exception_ce, + "Port must be 0 (assigned by the kernel) or between 1 and 65535", 0); + return false; + } + + return true; +} + /* {{{ proto HttpServerConfig::__construct(?string $host = null, int $port = 8080) */ ZEND_METHOD(TrueAsync_HttpServerConfig, __construct) { @@ -476,9 +491,7 @@ ZEND_METHOD(TrueAsync_HttpServerConfig, __construct) /* Add default listener if host provided */ if (host) { - if (port < 1 || port > 65535) { - zend_throw_exception(http_server_invalid_argument_exception_ce, - "Port must be between 1 and 65535", 0); + if (!config_check_tcp_port(port)) { return; } @@ -512,9 +525,7 @@ ZEND_METHOD(TrueAsync_HttpServerConfig, addListener) return; } - if (port < 1 || port > 65535) { - zend_throw_exception(http_server_invalid_argument_exception_ce, - "Port must be between 1 and 65535", 0); + if (!config_check_tcp_port(port)) { return; } @@ -549,9 +560,7 @@ ZEND_METHOD(TrueAsync_HttpServerConfig, addHttp1Listener) return; } - if (port < 1 || port > 65535) { - zend_throw_exception(http_server_invalid_argument_exception_ce, - "Port must be between 1 and 65535", 0); + if (!config_check_tcp_port(port)) { return; } @@ -588,9 +597,7 @@ ZEND_METHOD(TrueAsync_HttpServerConfig, addHttp2Listener) return; } - if (port < 1 || port > 65535) { - zend_throw_exception(http_server_invalid_argument_exception_ce, - "Port must be between 1 and 65535", 0); + if (!config_check_tcp_port(port)) { return; } diff --git a/stubs/HttpServer.php b/stubs/HttpServer.php index 189d266..1e0cf18 100644 --- a/stubs/HttpServer.php +++ b/stubs/HttpServer.php @@ -241,6 +241,23 @@ public function resetTelemetry(): bool {} */ public function getConfig(): HttpServerConfig {} + /** + * Addresses this server is bound to, one entry per configured listener, + * in configuration order. + * + * A listener configured with port 0 is bound to a port the kernel picks, + * and the entry carries that port: there is no gap between choosing an + * address and owning it, which a caller picking a free port beforehand + * cannot avoid. HTTP/3 listeners still require an explicit port and + * report the configured one. + * + * Empty while the server is not running: before start(), after stop(). + * + * @return array + */ + public function getBoundListeners(): array {} + /** * Get per-listener HTTP/3 observability counters. * diff --git a/stubs/HttpServer.php_arginfo.h b/stubs/HttpServer.php_arginfo.h index d61a2ec..908d25e 100644 --- a/stubs/HttpServer.php_arginfo.h +++ b/stubs/HttpServer.php_arginfo.h @@ -70,6 +70,8 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_TrueAsync_HttpServer_getConfig, 0, 0, TrueAsync\\HttpServerConfig, 0) ZEND_END_ARG_INFO() +#define arginfo_class_TrueAsync_HttpServer_getBoundListeners arginfo_class_TrueAsync_HttpServer_getTelemetry + #define arginfo_class_TrueAsync_HttpServer_getHttp3Stats arginfo_class_TrueAsync_HttpServer_getTelemetry #define arginfo_class_TrueAsync_HttpServer_getRuntimeStats arginfo_class_TrueAsync_HttpServer_getTelemetry @@ -97,6 +99,7 @@ ZEND_METHOD(TrueAsync_HttpServer, isRunning); ZEND_METHOD(TrueAsync_HttpServer, getTelemetry); ZEND_METHOD(TrueAsync_HttpServer, resetTelemetry); ZEND_METHOD(TrueAsync_HttpServer, getConfig); +ZEND_METHOD(TrueAsync_HttpServer, getBoundListeners); ZEND_METHOD(TrueAsync_HttpServer, getHttp3Stats); ZEND_METHOD(TrueAsync_HttpServer, getRuntimeStats); ZEND_METHOD(TrueAsync_HttpServer, getStats); @@ -123,6 +126,7 @@ static const zend_function_entry class_TrueAsync_HttpServer_methods[] = { ZEND_ME(TrueAsync_HttpServer, getTelemetry, arginfo_class_TrueAsync_HttpServer_getTelemetry, ZEND_ACC_PUBLIC) ZEND_ME(TrueAsync_HttpServer, resetTelemetry, arginfo_class_TrueAsync_HttpServer_resetTelemetry, ZEND_ACC_PUBLIC) ZEND_ME(TrueAsync_HttpServer, getConfig, arginfo_class_TrueAsync_HttpServer_getConfig, ZEND_ACC_PUBLIC) + ZEND_ME(TrueAsync_HttpServer, getBoundListeners, arginfo_class_TrueAsync_HttpServer_getBoundListeners, ZEND_ACC_PUBLIC) ZEND_ME(TrueAsync_HttpServer, getHttp3Stats, arginfo_class_TrueAsync_HttpServer_getHttp3Stats, ZEND_ACC_PUBLIC) ZEND_ME(TrueAsync_HttpServer, getRuntimeStats, arginfo_class_TrueAsync_HttpServer_getRuntimeStats, ZEND_ACC_PUBLIC) ZEND_ME(TrueAsync_HttpServer, getStats, arginfo_class_TrueAsync_HttpServer_getStats, ZEND_ACC_PUBLIC) diff --git a/tests/phpt/server/core/032-config-validation.phpt b/tests/phpt/server/core/032-config-validation.phpt index 866de07..bea86d3 100644 --- a/tests/phpt/server/core/032-config-validation.phpt +++ b/tests/phpt/server/core/032-config-validation.phpt @@ -31,9 +31,14 @@ function check(string $label, callable $fn, bool $expectThrow): void } } -/* ---- Port range (every listener-add method has its own check) ---- */ +/* ---- Port range (every listener-add method has its own check) ---- + * A TCP listener takes 0 as "the kernel picks at bind time" and reports the + * assignment through HttpServer::getBoundListeners(). HTTP/3 binds through the + * UDP path, which cannot report a local address, so 0 stays refused there. */ foreach (['addListener', 'addHttp1Listener', 'addHttp2Listener', 'addHttp3Listener'] as $m) { - check("$m:port0", fn(HttpServerConfig $c) => $c->$m('127.0.0.1', 0), true); + check("$m:port0", fn(HttpServerConfig $c) => $c->$m('127.0.0.1', 0), + $m === 'addHttp3Listener'); + check("$m:port-negative", fn(HttpServerConfig $c) => $c->$m('127.0.0.1', -1), true); check("$m:port65536", fn(HttpServerConfig $c) => $c->$m('127.0.0.1', 65536), true); check("$m:port-valid", fn(HttpServerConfig $c) => $c->$m('127.0.0.1', 12345), false); } @@ -131,15 +136,19 @@ echo "done\n"; ?> --EXPECT-- addListener:port0: OK +addListener:port-negative: OK addListener:port65536: OK addListener:port-valid: OK addHttp1Listener:port0: OK +addHttp1Listener:port-negative: OK addHttp1Listener:port65536: OK addHttp1Listener:port-valid: OK addHttp2Listener:port0: OK +addHttp2Listener:port-negative: OK addHttp2Listener:port65536: OK addHttp2Listener:port-valid: OK addHttp3Listener:port0: OK +addHttp3Listener:port-negative: OK addHttp3Listener:port65536: OK addHttp3Listener:port-valid: OK backlog:0: OK diff --git a/tests/phpt/server/core/078-listener-port-zero.phpt b/tests/phpt/server/core/078-listener-port-zero.phpt new file mode 100644 index 0000000..0317d81 --- /dev/null +++ b/tests/phpt/server/core/078-listener-port-zero.phpt @@ -0,0 +1,158 @@ +--TEST-- +HttpServer: a listener bound on port 0 reports the port the kernel gave it +--EXTENSIONS-- +true_async_server +true_async +--FILE-- +addListener('127.0.0.1', 0) + ->setReadTimeout(5) + ->setWriteTimeout(5) +); + +$server->addHttpHandler(function ($req, $res) { + $res->setStatusCode(200)->setBody('ok')->end(); +}); + +echo 'before start: ', count($server->getBoundListeners()), "\n"; + +$client = spawn(function () use ($server) { + for ($i = 0; $i < 100 && !$server->isRunning(); $i++) { + usleep(20000); + } + + $bound = $server->getBoundListeners(); + $entry = $bound[0] ?? []; + + echo 'entries: ', count($bound), "\n"; + echo 'type: ', $entry['type'] ?? '', "\n"; + echo 'host: ', $entry['host'] ?? '', "\n"; + echo 'port assigned: ', (($entry['port'] ?? 0) > 0 ? 'yes' : 'no'), "\n"; + echo 'tls: ', ($entry['tls'] ?? true) ? 'yes' : 'no', "\n"; + + $port = (int) ($entry['port'] ?? 0); + $fp = @stream_socket_client("tcp://127.0.0.1:$port", $errno, $errstr, 2); + + if ($fp === false) { + echo "connect: failed\n"; + $server->stop(); + return; + } + + fwrite($fp, "GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); + stream_set_timeout($fp, 2); + $raw = ''; + + while (!feof($fp)) { + $chunk = fread($fp, 8192); + + if ($chunk === false || $chunk === '') { + break; + } + + $raw .= $chunk; + } + + fclose($fp); + + echo 'served: ', (str_contains($raw, ' 200 ') && str_ends_with($raw, 'ok') ? 'yes' : 'no'), "\n"; + $server->stop(); +}); + +$server->start(); +await($client); + +echo 'after stop: ', count($server->getBoundListeners()), "\n"; + +try { + (new HttpServerConfig())->addListener('127.0.0.1', -1); + echo "negative port: accepted\n"; +} catch (HttpServerInvalidArgumentException $e) { + echo "negative port: refused\n"; +} + +/* Several threads on one port-0 listener: the address is bound once into the + * shared set and every thread adopts a duplicate, so the whole server answers + * on one port. A parent holds no listen event of its own, and the set is where + * its answer lives. */ +$pool = new HttpServer( + (new HttpServerConfig()) + ->addListener('127.0.0.1', 0) + ->setWorkers(2) + ->setReadTimeout(5) + ->setWriteTimeout(5) +); + +$pool->addHttpHandler(function ($req, $res) { + $res->setStatusCode(200)->setBody('ok')->end(); +}); + +$pool_client = spawn(function () use ($pool) { + for ($i = 0; $i < 150 && !$pool->isRunning(); $i++) { + usleep(20000); + } + + usleep(300000); + + $port = (int) ($pool->getBoundListeners()[0]['port'] ?? 0); + echo 'pool port assigned: ', ($port > 0 ? 'yes' : 'no'), "\n"; + + $fp = $port > 0 ? @stream_socket_client("tcp://127.0.0.1:$port", $errno, $errstr, 2) : false; + + if ($fp === false) { + echo "pool served: no\n"; + $pool->stop(); + return; + } + + fwrite($fp, "GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); + stream_set_timeout($fp, 2); + $raw = ''; + + while (!feof($fp)) { + $chunk = fread($fp, 8192); + + if ($chunk === false || $chunk === '') { + break; + } + + $raw .= $chunk; + } + + fclose($fp); + + echo 'pool served: ', (str_contains($raw, ' 200 ') ? 'yes' : 'no'), "\n"; + $pool->stop(); +}); + +$pool->start(); +await($pool_client); + +echo "Done\n"; +?> +--EXPECTF-- +before start: 0 +entries: 1 +type: tcp +host: 127.0.0.1 +port assigned: yes +tls: no +served: yes +after stop: 0 +negative port: refused +pool port assigned: yes +pool served: yes +%ADone From 65e918ce54c1d41acb147d08c7cad179659b21a0 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:58:39 +0300 Subject: [PATCH 2/2] chore(stubs): regenerate the HttpServer arginfo stub hash --- stubs/HttpServer.php_arginfo.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stubs/HttpServer.php_arginfo.h b/stubs/HttpServer.php_arginfo.h index 908d25e..383fb9c 100644 --- a/stubs/HttpServer.php_arginfo.h +++ b/stubs/HttpServer.php_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit HttpServer.php.stub.php instead. - * Stub hash: 7b8c5ac0e86ceec057e2e915302f64cd8427790d */ + * Stub hash: e2cb9e03600e706ce7a791b36fe0d28bc080a212 */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_TrueAsync_HttpServer___construct, 0, 0, 1) ZEND_ARG_OBJ_INFO(0, config, TrueAsync\\HttpServerConfig, 0)