fiber-interop

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

Repository

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:

Probes (php probes/scope-*.php, each prints ok or the mismatch):

Limitations

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

Repository

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:

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:

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.

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):

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):

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

Repository

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

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):

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:

No work is done per callback, per suspend or per loop iteration.

Limitations

Tests

composer install
vendor/bin/phpunit
php vendor/fiber-interop/api/bin/fiber-interop-conformance tests/bootstrap.php

ReactPHP fiber-interop/react

Repository

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:

Probes (php probes/scope-*.php; exit 0 and print ok, or print the mismatch and exit 1):

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

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

Repository

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.

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):

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:

Select the extension with PHP_INI_SCAN_DIR=:<repo>/build/ini (Swoole) or :<repo>/build/ini-openswoole; never load both.

Known upstream issues

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.