Search by

phasync / swerve

frodeborli

S(w)erve your long-running PHP applications from the CLI fast and efficiently.

Package info

github.com/phasync/swerve

pkg:composer/phasync/swerve

Statistics

Installs: 1 211

Dependents: 0

Suggesters: 0

Stars: 13

Open Issues: 11

0.1.0-beta7 2026-10-15 21:13 UTC

README

SWERVE

A PHP application server for connections that stay open.

<?php // swerve.php

return new Swerve\RequestHandler(function (Swerve\ClientRequest $request) {
    $request->sendResponseHeaders(200, ['content-type' => 'text/plain']);
    $request->write("Hello, World\n");
});

Swerve is a PHP application server, built on phasync coroutines. Your application stays loaded between requests, every worker serves many requests and connections at once, and request and response bodies stream. A handler gets a ClientRequest: the request to read and the response to write, for as long as it keeps it. Frameworks are served through an adapter that turns a ClientRequest into the framework's own request and response: Laravel, Symfony, Yii, CakePHP, Spiral, CodeIgniter and Laminas.

Beta: APIs and options may still change until 1.0.

Documentation · Examples: a chat room over Server-Sent Events.

Why swerve

PHP-FPM builds your application from nothing for every request (autoloader, framework, container, database connection), runs one request per process, and throws it all away. swerve keeps the application loaded in long-running workers, and each worker serves thousands of requests and connections at once on phasync coroutines.

  • Connections that stay open. Server-Sent Events and long polling from an ordinary request handler, streaming request and response bodies, raw connections after a 101, and publish/subscribe between workers, so live features need no separate Node or Go service.
  • Boot once, serve forever. The framework, routes and connection pools are built once per worker. A Slim app costs swerve about 10% over a bare handler; Express costs Node half or more.
  • Waiting is free. While one request waits for the database or an API, the same worker serves others. Your handler stays ordinary sequential PHP: no promises, no callbacks.
  • Isolated requests. Each request runs in a Scope of its own, which the coroutines it starts share, so request-scoped state stays separate even with thousands in flight.
  • Supervised. Crashed workers restart, a worker stuck in a loop is replaced (the watchdog), workers that grow are recycled, and reloads roll one worker at a time.
  • Yours to own. MIT, with no third-party dependencies beyond phasync and the PSR logger and cache interfaces. See the Ennerd philosophy.

The same application on every server, 2 and 8 workers on one CCD of a Ryzen 9 9950X3D, wrk on the other, over loopback (method, scripts and raw results). 8 workers serve 3.9× what 2 do.

Hello world, requests per second: Swoole 1.01M, OpenSwoole 966k, swerve 860k, swerve + phasync-ext 846k, ReactPHP 617k, FrankenPHP 457k, RoadRunner 144k at 8 workers 10 KB page, requests per second at 8 workers: Swoole 220k, OpenSwoole 215k, swerve + phasync-ext 214k, swerve 213k, ReactPHP 200k, FrankenPHP 135k, RoadRunner 85k 10 ms wait, 10,000 connections, 8 workers: Swoole 621k, OpenSwoole 557k, swerve + phasync-ext 501k, ReactPHP 223k, FrankenPHP 706, RoadRunner 701 requests per second Time for a message to reach the last of 50,000 WebSockets: swerve + phasync-ext 78 ms, ReactPHP 150 ms, Swoole 199 ms, OpenSwoole 203 ms

The same framework applications on each server they have an integration for (method and results). With phasync-ext, Symfony on swerve also overlaps requests that wait: 10,455 req/s with a 10 ms wait at 8 workers, where one request per worker at a time gives about 780.

JSON route, 8 workers. Slim: Swoole 834k, swerve 705k, swerve + phasync-ext 699k, ReactPHP 546k, FrankenPHP 366k, RoadRunner 132k. Symfony: Swoole 206k, swerve + phasync-ext 96.6k, swerve 94.6k, FrankenPHP 88.9k, RoadRunner 57.6k. Yii 3: swerve 197k, swerve + phasync-ext 192k, RoadRunner 80k JSON route, 8 workers. Laravel: FrankenPHP 21.5k, Swoole 21.1k, RoadRunner 19.2k (all Octane), swerve 18.5k, swerve + phasync-ext 18.4k. CodeIgniter: swerve 20.6k, swerve + phasync-ext 20.2k, FrankenPHP 17.6k. Spiral: RoadRunner 21.4k, swerve + phasync-ext 19.8k, swerve 19.8k

Up to 512 connections per worker swerve runs on PHP's own stream_select(); above that it needs phasync-ext, which waits with epoll.

Coming from PHP-FPM

  1. Already using phasync under FPM? That code runs unchanged in swerve: the coroutines you started inside one request now also share the worker with other requests.
  2. Keep per-request state per request. A worker runs many requests at once, so static properties and globals are shared between them. There are no $_GET, $_SERVER or $_SESSION: read request data from the ClientRequest (a framework's adapter does), and keep request-scoped state in a WeakMap keyed by Scope::getCurrent(): each request is a Scope of its own. See How swerve runs your application.
  3. Return a Swerve\RequestHandler from swerve.php. A framework gets an adapter that turns the ClientRequest into its own request and response.
  4. Load phasync-ext in production (it ships inside phasync: --ext, or "extra": {"phasync": {"ext": true}} in composer.json). Blocking calls in libraries you did not write (MySQL through PDO or mysqli, curl, Guzzle, file and DNS functions) then wait as a coroutine instead of stalling the worker, and a worker can hold far more than 1,024 connections.

Getting started

Requirements: PHP 8.2 or later on Linux, with the pcntl, posix and sockets extensions.

  1. Install swerve. While it is beta, your project must allow beta packages:

    composer config minimum-stability beta
    composer config prefer-stable true
    composer require phasync/swerve
  2. Create swerve.php in your project root, returning a Swerve\RequestHandler that wraps a closure (the file is also your bootstrap: each worker runs it once at start). The closure runs once for each HTTP exchange, and the exchange lasts as long as the closure does:

    <?php // swerve.php
    
    return new Swerve\RequestHandler(function (Swerve\ClientRequest $request) {
        $request->sendResponseHeaders(200, ['content-type' => 'text/plain']);
        $request->write("Hello, World\n");
    });

    Keeping the request open makes a stream. This one pushes a number every second as Server-Sent Events, until the client leaves:

    <?php // swerve.php
    
    use phasync;
    use phasync\IOException;
    
    return new Swerve\RequestHandler(function (Swerve\ClientRequest $request) {
        $request->sendResponseHeaders(200, ['content-type' => 'text/event-stream', 'cache-control' => 'no-cache']);
        $request->flush(); // the head goes out now, not with the first event
        try {
            for ($n = 1; ; ++$n) {
                $request->write("data: $n\n\n");
                phasync::sleep(1);
            }
        } catch (IOException) {
            // the client left
        }
    });
  3. Run it:

    > vendor/bin/swerve
    2026-09-26 17:00:30.12    swerve 0.1.0 serving ./swerve.php on http://127.0.0.1:8080 with 8 workers
    2026-09-26 17:00:31.25 3 GET / 200 1.2ms
    2026-09-26 17:00:31.31 6 GET /missing 404 0.4ms
    

    Each line has the time, the worker (its slot number, 0 to 7 here; blank for the master process), and the message: a request with its status and how long the application took, or anything else worth knowing, such as a worker that crashed and was restarted. Ctrl+C stops swerve, letting the requests in flight finish first.

    vendor/bin/swerve --watch              # during development: reload when a PHP file changes
    vendor/bin/swerve --public=public      # also serve the files in public/ (CSS, JavaScript, images)
    vendor/bin/swerve app.php --http :80   # app.php on port 80 of every interface

Usage

> vendor/bin/swerve --help
Usage: swerve [options] [file]

Application:
  [file]                           With the swerve adapter: swerve.php, a PHP file returning a Swerve\RequestHandler, which runs once per request with a ClientRequest (default: ./swerve.php). With another adapter: given to it (a router script, say, as for php -S)
  -t, --docroot=<dir>              The document root, given to the adapter (one serving plain PHP files, say, as php -S does); not for the swerve adapter, see --public
  --adapter=<name>                 The adapter that provides the entry point: an installed one by name, or swerve for swerve.php (see README, Adapters); without it, the application's composer.json, the only installed adapter, else swerve

Serving:
  --http=<address>                 Serve HTTP here: 8080 (this machine only), :8080 (every interface), host:port, [ipv6]:port or unix:/path; repeat for several (default: 127.0.0.1:8080)
  --public=<dir>                   HTTP: serve the files in this directory (CSS, JavaScript, images), and pass the rest to the application
  --trusted-proxy=<ip|range|unix>  HTTP: believe the X-Forwarded-For, -Proto and -Host of a proxy at this IP address, range (10.0.0.0/8) or unix (unix: sockets); repeat for several
  -w, --workers=<n>                Worker processes; auto is one per CPU core (default: auto)

Extension:
  --ext                            Load phasync-ext (bundled with phasync) even if composer.json does not enable it; swerve stops if it cannot

Development:
  --watch                          Restart all workers when a PHP file of the application changes

Logging (to the terminal, or with --log to a file):
  -v, --verbose                    Log more: -v also what swerve does (workers starting, draining), -vv also debug
  -q, --quiet                      Log nothing to the terminal (--log still logs to its file)
  --no-access-log                  No line per request
  --log=<path>                     Append the log, and PHP errors, to this file instead

Limits:
  --max-body=<bytes>               HTTP: the largest request body in bytes (413), 0 for no limit (default: 8388608)
  --grace=<seconds>                Seconds workers get to clean up on shutdown, reload and recycle before SIGKILL (default: 30)
  --linger=<seconds>               Seconds a recycled worker may keep serving its upgraded connections (101) after its replacement took over; 0 = not at all (default: 1800)
  --watchdog=<seconds>             Replace a worker whose event loop is stuck this long (CPU work that never yields counts); at least 1, 0 = off (default: 30)
  --max-memory=<size|P%>           Recycle a worker above this memory after gc: bytes, K, M or G, or a % of memory_limit; 0 = off (default: 80%)
  --max-requests=<n>               Recycle a worker after about n requests; 0 = off (default: 0)
  --cache-size=<size>              The most Swerve::cache() holds, shared by the workers in the master: bytes, K, M or G (default: 64M)

Information:
  -h, --help                       This help
  --version                        The versions of swerve, PHP, phasync and phasync-ext

Options and the application file may come in any order; an option's value is attached (--workers=4, -w4) or the next word (--workers 4, -w 4). A usage error exits with code 2, and so does an application (a swerve.php or an adapter's entry) that returns anything but a Swerve\RequestHandler.

swerve runs in the foreground, as systemd, Docker and supervisord expect, and logs to standard output, which they collect; there is no daemon mode. Elsewhere, nohup swerve -q --log=/var/log/swerve.log & does the same.

Every worker serves HTTP/1.1 itself on the --http address, and the kernel spreads new connections over the workers. A proxy in front (nginx, HAProxy, Caddy) speaks HTTP to it too.

The line per request costs about 5% of the throughput of a hello-world application, and less of a real one; --no-access-log turns it off.

Adapters

swerve is installed into the application's own vendor/, so the application and swerve share one version of each library. Run as vendor/bin/swerve, the worker loads only swerve's own dependency closure (swerve, the installed adapters, and what they require), never the application's classes or its Composer files: an application that includes vendor/autoload.php itself (a plain PHP application under swerve-sapi) gets them fresh in every request, framework bootstraps included, as under php-fpm. swerve accepts every major version of the PSR packages it uses (psr/log 1 to 3, psr/simple-cache 1 to 3, psr/http-message 1 and 2), and Composer picks the one the application needs; php tests/psr-versions.php checks that every class loads with each.

An adapter is an installed package that provides the entry point in place of swerve.php. It declares itself in its own composer.json:

{"extra": {"swerve": {"adapter": "psr15", "entry": "Swerve\\Psr15\\init"}}}

entry names a function that each worker calls once, after the fork, with the application directory; it returns a Swerve\RequestHandler, as swerve.php does, and anything else stops swerve with exit code 2. The entry runs with swerve's dependency closure only (above): the adapter, what it requires, swerve and phasync. Whether the application's classes are loaded once per worker and shared by its requests, or fresh in each request, is the adapter's choice. One that shares them (a framework adapter) requires the application's autoloader in its entry, from the vendor directory swerve runs from:

require_once $GLOBALS['_composer_autoload_path'] ?? "$appDir/vendor/autoload.php";
``` The master only reads
`vendor/composer/installed.json` to find adapters, and doesn't call their entry.

The adapter is the first of: `--adapter=<name>`; `"extra": {"swerve": {"adapter": "<name>"}}` in the
application's own `composer.json`; the only installed adapter; `swerve`, the built-in one, which
loads `swerve.php` (always available as `--adapter=swerve`). Several installed adapters and no
choice, or a name that is not installed, stops swerve at start. Adapters are looked up in the
vendor directory swerve itself was loaded from (`vendor/`, or wherever the application keeps it,
such as Joomla's `libraries/vendor`), and in the root package's `composer.json`, which may declare
an adapter with its `entry` itself (an adapter package run from its own checkout). With an adapter other
than `swerve`, a `swerve.php` in the application directory is ignored (logged once). The
application directory is the current one, or for the `swerve` adapter the directory of the
`swerve.php` given.

An adapter's entry may take two more arguments, which swerve passes by name, as absolute paths,
only when given on the command line: `$docroot` (`-t <dir>`) and `$file` (the file argument). What
they mean is the adapter's: one serving plain PHP files can take the shape of PHP's built-in
server, so that `swerve -t public router.php` serves like `php -S 127.0.0.1:8080 -t public
router.php`. An entry without such a parameter stops swerve with exit code 2 when it is
given; `-t` with the `swerve` adapter is a usage error (`--public` serves files in front of a
`swerve.php`).

```php
function entry(string $appDir, ?string $docroot = null, ?string $file = null): Swerve\RequestHandler

Integrating a package with swerve

A package configures swerve from a file in its composer.json files, which Composer loads after swerve's own, when the master loads the autoloader at startup:

{"autoload": {"files": ["integrate-with-swerve.php"]}}
use Swerve\Swerve;

Swerve::ini(['phasync.virtualize' => true]);  // php.ini settings, applied by swerve's startup restart
Swerve::onWorkerStart(function () {           // in every worker, before the application loads
    // set up what the worker shares between requests; coroutines started here run for its life
});

ini() wins over swerve's own settings, and an explicit -d on the command line wins over it. Calls after startup change nothing, so the file may run again when an application includes the autoloader in every request.

Per-request process state, for framework authors

Each request runs in a Scope of its own (fiber-interop/scope), which the coroutines it starts share. A framework that runs its requests as coroutines of one worker (not one process each, as PHP-FPM does) shares process-wide PHP state - setlocale(), date_default_timezone_set(), a static of its own - between every request running there. A phasync::onScopeChange() hook keeps that state as each request's own, so application code can go on using the plain functions:

$locales = new WeakMap();   // per Scope
phasync::onScopeChange(function (Scope $new, ?Scope $old) use ($locales) {
    if (null !== $old) {
        $locales[$old] = setlocale(LC_ALL, 0);
    }
    setlocale(LC_ALL, $locales[$new] ?? 'C');
});

Register it once at boot. It runs before a coroutine of another scope than the last one runs, and as a request begins: never between a request's own coroutines. The worker's own code runs in the root scope (Scope::getRootScope()), which gets its state saved and restored the same way. A hook runs in the event loop and must not wait, and an exception from one stops the worker.

Static files

--public=<dir> serves the files in a directory, and passes every other request to the application: /css/site.css is public/css/site.css, / is public/index.html. Files are streamed, with their Content-Type, Content-Length, Last-Modified and ETag; a browser's revalidation gets 304 Not Modified, and a Range request (video, resumed downloads) 206 Partial Content. Other methods than GET and HEAD, names starting with a dot (.env, .git/; .well-known/ is served), PHP files (.php, .phtml, .phar, .inc: a source is never sent), and paths leading out of the directory (.., a symlink pointing outside) go to the application; directories are never listed. This is deliberately small: for a lot of static content, or anything beyond this, let nginx or a CDN serve it in front of swerve.

The same is available to your own handler: (new Swerve\StaticFiles($dir))->wrap($handler) returns a handler that answers file requests and calls $handler for the rest.

Logging from the application

Swerve::log() is swerve's log as a PSR-3 LoggerInterface: lines go where swerve's go (the terminal, or --log's file), with the time and the worker's slot. Give it to anything that takes a logger, or log directly:

Swerve::log()->warning('Payment {id} declined', ['id' => $id]);

With -q it logs nothing; embedded without swerve's command line, it logs nothing until you give it a logger with Swerve::setLog().

The ClientRequest

Swerve\ClientRequest is one HTTP exchange, a phasync\Net\Duplex: read() returns the request body ('' only at its end) and write() sends the response body. The module does the framing. Requests and responses is the reference; in short:

  • The handler runs in the connection's own coroutine, once per exchange, in a Scope of its own (phasync::finally() in a handler runs once the exchange is finished). Return to finish the response; the request body can no longer be read after that.
  • sendResponseHeaders($status, $headers) sets the head, held until the first write(), end(), flush() or sendFile(). A 1xx goes out at once (103 Early Hints, repeatable); a 101 switches to the raw connection.
  • Nothing is buffered: write() goes straight out, chunked unless you send a content-length. write() throws phasync\IOException once the client is gone, which is how a producer learns it can stop.
  • An exception before the head went out is a 500 (logged); after, the connection is aborted.
  • The request body, query string and cookies are yours to parse from read(), getTarget() and getRequestHeaders(); there is no PSR-7 request.

Long-lived connections

  • Shutdown and reload. The worker stops accepting, and every coroutine, requests in flight included, gets a Swerve\WorkerStoppingException at its wait at once: a phasync ShutdownException, so a CancelledException. swerve ends what it owns: a request whose response has not started gets a 503, a started response is cut, a Swerve\WebSocket closes with 1001 (Going Away, or 1012, Service Restart, on a reload), idle connections close. Clients retry and reconnect, as they must when any server dies. Save or undo partial work in phasync::finally() or phasync::shielded(); the exception reaches every later wait too, so a goodbye of your own (on a raw 101 tunnel, say) is written shielded. $e->reason (Shutdown, Reload, Recycle) says what happens next. The worker exits once every coroutine has ended, a second before --grace at most, whatever is left, which is logged.
  • Recycle: lingering. A worker replaced by a recycle (--max-memory, --max-requests) keeps its upgraded connections instead of draining them: it stops accepting, finishes its plain requests and stays until the last client has left, or --linger seconds (1800) have passed, which then ends it like a shutdown. Subscriptions, the cache and claims keep working meanwhile. A slot has at most three lingering workers; a recycle that would make a fourth waits until one exits. A reload or shutdown ends the lingering with --grace. --linger=0 drains a recycled worker at once.
  • Saying goodbye. Swerve::onShutdown($callback) runs the callback in a coroutine when the worker stops (for a recycle, at the end of the lingering; Swerve::draining() turns true when it begins), for example to send a protocol-level goodbye from a place that holds no request. Its waits get the WorkerStoppingException too: write the goodbye shielded. A callback runs in the Scope that registered it, which holds it weakly: one whose request has ended is dropped (it relies on phasync's garbage collection, about half a second after a coroutine ends) and never runs. An exception in a callback is logged and stops no other. Swerve::awaitShutdown($timeout) waits for the same moment.
  • Many connections. Without phasync-ext a worker serves at most 512 connections, whatever ulimit -n says: add workers or install the extension for many of them.
  • Server-Sent Events and WebSockets are in core: Swerve\ServerSentEvents and Swerve\WebSocket, specified in WebSockets and Server-Sent Events; see also Realtime.

Publish and subscribe

A message published in any worker reaches the subscribers of its topic in every worker, the publishing one included: a chat room, a live dashboard, cache invalidation.

use Swerve\Swerve;

Swerve::publish('chat', $message);

foreach (Swerve::subscribe('chat') as $message) {
    // every message published to 'chat' from the moment subscribe() returned
}
  • Messages travel as JSON, decoded once per worker and shared by its subscribers: a subscriber gets the value published (Swerve::publish('game', ['kill', $id]) an array, '{}' a string, ['end' => true] a read-only object: $message->end).
  • The master keeps, per topic, which workers have subscribers; a publishing worker writes the message straight into their inboxes. One worker's messages arrive in the order it published them; there is no order between workers. Without the master (swerve embedded in your own process), they are delivered in that process.
  • Delivery is at most once, to the subscriptions that exist when a message reaches their worker. There is no history: a worker started after a reload, a recycle or a crash sees nothing sent before it. Swerve is one machine; across machines, use Redis, NATS or the like.
  • A subscription ends when its last reference goes: a break, the variable going out of scope, the request's coroutine ending. A topic costs nothing in a worker without subscribers.
  • new \Swerve\OrderedChannel('ledger') is for many publishers that need one common order: its write() and subscribe() (or foreach ($channel as $m)) give every subscriber, in every worker, the same order, apart from the plain topic of that name. The workers append to a log file in the master's temporary directory, and a message that was appended survives the death of its worker. Files are kept for about 30 s, a subscriber further behind gets a SubscriberLagException, and nothing is written while nobody subscribes. SWERVE_TMPDIR names the directory the master makes its temporary directories in (default the system's temporary directory; tmpfs such as /dev/shm is fine). Nothing is synced or durable beyond the master.
  • A message is kept once per worker, however many subscribe, until the slowest subscriber read it. One falling more than maxLag seconds behind (30 by default, Swerve::subscribe('prices', maxLag: 5)) gets a SubscriberLagException from its loop.
  • Topics are 1 to 255 bytes, messages at most 128 KiB. A worker that does not read its inbox (its event loop stuck) loses the messages sent to it after 0.1 s, with a warning in the log; it does not hold up the publishers.
  • Swerve::publishRaw('frames', $bytes) sends $bytes unchanged, for messages publish() cannot carry (bytes that are not valid UTF-8, such as a binary WebSocket frame). Its subscribers get exactly $bytes; a topic may mix publish() and publishRaw() messages.

Shared cache

Swerve::cache() is a PSR-16 cache every worker shares, held by the master: sessions, rate limits, results that are costly to make.

$cache = Swerve::cache();
$user  = $cache->get("user.$id") ?? load_user($id);
$cache->set("user.$id", $user, 60);
  • Each worker keeps what it read in a local layer (8 MiB), so a repeated read costs about a microsecond. A write goes to the master, which tells every worker to forget those keys; a worker never keeps a value older than the last write it was told of. A trip to the master costs about 0.1 ms, and only the calling coroutine waits.
  • The master evicts the least recently used entries past --cache-size (64 MiB). It keeps values serialized and never loads your classes. A reload restarts swerve, so it empties them too.
  • A coroutine that swerve.php starts may use it at once: the call waits until the worker serves. Directly in swerve.php it throws (details). Without the master (swerve embedded in your own process), the cache is the process's own.

Claims

Swerve::claim($name) gives a handle on a name that one worker at a time can hold: a job that must not run twice, a leader. The handle claims nothing yet.

function nightlyReport(): void
{
    if ($claim = Swerve::claim('nightly-report')->acquire()) {
        work();
    }
}   // $claim goes out of scope: released
  • available() tells whether nobody holds the name, claiming nothing. acquire($timeout) takes it and returns the handle, or null; with a $timeout it tries again every 20 ms until the name is free (no queue: the first to try when it frees wins). On a handle that holds the name already, it returns the handle. held() tells whether this handle holds it. release() gives it up.
  • A claim has no TTL: it is held until released, destroyed, or its worker exits or dies (the master clears a dead worker's claims). Names are apart from the cache's keys.
  • A claim is a file in a temporary directory (under $SWERVE_TMPDIR) that the master creates and removes: a hard link to a file with its holder's pid in it. Linking is atomic, and fails while the name is held. Without the master it works the same, in a directory of the process that dies with it.

Supervision

A master process starts the workers and looks after them. It never loads the application itself: each worker loads it after starting, so a reload runs the current code.

Signal to the master Effect
SIGTERM, SIGINT (Ctrl+C), SIGQUIT Shutdown: the workers stop accepting, end their coroutines with a WorkerStoppingException (requests in flight get a 503, or are cut) and exit once these have cleaned up. What is left after --grace seconds is killed. A second signal kills at once.
SIGHUP, SIGUSR2 Reload: every worker stops as on a shutdown, then the master restarts swerve in place (same PID, same command line), so the application, swerve, phasync, php.ini and opcache are all fresh, and old and new code never run side by side. The cache starts empty. Connections arriving while swerve restarts are refused. The --log file is reopened first, by the master and the workers, so logrotate can rename it and send SIGHUP.
SIGUSR1 Reopen the --log file only.

SIGQUIT, SIGUSR1 and SIGUSR2 mean what they mean to php-fpm, so its deploy and logrotate scripts work unchanged. The master asks a worker to drain over its pipe, not with a signal, which would cut short a blocking call (sleep(), stream_select()) of a request in flight.

Workers ignore SIGINT and SIGHUP sent to the whole process group (Ctrl+C, a closing terminal): only the master decides. When the master itself dies, the workers drain and exit; one stuck in a busy loop or a blocking call, which can't drain, kills itself a second after the --watchdog timeout, so that it doesn't keep the port (with --watchdog=0 it stays until it gets unstuck); one still loading the application does the same a second after the 60 seconds the master allows for that. A worker sent SIGTERM by someone else (an operator, the OOM tooling) drains, and the master starts another in its slot. SIGQUIT to a worker logs what it is doing: its requests in flight, and where its code runs.

  • Crashes. Every worker exit is logged with its exit code or signal, and the worker is restarted. A worker that dies before it was ready, or within 5 seconds of it without having served a request, is a failed start: its slot waits 0.5, 1, 2, 4, ... up to 30 seconds before the next start, and three in a row are logged as a crash loop. A worker that served requests started fine: a request killed it, and it is restarted at once. When every slot failed to start before any worker became ready (the application can't load, the port is taken), swerve exits with the worker's exit code, or 1 when that is 0 (die('no config')). A new worker of a reload or a recycle that fails to start leaves the old one serving, and is retried in its slot with the same backoff, so a failure that passes (a database away for a moment) doesn't leave the old code serving for good. A PHP fatal error is logged by its worker, with the requests it had in flight. An exception in the application answers 500, is logged with its request, and the worker serves on; one in a background coroutine nobody awaits is logged too. The master also reaps children it did not start, as PID 1 in a container without --init or after job & exec swerve in an entrypoint.
  • Stuck workers. Each worker's event loop sends the master a heartbeat four times a second. Requests starting and ending count too, so a worker busy with many short blocking requests in a row is not taken for stuck. A worker silent for --watchdog seconds, stuck in a busy loop or a blocking call, logs its requests in flight and where its code runs (SIGQUIT), and is killed and replaced. So is a request that does more CPU work than that without yielding; raise --watchdog, or set it to 0, for such applications. A worker uses SIGALRM to notice that the master died while it was stuck (see above), a second after the timeout, so that a live master kills it first and a shorter blocking call (sleep(), stream_select()) is never cut short; with --watchdog=0 the application may use SIGALRM itself.
  • Memory leaks. After each request, and every tick, a worker compares memory_get_usage(true) with --max-memory (by default 80 % of memory_limit, and off when memory_limit is -1 unless the option is given). Above it, after a garbage collection, the master starts a replacement and the leaking worker finishes its plain requests and lingers (see Recycle: lingering above), up to --linger seconds. --max-requests recycles after about so many requests. The limits get some jitter, so workers don't recycle together. A --max-memory not below memory_limit could never be reached first: it is logged as a warning, and recycling is off. A worker that hits memory_limit itself dies with PHP's fatal error (in the log with --log), loses its requests in flight, and is replaced.
  • --watch watches the *.php files next to and below the swerve file (not in vendor/, nor names starting with a dot, such as editors' lock files; but vendor/composer/installed.php, so composer install counts) and reloads 1 to 2 seconds after the last change. Directories it can't read are skipped. The scan runs in the master: on a large tree (node_modules, caches) it waits ten times as long as the last scan took before the next, so that the master stays mostly idle, and changes take that much longer to be noticed.

What a reload picks up: the swerve file and every class autoloaded in the worker, with Composer's class map and PSR-4 prefixes read again (opcache_reset() is called too). Workers never check source files for changes themselves: swerve turns off opcache.validate_timestamps, so changed code arrives only through a reload. It does not pick up swerve itself, phasync, Composer files autoloads, php.ini or the command line options; restart for those. The swerve file's path is not resolved, so a deploy that points a current symlink at a new release and sends SIGHUP runs the new release; but the class map and PSR-4 prefixes read again are those of the vendor/ directory the master loaded at its start, so an application whose swerve file requires its own release's vendor/autoload.php is safest.

Operating notes:

  • Linux resets connections still waiting in the accept queue of a listener that closes. For handovers without any lost connection, set sysctl net.ipv4.tcp_migrate_req=1 (swerve logs a hint at startup when it is 0).
  • Give the process manager more time than --grace: docker stop -t 35, Compose stop_grace_period: 35s or systemd TimeoutStopSec=35 for the default 30 seconds. During a handover a slot briefly has two workers, so budget for one worker's memory more.
  • Long responses such as Server-Sent Events or long polling are cut at once on a reload or shutdown; a recycled worker keeps them up to --linger seconds. A slot may then have up to three lingering workers beside the serving one: budget their memory too.
  • Without -v the log has notices and up: reloads, recycles, every worker exit, the pid of each worker started after the first ones, and shutdown. -v adds the first starts and each worker's readiness, -vv debug lines. What a client sent, such as a request target in an error, is logged as it is, with control characters escaped.
  • swerve refuses to start on an address something already listens on. Its own workers share the address with SO_REUSEPORT, which would otherwise let a second swerve (a forgotten one, or one started by hand next to systemd's) silently take a share of the connections.
  • Log lines to a pipe that nobody reads (a stalled log collector) are dropped rather than block the server; with --log they go to a file, which never blocks. A line that can't be written (a full disk, a log reader gone) is dropped without a PHP warning, which an application's error handler could turn into an exception.
  • A worker at its limit of connections (its open-file limit less 64, and without phasync-ext at most 512) logs a warning, at most once a minute.
  • Options go before the swerve file: anything after it is refused.
  • The listeners and the workers' pipes to the master are close-on-exec (this needs the sockets extension of PHP 8.4 or later), so a background job the application starts (exec('job &'), proc_open(), mail()) doesn't keep the port after its worker died or swerve stopped. A client connection is still inherited, and the job holds it open until it exits.