diff --git a/Zend/tests/async/continuation_current_main.phpt b/Zend/tests/async/continuation_current_main.phpt
new file mode 100644
index 000000000000..61376c048fdc
--- /dev/null
+++ b/Zend/tests/async/continuation_current_main.phpt
@@ -0,0 +1,52 @@
+--TEST--
+Async\Continuation: currentContinuation captures main, a continuation switches back into it
+--FILE--
+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/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_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/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/tests/async/fiber_lowlevel_null.phpt b/Zend/tests/async/fiber_lowlevel_null.phpt
new file mode 100644
index 000000000000..cffbb05dcd92
--- /dev/null
+++ b/Zend/tests/async/fiber_lowlevel_null.phpt
@@ -0,0 +1,43 @@
+--TEST--
+interceptFiber returning null keeps the fiber on the low-level path
+--FILE--
+ 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(); }
+ public function contextFind(object $context, mixed $key): mixed { return null; }
+ 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";
+ return null;
+ }
+ public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; }
+ public function onSuspend(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 null;
+ }
+});
+
+// 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..590ad9868a6e
--- /dev/null
+++ b/Zend/tests/async/fiber_managed_after_main.phpt
@@ -0,0 +1,54 @@
+--TEST--
+After-main handover runs deferred managed coroutines; abandoned ones die cleanly
+--FILE--
+ 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(); }
+ public function contextFind(object $context, mixed $key): mixed { return null; }
+ 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(); }
+ public function onFiber(Fiber $fiber): ?object {
+ return new class($fiber) {
+ public function __construct(public readonly Fiber $fiber) {}
+ };
+ }
+ public function onEnqueue(object $coroutine, ?Throwable $error = null): bool {
+ $this->queue->enqueue($coroutine);
+ return true;
+ }
+ public function onSuspend(bool $fromMain, bool $isBailout): ?object {
+ // This scheduler defers everything: nothing runs until after main.
+ if (!$fromMain) {
+ return null;
+ }
+ while (!$this->queue->isEmpty()) {
+ $fiber = $this->queue->dequeue()->fiber;
+ $fiber->isStarted() ? $fiber->resume() : $fiber->start();
+ }
+ return null;
+ }
+});
+
+$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";
+?>
+--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..1ff1ccbfc71e
--- /dev/null
+++ b/Zend/tests/async/fiber_managed_basic.phpt
@@ -0,0 +1,61 @@
+--TEST--
+A fiber adopted by the scheduler runs through the coroutine path
+--FILE--
+queue = new SplQueue(); }
+ public function onFiber(Fiber $fiber): ?object {
+ $this->log[] = 'intercept';
+ return new class($fiber) {
+ public function __construct(public readonly Fiber $fiber) {}
+ };
+ }
+ public function onEnqueue(object $coroutine, ?Throwable $error = null): bool {
+ $this->log[] = 'enqueue';
+ $this->queue->enqueue($coroutine);
+ return true;
+ }
+ public function onSuspend(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 null;
+ }
+ }
+ return null;
+ }
+};
+
+Async\SchedulerHook::register('test', fn () => $scheduler);
+
+$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(',', $scheduler->log), "\n";
+?>
+--EXPECT--
+int(6)
+bool(true)
+NULL
+bool(true)
+int(40)
+intercept,enqueue,suspend,enqueue,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..e767b64ada28
--- /dev/null
+++ b/Zend/tests/async/fiber_managed_throw.phpt
@@ -0,0 +1,56 @@
+--TEST--
+Fiber::throw() on a managed fiber delivers the exception at the suspension point
+--FILE--
+ 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(); }
+ public function contextFind(object $context, mixed $key): mixed { return null; }
+ 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(); }
+ public function onFiber(Fiber $fiber): ?object {
+ return new class($fiber) {
+ public function __construct(public readonly Fiber $fiber) {}
+ };
+ }
+ 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 onSuspend(bool $fromMain, bool $isBailout): ?object {
+ while (!$this->queue->isEmpty()) {
+ $fiber = $this->queue->dequeue()->fiber;
+ $fiber->isStarted() ? $fiber->resume() : $fiber->start();
+ if (!$fromMain) {
+ return null;
+ }
+ }
+ return null;
+ }
+});
+
+$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"
+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
new file mode 100644
index 000000000000..ab6a2a1a1931
--- /dev/null
+++ b/Zend/tests/async/fiber_managed_uncaught.phpt
@@ -0,0 +1,51 @@
+--TEST--
+An uncaught exception in a managed fiber surfaces at the start()/resume() caller
+--FILE--
+ 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(); }
+ public function contextFind(object $context, mixed $key): mixed { return null; }
+ 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(); }
+ public function onFiber(Fiber $fiber): ?object {
+ return new class($fiber) {
+ public function __construct(public readonly Fiber $fiber) {}
+ };
+ }
+ public function onEnqueue(object $coroutine, ?Throwable $error = null): bool {
+ $this->queue->enqueue($coroutine);
+ return true;
+ }
+ public function onSuspend(bool $fromMain, bool $isBailout): ?object {
+ while (!$this->queue->isEmpty()) {
+ $fiber = $this->queue->dequeue()->fiber;
+ $fiber->isStarted() ? $fiber->resume() : $fiber->start();
+ if (!$fromMain) {
+ return null;
+ }
+ }
+ return null;
+ }
+});
+
+$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/microtasks_basic.phpt b/Zend/tests/async/microtasks_basic.phpt
new file mode 100644
index 000000000000..54fd78b92008
--- /dev/null
+++ b/Zend/tests/async/microtasks_basic.phpt
@@ -0,0 +1,51 @@
+--TEST--
+Microtasks: defer() forwards to the scheduler's defer() hook; the queue is the scheduler'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): void {
+ // The queue is owned by the scheduler, not by the engine.
+ $this->tasks->enqueue($task);
+ }
+};
+
+Async\SchedulerHook::register('test', fn () => $scheduler);
+
+Async\SchedulerHook::defer(function (): 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 (!$scheduler->tasks->isEmpty()) {
+ ($scheduler->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..0819a9ee2112
--- /dev/null
+++ b/Zend/tests/async/scheduler_context.phpt
@@ -0,0 +1,69 @@
+--TEST--
+Fiber operations on a bound fiber: direct inside the scheduler, routed outside
+--FILE--
+queue = new SplQueue(); }
+ public function onFiber(Fiber $fiber): ?object {
+ return new class($fiber) {
+ public function __construct(public readonly Fiber $fiber) {}
+ };
+ }
+ public function onEnqueue(object $coroutine, ?Throwable $error = null): bool {
+ $this->log[] = 'enqueue';
+ $this->queue->enqueue($coroutine);
+ return true;
+ }
+ public function onSuspend(bool $fromMain, bool $isBailout): ?object {
+ 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();
+
+ if (!$fromMain) {
+ return null;
+ }
+ }
+ return null;
+ }
+};
+
+Async\SchedulerHook::register('test', fn () => $scheduler);
+
+$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(',', $scheduler->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,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
new file mode 100644
index 000000000000..bafd528e4e55
--- /dev/null
+++ b/Zend/tests/async/scheduler_hook_constants.phpt
@@ -0,0 +1,14 @@
+--TEST--
+Async\Scheduler: interface shape
+--FILE--
+isInterface());
+
+$methods = array_map(fn ($m) => $m->name, $r->getMethods());
+sort($methods);
+echo implode(',', $methods), "\n";
+?>
+--EXPECT--
+bool(true)
+contextFind,contextSet,contextUnset,getContext,getInternalContext,onDefer,onEnqueue,onFiber,onShutdown,onSuspend,onWaitInfo
diff --git a/Zend/tests/async/scheduler_hook_invalid.phpt b/Zend/tests/async/scheduler_hook_invalid.phpt
new file mode 100644
index 000000000000..b47c7780f7a1
--- /dev/null
+++ b/Zend/tests/async/scheduler_hook_invalid.phpt
@@ -0,0 +1,25 @@
+--TEST--
+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());
+?>
+--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
new file mode 100644
index 000000000000..3cc0a89b7fd1
--- /dev/null
+++ b/Zend/tests/async/scheduler_hook_launch.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Async\SchedulerHook: the factory runs synchronously inside register()
+--FILE--
+
+--EXPECT--
+before register
+factory
+after register
diff --git a/Zend/tests/async/scheduler_hook_override.phpt b/Zend/tests/async/scheduler_hook_override.phpt
new file mode 100644
index 000000000000..caec959357bc
--- /dev/null
+++ b/Zend/tests/async/scheduler_hook_override.phpt
@@ -0,0 +1,32 @@
+--TEST--
+Async\SchedulerHook::register can be called only once
+--FILE--
+ 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(); }
+ 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): 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.
+Async\SchedulerHook::register('a', $make);
+echo "registered\n";
+
+// A second registration throws: a scheduler is registered once per process.
+try {
+ Async\SchedulerHook::register('b', $make);
+} catch (\Error $e) {
+ echo $e->getMessage(), "\n";
+}
+?>
+--EXPECT--
+registered
+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..3b4786e01705
--- /dev/null
+++ b/Zend/tests/async/scheduler_hook_register.phpt
@@ -0,0 +1,25 @@
+--TEST--
+Async\SchedulerHook::register activates and getModule() reports the driver
+--FILE--
+ 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(); }
+ 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): 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(Async\SchedulerHook::getModule());
+?>
+--EXPECT--
+NULL
+string(9) "my-driver"
diff --git a/Zend/tests/async/scheduler_mandate.phpt b/Zend/tests/async/scheduler_mandate.phpt
new file mode 100644
index 000000000000..ecd920769d9e
--- /dev/null
+++ b/Zend/tests/async/scheduler_mandate.phpt
@@ -0,0 +1,48 @@
+--TEST--
+The register() factory receives the createContinuation/currentContinuation/currentCoroutine mandate
+--FILE--
+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.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..dcc9b630f7be
--- /dev/null
+++ b/Zend/zend_async_API.c
@@ -0,0 +1,647 @@
+/*
+ +----------------------------------------------------------------------+
+ | 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)
+{
+ 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)
+{
+ /* 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)
+{
+ 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
+
+///////////////////////////////////////////////////////////////////
+/// 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;
+}
+
+/* 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;
+ 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;
+ZEND_API zend_async_wait_info_t zend_async_wait_info_fn = wait_info_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;
+ }
+
+ /* 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;
+ }
+
+ if (API_PROVIDES(api, wait_info)) {
+ zend_async_wait_info_fn = api->wait_info;
+ }
+
+ 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 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;
+ zend_async_gc_destructors_fn = NULL;
+ zend_async_defer_fn = defer_stub;
+ zend_async_wait_info_fn = wait_info_stub;
+
+ 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
new file mode 100644
index 000000000000..43ac4a353f24
--- /dev/null
+++ b/Zend/zend_async_API.h
@@ -0,0 +1,667 @@
+/*
+ +----------------------------------------------------------------------+
+ | 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.
+ */
+
+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 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.
+ */
+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;
+ /* 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`. */
+#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);
+
+/* 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).
+ *
+ * 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);
+
+/**
+ * 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 {
+ 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_wait_info_t wait_info;
+} 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;
+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
+ * 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);
+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);
+
+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)
+#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
+ * 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))
+
+/* 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
+///////////////////////////////////////////////////////////////////
+
+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_SHUTDOWN = 0,
+ 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_DEFER,
+ PHP_ASYNC_HOOK_WAIT_INFO,
+ 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;
+ /* 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 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;
+
+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;
+ /* 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()
+#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..290e8b93379e 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,62 @@ 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)) {
+ /* 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();
+ }
+
+ 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 +1181,48 @@ 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)) {
+ if (!EG(exception)) {
+ zend_throw_error(zend_ce_fiber_error, "The scheduler did not accept the coroutine");
+ }
+
+ 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 +1251,37 @@ 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)) {
+ if (!EG(exception)) {
+ zend_throw_error(zend_ce_fiber_error, "The scheduler did not accept the coroutine");
+ }
+
+ 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..cc3d063380ac
--- /dev/null
+++ b/Zend/zend_scheduler_hook.c
@@ -0,0 +1,960 @@
+/*
+ +----------------------------------------------------------------------+
+ | 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"
+#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. */
+static const struct {
+ const char *name;
+ size_t len;
+} php_async_hook_methods[PHP_ASYNC_HOOK_COUNT] = {
+ [PHP_ASYNC_HOOK_SHUTDOWN] = { ZEND_STRL("onshutdown") },
+ [PHP_ASYNC_HOOK_INTERCEPT_FIBER] = { ZEND_STRL("onfiber") },
+ [PHP_ASYNC_HOOK_ENQUEUE] = { ZEND_STRL("onenqueue") },
+ [PHP_ASYNC_HOOK_SUSPEND] = { ZEND_STRL("onsuspend") },
+ /* resume and cancel are the same PHP hook as enqueue: "make runnable,
+ * optionally with an error". */
+ [PHP_ASYNC_HOOK_RESUME] = { ZEND_STRL("onenqueue") },
+ [PHP_ASYNC_HOOK_CANCEL] = { ZEND_STRL("onenqueue") },
+ [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("ondefer") },
+ [PHP_ASYNC_HOOK_WAIT_INFO] = { ZEND_STRL("onwaitinfo") },
+};
+
+/* 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. */
+#define PHP_ASYNC_HANDLERS (ZEND_ASYNC_G(scheduler_hooks))
+#define PHP_ASYNC_HOOK(id) (&PHP_ASYNC_HANDLERS.hooks[(id)])
+
+/* Bind hook `id` to the scheduler's method of the same name, when it defines one.
+ * A scheduler that omits a method leaves that hook unset, so the engine keeps its
+ * own behaviour for it. */
+static void php_async_hook_bind(zend_object *scheduler, php_async_hook_id id)
+{
+ php_async_hook_t *hook = PHP_ASYNC_HOOK(id);
+ 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 (fn == NULL) {
+ hook->set = false;
+ return;
+ }
+
+ memset(&hook->fci, 0, sizeof(hook->fci));
+ memset(&hook->fcc, 0, sizeof(hook->fcc));
+
+ hook->fci.size = sizeof(hook->fci);
+ hook->fci.object = scheduler;
+ ZVAL_UNDEF(&hook->fci.function_name);
+
+ hook->fcc.function_handler = fn;
+ hook->fcc.object = scheduler;
+ hook->fcc.called_scope = scheduler->ce;
+
+ hook->set = true;
+}
+
+static void php_async_hook_release(php_async_hook_t *hook)
+{
+ /* The scheduler object (and thus the bound method) is owned once by the
+ * handlers container; nothing per-hook to release. */
+ 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;
+}
+
+/* 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;
+
+ 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;
+}
+
+/* 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
+/////////////////////////////////////////////////////////////////////
+
+/* 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;
+
+ 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;
+}
+
+/* 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_shutdown(void)
+{
+ return php_async_hook_call_void(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;
+
+ zend_coroutine_switch_handlers_destroy(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], retval;
+ ZVAL_BOOL(&args[0], from_main);
+ ZVAL_BOOL(&args[1], is_bailout);
+
+ const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_SUSPEND), 2, args, &retval);
+
+ /* 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;
+}
+
+/* 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_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)
+{
+ 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)
+{
+ (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);
+
+ const bool ok = php_async_hook_call(PHP_ASYNC_HOOK(PHP_ASYNC_HOOK_CONTEXT_FIND), 2, 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_void(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;
+}
+
+/////////////////////////////////////////////////////////////////////
+/// 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.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;
+ }
+
+ 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->size = sizeof(*api);
+
+ /* 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;
+ }
+
+ 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_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
+ * 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
+ * embedded in the object — a C-extension concern, not pure PHP. */
+}
+
+ZEND_METHOD(Async_SchedulerHook, register)
+{
+ zend_string *module;
+ zend_fcall_info factory_fci;
+ zend_fcall_info_cache factory_fcc;
+
+ ZEND_PARSE_PARAMETERS_START(2, 2)
+ Z_PARAM_STR(module)
+ Z_PARAM_FUNC(factory_fci, factory_fcc)
+ 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();
+ }
+
+ /* 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;
+ 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();
+ zend_throw_error(NULL, "Async\\SchedulerHook::register(): the engine refused the scheduler");
+ RETURN_THROWS();
+ }
+
+ ZEND_ASYNC_INITIALIZE;
+ ZEND_ASYNC_ACTIVATE;
+}
+
+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_void(PHP_ASYNC_HOOK_DEFER, 1, &arg);
+ zval_ptr_dtor(&arg);
+
+ if (UNEXPECTED(EG(exception))) {
+ RETURN_THROWS();
+ }
+}
+
+/////////////////////////////////////////////////////////////////////
+/// Async\Continuation — symmetric execution context
+/////////////////////////////////////////////////////////////////////
+
+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() */
+ bool captured; /* ctx is borrowed from a running flow, never owned */
+ 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 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);
+ 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.
+ * 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 (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);
+ }
+
+ 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;
+ co->captured = false;
+ ZVAL_UNDEF(&co->fci.function_name);
+ 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) {
+ 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;
+ }
+ }
+
+ 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_object *error = NULL;
+
+ 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) {
+ zend_throw_error(NULL, "Continuation was not created");
+ RETURN_THROWS();
+ }
+
+ 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();
+ }
+}
+
+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();
+
+ 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();
+ 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();
+ register_class_Async_SchedulerHook();
+ async_register_continuation();
+}
+
+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..959bd895fc01
--- /dev/null
+++ b/Zend/zend_scheduler_hook.stub.php
@@ -0,0 +1,138 @@
+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. Any other failure is an Error too.
+ */
+ 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 {}
+
+ /**
+ * Queues a callable on the scheduler's microtask queue (one-shot,
+ * runs on the next tick). Forwards to the scheduler's defer() hook.
+ */
+ 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`/`current` are temporary PoC entry points; the real API hands
+ * them as capabilities to the scheduler's factory at registration.
+ */
+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.
+ * $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
+ * 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
new file mode 100644
index 000000000000..6f8b2647319e
--- /dev/null
+++ b/Zend/zend_scheduler_hook_arginfo.h
@@ -0,0 +1,143 @@
+/* This is a generated file, edit zend_scheduler_hook.stub.php instead.
+ * 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()
+
+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_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()
+
+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_onDefer, 0, 1, IS_VOID, 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()
+
+#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, 2, IS_MIXED, 0)
+ ZEND_ARG_TYPE_INFO(0, context, IS_OBJECT, 0)
+ 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_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)
+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()
+
+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()
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_SchedulerHook_getModule, 0, 0, IS_STRING, 1)
+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)
+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)
+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, current);
+ZEND_METHOD(Async_Continuation, currentCoroutine);
+
+static const zend_function_entry class_Async_Scheduler_methods[] = {
+ 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("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)
+ 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_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)
+ ZEND_ME(Async_SchedulerHook, defer, arginfo_class_Async_SchedulerHook_defer, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC)
+ 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_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
+};
+
+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_SchedulerHook(void)
+{
+ zend_class_entry ce, *class_entry;
+
+ INIT_NS_CLASS_ENTRY(ce, "Async", "SchedulerHook", class_Async_SchedulerHook_methods);
+ class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL);
+
+ 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/configure.ac b/configure.ac
index 9014869fb94e..9d8adb84de9d 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1752,6 +1752,8 @@ PHP_ADD_SOURCES([Zend], m4_normalize([
zend_call_stack.c
zend_closures.c
zend_compile.c
+ zend_async_API.c
+ zend_scheduler_hook.c
zend_constants.c
zend_cpuinfo.c
zend_default_classes.c
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);
diff --git a/main/main.c b/main/main.c
index 48e4a757513b..43438f55f44d 100644
--- a/main/main.c
+++ b/main/main.c
@@ -87,6 +87,8 @@
#include "SAPI.h"
#include "rfc1867.h"
+#include "zend_async_API.h"
+
#include "main_arginfo.h"
/* }}} */
@@ -1992,6 +1994,13 @@ void php_request_shutdown(void *dummy)
zend_call_destructors();
} zend_end_try();
+ /* Before PHP shuts down completely, control is passed to the
+ * remaining coroutines one last time (if any). */
+ zend_try {
+ ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(false);
+ } zend_end_try();
+ ZEND_ASYNC_DEACTIVATE;
+
/* 3. Flush all output buffers */
zend_try {
php_output_end_all();
@@ -2648,6 +2657,10 @@ PHPAPI bool php_execute_script_ex(zend_file_handle *primary_file, zval *retval)
zend_set_timeout(zend_ini_long_literal("max_execution_time"), false);
}
+ if (ZEND_ASYNC_IS_READY) {
+ ZEND_ASYNC_SCHEDULER_LAUNCH();
+ }
+
if (prepend_file_p && result) {
result = zend_execute_script(ZEND_REQUIRE, NULL, prepend_file_p) == SUCCESS;
}
@@ -2657,7 +2670,11 @@ PHPAPI bool php_execute_script_ex(zend_file_handle *primary_file, zval *retval)
if (append_file_p && result) {
result = zend_execute_script(ZEND_REQUIRE, NULL, append_file_p) == SUCCESS;
}
+
+ ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(false);
} zend_catch {
+ /* Give the scheduler a chance to handle a bailout in the main flow. */
+ ZEND_ASYNC_RUN_SCHEDULER_AFTER_MAIN(true);
result = false;
} zend_end_try();
diff --git a/sapi/phpdbg/phpdbg_prompt.c b/sapi/phpdbg/phpdbg_prompt.c
index 2da7e4193001..d84ef9ae6680 100644
--- a/sapi/phpdbg/phpdbg_prompt.c
+++ b/sapi/phpdbg/phpdbg_prompt.c
@@ -18,6 +18,7 @@
#include
#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({