Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
95047c8
async: internal context moves into zend_coroutine_t; context leaves t…
EdmondDantes Jul 10, 2026
f27d70a
async: the internal context dies with its coroutine
EdmondDantes Jul 10, 2026
16f1a76
async: ext/test_scheduler — a reference C scheduler exercising every …
EdmondDantes Jul 10, 2026
1532f0c
async: test_scheduler — GC and destructor edge tests
EdmondDantes Jul 10, 2026
d3a6e51
async: the userland context — Async\Context, get_context() and the C API
EdmondDantes Jul 10, 2026
0a81896
async: the bridge marks main at adoption
EdmondDantes Jul 10, 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
48 changes: 48 additions & 0 deletions Zend/tests/async/context_basic.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
--TEST--
Async\get_context(): the main flow has a context with no scheduler registered
--FILE--
<?php

$context = Async\get_context();
var_dump($context instanceof Async\Context);

// The same store on every call.
var_dump(Async\get_context() === $context);

// String keys: find/has/set/unset, set() chains.
var_dump($context->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)
92 changes: 92 additions & 0 deletions Zend/tests/async/context_main_bridge.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
--TEST--
Async\get_context(): main's store survives adoption by a PHP scheduler
--FILE--
<?php

class Coro
{
public function __construct(
public readonly ?Fiber $fiber = null,
public readonly ?Async\Continuation $continuation = null,
) {
}
}

// Values set before any scheduler exists live in the main flow's store.
Async\get_context()->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)
69 changes: 69 additions & 0 deletions Zend/tests/async/context_per_coroutine.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
--TEST--
Async\get_context(): per-coroutine isolation and the explicit-object form
--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 SplQueue $queue;
public array $coroutines = [];
public function __construct() { $this->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"
14 changes: 14 additions & 0 deletions Zend/tests/async/context_unknown_object.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
--TEST--
Async\get_context(): an object the engine does not know as a coroutine is a ValueError
--FILE--
<?php

try {
Async\get_context(new stdClass());
} catch (ValueError $e) {
echo $e->getMessage(), "\n";
}

?>
--EXPECT--
Async\get_context(): Argument #1 ($coroutine) must be a coroutine object known to the scheduler
47 changes: 47 additions & 0 deletions Zend/tests/async/continuation_current_main.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
--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 {}
}

$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
Loading
Loading