fiber-interop

Generated from fiber-interop/api README.md, the single source of truth.

fiber-interop/api

One surface for fiber-based concurrency. Write a library once against async\go(), async\await() and friends; it runs unchanged on phasync, Revolt (and therefore AMPHP and ReactPHP), and natively on AMPHP and ReactPHP. PHP 8.1+.

composer require fiber-interop/api fiber-interop/revolt   # or /phasync, /amp, /react
use function async\{run, go, await, await_all, sleep, readable};

run(function () use ($socket) {
    $a = go(fn() => strtoupper('a'));
    $b = go(function () { sleep(0.5); return 'b'; });
    [$ra, $rb] = await_all([$a, $b], timeout: 2.0);

    $line = fgets(readable($socket));
});   // returns when everything started inside it has finished

The global names (\go() and so on) are not taken; everything lives in async\.

Functions

namespace async;

function go(callable $f, mixed ...$args): object;
function await(object $awaitable, float $timeout = \PHP_FLOAT_MAX): mixed;
function await_all(array $awaitables, float $timeout = \PHP_FLOAT_MAX): array;
function await_any(array $awaitables, float $timeout = \PHP_FLOAT_MAX): mixed;
function run(callable $f, mixed ...$args): mixed;
function service(callable $f, mixed ...$args): object;
function sleep(float $seconds): void;
function readable(mixed $resource, float $timeout = \PHP_FLOAT_MAX): mixed;
function writable(mixed $resource, float $timeout = \PHP_FLOAT_MAX): mixed;
function setup(Adapter $adapter): void;
function adapter(): Adapter;

Why run() is structured: a task only makes progress while something drives the event loop, and run() is the one construct that guarantees the loop is driven for exactly as long as the code inside it, and everything that code started, needs it. Making that the rule puts the lifetime of concurrent work in the source code: a library can use concurrency internally, wrapped in its own run(), without the application running a loop or even knowing one exists. It also removes every {main} special case from adapters: there are no waits outside run(), nothing is left pending when run() returns, and a failure nobody awaited has exactly one place to go, the run() that started it.

Rules every adapter honours (each has a conformance example):

Scope

\Scope (from fiber-interop/scope, the native extension plus a polyfill) is the identity of the operation (request, job) that code belongs to. go() captures \Scope::getCurrent() and runs the task inside $scope->run(...); adapters run timer and IO callbacks in the scope they were queued from. The root scope is a process-wide singleton, so "am I inside an operation" is Scope::getCurrent() !== Scope::getRootScope(). To start something that outlives every request:

$poller = \Scope::getRootScope()->run(fn() => async\go(function () { /* service */ }));

The api never names Scope in its own signatures, and gives no scope functions of its own: Scope is the global \Scope, not something in async\.

Superglobals. $_GET, $_POST, $_COOKIE and $_FILES are per scope: a new scope starts with copies of its creator's, and assignments inside a scope stay inside it. Adapters keep this true by calling fiber_interop\scope\activate() whenever the loop hands control to user code (see Per-scope superglobals).

Choosing an adapter

Adapter packages register themselves as candidates from their Composer files autoload; nothing is instantiated until the first api call:

// src/register.php, listed under "autoload": {"files": [...]}
if (class_exists(React\EventLoop\Loop::class)) async\register(fn() => new FiberInterop\React\Adapter(), 'fiber-interop/react');

On first use an explicit async\setup() wins; otherwise a single candidate is built and used; two or more throw NotConfiguredException naming them. So composer require react/event-loop fiber-interop/react needs no code. With none installed, the first call throws async\NotConfiguredException listing the adapter packages. To pick one explicitly, call async\setup(new SomeAdapter()) early. setup() with an adapter of the class already set is a no-op; one of another class throws \LogicException.

Writing an adapter

Implement async\Adapter:

interface Adapter {
    public function go(\Closure $f): object;         // schedule $f; return your awaitable
    public function service(\Closure $f): object;    // like go(), but must not keep the loop alive
    public function canAwait(object $o): bool;       // true for your own awaitables
    public function await(object $o, float $timeout): mixed;  // also handle then-ables
    public function sleep(float $seconds): void;
    public function readable(mixed $resource, float $timeout): void;
    public function writable(mixed $resource, float $timeout): void;
}

tests/Support/FiberAdapter.php in this repository is a complete adapter on raw Fibers and stream_select in about 250 lines; read it as the reference.

Conformance

examples/ holds one runnable file per function and rule; each prints deterministic output that must match its .expected file under every adapter. Certify an adapter with a bootstrap that loads the autoloader and registers it:

<?php // conformance-bootstrap.php
require __DIR__ . '/vendor/autoload.php';
async\setup(new My\Adapter());
vendor/bin/fiber-interop-conformance conformance-bootstrap.php          # all examples
vendor/bin/fiber-interop-conformance conformance-bootstrap.php 08-await-thenable

Exit status 0 means identical output. The runner appends error reported: yes|no to each example's output (yes when the example wrote to stderr or exited non-zero), which is how the unhandled-exception rule is checked: only 16-unhandled-exception-reaches-handler must say yes. A single example runs by hand with php -d auto_prepend_file=conformance-bootstrap.php examples/01-go-await.php.

This package's own tests (vendor/bin/phpunit) run the set under the reference adapter, plus the stress tier (--group stress or --exclude-group stress to select). Adapter packages run the stress tier with vendor/bin/fiber-interop-stress <bootstrap.php>; a failure prints the seed, replay it with FIBER_INTEROP_STRESS_SEED.

What the conformance set does not prove

License

MIT