feat(http1): add an HTTP/1.1 client and listener
HTTP/3-only is not a deployable position yet: plenty of clients, proxies and CI tooling still speak nothing but HTTP/1.1. This adds that path using the request/response types the HTTP/3 stack already uses, so a route handler or call site moves between the two protocols by changing the class name. - :HTTP1 — transport-free wire format. Serialisation with the framing headers owned by the serialiser, and an incremental parser that takes arbitrary socket chunks and yields one message at a time: keep-alive, pipelining, content-length and chunked bodies (with trailers), read-to-EOF responses, interim 1xx skipping, HEAD/204/304 framing and Expect: 100-continue. Ambiguous framing is rejected rather than guessed at (content-length with transfer-encoding, disagreeing content-lengths, whitespace before a colon), and CR/LF in a value we are asked to serialise is refused. - ClientHTTP1 — persistent connection, redialling once when a pooled connection turns out to have been closed by the peer, which is the race HTTP/1.1 keep-alive cannot avoid. Nothing is replayed after a response byte has arrived. - ListenerHTTP1 — one thread per connection (keep-alive connections are idle most of their life and would pin every ThreadPool thread), automatic Date, HEAD, 100-continue, handler-requested close, idle and request timeouts, and 400/404/500 responses. Routes fall back to the query-stripped path so `/thing?x=1` reaches the handler for `/thing`. No TLS: this is `http://` only. Encrypted traffic still goes over HTTP/3, or through a TLS-terminating proxy. Tests: codec unit tests including the malformed inputs above, a client/server round-trip, keep-alive and stale-connection recovery, a 10 MiB body both ways, and interop both directions against curl and python3's http.server (skipped when those are not installed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b758419007
commit
337ce32eca
12 changed files with 2175 additions and 4 deletions
233
tests/ShouldInteropCurlHTTP1/main.cpp
Normal file
233
tests/ShouldInteropCurlHTTP1/main.cpp
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// Interop against implementations that are not this library — the whole
|
||||
// point of shipping HTTP/1.1 in the first place.
|
||||
//
|
||||
// * curl drives ListenerHTTP1: keep-alive reuse, chunked upload,
|
||||
// Expect: 100-continue, HEAD, and a plain GET.
|
||||
// * ClientHTTP1 drives python3's http.server, which answers HTTP/1.0 with
|
||||
// `Connection: close` — the legacy shape our own listener never emits.
|
||||
//
|
||||
// Both peers are optional: if curl or python3 is missing the corresponding
|
||||
// half is skipped rather than failed, so the suite still runs on a bare
|
||||
// machine.
|
||||
|
||||
#include <signal.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/wait.h>
|
||||
#include <stdio.h>
|
||||
|
||||
import Crafter.Network;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool condition, std::string_view what) {
|
||||
if (!condition) {
|
||||
std::println("FAIL: {}", what);
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
bool HaveCommand(std::string_view name) {
|
||||
const std::string probe = "command -v " + std::string(name) + " >/dev/null 2>&1";
|
||||
return std::system(probe.c_str()) == 0;
|
||||
}
|
||||
|
||||
// Run a command and return its stdout. stderr is folded in so a curl
|
||||
// failure explains itself in the test output.
|
||||
std::string Run(const std::string& command) {
|
||||
std::string output;
|
||||
FILE* pipe = popen((command + " 2>&1").c_str(), "r");
|
||||
if (pipe == nullptr) return output;
|
||||
char buffer[4096];
|
||||
while (std::size_t read = std::fread(buffer, 1, sizeof(buffer), pipe)) {
|
||||
output.append(buffer, read);
|
||||
}
|
||||
pclose(pipe);
|
||||
return output;
|
||||
}
|
||||
|
||||
// A child process, killed when this goes out of scope.
|
||||
class Child {
|
||||
public:
|
||||
explicit Child(std::vector<std::string> argv) {
|
||||
std::vector<char*> raw;
|
||||
for (auto& argument : argv) raw.push_back(argument.data());
|
||||
raw.push_back(nullptr);
|
||||
pid = fork();
|
||||
if (pid == 0) {
|
||||
// Keep the test output clean; the child's chatter is not
|
||||
// interesting unless it fails to start, which shows up as a
|
||||
// connection failure instead.
|
||||
freopen("/dev/null", "w", stdout);
|
||||
freopen("/dev/null", "w", stderr);
|
||||
execvp(raw[0], raw.data());
|
||||
_exit(127);
|
||||
}
|
||||
}
|
||||
~Child() {
|
||||
if (pid > 0) {
|
||||
kill(pid, SIGTERM);
|
||||
int status = 0;
|
||||
waitpid(pid, &status, 0);
|
||||
}
|
||||
}
|
||||
Child(const Child&) = delete;
|
||||
bool Started() const { return pid > 0; }
|
||||
|
||||
private:
|
||||
pid_t pid = -1;
|
||||
};
|
||||
|
||||
// Poll until something accepts on the port, so the test doesn't race a
|
||||
// slow-starting server.
|
||||
bool WaitForPort(std::uint16_t port, std::chrono::milliseconds budget) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + budget;
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
try {
|
||||
ClientTCP probe("localhost", port);
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(25));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void CurlAgainstListener() {
|
||||
if (!HaveCommand("curl")) {
|
||||
std::println("skipping the curl half: curl is not installed");
|
||||
return;
|
||||
}
|
||||
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
|
||||
routes["/hello"] = [](const HTTPRequest&) {
|
||||
return CreateResponseHTTP("200", {{"content-type", "text/plain"}}, "Hello curl!");
|
||||
};
|
||||
routes["/echo"] = [](const HTTPRequest& request) {
|
||||
return CreateResponseHTTP("200", request.method + ":" + request.body);
|
||||
};
|
||||
routes["/agent"] = [](const HTTPRequest& request) {
|
||||
auto agent = request.headers.find("user-agent");
|
||||
return CreateResponseHTTP("200", agent == request.headers.end() ? "none" : agent->second);
|
||||
};
|
||||
|
||||
ListenerAsyncHTTP1 listener(8093, std::move(routes));
|
||||
Check(WaitForPort(8093, std::chrono::seconds(2)), "the HTTP/1.1 listener came up");
|
||||
|
||||
const std::string base = "http://localhost:8093";
|
||||
|
||||
Check(Run("curl -sS --http1.1 " + base + "/hello") == "Hello curl!", "curl GET");
|
||||
|
||||
// Two URLs in one invocation: curl reuses the connection, which
|
||||
// only works if our framing let it know the first response ended.
|
||||
const std::uint64_t before = listener.listener.AcceptedCount();
|
||||
const std::string both = Run("curl -sS --http1.1 " + base + "/hello " + base + "/hello");
|
||||
Check(both == "Hello curl!Hello curl!", "curl got both responses");
|
||||
Check(listener.listener.AcceptedCount() == before + 1, "curl reused one connection for both");
|
||||
|
||||
Check(Run("curl -sS --http1.1 -d 'body text' " + base + "/echo") == "POST:body text",
|
||||
"curl POST with content-length");
|
||||
|
||||
// Chunked upload — curl streams stdin with Transfer-Encoding:
|
||||
// chunked when it can't know the length up front.
|
||||
Check(Run("printf 'streamed body' | curl -sS --http1.1 -H 'Transfer-Encoding: chunked' "
|
||||
"--data-binary @- " + base + "/echo") == "POST:streamed body",
|
||||
"curl chunked upload");
|
||||
|
||||
// A body over 1 KiB makes curl wait for `100 Continue` before it
|
||||
// sends anything. Run it verbosely so the trace proves the interim
|
||||
// response actually went out — without it curl still recovers after
|
||||
// a one-second stall, which would hide the bug.
|
||||
const std::string large(64 * 1024, 'x');
|
||||
const std::string upload =
|
||||
"head -c 65536 /dev/zero | tr '\\0' 'x' | curl -sS --http1.1 "
|
||||
"-H 'Expect: 100-continue' --data-binary @- " + base + "/echo";
|
||||
// The trace goes to stderr and the body to stdout; keeping them in
|
||||
// separate runs avoids the two streams interleaving in the pipe.
|
||||
Check(Run(upload + " -v -o /dev/null").find("HTTP/1.1 100 Continue") != std::string::npos,
|
||||
"curl saw the interim 100 Continue");
|
||||
Check(Run(upload) == "POST:" + large, "curl Expect: 100-continue upload arrived intact");
|
||||
|
||||
// HEAD must produce the GET headers and no body.
|
||||
const std::string head = Run("curl -sS --http1.1 -I " + base + "/hello");
|
||||
Check(head.find("HTTP/1.1 200 OK") != std::string::npos, "curl HEAD status line");
|
||||
Check(head.find("content-length: 11") != std::string::npos, "curl HEAD keeps content-length");
|
||||
Check(head.find("Hello curl!") == std::string::npos, "curl HEAD carries no body");
|
||||
|
||||
Check(Run("curl -sS --http1.1 -A 'crafter-test/1.0' " + base + "/agent") == "crafter-test/1.0",
|
||||
"request headers reach the handler");
|
||||
|
||||
// The status line has to be readable by a real client, not just by
|
||||
// our own parser.
|
||||
Check(Run("curl -sS --http1.1 -o /dev/null -w '%{http_code}' " + base + "/missing") == "404",
|
||||
"curl reads the 404 status");
|
||||
|
||||
listener.Stop();
|
||||
}
|
||||
|
||||
void ClientAgainstPythonServer() {
|
||||
if (!HaveCommand("python3")) {
|
||||
std::println("skipping the python half: python3 is not installed");
|
||||
return;
|
||||
}
|
||||
|
||||
const std::filesystem::path root =
|
||||
std::filesystem::temp_directory_path() / "crafter-network-http1-interop";
|
||||
std::filesystem::create_directories(root);
|
||||
const std::string content = "served by python\n";
|
||||
{
|
||||
std::ofstream file(root / "hello.txt", std::ios::binary);
|
||||
file << content;
|
||||
}
|
||||
|
||||
// http.server answers HTTP/1.0 with `Connection: close`: every
|
||||
// request needs its own connection, and the client has to notice.
|
||||
Child server({"python3", "-m", "http.server", "8094", "--bind", "127.0.0.1",
|
||||
"--directory", root.string()});
|
||||
Check(server.Started(), "python3 http.server was spawned");
|
||||
if (!WaitForPort(8094, std::chrono::seconds(10))) {
|
||||
std::println("skipping the python half: http.server never came up");
|
||||
return;
|
||||
}
|
||||
|
||||
ClientHTTP1 client("localhost", 8094);
|
||||
HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/hello.txt", "localhost:8094"));
|
||||
Check(response.status == "200", "python GET status");
|
||||
Check(response.body == content, "python GET body");
|
||||
Check(!client.Connected(), "an HTTP/1.0 response closes the connection");
|
||||
|
||||
HTTPResponse listing = client.Send(CreateRequestHTTP("GET", "/", "localhost:8094"));
|
||||
Check(listing.status == "200", "python directory listing status");
|
||||
Check(listing.body.find("hello.txt") != std::string::npos, "python directory listing body");
|
||||
|
||||
HTTPResponse missing = client.Send(CreateRequestHTTP("GET", "/nothing-here", "localhost:8094"));
|
||||
Check(missing.status == "404", "python 404");
|
||||
|
||||
HTTPResponse head = client.Send(CreateRequestHTTP("HEAD", "/hello.txt", "localhost:8094"));
|
||||
Check(head.status == "200", "python HEAD status");
|
||||
Check(head.body.empty(), "python HEAD has no body");
|
||||
|
||||
std::filesystem::remove_all(root);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
try {
|
||||
CurlAgainstListener();
|
||||
ClientAgainstPythonServer();
|
||||
} catch (const std::exception& error) {
|
||||
std::println("threw: {}", error.what());
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (failures != 0) {
|
||||
std::println("{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Loading…
Reference in a new issue