From 8fc2c91097f1960331b414f2aeb80fa665111560 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Thu, 17 Sep 2026 21:27:23 +0000 Subject: [PATCH 1/9] client: add Session::async_get() for a whole GET in one operation async_get() submits the request, ends its empty body, waits for the response and reads all of it, handing back a plain Beast message -- status, header fields and the body as a std::string, aliased as client::Message. What a simple GET spread over four steps is one asynchronous operation now, completion tokens and cancellation included. Sending an actual GET means the method can no longer be hardcoded: all three backends built their request with ":method: POST", so Session::Impl::async_submit() takes the method as a parameter. The public async_submit() still passes "POST", so nothing else changes on the wire. The request goes out with "Content-Length: 0" unless the caller frames a body itself. That keeps HTTP/1.1 from making a bodiless request chunked, and with it from counting the request as incomplete -- and the session as busy -- until the body is ended. The message that comes with an error is empty and says status::unknown, rather than the 200 a default-constructed Beast response would claim. test_get.cpp covers status, fields, body, request headers, an empty body, a 1 MiB one, two requests in a row and cancellation, for all three protocols, plus the request line as a raw HTTP/1.1 peer sees it. Co-Authored-By: Claude Opus 5 --- README.md | 15 +++ include/anyhttp/client.hpp | 13 +++ include/anyhttp/h1_session.hpp | 6 +- include/anyhttp/h2_session.hpp | 3 +- include/anyhttp/session.hpp | 45 ++++++++ include/anyhttp/session_impl.hpp | 3 +- src/h1_session.cpp | 10 +- src/h2_session.cpp | 10 +- src/h3_client.cpp | 20 ++-- src/h3_server.cpp | 6 +- src/session.cpp | 101 +++++++++++++++- test/test_get.cpp | 192 +++++++++++++++++++++++++++++++ 12 files changed, 398 insertions(+), 26 deletions(-) create mode 100644 test/test_get.cpp diff --git a/README.md b/README.md index db1fa75..352aeba 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,21 @@ awaitable do_session(Client& client, boost::urls::url url) auto response = co_await request.async_get_response(); } ``` + +A plain GET needs none of those four steps spelled out. `async_get()` does the whole request as one +operation and hands back the response as a plain Beast message -- status, fields and the body as a +`std::string`: + +```c++ + auto session = co_await client.async_connect(); + auto message = co_await session.async_get(url); + std::println("{}: {} bytes", message.result_int(), message.body().size()); +``` + +The convenience is paid for with memory, as the body is buffered in full: anything that wants to +look at the body while it arrives, or to send a body of its own, still goes through +`async_submit()`. + # Implementation The asynchronous operations exposed by server and client are [ASIO asynchronous operations](https://think-async.com/Asio/asio-1.30.2/doc/asio/reference/asynchronous_operations.html). As such, they support a range of [completion tokens](https://think-async.com/Asio/asio-1.30.2/doc/asio/overview/model/completion_tokens.html) like [use_awaitable](https://think-async.com/Asio/asio-1.30.2/doc/asio/reference/use_awaitable.html) or plain callbacks. diff --git a/include/anyhttp/client.hpp b/include/anyhttp/client.hpp index c8d0e19..4d20c85 100644 --- a/include/anyhttp/client.hpp +++ b/include/anyhttp/client.hpp @@ -6,6 +6,8 @@ #include #include +#include +#include #include @@ -34,6 +36,17 @@ struct Config // ================================================================================================= +// +// A response received in one piece: status, header fields and the whole body as a string. +// +// This is a plain Beast message, with nothing of anyhttp left in it -- what \c Session::async_get() +// hands back. Its version is 11 whatever the protocol was: HTTP/2 and HTTP/3 have no version on +// the wire, and a Beast message has nowhere else to put one. +// +using Message = boost::beast::http::response; + +// ------------------------------------------------------------------------------------------------- + class Response { public: diff --git a/include/anyhttp/h1_session.hpp b/include/anyhttp/h1_session.hpp index c025b22..da7a979 100644 --- a/include/anyhttp/h1_session.hpp +++ b/include/anyhttp/h1_session.hpp @@ -119,7 +119,8 @@ class ServerSession : public ServerSessionBase, public BeastSession ServerSession(server::Server::Impl& parent, any_io_executor executor, Stream&& stream); void destroy() noexcept override; - void async_submit(SubmitHandler&& handler, boost::urls::url url, const Fields& headers) override; + void async_submit(SubmitHandler&& handler, std::string_view method, boost::urls::url url, + const Fields& headers) override; awaitable do_session(Buffer&& data) override; private: @@ -194,7 +195,8 @@ class ClientSession : public ClientSessionBase, public BeastSession public: ClientSession(client::Client::Impl& parent, any_io_executor executor, Stream&& stream); - void async_submit(SubmitHandler&& handler, boost::urls::url url, const Fields& headers) override; + void async_submit(SubmitHandler&& handler, std::string_view method, boost::urls::url url, + const Fields& headers) override; awaitable do_session(Buffer&& data) override; // ---------------------------------------------------------------------------------------------- diff --git a/include/anyhttp/h2_session.hpp b/include/anyhttp/h2_session.hpp index e75dbae..908c483 100644 --- a/include/anyhttp/h2_session.hpp +++ b/include/anyhttp/h2_session.hpp @@ -70,7 +70,8 @@ class NGHttp2Session : public anyhttp::Session::Impl // ---------------------------------------------------------------------------------------------- - void async_submit(SubmitHandler&& handler, boost::urls::url url, const Fields& headers) override; + void async_submit(SubmitHandler&& handler, std::string_view method, boost::urls::url url, + const Fields& headers) override; // ---------------------------------------------------------------------------------------------- diff --git a/include/anyhttp/session.hpp b/include/anyhttp/session.hpp index ac92d0b..30348f5 100644 --- a/include/anyhttp/session.hpp +++ b/include/anyhttp/session.hpp @@ -14,6 +14,9 @@ namespace anyhttp using Submit = void(boost::system::error_code, client::Request); using SubmitHandler = boost::asio::any_completion_handler; +using Get = void(boost::system::error_code, client::Message); +using GetHandler = boost::asio::any_completion_handler; + class Session { public: @@ -69,10 +72,52 @@ class Session token, std::move(url), headers); } + /** + * Performs a whole GET request in one operation, and hands back the whole response. + * + * This is the short way through what \ref async_submit() spreads over four steps: it submits + * the request, ends its (empty) body, waits for the response and reads all of it into a + * \c client::Message -- status, header fields and body as a \c std::string: + * + * \code + * auto message = co_await session.async_get(url); + * EXPECT_EQ(message.result_int(), 200); + * EXPECT_EQ(message.body(), "Hello, World!"); + * \endcode + * + * The convenience is paid for with memory: the body is buffered in full, however large it + * turns out to be, as there is no way to look at it before it is complete. Anything that needs + * the body while it arrives, a request body of its own, or a method other than GET still wants + * \ref async_submit(). + * + * The request goes out with "Content-Length: 0" unless \p headers already frames a body, so + * that HTTP/1.1 does not have to make it chunked. + * + * Any error along the way completes this operation: the ones \ref async_submit() describes, + * \c http::error::header_limit for a response header section over + * \c client::Config::max_header_size, and \c http::error::partial_message for a body cut + * short. The message that comes with an error is empty, and says \c status::unknown rather + * than the 200 a default-constructed Beast response would claim. A response that says 404, on + * the other hand, is not an error -- it is a response, and arrives as one. + */ + template + auto async_get(boost::urls::url url, const Fields& headers = {}, + CompletionToken&& token = CompletionToken()) + { + auto executor = asio::get_associated_executor(token, get_executor()); + return asio::async_initiate( + asio::bind_executor(executor, + [this](auto&& handler, boost::urls::url url, const Fields& headers) {// + async_get_any(std::move(handler), std::move(url), headers); + }), + token, std::move(url), headers); + } + boost::asio::any_io_executor get_executor() const noexcept; private: void async_submit_any(SubmitHandler&& handler, boost::urls::url url, const Fields& headers); + void async_get_any(GetHandler&& handler, boost::urls::url url, const Fields& headers); std::shared_ptr impl; }; diff --git a/include/anyhttp/session_impl.hpp b/include/anyhttp/session_impl.hpp index eb80f40..3bf3a7b 100644 --- a/include/anyhttp/session_impl.hpp +++ b/include/anyhttp/session_impl.hpp @@ -19,7 +19,8 @@ class Session::Impl : public std::enable_shared_from_this public: virtual ~Impl() {} virtual boost::asio::any_io_executor get_executor() const noexcept = 0; - virtual void async_submit(SubmitHandler&& handler, boost::urls::url url, const Fields& headers) = 0; + virtual void async_submit(SubmitHandler&& handler, std::string_view method, + boost::urls::url url, const Fields& headers) = 0; virtual asio::awaitable do_session(Buffer&& data) = 0; virtual void destroy() noexcept = 0; }; diff --git a/src/h1_session.cpp b/src/h1_session.cpp index f237f73..a13432d 100644 --- a/src/h1_session.cpp +++ b/src/h1_session.cpp @@ -1208,15 +1208,15 @@ awaitable ClientSession::do_session(Buffer&& buffer) // ------------------------------------------------------------------------------------------------- template -void ServerSession::async_submit(SubmitHandler&& handler, boost::urls::url url, - const Fields& headers) +void ServerSession::async_submit(SubmitHandler&& handler, std::string_view method, + boost::urls::url url, const Fields& headers) { assert(false); } template -void ClientSession::async_submit(SubmitHandler&& handler, boost::urls::url url, - const Fields& headers) +void ClientSession::async_submit(SubmitHandler&& handler, std::string_view method, + boost::urls::url url, const Fields& headers) { // // Only one request can be incomplete at a time, see ClientSession. Instead of waiting for the @@ -1240,7 +1240,7 @@ void ClientSession::async_submit(SubmitHandler&& handler, boost::urls::u auto& request = writer->message; request.base().target(url.encoded_target()); - request.method(http::verb::post); + request.method_string(method); request.set(http::field::user_agent, "anyhttp"); add_fields(request, headers); if (request.find(http::field::host) == request.end()) diff --git a/src/h2_session.cpp b/src/h2_session.cpp index d9d7aca..a5dc107 100644 --- a/src/h2_session.cpp +++ b/src/h2_session.cpp @@ -422,8 +422,8 @@ NGHttp2Session::~NGHttp2Session() // ================================================================================================= -void NGHttp2Session::async_submit(SubmitHandler&& handler, boost::urls::url url, - const Fields& headers) +void NGHttp2Session::async_submit(SubmitHandler&& handler, std::string_view method, + boost::urls::url url, const Fields& headers) { mlogi("submit: {}", url.buffer()); @@ -449,14 +449,14 @@ void NGHttp2Session::async_submit(SubmitHandler&& handler, boost::urls::url url, // TODO: CONNECT // https://datatracker.ietf.org/doc/html/rfc7540#section-8.3 // - std::string method("POST"); + std::string method_str(method); std::string scheme(url.scheme()); std::string target(url.encoded_target()); std::string authority(url.host_address()); auto nva = boost::container::small_vector(); nva.reserve(4 + std::distance(headers.begin(), headers.end())); - nva.push_back(make_nv_ls(":method", method)); + nva.push_back(make_nv_ls(":method", method_str)); nva.push_back(make_nv_ls(":scheme", scheme)); nva.push_back(make_nv_ls(":path", target)); nva.push_back(make_nv_ls(":authority", authority)); @@ -470,7 +470,7 @@ void NGHttp2Session::async_submit(SubmitHandler&& handler, boost::urls::url url, nva.push_back(make_nv_ls(item.name_string(), item.value())); } - logd("[{}] {} {}", stream->logPrefix, method, url.buffer()); + logd("[{}] {} {}", stream->logPrefix, method_str, url.buffer()); for (auto nv : nva) logd("[{}] \x1b[1;34m{}\x1b[0m: {}", stream->logPrefix, truncated(name_of(nv)), truncated(value_of(nv))); diff --git a/src/h3_client.cpp b/src/h3_client.cpp index 62a45a7..1ed7fbb 100644 --- a/src/h3_client.cpp +++ b/src/h3_client.cpp @@ -152,7 +152,8 @@ class Http3ClientStream : public http3::Http3Stream void submit_response(unsigned int, const Fields&) override {} /// Assembles and submits the request headers. Called once, right after the stream is created. - bool submit_request(const boost::urls::url& url, const Fields& headers); + bool submit_request(std::string_view method, const boost::urls::url& url, + const Fields& headers); void async_get_response(client::Request::GetResponseHandler&& handler); void deliver_response(); @@ -202,7 +203,8 @@ class Http3ClientSession : public http3::Http3Session // // Session::Impl // - void async_submit(SubmitHandler&& handler, boost::urls::url url, const Fields& headers) override; + void async_submit(SubmitHandler&& handler, std::string_view method, boost::urls::url url, + const Fields& headers) override; awaitable do_session(Buffer&& data) override; void destroy() noexcept override; @@ -309,15 +311,15 @@ void Http3ClientStream::deliver_failure() swap_and_invoke(response_handler, failure_ec, client::Response{nullptr}); } -bool Http3ClientStream::submit_request(const boost::urls::url& request_url, const Fields& headers) +bool Http3ClientStream::submit_request(std::string_view method, + const boost::urls::url& request_url, const Fields& headers) { url = request_url; // - // TODO: CONNECT / other methods -- mirrors the h2 client's NGHttp2Session::async_submit(), - // which is likewise hard-coded to POST. + // TODO: CONNECT // - std::string method_str("POST"); + std::string method_str(method); std::string scheme(request_url.scheme()); std::string target(request_url.encoded_target()); std::string authority(request_url.host_address()); @@ -649,8 +651,8 @@ int Http3ClientSession::on_read(std::span data) // ------------------------------------------------------------------------------------------------- -void Http3ClientSession::async_submit(SubmitHandler&& handler, boost::urls::url url, - const Fields& headers) +void Http3ClientSession::async_submit(SubmitHandler&& handler, std::string_view method, + boost::urls::url url, const Fields& headers) { if (closed() || !h3()) { @@ -668,7 +670,7 @@ void Http3ClientSession::async_submit(SubmitHandler&& handler, boost::urls::url } auto* stream = static_cast(create_stream(stream_id)); - if (!stream->submit_request(url, headers)) + if (!stream->submit_request(method, url, headers)) { erase_stream(stream_id); std::move(handler)(errc::make_error_code(errc::invalid_argument), client::Request{nullptr}); diff --git a/src/h3_server.cpp b/src/h3_server.cpp index 1db745a..a828d5e 100644 --- a/src/h3_server.cpp +++ b/src/h3_server.cpp @@ -263,7 +263,8 @@ class Http3ServerSession : public http3::Http3Session // it, giving one QUIC connection the same single-threaded world a TCP connection gets from // the strand its socket lives on. // - void async_submit(SubmitHandler&& handler, boost::urls::url, const Fields&) override; + void async_submit(SubmitHandler&& handler, std::string_view, boost::urls::url, + const Fields&) override; awaitable do_session(Buffer&& data) override; void destroy() noexcept override; @@ -527,7 +528,8 @@ Http3ServerSession::~Http3ServerSession() // ------------------------------------------------------------------------------------------------- -void Http3ServerSession::async_submit(SubmitHandler&& handler, boost::urls::url, const Fields&) +void Http3ServerSession::async_submit(SubmitHandler&& handler, std::string_view, boost::urls::url, + const Fields&) { // A server does not initiate requests; see Http3ClientSession::async_submit(). std::move(handler)(errc::make_error_code(errc::operation_not_supported), diff --git a/src/session.cpp b/src/session.cpp index d57bf5a..066ef52 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -1,6 +1,14 @@ #include "anyhttp/session.hpp" +#include "anyhttp/literals.hpp" #include "anyhttp/session_impl.hpp" +#include +#include +#include +#include + +#include + using namespace boost::asio; namespace anyhttp @@ -50,7 +58,98 @@ boost::asio::any_io_executor Session::get_executor() const noexcept void Session::async_submit_any(SubmitHandler&& handler, boost::urls::url url, const Fields& headers) { - impl->async_submit(std::move(handler), url, std::move(headers)); + impl->async_submit(std::move(handler), "POST", url, std::move(headers)); +} + +// ================================================================================================= + +namespace +{ + +// +// Submits a request with a method of its own. The public Session::async_submit() is POST-only, +// but every backend can send whatever method it is handed, see Session::Impl::async_submit(). +// +template +auto async_submit(Session::Impl& impl, std::string_view method, boost::urls::url url, + const Fields& headers, CompletionToken&& token) +{ + return asio::async_initiate( + [&impl](auto&& handler, std::string_view method, boost::urls::url url, + const Fields& headers) { // + impl.async_submit(std::move(handler), method, std::move(url), headers); + }, + token, method, std::move(url), headers); +} + +// +// The whole of async_get(), as the coroutine it reads best as: submit, end the empty request +// body, wait for the response, read all of it. Every step is left to throw, and the co_spawn() +// below turns that back into the error code the caller gets. +// +// The implementation is held by shared_ptr: a Session released while the GET is still in flight +// must not pull the ground out from under the operations still running on it. +// +awaitable get_message(std::shared_ptr session, + boost::urls::url url, Fields headers) +{ + // + // A GET has no body, and saying so with a "Content-Length: 0" keeps HTTP/1.1 from framing one + // as chunked -- which would leave the request incomplete until the write_eof() below, and the + // session unable to take another one until then. + // + if (!headers.count(boost::beast::http::field::content_length) && + !headers.count(boost::beast::http::field::transfer_encoding)) + headers.set(boost::beast::http::field::content_length, "0"); + + auto request = co_await async_submit(*session, "GET", std::move(url), headers, deferred); + co_await request.async_write_eof(); + auto response = co_await request.async_get_response(); + + client::Message message; + message.result(static_cast(response.status_code())); + for (auto&& field : response.fields()) + message.insert(field.name_string(), field.value()); // insert(), so repeated fields survive + + auto& body = message.body(); + std::array buffer; + for (;;) + { + auto [ec, n] = co_await response.async_read_some(asio::buffer(buffer), as_tuple); + body.append(buffer.data(), n); + + if (ec == asio::error::eof) + break; + else if (ec) + throw boost::system::system_error(ec); + } + + logd("async_get: {} {}, {} bytes", message.result_int(), message.reason(), body.size()); + co_return std::move(message); +} + +} // namespace + +void Session::async_get_any(GetHandler&& handler, boost::urls::url url, const Fields& headers) +{ + auto executor = get_associated_executor(handler, get_executor()); + auto slot = get_associated_cancellation_slot(handler); + + // + // co_spawn() gives the coroutine a cancellation slot of its own, so binding the caller's to it + // is what makes cancelling async_get() reach the operation it is currently waiting for. + // + co_spawn(get_executor(), get_message(impl, std::move(url), headers), + bind_cancellation_slot( + slot, bind_executor(executor, [handler = std::move(handler)]( + const std::exception_ptr& ep, + client::Message message) mutable + { + auto ec = code(ep); + if (ec) + message.result(boost::beast::http::status::unknown); // not the Beast default + std::move(handler)(ec, std::move(message)); + }))); } // ================================================================================================= diff --git a/test/test_get.cpp b/test/test_get.cpp new file mode 100644 index 0000000..2955c14 --- /dev/null +++ b/test/test_get.cpp @@ -0,0 +1,192 @@ +#include "test_fixtures.hpp" + +#include + +using namespace testing; + +namespace http = boost::beast::http; + +// ================================================================================================= + +// +// Session::async_get(): a whole GET request as a single operation, with the response handed back +// as a plain Beast message -- status, fields and the body as a string. +// +class AsyncGet : public ClientAsync +{ +protected: + /// Installs a request handler that drains the request and responds 200 with \p body. + void respond_with(std::string body) + { + custom = [body = std::move(body)](server::Request request, + server::Response response) -> awaitable + { + EXPECT_EQ(co_await drain(request), 0); // a GET has no body + co_await response.async_submit( + 200, fields({{"Content-Length", body.size()}, {"X-Answer", 42}})); + co_await response.async_write_eof(asio::buffer(body)); + }; + } +}; + +INSTANTIATE_TEST_SUITE_P(AsyncGet, AsyncGet, + Values(anyhttp::Protocol::http11, anyhttp::Protocol::h2, + anyhttp::Protocol::h3), + NameGenerator); + +// ------------------------------------------------------------------------------------------------- + +TEST_P(AsyncGet, WHEN_get_THEN_message_has_status_fields_and_body) +{ + respond_with("Hello, World!"); + test = [this](Session session) -> awaitable + { + auto message = co_await session.async_get(url); + EXPECT_EQ(message.result(), http::status::ok); + EXPECT_EQ(message.result_int(), 200); + EXPECT_EQ(message["x-answer"], "42"); + EXPECT_EQ(message.body(), "Hello, World!"); + }; +} + +TEST_P(AsyncGet, WHEN_response_has_no_body_THEN_body_is_empty) +{ + respond_with(""); + test = [this](Session session) -> awaitable + { + auto message = co_await session.async_get(url); + EXPECT_EQ(message.result_int(), 200); + EXPECT_THAT(message.body(), IsEmpty()); + }; +} + +// +// A response is a response, whatever it says: only a request that gets none at all -- like the +// cancelled one below -- completes with an error. +// +TEST_P(AsyncGet, WHEN_path_is_unknown_THEN_message_says_404) +{ + test = [this](Session session) -> awaitable + { + auto message = co_await session.async_get(url.set_path("unknown")); + EXPECT_EQ(message.result(), http::status::not_found); + }; +} + +TEST_P(AsyncGet, WHEN_body_is_large_THEN_all_of_it_arrives) +{ + auto body = std::string(1_m, 'x'); + respond_with(body); + test = [this, body](Session session) -> awaitable + { + auto message = co_await session.async_get(url); + EXPECT_EQ(message.result_int(), 200); + EXPECT_EQ(message.body().size(), body.size()); + EXPECT_EQ(message.body(), body); + }; +} + +TEST_P(AsyncGet, WHEN_headers_are_given_THEN_they_arrive_with_the_request) +{ + custom = [](server::Request request, server::Response response) -> awaitable + { + EXPECT_EQ(request.fields()["x-question"], "what?"); + EXPECT_EQ(request.fields()["content-length"], "0"); + co_await drain(request); + co_await response.async_submit(200, fields({{"Content-Length", 0}})); + co_await response.async_write_eof(); + }; + test = [this](Session session) -> awaitable + { + auto message = co_await session.async_get(url, fields({{"X-Question", "what?"}})); + EXPECT_EQ(message.result_int(), 200); + }; +} + +// +// A GET is complete as soon as it has been submitted, so even HTTP/1.1, which allows just one +// request in progress at a time, takes the next one right away. +// +TEST_P(AsyncGet, WHEN_two_requests_in_a_row_THEN_both_are_answered) +{ + respond_with("Hello, World!"); + test = [this](Session session) -> awaitable + { + for (size_t i = 0; i < 2; ++i) + { + auto message = co_await session.async_get(url); + EXPECT_EQ(message.result_int(), 200); + EXPECT_EQ(message.body(), "Hello, World!"); + } + }; +} + +// +// Cancellation reaches whichever of the steps the GET is waiting for, and comes out of the one +// operation the caller started. Nothing arrived, so the message stays empty. +// +TEST_P(AsyncGet, WHEN_cancelled_THEN_completes_with_operation_canceled_and_empty_message) +{ + // + // Responds late, and to nobody in particular: by then the client has given up, so writing to + // the stream is expected to fail. + // + custom = [](server::Request request, server::Response response) -> awaitable + { + co_await sleep(1s); + std::ignore = co_await response.async_submit(200, {}, as_tuple); + std::ignore = co_await response.async_write_eof(as_tuple); + }; + test = [this](Session session) -> awaitable + { + auto [ec, message] = co_await session.async_get(url, {}, cancel_after(100ms, as_tuple)); + EXPECT_EQ(ec, boost::system::errc::operation_canceled); + EXPECT_EQ(message.result_int(), 0); + EXPECT_THAT(message.body(), IsEmpty()); + }; +} + +// ================================================================================================= + +// +// What goes out on the wire, checked against a raw HTTP/1.1 peer: async_get() sends a GET, where +// async_submit() sends a POST, and frames the absent body with "Content-Length: 0" instead of +// making it chunked. HTTP/2 and HTTP/3 put the very same method string into ':method'. +// +TEST(AsyncGetRaw, WHEN_get_THEN_request_line_says_GET) +{ + setupLogging(); + io_context context; + tcp::acceptor acceptor(context, tcp::endpoint(ip::make_address("127.0.0.1"), 0)); + + std::string head; + co_spawn(context, [&]() -> awaitable + { + auto socket = co_await acceptor.async_accept(); + co_await async_read_until(socket, dynamic_buffer(head), "\r\n\r\n"); + constexpr auto response = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"sv; + co_await async_write(socket, buffer(response)); + socket.shutdown(tcp::socket::shutdown_send); + }, detached); + + auto url = boost::urls::url("http://127.0.0.1"); + url.set_port_number(acceptor.local_endpoint().port()); + + client::Client client(context.get_executor(), + {.url = url, .protocol = anyhttp::Protocol::http11}); + co_spawn(context, [&]() -> awaitable + { + auto session = co_await client.async_connect(); + auto message = co_await session.async_get(url.set_path("/index.html")); + EXPECT_EQ(message.result_int(), 200); + EXPECT_EQ(message.body(), "hello"); + }, [](const std::exception_ptr& ep) { EXPECT_FALSE(ep) << what(ep); }); + + context.run(); + + EXPECT_THAT(head, StartsWith("GET /index.html HTTP/1.1\r\n")); + EXPECT_THAT(head, HasSubstr("Content-Length: 0\r\n")); + EXPECT_THAT(head, Not(HasSubstr("chunked"))); +} + +// ================================================================================================= From 2214f73142fdcd12325c9ad598c6ac677fda7b56 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Thu, 17 Sep 2026 21:27:29 +0000 Subject: [PATCH 2/9] test: use async_get() where a testcase just needs the whole response The header, file handler and client testcases that send no request body and read the response to its end say so in one line now. client::Message is a Beast fields, so expect_contains() and values_of() take it unchanged. FileHandler::get() was async_get() spelled out, and hands back the message itself instead of a (status, body) tuple. HeaderLimits::request() loses its two error checks along with the operations they belonged to. These requests go out as GET with "Content-Length: 0" now, instead of as a chunked POST -- which is what the testcases describe anyway. Co-Authored-By: Claude Opus 5 --- test/test_client_async.cpp | 21 +++-------- test/test_file_handler.cpp | 77 +++++++++++++++++--------------------- test/test_headers.cpp | 34 +++++------------ 3 files changed, 51 insertions(+), 81 deletions(-) diff --git a/test/test_client_async.cpp b/test/test_client_async.cpp index 0275759..4e5c701 100644 --- a/test/test_client_async.cpp +++ b/test/test_client_async.cpp @@ -496,10 +496,7 @@ TEST_P(ClientAsync, WHEN_written_after_eof_THEN_reports_broken_pipe) }; test = [this](Session session) -> awaitable { - auto request = co_await session.async_submit(url); - co_await request.async_write_eof(); - auto response = co_await request.async_get_response(); - EXPECT_EQ(co_await read(response), hello); + EXPECT_EQ((co_await session.async_get(url)).body(), hello); }; } @@ -513,11 +510,9 @@ TEST_P(ClientAsync, HelloWorld) }; test = [this](Session session) -> awaitable { - auto request = co_await session.async_submit(url); - co_await request.async_write_eof(); - auto response = co_await request.async_get_response(); - auto body = co_await read(response); - EXPECT_EQ(body, hello); + auto message = co_await session.async_get(url); + EXPECT_EQ(message.result_int(), 200); + EXPECT_EQ(message.body(), hello); }; } @@ -548,9 +543,7 @@ TEST_P(ClientAsync, WHEN_server_writes_large_buffer_at_once_THEN_receives_all) }; test = [this](Session session) -> awaitable { - auto request = co_await session.async_submit(url); - co_await request.async_write_eof(); - EXPECT_EQ(co_await count_response(request), body.size()); + EXPECT_EQ((co_await session.async_get(url)).body().size(), body.size()); }; } @@ -686,9 +679,7 @@ TEST_P(ClientAsync, ServerYieldFirst) }; test = [this](Session session) -> awaitable { - auto request = co_await session.async_submit(url); - co_await request.async_write_eof(); - co_await count_response(request); + EXPECT_EQ((co_await session.async_get(url)).result_int(), 200); }; } diff --git a/test/test_file_handler.cpp b/test/test_file_handler.cpp index 7d085ec..16c8e03 100644 --- a/test/test_file_handler.cpp +++ b/test/test_file_handler.cpp @@ -54,20 +54,13 @@ class FileHandler : public ClientAsync std::ofstream(path, std::ios::binary).write(content.data(), content.size()); } - // - // Requests \p target and returns status code and body. The request is finished right away -- - // serve_file() ignores the request body, but still has to consume it. - // - awaitable> get(Session& session, boost::urls::url target) - { - auto request = co_await session.async_submit(target, {}); - co_await request.async_write_eof(); - auto response = co_await request.async_get_response(); - auto body = co_await read(response); - co_return std::make_tuple(response.status_code(), std::move(body)); + /// Requests \p target and returns the whole response, body and all. + awaitable get(Session& session, boost::urls::url target) + { + co_return co_await session.async_get(std::move(target)); } - awaitable> get(Session& session, std::string_view path) + awaitable get(Session& session, std::string_view path) { co_return co_await get(session, boost::urls::url(url).set_path(path)); } @@ -98,9 +91,9 @@ TEST_P(FileHandler, WHEN_file_exists_THEN_serves_content) { test = [this](Session session) -> awaitable { - auto [status, body] = co_await get(session, "/custom/hello.txt"); - EXPECT_EQ(status, 200); - EXPECT_EQ(body, "Hello, File!"); + auto message = co_await get(session, "/custom/hello.txt"); + EXPECT_EQ(message.result_int(), 200); + EXPECT_EQ(message.body(), "Hello, File!"); }; } @@ -108,9 +101,9 @@ TEST_P(FileHandler, WHEN_file_is_in_subdirectory_THEN_serves_content) { test = [this](Session session) -> awaitable { - auto [status, body] = co_await get(session, "/custom/sub/nested.txt"); - EXPECT_EQ(status, 200); - EXPECT_EQ(body, "Nested!"); + auto message = co_await get(session, "/custom/sub/nested.txt"); + EXPECT_EQ(message.result_int(), 200); + EXPECT_EQ(message.body(), "Nested!"); }; } @@ -122,9 +115,9 @@ TEST_P(FileHandler, WHEN_file_is_empty_THEN_serves_empty_body) { test = [this](Session session) -> awaitable { - auto [status, body] = co_await get(session, "/custom/empty.txt"); - EXPECT_EQ(status, 200); - EXPECT_EQ(body, ""); + auto message = co_await get(session, "/custom/empty.txt"); + EXPECT_EQ(message.result_int(), 200); + EXPECT_THAT(message.body(), IsEmpty()); }; } @@ -136,9 +129,9 @@ TEST_P(FileHandler, WHEN_file_is_large_THEN_serves_all_of_it) { test = [this](Session session) -> awaitable { - auto [status, body] = co_await get(session, "/custom/large.bin"); - EXPECT_EQ(status, 200); - EXPECT_EQ(body, std::string(256_k, 'x')); + auto message = co_await get(session, "/custom/large.bin"); + EXPECT_EQ(message.result_int(), 200); + EXPECT_EQ(message.body(), std::string(256_k, 'x')); }; } @@ -146,9 +139,9 @@ TEST_P(FileHandler, WHEN_file_does_not_exist_THEN_error_404) { test = [this](Session session) -> awaitable { - auto [status, body] = co_await get(session, "/custom/missing.txt"); - EXPECT_EQ(status, 404); - EXPECT_EQ(body, ""); + auto message = co_await get(session, "/custom/missing.txt"); + EXPECT_EQ(message.result_int(), 404); + EXPECT_THAT(message.body(), IsEmpty()); }; } @@ -159,8 +152,8 @@ TEST_P(FileHandler, WHEN_path_is_a_directory_THEN_error_404) { test = [this](Session session) -> awaitable { - EXPECT_EQ(std::get<0>(co_await get(session, "/custom/sub")), 404); - EXPECT_EQ(std::get<0>(co_await get(session, "/custom/")), 404); + EXPECT_EQ((co_await get(session, "/custom/sub")).result_int(), 404); + EXPECT_EQ((co_await get(session, "/custom/")).result_int(), 404); }; } @@ -168,9 +161,9 @@ TEST_P(FileHandler, WHEN_path_escapes_the_root_THEN_error_404) { test = [this](Session session) -> awaitable { - EXPECT_EQ(std::get<0>(co_await get(session, "/custom/../outside.txt")), 404); - EXPECT_EQ(std::get<0>(co_await get(session, "/custom/sub/../../outside.txt")), 404); - EXPECT_EQ(std::get<0>(co_await get(session, encoded("/custom/%2e%2e/outside.txt"))), 404); + EXPECT_EQ((co_await get(session, "/custom/../outside.txt")).result_int(), 404); + EXPECT_EQ((co_await get(session, "/custom/sub/../../outside.txt")).result_int(), 404); + EXPECT_EQ((co_await get(session, encoded("/custom/%2e%2e/outside.txt"))).result_int(), 404); }; } @@ -181,7 +174,7 @@ TEST_P(FileHandler, WHEN_symlink_points_outside_the_root_THEN_error_404) { test = [this](Session session) -> awaitable { - EXPECT_EQ(std::get<0>(co_await get(session, "/custom/escape.txt")), 404); + EXPECT_EQ((co_await get(session, "/custom/escape.txt")).result_int(), 404); }; } @@ -193,10 +186,10 @@ TEST_P(FileHandler, WHEN_prefix_matches_mid_segment_THEN_error_404) { test = [this](Session session) -> awaitable { - auto [status, body] = co_await get(session, "/customer.txt"); - EXPECT_EQ(status, 404); - EXPECT_EQ(body, ""); - EXPECT_EQ(std::get<0>(co_await get(session, "/customer/hello.txt")), 404); + auto message = co_await get(session, "/customer.txt"); + EXPECT_EQ(message.result_int(), 404); + EXPECT_THAT(message.body(), IsEmpty()); + EXPECT_EQ((co_await get(session, "/customer/hello.txt")).result_int(), 404); }; } @@ -207,9 +200,9 @@ TEST_P(FileHandler, WHEN_file_is_not_readable_THEN_error_403) test = [this](Session session) -> awaitable { - auto [status, body] = co_await get(session, "/custom/secret.txt"); - EXPECT_EQ(status, 403); - EXPECT_EQ(body, ""); + auto message = co_await get(session, "/custom/secret.txt"); + EXPECT_EQ(message.result_int(), 403); + EXPECT_THAT(message.body(), IsEmpty()); }; } @@ -217,8 +210,8 @@ TEST_P(FileHandler, WHEN_same_file_is_requested_twice_THEN_serves_it_twice) { test = [this](Session session) -> awaitable { - EXPECT_EQ(std::get<1>(co_await get(session, "/custom/hello.txt")), "Hello, File!"); - EXPECT_EQ(std::get<1>(co_await get(session, "/custom/hello.txt")), "Hello, File!"); + EXPECT_EQ((co_await get(session, "/custom/hello.txt")).body(), "Hello, File!"); + EXPECT_EQ((co_await get(session, "/custom/hello.txt")).body(), "Hello, File!"); }; } diff --git a/test/test_headers.cpp b/test/test_headers.cpp index 7848217..ebe410a 100644 --- a/test/test_headers.cpp +++ b/test/test_headers.cpp @@ -93,12 +93,10 @@ void Headers::round_trip(Fields sent) }; test = [this, sent](Session session) -> awaitable { - auto request = co_await session.async_submit(url, sent); - co_await request.async_write_eof(); - auto response = co_await request.async_get_response(); - EXPECT_EQ(response.status_code(), 200); - expect_contains(response.fields(), sent); - EXPECT_EQ(co_await drain(response), 0); + auto message = co_await session.async_get(url, sent); + EXPECT_EQ(message.result_int(), 200); + expect_contains(message, sent); + EXPECT_THAT(message.body(), IsEmpty()); }; } @@ -130,11 +128,8 @@ TEST_P(Headers, WHEN_header_name_repeats_THEN_all_values_arrive_in_order) }; test = [this, sent, values](Session session) -> awaitable { - auto request = co_await session.async_submit(url, sent); - co_await request.async_write_eof(); - auto response = co_await request.async_get_response(); - EXPECT_THAT(values_of(response.fields(), "x-repeated"), ElementsAreArray(values)); - co_await drain(response); + auto message = co_await session.async_get(url, sent); + EXPECT_THAT(values_of(message, "x-repeated"), ElementsAreArray(values)); }; } @@ -167,10 +162,8 @@ TEST_P(Headers, WHEN_request_headers_exceed_default_limit_THEN_server_responds_4 }; test = [this, sent](Session session) -> awaitable { - auto request = co_await session.async_submit(url, sent); - co_await request.async_write_eof(); - auto response = co_await request.async_get_response(); - EXPECT_EQ(response.status_code(), 431); + auto message = co_await session.async_get(url, sent); + EXPECT_EQ(message.result_int(), 431); }; } @@ -217,17 +210,10 @@ class HeaderLimits : public ClientAsync if (response_size) target.params().set("response_size", std::to_string(response_size)); - auto [ec, request] = co_await session.async_submit(target, sent, as_tuple); - if (!ec) - std::tie(ec) = co_await request.async_write_eof(as_tuple); + auto [ec, message] = co_await session.async_get(target, sent, as_tuple); if (ec) co_return std::unexpected(ec); - - auto [ec2, response] = co_await request.async_get_response(as_tuple); - if (ec2) - co_return std::unexpected(ec2); - co_await drain(response); - co_return response.status_code(); + co_return message.result_int(); } size_t handled = 0; From 8f445a43012c635ee955f6a00a9b04f7167a8b81 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Fri, 18 Sep 2026 12:30:36 +0000 Subject: [PATCH 3/9] buffer_array: fix the buffer sequence constructor and use it The constructor taking a buffer sequence ended its loop at buffer_sequence_begin() rather than buffer_sequence_end(), so first and last were the same iterator and it copied nothing at all: n_ and size_ stayed 0, leaving an empty buffer array. Nothing ever instantiated it, which is why this never showed. AnyAsyncStream spelled out the iterator pair constructor instead, and that one was correct. It passes the sequence itself now, which is what the constructor is there for -- the two loops are otherwise the same, both capping at N and skipping empty buffers. The bug could not have survived being used: with buffer_sequence_begin() put back, the HTTP/1.1 testcases hang, reading and writing zero bytes forever, as an empty buffer sequence makes them. Co-Authored-By: Claude Opus 5 --- include/anyhttp/any_async_stream.hpp | 12 ++++-------- include/anyhttp/buffer_array.hpp | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/include/anyhttp/any_async_stream.hpp b/include/anyhttp/any_async_stream.hpp index 6b56faf..6fa2d22 100644 --- a/include/anyhttp/any_async_stream.hpp +++ b/include/anyhttp/any_async_stream.hpp @@ -108,10 +108,8 @@ class AnyAsyncStream { return boost::asio::async_initiate( [this](ReadWriteHandler handler, const ConstBufferSequence& buffers) - { - impl->async_write_some(std::move(handler), - ConstBufferVector{asio::buffer_sequence_begin(buffers), - asio::buffer_sequence_end(buffers)}); + { // + impl->async_write_some(std::move(handler), ConstBufferVector{buffers}); }, token, buffers); } @@ -127,10 +125,8 @@ class AnyAsyncStream { return boost::asio::async_initiate( [this](ReadWriteHandler handler, const MutableBufferSequence& buffers) - { - impl->async_read_some(std::move(handler), - MutableBufferVector{asio::buffer_sequence_begin(buffers), - asio::buffer_sequence_end(buffers)}); + { // + impl->async_read_some(std::move(handler), MutableBufferVector{buffers}); }, token, buffers); } }; diff --git a/include/anyhttp/buffer_array.hpp b/include/anyhttp/buffer_array.hpp index f17a61a..b82f0df 100644 --- a/include/anyhttp/buffer_array.hpp +++ b/include/anyhttp/buffer_array.hpp @@ -106,7 +106,7 @@ class buffer_array buffer_array(BS const& bs) noexcept : dummy_(0) { auto it = buffer_sequence_begin(bs); - auto const last = buffer_sequence_begin(bs); + auto const last = buffer_sequence_end(bs); while (it != last && n_ < N) { value_type b(*it); From 64caeea11367d6dadfbbb10d457965dcf9974b91 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Fri, 18 Sep 2026 17:20:57 +0000 Subject: [PATCH 4/9] documentation updates, cleanup --- include/anyhttp/any_async_stream.hpp | 29 ++++++++++++++-------------- src/server_impl.cpp | 4 ++-- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/include/anyhttp/any_async_stream.hpp b/include/anyhttp/any_async_stream.hpp index 6fa2d22..7681df0 100644 --- a/include/anyhttp/any_async_stream.hpp +++ b/include/anyhttp/any_async_stream.hpp @@ -21,6 +21,7 @@ namespace ip = asio::ip; namespace anyhttp { + // ================================================================================================= using ReadWrite = void(boost::system::error_code, std::size_t); @@ -37,12 +38,12 @@ using ShutdownHandler = asio::any_completion_handler; /** * Attempt to create a type-erased async stream. * - * The difficult part here is to type-erase the buffer sequences. For starters, the buffers are - * copied into a small vector that can hold up to 4 buffers without allocation. This seems to work - * reasonably well. + * The difficult part here is to type-erase the buffer sequences. The buffers are copied into a + * buffer_array, a fixed-capacity, non-allocating array of buffer descriptors that is itself a + * buffer sequence. This seems to work reasonably well. * * There is also asio::buffer_sequence_adapter and linearise(), which seems to be used in ASIO's - * SSL code was well. It merges a set of buffers into a new, contiguous buffer. But that is slow. + * SSL code as well. It merges a set of buffers into a new, contiguous buffer. But that is slow. */ class AnyAsyncStream { @@ -52,21 +53,19 @@ class AnyAsyncStream class Impl { public: - using executor_type = boost::asio::any_io_executor; virtual ~Impl() = default; + + using executor_type = boost::asio::any_io_executor; virtual executor_type get_executor() noexcept = 0; virtual ip::tcp::socket& get_socket() = 0; - using ConstBuffers = ConstBufferVector; - using MutableBuffers = MutableBufferVector; virtual void async_write_some(ReadWriteHandler handler, ConstBufferVector buffer) = 0; virtual void async_read_some(ReadWriteHandler handler, MutableBufferVector buffer) = 0; - virtual void async_shutdown_impl(ShutdownHandler handler) { auto ex = boost::asio::get_associated_immediate_executor(handler, get_executor()); ex.execute([handler = std::move(handler)]() mutable { // - handler(boost::system::error_code()); + std::move(handler)(boost::system::error_code()); }); } }; @@ -84,20 +83,22 @@ class AnyAsyncStream // async_write_some // // The async operations of ASIO are designed to work with sequences of buffers. Those cannot - // easily be type-erased, aside transforming them to a vector. + // easily be type-erased, so we copy the buffer descriptors into a fixed-capacity array. // // The requirements for ConstBufferSequence are defined here: // https://live.boost.org/doc/libs/1_88_0/doc/html/boost_asio/reference/ConstBufferSequence.html // // The iterators returned by asio::buffer_sequence_{begin,end} must be 'bidirectional', but are - // not required to be 'contiguous'. So those iterators cannot be simply convereted to a span. + // not required to be 'contiguous'. So those iterators cannot be simply converted to a span. // // * https://en.cppreference.com/w/cpp/iterator/bidirectional_iterator // * https://en.cppreference.com/w/cpp/iterator/contiguous_iterator.html // - // In the end, we just re-use ASIOs "buffer sequence adapter" that yields a single buffer. - // When writing, it merges small buffers and when reading, it uses the first non-empty buffer. - // This is simple, but effective -- and also what an SSL stream does, internally. + // Instead, we copy them into a buffer_array, which is itself a (contiguous) buffer sequence and + // can be passed on to the underlying stream unchanged. Nothing is merged or linearized, so + // scatter/gather I/O is preserved. Empty buffers are dropped while copying, and sequences longer + // than the array's capacity are truncated -- which is harmless for a "some" operation, as it + // just results in a shorter transfer. // template Date: Sat, 19 Sep 2026 05:32:54 +0000 Subject: [PATCH 5/9] cleanup: comments, doc links and get_unused_port() Nothing functional, except that get_unused_port() now probes on the IPv6 loopback: its only caller connects to "localhost" and expects to be refused, and that resolves to ::1 first. Co-Authored-By: Claude Opus 5 --- include/anyhttp/any_async_stream.hpp | 12 +++++------- include/anyhttp/concepts.hpp | 4 ++-- src/utils.cpp | 13 +++++++------ 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/include/anyhttp/any_async_stream.hpp b/include/anyhttp/any_async_stream.hpp index 7681df0..dacd06d 100644 --- a/include/anyhttp/any_async_stream.hpp +++ b/include/anyhttp/any_async_stream.hpp @@ -27,8 +27,6 @@ namespace anyhttp using ReadWrite = void(boost::system::error_code, std::size_t); using ReadWriteHandler = asio::any_completion_handler; -// using ConstBufferVector = boost::container::small_vector; -// using MutableBufferVector = boost::container::small_vector; using ConstBufferVector = const_buffer_array<16>; using MutableBufferVector = mutable_buffer_array<16>; @@ -36,13 +34,13 @@ using Shutdown = void(boost::system::error_code); using ShutdownHandler = asio::any_completion_handler; /** - * Attempt to create a type-erased async stream. + * Attempt to create a type-erased async stream with ASIO. * * The difficult part here is to type-erase the buffer sequences. The buffers are copied into a * buffer_array, a fixed-capacity, non-allocating array of buffer descriptors that is itself a * buffer sequence. This seems to work reasonably well. * - * There is also asio::buffer_sequence_adapter and linearise(), which seems to be used in ASIO's + * There is also \c asio::buffer_sequence_adapter and \c linearise(), which seem to be used in ASIO * SSL code as well. It merges a set of buffers into a new, contiguous buffer. But that is slow. */ class AnyAsyncStream @@ -54,7 +52,7 @@ class AnyAsyncStream { public: virtual ~Impl() = default; - + using executor_type = boost::asio::any_io_executor; virtual executor_type get_executor() noexcept = 0; virtual ip::tcp::socket& get_socket() = 0; @@ -76,8 +74,8 @@ class AnyAsyncStream public: AnyAsyncStream(std::unique_ptr impl_) : impl(std::move(impl_)) {} - inline executor_type get_executor() noexcept { return impl->get_executor(); } - inline ip::tcp::socket& get_socket() { return impl->get_socket(); } + executor_type get_executor() noexcept { return impl->get_executor(); } + ip::tcp::socket& get_socket() { return impl->get_socket(); } // // async_write_some diff --git a/include/anyhttp/concepts.hpp b/include/anyhttp/concepts.hpp index 419b9a8..e93c45c 100644 --- a/include/anyhttp/concepts.hpp +++ b/include/anyhttp/concepts.hpp @@ -16,8 +16,8 @@ concept MutableBufferSequence = boost::asio::is_mutable_buffer_sequence::valu // -// https://think-async.com/Asio/asio-1.11.0/doc/asio/reference/AsyncReadStream.html -// https://think-async.com/Asio/asio-1.11.0/doc/asio/reference/AsyncWriteStream.html +// https://think-async.com/Asio/asio-1.38.2/doc/asio/reference/AsyncReadStream.html +// https://think-async.com/Asio/asio-1.38.2/doc/asio/reference/AsyncWriteStream.html // template diff --git a/src/utils.cpp b/src/utils.cpp index 7210c21..88da872 100644 --- a/src/utils.cpp +++ b/src/utils.cpp @@ -33,16 +33,17 @@ size_t run(boost::asio::io_context& context) unsigned short get_unused_port(boost::asio::io_context& io) { - boost::asio::ip::tcp::acceptor acc(io); + using namespace boost::asio::ip; - acc.open(boost::asio::ip::tcp::v4()); - acc.bind({boost::asio::ip::address_v4::loopback(), 0}); + tcp::acceptor acc(io); + acc.open(tcp::v6()); + acc.bind({address_v6::loopback(), 0}); - unsigned short port = acc.local_endpoint().port(); + auto port = acc.local_endpoint().port(); - acc.close(); // release immediately + acc.close(); // release immediately - return port; + return port; } // ================================================================================================= From 3a68bb5b8c6f31b239caf44fa646c886c76cd868 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Sat, 19 Sep 2026 05:33:36 +0000 Subject: [PATCH 6/9] server: create the TLS context once per server, not per connection The TCP side built a fresh asio::ssl::context inside handle_connection(), reading the PEM files from disk for every single connection, while HTTP/3 kept its SSL_CTX in a function-local static for the lifetime of the process. Apart from the per-connection cost, the two disagreed about when a rotated certificate is picked up: regenerating the test PKI under a running server broke HTTP/3 while HTTP/2 silently carried on with the new chain. Both now hold their context for as long as the server lives -- Server::Impl for TCP, Http3ServerImpl for QUIC -- so a regenerated PKI needs a restart either way, and nothing is read from disk per connection. Also adds anyhttp.org to the test server certificate. Co-Authored-By: Claude Opus 5 --- include/anyhttp/server_impl.hpp | 7 ++++++ pki/server.json | 3 ++- src/h3_server.cpp | 22 +++++++++-------- src/server_impl.cpp | 44 +++++++++++++++++++++++---------- 4 files changed, 52 insertions(+), 24 deletions(-) diff --git a/include/anyhttp/server_impl.hpp b/include/anyhttp/server_impl.hpp index 60608c7..858da93 100644 --- a/include/anyhttp/server_impl.hpp +++ b/include/anyhttp/server_impl.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -68,6 +69,11 @@ class Server::Impl : public std::enable_shared_from_this const Config& config() const { return m_config; } boost::asio::any_io_executor get_executor() const noexcept { return m_executor; } + // + // The TLS context used for every TCP connection, see make_tls_server_context(). + // + boost::asio::ssl::context& tls_context() noexcept { return m_tlsContext; } + asio::awaitable tcp_accept_loop(); asio::awaitable handle_connection(asio::ip::tcp::socket socket); @@ -93,6 +99,7 @@ class Server::Impl : public std::enable_shared_from_this Config m_config; boost::asio::any_io_executor m_executor; + boost::asio::ssl::context m_tlsContext; std::optional m_acceptor; std::mutex m_sessionMutex; diff --git a/pki/server.json b/pki/server.json index b8531c4..5ff79ff 100644 --- a/pki/server.json +++ b/pki/server.json @@ -6,8 +6,9 @@ }, "hosts": [ "localhost", + "anyhttp.org", "::1", "127.0.0.1", "127.0.0.2" ] -} \ No newline at end of file +} diff --git a/src/h3_server.cpp b/src/h3_server.cpp index a828d5e..5f7362e 100644 --- a/src/h3_server.cpp +++ b/src/h3_server.cpp @@ -114,7 +114,8 @@ namespace { // -// The process-wide BoringSSL SSL_CTX used for every QUIC connection. +// The BoringSSL SSL_CTX every QUIC connection of one server is served from. Owned by +// Http3ServerImpl, so it is built with the server and not on the first connection. // struct TlsServerContext { @@ -175,14 +176,6 @@ struct TlsServerContext SSL_CTX* ctx = nullptr; }; -TlsServerContext& tls_context() -{ - static TlsServerContext instance; - return instance; -} - -// ------------------------------------------------------------------------------------------------- - std::string cid_key(const ngtcp2_cid& cid) { return std::string{reinterpret_cast(cid.data), cid.datalen}; @@ -356,6 +349,13 @@ class Http3ServerImpl : public Http3Server, public std::enable_shared_from_this< const RequestHandler& requestHandler() const { return parent_.requestHandler(); } asio::any_io_executor get_executor() const noexcept { return parent_.get_executor(); } + // + // The TLS context every QUIC connection is served from, created with this server rather than + // lazily on the first connection, so that a certificate rotated afterwards is never picked up + // by one protocol only. See make_tls_server_context() on the TCP side. + // + SSL_CTX* tls_context() noexcept { return tls_.ctx; } + // // QUIC connection-ID demux table. Populated as new source CIDs are minted, consulted by // udp_on_read() to route packets to the right connection. Guarded by mutex_: the receive loop @@ -377,6 +377,8 @@ class Http3ServerImpl : public Http3Server, public std::enable_shared_from_this< private: Server::Impl& parent_; + TlsServerContext tls_; + // // The socket gets its own strand: udp_receive_loop() runs on it, and destroy() dispatches the // shutdown close() through it, so the two never touch the socket concurrently. @@ -742,7 +744,7 @@ int Http3ServerSession::init(const ngtcp2_cid& dcid, const ngtcp2_cid& scid, uin return -1; } - if (setup_tls(tls_context().ctx, true /* server */) != 0) + if (setup_tls(server_.tls_context(), true /* server */) != 0) return -1; logi("[{}] new connection, scid={} version=0x{:x}", log_prefix_, diff --git a/src/server_impl.cpp b/src/server_impl.cpp index 69aa21d..775e582 100644 --- a/src/server_impl.cpp +++ b/src/server_impl.cpp @@ -55,8 +55,14 @@ Response::Impl::~Impl() = default; // ================================================================================================= +// +// Defined further down, together with the ALPN callbacks it installs. +// +static asio::ssl::context make_tls_server_context(); + Server::Impl::Impl(boost::asio::any_io_executor executor, Config config) - : m_config(std::move(config)), m_executor(std::move(executor)), m_acceptor(m_executor) + : m_config(std::move(config)), m_executor(std::move(executor)), + m_tlsContext(make_tls_server_context()), m_acceptor(m_executor) { logi("Server: ctor"); listen_tcp(); @@ -212,6 +218,29 @@ static int alpn_select_proto_cb(SSL* ssl, const unsigned char** out, unsigned ch return SSL_TLSEXT_ERR_NOACK; } +// +// The TLS context every TCP connection is served from. It is created once, when the server is +// constructed, and not per connection: building it reads the PEM files from disk, and a context +// built per connection would also pick up a certificate that was rotated underneath a running +// server -- unlike HTTP/3, which holds its context for the lifetime of the server. That +// difference made a regenerated test PKI fail over HTTP/3 while HTTP/2 silently kept working. +// +static asio::ssl::context make_tls_server_context() +{ + asio::ssl::context ctx{asio::ssl::context::tlsv13}; + SSL_CTX_set_next_protos_advertised_cb(ctx.native_handle(), next_proto_cb, NULL); + SSL_CTX_set_alpn_select_cb(ctx.native_handle(), alpn_select_proto_cb, NULL); + + // + // This is a testing key only. It is not in the repository, but generated at build time + // by the 'pki' target (see cmake/pki.cmake). + // + ctx.use_certificate_chain_file("pki/out/server-chain.pem"); + ctx.use_private_key_file("pki/out/server-key.pem", asio::ssl::context::pem); + + return ctx; +} + // ------------------------------------------------------------------------------------------------- class TestStream : public AnyAsyncStream::Impl @@ -271,18 +300,7 @@ awaitable Server::Impl::handle_connection(ip::tcp::socket socket) { logi("[{}] detected TLS client hello, {} bytes in buffer", prefix, buffer.size()); - asio::ssl::context ctx{asio::ssl::context::tlsv13}; - SSL_CTX_set_next_protos_advertised_cb(ctx.native_handle(), next_proto_cb, NULL); - SSL_CTX_set_alpn_select_cb(ctx.native_handle(), alpn_select_proto_cb, NULL); - - // - // This is a testing key only. It is not in the repository, but generated at build time - // by the 'pki' target (see cmake/pki.cmake). - // - ctx.use_certificate_chain_file("pki/out/server-chain.pem"); - ctx.use_private_key_file("pki/out/server-key.pem", asio::ssl::context::pem); - - ssl_stream.emplace(std::move(socket), ctx); + ssl_stream.emplace(std::move(socket), m_tlsContext); auto n = co_await ssl_stream->async_handshake(asio::ssl::stream_base::server, buffer.data()); buffer.consume(n); From 9669f3b1770ed4a381541ee63716ecd93b644787 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Sat, 19 Sep 2026 05:33:47 +0000 Subject: [PATCH 7/9] stream_traits: one trait for the stream types a session can run on A session is a template over its stream, and there are four of those: a plain tcp::socket, a TLS stream, beast's tcp_stream and the type-erased AnyAsyncStream. Beyond the async read and write operations they already have in common, a session needs the underlying socket -- to shut it down or close it -- and an executor to run its loops on, and neither is spelled the same way by all four. Both h1_session.cpp and h2_session_details.hpp had their own private set of get_socket() overloads for this, and the executor was passed in from outside. stream_traits now provides get_socket() and get_executor() for all of them, with a SocketStream concept and a free get_socket() on top. There is no free get_executor(), because the classes calling these have a get_executor() of their own that would hide it. With the executor reachable from the stream, each backend's per-stream-type factory overloads collapse into a single constrained template taking the stream by rvalue reference and no executor argument -- ten declarations down to four. The h2 h2c variant folds into the same template via an optional, which retires the three make_h2c_session() dispatch helpers in h1_session.cpp, including the one that only existed to throw for TLS. The definitions stay in the .cpp files, explicitly instantiated there, so beast and nghttp2 are still confined to one translation unit each. Taking the executor from the stream only holds if the stream is on the right one to begin with. It was not: with Config::use_strand, tcp_accept_loop() made the strand at co_spawn() time, leaving the socket on the plain executor. The socket is now accepted onto the connection's executor, as the HTTP/3 side already does, and handle_connection() is spawned on that. Co-Authored-By: Claude Opus 5 --- .cspell.json | 1 + include/anyhttp/detail/h2_session_details.hpp | 14 +-- include/anyhttp/h1_backend.hpp | 39 +++--- include/anyhttp/h2_backend.hpp | 58 ++++----- include/anyhttp/stream_traits.hpp | 116 ++++++++++++++++++ src/client_impl.cpp | 5 +- src/h1_session.cpp | 103 +++++----------- src/h2_session.cpp | 65 +++------- src/server_impl.cpp | 35 +++--- 9 files changed, 247 insertions(+), 189 deletions(-) create mode 100644 include/anyhttp/stream_traits.hpp diff --git a/.cspell.json b/.cspell.json index 3b7b7f4..4bfb9b5 100644 --- a/.cspell.json +++ b/.cspell.json @@ -49,6 +49,7 @@ "respawn", "RESPAWNED", "respawning", + "rvalues", "scid", "SCIDLEN", "scids", diff --git a/include/anyhttp/detail/h2_session_details.hpp b/include/anyhttp/detail/h2_session_details.hpp index 98e2be5..93a255e 100644 --- a/include/anyhttp/detail/h2_session_details.hpp +++ b/include/anyhttp/detail/h2_session_details.hpp @@ -5,10 +5,10 @@ // factories in anyhttp/h2_backend.hpp are what the generic server and client use instead. // -#include "anyhttp/any_async_stream.hpp" #include "anyhttp/h2_common.hpp" #include "anyhttp/h2_session.hpp" #include "anyhttp/literals.hpp" +#include "anyhttp/stream_traits.hpp" #include #include @@ -16,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -34,11 +33,6 @@ using namespace boost::asio; using namespace boost::beast; using socket = asio::ip::tcp::socket; -inline auto& get_socket(socket& socket) { return socket; } -inline auto& get_socket(tcp_stream& stream) { return stream.socket(); } -inline auto& get_socket(ssl::stream& stream) { return stream.lowest_layer(); } -inline auto& get_socket(AnyAsyncStream& stream) { return stream.get_socket(); } - // ================================================================================================= template @@ -113,7 +107,7 @@ awaitable NGHttp2SessionImpl::send_loop() { const std::array seq{buffer.data(), asio::buffer(data, nread)}; mylogd("send loop: writing {} bytes...", bytes_to_write); - auto [ec, written] = co_await asio::async_write(m_stream, seq, as_tuple(deferred)); + auto [ec, written] = co_await asio::async_write(m_stream, seq, as_tuple); if (ec) { mloge("send loop: error writing {} bytes: {}", bytes_to_write, ec.message()); @@ -166,7 +160,7 @@ awaitable NGHttp2SessionImpl::recv_loop() while (nghttp2_session_want_read(session) || nghttp2_session_want_write(session)) { auto free = m_buffer.capacity() - m_buffer.size(); - auto [ec, n] = co_await m_stream.async_read_some(m_buffer.prepare(free), as_tuple(deferred)); + auto [ec, n] = co_await m_stream.async_read_some(m_buffer.prepare(free), as_tuple); if (ec) { mylogd("read: {}, terminating session", ec.message()); @@ -201,7 +195,6 @@ template awaitable ServerSession::do_session(Buffer&& buffer) { m_buffer = std::move(buffer); - // get_socket(m_stream).set_option(ip::tcp::no_delay(true)); auto callbacks = super::setup_callbacks(); // @@ -298,7 +291,6 @@ template awaitable ClientSession::do_session(Buffer&& buffer) { m_buffer = std::move(buffer); - // get_socket(m_stream).set_option(ip::tcp::no_delay(true)); auto callbacks = super::setup_callbacks(); // diff --git a/include/anyhttp/h1_backend.hpp b/include/anyhttp/h1_backend.hpp index 05985ce..c5ffb5a 100644 --- a/include/anyhttp/h1_backend.hpp +++ b/include/anyhttp/h1_backend.hpp @@ -7,12 +7,11 @@ // and h1_session.cpp, which are the only places instantiating them. // -#include "anyhttp/any_async_stream.hpp" #include "anyhttp/client_impl.hpp" #include "anyhttp/server_impl.hpp" #include "anyhttp/session_impl.hpp" +#include "anyhttp/stream_traits.hpp" -#include #include #include @@ -25,21 +24,33 @@ namespace anyhttp::beast_impl using SslStream = boost::asio::ssl::stream; -std::shared_ptr make_server_session(server::Server::Impl& server, - boost::asio::any_io_executor executor, - SslStream&& stream); +// ------------------------------------------------------------------------------------------------- -std::shared_ptr make_server_session(server::Server::Impl& server, - boost::asio::any_io_executor executor, - AnyAsyncStream&& stream); +// +// The stream is moved into the session, which runs on the stream's own executor -- hence the +// rvalue reference, which also keeps the SocketStream constraint from matching an lvalue. +// +// These are defined in src/h1_session.cpp and explicitly instantiated there for each of the +// stream types below, so that beast's HTTP machinery is instantiated in that one place only. +// + +template +std::shared_ptr make_server_session(server::Server::Impl& server, Stream&& stream); + +template +std::shared_ptr make_client_session(client::Client::Impl& client, Stream&& stream); -std::shared_ptr make_server_session(server::Server::Impl& server, - boost::asio::any_io_executor executor, - boost::asio::ip::tcp::socket&& socket); +extern template std::shared_ptr +make_server_session(server::Server::Impl&, + boost::asio::ip::tcp::socket&&); +extern template std::shared_ptr +make_server_session(server::Server::Impl&, SslStream&&); +extern template std::shared_ptr +make_server_session(server::Server::Impl&, AnyAsyncStream&&); -std::shared_ptr make_client_session(client::Client::Impl& client, - boost::asio::any_io_executor executor, - boost::asio::ip::tcp::socket&& socket); +extern template std::shared_ptr +make_client_session(client::Client::Impl&, + boost::asio::ip::tcp::socket&&); // ================================================================================================= diff --git a/include/anyhttp/h2_backend.hpp b/include/anyhttp/h2_backend.hpp index fd73871..00e63df 100644 --- a/include/anyhttp/h2_backend.hpp +++ b/include/anyhttp/h2_backend.hpp @@ -7,17 +7,17 @@ // and h2_session.cpp, so that dispatching to HTTP/2 needs no nghttp2 type here. // -#include "anyhttp/any_async_stream.hpp" #include "anyhttp/client_impl.hpp" #include "anyhttp/server_impl.hpp" #include "anyhttp/session_impl.hpp" +#include "anyhttp/stream_traits.hpp" -#include #include #include #include #include +#include #include namespace anyhttp::nghttp2 @@ -40,34 +40,34 @@ struct Upgrade Fields fields; ///< request headers, without the connection-specific ones }; -std::shared_ptr make_server_session(server::Server::Impl& server, - boost::asio::any_io_executor executor, - boost::asio::ip::tcp::socket&& socket); - -std::shared_ptr make_server_session(server::Server::Impl& server, - boost::asio::any_io_executor executor, - SslStream&& stream); - -std::shared_ptr make_server_session(server::Server::Impl& server, - boost::asio::any_io_executor executor, - AnyAsyncStream&& stream); - -// Cleartext only: there is no upgrade to HTTP/2 over TLS, that is what ALPN is for. - -std::shared_ptr make_server_session(server::Server::Impl& server, - boost::asio::any_io_executor executor, - boost::asio::ip::tcp::socket&& socket, - Upgrade&& upgrade); - -std::shared_ptr make_server_session(server::Server::Impl& server, - boost::asio::any_io_executor executor, - AnyAsyncStream&& stream, Upgrade&& upgrade); - -// ------------------------------------------------------------------------------------------------- +// +// The stream is moved into the session, which runs on the stream's own executor -- hence the +// rvalue reference, which also keeps the SocketStream constraint from matching an lvalue. +// +// These are defined in src/h2_session.cpp and explicitly instantiated there for each of the +// stream types below, so that nghttp2 is instantiated in that one place only. +// -std::shared_ptr make_client_session(client::Client::Impl& client, - boost::asio::any_io_executor executor, - boost::asio::ip::tcp::socket&& socket); +template +std::shared_ptr make_server_session(server::Server::Impl& server, Stream&& stream, + std::optional upgrade = {}); + +template +std::shared_ptr make_client_session(client::Client::Impl& client, Stream&& stream); + +extern template std::shared_ptr +make_server_session(server::Server::Impl&, + boost::asio::ip::tcp::socket&&, + std::optional); +extern template std::shared_ptr +make_server_session(server::Server::Impl&, SslStream&&, std::optional); +extern template std::shared_ptr +make_server_session(server::Server::Impl&, AnyAsyncStream&&, + std::optional); + +extern template std::shared_ptr +make_client_session(client::Client::Impl&, + boost::asio::ip::tcp::socket&&); // ================================================================================================= diff --git a/include/anyhttp/stream_traits.hpp b/include/anyhttp/stream_traits.hpp new file mode 100644 index 0000000..6e29079 --- /dev/null +++ b/include/anyhttp/stream_traits.hpp @@ -0,0 +1,116 @@ +#pragma once + +// +// The sessions are templates over the stream they run on, and there are four of those: a plain +// TCP socket, a TLS stream on top of one, beast's tcp_stream and the type-erased AnyAsyncStream. +// Beyond the async read and write operations, which all of them have in common already, a session +// needs two more things from its stream: the underlying socket, to shut it down or close it, and +// an executor to run its loops on. Neither is spelled the same way by all four, so they are +// reached through this trait instead. +// + +#include "anyhttp/any_async_stream.hpp" + +#include +#include +#include +#include + +#include + +namespace anyhttp +{ + +// ================================================================================================= + +/** + * Specialized below for every stream type a session can be instantiated with. The primary template + * is left undefined on purpose, so that \c SocketStream rejects anything else. + */ +template +struct stream_traits; + +/// A plain TCP socket is its own socket. +template +struct stream_traits> +{ + using stream_type = boost::asio::basic_stream_socket; + + static stream_type& get_socket(stream_type& stream) noexcept { return stream; } + static Executor get_executor(stream_type& stream) noexcept { return stream.get_executor(); } +}; + +/// A TLS stream, which may be layered on top of anything that has a socket at the bottom. +template +struct stream_traits> +{ + using stream_type = boost::asio::ssl::stream; + + static auto& get_socket(stream_type& stream) noexcept { return stream.lowest_layer(); } + static auto get_executor(stream_type& stream) noexcept { return stream.get_executor(); } +}; + +/// Beast's stream, which wraps a socket to add timeouts and a rate policy. +template +struct stream_traits> +{ + using stream_type = boost::beast::basic_stream; + + static auto& get_socket(stream_type& stream) noexcept { return stream.socket(); } + static auto get_executor(stream_type& stream) noexcept { return stream.get_executor(); } +}; + +/// The type-erased stream already offers both, its implementation has to provide them. +template <> +struct stream_traits +{ + using stream_type = AnyAsyncStream; + + static auto& get_socket(stream_type& stream) noexcept { return stream.get_socket(); } + static auto get_executor(stream_type& stream) noexcept { return stream.get_executor(); } +}; + +// ------------------------------------------------------------------------------------------------- + +/** + * What the four get_socket() above have in common: a TLS stream hands out its \c lowest_layer(), + * which is this rather than the full ip::tcp::socket. It is enough for shutdown() and close(), + * which is all a session does with it. + */ +using TcpSocketBase = boost::asio::basic_socket; + +/** + * An async stream that is backed by a TCP socket and knows the executor it runs on -- in other + * words, something a session can be built on. Note that this deliberately does not match + * references: the factories taking it move the stream into the session they create. + */ +template +concept SocketStream = requires(Stream& stream) { + { stream_traits::get_socket(stream) } -> std::convertible_to; + { + stream_traits::get_executor(stream) + } -> std::convertible_to; +}; + +/** + * The socket underneath \p stream, for shutdown() and close(). There is no free function for the + * executor, because the classes calling this have a \c get_executor() of their own, which would + * hide it. + */ +template +decltype(auto) get_socket(Stream& stream) noexcept +{ + return stream_traits::get_socket(stream); +} + +// ------------------------------------------------------------------------------------------------- + +static_assert(SocketStream); +static_assert(SocketStream>); +static_assert(SocketStream); +static_assert(SocketStream); +static_assert(!SocketStream); // rvalues only, see above + +// ================================================================================================= + +} // namespace anyhttp diff --git a/src/client_impl.cpp b/src/client_impl.cpp index 46b5e72..63eb720 100644 --- a/src/client_impl.cpp +++ b/src/client_impl.cpp @@ -16,7 +16,6 @@ #include #include -#include #include #include @@ -155,11 +154,11 @@ awaitable Client::Impl::async_connect() switch (config().protocol) { case Protocol::http11: - impl = beast_impl::make_client_session(*this, m_executor, std::move(socket)); + impl = beast_impl::make_client_session(*this, std::move(socket)); break; case Protocol::h2: - impl = nghttp2::make_client_session(*this, m_executor, std::move(socket)); + impl = nghttp2::make_client_session(*this, std::move(socket)); break; case anyhttp::Protocol::h3: diff --git a/src/h1_session.cpp b/src/h1_session.cpp index a13432d..8dd4bd9 100644 --- a/src/h1_session.cpp +++ b/src/h1_session.cpp @@ -7,6 +7,7 @@ #include "anyhttp/h2_backend.hpp" #include "anyhttp/literals.hpp" #include "anyhttp/server.hpp" +#include "anyhttp/stream_traits.hpp" #include #include @@ -23,7 +24,6 @@ #include #include #include -#include #include #include #include @@ -62,11 +62,6 @@ using namespace asio; using namespace boost::beast; using socket = asio::ip::tcp::socket; -inline auto& get_socket(socket& socket) { return socket; } -inline auto& get_socket(tcp_stream& stream) { return stream.socket(); } -inline auto& get_socket(ssl::stream& stream) { return stream.lowest_layer(); } -inline auto& get_socket(AnyAsyncStream& stream) { return stream.get_socket(); } - /** * Adds the user's header fields to an outgoing message. A field replaces whatever the message * already has under that name, like a default set before, but repeated fields are all kept. @@ -562,7 +557,8 @@ class ResponseWriter mlogd("{} {}", message.result_int(), message.reason()); for (const auto& header : message) - mlogd(" \x1b[1;34m{}\x1b[0m: {}", truncated(header.name_string()), truncated(header.value())); + mlogd(" \x1b[1;34m{}\x1b[0m: {}", truncated(header.name_string()), + truncated(header.value())); // // TODO: For bundling writing the header and body, we should just post the writing here, @@ -752,7 +748,8 @@ class RequestWriter http::response_parser::value_type& msg = reader->parser.get(); mlogd("{} {}", msg.result_int(), msg.reason()); for (const auto& header : msg) - mlogd(" \x1b[1;34m{}\x1b[0m: {}", truncated(header.name_string()), truncated(header.value())); + mlogd(" \x1b[1;34m{}\x1b[0m: {}", truncated(header.name_string()), + truncated(header.value())); } else mlogw("async_read_header: {} len={}", ec.message(), len); @@ -936,31 +933,6 @@ static std::optional h2c_upgrade(const http::request make_h2c_session(server::Server::Impl& server, - any_io_executor executor, tcp_stream& stream, - nghttp2::Upgrade&& upgrade) -{ - return nghttp2::make_server_session(server, std::move(executor), stream.release_socket(), - std::move(upgrade)); -} - -static std::shared_ptr make_h2c_session(server::Server::Impl& server, - any_io_executor executor, - AnyAsyncStream& stream, - nghttp2::Upgrade&& upgrade) -{ - return nghttp2::make_server_session(server, std::move(executor), std::move(stream), - std::move(upgrade)); -} - -static std::shared_ptr make_h2c_session(server::Server::Impl&, any_io_executor, - ssl::stream&, nghttp2::Upgrade&&) -{ - throw std::logic_error("h2c upgrade over TLS"); // rejected by h2c_upgrade() -} - -// ================================================================================================= - /** * This function waits for headers of an incoming, new request and passes control to a registered * handler. After the request has been completed, and if the connection can be kept open, it starts @@ -980,11 +952,6 @@ awaitable ServerSession::do_session(Buffer&& buffer) m_buffer = std::move(buffer); mlogd("do_server_session, {} bytes in buffer", m_buffer.size()); - // get_socket(m_stream).set_option(asio::ip::tcp::no_delay(true)); - - // Set the timeout. TODO: don't rely on beast timeouts - // m_stream.expires_after(std::chrono::seconds(5)); - // m_stream.expires_never(); bool close = false; beast::error_code ec; @@ -1058,7 +1025,8 @@ awaitable ServerSession::do_session(Buffer&& buffer) mlogd("{} {} (need_eof={})", request.method_string(), reader->m_url.buffer(), need_eof); for (auto& header : request) - mlogd(" \x1b[1;34m{}\x1b[0m: {}", truncated(header.name_string()), truncated(header.value())); + mlogd(" \x1b[1;34m{}\x1b[0m: {}", truncated(header.name_string()), + truncated(header.value())); // // Upgrade to h2c, if requested: Answer with "101 Switching Protocols" and hand over the @@ -1079,8 +1047,10 @@ awaitable ServerSession::do_session(Buffer&& buffer) } mlogi("upgrading to h2c, {} bytes in buffer", m_buffer.size()); - m_upgraded = - make_h2c_session(server(), super::get_executor(), m_stream, std::move(*upgrade)); + // Stream is whatever this session runs on, but never a TLS one: h2c_upgrade() takes + // cleartext requests only, as h2 over TLS is negotiated by ALPN instead. + m_upgraded = nghttp2::make_server_session(server(), std::move(m_stream), + std::move(*upgrade)); co_await m_upgraded->do_session(std::move(m_buffer)); mlogi("h2c session done, served {} requests before upgrade", requestCounter - 1); co_return; @@ -1168,10 +1138,9 @@ awaitable ClientSession::do_session(Buffer&& buffer) m_buffer = std::move(buffer); mlogd("do_client_session, {} bytes in buffer", m_buffer.size()); - // get_socket(m_stream).set_option(asio::ip::tcp::no_delay(true)); // Set the low-level TCP stream timeout. This is relevant for some testcases... - m_stream.expires_after(5s); + // m_stream.expires_after(5s); // // Even in HTTP/1.1, where the current request and the current response's serializers take @@ -1201,7 +1170,7 @@ awaitable ClientSession::do_session(Buffer&& buffer) timer.expires_after(2s); co_await timer.async_wait(deferred); - // auto [ec, len] = co_await async_read_header(m_stream, buffer, parser, as_tuple(deferred)); + // auto [ec, len] = co_await async_read_header(m_stream, buffer, parser, as_tuple); co_return; } @@ -1250,7 +1219,8 @@ void ClientSession::async_submit(SubmitHandler&& handler, std::string_vi mlogd("{} {}", request.method_string(), url.buffer()); for (const auto& header : request) - mlogd(" \x1b[1;34m{}\x1b[0m: {}", truncated(header.name_string()), truncated(header.value())); + mlogd(" \x1b[1;34m{}\x1b[0m: {}", truncated(header.name_string()), + truncated(header.value())); writer->sequence = m_requests_sent++; m_sending = writer.get(); @@ -1318,8 +1288,7 @@ void ClientSession::reader_finished(bool complete) // ================================================================================================= -template class ClientSession; -template class ServerSession; +template class ServerSession; template class ServerSession>; template class ServerSession; @@ -1328,37 +1297,29 @@ template class ServerSession; // translation unit, so that the generic server and client stay free of beast's HTTP machinery. // ================================================================================================= -std::shared_ptr make_server_session(server::Server::Impl& server, - asio::any_io_executor executor, - SslStream&& stream) +template +std::shared_ptr make_server_session(server::Server::Impl& server, Stream&& stream) { - return std::make_shared>(server, std::move(executor), - std::move(stream)); + auto executor = stream_traits::get_executor(stream); // before the stream is moved from + return std::make_shared>(server, std::move(executor), std::move(stream)); } -std::shared_ptr make_server_session(server::Server::Impl& server, - asio::any_io_executor executor, - AnyAsyncStream&& stream) +template +std::shared_ptr make_client_session(client::Client::Impl& client, Stream&& stream) { - return std::make_shared>(server, std::move(executor), - std::move(stream)); + auto executor = stream_traits::get_executor(stream); // before the stream is moved from + return std::make_shared>(client, std::move(executor), std::move(stream)); } -std::shared_ptr make_server_session(server::Server::Impl& server, - asio::any_io_executor executor, - asio::ip::tcp::socket&& socket) -{ - return std::make_shared>( - server, std::move(executor), boost::beast::tcp_stream(std::move(socket))); -} +template std::shared_ptr make_server_session(server::Server::Impl&, + socket&&); +template std::shared_ptr make_server_session(server::Server::Impl&, + SslStream&&); +template std::shared_ptr +make_server_session(server::Server::Impl&, AnyAsyncStream&&); -std::shared_ptr make_client_session(client::Client::Impl& client, - asio::any_io_executor executor, - asio::ip::tcp::socket&& socket) -{ - return std::make_shared>( - client, std::move(executor), boost::beast::tcp_stream(std::move(socket))); -} +template std::shared_ptr make_client_session(client::Client::Impl&, + socket&&); // ================================================================================================= diff --git a/src/h2_session.cpp b/src/h2_session.cpp index a5dc107..b23a425 100644 --- a/src/h2_session.cpp +++ b/src/h2_session.cpp @@ -16,7 +16,6 @@ #include #include -#include #include #include #include @@ -664,58 +663,34 @@ void NGHttp2Session::start_write() // translation unit, so that the generic server and client never see an nghttp2 type. // ================================================================================================= -std::shared_ptr make_server_session(server::Server::Impl& server, - asio::any_io_executor executor, - SslStream&& stream) +template +std::shared_ptr make_server_session(server::Server::Impl& server, Stream&& stream, + std::optional upgrade) { - return std::make_shared>(server, std::move(executor), - std::move(stream)); -} - -std::shared_ptr make_server_session(server::Server::Impl& server, - asio::any_io_executor executor, - AnyAsyncStream&& stream) -{ - return std::make_shared>(server, std::move(executor), - std::move(stream)); -} - -std::shared_ptr make_server_session(server::Server::Impl& server, - asio::any_io_executor executor, - asio::ip::tcp::socket&& socket) -{ - return std::make_shared>(server, std::move(executor), - std::move(socket)); -} - -std::shared_ptr make_server_session(server::Server::Impl& server, - asio::any_io_executor executor, - asio::ip::tcp::socket&& socket, - Upgrade&& upgrade) -{ - auto session = std::make_shared>( - server, std::move(executor), std::move(socket)); + auto executor = stream_traits::get_executor(stream); // before the stream is moved from + auto session = + std::make_shared>(server, std::move(executor), std::move(stream)); session->m_upgrade = std::move(upgrade); return session; } -std::shared_ptr make_server_session(server::Server::Impl& server, - asio::any_io_executor executor, - AnyAsyncStream&& stream, Upgrade&& upgrade) +template +std::shared_ptr make_client_session(client::Client::Impl& client, Stream&& stream) { - auto session = std::make_shared>(server, std::move(executor), - std::move(stream)); - session->m_upgrade = std::move(upgrade); - return session; + auto executor = stream_traits::get_executor(stream); // before the stream is moved from + return std::make_shared>(client, std::move(executor), std::move(stream)); } -std::shared_ptr make_client_session(client::Client::Impl& client, - asio::any_io_executor executor, - asio::ip::tcp::socket&& socket) -{ - return std::make_shared>(client, std::move(executor), - std::move(socket)); -} +template std::shared_ptr +make_server_session(server::Server::Impl&, socket&&, std::optional); +template std::shared_ptr +make_server_session(server::Server::Impl&, SslStream&&, std::optional); +template std::shared_ptr +make_server_session(server::Server::Impl&, AnyAsyncStream&&, + std::optional); + +template std::shared_ptr make_client_session(client::Client::Impl&, + socket&&); // ================================================================================================= diff --git a/src/server_impl.cpp b/src/server_impl.cpp index 775e582..214ae13 100644 --- a/src/server_impl.cpp +++ b/src/server_impl.cpp @@ -288,7 +288,6 @@ awaitable Server::Impl::handle_connection(ip::tcp::socket socket) // socket.set_option(sb::send_buffer_size(8192)); // socket.set_option(sb::receive_buffer_size(8192)); // makes 'PostRange' testcases very slow - auto executor = co_await boost::asio::this_coro::executor; auto buffer = boost::beast::flat_buffer(); // @@ -320,9 +319,9 @@ awaitable Server::Impl::handle_connection(ip::tcp::socket socket) tls_handshake_info(ssl_stream->native_handle())); if (alpn == "h2") - session = nghttp2::make_server_session(*this, executor, std::move(*ssl_stream)); + session = nghttp2::make_server_session(*this, std::move(*ssl_stream)); else if (alpn == "http/1.1") - session = beast_impl::make_server_session(*this, executor, std::move(*ssl_stream)); + session = beast_impl::make_server_session(*this, std::move(*ssl_stream)); } // @@ -333,9 +332,9 @@ awaitable Server::Impl::handle_connection(ip::tcp::socket socket) logi("[{}] detected HTTP2 client preface, {} bytes in buffer", prefix, buffer.size()); #if 1 AnyAsyncStream stream(std::make_unique(std::move(socket))); - session = nghttp2::make_server_session(*this, executor, std::move(stream)); + session = nghttp2::make_server_session(*this, std::move(stream)); #else - session = nghttp2::make_server_session(*this, executor, std::move(socket)); + session = nghttp2::make_server_session(*this, std::move(socket)); #endif } @@ -347,9 +346,9 @@ awaitable Server::Impl::handle_connection(ip::tcp::socket socket) logi("[{}] no HTTP2 client preface, assuming HTTP/1.x", prefix); #if 1 AnyAsyncStream stream(std::make_unique(std::move(socket))); - session = beast_impl::make_server_session(*this, executor, std::move(stream)); + session = beast_impl::make_server_session(*this, std::move(stream)); #else - session = beast_impl::make_server_session(*this, executor, std::move(socket)); + session = beast_impl::make_server_session(*this, std::move(socket)); #endif } @@ -399,7 +398,16 @@ awaitable Server::Impl::tcp_accept_loop() size_t sessionCounter = 0; for (;;) { - auto [ec, socket] = co_await acceptor.async_accept(as_tuple(deferred)); + // + // Put each connection on a strand if needed. The socket is accepted onto that executor, + // so that everything layered on top of it stays there, too: the session takes its executor + // from the stream it is given, see the make_*_session() factories. + // + // NOTE: This is slow. Consider multiple IO contexts instead, + // or explicit thread pools where really needed. + // + ip::tcp::socket socket(config().use_strand ? boost::asio::make_strand(executor) : executor); + auto [ec] = co_await acceptor.async_accept(socket, as_tuple); if (ec) { if (ec == boost::system::errc::operation_canceled) @@ -421,14 +429,9 @@ awaitable Server::Impl::tcp_accept_loop() ++sessionCounter; } - // - // Put each connection on a strand if needed. - // - // NOTE: This is slow. Consider multiple IO contexts instead, - // or explicit thread pools where really needed. - // - co_spawn(config().use_strand ? boost::asio::make_strand(executor) : executor, - handle_connection(std::move(socket)), [&, ep](const std::exception_ptr& ex) mutable + auto connection_executor = socket.get_executor(); + co_spawn(connection_executor, handle_connection(std::move(socket)), + [&, ep](const std::exception_ptr& ex) mutable { auto lock = std::lock_guard(m_sessionMutex); --sessionCounter; From 6515ef520b845f48d9b07bbd215f2a7a1b4fbb89 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Sat, 19 Sep 2026 07:19:16 +0000 Subject: [PATCH 8/9] any_async_stream: move it to detail/ and hide its implementation The type-erased stream, the TLS detection and the HTTP/2 preface detection are internals of the server, not part of its interface, so they move to include/anyhttp/detail/ -- h2_detect.hpp as detect_h2.hpp, to match the name of the operation it declares. AnyAsyncStream becomes any_async_stream, like the other stream types it stands in for. Its implementation is now only forward declared. The buffer-erasing async operations stay inline, but the buffer sequence is copied into the buffer_array before async_initiate(), so the initiation can forward to an out-of-line member instead of dereferencing the incomplete implementation. The implementation itself lives in detail/any_async_stream_impl.hpp, which only src/any_async_stream_impl.cpp includes. What used to be TestStream in server_impl.cpp is a template over the stream now, taking its socket and executor from stream_traits instead of assuming a bare one, and is explicitly instantiated for a TCP socket and a TLS stream -- the same pattern the session backends use. Callers get make_any_async_stream(), because converting a unique_ptr to the base is no longer possible where the base is incomplete. get_socket() returns TcpSocketBase rather than an ip::tcp::socket, which is what a TLS stream can actually hand out and all a session needs for shutdown() and close(). The alias moves next to the stream returning it, together with SslStream, which the two backends had been spelling out separately. Co-Authored-By: Claude Opus 5 --- .../anyhttp/{ => detail}/any_async_stream.hpp | 105 +++++++++++------- .../anyhttp/detail/any_async_stream_impl.hpp | 93 ++++++++++++++++ .../{h2_detect.hpp => detail/detect_h2.hpp} | 0 include/anyhttp/{ => detail}/detect_ssl.hpp | 0 include/anyhttp/h1_backend.hpp | 6 +- include/anyhttp/h2_backend.hpp | 4 +- include/anyhttp/stream_traits.hpp | 20 ++-- src/any_async_stream_impl.cpp | 48 ++++++++ src/h1_session.cpp | 6 +- src/h2_session.cpp | 2 +- src/server_impl.cpp | 35 +----- 11 files changed, 226 insertions(+), 93 deletions(-) rename include/anyhttp/{ => detail}/any_async_stream.hpp (56%) create mode 100644 include/anyhttp/detail/any_async_stream_impl.hpp rename include/anyhttp/{h2_detect.hpp => detail/detect_h2.hpp} (100%) rename include/anyhttp/{ => detail}/detect_ssl.hpp (100%) create mode 100644 src/any_async_stream_impl.cpp diff --git a/include/anyhttp/any_async_stream.hpp b/include/anyhttp/detail/any_async_stream.hpp similarity index 56% rename from include/anyhttp/any_async_stream.hpp rename to include/anyhttp/detail/any_async_stream.hpp index dacd06d..36ed4b2 100644 --- a/include/anyhttp/any_async_stream.hpp +++ b/include/anyhttp/detail/any_async_stream.hpp @@ -1,21 +1,26 @@ #pragma once +// +// The type-erased async stream as its users see it. The implementation behind it is only forward +// declared here: it lives in anyhttp/detail/any_async_stream_impl.hpp, which src/ +// any_async_stream_impl.cpp is the only place to include -- and to instantiate. +// + #include #include #include -#include -#include +#include #include -#include -#include #include - -#include +#include #include #include +#include +#include + namespace asio = boost::asio; namespace ip = asio::ip; @@ -33,6 +38,16 @@ using MutableBufferVector = mutable_buffer_array<16>; using Shutdown = void(boost::system::error_code); using ShutdownHandler = asio::any_completion_handler; +/** + * The socket underneath a stream, as far as the sessions care about it: enough for shutdown() and + * close(), which is all they do with it. A TLS stream hands out its \c lowest_layer(), which is + * this rather than the full ip::tcp::socket, so this is what the type-erased stream offers, too. + */ +using TcpSocketBase = asio::basic_socket; + +/// The TLS stream the server and client run on, spelled out often enough to deserve a name. +using SslStream = asio::ssl::stream; + /** * Attempt to create a type-erased async stream with ASIO. * @@ -43,39 +58,21 @@ using ShutdownHandler = asio::any_completion_handler; * There is also \c asio::buffer_sequence_adapter and \c linearise(), which seem to be used in ASIO * SSL code as well. It merges a set of buffers into a new, contiguous buffer. But that is slow. */ -class AnyAsyncStream +class any_async_stream { public: using executor_type = boost::asio::any_io_executor; - class Impl - { - public: - virtual ~Impl() = default; - - using executor_type = boost::asio::any_io_executor; - virtual executor_type get_executor() noexcept = 0; - virtual ip::tcp::socket& get_socket() = 0; - - virtual void async_write_some(ReadWriteHandler handler, ConstBufferVector buffer) = 0; - virtual void async_read_some(ReadWriteHandler handler, MutableBufferVector buffer) = 0; - virtual void async_shutdown_impl(ShutdownHandler handler) - { - auto ex = boost::asio::get_associated_immediate_executor(handler, get_executor()); - ex.execute([handler = std::move(handler)]() mutable { // - std::move(handler)(boost::system::error_code()); - }); - } - }; - -protected: - std::unique_ptr impl; + /// The type-erased stream itself, defined in anyhttp/detail/any_async_stream_impl.hpp. + class Impl; -public: - AnyAsyncStream(std::unique_ptr impl_) : impl(std::move(impl_)) {} + explicit any_async_stream(std::unique_ptr impl); + any_async_stream(any_async_stream&&) noexcept; + any_async_stream& operator=(any_async_stream&&) noexcept; + ~any_async_stream(); - executor_type get_executor() noexcept { return impl->get_executor(); } - ip::tcp::socket& get_socket() { return impl->get_socket(); } + executor_type get_executor() noexcept; + TcpSocketBase& get_socket(); // // async_write_some @@ -106,10 +103,10 @@ class AnyAsyncStream CompletionToken&& token = CompletionToken()) { return boost::asio::async_initiate( - [this](ReadWriteHandler handler, const ConstBufferSequence& buffers) + [this](ReadWriteHandler handler, ConstBufferVector buffers) { // - impl->async_write_some(std::move(handler), ConstBufferVector{buffers}); - }, token, buffers); + write_some(std::move(handler), std::move(buffers)); + }, token, ConstBufferVector{buffers}); } // @@ -123,14 +120,42 @@ class AnyAsyncStream CompletionToken&& token = CompletionToken()) { return boost::asio::async_initiate( - [this](ReadWriteHandler handler, const MutableBufferSequence& buffers) + [this](ReadWriteHandler handler, MutableBufferVector buffers) { // - impl->async_read_some(std::move(handler), MutableBufferVector{buffers}); - }, token, buffers); + read_some(std::move(handler), std::move(buffers)); + }, token, MutableBufferVector{buffers}); } + +private: + // + // The initiations, with the buffer sequence already type-erased. Out of line, because this is + // where the implementation is dereferenced -- it is incomplete here. + // + void write_some(ReadWriteHandler handler, ConstBufferVector buffers); + void read_some(ReadWriteHandler handler, MutableBufferVector buffers); + + std::unique_ptr impl; }; -static_assert(boost::beast::is_async_stream::value); +static_assert(boost::beast::is_async_stream::value); + +// ------------------------------------------------------------------------------------------------- + +// +// The stream is moved into the type-erasing wrapper -- hence the rvalue reference, which the +// constraint keeps from matching an lvalue, just like the session factories do it. +// +// This is defined in anyhttp/detail/any_async_stream_impl.hpp and explicitly instantiated in +// src/any_async_stream_impl.cpp for each of the stream types below, so that the implementation +// is instantiated in that one place only. +// + +template + requires(!std::is_reference_v) +any_async_stream make_any_async_stream(Stream&& stream); + +extern template any_async_stream make_any_async_stream(ip::tcp::socket&&); +extern template any_async_stream make_any_async_stream(SslStream&&); // ================================================================================================= diff --git a/include/anyhttp/detail/any_async_stream_impl.hpp b/include/anyhttp/detail/any_async_stream_impl.hpp new file mode 100644 index 0000000..0434afc --- /dev/null +++ b/include/anyhttp/detail/any_async_stream_impl.hpp @@ -0,0 +1,93 @@ +#pragma once + +// +// Definition of the implementation behind anyhttp::any_async_stream, instantiated only by +// src/any_async_stream_impl.cpp -- make_any_async_stream() is what everyone else uses instead. +// + +#include "anyhttp/detail/any_async_stream.hpp" +#include "anyhttp/stream_traits.hpp" + +#include + +#include + +namespace anyhttp +{ + +// ================================================================================================= + +/** + * The type-erased stream, with the buffer sequences of the async operations erased as well: they + * arrive as a \c buffer_array, which is a buffer sequence itself and can be handed to the + * underlying stream unchanged. + */ +class any_async_stream::Impl +{ +public: + virtual ~Impl() = default; + + using executor_type = boost::asio::any_io_executor; + virtual executor_type get_executor() noexcept = 0; + virtual TcpSocketBase& get_socket() = 0; + + virtual void async_write_some(ReadWriteHandler handler, ConstBufferVector buffer) = 0; + virtual void async_read_some(ReadWriteHandler handler, MutableBufferVector buffer) = 0; + virtual void async_shutdown_impl(ShutdownHandler handler) + { + auto ex = boost::asio::get_associated_immediate_executor(handler, get_executor()); + ex.execute([handler = std::move(handler)]() mutable { // + std::move(handler)(boost::system::error_code()); + }); + } +}; + +// ------------------------------------------------------------------------------------------------- + +/** + * The implementation for a concrete stream, which is moved in and owned here -- among other things + * so that the socket underneath it stays around for cancellation. Everything beyond the async read + * and write operations comes from \c stream_traits, so this works for every stream a session can + * run on. + */ +template +class any_async_stream_impl final : public any_async_stream::Impl +{ +public: + explicit any_async_stream_impl(Stream stream) : m_stream(std::move(stream)) {} + + executor_type get_executor() noexcept override + { + return stream_traits::get_executor(m_stream); + } + TcpSocketBase& get_socket() override { return anyhttp::get_socket(m_stream); } + + void async_write_some(ReadWriteHandler handler, ConstBufferVector buffers) override + { + m_stream.async_write_some(buffers, std::move(handler)); + } + + void async_read_some(ReadWriteHandler handler, MutableBufferVector buffers) override + { + m_stream.async_read_some(buffers, std::move(handler)); + } + +private: + Stream m_stream; +}; + +// ------------------------------------------------------------------------------------------------- + +template + requires(!std::is_reference_v) +any_async_stream make_any_async_stream(Stream&& stream) +{ + return any_async_stream(std::make_unique>(std::move(stream))); +} + +extern template class any_async_stream_impl; +extern template class any_async_stream_impl; + +// ================================================================================================= + +} // namespace anyhttp diff --git a/include/anyhttp/h2_detect.hpp b/include/anyhttp/detail/detect_h2.hpp similarity index 100% rename from include/anyhttp/h2_detect.hpp rename to include/anyhttp/detail/detect_h2.hpp diff --git a/include/anyhttp/detect_ssl.hpp b/include/anyhttp/detail/detect_ssl.hpp similarity index 100% rename from include/anyhttp/detect_ssl.hpp rename to include/anyhttp/detail/detect_ssl.hpp diff --git a/include/anyhttp/h1_backend.hpp b/include/anyhttp/h1_backend.hpp index c5ffb5a..ab8e3f6 100644 --- a/include/anyhttp/h1_backend.hpp +++ b/include/anyhttp/h1_backend.hpp @@ -22,10 +22,6 @@ namespace anyhttp::beast_impl // ================================================================================================= -using SslStream = boost::asio::ssl::stream; - -// ------------------------------------------------------------------------------------------------- - // // The stream is moved into the session, which runs on the stream's own executor -- hence the // rvalue reference, which also keeps the SocketStream constraint from matching an lvalue. @@ -46,7 +42,7 @@ make_server_session(server::Server::Impl&, extern template std::shared_ptr make_server_session(server::Server::Impl&, SslStream&&); extern template std::shared_ptr -make_server_session(server::Server::Impl&, AnyAsyncStream&&); +make_server_session(server::Server::Impl&, any_async_stream&&); extern template std::shared_ptr make_client_session(client::Client::Impl&, diff --git a/include/anyhttp/h2_backend.hpp b/include/anyhttp/h2_backend.hpp index 00e63df..327a96d 100644 --- a/include/anyhttp/h2_backend.hpp +++ b/include/anyhttp/h2_backend.hpp @@ -25,8 +25,6 @@ namespace anyhttp::nghttp2 // ================================================================================================= -using SslStream = boost::asio::ssl::stream; - /** * A request received as HTTP/1.1 with "Upgrade: h2c" (RFC 7540, section 3.2) that has been answered * with "101 Switching Protocols". The HTTP/2 session continues it as stream 1. Only requests without @@ -62,7 +60,7 @@ make_server_session(server::Server::Impl&, extern template std::shared_ptr make_server_session(server::Server::Impl&, SslStream&&, std::optional); extern template std::shared_ptr -make_server_session(server::Server::Impl&, AnyAsyncStream&&, +make_server_session(server::Server::Impl&, any_async_stream&&, std::optional); extern template std::shared_ptr diff --git a/include/anyhttp/stream_traits.hpp b/include/anyhttp/stream_traits.hpp index 6e29079..d2f745a 100644 --- a/include/anyhttp/stream_traits.hpp +++ b/include/anyhttp/stream_traits.hpp @@ -2,14 +2,14 @@ // // The sessions are templates over the stream they run on, and there are four of those: a plain -// TCP socket, a TLS stream on top of one, beast's tcp_stream and the type-erased AnyAsyncStream. +// TCP socket, a TLS stream on top of one, beast's tcp_stream and the type-erased any_async_stream. // Beyond the async read and write operations, which all of them have in common already, a session // needs two more things from its stream: the underlying socket, to shut it down or close it, and // an executor to run its loops on. Neither is spelled the same way by all four, so they are // reached through this trait instead. // -#include "anyhttp/any_async_stream.hpp" +#include "anyhttp/detail/any_async_stream.hpp" #include #include @@ -62,9 +62,9 @@ struct stream_traits> /// The type-erased stream already offers both, its implementation has to provide them. template <> -struct stream_traits +struct stream_traits { - using stream_type = AnyAsyncStream; + using stream_type = any_async_stream; static auto& get_socket(stream_type& stream) noexcept { return stream.get_socket(); } static auto get_executor(stream_type& stream) noexcept { return stream.get_executor(); } @@ -72,12 +72,10 @@ struct stream_traits // ------------------------------------------------------------------------------------------------- -/** - * What the four get_socket() above have in common: a TLS stream hands out its \c lowest_layer(), - * which is this rather than the full ip::tcp::socket. It is enough for shutdown() and close(), - * which is all a session does with it. - */ -using TcpSocketBase = boost::asio::basic_socket; +// +// What the four get_socket() above have in common is TcpSocketBase, declared next to the +// type-erased stream, which returns it directly. +// /** * An async stream that is backed by a TCP socket and knows the executor it runs on -- in other @@ -108,7 +106,7 @@ decltype(auto) get_socket(Stream& stream) noexcept static_assert(SocketStream); static_assert(SocketStream>); static_assert(SocketStream); -static_assert(SocketStream); +static_assert(SocketStream); static_assert(!SocketStream); // rvalues only, see above // ================================================================================================= diff --git a/src/any_async_stream_impl.cpp b/src/any_async_stream_impl.cpp new file mode 100644 index 0000000..4328c85 --- /dev/null +++ b/src/any_async_stream_impl.cpp @@ -0,0 +1,48 @@ +#include "anyhttp/detail/any_async_stream_impl.hpp" + +namespace anyhttp +{ + +// ================================================================================================= + +// +// Everything that has to see the implementation, which is incomplete in the header the users of +// any_async_stream include. +// + +any_async_stream::any_async_stream(std::unique_ptr impl_) : impl(std::move(impl_)) {} +any_async_stream::any_async_stream(any_async_stream&&) noexcept = default; +any_async_stream& any_async_stream::operator=(any_async_stream&&) noexcept = default; +any_async_stream::~any_async_stream() = default; + +any_async_stream::executor_type any_async_stream::get_executor() noexcept +{ + return impl->get_executor(); +} + +TcpSocketBase& any_async_stream::get_socket() { return impl->get_socket(); } + +void any_async_stream::write_some(ReadWriteHandler handler, ConstBufferVector buffers) +{ + impl->async_write_some(std::move(handler), std::move(buffers)); +} + +void any_async_stream::read_some(ReadWriteHandler handler, MutableBufferVector buffers) +{ + impl->async_read_some(std::move(handler), std::move(buffers)); +} + +// ================================================================================================= +// The implementations, see anyhttp/detail/any_async_stream_impl.hpp. Instantiating them is kept to +// this translation unit, so that including the type-erased stream stays cheap. +// ================================================================================================= + +template class any_async_stream_impl; +template class any_async_stream_impl; + +template any_async_stream make_any_async_stream(ip::tcp::socket&&); +template any_async_stream make_any_async_stream(SslStream&&); + +// ================================================================================================= + +} // namespace anyhttp diff --git a/src/h1_session.cpp b/src/h1_session.cpp index 8dd4bd9..2fdf1a5 100644 --- a/src/h1_session.cpp +++ b/src/h1_session.cpp @@ -1,7 +1,7 @@ #include "anyhttp/h1_session.hpp" -#include "anyhttp/any_async_stream.hpp" #include "anyhttp/common.hpp" +#include "anyhttp/detail/any_async_stream.hpp" #include "anyhttp/formatter.hpp" // IWYU pragma: keep #include "anyhttp/h1_backend.hpp" #include "anyhttp/h2_backend.hpp" @@ -1290,7 +1290,7 @@ void ClientSession::reader_finished(bool complete) template class ServerSession; template class ServerSession>; -template class ServerSession; +template class ServerSession; // ================================================================================================= // Factories, see anyhttp/h1_backend.hpp. Instantiating the session templates is kept to this @@ -1316,7 +1316,7 @@ template std::shared_ptr make_server_session(server::Serv template std::shared_ptr make_server_session(server::Server::Impl&, SslStream&&); template std::shared_ptr -make_server_session(server::Server::Impl&, AnyAsyncStream&&); +make_server_session(server::Server::Impl&, any_async_stream&&); template std::shared_ptr make_client_session(client::Client::Impl&, socket&&); diff --git a/src/h2_session.cpp b/src/h2_session.cpp index b23a425..70ca792 100644 --- a/src/h2_session.cpp +++ b/src/h2_session.cpp @@ -686,7 +686,7 @@ make_server_session(server::Server::Impl&, socket&&, std::optional make_server_session(server::Server::Impl&, SslStream&&, std::optional); template std::shared_ptr -make_server_session(server::Server::Impl&, AnyAsyncStream&&, +make_server_session(server::Server::Impl&, any_async_stream&&, std::optional); template std::shared_ptr make_client_session(client::Client::Impl&, diff --git a/src/server_impl.cpp b/src/server_impl.cpp index 214ae13..efe2d0d 100644 --- a/src/server_impl.cpp +++ b/src/server_impl.cpp @@ -1,11 +1,11 @@ #include "anyhttp/server_impl.hpp" -#include "anyhttp/any_async_stream.hpp" -#include "anyhttp/detect_ssl.hpp" +#include "anyhttp/detail/any_async_stream.hpp" +#include "anyhttp/detail/detect_h2.hpp" +#include "anyhttp/detail/detect_ssl.hpp" #include "anyhttp/formatter.hpp" // IWYU pragma: keep #include "anyhttp/h1_backend.hpp" #include "anyhttp/h2_backend.hpp" -#include "anyhttp/h2_detect.hpp" #include "anyhttp/h3_backend.hpp" #include "anyhttp/tls.hpp" @@ -243,29 +243,6 @@ static asio::ssl::context make_tls_server_context() // ------------------------------------------------------------------------------------------------- -class TestStream : public AnyAsyncStream::Impl -{ -public: - TestStream(ip::tcp::socket socket) : socket_(std::move(socket)) {} - executor_type get_executor() noexcept override { return socket_.get_executor(); } - - ip::tcp::socket& get_socket() final { return socket_; } - void async_write_some(ReadWriteHandler handler, ConstBufferVector buffers) final - { - socket_.async_write_some(buffers, std::move(handler)); - } - - void async_read_some(ReadWriteHandler handler, MutableBufferVector buffers) final - { - socket_.async_read_some(buffers, std::move(handler)); - } - -private: - ip::tcp::socket socket_; // the underlying socket, for cancellation -}; - -// ------------------------------------------------------------------------------------------------- - awaitable Server::Impl::handle_connection(ip::tcp::socket socket) { const auto prefix = normalize(socket.remote_endpoint()); @@ -331,8 +308,7 @@ awaitable Server::Impl::handle_connection(ip::tcp::socket socket) { logi("[{}] detected HTTP2 client preface, {} bytes in buffer", prefix, buffer.size()); #if 1 - AnyAsyncStream stream(std::make_unique(std::move(socket))); - session = nghttp2::make_server_session(*this, std::move(stream)); + session = nghttp2::make_server_session(*this, make_any_async_stream(std::move(socket))); #else session = nghttp2::make_server_session(*this, std::move(socket)); #endif @@ -345,8 +321,7 @@ awaitable Server::Impl::handle_connection(ip::tcp::socket socket) { logi("[{}] no HTTP2 client preface, assuming HTTP/1.x", prefix); #if 1 - AnyAsyncStream stream(std::make_unique(std::move(socket))); - session = beast_impl::make_server_session(*this, std::move(stream)); + session = beast_impl::make_server_session(*this, make_any_async_stream(std::move(socket))); #else session = beast_impl::make_server_session(*this, std::move(socket)); #endif From b9c45d7be6d838bb1af8853c546ceeae79df7daf Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Sat, 19 Sep 2026 07:30:39 +0000 Subject: [PATCH 9/9] fix: enable clang-format for run function logging --- src/utils.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.cpp b/src/utils.cpp index 88da872..f9761ce 100644 --- a/src/utils.cpp +++ b/src/utils.cpp @@ -23,7 +23,7 @@ size_t run(boost::asio::io_context& context) std::println("--- {} ------------------------------------------------------------------------", i); else std::println("\x1b[1;31m--- {} ({}) ----------------------------------------------------------------\x1b[0m", i, dt); - // clang-format off + // clang-format on } return i; }