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.1was routed with the pathhttp://example.com/onlygetand answered404. The path and query of the target are now the ones of the request, an empty path being/, and its authority, without userinfo, replaces theHostheader 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 with400. 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.1orhttp/1.1was accepted as HTTP/1.1 by the server, by the response parser ofHttpClientand in theCONNECTresponse of a proxy (whose version was not checked at all). The version must now beHTTP/digit.digit, case-sensitive (RFC 9112 §2.3): the server answers any other version token with400, andHttpClientfails withmalformedResponse(proxyErrorfor a proxy).HttpClientalso rejects a status line of another major version than 1, such asHTTP/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 defaultmaxBodyBytes. The decoding now resumes where the previous read stopped. A chunk making the body exceedmaxBodyBytes(orDecompressionConfig::maxCompressedBytes) is rejected with413when 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 defaultmaxBodyBytesnot applying to it) and searched again by each read. A chunk-size line, CRLF included, longer thanmaxHeaderBytesis now rejected with400, and trailers longer thanmaxHeaderByteswith431, whatever the way they are received. - A body set on a
204or304response is no longer sent: a handler returningHttpResponse(204, "body")producedcontent-length: 11followed by the body, which the client read as the start of the next response, misreading every later response of the connection. A streaming handler answering204senttransfer-encoding: chunkedand a final0chunk, with the same effect.1xx,204and304responses now end at the empty line after their headers (RFC 9112 §6.3), without body, trailers,Content-Length,Transfer-Encoding,Content-TypenorContent-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 aCOMPRESSION_ERROR. - The HPACK dynamic table of the encoder is capped by
Http2Config::headerTableSize: the encoder took theSETTINGS_HEADER_TABLE_SIZEof the peer as is, so a peer advertising 4 GiB made the connection keep every indexed field it sent (a varyingx-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 withheaderTableSizeinstead of the 4096 bytes of the decoder of the peer (RFC 9113 §4.3.1): with aheaderTableSizeabove 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 ofHttpClient) to 4 GiB, then fill it with fields that each reset their stream (maxHeaderListSizedoes not bound the dynamic table, whose insertions are kept to stay in sync). An update above the advertisedSETTINGS_HEADER_TABLE_SIZEis now a connection error (COMPRESSION_ERROR, RFC 7541 §6.3). AheaderTableSizebelow 4096 applies once the peer acknowledges theSETTINGS(RFC 9113 §4.3.1): before, the requests sent by a client before receiving them could refer to entries already evicted, failing the connection withCOMPRESSION_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_STREAMSTREAM_CLOSEDwithout 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 withCOMPRESSION_ERROR(server andHttpClient). The header block is now decoded before the stream is reset (RFC 9113 §4.3). - A stream window made negative by
SETTINGS_INITIAL_WINDOW_SIZEno longer fails the HTTP/2 connection: a peer lowering itsSETTINGS_INITIAL_WINDOW_SIZEbelow the bytes a stream already sent was answered with aGOAWAY(FLOW_CONTROL_ERROR), closing all the streams of the connection (server andHttpClient). RFC 9113 §6.9.2 allows this negative window: the stream now waits for enoughWINDOW_UPDATEcredit, 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
maxConcurrentStreamsis refused instead of failing the HTTP/2 connection: a HEADERS frame exceedingSETTINGS_MAX_CONCURRENT_STREAMSclosed the whole connection withGOAWAYPROTOCOL_ERROR, while RFC 9113 §5.1.2 makes it a stream error. A client may open a stream before receiving theSETTINGSof the server, or reuse a slot it believes freed: the stream is now reset withREFUSED_STREAM, which tells the client that it can retry the request, and the other streams go on. settingsTimeout,pingInterval,pingTimeoutandmaxStreamsPerConnectionare applied by the HTTP/2 server: theseHttp2Configfields were documented, but ignored. A client not acknowledging theSETTINGSof the server withinsettingsTimeoutnow gets aGOAWAYSETTINGS_TIMEOUT. WithpingIntervalset, a connection whose streams are open but on which nothing happened forpingIntervalis pinged, and closed when thePINGis not acknowledged withinpingTimeout: such a connection, never idle, escapedkeepAliveTimeouteven when its client had disappeared. The stream completingmaxStreamsPerConnectionsends a gracefulGOAWAY, and the connection is closed once its streams are answered. The new streams of the client after aGOAWAYof the server are ignored, but their header blocks are now decoded: ignoring them without decoding made the next requests fail the connection withCOMPRESSION_ERROR(HPACK), or withPROTOCOL_ERRORfor a header block continued inCONTINUATIONframes. AGOAWAYreceived 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, withConnection: 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 itskeepAliveTimeout, delaying the end of the drain. It now receives a gracefulGOAWAY, 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_STREAMof a request whose body was too large, was taken for a stream opened with a decreasing id, closing the connection withGOAWAYPROTOCOL_ERROR(orINTERNAL_ERRORfor 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.HttpClienttook 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): aRST_STREAM(§6.4), DATA orWINDOW_UPDATEframe on a stream never opened was ignored.HttpClientalso 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.cmakecompared uppercase names against vcpkg's lowercase feature list, so every optional feature (openssl,spdlog,brotli,zlib,zstd,opentelemetry,glaze) was always configuredOFF. The features without a dependency (HTTP/2, WebSocket, ...) are no longer forcedOFFby the port: they keep their default (ON), like with the Conan recipe.aeronetConfig.cmakenow setsAERONET_WITH_OPENSSLandAERONET_WITH_HTTP2for consumers, and only looks for OpenSSL when aeronet was built with it. Theopentelemetryfeature pulls the OTLP HTTP exporter of opentelemetry-cpp, andbrotliuses 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()andtiming()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), notopentelemetry-cpp::otlp_http_exporterlike 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. Withwith_zstd, the build needed a zstd installed on the system as well: aeronet copied the include directories of thezstd::libzstdtarget, 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 onlyzlib-ng::zlib: the installedaeronetConfig.cmakedid not look for opentelemetry-cpp nor for a brotli package, which the exported targets reference, and only definedZLIBNG::ZLIBNGfor azlib-ng::zlibstatictarget, absent when zlib-ng installs a single library (vcpkg).find_package(aeronet)works when amc was downloaded during the build: the installedaeronetConfig.cmakecreated theamc::amcstub target after loadingaeronetTargets.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: newopentelemetry: <version>(ordisabled) andfeatures: http-server, http-client, http2, websocket, async-handlers(the enabled ones, ornone) lines betweenloggingandcompression.
Breaking changes¶
locationis rejected inHttpServerConfig::globalHeaders:HttpServerConfig::validate()now throwsstd::invalid_argumentfor it, like for the reserved headers. Its value is specific to each response, and a response made withmakeResponse()starts with the global headers, so a trailing slash redirect carried twolocationheaders, the global one first, which the clients follow. Set it on the responses that need it withHttpResponse::location().- 32-bit targets are rejected: CMake stops at configure time (
CMAKE_SIZEOF_VOID_Pother than 8) andaeronet/compiler-config.hpphas astatic_assertonsizeof(void*)andsizeof(std::size_t). The vcpkg port is only supported onx64andarm64, and the Conan recipe rejects the 32-bitarchsettings. 32-bit targets never were in the CI and did not work in practice (a 32-bitstd::size_ttruncates 64-bit sizes and lengths, and the vendored hash maps crash). See 64-bit targets only. WebSocketConfig::maxMessageSizeandmaxFrameSizeno longer accept0:0used to mean unlimited (formaxFrameSize, undocumented).WebSocketConfig::validate()now throwsstd::invalid_argumentfor it, like the other limits of the library. Setstd::numeric_limits<std::size_t>::max()to approximate no limit.WebSocketConfigmoved toaeronet/websocket-config.hpp(still included bywebsocket-handler.hpp). See Configuration.
Improvements¶
- New
AERONET_ENABLE_PROTOBUFCMake option for Protocol Buffers support,ONby default when aeronet is the top-level project like the other optional dependencies: turn itOFFwhen protobuf is not installed, as it is never fetched. The support is header-only (the aeronet libraries do not use protobuf), andfind_package(aeronet)finds protobuf for the consumers.AERONET_PROTOBUF_RUNTIMEselects thefull(default) orliteruntime.protobufEnabled()(aeronet/features.hpp) and a newprotobuf: <version>line offullVersionStringView()report it. Conan optionwith_protobuf, vcpkg featureprotobuf. 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/configwere 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
SIGPIPEand 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_ZEROCOPYbuffers, default OCSP response with SNI certificates, and many races onstats(), 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 (
400with 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/HttpRequestobjects.
1.7.0 Breaking changes¶
Behavior¶
- Static files: hidden files and links leaving the root are no longer served: with
showHiddenFilesoff (the default), a path with a segment starting with a dot (/.env,/.git/config) is answered404, 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 answered404too. 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,showHiddenFilesonly 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 noOrigin) may connect; others receive403. A factory returningnullptrrefuses the upgrade (403), an exception escaping it is answered500. Behind a reverse proxy that rewritesHost(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 browserHost. 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 newWebSocketConfig::idleTimeout(60 s), and the server pings an idle connection after half of it (autoPing). See Idle connections. - The
500answering 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:
AuthorizationandCookie(including those ofglobalHeaders) are removed when the origin changes, and the3xxresponse is returned instead of following a redirect to cleartext HTTP. Over HTTP/2, the client no longer adds itsglobalHeadersto each request it sends: like over HTTP/1.1, a request carries its own headers (which include the global ones when made bymakeRequest()), 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=0withoutidentityis answered406(RFC 9110 §12.5.3): the wildcard also excludes the uncompressed form whenidentityis not listed.- HTTP/2 requests whose
content-lengthdoes not match their body are reset (RFC 9113 §8.1.1): a non numericcontent-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 withPROTOCOL_ERROR(as soon as the excess arrives). Acontent-lengthabovemaxBodyBytesis still answered413. See Request validation. maxDecompressedBytesbounds the whole decoded body, and exceeding it is answered413(was400): 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 byHttpClient), and decoding into a reused buffer could exceed the limit by the room it already had.- Received
Content-Encodingvalues are lower-cased (content codings are case-insensitive, RFC 9110 §8.4.1): handlers, and client responses kept undecoded, readgzipfor a receivedGZip. See Request body decompression. - HTTP/2 streams start with a 1 MiB receive window:
Http2Config::initialWindowSizedefaults to 1 MiB instead of 65,535 bytes (like hyper and Go), for the server andHttpClient. 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:
maxPerEventReadBytesis now the budget of bytes read plus written per readable event of a connection, andminReadChunkBytesis 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. HttpClientwithkeepAlive = falsenow sendsConnection: 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, holdingTIME_WAITsockets (about 14k new connections/s at most on loopback). When the request has noConnectionheader 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::handshakeTimeoutwas 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()(soHttpServerConfig::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 thanDefault, 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, likewithTlsCertKeyMemory()clears the files. alpnMustMatchalso rejects a client offering no ALPN protocol: it fails the handshake (no_application_protocolalert) and counts intlsAlpnStrictMismatches.- Malformed TLS credentials throw
std::runtime_errorwith the OpenSSL reason (instead ofstd::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
Legacycipher policy extendsCompatibility: 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 (Compatibilitygains the ECDSA CBC SHA-2 ciphers), and AEAD ciphers come first. - HTTP client: unset
tlsMinVersionmeans TLS 1.2, like on the server, instead of no minimum.
API¶
- DogStatsD metrics and
TelemetryContextcounters are sent atflush(): aDogStatsDclient buffers its metrics, and aTelemetryContextsums its counters in memory, untilflush(). The server andHttpClientflush their own telemetry (custom metrics sent throughTelemetryContext::dogstatsdClient()included). ADogStatsDclient orTelemetryContextof your own must callflush()periodically (their destructors flush too). The DogStatsD namespace is limited to 255 characters with its trailing dot. See Batching and flush. RequestTaskcoroutines only accept the awaitables they support:co_awaitonly compiles for aeronet awaitables (bodyAwaitable(),readBodyAsync(),deferWork()),std::suspend_alwaysandstd::suspend_never(newRequestTaskAwaitableconcept). 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 fromawait_suspend(), can declareusing AeronetAwaitableTag = void;. Asynchronous work should usedeferWork().- HTTP client: precise errors for oversized and truncated responses: a response exceeding
maxResponseBytesor the decompression limits now fails with the newHttpClientErrc::responseTooLarge, and an HTTP/1.1 response cut short by the server closing the connection withconnectionClosed(as over HTTP/2).malformedResponseis left to responses that cannot be parsed or decoded (itsErrcToStr()becomesmalformed response). See Error model. HttpServerConfig::connectAllowlistis a public field: replaceconfig.connectAllowlist()byconfig.connectAllowlist(aConcatenatedStrings32, likeglobalHeaders), which makesHttpServerConfigan aggregate that Glaze reflects member by member.withConnectAllowlist()and the configuration file key are unchanged.MultipartFormData::Part::filenameandcontentTypearestd::string_viewinstead ofstd::optional<std::string_view>. UsePart::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 anddumpConfig()are unchanged. See Serialize configuration objects yourself. - Module and header reorganization:
ConnectionState, the request pipeline, the HTTPS redirect andHttp2ProtocolHandlermoved toaeronet_server, andhttp-headers-view.hpp,http-method.hppandmergeable-headers.hpptoaeronet_objects(include paths unchanged).aeronet_httpno longer depends onaeronet_tlsnor publicly onaeronet_websocket, andaeronet_http2is now the HTTP/2 protocol core (HPACK, frames, connection, streams) withoutaeronet_http. A target linkingaeronet_httporaeronet_http2withoutaeronet_servermust linkaeronet_tls/aeronet_websocketitself to use them, and code usingWebSocketEndpointmust 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)andTlsTransport(ssl)replace the constructors taking aZerocopyModeand a minimum size, andTransportno longer exposes the zerocopy state (isZerocopyEnabled(),hasZerocopyPending(),pollZerocopyCompletions(),disableZerocopy()): the server decides eachMSG_ZEROCOPYwrite itself.maxZerocopyPendingBytesno longer disables zerocopy for the rest of a connection: writes beyond it are copied until completions release retained responses. - HPACK:
HpackDecoder::decode()returns aDecodedHeaders(pseudo-header fields apart from the regular ones,pseudoHeader(PseudoHeader::Path)...), received non-const byEventSink::onHeadersDecoded()so that the sink can take its map and storage over. TheHpackDecoderconstructor takes the header list size limit, enforced bydecode()(newHeaderListTooLargeerror). - HTTP/2 frames:
FrameHeaderexposeslength(),type(),flags(),streamId()and is built by its constructor (isValid()removed).DataFrameandHeadersFrameno longer copy the frame flags nor the padding length,ParseHeadersFrame()returns the stream dependency apart,HeadersFrame::headerBlockFragment()is a method. IProtocolHandler::processInput()loses itsConnectionState¶meter, and the unusedProtocolHandlerFactoryalias 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
400when it had duplicate headers or (since 1.6.0) a query string, and before 1.6.0 its request-target was decoded twice (%2561read asa). 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) and100 Continue(interim responses of theExpectationHandlerincluded) 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-Lengthother than 0, orTransfer-Encoding), the response carriesConnection: closeand 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 TLSclose_notify), then discards input until the client closes, at most for the newHttpServerConfig::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: closewhen 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 themaxRequestsPerConnectionlimit. - A streaming handler throwing is answered
500, or its response is signaled incomplete: over HTTP/2 the response used to be completed with a200, over HTTP/1.1 a whole500was written after the head and body bytes already sent. The500is 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:
getaddrinforan 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 get503), each at mostkeepAliveTimeout(then504), and an HTTP/2 stream waiting for it gets no flow-control credit. See Name resolution. - The
200response to an HTTP/1.1 CONNECT has no content: it carried aConnection Establishedbody with aContent-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]:443was answered400over HTTP/1.1 and502over 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
maxOutboundBufferByteswait 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::maxHeaderListSizeand 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 withENHANCE_YOUR_CALMas before) and repeated fields are joined once, in a single copy. The same block now takes 17 KB. - Request bodies are bounded while received:
maxBodyByteswas only checked once the whole body was received, so a client could stream an endless body. A body exceeding it, or acontent-lengthdeclaring more, is answered413right away, then the stream is reset withNO_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 ofmaxOutboundBufferBytes(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(orcontent-type,range...) fields, whose last occurrence wins over HTTP/1.1, were read as their two values joined by the letterO, and an empty occurrence added a leading or trailing separator. See Header names. - Credentials are never indexed (CRIME): the HPACK encoder (server responses and
HttpClientrequests) addedauthorizationandcookievalues 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-authorizationandcookievalues 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 the403; trailing slash redirects were answered404; the middleware metrics callback never fired; request middleware did not see path and query parameters; short-circuiting middleware, a throwing handler's500and streaming responses skipped the response middleware; streaming responses lacked CORS headers;405had 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 a413) were each answered by anotherRST_STREAM, and a trailer block was answered without being decoded, leaving the HPACK state out of sync (laterCOMPRESSION_ERRORor 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, sohasMoreBody()was false andreadBody()threw.- HTTP/2 requests know their connection:
clientAddress(),tlsCipher(),tlsVersion()andalpnProtocol()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 withreadBody()(they calledbody()).
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-Lengthrequest 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, anddeferWork()no longer terminates the process when no thread can be created (thestd::system_erroris rethrown byco_await).- An async handler throwing when called no longer stops the server: a function returning a
RequestTaskwithout being a coroutine, throwing before creating it, propagated the exception out of the HTTP/1.1 event loop. It is answered500through 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/TRACEanswer on an async route whose body was not fully received left the request unconsumed, so it was answered again and again (tens of thousands of401per 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_alwayslike 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 countsssl_set_accept_state_failed, the client throws); builds against OpenSSL 4 (the client no longer calls the deprecatedSSL_set1_host()). - Hot reload: TLS files rotated at the same path (rewritten in place, atomic rename, Kubernetes secret volume
..dataswap) are reloaded bypostConfigUpdate(): each applied update checks the files the context was built from (onestat()per file) and rebuilds it when one changed, so an empty update reloads rotated files; files are never checked on the handshake path. A standaloneSingleHttpServerkeeps 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
TLSConfigand the strings ofHttpClientConfig(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(nothandshake_eof); tlsAlpnStrictMismatchesis no longer reset by a TLS context rebuild;- no more
Ignoring excess 0 TLS session ticket keyswarning with exactlymaxKeysstatic keys. - HTTP client: no SNI for IP addresses (RFC 6066); HTTPS verification works on Windows without an explicit CA (the trusted roots of the
ROOTsystem store are loaded whenSSL_CERT_FILE/SSL_CERT_DIRare not set).
WebSocket¶
permessage-deflateinteroperates 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 negotiatedserver_max_window_bits/client_max_window_bitsare 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 offerclient_max_window_bits. No more spuriouszlib: end returned -3warning 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).maxMessageSizenow applies to the decompressed size (below 16 KiB every compressed message was rejected, and a message of exactlymaxMessageSizebytes 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::WithFactoryfactory was replaced by one without callbacks; compression is now enabled on it through the newWebSocketHandler::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:
closeTimeoutis 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 holdTIME_WAIT), a per-routetimeout()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
keepAliveTimeoutwas 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 wholekeepAliveTimeout(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_ZEROCOPYon kTLS connections (kTLS sockets reject it: each large write failed first).
Compression and static files¶
- Truncated zstd bodies are rejected (
400for a request,malformedResponsefor 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
maxDecompressedBytesare accepted in streaming mode (they were rejected withindecoderChunkSizeof the limit while decoding was still in progress). Accept-Encodingnegotiation with a partialpreferredFormats: 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-deflatedata 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 answered503; they are now served as directories and answered404. - HTTP client: an empty HTTP/1.1 response body with a
Content-Encodingis returned empty (it failed withmalformedResponsefor 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 invalidcontent-length, now fails withmalformedResponse(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 a413); the client discarded the complete response and failed withconnectionClosed. - A rejected redirect
Locationno 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 theHttpRequestgiven toHttpClient::request(HttpRequest&&)unsafe to reuse. The target is now validated first, the switch to a body-lessGETonly applies to a followed redirect, and an invalid absolute-pathLocation(/a%zz) returnsHttpClientErrc::invalidUrlinstead of throwingstd::invalid_argument. - Idle connections to origins no longer requested are swept: an idle pooled connection older than
keepAliveTimeoutwas 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 perkeepAliveTimeout.
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
MultiHttpServerworker). 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
maxHeaderBytesormaxBodyBytesat runtime no longer stops a server with routes: routes registered withsetPath()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. headerReadTimeoutno 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.1408), 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. ChangingheaderReadTimeoutorbodyReadTimeoutat 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: whenbeginDrain()is called right after it started (workers not launched yet never drained, andrun()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 concurrentstop()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 ofMultiHttpServer): it only requests termination (still non-blocking), the event loop closes the listener and connections itself, and a restart before it exited throwsstd::logic_error.stop()called from a handler no longer terminates the process (the event-loop thread tried to join itself) nor deadlocksMultiHttpServer::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
SIGPIPEon connections reset by the peer (Linux): a client leaving in the middle of a response could terminate a server not ignoringSIGPIPE, and a server closing during an upload could do the same to anHttpClientuser (TLS requests, file bodies). Plain sockets are written withsend()/sendmsg()andMSG_NOSIGNAL; assendfile()and OpenSSL cannot pass that flag, the event loop keepsSIGPIPEblocked in its thread for its whole run (andHttpClientduring TLS and file-body exchanges), the write failing withEPIPE. The process-wideSIGPIPEdisposition 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_TSANoption): 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()andstop()can be called while another thread runsrun()/start();- the
SIGINT/SIGTERMhandler ofSignalHandlerno 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 latestTelemetryConfig::exportIntervalafter they end (a span ending while the queue is full, 2048 by default, is dropped). aeronet.bytes.readcounts every socket read (only the read right after the accept was counted), andRequestMetrics::reusedConnectionisfalsefor the first request of a connection (it was alwaystruefor buffered HTTP/1.x responses, CONNECT tunnels and HTTPS redirects). See Observability.
Platforms and build¶
- Windows:
- files can be sent several times:
TransmitFileworks from the current position of the file, which was shared with allFilecopies (duplicated descriptor), so sending the same file or a copy again failed withWSAEINVAL. The file is positioned before each transfer, aFilecopy reopens the file, and the length of a singleTransmitFilecall is capped (it sends at most 2,147,483,646 bytes, so largerHttpClientuploads failed); - read and write lengths above the Winsock limits are clamped: a
maxPerEventReadBytesorHttpClientConfig::minReadChunkBytesabove 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), soHttpClientbusy-polled and the server received spurious writable events.mod()now deletes the absent filters in the samekevent()call. - Shared library builds (
AERONET_BUILD_SHARED=ON) link with every feature: with brotli enabled,libaeronet_http.soand callers offullVersionWithRuntime()used brotli without linking it. Static dependencies (zlib-ng, zstd) are now position independent, shared libraries are linked with-z defson 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)andget(url, sink)deliver the body of the final response to aResponseBodySinkas 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. HttpClientConfigloads from JSON and YAML files:LoadClientConfig()(new<aeronet/client-config-loader.hpp>) reads theclientsection of a configuration file,HttpClientaccepts the path of such a file,dumpConfig()/saveConfig()dump a client configuration andaeronet-config-dump --clientprints 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 oncerequestTimeout, 30 s by default, is raised above it), so that a long download with a largerequestTimeoutstill 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_ERRORfor a malformed response,CANCELotherwise) 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 aheaderRemoveLine()call per occurrence). Same rules asheaderRemoveLine()for the headers managed by aeronet.deferWork()accepts more work functions: they may returnvoidor a type that is not default constructible, and may be move-only.bodyInlineAppend()appends to moved and static bodies (it threwstd::logic_errorfor a body set bybody(std::string&&), vectors,std::unique_ptrbuffers orbodyStatic(), unlikebodyAppend()): astd::stringor vector is written in place at the end of its buffer, a static orstd::unique_ptrbody is copied once into a message buffer, and afile()body still throws. Adding a trailer to a movedstd::stringor vector body no longer reallocates and copies it each time. See Bodies.- Constructors,
makeResponse()andmakeRequest()move owned bodies: like thebody()setters,HttpResponseconstructors,HttpRequestView::makeResponse()andHttpClient::makeRequest()accept astd::string&&,std::vector<char>&&,std::vector<std::byte>&&or astd::unique_ptr<char[]>/std::unique_ptr<std::byte[]>buffer with its size and move it into the message (HttpResponse(std::format(...))andreq.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
ServerStatscounters for zerocopy:zerocopySends,zerocopyBytes,zerocopyCopiedSends,zerocopyNoBufferSpaceFallbacks,zerocopyRetainLimitFallbacks,zerocopyResetConnections. NewHttp2Config::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 atminReadChunkBytes(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 ofread()/write()/writev()(about 100 to 250 cycles per call), and a single remaining buffer is sent withsend(). - 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
eventfdat each deferral. - Cheaper keep-alive bookkeeping: one update of the keep-alive deadline heap per
keepAliveTimeoutper active connection instead of one per event. - Cheaper
Accept-Encodingnegotiation and keep-alive decision: each event loop remembers the result for the lastAccept-Encodingvalue (up togzip, deflate, br, zstdlong), and theConnectionheader 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 withTCP_NODELAY(400-500k to 1.1-4.2M requests/s) and without the 40 ms stalls in the default configuration. TCP_CORKonly for flushes taking several writes (Linux): every response write used to cost twosetsockopt()syscalls withTCP_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_ZEROCOPYwhen 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 its101to be fully written. See Performance. - Memory footprint of messages: the captured body payload is no longer a 64-byte member of
HttpMessageand 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) ofRawChars/RawBytesgrow bymalloc+ copy +freeinstead ofrealloc, 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-lengthis allocated at once (up toconnectionWindowSize, 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::maxRecycledRequestsfinished 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-levelWINDOW_UPDATEonly 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_ZEROCOPYis enabled once on the listening socket (inherited), completions are read onEPOLLERRinstead of up to threerecvmsg()per write,Opportunisticmode stops using zerocopy on a connection once the kernel reports copied sends (same-host peer, container network), and a send failing withENOBUFSmakes 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 fromonMessage(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 (newwebsocket::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_TSANCMake option: builds aeronet and its tests with ThreadSanitizer (AERONET_TSAN_OPTIONSoverrides the flags; cannot be combined withAERONET_ENABLE_ASAN). The test allocation failure injection is disabled under ThreadSanitizer, as under AddressSanitizer. Seedocs/INSTALL.md. - Much lighter development builds: the new
AERONET_COMPACT_DEBUG_INFOoption (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_BUILDCMake 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.
SingleHttpServersplit 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),SingleHttpServerkeeping 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 throughrouter(), CONNECT resolver wakeups):HttpRequestViewis 24 bytes smaller, dispatching an async handler no longer copies astd::functionandrouter()no longer allocates.RouterUpdateProxy::Dispatcheris 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 aLowerAsciiKey: 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,
0no longer means "unlimited": StaticFileConfig::maxMultipartRanges:0now rejects every range query with416;Http2Config::maxStreamsPerConnection: default1000000;DecompressionConfig::maxCompressedBytes: default128 MiB, andmaxExpansionRatio: default1000.0;DecompressionConfig::streamingDecompressionThresholdBytes = 0now streams every body (it used to mean aggregated mode);HttpServerConfig::zerocopyMinBytescannot be0(10 KBor more recommended).- TLS 1.2 or newer by default: TLS 1.0 and 1.1 handshakes are refused unless
TLSConfig::minVersionis set explicitly. - HTTP methods are case-sensitive (RFC 9110 §9.1):
getorGeTis an unknown method answered501(and a denied CORS preflight). Accepting them allowed bypassing intermediary rules written for the exact method name. - API:
StaticFileConfigandDecompressionConfigfield order changed (decoderChunkSizeis auint32_tand the second field ofDecompressionConfig): check aggregate initializations;CorsPolicy::PreflightResult::responseis astd::optional<HttpResponse>, only set when the preflight is allowed;InMemoryTokenBucketRateLimitStorehas no default constructor:explicit InMemoryTokenBucketRateLimitStore(uint8_t nbShards);RetryConfig::retryStatusesis a set-like container;MiddlewareResult::Decisionwas removed;HttpClientConfig::validate()rejects nonsensical timeouts and retry settings (std::invalid_argument);Http2Config::enablePushandenableH2cUpgradeare 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(Opportunisticby default, for responses of at leastzerocopyMinBytesto 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 oncemaxOutboundBufferBytesis 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 to0(which ended the body early and let the following bytes be parsed as another request) and gets413, and aContent-Lengthclose toSIZE_MAXno 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%00in the path is rejected (all400); Connection: closeis honored anywhere in theConnectionlist (RFC 9110 §7.6.1), and an HTTP/1.0 connection is only kept alive with an explicitkeep-alive;- the client validates header names and values of HTTP/1.1 responses, and
HttpClientConfig::maxResponseBytescounts 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 bylocation()) did not validate the value andreason()accepted any character, so CR/LF could split the response (both now throwstd::invalid_argument).%00/%1Fin query strings, the internal separators of decoded parameters, stay encoded (?a=1%00admin%1Ftruecould 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 inHttpResponseconstructors). - 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 intoLocationand 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 = truemade the server advertiseSETTINGS_ENABLE_PUSH = 1, which RFC 9113 §6.5.2 forbids and curl answers by closing the connection: servers and clients now always advertise0, and the client treats a server advertising1asPROTOCOL_ERROR;- cleartext
Upgrade: h2crequests are answered over HTTP/1.1 instead of hanging (deprecated mechanism, RFC 9113 §3.1; prior-knowledge cleartext HTTP/2 remains available throughHttp2Config::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
CONNECTno 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 toallowRequestHeader()/exposeHeader()are deduplicated case-insensitively); - a throwing
startDetachedAndStopWhen()/startDetachedWithStopToken()predicate no longer crashes on Windows, and a data race inAsyncHandle::stop()/ lifecycle state is fixed (ThreadSanitizer). HttpMessageconsistency:- body headers stay consistent when an empty
file()body is replaced, when a body is replaced by a directly compressed one (Content-Encodingwas lost), and when headers are added during direct compression (they were overwritten by the finalContent-Length); Content-Type,Content-LengthandContent-Encodingcan no longer be modified through the genericheaderAppendValue/headerRemoveValue(same rules asheader,headerAddLine,headerRemoveLine);headerRemoveValuetrims the value and no longer corrupts headers when removing an empty token ("a, b"became"ab");Content-Typevalues as short asa/bare accepted by all body setters and in client responses, and replacing aContent-Typecontaining:wrote at a wrong position;- streaming
HttpResponseWriter::file()sends a singleContent-Type, andHttpResponseWriterthrows on an invalidContent-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 movedHttpClient(orSingleHttpServerwith 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=OFFandAERONET_ENABLE_WEBSOCKET=ON, withAERONET_ENABLE_HTTP2=ONandAERONET_ENABLE_HTTP_CLIENT=OFF, and in Release withoutAERONET_ENABLE_TEST_HOOKS.
1.6.0 Improvements¶
- Bounded deferred output and zerocopy retention:
maxOutboundBufferBytesnow 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, answers503for fixed responses that cannot fit, and resets streaming responses exceeding the newHttp2Config::maxStreamPendingBytes. The newmaxZerocopyPendingBytesceiling 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
Http2Connectionshrunk from 728 to 524 bytes. - Server performance:
Dateheader 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 libcmemchr, lock-sharded rate limiter, fewer copies in the WebSocket upgrade and for a large firstHttpResponseWriterchunk, no moreHttp2Streampush 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::maxPriorityTreeDepthis 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(defaulttrue),HttpClientis 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 of505.
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.handbrotli/encode.hremoved from internal headers;aeronet/version.hppadded toaeronet-client.hpp. - Unknown HTTP/2
SETTINGSids 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::connectAllowlistrejects every target with403, closing an unauthenticated SSRF / open proxy in default configurations (HTTP/1.1 and HTTP/2). Configure trusted hosts withwithConnectAllowlist()(or theconnectAllowlistsetting);["*"]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,hasHeaderandhasTrailertake aLowerAsciiKey(headerValue("Host")no longer compiles,headerValue("host")does).headers()andtrailers()return a case-sensitive map with lower-case keys (find("X-Header")can no longer match). http::Connection,http::ContentType,http::Host... areLowerAsciiKeyinstead ofstd::string_view(validated at compile time). Implicit conversion back keeps typical code unchanged;*Sepformatting constants staystd::string_view.File::Identityis private andFile::identity()is replaced byFile::appendIdentityData(char*)(buffer of at leastFile::kIdentitySizebytes).- 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 ashared_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
keepAliveTimeoutno 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 a200with fewer bytes thancontent-length. - Stricter request validation:
- HTTP/1.1 requests without
Hostget400(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_ERRORwithout closing the connection (field sections are still decoded in full to keep HPACK in sync); unsupported extension methods return501instead of being dispatched asGET; Http2Config::maxHeaderListSizeis 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
Startingstate 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 withEV_RECEIPT).
1.5.0 Improvements¶
- Router: literal-route slots store compact side-table indices and
RoutingResultreferences immutable route metadata (96 to 48 bytes,Router::match39% 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
WriteIntinstead ofstd::to_chars, and lookups normalized to lower case. - The 16-setting limit of
SETTINGSframes 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_tlsstatic 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) whenSSL_CERT_FILE/SSL_CERT_DIRare 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¶
HttpRequestrenamedHttpRequestView(non-owning).HttpRequestis now an owning type used by theHttpClientAPI to build a request, mirroringHttpResponse.- HTTP/2-specific
HttpRequestViewAPIs (isHttp2(),streamId(),scheme(),authority()) are only compiled withAERONET_ENABLE_HTTP2. - JSON/YAML helpers (
HttpResponse::bodyJson/bodyYaml,HttpRequestView::bodyAs/bodyAsYaml) need<aeronet/http-json.hpp>(the<aeronet/aeronet.hpp>umbrella is unaffected), andaeronet::logneeds<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::httpVersionAuto/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,CONNECTtunnel for HTTPS origins) and an opt-in response cache (withCache). Requests return anHttpClientResult(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 withkidselection,alg:nonealways rejected, algorithm-family anti-confusion and constant-time HMAC. Exception-free, JWE out of scope, plus abase64urlcodec. See the JWT guide. - HTTP to HTTPS redirect:
HttpServerConfig::withHttpsRedirect(targetHttpsPort = 443, statusCode = 301)answers every plaintext request with a redirect built from theHostheader (path and query re-encoded,400withoutHost). Not combinable withtls.enabledon the same listener. - Rate-limiting middleware:
RateLimitRequestMiddlewareBuilderwith configurable client keys (peer address,X-Forwarded-For, custom header or extractor), an in-memory token bucket (429withRetry-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,/readyzand/startupzfrom a separate event loop, with heartbeat-based liveness (unhealthy only when every worker's heartbeat is stale beyondlivenessStaleThreshold, 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 (eventuallyMax 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;
requireClientCertwas silently ignored whenrequestClientCert=false(the invariant is now enforced on every configuration path). - HTTP/1.1: empty-body responses omitted
Content-Length(defeating keep-alive;Content-Length: 0is 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 infullVersionStringView()was fixed, and built-in server error bodies carry aContent-Type.
1.4.0 Improvements¶
- Lower compile-time footprint of core headers (Glaze and spdlog moved out),
aeronetsplit intoaeronet_server,aeronet_clientand anaeronetumbrella target. - Static files: per-file
ETag/Last-Modified/Content-Typefragments 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 smallCopy/Append(about 1.5x on a realistic mix); better HPACK reserve. HttpServerConfig::nbThreadsnarrowed tostd::uint16_t; status line limit raised from 64 KiB to 16 MiB andreasonlimit from 1024 to 65522; scripted HTTP/2 client benchmarks (vs libcurl,h2candh2-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 astd::regexfallback. - Route groups (
RouteGroupviaRouter::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) andmaxHeaderBytes(431), throughPathEntryConfigor the chainable.timeout(),.maxBodyBytes(),.maxHeaderBytes(). - Configuration from YAML / JSON files with
AERONET_ENABLE_GLAZE(SingleHttpServerandMultiHttpServerconstructors taking a path). - Structured access logging (
HttpServerConfig::accessLog):clforjson, sinksnone,stdout,file, optionalX-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), andHttpServerConfig::maxAcceptBatchSize(64) against connection-burst starvation. - Deterministic network fault injection test infrastructure (
FaultPolicy,TestPipe,FaultInjectingTransport, compile-time gated byAERONET_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
maxPerEventReadBytescut 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 whenepoll_ctl(EPOLL_CTL_MOD)failed. minReadChunkBytesis honored as the actual read size; HTTP/2 admits peer-initiated streams against the localSETTINGS_MAX_CONCURRENT_STREAMS; no emptyHEADERSframe for deferred file sends without trailers.
1.3.0 Breaking changes¶
HttpServerConfig::maxHeaderBytes(andwithMaxHeaderBytes()) is astd::uint32_t(wasstd::size_t).HttpServerConfig::maxPerEventReadBytesmust be> 0(std::numeric_limits<uint32_t>::max()approximates unlimited) and defaults to128 KiBinstead 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,
HttpResponseWritercompression buffer reuse, HTTP/2 per-stream map consolidation, async handler state allocated only for connections using them. - Backpressure rejects extra response chunks immediately once
maxOutboundBufferBytesmarks 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::tcpNoDelayis an enum with anAutomode (default:TCP_NODELAYfor HTTP/2 over TLS only).withTcpNoDelay()is unchanged.
1.2.0 New features¶
- HTTP/2
CONNECTtunneling (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 bymaxMultipartRangesandmaxMultipartBodySize. StreamingHandlersupports HTTP/2.
1.2.0 Bug fixes¶
- HTTP/2
:pathquery parsing now mirrors HTTP/1.1 (in-place percent-decoding);Routerno 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 returns400instead of waiting. - OpenSSL error queue is cleared after
SSL_shutdown()(stale errors misclassified on connection reuse);ENOBUFSon zerocopy responses falls back to copying; zerocopy data corruption under sustained load on virtual network devices fixed by draining completion notifications before each send (andENOBUFShandling on TLS + kTLS). CompressionConfig::maxCompressRatiois strictly respected; a race in multi-threadedstop()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 andsendfilechunking.- Compression: streaming compression starts with a small chunk size growing exponentially, brotli encoder state and zstd contexts are reused, and
HttpResponseWriterno longer over-allocates for brotli and zstd. - Connections are stored in a vector indexed by fd; no
memmovefor non-prepared HTTP/2 bodies; fewersteady_clock::now()calls;std::memchrCRLF search; binary search for HPACK static names;HttpServerConfig::zerocopyMinBytes(128 KiB); compression telemetry counters (aeronet.http_responses.compression.*);WebSocketDeflatevalidation; HTTP/2 hardening and metrics. - zlib-ng (native mode, SIMD) replaces zlib for gzip / deflate and WebSocket
permessage-deflate(AERONET_ENABLE_ZLIBNG=OFFkeeps classic zlib, bumped to 1.3.2). - New
fullVersionWithRuntime(), clang-format 21, HTTP/2 benchmarks withh2loadin CI.
[1.1.0] - 2026-02-16¶
1.1.0 Breaking changes¶
HttpServerConfig::globalHeadersmust be key-value pairs separated byhttp::HeaderSep; invalid header names or values inserted in a response, or malformed concatenated headers inHttpResponseconstructors, throwstd::invalid_argument.- Setting
Content-TypeandContent-Lengththroughheader/headerAddLineis prohibited (it was undefined behavior): usecontentType()/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 fromHttpServer/SingleHttpServerand telemetry metric methods (DogStatsDincluded) are no longerconst.
1.1.0 New features¶
- Direct compression: inline bodies created through
HttpRequestView::makeResponse()can be compressed atbody()/bodyAppend()time (DirectCompressionModeAuto/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/reservehelpers onHttpResponse, andHttpServerConfig::addTrailerHeader(HTTP/1.1).- New
HttpRequestViewhelpers:hasHeader,hasTrailer,hasPathParam,hasQueryParam,pathParamValue*,queryParamValue*,queryParamInt,headerRemoveLine,headerRemoveValue. Router paths accept an asterisk as a non-terminal segment (/files/*/metadata). MSG_ZEROCOPYsupport (plain text and kTLS, Linux) throughHttpServerConfig::withZerocopyMode()(Disabled,Opportunisticdefault,Enabled).- New
AERONET_ENABLE_ASYNC_HANDLERSoption and an experimental C++20 module interface file.
1.1.0 Bug fixes¶
Content-Lengthupdated correctly bybodyAppend()on a captured body; HEAD responses with trailers omit the body and no longer switch to chunked encoding (RFC 7230 §4.3.2).Accept-Encodingparsing is closer to RFC 7231 §5.3.4 (highestqamong duplicate encodings), andh2cconnections handle the HTTP/1.1Upgrademechanism.
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(), exponentialbodyAppendgrowth, and a single shared compression buffer per server instead of per connection. - Header values stored in
HttpResponse/HttpResponseWriterare trimmed;DogStatsDreconnects automatically;WebSocketConfig::maxMessageSizeis strictly enforced on decompression; prepared responses avoid body and trailer allocation for HEAD. body(std::string&&, ...)andbody(std::vector<std::byte>&&, ...)only capture rvalues (an lvaluestd::stringselects the inlinestd::string_viewoverload, avoiding silent copies); an invalid chunked length returns400instead of413;CompressionConfig::maxCompressRatiodefault raised from0.5to0.6.- ARM64 in CI, Crow in the benchmarks, a
body-codecscenario, 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, configurableTRACE, duplicate header merging. - HTTP/2 (RFC 9113): HPACK, multiplexing with stream and connection flow control, ALPN
h2, h2c prior knowledge andUpgrade, the sameHttpRequestViewAPI as HTTP/1.1, optionalPRIORITY, CORS and middleware. (Server push is intentionally not implemented.) - Compression: gzip, deflate, zstd and brotli (feature-gated),
Accept-Encodingnegotiation with q-values, inbound body decompression with safe limits (413/415), automaticVary. - 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(HttpServerwrapper), 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),SafeCastand 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,
400on malformed input, opt-in Slowloris header timeout, decompression expansion guards,415on 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.