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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions async_API.c
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
132 changes: 76 additions & 56 deletions scope.c
Original file line number Diff line number Diff line change
Expand Up @@ -371,95 +371,115 @@ METHOD(awaitCompletion)
zend_async_waker_clean(current_coroutine);
}

METHOD(awaitAfterCancellation)
/* 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,
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.
// 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(
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)
Expand Down
6 changes: 6 additions & 0 deletions scope.h
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ 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 (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,
zend_async_event_t *cancellation);

void async_scope_notify_coroutine_finished(async_coroutine_t *coroutine);

/* Mark coroutine as zombie and update active count */
Expand Down
46 changes: 46 additions & 0 deletions tests/thread_pool/065-task_scope_nursery_no_uaf.phpt
Original file line number Diff line number Diff line change
@@ -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--
<?php
if (!PHP_ZTS) die('skip ZTS required');
if (!class_exists('Async\ThreadPool')) die('skip ThreadPool not available');
?>
--FILE--
<?php
/*
* Regression: a ThreadPool task deep-copies its closure (and every nested
* closure) into a per-task snapshot arena. The worker used to free that arena
* the moment the task body returned — while a coroutine the task had spawned
* was still pending. The pending coroutine's op_array lived in the just-freed
* arena, so running it dereferenced freed memory (Windows debug-heap crash;
* ASAN-caught on Linux).
*
* The fix runs each task body as a coroutine in its own per-task Scope (a
* nursery): on task exit the scope is cancelled and drained — awaited until
* every spawned coroutine is physically disposed — before the snapshot is
* freed. The only guaranteed invariant is that no spawned coroutine outlives
* the snapshot; whether such a coroutine got to run at all is timing-dependent
* and deliberately NOT asserted. The test passes iff there is no use-after-free
* (caught by ASAN / the debug heap), the future resolves, and nothing hangs.
*/
use Async\ThreadPool;
use function Async\spawn;
use function Async\await;
use function Async\delay;

$pool = new ThreadPool(1);

$f = $pool->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
39 changes: 39 additions & 0 deletions tests/thread_pool/066-task_fatal_rejects_future.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
--TEST--
ThreadPool: a fatal (OOM) in a sync task rejects its future with ThreadTransferException (not a hang)
--SKIPIF--
<?php
if (!PHP_ZTS) die('skip ZTS required');
if (!class_exists('Async\ThreadPool')) die('skip ThreadPool not available');
?>
--INI--
memory_limit=64M
--FILE--
<?php
/*
* Regression: a fatal error in a sync task body re-raises zend_bailout() out of
* the task coroutine and longjmps to the worker's bailout handler, past the
* normal future-resolution. The handler must still reject the in-flight task's
* future (it was already dequeued, so draining the channel does not reach it) —
* otherwise the awaiter waits forever.
*/
use Async\ThreadPool;
use function Async\await;

$pool = new ThreadPool(1);

$f = $pool->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
Loading
Loading