From 0089143e919d602b5f32baf1a6ffeaa34f4327ca Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:45:43 +0000 Subject: [PATCH 1/3] circular_buffer: return 0 from circular_buffer_count() for an uninitialized buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A buffer with capacity 0 — a scheduler queue read before the scheduler has allocated it — holds nothing, but circular_buffer_count() asserted `head < capacity` (0 < 0) and aborted on a debug build. Treat capacity 0 as empty and return 0 before the bounds asserts. Foundational part of the fix for #164: protects every caller of the primitive. --- internal/circular_buffer.c | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/internal/circular_buffer.c b/internal/circular_buffer.c index cdbdb983..91ff28ae 100644 --- a/internal/circular_buffer.c +++ b/internal/circular_buffer.c @@ -520,6 +520,16 @@ bool circular_buffer_is_full(const circular_buffer_t *buffer) size_t circular_buffer_count(const circular_buffer_t *buffer) { ZEND_ASSERT(buffer != NULL && "Buffer cannot be NULL"); + + /* + * A buffer that was never initialized (capacity 0 — e.g. a scheduler queue + * read before the scheduler has allocated it) holds nothing. Return 0 rather + * than asserting the head/tail bounds, where 0 < 0 would fail. + */ + if (buffer->capacity == 0) { + return 0; + } + ZEND_ASSERT(buffer->head < buffer->capacity && "Head index out of bounds"); ZEND_ASSERT(buffer->tail < buffer->capacity && "Tail index out of bounds"); From 2f7c0911bc0acc75be8a4c8421aa45da243b30d7 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:46:23 +0000 Subject: [PATCH 2/3] runtime_stats: clamp the queue counts like the pool capacity (pre-init safety) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool capacity was already clamped with `capacity > 0 ? ... : 0` against the pre-scheduler state, but the four circular_buffer_count() reads were not — so runtime_stats() read uninitialized buffers when called before the scheduler started. Clamp the counts the same way, consistent with the capacity read. Part of the fix for #164. --- async.c | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/async.c b/async.c index a0dacae0..e1776d9c 100644 --- a/async.c +++ b/async.c @@ -722,9 +722,11 @@ PHP_FUNCTION(Async_runtime_stats) const circular_buffer_t *const coro_queue = &ASYNC_G(coroutine_queue); const circular_buffer_t *const resumed_q = &ASYNC_G(resumed_coroutines); - const size_t pool_count = circular_buffer_count(pool); - /* circular_buffer_capacity does capacity-1, underflows to SIZE_MAX - * pre-init. Clamp. */ + /* Both reads guard against capacity 0: before the scheduler allocates these + * buffers, circular_buffer_capacity() would underflow (capacity-1 = SIZE_MAX) + * and circular_buffer_count() would read uninitialized head/tail. Clamp to 0. */ + const size_t pool_count = pool->capacity > 0 + ? circular_buffer_count(pool) : 0; const size_t pool_capacity = pool->capacity > 0 ? circular_buffer_capacity(pool) : 0; const size_t stack_size = EG(fiber_stack_size); @@ -737,11 +739,11 @@ PHP_FUNCTION(Async_runtime_stats) (zend_long) ZEND_ASYNC_ACTIVE_COROUTINE_COUNT); add_assoc_long(return_value, "microtasks_queue", - (zend_long) circular_buffer_count(microtasks)); + (zend_long) (microtasks->capacity > 0 ? circular_buffer_count(microtasks) : 0)); add_assoc_long(return_value, "coroutine_queue", - (zend_long) circular_buffer_count(coro_queue)); + (zend_long) (coro_queue->capacity > 0 ? circular_buffer_count(coro_queue) : 0)); add_assoc_long(return_value, "resumed_queue", - (zend_long) circular_buffer_count(resumed_q)); + (zend_long) (resumed_q->capacity > 0 ? circular_buffer_count(resumed_q) : 0)); add_assoc_long(return_value, "fiber_pool_count", (zend_long) pool_count); add_assoc_long(return_value, "fiber_pool_capacity", (zend_long) pool_capacity); From c90818b040f744870f03eb207eef7b03eb90384b Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Sun, 14 Jun 2026 14:49:05 +0000 Subject: [PATCH 3/3] runtime_stats: zeroed snapshot before the scheduler starts + regression test Short-circuit runtime_stats() when ZEND_ASYNC_SCHEDULER is NULL (the scheduler has not launched yet, e.g. a top-level call before the first spawn) and return a zeroed snapshot with the same keys, instead of reading the unallocated queue buffers. Add tests/common/runtime_stats.phpt covering the top-level case (which used to abort the process) and the in-coroutine case. Completes the fix for #164. --- async.c | 21 +++++++++++++++++ tests/common/runtime_stats.phpt | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 tests/common/runtime_stats.phpt diff --git a/async.c b/async.c index e1776d9c..bb029bde 100644 --- a/async.c +++ b/async.c @@ -717,6 +717,27 @@ PHP_FUNCTION(Async_runtime_stats) { ZEND_PARSE_PARAMETERS_NONE(); + /* + * Before the scheduler is launched (e.g. runtime_stats() called at the top + * level, outside any coroutine), the queue buffers are not yet allocated. + * Report a zeroed snapshot rather than reading uninitialized state; the pool + * minimum and fiber stack size are static and still meaningful. + */ + if (ZEND_ASYNC_SCHEDULER == NULL) { + array_init(return_value); + add_assoc_long(return_value, "coroutines_total", 0); + add_assoc_long(return_value, "coroutines_active", 0); + add_assoc_long(return_value, "microtasks_queue", 0); + add_assoc_long(return_value, "coroutine_queue", 0); + add_assoc_long(return_value, "resumed_queue", 0); + add_assoc_long(return_value, "fiber_pool_count", 0); + add_assoc_long(return_value, "fiber_pool_capacity", 0); + add_assoc_long(return_value, "fiber_pool_min", (zend_long) ASYNC_FIBER_POOL_SIZE); + add_assoc_long(return_value, "fiber_stack_size", (zend_long) EG(fiber_stack_size)); + add_assoc_long(return_value, "fiber_pool_virtual_bytes", 0); + return; + } + const circular_buffer_t *const pool = &ASYNC_G(fiber_context_pool); const circular_buffer_t *const microtasks = &ASYNC_G(microtasks); const circular_buffer_t *const coro_queue = &ASYNC_G(coroutine_queue); diff --git a/tests/common/runtime_stats.phpt b/tests/common/runtime_stats.phpt new file mode 100644 index 00000000..a059d160 --- /dev/null +++ b/tests/common/runtime_stats.phpt @@ -0,0 +1,42 @@ +--TEST-- +Async\runtime_stats(): safe to call before the scheduler starts (#164) +--FILE-- += 1); +})); + +echo "done\n"; +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +done