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)