mikepultz / php-quic
Raw QUIC transport for PHP (client + server), built on OpenSSL 3.5+ native QUIC.
Package info
Language:C
Type:php-ext
Ext name:ext-quic
pkg:composer/mikepultz/php-quic
Requires
- php: >=8.4
README
A PHP extension that exposes raw QUIC transport, client and server, with first-class access to QUIC streams. It is built on OpenSSL 3.5+'s native QUIC stack: no ngtcp2, no quiche, no Rust, no FFI. The only dependency is the OpenSSL that PHP already links against.
It gives you Quic\Connection, Quic\Listener, and Quic\Stream objects plus
a Quic\poll() event loop, so you can build any QUIC-based protocol in PHP
(HTTP/3, DNS-over-QUIC, or your own). The extension is the transport; the
protocol framing on top is yours.
// open a QUIC connection and exchange data on a stream $conn = new Quic\Connection('example.com', 443, ['alpn' => 'myproto']); $stream = $conn->openStream(); // send the data, then conclude the stream (FIN) $stream->write("hello", true); $reply = $stream->read(65535); $conn->close();
Requirements
- PHP 8.4+ (NTS or ZTS) on Linux and macOS; PHP 8.5+ on Windows (see the platform note below)
- OpenSSL 3.5.0+ with native QUIC (client QUIC since 3.2, server since 3.5). The extension requires 3.5 because it supports both client and server.
- Linux, macOS, and Windows (all built and tested in CI). On Linux and macOS the extension links the system OpenSSL, so any PHP 8.4+ works as long as that OpenSSL is 3.5+. On Windows it links the OpenSSL that ships in the PHP build's dependency pack: PHP 8.5 bundles OpenSSL 3.5+, but PHP 8.4 bundles OpenSSL 3.0 (which has no QUIC), so Windows requires PHP 8.5+.
Installing
With PIE (recommended)
PIE is the modern replacement for PECL:
pie install mikepultz/php-quic
From source
phpize ./configure --with-quic # or --with-quic=/path/to/openssl-3.5-prefix make make test # runs the .phpt suite sudo make install
Then enable it (extension=quic) in your php.ini.
API
All classes live in the Quic\ namespace.
Quic\Connection
Construct with new Quic\Connection(...) to open a client connection;
server-accepted connections come from Listener::accept().
| Method | Description |
|---|---|
__construct(string $host, int $port, array $options = []) |
Open a client connection and complete the handshake. |
openStream(bool $bidirectional = true): Stream |
Open a locally-initiated stream (same as new Quic\Stream($conn, $bidirectional)). |
acceptStream(): ?Stream |
Accept a peer-initiated stream (null if none pending). |
close(int $errorCode = 0, string $reason = ""): void |
Send CONNECTION_CLOSE. Waits for the peer to acknowledge written data, so on a blocking connection with an unresponsive peer it can block until the idle timeout (see below). |
getCloseInfo(): ?array |
Once closed, the CONNECTION_CLOSE details: error_code, frame_type, reason, local (we initiated it), transport (a transport error vs an application code). null while still open. |
getFd(): int, setBlocking(bool), wantsRead(): bool, wantsWrite(): bool, getTimeout(): ?float, handleEvents(): void |
Event-loop integration (see below). |
getCryptoInfo(): array |
Negotiated session info (protocol, cipher_name, cipher_bits, cipher_version, alpn_protocol), the same keys as stream_get_meta_data()['crypto']. |
getNegotiatedAlpn(): ?string |
The negotiated ALPN protocol. |
getPeerCertificate(): ?string |
The peer's leaf certificate (PEM). Bridge to ext-openssl with openssl_x509_read(). |
getPeerCertificateChain(): array |
The peer's certificate chain (array of PEM strings). |
getVerifyResult(): int, getVerifyResultString(): string |
Certificate verification result (0 is OK) and its description. |
Constructor options use the ssl stream-context vocabulary. The full set:
| Option | Type | Default | Description |
|---|---|---|---|
alpn |
string or array | (required) | ALPN protocol(s) to offer, for example 'h3', 'doq', or ['h3', 'h3-29']. A string may be comma-separated ('h3,h2'). |
alpn_protocols |
string | Alias for alpn (the ssl context spelling), a comma-separated list. |
|
peer_name |
string | $host |
Name used for SNI and for certificate-name verification. |
verify_peer |
bool | true |
Verify the server certificate chain against the trust store. |
verify_peer_name |
bool | = verify_peer |
Verify the certificate matches peer_name. |
allow_self_signed |
bool | false |
Accept a self-signed certificate (only the self-signed errors are downgraded; still requires verify_peer). |
cafile |
string | Path to a PEM CA bundle used for verification. | |
capath |
string | Directory of hashed CA certificates. | |
verify_depth |
int | (OpenSSL default) | Maximum certificate chain depth to accept. |
local_cert |
string | PEM client certificate chain for mutual TLS (mTLS). | |
local_pk |
string | = local_cert |
PEM client private key (defaults to reading the key from local_cert). |
passphrase |
string | Passphrase for local_pk. |
|
peer_fingerprint |
string or array | Certificate pinning. A hex digest string (algorithm inferred from length: md5/sha1/sha256/sha384/sha512), or an array ['sha256' => 'hex', ...] where every entry must match. Throws Quic\Exception on mismatch. |
|
SNI_enabled |
bool | true |
Send the Server Name Indication extension. |
security_level |
int | (OpenSSL default) | OpenSSL security level (0 to 5). |
ciphersuites |
string | (OpenSSL default) | Colon-separated TLS 1.3 ciphersuite list, for example 'TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256'. |
groups |
string | (OpenSSL default) | Colon-separated key-exchange groups, for example 'X25519:P-256'. |
sigalgs |
string | (OpenSSL default) | Colon-separated signature algorithms. |
idle_timeout_ms |
int | (negotiated) | Requested maximum idle timeout in milliseconds. |
connect_timeout_ms |
int | 0 | Abort the handshake with a Quic\Exception if it does not complete within this many milliseconds (0 = block on the QUIC defaults). |
TLS version is fixed at 1.3. QUIC mandates it (RFC 9001), so there is no version selector. The
sslcontext'sciphers(the pre-TLS-1.3 cipher list),disable_compression, andcrypto_methodoptions do not apply.
close()waits for the peer, and blocking connections wait a long time. It performs the RFC-compliant shutdown, waiting for the peer to acknowledge data already written so that it is actually delivered. If the peer has stopped responding, a blocking connection waits out the QUIC idle timeout: measured at 30.03s blocking versus 0.00s aftersetBlocking(false). In a single-threadedQuic\poll()server that stalls every other connection, so switch accepted connections to non-blocking right afteraccept(), asexamples/echo-server.phpdoes. To drop a connection without waiting at all, release the object and let it be collected; that path never waits for the peer.
close($code, $reason, rapid: true)skips the wait explicitly. A live peer still receives data already written, but the call returns without confirming it, so the last bytes become best-effort rather than guaranteed.
Quic\Listener
Construct with new Quic\Listener(...) to bind a server and start listening.
| Method | Description |
|---|---|
__construct(string $host, int $port, array $options) |
Bind a server and start listening. |
accept(): ?Connection |
Accept an inbound connection (null if none pending). |
close(): void |
Stop listening. Accepted connections keep working and can still be drained; the socket is released once the last of them is freed. See examples/graceful-shutdown.php. |
getFd(), setBlocking(), wantsRead(), wantsWrite(), getTimeout(), handleEvents() |
Event-loop integration. |
Constructor options: local_cert (PEM chain path, required), local_pk
(PEM key path), passphrase, alpn (string or array, required),
verify_peer (request and verify a client certificate, default false),
cafile/capath, reuse_port (set SO_REUSEPORT so multiple processes can
share the port, see Scaling), security_level, ciphersuites, groups,
sigalgs.
Quic\Stream
| Method | Description |
|---|---|
__construct(Connection $connection, bool $bidirectional = true) |
Open a local stream (same as $conn->openStream()). |
write(string $data, bool $fin = false): int |
Write data; optionally conclude (FIN). |
read(int $length): ?string |
Up to $length bytes (a single call may return fewer even when more is buffered, like fread), "" if nothing available (non-blocking), or null at end-of-stream (FIN or reset; see getResetCode()). |
end(): void |
Conclude the send side (FIN). |
reset(int $errorCode = 0): void |
Abortively reset the stream. |
getResetCode(): ?int |
After read() returns null, the application error code if the stream was reset, or null for a clean FIN. |
getId(): int, isBidirectional(): bool, getConnection(): Connection |
Stream metadata. |
asStream(): resource |
Expose the stream as a PHP stream resource for fread/fwrite/stream_get_contents. A data facade, not a readiness source: it is not select()able (see below). |
asStream()moves bytes, it does not report readiness. The resource is deliberately notselect()able:stream_select()on it warns and skips it (and then throwsValueErrorif it was the only stream passed). Readiness in QUIC belongs to the connection, not the stream. The descriptor underneath is the connection's UDP socket, shared by every stream on that connection, so it goes readable for traffic belonging to another stream and stays quiet while this connection's own timers come due. Wait withQuic\poll(), or see driving QUIC from an existing event loop.
Quic\poll(), the event-loop primitive
Quic\poll(array $items, ?float $timeout = null): array
The native way to wait for QUIC events across many objects at once, preferred
over driving stream_select() on getFd(). Each item is [$object, $events]
where $object is a Connection, Listener, or Stream and $events is a
bitmask of the constants below. It returns a map of each ready item's $items
key to its revents (preserving the key of a non-list array), and it pumps the
QUIC engine in the same call.
$timeout is in seconds (0.0 polls without blocking, null blocks until an
event). It also honours each connection's QUIC timers internally.
| Constant | Meaning |
|---|---|
Quic\POLL_READ |
stream has data to read |
Quic\POLL_WRITE |
stream can accept more writes |
Quic\POLL_ACCEPT_CONNECTION |
listener has an inbound connection (accept()) |
Quic\POLL_ACCEPT_STREAM |
connection has an inbound stream (acceptStream()) |
Quic\POLL_ERROR |
error or closed condition |
Return value, timeouts, and errors. The returned array maps each ready
item's $items key to its revents bitmask; items with no event are omitted, so
you iterate it directly. An empty array means the timeout elapsed with nothing
ready (there is no separate timeout return; with a null timeout poll()
blocks until an event, so it will not return empty for a non-empty item set). A
single object erroring or closing is not an exception, it is the
Quic\POLL_ERROR bit in that item's revents, so test $revents & Quic\POLL_ERROR. The call throws Quic\Exception only if the poll itself
fails (an SSL error, or a malformed item that is not [object, events] naming a
live Connection/Listener/Stream).
try { $ready = Quic\poll($items, 1.0); if (count($ready) == 0) { // timed out: nothing became ready within 1.0s } foreach ($ready as $i => $revents) { if (($revents & Quic\POLL_ERROR) != 0) { // this object errored or closed } if (($revents & Quic\POLL_READ) != 0) { // readable } } } catch (Quic\Exception $e) { // the poll call itself failed }
A complete concurrent, single-process echo server built on this is in
examples/echo-server.php (verified by the test
suite). Note that forking or threading per connection does not work for QUIC:
every connection is multiplexed over the listener's single UDP socket, so
concurrency is done in one process with Quic\poll().
Choosing a loop
| Your program | Use |
|---|---|
| Talks QUIC only | Quic\poll(). One call waits, honours the QUIC timers, and pumps the engine. Nothing else is needed. |
| Has a loop of its own over non-QUIC descriptors | getFd() + getTimeout() to feed that loop, then Quic\poll($items, 0.0) after each wake. |
| Uses a framework reactor (ReactPHP, Amp, Revolt) | The same: the reactor owns the wait, you give it the descriptor and the deadline. |
Both paths exist because only the first can let QUIC own the blocking wait. A
QUIC connection has mandatory timer work (loss recovery and PTO, delayed ACKs,
handshake retransmission, idle timeout) that comes due while its socket is
completely silent, so whoever owns the wait has to know that deadline. When the
wait is Quic\poll(), it is handled internally and you write nothing: a long
poll() goes on servicing loss recovery for the whole time it blocks. When the
wait belongs to someone else's loop, getTimeout() is how you tell it.
Prefer the first row whenever you can. The lower-level path is not a stylistic alternative for QUIC-only programs, it is the escape hatch for when the descriptor set is not yours. See driving QUIC from an existing event loop.
Error codes
A QUIC error code lives in one of two namespaces, and getCloseInfo()'s
transport flag tells you which.
Transport errors (transport => true) are defined by QUIC itself
(RFC 9000 section 20.1) and exposed as Quic\ERR_* constants:
| Constant | Value | Meaning |
|---|---|---|
Quic\ERR_NO_ERROR |
0x00 | no error |
Quic\ERR_INTERNAL_ERROR |
0x01 | internal implementation error |
Quic\ERR_CONNECTION_REFUSED |
0x02 | server refuses to accept the connection |
Quic\ERR_FLOW_CONTROL_ERROR |
0x03 | flow-control limits exceeded |
Quic\ERR_STREAM_LIMIT_ERROR |
0x04 | too many streams opened |
Quic\ERR_STREAM_STATE_ERROR |
0x05 | frame received in an invalid stream state |
Quic\ERR_FINAL_SIZE_ERROR |
0x06 | final size of a stream changed |
Quic\ERR_FRAME_ENCODING_ERROR |
0x07 | malformed frame |
Quic\ERR_TRANSPORT_PARAMETER_ERROR |
0x08 | invalid transport parameters |
Quic\ERR_CONNECTION_ID_LIMIT_ERROR |
0x09 | too many connection IDs issued |
Quic\ERR_PROTOCOL_VIOLATION |
0x0a | general protocol violation |
Quic\ERR_INVALID_TOKEN |
0x0b | invalid Retry token |
Quic\ERR_APPLICATION_ERROR |
0x0c | application error with no application code |
Quic\ERR_CRYPTO_BUFFER_EXCEEDED |
0x0d | CRYPTO data exceeded the buffer |
Quic\ERR_KEY_UPDATE_ERROR |
0x0e | key update error |
Quic\ERR_AEAD_LIMIT_REACHED |
0x0f | AEAD confidentiality limit reached |
Quic\ERR_NO_VIABLE_PATH |
0x10 | no viable network path |
Quic\ERR_CRYPTO_BEGIN to Quic\ERR_CRYPTO_END |
0x0100 to 0x01ff | TLS alert; the alert number is $code - Quic\ERR_CRYPTO_BEGIN |
Application errors (transport => false, and every getResetCode(),
reset(), and close() code) are defined by the protocol you run over QUIC,
not by QUIC. There is no single list: HTTP/3 has H3_* (RFC 9114, 0x0100+),
DNS-over-QUIC has DOQ_* (RFC 9250, 0x0 to 0x5), and your own protocol picks
its own. The extension does not define these; they belong in a userland
protocol library.
$info = $conn->getCloseInfo(); if ($info !== null && $info['transport'] == true && $info['error_code'] === Quic\ERR_PROTOCOL_VIOLATION) { // the peer tore down the connection for a QUIC protocol violation }
Examples
HTTP/3 GET request
The extension is the QUIC transport; HTTP/3 framing (the control stream, the SETTINGS frame, QPACK, and the request/response frames) is built on top. This minimal client opens the required streams, sends a GET, and reads the response body. It runs against any public HTTP/3 server.
$host = 'cloudflare-quic.com'; $conn = new Quic\Connection($host, 443, ['alpn' => 'h3']); // // HTTP/3 needs a control stream with SETTINGS plus QPACK encoder/decoder // streams. Keep these references for the connection's lifetime: closing the // control stream is a fatal H3 error. // // unidirectional control stream: type 0x00 (control) + empty SETTINGS $control = $conn->openStream(false); $control->write("\x00\x04\x00"); // QPACK encoder (0x02) and decoder (0x03) streams $qpackEnc = $conn->openStream(false); $qpackEnc->write("\x02"); $qpackDec = $conn->openStream(false); $qpackDec->write("\x03"); // // a GET is a HEADERS frame holding a QPACK field section. Pseudo-headers come // from the QPACK static table: GET=17, https=23, :path /=1, :authority=0. // // QPACK prefix $fields = "\x00\x00"; // :method GET $fields .= "\xD1"; // :scheme https $fields .= "\xD7"; // :authority (literal) $fields .= "\x50" . chr(strlen($host)) . $host; // :path / $fields .= "\xC1"; // HEADERS frame $request = "\x01" . chr(strlen($fields)) . $fields; // request stream (bidirectional): send the request + FIN (GET has no body) $stream = $conn->openStream(true); $stream->write($request, true); $resp = ''; while (($chunk = $stream->read(65535)) !== null) { if ($chunk !== '') { $resp .= $chunk; } } // // parse the HTTP/3 frames and collect DATA frames (the body) // $read_varint = function (string $_d, int &$_o): int { $b = ord($_d[$_o]); $n = 1 << ($b >> 6); $v = $b & 0x3f; for ($i = 1; $i < $n; $i++) { $v = ($v << 8) | ord($_d[$_o + $i]); } $_o += $n; return $v; }; $off = 0; $body = ''; while ($off < strlen($resp)) { $type = $read_varint($resp, $off); $len = $read_varint($resp, $off); $payload = substr($resp, $off, $len); $off += $len; // type 0x00 is a DATA frame; 0x01 is the response HEADERS frame (QPACK) if ($type === 0x00) { $body .= $payload; } } // e.g. "<!DOCTYPE html>" echo strlen($body), " bytes: ", strtok($body, "\n"), "\n"; $conn->close();
Decoding the response headers (the status and header fields) needs a QPACK decoder, which is out of scope for a transport library and belongs in a userland HTTP/3 client built on top of this.
DNS-over-QUIC (RFC 9250)
One DNS query per client-initiated bidirectional stream, framed with a 2-octet
length prefix, message id 0, ALPN doq:
$conn = new Quic\Connection('dns.adguard-dns.com', 853, ['alpn' => 'doq']); $stream = $conn->openStream(); // a wire-format DNS message (id 0), e.g. from a DNS library $query = "..."; // frame with a 2-octet length prefix and conclude (FIN) $stream->write(pack('n', strlen($query)) . $query, true); $len = unpack('n', $stream->read(2))[1]; $resp = $stream->read($len); $conn->close();
Echo server (blocking)
$listener = new Quic\Listener('0.0.0.0', 4433, [ 'local_cert' => '/etc/ssl/server.crt', 'local_pk' => '/etc/ssl/server.key', 'alpn' => 'myproto', ]); while (true) { // blocking accept of a connection and its first stream $conn = $listener->accept(); $stream = $conn->acceptStream(); $data = ''; while (($chunk = $stream->read(65535)) !== null) { if ($chunk !== '') { $data .= $chunk; } } $stream->write("echo: $data", true); $conn->close(); }
For concurrency (many connections in one process), see
examples/echo-server.php, which uses Quic\poll().
Non-blocking client with Quic\poll()
Quic\poll() is the native event-loop primitive. Give it the objects you care
about and it waits for readiness, drives QUIC timers, and pumps the engine in
one call. No php://fd wrapper, no manual timeout math.
$conn = new Quic\Connection($host, $port, ['alpn' => 'myproto']); $conn->setBlocking(false); $stream = $conn->openStream(); // send the request + FIN $stream->write($request, true); $response = ''; while (true) { // wait until the stream is readable (or a QUIC timer needs servicing) Quic\poll([[$stream, Quic\POLL_READ | Quic\POLL_ERROR]], 1.0); $chunk = $stream->read(65535); // end of stream if ($chunk === null) { break; } if ($chunk !== '') { $response .= $chunk; } } $conn->close();
Driving QUIC from an existing event loop
You probably do not need this section. If your program only talks QUIC,
Quic\poll() is the whole loop. It waits, honours the QUIC timers, and pumps
the engine in one call, and a long poll() keeps servicing loss recovery for
the entire time it blocks. You do not need getFd(), getTimeout(), or
handleEvents() at all. See choosing a loop.
This section covers the one case Quic\poll() cannot: a loop you do not
control already owns the descriptor set. A hand-rolled stream_select() over
sockets you already have, or a framework reactor (ReactPHP, Amp, Revolt). QUIC
cannot own the wait there, so you hand that loop what it cannot work out for
itself.
The invariant. A QUIC connection has mandatory timer work that comes due while its socket is completely quiet: loss recovery and PTO, delayed ACKs, handshake retransmission, idle timeout. "No descriptor is ready" does not mean "nothing to do". A loop that sleeps only on descriptor readiness will sleep straight through a retransmission deadline, nothing is resent, and every stream on that connection stalls until unrelated I/O happens to wake it.
So every iteration must:
- bound the sleep by
min(your own deadline, getTimeout()); - re-read
getTimeout()each time round, because the deadline moves; - call
Quic\poll($items, 0.0)after every wake, including a wake where nothing was ready, since a timer rather than a descriptor is exactly what came due. That one non-blocking call pumps the engine and reports which streams are ready, so there is no separate pump step to forget.
$conn->setBlocking(false); $sock = fopen('php://fd/' . $conn->getFd(), 'r'); $items = [[$stream, Quic\POLL_READ | Quic\POLL_ERROR]]; while (true) { // // never sleep past the connection's own deadline // $quic = $conn->getTimeout(); $wait = $quic === null ? $deadline : min($quic, $deadline); $r = [$sock, ...$yourOtherDescriptors]; $w = $conn->wantsWrite() == true ? [$sock] : []; $e = []; @stream_select($r, $w, $e, (int)$wait, (int)(fmod($wait, 1.0) * 1000000)); // // pump the QUIC engine and collect stream readiness in one call // foreach (Quic\poll($items, 0.0) as $key => $revents) { // ... read/write the ready streams } // ... your own descriptors, handled exactly as they always were }
getTimeout() returns seconds until the next deadline, or null for none
pending. getFd() gives the connection's UDP socket, and wantsRead() /
wantsWrite() say which direction the engine currently cares about.
A complete runnable version, mixing a QUIC stream and an ordinary
socket in one stream_select(), is in
examples/foreign-loop.php (verified by
tests/125-foreign-loop.phpt). tests/020-nonblocking.phpt covers the same
shape using handleEvents() instead of a zero-timeout poll(), which also
works if you only need to pump.
Note that the descriptor here comes from Connection::getFd(), not from
Stream::asStream(). Readiness is a connection-level property, so that is the
only level at which selecting on a descriptor is meaningful.
Clients and servers aggregate differently
How far one getTimeout() or handleEvents() reaches depends on which side you
are on, because OpenSSL runs one QUIC engine per bound UDP port.
| deadline | pumping | |
|---|---|---|
| Server (listener + accepted connections) | $listener->getTimeout() already covers every accepted connection |
$listener->handleEvents() services every accepted connection |
| Client (N connections) | each connection has its own deadline; take the min() across all of them |
tick each connection individually |
Every client Quic\Connection is an independent engine with its own socket, so
pumping one does nothing for the others. Server-side the opposite holds: the
listener and all of its accepted connections share one engine, and every one of
them reports the same engine-wide deadline.
One consequence is easy to trip over: a server-side
Connection::getTimeout()does not report that individual connection's deadline, it reports the earliest across the whole listener. Do not build per-connection timeout logic, idle reaping, or stats on it.
This is OpenSSL implementation behaviour rather than anything QUIC guarantees,
so do not lean on it harder than you must. It matters only on this lower-level
path: a Quic\poll() loop is unaffected either way, because poll() pumps
every object in the item set individually rather than relying on one of them to
cover the others.
Inspecting the TLS session and peer certificate
$conn = new Quic\Connection('cloudflare-quic.com', 443, ['alpn' => 'h3']); $info = $conn->getCryptoInfo(); // ['protocol' => 'QUICv1', 'cipher_name' => 'TLS_AES_256_GCM_SHA384', // 'cipher_bits' => 256, 'cipher_version' => 'TLSv1.3', 'alpn_protocol' => 'h3'] // bridge the PEM leaf certificate to ext-openssl $cert = openssl_x509_read($conn->getPeerCertificate()); $meta = openssl_x509_parse($cert); echo $meta['subject']['CN'], "\n"; // prints "ok" on success echo "verify: ", $conn->getVerifyResultString(), "\n";
Certificate pinning
$conn = new Quic\Connection($host, $port, [ 'alpn' => 'myproto', // throws Quic\Exception on mismatch 'peer_fingerprint' => ['sha256' => 'aabbcc...'], ]);
Scaling
A single Quic\poll() loop is one PHP thread, which is one CPU core. You
cannot fork or thread per connection (every connection is multiplexed over the
listener's one UDP socket), so you scale horizontally with SO_REUSEPORT:
run one process per core, each new Quic\Listener(host, port, [..., 'reuse_port' => true]) on the same port, and the kernel load-balances inbound datagrams.
OpenSSL's native QUIC does not do connection migration, which keeps the 4-tuple
stable and makes plain SO_REUSEPORT hashing work well without eBPF in most
deployments.
Measured locally (single-process vs two-process reuse_port, localhost,
RSA-2048 cert; client and server contend for the same cores, so this is a
floor):
| mode | 1 proc (no reuse_port) | 2 procs (reuse_port) |
|---|---|---|
| new connection + request | ~60 tx/s | ~85 to 100 tx/s |
| request on persistent conn | ~5k to 7k tx/s | ~6k to 9k tx/s |
reuse_port gives roughly 1.3x to 1.7x on this 2-core box; on a many-core host
with load from separate machines, new-connection throughput scales roughly
linearly with cores. The OpenSSL userspace QUIC data path (no UDP GSO offload)
is the per-core ceiling, and per-event work must stay non-blocking or it stalls
the whole loop.
There are no built-in caps on accepted connections or streams, and read($len)
allocates up to $len, so apply your own backpressure (bound the accept loop,
cap $len) for untrusted peers.
Limitations
The OpenSSL native QUIC stack is streams-only. This extension therefore does not support:
- RFC 9221 unreliable datagrams (not implemented in OpenSSL through 4.0)
- 0-RTT / TLS 1.3 early data
- connection migration
None of these are needed for HTTP/3, DoQ, or typical request/response protocols. If you require QUIC datagrams, you need a different engine (ngtcp2, quiche, or msquic).
There is also no server-side idle-timeout control: OpenSSL 3.5 scopes the
max-idle-timeout transport parameter to a connection and requires it before the
handshake, but accepted connections come off the listener already handshaked,
and there is no listener/CTX-level transport-parameter hook. So Listener has
no idle_timeout_ms; the client-side idle_timeout_ms and connect_timeout_ms
options are unaffected.
Per-stream memory on long-lived connections
OpenSSL's QUIC stack retains roughly 300 bytes of per-stream state on a
connection until the connection is closed, even after the stream is fully
concluded and SSL_freed (measured at about 308 bytes/stream, not reclaimed by
pumping events, and valgrind-clean, meaning it is freed at connection teardown
rather than being a classic leak). So a single connection that opens a very
large number of streams (for example a persistent HTTP/3 or
DoQ-over-one-connection client doing millions of requests) grows over time. The
mitigation is to recycle the connection periodically. The common
new-connection-per-request pattern is unaffected: its memory is flat across
hundreds of thousands of connections in soak testing. This is an upstream
OpenSSL characteristic, not specific to this extension.
License
BSD-3-Clause. See LICENSE.