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,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;
std::chrono::milliseconds timeout{30000};
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, 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, 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

@ -0,0 +1,301 @@
//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;
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) {
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.
}
pointer->finished.store(true);
});
connections.push_back(std::move(connection));
}
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::lock_guard lock(impl->mutex);
for (auto& connection : impl->connections) {
// Wake the serving thread out of poll()/recv() without closing
// the descriptor underneath it.
if (connection->client) shutdown(connection->client->socketid, SHUT_RDWR);
}
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();
}