From 6a0d29007f54dfc7e9a3cb396361a7fdbadbdaf0 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Sat, 19 Sep 2026 21:34:33 +0000 Subject: [PATCH 01/19] server: serve every ALPN that is not "h2" as HTTP/1.1 A TLS client that negotiates no ALPN at all -- curl --no-alpn, or anything that offers only protocols the server does not know -- left `session` unset after the handshake, so the connection was dropped without an answer. Refusing those buys nothing: HTTP/1.1 is what a connection without a negotiated protocol speaks anyway. Co-Authored-By: Claude Opus 5 --- src/server_impl.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/server_impl.cpp b/src/server_impl.cpp index efe2d0d..a017540 100644 --- a/src/server_impl.cpp +++ b/src/server_impl.cpp @@ -295,9 +295,14 @@ awaitable Server::Impl::handle_connection(ip::tcp::socket socket) logi("[{}] TLS handshake completed: {}", prefix, tls_handshake_info(ssl_stream->native_handle())); + // + // Everything that is not "h2" is served as HTTP/1.1, including the empty ALPN of a client + // that offered none at all (curl --no-alpn) and one nobody agreed on. Refusing those would + // buy nothing: HTTP/1.1 is what a connection without a negotiated protocol speaks anyway. + // if (alpn == "h2") session = nghttp2::make_server_session(*this, std::move(*ssl_stream)); - else if (alpn == "http/1.1") + else session = beast_impl::make_server_session(*this, std::move(*ssl_stream)); } From 1e72c7ec059c9da30d58716c95eaeea1d240d9b4 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Sat, 19 Sep 2026 21:34:47 +0000 Subject: [PATCH 02/19] alt-svc: advertise the HTTP/3 endpoint, and follow it HTTP/3 is not something a TCP connection can turn into: there is no `Connection: Upgrade` on the way to QUIC. What there is instead is RFC 7838, where the server says where else it can be reached and the client makes its *next* connection there. The server puts its own HTTP/3 endpoint into every response it sends over HTTP/1.1 and HTTP/2, but never over HTTP/3, which is already there. As QUIC shares the endpoint the TCP acceptor is listening on, the alt-authority is a port and nothing else. `server::Config::alt_svc_max_age` is how long a client may remember it, and 0s advertises nothing at all. The client acts on it only with `client::Config::follow_alt_svc` set, and then it takes precedence over `Config::protocol`. It reads the header field from HTTP/1.1 and HTTP/2 responses and, over HTTP/2, the ALTSVC frame as well, which lets a server advertise before the first request has been sent. The alternative is kept for as long as `ma` says, but only in memory and only for the lifetime of the Client. The origin does not change with any of this, only where it is reached. The parser is shared by both sources and skips alternatives it does not understand instead of failing the whole field. Co-Authored-By: Claude Opus 5 --- README.md | 54 +++ include/anyhttp/alt_svc.hpp | 74 ++++ include/anyhttp/client.hpp | 13 + include/anyhttp/client_impl.hpp | 41 ++ include/anyhttp/detail/h2_session_details.hpp | 8 + include/anyhttp/h2_session.hpp | 16 + include/anyhttp/server.hpp | 13 + include/anyhttp/server_impl.hpp | 8 + src/alt_svc.cpp | 297 +++++++++++++ src/client_impl.cpp | 63 ++- src/h1_session.cpp | 15 + src/h2_session.cpp | 27 ++ src/h2_stream.cpp | 11 +- src/server_impl.cpp | 11 + src/server_main.cpp | 6 + test/test_alt_svc.cpp | 391 ++++++++++++++++++ test/test_external.cpp | 25 ++ test/test_fixtures.hpp | 3 + 18 files changed, 1074 insertions(+), 2 deletions(-) create mode 100644 include/anyhttp/alt_svc.hpp create mode 100644 src/alt_svc.cpp create mode 100644 test/test_alt_svc.cpp diff --git a/README.md b/README.md index 352aeba..fc4d0c6 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,60 @@ HTTP/2 and HTTP/3 have a limit of their own: the peer's `SETTINGS_MAX_CONCURRENT One difference remains to be decided: in HTTP/2 and HTTP/3, a stream counts against the limit until it is closed in *both* directions, that is, until its response has been received as well. Taken strictly, "max concurrent streams = 1" would forbid submitting the next request before the previous response has been read -- which is stricter than HTTP/1.1 pipelining as implemented. +## Moving to HTTP/3: Alt-Svc + +HTTP/3 runs on QUIC, and QUIC is not something a TCP connection can turn into: there is no +`Connection: Upgrade` on the way to HTTP/3, the way there is one from HTTP/1.1 to HTTP/2. What +there is instead is the server saying where else it can be reached +([RFC 7838](https://www.rfc-editor.org/rfc/rfc7838)), and the client making its *next* connection +there. + +The server does that for its own HTTP/3 endpoint, which shares the address and port the TCP +acceptor is listening on, so the advertised alt-authority is a port and nothing else -- an empty +host in one means "the host of the origin": + +``` +Alt-Svc: h3=":8080"; ma=86400 +``` + +It goes into every response sent over HTTP/1.1 and HTTP/2, but never over HTTP/3, which is already +there. `server::Config::alt_svc_max_age` is how long a client may remember it, and `0s` advertises +nothing at all. + +A client only acts on it with `client::Config::follow_alt_svc` set, and then it takes precedence +over `client::Config::protocol`: + +```c++ + client::Client client(executor, {.url = url, .protocol = Protocol::h2, .follow_alt_svc = true}); + + auto first = co_await client.async_connect(); // HTTP/2, and learns about the alternative + auto second = co_await client.async_connect(); // HTTP/3 +``` + +The session that learns about the alternative keeps speaking what it speaks -- a connection in the +middle of a request can not be moved -- and the alternative is remembered for as long as `ma` says, +but only for the lifetime of the `Client`: there is no cache on disk. The origin does not change +with any of this, only where it is reached: requests still go out with the authority of +`Config::url`. + +Over HTTP/2, an alternative may also arrive in an `ALTSVC` frame instead of a header field, which +lets a server advertise before the first request has even been sent. anyhttp's client reads both; +its server sends the header field only. + +`curl` does the same thing, which is the easy way to watch it happen -- it honours `Alt-Svc` for +`https://` origins only, so this needs TLS, and a cache file to remember the alternative between +invocations: + +```sh +./build/src/server -p 8080 +``` + +```sh +curl --alt-svc altsvc.txt --cacert pki/out/root.pem https://localhost:8080/echo -d hello -so/dev/null -w '%{http_version}\n' +``` + +The first run answers `2`, and every one after it `3`. + ## Links For now, this section contains just a set of random links collected during development. diff --git a/include/anyhttp/alt_svc.hpp b/include/anyhttp/alt_svc.hpp new file mode 100644 index 0000000..3417b86 --- /dev/null +++ b/include/anyhttp/alt_svc.hpp @@ -0,0 +1,74 @@ +#pragma once + +// +// Alternative services (RFC 7838): how a server tells a client that the origin it is talking to +// is also reachable somewhere else, over some other protocol. For anyhttp, that is the way from +// HTTP/1.1 or HTTP/2 to HTTP/3 -- QUIC has no in-band upgrade of its own, so an "Alt-Svc" naming +// "h3" is what an HTTP/3 "upgrade" amounts to: the *next* connection is made over QUIC. +// +// This is the parser for the field value alone, shared by the "Alt-Svc" header field and the +// HTTP/2 ALTSVC frame, which carry the very same syntax. What a client does with the result is +// in client_impl.hpp, what a server advertises in server_impl.cpp. +// + +#include +#include +#include +#include + +namespace anyhttp +{ + +// ================================================================================================= + +/** + * One advertised alternative: which protocol to speak, where to reach it, and for how long that + * is worth remembering. + */ +struct AltService +{ + std::string protocol; ///< ALPN protocol name, percent-decoding undone: "h3", "h2", ... + std::string host; ///< empty if the alternative is on the host of the origin itself + ///< (an IPv6 literal comes without its brackets) + std::string port; ///< decimal, never empty -- an alt-authority without a port is invalid + + /// The "ma" parameter: how long this may be used without being advertised again. + std::chrono::seconds max_age{86400}; // RFC 7838, section 3.1: 24 hours unless given + + /// The "persist=1" parameter: the alternative survives a change of network. + bool persist = false; +}; + +// ------------------------------------------------------------------------------------------------- + +/** + * The parsed value of an "Alt-Svc" header field or an HTTP/2 ALTSVC frame. + */ +struct AltSvc +{ + /// The field value was "clear": everything known about the origin is to be forgotten. + bool clear = false; + + // + // The advertised alternatives, in the order they were given, which is the order of the + // server's preference (RFC 7838, section 3). + // + std::vector services; + + /// The first alternative offering \p protocol, or nullptr if there is none. + const AltService* find(std::string_view protocol) const noexcept; +}; + +/** + * Parses an "Alt-Svc" field value, which is a list of alternatives, or the single token "clear". + * + * Nothing in here is fatal: an alternative that does not parse is skipped, and so is one whose + * parameters are malformed -- a client is free to ignore what it does not understand (RFC 7838, + * section 3), and the alternatives it *can* read are still worth having. The result is empty for + * a field value that yields nothing at all. + */ +AltSvc parse_alt_svc(std::string_view value); + +// ================================================================================================= + +} // namespace anyhttp diff --git a/include/anyhttp/client.hpp b/include/anyhttp/client.hpp index 4d20c85..bf20bf2 100644 --- a/include/anyhttp/client.hpp +++ b/include/anyhttp/client.hpp @@ -32,6 +32,19 @@ struct Config // streams, the connection can not be used any more. // size_t max_header_size = default_max_header_size; + + // + // Whether to take up an HTTP/3 alternative service the server advertises (RFC 7838), as an + // "Alt-Svc" header field on a response or, over HTTP/2, an ALTSVC frame. QUIC has no in-band + // upgrade, so this is what an "upgrade" to HTTP/3 comes down to: the connection that learns + // about the alternative keeps speaking what it speaks, and the *next* async_connect() goes to + // the advertised endpoint over HTTP/3 instead of using \c protocol. + // + // The alternative is remembered for as long as its "ma" parameter says, which is 24 hours + // unless the server gives one, and only for as long as the Client itself lives -- there is no + // cache on disk, so a fresh process starts over with \c protocol. + // + bool follow_alt_svc = false; }; // ================================================================================================= diff --git a/include/anyhttp/client_impl.hpp b/include/anyhttp/client_impl.hpp index 37fe2d9..dc43b0a 100644 --- a/include/anyhttp/client_impl.hpp +++ b/include/anyhttp/client_impl.hpp @@ -5,6 +5,11 @@ #include #include +#include +#include +#include +#include + namespace anyhttp::client { @@ -50,6 +55,35 @@ class Client::Impl void async_connect(ConnectHandler handler); const Config& config() const { return m_config; } + // ---------------------------------------------------------------------------------------------- + + /** + * Where the origin is also reachable over HTTP/3, as a server has advertised it (RFC 7838). + * + * There is room for exactly one of these, not a cache keyed by origin: a Client connects to + * the one authority its Config::url names, so every response it ever sees comes from that + * same origin. + */ + struct AlternativeService + { + std::string host; ///< empty if the alternative is on the host of the origin itself + std::string port; + std::chrono::steady_clock::time_point expires; + }; + + /** + * Takes in an "Alt-Svc" field value seen by a session of this client -- a header field on a + * response, or the payload of an HTTP/2 ALTSVC frame, which have the same syntax. + * + * Only an "h3" alternative is remembered, and only with Config::follow_alt_svc set: HTTP/3 is + * the one thing anyhttp can move a *later* connection to, while a client already told to speak + * HTTP/1.1 or HTTP/2 has no use for an alternative offering those. + */ + void on_alt_svc(std::string_view field_value); + + /// The advertised HTTP/3 endpoint, as long as its "ma" has not run out. + std::optional alt_svc() const; + private: awaitable async_connect(); @@ -57,6 +91,13 @@ class Client::Impl Config m_config; asio::any_io_executor m_executor; std::optional m_resolver; + + // + // Sessions run on their own executor, which is not necessarily the one the next connect is + // made from, so this is reached from more than one thread. + // + mutable std::mutex m_altSvcMutex; + std::optional m_altSvc; }; // ================================================================================================= diff --git a/include/anyhttp/detail/h2_session_details.hpp b/include/anyhttp/detail/h2_session_details.hpp index 93a255e..7cacd52 100644 --- a/include/anyhttp/detail/h2_session_details.hpp +++ b/include/anyhttp/detail/h2_session_details.hpp @@ -187,6 +187,7 @@ ServerSession::ServerSession(server::Server::Impl& parent, any_io_execut : ServerReference(parent), super("\x1b[1;31mserver\x1b[0m", executor, std::move(stream)) { m_max_header_size = parent.config().max_header_size; + m_alt_svc = parent.alt_svc(); } // ------------------------------------------------------------------------------------------------- @@ -305,6 +306,13 @@ awaitable ClientSession::do_session(Buffer&& buffer) nghttp2_option_set_max_send_header_block_length(options.get(), 1_m); nghttp2_option_set_max_continuations(options.get(), max_continuations(m_max_header_size)); + // + // ALTSVC (RFC 7838, section 4) is an extension frame: without this, nghttp2 drops it before + // on_frame_recv_callback() ever sees it. It is how a server may advertise its HTTP/3 endpoint + // without waiting for a request, see Config::follow_alt_svc. + // + nghttp2_option_set_builtin_recv_extension_type(options.get(), NGHTTP2_ALTSVC); + if (auto rv = nghttp2_session_client_new2(&session, callbacks.get(), this, options.get())) throw std::runtime_error("nghttp2_session_client_new"); diff --git a/include/anyhttp/h2_session.hpp b/include/anyhttp/h2_session.hpp index 908c483..6f64382 100644 --- a/include/anyhttp/h2_session.hpp +++ b/include/anyhttp/h2_session.hpp @@ -111,6 +111,13 @@ class NGHttp2Session : public anyhttp::Session::Impl nghttp2_unique_ptr setup_callbacks(); + /** + * Called with the value of an "Alt-Svc" received from the peer, either as a response header + * field or as an ALTSVC frame (RFC 7838). A server has nothing to do with one, so this does + * nothing unless the session is a client's, see ClientSession. + */ + virtual void on_alt_svc(std::string_view field_value) {} + NGHttp2Stream* create_stream(int32_t stream_id); NGHttp2Stream* find_stream(int32_t stream_id); void close_stream(int32_t stream_id); @@ -128,6 +135,12 @@ class NGHttp2Session : public anyhttp::Session::Impl /// The largest header section accepted from the peer, see Config::max_header_size. size_t m_max_header_size = default_max_header_size; + // + // What to advertise as this origin's HTTP/3 endpoint in every response, see + // server::Config::alt_svc_max_age. Only a server session ever has one. + // + std::string m_alt_svc; + Buffer m_buffer; }; @@ -180,6 +193,7 @@ class ServerSession : public ServerReference, public NGHttp2SessionImpl using super::recv_loop; using super::send_loop; + using super::m_alt_svc; using super::m_buffer; using super::m_max_header_size; using super::m_stream; @@ -232,6 +246,8 @@ class ClientSession : public ClientReference, public NGHttp2SessionImpl ClientSession(client::Client::Impl& parent, any_io_executor executor, Stream&& stream); awaitable do_session(Buffer&& data) override; + + void on_alt_svc(std::string_view field_value) override { client().on_alt_svc(field_value); } }; // ================================================================================================= diff --git a/include/anyhttp/server.hpp b/include/anyhttp/server.hpp index ab50e4d..fe8b536 100644 --- a/include/anyhttp/server.hpp +++ b/include/anyhttp/server.hpp @@ -43,6 +43,19 @@ struct Config // size_t max_header_size = default_max_header_size; + // + // How long a client may remember the HTTP/3 endpoint this server advertises in every response + // it sends over HTTP/1.1 and HTTP/2, as "Alt-Svc: h3=\":\"; ma=" (RFC 7838). + // A client that takes it up makes its *next* connection over QUIC -- there is no in-band + // upgrade to HTTP/3, so this is the whole of it. HTTP/3 shares the endpoint the TCP acceptor + // is listening on, so what is advertised is the port and nothing else: the same host, over + // QUIC. Zero sends no "Alt-Svc" at all. + // + // Advertising over cleartext HTTP is of no use to browsers and curl, which honour "Alt-Svc" + // for https:// origins only -- the alternative has to be at least as secure as the origin. + // + std::chrono::seconds alt_svc_max_age = 24h; + // // HTTP/3 only: how long a QUIC connection may go without a packet from its peer before it is // dropped. This is the only way a peer that vanished without a CONNECTION_CLOSE -- a killed diff --git a/include/anyhttp/server_impl.hpp b/include/anyhttp/server_impl.hpp index 858da93..4fd8f7f 100644 --- a/include/anyhttp/server_impl.hpp +++ b/include/anyhttp/server_impl.hpp @@ -74,6 +74,13 @@ class Server::Impl : public std::enable_shared_from_this // boost::asio::ssl::context& tls_context() noexcept { return m_tlsContext; } + // + // The "Alt-Svc" field value pointing at this server's HTTP/3 endpoint, put into every response + // sent over HTTP/1.1 and HTTP/2, see Config::alt_svc_max_age. Empty when there is nothing to + // advertise, which is also what HTTP/3 sessions see -- they are already there. + // + const std::string& alt_svc() const noexcept { return m_altSvc; } + asio::awaitable tcp_accept_loop(); asio::awaitable handle_connection(asio::ip::tcp::socket socket); @@ -101,6 +108,7 @@ class Server::Impl : public std::enable_shared_from_this boost::asio::any_io_executor m_executor; boost::asio::ssl::context m_tlsContext; std::optional m_acceptor; + std::string m_altSvc; std::mutex m_sessionMutex; std::set> m_sessions; diff --git a/src/alt_svc.cpp b/src/alt_svc.cpp new file mode 100644 index 0000000..8726c93 --- /dev/null +++ b/src/alt_svc.cpp @@ -0,0 +1,297 @@ +#include "anyhttp/alt_svc.hpp" + +#include +#include +#include +#include + +namespace anyhttp +{ + +// ================================================================================================= + +namespace +{ + +/// A token character, as defined for HTTP field values (RFC 9110, section 5.6.2). +constexpr bool is_tchar(char c) noexcept +{ + constexpr std::string_view special = "!#$%&'*+-.^_`|~"; + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + special.find(c) != std::string_view::npos; +} + +/// Optional whitespace, which may appear around the separators of a field value. +constexpr bool is_ows(char c) noexcept { return c == ' ' || c == '\t'; } + +// ------------------------------------------------------------------------------------------------- + +/** + * Undoes the percent-encoding an ALPN protocol name is advertised with (RFC 7838, section 3): + * anything outside the token characters has to be escaped, so "h3" may arrive as "%68%33". + * + * A "%" that is not followed by two hex digits is not an escape and stays as it is. + */ +std::string pct_decode(std::string_view in) +{ + std::string out; + out.reserve(in.size()); + for (size_t i = 0; i < in.size(); ++i) + { + unsigned value = 0; + const auto* first = in.data() + i + 1; + const auto* last = first + 2; + if (in[i] == '%' && i + 2 < in.size() && + std::from_chars(first, last, value, 16) == std::from_chars_result{last, std::errc{}}) + { + out.push_back(static_cast(value)); + i += 2; + } + else + out.push_back(in[i]); + } + return out; +} + +/** + * Splits an alt-authority, "[uri-host] ':' port", into host and port. The host may be empty, + * meaning the host of the origin itself, or an IPv6 literal in brackets, which brings colons of + * its own -- so the port is looked for behind the closing bracket, and the brackets are taken + * off: what is handed out is a host a resolver can be given as it is. + */ +bool split_authority(std::string_view authority, std::string& host, std::string& port) +{ + size_t colon = std::string_view::npos; + bool literal = false; + if (authority.starts_with('[')) + { + if (auto bracket = authority.find(']'); bracket != std::string_view::npos) + { + colon = authority.find(':', bracket); + literal = true; + } + } + else + colon = authority.rfind(':'); + + if (colon == std::string_view::npos) + return false; + + const auto digits = authority.substr(colon + 1); + if (digits.empty() || !std::ranges::all_of(digits, [](char c) { return c >= '0' && c <= '9'; })) + return false; + + auto uri_host = authority.substr(0, colon); + if (literal) + uri_host = uri_host.substr(1, uri_host.size() - 2); + + host = uri_host; + port = digits; + return true; +} + +// ------------------------------------------------------------------------------------------------- + +/// Just enough of a field value parser for the grammar of RFC 7838, section 3. +class Parser +{ +public: + explicit Parser(std::string_view input) noexcept : m_input(input) {} + + bool eof() const noexcept { return m_pos >= m_input.size(); } + bool next_is(char c) const noexcept { return !eof() && m_input[m_pos] == c; } + + void skip_ows() noexcept + { + while (!eof() && is_ows(m_input[m_pos])) + ++m_pos; + } + + bool consume(char c) noexcept + { + if (!next_is(c)) + return false; + ++m_pos; + return true; + } + + std::string_view token() noexcept + { + const auto start = m_pos; + while (!eof() && is_tchar(m_input[m_pos])) + ++m_pos; + return m_input.substr(start, m_pos - start); + } + + /// Reads a quoted string and returns its content, with the backslash escapes resolved. + std::optional quoted_string() + { + if (!consume('"')) + return std::nullopt; + + std::string result; + while (!eof()) + { + char c = m_input[m_pos++]; + if (c == '"') + return result; + if (c == '\\' && !eof()) + c = m_input[m_pos++]; + result.push_back(c); + } + + return std::nullopt; // unterminated + } + + /// A token or a quoted string, which is what a parameter value may be. + std::optional token_or_quoted_string() + { + if (next_is('"')) + return quoted_string(); + if (auto value = token(); !value.empty()) + return std::string(value); + return std::nullopt; + } + + // + // Moves past the next comma that is not inside a quoted string, so that parsing can go on with + // the next alternative after one that could not be read. + // + void skip_element() noexcept + { + bool quoted = false; + while (!eof()) + { + const char c = m_input[m_pos++]; + if (!quoted) + { + if (c == ',') + return; + quoted = c == '"'; + } + else if (c == '\\' && !eof()) + ++m_pos; + else if (c == '"') + quoted = false; + } + } + +private: + std::string_view m_input; + size_t m_pos = 0; +}; + +// ------------------------------------------------------------------------------------------------- + +/// Parses one alt-value: an alternative and the parameters that go with it. +std::optional parse_alt_value(Parser& parser) +{ + const auto protocol = parser.token(); + if (protocol.empty() || !parser.consume('=')) + return std::nullopt; + + const auto authority = parser.quoted_string(); + if (!authority) + return std::nullopt; + + AltService service; + service.protocol = pct_decode(protocol); + if (!split_authority(*authority, service.host, service.port)) + return std::nullopt; + + // + // Parameters. A repeated one is ignored (RFC 7838, section 3: they "MUST NOT occur more than + // once"), as is one we don't know. + // + bool have_max_age = false; + bool have_persist = false; + for (parser.skip_ows(); parser.consume(';'); parser.skip_ows()) + { + parser.skip_ows(); + const auto name = parser.token(); + if (name.empty() || !parser.consume('=')) + return std::nullopt; + + const auto value = parser.token_or_quoted_string(); + if (!value) + return std::nullopt; + + if (name == "ma" && !std::exchange(have_max_age, true)) + { + const auto* last = value->data() + value->size(); + if (uint64_t seconds = 0; std::from_chars(value->data(), last, seconds) == + std::from_chars_result{last, std::errc{}}) + service.max_age = std::chrono::seconds{seconds}; + } + else if (name == "persist" && !std::exchange(have_persist, true)) + service.persist = *value == "1"; + } + + return service; +} + +} // namespace + +// ================================================================================================= + +const AltService* AltSvc::find(std::string_view protocol) const noexcept +{ + for (const auto& service : services) + if (service.protocol == protocol) + return &service; + + return nullptr; +} + +// ------------------------------------------------------------------------------------------------- + +AltSvc parse_alt_svc(std::string_view value) +{ + // + // "clear" is the whole field value and nothing else -- as a list element, it is just an + // alternative with a missing alt-authority, and ignored like any other malformed one. + // + auto trimmed = value; + while (!trimmed.empty() && is_ows(trimmed.front())) + trimmed.remove_prefix(1); + while (!trimmed.empty() && is_ows(trimmed.back())) + trimmed.remove_suffix(1); + if (trimmed == "clear") + return {.clear = true}; + + AltSvc result; + Parser parser(value); + for (;;) + { + parser.skip_ows(); + if (parser.eof()) + break; + + // + // The list may have empty elements, which are skipped (RFC 9110, section 5.6.1). + // + if (parser.consume(',')) + continue; + + if (auto service = parse_alt_value(parser)) + { + result.services.push_back(std::move(*service)); + + // + // An alternative has to be followed by a comma or the end of the field value. Anything + // else means we are out of step with the sender, and the rest of this element goes. + // + parser.skip_ows(); + if (parser.eof() || parser.consume(',')) + continue; + } + + parser.skip_element(); + } + + return result; +} + +// ================================================================================================= + +} // namespace anyhttp diff --git a/src/client_impl.cpp b/src/client_impl.cpp index 63eb720..f6d5b04 100644 --- a/src/client_impl.cpp +++ b/src/client_impl.cpp @@ -1,4 +1,5 @@ #include "anyhttp/client_impl.hpp" +#include "anyhttp/alt_svc.hpp" #include "anyhttp/common.hpp" #include "anyhttp/formatter.hpp" // IWYU pragma: keep #include "anyhttp/h1_backend.hpp" @@ -60,6 +61,51 @@ Client::Impl::~Impl() { logi("Client: dtor"); } // ------------------------------------------------------------------------------------------------- +void Client::Impl::on_alt_svc(std::string_view field_value) +{ + if (!config().follow_alt_svc) + return; + + const auto alt_svc = parse_alt_svc(field_value); + + // + // "clear" tells us to forget what we know, and so does an alternative with "ma=0": it expires + // the moment it arrives (RFC 7838, section 3.1). + // + const auto* service = alt_svc.find("h3"); + if (alt_svc.clear || (service && service->max_age.count() == 0)) + { + auto lock = std::lock_guard(m_altSvcMutex); + if (m_altSvc) + logi("Client: Alt-Svc: dropping the HTTP/3 alternative"); + m_altSvc.reset(); + return; + } + + if (!service) + return; + + logi("Client: Alt-Svc: HTTP/3 at {}:{} for {}s", // + service->host.empty() ? config().url.host_address() : service->host, service->port, + service->max_age.count()); + + auto lock = std::lock_guard(m_altSvcMutex); + m_altSvc = AlternativeService{.host = service->host, + .port = service->port, + .expires = std::chrono::steady_clock::now() + service->max_age}; +} + +std::optional Client::Impl::alt_svc() const +{ + auto lock = std::lock_guard(m_altSvcMutex); + if (m_altSvc && m_altSvc->expires <= std::chrono::steady_clock::now()) + return std::nullopt; // its "ma" has run out; the next advertisement overwrites it + + return m_altSvc; +} + +// ------------------------------------------------------------------------------------------------- + // // void Client::Impl::async_connect(ConnectHandler handler) @@ -92,6 +138,20 @@ awaitable Client::Impl::async_connect() std::string host = config().url.host_address(); std::string port = config().url.port(); + // + // An HTTP/3 alternative service a previous session was told about takes precedence over the + // configured protocol -- that is what following it means, see Config::follow_alt_svc. The + // origin does not change with it, only where it is reached: requests still go out with the + // authority of config().url. + // + if (auto alt = alt_svc()) + { + auto alt_host = alt->host.empty() ? host : alt->host; + logi("Client: connecting to {}:{} over HTTP/3, as advertised by Alt-Svc", alt_host, + alt->port); + co_return Session{co_await async_connect_http3(m_executor, alt_host, alt->port, config())}; + } + // // HTTP/3 runs over QUIC (UDP), so it needs an entirely different transport setup (TLS, // handshake, ...) than the TCP-based http11/h2 paths below. @@ -146,7 +206,8 @@ awaitable Client::Impl::async_connect() // There are different types of upgrades: // // 1) HTTP/1.1 to HTTP/2 via Connection: upgrade header - // 2) HTTP/1.1 to HTTP/3 via Alt-Svc header + // 2) HTTP/1.1 or HTTP/2 to HTTP/3 via Alt-Svc -- implemented, see Config::follow_alt_svc and + // on_alt_svc() above, which the sessions feed // 3) Proactively connect using HTTP/1 (using TCP) and HTTP/3 (UDP) in parallel // 4) Support DNS HTTPS RR (serving the same purpose as Alt-Svc) // diff --git a/src/h1_session.cpp b/src/h1_session.cpp index 2fdf1a5..ace6268 100644 --- a/src/h1_session.cpp +++ b/src/h1_session.cpp @@ -750,6 +750,13 @@ class RequestWriter for (const auto& header : msg) mlogd(" \x1b[1;34m{}\x1b[0m: {}", truncated(header.name_string()), truncated(header.value())); + + // + // An "Alt-Svc" on any response may point at an HTTP/3 endpoint to use for the next + // connection (RFC 7838), see Client::Impl::on_alt_svc(). + // + if (auto alt_svc = msg[http::field::alt_svc]; session && !alt_svc.empty()) + client_session().client().on_alt_svc(std::string_view(alt_svc)); } else mlogw("async_read_header: {} len={}", ec.message(), len); @@ -1066,6 +1073,14 @@ awaitable ServerSession::do_session(Buffer&& buffer) http::response_serializer& serializer = writer->serializer; response.set(http::field::server, "anyhttp"); + // + // Point the client at our HTTP/3 endpoint, see server::Config::alt_svc_max_age. Set before + // the handler runs, so one that wants to say something else about alternative services can + // simply pass its own field: submitting a response replaces the fields it names. + // + if (const auto& alt_svc = server().alt_svc(); !alt_svc.empty()) + response.set(http::field::alt_svc, alt_svc); + // // Call user-provided request handler. // diff --git a/src/h2_session.cpp b/src/h2_session.cpp index 70ca792..c01a262 100644 --- a/src/h2_session.cpp +++ b/src/h2_session.cpp @@ -239,6 +239,24 @@ int on_invalid_frame_recv_callback(nghttp2_session* session, const nghttp2_frame int on_frame_recv_callback(nghttp2_session* session, const nghttp2_frame* frame, void* user_data) { const auto handler = static_cast(user_data); + + // + // ALTSVC is an extension frame, and only ever received when it has been enabled for the + // session -- which the client does and the server doesn't, see ClientSession::do_session(). It + // may arrive on stream 0, carrying the origin it is about, or on a request stream, where the + // origin is that of the request. Either way it is handled before the stream is looked up: an + // ALTSVC for a stream that is already gone is to be ignored (RFC 7838, section 4), not + // answered with the RST_STREAM below. + // + if (frame->hd.type == NGHTTP2_ALTSVC) + { + const auto* altsvc = static_cast(frame->ext.payload); + const auto value = make_string_view(altsvc->field_value, altsvc->field_value_len); + logd("[{}] on_frame_recv_callback: ALTSVC: {}", handler->logPrefix(frame), value); + handler->on_alt_svc(value); + return 0; + } + const auto stream = handler->find_stream(frame->hd.stream_id); if (!stream && frame->hd.stream_id > 0) @@ -278,7 +296,16 @@ int on_frame_recv_callback(nghttp2_session* session, const nghttp2_frame* frame, if (frame->headers.cat == NGHTTP2_HCAT_REQUEST) stream->on_request(); else if (frame->headers.cat == NGHTTP2_HCAT_RESPONSE) + { + // + // A response may carry an alternative service as a header field instead of, or as well + // as, in an ALTSVC frame -- the two say the same thing in the same syntax. + // + if (auto alt_svc = stream->fields["alt-svc"]; !alt_svc.empty()) + handler->on_alt_svc(std::string_view(alt_svc)); + stream->on_response(); + } // end of of stream already? --> no body if (frame->hd.flags & NGHTTP2_FLAG_END_STREAM) diff --git a/src/h2_stream.cpp b/src/h2_stream.cpp index 2ef0fdb..df604f2 100644 --- a/src/h2_stream.cpp +++ b/src/h2_stream.cpp @@ -224,10 +224,19 @@ void NGHttp2Writer::async_submit(StatusHandler&& handler, unsigned int sta const std::string date = format_http_date(std::chrono::system_clock::now()); auto nva = boost::container::small_vector(); - nva.reserve(3 + std::distance(headers.begin(), headers.end())); + nva.reserve(4 + std::distance(headers.begin(), headers.end())); nva.push_back(make_nv_ls(":status", status_code_str)); nva.push_back(make_nv_ls("date", date)); + // + // Point the client at our HTTP/3 endpoint, see server::Config::alt_svc_max_age. Only a server + // session has one of these, and a handler that names the field itself gets its way: HTTP/2 + // would happily carry both, which is not what "Alt-Svc" means. + // + if (const auto& alt_svc = stream->parent.m_alt_svc; + !alt_svc.empty() && !headers.count("alt-svc")) + nva.push_back(make_nv_ls("alt-svc", alt_svc)); + for (auto&& item : headers) { if (item.name_string().starts_with(':')) diff --git a/src/server_impl.cpp b/src/server_impl.cpp index a017540..65d7bdf 100644 --- a/src/server_impl.cpp +++ b/src/server_impl.cpp @@ -73,6 +73,17 @@ Server::Impl::Impl(boost::asio::any_io_executor executor, Config config) // auto tcp_ep = m_acceptor->local_endpoint(); m_http3 = make_http3_server(*this, ip::udp::endpoint{tcp_ep.address(), tcp_ep.port()}); + + // + // Advertise that endpoint to HTTP/1.1 and HTTP/2 clients, see Config::alt_svc_max_age. The + // alt-authority carries the port alone: an empty host in one means the host of the origin + // itself, which is exactly where HTTP/3 is, one transport over. + // + if (m_http3 && m_config.alt_svc_max_age > 0s) + { + m_altSvc = std::format("h3=\":{}\"; ma={}", tcp_ep.port(), m_config.alt_svc_max_age.count()); + logi("Server: advertising '{}'", m_altSvc); + } } // ------------------------------------------------------------------------------------------------- diff --git a/src/server_main.cpp b/src/server_main.cpp index 603897b..b478222 100644 --- a/src/server_main.cpp +++ b/src/server_main.cpp @@ -64,6 +64,10 @@ std::expected parseConfig(int argc, char* argv[]) opts("max-header-size", po::value(&config.server.max_header_size)->default_value(config.server.max_header_size), "largest request header section accepted, in bytes (answered with 431 if exceeded)"); + long alt_svc_max_age = config.server.alt_svc_max_age.count(); + opts("alt-svc-max-age", po::value(&alt_svc_max_age)->default_value(alt_svc_max_age), + "how long clients may remember the HTTP/3 endpoint advertised as 'Alt-Svc' over HTTP/1.1 " + "and HTTP/2, in seconds (0 advertises nothing)"); po::variables_map vm; try @@ -72,6 +76,8 @@ std::expected parseConfig(int argc, char* argv[]) po::store(parsed, vm); po::notify(vm); + config.server.alt_svc_max_age = std::chrono::seconds{std::max(0L, alt_svc_max_age)}; + // 'verbose' takes no argument, so its parsed value is always empty -- count occurrences config.verbose = std::ranges::count_if(parsed.options, [](const po::option& option) { return option.string_key == "verbose"; }); diff --git a/test/test_alt_svc.cpp b/test/test_alt_svc.cpp new file mode 100644 index 0000000..c04b946 --- /dev/null +++ b/test/test_alt_svc.cpp @@ -0,0 +1,391 @@ +#include "test_fixtures.hpp" + +#include "anyhttp/alt_svc.hpp" + +#include + +#include + +#include +#include + +using namespace testing; + +namespace http = boost::beast::http; + +// ================================================================================================= +// Parsing the field value, which is the same for the header field and the HTTP/2 ALTSVC frame. +// ================================================================================================= + +TEST(AltSvc, WHEN_a_single_alternative_is_given_THEN_it_is_parsed) +{ + auto alt_svc = parse_alt_svc(R"(h3=":443")"); + ASSERT_EQ(alt_svc.services.size(), 1u); + + const auto& service = alt_svc.services.front(); + EXPECT_EQ(service.protocol, "h3"); + EXPECT_EQ(service.host, ""); + EXPECT_EQ(service.port, "443"); + EXPECT_EQ(service.max_age, 24h) << "the default of RFC 7838, section 3.1"; + EXPECT_FALSE(service.persist); +} + +TEST(AltSvc, WHEN_parameters_are_given_THEN_they_are_parsed) +{ + auto alt_svc = parse_alt_svc(R"(h3="alt.example.com:8443"; ma=3600; persist=1)"); + ASSERT_EQ(alt_svc.services.size(), 1u); + + const auto& service = alt_svc.services.front(); + EXPECT_EQ(service.protocol, "h3"); + EXPECT_EQ(service.host, "alt.example.com"); + EXPECT_EQ(service.port, "8443"); + EXPECT_EQ(service.max_age, 1h); + EXPECT_TRUE(service.persist); +} + +TEST(AltSvc, WHEN_alternatives_are_listed_THEN_the_order_is_kept) +{ + auto alt_svc = parse_alt_svc(R"(h2=":443", h3=":443"; ma=60 , h3-29=":444")"); + ASSERT_EQ(alt_svc.services.size(), 3u); + EXPECT_EQ(alt_svc.services[0].protocol, "h2"); + EXPECT_EQ(alt_svc.services[1].protocol, "h3"); + EXPECT_EQ(alt_svc.services[2].protocol, "h3-29"); + + // + // find() answers with the first alternative for a protocol, which is the one the server + // prefers -- "h3-29" is a protocol of its own and not an "h3". + // + const auto* h3 = alt_svc.find("h3"); + ASSERT_NE(h3, nullptr); + EXPECT_EQ(h3->max_age, 60s); + EXPECT_EQ(alt_svc.find("h4"), nullptr); +} + +TEST(AltSvc, WHEN_the_host_is_an_IPv6_literal_THEN_the_brackets_are_taken_off) +{ + auto alt_svc = parse_alt_svc(R"(h3="[::1]:443")"); + ASSERT_EQ(alt_svc.services.size(), 1u); + EXPECT_EQ(alt_svc.services.front().host, "::1") << "a resolver wants it without them"; + EXPECT_EQ(alt_svc.services.front().port, "443"); +} + +TEST(AltSvc, WHEN_the_protocol_is_percent_encoded_THEN_it_is_decoded) +{ + auto alt_svc = parse_alt_svc(R"(%68%33=":443")"); + ASSERT_EQ(alt_svc.services.size(), 1u); + EXPECT_EQ(alt_svc.services.front().protocol, "h3"); +} + +TEST(AltSvc, WHEN_the_value_is_clear_THEN_it_says_so) +{ + EXPECT_TRUE(parse_alt_svc("clear").clear); + EXPECT_TRUE(parse_alt_svc(" clear ").clear); + EXPECT_TRUE(parse_alt_svc("clear").services.empty()); + + // "clear" is the whole field value and nothing else, so as a list element it is just garbage + auto alt_svc = parse_alt_svc(R"(clear, h3=":443")"); + EXPECT_FALSE(alt_svc.clear); + EXPECT_EQ(alt_svc.services.size(), 1u); +} + +TEST(AltSvc, WHEN_an_alternative_is_malformed_THEN_the_others_are_still_read) +{ + // + // A missing alt-authority, one without a port, an unterminated quoted string, a parameter + // without a value: every one of those takes its own alternative down and nothing else. + // + EXPECT_THAT(parse_alt_svc(R"(h3, h2=":443")").services, SizeIs(1)); + EXPECT_THAT(parse_alt_svc(R"(h3="example.com", h2=":443")").services, SizeIs(1)); + EXPECT_THAT(parse_alt_svc(R"(h3=":443"; ma, h2=":443")").services, SizeIs(1)); + EXPECT_THAT(parse_alt_svc(R"(h3="unterminated, h2=":443")").services, IsEmpty()); + EXPECT_THAT(parse_alt_svc(R"(h3=":443"; ma=x, h2=":443")").services, SizeIs(2)) + << "an unreadable parameter value leaves the alternative itself alone"; + + EXPECT_THAT(parse_alt_svc("").services, IsEmpty()); + EXPECT_THAT(parse_alt_svc(",,,").services, IsEmpty()); + EXPECT_THAT(parse_alt_svc("garbage").services, IsEmpty()); +} + +TEST(AltSvc, WHEN_the_alternative_expires_immediately_THEN_max_age_is_zero) +{ + auto alt_svc = parse_alt_svc(R"(h3=":443"; ma=0)"); + ASSERT_EQ(alt_svc.services.size(), 1u); + EXPECT_EQ(alt_svc.services.front().max_age, 0s); +} + +// ================================================================================================= +// Taking up the alternative: the server advertises its HTTP/3 endpoint over HTTP/1.1 and HTTP/2, +// and the next connection of a client that follows it is made over QUIC. +// ================================================================================================= + +// +// What a response says about the protocol that carried it: only the HTTP/3 server sends this +// "server" field, and only HTTP/1.1 and HTTP/2 advertise an alternative in the first place. +// +constexpr auto http3_server = "anyhttp-quic/0.1"sv; + +static bool served_over_http3(const client::Message& message) +{ + return message[http::field::server] == http3_server; +} + +class AltSvcUpgrade : public ClientAsync +{ +protected: + void configure_client(client::Config& config) override { config.follow_alt_svc = true; } + + /// The fixture's request handler knows this one, and it needs neither a body nor a handler. + boost::urls::url echo() const + { + auto target = url; + target.set_path("/echo"); + return target; + } +}; + +// +// Only HTTP/1.1 and HTTP/2 have anywhere to go: a client that already speaks HTTP/3 is there. +// +INSTANTIATE_TEST_SUITE_P(AltSvcUpgrade, AltSvcUpgrade, + Values(anyhttp::Protocol::http11, anyhttp::Protocol::h2), NameGenerator); + +TEST_P(AltSvcUpgrade, WHEN_the_server_advertises_h3_THEN_the_next_connection_uses_it) +{ + test = [this](Session session) -> awaitable + { + auto first = co_await session.async_get(echo()); + EXPECT_EQ(first.result_int(), 200); + EXPECT_FALSE(served_over_http3(first)); + EXPECT_THAT(std::string(first[http::field::alt_svc]), HasSubstr("h3=")); + + auto second = co_await (co_await client->async_connect()).async_get(echo()); + EXPECT_EQ(second.result_int(), 200); + EXPECT_TRUE(served_over_http3(second)) << "the second connection is not HTTP/3"; + EXPECT_EQ(second[http::field::alt_svc], "") + << "HTTP/3 has no alternative to advertise, it is the alternative"; + }; +} + +// +// The session that learns about the alternative keeps speaking what it speaks: there is no +// in-band upgrade to HTTP/3, and a connection in the middle of a request can not be moved. +// +TEST_P(AltSvcUpgrade, WHEN_the_alternative_is_learned_THEN_the_session_that_learned_it_stays) +{ + test = [this](Session session) -> awaitable + { + EXPECT_FALSE(served_over_http3(co_await session.async_get(echo()))); + EXPECT_FALSE(served_over_http3(co_await session.async_get(echo()))); + }; +} + +TEST_P(AltSvcUpgrade, WHEN_the_server_clears_the_alternative_THEN_it_is_not_used) +{ + custom = [](server::Request request, server::Response response) -> awaitable + { + co_await drain(request); + co_await response.async_submit(200, fields({{"Alt-Svc", "clear"}, {"Content-Length", 0}})); + co_await response.async_write_eof(); + }; + + test = [this](Session session) -> awaitable + { + EXPECT_THAT(std::string((co_await session.async_get(echo()))[http::field::alt_svc]), + HasSubstr("h3=")); + + auto cleared = co_await session.async_get(url); // "/custom", the handler above + EXPECT_EQ(cleared[http::field::alt_svc], "clear") << "the handler's field beats ours"; + + auto second = co_await (co_await client->async_connect()).async_get(echo()); + EXPECT_FALSE(served_over_http3(second)) << "the alternative should have been forgotten"; + }; +} + +// ------------------------------------------------------------------------------------------------- + +// +// Without Config::follow_alt_svc, the advertisement is still there to be seen, but nothing is +// done with it: a client asked for a protocol gets that protocol. +// +class AltSvcIgnored : public ClientAsync +{ +}; + +INSTANTIATE_TEST_SUITE_P(AltSvcIgnored, AltSvcIgnored, + Values(anyhttp::Protocol::http11, anyhttp::Protocol::h2), NameGenerator); + +TEST_P(AltSvcIgnored, WHEN_the_client_does_not_follow_alt_svc_THEN_it_keeps_its_protocol) +{ + test = [this](Session session) -> awaitable + { + auto target = url; + target.set_path("/echo"); + + auto first = co_await session.async_get(target); + EXPECT_THAT(std::string(first[http::field::alt_svc]), HasSubstr("h3=")); + + auto second = co_await (co_await client->async_connect()).async_get(target); + EXPECT_FALSE(served_over_http3(second)); + }; +} + +// ------------------------------------------------------------------------------------------------- + +// +// A server that advertises nothing, see server::Config::alt_svc_max_age. +// +class AltSvcDisabled : public ClientAsync +{ +protected: + void configure_server(server::Config& config) override { config.alt_svc_max_age = 0s; } + void configure_client(client::Config& config) override { config.follow_alt_svc = true; } +}; + +INSTANTIATE_TEST_SUITE_P(AltSvcDisabled, AltSvcDisabled, + Values(anyhttp::Protocol::http11, anyhttp::Protocol::h2), NameGenerator); + +TEST_P(AltSvcDisabled, WHEN_the_server_advertises_nothing_THEN_the_client_stays_where_it_is) +{ + test = [this](Session session) -> awaitable + { + auto target = url; + target.set_path("/echo"); + + auto first = co_await session.async_get(target); + EXPECT_EQ(first[http::field::alt_svc], ""); + + auto second = co_await (co_await client->async_connect()).async_get(target); + EXPECT_FALSE(served_over_http3(second)); + }; +} + +// ================================================================================================= +// The HTTP/2 ALTSVC frame (RFC 7838, section 4), which advertises without waiting for a request. +// ================================================================================================= + +namespace +{ + +nghttp2_nv nv(std::string_view name, std::string_view value) +{ + return {const_cast(reinterpret_cast(name.data())), + const_cast(reinterpret_cast(value.data())), name.size(), + value.size(), NGHTTP2_NV_FLAG_NONE}; +} + +/** + * A bare HTTP/2 server, driven by nghttp2 by hand, that sends one ALTSVC frame right after its + * SETTINGS and answers every request with an empty 200. + * + * anyhttp's own server has no reason to send one -- it puts the very same thing into a header + * field of every response -- so this is the only way to get the client's frame path under test. + */ +awaitable serve_h2_with_altsvc(tcp::socket socket, std::string origin, std::string value) +{ + auto callbacks = std::invoke([] + { + nghttp2_session_callbacks* cbs; + nghttp2_session_callbacks_new(&cbs); + nghttp2_session_callbacks_set_on_frame_recv_callback( + cbs, [](nghttp2_session* session, const nghttp2_frame* frame, void*) -> int + { + if (frame->hd.type == NGHTTP2_HEADERS && frame->headers.cat == NGHTTP2_HCAT_REQUEST) + { + std::array nva{nv(":status", "200"), nv("content-length", "0")}; + nghttp2_submit_response2(session, frame->hd.stream_id, nva.data(), nva.size(), nullptr); + } + return 0; + }); + return std::unique_ptr{ + cbs, nghttp2_session_callbacks_del}; + }); + + nghttp2_session* raw = nullptr; + nghttp2_session_server_new(&raw, callbacks.get(), nullptr); + auto session = + std::unique_ptr{raw, nghttp2_session_del}; + + nghttp2_settings_entry settings{NGHTTP2_SETTINGS_MAX_CONCURRENT_STREAMS, 100}; + nghttp2_submit_settings(raw, NGHTTP2_FLAG_NONE, &settings, 1); + + // + // On stream 0, the frame has to name the origin it is about (RFC 7838, section 4). + // + EXPECT_EQ(nghttp2_submit_altsvc(raw, NGHTTP2_FLAG_NONE, 0, + reinterpret_cast(origin.data()), origin.size(), + reinterpret_cast(value.data()), value.size()), + 0); + + std::array buffer; + for (;;) + { + for (;;) + { + const uint8_t* data = nullptr; + auto n = nghttp2_session_mem_send2(raw, &data); + if (n <= 0) + break; + if (auto [ec, written] = co_await async_write(socket, asio::buffer(data, n), as_tuple); ec) + co_return; + } + + auto [ec, n] = co_await socket.async_read_some(asio::buffer(buffer), as_tuple); + if (ec || nghttp2_session_mem_recv2(raw, buffer.data(), n) < 0) + co_return; + } +} + +} // namespace + +class AltSvcFrame : public Server +{ +}; + +INSTANTIATE_TEST_SUITE_P(AltSvcFrame, AltSvcFrame, Values(anyhttp::Protocol::h2), NameGenerator); + +TEST_P(AltSvcFrame, WHEN_an_altsvc_frame_arrives_THEN_the_next_connection_uses_it) +{ + // + // The bare HTTP/2 server gets an endpoint of its own, and points at the HTTP/3 endpoint of the + // fixture's server, which is the one that can actually answer over QUIC. + // + tcp::acceptor acceptor(context, tcp::endpoint(ip::make_address("127.0.0.2"), 0)); + auto origin = std::format("http://127.0.0.2:{}", acceptor.local_endpoint().port()); + auto value = std::format("h3=\":{}\"", server->local_endpoint().port()); + + co_spawn(context, [&]() -> awaitable + { + auto socket = co_await acceptor.async_accept(); + co_await serve_h2_with_altsvc(std::move(socket), origin, value); + }, [](const std::exception_ptr& ex) { logi("bare HTTP/2 server: {}", what(ex)); }); + + boost::urls::url target{"http://127.0.0.2/echo"}; + target.set_port_number(acceptor.local_endpoint().port()); + client::Client client(context.get_executor(), + {.url = target, .protocol = Protocol::h2, .follow_alt_svc = true}); + + co_spawn(context, + [&]() -> awaitable + { + // + // The response itself carries no "Alt-Svc" -- everything the client learns here, it learns + // from the frame that arrived before the request was even sent. + // + auto first = co_await (co_await client.async_connect()).async_get(target); + EXPECT_EQ(first.result_int(), 200); + EXPECT_EQ(first[http::field::alt_svc], ""); + + auto second = co_await (co_await client.async_connect()).async_get(target); + EXPECT_EQ(second.result_int(), 200); + EXPECT_TRUE(served_over_http3(second)) << "the second connection is not HTTP/3"; + }, [&](const std::exception_ptr& ex) + { + EXPECT_FALSE(ex) << what(ex); + acceptor.close(); + server.reset(); + }); + + run(); +} + +// ================================================================================================= diff --git a/test/test_external.cpp b/test/test_external.cpp index 2fd04c9..b175d9d 100644 --- a/test/test_external.cpp +++ b/test/test_external.cpp @@ -447,4 +447,29 @@ TEST_F(ExternalCustom, curl_h2c_upgrade) EXPECT_EQ(upgraded, 2) << output; } +// +// The server advertises its HTTP/3 endpoint as "Alt-Svc" in every response it sends over HTTP/1.1 +// and HTTP/2, see server::Config::alt_svc_max_age. curl only remembers that with a cache file +// given as --alt-svc, and only for https:// origins -- and it needs ALPN, which is what "h3" is +// negotiated as, so --no-alpn would rule out the alternative as much as it rules out HTTP/2. +// +// The alternative moves the *next* connection, never the one that learns about it, so this takes +// one curl invocation per request: the first is answered over TCP, the ones after it over QUIC. +// +TEST_F(ExternalCustom, curl_alt_svc) +{ + auto url = boost::url("https://127.0.0.2/dump").set_port_number(port()); + auto cmd = std::format("cache=$(mktemp) && trap 'rm -f $cache' EXIT && " + "for i in 1 2 3; do " + "timeout 5 {} -sS -v --cacert pki/out/root.pem --alt-svc $cache " + "-o /dev/null -w 'HTTP/%{{http_version}}\\n' {}; " + "done", + CURL_PATH, url.buffer()); + + auto future = spawn("/usr/bin/bash", {"-c", cmd}); + run(); + + EXPECT_EQ(future.get(), "HTTP/2\nHTTP/3\nHTTP/3\n"); +} + // ================================================================================================= diff --git a/test/test_fixtures.hpp b/test/test_fixtures.hpp index 60f2d8f..38feb8f 100644 --- a/test/test_fixtures.hpp +++ b/test/test_fixtures.hpp @@ -172,6 +172,9 @@ class Server : public testing::TestWithParam /// Lets a derived fixture adjust the server configuration before the server is created. virtual void configure_server(server::Config&) {} + /// Returns listening port of the server. + auto port() const noexcept { return server->local_endpoint().port(); } + protected: boost::asio::io_context context; std::optional server; From 0dcb06fe21d6d82b9ba1c57cdce3e15e48dc0311 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Sat, 19 Sep 2026 21:35:21 +0000 Subject: [PATCH 03/19] gitignore: add alt-svc.log to ignore list --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index cbf2c11..fde31d2 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ _codeql_detected_source_root cmake_test_discovery_*.json googletest_discovery_*.json keylog.log +alt-svc.log + From 7c60f1407d97c67c3ebe8354005b39cd124af661 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Sat, 19 Sep 2026 21:47:44 +0000 Subject: [PATCH 04/19] h1: announce the last response as the last one A request with "Connection: close", and any HTTP/1.0 request that does not ask for a persistent connection, was served and the connection was closed right after -- but the response never said so. A client reading it sees a plain "HTTP/1.1 200" and expects the connection to stay up, with no way of telling the close that follows from a connection lost mid-message (RFC 9112, section 9.6). The response keeps its own version: answering 1.0 would rule out the chunked framing used for responses without a content length, and an explicit "Connection: close" is unambiguous either way. The tests drive a raw socket, so they see what goes over the wire and whether the connection ends or is dropped. Co-Authored-By: Claude Opus 5 --- src/h1_session.cpp | 9 ++ test/test_connection_close.cpp | 149 +++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 test/test_connection_close.cpp diff --git a/src/h1_session.cpp b/src/h1_session.cpp index ace6268..7fd1878 100644 --- a/src/h1_session.cpp +++ b/src/h1_session.cpp @@ -1081,6 +1081,15 @@ awaitable ServerSession::do_session(Buffer&& buffer) if (const auto& alt_svc = server().alt_svc(); !alt_svc.empty()) response.set(http::field::alt_svc, alt_svc); + // + // A client that asked for the connection to end -- or one speaking HTTP/1.0, which has no + // persistent connections unless it asks for one -- gets one last response, and that response + // has to say that it is the last one (RFC 9112, section 9.6): without it, the client can not + // tell the end of the connection from one that was lost mid-message. + // + if (need_eof) + response.keep_alive(false); + // // Call user-provided request handler. // diff --git a/test/test_connection_close.cpp b/test/test_connection_close.cpp new file mode 100644 index 0000000..55df067 --- /dev/null +++ b/test/test_connection_close.cpp @@ -0,0 +1,149 @@ +#include "test_fixtures.hpp" + +#include +#include + +#include +#include +#include + +using namespace testing; + +namespace http = boost::beast::http; + +// ================================================================================================= + +// +// "Connection: close" over HTTP/1.1 (RFC 9112, section 9.6): a client that asks for the connection +// to end gets one last response, and that response has to say that it is the last one -- otherwise +// the client has no way of telling the end of the connection from one that was lost. +// +// The client side is a raw socket driven by hand, so that the test sees what actually goes over +// the wire, and whether the server ends the connection or drops it. +// +class ConnectionClose : public Server +{ +protected: + using Request = http::request; + using Response = http::response; + + awaitable connect() + { + tcp::socket socket(co_await this_coro::executor); + co_await socket.async_connect(server->local_endpoint()); + co_return socket; + } + + /// Writes \p request, with the Host field filled in, and reads the response that follows. + awaitable exchange(tcp::socket& socket, Request request) + { + request.set(http::field::host, std::format("127.0.0.2:{}", port())); + request.prepare_payload(); + co_await http::async_write(socket, request); + + Response response; + co_await http::async_read(socket, m_buffer, response); + co_return response; + } + + /// Reads what follows the last response, which must be the end of the stream and nothing else. + awaitable read_eof(tcp::socket& socket) + { + EXPECT_EQ(m_buffer.size(), 0) << "unread data left over from the response"; + + std::array buffer; + auto [ec, n] = co_await socket.async_read_some(asio::buffer(buffer), as_tuple); + if (!ec) + ADD_FAILURE() << std::format("{} bytes after the last response: '{}'", n, + std::string_view(buffer.data(), n)); + co_return ec; + } + + /// Runs \p task to completion, then stops the server. + void run(awaitable task) + { + co_spawn(context, std::move(task), [this](const std::exception_ptr& ep) + { + if (ep) + ADD_FAILURE() << what(ep); + server.reset(); + }); + Server::run(); + } + + boost::beast::flat_buffer m_buffer; +}; + +// ------------------------------------------------------------------------------------------------- + +TEST_F(ConnectionClose, WHEN_request_asks_to_close_THEN_response_says_so_and_stream_ends) +{ + run([&]() -> awaitable + { + auto socket = co_await connect(); + + Request request{http::verb::get, "/dump", 11}; + request.set(http::field::connection, "close"); + auto response = co_await exchange(socket, std::move(request)); + + EXPECT_EQ(response.result_int(), 200); + EXPECT_FALSE(response.keep_alive()) << "the last response has to announce itself as one"; + EXPECT_EQ(co_await read_eof(socket), asio::error::eof); + }()); +} + +TEST_F(ConnectionClose, WHEN_request_with_body_asks_to_close_THEN_body_is_served_first) +{ + run([&]() -> awaitable + { + auto socket = co_await connect(); + + Request request{http::verb::post, "/echo", 11}; + request.body() = "hello"; + request.set(http::field::connection, "close"); + auto response = co_await exchange(socket, std::move(request)); + + EXPECT_EQ(response.result_int(), 200); + EXPECT_EQ(response.body(), "hello"); + EXPECT_FALSE(response.keep_alive()); + EXPECT_EQ(co_await read_eof(socket), asio::error::eof); + }()); +} + +TEST_F(ConnectionClose, WHEN_request_does_not_ask_to_close_THEN_connection_takes_the_next_request) +{ + run([&]() -> awaitable + { + auto socket = co_await connect(); + + auto first = co_await exchange(socket, Request{http::verb::get, "/dump?first", 11}); + EXPECT_EQ(first.result_int(), 200); + EXPECT_TRUE(first.keep_alive()); + EXPECT_THAT(first.body(), HasSubstr("query: first")); + + auto second = co_await exchange(socket, Request{http::verb::get, "/dump?second", 11}); + EXPECT_EQ(second.result_int(), 200); + EXPECT_TRUE(second.keep_alive()); + EXPECT_THAT(second.body(), HasSubstr("query: second")); + }()); +} + +TEST_F(ConnectionClose, WHEN_request_is_http_1_0_THEN_stream_ends_after_the_response) +{ + run([&]() -> awaitable + { + auto socket = co_await connect(); + + // + // HTTP/1.0 has no persistent connections unless the client asks for one, so this response + // is the last one even though nothing said "close". + // + auto response = co_await exchange(socket, Request{http::verb::get, "/dump", 10}); + + EXPECT_EQ(response.result_int(), 200); + EXPECT_FALSE(response.keep_alive()); + EXPECT_EQ(co_await read_eof(socket), asio::error::eof); + }()); +} + +// ================================================================================================= From ff5d5f0c56c754c0c0347785da76c95062e7d6ee Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Sat, 19 Sep 2026 21:53:29 +0000 Subject: [PATCH 05/19] h1: end the connection with a FIN, not a close() The server closed the socket and only then tried to shut it down, which always failed with EBADF -- the FIXME on it was right. Closing a socket that still has unread data in its receive queue answers the peer with an RST, and an RST discards what has not been delivered yet: the peer can lose the very response that told it the connection was ending. So shut the sending side down first, and let go of the socket after that. A client writing into the teardown now sees EPIPE where it used to see ECONNRESET, depending on which write the reset catches, so the test for it accepts either. Co-Authored-By: Claude Opus 5 --- src/h1_session.cpp | 11 +++++-- test/test_client_async.cpp | 10 ++++++- test/test_connection_close.cpp | 54 ++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/h1_session.cpp b/src/h1_session.cpp index 7fd1878..c1f6b0e 100644 --- a/src/h1_session.cpp +++ b/src/h1_session.cpp @@ -1147,9 +1147,16 @@ awaitable ServerSession::do_session(Buffer&& buffer) mlogi("closing stream, served {} requests", requestCounter); - // FIXME: close() before shutdown()?! - get_socket(m_stream).close(); + // + // Send a FIN first, and let go of the socket only after that: closing one that still has + // unread data in its receive queue answers the peer with an RST instead, and an RST discards + // whatever has not been delivered yet -- which can be the very response that said the + // connection was ending. + // get_socket(m_stream).shutdown(asio::ip::tcp::socket::shutdown_send, ec); + if (ec && ec != asio::error::not_connected) // the peer may be gone already + mlogw("shutdown: {}", ec.message()); + get_socket(m_stream).close(ec); mlogd("session done"); } diff --git a/test/test_client_async.cpp b/test/test_client_async.cpp index 4e5c701..88c3749 100644 --- a/test/test_client_async.cpp +++ b/test/test_client_async.cpp @@ -280,7 +280,15 @@ TEST_P(ClientAsync, WHEN_server_discards_request_while_writing_THEN_connection_i auto request = co_await session.async_submit(url); auto executor = co_await this_coro::executor; auto [ec] = co_await co_spawn(executor, send(request, rv::iota(uint8_t(0))), as_tuple); - EXPECT_EQ(code(ec), boost::system::errc::connection_reset); + + // + // Which of the two it is depends on where the teardown catches the write: the RST that + // follows the server's FIN fails the write in progress with ECONNRESET, and every one + // after it with EPIPE. + // + EXPECT_THAT(code(ec), AnyOf(boost::system::errc::connection_reset, // + boost::system::errc::broken_pipe)) + << what(ec); }; } diff --git a/test/test_connection_close.cpp b/test/test_connection_close.cpp index 55df067..05fe58e 100644 --- a/test/test_connection_close.cpp +++ b/test/test_connection_close.cpp @@ -147,3 +147,57 @@ TEST_F(ConnectionClose, WHEN_request_is_http_1_0_THEN_stream_ends_after_the_resp } // ================================================================================================= + +// +// A request whose header section is too large is answered with 431 and ends the connection, with +// the rest of the request still on its way. That is the one case here where the connection is +// closed while data is still coming in, and closing a socket with unread data in its receive +// queue is what makes the kernel send an RST instead of a FIN. +// +class RejectedRequest : public ConnectionClose +{ +protected: + static constexpr size_t limit = 4_k; + + void configure_server(server::Config& config) override { config.max_header_size = limit; } +}; + +TEST_F(RejectedRequest, WHEN_request_is_rejected_THEN_the_response_arrives_anyway) +{ + run([&]() -> awaitable + { + auto socket = co_await connect(); + + // + // Far more than the server is willing to read: it stops at the limit, answers, and hangs + // up, leaving the rest of this unread on the connection. + // + std::string request = std::format("GET /dump HTTP/1.1\r\nHost: 127.0.0.2:{}\r\n", port()); + for (size_t i = 0; request.size() < 4_m; ++i) + request += std::format("x-header-{}: {}\r\n", i, std::string(1_k, 'a')); + request += "\r\n"; + + // + // Sending and receiving have to overlap: the server stops reading long before the request + // is out, so a write of all of it only completes once the connection is gone. + // + Response response; + auto send = [&]() -> awaitable + { + auto [ec, n] = co_await asio::async_write(socket, asio::buffer(request), as_tuple); + co_return ec; + }; + auto receive = [&]() -> awaitable + { + auto [ec, n] = co_await http::async_read(socket, m_buffer, response, as_tuple); + co_return ec; + }; + + auto [send_ec, receive_ec] = co_await (send() && receive()); + EXPECT_FALSE(receive_ec) << "the 431 was lost: " << receive_ec.message(); + EXPECT_EQ(response.result_int(), 431); + EXPECT_FALSE(response.keep_alive()); + }()); +} + +// ================================================================================================= From a897ef412efaebe04d224cfc3444057894c3befa Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Sun, 20 Sep 2026 08:30:41 +0000 Subject: [PATCH 06/19] tls: end a TLS connection with "close_notify" The sessions ended a TLS connection by shutting down the TCP socket beneath it, which is a truncated stream as far as TLS is concerned: the peer can not tell the end of the data from a connection that was cut, and every read after the last response fails with ssl::error::stream_truncated instead of a clean end of stream. OpenSSL calls it "unexpected eof while reading". Ending the stream itself is asynchronous -- "close_notify" goes out and the peer answers with one of its own -- so it is an awaitable, called from the session coroutines before the FIN. A peer that never answers must not keep the session around for good, so the wait for it is bounded: the connection is going away either way. Streams that have nothing of their own to end complete right away. The type-erased stream forwards to whatever it wraps, which is what the unused async_shutdown_impl() hook was waiting for. Co-Authored-By: Claude Opus 5 --- include/anyhttp/detail/any_async_stream.hpp | 29 ++++++++++ .../anyhttp/detail/any_async_stream_impl.hpp | 12 ++++ include/anyhttp/detail/h2_session_details.hpp | 7 +++ include/anyhttp/stream_traits.hpp | 32 +++++++++- src/any_async_stream_impl.cpp | 5 ++ src/h1_session.cpp | 9 ++- test/test_connection_close.cpp | 58 ++++++++++++++++++- 7 files changed, 148 insertions(+), 4 deletions(-) diff --git a/include/anyhttp/detail/any_async_stream.hpp b/include/anyhttp/detail/any_async_stream.hpp index 36ed4b2..966978a 100644 --- a/include/anyhttp/detail/any_async_stream.hpp +++ b/include/anyhttp/detail/any_async_stream.hpp @@ -126,13 +126,42 @@ class any_async_stream }, token, MutableBufferVector{buffers}); } + // + // async_shutdown + // + // Ends the stream itself, which is something only a TLS stream has to do: see async_teardown() + // in anyhttp/stream_traits.hpp, which is how the sessions reach this. For a stream that has + // nothing to end, this completes immediately and successfully. + // + template > + auto async_shutdown(CompletionToken&& token = CompletionToken()) + { + return boost::asio::async_initiate(initiate_shutdown{this}, token); + } + private: + // + // A named initiation rather than a lambda, because it has to offer the executor the operation + // runs on: tokens with a timer of their own -- cancel_after, which is how the sessions bound + // the wait for the peer's "close_notify" -- look for it here. + // + struct initiate_shutdown + { + using executor_type = boost::asio::any_io_executor; + executor_type get_executor() const noexcept { return self->get_executor(); } + void operator()(ShutdownHandler handler) const { self->shutdown(std::move(handler)); } + + any_async_stream* self; + }; + // // 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); + void shutdown(ShutdownHandler handler); std::unique_ptr impl; }; diff --git a/include/anyhttp/detail/any_async_stream_impl.hpp b/include/anyhttp/detail/any_async_stream_impl.hpp index 0434afc..5e804c1 100644 --- a/include/anyhttp/detail/any_async_stream_impl.hpp +++ b/include/anyhttp/detail/any_async_stream_impl.hpp @@ -72,6 +72,18 @@ class any_async_stream_impl final : public any_async_stream::Impl m_stream.async_read_some(buffers, std::move(handler)); } + // + // Only a TLS stream has something to end, everything else keeps the base class' immediate + // completion. + // + void async_shutdown_impl(ShutdownHandler handler) override + { + if constexpr (requires { m_stream.async_shutdown(std::move(handler)); }) + m_stream.async_shutdown(std::move(handler)); + else + Impl::async_shutdown_impl(std::move(handler)); + } + private: Stream m_stream; }; diff --git a/include/anyhttp/detail/h2_session_details.hpp b/include/anyhttp/detail/h2_session_details.hpp index 7cacd52..b3dcd68 100644 --- a/include/anyhttp/detail/h2_session_details.hpp +++ b/include/anyhttp/detail/h2_session_details.hpp @@ -271,6 +271,13 @@ awaitable ServerSession::do_session(Buffer&& buffer) mlogd("server session done"); + // + // End the stream itself: over TLS, that is the "close_notify" the peer needs to tell the end + // of the data from a connection that was cut, see async_teardown(). + // + if (auto ec = co_await async_teardown(m_stream); ec) + mlogd("teardown: {}", ec.message()); + nghttp2_session_del(session); session = nullptr; mlogd("server session deleted"); diff --git a/include/anyhttp/stream_traits.hpp b/include/anyhttp/stream_traits.hpp index d2f745a..3b69a89 100644 --- a/include/anyhttp/stream_traits.hpp +++ b/include/anyhttp/stream_traits.hpp @@ -6,16 +6,21 @@ // 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. +// reached through this trait instead. Ending the stream itself, which only TLS has to do, is +// async and comes as a free function below. // #include "anyhttp/detail/any_async_stream.hpp" #include +#include +#include +#include #include #include #include +#include #include namespace anyhttp @@ -101,6 +106,31 @@ decltype(auto) get_socket(Stream& stream) noexcept return stream_traits::get_socket(stream); } +/** + * Ends \p stream as far as the stream itself is concerned, which is something only TLS has: a + * "close_notify", which tells the peer that the end of the data is the end of the data and not a + * connection that was cut. Without it, everything the peer reads after the last response fails as + * a truncated stream instead of ending cleanly. Streams that have nothing of their own to end + * complete right away, and the FIN that the caller sends afterwards is the whole of it. + * + * The peer answers a "close_notify" with one of its own, and one that never does must not keep the + * session around for good, so the wait for it is bounded: the connection is going away either way. + */ +template +boost::asio::awaitable async_teardown(Stream& stream) +{ + constexpr auto timeout = std::chrono::seconds(2); + + if constexpr (requires { stream.async_shutdown(boost::asio::as_tuple); }) + { + auto [ec] = co_await stream.async_shutdown( // + boost::asio::cancel_after(timeout, boost::asio::as_tuple)); + co_return ec; + } + else + co_return boost::system::error_code{}; +} + // ------------------------------------------------------------------------------------------------- static_assert(SocketStream); diff --git a/src/any_async_stream_impl.cpp b/src/any_async_stream_impl.cpp index 4328c85..43f466e 100644 --- a/src/any_async_stream_impl.cpp +++ b/src/any_async_stream_impl.cpp @@ -32,6 +32,11 @@ void any_async_stream::read_some(ReadWriteHandler handler, MutableBufferVector b impl->async_read_some(std::move(handler), std::move(buffers)); } +void any_async_stream::shutdown(ShutdownHandler handler) +{ + impl->async_shutdown_impl(std::move(handler)); +} + // ================================================================================================= // 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. diff --git a/src/h1_session.cpp b/src/h1_session.cpp index c1f6b0e..63780b5 100644 --- a/src/h1_session.cpp +++ b/src/h1_session.cpp @@ -1148,7 +1148,14 @@ awaitable ServerSession::do_session(Buffer&& buffer) mlogi("closing stream, served {} requests", requestCounter); // - // Send a FIN first, and let go of the socket only after that: closing one that still has + // End the stream itself first: over TLS, that is the "close_notify" the peer needs to tell the + // end of the data from a connection that was cut. Everything else has nothing to send here. + // + if (auto teardown_ec = co_await async_teardown(m_stream); teardown_ec) + mlogw("teardown: {}", teardown_ec.message()); + + // + // Send a FIN next, and let go of the socket only after that: closing one that still has // unread data in its receive queue answers the peer with an RST instead, and an RST discards // whatever has not been delivered yet -- which can be the very response that said the // connection was ending. diff --git a/test/test_connection_close.cpp b/test/test_connection_close.cpp index 05fe58e..2575543 100644 --- a/test/test_connection_close.cpp +++ b/test/test_connection_close.cpp @@ -1,5 +1,6 @@ #include "test_fixtures.hpp" +#include #include #include @@ -35,7 +36,8 @@ class ConnectionClose : public Server } /// Writes \p request, with the Host field filled in, and reads the response that follows. - awaitable exchange(tcp::socket& socket, Request request) + template + awaitable exchange(Stream& socket, Request request) { request.set(http::field::host, std::format("127.0.0.2:{}", port())); request.prepare_payload(); @@ -47,7 +49,8 @@ class ConnectionClose : public Server } /// Reads what follows the last response, which must be the end of the stream and nothing else. - awaitable read_eof(tcp::socket& socket) + template + awaitable read_eof(Stream& socket) { EXPECT_EQ(m_buffer.size(), 0) << "unread data left over from the response"; @@ -201,3 +204,54 @@ TEST_F(RejectedRequest, WHEN_request_is_rejected_THEN_the_response_arrives_anywa } // ================================================================================================= + +// +// The same over TLS, where ending the connection takes one more step: a "close_notify" that tells +// the peer that the end of the data is the end of the data, and not a connection that was cut. +// Without it, everything the peer reads afterwards fails with ssl::error::stream_truncated +// instead of a clean end of stream -- which is a truncation attack as far as TLS is concerned. +// +class TlsConnectionClose : public ConnectionClose +{ +protected: + using SslStream = asio::ssl::stream; + + awaitable connect_tls() + { + SslStream stream(co_await this_coro::executor, m_context); + co_await stream.next_layer().async_connect(server->local_endpoint()); + co_await stream.async_handshake(asio::ssl::stream_base::client); + co_return stream; + } + + asio::ssl::context m_context = std::invoke([] + { + asio::ssl::context context{asio::ssl::context::tlsv13}; + context.load_verify_file("pki/out/root.pem"); + context.set_verify_mode(asio::ssl::verify_peer); + context.set_verify_callback(asio::ssl::host_name_verification("127.0.0.2")); + return context; + }); +}; + +// ------------------------------------------------------------------------------------------------- + +TEST_F(TlsConnectionClose, WHEN_request_asks_to_close_THEN_close_notify_comes_before_the_end) +{ + run([&]() -> awaitable + { + auto stream = co_await connect_tls(); + + Request request{http::verb::get, "/dump", 11}; + request.set(http::field::connection, "close"); + auto response = co_await exchange(stream, std::move(request)); + + EXPECT_EQ(response.result_int(), 200); + EXPECT_FALSE(response.keep_alive()); + + // a clean end of stream, not ssl::error::stream_truncated + EXPECT_EQ(co_await read_eof(stream), asio::error::eof); + }()); +} + +// ================================================================================================= From c142cb49a5573dd81726bac4b0ee80f98cbb5038 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Sun, 20 Sep 2026 14:08:31 +0000 Subject: [PATCH 07/19] refactor: simplify async_connect invocation in Client::Impl --- src/client_impl.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/client_impl.cpp b/src/client_impl.cpp index f6d5b04..e8a577c 100644 --- a/src/client_impl.cpp +++ b/src/client_impl.cpp @@ -125,9 +125,8 @@ void Client::Impl::async_connect(ConnectHandler handler) std::move(handler)(code(ep), std::move(session)); }; - co_spawn(get_executor(), [this] mutable -> awaitable { - co_return co_await async_connect(); - }, bind_executor(executor, bind_cancellation_slot(slot, std::move(completion)))); + co_spawn(get_executor(), async_connect(), + bind_executor(executor, bind_cancellation_slot(slot, std::move(completion)))); } awaitable Client::Impl::async_connect() From 6b26537ac86f829b037ae849d64edc45441dceac Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Sun, 20 Sep 2026 14:25:33 +0000 Subject: [PATCH 08/19] Refactor test lambdas to use 'clientSession' and 'requestHandler' --- test/test_alt_svc.cpp | 12 +- test/test_client_async.cpp | 148 ++++++++++++------------ test/test_client_async_cancellation.cpp | 20 ++-- test/test_file_handler.cpp | 31 +++-- test/test_fixtures.hpp | 11 +- test/test_get.cpp | 22 ++-- test/test_headers.cpp | 37 +++--- 7 files changed, 138 insertions(+), 143 deletions(-) diff --git a/test/test_alt_svc.cpp b/test/test_alt_svc.cpp index c04b946..fb78a13 100644 --- a/test/test_alt_svc.cpp +++ b/test/test_alt_svc.cpp @@ -151,7 +151,7 @@ INSTANTIATE_TEST_SUITE_P(AltSvcUpgrade, AltSvcUpgrade, TEST_P(AltSvcUpgrade, WHEN_the_server_advertises_h3_THEN_the_next_connection_uses_it) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto first = co_await session.async_get(echo()); EXPECT_EQ(first.result_int(), 200); @@ -172,7 +172,7 @@ TEST_P(AltSvcUpgrade, WHEN_the_server_advertises_h3_THEN_the_next_connection_use // TEST_P(AltSvcUpgrade, WHEN_the_alternative_is_learned_THEN_the_session_that_learned_it_stays) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { EXPECT_FALSE(served_over_http3(co_await session.async_get(echo()))); EXPECT_FALSE(served_over_http3(co_await session.async_get(echo()))); @@ -181,14 +181,14 @@ TEST_P(AltSvcUpgrade, WHEN_the_alternative_is_learned_THEN_the_session_that_lear TEST_P(AltSvcUpgrade, WHEN_the_server_clears_the_alternative_THEN_it_is_not_used) { - custom = [](server::Request request, server::Response response) -> awaitable + requestHandler = [](server::Request request, server::Response response) -> awaitable { co_await drain(request); co_await response.async_submit(200, fields({{"Alt-Svc", "clear"}, {"Content-Length", 0}})); co_await response.async_write_eof(); }; - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { EXPECT_THAT(std::string((co_await session.async_get(echo()))[http::field::alt_svc]), HasSubstr("h3=")); @@ -216,7 +216,7 @@ INSTANTIATE_TEST_SUITE_P(AltSvcIgnored, AltSvcIgnored, TEST_P(AltSvcIgnored, WHEN_the_client_does_not_follow_alt_svc_THEN_it_keeps_its_protocol) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto target = url; target.set_path("/echo"); @@ -246,7 +246,7 @@ INSTANTIATE_TEST_SUITE_P(AltSvcDisabled, AltSvcDisabled, TEST_P(AltSvcDisabled, WHEN_the_server_advertises_nothing_THEN_the_client_stays_where_it_is) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto target = url; target.set_path("/echo"); diff --git a/test/test_client_async.cpp b/test/test_client_async.cpp index 88c3749..3b3b0e1 100644 --- a/test/test_client_async.cpp +++ b/test/test_client_async.cpp @@ -22,7 +22,7 @@ INSTANTIATE_TEST_SUITE_P(ClientAsync, ClientAsync, TEST_P(ClientAsync, WHEN_post_data_THEN_receive_echo) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); size_t bytes = 1024; @@ -33,7 +33,7 @@ TEST_P(ClientAsync, WHEN_post_data_THEN_receive_echo) TEST_P(ClientAsync, WHEN_post_without_path_THEN_error_404) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path(""), {}); co_await generate(request, 1024); @@ -44,7 +44,7 @@ TEST_P(ClientAsync, WHEN_post_without_path_THEN_error_404) TEST_P(ClientAsync, WHEN_post_to_unknown_path_THEN_error_404) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("unknown"), {}); co_await generate(request, 1_m); @@ -56,7 +56,7 @@ TEST_P(ClientAsync, WHEN_post_to_unknown_path_THEN_error_404) TEST_P(ClientAsync, WHEN_server_discards_request_THEN_error_500) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("discard"), {}); co_await generate(request, 1024); @@ -67,7 +67,7 @@ TEST_P(ClientAsync, WHEN_server_discards_request_THEN_error_500) TEST_P(ClientAsync, WHEN_server_discards_request_delayed_THEN_error_500) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("detach"), {}); co_await generate(request, 1024); @@ -78,7 +78,7 @@ TEST_P(ClientAsync, WHEN_server_discards_request_delayed_THEN_error_500) TEST_P(ClientAsync, WHEN_server_discards_request_with_body_delayed_THEN_error_500) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto executor = co_await this_coro::executor; auto request = co_await session.async_submit(url.set_path("detach"), {}); @@ -89,7 +89,7 @@ TEST_P(ClientAsync, WHEN_server_discards_request_with_body_delayed_THEN_error_50 TEST_P(ClientAsync, WHEN_invalid_port_in_host_header_THEN_reports_error) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { Fields fields; fields.set("Host", "host:12345x"); @@ -100,7 +100,7 @@ TEST_P(ClientAsync, WHEN_invalid_port_in_host_header_THEN_reports_error) TEST_P(ClientAsync, WHEN_get_response_is_called_twice_THEN_reports_error) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo")); auto [ec, response] = co_await request.async_get_response(as_tuple); @@ -116,7 +116,7 @@ TEST_P(ClientAsync, WHEN_get_response_is_detached_THEN_does_not_crash) if (GetParam() == anyhttp::Protocol::http11) GTEST_SKIP(); - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo")); request.async_get_response(detached); @@ -145,7 +145,7 @@ TEST_P(ClientAsync, WHEN_session_is_gone_THEN_request_reports_error) if (GetParam() != anyhttp::Protocol::http11) GTEST_SKIP(); - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); session.reset(); @@ -164,7 +164,8 @@ TEST_P(ClientAsync, WHEN_session_is_gone_THEN_request_reports_error) TEST_P(ClientAsync, WHEN_server_session_is_gone_THEN_response_reports_error) { auto responded = std::make_shared(false); - custom = [responded](server::Request request, server::Response response) -> awaitable + requestHandler = [responded](server::Request request, + server::Response response) -> awaitable { // // Keep the response around beyond the request handler, until the client has closed the @@ -185,7 +186,7 @@ TEST_P(ClientAsync, WHEN_server_session_is_gone_THEN_response_reports_error) }, detached); co_return; }; - test = [this, responded](Session session) -> awaitable + clientSession = [this, responded](Session session) -> awaitable { auto request = co_await session.async_submit(url, {}); co_await request.async_write_eof(); @@ -203,12 +204,13 @@ TEST_P(ClientAsync, WHEN_server_session_is_gone_THEN_response_reports_error) // one must not make the session forget about the later one, which has to learn about the session // going away all the same. // -TEST_P(ClientAsync, WHEN_earlier_request_is_released_THEN_later_request_still_learns_session_is_gone) +TEST_P(ClientAsync, + WHEN_earlier_request_is_released_THEN_later_request_still_learns_session_is_gone) { if (GetParam() != anyhttp::Protocol::http11) GTEST_SKIP(); - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request1 = co_await session.async_submit(url.set_path("echo"), {}); co_await request1.async_write_eof(asio::buffer("Hello, Server #1!"sv)); @@ -227,7 +229,7 @@ TEST_P(ClientAsync, WHEN_session_is_gone_THEN_earlier_request_reports_error) if (GetParam() != anyhttp::Protocol::http11) GTEST_SKIP(); - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request1 = co_await session.async_submit(url.set_path("echo"), {}); co_await request1.async_write_eof(asio::buffer("Hello, Server #1!"sv)); @@ -240,12 +242,13 @@ TEST_P(ClientAsync, WHEN_session_is_gone_THEN_earlier_request_reports_error) }; } -TEST_P(ClientAsync, WHEN_earlier_response_is_released_THEN_later_response_still_learns_session_is_gone) +TEST_P(ClientAsync, + WHEN_earlier_response_is_released_THEN_later_response_still_learns_session_is_gone) { if (GetParam() != anyhttp::Protocol::http11) GTEST_SKIP(); - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request1 = co_await session.async_submit(url.set_path("echo"), {}); co_await request1.async_write_eof(asio::buffer("Hello, Server #1!"sv)); @@ -270,12 +273,12 @@ TEST_P(ClientAsync, WHEN_earlier_response_is_released_THEN_later_response_still_ TEST_P(ClientAsync, WHEN_server_discards_request_while_writing_THEN_connection_is_reset) { - custom = [this](server::Request request, server::Response response) -> awaitable + requestHandler = [this](server::Request request, server::Response response) -> awaitable { co_await sleep(150ms); request.reset(); }; - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url); auto executor = co_await this_coro::executor; @@ -297,13 +300,13 @@ TEST_P(ClientAsync, WHEN_server_discards_request_and_response_THEN_completes_any // if (GetParam() == anyhttp::Protocol::http11) // GTEST_SKIP(); // FIXME: timeout - custom = [this](server::Request request, server::Response response) -> awaitable + requestHandler = [this](server::Request request, server::Response response) -> awaitable { std::ignore = request; std::ignore = response; co_return; }; - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url); auto [ec, _] = co_await request.async_get_response(as_tuple); @@ -317,7 +320,7 @@ TEST_P(ClientAsync, WHEN_client_cancels_write_THEN_can_resume) if (GetParam() == anyhttp::Protocol::http11) GTEST_SKIP(); // a chunked body cannot be cancelled correctly --> disconnects - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { co_await this_coro::throw_if_cancelled(false); auto executor = co_await this_coro::executor; @@ -363,7 +366,7 @@ TEST_P(ClientAsync, YieldFuzz) static std::mt19937 gen(42); // fixed seed for reproducibility #endif - custom = [this](server::Request request, server::Response response) -> awaitable + requestHandler = [this](server::Request request, server::Response response) -> awaitable { std::uniform_int_distribution<> dist(0, 10); constexpr auto msg = "Hello, Client!"sv; @@ -379,7 +382,7 @@ TEST_P(ClientAsync, YieldFuzz) std::array data; co_await request.async_read_some(asio::buffer(data), as_tuple); }; - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { std::uniform_int_distribution<> dist(0, 10); for (size_t i = 0; i < 100; ++i) @@ -408,13 +411,13 @@ TEST_P(ClientAsync, YieldFuzz) TEST_P(ClientAsync, WHEN_body_ends_THEN_read_reports_eof) { static const auto hello = "Hello, World!"sv; - custom = [this](server::Request request, server::Response response) -> awaitable + requestHandler = [this](server::Request request, server::Response response) -> awaitable { co_await drain(request); co_await response.async_submit(200, fields({{"Content-Length", hello.size()}})); co_await response.async_write_eof(asio::buffer(hello)); }; - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url); co_await request.async_write_eof(); @@ -459,14 +462,14 @@ TEST_P(ClientAsync, WHEN_body_ends_THEN_read_reports_eof) TEST_P(ClientAsync, WHEN_empty_buffer_is_written_THEN_body_stays_open) { static const auto tail = "still here"sv; - custom = [this](server::Request request, server::Response response) -> awaitable + requestHandler = [this](server::Request request, server::Response response) -> awaitable { EXPECT_EQ(co_await drain(request), 0u); co_await response.async_submit(200, {}); co_await response.async_write({}); // writes nothing, leaves the body open co_await response.async_write_eof(asio::buffer(tail)); }; - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url); co_await request.async_write({}); // likewise: the request body stays open @@ -484,7 +487,7 @@ TEST_P(ClientAsync, WHEN_empty_buffer_is_written_THEN_body_stays_open) TEST_P(ClientAsync, WHEN_written_after_eof_THEN_reports_broken_pipe) { static const auto hello = "Hello, World!"sv; - custom = [this](server::Request request, server::Response response) -> awaitable + requestHandler = [this](server::Request request, server::Response response) -> awaitable { co_await drain(request); co_await response.async_submit(200, fields({{"Content-Length", hello.size()}})); @@ -502,21 +505,19 @@ TEST_P(ClientAsync, WHEN_written_after_eof_THEN_reports_broken_pipe) std::tie(ec) = co_await response.async_write_eof(asio::buffer(hello), as_tuple); EXPECT_EQ(ec, boost::system::errc::broken_pipe); }; - test = [this](Session session) -> awaitable - { - EXPECT_EQ((co_await session.async_get(url)).body(), hello); - }; + clientSession = [this](Session session) -> awaitable + { EXPECT_EQ((co_await session.async_get(url)).body(), hello); }; } TEST_P(ClientAsync, HelloWorld) { static const auto hello = "Hello, World!"sv; - custom = [this](server::Request request, server::Response response) -> awaitable + requestHandler = [this](server::Request request, server::Response response) -> awaitable { co_await response.async_submit(200, {}); co_await response.async_write_eof(asio::buffer(hello)); }; - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto message = co_await session.async_get(url); EXPECT_EQ(message.result_int(), 200); @@ -541,7 +542,7 @@ TEST_P(ClientAsync, WHEN_server_writes_large_buffer_at_once_THEN_receives_all) return data; }(); - custom = [this](server::Request request, server::Response response) -> awaitable + requestHandler = [this](server::Request request, server::Response response) -> awaitable { // drain the request -- HTTP/1.1 closes the connection on an unfinished parser co_await drain(request); @@ -549,10 +550,8 @@ TEST_P(ClientAsync, WHEN_server_writes_large_buffer_at_once_THEN_receives_all) co_await response.async_submit(200, fields({{"Content-Length", body.size()}})); co_await response.async_write_eof(asio::buffer(body)); }; - test = [this](Session session) -> awaitable - { - EXPECT_EQ((co_await session.async_get(url)).body().size(), body.size()); - }; + clientSession = [this](Session session) -> awaitable + { EXPECT_EQ((co_await session.async_get(url)).body().size(), body.size()); }; } // @@ -565,7 +564,7 @@ TEST_P(ClientAsync, WHEN_server_cancels_write_eof_THEN_client_sees_truncated_bod { static const std::vector body(8_m, 'x'); - custom = [this](server::Request request, server::Response response) -> awaitable + requestHandler = [this](server::Request request, server::Response response) -> awaitable { co_await drain(request); co_await response.async_submit(200, {}); @@ -574,11 +573,11 @@ TEST_P(ClientAsync, WHEN_server_cancels_write_eof_THEN_client_sees_truncated_bod // Far more than the peer's receive window, and the client below doesn't read a byte until // this is over, so the write is guaranteed to still be in progress when it is cancelled. // - auto [ec] = co_await response.async_write_eof(asio::buffer(body), - cancel_after(50ms, as_tuple)); + auto [ec] = + co_await response.async_write_eof(asio::buffer(body), cancel_after(50ms, as_tuple)); EXPECT_EQ(ec, boost::system::errc::operation_canceled); }; - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url); co_await request.async_write_eof(); @@ -607,7 +606,7 @@ TEST_P(ClientAsync, WHEN_client_cancels_write_eof_THEN_can_still_end) static const std::vector body(8_m, 'x'); - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { co_await this_coro::throw_if_cancelled(false); auto executor = co_await this_coro::executor; @@ -636,7 +635,7 @@ TEST_P(ClientAsync, WHEN_server_cancels_write_THEN_client_sees_truncated_body) { static const std::vector body(8_m, 'x'); - custom = [this](server::Request request, server::Response response) -> awaitable + requestHandler = [this](server::Request request, server::Response response) -> awaitable { // drain the request -- HTTP/1.1 closes the connection on an unfinished parser co_await drain(request); @@ -648,11 +647,11 @@ TEST_P(ClientAsync, WHEN_server_cancels_write_THEN_client_sees_truncated_body) // this is over, so the write is guaranteed to still be in progress when it is cancelled. // auto executor = co_await this_coro::executor; - auto [ep] = co_await co_spawn(executor, send(response, std::span(body)), - cancel_after(50ms, as_tuple)); + auto [ep] = + co_await co_spawn(executor, send(response, std::span(body)), cancel_after(50ms, as_tuple)); EXPECT_EQ(code(ep), boost::system::errc::operation_canceled); }; - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url); co_await request.async_write_eof(); @@ -678,17 +677,15 @@ TEST_P(ClientAsync, WHEN_server_cancels_write_THEN_client_sees_truncated_body) TEST_P(ClientAsync, ServerYieldFirst) { - custom = [this](server::Request request, server::Response response) -> awaitable + requestHandler = [this](server::Request request, server::Response response) -> awaitable { co_await yield(10); co_await response.async_submit(200, {}); co_await yield(10); co_await response.async_write_eof(); }; - test = [this](Session session) -> awaitable - { - EXPECT_EQ((co_await session.async_get(url)).result_int(), 200); - }; + clientSession = [this](Session session) -> awaitable + { EXPECT_EQ((co_await session.async_get(url)).result_int(), 200); }; } // ---------------------------------------------------------------------------------------------- @@ -727,7 +724,7 @@ TEST_P(ClientAsync, Recursion) if (!stackRemainingBytes()) GTEST_SKIP() << "unable to measure stack on this platform"; - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto ex = co_await this_coro::executor; auto request = co_await session.async_submit(url.set_path("echo"), {}); @@ -752,7 +749,7 @@ TEST_P(ClientAsync, Recursion) TEST_P(ClientAsync, Custom) { - custom = [this](server::Request request, server::Response response) -> awaitable + requestHandler = [this](server::Request request, server::Response response) -> awaitable { co_await response.async_submit(200, {}); std::array buffer; @@ -767,7 +764,7 @@ TEST_P(ClientAsync, Custom) co_await response.async_write(asio::buffer(buffer, n)); } }; - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url, {}); constexpr size_t bytes = 1024; @@ -778,12 +775,12 @@ TEST_P(ClientAsync, Custom) TEST_P(ClientAsync, IgnoreRequest) { - custom = [this](server::Request request, server::Response response) -> awaitable + requestHandler = [this](server::Request request, server::Response response) -> awaitable { co_await response.async_submit(200, {}); co_await response.async_write_eof(); }; - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { Fields fields; fields.set("content-length", "0"); @@ -795,13 +792,13 @@ TEST_P(ClientAsync, IgnoreRequest) TEST_P(ClientAsync, IgnoreRequestAndResponse) { - custom = [this](server::Request request, server::Response response) -> awaitable + requestHandler = [this](server::Request request, server::Response response) -> awaitable { std::ignore = request; std::ignore = response; co_return; }; - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url, {}); auto res = co_await (generate(request, 0) && try_read_response(request)); @@ -814,7 +811,7 @@ TEST_P(ClientAsync, IgnoreRequestAndResponse) TEST_P(ClientAsync, PostRange) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); // co_await request.async_write(asio::buffer("ping"sv)); // FIXME: @@ -831,7 +828,7 @@ TEST_P(ClientAsync, PostRange) TEST_P(ClientAsync, PostRangeImmediate) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); auto sender = sendAndForceEOF(request, rv::iota(uint8_t(0)) | rv::take(1_m)); @@ -845,7 +842,7 @@ TEST_P(ClientAsync, PostRangeImmediate) TEST_P(ClientAsync, WHEN_request_is_sent_THEN_response_is_received_before_body_is_posted) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); auto response = co_await request.async_get_response(); @@ -865,7 +862,7 @@ TEST_P(ClientAsync, WHEN_request_is_sent_THEN_response_is_received_before_body_i // TEST_P(ClientAsync, WHEN_multiple_request_are_made_THEN_responses_are_received_in_order) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request1 = co_await session.async_submit(url.set_path("echo"), {}); co_await request1.async_write_eof(asio::buffer("Hello, Server #1!"sv)); @@ -893,7 +890,7 @@ static constexpr auto body2 = "Hello, Server #2! XYZ"sv; // TEST_P(ClientAsync, WHEN_request_is_submitted_before_previous_is_complete_THEN_reports_would_block) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { const bool limited = GetParam() == anyhttp::Protocol::http11; @@ -921,7 +918,7 @@ TEST_P(ClientAsync, WHEN_request_is_submitted_before_previous_is_complete_THEN_r TEST_P(ClientAsync, WHEN_many_requests_are_made_THEN_all_are_answered_in_order) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { std::vector requests; for (size_t i = 0; i < 10; ++i) @@ -947,7 +944,7 @@ TEST_P(ClientAsync, WHEN_getting_response_before_previous_is_read_THEN_reports_w if (GetParam() != anyhttp::Protocol::http11) GTEST_SKIP(); // requests are multiplexed, nothing to wait for - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request1 = co_await session.async_submit(url.set_path("echo"), {}); co_await request1.async_write_eof(asio::buffer(body1)); @@ -977,7 +974,7 @@ TEST_P(ClientAsync, WHEN_request_has_no_body_THEN_it_is_complete_after_submit) if (GetParam() != anyhttp::Protocol::http11) GTEST_SKIP(); // requests are multiplexed, nothing to wait for - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request1 = co_await session.async_submit(url.set_path("echo"), fields({{"Content-Length", 0}})); @@ -1005,7 +1002,7 @@ TEST_P(ClientAsync, WHEN_content_length_is_written_without_eof_THEN_request_is_n if (GetParam() != anyhttp::Protocol::http11) GTEST_SKIP(); // requests are multiplexed, nothing to wait for - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request1 = co_await session.async_submit(url.set_path("echo"), fields({{"Content-Length", body1.size()}})); @@ -1034,7 +1031,7 @@ TEST_P(ClientAsync, WHEN_incomplete_request_is_released_THEN_later_requests_repo if (GetParam() != anyhttp::Protocol::http11) GTEST_SKIP(); // requests are multiplexed, and independent of each other - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request1 = co_await session.async_submit(url.set_path("echo"), {}); co_await request1.async_write(asio::buffer(body1)); @@ -1049,12 +1046,13 @@ TEST_P(ClientAsync, WHEN_incomplete_request_is_released_THEN_later_requests_repo // A request that has been sent, but goes away without asking for its response, leaves that // response unread on the connection -- and with it, all responses after it. // -TEST_P(ClientAsync, WHEN_request_is_released_without_getting_response_THEN_later_responses_report_error) +TEST_P(ClientAsync, + WHEN_request_is_released_without_getting_response_THEN_later_responses_report_error) { if (GetParam() != anyhttp::Protocol::http11) GTEST_SKIP(); // requests are multiplexed, and independent of each other - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request1 = co_await session.async_submit(url.set_path("echo"), {}); co_await request1.async_write_eof(asio::buffer(body1)); @@ -1071,7 +1069,7 @@ TEST_P(ClientAsync, WHEN_request_is_released_without_getting_response_THEN_later TEST_P(ClientAsync, EatRequest) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("eat_request"), {}); co_await generate(request, 1024); @@ -1085,7 +1083,7 @@ TEST_P(ClientAsync, EatRequest) TEST_P(ClientAsync, Dump) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit( url.set_path("dump space").set_params({{"blah", "white space"}, {"x", "y"}}), {}); diff --git a/test/test_client_async_cancellation.cpp b/test/test_client_async_cancellation.cpp index 99c5d1c..43dc709 100644 --- a/test/test_client_async_cancellation.cpp +++ b/test/test_client_async_cancellation.cpp @@ -24,7 +24,7 @@ INSTANTIATE_TEST_SUITE_P(ClientAsyncCancellation, ClientAsyncCancellation, TEST_P(ClientAsyncCancellation, Backpressure) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); auto response = co_await request.async_get_response(); @@ -66,7 +66,7 @@ TEST_P(ClientAsyncCancellation, Backpressure) // TEST_P(ClientAsyncCancellation, CancellationContentLength) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { const size_t length = 50_m; const std::vector buffer(length); @@ -113,7 +113,7 @@ TEST_P(ClientAsyncCancellation, CancellationContentLength) // TEST_P(ClientAsyncCancellation, Cancellation) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { const size_t length = 50_m; const std::vector buffer(length, 'a'); @@ -156,7 +156,7 @@ TEST_P(ClientAsyncCancellation, Cancellation) // TEST_P(ClientAsyncCancellation, CancellationRange) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { for (size_t i = 6; i <= 6; ++i) { @@ -177,7 +177,7 @@ TEST_P(ClientAsyncCancellation, CancellationRange) TEST_P(ClientAsyncCancellation, PerOperationCancellation) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); auto response = co_await request.async_get_response(); @@ -197,7 +197,7 @@ TEST_P(ClientAsyncCancellation, PerOperationCancellation) TEST_P(ClientAsyncCancellation, CancelAfter) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo").set_params({{"delay", "1000"}}), {}); @@ -218,7 +218,7 @@ TEST_P(ClientAsyncCancellation, CancelAfter) TEST_P(ClientAsyncCancellation, WHEN_send_more_than_content_length_THEN_connection_is_reset) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { Fields fields; fields.set("content-length", "1024"); @@ -244,7 +244,7 @@ TEST_P(ClientAsyncCancellation, WHEN_send_more_than_content_length_THEN_connecti TEST_P(ClientAsyncCancellation, ClientDropRequest) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); auto response = co_await request.async_get_response(); @@ -255,7 +255,7 @@ TEST_P(ClientAsyncCancellation, ClientDropRequest) TEST_P(ClientAsyncCancellation, ResetServerDuringRequest) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); auto response = co_await request.async_get_response(); @@ -298,7 +298,7 @@ TEST_P(ClientAsyncCancellation, DISABLED_SpawnAndForget) if (GetParam() == anyhttp::Protocol::http11) GTEST_SKIP(); // FIXME: ASAN errors - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); auto response = co_await request.async_get_response(); diff --git a/test/test_file_handler.cpp b/test/test_file_handler.cpp index 16c8e03..8e93e4c 100644 --- a/test/test_file_handler.cpp +++ b/test/test_file_handler.cpp @@ -38,9 +38,8 @@ class FileHandler : public ClientAsync ClientAsync::SetUp(); - custom = [this](server::Request request, server::Response response) -> awaitable { - co_await serve_file(std::move(request), std::move(response), root, "/custom"); - }; + requestHandler = [this](server::Request request, server::Response response) -> awaitable + { co_await serve_file(std::move(request), std::move(response), root, "/custom"); }; } void TearDown() override @@ -89,7 +88,7 @@ INSTANTIATE_TEST_SUITE_P(FileHandler, FileHandler, TEST_P(FileHandler, WHEN_file_exists_THEN_serves_content) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto message = co_await get(session, "/custom/hello.txt"); EXPECT_EQ(message.result_int(), 200); @@ -99,7 +98,7 @@ TEST_P(FileHandler, WHEN_file_exists_THEN_serves_content) TEST_P(FileHandler, WHEN_file_is_in_subdirectory_THEN_serves_content) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto message = co_await get(session, "/custom/sub/nested.txt"); EXPECT_EQ(message.result_int(), 200); @@ -113,7 +112,7 @@ TEST_P(FileHandler, WHEN_file_is_in_subdirectory_THEN_serves_content) // TEST_P(FileHandler, WHEN_file_is_empty_THEN_serves_empty_body) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto message = co_await get(session, "/custom/empty.txt"); EXPECT_EQ(message.result_int(), 200); @@ -127,7 +126,7 @@ TEST_P(FileHandler, WHEN_file_is_empty_THEN_serves_empty_body) // TEST_P(FileHandler, WHEN_file_is_large_THEN_serves_all_of_it) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto message = co_await get(session, "/custom/large.bin"); EXPECT_EQ(message.result_int(), 200); @@ -137,7 +136,7 @@ TEST_P(FileHandler, WHEN_file_is_large_THEN_serves_all_of_it) TEST_P(FileHandler, WHEN_file_does_not_exist_THEN_error_404) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto message = co_await get(session, "/custom/missing.txt"); EXPECT_EQ(message.result_int(), 404); @@ -150,7 +149,7 @@ TEST_P(FileHandler, WHEN_file_does_not_exist_THEN_error_404) // TEST_P(FileHandler, WHEN_path_is_a_directory_THEN_error_404) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { EXPECT_EQ((co_await get(session, "/custom/sub")).result_int(), 404); EXPECT_EQ((co_await get(session, "/custom/")).result_int(), 404); @@ -159,7 +158,7 @@ TEST_P(FileHandler, WHEN_path_is_a_directory_THEN_error_404) TEST_P(FileHandler, WHEN_path_escapes_the_root_THEN_error_404) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { 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); @@ -172,10 +171,8 @@ TEST_P(FileHandler, WHEN_path_escapes_the_root_THEN_error_404) // TEST_P(FileHandler, WHEN_symlink_points_outside_the_root_THEN_error_404) { - test = [this](Session session) -> awaitable - { - EXPECT_EQ((co_await get(session, "/custom/escape.txt")).result_int(), 404); - }; + clientSession = [this](Session session) -> awaitable + { EXPECT_EQ((co_await get(session, "/custom/escape.txt")).result_int(), 404); }; } // @@ -184,7 +181,7 @@ TEST_P(FileHandler, WHEN_symlink_points_outside_the_root_THEN_error_404) // TEST_P(FileHandler, WHEN_prefix_matches_mid_segment_THEN_error_404) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto message = co_await get(session, "/customer.txt"); EXPECT_EQ(message.result_int(), 404); @@ -198,7 +195,7 @@ TEST_P(FileHandler, WHEN_file_is_not_readable_THEN_error_403) if (::geteuid() == 0) GTEST_SKIP() << "running as root, permissions do not apply"; - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto message = co_await get(session, "/custom/secret.txt"); EXPECT_EQ(message.result_int(), 403); @@ -208,7 +205,7 @@ TEST_P(FileHandler, WHEN_file_is_not_readable_THEN_error_403) TEST_P(FileHandler, WHEN_same_file_is_requested_twice_THEN_serves_it_twice) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { 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_fixtures.hpp b/test/test_fixtures.hpp index 38feb8f..b08444d 100644 --- a/test/test_fixtures.hpp +++ b/test/test_fixtures.hpp @@ -143,7 +143,7 @@ class Server : public testing::TestWithParam else if (request.url().path() == "/detach") co_await detach(std::move(request), std::move(response)); else if (request.url().path().starts_with("/custom")) - co_await custom(std::move(request), std::move(response)); + co_await requestHandler(std::move(request), std::move(response)); else co_await not_found(std::move(request), std::move(response)); }); @@ -178,7 +178,8 @@ class Server : public testing::TestWithParam protected: boost::asio::io_context context; std::optional server; - std::function(server::Request request, server::Response response)> custom; + std::function(server::Request request, server::Response response)> + requestHandler; }; // ================================================================================================= @@ -242,10 +243,10 @@ class ClientAsync : public Client // co_spawn(client->get_executor(), [this]() -> awaitable { - if (test) + if (clientSession) { auto session = co_await client->async_connect(); - co_await test(std::move(session)); + co_await clientSession(std::move(session)); } }, token()); } @@ -258,7 +259,7 @@ class ClientAsync : public Client public: decltype(boost::asio::make_work_guard(context)) work = boost::asio::make_work_guard(context); - std::function(Session session)> test; + std::function(Session session)> clientSession; }; // ================================================================================================= diff --git a/test/test_get.cpp b/test/test_get.cpp index 2955c14..5dfad7c 100644 --- a/test/test_get.cpp +++ b/test/test_get.cpp @@ -18,8 +18,8 @@ class AsyncGet : public ClientAsync /// 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 + requestHandler = [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( @@ -39,7 +39,7 @@ INSTANTIATE_TEST_SUITE_P(AsyncGet, AsyncGet, TEST_P(AsyncGet, WHEN_get_THEN_message_has_status_fields_and_body) { respond_with("Hello, World!"); - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto message = co_await session.async_get(url); EXPECT_EQ(message.result(), http::status::ok); @@ -52,7 +52,7 @@ TEST_P(AsyncGet, WHEN_get_THEN_message_has_status_fields_and_body) TEST_P(AsyncGet, WHEN_response_has_no_body_THEN_body_is_empty) { respond_with(""); - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto message = co_await session.async_get(url); EXPECT_EQ(message.result_int(), 200); @@ -66,7 +66,7 @@ TEST_P(AsyncGet, WHEN_response_has_no_body_THEN_body_is_empty) // TEST_P(AsyncGet, WHEN_path_is_unknown_THEN_message_says_404) { - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto message = co_await session.async_get(url.set_path("unknown")); EXPECT_EQ(message.result(), http::status::not_found); @@ -77,7 +77,7 @@ 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 + clientSession = [this, body](Session session) -> awaitable { auto message = co_await session.async_get(url); EXPECT_EQ(message.result_int(), 200); @@ -88,7 +88,7 @@ TEST_P(AsyncGet, WHEN_body_is_large_THEN_all_of_it_arrives) TEST_P(AsyncGet, WHEN_headers_are_given_THEN_they_arrive_with_the_request) { - custom = [](server::Request request, server::Response response) -> awaitable + requestHandler = [](server::Request request, server::Response response) -> awaitable { EXPECT_EQ(request.fields()["x-question"], "what?"); EXPECT_EQ(request.fields()["content-length"], "0"); @@ -96,7 +96,7 @@ TEST_P(AsyncGet, WHEN_headers_are_given_THEN_they_arrive_with_the_request) co_await response.async_submit(200, fields({{"Content-Length", 0}})); co_await response.async_write_eof(); }; - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto message = co_await session.async_get(url, fields({{"X-Question", "what?"}})); EXPECT_EQ(message.result_int(), 200); @@ -110,7 +110,7 @@ TEST_P(AsyncGet, WHEN_headers_are_given_THEN_they_arrive_with_the_request) TEST_P(AsyncGet, WHEN_two_requests_in_a_row_THEN_both_are_answered) { respond_with("Hello, World!"); - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { for (size_t i = 0; i < 2; ++i) { @@ -131,13 +131,13 @@ TEST_P(AsyncGet, WHEN_cancelled_THEN_completes_with_operation_canceled_and_empty // 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 + requestHandler = [](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 + clientSession = [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); diff --git a/test/test_headers.cpp b/test/test_headers.cpp index ebe410a..9d929dc 100644 --- a/test/test_headers.cpp +++ b/test/test_headers.cpp @@ -52,10 +52,8 @@ static std::vector values_of(const Fields& fields, std::string static std::vector> pairs_of(const Fields& fields) { return fields | rv::transform([](auto& field) { - return std::pair(std::string_view(field.name_string()), - std::string_view(field.value())); - }) | - std::ranges::to(); + return std::pair(std::string_view(field.name_string()), std::string_view(field.value())); + }) | std::ranges::to(); } /// Expects every field of \p expected to be found in \p actual. @@ -82,7 +80,7 @@ static size_t wire_size(const Fields& fields) // void Headers::round_trip(Fields sent) { - custom = [sent](server::Request request, server::Response response) -> awaitable + requestHandler = [sent](server::Request request, server::Response response) -> awaitable { expect_contains(request.fields(), sent); co_await drain(request); @@ -91,7 +89,7 @@ void Headers::round_trip(Fields sent) co_await response.async_submit(200, fields); co_await response.async_write_eof(); }; - test = [this, sent](Session session) -> awaitable + clientSession = [this, sent](Session session) -> awaitable { auto message = co_await session.async_get(url, sent); EXPECT_EQ(message.result_int(), 200); @@ -117,7 +115,8 @@ TEST_P(Headers, WHEN_header_name_repeats_THEN_all_values_arrive_in_order) for (size_t i = 0; i < 50; ++i) sent.insert("x-repeated", values.emplace_back(std::format("value-{}", i))); - custom = [sent, values](server::Request request, server::Response response) -> awaitable + requestHandler = [sent, values](server::Request request, + server::Response response) -> awaitable { EXPECT_THAT(values_of(request.fields(), "x-repeated"), ElementsAreArray(values)); co_await drain(request); @@ -126,7 +125,7 @@ TEST_P(Headers, WHEN_header_name_repeats_THEN_all_values_arrive_in_order) co_await response.async_submit(200, fields); co_await response.async_write_eof(); }; - test = [this, sent, values](Session session) -> awaitable + clientSession = [this, sent, values](Session session) -> awaitable { auto message = co_await session.async_get(url, sent); EXPECT_THAT(values_of(message, "x-repeated"), ElementsAreArray(values)); @@ -154,13 +153,13 @@ TEST_P(Headers, WHEN_request_headers_exceed_default_limit_THEN_server_responds_4 auto sent = make_fields(3, 30_k); ASSERT_GT(wire_size(sent), default_max_header_size); - custom = [](server::Request request, server::Response response) -> awaitable + requestHandler = [](server::Request request, server::Response response) -> awaitable { ADD_FAILURE() << "request handler called for oversized request headers"; co_await response.async_submit(200, {}); co_await response.async_write_eof(); }; - test = [this, sent](Session session) -> awaitable + clientSession = [this, sent](Session session) -> awaitable { auto message = co_await session.async_get(url, sent); EXPECT_EQ(message.result_int(), 431); @@ -188,7 +187,7 @@ class HeaderLimits : public ClientAsync /// Request handler: responds with the headers of size \p response_size given as query parameter. void respond_with_headers() { - custom = [this](server::Request request, server::Response response) -> awaitable + requestHandler = [this](server::Request request, server::Response response) -> awaitable { ++handled; auto size = request.get_param_as("response_size").value_or(0); @@ -229,7 +228,7 @@ INSTANTIATE_TEST_SUITE_P(HeaderLimits, HeaderLimits, TEST_P(HeaderLimits, WHEN_request_headers_are_within_limit_THEN_request_is_handled) { respond_with_headers(); - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { EXPECT_EQ(co_await request(session, make_fields(1, limit / 2), limit / 2), 200); EXPECT_EQ(handled, 1); @@ -239,7 +238,7 @@ TEST_P(HeaderLimits, WHEN_request_headers_are_within_limit_THEN_request_is_handl TEST_P(HeaderLimits, WHEN_request_headers_exceed_limit_THEN_server_responds_431) { respond_with_headers(); - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { EXPECT_EQ(co_await request(session, make_fields(1, limit)), 431); EXPECT_EQ(handled, 0); @@ -253,7 +252,7 @@ TEST_P(HeaderLimits, WHEN_request_headers_exceed_limit_THEN_server_responds_431) TEST_P(HeaderLimits, WHEN_many_small_fields_exceed_limit_THEN_server_responds_431) { respond_with_headers(); - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto sent = make_fields(200, 1); EXPECT_EQ(co_await request(session, sent), 431); @@ -273,7 +272,7 @@ TEST_P(HeaderLimits, WHEN_request_headers_far_exceed_limit_THEN_request_is_rejec ASSERT_GT(wire_size(sent), limit * 200); respond_with_headers(); - test = [this, sent](Session session) -> awaitable + clientSession = [this, sent](Session session) -> awaitable { auto result = co_await request(session, sent); if (GetParam() == anyhttp::Protocol::h2) @@ -290,7 +289,7 @@ TEST_P(HeaderLimits, WHEN_request_is_rejected_THEN_session_serves_next_request) GTEST_SKIP() << "HTTP/1.1 closes the connection after 431"; respond_with_headers(); - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { EXPECT_EQ(co_await request(session, make_fields(1, limit)), 431); EXPECT_EQ(co_await request(session, make_fields(1, 100)), 200); @@ -303,7 +302,7 @@ TEST_P(HeaderLimits, WHEN_request_is_rejected_THEN_session_serves_next_request) TEST_P(HeaderLimits, WHEN_response_headers_exceed_limit_THEN_get_response_fails) { respond_with_headers(); - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto result = co_await request(session, {}, limit); EXPECT_EQ(result, std::unexpected(error_code(boost::beast::http::error::header_limit))); @@ -316,7 +315,7 @@ TEST_P(HeaderLimits, WHEN_response_headers_exceed_limit_THEN_get_response_fails) TEST_P(HeaderLimits, WHEN_response_headers_exceed_limit_before_get_response_THEN_it_fails) { respond_with_headers(); - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto target = url; target.params().set("response_size", std::to_string(limit)); @@ -334,7 +333,7 @@ TEST_P(HeaderLimits, WHEN_response_is_rejected_THEN_session_serves_next_request) GTEST_SKIP() << "HTTP/1.1 can not skip the rest of a response"; respond_with_headers(); - test = [this](Session session) -> awaitable + clientSession = [this](Session session) -> awaitable { auto result = co_await request(session, {}, limit); EXPECT_EQ(result, std::unexpected(error_code(boost::beast::http::error::header_limit))); From f78464660eeac00701854e5c6fc48aceafcf90f8 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Sun, 20 Sep 2026 20:39:21 +0000 Subject: [PATCH 09/19] style: put lambda bodies on the signature line Set BeforeLambdaBody to false and LambdaBodyIndentation to Signature, then reformatted all tracked sources. Asio code is dense with short lambdas, and giving each one a brace line of its own plus outer-scope indentation cost a line per lambda while visually detaching the body from its capture list. Whitespace only: the code is token-identical to the previous commit, apart from reflowed comments, a sorted include pair in request_handlers.hpp and a namespace closing comment in test_formatter.cpp. Co-Authored-By: Claude Opus 5 --- .clang-format | 4 +- include/anyhttp/client.hpp | 27 +-- include/anyhttp/client_impl.hpp | 3 +- include/anyhttp/concepts.hpp | 1 - include/anyhttp/detail/any_async_stream.hpp | 16 +- include/anyhttp/detail/detect_h2.hpp | 3 +- include/anyhttp/detail/detect_ssl.hpp | 34 ++-- include/anyhttp/formatter.hpp | 6 +- include/anyhttp/h1_backend.hpp | 4 +- include/anyhttp/h1_session.hpp | 12 +- include/anyhttp/h2_backend.hpp | 12 +- include/anyhttp/h2_common.hpp | 5 +- include/anyhttp/h2_session.hpp | 3 +- include/anyhttp/h2_stream.hpp | 16 +- include/anyhttp/h3_backend.hpp | 2 +- include/anyhttp/h3_stream.hpp | 14 +- include/anyhttp/request_handlers.hpp | 2 +- include/anyhttp/session.hpp | 18 +- include/anyhttp/session_impl.hpp | 4 +- src/client_impl.cpp | 8 +- src/client_main.cpp | 2 +- src/file_handler.cpp | 5 +- src/h1_session.cpp | 149 +++++++------- src/h2_session.cpp | 16 +- src/h2_stream.cpp | 15 +- src/h3_client.cpp | 16 +- src/h3_server.cpp | 44 ++-- src/h3_session.cpp | 9 +- src/h3_stream.cpp | 6 +- src/research/sender.cpp | 104 +++++----- src/server_impl.cpp | 31 ++- src/server_main.cpp | 66 +++--- src/session.cpp | 12 +- test/test_alt_svc.cpp | 91 ++++----- test/test_client_async.cpp | 212 ++++++++------------ test/test_client_async_cancellation.cpp | 47 ++--- test/test_client_connect.cpp | 12 +- test/test_connection_close.cpp | 30 +-- test/test_external.cpp | 23 +-- test/test_file_handler.cpp | 41 ++-- test/test_fixtures.hpp | 79 ++++---- test/test_formatter.cpp | 20 +- test/test_get.cpp | 64 +++--- test/test_h2c_upgrade.cpp | 50 ++--- test/test_headers.cpp | 52 ++--- test/test_server.cpp | 50 ++--- 46 files changed, 649 insertions(+), 791 deletions(-) diff --git a/.clang-format b/.clang-format index 4cf04aa..e2be224 100644 --- a/.clang-format +++ b/.clang-format @@ -105,7 +105,7 @@ BraceWrapping: AfterUnion: true BeforeCatch: true BeforeElse: true - BeforeLambdaBody: true + BeforeLambdaBody: false BeforeWhile: false IndentBraces: false SplitEmptyFunction: true @@ -190,7 +190,7 @@ KeepEmptyLines: AtStartOfBlock: true AtStartOfFile: true KeepFormFeed: false -LambdaBodyIndentation: OuterScope +LambdaBodyIndentation: Signature LineEnding: DeriveLF MacroBlockBegin: '' MacroBlockEnd: '' diff --git a/include/anyhttp/client.hpp b/include/anyhttp/client.hpp index bf20bf2..27629e5 100644 --- a/include/anyhttp/client.hpp +++ b/include/anyhttp/client.hpp @@ -153,9 +153,10 @@ class Request { auto executor = asio::get_associated_executor(token, get_executor()); return asio::async_initiate( - asio::bind_executor(executor, [this](auto&& handler) { // - async_get_response_any(std::move(handler)); - }), + asio::bind_executor(executor, + [this](auto&& handler) { // + async_get_response_any(std::move(handler)); + }), token); } @@ -172,9 +173,10 @@ class Request // FIXME: get_executor() breaks testcase SpawnAndForget because the impl is already gone there auto executor = asio::get_associated_executor(token); // , get_executor()); return asio::async_initiate( - asio::bind_executor(executor, [this](auto&& handler, asio::const_buffer buffer) { // - async_write_any(std::move(handler), buffer, false); - }), + asio::bind_executor(executor, + [this](auto&& handler, asio::const_buffer buffer) { // + async_write_any(std::move(handler), buffer, false); + }), token, buffer); } @@ -193,9 +195,10 @@ class Request // see async_write() above for why the executor is not defaulted to get_executor() auto executor = asio::get_associated_executor(token); return asio::async_initiate( - asio::bind_executor(executor, [this](auto&& handler, asio::const_buffer buffer) { // - async_write_any(std::move(handler), buffer, true); - }), + asio::bind_executor(executor, + [this](auto&& handler, asio::const_buffer buffer) { // + async_write_any(std::move(handler), buffer, true); + }), token, buffer); } @@ -244,10 +247,8 @@ class Client auto async_connect(CompletionToken&& token = CompletionToken()) { auto executor = asio::get_associated_executor(token, get_executor()); - return asio::async_initiate( - bind_executor(executor, [&](auto&& handler) { // - async_connect_any(std::move(handler)); - }), + return asio::async_initiate( // + bind_executor(executor, [&](auto&& handler) { async_connect_any(std::move(handler)); }), token); } diff --git a/include/anyhttp/client_impl.hpp b/include/anyhttp/client_impl.hpp index dc43b0a..2f3c177 100644 --- a/include/anyhttp/client_impl.hpp +++ b/include/anyhttp/client_impl.hpp @@ -21,7 +21,8 @@ class Request::Impl : public impl::Writer Impl() noexcept; virtual ~Impl(); - virtual void async_submit(StatusHandler&& handler, unsigned int status_code, const Fields& headers) = 0; + virtual void async_submit(StatusHandler&& handler, unsigned int status_code, + const Fields& headers) = 0; virtual void async_get_response(GetResponseHandler&& handler) = 0; using ReaderOrWriter = impl::Writer; diff --git a/include/anyhttp/concepts.hpp b/include/anyhttp/concepts.hpp index e93c45c..18d4946 100644 --- a/include/anyhttp/concepts.hpp +++ b/include/anyhttp/concepts.hpp @@ -14,7 +14,6 @@ concept ConstBufferSequence = boost::asio::is_const_buffer_sequence::value; template concept MutableBufferSequence = boost::asio::is_mutable_buffer_sequence::value; - // // 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 diff --git a/include/anyhttp/detail/any_async_stream.hpp b/include/anyhttp/detail/any_async_stream.hpp index 966978a..290b546 100644 --- a/include/anyhttp/detail/any_async_stream.hpp +++ b/include/anyhttp/detail/any_async_stream.hpp @@ -103,10 +103,10 @@ class any_async_stream CompletionToken&& token = CompletionToken()) { return boost::asio::async_initiate( - [this](ReadWriteHandler handler, ConstBufferVector buffers) - { // - write_some(std::move(handler), std::move(buffers)); - }, token, ConstBufferVector{buffers}); + [this](ReadWriteHandler handler, ConstBufferVector buffers) { // + write_some(std::move(handler), std::move(buffers)); + }, + token, ConstBufferVector{buffers}); } // @@ -120,10 +120,10 @@ class any_async_stream CompletionToken&& token = CompletionToken()) { return boost::asio::async_initiate( - [this](ReadWriteHandler handler, MutableBufferVector buffers) - { // - read_some(std::move(handler), std::move(buffers)); - }, token, MutableBufferVector{buffers}); + [this](ReadWriteHandler handler, MutableBufferVector buffers) { // + read_some(std::move(handler), std::move(buffers)); + }, + token, MutableBufferVector{buffers}); } // diff --git a/include/anyhttp/detail/detect_h2.hpp b/include/anyhttp/detail/detect_h2.hpp index 3b5f517..5766d08 100644 --- a/include/anyhttp/detail/detect_h2.hpp +++ b/include/anyhttp/detail/detect_h2.hpp @@ -74,8 +74,7 @@ auto async_detect_http2_client_preface(AsyncReadStream& stream, DynamicBuffer& b using namespace boost::asio; return async_initiate( co_composed( - [](auto state, DynamicBuffer& buffer, AsyncReadStream& stream) -> void - { + [](auto state, DynamicBuffer& buffer, AsyncReadStream& stream) -> void { // // https://think-async.com/Asio/asio-1.26.0/doc/asio/reference/experimental__co_composed.html // diff --git a/include/anyhttp/detail/detect_ssl.hpp b/include/anyhttp/detail/detect_ssl.hpp index b3e9b6e..2e9d951 100644 --- a/include/anyhttp/detail/detect_ssl.hpp +++ b/include/anyhttp/detail/detect_ssl.hpp @@ -34,23 +34,23 @@ auto async_detect_ssl_awaitable(AsyncReadStream& stream, DynamicBuffer& buffer, using namespace boost::asio; return async_initiate( co_composed( - [](auto state, AsyncReadStream& stream, DynamicBuffer& buffer) -> void - { - state.reset_cancellation_state(enable_terminal_cancellation()); - - for (;;) - { - boost::tribool result = detail::is_tls_client_hello(buffer.data()); - if (!boost::indeterminate(result)) - co_return std::make_tuple(boost::system::error_code{}, static_cast(result)); - - auto prepared = buffer.prepare(1460); - auto [ec, n] = co_await stream.async_read_some(prepared, as_tuple); - if (ec) - co_return {ec, false}; - buffer.commit(n); - } - }, stream), + [](auto state, AsyncReadStream& stream, DynamicBuffer& buffer) -> void { + state.reset_cancellation_state(enable_terminal_cancellation()); + + for (;;) + { + boost::tribool result = detail::is_tls_client_hello(buffer.data()); + if (!boost::indeterminate(result)) + co_return std::make_tuple(boost::system::error_code{}, static_cast(result)); + + auto prepared = buffer.prepare(1460); + auto [ec, n] = co_await stream.async_read_some(prepared, as_tuple); + if (ec) + co_return {ec, false}; + buffer.commit(n); + } + }, + stream), token, std::ref(stream), std::ref(buffer)); } diff --git a/include/anyhttp/formatter.hpp b/include/anyhttp/formatter.hpp index 0d0553f..632e1f3 100644 --- a/include/anyhttp/formatter.hpp +++ b/include/anyhttp/formatter.hpp @@ -92,7 +92,8 @@ struct Truncated size_t max_size; }; -/// Default for truncated(): long enough for any regular header, short enough to keep the log readable. +/// Default for truncated(): long enough for any regular header, short enough to keep the log +/// readable. inline constexpr size_t max_logged_size = 80; /** @@ -138,8 +139,7 @@ struct std::formatter : std::formatter::format("all", ctx); bool first = true; - auto append = [&](boost::asio::cancellation_type flag, std::string_view name) - { + auto append = [&](boost::asio::cancellation_type flag, std::string_view name) { if ((type & flag) == flag) { std::format_to(ctx.out(), "{}{}", first ? "" : "|", name); diff --git a/include/anyhttp/h1_backend.hpp b/include/anyhttp/h1_backend.hpp index ab8e3f6..d49a9af 100644 --- a/include/anyhttp/h1_backend.hpp +++ b/include/anyhttp/h1_backend.hpp @@ -39,8 +39,8 @@ std::shared_ptr make_client_session(client::Client::Impl& client, 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&, + SslStream&&); extern template std::shared_ptr make_server_session(server::Server::Impl&, any_async_stream&&); diff --git a/include/anyhttp/h1_session.hpp b/include/anyhttp/h1_session.hpp index da7a979..25ec979 100644 --- a/include/anyhttp/h1_session.hpp +++ b/include/anyhttp/h1_session.hpp @@ -34,11 +34,11 @@ class BeastSession : public ::anyhttp::Session::Impl ~BeastSession() override; std::string_view logPrefix() const { return m_logPrefix; } - + // ---------------------------------------------------------------------------------------------- - + void destroy() noexcept override; - + boost::asio::any_io_executor get_executor() const noexcept override { return m_executor; } // ---------------------------------------------------------------------------------------------- @@ -108,12 +108,12 @@ class ServerSession : public ServerSessionBase, public BeastSession using super = BeastSession; // FIXME: maybe use CRTP or something similar to avoid this? + using super::detach_readers; + using super::detach_writers; using super::logPrefix; using super::m_buffer; - using super::m_stream; using super::m_closed; - using super::detach_readers; - using super::detach_writers; + using super::m_stream; public: ServerSession(server::Server::Impl& parent, any_io_executor executor, Stream&& stream); diff --git a/include/anyhttp/h2_backend.hpp b/include/anyhttp/h2_backend.hpp index 327a96d..886b882 100644 --- a/include/anyhttp/h2_backend.hpp +++ b/include/anyhttp/h2_backend.hpp @@ -27,8 +27,8 @@ namespace anyhttp::nghttp2 /** * 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 - * a body are upgraded, so the stream starts out half-closed (remote). + * with "101 Switching Protocols". The HTTP/2 session continues it as stream 1. Only requests + * without a body are upgraded, so the stream starts out half-closed (remote). */ struct Upgrade { @@ -53,15 +53,13 @@ std::shared_ptr make_server_session(server::Server::Impl& server, 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&, 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&, any_async_stream&&, - std::optional); + std::optional); extern template std::shared_ptr make_client_session(client::Client::Impl&, diff --git a/include/anyhttp/h2_common.hpp b/include/anyhttp/h2_common.hpp index 976941a..e19bcc9 100644 --- a/include/anyhttp/h2_common.hpp +++ b/include/anyhttp/h2_common.hpp @@ -43,7 +43,10 @@ inline size_t max_continuations(size_t max_header_size) } inline std::string_view name_of(const nghttp2_nv& nv) { return {(const char*)nv.name, nv.namelen}; } -inline std::string_view value_of(const nghttp2_nv& nv) { return {(const char*)nv.value, nv.valuelen}; } +inline std::string_view value_of(const nghttp2_nv& nv) +{ + return {(const char*)nv.value, nv.valuelen}; +} // ================================================================================================= diff --git a/include/anyhttp/h2_session.hpp b/include/anyhttp/h2_session.hpp index 6f64382..c8ada9d 100644 --- a/include/anyhttp/h2_session.hpp +++ b/include/anyhttp/h2_session.hpp @@ -86,8 +86,7 @@ class NGHttp2Session : public anyhttp::Session::Impl auto async_wait_send(CompletionToken&& token = CompletionToken()) { return asio::async_initiate( - [&](ResumeHandler handler) - { + [&](ResumeHandler handler) { assert(!m_send_handler); m_send_handler = std::move(handler); }, diff --git a/include/anyhttp/h2_stream.hpp b/include/anyhttp/h2_stream.hpp index b589bcd..005204a 100644 --- a/include/anyhttp/h2_stream.hpp +++ b/include/anyhttp/h2_stream.hpp @@ -7,10 +7,10 @@ #include #include -#include #include #include #include +#include #include #include #include @@ -40,7 +40,7 @@ class NGHttp2Reader : public Interface std::optional content_length() const noexcept override; void async_read_some(boost::asio::mutable_buffer buffer, ReadSomeHandler&& handler) override; void detach() override; - + unsigned int status_code() const noexcept override; boost::url_view url() const override; const Fields& fields() const override; @@ -117,16 +117,16 @@ class NGHttp2Stream : public std::enable_shared_from_this static_cast(buffer.data()) + asio::buffer_size(buffer)); } - static inline bool is_empty(asio::const_buffer buffer) - { - return asio::buffer_size(buffer) == 0; - } + static inline bool is_empty(asio::const_buffer buffer) { return asio::buffer_size(buffer) == 0; } /** * Returns true if all data has been read by the user. * This is true if there was an EOF flag and all buffers have been consumed. */ - inline bool reading_finished() const { return !reader || eof_received && is_empty(m_read_buffer); } + inline bool reading_finished() const + { + return !reader || eof_received && is_empty(m_read_buffer); + } /// Returns true if the user has submitted EOF and this has been delivered to nghttp2. inline bool writing_finished() const { return !writer || eof_submitted; }; @@ -142,7 +142,7 @@ class NGHttp2Stream : public std::enable_shared_from_this // // async_write() // - asio::const_buffer write_buffer; // undefined unless write_handler is set + asio::const_buffer write_buffer; // undefined unless write_handler is set WriteHandler write_handler; bool is_deferred = false; diff --git a/include/anyhttp/h3_backend.hpp b/include/anyhttp/h3_backend.hpp index e2d6e42..5153bf8 100644 --- a/include/anyhttp/h3_backend.hpp +++ b/include/anyhttp/h3_backend.hpp @@ -26,7 +26,7 @@ namespace anyhttp::server // // The server's HTTP/3 half: one UDP socket shared by all QUIC connections, the receive loop // de-multiplexing datagrams onto them by connection ID, and the connections themselves. -// +// // Sessions register with the owning Server::Impl just like the TCP-based ones, so they // take part in server-wide shutdown. // diff --git a/include/anyhttp/h3_stream.hpp b/include/anyhttp/h3_stream.hpp index 5cb8bab..0d5faa5 100644 --- a/include/anyhttp/h3_stream.hpp +++ b/include/anyhttp/h3_stream.hpp @@ -300,17 +300,15 @@ class Http3Reader : public Interface auto cs = asio::get_associated_cancellation_slot(handler); if (cs.is_connected() && !cs.has_handler()) { - cs.assign([this](asio::cancellation_type_t) - { + cs.assign([this](asio::cancellation_type_t) { if (stream && stream->read_handler) { asio::post(stream->get_executor(), - [handler = std::move(stream->read_handler)]() mutable - { - std::move(handler)( - boost::system::errc::make_error_code(boost::system::errc::operation_canceled), - 0); - }); + [handler = std::move(stream->read_handler)]() mutable { + std::move(handler)(boost::system::errc::make_error_code( + boost::system::errc::operation_canceled), + 0); + }); } }); } diff --git a/include/anyhttp/request_handlers.hpp b/include/anyhttp/request_handlers.hpp index f0de405..93884e1 100644 --- a/include/anyhttp/request_handlers.hpp +++ b/include/anyhttp/request_handlers.hpp @@ -1,8 +1,8 @@ #pragma once #include "anyhttp/client.hpp" -#include "anyhttp/server.hpp" #include "anyhttp/literals.hpp" +#include "anyhttp/server.hpp" #include #include diff --git a/include/anyhttp/session.hpp b/include/anyhttp/session.hpp index 30348f5..d2929df 100644 --- a/include/anyhttp/session.hpp +++ b/include/anyhttp/session.hpp @@ -65,10 +65,11 @@ class Session { 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_submit_any(std::move(handler), std::move(url), headers); - }), + asio::bind_executor( + executor, + [this](auto&& handler, boost::urls::url url, const Fields& headers) { // + async_submit_any(std::move(handler), std::move(url), headers); + }), token, std::move(url), headers); } @@ -106,10 +107,11 @@ class Session { 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); - }), + 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); } diff --git a/include/anyhttp/session_impl.hpp b/include/anyhttp/session_impl.hpp index 3bf3a7b..19df34c 100644 --- a/include/anyhttp/session_impl.hpp +++ b/include/anyhttp/session_impl.hpp @@ -19,8 +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, std::string_view method, - 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/client_impl.cpp b/src/client_impl.cpp index e8a577c..cf9aaeb 100644 --- a/src/client_impl.cpp +++ b/src/client_impl.cpp @@ -117,9 +117,8 @@ void Client::Impl::async_connect(ConnectHandler handler) // auto slot = get_associated_cancellation_slot(handler); auto executor = get_associated_executor(handler); - auto completion = - [this, handler = std::move(handler)](std::exception_ptr ep, Session session) mutable - { + auto completion = [this, handler = std::move(handler)](std::exception_ptr ep, + Session session) mutable { if (ep) loge("Client: async_connect: {}", what(ep)); std::move(handler)(code(ep), std::move(session)); @@ -236,8 +235,7 @@ awaitable Client::Impl::async_connect() // of the user-facing "Session" object. So we should use only the "impl" internally. // #if 1 - co_spawn(m_executor, impl->do_session(Buffer{}), [impl](const std::exception_ptr& ex) mutable - { + co_spawn(m_executor, impl->do_session(Buffer{}), [impl](const std::exception_ptr& ex) mutable { if (ex) logw("client run: {}", what(ex)); else diff --git a/src/client_main.cpp b/src/client_main.cpp index 36e2ce5..db73f8c 100644 --- a/src/client_main.cpp +++ b/src/client_main.cpp @@ -60,7 +60,7 @@ awaitable do_session(Client& client, boost::urls::url url) #if 1 for (size_t i = 0; i < 1; ++i) - co_await do_request(session, url); + co_await do_request(session, url); #else for (size_t i = 0; i < 3; ++i) co_spawn(client.get_executor(), do_requests(client.get_executor(), session, url), detached); diff --git a/src/file_handler.cpp b/src/file_handler.cpp index 58138b5..a2052b3 100644 --- a/src/file_handler.cpp +++ b/src/file_handler.cpp @@ -258,9 +258,8 @@ class FileCache // Read mtime() before the move, rather than relying on argument evaluation order. auto last_modified = format_http_date(mapped->mtime()); - auto entry = std::make_shared(*resolved, std::move(*mapped), - std::move(last_modified), - content_type(*resolved)); + auto entry = std::make_shared( + *resolved, std::move(*mapped), std::move(last_modified), content_type(*resolved)); insert(request_path, entry); return entry; } diff --git a/src/h1_session.cpp b/src/h1_session.cpp index 63780b5..a139af9 100644 --- a/src/h1_session.cpp +++ b/src/h1_session.cpp @@ -193,8 +193,7 @@ class BeastReader : public Interface auto ex = get_associated_executor(handler, get_executor()); auto cs = get_associated_cancellation_slot(handler); auto cb = [this, self = Interface::shared_from_this(), body_buffer = std::move(body_buffer), - handler = std::move(handler)](boost::system::error_code ec, size_t n) mutable - { + handler = std::move(handler)](boost::system::error_code ec, size_t n) mutable { reading = false; auto& body = parser.get().body(); @@ -389,77 +388,76 @@ class WriterBase : public Parent auto cb = [this, self = Parent::shared_from_this(), expected = buffer.size(), eof, handler = std::move(handler)] // - (boost::system::error_code ec, size_t n) mutable - { - // async op result 'n' is the number of bytes written to the stream, - // not the number of bytes read from the buffer - mlogd("async_write: n={} (\x1b[1;{}m{}\x1b[0m) done={} (body {})", n, - ec == beast::http::error::need_buffer ? 33 : 31, // need_buffer in yellow only - ec.message(), serializer.is_done(), serializer.get().body().size); + (boost::system::error_code ec, size_t n) mutable { + // async op result 'n' is the number of bytes written to the stream, + // not the number of bytes read from the buffer + mlogd("async_write: n={} (\x1b[1;{}m{}\x1b[0m) done={} (body {})", n, + ec == beast::http::error::need_buffer ? 33 : 31, // need_buffer in yellow only + ec.message(), serializer.is_done(), serializer.get().body().size); - writing = false; + writing = false; - // - // 'need_buffer' means that the serializer is done consuming all of the given buffer - // and is ready to accept a new one. - // - if (ec == beast::http::error::need_buffer) - ec = {}; - else if (ec == errc::operation_canceled) - { - // - // Cancellation is tricky, see e.g.: https://github.com/boostorg/beast/issues/2325. - // - // Main reason is that, depending on when the cancellation actually takes place, - // the stream is in an undefined state. For example, when writing a large chunk is - // interrupted, there is no meaningful way to recover: The length of the chunk has - // been written, but only part of the data. // - // So the only sensible thing to do here is to close the socket. + // 'need_buffer' means that the serializer is done consuming all of the given buffer + // and is ready to accept a new one. // - // TODO: We could try to support partial cancellation, but that would only work - // at chunk boundaries. - // - mlogw("async_write: canceled after writing {} of {} bytes", n, expected); - cancelled = true; - if (session) // otherwise, the stream is gone already + if (ec == beast::http::error::need_buffer) + ec = {}; + else if (ec == errc::operation_canceled) { - mlogw("async_write: canceled, closing stream"); - get_socket(stream).shutdown(boost::asio::socket_base::shutdown_send); + // + // Cancellation is tricky, see e.g.: https://github.com/boostorg/beast/issues/2325. + // + // Main reason is that, depending on when the cancellation actually takes place, + // the stream is in an undefined state. For example, when writing a large chunk is + // interrupted, there is no meaningful way to recover: The length of the chunk has + // been written, but only part of the data. + // + // So the only sensible thing to do here is to close the socket. + // + // TODO: We could try to support partial cancellation, but that would only work + // at chunk boundaries. + // + mlogw("async_write: canceled after writing {} of {} bytes", n, expected); + cancelled = true; + if (session) // otherwise, the stream is gone already + { + mlogw("async_write: canceled, closing stream"); + get_socket(stream).shutdown(boost::asio::socket_base::shutdown_send); + } } - } - else if (ec) - { - cancelled = true; - } - /* - else if (!ec && n < expected) - { - mlogw("async_write: wrote {} bytes which is less than expected ({})", n, expected); - ec = errc::make_error_code(errc::message_size); - } - */ + else if (ec) + { + cancelled = true; + } + /* + else if (!ec && n < expected) + { + mlogw("async_write: wrote {} bytes which is less than expected ({})", n, expected); + ec = errc::make_error_code(errc::message_size); + } + */ - // - // Only now is the body really ended: a cancelled or failed EOF write never got its - // terminating bytes onto the wire, and latching the flag at accept time would let a - // retried async_write_eof() report success for a body the peer sees as truncated. - // - if (!ec && eof) - eof_submitted = true; + // + // Only now is the body really ended: a cancelled or failed EOF write never got its + // terminating bytes onto the wire, and latching the flag at accept time would let a + // retried async_write_eof() report success for a body the peer sees as truncated. + // + if (!ec && eof) + eof_submitted = true; - if (session && eof_submitted) - body_ended(); - else if (session && cancelled) - write_failed(); + if (session && eof_submitted) + body_ended(); + else if (session && cancelled) + write_failed(); - // - // The handler may resume the caller right here. If it releases the writer, that has to - // take effect immediately, not only when this callback is gone. - // - self.reset(); - std::move(handler)(ec); - }; + // + // The handler may resume the caller right here. If it releases the writer, that has to + // take effect immediately, not only when this callback is gone. + // + self.reset(); + std::move(handler)(ec); + }; http::async_write( stream, serializer, @@ -741,8 +739,7 @@ class RequestWriter auto ex = get_associated_executor(handler, get_executor()); auto slot = get_associated_cancellation_slot(handler); auto intermediate = [reader = std::move(reader), handler = std::move(handler), - this](boost::system::error_code ec, size_t len) mutable - { + this](boost::system::error_code ec, size_t len) mutable { if (!ec) { http::response_parser::value_type& msg = reader->parser.get(); @@ -867,8 +864,7 @@ void ServerSession::destroy() noexcept static std::optional h2c_upgrade(const http::request& request, const boost::urls::url& url, bool complete) { - const auto has_token = [](std::string_view list, std::string_view token) - { + const auto has_token = [](std::string_view list, std::string_view token) { for (auto item : http::token_list(list)) if (beast::iequals(item, token)) return true; @@ -1056,8 +1052,8 @@ awaitable ServerSession::do_session(Buffer&& buffer) mlogi("upgrading to h2c, {} bytes in buffer", m_buffer.size()); // 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)); + 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; @@ -1266,11 +1262,10 @@ void ClientSession::async_submit(SubmitHandler&& handler, std::string_vi auto& serializer = writer->serializer; auto ex = get_associated_executor(handler, super::get_executor()); auto cb = [handler = std::move(handler), writer = std::move(writer)] // - (error_code ec, size_t) mutable - { - writer->header_written(ec); - std::move(handler)(ec, client::Request(std::move(writer))); - }; + (error_code ec, size_t) mutable { + writer->header_written(ec); + std::move(handler)(ec, client::Request(std::move(writer))); + }; async_write_header(m_stream, serializer, bind_executor(ex, std::move(cb))); } @@ -1353,8 +1348,8 @@ template std::shared_ptr make_server_session(server::Serv socket&&); template std::shared_ptr make_server_session(server::Server::Impl&, SslStream&&); -template std::shared_ptr -make_server_session(server::Server::Impl&, any_async_stream&&); +template std::shared_ptr 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 c01a262..782a6a8 100644 --- a/src/h2_session.cpp +++ b/src/h2_session.cpp @@ -509,8 +509,7 @@ void NGHttp2Session::async_submit(SubmitHandler&& handler, std::string_view meth nghttp2_data_provider2 prd; prd.source.ptr = stream.get(); prd.read_callback = [](nghttp2_session* session, int32_t stream_id, uint8_t* buf, size_t length, - uint32_t* data_flags, nghttp2_data_source* source, void*) -> ssize_t - { + uint32_t* data_flags, nghttp2_data_source* source, void*) -> ssize_t { auto stream = static_cast(source->ptr); assert(stream); assert(stream->id == stream_id); @@ -536,10 +535,9 @@ void NGHttp2Session::async_submit(SubmitHandler&& handler, std::string_view meth m_streams.emplace(id, stream); post(get_executor(), [handler = std::move(handler), - writer = std::make_unique>(*stream)]() mutable - { - std::move(handler)(boost::system::error_code{}, client::Request{std::move(writer)}); // - }); + writer = std::make_unique>(*stream)]() mutable { + std::move(handler)(boost::system::error_code{}, client::Request{std::move(writer)}); // + }); start_write(); } @@ -708,13 +706,13 @@ std::shared_ptr make_client_session(client::Client::Impl& client, return std::make_shared>(client, std::move(executor), std::move(stream)); } -template std::shared_ptr -make_server_session(server::Server::Impl&, socket&&, std::optional); +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&, any_async_stream&&, - std::optional); + std::optional); template std::shared_ptr make_client_session(client::Client::Impl&, socket&&); diff --git a/src/h2_stream.cpp b/src/h2_stream.cpp index df604f2..8ba1512 100644 --- a/src/h2_stream.cpp +++ b/src/h2_stream.cpp @@ -141,8 +141,7 @@ void NGHttp2Reader::async_read_some(boost::asio::mutable_buffer buffer, auto cs = asio::get_associated_cancellation_slot(handler); if (cs.is_connected() && !cs.has_handler()) { - cs.assign([this](asio::cancellation_type_t ct) - { + cs.assign([this](asio::cancellation_type_t ct) { logd("[{}] async_read_some: \x1b[1;31m{}\x1b[0m ({})", // stream->logPrefix, "cancelled", int(ct)); @@ -260,8 +259,7 @@ void NGHttp2Writer::async_submit(StatusHandler&& handler, unsigned int sta nghttp2_data_provider2 prd; prd.source.ptr = stream; prd.read_callback = [](nghttp2_session*, int32_t stream_id, uint8_t* buf, size_t length, - uint32_t* data_flags, nghttp2_data_source* source, void*) -> ssize_t - { + uint32_t* data_flags, nghttp2_data_source* source, void*) -> ssize_t { auto stream = static_cast(source->ptr); assert(stream); assert(stream->id == stream_id); @@ -660,8 +658,7 @@ void NGHttp2Stream::async_write(WriteHandler handler, asio::const_buffer buffer, auto slot = asio::get_associated_cancellation_slot(write_handler); if (slot.is_connected() && !slot.has_handler()) { - slot.assign([this](asio::cancellation_type_t ct) - { + slot.assign([this](asio::cancellation_type_t ct) { logd("[{}] async_write: \x1b[1;31m{}\x1b[0m ({})", logPrefix, "cancelled", ct); // delete_writer(); @@ -717,15 +714,13 @@ void NGHttp2Stream::async_get_response(client::Request::GetResponseHandler&& han auto cs = handler.get_cancellation_slot(); if (cs.is_connected()) { - cs.assign([this](asio::cancellation_type_t ct) - { + cs.assign([this](asio::cancellation_type_t ct) { logd("[{}] async_get_response: \x1b[1;31m{}\x1b[0m ({})", logPrefix, "cancelled", ct); if (response_handler) { // auto executor = get_associated_executor(response_handler, get_executor()); - post(get_executor(), [handler = std::move(response_handler)]() mutable - { + post(get_executor(), [handler = std::move(response_handler)]() mutable { std::move(handler)(errc::make_error_code(errc::operation_canceled), client::Response{nullptr}); }); diff --git a/src/h3_client.cpp b/src/h3_client.cpp index 1ed7fbb..fd942a8 100644 --- a/src/h3_client.cpp +++ b/src/h3_client.cpp @@ -152,8 +152,7 @@ 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(std::string_view method, 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(); @@ -311,8 +310,8 @@ void Http3ClientStream::deliver_failure() swap_and_invoke(response_handler, failure_ec, client::Response{nullptr}); } -bool Http3ClientStream::submit_request(std::string_view method, - 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; @@ -359,13 +358,11 @@ void Http3ClientStream::async_get_response(client::Request::GetResponseHandler&& auto cs = handler.get_cancellation_slot(); if (cs.is_connected()) { - cs.assign([this](asio::cancellation_type_t ct) - { + cs.assign([this](asio::cancellation_type_t ct) { logd("[{}] async_get_response: cancelled ({})", log_prefix, ct); if (response_handler) { - asio::post(get_executor(), [handler = std::move(response_handler)]() mutable - { + asio::post(get_executor(), [handler = std::move(response_handler)]() mutable { std::move(handler)(errc::make_error_code(errc::operation_canceled), client::Response{nullptr}); }); @@ -704,8 +701,7 @@ awaitable> async_connect_http3(asio::any_io_execu std::shared_ptr impl = session; - co_spawn(executor, impl->do_session(Buffer{}), [impl](const std::exception_ptr& ex) mutable - { + co_spawn(executor, impl->do_session(Buffer{}), [impl](const std::exception_ptr& ex) mutable { if (ex) logw("client run: {}", what(ex)); else diff --git a/src/h3_server.cpp b/src/h3_server.cpp index 5f7362e..ab4a8dc 100644 --- a/src/h3_server.cpp +++ b/src/h3_server.cpp @@ -556,8 +556,9 @@ void Http3ServerSession::destroy() noexcept // this session's executor first; with use_strand off and the caller already inside the // io_context, dispatch() degenerates to an inline call. // - asio::dispatch(get_executor(), [self = shared_from_this()] - { static_cast(*self).do_destroy(); }); + asio::dispatch(get_executor(), [self = shared_from_this()] { + static_cast(*self).do_destroy(); + }); } void Http3ServerSession::do_destroy() noexcept @@ -825,8 +826,7 @@ void Http3ServerSession::schedule_close_timer() auto delay = conn_ ? std::chrono::nanoseconds{ngtcp2_conn_get_pto(conn_) * 3} : std::chrono::nanoseconds{std::chrono::milliseconds{100}}; timer_.expires_after(delay); - timer_.async_wait([self = weak_from_this()](const boost::system::error_code& ec) - { + timer_.async_wait([self = weak_from_this()](const boost::system::error_code& ec) { if (ec) return; auto session = std::static_pointer_cast(self.lock()); @@ -892,13 +892,12 @@ void Http3ServerImpl::start() { // On the socket's strand, so that the loop and destroy()'s close() never race on the socket. co_spawn(socket_->get_executor(), udp_receive_loop(), - [self = shared_from_this(), owner = owner()](const std::exception_ptr& ex) - { - if (ex) - logw("UDP receive loop: {}", what(ex)); - else - logi("UDP receive loop: done"); - }); + [self = shared_from_this(), owner = owner()](const std::exception_ptr& ex) { + if (ex) + logw("UDP receive loop: {}", what(ex)); + else + logi("UDP receive loop: done"); + }); } void Http3ServerImpl::destroy() @@ -910,8 +909,9 @@ void Http3ServerImpl::destroy() // Server::Impl at this point, each sending its final CONNECTION_CLOSE through its own // dup()ed fd, so closing this socket doesn't race that. // - asio::dispatch(socket_->get_executor(), [self = shared_from_this(), owner = owner()] - { self->socket_->close(); }); // breaks udp_receive_loop() + asio::dispatch(socket_->get_executor(), [self = shared_from_this(), owner = owner()] { + self->socket_->close(); + }); // breaks udp_receive_loop() } // ------------------------------------------------------------------------------------------------- @@ -1075,10 +1075,9 @@ int Http3ServerImpl::udp_on_read(Endpoint& ep) // for (auto& [session, batch] : batches) { - asio::post(session->get_executor(), - [self = shared_from_this(), owner = owner(), session, - batch = std::move(batch)]() mutable { // - self->process_quic_batch(session, std::move(batch)); + asio::post(session->get_executor(), [self = shared_from_this(), owner = owner(), session, + batch = std::move(batch)]() mutable { // + self->process_quic_batch(session, std::move(batch)); }); } @@ -1133,12 +1132,11 @@ void Http3ServerImpl::process_quic_batch(const std::shared_ptrget_executor(), session->do_session({}), - [self = shared_from_this(), owner = owner(), session](const std::exception_ptr& ex) - { - if (ex) - logw("[{}] {}", session->logPrefix(), what(ex)); - self->parent_.remove_session(session); - }); + [self = shared_from_this(), owner = owner(), session](const std::exception_ptr& ex) { + if (ex) + logw("[{}] {}", session->logPrefix(), what(ex)); + self->parent_.remove_session(session); + }); } for (; next < batch.datagrams.size(); ++next) diff --git a/src/h3_session.cpp b/src/h3_session.cpp index b04504b..7acc5a6 100644 --- a/src/h3_session.cpp +++ b/src/h3_session.cpp @@ -114,8 +114,7 @@ void Http3Session::wake_write() return; write_posted_ = true; - asio::post(get_executor(), [self = weak_from_this()] - { + asio::post(get_executor(), [self = weak_from_this()] { auto session = std::static_pointer_cast(self.lock()); if (!session) return; @@ -324,8 +323,7 @@ void Http3Session::arm_timer_from_ngtcp2() expiry <= now ? std::chrono::nanoseconds{1} : std::chrono::nanoseconds{expiry - now}; timer_.expires_after(delay); - timer_.async_wait([self = weak_from_this()](const boost::system::error_code& ec) - { + timer_.async_wait([self = weak_from_this()](const boost::system::error_code& ec) { if (ec) return; if (auto session = std::static_pointer_cast(self.lock())) @@ -564,8 +562,7 @@ int Http3Session::setup_http3() int Http3Session::cb_handshake_completed(ngtcp2_conn*, void* user) { auto self = static_cast(user); - logi("[{}] TLS handshake completed: {}", self->log_prefix_, - tls_handshake_info(self->ssl_)); + logi("[{}] TLS handshake completed: {}", self->log_prefix_, tls_handshake_info(self->ssl_)); if (self->setup_http3() != 0) return NGTCP2_ERR_CALLBACK_FAILURE; return 0; diff --git a/src/h3_stream.cpp b/src/h3_stream.cpp index cb56dbb..53a17cb 100644 --- a/src/h3_stream.cpp +++ b/src/h3_stream.cpp @@ -295,8 +295,7 @@ void Http3Stream::bind_write_cancellation(WriteHandler& handler, uint64_t token) if (!cs.is_connected() || cs.has_handler()) return; - cs.assign([this, token](asio::cancellation_type_t ct) - { + cs.assign([this, token](asio::cancellation_type_t ct) { // // Cancellation completes the write immediately, without waiting for what it would normally // complete on -- see below for what that costs in either write mode. @@ -404,8 +403,7 @@ nghttp3_ssize Http3Stream::data_reader(nghttp3_vec* vec, size_t veccnt, uint32_t // the FIN does not complete the write: in ZeroCopy it still completes on acknowledgement, in // Staged on confirmation of that final chunk. // - auto flag_eof_if_last = [&] - { + auto flag_eof_if_last = [&] { const size_t handed = write_mode == WriteMode::ZeroCopy ? write_offered : write_source_copied; if (write_is_eof && handed == total) { diff --git a/src/research/sender.cpp b/src/research/sender.cpp index 8ea511e..ce31875 100644 --- a/src/research/sender.cpp +++ b/src/research/sender.cpp @@ -1,59 +1,67 @@ #include -#include #include -#include +#include #include +#include namespace asio = boost::asio; namespace ex = stdexec; -class async_read_until_sender { +class async_read_until_sender +{ public: - async_read_until_sender(asio::ip::tcp::socket& socket, std::vector& buffer, char delimiter) - : socket_(socket), buffer_(buffer), delimiter_(delimiter) {} - - template - struct operation { - asio::ip::tcp::socket& socket_; - std::vector& buffer_; - char delimiter_; - Receiver receiver_; - - void start() { - do_read(); - } - - private: - void do_read() { - buffer_.resize(buffer_.size() + 512); - socket_.async_read_some(asio::buffer(buffer_.data() + buffer_.size() - 512, 512), - [this](boost::system::error_code ec, std::size_t bytes_transferred) mutable { - if (ec) { - ex::set_error(std::move(receiver_), ec); - return; - } - - buffer_.resize(buffer_.size() - 512 + bytes_transferred); - auto pos = std::find(buffer_.begin(), buffer_.end(), delimiter_); - if (pos != buffer_.end()) { - std::size_t delimiter_pos = std::distance(buffer_.begin(), pos); - buffer_.resize(delimiter_pos + 1); - ex::set_value(std::move(receiver_), delimiter_pos + 1); - } else { - do_read(); - } - } - ); - } - }; - - template - operation connect(Receiver receiver) { - return {socket_, buffer_, delimiter_, std::move(receiver)}; - } + async_read_until_sender(asio::ip::tcp::socket& socket, std::vector& buffer, char delimiter) + : socket_(socket), buffer_(buffer), delimiter_(delimiter) + { + } + + template + struct operation + { + asio::ip::tcp::socket& socket_; + std::vector& buffer_; + char delimiter_; + Receiver receiver_; + + void start() { do_read(); } + + private: + void do_read() + { + buffer_.resize(buffer_.size() + 512); + socket_.async_read_some( + asio::buffer(buffer_.data() + buffer_.size() - 512, 512), + [this](boost::system::error_code ec, std::size_t bytes_transferred) mutable { + if (ec) + { + ex::set_error(std::move(receiver_), ec); + return; + } + + buffer_.resize(buffer_.size() - 512 + bytes_transferred); + auto pos = std::find(buffer_.begin(), buffer_.end(), delimiter_); + if (pos != buffer_.end()) + { + std::size_t delimiter_pos = std::distance(buffer_.begin(), pos); + buffer_.resize(delimiter_pos + 1); + ex::set_value(std::move(receiver_), delimiter_pos + 1); + } + else + { + do_read(); + } + }); + } + }; + + template + operation connect(Receiver receiver) + { + return {socket_, buffer_, delimiter_, std::move(receiver)}; + } private: - asio::ip::tcp::socket& socket_; - std::vector& buffer_; - char delimiter_; + asio::ip::tcp::socket& socket_; + std::vector& buffer_; + char delimiter_; }; \ No newline at end of file diff --git a/src/server_impl.cpp b/src/server_impl.cpp index 65d7bdf..1801062 100644 --- a/src/server_impl.cpp +++ b/src/server_impl.cpp @@ -96,13 +96,13 @@ Server::Impl::Impl(boost::asio::any_io_executor executor, Config config) */ void Server::Impl::start() { - co_spawn(m_executor, tcp_accept_loop(), [self = shared_from_this()](const std::exception_ptr& ex) - { - if (ex) - logw("TCP accept loop: {}", what(ex)); - else - logi("TCP accept loop: done"); - }); + co_spawn(m_executor, tcp_accept_loop(), + [self = shared_from_this()](const std::exception_ptr& ex) { + if (ex) + logw("TCP accept loop: {}", what(ex)); + else + logi("TCP accept loop: done"); + }); if (m_http3) m_http3->start(); @@ -422,15 +422,14 @@ awaitable Server::Impl::tcp_accept_loop() 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; - if (ex) - logw("[{}] {}", ep, what(ex)); - else - logi("[{}] session finished, {} sessions left", ep, sessionCounter); - }); + [&, ep](const std::exception_ptr& ex) mutable { + auto lock = std::lock_guard(m_sessionMutex); + --sessionCounter; + if (ex) + logw("[{}] {}", ep, what(ex)); + else + logi("[{}] session finished, {} sessions left", ep, sessionCounter); + }); } // diff --git a/src/server_main.cpp b/src/server_main.cpp index b478222..6020592 100644 --- a/src/server_main.cpp +++ b/src/server_main.cpp @@ -79,8 +79,8 @@ std::expected parseConfig(int argc, char* argv[]) config.server.alt_svc_max_age = std::chrono::seconds{std::max(0L, alt_svc_max_age)}; // 'verbose' takes no argument, so its parsed value is always empty -- count occurrences - config.verbose = std::ranges::count_if(parsed.options, [](const po::option& option) - { return option.string_key == "verbose"; }); + config.verbose = std::ranges::count_if( + parsed.options, [](const po::option& option) { return option.string_key == "verbose"; }); } catch (const po::error& error) { @@ -133,44 +133,42 @@ int main(int argc, char* argv[]) auto server = std::make_optional(executor, config->server); signal_set signals(context, SIGINT, SIGTERM); - signals.async_wait([&](boost::system::error_code error, auto signal) - { + signals.async_wait([&](boost::system::error_code error, auto signal) { std::println(" INTERRUPTED (signal {})", signal); logw("interrupt"); server.reset(); }); server->setRequestHandler( - [](server::Request request, server::Response response) -> awaitable - { - std::string path = request.url().path(); - if (path == "/echo") - co_await echo(std::move(request), std::move(response)); - else if (path == "/generate") - co_await generate(std::move(request), std::move(response)); - else if (path == "/dump") - co_await dump(std::move(request), std::move(response)); - else if (path == "/dump space") - co_await dump(std::move(request), std::move(response)); - else if (path == "/discard") - co_return; - else if (path == "/test" || path.starts_with("/test/")) - co_await serve_file(std::move(request), std::move(response), "test", "/test"); - else if (path == "/eat_request") - co_await eat_request(std::move(request), std::move(response)); - else if (path == "/upload") - { - // Unlike eat_request, respond only after the whole body is in: clients such as h2load - // stop uploading as soon as the response is complete. - co_await drain(request); - co_await response.async_submit(200, {}); - co_await response.async_write_eof(); - } - else if (path == "/" || path == "/h2spec") - co_await h2spec(std::move(request), std::move(response)); - else - co_await not_found(std::move(response)); - }); + [](server::Request request, server::Response response) -> awaitable { + std::string path = request.url().path(); + if (path == "/echo") + co_await echo(std::move(request), std::move(response)); + else if (path == "/generate") + co_await generate(std::move(request), std::move(response)); + else if (path == "/dump") + co_await dump(std::move(request), std::move(response)); + else if (path == "/dump space") + co_await dump(std::move(request), std::move(response)); + else if (path == "/discard") + co_return; + else if (path == "/test" || path.starts_with("/test/")) + co_await serve_file(std::move(request), std::move(response), "test", "/test"); + else if (path == "/eat_request") + co_await eat_request(std::move(request), std::move(response)); + else if (path == "/upload") + { + // Unlike eat_request, respond only after the whole body is in: clients such as h2load + // stop uploading as soon as the response is complete. + co_await drain(request); + co_await response.async_submit(200, {}); + co_await response.async_write_eof(); + } + else if (path == "/" || path == "/h2spec") + co_await h2spec(std::move(request), std::move(response)); + else + co_await not_found(std::move(response)); + }); auto threads = rv::iota(0) | rv::take(config->threads > 0 ? config->threads - 1 : 0) | rv::transform([&](size_t) { return std::thread([&] { context.run(); }); }) | diff --git a/src/session.cpp b/src/session.cpp index 066ef52..b018ed9 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -51,10 +51,7 @@ Session::~Session() { reset(); } // ------------------------------------------------------------------------------------------------- -boost::asio::any_io_executor Session::get_executor() const noexcept -{ - return impl->get_executor(); -} +boost::asio::any_io_executor Session::get_executor() const noexcept { return impl->get_executor(); } void Session::async_submit_any(SubmitHandler&& handler, boost::urls::url url, const Fields& headers) { @@ -90,8 +87,8 @@ auto async_submit(Session::Impl& impl, std::string_view method, boost::urls::url // 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) +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 @@ -143,8 +140,7 @@ void Session::async_get_any(GetHandler&& handler, boost::urls::url url, const Fi bind_cancellation_slot( slot, bind_executor(executor, [handler = std::move(handler)]( const std::exception_ptr& ep, - client::Message message) mutable - { + client::Message message) mutable { auto ec = code(ep); if (ec) message.result(boost::beast::http::status::unknown); // not the Beast default diff --git a/test/test_alt_svc.cpp b/test/test_alt_svc.cpp index fb78a13..9ccf7d5 100644 --- a/test/test_alt_svc.cpp +++ b/test/test_alt_svc.cpp @@ -151,8 +151,7 @@ INSTANTIATE_TEST_SUITE_P(AltSvcUpgrade, AltSvcUpgrade, TEST_P(AltSvcUpgrade, WHEN_the_server_advertises_h3_THEN_the_next_connection_uses_it) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto first = co_await session.async_get(echo()); EXPECT_EQ(first.result_int(), 200); EXPECT_FALSE(served_over_http3(first)); @@ -172,8 +171,7 @@ TEST_P(AltSvcUpgrade, WHEN_the_server_advertises_h3_THEN_the_next_connection_use // TEST_P(AltSvcUpgrade, WHEN_the_alternative_is_learned_THEN_the_session_that_learned_it_stays) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { EXPECT_FALSE(served_over_http3(co_await session.async_get(echo()))); EXPECT_FALSE(served_over_http3(co_await session.async_get(echo()))); }; @@ -181,15 +179,13 @@ TEST_P(AltSvcUpgrade, WHEN_the_alternative_is_learned_THEN_the_session_that_lear TEST_P(AltSvcUpgrade, WHEN_the_server_clears_the_alternative_THEN_it_is_not_used) { - requestHandler = [](server::Request request, server::Response response) -> awaitable - { + requestHandler = [](server::Request request, server::Response response) -> awaitable { co_await drain(request); co_await response.async_submit(200, fields({{"Alt-Svc", "clear"}, {"Content-Length", 0}})); co_await response.async_write_eof(); }; - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { EXPECT_THAT(std::string((co_await session.async_get(echo()))[http::field::alt_svc]), HasSubstr("h3=")); @@ -216,8 +212,7 @@ INSTANTIATE_TEST_SUITE_P(AltSvcIgnored, AltSvcIgnored, TEST_P(AltSvcIgnored, WHEN_the_client_does_not_follow_alt_svc_THEN_it_keeps_its_protocol) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto target = url; target.set_path("/echo"); @@ -246,8 +241,7 @@ INSTANTIATE_TEST_SUITE_P(AltSvcDisabled, AltSvcDisabled, TEST_P(AltSvcDisabled, WHEN_the_server_advertises_nothing_THEN_the_client_stays_where_it_is) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto target = url; target.set_path("/echo"); @@ -282,20 +276,19 @@ nghttp2_nv nv(std::string_view name, std::string_view value) */ awaitable serve_h2_with_altsvc(tcp::socket socket, std::string origin, std::string value) { - auto callbacks = std::invoke([] - { + auto callbacks = std::invoke([] { nghttp2_session_callbacks* cbs; nghttp2_session_callbacks_new(&cbs); nghttp2_session_callbacks_set_on_frame_recv_callback( - cbs, [](nghttp2_session* session, const nghttp2_frame* frame, void*) -> int - { - if (frame->hd.type == NGHTTP2_HEADERS && frame->headers.cat == NGHTTP2_HCAT_REQUEST) - { - std::array nva{nv(":status", "200"), nv("content-length", "0")}; - nghttp2_submit_response2(session, frame->hd.stream_id, nva.data(), nva.size(), nullptr); - } - return 0; - }); + cbs, [](nghttp2_session* session, const nghttp2_frame* frame, void*) -> int { + if (frame->hd.type == NGHTTP2_HEADERS && frame->headers.cat == NGHTTP2_HCAT_REQUEST) + { + std::array nva{nv(":status", "200"), nv("content-length", "0")}; + nghttp2_submit_response2(session, frame->hd.stream_id, nva.data(), nva.size(), + nullptr); + } + return 0; + }); return std::unique_ptr{ cbs, nghttp2_session_callbacks_del}; }); @@ -353,37 +346,39 @@ TEST_P(AltSvcFrame, WHEN_an_altsvc_frame_arrives_THEN_the_next_connection_uses_i auto origin = std::format("http://127.0.0.2:{}", acceptor.local_endpoint().port()); auto value = std::format("h3=\":{}\"", server->local_endpoint().port()); - co_spawn(context, [&]() -> awaitable - { - auto socket = co_await acceptor.async_accept(); - co_await serve_h2_with_altsvc(std::move(socket), origin, value); - }, [](const std::exception_ptr& ex) { logi("bare HTTP/2 server: {}", what(ex)); }); + co_spawn( + context, + [&]() -> awaitable { + auto socket = co_await acceptor.async_accept(); + co_await serve_h2_with_altsvc(std::move(socket), origin, value); + }, + [](const std::exception_ptr& ex) { logi("bare HTTP/2 server: {}", what(ex)); }); boost::urls::url target{"http://127.0.0.2/echo"}; target.set_port_number(acceptor.local_endpoint().port()); client::Client client(context.get_executor(), {.url = target, .protocol = Protocol::h2, .follow_alt_svc = true}); - co_spawn(context, - [&]() -> awaitable - { - // - // The response itself carries no "Alt-Svc" -- everything the client learns here, it learns - // from the frame that arrived before the request was even sent. - // - auto first = co_await (co_await client.async_connect()).async_get(target); - EXPECT_EQ(first.result_int(), 200); - EXPECT_EQ(first[http::field::alt_svc], ""); - - auto second = co_await (co_await client.async_connect()).async_get(target); - EXPECT_EQ(second.result_int(), 200); - EXPECT_TRUE(served_over_http3(second)) << "the second connection is not HTTP/3"; - }, [&](const std::exception_ptr& ex) - { - EXPECT_FALSE(ex) << what(ex); - acceptor.close(); - server.reset(); - }); + co_spawn( + context, + [&]() -> awaitable { + // + // The response itself carries no "Alt-Svc" -- everything the client learns here, it learns + // from the frame that arrived before the request was even sent. + // + auto first = co_await (co_await client.async_connect()).async_get(target); + EXPECT_EQ(first.result_int(), 200); + EXPECT_EQ(first[http::field::alt_svc], ""); + + auto second = co_await (co_await client.async_connect()).async_get(target); + EXPECT_EQ(second.result_int(), 200); + EXPECT_TRUE(served_over_http3(second)) << "the second connection is not HTTP/3"; + }, + [&](const std::exception_ptr& ex) { + EXPECT_FALSE(ex) << what(ex); + acceptor.close(); + server.reset(); + }); run(); } diff --git a/test/test_client_async.cpp b/test/test_client_async.cpp index 3b3b0e1..6e503a0 100644 --- a/test/test_client_async.cpp +++ b/test/test_client_async.cpp @@ -22,8 +22,7 @@ INSTANTIATE_TEST_SUITE_P(ClientAsync, ClientAsync, TEST_P(ClientAsync, WHEN_post_data_THEN_receive_echo) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); size_t bytes = 1024; auto count = co_await (generate(request, bytes) && count_response(request)); @@ -33,8 +32,7 @@ TEST_P(ClientAsync, WHEN_post_data_THEN_receive_echo) TEST_P(ClientAsync, WHEN_post_without_path_THEN_error_404) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path(""), {}); co_await generate(request, 1024); auto [ec, response] = co_await request.async_get_response(as_tuple); @@ -44,8 +42,7 @@ TEST_P(ClientAsync, WHEN_post_without_path_THEN_error_404) TEST_P(ClientAsync, WHEN_post_to_unknown_path_THEN_error_404) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("unknown"), {}); co_await generate(request, 1_m); auto response = co_await request.async_get_response(); @@ -56,8 +53,7 @@ TEST_P(ClientAsync, WHEN_post_to_unknown_path_THEN_error_404) TEST_P(ClientAsync, WHEN_server_discards_request_THEN_error_500) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("discard"), {}); co_await generate(request, 1024); auto [ec, response] = co_await request.async_get_response(as_tuple); @@ -67,8 +63,7 @@ TEST_P(ClientAsync, WHEN_server_discards_request_THEN_error_500) TEST_P(ClientAsync, WHEN_server_discards_request_delayed_THEN_error_500) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("detach"), {}); co_await generate(request, 1024); auto [ec, response] = co_await request.async_get_response(as_tuple); @@ -78,8 +73,7 @@ TEST_P(ClientAsync, WHEN_server_discards_request_delayed_THEN_error_500) TEST_P(ClientAsync, WHEN_server_discards_request_with_body_delayed_THEN_error_500) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto executor = co_await this_coro::executor; auto request = co_await session.async_submit(url.set_path("detach"), {}); auto [ep] = co_await co_spawn(executor, send(request, rv::iota(uint8_t{0})), as_tuple); @@ -89,8 +83,7 @@ TEST_P(ClientAsync, WHEN_server_discards_request_with_body_delayed_THEN_error_50 TEST_P(ClientAsync, WHEN_invalid_port_in_host_header_THEN_reports_error) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { Fields fields; fields.set("Host", "host:12345x"); auto request = co_await session.async_submit(url.set_path("echo"), fields); @@ -100,8 +93,7 @@ TEST_P(ClientAsync, WHEN_invalid_port_in_host_header_THEN_reports_error) TEST_P(ClientAsync, WHEN_get_response_is_called_twice_THEN_reports_error) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo")); auto [ec, response] = co_await request.async_get_response(as_tuple); EXPECT_EQ(ec, boost::system::errc::success); @@ -116,8 +108,7 @@ TEST_P(ClientAsync, WHEN_get_response_is_detached_THEN_does_not_crash) if (GetParam() == anyhttp::Protocol::http11) GTEST_SKIP(); - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo")); request.async_get_response(detached); }; @@ -145,8 +136,7 @@ TEST_P(ClientAsync, WHEN_session_is_gone_THEN_request_reports_error) if (GetParam() != anyhttp::Protocol::http11) GTEST_SKIP(); - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); session.reset(); @@ -165,29 +155,28 @@ TEST_P(ClientAsync, WHEN_server_session_is_gone_THEN_response_reports_error) { auto responded = std::make_shared(false); requestHandler = [responded](server::Request request, - server::Response response) -> awaitable - { + server::Response response) -> awaitable { // // Keep the response around beyond the request handler, until the client has closed the // connection and the server session has ended. // - co_spawn(co_await this_coro::executor, - [responded, response = std::move(response)]() mutable -> awaitable - { - co_await sleep(100ms); + co_spawn( + co_await this_coro::executor, + [responded, response = std::move(response)]() mutable -> awaitable { + co_await sleep(100ms); - auto [ec] = co_await response.async_submit(200, {}, as_tuple); - EXPECT_TRUE(is_connection_error(ec)) << what(ec); + auto [ec] = co_await response.async_submit(200, {}, as_tuple); + EXPECT_TRUE(is_connection_error(ec)) << what(ec); - std::tie(ec) = co_await response.async_write(asio::buffer("Hello"sv), as_tuple); - EXPECT_TRUE(is_connection_error(ec)) << what(ec); + std::tie(ec) = co_await response.async_write(asio::buffer("Hello"sv), as_tuple); + EXPECT_TRUE(is_connection_error(ec)) << what(ec); - *responded = true; - }, detached); + *responded = true; + }, + detached); co_return; }; - clientSession = [this, responded](Session session) -> awaitable - { + clientSession = [this, responded](Session session) -> awaitable { auto request = co_await session.async_submit(url, {}); co_await request.async_write_eof(); request.reset(); @@ -210,8 +199,7 @@ TEST_P(ClientAsync, if (GetParam() != anyhttp::Protocol::http11) GTEST_SKIP(); - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request1 = co_await session.async_submit(url.set_path("echo"), {}); co_await request1.async_write_eof(asio::buffer("Hello, Server #1!"sv)); auto request2 = co_await session.async_submit(url.set_path("echo"), {}); @@ -229,8 +217,7 @@ TEST_P(ClientAsync, WHEN_session_is_gone_THEN_earlier_request_reports_error) if (GetParam() != anyhttp::Protocol::http11) GTEST_SKIP(); - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request1 = co_await session.async_submit(url.set_path("echo"), {}); co_await request1.async_write_eof(asio::buffer("Hello, Server #1!"sv)); auto request2 = co_await session.async_submit(url.set_path("echo"), {}); @@ -248,8 +235,7 @@ TEST_P(ClientAsync, if (GetParam() != anyhttp::Protocol::http11) GTEST_SKIP(); - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request1 = co_await session.async_submit(url.set_path("echo"), {}); co_await request1.async_write_eof(asio::buffer("Hello, Server #1!"sv)); auto request2 = co_await session.async_submit(url.set_path("echo"), {}); @@ -273,13 +259,11 @@ TEST_P(ClientAsync, TEST_P(ClientAsync, WHEN_server_discards_request_while_writing_THEN_connection_is_reset) { - requestHandler = [this](server::Request request, server::Response response) -> awaitable - { + requestHandler = [this](server::Request request, server::Response response) -> awaitable { co_await sleep(150ms); request.reset(); }; - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url); auto executor = co_await this_coro::executor; auto [ec] = co_await co_spawn(executor, send(request, rv::iota(uint8_t(0))), as_tuple); @@ -300,14 +284,12 @@ TEST_P(ClientAsync, WHEN_server_discards_request_and_response_THEN_completes_any // if (GetParam() == anyhttp::Protocol::http11) // GTEST_SKIP(); // FIXME: timeout - requestHandler = [this](server::Request request, server::Response response) -> awaitable - { + requestHandler = [this](server::Request request, server::Response response) -> awaitable { std::ignore = request; std::ignore = response; co_return; }; - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url); auto [ec, _] = co_await request.async_get_response(as_tuple); EXPECT_EQ(ec, boost::beast::http::error::end_of_stream); @@ -320,8 +302,7 @@ TEST_P(ClientAsync, WHEN_client_cancels_write_THEN_can_resume) if (GetParam() == anyhttp::Protocol::http11) GTEST_SKIP(); // a chunked body cannot be cancelled correctly --> disconnects - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { co_await this_coro::throw_if_cancelled(false); auto executor = co_await this_coro::executor; auto request = co_await session.async_submit(url.set_path("echo")); @@ -366,8 +347,7 @@ TEST_P(ClientAsync, YieldFuzz) static std::mt19937 gen(42); // fixed seed for reproducibility #endif - requestHandler = [this](server::Request request, server::Response response) -> awaitable - { + requestHandler = [this](server::Request request, server::Response response) -> awaitable { std::uniform_int_distribution<> dist(0, 10); constexpr auto msg = "Hello, Client!"sv; co_await yield(dist(gen)); @@ -382,8 +362,7 @@ TEST_P(ClientAsync, YieldFuzz) std::array data; co_await request.async_read_some(asio::buffer(data), as_tuple); }; - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { std::uniform_int_distribution<> dist(0, 10); for (size_t i = 0; i < 100; ++i) { @@ -411,14 +390,12 @@ TEST_P(ClientAsync, YieldFuzz) TEST_P(ClientAsync, WHEN_body_ends_THEN_read_reports_eof) { static const auto hello = "Hello, World!"sv; - requestHandler = [this](server::Request request, server::Response response) -> awaitable - { + requestHandler = [this](server::Request request, server::Response response) -> awaitable { co_await drain(request); co_await response.async_submit(200, fields({{"Content-Length", hello.size()}})); co_await response.async_write_eof(asio::buffer(hello)); }; - clientSession = [this](Session session) -> awaitable - { + clientSession = [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(); @@ -462,15 +439,13 @@ TEST_P(ClientAsync, WHEN_body_ends_THEN_read_reports_eof) TEST_P(ClientAsync, WHEN_empty_buffer_is_written_THEN_body_stays_open) { static const auto tail = "still here"sv; - requestHandler = [this](server::Request request, server::Response response) -> awaitable - { + requestHandler = [this](server::Request request, server::Response response) -> awaitable { EXPECT_EQ(co_await drain(request), 0u); co_await response.async_submit(200, {}); co_await response.async_write({}); // writes nothing, leaves the body open co_await response.async_write_eof(asio::buffer(tail)); }; - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url); co_await request.async_write({}); // likewise: the request body stays open co_await request.async_write_eof(); @@ -487,8 +462,7 @@ TEST_P(ClientAsync, WHEN_empty_buffer_is_written_THEN_body_stays_open) TEST_P(ClientAsync, WHEN_written_after_eof_THEN_reports_broken_pipe) { static const auto hello = "Hello, World!"sv; - requestHandler = [this](server::Request request, server::Response response) -> awaitable - { + requestHandler = [this](server::Request request, server::Response response) -> awaitable { co_await drain(request); co_await response.async_submit(200, fields({{"Content-Length", hello.size()}})); co_await response.async_write_eof(asio::buffer(hello)); @@ -505,20 +479,19 @@ TEST_P(ClientAsync, WHEN_written_after_eof_THEN_reports_broken_pipe) std::tie(ec) = co_await response.async_write_eof(asio::buffer(hello), as_tuple); EXPECT_EQ(ec, boost::system::errc::broken_pipe); }; - clientSession = [this](Session session) -> awaitable - { EXPECT_EQ((co_await session.async_get(url)).body(), hello); }; + clientSession = [this](Session session) -> awaitable { + EXPECT_EQ((co_await session.async_get(url)).body(), hello); + }; } TEST_P(ClientAsync, HelloWorld) { static const auto hello = "Hello, World!"sv; - requestHandler = [this](server::Request request, server::Response response) -> awaitable - { + requestHandler = [this](server::Request request, server::Response response) -> awaitable { co_await response.async_submit(200, {}); co_await response.async_write_eof(asio::buffer(hello)); }; - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto message = co_await session.async_get(url); EXPECT_EQ(message.result_int(), 200); EXPECT_EQ(message.body(), hello); @@ -535,23 +508,22 @@ TEST_P(ClientAsync, HelloWorld) // TEST_P(ClientAsync, WHEN_server_writes_large_buffer_at_once_THEN_receives_all) { - static const std::vector body = [] - { + static const std::vector body = [] { std::vector data(256_k); std::ranges::generate(data, [i = uint8_t(0)]() mutable { return i++; }); return data; }(); - requestHandler = [this](server::Request request, server::Response response) -> awaitable - { + requestHandler = [this](server::Request request, server::Response response) -> awaitable { // drain the request -- HTTP/1.1 closes the connection on an unfinished parser co_await drain(request); co_await response.async_submit(200, fields({{"Content-Length", body.size()}})); co_await response.async_write_eof(asio::buffer(body)); }; - clientSession = [this](Session session) -> awaitable - { EXPECT_EQ((co_await session.async_get(url)).body().size(), body.size()); }; + clientSession = [this](Session session) -> awaitable { + EXPECT_EQ((co_await session.async_get(url)).body().size(), body.size()); + }; } // @@ -564,8 +536,7 @@ TEST_P(ClientAsync, WHEN_server_cancels_write_eof_THEN_client_sees_truncated_bod { static const std::vector body(8_m, 'x'); - requestHandler = [this](server::Request request, server::Response response) -> awaitable - { + requestHandler = [this](server::Request request, server::Response response) -> awaitable { co_await drain(request); co_await response.async_submit(200, {}); @@ -577,8 +548,7 @@ TEST_P(ClientAsync, WHEN_server_cancels_write_eof_THEN_client_sees_truncated_bod co_await response.async_write_eof(asio::buffer(body), cancel_after(50ms, as_tuple)); EXPECT_EQ(ec, boost::system::errc::operation_canceled); }; - clientSession = [this](Session session) -> awaitable - { + clientSession = [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(); @@ -606,16 +576,16 @@ TEST_P(ClientAsync, WHEN_client_cancels_write_eof_THEN_can_still_end) static const std::vector body(8_m, 'x'); - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { co_await this_coro::throw_if_cancelled(false); auto executor = co_await this_coro::executor; auto request = co_await session.async_submit(url.set_path("echo")); auto response = co_await request.async_get_response(); // far more than the send window, with nobody reading the echo yet: this cannot complete - auto write_eof = [&]() -> awaitable - { co_await request.async_write_eof(asio::buffer(body)); }; + auto write_eof = [&]() -> awaitable { + co_await request.async_write_eof(asio::buffer(body)); + }; auto [ep] = co_await co_spawn(executor, write_eof(), cancel_after(100ms, as_tuple)); EXPECT_EQ(code(ep), boost::system::errc::operation_canceled); @@ -635,8 +605,7 @@ TEST_P(ClientAsync, WHEN_server_cancels_write_THEN_client_sees_truncated_body) { static const std::vector body(8_m, 'x'); - requestHandler = [this](server::Request request, server::Response response) -> awaitable - { + requestHandler = [this](server::Request request, server::Response response) -> awaitable { // drain the request -- HTTP/1.1 closes the connection on an unfinished parser co_await drain(request); @@ -651,8 +620,7 @@ TEST_P(ClientAsync, WHEN_server_cancels_write_THEN_client_sees_truncated_body) co_await co_spawn(executor, send(response, std::span(body)), cancel_after(50ms, as_tuple)); EXPECT_EQ(code(ep), boost::system::errc::operation_canceled); }; - clientSession = [this](Session session) -> awaitable - { + clientSession = [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(); @@ -677,15 +645,15 @@ TEST_P(ClientAsync, WHEN_server_cancels_write_THEN_client_sees_truncated_body) TEST_P(ClientAsync, ServerYieldFirst) { - requestHandler = [this](server::Request request, server::Response response) -> awaitable - { + requestHandler = [this](server::Request request, server::Response response) -> awaitable { co_await yield(10); co_await response.async_submit(200, {}); co_await yield(10); co_await response.async_write_eof(); }; - clientSession = [this](Session session) -> awaitable - { EXPECT_EQ((co_await session.async_get(url)).result_int(), 200); }; + clientSession = [this](Session session) -> awaitable { + EXPECT_EQ((co_await session.async_get(url)).result_int(), 200); + }; } // ---------------------------------------------------------------------------------------------- @@ -724,8 +692,7 @@ TEST_P(ClientAsync, Recursion) if (!stackRemainingBytes()) GTEST_SKIP() << "unable to measure stack on this platform"; - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto ex = co_await this_coro::executor; auto request = co_await session.async_submit(url.set_path("echo"), {}); auto response = co_await request.async_get_response(); @@ -749,8 +716,7 @@ TEST_P(ClientAsync, Recursion) TEST_P(ClientAsync, Custom) { - requestHandler = [this](server::Request request, server::Response response) -> awaitable - { + requestHandler = [this](server::Request request, server::Response response) -> awaitable { co_await response.async_submit(200, {}); std::array buffer; for (;;) @@ -764,8 +730,7 @@ TEST_P(ClientAsync, Custom) co_await response.async_write(asio::buffer(buffer, n)); } }; - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url, {}); constexpr size_t bytes = 1024; auto count = co_await (generate(request, bytes) && count_response(request)); @@ -775,13 +740,11 @@ TEST_P(ClientAsync, Custom) TEST_P(ClientAsync, IgnoreRequest) { - requestHandler = [this](server::Request request, server::Response response) -> awaitable - { + requestHandler = [this](server::Request request, server::Response response) -> awaitable { co_await response.async_submit(200, {}); co_await response.async_write_eof(); }; - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { Fields fields; fields.set("content-length", "0"); auto request = co_await session.async_submit(url, fields); @@ -792,14 +755,12 @@ TEST_P(ClientAsync, IgnoreRequest) TEST_P(ClientAsync, IgnoreRequestAndResponse) { - requestHandler = [this](server::Request request, server::Response response) -> awaitable - { + requestHandler = [this](server::Request request, server::Response response) -> awaitable { std::ignore = request; std::ignore = response; co_return; }; - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url, {}); auto res = co_await (generate(request, 0) && try_read_response(request)); EXPECT_FALSE(res.has_value()); @@ -811,8 +772,7 @@ TEST_P(ClientAsync, IgnoreRequestAndResponse) TEST_P(ClientAsync, PostRange) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); // co_await request.async_write(asio::buffer("ping"sv)); // FIXME: auto response = co_await request.async_get_response(); @@ -828,8 +788,7 @@ TEST_P(ClientAsync, PostRange) TEST_P(ClientAsync, PostRangeImmediate) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); auto sender = sendAndForceEOF(request, rv::iota(uint8_t(0)) | rv::take(1_m)); auto received = co_await (std::move(sender) && count_response(request)); @@ -842,8 +801,7 @@ TEST_P(ClientAsync, PostRangeImmediate) TEST_P(ClientAsync, WHEN_request_is_sent_THEN_response_is_received_before_body_is_posted) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); auto response = co_await request.async_get_response(); constexpr size_t bytes = 1024; @@ -862,8 +820,7 @@ TEST_P(ClientAsync, WHEN_request_is_sent_THEN_response_is_received_before_body_i // TEST_P(ClientAsync, WHEN_multiple_request_are_made_THEN_responses_are_received_in_order) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request1 = co_await session.async_submit(url.set_path("echo"), {}); co_await request1.async_write_eof(asio::buffer("Hello, Server #1!"sv)); @@ -890,8 +847,7 @@ static constexpr auto body2 = "Hello, Server #2! XYZ"sv; // TEST_P(ClientAsync, WHEN_request_is_submitted_before_previous_is_complete_THEN_reports_would_block) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { const bool limited = GetParam() == anyhttp::Protocol::http11; auto request1 = co_await session.async_submit(url.set_path("echo"), {}); @@ -918,8 +874,7 @@ TEST_P(ClientAsync, WHEN_request_is_submitted_before_previous_is_complete_THEN_r TEST_P(ClientAsync, WHEN_many_requests_are_made_THEN_all_are_answered_in_order) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { std::vector requests; for (size_t i = 0; i < 10; ++i) { @@ -944,8 +899,7 @@ TEST_P(ClientAsync, WHEN_getting_response_before_previous_is_read_THEN_reports_w if (GetParam() != anyhttp::Protocol::http11) GTEST_SKIP(); // requests are multiplexed, nothing to wait for - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request1 = co_await session.async_submit(url.set_path("echo"), {}); co_await request1.async_write_eof(asio::buffer(body1)); auto request2 = co_await session.async_submit(url.set_path("echo"), {}); @@ -974,8 +928,7 @@ TEST_P(ClientAsync, WHEN_request_has_no_body_THEN_it_is_complete_after_submit) if (GetParam() != anyhttp::Protocol::http11) GTEST_SKIP(); // requests are multiplexed, nothing to wait for - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request1 = co_await session.async_submit(url.set_path("echo"), fields({{"Content-Length", 0}})); auto request2 = co_await session.async_submit(url.set_path("echo"), {}); @@ -1002,8 +955,7 @@ TEST_P(ClientAsync, WHEN_content_length_is_written_without_eof_THEN_request_is_n if (GetParam() != anyhttp::Protocol::http11) GTEST_SKIP(); // requests are multiplexed, nothing to wait for - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request1 = co_await session.async_submit(url.set_path("echo"), fields({{"Content-Length", body1.size()}})); co_await request1.async_write(asio::buffer(body1)); @@ -1031,8 +983,7 @@ TEST_P(ClientAsync, WHEN_incomplete_request_is_released_THEN_later_requests_repo if (GetParam() != anyhttp::Protocol::http11) GTEST_SKIP(); // requests are multiplexed, and independent of each other - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request1 = co_await session.async_submit(url.set_path("echo"), {}); co_await request1.async_write(asio::buffer(body1)); request1.reset(); @@ -1052,8 +1003,7 @@ TEST_P(ClientAsync, if (GetParam() != anyhttp::Protocol::http11) GTEST_SKIP(); // requests are multiplexed, and independent of each other - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request1 = co_await session.async_submit(url.set_path("echo"), {}); co_await request1.async_write_eof(asio::buffer(body1)); auto request2 = co_await session.async_submit(url.set_path("echo"), {}); @@ -1069,8 +1019,7 @@ TEST_P(ClientAsync, TEST_P(ClientAsync, EatRequest) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("eat_request"), {}); co_await generate(request, 1024); auto response = co_await request.async_get_response(); @@ -1083,8 +1032,7 @@ TEST_P(ClientAsync, EatRequest) TEST_P(ClientAsync, Dump) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit( url.set_path("dump space").set_params({{"blah", "white space"}, {"x", "y"}}), {}); co_await send_eof(request); diff --git a/test/test_client_async_cancellation.cpp b/test/test_client_async_cancellation.cpp index 43dc709..6bd43eb 100644 --- a/test/test_client_async_cancellation.cpp +++ b/test/test_client_async_cancellation.cpp @@ -24,8 +24,7 @@ INSTANTIATE_TEST_SUITE_P(ClientAsyncCancellation, ClientAsyncCancellation, TEST_P(ClientAsyncCancellation, Backpressure) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); auto response = co_await request.async_get_response(); auto sender = send(request, rv::iota(uint8_t(0))); @@ -66,8 +65,7 @@ TEST_P(ClientAsyncCancellation, Backpressure) // TEST_P(ClientAsyncCancellation, CancellationContentLength) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { const size_t length = 50_m; const std::vector buffer(length); for (size_t i = 0; i <= 20; ++i) @@ -113,8 +111,7 @@ TEST_P(ClientAsyncCancellation, CancellationContentLength) // TEST_P(ClientAsyncCancellation, Cancellation) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { const size_t length = 50_m; const std::vector buffer(length, 'a'); for (size_t i = 0; i <= 20; ++i) @@ -156,8 +153,7 @@ TEST_P(ClientAsyncCancellation, Cancellation) // TEST_P(ClientAsyncCancellation, CancellationRange) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { for (size_t i = 6; i <= 6; ++i) { co_await yield(); @@ -177,8 +173,7 @@ TEST_P(ClientAsyncCancellation, CancellationRange) TEST_P(ClientAsyncCancellation, PerOperationCancellation) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); auto response = co_await request.async_get_response(); @@ -197,8 +192,7 @@ TEST_P(ClientAsyncCancellation, PerOperationCancellation) TEST_P(ClientAsyncCancellation, CancelAfter) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo").set_params({{"delay", "1000"}}), {}); auto [ec, response] = co_await request.async_get_response(cancel_after(250ms, as_tuple)); @@ -218,8 +212,7 @@ TEST_P(ClientAsyncCancellation, CancelAfter) TEST_P(ClientAsyncCancellation, WHEN_send_more_than_content_length_THEN_connection_is_reset) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { Fields fields; fields.set("content-length", "1024"); auto request = co_await session.async_submit(url.set_path("eat_request"), fields); @@ -244,8 +237,7 @@ TEST_P(ClientAsyncCancellation, WHEN_send_more_than_content_length_THEN_connecti TEST_P(ClientAsyncCancellation, ClientDropRequest) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); auto response = co_await request.async_get_response(); }; @@ -255,8 +247,7 @@ TEST_P(ClientAsyncCancellation, ClientDropRequest) TEST_P(ClientAsyncCancellation, ResetServerDuringRequest) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); auto response = co_await request.async_get_response(); @@ -298,21 +289,21 @@ TEST_P(ClientAsyncCancellation, DISABLED_SpawnAndForget) if (GetParam() == anyhttp::Protocol::http11) GTEST_SKIP(); // FIXME: ASAN errors - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo"), {}); auto response = co_await request.async_get_response(); co_await yield(); std::println("- - spawning - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - "); - co_spawn(context, - [request = std::move(request)]() mutable -> awaitable - { // - std::println("- - SPAWNED - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -"); - co_await yield(5); - std::println("- - SPAWNED, sending - - - - - - - - - - - - - - - - - - - - - - - - -"); - co_await send(request, rv::iota(uint8_t(0))); - }, detached); + co_spawn( + context, + [request = std::move(request)]() mutable -> awaitable { // + std::println("- - SPAWNED - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -"); + co_await yield(5); + std::println("- - SPAWNED, sending - - - - - - - - - - - - - - - - - - - - - - - - -"); + co_await send(request, rv::iota(uint8_t(0))); + }, + detached); }; } diff --git a/test/test_client_connect.cpp b/test/test_client_connect.cpp index 933baa0..7c1f808 100644 --- a/test/test_client_connect.cpp +++ b/test/test_client_connect.cpp @@ -17,8 +17,7 @@ TEST_F(ClientConnect, WHEN_unknown_host_THEN_completes_with_host_not_found_event boost::asio::io_context context; client::Config config{.url = boost::urls::url("http://this-domain-does-not-exist:12345")}; client::Client client(context.get_executor(), config); - client.async_connect([this](boost::system::error_code ec, Session session) - { + client.async_connect([this](boost::system::error_code ec, Session session) { loge("ERROR: {}", ec.message()); EXPECT_TRUE(ec == boost::asio::error::netdb_errors::host_not_found || ec == boost::asio::error::netdb_errors::host_not_found_try_again); @@ -32,8 +31,7 @@ TEST_F(ClientConnect, WHEN_wrong_port_THEN_completes_with_host_not_found_eventua auto port = get_unused_port(context); client::Config config{.url = boost::urls::url("http://localhost").set_port_number(port)}; client::Client client(context.get_executor(), config); - client.async_connect([this](boost::system::error_code ec, Session session) - { + client.async_connect([this](boost::system::error_code ec, Session session) { loge("ERROR: {}", ec.message()); EXPECT_EQ(ec, boost::system::errc::connection_refused); }); @@ -45,8 +43,7 @@ TEST_F(ClientConnect, WHEN_async_connect_is_cancelled_THEN_returns_operation_abo boost::asio::io_context context; client::Config config{.url = boost::urls::url("http://localhost:12345")}; client::Client client(context.get_executor(), config); - client.async_connect(cancel_after(0ms, [this](boost::system::error_code ec, Session session) - { + client.async_connect(cancel_after(0ms, [this](boost::system::error_code ec, Session session) { loge("ERROR: {}", ec.message()); EXPECT_EQ(ec, boost::system::errc::operation_canceled); })); @@ -59,8 +56,7 @@ TEST_F(ClientConnect, WHEN_connect_to_broadcast_ip_THEN_completes_with_network_u boost::asio::io_context context; client::Config config{.url = boost::urls::url("http://255.255.255.255:12345")}; client::Client client(context.get_executor(), config); - client.async_connect([this](boost::system::error_code ec, Session session) - { + client.async_connect([this](boost::system::error_code ec, Session session) { loge("ERROR: {}", ec.message()); EXPECT_EQ(ec, boost::system::errc::network_unreachable); }); diff --git a/test/test_connection_close.cpp b/test/test_connection_close.cpp index 2575543..df6c2e0 100644 --- a/test/test_connection_close.cpp +++ b/test/test_connection_close.cpp @@ -65,8 +65,7 @@ class ConnectionClose : public Server /// Runs \p task to completion, then stops the server. void run(awaitable task) { - co_spawn(context, std::move(task), [this](const std::exception_ptr& ep) - { + co_spawn(context, std::move(task), [this](const std::exception_ptr& ep) { if (ep) ADD_FAILURE() << what(ep); server.reset(); @@ -81,8 +80,7 @@ class ConnectionClose : public Server TEST_F(ConnectionClose, WHEN_request_asks_to_close_THEN_response_says_so_and_stream_ends) { - run([&]() -> awaitable - { + run([&]() -> awaitable { auto socket = co_await connect(); Request request{http::verb::get, "/dump", 11}; @@ -97,8 +95,7 @@ TEST_F(ConnectionClose, WHEN_request_asks_to_close_THEN_response_says_so_and_str TEST_F(ConnectionClose, WHEN_request_with_body_asks_to_close_THEN_body_is_served_first) { - run([&]() -> awaitable - { + run([&]() -> awaitable { auto socket = co_await connect(); Request request{http::verb::post, "/echo", 11}; @@ -115,8 +112,7 @@ TEST_F(ConnectionClose, WHEN_request_with_body_asks_to_close_THEN_body_is_served TEST_F(ConnectionClose, WHEN_request_does_not_ask_to_close_THEN_connection_takes_the_next_request) { - run([&]() -> awaitable - { + run([&]() -> awaitable { auto socket = co_await connect(); auto first = co_await exchange(socket, Request{http::verb::get, "/dump?first", 11}); @@ -133,8 +129,7 @@ TEST_F(ConnectionClose, WHEN_request_does_not_ask_to_close_THEN_connection_takes TEST_F(ConnectionClose, WHEN_request_is_http_1_0_THEN_stream_ends_after_the_response) { - run([&]() -> awaitable - { + run([&]() -> awaitable { auto socket = co_await connect(); // @@ -167,8 +162,7 @@ class RejectedRequest : public ConnectionClose TEST_F(RejectedRequest, WHEN_request_is_rejected_THEN_the_response_arrives_anyway) { - run([&]() -> awaitable - { + run([&]() -> awaitable { auto socket = co_await connect(); // @@ -185,13 +179,11 @@ TEST_F(RejectedRequest, WHEN_request_is_rejected_THEN_the_response_arrives_anywa // is out, so a write of all of it only completes once the connection is gone. // Response response; - auto send = [&]() -> awaitable - { + auto send = [&]() -> awaitable { auto [ec, n] = co_await asio::async_write(socket, asio::buffer(request), as_tuple); co_return ec; }; - auto receive = [&]() -> awaitable - { + auto receive = [&]() -> awaitable { auto [ec, n] = co_await http::async_read(socket, m_buffer, response, as_tuple); co_return ec; }; @@ -224,8 +216,7 @@ class TlsConnectionClose : public ConnectionClose co_return stream; } - asio::ssl::context m_context = std::invoke([] - { + asio::ssl::context m_context = std::invoke([] { asio::ssl::context context{asio::ssl::context::tlsv13}; context.load_verify_file("pki/out/root.pem"); context.set_verify_mode(asio::ssl::verify_peer); @@ -238,8 +229,7 @@ class TlsConnectionClose : public ConnectionClose TEST_F(TlsConnectionClose, WHEN_request_asks_to_close_THEN_close_notify_comes_before_the_end) { - run([&]() -> awaitable - { + run([&]() -> awaitable { auto stream = co_await connect_tls(); Request request{http::verb::get, "/dump", 11}; diff --git a/test/test_external.cpp b/test/test_external.cpp index b175d9d..37f5b18 100644 --- a/test/test_external.cpp +++ b/test/test_external.cpp @@ -38,8 +38,7 @@ class External : public Server awaitable log(std::string prefix, readable_pipe& pipe) { std::string buffer; - auto print = [&](std::string_view line) - { + auto print = [&](std::string_view line) { if (line.ends_with('\r')) line.remove_suffix(1); @@ -135,15 +134,14 @@ class External : public Server auto future = promise.get_future(); co_spawn(strand, spawn_process(std::move(path), std::move(args)), bind_executor(strand, [this, promise = std::move(promise)]( - const std::exception_ptr& ex, std::string str) mutable - { - if (ex) - { - loge("{}", what(ex)); - server.reset(); - } - promise.set_value(std::move(str)); - })); + const std::exception_ptr& ex, std::string str) mutable { + if (ex) + { + loge("{}", what(ex)); + server.reset(); + } + promise.set_value(std::move(str)); + })); return std::move(future); } @@ -401,8 +399,7 @@ TEST_F(ExternalCustom, h2spec) // https://github.com/nghttp2/nghttp2/issues/2278 // https://github.com/nghttp2/nghttp2/issues/2365 - const int expected_ok = std::invoke([] - { + const int expected_ok = std::invoke([] { if (NGHTTP2_VERSION_NUM >= 0x004200) // 1.66 return 138; // 6.9.1 else if (NGHTTP2_VERSION_NUM == 0x004100) // 1.65 diff --git a/test/test_file_handler.cpp b/test/test_file_handler.cpp index 8e93e4c..6a28dc6 100644 --- a/test/test_file_handler.cpp +++ b/test/test_file_handler.cpp @@ -38,8 +38,10 @@ class FileHandler : public ClientAsync ClientAsync::SetUp(); - requestHandler = [this](server::Request request, server::Response response) -> awaitable - { co_await serve_file(std::move(request), std::move(response), root, "/custom"); }; + requestHandler = [this](server::Request request, + server::Response response) -> awaitable { + co_await serve_file(std::move(request), std::move(response), root, "/custom"); + }; } void TearDown() override @@ -88,8 +90,7 @@ INSTANTIATE_TEST_SUITE_P(FileHandler, FileHandler, TEST_P(FileHandler, WHEN_file_exists_THEN_serves_content) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto message = co_await get(session, "/custom/hello.txt"); EXPECT_EQ(message.result_int(), 200); EXPECT_EQ(message.body(), "Hello, File!"); @@ -98,8 +99,7 @@ TEST_P(FileHandler, WHEN_file_exists_THEN_serves_content) TEST_P(FileHandler, WHEN_file_is_in_subdirectory_THEN_serves_content) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto message = co_await get(session, "/custom/sub/nested.txt"); EXPECT_EQ(message.result_int(), 200); EXPECT_EQ(message.body(), "Nested!"); @@ -112,8 +112,7 @@ TEST_P(FileHandler, WHEN_file_is_in_subdirectory_THEN_serves_content) // TEST_P(FileHandler, WHEN_file_is_empty_THEN_serves_empty_body) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto message = co_await get(session, "/custom/empty.txt"); EXPECT_EQ(message.result_int(), 200); EXPECT_THAT(message.body(), IsEmpty()); @@ -126,8 +125,7 @@ TEST_P(FileHandler, WHEN_file_is_empty_THEN_serves_empty_body) // TEST_P(FileHandler, WHEN_file_is_large_THEN_serves_all_of_it) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto message = co_await get(session, "/custom/large.bin"); EXPECT_EQ(message.result_int(), 200); EXPECT_EQ(message.body(), std::string(256_k, 'x')); @@ -136,8 +134,7 @@ TEST_P(FileHandler, WHEN_file_is_large_THEN_serves_all_of_it) TEST_P(FileHandler, WHEN_file_does_not_exist_THEN_error_404) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto message = co_await get(session, "/custom/missing.txt"); EXPECT_EQ(message.result_int(), 404); EXPECT_THAT(message.body(), IsEmpty()); @@ -149,8 +146,7 @@ TEST_P(FileHandler, WHEN_file_does_not_exist_THEN_error_404) // TEST_P(FileHandler, WHEN_path_is_a_directory_THEN_error_404) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { EXPECT_EQ((co_await get(session, "/custom/sub")).result_int(), 404); EXPECT_EQ((co_await get(session, "/custom/")).result_int(), 404); }; @@ -158,8 +154,7 @@ TEST_P(FileHandler, WHEN_path_is_a_directory_THEN_error_404) TEST_P(FileHandler, WHEN_path_escapes_the_root_THEN_error_404) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { 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); @@ -171,8 +166,9 @@ TEST_P(FileHandler, WHEN_path_escapes_the_root_THEN_error_404) // TEST_P(FileHandler, WHEN_symlink_points_outside_the_root_THEN_error_404) { - clientSession = [this](Session session) -> awaitable - { EXPECT_EQ((co_await get(session, "/custom/escape.txt")).result_int(), 404); }; + clientSession = [this](Session session) -> awaitable { + EXPECT_EQ((co_await get(session, "/custom/escape.txt")).result_int(), 404); + }; } // @@ -181,8 +177,7 @@ TEST_P(FileHandler, WHEN_symlink_points_outside_the_root_THEN_error_404) // TEST_P(FileHandler, WHEN_prefix_matches_mid_segment_THEN_error_404) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto message = co_await get(session, "/customer.txt"); EXPECT_EQ(message.result_int(), 404); EXPECT_THAT(message.body(), IsEmpty()); @@ -195,8 +190,7 @@ TEST_P(FileHandler, WHEN_file_is_not_readable_THEN_error_403) if (::geteuid() == 0) GTEST_SKIP() << "running as root, permissions do not apply"; - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto message = co_await get(session, "/custom/secret.txt"); EXPECT_EQ(message.result_int(), 403); EXPECT_THAT(message.body(), IsEmpty()); @@ -205,8 +199,7 @@ TEST_P(FileHandler, WHEN_file_is_not_readable_THEN_error_403) TEST_P(FileHandler, WHEN_same_file_is_requested_twice_THEN_serves_it_twice) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { 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_fixtures.hpp b/test/test_fixtures.hpp index b08444d..7e69896 100644 --- a/test/test_fixtures.hpp +++ b/test/test_fixtures.hpp @@ -121,32 +121,31 @@ class Server : public testing::TestWithParam // server.emplace(context.get_executor(), config); server->setRequestHandler( - [this](server::Request request, server::Response response) -> awaitable - { - logd("{} ({})", request.url().path(), request.url().buffer()); - - if (auto delay = request.get_param_as("delay")) - co_await sleep(std::chrono::milliseconds{*delay}); - - if (request.url().path() == "/echo") - co_await echo(std::move(request), std::move(response)); - else if (request.url().path() == "/eat_request") - co_await eat_request(std::move(request), std::move(response)); - else if (request.url().path() == "/discard") - co_return; - else if (request.url().path() == "/h2spec") - co_await h2spec(std::move(request), std::move(response)); - else if (request.url().path() == "/dump") - co_await dump(std::move(request), std::move(response)); - else if (request.url().path() == "/dump space") - co_await dump(std::move(request), std::move(response)); - else if (request.url().path() == "/detach") - co_await detach(std::move(request), std::move(response)); - else if (request.url().path().starts_with("/custom")) - co_await requestHandler(std::move(request), std::move(response)); - else - co_await not_found(std::move(request), std::move(response)); - }); + [this](server::Request request, server::Response response) -> awaitable { + logd("{} ({})", request.url().path(), request.url().buffer()); + + if (auto delay = request.get_param_as("delay")) + co_await sleep(std::chrono::milliseconds{*delay}); + + if (request.url().path() == "/echo") + co_await echo(std::move(request), std::move(response)); + else if (request.url().path() == "/eat_request") + co_await eat_request(std::move(request), std::move(response)); + else if (request.url().path() == "/discard") + co_return; + else if (request.url().path() == "/h2spec") + co_await h2spec(std::move(request), std::move(response)); + else if (request.url().path() == "/dump") + co_await dump(std::move(request), std::move(response)); + else if (request.url().path() == "/dump space") + co_await dump(std::move(request), std::move(response)); + else if (request.url().path() == "/detach") + co_await detach(std::move(request), std::move(response)); + else if (request.url().path().starts_with("/custom")) + co_await requestHandler(std::move(request), std::move(response)); + else + co_await not_found(std::move(request), std::move(response)); + }); } void run() @@ -162,9 +161,10 @@ class Server : public testing::TestWithParam // The extra threads use context.run() directly: the per-operation logging of ::run() is // meant for single-threaded debugging and would just interleave into noise here. // - auto pool = rv::iota(size_t{1}, n) | rv::transform([this](size_t) { - return std::jthread([this] { context.run(); }); - }) | std::ranges::to(); + auto pool = + rv::iota(size_t{1}, n) | + rv::transform([this](size_t) { return std::jthread([this] { context.run(); }); }) | + std::ranges::to(); context.run(); } @@ -215,8 +215,7 @@ class ClientAsync : public Client public: auto token() { - return [this](const std::exception_ptr& ep) - { + return [this](const std::exception_ptr& ep) { auto ec = code(ep); if (ec) logw("client completed with \x1b[1;31m{}\x1b[0m", what(ec)); @@ -241,14 +240,16 @@ class ClientAsync : public Client // // Spawn the testcase coroutine on the client's executor so that access to it is serialized. // - co_spawn(client->get_executor(), [this]() -> awaitable - { - if (clientSession) - { - auto session = co_await client->async_connect(); - co_await clientSession(std::move(session)); - } - }, token()); + co_spawn( + client->get_executor(), + [this]() -> awaitable { + if (clientSession) + { + auto session = co_await client->async_connect(); + co_await clientSession(std::move(session)); + } + }, + token()); } void TearDown() override diff --git a/test/test_formatter.cpp b/test/test_formatter.cpp index 81544bc..864eae0 100644 --- a/test/test_formatter.cpp +++ b/test/test_formatter.cpp @@ -191,19 +191,15 @@ TEST(FormatterTest, CancellationTypeMultipleCombined) namespace { - // Helper to create nghttp2_nv from string literals - // Note: const_cast is safe here as the formatter doesn't modify the data - nghttp2_nv make_nghttp2_nv(const char* name, const char* value) - { - return nghttp2_nv{ - const_cast(reinterpret_cast(name)), - const_cast(reinterpret_cast(value)), - strlen(name), - strlen(value), - NGHTTP2_NV_FLAG_NONE - }; - } +// Helper to create nghttp2_nv from string literals +// Note: const_cast is safe here as the formatter doesn't modify the data +nghttp2_nv make_nghttp2_nv(const char* name, const char* value) +{ + return nghttp2_nv{const_cast(reinterpret_cast(name)), + const_cast(reinterpret_cast(value)), strlen(name), + strlen(value), NGHTTP2_NV_FLAG_NONE}; } +} // namespace TEST(FormatterTest, NgHttp2NvDefault) { diff --git a/test/test_get.cpp b/test/test_get.cpp index 5dfad7c..a4ca273 100644 --- a/test/test_get.cpp +++ b/test/test_get.cpp @@ -19,8 +19,7 @@ class AsyncGet : public ClientAsync void respond_with(std::string body) { requestHandler = [body = std::move(body)](server::Request request, - server::Response response) -> awaitable - { + 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}})); @@ -39,8 +38,7 @@ INSTANTIATE_TEST_SUITE_P(AsyncGet, AsyncGet, TEST_P(AsyncGet, WHEN_get_THEN_message_has_status_fields_and_body) { respond_with("Hello, World!"); - clientSession = [this](Session session) -> awaitable - { + clientSession = [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); @@ -52,8 +50,7 @@ TEST_P(AsyncGet, WHEN_get_THEN_message_has_status_fields_and_body) TEST_P(AsyncGet, WHEN_response_has_no_body_THEN_body_is_empty) { respond_with(""); - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto message = co_await session.async_get(url); EXPECT_EQ(message.result_int(), 200); EXPECT_THAT(message.body(), IsEmpty()); @@ -66,8 +63,7 @@ TEST_P(AsyncGet, WHEN_response_has_no_body_THEN_body_is_empty) // TEST_P(AsyncGet, WHEN_path_is_unknown_THEN_message_says_404) { - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto message = co_await session.async_get(url.set_path("unknown")); EXPECT_EQ(message.result(), http::status::not_found); }; @@ -77,8 +73,7 @@ TEST_P(AsyncGet, WHEN_body_is_large_THEN_all_of_it_arrives) { auto body = std::string(1_m, 'x'); respond_with(body); - clientSession = [this, body](Session session) -> awaitable - { + clientSession = [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()); @@ -88,16 +83,14 @@ TEST_P(AsyncGet, WHEN_body_is_large_THEN_all_of_it_arrives) TEST_P(AsyncGet, WHEN_headers_are_given_THEN_they_arrive_with_the_request) { - requestHandler = [](server::Request request, server::Response response) -> awaitable - { + requestHandler = [](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(); }; - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto message = co_await session.async_get(url, fields({{"X-Question", "what?"}})); EXPECT_EQ(message.result_int(), 200); }; @@ -110,8 +103,7 @@ TEST_P(AsyncGet, WHEN_headers_are_given_THEN_they_arrive_with_the_request) TEST_P(AsyncGet, WHEN_two_requests_in_a_row_THEN_both_are_answered) { respond_with("Hello, World!"); - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { for (size_t i = 0; i < 2; ++i) { auto message = co_await session.async_get(url); @@ -131,14 +123,12 @@ TEST_P(AsyncGet, WHEN_cancelled_THEN_completes_with_operation_canceled_and_empty // Responds late, and to nobody in particular: by then the client has given up, so writing to // the stream is expected to fail. // - requestHandler = [](server::Request request, server::Response response) -> awaitable - { + requestHandler = [](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); }; - clientSession = [this](Session session) -> awaitable - { + clientSession = [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); @@ -160,27 +150,31 @@ TEST(AsyncGetRaw, WHEN_get_THEN_request_line_says_GET) 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); + 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); }); + 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(); diff --git a/test/test_h2c_upgrade.cpp b/test/test_h2c_upgrade.cpp index 6e7f02c..16b67bb 100644 --- a/test/test_h2c_upgrade.cpp +++ b/test/test_h2c_upgrade.cpp @@ -67,36 +67,32 @@ class H2CUpgrade : public Server const auto authority = std::format("127.0.0.2:{}", server->local_endpoint().port()); Responses responses; - auto callbacks = std::invoke([] - { + auto callbacks = std::invoke([] { nghttp2_session_callbacks* cbs; nghttp2_session_callbacks_new(&cbs); nghttp2_session_callbacks_set_on_header_callback( cbs, [](nghttp2_session*, const nghttp2_frame* frame, const uint8_t* name, size_t namelen, - const uint8_t* value, size_t valuelen, uint8_t, void* user_data) -> int - { - auto& responses = *static_cast(user_data); - if (std::string_view(reinterpret_cast(name), namelen) == ":status") - responses[frame->hd.stream_id].status = - std::stoul(std::string(reinterpret_cast(value), valuelen)); - return 0; - }); + const uint8_t* value, size_t valuelen, uint8_t, void* user_data) -> int { + auto& responses = *static_cast(user_data); + if (std::string_view(reinterpret_cast(name), namelen) == ":status") + responses[frame->hd.stream_id].status = + std::stoul(std::string(reinterpret_cast(value), valuelen)); + return 0; + }); nghttp2_session_callbacks_set_on_data_chunk_recv_callback( cbs, [](nghttp2_session*, uint8_t, int32_t stream_id, const uint8_t* data, size_t len, - void* user_data) -> int - { - auto& responses = *static_cast(user_data); - responses[stream_id].body.append(reinterpret_cast(data), len); - return 0; - }); + void* user_data) -> int { + auto& responses = *static_cast(user_data); + responses[stream_id].body.append(reinterpret_cast(data), len); + return 0; + }); nghttp2_session_callbacks_set_on_stream_close_callback( - cbs, [](nghttp2_session*, int32_t stream_id, uint32_t, void* user_data) -> int - { - static_cast(user_data)->operator[](stream_id).closed = true; - return 0; - }); + cbs, [](nghttp2_session*, int32_t stream_id, uint32_t, void* user_data) -> int { + static_cast(user_data)->operator[](stream_id).closed = true; + return 0; + }); return std::unique_ptr( cbs, nghttp2_session_callbacks_del); }); @@ -148,22 +144,19 @@ class H2CUpgrade : public Server EXPECT_GT(id, 0) << nghttp2_strerror(id); } - auto recv = [&](const_buffer data) - { + auto recv = [&](const_buffer data) { auto n = nghttp2_session_mem_recv2(session, static_cast(data.data()), data.size()); EXPECT_EQ(n, data.size()) << nghttp2_strerror(n); }; - auto done = [&] - { + auto done = [&] { return std::ranges::count_if(responses, [](auto& item) { return item.second.closed; }) == targets.size(); }; std::string out; // nghttp2 starts with the client magic by itself - auto send = [&]() -> awaitable - { + auto send = [&]() -> awaitable { const uint8_t* data; while (auto n = nghttp2_session_mem_send2(session, &data)) { @@ -220,8 +213,7 @@ class H2CUpgrade : public Server T run(awaitable task) { T result; - co_spawn(context, std::move(task), [&](const std::exception_ptr& ep, T value) - { + co_spawn(context, std::move(task), [&](const std::exception_ptr& ep, T value) { if (ep) ADD_FAILURE() << what(ep); result = std::move(value); diff --git a/test/test_headers.cpp b/test/test_headers.cpp index 9d929dc..5207e24 100644 --- a/test/test_headers.cpp +++ b/test/test_headers.cpp @@ -52,8 +52,10 @@ static std::vector values_of(const Fields& fields, std::string static std::vector> pairs_of(const Fields& fields) { return fields | rv::transform([](auto& field) { - return std::pair(std::string_view(field.name_string()), std::string_view(field.value())); - }) | std::ranges::to(); + return std::pair(std::string_view(field.name_string()), + std::string_view(field.value())); + }) | + std::ranges::to(); } /// Expects every field of \p expected to be found in \p actual. @@ -80,8 +82,7 @@ static size_t wire_size(const Fields& fields) // void Headers::round_trip(Fields sent) { - requestHandler = [sent](server::Request request, server::Response response) -> awaitable - { + requestHandler = [sent](server::Request request, server::Response response) -> awaitable { expect_contains(request.fields(), sent); co_await drain(request); auto fields = sent; @@ -89,8 +90,7 @@ void Headers::round_trip(Fields sent) co_await response.async_submit(200, fields); co_await response.async_write_eof(); }; - clientSession = [this, sent](Session session) -> awaitable - { + clientSession = [this, sent](Session session) -> awaitable { auto message = co_await session.async_get(url, sent); EXPECT_EQ(message.result_int(), 200); expect_contains(message, sent); @@ -116,8 +116,7 @@ TEST_P(Headers, WHEN_header_name_repeats_THEN_all_values_arrive_in_order) sent.insert("x-repeated", values.emplace_back(std::format("value-{}", i))); requestHandler = [sent, values](server::Request request, - server::Response response) -> awaitable - { + server::Response response) -> awaitable { EXPECT_THAT(values_of(request.fields(), "x-repeated"), ElementsAreArray(values)); co_await drain(request); auto fields = sent; @@ -125,8 +124,7 @@ TEST_P(Headers, WHEN_header_name_repeats_THEN_all_values_arrive_in_order) co_await response.async_submit(200, fields); co_await response.async_write_eof(); }; - clientSession = [this, sent, values](Session session) -> awaitable - { + clientSession = [this, sent, values](Session session) -> awaitable { auto message = co_await session.async_get(url, sent); EXPECT_THAT(values_of(message, "x-repeated"), ElementsAreArray(values)); }; @@ -153,14 +151,12 @@ TEST_P(Headers, WHEN_request_headers_exceed_default_limit_THEN_server_responds_4 auto sent = make_fields(3, 30_k); ASSERT_GT(wire_size(sent), default_max_header_size); - requestHandler = [](server::Request request, server::Response response) -> awaitable - { + requestHandler = [](server::Request request, server::Response response) -> awaitable { ADD_FAILURE() << "request handler called for oversized request headers"; co_await response.async_submit(200, {}); co_await response.async_write_eof(); }; - clientSession = [this, sent](Session session) -> awaitable - { + clientSession = [this, sent](Session session) -> awaitable { auto message = co_await session.async_get(url, sent); EXPECT_EQ(message.result_int(), 431); }; @@ -187,8 +183,8 @@ class HeaderLimits : public ClientAsync /// Request handler: responds with the headers of size \p response_size given as query parameter. void respond_with_headers() { - requestHandler = [this](server::Request request, server::Response response) -> awaitable - { + requestHandler = [this](server::Request request, + server::Response response) -> awaitable { ++handled; auto size = request.get_param_as("response_size").value_or(0); co_await drain(request); @@ -228,8 +224,7 @@ INSTANTIATE_TEST_SUITE_P(HeaderLimits, HeaderLimits, TEST_P(HeaderLimits, WHEN_request_headers_are_within_limit_THEN_request_is_handled) { respond_with_headers(); - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { EXPECT_EQ(co_await request(session, make_fields(1, limit / 2), limit / 2), 200); EXPECT_EQ(handled, 1); }; @@ -238,8 +233,7 @@ TEST_P(HeaderLimits, WHEN_request_headers_are_within_limit_THEN_request_is_handl TEST_P(HeaderLimits, WHEN_request_headers_exceed_limit_THEN_server_responds_431) { respond_with_headers(); - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { EXPECT_EQ(co_await request(session, make_fields(1, limit)), 431); EXPECT_EQ(handled, 0); }; @@ -252,8 +246,7 @@ TEST_P(HeaderLimits, WHEN_request_headers_exceed_limit_THEN_server_responds_431) TEST_P(HeaderLimits, WHEN_many_small_fields_exceed_limit_THEN_server_responds_431) { respond_with_headers(); - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto sent = make_fields(200, 1); EXPECT_EQ(co_await request(session, sent), 431); EXPECT_EQ(handled, 0); @@ -272,8 +265,7 @@ TEST_P(HeaderLimits, WHEN_request_headers_far_exceed_limit_THEN_request_is_rejec ASSERT_GT(wire_size(sent), limit * 200); respond_with_headers(); - clientSession = [this, sent](Session session) -> awaitable - { + clientSession = [this, sent](Session session) -> awaitable { auto result = co_await request(session, sent); if (GetParam() == anyhttp::Protocol::h2) EXPECT_FALSE(result.has_value()) << "status " << result.value_or(0); @@ -289,8 +281,7 @@ TEST_P(HeaderLimits, WHEN_request_is_rejected_THEN_session_serves_next_request) GTEST_SKIP() << "HTTP/1.1 closes the connection after 431"; respond_with_headers(); - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { EXPECT_EQ(co_await request(session, make_fields(1, limit)), 431); EXPECT_EQ(co_await request(session, make_fields(1, 100)), 200); EXPECT_EQ(handled, 1); @@ -302,8 +293,7 @@ TEST_P(HeaderLimits, WHEN_request_is_rejected_THEN_session_serves_next_request) TEST_P(HeaderLimits, WHEN_response_headers_exceed_limit_THEN_get_response_fails) { respond_with_headers(); - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto result = co_await request(session, {}, limit); EXPECT_EQ(result, std::unexpected(error_code(boost::beast::http::error::header_limit))); }; @@ -315,8 +305,7 @@ TEST_P(HeaderLimits, WHEN_response_headers_exceed_limit_THEN_get_response_fails) TEST_P(HeaderLimits, WHEN_response_headers_exceed_limit_before_get_response_THEN_it_fails) { respond_with_headers(); - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto target = url; target.params().set("response_size", std::to_string(limit)); auto request = co_await session.async_submit(target, {}); @@ -333,8 +322,7 @@ TEST_P(HeaderLimits, WHEN_response_is_rejected_THEN_session_serves_next_request) GTEST_SKIP() << "HTTP/1.1 can not skip the rest of a response"; respond_with_headers(); - clientSession = [this](Session session) -> awaitable - { + clientSession = [this](Session session) -> awaitable { auto result = co_await request(session, {}, limit); EXPECT_EQ(result, std::unexpected(error_code(boost::beast::http::error::header_limit))); EXPECT_EQ(co_await request(session, {}, 100), 200); diff --git a/test/test_server.cpp b/test/test_server.cpp index c7d0360..8c29a24 100644 --- a/test/test_server.cpp +++ b/test/test_server.cpp @@ -49,23 +49,22 @@ class Http3IdleTimeout : public Test { setupLogging(); - server.emplace(context.get_executor(), server::Config{.listen_address = "127.0.0.2", - .port = 0, - .idle_timeout = IdleTimeout}); + server.emplace( + context.get_executor(), + server::Config{.listen_address = "127.0.0.2", .port = 0, .idle_timeout = IdleTimeout}); server->setRequestHandler( - [this](server::Request request, server::Response response) -> awaitable - { - co_await response.async_submit(200, {}); - - // - // Wait for a request body that never comes: this first read is where the handler is - // suspended when the client freezes, and it must be resumed -- with an error -- once - // the server gives up on the connection. - // - std::array buffer; - auto [ec, n] = co_await request.async_read_some(asio::buffer(buffer), as_tuple); - handler_result.set_value(ec); - }); + [this](server::Request request, server::Response response) -> awaitable { + co_await response.async_submit(200, {}); + + // + // Wait for a request body that never comes: this first read is where the handler is + // suspended when the client freezes, and it must be resumed -- with an error -- once + // the server gives up on the connection. + // + std::array buffer; + auto [ec, n] = co_await request.async_read_some(asio::buffer(buffer), as_tuple); + handler_result.set_value(ec); + }); url.set_port_number(server->local_endpoint().port()); } @@ -108,19 +107,22 @@ TEST_F(Http3IdleTimeout, WHEN_client_vanishes_in_flight_THEN_idle_timer_drops_th std::optional response; bool responded = false; - co_spawn(client_context, [&]() -> awaitable - { - session = co_await client.async_connect(); - request = co_await session->async_submit(url, {}); - response = co_await request->async_get_response(); - responded = true; - }, detached); + co_spawn( + client_context, + [&]() -> awaitable { + session = co_await client.async_connect(); + request = co_await session->async_submit(url, {}); + response = co_await request->async_get_response(); + responded = true; + }, + detached); // // Run the client just far enough to have the request open and answered, then stop running it: // from here on it never touches its socket again. // - while (client_context.run_one() && !responded); + while (client_context.run_one() && !responded) + ; ASSERT_TRUE(responded) << "client never received a response"; std::println("=== freezing the client, request still in flight ==="); From 505ab5f9de0f8b43293416ec4b26336b366df953 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Sun, 20 Sep 2026 20:55:15 +0000 Subject: [PATCH 10/19] refactor: simplify template parameters in async_write_some and async_read_some --- include/anyhttp/detail/any_async_stream.hpp | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/include/anyhttp/detail/any_async_stream.hpp b/include/anyhttp/detail/any_async_stream.hpp index 290b546..844ef11 100644 --- a/include/anyhttp/detail/any_async_stream.hpp +++ b/include/anyhttp/detail/any_async_stream.hpp @@ -95,12 +95,10 @@ class any_async_stream // than the array's capacity are truncated -- which is harmless for a "some" operation, as it // just results in a shorter transfer. // - template > - requires boost::beast::is_const_buffer_sequence::value - auto async_write_some(const ConstBufferSequence& buffers, - CompletionToken&& token = CompletionToken()) + auto async_write_some(const Buffers& buffers, CompletionToken&& token = CompletionToken()) { return boost::asio::async_initiate( [this](ReadWriteHandler handler, ConstBufferVector buffers) { // @@ -112,12 +110,10 @@ class any_async_stream // // async_read_some // - template > - requires boost::beast::is_mutable_buffer_sequence::value - auto async_read_some(const MutableBufferSequence& buffers, - CompletionToken&& token = CompletionToken()) + auto async_read_some(const Buffers& buffers, CompletionToken&& token = CompletionToken()) { return boost::asio::async_initiate( [this](ReadWriteHandler handler, MutableBufferVector buffers) { // From f41ef2453f49eac69cbbe57e52bc2adb38eeab3a Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Mon, 21 Sep 2026 06:10:37 +0000 Subject: [PATCH 11/19] refactor: give the reading and writing halves of a message their own handles Each of the four public interfaces -- {server,client}::{Request,Response} -- wraps exactly one of the two abstract bases the protocol backends implement, but that split never reached the public layer. Anything interested in only the reading or only the writing half of a message, drain() above all, had to be a template to accept both sides. So mirror the implementation hierarchy in the handles. Reader and Writer are handle classes of their own now, and the four interfaces derive from one each and add no data members, recovering their own Impl through a static_cast in pimpl(): there is still exactly one shared_ptr per handle, and moving one into a bare Reader or Writer hands off that half of the message with its ownership intact. The destructors stay non-virtual, as these are never owned through a base pointer. drain(), read(), try_receive() and send_eof() are plain functions over Reader& and Writer& now, and send(), sendAndForceEOF() and generate() no longer take the writer as a template parameter. No call site changes. The abstract bases move along, out of the anyhttp::impl namespace -- which is gone -- and into reader.hpp and writer.hpp as Reader::Impl and Writer::Impl, which is how every other Impl in the library is spelled. The four derived Impl classes keep their names and lose the ReaderOrWriter alias, which never had a user. Merging the four copies of the Asio boilerplate forced a decision in two places where the two sides had drifted apart: server::Response bound get_executor() to its write initiations, while client::Request bound whatever the token brought, because the implementation may already be gone by the time a write is started (the SpawnAndForget test). Writer binds get_associated_executor(token, get_executor()), which is what the server already got -- a token's own executor takes precedence either way -- and get_executor() answers with an empty executor instead of asserting once the handle has been released. Such a write fails with bad_descriptor without touching an executor at all. The buffer sequence overload of async_read_some() fell off the end of a non-void function when every buffer in the sequence was empty. It forwards an empty buffer now, which Reader::Impl already defines as an immediate, zero-byte success. Incidentally, server::Request gains that sequence overload and client::Response gains content_length(), both of which only one side used to have. Co-Authored-By: Claude Opus 5 --- include/anyhttp/client.hpp | 102 ++---------------- include/anyhttp/client_impl.hpp | 10 +- include/anyhttp/common.hpp | 54 ---------- include/anyhttp/h1_session.hpp | 12 +-- include/anyhttp/h2_stream.hpp | 6 +- include/anyhttp/h3_stream.hpp | 6 +- include/anyhttp/reader.hpp | 130 +++++++++++++++++++++++ include/anyhttp/request_handlers.hpp | 40 ++----- include/anyhttp/server.hpp | 103 +++--------------- include/anyhttp/server_impl.hpp | 10 +- include/anyhttp/writer.hpp | 153 +++++++++++++++++++++++++++ src/client.cpp | 50 +++------ src/reader.cpp | 49 +++++++++ src/request_handlers.cpp | 41 +++++-- src/server.cpp | 58 ++-------- src/writer.cpp | 50 +++++++++ 16 files changed, 497 insertions(+), 377 deletions(-) create mode 100644 include/anyhttp/reader.hpp create mode 100644 include/anyhttp/writer.hpp create mode 100644 src/reader.cpp create mode 100644 src/writer.cpp diff --git a/include/anyhttp/client.hpp b/include/anyhttp/client.hpp index 27629e5..cd83699 100644 --- a/include/anyhttp/client.hpp +++ b/include/anyhttp/client.hpp @@ -1,6 +1,8 @@ #pragma once #include "common.hpp" // IWYU pragma: keep +#include "reader.hpp" +#include "writer.hpp" #include #include @@ -60,7 +62,7 @@ using Message = boost::beast::http::response; // ------------------------------------------------------------------------------------------------- -class Response +class Response : public Reader { public: class Impl; @@ -71,55 +73,22 @@ class Response void reset() noexcept; ~Response(); - constexpr operator bool() const noexcept { return static_cast(impl); } - - using executor_type = asio::any_io_executor; - executor_type get_executor() const noexcept; - public: int status_code() const noexcept; /// The response header fields, without HTTP/2 and HTTP/3 pseudo-headers. const Fields& fields() const; -public: - /** - * Reads a part of the response body. - * - * The end of the body is reported as \c asio::error::eof with zero bytes, as ASIO does - * everywhere else, and so is every read after it. A body cut short by a reset stream or a lost - * connection completes with \c http::error::partial_message instead. - */ - template - requires(boost::asio::is_mutable_buffer_sequence::value) - auto async_read_some(const Buffers& buffers, CompletionToken&& token = CompletionToken()) - { - for (auto& buffer : buffers) - if (buffer.size() > 0) - return async_read_some(buffer, std::forward(token)); - } - - template - auto async_read_some(asio::mutable_buffer buffer, CompletionToken&& token = CompletionToken()) - { - return asio::async_initiate( - [&](ReadSomeHandler handler, asio::mutable_buffer buffer) { // - async_read_some_any(buffer, std::move(handler)); - }, - token, buffer); - } - private: - void async_read_some_any(asio::mutable_buffer buffer, ReadSomeHandler&& handler); - std::shared_ptr impl; + /// Hides Reader::pimpl(), narrowing it to the implementation this handle was built from. + Impl& pimpl() const noexcept; }; static_assert(boost::beast::is_async_read_stream::value); // ------------------------------------------------------------------------------------------------- -class Request +class Request : public Writer { public: class Impl; @@ -129,11 +98,6 @@ class Request void reset() noexcept; ~Request(); - constexpr operator bool() const noexcept { return static_cast(impl); } - - using executor_type = asio::any_io_executor; - executor_type get_executor() const noexcept; - public: using GetResponse = void(boost::system::error_code, Response); using GetResponseHandler = asio::any_completion_handler; @@ -160,59 +124,11 @@ class Request token); } -public: - /** - * Writes \p buffer as part of the request body, which stays open for more. - * - * An empty buffer writes nothing and completes immediately -- use \c async_write_eof() to end - * the body. - */ - template - auto async_write(asio::const_buffer buffer, CompletionToken&& token = CompletionToken()) - { - // FIXME: get_executor() breaks testcase SpawnAndForget because the impl is already gone there - auto executor = asio::get_associated_executor(token); // , get_executor()); - return asio::async_initiate( - asio::bind_executor(executor, - [this](auto&& handler, asio::const_buffer buffer) { // - async_write_any(std::move(handler), buffer, false); - }), - token, buffer); - } - - /** - * Writes \p buffer as the last part of the request body and ends it. - * - * Both go out together, so ending a body that has a tail of data left costs no more than - * writing that tail: no second, empty write and no extra round trip through the protocol - * stack. Re-ending an already-ended body with an empty buffer completes immediately and - * changes nothing; with data attached it completes with \c errc::broken_pipe, just as writing - * that data would -- there is no body left for it to belong to. - */ - template - auto async_write_eof(asio::const_buffer buffer, CompletionToken&& token = CompletionToken()) - { - // see async_write() above for why the executor is not defaulted to get_executor() - auto executor = asio::get_associated_executor(token); - return asio::async_initiate( - asio::bind_executor(executor, - [this](auto&& handler, asio::const_buffer buffer) { // - async_write_any(std::move(handler), buffer, true); - }), - token, buffer); - } - - /// Ends the request body without writing anything more. - template - auto async_write_eof(CompletionToken&& token = CompletionToken()) - { - return async_write_eof(asio::const_buffer{}, std::forward(token)); - } - private: - void async_write_any(WriteHandler&& handler, asio::const_buffer buffer, bool eof); void async_get_response_any(GetResponseHandler&& handler); - std::shared_ptr impl; + + /// Hides Writer::pimpl(), narrowing it to the implementation this handle was built from. + Impl& pimpl() const noexcept; }; // static_assert(boost::beast::is_async_write_stream::value); diff --git a/include/anyhttp/client_impl.hpp b/include/anyhttp/client_impl.hpp index 2f3c177..2f5279b 100644 --- a/include/anyhttp/client_impl.hpp +++ b/include/anyhttp/client_impl.hpp @@ -1,5 +1,7 @@ #pragma once #include "client.hpp" +#include "reader.hpp" +#include "writer.hpp" #include #include @@ -15,7 +17,7 @@ namespace anyhttp::client // ================================================================================================= -class Request::Impl : public impl::Writer +class Request::Impl : public Writer::Impl { public: Impl() noexcept; @@ -24,13 +26,11 @@ class Request::Impl : public impl::Writer virtual void async_submit(StatusHandler&& handler, unsigned int status_code, const Fields& headers) = 0; virtual void async_get_response(GetResponseHandler&& handler) = 0; - - using ReaderOrWriter = impl::Writer; }; // ------------------------------------------------------------------------------------------------- -class Response::Impl : public impl::Reader +class Response::Impl : public Reader::Impl { public: Impl() noexcept; @@ -39,8 +39,6 @@ class Response::Impl : public impl::Reader virtual unsigned int status_code() const noexcept = 0; virtual boost::url_view url() const = 0; virtual const Fields& fields() const = 0; - - using ReaderOrWriter = impl::Reader; }; // ================================================================================================= diff --git a/include/anyhttp/common.hpp b/include/anyhttp/common.hpp index f623e23..541d386 100644 --- a/include/anyhttp/common.hpp +++ b/include/anyhttp/common.hpp @@ -170,60 +170,6 @@ inline void complete_immediately(Handler&& handler, const asio::any_io_executor& // ================================================================================================= -namespace impl -{ -class Reader : public std::enable_shared_from_this -{ -public: - virtual ~Reader() = default; - virtual asio::any_io_executor get_executor() const noexcept = 0; - virtual std::optional content_length() const noexcept = 0; - - // - // Reads at most one buffer worth of the incoming body. The end of the body is reported the way - // ASIO reports it everywhere else: \c asio::error::eof with zero bytes, and again for every - // further read -- including reads issued after the underlying stream object is long gone. A - // body that ends before it was supposed to -- a reset stream, a connection that went away - // mid-message -- is reported as \c http::error::partial_message instead, so the two cases stay - // distinguishable. - // - // An empty buffer is not a request to do anything; it completes immediately with success and - // zero bytes, wherever the body stands. - // - virtual void async_read_some(asio::mutable_buffer buffer, ReadSomeHandler&& handler) = 0; - virtual void detach() = 0; - virtual void destroy() {}; -}; - -class Writer : public std::enable_shared_from_this -{ -public: - virtual ~Writer() = default; - virtual asio::any_io_executor get_executor() const noexcept = 0; - virtual void content_length(std::optional content_length) = 0; - - // - // Writes \p buffer and, if \p eof is set, ends the outgoing body after it. The two travel - // together on purpose: every backend can put the last bytes of a body and the flag that ends - // it into the same protocol element -- one DATA frame with END_STREAM (HTTP/2), one QUIC - // STREAM frame with FIN (HTTP/3), one last chunk (HTTP/1.1) -- so a message that ends with - // data needs no second, empty write to close it out. - // - // Every implementation answers the same entry ladder, in this order: an empty buffer with - // \p eof clear writes nothing at all and completes immediately with success, wherever the - // body stands -- it is not, as it once was, how a body is ended. Once the body has been ended, - // writing data -- through either entry point -- completes with \c errc::broken_pipe, while - // re-ending it with no data attached is an idempotent no-op. Only then do stream-level - // failures (closed, cancelled) get their say. - // - virtual void async_write(WriteHandler&& handler, asio::const_buffer buffer, bool eof) = 0; - virtual void detach() = 0; - virtual void destroy() {}; -}; -} // namespace impl - -// ================================================================================================= - template constexpr std::string_view make_string_view(const T* data, size_t len) { diff --git a/include/anyhttp/h1_session.hpp b/include/anyhttp/h1_session.hpp index 25ec979..527c8e1 100644 --- a/include/anyhttp/h1_session.hpp +++ b/include/anyhttp/h1_session.hpp @@ -48,10 +48,10 @@ class BeastSession : public ::anyhttp::Session::Impl // registers here for as long as it exists, to be detach()ed when the session goes away first. // With pipelining, a client session may have more than one of each at a time. // - void attach(impl::Reader& reader) { m_readers.push_back(&reader); } - void attach(impl::Writer& writer) { m_writers.push_back(&writer); } - void release(impl::Reader& reader) { std::erase(m_readers, &reader); } - void release(impl::Writer& writer) { std::erase(m_writers, &writer); } + void attach(Reader::Impl& reader) { m_readers.push_back(&reader); } + void attach(Writer::Impl& writer) { m_writers.push_back(&writer); } + void release(Reader::Impl& reader) { std::erase(m_readers, &reader); } + void release(Writer::Impl& writer) { std::erase(m_writers, &writer); } void detach_readers() { @@ -80,10 +80,10 @@ class BeastSession : public ::anyhttp::Session::Impl private: /// Non-owning pointers to the attached readers, see attach(). - std::vector m_readers; + std::vector m_readers; /// Non-owning pointers to the attached writers, see attach(). - std::vector m_writers; + std::vector m_writers; }; // ================================================================================================= diff --git a/include/anyhttp/h2_stream.hpp b/include/anyhttp/h2_stream.hpp index 005204a..97a265c 100644 --- a/include/anyhttp/h2_stream.hpp +++ b/include/anyhttp/h2_stream.hpp @@ -2,6 +2,8 @@ #include "client.hpp" #include "common.hpp" +#include "reader.hpp" +#include "writer.hpp" #include "nghttp2/nghttp2.h" @@ -297,8 +299,8 @@ class NGHttp2Stream : public std::enable_shared_from_this /// Log and discard the headers collected by on_header_callback(). void log_received_headers(); - impl::Reader* reader = nullptr; - impl::Writer* writer = nullptr; + Reader::Impl* reader = nullptr; + Writer::Impl* writer = nullptr; void delete_reader(); void delete_writer(); diff --git a/include/anyhttp/h3_stream.hpp b/include/anyhttp/h3_stream.hpp index 0d5faa5..cf82112 100644 --- a/include/anyhttp/h3_stream.hpp +++ b/include/anyhttp/h3_stream.hpp @@ -1,6 +1,8 @@ #pragma once #include "anyhttp/common.hpp" +#include "anyhttp/reader.hpp" +#include "anyhttp/writer.hpp" #include #include @@ -161,8 +163,8 @@ class Http3Stream : public std::enable_shared_from_this // // Lifecycle. // - impl::Reader* reader = nullptr; // the Http3Reader, while attached - impl::Writer* writer = nullptr; // the Http3Writer, while attached + Reader::Impl* reader = nullptr; // the Http3Reader, while attached + Writer::Impl* writer = nullptr; // the Http3Writer, while attached bool closed = false; asio::any_io_executor get_executor() const noexcept; diff --git a/include/anyhttp/reader.hpp b/include/anyhttp/reader.hpp new file mode 100644 index 0000000..54e0a6f --- /dev/null +++ b/include/anyhttp/reader.hpp @@ -0,0 +1,130 @@ +#pragma once + +#include "common.hpp" + +#include +#include +#include + +#include +#include +#include + +namespace anyhttp +{ + +// ================================================================================================= + +/** + * The reading half of a message, as a handle: what a \c server::Request and a \c client::Response + * have in common, and all that an operation which only consumes a body -- \c drain() -- needs to + * see of either. + * + * This is the PIMPL handle itself, not a view onto one: it owns its share of the implementation, + * and \c server::Request and \c client::Response derive from it rather than holding a pointer of + * their own. They add no data members, so moving one into a \c Reader ("slicing") is a sound and + * useful thing to do -- it hands off the reading half with its ownership intact, to be drained by + * a coroutine that has no business with the rest of the message. That is also why the destructor + * is not virtual: these are handles, never owned through a base pointer. + */ +class Reader +{ +public: + /** + * What a protocol backend implements to be read from, and what the handles of both sides + * narrow to their own: \c server::Request::Impl and \c client::Response::Impl derive from this + * and add whatever else their side of the message has to offer. + */ + class Impl : public std::enable_shared_from_this + { + public: + virtual ~Impl() = default; + virtual asio::any_io_executor get_executor() const noexcept = 0; + virtual std::optional content_length() const noexcept = 0; + + // + // Reads at most one buffer worth of the incoming body. The end of the body is reported the + // way ASIO reports it everywhere else: \c asio::error::eof with zero bytes, and again for + // every further read -- including reads issued after the underlying stream object is long + // gone. A body that ends before it was supposed to -- a reset stream, a connection that went + // away mid-message -- is reported as \c http::error::partial_message instead, so the two + // cases stay distinguishable. + // + // An empty buffer is not a request to do anything; it completes immediately with success and + // zero bytes, wherever the body stands. + // + virtual void async_read_some(asio::mutable_buffer buffer, ReadSomeHandler&& handler) = 0; + virtual void detach() = 0; + virtual void destroy() {}; + }; + + // ---------------------------------------------------------------------------------------------- + + using executor_type = asio::any_io_executor; + + Reader() noexcept = default; + explicit Reader(std::shared_ptr impl) noexcept; + Reader(Reader&&) noexcept; + Reader& operator=(Reader&&) noexcept; + ~Reader(); + + /// Releases the implementation, as the destructor does. Reading afterwards fails. + void reset() noexcept; + + constexpr operator bool() const noexcept { return static_cast(m_impl); } + + /// The executor of the session this message belongs to, or an empty one after \c reset(). + executor_type get_executor() const noexcept; + + /// What the incoming message announced as its body length, if it announced one. + std::optional content_length() const noexcept; + + /** + * Reads a part of the incoming body. + * + * The end of the body is reported as \c asio::error::eof with zero bytes, as ASIO does + * everywhere else, and so is every read after it. A body cut short by a reset stream or a lost + * connection completes with \c http::error::partial_message instead. + */ + template + auto async_read_some(asio::mutable_buffer buffer, CompletionToken&& token = CompletionToken()) + { + return asio::async_initiate( + [&](ReadSomeHandler handler, asio::mutable_buffer buffer) { // + async_read_some_any(buffer, std::move(handler)); + }, + token, buffer); + } + + /** + * \overload + * + * FIXME: When given an actual sequence of buffers, this fills only the first non-empty one. + */ + template + requires(asio::is_mutable_buffer_sequence::value && + !std::convertible_to) + auto async_read_some(const Buffers& buffers, CompletionToken&& token = CompletionToken()) + { + for (auto& buffer : buffers) + if (buffer.size() > 0) + return async_read_some(asio::mutable_buffer(buffer), + std::forward(token)); + + return async_read_some(asio::mutable_buffer{}, std::forward(token)); + } + +protected: + /// The implementation, for the derived handle to narrow to its own \c Impl. Never null. + Impl& pimpl() const noexcept { return *m_impl; } + +private: + void async_read_some_any(asio::mutable_buffer buffer, ReadSomeHandler&& handler); + + std::shared_ptr m_impl; +}; + +// ================================================================================================= + +} // namespace anyhttp diff --git a/include/anyhttp/request_handlers.hpp b/include/anyhttp/request_handlers.hpp index 93884e1..81eddb4 100644 --- a/include/anyhttp/request_handlers.hpp +++ b/include/anyhttp/request_handlers.hpp @@ -78,8 +78,8 @@ awaitable discard(server::Request request, server::Response response); // ================================================================================================= -awaitable generate(client::Request& request, size_t bytes); -awaitable read(client::Response& response); +awaitable generate(Writer& writer, size_t bytes); +awaitable read(Reader& reader); // // Reads and discards whatever is left of an incoming body, and returns how much that was. @@ -88,35 +88,13 @@ awaitable read(client::Response& response); // EOF, and let anything else -- a reset stream, a connection that went away mid-body -- come out // as an exception. // -template -awaitable drain(Reader& reader) -{ - size_t bytes = 0; - std::array buffer; - for (;;) - { - auto [ec, n] = co_await reader.async_read_some(asio::buffer(buffer), asio::as_tuple); - bytes += n; - - // the regular end of the body is not something to report as an error - if (ec == asio::error::eof) - { - logd("drain: EOF after reading {} bytes", bytes); - co_return bytes; - } - else if (ec) - { - logw("drain: \x1b[1;31m{}\x1b[0m after reading {} bytes, throwing", what(ec), bytes); - throw boost::system::system_error(ec); - } - } -} +awaitable drain(Reader& reader); -awaitable> try_receive(client::Response& response); -awaitable try_receive(client::Response& response, boost::system::error_code& ec); +awaitable> try_receive(Reader& reader); +awaitable try_receive(Reader& reader, boost::system::error_code& ec); awaitable count_response(client::Request& request); awaitable> try_read_response(client::Request& request); -awaitable send_eof(client::Request& request); +awaitable send_eof(Writer& writer); // ================================================================================================= @@ -128,7 +106,7 @@ concept ByteRange = // FIXME: Do we really need to restrict to "borrowed range" here? The range is kept alive in // the coroutine frame, so we do not need to worry about it's lifetime. // -template +template requires std::ranges::contiguous_range awaitable send(Writer& request, Range range) { @@ -140,7 +118,7 @@ awaitable send(Writer& request, Range range) // // For a non-contiguous range, we need to copy into a buffer first. // -template +template requires(!std::ranges::contiguous_range) awaitable send(Writer& request, Range range) { @@ -202,7 +180,7 @@ awaitable sendAndDrop(client::Request request, Range range) // ------------------------------------------------------------------------------------------------- -template +template awaitable sendAndForceEOF(Writer& request, Range range) { using namespace asio; diff --git a/include/anyhttp/server.hpp b/include/anyhttp/server.hpp index fe8b536..f24042d 100644 --- a/include/anyhttp/server.hpp +++ b/include/anyhttp/server.hpp @@ -1,6 +1,8 @@ #pragma once #include "common.hpp" // IWYU pragma: keep +#include "reader.hpp" +#include "writer.hpp" #include #include @@ -85,7 +87,7 @@ struct Config // ================================================================================================= -class Request +class Request : public Reader { public: class Impl; @@ -95,13 +97,8 @@ class Request void reset() noexcept; ~Request(); - constexpr operator bool() const noexcept { return static_cast(impl); } - - using executor_type = asio::any_io_executor; - executor_type get_executor() const noexcept; - +public: boost::url_view url() const; - std::optional content_length() const noexcept; /// The request header fields, without HTTP/2 and HTTP/3 pseudo-headers. const Fields& fields() const; @@ -141,28 +138,11 @@ class Request return std::nullopt; } -public: - /** - * Reads a part of the request body. - * - * The end of the body is reported as \c asio::error::eof with zero bytes, as ASIO does - * everywhere else, and so is every read after it. A body cut short by a reset stream or a lost - * connection completes with \c http::error::partial_message instead. - */ - template - auto async_read_some(boost::asio::mutable_buffer buffer, - CompletionToken&& token = CompletionToken()) - { - return boost::asio::async_initiate( - [&](ReadSomeHandler handler, asio::mutable_buffer buffer) { // - async_read_some_any(buffer, std::move(handler)); - }, - token, buffer); - } - private: - void async_read_some_any(boost::asio::mutable_buffer buffer, ReadSomeHandler&& handler); - std::shared_ptr impl; + // + // Hides Reader::pimpl(), narrowing it to the implementation this handle was built from. + // + Impl& pimpl() const noexcept; }; // ------------------------------------------------------------------------------------------------- @@ -176,7 +156,7 @@ awaitable sleep(T duration) co_await timer.async_wait(); } -class Response +class Response : public Writer { public: class Impl; @@ -186,21 +166,17 @@ class Response void reset() noexcept; ~Response(); - constexpr operator bool() const noexcept { return static_cast(impl); } - - using executor_type = asio::any_io_executor; - executor_type get_executor() const noexcept; - - void content_length(std::optional content_length); - public: + /** + * Sends the response header, which opens the body for writing. + */ template auto async_submit(unsigned int status_code, const Fields& headers, CompletionToken&& token = CompletionToken()) { // binding the executor lets tokens that need one -- cancel_after's timer -- find it here - return boost::asio::async_initiate( - asio::bind_executor(get_executor(), + return asio::async_initiate( + asio::bind_executor(asio::get_associated_executor(token, get_executor()), [this](StatusHandler handler, unsigned int status_code, const Fields& headers) { // async_submit_any(std::move(handler), status_code, headers); @@ -208,56 +184,11 @@ class Response token, status_code, headers); } - /** - * Writes \p buffer as part of the response body, which stays open for more. - * - * An empty buffer writes nothing and completes immediately -- use \c async_write_eof() to end - * the body. - */ - template - auto async_write(asio::const_buffer buffer, CompletionToken&& token = CompletionToken()) - { - // binding the executor lets tokens that need one -- cancel_after's timer -- find it here - return boost::asio::async_initiate( - asio::bind_executor(get_executor(), - [this](WriteHandler handler, asio::const_buffer buffer) { // - async_write_any(std::move(handler), buffer, false); - }), - token, buffer); - } - - /** - * Writes \p buffer as the last part of the response body and ends it. - * - * Both go out together, so ending a body that has a tail of data left costs no more than - * writing that tail: no second, empty write and no extra round trip through the protocol - * stack. Re-ending an already-ended body with an empty buffer completes immediately and - * changes nothing; with data attached it completes with \c errc::broken_pipe, just as writing - * that data would -- there is no body left for it to belong to. - */ - template - auto async_write_eof(asio::const_buffer buffer, CompletionToken&& token = CompletionToken()) - { - // binding the executor lets tokens that need one -- cancel_after's timer -- find it here - return boost::asio::async_initiate( - asio::bind_executor(get_executor(), - [this](WriteHandler handler, asio::const_buffer buffer) { // - async_write_any(std::move(handler), buffer, true); - }), - token, buffer); - } - - /// Ends the response body without writing anything more. - template - auto async_write_eof(CompletionToken&& token = CompletionToken()) - { - return async_write_eof(asio::const_buffer{}, std::forward(token)); - } - private: void async_submit_any(StatusHandler&& handler, unsigned int status_code, const Fields& headers); - void async_write_any(WriteHandler&& handler, asio::const_buffer buffer, bool eof); - std::shared_ptr impl; + + /// Hides Writer::pimpl(), narrowing it to the implementation this handle was built from. + Impl& pimpl() const noexcept; }; // ================================================================================================= diff --git a/include/anyhttp/server_impl.hpp b/include/anyhttp/server_impl.hpp index 4fd8f7f..c4e1a23 100644 --- a/include/anyhttp/server_impl.hpp +++ b/include/anyhttp/server_impl.hpp @@ -1,6 +1,8 @@ #pragma once +#include "reader.hpp" #include "server.hpp" #include "session.hpp" +#include "writer.hpp" #include #include @@ -19,7 +21,7 @@ namespace anyhttp::server // ================================================================================================= -class Request::Impl : public impl::Reader +class Request::Impl : public Reader::Impl { public: Impl() noexcept; @@ -29,13 +31,11 @@ class Request::Impl : public impl::Reader virtual unsigned int status_code() const noexcept = 0; virtual boost::url_view url() const = 0; virtual const Fields& fields() const = 0; - - using ReaderOrWriter = impl::Reader; }; // ------------------------------------------------------------------------------------------------- -class Response::Impl : public impl::Writer +class Response::Impl : public Writer::Impl { public: Impl() noexcept; @@ -43,8 +43,6 @@ class Response::Impl : public impl::Writer virtual void async_submit(StatusHandler&& handler, unsigned int status_code, const Fields& fields) = 0; - - using ReaderOrWriter = impl::Writer; }; // ================================================================================================= diff --git a/include/anyhttp/writer.hpp b/include/anyhttp/writer.hpp new file mode 100644 index 0000000..772a742 --- /dev/null +++ b/include/anyhttp/writer.hpp @@ -0,0 +1,153 @@ +#pragma once + +#include "common.hpp" + +#include +#include +#include +#include +#include + +#include +#include + +namespace anyhttp +{ + +// ================================================================================================= + +/** + * The writing half of a message, as a handle: what a \c server::Response and a \c client::Request + * have in common, and all that an operation which only produces a body -- \c send() -- needs to + * see of either. + * + * The counterpart of \c Reader, and owned the same way: \c server::Response and \c client::Request + * derive from it and add no data members of their own, so moving one into a \c Writer hands off + * the writing half with its ownership intact. See \c Reader for why the destructor is not virtual. + */ +class Writer +{ +public: + /** + * What a protocol backend implements to be written to, and what the handles of both sides + * narrow to their own: \c server::Response::Impl and \c client::Request::Impl derive from this + * and add whatever else their side of the message has to offer. + */ + class Impl : public std::enable_shared_from_this + { + public: + virtual ~Impl() = default; + virtual asio::any_io_executor get_executor() const noexcept = 0; + virtual void content_length(std::optional content_length) = 0; + + // + // Writes \p buffer and, if \p eof is set, ends the outgoing body after it. The two travel + // together on purpose: every backend can put the last bytes of a body and the flag that ends + // it into the same protocol element -- one DATA frame with END_STREAM (HTTP/2), one QUIC + // STREAM frame with FIN (HTTP/3), one last chunk (HTTP/1.1) -- so a message that ends with + // data needs no second, empty write to close it out. + // + // Every implementation answers the same entry ladder, in this order: an empty buffer with + // \p eof clear writes nothing at all and completes immediately with success, wherever the + // body stands -- it is not, as it once was, how a body is ended. Once the body has been + // ended, writing data -- through either entry point -- completes with \c errc::broken_pipe, + // while re-ending it with no data attached is an idempotent no-op. Only then do stream-level + // failures (closed, cancelled) get their say. + // + virtual void async_write(WriteHandler&& handler, asio::const_buffer buffer, bool eof) = 0; + virtual void detach() = 0; + virtual void destroy() {}; + }; + + // ---------------------------------------------------------------------------------------------- + + using executor_type = asio::any_io_executor; + + Writer() noexcept = default; + explicit Writer(std::shared_ptr impl) noexcept; + Writer(Writer&&) noexcept; + Writer& operator=(Writer&&) noexcept; + ~Writer(); + + /// Releases the implementation, as the destructor does. Writing afterwards fails. + void reset() noexcept; + + constexpr operator bool() const noexcept { return static_cast(m_impl); } + + /// The executor of the session this message belongs to, or an empty one after \c reset(). + executor_type get_executor() const noexcept; + + /// Announces the length of the outgoing body, before its header is submitted. + void content_length(std::optional content_length); + + /** + * Writes \p buffer as part of the outgoing body, which stays open for more. + * + * An empty buffer writes nothing and completes immediately -- use \c async_write_eof() to end + * the body. + */ + template + auto async_write(asio::const_buffer buffer, CompletionToken&& token = CompletionToken()) + { + return asio::async_initiate( + asio::bind_executor(write_executor(token), + [this](WriteHandler handler, asio::const_buffer buffer) { // + async_write_any(std::move(handler), buffer, false); + }), + token, buffer); + } + + /** + * Writes \p buffer as the last part of the outgoing body and ends it. + * + * Both go out together, so ending a body that has a tail of data left costs no more than + * writing that tail: no second, empty write and no extra round trip through the protocol + * stack. Re-ending an already-ended body with an empty buffer completes immediately and + * changes nothing; with data attached it completes with \c errc::broken_pipe, just as writing + * that data would -- there is no body left for it to belong to. + */ + template + auto async_write_eof(asio::const_buffer buffer, CompletionToken&& token = CompletionToken()) + { + return asio::async_initiate( + asio::bind_executor(write_executor(token), + [this](WriteHandler handler, asio::const_buffer buffer) { // + async_write_any(std::move(handler), buffer, true); + }), + token, buffer); + } + + /// Ends the outgoing body without writing anything more. + template + auto async_write_eof(CompletionToken&& token = CompletionToken()) + { + return async_write_eof(asio::const_buffer{}, std::forward(token)); + } + +protected: + /// The implementation, for the derived handle to narrow to its own \c Impl. Never null. + Impl& pimpl() const noexcept { return *m_impl; } + +private: + // + // Binding an executor to the initiating function lets tokens that need one -- the timer behind + // cancel_after -- find it here, with the token's own executor taking precedence as usual. A + // writer whose implementation is already gone (the handle was released while a write was still + // outstanding, see the SpawnAndForget test) has none to offer, and the token is left with + // whatever it brought itself. Such a write fails with bad_descriptor without touching an + // executor at all, so an empty one here is never used. + // + template + auto write_executor(const CompletionToken& token) const noexcept + { + return asio::get_associated_executor(token, get_executor()); + } + + void async_write_any(WriteHandler&& handler, asio::const_buffer buffer, bool eof); + + std::shared_ptr m_impl; +}; + +// ================================================================================================= + +} // namespace anyhttp diff --git a/src/client.cpp b/src/client.cpp index a647ad7..4898cce 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -12,9 +12,9 @@ namespace anyhttp::client // ================================================================================================= -Request::Request(std::unique_ptr impl_) : impl(std::move(impl_)) +Request::Request(std::unique_ptr impl) : Writer(std::move(impl)) { - if (impl) + if (*this) logd("\x1b[1;34mClient::Request: ctor\x1b[0m"); } @@ -23,11 +23,10 @@ Request& Request::operator=(Request&& other) noexcept = default; void Request::reset() noexcept { - if (impl) + if (*this) { logd("\x1b[34mClient::Request: dtor\x1b[0m"); - impl->destroy(); - impl.reset(); + Writer::reset(); } } @@ -35,35 +34,23 @@ Request::~Request() { reset(); } // ------------------------------------------------------------------------------------------------- -void Request::async_write_any(WriteHandler&& handler, asio::const_buffer buffer, bool eof) -{ - if (impl) - impl->async_write(std::move(handler), buffer, eof); - else - std::move(handler)(boost::asio::error::bad_descriptor); -} +Request::Impl& Request::pimpl() const noexcept { return static_cast(Writer::pimpl()); } void Request::async_get_response_any(Request::GetResponseHandler&& handler) { - if (impl) - impl->async_get_response(std::move(handler)); + if (*this) + pimpl().async_get_response(std::move(handler)); else std::move(handler)(boost::asio::error::bad_descriptor, Response{nullptr}); } -asio::any_io_executor Request::get_executor() const noexcept -{ - assert(impl); - return impl->get_executor(); -} - // ================================================================================================= -Response::Response() : impl(nullptr) {} +Response::Response() = default; -Response::Response(std::unique_ptr impl_) : impl(std::move(impl_)) +Response::Response(std::unique_ptr impl) : Reader(std::move(impl)) { - if (impl) + if (*this) logd("\x1b[1;34mClient::Response: ctor\x1b[0m"); } @@ -72,11 +59,10 @@ Response& Response::operator=(Response&& other) noexcept = default; void Response::reset() noexcept { - if (impl) + if (*this) { logd("\x1b[34mClient::Response: dtor\x1b[0m"); - impl->destroy(); - impl.reset(); + Reader::reset(); } } @@ -84,16 +70,10 @@ Response::~Response() { reset(); } // ------------------------------------------------------------------------------------------------- -int Response::status_code() const noexcept { return impl->status_code(); } -const Fields& Response::fields() const { return impl->fields(); } +Response::Impl& Response::pimpl() const noexcept { return static_cast(Reader::pimpl()); } -void Response::async_read_some_any(boost::asio::mutable_buffer buffer, ReadSomeHandler&& handler) -{ - if (impl) - impl->async_read_some(buffer, std::move(handler)); - else - std::move(handler)(boost::asio::error::bad_descriptor, 0); -} +int Response::status_code() const noexcept { return pimpl().status_code(); } +const Fields& Response::fields() const { return pimpl().fields(); } // ================================================================================================= diff --git a/src/reader.cpp b/src/reader.cpp new file mode 100644 index 0000000..ef357c7 --- /dev/null +++ b/src/reader.cpp @@ -0,0 +1,49 @@ +#include "anyhttp/reader.hpp" + +#include +#include + +namespace anyhttp +{ + +// ================================================================================================= + +Reader::Reader(std::shared_ptr impl) noexcept : m_impl(std::move(impl)) {} + +Reader::Reader(Reader&&) noexcept = default; +Reader& Reader::operator=(Reader&&) noexcept = default; + +Reader::~Reader() { reset(); } + +void Reader::reset() noexcept +{ + if (m_impl) + { + m_impl->destroy(); + m_impl.reset(); + } +} + +// ------------------------------------------------------------------------------------------------- + +Reader::executor_type Reader::get_executor() const noexcept +{ + return m_impl ? m_impl->get_executor() : executor_type{}; +} + +std::optional Reader::content_length() const noexcept +{ + return m_impl ? m_impl->content_length() : std::nullopt; +} + +void Reader::async_read_some_any(asio::mutable_buffer buffer, ReadSomeHandler&& handler) +{ + if (m_impl) + m_impl->async_read_some(buffer, std::move(handler)); + else + std::move(handler)(asio::error::bad_descriptor, 0); +} + +// ================================================================================================= + +} // namespace anyhttp diff --git a/src/request_handlers.cpp b/src/request_handlers.cpp index 1d42440..f05530c 100644 --- a/src/request_handlers.cpp +++ b/src/request_handlers.cpp @@ -159,18 +159,41 @@ awaitable discard(server::Request request, server::Response response) { co // ================================================================================================= -awaitable generate(client::Request& request, size_t bytes) +awaitable generate(Writer& writer, size_t bytes) { - return sendAndForceEOF(request, rv::iota(uint8_t{0}) | rv::take(bytes)); + return sendAndForceEOF(writer, rv::iota(uint8_t{0}) | rv::take(bytes)); } -awaitable read(client::Response& response) +awaitable drain(Reader& reader) +{ + size_t bytes = 0; + std::array buffer; + for (;;) + { + auto [ec, n] = co_await reader.async_read_some(asio::buffer(buffer), as_tuple); + bytes += n; + + // the regular end of the body is not something to report as an error + if (ec == asio::error::eof) + { + logd("drain: EOF after reading {} bytes", bytes); + co_return bytes; + } + else if (ec) + { + logw("drain: \x1b[1;31m{}\x1b[0m after reading {} bytes, throwing", what(ec), bytes); + throw boost::system::system_error(ec); + } + } +} + +awaitable read(Reader& reader) { std::string body; std::array buffer; for (;;) { - auto [ec, n] = co_await response.async_read_some(asio::buffer(buffer), as_tuple); + auto [ec, n] = co_await reader.async_read_some(asio::buffer(buffer), as_tuple); body += std::string_view(buffer.data(), n); if (ec == asio::error::eof) { @@ -187,13 +210,13 @@ awaitable read(client::Response& response) } } -awaitable> try_receive(client::Response& response) +awaitable> try_receive(Reader& reader) { size_t bytes = 0; std::array buffer; for (;;) { - auto [ec, n] = co_await response.async_read_some(asio::buffer(buffer), as_tuple); + auto [ec, n] = co_await reader.async_read_some(asio::buffer(buffer), as_tuple); bytes += n; // the regular end of the body is not something to report as an error @@ -210,10 +233,10 @@ awaitable> try_receive(client::Response& response } } -awaitable try_receive(client::Response& response, error_code& ec) +awaitable try_receive(Reader& reader, error_code& ec) { size_t bytes; - std::tie(bytes, ec) = co_await try_receive(response); + std::tie(bytes, ec) = co_await try_receive(reader); co_return bytes; } @@ -236,7 +259,7 @@ awaitable> try_read_response(client::Request& request) } } -awaitable send_eof(client::Request& request) { co_await request.async_write_eof(); } +awaitable send_eof(Writer& writer) { co_await writer.async_write_eof(); } awaitable h2spec(server::Request request, server::Response response) { diff --git a/src/server.cpp b/src/server.cpp index 3684522..72210e7 100644 --- a/src/server.cpp +++ b/src/server.cpp @@ -9,7 +9,7 @@ namespace anyhttp::server // ================================================================================================= -Request::Request(std::shared_ptr impl) : impl(std::move(impl)) +Request::Request(std::shared_ptr impl) : Reader(std::move(impl)) { logd("\x1b[1;35mServer::Request: ctor\x1b[0m"); } @@ -19,11 +19,10 @@ Request& Request::operator=(Request&& other) noexcept = default; void Request::reset() noexcept { - if (impl) + if (*this) { logd("\x1b[35mServer::Request: dtor\x1b[0m"); - impl->destroy(); - impl.reset(); + Reader::reset(); } } @@ -31,35 +30,14 @@ Request::~Request() { reset(); } // ------------------------------------------------------------------------------------------------- -asio::any_io_executor Request::get_executor() const noexcept { return impl->get_executor(); } +Request::Impl& Request::pimpl() const noexcept { return static_cast(Reader::pimpl()); } -boost::url_view Request::url() const -{ - assert(impl); - return impl->url(); -} - -std::optional Request::content_length() const noexcept -{ - assert(impl); - return impl->content_length(); -} - -const Fields& Request::fields() const -{ - assert(impl); - return impl->fields(); -} - -void Request::async_read_some_any(asio::mutable_buffer buffer, ReadSomeHandler&& handler) -{ - assert(impl); - impl->async_read_some(buffer, std::move(handler)); -} +boost::url_view Request::url() const { return pimpl().url(); } +const Fields& Request::fields() const { return pimpl().fields(); } // ================================================================================================= -Response::Response(std::shared_ptr impl) : impl(std::move(impl)) +Response::Response(std::shared_ptr impl) : Writer(std::move(impl)) { logd("\x1b[1;35mServer::Response: ctor\x1b[0m"); } @@ -69,11 +47,10 @@ Response& Response::operator=(Response&& other) noexcept = default; void Response::reset() noexcept { - if (impl) + if (*this) { logd("\x1b[35mServer::Response: dtor\x1b[0m"); - impl->destroy(); - impl.reset(); + Writer::reset(); } } @@ -81,25 +58,12 @@ Response::~Response() { reset(); } // ------------------------------------------------------------------------------------------------- -asio::any_io_executor Response::get_executor() const noexcept { return impl->get_executor(); } - -void Response::content_length(std::optional content_length) -{ - assert(impl); - impl->content_length(content_length); -} +Response::Impl& Response::pimpl() const noexcept { return static_cast(Writer::pimpl()); } void Response::async_submit_any(StatusHandler&& handler, unsigned int status_code, const Fields& headers) { - assert(impl); - impl->async_submit(std::move(handler), status_code, std::move(headers)); -} - -void Response::async_write_any(WriteHandler&& handler, asio::const_buffer buffer, bool eof) -{ - assert(impl); - impl->async_write(std::move(handler), buffer, eof); + pimpl().async_submit(std::move(handler), status_code, headers); } // ================================================================================================= diff --git a/src/writer.cpp b/src/writer.cpp new file mode 100644 index 0000000..582a1f3 --- /dev/null +++ b/src/writer.cpp @@ -0,0 +1,50 @@ +#include "anyhttp/writer.hpp" + +#include +#include + +namespace anyhttp +{ + +// ================================================================================================= + +Writer::Writer(std::shared_ptr impl) noexcept : m_impl(std::move(impl)) {} + +Writer::Writer(Writer&&) noexcept = default; +Writer& Writer::operator=(Writer&&) noexcept = default; + +Writer::~Writer() { reset(); } + +void Writer::reset() noexcept +{ + if (m_impl) + { + m_impl->destroy(); + m_impl.reset(); + } +} + +// ------------------------------------------------------------------------------------------------- + +Writer::executor_type Writer::get_executor() const noexcept +{ + return m_impl ? m_impl->get_executor() : executor_type{}; +} + +void Writer::content_length(std::optional content_length) +{ + assert(m_impl); + m_impl->content_length(content_length); +} + +void Writer::async_write_any(WriteHandler&& handler, asio::const_buffer buffer, bool eof) +{ + if (m_impl) + m_impl->async_write(std::move(handler), buffer, eof); + else + std::move(handler)(asio::error::bad_descriptor); +} + +// ================================================================================================= + +} // namespace anyhttp From 29ab30e6ad823f695fd2df1cd2056059b8c15312 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Mon, 21 Sep 2026 06:29:43 +0000 Subject: [PATCH 12/19] refactor: give a server request a method() instead of a status code A request has a method and a URL, a response has a status code -- but both Reader implementations declared all three, with a FIXME admitting that a status code on server::Request::Impl makes no sense. The mirror image was url() on client::Response::Impl, which handed out the URL of the *request* and was never called. Both are gone now; fields() stays on both, being the one part of a message header that really is symmetric. server::Request gains a public method(), which had no way of reaching the request handler before. All three backends already keep the method on the stream, so nothing new has to be parsed or stored for it. What this cost is the single reader template per backend. NGHttp2Reader, Http3Reader and BeastReader are each instantiated once per role and marked the role-specific accessors "override", which stops working the moment the two interfaces stop declaring the same set. Constraining a member is not an option -- a virtual function may not have a trailing requires-clause -- and dropping "override" would only move the meaningless member from the interface into the backend. So each reader now splits into the shared part and a thin role class next to where it is built, leaving the backend headers free of server_impl.hpp/client_impl.hpp. That split pays for itself in h1: the role class fixes the parser type, so status_code() loses its "if constexpr (Parser::is_request())" branch, m_url moves down into the request reader where it belongs, the dead m_status_code member is gone, and both call sites get considerably shorter. The dump handler now reports the method, which puts ClientAsync.Dump behind it on all three protocols; the h2c upgrade test checks for it as well, that path carrying the method across the handover to HTTP/2 separately. Co-Authored-By: Claude Opus 5 --- include/anyhttp/client_impl.hpp | 5 ++- include/anyhttp/h2_stream.hpp | 2 -- include/anyhttp/h3_stream.hpp | 9 ----- include/anyhttp/server.hpp | 3 ++ include/anyhttp/server_impl.hpp | 7 ++-- src/h1_session.cpp | 60 ++++++++++++++++++++++++--------- src/h2_stream.cpp | 55 +++++++++++++++++++++--------- src/h3_client.cpp | 17 ++++++++-- src/h3_server.cpp | 26 +++++++++++++- src/request_handlers.cpp | 1 + src/server.cpp | 1 + test/test_client_async.cpp | 1 + test/test_h2c_upgrade.cpp | 1 + 13 files changed, 139 insertions(+), 49 deletions(-) diff --git a/include/anyhttp/client_impl.hpp b/include/anyhttp/client_impl.hpp index 2f5279b..b360f70 100644 --- a/include/anyhttp/client_impl.hpp +++ b/include/anyhttp/client_impl.hpp @@ -36,8 +36,11 @@ class Response::Impl : public Reader::Impl Impl() noexcept; virtual ~Impl(); + // + // The status line, as it arrived. A response has no method or URL -- those are the other half + // of the exchange, on server::Request::Impl. + // virtual unsigned int status_code() const noexcept = 0; - virtual boost::url_view url() const = 0; virtual const Fields& fields() const = 0; }; diff --git a/include/anyhttp/h2_stream.hpp b/include/anyhttp/h2_stream.hpp index 97a265c..8a4134e 100644 --- a/include/anyhttp/h2_stream.hpp +++ b/include/anyhttp/h2_stream.hpp @@ -43,8 +43,6 @@ class NGHttp2Reader : public Interface void async_read_some(boost::asio::mutable_buffer buffer, ReadSomeHandler&& handler) override; void detach() override; - unsigned int status_code() const noexcept override; - boost::url_view url() const override; const Fields& fields() const override; NGHttp2Stream* stream; diff --git a/include/anyhttp/h3_stream.hpp b/include/anyhttp/h3_stream.hpp index cf82112..173fb90 100644 --- a/include/anyhttp/h3_stream.hpp +++ b/include/anyhttp/h3_stream.hpp @@ -260,15 +260,6 @@ class Http3Reader : public Interface return stream ? stream->content_length : std::nullopt; } - /// Only meaningful for a client::Response; a server::Request has no status, and reports 0. - unsigned int status_code() const noexcept override { return stream ? stream->status_code : 0; } - - boost::url_view url() const override - { - assert(stream); - return stream->url; - } - const Fields& fields() const override { assert(stream); diff --git a/include/anyhttp/server.hpp b/include/anyhttp/server.hpp index f24042d..61e7a52 100644 --- a/include/anyhttp/server.hpp +++ b/include/anyhttp/server.hpp @@ -98,6 +98,9 @@ class Request : public Reader ~Request(); public: + /// The request method, as it arrived: "GET", "POST", ... + std::string_view method() const noexcept; + boost::url_view url() const; /// The request header fields, without HTTP/2 and HTTP/3 pseudo-headers. diff --git a/include/anyhttp/server_impl.hpp b/include/anyhttp/server_impl.hpp index c4e1a23..bd92c7d 100644 --- a/include/anyhttp/server_impl.hpp +++ b/include/anyhttp/server_impl.hpp @@ -27,8 +27,11 @@ class Request::Impl : public Reader::Impl Impl() noexcept; virtual ~Impl(); - // FIXME: doesn't make sense to have a status_code() for a server request, but keeps beast happy - virtual unsigned int status_code() const noexcept = 0; + // + // The request line, as it arrived. A request has no status code -- that is the other half of + // the exchange, on client::Response::Impl. + // + virtual std::string_view method() const noexcept = 0; virtual boost::url_view url() const = 0; virtual const Fields& fields() const = 0; }; diff --git a/src/h1_session.cpp b/src/h1_session.cpp index a139af9..fe7f1e2 100644 --- a/src/h1_session.cpp +++ b/src/h1_session.cpp @@ -140,14 +140,6 @@ class BeastReader : public Interface session = nullptr; } - unsigned int status_code() const noexcept override - { - if constexpr (typename Parser::is_request()) - return 0; - else - return parser.get().result_int(); - } - boost::url_view url() const override { return m_url; } const Fields& fields() const override { return parser.get(); } std::optional content_length() const noexcept override { @@ -237,14 +229,53 @@ class BeastReader : public Interface Buffer& buffer; Parser parser; asio::any_io_executor m_executor; // kept as a copy so a detached reader can still complete - std::optional m_status_code = 0; - boost::url m_url; bool reading = false; bool finished = false; // see finish() }; // ------------------------------------------------------------------------------------------------- +// +// The two roles a reader can be in. Everything above is the same for both; what they add is the +// half of the incoming message that only their role has -- a request line, or a status code. +// Which of the two Beast parsers a role reads with follows from the role itself. +// + +template +class BeastRequestReader final + : public BeastReader> +{ + using Base = BeastReader>; + +public: + using Base::Base; + + std::string_view method() const noexcept override { return this->parser.get().method_string(); } + boost::url_view url() const override { return m_url; } + + /// Assembled from the request target, the host header and the kind of socket this arrived on, + /// by the session, right after the header was parsed. + boost::url m_url; +}; + +template +class BeastResponseReader final + : public BeastReader> +{ + using Base = BeastReader>; + +public: + using Base::Base; + + unsigned int status_code() const noexcept override { return this->parser.get().result_int(); } +}; + +// ------------------------------------------------------------------------------------------------- + /** * Handler wrapper that forwards all associated properties (executor, allocator, cancellation slot) * to the underlying async operation. This is more efficient and cleaner than nested bind_* calls. @@ -730,8 +761,7 @@ class RequestWriter // auto& stream = session->m_stream; auto reader = - std::make_unique, - decltype(buffer), http::response_parser>>( + std::make_unique, decltype(buffer)>>( *session, stream, buffer); http::response_parser& parser = reader->parser; parser.header_limit(header_limit(cs.client().config().max_header_size)); @@ -963,10 +993,8 @@ awaitable ServerSession::do_session(Buffer&& buffer) while (!m_closed) { detach_readers(); // the previous request, if still around, is done with the stream - auto reader = - std::make_unique>>(*this, m_stream, - m_buffer); + auto reader = std::make_unique>( + *this, m_stream, m_buffer); logd(""); mlogd("waiting for request (size={} capacity={})", m_buffer.size(), m_buffer.capacity()); diff --git a/src/h2_stream.cpp b/src/h2_stream.cpp index 8ba1512..5fb02f8 100644 --- a/src/h2_stream.cpp +++ b/src/h2_stream.cpp @@ -80,20 +80,6 @@ asio::any_io_executor NGHttp2Reader::get_executor() const noexcept return executor; } -template -unsigned int NGHttp2Reader::status_code() const noexcept -{ - assert(stream); - return stream->status_code.value_or(0); -} - -template -boost::url_view NGHttp2Reader::url() const -{ - assert(stream); - return {stream->url}; -} - template const Fields& NGHttp2Reader::fields() const { @@ -164,6 +150,43 @@ void NGHttp2Reader::async_read_some(boost::asio::mutable_buffer buffer, // ================================================================================================= +// +// The two roles a reader can be in. Everything above is the same for both; what they add is the +// half of the incoming message that only their role has -- a request line, or a status code. +// + +class NGHttp2RequestReader final : public NGHttp2Reader +{ +public: + using NGHttp2Reader::NGHttp2Reader; + + std::string_view method() const noexcept override + { + assert(stream); + return stream->method; + } + + boost::url_view url() const override + { + assert(stream); + return {stream->url}; + } +}; + +class NGHttp2ResponseReader final : public NGHttp2Reader +{ +public: + using NGHttp2Reader::NGHttp2Reader; + + unsigned int status_code() const noexcept override + { + assert(stream); + return stream->status_code.value_or(0); + } +}; + +// ================================================================================================= + template NGHttp2Writer::NGHttp2Writer(NGHttp2Stream& stream) : stream(&stream), executor(stream.get_executor()) @@ -854,7 +877,7 @@ void NGHttp2Stream::deliver_response() else { response_delivered = true; - auto impl = client::Response{std::make_unique>(*this)}; + auto impl = client::Response{std::make_unique(*this)}; swap_and_invoke(response_handler, boost::system::error_code{}, std::move(impl)); } } @@ -872,7 +895,7 @@ void NGHttp2Stream::on_request() // TODO: Implement request queue. Until then, separate preparation of request/response from // the actual handling. // - server::Request request(std::make_unique>(*this)); + server::Request request(std::make_unique(*this)); server::Response response(std::make_unique>(*this)); auto& server = dynamic_cast(parent).server(); diff --git a/src/h3_client.cpp b/src/h3_client.cpp index fd942a8..1a992ce 100644 --- a/src/h3_client.cpp +++ b/src/h3_client.cpp @@ -247,6 +247,20 @@ class Http3ClientSession : public http3::Http3Session // Http3ClientStream implementation // ================================================================================================= +// +// The reading half of a client response: what http3::Http3Reader has for both roles, plus the +// status code, which only this role has. Its counterpart on the server is Http3RequestReader. +// +class Http3ResponseReader final : public http3::Http3Reader +{ +public: + using Http3Reader::Http3Reader; + + unsigned int status_code() const noexcept override { return stream ? stream->status_code : 0; } +}; + +// ------------------------------------------------------------------------------------------------- + Http3ClientStream::Http3ClientStream(Http3ClientSession& s, int64_t stream_id) : http3::Http3Stream(s, stream_id, http3::WriteMode::Staged) { @@ -395,8 +409,7 @@ void Http3ClientStream::deliver_response() return; response_delivered = true; - auto response = - client::Response{std::make_unique>(*this)}; + auto response = client::Response{std::make_unique(*this)}; swap_and_invoke(response_handler, boost::system::error_code{}, std::move(response)); } diff --git a/src/h3_server.cpp b/src/h3_server.cpp index ab4a8dc..80ec3a2 100644 --- a/src/h3_server.cpp +++ b/src/h3_server.cpp @@ -393,6 +393,30 @@ class Http3ServerImpl : public Http3Server, public std::enable_shared_from_this< // Http3ServerStream implementation // ================================================================================================= +// +// The reading half of a server request: what http3::Http3Reader has for both roles, plus the +// request line, which only this role has. Its counterpart on the client is Http3ResponseReader. +// +class Http3RequestReader final : public http3::Http3Reader +{ +public: + using Http3Reader::Http3Reader; + + std::string_view method() const noexcept override + { + assert(stream); + return stream->method; + } + + boost::url_view url() const override + { + assert(stream); + return stream->url; + } +}; + +// ------------------------------------------------------------------------------------------------- + Http3ServerStream::Http3ServerStream(Http3ServerSession& s, int64_t stream_id) : http3::Http3Stream(s, stream_id, http3::WriteMode::ZeroCopy) { @@ -427,7 +451,7 @@ void Http3ServerStream::on_headers_complete() // // Build the user-facing Request/Response and dispatch through the shared handler. // - server::Request request(std::make_unique>(*this)); + server::Request request(std::make_unique(*this)); server::Response response(std::make_unique>(*this)); auto& sv = static_cast(session).server(); diff --git a/src/request_handlers.cpp b/src/request_handlers.cpp index f05530c..9609ccc 100644 --- a/src/request_handlers.cpp +++ b/src/request_handlers.cpp @@ -60,6 +60,7 @@ awaitable dump(server::Request request, server::Response response) auto url = request.url(); std::stringstream str; + std::println(str, "method: {}", request.method()); std::println(str, "RAW URL: {}", url.buffer()); std::println(str, "authority: {} ({})", url.authority(), url.encoded_authority()); std::println(str, "path: {} ({})", url.path(), url.encoded_path()); diff --git a/src/server.cpp b/src/server.cpp index 72210e7..378dc21 100644 --- a/src/server.cpp +++ b/src/server.cpp @@ -32,6 +32,7 @@ Request::~Request() { reset(); } Request::Impl& Request::pimpl() const noexcept { return static_cast(Reader::pimpl()); } +std::string_view Request::method() const noexcept { return pimpl().method(); } boost::url_view Request::url() const { return pimpl().url(); } const Fields& Request::fields() const { return pimpl().fields(); } diff --git a/test/test_client_async.cpp b/test/test_client_async.cpp index 6e503a0..adede84 100644 --- a/test/test_client_async.cpp +++ b/test/test_client_async.cpp @@ -1038,6 +1038,7 @@ TEST_P(ClientAsync, Dump) co_await send_eof(request); auto response = co_await request.async_get_response(); auto dump = co_await read(response); + EXPECT_THAT(dump, HasSubstr("method: POST")); EXPECT_THAT(dump, HasSubstr("path: /dump space")); EXPECT_THAT(dump, HasSubstr(" blah=white space")); }; diff --git a/test/test_h2c_upgrade.cpp b/test/test_h2c_upgrade.cpp index 16b67bb..a9c4394 100644 --- a/test/test_h2c_upgrade.cpp +++ b/test/test_h2c_upgrade.cpp @@ -244,6 +244,7 @@ TEST_F(H2CUpgrade, WHEN_upgrade_is_requested_THEN_request_continues_as_stream_1) ASSERT_TRUE(responses.contains(1)); EXPECT_EQ(responses[1].status, 200); EXPECT_TRUE(responses[1].closed); + EXPECT_THAT(responses[1].body, HasSubstr("method: GET")); // carried over by the upgrade EXPECT_THAT(responses[1].body, HasSubstr("path: /dump")); EXPECT_THAT(responses[1].body, HasSubstr("query: first")); } From 80d61cf256de935608be19b42159e6b2ae8cf069 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Mon, 21 Sep 2026 19:47:17 +0000 Subject: [PATCH 13/19] refactor: integrate concurrent_channel for session management in server and client tests --- src/server_impl.cpp | 19 +++++++++++++++++-- test/test_client_async.cpp | 36 ++++++++++++++++++++++++++---------- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/src/server_impl.cpp b/src/server_impl.cpp index 1801062..f60b484 100644 --- a/src/server_impl.cpp +++ b/src/server_impl.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -387,6 +388,17 @@ awaitable Server::Impl::tcp_accept_loop() // Maybe the simplest solution is to put a mutex around it... // size_t sessionCounter = 0; + + // + // Sessions run on their own executors and finish on whatever thread happens to be running + // them, so the "one more is gone" signal has to cross threads: concurrent_channel is the + // thread-safe flavour. It is only a nudge -- sessionCounter, read under the mutex, is the + // actual condition -- so a try_send() that finds the buffer full may be dropped: whenever + // the waiter is about to block, the buffer is empty and every session still counted has its + // own send() ahead of it. + // + experimental::concurrent_channel sessionDone{executor, 1}; + for (;;) { // @@ -425,6 +437,7 @@ awaitable Server::Impl::tcp_accept_loop() [&, ep](const std::exception_ptr& ex) mutable { auto lock = std::lock_guard(m_sessionMutex); --sessionCounter; + std::ignore = sessionDone.try_send(boost::system::error_code{}); if (ex) logw("[{}] {}", ep, what(ex)); else @@ -433,7 +446,9 @@ awaitable Server::Impl::tcp_accept_loop() } // - // FIXME: implement a better waiting mechanism using async promises or just a condition variable. + // Wait for the sessions spawned above, sweeping the registry on every wake-up: a connection + // that was already in flight when the acceptor closed may still register itself after the + // first sweep, and destroying it is what makes it finish. // auto lock = std::unique_lock(m_sessionMutex); const auto waitingFor = sessionCounter; @@ -447,7 +462,7 @@ awaitable Server::Impl::tcp_accept_loop() m_sessions.clear(); lock.unlock(); - co_await post(executor); + std::ignore = co_await sessionDone.async_receive(as_tuple); lock.lock(); } diff --git a/test/test_client_async.cpp b/test/test_client_async.cpp index adede84..9a36143 100644 --- a/test/test_client_async.cpp +++ b/test/test_client_async.cpp @@ -2,6 +2,8 @@ #include +#include + #include #include #include @@ -151,19 +153,34 @@ TEST_P(ClientAsync, WHEN_session_is_gone_THEN_request_reports_error) }; } +// +// The two sides hand over explicitly instead of polling: the client says when it is gone, and +// the responder says when it is done. The latter is not just tidiness -- the client coroutine +// returning is what tears the server down, so it must not return early. +// +// One wait resists that treatment. What the responder is really waiting for is its own session +// to be gone, and for HTTP/2 and HTTP/3 that only happens once the server has noticed the closed +// connection -- a network event, which nothing in this process can be woken by. So the sleep +// stays, but it now covers only that, with the client's own teardown fenced off ahead of it. +// +using Signal = asio::experimental::concurrent_channel; + TEST_P(ClientAsync, WHEN_server_session_is_gone_THEN_response_reports_error) { - auto responded = std::make_shared(false); - requestHandler = [responded](server::Request request, - server::Response response) -> awaitable { + auto clientGone = std::make_shared(context.get_executor(), 1); + auto responded = std::make_shared(context.get_executor(), 1); + + requestHandler = [clientGone, responded](server::Request request, + server::Response response) -> awaitable { // // Keep the response around beyond the request handler, until the client has closed the // connection and the server session has ended. // co_spawn( co_await this_coro::executor, - [responded, response = std::move(response)]() mutable -> awaitable { - co_await sleep(100ms); + [clientGone, responded, response = std::move(response)]() mutable -> awaitable { + co_await clientGone->async_receive(); + co_await sleep(100ms); // no channel can stand in for this, see above auto [ec] = co_await response.async_submit(200, {}, as_tuple); EXPECT_TRUE(is_connection_error(ec)) << what(ec); @@ -171,20 +188,19 @@ TEST_P(ClientAsync, WHEN_server_session_is_gone_THEN_response_reports_error) std::tie(ec) = co_await response.async_write(asio::buffer("Hello"sv), as_tuple); EXPECT_TRUE(is_connection_error(ec)) << what(ec); - *responded = true; + co_await responded->async_send(boost::system::error_code{}); }, detached); co_return; }; - clientSession = [this, responded](Session session) -> awaitable { + clientSession = [this, clientGone, responded](Session session) -> awaitable { auto request = co_await session.async_submit(url, {}); co_await request.async_write_eof(); request.reset(); session.reset(); - for (int i = 0; i < 100 && !*responded; ++i) - co_await sleep(10ms); - EXPECT_TRUE(*responded); + co_await clientGone->async_send(boost::system::error_code{}); + co_await responded->async_receive(); }; } From a187c9934404d0de28b052c0f0744bea937e4cc3 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Mon, 21 Sep 2026 19:47:54 +0000 Subject: [PATCH 14/19] docs: replace the mermaid class diagram with a drawio overview The inline mermaid diagram had drifted out of sync with the actual class hierarchy and was awkward to extend. Render it from a drawio SVG instead, shown in a new "Class Hierarchy" section. Co-Authored-By: Claude Opus 5 --- README.md | 50 +-- docs/overview.drawio.svg | 830 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 834 insertions(+), 46 deletions(-) create mode 100644 docs/overview.drawio.svg diff --git a/README.md b/README.md index fc4d0c6..d3fd075 100644 --- a/README.md +++ b/README.md @@ -78,60 +78,18 @@ The convenience is paid for with memory, as the body is buffered in full: anythi look at the body while it arrives, or to send a body of its own, still goes through `async_submit()`. +# Class Hierarchy + +![Class hierarchy](docs/overview.drawio.svg) + # 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. The implementation is hidden behind [any_completion_handler](https://www.boost.org/doc/libs/1_86_0/doc/html/boost_asio/reference/any_completion_handler.html) so that it can be compiled separately. - This work is partly inspired by [asio-grpc](https://github.com/Tradias/asio-grpc), which takes the idea even one step further and also supports the upcoming sender/receiver model of execution. -```mermaid -classDiagram - -Response --|> Reader -Request_Impl --|> Writer - -namespace client { - class Response { - async_read_some(buffer) - } - class Request { - async_get_response() - async_write(buffer) - async_write_eof(buffer) - } - class Client { - async_connect() - } - - class Request_Impl { - - } -} - -namespace impl { - class Reader { - get_executor() - content_length() - async_read_some(buffer) - detach() - destroy() - } - class Writer { - get_executor() - content_length(optional) - async_write(buffer, eof) - detach() - destroy() - } - class Client { - get_executor() - } -} -``` - ## Concurrent Requests diff --git a/docs/overview.drawio.svg b/docs/overview.drawio.svg new file mode 100644 index 0000000..fe6685f --- /dev/null +++ b/docs/overview.drawio.svg @@ -0,0 +1,830 @@ + + + + + + + + + + +
+
+
+ anyhttp::client +
+
+
+
+ + anyhttp::client + +
+
+
+ + + + + + + + + +
+
+
+ Client +
+
+
+
+ + Client + +
+
+
+ + + + + + + +
+
+
+ + executor: any_io_executor +
+
+
+
+ + + executor: any_io_executor + +
+
+
+ + + + + + + + + + +
+
+
+ + async_connect(): Session +
+
+
+
+ + + async_connect(): Session + +
+
+
+ + + + + + + + + +
+
+
+ Request +
+
+
+
+ + Request + +
+
+
+ + + + + + + +
+
+
+ + fields {read-only} +
+
+
+
+ + + fields {read-only} + +
+
+
+ + + + + + + + + + +
+
+
+ + method(): http::verb +
+ + async_get_response(): Response +
+
+
+
+
+ + + method(): http::verb... + +
+
+
+ + + + + + + + + +
+
+
+ Response +
+
+
+
+ + Response + +
+
+
+ + + + + + + +
+
+
+ + fields +
+
+
+
+ + + fields + +
+
+
+ + + + + + + + + + +
+
+
+ + method(type): type +
+
+
+
+ + + method(type): type + +
+
+
+ + + + + + + + +
+
+
+ gets +
+
+
+
+ + gets + +
+
+
+ + + + + + + +
+
+
+ anyhttp +
+
+
+
+ + anyhttp + +
+
+
+ + + + + + + + + +
+
+
+ Writer +
+
+
+
+ + Writer + +
+
+
+ + + + + + + +
+
+
+ + + executor: any_io_executor + +
+ + + + content_length: optional<size_t> + + +
+
+
+
+
+ + + executor: any_io_executor... + +
+
+
+ + + + + + + + + + +
+
+
+ + async_write(buffer): size_t +
+ + async_write_eof(buffer):  size_t +
+
+ + async_write_eof(): void +
+
+
+
+
+ + + async_write(buffer): size_t... + +
+
+
+ + + + + + + + + +
+
+
+ Session +
+
+
+
+ + Session + +
+
+
+ + + + + + + +
+
+
+ + executor +
+
+
+
+ + + executor + +
+
+
+ + + + + + + + + + +
+
+
+ + async_submit(url, headers): client::Request +
+
+
+
+ + + async_submit(url, headers): client::Request + +
+
+
+ + + + + + + + + +
+
+
+ Reader +
+
+
+
+ + Reader + +
+
+
+ + + + + + + +
+
+
+ + executor: any_io_executor +
+ + content_length: optional<size_t> {read-only} +
+
+
+
+
+ + + executor: any_io_executor... + +
+
+
+ + + + + + + + + + +
+
+
+ + reset() +
+
+ + content_length(): size_t +
+
+ + async_read_some(buffer): size_t +
+
+
+
+
+
+ + + reset()... + +
+
+
+ + + + + + + +
+
+
+ anyhttp::server +
+
+
+
+ + anyhttp::server + +
+
+
+ + + + + + + + + +
+
+
+ Request +
+
+
+
+ + Request + +
+
+
+ + + + + + + +
+
+
+ + fields { + + read-only} + +
+
+
+
+ + + fields {read-only} + +
+
+
+ + + + + + + + + + +
+
+
+ + method(): http::verb +
+
+
+
+ + + method(): http::verb + +
+
+
+ + + + + + + + + +
+
+
+ Response +
+
+
+
+ + Response + +
+
+
+ + + + + + + +
+
+
+ + fields +
+ + status_code +
+
+
+
+
+ + + fields... + +
+
+
+ + + + + + + + + + +
+
+
+ + method(type): type +
+
+
+
+ + + method(type): type + +
+
+
+ + + + + + + + + +
+
+
+ Server +
+
+
+
+ + Server + +
+
+
+ + + + + + + +
+
+
+ + executor: any_io_executor +
+ + request_handlers: [0..n] +
+
+
+
+
+ + + executor: any_io_executor... + +
+
+
+ + + + + + + + + + +
+
+
+ + async_connect(): Session +
+
+
+
+ + + async_connect(): Session + +
+
+
+ + + + + + + + +
+
+
+ on request +
+
+
+
+ + on request + +
+
+
+ + + + + + + + +
+
+
+ on request +
+
+
+
+ + on request + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ submits +
+
+
+
+ + submits + +
+
+
+
+ + + + + Text is not SVG - cannot display + + + +
\ No newline at end of file From 6e67e69a9fa6941f2e04e4035bc1757e4d251692 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Mon, 21 Sep 2026 19:47:59 +0000 Subject: [PATCH 15/19] docs: drop the marp slide deck and a stray drawio scratch file Neither is referenced any more now that the overview diagram lives in docs/. Co-Authored-By: Claude Opus 5 --- marp.md | 138 ------------------------------------------------ test.svg.drawio | 13 ----- 2 files changed, 151 deletions(-) delete mode 100644 marp.md delete mode 100644 test.svg.drawio diff --git a/marp.md b/marp.md deleted file mode 100644 index 26db22c..0000000 --- a/marp.md +++ /dev/null @@ -1,138 +0,0 @@ ---- -marp: true -author: Peter Eisenlohr -theme: default -class: - - lead - - invert -paginate: true -transition: fade -# header: Network Programming with ASIO and C++20 Coroutines -# footer: Network Programming wth ASIO and C++20 Coroutines ---- - - -## Network Programming with ASIO and C++20 Coroutines -Peter Eisenlohr - ---- -# Overview -* Disclaimer -* Introduction to ASIO -* Synchronous Operations -* Asynchronous Operations -* Completion Tokens -* Coroutines -* Tasks -* Structured Concurrency - ---- -# Disclaimer -* This presentation is NOT about the C++20 Coroutines Language Feature -* But: To use coroutines with ASIO, you don't need to now all the gory details - - ---- - -# What is ASIO -* ASIO (Asynchronous Input/Output) is a cross-platform C++ library for network and low-level I/O programming. It provides a consistent asynchronous model using modern C++. ---- -* Key Features: - - Header-only or standalone (no Boost required) - - Supports synchronous and asynchronous operations - - Works with sockets, timers, serial ports, and more - - Integrates naturally with C++ coroutines (co_await) - -* Why Use It? - - Efficient event-driven I/O - - Scales well for high-performance servers and clients - - Clean abstraction over platform-specific APIs (epoll, IOCP, etc.) ---- - - -```c++ -void session(tcp::socket sock) -{ - std::array buffer; - for (;;) - { - boost::system::error_code error; - size_t length = sock.read_some(asio::buffer(data), error); - if (error == asio::error::eof) - break; - asio::write(sock, asio::buffer(data, length)); - } -} - -int main(int argc, char* argv[]) -{ - asio::io_context io_context; - tcp::acceptor acceptor(io_context, tcp::endpoint(tcp::v4(), port)); - session(acceptor.accept()); - return 0; -} -``` ---- - -```c++ -void session(tcp::socket sock) -{ - std::array buffer; - for (;;) - { - boost::system::error_code error; - size_t length = sock.read_some(asio::buffer(data), error); - if (error == asio::error::eof) - break; - asio::write(sock, asio::buffer(data, length)); - } -} - -int main(int argc, char* argv[]) -{ - asio::io_context io_context; - tcp::acceptor acceptor(io_context, tcp::endpoint(tcp::v4(), port)); - for (;;) - std::thread(session, acceptor.accept()).detach(); - return 0; -} -``` - ---- - -# Thoughts -* Using C++ coroutines could be simpler, but there is no library support -* ASIO is well-established, performant and part of boost -* Create better networking code today - ---- - -# Outlook -## std::execution -* P2300 std::execution -* far away from procedural programming style -* builds graphs of async operations at compile time -* dedicated value, error and cancellation channels -* gives the compiler more opportunities to optimize -* maybe there will be some S/R based networking in the future -* should interop with coroutines - ---- - -* [Collection of Coroutine](https://www.reddit.com/r/cpp/comments/1hqj6ve/feeing_hard_to_understand_coroutine_in_c20_and/) -* [CppCon 2018: G. Nishanov “Nano-coroutines to the Rescue! (Using Coroutines TS, of Course)”](https://www.youtube.com/watch?v=j9tlJAqMV7U) - - diff --git a/test.svg.drawio b/test.svg.drawio deleted file mode 100644 index df914b9..0000000 --- a/test.svg.drawio +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file From c27ebd8794003818e696133710ae9688a1fd2637 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Mon, 21 Sep 2026 19:47:59 +0000 Subject: [PATCH 16/19] chore: correct the LICENSE copyright holder The file still carried the Microsoft boilerplate from the template this repository started out as. Attribute it properly and drop the stray indentation. Co-Authored-By: Claude Opus 5 --- LICENSE | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/LICENSE b/LICENSE index 4b1ad51..2b961e1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ - MIT License +MIT License - Copyright (c) Microsoft Corporation. All rights reserved. +Copyright (c) 2026 Peter Eisenlohr - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE From 1feed59f5634ee978e401f37737305fb239a7ff7 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Mon, 21 Sep 2026 20:03:09 +0000 Subject: [PATCH 17/19] refactor: move Reader::Impl and Writer::Impl into their own headers The backend interfaces lived inside reader.hpp and writer.hpp, so every user of the public handles -- client.hpp and server.hpp, and with them anything that merely wants to read or write a body -- pulled in the full virtual interface a protocol backend has to implement. Split them out into reader_impl.hpp and writer_impl.hpp, matching the client_impl.hpp / server_impl.hpp / session_impl.hpp convention, and leave a forward declaration behind. The handles need no more than that: their destructor, move constructor and move assignment were already out-of-line, which is what keeps shared_ptr happy against an incomplete type. Only code that implements or narrows to an Impl now includes the new headers: client_impl.hpp and server_impl.hpp derive from them, and h2_stream.hpp and h3_stream.hpp keep Reader::Impl* / Writer::Impl* back pointers. Co-Authored-By: Claude Opus 5 --- include/anyhttp/client_impl.hpp | 4 +-- include/anyhttp/h1_session.hpp | 2 +- include/anyhttp/h2_stream.hpp | 4 +-- include/anyhttp/h3_stream.hpp | 4 +-- include/anyhttp/reader.hpp | 32 ++------------------ include/anyhttp/reader_impl.hpp | 49 +++++++++++++++++++++++++++++++ include/anyhttp/server_impl.hpp | 4 +-- include/anyhttp/session_impl.hpp | 2 ++ include/anyhttp/writer.hpp | 35 ++-------------------- include/anyhttp/writer_impl.hpp | 50 ++++++++++++++++++++++++++++++++ src/reader.cpp | 2 +- src/writer.cpp | 2 +- 12 files changed, 118 insertions(+), 72 deletions(-) create mode 100644 include/anyhttp/reader_impl.hpp create mode 100644 include/anyhttp/writer_impl.hpp diff --git a/include/anyhttp/client_impl.hpp b/include/anyhttp/client_impl.hpp index b360f70..6d7b0ff 100644 --- a/include/anyhttp/client_impl.hpp +++ b/include/anyhttp/client_impl.hpp @@ -1,7 +1,7 @@ #pragma once #include "client.hpp" -#include "reader.hpp" -#include "writer.hpp" +#include "reader_impl.hpp" +#include "writer_impl.hpp" #include #include diff --git a/include/anyhttp/h1_session.hpp b/include/anyhttp/h1_session.hpp index 527c8e1..257ba30 100644 --- a/include/anyhttp/h1_session.hpp +++ b/include/anyhttp/h1_session.hpp @@ -7,8 +7,8 @@ #include "session_impl.hpp" #include - #include + #include #include #include diff --git a/include/anyhttp/h2_stream.hpp b/include/anyhttp/h2_stream.hpp index 8a4134e..7392d7a 100644 --- a/include/anyhttp/h2_stream.hpp +++ b/include/anyhttp/h2_stream.hpp @@ -2,8 +2,8 @@ #include "client.hpp" #include "common.hpp" -#include "reader.hpp" -#include "writer.hpp" +#include "reader_impl.hpp" +#include "writer_impl.hpp" #include "nghttp2/nghttp2.h" diff --git a/include/anyhttp/h3_stream.hpp b/include/anyhttp/h3_stream.hpp index 173fb90..24f8e23 100644 --- a/include/anyhttp/h3_stream.hpp +++ b/include/anyhttp/h3_stream.hpp @@ -1,8 +1,8 @@ #pragma once #include "anyhttp/common.hpp" -#include "anyhttp/reader.hpp" -#include "anyhttp/writer.hpp" +#include "anyhttp/reader_impl.hpp" +#include "anyhttp/writer_impl.hpp" #include #include diff --git a/include/anyhttp/reader.hpp b/include/anyhttp/reader.hpp index 54e0a6f..edeb9bf 100644 --- a/include/anyhttp/reader.hpp +++ b/include/anyhttp/reader.hpp @@ -30,35 +30,9 @@ namespace anyhttp class Reader { public: - /** - * What a protocol backend implements to be read from, and what the handles of both sides - * narrow to their own: \c server::Request::Impl and \c client::Response::Impl derive from this - * and add whatever else their side of the message has to offer. - */ - class Impl : public std::enable_shared_from_this - { - public: - virtual ~Impl() = default; - virtual asio::any_io_executor get_executor() const noexcept = 0; - virtual std::optional content_length() const noexcept = 0; - - // - // Reads at most one buffer worth of the incoming body. The end of the body is reported the - // way ASIO reports it everywhere else: \c asio::error::eof with zero bytes, and again for - // every further read -- including reads issued after the underlying stream object is long - // gone. A body that ends before it was supposed to -- a reset stream, a connection that went - // away mid-message -- is reported as \c http::error::partial_message instead, so the two - // cases stay distinguishable. - // - // An empty buffer is not a request to do anything; it completes immediately with success and - // zero bytes, wherever the body stands. - // - virtual void async_read_some(asio::mutable_buffer buffer, ReadSomeHandler&& handler) = 0; - virtual void detach() = 0; - virtual void destroy() {}; - }; - - // ---------------------------------------------------------------------------------------------- + /// The implementation, defined in \c reader_impl.hpp: what a protocol backend implements to + /// be read from. Only code that implements or narrows one needs to see it. + class Impl; using executor_type = asio::any_io_executor; diff --git a/include/anyhttp/reader_impl.hpp b/include/anyhttp/reader_impl.hpp new file mode 100644 index 0000000..d6d6db4 --- /dev/null +++ b/include/anyhttp/reader_impl.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include "common.hpp" +#include "reader.hpp" + +#include +#include + +#include +#include + +namespace anyhttp +{ + +// ================================================================================================= + +/** + * What a protocol backend implements to be read from, and what the handles of both sides narrow + * to their own: \c server::Request::Impl and \c client::Response::Impl derive from this and add + * whatever else their side of the message has to offer. + */ +class Reader::Impl : public std::enable_shared_from_this +{ +public: + virtual ~Impl() = default; + virtual asio::any_io_executor get_executor() const noexcept = 0; + virtual std::optional content_length() const noexcept = 0; + + // + // Reads at most one buffer worth of the incoming body. The end of the body is reported the + // way ASIO reports it everywhere else: \c asio::error::eof with zero bytes, and again for + // every further read -- including reads issued after the underlying stream object is long + // gone. A body that ends before it was supposed to -- a reset stream, a connection that went + // away mid-message -- is reported as \c http::error::partial_message instead, so the two + // cases stay distinguishable. + // + // An empty buffer is not a request to do anything; it completes immediately with success and + // zero bytes, wherever the body stands. + // + virtual void async_read_some(asio::mutable_buffer buffer, ReadSomeHandler&& handler) = 0; + virtual void detach() = 0; + + /// Called by the implementation from its destructor. + virtual void destroy() {}; +}; + +// ================================================================================================= + +} // namespace anyhttp diff --git a/include/anyhttp/server_impl.hpp b/include/anyhttp/server_impl.hpp index bd92c7d..c0cbfdd 100644 --- a/include/anyhttp/server_impl.hpp +++ b/include/anyhttp/server_impl.hpp @@ -1,8 +1,8 @@ #pragma once -#include "reader.hpp" +#include "reader_impl.hpp" #include "server.hpp" #include "session.hpp" -#include "writer.hpp" +#include "writer_impl.hpp" #include #include diff --git a/include/anyhttp/session_impl.hpp b/include/anyhttp/session_impl.hpp index 19df34c..a9047e0 100644 --- a/include/anyhttp/session_impl.hpp +++ b/include/anyhttp/session_impl.hpp @@ -4,7 +4,9 @@ #include #include + #include + #include namespace anyhttp diff --git a/include/anyhttp/writer.hpp b/include/anyhttp/writer.hpp index 772a742..c0eca0d 100644 --- a/include/anyhttp/writer.hpp +++ b/include/anyhttp/writer.hpp @@ -28,38 +28,9 @@ namespace anyhttp class Writer { public: - /** - * What a protocol backend implements to be written to, and what the handles of both sides - * narrow to their own: \c server::Response::Impl and \c client::Request::Impl derive from this - * and add whatever else their side of the message has to offer. - */ - class Impl : public std::enable_shared_from_this - { - public: - virtual ~Impl() = default; - virtual asio::any_io_executor get_executor() const noexcept = 0; - virtual void content_length(std::optional content_length) = 0; - - // - // Writes \p buffer and, if \p eof is set, ends the outgoing body after it. The two travel - // together on purpose: every backend can put the last bytes of a body and the flag that ends - // it into the same protocol element -- one DATA frame with END_STREAM (HTTP/2), one QUIC - // STREAM frame with FIN (HTTP/3), one last chunk (HTTP/1.1) -- so a message that ends with - // data needs no second, empty write to close it out. - // - // Every implementation answers the same entry ladder, in this order: an empty buffer with - // \p eof clear writes nothing at all and completes immediately with success, wherever the - // body stands -- it is not, as it once was, how a body is ended. Once the body has been - // ended, writing data -- through either entry point -- completes with \c errc::broken_pipe, - // while re-ending it with no data attached is an idempotent no-op. Only then do stream-level - // failures (closed, cancelled) get their say. - // - virtual void async_write(WriteHandler&& handler, asio::const_buffer buffer, bool eof) = 0; - virtual void detach() = 0; - virtual void destroy() {}; - }; - - // ---------------------------------------------------------------------------------------------- + /// The implementation, defined in \c writer_impl.hpp: what a protocol backend implements to + /// be written to. Only code that implements or narrows one needs to see it. + class Impl; using executor_type = asio::any_io_executor; diff --git a/include/anyhttp/writer_impl.hpp b/include/anyhttp/writer_impl.hpp new file mode 100644 index 0000000..733ec0d --- /dev/null +++ b/include/anyhttp/writer_impl.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include "common.hpp" +#include "writer.hpp" + +#include +#include + +#include +#include + +namespace anyhttp +{ + +// ================================================================================================= + +/** + * What a protocol backend implements to be written to, and what the handles of both sides narrow + * to their own: \c server::Response::Impl and \c client::Request::Impl derive from this and add + * whatever else their side of the message has to offer. + */ +class Writer::Impl : public std::enable_shared_from_this +{ +public: + virtual ~Impl() = default; + virtual asio::any_io_executor get_executor() const noexcept = 0; + virtual void content_length(std::optional content_length) = 0; + + // + // Writes \p buffer and, if \p eof is set, ends the outgoing body after it. The two travel + // together on purpose: every backend can put the last bytes of a body and the flag that ends + // it into the same protocol element -- one DATA frame with END_STREAM (HTTP/2), one QUIC + // STREAM frame with FIN (HTTP/3), one last chunk (HTTP/1.1) -- so a message that ends with + // data needs no second, empty write to close it out. + // + // Every implementation answers the same entry ladder, in this order: an empty buffer with + // \p eof clear writes nothing at all and completes immediately with success, wherever the + // body stands -- it is not, as it once was, how a body is ended. Once the body has been + // ended, writing data -- through either entry point -- completes with \c errc::broken_pipe, + // while re-ending it with no data attached is an idempotent no-op. Only then do stream-level + // failures (closed, cancelled) get their say. + // + virtual void async_write(WriteHandler&& handler, asio::const_buffer buffer, bool eof) = 0; + virtual void detach() = 0; + virtual void destroy() {}; +}; + +// ================================================================================================= + +} // namespace anyhttp diff --git a/src/reader.cpp b/src/reader.cpp index ef357c7..c645a6d 100644 --- a/src/reader.cpp +++ b/src/reader.cpp @@ -1,4 +1,4 @@ -#include "anyhttp/reader.hpp" +#include "anyhttp/reader_impl.hpp" #include #include diff --git a/src/writer.cpp b/src/writer.cpp index 582a1f3..ae592ca 100644 --- a/src/writer.cpp +++ b/src/writer.cpp @@ -1,4 +1,4 @@ -#include "anyhttp/writer.hpp" +#include "anyhttp/writer_impl.hpp" #include #include From 5e3573218eb0e9f17b965bd66dcbef590ea709db Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Mon, 21 Sep 2026 20:18:49 +0000 Subject: [PATCH 18/19] refactor: replace the boost/asio.hpp umbrella with granular includes Seven headers pulled in all of Boost.Asio -- client_impl.hpp, server_impl.hpp, h1_session.hpp, h2_session.hpp, h2_stream.hpp and the two detection helpers under detail/ -- and with them every backend translation unit that includes any of those. Replace each with the handful of headers the file actually needs, and do the same for the sources and the test fixture header. The global "using namespace boost::asio;" in h1_session.hpp and h2_session.hpp is what let this go unnoticed: names like co_spawn, detached, make_strand and ip::v6_only were reaching their users through the umbrella, so those users now include what they use. sender.cpp under src/research is left alone; no target builds it. This trims about 9% off the preprocessed size of a typical backend translation unit (~30k lines of roughly 330k). It does not show up in clean-rebuild wall time, which on this machine stays at 30s either way -- parsing declarations is not what the build spends its time on. Co-Authored-By: Claude Opus 5 --- include/anyhttp/client_impl.hpp | 4 +++- include/anyhttp/detail/detect_h2.hpp | 11 ++++++++++- include/anyhttp/detail/detect_ssl.hpp | 7 ++++++- include/anyhttp/h1_session.hpp | 2 +- include/anyhttp/h2_session.hpp | 2 +- include/anyhttp/h2_stream.hpp | 2 +- include/anyhttp/server_impl.hpp | 4 +++- src/client_impl.cpp | 2 +- src/h2_stream.cpp | 2 ++ src/h3_client.cpp | 1 - src/h3_server.cpp | 5 ++++- src/server_impl.cpp | 3 ++- test/test_fixtures.hpp | 8 +++++++- 13 files changed, 41 insertions(+), 12 deletions(-) diff --git a/include/anyhttp/client_impl.hpp b/include/anyhttp/client_impl.hpp index 6d7b0ff..8cb7098 100644 --- a/include/anyhttp/client_impl.hpp +++ b/include/anyhttp/client_impl.hpp @@ -3,8 +3,10 @@ #include "reader_impl.hpp" #include "writer_impl.hpp" -#include #include +#include +#include +#include #include #include diff --git a/include/anyhttp/detail/detect_h2.hpp b/include/anyhttp/detail/detect_h2.hpp index 5766d08..26eb381 100644 --- a/include/anyhttp/detail/detect_h2.hpp +++ b/include/anyhttp/detail/detect_h2.hpp @@ -1,11 +1,20 @@ #pragma once -#include +#include +#include +#include +#include +#include +#include #include #include #include +#include +#include +#include + namespace anyhttp::server { namespace asio = boost::asio; diff --git a/include/anyhttp/detail/detect_ssl.hpp b/include/anyhttp/detail/detect_ssl.hpp index 2e9d951..5dc32ce 100644 --- a/include/anyhttp/detail/detect_ssl.hpp +++ b/include/anyhttp/detail/detect_ssl.hpp @@ -1,5 +1,10 @@ #pragma once -#include +#include +#include +#include +#include +#include +#include #include #include diff --git a/include/anyhttp/h1_session.hpp b/include/anyhttp/h1_session.hpp index 257ba30..a5dff99 100644 --- a/include/anyhttp/h1_session.hpp +++ b/include/anyhttp/h1_session.hpp @@ -6,7 +6,7 @@ #include "server_impl.hpp" #include "session_impl.hpp" -#include +#include #include #include diff --git a/include/anyhttp/h2_session.hpp b/include/anyhttp/h2_session.hpp index c8ada9d..f431fea 100644 --- a/include/anyhttp/h2_session.hpp +++ b/include/anyhttp/h2_session.hpp @@ -7,7 +7,7 @@ #include "server_impl.hpp" #include "session_impl.hpp" -#include +#include #include #include #include diff --git a/include/anyhttp/h2_stream.hpp b/include/anyhttp/h2_stream.hpp index 7392d7a..20355e3 100644 --- a/include/anyhttp/h2_stream.hpp +++ b/include/anyhttp/h2_stream.hpp @@ -7,7 +7,7 @@ #include "nghttp2/nghttp2.h" -#include +#include #include #include #include diff --git a/include/anyhttp/server_impl.hpp b/include/anyhttp/server_impl.hpp index c0cbfdd..ba668a2 100644 --- a/include/anyhttp/server_impl.hpp +++ b/include/anyhttp/server_impl.hpp @@ -4,8 +4,10 @@ #include "session.hpp" #include "writer_impl.hpp" -#include #include +#include +#include +#include #include #include diff --git a/src/client_impl.cpp b/src/client_impl.cpp index cf9aaeb..e86fb94 100644 --- a/src/client_impl.cpp +++ b/src/client_impl.cpp @@ -6,7 +6,7 @@ #include "anyhttp/h2_backend.hpp" #include "anyhttp/h3_backend.hpp" -#include +#include #include #include #include diff --git a/src/h2_stream.cpp b/src/h2_stream.cpp index 5fb02f8..5a8daff 100644 --- a/src/h2_stream.cpp +++ b/src/h2_stream.cpp @@ -13,6 +13,8 @@ #include #include #include +#include +#include #include #include #include diff --git a/src/h3_client.cpp b/src/h3_client.cpp index 1a992ce..239a187 100644 --- a/src/h3_client.cpp +++ b/src/h3_client.cpp @@ -24,7 +24,6 @@ #include "anyhttp/literals.hpp" #include "anyhttp/session_impl.hpp" -#include #include #include #include diff --git a/src/h3_server.cpp b/src/h3_server.cpp index 80ec3a2..99eeabb 100644 --- a/src/h3_server.cpp +++ b/src/h3_server.cpp @@ -38,11 +38,14 @@ #include "anyhttp/server_impl.hpp" #include "anyhttp/session_impl.hpp" -#include #include +#include +#include #include +#include #include #include +#include #include #include diff --git a/src/server_impl.cpp b/src/server_impl.cpp index f60b484..8c7d7d7 100644 --- a/src/server_impl.cpp +++ b/src/server_impl.cpp @@ -9,7 +9,6 @@ #include "anyhttp/h3_backend.hpp" #include "anyhttp/tls.hpp" -#include #include #include #include @@ -17,6 +16,8 @@ #include #include #include +#include +#include #include #include diff --git a/test/test_fixtures.hpp b/test/test_fixtures.hpp index 7e69896..b43a494 100644 --- a/test/test_fixtures.hpp +++ b/test/test_fixtures.hpp @@ -10,21 +10,27 @@ #include "anyhttp/session.hpp" #include "anyhttp/utils.hpp" -#include #include #include #include #include +#include #include +#include +#include #include #include #include #include #include #include +#include +#include +#include #include #include #include +#include #include #include From 78b5c39f48c1b549bce39969d44d50f76984cb8b Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Mon, 21 Sep 2026 20:28:58 +0000 Subject: [PATCH 19/19] refactor: drop the generic using-directives from headers A header that says "using namespace boost::asio" at file scope hands every translation unit that includes it the whole of Asio in its global namespace. h1_session.hpp, h2_session.hpp and h2_session_details.hpp did exactly that -- the last one adding boost::beast on top -- which is what had been quietly supplying names to their users all along, and what hid the missing includes fixed in the previous commit. Qualify with the asio:: alias instead, and let the two sources that were leaning on the headers (h2_session.cpp, h2_stream.cpp) say what they mean: h2_session.cpp gets the errc and http aliases h1_session.cpp already had. The awaitable operators are now pulled in per function, where the operators are actually used. server.hpp is a public header, so its chrono_literals directive leaked into every consumer for the sake of two defaults; spell those out as std::chrono::hours{24} and std::chrono::seconds{30}. The same directive in request_handlers.hpp and h2_session.hpp was dead -- neither file uses a chrono literal. test_fixtures.hpp keeps its "using namespace asio". It is test-only, it reaches no further than the test binary, and every test leans on the unqualified names; naming them one by one there buys nothing. Likewise the function-local directives: they are scoped, which was never the problem. Also drop src/research/sender.cpp, which no target built. Co-Authored-By: Claude Opus 5 --- include/anyhttp/detail/h2_session_details.hpp | 18 +++-- include/anyhttp/h1_session.hpp | 8 +-- include/anyhttp/h2_session.hpp | 16 ++--- include/anyhttp/request_handlers.hpp | 2 - include/anyhttp/server.hpp | 6 +- src/h2_session.cpp | 5 +- src/h2_stream.cpp | 10 +-- src/research/sender.cpp | 67 ------------------- 8 files changed, 28 insertions(+), 104 deletions(-) delete mode 100644 src/research/sender.cpp diff --git a/include/anyhttp/detail/h2_session_details.hpp b/include/anyhttp/detail/h2_session_details.hpp index b3dcd68..068e49c 100644 --- a/include/anyhttp/detail/h2_session_details.hpp +++ b/include/anyhttp/detail/h2_session_details.hpp @@ -22,15 +22,11 @@ #include -using namespace boost::asio::experimental::awaitable_operators; - namespace anyhttp::nghttp2 { // ================================================================================================= -using namespace boost::asio; -using namespace boost::beast; using socket = asio::ip::tcp::socket; // ================================================================================================= @@ -40,7 +36,7 @@ void NGHttp2SessionImpl::destroy() noexcept { // post(get_executor(), [this, self]() mutable { boost::system::error_code ec; - get_socket(m_stream).shutdown(socket_base::shutdown_both, ec); + get_socket(m_stream).shutdown(asio::socket_base::shutdown_both, ec); logwi(ec, "[{}] destroy: socket shutdown: {}", m_logPrefix, ec.message()); // }); } @@ -107,7 +103,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); + auto [ec, written] = co_await asio::async_write(m_stream, seq, asio::as_tuple); if (ec) { mloge("send loop: error writing {} bytes: {}", bytes_to_write, ec.message()); @@ -133,7 +129,7 @@ awaitable NGHttp2SessionImpl::send_loop() break; // nghttp2 doesn't want to send or receive any more, so we are done mylogd("send loop: waiting..."); - co_await async_wait_send(deferred); + co_await async_wait_send(asio::deferred); mylogd("send loop: waiting... done"); } } @@ -160,7 +156,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); + auto [ec, n] = co_await m_stream.async_read_some(m_buffer.prepare(free), asio::as_tuple); if (ec) { mylogd("read: {}, terminating session", ec.message()); @@ -182,7 +178,7 @@ awaitable NGHttp2SessionImpl::recv_loop() // ================================================================================================= template -ServerSession::ServerSession(server::Server::Impl& parent, any_io_executor executor, +ServerSession::ServerSession(server::Server::Impl& parent, asio::any_io_executor executor, Stream&& stream) : ServerReference(parent), super("\x1b[1;31mserver\x1b[0m", executor, std::move(stream)) { @@ -267,6 +263,7 @@ awaitable ServerSession::do_session(Buffer&& buffer) // // send/receive loop // + using namespace asio::experimental::awaitable_operators; co_await (send_loop() && recv_loop()); mlogd("server session done"); @@ -286,7 +283,7 @@ awaitable ServerSession::do_session(Buffer&& buffer) // ================================================================================================= template -ClientSession::ClientSession(client::Client::Impl& parent, any_io_executor executor, +ClientSession::ClientSession(client::Client::Impl& parent, asio::any_io_executor executor, Stream&& stream) : ClientReference(parent), super("\x1b[1;32mclient\x1b[0m", executor, std::move(stream)) { @@ -347,6 +344,7 @@ awaitable ClientSession::do_session(Buffer&& buffer) // // send/receive loop // + using namespace asio::experimental::awaitable_operators; co_await (send_loop() && recv_loop()); mlogd("client session done"); diff --git a/include/anyhttp/h1_session.hpp b/include/anyhttp/h1_session.hpp index a5dff99..375a7b3 100644 --- a/include/anyhttp/h1_session.hpp +++ b/include/anyhttp/h1_session.hpp @@ -17,8 +17,6 @@ #include #include -using namespace boost::asio; - namespace anyhttp::beast_impl { @@ -28,7 +26,7 @@ template class BeastSession : public ::anyhttp::Session::Impl { protected: - BeastSession(std::string_view logPrefix, any_io_executor executor, Stream&& stream); + BeastSession(std::string_view logPrefix, asio::any_io_executor executor, Stream&& stream); public: ~BeastSession() override; @@ -116,7 +114,7 @@ class ServerSession : public ServerSessionBase, public BeastSession using super::m_stream; public: - ServerSession(server::Server::Impl& parent, any_io_executor executor, Stream&& stream); + ServerSession(server::Server::Impl& parent, asio::any_io_executor executor, Stream&& stream); void destroy() noexcept override; void async_submit(SubmitHandler&& handler, std::string_view method, boost::urls::url url, @@ -193,7 +191,7 @@ class ClientSession : public ClientSessionBase, public BeastSession using super::m_stream; public: - ClientSession(client::Client::Impl& parent, any_io_executor executor, Stream&& stream); + ClientSession(client::Client::Impl& parent, asio::any_io_executor executor, Stream&& stream); void async_submit(SubmitHandler&& handler, std::string_view method, boost::urls::url url, const Fields& headers) override; diff --git a/include/anyhttp/h2_session.hpp b/include/anyhttp/h2_session.hpp index f431fea..3d807b1 100644 --- a/include/anyhttp/h2_session.hpp +++ b/include/anyhttp/h2_session.hpp @@ -17,10 +17,6 @@ #include "nghttp2/nghttp2.h" -using namespace std::chrono_literals; - -using namespace boost::asio; - namespace anyhttp::nghttp2 { @@ -49,10 +45,10 @@ class NGHttp2Stream; class NGHttp2Session : public anyhttp::Session::Impl { public: - NGHttp2Session(std::string_view prefix, any_io_executor executor); + NGHttp2Session(std::string_view prefix, asio::any_io_executor executor); virtual ~NGHttp2Session(); - boost::asio::any_io_executor get_executor() const noexcept override { return m_executor; } + asio::any_io_executor get_executor() const noexcept override { return m_executor; } const std::string& logPrefix() const { return m_logPrefix; } std::string logPrefix(int stream_id) const @@ -124,7 +120,7 @@ class NGHttp2Session : public anyhttp::Session::Impl public: std::string m_logPrefix; - boost::asio::any_io_executor m_executor; + asio::any_io_executor m_executor; nghttp2_session* session = nullptr; std::map> m_streams; @@ -149,7 +145,7 @@ template class NGHttp2SessionImpl : public NGHttp2Session { protected: - NGHttp2SessionImpl(std::string_view logPrefix, any_io_executor executor, Stream&& stream) + NGHttp2SessionImpl(std::string_view logPrefix, asio::any_io_executor executor, Stream&& stream) : NGHttp2Session(logPrefix, executor), m_stream(std::move(stream)) { } @@ -199,7 +195,7 @@ class ServerSession : public ServerReference, public NGHttp2SessionImpl using super::session; public: - ServerSession(server::Server::Impl& parent, any_io_executor executor, Stream&& stream); + ServerSession(server::Server::Impl& parent, asio::any_io_executor executor, Stream&& stream); awaitable do_session(Buffer&& data) override; @@ -242,7 +238,7 @@ class ClientSession : public ClientReference, public NGHttp2SessionImpl using super::session; public: - ClientSession(client::Client::Impl& parent, any_io_executor executor, Stream&& stream); + ClientSession(client::Client::Impl& parent, asio::any_io_executor executor, Stream&& stream); awaitable do_session(Buffer&& data) override; diff --git a/include/anyhttp/request_handlers.hpp b/include/anyhttp/request_handlers.hpp index 81eddb4..043a95c 100644 --- a/include/anyhttp/request_handlers.hpp +++ b/include/anyhttp/request_handlers.hpp @@ -21,8 +21,6 @@ #include -using namespace std::chrono_literals; - namespace anyhttp { template diff --git a/include/anyhttp/server.hpp b/include/anyhttp/server.hpp index 61e7a52..37bcb46 100644 --- a/include/anyhttp/server.hpp +++ b/include/anyhttp/server.hpp @@ -18,8 +18,6 @@ #include #include -using namespace std::chrono_literals; - namespace anyhttp::server { @@ -56,7 +54,7 @@ struct Config // Advertising over cleartext HTTP is of no use to browsers and curl, which honour "Alt-Svc" // for https:// origins only -- the alternative has to be at least as secure as the origin. // - std::chrono::seconds alt_svc_max_age = 24h; + std::chrono::seconds alt_svc_max_age = std::chrono::hours{24}; // // HTTP/3 only: how long a QUIC connection may go without a packet from its peer before it is @@ -64,7 +62,7 @@ struct Config // client, a machine that went to sleep -- is ever noticed, so it also bounds how long its // session and streams stay around. 30s is what the ngtcp2 examples use. // - std::chrono::nanoseconds idle_timeout = 30s; + std::chrono::nanoseconds idle_timeout = std::chrono::seconds{30}; // // HTTP/3 only, testing aid: probability (0.0 ... 1.0) with which an individual QUIC datagram diff --git a/src/h2_session.cpp b/src/h2_session.cpp index 782a6a8..cc58062 100644 --- a/src/h2_session.cpp +++ b/src/h2_session.cpp @@ -35,6 +35,9 @@ using namespace boost::asio::experimental::awaitable_operators; +namespace errc = boost::system::errc; +namespace http = boost::beast::http; + // ================================================================================================= namespace anyhttp::nghttp2 @@ -432,7 +435,7 @@ nghttp2_unique_ptr NGHttp2Session::setup_callbacks() // ================================================================================================= -NGHttp2Session::NGHttp2Session(std::string_view prefix, any_io_executor executor) +NGHttp2Session::NGHttp2Session(std::string_view prefix, asio::any_io_executor executor) : m_executor(std::move(executor)), m_logPrefix(prefix) { mlogd("session created"); diff --git a/src/h2_stream.cpp b/src/h2_stream.cpp index 5a8daff..7851391 100644 --- a/src/h2_stream.cpp +++ b/src/h2_stream.cpp @@ -729,7 +729,7 @@ void NGHttp2Stream::async_get_response(client::Request::GetResponseHandler&& han { auto ec = asio::error::basic_errors::already_started; logw("[{}] async_get_response: \x1b[1;31m{}\x1b[0m", logPrefix, what(ec)); - any_completion_executor ex = get_associated_immediate_executor(handler, get_executor()); + asio::any_completion_executor ex = asio::get_associated_immediate_executor(handler, get_executor()); ex.execute([handler = std::move(handler), ec = std::move(ec)]() mutable { // std::move(handler)(ec, client::Response{nullptr}); }); @@ -902,14 +902,14 @@ void NGHttp2Stream::on_request() auto& server = dynamic_cast(parent).server(); if (header_limit_exceeded) - co_spawn(get_executor(), header_fields_too_large(std::move(request), std::move(response)), - detached); + asio::co_spawn(get_executor(), header_fields_too_large(std::move(request), std::move(response)), + asio::detached); else if (auto& handler = server.requestHandler()) - co_spawn(get_executor(), handler(std::move(request), std::move(response)), detached); + asio::co_spawn(get_executor(), handler(std::move(request), std::move(response)), asio::detached); else { loge("[{}] on_request: no request handler!", logPrefix); - co_spawn(get_executor(), not_found(std::move(response)), detached); + asio::co_spawn(get_executor(), not_found(std::move(response)), asio::detached); } } diff --git a/src/research/sender.cpp b/src/research/sender.cpp deleted file mode 100644 index ce31875..0000000 --- a/src/research/sender.cpp +++ /dev/null @@ -1,67 +0,0 @@ -#include -#include -#include -#include -#include - -namespace asio = boost::asio; -namespace ex = stdexec; - -class async_read_until_sender -{ -public: - async_read_until_sender(asio::ip::tcp::socket& socket, std::vector& buffer, char delimiter) - : socket_(socket), buffer_(buffer), delimiter_(delimiter) - { - } - - template - struct operation - { - asio::ip::tcp::socket& socket_; - std::vector& buffer_; - char delimiter_; - Receiver receiver_; - - void start() { do_read(); } - - private: - void do_read() - { - buffer_.resize(buffer_.size() + 512); - socket_.async_read_some( - asio::buffer(buffer_.data() + buffer_.size() - 512, 512), - [this](boost::system::error_code ec, std::size_t bytes_transferred) mutable { - if (ec) - { - ex::set_error(std::move(receiver_), ec); - return; - } - - buffer_.resize(buffer_.size() - 512 + bytes_transferred); - auto pos = std::find(buffer_.begin(), buffer_.end(), delimiter_); - if (pos != buffer_.end()) - { - std::size_t delimiter_pos = std::distance(buffer_.begin(), pos); - buffer_.resize(delimiter_pos + 1); - ex::set_value(std::move(receiver_), delimiter_pos + 1); - } - else - { - do_read(); - } - }); - } - }; - - template - operation connect(Receiver receiver) - { - return {socket_, buffer_, delimiter_, std::move(receiver)}; - } - -private: - asio::ip::tcp::socket& socket_; - std::vector& buffer_; - char delimiter_; -}; \ No newline at end of file