Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 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
d9cce51
async: rename scheduler hooks to on* and align PHP interface; merge e…
EdmondDantes Jul 7, 2026
401ae6e
async: wire onLaunch mandate for PHP schedulers — bridge hands create…
EdmondDantes Jul 8, 2026
77c75ef
async: currentContinuation mandate — capture the running context for …
EdmondDantes Jul 8, 2026
48d14f6
async: factory-based registration — the scheduler is constructed hold…
EdmondDantes Jul 8, 2026
0ba4c42
async: one error channel, switchTo($error), switch handlers and fork API
EdmondDantes Jul 9, 2026
3f42db6
async: bridge onWaitInfo through a wait_info slot
EdmondDantes Jul 9, 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
52 changes: 52 additions & 0 deletions Zend/tests/async/continuation_current_main.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
--TEST--
Async\Continuation: currentContinuation captures main, a continuation switches back into it
--FILE--
<?php
final class CaptureScheduler implements Async\Scheduler {
public function __construct(
public readonly Closure $create, // createContinuation(callable $entry): Continuation
public readonly Closure $capture, // currentContinuation(): Continuation
) {}

public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; }
public function onSuspend(bool $fromMain, bool $isBailout): ?object { return null; }
public function onShutdown(): void {}
public function onWaitInfo(object $coroutine, string $info): void {}
public function onFiber(Fiber $fiber): ?object { return null; }
public function onDefer(callable $task): void {}
public function getContext(object $coroutine): object { return new stdClass(); }
public function getInternalContext(object $coroutine): object { return new stdClass(); }
public function contextFind(object $context, mixed $key): mixed { return null; }
public function contextSet(object $context, mixed $key, mixed $value): void {}
public function contextUnset(object $context, mixed $key): bool { return true; }
}

$sched = null;
Async\SchedulerHook::register('test',
function (callable $create, callable $capture) use (&$sched): CaptureScheduler {
return $sched = new CaptureScheduler($create, $capture);
});

// The main flow becomes addressable like any other coroutine: this is the
// capture that normalisation ("the main flow is a coroutine too") builds on.
$main = ($sched->capture)();
var_dump($main instanceof Async\Continuation);

$c = ($sched->create)(function () use ($main): void {
echo " in continuation\n";
$back = $main->switchTo('back to main'); // symmetric jump into captured main
echo " finishing ($back)\n";
});

var_dump($c->switchTo());
echo "main resumed\n";
$c->switchTo('bye'); // let the body run to completion
echo "done\n";
?>
--EXPECT--
bool(true)
in continuation
string(12) "back to main"
main resumed
finishing (bye)
done
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
52 changes: 52 additions & 0 deletions Zend/tests/async/continuation_switchto_error.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
--TEST--
Async\Continuation: switchTo() delivers $error at the suspension point; boundary cases
--FILE--
<?php
// 1. An error is thrown from the switchTo() the target is suspended in.
$main = Async\Continuation::current();

$worker = Async\Continuation::create(function () use ($main): void {
echo " worker started\n";
try {
$main->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
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
43 changes: 43 additions & 0 deletions Zend/tests/async/fiber_lowlevel_null.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
--TEST--
interceptFiber returning null keeps the fiber on the low-level path
--FILE--
<?php
Async\SchedulerHook::register('test', fn () => new class implements \Async\Scheduler {
public function onShutdown(): void {}
public function onWaitInfo(object $coroutine, string $info): void {}
public function onDefer(callable $task): void {}
public function getContext(object $coroutine): object { return new \stdClass(); }
public function getInternalContext(object $coroutine): object { return new \stdClass(); }
public function contextFind(object $context, mixed $key): mixed { return null; }
public function contextSet(object $context, mixed $key, mixed $value): void {}
public function contextUnset(object $context, mixed $key): bool { return true; }
public function onFiber(Fiber $fiber): ?object {
echo "intercept: null\n";
return null;
}
public function onEnqueue(object $coroutine, ?Throwable $error = null): bool { return true; }
public function onSuspend(bool $fromMain, bool $isBailout): ?object {
// Never called for the low-level fiber itself; the engine invokes it
// for the after-main handover (script end, then after destructors).
echo "suspend(fromMain: ", var_export($fromMain, true), ")\n";
return null;
}
});

// A low-level fiber behaves exactly as classic Fiber even with a scheduler.
$fiber = new Fiber(function (int $x): int {
$y = Fiber::suspend($x + 1);
return $y * 10;
});

var_dump($fiber->start(5));
var_dump($fiber->resume(4));
var_dump($fiber->getReturn());
?>
--EXPECT--
intercept: null
int(6)
NULL
int(40)
suspend(fromMain: true)
suspend(fromMain: true)
54 changes: 54 additions & 0 deletions Zend/tests/async/fiber_managed_after_main.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
--TEST--
After-main handover runs deferred managed coroutines; abandoned ones die cleanly
--FILE--
<?php
Async\SchedulerHook::register('test', fn () => new class implements \Async\Scheduler {
public function onShutdown(): void {}
public function onWaitInfo(object $coroutine, string $info): void {}
public function onDefer(callable $task): void {}
public function getContext(object $coroutine): object { return new \stdClass(); }
public function getInternalContext(object $coroutine): object { return new \stdClass(); }
public function contextFind(object $context, mixed $key): mixed { return null; }
public function contextSet(object $context, mixed $key, mixed $value): void {}
public function contextUnset(object $context, mixed $key): bool { return true; }
public SplQueue $queue;
public function __construct() { $this->queue = new SplQueue(); }
public function onFiber(Fiber $fiber): ?object {
return new class($fiber) {
public function __construct(public readonly Fiber $fiber) {}
};
}
public function onEnqueue(object $coroutine, ?Throwable $error = null): bool {
$this->queue->enqueue($coroutine);
return true;
}
public function onSuspend(bool $fromMain, bool $isBailout): ?object {
// This scheduler defers everything: nothing runs until after main.
if (!$fromMain) {
return null;
}
while (!$this->queue->isEmpty()) {
$fiber = $this->queue->dequeue()->fiber;
$fiber->isStarted() ? $fiber->resume() : $fiber->start();
}
return null;
}
});

$fiber = new Fiber(function (): void {
echo "ran after main\n";
Fiber::suspend();
echo "never printed: nobody resumes\n";
});

// The deferring scheduler does not run the fiber here.
var_dump($fiber->start());
var_dump($fiber->isStarted());

echo "end of script\n";
?>
--EXPECT--
NULL
bool(false)
end of script
ran after main
61 changes: 61 additions & 0 deletions Zend/tests/async/fiber_managed_basic.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
--TEST--
A fiber adopted by the scheduler runs through the coroutine path
--FILE--
<?php
$scheduler = new class implements \Async\Scheduler {
public function onShutdown(): void {}
public function onWaitInfo(object $coroutine, string $info): void {}
public function onDefer(callable $task): void {}
public function getContext(object $coroutine): object { return new \stdClass(); }
public function getInternalContext(object $coroutine): object { return new \stdClass(); }
public function contextFind(object $context, mixed $key): mixed { return null; }
public function contextSet(object $context, mixed $key, mixed $value): void {}
public function contextUnset(object $context, mixed $key): bool { return true; }
public SplQueue $queue;
public array $log = [];
public function __construct() { $this->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
56 changes: 56 additions & 0 deletions Zend/tests/async/fiber_managed_throw.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
--TEST--
Fiber::throw() on a managed fiber delivers the exception at the suspension point
--FILE--
<?php
Async\SchedulerHook::register('test', fn () => new class implements \Async\Scheduler {
public function onShutdown(): void {}
public function onWaitInfo(object $coroutine, string $info): void {}
public function onDefer(callable $task): void {}
public function getContext(object $coroutine): object { return new \stdClass(); }
public function getInternalContext(object $coroutine): object { return new \stdClass(); }
public function contextFind(object $context, mixed $key): mixed { return null; }
public function contextSet(object $context, mixed $key, mixed $value): void {}
public function contextUnset(object $context, mixed $key): bool { return true; }
public SplQueue $queue;
public function __construct() { $this->queue = new SplQueue(); }
public function onFiber(Fiber $fiber): ?object {
return new class($fiber) {
public function __construct(public readonly Fiber $fiber) {}
};
}
public function onEnqueue(object $coroutine, ?Throwable $error = null): bool {
if ($error !== null) {
echo "enqueue hook error: ", get_class($error), "\n";
}
$this->queue->enqueue($coroutine);
return true;
}
public function onSuspend(bool $fromMain, bool $isBailout): ?object {
while (!$this->queue->isEmpty()) {
$fiber = $this->queue->dequeue()->fiber;
$fiber->isStarted() ? $fiber->resume() : $fiber->start();
if (!$fromMain) {
return null;
}
}
return null;
}
});

$fiber = new Fiber(function (): string {
try {
Fiber::suspend('waiting');
} catch (RuntimeException $e) {
return 'caught: ' . $e->getMessage();
}
return 'not reached';
});

var_dump($fiber->start());
$fiber->throw(new RuntimeException('boom'));
var_dump($fiber->getReturn());
?>
--EXPECT--
string(7) "waiting"
enqueue hook error: RuntimeException
string(12) "caught: boom"
Loading