Skip to content

Releases: true-async/server

v0.9.0 — WebSocket (RFC 6455 / 8441 / 7692)

Choose a tag to compare

@EdmondDantes EdmondDantes released this 01 Jul 08:33

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 / WebSocketUpgrade classes, WebSocketCloseCode enum, exception hierarchy.
  • Pull API — recv() and foreach ($ws as $msg) (WebSocket is an Iterator); graceful close ends the loop, error close throws WebSocketClosedException with $closeCode / $closeReason.
  • Multi-producer send() / sendBinary() + non-blocking trySend() / 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

Choose a tag to compare

@EdmondDantes EdmondDantes released this 28 Jun 11:06

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 next uv_write() fail at submit, leaving an Async\AsyncException
    ("Failed to start stream write: broken pipe") in EG(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, catchable HttpException (499 "stream closed by
    peer"). New phpt 025-h1-sse-client-disconnect reproduces 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 failed ZEND_ASYNC_IO_READ submit left its exception in EG(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

Choose a tag to compare

@EdmondDantes EdmondDantes released this 27 Jun 06:21

Added

  • Server-Sent Events API (#3). First-class text/event-stream helpers on
    HttpResponsesseStart(), sseEvent($data, $event, $id, $retry),
    sseComment() and sseRetry() — 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: multiline data is split per
    line, single-line fields reject CR/LF and id rejects 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
    stream GET <path> returns the file bytes + FIN from setHttp3HqDocroot().
    Lets the quic-interop-runner reach the server for the whole transport matrix
    (transfer/multiplexing/migration/loss), which it negotiates over hq, not h3.
    h3 stays 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 empty 200 on H2/H3 (#3).
    Starting an event stream and closing it before any sseEvent()/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 empty text/event-stream. mark_ended now
    commits the empty streaming response on all three protocols.

  • SSE: mixing send() and the sse* helpers now throws (#3). A response is
    either a plain send() stream or an SSE stream; crossing over silently shipped
    wrong-Content-Type (and possibly gzip-wrapped) event records. Each side now
    raises HttpServerRuntimeException once the other has committed the stream.

  • Windows: TCP listeners now bind. The server failed to start on Windows
    with Async\AsyncException: Failed to bind to <host>:<port>: operation not supported on socket. The listener requested SO_REUSEPORT, which libuv's
    uv_tcp_bind() rejects with UV_ENOTSUP on 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: StaticHandler accepts native absolute paths. Root-directory
    validation only accepted a leading /, rejecting every Windows path
    (C:\...) and making StaticHandler unusable 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 without O_BINARY, so Windows text-mode translation
    could corrupt or truncate binary bodies (precompressed .br/.gz, byte
    ranges, images). It now opens with O_BINARY, matching the policy open path.

v0.7.2 — optional per-request scope

Choose a tag to compare

@EdmondDantes EdmondDantes released this 02 Jun 15:28

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 across setWorkers(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 with Async\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

Choose a tag to compare

@EdmondDantes EdmondDantes released this 01 Jun 21:50
0f0b8fa

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 at initial_max_streams_bidi
    (default 100). After 100 requests a client could not open another stream and the
    connection stalled — HttpArena baseline-h3 / static-h3 at c=64 collapsed to
    ~1277 req/s (≈20 req/s per connection) with the server otherwise idle. ngtcp2 does
    not auto-send MAX_STREAMS on close; the application must. The fix calls
    ngtcp2_conn_extend_max_streams_bidi(conn, 1) for each client-initiated bidi
    stream (id & 3 == 0) in stream_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

Choose a tag to compare

@EdmondDantes EdmondDantes released this 01 Jun 12:09

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 with awaitBody, streaming send(),
    HEAD, sendFile() delivery, and addStaticHandler mount 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 via UDP_GRO; UDP socket buffers enlarged.
  • HTTP/1 conformance hardening: Date header, HEAD sends no body, reject
    CONNECT and asterisk-form targets, validate Host, reject empty
    Transfer-Encoding, reject fragment/backslash in request-target, reject
    duplicate Content-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_scope is
    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, and arm_timer NULL-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

Choose a tag to compare

@EdmondDantes EdmondDantes released this 27 May 15:55
d41c450

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 for http_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_file engine open() uses O_NOFOLLOW on REJECT-mount so a symlink swapped in after the open-file-cache TTL still 404s (C2, new phpt static/021).
  • DS2 assert on http2_emit_record_t.body.len bound.
  • License headers added to compression / http3 / core TUs that were missing them.

Tests

  • 034-config-tls-and-log.phpt: drop the deprecated curl_close($ch) call (no-op since PHP 8.0; emits Deprecated on 8.5+). This and the previously baseline-fail 044 are 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

Choose a tag to compare

@EdmondDantes EdmondDantes released this 20 May 07:22

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 inside llhttp_execute (the dispatch callback fires from on_headers_complete), causing a use-after-free SIGSEGV in on_message_complete. Connection teardown now defers (in_parser_feed guard) while a parser feed is on the stack and is finalised once the feed unwinds.
  • Stranded Async\AsyncException on I/O write submit failure. Fire-and-forget write submit failures (broken pipe / connection reset) left an exception in EG(exception) with no coroutine to receive it; it then aborted an unrelated ZEND_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

Choose a tag to compare

@EdmondDantes EdmondDantes released this 19 May 20:04

Added

  • One-shot brotli compress with BROTLI_PARAM_SIZE_HINT (Step 4 of perf TODO). apply_buffered uses the stateless one-pass BrotliEncoderCompress() 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 slots compress_oneshot + max_compressed_size; streaming path stays for chunked / unknown-length responses. Closes the brotli encode gap vs Swoole's BrotliEncoderCompress-based path. C-side defaults stay production-typical (gzip 6, brotli 4); bench callers set setCompressionLevel(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: close request header now produces Connection: close in the response (RFC 9112 §9.6). The parser already flipped req->keep_alive = false and 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 hit ECONNRESET — 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 common gzip, br Accept-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's deflateReset path 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

Choose a tag to compare

@EdmondDantes EdmondDantes released this 19 May 10:31

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