fiber-interop / amp
Native AMPHP v3 adapter for fiber-interop/api: go() returns an Amp\Future; await, sleep, readable, writable on Amp and Revolt.
Requires
- php: ^8.1
- amphp/amp: ^3.0
- fiber-interop/api: ^0.3
- fiber-interop/scope: ^0.2 || ^0.3 || ^0.4 || ^0.5 || ^0.6
- revolt/event-loop: ^1.0
Requires (Dev)
- phpunit/phpunit: ^10.5 || ^11
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
The native AMPHP v3 adapter for fiber-interop/api. Code written against fiber\go(), fiber\await(),
fiber\sleep(), fiber\readable() and fiber\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 fiber\register(...)); if it is the only
adapter installed, nothing else is needed.
use function fiber\{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 |
cancel($task, $reason) |
the task's current wait is aborted with a per-wait DeferredCancellation (await(Future), sleep() via Amp\delay(cancellation:)) or by settling the Suspension wait (readable()/writable()); the wait throws $reason itself. The abort is kept in a WeakMap keyed by the task's fiber, found through the Future |
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 fiber\TimeoutExceptionAmp\CancelledException implements fiber\CancelledException
(both are marker interfaces in
fiber-interop/api; the dependency can be asuggestor a tinyfiber-interop/contractssplit). Until then the adapter throwsfiber\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 throughfiber\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 callfiber\setup()).cancel()aborts a task's current wait only; it does not stop a task that is running or waiting in place from a foreign fiber (the api then throws the reason at the task's next wait). Amp'sCancellationobjects stay the tool for cancelling work inside the task.
Tests
composer install
vendor/bin/phpunit
php vendor/fiber-interop/api/bin/fiber-interop-conformance tests/bootstrap.php