Releases: true-async/server
Release list
v0.9.0 — WebSocket (RFC 6455 / 8441 / 7692)
First-class WebSocket support — full-duplex over HTTP/1.1 Upgrade, wss:// (TLS), and HTTP/2 Extended CONNECT (RFC 8441), with permessage-deflate (RFC 7692). 246/246 Autobahn|Testsuite conformance on behavior, wired into CI.
Added
- WebSocket API:
HttpServer::addWebSocketHandler();WebSocket/WebSocketMessage/WebSocketUpgradeclasses,WebSocketCloseCodeenum, exception hierarchy. - Pull API —
recv()andforeach ($ws as $msg)(WebSocketis anIterator); graceful close ends the loop, error close throwsWebSocketClosedExceptionwith$closeCode/$closeReason. - Multi-producer
send()/sendBinary()+ non-blockingtrySend()/trySendBinary()with backpressure. - Keepalive ping (
ws_ping_interval_ms) + pong deadline (ws_pong_timeout_ms→ close 1001). - Outbound auto-fragmentation on
ws_max_frame_size. - Autobahn conformance runner (
e2e/autobahn/, built from source in Docker) + wslay frame-ingress fuzzer.
Fixed
- UTF-8 fail-fast no longer lingers the socket (protocol-error teardown via
wslay_event_want_read). - Handshake-reject paths no longer leak the parsed request.
Performance
- One
write()per WebSocket frame (header+payload coalesced): −51% write syscalls, +43% echo throughput under load.
Full changelog: v0.8.1...v0.9.0
v0.8.1
Bug-fix release on top of 0.8.0.
Fixed
- SSE/streaming: a client that aborts mid-stream no longer crashes the server (#3).
A peer RST made the nextuv_write()fail at submit, leaving anAsync\AsyncException
("Failed to start stream write: broken pipe") inEG(exception). The awaiting send path
(http_connection_send_raw) returned failure without absorbing it — unlike the completion
path and the fire-and-forget writers — so it surfaced with no PHP frame (#0 {main}) as an
uncaught fatal that took down every connection. The submit-failure branch now absorbs it, so a
dead peer reaches the handler as the canonical, catchableHttpException(499 "stream closed by
peer"). New phpt025-h1-sse-client-disconnectreproduces the crash and asserts the 499. - H3 static-file pump now absorbs a read-submit failure too (#3). Same asymmetry on the
file-read side: a failedZEND_ASYNC_IO_READsubmit left its exception inEG(exception); the
pump now absorbs it, keeping error handling symmetric across the write and read submit paths.
Full changelog: v0.8.0...v0.8.1
v0.8.0 — SSE, hq-interop, HTTP/3 reactor pool, Windows fixes
Added
-
Server-Sent Events API (#3). First-class
text/event-streamhelpers on
HttpResponse—sseStart(),sseEvent($data, $event, $id, $retry),
sseComment()andsseRetry()— layered on the existing streaming pipeline,
so the same handler works over HTTP/1.1, HTTP/2 and HTTP/3.sseStart()sets
the canonical headers (Content-Type: text/event-stream,Cache-Control: no-cache, no-transform,X-Accel-Buffering: no) and marks the response
non-compressible. Framing follows WHATWG §9.2: multilinedatais split per
line, single-line fields reject CR/LF andidrejects NUL. phpt coverage for
H1/H2/H3 plus the validation surface. -
hq-interop (HTTP/0.9-over-QUIC) for the interop matrix (#80). A second QUIC
ALPN,hq-interop, served straight off the transport (no nghttp3): a raw bidi
streamGET <path>returns the file bytes + FIN fromsetHttp3HqDocroot().
Lets the quic-interop-runner reach the server for the whole transport matrix
(transfer/multiplexing/migration/loss), which it negotiates over hq, not h3.
h3stays preferred when a peer offers both; the h3 path is unchanged. -
HTTP/3 transport reactor pool (experimental, #80). Behind
TRUE_ASYNC_SERVER_REACTOR_POOL=1+setWorkers(2+): dedicated C reactors own the
QUIC sockets (no PHP on the transport thread), hand parsed requests to PHP workers
by pointer, and serve responses back over a non-blocking reverse channel; static
files are served on the reactor. Adds CID steering (owner-reactor id encoded in the
connection id, forwarding migrated clients across the split — #72) and a
migration-storm guard that sheds clients rebinding past a rate cap. Dispatch is
reactor-paired: a connection sticks to one of its reactor's workers and spills to a
less-loaded worker when its home backs up or dies. Off by default. -
Lock-free inter-thread message queue primitive (#81). Bounded MPSC/SPSC C-ABI
wrappers over moodycamel (thread_queue) plus a reactor-integrated MPSC mailbox
(thread_mailbox) that wakes the consumer's loop via a trigger event with
lost-wakeup-safe batch drain. Foundation for cross-worker HTTP/3 (#72) and
WebSocket (#2). Adds a C++ build dependency (libstdc++).
Fixed
-
SSE:
sseStart()with no event now commits an empty200on H2/H3 (#3).
Starting an event stream and closing it before anysseEvent()/sseComment()
left HTTP/2 and HTTP/3 without a HEADERS frame (the client saw a reset stream),
while HTTP/1.1 already sent a clean emptytext/event-stream.mark_endednow
commits the empty streaming response on all three protocols. -
SSE: mixing
send()and thesse*helpers now throws (#3). A response is
either a plainsend()stream or an SSE stream; crossing over silently shipped
wrong-Content-Type(and possibly gzip-wrapped) event records. Each side now
raisesHttpServerRuntimeExceptiononce the other has committed the stream. -
Windows: TCP listeners now bind. The server failed to start on Windows
withAsync\AsyncException: Failed to bind to <host>:<port>: operation not supported on socket. The listener requestedSO_REUSEPORT, which libuv's
uv_tcp_bind()rejects withUV_ENOTSUPon Windows (Winsock has no
SO_REUSEPORT). REUSEPORT is now treated as a platform capability and never
requested on Windows; the default single-listener server binds directly. No
change on Linux/BSD/macOS (#82). -
Windows:
StaticHandleraccepts native absolute paths. Root-directory
validation only accepted a leading/, rejecting every Windows path
(C:\...) and makingStaticHandlerunusable there. It now uses
IS_ABSOLUTE_PATH(drive-letter / UNC on Windows, leading/on POSIX). -
Windows: static file bodies are served binary-clean. The
send_file
engine opened files withoutO_BINARY, so Windows text-mode translation
could corrupt or truncate binary bodies (precompressed.br/.gz, byte
ranges, images). It now opens withO_BINARY, matching the policy open path.
v0.7.2 — optional per-request scope
Feature release: a new opt-in knob to drop the per-request async scope on hot paths.
Added
-
HttpServerConfig::setRequestScope(bool)/isRequestScope()— opt out of the
per-request child async scope (default on, behaviour unchanged). When off, each
H1/H2/H3 handler coroutine reuses the connection scope directly instead of minting a
fresh per-request child, saving two allocations (emalloc/efree) per request. The
setter is chainable and locks once the config is handed to a server.Disabling it means
Async\request_context()resolves to null for that request
(use the?->operator) — there is no per-request context subtree. Only disable it
for handlers that never rely on per-request context (e.g. throughput benchmarks). The
knob propagates correctly acrosssetWorkers(N > 1)via the cross-thread shared-config
snapshot.
Tests
server/core/049-request-scope-setter— default / toggle / chainable / locked-guard,
plus scope-OFF serving withAsync\request_context()asserted null.server/core/050-request-scope-workers— the knob is honoured on worker threads
(setWorkers(2)), guarding the shared-config propagation path.
Also folds in 0.7.1 (HTTP/3 bidi stream-credit fix, #79), which shipped tagged but
without a changelog entry.
v0.7.1 — HTTP/3 stream-credit fix
Patch release on top of 0.7.0 — a single focused fix to the HTTP/3 path.
Fixed
- HTTP/3 throughput collapse under sustained concurrency (#79).
stream_close_cb
closed each nghttp3 request stream but never replenished the QUIC bidi stream
credit, so every connection was permanently capped atinitial_max_streams_bidi
(default 100). After 100 requests a client could not open another stream and the
connection stalled — HttpArenabaseline-h3/static-h3at c=64 collapsed to
~1277 req/s (≈20 req/s per connection) with the server otherwise idle. ngtcp2 does
not auto-sendMAX_STREAMSon close; the application must. The fix calls
ngtcp2_conn_extend_max_streams_bidi(conn, 1)for each client-initiated bidi
stream (id & 3 == 0) instream_close_cb. A/B at c=64: 6400 done/30s →
60000 done/10s.
Tests
- New
036-h3-stream-credit-replenish— 150 request streams over one connection
(pre-fix stalls at 100, post-fix all 150 complete). Full H3 suite 36/36 green.
Full changelog: v0.7.0...v0.7.1
v0.7.0 — HTTP/3 over QUIC
Headline release: HTTP/3 over QUIC. Folds in everything tagged but not yet
documented since 0.6.7 (the 0.6.8 tag carried no changelog entry).
Added
- HTTP/3 / QUIC server (
HttpServerConfig::addHttp3Listener) — full request
lifecycle over QUIC: end-to-end GET/POST withawaitBody, streamingsend(),
HEAD,sendFile()delivery, andaddStaticHandlermount routing. Built on
ngtcp2 + nghttp3 + OpenSSL ≥ 3.5; auto-detected (--enable-http3/
--disable-http3). - HTTP/3 production controls: connection migration / NAT rebinding (RFC 9000 §9),
opt-in send pacing (setHttp3Pacing), per-peer connection budget with global
cap and explicit refusal, configurable UDP socket buffer
(setHttp3SocketBufferBytes), idle timeout, Alt-Svc advertisement, Retry token
source-address validation, version negotiation, and stateless reset. HttpServer::getHttp3Stats()— handshake / ALPN / nghttp3 / send-error counters.HttpServer::isHttp2()/isHttp3()compile-time capability probes.HttpServerConfig::setTlsBufferBytes— tunable TLS clear-text-out BIO ring (#29).- Shared-fd TCP listener path for workers on kernels without load-balancing
SO_REUSEPORT, selectable at runtime.
Changed
- HTTP/3 send path coalesces outbound datagrams to once-per-tick and splits
coalesced inbound datagrams viaUDP_GRO; UDP socket buffers enlarged. - HTTP/1 conformance hardening:
Dateheader, HEAD sends no body, reject
CONNECTand asterisk-form targets, validateHost, reject empty
Transfer-Encoding, reject fragment/backslash in request-target, reject
duplicateContent-Type. - HTTP/2 over TLS parks the emit remainder when the clear-text-out BIO ring fills
(backpressure instead of a write deadlock) (#29). HttpServer::start()now throws on listener bind failure instead of failing
silently.
Fixed
- Drain in-flight per-request coroutines on server shutdown so
server_scopeis
not disposed while handlers are still running (#74). - HTTP/3: dirty-list use-after-free on connection free, dispatched-stream slot
leak when a stream is rejected mid-awaitBody, andarm_timerNULL-ngtcp2_conn
guard. http_server: use-after-free of the wait event on non-stop teardown.- Windows MSVC build.
v0.6.6 — code audit + memory observability
Closes #37 (Code audit & refactoring) — Phases 1–6 rolled up.
Highlights
Refactor / cleanup
src/http_response.c(2173 lines) split into three TUs (S7):src/http_response.c— PHP class machinery only.src/http1/http1_format.c— HTTP/1.x wire formatters.src/http_response_server_api.c— server-side C-API used by static / h2 / compression paths.
- Dedup of repeated patterns across compression / h2 / parser / response code (X1–X14).
- Dead code & stale comment removal across Phase 2.
Observability
HttpServer::getRuntimeStats(): array— lock-free snapshot of the server's internal allocators:conn_arena(live / total / chunks / bytes) — slab pool forhttp_connection_t.body_pool[](per-class LIFO of large request bodies) +body_pool_total_bytes.- Pairs with
Async\runtime_stats()and (debug builds)zend_mm_dump_live_allocations()to attribute live RSS down to a concrete subsystem.
Correctness / hygiene
send_fileengineopen()usesO_NOFOLLOWon REJECT-mount so a symlink swapped in after the open-file-cache TTL still 404s (C2, new phptstatic/021).- DS2 assert on
http2_emit_record_t.body.lenbound. - License headers added to compression / http3 / core TUs that were missing them.
Tests
034-config-tls-and-log.phpt: drop the deprecatedcurl_close($ch)call (no-op since PHP 8.0; emits Deprecated on 8.5+). This and the previously baseline-fail044are now green: phpt 211/211.
Test plan
- phpt 211/211 PASS on PHP 8.6 dev.
- HttpArena validate: 57/0 PASS (true-async-server) · 43/0 PASS (symfony-spawn-tas, including
async-db). - h2load smoke (no docker overhead, c=64, 10s):
- baseline H1 · 287 k req/s
- baseline-h2 TLS · 158 k req/s
- baseline-h2c · 220 k req/s
- /json/1 · 300 k req/s
- /async-db?limit=10 · 38.6 k req/s
- Stress
/async-db(Symfony): c=256 m=20 / 60k req — 0 errors, RSS ≤ 114 MiB, no SEGV.
TODO file
A new Step 5 entry in TODO.md documents the Zend MM retention analysis and proposes a future setMaxRequestsPerWorker(N) knob for FPM-style worker recycle (RSS reclamation on long-running benches). No code in this release — design notes only.
v0.6.4
Fixed
- HTTP/1 pipelining crash under high connection count (HttpArena
pipelined/4096c). A handler-coroutine spawn failure destroyed the connection — freeing its llhttp parser — synchronously from insidellhttp_execute(the dispatch callback fires fromon_headers_complete), causing a use-after-free SIGSEGV inon_message_complete. Connection teardown now defers (in_parser_feedguard) while a parser feed is on the stack and is finalised once the feed unwinds. - Stranded
Async\AsyncExceptionon I/O write submit failure. Fire-and-forget write submit failures (broken pipe / connection reset) left an exception inEG(exception)with no coroutine to receive it; it then aborted an unrelatedZEND_ASYNC_NEW_COROUTINE— which is exactly what produced the spawn failure above. The batched-send paths now log and clear the exception at the submission site.
v0.6.3
Added
- One-shot brotli compress with
BROTLI_PARAM_SIZE_HINT(Step 4 of perf TODO).apply_buffereduses the stateless one-passBrotliEncoderCompress()when the body is fully known. The size hint lets the encoder right-size its ring buffer / hash tables for the actual payload instead of for arbitrary streaming. New optional vtable slotscompress_oneshot+max_compressed_size; streaming path stays for chunked / unknown-length responses. Closes the brotli encode gap vs Swoole'sBrotliEncoderCompress-based path. C-side defaults stay production-typical (gzip 6, brotli 4); bench callers setsetCompressionLevel(1)/setBrotliLevel(1)for Swoole-equivalent throughput. - Loud stderr logging on unexpected worker thread exits in
pool_worker_handler— covers uncaught$server->start()exceptions, clean returns while the await loop still expects workers, and server-transfer failure. Previously each case silently dropped 1/N of accept capacity with no operator signal.
Fixed
Connection: closerequest header now producesConnection: closein the response (RFC 9112 §9.6). The parser already flippedreq->keep_alive = falseand the dispose path closed the FD, but the missing response header left clients unable to tell the TCP was not reusable until the next write hitECONNRESET— wrk under-H 'Connection: close'counted every reply as a read error. Side effect on the local short-lived bench (wrk c=512 d=10s): 174k → 230k RPS, p50 14.5 ms → 2.5 ms, read-errors 2.0M → 0.
Changed
- Server-side codec preference order flipped to
zstd > gzip > brotli > identity. Clients sending the commongzip, brAccept-Encoding now get gzip — the brotli pool can't reuse encoder state (libbrotli has no public reset API), so until the arena-allocator follow-up lands, gzip'sdeflateResetpath is the better default. Clients that explicitly want brotli via q-values (br;q=1.0, gzip;q=0.5) still get it.
Bench delta vs Swoole (docker, /json/40, c=512, 5-run median, both with q=1)
| Accept-Encoding | TAS v0.6.3 | Swoole | Δ |
|---|---|---|---|
| br | 106k | 94k | +13% |
| gzip | 94k | 67k | +40% |
| gzip, br | 95k | 95k | parity |
v0.6.2 — H2 TLS hybrid emit selector
What's new
HTTP/2 over TLS now picks its emit path adaptively based on the in-flight body size — small responses take a low-overhead DRAIN path, large ones get amortised over a single `SSL_write_ex` via GATHER.
Hybrid emit selector (#30, #32)
Each HTTP/2 session pins a counter when it submits a response whose body exceeds 2 KiB (or whose total size is unknown — streaming). The emit pump picks per pass:
- DRAIN (counter == 0): `nghttp2_session_mem_send` into a 16 KiB stack buffer → `BIO_write` straight into the plaintext BIO → `tls_drain` encrypts. No `records[]` / `body_refs[]` allocation, no per-pass alloc churn. Wins on short responses where alloc / `zval_ptr_dtor` cost dominates.
- GATHER (counter > 0): drive nghttp2 via `session_send` + NO_COPY callbacks, fold frames into `records[]` (with `body_refs[]` keeping bodies alive), memcpy into stage[] and ship in one `SSL_write_ex`. Wins on bodies that fill at least one TLS record — amortises cipher setup; only one memcpy of the body instead of two.
Bench
Release PHP, h2 TLS, c=100 m=32, h2load -t 1, 10 s × N median.
| body | gather (old default) | drain | hybrid |
|---|---|---|---|
| dyn 3B | 162k | 235k | 243k |
| dyn 16K | 58k | 43k | 57k |
| dyn 64K | 18k | 11k | 18k |
| static 100B | 125k | 146k | 145k |
| static 16K | 55k | 40k | 61k |
| static 64K | 17k | 12k | 17k |
Override
Set `TRUE_ASYNC_H2_TLS_EMIT_MODE` to `drain`, `gather`, or `hybrid` (default) for A/B testing. Read once and cached.
Docs
`docs/H2_TLS_EMIT_STRATEGIES.md` walks through the three paths and the cross-over arithmetic.
Up next
Kernel TLS (kTLS) support is tracked in #31 on a separate branch.
Full diff: v0.6.1...v0.6.2