From b758419007c2a81e712df062f6653c3e6e19a5c2 Mon Sep 17 00:00:00 2001 From: catbot Date: Mon, 27 Jul 2026 00:44:56 +0000 Subject: [PATCH 1/4] fix(tcp): resolve, connect, bind and send failures were silent or wrong The HTTP/1.1 stack sits directly on these two classes and each of these bit it: - gethostbyname() returning null on an unresolvable host was dereferenced straight into a crash, and it is not thread safe; use getaddrinfo. - A failed socket()/connect() only printed to stderr and handed back an unusable ClientTCP, so the real error surfaced much later as an unrelated errno from send(). - send() was assumed to accept everything it was offered. It does not once a buffer outgrows the socket's send buffer, which silently truncated multi-megabyte bodies. Loop, and pass MSG_NOSIGNAL so a vanished peer raises EPIPE instead of killing the process. - ClientTCP's move constructor closed the socket it had just taken ownership of, and both it and the destructor tested `socketid != 1` where they meant `!= -1`. - ListenerTCP ignored bind()'s result, leaving a listener that accepted nothing with no explanation, and did not set SO_REUSEADDR, so a restart hit EADDRINUSE for the length of TIME_WAIT. accept() failing during Stop() is expected and no longer logged. Co-Authored-By: Claude Opus 4.8 --- implementations/Crafter.Network-ClientTCP.cpp | 60 +++-- .../Crafter.Network-ListenerTCP.cpp | 247 ++++++++++-------- 2 files changed, 176 insertions(+), 131 deletions(-) diff --git a/implementations/Crafter.Network-ClientTCP.cpp b/implementations/Crafter.Network-ClientTCP.cpp index dc2d8a9..31270e0 100755 --- a/implementations/Crafter.Network-ClientTCP.cpp +++ b/implementations/Crafter.Network-ClientTCP.cpp @@ -31,11 +31,22 @@ ClientTCP::ClientTCP(int socketid) : socketid(socketid) ClientTCP::ClientTCP(const char* hostName, std::uint16_t port) { - host = gethostbyname(hostName); - serv_addr.sin_family = AF_INET; - serv_addr.sin_port = htons(port); - serv_addr.sin_addr = *((struct in_addr *)host->h_addr); - bzero(&(serv_addr.sin_zero),8); + // getaddrinfo rather than gethostbyname: the latter is not thread safe, + // and it signals failure by returning null — which was then dereferenced + // straight into a crash on any unresolvable host. + addrinfo hints{}; + 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(resolved->ai_addr); + freeaddrinfo(resolved); 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) { - if(socketid != 1) { - shutdown(socketid, SHUT_RDWR); - close(socketid); - } other.socketid = -1; } ClientTCP::~ClientTCP() { - if(socketid != 1) { + if(socketid != -1) { shutdown(socketid, SHUT_RDWR); close(socketid); } @@ -63,11 +73,17 @@ ClientTCP::~ClientTCP() void ClientTCP::Connect() { 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){ - 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 { - int status = send(socketid, reinterpret_cast(buffer), size, 0); - - if (status == 0) { - throw SocketClosedException(); - } else if (status < 0) { - throw std::runtime_error(std::strerror(errno)); + // send() is free to accept less than it was offered, and does so + // routinely once a buffer outgrows the socket's send buffer — a + // multi-megabyte HTTP body, say. Loop until it is all handed over. + const char* data = reinterpret_cast(buffer); + std::uint32_t sent = 0; + 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(status); } } std::vector ClientTCP::RecieveSync(std::uint32_t bufferSize) const { diff --git a/implementations/Crafter.Network-ListenerTCP.cpp b/implementations/Crafter.Network-ListenerTCP.cpp index 584aafc..57e956f 100755 --- a/implementations/Crafter.Network-ListenerTCP.cpp +++ b/implementations/Crafter.Network-ListenerTCP.cpp @@ -1,114 +1,135 @@ -//SPDX-License-Identifier: LGPL-3.0-only -//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® - -module; - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -module Crafter.Network:ListenerTCP_impl; -import :ListenerTCP; -import std; -import Crafter.Thread; - -using namespace Crafter; - -ListenerTCP::ListenerTCP(std::uint16_t port, std::function connectCallback, std::uint32_t concurrentClientLimit, std::uint32_t totalClientLimit) : connectCallback(connectCallback), concurrentClientLimit(concurrentClientLimit), totalClientLimit(totalClientLimit) { - sockaddr_in servAddr; - bzero((char*)&servAddr, sizeof(servAddr)); - servAddr.sin_family = AF_INET; - servAddr.sin_addr.s_addr = htonl(INADDR_ANY); - servAddr.sin_port = htons(port); - - s = socket(AF_INET, SOCK_STREAM, 0); - if(s < 0) - { - throw std::runtime_error("Error establishing the server socket"); - } - int bindStatus = bind(s, (struct sockaddr*) &servAddr, sizeof(servAddr)); - listen(s, 5); -} - -void ListenerTCP::Stop() { - running = false; - shutdown(s, SHUT_RDWR); - close(s); - s = -1; -} - -void ListenerTCP::ListenSyncSync() { - while (running && totalClientCounter < totalClientLimit) { - sockaddr_in newSockAddr; - socklen_t newSockAddrSize = sizeof(newSockAddr); - int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize); - if (client > 0) { - connectCallback(new ClientTCP(client)); - this->totalClientCounter++; - } - else { - std::cerr << "Error accepting request from client!" << std::endl; - } - } -} - -void ListenerTCP::ListenSyncAsync() { - 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 { - std::cerr << "Error accepting request from client!" << std::endl; - } - } -} - -void ListenerTCP::ListenAsyncSync() { - 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) { - connectCallback(new ClientTCP(client)); - this->totalClientCounter++; - } - else { - 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); - int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize); - if (client > 0) { - ThreadPool::Enqueue([this, client]() {connectCallback(new ClientTCP(client)); }); - this->totalClientCounter++; - } - else { - std::cerr << "Error accepting request from client!" << std::endl; - } - } - }); -} - -ListenerTCP::~ListenerTCP() { - if(s != -1) { - close(s); - } +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module; + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +module Crafter.Network:ListenerTCP_impl; +import :ListenerTCP; +import std; +import Crafter.Thread; + +using namespace Crafter; + +ListenerTCP::ListenerTCP(std::uint16_t port, std::function connectCallback, std::uint32_t concurrentClientLimit, std::uint32_t totalClientLimit) : connectCallback(connectCallback), concurrentClientLimit(concurrentClientLimit), totalClientLimit(totalClientLimit) { + sockaddr_in servAddr; + bzero((char*)&servAddr, sizeof(servAddr)); + servAddr.sin_family = AF_INET; + servAddr.sin_addr.s_addr = htonl(INADDR_ANY); + servAddr.sin_port = htons(port); + + s = socket(AF_INET, SOCK_STREAM, 0); + if(s < 0) + { + throw std::runtime_error("Error establishing the server socket"); + } + // 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. + int reuse = 1; + setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + + if(bind(s, (struct sockaddr*) &servAddr, sizeof(servAddr)) < 0) { + // Ignoring this left the listener silently accepting nothing at + // all, with no hint as to why. + const std::string reason = std::strerror(errno); + close(s); + s = -1; + throw std::runtime_error("Could not bind port " + std::to_string(port) + ": " + reason); + } + if(listen(s, 128) < 0) { + const std::string reason = std::strerror(errno); + close(s); + s = -1; + throw std::runtime_error("Could not listen on port " + std::to_string(port) + ": " + reason); + } +} + +void ListenerTCP::Stop() { + running = false; + shutdown(s, SHUT_RDWR); + close(s); + s = -1; +} + +void ListenerTCP::ListenSyncSync() { + while (running && totalClientCounter < totalClientLimit) { + sockaddr_in newSockAddr; + socklen_t newSockAddrSize = sizeof(newSockAddr); + int client = accept(s, (sockaddr*)&newSockAddr, &newSockAddrSize); + if (client > 0) { + connectCallback(new ClientTCP(client)); + this->totalClientCounter++; + } + else if (running) { + // accept() also fails once on the way out, when Stop() closes + // the listening socket — that one is not worth reporting. + std::cerr << "Error accepting request from client!" << std::endl; + } + } +} + +void ListenerTCP::ListenSyncAsync() { + 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; + } + } +} + +void ListenerTCP::ListenAsyncSync() { + 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) { + 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); + 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); + } } \ No newline at end of file -- 2.47.3 From 337ce32eca072ccdd0975afb090f3e1329b73379 Mon Sep 17 00:00:00 2001 From: catbot Date: Mon, 27 Jul 2026 00:45:09 +0000 Subject: [PATCH 2/4] feat(http1): add an HTTP/1.1 client and listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Crafter.Network-ClientHTTP1.cpp | 155 ++++ .../Crafter.Network-ListenerHTTP1.cpp | 301 +++++++ interfaces/Crafter.Network-ClientHTTP1.cppm | 69 ++ interfaces/Crafter.Network-HTTP1.cppm | 777 ++++++++++++++++++ interfaces/Crafter.Network-ListenerHTTP1.cppm | 76 ++ interfaces/Crafter.Network.cppm | 6 + project.cpp | 18 +- tests/ShouldInteropCurlHTTP1/main.cpp | 233 ++++++ tests/ShouldParseHTTP1/main.cpp | 294 +++++++ tests/ShouldSendRecieveHTTP1/main.cpp | 87 ++ .../ShouldSendRecieveKeepaliveHTTP1/main.cpp | 89 ++ tests/ShouldSendRecieveLargeHTTP1/main.cpp | 74 ++ 12 files changed, 2175 insertions(+), 4 deletions(-) create mode 100644 implementations/Crafter.Network-ClientHTTP1.cpp create mode 100644 implementations/Crafter.Network-ListenerHTTP1.cpp create mode 100644 interfaces/Crafter.Network-ClientHTTP1.cppm create mode 100644 interfaces/Crafter.Network-HTTP1.cppm create mode 100644 interfaces/Crafter.Network-ListenerHTTP1.cppm create mode 100644 tests/ShouldInteropCurlHTTP1/main.cpp create mode 100644 tests/ShouldParseHTTP1/main.cpp create mode 100644 tests/ShouldSendRecieveHTTP1/main.cpp create mode 100644 tests/ShouldSendRecieveKeepaliveHTTP1/main.cpp create mode 100644 tests/ShouldSendRecieveLargeHTTP1/main.cpp diff --git a/implementations/Crafter.Network-ClientHTTP1.cpp b/implementations/Crafter.Network-ClientHTTP1.cpp new file mode 100644 index 0000000..13ccb5a --- /dev/null +++ b/implementations/Crafter.Network-ClientHTTP1.cpp @@ -0,0 +1,155 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module; +#include +#include +#include + +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(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(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 tcp; + std::chrono::milliseconds timeout{30000}; + + void Connect() { + tcp = std::make_unique(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(wire.size())); + + HTTP1::MessageParser parser(HTTP1::MessageKind::Response, limits); + parser.SetRequestMethod(method); + std::vector 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(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 onSuccess, + std::function 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"); + } + }); +} diff --git a/implementations/Crafter.Network-ListenerHTTP1.cpp b/implementations/Crafter.Network-ListenerHTTP1.cpp new file mode 100644 index 0000000..4808fa9 --- /dev/null +++ b/implementations/Crafter.Network-ListenerHTTP1.cpp @@ -0,0 +1,301 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module; +#include +#include +#include + +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(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(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 client; + std::thread thread; + std::atomic finished{false}; +}; + +struct ListenerHTTP1::Impl { + ListenerHTTP1* owner = nullptr; + std::unique_ptr listener; + std::mutex mutex; + std::vector> connections; + std::atomic running{true}; + std::atomic 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& 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(); + 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(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 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> routes) + : routes(std::move(routes)) + , impl(std::make_unique()) +{ + impl->owner = this; + Impl* state = impl.get(); + impl->listener = std::make_unique(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> 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> routes) + : listener(port, std::move(routes)) + , thread(&ListenerHTTP1::Listen, &listener) +{} + +ListenerAsyncHTTP1::~ListenerAsyncHTTP1() { + Stop(); +} + +void ListenerAsyncHTTP1::Stop() { + listener.Stop(); + if (thread.joinable()) thread.join(); +} diff --git a/interfaces/Crafter.Network-ClientHTTP1.cppm b/interfaces/Crafter.Network-ClientHTTP1.cppm new file mode 100644 index 0000000..bb5a446 --- /dev/null +++ b/interfaces/Crafter.Network-ClientHTTP1.cppm @@ -0,0 +1,69 @@ +//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 onSuccess, + std::function 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; + + private: + struct Impl; + std::unique_ptr impl; + }; +} +#endif diff --git a/interfaces/Crafter.Network-HTTP1.cppm b/interfaces/Crafter.Network-HTTP1.cppm new file mode 100644 index 0000000..db84c02 --- /dev/null +++ b/interfaces/Crafter.Network-HTTP1.cppm @@ -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(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(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(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 SplitCommaList(std::string_view value) { + std::vector 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 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(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& 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 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 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 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::min(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::min(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 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; + }; +} diff --git a/interfaces/Crafter.Network-ListenerHTTP1.cppm b/interfaces/Crafter.Network-ListenerHTTP1.cppm new file mode 100644 index 0000000..72f4cbd --- /dev/null +++ b/interfaces/Crafter.Network-ListenerHTTP1.cppm @@ -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> 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> 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; + }; + + // 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> routes); + ~ListenerAsyncHTTP1(); + void Stop(); + }; +} +#endif diff --git a/interfaces/Crafter.Network.cppm b/interfaces/Crafter.Network.cppm index efbe32d..3ea16b9 100755 --- a/interfaces/Crafter.Network.cppm +++ b/interfaces/Crafter.Network.cppm @@ -17,4 +17,10 @@ export import :WebTransport; // not need the HTTP/3 frame helpers directly. Excluded from the browser // build — HTTP3 uses throw and the wasm target runs with -fno-exceptions. 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 \ No newline at end of file diff --git a/project.cpp b/project.cpp index 39a92a9..3b0160e 100644 --- a/project.cpp +++ b/project.cpp @@ -7,13 +7,16 @@ namespace fs = std::filesystem; using namespace Crafter; extern "C" Configuration CrafterBuildProject(std::span args) { - constexpr std::array networkInterfaces = { + constexpr std::array networkInterfaces = { "interfaces/Crafter.Network", "interfaces/Crafter.Network-ClientTCP", "interfaces/Crafter.Network-ListenerTCP", "interfaces/Crafter.Network-ClientHTTP", "interfaces/Crafter.Network-ListenerHTTP", "interfaces/Crafter.Network-HTTP", + "interfaces/Crafter.Network-HTTP1", + "interfaces/Crafter.Network-ClientHTTP1", + "interfaces/Crafter.Network-ListenerHTTP1", "interfaces/Crafter.Network-HTTP3", "interfaces/Crafter.Network-ClientQUIC", "interfaces/Crafter.Network-ListenerQUIC", @@ -69,11 +72,13 @@ extern "C" Configuration CrafterBuildProject(std::span a return cfg; } - constexpr std::array networkImplementations = { + constexpr std::array networkImplementations = { "implementations/Crafter.Network-ClientTCP", "implementations/Crafter.Network-ListenerTCP", "implementations/Crafter.Network-ClientHTTP", "implementations/Crafter.Network-ListenerHTTP", + "implementations/Crafter.Network-ClientHTTP1", + "implementations/Crafter.Network-ListenerHTTP1", "implementations/Crafter.Network-ClientQUIC", "implementations/Crafter.Network-ListenerQUIC", "implementations/Crafter.Network-WebTransport", @@ -103,9 +108,9 @@ extern "C" Configuration CrafterBuildProject(std::span a // linker at the actual output location. msquic.libDirs = { "bin/Release" }; msquic.libs = { "msquic" }; - std::array ifaces; + std::array ifaces; std::ranges::copy(networkInterfaces, ifaces.begin()); - std::array impls; + std::array impls; std::ranges::copy(networkImplementations, impls.begin()); cfg.GetInterfacesAndImplementations(ifaces, impls); @@ -114,8 +119,13 @@ extern "C" Configuration CrafterBuildProject(std::span a // crafter-network static lib via .Dependencies({ &cfg }). if (cfg.target == "x86_64-pc-linux-gnu") { cfg.AddTest("ShouldEchoWebTransport").Dependencies({ &cfg }); + cfg.AddTest("ShouldInteropCurlHTTP1").Dependencies({ &cfg }); cfg.AddTest("ShouldNotDropEarlyStreams").Dependencies({ &cfg }); + cfg.AddTest("ShouldParseHTTP1").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("ShouldSendRecieveKeepaliveHTTP").Dependencies({ &cfg }); cfg.AddTest("ShouldSendRecieveLargeHTTP").Dependencies({ &cfg }); diff --git a/tests/ShouldInteropCurlHTTP1/main.cpp b/tests/ShouldInteropCurlHTTP1/main.cpp new file mode 100644 index 0000000..37e700c --- /dev/null +++ b/tests/ShouldInteropCurlHTTP1/main.cpp @@ -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 +#include +#include +#include + +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 argv) { + std::vector 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> 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; +} diff --git a/tests/ShouldParseHTTP1/main.cpp b/tests/ShouldParseHTTP1/main.cpp new file mode 100644 index 0000000..b7eb700 --- /dev/null +++ b/tests/ShouldParseHTTP1/main.cpp @@ -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 + 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(request.method, "POST", "method"); + CheckEqual(request.path, "/submit?id=7", "target keeps its query string"); + CheckEqual(request.authority, "example.test:8080", "host becomes authority"); + CheckEqual(request.body, "hello", "body"); + Check(!request.headers.contains("host"), "host is not duplicated into the header map"); + CheckEqual(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(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(request.body, "hello world", "chunks are reassembled"); + CheckEqual(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(parser.TakeRequest().path, "/one", "first target"); + parser.Reset(); + Check(parser.Complete(), "second request was already buffered"); + CheckEqual(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: \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(response.status, "200", "1xx is skipped"); + CheckEqual(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(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(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(request.path, "/page", "absolute-form path"); + CheckEqual(request.authority, "proxy.test", "absolute-form authority wins over Host"); + CheckEqual(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; +} diff --git a/tests/ShouldSendRecieveHTTP1/main.cpp b/tests/ShouldSendRecieveHTTP1/main.cpp new file mode 100644 index 0000000..400d2fd --- /dev/null +++ b/tests/ShouldSendRecieveHTTP1/main.cpp @@ -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> 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; +} diff --git a/tests/ShouldSendRecieveKeepaliveHTTP1/main.cpp b/tests/ShouldSendRecieveKeepaliveHTTP1/main.cpp new file mode 100644 index 0000000..6164e5c --- /dev/null +++ b/tests/ShouldSendRecieveKeepaliveHTTP1/main.cpp @@ -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> 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; +} diff --git a/tests/ShouldSendRecieveLargeHTTP1/main.cpp b/tests/ShouldSendRecieveLargeHTTP1/main.cpp new file mode 100644 index 0000000..b2831b6 --- /dev/null +++ b/tests/ShouldSendRecieveLargeHTTP1/main.cpp @@ -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('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> 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; +} -- 2.47.3 From ea1310faec064f9b62f4037873349c9383931e55 Mon Sep 17 00:00:00 2001 From: catbot Date: Mon, 27 Jul 2026 00:56:53 +0000 Subject: [PATCH 3/4] fix(http1): close a finished connection instead of holding it until reap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A connection's socket was owned by the registry entry and only released when the next accept() reaped it, so a peer we had finished with — after a 408, a 400, or a `connection: close` — never saw EOF and sat waiting for a server that was done talking. On a server that goes quiet it also held every descriptor from the last burst indefinitely. The connection thread now closes its own socket the moment Serve() returns, under the registry lock so Stop()'s shutdown() can never name a descriptor that has already been released, and Stop() waits on a condition variable for the last thread rather than assuming the vector it moved out is quiescent. Adopt() is also fully guarded: it runs on ListenerTCP's accept loop, which has no handler, so anything escaping it would abort the process. Found by ShouldSurviveAbuseHTTP1, added here: 24 concurrent keep-alive clients, peers that vanish mid-request or send garbage, and a peer that stalls forever — the server must keep serving and still stop promptly. Co-Authored-By: Claude Opus 4.8 --- .../Crafter.Network-ListenerHTTP1.cpp | 35 ++++- project.cpp | 1 + tests/ShouldSurviveAbuseHTTP1/main.cpp | 127 ++++++++++++++++++ 3 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 tests/ShouldSurviveAbuseHTTP1/main.cpp diff --git a/implementations/Crafter.Network-ListenerHTTP1.cpp b/implementations/Crafter.Network-ListenerHTTP1.cpp index 4808fa9..d45e3fc 100644 --- a/implementations/Crafter.Network-ListenerHTTP1.cpp +++ b/implementations/Crafter.Network-ListenerHTTP1.cpp @@ -65,6 +65,9 @@ struct ListenerHTTP1::Impl { ListenerHTTP1* owner = nullptr; std::unique_ptr 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> connections; std::atomic running{true}; std::atomic accepted{0}; @@ -79,7 +82,10 @@ struct ListenerHTTP1::Impl { }); } - void Adopt(ClientTCP* accepted) { + 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(); connection->client.reset(accepted); HTTP1Connection* pointer = connection.get(); @@ -94,9 +100,24 @@ struct ListenerHTTP1::Impl { } catch (...) { // A connection dying must never take the server with it. } - pointer->finished.store(true); + { + 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) { @@ -261,12 +282,18 @@ void ListenerHTTP1::Stop() { std::vector> closing; { - std::lock_guard lock(impl->mutex); + 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 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& connection) { + return connection->finished.load(); + }); + }); closing = std::move(impl->connections); impl->connections.clear(); } diff --git a/project.cpp b/project.cpp index 3b0160e..cddc2cb 100644 --- a/project.cpp +++ b/project.cpp @@ -131,6 +131,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("ShouldSendRecieveLargeHTTP").Dependencies({ &cfg }); cfg.AddTest("ShouldSendRecieveQUICDatagram").Dependencies({ &cfg }); cfg.AddTest("ShouldSendRecieveQUICStream").Dependencies({ &cfg }); + cfg.AddTest("ShouldSurviveAbuseHTTP1").Dependencies({ &cfg }); } return cfg; diff --git a/tests/ShouldSurviveAbuseHTTP1/main.cpp b/tests/ShouldSurviveAbuseHTTP1/main.cpp new file mode 100644 index 0000000..ec90aad --- /dev/null +++ b/tests/ShouldSurviveAbuseHTTP1/main.cpp @@ -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 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(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> 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 completed{0}; + std::vector 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(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; +} -- 2.47.3 From a79ab6a02415b5a72f53845abeeb053569d47d54 Mon Sep 17 00:00:00 2001 From: catbot Date: Mon, 27 Jul 2026 01:02:37 +0000 Subject: [PATCH 4/4] docs(http1): document the HTTP/1.1 stack, and expose the client's timeout README: HTTP/1.1 in the intro, feature list, module list, browser-build exclusions, dependencies and test list, plus a Components section covering both classes, the standalone codec, what is and is not implemented, the smuggling-shaped inputs that are rejected, and an explicit note that this path is plaintext and belongs behind a TLS terminator. ClientHTTP1::timeout was hard-coded and invisible; make it a public member alongside `limits`, mirroring the listener's timeouts. Also pipelining coverage in ShouldSendRecieveHTTP1: two requests written before either is answered, driven from a raw socket since ClientHTTP1 waits for each response. Co-Authored-By: Claude Opus 4.8 --- README.md | 73 +++++++++++++++++-- .../Crafter.Network-ClientHTTP1.cpp | 6 +- interfaces/Crafter.Network-ClientHTTP1.cppm | 3 + tests/ShouldSendRecieveHTTP1/main.cpp | 28 +++++++ 4 files changed, 102 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3dd40a2..608966d 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ # 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 -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 - **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. - **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. - **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 pool–based 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: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: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: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. @@ -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. +### 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> 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 `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. - `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. -- `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`. 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 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 +- 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 @@ -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. - 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 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`. ## Usage Example diff --git a/implementations/Crafter.Network-ClientHTTP1.cpp b/implementations/Crafter.Network-ClientHTTP1.cpp index 13ccb5a..7ffbcf1 100644 --- a/implementations/Crafter.Network-ClientHTTP1.cpp +++ b/implementations/Crafter.Network-ClientHTTP1.cpp @@ -54,7 +54,6 @@ struct ClientHTTP1::Impl { std::string host; std::uint16_t port; std::unique_ptr tcp; - std::chrono::milliseconds timeout{30000}; void Connect() { tcp = std::make_unique(host, port); @@ -68,7 +67,8 @@ struct ClientHTTP1::Impl { // 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) { + const HTTP1::MessageLimits& limits, + std::chrono::milliseconds timeout, bool& received) { tcp->Send(wire.data(), static_cast(wire.size())); HTTP1::MessageParser parser(HTTP1::MessageKind::Response, limits); @@ -127,7 +127,7 @@ HTTPResponse ClientHTTP1::Send(const HTTPRequest& request) { bool received = false; try { - return impl->Exchange(wire, method, limits, received); + return impl->Exchange(wire, method, limits, timeout, received); } catch (const std::exception&) { impl->Close(); if (attempt == 0 && reused && !received) continue; diff --git a/interfaces/Crafter.Network-ClientHTTP1.cppm b/interfaces/Crafter.Network-ClientHTTP1.cppm index bb5a446..7244221 100644 --- a/interfaces/Crafter.Network-ClientHTTP1.cppm +++ b/interfaces/Crafter.Network-ClientHTTP1.cppm @@ -60,6 +60,9 @@ namespace Crafter { // 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; diff --git a/tests/ShouldSendRecieveHTTP1/main.cpp b/tests/ShouldSendRecieveHTTP1/main.cpp index 400d2fd..ac0b2db 100644 --- a/tests/ShouldSendRecieveHTTP1/main.cpp +++ b/tests/ShouldSendRecieveHTTP1/main.cpp @@ -73,6 +73,34 @@ int main() { // 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(pipelined.size())); + + HTTP1::MessageParser parser(HTTP1::MessageKind::Response); + std::string first; + std::string second; + while (second.empty()) { + std::vector 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()); -- 2.47.3