Skip to content

Releases: true-async/server

v0.9.2 — HttpServer::reload() hot reload + built-in triggers (#93)

Choose a tag to compare

@EdmondDantes EdmondDantes released this 03 Jul 19:59

Hot reload of the worker pool without dropping the listen sockets — the #93 epic.

Added

  • HttpServer::reload() — rotate the worker pool live, without dropping the listen sockets. An epoch beacon fans out to worker clones; each drains its in-flight requests, stops, and its replacement re-runs the bootloader so changed code is picked up. Reload is serialized (one rotation at a time) and its lifecycle is logged (reload.start / reload.done, per-worker server.stop reason=reload).
  • Built-in reload triggers
    • HttpServerConfig::enableHotReload(array $watchPaths, array $extensions = ['php'], int $debounceMs = 300, int $maxHoldMs = 2000) — dev trigger: a recursive Async\FileSystemWatcher per path; a debounced change invalidates the watched trees in opcache and calls reload().
    • HttpServerConfig::enableReloadOnSignal(bool $enabled = true) — prod trigger: a persistent SIGHUP handler that calls reload().
  • Reload under the reactor poolreload() now works with TRUE_ASYNC_SERVER_REACTOR_POOL=1 via a worker-inbox retirement protocol: the retiring slot is unpublished (admin mutex; picks stay lock-free), every reactor is fenced so no pre-retire inbox pointer survives, and a zero-crossing decrement wakes the worker to free its inbox; connections re-home on their next request and slots are reclaimed (#100).

Fixed

  • ~10 KB leaked per reload rotation (worker transit shell C-state + side-cars).
  • Heap corruption on rotation under the gated reactor pool — a dying worker clone tore down the global worker registry from the worker thread; the catch-all teardown is now parent-only.
  • macOS build: TSRM mutex instead of uv_mutex in the worker registry.

Changed

  • Test suite: kernel-allocated ports across every phpt — kills the port-collision flake class.
  • New Ctrl+C signal-delivery test harness for macOS/Linux (#94).

Note: v0.9.1 (static-build header fixes + htons/ntohs under strict C99) was tagged but never published as a GitHub release; its notes are folded into the 0.9.1 section of CHANGELOG.md.

Validation: server phpt 286/287 passing (1 XFAIL section whose test now passes); reactor-pool reload paths (045-h3-reload-reactor-pool, WS 034-hot-reload-ws, core 051053) green under TRUE_ASYNC_SERVER_REACTOR_POOL=1; laravel-spawn adapter suite 69/69.

Full changelog: https://github.com/true-async/server/blob/v0.9.2/CHANGELOG.md

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