phasync
Coroutines / Design

Coroutines

Design

Why phasync and Swerve are built the way they are.

phasync's own contract, docs/SEMANTICS.md, opens with what the library is for: "an async IO and concurrency library for one PHP process." Everything it keeps or refuses follows from reading a coroutine as if it were synchronous code, not from adding features because they were possible.

What stays in core#

In scope: the scheduler, coroutines, frames and scopes, cancellation, timeouts, channels, WaitGroup, RateLimiter, flags, non-blocking stream I/O, Process pipes, and StringBuffer. Out of scope, left to other packages: worker pools and cross-process proxies, serialization, PSR-7 implementations, FastCGI and WebSocket protocol code, database drivers, HTTP servers — that is what Swerve, the PSR-7 layer and the adapters are for. StringBuffer looks like an exception (a byte buffer belongs in a protocol library, not a concurrency one) but it stays, on purpose: it is fast enough for protocol code to build on directly, and a decoupled package could not take the same shortcuts without losing that.

The principles behind it#

A decision made by this rule: select() is gone#

2.0.0 removed phasync::select() and the Selector machinery behind it, rather than fix the real bugs an audit found in it (a race between detecting readiness and acting on it; a wrong branch; leaked helper coroutines). The fix was designed and would have worked. It shipped anyway as a removal, because select() is the one primitive that asks the developer to think about racing, per-candidate timeouts and cancelling losers — exactly the async-aware reasoning the rest of phasync exists to make unnecessary. Checked against real usage before the decision: phasync's own README never gave it a section the way Channel and WaitGroup got one, Swerve never called it, and the one real consumer only ever needed "wait across a few raw sockets," which is the same pattern this section warns about from the other side. The idiomatic replacement, used throughout this site, is a coroutine per source, each with its own read loop, letting the scheduler interleave them — see Channels:

// One coroutine per source, instead of racing both with a removed select()
phasync::go(function () use ($a) {
    phasync::readable($a, 1.0);
    echo fread($a, 100), "\n";
});
phasync::go(function () use ($b) {
    phasync::readable($b, 1.0);
    echo fread($b, 100), "\n";
});

SelectableInterface itself (isReady()/await()) was never coupled to select() and stays: Channel, StringBuffer, WaitGroup and RateLimiter all implement it directly.

Why no dependencies#

Beyond PHP itself, PSR interfaces and fiber-interop/scope, phasync, the extension and Swerve depend only on each other. That is a deliberate trade against convenience: no dependency tree to audit, no upstream release to wait for, and no third-party churn an application did not choose. It is also what makes "read it whole" realistic: phasync's src/ is about 12,900 lines of PHP, Swerve's about 9,000 — a size a developer, or a coding agent, can read in full and change with confidence, which is the point of owning the code at all instead of depending on it. MIT licensed, including the extension.