From 6e1a5ce65ffd56119f563d6bd68f23379e0cd35d Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:10:26 +0000 Subject: [PATCH 01/23] Add the Async Scheduler Hook API: a thin, policy-free concurrency core The engine gains the ability to activate a concurrent execution mode. A scheduler - a C extension or a PHP library - registers a set of hooks through a single call and takes control of concurrency. The core contains NO implementation: no scheduler, no queues, no event system; it is hook routing plus the minimal mechanism only the engine can perform (context switching, the GC destructor walker, the coroutine lifecycle status). Core ABI (Zend/zend_async_API.h): - zend_coroutine_t: lifecycle status packed into the flags word, modifier flags, object_offset for the single-allocation embed pattern (or a stored object pointer via OBJ_REF), an awaiting_info diagnostics hook. No embedded wait state. - scheduler slots registered once per process via a versioned struct: new_coroutine, enqueue_coroutine, suspend(from_main, is_bailout), resume, cancel, launch, shutdown, get_class_ce, call_on_main_stack, context accessors (userland zval keys + internal numeric keys with a core-owned key registry), intercept_fiber, gc_destructors, defer. - zend_async_microtask_t: a thin one-shot task (handler, dtor, 32-bit ref_count, named flag bits incl. is_cancelled). The queue is provider-owned; the defer slot only routes the pointer. - per-thread globals: state, current/main coroutine, live coroutine count, the in_scheduler_context flag, the PHP bridge hook storage. Engine integration: - the scheduler is not lazy: it launches right before the script code runs (main.c, phpdbg) and receives the after-main handover; after destructors the API deactivates. - fibers: intercept_fiber links a fiber to a coroutine (the hook returns the coroutine to bind, created by the scheduler, or NULL for the legacy low-level path). There is NO switching API: inside scheduler code (in_scheduler_context, set around hook invocations) the plain Fiber API on a bound fiber performs the direct context switch; in application code the same calls park the value and route through the hooks. Deferred starts take an owned deep copy of their arguments. Fiber::suspend() is unchanged. - GC: the destructor phase is an around-interceptor. The engine keeps the walker, the once-per-object guarantee and the phase cursor, and re-runs missed destructors after the hook as a safety net; the hook brackets the run with provider logic (open a completion group, run, await everything the destructors spawned, transitively). Each destructor executes as application code (the scheduler flag is dropped around it). The executor reaches the hook as a Closure over an unregistered internal function - unreachable from application code. PHP bridge (Zend/zend_scheduler_hook.c): final class Async\ SchedulerHook - hook-name constants, register(module, hooks) (throws when a scheduler is already registered), getModule(), defer() (forwards to the DEFER hook). Hooks are stored by numeric index in the per-thread globals; each slot is backed by a C thunk forwarding to the stored callable. Tests (Zend/tests/async, 13): registration semantics, constants, launch-at-registration, managed-fiber lifecycle through a plain-Fiber scheduler loop (start/suspend/resume/finish, throw at the suspension point, uncaught exception propagation, after-main drain, direct- inside/routed-outside context semantics), low-level fibers untouched by a null intercept, microtask forwarding, and a GC cycle whose destructors spawn managed fibers awaited by the hook. Full async, fibers and gc suites pass (194/194, debug build - leak-checked). RFC and integration reference: https://github.com/true-async/php-async-core-rfc --- Zend/tests/async/fiber_lowlevel_null.phpt | 34 + .../tests/async/fiber_managed_after_main.phpt | 50 ++ Zend/tests/async/fiber_managed_basic.phpt | 62 ++ Zend/tests/async/fiber_managed_throw.phpt | 52 ++ Zend/tests/async/fiber_managed_uncaught.phpt | 44 ++ Zend/tests/async/gc_destructors_hook.phpt | 86 +++ Zend/tests/async/microtasks_basic.phpt | 43 ++ Zend/tests/async/scheduler_context.phpt | 64 ++ .../tests/async/scheduler_hook_constants.phpt | 26 + Zend/tests/async/scheduler_hook_invalid.phpt | 18 + Zend/tests/async/scheduler_hook_launch.phpt | 22 + Zend/tests/async/scheduler_hook_override.phpt | 23 + Zend/tests/async/scheduler_hook_register.phpt | 19 + Zend/zend.c | 8 + Zend/zend_async_API.c | 442 +++++++++++++ Zend/zend_async_API.h | 573 +++++++++++++++++ Zend/zend_fibers.c | 264 +++++++- Zend/zend_fibers.h | 32 +- Zend/zend_gc.c | 53 +- Zend/zend_gc.h | 4 + Zend/zend_scheduler_hook.c | 586 ++++++++++++++++++ Zend/zend_scheduler_hook.h | 31 + Zend/zend_scheduler_hook.stub.php | 49 ++ Zend/zend_scheduler_hook_arginfo.h | 119 ++++ configure.ac | 2 + main/main.c | 20 + sapi/phpdbg/phpdbg_prompt.c | 5 + win32/build/config.w32 | 2 +- 28 files changed, 2726 insertions(+), 7 deletions(-) create mode 100644 Zend/tests/async/fiber_lowlevel_null.phpt create mode 100644 Zend/tests/async/fiber_managed_after_main.phpt create mode 100644 Zend/tests/async/fiber_managed_basic.phpt create mode 100644 Zend/tests/async/fiber_managed_throw.phpt create mode 100644 Zend/tests/async/fiber_managed_uncaught.phpt create mode 100644 Zend/tests/async/gc_destructors_hook.phpt create mode 100644 Zend/tests/async/microtasks_basic.phpt create mode 100644 Zend/tests/async/scheduler_context.phpt create mode 100644 Zend/tests/async/scheduler_hook_constants.phpt create mode 100644 Zend/tests/async/scheduler_hook_invalid.phpt create mode 100644 Zend/tests/async/scheduler_hook_launch.phpt create mode 100644 Zend/tests/async/scheduler_hook_override.phpt create mode 100644 Zend/tests/async/scheduler_hook_register.phpt create mode 100644 Zend/zend_async_API.c create mode 100644 Zend/zend_async_API.h create mode 100644 Zend/zend_scheduler_hook.c create mode 100644 Zend/zend_scheduler_hook.h create mode 100644 Zend/zend_scheduler_hook.stub.php create mode 100644 Zend/zend_scheduler_hook_arginfo.h diff --git a/Zend/tests/async/fiber_lowlevel_null.phpt b/Zend/tests/async/fiber_lowlevel_null.phpt new file mode 100644 index 000000000000..e78aa334757c --- /dev/null +++ b/Zend/tests/async/fiber_lowlevel_null.phpt @@ -0,0 +1,34 @@ +--TEST-- +intercept_fiber returning null keeps the fiber on the low-level path +--FILE-- + function (Fiber $fiber): ?object { + echo "intercept: null\n"; + return null; + }, + Async\SchedulerHook::SUSPEND => function (bool $fromMain, bool $isBailout): bool { + // Never called for the low-level fiber itself; the engine invokes it + // for the after-main handover (script end, then after destructors). + echo "suspend(fromMain: ", var_export($fromMain, true), ")\n"; + return true; + }, +]); + +// A low-level fiber behaves exactly as classic Fiber even with a scheduler. +$fiber = new Fiber(function (int $x): int { + $y = Fiber::suspend($x + 1); + return $y * 10; +}); + +var_dump($fiber->start(5)); +var_dump($fiber->resume(4)); +var_dump($fiber->getReturn()); +?> +--EXPECT-- +intercept: null +int(6) +NULL +int(40) +suspend(fromMain: true) +suspend(fromMain: true) diff --git a/Zend/tests/async/fiber_managed_after_main.phpt b/Zend/tests/async/fiber_managed_after_main.phpt new file mode 100644 index 000000000000..4c3337923983 --- /dev/null +++ b/Zend/tests/async/fiber_managed_after_main.phpt @@ -0,0 +1,50 @@ +--TEST-- +After-main handover runs deferred managed coroutines; abandoned ones die cleanly +--FILE-- + fn (Fiber $fiber): object + => new class($fiber) { + public function __construct(public readonly Fiber $fiber) {} + }, + Async\SchedulerHook::ENQUEUE => function (object $coroutine) use ($queue): bool { + $queue->enqueue($coroutine); + return true; + }, + Async\SchedulerHook::SUSPEND => function (bool $fromMain, bool $isBailout) use ($queue): bool { + // This scheduler defers everything: nothing runs until after main. + if (!$fromMain) { + return true; + } + + while (!$queue->isEmpty()) { + $fiber = $queue->dequeue()->fiber; + $fiber->isStarted() ? $fiber->resume() : $fiber->start(); + } + + return true; + }, +]); + +$fiber = new Fiber(function (): void { + echo "ran after main\n"; + Fiber::suspend(); + echo "never printed: nobody resumes\n"; +}); + +// The deferring scheduler does not run the fiber here. +var_dump($fiber->start()); +var_dump($fiber->isStarted()); + +echo "end of script\n"; +// After-main handover: the scheduler drains its queue, the fiber runs and +// suspends. Nobody resumes it, so it is destroyed at shutdown without +// completing; the second echo never happens. +?> +--EXPECT-- +NULL +bool(false) +end of script +ran after main diff --git a/Zend/tests/async/fiber_managed_basic.phpt b/Zend/tests/async/fiber_managed_basic.phpt new file mode 100644 index 000000000000..6ae794c600f3 --- /dev/null +++ b/Zend/tests/async/fiber_managed_basic.phpt @@ -0,0 +1,62 @@ +--TEST-- +A fiber adopted by the scheduler runs through the coroutine path +--FILE-- + function (Fiber $fiber) use (&$log): object { + $log[] = 'intercept'; + + // The scheduler defines the coroutine; here it simply remembers + // which fiber the coroutine drives. + return new class($fiber) { + public function __construct(public readonly Fiber $fiber) {} + }; + }, + Async\SchedulerHook::ENQUEUE => function (object $coroutine) use ($queue, &$log): bool { + $log[] = 'enqueue'; + $queue->enqueue($coroutine); + return true; + }, + Async\SchedulerHook::RESUME => function (object $coroutine, ?Throwable $error) use ($queue, &$log): bool { + $log[] = 'resume'; + $queue->enqueue($coroutine); + return true; + }, + Async\SchedulerHook::SUSPEND => function (bool $fromMain, bool $isBailout) use ($queue, &$log): bool { + $log[] = 'suspend'; + + while (!$queue->isEmpty()) { + $fiber = $queue->dequeue()->fiber; + $fiber->isStarted() ? $fiber->resume() : $fiber->start(); + + if (!$fromMain) { + return true; + } + } + + return true; + }, +]); + +$fiber = new Fiber(function (int $x): int { + $y = Fiber::suspend($x + 1); + return $y * 10; +}); + +var_dump($fiber->start(5)); +var_dump($fiber->isSuspended()); +var_dump($fiber->resume(4)); +var_dump($fiber->isTerminated()); +var_dump($fiber->getReturn()); +echo implode(',', $log), "\n"; +?> +--EXPECT-- +int(6) +bool(true) +NULL +bool(true) +int(40) +intercept,enqueue,suspend,resume,suspend diff --git a/Zend/tests/async/fiber_managed_throw.phpt b/Zend/tests/async/fiber_managed_throw.phpt new file mode 100644 index 000000000000..e4feca92ce68 --- /dev/null +++ b/Zend/tests/async/fiber_managed_throw.phpt @@ -0,0 +1,52 @@ +--TEST-- +Fiber::throw() on a managed fiber delivers the exception at the suspension point +--FILE-- + fn (Fiber $fiber): object + => new class($fiber) { + public function __construct(public readonly Fiber $fiber) {} + }, + Async\SchedulerHook::ENQUEUE => function (object $coroutine) use ($queue): bool { + $queue->enqueue($coroutine); + return true; + }, + Async\SchedulerHook::RESUME => function (object $coroutine, ?Throwable $error) use ($queue): bool { + echo "resume hook error: ", $error === null ? 'none' : get_class($error), "\n"; + $queue->enqueue($coroutine); + return true; + }, + Async\SchedulerHook::SUSPEND => function (bool $fromMain, bool $isBailout) use ($queue): bool { + while (!$queue->isEmpty()) { + $fiber = $queue->dequeue()->fiber; + $fiber->isStarted() ? $fiber->resume() : $fiber->start(); + + if (!$fromMain) { + return true; + } + } + + return true; + }, +]); + +$fiber = new Fiber(function (): string { + try { + Fiber::suspend('waiting'); + } catch (RuntimeException $e) { + return 'caught: ' . $e->getMessage(); + } + + return 'not reached'; +}); + +var_dump($fiber->start()); +$fiber->throw(new RuntimeException('boom')); +var_dump($fiber->getReturn()); +?> +--EXPECT-- +string(7) "waiting" +resume hook error: RuntimeException +string(12) "caught: boom" diff --git a/Zend/tests/async/fiber_managed_uncaught.phpt b/Zend/tests/async/fiber_managed_uncaught.phpt new file mode 100644 index 000000000000..3cad9122f66c --- /dev/null +++ b/Zend/tests/async/fiber_managed_uncaught.phpt @@ -0,0 +1,44 @@ +--TEST-- +An uncaught exception in a managed fiber surfaces at the start()/resume() caller +--FILE-- + fn (Fiber $fiber): object + => new class($fiber) { + public function __construct(public readonly Fiber $fiber) {} + }, + Async\SchedulerHook::ENQUEUE => function (object $coroutine) use ($queue): bool { + $queue->enqueue($coroutine); + return true; + }, + Async\SchedulerHook::SUSPEND => function (bool $fromMain, bool $isBailout) use ($queue): bool { + while (!$queue->isEmpty()) { + $fiber = $queue->dequeue()->fiber; + $fiber->isStarted() ? $fiber->resume() : $fiber->start(); + + if (!$fromMain) { + return true; + } + } + + return true; + }, +]); + +$fiber = new Fiber(function (): void { + throw new RuntimeException('escaped'); +}); + +try { + $fiber->start(); +} catch (RuntimeException $e) { + echo "caught: ", $e->getMessage(), "\n"; +} + +var_dump($fiber->isTerminated()); +?> +--EXPECT-- +caught: escaped +bool(true) diff --git a/Zend/tests/async/gc_destructors_hook.phpt b/Zend/tests/async/gc_destructors_hook.phpt new file mode 100644 index 000000000000..0a265a49c857 --- /dev/null +++ b/Zend/tests/async/gc_destructors_hook.phpt @@ -0,0 +1,86 @@ +--TEST-- +GC destructor phase: the hook brackets the engine run and awaits spawned work +--FILE-- + fn (Fiber $fiber): object + => new class($fiber) { + public function __construct(public readonly Fiber $fiber) {} + }, + Async\SchedulerHook::ENQUEUE => function (object $coroutine) use ($queue): bool { + $queue->enqueue($coroutine); + return true; + }, + Async\SchedulerHook::SUSPEND => function (bool $fromMain, bool $isBailout) use ($queue): bool { + // Defer: the queue is drained by the GC hook (and after main). + if (!$fromMain) { + return true; + } + + while (!$queue->isEmpty()) { + $fiber = $queue->dequeue()->fiber; + $fiber->isStarted() ? $fiber->resume() : $fiber->start(); + } + + return true; + }, + Async\SchedulerHook::GC_DESTRUCTORS => function (callable $run) use ($queue): bool { + echo "hook: before\n"; + + $run(); // the engine calls every pending destructor + + // Await everything the destructors spawned (the "scope" logic: + // membership is the scheduler's own bookkeeping). + while (!$queue->isEmpty()) { + $fiber = $queue->dequeue()->fiber; + $fiber->isStarted() ? $fiber->resume() : $fiber->start(); + } + + echo "hook: after\n"; + return true; + }, +]); + +class Node +{ + public ?Node $other = null; + + public function __destruct() + { + echo "destructor\n"; + + // The destructor spawns concurrent work; the hook must wait for it. + $fiber = new Fiber(function (): void { + echo "spawned by destructor\n"; + }); + $fiber->start(); + } +} + +// A garbage cycle: collected only by the GC. +$a = new Node(); +$b = new Node(); +$a->other = $b; +$b->other = $a; +unset($a, $b); + +// The GC reruns after the destructor phase: the finished fiber/coroutine +// pairs collected there carry internal destructors, so the hook brackets +// a second (empty) phase. +$collected = gc_collect_cycles(); +var_dump($collected > 0); +echo "end\n"; +?> +--EXPECT-- +hook: before +destructor +destructor +spawned by destructor +spawned by destructor +hook: after +hook: before +hook: after +bool(true) +end diff --git a/Zend/tests/async/microtasks_basic.phpt b/Zend/tests/async/microtasks_basic.phpt new file mode 100644 index 000000000000..95037412b328 --- /dev/null +++ b/Zend/tests/async/microtasks_basic.phpt @@ -0,0 +1,43 @@ +--TEST-- +Microtasks: defer() forwards to the scheduler's DEFER hook; the queue is the scheduler's +--FILE-- + function (bool $fromMain, bool $isBailout): bool { + return true; + }, + Async\SchedulerHook::DEFER => function (callable $task) use ($tasks): bool { + // The queue is owned by the scheduler, not by the engine. + $tasks->enqueue($task); + return true; + }, +]); + +Async\SchedulerHook::defer(function () use ($tasks): void { + echo "task 1\n"; + + // Queued while draining: the scheduler decides the semantics; this + // one drains everything queued before it finishes (classic microtasks). + Async\SchedulerHook::defer(function (): void { + echo "task 3 (queued by task 1)\n"; + }); +}); + +Async\SchedulerHook::defer(function (): void { + echo "task 2\n"; +}); + +// The scheduler drains its own queue on its tick; simulate one here. +while (!$tasks->isEmpty()) { + ($tasks->dequeue())(); +} + +echo "drained\n"; +?> +--EXPECT-- +task 1 +task 2 +task 3 (queued by task 1) +drained diff --git a/Zend/tests/async/scheduler_context.phpt b/Zend/tests/async/scheduler_context.phpt new file mode 100644 index 000000000000..ab4b1c4102e0 --- /dev/null +++ b/Zend/tests/async/scheduler_context.phpt @@ -0,0 +1,64 @@ +--TEST-- +Fiber operations on a bound fiber: direct inside the scheduler, routed outside +--FILE-- + fn (Fiber $fiber): object + => new class($fiber) { + public function __construct(public readonly Fiber $fiber) {} + }, + Async\SchedulerHook::ENQUEUE => function (object $coroutine) use ($queue, &$log): bool { + $log[] = 'enqueue'; + $queue->enqueue($coroutine); + return true; + }, + Async\SchedulerHook::RESUME => function (object $coroutine, ?Throwable $error) use ($queue, &$log): bool { + $log[] = 'resume-hook'; + $queue->enqueue($coroutine); + return true; + }, + Async\SchedulerHook::SUSPEND => function (bool $fromMain, bool $isBailout) use ($queue, &$log): bool { + while (!$queue->isEmpty()) { + $fiber = $queue->dequeue()->fiber; + + // Inside a hook this is a DIRECT switch: no hooks re-enter. + $fiber->isStarted() ? $fiber->resume() : $fiber->start(); + + if (!$fromMain) { + return true; + } + } + + return true; + }, +]); + +$fiber = new Fiber(function (): string { + Fiber::suspend('first'); + return 'done'; +}); + +// Application code: operations route through the hooks. +var_dump($fiber->start()); +var_dump($fiber->resume()); +var_dump($fiber->getReturn()); + +// The scheduler's direct switches fired no extra hook calls: +echo implode(',', $log), "\n"; + +// A finished bound fiber cannot be resumed, same rule as classic fibers. +try { + $fiber->resume(); +} catch (FiberError $e) { + echo $e->getMessage(), "\n"; +} +?> +--EXPECT-- +string(5) "first" +NULL +string(4) "done" +enqueue,resume-hook +Cannot resume a fiber that is not suspended diff --git a/Zend/tests/async/scheduler_hook_constants.phpt b/Zend/tests/async/scheduler_hook_constants.phpt new file mode 100644 index 000000000000..26d3f3efa015 --- /dev/null +++ b/Zend/tests/async/scheduler_hook_constants.phpt @@ -0,0 +1,26 @@ +--TEST-- +Async\SchedulerHook: hook name constants +--FILE-- + +--EXPECT-- +string(6) "launch" +string(8) "shutdown" +string(15) "intercept_fiber" +string(17) "enqueue_coroutine" +string(7) "suspend" +string(6) "resume" +string(6) "cancel" +string(12) "context_find" +string(11) "context_set" +string(13) "context_unset" diff --git a/Zend/tests/async/scheduler_hook_invalid.phpt b/Zend/tests/async/scheduler_hook_invalid.phpt new file mode 100644 index 000000000000..adfe79314c7d --- /dev/null +++ b/Zend/tests/async/scheduler_hook_invalid.phpt @@ -0,0 +1,18 @@ +--TEST-- +Async\SchedulerHook::register rejects a non-callable hook +--FILE-- + 'this_function_does_not_exist', + ]); +} catch (\TypeError $e) { + echo $e->getMessage(), "\n"; +} + +// A refused registration leaves no active scheduler. +var_dump(Async\SchedulerHook::getModule()); +?> +--EXPECTF-- +Async scheduler hook "suspend" must be a valid callable: %s +NULL diff --git a/Zend/tests/async/scheduler_hook_launch.phpt b/Zend/tests/async/scheduler_hook_launch.phpt new file mode 100644 index 000000000000..66fe0c1150cc --- /dev/null +++ b/Zend/tests/async/scheduler_hook_launch.phpt @@ -0,0 +1,22 @@ +--TEST-- +Async\SchedulerHook: launch runs immediately at PHP registration +--FILE-- + function () use (&$launched): bool { + $launched = true; + return true; + }, + Async\SchedulerHook::SUSPEND => function (bool $fromMain, bool $isBailout): bool { + return true; + }, +]); + +// The engine launch point has already passed by the time userland runs, +// so a PHP scheduler is launched synchronously inside register(). +var_dump($launched); +?> +--EXPECT-- +bool(true) diff --git a/Zend/tests/async/scheduler_hook_override.phpt b/Zend/tests/async/scheduler_hook_override.phpt new file mode 100644 index 000000000000..662aff395cb5 --- /dev/null +++ b/Zend/tests/async/scheduler_hook_override.phpt @@ -0,0 +1,23 @@ +--TEST-- +Async\SchedulerHook::register can be called only once +--FILE-- + $noop, +])); + +// A second registration throws: a scheduler is registered once per process. +try { + Async\SchedulerHook::register('b', [ + Async\SchedulerHook::SUSPEND => $noop, + ]); +} catch (\Error $e) { + echo $e->getMessage(), "\n"; +} +?> +--EXPECT-- +bool(true) +A scheduler is already registered diff --git a/Zend/tests/async/scheduler_hook_register.phpt b/Zend/tests/async/scheduler_hook_register.phpt new file mode 100644 index 000000000000..f4edda2e3c49 --- /dev/null +++ b/Zend/tests/async/scheduler_hook_register.phpt @@ -0,0 +1,19 @@ +--TEST-- +Async\SchedulerHook::register activates and getModule() reports the driver +--FILE-- + function (bool $fromMain, bool $isBailout): bool { + return true; + }, +]); + +var_dump($ok); +var_dump(Async\SchedulerHook::getModule()); +?> +--EXPECT-- +NULL +bool(true) +string(9) "my-driver" diff --git a/Zend/zend.c b/Zend/zend.c index 9411b92a2018..dd5bec602ede 100644 --- a/Zend/zend.c +++ b/Zend/zend.c @@ -24,6 +24,7 @@ #include "zend_API.h" #include "zend_exceptions.h" #include "zend_builtin_functions.h" +#include "zend_scheduler_hook.h" #include "zend_ini.h" #include "zend_vm.h" #include "zend_dtrace.h" @@ -1060,6 +1061,8 @@ void zend_startup(zend_utility_functions *utility_functions) /* {{{ */ zend_interned_strings_init(); zend_object_handlers_startup(); zend_startup_builtin_functions(); + zend_async_globals_ctor(); + zend_register_scheduler_hook(); zend_register_standard_constants(); zend_register_auto_global(zend_string_init_interned("GLOBALS", sizeof("GLOBALS") - 1, 1), 1, php_auto_globals_create_globals); @@ -1167,6 +1170,8 @@ zend_result zend_post_startup(void) /* {{{ */ void zend_shutdown(void) /* {{{ */ { + zend_async_api_shutdown(); + zend_async_globals_dtor(); zend_vm_dtor(); zend_destroy_rsrc_list(&EG(persistent_list)); @@ -1350,6 +1355,9 @@ ZEND_API void zend_deactivate(void) /* {{{ */ /* we're no longer executing anything */ EG(current_execute_data) = NULL; + /* Drop any PHP-registered scheduler handlers held for this request. */ + zend_scheduler_hook_request_shutdown(); + zend_try { shutdown_scanner(); } zend_end_try(); diff --git a/Zend/zend_async_API.c b/Zend/zend_async_API.c new file mode 100644 index 000000000000..79949e05da91 --- /dev/null +++ b/Zend/zend_async_API.c @@ -0,0 +1,442 @@ +/* + +----------------------------------------------------------------------+ + | Copyright © The PHP Group and Contributors. | + +----------------------------------------------------------------------+ + | This source file is subject to the Modified BSD License that is | + | bundled with this package in the file LICENSE, and is available | + | through the World Wide Web at . | + | | + | SPDX-License-Identifier: BSD-3-Clause | + +----------------------------------------------------------------------+ + | Authors: Edmond | + +----------------------------------------------------------------------+ +*/ +#include "zend_async_API.h" +#include "zend_exceptions.h" + +/////////////////////////////////////////////////////////////////// +/// Globals +/////////////////////////////////////////////////////////////////// + +#ifdef ZTS +ZEND_API int zend_async_globals_id = 0; + + +static void async_globals_ctor(zend_async_globals_t *globals) +{ + memset(globals, 0, sizeof(zend_async_globals_t)); +} + +static void async_globals_dtor(zend_async_globals_t *globals) +{ + (void) globals; +} + +void zend_async_globals_ctor(void) +{ + /* A regular (non-fast) resource id: the TSRM fast-globals area is + * reserved for the fixed set of engine globals; growing it at this + * point would realloc the storage under already-cached pointers. */ + ts_allocate_id(&zend_async_globals_id, sizeof(zend_async_globals_t), + (ts_allocate_ctor) async_globals_ctor, (ts_allocate_dtor) async_globals_dtor); +} + +void zend_async_globals_dtor(void) +{ +} +#else +ZEND_API zend_async_globals_t zend_async_globals_api = { 0 }; + +void zend_async_globals_ctor(void) +{ + memset(&zend_async_globals_api, 0, sizeof(zend_async_globals_t)); +} + +void zend_async_globals_dtor(void) +{ +} +#endif + +/////////////////////////////////////////////////////////////////// +/// Internal context key registry +/////////////////////////////////////////////////////////////////// + +/* + * Maps a static C-string name to a process-unique numeric key. + * Keys are allocated once per process (typically at MINIT); the registry + * intentionally uses persistent memory and, under ZTS, a mutex. + */ +static HashTable *internal_context_key_names = NULL; +static uint32_t internal_context_next_key = 1; +#ifdef ZTS +static MUTEX_T internal_context_mutex = NULL; +#endif + +ZEND_API uint32_t zend_async_internal_context_key_alloc(const char *key_name) +{ +#ifdef ZTS + if (internal_context_mutex == NULL) { + internal_context_mutex = tsrm_mutex_alloc(); + } + tsrm_mutex_lock(internal_context_mutex); +#endif + + if (internal_context_key_names == NULL) { + internal_context_key_names = pemalloc(sizeof(HashTable), 1); + /* Values are static C strings owned by the callers - no destructor. */ + zend_hash_init(internal_context_key_names, 8, NULL, NULL, 1); + } + + const uint32_t key = internal_context_next_key++; + zend_hash_index_add_new_ptr(internal_context_key_names, key, (void *) key_name); + +#ifdef ZTS + tsrm_mutex_unlock(internal_context_mutex); +#endif + + return key; +} + +ZEND_API const char *zend_async_internal_context_key_name(uint32_t key) +{ + if (internal_context_key_names == NULL) { + return NULL; + } + + return zend_hash_index_find_ptr(internal_context_key_names, key); +} + +static void internal_context_keys_shutdown(void) +{ + if (internal_context_key_names != NULL) { + zend_hash_destroy(internal_context_key_names); + pefree(internal_context_key_names, 1); + internal_context_key_names = NULL; + } + + internal_context_next_key = 1; + +#ifdef ZTS + if (internal_context_mutex != NULL) { + tsrm_mutex_free(internal_context_mutex); + internal_context_mutex = NULL; + } +#endif +} + +/////////////////////////////////////////////////////////////////// +/// Default slot implementations (no scheduler registered) +/////////////////////////////////////////////////////////////////// + +static ZEND_COLD void throw_no_scheduler(void) +{ + zend_throw_error(NULL, "The Async API requires a scheduler implementation to be registered"); +} + +static zend_coroutine_t *new_coroutine_stub(size_t extra_size) +{ + (void) extra_size; + throw_no_scheduler(); + return NULL; +} + +static bool enqueue_coroutine_stub(zend_coroutine_t *coroutine) +{ + (void) coroutine; + throw_no_scheduler(); + return false; +} + +static bool suspend_stub(bool from_main, bool is_bailout) +{ + (void) from_main; + (void) is_bailout; + throw_no_scheduler(); + return false; +} + +static bool resume_stub(zend_coroutine_t *coroutine, zend_object *error, const bool transfer_error) +{ + (void) coroutine; + if (error != NULL && transfer_error) { + OBJ_RELEASE(error); + } + throw_no_scheduler(); + return false; +} + +static bool cancel_stub( + zend_coroutine_t *coroutine, zend_object *error, bool transfer_error, const bool is_safely) +{ + (void) coroutine; + (void) is_safely; + if (error != NULL && transfer_error) { + OBJ_RELEASE(error); + } + throw_no_scheduler(); + return false; +} + +static bool defer_stub(zend_async_microtask_t *task) +{ + (void) task; + throw_no_scheduler(); + return false; +} + +static bool bool_false_stub(void) +{ + return false; +} + +static zend_async_context_t *get_context_stub(zend_coroutine_t *coroutine) +{ + (void) coroutine; + throw_no_scheduler(); + return NULL; +} + +static bool context_find_stub( + zend_async_context_t *context, zval *key, zval *result, bool include_parent) +{ + (void) context; + (void) key; + (void) include_parent; + + if (result != NULL) { + ZVAL_NULL(result); + } + + throw_no_scheduler(); + return false; +} + +static bool context_set_stub(zend_async_context_t *context, zval *key, zval *value) +{ + (void) context; + (void) key; + (void) value; + throw_no_scheduler(); + return false; +} + +static bool context_unset_stub(zend_async_context_t *context, zval *key) +{ + (void) context; + (void) key; + throw_no_scheduler(); + return false; +} + +static zval *internal_context_find_stub(zend_async_context_t *context, uint32_t key) +{ + (void) context; + (void) key; + throw_no_scheduler(); + return NULL; +} + +static bool internal_context_set_stub(zend_async_context_t *context, uint32_t key, zval *value) +{ + (void) context; + (void) key; + (void) value; + throw_no_scheduler(); + return false; +} + +static bool internal_context_unset_stub(zend_async_context_t *context, uint32_t key) +{ + (void) context; + (void) key; + throw_no_scheduler(); + return false; +} + +static zend_class_entry *get_class_ce_default(zend_async_class type) +{ + /* Without a scheduler there are no Async classes; every exception type + * degrades to the base \Exception so error paths still work. */ + if (type >= ZEND_ASYNC_EXCEPTION_DEFAULT) { + return zend_ce_exception; + } + + return NULL; +} + +static void default_call_on_main_stack(void (*fn)(void *), void *arg) +{ + fn(arg); +} + +ZEND_API zend_async_new_coroutine_t zend_async_new_coroutine_fn = new_coroutine_stub; +ZEND_API zend_async_enqueue_coroutine_t zend_async_enqueue_coroutine_fn = enqueue_coroutine_stub; +ZEND_API zend_async_suspend_t zend_async_suspend_fn = suspend_stub; +ZEND_API zend_async_resume_t zend_async_resume_fn = resume_stub; +ZEND_API zend_async_cancel_t zend_async_cancel_fn = cancel_stub; +ZEND_API zend_async_scheduler_launch_t zend_async_scheduler_launch_fn = bool_false_stub; +ZEND_API zend_async_shutdown_t zend_async_shutdown_fn = bool_false_stub; +ZEND_API zend_async_get_class_ce_t zend_async_get_class_ce_fn = get_class_ce_default; +ZEND_API zend_async_call_on_main_stack_t zend_async_call_on_main_stack_fn = + default_call_on_main_stack; +ZEND_API zend_async_get_context_t zend_async_get_context_fn = get_context_stub; +ZEND_API zend_async_get_context_t zend_async_get_internal_context_fn = get_context_stub; +ZEND_API zend_async_context_find_t zend_async_context_find_fn = context_find_stub; +ZEND_API zend_async_context_set_t zend_async_context_set_fn = context_set_stub; +ZEND_API zend_async_context_unset_t zend_async_context_unset_fn = context_unset_stub; +ZEND_API zend_async_internal_context_find_t zend_async_internal_context_find_fn = + internal_context_find_stub; +ZEND_API zend_async_internal_context_set_t zend_async_internal_context_set_fn = + internal_context_set_stub; +ZEND_API zend_async_internal_context_unset_t zend_async_internal_context_unset_fn = + internal_context_unset_stub; +ZEND_API zend_async_intercept_fiber_t zend_async_intercept_fiber_fn = NULL; +ZEND_API zend_async_gc_destructors_t zend_async_gc_destructors_fn = NULL; +ZEND_API zend_async_defer_t zend_async_defer_fn = defer_stub; + +static const char *scheduler_module_name = NULL; + +/* True when the field lies within the size the provider was compiled against. */ +#define API_PROVIDES(api, field) \ + ((api)->size >= offsetof(zend_async_scheduler_api_t, field) + sizeof((api)->field) \ + && (api)->field != NULL) + +ZEND_API bool zend_async_scheduler_register( + const char *module, const zend_async_scheduler_api_t *api) +{ + if (api == NULL || module == NULL) { + return false; + } + + if ((api->version >> 16) != ZEND_ASYNC_API_VERSION_MAJOR) { + zend_error(E_CORE_WARNING, + "Module %s was compiled against an incompatible Async API version", module); + return false; + } + + /* A scheduler is registered once per process. */ + if (scheduler_module_name != NULL) { + zend_error(E_CORE_WARNING, + "The module %s cannot register an Async scheduler: %s already did", + module, scheduler_module_name); + return false; + } + + if (API_PROVIDES(api, new_coroutine)) { + zend_async_new_coroutine_fn = api->new_coroutine; + } + if (API_PROVIDES(api, enqueue_coroutine)) { + zend_async_enqueue_coroutine_fn = api->enqueue_coroutine; + } + if (API_PROVIDES(api, suspend)) { + zend_async_suspend_fn = api->suspend; + } + if (API_PROVIDES(api, resume)) { + zend_async_resume_fn = api->resume; + } + if (API_PROVIDES(api, cancel)) { + zend_async_cancel_fn = api->cancel; + } + if (API_PROVIDES(api, launch)) { + zend_async_scheduler_launch_fn = api->launch; + } + if (API_PROVIDES(api, shutdown)) { + zend_async_shutdown_fn = api->shutdown; + } + if (API_PROVIDES(api, get_class_ce)) { + zend_async_get_class_ce_fn = api->get_class_ce; + } + if (API_PROVIDES(api, call_on_main_stack)) { + zend_async_call_on_main_stack_fn = api->call_on_main_stack; + } + if (API_PROVIDES(api, get_context)) { + zend_async_get_context_fn = api->get_context; + } + if (API_PROVIDES(api, get_internal_context)) { + zend_async_get_internal_context_fn = api->get_internal_context; + } + if (API_PROVIDES(api, context_find)) { + zend_async_context_find_fn = api->context_find; + } + if (API_PROVIDES(api, context_set)) { + zend_async_context_set_fn = api->context_set; + } + if (API_PROVIDES(api, context_unset)) { + zend_async_context_unset_fn = api->context_unset; + } + if (API_PROVIDES(api, internal_context_find)) { + zend_async_internal_context_find_fn = api->internal_context_find; + } + if (API_PROVIDES(api, internal_context_set)) { + zend_async_internal_context_set_fn = api->internal_context_set; + } + if (API_PROVIDES(api, internal_context_unset)) { + zend_async_internal_context_unset_fn = api->internal_context_unset; + } + if (API_PROVIDES(api, intercept_fiber)) { + zend_async_intercept_fiber_fn = api->intercept_fiber; + } + + if (API_PROVIDES(api, gc_destructors)) { + zend_async_gc_destructors_fn = api->gc_destructors; + } + + if (API_PROVIDES(api, defer)) { + zend_async_defer_fn = api->defer; + } + + scheduler_module_name = module; + + return true; +} + +ZEND_API bool zend_async_is_enabled(void) +{ + return scheduler_module_name != NULL; +} + +ZEND_API const char *zend_async_get_scheduler_module(void) +{ + return scheduler_module_name; +} + +ZEND_API const char *zend_async_get_api_version(void) +{ + return ZEND_ASYNC_API; +} + +ZEND_API int zend_async_get_api_version_number(void) +{ + return ZEND_ASYNC_API_VERSION_NUMBER; +} + +ZEND_API void zend_async_scheduler_unregister(void) +{ + zend_async_api_shutdown(); +} + +void zend_async_api_shutdown(void) +{ + scheduler_module_name = NULL; + + zend_async_new_coroutine_fn = new_coroutine_stub; + zend_async_enqueue_coroutine_fn = enqueue_coroutine_stub; + zend_async_suspend_fn = suspend_stub; + zend_async_resume_fn = resume_stub; + zend_async_cancel_fn = cancel_stub; + zend_async_scheduler_launch_fn = bool_false_stub; + zend_async_shutdown_fn = bool_false_stub; + zend_async_get_class_ce_fn = get_class_ce_default; + zend_async_call_on_main_stack_fn = default_call_on_main_stack; + zend_async_get_context_fn = get_context_stub; + zend_async_get_internal_context_fn = get_context_stub; + zend_async_context_find_fn = context_find_stub; + zend_async_context_set_fn = context_set_stub; + zend_async_context_unset_fn = context_unset_stub; + zend_async_internal_context_find_fn = internal_context_find_stub; + zend_async_internal_context_set_fn = internal_context_set_stub; + zend_async_internal_context_unset_fn = internal_context_unset_stub; + zend_async_intercept_fiber_fn = NULL; + + internal_context_keys_shutdown(); +} diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h new file mode 100644 index 000000000000..2188cef31f82 --- /dev/null +++ b/Zend/zend_async_API.h @@ -0,0 +1,573 @@ +/* + +----------------------------------------------------------------------+ + | Copyright © The PHP Group and Contributors. | + +----------------------------------------------------------------------+ + | This source file is subject to the Modified BSD License that is | + | bundled with this package in the file LICENSE, and is available | + | through the World Wide Web at . | + | | + | SPDX-License-Identifier: BSD-3-Clause | + +----------------------------------------------------------------------+ + | Authors: Edmond | + +----------------------------------------------------------------------+ +*/ +#ifndef ZEND_ASYNC_API_H +#define ZEND_ASYNC_API_H + +#include "zend_API.h" + +/* + * Async Core ABI. + * + * This header is intentionally thin: it contains only the coroutine data + * structure and the function-pointer slots a scheduler implementation + * fills in. It contains NO policy: no scheduler, no reactor, no event + * system. How a coroutine waits — and for what — is entirely the + * provider's business; the core only offers the awaiting_info hook so + * that the wait state can be inspected for diagnostics. + */ +#define ZEND_ASYNC_API "AsyncCore ABI v0.1.0" +#define ZEND_ASYNC_API_VERSION_MAJOR 0 +#define ZEND_ASYNC_API_VERSION_MINOR 1 +#define ZEND_ASYNC_API_VERSION_PATCH 0 + +#define ZEND_ASYNC_API_VERSION_NUMBER \ + ((ZEND_ASYNC_API_VERSION_MAJOR << 16) | (ZEND_ASYNC_API_VERSION_MINOR << 8) \ + | (ZEND_ASYNC_API_VERSION_PATCH)) + +typedef struct _zend_coroutine_s zend_coroutine_t; +typedef struct _zend_async_context_s zend_async_context_t; +typedef struct _zend_fcall_s zend_fcall_t; +typedef struct _zend_fiber zend_fiber; +typedef void (*zend_coroutine_entry_t)(void); + +/* Class/exception registry keys resolved through zend_async_get_class_ce_fn. */ +typedef enum { + ZEND_ASYNC_CLASS_NO = 0, + ZEND_ASYNC_CLASS_COROUTINE = 1, + + ZEND_ASYNC_EXCEPTION_DEFAULT = 30, + ZEND_ASYNC_EXCEPTION_CANCELLATION = 31, +} zend_async_class; + +struct _zend_fcall_s { + zend_fcall_info fci; + zend_fcall_info_cache fci_cache; +}; + +/////////////////////////////////////////////////////////////////// +/// Context +/////////////////////////////////////////////////////////////////// + +/* + * Execution-flow context: key/value storage bound to a coroutine + * (structured-concurrency context). Storage, inheritance and lifetime are + * the provider's business — the core only routes the calls and holds the + * single field it needs to bridge a context to a PHP object. + * + * The provider extends this by embedding it (its own fields follow, or the + * context shares one allocation with a zend_object exactly like a + * coroutine). A pure C context that has no PHP object leaves object_offset + * at 0. + * + * Keys are strings or objects (object identity). A non string/object + * key raises a TypeError and the call returns false. + */ +struct _zend_async_context_s { + /* Offset of the embedding zend_object within the allocation, or 0 when + * the context has no PHP object. Symmetric with zend_coroutine_t. */ + uint32_t object_offset; +}; + +/* The zend_object a context is embedded in, or NULL when it has none. */ +#define ZEND_ASYNC_CONTEXT_OBJECT(context) \ + ((context)->object_offset != 0 \ + ? (zend_object *) ((char *) (context) + (context)->object_offset) \ + : NULL) + +/* Return the context of `coroutine`, creating it lazily. + * NULL means the currently running coroutine. + * + * The same signature serves two separate storages: + * - get_context - the userland context, zval keys; + * - get_internal_context - a context reserved for C extensions with + * NUMERIC keys, never visible to PHP code. */ +typedef zend_async_context_t *(*zend_async_get_context_t)(zend_coroutine_t *coroutine); +/* Looks the key up in the context; when include_parent is true, the + * lookup continues along the inheritance chain. On success copies the + * value into `result` (when not NULL) and returns true; otherwise sets + * `result` to NULL and returns false. */ +typedef bool (*zend_async_context_find_t)( + zend_async_context_t *context, zval *key, zval *result, bool include_parent); +typedef bool (*zend_async_context_set_t)( + zend_async_context_t *context, zval *key, zval *value); +typedef bool (*zend_async_context_unset_t)(zend_async_context_t *context, zval *key); + +/* + * Internal context accessors. The context comes from get_internal_context; + * keys are NUMERIC: an extension allocates its key once per process from a + * static C-string name (zend_async_internal_context_key_alloc), then + * reads/writes values through the slots below. Values are destroyed when + * the owning coroutine completes. + */ +typedef zval *(*zend_async_internal_context_find_t)( + zend_async_context_t *context, uint32_t key); +typedef bool (*zend_async_internal_context_set_t)( + zend_async_context_t *context, uint32_t key, zval *value); +typedef bool (*zend_async_internal_context_unset_t)( + zend_async_context_t *context, uint32_t key); + +/////////////////////////////////////////////////////////////////// +/// Coroutine +/////////////////////////////////////////////////////////////////// + +typedef void (*zend_async_coroutine_dispose)(zend_coroutine_t *coroutine); + +/** + * Debug hook: returns a human-readable description of what the coroutine + * is currently waiting for (e.g. "poll: socket 12, readable" or + * "channel receive"). Assigned by whoever suspends the coroutine — + * scheduler, reactor, channel. The returned string is owned by the + * caller. May be NULL when nothing is known about the wait. + */ +typedef zend_string *(*zend_coroutine_awaiting_info_fn)(zend_coroutine_t *coroutine); + +/** + * Coroutine lifecycle. The single source of truth, managed by the + * scheduler. Maps 1:1 to the PHP-level is*() methods. + */ +typedef enum { + ZEND_COROUTINE_STATUS_CREATED = 0, /* spawned, never executed */ + ZEND_COROUTINE_STATUS_QUEUED, /* ready, waiting in the run queue */ + ZEND_COROUTINE_STATUS_RUNNING, /* currently executing */ + ZEND_COROUTINE_STATUS_SUSPENDED, /* waiting; see awaiting_info */ + ZEND_COROUTINE_STATUS_FINISHED /* completed; result or exception is set */ +} zend_coroutine_status; + +struct _zend_coroutine_s { + /* Bits 0-3: zend_coroutine_status (the scheduler is the only writer); + * bits 4+: ZEND_COROUTINE_F_* modifiers. */ + uint32_t flags; + /* Offset of the wrapping zend_object within the allocation, when the + * coroutine is embedded in one (single-allocation pattern: the object + * and the coroutine share one block, reached via container_of). 0 for + * a plain C coroutine with no PHP object. */ + uint32_t object_offset; + /* Userland entry point. NULL for internal coroutines. */ + zend_fcall_t *fcall; + /* C entry point. NULL for userland coroutines. */ + zend_coroutine_entry_t internal_entry; + /* Custom data of the scheduler/extension. Nullable. */ + void *extended_data; + /* Completion result. */ + zval result; + /* Completion exception. Nullable. */ + zend_object *exception; + /* Spawn location (diagnostics). */ + zend_string *filename; + uint32_t lineno; + /* Describes the current wait for diagnostics. Nullable. */ + zend_coroutine_awaiting_info_fn awaiting_info; + /* Extended dispose handler. Nullable. */ + zend_async_coroutine_dispose extended_dispose; +}; + +/* The lifecycle status is packed into the low 4 bits of `flags`. */ +#define ZEND_COROUTINE_STATUS_MASK 0xFu + +#define ZEND_COROUTINE_STATUS(coroutine) \ + ((zend_coroutine_status) ((coroutine)->flags & ZEND_COROUTINE_STATUS_MASK)) +#define ZEND_COROUTINE_SET_STATUS(coroutine, _status) \ + ((coroutine)->flags = \ + ((coroutine)->flags & ~ZEND_COROUTINE_STATUS_MASK) | (uint32_t) (_status)) + +/* Orthogonal modifiers packed above the status bits. */ +#define ZEND_COROUTINE_F_CANCELLED (1u << 4) /* cancellation was requested */ +#define ZEND_COROUTINE_F_MAIN (1u << 5) /* the main coroutine */ +/* object_offset points at a stored zend_object* instead of an embedded + * object (used when the coroutine and its object live in different + * allocations, e.g. a coroutine bound to a Fiber). */ +#define ZEND_COROUTINE_F_OBJ_REF (1u << 6) + +#define ZEND_COROUTINE_IS_CANCELLED(coroutine) \ + (((coroutine)->flags & ZEND_COROUTINE_F_CANCELLED) != 0) +#define ZEND_COROUTINE_SET_CANCELLED(coroutine) \ + ((coroutine)->flags |= ZEND_COROUTINE_F_CANCELLED) + +#define ZEND_COROUTINE_IS_MAIN(coroutine) (((coroutine)->flags & ZEND_COROUTINE_F_MAIN) != 0) +#define ZEND_COROUTINE_SET_MAIN(coroutine) ((coroutine)->flags |= ZEND_COROUTINE_F_MAIN) + +/* The zend_object of a coroutine, or NULL for a plain C coroutine. + * Embedded model: the object lives at object_offset within the same + * allocation. OBJ_REF model: a zend_object* is stored at object_offset. */ +#define ZEND_COROUTINE_OBJECT(coroutine) \ + ((coroutine)->object_offset == 0 \ + ? NULL \ + : ((coroutine)->flags & ZEND_COROUTINE_F_OBJ_REF) \ + ? *(zend_object **) ((char *) (coroutine) + (coroutine)->object_offset) \ + : (zend_object *) ((char *) (coroutine) + (coroutine)->object_offset)) + +/* Lifecycle predicates over the packed status. */ +#define ZEND_COROUTINE_IS_STARTED(coroutine) \ + (ZEND_COROUTINE_STATUS(coroutine) != ZEND_COROUTINE_STATUS_CREATED) +#define ZEND_COROUTINE_IS_QUEUED(coroutine) \ + (ZEND_COROUTINE_STATUS(coroutine) == ZEND_COROUTINE_STATUS_QUEUED) +#define ZEND_COROUTINE_IS_RUNNING(coroutine) \ + (ZEND_COROUTINE_STATUS(coroutine) == ZEND_COROUTINE_STATUS_RUNNING) +#define ZEND_COROUTINE_IS_SUSPENDED(coroutine) \ + (ZEND_COROUTINE_STATUS(coroutine) == ZEND_COROUTINE_STATUS_SUSPENDED) +#define ZEND_COROUTINE_IS_FINISHED(coroutine) \ + (ZEND_COROUTINE_STATUS(coroutine) == ZEND_COROUTINE_STATUS_FINISHED) + +/** + * Fetch the wait description of a suspended coroutine. + * NULL when the coroutine is not waiting or no info handler is assigned. + * The caller owns the returned string. + */ +#define ZEND_COROUTINE_AWAITING_INFO(coroutine) \ + ((coroutine)->awaiting_info != NULL ? (coroutine)->awaiting_info(coroutine) : NULL) + +/** + * Build a zend_fcall_t from PHP function parameters + * (Z_PARAM_FUNC + Z_PARAM_VARIADIC_WITH_NAMED). + */ +#define ZEND_ASYNC_FCALL_DEFINE(_fcall_var, _src_fci, _src_fcc, _src_args, _src_args_count, _src_named_args) \ + zend_fcall_t *_fcall_var = ecalloc(1, sizeof(zend_fcall_t)); \ + _fcall_var->fci = _src_fci; \ + _fcall_var->fci_cache = _src_fcc; \ + if (_src_args_count) { \ + _fcall_var->fci.param_count = _src_args_count; \ + _fcall_var->fci.params = safe_emalloc(_src_args_count, sizeof(zval), 0); \ + for (uint32_t _fcall_i = 0; _fcall_i < _src_args_count; _fcall_i++) { \ + ZVAL_COPY(&_fcall_var->fci.params[_fcall_i], &_src_args[_fcall_i]); \ + } \ + } \ + if (_src_named_args) { \ + _fcall_var->fci.named_params = _src_named_args; \ + GC_ADDREF(_src_named_args); \ + } \ + Z_TRY_ADDREF(_fcall_var->fci.function_name); + +/////////////////////////////////////////////////////////////////// +/// Scheduler API slots +/////////////////////////////////////////////////////////////////// + +/* Allocate a coroutine in STATUS_CREATED; extra_size bytes are appended + * for the caller. */ +typedef zend_coroutine_t *(*zend_async_new_coroutine_t)(size_t extra_size); +/* Put a CREATED/SUSPENDED coroutine into the run queue (-> STATUS_QUEUED). */ +typedef bool (*zend_async_enqueue_coroutine_t)(zend_coroutine_t *coroutine); +/* Yield the current coroutine (-> STATUS_SUSPENDED) and give control to the + * scheduler. Returns after somebody resumes the coroutine; a delivered error + * is rethrown inside it, so the caller checks EG(exception) as usual. + * `from_main` = true is the after-main handoff: the main script (or its + * destructors) has finished and remaining coroutines get to run; + * `is_bailout` tells the scheduler the main flow ended with a bailout. */ +typedef bool (*zend_async_suspend_t)(bool from_main, bool is_bailout); +/* Wake a suspended coroutine. When `error` is non-NULL it is thrown at the + * suspension point; transfer_error passes ownership of the reference. */ +typedef bool (*zend_async_resume_t)( + zend_coroutine_t *coroutine, zend_object *error, const bool transfer_error); +/* Request cancellation: sets F_CANCELLED and wakes the coroutine with the + * error. `is_safely` defers delivery until a cancellation-safe point. */ +typedef bool (*zend_async_cancel_t)( + zend_coroutine_t *coroutine, zend_object *error, bool transfer_error, const bool is_safely); +typedef bool (*zend_async_scheduler_launch_t)(void); +typedef bool (*zend_async_shutdown_t)(void); +typedef zend_class_entry *(*zend_async_get_class_ce_t)(zend_async_class type); +/* Run fn(arg) on the main coroutine's OS-thread stack (FFI/JNI etc.). */ +typedef void (*zend_async_call_on_main_stack_t)(void (*fn)(void *), void *arg); +/* + * Microtask: a one-shot task executed on the next scheduler tick. + * + * A structure with a lifetime: refcount ownership, cancellable at any + * moment. The consumer embeds it in its own container (container_of). + * The queue is OWNED BY THE PROVIDER; the core only routes the pointer + * through the defer slot. Provider tick contract: + * + * if (!ZEND_ASYNC_MICROTASK_IS_CANCELLED(task)) task->handler(task); + * ZEND_ASYNC_MICROTASK_RELEASE(task); + */ +typedef struct _zend_async_microtask_s zend_async_microtask_t; +typedef void (*zend_async_microtask_handler_t)(zend_async_microtask_t *task); + +struct _zend_async_microtask_s { + /* Runs on the tick; NOT invoked for a cancelled task. */ + zend_async_microtask_handler_t handler; + /* Releases the container's resources when the last reference dies. + * NULL means a plain efree of the task. */ + zend_async_microtask_handler_t dtor; + /* A full 32-bit reference counter. */ + uint32_t ref_count; + /* 32 bits of named flags. */ + uint32_t is_cancelled : 1; + uint32_t reserved : 31; +}; + +#define ZEND_ASYNC_MICROTASK_IS_CANCELLED(task) ((task)->is_cancelled != 0) +#define ZEND_ASYNC_MICROTASK_CANCEL(task) ((task)->is_cancelled = 1) + +#define ZEND_ASYNC_MICROTASK_ADDREF(task) ((task)->ref_count++) + +#define ZEND_ASYNC_MICROTASK_RELEASE(task) \ + do { \ + if (--(task)->ref_count == 0) { \ + if ((task)->dtor != NULL) { \ + (task)->dtor(task); \ + } \ + efree(task); \ + } \ + } while (0) + +/* Queue the task on the provider's microtask queue. */ +typedef bool (*zend_async_defer_t)(zend_async_microtask_t *task); + +/* + * GC destructor phase interceptor (around). + * + * When the garbage collector reaches the destructor phase and the API is + * active, it calls this hook instead of running the phase directly. `run` + * is the engine's own destructor executor: the hook MUST call it (the + * engine re-runs any missed destructors afterwards as a safety net) and + * may bracket it with provider logic - typically opening a completion + * group before and awaiting everything the destructors spawned after. + * `run` is valid only for the duration of the phase. + */ +typedef bool (*zend_async_gc_run_dtors_fn)(void); +typedef bool (*zend_async_gc_destructors_t)(zend_async_gc_run_dtors_fn run); + +/* + * The point where the engine links a fiber to a coroutine. + * + * There are two kinds of fibers: low-level ones (pure context switching, + * the primitive Revolt-style loops drive themselves; no coroutine) and + * high-level ones (fiber + coroutine, driven by the scheduler). Called by + * the engine on every Fiber::start() while the API is active, the hook + * decides which kind this fiber is: + * + * returns a coroutine -> the engine binds it to the fiber + * (fiber->coroutine) and the fiber runs on the + * coroutine path; + * returns NULL -> the fiber keeps the legacy low-level behaviour. + * + * The coroutine is created by the scheduler, never by the engine. Because + * only the scheduler can tell its own internal fibers apart from + * application fibers, this also prevents self-recursion: the scheduler + * returns NULL for the fibers it drives itself. */ +typedef zend_coroutine_t *(*zend_async_intercept_fiber_t)(zend_fiber *fiber); + +/** + * Versioned scheduler API bundle. A provider fills the struct and calls + * zend_async_scheduler_register(). New slots are appended at the end only; + * `size` lets the core detect how much of the struct the provider knows. + */ +typedef struct _zend_async_scheduler_api_s { + uint32_t version; /* ZEND_ASYNC_API_VERSION_NUMBER the provider was built against */ + size_t size; /* sizeof(zend_async_scheduler_api_t) at provider build time */ + + zend_async_new_coroutine_t new_coroutine; + zend_async_enqueue_coroutine_t enqueue_coroutine; + zend_async_suspend_t suspend; + zend_async_resume_t resume; + zend_async_cancel_t cancel; + zend_async_scheduler_launch_t launch; + zend_async_shutdown_t shutdown; + zend_async_get_class_ce_t get_class_ce; + zend_async_call_on_main_stack_t call_on_main_stack; + zend_async_get_context_t get_context; + zend_async_get_context_t get_internal_context; + zend_async_context_find_t context_find; + zend_async_context_set_t context_set; + zend_async_context_unset_t context_unset; + zend_async_internal_context_find_t internal_context_find; + zend_async_internal_context_set_t internal_context_set; + zend_async_internal_context_unset_t internal_context_unset; + zend_async_intercept_fiber_t intercept_fiber; + zend_async_gc_destructors_t gc_destructors; + zend_async_defer_t defer; +} zend_async_scheduler_api_t; + +BEGIN_EXTERN_C() + +ZEND_API extern zend_async_new_coroutine_t zend_async_new_coroutine_fn; +ZEND_API extern zend_async_enqueue_coroutine_t zend_async_enqueue_coroutine_fn; +ZEND_API extern zend_async_suspend_t zend_async_suspend_fn; +ZEND_API extern zend_async_resume_t zend_async_resume_fn; +ZEND_API extern zend_async_cancel_t zend_async_cancel_fn; +ZEND_API extern zend_async_scheduler_launch_t zend_async_scheduler_launch_fn; +ZEND_API extern zend_async_shutdown_t zend_async_shutdown_fn; +ZEND_API extern zend_async_get_class_ce_t zend_async_get_class_ce_fn; +ZEND_API extern zend_async_call_on_main_stack_t zend_async_call_on_main_stack_fn; +ZEND_API extern zend_async_get_context_t zend_async_get_context_fn; +ZEND_API extern zend_async_get_context_t zend_async_get_internal_context_fn; +ZEND_API extern zend_async_context_find_t zend_async_context_find_fn; +ZEND_API extern zend_async_context_set_t zend_async_context_set_fn; +ZEND_API extern zend_async_context_unset_t zend_async_context_unset_fn; +ZEND_API extern zend_async_internal_context_find_t zend_async_internal_context_find_fn; +ZEND_API extern zend_async_internal_context_set_t zend_async_internal_context_set_fn; +ZEND_API extern zend_async_internal_context_unset_t zend_async_internal_context_unset_fn; +ZEND_API extern zend_async_intercept_fiber_t zend_async_intercept_fiber_fn; +ZEND_API extern zend_async_gc_destructors_t zend_async_gc_destructors_fn; +ZEND_API extern zend_async_defer_t zend_async_defer_fn; + +/* Internal context key registry (implemented by the core): maps a static + * C-string name to a process-unique numeric key. Thread-safe under ZTS. */ +ZEND_API uint32_t zend_async_internal_context_key_alloc(const char *key_name); +ZEND_API const char *zend_async_internal_context_key_name(uint32_t key); + +ZEND_API bool zend_async_scheduler_register( + const char *module, const zend_async_scheduler_api_t *api); +/* Withdraw the registration and reset every slot to its default. For + * request-scoped providers (the PHP bridge): a process may serve many + * requests, and a scheduler whose hooks die with the request must free + * the registration for the next one. C providers registered at MINIT + * have no reason to call this. */ +ZEND_API void zend_async_scheduler_unregister(void); + +ZEND_API bool zend_async_is_enabled(void); +/* The module name of the registered scheduler, or NULL when none. */ +ZEND_API const char *zend_async_get_scheduler_module(void); +ZEND_API const char *zend_async_get_api_version(void); +ZEND_API int zend_async_get_api_version_number(void); + +END_EXTERN_C() + +#define ZEND_ASYNC_NEW_COROUTINE() zend_async_new_coroutine_fn(0) +#define ZEND_ASYNC_NEW_COROUTINE_EX(extra_size) zend_async_new_coroutine_fn(extra_size) +#define ZEND_ASYNC_ENQUEUE_COROUTINE(coroutine) zend_async_enqueue_coroutine_fn(coroutine) +#define ZEND_ASYNC_SUSPEND() zend_async_suspend_fn(false, false) +/* Hand control to the scheduler one last time after the main flow ends. + * Safe to call unconditionally: a no-op while the Async API is inactive. */ +#define ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(is_bailout) \ + do { \ + if (ZEND_ASYNC_IS_ACTIVE) { \ + zend_async_suspend_fn(true, (is_bailout)); \ + } \ + } while (0) +#define ZEND_ASYNC_RESUME(coroutine) zend_async_resume_fn((coroutine), NULL, false) +#define ZEND_ASYNC_RESUME_WITH_ERROR(coroutine, error, transfer_error) \ + zend_async_resume_fn((coroutine), (error), (transfer_error)) +#define ZEND_ASYNC_CANCEL(coroutine, error, transfer_error) \ + zend_async_cancel_fn((coroutine), (error), (transfer_error), false) +#define ZEND_ASYNC_SCHEDULER_LAUNCH() zend_async_scheduler_launch_fn() +#define ZEND_ASYNC_SHUTDOWN() zend_async_shutdown_fn() +#define ZEND_ASYNC_GET_CE(type) zend_async_get_class_ce_fn(type) +#define ZEND_ASYNC_GET_EXCEPTION_CE(type) zend_async_get_class_ce_fn(type) +#define ZEND_ASYNC_CALL_ON_MAIN_STACK(fn, arg) zend_async_call_on_main_stack_fn((fn), (arg)) +#define ZEND_ASYNC_DEFER(task) zend_async_defer_fn(task) + +/* The coroutine to bind to a starting fiber, or NULL for the legacy + * low-level path. A scheduler with no intercept_fiber slot leaves every + * fiber low-level. */ +#define ZEND_ASYNC_INTERCEPT_FIBER(fiber) \ + ((ZEND_ASYNC_IS_ACTIVE && zend_async_intercept_fiber_fn != NULL) \ + ? zend_async_intercept_fiber_fn(fiber) \ + : NULL) + +#define ZEND_ASYNC_GET_CONTEXT(coroutine) zend_async_get_context_fn(coroutine) +#define ZEND_ASYNC_CURRENT_CONTEXT ZEND_ASYNC_GET_CONTEXT(NULL) +#define ZEND_ASYNC_GET_INTERNAL_CONTEXT(coroutine) zend_async_get_internal_context_fn(coroutine) +#define ZEND_ASYNC_CURRENT_INTERNAL_CONTEXT ZEND_ASYNC_GET_INTERNAL_CONTEXT(NULL) +#define ZEND_ASYNC_INTERNAL_CONTEXT_KEY_ALLOC(name) zend_async_internal_context_key_alloc(name) +#define ZEND_ASYNC_INTERNAL_CONTEXT_FIND(context, key) \ + zend_async_internal_context_find_fn((context), (key)) +#define ZEND_ASYNC_INTERNAL_CONTEXT_SET(context, key, value) \ + zend_async_internal_context_set_fn((context), (key), (value)) +#define ZEND_ASYNC_INTERNAL_CONTEXT_UNSET(context, key) \ + zend_async_internal_context_unset_fn((context), (key)) +#define ZEND_ASYNC_CONTEXT_FIND(context, key, result, include_parent) \ + zend_async_context_find_fn((context), (key), (result), (include_parent)) +#define ZEND_ASYNC_CONTEXT_SET(context, key, value) \ + zend_async_context_set_fn((context), (key), (value)) +#define ZEND_ASYNC_CONTEXT_UNSET(context, key) zend_async_context_unset_fn((context), (key)) + +/////////////////////////////////////////////////////////////////// +/// Globals +/////////////////////////////////////////////////////////////////// + +typedef enum { + ZEND_ASYNC_OFF, + ZEND_ASYNC_READY, + ZEND_ASYNC_ACTIVE +} zend_async_state_t; + +/* + * Storage of a PHP-registered scheduler (the Async\SchedulerHook bridge). + * Lives in the per-thread globals: the callables are request-local values. + * Hooks are addressed by index; the string names exist only to map the + * incoming array keys. + */ +typedef enum { + PHP_ASYNC_HOOK_LAUNCH = 0, + PHP_ASYNC_HOOK_SHUTDOWN, + PHP_ASYNC_HOOK_INTERCEPT_FIBER, + PHP_ASYNC_HOOK_ENQUEUE, + PHP_ASYNC_HOOK_SUSPEND, + PHP_ASYNC_HOOK_RESUME, + PHP_ASYNC_HOOK_CANCEL, + PHP_ASYNC_HOOK_CONTEXT_FIND, + PHP_ASYNC_HOOK_CONTEXT_SET, + PHP_ASYNC_HOOK_CONTEXT_UNSET, + PHP_ASYNC_HOOK_GC_DESTRUCTORS, + PHP_ASYNC_HOOK_DEFER, + PHP_ASYNC_HOOK_COUNT +} php_async_hook_id; + +/* One stored PHP callable. `set` distinguishes "provided" from "absent". */ +typedef struct { + bool set; + zend_fcall_info fci; + zend_fcall_info_cache fcc; +} php_async_hook_t; + +typedef struct { + bool active; + zend_string *module; + php_async_hook_t hooks[PHP_ASYNC_HOOK_COUNT]; +} php_async_handlers_t; + +typedef struct { + zend_async_state_t state; + /* Currently executing coroutine. NULL outside coroutine context. */ + zend_coroutine_t *coroutine; + /* The main coroutine (top-level script on the OS thread stack). */ + zend_coroutine_t *main_coroutine; + /* Number of live (not finished) coroutines. */ + unsigned int active_coroutine_count; + /* True while scheduler code runs (a hook invocation, or the provider's + * own machinery). Fiber operations on a bound fiber switch directly in + * this context; application code routes through the hooks instead. */ + bool in_scheduler_context; + /* PHP-registered scheduler hooks (the Async\SchedulerHook bridge). */ + php_async_handlers_t scheduler_hooks; +} zend_async_globals_t; + +BEGIN_EXTERN_C() +#ifdef ZTS +ZEND_API extern int zend_async_globals_id; +#define ZEND_ASYNC_G(v) ZEND_TSRMG(zend_async_globals_id, zend_async_globals_t *, v) +#else +ZEND_API extern zend_async_globals_t zend_async_globals_api; +#define ZEND_ASYNC_G(v) (zend_async_globals_api.v) +#endif + +void zend_async_globals_ctor(void); +void zend_async_globals_dtor(void); +void zend_async_api_shutdown(void); + +END_EXTERN_C() + +#define ZEND_ASYNC_ON (ZEND_ASYNC_G(state) > ZEND_ASYNC_OFF) +#define ZEND_ASYNC_IS_ACTIVE (ZEND_ASYNC_G(state) == ZEND_ASYNC_ACTIVE) +#define ZEND_ASYNC_IS_OFF (ZEND_ASYNC_G(state) == ZEND_ASYNC_OFF) +#define ZEND_ASYNC_IS_READY (ZEND_ASYNC_G(state) == ZEND_ASYNC_READY) +#define ZEND_ASYNC_ACTIVATE ZEND_ASYNC_G(state) = ZEND_ASYNC_ACTIVE +#define ZEND_ASYNC_INITIALIZE ZEND_ASYNC_G(state) = ZEND_ASYNC_READY +#define ZEND_ASYNC_DEACTIVATE ZEND_ASYNC_G(state) = ZEND_ASYNC_OFF + +#define ZEND_ASYNC_CURRENT_COROUTINE ZEND_ASYNC_G(coroutine) +#define ZEND_ASYNC_MAIN_COROUTINE ZEND_ASYNC_G(main_coroutine) +#define ZEND_ASYNC_ACTIVE_COROUTINE_COUNT ZEND_ASYNC_G(active_coroutine_count) +#define ZEND_ASYNC_IN_SCHEDULER_CONTEXT ZEND_ASYNC_G(in_scheduler_context) + +#endif /* ZEND_ASYNC_API_H */ diff --git a/Zend/zend_fibers.c b/Zend/zend_fibers.c index c91436050856..5e0654e6a34c 100644 --- a/Zend/zend_fibers.c +++ b/Zend/zend_fibers.c @@ -810,6 +810,31 @@ static void zend_fiber_object_free(zend_object *object) zval_ptr_dtor(&fiber->fci.function_name); zval_ptr_dtor(&fiber->result); + zval_ptr_dtor(&fiber->transfer); + + if (fiber->flags & ZEND_FIBER_FLAG_PARAMS_COPIED) { + for (uint32_t i = 0; i < fiber->fci.param_count; i++) { + zval_ptr_dtor(&fiber->fci.params[i]); + } + + if (fiber->fci.params != NULL) { + efree(fiber->fci.params); + } + + if (fiber->fci.named_params != NULL) { + zend_array_release(fiber->fci.named_params); + } + } + + /* The coroutine handle is owned by whoever minted it; the dispose + * handler knows how to release it. */ + if (fiber->coroutine != NULL) { + if (fiber->coroutine->extended_dispose != NULL) { + fiber->coroutine->extended_dispose(fiber->coroutine); + } + + fiber->coroutine = NULL; + } zend_object_std_dtor(&fiber->std); } @@ -821,6 +846,19 @@ static HashTable *zend_fiber_object_gc(zend_object *object, zval **table, int *n zend_get_gc_buffer_add_zval(buf, &fiber->fci.function_name); zend_get_gc_buffer_add_zval(buf, &fiber->result); + zend_get_gc_buffer_add_zval(buf, &fiber->transfer); + + /* Coroutine mode: the fiber owns a reference to the scheduler's + * coroutine object (released in the extended_dispose). Declare the + * edge, or a coroutine object referencing the fiber back would form + * an uncollectable cycle. */ + if (fiber->coroutine != NULL) { + zend_object *coroutine_object = ZEND_COROUTINE_OBJECT(fiber->coroutine); + + if (coroutine_object != NULL) { + zend_get_gc_buffer_add_obj(buf, coroutine_object); + } + } if (fiber->context.status != ZEND_FIBER_STATUS_SUSPENDED || fiber->caller != NULL) { zend_get_gc_buffer_use(buf, table, num); @@ -891,14 +929,124 @@ ZEND_METHOD(Fiber, __construct) Z_TRY_ADDREF(fiber->fci.function_name); } +/* Coroutine mode, scheduler context: switch directly into a bound fiber. + * Runs it until it yields or finishes; the yielded value (or completion + * NULL) is written to return_value. The scheduler reaches this through the + * plain Fiber API - there is no other switching primitive. */ +static void zend_fiber_scheduler_switch(zend_fiber *fiber, zval *return_value) +{ + zend_coroutine_t *coroutine = fiber->coroutine; + + ZVAL_NULL(return_value); + + if (UNEXPECTED(zend_fiber_switch_blocked())) { + zend_throw_error(zend_ce_fiber_error, "Cannot switch fibers in current execution context"); + return; + } + + /* Take the parked input (resume value or pending exception). */ + const bool is_error = (fiber->flags & ZEND_FIBER_FLAG_ERROR_TRANSFER) != 0; + fiber->flags &= ~ZEND_FIBER_FLAG_ERROR_TRANSFER; + + zval value; + ZVAL_COPY_VALUE(&value, &fiber->transfer); + ZVAL_UNDEF(&fiber->transfer); + + if (fiber->context.status == ZEND_FIBER_STATUS_INIT) { + if (zend_fiber_init_context(&fiber->context, zend_ce_fiber, zend_fiber_execute, + EG(fiber_stack_size)) == FAILURE) { + zval_ptr_dtor(&value); + return; + } + + fiber->previous = &fiber->context; + } else if (UNEXPECTED(fiber->context.status != ZEND_FIBER_STATUS_SUSPENDED + || fiber->caller != NULL)) { + zval_ptr_dtor(&value); + zend_throw_error(zend_ce_fiber_error, "Cannot resume a fiber that is not suspended"); + return; + } else { + fiber->stack_bottom->prev_execute_data = EG(current_execute_data); + } + + ZEND_COROUTINE_SET_STATUS(coroutine, ZEND_COROUTINE_STATUS_RUNNING); + + /* The fiber body is application code, not the scheduler. */ + const bool saved_context = ZEND_ASYNC_IN_SCHEDULER_CONTEXT; + ZEND_ASYNC_IN_SCHEDULER_CONTEXT = false; + + zend_fiber_transfer transfer = zend_fiber_resume_internal( + fiber, Z_ISUNDEF(value) ? NULL : &value, is_error); + + ZEND_ASYNC_IN_SCHEDULER_CONTEXT = saved_context; + + zval_ptr_dtor(&value); + + if (fiber->context.status == ZEND_FIBER_STATUS_DEAD) { + ZEND_COROUTINE_SET_STATUS(coroutine, ZEND_COROUTINE_STATUS_FINISHED); + + if (Z_ISUNDEF(coroutine->result) && !Z_ISUNDEF(fiber->result)) { + ZVAL_COPY(&coroutine->result, &fiber->result); + } + } else { + ZEND_COROUTINE_SET_STATUS(coroutine, ZEND_COROUTINE_STATUS_SUSPENDED); + + /* Park a copy of the yield for the start()/resume() reader. */ + if (!(transfer.flags & ZEND_FIBER_TRANSFER_FLAG_ERROR)) { + zval_ptr_dtor(&fiber->transfer); + ZVAL_COPY(&fiber->transfer, &transfer.value); + } + } + + /* Deliver the yield (or the escaped exception) to the switchTo caller. */ + if (transfer.flags & ZEND_FIBER_TRANSFER_FLAG_ERROR) { + zend_throw_exception_internal(Z_OBJ(transfer.value)); + } else { + ZVAL_COPY_VALUE(return_value, &transfer.value); + } +} + +/* Coroutine mode: wait until the fiber yields (or finishes) and return the + * yielded value. Hands control to the scheduler, which continues coroutines + * through switchTo; when it hands control back, the value the fiber parked + * on yield becomes the result (NULL when it finished or was not run). */ +static void zend_fiber_wait_for_yield(zend_fiber *fiber, zval *return_value) +{ + ZEND_ASYNC_SUSPEND(); + + if (UNEXPECTED(EG(exception))) { + RETVAL_NULL(); + return; + } + + if (Z_ISUNDEF(fiber->transfer)) { + RETVAL_NULL(); + } else { + RETVAL_COPY_VALUE(&fiber->transfer); + ZVAL_UNDEF(&fiber->transfer); + } +} + ZEND_METHOD(Fiber, start) { zend_fiber *fiber = (zend_fiber *) Z_OBJ_P(ZEND_THIS); + zval *args; + uint32_t argc; + HashTable *named_args; ZEND_PARSE_PARAMETERS_START(0, -1) - Z_PARAM_VARIADIC_WITH_NAMED(fiber->fci.params, fiber->fci.param_count, fiber->fci.named_params); + Z_PARAM_VARIADIC_WITH_NAMED(args, argc, named_args); ZEND_PARSE_PARAMETERS_END(); + /* Bind the arguments. A scheduler-context restart with no arguments + * keeps the ones the application call bound. */ + if (!(ZEND_ASYNC_IN_SCHEDULER_CONTEXT && fiber->coroutine != NULL && argc == 0 + && named_args == NULL)) { + fiber->fci.params = args; + fiber->fci.param_count = argc; + fiber->fci.named_params = named_args; + } + if (UNEXPECTED(zend_fiber_switch_blocked())) { zend_throw_error(zend_ce_fiber_error, "Cannot switch fibers in current execution context"); RETURN_THROWS(); @@ -909,6 +1057,55 @@ ZEND_METHOD(Fiber, start) RETURN_THROWS(); } + /* Coroutine mode: the scheduler may bind a coroutine to this fiber. + * NULL keeps the fiber on the low-level path. */ + if (fiber->coroutine == NULL) { + fiber->coroutine = ZEND_ASYNC_INTERCEPT_FIBER(fiber); + + if (fiber->coroutine != NULL && fiber->coroutine->extended_data == NULL) { + fiber->coroutine->extended_data = fiber; + } + } + + if (fiber->coroutine != NULL) { + /* The scheduler runs a bound fiber directly through the plain + * Fiber API; application code hands over to the scheduler. */ + if (ZEND_ASYNC_IN_SCHEDULER_CONTEXT) { + zend_fiber_scheduler_switch(fiber, return_value); + + if (UNEXPECTED(EG(exception))) { + RETURN_THROWS(); + } + + return; + } + + /* The start is deferred: the arguments must survive this call + * frame, so the fiber takes an owned deep copy. */ + if (fiber->fci.param_count > 0) { + zval *copy = safe_emalloc(fiber->fci.param_count, sizeof(zval), 0); + + for (uint32_t i = 0; i < fiber->fci.param_count; i++) { + ZVAL_COPY(©[i], &fiber->fci.params[i]); + } + + fiber->fci.params = copy; + fiber->flags |= ZEND_FIBER_FLAG_PARAMS_COPIED; + } + + if (fiber->fci.named_params != NULL) { + GC_ADDREF(fiber->fci.named_params); + fiber->flags |= ZEND_FIBER_FLAG_PARAMS_COPIED; + } + + if (!ZEND_ASYNC_ENQUEUE_COROUTINE(fiber->coroutine) || EG(exception)) { + RETURN_THROWS(); + } + + zend_fiber_wait_for_yield(fiber, return_value); + return; + } + if (zend_fiber_init_context(&fiber->context, zend_ce_fiber, zend_fiber_execute, EG(fiber_stack_size)) == FAILURE) { RETURN_THROWS(); } @@ -977,6 +1174,44 @@ ZEND_METHOD(Fiber, resume) RETURN_THROWS(); } + /* Coroutine mode: the scheduler switches directly; application code + * parks the value, notifies the scheduler and lets it drive. */ + if (fiber->coroutine != NULL) { + if (ZEND_ASYNC_IN_SCHEDULER_CONTEXT) { + /* An explicit value overrides the parked one. */ + if (value != NULL) { + zval_ptr_dtor(&fiber->transfer); + ZVAL_COPY(&fiber->transfer, value); + fiber->flags &= ~ZEND_FIBER_FLAG_ERROR_TRANSFER; + } + + zend_fiber_scheduler_switch(fiber, return_value); + + if (UNEXPECTED(EG(exception))) { + RETURN_THROWS(); + } + + return; + } + + zval_ptr_dtor(&fiber->transfer); + + if (value != NULL) { + ZVAL_COPY(&fiber->transfer, value); + } else { + ZVAL_UNDEF(&fiber->transfer); + } + + fiber->flags &= ~ZEND_FIBER_FLAG_ERROR_TRANSFER; + + if (!ZEND_ASYNC_RESUME(fiber->coroutine) || EG(exception)) { + RETURN_THROWS(); + } + + zend_fiber_wait_for_yield(fiber, return_value); + return; + } + fiber->stack_bottom->prev_execute_data = EG(current_execute_data); zend_fiber_transfer transfer = zend_fiber_resume_internal(fiber, value, false); @@ -1005,6 +1240,33 @@ ZEND_METHOD(Fiber, throw) RETURN_THROWS(); } + /* Coroutine mode: park the exception; the scheduler switches directly + * (the throw happens at the suspension point), application code + * notifies the scheduler and lets it drive. */ + if (fiber->coroutine != NULL) { + zval_ptr_dtor(&fiber->transfer); + ZVAL_COPY(&fiber->transfer, exception); + fiber->flags |= ZEND_FIBER_FLAG_ERROR_TRANSFER; + + if (ZEND_ASYNC_IN_SCHEDULER_CONTEXT) { + zend_fiber_scheduler_switch(fiber, return_value); + + if (UNEXPECTED(EG(exception))) { + RETURN_THROWS(); + } + + return; + } + + if (!ZEND_ASYNC_RESUME_WITH_ERROR(fiber->coroutine, Z_OBJ_P(exception), false) + || EG(exception)) { + RETURN_THROWS(); + } + + zend_fiber_wait_for_yield(fiber, return_value); + return; + } + fiber->stack_bottom->prev_execute_data = EG(current_execute_data); zend_fiber_transfer transfer = zend_fiber_resume_internal(fiber, exception, true); diff --git a/Zend/zend_fibers.h b/Zend/zend_fibers.h index c72ffdc8f18e..081531566757 100644 --- a/Zend/zend_fibers.h +++ b/Zend/zend_fibers.h @@ -21,6 +21,7 @@ #include "zend_API.h" #include "zend_types.h" +#include "zend_async_API.h" #define ZEND_FIBER_GUARD_PAGES 1 @@ -40,6 +41,12 @@ typedef enum { ZEND_FIBER_FLAG_THREW = 1 << 0, ZEND_FIBER_FLAG_BAILOUT = 1 << 1, ZEND_FIBER_FLAG_DESTROYED = 1 << 2, + /* The parked transfer value is an exception to throw at the + * suspension point (coroutine mode only). */ + ZEND_FIBER_FLAG_ERROR_TRANSFER = 1 << 3, + /* fci.params is a fiber-owned deep copy (coroutine mode: a deferred + * start must not reference the caller's dead stack frame). */ + ZEND_FIBER_FLAG_PARAMS_COPIED = 1 << 4, } zend_fiber_flag; typedef enum { @@ -108,8 +115,20 @@ struct _zend_fiber { /* Native C fiber context. */ zend_fiber_context context; - /* Fiber that resumed us. */ - zend_fiber_context *caller; + /* + * Associated coroutine, when a scheduler is active. NULL in legacy + * mode: the fiber then blocks the thread exactly as before. When set, + * the fiber's wait becomes cooperative - resume/suspend route through + * the scheduler slots instead of switching fiber contexts directly. + */ + zend_coroutine_t *coroutine; + + union { + /* Fiber that resumed us (legacy mode, coroutine == NULL). */ + zend_fiber_context *caller; + /* Coroutine that resumed us (coroutine mode). */ + zend_coroutine_t *caller_coroutine; + }; /* Fiber that suspended us. */ zend_fiber_context *previous; @@ -129,6 +148,15 @@ struct _zend_fiber { /* Storage for fiber return value. */ zval result; + + /* + * Coroutine mode only: the value crossing the scheduler boundary. + * Before a switch it holds the pending resume value (or the exception + * when ZEND_FIBER_FLAG_ERROR_TRANSFER is set); after a yield it holds + * the value the fiber suspended with, so start()/resume() can return + * it once the scheduler hands control back. + */ + zval transfer; }; ZEND_API zend_result zend_fiber_start(zend_fiber *fiber, zval *return_value); diff --git a/Zend/zend_gc.c b/Zend/zend_gc.c index 5de2b69bf568..3831e7dab1bb 100644 --- a/Zend/zend_gc.c +++ b/Zend/zend_gc.c @@ -70,6 +70,7 @@ #include "zend_compile.h" #include "zend_errors.h" #include "zend_fibers.h" +#include "zend_async_API.h" #include "zend_hrtime.h" #include "zend_portability.h" #include "zend_types.h" @@ -293,6 +294,9 @@ typedef struct _zend_gc_globals { uint32_t dtor_end; zend_fiber *dtor_fiber; bool dtor_fiber_running; + /* The destructor phase is active: zend_gc_run_pending_destructors() + * is allowed to run. */ + bool dtor_phase_active; #if GC_BENCH uint32_t root_buf_length; @@ -535,6 +539,7 @@ static void gc_globals_ctor_ex(zend_gc_globals *gc_globals) gc_globals->dtor_end = 0; gc_globals->dtor_fiber = NULL; gc_globals->dtor_fiber_running = false; + gc_globals->dtor_phase_active = false; #if GC_BENCH gc_globals->root_buf_length = 0; @@ -583,6 +588,7 @@ void gc_reset(void) GC_G(dtor_end) = 0; GC_G(dtor_fiber) = NULL; GC_G(dtor_fiber_running) = false; + GC_G(dtor_phase_active) = false; #if GC_BENCH GC_G(root_buf_length) = 0; @@ -1892,7 +1898,15 @@ static zend_always_inline zend_result gc_call_destructors(uint32_t idx, uint32_t GC_TRACE_REF(obj, "calling destructor"); GC_ADD_FLAGS(obj, IS_OBJ_DESTRUCTOR_CALLED); GC_ADDREF(obj); + + /* A destructor is application code even when the phase runs + * inside a scheduler hook. */ + const bool saved_context = ZEND_ASYNC_IN_SCHEDULER_CONTEXT; + ZEND_ASYNC_IN_SCHEDULER_CONTEXT = false; + obj->handlers->dtor_obj(obj); + + ZEND_ASYNC_IN_SCHEDULER_CONTEXT = saved_context; GC_TRACE_REF(obj, "returned from destructor"); GC_DELREF(obj); if (UNEXPECTED(fiber != NULL && GC_G(dtor_fiber) != fiber)) { @@ -1990,6 +2004,29 @@ static zend_never_inline void gc_call_destructors_in_fiber(void) EG(exception) = exception; } +/* The engine's destructor-phase executor handed to the gc_destructors + * hook. Re-runnable while the phase is active: already-called destructors + * are skipped by the IS_OBJ_DESTRUCTOR_CALLED flag. */ +static bool gc_run_destructor_phase(void) +{ + if (!GC_G(dtor_phase_active)) { + return false; + } + + if (EXPECTED(!EG(active_fiber))) { + gc_call_destructors(GC_FIRST_ROOT, GC_G(first_unused), NULL); + } else { + gc_call_destructors_in_fiber(); + } + + return true; +} + +ZEND_API bool zend_gc_run_pending_destructors(void) +{ + return gc_run_destructor_phase(); +} + /* Perform a garbage collection run. The default implementation of gc_collect_cycles. */ ZEND_API int zend_gc_collect_cycles(void) { @@ -2091,11 +2128,21 @@ ZEND_API int zend_gc_collect_cycles(void) /* Actually call destructors. */ zend_hrtime_t dtor_start_time = zend_hrtime(); - if (EXPECTED(!EG(active_fiber))) { - gc_call_destructors(GC_FIRST_ROOT, end, NULL); + GC_G(dtor_phase_active) = true; + + if (UNEXPECTED(ZEND_ASYNC_IS_ACTIVE && zend_async_gc_destructors_fn != NULL)) { + /* The provider brackets the phase (e.g. opens a completion + * group and awaits everything the destructors spawned). */ + zend_async_gc_destructors_fn(gc_run_destructor_phase); + + /* Safety net: the hook cannot prevent destructors from + * running - execute any that were missed. */ + gc_run_destructor_phase(); } else { - gc_call_destructors_in_fiber(); + gc_run_destructor_phase(); } + + GC_G(dtor_phase_active) = false; GC_G(dtor_time) += zend_hrtime() - dtor_start_time; if (GC_G(gc_protected)) { diff --git a/Zend/zend_gc.h b/Zend/zend_gc.h index b72b90ccd5ef..e3ea24c5595c 100644 --- a/Zend/zend_gc.h +++ b/Zend/zend_gc.h @@ -61,6 +61,10 @@ void gc_bench_print(void); /* The default implementation of the gc_collect_cycles callback. */ ZEND_API int zend_gc_collect_cycles(void); +/* Run the destructors pending in the current GC destructor phase. Valid + * only while the phase is active (from inside the gc_destructors hook); + * otherwise a no-op returning false. */ +ZEND_API bool zend_gc_run_pending_destructors(void); ZEND_API void zend_gc_get_status(zend_gc_status *status); diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c new file mode 100644 index 000000000000..0008b4363e89 --- /dev/null +++ b/Zend/zend_scheduler_hook.c @@ -0,0 +1,586 @@ +/* + +----------------------------------------------------------------------+ + | Copyright © The PHP Group and Contributors. | + +----------------------------------------------------------------------+ + | This source file is subject to the Modified BSD License that is | + | bundled with this package in the file LICENSE, and is available | + | through the World Wide Web at . | + | | + | SPDX-License-Identifier: BSD-3-Clause | + +----------------------------------------------------------------------+ + | Authors: Edmond | + +----------------------------------------------------------------------+ +*/ + +/* + * The PHP registration bridge for the Async Core. + * + * Async\SchedulerHook::register() lets a scheduler written in PHP fill the + * engine's scheduler slots with plain callables. Each slot is backed by a + * C thunk that forwards to the stored callable. + * + * The coroutine itself is defined by external code (the scheduler / + * provider): the bridge never creates a coroutine object, it only unwraps + * a zend_coroutine_t to the object the provider embedded it in. + * + * NOTE: this file compiles but is not yet runtime-tested — it needs a full + * build and a reference scheduler to exercise. The context accessors are a + * follow-up. + */ + +#include "zend_scheduler_hook.h" +#include "zend_async_API.h" +#include "zend_scheduler_hook_arginfo.h" +#include "zend_fibers.h" +#include "zend_exceptions.h" +#include "zend_closures.h" + +/* The hook names, indexed by php_async_hook_id: used only to map the + * incoming array keys (the Async\SchedulerHook class constants). */ +static const struct { + const char *name; + size_t len; +} php_async_hook_names[PHP_ASYNC_HOOK_COUNT] = { + [PHP_ASYNC_HOOK_LAUNCH] = { ZEND_STRL("launch") }, + [PHP_ASYNC_HOOK_SHUTDOWN] = { ZEND_STRL("shutdown") }, + [PHP_ASYNC_HOOK_INTERCEPT_FIBER] = { ZEND_STRL("intercept_fiber") }, + [PHP_ASYNC_HOOK_ENQUEUE] = { ZEND_STRL("enqueue_coroutine") }, + [PHP_ASYNC_HOOK_SUSPEND] = { ZEND_STRL("suspend") }, + [PHP_ASYNC_HOOK_RESUME] = { ZEND_STRL("resume") }, + [PHP_ASYNC_HOOK_CANCEL] = { ZEND_STRL("cancel") }, + [PHP_ASYNC_HOOK_CONTEXT_FIND] = { ZEND_STRL("context_find") }, + [PHP_ASYNC_HOOK_CONTEXT_SET] = { ZEND_STRL("context_set") }, + [PHP_ASYNC_HOOK_CONTEXT_UNSET] = { ZEND_STRL("context_unset") }, + [PHP_ASYNC_HOOK_GC_DESTRUCTORS] = { ZEND_STRL("gc_destructors") }, + [PHP_ASYNC_HOOK_DEFER] = { ZEND_STRL("defer") }, +}; + +/* The storage lives in the per-thread async globals: correct under ZTS, + * request-local by construction. */ +#define PHP_ASYNC_HANDLERS (ZEND_ASYNC_G(scheduler_hooks)) +#define PHP_ASYNC_HOOK(id) (&PHP_ASYNC_HANDLERS.hooks[(id)]) + +/* Read the callable for `id` from the incoming array into storage. */ +static bool php_async_hook_take(HashTable *array, php_async_hook_id id) +{ + php_async_hook_t *hook = PHP_ASYNC_HOOK(id); + zval *entry = zend_hash_str_find(array, php_async_hook_names[id].name, php_async_hook_names[id].len); + + if (entry == NULL) { + hook->set = false; + return true; + } + + char *error = NULL; + + if (zend_fcall_info_init(entry, 0, &hook->fci, &hook->fcc, NULL, &error) != SUCCESS) { + zend_type_error("Async scheduler hook \"%s\" must be a valid callable: %s", + php_async_hook_names[id].name, error != NULL ? error : "unknown error"); + if (error != NULL) { + efree(error); + } + + return false; + } + + if (error != NULL) { + efree(error); + } + + /* Keep the callable alive for the whole request. */ + Z_TRY_ADDREF(hook->fci.function_name); + if (hook->fci.object != NULL) { + GC_ADDREF(hook->fci.object); + } + + hook->set = true; + return true; +} + +static void php_async_hook_release(php_async_hook_t *hook) +{ + if (!hook->set) { + return; + } + + zval_ptr_dtor(&hook->fci.function_name); + + if (hook->fci.object != NULL) { + OBJ_RELEASE(hook->fci.object); + } + + hook->set = false; +} + +/* Call a stored hook with `argc` prepared arguments; result in `retval` + * (caller owns it). Returns false when the hook is absent or the call fails. */ +static bool php_async_hook_call(php_async_hook_t *hook, uint32_t argc, zval *argv, zval *retval) +{ + if (!hook->set) { + ZVAL_UNDEF(retval); + return false; + } + + hook->fci.param_count = argc; + hook->fci.params = argv; + hook->fci.retval = retval; + + /* A hook invocation IS scheduler code: bound-fiber operations inside + * it switch directly instead of routing back through the hooks. */ + const bool saved_context = ZEND_ASYNC_IN_SCHEDULER_CONTEXT; + ZEND_ASYNC_IN_SCHEDULER_CONTEXT = true; + + const bool ok = zend_call_function(&hook->fci, &hook->fcc) == SUCCESS && !EG(exception); + + ZEND_ASYNC_IN_SCHEDULER_CONTEXT = saved_context; + + return ok; +} + +static bool php_async_hook_call_bool(php_async_hook_id id, uint32_t argc, zval *argv) +{ + zval retval; + + if (!php_async_hook_call(PHP_ASYNC_HOOK(id), argc, argv, &retval)) { + return false; + } + + const bool ok = zend_is_true(&retval); + zval_ptr_dtor(&retval); + return ok; +} + +///////////////////////////////////////////////////////////////////// +/// Non-coroutine thunks +///////////////////////////////////////////////////////////////////// + +static bool php_async_thunk_launch(void) +{ + return php_async_hook_call_bool(PHP_ASYNC_HOOK_LAUNCH, 0, NULL); +} + +static bool php_async_thunk_shutdown(void) +{ + return php_async_hook_call_bool(PHP_ASYNC_HOOK_SHUTDOWN, 0, NULL); +} + +/* + * The coroutine handle the bridge mints for a coroutine object returned by + * the PHP intercept_fiber hook. The scheduler's coroutine is a plain PHP + * object, so the engine-visible zend_coroutine_t lives in its own small + * allocation and reaches the object through a stored pointer (OBJ_REF). + * The handle holds one reference to the object; both are released by the + * fiber teardown once phase 2 wires it (TODO). + */ +typedef struct { + zend_coroutine_t coro; + zend_object *object; +} php_coroutine_t; + +/* Release a minted handle: the fiber teardown calls this via + * coro->extended_dispose. */ +static void php_async_coroutine_dispose(zend_coroutine_t *coro) +{ + php_coroutine_t *handle = (php_coroutine_t *) coro; + + zval_ptr_dtor(&coro->result); + + if (coro->exception != NULL) { + OBJ_RELEASE(coro->exception); + } + + if (handle->object != NULL) { + OBJ_RELEASE(handle->object); + } + + efree(handle); +} + +static zend_coroutine_t *php_async_thunk_intercept_fiber(zend_fiber *fiber) +{ + zval arg, retval; + ZVAL_OBJ(&arg, &fiber->std); + GC_ADDREF(&fiber->std); + + const bool ok = php_async_hook_call( + PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_INTERCEPT_FIBER), 1, &arg, &retval); + + zval_ptr_dtor(&arg); + + if (!ok || Z_TYPE(retval) != IS_OBJECT) { + /* NULL (or any non-object) keeps the fiber on the low-level path. */ + zval_ptr_dtor(&retval); + return NULL; + } + + php_coroutine_t *adopted = ecalloc(1, sizeof(*adopted)); + + adopted->object = Z_OBJ(retval); /* takes over the retval reference */ + adopted->coro.flags = ZEND_COROUTINE_F_OBJ_REF; + adopted->coro.object_offset = offsetof(php_coroutine_t, object); + adopted->coro.extended_data = fiber; + adopted->coro.extended_dispose = php_async_coroutine_dispose; + ZVAL_UNDEF(&adopted->coro.result); + + return &adopted->coro; +} + +///////////////////////////////////////////////////////////////////// +/// Coroutine-carrying thunks +///////////////////////////////////////////////////////////////////// + +/* The coroutine's PHP object as a zval, with a borrowed +1 for the call. + * The object is defined by external code (the scheduler / provider) and + * reached through coro->object_offset. */ +static void php_async_coroutine_arg(zend_coroutine_t *coro, zval *out) +{ + zend_object *object = ZEND_COROUTINE_OBJECT(coro); + + if (object != NULL) { + ZVAL_OBJ(out, object); + GC_ADDREF(object); + } else { + ZVAL_NULL(out); + } +} + +static bool php_async_thunk_enqueue(zend_coroutine_t *coro) +{ + zval arg; + php_async_coroutine_arg(coro, &arg); + + const bool result = php_async_hook_call_bool(PHP_ASYNC_HOOK_ENQUEUE, 1, &arg); + + zval_ptr_dtor(&arg); + return result; +} + +static bool php_async_thunk_suspend(bool from_main, bool is_bailout) +{ + zval args[2]; + ZVAL_BOOL(&args[0], from_main); + ZVAL_BOOL(&args[1], is_bailout); + + return php_async_hook_call_bool(PHP_ASYNC_HOOK_SUSPEND, 2, args); +} + +/* Shared body for resume/cancel: (coroutine, ?error). transfer_error hands + * over ownership of the error reference to the call. */ +static bool php_async_thunk_wake(php_async_hook_id id, zend_coroutine_t *coro, + zend_object *error, bool transfer_error) +{ + zval args[2]; + php_async_coroutine_arg(coro, &args[0]); + + if (error != NULL) { + ZVAL_OBJ(&args[1], error); + if (!transfer_error) { + GC_ADDREF(error); + } + } else { + ZVAL_NULL(&args[1]); + } + + const bool result = php_async_hook_call_bool(id, 2, args); + + zval_ptr_dtor(&args[0]); + zval_ptr_dtor(&args[1]); + return result; +} + +static bool php_async_thunk_resume( + zend_coroutine_t *coro, zend_object *error, const bool transfer_error) +{ + return php_async_thunk_wake(PHP_ASYNC_HOOK_RESUME, coro, error, transfer_error); +} + +static bool php_async_thunk_cancel( + zend_coroutine_t *coro, zend_object *error, bool transfer_error, const bool is_safely) +{ + (void) is_safely; /* the PHP cancel hook takes no "safely" flag */ + return php_async_thunk_wake(PHP_ASYNC_HOOK_CANCEL, coro, error, transfer_error); +} + +///////////////////////////////////////////////////////////////////// +/// Context thunks +///////////////////////////////////////////////////////////////////// + +/* The context's PHP object as a zval, with a borrowed +1 for the call. + * The context is defined by external code and reached through + * context->object_offset. */ +static void php_async_context_arg(zend_async_context_t *context, zval *out) +{ + zend_object *object = ZEND_ASYNC_CONTEXT_OBJECT(context); + + if (object != NULL) { + ZVAL_OBJ(out, object); + GC_ADDREF(object); + } else { + ZVAL_NULL(out); + } +} + +static bool php_async_thunk_context_find( + zend_async_context_t *context, zval *key, zval *result, bool include_parent) +{ + zval args[3], retval; + php_async_context_arg(context, &args[0]); + ZVAL_COPY(&args[1], key); + ZVAL_BOOL(&args[2], include_parent); + + const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_CONTEXT_FIND), 3, args, &retval); + + if (result != NULL) { + if (ok) { + ZVAL_COPY(result, &retval); + } else { + ZVAL_NULL(result); + } + } + + const bool found = ok && Z_TYPE(retval) != IS_NULL; + + zval_ptr_dtor(&args[0]); + zval_ptr_dtor(&args[1]); + zval_ptr_dtor(&retval); + return found; +} + +static bool php_async_thunk_context_set(zend_async_context_t *context, zval *key, zval *value) +{ + zval args[3]; + php_async_context_arg(context, &args[0]); + ZVAL_COPY(&args[1], key); + ZVAL_COPY(&args[2], value); + + const bool result = php_async_hook_call_bool(PHP_ASYNC_HOOK_CONTEXT_SET, 3, args); + + zval_ptr_dtor(&args[0]); + zval_ptr_dtor(&args[1]); + zval_ptr_dtor(&args[2]); + return result; +} + +static bool php_async_thunk_context_unset(zend_async_context_t *context, zval *key) +{ + zval args[2]; + php_async_context_arg(context, &args[0]); + ZVAL_COPY(&args[1], key); + + const bool result = php_async_hook_call_bool(PHP_ASYNC_HOOK_CONTEXT_UNSET, 2, args); + + zval_ptr_dtor(&args[0]); + zval_ptr_dtor(&args[1]); + return result; +} + +///////////////////////////////////////////////////////////////////// +/// GC destructors thunk +///////////////////////////////////////////////////////////////////// + +/* The engine's destructor executor as a PHP function. Deliberately NOT + * registered in any function table: the only way to reach it is the + * Closure the hook receives - application code cannot call it. */ +static ZEND_NAMED_FUNCTION(php_async_run_gc_destructors) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + RETURN_BOOL(zend_gc_run_pending_destructors()); +} + +static zend_internal_function php_async_gc_run_function; + +/* The hook receives a Closure over the engine's destructor executor. + * `run` is ignored at the PHP level: the Closure is the executor. */ +static bool php_async_thunk_gc_destructors(zend_async_gc_run_dtors_fn run) +{ + (void) run; + + zval closure; + zend_create_closure(&closure, (zend_function *) &php_async_gc_run_function, NULL, NULL, NULL); + + const bool result = php_async_hook_call_bool(PHP_ASYNC_HOOK_GC_DESTRUCTORS, 1, &closure); + + zval_ptr_dtor(&closure); + return result; +} + +///////////////////////////////////////////////////////////////////// +/// Registration +///////////////////////////////////////////////////////////////////// + +static void php_async_handlers_reset(void) +{ + for (php_async_hook_id id = 0; id < PHP_ASYNC_HOOK_COUNT; id++) { + php_async_hook_release(PHP_ASYNC_HOOK(id)); + } + + if (PHP_ASYNC_HANDLERS.module != NULL) { + zend_string_release(PHP_ASYNC_HANDLERS.module); + PHP_ASYNC_HANDLERS.module = NULL; + } + + PHP_ASYNC_HANDLERS.active = false; +} + +/* Fill `api` with the thunk pointers for the hooks that were provided. */ +static void php_async_build_api(zend_async_scheduler_api_t *api) +{ + memset(api, 0, sizeof(*api)); + api->version = ZEND_ASYNC_API_VERSION_NUMBER; + api->size = sizeof(*api); + + if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_LAUNCH)->set) { + api->launch = php_async_thunk_launch; + } + + if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_SHUTDOWN)->set) { + api->shutdown = php_async_thunk_shutdown; + } + + if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_INTERCEPT_FIBER)->set) { + api->intercept_fiber = php_async_thunk_intercept_fiber; + } + + if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_ENQUEUE)->set) { + api->enqueue_coroutine = php_async_thunk_enqueue; + } + + if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_SUSPEND)->set) { + api->suspend = php_async_thunk_suspend; + } + + if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_RESUME)->set) { + api->resume = php_async_thunk_resume; + } + + if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_CANCEL)->set) { + api->cancel = php_async_thunk_cancel; + } + + if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_CONTEXT_FIND)->set) { + api->context_find = php_async_thunk_context_find; + } + + if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_CONTEXT_SET)->set) { + api->context_set = php_async_thunk_context_set; + } + + if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_CONTEXT_UNSET)->set) { + api->context_unset = php_async_thunk_context_unset; + } + + if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_GC_DESTRUCTORS)->set) { + api->gc_destructors = php_async_thunk_gc_destructors; + } + + /* get_context / get_internal_context are not bridged: the slot must + * return a zend_async_context_t*, which requires the context to be + * embedded in the object — a C-extension concern, not pure PHP. */ +} + +ZEND_METHOD(Async_SchedulerHook, register) +{ + zend_string *module; + HashTable *hooks; + + ZEND_PARSE_PARAMETERS_START(2, 2) + Z_PARAM_STR(module) + Z_PARAM_ARRAY_HT(hooks) + ZEND_PARSE_PARAMETERS_END(); + + /* A scheduler is registered once per process — by a C extension or by + * PHP, whichever comes first. */ + if (zend_async_is_enabled()) { + zend_throw_error(NULL, "A scheduler is already registered"); + RETURN_THROWS(); + } + + for (php_async_hook_id id = 0; id < PHP_ASYNC_HOOK_COUNT; id++) { + if (!php_async_hook_take(hooks, id)) { + php_async_handlers_reset(); + RETURN_THROWS(); + } + } + + PHP_ASYNC_HANDLERS.module = zend_string_copy(module); + PHP_ASYNC_HANDLERS.active = true; + + zend_async_scheduler_api_t api; + php_async_build_api(&api); + + if (!zend_async_scheduler_register(ZSTR_VAL(module), &api)) { + php_async_handlers_reset(); + RETURN_FALSE; + } + + /* The engine's own launch point runs before userland code; a PHP + * scheduler therefore launches immediately at registration. */ + ZEND_ASYNC_INITIALIZE; + + if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_LAUNCH)->set) { + ZEND_ASYNC_SCHEDULER_LAUNCH(); + } + + ZEND_ASYNC_ACTIVATE; + + RETURN_TRUE; +} + +ZEND_METHOD(Async_SchedulerHook, getModule) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + const char *module = zend_async_get_scheduler_module(); + + if (module == NULL) { + RETURN_NULL(); + } + + RETURN_STRING(module); +} + +ZEND_METHOD(Async_SchedulerHook, defer) +{ + zval *task; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_ZVAL(task) + ZEND_PARSE_PARAMETERS_END(); + + /* The queue is the provider's: forward the callable to its DEFER hook. */ + if (!PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_DEFER)->set) { + zend_throw_error(NULL, "The registered scheduler provides no defer hook"); + RETURN_THROWS(); + } + + zval arg; + ZVAL_COPY(&arg, task); + php_async_hook_call_bool(PHP_ASYNC_HOOK_DEFER, 1, &arg); + zval_ptr_dtor(&arg); + + if (UNEXPECTED(EG(exception))) { + RETURN_THROWS(); + } +} + +void zend_register_scheduler_hook(void) +{ + register_class_Async_SchedulerHook(); + + php_async_gc_run_function.type = ZEND_INTERNAL_FUNCTION; + php_async_gc_run_function.function_name = + zend_string_init_interned(ZEND_STRL("Async\\SchedulerHook::gcDestructorsRun"), true); + php_async_gc_run_function.handler = php_async_run_gc_destructors; +} + +void zend_scheduler_hook_request_shutdown(void) +{ + if (PHP_ASYNC_HANDLERS.active) { + php_async_handlers_reset(); + + /* The hooks died with this request; free the registration so the + * next request in this process can register a scheduler again. */ + zend_async_scheduler_unregister(); + } +} diff --git a/Zend/zend_scheduler_hook.h b/Zend/zend_scheduler_hook.h new file mode 100644 index 000000000000..c9218db23950 --- /dev/null +++ b/Zend/zend_scheduler_hook.h @@ -0,0 +1,31 @@ +/* + +----------------------------------------------------------------------+ + | Copyright © The PHP Group and Contributors. | + +----------------------------------------------------------------------+ + | This source file is subject to the Modified BSD License that is | + | bundled with this package in the file LICENSE, and is available | + | through the World Wide Web at . | + | | + | SPDX-License-Identifier: BSD-3-Clause | + +----------------------------------------------------------------------+ + | Authors: Edmond | + +----------------------------------------------------------------------+ +*/ +#ifndef ZEND_SCHEDULER_HOOK_H +#define ZEND_SCHEDULER_HOOK_H + +#include "zend.h" + +BEGIN_EXTERN_C() + +/* Registers the async_scheduler_register() userland function. Called once + * from the engine startup. */ +void zend_register_scheduler_hook(void); + +/* Releases the PHP scheduler handlers held for the current request. Called + * from request shutdown; a no-op when no PHP scheduler was registered. */ +void zend_scheduler_hook_request_shutdown(void); + +END_EXTERN_C() + +#endif /* ZEND_SCHEDULER_HOOK_H */ diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php new file mode 100644 index 000000000000..6c73eddf88d3 --- /dev/null +++ b/Zend/zend_scheduler_hook.stub.php @@ -0,0 +1,49 @@ + #include "zend.h" #include "zend_compile.h" +#include "zend_async_API.h" #include "zend_exceptions.h" #include "zend_vm.h" #include "zend_generators.h" @@ -857,7 +858,11 @@ PHPDBG_COMMAND(run) /* {{{ */ zend_try { PHPDBG_G(flags) ^= PHPDBG_IS_INTERACTIVE; PHPDBG_G(flags) |= PHPDBG_IS_RUNNING; + if (ZEND_ASYNC_IS_READY) { + ZEND_ASYNC_SCHEDULER_LAUNCH(); + } zend_execute(PHPDBG_G(ops), &PHPDBG_G(retval)); + ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(false); PHPDBG_G(flags) ^= PHPDBG_IS_INTERACTIVE; } zend_catch { PHPDBG_G(in_execution) = 0; diff --git a/win32/build/config.w32 b/win32/build/config.w32 index a0f26033306f..81bc6c31bf7e 100644 --- a/win32/build/config.w32 +++ b/win32/build/config.w32 @@ -241,7 +241,7 @@ ADD_SOURCES("Zend", "zend_language_parser.c zend_language_scanner.c \ zend_float.c zend_string.c zend_generators.c zend_virtual_cwd.c zend_ast.c \ zend_inheritance.c zend_smart_str.c zend_cpuinfo.c zend_observer.c zend_system_id.c \ zend_enum.c zend_fibers.c zend_atomic.c zend_hrtime.c zend_frameless_function.c zend_property_hooks.c \ - zend_lazy_objects.c zend_autoload.c"); + zend_lazy_objects.c zend_autoload.c zend_async_API.c zend_scheduler_hook.c"); ADD_SOURCES("Zend\\Optimizer", "zend_optimizer.c pass1.c pass3.c optimize_func_calls.c block_pass.c optimize_temp_vars_5.c nop_removal.c compact_literals.c zend_cfg.c zend_dfg.c dfa_pass.c zend_ssa.c zend_inference.c zend_func_info.c zend_call_graph.c zend_dump.c escape_analysis.c compact_vars.c dce.c sccp.c scdf.c"); var PHP_ASSEMBLER = PATH_PROG({ From 7a8f1548a7ded4dd5391ab7b5ea534abb974ab32 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:06:18 +0000 Subject: [PATCH 02/23] async: remove gc_destructors PHP-land hook, keep C-only slot --- Zend/zend_async_API.h | 1 - Zend/zend_scheduler_hook.c | 45 ++++-------------------------- Zend/zend_scheduler_hook.stub.php | 1 - Zend/zend_scheduler_hook_arginfo.h | 9 +----- 4 files changed, 6 insertions(+), 50 deletions(-) diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h index 2188cef31f82..1f9e4a0fbce9 100644 --- a/Zend/zend_async_API.h +++ b/Zend/zend_async_API.h @@ -508,7 +508,6 @@ typedef enum { PHP_ASYNC_HOOK_CONTEXT_FIND, PHP_ASYNC_HOOK_CONTEXT_SET, PHP_ASYNC_HOOK_CONTEXT_UNSET, - PHP_ASYNC_HOOK_GC_DESTRUCTORS, PHP_ASYNC_HOOK_DEFER, PHP_ASYNC_HOOK_COUNT } php_async_hook_id; diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c index 0008b4363e89..2ef07776f088 100644 --- a/Zend/zend_scheduler_hook.c +++ b/Zend/zend_scheduler_hook.c @@ -51,7 +51,6 @@ static const struct { [PHP_ASYNC_HOOK_CONTEXT_FIND] = { ZEND_STRL("context_find") }, [PHP_ASYNC_HOOK_CONTEXT_SET] = { ZEND_STRL("context_set") }, [PHP_ASYNC_HOOK_CONTEXT_UNSET] = { ZEND_STRL("context_unset") }, - [PHP_ASYNC_HOOK_GC_DESTRUCTORS] = { ZEND_STRL("gc_destructors") }, [PHP_ASYNC_HOOK_DEFER] = { ZEND_STRL("defer") }, }; @@ -374,37 +373,6 @@ static bool php_async_thunk_context_unset(zend_async_context_t *context, zval *k return result; } -///////////////////////////////////////////////////////////////////// -/// GC destructors thunk -///////////////////////////////////////////////////////////////////// - -/* The engine's destructor executor as a PHP function. Deliberately NOT - * registered in any function table: the only way to reach it is the - * Closure the hook receives - application code cannot call it. */ -static ZEND_NAMED_FUNCTION(php_async_run_gc_destructors) -{ - ZEND_PARSE_PARAMETERS_NONE(); - - RETURN_BOOL(zend_gc_run_pending_destructors()); -} - -static zend_internal_function php_async_gc_run_function; - -/* The hook receives a Closure over the engine's destructor executor. - * `run` is ignored at the PHP level: the Closure is the executor. */ -static bool php_async_thunk_gc_destructors(zend_async_gc_run_dtors_fn run) -{ - (void) run; - - zval closure; - zend_create_closure(&closure, (zend_function *) &php_async_gc_run_function, NULL, NULL, NULL); - - const bool result = php_async_hook_call_bool(PHP_ASYNC_HOOK_GC_DESTRUCTORS, 1, &closure); - - zval_ptr_dtor(&closure); - return result; -} - ///////////////////////////////////////////////////////////////////// /// Registration ///////////////////////////////////////////////////////////////////// @@ -470,9 +438,11 @@ static void php_async_build_api(zend_async_scheduler_api_t *api) api->context_unset = php_async_thunk_context_unset; } - if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_GC_DESTRUCTORS)->set) { - api->gc_destructors = php_async_thunk_gc_destructors; - } + /* gc_destructors is intentionally not bridged: the destructor phase runs at + * the very latest stage of the request (teardown of globals and the object + * store), where userland is already being dismantled and a PHP-registered + * scheduler cannot be safely re-entered. The slot stays a C-extension-only + * responsibility; userland __destruct always takes the classic path. */ /* get_context / get_internal_context are not bridged: the slot must * return a zend_async_context_t*, which requires the context to be @@ -567,11 +537,6 @@ ZEND_METHOD(Async_SchedulerHook, defer) void zend_register_scheduler_hook(void) { register_class_Async_SchedulerHook(); - - php_async_gc_run_function.type = ZEND_INTERNAL_FUNCTION; - php_async_gc_run_function.function_name = - zend_string_init_interned(ZEND_STRL("Async\\SchedulerHook::gcDestructorsRun"), true); - php_async_gc_run_function.handler = php_async_run_gc_destructors; } void zend_scheduler_hook_request_shutdown(void) diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php index 6c73eddf88d3..27d5e9bdace9 100644 --- a/Zend/zend_scheduler_hook.stub.php +++ b/Zend/zend_scheduler_hook.stub.php @@ -23,7 +23,6 @@ final class SchedulerHook public const string CONTEXT_FIND = 'context_find'; public const string CONTEXT_SET = 'context_set'; public const string CONTEXT_UNSET = 'context_unset'; - public const string GC_DESTRUCTORS = 'gc_destructors'; public const string DEFER = 'defer'; /** diff --git a/Zend/zend_scheduler_hook_arginfo.h b/Zend/zend_scheduler_hook_arginfo.h index 45da817c6d73..06f0697e5651 100644 --- a/Zend/zend_scheduler_hook_arginfo.h +++ b/Zend/zend_scheduler_hook_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit zend_scheduler_hook.stub.php instead. - * Stub hash: 06651f905db71f840fb874392eb3edf6709a87c7 */ + * Stub hash: 7f669cd082ae394d4b4e57099d6a78a2745e8daf */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_SchedulerHook_register, 0, 2, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, module, IS_STRING, 0) @@ -101,13 +101,6 @@ static zend_class_entry *register_class_Async_SchedulerHook(void) zend_declare_typed_class_constant(class_entry, const_CONTEXT_UNSET_name, &const_CONTEXT_UNSET_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); zend_string_release_ex(const_CONTEXT_UNSET_name, true); - zval const_GC_DESTRUCTORS_value; - zend_string *const_GC_DESTRUCTORS_value_str = zend_string_init("gc_destructors", strlen("gc_destructors"), 1); - ZVAL_STR(&const_GC_DESTRUCTORS_value, const_GC_DESTRUCTORS_value_str); - zend_string *const_GC_DESTRUCTORS_name = zend_string_init_interned("GC_DESTRUCTORS", sizeof("GC_DESTRUCTORS") - 1, true); - zend_declare_typed_class_constant(class_entry, const_GC_DESTRUCTORS_name, &const_GC_DESTRUCTORS_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(const_GC_DESTRUCTORS_name, true); - zval const_DEFER_value; zend_string *const_DEFER_value_str = zend_string_init("defer", strlen("defer"), 1); ZVAL_STR(&const_DEFER_value, const_DEFER_value_str); From 7aaaa3306b09452abb12cb3a8069397be7197b58 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:45:25 +0000 Subject: [PATCH 03/23] async: drop separate Async API versioning; rely on ZEND_MODULE_API_NO --- Zend/zend_async_API.c | 15 --------------- Zend/zend_async_API.h | 16 ++++------------ Zend/zend_scheduler_hook.c | 1 - 3 files changed, 4 insertions(+), 28 deletions(-) diff --git a/Zend/zend_async_API.c b/Zend/zend_async_API.c index 79949e05da91..56e53e548da3 100644 --- a/Zend/zend_async_API.c +++ b/Zend/zend_async_API.c @@ -308,12 +308,6 @@ ZEND_API bool zend_async_scheduler_register( return false; } - if ((api->version >> 16) != ZEND_ASYNC_API_VERSION_MAJOR) { - zend_error(E_CORE_WARNING, - "Module %s was compiled against an incompatible Async API version", module); - return false; - } - /* A scheduler is registered once per process. */ if (scheduler_module_name != NULL) { zend_error(E_CORE_WARNING, @@ -400,15 +394,6 @@ ZEND_API const char *zend_async_get_scheduler_module(void) return scheduler_module_name; } -ZEND_API const char *zend_async_get_api_version(void) -{ - return ZEND_ASYNC_API; -} - -ZEND_API int zend_async_get_api_version_number(void) -{ - return ZEND_ASYNC_API_VERSION_NUMBER; -} ZEND_API void zend_async_scheduler_unregister(void) { diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h index 1f9e4a0fbce9..cce9584ceaba 100644 --- a/Zend/zend_async_API.h +++ b/Zend/zend_async_API.h @@ -26,14 +26,6 @@ * provider's business; the core only offers the awaiting_info hook so * that the wait state can be inspected for diagnostics. */ -#define ZEND_ASYNC_API "AsyncCore ABI v0.1.0" -#define ZEND_ASYNC_API_VERSION_MAJOR 0 -#define ZEND_ASYNC_API_VERSION_MINOR 1 -#define ZEND_ASYNC_API_VERSION_PATCH 0 - -#define ZEND_ASYNC_API_VERSION_NUMBER \ - ((ZEND_ASYNC_API_VERSION_MAJOR << 16) | (ZEND_ASYNC_API_VERSION_MINOR << 8) \ - | (ZEND_ASYNC_API_VERSION_PATCH)) typedef struct _zend_coroutine_s zend_coroutine_t; typedef struct _zend_async_context_s zend_async_context_t; @@ -357,12 +349,14 @@ typedef bool (*zend_async_gc_destructors_t)(zend_async_gc_run_dtors_fn run); typedef zend_coroutine_t *(*zend_async_intercept_fiber_t)(zend_fiber *fiber); /** - * Versioned scheduler API bundle. A provider fills the struct and calls + * Scheduler API bundle. A provider fills the struct and calls * zend_async_scheduler_register(). New slots are appended at the end only; * `size` lets the core detect how much of the struct the provider knows. + * ABI compatibility rides on the standard PHP module API (ZEND_MODULE_API_NO), + * enforced when the provider extension is loaded — there is no separate + * Async API version. */ typedef struct _zend_async_scheduler_api_s { - uint32_t version; /* ZEND_ASYNC_API_VERSION_NUMBER the provider was built against */ size_t size; /* sizeof(zend_async_scheduler_api_t) at provider build time */ zend_async_new_coroutine_t new_coroutine; @@ -427,8 +421,6 @@ ZEND_API void zend_async_scheduler_unregister(void); ZEND_API bool zend_async_is_enabled(void); /* The module name of the registered scheduler, or NULL when none. */ ZEND_API const char *zend_async_get_scheduler_module(void); -ZEND_API const char *zend_async_get_api_version(void); -ZEND_API int zend_async_get_api_version_number(void); END_EXTERN_C() diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c index 2ef07776f088..8214e90b638e 100644 --- a/Zend/zend_scheduler_hook.c +++ b/Zend/zend_scheduler_hook.c @@ -395,7 +395,6 @@ static void php_async_handlers_reset(void) static void php_async_build_api(zend_async_scheduler_api_t *api) { memset(api, 0, sizeof(*api)); - api->version = ZEND_ASYNC_API_VERSION_NUMBER; api->size = sizeof(*api); if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_LAUNCH)->set) { From 796800e0056c99591faadf62fed0ed191d33d039 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:47:18 +0000 Subject: [PATCH 04/23] tests: drop gc_destructors_hook.phpt (hook removed from PHP-land) --- Zend/tests/async/gc_destructors_hook.phpt | 86 ----------------------- 1 file changed, 86 deletions(-) delete mode 100644 Zend/tests/async/gc_destructors_hook.phpt diff --git a/Zend/tests/async/gc_destructors_hook.phpt b/Zend/tests/async/gc_destructors_hook.phpt deleted file mode 100644 index 0a265a49c857..000000000000 --- a/Zend/tests/async/gc_destructors_hook.phpt +++ /dev/null @@ -1,86 +0,0 @@ ---TEST-- -GC destructor phase: the hook brackets the engine run and awaits spawned work ---FILE-- - fn (Fiber $fiber): object - => new class($fiber) { - public function __construct(public readonly Fiber $fiber) {} - }, - Async\SchedulerHook::ENQUEUE => function (object $coroutine) use ($queue): bool { - $queue->enqueue($coroutine); - return true; - }, - Async\SchedulerHook::SUSPEND => function (bool $fromMain, bool $isBailout) use ($queue): bool { - // Defer: the queue is drained by the GC hook (and after main). - if (!$fromMain) { - return true; - } - - while (!$queue->isEmpty()) { - $fiber = $queue->dequeue()->fiber; - $fiber->isStarted() ? $fiber->resume() : $fiber->start(); - } - - return true; - }, - Async\SchedulerHook::GC_DESTRUCTORS => function (callable $run) use ($queue): bool { - echo "hook: before\n"; - - $run(); // the engine calls every pending destructor - - // Await everything the destructors spawned (the "scope" logic: - // membership is the scheduler's own bookkeeping). - while (!$queue->isEmpty()) { - $fiber = $queue->dequeue()->fiber; - $fiber->isStarted() ? $fiber->resume() : $fiber->start(); - } - - echo "hook: after\n"; - return true; - }, -]); - -class Node -{ - public ?Node $other = null; - - public function __destruct() - { - echo "destructor\n"; - - // The destructor spawns concurrent work; the hook must wait for it. - $fiber = new Fiber(function (): void { - echo "spawned by destructor\n"; - }); - $fiber->start(); - } -} - -// A garbage cycle: collected only by the GC. -$a = new Node(); -$b = new Node(); -$a->other = $b; -$b->other = $a; -unset($a, $b); - -// The GC reruns after the destructor phase: the finished fiber/coroutine -// pairs collected there carry internal destructors, so the hook brackets -// a second (empty) phase. -$collected = gc_collect_cycles(); -var_dump($collected > 0); -echo "end\n"; -?> ---EXPECT-- -hook: before -destructor -destructor -spawned by destructor -spawned by destructor -hook: after -hook: before -hook: after -bool(true) -end From 0b1ba73362744c8d2e841940ea1f1c6aff3d8cfb Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:33:44 +0000 Subject: [PATCH 05/23] async: interface-based scheduler registration (Async\Scheduler + AbstractScheduler) --- Zend/zend_async_API.h | 3 + Zend/zend_scheduler_hook.c | 146 +++++++++++++------- Zend/zend_scheduler_hook.stub.php | 104 ++++++++++---- Zend/zend_scheduler_hook_arginfo.h | 209 ++++++++++++++++++----------- 4 files changed, 311 insertions(+), 151 deletions(-) diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h index cce9584ceaba..ac634926ca57 100644 --- a/Zend/zend_async_API.h +++ b/Zend/zend_async_API.h @@ -514,6 +514,9 @@ typedef struct { typedef struct { bool active; zend_string *module; + /* The registered Async\Scheduler instance; one ref held for the request. + * The bound hook methods borrow this object. */ + zend_object *scheduler; php_async_hook_t hooks[PHP_ASYNC_HOOK_COUNT]; } php_async_handlers_t; diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c index 8214e90b638e..ca457f2ce3d5 100644 --- a/Zend/zend_scheduler_hook.c +++ b/Zend/zend_scheduler_hook.c @@ -35,79 +35,67 @@ #include "zend_exceptions.h" #include "zend_closures.h" -/* The hook names, indexed by php_async_hook_id: used only to map the - * incoming array keys (the Async\SchedulerHook class constants). */ +/* The Async\Scheduler method name for each hook (lower-cased, as stored in the + * class function table), indexed by php_async_hook_id. */ static const struct { const char *name; size_t len; -} php_async_hook_names[PHP_ASYNC_HOOK_COUNT] = { +} php_async_hook_methods[PHP_ASYNC_HOOK_COUNT] = { [PHP_ASYNC_HOOK_LAUNCH] = { ZEND_STRL("launch") }, [PHP_ASYNC_HOOK_SHUTDOWN] = { ZEND_STRL("shutdown") }, - [PHP_ASYNC_HOOK_INTERCEPT_FIBER] = { ZEND_STRL("intercept_fiber") }, - [PHP_ASYNC_HOOK_ENQUEUE] = { ZEND_STRL("enqueue_coroutine") }, + [PHP_ASYNC_HOOK_INTERCEPT_FIBER] = { ZEND_STRL("interceptfiber") }, + [PHP_ASYNC_HOOK_ENQUEUE] = { ZEND_STRL("enqueue") }, [PHP_ASYNC_HOOK_SUSPEND] = { ZEND_STRL("suspend") }, [PHP_ASYNC_HOOK_RESUME] = { ZEND_STRL("resume") }, [PHP_ASYNC_HOOK_CANCEL] = { ZEND_STRL("cancel") }, - [PHP_ASYNC_HOOK_CONTEXT_FIND] = { ZEND_STRL("context_find") }, - [PHP_ASYNC_HOOK_CONTEXT_SET] = { ZEND_STRL("context_set") }, - [PHP_ASYNC_HOOK_CONTEXT_UNSET] = { ZEND_STRL("context_unset") }, + [PHP_ASYNC_HOOK_CONTEXT_FIND] = { ZEND_STRL("contextfind") }, + [PHP_ASYNC_HOOK_CONTEXT_SET] = { ZEND_STRL("contextset") }, + [PHP_ASYNC_HOOK_CONTEXT_UNSET] = { ZEND_STRL("contextunset") }, [PHP_ASYNC_HOOK_DEFER] = { ZEND_STRL("defer") }, }; +/* Class entries, filled by zend_register_scheduler_hook(). */ +static zend_class_entry *async_ce_Scheduler; +static zend_class_entry *async_ce_AbstractScheduler; + /* The storage lives in the per-thread async globals: correct under ZTS, * request-local by construction. */ #define PHP_ASYNC_HANDLERS (ZEND_ASYNC_G(scheduler_hooks)) #define PHP_ASYNC_HOOK(id) (&PHP_ASYNC_HANDLERS.hooks[(id)]) -/* Read the callable for `id` from the incoming array into storage. */ -static bool php_async_hook_take(HashTable *array, php_async_hook_id id) +/* Bind hook `id` to the scheduler's method of the same name. A hook is + * considered "provided" only when the method is overridden below + * Async\AbstractScheduler; a method that resolves to AbstractScheduler is its + * inert default, so the engine keeps its own behaviour for that hook. */ +static void php_async_hook_bind(zend_object *scheduler, php_async_hook_id id) { php_async_hook_t *hook = PHP_ASYNC_HOOK(id); - zval *entry = zend_hash_str_find(array, php_async_hook_names[id].name, php_async_hook_names[id].len); + zend_function *fn = zend_hash_str_find_ptr(&scheduler->ce->function_table, + php_async_hook_methods[id].name, php_async_hook_methods[id].len); - if (entry == NULL) { + if (fn == NULL || fn->common.scope == async_ce_AbstractScheduler) { hook->set = false; - return true; + return; } - char *error = NULL; - - if (zend_fcall_info_init(entry, 0, &hook->fci, &hook->fcc, NULL, &error) != SUCCESS) { - zend_type_error("Async scheduler hook \"%s\" must be a valid callable: %s", - php_async_hook_names[id].name, error != NULL ? error : "unknown error"); - if (error != NULL) { - efree(error); - } - - return false; - } + memset(&hook->fci, 0, sizeof(hook->fci)); + memset(&hook->fcc, 0, sizeof(hook->fcc)); - if (error != NULL) { - efree(error); - } + hook->fci.size = sizeof(hook->fci); + hook->fci.object = scheduler; + ZVAL_UNDEF(&hook->fci.function_name); - /* Keep the callable alive for the whole request. */ - Z_TRY_ADDREF(hook->fci.function_name); - if (hook->fci.object != NULL) { - GC_ADDREF(hook->fci.object); - } + hook->fcc.function_handler = fn; + hook->fcc.object = scheduler; + hook->fcc.called_scope = scheduler->ce; hook->set = true; - return true; } static void php_async_hook_release(php_async_hook_t *hook) { - if (!hook->set) { - return; - } - - zval_ptr_dtor(&hook->fci.function_name); - - if (hook->fci.object != NULL) { - OBJ_RELEASE(hook->fci.object); - } - + /* The scheduler object (and thus the bound method) is owned once by the + * handlers container; nothing per-hook to release. */ hook->set = false; } @@ -383,6 +371,11 @@ static void php_async_handlers_reset(void) php_async_hook_release(PHP_ASYNC_HOOK(id)); } + if (PHP_ASYNC_HANDLERS.scheduler != NULL) { + OBJ_RELEASE(PHP_ASYNC_HANDLERS.scheduler); + PHP_ASYNC_HANDLERS.scheduler = NULL; + } + if (PHP_ASYNC_HANDLERS.module != NULL) { zend_string_release(PHP_ASYNC_HANDLERS.module); PHP_ASYNC_HANDLERS.module = NULL; @@ -451,11 +444,11 @@ static void php_async_build_api(zend_async_scheduler_api_t *api) ZEND_METHOD(Async_SchedulerHook, register) { zend_string *module; - HashTable *hooks; + zend_object *scheduler; ZEND_PARSE_PARAMETERS_START(2, 2) Z_PARAM_STR(module) - Z_PARAM_ARRAY_HT(hooks) + Z_PARAM_OBJ_OF_CLASS(scheduler, async_ce_Scheduler) ZEND_PARSE_PARAMETERS_END(); /* A scheduler is registered once per process — by a C extension or by @@ -466,12 +459,11 @@ ZEND_METHOD(Async_SchedulerHook, register) } for (php_async_hook_id id = 0; id < PHP_ASYNC_HOOK_COUNT; id++) { - if (!php_async_hook_take(hooks, id)) { - php_async_handlers_reset(); - RETURN_THROWS(); - } + php_async_hook_bind(scheduler, id); } + PHP_ASYNC_HANDLERS.scheduler = scheduler; + GC_ADDREF(scheduler); PHP_ASYNC_HANDLERS.module = zend_string_copy(module); PHP_ASYNC_HANDLERS.active = true; @@ -533,8 +525,64 @@ ZEND_METHOD(Async_SchedulerHook, defer) } } +///////////////////////////////////////////////////////////////////// +/// Async\AbstractScheduler default hooks +///////////////////////////////////////////////////////////////////// + +/* These bodies are the inert defaults: the bridge detects them by scope and + * keeps the engine's own behaviour instead of calling them. They only run when + * userland invokes them directly; the argument count/types are already checked + * against the arginfo at the call boundary, so the bodies just return. */ + +ZEND_METHOD(Async_AbstractScheduler, interceptFiber) +{ + RETURN_NULL(); +} + +ZEND_METHOD(Async_AbstractScheduler, resume) +{ + RETURN_FALSE; +} + +ZEND_METHOD(Async_AbstractScheduler, cancel) +{ + RETURN_FALSE; +} + +ZEND_METHOD(Async_AbstractScheduler, defer) +{ + RETURN_FALSE; +} + +ZEND_METHOD(Async_AbstractScheduler, launch) +{ + RETURN_TRUE; +} + +ZEND_METHOD(Async_AbstractScheduler, shutdown) +{ + RETURN_TRUE; +} + +ZEND_METHOD(Async_AbstractScheduler, contextFind) +{ + RETURN_NULL(); +} + +ZEND_METHOD(Async_AbstractScheduler, contextSet) +{ + RETURN_FALSE; +} + +ZEND_METHOD(Async_AbstractScheduler, contextUnset) +{ + RETURN_FALSE; +} + void zend_register_scheduler_hook(void) { + async_ce_Scheduler = register_class_Async_Scheduler(); + async_ce_AbstractScheduler = register_class_Async_AbstractScheduler(async_ce_Scheduler); register_class_Async_SchedulerHook(); } diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php index 27d5e9bdace9..f41e9f4c4a76 100644 --- a/Zend/zend_scheduler_hook.stub.php +++ b/Zend/zend_scheduler_hook.stub.php @@ -4,45 +4,103 @@ namespace Async; +/** + * A scheduler plugs into the concurrent mode by implementing this interface + * and handing an instance to Async\SchedulerHook::register(). Each method is + * a scheduling *policy* hook; the engine performs the actual context switches. + * + * Extend Async\AbstractScheduler to implement only the hooks you need. + */ +interface Scheduler +{ + /** A coroutine became runnable (freshly created, or its wait ended). */ + public function enqueue(object $coroutine): bool; + + /** The current flow yields; pick who runs next and switch to them. */ + public function suspend(bool $fromMain, bool $isBailout): bool; + + /** A fiber is starting: return a coroutine object to adopt it, or null. */ + public function interceptFiber(\Fiber $fiber): ?object; + + /** Wake a suspended coroutine (deferred: enqueue it to run later). */ + public function resume(object $coroutine, ?\Throwable $error = null): bool; + + /** Cancel a coroutine. */ + public function cancel(object $coroutine, ?\Throwable $error = null): bool; + + /** Store a one-shot microtask on the scheduler's queue. */ + public function defer(callable $task): bool; + + /** Invoked once when the scheduler starts. */ + public function launch(): bool; + + /** A graceful shutdown has been requested. */ + public function shutdown(): bool; + + /** Look up a value in a coroutine context. */ + public function contextFind(object $context, mixed $key, bool $includeParent): mixed; + + /** Set a value in a coroutine context. */ + public function contextSet(object $context, mixed $key, mixed $value): bool; + + /** Remove a value from a coroutine context. */ + public function contextUnset(object $context, mixed $key): bool; +} + +/** + * Convenience base: only enqueue() and suspend() are required; every other + * hook has a default, so the engine keeps its own behaviour for the ones you + * do not override. + */ +abstract class AbstractScheduler implements Scheduler +{ + abstract public function enqueue(object $coroutine): bool; + + abstract public function suspend(bool $fromMain, bool $isBailout): bool; + + public function interceptFiber(\Fiber $fiber): ?object {} + + public function resume(object $coroutine, ?\Throwable $error = null): bool {} + + public function cancel(object $coroutine, ?\Throwable $error = null): bool {} + + public function defer(callable $task): bool {} + + public function launch(): bool {} + + public function shutdown(): bool {} + + public function contextFind(object $context, mixed $key, bool $includeParent): mixed {} + + public function contextSet(object $context, mixed $key, mixed $value): bool {} + + public function contextUnset(object $context, mixed $key): bool {} +} + /** * Activation point for the concurrent mode. * - * A scheduler is registered by handing register() a map of hook name - * (the class constants below) to callable. There is exactly one scheduler - * per process, so the class is used only through its static methods. + * A scheduler is registered by handing register() an object that implements + * Async\Scheduler. There is exactly one scheduler per process, so the class + * is used only through its static methods. */ final class SchedulerHook { - public const string LAUNCH = 'launch'; - public const string SHUTDOWN = 'shutdown'; - public const string INTERCEPT_FIBER = 'intercept_fiber'; - public const string ENQUEUE = 'enqueue_coroutine'; - public const string SUSPEND = 'suspend'; - public const string RESUME = 'resume'; - public const string CANCEL = 'cancel'; - public const string CONTEXT_FIND = 'context_find'; - public const string CONTEXT_SET = 'context_set'; - public const string CONTEXT_UNSET = 'context_unset'; - public const string DEFER = 'defer'; - /** * Registers a scheduler and activates the concurrent mode. * - * $hooks maps a hook constant to a callable; omitted hooks keep their - * defaults. A scheduler is registered once per process: calling this - * when a scheduler is already registered (by a C extension or by an - * earlier PHP call) throws an Error. + * A scheduler is registered once per process: calling this when a + * scheduler is already registered (by a C extension or by an earlier PHP + * call) throws an Error. */ - public static function register(string $module, array $hooks): bool {} + public static function register(string $module, Scheduler $scheduler): bool {} /** Returns the module name of the registered scheduler, or null when none. */ public static function getModule(): ?string {} /** * Queues a callable on the scheduler's microtask queue (one-shot, - * runs on the next tick). Forwards to the DEFER hook; the queue and - * its draining belong to the scheduler. + * runs on the next tick). Forwards to the scheduler's defer() hook. */ public static function defer(callable $task): void {} - } diff --git a/Zend/zend_scheduler_hook_arginfo.h b/Zend/zend_scheduler_hook_arginfo.h index 06f0697e5651..3156a0ab2ab9 100644 --- a/Zend/zend_scheduler_hook_arginfo.h +++ b/Zend/zend_scheduler_hook_arginfo.h @@ -1,9 +1,77 @@ /* This is a generated file, edit zend_scheduler_hook.stub.php instead. - * Stub hash: 7f669cd082ae394d4b4e57099d6a78a2745e8daf */ + * Stub hash: 1c088b1cb35579b4f3863c1bc8d4aa46b28db602 */ + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_enqueue, 0, 1, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_suspend, 0, 2, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, fromMain, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, isBailout, _IS_BOOL, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_interceptFiber, 0, 1, IS_OBJECT, 1) + ZEND_ARG_OBJ_INFO(0, fiber, Fiber, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_resume, 0, 1, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) + ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, error, Throwable, 1, "null") +ZEND_END_ARG_INFO() + +#define arginfo_class_Async_Scheduler_cancel arginfo_class_Async_Scheduler_resume + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_defer, 0, 1, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, task, IS_CALLABLE, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_launch, 0, 0, _IS_BOOL, 0) +ZEND_END_ARG_INFO() + +#define arginfo_class_Async_Scheduler_shutdown arginfo_class_Async_Scheduler_launch + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_contextFind, 0, 3, IS_MIXED, 0) + ZEND_ARG_TYPE_INFO(0, context, IS_OBJECT, 0) + ZEND_ARG_TYPE_INFO(0, key, IS_MIXED, 0) + ZEND_ARG_TYPE_INFO(0, includeParent, _IS_BOOL, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_contextSet, 0, 3, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, context, IS_OBJECT, 0) + ZEND_ARG_TYPE_INFO(0, key, IS_MIXED, 0) + ZEND_ARG_TYPE_INFO(0, value, IS_MIXED, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_contextUnset, 0, 2, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, context, IS_OBJECT, 0) + ZEND_ARG_TYPE_INFO(0, key, IS_MIXED, 0) +ZEND_END_ARG_INFO() + +#define arginfo_class_Async_AbstractScheduler_enqueue arginfo_class_Async_Scheduler_enqueue + +#define arginfo_class_Async_AbstractScheduler_suspend arginfo_class_Async_Scheduler_suspend + +#define arginfo_class_Async_AbstractScheduler_interceptFiber arginfo_class_Async_Scheduler_interceptFiber + +#define arginfo_class_Async_AbstractScheduler_resume arginfo_class_Async_Scheduler_resume + +#define arginfo_class_Async_AbstractScheduler_cancel arginfo_class_Async_Scheduler_resume + +#define arginfo_class_Async_AbstractScheduler_defer arginfo_class_Async_Scheduler_defer + +#define arginfo_class_Async_AbstractScheduler_launch arginfo_class_Async_Scheduler_launch + +#define arginfo_class_Async_AbstractScheduler_shutdown arginfo_class_Async_Scheduler_launch + +#define arginfo_class_Async_AbstractScheduler_contextFind arginfo_class_Async_Scheduler_contextFind + +#define arginfo_class_Async_AbstractScheduler_contextSet arginfo_class_Async_Scheduler_contextSet + +#define arginfo_class_Async_AbstractScheduler_contextUnset arginfo_class_Async_Scheduler_contextUnset ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_SchedulerHook_register, 0, 2, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, module, IS_STRING, 0) - ZEND_ARG_TYPE_INFO(0, hooks, IS_ARRAY, 0) + ZEND_ARG_OBJ_INFO(0, scheduler, Async\\Scheduler, 0) ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_SchedulerHook_getModule, 0, 0, IS_STRING, 1) @@ -13,10 +81,49 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_SchedulerHook_defer, ZEND_ARG_TYPE_INFO(0, task, IS_CALLABLE, 0) ZEND_END_ARG_INFO() +ZEND_METHOD(Async_AbstractScheduler, interceptFiber); +ZEND_METHOD(Async_AbstractScheduler, resume); +ZEND_METHOD(Async_AbstractScheduler, cancel); +ZEND_METHOD(Async_AbstractScheduler, defer); +ZEND_METHOD(Async_AbstractScheduler, launch); +ZEND_METHOD(Async_AbstractScheduler, shutdown); +ZEND_METHOD(Async_AbstractScheduler, contextFind); +ZEND_METHOD(Async_AbstractScheduler, contextSet); +ZEND_METHOD(Async_AbstractScheduler, contextUnset); ZEND_METHOD(Async_SchedulerHook, register); ZEND_METHOD(Async_SchedulerHook, getModule); ZEND_METHOD(Async_SchedulerHook, defer); +static const zend_function_entry class_Async_Scheduler_methods[] = { + ZEND_RAW_FENTRY("enqueue", NULL, arginfo_class_Async_Scheduler_enqueue, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("suspend", NULL, arginfo_class_Async_Scheduler_suspend, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("interceptFiber", NULL, arginfo_class_Async_Scheduler_interceptFiber, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("resume", NULL, arginfo_class_Async_Scheduler_resume, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("cancel", NULL, arginfo_class_Async_Scheduler_cancel, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("defer", NULL, arginfo_class_Async_Scheduler_defer, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("launch", NULL, arginfo_class_Async_Scheduler_launch, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("shutdown", NULL, arginfo_class_Async_Scheduler_shutdown, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("contextFind", NULL, arginfo_class_Async_Scheduler_contextFind, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("contextSet", NULL, arginfo_class_Async_Scheduler_contextSet, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("contextUnset", NULL, arginfo_class_Async_Scheduler_contextUnset, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_FE_END +}; + +static const zend_function_entry class_Async_AbstractScheduler_methods[] = { + ZEND_RAW_FENTRY("enqueue", NULL, arginfo_class_Async_AbstractScheduler_enqueue, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("suspend", NULL, arginfo_class_Async_AbstractScheduler_suspend, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_ME(Async_AbstractScheduler, interceptFiber, arginfo_class_Async_AbstractScheduler_interceptFiber, ZEND_ACC_PUBLIC) + ZEND_ME(Async_AbstractScheduler, resume, arginfo_class_Async_AbstractScheduler_resume, ZEND_ACC_PUBLIC) + ZEND_ME(Async_AbstractScheduler, cancel, arginfo_class_Async_AbstractScheduler_cancel, ZEND_ACC_PUBLIC) + ZEND_ME(Async_AbstractScheduler, defer, arginfo_class_Async_AbstractScheduler_defer, ZEND_ACC_PUBLIC) + ZEND_ME(Async_AbstractScheduler, launch, arginfo_class_Async_AbstractScheduler_launch, ZEND_ACC_PUBLIC) + ZEND_ME(Async_AbstractScheduler, shutdown, arginfo_class_Async_AbstractScheduler_shutdown, ZEND_ACC_PUBLIC) + ZEND_ME(Async_AbstractScheduler, contextFind, arginfo_class_Async_AbstractScheduler_contextFind, ZEND_ACC_PUBLIC) + ZEND_ME(Async_AbstractScheduler, contextSet, arginfo_class_Async_AbstractScheduler_contextSet, ZEND_ACC_PUBLIC) + ZEND_ME(Async_AbstractScheduler, contextUnset, arginfo_class_Async_AbstractScheduler_contextUnset, ZEND_ACC_PUBLIC) + ZEND_FE_END +}; + static const zend_function_entry class_Async_SchedulerHook_methods[] = { ZEND_ME(Async_SchedulerHook, register, arginfo_class_Async_SchedulerHook_register, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_ME(Async_SchedulerHook, getModule, arginfo_class_Async_SchedulerHook_getModule, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) @@ -24,6 +131,27 @@ static const zend_function_entry class_Async_SchedulerHook_methods[] = { ZEND_FE_END }; +static zend_class_entry *register_class_Async_Scheduler(void) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "Async", "Scheduler", class_Async_Scheduler_methods); + class_entry = zend_register_internal_interface(&ce); + + return class_entry; +} + +static zend_class_entry *register_class_Async_AbstractScheduler(zend_class_entry *class_entry_Async_Scheduler) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "Async", "AbstractScheduler", class_Async_AbstractScheduler_methods); + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_ABSTRACT); + zend_class_implements(class_entry, 1, class_entry_Async_Scheduler); + + return class_entry; +} + static zend_class_entry *register_class_Async_SchedulerHook(void) { zend_class_entry ce, *class_entry; @@ -31,82 +159,5 @@ static zend_class_entry *register_class_Async_SchedulerHook(void) INIT_NS_CLASS_ENTRY(ce, "Async", "SchedulerHook", class_Async_SchedulerHook_methods); class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL); - zval const_LAUNCH_value; - zend_string *const_LAUNCH_value_str = zend_string_init("launch", strlen("launch"), 1); - ZVAL_STR(&const_LAUNCH_value, const_LAUNCH_value_str); - zend_string *const_LAUNCH_name = zend_string_init_interned("LAUNCH", sizeof("LAUNCH") - 1, true); - zend_declare_typed_class_constant(class_entry, const_LAUNCH_name, &const_LAUNCH_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(const_LAUNCH_name, true); - - zval const_SHUTDOWN_value; - zend_string *const_SHUTDOWN_value_str = zend_string_init("shutdown", strlen("shutdown"), 1); - ZVAL_STR(&const_SHUTDOWN_value, const_SHUTDOWN_value_str); - zend_string *const_SHUTDOWN_name = zend_string_init_interned("SHUTDOWN", sizeof("SHUTDOWN") - 1, true); - zend_declare_typed_class_constant(class_entry, const_SHUTDOWN_name, &const_SHUTDOWN_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(const_SHUTDOWN_name, true); - - zval const_INTERCEPT_FIBER_value; - zend_string *const_INTERCEPT_FIBER_value_str = zend_string_init("intercept_fiber", strlen("intercept_fiber"), 1); - ZVAL_STR(&const_INTERCEPT_FIBER_value, const_INTERCEPT_FIBER_value_str); - zend_string *const_INTERCEPT_FIBER_name = zend_string_init_interned("INTERCEPT_FIBER", sizeof("INTERCEPT_FIBER") - 1, true); - zend_declare_typed_class_constant(class_entry, const_INTERCEPT_FIBER_name, &const_INTERCEPT_FIBER_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(const_INTERCEPT_FIBER_name, true); - - zval const_ENQUEUE_value; - zend_string *const_ENQUEUE_value_str = zend_string_init("enqueue_coroutine", strlen("enqueue_coroutine"), 1); - ZVAL_STR(&const_ENQUEUE_value, const_ENQUEUE_value_str); - zend_string *const_ENQUEUE_name = zend_string_init_interned("ENQUEUE", sizeof("ENQUEUE") - 1, true); - zend_declare_typed_class_constant(class_entry, const_ENQUEUE_name, &const_ENQUEUE_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(const_ENQUEUE_name, true); - - zval const_SUSPEND_value; - zend_string *const_SUSPEND_value_str = zend_string_init("suspend", strlen("suspend"), 1); - ZVAL_STR(&const_SUSPEND_value, const_SUSPEND_value_str); - zend_string *const_SUSPEND_name = zend_string_init_interned("SUSPEND", sizeof("SUSPEND") - 1, true); - zend_declare_typed_class_constant(class_entry, const_SUSPEND_name, &const_SUSPEND_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(const_SUSPEND_name, true); - - zval const_RESUME_value; - zend_string *const_RESUME_value_str = zend_string_init("resume", strlen("resume"), 1); - ZVAL_STR(&const_RESUME_value, const_RESUME_value_str); - zend_string *const_RESUME_name = zend_string_init_interned("RESUME", sizeof("RESUME") - 1, true); - zend_declare_typed_class_constant(class_entry, const_RESUME_name, &const_RESUME_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(const_RESUME_name, true); - - zval const_CANCEL_value; - zend_string *const_CANCEL_value_str = zend_string_init("cancel", strlen("cancel"), 1); - ZVAL_STR(&const_CANCEL_value, const_CANCEL_value_str); - zend_string *const_CANCEL_name = zend_string_init_interned("CANCEL", sizeof("CANCEL") - 1, true); - zend_declare_typed_class_constant(class_entry, const_CANCEL_name, &const_CANCEL_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(const_CANCEL_name, true); - - zval const_CONTEXT_FIND_value; - zend_string *const_CONTEXT_FIND_value_str = zend_string_init("context_find", strlen("context_find"), 1); - ZVAL_STR(&const_CONTEXT_FIND_value, const_CONTEXT_FIND_value_str); - zend_string *const_CONTEXT_FIND_name = zend_string_init_interned("CONTEXT_FIND", sizeof("CONTEXT_FIND") - 1, true); - zend_declare_typed_class_constant(class_entry, const_CONTEXT_FIND_name, &const_CONTEXT_FIND_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(const_CONTEXT_FIND_name, true); - - zval const_CONTEXT_SET_value; - zend_string *const_CONTEXT_SET_value_str = zend_string_init("context_set", strlen("context_set"), 1); - ZVAL_STR(&const_CONTEXT_SET_value, const_CONTEXT_SET_value_str); - zend_string *const_CONTEXT_SET_name = zend_string_init_interned("CONTEXT_SET", sizeof("CONTEXT_SET") - 1, true); - zend_declare_typed_class_constant(class_entry, const_CONTEXT_SET_name, &const_CONTEXT_SET_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(const_CONTEXT_SET_name, true); - - zval const_CONTEXT_UNSET_value; - zend_string *const_CONTEXT_UNSET_value_str = zend_string_init("context_unset", strlen("context_unset"), 1); - ZVAL_STR(&const_CONTEXT_UNSET_value, const_CONTEXT_UNSET_value_str); - zend_string *const_CONTEXT_UNSET_name = zend_string_init_interned("CONTEXT_UNSET", sizeof("CONTEXT_UNSET") - 1, true); - zend_declare_typed_class_constant(class_entry, const_CONTEXT_UNSET_name, &const_CONTEXT_UNSET_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(const_CONTEXT_UNSET_name, true); - - zval const_DEFER_value; - zend_string *const_DEFER_value_str = zend_string_init("defer", strlen("defer"), 1); - ZVAL_STR(&const_DEFER_value, const_DEFER_value_str); - zend_string *const_DEFER_name = zend_string_init_interned("DEFER", sizeof("DEFER") - 1, true); - zend_declare_typed_class_constant(class_entry, const_DEFER_name, &const_DEFER_value, ZEND_ACC_PUBLIC, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_STRING)); - zend_string_release_ex(const_DEFER_name, true); - return class_entry; } From 73eadfe7aefe724bce6239033afd0fdf7b1ee573 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:36:28 +0000 Subject: [PATCH 06/23] tests: port async suite to interface-based scheduler registration --- Zend/tests/async/fiber_lowlevel_null.phpt | 15 +++--- .../tests/async/fiber_managed_after_main.phpt | 34 ++++++------ Zend/tests/async/fiber_managed_basic.phpt | 52 +++++++++---------- Zend/tests/async/fiber_managed_throw.phpt | 38 +++++++------- Zend/tests/async/fiber_managed_uncaught.phpt | 31 ++++++----- Zend/tests/async/microtasks_basic.phpt | 29 ++++++----- Zend/tests/async/scheduler_context.phpt | 46 ++++++++-------- .../tests/async/scheduler_hook_constants.phpt | 33 +++++------- Zend/tests/async/scheduler_hook_invalid.phpt | 10 ++-- Zend/tests/async/scheduler_hook_launch.phpt | 19 +++---- Zend/tests/async/scheduler_hook_override.phpt | 13 +++-- Zend/tests/async/scheduler_hook_register.phpt | 9 ++-- 12 files changed, 153 insertions(+), 176 deletions(-) diff --git a/Zend/tests/async/fiber_lowlevel_null.phpt b/Zend/tests/async/fiber_lowlevel_null.phpt index e78aa334757c..9743abcffd76 100644 --- a/Zend/tests/async/fiber_lowlevel_null.phpt +++ b/Zend/tests/async/fiber_lowlevel_null.phpt @@ -1,19 +1,20 @@ --TEST-- -intercept_fiber returning null keeps the fiber on the low-level path +interceptFiber returning null keeps the fiber on the low-level path --FILE-- function (Fiber $fiber): ?object { +Async\SchedulerHook::register('test', new class extends Async\AbstractScheduler { + public function interceptFiber(Fiber $fiber): ?object { echo "intercept: null\n"; return null; - }, - Async\SchedulerHook::SUSPEND => function (bool $fromMain, bool $isBailout): bool { + } + public function enqueue(object $coroutine): bool { return true; } + public function suspend(bool $fromMain, bool $isBailout): bool { // Never called for the low-level fiber itself; the engine invokes it // for the after-main handover (script end, then after destructors). echo "suspend(fromMain: ", var_export($fromMain, true), ")\n"; return true; - }, -]); + } +}); // A low-level fiber behaves exactly as classic Fiber even with a scheduler. $fiber = new Fiber(function (int $x): int { diff --git a/Zend/tests/async/fiber_managed_after_main.phpt b/Zend/tests/async/fiber_managed_after_main.phpt index 4c3337923983..c5ea11ad5b97 100644 --- a/Zend/tests/async/fiber_managed_after_main.phpt +++ b/Zend/tests/async/fiber_managed_after_main.phpt @@ -2,31 +2,30 @@ After-main handover runs deferred managed coroutines; abandoned ones die cleanly --FILE-- fn (Fiber $fiber): object - => new class($fiber) { +Async\SchedulerHook::register('test', new class extends Async\AbstractScheduler { + public SplQueue $queue; + public function __construct() { $this->queue = new SplQueue(); } + public function interceptFiber(Fiber $fiber): ?object { + return new class($fiber) { public function __construct(public readonly Fiber $fiber) {} - }, - Async\SchedulerHook::ENQUEUE => function (object $coroutine) use ($queue): bool { - $queue->enqueue($coroutine); + }; + } + public function enqueue(object $coroutine): bool { + $this->queue->enqueue($coroutine); return true; - }, - Async\SchedulerHook::SUSPEND => function (bool $fromMain, bool $isBailout) use ($queue): bool { + } + public function suspend(bool $fromMain, bool $isBailout): bool { // This scheduler defers everything: nothing runs until after main. if (!$fromMain) { return true; } - - while (!$queue->isEmpty()) { - $fiber = $queue->dequeue()->fiber; + while (!$this->queue->isEmpty()) { + $fiber = $this->queue->dequeue()->fiber; $fiber->isStarted() ? $fiber->resume() : $fiber->start(); } - return true; - }, -]); + } +}); $fiber = new Fiber(function (): void { echo "ran after main\n"; @@ -39,9 +38,6 @@ var_dump($fiber->start()); var_dump($fiber->isStarted()); echo "end of script\n"; -// After-main handover: the scheduler drains its queue, the fiber runs and -// suspends. Nobody resumes it, so it is destroyed at shutdown without -// completing; the second echo never happens. ?> --EXPECT-- NULL diff --git a/Zend/tests/async/fiber_managed_basic.phpt b/Zend/tests/async/fiber_managed_basic.phpt index 6ae794c600f3..e949129301fe 100644 --- a/Zend/tests/async/fiber_managed_basic.phpt +++ b/Zend/tests/async/fiber_managed_basic.phpt @@ -2,44 +2,40 @@ A fiber adopted by the scheduler runs through the coroutine path --FILE-- function (Fiber $fiber) use (&$log): object { - $log[] = 'intercept'; - - // The scheduler defines the coroutine; here it simply remembers - // which fiber the coroutine drives. +$scheduler = new class extends Async\AbstractScheduler { + public SplQueue $queue; + public array $log = []; + public function __construct() { $this->queue = new SplQueue(); } + public function interceptFiber(Fiber $fiber): ?object { + $this->log[] = 'intercept'; return new class($fiber) { public function __construct(public readonly Fiber $fiber) {} }; - }, - Async\SchedulerHook::ENQUEUE => function (object $coroutine) use ($queue, &$log): bool { - $log[] = 'enqueue'; - $queue->enqueue($coroutine); + } + public function enqueue(object $coroutine): bool { + $this->log[] = 'enqueue'; + $this->queue->enqueue($coroutine); return true; - }, - Async\SchedulerHook::RESUME => function (object $coroutine, ?Throwable $error) use ($queue, &$log): bool { - $log[] = 'resume'; - $queue->enqueue($coroutine); + } + public function resume(object $coroutine, ?Throwable $error = null): bool { + $this->log[] = 'resume'; + $this->queue->enqueue($coroutine); return true; - }, - Async\SchedulerHook::SUSPEND => function (bool $fromMain, bool $isBailout) use ($queue, &$log): bool { - $log[] = 'suspend'; - - while (!$queue->isEmpty()) { - $fiber = $queue->dequeue()->fiber; + } + public function suspend(bool $fromMain, bool $isBailout): bool { + $this->log[] = 'suspend'; + while (!$this->queue->isEmpty()) { + $fiber = $this->queue->dequeue()->fiber; $fiber->isStarted() ? $fiber->resume() : $fiber->start(); - if (!$fromMain) { return true; } } - return true; - }, -]); + } +}; + +Async\SchedulerHook::register('test', $scheduler); $fiber = new Fiber(function (int $x): int { $y = Fiber::suspend($x + 1); @@ -51,7 +47,7 @@ var_dump($fiber->isSuspended()); var_dump($fiber->resume(4)); var_dump($fiber->isTerminated()); var_dump($fiber->getReturn()); -echo implode(',', $log), "\n"; +echo implode(',', $scheduler->log), "\n"; ?> --EXPECT-- int(6) diff --git a/Zend/tests/async/fiber_managed_throw.phpt b/Zend/tests/async/fiber_managed_throw.phpt index e4feca92ce68..b43c5581fbda 100644 --- a/Zend/tests/async/fiber_managed_throw.phpt +++ b/Zend/tests/async/fiber_managed_throw.phpt @@ -2,35 +2,34 @@ Fiber::throw() on a managed fiber delivers the exception at the suspension point --FILE-- fn (Fiber $fiber): object - => new class($fiber) { +Async\SchedulerHook::register('test', new class extends Async\AbstractScheduler { + public SplQueue $queue; + public function __construct() { $this->queue = new SplQueue(); } + public function interceptFiber(Fiber $fiber): ?object { + return new class($fiber) { public function __construct(public readonly Fiber $fiber) {} - }, - Async\SchedulerHook::ENQUEUE => function (object $coroutine) use ($queue): bool { - $queue->enqueue($coroutine); + }; + } + public function enqueue(object $coroutine): bool { + $this->queue->enqueue($coroutine); return true; - }, - Async\SchedulerHook::RESUME => function (object $coroutine, ?Throwable $error) use ($queue): bool { + } + public function resume(object $coroutine, ?Throwable $error = null): bool { echo "resume hook error: ", $error === null ? 'none' : get_class($error), "\n"; - $queue->enqueue($coroutine); + $this->queue->enqueue($coroutine); return true; - }, - Async\SchedulerHook::SUSPEND => function (bool $fromMain, bool $isBailout) use ($queue): bool { - while (!$queue->isEmpty()) { - $fiber = $queue->dequeue()->fiber; + } + public function suspend(bool $fromMain, bool $isBailout): bool { + while (!$this->queue->isEmpty()) { + $fiber = $this->queue->dequeue()->fiber; $fiber->isStarted() ? $fiber->resume() : $fiber->start(); - if (!$fromMain) { return true; } } - return true; - }, -]); + } +}); $fiber = new Fiber(function (): string { try { @@ -38,7 +37,6 @@ $fiber = new Fiber(function (): string { } catch (RuntimeException $e) { return 'caught: ' . $e->getMessage(); } - return 'not reached'; }); diff --git a/Zend/tests/async/fiber_managed_uncaught.phpt b/Zend/tests/async/fiber_managed_uncaught.phpt index 3cad9122f66c..7c1a7940cfab 100644 --- a/Zend/tests/async/fiber_managed_uncaught.phpt +++ b/Zend/tests/async/fiber_managed_uncaught.phpt @@ -2,30 +2,29 @@ An uncaught exception in a managed fiber surfaces at the start()/resume() caller --FILE-- fn (Fiber $fiber): object - => new class($fiber) { +Async\SchedulerHook::register('test', new class extends Async\AbstractScheduler { + public SplQueue $queue; + public function __construct() { $this->queue = new SplQueue(); } + public function interceptFiber(Fiber $fiber): ?object { + return new class($fiber) { public function __construct(public readonly Fiber $fiber) {} - }, - Async\SchedulerHook::ENQUEUE => function (object $coroutine) use ($queue): bool { - $queue->enqueue($coroutine); + }; + } + public function enqueue(object $coroutine): bool { + $this->queue->enqueue($coroutine); return true; - }, - Async\SchedulerHook::SUSPEND => function (bool $fromMain, bool $isBailout) use ($queue): bool { - while (!$queue->isEmpty()) { - $fiber = $queue->dequeue()->fiber; + } + public function suspend(bool $fromMain, bool $isBailout): bool { + while (!$this->queue->isEmpty()) { + $fiber = $this->queue->dequeue()->fiber; $fiber->isStarted() ? $fiber->resume() : $fiber->start(); - if (!$fromMain) { return true; } } - return true; - }, -]); + } +}); $fiber = new Fiber(function (): void { throw new RuntimeException('escaped'); diff --git a/Zend/tests/async/microtasks_basic.phpt b/Zend/tests/async/microtasks_basic.phpt index 95037412b328..3dbd596725d8 100644 --- a/Zend/tests/async/microtasks_basic.phpt +++ b/Zend/tests/async/microtasks_basic.phpt @@ -1,21 +1,22 @@ --TEST-- -Microtasks: defer() forwards to the scheduler's DEFER hook; the queue is the scheduler's +Microtasks: defer() forwards to the scheduler's defer() hook; the queue is the scheduler's --FILE-- function (bool $fromMain, bool $isBailout): bool { - return true; - }, - Async\SchedulerHook::DEFER => function (callable $task) use ($tasks): bool { +$scheduler = new class extends Async\AbstractScheduler { + public SplQueue $tasks; + public function __construct() { $this->tasks = new SplQueue(); } + public function enqueue(object $coroutine): bool { return true; } + public function suspend(bool $fromMain, bool $isBailout): bool { return true; } + public function defer(callable $task): bool { // The queue is owned by the scheduler, not by the engine. - $tasks->enqueue($task); + $this->tasks->enqueue($task); return true; - }, -]); + } +}; -Async\SchedulerHook::defer(function () use ($tasks): void { +Async\SchedulerHook::register('test', $scheduler); + +Async\SchedulerHook::defer(function (): void { echo "task 1\n"; // Queued while draining: the scheduler decides the semantics; this @@ -30,8 +31,8 @@ Async\SchedulerHook::defer(function (): void { }); // The scheduler drains its own queue on its tick; simulate one here. -while (!$tasks->isEmpty()) { - ($tasks->dequeue())(); +while (!$scheduler->tasks->isEmpty()) { + ($scheduler->tasks->dequeue())(); } echo "drained\n"; diff --git a/Zend/tests/async/scheduler_context.phpt b/Zend/tests/async/scheduler_context.phpt index ab4b1c4102e0..b5eb0ebab873 100644 --- a/Zend/tests/async/scheduler_context.phpt +++ b/Zend/tests/async/scheduler_context.phpt @@ -2,27 +2,28 @@ Fiber operations on a bound fiber: direct inside the scheduler, routed outside --FILE-- fn (Fiber $fiber): object - => new class($fiber) { +$scheduler = new class extends Async\AbstractScheduler { + public SplQueue $queue; + public array $log = []; + public function __construct() { $this->queue = new SplQueue(); } + public function interceptFiber(Fiber $fiber): ?object { + return new class($fiber) { public function __construct(public readonly Fiber $fiber) {} - }, - Async\SchedulerHook::ENQUEUE => function (object $coroutine) use ($queue, &$log): bool { - $log[] = 'enqueue'; - $queue->enqueue($coroutine); + }; + } + public function enqueue(object $coroutine): bool { + $this->log[] = 'enqueue'; + $this->queue->enqueue($coroutine); return true; - }, - Async\SchedulerHook::RESUME => function (object $coroutine, ?Throwable $error) use ($queue, &$log): bool { - $log[] = 'resume-hook'; - $queue->enqueue($coroutine); + } + public function resume(object $coroutine, ?Throwable $error = null): bool { + $this->log[] = 'resume-hook'; + $this->queue->enqueue($coroutine); return true; - }, - Async\SchedulerHook::SUSPEND => function (bool $fromMain, bool $isBailout) use ($queue, &$log): bool { - while (!$queue->isEmpty()) { - $fiber = $queue->dequeue()->fiber; + } + public function suspend(bool $fromMain, bool $isBailout): bool { + while (!$this->queue->isEmpty()) { + $fiber = $this->queue->dequeue()->fiber; // Inside a hook this is a DIRECT switch: no hooks re-enter. $fiber->isStarted() ? $fiber->resume() : $fiber->start(); @@ -31,10 +32,11 @@ Async\SchedulerHook::register('test', [ return true; } } - return true; - }, -]); + } +}; + +Async\SchedulerHook::register('test', $scheduler); $fiber = new Fiber(function (): string { Fiber::suspend('first'); @@ -47,7 +49,7 @@ var_dump($fiber->resume()); var_dump($fiber->getReturn()); // The scheduler's direct switches fired no extra hook calls: -echo implode(',', $log), "\n"; +echo implode(',', $scheduler->log), "\n"; // A finished bound fiber cannot be resumed, same rule as classic fibers. try { diff --git a/Zend/tests/async/scheduler_hook_constants.phpt b/Zend/tests/async/scheduler_hook_constants.phpt index 26d3f3efa015..7054b4b0b0b5 100644 --- a/Zend/tests/async/scheduler_hook_constants.phpt +++ b/Zend/tests/async/scheduler_hook_constants.phpt @@ -1,26 +1,17 @@ --TEST-- -Async\SchedulerHook: hook name constants +Async\Scheduler: interface shape and AbstractScheduler base --FILE-- isInterface()); + +$methods = array_map(fn ($m) => $m->name, $r->getMethods()); +sort($methods); +echo implode(',', $methods), "\n"; + +var_dump((new ReflectionClass(Async\AbstractScheduler::class))->isAbstract()); ?> --EXPECT-- -string(6) "launch" -string(8) "shutdown" -string(15) "intercept_fiber" -string(17) "enqueue_coroutine" -string(7) "suspend" -string(6) "resume" -string(6) "cancel" -string(12) "context_find" -string(11) "context_set" -string(13) "context_unset" +bool(true) +cancel,contextFind,contextSet,contextUnset,defer,enqueue,interceptFiber,launch,resume,shutdown,suspend +bool(true) diff --git a/Zend/tests/async/scheduler_hook_invalid.phpt b/Zend/tests/async/scheduler_hook_invalid.phpt index adfe79314c7d..b653ed0f4111 100644 --- a/Zend/tests/async/scheduler_hook_invalid.phpt +++ b/Zend/tests/async/scheduler_hook_invalid.phpt @@ -1,11 +1,9 @@ --TEST-- -Async\SchedulerHook::register rejects a non-callable hook +Async\SchedulerHook::register requires an Async\Scheduler instance --FILE-- 'this_function_does_not_exist', - ]); + Async\SchedulerHook::register('test', new stdClass()); } catch (\TypeError $e) { echo $e->getMessage(), "\n"; } @@ -13,6 +11,6 @@ try { // A refused registration leaves no active scheduler. var_dump(Async\SchedulerHook::getModule()); ?> ---EXPECTF-- -Async scheduler hook "suspend" must be a valid callable: %s +--EXPECT-- +Async\SchedulerHook::register(): Argument #2 ($scheduler) must be of type Async\Scheduler, stdClass given NULL diff --git a/Zend/tests/async/scheduler_hook_launch.phpt b/Zend/tests/async/scheduler_hook_launch.phpt index 66fe0c1150cc..f1e73d6b14a8 100644 --- a/Zend/tests/async/scheduler_hook_launch.phpt +++ b/Zend/tests/async/scheduler_hook_launch.phpt @@ -2,21 +2,18 @@ Async\SchedulerHook: launch runs immediately at PHP registration --FILE-- launched = true; return true; } +}; -Async\SchedulerHook::register('test', [ - Async\SchedulerHook::LAUNCH => function () use (&$launched): bool { - $launched = true; - return true; - }, - Async\SchedulerHook::SUSPEND => function (bool $fromMain, bool $isBailout): bool { - return true; - }, -]); +Async\SchedulerHook::register('test', $scheduler); // The engine launch point has already passed by the time userland runs, // so a PHP scheduler is launched synchronously inside register(). -var_dump($launched); +var_dump($scheduler->launched); ?> --EXPECT-- bool(true) diff --git a/Zend/tests/async/scheduler_hook_override.phpt b/Zend/tests/async/scheduler_hook_override.phpt index 662aff395cb5..9a43f42320a9 100644 --- a/Zend/tests/async/scheduler_hook_override.phpt +++ b/Zend/tests/async/scheduler_hook_override.phpt @@ -2,18 +2,17 @@ Async\SchedulerHook::register can be called only once --FILE-- new class extends Async\AbstractScheduler { + public function enqueue(object $coroutine): bool { return true; } + public function suspend(bool $fromMain, bool $isBailout): bool { return true; } +}; // First registration succeeds. -var_dump(Async\SchedulerHook::register('a', [ - Async\SchedulerHook::SUSPEND => $noop, -])); +var_dump(Async\SchedulerHook::register('a', $make())); // A second registration throws: a scheduler is registered once per process. try { - Async\SchedulerHook::register('b', [ - Async\SchedulerHook::SUSPEND => $noop, - ]); + Async\SchedulerHook::register('b', $make()); } catch (\Error $e) { echo $e->getMessage(), "\n"; } diff --git a/Zend/tests/async/scheduler_hook_register.phpt b/Zend/tests/async/scheduler_hook_register.phpt index f4edda2e3c49..4691d91c4e8b 100644 --- a/Zend/tests/async/scheduler_hook_register.phpt +++ b/Zend/tests/async/scheduler_hook_register.phpt @@ -4,11 +4,10 @@ Async\SchedulerHook::register activates and getModule() reports the driver function (bool $fromMain, bool $isBailout): bool { - return true; - }, -]); +$ok = Async\SchedulerHook::register('my-driver', new class extends Async\AbstractScheduler { + public function enqueue(object $coroutine): bool { return true; } + public function suspend(bool $fromMain, bool $isBailout): bool { return true; } +}); var_dump($ok); var_dump(Async\SchedulerHook::getModule()); From 02b2f3f4462b90aae1cf2b3eec10b87a0643ea26 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:27:18 +0000 Subject: [PATCH 07/23] =?UTF-8?q?async:=20Async\Coroutine::current()/resum?= =?UTF-8?q?e()=20=E2=80=94=20engine=20tracks=20current=20from=20intercept?= =?UTF-8?q?=20binding,=20deferred=20wake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/async/coroutine_current_resume.phpt | 57 ++++++++++++++ Zend/zend_fibers.c | 7 ++ Zend/zend_scheduler_hook.c | 76 +++++++++++++++++++ Zend/zend_scheduler_hook.stub.php | 17 +++++ Zend/zend_scheduler_hook_arginfo.h | 28 ++++++- 5 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 Zend/tests/async/coroutine_current_resume.phpt diff --git a/Zend/tests/async/coroutine_current_resume.phpt b/Zend/tests/async/coroutine_current_resume.phpt new file mode 100644 index 000000000000..ff2726310793 --- /dev/null +++ b/Zend/tests/async/coroutine_current_resume.phpt @@ -0,0 +1,57 @@ +--TEST-- +Async\Coroutine: current() tracks the running coroutine; resume() defers a wake +--FILE-- +ready = new SplQueue(); } + public function interceptFiber(Fiber $fiber): ?object { + return new class($fiber) { + public function __construct(public readonly Fiber $fiber) {} + }; + } + public function enqueue(object $coroutine): bool { + $this->ready->enqueue($coroutine); + return true; + } + public function suspend(bool $fromMain, bool $isBailout): bool { + if (!$fromMain) { + return true; + } + while (!$this->ready->isEmpty()) { + $fiber = $this->ready->dequeue()->fiber; + $fiber->isStarted() ? $fiber->resume() : $fiber->start(); + } + return true; + } +}); + +// No coroutine is current in the main flow. +var_dump(Async\Coroutine::current()); + +$waiter = null; + +$w = new Fiber(function () use (&$waiter): void { + $waiter = Async\Coroutine::current(); // the running coroutine object + echo "waiter: is-coroutine=", var_export($waiter !== null, true), "\n"; + Fiber::suspend(); // await + echo "waiter: resumed\n"; +}); + +$r = new Fiber(function () use (&$waiter): void { + echo "resolver: resume waiter\n"; + Async\Coroutine::resume($waiter); // deferred wake (no immediate switch) + echo "resolver: done\n"; +}); + +$w->start(); +$r->start(); +echo "main: end\n"; +?> +--EXPECT-- +NULL +main: end +waiter: is-coroutine=true +resolver: resume waiter +resolver: done +waiter: resumed diff --git a/Zend/zend_fibers.c b/Zend/zend_fibers.c index 5e0654e6a34c..832d2f58650f 100644 --- a/Zend/zend_fibers.c +++ b/Zend/zend_fibers.c @@ -975,9 +975,16 @@ static void zend_fiber_scheduler_switch(zend_fiber *fiber, zval *return_value) const bool saved_context = ZEND_ASYNC_IN_SCHEDULER_CONTEXT; ZEND_ASYNC_IN_SCHEDULER_CONTEXT = false; + /* The coroutine bound to this fiber (via the scheduler's intercept-fiber + * return) is the current one for the duration of its body. Restored when it + * suspends and hands control back here. */ + zend_coroutine_t *saved_coroutine = ZEND_ASYNC_CURRENT_COROUTINE; + ZEND_ASYNC_CURRENT_COROUTINE = coroutine; + zend_fiber_transfer transfer = zend_fiber_resume_internal( fiber, Z_ISUNDEF(value) ? NULL : &value, is_error); + ZEND_ASYNC_CURRENT_COROUTINE = saved_coroutine; ZEND_ASYNC_IN_SCHEDULER_CONTEXT = saved_context; zval_ptr_dtor(&value); diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c index ca457f2ce3d5..8c86139b3ec0 100644 --- a/Zend/zend_scheduler_hook.c +++ b/Zend/zend_scheduler_hook.c @@ -579,11 +579,87 @@ ZEND_METHOD(Async_AbstractScheduler, contextUnset) RETURN_FALSE; } +///////////////////////////////////////////////////////////////////// +/// Async\Coroutine +///////////////////////////////////////////////////////////////////// + +ZEND_METHOD(Async_Coroutine, current) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + zend_coroutine_t *coro = ZEND_ASYNC_CURRENT_COROUTINE; + + if (coro == NULL) { + RETURN_NULL(); + } + + zend_object *object = ZEND_COROUTINE_OBJECT(coro); + + if (object == NULL) { + RETURN_NULL(); + } + + GC_ADDREF(object); + RETURN_OBJ(object); +} + +ZEND_METHOD(Async_Coroutine, resume) +{ + zval *coroutine; + zend_object *error = NULL; + + ZEND_PARSE_PARAMETERS_START(1, 2) + Z_PARAM_OBJECT(coroutine) + Z_PARAM_OPTIONAL + Z_PARAM_OBJ_OF_CLASS_OR_NULL(error, zend_ce_throwable) + ZEND_PARSE_PARAMETERS_END(); + + /* A deferred wake: route the coroutine back to the scheduler's resume() + * hook to be re-queued (never an immediate fiber switch). Fall back to + * enqueue() when the scheduler does not override resume(). */ + php_async_hook_id id; + + if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_RESUME)->set) { + id = PHP_ASYNC_HOOK_RESUME; + } else if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_ENQUEUE)->set) { + id = PHP_ASYNC_HOOK_ENQUEUE; + } else { + zend_throw_error(NULL, "The registered scheduler cannot resume coroutines"); + RETURN_THROWS(); + } + + zval args[2]; + ZVAL_COPY(&args[0], coroutine); + uint32_t argc = 1; + + if (id == PHP_ASYNC_HOOK_RESUME) { + if (error != NULL) { + ZVAL_OBJ(&args[1], error); + GC_ADDREF(error); + } else { + ZVAL_NULL(&args[1]); + } + argc = 2; + } + + php_async_hook_call_bool(id, argc, args); + + zval_ptr_dtor(&args[0]); + if (argc == 2) { + zval_ptr_dtor(&args[1]); + } + + if (UNEXPECTED(EG(exception))) { + RETURN_THROWS(); + } +} + void zend_register_scheduler_hook(void) { async_ce_Scheduler = register_class_Async_Scheduler(); async_ce_AbstractScheduler = register_class_Async_AbstractScheduler(async_ce_Scheduler); register_class_Async_SchedulerHook(); + register_class_Async_Coroutine(); } void zend_scheduler_hook_request_shutdown(void) diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php index f41e9f4c4a76..2ce94617dcb6 100644 --- a/Zend/zend_scheduler_hook.stub.php +++ b/Zend/zend_scheduler_hook.stub.php @@ -104,3 +104,20 @@ public static function getModule(): ?string {} */ public static function defer(callable $task): void {} } + +/** + * The coroutine currently on the stack, and the way to wake a suspended one. + * + * The engine tracks the current coroutine from the object the scheduler + * returned from Async\Scheduler::interceptFiber() — there is no setter. + * resume() is a *deferred* wake: it routes the coroutine back to the + * scheduler's resume() hook to be re-queued, never an immediate fiber switch. + */ +final class Coroutine +{ + /** The coroutine object running on the current stack, or null in the main flow. */ + public static function current(): ?object {} + + /** Wake a suspended coroutine: hand it to the scheduler to be run later. */ + public static function resume(object $coroutine, ?\Throwable $error = null): void {} +} diff --git a/Zend/zend_scheduler_hook_arginfo.h b/Zend/zend_scheduler_hook_arginfo.h index 3156a0ab2ab9..47b3ce3e26b8 100644 --- a/Zend/zend_scheduler_hook_arginfo.h +++ b/Zend/zend_scheduler_hook_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit zend_scheduler_hook.stub.php instead. - * Stub hash: 1c088b1cb35579b4f3863c1bc8d4aa46b28db602 */ + * Stub hash: 3d9e53ed2acd53ce7f957780c7dac66eb451d7d9 */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_enqueue, 0, 1, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) @@ -81,6 +81,14 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_SchedulerHook_defer, ZEND_ARG_TYPE_INFO(0, task, IS_CALLABLE, 0) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_current, 0, 0, IS_OBJECT, 1) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_resume, 0, 1, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) + ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, error, Throwable, 1, "null") +ZEND_END_ARG_INFO() + ZEND_METHOD(Async_AbstractScheduler, interceptFiber); ZEND_METHOD(Async_AbstractScheduler, resume); ZEND_METHOD(Async_AbstractScheduler, cancel); @@ -93,6 +101,8 @@ ZEND_METHOD(Async_AbstractScheduler, contextUnset); ZEND_METHOD(Async_SchedulerHook, register); ZEND_METHOD(Async_SchedulerHook, getModule); ZEND_METHOD(Async_SchedulerHook, defer); +ZEND_METHOD(Async_Coroutine, current); +ZEND_METHOD(Async_Coroutine, resume); static const zend_function_entry class_Async_Scheduler_methods[] = { ZEND_RAW_FENTRY("enqueue", NULL, arginfo_class_Async_Scheduler_enqueue, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) @@ -131,6 +141,12 @@ static const zend_function_entry class_Async_SchedulerHook_methods[] = { ZEND_FE_END }; +static const zend_function_entry class_Async_Coroutine_methods[] = { + ZEND_ME(Async_Coroutine, current, arginfo_class_Async_Coroutine_current, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) + ZEND_ME(Async_Coroutine, resume, arginfo_class_Async_Coroutine_resume, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) + ZEND_FE_END +}; + static zend_class_entry *register_class_Async_Scheduler(void) { zend_class_entry ce, *class_entry; @@ -161,3 +177,13 @@ static zend_class_entry *register_class_Async_SchedulerHook(void) return class_entry; } + +static zend_class_entry *register_class_Async_Coroutine(void) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "Async", "Coroutine", class_Async_Coroutine_methods); + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL); + + return class_entry; +} From daa6f28af5f27ae470eb8149a8d678b6443c532c Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:40:11 +0000 Subject: [PATCH 08/23] async: scheduler declares current coroutine via Async\Coroutine::setCurrent() in suspend hook (drop engine auto-tracking) --- .../tests/async/coroutine_current_resume.phpt | 7 ++-- Zend/zend_async_API.h | 3 ++ Zend/zend_fibers.c | 7 ---- Zend/zend_scheduler_hook.c | 34 +++++++++++++++---- Zend/zend_scheduler_hook.stub.php | 11 +++--- Zend/zend_scheduler_hook_arginfo.h | 8 ++++- 6 files changed, 49 insertions(+), 21 deletions(-) diff --git a/Zend/tests/async/coroutine_current_resume.phpt b/Zend/tests/async/coroutine_current_resume.phpt index ff2726310793..4de0228f34f0 100644 --- a/Zend/tests/async/coroutine_current_resume.phpt +++ b/Zend/tests/async/coroutine_current_resume.phpt @@ -1,5 +1,5 @@ --TEST-- -Async\Coroutine: current() tracks the running coroutine; resume() defers a wake +Async\Coroutine: setCurrent() declares the running coroutine; resume() defers a wake --FILE-- ready->isEmpty()) { - $fiber = $this->ready->dequeue()->fiber; + $coroutine = $this->ready->dequeue(); + $fiber = $coroutine->fiber; + Async\Coroutine::setCurrent($coroutine); $fiber->isStarted() ? $fiber->resume() : $fiber->start(); + Async\Coroutine::setCurrent(null); } return true; } diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h index ac634926ca57..29d86f812360 100644 --- a/Zend/zend_async_API.h +++ b/Zend/zend_async_API.h @@ -517,6 +517,9 @@ typedef struct { /* The registered Async\Scheduler instance; one ref held for the request. * The bound hook methods borrow this object. */ zend_object *scheduler; + /* The coroutine object the scheduler declared current via + * Async\Coroutine::setCurrent(); NULL in the main flow. */ + zend_object *current_coroutine; php_async_hook_t hooks[PHP_ASYNC_HOOK_COUNT]; } php_async_handlers_t; diff --git a/Zend/zend_fibers.c b/Zend/zend_fibers.c index 832d2f58650f..5e0654e6a34c 100644 --- a/Zend/zend_fibers.c +++ b/Zend/zend_fibers.c @@ -975,16 +975,9 @@ static void zend_fiber_scheduler_switch(zend_fiber *fiber, zval *return_value) const bool saved_context = ZEND_ASYNC_IN_SCHEDULER_CONTEXT; ZEND_ASYNC_IN_SCHEDULER_CONTEXT = false; - /* The coroutine bound to this fiber (via the scheduler's intercept-fiber - * return) is the current one for the duration of its body. Restored when it - * suspends and hands control back here. */ - zend_coroutine_t *saved_coroutine = ZEND_ASYNC_CURRENT_COROUTINE; - ZEND_ASYNC_CURRENT_COROUTINE = coroutine; - zend_fiber_transfer transfer = zend_fiber_resume_internal( fiber, Z_ISUNDEF(value) ? NULL : &value, is_error); - ZEND_ASYNC_CURRENT_COROUTINE = saved_coroutine; ZEND_ASYNC_IN_SCHEDULER_CONTEXT = saved_context; zval_ptr_dtor(&value); diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c index 8c86139b3ec0..1527e291babc 100644 --- a/Zend/zend_scheduler_hook.c +++ b/Zend/zend_scheduler_hook.c @@ -371,6 +371,11 @@ static void php_async_handlers_reset(void) php_async_hook_release(PHP_ASYNC_HOOK(id)); } + if (PHP_ASYNC_HANDLERS.current_coroutine != NULL) { + OBJ_RELEASE(PHP_ASYNC_HANDLERS.current_coroutine); + PHP_ASYNC_HANDLERS.current_coroutine = NULL; + } + if (PHP_ASYNC_HANDLERS.scheduler != NULL) { OBJ_RELEASE(PHP_ASYNC_HANDLERS.scheduler); PHP_ASYNC_HANDLERS.scheduler = NULL; @@ -587,13 +592,7 @@ ZEND_METHOD(Async_Coroutine, current) { ZEND_PARSE_PARAMETERS_NONE(); - zend_coroutine_t *coro = ZEND_ASYNC_CURRENT_COROUTINE; - - if (coro == NULL) { - RETURN_NULL(); - } - - zend_object *object = ZEND_COROUTINE_OBJECT(coro); + zend_object *object = PHP_ASYNC_HANDLERS.current_coroutine; if (object == NULL) { RETURN_NULL(); @@ -603,6 +602,27 @@ ZEND_METHOD(Async_Coroutine, current) RETURN_OBJ(object); } +ZEND_METHOD(Async_Coroutine, setCurrent) +{ + zend_object *coroutine = NULL; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_OBJ_OR_NULL(coroutine) + ZEND_PARSE_PARAMETERS_END(); + + /* The scheduler declares the current coroutine at each switch (in its + * suspend hook, around $fiber->resume()). One ref is held while current. */ + if (PHP_ASYNC_HANDLERS.current_coroutine != NULL) { + OBJ_RELEASE(PHP_ASYNC_HANDLERS.current_coroutine); + } + + if (coroutine != NULL) { + GC_ADDREF(coroutine); + } + + PHP_ASYNC_HANDLERS.current_coroutine = coroutine; +} + ZEND_METHOD(Async_Coroutine, resume) { zval *coroutine; diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php index 2ce94617dcb6..b965e74d6b34 100644 --- a/Zend/zend_scheduler_hook.stub.php +++ b/Zend/zend_scheduler_hook.stub.php @@ -108,16 +108,19 @@ public static function defer(callable $task): void {} /** * The coroutine currently on the stack, and the way to wake a suspended one. * - * The engine tracks the current coroutine from the object the scheduler - * returned from Async\Scheduler::interceptFiber() — there is no setter. - * resume() is a *deferred* wake: it routes the coroutine back to the - * scheduler's resume() hook to be re-queued, never an immediate fiber switch. + * The scheduler declares the current coroutine at each switch, from its + * suspend() hook around $fiber->resume(), via setCurrent(). resume() is a + * *deferred* wake: it routes the coroutine back to the scheduler's resume() + * hook to be re-queued, never an immediate fiber switch. */ final class Coroutine { /** The coroutine object running on the current stack, or null in the main flow. */ public static function current(): ?object {} + /** Declare the coroutine now running (scheduler-only, called at each switch). */ + public static function setCurrent(?object $coroutine): void {} + /** Wake a suspended coroutine: hand it to the scheduler to be run later. */ public static function resume(object $coroutine, ?\Throwable $error = null): void {} } diff --git a/Zend/zend_scheduler_hook_arginfo.h b/Zend/zend_scheduler_hook_arginfo.h index 47b3ce3e26b8..58aba9545b78 100644 --- a/Zend/zend_scheduler_hook_arginfo.h +++ b/Zend/zend_scheduler_hook_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit zend_scheduler_hook.stub.php instead. - * Stub hash: 3d9e53ed2acd53ce7f957780c7dac66eb451d7d9 */ + * Stub hash: 379bfe8a30b06680740a352a6f81538a08ddee89 */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_enqueue, 0, 1, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) @@ -84,6 +84,10 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_current, 0, 0, IS_OBJECT, 1) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_setCurrent, 0, 1, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 1) +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_resume, 0, 1, IS_VOID, 0) ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, error, Throwable, 1, "null") @@ -102,6 +106,7 @@ ZEND_METHOD(Async_SchedulerHook, register); ZEND_METHOD(Async_SchedulerHook, getModule); ZEND_METHOD(Async_SchedulerHook, defer); ZEND_METHOD(Async_Coroutine, current); +ZEND_METHOD(Async_Coroutine, setCurrent); ZEND_METHOD(Async_Coroutine, resume); static const zend_function_entry class_Async_Scheduler_methods[] = { @@ -143,6 +148,7 @@ static const zend_function_entry class_Async_SchedulerHook_methods[] = { static const zend_function_entry class_Async_Coroutine_methods[] = { ZEND_ME(Async_Coroutine, current, arginfo_class_Async_Coroutine_current, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) + ZEND_ME(Async_Coroutine, setCurrent, arginfo_class_Async_Coroutine_setCurrent, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_ME(Async_Coroutine, resume, arginfo_class_Async_Coroutine_resume, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_FE_END }; From 337e17e87262e006417a0358afa19aedcd273568 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:56:16 +0000 Subject: [PATCH 09/23] Revert "async: scheduler declares current coroutine via Async\Coroutine::setCurrent() in suspend hook (drop engine auto-tracking)" This reverts commit daa6f28af5f27ae470eb8149a8d678b6443c532c. --- .../tests/async/coroutine_current_resume.phpt | 7 ++-- Zend/zend_async_API.h | 3 -- Zend/zend_fibers.c | 7 ++++ Zend/zend_scheduler_hook.c | 34 ++++--------------- Zend/zend_scheduler_hook.stub.php | 11 +++--- Zend/zend_scheduler_hook_arginfo.h | 8 +---- 6 files changed, 21 insertions(+), 49 deletions(-) diff --git a/Zend/tests/async/coroutine_current_resume.phpt b/Zend/tests/async/coroutine_current_resume.phpt index 4de0228f34f0..ff2726310793 100644 --- a/Zend/tests/async/coroutine_current_resume.phpt +++ b/Zend/tests/async/coroutine_current_resume.phpt @@ -1,5 +1,5 @@ --TEST-- -Async\Coroutine: setCurrent() declares the running coroutine; resume() defers a wake +Async\Coroutine: current() tracks the running coroutine; resume() defers a wake --FILE-- ready->isEmpty()) { - $coroutine = $this->ready->dequeue(); - $fiber = $coroutine->fiber; - Async\Coroutine::setCurrent($coroutine); + $fiber = $this->ready->dequeue()->fiber; $fiber->isStarted() ? $fiber->resume() : $fiber->start(); - Async\Coroutine::setCurrent(null); } return true; } diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h index 29d86f812360..ac634926ca57 100644 --- a/Zend/zend_async_API.h +++ b/Zend/zend_async_API.h @@ -517,9 +517,6 @@ typedef struct { /* The registered Async\Scheduler instance; one ref held for the request. * The bound hook methods borrow this object. */ zend_object *scheduler; - /* The coroutine object the scheduler declared current via - * Async\Coroutine::setCurrent(); NULL in the main flow. */ - zend_object *current_coroutine; php_async_hook_t hooks[PHP_ASYNC_HOOK_COUNT]; } php_async_handlers_t; diff --git a/Zend/zend_fibers.c b/Zend/zend_fibers.c index 5e0654e6a34c..832d2f58650f 100644 --- a/Zend/zend_fibers.c +++ b/Zend/zend_fibers.c @@ -975,9 +975,16 @@ static void zend_fiber_scheduler_switch(zend_fiber *fiber, zval *return_value) const bool saved_context = ZEND_ASYNC_IN_SCHEDULER_CONTEXT; ZEND_ASYNC_IN_SCHEDULER_CONTEXT = false; + /* The coroutine bound to this fiber (via the scheduler's intercept-fiber + * return) is the current one for the duration of its body. Restored when it + * suspends and hands control back here. */ + zend_coroutine_t *saved_coroutine = ZEND_ASYNC_CURRENT_COROUTINE; + ZEND_ASYNC_CURRENT_COROUTINE = coroutine; + zend_fiber_transfer transfer = zend_fiber_resume_internal( fiber, Z_ISUNDEF(value) ? NULL : &value, is_error); + ZEND_ASYNC_CURRENT_COROUTINE = saved_coroutine; ZEND_ASYNC_IN_SCHEDULER_CONTEXT = saved_context; zval_ptr_dtor(&value); diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c index 1527e291babc..8c86139b3ec0 100644 --- a/Zend/zend_scheduler_hook.c +++ b/Zend/zend_scheduler_hook.c @@ -371,11 +371,6 @@ static void php_async_handlers_reset(void) php_async_hook_release(PHP_ASYNC_HOOK(id)); } - if (PHP_ASYNC_HANDLERS.current_coroutine != NULL) { - OBJ_RELEASE(PHP_ASYNC_HANDLERS.current_coroutine); - PHP_ASYNC_HANDLERS.current_coroutine = NULL; - } - if (PHP_ASYNC_HANDLERS.scheduler != NULL) { OBJ_RELEASE(PHP_ASYNC_HANDLERS.scheduler); PHP_ASYNC_HANDLERS.scheduler = NULL; @@ -592,35 +587,20 @@ ZEND_METHOD(Async_Coroutine, current) { ZEND_PARSE_PARAMETERS_NONE(); - zend_object *object = PHP_ASYNC_HANDLERS.current_coroutine; + zend_coroutine_t *coro = ZEND_ASYNC_CURRENT_COROUTINE; - if (object == NULL) { + if (coro == NULL) { RETURN_NULL(); } - GC_ADDREF(object); - RETURN_OBJ(object); -} - -ZEND_METHOD(Async_Coroutine, setCurrent) -{ - zend_object *coroutine = NULL; - - ZEND_PARSE_PARAMETERS_START(1, 1) - Z_PARAM_OBJ_OR_NULL(coroutine) - ZEND_PARSE_PARAMETERS_END(); - - /* The scheduler declares the current coroutine at each switch (in its - * suspend hook, around $fiber->resume()). One ref is held while current. */ - if (PHP_ASYNC_HANDLERS.current_coroutine != NULL) { - OBJ_RELEASE(PHP_ASYNC_HANDLERS.current_coroutine); - } + zend_object *object = ZEND_COROUTINE_OBJECT(coro); - if (coroutine != NULL) { - GC_ADDREF(coroutine); + if (object == NULL) { + RETURN_NULL(); } - PHP_ASYNC_HANDLERS.current_coroutine = coroutine; + GC_ADDREF(object); + RETURN_OBJ(object); } ZEND_METHOD(Async_Coroutine, resume) diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php index b965e74d6b34..2ce94617dcb6 100644 --- a/Zend/zend_scheduler_hook.stub.php +++ b/Zend/zend_scheduler_hook.stub.php @@ -108,19 +108,16 @@ public static function defer(callable $task): void {} /** * The coroutine currently on the stack, and the way to wake a suspended one. * - * The scheduler declares the current coroutine at each switch, from its - * suspend() hook around $fiber->resume(), via setCurrent(). resume() is a - * *deferred* wake: it routes the coroutine back to the scheduler's resume() - * hook to be re-queued, never an immediate fiber switch. + * The engine tracks the current coroutine from the object the scheduler + * returned from Async\Scheduler::interceptFiber() — there is no setter. + * resume() is a *deferred* wake: it routes the coroutine back to the + * scheduler's resume() hook to be re-queued, never an immediate fiber switch. */ final class Coroutine { /** The coroutine object running on the current stack, or null in the main flow. */ public static function current(): ?object {} - /** Declare the coroutine now running (scheduler-only, called at each switch). */ - public static function setCurrent(?object $coroutine): void {} - /** Wake a suspended coroutine: hand it to the scheduler to be run later. */ public static function resume(object $coroutine, ?\Throwable $error = null): void {} } diff --git a/Zend/zend_scheduler_hook_arginfo.h b/Zend/zend_scheduler_hook_arginfo.h index 58aba9545b78..47b3ce3e26b8 100644 --- a/Zend/zend_scheduler_hook_arginfo.h +++ b/Zend/zend_scheduler_hook_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit zend_scheduler_hook.stub.php instead. - * Stub hash: 379bfe8a30b06680740a352a6f81538a08ddee89 */ + * Stub hash: 3d9e53ed2acd53ce7f957780c7dac66eb451d7d9 */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_enqueue, 0, 1, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) @@ -84,10 +84,6 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_current, 0, 0, IS_OBJECT, 1) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_setCurrent, 0, 1, IS_VOID, 0) - ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 1) -ZEND_END_ARG_INFO() - ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_resume, 0, 1, IS_VOID, 0) ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, error, Throwable, 1, "null") @@ -106,7 +102,6 @@ ZEND_METHOD(Async_SchedulerHook, register); ZEND_METHOD(Async_SchedulerHook, getModule); ZEND_METHOD(Async_SchedulerHook, defer); ZEND_METHOD(Async_Coroutine, current); -ZEND_METHOD(Async_Coroutine, setCurrent); ZEND_METHOD(Async_Coroutine, resume); static const zend_function_entry class_Async_Scheduler_methods[] = { @@ -148,7 +143,6 @@ static const zend_function_entry class_Async_SchedulerHook_methods[] = { static const zend_function_entry class_Async_Coroutine_methods[] = { ZEND_ME(Async_Coroutine, current, arginfo_class_Async_Coroutine_current, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) - ZEND_ME(Async_Coroutine, setCurrent, arginfo_class_Async_Coroutine_setCurrent, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_ME(Async_Coroutine, resume, arginfo_class_Async_Coroutine_resume, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_FE_END }; From 1bfc9f4e9c7b5c4bdf558792595d2b6a9cd82a72 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:26:44 +0000 Subject: [PATCH 10/23] Reapply "async: scheduler declares current coroutine via Async\Coroutine::setCurrent() in suspend hook (drop engine auto-tracking)" This reverts commit 337e17e87262e006417a0358afa19aedcd273568. --- .../tests/async/coroutine_current_resume.phpt | 7 ++-- Zend/zend_async_API.h | 3 ++ Zend/zend_fibers.c | 7 ---- Zend/zend_scheduler_hook.c | 34 +++++++++++++++---- Zend/zend_scheduler_hook.stub.php | 11 +++--- Zend/zend_scheduler_hook_arginfo.h | 8 ++++- 6 files changed, 49 insertions(+), 21 deletions(-) diff --git a/Zend/tests/async/coroutine_current_resume.phpt b/Zend/tests/async/coroutine_current_resume.phpt index ff2726310793..4de0228f34f0 100644 --- a/Zend/tests/async/coroutine_current_resume.phpt +++ b/Zend/tests/async/coroutine_current_resume.phpt @@ -1,5 +1,5 @@ --TEST-- -Async\Coroutine: current() tracks the running coroutine; resume() defers a wake +Async\Coroutine: setCurrent() declares the running coroutine; resume() defers a wake --FILE-- ready->isEmpty()) { - $fiber = $this->ready->dequeue()->fiber; + $coroutine = $this->ready->dequeue(); + $fiber = $coroutine->fiber; + Async\Coroutine::setCurrent($coroutine); $fiber->isStarted() ? $fiber->resume() : $fiber->start(); + Async\Coroutine::setCurrent(null); } return true; } diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h index ac634926ca57..29d86f812360 100644 --- a/Zend/zend_async_API.h +++ b/Zend/zend_async_API.h @@ -517,6 +517,9 @@ typedef struct { /* The registered Async\Scheduler instance; one ref held for the request. * The bound hook methods borrow this object. */ zend_object *scheduler; + /* The coroutine object the scheduler declared current via + * Async\Coroutine::setCurrent(); NULL in the main flow. */ + zend_object *current_coroutine; php_async_hook_t hooks[PHP_ASYNC_HOOK_COUNT]; } php_async_handlers_t; diff --git a/Zend/zend_fibers.c b/Zend/zend_fibers.c index 832d2f58650f..5e0654e6a34c 100644 --- a/Zend/zend_fibers.c +++ b/Zend/zend_fibers.c @@ -975,16 +975,9 @@ static void zend_fiber_scheduler_switch(zend_fiber *fiber, zval *return_value) const bool saved_context = ZEND_ASYNC_IN_SCHEDULER_CONTEXT; ZEND_ASYNC_IN_SCHEDULER_CONTEXT = false; - /* The coroutine bound to this fiber (via the scheduler's intercept-fiber - * return) is the current one for the duration of its body. Restored when it - * suspends and hands control back here. */ - zend_coroutine_t *saved_coroutine = ZEND_ASYNC_CURRENT_COROUTINE; - ZEND_ASYNC_CURRENT_COROUTINE = coroutine; - zend_fiber_transfer transfer = zend_fiber_resume_internal( fiber, Z_ISUNDEF(value) ? NULL : &value, is_error); - ZEND_ASYNC_CURRENT_COROUTINE = saved_coroutine; ZEND_ASYNC_IN_SCHEDULER_CONTEXT = saved_context; zval_ptr_dtor(&value); diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c index 8c86139b3ec0..1527e291babc 100644 --- a/Zend/zend_scheduler_hook.c +++ b/Zend/zend_scheduler_hook.c @@ -371,6 +371,11 @@ static void php_async_handlers_reset(void) php_async_hook_release(PHP_ASYNC_HOOK(id)); } + if (PHP_ASYNC_HANDLERS.current_coroutine != NULL) { + OBJ_RELEASE(PHP_ASYNC_HANDLERS.current_coroutine); + PHP_ASYNC_HANDLERS.current_coroutine = NULL; + } + if (PHP_ASYNC_HANDLERS.scheduler != NULL) { OBJ_RELEASE(PHP_ASYNC_HANDLERS.scheduler); PHP_ASYNC_HANDLERS.scheduler = NULL; @@ -587,13 +592,7 @@ ZEND_METHOD(Async_Coroutine, current) { ZEND_PARSE_PARAMETERS_NONE(); - zend_coroutine_t *coro = ZEND_ASYNC_CURRENT_COROUTINE; - - if (coro == NULL) { - RETURN_NULL(); - } - - zend_object *object = ZEND_COROUTINE_OBJECT(coro); + zend_object *object = PHP_ASYNC_HANDLERS.current_coroutine; if (object == NULL) { RETURN_NULL(); @@ -603,6 +602,27 @@ ZEND_METHOD(Async_Coroutine, current) RETURN_OBJ(object); } +ZEND_METHOD(Async_Coroutine, setCurrent) +{ + zend_object *coroutine = NULL; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_OBJ_OR_NULL(coroutine) + ZEND_PARSE_PARAMETERS_END(); + + /* The scheduler declares the current coroutine at each switch (in its + * suspend hook, around $fiber->resume()). One ref is held while current. */ + if (PHP_ASYNC_HANDLERS.current_coroutine != NULL) { + OBJ_RELEASE(PHP_ASYNC_HANDLERS.current_coroutine); + } + + if (coroutine != NULL) { + GC_ADDREF(coroutine); + } + + PHP_ASYNC_HANDLERS.current_coroutine = coroutine; +} + ZEND_METHOD(Async_Coroutine, resume) { zval *coroutine; diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php index 2ce94617dcb6..b965e74d6b34 100644 --- a/Zend/zend_scheduler_hook.stub.php +++ b/Zend/zend_scheduler_hook.stub.php @@ -108,16 +108,19 @@ public static function defer(callable $task): void {} /** * The coroutine currently on the stack, and the way to wake a suspended one. * - * The engine tracks the current coroutine from the object the scheduler - * returned from Async\Scheduler::interceptFiber() — there is no setter. - * resume() is a *deferred* wake: it routes the coroutine back to the - * scheduler's resume() hook to be re-queued, never an immediate fiber switch. + * The scheduler declares the current coroutine at each switch, from its + * suspend() hook around $fiber->resume(), via setCurrent(). resume() is a + * *deferred* wake: it routes the coroutine back to the scheduler's resume() + * hook to be re-queued, never an immediate fiber switch. */ final class Coroutine { /** The coroutine object running on the current stack, or null in the main flow. */ public static function current(): ?object {} + /** Declare the coroutine now running (scheduler-only, called at each switch). */ + public static function setCurrent(?object $coroutine): void {} + /** Wake a suspended coroutine: hand it to the scheduler to be run later. */ public static function resume(object $coroutine, ?\Throwable $error = null): void {} } diff --git a/Zend/zend_scheduler_hook_arginfo.h b/Zend/zend_scheduler_hook_arginfo.h index 47b3ce3e26b8..58aba9545b78 100644 --- a/Zend/zend_scheduler_hook_arginfo.h +++ b/Zend/zend_scheduler_hook_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit zend_scheduler_hook.stub.php instead. - * Stub hash: 3d9e53ed2acd53ce7f957780c7dac66eb451d7d9 */ + * Stub hash: 379bfe8a30b06680740a352a6f81538a08ddee89 */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_enqueue, 0, 1, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) @@ -84,6 +84,10 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_current, 0, 0, IS_OBJECT, 1) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_setCurrent, 0, 1, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 1) +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_resume, 0, 1, IS_VOID, 0) ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, error, Throwable, 1, "null") @@ -102,6 +106,7 @@ ZEND_METHOD(Async_SchedulerHook, register); ZEND_METHOD(Async_SchedulerHook, getModule); ZEND_METHOD(Async_SchedulerHook, defer); ZEND_METHOD(Async_Coroutine, current); +ZEND_METHOD(Async_Coroutine, setCurrent); ZEND_METHOD(Async_Coroutine, resume); static const zend_function_entry class_Async_Scheduler_methods[] = { @@ -143,6 +148,7 @@ static const zend_function_entry class_Async_SchedulerHook_methods[] = { static const zend_function_entry class_Async_Coroutine_methods[] = { ZEND_ME(Async_Coroutine, current, arginfo_class_Async_Coroutine_current, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) + ZEND_ME(Async_Coroutine, setCurrent, arginfo_class_Async_Coroutine_setCurrent, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_ME(Async_Coroutine, resume, arginfo_class_Async_Coroutine_resume, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_FE_END }; From 2dbae87382a9fba825285c41d85caf2e9f443d50 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:40:57 +0000 Subject: [PATCH 11/23] WIP async: suspend hook returns the current coroutine (engine records return); drop setCurrent --- Zend/zend_scheduler_hook.c | 43 ++++++++++++++---------------- Zend/zend_scheduler_hook.stub.php | 21 ++++++++------- Zend/zend_scheduler_hook_arginfo.h | 10 ++----- 3 files changed, 33 insertions(+), 41 deletions(-) diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c index 1527e291babc..b349c3ea891e 100644 --- a/Zend/zend_scheduler_hook.c +++ b/Zend/zend_scheduler_hook.c @@ -244,11 +244,29 @@ static bool php_async_thunk_enqueue(zend_coroutine_t *coro) static bool php_async_thunk_suspend(bool from_main, bool is_bailout) { - zval args[2]; + zval args[2], retval; ZVAL_BOOL(&args[0], from_main); ZVAL_BOOL(&args[1], is_bailout); - return php_async_hook_call_bool(PHP_ASYNC_HOOK_SUSPEND, 2, args); + const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_SUSPEND), 2, args, &retval); + + /* The suspend hook returns the coroutine that is now current (or null). + * That return value IS how the scheduler tells the engine which coroutine + * is current — fiber-agnostic, no separate setter. */ + zend_object *current = (ok && Z_TYPE(retval) == IS_OBJECT) ? Z_OBJ(retval) : NULL; + + if (current != NULL) { + GC_ADDREF(current); + } + + if (PHP_ASYNC_HANDLERS.current_coroutine != NULL) { + OBJ_RELEASE(PHP_ASYNC_HANDLERS.current_coroutine); + } + + PHP_ASYNC_HANDLERS.current_coroutine = current; + + zval_ptr_dtor(&retval); + return ok; } /* Shared body for resume/cancel: (coroutine, ?error). transfer_error hands @@ -602,27 +620,6 @@ ZEND_METHOD(Async_Coroutine, current) RETURN_OBJ(object); } -ZEND_METHOD(Async_Coroutine, setCurrent) -{ - zend_object *coroutine = NULL; - - ZEND_PARSE_PARAMETERS_START(1, 1) - Z_PARAM_OBJ_OR_NULL(coroutine) - ZEND_PARSE_PARAMETERS_END(); - - /* The scheduler declares the current coroutine at each switch (in its - * suspend hook, around $fiber->resume()). One ref is held while current. */ - if (PHP_ASYNC_HANDLERS.current_coroutine != NULL) { - OBJ_RELEASE(PHP_ASYNC_HANDLERS.current_coroutine); - } - - if (coroutine != NULL) { - GC_ADDREF(coroutine); - } - - PHP_ASYNC_HANDLERS.current_coroutine = coroutine; -} - ZEND_METHOD(Async_Coroutine, resume) { zval *coroutine; diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php index b965e74d6b34..2a3b48656676 100644 --- a/Zend/zend_scheduler_hook.stub.php +++ b/Zend/zend_scheduler_hook.stub.php @@ -16,8 +16,12 @@ interface Scheduler /** A coroutine became runnable (freshly created, or its wait ended). */ public function enqueue(object $coroutine): bool; - /** The current flow yields; pick who runs next and switch to them. */ - public function suspend(bool $fromMain, bool $isBailout): bool; + /** + * The current flow yields; pick who runs next and switch to them. Return + * the coroutine that is now current (or null) — the engine records it as + * Async\Coroutine::current(). + */ + public function suspend(bool $fromMain, bool $isBailout): ?object; /** A fiber is starting: return a coroutine object to adopt it, or null. */ public function interceptFiber(\Fiber $fiber): ?object; @@ -56,7 +60,7 @@ abstract class AbstractScheduler implements Scheduler { abstract public function enqueue(object $coroutine): bool; - abstract public function suspend(bool $fromMain, bool $isBailout): bool; + abstract public function suspend(bool $fromMain, bool $isBailout): ?object; public function interceptFiber(\Fiber $fiber): ?object {} @@ -108,19 +112,16 @@ public static function defer(callable $task): void {} /** * The coroutine currently on the stack, and the way to wake a suspended one. * - * The scheduler declares the current coroutine at each switch, from its - * suspend() hook around $fiber->resume(), via setCurrent(). resume() is a - * *deferred* wake: it routes the coroutine back to the scheduler's resume() + * The current coroutine is whatever the scheduler's suspend() hook returned: + * the engine records the hook's return value. There is no setter. resume() is + * a *deferred* wake: it routes the coroutine back to the scheduler's resume() * hook to be re-queued, never an immediate fiber switch. */ final class Coroutine { - /** The coroutine object running on the current stack, or null in the main flow. */ + /** The coroutine object the suspend() hook last returned, or null. */ public static function current(): ?object {} - /** Declare the coroutine now running (scheduler-only, called at each switch). */ - public static function setCurrent(?object $coroutine): void {} - /** Wake a suspended coroutine: hand it to the scheduler to be run later. */ public static function resume(object $coroutine, ?\Throwable $error = null): void {} } diff --git a/Zend/zend_scheduler_hook_arginfo.h b/Zend/zend_scheduler_hook_arginfo.h index 58aba9545b78..94ed812abe3e 100644 --- a/Zend/zend_scheduler_hook_arginfo.h +++ b/Zend/zend_scheduler_hook_arginfo.h @@ -1,11 +1,11 @@ /* This is a generated file, edit zend_scheduler_hook.stub.php instead. - * Stub hash: 379bfe8a30b06680740a352a6f81538a08ddee89 */ + * Stub hash: 40a755bbc223f455ed7c3946fe4f2a3cfc1687d5 */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_enqueue, 0, 1, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_suspend, 0, 2, _IS_BOOL, 0) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_suspend, 0, 2, IS_OBJECT, 1) ZEND_ARG_TYPE_INFO(0, fromMain, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, isBailout, _IS_BOOL, 0) ZEND_END_ARG_INFO() @@ -84,10 +84,6 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_current, 0, 0, IS_OBJECT, 1) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_setCurrent, 0, 1, IS_VOID, 0) - ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 1) -ZEND_END_ARG_INFO() - ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_resume, 0, 1, IS_VOID, 0) ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, error, Throwable, 1, "null") @@ -106,7 +102,6 @@ ZEND_METHOD(Async_SchedulerHook, register); ZEND_METHOD(Async_SchedulerHook, getModule); ZEND_METHOD(Async_SchedulerHook, defer); ZEND_METHOD(Async_Coroutine, current); -ZEND_METHOD(Async_Coroutine, setCurrent); ZEND_METHOD(Async_Coroutine, resume); static const zend_function_entry class_Async_Scheduler_methods[] = { @@ -148,7 +143,6 @@ static const zend_function_entry class_Async_SchedulerHook_methods[] = { static const zend_function_entry class_Async_Coroutine_methods[] = { ZEND_ME(Async_Coroutine, current, arginfo_class_Async_Coroutine_current, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) - ZEND_ME(Async_Coroutine, setCurrent, arginfo_class_Async_Coroutine_setCurrent, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_ME(Async_Coroutine, resume, arginfo_class_Async_Coroutine_resume, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_FE_END }; From f07dd09b54a16054ea1439b162941ee4b31d6a56 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:52:32 +0000 Subject: [PATCH 12/23] tests: async suite for suspend() returning the current coroutine (13/13) --- .../tests/async/coroutine_current_resume.phpt | 72 +++++++++---------- Zend/tests/async/fiber_lowlevel_null.phpt | 4 +- .../tests/async/fiber_managed_after_main.phpt | 6 +- Zend/tests/async/fiber_managed_basic.phpt | 6 +- Zend/tests/async/fiber_managed_throw.phpt | 6 +- Zend/tests/async/fiber_managed_uncaught.phpt | 6 +- Zend/tests/async/microtasks_basic.phpt | 2 +- Zend/tests/async/scheduler_context.phpt | 6 +- Zend/tests/async/scheduler_hook_launch.phpt | 2 +- Zend/tests/async/scheduler_hook_override.phpt | 2 +- Zend/tests/async/scheduler_hook_register.phpt | 2 +- 11 files changed, 55 insertions(+), 59 deletions(-) diff --git a/Zend/tests/async/coroutine_current_resume.phpt b/Zend/tests/async/coroutine_current_resume.phpt index 4de0228f34f0..6b7bb0b57b5c 100644 --- a/Zend/tests/async/coroutine_current_resume.phpt +++ b/Zend/tests/async/coroutine_current_resume.phpt @@ -1,60 +1,56 @@ --TEST-- -Async\Coroutine: setCurrent() declares the running coroutine; resume() defers a wake +Async\Coroutine::current() is whatever suspend() returned; resume() defers a wake --FILE-- ready = new SplQueue(); } +$scheduler = new class extends Async\AbstractScheduler { + public ?object $coroutine = null; + public int $resumeHookCalls = 0; + public function interceptFiber(Fiber $fiber): ?object { return new class($fiber) { public function __construct(public readonly Fiber $fiber) {} }; } public function enqueue(object $coroutine): bool { - $this->ready->enqueue($coroutine); + $this->coroutine = $coroutine; return true; } - public function suspend(bool $fromMain, bool $isBailout): bool { - if (!$fromMain) { - return true; - } - while (!$this->ready->isEmpty()) { - $coroutine = $this->ready->dequeue(); - $fiber = $coroutine->fiber; - Async\Coroutine::setCurrent($coroutine); - $fiber->isStarted() ? $fiber->resume() : $fiber->start(); - Async\Coroutine::setCurrent(null); - } + public function resume(object $coroutine, ?Throwable $error = null): bool { + $this->resumeHookCalls++; return true; } -}); + public function suspend(bool $fromMain, bool $isBailout): ?object { + if ($this->coroutine !== null) { + $fiber = $this->coroutine->fiber; + if (!$fiber->isStarted()) { + $fiber->start(); + } elseif ($fiber->isSuspended()) { + $fiber->resume(); + } + } + // Report the coroutine we switched to as the current one. + return $this->coroutine; + } +}; -// No coroutine is current in the main flow. -var_dump(Async\Coroutine::current()); +Async\SchedulerHook::register('test', $scheduler); -$waiter = null; +// No coroutine is current in the main flow yet. +var_dump(Async\Coroutine::current()); -$w = new Fiber(function () use (&$waiter): void { - $waiter = Async\Coroutine::current(); // the running coroutine object - echo "waiter: is-coroutine=", var_export($waiter !== null, true), "\n"; - Fiber::suspend(); // await - echo "waiter: resumed\n"; +$fiber = new Fiber(function (): void { + Fiber::suspend(); }); +$fiber->start(); // intercept + enqueue + suspend(); suspend() returns the coroutine -$r = new Fiber(function () use (&$waiter): void { - echo "resolver: resume waiter\n"; - Async\Coroutine::resume($waiter); // deferred wake (no immediate switch) - echo "resolver: done\n"; -}); +// The engine recorded suspend()'s return value as the current coroutine. +var_dump(Async\Coroutine::current() === $scheduler->coroutine); -$w->start(); -$r->start(); -echo "main: end\n"; +// resume() is a deferred wake: it routes to the scheduler's resume() hook. +Async\Coroutine::resume($scheduler->coroutine); +var_dump($scheduler->resumeHookCalls); ?> --EXPECT-- NULL -main: end -waiter: is-coroutine=true -resolver: resume waiter -resolver: done -waiter: resumed +bool(true) +int(1) diff --git a/Zend/tests/async/fiber_lowlevel_null.phpt b/Zend/tests/async/fiber_lowlevel_null.phpt index 9743abcffd76..11737d5fed3a 100644 --- a/Zend/tests/async/fiber_lowlevel_null.phpt +++ b/Zend/tests/async/fiber_lowlevel_null.phpt @@ -8,11 +8,11 @@ Async\SchedulerHook::register('test', new class extends Async\AbstractScheduler return null; } public function enqueue(object $coroutine): bool { return true; } - public function suspend(bool $fromMain, bool $isBailout): bool { + public function suspend(bool $fromMain, bool $isBailout): ?object { // Never called for the low-level fiber itself; the engine invokes it // for the after-main handover (script end, then after destructors). echo "suspend(fromMain: ", var_export($fromMain, true), ")\n"; - return true; + return null; } }); diff --git a/Zend/tests/async/fiber_managed_after_main.phpt b/Zend/tests/async/fiber_managed_after_main.phpt index c5ea11ad5b97..c45d30274016 100644 --- a/Zend/tests/async/fiber_managed_after_main.phpt +++ b/Zend/tests/async/fiber_managed_after_main.phpt @@ -14,16 +14,16 @@ Async\SchedulerHook::register('test', new class extends Async\AbstractScheduler $this->queue->enqueue($coroutine); return true; } - public function suspend(bool $fromMain, bool $isBailout): bool { + public function suspend(bool $fromMain, bool $isBailout): ?object { // This scheduler defers everything: nothing runs until after main. if (!$fromMain) { - return true; + return null; } while (!$this->queue->isEmpty()) { $fiber = $this->queue->dequeue()->fiber; $fiber->isStarted() ? $fiber->resume() : $fiber->start(); } - return true; + return null; } }); diff --git a/Zend/tests/async/fiber_managed_basic.phpt b/Zend/tests/async/fiber_managed_basic.phpt index e949129301fe..eabf1565da95 100644 --- a/Zend/tests/async/fiber_managed_basic.phpt +++ b/Zend/tests/async/fiber_managed_basic.phpt @@ -22,16 +22,16 @@ $scheduler = new class extends Async\AbstractScheduler { $this->queue->enqueue($coroutine); return true; } - public function suspend(bool $fromMain, bool $isBailout): bool { + public function suspend(bool $fromMain, bool $isBailout): ?object { $this->log[] = 'suspend'; while (!$this->queue->isEmpty()) { $fiber = $this->queue->dequeue()->fiber; $fiber->isStarted() ? $fiber->resume() : $fiber->start(); if (!$fromMain) { - return true; + return null; } } - return true; + return null; } }; diff --git a/Zend/tests/async/fiber_managed_throw.phpt b/Zend/tests/async/fiber_managed_throw.phpt index b43c5581fbda..0f108a5247c4 100644 --- a/Zend/tests/async/fiber_managed_throw.phpt +++ b/Zend/tests/async/fiber_managed_throw.phpt @@ -19,15 +19,15 @@ Async\SchedulerHook::register('test', new class extends Async\AbstractScheduler $this->queue->enqueue($coroutine); return true; } - public function suspend(bool $fromMain, bool $isBailout): bool { + public function suspend(bool $fromMain, bool $isBailout): ?object { while (!$this->queue->isEmpty()) { $fiber = $this->queue->dequeue()->fiber; $fiber->isStarted() ? $fiber->resume() : $fiber->start(); if (!$fromMain) { - return true; + return null; } } - return true; + return null; } }); diff --git a/Zend/tests/async/fiber_managed_uncaught.phpt b/Zend/tests/async/fiber_managed_uncaught.phpt index 7c1a7940cfab..f5d6d7049c52 100644 --- a/Zend/tests/async/fiber_managed_uncaught.phpt +++ b/Zend/tests/async/fiber_managed_uncaught.phpt @@ -14,15 +14,15 @@ Async\SchedulerHook::register('test', new class extends Async\AbstractScheduler $this->queue->enqueue($coroutine); return true; } - public function suspend(bool $fromMain, bool $isBailout): bool { + public function suspend(bool $fromMain, bool $isBailout): ?object { while (!$this->queue->isEmpty()) { $fiber = $this->queue->dequeue()->fiber; $fiber->isStarted() ? $fiber->resume() : $fiber->start(); if (!$fromMain) { - return true; + return null; } } - return true; + return null; } }); diff --git a/Zend/tests/async/microtasks_basic.phpt b/Zend/tests/async/microtasks_basic.phpt index 3dbd596725d8..34384d5795c2 100644 --- a/Zend/tests/async/microtasks_basic.phpt +++ b/Zend/tests/async/microtasks_basic.phpt @@ -6,7 +6,7 @@ $scheduler = new class extends Async\AbstractScheduler { public SplQueue $tasks; public function __construct() { $this->tasks = new SplQueue(); } public function enqueue(object $coroutine): bool { return true; } - public function suspend(bool $fromMain, bool $isBailout): bool { return true; } + public function suspend(bool $fromMain, bool $isBailout): ?object { return null; } public function defer(callable $task): bool { // The queue is owned by the scheduler, not by the engine. $this->tasks->enqueue($task); diff --git a/Zend/tests/async/scheduler_context.phpt b/Zend/tests/async/scheduler_context.phpt index b5eb0ebab873..7a830876d015 100644 --- a/Zend/tests/async/scheduler_context.phpt +++ b/Zend/tests/async/scheduler_context.phpt @@ -21,7 +21,7 @@ $scheduler = new class extends Async\AbstractScheduler { $this->queue->enqueue($coroutine); return true; } - public function suspend(bool $fromMain, bool $isBailout): bool { + public function suspend(bool $fromMain, bool $isBailout): ?object { while (!$this->queue->isEmpty()) { $fiber = $this->queue->dequeue()->fiber; @@ -29,10 +29,10 @@ $scheduler = new class extends Async\AbstractScheduler { $fiber->isStarted() ? $fiber->resume() : $fiber->start(); if (!$fromMain) { - return true; + return null; } } - return true; + return null; } }; diff --git a/Zend/tests/async/scheduler_hook_launch.phpt b/Zend/tests/async/scheduler_hook_launch.phpt index f1e73d6b14a8..5158313feec5 100644 --- a/Zend/tests/async/scheduler_hook_launch.phpt +++ b/Zend/tests/async/scheduler_hook_launch.phpt @@ -5,7 +5,7 @@ Async\SchedulerHook: launch runs immediately at PHP registration $scheduler = new class extends Async\AbstractScheduler { public bool $launched = false; public function enqueue(object $coroutine): bool { return true; } - public function suspend(bool $fromMain, bool $isBailout): bool { return true; } + public function suspend(bool $fromMain, bool $isBailout): ?object { return null; } public function launch(): bool { $this->launched = true; return true; } }; diff --git a/Zend/tests/async/scheduler_hook_override.phpt b/Zend/tests/async/scheduler_hook_override.phpt index 9a43f42320a9..c802151bd1a1 100644 --- a/Zend/tests/async/scheduler_hook_override.phpt +++ b/Zend/tests/async/scheduler_hook_override.phpt @@ -4,7 +4,7 @@ Async\SchedulerHook::register can be called only once new class extends Async\AbstractScheduler { public function enqueue(object $coroutine): bool { return true; } - public function suspend(bool $fromMain, bool $isBailout): bool { return true; } + public function suspend(bool $fromMain, bool $isBailout): ?object { return null; } }; // First registration succeeds. diff --git a/Zend/tests/async/scheduler_hook_register.phpt b/Zend/tests/async/scheduler_hook_register.phpt index 4691d91c4e8b..0d3ffb379b28 100644 --- a/Zend/tests/async/scheduler_hook_register.phpt +++ b/Zend/tests/async/scheduler_hook_register.phpt @@ -6,7 +6,7 @@ var_dump(Async\SchedulerHook::getModule()); $ok = Async\SchedulerHook::register('my-driver', new class extends Async\AbstractScheduler { public function enqueue(object $coroutine): bool { return true; } - public function suspend(bool $fromMain, bool $isBailout): bool { return true; } + public function suspend(bool $fromMain, bool $isBailout): ?object { return null; } }); var_dump($ok); From 6d08811738b0d113baa99ebc3b7b9e88658da99d Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:12:19 +0000 Subject: [PATCH 13/23] async: add getContext/getInternalContext + internalContext* to Async\Scheduler; launch(): ?object records current --- .../tests/async/scheduler_hook_constants.phpt | 2 +- Zend/tests/async/scheduler_hook_launch.phpt | 2 +- Zend/zend_scheduler_hook.c | 63 +++++++++++++++---- Zend/zend_scheduler_hook.stub.php | 36 ++++++++--- Zend/zend_scheduler_hook_arginfo.h | 59 +++++++++++++++-- 5 files changed, 133 insertions(+), 29 deletions(-) diff --git a/Zend/tests/async/scheduler_hook_constants.phpt b/Zend/tests/async/scheduler_hook_constants.phpt index 7054b4b0b0b5..967e017eb497 100644 --- a/Zend/tests/async/scheduler_hook_constants.phpt +++ b/Zend/tests/async/scheduler_hook_constants.phpt @@ -13,5 +13,5 @@ var_dump((new ReflectionClass(Async\AbstractScheduler::class))->isAbstract()); ?> --EXPECT-- bool(true) -cancel,contextFind,contextSet,contextUnset,defer,enqueue,interceptFiber,launch,resume,shutdown,suspend +cancel,contextFind,contextSet,contextUnset,defer,enqueue,getContext,getInternalContext,interceptFiber,internalContextFind,internalContextSet,internalContextUnset,launch,resume,shutdown,suspend bool(true) diff --git a/Zend/tests/async/scheduler_hook_launch.phpt b/Zend/tests/async/scheduler_hook_launch.phpt index 5158313feec5..4dfd5ab81218 100644 --- a/Zend/tests/async/scheduler_hook_launch.phpt +++ b/Zend/tests/async/scheduler_hook_launch.phpt @@ -6,7 +6,7 @@ $scheduler = new class extends Async\AbstractScheduler { public bool $launched = false; public function enqueue(object $coroutine): bool { return true; } public function suspend(bool $fromMain, bool $isBailout): ?object { return null; } - public function launch(): bool { $this->launched = true; return true; } + public function launch(): ?object { $this->launched = true; return null; } }; Async\SchedulerHook::register('test', $scheduler); diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c index b349c3ea891e..4014af0a52ab 100644 --- a/Zend/zend_scheduler_hook.c +++ b/Zend/zend_scheduler_hook.c @@ -141,9 +141,31 @@ static bool php_async_hook_call_bool(php_async_hook_id id, uint32_t argc, zval * /// Non-coroutine thunks ///////////////////////////////////////////////////////////////////// +/* Record the coroutine a hook returned (launch/suspend) as the current one. */ +static void php_async_record_current(bool ok, zval *retval) +{ + zend_object *current = (ok && Z_TYPE_P(retval) == IS_OBJECT) ? Z_OBJ_P(retval) : NULL; + + if (current != NULL) { + GC_ADDREF(current); + } + + if (PHP_ASYNC_HANDLERS.current_coroutine != NULL) { + OBJ_RELEASE(PHP_ASYNC_HANDLERS.current_coroutine); + } + + PHP_ASYNC_HANDLERS.current_coroutine = current; +} + static bool php_async_thunk_launch(void) { - return php_async_hook_call_bool(PHP_ASYNC_HOOK_LAUNCH, 0, NULL); + zval retval; + const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_LAUNCH), 0, NULL, &retval); + + php_async_record_current(ok, &retval); + + zval_ptr_dtor(&retval); + return ok; } static bool php_async_thunk_shutdown(void) @@ -253,17 +275,7 @@ static bool php_async_thunk_suspend(bool from_main, bool is_bailout) /* The suspend hook returns the coroutine that is now current (or null). * That return value IS how the scheduler tells the engine which coroutine * is current — fiber-agnostic, no separate setter. */ - zend_object *current = (ok && Z_TYPE(retval) == IS_OBJECT) ? Z_OBJ(retval) : NULL; - - if (current != NULL) { - GC_ADDREF(current); - } - - if (PHP_ASYNC_HANDLERS.current_coroutine != NULL) { - OBJ_RELEASE(PHP_ASYNC_HANDLERS.current_coroutine); - } - - PHP_ASYNC_HANDLERS.current_coroutine = current; + php_async_record_current(ok, &retval); zval_ptr_dtor(&retval); return ok; @@ -579,7 +591,7 @@ ZEND_METHOD(Async_AbstractScheduler, defer) ZEND_METHOD(Async_AbstractScheduler, launch) { - RETURN_TRUE; + RETURN_NULL(); } ZEND_METHOD(Async_AbstractScheduler, shutdown) @@ -587,6 +599,16 @@ ZEND_METHOD(Async_AbstractScheduler, shutdown) RETURN_TRUE; } +ZEND_METHOD(Async_AbstractScheduler, getContext) +{ + RETURN_NULL(); +} + +ZEND_METHOD(Async_AbstractScheduler, getInternalContext) +{ + RETURN_NULL(); +} + ZEND_METHOD(Async_AbstractScheduler, contextFind) { RETURN_NULL(); @@ -602,6 +624,21 @@ ZEND_METHOD(Async_AbstractScheduler, contextUnset) RETURN_FALSE; } +ZEND_METHOD(Async_AbstractScheduler, internalContextFind) +{ + RETURN_NULL(); +} + +ZEND_METHOD(Async_AbstractScheduler, internalContextSet) +{ + RETURN_FALSE; +} + +ZEND_METHOD(Async_AbstractScheduler, internalContextUnset) +{ + RETURN_FALSE; +} + ///////////////////////////////////////////////////////////////////// /// Async\Coroutine ///////////////////////////////////////////////////////////////////// diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php index 2a3b48656676..ee34f55bcb34 100644 --- a/Zend/zend_scheduler_hook.stub.php +++ b/Zend/zend_scheduler_hook.stub.php @@ -35,20 +35,30 @@ public function cancel(object $coroutine, ?\Throwable $error = null): bool; /** Store a one-shot microtask on the scheduler's queue. */ public function defer(callable $task): bool; - /** Invoked once when the scheduler starts. */ - public function launch(): bool; + /** + * Invoked once when the scheduler starts. Return the coroutine that is now + * current (or null) — the core records it, same as suspend(). + */ + public function launch(): ?object; /** A graceful shutdown has been requested. */ public function shutdown(): bool; - /** Look up a value in a coroutine context. */ - public function contextFind(object $context, mixed $key, bool $includeParent): mixed; + /** The coroutine's userland context (string/object keys), or null. */ + public function getContext(object $coroutine): ?object; - /** Set a value in a coroutine context. */ - public function contextSet(object $context, mixed $key, mixed $value): bool; + /** The coroutine's internal context (numeric keys, for C extensions), or null. */ + public function getInternalContext(object $coroutine): ?object; - /** Remove a value from a coroutine context. */ + /** Userland context operations (string/object keys). */ + public function contextFind(object $context, mixed $key, bool $includeParent): mixed; + public function contextSet(object $context, mixed $key, mixed $value): bool; public function contextUnset(object $context, mixed $key): bool; + + /** Internal context operations (numeric keys). */ + public function internalContextFind(object $context, int $key): mixed; + public function internalContextSet(object $context, int $key, mixed $value): bool; + public function internalContextUnset(object $context, int $key): bool; } /** @@ -70,15 +80,25 @@ public function cancel(object $coroutine, ?\Throwable $error = null): bool {} public function defer(callable $task): bool {} - public function launch(): bool {} + public function launch(): ?object {} public function shutdown(): bool {} + public function getContext(object $coroutine): ?object {} + + public function getInternalContext(object $coroutine): ?object {} + public function contextFind(object $context, mixed $key, bool $includeParent): mixed {} public function contextSet(object $context, mixed $key, mixed $value): bool {} public function contextUnset(object $context, mixed $key): bool {} + + public function internalContextFind(object $context, int $key): mixed {} + + public function internalContextSet(object $context, int $key, mixed $value): bool {} + + public function internalContextUnset(object $context, int $key): bool {} } /** diff --git a/Zend/zend_scheduler_hook_arginfo.h b/Zend/zend_scheduler_hook_arginfo.h index 94ed812abe3e..44acd0ba5e62 100644 --- a/Zend/zend_scheduler_hook_arginfo.h +++ b/Zend/zend_scheduler_hook_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit zend_scheduler_hook.stub.php instead. - * Stub hash: 40a755bbc223f455ed7c3946fe4f2a3cfc1687d5 */ + * Stub hash: c12d1457d6fb9a691ce0a623c13cae2c2e8aa33c */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_enqueue, 0, 1, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) @@ -25,10 +25,17 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_defer, 0, ZEND_ARG_TYPE_INFO(0, task, IS_CALLABLE, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_launch, 0, 0, _IS_BOOL, 0) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_launch, 0, 0, IS_OBJECT, 1) ZEND_END_ARG_INFO() -#define arginfo_class_Async_Scheduler_shutdown arginfo_class_Async_Scheduler_launch +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_shutdown, 0, 0, _IS_BOOL, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_getContext, 0, 1, IS_OBJECT, 1) + ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) +ZEND_END_ARG_INFO() + +#define arginfo_class_Async_Scheduler_getInternalContext arginfo_class_Async_Scheduler_getContext ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_contextFind, 0, 3, IS_MIXED, 0) ZEND_ARG_TYPE_INFO(0, context, IS_OBJECT, 0) @@ -47,6 +54,22 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_contextUns ZEND_ARG_TYPE_INFO(0, key, IS_MIXED, 0) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_internalContextFind, 0, 2, IS_MIXED, 0) + ZEND_ARG_TYPE_INFO(0, context, IS_OBJECT, 0) + ZEND_ARG_TYPE_INFO(0, key, IS_LONG, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_internalContextSet, 0, 3, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, context, IS_OBJECT, 0) + ZEND_ARG_TYPE_INFO(0, key, IS_LONG, 0) + ZEND_ARG_TYPE_INFO(0, value, IS_MIXED, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_internalContextUnset, 0, 2, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, context, IS_OBJECT, 0) + ZEND_ARG_TYPE_INFO(0, key, IS_LONG, 0) +ZEND_END_ARG_INFO() + #define arginfo_class_Async_AbstractScheduler_enqueue arginfo_class_Async_Scheduler_enqueue #define arginfo_class_Async_AbstractScheduler_suspend arginfo_class_Async_Scheduler_suspend @@ -61,7 +84,11 @@ ZEND_END_ARG_INFO() #define arginfo_class_Async_AbstractScheduler_launch arginfo_class_Async_Scheduler_launch -#define arginfo_class_Async_AbstractScheduler_shutdown arginfo_class_Async_Scheduler_launch +#define arginfo_class_Async_AbstractScheduler_shutdown arginfo_class_Async_Scheduler_shutdown + +#define arginfo_class_Async_AbstractScheduler_getContext arginfo_class_Async_Scheduler_getContext + +#define arginfo_class_Async_AbstractScheduler_getInternalContext arginfo_class_Async_Scheduler_getContext #define arginfo_class_Async_AbstractScheduler_contextFind arginfo_class_Async_Scheduler_contextFind @@ -69,6 +96,12 @@ ZEND_END_ARG_INFO() #define arginfo_class_Async_AbstractScheduler_contextUnset arginfo_class_Async_Scheduler_contextUnset +#define arginfo_class_Async_AbstractScheduler_internalContextFind arginfo_class_Async_Scheduler_internalContextFind + +#define arginfo_class_Async_AbstractScheduler_internalContextSet arginfo_class_Async_Scheduler_internalContextSet + +#define arginfo_class_Async_AbstractScheduler_internalContextUnset arginfo_class_Async_Scheduler_internalContextUnset + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_SchedulerHook_register, 0, 2, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, module, IS_STRING, 0) ZEND_ARG_OBJ_INFO(0, scheduler, Async\\Scheduler, 0) @@ -81,8 +114,7 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_SchedulerHook_defer, ZEND_ARG_TYPE_INFO(0, task, IS_CALLABLE, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_current, 0, 0, IS_OBJECT, 1) -ZEND_END_ARG_INFO() +#define arginfo_class_Async_Coroutine_current arginfo_class_Async_Scheduler_launch ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_resume, 0, 1, IS_VOID, 0) ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) @@ -95,9 +127,14 @@ ZEND_METHOD(Async_AbstractScheduler, cancel); ZEND_METHOD(Async_AbstractScheduler, defer); ZEND_METHOD(Async_AbstractScheduler, launch); ZEND_METHOD(Async_AbstractScheduler, shutdown); +ZEND_METHOD(Async_AbstractScheduler, getContext); +ZEND_METHOD(Async_AbstractScheduler, getInternalContext); ZEND_METHOD(Async_AbstractScheduler, contextFind); ZEND_METHOD(Async_AbstractScheduler, contextSet); ZEND_METHOD(Async_AbstractScheduler, contextUnset); +ZEND_METHOD(Async_AbstractScheduler, internalContextFind); +ZEND_METHOD(Async_AbstractScheduler, internalContextSet); +ZEND_METHOD(Async_AbstractScheduler, internalContextUnset); ZEND_METHOD(Async_SchedulerHook, register); ZEND_METHOD(Async_SchedulerHook, getModule); ZEND_METHOD(Async_SchedulerHook, defer); @@ -113,9 +150,14 @@ static const zend_function_entry class_Async_Scheduler_methods[] = { ZEND_RAW_FENTRY("defer", NULL, arginfo_class_Async_Scheduler_defer, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_RAW_FENTRY("launch", NULL, arginfo_class_Async_Scheduler_launch, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_RAW_FENTRY("shutdown", NULL, arginfo_class_Async_Scheduler_shutdown, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("getContext", NULL, arginfo_class_Async_Scheduler_getContext, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("getInternalContext", NULL, arginfo_class_Async_Scheduler_getInternalContext, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_RAW_FENTRY("contextFind", NULL, arginfo_class_Async_Scheduler_contextFind, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_RAW_FENTRY("contextSet", NULL, arginfo_class_Async_Scheduler_contextSet, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_RAW_FENTRY("contextUnset", NULL, arginfo_class_Async_Scheduler_contextUnset, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("internalContextFind", NULL, arginfo_class_Async_Scheduler_internalContextFind, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("internalContextSet", NULL, arginfo_class_Async_Scheduler_internalContextSet, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("internalContextUnset", NULL, arginfo_class_Async_Scheduler_internalContextUnset, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_FE_END }; @@ -128,9 +170,14 @@ static const zend_function_entry class_Async_AbstractScheduler_methods[] = { ZEND_ME(Async_AbstractScheduler, defer, arginfo_class_Async_AbstractScheduler_defer, ZEND_ACC_PUBLIC) ZEND_ME(Async_AbstractScheduler, launch, arginfo_class_Async_AbstractScheduler_launch, ZEND_ACC_PUBLIC) ZEND_ME(Async_AbstractScheduler, shutdown, arginfo_class_Async_AbstractScheduler_shutdown, ZEND_ACC_PUBLIC) + ZEND_ME(Async_AbstractScheduler, getContext, arginfo_class_Async_AbstractScheduler_getContext, ZEND_ACC_PUBLIC) + ZEND_ME(Async_AbstractScheduler, getInternalContext, arginfo_class_Async_AbstractScheduler_getInternalContext, ZEND_ACC_PUBLIC) ZEND_ME(Async_AbstractScheduler, contextFind, arginfo_class_Async_AbstractScheduler_contextFind, ZEND_ACC_PUBLIC) ZEND_ME(Async_AbstractScheduler, contextSet, arginfo_class_Async_AbstractScheduler_contextSet, ZEND_ACC_PUBLIC) ZEND_ME(Async_AbstractScheduler, contextUnset, arginfo_class_Async_AbstractScheduler_contextUnset, ZEND_ACC_PUBLIC) + ZEND_ME(Async_AbstractScheduler, internalContextFind, arginfo_class_Async_AbstractScheduler_internalContextFind, ZEND_ACC_PUBLIC) + ZEND_ME(Async_AbstractScheduler, internalContextSet, arginfo_class_Async_AbstractScheduler_internalContextSet, ZEND_ACC_PUBLIC) + ZEND_ME(Async_AbstractScheduler, internalContextUnset, arginfo_class_Async_AbstractScheduler_internalContextUnset, ZEND_ACC_PUBLIC) ZEND_FE_END }; From 4761a94ce3ba3a0de57aac6369fc5232c0bcb0ed Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:14:13 +0000 Subject: [PATCH 14/23] async: drop internalContext find/set/unset from Async\Scheduler (internal context ops are C-only) --- .../tests/async/scheduler_hook_constants.phpt | 2 +- Zend/zend_scheduler_hook.c | 15 --------- Zend/zend_scheduler_hook.stub.php | 11 ------- Zend/zend_scheduler_hook_arginfo.h | 33 +------------------ 4 files changed, 2 insertions(+), 59 deletions(-) diff --git a/Zend/tests/async/scheduler_hook_constants.phpt b/Zend/tests/async/scheduler_hook_constants.phpt index 967e017eb497..804e1eb3b3d7 100644 --- a/Zend/tests/async/scheduler_hook_constants.phpt +++ b/Zend/tests/async/scheduler_hook_constants.phpt @@ -13,5 +13,5 @@ var_dump((new ReflectionClass(Async\AbstractScheduler::class))->isAbstract()); ?> --EXPECT-- bool(true) -cancel,contextFind,contextSet,contextUnset,defer,enqueue,getContext,getInternalContext,interceptFiber,internalContextFind,internalContextSet,internalContextUnset,launch,resume,shutdown,suspend +cancel,contextFind,contextSet,contextUnset,defer,enqueue,getContext,getInternalContext,interceptFiber,launch,resume,shutdown,suspend bool(true) diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c index 4014af0a52ab..a3e310495bdf 100644 --- a/Zend/zend_scheduler_hook.c +++ b/Zend/zend_scheduler_hook.c @@ -624,21 +624,6 @@ ZEND_METHOD(Async_AbstractScheduler, contextUnset) RETURN_FALSE; } -ZEND_METHOD(Async_AbstractScheduler, internalContextFind) -{ - RETURN_NULL(); -} - -ZEND_METHOD(Async_AbstractScheduler, internalContextSet) -{ - RETURN_FALSE; -} - -ZEND_METHOD(Async_AbstractScheduler, internalContextUnset) -{ - RETURN_FALSE; -} - ///////////////////////////////////////////////////////////////////// /// Async\Coroutine ///////////////////////////////////////////////////////////////////// diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php index ee34f55bcb34..31f22498d0bf 100644 --- a/Zend/zend_scheduler_hook.stub.php +++ b/Zend/zend_scheduler_hook.stub.php @@ -54,11 +54,6 @@ public function getInternalContext(object $coroutine): ?object; public function contextFind(object $context, mixed $key, bool $includeParent): mixed; public function contextSet(object $context, mixed $key, mixed $value): bool; public function contextUnset(object $context, mixed $key): bool; - - /** Internal context operations (numeric keys). */ - public function internalContextFind(object $context, int $key): mixed; - public function internalContextSet(object $context, int $key, mixed $value): bool; - public function internalContextUnset(object $context, int $key): bool; } /** @@ -93,12 +88,6 @@ public function contextFind(object $context, mixed $key, bool $includeParent): m public function contextSet(object $context, mixed $key, mixed $value): bool {} public function contextUnset(object $context, mixed $key): bool {} - - public function internalContextFind(object $context, int $key): mixed {} - - public function internalContextSet(object $context, int $key, mixed $value): bool {} - - public function internalContextUnset(object $context, int $key): bool {} } /** diff --git a/Zend/zend_scheduler_hook_arginfo.h b/Zend/zend_scheduler_hook_arginfo.h index 44acd0ba5e62..e21ebf5cc4ae 100644 --- a/Zend/zend_scheduler_hook_arginfo.h +++ b/Zend/zend_scheduler_hook_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit zend_scheduler_hook.stub.php instead. - * Stub hash: c12d1457d6fb9a691ce0a623c13cae2c2e8aa33c */ + * Stub hash: e847438f6407a1989ae1068f41301457708fa2a5 */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_enqueue, 0, 1, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) @@ -54,22 +54,6 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_contextUns ZEND_ARG_TYPE_INFO(0, key, IS_MIXED, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_internalContextFind, 0, 2, IS_MIXED, 0) - ZEND_ARG_TYPE_INFO(0, context, IS_OBJECT, 0) - ZEND_ARG_TYPE_INFO(0, key, IS_LONG, 0) -ZEND_END_ARG_INFO() - -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_internalContextSet, 0, 3, _IS_BOOL, 0) - ZEND_ARG_TYPE_INFO(0, context, IS_OBJECT, 0) - ZEND_ARG_TYPE_INFO(0, key, IS_LONG, 0) - ZEND_ARG_TYPE_INFO(0, value, IS_MIXED, 0) -ZEND_END_ARG_INFO() - -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_internalContextUnset, 0, 2, _IS_BOOL, 0) - ZEND_ARG_TYPE_INFO(0, context, IS_OBJECT, 0) - ZEND_ARG_TYPE_INFO(0, key, IS_LONG, 0) -ZEND_END_ARG_INFO() - #define arginfo_class_Async_AbstractScheduler_enqueue arginfo_class_Async_Scheduler_enqueue #define arginfo_class_Async_AbstractScheduler_suspend arginfo_class_Async_Scheduler_suspend @@ -96,12 +80,6 @@ ZEND_END_ARG_INFO() #define arginfo_class_Async_AbstractScheduler_contextUnset arginfo_class_Async_Scheduler_contextUnset -#define arginfo_class_Async_AbstractScheduler_internalContextFind arginfo_class_Async_Scheduler_internalContextFind - -#define arginfo_class_Async_AbstractScheduler_internalContextSet arginfo_class_Async_Scheduler_internalContextSet - -#define arginfo_class_Async_AbstractScheduler_internalContextUnset arginfo_class_Async_Scheduler_internalContextUnset - ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_SchedulerHook_register, 0, 2, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, module, IS_STRING, 0) ZEND_ARG_OBJ_INFO(0, scheduler, Async\\Scheduler, 0) @@ -132,9 +110,6 @@ ZEND_METHOD(Async_AbstractScheduler, getInternalContext); ZEND_METHOD(Async_AbstractScheduler, contextFind); ZEND_METHOD(Async_AbstractScheduler, contextSet); ZEND_METHOD(Async_AbstractScheduler, contextUnset); -ZEND_METHOD(Async_AbstractScheduler, internalContextFind); -ZEND_METHOD(Async_AbstractScheduler, internalContextSet); -ZEND_METHOD(Async_AbstractScheduler, internalContextUnset); ZEND_METHOD(Async_SchedulerHook, register); ZEND_METHOD(Async_SchedulerHook, getModule); ZEND_METHOD(Async_SchedulerHook, defer); @@ -155,9 +130,6 @@ static const zend_function_entry class_Async_Scheduler_methods[] = { ZEND_RAW_FENTRY("contextFind", NULL, arginfo_class_Async_Scheduler_contextFind, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_RAW_FENTRY("contextSet", NULL, arginfo_class_Async_Scheduler_contextSet, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_RAW_FENTRY("contextUnset", NULL, arginfo_class_Async_Scheduler_contextUnset, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) - ZEND_RAW_FENTRY("internalContextFind", NULL, arginfo_class_Async_Scheduler_internalContextFind, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) - ZEND_RAW_FENTRY("internalContextSet", NULL, arginfo_class_Async_Scheduler_internalContextSet, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) - ZEND_RAW_FENTRY("internalContextUnset", NULL, arginfo_class_Async_Scheduler_internalContextUnset, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_FE_END }; @@ -175,9 +147,6 @@ static const zend_function_entry class_Async_AbstractScheduler_methods[] = { ZEND_ME(Async_AbstractScheduler, contextFind, arginfo_class_Async_AbstractScheduler_contextFind, ZEND_ACC_PUBLIC) ZEND_ME(Async_AbstractScheduler, contextSet, arginfo_class_Async_AbstractScheduler_contextSet, ZEND_ACC_PUBLIC) ZEND_ME(Async_AbstractScheduler, contextUnset, arginfo_class_Async_AbstractScheduler_contextUnset, ZEND_ACC_PUBLIC) - ZEND_ME(Async_AbstractScheduler, internalContextFind, arginfo_class_Async_AbstractScheduler_internalContextFind, ZEND_ACC_PUBLIC) - ZEND_ME(Async_AbstractScheduler, internalContextSet, arginfo_class_Async_AbstractScheduler_internalContextSet, ZEND_ACC_PUBLIC) - ZEND_ME(Async_AbstractScheduler, internalContextUnset, arginfo_class_Async_AbstractScheduler_internalContextUnset, ZEND_ACC_PUBLIC) ZEND_FE_END }; From 6e141c6e24688ba95ca0b7da7ecad97dfe78e02c Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:22:24 +0000 Subject: [PATCH 15/23] =?UTF-8?q?async:=20remove=20Async\Coroutine=20?= =?UTF-8?q?=E2=80=94=20coroutine=20is=20the=20scheduler's=20implementation?= =?UTF-8?q?,=20not=20core=20API=20(cancel=20stays=20a=20Scheduler=20hook)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/async/coroutine_current_resume.phpt | 56 ---------- Zend/zend_async_API.h | 3 - Zend/zend_scheduler_hook.c | 103 +----------------- Zend/zend_scheduler_hook.stub.php | 17 --- Zend/zend_scheduler_hook_arginfo.h | 27 +---- 5 files changed, 6 insertions(+), 200 deletions(-) delete mode 100644 Zend/tests/async/coroutine_current_resume.phpt diff --git a/Zend/tests/async/coroutine_current_resume.phpt b/Zend/tests/async/coroutine_current_resume.phpt deleted file mode 100644 index 6b7bb0b57b5c..000000000000 --- a/Zend/tests/async/coroutine_current_resume.phpt +++ /dev/null @@ -1,56 +0,0 @@ ---TEST-- -Async\Coroutine::current() is whatever suspend() returned; resume() defers a wake ---FILE-- -coroutine = $coroutine; - return true; - } - public function resume(object $coroutine, ?Throwable $error = null): bool { - $this->resumeHookCalls++; - return true; - } - public function suspend(bool $fromMain, bool $isBailout): ?object { - if ($this->coroutine !== null) { - $fiber = $this->coroutine->fiber; - if (!$fiber->isStarted()) { - $fiber->start(); - } elseif ($fiber->isSuspended()) { - $fiber->resume(); - } - } - // Report the coroutine we switched to as the current one. - return $this->coroutine; - } -}; - -Async\SchedulerHook::register('test', $scheduler); - -// No coroutine is current in the main flow yet. -var_dump(Async\Coroutine::current()); - -$fiber = new Fiber(function (): void { - Fiber::suspend(); -}); -$fiber->start(); // intercept + enqueue + suspend(); suspend() returns the coroutine - -// The engine recorded suspend()'s return value as the current coroutine. -var_dump(Async\Coroutine::current() === $scheduler->coroutine); - -// resume() is a deferred wake: it routes to the scheduler's resume() hook. -Async\Coroutine::resume($scheduler->coroutine); -var_dump($scheduler->resumeHookCalls); -?> ---EXPECT-- -NULL -bool(true) -int(1) diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h index 29d86f812360..ac634926ca57 100644 --- a/Zend/zend_async_API.h +++ b/Zend/zend_async_API.h @@ -517,9 +517,6 @@ typedef struct { /* The registered Async\Scheduler instance; one ref held for the request. * The bound hook methods borrow this object. */ zend_object *scheduler; - /* The coroutine object the scheduler declared current via - * Async\Coroutine::setCurrent(); NULL in the main flow. */ - zend_object *current_coroutine; php_async_hook_t hooks[PHP_ASYNC_HOOK_COUNT]; } php_async_handlers_t; diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c index a3e310495bdf..5b6d8340fad1 100644 --- a/Zend/zend_scheduler_hook.c +++ b/Zend/zend_scheduler_hook.c @@ -141,29 +141,14 @@ static bool php_async_hook_call_bool(php_async_hook_id id, uint32_t argc, zval * /// Non-coroutine thunks ///////////////////////////////////////////////////////////////////// -/* Record the coroutine a hook returned (launch/suspend) as the current one. */ -static void php_async_record_current(bool ok, zval *retval) -{ - zend_object *current = (ok && Z_TYPE_P(retval) == IS_OBJECT) ? Z_OBJ_P(retval) : NULL; - - if (current != NULL) { - GC_ADDREF(current); - } - - if (PHP_ASYNC_HANDLERS.current_coroutine != NULL) { - OBJ_RELEASE(PHP_ASYNC_HANDLERS.current_coroutine); - } - - PHP_ASYNC_HANDLERS.current_coroutine = current; -} - static bool php_async_thunk_launch(void) { zval retval; const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_LAUNCH), 0, NULL, &retval); - php_async_record_current(ok, &retval); - + /* The hook may return the coroutine it made current; the coroutine is the + * scheduler's own object, so exposing it is the scheduler's concern — the + * core does not keep it. */ zval_ptr_dtor(&retval); return ok; } @@ -272,11 +257,8 @@ static bool php_async_thunk_suspend(bool from_main, bool is_bailout) const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_SUSPEND), 2, args, &retval); - /* The suspend hook returns the coroutine that is now current (or null). - * That return value IS how the scheduler tells the engine which coroutine - * is current — fiber-agnostic, no separate setter. */ - php_async_record_current(ok, &retval); - + /* The hook may return the coroutine it switched to; that coroutine is the + * scheduler's own object, so the core does not keep it. */ zval_ptr_dtor(&retval); return ok; } @@ -401,11 +383,6 @@ static void php_async_handlers_reset(void) php_async_hook_release(PHP_ASYNC_HOOK(id)); } - if (PHP_ASYNC_HANDLERS.current_coroutine != NULL) { - OBJ_RELEASE(PHP_ASYNC_HANDLERS.current_coroutine); - PHP_ASYNC_HANDLERS.current_coroutine = NULL; - } - if (PHP_ASYNC_HANDLERS.scheduler != NULL) { OBJ_RELEASE(PHP_ASYNC_HANDLERS.scheduler); PHP_ASYNC_HANDLERS.scheduler = NULL; @@ -624,81 +601,11 @@ ZEND_METHOD(Async_AbstractScheduler, contextUnset) RETURN_FALSE; } -///////////////////////////////////////////////////////////////////// -/// Async\Coroutine -///////////////////////////////////////////////////////////////////// - -ZEND_METHOD(Async_Coroutine, current) -{ - ZEND_PARSE_PARAMETERS_NONE(); - - zend_object *object = PHP_ASYNC_HANDLERS.current_coroutine; - - if (object == NULL) { - RETURN_NULL(); - } - - GC_ADDREF(object); - RETURN_OBJ(object); -} - -ZEND_METHOD(Async_Coroutine, resume) -{ - zval *coroutine; - zend_object *error = NULL; - - ZEND_PARSE_PARAMETERS_START(1, 2) - Z_PARAM_OBJECT(coroutine) - Z_PARAM_OPTIONAL - Z_PARAM_OBJ_OF_CLASS_OR_NULL(error, zend_ce_throwable) - ZEND_PARSE_PARAMETERS_END(); - - /* A deferred wake: route the coroutine back to the scheduler's resume() - * hook to be re-queued (never an immediate fiber switch). Fall back to - * enqueue() when the scheduler does not override resume(). */ - php_async_hook_id id; - - if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_RESUME)->set) { - id = PHP_ASYNC_HOOK_RESUME; - } else if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_ENQUEUE)->set) { - id = PHP_ASYNC_HOOK_ENQUEUE; - } else { - zend_throw_error(NULL, "The registered scheduler cannot resume coroutines"); - RETURN_THROWS(); - } - - zval args[2]; - ZVAL_COPY(&args[0], coroutine); - uint32_t argc = 1; - - if (id == PHP_ASYNC_HOOK_RESUME) { - if (error != NULL) { - ZVAL_OBJ(&args[1], error); - GC_ADDREF(error); - } else { - ZVAL_NULL(&args[1]); - } - argc = 2; - } - - php_async_hook_call_bool(id, argc, args); - - zval_ptr_dtor(&args[0]); - if (argc == 2) { - zval_ptr_dtor(&args[1]); - } - - if (UNEXPECTED(EG(exception))) { - RETURN_THROWS(); - } -} - void zend_register_scheduler_hook(void) { async_ce_Scheduler = register_class_Async_Scheduler(); async_ce_AbstractScheduler = register_class_Async_AbstractScheduler(async_ce_Scheduler); register_class_Async_SchedulerHook(); - register_class_Async_Coroutine(); } void zend_scheduler_hook_request_shutdown(void) diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php index 31f22498d0bf..868cff9b41c4 100644 --- a/Zend/zend_scheduler_hook.stub.php +++ b/Zend/zend_scheduler_hook.stub.php @@ -117,20 +117,3 @@ public static function getModule(): ?string {} */ public static function defer(callable $task): void {} } - -/** - * The coroutine currently on the stack, and the way to wake a suspended one. - * - * The current coroutine is whatever the scheduler's suspend() hook returned: - * the engine records the hook's return value. There is no setter. resume() is - * a *deferred* wake: it routes the coroutine back to the scheduler's resume() - * hook to be re-queued, never an immediate fiber switch. - */ -final class Coroutine -{ - /** The coroutine object the suspend() hook last returned, or null. */ - public static function current(): ?object {} - - /** Wake a suspended coroutine: hand it to the scheduler to be run later. */ - public static function resume(object $coroutine, ?\Throwable $error = null): void {} -} diff --git a/Zend/zend_scheduler_hook_arginfo.h b/Zend/zend_scheduler_hook_arginfo.h index e21ebf5cc4ae..bf4318186b1b 100644 --- a/Zend/zend_scheduler_hook_arginfo.h +++ b/Zend/zend_scheduler_hook_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit zend_scheduler_hook.stub.php instead. - * Stub hash: e847438f6407a1989ae1068f41301457708fa2a5 */ + * Stub hash: f0c59c981f1275ab3e505bd41dfb1bfeca75c0f7 */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_enqueue, 0, 1, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) @@ -92,13 +92,6 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_SchedulerHook_defer, ZEND_ARG_TYPE_INFO(0, task, IS_CALLABLE, 0) ZEND_END_ARG_INFO() -#define arginfo_class_Async_Coroutine_current arginfo_class_Async_Scheduler_launch - -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Coroutine_resume, 0, 1, IS_VOID, 0) - ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) - ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, error, Throwable, 1, "null") -ZEND_END_ARG_INFO() - ZEND_METHOD(Async_AbstractScheduler, interceptFiber); ZEND_METHOD(Async_AbstractScheduler, resume); ZEND_METHOD(Async_AbstractScheduler, cancel); @@ -113,8 +106,6 @@ ZEND_METHOD(Async_AbstractScheduler, contextUnset); ZEND_METHOD(Async_SchedulerHook, register); ZEND_METHOD(Async_SchedulerHook, getModule); ZEND_METHOD(Async_SchedulerHook, defer); -ZEND_METHOD(Async_Coroutine, current); -ZEND_METHOD(Async_Coroutine, resume); static const zend_function_entry class_Async_Scheduler_methods[] = { ZEND_RAW_FENTRY("enqueue", NULL, arginfo_class_Async_Scheduler_enqueue, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) @@ -157,12 +148,6 @@ static const zend_function_entry class_Async_SchedulerHook_methods[] = { ZEND_FE_END }; -static const zend_function_entry class_Async_Coroutine_methods[] = { - ZEND_ME(Async_Coroutine, current, arginfo_class_Async_Coroutine_current, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) - ZEND_ME(Async_Coroutine, resume, arginfo_class_Async_Coroutine_resume, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) - ZEND_FE_END -}; - static zend_class_entry *register_class_Async_Scheduler(void) { zend_class_entry ce, *class_entry; @@ -193,13 +178,3 @@ static zend_class_entry *register_class_Async_SchedulerHook(void) return class_entry; } - -static zend_class_entry *register_class_Async_Coroutine(void) -{ - zend_class_entry ce, *class_entry; - - INIT_NS_CLASS_ENTRY(ce, "Async", "Coroutine", class_Async_Coroutine_methods); - class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL); - - return class_entry; -} From 9b6035973bca4045211e18029e0ce0a40dc54e52 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:28:44 +0000 Subject: [PATCH 16/23] async: keep engine current-coroutine slot (records suspend/launch return); only the PHP Async\Coroutine API was removed --- Zend/zend_async_API.h | 4 ++++ Zend/zend_scheduler_hook.c | 34 +++++++++++++++++++++++++++++----- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h index ac634926ca57..aea6eaddb571 100644 --- a/Zend/zend_async_API.h +++ b/Zend/zend_async_API.h @@ -517,6 +517,10 @@ typedef struct { /* The registered Async\Scheduler instance; one ref held for the request. * The bound hook methods borrow this object. */ zend_object *scheduler; + /* The coroutine object the scheduler last returned from the suspend/launch + * hook — the engine's record of the current coroutine. Not exposed as a PHP + * API (that is the scheduler's business); kept for the core. */ + zend_object *current_coroutine; php_async_hook_t hooks[PHP_ASYNC_HOOK_COUNT]; } php_async_handlers_t; diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c index 5b6d8340fad1..e77fbbd9b9e3 100644 --- a/Zend/zend_scheduler_hook.c +++ b/Zend/zend_scheduler_hook.c @@ -141,14 +141,31 @@ static bool php_async_hook_call_bool(php_async_hook_id id, uint32_t argc, zval * /// Non-coroutine thunks ///////////////////////////////////////////////////////////////////// +/* Record the coroutine a hook returned (launch/suspend) as the current one. + * The engine keeps it; it is not exposed as a PHP API (the scheduler owns how + * userland sees the coroutine). */ +static void php_async_record_current(bool ok, zval *retval) +{ + zend_object *current = (ok && Z_TYPE_P(retval) == IS_OBJECT) ? Z_OBJ_P(retval) : NULL; + + if (current != NULL) { + GC_ADDREF(current); + } + + if (PHP_ASYNC_HANDLERS.current_coroutine != NULL) { + OBJ_RELEASE(PHP_ASYNC_HANDLERS.current_coroutine); + } + + PHP_ASYNC_HANDLERS.current_coroutine = current; +} + static bool php_async_thunk_launch(void) { zval retval; const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_LAUNCH), 0, NULL, &retval); - /* The hook may return the coroutine it made current; the coroutine is the - * scheduler's own object, so exposing it is the scheduler's concern — the - * core does not keep it. */ + php_async_record_current(ok, &retval); + zval_ptr_dtor(&retval); return ok; } @@ -257,8 +274,10 @@ static bool php_async_thunk_suspend(bool from_main, bool is_bailout) const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_SUSPEND), 2, args, &retval); - /* The hook may return the coroutine it switched to; that coroutine is the - * scheduler's own object, so the core does not keep it. */ + /* The hook returns the coroutine it switched to; the engine records it as + * the current coroutine (not exposed as a PHP API). */ + php_async_record_current(ok, &retval); + zval_ptr_dtor(&retval); return ok; } @@ -383,6 +402,11 @@ static void php_async_handlers_reset(void) php_async_hook_release(PHP_ASYNC_HOOK(id)); } + if (PHP_ASYNC_HANDLERS.current_coroutine != NULL) { + OBJ_RELEASE(PHP_ASYNC_HANDLERS.current_coroutine); + PHP_ASYNC_HANDLERS.current_coroutine = NULL; + } + if (PHP_ASYNC_HANDLERS.scheduler != NULL) { OBJ_RELEASE(PHP_ASYNC_HANDLERS.scheduler); PHP_ASYNC_HANDLERS.scheduler = NULL; From 4b0b520f5bd30a6b21aa8f63a682e0d15aad15ad Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:34:34 +0000 Subject: [PATCH 17/23] =?UTF-8?q?async:=20Async\Continuation=20=E2=80=94?= =?UTF-8?q?=20symmetric=20coroutine=20switch=20(switchTo/create)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Async\Continuation::create(callable) mints a symmetric execution context backed by the engine's fiber machinery; switchTo() jumps directly into it, carrying a value in and delivering the body's return value — or its thrown exception or bailout — back to the switcher. No central pump: control goes A -> B directly. Built on the exported zend_fiber_switch_context; the entry trampoline follows zend_fiber_execute (VM-stack setup + zend_call_function) and the switch follows TrueAsync's fiber_switch_context_ex. A finished context is freed by zend_fiber_switch_context itself, so free_object only tears down one that never completed. Tests: Zend/tests/async/continuation_{switch,value_exception,symmetric_nested}.phpt. --- Zend/tests/async/continuation_switch.phpt | 26 ++ .../async/continuation_symmetric_nested.phpt | 25 ++ .../async/continuation_value_exception.phpt | 22 ++ Zend/zend_scheduler_hook.c | 247 ++++++++++++++++++ Zend/zend_scheduler_hook.stub.php | 18 ++ Zend/zend_scheduler_hook_arginfo.h | 28 +- main/main.c | 3 - 7 files changed, 365 insertions(+), 4 deletions(-) create mode 100644 Zend/tests/async/continuation_switch.phpt create mode 100644 Zend/tests/async/continuation_symmetric_nested.phpt create mode 100644 Zend/tests/async/continuation_value_exception.phpt diff --git a/Zend/tests/async/continuation_switch.phpt b/Zend/tests/async/continuation_switch.phpt new file mode 100644 index 000000000000..72adf2331d2d --- /dev/null +++ b/Zend/tests/async/continuation_switch.phpt @@ -0,0 +1,26 @@ +--TEST-- +Async\Continuation: switchTo runs the body on its own stack and returns to the switcher +--FILE-- +switchTo(); +$b->switchTo(); +echo "after\n"; +?> +--EXPECT-- +before + A + B sum=499500 +after diff --git a/Zend/tests/async/continuation_symmetric_nested.phpt b/Zend/tests/async/continuation_symmetric_nested.phpt new file mode 100644 index 000000000000..526ebfa8a4e6 --- /dev/null +++ b/Zend/tests/async/continuation_symmetric_nested.phpt @@ -0,0 +1,25 @@ +--TEST-- +Async\Continuation: a coroutine switches into another mid-run; the inner returns to the outer (symmetric) +--FILE-- + control goes back to B's switcher (A) +}); + +$a = Async\Continuation::create(function () use ($b): void { + echo " A: 1\n"; + $b->switchTo(); // A -> B mid-run; B completes -> back to A + echo " A: 2 (after B)\n"; +}); + +echo "main -> A\n"; +$a->switchTo(); // main -> A -> B(done) -> A(done) -> main +echo "main: done\n"; +?> +--EXPECT-- +main -> A + A: 1 + B: runs to completion + A: 2 (after B) +main: done diff --git a/Zend/tests/async/continuation_value_exception.phpt b/Zend/tests/async/continuation_value_exception.phpt new file mode 100644 index 000000000000..cd139c110db8 --- /dev/null +++ b/Zend/tests/async/continuation_value_exception.phpt @@ -0,0 +1,22 @@ +--TEST-- +Async\Continuation: switchTo() returns the body's value and re-raises its exception +--FILE-- + 42)->switchTo()); +var_dump(Async\Continuation::create(fn () => "hi")->switchTo()); + +try { + Async\Continuation::create(function () { + throw new RuntimeException("boom"); + })->switchTo(); +} catch (RuntimeException $e) { + echo "caught: ", $e->getMessage(), "\n"; +} + +echo "survived\n"; +?> +--EXPECT-- +int(42) +string(2) "hi" +caught: boom +survived diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c index e77fbbd9b9e3..745aee06ee38 100644 --- a/Zend/zend_scheduler_hook.c +++ b/Zend/zend_scheduler_hook.c @@ -34,6 +34,8 @@ #include "zend_fibers.h" #include "zend_exceptions.h" #include "zend_closures.h" +#include "zend_execute.h" +#include "zend_ini.h" /* The Async\Scheduler method name for each hook (lower-cased, as stored in the * class function table), indexed by php_async_hook_id. */ @@ -625,11 +627,256 @@ ZEND_METHOD(Async_AbstractScheduler, contextUnset) RETURN_FALSE; } +///////////////////////////////////////////////////////////////////// +/// Async\Continuation — symmetric execution context +///////////////////////////////////////////////////////////////////// + +static zend_class_entry *async_ce_Continuation; +static zend_object_handlers async_continuation_handlers; + +/* Root frame for a continuation's Zend call stack. */ +static zend_function async_continuation_root_function; + +typedef struct { + zend_fiber_context *ctx; /* NULL until create() */ + zend_fcall_info fci; + zend_fcall_info_cache fcc; + zval result; + zend_vm_stack vm_stack; /* captured when the body returns */ + zend_object std; +} async_continuation_t; + +static zend_always_inline async_continuation_t *continuation_from_obj(zend_object *obj) +{ + return (async_continuation_t *) ((char *) obj - offsetof(async_continuation_t, std)); +} + +/* Set by the switcher before a first entry: a fresh context has no other way to + * reach its own object. */ +static async_continuation_t *async_continuation_starting = NULL; + +/* First-run trampoline: install a VM stack, run the body, return to the switcher. */ +static ZEND_STACK_ALIGNED void async_continuation_entry(zend_fiber_transfer *transfer) +{ + async_continuation_t *co = async_continuation_starting; + zend_fiber_context *caller = transfer->context; + + transfer->context = NULL; + + /* A first entry carries no resume value. */ + zval_ptr_dtor(&transfer->value); + ZVAL_UNDEF(&transfer->value); + transfer->flags = 0; + + zend_long error_reporting = zend_ini_long_literal("error_reporting"); + if (!error_reporting && !zend_ini_str_literal("error_reporting")) { + error_reporting = E_ALL; + } + + EG(vm_stack) = NULL; + + zend_first_try { + zend_vm_stack stack = zend_vm_stack_new_page(ZEND_FIBER_VM_STACK_SIZE, NULL); + EG(vm_stack) = stack; + EG(vm_stack_top) = stack->top + ZEND_CALL_FRAME_SLOT; + EG(vm_stack_end) = stack->end; + EG(vm_stack_page_size) = ZEND_FIBER_VM_STACK_SIZE; + + zend_execute_data *execute_data = (zend_execute_data *) stack->top; + memset(execute_data, 0, sizeof(zend_execute_data)); + execute_data->func = &async_continuation_root_function; + execute_data->prev_execute_data = EG(current_execute_data); + + EG(current_execute_data) = execute_data; + EG(jit_trace_num) = 0; + EG(error_reporting) = (int) error_reporting; + +#ifdef ZEND_CHECK_STACK_LIMIT + EG(stack_base) = zend_fiber_stack_base(co->ctx->stack); + EG(stack_limit) = zend_fiber_stack_limit(co->ctx->stack); +#endif + + co->fci.retval = &co->result; + zend_call_function(&co->fci, &co->fcc); + + zval_ptr_dtor(&co->fci.function_name); + ZVAL_UNDEF(&co->fci.function_name); + + if (UNEXPECTED(EG(exception))) { + zend_object *ex = EG(exception); + GC_ADDREF(ex); + zend_clear_exception(); + transfer->flags |= ZEND_FIBER_TRANSFER_FLAG_ERROR; + ZVAL_OBJ(&transfer->value, ex); + } else { + ZVAL_COPY_VALUE(&transfer->value, &co->result); + ZVAL_UNDEF(&co->result); + } + } zend_catch { + transfer->flags |= ZEND_FIBER_TRANSFER_FLAG_BAILOUT; + } zend_end_try(); + + co->vm_stack = EG(vm_stack); + + /* The trampoline marks this context DEAD and switches to transfer->context. */ + transfer->context = caller; +} + +/* Symmetric switch into `co`. Ported from TrueAsync's fiber_switch_context_ex. */ +static void async_continuation_switch(async_continuation_t *co, zval *send_value, zval *return_value) +{ + async_continuation_starting = co; + + zend_fiber_transfer transfer = { .context = co->ctx, .flags = 0 }; + + if (send_value != NULL) { + ZVAL_COPY(&transfer.value, send_value); + } else { + ZVAL_NULL(&transfer.value); + } + + zend_fiber_switch_context(&transfer); + + /* Bailout initiated on the other side: re-raise here after our context is + * restored. */ + if (UNEXPECTED(transfer.flags & ZEND_FIBER_TRANSFER_FLAG_BAILOUT)) { + zend_bailout(); + } + + /* An exception thrown on the other side surfaces at the switch site. */ + if (UNEXPECTED(transfer.flags & ZEND_FIBER_TRANSFER_FLAG_ERROR)) { + zend_throw_exception_internal(Z_OBJ(transfer.value)); + if (return_value != NULL) { + ZVAL_NULL(return_value); + } + return; + } + + if (return_value != NULL) { + ZVAL_COPY_VALUE(return_value, &transfer.value); /* move the ref out */ + } else { + zval_ptr_dtor(&transfer.value); + } +} + +static zend_object *async_continuation_create_object(zend_class_entry *ce) +{ + async_continuation_t *co = zend_object_alloc(sizeof(async_continuation_t), ce); + + zend_object_std_init(&co->std, ce); + object_properties_init(&co->std, ce); + co->std.handlers = &async_continuation_handlers; + + co->ctx = NULL; + ZVAL_UNDEF(&co->result); + co->vm_stack = NULL; + + return &co->std; +} + +static void async_continuation_free_object(zend_object *object) +{ + async_continuation_t *co = continuation_from_obj(object); + + if (co->vm_stack != NULL) { + zend_vm_stack current_stack = EG(vm_stack); + EG(vm_stack) = co->vm_stack; + zend_vm_stack_destroy(); + EG(vm_stack) = current_stack; + co->vm_stack = NULL; + } + + if (co->ctx != NULL) { + /* A finished coroutine's context was already destroyed by + * zend_fiber_switch_context when control returned (it frees a DEAD + * context). Only destroy one that never ran to completion. */ + if (co->ctx->status != ZEND_FIBER_STATUS_DEAD) { + zend_fiber_destroy_context(co->ctx); + } + efree(co->ctx); + co->ctx = NULL; + } + + if (Z_TYPE(co->fci.function_name) != IS_UNDEF) { + zval_ptr_dtor(&co->fci.function_name); + } + + zval_ptr_dtor(&co->result); + zend_object_std_dtor(object); +} + +ZEND_METHOD(Async_Continuation, create) +{ + zend_fcall_info fci; + zend_fcall_info_cache fcc; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_FUNC(fci, fcc) + ZEND_PARSE_PARAMETERS_END(); + + zend_object *obj = async_continuation_create_object(async_ce_Continuation); + async_continuation_t *co = continuation_from_obj(obj); + + co->fci = fci; + co->fcc = fcc; + Z_TRY_ADDREF(co->fci.function_name); + + co->ctx = ecalloc(1, sizeof(zend_fiber_context)); + + if (zend_fiber_init_context(co->ctx, async_ce_Continuation, + async_continuation_entry, EG(fiber_stack_size)) == FAILURE) { + efree(co->ctx); + co->ctx = NULL; + OBJ_RELEASE(obj); + RETURN_THROWS(); + } + + RETURN_OBJ(obj); +} + +ZEND_METHOD(Async_Continuation, switchTo) +{ + zval *value = NULL; + + ZEND_PARSE_PARAMETERS_START(0, 1) + Z_PARAM_OPTIONAL + Z_PARAM_ZVAL(value) + ZEND_PARSE_PARAMETERS_END(); + + async_continuation_t *co = continuation_from_obj(Z_OBJ_P(ZEND_THIS)); + + if (co->ctx == NULL) { + zend_throw_error(NULL, "Continuation was not created"); + RETURN_THROWS(); + } + + async_continuation_switch(co, value, return_value); + + if (UNEXPECTED(EG(exception))) { + RETURN_THROWS(); + } +} + +static void async_register_continuation(void) +{ + async_ce_Continuation = register_class_Async_Continuation(); + async_ce_Continuation->create_object = async_continuation_create_object; + + memcpy(&async_continuation_handlers, zend_get_std_object_handlers(), sizeof(zend_object_handlers)); + async_continuation_handlers.offset = offsetof(async_continuation_t, std); + async_continuation_handlers.free_obj = async_continuation_free_object; + + async_continuation_root_function.type = ZEND_INTERNAL_FUNCTION; + async_continuation_root_function.common.function_name = + zend_string_init_interned(ZEND_STRL("Async\\Continuation"), true); +} + void zend_register_scheduler_hook(void) { async_ce_Scheduler = register_class_Async_Scheduler(); async_ce_AbstractScheduler = register_class_Async_AbstractScheduler(async_ce_Scheduler); register_class_Async_SchedulerHook(); + async_register_continuation(); } void zend_scheduler_hook_request_shutdown(void) diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php index 868cff9b41c4..68a2a7c06b7e 100644 --- a/Zend/zend_scheduler_hook.stub.php +++ b/Zend/zend_scheduler_hook.stub.php @@ -117,3 +117,21 @@ public static function getModule(): ?string {} */ public static function defer(callable $task): void {} } + +/** + * A symmetric execution context (PoC). Created with a callable; switching into + * it with switchTo() runs the callable until it either switches away or returns + * (on return, control goes back to the switcher). Backed by the engine's fiber + * context, so it is compatible with fiber-aware tooling. + * + * NOTE: `create`/`switchTo` are temporary PoC entry points; the real API hands + * these as a capability to the scheduler at onLaunch(). + */ +final class Continuation +{ + /** Create a coroutine execution context that will run $entry. */ + public static function create(callable $entry): Continuation {} + + /** Switch control into this continuation; returns what it hands back. */ + public function switchTo(mixed $value = null): mixed {} +} diff --git a/Zend/zend_scheduler_hook_arginfo.h b/Zend/zend_scheduler_hook_arginfo.h index bf4318186b1b..0847b0d30a28 100644 --- a/Zend/zend_scheduler_hook_arginfo.h +++ b/Zend/zend_scheduler_hook_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit zend_scheduler_hook.stub.php instead. - * Stub hash: f0c59c981f1275ab3e505bd41dfb1bfeca75c0f7 */ + * Stub hash: 874992e56a1ee42ae36544b2bf2b927418f51248 */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_enqueue, 0, 1, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) @@ -92,6 +92,14 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_SchedulerHook_defer, ZEND_ARG_TYPE_INFO(0, task, IS_CALLABLE, 0) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Async_Continuation_create, 0, 1, Async\\Continuation, 0) + ZEND_ARG_TYPE_INFO(0, entry, IS_CALLABLE, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Continuation_switchTo, 0, 0, IS_MIXED, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, value, IS_MIXED, 0, "null") +ZEND_END_ARG_INFO() + ZEND_METHOD(Async_AbstractScheduler, interceptFiber); ZEND_METHOD(Async_AbstractScheduler, resume); ZEND_METHOD(Async_AbstractScheduler, cancel); @@ -106,6 +114,8 @@ ZEND_METHOD(Async_AbstractScheduler, contextUnset); ZEND_METHOD(Async_SchedulerHook, register); ZEND_METHOD(Async_SchedulerHook, getModule); ZEND_METHOD(Async_SchedulerHook, defer); +ZEND_METHOD(Async_Continuation, create); +ZEND_METHOD(Async_Continuation, switchTo); static const zend_function_entry class_Async_Scheduler_methods[] = { ZEND_RAW_FENTRY("enqueue", NULL, arginfo_class_Async_Scheduler_enqueue, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) @@ -148,6 +158,12 @@ static const zend_function_entry class_Async_SchedulerHook_methods[] = { ZEND_FE_END }; +static const zend_function_entry class_Async_Continuation_methods[] = { + ZEND_ME(Async_Continuation, create, arginfo_class_Async_Continuation_create, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) + ZEND_ME(Async_Continuation, switchTo, arginfo_class_Async_Continuation_switchTo, ZEND_ACC_PUBLIC) + ZEND_FE_END +}; + static zend_class_entry *register_class_Async_Scheduler(void) { zend_class_entry ce, *class_entry; @@ -178,3 +194,13 @@ static zend_class_entry *register_class_Async_SchedulerHook(void) return class_entry; } + +static zend_class_entry *register_class_Async_Continuation(void) +{ + zend_class_entry ce, *class_entry; + + INIT_NS_CLASS_ENTRY(ce, "Async", "Continuation", class_Async_Continuation_methods); + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL); + + return class_entry; +} diff --git a/main/main.c b/main/main.c index aeec357857fd..43438f55f44d 100644 --- a/main/main.c +++ b/main/main.c @@ -2657,9 +2657,6 @@ PHPAPI bool php_execute_script_ex(zend_file_handle *primary_file, zval *retval) zend_set_timeout(zend_ini_long_literal("max_execution_time"), false); } - /* The scheduler is not lazy: it always starts right before the - * script code runs. A no-op until a scheduler module has - * registered itself. */ if (ZEND_ASYNC_IS_READY) { ZEND_ASYNC_SCHEDULER_LAUNCH(); } From d9cce5117179b7fe7929767a32b463f52c5ea0d4 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Tue, 7 Jul 2026 20:42:46 +0000 Subject: [PATCH 18/23] async: rename scheduler hooks to on* and align PHP interface; merge enqueue/resume/cancel into onEnqueue; remove AbstractScheduler; getContext returns object; contextFind drops includeParent; update tests (15/15) --- Zend/tests/async/fiber_lowlevel_null.phpt | 16 ++- .../tests/async/fiber_managed_after_main.phpt | 16 ++- Zend/tests/async/fiber_managed_basic.phpt | 23 ++-- Zend/tests/async/fiber_managed_throw.phpt | 26 +++-- Zend/tests/async/fiber_managed_uncaught.phpt | 16 ++- Zend/tests/async/microtasks_basic.phpt | 16 ++- Zend/tests/async/scheduler_context.phpt | 23 ++-- .../tests/async/scheduler_hook_constants.phpt | 7 +- Zend/tests/async/scheduler_hook_launch.phpt | 16 ++- Zend/tests/async/scheduler_hook_override.phpt | 15 ++- Zend/tests/async/scheduler_hook_register.phpt | 15 ++- Zend/zend_scheduler_hook.c | 107 ++++------------- Zend/zend_scheduler_hook.stub.php | 90 +++++--------- Zend/zend_scheduler_hook_arginfo.h | 110 +++--------------- 14 files changed, 197 insertions(+), 299 deletions(-) diff --git a/Zend/tests/async/fiber_lowlevel_null.phpt b/Zend/tests/async/fiber_lowlevel_null.phpt index 11737d5fed3a..64848813178b 100644 --- a/Zend/tests/async/fiber_lowlevel_null.phpt +++ b/Zend/tests/async/fiber_lowlevel_null.phpt @@ -2,13 +2,21 @@ interceptFiber returning null keeps the fiber on the low-level path --FILE-- queue = new SplQueue(); } - public function interceptFiber(Fiber $fiber): ?object { + public function onFiber(Fiber $fiber): ?object { return new class($fiber) { public function __construct(public readonly Fiber $fiber) {} }; } - public function enqueue(object $coroutine): bool { + public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { $this->queue->enqueue($coroutine); return true; } - public function suspend(bool $fromMain, bool $isBailout): ?object { + public function onSuspend(bool $fromMain, bool $isBailout): ?object { // This scheduler defers everything: nothing runs until after main. if (!$fromMain) { return null; diff --git a/Zend/tests/async/fiber_managed_basic.phpt b/Zend/tests/async/fiber_managed_basic.phpt index eabf1565da95..c4ae887908bc 100644 --- a/Zend/tests/async/fiber_managed_basic.phpt +++ b/Zend/tests/async/fiber_managed_basic.phpt @@ -2,27 +2,30 @@ A fiber adopted by the scheduler runs through the coroutine path --FILE-- queue = new SplQueue(); } - public function interceptFiber(Fiber $fiber): ?object { + public function onFiber(Fiber $fiber): ?object { $this->log[] = 'intercept'; return new class($fiber) { public function __construct(public readonly Fiber $fiber) {} }; } - public function enqueue(object $coroutine): bool { + public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { $this->log[] = 'enqueue'; $this->queue->enqueue($coroutine); return true; } - public function resume(object $coroutine, ?Throwable $error = null): bool { - $this->log[] = 'resume'; - $this->queue->enqueue($coroutine); - return true; - } - public function suspend(bool $fromMain, bool $isBailout): ?object { + public function onSuspend(bool $fromMain, bool $isBailout): ?object { $this->log[] = 'suspend'; while (!$this->queue->isEmpty()) { $fiber = $this->queue->dequeue()->fiber; @@ -55,4 +58,4 @@ bool(true) NULL bool(true) int(40) -intercept,enqueue,suspend,resume,suspend +intercept,enqueue,suspend,enqueue,suspend diff --git a/Zend/tests/async/fiber_managed_throw.phpt b/Zend/tests/async/fiber_managed_throw.phpt index 0f108a5247c4..92c7b03b0e62 100644 --- a/Zend/tests/async/fiber_managed_throw.phpt +++ b/Zend/tests/async/fiber_managed_throw.phpt @@ -2,24 +2,30 @@ Fiber::throw() on a managed fiber delivers the exception at the suspension point --FILE-- queue = new SplQueue(); } - public function interceptFiber(Fiber $fiber): ?object { + public function onFiber(Fiber $fiber): ?object { return new class($fiber) { public function __construct(public readonly Fiber $fiber) {} }; } - public function enqueue(object $coroutine): bool { - $this->queue->enqueue($coroutine); - return true; - } - public function resume(object $coroutine, ?Throwable $error = null): bool { - echo "resume hook error: ", $error === null ? 'none' : get_class($error), "\n"; + public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { + if ($error !== null) { + echo "enqueue hook error: ", get_class($error), "\n"; + } $this->queue->enqueue($coroutine); return true; } - public function suspend(bool $fromMain, bool $isBailout): ?object { + public function onSuspend(bool $fromMain, bool $isBailout): ?object { while (!$this->queue->isEmpty()) { $fiber = $this->queue->dequeue()->fiber; $fiber->isStarted() ? $fiber->resume() : $fiber->start(); @@ -46,5 +52,5 @@ var_dump($fiber->getReturn()); ?> --EXPECT-- string(7) "waiting" -resume hook error: RuntimeException +enqueue hook error: RuntimeException string(12) "caught: boom" diff --git a/Zend/tests/async/fiber_managed_uncaught.phpt b/Zend/tests/async/fiber_managed_uncaught.phpt index f5d6d7049c52..9da7f9cb4c54 100644 --- a/Zend/tests/async/fiber_managed_uncaught.phpt +++ b/Zend/tests/async/fiber_managed_uncaught.phpt @@ -2,19 +2,27 @@ An uncaught exception in a managed fiber surfaces at the start()/resume() caller --FILE-- queue = new SplQueue(); } - public function interceptFiber(Fiber $fiber): ?object { + public function onFiber(Fiber $fiber): ?object { return new class($fiber) { public function __construct(public readonly Fiber $fiber) {} }; } - public function enqueue(object $coroutine): bool { + public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { $this->queue->enqueue($coroutine); return true; } - public function suspend(bool $fromMain, bool $isBailout): ?object { + public function onSuspend(bool $fromMain, bool $isBailout): ?object { while (!$this->queue->isEmpty()) { $fiber = $this->queue->dequeue()->fiber; $fiber->isStarted() ? $fiber->resume() : $fiber->start(); diff --git a/Zend/tests/async/microtasks_basic.phpt b/Zend/tests/async/microtasks_basic.phpt index 34384d5795c2..c26542022e18 100644 --- a/Zend/tests/async/microtasks_basic.phpt +++ b/Zend/tests/async/microtasks_basic.phpt @@ -2,12 +2,20 @@ Microtasks: defer() forwards to the scheduler's defer() hook; the queue is the scheduler's --FILE-- tasks = new SplQueue(); } - public function enqueue(object $coroutine): bool { return true; } - public function suspend(bool $fromMain, bool $isBailout): ?object { return null; } - public function defer(callable $task): bool { + public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; } + public function onSuspend(bool $fromMain, bool $isBailout): ?object { return null; } + public function onDefer(callable $task): bool { // The queue is owned by the scheduler, not by the engine. $this->tasks->enqueue($task); return true; diff --git a/Zend/tests/async/scheduler_context.phpt b/Zend/tests/async/scheduler_context.phpt index 7a830876d015..a8421a691fdd 100644 --- a/Zend/tests/async/scheduler_context.phpt +++ b/Zend/tests/async/scheduler_context.phpt @@ -2,26 +2,29 @@ Fiber operations on a bound fiber: direct inside the scheduler, routed outside --FILE-- queue = new SplQueue(); } - public function interceptFiber(Fiber $fiber): ?object { + public function onFiber(Fiber $fiber): ?object { return new class($fiber) { public function __construct(public readonly Fiber $fiber) {} }; } - public function enqueue(object $coroutine): bool { + public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { $this->log[] = 'enqueue'; $this->queue->enqueue($coroutine); return true; } - public function resume(object $coroutine, ?Throwable $error = null): bool { - $this->log[] = 'resume-hook'; - $this->queue->enqueue($coroutine); - return true; - } - public function suspend(bool $fromMain, bool $isBailout): ?object { + public function onSuspend(bool $fromMain, bool $isBailout): ?object { while (!$this->queue->isEmpty()) { $fiber = $this->queue->dequeue()->fiber; @@ -62,5 +65,5 @@ try { string(5) "first" NULL string(4) "done" -enqueue,resume-hook +enqueue,enqueue Cannot resume a fiber that is not suspended diff --git a/Zend/tests/async/scheduler_hook_constants.phpt b/Zend/tests/async/scheduler_hook_constants.phpt index 804e1eb3b3d7..b81f0012616d 100644 --- a/Zend/tests/async/scheduler_hook_constants.phpt +++ b/Zend/tests/async/scheduler_hook_constants.phpt @@ -1,5 +1,5 @@ --TEST-- -Async\Scheduler: interface shape and AbstractScheduler base +Async\Scheduler: interface shape --FILE-- isInterface()); $methods = array_map(fn ($m) => $m->name, $r->getMethods()); sort($methods); echo implode(',', $methods), "\n"; - -var_dump((new ReflectionClass(Async\AbstractScheduler::class))->isAbstract()); ?> --EXPECT-- bool(true) -cancel,contextFind,contextSet,contextUnset,defer,enqueue,getContext,getInternalContext,interceptFiber,launch,resume,shutdown,suspend -bool(true) +contextFind,contextSet,contextUnset,getContext,getInternalContext,onDefer,onEnqueue,onFiber,onLaunch,onShutdown,onSuspend diff --git a/Zend/tests/async/scheduler_hook_launch.phpt b/Zend/tests/async/scheduler_hook_launch.phpt index 4dfd5ab81218..81f9af26fcf9 100644 --- a/Zend/tests/async/scheduler_hook_launch.phpt +++ b/Zend/tests/async/scheduler_hook_launch.phpt @@ -2,11 +2,19 @@ Async\SchedulerHook: launch runs immediately at PHP registration --FILE-- launched = true; return null; } + public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; } + public function onSuspend(bool $fromMain, bool $isBailout): ?object { return null; } + public function onLaunch(): ?object { $this->launched = true; return null; } }; Async\SchedulerHook::register('test', $scheduler); diff --git a/Zend/tests/async/scheduler_hook_override.phpt b/Zend/tests/async/scheduler_hook_override.phpt index c802151bd1a1..e17eb4eff856 100644 --- a/Zend/tests/async/scheduler_hook_override.phpt +++ b/Zend/tests/async/scheduler_hook_override.phpt @@ -2,9 +2,18 @@ Async\SchedulerHook::register can be called only once --FILE-- new class extends Async\AbstractScheduler { - public function enqueue(object $coroutine): bool { return true; } - public function suspend(bool $fromMain, bool $isBailout): ?object { return null; } +$make = fn () => new class implements \Async\Scheduler { + public function onLaunch(): ?object { return null; } + public function onShutdown(): bool { return true; } + public function onFiber(\Fiber $fiber): ?object { return null; } + public function onDefer(callable $task): bool { return true; } + public function getContext(object $coroutine): object { return new \stdClass(); } + public function getInternalContext(object $coroutine): object { return new \stdClass(); } + public function contextFind(object $context, mixed $key): mixed { return null; } + public function contextSet(object $context, mixed $key, mixed $value): bool { return true; } + public function contextUnset(object $context, mixed $key): bool { return true; } + public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; } + public function onSuspend(bool $fromMain, bool $isBailout): ?object { return null; } }; // First registration succeeds. diff --git a/Zend/tests/async/scheduler_hook_register.phpt b/Zend/tests/async/scheduler_hook_register.phpt index 0d3ffb379b28..a78785b97e11 100644 --- a/Zend/tests/async/scheduler_hook_register.phpt +++ b/Zend/tests/async/scheduler_hook_register.phpt @@ -4,9 +4,18 @@ Async\SchedulerHook::register activates and getModule() reports the driver ce->function_table, php_async_hook_methods[id].name, php_async_hook_methods[id].len); - if (fn == NULL || fn->common.scope == async_ce_AbstractScheduler) { + if (fn == NULL) { hook->set = false; return; } @@ -343,12 +343,13 @@ static void php_async_context_arg(zend_async_context_t *context, zval *out) static bool php_async_thunk_context_find( zend_async_context_t *context, zval *key, zval *result, bool include_parent) { - zval args[3], retval; + (void) include_parent; /* the PHP contextFind hook does not take it */ + + zval args[2], retval; php_async_context_arg(context, &args[0]); ZVAL_COPY(&args[1], key); - ZVAL_BOOL(&args[2], include_parent); - const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_CONTEXT_FIND), 3, args, &retval); + const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_CONTEXT_FIND), 2, args, &retval); if (result != NULL) { if (ok) { @@ -489,6 +490,13 @@ ZEND_METHOD(Async_SchedulerHook, register) Z_PARAM_OBJ_OF_CLASS(scheduler, async_ce_Scheduler) ZEND_PARSE_PARAMETERS_END(); + /* A scheduler provides whatever hooks it needs; the two below are mandatory. */ + if (zend_hash_str_find_ptr(&scheduler->ce->function_table, ZEND_STRL("onenqueue")) == NULL + || zend_hash_str_find_ptr(&scheduler->ce->function_table, ZEND_STRL("onsuspend")) == NULL) { + zend_type_error("Async\\SchedulerHook::register(): Argument #2 ($scheduler) must implement onEnqueue() and onSuspend()"); + RETURN_THROWS(); + } + /* A scheduler is registered once per process — by a C extension or by * PHP, whichever comes first. */ if (zend_async_is_enabled()) { @@ -563,70 +571,6 @@ ZEND_METHOD(Async_SchedulerHook, defer) } } -///////////////////////////////////////////////////////////////////// -/// Async\AbstractScheduler default hooks -///////////////////////////////////////////////////////////////////// - -/* These bodies are the inert defaults: the bridge detects them by scope and - * keeps the engine's own behaviour instead of calling them. They only run when - * userland invokes them directly; the argument count/types are already checked - * against the arginfo at the call boundary, so the bodies just return. */ - -ZEND_METHOD(Async_AbstractScheduler, interceptFiber) -{ - RETURN_NULL(); -} - -ZEND_METHOD(Async_AbstractScheduler, resume) -{ - RETURN_FALSE; -} - -ZEND_METHOD(Async_AbstractScheduler, cancel) -{ - RETURN_FALSE; -} - -ZEND_METHOD(Async_AbstractScheduler, defer) -{ - RETURN_FALSE; -} - -ZEND_METHOD(Async_AbstractScheduler, launch) -{ - RETURN_NULL(); -} - -ZEND_METHOD(Async_AbstractScheduler, shutdown) -{ - RETURN_TRUE; -} - -ZEND_METHOD(Async_AbstractScheduler, getContext) -{ - RETURN_NULL(); -} - -ZEND_METHOD(Async_AbstractScheduler, getInternalContext) -{ - RETURN_NULL(); -} - -ZEND_METHOD(Async_AbstractScheduler, contextFind) -{ - RETURN_NULL(); -} - -ZEND_METHOD(Async_AbstractScheduler, contextSet) -{ - RETURN_FALSE; -} - -ZEND_METHOD(Async_AbstractScheduler, contextUnset) -{ - RETURN_FALSE; -} - ///////////////////////////////////////////////////////////////////// /// Async\Continuation — symmetric execution context ///////////////////////////////////////////////////////////////////// @@ -874,7 +818,6 @@ static void async_register_continuation(void) void zend_register_scheduler_hook(void) { async_ce_Scheduler = register_class_Async_Scheduler(); - async_ce_AbstractScheduler = register_class_Async_AbstractScheduler(async_ce_Scheduler); register_class_Async_SchedulerHook(); async_register_continuation(); } diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php index 68a2a7c06b7e..de8227e605d8 100644 --- a/Zend/zend_scheduler_hook.stub.php +++ b/Zend/zend_scheduler_hook.stub.php @@ -5,91 +5,53 @@ namespace Async; /** - * A scheduler plugs into the concurrent mode by implementing this interface - * and handing an instance to Async\SchedulerHook::register(). Each method is - * a scheduling *policy* hook; the engine performs the actual context switches. + * A scheduler plugs into the concurrent mode by implementing this interface and + * handing an instance to Async\SchedulerHook::register(). The on* methods are + * event callbacks the engine invokes; the engine performs the context switches. * * Extend Async\AbstractScheduler to implement only the hooks you need. */ interface Scheduler { - /** A coroutine became runnable (freshly created, or its wait ended). */ - public function enqueue(object $coroutine): bool; - /** - * The current flow yields; pick who runs next and switch to them. Return - * the coroutine that is now current (or null) — the engine records it as - * Async\Coroutine::current(). + * Invoked once when the scheduler starts. Returns the coroutine that is now + * current (or null); the engine records it, same as onSuspend(). */ - public function suspend(bool $fromMain, bool $isBailout): ?object; - - /** A fiber is starting: return a coroutine object to adopt it, or null. */ - public function interceptFiber(\Fiber $fiber): ?object; + public function onLaunch(): ?object; - /** Wake a suspended coroutine (deferred: enqueue it to run later). */ - public function resume(object $coroutine, ?\Throwable $error = null): bool; + /** A graceful shutdown has been requested. */ + public function onShutdown(): bool; - /** Cancel a coroutine. */ - public function cancel(object $coroutine, ?\Throwable $error = null): bool; + /** A foreign Fiber is starting: return a coroutine to adopt it, or null. */ + public function onFiber(\Fiber $fiber): ?object; - /** Store a one-shot microtask on the scheduler's queue. */ - public function defer(callable $task): bool; + /** + * Make a coroutine runnable (enqueue and resume are the same operation). A + * non-null $error is raised at the coroutine's suspension point, which is how + * cancellation and IO/timeout failures reach waiting code. + */ + public function onEnqueue(object $coroutine, ?\Throwable $error = null): bool; /** - * Invoked once when the scheduler starts. Return the coroutine that is now - * current (or null) — the core records it, same as suspend(). + * The current flow yields; pick who runs next and switch to them. Return the + * coroutine that is now current (or null); the engine records it. */ - public function launch(): ?object; + public function onSuspend(bool $fromMain, bool $isBailout): ?object; - /** A graceful shutdown has been requested. */ - public function shutdown(): bool; + /** Store a one-shot microtask on the scheduler's queue. */ + public function onDefer(callable $task): bool; - /** The coroutine's userland context (string/object keys), or null. */ - public function getContext(object $coroutine): ?object; + /** The coroutine's userland context (string/object keys). */ + public function getContext(object $coroutine): object; - /** The coroutine's internal context (numeric keys, for C extensions), or null. */ - public function getInternalContext(object $coroutine): ?object; + /** The coroutine's internal context (numeric keys, for C extensions). */ + public function getInternalContext(object $coroutine): object; - /** Userland context operations (string/object keys). */ - public function contextFind(object $context, mixed $key, bool $includeParent): mixed; + public function contextFind(object $context, mixed $key): mixed; public function contextSet(object $context, mixed $key, mixed $value): bool; public function contextUnset(object $context, mixed $key): bool; } -/** - * Convenience base: only enqueue() and suspend() are required; every other - * hook has a default, so the engine keeps its own behaviour for the ones you - * do not override. - */ -abstract class AbstractScheduler implements Scheduler -{ - abstract public function enqueue(object $coroutine): bool; - - abstract public function suspend(bool $fromMain, bool $isBailout): ?object; - - public function interceptFiber(\Fiber $fiber): ?object {} - - public function resume(object $coroutine, ?\Throwable $error = null): bool {} - - public function cancel(object $coroutine, ?\Throwable $error = null): bool {} - - public function defer(callable $task): bool {} - - public function launch(): ?object {} - - public function shutdown(): bool {} - - public function getContext(object $coroutine): ?object {} - - public function getInternalContext(object $coroutine): ?object {} - - public function contextFind(object $context, mixed $key, bool $includeParent): mixed {} - - public function contextSet(object $context, mixed $key, mixed $value): bool {} - - public function contextUnset(object $context, mixed $key): bool {} -} - /** * Activation point for the concurrent mode. * diff --git a/Zend/zend_scheduler_hook_arginfo.h b/Zend/zend_scheduler_hook_arginfo.h index 0847b0d30a28..5e8309a27216 100644 --- a/Zend/zend_scheduler_hook_arginfo.h +++ b/Zend/zend_scheduler_hook_arginfo.h @@ -1,46 +1,39 @@ /* This is a generated file, edit zend_scheduler_hook.stub.php instead. - * Stub hash: 874992e56a1ee42ae36544b2bf2b927418f51248 */ + * Stub hash: f77e5c2951aa51f22a23b152e32b15095e32b7ed */ -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_enqueue, 0, 1, _IS_BOOL, 0) - ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onLaunch, 0, 0, IS_OBJECT, 1) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_suspend, 0, 2, IS_OBJECT, 1) - ZEND_ARG_TYPE_INFO(0, fromMain, _IS_BOOL, 0) - ZEND_ARG_TYPE_INFO(0, isBailout, _IS_BOOL, 0) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onShutdown, 0, 0, _IS_BOOL, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_interceptFiber, 0, 1, IS_OBJECT, 1) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onFiber, 0, 1, IS_OBJECT, 1) ZEND_ARG_OBJ_INFO(0, fiber, Fiber, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_resume, 0, 1, _IS_BOOL, 0) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onEnqueue, 0, 1, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, error, Throwable, 1, "null") ZEND_END_ARG_INFO() -#define arginfo_class_Async_Scheduler_cancel arginfo_class_Async_Scheduler_resume - -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_defer, 0, 1, _IS_BOOL, 0) - ZEND_ARG_TYPE_INFO(0, task, IS_CALLABLE, 0) -ZEND_END_ARG_INFO() - -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_launch, 0, 0, IS_OBJECT, 1) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onSuspend, 0, 2, IS_OBJECT, 1) + ZEND_ARG_TYPE_INFO(0, fromMain, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, isBailout, _IS_BOOL, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_shutdown, 0, 0, _IS_BOOL, 0) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onDefer, 0, 1, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, task, IS_CALLABLE, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_getContext, 0, 1, IS_OBJECT, 1) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_getContext, 0, 1, IS_OBJECT, 0) ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) ZEND_END_ARG_INFO() #define arginfo_class_Async_Scheduler_getInternalContext arginfo_class_Async_Scheduler_getContext -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_contextFind, 0, 3, IS_MIXED, 0) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_contextFind, 0, 2, IS_MIXED, 0) ZEND_ARG_TYPE_INFO(0, context, IS_OBJECT, 0) ZEND_ARG_TYPE_INFO(0, key, IS_MIXED, 0) - ZEND_ARG_TYPE_INFO(0, includeParent, _IS_BOOL, 0) ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_contextSet, 0, 3, _IS_BOOL, 0) @@ -54,32 +47,6 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_contextUns ZEND_ARG_TYPE_INFO(0, key, IS_MIXED, 0) ZEND_END_ARG_INFO() -#define arginfo_class_Async_AbstractScheduler_enqueue arginfo_class_Async_Scheduler_enqueue - -#define arginfo_class_Async_AbstractScheduler_suspend arginfo_class_Async_Scheduler_suspend - -#define arginfo_class_Async_AbstractScheduler_interceptFiber arginfo_class_Async_Scheduler_interceptFiber - -#define arginfo_class_Async_AbstractScheduler_resume arginfo_class_Async_Scheduler_resume - -#define arginfo_class_Async_AbstractScheduler_cancel arginfo_class_Async_Scheduler_resume - -#define arginfo_class_Async_AbstractScheduler_defer arginfo_class_Async_Scheduler_defer - -#define arginfo_class_Async_AbstractScheduler_launch arginfo_class_Async_Scheduler_launch - -#define arginfo_class_Async_AbstractScheduler_shutdown arginfo_class_Async_Scheduler_shutdown - -#define arginfo_class_Async_AbstractScheduler_getContext arginfo_class_Async_Scheduler_getContext - -#define arginfo_class_Async_AbstractScheduler_getInternalContext arginfo_class_Async_Scheduler_getContext - -#define arginfo_class_Async_AbstractScheduler_contextFind arginfo_class_Async_Scheduler_contextFind - -#define arginfo_class_Async_AbstractScheduler_contextSet arginfo_class_Async_Scheduler_contextSet - -#define arginfo_class_Async_AbstractScheduler_contextUnset arginfo_class_Async_Scheduler_contextUnset - ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_SchedulerHook_register, 0, 2, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, module, IS_STRING, 0) ZEND_ARG_OBJ_INFO(0, scheduler, Async\\Scheduler, 0) @@ -100,17 +67,6 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Continuation_switchT ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, value, IS_MIXED, 0, "null") ZEND_END_ARG_INFO() -ZEND_METHOD(Async_AbstractScheduler, interceptFiber); -ZEND_METHOD(Async_AbstractScheduler, resume); -ZEND_METHOD(Async_AbstractScheduler, cancel); -ZEND_METHOD(Async_AbstractScheduler, defer); -ZEND_METHOD(Async_AbstractScheduler, launch); -ZEND_METHOD(Async_AbstractScheduler, shutdown); -ZEND_METHOD(Async_AbstractScheduler, getContext); -ZEND_METHOD(Async_AbstractScheduler, getInternalContext); -ZEND_METHOD(Async_AbstractScheduler, contextFind); -ZEND_METHOD(Async_AbstractScheduler, contextSet); -ZEND_METHOD(Async_AbstractScheduler, contextUnset); ZEND_METHOD(Async_SchedulerHook, register); ZEND_METHOD(Async_SchedulerHook, getModule); ZEND_METHOD(Async_SchedulerHook, defer); @@ -118,14 +74,12 @@ ZEND_METHOD(Async_Continuation, create); ZEND_METHOD(Async_Continuation, switchTo); static const zend_function_entry class_Async_Scheduler_methods[] = { - ZEND_RAW_FENTRY("enqueue", NULL, arginfo_class_Async_Scheduler_enqueue, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) - ZEND_RAW_FENTRY("suspend", NULL, arginfo_class_Async_Scheduler_suspend, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) - ZEND_RAW_FENTRY("interceptFiber", NULL, arginfo_class_Async_Scheduler_interceptFiber, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) - ZEND_RAW_FENTRY("resume", NULL, arginfo_class_Async_Scheduler_resume, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) - ZEND_RAW_FENTRY("cancel", NULL, arginfo_class_Async_Scheduler_cancel, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) - ZEND_RAW_FENTRY("defer", NULL, arginfo_class_Async_Scheduler_defer, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) - ZEND_RAW_FENTRY("launch", NULL, arginfo_class_Async_Scheduler_launch, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) - ZEND_RAW_FENTRY("shutdown", NULL, arginfo_class_Async_Scheduler_shutdown, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("onLaunch", NULL, arginfo_class_Async_Scheduler_onLaunch, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("onShutdown", NULL, arginfo_class_Async_Scheduler_onShutdown, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("onFiber", NULL, arginfo_class_Async_Scheduler_onFiber, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("onEnqueue", NULL, arginfo_class_Async_Scheduler_onEnqueue, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("onSuspend", NULL, arginfo_class_Async_Scheduler_onSuspend, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("onDefer", NULL, arginfo_class_Async_Scheduler_onDefer, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_RAW_FENTRY("getContext", NULL, arginfo_class_Async_Scheduler_getContext, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_RAW_FENTRY("getInternalContext", NULL, arginfo_class_Async_Scheduler_getInternalContext, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_RAW_FENTRY("contextFind", NULL, arginfo_class_Async_Scheduler_contextFind, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) @@ -134,23 +88,6 @@ static const zend_function_entry class_Async_Scheduler_methods[] = { ZEND_FE_END }; -static const zend_function_entry class_Async_AbstractScheduler_methods[] = { - ZEND_RAW_FENTRY("enqueue", NULL, arginfo_class_Async_AbstractScheduler_enqueue, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) - ZEND_RAW_FENTRY("suspend", NULL, arginfo_class_Async_AbstractScheduler_suspend, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) - ZEND_ME(Async_AbstractScheduler, interceptFiber, arginfo_class_Async_AbstractScheduler_interceptFiber, ZEND_ACC_PUBLIC) - ZEND_ME(Async_AbstractScheduler, resume, arginfo_class_Async_AbstractScheduler_resume, ZEND_ACC_PUBLIC) - ZEND_ME(Async_AbstractScheduler, cancel, arginfo_class_Async_AbstractScheduler_cancel, ZEND_ACC_PUBLIC) - ZEND_ME(Async_AbstractScheduler, defer, arginfo_class_Async_AbstractScheduler_defer, ZEND_ACC_PUBLIC) - ZEND_ME(Async_AbstractScheduler, launch, arginfo_class_Async_AbstractScheduler_launch, ZEND_ACC_PUBLIC) - ZEND_ME(Async_AbstractScheduler, shutdown, arginfo_class_Async_AbstractScheduler_shutdown, ZEND_ACC_PUBLIC) - ZEND_ME(Async_AbstractScheduler, getContext, arginfo_class_Async_AbstractScheduler_getContext, ZEND_ACC_PUBLIC) - ZEND_ME(Async_AbstractScheduler, getInternalContext, arginfo_class_Async_AbstractScheduler_getInternalContext, ZEND_ACC_PUBLIC) - ZEND_ME(Async_AbstractScheduler, contextFind, arginfo_class_Async_AbstractScheduler_contextFind, ZEND_ACC_PUBLIC) - ZEND_ME(Async_AbstractScheduler, contextSet, arginfo_class_Async_AbstractScheduler_contextSet, ZEND_ACC_PUBLIC) - ZEND_ME(Async_AbstractScheduler, contextUnset, arginfo_class_Async_AbstractScheduler_contextUnset, ZEND_ACC_PUBLIC) - ZEND_FE_END -}; - static const zend_function_entry class_Async_SchedulerHook_methods[] = { ZEND_ME(Async_SchedulerHook, register, arginfo_class_Async_SchedulerHook_register, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_ME(Async_SchedulerHook, getModule, arginfo_class_Async_SchedulerHook_getModule, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) @@ -174,17 +111,6 @@ static zend_class_entry *register_class_Async_Scheduler(void) return class_entry; } -static zend_class_entry *register_class_Async_AbstractScheduler(zend_class_entry *class_entry_Async_Scheduler) -{ - zend_class_entry ce, *class_entry; - - INIT_NS_CLASS_ENTRY(ce, "Async", "AbstractScheduler", class_Async_AbstractScheduler_methods); - class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_ABSTRACT); - zend_class_implements(class_entry, 1, class_entry_Async_Scheduler); - - return class_entry; -} - static zend_class_entry *register_class_Async_SchedulerHook(void) { zend_class_entry ce, *class_entry; From 401ae6e804cfda3c1459e992b8bc7111dc6d944e Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:28:54 +0000 Subject: [PATCH 19/23] =?UTF-8?q?async:=20wire=20onLaunch=20mandate=20for?= =?UTF-8?q?=20PHP=20schedulers=20=E2=80=94=20bridge=20hands=20createContin?= =?UTF-8?q?uation=20+=20currentCoroutine=20closures=20(switchTo=20stays=20?= =?UTF-8?q?a=20Continuation=20method);=20+mandate=20test=20(16/16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Zend/tests/async/fiber_lowlevel_null.phpt | 2 +- .../tests/async/fiber_managed_after_main.phpt | 2 +- Zend/tests/async/fiber_managed_basic.phpt | 2 +- Zend/tests/async/fiber_managed_throw.phpt | 2 +- Zend/tests/async/fiber_managed_uncaught.phpt | 2 +- Zend/tests/async/microtasks_basic.phpt | 2 +- Zend/tests/async/scheduler_context.phpt | 2 +- Zend/tests/async/scheduler_hook_launch.phpt | 2 +- Zend/tests/async/scheduler_hook_override.phpt | 2 +- Zend/tests/async/scheduler_hook_register.phpt | 2 +- Zend/tests/async/scheduler_mandate.phpt | 36 +++++++++++++++++++ Zend/zend_scheduler_hook.c | 34 ++++++++++++++++-- Zend/zend_scheduler_hook.stub.php | 15 +++++--- Zend/zend_scheduler_hook_arginfo.h | 11 ++++-- 14 files changed, 97 insertions(+), 19 deletions(-) create mode 100644 Zend/tests/async/scheduler_mandate.phpt diff --git a/Zend/tests/async/fiber_lowlevel_null.phpt b/Zend/tests/async/fiber_lowlevel_null.phpt index 64848813178b..5cdcbd81f53b 100644 --- a/Zend/tests/async/fiber_lowlevel_null.phpt +++ b/Zend/tests/async/fiber_lowlevel_null.phpt @@ -3,7 +3,7 @@ interceptFiber returning null keeps the fiber on the low-level path --FILE-- launched = true; return null; } + public function onLaunch(\Closure $createContinuation, \Closure $currentCoroutine): ?object { $this->launched = true; return null; } }; Async\SchedulerHook::register('test', $scheduler); diff --git a/Zend/tests/async/scheduler_hook_override.phpt b/Zend/tests/async/scheduler_hook_override.phpt index e17eb4eff856..7dbc4dd5ca49 100644 --- a/Zend/tests/async/scheduler_hook_override.phpt +++ b/Zend/tests/async/scheduler_hook_override.phpt @@ -3,7 +3,7 @@ Async\SchedulerHook::register can be called only once --FILE-- new class implements \Async\Scheduler { - public function onLaunch(): ?object { return null; } + public function onLaunch(\Closure $createContinuation, \Closure $currentCoroutine): ?object { return null; } public function onShutdown(): bool { return true; } public function onFiber(\Fiber $fiber): ?object { return null; } public function onDefer(callable $task): bool { return true; } diff --git a/Zend/tests/async/scheduler_hook_register.phpt b/Zend/tests/async/scheduler_hook_register.phpt index a78785b97e11..c33c39cd88a8 100644 --- a/Zend/tests/async/scheduler_hook_register.phpt +++ b/Zend/tests/async/scheduler_hook_register.phpt @@ -5,7 +5,7 @@ Async\SchedulerHook::register activates and getModule() reports the driver var_dump(Async\SchedulerHook::getModule()); $ok = Async\SchedulerHook::register('my-driver', new class implements \Async\Scheduler { - public function onLaunch(): ?object { return null; } + public function onLaunch(\Closure $createContinuation, \Closure $currentCoroutine): ?object { return null; } public function onShutdown(): bool { return true; } public function onFiber(\Fiber $fiber): ?object { return null; } public function onDefer(callable $task): bool { return true; } diff --git a/Zend/tests/async/scheduler_mandate.phpt b/Zend/tests/async/scheduler_mandate.phpt new file mode 100644 index 000000000000..674482362d85 --- /dev/null +++ b/Zend/tests/async/scheduler_mandate.phpt @@ -0,0 +1,36 @@ +--TEST-- +Async\Scheduler::onLaunch receives the createContinuation/currentCoroutine mandate +--FILE-- +create = $createContinuation; + $this->current = $currentCoroutine; + return null; + } + public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; } + public function onSuspend(bool $fromMain, bool $isBailout): ?object { return null; } + public function onShutdown(): bool { return true; } + public function onFiber(Fiber $fiber): ?object { return null; } + public function onDefer(callable $task): bool { return true; } + public function getContext(object $coroutine): object { return new stdClass(); } + public function getInternalContext(object $coroutine): object { return new stdClass(); } + public function contextFind(object $context, mixed $key): mixed { return null; } + public function contextSet(object $context, mixed $key, mixed $value): bool { return true; } + public function contextUnset(object $context, mixed $key): bool { return true; } +}; +Async\SchedulerHook::register('test', $sched); + +// createContinuation mints a Continuation the scheduler can drive; it switches +// into it with the Continuation's own switchTo(). +$c = ($sched->create)(function (): void { echo " in continuation\n"; }); +var_dump($c instanceof Async\Continuation); +$c->switchTo(); +var_dump(($sched->current)()); +?> +--EXPECT-- +bool(true) + in continuation +NULL diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c index ab845dc85962..f72d5d11c58d 100644 --- a/Zend/zend_scheduler_hook.c +++ b/Zend/zend_scheduler_hook.c @@ -60,6 +60,7 @@ static const struct { /* Class entries, filled by zend_register_scheduler_hook(). */ static zend_class_entry *async_ce_Scheduler; +static zend_class_entry *async_ce_Continuation; /* The storage lives in the per-thread async globals: correct under ZTS, * request-local by construction. */ @@ -161,13 +162,30 @@ static void php_async_record_current(bool ok, zval *retval) PHP_ASYNC_HANDLERS.current_coroutine = current; } +/* A closure over one of Async\Continuation's mandate providers. */ +static void php_async_mandate_closure(zval *out, const char *name, size_t len) +{ + zend_function *fn = zend_hash_str_find_ptr(&async_ce_Continuation->function_table, name, len); + + ZEND_ASSERT(fn != NULL && "mandate provider missing"); + zend_create_fake_closure(out, fn, async_ce_Continuation, async_ce_Continuation, NULL); +} + static bool php_async_thunk_launch(void) { - zval retval; - const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_LAUNCH), 0, NULL, &retval); + /* The bridge hands a PHP scheduler its mandate as closures; a C scheduler + * reaches the primitives directly and never runs this thunk. Switching is a + * Continuation method, so it is not in the mandate. */ + zval args[2], retval; + php_async_mandate_closure(&args[0], ZEND_STRL("create")); + php_async_mandate_closure(&args[1], ZEND_STRL("currentcoroutine")); + + const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_LAUNCH), 2, args, &retval); php_async_record_current(ok, &retval); + zval_ptr_dtor(&args[0]); + zval_ptr_dtor(&args[1]); zval_ptr_dtor(&retval); return ok; } @@ -575,7 +593,6 @@ ZEND_METHOD(Async_SchedulerHook, defer) /// Async\Continuation — symmetric execution context ///////////////////////////////////////////////////////////////////// -static zend_class_entry *async_ce_Continuation; static zend_object_handlers async_continuation_handlers; /* Root frame for a continuation's Zend call stack. */ @@ -801,6 +818,17 @@ ZEND_METHOD(Async_Continuation, switchTo) } } +ZEND_METHOD(Async_Continuation, currentCoroutine) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + if (PHP_ASYNC_HANDLERS.current_coroutine != NULL) { + RETURN_OBJ_COPY(PHP_ASYNC_HANDLERS.current_coroutine); + } + + RETURN_NULL(); +} + static void async_register_continuation(void) { async_ce_Continuation = register_class_Async_Continuation(); diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php index de8227e605d8..1ddc9722e3fe 100644 --- a/Zend/zend_scheduler_hook.stub.php +++ b/Zend/zend_scheduler_hook.stub.php @@ -14,10 +14,14 @@ interface Scheduler { /** - * Invoked once when the scheduler starts. Returns the coroutine that is now - * current (or null); the engine records it, same as onSuspend(). + * Invoked once when the scheduler starts. The bridge hands a PHP scheduler + * its mandate as closures (a C scheduler reaches the primitives directly and + * receives none): $createContinuation(callable $entry): Continuation and + * $currentCoroutine(): ?object. Switching is done on the Continuation itself + * ($coroutine->switchTo()), so it is not part of the mandate. + * Returns the coroutine now current (or null); the engine records it. */ - public function onLaunch(): ?object; + public function onLaunch(\Closure $createContinuation, \Closure $currentCoroutine): ?object; /** A graceful shutdown has been requested. */ public function onShutdown(): bool; @@ -94,6 +98,9 @@ final class Continuation /** Create a coroutine execution context that will run $entry. */ public static function create(callable $entry): Continuation {} - /** Switch control into this continuation; returns what it hands back. */ + /** Switch control into this continuation, optionally sending $value; returns what it hands back. */ public function switchTo(mixed $value = null): mixed {} + + /** @internal Mandate provider: the coroutine the engine records as current. */ + public static function currentCoroutine(): ?object {} } diff --git a/Zend/zend_scheduler_hook_arginfo.h b/Zend/zend_scheduler_hook_arginfo.h index 5e8309a27216..d0f3940e8f15 100644 --- a/Zend/zend_scheduler_hook_arginfo.h +++ b/Zend/zend_scheduler_hook_arginfo.h @@ -1,7 +1,9 @@ /* This is a generated file, edit zend_scheduler_hook.stub.php instead. - * Stub hash: f77e5c2951aa51f22a23b152e32b15095e32b7ed */ + * Stub hash: 3398ced1741d69589cb995dcbd40fc8ed354b0d3 */ -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onLaunch, 0, 0, IS_OBJECT, 1) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onLaunch, 0, 2, IS_OBJECT, 1) + ZEND_ARG_OBJ_INFO(0, createContinuation, Closure, 0) + ZEND_ARG_OBJ_INFO(0, currentCoroutine, Closure, 0) ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onShutdown, 0, 0, _IS_BOOL, 0) @@ -67,11 +69,15 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Continuation_switchT ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, value, IS_MIXED, 0, "null") ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Continuation_currentCoroutine, 0, 0, IS_OBJECT, 1) +ZEND_END_ARG_INFO() + ZEND_METHOD(Async_SchedulerHook, register); ZEND_METHOD(Async_SchedulerHook, getModule); ZEND_METHOD(Async_SchedulerHook, defer); ZEND_METHOD(Async_Continuation, create); ZEND_METHOD(Async_Continuation, switchTo); +ZEND_METHOD(Async_Continuation, currentCoroutine); static const zend_function_entry class_Async_Scheduler_methods[] = { ZEND_RAW_FENTRY("onLaunch", NULL, arginfo_class_Async_Scheduler_onLaunch, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) @@ -98,6 +104,7 @@ static const zend_function_entry class_Async_SchedulerHook_methods[] = { static const zend_function_entry class_Async_Continuation_methods[] = { ZEND_ME(Async_Continuation, create, arginfo_class_Async_Continuation_create, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_ME(Async_Continuation, switchTo, arginfo_class_Async_Continuation_switchTo, ZEND_ACC_PUBLIC) + ZEND_ME(Async_Continuation, currentCoroutine, arginfo_class_Async_Continuation_currentCoroutine, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_FE_END }; From 77c75ef51b47639f97d520461e82843228194f2d Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:48:59 +0000 Subject: [PATCH 20/23] =?UTF-8?q?async:=20currentContinuation=20mandate=20?= =?UTF-8?q?=E2=80=94=20capture=20the=20running=20context=20for=20main=20no?= =?UTF-8?q?rmalisation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Continuation::current() wraps EG(current_fiber_context); a captured context is borrowed (never owned), free_obj does not destroy it - onLaunch mandate grows to three closures: createContinuation, currentContinuation, currentCoroutine; the bridge hands all three - create_object initialises fci.function_name (fixes a latent free on garbage for objects that never went through create()) - tests: signatures updated, mandate test covers the capture, new continuation_current_main.phpt switches from a continuation back into the captured main flow and back (17/17) --- .../async/continuation_current_main.phpt | 48 +++++++++++++++++++ Zend/tests/async/fiber_lowlevel_null.phpt | 2 +- .../tests/async/fiber_managed_after_main.phpt | 2 +- Zend/tests/async/fiber_managed_basic.phpt | 2 +- Zend/tests/async/fiber_managed_throw.phpt | 2 +- Zend/tests/async/fiber_managed_uncaught.phpt | 2 +- Zend/tests/async/microtasks_basic.phpt | 2 +- Zend/tests/async/scheduler_context.phpt | 2 +- Zend/tests/async/scheduler_hook_launch.phpt | 2 +- Zend/tests/async/scheduler_hook_override.phpt | 2 +- Zend/tests/async/scheduler_hook_register.phpt | 2 +- Zend/tests/async/scheduler_mandate.phpt | 11 ++++- Zend/zend_scheduler_hook.c | 47 ++++++++++++++---- Zend/zend_scheduler_hook.stub.php | 22 +++++++-- Zend/zend_scheduler_hook_arginfo.h | 10 +++- 15 files changed, 130 insertions(+), 28 deletions(-) create mode 100644 Zend/tests/async/continuation_current_main.phpt diff --git a/Zend/tests/async/continuation_current_main.phpt b/Zend/tests/async/continuation_current_main.phpt new file mode 100644 index 000000000000..73a5e52f5629 --- /dev/null +++ b/Zend/tests/async/continuation_current_main.phpt @@ -0,0 +1,48 @@ +--TEST-- +Async\Continuation: currentContinuation captures main, a continuation switches back into it +--FILE-- +create = $createContinuation; + $this->capture = $currentContinuation; + return null; + } + public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; } + public function onSuspend(bool $fromMain, bool $isBailout): ?object { return null; } + public function onShutdown(): bool { return true; } + public function onFiber(Fiber $fiber): ?object { return null; } + public function onDefer(callable $task): bool { return true; } + public function getContext(object $coroutine): object { return new stdClass(); } + public function getInternalContext(object $coroutine): object { return new stdClass(); } + public function contextFind(object $context, mixed $key): mixed { return null; } + public function contextSet(object $context, mixed $key, mixed $value): bool { return true; } + public function contextUnset(object $context, mixed $key): bool { return true; } +}; +Async\SchedulerHook::register('test', $sched); + +// The main flow becomes addressable like any other coroutine: this is the +// capture that normalisation ("the main flow is a coroutine too") builds on. +$main = ($sched->capture)(); +var_dump($main instanceof Async\Continuation); + +$c = ($sched->create)(function () use ($main): void { + echo " in continuation\n"; + $back = $main->switchTo('back to main'); // symmetric jump into captured main + echo " finishing ($back)\n"; +}); + +var_dump($c->switchTo()); +echo "main resumed\n"; +$c->switchTo('bye'); // let the body run to completion +echo "done\n"; +?> +--EXPECT-- +bool(true) + in continuation +string(12) "back to main" +main resumed + finishing (bye) +done diff --git a/Zend/tests/async/fiber_lowlevel_null.phpt b/Zend/tests/async/fiber_lowlevel_null.phpt index 5cdcbd81f53b..96f12ada205b 100644 --- a/Zend/tests/async/fiber_lowlevel_null.phpt +++ b/Zend/tests/async/fiber_lowlevel_null.phpt @@ -3,7 +3,7 @@ interceptFiber returning null keeps the fiber on the low-level path --FILE-- launched = true; return null; } + public function onLaunch(\Closure $createContinuation, \Closure $currentContinuation, \Closure $currentCoroutine): ?object { $this->launched = true; return null; } }; Async\SchedulerHook::register('test', $scheduler); diff --git a/Zend/tests/async/scheduler_hook_override.phpt b/Zend/tests/async/scheduler_hook_override.phpt index 7dbc4dd5ca49..750f1746851f 100644 --- a/Zend/tests/async/scheduler_hook_override.phpt +++ b/Zend/tests/async/scheduler_hook_override.phpt @@ -3,7 +3,7 @@ Async\SchedulerHook::register can be called only once --FILE-- new class implements \Async\Scheduler { - public function onLaunch(\Closure $createContinuation, \Closure $currentCoroutine): ?object { return null; } + public function onLaunch(\Closure $createContinuation, \Closure $currentContinuation, \Closure $currentCoroutine): ?object { return null; } public function onShutdown(): bool { return true; } public function onFiber(\Fiber $fiber): ?object { return null; } public function onDefer(callable $task): bool { return true; } diff --git a/Zend/tests/async/scheduler_hook_register.phpt b/Zend/tests/async/scheduler_hook_register.phpt index c33c39cd88a8..3b177a763df3 100644 --- a/Zend/tests/async/scheduler_hook_register.phpt +++ b/Zend/tests/async/scheduler_hook_register.phpt @@ -5,7 +5,7 @@ Async\SchedulerHook::register activates and getModule() reports the driver var_dump(Async\SchedulerHook::getModule()); $ok = Async\SchedulerHook::register('my-driver', new class implements \Async\Scheduler { - public function onLaunch(\Closure $createContinuation, \Closure $currentCoroutine): ?object { return null; } + public function onLaunch(\Closure $createContinuation, \Closure $currentContinuation, \Closure $currentCoroutine): ?object { return null; } public function onShutdown(): bool { return true; } public function onFiber(\Fiber $fiber): ?object { return null; } public function onDefer(callable $task): bool { return true; } diff --git a/Zend/tests/async/scheduler_mandate.phpt b/Zend/tests/async/scheduler_mandate.phpt index 674482362d85..b5aec245c3fd 100644 --- a/Zend/tests/async/scheduler_mandate.phpt +++ b/Zend/tests/async/scheduler_mandate.phpt @@ -1,12 +1,14 @@ --TEST-- -Async\Scheduler::onLaunch receives the createContinuation/currentCoroutine mandate +Async\Scheduler::onLaunch receives the createContinuation/currentContinuation/currentCoroutine mandate --FILE-- create = $createContinuation; + $this->capture = $currentContinuation; $this->current = $currentCoroutine; return null; } @@ -29,8 +31,13 @@ $c = ($sched->create)(function (): void { echo " in continuation\n"; }); var_dump($c instanceof Async\Continuation); $c->switchTo(); var_dump(($sched->current)()); + +// currentContinuation captures the flow that is running right now (main). +$main = ($sched->capture)(); +var_dump($main instanceof Async\Continuation); ?> --EXPECT-- bool(true) in continuation NULL +bool(true) diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c index f72d5d11c58d..12f9b2a4cddd 100644 --- a/Zend/zend_scheduler_hook.c +++ b/Zend/zend_scheduler_hook.c @@ -176,16 +176,18 @@ static bool php_async_thunk_launch(void) /* The bridge hands a PHP scheduler its mandate as closures; a C scheduler * reaches the primitives directly and never runs this thunk. Switching is a * Continuation method, so it is not in the mandate. */ - zval args[2], retval; + zval args[3], retval; php_async_mandate_closure(&args[0], ZEND_STRL("create")); - php_async_mandate_closure(&args[1], ZEND_STRL("currentcoroutine")); + php_async_mandate_closure(&args[1], ZEND_STRL("current")); + php_async_mandate_closure(&args[2], ZEND_STRL("currentcoroutine")); - const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_LAUNCH), 2, args, &retval); + const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_LAUNCH), 3, args, &retval); php_async_record_current(ok, &retval); zval_ptr_dtor(&args[0]); zval_ptr_dtor(&args[1]); + zval_ptr_dtor(&args[2]); zval_ptr_dtor(&retval); return ok; } @@ -600,6 +602,7 @@ static zend_function async_continuation_root_function; typedef struct { zend_fiber_context *ctx; /* NULL until create() */ + bool captured; /* ctx is borrowed from a running flow, never owned */ zend_fcall_info fci; zend_fcall_info_cache fcc; zval result; @@ -729,6 +732,8 @@ static zend_object *async_continuation_create_object(zend_class_entry *ce) co->std.handlers = &async_continuation_handlers; co->ctx = NULL; + co->captured = false; + ZVAL_UNDEF(&co->fci.function_name); ZVAL_UNDEF(&co->result); co->vm_stack = NULL; @@ -748,14 +753,19 @@ static void async_continuation_free_object(zend_object *object) } if (co->ctx != NULL) { - /* A finished coroutine's context was already destroyed by - * zend_fiber_switch_context when control returned (it frees a DEAD - * context). Only destroy one that never ran to completion. */ - if (co->ctx->status != ZEND_FIBER_STATUS_DEAD) { - zend_fiber_destroy_context(co->ctx); + if (co->captured) { + /* Borrowed from a running flow (e.g. main): its owner frees it. */ + co->ctx = NULL; + } else { + /* A finished coroutine's context was already destroyed by + * zend_fiber_switch_context when control returned (it frees a DEAD + * context). Only destroy one that never ran to completion. */ + if (co->ctx->status != ZEND_FIBER_STATUS_DEAD) { + zend_fiber_destroy_context(co->ctx); + } + efree(co->ctx); + co->ctx = NULL; } - efree(co->ctx); - co->ctx = NULL; } if (Z_TYPE(co->fci.function_name) != IS_UNDEF) { @@ -818,6 +828,23 @@ ZEND_METHOD(Async_Continuation, switchTo) } } +ZEND_METHOD(Async_Continuation, current) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + /* Capture the execution context that is running right now (for the main + * flow, the context the engine installed at startup). The object borrows + * the context: free_obj never destroys a captured one. This is how a + * scheduler adopts the main flow as a coroutine. */ + zend_object *obj = async_continuation_create_object(async_ce_Continuation); + async_continuation_t *co = continuation_from_obj(obj); + + co->ctx = EG(current_fiber_context); + co->captured = true; + + RETURN_OBJ(obj); +} + ZEND_METHOD(Async_Continuation, currentCoroutine) { ZEND_PARSE_PARAMETERS_NONE(); diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php index 1ddc9722e3fe..7e005a344774 100644 --- a/Zend/zend_scheduler_hook.stub.php +++ b/Zend/zend_scheduler_hook.stub.php @@ -16,12 +16,19 @@ interface Scheduler /** * Invoked once when the scheduler starts. The bridge hands a PHP scheduler * its mandate as closures (a C scheduler reaches the primitives directly and - * receives none): $createContinuation(callable $entry): Continuation and - * $currentCoroutine(): ?object. Switching is done on the Continuation itself - * ($coroutine->switchTo()), so it is not part of the mandate. + * receives none): $createContinuation(callable $entry): Continuation mints a + * fresh context, $currentContinuation(): Continuation captures the execution + * context that is running right now (how the main flow is adopted as a + * coroutine), and $currentCoroutine(): ?object reads the coroutine the + * engine records as current. Switching is done on the Continuation itself + * ($continuation->switchTo()), so it is not part of the mandate. * Returns the coroutine now current (or null); the engine records it. */ - public function onLaunch(\Closure $createContinuation, \Closure $currentCoroutine): ?object; + public function onLaunch( + \Closure $createContinuation, + \Closure $currentContinuation, + \Closure $currentCoroutine, + ): ?object; /** A graceful shutdown has been requested. */ public function onShutdown(): bool; @@ -101,6 +108,13 @@ public static function create(callable $entry): Continuation {} /** Switch control into this continuation, optionally sending $value; returns what it hands back. */ public function switchTo(mixed $value = null): mixed {} + /** + * @internal Mandate provider: capture the execution context that is running + * right now (for the main flow, the context the engine installed at startup). + * The returned Continuation borrows the context; it never owns it. + */ + public static function current(): Continuation {} + /** @internal Mandate provider: the coroutine the engine records as current. */ public static function currentCoroutine(): ?object {} } diff --git a/Zend/zend_scheduler_hook_arginfo.h b/Zend/zend_scheduler_hook_arginfo.h index d0f3940e8f15..ab100939a5dc 100644 --- a/Zend/zend_scheduler_hook_arginfo.h +++ b/Zend/zend_scheduler_hook_arginfo.h @@ -1,8 +1,9 @@ /* This is a generated file, edit zend_scheduler_hook.stub.php instead. - * Stub hash: 3398ced1741d69589cb995dcbd40fc8ed354b0d3 */ + * Stub hash: 8caf747ba2d5d7c1abb83e6f4b0d1ddfbd1e3960 */ -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onLaunch, 0, 2, IS_OBJECT, 1) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onLaunch, 0, 3, IS_OBJECT, 1) ZEND_ARG_OBJ_INFO(0, createContinuation, Closure, 0) + ZEND_ARG_OBJ_INFO(0, currentContinuation, Closure, 0) ZEND_ARG_OBJ_INFO(0, currentCoroutine, Closure, 0) ZEND_END_ARG_INFO() @@ -69,6 +70,9 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Continuation_switchT ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, value, IS_MIXED, 0, "null") ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Async_Continuation_current, 0, 0, Async\\Continuation, 0) +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Continuation_currentCoroutine, 0, 0, IS_OBJECT, 1) ZEND_END_ARG_INFO() @@ -77,6 +81,7 @@ ZEND_METHOD(Async_SchedulerHook, getModule); ZEND_METHOD(Async_SchedulerHook, defer); ZEND_METHOD(Async_Continuation, create); ZEND_METHOD(Async_Continuation, switchTo); +ZEND_METHOD(Async_Continuation, current); ZEND_METHOD(Async_Continuation, currentCoroutine); static const zend_function_entry class_Async_Scheduler_methods[] = { @@ -104,6 +109,7 @@ static const zend_function_entry class_Async_SchedulerHook_methods[] = { static const zend_function_entry class_Async_Continuation_methods[] = { ZEND_ME(Async_Continuation, create, arginfo_class_Async_Continuation_create, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_ME(Async_Continuation, switchTo, arginfo_class_Async_Continuation_switchTo, ZEND_ACC_PUBLIC) + ZEND_ME(Async_Continuation, current, arginfo_class_Async_Continuation_current, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_ME(Async_Continuation, currentCoroutine, arginfo_class_Async_Continuation_currentCoroutine, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_FE_END }; From 48d14f67c716b7f0a70c4086f345ff2678af7b8e Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:09:59 +0000 Subject: [PATCH 21/23] =?UTF-8?q?async:=20factory-based=20registration=20?= =?UTF-8?q?=E2=80=94=20the=20scheduler=20is=20constructed=20holding=20its?= =?UTF-8?q?=20mandate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback: - SchedulerHook::register(string $module, callable $factory): the factory receives the mandate (createContinuation, currentContinuation, currentCoroutine) and returns the Async\Scheduler, so the scheduler is created in a valid state; the factory runs synchronously inside register(), which is the launch moment for a PHP scheduler - onLaunch is removed from the interface; the launch slot is no longer bridged (C-scheduler concern only) - the engine now learns the current coroutine in exactly one way: the return value of onSuspend() - Closure parameter types are gone from the interface; register() takes callable - tests updated to the factory form; 17/17 --- .../async/continuation_current_main.phpt | 23 +++-- Zend/tests/async/fiber_lowlevel_null.phpt | 3 +- .../tests/async/fiber_managed_after_main.phpt | 3 +- Zend/tests/async/fiber_managed_basic.phpt | 3 +- Zend/tests/async/fiber_managed_throw.phpt | 3 +- Zend/tests/async/fiber_managed_uncaught.phpt | 3 +- Zend/tests/async/microtasks_basic.phpt | 3 +- Zend/tests/async/scheduler_context.phpt | 3 +- .../tests/async/scheduler_hook_constants.phpt | 2 +- Zend/tests/async/scheduler_hook_invalid.phpt | 15 ++- Zend/tests/async/scheduler_hook_launch.phpt | 46 +++++---- Zend/tests/async/scheduler_hook_override.phpt | 5 +- Zend/tests/async/scheduler_hook_register.phpt | 3 +- Zend/tests/async/scheduler_mandate.phpt | 30 +++--- Zend/zend_async_API.h | 9 +- Zend/zend_scheduler_hook.c | 99 ++++++++++--------- Zend/zend_scheduler_hook.stub.php | 54 +++++----- Zend/zend_scheduler_hook_arginfo.h | 11 +-- 18 files changed, 167 insertions(+), 151 deletions(-) diff --git a/Zend/tests/async/continuation_current_main.phpt b/Zend/tests/async/continuation_current_main.phpt index 73a5e52f5629..19905d7f409e 100644 --- a/Zend/tests/async/continuation_current_main.phpt +++ b/Zend/tests/async/continuation_current_main.phpt @@ -2,14 +2,12 @@ Async\Continuation: currentContinuation captures main, a continuation switches back into it --FILE-- create = $createContinuation; - $this->capture = $currentContinuation; - return null; - } +final class CaptureScheduler implements Async\Scheduler { + public function __construct( + public readonly Closure $create, // createContinuation(callable $entry): Continuation + public readonly Closure $capture, // currentContinuation(): Continuation + ) {} + public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; } public function onSuspend(bool $fromMain, bool $isBailout): ?object { return null; } public function onShutdown(): bool { return true; } @@ -20,8 +18,13 @@ $sched = new class implements Async\Scheduler { public function contextFind(object $context, mixed $key): mixed { return null; } public function contextSet(object $context, mixed $key, mixed $value): bool { return true; } public function contextUnset(object $context, mixed $key): bool { return true; } -}; -Async\SchedulerHook::register('test', $sched); +} + +$sched = null; +Async\SchedulerHook::register('test', + function (callable $create, callable $capture) use (&$sched): CaptureScheduler { + return $sched = new CaptureScheduler($create, $capture); + }); // The main flow becomes addressable like any other coroutine: this is the // capture that normalisation ("the main flow is a coroutine too") builds on. diff --git a/Zend/tests/async/fiber_lowlevel_null.phpt b/Zend/tests/async/fiber_lowlevel_null.phpt index 96f12ada205b..fc87a539d134 100644 --- a/Zend/tests/async/fiber_lowlevel_null.phpt +++ b/Zend/tests/async/fiber_lowlevel_null.phpt @@ -2,8 +2,7 @@ interceptFiber returning null keeps the fiber on the low-level path --FILE-- new class implements \Async\Scheduler { public function onShutdown(): bool { return true; } public function onDefer(callable $task): bool { return true; } public function getContext(object $coroutine): object { return new \stdClass(); } diff --git a/Zend/tests/async/fiber_managed_after_main.phpt b/Zend/tests/async/fiber_managed_after_main.phpt index 567822366b45..78943c9cd87e 100644 --- a/Zend/tests/async/fiber_managed_after_main.phpt +++ b/Zend/tests/async/fiber_managed_after_main.phpt @@ -2,8 +2,7 @@ After-main handover runs deferred managed coroutines; abandoned ones die cleanly --FILE-- new class implements \Async\Scheduler { public function onShutdown(): bool { return true; } public function onDefer(callable $task): bool { return true; } public function getContext(object $coroutine): object { return new \stdClass(); } diff --git a/Zend/tests/async/fiber_managed_basic.phpt b/Zend/tests/async/fiber_managed_basic.phpt index 351e6bb91975..94fb380d44a4 100644 --- a/Zend/tests/async/fiber_managed_basic.phpt +++ b/Zend/tests/async/fiber_managed_basic.phpt @@ -3,7 +3,6 @@ A fiber adopted by the scheduler runs through the coroutine path --FILE-- $scheduler); $fiber = new Fiber(function (int $x): int { $y = Fiber::suspend($x + 1); diff --git a/Zend/tests/async/fiber_managed_throw.phpt b/Zend/tests/async/fiber_managed_throw.phpt index 5cf4a6ea9608..691db52f2191 100644 --- a/Zend/tests/async/fiber_managed_throw.phpt +++ b/Zend/tests/async/fiber_managed_throw.phpt @@ -2,8 +2,7 @@ Fiber::throw() on a managed fiber delivers the exception at the suspension point --FILE-- new class implements \Async\Scheduler { public function onShutdown(): bool { return true; } public function onDefer(callable $task): bool { return true; } public function getContext(object $coroutine): object { return new \stdClass(); } diff --git a/Zend/tests/async/fiber_managed_uncaught.phpt b/Zend/tests/async/fiber_managed_uncaught.phpt index fbc243c7abb8..5884056bf380 100644 --- a/Zend/tests/async/fiber_managed_uncaught.phpt +++ b/Zend/tests/async/fiber_managed_uncaught.phpt @@ -2,8 +2,7 @@ An uncaught exception in a managed fiber surfaces at the start()/resume() caller --FILE-- new class implements \Async\Scheduler { public function onShutdown(): bool { return true; } public function onDefer(callable $task): bool { return true; } public function getContext(object $coroutine): object { return new \stdClass(); } diff --git a/Zend/tests/async/microtasks_basic.phpt b/Zend/tests/async/microtasks_basic.phpt index c6968057ab90..674247067151 100644 --- a/Zend/tests/async/microtasks_basic.phpt +++ b/Zend/tests/async/microtasks_basic.phpt @@ -3,7 +3,6 @@ Microtasks: defer() forwards to the scheduler's defer() hook; the queue is the s --FILE-- $scheduler); Async\SchedulerHook::defer(function (): void { echo "task 1\n"; diff --git a/Zend/tests/async/scheduler_context.phpt b/Zend/tests/async/scheduler_context.phpt index 983dab00d78e..0677c87736c0 100644 --- a/Zend/tests/async/scheduler_context.phpt +++ b/Zend/tests/async/scheduler_context.phpt @@ -3,7 +3,6 @@ Fiber operations on a bound fiber: direct inside the scheduler, routed outside --FILE-- $scheduler); $fiber = new Fiber(function (): string { Fiber::suspend('first'); diff --git a/Zend/tests/async/scheduler_hook_constants.phpt b/Zend/tests/async/scheduler_hook_constants.phpt index b81f0012616d..afc137dc2a99 100644 --- a/Zend/tests/async/scheduler_hook_constants.phpt +++ b/Zend/tests/async/scheduler_hook_constants.phpt @@ -11,4 +11,4 @@ echo implode(',', $methods), "\n"; ?> --EXPECT-- bool(true) -contextFind,contextSet,contextUnset,getContext,getInternalContext,onDefer,onEnqueue,onFiber,onLaunch,onShutdown,onSuspend +contextFind,contextSet,contextUnset,getContext,getInternalContext,onDefer,onEnqueue,onFiber,onShutdown,onSuspend diff --git a/Zend/tests/async/scheduler_hook_invalid.phpt b/Zend/tests/async/scheduler_hook_invalid.phpt index b653ed0f4111..b47c7780f7a1 100644 --- a/Zend/tests/async/scheduler_hook_invalid.phpt +++ b/Zend/tests/async/scheduler_hook_invalid.phpt @@ -1,16 +1,25 @@ --TEST-- -Async\SchedulerHook::register requires an Async\Scheduler instance +Async\SchedulerHook::register validates the factory and what it returns --FILE-- getMessage(), "\n"; } +// The factory must return an Async\Scheduler instance. +try { + Async\SchedulerHook::register('test', fn () => new stdClass()); +} catch (\TypeError $e) { + echo $e->getMessage(), "\n"; +} + // A refused registration leaves no active scheduler. var_dump(Async\SchedulerHook::getModule()); ?> ---EXPECT-- -Async\SchedulerHook::register(): Argument #2 ($scheduler) must be of type Async\Scheduler, stdClass given +--EXPECTF-- +Async\SchedulerHook::register(): Argument #2 ($factory) must be a valid callback, %s +Async\SchedulerHook::register(): Argument #2 ($factory) must return an instance of Async\Scheduler NULL diff --git a/Zend/tests/async/scheduler_hook_launch.phpt b/Zend/tests/async/scheduler_hook_launch.phpt index 1c47af56f868..f6e60534aa53 100644 --- a/Zend/tests/async/scheduler_hook_launch.phpt +++ b/Zend/tests/async/scheduler_hook_launch.phpt @@ -1,27 +1,35 @@ --TEST-- -Async\SchedulerHook: launch runs immediately at PHP registration +Async\SchedulerHook: the factory runs synchronously inside register() --FILE-- launched = true; return null; } +$order = []; + +$factory = function () use (&$order): \Async\Scheduler { + $order[] = 'factory'; + return new class implements \Async\Scheduler { + public function onShutdown(): bool { return true; } + public function onFiber(\Fiber $fiber): ?object { return null; } + public function onDefer(callable $task): bool { return true; } + public function getContext(object $coroutine): object { return new \stdClass(); } + public function getInternalContext(object $coroutine): object { return new \stdClass(); } + public function contextFind(object $context, mixed $key): mixed { return null; } + public function contextSet(object $context, mixed $key, mixed $value): bool { return true; } + public function contextUnset(object $context, mixed $key): bool { return true; } + public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; } + public function onSuspend(bool $fromMain, bool $isBailout): ?object { return null; } + }; }; -Async\SchedulerHook::register('test', $scheduler); +$order[] = 'before register'; +Async\SchedulerHook::register('test', $factory); +$order[] = 'after register'; -// The engine launch point has already passed by the time userland runs, -// so a PHP scheduler is launched synchronously inside register(). -var_dump($scheduler->launched); +// The engine launch point has already passed by the time userland runs, so +// the factory (the launch moment for a PHP scheduler) runs synchronously +// inside register(). +echo implode("\n", $order), "\n"; ?> --EXPECT-- -bool(true) +before register +factory +after register diff --git a/Zend/tests/async/scheduler_hook_override.phpt b/Zend/tests/async/scheduler_hook_override.phpt index 750f1746851f..f74c7874cc7d 100644 --- a/Zend/tests/async/scheduler_hook_override.phpt +++ b/Zend/tests/async/scheduler_hook_override.phpt @@ -3,7 +3,6 @@ Async\SchedulerHook::register can be called only once --FILE-- new class implements \Async\Scheduler { - public function onLaunch(\Closure $createContinuation, \Closure $currentContinuation, \Closure $currentCoroutine): ?object { return null; } public function onShutdown(): bool { return true; } public function onFiber(\Fiber $fiber): ?object { return null; } public function onDefer(callable $task): bool { return true; } @@ -17,11 +16,11 @@ $make = fn () => new class implements \Async\Scheduler { }; // First registration succeeds. -var_dump(Async\SchedulerHook::register('a', $make())); +var_dump(Async\SchedulerHook::register('a', $make)); // A second registration throws: a scheduler is registered once per process. try { - Async\SchedulerHook::register('b', $make()); + Async\SchedulerHook::register('b', $make); } catch (\Error $e) { echo $e->getMessage(), "\n"; } diff --git a/Zend/tests/async/scheduler_hook_register.phpt b/Zend/tests/async/scheduler_hook_register.phpt index 3b177a763df3..659fd75ab652 100644 --- a/Zend/tests/async/scheduler_hook_register.phpt +++ b/Zend/tests/async/scheduler_hook_register.phpt @@ -4,8 +4,7 @@ Async\SchedulerHook::register activates and getModule() reports the driver new class implements \Async\Scheduler { public function onShutdown(): bool { return true; } public function onFiber(\Fiber $fiber): ?object { return null; } public function onDefer(callable $task): bool { return true; } diff --git a/Zend/tests/async/scheduler_mandate.phpt b/Zend/tests/async/scheduler_mandate.phpt index b5aec245c3fd..e2b463d5dd79 100644 --- a/Zend/tests/async/scheduler_mandate.phpt +++ b/Zend/tests/async/scheduler_mandate.phpt @@ -1,17 +1,14 @@ --TEST-- -Async\Scheduler::onLaunch receives the createContinuation/currentContinuation/currentCoroutine mandate +The register() factory receives the createContinuation/currentContinuation/currentCoroutine mandate --FILE-- create = $createContinuation; - $this->capture = $currentContinuation; - $this->current = $currentCoroutine; - return null; - } +final class MandateScheduler implements Async\Scheduler { + public function __construct( + public readonly Closure $create, // createContinuation(callable $entry): Continuation + public readonly Closure $capture, // currentContinuation(): Continuation + public readonly Closure $current, // currentCoroutine(): ?object + ) {} + public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; } public function onSuspend(bool $fromMain, bool $isBailout): ?object { return null; } public function onShutdown(): bool { return true; } @@ -22,8 +19,15 @@ $sched = new class implements Async\Scheduler { public function contextFind(object $context, mixed $key): mixed { return null; } public function contextSet(object $context, mixed $key, mixed $value): bool { return true; } public function contextUnset(object $context, mixed $key): bool { return true; } -}; -Async\SchedulerHook::register('test', $sched); +} + +$sched = null; +Async\SchedulerHook::register('test', + function (callable $create, callable $capture, callable $current) use (&$sched): MandateScheduler { + // The scheduler is created in a valid state: the mandate arrives + // through the constructor, not through a later hook. + return $sched = new MandateScheduler($create, $capture, $current); + }); // createContinuation mints a Continuation the scheduler can drive; it switches // into it with the Continuation's own switchTo(). diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h index aea6eaddb571..e067e4d80caf 100644 --- a/Zend/zend_async_API.h +++ b/Zend/zend_async_API.h @@ -490,8 +490,7 @@ typedef enum { * incoming array keys. */ typedef enum { - PHP_ASYNC_HOOK_LAUNCH = 0, - PHP_ASYNC_HOOK_SHUTDOWN, + PHP_ASYNC_HOOK_SHUTDOWN = 0, PHP_ASYNC_HOOK_INTERCEPT_FIBER, PHP_ASYNC_HOOK_ENQUEUE, PHP_ASYNC_HOOK_SUSPEND, @@ -517,9 +516,9 @@ typedef struct { /* The registered Async\Scheduler instance; one ref held for the request. * The bound hook methods borrow this object. */ zend_object *scheduler; - /* The coroutine object the scheduler last returned from the suspend/launch - * hook — the engine's record of the current coroutine. Not exposed as a PHP - * API (that is the scheduler's business); kept for the core. */ + /* The coroutine object the scheduler last returned from the suspend hook: + * the single way the engine learns the current coroutine. Not exposed as a + * PHP API (that is the scheduler's business); kept for the core. */ zend_object *current_coroutine; php_async_hook_t hooks[PHP_ASYNC_HOOK_COUNT]; } php_async_handlers_t; diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c index 12f9b2a4cddd..242480acc165 100644 --- a/Zend/zend_scheduler_hook.c +++ b/Zend/zend_scheduler_hook.c @@ -43,7 +43,6 @@ static const struct { const char *name; size_t len; } php_async_hook_methods[PHP_ASYNC_HOOK_COUNT] = { - [PHP_ASYNC_HOOK_LAUNCH] = { ZEND_STRL("onlaunch") }, [PHP_ASYNC_HOOK_SHUTDOWN] = { ZEND_STRL("onshutdown") }, [PHP_ASYNC_HOOK_INTERCEPT_FIBER] = { ZEND_STRL("onfiber") }, [PHP_ASYNC_HOOK_ENQUEUE] = { ZEND_STRL("onenqueue") }, @@ -144,9 +143,9 @@ static bool php_async_hook_call_bool(php_async_hook_id id, uint32_t argc, zval * /// Non-coroutine thunks ///////////////////////////////////////////////////////////////////// -/* Record the coroutine a hook returned (launch/suspend) as the current one. - * The engine keeps it; it is not exposed as a PHP API (the scheduler owns how - * userland sees the coroutine). */ +/* Record the coroutine the suspend hook returned as the current one: the + * single way the engine learns it. The engine keeps it; it is not exposed as + * a PHP API (the scheduler owns how userland sees the coroutine). */ static void php_async_record_current(bool ok, zval *retval) { zend_object *current = (ok && Z_TYPE_P(retval) == IS_OBJECT) ? Z_OBJ_P(retval) : NULL; @@ -171,27 +170,6 @@ static void php_async_mandate_closure(zval *out, const char *name, size_t len) zend_create_fake_closure(out, fn, async_ce_Continuation, async_ce_Continuation, NULL); } -static bool php_async_thunk_launch(void) -{ - /* The bridge hands a PHP scheduler its mandate as closures; a C scheduler - * reaches the primitives directly and never runs this thunk. Switching is a - * Continuation method, so it is not in the mandate. */ - zval args[3], retval; - php_async_mandate_closure(&args[0], ZEND_STRL("create")); - php_async_mandate_closure(&args[1], ZEND_STRL("current")); - php_async_mandate_closure(&args[2], ZEND_STRL("currentcoroutine")); - - const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_LAUNCH), 3, args, &retval); - - php_async_record_current(ok, &retval); - - zval_ptr_dtor(&args[0]); - zval_ptr_dtor(&args[1]); - zval_ptr_dtor(&args[2]); - zval_ptr_dtor(&retval); - return ok; -} - static bool php_async_thunk_shutdown(void) { return php_async_hook_call_bool(PHP_ASYNC_HOOK_SHUTDOWN, 0, NULL); @@ -449,9 +427,9 @@ static void php_async_build_api(zend_async_scheduler_api_t *api) memset(api, 0, sizeof(*api)); api->size = sizeof(*api); - if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_LAUNCH)->set) { - api->launch = php_async_thunk_launch; - } + /* launch is not bridged: for a PHP scheduler the launch moment is the + * factory invocation inside register(), because the engine's own launch + * point has already passed by the time userland code runs. */ if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_SHUTDOWN)->set) { api->shutdown = php_async_thunk_shutdown; @@ -503,20 +481,14 @@ static void php_async_build_api(zend_async_scheduler_api_t *api) ZEND_METHOD(Async_SchedulerHook, register) { zend_string *module; - zend_object *scheduler; + zend_fcall_info factory_fci; + zend_fcall_info_cache factory_fcc; ZEND_PARSE_PARAMETERS_START(2, 2) Z_PARAM_STR(module) - Z_PARAM_OBJ_OF_CLASS(scheduler, async_ce_Scheduler) + Z_PARAM_FUNC(factory_fci, factory_fcc) ZEND_PARSE_PARAMETERS_END(); - /* A scheduler provides whatever hooks it needs; the two below are mandatory. */ - if (zend_hash_str_find_ptr(&scheduler->ce->function_table, ZEND_STRL("onenqueue")) == NULL - || zend_hash_str_find_ptr(&scheduler->ce->function_table, ZEND_STRL("onsuspend")) == NULL) { - zend_type_error("Async\\SchedulerHook::register(): Argument #2 ($scheduler) must implement onEnqueue() and onSuspend()"); - RETURN_THROWS(); - } - /* A scheduler is registered once per process — by a C extension or by * PHP, whichever comes first. */ if (zend_async_is_enabled()) { @@ -524,12 +496,56 @@ ZEND_METHOD(Async_SchedulerHook, register) RETURN_THROWS(); } + /* The factory receives the mandate and returns the scheduler, so the + * scheduler is constructed already holding its capabilities. For a PHP + * scheduler this call IS the launch moment: the engine's own launch point + * has already passed by the time userland code runs. */ + zval args[3], retval; + php_async_mandate_closure(&args[0], ZEND_STRL("create")); + php_async_mandate_closure(&args[1], ZEND_STRL("current")); + php_async_mandate_closure(&args[2], ZEND_STRL("currentcoroutine")); + + factory_fci.param_count = 3; + factory_fci.params = args; + factory_fci.retval = &retval; + + const bool saved_context = ZEND_ASYNC_IN_SCHEDULER_CONTEXT; + ZEND_ASYNC_IN_SCHEDULER_CONTEXT = true; + const zend_result called = zend_call_function(&factory_fci, &factory_fcc); + ZEND_ASYNC_IN_SCHEDULER_CONTEXT = saved_context; + + zval_ptr_dtor(&args[0]); + zval_ptr_dtor(&args[1]); + zval_ptr_dtor(&args[2]); + + if (called != SUCCESS || EG(exception)) { + zval_ptr_dtor(&retval); + RETURN_THROWS(); + } + + if (Z_TYPE(retval) != IS_OBJECT + || !instanceof_function(Z_OBJCE(retval), async_ce_Scheduler)) { + zval_ptr_dtor(&retval); + zend_type_error("Async\\SchedulerHook::register(): Argument #2 ($factory) must return an instance of Async\\Scheduler"); + RETURN_THROWS(); + } + + zend_object *scheduler = Z_OBJ(retval); + + /* A scheduler provides whatever hooks it needs; the two below are mandatory. */ + if (zend_hash_str_find_ptr(&scheduler->ce->function_table, ZEND_STRL("onenqueue")) == NULL + || zend_hash_str_find_ptr(&scheduler->ce->function_table, ZEND_STRL("onsuspend")) == NULL) { + zval_ptr_dtor(&retval); + zend_type_error("Async\\SchedulerHook::register(): the scheduler must implement onEnqueue() and onSuspend()"); + RETURN_THROWS(); + } + for (php_async_hook_id id = 0; id < PHP_ASYNC_HOOK_COUNT; id++) { php_async_hook_bind(scheduler, id); } + /* The handlers container takes over the factory's reference. */ PHP_ASYNC_HANDLERS.scheduler = scheduler; - GC_ADDREF(scheduler); PHP_ASYNC_HANDLERS.module = zend_string_copy(module); PHP_ASYNC_HANDLERS.active = true; @@ -541,14 +557,7 @@ ZEND_METHOD(Async_SchedulerHook, register) RETURN_FALSE; } - /* The engine's own launch point runs before userland code; a PHP - * scheduler therefore launches immediately at registration. */ ZEND_ASYNC_INITIALIZE; - - if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_LAUNCH)->set) { - ZEND_ASYNC_SCHEDULER_LAUNCH(); - } - ZEND_ASYNC_ACTIVATE; RETURN_TRUE; diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php index 7e005a344774..14098b71aa53 100644 --- a/Zend/zend_scheduler_hook.stub.php +++ b/Zend/zend_scheduler_hook.stub.php @@ -5,31 +5,16 @@ namespace Async; /** - * A scheduler plugs into the concurrent mode by implementing this interface and - * handing an instance to Async\SchedulerHook::register(). The on* methods are - * event callbacks the engine invokes; the engine performs the context switches. + * A scheduler plugs into the concurrent mode by returning an instance of this + * interface from the factory handed to Async\SchedulerHook::register(). The on* + * methods are event callbacks the engine invokes; the engine performs the + * context switches. * - * Extend Async\AbstractScheduler to implement only the hooks you need. + * The engine learns the current coroutine in exactly one way: the return value + * of onSuspend(). It never chooses one on its own. */ interface Scheduler { - /** - * Invoked once when the scheduler starts. The bridge hands a PHP scheduler - * its mandate as closures (a C scheduler reaches the primitives directly and - * receives none): $createContinuation(callable $entry): Continuation mints a - * fresh context, $currentContinuation(): Continuation captures the execution - * context that is running right now (how the main flow is adopted as a - * coroutine), and $currentCoroutine(): ?object reads the coroutine the - * engine records as current. Switching is done on the Continuation itself - * ($continuation->switchTo()), so it is not part of the mandate. - * Returns the coroutine now current (or null); the engine records it. - */ - public function onLaunch( - \Closure $createContinuation, - \Closure $currentContinuation, - \Closure $currentCoroutine, - ): ?object; - /** A graceful shutdown has been requested. */ public function onShutdown(): bool; @@ -66,20 +51,35 @@ public function contextUnset(object $context, mixed $key): bool; /** * Activation point for the concurrent mode. * - * A scheduler is registered by handing register() an object that implements - * Async\Scheduler. There is exactly one scheduler per process, so the class - * is used only through its static methods. + * A scheduler is registered by handing register() a factory that returns an + * Async\Scheduler. There is exactly one scheduler per process, so the class is + * used only through its static methods. */ final class SchedulerHook { /** * Registers a scheduler and activates the concurrent mode. * + * The factory receives the mandate and returns the scheduler, constructed + * already holding its capabilities: + * + * fn (callable $createContinuation, callable $currentContinuation, + * callable $currentCoroutine): Async\Scheduler + * + * $createContinuation(callable $entry): Continuation mints a fresh context, + * $currentContinuation(): Continuation captures the context running right + * now (how the main flow is adopted as a coroutine), and + * $currentCoroutine(): ?object reads the coroutine the engine records as + * current. Switching is done on the Continuation itself + * ($continuation->switchTo()), so it is not part of the mandate. For a PHP + * scheduler the factory call is the launch moment: the engine's own launch + * point has already passed by the time userland code runs. + * * A scheduler is registered once per process: calling this when a * scheduler is already registered (by a C extension or by an earlier PHP * call) throws an Error. */ - public static function register(string $module, Scheduler $scheduler): bool {} + public static function register(string $module, callable $factory): bool {} /** Returns the module name of the registered scheduler, or null when none. */ public static function getModule(): ?string {} @@ -97,8 +97,8 @@ public static function defer(callable $task): void {} * (on return, control goes back to the switcher). Backed by the engine's fiber * context, so it is compatible with fiber-aware tooling. * - * NOTE: `create`/`switchTo` are temporary PoC entry points; the real API hands - * these as a capability to the scheduler at onLaunch(). + * NOTE: `create`/`current` are temporary PoC entry points; the real API hands + * them as capabilities to the scheduler's factory at registration. */ final class Continuation { diff --git a/Zend/zend_scheduler_hook_arginfo.h b/Zend/zend_scheduler_hook_arginfo.h index ab100939a5dc..c200a6c1fc70 100644 --- a/Zend/zend_scheduler_hook_arginfo.h +++ b/Zend/zend_scheduler_hook_arginfo.h @@ -1,11 +1,5 @@ /* This is a generated file, edit zend_scheduler_hook.stub.php instead. - * Stub hash: 8caf747ba2d5d7c1abb83e6f4b0d1ddfbd1e3960 */ - -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onLaunch, 0, 3, IS_OBJECT, 1) - ZEND_ARG_OBJ_INFO(0, createContinuation, Closure, 0) - ZEND_ARG_OBJ_INFO(0, currentContinuation, Closure, 0) - ZEND_ARG_OBJ_INFO(0, currentCoroutine, Closure, 0) -ZEND_END_ARG_INFO() + * Stub hash: def7e0aaeb880a663737a125b4cff010f76a10d8 */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onShutdown, 0, 0, _IS_BOOL, 0) ZEND_END_ARG_INFO() @@ -52,7 +46,7 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_SchedulerHook_register, 0, 2, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, module, IS_STRING, 0) - ZEND_ARG_OBJ_INFO(0, scheduler, Async\\Scheduler, 0) + ZEND_ARG_TYPE_INFO(0, factory, IS_CALLABLE, 0) ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_SchedulerHook_getModule, 0, 0, IS_STRING, 1) @@ -85,7 +79,6 @@ ZEND_METHOD(Async_Continuation, current); ZEND_METHOD(Async_Continuation, currentCoroutine); static const zend_function_entry class_Async_Scheduler_methods[] = { - ZEND_RAW_FENTRY("onLaunch", NULL, arginfo_class_Async_Scheduler_onLaunch, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_RAW_FENTRY("onShutdown", NULL, arginfo_class_Async_Scheduler_onShutdown, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_RAW_FENTRY("onFiber", NULL, arginfo_class_Async_Scheduler_onFiber, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_RAW_FENTRY("onEnqueue", NULL, arginfo_class_Async_Scheduler_onEnqueue, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) From 0ba4c429bdbdbcc2e939c60a38c6519120b09a19 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:01:25 +0000 Subject: [PATCH 22/23] async: one error channel, switchTo($error), switch handlers and fork API - PHP hooks report failure by exception only: onShutdown/onDefer/contextSet return void (new call_void bridge path), register() throws instead of returning false; bool stays where false is data (onEnqueue, contextUnset) - Continuation::switchTo(mixed $value = null, ?Throwable $error = null): a non-null error is thrown at the target's suspension point; a first entry with an error finishes the continuation without starting the body; both arguments at once is a ValueError; switching into a finished continuation throws Error (+continuation_switchto_error test) - fix enqueue/resume rejection without a pending exception: throw FiberError at the PHP boundary instead of RETURN_THROWS on empty EG(exception) - port the C-only coroutine switch-handler API (per-coroutine vector + main-coroutine start handlers) from the true-async tree - port the fork API with default deny: ZEND_ASYNC_BEFORE_FORK() throws while the Async state is on unless a fork-hook pair is registered; pcntl_fork() guarded, child reinitialised via ZEND_ASYNC_AFTER_FORK_CHILD() - update tests to the new signatures (18/18 async, fibers green) --- .../async/continuation_current_main.phpt | 6 +- .../async/continuation_switchto_error.phpt | 52 +++++ Zend/tests/async/fiber_lowlevel_null.phpt | 6 +- .../tests/async/fiber_managed_after_main.phpt | 6 +- Zend/tests/async/fiber_managed_basic.phpt | 6 +- Zend/tests/async/fiber_managed_throw.phpt | 6 +- Zend/tests/async/fiber_managed_uncaught.phpt | 6 +- Zend/tests/async/microtasks_basic.phpt | 7 +- Zend/tests/async/scheduler_context.phpt | 6 +- Zend/tests/async/scheduler_hook_launch.phpt | 6 +- Zend/tests/async/scheduler_hook_override.phpt | 11 +- Zend/tests/async/scheduler_hook_register.phpt | 10 +- Zend/tests/async/scheduler_mandate.phpt | 6 +- Zend/zend_async_API.c | 205 +++++++++++++++++- Zend/zend_async_API.h | 88 ++++++++ Zend/zend_fibers.c | 15 ++ Zend/zend_scheduler_hook.c | 66 +++++- Zend/zend_scheduler_hook.stub.php | 26 ++- Zend/zend_scheduler_hook_arginfo.h | 15 +- ext/pcntl/pcntl.c | 10 + 20 files changed, 489 insertions(+), 70 deletions(-) create mode 100644 Zend/tests/async/continuation_switchto_error.phpt diff --git a/Zend/tests/async/continuation_current_main.phpt b/Zend/tests/async/continuation_current_main.phpt index 19905d7f409e..3c0309e06c5b 100644 --- a/Zend/tests/async/continuation_current_main.phpt +++ b/Zend/tests/async/continuation_current_main.phpt @@ -10,13 +10,13 @@ final class CaptureScheduler implements Async\Scheduler { public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; } public function onSuspend(bool $fromMain, bool $isBailout): ?object { return null; } - public function onShutdown(): bool { return true; } + public function onShutdown(): void {} public function onFiber(Fiber $fiber): ?object { return null; } - public function onDefer(callable $task): bool { return true; } + public function onDefer(callable $task): void {} public function getContext(object $coroutine): object { return new stdClass(); } public function getInternalContext(object $coroutine): object { return new stdClass(); } public function contextFind(object $context, mixed $key): mixed { return null; } - public function contextSet(object $context, mixed $key, mixed $value): bool { return true; } + public function contextSet(object $context, mixed $key, mixed $value): void {} public function contextUnset(object $context, mixed $key): bool { return true; } } diff --git a/Zend/tests/async/continuation_switchto_error.phpt b/Zend/tests/async/continuation_switchto_error.phpt new file mode 100644 index 000000000000..8f3a23b93d2f --- /dev/null +++ b/Zend/tests/async/continuation_switchto_error.phpt @@ -0,0 +1,52 @@ +--TEST-- +Async\Continuation: switchTo() delivers $error at the suspension point; boundary cases +--FILE-- +switchTo("parked"); + } catch (\RuntimeException $e) { + echo " caught at suspension point: {$e->getMessage()}\n"; + } +}); + +var_dump($worker->switchTo()); +$worker->switchTo(null, new \RuntimeException("cancelled")); + +// 2. Passing both a non-null value and an error is a ValueError. +$other = Async\Continuation::create(function (): void {}); +try { + $other->switchTo("value", new \RuntimeException("boom")); +} catch (\ValueError $e) { + echo "ValueError\n"; +} + +// 3. A first entry with an error finishes the continuation without starting +// the body; the error surfaces at the switch site. +$never = Async\Continuation::create(function (): void { + echo " never printed\n"; +}); +try { + $never->switchTo(null, new \LogicException("cancel before start")); +} catch (\LogicException $e) { + echo "first entry: {$e->getMessage()}\n"; +} + +// 4. Switching into a finished continuation throws an Error. +try { + $never->switchTo(); +} catch (\Error $e) { + echo $e->getMessage(), "\n"; +} +?> +--EXPECT-- + worker started +string(6) "parked" + caught at suspension point: cancelled +ValueError +first entry: cancel before start +Cannot switch into a finished continuation diff --git a/Zend/tests/async/fiber_lowlevel_null.phpt b/Zend/tests/async/fiber_lowlevel_null.phpt index fc87a539d134..e964c2a2a64a 100644 --- a/Zend/tests/async/fiber_lowlevel_null.phpt +++ b/Zend/tests/async/fiber_lowlevel_null.phpt @@ -3,12 +3,12 @@ interceptFiber returning null keeps the fiber on the low-level path --FILE-- new class implements \Async\Scheduler { - public function onShutdown(): bool { return true; } - public function onDefer(callable $task): bool { return true; } + public function onShutdown(): void {} + public function onDefer(callable $task): void {} public function getContext(object $coroutine): object { return new \stdClass(); } public function getInternalContext(object $coroutine): object { return new \stdClass(); } public function contextFind(object $context, mixed $key): mixed { return null; } - public function contextSet(object $context, mixed $key, mixed $value): bool { return true; } + public function contextSet(object $context, mixed $key, mixed $value): void {} public function contextUnset(object $context, mixed $key): bool { return true; } public function onFiber(Fiber $fiber): ?object { echo "intercept: null\n"; diff --git a/Zend/tests/async/fiber_managed_after_main.phpt b/Zend/tests/async/fiber_managed_after_main.phpt index 78943c9cd87e..f5eaa43b9f81 100644 --- a/Zend/tests/async/fiber_managed_after_main.phpt +++ b/Zend/tests/async/fiber_managed_after_main.phpt @@ -3,12 +3,12 @@ After-main handover runs deferred managed coroutines; abandoned ones die cleanly --FILE-- new class implements \Async\Scheduler { - public function onShutdown(): bool { return true; } - public function onDefer(callable $task): bool { return true; } + public function onShutdown(): void {} + public function onDefer(callable $task): void {} public function getContext(object $coroutine): object { return new \stdClass(); } public function getInternalContext(object $coroutine): object { return new \stdClass(); } public function contextFind(object $context, mixed $key): mixed { return null; } - public function contextSet(object $context, mixed $key, mixed $value): bool { return true; } + public function contextSet(object $context, mixed $key, mixed $value): void {} public function contextUnset(object $context, mixed $key): bool { return true; } public SplQueue $queue; public function __construct() { $this->queue = new SplQueue(); } diff --git a/Zend/tests/async/fiber_managed_basic.phpt b/Zend/tests/async/fiber_managed_basic.phpt index 94fb380d44a4..3fa421546e5f 100644 --- a/Zend/tests/async/fiber_managed_basic.phpt +++ b/Zend/tests/async/fiber_managed_basic.phpt @@ -3,12 +3,12 @@ A fiber adopted by the scheduler runs through the coroutine path --FILE-- new class implements \Async\Scheduler { - public function onShutdown(): bool { return true; } - public function onDefer(callable $task): bool { return true; } + public function onShutdown(): void {} + public function onDefer(callable $task): void {} public function getContext(object $coroutine): object { return new \stdClass(); } public function getInternalContext(object $coroutine): object { return new \stdClass(); } public function contextFind(object $context, mixed $key): mixed { return null; } - public function contextSet(object $context, mixed $key, mixed $value): bool { return true; } + public function contextSet(object $context, mixed $key, mixed $value): void {} public function contextUnset(object $context, mixed $key): bool { return true; } public SplQueue $queue; public function __construct() { $this->queue = new SplQueue(); } diff --git a/Zend/tests/async/fiber_managed_uncaught.phpt b/Zend/tests/async/fiber_managed_uncaught.phpt index 5884056bf380..64294b371c5c 100644 --- a/Zend/tests/async/fiber_managed_uncaught.phpt +++ b/Zend/tests/async/fiber_managed_uncaught.phpt @@ -3,12 +3,12 @@ An uncaught exception in a managed fiber surfaces at the start()/resume() caller --FILE-- new class implements \Async\Scheduler { - public function onShutdown(): bool { return true; } - public function onDefer(callable $task): bool { return true; } + public function onShutdown(): void {} + public function onDefer(callable $task): void {} public function getContext(object $coroutine): object { return new \stdClass(); } public function getInternalContext(object $coroutine): object { return new \stdClass(); } public function contextFind(object $context, mixed $key): mixed { return null; } - public function contextSet(object $context, mixed $key, mixed $value): bool { return true; } + public function contextSet(object $context, mixed $key, mixed $value): void {} public function contextUnset(object $context, mixed $key): bool { return true; } public SplQueue $queue; public function __construct() { $this->queue = new SplQueue(); } diff --git a/Zend/tests/async/microtasks_basic.phpt b/Zend/tests/async/microtasks_basic.phpt index 674247067151..434c502205e1 100644 --- a/Zend/tests/async/microtasks_basic.phpt +++ b/Zend/tests/async/microtasks_basic.phpt @@ -3,21 +3,20 @@ Microtasks: defer() forwards to the scheduler's defer() hook; the queue is the s --FILE-- tasks = new SplQueue(); } public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; } public function onSuspend(bool $fromMain, bool $isBailout): ?object { return null; } - public function onDefer(callable $task): bool { + public function onDefer(callable $task): void { // The queue is owned by the scheduler, not by the engine. $this->tasks->enqueue($task); - return true; } }; diff --git a/Zend/tests/async/scheduler_context.phpt b/Zend/tests/async/scheduler_context.phpt index 0677c87736c0..05c8b90c90d0 100644 --- a/Zend/tests/async/scheduler_context.phpt +++ b/Zend/tests/async/scheduler_context.phpt @@ -3,12 +3,12 @@ Fiber operations on a bound fiber: direct inside the scheduler, routed outside --FILE-- new class implements \Async\Scheduler { - public function onShutdown(): bool { return true; } + public function onShutdown(): void {} public function onFiber(\Fiber $fiber): ?object { return null; } - public function onDefer(callable $task): bool { return true; } + public function onDefer(callable $task): void {} public function getContext(object $coroutine): object { return new \stdClass(); } public function getInternalContext(object $coroutine): object { return new \stdClass(); } public function contextFind(object $context, mixed $key): mixed { return null; } - public function contextSet(object $context, mixed $key, mixed $value): bool { return true; } + public function contextSet(object $context, mixed $key, mixed $value): void {} public function contextUnset(object $context, mixed $key): bool { return true; } public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; } public function onSuspend(bool $fromMain, bool $isBailout): ?object { return null; } }; // First registration succeeds. -var_dump(Async\SchedulerHook::register('a', $make)); +Async\SchedulerHook::register('a', $make); +echo "registered\n"; // A second registration throws: a scheduler is registered once per process. try { @@ -26,5 +27,5 @@ try { } ?> --EXPECT-- -bool(true) +registered A scheduler is already registered diff --git a/Zend/tests/async/scheduler_hook_register.phpt b/Zend/tests/async/scheduler_hook_register.phpt index 659fd75ab652..7392a070a743 100644 --- a/Zend/tests/async/scheduler_hook_register.phpt +++ b/Zend/tests/async/scheduler_hook_register.phpt @@ -4,23 +4,21 @@ Async\SchedulerHook::register activates and getModule() reports the driver new class implements \Async\Scheduler { - public function onShutdown(): bool { return true; } +Async\SchedulerHook::register('my-driver', fn () => new class implements \Async\Scheduler { + public function onShutdown(): void {} public function onFiber(\Fiber $fiber): ?object { return null; } - public function onDefer(callable $task): bool { return true; } + public function onDefer(callable $task): void {} public function getContext(object $coroutine): object { return new \stdClass(); } public function getInternalContext(object $coroutine): object { return new \stdClass(); } public function contextFind(object $context, mixed $key): mixed { return null; } - public function contextSet(object $context, mixed $key, mixed $value): bool { return true; } + public function contextSet(object $context, mixed $key, mixed $value): void {} public function contextUnset(object $context, mixed $key): bool { return true; } public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; } public function onSuspend(bool $fromMain, bool $isBailout): ?object { return null; } }); -var_dump($ok); var_dump(Async\SchedulerHook::getModule()); ?> --EXPECT-- NULL -bool(true) string(9) "my-driver" diff --git a/Zend/tests/async/scheduler_mandate.phpt b/Zend/tests/async/scheduler_mandate.phpt index e2b463d5dd79..bb66a163dd5c 100644 --- a/Zend/tests/async/scheduler_mandate.phpt +++ b/Zend/tests/async/scheduler_mandate.phpt @@ -11,13 +11,13 @@ final class MandateScheduler implements Async\Scheduler { public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; } public function onSuspend(bool $fromMain, bool $isBailout): ?object { return null; } - public function onShutdown(): bool { return true; } + public function onShutdown(): void {} public function onFiber(Fiber $fiber): ?object { return null; } - public function onDefer(callable $task): bool { return true; } + public function onDefer(callable $task): void {} public function getContext(object $coroutine): object { return new stdClass(); } public function getInternalContext(object $coroutine): object { return new stdClass(); } public function contextFind(object $context, mixed $key): mixed { return null; } - public function contextSet(object $context, mixed $key, mixed $value): bool { return true; } + public function contextSet(object $context, mixed $key, mixed $value): void {} public function contextUnset(object $context, mixed $key): bool { return true; } } diff --git a/Zend/zend_async_API.c b/Zend/zend_async_API.c index 56e53e548da3..7f622ff8b5f0 100644 --- a/Zend/zend_async_API.c +++ b/Zend/zend_async_API.c @@ -29,7 +29,10 @@ static void async_globals_ctor(zend_async_globals_t *globals) static void async_globals_dtor(zend_async_globals_t *globals) { - (void) globals; + if (globals->main_coroutine_start_handlers.data != NULL) { + pefree(globals->main_coroutine_start_handlers.data, 1); + globals->main_coroutine_start_handlers.data = NULL; + } } void zend_async_globals_ctor(void) @@ -54,6 +57,10 @@ void zend_async_globals_ctor(void) void zend_async_globals_dtor(void) { + if (zend_async_globals_api.main_coroutine_start_handlers.data != NULL) { + pefree(zend_async_globals_api.main_coroutine_start_handlers.data, 1); + zend_async_globals_api.main_coroutine_start_handlers.data = NULL; + } } #endif @@ -425,3 +432,199 @@ void zend_async_api_shutdown(void) internal_context_keys_shutdown(); } + +/////////////////////////////////////////////////////////////////// +/// Coroutine switch handlers (C-only) +/////////////////////////////////////////////////////////////////// + +ZEND_API void zend_coroutine_switch_handlers_init(zend_coroutine_t *coroutine) +{ + if (coroutine->switch_handlers != NULL) { + return; + } + + coroutine->switch_handlers = ecalloc(1, sizeof(zend_coroutine_switch_handlers_vector_t)); +} + +ZEND_API void zend_coroutine_switch_handlers_destroy(zend_coroutine_t *coroutine) +{ + if (coroutine->switch_handlers == NULL) { + return; + } + + if (coroutine->switch_handlers->data != NULL) { + efree(coroutine->switch_handlers->data); + } + + efree(coroutine->switch_handlers); + coroutine->switch_handlers = NULL; +} + +ZEND_API uint32_t zend_coroutine_add_switch_handler( + zend_coroutine_t *coroutine, zend_coroutine_switch_handler_fn handler) +{ + ZEND_ASSERT(handler != NULL && "switch handler must not be NULL"); + + if (coroutine->switch_handlers == NULL) { + zend_coroutine_switch_handlers_init(coroutine); + } + + zend_coroutine_switch_handlers_vector_t *vector = coroutine->switch_handlers; + + if (vector->in_execution) { + zend_error(E_WARNING, "Cannot add a switch handler during handler execution"); + return 0; + } + + /* Adding a duplicate is idempotent: return the existing index. */ + for (uint32_t i = 0; i < vector->length; i++) { + if (vector->data[i].handler == handler) { + return i; + } + } + + if (vector->length == vector->capacity) { + vector->capacity = vector->capacity ? vector->capacity * 2 : 4; + vector->data = safe_erealloc( + vector->data, vector->capacity, sizeof(zend_coroutine_switch_handler_t), 0); + } + + const uint32_t index = vector->length; + vector->data[index].handler = handler; + vector->length++; + + return index; +} + +ZEND_API bool zend_coroutine_remove_switch_handler( + zend_coroutine_t *coroutine, uint32_t handler_index) +{ + zend_coroutine_switch_handlers_vector_t *vector = coroutine->switch_handlers; + + if (vector == NULL || handler_index >= vector->length) { + return false; + } + + if (vector->in_execution) { + zend_error(E_WARNING, "Cannot remove a switch handler during handler execution"); + return false; + } + + for (uint32_t i = handler_index; i < vector->length - 1; i++) { + vector->data[i] = vector->data[i + 1]; + } + + vector->length--; + return true; +} + +ZEND_API bool zend_coroutine_call_switch_handlers( + zend_coroutine_t *coroutine, bool is_enter, bool is_finishing) +{ + zend_coroutine_switch_handlers_vector_t *vector = coroutine->switch_handlers; + + if (vector == NULL || vector->length == 0) { + return true; + } + + vector->in_execution = true; + + /* Call every handler; compact away those that ask to be removed. */ + uint32_t write_index = 0; + for (uint32_t read_index = 0; read_index < vector->length; read_index++) { + if (vector->data[read_index].handler(coroutine, is_enter, is_finishing)) { + if (write_index != read_index) { + vector->data[write_index] = vector->data[read_index]; + } + + write_index++; + } + } + + vector->length = write_index; + vector->in_execution = false; + + if (vector->length == 0) { + zend_coroutine_switch_handlers_destroy(coroutine); + } + + return true; +} + +ZEND_API bool zend_async_add_main_coroutine_start_handler(zend_coroutine_switch_handler_fn handler) +{ + zend_coroutine_switch_handlers_vector_t *vector = &ZEND_ASYNC_G(main_coroutine_start_handlers); + + for (uint32_t i = 0; i < vector->length; i++) { + if (vector->data[i].handler == handler) { + return false; + } + } + + if (vector->length == vector->capacity) { + vector->capacity = vector->capacity ? vector->capacity * 2 : 4; + vector->data = safe_perealloc( + vector->data, vector->capacity, sizeof(zend_coroutine_switch_handler_t), 0, 1); + } + + vector->data[vector->length].handler = handler; + vector->length++; + return true; +} + +ZEND_API bool zend_async_call_main_coroutine_start_handlers(zend_coroutine_t *main_coroutine) +{ + zend_coroutine_switch_handlers_vector_t *vector = &ZEND_ASYNC_G(main_coroutine_start_handlers); + + if (vector->length == 0) { + return true; + } + + /* Move the pre-registered handlers onto the coroutine, then run them + * through the regular path so its removal logic applies. */ + for (uint32_t i = 0; i < vector->length; i++) { + zend_coroutine_add_switch_handler(main_coroutine, vector->data[i].handler); + } + + zend_coroutine_call_switch_handlers(main_coroutine, true, false); + + return EG(exception) == NULL; +} + +/////////////////////////////////////////////////////////////////// +/// Fork API +/////////////////////////////////////////////////////////////////// + +ZEND_API zend_async_before_fork_t zend_async_before_fork_fn = NULL; +ZEND_API zend_async_after_fork_child_t zend_async_after_fork_child_fn = NULL; + +ZEND_API void zend_async_fork_register(zend_async_before_fork_t before_fork_fn, + zend_async_after_fork_child_t after_fork_child_fn) +{ + zend_async_before_fork_fn = before_fork_fn; + zend_async_after_fork_child_fn = after_fork_child_fn; +} + +ZEND_API bool zend_async_before_fork(void) +{ + if (ZEND_ASYNC_IS_OFF) { + return true; + } + + /* Default deny: a live scheduler does not survive fork(). A registered + * hook pair is the escape hatch; it throws itself when it refuses. */ + if (zend_async_before_fork_fn == NULL) { + zend_throw_error(NULL, + "Cannot fork() while a scheduler is active: no fork handler is registered"); + return false; + } + + return zend_async_before_fork_fn(); +} + +ZEND_API void zend_async_after_fork_child(void) +{ + if (zend_async_after_fork_child_fn != NULL) { + zend_async_after_fork_child_fn(); + } +} diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h index e067e4d80caf..8504b587e9ba 100644 --- a/Zend/zend_async_API.h +++ b/Zend/zend_async_API.h @@ -124,6 +124,37 @@ typedef void (*zend_async_coroutine_dispose)(zend_coroutine_t *coroutine); */ typedef zend_string *(*zend_coroutine_awaiting_info_fn)(zend_coroutine_t *coroutine); +/* + * Coroutine switch handlers (C-only, never bridged to PHP). + * + * A per-coroutine vector of C callbacks fired around every context switch: + * on enter (is_enter = true), on leave (is_enter = false) and once more when + * the coroutine finishes (is_finishing = true). A handler returns whether to + * keep it registered; returning false removes it after this invocation. + * + * This is the observation seam for engine subsystems and extensions that + * must react to every switch (profilers, debuggers, shutdown watchdogs). A + * scheduler implemented in PHP does not see it: the microtask queue and the + * internal context cover the same patterns at the PHP level. + */ +typedef struct _zend_coroutine_switch_handler_s zend_coroutine_switch_handler_t; +typedef struct _zend_coroutine_switch_handlers_vector_s zend_coroutine_switch_handlers_vector_t; + +typedef bool (*zend_coroutine_switch_handler_fn)( + zend_coroutine_t *coroutine, bool is_enter, bool is_finishing); + +struct _zend_coroutine_switch_handler_s { + zend_coroutine_switch_handler_fn handler; +}; + +struct _zend_coroutine_switch_handlers_vector_s { + uint32_t length; + uint32_t capacity; + zend_coroutine_switch_handler_t *data; + /* Guards against add/remove while the vector is being walked. */ + bool in_execution; +}; + /** * Coroutine lifecycle. The single source of truth, managed by the * scheduler. Maps 1:1 to the PHP-level is*() methods. @@ -162,6 +193,8 @@ struct _zend_coroutine_s { zend_coroutine_awaiting_info_fn awaiting_info; /* Extended dispose handler. Nullable. */ zend_async_coroutine_dispose extended_dispose; + /* C-level switch handlers. NULL until the first one is added. */ + zend_coroutine_switch_handlers_vector_t *switch_handlers; }; /* The lifecycle status is packed into the low 4 bits of `flags`. */ @@ -404,6 +437,43 @@ ZEND_API extern zend_async_intercept_fiber_t zend_async_intercept_fiber_fn; ZEND_API extern zend_async_gc_destructors_t zend_async_gc_destructors_fn; ZEND_API extern zend_async_defer_t zend_async_defer_fn; +/* Coroutine switch handlers (C-only; see the typedefs above). add() returns + * the handler's index for remove(); adding a duplicate returns the existing + * index. call() walks the vector and drops the handlers that return false. */ +ZEND_API uint32_t zend_coroutine_add_switch_handler( + zend_coroutine_t *coroutine, zend_coroutine_switch_handler_fn handler); +ZEND_API bool zend_coroutine_remove_switch_handler( + zend_coroutine_t *coroutine, uint32_t handler_index); +ZEND_API bool zend_coroutine_call_switch_handlers( + zend_coroutine_t *coroutine, bool is_enter, bool is_finishing); +ZEND_API void zend_coroutine_switch_handlers_init(zend_coroutine_t *coroutine); +ZEND_API void zend_coroutine_switch_handlers_destroy(zend_coroutine_t *coroutine); + +/* Handlers applied to the main coroutine at its adoption: registered before + * the main flow has a coroutine object (there is nothing to attach to yet), + * copied onto it and fired when the scheduler adopts it. */ +ZEND_API bool zend_async_add_main_coroutine_start_handler(zend_coroutine_switch_handler_fn handler); +ZEND_API bool zend_async_call_main_coroutine_start_handlers(zend_coroutine_t *main_coroutine); + +/* Fork API. fork() cannot preserve a live scheduler: parked coroutines, + * watcher fds and worker threads do not survive it. While the Async state is + * on and no fork hooks are registered, zend_async_before_fork() throws an + * Error and returns false: the default answer is "no". A C extension that + * can survive a fork registers the pair: before_fork() runs in the parent + * and decides whether this fork is allowed (returning false after throwing), + * after_fork_child() reinitialises the extension's state (the reactor) in + * the freshly forked child. */ +typedef bool (*zend_async_before_fork_t)(void); +typedef void (*zend_async_after_fork_child_t)(void); + +ZEND_API extern zend_async_before_fork_t zend_async_before_fork_fn; +ZEND_API extern zend_async_after_fork_child_t zend_async_after_fork_child_fn; + +ZEND_API void zend_async_fork_register(zend_async_before_fork_t before_fork_fn, + zend_async_after_fork_child_t after_fork_child_fn); +ZEND_API bool zend_async_before_fork(void); +ZEND_API void zend_async_after_fork_child(void); + /* Internal context key registry (implemented by the core): maps a static * C-string name to a process-unique numeric key. Thread-safe under ZTS. */ ZEND_API uint32_t zend_async_internal_context_key_alloc(const char *key_name); @@ -473,6 +543,20 @@ END_EXTERN_C() zend_async_context_set_fn((context), (key), (value)) #define ZEND_ASYNC_CONTEXT_UNSET(context, key) zend_async_context_unset_fn((context), (key)) +/* Coroutine switch handlers (C-only). */ +#define ZEND_COROUTINE_ADD_SWITCH_HANDLER(coroutine, handler) \ + zend_coroutine_add_switch_handler((coroutine), (handler)) +#define ZEND_COROUTINE_ENTER(coroutine) zend_coroutine_call_switch_handlers((coroutine), true, false) +#define ZEND_COROUTINE_LEAVE(coroutine) zend_coroutine_call_switch_handlers((coroutine), false, false) +#define ZEND_COROUTINE_FINISH(coroutine) zend_coroutine_call_switch_handlers((coroutine), false, true) +#define ZEND_ASYNC_ADD_MAIN_COROUTINE_START_HANDLER(handler) \ + zend_async_add_main_coroutine_start_handler(handler) + +/* Fork guard: default deny while the Async state is on, a registered hook + * pair is the escape hatch. */ +#define ZEND_ASYNC_BEFORE_FORK() zend_async_before_fork() +#define ZEND_ASYNC_AFTER_FORK_CHILD() zend_async_after_fork_child() + /////////////////////////////////////////////////////////////////// /// Globals /////////////////////////////////////////////////////////////////// @@ -537,6 +621,10 @@ typedef struct { bool in_scheduler_context; /* PHP-registered scheduler hooks (the Async\SchedulerHook bridge). */ php_async_handlers_t scheduler_hooks; + /* Switch handlers registered before the main flow has a coroutine object; + * copied onto the main coroutine at its adoption. Persistent allocation: + * registrations typically happen at MINIT and outlive the request. */ + zend_coroutine_switch_handlers_vector_t main_coroutine_start_handlers; } zend_async_globals_t; BEGIN_EXTERN_C() diff --git a/Zend/zend_fibers.c b/Zend/zend_fibers.c index 5e0654e6a34c..290e8b93379e 100644 --- a/Zend/zend_fibers.c +++ b/Zend/zend_fibers.c @@ -1099,6 +1099,13 @@ ZEND_METHOD(Fiber, start) } if (!ZEND_ASYNC_ENQUEUE_COROUTINE(fiber->coroutine) || EG(exception)) { + /* A quiet rejection (false without an exception) means the + * scheduler is shutting down: surface it as an error here, at + * the PHP-visible boundary. */ + if (!EG(exception)) { + zend_throw_error(zend_ce_fiber_error, "The scheduler did not accept the coroutine"); + } + RETURN_THROWS(); } @@ -1205,6 +1212,10 @@ ZEND_METHOD(Fiber, resume) fiber->flags &= ~ZEND_FIBER_FLAG_ERROR_TRANSFER; if (!ZEND_ASYNC_RESUME(fiber->coroutine) || EG(exception)) { + if (!EG(exception)) { + zend_throw_error(zend_ce_fiber_error, "The scheduler did not accept the coroutine"); + } + RETURN_THROWS(); } @@ -1260,6 +1271,10 @@ ZEND_METHOD(Fiber, throw) if (!ZEND_ASYNC_RESUME_WITH_ERROR(fiber->coroutine, Z_OBJ_P(exception), false) || EG(exception)) { + if (!EG(exception)) { + zend_throw_error(zend_ce_fiber_error, "The scheduler did not accept the coroutine"); + } + RETURN_THROWS(); } diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c index 242480acc165..cee08ed73621 100644 --- a/Zend/zend_scheduler_hook.c +++ b/Zend/zend_scheduler_hook.c @@ -126,6 +126,8 @@ static bool php_async_hook_call(php_async_hook_t *hook, uint32_t argc, zval *arg return ok; } +/* For hooks whose bool return is data (enqueue: accepted or not). A thrown + * exception also yields false, distinguished by EG(exception). */ static bool php_async_hook_call_bool(php_async_hook_id id, uint32_t argc, zval *argv) { zval retval; @@ -139,6 +141,18 @@ static bool php_async_hook_call_bool(php_async_hook_id id, uint32_t argc, zval * return ok; } +/* For void hooks: the only failure channel is an exception. Returns whether + * the call completed without one. */ +static bool php_async_hook_call_void(php_async_hook_id id, uint32_t argc, zval *argv) +{ + zval retval; + + const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(id), argc, argv, &retval); + + zval_ptr_dtor(&retval); + return ok; +} + ///////////////////////////////////////////////////////////////////// /// Non-coroutine thunks ///////////////////////////////////////////////////////////////////// @@ -172,7 +186,7 @@ static void php_async_mandate_closure(zval *out, const char *name, size_t len) static bool php_async_thunk_shutdown(void) { - return php_async_hook_call_bool(PHP_ASYNC_HOOK_SHUTDOWN, 0, NULL); + return php_async_hook_call_void(PHP_ASYNC_HOOK_SHUTDOWN, 0, NULL); } /* @@ -194,6 +208,7 @@ static void php_async_coroutine_dispose(zend_coroutine_t *coro) { php_coroutine_t *handle = (php_coroutine_t *) coro; + zend_coroutine_switch_handlers_destroy(coro); zval_ptr_dtor(&coro->result); if (coro->exception != NULL) { @@ -372,7 +387,7 @@ static bool php_async_thunk_context_set(zend_async_context_t *context, zval *key ZVAL_COPY(&args[1], key); ZVAL_COPY(&args[2], value); - const bool result = php_async_hook_call_bool(PHP_ASYNC_HOOK_CONTEXT_SET, 3, args); + const bool result = php_async_hook_call_void(PHP_ASYNC_HOOK_CONTEXT_SET, 3, args); zval_ptr_dtor(&args[0]); zval_ptr_dtor(&args[1]); @@ -554,13 +569,12 @@ ZEND_METHOD(Async_SchedulerHook, register) if (!zend_async_scheduler_register(ZSTR_VAL(module), &api)) { php_async_handlers_reset(); - RETURN_FALSE; + zend_throw_error(NULL, "Async\\SchedulerHook::register(): the engine refused the scheduler"); + RETURN_THROWS(); } ZEND_ASYNC_INITIALIZE; ZEND_ASYNC_ACTIVATE; - - RETURN_TRUE; } ZEND_METHOD(Async_SchedulerHook, getModule) @@ -592,7 +606,7 @@ ZEND_METHOD(Async_SchedulerHook, defer) zval arg; ZVAL_COPY(&arg, task); - php_async_hook_call_bool(PHP_ASYNC_HOOK_DEFER, 1, &arg); + php_async_hook_call_void(PHP_ASYNC_HOOK_DEFER, 1, &arg); zval_ptr_dtor(&arg); if (UNEXPECTED(EG(exception))) { @@ -636,6 +650,15 @@ static ZEND_STACK_ALIGNED void async_continuation_entry(zend_fiber_transfer *tra transfer->context = NULL; + /* A first entry with an error cancels the continuation before it ran: the + * body never starts, and the error travels back to the switcher (where it + * surfaces at the switch site, like any unhandled completion). The value + * and the ERROR flag pass through untouched. */ + if (UNEXPECTED(transfer->flags & ZEND_FIBER_TRANSFER_FLAG_ERROR)) { + transfer->context = caller; + return; + } + /* A first entry carries no resume value. */ zval_ptr_dtor(&transfer->value); ZVAL_UNDEF(&transfer->value); @@ -695,14 +718,22 @@ static ZEND_STACK_ALIGNED void async_continuation_entry(zend_fiber_transfer *tra transfer->context = caller; } -/* Symmetric switch into `co`. Ported from TrueAsync's fiber_switch_context_ex. */ -static void async_continuation_switch(async_continuation_t *co, zval *send_value, zval *return_value) +/* Symmetric switch into `co`. Ported from TrueAsync's fiber_switch_context_ex. + * A non-null `error` is delivered instead of a value: it is thrown from the + * switchTo() call the target is suspended in (or, on a first entry, finishes + * the continuation without starting the body). */ +static void async_continuation_switch( + async_continuation_t *co, zval *send_value, zend_object *error, zval *return_value) { async_continuation_starting = co; zend_fiber_transfer transfer = { .context = co->ctx, .flags = 0 }; - if (send_value != NULL) { + if (error != NULL) { + GC_ADDREF(error); + ZVAL_OBJ(&transfer.value, error); + transfer.flags = ZEND_FIBER_TRANSFER_FLAG_ERROR; + } else if (send_value != NULL) { ZVAL_COPY(&transfer.value, send_value); } else { ZVAL_NULL(&transfer.value); @@ -817,12 +848,20 @@ ZEND_METHOD(Async_Continuation, create) ZEND_METHOD(Async_Continuation, switchTo) { zval *value = NULL; + zend_object *error = NULL; - ZEND_PARSE_PARAMETERS_START(0, 1) + ZEND_PARSE_PARAMETERS_START(0, 2) Z_PARAM_OPTIONAL Z_PARAM_ZVAL(value) + Z_PARAM_OBJ_OF_CLASS_OR_NULL(error, zend_ce_throwable) ZEND_PARSE_PARAMETERS_END(); + if (error != NULL && value != NULL && Z_TYPE_P(value) != IS_NULL) { + zend_argument_value_error(1, + "must be null when an error is delivered: there is nowhere the value could arrive"); + RETURN_THROWS(); + } + async_continuation_t *co = continuation_from_obj(Z_OBJ_P(ZEND_THIS)); if (co->ctx == NULL) { @@ -830,7 +869,12 @@ ZEND_METHOD(Async_Continuation, switchTo) RETURN_THROWS(); } - async_continuation_switch(co, value, return_value); + if (UNEXPECTED(co->ctx->status == ZEND_FIBER_STATUS_DEAD)) { + zend_throw_error(NULL, "Cannot switch into a finished continuation"); + RETURN_THROWS(); + } + + async_continuation_switch(co, value, error, return_value); if (UNEXPECTED(EG(exception))) { RETURN_THROWS(); diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php index 14098b71aa53..a01549de5189 100644 --- a/Zend/zend_scheduler_hook.stub.php +++ b/Zend/zend_scheduler_hook.stub.php @@ -16,7 +16,7 @@ interface Scheduler { /** A graceful shutdown has been requested. */ - public function onShutdown(): bool; + public function onShutdown(): void; /** A foreign Fiber is starting: return a coroutine to adopt it, or null. */ public function onFiber(\Fiber $fiber): ?object; @@ -24,7 +24,8 @@ public function onFiber(\Fiber $fiber): ?object; /** * Make a coroutine runnable (enqueue and resume are the same operation). A * non-null $error is raised at the coroutine's suspension point, which is how - * cancellation and IO/timeout failures reach waiting code. + * cancellation and IO/timeout failures reach waiting code. Returns false when + * the coroutine was not accepted (shutdown): a quiet rejection, not an error. */ public function onEnqueue(object $coroutine, ?\Throwable $error = null): bool; @@ -35,7 +36,7 @@ public function onEnqueue(object $coroutine, ?\Throwable $error = null): bool; public function onSuspend(bool $fromMain, bool $isBailout): ?object; /** Store a one-shot microtask on the scheduler's queue. */ - public function onDefer(callable $task): bool; + public function onDefer(callable $task): void; /** The coroutine's userland context (string/object keys). */ public function getContext(object $coroutine): object; @@ -44,7 +45,9 @@ public function getContext(object $coroutine): object; public function getInternalContext(object $coroutine): object; public function contextFind(object $context, mixed $key): mixed; - public function contextSet(object $context, mixed $key, mixed $value): bool; + public function contextSet(object $context, mixed $key, mixed $value): void; + + /** Returns whether the key existed (unset semantics), not success. */ public function contextUnset(object $context, mixed $key): bool; } @@ -77,9 +80,9 @@ final class SchedulerHook * * A scheduler is registered once per process: calling this when a * scheduler is already registered (by a C extension or by an earlier PHP - * call) throws an Error. + * call) throws an Error. Any other failure is an Error too. */ - public static function register(string $module, callable $factory): bool {} + public static function register(string $module, callable $factory): void {} /** Returns the module name of the registered scheduler, or null when none. */ public static function getModule(): ?string {} @@ -105,8 +108,15 @@ final class Continuation /** Create a coroutine execution context that will run $entry. */ public static function create(callable $entry): Continuation {} - /** Switch control into this continuation, optionally sending $value; returns what it hands back. */ - public function switchTo(mixed $value = null): mixed {} + /** + * Switch control into this continuation; returns what it hands back. + * $value is delivered as the return value of the switchTo() call the + * target is suspended in; a non-null $error is thrown from that call + * instead (passing both is a ValueError). A first entry ignores $value; + * a first entry with $error finishes the continuation without starting + * the body. Switching into a finished continuation throws Error. + */ + public function switchTo(mixed $value = null, ?\Throwable $error = null): mixed {} /** * @internal Mandate provider: capture the execution context that is running diff --git a/Zend/zend_scheduler_hook_arginfo.h b/Zend/zend_scheduler_hook_arginfo.h index c200a6c1fc70..f55af0e6caae 100644 --- a/Zend/zend_scheduler_hook_arginfo.h +++ b/Zend/zend_scheduler_hook_arginfo.h @@ -1,7 +1,7 @@ /* This is a generated file, edit zend_scheduler_hook.stub.php instead. - * Stub hash: def7e0aaeb880a663737a125b4cff010f76a10d8 */ + * Stub hash: 4ade5b3f716aa0a3bb51b7b217de7684065b29ce */ -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onShutdown, 0, 0, _IS_BOOL, 0) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onShutdown, 0, 0, IS_VOID, 0) ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onFiber, 0, 1, IS_OBJECT, 1) @@ -18,7 +18,7 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onSuspend, ZEND_ARG_TYPE_INFO(0, isBailout, _IS_BOOL, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onDefer, 0, 1, _IS_BOOL, 0) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onDefer, 0, 1, IS_VOID, 0) ZEND_ARG_TYPE_INFO(0, task, IS_CALLABLE, 0) ZEND_END_ARG_INFO() @@ -33,7 +33,7 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_contextFin ZEND_ARG_TYPE_INFO(0, key, IS_MIXED, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_contextSet, 0, 3, _IS_BOOL, 0) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_contextSet, 0, 3, IS_VOID, 0) ZEND_ARG_TYPE_INFO(0, context, IS_OBJECT, 0) ZEND_ARG_TYPE_INFO(0, key, IS_MIXED, 0) ZEND_ARG_TYPE_INFO(0, value, IS_MIXED, 0) @@ -44,7 +44,7 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_contextUns ZEND_ARG_TYPE_INFO(0, key, IS_MIXED, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_SchedulerHook_register, 0, 2, _IS_BOOL, 0) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_SchedulerHook_register, 0, 2, IS_VOID, 0) ZEND_ARG_TYPE_INFO(0, module, IS_STRING, 0) ZEND_ARG_TYPE_INFO(0, factory, IS_CALLABLE, 0) ZEND_END_ARG_INFO() @@ -52,9 +52,7 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_SchedulerHook_getModule, 0, 0, IS_STRING, 1) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_SchedulerHook_defer, 0, 1, IS_VOID, 0) - ZEND_ARG_TYPE_INFO(0, task, IS_CALLABLE, 0) -ZEND_END_ARG_INFO() +#define arginfo_class_Async_SchedulerHook_defer arginfo_class_Async_Scheduler_onDefer ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Async_Continuation_create, 0, 1, Async\\Continuation, 0) ZEND_ARG_TYPE_INFO(0, entry, IS_CALLABLE, 0) @@ -62,6 +60,7 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Continuation_switchTo, 0, 0, IS_MIXED, 0) ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, value, IS_MIXED, 0, "null") + ZEND_ARG_OBJ_INFO_WITH_DEFAULT_VALUE(0, error, Throwable, 1, "null") ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Async_Continuation_current, 0, 0, Async\\Continuation, 0) diff --git a/ext/pcntl/pcntl.c b/ext/pcntl/pcntl.c index 794e75a1716e..ecc3626bd169 100644 --- a/ext/pcntl/pcntl.c +++ b/ext/pcntl/pcntl.c @@ -30,6 +30,7 @@ #include "php_signal.h" #include "php_ticks.h" #include "zend_fibers.h" +#include "zend_async_API.h" #include "main/php_main.h" #if defined(HAVE_GETPRIORITY) || defined(HAVE_SETPRIORITY) || defined(HAVE_WAIT3) @@ -269,6 +270,12 @@ PHP_FUNCTION(pcntl_fork) ZEND_PARSE_PARAMETERS_NONE(); + /* A live scheduler does not survive fork(): default deny, a registered + * fork-hook pair is the escape hatch. Throws on refusal. */ + if (UNEXPECTED(!ZEND_ASYNC_BEFORE_FORK())) { + RETURN_THROWS(); + } + id = fork(); if (id == -1) { PCNTL_G(last_error) = errno; @@ -294,6 +301,9 @@ PHP_FUNCTION(pcntl_fork) } } else if (id == 0) { php_child_init(); + /* Give the child an independent reactor: the inherited state shares + * the parent's kernel objects, so the hook reinitializes it. */ + ZEND_ASYNC_AFTER_FORK_CHILD(); } RETURN_LONG((zend_long) id); From 3f42db6e5150689890c8bd190c7545b6f1af3d57 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:22:34 +0000 Subject: [PATCH 23/23] async: bridge onWaitInfo through a wait_info slot - new C slot zend_async_wait_info_t (appended to the API bundle) with a quiet no-op stub: wait info is diagnostics, not an error without a subscriber; ZEND_ASYNC_WAIT_INFO() macro for C callers - the PHP bridge binds Scheduler::onWaitInfo(object, string): void and forwards through the void-hook path - onWaitInfo added to the Async\Scheduler interface stub and tests - api_shutdown: also reset gc_destructors/defer/wait_info slots - onEnqueue doc: state what true means (queued and will run) --- .../tests/async/continuation_current_main.phpt | 1 + Zend/tests/async/fiber_lowlevel_null.phpt | 1 + Zend/tests/async/fiber_managed_after_main.phpt | 1 + Zend/tests/async/fiber_managed_basic.phpt | 1 + Zend/tests/async/fiber_managed_throw.phpt | 1 + Zend/tests/async/fiber_managed_uncaught.phpt | 1 + Zend/tests/async/microtasks_basic.phpt | 1 + Zend/tests/async/scheduler_context.phpt | 1 + Zend/tests/async/scheduler_hook_constants.phpt | 2 +- Zend/tests/async/scheduler_hook_launch.phpt | 1 + Zend/tests/async/scheduler_hook_override.phpt | 1 + Zend/tests/async/scheduler_hook_register.phpt | 1 + Zend/tests/async/scheduler_mandate.phpt | 1 + Zend/zend_async_API.c | 17 +++++++++++++++++ Zend/zend_async_API.h | 9 +++++++++ Zend/zend_scheduler_hook.c | 18 ++++++++++++++++++ Zend/zend_scheduler_hook.stub.php | 12 ++++++++++-- Zend/zend_scheduler_hook_arginfo.h | 8 +++++++- 18 files changed, 74 insertions(+), 4 deletions(-) diff --git a/Zend/tests/async/continuation_current_main.phpt b/Zend/tests/async/continuation_current_main.phpt index 3c0309e06c5b..61376c048fdc 100644 --- a/Zend/tests/async/continuation_current_main.phpt +++ b/Zend/tests/async/continuation_current_main.phpt @@ -11,6 +11,7 @@ final class CaptureScheduler implements Async\Scheduler { public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; } public function onSuspend(bool $fromMain, bool $isBailout): ?object { return null; } public function onShutdown(): void {} + public function onWaitInfo(object $coroutine, string $info): void {} public function onFiber(Fiber $fiber): ?object { return null; } public function onDefer(callable $task): void {} public function getContext(object $coroutine): object { return new stdClass(); } diff --git a/Zend/tests/async/fiber_lowlevel_null.phpt b/Zend/tests/async/fiber_lowlevel_null.phpt index e964c2a2a64a..cffbb05dcd92 100644 --- a/Zend/tests/async/fiber_lowlevel_null.phpt +++ b/Zend/tests/async/fiber_lowlevel_null.phpt @@ -4,6 +4,7 @@ interceptFiber returning null keeps the fiber on the low-level path new class implements \Async\Scheduler { public function onShutdown(): void {} + public function onWaitInfo(object $coroutine, string $info): void {} public function onDefer(callable $task): void {} public function getContext(object $coroutine): object { return new \stdClass(); } public function getInternalContext(object $coroutine): object { return new \stdClass(); } diff --git a/Zend/tests/async/fiber_managed_after_main.phpt b/Zend/tests/async/fiber_managed_after_main.phpt index f5eaa43b9f81..590ad9868a6e 100644 --- a/Zend/tests/async/fiber_managed_after_main.phpt +++ b/Zend/tests/async/fiber_managed_after_main.phpt @@ -4,6 +4,7 @@ After-main handover runs deferred managed coroutines; abandoned ones die cleanly new class implements \Async\Scheduler { public function onShutdown(): void {} + public function onWaitInfo(object $coroutine, string $info): void {} public function onDefer(callable $task): void {} public function getContext(object $coroutine): object { return new \stdClass(); } public function getInternalContext(object $coroutine): object { return new \stdClass(); } diff --git a/Zend/tests/async/fiber_managed_basic.phpt b/Zend/tests/async/fiber_managed_basic.phpt index 3fa421546e5f..1ff1ccbfc71e 100644 --- a/Zend/tests/async/fiber_managed_basic.phpt +++ b/Zend/tests/async/fiber_managed_basic.phpt @@ -4,6 +4,7 @@ A fiber adopted by the scheduler runs through the coroutine path new class implements \Async\Scheduler { public function onShutdown(): void {} + public function onWaitInfo(object $coroutine, string $info): void {} public function onDefer(callable $task): void {} public function getContext(object $coroutine): object { return new \stdClass(); } public function getInternalContext(object $coroutine): object { return new \stdClass(); } diff --git a/Zend/tests/async/fiber_managed_uncaught.phpt b/Zend/tests/async/fiber_managed_uncaught.phpt index 64294b371c5c..ab6a2a1a1931 100644 --- a/Zend/tests/async/fiber_managed_uncaught.phpt +++ b/Zend/tests/async/fiber_managed_uncaught.phpt @@ -4,6 +4,7 @@ An uncaught exception in a managed fiber surfaces at the start()/resume() caller new class implements \Async\Scheduler { public function onShutdown(): void {} + public function onWaitInfo(object $coroutine, string $info): void {} public function onDefer(callable $task): void {} public function getContext(object $coroutine): object { return new \stdClass(); } public function getInternalContext(object $coroutine): object { return new \stdClass(); } diff --git a/Zend/tests/async/microtasks_basic.phpt b/Zend/tests/async/microtasks_basic.phpt index 434c502205e1..54fd78b92008 100644 --- a/Zend/tests/async/microtasks_basic.phpt +++ b/Zend/tests/async/microtasks_basic.phpt @@ -4,6 +4,7 @@ Microtasks: defer() forwards to the scheduler's defer() hook; the queue is the s --EXPECT-- bool(true) -contextFind,contextSet,contextUnset,getContext,getInternalContext,onDefer,onEnqueue,onFiber,onShutdown,onSuspend +contextFind,contextSet,contextUnset,getContext,getInternalContext,onDefer,onEnqueue,onFiber,onShutdown,onSuspend,onWaitInfo diff --git a/Zend/tests/async/scheduler_hook_launch.phpt b/Zend/tests/async/scheduler_hook_launch.phpt index 5a5c15306eda..3cc0a89b7fd1 100644 --- a/Zend/tests/async/scheduler_hook_launch.phpt +++ b/Zend/tests/async/scheduler_hook_launch.phpt @@ -8,6 +8,7 @@ $factory = function () use (&$order): \Async\Scheduler { $order[] = 'factory'; return new class implements \Async\Scheduler { public function onShutdown(): void {} + public function onWaitInfo(object $coroutine, string $info): void {} public function onFiber(\Fiber $fiber): ?object { return null; } public function onDefer(callable $task): void {} public function getContext(object $coroutine): object { return new \stdClass(); } diff --git a/Zend/tests/async/scheduler_hook_override.phpt b/Zend/tests/async/scheduler_hook_override.phpt index ec3a7e023d9e..caec959357bc 100644 --- a/Zend/tests/async/scheduler_hook_override.phpt +++ b/Zend/tests/async/scheduler_hook_override.phpt @@ -4,6 +4,7 @@ Async\SchedulerHook::register can be called only once new class implements \Async\Scheduler { public function onShutdown(): void {} + public function onWaitInfo(object $coroutine, string $info): void {} public function onFiber(\Fiber $fiber): ?object { return null; } public function onDefer(callable $task): void {} public function getContext(object $coroutine): object { return new \stdClass(); } diff --git a/Zend/tests/async/scheduler_hook_register.phpt b/Zend/tests/async/scheduler_hook_register.phpt index 7392a070a743..3b4786e01705 100644 --- a/Zend/tests/async/scheduler_hook_register.phpt +++ b/Zend/tests/async/scheduler_hook_register.phpt @@ -6,6 +6,7 @@ var_dump(Async\SchedulerHook::getModule()); Async\SchedulerHook::register('my-driver', fn () => new class implements \Async\Scheduler { public function onShutdown(): void {} + public function onWaitInfo(object $coroutine, string $info): void {} public function onFiber(\Fiber $fiber): ?object { return null; } public function onDefer(callable $task): void {} public function getContext(object $coroutine): object { return new \stdClass(); } diff --git a/Zend/tests/async/scheduler_mandate.phpt b/Zend/tests/async/scheduler_mandate.phpt index bb66a163dd5c..ecd920769d9e 100644 --- a/Zend/tests/async/scheduler_mandate.phpt +++ b/Zend/tests/async/scheduler_mandate.phpt @@ -12,6 +12,7 @@ final class MandateScheduler implements Async\Scheduler { public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; } public function onSuspend(bool $fromMain, bool $isBailout): ?object { return null; } public function onShutdown(): void {} + public function onWaitInfo(object $coroutine, string $info): void {} public function onFiber(Fiber $fiber): ?object { return null; } public function onDefer(callable $task): void {} public function getContext(object $coroutine): object { return new stdClass(); } diff --git a/Zend/zend_async_API.c b/Zend/zend_async_API.c index 7f622ff8b5f0..dcc9b630f7be 100644 --- a/Zend/zend_async_API.c +++ b/Zend/zend_async_API.c @@ -196,6 +196,15 @@ static bool bool_false_stub(void) return false; } +/* Wait info is advisory (diagnostics only): with no subscriber it is + * quietly dropped, not an error. */ +static bool wait_info_stub(zend_coroutine_t *coroutine, zend_string *info) +{ + (void) coroutine; + (void) info; + return true; +} + static zend_async_context_t *get_context_stub(zend_coroutine_t *coroutine) { (void) coroutine; @@ -300,6 +309,7 @@ ZEND_API zend_async_internal_context_unset_t zend_async_internal_context_unset_f ZEND_API zend_async_intercept_fiber_t zend_async_intercept_fiber_fn = NULL; ZEND_API zend_async_gc_destructors_t zend_async_gc_destructors_fn = NULL; ZEND_API zend_async_defer_t zend_async_defer_fn = defer_stub; +ZEND_API zend_async_wait_info_t zend_async_wait_info_fn = wait_info_stub; static const char *scheduler_module_name = NULL; @@ -386,6 +396,10 @@ ZEND_API bool zend_async_scheduler_register( zend_async_defer_fn = api->defer; } + if (API_PROVIDES(api, wait_info)) { + zend_async_wait_info_fn = api->wait_info; + } + scheduler_module_name = module; return true; @@ -429,6 +443,9 @@ void zend_async_api_shutdown(void) zend_async_internal_context_set_fn = internal_context_set_stub; zend_async_internal_context_unset_fn = internal_context_unset_stub; zend_async_intercept_fiber_fn = NULL; + zend_async_gc_destructors_fn = NULL; + zend_async_defer_fn = defer_stub; + zend_async_wait_info_fn = wait_info_stub; internal_context_keys_shutdown(); } diff --git a/Zend/zend_async_API.h b/Zend/zend_async_API.h index 8504b587e9ba..43ac4a353f24 100644 --- a/Zend/zend_async_API.h +++ b/Zend/zend_async_API.h @@ -347,6 +347,11 @@ struct _zend_async_microtask_s { /* Queue the task on the provider's microtask queue. */ typedef bool (*zend_async_defer_t)(zend_async_microtask_t *task); +/* Record a human-readable description of what `coroutine` is waiting for + * ("socket #7 (readable)", "channel recv"), attached by whoever suspends it. + * Diagnostics only (introspection, deadlock reports): no scheduling effect. */ +typedef bool (*zend_async_wait_info_t)(zend_coroutine_t *coroutine, zend_string *info); + /* * GC destructor phase interceptor (around). * @@ -412,6 +417,7 @@ typedef struct _zend_async_scheduler_api_s { zend_async_intercept_fiber_t intercept_fiber; zend_async_gc_destructors_t gc_destructors; zend_async_defer_t defer; + zend_async_wait_info_t wait_info; } zend_async_scheduler_api_t; BEGIN_EXTERN_C() @@ -436,6 +442,7 @@ ZEND_API extern zend_async_internal_context_unset_t zend_async_internal_context_ ZEND_API extern zend_async_intercept_fiber_t zend_async_intercept_fiber_fn; ZEND_API extern zend_async_gc_destructors_t zend_async_gc_destructors_fn; ZEND_API extern zend_async_defer_t zend_async_defer_fn; +ZEND_API extern zend_async_wait_info_t zend_async_wait_info_fn; /* Coroutine switch handlers (C-only; see the typedefs above). add() returns * the handler's index for remove(); adding a duplicate returns the existing @@ -517,6 +524,7 @@ END_EXTERN_C() #define ZEND_ASYNC_GET_EXCEPTION_CE(type) zend_async_get_class_ce_fn(type) #define ZEND_ASYNC_CALL_ON_MAIN_STACK(fn, arg) zend_async_call_on_main_stack_fn((fn), (arg)) #define ZEND_ASYNC_DEFER(task) zend_async_defer_fn(task) +#define ZEND_ASYNC_WAIT_INFO(coroutine, info) zend_async_wait_info_fn((coroutine), (info)) /* The coroutine to bind to a starting fiber, or NULL for the legacy * low-level path. A scheduler with no intercept_fiber slot leaves every @@ -584,6 +592,7 @@ typedef enum { PHP_ASYNC_HOOK_CONTEXT_SET, PHP_ASYNC_HOOK_CONTEXT_UNSET, PHP_ASYNC_HOOK_DEFER, + PHP_ASYNC_HOOK_WAIT_INFO, PHP_ASYNC_HOOK_COUNT } php_async_hook_id; diff --git a/Zend/zend_scheduler_hook.c b/Zend/zend_scheduler_hook.c index cee08ed73621..cc3d063380ac 100644 --- a/Zend/zend_scheduler_hook.c +++ b/Zend/zend_scheduler_hook.c @@ -55,6 +55,7 @@ static const struct { [PHP_ASYNC_HOOK_CONTEXT_SET] = { ZEND_STRL("contextset") }, [PHP_ASYNC_HOOK_CONTEXT_UNSET] = { ZEND_STRL("contextunset") }, [PHP_ASYNC_HOOK_DEFER] = { ZEND_STRL("ondefer") }, + [PHP_ASYNC_HOOK_WAIT_INFO] = { ZEND_STRL("onwaitinfo") }, }; /* Class entries, filled by zend_register_scheduler_hook(). */ @@ -321,6 +322,19 @@ static bool php_async_thunk_wake(php_async_hook_id id, zend_coroutine_t *coro, return result; } +static bool php_async_thunk_wait_info(zend_coroutine_t *coro, zend_string *info) +{ + zval args[2]; + php_async_coroutine_arg(coro, &args[0]); + ZVAL_STR_COPY(&args[1], info); + + const bool result = php_async_hook_call_void(PHP_ASYNC_HOOK_WAIT_INFO, 2, args); + + zval_ptr_dtor(&args[0]); + zval_ptr_dtor(&args[1]); + return result; +} + static bool php_async_thunk_resume( zend_coroutine_t *coro, zend_object *error, const bool transfer_error) { @@ -482,6 +496,10 @@ static void php_async_build_api(zend_async_scheduler_api_t *api) api->context_unset = php_async_thunk_context_unset; } + if (PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_WAIT_INFO)->set) { + api->wait_info = php_async_thunk_wait_info; + } + /* gc_destructors is intentionally not bridged: the destructor phase runs at * the very latest stage of the request (teardown of globals and the object * store), where userland is already being dismantled and a PHP-registered diff --git a/Zend/zend_scheduler_hook.stub.php b/Zend/zend_scheduler_hook.stub.php index a01549de5189..959bd895fc01 100644 --- a/Zend/zend_scheduler_hook.stub.php +++ b/Zend/zend_scheduler_hook.stub.php @@ -24,8 +24,9 @@ public function onFiber(\Fiber $fiber): ?object; /** * Make a coroutine runnable (enqueue and resume are the same operation). A * non-null $error is raised at the coroutine's suspension point, which is how - * cancellation and IO/timeout failures reach waiting code. Returns false when - * the coroutine was not accepted (shutdown): a quiet rejection, not an error. + * cancellation and IO/timeout failures reach waiting code. Returns true when + * the coroutine is queued and will run; false when it was not accepted + * (shutdown): a quiet rejection, not an error. */ public function onEnqueue(object $coroutine, ?\Throwable $error = null): bool; @@ -38,6 +39,13 @@ public function onSuspend(bool $fromMain, bool $isBailout): ?object; /** Store a one-shot microtask on the scheduler's queue. */ public function onDefer(callable $task): void; + /** + * Record a human-readable description of what $coroutine is waiting for + * (e.g. "socket #7 (readable)"), attached by whoever suspended it. Used + * by introspection tooling and deadlock reports; no scheduling effect. + */ + public function onWaitInfo(object $coroutine, string $info): void; + /** The coroutine's userland context (string/object keys). */ public function getContext(object $coroutine): object; diff --git a/Zend/zend_scheduler_hook_arginfo.h b/Zend/zend_scheduler_hook_arginfo.h index f55af0e6caae..6f8b2647319e 100644 --- a/Zend/zend_scheduler_hook_arginfo.h +++ b/Zend/zend_scheduler_hook_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit zend_scheduler_hook.stub.php instead. - * Stub hash: 4ade5b3f716aa0a3bb51b7b217de7684065b29ce */ + * Stub hash: b5420101edac22f6da8f86176fd48ec994ead9e7 */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onShutdown, 0, 0, IS_VOID, 0) ZEND_END_ARG_INFO() @@ -22,6 +22,11 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onDefer, 0 ZEND_ARG_TYPE_INFO(0, task, IS_CALLABLE, 0) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_onWaitInfo, 0, 2, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) + ZEND_ARG_TYPE_INFO(0, info, IS_STRING, 0) +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Scheduler_getContext, 0, 1, IS_OBJECT, 0) ZEND_ARG_TYPE_INFO(0, coroutine, IS_OBJECT, 0) ZEND_END_ARG_INFO() @@ -83,6 +88,7 @@ static const zend_function_entry class_Async_Scheduler_methods[] = { ZEND_RAW_FENTRY("onEnqueue", NULL, arginfo_class_Async_Scheduler_onEnqueue, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_RAW_FENTRY("onSuspend", NULL, arginfo_class_Async_Scheduler_onSuspend, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_RAW_FENTRY("onDefer", NULL, arginfo_class_Async_Scheduler_onDefer, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) + ZEND_RAW_FENTRY("onWaitInfo", NULL, arginfo_class_Async_Scheduler_onWaitInfo, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_RAW_FENTRY("getContext", NULL, arginfo_class_Async_Scheduler_getContext, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_RAW_FENTRY("getInternalContext", NULL, arginfo_class_Async_Scheduler_getInternalContext, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL) ZEND_RAW_FENTRY("contextFind", NULL, arginfo_class_Async_Scheduler_contextFind, ZEND_ACC_PUBLIC|ZEND_ACC_ABSTRACT, NULL, NULL)