Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6e1a5ce
Add the Async Scheduler Hook API: a thin, policy-free concurrency core
EdmondDantes Jul 3, 2026
7a8f154
async: remove gc_destructors PHP-land hook, keep C-only slot
EdmondDantes Jul 6, 2026
7aaaa33
async: drop separate Async API versioning; rely on ZEND_MODULE_API_NO
EdmondDantes Jul 6, 2026
796800e
tests: drop gc_destructors_hook.phpt (hook removed from PHP-land)
EdmondDantes Jul 6, 2026
0b1ba73
async: interface-based scheduler registration (Async\Scheduler + Abst…
EdmondDantes Jul 7, 2026
73eadfe
tests: port async suite to interface-based scheduler registration
EdmondDantes Jul 7, 2026
02b2f3f
async: Async\Coroutine::current()/resume() — engine tracks current fr…
EdmondDantes Jul 7, 2026
daa6f28
async: scheduler declares current coroutine via Async\Coroutine::setC…
EdmondDantes Jul 7, 2026
337e17e
Revert "async: scheduler declares current coroutine via Async\Corouti…
EdmondDantes Jul 7, 2026
1bfc9f4
Reapply "async: scheduler declares current coroutine via Async\Corout…
EdmondDantes Jul 7, 2026
2dbae87
WIP async: suspend hook returns the current coroutine (engine records…
EdmondDantes Jul 7, 2026
f07dd09
tests: async suite for suspend() returning the current coroutine (13/13)
EdmondDantes Jul 7, 2026
6d08811
async: add getContext/getInternalContext + internalContext* to Async\…
EdmondDantes Jul 7, 2026
4761a94
async: drop internalContext find/set/unset from Async\Scheduler (inte…
EdmondDantes Jul 7, 2026
6e141c6
async: remove Async\Coroutine — coroutine is the scheduler's implemen…
EdmondDantes Jul 7, 2026
9b60359
async: keep engine current-coroutine slot (records suspend/launch ret…
EdmondDantes Jul 7, 2026
4b0b520
async: Async\Continuation — symmetric coroutine switch (switchTo/create)
EdmondDantes Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Zend/tests/async/continuation_switch.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
--TEST--
Async\Continuation: switchTo runs the body on its own stack and returns to the switcher
--FILE--
<?php
$a = Async\Continuation::create(function (): void {
echo " A\n";
});

$b = Async\Continuation::create(function (): void {
$sum = 0;
for ($i = 0; $i < 1000; $i++) {
$sum += $i; // real VM work on the continuation's own stack
}
echo " B sum=$sum\n";
});

echo "before\n";
$a->switchTo();
$b->switchTo();
echo "after\n";
?>
--EXPECT--
before
A
B sum=499500
after
25 changes: 25 additions & 0 deletions Zend/tests/async/continuation_symmetric_nested.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
--TEST--
Async\Continuation: a coroutine switches into another mid-run; the inner returns to the outer (symmetric)
--FILE--
<?php
$b = Async\Continuation::create(function (): void {
echo " B: runs to completion\n";
// returns -> 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
22 changes: 22 additions & 0 deletions Zend/tests/async/continuation_value_exception.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
--TEST--
Async\Continuation: switchTo() returns the body's value and re-raises its exception
--FILE--
<?php
var_dump(Async\Continuation::create(fn () => 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
35 changes: 35 additions & 0 deletions Zend/tests/async/fiber_lowlevel_null.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
--TEST--
interceptFiber returning null keeps the fiber on the low-level path
--FILE--
<?php
Async\SchedulerHook::register('test', new class extends Async\AbstractScheduler {
public function interceptFiber(Fiber $fiber): ?object {
echo "intercept: null\n";
return null;
}
public function enqueue(object $coroutine): bool { return true; }
public function suspend(bool $fromMain, bool $isBailout): ?object {
// Never called for the low-level fiber itself; the engine invokes it
// for the after-main handover (script end, then after destructors).
echo "suspend(fromMain: ", var_export($fromMain, true), ")\n";
return 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)
46 changes: 46 additions & 0 deletions Zend/tests/async/fiber_managed_after_main.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
--TEST--
After-main handover runs deferred managed coroutines; abandoned ones die cleanly
--FILE--
<?php
Async\SchedulerHook::register('test', new class extends Async\AbstractScheduler {
public SplQueue $queue;
public function __construct() { $this->queue = new SplQueue(); }
public function interceptFiber(Fiber $fiber): ?object {
return new class($fiber) {
public function __construct(public readonly Fiber $fiber) {}
};
}
public function enqueue(object $coroutine): bool {
$this->queue->enqueue($coroutine);
return true;
}
public function suspend(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
58 changes: 58 additions & 0 deletions Zend/tests/async/fiber_managed_basic.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
--TEST--
A fiber adopted by the scheduler runs through the coroutine path
--FILE--
<?php
$scheduler = new class extends Async\AbstractScheduler {
public SplQueue $queue;
public array $log = [];
public function __construct() { $this->queue = new SplQueue(); }
public function interceptFiber(Fiber $fiber): ?object {
$this->log[] = 'intercept';
return new class($fiber) {
public function __construct(public readonly Fiber $fiber) {}
};
}
public function enqueue(object $coroutine): bool {
$this->log[] = 'enqueue';
$this->queue->enqueue($coroutine);
return true;
}
public function resume(object $coroutine, ?Throwable $error = null): bool {
$this->log[] = 'resume';
$this->queue->enqueue($coroutine);
return true;
}
public function suspend(bool $fromMain, bool $isBailout): ?object {
$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', $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,resume,suspend
50 changes: 50 additions & 0 deletions Zend/tests/async/fiber_managed_throw.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
--TEST--
Fiber::throw() on a managed fiber delivers the exception at the suspension point
--FILE--
<?php
Async\SchedulerHook::register('test', new class extends Async\AbstractScheduler {
public SplQueue $queue;
public function __construct() { $this->queue = new SplQueue(); }
public function interceptFiber(Fiber $fiber): ?object {
return new class($fiber) {
public function __construct(public readonly Fiber $fiber) {}
};
}
public function enqueue(object $coroutine): bool {
$this->queue->enqueue($coroutine);
return true;
}
public function resume(object $coroutine, ?Throwable $error = null): bool {
echo "resume hook error: ", $error === null ? 'none' : get_class($error), "\n";
$this->queue->enqueue($coroutine);
return true;
}
public function suspend(bool $fromMain, bool $isBailout): ?object {
while (!$this->queue->isEmpty()) {
$fiber = $this->queue->dequeue()->fiber;
$fiber->isStarted() ? $fiber->resume() : $fiber->start();
if (!$fromMain) {
return 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"
resume hook error: RuntimeException
string(12) "caught: boom"
43 changes: 43 additions & 0 deletions Zend/tests/async/fiber_managed_uncaught.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
--TEST--
An uncaught exception in a managed fiber surfaces at the start()/resume() caller
--FILE--
<?php
Async\SchedulerHook::register('test', new class extends Async\AbstractScheduler {
public SplQueue $queue;
public function __construct() { $this->queue = new SplQueue(); }
public function interceptFiber(Fiber $fiber): ?object {
return new class($fiber) {
public function __construct(public readonly Fiber $fiber) {}
};
}
public function enqueue(object $coroutine): bool {
$this->queue->enqueue($coroutine);
return true;
}
public function suspend(bool $fromMain, bool $isBailout): ?object {
while (!$this->queue->isEmpty()) {
$fiber = $this->queue->dequeue()->fiber;
$fiber->isStarted() ? $fiber->resume() : $fiber->start();
if (!$fromMain) {
return 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)
44 changes: 44 additions & 0 deletions Zend/tests/async/microtasks_basic.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
--TEST--
Microtasks: defer() forwards to the scheduler's defer() hook; the queue is the scheduler's
--FILE--
<?php
$scheduler = new class extends Async\AbstractScheduler {
public SplQueue $tasks;
public function __construct() { $this->tasks = new SplQueue(); }
public function enqueue(object $coroutine): bool { return true; }
public function suspend(bool $fromMain, bool $isBailout): ?object { return null; }
public function defer(callable $task): bool {
// The queue is owned by the scheduler, not by the engine.
$this->tasks->enqueue($task);
return true;
}
};

Async\SchedulerHook::register('test', $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
Loading
Loading