From 90ef2dc82d1a143117e527dbfc744c06c3e983cb Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:03:08 +0000 Subject: [PATCH 1/2] fix(server): fail start() when the pool's bootloader kills every worker A bootloader that throws closes the pool before any worker reaches its request loop, so every submission is rejected and the server binds nothing. The parent discarded the rejection's exception and read pending == 0 as success: start() answered true for a run in which no connection was ever accepted, and the process exited 0 with no trace of the cause. pool_worker_done_cb now counts the rejected workers and prints the first reason; start() answers false when that count is non-zero. The exception text comes from the worker itself and needs the matching true_async build. --- CHANGELOG.md | 6 +++ src/http_server_class.c | 35 ++++++++++++++++- .../server/core/022-bootloader-failure.phpt | 38 +++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 tests/phpt/server/core/022-bootloader-failure.phpt diff --git a/CHANGELOG.md b/CHANGELOG.md index 92bf7cc..c9d84e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- **A bootloader that threw made `HttpServer::start()` answer `true` for a server that never accepted a connection.** The pool rejects every worker's submission before its request loop starts, and the parent counted those rejections as workers that had finished their work, so the run read as a clean start followed by a clean stop. `start()` now answers `false` when any worker was rejected, and the parent names the first reason on stderr (`worker did not start: …`). The exception text itself comes from the worker and needs a `true_async` build that reports it. + ## [0.11.2] - 2026-07-15 ### Fixed diff --git a/src/http_server_class.c b/src/http_server_class.c index 1c6422d..b97fc27 100644 --- a/src/http_server_class.c +++ b/src/http_server_class.c @@ -2214,6 +2214,7 @@ static void http_server_release_worker_shell(zval *transit); typedef struct { int pending; /* workers not yet done */ + int failed; /* workers whose submission was rejected */ zend_async_event_t *all_done; /* fires when pending == 0 */ zend_async_event_callback_t cb; /* embedded — recovered via offsetof */ } pool_await_state_t; @@ -2277,12 +2278,34 @@ static void pool_worker_done_cb(zend_async_event_t *event, zend_async_event_callback_t *cb, void *result, zend_object *exception) { - (void)event; (void)result; (void)exception; + (void)event; (void)result; /* Callbacks fire on the parent thread (cross-thread wakeup is * already serialized by the reactor) — no atomicity needed. */ pool_await_state_t *st = (pool_await_state_t *) ((char *)cb - offsetof(pool_await_state_t, cb)); + /* A rejected submission means pool_worker_handler never ran: the pool failed + * the task before any worker could take it (a bootloader that threw, a shell + * that would not transfer). The worker-side report in pool_worker_handler + * cannot cover this — there is no worker — so the count is what keeps start() + * from reporting a server that never accepted a connection as started. */ + if (UNEXPECTED(exception != NULL)) { + st->failed++; + + /* One line per run: N workers of the same pool fail for the same reason, + * and the reason itself is already printed by the worker thread. */ + if (st->failed == 1) { + zval *msg_zv = zend_read_property(exception->ce, exception, + "message", sizeof("message") - 1, + /*silent=*/1, NULL); + fprintf(stderr, + "[true-async-server] worker did not start: %s: %s\n", + ZSTR_VAL(exception->ce->name), + (msg_zv != NULL && Z_TYPE_P(msg_zv) == IS_STRING) ? Z_STRVAL_P(msg_zv) : ""); + fflush(stderr); + } + } + if (--st->pending == 0 && st->all_done != NULL) { ZEND_ASYNC_CALLBACKS_NOTIFY(st->all_done, NULL, NULL); } @@ -3523,7 +3546,15 @@ static int http_server_start_pool(http_server_object *server, } } - rc = (st->pending == 0) ? SUCCESS : FAILURE; + /* start() answers "did this server serve": a run where every worker was + * rejected before it reached accept() is a failure, however cleanly the + * parent's await resolved. */ + rc = (st->pending == 0 && st->failed == 0) ? SUCCESS : FAILURE; + + if (st->failed > 0) { + http_logf_error(&server->log_state, "server.start.failed mode=pool workers=%d", + st->failed); + } /* Workers still serving: the await was cancelled, not resolved * (Async\graceful_shutdown()). Nothing in the engine stops a BUSY worker — diff --git a/tests/phpt/server/core/022-bootloader-failure.phpt b/tests/phpt/server/core/022-bootloader-failure.phpt new file mode 100644 index 0000000..969abcc --- /dev/null +++ b/tests/phpt/server/core/022-bootloader-failure.phpt @@ -0,0 +1,38 @@ +--TEST-- +HttpServer: a bootloader that throws is reported and start() answers false +--EXTENSIONS-- +true_async_server +true_async +--SKIPIF-- + +--FILE-- +addListener('127.0.0.1', tas_free_port()) + ->setWorkers(2) + ->setBootloader(static function (): void { + throw new \RuntimeException('boot failed!'); + }); + +$server = new HttpServer($config); +$server->addHttpHandler(function ($req, $res) { + $res->setStatusCode(200)->setBody('never served'); +}); + +var_dump($server->start()); +?> +--EXPECTF-- +%AUncaught RuntimeException: boot failed!%Abool(false)%A From 3e5f20a6ed839c9f28ffcd438904340f7ec259a2 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:18:54 +0000 Subject: [PATCH 2/2] test(server): pin the parent's report, not the pool's The fatal comes from the extension, so asserting it here made the test fail on any build older than the pool fix. php-async pins that half already; this test keeps what the server itself owns: the reason on stderr and start() == false. --- tests/phpt/server/core/022-bootloader-failure.phpt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/phpt/server/core/022-bootloader-failure.phpt b/tests/phpt/server/core/022-bootloader-failure.phpt index 969abcc..df91455 100644 --- a/tests/phpt/server/core/022-bootloader-failure.phpt +++ b/tests/phpt/server/core/022-bootloader-failure.phpt @@ -10,10 +10,13 @@ if (PHP_OS_FAMILY === 'Windows') die('skip libuv on Windows lacks SO_REUSEPORT') --FILE-- addHttpHandler(function ($req, $res) { var_dump($server->start()); ?> --EXPECTF-- -%AUncaught RuntimeException: boot failed!%Abool(false)%A +%A[true-async-server] worker did not start: RuntimeException: boot failed!%Abool(false)%A