Skip to content

CHANGELOG

All notable changes to aeronet are documented in this file.

Unreleased

Fixed

  • An absolute-form request target is routed by its path and takes its host from the target: GET http://example.com/onlyget HTTP/1.1 was routed with the path http://example.com/onlyget and answered 404. The path and query of the target are now the ones of the request, an empty path being /, and its authority, without userinfo, replaces the Host header field (RFC 9112 §3.2.2, §3.3): headerValueOrEmpty("host"), the HTTP to HTTPS redirect and the WebSocket origin check see it. HttpRequestView::authority() returns it, and is no longer limited to HTTP/2 builds. A target without authority, with an empty host, a fragment or several @ in its authority is rejected with 400. See Request target forms.
  • The name of the HTTP version is checked: only the length, digits and dot of a version were checked, so XTTP/1.1 or http/1.1 was accepted as HTTP/1.1 by the server, by the response parser of HttpClient and in the CONNECT response of a proxy (whose version was not checked at all). The version must now be HTTP/ digit . digit, case-sensitive (RFC 9112 §2.3): the server answers any other version token with 400, and HttpClient fails with malformedResponse (proxyError for a proxy). HttpClient also rejects a status line of another major version than 1, such as HTTP/2.0, and processes a higher minor version as HTTP/1.1 - keep-alive by default, as the server does (RFC 9110 §6.2). The same check applies to the TLS versions of the configuration (TLS1.3).
  • A chunked request body costs time linear in its size: each read of a body received in several reads decoded it again from its first chunk, so the time grew with the square of its size (128 MiB in 16 KiB writes took 15.7 s chunked, against 0.18 s with Content-Length), keeping the event-loop thread busy for minutes with the default maxBodyBytes. The decoding now resumes where the previous read stopped. A chunk making the body exceed maxBodyBytes (or DecompressionConfig::maxCompressedBytes) is rejected with 413 when announced, instead of once its data is received. See Chunked bodies and trailers.
  • A chunk-size or trailer line without end is bounded: it was buffered without limit (a 1; followed by 320 MiB without CRLF made the server grow by 200 MiB, the default maxBodyBytes not applying to it) and searched again by each read. A chunk-size line, CRLF included, longer than maxHeaderBytes is now rejected with 400, and trailers longer than maxHeaderBytes with 431, whatever the way they are received.
  • A body set on a 204 or 304 response is no longer sent: a handler returning HttpResponse(204, "body") produced content-length: 11 followed by the body, which the client read as the start of the next response, misreading every later response of the connection. A streaming handler answering 204 sent transfer-encoding: chunked and a final 0 chunk, with the same effect. 1xx, 204 and 304 responses now end at the empty line after their headers (RFC 9112 §6.3), without body, trailers, Content-Length, Transfer-Encoding, Content-Type nor Content-Encoding (RFC 9110 §8.6, RFC 9112 §6.1), over HTTP/1.1 and HTTP/2, for buffered and streaming handlers. See Statuses without content.
  • A crafted HPACK string length no longer crashes the server or the client: the check that a string lies within its header block was computed in 32 bits, so a length close to 2^32 wrapped around it. A Huffman-coded value of about 70 bytes made the decoder read far past the block, crashing the process (server and HttpClient). Such a block is now a COMPRESSION_ERROR.
  • The HPACK dynamic table of the encoder is capped by Http2Config::headerTableSize: the encoder took the SETTINGS_HEADER_TABLE_SIZE of the peer as is, so a peer advertising 4 GiB made the connection keep every indexed field it sent (a varying x-request-id, for instance). It now keeps at most the smaller of both values: browsers, advertising 65536, get 4096 bytes by default. It also started with headerTableSize instead of the 4096 bytes of the decoder of the peer (RFC 9113 §4.3.1): with a headerTableSize above 4096 and a peer not advertising its own, the encoder referred to entries the peer had evicted. A dynamic table size update is only sent when the table size changes.
  • HPACK integers beyond 32 bits and misplaced dynamic table size updates are decoding errors (COMPRESSION_ERROR): an integer was truncated to 32 bits (an index of 2^32 + 2 was decoded as :method: GET), and a dynamic table size update was accepted after a field, while RFC 7541 §4.2 only allows it at the beginning of a header block.
  • The HPACK dynamic table of the decoder is bounded by Http2Config::headerTableSize: a dynamic table size update was applied whatever its value, so a peer could raise the dynamic table of the server (or of HttpClient) to 4 GiB, then fill it with fields that each reset their stream (maxHeaderListSize does not bound the dynamic table, whose insertions are kept to stay in sync). An update above the advertised SETTINGS_HEADER_TABLE_SIZE is now a connection error (COMPRESSION_ERROR, RFC 7541 §6.3). A headerTableSize below 4096 applies once the peer acknowledges the SETTINGS (RFC 9113 §4.3.1): before, the requests sent by a client before receiving them could refer to entries already evicted, failing the connection with COMPRESSION_ERROR. See Flow control and memory.
  • A header block on a closed or half-closed HTTP/2 stream no longer fails the connection: a HEADERS frame on a stream that cannot receive one (a second header block after the request ended, for instance) was answered with RST_STREAM STREAM_CLOSED without decoding its header block. The HPACK dynamic table of the connection then lacked its fields, so the next request referring to them failed the whole connection with COMPRESSION_ERROR (server and HttpClient). The header block is now decoded before the stream is reset (RFC 9113 §4.3).
  • A stream window made negative by SETTINGS_INITIAL_WINDOW_SIZE no longer fails the HTTP/2 connection: a peer lowering its SETTINGS_INITIAL_WINDOW_SIZE below the bytes a stream already sent was answered with a GOAWAY (FLOW_CONTROL_ERROR), closing all the streams of the connection (server and HttpClient). RFC 9113 §6.9.2 allows this negative window: the stream now waits for enough WINDOW_UPDATE credit, and only a window above 2^31-1 is an error. An empty DATA frame ending a stream (closing a CONNECT tunnel, for instance) is sent even with a negative window (RFC 9113 §6.9.1).
  • A stream above maxConcurrentStreams is refused instead of failing the HTTP/2 connection: a HEADERS frame exceeding SETTINGS_MAX_CONCURRENT_STREAMS closed the whole connection with GOAWAY PROTOCOL_ERROR, while RFC 9113 §5.1.2 makes it a stream error. A client may open a stream before receiving the SETTINGS of the server, or reuse a slot it believes freed: the stream is now reset with REFUSED_STREAM, which tells the client that it can retry the request, and the other streams go on.
  • settingsTimeout, pingInterval, pingTimeout and maxStreamsPerConnection are applied by the HTTP/2 server: these Http2Config fields were documented, but ignored. A client not acknowledging the SETTINGS of the server within settingsTimeout now gets a GOAWAY SETTINGS_TIMEOUT. With pingInterval set, a connection whose streams are open but on which nothing happened for pingInterval is pinged, and closed when the PING is not acknowledged within pingTimeout: such a connection, never idle, escaped keepAliveTimeout even when its client had disappeared. The stream completing maxStreamsPerConnection sends a graceful GOAWAY, and the connection is closed once its streams are answered. The new streams of the client after a GOAWAY of the server are ignored, but their header blocks are now decoded: ignoring them without decoding made the next requests fail the connection with COMPRESSION_ERROR (HPACK), or with PROTOCOL_ERROR for a header block continued in CONTINUATION frames. A GOAWAY received from the client no longer makes the server ignore the header blocks of its streams: its last stream id refers to the streams of the server (RFC 9113 §6.8). See Connection lifetime.
  • A drain closes the HTTP/2 connections gracefully: beginDrain() (or a signal) only announced the close to HTTP/1.1 connections, with Connection: close. An HTTP/2 connection kept receiving new requests until the drain deadline, which then closed it while requests were in flight, and an idle one stayed open until its keepAliveTimeout, delaying the end of the drain. It now receives a graceful GOAWAY, serves its open streams, then closes. See Connection lifetime.
  • The 408 of an HTTP/2 stream whose per-route timeout elapsed is sent right away: queued by the periodic maintenance of the server, it waited for the next event of the connection, which an async handler waiting for its deferred work may never produce.
  • Late frames on a forgotten HTTP/2 stream no longer fail the connection: a closed stream is forgotten once 16 more streams closed after it. A HEADERS frame on it, such as the trailers a client sent before receiving the RST_STREAM of a request whose body was too large, was taken for a stream opened with a decreasing id, closing the connection with GOAWAY PROTOCOL_ERROR (or INTERNAL_ERROR for a stream forgotten between its HEADERS and CONTINUATION frames). It is now ignored, like any frame on a closed stream (RFC 9113 §5.1), its header block being decoded. HttpClient took the late response of a request it had cancelled for a new stream, which stayed active, reducing the concurrency of the connection, for the life of the connection.
  • Frames on idle HTTP/2 streams are connection errors (PROTOCOL_ERROR, RFC 9113 §5.1): a RST_STREAM (§6.4), DATA or WINDOW_UPDATE frame on a stream never opened was ignored. HttpClient also rejects a HEADERS frame on a stream it did not open: a server cannot open streams without pushing them. A HEADERS frame opening a stream below the last one opened remains a connection error, but one on a stream closed then forgotten is ignored (see above).
  • vcpkg port features are now applied: ports/aeronet/portfile.cmake compared uppercase names against vcpkg's lowercase feature list, so every optional feature (openssl, spdlog, brotli, zlib, zstd, opentelemetry, glaze) was always configured OFF. The features without a dependency (HTTP/2, WebSocket, ...) are no longer forced OFF by the port: they keep their default (ON), like with the Conan recipe. aeronetConfig.cmake now sets AERONET_WITH_OPENSSL and AERONET_WITH_HTTP2 for consumers, and only looks for OpenSSL when aeronet was built with it. The opentelemetry feature pulls the OTLP HTTP exporter of opentelemetry-cpp, and brotli uses the brotli package of vcpkg (unofficial-brotli) instead of an empty download.
  • OpenTelemetry builds with the default ABI of opentelemetry-cpp: aeronet only compiled with the ABI v2 of opentelemetry-cpp, an opt-in preview, the one of the opentelemetry-cpp that aeronet downloads. With an installed opentelemetry-cpp - vcpkg, Conan and system packages build the ABI v1 by default - the build failed. With the ABI v1, which has no synchronous gauge, gauge() and timing() are exported as up-down counters whose value is the last one recorded. See opentelemetry-cpp ABI.
  • The Conan package builds with the opentelemetry-cpp of Conan: Conan names the targets of the opentelemetry-cpp exporters opentelemetry-cpp::exporter_otlp_http (and so on), not opentelemetry-cpp::otlp_http_exporter like the CMake package of opentelemetry-cpp. aeronet did not recognize them, and built with an opentelemetry-cpp it downloaded (1.30, ABI v2), while the programs linking the package link the one of Conan (1.24, ABI v1). With vcpkg and Conan, an opentelemetry-cpp package without the OTLP HTTP exporter is now a configure error instead of a second opentelemetry-cpp. The Conan builds in Debug also used to download the dependencies instead of using the Conan packages: the Conan toolchain was not recognized. With with_zstd, the build needed a zstd installed on the system as well: aeronet copied the include directories of the zstd::libzstd target, an alias without any in the Conan package, instead of linking it.
  • find_package(aeronet) finds the packages of opentelemetry-cpp and brotli, and zlib-ng installing only zlib-ng::zlib: the installed aeronetConfig.cmake did not look for opentelemetry-cpp nor for a brotli package, which the exported targets reference, and only defined ZLIBNG::ZLIBNG for a zlib-ng::zlibstatic target, absent when zlib-ng installs a single library (vcpkg).
  • find_package(aeronet) works when amc was downloaded during the build: the installed aeronetConfig.cmake created the amc::amc stub target after loading aeronetTargets.cmake, which already references it, so configuring a consumer failed with "amc::amc missing". The stub is now created before the targets are loaded. vcpkg and Conan provide amc themselves and were not affected.
  • fullVersionStringView() / fullVersionWithRuntime() report the OpenTelemetry version and the compile-time features: new opentelemetry: <version> (or disabled) and features: http-server, http-client, http2, websocket, async-handlers (the enabled ones, or none) lines between logging and compression.

Breaking changes

  • location is rejected in HttpServerConfig::globalHeaders: HttpServerConfig::validate() now throws std::invalid_argument for it, like for the reserved headers. Its value is specific to each response, and a response made with makeResponse() starts with the global headers, so a trailing slash redirect carried two location headers, the global one first, which the clients follow. Set it on the responses that need it with HttpResponse::location().
  • 32-bit targets are rejected: CMake stops at configure time (CMAKE_SIZEOF_VOID_P other than 8) and aeronet/compiler-config.hpp has a static_assert on sizeof(void*) and sizeof(std::size_t). The vcpkg port is only supported on x64 and arm64, and the Conan recipe rejects the 32-bit arch settings. 32-bit targets never were in the CI and did not work in practice (a 32-bit std::size_t truncates 64-bit sizes and lengths, and the vendored hash maps crash). See 64-bit targets only.
  • WebSocketConfig::maxMessageSize and maxFrameSize no longer accept 0: 0 used to mean unlimited (for maxFrameSize, undocumented). WebSocketConfig::validate() now throws std::invalid_argument for it, like the other limits of the library. Set std::numeric_limits<std::size_t>::max() to approximate no limit. WebSocketConfig moved to aeronet/websocket-config.hpp (still included by websocket-handler.hpp). See Configuration.

Improvements

  • New AERONET_ENABLE_PROTOBUF CMake option for Protocol Buffers support, ON by default when aeronet is the top-level project like the other optional dependencies: turn it OFF when protobuf is not installed, as it is never fetched. The support is header-only (the aeronet libraries do not use protobuf), and find_package(aeronet) finds protobuf for the consumers. AERONET_PROTOBUF_RUNTIME selects the full (default) or lite runtime. protobufEnabled() (aeronet/features.hpp) and a new protobuf: <version> line of fullVersionStringView() report it. Conan option with_protobuf, vcpkg feature protobuf. See Build configuration.

[1.7.0] - 2026-10-26

1.7.0 Highlights

The most important changes. Upgrade especially if one of these applies to you:

  • Cross-site WebSocket hijacking: upgrades bypassed request middleware and CORS and accepted any Origin. They now go through the middleware and check the origin.
  • Secrets served by the static file handler: /.env, /.git/config were served, and symbolic links were followed outside of the root directory.
  • HTTP/2 memory exhaustion: a 4.5 KB HPACK header block could make the decoder allocate 2.9 GB, and request bodies were only bounded once fully received.
  • Credentials leaked by the HTTP client: redirects to another origin kept Authorization / Cookie, and HTTPS to HTTP redirects were followed.
  • Server stalls and crashes: CONNECT name resolution and OpenTelemetry span export blocked the event loop, a connection arriving while the server stops could crash it, and a write on a connection reset by the peer could raise SIGPIPE and terminate the process.
  • TLS: intermediate certificates were not sent, mTLS sessions never resumed, and an OpenSSL error left by a handler (a forged JWT signature, for instance) could close healthy connections.
  • Use-after-free and data races: HPACK table eviction, async handlers and deferWork() outliving their connection, router or buffers, MSG_ZEROCOPY buffers, default OCSP response with SNI certificates, and many races on stats(), callbacks and the server lifecycle. The whole test suite now runs clean under ThreadSanitizer (AERONET_ENABLE_TSAN).
  • HTTP/1.1 requests received in several reads were parsed again on each read (400 with duplicate headers or a query string).
  • Performance: a large round of HTTP/1.1, HTTP/2, TLS, WebSocket and telemetry work (see Improvements > Performance), and much smaller HttpResponse / HttpRequest objects.

1.7.0 Breaking changes

Behavior

  • Static files: hidden files and links leaving the root are no longer served: with showHiddenFiles off (the default), a path with a segment starting with a dot (/.env, /.git/config) is answered 404, except .well-known (RFC 8615). A file or directory reached through a link leading outside of the root, or whose resolved path is hidden (a link to a dotfile, a Windows short name such as /GIT~1/config), is answered 404 too. The path is resolved once per file and verified against the opened file (so that a link retargeted in between cannot get an outside file served), then remembered in the header cache. Only regular files and directories are served (a FIFO could block the event loop). Before, showHiddenFiles only hid dotfiles from directory listings. See Static files.
  • WebSocket upgrades go through the request middleware and check the Origin: the upgrade bypassed the request middleware and the CORS policies, letting any website open a connection authenticated by the user's cookies (cross-site WebSocket hijacking). The origin is now checked by the CORS policy of the route when it has one, otherwise only same-origin browsers (and clients sending no Origin) may connect; others receive 403. A factory returning nullptr refuses the upgrade (403), an exception escaping it is answered 500. Behind a reverse proxy that rewrites Host (nginx sets it to the upstream address by default), every browser upgrade is refused: give such routes a CORS policy listing the allowed origins, or have the proxy forward the browser Host. See WebSocket upgrade handshake.
  • WebSocket connections have their own idle timeout: HttpServerConfig::keepAliveTimeout (5 s by default) used to close idle WebSocket connections. They now use the new WebSocketConfig::idleTimeout (60 s), and the server pings an idle connection after half of it (autoPing). See Idle connections.
  • The 500 answering a throwing handler has no body: it contained the exception message, which could reveal internal details. The message is logged only.
  • HTTP client: redirects keep credentials within their origin and never downgrade to cleartext: Authorization and Cookie (including those of globalHeaders) are removed when the origin changes, and the 3xx response is returned instead of following a redirect to cleartext HTTP. Over HTTP/2, the client no longer adds its globalHeaders to each request it sends: like over HTTP/1.1, a request carries its own headers (which include the global ones when made by makeRequest()), so a global header removed from the request is not sent, and a request made by another client is not completed with the global headers of this one. See Redirects.
  • Accept-Encoding: *;q=0 without identity is answered 406 (RFC 9110 §12.5.3): the wildcard also excludes the uncompressed form when identity is not listed.
  • HTTP/2 requests whose content-length does not match their body are reset (RFC 9113 §8.1.1): a non numeric content-length, one that differs from the total length of the DATA frames, or not 0 on a request ending with its HEADERS frame, resets the stream with PROTOCOL_ERROR (as soon as the excess arrives). A content-length above maxBodyBytes is still answered 413. See Request validation.
  • maxDecompressedBytes bounds the whole decoded body, and exceeding it is answered 413 (was 400): a body decoded in streaming mode or sent chunked was only bounded piece by piece, so a larger decoded body reached the handler (or was returned by HttpClient), and decoding into a reused buffer could exceed the limit by the room it already had.
  • Received Content-Encoding values are lower-cased (content codings are case-insensitive, RFC 9110 §8.4.1): handlers, and client responses kept undecoded, read gzip for a received GZip. See Request body decompression.
  • HTTP/2 streams start with a 1 MiB receive window: Http2Config::initialWindowSize defaults to 1 MiB instead of 65,535 bytes (like hyper and Go), for the server and HttpClient. The default window capped a single upload or download to about 64 KiB per round trip (1.3 MB/s with a 50 ms RTT). A CONNECT tunnel stream may now hold up to 1 MiB for a slow target. withInitialWindowSize(65535) restores the previous behavior. See HTTP/2 configuration.
  • Read budget and read size semantics: maxPerEventReadBytes is now the budget of bytes read plus written per readable event of a connection, and minReadChunkBytes is the initial read size of a connection (it adapts afterwards, see Performance). Before, only read bytes counted, so a client sending small requests for large responses (several HTTP/2 streams on large files, or pipelined HTTP/1.1 requests, especially over TLS where records are read one by one) kept its connection served for gigabytes of responses while the others waited. Pipelined HTTP/1.1 requests read at once now yield once their responses reach the budget.
  • HttpClient with keepAlive = false now sends Connection: close: the option only dropped the connection after the response, while HTTP/1.1 requests still announced a persistent one (RFC 9112 §9.6). The server kept the connection open and the client closed first, holding TIME_WAIT sockets (about 14k new connections/s at most on loopback). When the request has no Connection header of its own, the option is spliced into the head written on the wire only, so the request is neither modified nor copied and stays valid for HTTP/2.

TLS

  • TLS handshakes time out after 10 seconds by default: TLSConfig::handshakeTimeout was disabled, so a client trickling handshake bytes could hold a connection indefinitely. Set it to 0 to restore the previous behavior. See Handshake protection.
  • TLS sessions only resume with session tickets: with tickets disabled (the default), OpenSSL still resumed sessions through its server-side cache (up to 20,480 sessions per worker and per SNI certificate, each holding the client certificate with mTLS). The cache is now off. With tickets enabled, a ticket is accepted for sessionTickets.lifetime (24 hours by default, instead of 2) and a ticket encrypted with an older key is renewed. See Session tickets.
  • Stricter TLS configuration validation: TLSConfig::validate() (so HttpServerConfig::validate() and server construction) rejects a certificate and key given both as files and as in-memory PEM, a cipher list combined with a cipher policy other than Default, a maximum TLS version below the minimum, and an SNI pattern mapped twice. HttpServerConfig::validate() rejects an ALPN list offering "h2" when HTTP/2 is disabled or not built. HttpServerConfig::withTlsCertKey() now clears the in-memory PEM credentials, like withTlsCertKeyMemory() clears the files.
  • alpnMustMatch also rejects a client offering no ALPN protocol: it fails the handshake (no_application_protocol alert) and counts in tlsAlpnStrictMismatches.
  • Malformed TLS credentials throw std::runtime_error with the OpenSSL reason (instead of std::bad_alloc), and an encrypted private key is rejected instead of OpenSSL prompting for its passphrase on the terminal (which blocked the event loop during a reload).
  • TLS Legacy cipher policy extends Compatibility: it only allowed four RSA CBC ciphers for TLS 1.2 (no TLS 1.2 at all with an ECDSA certificate) and dropped ChaCha20-Poly1305 for TLS 1.3. The policies now share the TLS 1.3 suites, each extends the previous one for ECDSA and RSA certificates alike (Compatibility gains the ECDSA CBC SHA-2 ciphers), and AEAD ciphers come first.
  • HTTP client: unset tlsMinVersion means TLS 1.2, like on the server, instead of no minimum.

API

  • DogStatsD metrics and TelemetryContext counters are sent at flush(): a DogStatsD client buffers its metrics, and a TelemetryContext sums its counters in memory, until flush(). The server and HttpClient flush their own telemetry (custom metrics sent through TelemetryContext::dogstatsdClient() included). A DogStatsD client or TelemetryContext of your own must call flush() periodically (their destructors flush too). The DogStatsD namespace is limited to 255 characters with its trailing dot. See Batching and flush.
  • RequestTask coroutines only accept the awaitables they support: co_await only compiles for aeronet awaitables (bodyAwaitable(), readBodyAsync(), deferWork()), std::suspend_always and std::suspend_never (new RequestTaskAwaitable concept). The server resumes right away a coroutine suspended by an unknown awaitable, so one resuming the coroutine by itself (from another thread, for instance) resumed it twice. A custom awaitable that never suspends, or resumes before returning from await_suspend(), can declare using AeronetAwaitableTag = void;. Asynchronous work should use deferWork().
  • HTTP client: precise errors for oversized and truncated responses: a response exceeding maxResponseBytes or the decompression limits now fails with the new HttpClientErrc::responseTooLarge, and an HTTP/1.1 response cut short by the server closing the connection with connectionClosed (as over HTTP/2). malformedResponse is left to responses that cannot be parsed or decoded (its ErrcToStr() becomes malformed response). See Error model.
  • HttpServerConfig::connectAllowlist is a public field: replace config.connectAllowlist() by config.connectAllowlist (a ConcatenatedStrings32, like globalHeaders), which makes HttpServerConfig an aggregate that Glaze reflects member by member. withConnectAllowlist() and the configuration file key are unchanged.
  • MultipartFormData::Part::filename and contentType are std::string_view instead of std::optional<std::string_view>. Use Part::hasFilename() to know whether the filename field was set but empty.
  • Glaze metadata of the configuration objects moved to companion headers (<aeronet/tls-config-glaze.hpp>, <aeronet/http-server-config-glaze.hpp>...), so that files including a configuration header no longer compile Glaze (translation units including Glaze: 149 to 24 in a Debug build with all features, compiler front-end time -19%, full rebuild -16%). Code serializing a configuration object with Glaze itself must include <aeronet/aeronet-config.hpp> (server, router and static file objects) or <aeronet/http-client-config-glaze.hpp>. The umbrella headers include them; configuration files, loaders and dumpConfig() are unchanged. See Serialize configuration objects yourself.
  • Module and header reorganization: ConnectionState, the request pipeline, the HTTPS redirect and Http2ProtocolHandler moved to aeronet_server, and http-headers-view.hpp, http-method.hpp and mergeable-headers.hpp to aeronet_objects (include paths unchanged). aeronet_http no longer depends on aeronet_tls nor publicly on aeronet_websocket, and aeronet_http2 is now the HTTP/2 protocol core (HPACK, frames, connection, streams) without aeronet_http. A target linking aeronet_http or aeronet_http2 without aeronet_server must link aeronet_tls / aeronet_websocket itself to use them, and code using WebSocketEndpoint must include <aeronet/websocket-endpoint.hpp>. <aeronet/aeronet-server.hpp>, <aeronet/aeronet.hpp> and <aeronet/single-http-server.hpp> no longer include the HTTP/2 internals (http2-connection.hpp, http2-frame.hpp, http2-frame-types.hpp, http2-stream.hpp, http2-protocol-handler.hpp): include them for the low-level HTTP/2 API. Editing an HTTP/2 or WebSocket header now recompiles about a third of the files it did.
  • Low-level API changes:
  • Transports: Transport(fd), PlainTransport(fd) and TlsTransport(ssl) replace the constructors taking a ZerocopyMode and a minimum size, and Transport no longer exposes the zerocopy state (isZerocopyEnabled(), hasZerocopyPending(), pollZerocopyCompletions(), disableZerocopy()): the server decides each MSG_ZEROCOPY write itself. maxZerocopyPendingBytes no longer disables zerocopy for the rest of a connection: writes beyond it are copied until completions release retained responses.
  • HPACK: HpackDecoder::decode() returns a DecodedHeaders (pseudo-header fields apart from the regular ones, pseudoHeader(PseudoHeader::Path)...), received non-const by EventSink::onHeadersDecoded() so that the sink can take its map and storage over. The HpackDecoder constructor takes the header list size limit, enforced by decode() (new HeaderListTooLarge error).
  • HTTP/2 frames: FrameHeader exposes length(), type(), flags(), streamId() and is built by its constructor (isValid() removed). DataFrame and HeadersFrame no longer copy the frame flags nor the padding length, ParseHeadersFrame() returns the stream dependency apart, HeadersFrame::headerBlockFragment() is a method.
  • IProtocolHandler::processInput() loses its ConnectionState& parameter, and the unused ProtocolHandlerFactory alias is removed.

1.7.0 Bug Fixes

HTTP/1.1 parsing, pipelining and connection close

  • Requests received in several reads are parsed correctly: the head of a request was parsed again on each read until the whole request was received, although parsing modifies it in place (decoded request-target, merged duplicate headers). A request whose head or body arrived in several reads was thus answered 400 when it had duplicate headers or (since 1.6.0) a query string, and before 1.6.0 its request-target was decoded twice (%2561 read as a). A head is now parsed once, an incomplete head is resumed at its first line not parsed yet, and a request waiting for its body is not parsed again. As a result, such a request is counted once (totalRequestsServed, maxRequestsPerConnection) and 100 Continue (interim responses of the ExpectationHandler included) is sent once.
  • A final response to an expectation no longer stalls the requests pipelined behind it, nor reads a body as a request: requests already received behind the answered one are served right away. When the answered request announces a body (Content-Length other than 0, or Transfer-Encoding), the response carries Connection: close and the connection closes, instead of parsing that body as the next request. See Expect.
  • Input left behind a blocked output is always served: when the output of a connection blocked, the pipelined HTTP/1.1 requests (and the HTTP/2 frames above the output high-water mark) were only resumed by the next writable event. When the output was drained elsewhere (before reading new input, or by the periodic retry of a missed writable event), they stayed unserved until the client sent more data or the keep-alive timeout expired.
  • A response closing an HTTP/1.1 connection before the end of the request reaches the client: answering before reading the body (413, an error status, the HTTPS redirect, a middleware answering an async route) or with requests pipelined behind closed the connection with unread input, which resets it and could destroy the response before the client read it. As recommended by RFC 9112 §9.6, the server now shuts down the write side (after a TLS close_notify), then discards input until the client closes, at most for the new HttpServerConfig::lingeringCloseTimeout (5 s by default, 0 restores the immediate close). See Lingering close.
  • Streamed HTTP/1.1 responses announce every close of the connection: their head only carried Connection: close when the client asked for it, although RFC 9112 §9.6 asks for it whenever the server closes (keep-alive disabled, maxRequestsPerConnection, drain or stop). They now take the same decision as buffered responses when their head is sent (only a drain beginning afterwards closes unannounced), and no longer close one request before the maxRequestsPerConnection limit.
  • A streaming handler throwing is answered 500, or its response is signaled incomplete: over HTTP/2 the response used to be completed with a 200, over HTTP/1.1 a whole 500 was written after the head and body bytes already sent. The 500 is now sent when nothing was sent yet; otherwise the HTTP/1.1 connection is closed without completing the body and the HTTP/2 stream reset (INTERNAL_ERROR). The same applies to a streamed response whose compression fails after its head (allocation failure in zstd or brotli, failed send over HTTP/2): the connection used to stay open after the truncated body.

CONNECT

  • Target names are resolved off the event loop: getaddrinfo ran on the event-loop thread, blocking all connections until the DNS server answered. Host names are now resolved on background threads (at most 4 at a time per server thread) and the CONNECT request is answered once resolved; IP addresses still connect right away. At most 256 CONNECT requests per server thread wait for resolution (the next ones get 503), each at most keepAliveTimeout (then 504), and an HTTP/2 stream waiting for it gets no flow-control credit. See Name resolution.
  • The 200 response to an HTTP/1.1 CONNECT has no content: it carried a Connection Established body with a Content-Length, which clients ignore (RFC 9110 §9.3.6) and read as the first tunneled bytes, breaking TLS handshakes through the tunnel.
  • CONNECT to an IPv6 address works: [::1]:443 was answered 400 over HTTP/1.1 and 502 over HTTP/2 (brackets were passed to the resolver).
  • CONNECT tunnels no longer buffer without limit: a client that stopped reading a download (or a target slower than an upload) made the server hold the whole flow in memory, and over HTTP/2 the stream window was replenished whatever the target took. The server now stops reading a side while maxOutboundBufferBytes wait for the other, and grants no credit to an HTTP/2 tunnel stream while bytes wait for its target. See Backpressure.

HTTP/2

  • HPACK: no more read of freed memory when a new field takes the name of the entry it evicts: since 1.5.0, an entry added to a full dynamic table reuses the buffer of the oldest one it evicts. A literal field with incremental indexing whose name refers to that oldest entry (a peer can send it at will) read its name from the buffer after reallocation, so the new entry and the next fields referring to it got garbage as their name.
  • An HTTP/2 header block can no longer exhaust the memory of the server or the client: the HPACK decoder stored every field before checking Http2Config::maxHeaderListSize and joined repeated values by copying them all again, so a 4.5 KB block (a 4 KB field added to the dynamic table, then referenced byte after byte) made it allocate 2.9 GB in about a second. The limit is now enforced while decoding (fields beyond it are decoded to keep the table in sync but not stored, and the stream is reset with ENHANCE_YOUR_CALM as before) and repeated fields are joined once, in a single copy. The same block now takes 17 KB.
  • Request bodies are bounded while received: maxBodyBytes was only checked once the whole body was received, so a client could stream an endless body. A body exceeding it, or a content-length declaring more, is answered 413 right away, then the stream is reset with NO_ERROR. The connection-level credit of DATA frames discarded on a reset stream is returned too.
  • File transfers are bounded in memory: a file (HttpResponse::file(), static files) was read as far as the flow-control windows allowed, so a client granting a large window (h2load grants 1 GiB) made the server load the whole file. Files are now read ahead only while the output retained by the connection is under half of maxOutboundBufferBytes (2 MiB by default), shared by the file transfers of a connection. See Flow control and memory.
  • Repeated request fields are combined like over HTTP/1.1: two authorization (or content-type, range...) fields, whose last occurrence wins over HTTP/1.1, were read as their two values joined by the letter O, and an empty occurrence added a leading or trailing separator. See Header names.
  • Credentials are never indexed (CRIME): the HPACK encoder (server responses and HttpClient requests) added authorization and cookie values to its dynamic table, letting an attacker able to add guesses to a client's requests recognize the right one by the compressed size. authorization, proxy-authorization and cookie values shorter than 20 bytes are now sent as never-indexed literals (RFC 7541 §7.1.3), as nghttp2 does. See HTTP/2.
  • HTTP/2 requests go through the same request pipeline as HTTP/1.1: the HTTP/2 handler had its own drifted copy of the steps following request parsing. Fixed as a result: cleartext HTTP/2 clients (prior knowledge) were served without the withHttpsRedirect() redirect; a request from an origin denied by the route CORS policy ran its handler (side effects included) before the 403; trailing slash redirects were answered 404; the middleware metrics callback never fired; request middleware did not see path and query parameters; short-circuiting middleware, a throwing handler's 500 and streaming responses skipped the response middleware; streaming responses lacked CORS headers; 405 had no body. See Handlers.
  • Frames received on a stream after resetting it are ignored (RFC 9113 §5.1): DATA frames sent before the peer received a RST_STREAM (the rest of a body after a 413) were each answered by another RST_STREAM, and a trailer block was answered without being decoded, leaving the HPACK state out of sync (later COMPRESSION_ERROR or wrong header values). Header blocks are still decoded and the connection-level credit of the DATA is still given back. Applies to the server and the HTTP client.
  • readBody() / readBodyAsync() work: the server switched the body to aggregated mode before calling the handler, so hasMoreBody() was false and readBody() threw.
  • HTTP/2 requests know their connection: clientAddress(), tlsCipher(), tlsVersion() and alpnProtocol() dereferenced a null pointer, crashing the server as soon as a metrics callback or an access log was configured. Request metrics also no longer throw for a body read with readBody() (they called body()).

Async handlers and deferWork()

  • Request data stays valid while a handler is suspended: query parameters were not copied with the request head, the body of a Content-Length request stayed a view into the connection buffer (reallocated by bytes received meanwhile, or shrunk by the maintenance sweep), and path parameter names pointed into the router. While an HTTP/1.1 async handler runs with its whole request received, the next bytes are left in the socket (which also bounds the memory a client can make the server buffer) and read once it completed.
  • Router updates no longer free the state of suspended handlers: a router update (router(), postRouterUpdate()) could destroy or move the handler object whose captures the coroutine uses, and the CORS policy and response middleware applied to its response. Async handlers are now shared with the requests running them, and the response metadata is looked up again when a handler that outlived its dispatch completes.
  • deferWork(): the coroutine frame and the request outlive a closed connection or stream until the work completes: they were destroyed when the connection closed (client, timeout, invalid body) or the HTTP/2 stream was reset, so a work function capturing them by reference (as in the documentation example) used freed memory, and on HTTP/2 the completion could even resume the coroutine of a later stream allocated at the same address. Destroying the server now waits for the work still running. The result (or exception) of the work function is always destroyed by the event loop thread, and deferWork() no longer terminates the process when no thread can be created (the std::system_error is rethrown by co_await).
  • An async handler throwing when called no longer stops the server: a function returning a RequestTask without being a coroutine, throwing before creating it, propagated the exception out of the HTTP/1.1 event loop. It is answered 500 through the response middleware, over HTTP/1.1 and HTTP/2.
  • HTTP/1.1: a response sent before the request body was received no longer loops: a short-circuiting middleware, CORS rejection or OPTIONS / TRACE answer on an async route whose body was not fully received left the request unconsumed, so it was answered again and again (tens of thousands of 401 per second). The connection is now closed after such a response.
  • HTTP/1.1: requests pipelined behind an async handler are served right away (they waited for new bytes or the maintenance sweep). HTTP/2 now resumes a coroutine suspended by std::suspend_always like HTTP/1.1 (it asserted in debug builds).

TLS

  • Intermediate certificates are sent: only the first certificate of a certificate file or in-memory PEM was loaded, so a certificate issued through an intermediate CA (Let's Encrypt, most public CAs) failed verification by clients not already holding it. The server (main and SNI certificates) and HttpClient (mTLS client certificate) now send the whole chain. A key of another algorithm than its certificate is rejected (it used to fail every handshake). See Certificates. Trusted client certificate bundles (withTlsTrustedClientCert()) are also fully loaded (only the first certificate was trusted), and the trusted names are sent in the certificate request.
  • mTLS sessions resume: with session tickets and client certificates, every reconnecting browser or curl failed because no session id context was set. It is now a digest of the client verification settings, so a session established under other settings (trust anchors, request or require mode, CRL file, revocation callback) does not resume. See Mutual TLS.
  • An OpenSSL error left by other code no longer closes healthy TLS connections: an error left on the per-thread queue by a handler (a JWT check of a forged RS256 signature, which any client can send) made the next TLS read that would block look fatal. The transport now empties the queue before each TLS I/O operation, and the JWT functions leave it empty.
  • A TLS handshake larger than the socket send buffer completes: the writable event only flushed the (empty) output and dropped the writable interest, so the client waited for the rest of a long certificate chain until a timeout. A write completing the handshake no longer skips its finalization (ALPN setup, statistics, kTLS).
  • Robustness of the OpenSSL integration: no exception escapes the session ticket and key log callbacks into OpenSSL (no ticket is issued for that handshake, the error is logged); no crash on an allocation failure while setting up a connection with OpenSSL 3.2+ (SSL_set_accept_state() / SSL_set_connect_state(); the server closes the connection and counts ssl_set_accept_state_failed, the client throws); builds against OpenSSL 4 (the client no longer calls the deprecated SSL_set1_host()).
  • Hot reload: TLS files rotated at the same path (rewritten in place, atomic rename, Kubernetes secret volume ..data swap) are reloaded by postConfigUpdate(): each applied update checks the files the context was built from (one stat() per file) and rebuilds it when one changed, so an empty update reloads rotated files; files are never checked on the handshake path. A standalone SingleHttpServer keeps its session ticket keys when its context is rebuilt without a change of the ticket settings. The default OCSP response is no longer read from freed memory when SNI certificates are configured. See TLS hot reload.
  • In-memory private keys leave no copy in freed memory: copies stayed after a reallocation, an assignment, or beyond the end of a buffer after a shorter string replaced a longer one. The TLS strings of TLSConfig and the strings of HttpClientConfig (client key PEM, proxy URL) are now overwritten before their memory is released or reused, and static session ticket keys are scrubbed on copy and move assignment.
  • Smaller TLS fixes:
  • the handshake rate limiter no longer rejects a burst after a long idle period (32-bit token refill wraparound);
  • SNI names with a trailing dot select their certificate;
  • handshakes failing on a transport error are reported as handshake_error (not handshake_eof);
  • tlsAlpnStrictMismatches is no longer reset by a TLS context rebuild;
  • no more Ignoring excess 0 TLS session ticket keys warning with exactly maxKeys static keys.
  • HTTP client: no SNI for IP addresses (RFC 6066); HTTPS verification works on Windows without an explicit CA (the trusted roots of the ROOT system store are loaded when SSL_CERT_FILE / SSL_CERT_DIR are not set).

WebSocket

  • permessage-deflate interoperates with browsers and standard clients: messages used the zlib wrapper (RFC 1950) instead of raw DEFLATE (RFC 7692), so compressed messages from standard clients failed to decompress. The negotiated server_max_window_bits / client_max_window_bits are applied (a side asked for 8 bits compresses with a 512-byte window, as browsers do, since zlib cannot do 256). The server no longer limits the window of a client that does not offer client_max_window_bits. No more spurious zlib: end returned -3 warning on close.
  • Context takeover and sizes: with context takeover (the default, and browsers'), the sync flush marker (00 00 ff ff, RFC 7692 §7.2.2) was only inflated when the message decompressed to a multiple of 16 KiB, so the next message failed (1007). maxMessageSize now applies to the decompressed size (below 16 KiB every compressed message was rejected, and a message of exactly maxMessageSize bytes is now accepted). See Compression.
  • Echoing a compressed message no longer reads freed memory, and endpoint factories keep their callbacks when compression is negotiated (the handler returned by a WebSocketEndpoint::WithFactory factory was replaced by one without callbacks; compression is now enabled on it through the new WebSocketHandler::enableCompression()).
  • Frames: frames split across reads are delivered once and intact (the handler kept the bytes of an incomplete frame while also reporting them unconsumed, duplicating or corrupting large messages); frames received in the same read as the upgrade request are processed right away.
  • Closing: closeTimeout is enforced (the server waited for the keep-alive timeout), the server closes the TCP connection after answering a peer Close frame (RFC 6455 §7.1.1, so the client does not hold TIME_WAIT), a per-route timeout() no longer closes the upgraded connection, and the endpoint factory sees the path parameters of the upgrade request.

MSG_ZEROCOPY

  • Buffers are kept until the kernel no longer reads them: closing a connection right after its response (Connection: close, HTTP/1.0, EOF, maximum requests, drain) released buffers the kernel still sent or retransmitted from, so once reused a client could receive bytes of another client's response. A graceful close now waits for completions, and a forced close with sends in flight resets the connection so that the kernel drops them. Out-of-order completions (retransmissions) are now counted and buffers released one by one once all sends reading them completed. A response buffer was also reallocated while sends still read it (periodic memory reclaim, WebSocket upgrade queued behind it) and released when a write or event loop registration failed; the notification control buffer was too small and unaligned. See MSG_ZEROCOPY.
  • Slow clients receive whole responses: a connection with sends still in flight at keepAliveTimeout was reset, truncating a large response read slowly. It is now closed once its sends complete, as long as they progress, and reset when they make no progress during a whole keepAliveTimeout (a reset connection keeps its buffers one more second). Without keep-alive, a client that stopped reading held its connection and buffers forever.
  • No more MSG_ZEROCOPY on kTLS connections (kTLS sockets reject it: each large write failed first).

Compression and static files

  • Truncated zstd bodies are rejected (400 for a request, malformedResponse for a response) like gzip, deflate and brotli ones, and a zstd body made of several frames (RFC 8878 §3.1) is accepted.
  • Large gzip and deflate bodies close to maxDecompressedBytes are accepted in streaming mode (they were rejected within decoderChunkSize of the limit while decoding was still in progress).
  • Accept-Encoding negotiation with a partial preferredFormats: the preference of the codings not listed was read uninitialized. They now come after the listed ones, in the default order, for explicit codings and * alike.
  • Response compression no longer wrongly aborts (body sent uncompressed) for gzip / deflate when the max compressed size exceeds initialCompressionBufferLimit.
  • A moved server keeps compressing with gzip and deflate: zlib binds streams to their address, so after moving a stopped server its next gzip or deflate response was answered 500, request bodies could no longer be decompressed and its destruction could crash. A moved stream is initialized again by its next use.
  • gzip, deflate and permessage-deflate data of 4 GiB and more: zlib counts buffer sizes in 32 bits, and larger sizes were truncated: a body or WebSocket message of 4 GiB or more was compressed partially (and sent as if complete) or corrupted, and 4 GiB or more of compressed input failed to decode. Such buffers are now given to zlib in slices.
  • Static files: links to directories and dangling links: a link to a directory was opened as a file (500) and a dangling link answered 503; they are now served as directories and answered 404.
  • HTTP client: an empty HTTP/1.1 response body with a Content-Encoding is returned empty (it failed with malformedResponse for zstd, and an assertion in debug builds for the others).
  • A file sent over user-space TLS stops at the first failed write (the server kept reading the rest of the file for a client that left).

HTTP client

  • HTTP/2: response bodies must match their content-length (RFC 9113 §8.1.1): a longer or shorter body, or an invalid content-length, now fails with malformedResponse (not checked for responses without content: HEAD, 204, 304). See Error model.
  • HTTP/2: a response followed by RST_STREAM(NO_ERROR) is kept: a server answering before receiving the whole request body may reset the stream this way (RFC 9113 §8.1, as aeronet does for a 413); the client discarded the complete response and failed with connectionClosed.
  • A rejected redirect Location no longer corrupts the request: an invalid target was detected after being written into the request buffer (or after the request origin had been updated), leaving the HttpRequest given to HttpClient::request(HttpRequest&&) unsafe to reuse. The target is now validated first, the switch to a body-less GET only applies to a followed redirect, and an invalid absolute-path Location (/a%zz) returns HttpClientErrc::invalidUrl instead of throwing std::invalid_argument.
  • Idle connections to origins no longer requested are swept: an idle pooled connection older than keepAliveTimeout was only closed when its origin was requested again, so a client talking to many origins kept sockets (CLOSE_WAIT), TLS sessions, HTTP/2 state and a pool entry for each. Completing a request now sweeps the expired idle connections of every origin, at most once per keepAliveTimeout.

Server lifecycle, configuration updates and data races

  • A failed config update leaves the server running with its previous configuration: changes made by an updater before it threw, or by an update failing validation, stayed applied, and a TLS context that could not be built (unreadable or invalid certificate, key mismatch, invalid OCSP response or CRL) or an access log that could not be opened threw out of the event loop, stopping the server (or MultiHttpServer worker). Each posted update is now applied atomically: on failure the error is logged (for TLS, with the faulty file and OpenSSL reason), the previous configuration and TLS context are kept, the other updates posted at the same time still apply, and a failed reload of changed TLS files is retried at the next update.
  • Lowering maxHeaderBytes or maxBodyBytes at runtime no longer stops a server with routes: routes registered with setPath() kept the server limit in place of their own, so lowering a server limit threw out of the event loop (as did registering a route with a limit above the server's). A per-route limit above the server one is now capped to it with a warning (also at startup), and routes without their own limit follow the server limits. See Per-route options.
  • headerReadTimeout no longer closes HTTP/2 connections and CONNECT tunnels, and applies to every request of a keep-alive connection: the timer, meant for HTTP/1.1 request heads, was also started by the first read of an HTTP/2 connection and by tunneled bytes (closing HTTP/2 connections with an HTTP/1.1 408), and its running count went below zero from the second request of a keep-alive connection (assertion in debug builds, in release builds every maintenance tick scanned all connections). It now starts at the accept for the first request, with the first byte of the next ones, and only for HTTP/1.1. Changing headerReadTimeout or bodyReadTimeout at runtime now applies cleanly to the requests in progress (counts were only kept while the timeout was enabled). See Limits and timeouts and Updating a running server.
  • MultiHttpServer::run() no longer hangs: when beginDrain() is called right after it started (workers not launched yet never drained, and run() waited for a worker running at the time it checked), or when no worker can start (e.g. unopenable access log file): it now waits for exactly the launched workers and returns once all exited, their errors being logged.
  • beginDrain() no longer turns a concurrent stop() back into a drain, nor loses its deadline: the drain deadline is now a single atomic value that only moves earlier, set before entering the draining state.
  • stop() no longer tears down an event loop run by another thread (run() / runUntil() from a user thread, startDetached*(), the probe listener of MultiHttpServer): it only requests termination (still non-blocking), the event loop closes the listener and connections itself, and a restart before it exited throws std::logic_error. stop() called from a handler no longer terminates the process (the event-loop thread tried to join itself) nor deadlocks MultiHttpServer::run().
  • A connection arriving while the server stops no longer crashes it: a listener event polled in the same batch as the stop was handled as an event of an unknown connection, reading beyond the connection storage (Linux, server that had not accepted any connection yet).
  • The ephemeral port of a stopped server can no longer be taken by another process (Linux, reusePort): a server that resolved an ephemeral port keeps it reserved for its whole life with a bound, non-listening socket.
  • A client leaving in the middle of a file transfer no longer stalls later connections: the reset of a reused connection state left its file transfer flags set (kqueue reporting the read side first, as on macOS), so the next connection reusing the state never processed its requests and was exempt from the keep-alive timeout.
  • aeronet no longer raises SIGPIPE on connections reset by the peer (Linux): a client leaving in the middle of a response could terminate a server not ignoring SIGPIPE, and a server closing during an upload could do the same to an HttpClient user (TLS requests, file bodies). Plain sockets are written with send() / sendmsg() and MSG_NOSIGNAL; as sendfile() and OpenSSL cannot pass that flag, the event loop keeps SIGPIPE blocked in its thread for its whole run (and HttpClient during TLS and file-body exchanges), the write failing with EPIPE. The process-wide SIGPIPE disposition is untouched. Threads started from an event-loop thread (deferWork(), CONNECT name resolution, handler threads) inherit the blocked signal.
  • Data races found with ThreadSanitizer (the whole test suite now runs clean with the new AERONET_ENABLE_TSAN option):
  • stats() (SingleHttpServer, MultiHttpServer) can be called from any thread (single-writer atomics, mutex-guarded TLS maps; the snapshot could even crash when a TLS map was rehashed);
  • the callback setters (setParserErrorCallback, setMetricsCallback, setTlsHandshakeCallback, setExpectationHandler, setMiddlewareMetricsCallback) are applied by the event loop while running (a call from another thread waits for it);
  • postConfigUpdate() snapshot taken by the event loop; router updates and callback changes applied directly while stopped can no longer race with a concurrent start; MultiHttpServer::isRunning(), start() and stop() can be called while another thread runs run() / start();
  • the SIGINT / SIGTERM handler of SignalHandler no longer logs from the signal handler (not async-signal-safe), it only stores into a lock-free atomic and the first event loop observing it logs;
  • beginDrain() no longer reads the connection count from the calling thread (the log message no longer includes it);
  • OpenSSL ex data indexes are allocated on first use, so that a TLS server created during static initialization (a global server) cannot use them before initialization.

Telemetry and metrics

  • OpenTelemetry span export no longer blocks the event loop: with otelEnabled, each span was exported synchronously (OTLP HTTP POST) by the event loop thread, stalling all connections for a collector round trip after every request, and up to the exporter timeout (10 s) when the collector was slow or unreachable (a lost wakeup in opentelemetry-cpp's OTLP HTTP client up to v1.29.0 could even make it wait the full timeout on an immediate failure). Ended spans are now queued and exported in batches by a background thread, at the latest TelemetryConfig::exportInterval after they end (a span ending while the queue is full, 2048 by default, is dropped).
  • aeronet.bytes.read counts every socket read (only the read right after the accept was counted), and RequestMetrics::reusedConnection is false for the first request of a connection (it was always true for buffered HTTP/1.x responses, CONNECT tunnels and HTTPS redirects). See Observability.

Platforms and build

  • Windows:
  • files can be sent several times: TransmitFile works from the current position of the file, which was shared with all File copies (duplicated descriptor), so sending the same file or a copy again failed with WSAEINVAL. The file is positioned before each transfer, a File copy reopens the file, and the length of a single TransmitFile call is capped (it sends at most 2,147,483,646 bytes, so larger HttpClient uploads failed);
  • read and write lengths above the Winsock limits are clamped: a maxPerEventReadBytes or HttpClientConfig::minReadChunkBytes above 2 GiB (e.g. std::numeric_limits<uint32_t>::max()) turned negative and closed every plain-text connection, and a buffer above 4 GiB in a gather write was truncated to 32 bits.
  • macOS: an interest dropped from a watched socket is no longer reported: EventLoop::mod() kept the old kqueue filters (epoll replaces the set), so HttpClient busy-polled and the server received spurious writable events. mod() now deletes the absent filters in the same kevent() call.
  • Shared library builds (AERONET_BUILD_SHARED=ON) link with every feature: with brotli enabled, libaeronet_http.so and callers of fullVersionWithRuntime() used brotli without linking it. Static dependencies (zlib-ng, zstd) are now position independent, shared libraries are linked with -z defs on Linux, and a client-only build (AERONET_ENABLE_HTTP_SERVER=OFF) no longer compiles the HTTP/2 server protocol handler.
  • Glaze builds with CMake 3.28 to 3.30 again: since 1.5.0 (glaze 8.1) the fetched glaze required CMake 3.31. With CMake < 3.31, aeronet now uses the glaze headers without running its CMakeLists.txt. See Glaze with CMake older than 3.31.

1.7.0 Improvements

New features and configuration

  • HTTP client streams response bodies: request(request, sink) and get(url, sink) deliver the body of the final response to a ResponseBodySink as it arrives, de-chunked and decoded, instead of buffering it: a download of any size keeps one read in memory, the sink is told the size when known and can stop the transfer. See Streaming response bodies.
  • HttpClientConfig loads from JSON and YAML files: LoadClientConfig() (new <aeronet/client-config-loader.hpp>) reads the client section of a configuration file, HttpClient accepts the path of such a file, dumpConfig() / saveConfig() dump a client configuration and aeronet-config-dump --client prints the default one. The configuration objects are now reflected by Glaze member by member, so a new field is read and written without serialization code. See Client configuration files.
  • New HttpClientConfig::inactivityTimeout: bounds each wait of an exchange for the connection to send or receive a byte (60 s by default, so only effective once requestTimeout, 30 s by default, is raised above it), so that a long download with a large requestTimeout still fails fast when it stalls. See Timeouts.
  • HTTP client keeps its HTTP/2 connection when only a stream fails: a malformed or too large response, a corrupt content coding or a stream reset by the server closed the whole connection (and its TLS session). The client now resets only that stream when still open (PROTOCOL_ERROR for a malformed response, CANCEL otherwise) and keeps the connection pooled. See HTTP/2.
  • New HttpClient::releaseUnusedMemory(): shrinks the request, response and decompression scratch buffers gradually (each call at most halves a mostly unused one), closes expired idle pooled connections and drops expired cached responses, keeping the memory of a long-running client bounded.
  • HttpRequestView::tlsPeerSubject() returns the RFC 2253 subject of the client certificate verified during the handshake (also on a resumed session), so that handlers can authorize mTLS clients. See Mutual TLS.
  • TLS servers select ChaCha20-Poly1305 for clients preferring it (SSL_OP_PRIORITIZE_CHACHA), typically clients without AES hardware acceleration, despite the server cipher preference.
  • headerRemoveAllLines(name) (HttpResponse, HttpRequest) removes every line of a header in a single pass (one move per removed line and a single move of the body, instead of a headerRemoveLine() call per occurrence). Same rules as headerRemoveLine() for the headers managed by aeronet.
  • deferWork() accepts more work functions: they may return void or a type that is not default constructible, and may be move-only.
  • bodyInlineAppend() appends to moved and static bodies (it threw std::logic_error for a body set by body(std::string&&), vectors, std::unique_ptr buffers or bodyStatic(), unlike bodyAppend()): a std::string or vector is written in place at the end of its buffer, a static or std::unique_ptr body is copied once into a message buffer, and a file() body still throws. Adding a trailer to a moved std::string or vector body no longer reallocates and copies it each time. See Bodies.
  • Constructors, makeResponse() and makeRequest() move owned bodies: like the body() setters, HttpResponse constructors, HttpRequestView::makeResponse() and HttpClient::makeRequest() accept a std::string&&, std::vector<char>&&, std::vector<std::byte>&& or a std::unique_ptr<char[]> / std::unique_ptr<std::byte[]> buffer with its size and move it into the message (HttpResponse(std::format(...)) and req.makeResponse(std::move(json)) used to copy). Other inputs are still copied, now by constructors inlined at the call site, where a literal content type is checked at compile time (building a response with a small copied body is 9% faster, 23% with server global headers). See Building a response and Requests.
  • New ServerStats counters for zerocopy: zerocopySends, zerocopyBytes, zerocopyCopiedSends, zerocopyNoBufferSpaceFallbacks, zerocopyRetainLimitFallbacks, zerocopyResetConnections. New Http2Config::maxRecycledRequests (8 by default, see HTTP/2 performance below).
  • Documentation site replaces docs/FEATURES.md: dedicated pages for routing, requests, responses, streaming, static files, middleware, CORS, compression and server lifecycle guides; TLS, HTTP/2, WebSocket, CONNECT, HTTP client, JWT and configuration files; an HTTP/1.1 conformance reference; health probes and performance operations pages. Former page URLs redirect to their new location. See the documentation site.

Performance

Benchmarks are in benchmarks/internal/ (Release builds, A/B against 1.6.0 unless stated).

  • HTTP/1.1 server I/O:
  • Adaptive read size: connections no longer reserve their whole fairness budget as input buffer (128 KiB per connection by default, up to 4 GiB with a huge maxPerEventReadBytes, which a system limiting memory overcommit refuses). Each connection starts at minReadChunkBytes (4 KiB by default), doubles after a read filling it up to 256 KiB (HttpServerConfig::kMaxReadChunkBytes) and halves after a read filling less than a quarter of it.
  • One read syscall per request instead of two on plain connections: a read returning less than requested already proves the receive queue drained, so the edge-triggered loop stops there (unless the event flagged a hang-up or error).
  • Cheaper plain socket I/O: recv() / send() / sendmsg() instead of read() / write() / writev() (about 100 to 250 cycles per call), and a single remaining buffer is sent with send().
  • Idle keep-alive connections hold no input buffer: they read into the input buffer of their event loop (still in CPU caches) and only keep one while holding a partial request or frame, or an async handler's request.
  • No wakeup syscalls for deferred input: the event loop polls without waiting while connections with deferred input are pending, instead of a write, an extra event and a read on its wakeup eventfd at each deferral.
  • Cheaper keep-alive bookkeeping: one update of the keep-alive deadline heap per keepAliveTimeout per active connection instead of one per event.
  • Cheaper Accept-Encoding negotiation and keep-alive decision: each event loop remembers the result for the last Accept-Encoding value (up to gzip, deflate, br, zstd long), and the Connection header is scanned once per response instead of twice.
  • Pipelined requests are answered by a single write and no longer moved after each served request: with Nagle's algorithm (plaintext connections without TCP_NODELAY, the default) the responses after the first of a read waited for the client's acknowledgment (40 ms per read with delayed ACKs), and erasing each served request from the front of the buffer was quadratic in the number of pipelined requests. Batches of 8 to 4096 pipelined requests on one connection are served 2.7 to 9 times faster with TCP_NODELAY (400-500k to 1.1-4.2M requests/s) and without the 40 ms stalls in the default configuration.
  • TCP_CORK only for flushes taking several writes (Linux): every response write used to cost two setsockopt() syscalls with TCP_NODELAY.
  • Output waiting for a slow reader is no longer copied: responses and streamed chunks produced while earlier output waited for the socket are queued in their own buffers (small ones up to 4 KiB merged) and written several at a time in a single gather write, MSG_ZEROCOPY when eligible. Streaming an 8 MiB response in 64 or 256 KiB chunks to a slow reader uses 10-13% less CPU and sends 2 to 3 times more of the response without copy. The output of a connection upgraded to WebSocket now waits for its 101 to be fully written. See Performance.
  • Memory footprint of messages: the captured body payload is no longer a 64-byte member of HttpMessage and of the HTTP/1 output buffer; it is constructed in place in the head buffer, right after the head. sizeof(HttpResponse) goes from 112 to 48 bytes (one cache line), sizeof(HttpRequest) from 120 to 56, the HTTP/1 output buffer from 96 to 32 (64-bit Linux): a response move is 25-39% faster and the handler-to-output-buffer path 7-16% faster for small bodies. Small buffers (up to 1 KiB) of RawChars / RawBytes grow by malloc + copy + free instead of realloc, which is about twice as fast with glibc (16-34% on the handler-to-output-buffer path; many buffers growing at once are up to 5% slower).
  • HTTP/2:
  • Uploads (176 KiB echoed back): a body declaring its content-length is allocated at once (up to connectionWindowSize, 1 MiB by default) instead of doubling as DATA frames arrive. 19-22% (h2c) and 7-10% (h2-tls) more requests/s, 10-21% less peak memory.
  • Moved and static response bodies are sent without copy: DATA frames gather their payload from the response storage (which the part waiting for flow-control credit keeps too), instead of copying bodies above 256 bytes, twice for the part exceeding the windows.
  • No output buffer allocation per response: queued blocks record their position in the connection output buffer, which is kept and reused once written (and bounded while a slow reader keeps output pending). Batches of 1 KiB responses are sent 28-36% faster.
  • Faster request heads: the HPACK decoder writes each field once into a storage that the stream's request takes over with the map, keeps pseudo-header fields apart, and refers to the static table instead of copying; a connection keeps up to Http2Config::maxRecycledRequests finished requests to reuse their capacity. A GET is handled 14% (1 regular field) to 33% (24 fields) faster, HPACK decoding alone up to 20%.
  • Lighter output: the HPACK encoder no longer indexes fields whose value differs in most messages (content-length, etag, last-modified, location, set-cookie, age, if-modified-since, if-none-match), like nghttp2: a response with three of them is encoded 3.5 times faster, in 6% fewer bytes. Files are read up to 256 KiB at a time instead of one read and allocation per 16 KiB frame, the scan of streams for responses waiting for credit is skipped when there is none, and a connection-level WINDOW_UPDATE only visits CONNECT tunnels.
  • Faster frame parsing: the 9-byte frame header is kept in its wire layout in two integers (4 times faster parse with GCC, avoiding a store forwarding stall).
  • TLS and zerocopy:
  • Faster TLS for large requests and responses: server TLS contexts read ahead and decrypt the records received until the socket would block (so a short read proves the socket drained, like a plain one), and gather writes are copied into full TLS records. With kTLS send, gather writes are a single sendmsg(). For 176 KiB HTTP/2 uploads echoed over TLS: 28 syscalls per request instead of 74 (17 with kTLS send) and 25% more requests/s (30% with kTLS send). See TLS record I/O.
  • Cheaper and adaptive MSG_ZEROCOPY: SO_ZEROCOPY is enabled once on the listening socket (inherited), completions are read on EPOLLERR instead of up to three recvmsg() per write, Opportunistic mode stops using zerocopy on a connection once the kernel reports copied sends (same-host peer, container network), and a send failing with ENOBUFS makes the server copy for one second. See MSG_ZEROCOPY.
  • WebSocket:
  • UTF-8 validation with the lookup algorithm of Keiser and Lemire (simdjson, simdutf): 32 bytes at a time with AVX2, 16 with SSSE3 (detected at runtime) or NEON, 25 GB/s with AVX2 (was about 1 GB/s for non-ASCII text): 8 to 23 times faster from 256 bytes of non-ASCII text, 2 to 13 times for 16 to 100 bytes, up to 1.7 times for ASCII from 40 bytes. Client text messages are unmasked in the same pass (1.1 to 3 times faster), fragments are validated in the same pass as they are copied to the reassembly buffer (up to 3 times faster), and the last bytes of a payload are unmasked by words. See Callbacks.
  • Large messages and permessage-deflate: payloads of at least 4 KiB sent back from onMessage (echo) and messages compressed to at least 4 KiB are written from their buffer instead of being copied; directions negotiated without context takeover use zlib streams shared by the connections of an event loop (new websocket::SharedDeflateStreams), avoiding the clearing of a 128 KiB hash table at each stream reset; an uncompressed message on a connection that negotiated compression is no longer copied to the reassembly buffer. With 384 connections on 6 server threads: 64 KiB echoes 77k to 116k messages/s, compressed 1.5 KB JSON echoes without context takeover 252k to 469k messages/s, peak memory 103 MB to 20 MB. A WebSocket or HTTP/2 connection without pending input reads into the input buffer of the previous read of its event loop.
  • Telemetry (batched, no more datagram per metric): counters are summed in memory per name and labels and exported at each maintenance tick of the server (after each request for HttpClient): one OTLP update and one DogStatsD line per counter, DogStatsD lines sent several per datagram up to 8 KiB. Recording the DogStatsD metrics of an HTTP/2 request takes 0.58 µs instead of 7.6 µs, and a counter increment 28 ns instead of a 385 ns datagram. See Batching and flush.

Build and tooling

  • New AERONET_ENABLE_TSAN CMake option: builds aeronet and its tests with ThreadSanitizer (AERONET_TSAN_OPTIONS overrides the flags; cannot be combined with AERONET_ENABLE_ASAN). The test allocation failure injection is disabled under ThreadSanitizer, as under AddressSanitizer. See docs/INSTALL.md.
  • Much lighter development builds: the new AERONET_COMPACT_DEBUG_INFO option (ON by default for Debug builds with tests, Linux with GCC or Clang) splits and compresses the debug info (-gsplit-dwarf): Debug test executables are ~3.5x smaller (http-core_test: 92 MB to 27 MB). Shared library builds no longer relink the executables when only the implementation of a library changed (editing a library source relinks that library only, instead of up to ~150 test executables, about 4 GB written per Debug rebuild).
  • New AERONET_UNITY_BUILD CMake option: builds the aeronet libraries (not the tests) as unity builds in batches of 16 sources, dividing their compilation CPU time by 4 to 6 in Release. Meant for trees built once and not edited (aeronet as a dependency, a package, a CI image). See the unity build recipe.
  • Fetched dependencies only build what aeronet links: they are declared EXCLUDE_FROM_ALL (88 fewer objects in a Debug build with the default features).

1.7.0 Others

  • Bumped opentelemetry version from 1.29.0 to 1.30.0.
  • SingleHttpServer split into components: the server keeps its lifecycle, listener, event dispatch and updates, and serves its connections through components (connection I/O, connection maintenance, HTTP/1.1 pipeline, TLS acceptor) sharing an internal context, without virtual calls; CONNECT tunnels and the streaming response transport no longer need friend access. No behavior change and no measurable cost end to end. The callback types are also available at namespace scope from the new <aeronet/server-callbacks.hpp> (ExpectationHandler, ExpectationResult, ExpectationResultKind, MetricsCallback, ParserErrorCallback), SingleHttpServer keeping its aliases. See Server architecture.
  • Internal callbacks are function pointers instead of std::function (async handler deferred-work completion, HTTP/2 completed request accounting, router updates posted through router(), CONNECT resolver wakeups): HttpRequestView is 24 bytes smaller, dispatching an async handler no longer copies a std::function and router() no longer allocates. RouterUpdateProxy::Dispatcher is no longer public.

[1.6.0] - 2026-10-03

1.6.0 Breaking changes

  • Header and trailer names are stored in lower-case (HttpRequest, HttpResponse, HttpResponseWriter, client responses, views, iterators, HTTP/1.x serialization). Every API taking one name (hasHeader, headerValue*, header, headerAddLine, headerAppendValue, headerRemove*, hasTrailer, trailerValue*, trailerAddLine) takes a LowerAsciiKey: uppercase literals fail to compile and dynamic keys must be normalized first. Title-case field names are no longer preserved on HTTP/1.x (lower-case is valid in HTTP/1.x and required by HTTP/2). Empty header names are rejected.
  • Safer defaults, 0 no longer means "unlimited":
  • StaticFileConfig::maxMultipartRanges: 0 now rejects every range query with 416;
  • Http2Config::maxStreamsPerConnection: default 1000000;
  • DecompressionConfig::maxCompressedBytes: default 128 MiB, and maxExpansionRatio: default 1000.0;
  • DecompressionConfig::streamingDecompressionThresholdBytes = 0 now streams every body (it used to mean aggregated mode);
  • HttpServerConfig::zerocopyMinBytes cannot be 0 (10 KB or more recommended).
  • TLS 1.2 or newer by default: TLS 1.0 and 1.1 handshakes are refused unless TLSConfig::minVersion is set explicitly.
  • HTTP methods are case-sensitive (RFC 9110 §9.1): get or GeT is an unknown method answered 501 (and a denied CORS preflight). Accepting them allowed bypassing intermediary rules written for the exact method name.
  • API:
  • StaticFileConfig and DecompressionConfig field order changed (decoderChunkSize is a uint32_t and the second field of DecompressionConfig): check aggregate initializations;
  • CorsPolicy::PreflightResult::response is a std::optional<HttpResponse>, only set when the preflight is allowed;
  • InMemoryTokenBucketRateLimitStore has no default constructor: explicit InMemoryTokenBucketRateLimitStore(uint8_t nbShards);
  • RetryConfig::retryStatuses is a set-like container;
  • MiddlewareResult::Decision was removed;
  • HttpClientConfig::validate() rejects nonsensical timeouts and retry settings (std::invalid_argument);
  • Http2Config::enablePush and enableH2cUpgrade are deprecated and have no effect (kept so that existing code and configuration files compile and load).

1.6.0 Bug Fixes

  • Zerocopy: closing a connection no longer releases buffers the kernel still sends: with MSG_ZEROCOPY (Opportunistic by default, for responses of at least zerocopyMinBytes to non-loopback peers), a connection closed right after its response released buffers the kernel still sent or retransmitted from, so once reused a client could receive bytes of another client's response. A graceful close now waits for the sends to complete, and a forced close with sends in flight resets the connection so that the kernel drops them.
  • A client that stops reading no longer blocks the server: unsent HTTP/1.1 output now waits for the write-readiness event instead of being retried, so a single client not reading a large or streamed response no longer stalls every connection of its server thread. Later responses and chunks queue behind it (a streaming writeBody() returns false once maxOutboundBufferBytes is exceeded).
  • Strict HTTP/1 parsing (several found with the HTTP Garden payloads):
  • a bare CR in a request head is rejected immediately with 400;
  • chunked bodies: an empty chunk size and control characters (bare LF) in chunk extensions get 400, a chunk size over 64 bits no longer wraps around to 0 (which ended the body early and let the following bytes be parsed as another request) and gets 413, and a Content-Length close to SIZE_MAX no longer overflows;
  • request-targets are validated: control characters are rejected (also in HTTP/2 :path), only origin-form, absolute-form, authority-form (CONNECT) and asterisk-form (OPTIONS) are accepted, and a decoded %00 in the path is rejected (all 400);
  • Connection: close is honored anywhere in the Connection list (RFC 9110 §7.6.1), and an HTTP/1.0 connection is only kept alive with an explicit keep-alive;
  • the client validates header names and values of HTTP/1.1 responses, and HttpClientConfig::maxResponseBytes counts header bytes as well as the body;
  • HTTP/2 field values with leading or trailing whitespace are rejected as malformed (RFC 9113 §8.2.1).
  • Injection: replacing a header with header() (also used by location()) did not validate the value and reason() accepted any character, so CR/LF could split the response (both now throw std::invalid_argument). %00 / %1F in query strings, the internal separators of decoded parameters, stay encoded (?a=1%00admin%1Ftrue could inject a second parameter). headerAppendValue() validates the value and separator, and header or trailer values with surrounding whitespace are trimmed or rejected consistently (trailerAddLine, concatenated headers in HttpResponse constructors).
  • Trailing slash redirects no longer stop the server: with TrailingSlashPolicy::Redirect, a path such as /users/a%0d%0a/ matching a parameterized route copied CR/LF into Location and the resulting exception escaped the event loop. The path is now percent-encoded.
  • HTTP/2:
  • request bodies no longer stall when a peer (h2load) advertises a large initial window: new streams used the peer's setting for both directions, so the server sent no WINDOW_UPDATE;
  • Http2Config::enablePush = true made the server advertise SETTINGS_ENABLE_PUSH = 1, which RFC 9113 §6.5.2 forbids and curl answers by closing the connection: servers and clients now always advertise 0, and the client treats a server advertising 1 as PROTOCOL_ERROR;
  • cleartext Upgrade: h2c requests are answered over HTTP/1.1 instead of hanging (deprecated mechanism, RFC 9113 §3.1; prior-knowledge cleartext HTTP/2 remains available through Http2Config::enableH2c);
  • async handlers now resume on prior-knowledge h2c connections (the connection was not flagged as HTTP/2);
  • the HTTP/2 TLS client reuses pooled connections with pending control frames (WINDOW_UPDATE) instead of reconnecting.
  • Server:
  • HTTP/1.1 CONNECT no longer stalls on the first tunnel bytes buffered with the head;
  • static file ranges follow RFC 9110 (case-insensitive units, ignored on HEAD, multipart parts in request order, malformed and unsatisfiable ranges distinguished);
  • beginDrain() is no longer lost when called while a server is still starting up (it waits for the startup, and the deadline uses a fresh clock);
  • an exception in a streaming handler returns 500, and streaming handlers are no longer called for HTTP/1.0 requests;
  • CORS request header names (Access-Control-Request-Headers) are matched case-insensitively, as browsers send them lower-cased (names given to allowRequestHeader() / exposeHeader() are deduplicated case-insensitively);
  • a throwing startDetachedAndStopWhen() / startDetachedWithStopToken() predicate no longer crashes on Windows, and a data race in AsyncHandle::stop() / lifecycle state is fixed (ThreadSanitizer).
  • HttpMessage consistency:
  • body headers stay consistent when an empty file() body is replaced, when a body is replaced by a directly compressed one (Content-Encoding was lost), and when headers are added during direct compression (they were overwritten by the final Content-Length);
  • Content-Type, Content-Length and Content-Encoding can no longer be modified through the generic headerAppendValue / headerRemoveValue (same rules as header, headerAddLine, headerRemoveLine);
  • headerRemoveValue trims the value and no longer corrupts headers when removing an empty token ("a, b" became "ab");
  • Content-Type values as short as a/b are accepted by all body setters and in client responses, and replacing a Content-Type containing : wrote at a wrong position;
  • streaming HttpResponseWriter::file() sends a single Content-Type, and HttpResponseWriter throws on an invalid Content-Type.
  • Memory safety: possible out-of-bounds write in direct compression finalization, out-of-bounds read when removing a captured or file body with a user Content-Encoding, and a moved HttpClient (or SingleHttpServer with DogStatsD) reading the freed configuration of the moved-from object.
  • Client: a request deadline wins over socket readiness arriving after it (HttpClientErrc::timeout).
  • Builds: fixed compilation with AERONET_ENABLE_HTTP2=OFF and AERONET_ENABLE_WEBSOCKET=ON, with AERONET_ENABLE_HTTP2=ON and AERONET_ENABLE_HTTP_CLIENT=OFF, and in Release without AERONET_ENABLE_TEST_HOOKS.

1.6.0 Improvements

  • Bounded deferred output and zerocopy retention: maxOutboundBufferBytes now covers HTTP/2 queued frames and flow-control-deferred in-memory responses. HTTP/2 pauses input at the output high-water mark, refuses new streams when the connection budget is exhausted, answers 503 for fixed responses that cannot fit, and resets streaming responses exceeding the new Http2Config::maxStreamPendingBytes. The new maxZerocopyPendingBytes ceiling stops using zerocopy on a connection before another retained payload would cross it (sends already in flight stay tracked until completion).
  • Advanced TLS operations and hardening: passive stapling of cached DER OCSP responses (globally or per SNI certificate), client certificate validation with PEM / DER CRLs or an application revocation hook, NSS key log in debug builds only, server cipher preference, renegotiation disabled where supported, SNI wildcards matching a single label, and scrubbing of private keys and session ticket keys on replacement or destruction.
  • Labeled HTTP/2 telemetry: metric calls accept non-owning per-measurement label spans (OpenTelemetry attributes, DogStatsD tags). HTTP/2 reports frame counts and bytes by direction and type, stream lifecycle, request duration and body size, HPACK block sizes and compression ratios.
  • HTTP/2 performance: adaptive outbound batching (small responses share the output buffer so TLS encrypts a batch in one write, larger payloads are gather-written without recopy), receive-window replenishment batched at a half-window low-water mark, a lazily built hash index for enlarged HPACK dynamic tables (lookups from 109 / 395 ns to about 9 ns at 16 / 64 KiB), and Http2Connection shrunk from 728 to 524 bytes.
  • Server performance: Date header cached per reactor (about 0.6 ns instead of 35 ns), transport dispatch without vtables or allocation for plain connections, header lookups without case folding on every comparison, CRLF search through libc memchr, lock-sharded rate limiter, fewer copies in the WebSocket upgrade and for a large first HttpResponseWriter chunk, no more Http2Stream push states.
  • Client performance: zero-copy hand-off of HTTP/1.1 response bodies, HTTP/2 file uploads without a scratch copy (a file shorter than declared fails with HttpClientErrc::writeError), single-pass cache pruning, and a poll timeout adapted to request timeouts under 25 ms.
  • New settings and behaviors: Http2Config::maxPriorityTreeDepth is enforced (deeper or cyclic dependencies clamp to the root), AccessLogConfig::flushThresholdInBytes (8192), HttpClientConfig::minReadChunkBytes (16 KB), HttpClientConfig::cache.maxRequestSize, CompressionConfig::useLeadingZeroesInContentLength (padding is now leading zeroes instead of trailing spaces), Http2Config::sendContentLengthHeader (default true), HttpClient is copyable, built-in error responses include the global headers, and HTTP/1.x requests with a higher minor version (e.g. HTTP/1.3) are served as HTTP/1.1 instead of 505.

1.6.0 Others

  • AddressSanitizer is no longer on by default in Debug builds: set AERONET_ENABLE_ASAN=1.
  • The server can be left out of the build with -D AERONET_ENABLE_HTTP_SERVER=OFF (client only).
  • Each configuration object's Glaze adapter moved into its own header; zstd.h and brotli/encode.h removed from internal headers; aeronet/version.hpp added to aeronet-client.hpp.
  • Unknown HTTP/2 SETTINGS ids are logged at debug level.
  • glaze 8.1.0 to 8.4.0, opentelemetry-cpp 1.28.0 to 1.29.0.

[1.5.0] - 2026-08-20

1.5.0 Breaking changes

  • HTTP CONNECT tunneling is disabled by default: an empty HttpServerConfig::connectAllowlist rejects every target with 403, closing an unauthenticated SSRF / open proxy in default configurations (HTTP/1.1 and HTTP/2). Configure trusted hosts with withConnectAllowlist() (or the connectAllowlist setting); ["*"] restores the previous unrestricted behavior, loopback, private-network and cloud metadata targets included.
  • Lower-case keys for header and trailer lookups on HttpRequestView: headerValue, headerValueOrEmpty, trailerValue, trailerValueOrEmpty, hasHeader and hasTrailer take a LowerAsciiKey (headerValue("Host") no longer compiles, headerValue("host") does). headers() and trailers() return a case-sensitive map with lower-case keys (find("X-Header") can no longer match).
  • http::Connection, http::ContentType, http::Host... are LowerAsciiKey instead of std::string_view (validated at compile time). Implicit conversion back keeps typical code unchanged; *Sep formatting constants stay std::string_view.
  • File::Identity is private and File::identity() is replaced by File::appendIdentityData(char*) (buffer of at least File::kIdentitySize bytes).
  • Global header values must be trimmed of OWS.

1.5.0 Bug Fixes

  • Async handlers: heap-use-after-free when a connection closed during a long deferWork(): the keep-alive sweep could free the connection while background work ran, which then wrote its result into freed memory. The worker no longer touches the request or coroutine frame (state co-owned through a shared_ptr), connections with an active async handler are excluded from the keep-alive sweep, and a per-connection generation token prevents a stale completion from resuming another connection that reused the same fd (HTTP/1.x and HTTP/2).
  • A handler slower than keepAliveTimeout no longer gets its own response swept as idle: the idle window now restarts when the work completes, and active HTTP/2 streams (including those waiting for flow-control credit) are excluded from keep-alive reaping. Clients used to get a 200 with fewer bytes than content-length.
  • Stricter request validation:
  • HTTP/1.1 requests without Host get 400 (RFC 9112 §3.2);
  • HTTP/1.1 field names must be valid tokens and values must not contain NUL, bare CR or bare LF;
  • HTTP/2 field names with uppercase or invalid bytes and values with NUL/CR/LF are rejected (RFC 9113 §8.2.1);
  • malformed HTTP/2 pseudo-header sections (duplicate, misordered, undefined, response-only, missing, invalid CONNECT shapes) reset the stream with PROTOCOL_ERROR without closing the connection (field sections are still decoded in full to keep HPACK in sync); unsupported extension methods return 501 instead of being dispatched as GET;
  • Http2Config::maxHeaderListSize is enforced on decoded headers and trailers (ENHANCE_YOUR_CALM).
  • HPACK dynamic table desynchronization: entries are added on incremental indexing even when the header is rejected as malformed (RFC 9113 §4.3).
  • Startup, shutdown and lifecycle: route updates submitted while a server was starting could race the router (a synchronized Starting state now queues them), and predicate or stop-token shutdown between event-loop iterations could leave the listener open without an event loop (intermittent 10 s Windows CI stalls).
  • Client: heap-buffer-overflow when adding a header to a body-less request built with reserved capacity (makeRequest(additionalCapacity, method, url)).
  • macOS: EventLoop::add() no longer masks a failed filter registration (each filter's result is checked with EV_RECEIPT).

1.5.0 Improvements

  • Router: literal-route slots store compact side-table indices and RoutingResult references immutable route metadata (96 to 48 bytes, Router::match 39% smaller, about 45% lower median latency for literal hits).
  • HTTP/1.1 client responses: completed chunked and decompressed bodies transfer suitably sized scratch allocations into HttpResponse (256 KiB chunked parse about 39% faster), and parsing is one stricter forward pass with SIMD CRLF search (25% faster for 1 KiB chunks, 68% for 16-byte chunks). The client rejects bare-LF framing, like the server.
  • HPACK: smaller Huffman lookup table with correct padding handling, collision-free perfect hash for the static table and cheaper dynamic table insertion and eviction (up to 46% faster decoding, 30% faster round trips, 63% faster static lookups).
  • Request parsing: faster header line parsing (about 40% for typical browser headers, 30% for API proxy headers), faster case-insensitive hashing and method parsing (10% for few headers, up to 23% for many), faster MIME lookup (about 40%), custom WriteInt instead of std::to_chars, and lookups normalized to lower case.
  • The 16-setting limit of SETTINGS frames was removed (the spec has none).
  • New MkDocs Material documentation site (GitHub Pages, with the live benchmark dashboards) and a benchmark profiling workflow (scripts/profile_benchmark.sh, flamegraphs).
  • Harmless MSVC and GCC warnings fixed in ndigits.hpp; internal <aeronet/stringconv.hpp> removed. glaze bumped to 8.1.0.

[1.4.1] - 2026-07-25

  • Fix compilation with OpenSSL: CMake did not export the aeronet_tls static library publicly.
  • HTTP client: HTTPS verification on minimal Linux images: with no explicit CA and peer verification on, the client relied only on OpenSSL's compiled-in default directory, often absent from minimal containers, so every handshake failed. It now also probes well-known CA locations (e.g. /etc/ssl/certs/ca-certificates.crt, /etc/pki/tls/certs/ca-bundle.crt) when SSL_CERT_FILE / SSL_CERT_DIR are not set.
  • New HttpClient::makeRequest(std::size_t additionalCapacity, std::string_view method, std::string_view url) to add headers before the body without reallocation.

[1.4.0] - 2026-07-23

1.4.0 Breaking changes

  • HttpRequest renamed HttpRequestView (non-owning). HttpRequest is now an owning type used by the HttpClient API to build a request, mirroring HttpResponse.
  • HTTP/2-specific HttpRequestView APIs (isHttp2(), streamId(), scheme(), authority()) are only compiled with AERONET_ENABLE_HTTP2.
  • JSON/YAML helpers (HttpResponse::bodyJson / bodyYaml, HttpRequestView::bodyAs / bodyAsYaml) need <aeronet/http-json.hpp> (the <aeronet/aeronet.hpp> umbrella is unaffected), and aeronet::log needs <aeronet/log.hpp>: Glaze and spdlog no longer leak into core headers.

1.4.0 New features

  • HTTP client (HttpClient, AERONET_ENABLE_HTTP_CLIENT): a synchronous client built on aeronet's own transport and event loop, speaking HTTP/1.1 and HTTP/2 (HPACK and frame codecs shared with the server; HttpClientConfig::httpVersion Auto / Http2 / Http1_1, ALPN over TLS, prior-knowledge h2c). It provides HTTPS (peer and hostname verification, TLS bounds, custom CA, ciphers, mTLS), per-origin keep-alive pooling, redirect following, a retry policy with backoff, transparent compression and decompression, request trailers, request bodies streamed from a file (HttpRequest::file()), cleartext forward proxy (withProxy, CONNECT tunnel for HTTPS origins) and an opt-in response cache (withCache). Requests return an HttpClientResult (std::expected<HttpResponse, HttpClientErrc>). A client is not thread-safe (one per thread).
  • JWT module (aeronet_jwt, AERONET_ENABLE_JWT): sign and verify JWS tokens (RFC 7519; HMAC, RSA, PS, ECDSA, EdDSA) over OpenSSL, with claim validation (exp, nbf, iss, aud, sub, leeway, injectable clock), JWK/JWKS parsing with kid selection, alg:none always rejected, algorithm-family anti-confusion and constant-time HMAC. Exception-free, JWE out of scope, plus a base64url codec. See the JWT guide.
  • HTTP to HTTPS redirect: HttpServerConfig::withHttpsRedirect(targetHttpsPort = 443, statusCode = 301) answers every plaintext request with a redirect built from the Host header (path and query re-encoded, 400 without Host). Not combinable with tls.enabled on the same listener.
  • Rate-limiting middleware: RateLimitRequestMiddlewareBuilder with configurable client keys (peer address, X-Forwarded-For, custom header or extractor), an in-memory token bucket (429 with Retry-After), global or per-route / per-group installation, and an optional Redis sliding-window contract.
  • Dedicated probe listener for MultiHttpServer: BuiltinProbesConfig::withDedicatedPort(port) serves /livez, /readyz and /startupz from a separate event loop, with heartbeat-based liveness (unhealthy only when every worker's heartbeat is stale beyond livenessStaleThreshold, 10 s by default). See the Kubernetes guide.
  • HttpRequestView::clientAddress() (HTTP/1.1 and HTTP/2) and HTTP/2 request trailers (trailers() / trailerValueOrEmpty(); pseudo-header fields in trailers, or a trailing block not ending the stream, reset the stream).

1.4.0 Bug fixes

  • Request trailers could leak across keep-alive requests (the trailer length was not reset for non-chunked bodies).
  • HTTP/2: responses larger than the peer's flow-control window never completed (the remainder is now deferred and flushed as WINDOW_UPDATEs arrive, trailers included); active-stream accounting leaked for responses completed outside frame processing (eventually Max concurrent streams exceeded); field values with trailing whitespace, rejected by nghttp2 / curl (RFC 9113 §8.2.1), are now trimmed before HPACK encoding.
  • TLS: large responses could be truncated by one 16 KiB record when a fatal error left on the shared OpenSSL error queue was misclassified against the next connection; requireClientCert was silently ignored when requestClientCert=false (the invariant is now enforced on every configuration path).
  • HTTP/1.1: empty-body responses omitted Content-Length (defeating keep-alive; Content-Length: 0 is now synthesized where allowed), and the status line omitted the mandatory SP before an empty reason phrase (HTTP/1.1 200 \r\n).
  • bodyAs<T>() / bodyAsYaml<T>() could over-read a non-null-terminated body, a stale poll event could crash on macOS (out-of-range fd), a Debug-only compile error in fullVersionStringView() was fixed, and built-in server error bodies carry a Content-Type.

1.4.0 Improvements

  • Lower compile-time footprint of core headers (Glaze and spdlog moved out), aeronet split into aeronet_server, aeronet_client and an aeronet umbrella target.
  • Static files: per-file ETag / Last-Modified / Content-Type fragments are precomputed and validated on size and mtime (StaticFileConfig::headerCacheCapacity, 1024 by default, O(1) LRU).
  • Faster CaseInsensitiveEqual (16 bytes at a time with SSE2 / NEON, 35-57% on strings of 16 bytes or more) and small Copy / Append (about 1.5x on a realistic mix); better HPACK reserve.
  • HttpServerConfig::nbThreads narrowed to std::uint16_t; status line limit raised from 64 KiB to 16 MiB and reason limit from 1024 to 65522; scripted HTTP/2 client benchmarks (vs libcurl, h2c and h2-TLS) added to CI.

[1.3.0] - 2026-06-01

1.3.0 New features

  • Route parameter constraints: {name:pattern} (e.g. /users/{id:[0-9]+}), compiled at registration, with a fast matcher for character classes and a std::regex fallback.
  • Route groups (RouteGroup via Router::group(prefix)): non-owning prefix proxies sharing configuration, CORS policy and middleware, nestable with inheritance.
  • Per-route configuration: requestTimeout (408, per-stream on HTTP/2), maxBodyBytes (413) and maxHeaderBytes (431), through PathEntryConfig or the chainable .timeout(), .maxBodyBytes(), .maxHeaderBytes().
  • Configuration from YAML / JSON files with AERONET_ENABLE_GLAZE (SingleHttpServer and MultiHttpServer constructors taking a path).
  • Structured access logging (HttpServerConfig::accessLog): clf or json, sinks none, stdout, file, optional X-Forwarded-For, independent from spdlog.
  • Event loop: adaptive poll timeout (pollIntervalMinFactor / pollIntervalMaxFactor, withPollIntervalFactors()), intrusive min-heap keep-alive deadline queue (no scan of every connection per tick), and HttpServerConfig::maxAcceptBatchSize (64) against connection-burst starvation.
  • Deterministic network fault injection test infrastructure (FaultPolicy, TestPipe, FaultInjectingTransport, compile-time gated by AERONET_ENABLE_TEST_HOOKS).

1.3.0 Bug fixes

  • flat_hash_map::erase(iterator) returned the wrong iterator when a colliding entry was relocated (erase-while-iterating could skip entries).
  • HTTP/1.1 parser header boundary off-by-one when the buffer ended exactly at a header CRLF (assertion or corrupted state).
  • WebSocket processInput() over-erased the input buffer when carry-over input existed.
  • Connection stalls: a maxPerEventReadBytes cut leaving data in the TCP buffer never raised another edge-triggered event (partially read fds are now re-processed next iteration), and a tunnel hung when epoll_ctl(EPOLL_CTL_MOD) failed.
  • minReadChunkBytes is honored as the actual read size; HTTP/2 admits peer-initiated streams against the local SETTINGS_MAX_CONCURRENT_STREAMS; no empty HEADERS frame for deferred file sends without trailers.

1.3.0 Breaking changes

  • HttpServerConfig::maxHeaderBytes (and withMaxHeaderBytes()) is a std::uint32_t (was std::size_t).
  • HttpServerConfig::maxPerEventReadBytes must be > 0 (std::numeric_limits<uint32_t>::max() approximates unlimited) and defaults to 128 KiB instead of unlimited, for fairness.

1.3.0 Improvements

  • HPACK: hash lookup in the static table (about 33.5% on BM_HpackFindHeader) and reuse of evicted-entry buffers in the dynamic table (about 46.9% on encode, 19.9% on round trips).
  • Fewer allocations and copies: WebSocket frame building, HttpResponseWriter compression buffer reuse, HTTP/2 per-stream map consolidation, async handler state allocated only for connections using them.
  • Backpressure rejects extra response chunks immediately once maxOutboundBufferBytes marks a connection for drain.
  • WebSocket benchmarks (k6: echo, mix, ping-pong, churn, compression, against uWebSockets and Drogon) added to CI.

[1.2.0] - 2026-03-23

aeronet now also runs on macOS (kqueue backend) and Windows (WSAPoll backend).

1.2.0 Breaking changes

  • HttpServerConfig::tcpNoDelay is an enum with an Auto mode (default: TCP_NODELAY for HTTP/2 over TLS only). withTcpNoDelay() is unchanged.

1.2.0 New features

  • HTTP/2 CONNECT tunneling (RFC 7540 §8.3): per-stream bidirectional tunnels, allowlist enforcement, cleanup on reset or close.
  • Truly asynchronous HTTP/2 handlers: co_await req.deferWork(...) suspends per stream (HTTP/2 used to drain async handlers synchronously, blocking the whole connection).
  • Multi-range responses (multipart/byteranges, RFC 7233): ranges sorted and coalesced, bounded by maxMultipartRanges and maxMultipartBodySize.
  • StreamingHandler supports HTTP/2.

1.2.0 Bug fixes

  • HTTP/2 :path query parsing now mirrors HTTP/1.1 (in-place percent-decoding); Router no longer asserts on a path sharing a prefix with a registered one but diverging at an unmatched segment; a malformed CRLF ending a chunked body returns 400 instead of waiting.
  • OpenSSL error queue is cleared after SSL_shutdown() (stale errors misclassified on connection reuse); ENOBUFS on zerocopy responses falls back to copying; zerocopy data corruption under sustained load on virtual network devices fixed by draining completion notifications before each send (and ENOBUFS handling on TLS + kTLS).
  • CompressionConfig::maxCompressRatio is strictly respected; a race in multi-threaded stop() and sweeping of tunnel connections were fixed.

1.2.0 Improvements

  • StaticFileHandler: small files (128 KiB threshold by default) are read into memory instead of zero-copy transfer, plus faster directory listing, metadata and sendfile chunking.
  • Compression: streaming compression starts with a small chunk size growing exponentially, brotli encoder state and zstd contexts are reused, and HttpResponseWriter no longer over-allocates for brotli and zstd.
  • Connections are stored in a vector indexed by fd; no memmove for non-prepared HTTP/2 bodies; fewer steady_clock::now() calls; std::memchr CRLF search; binary search for HPACK static names; HttpServerConfig::zerocopyMinBytes (128 KiB); compression telemetry counters (aeronet.http_responses.compression.*); WebSocketDeflate validation; HTTP/2 hardening and metrics.
  • zlib-ng (native mode, SIMD) replaces zlib for gzip / deflate and WebSocket permessage-deflate (AERONET_ENABLE_ZLIBNG=OFF keeps classic zlib, bumped to 1.3.2).
  • New fullVersionWithRuntime(), clang-format 21, HTTP/2 benchmarks with h2load in CI.

[1.1.0] - 2026-02-16

1.1.0 Breaking changes

  • HttpServerConfig::globalHeaders must be key-value pairs separated by http::HeaderSep; invalid header names or values inserted in a response, or malformed concatenated headers in HttpResponse constructors, throw std::invalid_argument.
  • Setting Content-Type and Content-Length through header / headerAddLine is prohibited (it was undefined behavior): use contentType() / contentLength() for streaming handlers, and set the content type with the body otherwise.
  • HttpRequestView::queryParams() is now a map-like view collapsing duplicate keys (last value wins); queryParamsRange() keeps the duplicate-preserving iteration. telemetryContext() was removed from HttpServer / SingleHttpServer and telemetry metric methods (DogStatsD included) are no longer const.

1.1.0 New features

  • Direct compression: inline bodies created through HttpRequestView::makeResponse() can be compressed at body() / bodyAppend() time (DirectCompressionMode Auto / Off / On, CompressionConfig::defaultDirectCompressionMode). See Direct Compression.
  • HttpRequestView::makeResponse() factories, HttpRequestView::deferWork() (run a task on a dedicated thread and come back to the event loop), size / length / reserve helpers on HttpResponse, and HttpServerConfig::addTrailerHeader (HTTP/1.1).
  • New HttpRequestView helpers: hasHeader, hasTrailer, hasPathParam, hasQueryParam, pathParamValue*, queryParamValue*, queryParamInt, headerRemoveLine, headerRemoveValue. Router paths accept an asterisk as a non-terminal segment (/files/*/metadata).
  • MSG_ZEROCOPY support (plain text and kTLS, Linux) through HttpServerConfig::withZerocopyMode() (Disabled, Opportunistic default, Enabled).
  • New AERONET_ENABLE_ASYNC_HANDLERS option and an experimental C++20 module interface file.

1.1.0 Bug fixes

  • Content-Length updated correctly by bodyAppend() on a captured body; HEAD responses with trailers omit the body and no longer switch to chunked encoding (RFC 7230 §4.3.2).
  • Accept-Encoding parsing is closer to RFC 7231 §5.3.4 (highest q among duplicate encodings), and h2c connections handle the HTTP/1.1 Upgrade mechanism.

1.1.0 Improvements

  • Router radix tree (about 40% faster pattern matching, lower memory), faster case-insensitive header hashing (FNV-1a), codec contexts reused, in-place compression of captured payloads, faster HttpResponse::file(), exponential bodyAppend growth, and a single shared compression buffer per server instead of per connection.
  • Header values stored in HttpResponse / HttpResponseWriter are trimmed; DogStatsD reconnects automatically; WebSocketConfig::maxMessageSize is strictly enforced on decompression; prepared responses avoid body and trailer allocation for HEAD.
  • body(std::string&&, ...) and body(std::vector<std::byte>&&, ...) only capture rvalues (an lvalue std::string selects the inline std::string_view overload, avoiding silent copies); an invalid chunked length returns 400 instead of 413; CompressionConfig::maxCompressRatio default raised from 0.5 to 0.6.
  • ARM64 in CI, Crow in the benchmarks, a body-codec scenario, and benchmark configurations split by connection count (default 50 per thread).

[1.0.0] - 2026-01-17

First production-ready release of a modern, high-performance HTTP/WebSocket server library for Linux (C++23, GCC 13+ / Clang 21+, OpenSSL 3+ optional).

Features

  • HTTP/1.1: strict RFC 7230 parsing, keep-alive and pipelining, chunked bodies with trailers, size limits, streaming responses with backpressure, HEAD, CONNECT, Expect: 100-continue, configurable TRACE, duplicate header merging.
  • HTTP/2 (RFC 9113): HPACK, multiplexing with stream and connection flow control, ALPN h2, h2c prior knowledge and Upgrade, the same HttpRequestView API as HTTP/1.1, optional PRIORITY, CORS and middleware. (Server push is intentionally not implemented.)
  • Compression: gzip, deflate, zstd and brotli (feature-gated), Accept-Encoding negotiation with q-values, inbound body decompression with safe limits (413 / 415), automatic Vary.
  • TLS: file or in-memory PEM, SNI multi-certificate routing, optional or required mTLS, strict ALPN, TLS 1.2+, handshake timeout, session tickets with key rotation, kernel TLS with sendfile, hot reload of certificates and trust store, handshake and cipher metrics.
  • Static files (RFC 7232 / 7233): zero-copy sendfile, single-range requests, conditional requests, strong ETags, directory listing, path traversal protection.
  • WebSocket (RFC 6455) with per-message compression, streaming responses (HttpResponseWriter), and routing: exact, parameterized (/users/{id}) and wildcard paths, global and per-path middleware, trailing slash policies.
  • Architecture and operations: single-thread epoll event loop per server scaled with SO_REUSEPORT (HttpServer wrapper), Kubernetes probes (/livez, /readyz, /startupz), OpenTelemetry, DogStatsD, a JSON stats endpoint, per-server telemetry state and graceful drain.

Security

  • Overflow-checked buffers (std::overflow_error), SafeCast and compile-time checks against integer wraparound, configurable header and body limits (431 / 413), chunk size validation, maxOutboundBufferBytes, and multipart part, header and size limits.
  • Static file path traversal protection (canonical paths, .. segments rejected, symlink_status() checks, root confinement).
  • Forbidden trailer headers rejected, 400 on malformed input, opt-in Slowloris header timeout, decompression expansion guards, 415 on unknown content codings, HTTP/2 frame and flow-control validation.
  • ASAN / UBSAN and clang-tidy in CI, about 94% code coverage, no global mutable state.

Known limitations (planned for later releases)

HTTP/2 CONNECT (use HTTP/1.1), multi-range responses, pluggable logging sinks, deterministic network fault tests and OCSP stapling.

Compatibility

No breaking changes from 0.8.x. Build instructions are in docs/getting-started/install.md, the live benchmark dashboard at https://sjanel.github.io/aeronet/benchmarks/, security reports go to dev.sjanel@gmail.com (not public issues), and the roadmap in docs/ROADMAP.md.