Merge pull request 'Add HTTP/1.1 client and server' (#2) from claude/issue-1 into master

This commit is contained in:
catbot 2026-07-27 03:05:15 +02:00
commit 219a31a8f7
16 changed files with 2605 additions and 140 deletions

View file

@ -1,16 +1,17 @@
# Crafter.Network # Crafter.Network
A cross-platform C++ networking library providing TCP, QUIC, HTTP/3, and WebTransport client/server functionality with modern C++ features. Builds for native Linux and for the browser (wasm32-wasip1). A cross-platform C++ networking library providing TCP, QUIC, HTTP/3, HTTP/1.1, and WebTransport client/server functionality with modern C++ features. Builds for native Linux and for the browser (wasm32-wasip1).
## Overview ## Overview
Crafter.Network is a C++ networking library designed for modern C++ applications. It provides TCP, QUIC, HTTP/3, and WebTransport-over-HTTP/3 capabilities with support for synchronous and asynchronous operations, making it suitable for a wide range of networking tasks including real-time multiplayer games. The same source compiles for native Linux (via msquic + POSIX sockets) and for the browser (via `fetch()` + `WebTransport` JS APIs); see [Browser build](#browser-build). Crafter.Network is a C++ networking library designed for modern C++ applications. It provides TCP, QUIC, HTTP/3, HTTP/1.1, and WebTransport-over-HTTP/3 capabilities with support for synchronous and asynchronous operations, making it suitable for a wide range of networking tasks including real-time multiplayer games. The same source compiles for native Linux (via msquic + POSIX sockets) and for the browser (via `fetch()` + `WebTransport` JS APIs); see [Browser build](#browser-build).
## Features ## Features
- **TCP Networking**: Client and server implementations for raw TCP connections (native only). - **TCP Networking**: Client and server implementations for raw TCP connections (native only).
- **QUIC Networking**: Encrypted, multi-stream transport via msquic — reliable streams for control plane, unreliable datagrams for low-latency state sync. - **QUIC Networking**: Encrypted, multi-stream transport via msquic — reliable streams for control plane, unreliable datagrams for low-latency state sync.
- **HTTP/3**: Client and server implementations on top of QUIC. Uses ALPN `h3`, QUIC bidi streams for requests/responses, the mandatory unidirectional control stream + SETTINGS frame (RFC 9114 §6.2.1), the (empty) QPACK encoder + decoder unidi streams required by stricter peers like Chromium, and a built-in QPACK codec (RFC 9204) with the full static table, Huffman *decoding* (RFC 7541), and literal-only emission. The QPACK dynamic table is unused. The client is interoperable with mainstream public h3 endpoints (cloudflare, nghttp3-based servers, etc.). - **HTTP/3**: Client and server implementations on top of QUIC. Uses ALPN `h3`, QUIC bidi streams for requests/responses, the mandatory unidirectional control stream + SETTINGS frame (RFC 9114 §6.2.1), the (empty) QPACK encoder + decoder unidi streams required by stricter peers like Chromium, and a built-in QPACK codec (RFC 9204) with the full static table, Huffman *decoding* (RFC 7541), and literal-only emission. The QPACK dynamic table is unused. The client is interoperable with mainstream public h3 endpoints (cloudflare, nghttp3-based servers, etc.).
- **HTTP/1.1**: Client and server over plain TCP (RFC 9112), for the large part of the world that is not ready for HTTP/3 — old proxies, CI tooling, load balancers, `curl` scripts. Shares the `HTTPRequest`/`HTTPResponse` types and the route-map API with the HTTP/3 stack, so a handler or call site moves between the two by changing the class name. Keep-alive and pipelining, `content-length` and `chunked` bodies with trailers, `Expect: 100-continue`, HEAD, automatic `Date`, and per-connection timeouts. Plaintext only — see [HTTP/1.1 Components](#http11-components).
- **WebTransport (server)**: `ListenerHTTP` accepts extended-CONNECT sessions (`:method=CONNECT, :protocol=webtransport`) negotiated on the existing h3 listener — no separate port or alternate stack. Both draft-02 and draft-07+ identifier sets are advertised in SETTINGS so current Chrome/Edge browsers connect out of the box. Per-route handlers receive a `WebTransportSession&` and can multiplex bidirectional streams over the session. - **WebTransport (server)**: `ListenerHTTP` accepts extended-CONNECT sessions (`:method=CONNECT, :protocol=webtransport`) negotiated on the existing h3 listener — no separate port or alternate stack. Both draft-02 and draft-07+ identifier sets are advertised in SETTINGS so current Chrome/Edge browsers connect out of the box. Per-route handlers receive a `WebTransportSession&` and can multiplex bidirectional streams over the session.
- **Browser client**: Same C++ API compiled to wasm32-wasip1 and routed through `fetch()` (for `ClientHTTP`) and `WebTransport` (for `ClientQUIC`). Listeners and raw TCP are not compiled in the browser build — the browser is client-only. - **Browser client**: Same C++ API compiled to wasm32-wasip1 and routed through `fetch()` (for `ClientHTTP`) and `WebTransport` (for `ClientQUIC`). Listeners and raw TCP are not compiled in the browser build — the browser is client-only.
- **Asynchronous Operations**: Thread poolbased async operations on native; the same `*Async` API on the browser side, where it's required (no synchronous I/O in the browser event loop). - **Asynchronous Operations**: Thread poolbased async operations on native; the same `*Async` API on the browser side, where it's required (no synchronous I/O in the browser event loop).
@ -27,7 +28,10 @@ The library follows a modular design using C++20 modules:
- `Crafter.Network:ListenerTCP`: TCP server implementation (native only) - `Crafter.Network:ListenerTCP`: TCP server implementation (native only)
- `Crafter.Network:ClientHTTP`: HTTP/3 client (ALPN `h3`). On browser builds this maps to `fetch()`. - `Crafter.Network:ClientHTTP`: HTTP/3 client (ALPN `h3`). On browser builds this maps to `fetch()`.
- `Crafter.Network:ListenerHTTP`: HTTP/3 + WebTransport server (ALPN `h3`, native only) - `Crafter.Network:ListenerHTTP`: HTTP/3 + WebTransport server (ALPN `h3`, native only)
- `Crafter.Network:HTTP`: HTTP request/response types and constructors - `Crafter.Network:HTTP`: HTTP request/response types and constructors, shared by every HTTP version
- `Crafter.Network:ClientHTTP1`: HTTP/1.1 client over TCP (native only)
- `Crafter.Network:ListenerHTTP1`: HTTP/1.1 server over TCP (native only)
- `Crafter.Network:HTTP1`: HTTP/1.1 wire format — serialisation plus an incremental parser (RFC 9112). Transport-free; usable on its own to speak HTTP/1.1 over some other byte stream. Native only, for the same reason as `:HTTP3`
- `Crafter.Network:ClientQUIC`: QUIC connection (client + accepted-server side) with reliable streams and unreliable datagrams. On browser builds this maps to the `WebTransport` JS API. - `Crafter.Network:ClientQUIC`: QUIC connection (client + accepted-server side) with reliable streams and unreliable datagrams. On browser builds this maps to the `WebTransport` JS API.
- `Crafter.Network:ListenerQUIC`: QUIC listener accepting incoming connections (native only). Also exports `ComputeCertificateHashSHA256()` and `GetSelfSignedCertificatePath()` for browser-peer cert pinning. - `Crafter.Network:ListenerQUIC`: QUIC listener accepting incoming connections (native only). Also exports `ComputeCertificateHashSHA256()` and `GetSelfSignedCertificatePath()` for browser-peer cert pinning.
- `Crafter.Network:WebTransport`: `WebTransportSession` type — the per-session handle handed to `ListenerHTTP` WT route handlers. - `Crafter.Network:WebTransport`: `WebTransportSession` type — the per-session handle handed to `ListenerHTTP` WT route handlers.
@ -89,6 +93,58 @@ listener.Listen();
The `HTTPRequest` exposes the four HTTP/3 pseudo-headers (`method`, `scheme`, `authority`, `path`) as named struct fields rather than mixing them into the regular `headers` map. Routes are dispatched by exact match on `path`; unmatched paths return a synthetic 404. The `HTTPRequest` exposes the four HTTP/3 pseudo-headers (`method`, `scheme`, `authority`, `path`) as named struct fields rather than mixing them into the regular `headers` map. Routes are dispatched by exact match on `path`; unmatched paths return a synthetic 404.
### HTTP/1.1 Components
HTTP/3 is not something every peer can be made to speak. `ClientHTTP1` and `ListenerHTTP1` provide the same API over plain TCP, using the same `HTTPRequest`/`HTTPResponse` types and the same route-map shape, so a handler can be registered with both and served over either protocol.
#### ClientHTTP1
```cpp
Crafter::ClientHTTP1 client("localhost", 8080);
Crafter::HTTPResponse response = client.Send(
Crafter::CreateRequestHTTP("GET", "/", "localhost")
);
```
The connection is persistent: the first `Send()` dials, and later calls reuse the socket unless the peer asked for it to be closed. A pooled connection that the peer closed while it looked idle — the race HTTP/1.1 keep-alive cannot avoid — is redialled once and the request replayed; nothing is replayed once a response byte has arrived, and a freshly dialled connection is never retried on, so a genuinely broken server surfaces as an exception rather than a retry loop. `authority` defaults to the host:port the client was constructed with (the port is elided when it is 80).
#### ListenerHTTP1
```cpp
std::unordered_map<std::string,
std::function<Crafter::HTTPResponse(const Crafter::HTTPRequest&)>> routes;
routes["/hello"] = [](const Crafter::HTTPRequest&) {
return Crafter::CreateResponseHTTP("200", "Hello World!");
};
Crafter::ListenerAsyncHTTP1 listener(8080, std::move(routes));
```
Each accepted connection gets its own thread and is served sequentially until the peer closes it, a `Connection: close` is seen, or a timeout expires (`keepAliveTimeout`, default 15 s between requests; `requestTimeout`, default 30 s for one request to arrive). A dedicated thread rather than a ThreadPool task is deliberate: keep-alive connections are idle most of their life and would otherwise pin every pool thread.
Routing matches `path` exactly and then falls back to the query-stripped path, so `/thing?x=1` reaches the handler registered for `/thing` while the handler still sees the full target in `request.path`. A handler that throws becomes a 500; an unknown path a 404; a request we refuse to parse a 400. `Date` is stamped automatically unless the handler set one, HEAD returns the headers a GET would have produced with no body, and a handler can end the connection by answering with a `connection: close` header.
Implemented: keep-alive, pipelining, `content-length` and `chunked` request bodies with trailers, `Expect: 100-continue`, absolute-form request targets, obs-fold, and HTTP/1.0 peers (which only get connection reuse when they ask for it). Not implemented: TLS, CONNECT tunnels, `Upgrade`, and chunked *responses* — handlers return a complete body, so responses are always `content-length` framed.
Ambiguous framing is rejected rather than guessed at, because guessing is how request smuggling happens (RFC 9112 §11.2): `Content-Length` together with `Transfer-Encoding`, disagreeing duplicate `Content-Length` values, and whitespace between a field name and its colon are all 400s. CR/LF in a header value we are asked to *send* throws instead of splitting the message.
#### No TLS
`ClientHTTP1`/`ListenerHTTP1` speak `http://` only. There is no `https://` support and no plan to link a TLS stack into this path: for encrypted traffic use `ClientHTTP`/`ListenerHTTP` (HTTP/3 over QUIC, which is always encrypted), or terminate TLS in a proxy in front of the HTTP/1.1 endpoint. Do not expose an HTTP/1.1 listener directly to the internet.
#### HTTP/1.1 wire format on its own
`Crafter::HTTP1` exposes the codec without the transport — `SerializeRequest` / `SerializeResponse` and an incremental `MessageParser` that takes arbitrary byte chunks and yields one message at a time. Useful for speaking HTTP/1.1 over a byte stream this library does not own.
```cpp
Crafter::HTTP1::MessageParser parser(Crafter::HTTP1::MessageKind::Response);
parser.SetRequestMethod("GET"); // framing depends on the request method
parser.Feed(chunk.data(), chunk.size());
if (parser.Complete()) {
Crafter::HTTPResponse response = parser.TakeResponse();
parser.Reset(); // rearm; keeps any pipelined bytes
}
```
### WebTransport Components ### WebTransport Components
`ListenerHTTP` has a WT-aware constructor overload that takes a second route map keyed by `:path`. When the map is non-empty the listener advertises both draft-02 (`SETTINGS_ENABLE_WEBTRANSPORT = 0x2b603742`) and draft-07+ (`SETTINGS_WT_MAX_SESSIONS = 0xc671706a`) identifiers in its SETTINGS frame so current browsers connect. An extended-CONNECT request (`:method=CONNECT, :protocol=webtransport`) whose `:path` matches a registered route is accepted with a `200` (no FIN), upgraded into a `WebTransportSession`, and dispatched on the ThreadPool. Plain HTTP/3 routes and WT routes coexist on the same listener and port. `ListenerHTTP` has a WT-aware constructor overload that takes a second route map keyed by `:path`. When the map is non-empty the listener advertises both draft-02 (`SETTINGS_ENABLE_WEBTRANSPORT = 0x2b603742`) and draft-07+ (`SETTINGS_WT_MAX_SESSIONS = 0xc671706a`) identifiers in its SETTINGS frame so current browsers connect. An extended-CONNECT request (`:method=CONNECT, :protocol=webtransport`) whose `:path` matches a registered route is accepted with a `200` (no FIN), upgraded into a `WebTransportSession`, and dispatched on the ThreadPool. Plain HTTP/3 routes and WT routes coexist on the same listener and port.
@ -135,7 +191,7 @@ Crafter.Network compiles for `wasm32-wasip1` via [Crafter.Build](https://forgejo
- `CRAFTER_NETWORK_BROWSER` is defined. Synchronous methods on `ClientHTTP` / `QUICStream` / `ClientQUIC` are not compiled — only the `*Async` variants are available. - `CRAFTER_NETWORK_BROWSER` is defined. Synchronous methods on `ClientHTTP` / `QUICStream` / `ClientQUIC` are not compiled — only the `*Async` variants are available.
- `ClientHTTP` calls into `crafterNetworkFetch` (JS) which delegates to `fetch()`. An empty `host` is a same-origin sentinel: the path is passed through as the URL, so `ClientHTTP("", 0).SendAsync({.path="/data.json"}, ...)` fetches from the page origin. - `ClientHTTP` calls into `crafterNetworkFetch` (JS) which delegates to `fetch()`. An empty `host` is a same-origin sentinel: the path is passed through as the URL, so `ClientHTTP("", 0).SendAsync({.path="/data.json"}, ...)` fetches from the page origin.
- `ClientQUIC` calls into `crafterNetworkWtConnect` which constructs a `WebTransport(url, opts)` against `https://{host}:{port}/{alpn}` (i.e. `alpn` is the WebTransport URL path on this target). `QUICClientCredentials::serverCertificateHash` is forwarded as `serverCertificateHashes`; leaving it zeroed makes the browser fall back to its normal trust store. - `ClientQUIC` calls into `crafterNetworkWtConnect` which constructs a `WebTransport(url, opts)` against `https://{host}:{port}/{alpn}` (i.e. `alpn` is the WebTransport URL path on this target). `QUICClientCredentials::serverCertificateHash` is forwarded as `serverCertificateHashes`; leaving it zeroed makes the browser fall back to its normal trust store.
- `ListenerTCP`, `ListenerHTTP`, `ListenerQUIC`, `ClientTCP`, and the sync receive/send paths are excluded — the browser is client-only. - `ListenerTCP`, `ListenerHTTP`, `ListenerQUIC`, `ClientTCP`, and the sync receive/send paths are excluded — the browser is client-only. So are `ClientHTTP1` / `ListenerHTTP1` / the `HTTP1` codec: a page cannot open a raw TCP socket anyway, and `fetch()` behind `ClientHTTP` already negotiates whatever HTTP version the server offers.
- `additional/network-env.js` is shipped alongside the produced `.wasm` and merged into the runtime's `env` import object by `EnableWasiBrowserRuntime`. - `additional/network-env.js` is shipped alongside the produced `.wasm` and merged into the runtime's `env` import object by `EnableWasiBrowserRuntime`.
A worked example pairing a wasm browser client with a native server lives in [examples/SimpleClient/](examples/SimpleClient/). Build the server with `crafter-build --target=x86_64-pc-linux-gnu`, run it, then run `crafter-build` (no `--target`) to produce the wasm and serve it over HTTPS. A worked example pairing a wasm browser client with a native server lives in [examples/SimpleClient/](examples/SimpleClient/). Build the server with `crafter-build --target=x86_64-pc-linux-gnu`, run it, then run `crafter-build` (no `--target`) to produce the wasm and serve it over HTTPS.
@ -158,8 +214,14 @@ The library includes tests covering:
- QUIC reliable streams (`ShouldSendRecieveQUICStream`) - QUIC reliable streams (`ShouldSendRecieveQUICStream`)
- QUIC unreliable datagrams (`ShouldSendRecieveQUICDatagram`) - QUIC unreliable datagrams (`ShouldSendRecieveQUICDatagram`)
- WebTransport echo (`ShouldEchoWebTransport`) — extended-CONNECT acceptance, draft-02 SETTINGS, bidi data stream framing (`WT_STREAM 0x41` + session-id varint), and byte-for-byte echo - WebTransport echo (`ShouldEchoWebTransport`) — extended-CONNECT acceptance, draft-02 SETTINGS, bidi data stream framing (`WT_STREAM 0x41` + session-id varint), and byte-for-byte echo
- HTTP/1.1 wire format (`ShouldParseHTTP1`) — serialisation, incremental parsing fed one byte at a time, chunked bodies with trailers, pipelining, interim 1xx, HTTP/1.0 and HEAD framing, and the malformed inputs the parser is required to reject
- HTTP/1.1 round-trip (`ShouldSendRecieveHTTP1`) — routing, query strings, HEAD, 404 and a throwing handler
- HTTP/1.1 keep-alive (`ShouldSendRecieveKeepaliveHTTP1`) — connection reuse, handler-requested close, and recovery from a pooled connection the server closed
- HTTP/1.1 large body transfer (`ShouldSendRecieveLargeHTTP1`) — 10 MiB in both directions on one connection
- HTTP/1.1 interop (`ShouldInteropCurlHTTP1`) — `curl` against `ListenerHTTP1` (keep-alive reuse, chunked upload, `Expect: 100-continue`, HEAD) and `ClientHTTP1` against python3's `http.server`, which answers HTTP/1.0 with `Connection: close`
- HTTP/1.1 under abuse (`ShouldSurviveAbuseHTTP1`) — 24 concurrent keep-alive clients, peers that vanish mid-request or send garbage, and a stalled peer that must be timed out
The external-interop test requires outbound UDP/443; if your network blocks it the test will fail. The external-interop test requires outbound UDP/443; if your network blocks it the test will fail. `ShouldInteropCurlHTTP1` skips whichever half is unavailable when `curl` or `python3` is not installed, so it passes on a bare machine — install both to actually exercise it.
## Dependencies ## Dependencies
@ -167,6 +229,7 @@ The external-interop test requires outbound UDP/443; if your network blocks it t
- **msquic** (native target only) — fetched and built automatically as a Crafter `ExternalDependency` (no system install required). The build clones `microsoft/msquic` recursively into the per-project external cache, configures it via CMake (`QUIC_TLS_LIB=quictls`, tests/tools/perf disabled), and links the produced `libmsquic` into the QUIC and HTTP/3 modules. Skipped entirely on browser builds. - **msquic** (native target only) — fetched and built automatically as a Crafter `ExternalDependency` (no system install required). The build clones `microsoft/msquic` recursively into the per-project external cache, configures it via CMake (`QUIC_TLS_LIB=quictls`, tests/tools/perf disabled), and links the produced `libmsquic` into the QUIC and HTTP/3 modules. Skipped entirely on browser builds.
- On Linux msquic links against `libnuma` (provided by the `numactl` package on most distros). - On Linux msquic links against `libnuma` (provided by the `numactl` package on most distros).
- The self-signed-cert path used by tests/dev shells out to the `openssl` CLI; install `openssl` if you intend to use `QUICServerCredentials{selfSigned = true}`. The same path also produces the cert hash that browser peers need for `serverCertificateHashes`. - The self-signed-cert path used by tests/dev shells out to the `openssl` CLI; install `openssl` if you intend to use `QUICServerCredentials{selfSigned = true}`. The same path also produces the cert hash that browser peers need for `serverCertificateHashes`.
- The HTTP/1.1 stack has no dependencies beyond POSIX sockets — no TLS library, no msquic.
- **Browser build** has no extra dependencies beyond Crafter.Build's `wasi-browser` runtime: HTTP delegates to the browser's `fetch()`, QUIC to its `WebTransport`. The JS glue lives in `additional/network-env.js` and is shipped alongside the produced `.wasm`. - **Browser build** has no extra dependencies beyond Crafter.Build's `wasi-browser` runtime: HTTP delegates to the browser's `fetch()`, QUIC to its `WebTransport`. The JS glue lives in `additional/network-env.js` and is shipped alongside the produced `.wasm`.
## Usage Example ## Usage Example

View file

@ -0,0 +1,155 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include <poll.h>
#include <sys/socket.h>
#include <cerrno>
module Crafter.Network:ClientHTTP1_impl;
import :ClientHTTP1;
import :ClientTCP;
import :HTTP;
import :HTTP1;
import Crafter.Thread;
import std;
using namespace Crafter;
namespace {
// Read whatever is available, waiting at most `timeout`. Returns 0 on a
// clean close by the peer. ClientTCP::RecieveSync() can't express a
// timeout — and a server that accepts the connection and then says
// nothing would hang Send() forever — so the read is done here.
std::size_t ReadSome(int socketid, char* buffer, std::size_t size,
std::chrono::milliseconds timeout) {
for (;;) {
pollfd pfd{ .fd = socketid, .events = POLLIN, .revents = 0 };
const int ready = poll(&pfd, 1, static_cast<int>(timeout.count()));
if (ready < 0) {
if (errno == EINTR) continue;
throw std::runtime_error(std::string("poll failed: ") + std::strerror(errno));
}
if (ready == 0) throw std::runtime_error("timed out waiting for the response");
const auto read = recv(socketid, buffer, size, 0);
if (read < 0) {
if (errno == EINTR) continue;
throw std::runtime_error(std::string("recv failed: ") + std::strerror(errno));
}
return static_cast<std::size_t>(read);
}
}
// Host header value. The port is elided when it is the scheme default,
// which is what every other HTTP/1.1 client on the wire does and what
// virtual-host matching on the far side tends to expect.
std::string DefaultAuthority(const std::string& host, std::uint16_t port) {
if (port == 80) return host;
return host + ":" + std::to_string(port);
}
}
struct ClientHTTP1::Impl {
std::string host;
std::uint16_t port;
std::unique_ptr<ClientTCP> tcp;
void Connect() {
tcp = std::make_unique<ClientTCP>(host, port);
}
void Close() {
tcp.reset();
}
// One request/response exchange on the current connection. `received`
// is set as soon as any response byte arrives, which is what decides
// whether a failure is safe to replay on a new connection.
HTTPResponse Exchange(const std::string& wire, std::string_view method,
const HTTP1::MessageLimits& limits,
std::chrono::milliseconds timeout, bool& received) {
tcp->Send(wire.data(), static_cast<std::uint32_t>(wire.size()));
HTTP1::MessageParser parser(HTTP1::MessageKind::Response, limits);
parser.SetRequestMethod(method);
std::vector<char> chunk(16 * 1024);
while (!parser.Complete()) {
const std::size_t read = ReadSome(tcp->socketid, chunk.data(), chunk.size(), timeout);
if (read == 0) {
// Peer closed. Completes a response framed by close;
// anything else throws out of Finish().
parser.Finish();
break;
}
received = true;
parser.Feed(chunk.data(), read);
}
if (!parser.Complete()) {
throw std::runtime_error("connection closed before a complete response arrived");
}
HTTPResponse response = parser.TakeResponse();
if (!parser.KeepAlive()) Close();
return response;
}
};
ClientHTTP1::ClientHTTP1(const char* host, std::uint16_t port)
: host(host), port(port), impl(std::make_unique<Impl>(std::string(host), port)) {}
ClientHTTP1::ClientHTTP1(std::string host, std::uint16_t port)
: ClientHTTP1(host.c_str(), port) {}
ClientHTTP1::ClientHTTP1(ClientHTTP1&&) noexcept = default;
ClientHTTP1::~ClientHTTP1() = default;
bool ClientHTTP1::Connected() const noexcept {
return impl && impl->tcp != nullptr;
}
void ClientHTTP1::Disconnect() {
if (impl) impl->Close();
}
HTTPResponse ClientHTTP1::Send(const HTTPRequest& request) {
HTTPRequest prepared = request;
if (prepared.authority.empty()) prepared.authority = DefaultAuthority(host, port);
const std::string wire = HTTP1::SerializeRequest(prepared);
const std::string method = prepared.method.empty() ? std::string("GET") : prepared.method;
// Two attempts at most, and the second one only for the keep-alive
// race: the peer may have closed a pooled connection at the same moment
// we wrote to it, which is indistinguishable from success until the
// read fails. Nothing is replayed once a response byte has arrived.
for (int attempt = 0;; ++attempt) {
const bool reused = impl->tcp != nullptr;
if (!reused) impl->Connect();
bool received = false;
try {
return impl->Exchange(wire, method, limits, timeout, received);
} catch (const std::exception&) {
impl->Close();
if (attempt == 0 && reused && !received) continue;
throw;
}
}
}
void ClientHTTP1::SendAsync(const HTTPRequest& request,
std::function<void(HTTPResponse)> onSuccess,
std::function<void(std::string)> onError) {
HTTPRequest copy = request;
ThreadPool::Enqueue([this, copy = std::move(copy),
onSuccess = std::move(onSuccess),
onError = std::move(onError)]() mutable {
try {
HTTPResponse response = this->Send(copy);
if (onSuccess) onSuccess(std::move(response));
} catch (const std::exception& e) {
if (onError) onError(e.what());
} catch (...) {
if (onError) onError("unknown error");
}
});
}

View file

@ -31,11 +31,22 @@ ClientTCP::ClientTCP(int socketid) : socketid(socketid)
ClientTCP::ClientTCP(const char* hostName, std::uint16_t port) ClientTCP::ClientTCP(const char* hostName, std::uint16_t port)
{ {
host = gethostbyname(hostName); // getaddrinfo rather than gethostbyname: the latter is not thread safe,
serv_addr.sin_family = AF_INET; // and it signals failure by returning null — which was then dereferenced
serv_addr.sin_port = htons(port); // straight into a crash on any unresolvable host.
serv_addr.sin_addr = *((struct in_addr *)host->h_addr); addrinfo hints{};
bzero(&(serv_addr.sin_zero),8); hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
addrinfo* resolved = nullptr;
const std::string service = std::to_string(port);
const int status = getaddrinfo(hostName, service.c_str(), &hints, &resolved);
if (status != 0 || resolved == nullptr) {
throw std::runtime_error(std::string("Could not resolve host '") + hostName + "': "
+ gai_strerror(status));
}
host = nullptr;
serv_addr = *reinterpret_cast<sockaddr_in*>(resolved->ai_addr);
freeaddrinfo(resolved);
Connect(); Connect();
} }
@ -45,17 +56,16 @@ ClientTCP::ClientTCP(std::string hostName, std::uint16_t port): ClientTCP(hostNa
} }
// The moved-from object gives up ownership; the socket itself stays open —
// closing it here (as this used to) meant moving a ClientTCP silently
// dropped the connection it was carrying.
ClientTCP::ClientTCP(ClientTCP&& other) noexcept : socketid(other.socketid) { ClientTCP::ClientTCP(ClientTCP&& other) noexcept : socketid(other.socketid) {
if(socketid != 1) {
shutdown(socketid, SHUT_RDWR);
close(socketid);
}
other.socketid = -1; other.socketid = -1;
} }
ClientTCP::~ClientTCP() ClientTCP::~ClientTCP()
{ {
if(socketid != 1) { if(socketid != -1) {
shutdown(socketid, SHUT_RDWR); shutdown(socketid, SHUT_RDWR);
close(socketid); close(socketid);
} }
@ -63,11 +73,17 @@ ClientTCP::~ClientTCP()
void ClientTCP::Connect() { void ClientTCP::Connect() {
if((socketid = socket(AF_INET, SOCK_STREAM, 0)) == -1){ if((socketid = socket(AF_INET, SOCK_STREAM, 0)) == -1){
std::cerr << "Could not open socket" << std::endl; throw std::runtime_error(std::string("Could not open socket: ") + std::strerror(errno));
} }
if(connect(socketid,(sockaddr*)&serv_addr, sizeof(sockaddr)) == -1){ if(connect(socketid,(sockaddr*)&serv_addr, sizeof(sockaddr)) == -1){
std::cerr << "Could not connect to server" << std::endl; // Report the failure instead of handing back a socket that is not
// connected to anything — every later send/recv on it would fail
// with a far less obvious error.
const std::string reason = std::strerror(errno);
close(socketid);
socketid = -1;
throw std::runtime_error("Could not connect to server: " + reason);
} }
} }
@ -78,12 +94,20 @@ void ClientTCP::Stop() {
} }
void ClientTCP::Send(const void* buffer, std::uint32_t size) const { void ClientTCP::Send(const void* buffer, std::uint32_t size) const {
int status = send(socketid, reinterpret_cast<const char*>(buffer), size, 0); // send() is free to accept less than it was offered, and does so
// routinely once a buffer outgrows the socket's send buffer — a
if (status == 0) { // multi-megabyte HTTP body, say. Loop until it is all handed over.
throw SocketClosedException(); const char* data = reinterpret_cast<const char*>(buffer);
} else if (status < 0) { std::uint32_t sent = 0;
throw std::runtime_error(std::strerror(errno)); while (sent < size) {
const auto status = send(socketid, data + sent, size - sent, MSG_NOSIGNAL);
if (status == 0) {
throw SocketClosedException();
} else if (status < 0) {
if (errno == EINTR) continue;
throw std::runtime_error(std::strerror(errno));
}
sent += static_cast<std::uint32_t>(status);
} }
} }
std::vector<char> ClientTCP::RecieveSync(std::uint32_t bufferSize) const { std::vector<char> ClientTCP::RecieveSync(std::uint32_t bufferSize) const {

View file

@ -0,0 +1,328 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#include <poll.h>
#include <sys/socket.h>
#include <cerrno>
module Crafter.Network:ListenerHTTP1_impl;
import :ListenerHTTP1;
import :ListenerTCP;
import :ClientTCP;
import :HTTP;
import :HTTP1;
import std;
using namespace Crafter;
namespace {
enum class ReadStatus { Data, Closed, TimedOut };
// Wait for readable data with a deadline, so an idle or stalled peer
// releases the connection thread instead of holding it forever.
ReadStatus ReadSome(int socketid, char* buffer, std::size_t size,
std::chrono::milliseconds timeout, std::size_t& read) {
read = 0;
for (;;) {
pollfd pfd{ .fd = socketid, .events = POLLIN, .revents = 0 };
const int ready = poll(&pfd, 1, static_cast<int>(timeout.count()));
if (ready < 0) {
if (errno == EINTR) continue;
return ReadStatus::Closed;
}
if (ready == 0) return ReadStatus::TimedOut;
const auto got = recv(socketid, buffer, size, 0);
if (got < 0) {
if (errno == EINTR) continue;
return ReadStatus::Closed;
}
if (got == 0) return ReadStatus::Closed;
read = static_cast<std::size_t>(got);
return ReadStatus::Data;
}
}
// Everything up to '?' — used as the fallback route key so `/thing?x=1`
// reaches the handler registered for `/thing`. Browsers append query
// strings freely, and requiring handlers to register every variant is
// not a workable API.
std::string_view PathWithoutQuery(std::string_view target) {
return target.substr(0, target.find('?'));
}
}
// One accepted connection: the socket, the thread serving it, and a flag
// the accept loop uses to join finished threads without blocking.
struct HTTP1Connection {
std::unique_ptr<ClientTCP> client;
std::thread thread;
std::atomic<bool> finished{false};
};
struct ListenerHTTP1::Impl {
ListenerHTTP1* owner = nullptr;
std::unique_ptr<ListenerTCP> listener;
std::mutex mutex;
// Signalled when a connection thread finishes, so Stop() can wait for
// the last one instead of polling.
std::condition_variable idle;
std::vector<std::unique_ptr<HTTP1Connection>> connections;
std::atomic<bool> running{true};
std::atomic<std::uint64_t> accepted{0};
// Join threads whose connection has ended. Called from the accept loop
// with `mutex` held, so a connection is never reaped mid-registration.
void ReapLocked() {
std::erase_if(connections, [](const std::unique_ptr<HTTP1Connection>& connection) {
if (!connection->finished.load()) return false;
if (connection->thread.joinable()) connection->thread.join();
return true;
});
}
void Adopt(ClientTCP* accepted) try {
// The unique_ptr takes ownership immediately, so every path out of
// here — including the shutdown early-return and a throwing thread
// constructor — closes the socket.
auto connection = std::make_unique<HTTP1Connection>();
connection->client.reset(accepted);
HTTP1Connection* pointer = connection.get();
std::lock_guard lock(mutex);
if (!running.load()) return; // shutting down — let the socket close
ReapLocked();
this->accepted.fetch_add(1);
connection->thread = std::thread([this, pointer] {
try {
Serve(*pointer->client);
} catch (...) {
// A connection dying must never take the server with it.
}
{
std::lock_guard lock(mutex);
// Close here rather than when the entry is reaped: the peer
// has to see the connection end as soon as we are done with
// it, and holding the descriptor until the next accept
// would be an unbounded leak on a server that goes quiet.
// Doing it under the lock keeps Stop()'s shutdown() from
// ever touching a descriptor number we have released.
pointer->client.reset();
pointer->finished.store(true);
}
idle.notify_all();
});
connections.push_back(std::move(connection));
} catch (...) {
// This runs on the accept loop, which has no handler of its own:
// letting anything escape (a thread that could not be spawned, say)
// would abort the process.
}
void Send(ClientTCP& client, const std::string& wire) {
client.Send(wire.data(), static_cast<std::uint32_t>(wire.size()));
}
HTTPResponse Dispatch(const HTTPRequest& request) {
const auto& routes = owner->routes;
auto route = routes.find(request.path);
if (route == routes.end()) {
const std::string bare(PathWithoutQuery(request.path));
route = routes.find(bare);
}
if (route == routes.end()) {
return CreateResponseHTTP("404", "Not Found");
}
try {
return route->second(request);
} catch (const std::exception& error) {
return CreateResponseHTTP("500", std::string(error.what()));
} catch (...) {
return CreateResponseHTTP("500", "Internal Server Error");
}
}
// Serve one connection until the peer goes away, asks to close, stalls,
// or sends something we refuse to parse.
void Serve(ClientTCP& client) {
HTTP1::MessageParser parser(HTTP1::MessageKind::Request, owner->limits);
std::vector<char> chunk(16 * 1024);
bool keepAlive = true;
while (running.load() && keepAlive) {
// ── Read one complete request ─────────────────────────────
bool closed = false;
try {
while (!parser.Complete()) {
const bool idle = parser.AtMessageBoundary();
const auto timeout = idle ? owner->keepAliveTimeout : owner->requestTimeout;
std::size_t read = 0;
const ReadStatus status = ReadSome(client.socketid, chunk.data(), chunk.size(),
timeout, read);
if (status == ReadStatus::TimedOut) {
// An idle keep-alive connection is simply dropped;
// a half-sent request earns a 408 first.
if (!idle) {
try {
Send(client, HTTP1::SerializeResponse(
CreateResponseHTTP("408", "Request Timeout"),
{ .keepAlive = false }));
} catch (...) {}
}
return;
}
if (status == ReadStatus::Closed) {
if (parser.AtMessageBoundary()) return; // clean end of connection
parser.Finish(); // throws if truncated
closed = true;
break;
}
parser.Feed(chunk.data(), read);
// The peer is holding its body back until we say go.
if (parser.ExpectsContinue()) {
Send(client, HTTP1::SerializeContinue());
parser.ContinueSent();
}
}
} catch (const HTTP1::HTTP1ProtocolError& error) {
try {
Send(client, HTTP1::SerializeResponse(
CreateResponseHTTP("400", std::string(error.what())),
{ .keepAlive = false }));
} catch (...) {}
return;
} catch (...) {
return;
}
if (!parser.Complete()) return;
// ── Dispatch and answer ───────────────────────────────────
const bool http10 = parser.Version() == HTTP1::kVersion10;
const bool head = parser.Method() == "HEAD";
keepAlive = parser.KeepAlive() && !closed && running.load();
HTTPRequest request = parser.TakeRequest();
HTTPResponse response = Dispatch(request);
// A handler may end the connection by answering with
// `connection: close`. The header itself is hop-by-hop and gets
// re-derived by the serialiser, so this is where it takes effect.
if (auto connection = response.headers.find("connection");
connection != response.headers.end() && HTTP1::HasToken(connection->second, "close")) {
keepAlive = false;
}
if (!response.headers.contains("date")) {
response.headers["date"] = HTTP1::FormatHTTPDate(std::chrono::system_clock::now());
}
try {
Send(client, HTTP1::SerializeResponse(response, {
.keepAlive = keepAlive,
// HTTP/1.0 peers only reuse a connection they were told
// stays open.
.explicitKeepAlive = http10,
.omitBody = head,
}));
} catch (...) {
return; // peer vanished mid-response
}
if (!keepAlive) return;
try {
// Rearms for the next request, parsing anything the peer
// already pipelined behind this one.
parser.Reset();
} catch (const HTTP1::HTTP1ProtocolError&) {
return;
}
}
}
};
ListenerHTTP1::ListenerHTTP1(std::uint16_t port,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes)
: routes(std::move(routes))
, impl(std::make_unique<Impl>())
{
impl->owner = this;
Impl* state = impl.get();
impl->listener = std::make_unique<ListenerTCP>(port, [state](ClientTCP* client) {
state->Adopt(client);
});
}
ListenerHTTP1::ListenerHTTP1(ListenerHTTP1&& other) noexcept
: routes(std::move(other.routes))
, keepAliveTimeout(other.keepAliveTimeout)
, requestTimeout(other.requestTimeout)
, limits(other.limits)
, impl(std::move(other.impl))
{
// The accept callback reaches the routes through Impl::owner, so the
// back-pointer has to follow the object.
if (impl) impl->owner = this;
}
ListenerHTTP1::~ListenerHTTP1() {
if (impl) Stop();
}
void ListenerHTTP1::Listen() {
if (!impl || !impl->listener) return;
// Accept on this thread; each connection is served on its own thread
// (see Impl::Adopt), so a slow keep-alive peer can't stall the loop.
impl->listener->ListenSyncSync();
}
void ListenerHTTP1::Stop() {
if (!impl) return;
if (!impl->running.exchange(false)) return;
if (impl->listener) impl->listener->Stop();
std::vector<std::unique_ptr<HTTP1Connection>> closing;
{
std::unique_lock lock(impl->mutex);
for (auto& connection : impl->connections) {
// Wake the serving thread out of poll()/recv() without closing
// the descriptor underneath it — the thread owns that.
if (connection->client) shutdown(connection->client->socketid, SHUT_RDWR);
}
impl->idle.wait(lock, [&] {
return std::ranges::all_of(impl->connections,
[](const std::unique_ptr<HTTP1Connection>& connection) {
return connection->finished.load();
});
});
closing = std::move(impl->connections);
impl->connections.clear();
}
for (auto& connection : closing) {
if (connection->thread.joinable()) connection->thread.join();
}
}
std::size_t ListenerHTTP1::ConnectionCount() const {
if (!impl) return 0;
std::lock_guard lock(impl->mutex);
return impl->connections.size();
}
std::uint64_t ListenerHTTP1::AcceptedCount() const {
return impl ? impl->accepted.load() : 0;
}
ListenerAsyncHTTP1::ListenerAsyncHTTP1(std::uint16_t port,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes)
: listener(port, std::move(routes))
, thread(&ListenerHTTP1::Listen, &listener)
{}
ListenerAsyncHTTP1::~ListenerAsyncHTTP1() {
Stop();
}
void ListenerAsyncHTTP1::Stop() {
listener.Stop();
if (thread.joinable()) thread.join();
}

View file

@ -1,114 +1,135 @@
//SPDX-License-Identifier: LGPL-3.0-only //SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module; module;
#include <stdio.h> #include <stdio.h>
#include <sys/types.h> #include <sys/types.h>
#include <sys/socket.h> #include <sys/socket.h>
#include <netinet/in.h> #include <netinet/in.h>
#include <arpa/inet.h> #include <arpa/inet.h>
#include <sys/uio.h> #include <sys/uio.h>
#include <sys/time.h> #include <sys/time.h>
#include <sys/wait.h> #include <sys/wait.h>
#include <strings.h> #include <strings.h>
#include <cerrno>
module Crafter.Network:ListenerTCP_impl;
import :ListenerTCP; module Crafter.Network:ListenerTCP_impl;
import std; import :ListenerTCP;
import Crafter.Thread; import std;
import Crafter.Thread;
using namespace Crafter;
using namespace Crafter;
ListenerTCP::ListenerTCP(std::uint16_t port, std::function<void(ClientTCP*)> connectCallback, std::uint32_t concurrentClientLimit, std::uint32_t totalClientLimit) : connectCallback(connectCallback), concurrentClientLimit(concurrentClientLimit), totalClientLimit(totalClientLimit) {
sockaddr_in servAddr; ListenerTCP::ListenerTCP(std::uint16_t port, std::function<void(ClientTCP*)> connectCallback, std::uint32_t concurrentClientLimit, std::uint32_t totalClientLimit) : connectCallback(connectCallback), concurrentClientLimit(concurrentClientLimit), totalClientLimit(totalClientLimit) {
bzero((char*)&servAddr, sizeof(servAddr)); sockaddr_in servAddr;
servAddr.sin_family = AF_INET; bzero((char*)&servAddr, sizeof(servAddr));
servAddr.sin_addr.s_addr = htonl(INADDR_ANY); servAddr.sin_family = AF_INET;
servAddr.sin_port = htons(port); servAddr.sin_addr.s_addr = htonl(INADDR_ANY);
servAddr.sin_port = htons(port);
s = socket(AF_INET, SOCK_STREAM, 0);
if(s < 0) s = socket(AF_INET, SOCK_STREAM, 0);
{ if(s < 0)
throw std::runtime_error("Error establishing the server socket"); {
} throw std::runtime_error("Error establishing the server socket");
int bindStatus = bind(s, (struct sockaddr*) &servAddr, sizeof(servAddr)); }
listen(s, 5); // Without SO_REUSEADDR the port stays unbindable for the length of
} // TIME_WAIT after a restart, which turns "restart the server" into a
// minute of failed binds.
void ListenerTCP::Stop() { int reuse = 1;
running = false; setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
shutdown(s, SHUT_RDWR);
close(s); if(bind(s, (struct sockaddr*) &servAddr, sizeof(servAddr)) < 0) {
s = -1; // Ignoring this left the listener silently accepting nothing at
} // all, with no hint as to why.
const std::string reason = std::strerror(errno);
void ListenerTCP::ListenSyncSync() { close(s);
while (running && totalClientCounter < totalClientLimit) { s = -1;
sockaddr_in newSockAddr; throw std::runtime_error("Could not bind port " + std::to_string(port) + ": " + reason);
socklen_t newSockAddrSize = sizeof(newSockAddr); }
int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize); if(listen(s, 128) < 0) {
if (client > 0) { const std::string reason = std::strerror(errno);
connectCallback(new ClientTCP(client)); close(s);
this->totalClientCounter++; s = -1;
} throw std::runtime_error("Could not listen on port " + std::to_string(port) + ": " + reason);
else { }
std::cerr << "Error accepting request from client!" << std::endl; }
}
} void ListenerTCP::Stop() {
} running = false;
shutdown(s, SHUT_RDWR);
void ListenerTCP::ListenSyncAsync() { close(s);
while (running && totalClientCounter < totalClientLimit) { s = -1;
sockaddr_in newSockAddr; }
socklen_t newSockAddrSize = sizeof(newSockAddr);
int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize); void ListenerTCP::ListenSyncSync() {
if (client > 0) { while (running && totalClientCounter < totalClientLimit) {
ThreadPool::Enqueue([this, client]() {connectCallback(new ClientTCP(client)); }); sockaddr_in newSockAddr;
this->totalClientCounter++; socklen_t newSockAddrSize = sizeof(newSockAddr);
} int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize);
else { if (client > 0) {
std::cerr << "Error accepting request from client!" << std::endl; connectCallback(new ClientTCP(client));
} this->totalClientCounter++;
} }
} else if (running) {
// accept() also fails once on the way out, when Stop() closes
void ListenerTCP::ListenAsyncSync() { // the listening socket — that one is not worth reporting.
ThreadPool::Enqueue([this]() { std::cerr << "Error accepting request from client!" << std::endl;
while (running && totalClientCounter < totalClientLimit) { }
sockaddr_in newSockAddr; }
socklen_t newSockAddrSize = sizeof(newSockAddr); }
int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize);
if (client > 0) { void ListenerTCP::ListenSyncAsync() {
connectCallback(new ClientTCP(client)); while (running && totalClientCounter < totalClientLimit) {
this->totalClientCounter++; sockaddr_in newSockAddr;
} socklen_t newSockAddrSize = sizeof(newSockAddr);
else { int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize);
std::cerr << "Error accepting request from client!" << std::endl; if (client > 0) {
} ThreadPool::Enqueue([this, client]() {connectCallback(new ClientTCP(client)); });
} this->totalClientCounter++;
}); }
} else if (running) {
std::cerr << "Error accepting request from client!" << std::endl;
void ListenerTCP::ListenAsyncAsync() { }
ThreadPool::Enqueue([this]() { }
while (running && totalClientCounter < totalClientLimit) { }
sockaddr_in newSockAddr;
socklen_t newSockAddrSize = sizeof(newSockAddr); void ListenerTCP::ListenAsyncSync() {
int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize); ThreadPool::Enqueue([this]() {
if (client > 0) { while (running && totalClientCounter < totalClientLimit) {
ThreadPool::Enqueue([this, client]() {connectCallback(new ClientTCP(client)); }); sockaddr_in newSockAddr;
this->totalClientCounter++; socklen_t newSockAddrSize = sizeof(newSockAddr);
} int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize);
else { if (client > 0) {
std::cerr << "Error accepting request from client!" << std::endl; connectCallback(new ClientTCP(client));
} this->totalClientCounter++;
} }
}); else if (running) {
} std::cerr << "Error accepting request from client!" << std::endl;
}
ListenerTCP::~ListenerTCP() { }
if(s != -1) { });
close(s); }
}
void ListenerTCP::ListenAsyncAsync() {
ThreadPool::Enqueue([this]() {
while (running && totalClientCounter < totalClientLimit) {
sockaddr_in newSockAddr;
socklen_t newSockAddrSize = sizeof(newSockAddr);
int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize);
if (client > 0) {
ThreadPool::Enqueue([this, client]() {connectCallback(new ClientTCP(client)); });
this->totalClientCounter++;
}
else if (running) {
std::cerr << "Error accepting request from client!" << std::endl;
}
}
});
}
ListenerTCP::~ListenerTCP() {
if(s != -1) {
close(s);
}
} }

View file

@ -0,0 +1,72 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Crafter.Network:ClientHTTP1;
import std;
import :HTTP;
import :HTTP1;
#ifndef CRAFTER_NETWORK_BROWSER
namespace Crafter {
// HTTP/1.1 client over plain TCP, for peers that cannot speak HTTP/3.
// The request/response types are the ones the HTTP/3 client uses, so
// swapping ClientHTTP for ClientHTTP1 is a one-line change at the call
// site.
//
// The connection is persistent: the first Send() dials, and later calls
// reuse the socket unless the peer asked for it to be closed
// (`Connection: close`, or an HTTP/1.0 response without
// `Connection: keep-alive`). A reused connection that turns out to have
// been closed by the peer in the meantime — the unavoidable race in
// HTTP/1.1 keep-alive — is redialled once and the request replayed;
// a freshly dialled connection is never replayed on, so a genuinely
// broken server surfaces as an exception rather than a retry loop.
//
// Thread-affinity matches ClientHTTP: one ClientHTTP1 serves one caller
// at a time; distinct instances are independent.
//
// No TLS. This talks `http://`; for an encrypted transport use
// ClientHTTP (HTTP/3 over QUIC), or put a TLS-terminating proxy in
// front of the HTTP/1.1 endpoint.
export class ClientHTTP1 {
public:
std::string host;
std::uint16_t port;
ClientHTTP1(const char* host, std::uint16_t port);
ClientHTTP1(std::string host, std::uint16_t port);
~ClientHTTP1();
ClientHTTP1(const ClientHTTP1&) = delete;
ClientHTTP1(ClientHTTP1&&) noexcept;
// Send a request and read the full response. `authority` defaults to
// the host:port this client was constructed with; `scheme` is
// ignored (the transport is plaintext).
HTTPResponse Send(const HTTPRequest& request);
// Send a request and deliver the response (or the error text) via
// callback, on Crafter.Thread's ThreadPool.
void SendAsync(const HTTPRequest& request,
std::function<void(HTTPResponse)> onSuccess,
std::function<void(std::string)> onError);
// Whether a pooled connection is currently open. Mostly useful for
// tests asserting that keep-alive actually kept the socket.
bool Connected() const noexcept;
// Drop the pooled connection; the next Send() dials again.
void Disconnect();
// Limits applied to responses. Set before the first Send().
HTTP1::MessageLimits limits;
// How long to wait for the next piece of a response before giving
// up on a server that accepted the connection and then went quiet.
std::chrono::milliseconds timeout{30000};
private:
struct Impl;
std::unique_ptr<Impl> impl;
};
}
#endif

View file

@ -0,0 +1,777 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// HTTP/1.1 wire format — RFC 9112 syntax on top of the RFC 9110 semantics
// already modelled by :HTTP (HTTPRequest / HTTPResponse are shared with the
// HTTP/3 stack, so a route handler written for one works unchanged on the
// other).
//
// Scope:
// - request / response serialisation, with the framing headers
// (host, content-length, transfer-encoding, connection) owned by the
// serialiser rather than the caller
// - an incremental parser that consumes arbitrary socket chunks and
// surfaces one message at a time, so a single connection can carry a
// keep-alive series (and pipelined requests)
// - both body framings that matter in practice: content-length and
// `Transfer-Encoding: chunked` (plus read-to-EOF for responses that
// use neither, RFC 9112 §6.3 item 8)
//
// Deliberately rejected rather than guessed at, because getting these
// wrong is how request smuggling happens (RFC 9112 §11.2):
// - Content-Length together with Transfer-Encoding
// - conflicting duplicate Content-Length values
// - whitespace between a field name and its colon
// - CR/LF inside a field value we are asked to serialise
//
// This partition has no transport dependency; ClientHTTP1 and ListenerHTTP1
// pair it with :ClientTCP / :ListenerTCP.
export module Crafter.Network:HTTP1;
import std;
import :HTTP;
namespace Crafter::HTTP1 {
// ---------------- Versions ----------------
export inline constexpr std::string_view kVersion10 = "HTTP/1.0";
export inline constexpr std::string_view kVersion11 = "HTTP/1.1";
// ---------------- Errors ----------------
// Thrown for any input we refuse to interpret. Callers turn this into a
// 400 (server side) or propagate it out of Send() (client side).
export class HTTP1ProtocolError : public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
// ---------------- Lexical helpers (RFC 9110 §5.6) ----------------
inline bool IsTChar(unsigned char c) {
constexpr std::string_view extra = "!#$%&'*+-.^_`|~";
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|| extra.find(static_cast<char>(c)) != std::string_view::npos;
}
inline bool IsToken(std::string_view s) {
return !s.empty() && std::ranges::all_of(s, [](char c) {
return IsTChar(static_cast<unsigned char>(c));
});
}
inline std::string ToLowerAscii(std::string_view s) {
std::string out(s);
std::ranges::transform(out, out.begin(), [](unsigned char c) {
return static_cast<char>(c >= 'A' && c <= 'Z' ? c + ('a' - 'A') : c);
});
return out;
}
inline bool EqualsIgnoreCase(std::string_view a, std::string_view b) {
return std::ranges::equal(a, b, [](unsigned char x, unsigned char y) {
auto lower = [](unsigned char c) { return c >= 'A' && c <= 'Z' ? c + ('a' - 'A') : c; };
return lower(x) == lower(y);
});
}
// Optional whitespace — SP / HTAB only (RFC 9110 §5.6.3).
inline std::string_view TrimOWS(std::string_view s) {
while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) s.remove_prefix(1);
while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.remove_suffix(1);
return s;
}
// Split a comma-separated field value into trimmed tokens (#rule).
export inline std::vector<std::string_view> SplitCommaList(std::string_view value) {
std::vector<std::string_view> parts;
while (!value.empty()) {
auto comma = value.find(',');
parts.push_back(TrimOWS(value.substr(0, comma)));
if (comma == std::string_view::npos) break;
value.remove_prefix(comma + 1);
}
std::erase_if(parts, [](std::string_view p) { return p.empty(); });
return parts;
}
// Whether a comma-separated field value contains `token`, compared
// case-insensitively — the shape of `Connection`, `Expect`,
// `Transfer-Encoding` and friends.
export inline bool HasToken(std::string_view fieldValue, std::string_view token) {
return std::ranges::any_of(SplitCommaList(fieldValue), [token](std::string_view candidate) {
return EqualsIgnoreCase(candidate, token);
});
}
// ---------------- Status reason phrases ----------------
// HTTP/1.1 keeps a reason phrase in the status line. It carries no
// meaning (RFC 9112 §4) but some ancient clients log it, and an empty
// one trips up a few proxies, so emit the registered text where we know
// it and a generic class name otherwise.
export inline std::string_view ReasonPhrase(std::string_view status) {
static const std::unordered_map<std::string_view, std::string_view> known = {
{"100", "Continue"}, {"101", "Switching Protocols"}, {"103", "Early Hints"},
{"200", "OK"}, {"201", "Created"}, {"202", "Accepted"}, {"204", "No Content"},
{"206", "Partial Content"},
{"301", "Moved Permanently"}, {"302", "Found"}, {"303", "See Other"},
{"304", "Not Modified"}, {"307", "Temporary Redirect"}, {"308", "Permanent Redirect"},
{"400", "Bad Request"}, {"401", "Unauthorized"}, {"403", "Forbidden"},
{"404", "Not Found"}, {"405", "Method Not Allowed"}, {"408", "Request Timeout"},
{"409", "Conflict"}, {"411", "Length Required"}, {"413", "Content Too Large"},
{"414", "URI Too Long"}, {"415", "Unsupported Media Type"},
{"426", "Upgrade Required"}, {"429", "Too Many Requests"},
{"431", "Request Header Fields Too Large"},
{"500", "Internal Server Error"}, {"501", "Not Implemented"},
{"502", "Bad Gateway"}, {"503", "Service Unavailable"}, {"504", "Gateway Timeout"},
{"505", "HTTP Version Not Supported"},
};
if (auto it = known.find(status); it != known.end()) return it->second;
if (status.size() == 3) {
switch (status[0]) {
case '1': return "Informational";
case '2': return "Success";
case '3': return "Redirection";
case '4': return "Client Error";
case '5': return "Server Error";
default: break;
}
}
return "Unknown";
}
// IMF-fixdate, the only date format an HTTP/1.1 sender may generate
// (RFC 9110 §5.6.7). Always UTC.
export inline std::string FormatHTTPDate(std::chrono::system_clock::time_point when) {
return std::format("{:%a, %d %b %Y %H:%M:%S} GMT",
std::chrono::floor<std::chrono::seconds>(when));
}
// ---------------- Serialisation ----------------
export struct RequestOptions {
// false emits `connection: close`, telling the peer we will not
// reuse the connection after this exchange.
bool keepAlive = true;
};
export struct ResponseOptions {
bool keepAlive = true;
// HTTP/1.0 peers have no keep-alive by default, so reuse has to be
// stated explicitly for them (RFC 9112 §9.3).
bool explicitKeepAlive = false;
// Response to HEAD: send the header section, including the
// content-length the body *would* have had, but no body.
bool omitBody = false;
};
// Headers whose value is derived from the message rather than copied
// from the caller's map — emitting the caller's copy as well would
// produce a duplicate, and for content-length a smuggling vector.
inline bool IsFramingHeader(std::string_view lowerName) {
return lowerName == "host" || lowerName == "content-length"
|| lowerName == "transfer-encoding" || lowerName == "connection";
}
inline void ValidateFieldName(std::string_view name) {
if (!IsToken(name)) {
throw HTTP1ProtocolError("invalid header field name: '" + std::string(name) + "'");
}
}
// A CR or LF smuggled through a field value would split the message.
inline void ValidateFieldValue(std::string_view name, std::string_view value) {
if (value.find_first_of("\r\n") != std::string_view::npos) {
throw HTTP1ProtocolError("header '" + std::string(name) + "' contains CR/LF");
}
}
inline void AppendHeaders(std::string& out,
const std::unordered_map<std::string, std::string>& headers) {
for (const auto& [name, value] : headers) {
// HTTP/3 pseudo-headers have no HTTP/1.1 equivalent — they are
// already represented by the start line.
if (name.empty() || name.front() == ':') continue;
std::string lower = ToLowerAscii(name);
if (IsFramingHeader(lower)) continue;
ValidateFieldName(lower);
ValidateFieldValue(lower, value);
out += lower;
out += ": ";
out += value;
out += "\r\n";
}
}
// Serialise a request in origin-form. `authority` becomes the mandatory
// Host header (RFC 9112 §3.2); ClientHTTP1 fills it in from the
// connection when the caller left it empty.
export inline std::string SerializeRequest(const HTTPRequest& request, RequestOptions options = {}) {
const std::string method = request.method.empty() ? std::string("GET") : request.method;
const std::string target = request.path.empty() ? std::string("/") : request.path;
if (!IsToken(method)) throw HTTP1ProtocolError("invalid request method: '" + method + "'");
if (target.find_first_of(" \r\n") != std::string::npos) {
throw HTTP1ProtocolError("invalid request target: '" + target + "'");
}
ValidateFieldValue("host", request.authority);
std::string out;
out.reserve(256 + request.headers.size() * 32 + request.body.size());
out += method;
out += ' ';
out += target;
out += " HTTP/1.1\r\n";
out += "host: ";
out += request.authority;
out += "\r\n";
AppendHeaders(out, request.headers);
// A body always gets an explicit length; so does a method that is
// normally expected to carry one, since some servers answer 411 for
// a bodyless POST that omits it.
if (!request.body.empty() || method == "POST" || method == "PUT" || method == "PATCH") {
out += "content-length: ";
out += std::to_string(request.body.size());
out += "\r\n";
}
if (!options.keepAlive) out += "connection: close\r\n";
out += "\r\n";
out += request.body;
return out;
}
export inline std::string SerializeResponse(const HTTPResponse& response, ResponseOptions options = {}) {
std::string status = response.status.empty() ? std::string("200") : response.status;
if (status.size() != 3 || !std::ranges::all_of(status, [](char c) { return c >= '0' && c <= '9'; })) {
throw HTTP1ProtocolError("status must be three digits, got '" + status + "'");
}
std::string out;
out.reserve(256 + response.headers.size() * 32 + response.body.size());
out += "HTTP/1.1 ";
out += status;
out += ' ';
out += ReasonPhrase(status);
out += "\r\n";
AppendHeaders(out, response.headers);
// 1xx / 204 / 304 must not carry a content-length at all — sending
// one makes some clients wait for a body that never arrives.
const bool bodyless = status[0] == '1' || status == "204" || status == "304";
if (!bodyless) {
out += "content-length: ";
out += std::to_string(response.body.size());
out += "\r\n";
}
if (!options.keepAlive) out += "connection: close\r\n";
else if (options.explicitKeepAlive) out += "connection: keep-alive\r\n";
out += "\r\n";
if (!options.omitBody && !bodyless) out += response.body;
return out;
}
// The interim response a server owes a client that sent
// `Expect: 100-continue` before it will send its body (RFC 9110 §10.1.1).
export inline std::string SerializeContinue() {
return "HTTP/1.1 100 Continue\r\n\r\n";
}
// ---------------- Incremental parser ----------------
export enum class MessageKind { Request, Response };
// Bounds on what we are willing to buffer before declaring the peer
// hostile. maxBody applies to the decoded body, so it also caps a
// chunked stream that never ends.
export struct MessageLimits {
std::size_t maxStartLine = 8 * 1024;
std::size_t maxHeaderSection = 64 * 1024;
std::uint64_t maxBody = 512ull * 1024 * 1024;
};
// Feed socket bytes in with Feed(); call Finish() when the peer closes
// its send side. Complete() then reports whether a whole message is
// available, and Take*() moves it out. Reset() rearms for the next
// message on the same connection, keeping any bytes that already
// belonged to it (HTTP/1.1 pipelining).
export class MessageParser {
public:
explicit MessageParser(MessageKind kind, MessageLimits limits = {})
: kind(kind), limits(limits) {}
// Responses are framed partly by the request that provoked them: a
// HEAD response has no body no matter what its content-length says.
// Tell the parser before feeding it bytes.
void SetRequestMethod(std::string_view requestMethod) {
headRequest = EqualsIgnoreCase(requestMethod, "HEAD");
}
void Feed(const char* data, std::size_t size) {
if (size != 0) buffer.append(data, size);
Advance();
}
void Feed(std::span<const char> bytes) { Feed(bytes.data(), bytes.size()); }
// The peer closed. Completes a read-to-EOF body; anything else
// that is mid-message is a truncation error.
void Finish() {
eof = true;
Advance();
}
bool HeadersComplete() const noexcept { return headersDone; }
bool Complete() const noexcept { return state == State::Done; }
// True while the peer is waiting for `100 Continue` before it sends
// the body. Clear it with ContinueSent() once the interim response
// is on the wire.
bool ExpectsContinue() const noexcept { return expectContinue && !Complete(); }
void ContinueSent() noexcept { expectContinue = false; }
// Whether the connection may carry another message after this one.
// Meaningful once HeadersComplete().
bool KeepAlive() const noexcept { return keepAlive; }
// No partial message buffered — a clean point to stop reading.
bool AtMessageBoundary() const noexcept {
return state == State::StartLine && pos == buffer.size() && !headersDone;
}
std::string_view Method() const noexcept { return method; }
std::string_view Version() const noexcept { return version; }
std::string_view Status() const noexcept { return status; }
HTTPRequest TakeRequest() {
if (state != State::Done) throw HTTP1ProtocolError("request is not complete");
HTTPRequest request;
request.method = std::move(method);
request.path = std::move(path);
request.scheme = scheme.empty() ? std::string("http") : std::move(scheme);
// Absolute-form targets carry their own authority and win over
// Host (RFC 9112 §3.2.2); otherwise Host supplies it. Either way
// it lives in the named field, matching the HTTP/3 shape, so it
// is removed from the header map.
if (authority.empty()) {
if (auto it = headers.find("host"); it != headers.end()) authority = it->second;
}
headers.erase("host");
request.authority = std::move(authority);
request.headers = std::move(headers);
request.body = std::move(body);
return request;
}
HTTPResponse TakeResponse() {
if (state != State::Done) throw HTTP1ProtocolError("response is not complete");
HTTPResponse response;
response.status = std::move(status);
response.headers = std::move(headers);
response.body = std::move(body);
return response;
}
// Rearm for the next message. Any bytes past the one just taken are
// retained and parsed immediately, so a pipelined request can be
// Complete() as soon as this returns.
void Reset() {
buffer.erase(0, pos);
pos = 0;
state = State::StartLine;
method.clear();
path.clear();
scheme.clear();
authority.clear();
version.clear();
status.clear();
lastHeaderName.clear();
headers.clear();
body.clear();
headerBytes = 0;
bodyRemaining = 0;
chunkRemaining = 0;
bodyMode = BodyMode::None;
headersDone = false;
expectContinue = false;
keepAlive = true;
headRequest = false;
Advance();
}
private:
enum class State { StartLine, Headers, Body, ChunkSize, ChunkData, ChunkTrailingCRLF, Trailers, Done };
enum class BodyMode { None, Length, Chunked, UntilClose };
// Read one CRLF-terminated line. A bare LF is accepted as well: it
// is not legal to generate, but recipients may recognise it
// (RFC 9112 §2.2) and some embedded clients still emit it.
std::optional<std::string_view> TryReadLine(std::size_t maxLength) {
const std::size_t nl = buffer.find('\n', pos);
if (nl == std::string::npos) {
if (buffer.size() - pos > maxLength) {
throw HTTP1ProtocolError("line exceeds " + std::to_string(maxLength) + " bytes");
}
if (eof && buffer.size() != pos) {
throw HTTP1ProtocolError("connection closed mid-message");
}
return std::nullopt;
}
std::size_t end = nl;
if (end > pos && buffer[end - 1] == '\r') --end;
if (end - pos > maxLength) {
throw HTTP1ProtocolError("line exceeds " + std::to_string(maxLength) + " bytes");
}
std::string_view line(buffer.data() + pos, end - pos);
pos = nl + 1;
return line;
}
void ParseRequestLine(std::string_view line) {
const auto first = line.find(' ');
if (first == std::string_view::npos) throw HTTP1ProtocolError("malformed request line");
const auto second = line.find(' ', first + 1);
if (second == std::string_view::npos) {
// HTTP/0.9 simple-request. Not supported, and accepting it
// would leave us unable to answer with headers.
throw HTTP1ProtocolError("malformed request line (missing version)");
}
method = std::string(line.substr(0, first));
const std::string_view target = line.substr(first + 1, second - first - 1);
version = std::string(line.substr(second + 1));
if (!IsToken(method)) throw HTTP1ProtocolError("invalid method token");
if (target.empty()) throw HTTP1ProtocolError("empty request target");
CheckVersion();
if (target.front() == '/') {
path = std::string(target); // origin-form
} else if (target == "*") {
path = "*"; // asterisk-form (OPTIONS)
} else if (const auto sep = target.find("://"); sep != std::string_view::npos) {
// absolute-form, as sent to a proxy.
scheme = ToLowerAscii(target.substr(0, sep));
const std::string_view rest = target.substr(sep + 3);
const auto slash = rest.find('/');
authority = std::string(rest.substr(0, slash));
path = slash == std::string_view::npos ? std::string("/") : std::string(rest.substr(slash));
} else {
// authority-form — only legal for CONNECT, which this
// listener does not implement.
authority = std::string(target);
path = std::string(target);
}
}
void ParseStatusLine(std::string_view line) {
const auto first = line.find(' ');
if (first == std::string_view::npos) throw HTTP1ProtocolError("malformed status line");
version = std::string(line.substr(0, first));
CheckVersion();
std::string_view rest = TrimOWS(line.substr(first + 1));
const auto space = rest.find(' ');
status = std::string(space == std::string_view::npos ? rest : rest.substr(0, space));
if (status.size() != 3
|| !std::ranges::all_of(status, [](char c) { return c >= '0' && c <= '9'; })) {
throw HTTP1ProtocolError("malformed status code '" + status + "'");
}
// The reason phrase is discarded: it carries no semantics and
// HTTPResponse has nowhere to put it.
}
void CheckVersion() {
if (version != kVersion11 && version != kVersion10) {
throw HTTP1ProtocolError("unsupported HTTP version '" + version + "'");
}
}
void AddHeader(std::string name, std::string_view value) {
auto [it, inserted] = headers.try_emplace(std::move(name), std::string(value));
if (!inserted) {
// Repeated field lines are equivalent to one comma-joined
// value (RFC 9110 §5.3) — except content-length, where
// disagreeing values are a smuggling attempt.
if (it->first == "content-length" && it->second != value) {
throw HTTP1ProtocolError("conflicting content-length values");
}
if (it->first != "content-length") {
it->second += ", ";
it->second += value;
}
}
}
void ParseHeaderLine(std::string_view line) {
headerBytes += line.size() + 2;
if (headerBytes > limits.maxHeaderSection) {
throw HTTP1ProtocolError("header section exceeds "
+ std::to_string(limits.maxHeaderSection) + " bytes");
}
if (line.front() == ' ' || line.front() == '\t') {
// obs-fold. RFC 9112 §5.2 allows replacing it with a space
// rather than rejecting the message.
if (lastHeaderName.empty()) throw HTTP1ProtocolError("obs-fold before any header");
auto it = headers.find(lastHeaderName);
if (it != headers.end()) {
it->second += ' ';
it->second += TrimOWS(line);
}
return;
}
const auto colon = line.find(':');
if (colon == std::string_view::npos) throw HTTP1ProtocolError("header line has no colon");
const std::string_view rawName = line.substr(0, colon);
// "No whitespace is allowed between the field name and colon" —
// a recipient MUST reject such a message (RFC 9112 §5.1).
if (!IsToken(rawName)) throw HTTP1ProtocolError("invalid header field name");
lastHeaderName = ToLowerAscii(rawName);
AddHeader(lastHeaderName, TrimOWS(line.substr(colon + 1)));
}
std::optional<std::string_view> Header(std::string_view name) const {
auto it = headers.find(std::string(name));
if (it == headers.end()) return std::nullopt;
return std::string_view(it->second);
}
void ResolveKeepAlive() {
const auto connection = Header("connection");
const bool close = connection && HasToken(*connection, "close");
const bool explicitKeep = connection && HasToken(*connection, "keep-alive");
keepAlive = version == kVersion11 ? !close : (explicitKeep && !close);
}
void ResolveFraming() {
const auto transferEncoding = Header("transfer-encoding");
const auto contentLength = Header("content-length");
if (transferEncoding && contentLength) {
// Ambiguous framing — the classic smuggling primitive.
throw HTTP1ProtocolError("both content-length and transfer-encoding present");
}
if (transferEncoding) {
auto codings = SplitCommaList(*transferEncoding);
if (codings.empty() || !EqualsIgnoreCase(codings.back(), "chunked")) {
throw HTTP1ProtocolError("unsupported transfer-encoding '"
+ std::string(*transferEncoding) + "'");
}
if (codings.size() > 1) {
throw HTTP1ProtocolError("stacked transfer-codings are not supported");
}
bodyMode = BodyMode::Chunked;
return;
}
if (contentLength) {
if (contentLength->empty()
|| !std::ranges::all_of(*contentLength, [](char c) { return c >= '0' && c <= '9'; })) {
throw HTTP1ProtocolError("malformed content-length '"
+ std::string(*contentLength) + "'");
}
std::uint64_t length = 0;
const auto result = std::from_chars(contentLength->data(),
contentLength->data() + contentLength->size(), length);
if (result.ec != std::errc{}) throw HTTP1ProtocolError("content-length out of range");
if (length > limits.maxBody) {
throw HTTP1ProtocolError("body exceeds " + std::to_string(limits.maxBody) + " bytes");
}
bodyRemaining = length;
bodyMode = length == 0 ? BodyMode::None : BodyMode::Length;
return;
}
// No explicit framing. A request has no body; a response runs
// to connection close (RFC 9112 §6.3).
if (kind == MessageKind::Request) {
bodyMode = BodyMode::None;
} else {
bodyMode = BodyMode::UntilClose;
keepAlive = false;
}
}
// Returns false when this was an interim (1xx) response and parsing
// must restart on the next status line.
bool FinishHeaders() {
ResolveKeepAlive();
if (kind == MessageKind::Response) {
if (status[0] == '1') {
// Interim response (100 Continue, 103 Early Hints, …).
// It has no body and is not the answer to the request,
// so drop it and keep reading.
headers.clear();
lastHeaderName.clear();
status.clear();
version.clear();
headerBytes = 0;
state = State::StartLine;
return false;
}
if (status == "204" || status == "304" || headRequest) {
bodyMode = BodyMode::None;
headersDone = true;
state = State::Done;
return true;
}
} else if (auto expect = Header("expect")) {
expectContinue = HasToken(*expect, "100-continue");
}
ResolveFraming();
headersDone = true;
switch (bodyMode) {
case BodyMode::None: state = State::Done; break;
case BodyMode::Length: state = State::Body; break;
case BodyMode::Chunked: state = State::ChunkSize; break;
case BodyMode::UntilClose: state = State::Body; break;
}
return true;
}
void AppendBody(const char* data, std::size_t size) {
if (body.size() + size > limits.maxBody) {
throw HTTP1ProtocolError("body exceeds " + std::to_string(limits.maxBody) + " bytes");
}
body.append(data, size);
}
// Drive the state machine as far as the buffered bytes allow, then
// drop what has been consumed so a long-lived connection (or a
// large body arriving in many chunks) doesn't grow the buffer
// without bound.
void Advance() {
AdvanceState();
if (pos != 0 && pos == buffer.size()) {
buffer.clear();
pos = 0;
}
}
void AdvanceState() {
for (;;) {
switch (state) {
case State::StartLine: {
auto line = TryReadLine(limits.maxStartLine);
if (!line) return;
// A server should tolerate stray empty lines left
// over from a previous message (RFC 9112 §2.2).
if (line->empty()) continue;
if (kind == MessageKind::Request) ParseRequestLine(*line);
else ParseStatusLine(*line);
state = State::Headers;
continue;
}
case State::Headers: {
auto line = TryReadLine(limits.maxHeaderSection);
if (!line) return;
if (line->empty()) {
FinishHeaders();
continue;
}
ParseHeaderLine(*line);
continue;
}
case State::Body: {
const std::size_t available = buffer.size() - pos;
if (bodyMode == BodyMode::UntilClose) {
AppendBody(buffer.data() + pos, available);
pos = buffer.size();
if (!eof) return;
state = State::Done;
continue;
}
const std::size_t take = static_cast<std::size_t>(
std::min<std::uint64_t>(available, bodyRemaining));
AppendBody(buffer.data() + pos, take);
pos += take;
bodyRemaining -= take;
if (bodyRemaining != 0) {
if (eof) throw HTTP1ProtocolError("connection closed mid-body");
return;
}
state = State::Done;
continue;
}
case State::ChunkSize: {
auto line = TryReadLine(limits.maxStartLine);
if (!line) return;
// Chunk extensions after ';' are legal and ignorable.
std::string_view digits = TrimOWS(line->substr(0, line->find(';')));
if (digits.empty()) throw HTTP1ProtocolError("empty chunk size");
std::uint64_t size = 0;
const auto result = std::from_chars(digits.data(), digits.data() + digits.size(),
size, 16);
if (result.ec != std::errc{} || result.ptr != digits.data() + digits.size()) {
throw HTTP1ProtocolError("malformed chunk size '" + std::string(digits) + "'");
}
if (size == 0) {
state = State::Trailers;
continue;
}
chunkRemaining = size;
state = State::ChunkData;
continue;
}
case State::ChunkData: {
const std::size_t available = buffer.size() - pos;
if (available == 0) {
if (eof) throw HTTP1ProtocolError("connection closed mid-chunk");
return;
}
const std::size_t take = static_cast<std::size_t>(
std::min<std::uint64_t>(available, chunkRemaining));
AppendBody(buffer.data() + pos, take);
pos += take;
chunkRemaining -= take;
if (chunkRemaining != 0) {
if (eof) throw HTTP1ProtocolError("connection closed mid-chunk");
return;
}
state = State::ChunkTrailingCRLF;
continue;
}
case State::ChunkTrailingCRLF: {
auto line = TryReadLine(2);
if (!line) return;
if (!line->empty()) throw HTTP1ProtocolError("chunk not terminated by CRLF");
state = State::ChunkSize;
continue;
}
case State::Trailers: {
auto line = TryReadLine(limits.maxHeaderSection);
if (!line) return;
if (line->empty()) {
state = State::Done;
continue;
}
// Trailer fields are merged into the header map;
// callers see one flat view of the message.
ParseHeaderLine(*line);
continue;
}
case State::Done:
return;
}
}
}
MessageKind kind;
MessageLimits limits;
State state = State::StartLine;
BodyMode bodyMode = BodyMode::None;
std::string buffer;
std::size_t pos = 0;
bool eof = false;
std::string method;
std::string path;
std::string scheme;
std::string authority;
std::string version;
std::string status;
std::string lastHeaderName;
std::unordered_map<std::string, std::string> headers;
std::string body;
std::size_t headerBytes = 0;
std::uint64_t bodyRemaining = 0;
std::uint64_t chunkRemaining = 0;
bool headersDone = false;
bool expectContinue = false;
bool keepAlive = true;
bool headRequest = false;
};
}

View file

@ -0,0 +1,76 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Crafter.Network:ListenerHTTP1;
import std;
import :HTTP;
import :HTTP1;
#ifndef CRAFTER_NETWORK_BROWSER
namespace Crafter {
// HTTP/1.1 server over plain TCP. Same route map shape as ListenerHTTP,
// so a handler can be registered with both and served over either
// protocol.
//
// Each accepted connection gets its own thread and is served
// sequentially until the peer closes it, `Connection: close` is seen, or
// an idle/read timeout expires. Handlers therefore run concurrently
// across connections but never for two requests on the same one, which
// is what HTTP/1.1 response ordering requires. A dedicated thread rather
// than a ThreadPool task is deliberate: keep-alive connections are idle
// most of their life and would otherwise pin every pool thread.
//
// Implemented: keep-alive and pipelining, content-length and chunked
// request bodies, `Expect: 100-continue`, HEAD (headers only), automatic
// `Date`, and 400/404/500 error responses. Not implemented: TLS,
// CONNECT tunnels, Upgrade, and chunked *responses* (handlers return a
// complete body, so responses are always content-length framed).
export class ListenerHTTP1 {
public:
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
// How long a connection may stay idle between requests, and how long
// a single request may take to arrive once started. Both guard
// against a peer holding a thread forever.
std::chrono::milliseconds keepAliveTimeout{15000};
std::chrono::milliseconds requestTimeout{30000};
// Limits applied to incoming requests.
HTTP1::MessageLimits limits;
ListenerHTTP1(std::uint16_t port,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes);
~ListenerHTTP1();
ListenerHTTP1(const ListenerHTTP1&) = delete;
ListenerHTTP1(ListenerHTTP1&&) noexcept;
// Run the accept loop on the calling thread until Stop().
void Listen();
// Stop accepting, close every live connection, and wait for the
// connection threads to finish.
void Stop();
// Number of connections currently being served.
std::size_t ConnectionCount() const;
// Connections accepted since construction.
std::uint64_t AcceptedCount() const;
private:
struct Impl;
std::unique_ptr<Impl> impl;
};
// Runs the accept loop on a background thread so the caller can keep
// going. Mirrors ListenerAsyncHTTP.
export class ListenerAsyncHTTP1 {
public:
ListenerHTTP1 listener;
std::thread thread;
ListenerAsyncHTTP1(std::uint16_t port,
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes);
~ListenerAsyncHTTP1();
void Stop();
};
}
#endif

View file

@ -17,4 +17,10 @@ export import :WebTransport;
// not need the HTTP/3 frame helpers directly. Excluded from the browser // not need the HTTP/3 frame helpers directly. Excluded from the browser
// build — HTTP3 uses throw and the wasm target runs with -fno-exceptions. // build — HTTP3 uses throw and the wasm target runs with -fno-exceptions.
export import :HTTP3; export import :HTTP3;
// HTTP/1.1 over TCP, for peers that are not ready for HTTP/3. Native only:
// in the browser this job is already done by fetch() behind :ClientHTTP,
// and these partitions use exceptions and POSIX sockets.
export import :HTTP1;
export import :ClientHTTP1;
export import :ListenerHTTP1;
#endif #endif

View file

@ -7,13 +7,16 @@ namespace fs = std::filesystem;
using namespace Crafter; using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) { extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
constexpr std::array<std::string_view, 10> networkInterfaces = { constexpr std::array<std::string_view, 13> networkInterfaces = {
"interfaces/Crafter.Network", "interfaces/Crafter.Network",
"interfaces/Crafter.Network-ClientTCP", "interfaces/Crafter.Network-ClientTCP",
"interfaces/Crafter.Network-ListenerTCP", "interfaces/Crafter.Network-ListenerTCP",
"interfaces/Crafter.Network-ClientHTTP", "interfaces/Crafter.Network-ClientHTTP",
"interfaces/Crafter.Network-ListenerHTTP", "interfaces/Crafter.Network-ListenerHTTP",
"interfaces/Crafter.Network-HTTP", "interfaces/Crafter.Network-HTTP",
"interfaces/Crafter.Network-HTTP1",
"interfaces/Crafter.Network-ClientHTTP1",
"interfaces/Crafter.Network-ListenerHTTP1",
"interfaces/Crafter.Network-HTTP3", "interfaces/Crafter.Network-HTTP3",
"interfaces/Crafter.Network-ClientQUIC", "interfaces/Crafter.Network-ClientQUIC",
"interfaces/Crafter.Network-ListenerQUIC", "interfaces/Crafter.Network-ListenerQUIC",
@ -69,11 +72,13 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
return cfg; return cfg;
} }
constexpr std::array<std::string_view, 7> networkImplementations = { constexpr std::array<std::string_view, 9> networkImplementations = {
"implementations/Crafter.Network-ClientTCP", "implementations/Crafter.Network-ClientTCP",
"implementations/Crafter.Network-ListenerTCP", "implementations/Crafter.Network-ListenerTCP",
"implementations/Crafter.Network-ClientHTTP", "implementations/Crafter.Network-ClientHTTP",
"implementations/Crafter.Network-ListenerHTTP", "implementations/Crafter.Network-ListenerHTTP",
"implementations/Crafter.Network-ClientHTTP1",
"implementations/Crafter.Network-ListenerHTTP1",
"implementations/Crafter.Network-ClientQUIC", "implementations/Crafter.Network-ClientQUIC",
"implementations/Crafter.Network-ListenerQUIC", "implementations/Crafter.Network-ListenerQUIC",
"implementations/Crafter.Network-WebTransport", "implementations/Crafter.Network-WebTransport",
@ -103,9 +108,9 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
// linker at the actual output location. // linker at the actual output location.
msquic.libDirs = { "bin/Release" }; msquic.libDirs = { "bin/Release" };
msquic.libs = { "msquic" }; msquic.libs = { "msquic" };
std::array<fs::path, 10> ifaces; std::array<fs::path, 13> ifaces;
std::ranges::copy(networkInterfaces, ifaces.begin()); std::ranges::copy(networkInterfaces, ifaces.begin());
std::array<fs::path, 7> impls; std::array<fs::path, 9> impls;
std::ranges::copy(networkImplementations, impls.begin()); std::ranges::copy(networkImplementations, impls.begin());
cfg.GetInterfacesAndImplementations(ifaces, impls); cfg.GetInterfacesAndImplementations(ifaces, impls);
@ -114,13 +119,19 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
// crafter-network static lib via .Dependencies({ &cfg }). // crafter-network static lib via .Dependencies({ &cfg }).
if (cfg.target == "x86_64-pc-linux-gnu") { if (cfg.target == "x86_64-pc-linux-gnu") {
cfg.AddTest("ShouldEchoWebTransport").Dependencies({ &cfg }); cfg.AddTest("ShouldEchoWebTransport").Dependencies({ &cfg });
cfg.AddTest("ShouldInteropCurlHTTP1").Dependencies({ &cfg });
cfg.AddTest("ShouldNotDropEarlyStreams").Dependencies({ &cfg }); cfg.AddTest("ShouldNotDropEarlyStreams").Dependencies({ &cfg });
cfg.AddTest("ShouldParseHTTP1").Dependencies({ &cfg });
cfg.AddTest("ShouldSend").Dependencies({ &cfg }); cfg.AddTest("ShouldSend").Dependencies({ &cfg });
cfg.AddTest("ShouldSendRecieveHTTP1").Dependencies({ &cfg });
cfg.AddTest("ShouldSendRecieveKeepaliveHTTP1").Dependencies({ &cfg });
cfg.AddTest("ShouldSendRecieveLargeHTTP1").Dependencies({ &cfg });
cfg.AddTest("ShouldSendRecieveHTTP").Dependencies({ &cfg }); cfg.AddTest("ShouldSendRecieveHTTP").Dependencies({ &cfg });
cfg.AddTest("ShouldSendRecieveKeepaliveHTTP").Dependencies({ &cfg }); cfg.AddTest("ShouldSendRecieveKeepaliveHTTP").Dependencies({ &cfg });
cfg.AddTest("ShouldSendRecieveLargeHTTP").Dependencies({ &cfg }); cfg.AddTest("ShouldSendRecieveLargeHTTP").Dependencies({ &cfg });
cfg.AddTest("ShouldSendRecieveQUICDatagram").Dependencies({ &cfg }); cfg.AddTest("ShouldSendRecieveQUICDatagram").Dependencies({ &cfg });
cfg.AddTest("ShouldSendRecieveQUICStream").Dependencies({ &cfg }); cfg.AddTest("ShouldSendRecieveQUICStream").Dependencies({ &cfg });
cfg.AddTest("ShouldSurviveAbuseHTTP1").Dependencies({ &cfg });
} }
return cfg; return cfg;

View file

@ -0,0 +1,233 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Interop against implementations that are not this library — the whole
// point of shipping HTTP/1.1 in the first place.
//
// * curl drives ListenerHTTP1: keep-alive reuse, chunked upload,
// Expect: 100-continue, HEAD, and a plain GET.
// * ClientHTTP1 drives python3's http.server, which answers HTTP/1.0 with
// `Connection: close` — the legacy shape our own listener never emits.
//
// Both peers are optional: if curl or python3 is missing the corresponding
// half is skipped rather than failed, so the suite still runs on a bare
// machine.
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
import Crafter.Network;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool condition, std::string_view what) {
if (!condition) {
std::println("FAIL: {}", what);
++failures;
}
}
bool HaveCommand(std::string_view name) {
const std::string probe = "command -v " + std::string(name) + " >/dev/null 2>&1";
return std::system(probe.c_str()) == 0;
}
// Run a command and return its stdout. stderr is folded in so a curl
// failure explains itself in the test output.
std::string Run(const std::string& command) {
std::string output;
FILE* pipe = popen((command + " 2>&1").c_str(), "r");
if (pipe == nullptr) return output;
char buffer[4096];
while (std::size_t read = std::fread(buffer, 1, sizeof(buffer), pipe)) {
output.append(buffer, read);
}
pclose(pipe);
return output;
}
// A child process, killed when this goes out of scope.
class Child {
public:
explicit Child(std::vector<std::string> argv) {
std::vector<char*> raw;
for (auto& argument : argv) raw.push_back(argument.data());
raw.push_back(nullptr);
pid = fork();
if (pid == 0) {
// Keep the test output clean; the child's chatter is not
// interesting unless it fails to start, which shows up as a
// connection failure instead.
freopen("/dev/null", "w", stdout);
freopen("/dev/null", "w", stderr);
execvp(raw[0], raw.data());
_exit(127);
}
}
~Child() {
if (pid > 0) {
kill(pid, SIGTERM);
int status = 0;
waitpid(pid, &status, 0);
}
}
Child(const Child&) = delete;
bool Started() const { return pid > 0; }
private:
pid_t pid = -1;
};
// Poll until something accepts on the port, so the test doesn't race a
// slow-starting server.
bool WaitForPort(std::uint16_t port, std::chrono::milliseconds budget) {
const auto deadline = std::chrono::steady_clock::now() + budget;
while (std::chrono::steady_clock::now() < deadline) {
try {
ClientTCP probe("localhost", port);
return true;
} catch (const std::exception&) {
std::this_thread::sleep_for(std::chrono::milliseconds(25));
}
}
return false;
}
void CurlAgainstListener() {
if (!HaveCommand("curl")) {
std::println("skipping the curl half: curl is not installed");
return;
}
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
routes["/hello"] = [](const HTTPRequest&) {
return CreateResponseHTTP("200", {{"content-type", "text/plain"}}, "Hello curl!");
};
routes["/echo"] = [](const HTTPRequest& request) {
return CreateResponseHTTP("200", request.method + ":" + request.body);
};
routes["/agent"] = [](const HTTPRequest& request) {
auto agent = request.headers.find("user-agent");
return CreateResponseHTTP("200", agent == request.headers.end() ? "none" : agent->second);
};
ListenerAsyncHTTP1 listener(8093, std::move(routes));
Check(WaitForPort(8093, std::chrono::seconds(2)), "the HTTP/1.1 listener came up");
const std::string base = "http://localhost:8093";
Check(Run("curl -sS --http1.1 " + base + "/hello") == "Hello curl!", "curl GET");
// Two URLs in one invocation: curl reuses the connection, which
// only works if our framing let it know the first response ended.
const std::uint64_t before = listener.listener.AcceptedCount();
const std::string both = Run("curl -sS --http1.1 " + base + "/hello " + base + "/hello");
Check(both == "Hello curl!Hello curl!", "curl got both responses");
Check(listener.listener.AcceptedCount() == before + 1, "curl reused one connection for both");
Check(Run("curl -sS --http1.1 -d 'body text' " + base + "/echo") == "POST:body text",
"curl POST with content-length");
// Chunked upload — curl streams stdin with Transfer-Encoding:
// chunked when it can't know the length up front.
Check(Run("printf 'streamed body' | curl -sS --http1.1 -H 'Transfer-Encoding: chunked' "
"--data-binary @- " + base + "/echo") == "POST:streamed body",
"curl chunked upload");
// A body over 1 KiB makes curl wait for `100 Continue` before it
// sends anything. Run it verbosely so the trace proves the interim
// response actually went out — without it curl still recovers after
// a one-second stall, which would hide the bug.
const std::string large(64 * 1024, 'x');
const std::string upload =
"head -c 65536 /dev/zero | tr '\\0' 'x' | curl -sS --http1.1 "
"-H 'Expect: 100-continue' --data-binary @- " + base + "/echo";
// The trace goes to stderr and the body to stdout; keeping them in
// separate runs avoids the two streams interleaving in the pipe.
Check(Run(upload + " -v -o /dev/null").find("HTTP/1.1 100 Continue") != std::string::npos,
"curl saw the interim 100 Continue");
Check(Run(upload) == "POST:" + large, "curl Expect: 100-continue upload arrived intact");
// HEAD must produce the GET headers and no body.
const std::string head = Run("curl -sS --http1.1 -I " + base + "/hello");
Check(head.find("HTTP/1.1 200 OK") != std::string::npos, "curl HEAD status line");
Check(head.find("content-length: 11") != std::string::npos, "curl HEAD keeps content-length");
Check(head.find("Hello curl!") == std::string::npos, "curl HEAD carries no body");
Check(Run("curl -sS --http1.1 -A 'crafter-test/1.0' " + base + "/agent") == "crafter-test/1.0",
"request headers reach the handler");
// The status line has to be readable by a real client, not just by
// our own parser.
Check(Run("curl -sS --http1.1 -o /dev/null -w '%{http_code}' " + base + "/missing") == "404",
"curl reads the 404 status");
listener.Stop();
}
void ClientAgainstPythonServer() {
if (!HaveCommand("python3")) {
std::println("skipping the python half: python3 is not installed");
return;
}
const std::filesystem::path root =
std::filesystem::temp_directory_path() / "crafter-network-http1-interop";
std::filesystem::create_directories(root);
const std::string content = "served by python\n";
{
std::ofstream file(root / "hello.txt", std::ios::binary);
file << content;
}
// http.server answers HTTP/1.0 with `Connection: close`: every
// request needs its own connection, and the client has to notice.
Child server({"python3", "-m", "http.server", "8094", "--bind", "127.0.0.1",
"--directory", root.string()});
Check(server.Started(), "python3 http.server was spawned");
if (!WaitForPort(8094, std::chrono::seconds(10))) {
std::println("skipping the python half: http.server never came up");
return;
}
ClientHTTP1 client("localhost", 8094);
HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/hello.txt", "localhost:8094"));
Check(response.status == "200", "python GET status");
Check(response.body == content, "python GET body");
Check(!client.Connected(), "an HTTP/1.0 response closes the connection");
HTTPResponse listing = client.Send(CreateRequestHTTP("GET", "/", "localhost:8094"));
Check(listing.status == "200", "python directory listing status");
Check(listing.body.find("hello.txt") != std::string::npos, "python directory listing body");
HTTPResponse missing = client.Send(CreateRequestHTTP("GET", "/nothing-here", "localhost:8094"));
Check(missing.status == "404", "python 404");
HTTPResponse head = client.Send(CreateRequestHTTP("HEAD", "/hello.txt", "localhost:8094"));
Check(head.status == "200", "python HEAD status");
Check(head.body.empty(), "python HEAD has no body");
std::filesystem::remove_all(root);
}
}
int main() {
try {
CurlAgainstListener();
ClientAgainstPythonServer();
} catch (const std::exception& error) {
std::println("threw: {}", error.what());
return 1;
}
if (failures != 0) {
std::println("{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,294 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Wire-format unit tests for the HTTP/1.1 codec. No sockets involved — the
// parser is fed the same byte sequences a peer would send, including the
// malformed ones we are supposed to refuse.
import Crafter.Network;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool condition, std::string_view what) {
if (!condition) {
std::println("FAIL: {}", what);
++failures;
}
}
template <typename T>
void CheckEqual(const T& actual, const T& expected, std::string_view what) {
if (!(actual == expected)) {
std::println("FAIL: {} — expected '{}', got '{}'", what, expected, actual);
++failures;
}
}
// Feed the message one byte at a time: any parser that only works on
// whole-message reads falls over here, and real sockets do split
// messages at arbitrary offsets.
HTTP1::MessageParser ParseByteByByte(HTTP1::MessageKind kind, std::string_view wire) {
HTTP1::MessageParser parser(kind);
for (char c : wire) parser.Feed(&c, 1);
return parser;
}
bool Rejects(HTTP1::MessageKind kind, std::string_view wire) {
try {
HTTP1::MessageParser parser(kind);
parser.Feed(wire.data(), wire.size());
parser.Finish();
return false;
} catch (const HTTP1::HTTP1ProtocolError&) {
return true;
} catch (...) {
return false;
}
}
void RequestRoundTrip() {
auto parser = ParseByteByByte(HTTP1::MessageKind::Request,
"POST /submit?id=7 HTTP/1.1\r\n"
"Host: example.test:8080\r\n"
"Content-Length: 5\r\n"
"X-Multi: a\r\n"
"X-Multi: b\r\n"
"\r\n"
"hello");
Check(parser.Complete(), "request completes");
Check(parser.KeepAlive(), "HTTP/1.1 defaults to keep-alive");
HTTPRequest request = parser.TakeRequest();
CheckEqual<std::string>(request.method, "POST", "method");
CheckEqual<std::string>(request.path, "/submit?id=7", "target keeps its query string");
CheckEqual<std::string>(request.authority, "example.test:8080", "host becomes authority");
CheckEqual<std::string>(request.body, "hello", "body");
Check(!request.headers.contains("host"), "host is not duplicated into the header map");
CheckEqual<std::string>(request.headers.at("x-multi"), "a, b", "repeated fields are joined");
}
void HeaderNamesAreCaseInsensitive() {
HTTP1::MessageParser parser(HTTP1::MessageKind::Request);
std::string_view wire = "GET / HTTP/1.1\r\nHOST: h\r\nContent-Type: text/plain\r\n\r\n";
parser.Feed(wire.data(), wire.size());
Check(parser.Complete(), "bodyless request completes at the blank line");
HTTPRequest request = parser.TakeRequest();
CheckEqual<std::string>(request.headers.at("content-type"), "text/plain",
"field names are lowercased");
}
void ChunkedBodyWithTrailers() {
auto parser = ParseByteByByte(HTTP1::MessageKind::Request,
"PUT /upload HTTP/1.1\r\n"
"Host: h\r\n"
"Transfer-Encoding: chunked\r\n"
"\r\n"
"5\r\nhello\r\n"
"6;ext=1\r\n world\r\n"
"0\r\n"
"X-Checksum: 42\r\n"
"\r\n");
Check(parser.Complete(), "chunked request completes at the zero chunk");
HTTPRequest request = parser.TakeRequest();
CheckEqual<std::string>(request.body, "hello world", "chunks are reassembled");
CheckEqual<std::string>(request.headers.at("x-checksum"), "42", "trailers are merged in");
}
void PipelinedRequests() {
HTTP1::MessageParser parser(HTTP1::MessageKind::Request);
std::string_view wire = "GET /one HTTP/1.1\r\nHost: h\r\n\r\nGET /two HTTP/1.1\r\nHost: h\r\n\r\n";
parser.Feed(wire.data(), wire.size());
Check(parser.Complete(), "first pipelined request is complete");
CheckEqual<std::string>(parser.TakeRequest().path, "/one", "first target");
parser.Reset();
Check(parser.Complete(), "second request was already buffered");
CheckEqual<std::string>(parser.TakeRequest().path, "/two", "second target");
}
void ExpectContinue() {
HTTP1::MessageParser parser(HTTP1::MessageKind::Request);
std::string_view head = "POST / HTTP/1.1\r\nHost: h\r\nExpect: 100-continue\r\nContent-Length: 2\r\n\r\n";
parser.Feed(head.data(), head.size());
Check(parser.ExpectsContinue(), "peer is waiting for 100 Continue");
parser.ContinueSent();
Check(!parser.ExpectsContinue(), "expectation clears once answered");
parser.Feed("hi", 2);
Check(parser.Complete(), "body arrives after the interim response");
}
void ConnectionCloseAndHTTP10() {
HTTP1::MessageParser closing(HTTP1::MessageKind::Request);
std::string_view wire = "GET / HTTP/1.1\r\nHost: h\r\nConnection: close\r\n\r\n";
closing.Feed(wire.data(), wire.size());
Check(!closing.KeepAlive(), "Connection: close ends the connection");
HTTP1::MessageParser legacy(HTTP1::MessageKind::Request);
std::string_view old = "GET / HTTP/1.0\r\nHost: h\r\n\r\n";
legacy.Feed(old.data(), old.size());
Check(!legacy.KeepAlive(), "HTTP/1.0 defaults to closing");
HTTP1::MessageParser legacyKeep(HTTP1::MessageKind::Request);
std::string_view oldKeep = "GET / HTTP/1.0\r\nHost: h\r\nConnection: keep-alive\r\n\r\n";
legacyKeep.Feed(oldKeep.data(), oldKeep.size());
Check(legacyKeep.KeepAlive(), "HTTP/1.0 reuses when asked explicitly");
}
void ResponseFramings() {
// Interim responses must not be mistaken for the real one.
auto early = ParseByteByByte(HTTP1::MessageKind::Response,
"HTTP/1.1 103 Early Hints\r\nLink: </s.css>\r\n\r\n"
"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok");
Check(early.Complete(), "response after 103 completes");
HTTPResponse response = early.TakeResponse();
CheckEqual<std::string>(response.status, "200", "1xx is skipped");
CheckEqual<std::string>(response.body, "ok", "final body");
Check(!response.headers.contains("link"), "interim headers do not leak into the response");
// No content-length and no chunking: the body ends at EOF.
HTTP1::MessageParser untilClose(HTTP1::MessageKind::Response);
std::string_view wire = "HTTP/1.1 200 OK\r\n\r\nstreamed";
untilClose.Feed(wire.data(), wire.size());
Check(!untilClose.Complete(), "close-framed body is not complete before EOF");
untilClose.Finish();
Check(untilClose.Complete(), "EOF ends a close-framed body");
Check(!untilClose.KeepAlive(), "a close-framed response cannot be followed by another");
CheckEqual<std::string>(untilClose.TakeResponse().body, "streamed", "close-framed body");
// A HEAD response carries the length the body would have had.
HTTP1::MessageParser head(HTTP1::MessageKind::Response);
head.SetRequestMethod("HEAD");
std::string_view headWire = "HTTP/1.1 200 OK\r\nContent-Length: 1234\r\n\r\n";
head.Feed(headWire.data(), headWire.size());
Check(head.Complete(), "HEAD response has no body to wait for");
CheckEqual<std::string>(head.TakeResponse().headers.at("content-length"), "1234",
"HEAD keeps the advertised length");
// 204 likewise.
HTTP1::MessageParser noContent(HTTP1::MessageKind::Response);
std::string_view noContentWire = "HTTP/1.1 204 No Content\r\n\r\n";
noContent.Feed(noContentWire.data(), noContentWire.size());
Check(noContent.Complete(), "204 has no body");
}
void AbsoluteFormTarget() {
HTTP1::MessageParser parser(HTTP1::MessageKind::Request);
std::string_view wire = "GET http://proxy.test/page HTTP/1.1\r\nHost: ignored\r\n\r\n";
parser.Feed(wire.data(), wire.size());
HTTPRequest request = parser.TakeRequest();
CheckEqual<std::string>(request.path, "/page", "absolute-form path");
CheckEqual<std::string>(request.authority, "proxy.test", "absolute-form authority wins over Host");
CheckEqual<std::string>(request.scheme, "http", "absolute-form scheme");
}
void RejectsMalformedMessages() {
Check(Rejects(HTTP1::MessageKind::Request,
"GET / HTTP/1.1\r\nHost: h\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\n\r\n"),
"content-length together with transfer-encoding is refused");
Check(Rejects(HTTP1::MessageKind::Request,
"GET / HTTP/1.1\r\nHost: h\r\nContent-Length: 1\r\nContent-Length: 2\r\n\r\n"),
"disagreeing content-lengths are refused");
Check(Rejects(HTTP1::MessageKind::Request, "GET / HTTP/1.1\r\nHost : h\r\n\r\n"),
"whitespace before the colon is refused");
Check(Rejects(HTTP1::MessageKind::Request, "GET / HTTP/1.1\r\nHost: h\r\nContent-Length: x\r\n\r\n"),
"non-numeric content-length is refused");
Check(Rejects(HTTP1::MessageKind::Request, "GET / HTTP/2.0\r\nHost: h\r\n\r\n"),
"unknown version is refused");
Check(Rejects(HTTP1::MessageKind::Request, "GET /\r\n\r\n"),
"HTTP/0.9 simple-request is refused");
Check(Rejects(HTTP1::MessageKind::Request,
"POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 10\r\n\r\nshort"),
"a truncated body is refused");
Check(Rejects(HTTP1::MessageKind::Response, "HTTP/1.1 2000 Huh\r\n\r\n"),
"a four-digit status is refused");
}
void RespectsLimits() {
HTTP1::MessageLimits limits;
limits.maxBody = 8;
HTTP1::MessageParser parser(HTTP1::MessageKind::Request, limits);
std::string_view wire = "POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 9\r\n\r\n123456789";
bool threw = false;
try {
parser.Feed(wire.data(), wire.size());
} catch (const HTTP1::HTTP1ProtocolError&) {
threw = true;
}
Check(threw, "a body over the limit is refused");
}
void Serialisation() {
HTTPRequest request = CreateRequestHTTP("GET", "/x", "example.test");
request.headers["Accept"] = "text/plain";
const std::string wire = HTTP1::SerializeRequest(request);
Check(wire.starts_with("GET /x HTTP/1.1\r\n"), "request line");
Check(wire.find("host: example.test\r\n") != std::string::npos, "host header is emitted");
Check(wire.find("accept: text/plain\r\n") != std::string::npos, "headers are lowercased");
Check(wire.find("content-length") == std::string::npos, "no content-length on a bodyless GET");
Check(wire.ends_with("\r\n\r\n"), "header section is terminated");
HTTPResponse response = CreateResponseHTTP("404", "nope");
const std::string responseWire = HTTP1::SerializeResponse(response, { .keepAlive = false });
Check(responseWire.starts_with("HTTP/1.1 404 Not Found\r\n"), "status line carries a reason phrase");
Check(responseWire.find("content-length: 4\r\n") != std::string::npos, "content-length is derived");
Check(responseWire.find("connection: close\r\n") != std::string::npos, "close is announced");
Check(responseWire.ends_with("\r\n\r\nnope"), "body follows the header section");
// A 204 must not advertise a body at all.
const std::string empty = HTTP1::SerializeResponse(CreateResponseHTTP("204"));
Check(empty.find("content-length") == std::string::npos, "204 carries no content-length");
// Header injection through a value must not be possible.
HTTPResponse injected = CreateResponseHTTP("200", "x");
injected.headers["x-evil"] = "a\r\nSet-Cookie: pwned=1";
bool threw = false;
try {
(void)HTTP1::SerializeResponse(injected);
} catch (const HTTP1::HTTP1ProtocolError&) {
threw = true;
}
Check(threw, "CRLF in a header value is refused");
}
// A request we serialise must be a request we parse back identically —
// the two halves of the codec are used against each other by every
// client/server pair in this library.
void SerializeThenParse() {
HTTPRequest original = CreateRequestHTTP("POST", "/echo", "host.test:81",
{{"content-type", "application/json"}},
std::string("{\"a\":1}"));
const std::string wire = HTTP1::SerializeRequest(original);
HTTP1::MessageParser parser(HTTP1::MessageKind::Request);
parser.Feed(wire.data(), wire.size());
Check(parser.Complete(), "serialised request parses back");
HTTPRequest parsed = parser.TakeRequest();
CheckEqual(parsed.method, original.method, "round-trip method");
CheckEqual(parsed.path, original.path, "round-trip path");
CheckEqual(parsed.authority, original.authority, "round-trip authority");
CheckEqual(parsed.body, original.body, "round-trip body");
CheckEqual(parsed.headers.at("content-type"), original.headers.at("content-type"),
"round-trip header");
}
}
int main() {
RequestRoundTrip();
HeaderNamesAreCaseInsensitive();
ChunkedBodyWithTrailers();
PipelinedRequests();
ExpectContinue();
ConnectionCloseAndHTTP10();
ResponseFramings();
AbsoluteFormTarget();
RejectsMalformedMessages();
RespectsLimits();
Serialisation();
SerializeThenParse();
if (failures != 0) {
std::println("{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,115 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Canonical HTTP/1.1 client/server round-trip: the same shape as
// ShouldSendRecieveHTTP, over TCP instead of QUIC.
import Crafter.Network;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool condition, std::string_view what) {
if (!condition) {
std::println("FAIL: {}", what);
++failures;
}
}
}
int main() {
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
routes["/"] = [](const HTTPRequest&) {
return CreateResponseHTTP("200", "Hello World!");
};
routes["/echo"] = [](const HTTPRequest& request) {
return CreateResponseHTTP("200", {{"content-type", "text/plain"}}, request.body);
};
routes["/query"] = [](const HTTPRequest& request) {
return CreateResponseHTTP("200", request.path);
};
routes["/boom"] = [](const HTTPRequest&) -> HTTPResponse {
throw std::runtime_error("handler exploded");
};
try {
ListenerAsyncHTTP1 listener(8090, std::move(routes));
ClientHTTP1 client("localhost", 8090);
HTTPResponse hello = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
Check(hello.status == "200", "GET / status");
Check(hello.body == "Hello World!", "GET / body");
Check(hello.headers.contains("date"), "the server stamps a Date header");
Check(hello.headers.at("content-length") == "12", "content-length matches the body");
HTTPResponse echoed = client.Send(CreateRequestHTTP("POST", "/echo", "localhost",
std::string("ping pong")));
Check(echoed.status == "200", "POST /echo status");
Check(echoed.body == "ping pong", "POST /echo returns the request body");
Check(echoed.headers.at("content-type") == "text/plain", "handler headers survive");
// Query strings must reach the handler intact while still routing on
// the bare path — browsers append them to everything.
HTTPResponse query = client.Send(CreateRequestHTTP("GET", "/query?a=1&b=2", "localhost"));
Check(query.status == "200", "query-string request routes to the bare path");
Check(query.body == "/query?a=1&b=2", "the handler sees the full target");
HTTPResponse missing = client.Send(CreateRequestHTTP("GET", "/nope", "localhost"));
Check(missing.status == "404", "unknown route is a 404");
// A HEAD gets the headers a GET would produce, and no body.
HTTPResponse head = client.Send(CreateRequestHTTP("HEAD", "/", "localhost"));
Check(head.status == "200", "HEAD status");
Check(head.body.empty(), "HEAD has no body");
Check(head.headers.at("content-length") == "12", "HEAD still advertises the length");
// A throwing handler must become a 500, not a dropped connection.
HTTPResponse boom = client.Send(CreateRequestHTTP("GET", "/boom", "localhost"));
Check(boom.status == "500", "a throwing handler yields 500");
Check(boom.body.find("handler exploded") != std::string::npos, "500 carries the reason");
// Every exchange above shared one connection.
Check(listener.listener.AcceptedCount() == 1, "the whole test used a single connection");
// Pipelining: two requests written in one go, before either is
// answered. The responses must come back in order, on that same
// connection. ClientHTTP1 never does this — it waits for each
// response — so drive it from a raw socket.
{
ClientTCP socket("localhost", 8090);
const std::string pipelined =
"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"
"GET /nope HTTP/1.1\r\nHost: localhost\r\n\r\n";
socket.Send(pipelined.data(), static_cast<std::uint32_t>(pipelined.size()));
HTTP1::MessageParser parser(HTTP1::MessageKind::Response);
std::string first;
std::string second;
while (second.empty()) {
std::vector<char> chunk = socket.RecieveSync();
parser.Feed(chunk.data(), chunk.size());
while (parser.Complete()) {
HTTPResponse response = parser.TakeResponse();
(first.empty() ? first : second) = response.status;
parser.Reset();
if (!second.empty()) break;
}
}
Check(first == "200", "first pipelined response");
Check(second == "404", "second pipelined response, in order");
}
listener.Stop();
} catch (const std::exception& error) {
std::println("threw: {}", error.what());
return 1;
}
if (failures != 0) {
std::println("{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,89 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// HTTP/1.1 connection reuse. Unlike HTTP/3 — where multiplexing is the
// transport's job — keep-alive here means the same socket carries request
// after request, and both ends have to agree on where each message ends.
// This test pins that down: repeated requests must not open new
// connections, `Connection: close` must end the connection, and the client
// must recover when the server closes a pooled connection under it.
import Crafter.Network;
import Crafter.Thread;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool condition, std::string_view what) {
if (!condition) {
std::println("FAIL: {}", what);
++failures;
}
}
}
int main() {
ThreadPool::Start();
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
routes["/"] = [](const HTTPRequest&) {
return CreateResponseHTTP("200", "Hello World!");
};
routes["/once"] = [](const HTTPRequest&) {
// A handler that asks for the connection to end after this reply.
return CreateResponseHTTP("200", {{"connection", "close"}}, "bye");
};
try {
ListenerAsyncHTTP1 listener(8091, std::move(routes));
ClientHTTP1 client("localhost", 8091);
for (int i = 0; i < 5; ++i) {
HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
Check(response.status == "200", "keep-alive request status");
Check(response.body == "Hello World!", "keep-alive request body");
Check(client.Connected(), "the client keeps the socket between requests");
}
Check(listener.listener.AcceptedCount() == 1, "five requests shared one connection");
// A handler that answers with `connection: close` ends the
// connection after its response; the client must notice and dial
// again for the next request instead of writing into a dead socket.
HTTPResponse closing = client.Send(CreateRequestHTTP("GET", "/once", "localhost"));
Check(closing.status == "200", "close-flagged response still arrives");
Check(closing.body == "bye", "close-flagged response body");
Check(!client.Connected(), "the client drops a connection the server closed");
HTTPResponse after = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
Check(after.body == "Hello World!", "the next request redials transparently");
Check(listener.listener.AcceptedCount() == 2, "exactly one extra connection was opened");
// Force the stale-connection race that HTTP/1.1 keep-alive cannot
// avoid: the server goes away while a pooled connection looks
// usable. The client is expected to replay the request on a fresh
// connection rather than surface the error.
Check(client.Connected(), "a connection is pooled before the restart");
listener.Stop();
ListenerAsyncHTTP1 restarted(8091, {{"/", [](const HTTPRequest&) {
return CreateResponseHTTP("200", "Hello Again!");
}}});
HTTPResponse recovered = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
Check(recovered.body == "Hello Again!", "the client recovers from a stale pooled connection");
Check(restarted.listener.AcceptedCount() == 1, "recovery dialled the new listener");
restarted.Stop();
} catch (const std::exception& error) {
std::println("threw: {}", error.what());
ThreadPool::Stop();
return 1;
}
ThreadPool::Stop();
if (failures != 0) {
std::println("{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,74 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// A body far larger than any socket buffer, in both directions. This is
// where short writes, partial reads and off-by-one framing show up: the
// request has to survive being split across dozens of send() calls and the
// response across dozens of recv() calls.
import Crafter.Network;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool condition, std::string_view what) {
if (!condition) {
std::println("FAIL: {}", what);
++failures;
}
}
// Non-uniform payload, so a mangled offset can't accidentally compare
// equal the way a run of identical bytes would.
std::string MakePayload(std::size_t size) {
std::string payload(size, '\0');
for (std::size_t i = 0; i < size; ++i) {
payload[i] = static_cast<char>('A' + (i * 7 + i / 251) % 26);
}
return payload;
}
}
int main() {
constexpr std::size_t payloadSize = 10 * 1024 * 1024;
const std::string payload = MakePayload(payloadSize);
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
routes["/echo"] = [](const HTTPRequest& request) {
return CreateResponseHTTP("200", request.body);
};
routes["/size"] = [](const HTTPRequest& request) {
return CreateResponseHTTP("200", std::to_string(request.body.size()));
};
try {
ListenerAsyncHTTP1 listener(8092, std::move(routes));
ClientHTTP1 client("localhost", 8092);
HTTPResponse size = client.Send(CreateRequestHTTP("POST", "/size", "localhost", payload));
Check(size.status == "200", "large POST status");
Check(size.body == std::to_string(payloadSize), "the server received every byte");
// Same again in both directions, and on the same connection, so a
// leftover byte from the previous exchange would desynchronise the
// framing and show up here.
HTTPResponse echoed = client.Send(CreateRequestHTTP("POST", "/echo", "localhost", payload));
Check(echoed.status == "200", "large echo status");
Check(echoed.body.size() == payloadSize, "the response body is the right length");
Check(echoed.body == payload, "the response body is byte-for-byte the request");
Check(listener.listener.AcceptedCount() == 1, "both transfers shared one connection");
listener.Stop();
} catch (const std::exception& error) {
std::println("threw: {}", error.what());
return 1;
}
if (failures != 0) {
std::println("{} check(s) failed", failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,127 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// A server is only as good as its worst peer. This drives ListenerHTTP1
// with many concurrent clients and then with peers that behave badly —
// vanishing mid-request, sending garbage, opening and dropping connections,
// stalling forever — and requires that it keeps serving correctly
// throughout and still shuts down promptly at the end.
import Crafter.Network;
import std;
using namespace Crafter;
namespace {
std::atomic<int> failures{0};
void Check(bool condition, std::string_view what) {
if (!condition) {
std::println("FAIL: {}", what);
failures.fetch_add(1);
}
}
constexpr std::uint16_t port = 8095;
// Raw socket, no HTTP client in the way, so the test can send things a
// well-behaved client never would.
void SendRaw(std::string_view bytes, bool waitForReply) {
ClientTCP socket("localhost", port);
if (!bytes.empty()) socket.Send(bytes.data(), static_cast<std::uint32_t>(bytes.size()));
if (waitForReply) {
try {
(void)socket.RecieveSync();
} catch (const std::exception&) {
// A dropped connection is an acceptable answer to nonsense.
}
}
}
}
int main() {
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
routes["/work"] = [](const HTTPRequest& request) {
return CreateResponseHTTP("200", request.body.empty() ? std::string("empty") : request.body);
};
try {
ListenerAsyncHTTP1 listener(port, std::move(routes));
// Keep the stalled-peer case quick; the defaults are measured in
// tens of seconds, which is right for a real server and wrong for a
// test.
listener.listener.requestTimeout = std::chrono::milliseconds(300);
listener.listener.keepAliveTimeout = std::chrono::milliseconds(500);
// ── Many clients at once, each reusing its own connection ──────
constexpr int clientCount = 24;
constexpr int requestsPerClient = 8;
std::atomic<int> completed{0};
std::vector<std::thread> clients;
for (int i = 0; i < clientCount; ++i) {
clients.emplace_back([i, &completed] {
try {
ClientHTTP1 client("localhost", port);
for (int request = 0; request < requestsPerClient; ++request) {
const std::string payload = std::format("client-{}-request-{}", i, request);
HTTPResponse response = client.Send(
CreateRequestHTTP("POST", "/work", "localhost", payload));
Check(response.status == "200", "concurrent request status");
Check(response.body == payload, "concurrent request body matches its sender");
completed.fetch_add(1);
}
} catch (const std::exception& error) {
std::println("client threw: {}", error.what());
failures.fetch_add(1);
}
});
}
for (auto& client : clients) client.join();
Check(completed.load() == clientCount * requestsPerClient, "every concurrent request finished");
Check(listener.listener.AcceptedCount() == clientCount,
"each client used exactly one connection");
// ── Peers that misbehave ──────────────────────────────────────
SendRaw("", false); // connect, say nothing, leave
SendRaw("GET /work HTTP/1.1\r\nHost: x\r\n", false); // headers cut short
SendRaw("POST /work HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\nshort", false);
SendRaw("not http at all\r\n\r\n", true); // garbage start line
SendRaw("GET / HTTP/1.1\r\nHost : x\r\n\r\n", true); // smuggling-shaped header
SendRaw(std::string("GET /") + std::string(16 * 1024, 'z') + " HTTP/1.1\r\n\r\n", true);
// A peer that opens a request and then just sits there must be cut
// loose by the request timeout rather than holding its thread.
{
ClientTCP stalled("localhost", port);
const std::string partial = "POST /work HTTP/1.1\r\nHost: x\r\nContent-Length: 10\r\n\r\nab";
stalled.Send(partial.data(), static_cast<std::uint32_t>(partial.size()));
const auto start = std::chrono::steady_clock::now();
try {
(void)stalled.RecieveUntilCloseSync();
} catch (const std::exception&) {}
const auto elapsed = std::chrono::steady_clock::now() - start;
Check(elapsed < std::chrono::seconds(5), "a stalled peer is timed out, not waited on");
}
// ── Still healthy afterwards ──────────────────────────────────
ClientHTTP1 client("localhost", port);
HTTPResponse response = client.Send(CreateRequestHTTP("POST", "/work", "localhost",
std::string("still here")));
Check(response.status == "200", "the server survived the abuse");
Check(response.body == "still here", "and still answers correctly");
// Shutdown has to finish quickly even with connections open.
const auto start = std::chrono::steady_clock::now();
listener.Stop();
const auto elapsed = std::chrono::steady_clock::now() - start;
Check(elapsed < std::chrono::seconds(5), "Stop() returns promptly with a connection still open");
} catch (const std::exception& error) {
std::println("threw: {}", error.what());
return 1;
}
if (failures.load() != 0) {
std::println("{} check(s) failed", failures.load());
return 1;
}
return 0;
}