diff --git a/Zend/tests/async/context_basic.phpt b/Zend/tests/async/context_basic.phpt
new file mode 100644
index 000000000000..e0f176217872
--- /dev/null
+++ b/Zend/tests/async/context_basic.phpt
@@ -0,0 +1,48 @@
+--TEST--
+Async\get_context(): the main flow has a context with no scheduler registered
+--FILE--
+has('request.id'));
+var_dump($context->set('request.id', 'r-1')->set('locale', 'en')->find('request.id'));
+var_dump($context->find('locale'));
+var_dump($context->has('request.id'));
+var_dump($context->unset('request.id'));
+var_dump($context->unset('request.id'));
+var_dump($context->find('request.id'));
+
+// A stored null is distinguishable from an absent key.
+$context->set('nullable', null);
+var_dump($context->find('nullable'), $context->has('nullable'));
+
+// Object keys.
+$key = new stdClass();
+$context->set($key, 'per-object');
+var_dump($context->find($key), $context->has($key));
+var_dump($context->has(new stdClass()));
+var_dump($context->unset($key));
+
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(false)
+string(3) "r-1"
+string(2) "en"
+bool(true)
+bool(true)
+bool(false)
+NULL
+NULL
+bool(true)
+string(10) "per-object"
+bool(true)
+bool(false)
+bool(true)
diff --git a/Zend/tests/async/context_main_bridge.phpt b/Zend/tests/async/context_main_bridge.phpt
new file mode 100644
index 000000000000..c6737146d59d
--- /dev/null
+++ b/Zend/tests/async/context_main_bridge.phpt
@@ -0,0 +1,92 @@
+--TEST--
+Async\get_context(): main's store survives adoption by a PHP scheduler
+--FILE--
+set('who', 'main-before');
+
+$scheduler = new class implements \Async\Scheduler {
+ public SplQueue $queue;
+ public ?Coro $main = null;
+ public $capture;
+ public $current;
+
+ public function __construct()
+ {
+ $this->queue = new SplQueue();
+ }
+
+ public function onShutdown(): void {}
+ public function onWaitInfo(object $coroutine, string $info): void {}
+ public function onDefer(callable $task): void {}
+
+ public function onFiber(Fiber $fiber): ?object
+ {
+ return new Coro($fiber);
+ }
+
+ public function onEnqueue(object $coroutine, ?Throwable $error = null): bool
+ {
+ $this->queue->enqueue($coroutine);
+ return true;
+ }
+
+ public function onSuspend(bool $fromMain, bool $isBailout): ?object
+ {
+ $self = ($this->current)();
+
+ if ($self === null) {
+ // The first yield normalises the main flow into a coroutine.
+ $self = $this->main = new Coro(null, ($this->capture)());
+ }
+
+ while (!$this->queue->isEmpty()) {
+ $fiber = $this->queue->dequeue()->fiber;
+ $fiber->isStarted() ? $fiber->resume() : $fiber->start();
+
+ if (!$fromMain) {
+ break;
+ }
+ }
+
+ return $self;
+ }
+};
+
+Async\SchedulerHook::register('test', function ($create, $capture, $current) use ($scheduler) {
+ $scheduler->capture = $capture;
+ $scheduler->current = $current;
+ return $scheduler;
+});
+
+$fiber = new Fiber(function () {
+ Async\get_context()->set('who', 'fiber');
+ var_dump(Async\get_context()->find('who'));
+});
+
+$fiber->start(); // main yields for the first time and is adopted
+
+// The pre-adoption values are still there: main resolves to the same store.
+var_dump(Async\get_context()->find('who'));
+
+// The explicit form through the scheduler's own main coroutine object
+// reaches the same store too.
+var_dump(Async\get_context($scheduler->main)->find('who'));
+var_dump(Async\get_context($scheduler->main) === Async\get_context());
+
+?>
+--EXPECT--
+string(5) "fiber"
+string(11) "main-before"
+string(11) "main-before"
+bool(true)
diff --git a/Zend/tests/async/context_per_coroutine.phpt b/Zend/tests/async/context_per_coroutine.phpt
new file mode 100644
index 000000000000..9d90795a5071
--- /dev/null
+++ b/Zend/tests/async/context_per_coroutine.phpt
@@ -0,0 +1,69 @@
+--TEST--
+Async\get_context(): per-coroutine isolation and the explicit-object form
+--FILE--
+queue = new SplQueue(); }
+ public function onFiber(Fiber $fiber): ?object {
+ $coroutine = new class($fiber) {
+ public function __construct(public readonly Fiber $fiber) {}
+ };
+ $this->coroutines[] = $coroutine;
+ return $coroutine;
+ }
+ 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;
+ }
+};
+
+Async\SchedulerHook::register('test', fn () => $scheduler);
+
+// Main's own store exists independently of the coroutines below.
+Async\get_context()->set('who', 'main');
+
+$make = static fn (string $name) => new Fiber(function () use ($name) {
+ Async\get_context()->set('who', $name);
+ Fiber::suspend();
+ var_dump(Async\get_context()->find('who'));
+});
+
+$fiberA = $make('A');
+$fiberB = $make('B');
+$fiberA->start();
+$fiberB->start();
+
+// Reaching the parked coroutines' contexts from outside, through the
+// scheduler's opaque coroutine objects (the explicit-object form).
+var_dump(Async\get_context($scheduler->coroutines[0])->find('who'));
+var_dump(Async\get_context($scheduler->coroutines[1])->find('who'));
+var_dump(Async\get_context()->find('who'));
+
+// Each coroutine still sees its own value when it resumes.
+$fiberA->resume();
+$fiberB->resume();
+var_dump(Async\get_context()->find('who'));
+
+?>
+--EXPECT--
+string(1) "A"
+string(1) "B"
+string(4) "main"
+string(1) "A"
+string(1) "B"
+string(4) "main"
diff --git a/Zend/tests/async/context_unknown_object.phpt b/Zend/tests/async/context_unknown_object.phpt
new file mode 100644
index 000000000000..c346484ab58b
--- /dev/null
+++ b/Zend/tests/async/context_unknown_object.phpt
@@ -0,0 +1,14 @@
+--TEST--
+Async\get_context(): an object the engine does not know as a coroutine is a ValueError
+--FILE--
+getMessage(), "\n";
+}
+
+?>
+--EXPECT--
+Async\get_context(): Argument #1 ($coroutine) must be a coroutine object known to the scheduler
diff --git a/Zend/tests/async/continuation_current_main.phpt b/Zend/tests/async/continuation_current_main.phpt
new file mode 100644
index 000000000000..bf726f613f2b
--- /dev/null
+++ b/Zend/tests/async/continuation_current_main.phpt
@@ -0,0 +1,47 @@
+--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..9448f6c9907b
--- /dev/null
+++ b/Zend/tests/async/fiber_lowlevel_null.phpt
@@ -0,0 +1,38 @@
+--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 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..17dc21ffa970
--- /dev/null
+++ b/Zend/tests/async/fiber_managed_after_main.phpt
@@ -0,0 +1,49 @@
+--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 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..8ec51f1c01e5
--- /dev/null
+++ b/Zend/tests/async/fiber_managed_basic.phpt
@@ -0,0 +1,56 @@
+--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..378bc1a78d3c
--- /dev/null
+++ b/Zend/tests/async/fiber_managed_throw.phpt
@@ -0,0 +1,51 @@
+--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 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..7257ff23e724
--- /dev/null
+++ b/Zend/tests/async/fiber_managed_uncaught.phpt
@@ -0,0 +1,46 @@
+--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 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..9e349913a7da
--- /dev/null
+++ b/Zend/tests/async/microtasks_basic.phpt
@@ -0,0 +1,46 @@
+--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..11c3650986f5
--- /dev/null
+++ b/Zend/tests/async/scheduler_context.phpt
@@ -0,0 +1,64 @@
+--TEST--
+Fiber operations on a bound fiber: direct inside the scheduler, routed outside
+--FILE--
+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..d9c672ae0242
--- /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)
+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..19caf251037a
--- /dev/null
+++ b/Zend/tests/async/scheduler_hook_launch.phpt
@@ -0,0 +1,31 @@
+--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..a1bf7e2482c6
--- /dev/null
+++ b/Zend/tests/async/scheduler_hook_override.phpt
@@ -0,0 +1,27 @@
+--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 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..80e08388e0b9
--- /dev/null
+++ b/Zend/tests/async/scheduler_hook_register.phpt
@@ -0,0 +1,20 @@
+--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 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..394610933d20
--- /dev/null
+++ b/Zend/tests/async/scheduler_mandate.phpt
@@ -0,0 +1,43 @@
+--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..768397bc523a
--- /dev/null
+++ b/Zend/zend_async_API.c
@@ -0,0 +1,602 @@
+/*
+ +----------------------------------------------------------------------+
+ | 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
+}
+
+///////////////////////////////////////////////////////////////////
+/// Internal context (engine-owned per-coroutine storage)
+///////////////////////////////////////////////////////////////////
+
+static zend_always_inline zend_coroutine_t *internal_context_coroutine(
+ zend_coroutine_t *coroutine)
+{
+ return coroutine != NULL ? coroutine : ZEND_ASYNC_CURRENT_COROUTINE;
+}
+
+ZEND_API zval *zend_async_internal_context_find(zend_coroutine_t *coroutine, uint32_t key)
+{
+ coroutine = internal_context_coroutine(coroutine);
+
+ if (coroutine == NULL || coroutine->internal_context == NULL) {
+ return NULL;
+ }
+
+ return zend_hash_index_find(coroutine->internal_context, key);
+}
+
+ZEND_API bool zend_async_internal_context_set(
+ zend_coroutine_t *coroutine, uint32_t key, zval *value)
+{
+ coroutine = internal_context_coroutine(coroutine);
+
+ if (coroutine == NULL) {
+ return false;
+ }
+
+ if (coroutine->internal_context == NULL) {
+ ALLOC_HASHTABLE(coroutine->internal_context);
+ zend_hash_init(coroutine->internal_context, 8, NULL, ZVAL_PTR_DTOR, false);
+ }
+
+ Z_TRY_ADDREF_P(value);
+ zend_hash_index_update(coroutine->internal_context, key, value);
+ return true;
+}
+
+ZEND_API bool zend_async_internal_context_unset(zend_coroutine_t *coroutine, uint32_t key)
+{
+ coroutine = internal_context_coroutine(coroutine);
+
+ if (coroutine == NULL || coroutine->internal_context == NULL) {
+ return false;
+ }
+
+ return zend_hash_index_del(coroutine->internal_context, key) == SUCCESS;
+}
+
+ZEND_API void zend_async_internal_context_destroy(zend_coroutine_t *coroutine)
+{
+ if (coroutine->internal_context == NULL) {
+ return;
+ }
+
+ zend_hash_destroy(coroutine->internal_context);
+ FREE_HASHTABLE(coroutine->internal_context);
+ coroutine->internal_context = NULL;
+}
+
+///////////////////////////////////////////////////////////////////
+/// 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_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_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, 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_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..c5daaa78026f
--- /dev/null
+++ b/Zend/zend_async_API.h
@@ -0,0 +1,636 @@
+/*
+ +----------------------------------------------------------------------+
+ | 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_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;
+};
+
+///////////////////////////////////////////////////////////////////
+/// 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 internal (C-extension) context, numeric keys. NULL until the first
+ * set; released by the dispose path. */
+ HashTable *internal_context;
+ /* The userland context (an Async\Context object): PHP-visible
+ * coroutine-local storage, string/object keys. NULL until the first
+ * access; released by the dispose path (zend_async_context_destroy). */
+ zend_object *context;
+};
+
+/* 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_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_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);
+
+/* The internal (C-extension) context, implemented by the core: engine-owned
+ * per-coroutine storage in zend_coroutine_t. An extension allocates its
+ * numeric key once per process from a static C-string name, then reads and
+ * writes values through the operations below (NULL coroutine = current).
+ * Values die with the coroutine (raw-pointer payloads stay owned by the
+ * extension); destroy() belongs in the coroutine's dispose path. */
+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 zval *zend_async_internal_context_find(zend_coroutine_t *coroutine, uint32_t key);
+ZEND_API bool zend_async_internal_context_set(
+ zend_coroutine_t *coroutine, uint32_t key, zval *value);
+ZEND_API bool zend_async_internal_context_unset(zend_coroutine_t *coroutine, uint32_t key);
+ZEND_API void zend_async_internal_context_destroy(zend_coroutine_t *coroutine);
+
+/* The userland context (Async\Context): PHP-visible per-coroutine storage
+ * with string/object keys, implemented by the core. Lazily created on the
+ * first access; a NULL coroutine means the current one (before any coroutine
+ * exists, the main flow's pre-adoption store, so the API works with no
+ * scheduler registered). find() returns a pointer into the store (do not
+ * hold it across a switch); destroy() belongs in the coroutine's dispose
+ * path, next to the internal context. */
+ZEND_API zend_object *zend_async_context_get(zend_coroutine_t *coroutine);
+ZEND_API zval *zend_async_context_find(zend_coroutine_t *coroutine, zval *key);
+ZEND_API bool zend_async_context_set(zend_coroutine_t *coroutine, zval *key, zval *value);
+ZEND_API bool zend_async_context_unset(zend_coroutine_t *coroutine, zval *key);
+ZEND_API void zend_async_context_destroy(zend_coroutine_t *coroutine);
+
+/* The coroutine-object registry: maps a provider's opaque coroutine object
+ * to its zend_coroutine_t, so engine APIs taking an object (get_context())
+ * can resolve it. A provider binds the pair when the object is minted and
+ * unbinds it in the dispose path; unbind is a no-op after the registry died
+ * with the request. */
+ZEND_API void zend_async_coroutine_object_bind(zend_coroutine_t *coroutine, zend_object *object);
+ZEND_API void zend_async_coroutine_object_unbind(zend_object *object);
+ZEND_API zend_coroutine_t *zend_async_coroutine_object_find(zend_object *object);
+
+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)
+
+/* The userland context (NULL coroutine = current, main included). */
+#define ZEND_ASYNC_CONTEXT_GET(coroutine) zend_async_context_get(coroutine)
+#define ZEND_ASYNC_CONTEXT_FIND(coroutine, key) zend_async_context_find((coroutine), (key))
+#define ZEND_ASYNC_CONTEXT_SET(coroutine, key, value) \
+ zend_async_context_set((coroutine), (key), (value))
+#define ZEND_ASYNC_CONTEXT_UNSET(coroutine, key) zend_async_context_unset((coroutine), (key))
+#define ZEND_ASYNC_CONTEXT_DESTROY(coroutine) zend_async_context_destroy(coroutine)
+
+/* The internal context (NULL coroutine = current). */
+#define ZEND_ASYNC_INTERNAL_CONTEXT_KEY_ALLOC(name) zend_async_internal_context_key_alloc(name)
+#define ZEND_ASYNC_INTERNAL_CONTEXT_FIND(coroutine, key) \
+ zend_async_internal_context_find((coroutine), (key))
+#define ZEND_ASYNC_INTERNAL_CONTEXT_SET(coroutine, key, value) \
+ zend_async_internal_context_set((coroutine), (key), (value))
+#define ZEND_ASYNC_INTERNAL_CONTEXT_UNSET(coroutine, key) \
+ zend_async_internal_context_unset((coroutine), (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_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;
+ /* zend_coroutine_t handles for the scheduler's coroutine objects, so C
+ * code has a coroutine identity (current coroutine, internal context):
+ * coroutine object handle -> zend_coroutine_t*. Fiber-adopted entries are
+ * owned by the fiber teardown; minted continuation entries by the bridge. */
+ HashTable *coroutines;
+ 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;
+ /* The main flow's userland context before its adoption (and with no
+ * scheduler at all): an Async\Context object, lazily created, released
+ * at request shutdown. A main coroutine always resolves to this store,
+ * so values set before concurrency starts stay visible after. */
+ zend_object *main_context;
+ /* Coroutine-object registry: object handle -> zend_coroutine_t*. Holds
+ * no references; providers bind/unbind (see the registry API). */
+ HashTable *coroutine_objects;
+ /* 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..fb9f014ba167 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,129 @@ 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; while it runs,
+ * the bound coroutine is the current one (the internal context and other
+ * per-coroutine state resolve through it). */
+ const bool saved_context = ZEND_ASYNC_IN_SCHEDULER_CONTEXT;
+ zend_coroutine_t *saved_coroutine = ZEND_ASYNC_CURRENT_COROUTINE;
+ ZEND_ASYNC_IN_SCHEDULER_CONTEXT = false;
+ ZEND_ASYNC_CURRENT_COROUTINE = coroutine;
+
+ zend_fiber_transfer transfer = zend_fiber_resume_internal(
+ fiber, Z_ISUNDEF(value) ? NULL : &value, is_error);
+
+ ZEND_ASYNC_IN_SCHEDULER_CONTEXT = saved_context;
+ ZEND_ASYNC_CURRENT_COROUTINE = saved_coroutine;
+
+ 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 +1062,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 +1186,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 +1256,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..973bd1e96df3
--- /dev/null
+++ b/Zend/zend_scheduler_hook.c
@@ -0,0 +1,1360 @@
+/*
+ +----------------------------------------------------------------------+
+ | 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_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;
+static zend_class_entry *async_ce_Context;
+
+/* 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
+/////////////////////////////////////////////////////////////////////
+
+/* 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 the scheduler's coroutine
+ * objects: 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 registry maps
+ * the object to its handle so every path shares one identity: the internal
+ * context lives on it.
+ *
+ * Lifetime rides on the object itself: minting a handle swaps the object's
+ * handlers for a copy whose free_obj destroys the handle (and with it the
+ * internal context) before the original free_obj runs, so the context
+ * cannot outlive its coroutine. The registry holds no reference. The one
+ * exception is a fiber-adopted handle: the fiber machinery keeps a raw
+ * pointer to it, so it takes a reference (the object then outlives the
+ * fiber) and the fiber teardown drives the release through extended_dispose.
+ */
+typedef struct {
+ zend_coroutine_t coro;
+ zend_object *object;
+} php_coroutine_t;
+
+typedef struct {
+ zend_object_handlers handlers;
+ const zend_object_handlers *original;
+} php_async_handlers_wrapper_t;
+
+/* Wrapped handler tables, one per original table (i.e. per coroutine
+ * class). Process-lifetime: objects carrying a wrapper may outlive any
+ * request-local storage. */
+static HashTable *php_async_wrapped_handlers = NULL;
+
+static void php_async_coroutine_free(php_coroutine_t *handle)
+{
+ zend_coroutine_switch_handlers_destroy(&handle->coro);
+ zend_async_internal_context_destroy(&handle->coro);
+ zend_async_context_destroy(&handle->coro);
+
+ if (handle->object != NULL) {
+ zend_async_coroutine_object_unbind(handle->object);
+ }
+
+ zval_ptr_dtor(&handle->coro.result);
+
+ if (handle->coro.exception != NULL) {
+ OBJ_RELEASE(handle->coro.exception);
+ }
+
+ /* Only a fiber-bound handle holds a reference; the object's own death
+ * frees the others. */
+ if (handle->coro.extended_data != NULL && handle->object != NULL) {
+ OBJ_RELEASE(handle->object);
+ }
+
+ efree(handle);
+}
+
+/* The C destructor installed on a coroutine object: the handle dies with
+ * the object, then the class's own free_obj runs. */
+static void php_async_coroutine_object_free(zend_object *object)
+{
+ const php_async_handlers_wrapper_t *wrapper =
+ (const php_async_handlers_wrapper_t *) object->handlers;
+
+ if (PHP_ASYNC_HANDLERS.coroutines != NULL) {
+ zval *entry = zend_hash_index_find(PHP_ASYNC_HANDLERS.coroutines, object->handle);
+
+ if (entry != NULL) {
+ php_async_coroutine_free(Z_PTR_P(entry));
+ zend_hash_index_del(PHP_ASYNC_HANDLERS.coroutines, object->handle);
+ }
+ }
+
+ wrapper->original->free_obj(object);
+}
+
+/* Fiber teardown path (extended_dispose). */
+static void php_async_coroutine_dispose(zend_coroutine_t *coro)
+{
+ php_coroutine_t *handle = (php_coroutine_t *) coro;
+
+ if (PHP_ASYNC_HANDLERS.coroutines != NULL && handle->object != NULL) {
+ zend_hash_index_del(PHP_ASYNC_HANDLERS.coroutines, handle->object->handle);
+ }
+
+ php_async_coroutine_free(handle);
+}
+
+static void php_async_install_object_free(zend_object *object)
+{
+ if (object->handlers->free_obj == php_async_coroutine_object_free) {
+ return;
+ }
+
+ if (php_async_wrapped_handlers == NULL) {
+ php_async_wrapped_handlers = pemalloc(sizeof(HashTable), 1);
+ zend_hash_init(php_async_wrapped_handlers, 8, NULL, NULL, 1);
+ }
+
+ php_async_handlers_wrapper_t *wrapper = zend_hash_index_find_ptr(
+ php_async_wrapped_handlers, (zend_ulong) (uintptr_t) object->handlers);
+
+ if (wrapper == NULL) {
+ wrapper = pemalloc(sizeof(*wrapper), 1);
+ wrapper->handlers = *object->handlers;
+ wrapper->handlers.free_obj = php_async_coroutine_object_free;
+ wrapper->original = object->handlers;
+
+ zend_hash_index_add_new_ptr(php_async_wrapped_handlers,
+ (zend_ulong) (uintptr_t) object->handlers, wrapper);
+ }
+
+ object->handlers = &wrapper->handlers;
+}
+
+/* Find or mint the handle for a coroutine object. */
+static zend_coroutine_t *php_async_coroutine_handle(zend_object *object)
+{
+ if (PHP_ASYNC_HANDLERS.coroutines == NULL) {
+ ALLOC_HASHTABLE(PHP_ASYNC_HANDLERS.coroutines);
+ zend_hash_init(PHP_ASYNC_HANDLERS.coroutines, 8, NULL, NULL, false);
+ }
+
+ zval *found = zend_hash_index_find(PHP_ASYNC_HANDLERS.coroutines, object->handle);
+ if (found != NULL) {
+ return Z_PTR_P(found);
+ }
+
+ php_coroutine_t *handle = ecalloc(1, sizeof(*handle));
+
+ handle->object = object;
+ handle->coro.flags = ZEND_COROUTINE_F_OBJ_REF;
+ handle->coro.object_offset = offsetof(php_coroutine_t, object);
+ handle->coro.extended_dispose = php_async_coroutine_dispose;
+ ZVAL_UNDEF(&handle->coro.result);
+
+ php_async_install_object_free(object);
+ zend_async_coroutine_object_bind(&handle->coro, object);
+
+ zval entry;
+ ZVAL_PTR(&entry, handle);
+ zend_hash_index_update(PHP_ASYNC_HANDLERS.coroutines, object->handle, &entry);
+
+ return &handle->coro;
+}
+
+/* Record the coroutine the suspend hook returned as the current one: the
+ * single way the engine learns it. Not exposed as a PHP API (the scheduler
+ * owns how userland sees the coroutine); the C-visible handle keeps
+ * ZEND_ASYNC_CURRENT_COROUTINE valid for the internal context. */
+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;
+ ZEND_ASYNC_CURRENT_COROUTINE = current != NULL ? php_async_coroutine_handle(current) : NULL;
+}
+
+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;
+ }
+
+ /* The handle is shared with the record-current path through the registry.
+ * Binding to the fiber makes its teardown the owner and pins the object:
+ * the fiber keeps a raw pointer to the handle, so the object (and with it
+ * the handle) must not die before the fiber does. */
+ zend_coroutine_t *coro = php_async_coroutine_handle(Z_OBJ(retval));
+
+ if (coro->extended_data == NULL) {
+ GC_ADDREF(Z_OBJ(retval));
+ coro->extended_data = fiber;
+ }
+
+ zval_ptr_dtor(&retval);
+ return 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);
+
+ /* Before the main flow is adopted, it is the only flow that can yield
+ * without a coroutine: the coroutine this call reports back is main. */
+ const bool adopting_main = ZEND_ASYNC_CURRENT_COROUTINE == NULL;
+
+ 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);
+
+ if (adopting_main && ZEND_ASYNC_CURRENT_COROUTINE != NULL) {
+ zend_coroutine_t *main_coroutine = ZEND_ASYNC_CURRENT_COROUTINE;
+
+ ZEND_COROUTINE_SET_MAIN(main_coroutine);
+ ZEND_ASYNC_MAIN_COROUTINE = main_coroutine;
+ zend_async_call_main_coroutine_start_handlers(main_coroutine);
+ }
+
+ 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);
+}
+
+/////////////////////////////////////////////////////////////////////
+/// 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));
+ }
+
+ ZEND_ASYNC_CURRENT_COROUTINE = NULL;
+ ZEND_ASYNC_MAIN_COROUTINE = NULL;
+
+ if (PHP_ASYNC_HANDLERS.coroutines != NULL) {
+ /* Detach the registry first so the object free_obj interceptor and
+ * the fiber teardown see it gone. Fiber-bound handles stay: the
+ * teardown owns them. */
+ HashTable *registry = PHP_ASYNC_HANDLERS.coroutines;
+ PHP_ASYNC_HANDLERS.coroutines = NULL;
+
+ zval *entry;
+ ZEND_HASH_FOREACH_VAL(registry, entry)
+ {
+ php_coroutine_t *handle = Z_PTR_P(entry);
+
+ if (handle->coro.extended_data == NULL) {
+ php_async_coroutine_free(handle);
+ }
+ }
+ ZEND_HASH_FOREACH_END();
+
+ zend_hash_destroy(registry);
+ FREE_HASHTABLE(registry);
+ }
+
+ 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_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. */
+}
+
+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\Context — the userland per-coroutine context
+/////////////////////////////////////////////////////////////////////
+
+/*
+ * Engine-owned storage behind Async\get_context(): string keys live in one
+ * table, object keys in two parallel ones (the value, and the key object
+ * itself, held alive because object handles are reused after free).
+ */
+typedef struct {
+ HashTable string_keys;
+ /* object handle -> stored value */
+ HashTable object_values;
+ /* object handle -> the key object (owned) */
+ HashTable object_keys;
+ zend_object std;
+} async_context_t;
+
+static zend_object_handlers async_context_handlers;
+
+static zend_always_inline async_context_t *context_from_obj(zend_object *obj)
+{
+ return (async_context_t *) ((char *) obj - offsetof(async_context_t, std));
+}
+
+static zend_object *async_context_create_object(zend_class_entry *ce)
+{
+ async_context_t *context = zend_object_alloc(sizeof(async_context_t), ce);
+
+ zend_object_std_init(&context->std, ce);
+ object_properties_init(&context->std, ce);
+ context->std.handlers = &async_context_handlers;
+
+ zend_hash_init(&context->string_keys, 8, NULL, ZVAL_PTR_DTOR, false);
+ zend_hash_init(&context->object_values, 8, NULL, ZVAL_PTR_DTOR, false);
+ zend_hash_init(&context->object_keys, 8, NULL, ZVAL_PTR_DTOR, false);
+
+ return &context->std;
+}
+
+static void async_context_free_object(zend_object *object)
+{
+ async_context_t *context = context_from_obj(object);
+
+ zend_hash_destroy(&context->string_keys);
+ zend_hash_destroy(&context->object_values);
+ zend_hash_destroy(&context->object_keys);
+ zend_object_std_dtor(object);
+}
+
+static HashTable *async_context_object_gc(zend_object *object, zval **table, int *num)
+{
+ async_context_t *context = context_from_obj(object);
+ zend_get_gc_buffer *buf = zend_get_gc_buffer_create();
+ zval *entry;
+
+ ZEND_HASH_FOREACH_VAL(&context->string_keys, entry)
+ {
+ zend_get_gc_buffer_add_zval(buf, entry);
+ }
+ ZEND_HASH_FOREACH_END();
+
+ ZEND_HASH_FOREACH_VAL(&context->object_values, entry)
+ {
+ zend_get_gc_buffer_add_zval(buf, entry);
+ }
+ ZEND_HASH_FOREACH_END();
+
+ ZEND_HASH_FOREACH_VAL(&context->object_keys, entry)
+ {
+ zend_get_gc_buffer_add_zval(buf, entry);
+ }
+ ZEND_HASH_FOREACH_END();
+
+ zend_get_gc_buffer_use(buf, table, num);
+ return NULL;
+}
+
+static zval *async_context_do_find(async_context_t *context, zend_string *skey, zend_object *okey)
+{
+ return skey != NULL ? zend_hash_find(&context->string_keys, skey)
+ : zend_hash_index_find(&context->object_values, okey->handle);
+}
+
+static void async_context_do_set(
+ async_context_t *context, zend_string *skey, zend_object *okey, zval *value)
+{
+ Z_TRY_ADDREF_P(value);
+
+ if (skey != NULL) {
+ zend_hash_update(&context->string_keys, skey, value);
+ return;
+ }
+
+ zend_hash_index_update(&context->object_values, okey->handle, value);
+
+ zval key_zv;
+ ZVAL_OBJ_COPY(&key_zv, okey);
+ zend_hash_index_update(&context->object_keys, okey->handle, &key_zv);
+}
+
+static bool async_context_do_unset(async_context_t *context, zend_string *skey, zend_object *okey)
+{
+ if (skey != NULL) {
+ return zend_hash_del(&context->string_keys, skey) == SUCCESS;
+ }
+
+ zend_hash_index_del(&context->object_keys, okey->handle);
+ return zend_hash_index_del(&context->object_values, okey->handle) == SUCCESS;
+}
+
+ZEND_METHOD(Async_Context, find)
+{
+ zend_string *skey = NULL;
+ zend_object *okey = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_OBJ_OR_STR(okey, skey)
+ ZEND_PARSE_PARAMETERS_END();
+
+ const zval *value = async_context_do_find(context_from_obj(Z_OBJ_P(ZEND_THIS)), skey, okey);
+
+ if (value == NULL) {
+ RETURN_NULL();
+ }
+
+ RETURN_COPY(value);
+}
+
+ZEND_METHOD(Async_Context, has)
+{
+ zend_string *skey = NULL;
+ zend_object *okey = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_OBJ_OR_STR(okey, skey)
+ ZEND_PARSE_PARAMETERS_END();
+
+ RETURN_BOOL(async_context_do_find(context_from_obj(Z_OBJ_P(ZEND_THIS)), skey, okey) != NULL);
+}
+
+ZEND_METHOD(Async_Context, set)
+{
+ zend_string *skey = NULL;
+ zend_object *okey = NULL;
+ zval *value;
+
+ ZEND_PARSE_PARAMETERS_START(2, 2)
+ Z_PARAM_OBJ_OR_STR(okey, skey)
+ Z_PARAM_ZVAL(value)
+ ZEND_PARSE_PARAMETERS_END();
+
+ async_context_do_set(context_from_obj(Z_OBJ_P(ZEND_THIS)), skey, okey, value);
+
+ RETURN_OBJ_COPY(Z_OBJ_P(ZEND_THIS));
+}
+
+ZEND_METHOD(Async_Context, unset)
+{
+ zend_string *skey = NULL;
+ zend_object *okey = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_OBJ_OR_STR(okey, skey)
+ ZEND_PARSE_PARAMETERS_END();
+
+ RETURN_BOOL(async_context_do_unset(context_from_obj(Z_OBJ_P(ZEND_THIS)), skey, okey));
+}
+
+/////////////////////////////////////////////////////////////////////
+/// The userland-context C API and the coroutine-object registry
+/////////////////////////////////////////////////////////////////////
+
+ZEND_API zend_object *zend_async_context_get(zend_coroutine_t *coroutine)
+{
+ if (coroutine == NULL) {
+ coroutine = ZEND_ASYNC_CURRENT_COROUTINE;
+ }
+
+ /* The main flow always resolves to the pre-adoption store, so values
+ * set before concurrency starts stay visible after; the store also
+ * serves plain PHP with no scheduler at all. */
+ if (coroutine == NULL || ZEND_COROUTINE_IS_MAIN(coroutine)) {
+ if (ZEND_ASYNC_G(main_context) == NULL) {
+ ZEND_ASYNC_G(main_context) = async_context_create_object(async_ce_Context);
+ }
+
+ return ZEND_ASYNC_G(main_context);
+ }
+
+ if (coroutine->context == NULL) {
+ coroutine->context = async_context_create_object(async_ce_Context);
+ }
+
+ return coroutine->context;
+}
+
+/* Split a string-or-object key zval; any other type is a programming error
+ * of the C caller. */
+static bool async_context_key_split(zval *key, zend_string **skey, zend_object **okey)
+{
+ if (Z_TYPE_P(key) == IS_STRING) {
+ *skey = Z_STR_P(key);
+ return true;
+ }
+
+ if (Z_TYPE_P(key) == IS_OBJECT) {
+ *okey = Z_OBJ_P(key);
+ return true;
+ }
+
+ return false;
+}
+
+ZEND_API zval *zend_async_context_find(zend_coroutine_t *coroutine, zval *key)
+{
+ zend_string *skey = NULL;
+ zend_object *okey = NULL;
+
+ if (!async_context_key_split(key, &skey, &okey)) {
+ return NULL;
+ }
+
+ return async_context_do_find(
+ context_from_obj(zend_async_context_get(coroutine)), skey, okey);
+}
+
+ZEND_API bool zend_async_context_set(zend_coroutine_t *coroutine, zval *key, zval *value)
+{
+ zend_string *skey = NULL;
+ zend_object *okey = NULL;
+
+ if (!async_context_key_split(key, &skey, &okey)) {
+ return false;
+ }
+
+ async_context_do_set(context_from_obj(zend_async_context_get(coroutine)), skey, okey, value);
+ return true;
+}
+
+ZEND_API bool zend_async_context_unset(zend_coroutine_t *coroutine, zval *key)
+{
+ zend_string *skey = NULL;
+ zend_object *okey = NULL;
+
+ if (!async_context_key_split(key, &skey, &okey)) {
+ return false;
+ }
+
+ return async_context_do_unset(
+ context_from_obj(zend_async_context_get(coroutine)), skey, okey);
+}
+
+ZEND_API void zend_async_context_destroy(zend_coroutine_t *coroutine)
+{
+ if (coroutine->context != NULL) {
+ OBJ_RELEASE(coroutine->context);
+ coroutine->context = NULL;
+ }
+}
+
+ZEND_API void zend_async_coroutine_object_bind(zend_coroutine_t *coroutine, zend_object *object)
+{
+ if (ZEND_ASYNC_G(coroutine_objects) == NULL) {
+ ALLOC_HASHTABLE(ZEND_ASYNC_G(coroutine_objects));
+ zend_hash_init(ZEND_ASYNC_G(coroutine_objects), 8, NULL, NULL, false);
+ }
+
+ zend_hash_index_update_ptr(ZEND_ASYNC_G(coroutine_objects), object->handle, coroutine);
+}
+
+ZEND_API void zend_async_coroutine_object_unbind(zend_object *object)
+{
+ if (ZEND_ASYNC_G(coroutine_objects) != NULL) {
+ zend_hash_index_del(ZEND_ASYNC_G(coroutine_objects), object->handle);
+ }
+}
+
+ZEND_API zend_coroutine_t *zend_async_coroutine_object_find(zend_object *object)
+{
+ if (ZEND_ASYNC_G(coroutine_objects) == NULL) {
+ return NULL;
+ }
+
+ return zend_hash_index_find_ptr(ZEND_ASYNC_G(coroutine_objects), object->handle);
+}
+
+ZEND_FUNCTION(Async_get_context)
+{
+ zend_object *coroutine_obj = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(0, 1)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_OBJ_OR_NULL(coroutine_obj)
+ ZEND_PARSE_PARAMETERS_END();
+
+ zend_coroutine_t *coroutine = NULL;
+
+ if (coroutine_obj != NULL) {
+ coroutine = zend_async_coroutine_object_find(coroutine_obj);
+
+ if (coroutine == NULL) {
+ zend_argument_value_error(1, "must be a coroutine object known to the scheduler");
+ RETURN_THROWS();
+ }
+ }
+
+ RETURN_OBJ_COPY(zend_async_context_get(coroutine));
+}
+
+static void async_register_context(void)
+{
+ async_ce_Context = register_class_Async_Context();
+ async_ce_Context->create_object = async_context_create_object;
+
+ memcpy(&async_context_handlers, zend_get_std_object_handlers(), sizeof(zend_object_handlers));
+ async_context_handlers.offset = offsetof(async_context_t, std);
+ async_context_handlers.free_obj = async_context_free_object;
+ async_context_handlers.get_gc = async_context_object_gc;
+ async_context_handlers.clone_obj = NULL;
+}
+
+/////////////////////////////////////////////////////////////////////
+/// 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();
+ async_register_context();
+
+ zend_register_functions(NULL, ext_functions, CG(function_table), MODULE_PERSISTENT);
+}
+
+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();
+ }
+
+ if (ZEND_ASYNC_G(main_context) != NULL) {
+ OBJ_RELEASE(ZEND_ASYNC_G(main_context));
+ ZEND_ASYNC_G(main_context) = NULL;
+ }
+
+ if (ZEND_ASYNC_G(coroutine_objects) != NULL) {
+ /* No references are held; coroutine objects freed later by the
+ * executor teardown unbind into a NULL registry, a no-op. */
+ zend_hash_destroy(ZEND_ASYNC_G(coroutine_objects));
+ FREE_HASHTABLE(ZEND_ASYNC_G(coroutine_objects));
+ ZEND_ASYNC_G(coroutine_objects) = NULL;
+ }
+}
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..c2f3e9f7ac31
--- /dev/null
+++ b/Zend/zend_scheduler_hook.stub.php
@@ -0,0 +1,161 @@
+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 {}
+}
+
+/**
+ * The userland context of a coroutine: coroutine-local key-value storage with
+ * string or object keys, created lazily on the first access and destroyed with
+ * the coroutine. The engine provides storage and access only: a fresh
+ * coroutine starts with an empty context, and any inheritance policy (what a
+ * child sees of the spawner's values) belongs to the scheduler's user-facing
+ * API, next to spawn().
+ *
+ * @strict-properties
+ * @not-serializable
+ */
+final class Context
+{
+ /** The value stored under $key, or null when absent. */
+ public function find(string|object $key): mixed {}
+
+ /** Whether $key exists: distinguishes an absent key from a stored null. */
+ public function has(string|object $key): bool {}
+
+ /** Store $value under $key. Returns $this for chaining. */
+ public function set(string|object $key, mixed $value): Context {}
+
+ /** Remove $key. The return value is data, not a status: whether the key existed. */
+ public function unset(string|object $key): bool {}
+}
+
+/**
+ * The context of $coroutine, or of the currently running flow when null (main
+ * included, whether or not a scheduler is registered). The explicit-object
+ * form is how a scheduler reaches a coroutine's context from outside it, e.g.
+ * to implement its inheritance policy in spawn(); passing an object the
+ * engine does not know as a coroutine is a ValueError.
+ */
+function get_context(?object $coroutine = null): Context {}
diff --git a/Zend/zend_scheduler_hook_arginfo.h b/Zend/zend_scheduler_hook_arginfo.h
new file mode 100644
index 000000000000..a908c4cda31a
--- /dev/null
+++ b/Zend/zend_scheduler_hook_arginfo.h
@@ -0,0 +1,163 @@
+/* This is a generated file, edit zend_scheduler_hook.stub.php instead.
+ * Stub hash: 878a63b7b189e06545c547dd8069755af3421f60 */
+
+ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_Async_get_context, 0, 0, Async\\Context, 0)
+ ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, coroutine, IS_OBJECT, 1, "null")
+ZEND_END_ARG_INFO()
+
+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_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_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Context_find, 0, 1, IS_MIXED, 0)
+ ZEND_ARG_TYPE_MASK(0, key, MAY_BE_STRING|MAY_BE_OBJECT, NULL)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Async_Context_has, 0, 1, _IS_BOOL, 0)
+ ZEND_ARG_TYPE_MASK(0, key, MAY_BE_STRING|MAY_BE_OBJECT, NULL)
+ZEND_END_ARG_INFO()
+
+ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Async_Context_set, 0, 2, Async\\Context, 0)
+ ZEND_ARG_TYPE_MASK(0, key, MAY_BE_STRING|MAY_BE_OBJECT, NULL)
+ ZEND_ARG_TYPE_INFO(0, value, IS_MIXED, 0)
+ZEND_END_ARG_INFO()
+
+#define arginfo_class_Async_Context_unset arginfo_class_Async_Context_has
+
+ZEND_FUNCTION(Async_get_context);
+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);
+ZEND_METHOD(Async_Context, find);
+ZEND_METHOD(Async_Context, has);
+ZEND_METHOD(Async_Context, set);
+ZEND_METHOD(Async_Context, unset);
+
+static const zend_function_entry ext_functions[] = {
+ ZEND_RAW_FENTRY(ZEND_NS_NAME("Async", "get_context"), zif_Async_get_context, arginfo_Async_get_context, 0, NULL, NULL)
+ ZEND_FE_END
+};
+
+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_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 const zend_function_entry class_Async_Context_methods[] = {
+ ZEND_ME(Async_Context, find, arginfo_class_Async_Context_find, ZEND_ACC_PUBLIC)
+ ZEND_ME(Async_Context, has, arginfo_class_Async_Context_has, ZEND_ACC_PUBLIC)
+ ZEND_ME(Async_Context, set, arginfo_class_Async_Context_set, ZEND_ACC_PUBLIC)
+ ZEND_ME(Async_Context, unset, arginfo_class_Async_Context_unset, ZEND_ACC_PUBLIC)
+ 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;
+}
+
+static zend_class_entry *register_class_Async_Context(void)
+{
+ zend_class_entry ce, *class_entry;
+
+ INIT_NS_CLASS_ENTRY(ce, "Async", "Context", class_Async_Context_methods);
+ class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE);
+
+ 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/ext/test_scheduler/config.m4 b/ext/test_scheduler/config.m4
new file mode 100644
index 000000000000..7ec5b6f667de
--- /dev/null
+++ b/ext/test_scheduler/config.m4
@@ -0,0 +1,13 @@
+PHP_ARG_ENABLE([test-scheduler],
+ [whether to enable the test_scheduler extension],
+ [AS_HELP_STRING([--enable-test-scheduler],
+ [Enable the test_scheduler extension: a reference C scheduler for the Async Core hooks (testing only)])],
+ [no])
+
+if test "$PHP_TEST_SCHEDULER" != "no"; then
+ AC_DEFINE([HAVE_TEST_SCHEDULER], [1],
+ [Define to 1 if the PHP extension 'test_scheduler' is available.])
+ PHP_NEW_EXTENSION([test_scheduler], [test_scheduler.c],
+ [$ext_shared],,
+ [-DZEND_ENABLE_STATIC_TSRMLS_CACHE=1])
+fi
diff --git a/ext/test_scheduler/config.w32 b/ext/test_scheduler/config.w32
new file mode 100644
index 000000000000..2abcb682429c
--- /dev/null
+++ b/ext/test_scheduler/config.w32
@@ -0,0 +1,6 @@
+ARG_ENABLE("test-scheduler", "test_scheduler extension: a reference C scheduler for the Async Core hooks (testing only)", "no");
+
+if (PHP_TEST_SCHEDULER != "no") {
+ EXTENSION("test_scheduler", "test_scheduler.c", null, "/DZEND_ENABLE_STATIC_TSRMLS_CACHE=1");
+ AC_DEFINE("HAVE_TEST_SCHEDULER", 1, "Define to 1 if the PHP extension 'test_scheduler' is available.");
+}
diff --git a/ext/test_scheduler/php_test_scheduler.h b/ext/test_scheduler/php_test_scheduler.h
new file mode 100644
index 000000000000..4f853ee20866
--- /dev/null
+++ b/ext/test_scheduler/php_test_scheduler.h
@@ -0,0 +1,27 @@
+/*
+ +----------------------------------------------------------------------+
+ | 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 PHP_TEST_SCHEDULER_H
+#define PHP_TEST_SCHEDULER_H
+
+extern zend_module_entry test_scheduler_module_entry;
+#define phpext_test_scheduler_ptr &test_scheduler_module_entry
+
+#define PHP_TEST_SCHEDULER_VERSION "0.1.0"
+
+#if defined(ZTS) && defined(COMPILE_DL_TEST_SCHEDULER)
+ZEND_TSRMLS_CACHE_EXTERN()
+#endif
+
+#endif /* PHP_TEST_SCHEDULER_H */
diff --git a/ext/test_scheduler/test_scheduler.c b/ext/test_scheduler/test_scheduler.c
new file mode 100644
index 000000000000..065f136c5a9e
--- /dev/null
+++ b/ext/test_scheduler/test_scheduler.c
@@ -0,0 +1,1586 @@
+/*
+ +----------------------------------------------------------------------+
+ | 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 |
+ +----------------------------------------------------------------------+
+*/
+
+/*
+ * A reference C scheduler for the Async Core hooks (testing only).
+ *
+ * The extension is the C twin of the MiniScheduler from the Scheduler Hook
+ * RFC: a deliberately naive FIFO scheduler (no reactor, no cancellation
+ * policy) that fills every zend_async_scheduler_api_t slot from a separate
+ * Zend extension. Its purpose is to exercise the C ABI end to end; the
+ * TestScheduler\ userland API exists so .phpt tests can drive it.
+ *
+ * Scheduling model (mirrors the RFC's MiniScheduler): the scheduler loop is
+ * distributed. Every flow that suspends drains the microtask and ready
+ * queues from inside its own suspend() frame, switching symmetrically into
+ * the next runnable coroutine. When the queues run dry, main returns from
+ * its suspension (a real scheduler would block in a reactor); any other
+ * flow parks until deliberately woken. A finishing coroutine always hands
+ * control back to the main flow, whose loop keeps draining; at the
+ * end-of-main drain the coroutines still parked are unwound.
+ */
+
+#ifdef HAVE_CONFIG_H
+#include
+#endif
+
+#include "php.h"
+#include "ext/standard/info.h"
+#include "zend_async_API.h"
+#include "zend_fibers.h"
+#include "zend_exceptions.h"
+#include "zend_interfaces.h"
+#include "zend_ini.h"
+#include "php_test_scheduler.h"
+#include "test_scheduler_arginfo.h"
+
+/*
+ * The scheduler's coroutine. `coro` must stay the first member: the engine
+ * speaks in zend_coroutine_t* and the two pointers are interchangeable.
+ *
+ * Two allocation models, both required by the ABI:
+ * - object-backed (spawn(), the adopted main flow): the zend_object is
+ * embedded at `std` and reached through coro.object_offset;
+ * - plain (the new_coroutine slot, adopted fibers): the allocation ends
+ * before `std`, coro.object_offset stays 0 and extended_dispose frees it.
+ */
+typedef struct _ts_coroutine_s ts_coroutine_t;
+
+struct _ts_coroutine_s {
+ zend_coroutine_t coro;
+ /* Own fiber context; NULL for adopted fibers and before the first run. */
+ zend_fiber_context *ctx;
+ /* The context is borrowed from a running flow (main): never destroy it. */
+ bool ctx_captured;
+ /* Counted in ZEND_ASYNC_ACTIVE_COROUTINE_COUNT until finished. */
+ bool active_counted;
+ /* Delivered (thrown) at the next switch into the coroutine. Owned. */
+ zend_object *pending_error;
+ /* Diagnostics recorded through the wait_info slot. Owned. */
+ zend_string *wait_info;
+ /* The body's VM stack, captured at completion and freed with the object. */
+ zend_vm_stack vm_stack;
+ /* Intrusive list of the spawned coroutines whose stacks may need
+ * unwinding at the end-of-main drain. */
+ ts_coroutine_t *live_prev;
+ ts_coroutine_t *live_next;
+ zend_object std;
+};
+
+#define TS_CORO_PLAIN_SIZE offsetof(ts_coroutine_t, std)
+
+typedef struct ts_fifo_node {
+ struct ts_fifo_node *next;
+ void *data;
+} ts_fifo_node_t;
+
+typedef struct {
+ ts_fifo_node_t *head;
+ ts_fifo_node_t *tail;
+ uint32_t count;
+} ts_fifo_t;
+
+/* A queued PHP callable behind the defer slot's microtask contract. */
+typedef struct {
+ zend_async_microtask_t task;
+ zval callable;
+} ts_microtask_t;
+
+ZEND_BEGIN_MODULE_GLOBALS(test_scheduler)
+ bool enable;
+ bool shutting_down;
+ /* ts_coroutine_t*, each entry holds one reference on its keeper object. */
+ ts_fifo_t ready;
+ /* zend_async_microtask_t*, each entry holds one task reference. */
+ ts_fifo_t microtasks;
+ /* zend_object*, references to drop at the next safe point. */
+ ts_fifo_t release_later;
+ ts_coroutine_t *main_coro;
+ ts_coroutine_t *live_head;
+ /* Counters exposed through stats(). */
+ zend_long spawned;
+ zend_long finished;
+ zend_long ticks;
+ zend_long switch_enters;
+ zend_long switch_leaves;
+ zend_long switch_finishes;
+ zend_long wait_infos;
+ zend_long gc_intercepts;
+ bool main_start_fired;
+ bool launched;
+ZEND_END_MODULE_GLOBALS(test_scheduler)
+
+ZEND_DECLARE_MODULE_GLOBALS(test_scheduler)
+
+#define TSG(v) ZEND_MODULE_GLOBALS_ACCESSOR(test_scheduler, v)
+
+#if defined(ZTS) && defined(COMPILE_DL_TEST_SCHEDULER)
+ZEND_TSRMLS_CACHE_DEFINE()
+#endif
+
+static zend_class_entry *ts_ce_coroutine;
+static zend_class_entry *ts_ce_cancellation;
+static zend_object_handlers ts_coroutine_handlers;
+
+/* Root frame for a coroutine's Zend call stack. */
+static zend_function ts_root_function;
+
+/* The internal-context key allocated once per process at MINIT. */
+static uint32_t ts_internal_key = 0;
+
+/* A fresh context has no other way to learn its coroutine: the switcher
+ * publishes it here right before the first switch. */
+static ts_coroutine_t *ts_starting = NULL;
+
+/* How the flow being switched into was woken. A deliberate wake (it was
+ * dequeued from the ready queue) means "your turn": the woken flow stops
+ * scheduling and returns from its suspension. A non-deliberate wake (a
+ * coroutine finished or parked) reaches only the main flow, whose loop
+ * keeps draining. Set by the switcher right before every switch. */
+static bool ts_wake_deliberate = false;
+
+static zend_always_inline ts_coroutine_t *ts_from_obj(zend_object *obj)
+{
+ return (ts_coroutine_t *) ((char *) obj - offsetof(ts_coroutine_t, std));
+}
+
+static zend_always_inline ts_coroutine_t *ts_from_coro(zend_coroutine_t *coro)
+{
+ return (ts_coroutine_t *) coro;
+}
+
+/////////////////////////////////////////////////////////////////////
+/// FIFO
+/////////////////////////////////////////////////////////////////////
+
+static void ts_fifo_push(ts_fifo_t *fifo, void *data)
+{
+ ts_fifo_node_t *node = emalloc(sizeof(*node));
+
+ node->next = NULL;
+ node->data = data;
+
+ if (fifo->tail != NULL) {
+ fifo->tail->next = node;
+ } else {
+ fifo->head = node;
+ }
+
+ fifo->tail = node;
+ fifo->count++;
+}
+
+static void *ts_fifo_shift(ts_fifo_t *fifo)
+{
+ ts_fifo_node_t *node = fifo->head;
+
+ if (node == NULL) {
+ return NULL;
+ }
+
+ fifo->head = node->next;
+
+ if (fifo->head == NULL) {
+ fifo->tail = NULL;
+ }
+
+ fifo->count--;
+
+ void *data = node->data;
+ efree(node);
+ return data;
+}
+
+/////////////////////////////////////////////////////////////////////
+/// Lifetime
+/////////////////////////////////////////////////////////////////////
+
+/* The object whose reference keeps the coroutine alive: the embedded object
+ * for object-backed coroutines, the bound fiber for adopted ones. */
+static zend_object *ts_keeper(ts_coroutine_t *ts)
+{
+ zend_object *object = ZEND_COROUTINE_OBJECT(&ts->coro);
+
+ if (object != NULL) {
+ return object;
+ }
+
+ if (ts->coro.extended_data != NULL) {
+ return &((zend_fiber *) ts->coro.extended_data)->std;
+ }
+
+ return NULL;
+}
+
+/* Park a reference drop: releasing an object may free it, which must not
+ * happen under the feet of a flow that still holds the raw pointer. */
+static void ts_release_later(zend_object *object)
+{
+ if (object != NULL) {
+ ts_fifo_push(&TSG(release_later), object);
+ }
+}
+
+static void ts_drain_releases(void)
+{
+ zend_object *object;
+
+ while ((object = ts_fifo_shift(&TSG(release_later))) != NULL) {
+ OBJ_RELEASE(object);
+ }
+}
+
+static void ts_live_link(ts_coroutine_t *ts)
+{
+ ts->live_next = TSG(live_head);
+
+ if (ts->live_next != NULL) {
+ ts->live_next->live_prev = ts;
+ }
+
+ TSG(live_head) = ts;
+}
+
+static void ts_live_unlink(ts_coroutine_t *ts)
+{
+ if (TSG(live_head) == ts) {
+ TSG(live_head) = ts->live_next;
+ } else if (ts->live_prev != NULL) {
+ ts->live_prev->live_next = ts->live_next;
+ } else {
+ return;
+ }
+
+ if (ts->live_next != NULL) {
+ ts->live_next->live_prev = ts->live_prev;
+ }
+
+ ts->live_prev = ts->live_next = NULL;
+}
+
+/* Shared teardown of the zend_coroutine_t payload. */
+static void ts_coroutine_clear(ts_coroutine_t *ts)
+{
+ zend_object *object = ZEND_COROUTINE_OBJECT(&ts->coro);
+
+ if (object != NULL) {
+ zend_async_coroutine_object_unbind(object);
+ }
+
+ ts_live_unlink(ts);
+ zend_coroutine_switch_handlers_destroy(&ts->coro);
+ zend_async_internal_context_destroy(&ts->coro);
+ zend_async_context_destroy(&ts->coro);
+
+ if (ts->coro.fcall != NULL) {
+ zend_fcall_t *fcall = ts->coro.fcall;
+
+ for (uint32_t i = 0; i < fcall->fci.param_count; i++) {
+ zval_ptr_dtor(&fcall->fci.params[i]);
+ }
+
+ if (fcall->fci.params != NULL) {
+ efree(fcall->fci.params);
+ }
+
+ if (fcall->fci.named_params != NULL) {
+ zend_array_release(fcall->fci.named_params);
+ }
+
+ zval_ptr_dtor(&fcall->fci.function_name);
+ efree(fcall);
+ ts->coro.fcall = NULL;
+ }
+
+ zval_ptr_dtor(&ts->coro.result);
+ ZVAL_UNDEF(&ts->coro.result);
+
+ if (ts->coro.exception != NULL) {
+ OBJ_RELEASE(ts->coro.exception);
+ ts->coro.exception = NULL;
+ }
+
+ if (ts->pending_error != NULL) {
+ OBJ_RELEASE(ts->pending_error);
+ ts->pending_error = NULL;
+ }
+
+ if (ts->wait_info != NULL) {
+ zend_string_release(ts->wait_info);
+ ts->wait_info = NULL;
+ }
+
+ if (ts->vm_stack != NULL) {
+ zend_vm_stack current_stack = EG(vm_stack);
+ EG(vm_stack) = ts->vm_stack;
+ zend_vm_stack_destroy();
+ EG(vm_stack) = current_stack;
+ ts->vm_stack = NULL;
+ }
+
+ if (ts->ctx != NULL) {
+ if (!ts->ctx_captured) {
+ /* A finished coroutine's context is destroyed by the switch
+ * machinery (it frees a DEAD context); destroy only one that
+ * never ran to completion. The struct is always ours. */
+ if (ts->ctx->status != ZEND_FIBER_STATUS_DEAD) {
+ zend_fiber_destroy_context(ts->ctx);
+ }
+ efree(ts->ctx);
+ }
+
+ ts->ctx = NULL;
+ }
+}
+
+static void ts_coroutine_object_free(zend_object *object)
+{
+ ts_coroutine_t *ts = ts_from_obj(object);
+
+ ts_coroutine_clear(ts);
+ zend_object_std_dtor(object);
+}
+
+static HashTable *ts_coroutine_object_gc(zend_object *object, zval **table, int *num)
+{
+ ts_coroutine_t *ts = ts_from_obj(object);
+ zend_get_gc_buffer *buf = zend_get_gc_buffer_create();
+
+ zend_get_gc_buffer_add_zval(buf, &ts->coro.result);
+
+ if (ts->coro.exception != NULL) {
+ zend_get_gc_buffer_add_obj(buf, ts->coro.exception);
+ }
+
+ if (ts->pending_error != NULL) {
+ zend_get_gc_buffer_add_obj(buf, ts->pending_error);
+ }
+
+ if (ts->coro.fcall != NULL) {
+ zend_get_gc_buffer_add_zval(buf, &ts->coro.fcall->fci.function_name);
+
+ for (uint32_t i = 0; i < ts->coro.fcall->fci.param_count; i++) {
+ zend_get_gc_buffer_add_zval(buf, &ts->coro.fcall->fci.params[i]);
+ }
+ }
+
+ zend_get_gc_buffer_use(buf, table, num);
+ return NULL;
+}
+
+/* Frees a plain (objectless) coroutine: the fiber teardown calls this
+ * through extended_dispose when the bound fiber dies. */
+static void ts_coroutine_dispose_plain(zend_coroutine_t *coro)
+{
+ ts_coroutine_t *ts = ts_from_coro(coro);
+
+ ts_coroutine_clear(ts);
+ efree(ts);
+}
+
+/////////////////////////////////////////////////////////////////////
+/// Coroutine construction
+/////////////////////////////////////////////////////////////////////
+
+static zend_string *ts_awaiting_info(zend_coroutine_t *coro)
+{
+ ts_coroutine_t *ts = ts_from_coro(coro);
+
+ return ts->wait_info != NULL ? zend_string_copy(ts->wait_info) : NULL;
+}
+
+/* Counts every context switch of every coroutine: coverage for the C-only
+ * switch-handler ABI, observable through stats(). */
+static bool ts_count_switches(zend_coroutine_t *coroutine, bool is_enter, bool is_finishing)
+{
+ (void) coroutine;
+
+ if (is_finishing) {
+ TSG(switch_finishes)++;
+ } else if (is_enter) {
+ TSG(switch_enters)++;
+ } else {
+ TSG(switch_leaves)++;
+ }
+
+ return true;
+}
+
+static bool ts_main_started(zend_coroutine_t *coroutine, bool is_enter, bool is_finishing)
+{
+ (void) coroutine;
+ (void) is_enter;
+ (void) is_finishing;
+ TSG(main_start_fired) = true;
+ return false;
+}
+
+static void ts_coroutine_init(ts_coroutine_t *ts)
+{
+ ZVAL_UNDEF(&ts->coro.result);
+ ts->coro.awaiting_info = ts_awaiting_info;
+ ZEND_COROUTINE_ADD_SWITCH_HANDLER(&ts->coro, ts_count_switches);
+}
+
+static zend_object *ts_coroutine_object_create(zend_class_entry *ce)
+{
+ ts_coroutine_t *ts = zend_object_alloc(sizeof(ts_coroutine_t), ce);
+
+ memset(ts, 0, TS_CORO_PLAIN_SIZE);
+ zend_object_std_init(&ts->std, ce);
+ object_properties_init(&ts->std, ce);
+ ts->std.handlers = &ts_coroutine_handlers;
+
+ ts->coro.object_offset = offsetof(ts_coroutine_t, std);
+ ts_coroutine_init(ts);
+ zend_async_coroutine_object_bind(&ts->coro, &ts->std);
+
+ return &ts->std;
+}
+
+/* The new_coroutine slot: a plain allocation with no PHP object, the shape
+ * a C extension consumer of the ABI receives. */
+static zend_coroutine_t *ts_new_coroutine(size_t extra_size)
+{
+ ts_coroutine_t *ts = ecalloc(1, TS_CORO_PLAIN_SIZE + extra_size);
+
+ ts->coro.extended_dispose = ts_coroutine_dispose_plain;
+ ts_coroutine_init(ts);
+
+ return &ts->coro;
+}
+
+/////////////////////////////////////////////////////////////////////
+/// The scheduler
+/////////////////////////////////////////////////////////////////////
+
+static bool ts_enqueue(zend_coroutine_t *coroutine)
+{
+ if (TSG(shutting_down)) {
+ /* A quiet rejection: false is data here, not an error. */
+ return false;
+ }
+
+ if (ZEND_COROUTINE_IS_FINISHED(coroutine)) {
+ zend_throw_error(NULL, "Cannot enqueue a finished coroutine");
+ return false;
+ }
+
+ if (ZEND_COROUTINE_IS_QUEUED(coroutine)) {
+ return true;
+ }
+
+ ts_coroutine_t *ts = ts_from_coro(coroutine);
+ zend_object *keeper = ts_keeper(ts);
+
+ if (keeper != NULL) {
+ GC_ADDREF(keeper);
+ }
+
+ if (!ZEND_COROUTINE_IS_RUNNING(coroutine)) {
+ ZEND_COROUTINE_SET_STATUS(coroutine, ZEND_COROUTINE_STATUS_QUEUED);
+ }
+
+ ts_fifo_push(&TSG(ready), ts);
+ return true;
+}
+
+static bool ts_resume(zend_coroutine_t *coroutine, zend_object *error, const bool transfer_error)
+{
+ ts_coroutine_t *ts = ts_from_coro(coroutine);
+
+ if (ZEND_COROUTINE_IS_FINISHED(coroutine)) {
+ if (error != NULL && transfer_error) {
+ OBJ_RELEASE(error);
+ }
+
+ zend_throw_error(NULL, "Cannot resume a finished coroutine");
+ return false;
+ }
+
+ if (error != NULL) {
+ if (!transfer_error) {
+ GC_ADDREF(error);
+ }
+
+ if (ts->pending_error != NULL) {
+ OBJ_RELEASE(ts->pending_error);
+ }
+
+ ts->pending_error = error;
+ }
+
+ return ts_enqueue(coroutine);
+}
+
+static bool ts_cancel(
+ zend_coroutine_t *coroutine, zend_object *error, bool transfer_error, const bool is_safely)
+{
+ /* The naive policy: no cancellation-safe sections, deliver right away. */
+ (void) is_safely;
+
+ if (ZEND_COROUTINE_IS_FINISHED(coroutine)) {
+ if (error != NULL && transfer_error) {
+ OBJ_RELEASE(error);
+ }
+
+ return true;
+ }
+
+ ZEND_COROUTINE_SET_CANCELLED(coroutine);
+ return ts_resume(coroutine, error, transfer_error);
+}
+
+static bool ts_launch(void)
+{
+ TSG(launched) = true;
+ ZEND_ASYNC_ACTIVATE;
+ return true;
+}
+
+static bool ts_shutdown(void)
+{
+ TSG(shutting_down) = true;
+ return true;
+}
+
+static zend_class_entry *ts_get_class_ce(zend_async_class type)
+{
+ switch (type) {
+ case ZEND_ASYNC_CLASS_COROUTINE:
+ return ts_ce_coroutine;
+ case ZEND_ASYNC_EXCEPTION_CANCELLATION:
+ return ts_ce_cancellation;
+ case ZEND_ASYNC_EXCEPTION_DEFAULT:
+ return zend_ce_exception;
+ default:
+ return NULL;
+ }
+}
+
+static bool ts_defer(zend_async_microtask_t *task)
+{
+ if (TSG(shutting_down)) {
+ return false;
+ }
+
+ ts_fifo_push(&TSG(microtasks), task);
+ return true;
+}
+
+static bool ts_wait_info(zend_coroutine_t *coroutine, zend_string *info)
+{
+ ts_coroutine_t *ts = ts_from_coro(coroutine);
+
+ if (ts->wait_info != NULL) {
+ zend_string_release(ts->wait_info);
+ }
+
+ ts->wait_info = zend_string_copy(info);
+ TSG(wait_infos)++;
+ return true;
+}
+
+static bool ts_gc_destructors(zend_async_gc_run_dtors_fn run)
+{
+ TSG(gc_intercepts)++;
+ return run();
+}
+
+/* Adopt every foreign fiber: this scheduler creates no fibers of its own,
+ * so there is nothing to tell apart. */
+static zend_coroutine_t *ts_intercept_fiber(zend_fiber *fiber)
+{
+ (void) fiber;
+
+ zend_coroutine_t *coro = ts_new_coroutine(0);
+ ts_coroutine_t *ts = ts_from_coro(coro);
+
+ ts->active_counted = true;
+ ZEND_ASYNC_ACTIVE_COROUTINE_COUNT++;
+
+ /* The engine binds fiber <-> coroutine after this returns and the fiber
+ * teardown drives extended_dispose; the fiber must therefore outlive the
+ * handle, which the queue references (on the fiber object) guarantee. */
+ return coro;
+}
+
+/* One tick of microtasks, per the provider contract. A task that throws
+ * stops the drain; the exception surfaces at the suspension point. */
+static void ts_drain_microtasks(void)
+{
+ zend_async_microtask_t *task;
+
+ while ((task = ts_fifo_shift(&TSG(microtasks))) != NULL) {
+ if (!ZEND_ASYNC_MICROTASK_IS_CANCELLED(task)) {
+ task->handler(task);
+ }
+
+ ZEND_ASYNC_MICROTASK_RELEASE(task);
+
+ if (UNEXPECTED(EG(exception) != NULL)) {
+ break;
+ }
+ }
+}
+
+/* First-run trampoline for the scheduler's own coroutines: install a VM
+ * stack, run the body, record the outcome and hand control to main. */
+static ZEND_STACK_ALIGNED void ts_coroutine_execute(zend_fiber_transfer *transfer)
+{
+ ts_coroutine_t *ts = ts_starting;
+ bool bailout = false;
+
+ if (UNEXPECTED(transfer->flags & ZEND_FIBER_TRANSFER_FLAG_ERROR)) {
+ /* Cancelled before the body ever ran. */
+ ts->coro.exception = Z_OBJ(transfer->value);
+ ZVAL_UNDEF(&transfer->value);
+ } else {
+ zval_ptr_dtor(&transfer->value);
+ ZVAL_UNDEF(&transfer->value);
+
+ 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 = &ts_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(ts->ctx->stack);
+ EG(stack_limit) = zend_fiber_stack_limit(ts->ctx->stack);
+#endif
+
+ ts->coro.fcall->fci.retval = &ts->coro.result;
+ zend_call_function(&ts->coro.fcall->fci, &ts->coro.fcall->fci_cache);
+
+ if (UNEXPECTED(EG(exception) != NULL)) {
+ /* An unwind/graceful exit is a termination signal, not an
+ * outcome of the body: it is not recorded. */
+ if (!zend_is_unwind_exit(EG(exception)) && !zend_is_graceful_exit(EG(exception))) {
+ ts->coro.exception = EG(exception);
+ GC_ADDREF(ts->coro.exception);
+ }
+
+ zend_clear_exception();
+ }
+ } zend_catch {
+ bailout = true;
+ } zend_end_try();
+
+ ts->vm_stack = EG(vm_stack);
+ }
+
+ ZEND_COROUTINE_SET_STATUS(&ts->coro, ZEND_COROUTINE_STATUS_FINISHED);
+ ZEND_COROUTINE_FINISH(&ts->coro);
+ ts_live_unlink(ts);
+
+ if (ts->active_counted) {
+ ts->active_counted = false;
+ ZEND_ASYNC_ACTIVE_COROUTINE_COUNT--;
+ }
+
+ TSG(finished)++;
+
+ /* Drop the scheduler's live reference once a flow is back on safe
+ * ground; the raw pointer stays valid until then. */
+ ts_release_later(ts_keeper(ts));
+
+ /* Hand control back to the main flow, which is frozen inside its own
+ * scheduler loop and keeps draining. A bailout travels with the
+ * transfer and re-raises there. */
+ ZEND_ASSERT(TSG(main_coro) != NULL && "a coroutine cannot finish before main is adopted");
+
+ ZEND_ASYNC_CURRENT_COROUTINE = &TSG(main_coro)->coro;
+ ts_wake_deliberate = false;
+
+ transfer->context = TSG(main_coro)->ctx;
+ transfer->flags = bailout ? ZEND_FIBER_TRANSFER_FLAG_BAILOUT : 0;
+ ZVAL_NULL(&transfer->value);
+}
+
+/* Handle the incoming transfer after control returned to this flow: rethrow
+ * a bailout, raise a delivered error here (at the suspension point). */
+static void ts_handle_wakeup(ts_coroutine_t *self, zend_fiber_transfer *transfer)
+{
+ ZEND_ASYNC_IN_SCHEDULER_CONTEXT = true;
+ ZEND_ASYNC_CURRENT_COROUTINE = &self->coro;
+
+ if (UNEXPECTED(transfer->flags & ZEND_FIBER_TRANSFER_FLAG_BAILOUT)) {
+ zend_bailout();
+ }
+
+ if (UNEXPECTED(transfer->flags & ZEND_FIBER_TRANSFER_FLAG_ERROR)) {
+ zend_throw_exception_internal(Z_OBJ(transfer->value));
+ } else {
+ zval_ptr_dtor(&transfer->value);
+ }
+}
+
+/* Symmetric switch into `ts` (a deliberate wake: its turn came), delivering
+ * `error` (ownership taken) at its suspension point. Returns when some flow
+ * switches back into the caller. */
+static void ts_switch_into(ts_coroutine_t *ts, ts_coroutine_t *self, zend_object *error)
+{
+ zend_fiber_transfer transfer = { .context = ts->ctx, .flags = 0 };
+
+ if (error != NULL) {
+ ZVAL_OBJ(&transfer.value, error);
+ transfer.flags = ZEND_FIBER_TRANSFER_FLAG_ERROR;
+ } else {
+ ZVAL_NULL(&transfer.value);
+ }
+
+ ts_starting = ts;
+ ts_wake_deliberate = true;
+ ZEND_COROUTINE_SET_STATUS(&ts->coro, ZEND_COROUTINE_STATUS_RUNNING);
+ ZEND_COROUTINE_ENTER(&ts->coro);
+ ZEND_ASYNC_CURRENT_COROUTINE = &ts->coro;
+ ZEND_ASYNC_IN_SCHEDULER_CONTEXT = false;
+
+ zend_fiber_switch_context(&transfer);
+
+ ts_handle_wakeup(self, &transfer);
+}
+
+/* A dry queue in a non-main flow: park by handing control back to the main
+ * loop, which keeps draining. Returns on the next deliberate wake. */
+static void ts_park(ts_coroutine_t *self)
+{
+ ZEND_ASSERT(TSG(main_coro) != NULL && self != TSG(main_coro));
+
+ zend_fiber_transfer transfer = { .context = TSG(main_coro)->ctx, .flags = 0 };
+ ZVAL_NULL(&transfer.value);
+
+ ts_wake_deliberate = false;
+ ZEND_ASYNC_CURRENT_COROUTINE = &TSG(main_coro)->coro;
+
+ zend_fiber_switch_context(&transfer);
+
+ ts_handle_wakeup(self, &transfer);
+}
+
+/* Drive an adopted fiber through the plain Fiber API: in scheduler context
+ * the engine switches into it directly and returns here when it yields or
+ * finishes. An unhandled exception surfaces at this call site and is
+ * recorded on the coroutine, exactly like an own coroutine's. */
+static void ts_drive_fiber(ts_coroutine_t *ts, zend_object *error)
+{
+ zend_fiber *fiber = ts->coro.extended_data;
+ zval retval;
+
+ ZVAL_UNDEF(&retval);
+
+ if (fiber->context.status == ZEND_FIBER_STATUS_INIT) {
+ if (error != NULL) {
+ /* Cancelled before the first run: the body never starts. */
+ ts->coro.exception = error;
+ ZEND_COROUTINE_SET_STATUS(&ts->coro, ZEND_COROUTINE_STATUS_FINISHED);
+ ZEND_COROUTINE_FINISH(&ts->coro);
+ } else {
+ zend_call_method_with_0_params(&fiber->std, zend_ce_fiber, NULL, "start", &retval);
+ }
+ } else if (error != NULL) {
+ zval error_zv;
+ ZVAL_OBJ(&error_zv, error);
+ zend_call_method_with_1_params(&fiber->std, zend_ce_fiber, NULL, "throw", &retval, &error_zv);
+ OBJ_RELEASE(error);
+ } else {
+ zend_call_method_with_0_params(&fiber->std, zend_ce_fiber, NULL, "resume", &retval);
+ }
+
+ zval_ptr_dtor(&retval);
+
+ if (UNEXPECTED(EG(exception) != NULL)) {
+ if (ts->coro.exception == NULL) {
+ ts->coro.exception = EG(exception);
+ GC_ADDREF(ts->coro.exception);
+ }
+
+ zend_clear_exception();
+ }
+
+ if (ZEND_COROUTINE_IS_FINISHED(&ts->coro)) {
+ if (ts->active_counted) {
+ ts->active_counted = false;
+ ZEND_ASYNC_ACTIVE_COROUTINE_COUNT--;
+ }
+
+ TSG(finished)++;
+ }
+}
+
+/* Adopt the running main flow as a coroutine on its first suspension. */
+static ts_coroutine_t *ts_adopt_main(void)
+{
+ zend_object *object = ts_coroutine_object_create(ts_ce_coroutine);
+ ts_coroutine_t *ts = ts_from_obj(object);
+
+ ts->ctx = EG(current_fiber_context);
+ ts->ctx_captured = true;
+
+ ZEND_COROUTINE_SET_MAIN(&ts->coro);
+ ZEND_COROUTINE_SET_STATUS(&ts->coro, ZEND_COROUTINE_STATUS_RUNNING);
+
+ TSG(main_coro) = ts;
+ ZEND_ASYNC_MAIN_COROUTINE = &ts->coro;
+ ZEND_ASYNC_CURRENT_COROUTINE = &ts->coro;
+
+ zend_async_call_main_coroutine_start_handlers(&ts->coro);
+
+ return ts;
+}
+
+/*
+ * The suspend slot: the distributed scheduler loop.
+ *
+ * The yielding flow drains microtasks and the ready queue from its own
+ * frame, switching into runnable coroutines; control returns here when
+ * some flow switches back. The loop ends when the queues run dry (a real
+ * scheduler would block in a reactor instead), when the flow dequeues
+ * itself (a self-resume), or when an error is delivered to it.
+ */
+static bool ts_suspend(bool from_main, bool is_bailout)
+{
+ /* The naive policy needs no special bailout handling: the drain below
+ * runs the remaining coroutines either way. */
+ (void) is_bailout;
+
+ ts_coroutine_t *self = ts_from_coro(ZEND_ASYNC_CURRENT_COROUTINE);
+
+ if (self == NULL) {
+ self = ts_adopt_main();
+ }
+
+ if (self->ctx == NULL && self->coro.extended_data != NULL) {
+ /* Inside an adopted fiber's body the yield primitive is
+ * Fiber::suspend(): it returns to the loop driving the fiber. */
+ zend_throw_error(NULL, "Cannot suspend inside an adopted fiber: use Fiber::suspend()");
+ return false;
+ }
+
+ const bool saved_context = ZEND_ASYNC_IN_SCHEDULER_CONTEXT;
+ ZEND_ASYNC_IN_SCHEDULER_CONTEXT = true;
+
+ ZEND_COROUTINE_SET_STATUS(&self->coro, ZEND_COROUTINE_STATUS_SUSPENDED);
+ ZEND_COROUTINE_LEAVE(&self->coro);
+
+drain:
+ while (EXPECTED(EG(exception) == NULL)) {
+ TSG(ticks)++;
+ ts_drain_microtasks();
+ ts_drain_releases();
+
+ if (UNEXPECTED(EG(exception) != NULL)) {
+ break;
+ }
+
+ ts_coroutine_t *next = ts_fifo_shift(&TSG(ready));
+
+ if (next == NULL) {
+ if (self == TSG(main_coro)) {
+ /* Everything ran dry: main returns from its suspension (a
+ * real scheduler would block in a reactor here). */
+ break;
+ }
+
+ /* A parked coroutine returns from suspend() only when some
+ * flow deliberately wakes it. */
+ ts_park(self);
+ break;
+ }
+
+ if (next == self) {
+ /* A self-resume: the flow yielded and its turn came. */
+ ts_release_later(ts_keeper(next));
+ break;
+ }
+
+ if (ZEND_COROUTINE_IS_FINISHED(&next->coro)) {
+ ts_release_later(ts_keeper(next));
+ continue;
+ }
+
+ zend_object *error = next->pending_error;
+ next->pending_error = NULL;
+
+ if (next->ctx == NULL && next->coro.extended_data != NULL) {
+ ts_drive_fiber(next, error);
+ ts_release_later(ts_keeper(next));
+ continue;
+ }
+
+ if (next->ctx == NULL) {
+ next->ctx = ecalloc(1, sizeof(zend_fiber_context));
+
+ if (zend_fiber_init_context(next->ctx, ts_ce_coroutine, ts_coroutine_execute,
+ EG(fiber_stack_size)) == FAILURE) {
+ efree(next->ctx);
+ next->ctx = NULL;
+
+ if (error != NULL) {
+ OBJ_RELEASE(error);
+ }
+
+ ts_release_later(ts_keeper(next));
+ break;
+ }
+ }
+
+ ts_switch_into(next, self, error);
+ ts_release_later(ts_keeper(next));
+
+ if (ts_wake_deliberate) {
+ /* This flow's own turn came: stop scheduling and resume it. */
+ break;
+ }
+
+ /* A coroutine finished or parked back into the main loop: keep
+ * draining. Only main is ever woken this way. */
+ }
+
+ if (from_main && EG(exception) == NULL) {
+ /* End-of-main policy: coroutines still parked with nobody left to
+ * resume them are terminated, unwinding their stacks (finally
+ * blocks run; the exit signal is not catchable). Anything a
+ * finally block re-enqueued is drained again. */
+ ts_coroutine_t *parked = TSG(live_head);
+
+ while (parked != NULL
+ && !(parked->ctx != NULL && ZEND_COROUTINE_IS_SUSPENDED(&parked->coro))) {
+ parked = parked->live_next;
+ }
+
+ if (parked != NULL) {
+ ts_switch_into(parked, self, zend_create_unwind_exit());
+ goto drain;
+ }
+ }
+
+ ZEND_COROUTINE_SET_STATUS(&self->coro, ZEND_COROUTINE_STATUS_RUNNING);
+ ZEND_COROUTINE_ENTER(&self->coro);
+ ZEND_ASYNC_CURRENT_COROUTINE = &self->coro;
+ ZEND_ASYNC_IN_SCHEDULER_CONTEXT = saved_context;
+
+ /* A self-resume with an error: deliver it now, at the suspension point. */
+ if (UNEXPECTED(self->pending_error != NULL && EG(exception) == NULL)) {
+ zend_object *error = self->pending_error;
+ self->pending_error = NULL;
+ zend_throw_exception_internal(error);
+ }
+
+ return EG(exception) == NULL;
+}
+
+/////////////////////////////////////////////////////////////////////
+/// Userland API
+/////////////////////////////////////////////////////////////////////
+
+static bool ts_check_active(void)
+{
+ if (!ZEND_ASYNC_IS_ACTIVE) {
+ zend_throw_error(NULL,
+ "The test scheduler is not active (test_scheduler.enable=1 is required)");
+ return false;
+ }
+
+ return true;
+}
+
+PHP_FUNCTION(TestScheduler_spawn)
+{
+ zend_fcall_info fci;
+ zend_fcall_info_cache fcc;
+ zval *args;
+ uint32_t argc;
+ HashTable *named_args;
+
+ ZEND_PARSE_PARAMETERS_START(1, -1)
+ Z_PARAM_FUNC(fci, fcc)
+ Z_PARAM_VARIADIC_WITH_NAMED(args, argc, named_args)
+ ZEND_PARSE_PARAMETERS_END();
+
+ if (!ts_check_active()) {
+ RETURN_THROWS();
+ }
+
+ zend_object *object = ts_coroutine_object_create(ts_ce_coroutine);
+ ts_coroutine_t *ts = ts_from_obj(object);
+
+ ZEND_ASYNC_FCALL_DEFINE(fcall, fci, fcc, args, argc, named_args);
+ ts->coro.fcall = fcall;
+
+ ts->active_counted = true;
+ ZEND_ASYNC_ACTIVE_COROUTINE_COUNT++;
+ TSG(spawned)++;
+ ts_live_link(ts);
+
+ /* The scheduler's live reference: a parked coroutine stays alive even
+ * when userland drops it. Released when it finishes. */
+ GC_ADDREF(object);
+
+ if (!ZEND_ASYNC_ENQUEUE_COROUTINE(&ts->coro)) {
+ GC_DELREF(object);
+ OBJ_RELEASE(object);
+
+ if (!EG(exception)) {
+ zend_throw_error(NULL, "The scheduler did not accept the coroutine");
+ }
+
+ RETURN_THROWS();
+ }
+
+ RETURN_OBJ(object);
+}
+
+PHP_FUNCTION(TestScheduler_suspend)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ if (!ts_check_active()) {
+ RETURN_THROWS();
+ }
+
+ ZEND_ASYNC_SUSPEND();
+
+ if (UNEXPECTED(EG(exception) != NULL)) {
+ RETURN_THROWS();
+ }
+}
+
+PHP_FUNCTION(TestScheduler_resume)
+{
+ zval *coroutine;
+ zend_object *error = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 2)
+ Z_PARAM_OBJECT_OF_CLASS(coroutine, ts_ce_coroutine)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_OBJ_OF_CLASS_OR_NULL(error, zend_ce_throwable)
+ ZEND_PARSE_PARAMETERS_END();
+
+ if (!ts_check_active()) {
+ RETURN_THROWS();
+ }
+
+ const bool accepted = error != NULL
+ ? ZEND_ASYNC_RESUME_WITH_ERROR(&ts_from_obj(Z_OBJ_P(coroutine))->coro, error, false)
+ : ZEND_ASYNC_RESUME(&ts_from_obj(Z_OBJ_P(coroutine))->coro);
+
+ if (UNEXPECTED(EG(exception) != NULL)) {
+ RETURN_THROWS();
+ }
+
+ RETURN_BOOL(accepted);
+}
+
+PHP_FUNCTION(TestScheduler_cancel)
+{
+ zval *coroutine;
+ zend_object *error = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 2)
+ Z_PARAM_OBJECT_OF_CLASS(coroutine, ts_ce_coroutine)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_OBJ_OF_CLASS_OR_NULL(error, zend_ce_throwable)
+ ZEND_PARSE_PARAMETERS_END();
+
+ if (!ts_check_active()) {
+ RETURN_THROWS();
+ }
+
+ bool transfer_error = false;
+
+ if (error == NULL) {
+ zval exception;
+ object_init_ex(&exception, ZEND_ASYNC_GET_EXCEPTION_CE(ZEND_ASYNC_EXCEPTION_CANCELLATION));
+
+ zval message;
+ ZVAL_STRING(&message, "The coroutine was cancelled");
+ zend_call_known_instance_method_with_1_params(
+ Z_OBJCE(exception)->constructor, Z_OBJ(exception), NULL, &message);
+ zval_ptr_dtor(&message);
+
+ error = Z_OBJ(exception);
+ transfer_error = true;
+ }
+
+ const bool accepted = zend_async_cancel_fn(
+ &ts_from_obj(Z_OBJ_P(coroutine))->coro, error, transfer_error, false);
+
+ if (UNEXPECTED(EG(exception) != NULL)) {
+ RETURN_THROWS();
+ }
+
+ RETURN_BOOL(accepted);
+}
+
+static void ts_microtask_run(zend_async_microtask_t *task)
+{
+ ts_microtask_t *container = (ts_microtask_t *) task;
+ zval retval;
+
+ call_user_function(NULL, NULL, &container->callable, &retval, 0, NULL);
+ zval_ptr_dtor(&retval);
+}
+
+static void ts_microtask_dtor(zend_async_microtask_t *task)
+{
+ ts_microtask_t *container = (ts_microtask_t *) task;
+
+ zval_ptr_dtor(&container->callable);
+}
+
+PHP_FUNCTION(TestScheduler_defer)
+{
+ zval *task;
+
+ ZEND_PARSE_PARAMETERS_START(1, 1)
+ Z_PARAM_ZVAL(task)
+ ZEND_PARSE_PARAMETERS_END();
+
+ if (!ts_check_active()) {
+ RETURN_THROWS();
+ }
+
+ if (!zend_is_callable(task, 0, NULL)) {
+ zend_argument_type_error(1, "must be a valid callback");
+ RETURN_THROWS();
+ }
+
+ ts_microtask_t *container = ecalloc(1, sizeof(*container));
+
+ container->task.handler = ts_microtask_run;
+ container->task.dtor = ts_microtask_dtor;
+ container->task.ref_count = 1;
+ ZVAL_COPY(&container->callable, task);
+
+ if (!ZEND_ASYNC_DEFER(&container->task)) {
+ ZEND_ASYNC_MICROTASK_RELEASE(&container->task);
+ zend_throw_error(NULL, "The scheduler did not accept the microtask");
+ RETURN_THROWS();
+ }
+}
+
+PHP_FUNCTION(TestScheduler_current)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ zend_coroutine_t *coro = ZEND_ASYNC_CURRENT_COROUTINE;
+
+ if (coro == NULL) {
+ RETURN_NULL();
+ }
+
+ zend_object *object = ZEND_COROUTINE_OBJECT(coro);
+
+ if (object == NULL) {
+ RETURN_NULL();
+ }
+
+ RETURN_OBJ_COPY(object);
+}
+
+PHP_FUNCTION(TestScheduler_set_wait_info)
+{
+ zval *coroutine;
+ zend_string *info;
+
+ ZEND_PARSE_PARAMETERS_START(2, 2)
+ Z_PARAM_OBJECT_OF_CLASS(coroutine, ts_ce_coroutine)
+ Z_PARAM_STR(info)
+ ZEND_PARSE_PARAMETERS_END();
+
+ if (!ts_check_active()) {
+ RETURN_THROWS();
+ }
+
+ ZEND_ASYNC_WAIT_INFO(&ts_from_obj(Z_OBJ_P(coroutine))->coro, info);
+}
+
+static zend_coroutine_t *ts_target_coroutine(zval *coroutine)
+{
+ return coroutine != NULL ? &ts_from_obj(Z_OBJ_P(coroutine))->coro : NULL;
+}
+
+PHP_FUNCTION(TestScheduler_ctx_set)
+{
+ zend_string *key;
+ zval *value;
+ zval *coroutine = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(2, 3)
+ Z_PARAM_STR(key)
+ Z_PARAM_ZVAL(value)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_OBJECT_OF_CLASS_OR_NULL(coroutine, ts_ce_coroutine)
+ ZEND_PARSE_PARAMETERS_END();
+
+ zval key_zv;
+ ZVAL_STR(&key_zv, key);
+
+ RETURN_BOOL(ZEND_ASYNC_CONTEXT_SET(ts_target_coroutine(coroutine), &key_zv, value));
+}
+
+PHP_FUNCTION(TestScheduler_ctx_find)
+{
+ zend_string *key;
+ zval *coroutine = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 2)
+ Z_PARAM_STR(key)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_OBJECT_OF_CLASS_OR_NULL(coroutine, ts_ce_coroutine)
+ ZEND_PARSE_PARAMETERS_END();
+
+ zval key_zv;
+ ZVAL_STR(&key_zv, key);
+
+ const zval *value = ZEND_ASYNC_CONTEXT_FIND(ts_target_coroutine(coroutine), &key_zv);
+
+ if (value == NULL) {
+ RETURN_NULL();
+ }
+
+ RETURN_COPY(value);
+}
+
+PHP_FUNCTION(TestScheduler_ctx_unset)
+{
+ zend_string *key;
+ zval *coroutine = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 2)
+ Z_PARAM_STR(key)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_OBJECT_OF_CLASS_OR_NULL(coroutine, ts_ce_coroutine)
+ ZEND_PARSE_PARAMETERS_END();
+
+ zval key_zv;
+ ZVAL_STR(&key_zv, key);
+
+ RETURN_BOOL(ZEND_ASYNC_CONTEXT_UNSET(ts_target_coroutine(coroutine), &key_zv));
+}
+
+PHP_FUNCTION(TestScheduler_internal_value_set)
+{
+ zval *value;
+ zval *coroutine = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(1, 2)
+ Z_PARAM_ZVAL(value)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_OBJECT_OF_CLASS_OR_NULL(coroutine, ts_ce_coroutine)
+ ZEND_PARSE_PARAMETERS_END();
+
+ RETURN_BOOL(ZEND_ASYNC_INTERNAL_CONTEXT_SET(ts_target_coroutine(coroutine), ts_internal_key, value));
+}
+
+PHP_FUNCTION(TestScheduler_internal_value_get)
+{
+ zval *coroutine = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(0, 1)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_OBJECT_OF_CLASS_OR_NULL(coroutine, ts_ce_coroutine)
+ ZEND_PARSE_PARAMETERS_END();
+
+ zval *value = ZEND_ASYNC_INTERNAL_CONTEXT_FIND(ts_target_coroutine(coroutine), ts_internal_key);
+
+ if (value == NULL) {
+ RETURN_NULL();
+ }
+
+ RETURN_COPY(value);
+}
+
+PHP_FUNCTION(TestScheduler_internal_value_unset)
+{
+ zval *coroutine = NULL;
+
+ ZEND_PARSE_PARAMETERS_START(0, 1)
+ Z_PARAM_OPTIONAL
+ Z_PARAM_OBJECT_OF_CLASS_OR_NULL(coroutine, ts_ce_coroutine)
+ ZEND_PARSE_PARAMETERS_END();
+
+ RETURN_BOOL(ZEND_ASYNC_INTERNAL_CONTEXT_UNSET(ts_target_coroutine(coroutine), ts_internal_key));
+}
+
+PHP_FUNCTION(TestScheduler_stats)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ array_init(return_value);
+ add_assoc_long(return_value, "spawned", TSG(spawned));
+ add_assoc_long(return_value, "finished", TSG(finished));
+ add_assoc_long(return_value, "ticks", TSG(ticks));
+ add_assoc_long(return_value, "ready", (zend_long) TSG(ready).count);
+ add_assoc_long(return_value, "microtasks", (zend_long) TSG(microtasks).count);
+ add_assoc_long(return_value, "active", (zend_long) ZEND_ASYNC_ACTIVE_COROUTINE_COUNT);
+ add_assoc_long(return_value, "switch_enters", TSG(switch_enters));
+ add_assoc_long(return_value, "switch_leaves", TSG(switch_leaves));
+ add_assoc_long(return_value, "switch_finishes", TSG(switch_finishes));
+ add_assoc_long(return_value, "wait_infos", TSG(wait_infos));
+ add_assoc_long(return_value, "gc_intercepts", TSG(gc_intercepts));
+ add_assoc_bool(return_value, "main_start_fired", TSG(main_start_fired));
+ add_assoc_bool(return_value, "launched", TSG(launched));
+ add_assoc_string(return_value, "module", zend_async_get_scheduler_module() != NULL
+ ? zend_async_get_scheduler_module() : "");
+}
+
+/////////////////////////////////////////////////////////////////////
+/// TestScheduler\Coroutine methods
+/////////////////////////////////////////////////////////////////////
+
+ZEND_METHOD(TestScheduler_Coroutine, __construct)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+ zend_throw_error(NULL, "TestScheduler\\Coroutine instances are minted by spawn()");
+}
+
+#define TS_STATUS_METHOD(name, predicate) \
+ ZEND_METHOD(TestScheduler_Coroutine, name) \
+ { \
+ ZEND_PARSE_PARAMETERS_NONE(); \
+ RETURN_BOOL(predicate(&ts_from_obj(Z_OBJ_P(ZEND_THIS))->coro)); \
+ }
+
+TS_STATUS_METHOD(isStarted, ZEND_COROUTINE_IS_STARTED)
+TS_STATUS_METHOD(isQueued, ZEND_COROUTINE_IS_QUEUED)
+TS_STATUS_METHOD(isRunning, ZEND_COROUTINE_IS_RUNNING)
+TS_STATUS_METHOD(isSuspended, ZEND_COROUTINE_IS_SUSPENDED)
+TS_STATUS_METHOD(isFinished, ZEND_COROUTINE_IS_FINISHED)
+TS_STATUS_METHOD(isCancelled, ZEND_COROUTINE_IS_CANCELLED)
+
+ZEND_METHOD(TestScheduler_Coroutine, getResult)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ ts_coroutine_t *ts = ts_from_obj(Z_OBJ_P(ZEND_THIS));
+
+ if (Z_TYPE(ts->coro.result) == IS_UNDEF) {
+ RETURN_NULL();
+ }
+
+ RETURN_COPY(&ts->coro.result);
+}
+
+ZEND_METHOD(TestScheduler_Coroutine, getException)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ ts_coroutine_t *ts = ts_from_obj(Z_OBJ_P(ZEND_THIS));
+
+ if (ts->coro.exception == NULL) {
+ RETURN_NULL();
+ }
+
+ RETURN_OBJ_COPY(ts->coro.exception);
+}
+
+ZEND_METHOD(TestScheduler_Coroutine, getAwaitingInfo)
+{
+ ZEND_PARSE_PARAMETERS_NONE();
+
+ zend_string *info = ZEND_COROUTINE_AWAITING_INFO(&ts_from_obj(Z_OBJ_P(ZEND_THIS))->coro);
+
+ if (info == NULL) {
+ RETURN_NULL();
+ }
+
+ RETURN_STR(info);
+}
+
+/////////////////////////////////////////////////////////////////////
+/// Module
+/////////////////////////////////////////////////////////////////////
+
+PHP_INI_BEGIN()
+ STD_PHP_INI_BOOLEAN("test_scheduler.enable", "0", PHP_INI_SYSTEM,
+ OnUpdateBool, enable, zend_test_scheduler_globals, test_scheduler_globals)
+PHP_INI_END()
+
+static PHP_MINIT_FUNCTION(test_scheduler)
+{
+ REGISTER_INI_ENTRIES();
+
+ ts_ce_coroutine = register_class_TestScheduler_Coroutine();
+ ts_ce_coroutine->create_object = ts_coroutine_object_create;
+
+ memcpy(&ts_coroutine_handlers, zend_get_std_object_handlers(), sizeof(zend_object_handlers));
+ ts_coroutine_handlers.offset = offsetof(ts_coroutine_t, std);
+ ts_coroutine_handlers.free_obj = ts_coroutine_object_free;
+ ts_coroutine_handlers.get_gc = ts_coroutine_object_gc;
+ ts_coroutine_handlers.clone_obj = NULL;
+
+ ts_ce_cancellation = register_class_TestScheduler_CancellationException(zend_ce_exception);
+
+ ts_root_function.type = ZEND_INTERNAL_FUNCTION;
+ ts_root_function.common.function_name =
+ zend_string_init_interned(ZEND_STRL("TestScheduler\\Coroutine"), true);
+
+ if (!TSG(enable)) {
+ return SUCCESS;
+ }
+
+ if (zend_async_is_enabled()) {
+ zend_error(E_CORE_WARNING,
+ "test_scheduler: another scheduler is already registered, staying inactive");
+ return SUCCESS;
+ }
+
+ zend_async_scheduler_api_t api;
+ memset(&api, 0, sizeof(api));
+ api.size = sizeof(api);
+ api.new_coroutine = ts_new_coroutine;
+ api.enqueue_coroutine = ts_enqueue;
+ api.suspend = ts_suspend;
+ api.resume = ts_resume;
+ api.cancel = ts_cancel;
+ api.launch = ts_launch;
+ api.shutdown = ts_shutdown;
+ api.get_class_ce = ts_get_class_ce;
+ api.intercept_fiber = ts_intercept_fiber;
+ api.gc_destructors = ts_gc_destructors;
+ api.defer = ts_defer;
+ api.wait_info = ts_wait_info;
+ /* call_on_main_stack keeps the engine default: the naive scheduler has
+ * no machinery to trampoline onto the main OS-thread stack. */
+
+ if (!zend_async_scheduler_register("test_scheduler", &api)) {
+ return SUCCESS;
+ }
+
+ ts_internal_key = ZEND_ASYNC_INTERNAL_CONTEXT_KEY_ALLOC("test_scheduler.value");
+ ZEND_ASYNC_ADD_MAIN_COROUTINE_START_HANDLER(ts_main_started);
+
+ return SUCCESS;
+}
+
+static PHP_MSHUTDOWN_FUNCTION(test_scheduler)
+{
+ UNREGISTER_INI_ENTRIES();
+ return SUCCESS;
+}
+
+static PHP_RINIT_FUNCTION(test_scheduler)
+{
+#if defined(ZTS) && defined(COMPILE_DL_TEST_SCHEDULER)
+ ZEND_TSRMLS_CACHE_UPDATE();
+#endif
+
+ if (TSG(enable) && zend_async_get_scheduler_module() != NULL
+ && strcmp(zend_async_get_scheduler_module(), "test_scheduler") == 0) {
+ /* READY: the engine invokes the launch slot right before it starts
+ * executing the main script. */
+ ZEND_ASYNC_INITIALIZE;
+ }
+
+ return SUCCESS;
+}
+
+static PHP_RSHUTDOWN_FUNCTION(test_scheduler)
+{
+ TSG(shutting_down) = true;
+
+ ts_coroutine_t *ts;
+ while ((ts = ts_fifo_shift(&TSG(ready))) != NULL) {
+ ts_release_later(ts_keeper(ts));
+ }
+
+ zend_async_microtask_t *task;
+ while ((task = ts_fifo_shift(&TSG(microtasks))) != NULL) {
+ ZEND_ASYNC_MICROTASK_RELEASE(task);
+ }
+
+ if (TSG(main_coro) != NULL) {
+ zend_object *main_object = ZEND_COROUTINE_OBJECT(&TSG(main_coro)->coro);
+
+ ZEND_ASYNC_MAIN_COROUTINE = NULL;
+ ZEND_ASYNC_CURRENT_COROUTINE = NULL;
+ TSG(main_coro) = NULL;
+ OBJ_RELEASE(main_object);
+ }
+
+ ts_drain_releases();
+
+ TSG(live_head) = NULL;
+ TSG(shutting_down) = false;
+ TSG(launched) = false;
+ TSG(main_start_fired) = false;
+ TSG(spawned) = TSG(finished) = TSG(ticks) = 0;
+ TSG(switch_enters) = TSG(switch_leaves) = TSG(switch_finishes) = 0;
+ TSG(wait_infos) = TSG(gc_intercepts) = 0;
+
+ return SUCCESS;
+}
+
+static PHP_MINFO_FUNCTION(test_scheduler)
+{
+ php_info_print_table_start();
+ php_info_print_table_row(2, "test_scheduler support", "enabled");
+ php_info_print_table_row(2, "Registered as the Async scheduler",
+ zend_async_get_scheduler_module() != NULL
+ && strcmp(zend_async_get_scheduler_module(), "test_scheduler") == 0
+ ? "yes"
+ : "no");
+ php_info_print_table_end();
+
+ DISPLAY_INI_ENTRIES();
+}
+
+static PHP_GINIT_FUNCTION(test_scheduler)
+{
+#if defined(ZTS) && defined(COMPILE_DL_TEST_SCHEDULER)
+ ZEND_TSRMLS_CACHE_UPDATE();
+#endif
+ memset(test_scheduler_globals, 0, sizeof(*test_scheduler_globals));
+}
+
+zend_module_entry test_scheduler_module_entry = {
+ STANDARD_MODULE_HEADER,
+ "test_scheduler",
+ ext_functions,
+ PHP_MINIT(test_scheduler),
+ PHP_MSHUTDOWN(test_scheduler),
+ PHP_RINIT(test_scheduler),
+ PHP_RSHUTDOWN(test_scheduler),
+ PHP_MINFO(test_scheduler),
+ PHP_TEST_SCHEDULER_VERSION,
+ PHP_MODULE_GLOBALS(test_scheduler),
+ PHP_GINIT(test_scheduler),
+ NULL,
+ NULL,
+ STANDARD_MODULE_PROPERTIES_EX
+};
+
+#ifdef COMPILE_DL_TEST_SCHEDULER
+ZEND_GET_MODULE(test_scheduler)
+#endif
diff --git a/ext/test_scheduler/test_scheduler.stub.php b/ext/test_scheduler/test_scheduler.stub.php
new file mode 100644
index 000000000000..332dc74cfe79
--- /dev/null
+++ b/ext/test_scheduler/test_scheduler.stub.php
@@ -0,0 +1,98 @@
+isFinished(), $b->isFinished());
+
+?>
+--EXPECT--
+main1
+a1
+b1
+main2
+a2
+b2
+main3
+bool(true)
+bool(true)
diff --git a/ext/test_scheduler/tests/002_args_result.phpt b/ext/test_scheduler/tests/002_args_result.phpt
new file mode 100644
index 000000000000..6cc16387c7b6
--- /dev/null
+++ b/ext/test_scheduler/tests/002_args_result.phpt
@@ -0,0 +1,28 @@
+--TEST--
+spawn() passes arguments; getResult() delivers the body's return value
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+ $x + $y, 2, 3);
+
+var_dump($c->getResult());
+suspend();
+var_dump($c->isFinished(), $c->getResult(), $c->getException());
+
+$named = spawn(static fn (string $sep, string ...$parts) => implode($sep, $parts), '-', 'a', 'b');
+suspend();
+var_dump($named->getResult());
+
+?>
+--EXPECT--
+NULL
+bool(true)
+int(5)
+NULL
+string(3) "a-b"
diff --git a/ext/test_scheduler/tests/003_uncaught_exception.phpt b/ext/test_scheduler/tests/003_uncaught_exception.phpt
new file mode 100644
index 000000000000..45ad4d59fff5
--- /dev/null
+++ b/ext/test_scheduler/tests/003_uncaught_exception.phpt
@@ -0,0 +1,30 @@
+--TEST--
+An uncaught exception finishes the coroutine and is recorded on it
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+isFinished());
+$e = $c->getException();
+var_dump($e instanceof RuntimeException, $e->getMessage());
+
+?>
+--EXPECT--
+before
+main survived
+bool(true)
+bool(true)
+string(4) "boom"
diff --git a/ext/test_scheduler/tests/004_resume_with_error.phpt b/ext/test_scheduler/tests/004_resume_with_error.phpt
new file mode 100644
index 000000000000..dd7f538ca7bd
--- /dev/null
+++ b/ext/test_scheduler/tests/004_resume_with_error.phpt
@@ -0,0 +1,35 @@
+--TEST--
+resume() with an error throws it at the coroutine's suspension point
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+getMessage()}\n";
+ return 'recovered';
+ }
+});
+
+suspend();
+var_dump($c->isSuspended());
+
+resume($c, new LogicException('delivered'));
+suspend();
+
+var_dump($c->isFinished(), $c->getResult());
+
+?>
+--EXPECT--
+bool(true)
+caught inside: delivered
+bool(true)
+string(9) "recovered"
diff --git a/ext/test_scheduler/tests/005_cancel_before_start.phpt b/ext/test_scheduler/tests/005_cancel_before_start.phpt
new file mode 100644
index 000000000000..93fe2f3e1e90
--- /dev/null
+++ b/ext/test_scheduler/tests/005_cancel_before_start.phpt
@@ -0,0 +1,29 @@
+--TEST--
+cancel() before the first run: the body never starts
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+isCancelled(), $c->isFinished());
+$e = $c->getException();
+var_dump($e instanceof TestScheduler\CancellationException, $e->getMessage());
+
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+string(27) "The coroutine was cancelled"
diff --git a/ext/test_scheduler/tests/006_cancel_suspended.phpt b/ext/test_scheduler/tests/006_cancel_suspended.phpt
new file mode 100644
index 000000000000..436322ff96df
--- /dev/null
+++ b/ext/test_scheduler/tests/006_cancel_suspended.phpt
@@ -0,0 +1,46 @@
+--TEST--
+cancel() of a suspended coroutine delivers the error at its suspension point
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+isSuspended(), $custom->isSuspended());
+
+cancel($graceful);
+cancel($custom, new DomainException('custom reason'));
+suspend();
+
+var_dump($graceful->isFinished(), $graceful->isCancelled(), $graceful->getException());
+$e = $custom->getException();
+var_dump($custom->isFinished(), $custom->isCancelled(), $e instanceof DomainException, $e->getMessage());
+
+?>
+--EXPECT--
+bool(true)
+bool(true)
+cleanup ran
+bool(true)
+bool(true)
+NULL
+bool(true)
+bool(true)
+bool(true)
+string(13) "custom reason"
diff --git a/ext/test_scheduler/tests/007_defer_microtasks.phpt b/ext/test_scheduler/tests/007_defer_microtasks.phpt
new file mode 100644
index 000000000000..02e3d1fd1b97
--- /dev/null
+++ b/ext/test_scheduler/tests/007_defer_microtasks.phpt
@@ -0,0 +1,39 @@
+--TEST--
+defer(): microtasks run on the tick, before the next coroutine switch
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECT--
+main before
+microtask 1
+microtask 2
+nested microtask
+coroutine
+main after
diff --git a/ext/test_scheduler/tests/008_defer_throw.phpt b/ext/test_scheduler/tests/008_defer_throw.phpt
new file mode 100644
index 000000000000..fc6115bc3e82
--- /dev/null
+++ b/ext/test_scheduler/tests/008_defer_throw.phpt
@@ -0,0 +1,33 @@
+--TEST--
+A microtask that throws surfaces the exception at the suspension point
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+getMessage()}\n";
+}
+
+suspend();
+var_dump($c->isFinished());
+
+?>
+--EXPECT--
+caught: from microtask
+coroutine still runs later
+bool(true)
diff --git a/ext/test_scheduler/tests/009_self_resume.phpt b/ext/test_scheduler/tests/009_self_resume.phpt
new file mode 100644
index 000000000000..b04f350d19db
--- /dev/null
+++ b/ext/test_scheduler/tests/009_self_resume.phpt
@@ -0,0 +1,36 @@
+--TEST--
+Yielding: resume(current()) + suspend() gives other coroutines a turn
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECT--
+NULL
+a1
+b1
+a2
+main
+bool(true)
diff --git a/ext/test_scheduler/tests/010_after_main_drain.phpt b/ext/test_scheduler/tests/010_after_main_drain.phpt
new file mode 100644
index 000000000000..fbd72b36bf72
--- /dev/null
+++ b/ext/test_scheduler/tests/010_after_main_drain.phpt
@@ -0,0 +1,26 @@
+--TEST--
+Coroutines left on the queue run after the main script ends
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECT--
+main end
+after main 1
+after main 2
diff --git a/ext/test_scheduler/tests/011_statuses.phpt b/ext/test_scheduler/tests/011_statuses.phpt
new file mode 100644
index 000000000000..d43039ad01b7
--- /dev/null
+++ b/ext/test_scheduler/tests/011_statuses.phpt
@@ -0,0 +1,46 @@
+--TEST--
+The coroutine lifecycle maps to the is*() predicates
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+isRunning());
+ suspend();
+});
+
+echo "queued:\n";
+var_dump($c->isQueued(), $c->isStarted(), $c->isFinished());
+
+echo "running:\n";
+suspend();
+
+echo "suspended:\n";
+var_dump($c->isSuspended(), $c->isFinished());
+
+resume($c);
+suspend();
+
+echo "finished:\n";
+var_dump($c->isFinished(), $c->isSuspended(), $c->isCancelled());
+
+?>
+--EXPECT--
+queued:
+bool(true)
+bool(true)
+bool(false)
+running:
+bool(true)
+suspended:
+bool(true)
+bool(false)
+finished:
+bool(true)
+bool(false)
+bool(false)
diff --git a/ext/test_scheduler/tests/012_wait_info.phpt b/ext/test_scheduler/tests/012_wait_info.phpt
new file mode 100644
index 000000000000..0d0e78679f3c
--- /dev/null
+++ b/ext/test_scheduler/tests/012_wait_info.phpt
@@ -0,0 +1,30 @@
+--TEST--
+set_wait_info() records diagnostics readable via getAwaitingInfo()
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+getAwaitingInfo());
+set_wait_info($c, 'socket #7 (readable)');
+var_dump($c->getAwaitingInfo());
+set_wait_info($c, 'channel recv');
+var_dump($c->getAwaitingInfo());
+var_dump(stats()['wait_infos']);
+
+?>
+--EXPECT--
+NULL
+string(20) "socket #7 (readable)"
+string(12) "channel recv"
+int(2)
diff --git a/ext/test_scheduler/tests/013_internal_context.phpt b/ext/test_scheduler/tests/013_internal_context.phpt
new file mode 100644
index 000000000000..5d2f06a87fd5
--- /dev/null
+++ b/ext/test_scheduler/tests/013_internal_context.phpt
@@ -0,0 +1,67 @@
+--TEST--
+The internal (C-level) per-coroutine context isolates values per coroutine
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECT--
+NULL
+bool(true)
+string(10) "main value"
+NULL
+array(2) {
+ [0]=>
+ string(3) "per"
+ [1]=>
+ string(9) "coroutine"
+}
+array(2) {
+ [0]=>
+ string(3) "per"
+ [1]=>
+ string(9) "coroutine"
+}
+string(10) "main value"
+array(2) {
+ [0]=>
+ string(3) "per"
+ [1]=>
+ string(9) "coroutine"
+}
+bool(true)
+bool(false)
+NULL
diff --git a/ext/test_scheduler/tests/014_fiber_adoption.phpt b/ext/test_scheduler/tests/014_fiber_adoption.phpt
new file mode 100644
index 000000000000..5c6a0efb2418
--- /dev/null
+++ b/ext/test_scheduler/tests/014_fiber_adoption.phpt
@@ -0,0 +1,40 @@
+--TEST--
+Fiber adoption: plain Fibers become coroutines driven by the scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+start(21);
+var_dump($yielded);
+
+var_dump($fiber->isSuspended());
+var_dump($fiber->resume('payload'));
+var_dump($fiber->isTerminated(), $fiber->getReturn());
+
+?>
+--EXPECT--
+coroutine ran first
+fiber started with 21
+int(42)
+bool(true)
+fiber resumed with payload
+NULL
+bool(true)
+string(4) "done"
diff --git a/ext/test_scheduler/tests/015_fiber_throw.phpt b/ext/test_scheduler/tests/015_fiber_throw.phpt
new file mode 100644
index 000000000000..bcdab8e6c5a0
--- /dev/null
+++ b/ext/test_scheduler/tests/015_fiber_throw.phpt
@@ -0,0 +1,27 @@
+--TEST--
+Fiber adoption: throw() delivers the exception at the fiber's suspension point
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+getMessage()}\n";
+ return 'handled';
+ }
+});
+
+var_dump($fiber->start());
+$fiber->throw(new RuntimeException('injected'));
+var_dump($fiber->getReturn());
+
+?>
+--EXPECT--
+string(5) "ready"
+fiber caught: injected
+string(7) "handled"
diff --git a/ext/test_scheduler/tests/016_stats_counters.phpt b/ext/test_scheduler/tests/016_stats_counters.phpt
new file mode 100644
index 000000000000..d1350a7410ca
--- /dev/null
+++ b/ext/test_scheduler/tests/016_stats_counters.phpt
@@ -0,0 +1,43 @@
+--TEST--
+stats(): launch, main-start handler, switch handlers and counters
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+ 0);
+var_dump($after['switch_enters'] > 0, $after['switch_leaves'] > 0, $after['switch_finishes'] > 0);
+
+?>
+--EXPECT--
+string(14) "test_scheduler"
+bool(true)
+bool(false)
+int(0)
+bool(true)
+int(2)
+int(1)
+int(1)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/test_scheduler/tests/017_error_paths.phpt b/ext/test_scheduler/tests/017_error_paths.phpt
new file mode 100644
index 000000000000..991ca69dcb3b
--- /dev/null
+++ b/ext/test_scheduler/tests/017_error_paths.phpt
@@ -0,0 +1,64 @@
+--TEST--
+Error paths: finished coroutines, double resume, direct construction, adopted-fiber suspend
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+isFinished());
+
+try {
+ resume($c);
+} catch (Error $e) {
+ echo "resume finished: {$e->getMessage()}\n";
+}
+
+// Cancelling a finished coroutine is a quiet no-op.
+var_dump(cancel($c));
+
+try {
+ new TestScheduler\Coroutine();
+} catch (Error $e) {
+ echo "new: {$e->getMessage()}\n";
+}
+
+try {
+ clone $c;
+} catch (Error $e) {
+ echo "clone: {$e->getMessage()}\n";
+}
+
+$fiber = new Fiber(function () {
+ try {
+ TestScheduler\suspend();
+ } catch (Error $e) {
+ echo "in fiber: {$e->getMessage()}\n";
+ }
+ Fiber::suspend('yield');
+});
+
+$fiber->start();
+
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+resume finished: Cannot resume a finished coroutine
+bool(true)
+new: Call to private TestScheduler\Coroutine::__construct() from global scope
+clone: Trying to clone an uncloneable object of class TestScheduler\Coroutine
+in fiber: Cannot suspend inside an adopted fiber: use Fiber::suspend()
diff --git a/ext/test_scheduler/tests/018_gc_destructors.phpt b/ext/test_scheduler/tests/018_gc_destructors.phpt
new file mode 100644
index 000000000000..e01e61994352
--- /dev/null
+++ b/ext/test_scheduler/tests/018_gc_destructors.phpt
@@ -0,0 +1,40 @@
+--TEST--
+The gc_destructors hook intercepts the GC destructor phase while active
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+self = $this;
+ }
+
+ public function __destruct()
+ {
+ echo "destructor ran\n";
+ }
+}
+
+suspend(); // activate and adopt main
+
+var_dump(stats()['gc_intercepts']);
+
+new Cycle();
+gc_collect_cycles();
+
+var_dump(stats()['gc_intercepts'] > 0);
+
+?>
+--EXPECT--
+int(0)
+destructor ran
+bool(true)
diff --git a/ext/test_scheduler/tests/019_disabled.phpt b/ext/test_scheduler/tests/019_disabled.phpt
new file mode 100644
index 000000000000..1b037cd24cbe
--- /dev/null
+++ b/ext/test_scheduler/tests/019_disabled.phpt
@@ -0,0 +1,41 @@
+--TEST--
+With test_scheduler.enable=0 the scheduler is not registered and the API throws
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=0
+--FILE--
+getMessage()}\n";
+}
+
+try {
+ suspend();
+} catch (Error $e) {
+ echo "suspend: {$e->getMessage()}\n";
+}
+
+// Plain fibers keep the legacy low-level behaviour.
+$fiber = new Fiber(function () {
+ echo Fiber::suspend('low-level'), "\n";
+});
+
+var_dump($fiber->start());
+$fiber->resume('resumed');
+
+?>
+--EXPECT--
+string(0) ""
+bool(false)
+spawn: The test scheduler is not active (test_scheduler.enable=1 is required)
+suspend: The test scheduler is not active (test_scheduler.enable=1 is required)
+string(9) "low-level"
+resumed
diff --git a/ext/test_scheduler/tests/020_fifo_many.phpt b/ext/test_scheduler/tests/020_fifo_many.phpt
new file mode 100644
index 000000000000..f01883ff5feb
--- /dev/null
+++ b/ext/test_scheduler/tests/020_fifo_many.phpt
@@ -0,0 +1,50 @@
+--TEST--
+FIFO order over many coroutines; wakes follow the resume order
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+isFinished());
+}
+
+?>
+--EXPECT--
+start 1
+start 2
+start 3
+start 4
+start 5
+wake 3
+wake 1
+wake 5
+wake 2
+wake 4
+bool(true)
+bool(true)
+bool(true)
+bool(true)
+bool(true)
diff --git a/ext/test_scheduler/tests/021_nested_spawn.phpt b/ext/test_scheduler/tests/021_nested_spawn.phpt
new file mode 100644
index 000000000000..06d195db1046
--- /dev/null
+++ b/ext/test_scheduler/tests/021_nested_spawn.phpt
@@ -0,0 +1,41 @@
+--TEST--
+A coroutine can spawn coroutines; direct coroutine-to-coroutine switching
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+isFinished(), $inner->isFinished());
+
+?>
+--EXPECT--
+a before
+inner start
+inner wake
+a wake
+bool(true)
+bool(true)
diff --git a/ext/test_scheduler/tests/022_abandoned_coroutines.phpt b/ext/test_scheduler/tests/022_abandoned_coroutines.phpt
new file mode 100644
index 000000000000..c88286253f67
--- /dev/null
+++ b/ext/test_scheduler/tests/022_abandoned_coroutines.phpt
@@ -0,0 +1,32 @@
+--TEST--
+Parked coroutines abandoned by userland are released at request shutdown
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECT--
+parked 1
+parked 2
+parked 3
+end
diff --git a/ext/test_scheduler/tests/023_defer_spawn.phpt b/ext/test_scheduler/tests/023_defer_spawn.phpt
new file mode 100644
index 000000000000..de013c1e4a1c
--- /dev/null
+++ b/ext/test_scheduler/tests/023_defer_spawn.phpt
@@ -0,0 +1,32 @@
+--TEST--
+Microtasks can spawn coroutines and defer further work from a coroutine
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECT--
+microtask done
+spawned from microtask
+deferred from coroutine
+main
diff --git a/ext/test_scheduler/tests/024_resume_main.phpt b/ext/test_scheduler/tests/024_resume_main.phpt
new file mode 100644
index 000000000000..f034b9b5e72e
--- /dev/null
+++ b/ext/test_scheduler/tests/024_resume_main.phpt
@@ -0,0 +1,35 @@
+--TEST--
+A coroutine can deliberately wake the main flow
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+isRunning());
+
+spawn(function () use ($main) {
+ echo "coroutine: waking main\n";
+ resume($main);
+ echo "coroutine: parking\n";
+ suspend();
+ echo "never reached\n";
+});
+
+// Main parks below: the coroutine runs, re-queues main and parks itself;
+// the drain then deliberately wakes main.
+suspend();
+echo "main woke\n";
+
+?>
+--EXPECT--
+bool(true)
+coroutine: waking main
+coroutine: parking
+main woke
diff --git a/ext/test_scheduler/tests/025_gc_cycle.phpt b/ext/test_scheduler/tests/025_gc_cycle.phpt
new file mode 100644
index 000000000000..3f33f2d6fbd8
--- /dev/null
+++ b/ext/test_scheduler/tests/025_gc_cycle.phpt
@@ -0,0 +1,32 @@
+--TEST--
+A reference cycle through a coroutine's result is collected by the GC
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+ $holder);
+$holder->coroutine = $c;
+
+suspend();
+var_dump($c->isFinished(), $c->getResult() === $holder);
+
+// The only remaining references form the cycle
+// coroutine --result--> holder --property--> coroutine.
+unset($c, $holder);
+
+var_dump(gc_collect_cycles() > 0);
+echo "done\n";
+
+?>
+--EXPECT--
+bool(true)
+bool(true)
+bool(true)
+done
diff --git a/ext/test_scheduler/tests/026_destructor_suspend.phpt b/ext/test_scheduler/tests/026_destructor_suspend.phpt
new file mode 100644
index 000000000000..7280a10f59ea
--- /dev/null
+++ b/ext/test_scheduler/tests/026_destructor_suspend.phpt
@@ -0,0 +1,36 @@
+--TEST--
+suspend() inside a destructor: queued coroutines run, the destructor continues
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+
+--EXPECT--
+destructor start
+coroutine ran
+destructor end
+after unset
diff --git a/ext/test_scheduler/tests/027_gc_destructor_cycles.phpt b/ext/test_scheduler/tests/027_gc_destructor_cycles.phpt
new file mode 100644
index 000000000000..edf0b45980c6
--- /dev/null
+++ b/ext/test_scheduler/tests/027_gc_destructor_cycles.phpt
@@ -0,0 +1,52 @@
+--TEST--
+GC-phase destructors run through the gc_destructors hook and may suspend
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+name}\n";
+ suspend();
+ echo "destructor end: {$this->name}\n";
+ }
+}
+
+suspend(); // adopt main so the destructor's suspend has a current coroutine
+
+$a = new Node('A');
+$b = new Node('B');
+$a->ref = $b;
+$b->ref = $a;
+
+unset($a, $b);
+echo "after unset\n";
+
+$intercepts = stats()['gc_intercepts'];
+$collected = gc_collect_cycles();
+
+echo "collected: $collected\n";
+var_dump(stats()['gc_intercepts'] > $intercepts);
+
+?>
+--EXPECT--
+after unset
+destructor start: A
+destructor end: A
+destructor start: B
+destructor end: B
+collected: 2
+bool(true)
diff --git a/ext/test_scheduler/tests/028_destructor_resurrection.phpt b/ext/test_scheduler/tests/028_destructor_resurrection.phpt
new file mode 100644
index 000000000000..03cc1f3dcfb6
--- /dev/null
+++ b/ext/test_scheduler/tests/028_destructor_resurrection.phpt
@@ -0,0 +1,50 @@
+--TEST--
+An object resurrected from a suspending destructor stays usable
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+speak();
+
+$storage = [];
+gc_collect_cycles();
+echo "done\n";
+
+?>
+--EXPECT--
+destructor start
+destructor end
+after unset
+int(1)
+still alive
+done
diff --git a/ext/test_scheduler/tests/029_destructor_spawn.phpt b/ext/test_scheduler/tests/029_destructor_spawn.phpt
new file mode 100644
index 000000000000..96a24a8c06b1
--- /dev/null
+++ b/ext/test_scheduler/tests/029_destructor_spawn.phpt
@@ -0,0 +1,49 @@
+--TEST--
+A destructor running in the GC phase can spawn coroutines
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+ref = $b;
+$b->ref = $a;
+unset($a, $b);
+
+// Spawning during the destructor phase adds new GC roots, so the collected
+// count is indeterminate here; what matters is that the destructors ran
+// inside the GC phase and their coroutines run afterwards.
+gc_collect_cycles();
+echo "after gc\n";
+suspend();
+echo "done\n";
+
+?>
+--EXPECT--
+destructor: spawning
+destructor: spawning
+after gc
+spawned from destructor
+spawned from destructor
+done
diff --git a/ext/test_scheduler/tests/030_destructor_resume_other.phpt b/ext/test_scheduler/tests/030_destructor_resume_other.phpt
new file mode 100644
index 000000000000..b5ccb468d4c0
--- /dev/null
+++ b/ext/test_scheduler/tests/030_destructor_resume_other.phpt
@@ -0,0 +1,46 @@
+--TEST--
+A destructor can resume a parked coroutine and yield to it
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+target);
+ suspend();
+ echo "destructor: back\n";
+ }
+}
+
+$obj = new Waker($parked);
+unset($obj);
+
+var_dump($parked->isFinished());
+
+?>
+--EXPECT--
+parked: waiting
+destructor: resuming
+parked: woken by destructor
+destructor: back
+bool(true)
diff --git a/ext/test_scheduler/tests/031_fiber_then_spawn.phpt b/ext/test_scheduler/tests/031_fiber_then_spawn.phpt
new file mode 100644
index 000000000000..80a10d9d104f
--- /dev/null
+++ b/ext/test_scheduler/tests/031_fiber_then_spawn.phpt
@@ -0,0 +1,33 @@
+--TEST--
+An adopted fiber started before any spawn interleaves with later coroutines
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+start();
+
+spawn(function () {
+ echo "coroutine\n";
+});
+
+// resume() re-queues the fiber behind the already-queued coroutine.
+$fiber->resume();
+echo "main end\n";
+
+?>
+--EXPECT--
+fiber part 1
+coroutine
+fiber part 2
+main end
diff --git a/ext/test_scheduler/tests/032_userland_context.phpt b/ext/test_scheduler/tests/032_userland_context.phpt
new file mode 100644
index 000000000000..a8e490182c22
--- /dev/null
+++ b/ext/test_scheduler/tests/032_userland_context.phpt
@@ -0,0 +1,44 @@
+--TEST--
+Async\get_context(): per-coroutine isolation under the C scheduler
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+set('who', 'main');
+
+$a = spawn(function () {
+ // A fresh coroutine starts with an empty context: no inheritance.
+ var_dump(get_context()->has('who'));
+ get_context()->set('who', 'A');
+ suspend();
+ var_dump(get_context()->find('who'));
+});
+
+suspend();
+
+// Main's value survived the adoption of the main flow as a coroutine.
+var_dump(get_context()->find('who'));
+
+// The explicit-handle form reaches the parked coroutine's store.
+var_dump(get_context($a)->find('who'));
+get_context($a)->set('injected', 'from main');
+
+resume($a);
+suspend();
+
+var_dump(get_context()->find('who'));
+
+?>
+--EXPECT--
+bool(false)
+string(4) "main"
+string(1) "A"
+string(1) "A"
+string(4) "main"
diff --git a/ext/test_scheduler/tests/033_context_c_api.phpt b/ext/test_scheduler/tests/033_context_c_api.phpt
new file mode 100644
index 000000000000..c0d8c0a21df4
--- /dev/null
+++ b/ext/test_scheduler/tests/033_context_c_api.phpt
@@ -0,0 +1,46 @@
+--TEST--
+The C-level zend_async_context_* API shares the store with Async\get_context()
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+ PHP read, PHP write -> C read (the main flow's store).
+var_dump(ctx_set('c-key', ['from', 'C']));
+var_dump(get_context()->find('c-key'));
+
+get_context()->set('php-key', 'from PHP');
+var_dump(ctx_find('php-key'));
+
+var_dump(ctx_unset('php-key'));
+var_dump(ctx_unset('php-key'));
+var_dump(get_context()->has('php-key'));
+
+// The C API takes an explicit coroutine handle too.
+$c = spawn(function () {
+ var_dump(ctx_find('seeded'));
+});
+
+var_dump(ctx_set('seeded', 'before first run', $c));
+suspend();
+
+?>
+--EXPECT--
+bool(true)
+array(2) {
+ [0]=>
+ string(4) "from"
+ [1]=>
+ string(1) "C"
+}
+string(8) "from PHP"
+bool(true)
+bool(false)
+bool(false)
+bool(true)
+string(16) "before first run"
diff --git a/ext/test_scheduler/tests/034_context_lifetime.phpt b/ext/test_scheduler/tests/034_context_lifetime.phpt
new file mode 100644
index 000000000000..942c876adfab
--- /dev/null
+++ b/ext/test_scheduler/tests/034_context_lifetime.phpt
@@ -0,0 +1,60 @@
+--TEST--
+The userland context dies with its coroutine; object keys stay alive while stored
+--EXTENSIONS--
+test_scheduler
+--INI--
+test_scheduler.enable=1
+--FILE--
+set(new KeyProbe(), new ValueProbe());
+ echo "stored\n";
+});
+
+suspend();
+var_dump($c->isFinished());
+
+// The context (and the key/value it holds) dies with the coroutine object.
+unset($c);
+echo "coroutine dropped\n";
+
+// A context can outlive the coroutine when userland keeps a reference.
+$held = null;
+
+$c2 = spawn(function () use (&$held) {
+ $held = get_context()->set('k', 'survives');
+});
+
+suspend();
+unset($c2);
+
+var_dump($held->find('k'));
+
+?>
+--EXPECT--
+stored
+bool(true)
+value released
+key released
+coroutine dropped
+string(8) "survives"
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({