Search by

Revolt event loop adapter for fiber-interop/api: go, await, sleep, readable, writable on Revolt (AMPHP v3, ReactPHP).

Package info

github.com/fiber-interop/revolt

pkg:composer/fiber-interop/revolt

Statistics

Installs: 18

Dependents: 7

Suggesters: 0

Stars: 0

Open Issues: 0

v0.3.2 2026-11-04 20:12 UTC

This package is auto-updated.

Last update: 2026-11-04 20:17:31 UTC


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-owned TimeoutCancellation becomes fiber\TimeoutException. Any other CancelledException (including one from a TimeoutCancellation the awaited future used itself) becomes FiberInterop\Revolt\Cancelled (implements fiber\CancelledException).
  • React\Promise\PromiseInterface and any object with then() (Guzzle, ...): rejection with a non-Throwable becomes fiber\RejectedException.
  • the Task returned by go() also has then(), 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) runs is_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 StreamSelectDriver lets PHP's stream_select() fail (TypeError "supplied resource is not a valid stream resource", or ValueError "No stream arrays were passed" when the dead stream was the only one). The decorating driver catches exactly that from run(), 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() and invokeCallbacks() 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\Future observes it through map()/catch() instead of Future::await().
  • cancel() only ends waits made through a Suspension. 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/queue is wrapped so it runs in the Scope that was current at registration (Scope::run() activates on entry and exit);
  • the Suspension returned by getSuspension() calls activate() right after suspend() returns, i.e. first thing in the resumed fiber;
  • run() calls activate() after the inner run() 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.php passes: two scopes interleaved with fiber\sleep() keep their own $_GET; root is restored.
  • scope-loop.php passes: the same scenario with EventLoop::delay + Suspension, and with Amp\async + Amp\delay, without any fiber\ call. It only passes because of the decorator.
  • scope-raw-fiber.php passes: a plain new Fiber inside a scope reads the creator's $_GET before and after a bare Fiber::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