fiber-interop / revolt
Revolt event loop adapter for fiber-interop/api: go, await, sleep, readable, writable on Revolt (AMPHP v3, ReactPHP).
Requires
- php: ^8.1
- 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)
- amphp/amp: ^3.0
- phpunit/phpunit: ^10.5 || ^11
- react/promise: ^3.0
Suggests
- amphp/amp: await() accepts Amp\Future natively
- react/promise: await() accepts React promises natively
Provides
None
Conflicts
None
Replaces
None
README
Runs the fiber-interop/api functions (fiber\run, go, service, await, await_all,
await_any, sleep, readable, writable, cancel; 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 (fiber\register(...) from Composer files autoload).
Nothing else to configure, unless several adapters are installed, then call fiber\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 |
cancel() |
the task's current wait is settled with the reason: its watcher, timer and subscription are cancelled and $suspension->throw($reason) resumes the task; a later wake-up of that wait is ignored. An Amp\Future wait gets a per-wait DeferredCancellation, cancelled with the reason, which await() rethrows (the future itself keeps running) |
| 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-ownedTimeoutCancellationbecomesfiber\TimeoutException. Any otherCancelledException(including one from aTimeoutCancellationthe awaited future used itself) becomesFiberInterop\Revolt\Cancelled(implementsfiber\CancelledException).React\Promise\PromiseInterfaceand any object withthen()(Guzzle, ...): rejection with a non-Throwable becomesfiber\RejectedException.- the
Taskreturned bygo()also hasthen(), so other adapters can await it.
So Amp\async() fibers can call fiber\sleep(), fiber\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(). cancel()only ends waits made through aSuspension. A task is never waiting in place (its own fiber always suspends), so this covers every wait the api asks it to cancel; a foreign fiber waiting in place inside a task is not that task's wait, and the reason is thrown from the task's next wait instead.- 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
fiber\. 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 withfiber\sleep()keep their own$_GET; root is restored.scope-loop.phppasses: the same scenario withEventLoop::delay+Suspension, and withAmp\async+Amp\delay, without anyfiber\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