feat(http1): add an HTTP/1.1 client and listener

HTTP/3-only is not a deployable position yet: plenty of clients, proxies
and CI tooling still speak nothing but HTTP/1.1. This adds that path
using the request/response types the HTTP/3 stack already uses, so a
route handler or call site moves between the two protocols by changing
the class name.

- :HTTP1 — transport-free wire format. Serialisation with the framing
  headers owned by the serialiser, and an incremental parser that takes
  arbitrary socket chunks and yields one message at a time: keep-alive,
  pipelining, content-length and chunked bodies (with trailers),
  read-to-EOF responses, interim 1xx skipping, HEAD/204/304 framing and
  Expect: 100-continue. Ambiguous framing is rejected rather than
  guessed at (content-length with transfer-encoding, disagreeing
  content-lengths, whitespace before a colon), and CR/LF in a value we
  are asked to serialise is refused.
- ClientHTTP1 — persistent connection, redialling once when a pooled
  connection turns out to have been closed by the peer, which is the
  race HTTP/1.1 keep-alive cannot avoid. Nothing is replayed after a
  response byte has arrived.
- ListenerHTTP1 — one thread per connection (keep-alive connections are
  idle most of their life and would pin every ThreadPool thread),
  automatic Date, HEAD, 100-continue, handler-requested close, idle and
  request timeouts, and 400/404/500 responses. Routes fall back to the
  query-stripped path so `/thing?x=1` reaches the handler for `/thing`.

No TLS: this is `http://` only. Encrypted traffic still goes over
HTTP/3, or through a TLS-terminating proxy.

Tests: codec unit tests including the malformed inputs above, a
client/server round-trip, keep-alive and stale-connection recovery, a
10 MiB body both ways, and interop both directions against curl and
python3's http.server (skipped when those are not installed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
catbot 2026-07-27 00:45:09 +00:00
commit 337ce32eca
12 changed files with 2175 additions and 4 deletions

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,87 @@
//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");
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;
}