From 66b7d182e36cc0cbac8138cb0bfa1f246ee375c1 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:50:53 +0000 Subject: [PATCH] fix(context): root_context() is the main Scope's context root_context() kept its context in a global of its own, ASYNC_G(root_context), created lazily by the function itself and never attached to any Scope. find() resolves a key by walking context->scope->parent_scope, so a context that belongs to no Scope was unreachable by construction: root_context()->set() was only ever readable by calling root_context() again. It is the main Scope's context, which is exactly what the docblock ("Returns the root Scope") already said. Now that it is, everything falls out of the Scope tree: root_context() === current_context() at top level, and every Scope chain terminates at the main Scope, so find() reaches it from any coroutine that inherits from it. A plain `new Scope()` is a detached root on purpose (async_new_scope(NULL)), so its coroutines still do not inherit the context -- Scope::inherit() is the API for that, and it does reach the root. Those coroutines can still read it via root_context() directly. The global is gone rather than kept in sync, so there is no second place for a root context to live. --- CHANGELOG.md | 2 ++ async.c | 27 ++++++++++------ async.stub.php | 17 +++++++++- async_API.c | 6 ---- async_arginfo.h | 2 +- php_async.h | 2 -- .../010-root_context_is_main_scope.phpt | 32 +++++++++++++++++++ 7 files changed, 68 insertions(+), 20 deletions(-) create mode 100644 tests/context/010-root_context_is_main_scope.phpt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d14190f..7134cf3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **`root_context()` was not the main Scope's context and could not be seen by `find()`.** It lived in a global of its own, outside the Scope tree, so `find()` — which walks the Scope chain — never reached it. It is now the main Scope's context: `root_context() === current_context()` at top level, and values set on it are visible from every coroutine that inherits from the main Scope. + - **`current_context()` silently discarded everything written before the first coroutine.** With no scope to anchor to, every top-level call returned a fresh detached context, so `set()` wrote where nobody could read it. The scheduler is now started on demand, as `spawn()` already does. - **`iterate()` ran `concurrency + 1` callbacks at once and silently cancelled the last one still working.** The coroutine driving the iteration loop executes the callback but did not take a slot, so a callback slower than its peers was killed with `AsyncCancellation` and lost without a trace. diff --git a/async.c b/async.c index 2acc6e5f..6230ea0b 100644 --- a/async.c +++ b/async.c @@ -880,11 +880,25 @@ PHP_FUNCTION(Async_root_context) THROW_IF_ASYNC_OFF; THROW_IF_SCHEDULER_CONTEXT; - if (ASYNC_G(root_context) == NULL) { - ASYNC_G(root_context) = (zend_async_context_t *) async_context_new(); + // The root context is the context of the main scope. Holding it in a global of its own put it outside + // the scope tree, where find() -- which walks scope->parent_scope -- could never reach it. + SCHEDULER_LAUNCH; + + zend_async_scope_t *scope = ZEND_ASYNC_MAIN_SCOPE; + + if (UNEXPECTED(scope == NULL)) { + async_throw_error("The main scope is not defined"); + RETURN_THROWS(); } - async_context_t *context = (async_context_t *) ASYNC_G(root_context); + if (scope->context == NULL) { + async_context_t *context = async_context_new(); + context->scope = scope; + scope->context = &context->base; + RETURN_OBJ_COPY(&context->std); + } + + async_context_t *context = (async_context_t *) scope->context; RETURN_OBJ_COPY(&context->std); } @@ -1695,7 +1709,6 @@ static PHP_GINIT_FUNCTION(async) async_globals->signal_handlers = NULL; async_globals->signal_events = NULL; async_globals->process_events = NULL; - async_globals->root_context = NULL; /* Maximum number of coroutines in the concurrent iterator */ async_globals->default_concurrency = 32; @@ -1813,12 +1826,6 @@ PHP_MINFO_FUNCTION(async) PHP_RSHUTDOWN_FUNCTION(async) { - if (ASYNC_G(root_context) != NULL) { - async_context_t *root_context = (async_context_t *) ASYNC_G(root_context); - ASYNC_G(root_context) = NULL; - OBJ_RELEASE(&root_context->std); - } - async_cpu_usage_reset_state(); return SUCCESS; diff --git a/async.stub.php b/async.stub.php index a9b60fc5..4a489c9c 100644 --- a/async.stub.php +++ b/async.stub.php @@ -181,8 +181,19 @@ function loadavg(): ?array {} function timeout(int $ms): Awaitable {} +/** + * Returns the context of the current Scope. + * + * Values set here are visible to every coroutine below this Scope, because find() walks up the Scope + * chain. At top level this is the main Scope, so it returns the same object as root_context(). + */ function current_context(): Context {} +/** + * Returns the context of the current coroutine. + * + * Unlike current_context(), this one belongs to the coroutine alone and is not inherited by anybody. + */ function coroutine_context(): Context {} /** @@ -196,7 +207,11 @@ function current_coroutine(): Coroutine {} //function finally(\Closure $callback): void {} /** - * Returns the root Scope. + * Returns the context of the main Scope, the root of the Scope tree. + * + * Values set here are reachable through find() from every coroutine that inherits from the main Scope, + * which is all of them except those in a deliberately detached `new Scope()`. Those still reach it by + * calling root_context() directly. */ function root_context(): Context {} diff --git a/async_API.c b/async_API.c index 5b343131..8adcc731 100644 --- a/async_API.c +++ b/async_API.c @@ -221,12 +221,6 @@ static bool engine_shutdown(void) zend_hash_destroy(&ASYNC_G(deadlock_channels)); zend_hash_destroy(&ASYNC_G(thread_channels)); - if (ASYNC_G(root_context) != NULL) { - async_context_t *root_context = (async_context_t *) ASYNC_G(root_context); - ASYNC_G(root_context) = NULL; - OBJ_RELEASE(&root_context->std); - } - // async_host_name_list_dtor(); return true; } diff --git a/async_arginfo.h b/async_arginfo.h index 73f437f7..0cf9dd78 100644 --- a/async_arginfo.h +++ b/async_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit async.stub.php instead. - * Stub hash: 4164d2d438cd19d3005883636e5bc68db35ac954 */ + * Stub hash: 73a3f3a9a874ef0399809a77256792435ef5086e */ ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_Async_spawn, 0, 1, Async\\Coroutine, 0) ZEND_ARG_TYPE_INFO(0, task, IS_CALLABLE, 0) diff --git a/php_async.h b/php_async.h index 79bf7327..6a1eaa89 100644 --- a/php_async.h +++ b/php_async.h @@ -92,8 +92,6 @@ HashTable coroutines; zend_fiber_transfer *main_transfer; /* The main flow stack */ zend_vm_stack main_vm_stack; -/* System root context */ -zend_async_context_t *root_context; /* The default concurrency */ int default_concurrency; diff --git a/tests/context/010-root_context_is_main_scope.phpt b/tests/context/010-root_context_is_main_scope.phpt new file mode 100644 index 00000000..81eadbf0 --- /dev/null +++ b/tests/context/010-root_context_is_main_scope.phpt @@ -0,0 +1,32 @@ +--TEST-- +root_context() - is the main Scope's context and is reachable through find() +--FILE-- +parent_scope -- could never reach it. It is the main Scope's context. +root_context()->set('cfg', 'X'); + +echo "root is current at top level: ", var_export(root_context() === current_context(), true), "\n"; +echo "plain coroutine: ", var_export(await(spawn(fn() => current_context()->find('cfg'))), true), "\n"; + +$inherited = Async\Scope::inherit(); +echo "Scope::inherit(): ", var_export(await($inherited->spawn(fn() => current_context()->find('cfg'))), true), "\n"; + +// A plain `new Scope()` is a detached root on purpose, so it does not inherit the context either -- +// but root_context() still reaches it directly. +$detached = new Async\Scope(); +echo "new Scope(): ", var_export(await($detached->spawn(fn() => current_context()->find('cfg'))), true), "\n"; +echo "new Scope() direct: ", var_export(await($detached->spawn(fn() => root_context()->find('cfg'))), true), "\n"; +?> +--EXPECT-- +root is current at top level: true +plain coroutine: 'X' +Scope::inherit(): 'X' +new Scope(): NULL +new Scope() direct: 'X'