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({