Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 33 additions & 2 deletions src/http_server_class.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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 —
Expand Down
41 changes: 41 additions & 0 deletions tests/phpt/server/core/022-bootloader-failure.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
--TEST--
HttpServer: a bootloader that throws is reported and start() answers false
--EXTENSIONS--
true_async_server
true_async
--SKIPIF--
<?php
if (PHP_OS_FAMILY === 'Windows') die('skip libuv on Windows lacks SO_REUSEPORT');
?>
--FILE--
<?php
/* A bootloader failure kills every worker before it reaches accept(), so the
* server serves nothing: the parent must name the reason and start() must
* report failure instead of the success of a run that never happened.
*
* Reporting the exception itself belongs to the pool and is pinned there
* (php-async, tests/thread_pool/080-bootloader_exception_reported.phpt). The
* fatal it prints lands in this test's output too, between the lines below,
* on any build new enough to carry it. */

use TrueAsync\HttpServer;
use TrueAsync\HttpServerConfig;

require_once __DIR__ . '/../_free_port.inc';

$config = (new HttpServerConfig())
->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
Loading