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..df91455 --- /dev/null +++ b/tests/phpt/server/core/022-bootloader-failure.phpt @@ -0,0 +1,41 @@ +--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-- +%A[true-async-server] worker did not start: RuntimeException: boot failed!%Abool(false)%A