Crafter.Network/tests/ShouldInteropCurlHTTP1/main.cpp

233 lines
9.5 KiB
C++
Raw Normal View History

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>
2026-07-27 00:45:09 +00:00
//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;
}