From 8a605e0dead8ad0a7ff06f83c938f65ec8755742 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:14:17 +0000 Subject: [PATCH 1/7] fix(threadpool): run sync task body in a per-task nursery scope (snapshot UAF) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A synchronous ThreadPool task ran its body inline in the worker and freed the per-task snapshot arena the moment the body returned — while a coroutine the body spawned but never awaited was still pending. That coroutine's op_array lived in the freed arena (Windows debug-heap crash; ASAN-caught on Linux). Run the body as a coroutine in its own per-task nursery Scope instead: Async\spawn() inside the body lands in that scope on its own (no coro->scope hijacking). On task exit the worker cancels the scope and drains it via the new ZEND_ASYNC_SCOPE_AWAIT_AFTER_CANCELLATION — awaiting physical disposal of every spawned coroutine — before freeing the snapshot. Invariant: no spawned coroutine outlives the snapshot. Requires php-src ABI v0.20.0 (zend_async_scope_await_after_cancellation_fn). Regression test tests/thread_pool/065-task_scope_nursery_no_uaf.phpt. --- CHANGELOG.md | 1 + async_API.c | 1 + scope.c | 136 ++++++++++------- scope.h | 7 + .../065-task_scope_nursery_no_uaf.phpt | 46 ++++++ thread_pool.c | 139 ++++++++++++++---- 6 files changed, 242 insertions(+), 88 deletions(-) create mode 100644 tests/thread_pool/065-task_scope_nursery_no_uaf.phpt diff --git a/CHANGELOG.md b/CHANGELOG.md index 84d48046..57221e88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **`ThreadPool` synchronous task: snapshot use-after-free when the task spawns an un-awaited coroutine** — a sync-mode task body ran inline in the worker and its per-task snapshot arena (which backs every spawned closure's op_array) was freed the instant the body returned, while a coroutine the body had spawned was still pending; running it later dereferenced freed memory (Windows debug-heap crash; ASAN-caught on Linux). The body now runs as a coroutine in its own per-task **nursery** `Scope`: `Async\spawn()` inside the body lands in that scope on its own (no scope-pointer hijacking), and on task exit the scope is cancelled and *drained* — awaited until every spawned coroutine is physically disposed — before the snapshot is freed. ABI bumped to v0.20.0: new `zend_async_scope_await_after_cancellation_fn` exposes the C core of `Scope::awaitAfterCancellation` so the worker reuses the canonical zombie-aware drain instead of hand-rolling it. Regression test `tests/thread_pool/065-task_scope_nursery_no_uaf.phpt`. - **`TaskSet`/`TaskGroup(scope: $scope)` use-after-free on teardown** — the group held only an event refcount on a PHP-supplied scope, which is shared with coroutine bookkeeping, so a finishing coroutine could free the scope while `group->scope` still pointed at it (`task_group.c:486`, seen as `zend_mm_heap corrupted`). The group now holds a strong ref to the external Scope object for its lifetime, so the scope can't be disposed while in use. Test `tests/task_group/043-task_group_external_scope_uaf.phpt`. ## [0.7.0] - 2026-06-02 diff --git a/async_API.c b/async_API.c index 371f48be..b456279e 100644 --- a/async_API.c +++ b/async_API.c @@ -1304,6 +1304,7 @@ void async_api_register(void) async_scheduler_coroutine_enqueue, async_coroutine_resume, async_coroutine_cancel, + async_scope_await_after_cancellation, async_spawn_and_throw, start_graceful_shutdown, async_waker_new, diff --git a/scope.c b/scope.c index 013019fa..772cd981 100644 --- a/scope.c +++ b/scope.c @@ -371,95 +371,119 @@ METHOD(awaitCompletion) zend_async_waker_clean(current_coroutine); } -METHOD(awaitAfterCancellation) +/* C core of Scope::awaitAfterCancellation, exposed via the async API so the + * thread pool can drain a per-task nursery from C without going through the PHP + * method. Suspends `awaiter` until the scope is COMPLETELY_DONE — active AND + * zombie counts both zero — so it waits for physical disposal of cancelled + * children, not the "completed" flag that flips the instant they are cancelled + * while their objects are still queued for disposal. `awaiter` must not belong + * to `scope` or its children. `error_fci`/`cancellation` are optional. */ +void async_scope_await_after_cancellation( + zend_async_scope_t *zend_scope, zend_coroutine_t *awaiter, + zend_fcall_info *error_fci, zend_fcall_info_cache *error_fci_cache, + zend_async_event_t *cancellation) { - zend_fcall_info error_handler_fci = { 0 }; - zend_fcall_info_cache error_handler_fcc = { 0 }; - zend_object *cancellation_obj = NULL; - - ZEND_PARSE_PARAMETERS_START(0, 2) - Z_PARAM_OPTIONAL - Z_PARAM_FUNC_OR_NULL(error_handler_fci, error_handler_fcc) - Z_PARAM_OBJ_OR_NULL(cancellation_obj) - ZEND_PARSE_PARAMETERS_END(); - - // Mark cancellation token as used immediately, before any early returns - if (cancellation_obj != NULL) { - zend_async_event_t *cancellation_event = ZEND_ASYNC_OBJECT_TO_EVENT(cancellation_obj); - ZEND_ASYNC_EVENT_SET_RESULT_USED(cancellation_event); - ZEND_ASYNC_EVENT_SET_EXC_CAUGHT(cancellation_event); - } - - zend_coroutine_t *current_coroutine = ZEND_ASYNC_CURRENT_COROUTINE; - if (UNEXPECTED(current_coroutine == NULL)) { + if (UNEXPECTED(awaiter == NULL || zend_scope == NULL || ZEND_ASYNC_SCOPE_IS_CLOSED(zend_scope))) { return; } - async_scope_object_t *scope_object = THIS_SCOPE; - if (UNEXPECTED(scope_object->scope == NULL || ZEND_ASYNC_SCOPE_IS_CLOSED(&scope_object->scope->scope))) { - return; - } - - if (false == ZEND_ASYNC_SCOPE_IS_CANCELLED(&scope_object->scope->scope)) { - async_throw_error("Attempt to await a Scope that has not been cancelled"); - } + async_scope_t *scope = (async_scope_t *) zend_scope; - // Check for deadlock: current coroutine belongs to this scope or its children - if (async_scope_contains_coroutine(scope_object->scope, current_coroutine, 0)) { + // Deadlock guard: the awaiter must not belong to this scope or its children. + if (async_scope_contains_coroutine(scope, awaiter, 0)) { async_throw_error( "Cannot await completion of scope from a coroutine that belongs to the same scope or its children"); - RETURN_THROWS(); + return; } if (UNEXPECTED(EG(exception))) { - RETURN_THROWS(); + return; } - // Check if scope is already finished (no active coroutines and no child scopes) - if (scope_object->scope->coroutines.length == 0 && scope_object->scope->scope.scopes.length == 0) { + // Already drained — no active coroutines and no child scopes. + if (scope->coroutines.length == 0 && scope->scope.scopes.length == 0) { return; } - ZEND_ASYNC_WAKER_NEW(current_coroutine); + ZEND_ASYNC_WAKER_NEW(awaiter); if (UNEXPECTED(EG(exception))) { - RETURN_THROWS(); + return; } - // We need to create a custom callback to handle errors coming from coroutines. + // Custom callback resumes only once the scope is COMPLETELY_DONE and routes + // any child error through the optional handler. scope_coroutine_callback_t *scope_callback = (scope_coroutine_callback_t *) zend_async_coroutine_callback_new( - current_coroutine, callback_resolve_when_zombie_completed, sizeof(scope_coroutine_callback_t)); + awaiter, callback_resolve_when_zombie_completed, sizeof(scope_coroutine_callback_t)); if (UNEXPECTED(scope_callback == NULL)) { - ZEND_ASYNC_WAKER_DESTROY(current_coroutine); - RETURN_THROWS(); + ZEND_ASYNC_WAKER_DESTROY(awaiter); + return; } - if (error_handler_fci.size != 0) { - scope_callback->error_fci = &error_handler_fci; - scope_callback->error_fci_cache = &error_handler_fcc; + if (error_fci != NULL && error_fci->size != 0) { + scope_callback->error_fci = error_fci; + scope_callback->error_fci_cache = error_fci_cache; } else { scope_callback->error_fci = NULL; scope_callback->error_fci_cache = NULL; } - if (UNEXPECTED(!zend_async_resume_when(current_coroutine, &scope_object->scope->scope.event, false, NULL, - &scope_callback->callback))) { - ZEND_ASYNC_WAKER_DESTROY(current_coroutine); - RETURN_THROWS(); + if (UNEXPECTED(!zend_async_resume_when( + awaiter, &zend_scope->event, false, NULL, &scope_callback->callback))) { + ZEND_ASYNC_WAKER_DESTROY(awaiter); + return; } - if (cancellation_obj != NULL) { - zend_async_resume_when(current_coroutine, - ZEND_ASYNC_OBJECT_TO_EVENT(cancellation_obj), - false, - zend_async_waker_callback_cancel, - NULL); + if (cancellation != NULL) { + zend_async_resume_when(awaiter, cancellation, false, zend_async_waker_callback_cancel, NULL); if (UNEXPECTED(EG(exception))) { - zend_async_waker_clean(current_coroutine); - RETURN_THROWS(); + zend_async_waker_clean(awaiter); + return; } } ZEND_ASYNC_SUSPEND(); - zend_async_waker_clean(current_coroutine); + zend_async_waker_clean(awaiter); +} + +METHOD(awaitAfterCancellation) +{ + zend_fcall_info error_handler_fci = { 0 }; + zend_fcall_info_cache error_handler_fcc = { 0 }; + zend_object *cancellation_obj = NULL; + + ZEND_PARSE_PARAMETERS_START(0, 2) + Z_PARAM_OPTIONAL + Z_PARAM_FUNC_OR_NULL(error_handler_fci, error_handler_fcc) + Z_PARAM_OBJ_OR_NULL(cancellation_obj) + ZEND_PARSE_PARAMETERS_END(); + + // Mark cancellation token as used immediately, before any early returns + zend_async_event_t *cancellation_event = NULL; + if (cancellation_obj != NULL) { + cancellation_event = ZEND_ASYNC_OBJECT_TO_EVENT(cancellation_obj); + ZEND_ASYNC_EVENT_SET_RESULT_USED(cancellation_event); + ZEND_ASYNC_EVENT_SET_EXC_CAUGHT(cancellation_event); + } + + zend_coroutine_t *current_coroutine = ZEND_ASYNC_CURRENT_COROUTINE; + if (UNEXPECTED(current_coroutine == NULL)) { + return; + } + + async_scope_object_t *scope_object = THIS_SCOPE; + if (UNEXPECTED(scope_object->scope == NULL || ZEND_ASYNC_SCOPE_IS_CLOSED(&scope_object->scope->scope))) { + return; + } + + if (false == ZEND_ASYNC_SCOPE_IS_CANCELLED(&scope_object->scope->scope)) { + async_throw_error("Attempt to await a Scope that has not been cancelled"); + RETURN_THROWS(); + } + + async_scope_await_after_cancellation( + &scope_object->scope->scope, current_coroutine, + error_handler_fci.size != 0 ? &error_handler_fci : NULL, + error_handler_fci.size != 0 ? &error_handler_fcc : NULL, + cancellation_event); } METHOD(isFinished) diff --git a/scope.h b/scope.h index baed8033..2f75363b 100644 --- a/scope.h +++ b/scope.h @@ -83,6 +83,13 @@ void async_register_scope_ce(void); /* Check if coroutine belongs to this scope or any of its child scopes */ bool async_scope_contains_coroutine(async_scope_t *scope, zend_coroutine_t *coroutine, uint32_t depth); +/* C core of Scope::awaitAfterCancellation; registered as the async API + * scope_await_after_cancellation_fn. See definition in scope.c. */ +void async_scope_await_after_cancellation( + zend_async_scope_t *scope, zend_coroutine_t *awaiter, + zend_fcall_info *error_fci, zend_fcall_info_cache *error_fci_cache, + zend_async_event_t *cancellation); + void async_scope_notify_coroutine_finished(async_coroutine_t *coroutine); /* Mark coroutine as zombie and update active count */ diff --git a/tests/thread_pool/065-task_scope_nursery_no_uaf.phpt b/tests/thread_pool/065-task_scope_nursery_no_uaf.phpt new file mode 100644 index 00000000..e174592c --- /dev/null +++ b/tests/thread_pool/065-task_scope_nursery_no_uaf.phpt @@ -0,0 +1,46 @@ +--TEST-- +ThreadPool: a coroutine spawned but never awaited inside a sync task cannot outlive the per-task snapshot (UAF regression) +--SKIPIF-- + +--FILE-- +submit(function () { + // Spawned, never awaited: still pending when the task body returns, so the + // worker must cancel and drain it before freeing this task's snapshot. + spawn(function () { delay(10000); }); + return 'task-done'; +}); + +var_dump(await($f)); + +$pool->close(); +echo "ok\n"; +--EXPECT-- +string(9) "task-done" +ok diff --git a/thread_pool.c b/thread_pool.c index 0c23cbd4..69116674 100644 --- a/thread_pool.c +++ b/thread_pool.c @@ -433,16 +433,70 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c goto task_cleanup; } - /* A real fatal error longjmps (zend_bailout); exit()/die() instead - * throws an unwind-exit token into EG(exception). Neither may be - * re-raised or passed to reject() — the token can't cross the fiber - * boundary and would crash the worker fiber. */ - volatile bool task_bailed = false; - zend_try { - zend_call_function(&fci, &fcc); - } zend_catch { - task_bailed = true; - } zend_end_try(); + /* Synchronous mode: run the task body in its own per-task nursery + * Scope. The body runs as a real coroutine, so CURRENT SCOPE follows + * it and any Async\spawn() inside the body lands in task_scope on its + * own — no scope hijacking. After the body finishes we cancel and + * drain the scope before freeing the snapshot, so a coroutine the + * body spawned but never awaited cannot outlive the snapshot arena + * that backs its op_array. */ + zend_coroutine_t *worker_coro = ZEND_ASYNC_CURRENT_COROUTINE; + zend_async_scope_t *task_scope = + worker_coro != NULL ? ZEND_ASYNC_NEW_SCOPE(ZEND_ASYNC_CURRENT_SCOPE) : NULL; + + if (UNEXPECTED(task_scope == NULL)) { + zend_atomic_int_dec(&pool->base.running_count); + zend_atomic_int_inc(&pool->base.completed_count); + if (EG(exception)) { + async_future_shared_state_reject(state, EG(exception)); + zend_clear_exception(); + } + goto task_cleanup; + } + + /* Nursery: un-awaited children are cancelled at task exit, never + * awaited forever. Pinned so the scope survives the cancel/drain + * below instead of self-disposing the moment it empties; cleared + * right before RELEASE. */ + ZEND_ASYNC_SCOPE_CLR_DISPOSE_SAFELY(task_scope); + ZEND_ASYNC_SCOPE_SET_OWNER_PINNED(task_scope); + + zend_coroutine_t *body = ZEND_ASYNC_SPAWN_WITH(task_scope); + if (UNEXPECTED(body == NULL)) { + ZEND_ASYNC_SCOPE_CLR_OWNER_PINNED(task_scope); + ZEND_ASYNC_SCOPE_RELEASE(task_scope); + zend_atomic_int_dec(&pool->base.running_count); + zend_atomic_int_inc(&pool->base.completed_count); + if (EG(exception)) { + async_future_shared_state_reject(state, EG(exception)); + zend_clear_exception(); + } + goto task_cleanup; + } + + /* Hand the prepared call to the body coroutine, reusing the params + * buffer the worker already populated. Ownership of params moves to + * the coroutine; the snapshot stays ours to free after the drain. */ + zend_fcall_t *fcall = ecalloc(1, sizeof(zend_fcall_t)); + fcall->fci = fci; + fcall->fci_cache = fcc; + fcall->fci.param_count = param_count; + fcall->fci.params = params; + fcall->fci.retval = &body->result; + Z_TRY_ADDREF(fcall->fci.function_name); + body->fcall = fcall; + params = NULL; + + /* Await the body coroutine. waker_callback_resolve copies its result + * (or error) into OUR waker at notify time — while the body is still + * alive — and marks the body's exception handled so it does not + * cascade and cancel the worker; we never read the body coroutine + * after it may have been disposed. An exit()/die() in the body is + * swallowed by the coroutine layer (exception NULL, result UNDEF). */ + ZEND_ASYNC_WAKER_NEW(worker_coro); + zend_async_resume_when(worker_coro, &body->event, false, + zend_async_waker_callback_resolve, NULL); + ZEND_ASYNC_SUSPEND(); /* Decrement running and bump completed BEFORE notifying the awaiter * via complete/reject — otherwise a coroutine waking from await() @@ -450,34 +504,55 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c zend_atomic_int_dec(&pool->base.running_count); zend_atomic_int_inc(&pool->base.completed_count); - const bool task_exited = (EG(exception) != NULL - && (zend_is_unwind_exit(EG(exception)) - || zend_is_graceful_exit(EG(exception)))); - - if (task_exited) { - /* exit()/die() is graceful "this task is done" — the worker's - * request survives it, so resolve the future with null (no return - * value) and keep serving the next task. Never pass the unwind - * token to reject(): it can't cross the fiber to the awaiter. */ + zend_object *body_error = NULL; + if (worker_coro->waker != NULL && worker_coro->waker->error != NULL) { + body_error = worker_coro->waker->error; + worker_coro->waker->error = NULL; + } else if (EG(exception) != NULL) { + body_error = EG(exception); + GC_ADDREF(body_error); zend_clear_exception(); + } + + if (body_error != NULL) { + async_future_shared_state_reject(state, body_error); + OBJ_RELEASE(body_error); + } else if (worker_coro->waker != NULL + && Z_TYPE(worker_coro->waker->result) != IS_UNDEF) { + async_future_shared_state_complete(state, &worker_coro->waker->result); + } else { zval null_result; ZVAL_NULL(&null_result); async_future_shared_state_complete(state, &null_result); - } else if (task_bailed) { - /* A real fatal (e.g. OOM) leaves the worker's request unusable — - * deliver a transfer exception and tear the pool down. */ - zend_object *bex = thread_pool_bailout_exception(); - async_future_shared_state_reject(state, bex); - thread_pool_close(pool); - thread_pool_drain_tasks(pool, true, bex); - OBJ_RELEASE(bex); - } else if (EG(exception) != NULL) { - async_future_shared_state_reject(state, EG(exception)); - zend_clear_exception(); - } else { - async_future_shared_state_complete(state, &retval); } + zend_async_waker_clean(worker_coro); + + /* Cancel coroutines the body spawned but left running, then await + * their physical disposal before freeing the snapshot arena that + * backs their op_arrays — so none can outlive the snapshot. */ + if (!ZEND_ASYNC_SCOPE_IS_CLOSED(task_scope)) { + ZEND_ASYNC_SCOPE_CANCEL(task_scope, NULL, false, false); + ZEND_ASYNC_SCOPE_AWAIT_AFTER_CANCELLATION(task_scope, worker_coro, NULL, NULL, NULL); + if (UNEXPECTED(EG(exception))) { + zend_clear_exception(); + } + } + + ZEND_ASYNC_SCOPE_CLR_OWNER_PINNED(task_scope); + ZEND_ASYNC_SCOPE_RELEASE(task_scope); + + /* Release our closure ref BEFORE the snapshot: callable's op_array + * lives in the snapshot arena, so destroy_op_array must run while the + * arena is still mapped. The body dropped its own closure ref when it + * disposed during the drain above, so this is the last one. */ + zval_ptr_dtor(&callable); + zval_ptr_dtor(&retval); + async_thread_snapshot_destroy(snapshot); + async_future_shared_state_delref(state); + zval_ptr_dtor(&task); + continue; + task_cleanup: if (params) { for (uint32_t i = 0; i < param_count; i++) { From 6b5aaa5d2ea90744a8c4e8c7c4e8d2b4cd24e14a Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Sun, 7 Jun 2026 11:13:35 +0000 Subject: [PATCH 2/7] fix(threadpool): deliver a fatal in a sync task body to the awaiter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the body as a coroutine meant a fatal (e.g. OOM) re-raised zend_bailout() out of the task coroutine and longjmped to the worker's bailout handler, past the future resolution — and that handler only drains the channel, not the already-dequeued in-flight task, so the awaiter hung. Track the in-flight task's future in a volatile and reject it from the bailout handler (ThreadTransferException), restoring the prior behaviour. Also trim the over-verbose comments from the previous commit. Test tests/thread_pool/066-task_fatal_rejects_future.phpt. --- scope.c | 14 ++--- scope.h | 3 +- .../066-task_fatal_rejects_future.phpt | 39 ++++++++++++++ thread_pool.c | 54 +++++++++---------- 4 files changed, 72 insertions(+), 38 deletions(-) create mode 100644 tests/thread_pool/066-task_fatal_rejects_future.phpt diff --git a/scope.c b/scope.c index 772cd981..ecefbaa9 100644 --- a/scope.c +++ b/scope.c @@ -371,13 +371,10 @@ METHOD(awaitCompletion) zend_async_waker_clean(current_coroutine); } -/* C core of Scope::awaitAfterCancellation, exposed via the async API so the - * thread pool can drain a per-task nursery from C without going through the PHP - * method. Suspends `awaiter` until the scope is COMPLETELY_DONE — active AND - * zombie counts both zero — so it waits for physical disposal of cancelled - * children, not the "completed" flag that flips the instant they are cancelled - * while their objects are still queued for disposal. `awaiter` must not belong - * to `scope` or its children. `error_fci`/`cancellation` are optional. */ +/* C core of Scope::awaitAfterCancellation, also used by the thread pool via the + * async API. Suspends `awaiter` until the scope is COMPLETELY_DONE (active and + * zombie counts both zero — physical disposal, not just "completed"). `awaiter` + * must not belong to `scope`. `error_fci`/`cancellation` are optional. */ void async_scope_await_after_cancellation( zend_async_scope_t *zend_scope, zend_coroutine_t *awaiter, zend_fcall_info *error_fci, zend_fcall_info_cache *error_fci_cache, @@ -409,8 +406,7 @@ void async_scope_await_after_cancellation( return; } - // Custom callback resumes only once the scope is COMPLETELY_DONE and routes - // any child error through the optional handler. + // Resumes only when COMPLETELY_DONE; routes any child error through the handler. scope_coroutine_callback_t *scope_callback = (scope_coroutine_callback_t *) zend_async_coroutine_callback_new( awaiter, callback_resolve_when_zombie_completed, sizeof(scope_coroutine_callback_t)); if (UNEXPECTED(scope_callback == NULL)) { diff --git a/scope.h b/scope.h index 2f75363b..7a02dc07 100644 --- a/scope.h +++ b/scope.h @@ -83,8 +83,7 @@ void async_register_scope_ce(void); /* Check if coroutine belongs to this scope or any of its child scopes */ bool async_scope_contains_coroutine(async_scope_t *scope, zend_coroutine_t *coroutine, uint32_t depth); -/* C core of Scope::awaitAfterCancellation; registered as the async API - * scope_await_after_cancellation_fn. See definition in scope.c. */ +/* C core of Scope::awaitAfterCancellation (see scope.c). */ void async_scope_await_after_cancellation( zend_async_scope_t *scope, zend_coroutine_t *awaiter, zend_fcall_info *error_fci, zend_fcall_info_cache *error_fci_cache, diff --git a/tests/thread_pool/066-task_fatal_rejects_future.phpt b/tests/thread_pool/066-task_fatal_rejects_future.phpt new file mode 100644 index 00000000..c12ae768 --- /dev/null +++ b/tests/thread_pool/066-task_fatal_rejects_future.phpt @@ -0,0 +1,39 @@ +--TEST-- +ThreadPool: a fatal (OOM) in a sync task rejects its future with ThreadTransferException (not a hang) +--SKIPIF-- + +--INI-- +memory_limit=64M +--FILE-- +submit(function () { + $s = str_repeat('x', 500 * 1024 * 1024); // exceeds memory_limit -> bailout + return strlen($s); +}); + +try { + var_dump(await($f)); +} catch (\Throwable $e) { + printf("%s: %s\n", get_class($e), + str_contains($e->getMessage(), 'memory size') ? 'memory exhausted' : 'other'); +} + +echo "done\n"; +--EXPECTF-- +%AAsync\ThreadTransferException: memory exhausted +done diff --git a/thread_pool.c b/thread_pool.c index 69116674..d2110b67 100644 --- a/thread_pool.c +++ b/thread_pool.c @@ -153,6 +153,10 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c async_thread_pool_t *pool = (async_thread_pool_t *) ctx; async_thread_channel_t *channel = pool->task_channel; int bailout = 0; + /* Future of the sync task whose body is running. A fatal in the body bails + * past the normal resolve, so the bailout handler rejects this one (volatile: + * written in the try, read in zend_catch). */ + zend_future_shared_state_t * volatile inflight_state = NULL; /* Per-worker pool scope: child of worker's main scope. Pinned for the * worker's lifetime; each spawned task lives in its own child scope of * this one (see thread_pool_spawn_task_coroutine). Created lazily, only @@ -433,13 +437,8 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c goto task_cleanup; } - /* Synchronous mode: run the task body in its own per-task nursery - * Scope. The body runs as a real coroutine, so CURRENT SCOPE follows - * it and any Async\spawn() inside the body lands in task_scope on its - * own — no scope hijacking. After the body finishes we cancel and - * drain the scope before freeing the snapshot, so a coroutine the - * body spawned but never awaited cannot outlive the snapshot arena - * that backs its op_array. */ + /* Run the body as a coroutine in a per-task nursery: Async\spawn() + * inside it lands in task_scope by itself, no scope hijacking. */ zend_coroutine_t *worker_coro = ZEND_ASYNC_CURRENT_COROUTINE; zend_async_scope_t *task_scope = worker_coro != NULL ? ZEND_ASYNC_NEW_SCOPE(ZEND_ASYNC_CURRENT_SCOPE) : NULL; @@ -454,10 +453,8 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c goto task_cleanup; } - /* Nursery: un-awaited children are cancelled at task exit, never - * awaited forever. Pinned so the scope survives the cancel/drain - * below instead of self-disposing the moment it empties; cleared - * right before RELEASE. */ + /* NOT-safe = un-awaited children are cancelled, not awaited. Pin so + * the scope can't self-dispose mid-drain; cleared before RELEASE. */ ZEND_ASYNC_SCOPE_CLR_DISPOSE_SAFELY(task_scope); ZEND_ASYNC_SCOPE_SET_OWNER_PINNED(task_scope); @@ -474,9 +471,7 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c goto task_cleanup; } - /* Hand the prepared call to the body coroutine, reusing the params - * buffer the worker already populated. Ownership of params moves to - * the coroutine; the snapshot stays ours to free after the drain. */ + /* Coroutine takes over params/fcall; the snapshot stays ours. */ zend_fcall_t *fcall = ecalloc(1, sizeof(zend_fcall_t)); fcall->fci = fci; fcall->fci_cache = fcc; @@ -487,16 +482,15 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c body->fcall = fcall; params = NULL; - /* Await the body coroutine. waker_callback_resolve copies its result - * (or error) into OUR waker at notify time — while the body is still - * alive — and marks the body's exception handled so it does not - * cascade and cancel the worker; we never read the body coroutine - * after it may have been disposed. An exit()/die() in the body is - * swallowed by the coroutine layer (exception NULL, result UNDEF). */ + /* Await the body. The callback copies its result/error into OUR waker + * while the body is still alive (it may be freed before we resume) + * and marks its exception handled so it can't cascade to the worker. */ + inflight_state = state; ZEND_ASYNC_WAKER_NEW(worker_coro); zend_async_resume_when(worker_coro, &body->event, false, zend_async_waker_callback_resolve, NULL); ZEND_ASYNC_SUSPEND(); + inflight_state = NULL; /* Decrement running and bump completed BEFORE notifying the awaiter * via complete/reject — otherwise a coroutine waking from await() @@ -528,9 +522,8 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c zend_async_waker_clean(worker_coro); - /* Cancel coroutines the body spawned but left running, then await - * their physical disposal before freeing the snapshot arena that - * backs their op_arrays — so none can outlive the snapshot. */ + /* Cancel and drain un-awaited children before freeing the snapshot + * their op_arrays live in. */ if (!ZEND_ASYNC_SCOPE_IS_CLOSED(task_scope)) { ZEND_ASYNC_SCOPE_CANCEL(task_scope, NULL, false, false); ZEND_ASYNC_SCOPE_AWAIT_AFTER_CANCELLATION(task_scope, worker_coro, NULL, NULL, NULL); @@ -542,10 +535,8 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c ZEND_ASYNC_SCOPE_CLR_OWNER_PINNED(task_scope); ZEND_ASYNC_SCOPE_RELEASE(task_scope); - /* Release our closure ref BEFORE the snapshot: callable's op_array - * lives in the snapshot arena, so destroy_op_array must run while the - * arena is still mapped. The body dropped its own closure ref when it - * disposed during the drain above, so this is the last one. */ + /* callable's op_array lives in the snapshot arena — drop it before + * freeing the snapshot. */ zval_ptr_dtor(&callable); zval_ptr_dtor(&retval); async_thread_snapshot_destroy(snapshot); @@ -611,6 +602,15 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c * crashes it, so reject any still-pending tasks and exit cleanly. Done * before DELREF so the pool is still alive for the drain. */ zend_object *bex = thread_pool_bailout_exception(); + + /* The task whose body bailed was already dequeued, so drain_tasks (which + * only sees the channel) won't reach it — reject it here. */ + if (inflight_state != NULL) { + async_future_shared_state_reject(inflight_state, bex); + async_future_shared_state_delref(inflight_state); + inflight_state = NULL; + } + thread_pool_close(pool); thread_pool_drain_tasks(pool, true, bex); OBJ_RELEASE(bex); From 9ca03275b683cf19cfa3abd207365f94101a5640 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:26:08 +0000 Subject: [PATCH 3/7] #159: fix snapshot UAF and libuv loop leak on a fatal in a ThreadPool task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two correctness fixes on top of the per-task nursery scope: 1. Fatal in a task body no longer UAFs the snapshot. The entry op_array's name strings (function_name, filename) are materialized into refcounted heap strings (thread_materialize_op_array_names), so holders that outlive the snapshot arena — the closure freed at request shutdown, and PG(last_error_file) — are freed by refcount instead of dangling into the freed arena. The sync body's bailout is caught and the future rejected. 2. A bailout through a parked SUSPEND no longer leaks the libuv loop. The ThreadChannel send/recv triggers and the worker's slot_event are uv_async handles; a fatal re-raising zend_bailout() through SUSPEND skipped their dispose, leaving an open uv_async that blocked uv_loop_close (EBUSY) and leaked the loop internals. Both paths now catch the bailout, dispose the trigger on the worker's reactor, and re-raise. Debug builds dump any handle that survives reactor shutdown. Tests: thread_pool/066 (sync fatal rejects future), 067 (coroutine fatal disposes channel trigger), 068 (concurrency fatal disposes slot_event). All green under ASAN+LSan. --- CHANGELOG.md | 1 + libuv_reactor.c | 25 ++++- .../066-task_fatal_rejects_future.phpt | 18 ++-- ...-coroutine_task_fatal_no_trigger_leak.phpt | 34 +++++++ ...8-concurrency_task_fatal_no_slot_leak.phpt | 33 +++++++ thread.c | 34 +++++++ thread.h | 5 + thread_channel.c | 41 +++++++- thread_pool.c | 94 ++++++++++++------- 9 files changed, 244 insertions(+), 41 deletions(-) create mode 100644 tests/thread_pool/067-coroutine_task_fatal_no_trigger_leak.phpt create mode 100644 tests/thread_pool/068-concurrency_task_fatal_no_slot_leak.phpt diff --git a/CHANGELOG.md b/CHANGELOG.md index 57221e88..de137059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **`ThreadPool` synchronous task: snapshot use-after-free when the task spawns an un-awaited coroutine** — a sync-mode task body ran inline in the worker and its per-task snapshot arena (which backs every spawned closure's op_array) was freed the instant the body returned, while a coroutine the body had spawned was still pending; running it later dereferenced freed memory (Windows debug-heap crash; ASAN-caught on Linux). The body now runs as a coroutine in its own per-task **nursery** `Scope`: `Async\spawn()` inside the body lands in that scope on its own (no scope-pointer hijacking), and on task exit the scope is cancelled and *drained* — awaited until every spawned coroutine is physically disposed — before the snapshot is freed. ABI bumped to v0.20.0: new `zend_async_scope_await_after_cancellation_fn` exposes the C core of `Scope::awaitAfterCancellation` so the worker reuses the canonical zombie-aware drain instead of hand-rolling it. Regression test `tests/thread_pool/065-task_scope_nursery_no_uaf.phpt`. +- **`ThreadPool`: fatal in a task no longer leaves a use-after-free or a leaked libuv loop** — a fatal (e.g. OOM) in a task body now rejects the future with `ThreadTransferException` and tears the pool down cleanly. The snapshot's op_array name strings (`function_name`, `filename`) are materialized into refcounted heap strings so holders that outlive the snapshot arena (the closure, `PG(last_error_file)`) are freed by refcount instead of dangling. The `zend_bailout()` that a fatal re-raises through a parked `ThreadChannel` send/recv or the worker's slot wait is now caught so the channel/slot trigger is disposed before re-raising — an undisposed trigger's open `uv_async` would block `uv_loop_close` and leak the reactor's loop internals. Debug builds dump any libuv handle that survives reactor shutdown. Regression tests `tests/thread_pool/066`–`068`. - **`TaskSet`/`TaskGroup(scope: $scope)` use-after-free on teardown** — the group held only an event refcount on a PHP-supplied scope, which is shared with coroutine bookkeeping, so a finishing coroutine could free the scope while `group->scope` still pointed at it (`task_group.c:486`, seen as `zend_mm_heap corrupted`). The group now holds a strong ref to the external Scope object for its lifetime, so the scope can't be disposed while in use. Test `tests/task_group/043-task_group_external_scope_uaf.phpt`. ## [0.7.0] - 2026-06-02 diff --git a/libuv_reactor.c b/libuv_reactor.c index 799aaa1d..00b868d2 100644 --- a/libuv_reactor.c +++ b/libuv_reactor.c @@ -411,6 +411,18 @@ static void libuv_reactor_stop_with_exception(void) /* }}} */ +#ifdef ZEND_DEBUG +/* Dump a libuv handle that survived reactor shutdown — such a handle keeps the + * loop from closing (uv_loop_close => EBUSY) and leaks the loop's internals. */ +static void libuv_debug_dump_handle(uv_handle_t *handle, void *arg) +{ + (void) arg; + fprintf(stderr, "async: leftover libuv handle: type=%s active=%d closing=%d has_ref=%d\n", + uv_handle_type_name(uv_handle_get_type(handle)), + uv_is_active(handle), uv_is_closing(handle), uv_has_ref(handle)); +} +#endif + /* {{{ libuv_reactor_shutdown */ bool libuv_reactor_shutdown(void) { @@ -434,9 +446,20 @@ bool libuv_reactor_shutdown(void) if (uv_loop_alive(UVLOOP)) { #ifdef ZEND_DEBUG fprintf(stderr, "async: libuv shutdown timeout; loop left open\n"); + uv_walk(UVLOOP, libuv_debug_dump_handle, NULL); #endif } else { - uv_loop_close(UVLOOP); + /* uv_loop_close fails with EBUSY if any handle is still open (even an + * unref'd one, which uv_loop_alive ignores) — that would silently + * leak the loop's internals. Surface it in debug builds. */ + const int close_result = uv_loop_close(UVLOOP); + if (UNEXPECTED(close_result != 0)) { +#ifdef ZEND_DEBUG + fprintf(stderr, "async: uv_loop_close failed (%s); leftover handles:\n", + uv_err_name(close_result)); + uv_walk(UVLOOP, libuv_debug_dump_handle, NULL); +#endif + } } ASYNC_G(reactor_started) = false; diff --git a/tests/thread_pool/066-task_fatal_rejects_future.phpt b/tests/thread_pool/066-task_fatal_rejects_future.phpt index c12ae768..cc4a3b89 100644 --- a/tests/thread_pool/066-task_fatal_rejects_future.phpt +++ b/tests/thread_pool/066-task_fatal_rejects_future.phpt @@ -1,5 +1,5 @@ --TEST-- -ThreadPool: a fatal (OOM) in a sync task rejects its future with ThreadTransferException (not a hang) +ThreadPool: a fatal (OOM) in a sync task rejects its future with ThreadTransferException (no hang/UAF/leak) --SKIPIF-- +--INI-- +memory_limit=64M +--FILE-- +submit(function () { + $s = str_repeat('x', 500 * 1024 * 1024); // exceeds memory_limit -> bailout + return strlen($s); +}); + +var_dump(await($f)); +echo "done\n"; +--EXPECTF-- +%ANULL +done diff --git a/tests/thread_pool/068-concurrency_task_fatal_no_slot_leak.phpt b/tests/thread_pool/068-concurrency_task_fatal_no_slot_leak.phpt new file mode 100644 index 00000000..99f357d4 --- /dev/null +++ b/tests/thread_pool/068-concurrency_task_fatal_no_slot_leak.phpt @@ -0,0 +1,33 @@ +--TEST-- +ThreadPool: a fatal with a concurrency limit disposes the worker's slot_event trigger (no libuv loop leak) +--SKIPIF-- + +--INI-- +memory_limit=64M +--FILE-- + 0 the worker parks on its slot_event trigger + * at the limit. A fatal in a task longjmps past the worker's `done:` cleanup, + * which disposes slot_event — leaving its open uv_async to block uv_loop_close + * and leak the libuv loop. The worker's bailout handler now disposes slot_event. + * Caught by LeakSanitizer. + */ +use Async\ThreadPool; +use function Async\await; + +$pool = new ThreadPool(1, 0, null, true, 1); // coroutine mode, concurrency = 1 + +$f = $pool->submit(function () { + $s = str_repeat('x', 500 * 1024 * 1024); + return strlen($s); +}); + +var_dump(await($f)); +echo "done\n"; +--EXPECTF-- +%ANULL +done diff --git a/thread.c b/thread.c index 179391db..9286d67f 100644 --- a/thread.c +++ b/thread.c @@ -1645,6 +1645,17 @@ static void thread_copy_callable( */ static void thread_release_closure_copy(thread_release_ctx_t *ctx, async_thread_closure_copy_t *copy) { + /* Drop the snapshot's ref on the name strings. No-op while interned (shared + * bootloader); for a materialized per-task snapshot this is the base ref. */ + if (copy->func != NULL) { + if (copy->func->function_name != NULL) { + zend_string_release(copy->func->function_name); + } + if (copy->func->filename != NULL) { + zend_string_release(copy->func->filename); + } + } + if (copy->bound_vars) { zval *val; ZEND_HASH_FOREACH_VAL(copy->bound_vars, val) { @@ -1692,6 +1703,29 @@ async_thread_snapshot_t *async_thread_snapshot_create(const zend_fcall_t *entry, return snapshot; } +/* Turn an op_array's interned (arena-backed) name strings into refcounted heap + * copies, so holders that outlive the arena (closure, PG(last_error_file)) are + * freed by refcount instead of dangling into freed memory. Idempotent. */ +static void thread_materialize_op_array_names(zend_op_array *op_array) +{ + if (op_array->function_name != NULL && ZSTR_IS_INTERNED(op_array->function_name)) { + op_array->function_name = zend_string_init( + ZSTR_VAL(op_array->function_name), ZSTR_LEN(op_array->function_name), 0); + } + + if (op_array->filename != NULL && ZSTR_IS_INTERNED(op_array->filename)) { + op_array->filename = zend_string_init( + ZSTR_VAL(op_array->filename), ZSTR_LEN(op_array->filename), 0); + } +} + +void async_thread_snapshot_materialize_entry(async_thread_snapshot_t *snapshot) +{ + if (snapshot != NULL && snapshot->entry.func != NULL) { + thread_materialize_op_array_names(snapshot->entry.func); + } +} + /** * Free snapshot resources. */ diff --git a/thread.h b/thread.h index 08da0aa5..61a6edd6 100644 --- a/thread.h +++ b/thread.h @@ -73,6 +73,11 @@ async_thread_snapshot_t *async_thread_snapshot_create( */ void async_thread_snapshot_destroy(async_thread_snapshot_t *snapshot); +/* Materialize the entry op_array's name strings into normal refcounted heap + * strings so they outlive the snapshot arena. Per-task snapshots only (never the + * shared bootloader); idempotent. */ +void async_thread_snapshot_materialize_entry(async_thread_snapshot_t *snapshot); + /////////////////////////////////////////////////////////// /// Thread lifecycle — PHP request in child thread /////////////////////////////////////////////////////////// diff --git a/thread_channel.c b/thread_channel.c index 4166264c..837a9bdb 100644 --- a/thread_channel.c +++ b/thread_channel.c @@ -112,7 +112,26 @@ static bool thread_channel_send(zend_async_channel_t *channel, zval *value) zend_async_resume_when(ZEND_ASYNC_CURRENT_COROUTINE, &trigger->base, false, zend_async_waker_callback_resolve, NULL); - ZEND_ASYNC_SUSPEND(); + + /* A bailout through SUSPEND would skip the dispose paths below and leak the + * trigger (open uv_async blocks uv_loop_close). Catch, dispose, re-raise. */ + bool channel_bailed = false; + zend_try { + ZEND_ASYNC_SUSPEND(); + } zend_catch { + channel_bailed = true; + } zend_end_try(); + + if (UNEXPECTED(channel_bailed)) { + ASYNC_MUTEX_LOCK(ch->mutex); + zend_hash_index_del(&ch->sender_triggers, (zend_ulong)(uintptr_t) trigger); + ASYNC_MUTEX_UNLOCK(ch->mutex); + ZEND_ASYNC_WAKER_DESTROY(ZEND_ASYNC_CURRENT_COROUTINE); + async_thread_release_transferred_zval(&persistent_copy); + trigger->base.dispose(&trigger->base); + zend_bailout(); + } + ZEND_ASYNC_WAKER_DESTROY(ZEND_ASYNC_CURRENT_COROUTINE); /* Woke up — remove from sender queue */ @@ -182,7 +201,25 @@ static bool thread_channel_receive( zend_async_resume_when(ZEND_ASYNC_CURRENT_COROUTINE, cancellation, false, zend_async_waker_callback_resolve, NULL); } - ZEND_ASYNC_SUSPEND(); + + /* A bailout through SUSPEND would skip the dispose paths below and leak the + * trigger (open uv_async blocks uv_loop_close). Catch, dispose, re-raise. */ + bool channel_bailed = false; + zend_try { + ZEND_ASYNC_SUSPEND(); + } zend_catch { + channel_bailed = true; + } zend_end_try(); + + if (UNEXPECTED(channel_bailed)) { + ASYNC_MUTEX_LOCK(ch->mutex); + zend_hash_index_del(&ch->receiver_triggers, (zend_ulong)(uintptr_t) trigger); + ASYNC_MUTEX_UNLOCK(ch->mutex); + ZEND_ASYNC_WAKER_DESTROY(ZEND_ASYNC_CURRENT_COROUTINE); + trigger->base.dispose(&trigger->base); + zend_bailout(); + } + ZEND_ASYNC_WAKER_DESTROY(ZEND_ASYNC_CURRENT_COROUTINE); /* Woke up — remove from receiver queue, observe closed state */ diff --git a/thread_pool.c b/thread_pool.c index d2110b67..69440109 100644 --- a/thread_pool.c +++ b/thread_pool.c @@ -153,20 +153,16 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c async_thread_pool_t *pool = (async_thread_pool_t *) ctx; async_thread_channel_t *channel = pool->task_channel; int bailout = 0; - /* Future of the sync task whose body is running. A fatal in the body bails - * past the normal resolve, so the bailout handler rejects this one (volatile: - * written in the try, read in zend_catch). */ - zend_future_shared_state_t * volatile inflight_state = NULL; /* Per-worker pool scope: child of worker's main scope. Pinned for the * worker's lifetime; each spawned task lives in its own child scope of * this one (see thread_pool_spawn_task_coroutine). Created lazily, only * when coroutine_mode is enabled — sync workers don't need it. */ zend_async_scope_t *pool_scope = NULL; - /* Concurrency slot accounting (only used when pool->concurrency > 0). - * Worker parks on slot_event when at the limit; dispose decrements - * active and fires slot_event to wake it. */ + /* Concurrency accounting (pool->concurrency > 0 only). Worker parks on + * slot_event at the limit. Volatile: assigned in the try, read by the + * bailout handler below, so it must survive the longjmp. */ int32_t active_count = 0; - zend_async_trigger_event_t *slot_event = NULL; + zend_async_trigger_event_t * volatile slot_event = NULL; ZEND_ASSERT(event == NULL); @@ -342,6 +338,10 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c ZVAL_UNDEF(&retval); ZVAL_UNDEF(&callable); + /* Per-task snapshot is ours alone: materialize its op_array names into + * refcounted heap strings so they outlive the arena on a fatal. */ + async_thread_snapshot_materialize_entry(snapshot); + async_thread_create_closure(&snapshot->entry, &callable); if (UNEXPECTED(EG(exception))) { @@ -437,8 +437,11 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c goto task_cleanup; } - /* Run the body as a coroutine in a per-task nursery: Async\spawn() - * inside it lands in task_scope by itself, no scope hijacking. */ + /* Sync mode: run the body as a real coroutine in a per-task nursery + * scope, so CURRENT SCOPE follows it and Async\spawn() inside the body + * lands there — no scope hijacking. Cancel + drain the scope before + * freeing the snapshot, so an un-awaited child can't outlive the arena + * backing its op_array. */ zend_coroutine_t *worker_coro = ZEND_ASYNC_CURRENT_COROUTINE; zend_async_scope_t *task_scope = worker_coro != NULL ? ZEND_ASYNC_NEW_SCOPE(ZEND_ASYNC_CURRENT_SCOPE) : NULL; @@ -453,8 +456,9 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c goto task_cleanup; } - /* NOT-safe = un-awaited children are cancelled, not awaited. Pin so - * the scope can't self-dispose mid-drain; cleared before RELEASE. */ + /* Nursery: un-awaited children are cancelled at task exit. Pinned so + * the scope survives the cancel/drain instead of self-disposing when it + * empties; unpinned right before RELEASE. */ ZEND_ASYNC_SCOPE_CLR_DISPOSE_SAFELY(task_scope); ZEND_ASYNC_SCOPE_SET_OWNER_PINNED(task_scope); @@ -471,7 +475,9 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c goto task_cleanup; } - /* Coroutine takes over params/fcall; the snapshot stays ours. */ + /* Hand the prepared call to the body coroutine, reusing the params + * buffer the worker already populated. Ownership of params moves to + * the coroutine; the snapshot stays ours to free after the drain. */ zend_fcall_t *fcall = ecalloc(1, sizeof(zend_fcall_t)); fcall->fci = fci; fcall->fci_cache = fcc; @@ -482,15 +488,20 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c body->fcall = fcall; params = NULL; - /* Await the body. The callback copies its result/error into OUR waker - * while the body is still alive (it may be freed before we resume) - * and marks its exception handled so it can't cascade to the worker. */ - inflight_state = state; + /* Await the body. Its callback copies result/error into OUR waker + * while the body is still alive (we never read it after disposal). A + * fatal re-raises zend_bailout() out of the coroutine — caught here so + * we still reject the awaiter (body_bailed set only in catch → no + * volatile). */ + bool body_bailed = false; ZEND_ASYNC_WAKER_NEW(worker_coro); zend_async_resume_when(worker_coro, &body->event, false, zend_async_waker_callback_resolve, NULL); - ZEND_ASYNC_SUSPEND(); - inflight_state = NULL; + zend_try { + ZEND_ASYNC_SUSPEND(); + } zend_catch { + body_bailed = true; + } zend_end_try(); /* Decrement running and bump completed BEFORE notifying the awaiter * via complete/reject — otherwise a coroutine waking from await() @@ -498,6 +509,26 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c zend_atomic_int_dec(&pool->base.running_count); zend_atomic_int_inc(&pool->base.completed_count); + if (UNEXPECTED(body_bailed)) { + /* Fatal in the body: reject the awaiter, tear the pool down. Freeing + * the snapshot is safe — op_array names are materialized and children + * are heap-self-contained, so nothing reads the freed arena. */ + zend_async_waker_clean(worker_coro); + zend_object *bex = thread_pool_bailout_exception(); + async_future_shared_state_reject(state, bex); + thread_pool_close(pool); + thread_pool_drain_tasks(pool, true, bex); + OBJ_RELEASE(bex); + ZEND_ASYNC_SCOPE_CLR_OWNER_PINNED(task_scope); + ZEND_ASYNC_SCOPE_RELEASE(task_scope); + zval_ptr_dtor(&callable); + zval_ptr_dtor(&retval); + async_thread_snapshot_destroy(snapshot); + async_future_shared_state_delref(state); + zval_ptr_dtor(&task); + break; + } + zend_object *body_error = NULL; if (worker_coro->waker != NULL && worker_coro->waker->error != NULL) { body_error = worker_coro->waker->error; @@ -522,8 +553,9 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c zend_async_waker_clean(worker_coro); - /* Cancel and drain un-awaited children before freeing the snapshot - * their op_arrays live in. */ + /* Cancel coroutines the body spawned but left running, then await + * their physical disposal before freeing the snapshot arena that + * backs their op_arrays — so none can outlive the snapshot. */ if (!ZEND_ASYNC_SCOPE_IS_CLOSED(task_scope)) { ZEND_ASYNC_SCOPE_CANCEL(task_scope, NULL, false, false); ZEND_ASYNC_SCOPE_AWAIT_AFTER_CANCELLATION(task_scope, worker_coro, NULL, NULL, NULL); @@ -535,8 +567,8 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c ZEND_ASYNC_SCOPE_CLR_OWNER_PINNED(task_scope); ZEND_ASYNC_SCOPE_RELEASE(task_scope); - /* callable's op_array lives in the snapshot arena — drop it before - * freeing the snapshot. */ + /* Drop our closure ref and free the snapshot. Names are materialized, + * so closure destruction no longer reads the arena — order is free. */ zval_ptr_dtor(&callable); zval_ptr_dtor(&retval); async_thread_snapshot_destroy(snapshot); @@ -602,18 +634,16 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c * crashes it, so reject any still-pending tasks and exit cleanly. Done * before DELREF so the pool is still alive for the drain. */ zend_object *bex = thread_pool_bailout_exception(); - - /* The task whose body bailed was already dequeued, so drain_tasks (which - * only sees the channel) won't reach it — reject it here. */ - if (inflight_state != NULL) { - async_future_shared_state_reject(inflight_state, bex); - async_future_shared_state_delref(inflight_state); - inflight_state = NULL; - } - thread_pool_close(pool); thread_pool_drain_tasks(pool, true, bex); OBJ_RELEASE(bex); + + /* Bailout longjmped past `done:` (which disposes slot_event). Its open + * uv_async would block uv_loop_close — dispose it while reactor is up. */ + if (slot_event != NULL) { + slot_event->base.dispose(&slot_event->base); + slot_event = NULL; + } } /* Release worker's ref on pool */ From 16c8bbaeb077932e31581ba7301bec27c7b77bc9 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:21:04 +0000 Subject: [PATCH 4/7] #159: deliver fatal cause to the awaiter robustly (coroutine mode no longer null) Two related improvements to how a ThreadPool task's fatal reaches the awaiter: 1. Coroutine-mode tasks no longer resolve to a silent null on a fatal. The pool_task_dispose completion path only checked coroutine->exception, but a bailout (OOM/fatal/exit) is not a thrown exception, so the future completed with UNDEF -> null. It now treats "no exception + UNDEF result" as a bailout and rejects the future with the cause, matching synchronous mode. 2. The cause crosses to the parent as a plain string, not a PHP object built in the dying worker. New async_future_shared_state_reject_bailout stores a pestrdup'd message (system malloc, not bound by memory_limit) on the shared state; shared_state_trigger_cb builds the ThreadTransferException on the healthy parent side. So the real fatal message survives even when the worker can no longer allocate a PHP object under the exhausted allocator. Tests thread_pool/067, 068 now assert the cause is delivered (was %ANULL). --- CHANGELOG.md | 1 + future.c | 50 +++++++++++++++++++ future.h | 21 ++++++++ ...-coroutine_task_fatal_no_trigger_leak.phpt | 19 +++++-- ...8-concurrency_task_fatal_no_slot_leak.phpt | 14 ++++-- thread.c | 8 +++ thread.h | 4 ++ thread_pool.c | 26 +++++++--- 8 files changed, 127 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de137059..2b3772aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **`ThreadPool` synchronous task: snapshot use-after-free when the task spawns an un-awaited coroutine** — a sync-mode task body ran inline in the worker and its per-task snapshot arena (which backs every spawned closure's op_array) was freed the instant the body returned, while a coroutine the body had spawned was still pending; running it later dereferenced freed memory (Windows debug-heap crash; ASAN-caught on Linux). The body now runs as a coroutine in its own per-task **nursery** `Scope`: `Async\spawn()` inside the body lands in that scope on its own (no scope-pointer hijacking), and on task exit the scope is cancelled and *drained* — awaited until every spawned coroutine is physically disposed — before the snapshot is freed. ABI bumped to v0.20.0: new `zend_async_scope_await_after_cancellation_fn` exposes the C core of `Scope::awaitAfterCancellation` so the worker reuses the canonical zombie-aware drain instead of hand-rolling it. Regression test `tests/thread_pool/065-task_scope_nursery_no_uaf.phpt`. - **`ThreadPool`: fatal in a task no longer leaves a use-after-free or a leaked libuv loop** — a fatal (e.g. OOM) in a task body now rejects the future with `ThreadTransferException` and tears the pool down cleanly. The snapshot's op_array name strings (`function_name`, `filename`) are materialized into refcounted heap strings so holders that outlive the snapshot arena (the closure, `PG(last_error_file)`) are freed by refcount instead of dangling. The `zend_bailout()` that a fatal re-raises through a parked `ThreadChannel` send/recv or the worker's slot wait is now caught so the channel/slot trigger is disposed before re-raising — an undisposed trigger's open `uv_async` would block `uv_loop_close` and leak the reactor's loop internals. Debug builds dump any libuv handle that survives reactor shutdown. Regression tests `tests/thread_pool/066`–`068`. +- **`ThreadPool` coroutine-mode task: a fatal now reports its cause instead of resolving to `null`** — in `coroutine: true` mode a task that hit a fatal/OOM (or `exit()`/`die()`) resolved its future to a silent `null`, because the completion path only checked for a thrown exception and a bailout is not one. It now detects the bailout (no exception, `UNDEF` result) and rejects the future with the fatal message, matching synchronous mode. The cause crosses to the awaiter as a plain string on the shared future state (new `async_future_shared_state_reject_bailout`): the dying worker only copies one persistent string (`pestrdup`, not bound by `memory_limit`) and the `ThreadTransferException` is built on the healthy parent side — so the real cause survives even when the worker can no longer allocate a PHP object. Tests `tests/thread_pool/067`, `068`. - **`TaskSet`/`TaskGroup(scope: $scope)` use-after-free on teardown** — the group held only an event refcount on a PHP-supplied scope, which is shared with coroutine bookkeeping, so a finishing coroutine could free the scope while `group->scope` still pointed at it (`task_group.c:486`, seen as `zend_mm_heap corrupted`). The group now holds a strong ref to the external Scope object for its lifetime, so the scope can't be disposed while in use. Test `tests/task_group/043-task_group_external_scope_uaf.phpt`. ## [0.7.0] - 2026-06-02 diff --git a/future.c b/future.c index e9541a23..d62fb8ed 100644 --- a/future.c +++ b/future.c @@ -1962,6 +1962,11 @@ void async_future_shared_state_destroy(zend_future_shared_state_t *state) async_thread_release_transferred_zval(&state->transferred_exception); } + if (state->bailout_cause != NULL) { + pefree(state->bailout_cause, 1); + state->bailout_cause = NULL; + } + if (state->trigger != NULL) { state->trigger->base.dispose(&state->trigger->base); state->trigger = NULL; @@ -1985,6 +1990,20 @@ static void shared_state_trigger_cb(zend_async_event_t *event, zend_future_shared_state_t *state = cb->state; zend_future_t *future = state->target_future; + if (state->bailout_cause != NULL) { + /* Source bailed out — build the exception here, in the healthy + * destination thread, from the plain cause string. */ + zend_object *exc = async_thread_create_transfer_exception(state->bailout_cause); + pefree(state->bailout_cause, 1); + state->bailout_cause = NULL; + + ZEND_FUTURE_REJECT(future, exc); + OBJ_RELEASE(exc); + + state->trigger->base.stop(&state->trigger->base); + return; + } + if (!Z_ISUNDEF(state->transferred_exception)) { zval exc_zval; async_thread_load_zval(&exc_zval, &state->transferred_exception); @@ -2059,6 +2078,7 @@ zend_future_shared_state_t *async_future_shared_state_create(void) ZVAL_UNDEF(&state->transferred_result); ZVAL_UNDEF(&state->transferred_exception); + state->bailout_cause = NULL; ZEND_ATOMIC_INT_INIT(&state->completed, 0); ZEND_ATOMIC_INT_INIT(&state->ref_count, 0); ASYNC_MUTEX_INIT(state->mutex); @@ -2152,6 +2172,36 @@ void async_future_shared_state_reject(zend_future_shared_state_t *state, zend_ob ASYNC_MUTEX_UNLOCK(state->mutex); } +/** @copydoc async_future_shared_state_reject_bailout */ +void async_future_shared_state_reject_bailout(zend_future_shared_state_t *state, const char *message) +{ + /* Fast path: already completed */ + if (zend_atomic_int_load(&state->completed)) { + return; + } + + ASYNC_MUTEX_LOCK(state->mutex); + + if (zend_atomic_int_load(&state->completed)) { + ASYNC_MUTEX_UNLOCK(state->mutex); + return; + } + + zend_atomic_int_store(&state->completed, 1); + + /* Only allocation on this path is one persistent string copy (system + * malloc — not bound by memory_limit), so it survives the OOM that + * triggered the bailout. The exception object is built on the parent side. */ + state->bailout_cause = pestrdup(message, 1); + + /* Owner may have already torn down the trigger — nobody to notify. */ + if (state->trigger != NULL) { + state->trigger->trigger(state->trigger); + } + + ASYNC_MUTEX_UNLOCK(state->mutex); +} + /** @copydoc async_future_shared_state_source_cb */ zend_async_event_callback_t *async_future_shared_state_source_cb(zend_future_shared_state_t *state) diff --git a/future.h b/future.h index f472b49f..c1d901a5 100644 --- a/future.h +++ b/future.h @@ -130,6 +130,13 @@ typedef struct _zend_future_shared_state_s { /** Transferred exception in persistent memory (UNDEF if success) */ zval transferred_exception; + /** Plain bailout-cause message (pemalloc), set when the source thread + * bailed out (fatal/OOM/exit) instead of throwing. The destination thread + * builds a ThreadTransferException from it. NULL unless a bailout occurred. + * Carried as a C string so the source never has to allocate a PHP object + * under an exhausted allocator. */ + char *bailout_cause; + /** Trigger event bound to the destination thread's event loop */ zend_async_trigger_event_t *trigger; @@ -183,6 +190,20 @@ void async_future_shared_state_complete(zend_future_shared_state_t *state, zval */ void async_future_shared_state_reject(zend_future_shared_state_t *state, zend_object *exception); +/** + * @brief Reject a shared state with a bailout cause message. Thread-safe. + * + * Stores @p message as a plain persistent string and fires the trigger; the + * destination thread builds the actual ThreadTransferException. Use instead of + * async_future_shared_state_reject when the source bailed out (fatal/OOM/exit) + * and must not allocate a PHP exception under an exhausted allocator. No-op if + * already completed. + * + * @param state The shared state to reject. + * @param message The cause text (copied with pestrdup — persistent allocator). + */ +void async_future_shared_state_reject_bailout(zend_future_shared_state_t *state, const char *message); + /** * @brief Increment the shared state reference count. Thread-safe. * diff --git a/tests/thread_pool/067-coroutine_task_fatal_no_trigger_leak.phpt b/tests/thread_pool/067-coroutine_task_fatal_no_trigger_leak.phpt index 0afb8a2e..f923240e 100644 --- a/tests/thread_pool/067-coroutine_task_fatal_no_trigger_leak.phpt +++ b/tests/thread_pool/067-coroutine_task_fatal_no_trigger_leak.phpt @@ -1,5 +1,5 @@ --TEST-- -ThreadPool: a fatal in a coroutine-mode task disposes the worker's channel trigger (no libuv loop leak) +ThreadPool: a fatal in a coroutine-mode task delivers the cause and disposes the worker's channel trigger (no libuv loop leak) --SKIPIF-- submit(function () { return strlen($s); }); -var_dump(await($f)); +try { + var_dump(await($f)); +} catch (\Throwable $e) { + printf("%s: %s\n", get_class($e), + str_contains($e->getMessage(), 'memory size') ? 'memory exhausted' : 'other'); +} echo "done\n"; --EXPECTF-- -%ANULL +%AAsync\ThreadTransferException: memory exhausted done diff --git a/tests/thread_pool/068-concurrency_task_fatal_no_slot_leak.phpt b/tests/thread_pool/068-concurrency_task_fatal_no_slot_leak.phpt index 99f357d4..fe79ceb7 100644 --- a/tests/thread_pool/068-concurrency_task_fatal_no_slot_leak.phpt +++ b/tests/thread_pool/068-concurrency_task_fatal_no_slot_leak.phpt @@ -13,8 +13,9 @@ memory_limit=64M * Regression: with concurrency > 0 the worker parks on its slot_event trigger * at the limit. A fatal in a task longjmps past the worker's `done:` cleanup, * which disposes slot_event — leaving its open uv_async to block uv_loop_close - * and leak the libuv loop. The worker's bailout handler now disposes slot_event. - * Caught by LeakSanitizer. + * and leak the libuv loop. The worker's bailout handler now disposes slot_event + * (no-leak verified by LeakSanitizer). Also asserts the fatal cause reaches the + * awaiter (reject, not a silent null). */ use Async\ThreadPool; use function Async\await; @@ -26,8 +27,13 @@ $f = $pool->submit(function () { return strlen($s); }); -var_dump(await($f)); +try { + var_dump(await($f)); +} catch (\Throwable $e) { + printf("%s: %s\n", get_class($e), + str_contains($e->getMessage(), 'memory size') ? 'memory exhausted' : 'other'); +} echo "done\n"; --EXPECTF-- -%ANULL +%AAsync\ThreadTransferException: memory exhausted done diff --git a/thread.c b/thread.c index 9286d67f..0a9a0be8 100644 --- a/thread.c +++ b/thread.c @@ -1775,6 +1775,14 @@ static zend_object *thread_create_transfer_exception( return Z_OBJ(exception_zv); } +/* Public builder for a ThreadTransferException carrying a plain cause message. + * Called on the destination thread to materialize a future shared state's + * bailout_cause into a real exception. */ +zend_object *async_thread_create_transfer_exception(const char *message) +{ + return thread_create_transfer_exception(message, NULL); +} + static zend_object *thread_wrap_remote_exception( zend_object *remote_obj, const char *remote_class_name) { diff --git a/thread.h b/thread.h index 61a6edd6..58d6816f 100644 --- a/thread.h +++ b/thread.h @@ -186,6 +186,10 @@ void async_thread_defer_release_ctx( PHP_ASYNC_API extern zend_class_entry *async_ce_remote_exception; PHP_ASYNC_API extern zend_class_entry *async_ce_thread_transfer_exception; +/* Build a ThreadTransferException carrying a plain cause message. Used on the + * destination thread to turn a future's bailout_cause string into an exception. */ +zend_object *async_thread_create_transfer_exception(const char *message); + /////////////////////////////////////////////////////////// /// Thread PHP object — Async\Thread class /////////////////////////////////////////////////////////// diff --git a/thread_pool.c b/thread_pool.c index 69440109..13a4e958 100644 --- a/thread_pool.c +++ b/thread_pool.c @@ -92,6 +92,15 @@ static zend_object *thread_pool_bailout_exception(void) : "ThreadPool worker terminated via exit() or a fatal error"); } +/* The bailout cause text (the fatal/OOM message). Returns a borrowed pointer + * valid until request shutdown; the future copies it with pestrdup. */ +static const char *thread_pool_bailout_cause(void) +{ + const zend_string *msg = PG(last_error_message); + return msg != NULL ? ZSTR_VAL(msg) + : "ThreadPool task terminated via exit() or a fatal error"; +} + /* Build a clean ThreadTransferException carrying another exception's message. * Used for errors thrown deep in the cross-thread transfer machinery (e.g. * "Cannot load transferred object"): that Error's full object graph — its @@ -510,12 +519,14 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c zend_atomic_int_inc(&pool->base.completed_count); if (UNEXPECTED(body_bailed)) { - /* Fatal in the body: reject the awaiter, tear the pool down. Freeing - * the snapshot is safe — op_array names are materialized and children - * are heap-self-contained, so nothing reads the freed arena. */ + /* Fatal in the body: deliver the cause to the awaiter and tear the + * pool down. The in-flight task uses the cause-string path (no PHP + * object built under the exhausted allocator); pending tasks are + * drained with a regular exception. Freeing the snapshot is safe — + * op_array names are materialized, so nothing reads the freed arena. */ zend_async_waker_clean(worker_coro); + async_future_shared_state_reject_bailout(state, thread_pool_bailout_cause()); zend_object *bex = thread_pool_bailout_exception(); - async_future_shared_state_reject(state, bex); thread_pool_close(pool); thread_pool_drain_tasks(pool, true, bex); OBJ_RELEASE(bex); @@ -689,9 +700,10 @@ static void pool_task_dispose(zend_coroutine_t *coroutine) } else if (Z_TYPE(coroutine->result) != IS_UNDEF) { async_future_shared_state_complete(ctx->state, &coroutine->result); } else { - zval undef; - ZVAL_UNDEF(&undef); - async_future_shared_state_complete(ctx->state, &undef); + /* No exception and no result means the body bailed out (fatal/OOM) or + * called exit()/die() — a normal return leaves result IS_NULL, not UNDEF. + * Deliver the cause to the awaiter instead of a silent null. */ + async_future_shared_state_reject_bailout(ctx->state, thread_pool_bailout_cause()); } async_future_shared_state_delref(ctx->state); From edb6d111e1292c7c524a24c55ed61a92708071ca Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:21:25 +0000 Subject: [PATCH 5/7] #159: fix cross-thread UAF of closure captured-variable names in spawn_thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A transferred closure's use-variable names were copied with zend_string_dup(), which returns interned strings as-is. Those names are interned in the parent thread, so the worker's snapshot stored pointers into the parent's interned- string table. If the parent ended/aborted its request (zend_interned_strings_ deactivate frees the table) while a worker was still constructing the closure, the worker read freed memory in async_thread_create_closure (ZSTR_VAL(key)) — heap-use-after-free, reproducible under WSL2+ASAN, deterministic 6/6. Copy each key into a private persistent string (zend_string_init persistent=1) owned by the snapshot, independent of the parent's interned-string table. Verified: tests/thread/050,052 now pass 5/5 under --asan + detect_leaks=1 (were failing 6/6); full thread suite 178 passed / 0 failed. --- CHANGELOG.md | 1 + thread.c | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b3772aa..4c3fbb17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`ThreadPool` synchronous task: snapshot use-after-free when the task spawns an un-awaited coroutine** — a sync-mode task body ran inline in the worker and its per-task snapshot arena (which backs every spawned closure's op_array) was freed the instant the body returned, while a coroutine the body had spawned was still pending; running it later dereferenced freed memory (Windows debug-heap crash; ASAN-caught on Linux). The body now runs as a coroutine in its own per-task **nursery** `Scope`: `Async\spawn()` inside the body lands in that scope on its own (no scope-pointer hijacking), and on task exit the scope is cancelled and *drained* — awaited until every spawned coroutine is physically disposed — before the snapshot is freed. ABI bumped to v0.20.0: new `zend_async_scope_await_after_cancellation_fn` exposes the C core of `Scope::awaitAfterCancellation` so the worker reuses the canonical zombie-aware drain instead of hand-rolling it. Regression test `tests/thread_pool/065-task_scope_nursery_no_uaf.phpt`. - **`ThreadPool`: fatal in a task no longer leaves a use-after-free or a leaked libuv loop** — a fatal (e.g. OOM) in a task body now rejects the future with `ThreadTransferException` and tears the pool down cleanly. The snapshot's op_array name strings (`function_name`, `filename`) are materialized into refcounted heap strings so holders that outlive the snapshot arena (the closure, `PG(last_error_file)`) are freed by refcount instead of dangling. The `zend_bailout()` that a fatal re-raises through a parked `ThreadChannel` send/recv or the worker's slot wait is now caught so the channel/slot trigger is disposed before re-raising — an undisposed trigger's open `uv_async` would block `uv_loop_close` and leak the reactor's loop internals. Debug builds dump any libuv handle that survives reactor shutdown. Regression tests `tests/thread_pool/066`–`068`. - **`ThreadPool` coroutine-mode task: a fatal now reports its cause instead of resolving to `null`** — in `coroutine: true` mode a task that hit a fatal/OOM (or `exit()`/`die()`) resolved its future to a silent `null`, because the completion path only checked for a thrown exception and a bailout is not one. It now detects the bailout (no exception, `UNDEF` result) and rejects the future with the fatal message, matching synchronous mode. The cause crosses to the awaiter as a plain string on the shared future state (new `async_future_shared_state_reject_bailout`): the dying worker only copies one persistent string (`pestrdup`, not bound by `memory_limit`) and the `ThreadTransferException` is built on the healthy parent side — so the real cause survives even when the worker can no longer allocate a PHP object. Tests `tests/thread_pool/067`, `068`. +- **`spawn_thread`/`ThreadPool`: cross-thread use-after-free of a closure's captured-variable names** — a transferred closure's `use`-variable names were copied with `zend_string_dup()`, which returns interned strings unchanged; the variable names are interned in the parent thread, so the worker's snapshot held pointers into the parent's interned-string table. When the parent aborted/ended its request (`zend_interned_strings_deactivate`) while a worker was still building the closure, the worker read freed memory in `async_thread_create_closure` (ASAN `heap-use-after-free`). The keys are now copied into private persistent strings owned by the snapshot. Regression tests `tests/thread/050`, `052`. - **`TaskSet`/`TaskGroup(scope: $scope)` use-after-free on teardown** — the group held only an event refcount on a PHP-supplied scope, which is shared with coroutine bookkeeping, so a finishing coroutine could free the scope while `group->scope` still pointed at it (`task_group.c:486`, seen as `zend_mm_heap corrupted`). The group now holds a strong ref to the external Scope object for its lifetime, so the scope can't be disposed while in use. Test `tests/task_group/043-task_group_external_scope_uaf.phpt`. ## [0.7.0] - 2026-06-02 diff --git a/thread.c b/thread.c index 0a9a0be8..f63f5c44 100644 --- a/thread.c +++ b/thread.c @@ -1604,7 +1604,12 @@ static void thread_copy_callable( thread_release_subgraph_zval(&transferred); break; } - zend_string *pkey = zend_string_dup(key, 1); + /* Force a private persistent copy even when the key is interned: + * zend_string_dup() aliases interned strings, but an interned key + * belongs to the source thread's interned-string table and dangles + * once that thread shuts down while a worker is still reading it + * (cross-thread UAF in async_thread_create_closure). */ + zend_string *pkey = zend_string_init(ZSTR_VAL(key), ZSTR_LEN(key), 1); zend_hash_add(dst->bound_vars, pkey, &transferred); zend_string_release(pkey); } ZEND_HASH_FOREACH_END(); From 3552230e34d3825ed6a7ed5fa21c4a290084cc12 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:04:19 +0000 Subject: [PATCH 6/7] #159: mark the copied bound_vars key PERSISTENT_LOCAL (fix zend_rc_debug assert) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit copied the captured-variable name into a persistent string with zend_string_init(persistent=1) but did not flag it GC_PERSISTENT_LOCAL. On the parent thread (where zend_rc_debug is enabled in --enable-rc-debug ASAN builds) the GC_ADDREF inside zend_hash_add then asserts "GC_PERSISTENT without GC_PERSISTENT_LOCAL" — failing every cross-thread test in the CI OpCache ASAN job (remote_future/*, thread/030,031, task_group/040). Mark the key PERSISTENT_LOCAL right after init, exactly as the dst->bound_vars hash itself is marked one line above. Verified under a full -DZEND_RC_DEBUG=1 + opcache.enable_cli=1 + ASAN/LSan rebuild: remote_future/thread/task_group/thread_pool/thread_channel = 236 passed, 0 failed. --- thread.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/thread.c b/thread.c index f63f5c44..69d4869f 100644 --- a/thread.c +++ b/thread.c @@ -1608,8 +1608,11 @@ static void thread_copy_callable( * zend_string_dup() aliases interned strings, but an interned key * belongs to the source thread's interned-string table and dangles * once that thread shuts down while a worker is still reading it - * (cross-thread UAF in async_thread_create_closure). */ + * (cross-thread UAF in async_thread_create_closure). Mark it + * PERSISTENT_LOCAL like the hash above so the GC_ADDREF in + * zend_hash_add doesn't trip the zend_rc_debug assertion. */ zend_string *pkey = zend_string_init(ZSTR_VAL(key), ZSTR_LEN(key), 1); + GC_MAKE_PERSISTENT_LOCAL(pkey); zend_hash_add(dst->bound_vars, pkey, &transferred); zend_string_release(pkey); } ZEND_HASH_FOREACH_END(); From 85cd102bd9e17f0d02b1a8374bd77a0c90433147 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+EdmondDantes@users.noreply.github.com> Date: Mon, 8 Jun 2026 16:41:48 +0000 Subject: [PATCH 7/7] #159: keep bailout out of Future; resolve the future with an exception The cross-thread bailout cause does not belong on the generic zend_future_shared_state_t (it is shared by RemoteFuture and every cross-thread future, not just ThreadPool). Drop the bailout_cause field, reject_bailout, the destination-side build branch, and the async_thread_create_transfer_exception helper. A task that bailed out now resolves its future the normal way: build a ThreadTransferException in the worker and reject via async_future_shared_state_ reject. Coroutine-mode still reports the cause instead of a silent null (pool_task_dispose detects "no exception + UNDEF result" and rejects). Also trims the verbose comments added during this work to 1-2 lines. Verified under -DZEND_RC_DEBUG=1 + opcache.enable_cli=1 + ASAN/LSan: remote_future/thread/task_group/thread_pool/thread_channel = 236 passed, 0 failed. --- CHANGELOG.md | 2 +- future.c | 50 ----------------------------------------- future.h | 21 ----------------- thread.c | 25 +++++---------------- thread.h | 9 ++------ thread_pool.c | 62 +++++++++++++++++---------------------------------- 6 files changed, 28 insertions(+), 141 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c3fbb17..022681a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **`ThreadPool` synchronous task: snapshot use-after-free when the task spawns an un-awaited coroutine** — a sync-mode task body ran inline in the worker and its per-task snapshot arena (which backs every spawned closure's op_array) was freed the instant the body returned, while a coroutine the body had spawned was still pending; running it later dereferenced freed memory (Windows debug-heap crash; ASAN-caught on Linux). The body now runs as a coroutine in its own per-task **nursery** `Scope`: `Async\spawn()` inside the body lands in that scope on its own (no scope-pointer hijacking), and on task exit the scope is cancelled and *drained* — awaited until every spawned coroutine is physically disposed — before the snapshot is freed. ABI bumped to v0.20.0: new `zend_async_scope_await_after_cancellation_fn` exposes the C core of `Scope::awaitAfterCancellation` so the worker reuses the canonical zombie-aware drain instead of hand-rolling it. Regression test `tests/thread_pool/065-task_scope_nursery_no_uaf.phpt`. - **`ThreadPool`: fatal in a task no longer leaves a use-after-free or a leaked libuv loop** — a fatal (e.g. OOM) in a task body now rejects the future with `ThreadTransferException` and tears the pool down cleanly. The snapshot's op_array name strings (`function_name`, `filename`) are materialized into refcounted heap strings so holders that outlive the snapshot arena (the closure, `PG(last_error_file)`) are freed by refcount instead of dangling. The `zend_bailout()` that a fatal re-raises through a parked `ThreadChannel` send/recv or the worker's slot wait is now caught so the channel/slot trigger is disposed before re-raising — an undisposed trigger's open `uv_async` would block `uv_loop_close` and leak the reactor's loop internals. Debug builds dump any libuv handle that survives reactor shutdown. Regression tests `tests/thread_pool/066`–`068`. -- **`ThreadPool` coroutine-mode task: a fatal now reports its cause instead of resolving to `null`** — in `coroutine: true` mode a task that hit a fatal/OOM (or `exit()`/`die()`) resolved its future to a silent `null`, because the completion path only checked for a thrown exception and a bailout is not one. It now detects the bailout (no exception, `UNDEF` result) and rejects the future with the fatal message, matching synchronous mode. The cause crosses to the awaiter as a plain string on the shared future state (new `async_future_shared_state_reject_bailout`): the dying worker only copies one persistent string (`pestrdup`, not bound by `memory_limit`) and the `ThreadTransferException` is built on the healthy parent side — so the real cause survives even when the worker can no longer allocate a PHP object. Tests `tests/thread_pool/067`, `068`. +- **`ThreadPool` coroutine-mode task: a fatal now reports its cause instead of resolving to `null`** — in `coroutine: true` mode a task that hit a fatal/OOM (or `exit()`/`die()`) resolved its future to a silent `null`, because the completion path only checked for a thrown exception and a bailout is not one. It now detects the bailout (no exception, `UNDEF` result) and rejects the future with a `ThreadTransferException` carrying the fatal message, matching synchronous mode. Tests `tests/thread_pool/067`, `068`. - **`spawn_thread`/`ThreadPool`: cross-thread use-after-free of a closure's captured-variable names** — a transferred closure's `use`-variable names were copied with `zend_string_dup()`, which returns interned strings unchanged; the variable names are interned in the parent thread, so the worker's snapshot held pointers into the parent's interned-string table. When the parent aborted/ended its request (`zend_interned_strings_deactivate`) while a worker was still building the closure, the worker read freed memory in `async_thread_create_closure` (ASAN `heap-use-after-free`). The keys are now copied into private persistent strings owned by the snapshot. Regression tests `tests/thread/050`, `052`. - **`TaskSet`/`TaskGroup(scope: $scope)` use-after-free on teardown** — the group held only an event refcount on a PHP-supplied scope, which is shared with coroutine bookkeeping, so a finishing coroutine could free the scope while `group->scope` still pointed at it (`task_group.c:486`, seen as `zend_mm_heap corrupted`). The group now holds a strong ref to the external Scope object for its lifetime, so the scope can't be disposed while in use. Test `tests/task_group/043-task_group_external_scope_uaf.phpt`. diff --git a/future.c b/future.c index d62fb8ed..e9541a23 100644 --- a/future.c +++ b/future.c @@ -1962,11 +1962,6 @@ void async_future_shared_state_destroy(zend_future_shared_state_t *state) async_thread_release_transferred_zval(&state->transferred_exception); } - if (state->bailout_cause != NULL) { - pefree(state->bailout_cause, 1); - state->bailout_cause = NULL; - } - if (state->trigger != NULL) { state->trigger->base.dispose(&state->trigger->base); state->trigger = NULL; @@ -1990,20 +1985,6 @@ static void shared_state_trigger_cb(zend_async_event_t *event, zend_future_shared_state_t *state = cb->state; zend_future_t *future = state->target_future; - if (state->bailout_cause != NULL) { - /* Source bailed out — build the exception here, in the healthy - * destination thread, from the plain cause string. */ - zend_object *exc = async_thread_create_transfer_exception(state->bailout_cause); - pefree(state->bailout_cause, 1); - state->bailout_cause = NULL; - - ZEND_FUTURE_REJECT(future, exc); - OBJ_RELEASE(exc); - - state->trigger->base.stop(&state->trigger->base); - return; - } - if (!Z_ISUNDEF(state->transferred_exception)) { zval exc_zval; async_thread_load_zval(&exc_zval, &state->transferred_exception); @@ -2078,7 +2059,6 @@ zend_future_shared_state_t *async_future_shared_state_create(void) ZVAL_UNDEF(&state->transferred_result); ZVAL_UNDEF(&state->transferred_exception); - state->bailout_cause = NULL; ZEND_ATOMIC_INT_INIT(&state->completed, 0); ZEND_ATOMIC_INT_INIT(&state->ref_count, 0); ASYNC_MUTEX_INIT(state->mutex); @@ -2172,36 +2152,6 @@ void async_future_shared_state_reject(zend_future_shared_state_t *state, zend_ob ASYNC_MUTEX_UNLOCK(state->mutex); } -/** @copydoc async_future_shared_state_reject_bailout */ -void async_future_shared_state_reject_bailout(zend_future_shared_state_t *state, const char *message) -{ - /* Fast path: already completed */ - if (zend_atomic_int_load(&state->completed)) { - return; - } - - ASYNC_MUTEX_LOCK(state->mutex); - - if (zend_atomic_int_load(&state->completed)) { - ASYNC_MUTEX_UNLOCK(state->mutex); - return; - } - - zend_atomic_int_store(&state->completed, 1); - - /* Only allocation on this path is one persistent string copy (system - * malloc — not bound by memory_limit), so it survives the OOM that - * triggered the bailout. The exception object is built on the parent side. */ - state->bailout_cause = pestrdup(message, 1); - - /* Owner may have already torn down the trigger — nobody to notify. */ - if (state->trigger != NULL) { - state->trigger->trigger(state->trigger); - } - - ASYNC_MUTEX_UNLOCK(state->mutex); -} - /** @copydoc async_future_shared_state_source_cb */ zend_async_event_callback_t *async_future_shared_state_source_cb(zend_future_shared_state_t *state) diff --git a/future.h b/future.h index c1d901a5..f472b49f 100644 --- a/future.h +++ b/future.h @@ -130,13 +130,6 @@ typedef struct _zend_future_shared_state_s { /** Transferred exception in persistent memory (UNDEF if success) */ zval transferred_exception; - /** Plain bailout-cause message (pemalloc), set when the source thread - * bailed out (fatal/OOM/exit) instead of throwing. The destination thread - * builds a ThreadTransferException from it. NULL unless a bailout occurred. - * Carried as a C string so the source never has to allocate a PHP object - * under an exhausted allocator. */ - char *bailout_cause; - /** Trigger event bound to the destination thread's event loop */ zend_async_trigger_event_t *trigger; @@ -190,20 +183,6 @@ void async_future_shared_state_complete(zend_future_shared_state_t *state, zval */ void async_future_shared_state_reject(zend_future_shared_state_t *state, zend_object *exception); -/** - * @brief Reject a shared state with a bailout cause message. Thread-safe. - * - * Stores @p message as a plain persistent string and fires the trigger; the - * destination thread builds the actual ThreadTransferException. Use instead of - * async_future_shared_state_reject when the source bailed out (fatal/OOM/exit) - * and must not allocate a PHP exception under an exhausted allocator. No-op if - * already completed. - * - * @param state The shared state to reject. - * @param message The cause text (copied with pestrdup — persistent allocator). - */ -void async_future_shared_state_reject_bailout(zend_future_shared_state_t *state, const char *message); - /** * @brief Increment the shared state reference count. Thread-safe. * diff --git a/thread.c b/thread.c index 69d4869f..e5dd913c 100644 --- a/thread.c +++ b/thread.c @@ -1604,13 +1604,8 @@ static void thread_copy_callable( thread_release_subgraph_zval(&transferred); break; } - /* Force a private persistent copy even when the key is interned: - * zend_string_dup() aliases interned strings, but an interned key - * belongs to the source thread's interned-string table and dangles - * once that thread shuts down while a worker is still reading it - * (cross-thread UAF in async_thread_create_closure). Mark it - * PERSISTENT_LOCAL like the hash above so the GC_ADDREF in - * zend_hash_add doesn't trip the zend_rc_debug assertion. */ + /* Private persistent copy: an interned key aliases the parent's + * interned table and dangles after the parent shuts down. */ zend_string *pkey = zend_string_init(ZSTR_VAL(key), ZSTR_LEN(key), 1); GC_MAKE_PERSISTENT_LOCAL(pkey); zend_hash_add(dst->bound_vars, pkey, &transferred); @@ -1653,8 +1648,7 @@ static void thread_copy_callable( */ static void thread_release_closure_copy(thread_release_ctx_t *ctx, async_thread_closure_copy_t *copy) { - /* Drop the snapshot's ref on the name strings. No-op while interned (shared - * bootloader); for a materialized per-task snapshot this is the base ref. */ + /* Release name strings (no-op while interned; the base ref once materialized). */ if (copy->func != NULL) { if (copy->func->function_name != NULL) { zend_string_release(copy->func->function_name); @@ -1711,9 +1705,8 @@ async_thread_snapshot_t *async_thread_snapshot_create(const zend_fcall_t *entry, return snapshot; } -/* Turn an op_array's interned (arena-backed) name strings into refcounted heap - * copies, so holders that outlive the arena (closure, PG(last_error_file)) are - * freed by refcount instead of dangling into freed memory. Idempotent. */ +/* Heap-copy the op_array's interned (arena-backed) name strings so holders that + * outlive the arena (closure, PG(last_error_file)) are freed by refcount. */ static void thread_materialize_op_array_names(zend_op_array *op_array) { if (op_array->function_name != NULL && ZSTR_IS_INTERNED(op_array->function_name)) { @@ -1783,14 +1776,6 @@ static zend_object *thread_create_transfer_exception( return Z_OBJ(exception_zv); } -/* Public builder for a ThreadTransferException carrying a plain cause message. - * Called on the destination thread to materialize a future shared state's - * bailout_cause into a real exception. */ -zend_object *async_thread_create_transfer_exception(const char *message) -{ - return thread_create_transfer_exception(message, NULL); -} - static zend_object *thread_wrap_remote_exception( zend_object *remote_obj, const char *remote_class_name) { diff --git a/thread.h b/thread.h index 58d6816f..95a752f9 100644 --- a/thread.h +++ b/thread.h @@ -73,9 +73,8 @@ async_thread_snapshot_t *async_thread_snapshot_create( */ void async_thread_snapshot_destroy(async_thread_snapshot_t *snapshot); -/* Materialize the entry op_array's name strings into normal refcounted heap - * strings so they outlive the snapshot arena. Per-task snapshots only (never the - * shared bootloader); idempotent. */ +/* Heap-copy the entry op_array's name strings so they outlive the snapshot + * arena. Per-task snapshots only; idempotent. */ void async_thread_snapshot_materialize_entry(async_thread_snapshot_t *snapshot); /////////////////////////////////////////////////////////// @@ -186,10 +185,6 @@ void async_thread_defer_release_ctx( PHP_ASYNC_API extern zend_class_entry *async_ce_remote_exception; PHP_ASYNC_API extern zend_class_entry *async_ce_thread_transfer_exception; -/* Build a ThreadTransferException carrying a plain cause message. Used on the - * destination thread to turn a future's bailout_cause string into an exception. */ -zend_object *async_thread_create_transfer_exception(const char *message); - /////////////////////////////////////////////////////////// /// Thread PHP object — Async\Thread class /////////////////////////////////////////////////////////// diff --git a/thread_pool.c b/thread_pool.c index 13a4e958..5a4fa4b2 100644 --- a/thread_pool.c +++ b/thread_pool.c @@ -92,15 +92,6 @@ static zend_object *thread_pool_bailout_exception(void) : "ThreadPool worker terminated via exit() or a fatal error"); } -/* The bailout cause text (the fatal/OOM message). Returns a borrowed pointer - * valid until request shutdown; the future copies it with pestrdup. */ -static const char *thread_pool_bailout_cause(void) -{ - const zend_string *msg = PG(last_error_message); - return msg != NULL ? ZSTR_VAL(msg) - : "ThreadPool task terminated via exit() or a fatal error"; -} - /* Build a clean ThreadTransferException carrying another exception's message. * Used for errors thrown deep in the cross-thread transfer machinery (e.g. * "Cannot load transferred object"): that Error's full object graph — its @@ -347,8 +338,7 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c ZVAL_UNDEF(&retval); ZVAL_UNDEF(&callable); - /* Per-task snapshot is ours alone: materialize its op_array names into - * refcounted heap strings so they outlive the arena on a fatal. */ + /* Heap-copy op_array names so they outlive the arena on a fatal. */ async_thread_snapshot_materialize_entry(snapshot); async_thread_create_closure(&snapshot->entry, &callable); @@ -446,11 +436,9 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c goto task_cleanup; } - /* Sync mode: run the body as a real coroutine in a per-task nursery - * scope, so CURRENT SCOPE follows it and Async\spawn() inside the body - * lands there — no scope hijacking. Cancel + drain the scope before - * freeing the snapshot, so an un-awaited child can't outlive the arena - * backing its op_array. */ + /* Sync mode: run the body as a coroutine in a per-task nursery scope so + * Async\spawn() inside it lands there. Cancel + drain before freeing the + * snapshot so an un-awaited child can't outlive its arena. */ zend_coroutine_t *worker_coro = ZEND_ASYNC_CURRENT_COROUTINE; zend_async_scope_t *task_scope = worker_coro != NULL ? ZEND_ASYNC_NEW_SCOPE(ZEND_ASYNC_CURRENT_SCOPE) : NULL; @@ -465,9 +453,8 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c goto task_cleanup; } - /* Nursery: un-awaited children are cancelled at task exit. Pinned so - * the scope survives the cancel/drain instead of self-disposing when it - * empties; unpinned right before RELEASE. */ + /* Nursery (NOT-safe): un-awaited children cancelled at exit. Pinned so + * it survives the drain; unpinned before RELEASE. */ ZEND_ASYNC_SCOPE_CLR_DISPOSE_SAFELY(task_scope); ZEND_ASYNC_SCOPE_SET_OWNER_PINNED(task_scope); @@ -484,9 +471,8 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c goto task_cleanup; } - /* Hand the prepared call to the body coroutine, reusing the params - * buffer the worker already populated. Ownership of params moves to - * the coroutine; the snapshot stays ours to free after the drain. */ + /* Hand the call to the body; params ownership moves to it, snapshot + * stays ours to free after the drain. */ zend_fcall_t *fcall = ecalloc(1, sizeof(zend_fcall_t)); fcall->fci = fci; fcall->fci_cache = fcc; @@ -497,11 +483,8 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c body->fcall = fcall; params = NULL; - /* Await the body. Its callback copies result/error into OUR waker - * while the body is still alive (we never read it after disposal). A - * fatal re-raises zend_bailout() out of the coroutine — caught here so - * we still reject the awaiter (body_bailed set only in catch → no - * volatile). */ + /* Await the body; its callback copies result/error into our waker. A + * fatal re-raises zend_bailout() out of the coroutine — caught here. */ bool body_bailed = false; ZEND_ASYNC_WAKER_NEW(worker_coro); zend_async_resume_when(worker_coro, &body->event, false, @@ -519,14 +502,10 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c zend_atomic_int_inc(&pool->base.completed_count); if (UNEXPECTED(body_bailed)) { - /* Fatal in the body: deliver the cause to the awaiter and tear the - * pool down. The in-flight task uses the cause-string path (no PHP - * object built under the exhausted allocator); pending tasks are - * drained with a regular exception. Freeing the snapshot is safe — - * op_array names are materialized, so nothing reads the freed arena. */ + /* Fatal in the body: reject this task and tear the pool down. */ zend_async_waker_clean(worker_coro); - async_future_shared_state_reject_bailout(state, thread_pool_bailout_cause()); zend_object *bex = thread_pool_bailout_exception(); + async_future_shared_state_reject(state, bex); thread_pool_close(pool); thread_pool_drain_tasks(pool, true, bex); OBJ_RELEASE(bex); @@ -564,9 +543,8 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c zend_async_waker_clean(worker_coro); - /* Cancel coroutines the body spawned but left running, then await - * their physical disposal before freeing the snapshot arena that - * backs their op_arrays — so none can outlive the snapshot. */ + /* Cancel + await un-awaited children before freeing the snapshot + * arena that backs their op_arrays. */ if (!ZEND_ASYNC_SCOPE_IS_CLOSED(task_scope)) { ZEND_ASYNC_SCOPE_CANCEL(task_scope, NULL, false, false); ZEND_ASYNC_SCOPE_AWAIT_AFTER_CANCELLATION(task_scope, worker_coro, NULL, NULL, NULL); @@ -578,8 +556,7 @@ static void thread_pool_worker_handler(zend_async_thread_event_t *event, void *c ZEND_ASYNC_SCOPE_CLR_OWNER_PINNED(task_scope); ZEND_ASYNC_SCOPE_RELEASE(task_scope); - /* Drop our closure ref and free the snapshot. Names are materialized, - * so closure destruction no longer reads the arena — order is free. */ + /* Drop the closure ref and free the snapshot. */ zval_ptr_dtor(&callable); zval_ptr_dtor(&retval); async_thread_snapshot_destroy(snapshot); @@ -700,10 +677,11 @@ static void pool_task_dispose(zend_coroutine_t *coroutine) } else if (Z_TYPE(coroutine->result) != IS_UNDEF) { async_future_shared_state_complete(ctx->state, &coroutine->result); } else { - /* No exception and no result means the body bailed out (fatal/OOM) or - * called exit()/die() — a normal return leaves result IS_NULL, not UNDEF. - * Deliver the cause to the awaiter instead of a silent null. */ - async_future_shared_state_reject_bailout(ctx->state, thread_pool_bailout_cause()); + /* UNDEF result, no exception = the body bailed out (fatal/OOM/exit). + * Reject with the cause instead of resolving to a silent null. */ + zend_object *bex = thread_pool_bailout_exception(); + async_future_shared_state_reject(ctx->state, bex); + OBJ_RELEASE(bex); } async_future_shared_state_delref(ctx->state);