Adapters
One package per runtime, each certified by the same conformance suite. Sections below are generated from each adapter's README.
phasync fiber-interop/phasync
Runs the fiber-interop/api functions (async\go, await, await_all, await_any, run,
sleep, readable, writable) on the phasync event loop.
composer require fiber-interop/phasync
Installing it is enough: src/register.php calls async\register(); the adapter is picked up on first use.
With several adapters installed, call async\setup(new FiberInterop\Phasync\Adapter()).
Mapping
| api | phasync |
|---|---|
go() |
phasync::go(); the returned Fiber is the awaitable |
service() |
phasync::service(); the coroutine is returned |
await(Fiber) |
phasync::await($fiber, $timeout) |
await(thenable) |
then() callbacks raise a flag (raiseFlag / awaitFlag), first settle wins, a helper coroutine implements the timeout and is cancelled afterwards |
sleep / readable / writable |
phasync::sleep / readable / writable |
Timeout |
phasync's TimeoutException is translated to async\Timeout (phasync's exception is getPrevious()) |
CancelledException |
phasync's CancelledException is translated to FiberInterop\Phasync\Cancelled (implements async\CancelledException) |
phasync's TimeoutException and CancelledException are wrapped only until phasync implements the api interfaces itself; then the translation goes away.
A task that itself fails with a phasync TimeoutException is rethrown unchanged, as is every other exception.
The api guarantees every call happens inside async\run(), whose first steps from {main} are go() and
await() of that task. So there is exactly one phasync::run(), the outermost: go() from {main} returns a
queued Task, and the await() of it wraps the phasync::run() that starts it. Everything else runs in
phasync coroutines, and a task's exception reaches async\run() as phasync delivers it.
service() uses phasync::service() and returns that coroutine. phasync's own run() waits for services;
the api's does not, so once every task has finished the outermost run cancels the services (write them to be
restartable, as the api asks).
A Fiber phasync did not create (started inside run()) cannot be suspended: suspending it would only return
to whoever started it. A wait there runs as a coroutine while the foreign fiber drives phasync's loop in place
(EventLoop::tick()), after which the interrupted coroutine's "current fiber" is restored by reflection
because tick() is written for the root loop. A nested async\run() inside such a fiber works the same way.
Any api call outside run() throws LogicException (the api itself throws NotInRunException first).
Scope state
$_GET, $_POST, $_COOKIE and $_FILES follow the current \Scope (polyfill, see fiber-interop/scope):
the adapter calls fiber_interop\scope\activate() wherever phasync hands control to user code.
| Where | How |
|---|---|
every switch to an api task (async\go()), also to a raw phasync::go() coroutine inside it that has its own context |
FiberInterop\Phasync\ScopeContext implements phasync\Context\SwitchAwareInterface; phasync calls resume() from the loop before a coroutine of that context runs after another context ran. The adapter gives every api task, and the outermost phasync::run(), its own ScopeContext |
after any adapter wait returns (await, sleep, readable, writable, thenables) |
activate() in a finally (fallback; also covers the in-place loop of foreign fibers) |
outermost run() back in {main} |
activate() after phasync::run() returns |
phasync's only switch hook is per context, not global: it fires when the next coroutine's context differs from the one that ran last (and only if the next context is switch-aware). So:
- Coroutines that share one context (every plain
phasync::go()inherits its creator's) are NOT activated on switches among themselves. Give them their own:phasync::go($fn, [], new ScopeContext()). A coroutine in a context that is not switch-aware gets no hook at all; the previous one stays live until the next adapter call. - Raw code that starts
phasync::run()itself from{main}must callScopeContext::activate()after it returns. - While
resume()runs (in the loop, outside any coroutine) the adapter's execution key provider tells\Scopewhich coroutine is about to run.
Probes (php probes/scope-*.php, each prints ok or the mismatch):
scope-api.phppasses: api tasks have their ownScopeContext, and waits activate on return.scope-loop.phppasses written withphasync::go+phasync::sleepand no api, but only because each coroutine is created withnew ScopeContext()andactivate()is called afterphasync::run(). With plainphasync::go()(--plain) it fails: interleaved coroutines of one context see each other's$_GET.scope-raw-fiber.phppasses through the polyfill's "most recently current" fallback.
Limitations
- A local
fclose()of a stream that fibers wait on is detected by phasync itself (phasync\IOException: Stream closed, immediately); the adapter turns that into a normal return ofreadable()/writable()when the resource is gone (other IOExceptions propagate), and wakes the extra waiters on the same stream through the flag chain. - Only tasks started through
async\go()are counted when the outermost run decides it is idle; a barephasync::go()coroutine is still waited for byphasync::run(), as phasync specifies. - phasync allows one reader and one writer per stream; the adapter serialises extra waiters on the same stream and direction (all of them wake when the stream is ready), but only among adapter calls.
- Streams must be non-blocking for
readable/writableto wait. - Requires PHP ^8.2 (phasync's floor).
Tests
The api's stress tier (FIBER_INTEROP_STRESS_ITERATIONS=200 php vendor/fiber-interop/api/bin/fiber-interop-stress tests/bootstrap.php) passes.
vendor/bin/phpunit
vendor/bin/fiber-interop-conformance tests/bootstrap.php
Revolt fiber-interop/revolt
Runs the fiber-interop/api functions (async\run, go, service, await, await_all,
await_any, sleep, readable, writable; every call happens inside run()) on the Revolt event loop, the loop under AMPHP v3
and usable under ReactPHP.
Install
composer require fiber-interop/revolt
Requiring the package registers the adapter (async\register(...) from Composer files autoload).
Nothing else to configure, unless several adapters are installed, then call async\setup().
Mapping
| fiber-interop | Revolt |
|---|---|
go() |
new Fiber started from EventLoop::queue(); returns a Task |
service() |
like go(), but every watcher the service creates is EventLoop::unreference()d, so a sleeping or I/O-waiting service never keeps the loop (or the outermost run()) alive |
| waiting | EventLoop::getSuspension() -> suspend() / resume() |
sleep() |
EventLoop::delay() |
readable/writable |
EventLoop::onReadable/onWritable, cancelled after one firing or on timeout; any number of fibers may wait on one stream |
| timeouts | EventLoop::delay(), cancelled on completion; the awaited task keeps running, a late result is ignored |
| unawaited failure | rethrown from a queued callback, so it reaches EventLoop::setErrorHandler() (uncaught: the loop dies, like any Revolt error) |
Callbacks and task bodies run in the \Scope that was current when they were queued.
AMPHP and ReactPHP users
await() natively accepts, when the classes exist:
Amp\Future: uses$future->await(new TimeoutCancellation($t)); only that adapter-ownedTimeoutCancellationbecomesasync\TimeoutException. Any otherCancelledException(including one from aTimeoutCancellationthe awaited future used itself) becomesFiberInterop\Revolt\Cancelled(implementsasync\CancelledException).React\Promise\PromiseInterfaceand any object withthen()(Guzzle, ...): rejection with a non-Throwable becomesasync\RejectedException.- the
Taskreturned bygo()also hasthen(), so other adapters can await it.
So Amp\async() fibers can call async\sleep(), async\await() etc. directly, and AMPHP v3 code and
fiber-interop code interleave on the same loop. For ReactPHP, run React on Revolt (react/event-loop
with the Revolt-backed loop) so both share one driver.
Semantics: closing a stream
fclose() (or destroying the resource) while fibers wait in readable()/writable() wakes them within ~20 ms and
the waits return normally; the next operation on the closed resource fails. Two parts:
- A timer: while any stream wait is pending, an unreferenced
EventLoop::repeat()(20 ms) runsis_resource()on each waited stream; for a gone one it cancels the watcher (errors ignored) and resumes its waiters. No timer exists when no stream wait is pending. This bounds the wake-up at 20 ms (and covers the ev/uv drivers, which never raise). - Recovery: if a stream is closed inside a callback and the loop selects before the timer fires, Revolt's
StreamSelectDriverlets PHP'sstream_select()fail (TypeError"supplied resource is not a valid stream resource", orValueError"No stream arrays were passed" when the dead stream was the only one). The decorating driver catches exactly that fromrun(), from {main}'s suspension and from the in-place drivers, sweeps once, and runs the loop again.
No work is done per callback, per suspend or per loop iteration.
Limitations: the in-place rule
A wait is done through a Revolt Suspension when the caller is the outermost run()'s {main}, a fiber created by go(), or a
Revolt callback fiber (EventLoop::queue(), Amp\async(), defer, watcher callbacks).
Any other fiber (one created by user code that nobody's loop owns) must not be suspended. Revolt's
Suspension::suspend() would call Fiber::suspend() on it, and EventLoop::run() throws inside any fiber.
The adapter therefore drives the loop in place: it resumes the driver's loop fiber directly (through the
closure DriverSuspension itself uses for {main}, read by reflection from a private property, so this is tied to
Revolt 1.x internals) and returns when the awaited thing completes, without suspending the foreign fiber.
- If the Revolt loop is currently executing (the foreign fiber was started from inside a task or callback, so
Revolt's loop fiber is an ancestor on the stack and cannot be resumed re-entrantly), the adapter instead runs
the driver's own
tick()andinvokeCallbacks()by hand from inside the foreign fiber, on a fresh callback fiber (also via reflection). Other tasks keep running while the foreign fiber waits. - Nested in-place waits across two foreign fibers are not supported.
- In-place waiting on an
Amp\Futureobserves it throughmap()/catch()instead ofFuture::await(). - Waiting with nothing scheduled in place throws
LogicException('Deadlock ...'). In {main}, Revolt itself throws.
Scope state
Per-scope superglobals ($_GET, $_POST, $_COOKIE, $_FILES) follow fiber-interop/scope: the adapter
calls fiber_interop\scope\activate() (lazy, a swap only on a real scope change) whenever the loop hands
control to user code. The call sites live in FiberInterop\Revolt\ActivatingDriver, a decorator around the
Revolt driver installed with EventLoop::setDriver() when the package is autoloaded (and again, idempotently,
from the Adapter constructor; it cannot be installed while the loop is running):
- every callback registered through
defer/delay/repeat/onReadable/onWritable/onSignal/queueis wrapped so it runs in theScopethat was current at registration (Scope::run()activates on entry and exit); - the
Suspensionreturned bygetSuspension()callsactivate()right aftersuspend()returns, i.e. first thing in the resumed fiber; run()callsactivate()after the innerrun()returns, so {main} sees its own scope again;- the in-place wait (foreign fibers) calls
activate()when it returns inside the foreign fiber.
Because this sits in the driver, code that uses Revolt or Amp directly is covered, not only code going through
async\. The in-place driving (driveNested) still uses reflection on AbstractDriver; the decorator cannot
replace it because the work is calling the driver's private tick()/invokeCallbacks(), so it stays as is.
Probes (php probes/<name>.php, each prints ok):
scope-api.phppasses: two scopes interleaved withasync\sleep()keep their own$_GET; root is restored.scope-loop.phppasses: the same scenario withEventLoop::delay+Suspension, and withAmp\async+Amp\delay, without anyasync\call. It only passes because of the decorator.scope-raw-fiber.phppasses: a plainnew Fiberinside a scope reads the creator's$_GETbefore and after a bareFiber::suspend(). This is the polyfill's "most recently current" fallback, not something the adapter does.
Callbacks registered before the package was loaded (or on a driver replaced later with EventLoop::setDriver())
are not wrapped.
Tests
composer install
vendor/bin/phpunit
vendor/bin/fiber-interop-conformance tests/bootstrap.php
AMPHP fiber-interop/amp
The native AMPHP v3 adapter for fiber-interop/api. Code written against async\go(), async\await(),
async\sleep(), async\readable() and async\writable() runs on Amp's own primitives, and the awaitable go()
returns is a real Amp\Future.
composer require fiber-interop/amp
Installing the package registers the adapter (Composer files autoload calls async\register(...)); if it is the only
adapter installed, nothing else is needed.
use function async\{run, go, await, sleep};
// run() is the only entry point: structured, it returns when the tasks started inside it are done
echo run(function () {
$a = go(function () { sleep(0.1); return 1; }); // Amp\Future
$b = \Amp\async(fn() => 2); // also awaitable by the api
echo $a->await(); // 1, Amp's own await works on api tasks
return await($a) + await($b); // 3
});
The api functions throw NotInRunException outside run().
Mapping
| api | Amp |
|---|---|
go($f) |
Amp\async($f), returns the Amp\Future |
service($f) |
Amp\async($f) too. The api's run() does not wait for services, so a sleeping service does not hold run() open (no unreference() needed) |
await(Future, $t) |
Future::await(new TimeoutCancellation($t)), exceptions translated (below) |
await(then-able) |
then() callbacks, suspended through Revolt |
sleep($s) |
Amp\delay($s) |
readable() / writable() |
EventLoop::onReadable/onWritable + Suspension, one watcher per wait, timeout via EventLoop::delay |
canAwait($o) |
Amp\Future or any object with then() |
| unawaited failure | Amp's own: an unobserved failed Future reports to the Revolt error handler when it is destroyed |
Timeouts leave the task running (the TimeoutCancellation only cancels the wait). Callbacks queued by the adapter
(timers, IO watchers) run in the \Scope they were queued from; task bodies run in the scope captured by go()
(done by the api).
In-place rule
Future::await() and Amp\delay() suspend the current fiber through Revolt, which is only correct in {main} (the
outermost run()'s wait), in a fiber started by Amp/Revolt, or in a Revolt callback. When await(), sleep() etc. are
called from a fiber that belongs to somebody else (a plain new Fiber, another library's scheduler), the adapter never
suspends it: it observes the future with map()/catch() and drives an event loop in place from inside that fiber until
the wait is done. Which case applies is decided by debug_backtrace() (is the first of "Revolt driver frame" /
"Fiber::start/resume" below us the driver?).
A foreign fiber started from inside a task (the usual case: inside run()) cannot resume the running Revolt loop: the
loop's own fiber and the current callback fiber are ancestors on the stack. For that case the adapter runs the same
driver's tick() and invokeCallbacks() by hand from the foreign fiber (reflection on AbstractDriver, on a fresh
callback fiber), exactly as fiber-interop/revolt does. Other tasks keep running meanwhile, and the foreign fiber can
await any outer-loop future, including a raw Amp\async() one.
What AMPHP would own
-
src/Adapter.php(about 200 lines) andsrc/register.php. -
Proposed one-line changes to
amphp/ampso that the translation disappears:Amp\TimeoutException implements async\TimeoutExceptionAmp\CancelledException implements async\CancelledException
(both are marker interfaces in
fiber-interop/api; the dependency can be asuggestor a tinyfiber-interop/contractssplit). Until then the adapter throwsasync\Timeout(previous: theAmp\CancelledException) when its own timeout fires, andFiberInterop\Amp\Cancelled(previous: the Amp exception) when the awaited future itself failed with anAmp\CancelledException, including its ownTimeoutCancellation. Every other exception is rethrown unchanged. -
The only Revolt internals used:
Revolt\EventLoop\Internal\DriverSuspension::$runandAbstractDriverinternalstick(),invokeCallbacks(),createCallbackFiber()(reflection, to drive the loop in place from a foreign fiber; a public re-entrant "run until" in Revolt would remove them) and theRevolt\EventLoop\Internal\frame prefix in the backtrace check. A public "run the loop from this fiber" in Revolt would remove both.
Scope state
$_GET, $_POST, $_COOKIE and $_FILES are per \Scope (see fiber-interop/scope). The loop must call fiber_interop\scope\activate() whenever it hands control to user code. Revolt has no hook for that, so the adapter's constructor installs a decorating driver (EventLoop::setDriver(), once; skipped if a scope-activating decorator, this one or another adapter's, is already installed, or if the loop is already running). It wraps every callback registered through defer/delay/repeat/onReadable/onWritable/onSignal/queue as $scope->run($cb, ...) with the \Scope current at registration (so callbacks, including an Amp\async() task's start, run in the scope they were registered from), makes Suspension::suspend() call activate() when it returns, and calls activate() when run() returns. Amp's async(), Future::await() and delay() all go through EventLoop, so they are covered. activate() is one comparison unless the scope really changed. The in-place path (a foreign fiber driving the loop) calls it when its wait ends.
Probes (php probes/<name>.php, exit 0 and print ok):
scope-api.phppasses: two scopes interleave throughasync\sleep(), each keeps its own$_GET, the root value is back afterrun().scope-loop.phppasses: the same withAmp\async+Amp\delayonly. The scope is entered aroundAmp\async(); the driver runs the task in it and keeps it active acrossdelay(). Both of these fail with the decorator removed.scope-raw-fiber.phppasses, but not because of this adapter: a plainnew Fiberhas no scope of its own and sees the creator's through the polyfill's "most recently current" fallback.
Caveats: callbacks registered before the adapter was constructed are not wrapped;
Semantics: closing a stream
fclose() (or destroying the resource) while fibers wait in readable()/writable() wakes them within ~20 ms and
the waits return normally; the next operation on the closed resource fails. Two parts:
- A timer: while any stream wait is pending, an unreferenced
EventLoop::repeat()(20 ms) runsis_resource()on each waited stream; for a gone one it cancels the watcher (errors ignored) and resumes its waiters. No timer exists when no stream wait is pending. This bounds the wake-up at 20 ms (and covers the ev/uv drivers, which never raise). - Recovery: if a stream is closed inside a callback and the loop selects before the timer fires, Revolt's
StreamSelectDriverlets PHP'sstream_select()fail (TypeError"supplied resource is not a valid stream resource", orValueError"No stream arrays were passed" when the dead stream was the only one). The decorating driver catches exactly that fromrun(), from {main}'s suspension and from the in-place drivers, sweeps once, and runs the loop again.
No work is done per callback, per suspend or per loop iteration.
Limitations
go()'s awaitable is anAmp\Future, which has nothen(): other adapters (Revolt, ReactPHP) can only await it if theircanAwait()knowsAmp\Future(the fiber-interop/revolt adapter does).fiber-interop/revoltand this package both drive Revolt; install only one adapter (or callasync\setup()).- Nothing here cancels a running task; Amp's
Cancellationobjects stay the tool for that, inside the task.
Tests
composer install
vendor/bin/phpunit
php vendor/fiber-interop/api/bin/fiber-interop-conformance tests/bootstrap.php
ReactPHP fiber-interop/react
Native ReactPHP adapter for fiber-interop/api: run, go, service, await, sleep, readable, writable (all called inside run()) on
react/event-loop ^1.5, react/promise ^3 and react/async ^4 (PHP 8.1+).
composer require fiber-interop/react
Requiring the package registers the adapter (src/register.php); nothing else to configure.
api -> React mapping
| api | React |
|---|---|
service($f) |
same as go(). React's await() from {main} calls Loop::stop() the moment the awaited promise settles, so a sleeping service (pending timer or stream listener) never keeps the outermost run() alive; no unreference is needed. Leftover watchers stay registered and fire if the loop runs again. |
go($f) |
React\Async\async($f)(); returns a real React\Promise\PromiseInterface (scope captured at call time) |
await($o) |
React\Async\await() in {main} or in an async() fiber; promises and plain then-ables accepted |
sleep($s) |
Loop::addTimer() + suspension |
readable() / writable() |
Loop::addReadStream() / addWriteStream(), one listener per stream shared by all waiters |
| timeout | Loop::addTimer() racing the awaited thing; async\Timeout, the awaited promise is left alone, late result ignored |
| non-Throwable rejection | async\RejectedException (plain then-ables; React v3 itself rejects with TypeError) |
| unawaited failure | React's own unhandled-rejection reporting (React\Promise\set_rejection_handler) |
await() decides how to wait by walking debug_backtrace() (it spans fibers): a fiber entered through
react/async is React's and suspends through React\Async\await(); any other fiber is foreign.
In-place rule
From a foreign fiber (one started with new Fiber), React\Async\await() would Fiber::suspend() a fiber React
does not own. The adapter instead runs Loop::run() inside that fiber until the awaited thing settles
(Loop::stop() from the settle callback). If the foreign fiber was started from inside a running loop callback the
loop cannot be re-entered; a LogicException is thrown.
Scope state
The adapter keeps the per-scope superglobals of fiber-interop/scope correct by calling
\fiber_interop\scope\activate() (lazy; swaps only on a real scope change) wherever control reaches user code:
- Decorating loop. The adapter constructor installs
FiberInterop\React\ActivatingLoopwithLoop::set()(once; never double-wrapped). It wraps every callback given toaddTimer,addPeriodicTimer,addReadStream,addWriteStream,addSignalandfutureTickasactivate(); $cb(...), andrun()activates after the innerrun()returns. Watchers registered before the adapter was created are not wrapped. - Adapter waits.
activate()runs at the start of everygo()task, after each suspension returns in the resumed fiber (sleep,await,readable,writable), and after an in-placeLoop::run()returns.
Probes (php probes/scope-*.php; exit 0 and print ok, or print the mismatch and exit 1):
scope-api.phppasses: everything goes through the adapter's waits.scope-raw-fiber.phppasses: a plainFiberresumed by its creator sees the creator's$_GET(polyfill fallback).scope-loop.phpfails, by design of React: written withReact\Async\async+React\Async\await+Loop::addTimeronly. After the timer fires,await()resumes the fiber with$fiber->resume()from a scheduler tick, and nothing runs in the target fiber between that resume and the user's next line, so no hook can activate the fiber's scope; the code reads the active (root) scope's$_GET(nullforreqin the probe) instead of its own. Observed output:a after sleep 1 saw null,a after sleep 2 saw null,b after sleep 1 saw null,b after sleep 2 saw null.
Precise ask to ReactPHP: a resume hook (a callback run in the target fiber, or around $fiber->resume(), in
react/async SimpleFiber/await()), or routing await() through a suspension object that user-visible code returns
from, as Revolt's Suspension::suspend() does. Until then, code that waits with raw React\Async\await() and then
reads superglobals may see the previously active scope's values; waiting through async\sleep()/async\await() is safe.
What the ReactPHP maintainers would own
src/Adapter.php (about 200 lines), src/ActivatingLoop.php and src/register.php. No React internals are used besides FiberMap's
location, used to recognise fibers made by async().
The ask to React
A re-entrant, foreign-fiber-safe "run until settled". Concretely, in react/async SimpleFiber::suspend()
(src/SimpleFiber.php) and await() (src/functions.php): when Fiber::getCurrent() is a fiber React did not create,
suspend() calls Fiber::suspend() and parks a fiber nobody will resume. A public
Loop::runUntil(PromiseInterface|callable $done) (or Loop::isRunning() plus a stop token that does not end an
outer run()) would let the adapter drop backtrace inspection and make Loop::stop() safe when nested.
Limitations
- In-place waiting from a foreign fiber shares
Loop::stop()with any suspended{main}scheduler thatReact\Async\await()created earlier in the same process: the nested stop ends that scheduler'sLoop::run(). Mix the two styles in one process only if foreign fibers are not used after a{main}await (the tests for the in-place path run in separate processes for this reason). - A local
fclose()of a stream that fibers wait on wakes the waiters within ~20 ms (the next operation on the closed resource fails). A periodic timer (20 ms) runsis_resource()on the watched streams; it exists only while stream waits are pending. If the stream is closed inside a callback andStreamSelectLoopselects before the timer fires,stream_select()fails withTypeError("supplied resource is not a valid stream resource", orValueError"No stream arrays were passed" when it was the only stream);ActivatingLoop::run()catches exactly that, removes the dead watchers viaremoveReadStream()/removeWriteStream()(which still key by(int)$streamafter close), wakes the waiters and runs the loop again. No work is done per callback. - No cancellation of
go()tasks beyond whatReact\Promisecancel()gives. - Foreign fibers started inside a loop callback cannot wait (see above).
Stress tier
The api's stress tier (vendor/fiber-interop/api/bin/fiber-interop-stress tests/bootstrap.php) passes
5 of 6 tests here. The failing one, FrameMembershipTest::testForeignFiberChildIsNeverOrphaned,
starts a plain new Fiber inside a task and calls async\sleep() from it while the React loop is
running. React's loop cannot be re-entered from inside a callback, so this adapter throws a clear
LogicException ("cannot wait in place from a fiber nobody owns while the React loop is running")
rather than hang. That is the same limitation as the in-place rule above, seen from the other side,
and the same ask to React: a re-entrant "run until settled". The other five tests pass at 200
iterations.
Tests
vendor/bin/phpunit
php vendor/fiber-interop/api/bin/fiber-interop-conformance tests/bootstrap.php
Swoole fiber-interop/swoole
Adapter that runs the fiber-interop/api functions (async\go, await, await_all, await_any,
run, sleep, readable, writable) on Swoole coroutines, so a library written against the api runs
unchanged for Swoole users.
Install
composer require fiber-interop/swoole
Requires ext-swoole 6.x (developed against 6.3.0RC1 on PHP 8.5 NTS) or ext-openswoole (tested 26.2.0). Composer cannot
express "either extension", so neither is hard-required; src/register.php registers the adapter only when
one of them is loaded. Tests must run with the extension loaded, e.g.
PHP_INI_SCAN_DIR=:/path/to/dir-with-swoole.ini vendor/bin/phpunit (the conformance runner spawns plain
php processes, so -d extension= is not enough; use the ini scan dir). Building from PECL source works on
PHP 8.5 (phpize && ./configure && make).
Mapping
| api | Swoole |
|---|---|
go($f) |
Coroutine::create(); returns a Task (result/exception stored, then() for other loops). Body runs in the captured Scope. |
await(Task) |
per-waiter Channel(1) fed when the task settles; pop($timeout) false -> async\Timeout, the task keeps running. Repeatable: the outcome is stored on the Task, never consumed. |
await(thenable) |
then() + channel, $done guard, non-Throwable rejection -> async\RejectedException, exceptions rethrown unchanged |
sleep() |
Coroutine::sleep() (clamped to Swoole's 1 ms minimum) |
readable()/writable() |
System::waitEvent(); it allows one waiter per fd and mode (a second gets EEXIST), so the adapter runs one poller coroutine per fd+mode and fans out to per-waiter channels; the poller is cancelled when the last waiter times out |
service($f) |
Coroutine::create() tracked as a service; cancelled with Coroutine::cancel() when the outermost run() returns |
| unawaited failure | after one tick without an awaiter the exception is rethrown in the coroutine; Swoole reports it as a fatal error (stderr, non-zero exit) |
run(), the scheduler and services
Every adapter call happens inside an api run(); the outermost run() starts with go() then await() from
{main}, and that first await() is where the adapter wraps the single Swoole\Coroutine\run() (the outcome is
carried out as a value, so a Timeout does not escape the scheduler). Co\run() returns only when every
coroutine has ended, so the adapter ends it itself once the work is done. Nested and concurrent run()s need no
special handling: the api keeps its frames per coroutine through \async\Registry::$executionKey, which the
adapter sets to the same provider it gives the Scope polyfill (see Scope).
service($f) tasks are cancelled when the outermost run() returns (allowed by the spec: runtime-specific,
services are restartable). The adapter counts the running go() tasks; when the last one ends inside its
Co\run() wrapper it calls Coroutine::cancel() on every service coroutine. Cancellation surfaces in the
adapter's waits (Coroutine::sleep, Channel::pop, which the stream waits and awaits use) as an exception
implementing async\CancelledException (FiberInterop\Swoole\Cancelled); the service coroutine swallows it and
ends cleanly, so Co\run() returns without warnings. A service that catches the exception and keeps going
would keep Co\run() open. A service that fails with nobody awaiting it is reported by Swoole as a fatal error
(stderr, non-zero exit); before rethrowing, the adapter closes every channel with a blocked consumer so Swoole
prints no Channel::~Channel() ... consumers will be discarded warning on stdout.
- the CLI's default hook flags include curl hooks; if ext-curl is absent Swoole warns on stdout, so the
adapter removes the curl flags from
hook_flagsin that case.
Fibers
Inside a coroutine, a PHP Fiber works with Swoole's API (the coroutine suspends). A Fiber started from {main}
sees no coroutine, so the wait wraps Co\run() in place (example 15 passes). Fibers are not a unit of
scheduling here; Fiber::suspend() is not involved.
Scope
Fiber::getCurrent() is null in every coroutine, so the userland Scope polyfill would share one {main} stack
between coroutines. The adapter registers an execution key provider with the polyfill:
\fiber_interop\scope\set_execution_key_provider(fn() => Coroutine::getCid() > 0 ? Coroutine::getContext() : null);
so every coroutine gets its own Scope stack, keyed on its Coroutine::getContext() (the same object for the
coroutine's whole life; the WeakMap entry vanishes when the coroutine ends). This covers raw Swoole switches too
(Channel, Co::sleep directly), not only the adapter's waits. go() propagates the creator's scope into the
task through $scope->run(). Examples 13 and 14 pass.
With the ext-scope C extension the provider function is a no-op, and the extension cannot see coroutine
switches (they are not fiber switches): use the polyfill with Swoole.
Scope state
$_GET, $_POST, $_COOKIE and $_FILES are per scope (scope 0.2). The polyfill swaps the live superglobals
when fiber_interop\scope\activate() is called. Swoole has no userland coroutine-switch hook, so the adapter
calls it at the points where it regains control: first thing in every coroutine it starts (go()/service()),
after every wait returns (Adapter::pop(), which await, sleep, readable and writable all end in), and after the
outermost run() returns to {main}. It is a single comparison when the scope is unchanged.
Probes (php probes/<name>.php, exit 0 and ok on success; same results on Swoole 6.3 and OpenSwoole 26.2):
scope-api.phppasses: two scopes interleaving throughasync\sleep()keep their own$_GET; the root value is back afterrun().scope-raw-fiber.phppasses: a plainFiberinside a scope sees its creator's$_GET(the polyfill's "most recently current" fallback).scope-loop.phpFAILS, as expected: withCoroutine::create+ a native sleep (noasync\call), a coroutine that Swoole itself resumes sees the superglobals of whatever scope was active last (here the root,[]), not its own.
What was checked for a hook: Coroutine::set() options, Coroutine::defer() (runs at coroutine exit only) and the
Coroutine method list on both extensions. The C++ core has Coroutine::set_on_yield/set_on_resume, but they are
C-level and not exposed to PHP, so no PHP code can run on a switch Swoole makes itself. Swoole applications use
Co::getContext() for per-request state for this reason; keep doing that for code that waits on native primitives.
Closing the gap needs engine-level support (the native ext-scope), which must then follow coroutine switches too.
OpenSwoole
Tested with OpenSwoole 26.2.0 (built from PECL on PHP 8.5.11 NTS): phpunit (11 tests) and all 21 conformance examples pass, same as on Swoole 6.3.0RC1. One code path serves both; the differences found:
- OpenSwoole aliases
Swoole\Coroutine,Coroutine\Channel,Coroutine\Systemand theSWOOLE_*constants, so the class names need no mapping. It has noCo\run()/Swoole\Coroutine\run()function and noswoole_strerror(). Coroutine::sleep()takes whole seconds only (a float is aTypeError). The adapter sleeps with a timedChannel::pop()instead.Coroutine::run()refuses to start (and segfaults in 26.2.0) when a coroutine was already created outside one, whichgo()before the firstawait()does. On OpenSwoole the adapter therefore starts its loop withCoroutine::create()+Event::wait(), settinghook_flagstoSWOOLE_HOOK_ALLasrun()would. The runner is picked once in the constructor.System::waitEventbehaves the same (stream examples 11 and 12 pass).
Select the extension with PHP_INI_SCAN_DIR=:<repo>/build/ini (Swoole) or :<repo>/build/ini-openswoole; never load both.
Known upstream issues
- A local
fclose()of a stream that fibers wait on is noticed by the poller within one 10 ms slice (it checksis_resource()before everywaitEvent()); all waiters return normally andwaitEvent()is never called on a dead resource. The poller waits on a dup of the fd (php://fd/N, fd found in/proc/self/fdby inode on Linux), because closing the original under a pendingwaitEvent()makes the reactor logfailed to delete events ... Bad file descriptorwhen the slice ends and there is no API to cancel a pendingwaitEvent()safely (see below). Consequence: the peer sees EOF up to 10 ms later than thefclose(); off Linux the warning can appear.
Swoole 6.3.0RC1: Coroutine::cancel() of a coroutine blocked in System::waitEvent() segfaults when the
event becomes ready in the same loop tick. gdb: SIGSEGV in swoole::PHPCoroutine::fiber_context_switch_try_notify(from=main_context, to=0x8000000000000000)
<- on_resume <- Coroutine::resume <- CallbackManager::execute <- Reactor::execute_end_callbacks: a deferred
resume runs on a stale coroutine. Found by the api stress tier (ReadinessTest::testReadersContendingOnOneStream,
exit 11 at 100+ iterations); OpenSwoole 26.2.0 is not affected. Standalone reproducer, no adapter code, crashes
within seconds: php tests/upstream/swoole-cancel-waitevent-segfault.php [seconds] (exit 139 = reproduced).
The adapter never cancels a stream poller. The poller polls waitEvent() in 50 ms slices, a late waiter joins
the running poller, and the poller exits by itself at the first slice boundary with no waiters (it holds the
resource, so the resource id cannot be reused meanwhile). The cost is an idle poller living up to 50 ms after the
last waiter left.
OpenSwoole: Timer::add() (ERRNO 505): msec value[0] is invalid for sub-millisecond timeouts. Swoole clamps
these; the adapter now clamps every channel timeout and sleep to 1 ms itself on both extensions.
Tests
vendor/bin/phpunit (with the extension loaded): ConformanceTest runs the api examples (all 21 examples), AdapterTest covers repeatable awaits, concurrent awaiters, timeouts, stream multiplexing,
service cancellation, nested run() in two coroutines and per-coroutine scope.