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/.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 + 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 diff --git a/README.md b/README.md index 352aeba..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 @@ -167,6 +125,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/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 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..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 @@ -32,6 +34,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; }; // ================================================================================================= @@ -47,7 +62,7 @@ using Message = boost::beast::http::response; // ------------------------------------------------------------------------------------------------- -class Response +class Response : public Reader { public: class Impl; @@ -58,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; @@ -116,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; @@ -140,63 +117,18 @@ 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); } -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); @@ -231,10 +163,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 37fe2d9..8cb7098 100644 --- a/include/anyhttp/client_impl.hpp +++ b/include/anyhttp/client_impl.hpp @@ -1,40 +1,49 @@ #pragma once #include "client.hpp" +#include "reader_impl.hpp" +#include "writer_impl.hpp" -#include #include +#include +#include +#include #include +#include +#include +#include +#include + namespace anyhttp::client { // ================================================================================================= -class Request::Impl : public impl::Writer +class Request::Impl : public Writer::Impl { public: 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; }; // ------------------------------------------------------------------------------------------------- -class Response::Impl : public impl::Reader +class Response::Impl : public Reader::Impl { public: 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; - - using ReaderOrWriter = impl::Reader; }; // ================================================================================================= @@ -50,6 +59,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 +95,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/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/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 36ed4b2..844ef11 100644 --- a/include/anyhttp/detail/any_async_stream.hpp +++ b/include/anyhttp/detail/any_async_stream.hpp @@ -95,44 +95,69 @@ 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) - { // - 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}); } // // 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) - { // - 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}); + } + + // + // 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/detect_h2.hpp b/include/anyhttp/detail/detect_h2.hpp index 3b5f517..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; @@ -74,8 +83,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..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 @@ -34,23 +39,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/detail/h2_session_details.hpp b/include/anyhttp/detail/h2_session_details.hpp index 93a255e..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,11 +178,12 @@ 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)) { m_max_header_size = parent.config().max_header_size; + m_alt_svc = parent.alt_svc(); } // ------------------------------------------------------------------------------------------------- @@ -266,10 +263,18 @@ 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"); + // + // 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"); @@ -278,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)) { @@ -305,6 +310,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"); @@ -332,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/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..375a7b3 100644 --- a/include/anyhttp/h1_session.hpp +++ b/include/anyhttp/h1_session.hpp @@ -6,9 +6,9 @@ #include "server_impl.hpp" #include "session_impl.hpp" -#include - +#include #include + #include #include #include @@ -17,8 +17,6 @@ #include #include -using namespace boost::asio; - namespace anyhttp::beast_impl { @@ -28,17 +26,17 @@ 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; 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; } // ---------------------------------------------------------------------------------------------- @@ -48,10 +46,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 +78,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; }; // ================================================================================================= @@ -108,15 +106,15 @@ 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); + 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_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 908c483..3d807b1 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 @@ -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 @@ -86,8 +82,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); }, @@ -111,6 +106,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); @@ -118,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; @@ -128,6 +130,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; }; @@ -137,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)) { } @@ -180,13 +188,14 @@ 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; 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; @@ -229,9 +238,11 @@ 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; + + void on_alt_svc(std::string_view field_value) override { client().on_alt_svc(field_value); } }; // ================================================================================================= diff --git a/include/anyhttp/h2_stream.hpp b/include/anyhttp/h2_stream.hpp index b589bcd..20355e3 100644 --- a/include/anyhttp/h2_stream.hpp +++ b/include/anyhttp/h2_stream.hpp @@ -2,15 +2,17 @@ #include "client.hpp" #include "common.hpp" +#include "reader_impl.hpp" +#include "writer_impl.hpp" #include "nghttp2/nghttp2.h" -#include +#include #include -#include #include #include #include +#include #include #include #include @@ -40,9 +42,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; NGHttp2Stream* stream; @@ -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; @@ -297,8 +297,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_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..24f8e23 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_impl.hpp" +#include "anyhttp/writer_impl.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; @@ -258,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); @@ -300,17 +293,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/reader.hpp b/include/anyhttp/reader.hpp new file mode 100644 index 0000000..edeb9bf --- /dev/null +++ b/include/anyhttp/reader.hpp @@ -0,0 +1,104 @@ +#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: + /// 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; + + 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/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/request_handlers.hpp b/include/anyhttp/request_handlers.hpp index f0de405..043a95c 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 @@ -21,8 +21,6 @@ #include -using namespace std::chrono_literals; - namespace anyhttp { template @@ -78,8 +76,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 +86,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; +awaitable drain(Reader& reader); - // 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> 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 +104,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 +116,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 +178,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 ab50e4d..37bcb46 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 @@ -16,8 +18,6 @@ #include #include -using namespace std::chrono_literals; - namespace anyhttp::server { @@ -43,13 +43,26 @@ 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 = std::chrono::hours{24}; + // // 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 // 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 @@ -72,7 +85,7 @@ struct Config // ================================================================================================= -class Request +class Request : public Reader { public: class Impl; @@ -82,13 +95,11 @@ 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: + /// The request method, as it arrived: "GET", "POST", ... + std::string_view method() const noexcept; 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; @@ -128,28 +139,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; }; // ------------------------------------------------------------------------------------------------- @@ -163,7 +157,7 @@ awaitable sleep(T duration) co_await timer.async_wait(); } -class Response +class Response : public Writer { public: class Impl; @@ -173,21 +167,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); @@ -195,56 +185,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 858da93..ba668a2 100644 --- a/include/anyhttp/server_impl.hpp +++ b/include/anyhttp/server_impl.hpp @@ -1,9 +1,13 @@ #pragma once +#include "reader_impl.hpp" #include "server.hpp" #include "session.hpp" +#include "writer_impl.hpp" -#include #include +#include +#include +#include #include #include @@ -19,23 +23,24 @@ namespace anyhttp::server // ================================================================================================= -class Request::Impl : public impl::Reader +class Request::Impl : public Reader::Impl { public: 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; - - using ReaderOrWriter = impl::Reader; }; // ------------------------------------------------------------------------------------------------- -class Response::Impl : public impl::Writer +class Response::Impl : public Writer::Impl { public: Impl() noexcept; @@ -43,8 +48,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; }; // ================================================================================================= @@ -74,6 +77,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 +111,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/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..a9047e0 100644 --- a/include/anyhttp/session_impl.hpp +++ b/include/anyhttp/session_impl.hpp @@ -4,7 +4,9 @@ #include #include + #include + #include namespace anyhttp @@ -19,8 +21,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/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/include/anyhttp/writer.hpp b/include/anyhttp/writer.hpp new file mode 100644 index 0000000..c0eca0d --- /dev/null +++ b/include/anyhttp/writer.hpp @@ -0,0 +1,124 @@ +#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: + /// 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; + + 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/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/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/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/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/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/client_impl.cpp b/src/client_impl.cpp index 63eb720..e86fb94 100644 --- a/src/client_impl.cpp +++ b/src/client_impl.cpp @@ -1,11 +1,12 @@ #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" #include "anyhttp/h2_backend.hpp" #include "anyhttp/h3_backend.hpp" -#include +#include #include #include #include @@ -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) @@ -71,17 +117,15 @@ 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)); }; - 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() @@ -92,6 +136,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 +204,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) // @@ -176,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 2fdf1a5..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 { @@ -193,8 +185,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(); @@ -238,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. @@ -389,77 +419,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. // - // TODO: We could try to support partial cancellation, but that would only work - // at chunk boundaries. + // 'need_buffer' means that the serializer is done consuming all of the given buffer + // and is ready to accept a new one. // - 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, @@ -732,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)); @@ -741,8 +769,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(); @@ -750,6 +777,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); @@ -860,8 +894,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; @@ -960,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()); @@ -1049,8 +1080,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; @@ -1066,6 +1097,23 @@ 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); + + // + // 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. // @@ -1123,9 +1171,23 @@ awaitable ServerSession::do_session(Buffer&& buffer) mlogi("closing stream, served {} requests", requestCounter); - // FIXME: close() before shutdown()?! - get_socket(m_stream).close(); + // + // 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. + // 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"); } @@ -1228,11 +1290,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))); } @@ -1315,8 +1376,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 70ca792..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 @@ -239,6 +242,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 +299,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) @@ -405,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"); @@ -482,8 +512,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); @@ -509,10 +538,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(); } @@ -681,13 +709,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 2ef0fdb..7851391 100644 --- a/src/h2_stream.cpp +++ b/src/h2_stream.cpp @@ -13,6 +13,8 @@ #include #include #include +#include +#include #include #include #include @@ -80,20 +82,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 { @@ -141,8 +129,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)); @@ -165,6 +152,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()) @@ -224,10 +248,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(':')) @@ -251,8 +284,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); @@ -651,8 +683,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(); @@ -698,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}); }); @@ -708,15 +739,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}); }); @@ -850,7 +879,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)); } } @@ -868,19 +897,19 @@ 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(); 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/h3_client.cpp b/src/h3_client.cpp index 1ed7fbb..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 @@ -152,8 +151,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(); @@ -248,6 +246,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) { @@ -311,8 +323,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 +371,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}); }); @@ -398,8 +408,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)); } @@ -704,8 +713,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..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 @@ -393,6 +396,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 +454,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(); @@ -556,8 +583,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 +853,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 +919,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 +936,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 +1102,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 +1159,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/reader.cpp b/src/reader.cpp new file mode 100644 index 0000000..c645a6d --- /dev/null +++ b/src/reader.cpp @@ -0,0 +1,49 @@ +#include "anyhttp/reader_impl.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..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()); @@ -159,18 +160,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 +211,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 +234,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 +260,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/research/sender.cpp b/src/research/sender.cpp deleted file mode 100644 index 8ea511e..0000000 --- a/src/research/sender.cpp +++ /dev/null @@ -1,59 +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 diff --git a/src/server.cpp b/src/server.cpp index 3684522..378dc21 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,15 @@ 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)); -} +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(); } // ================================================================================================= -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 +48,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 +59,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/server_impl.cpp b/src/server_impl.cpp index efe2d0d..8c7d7d7 100644 --- a/src/server_impl.cpp +++ b/src/server_impl.cpp @@ -9,13 +9,15 @@ #include "anyhttp/h3_backend.hpp" #include "anyhttp/tls.hpp" -#include #include #include #include +#include #include #include #include +#include +#include #include #include @@ -73,6 +75,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); + } } // ------------------------------------------------------------------------------------------------- @@ -85,13 +98,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(); @@ -295,9 +308,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)); } @@ -371,6 +389,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 (;;) { // @@ -406,19 +435,21 @@ 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; + std::ignore = sessionDone.try_send(boost::system::error_code{}); + if (ex) + logw("[{}] {}", ep, what(ex)); + else + logi("[{}] session finished, {} sessions left", ep, sessionCounter); + }); } // - // 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; @@ -432,7 +463,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/src/server_main.cpp b/src/server_main.cpp index 603897b..6020592 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,9 +76,11 @@ 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"; }); + config.verbose = std::ranges::count_if( + parsed.options, [](const po::option& option) { return option.string_key == "verbose"; }); } catch (const po::error& error) { @@ -127,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/src/writer.cpp b/src/writer.cpp new file mode 100644 index 0000000..ae592ca --- /dev/null +++ b/src/writer.cpp @@ -0,0 +1,50 @@ +#include "anyhttp/writer_impl.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 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 diff --git a/test/test_alt_svc.cpp b/test/test_alt_svc.cpp new file mode 100644 index 0000000..9ccf7d5 --- /dev/null +++ b/test/test_alt_svc.cpp @@ -0,0 +1,386 @@ +#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) +{ + 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)); + 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) +{ + 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()))); + }; +} + +TEST_P(AltSvcUpgrade, WHEN_the_server_clears_the_alternative_THEN_it_is_not_used) +{ + 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 { + 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) +{ + clientSession = [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) +{ + clientSession = [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_client_async.cpp b/test/test_client_async.cpp index 4e5c701..9a36143 100644 --- a/test/test_client_async.cpp +++ b/test/test_client_async.cpp @@ -2,6 +2,8 @@ #include +#include + #include #include #include @@ -22,8 +24,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; auto count = co_await (generate(request, bytes) && count_response(request)); @@ -33,8 +34,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); auto [ec, response] = co_await request.async_get_response(as_tuple); @@ -44,8 +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); auto response = co_await request.async_get_response(); @@ -56,8 +55,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); auto [ec, response] = co_await request.async_get_response(as_tuple); @@ -67,8 +65,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); auto [ec, response] = co_await request.async_get_response(as_tuple); @@ -78,8 +75,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"), {}); auto [ep] = co_await co_spawn(executor, send(request, rv::iota(uint8_t{0})), as_tuple); @@ -89,8 +85,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"); auto request = co_await session.async_submit(url.set_path("echo"), fields); @@ -100,8 +95,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); EXPECT_EQ(ec, boost::system::errc::success); @@ -116,8 +110,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,8 +138,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(); @@ -161,40 +153,54 @@ 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); - custom = [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); + co_spawn( + co_await this_coro::executor, + [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); + 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); + co_await responded->async_send(boost::system::error_code{}); + }, + detached); co_return; }; - test = [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(); }; } @@ -203,13 +209,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)); auto request2 = co_await session.async_submit(url.set_path("echo"), {}); @@ -227,8 +233,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)); auto request2 = co_await session.async_submit(url.set_path("echo"), {}); @@ -240,13 +245,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)); auto request2 = co_await session.async_submit(url.set_path("echo"), {}); @@ -270,17 +275,23 @@ 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; 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); }; } @@ -289,14 +300,12 @@ 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); EXPECT_EQ(ec, boost::beast::http::error::end_of_stream); @@ -309,8 +318,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; auto request = co_await session.async_submit(url.set_path("echo")); @@ -355,8 +363,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; co_await yield(dist(gen)); @@ -371,8 +378,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) { @@ -400,14 +406,12 @@ 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(); auto response = co_await request.async_get_response(); @@ -451,15 +455,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; - 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 co_await request.async_write_eof(); @@ -476,8 +478,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()}})); co_await response.async_write_eof(asio::buffer(hello)); @@ -494,8 +495,7 @@ 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 - { + clientSession = [this](Session session) -> awaitable { EXPECT_EQ((co_await session.async_get(url)).body(), hello); }; } @@ -503,13 +503,11 @@ TEST_P(ClientAsync, WHEN_written_after_eof_THEN_reports_broken_pipe) 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); EXPECT_EQ(message.body(), hello); @@ -526,23 +524,20 @@ 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; }(); - 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); 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 - { + clientSession = [this](Session session) -> awaitable { EXPECT_EQ((co_await session.async_get(url)).body().size(), body.size()); }; } @@ -557,8 +552,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, {}); @@ -566,12 +560,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(); auto response = co_await request.async_get_response(); @@ -599,16 +592,16 @@ 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; 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); @@ -628,8 +621,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); @@ -640,12 +632,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(); auto response = co_await request.async_get_response(); @@ -670,15 +661,13 @@ 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 - { + clientSession = [this](Session session) -> awaitable { EXPECT_EQ((co_await session.async_get(url)).result_int(), 200); }; } @@ -719,8 +708,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"), {}); auto response = co_await request.async_get_response(); @@ -744,8 +732,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; for (;;) @@ -759,8 +746,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; auto count = co_await (generate(request, bytes) && count_response(request)); @@ -770,13 +756,11 @@ 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"); auto request = co_await session.async_submit(url, fields); @@ -787,14 +771,12 @@ 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)); EXPECT_FALSE(res.has_value()); @@ -806,8 +788,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: auto response = co_await request.async_get_response(); @@ -823,8 +804,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)); auto received = co_await (std::move(sender) && count_response(request)); @@ -837,8 +817,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(); constexpr size_t bytes = 1024; @@ -857,8 +836,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)); @@ -885,8 +863,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; auto request1 = co_await session.async_submit(url.set_path("echo"), {}); @@ -913,8 +890,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) { @@ -939,8 +915,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)); auto request2 = co_await session.async_submit(url.set_path("echo"), {}); @@ -969,8 +944,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}})); auto request2 = co_await session.async_submit(url.set_path("echo"), {}); @@ -997,8 +971,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()}})); co_await request1.async_write(asio::buffer(body1)); @@ -1026,8 +999,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)); request1.reset(); @@ -1041,13 +1013,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)); auto request2 = co_await session.async_submit(url.set_path("echo"), {}); @@ -1063,8 +1035,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); auto response = co_await request.async_get_response(); @@ -1077,13 +1048,13 @@ 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"}}), {}); 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_client_async_cancellation.cpp b/test/test_client_async_cancellation.cpp index 99c5d1c..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) { - 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(); auto sender = send(request, rv::iota(uint8_t(0))); @@ -66,8 +65,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); for (size_t i = 0; i <= 20; ++i) @@ -113,8 +111,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'); for (size_t i = 0; i <= 20; ++i) @@ -156,8 +153,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) { co_await yield(); @@ -177,8 +173,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,8 +192,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"}}), {}); 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) { - test = [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) { - 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,8 +247,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,21 +289,21 @@ 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(); 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 new file mode 100644 index 0000000..df6c2e0 --- /dev/null +++ b/test/test_connection_close.cpp @@ -0,0 +1,247 @@ +#include "test_fixtures.hpp" + +#include +#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. + template + awaitable exchange(Stream& 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. + template + awaitable read_eof(Stream& 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); + }()); +} + +// ================================================================================================= + +// +// 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()); + }()); +} + +// ================================================================================================= + +// +// 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); + }()); +} + +// ================================================================================================= diff --git a/test/test_external.cpp b/test/test_external.cpp index 2fd04c9..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 @@ -447,4 +444,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_file_handler.cpp b/test/test_file_handler.cpp index 16c8e03..6a28dc6 100644 --- a/test/test_file_handler.cpp +++ b/test/test_file_handler.cpp @@ -38,7 +38,8 @@ class FileHandler : public ClientAsync ClientAsync::SetUp(); - custom = [this](server::Request request, server::Response response) -> awaitable { + requestHandler = [this](server::Request request, + server::Response response) -> awaitable { co_await serve_file(std::move(request), std::move(response), root, "/custom"); }; } @@ -89,8 +90,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); EXPECT_EQ(message.body(), "Hello, File!"); @@ -99,8 +99,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); EXPECT_EQ(message.body(), "Nested!"); @@ -113,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) { - 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); EXPECT_THAT(message.body(), IsEmpty()); @@ -127,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) { - 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); EXPECT_EQ(message.body(), std::string(256_k, 'x')); @@ -137,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) { - 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); EXPECT_THAT(message.body(), IsEmpty()); @@ -150,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) { - 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,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) { - 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); EXPECT_EQ((co_await get(session, encoded("/custom/%2e%2e/outside.txt"))).result_int(), 404); @@ -172,8 +166,7 @@ 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 - { + clientSession = [this](Session session) -> awaitable { EXPECT_EQ((co_await get(session, "/custom/escape.txt")).result_int(), 404); }; } @@ -184,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) { - test = [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()); @@ -198,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"; - 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); EXPECT_THAT(message.body(), IsEmpty()); @@ -208,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) { - 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 60f2d8f..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 @@ -121,32 +127,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 custom(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 +167,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(); } @@ -172,10 +178,14 @@ 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; - std::function(server::Request request, server::Response response)> custom; + std::function(server::Request request, server::Response response)> + requestHandler; }; // ================================================================================================= @@ -211,8 +221,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)); @@ -237,14 +246,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 (test) - { - auto session = co_await client->async_connect(); - co_await test(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 @@ -255,7 +266,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_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 2955c14..a4ca273 100644 --- a/test/test_get.cpp +++ b/test/test_get.cpp @@ -18,9 +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( 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!"); - 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); 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(""); - test = [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) { - 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,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); - 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); 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) { - 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"); co_await drain(request); co_await response.async_submit(200, fields({{"Content-Length", 0}})); co_await response.async_write_eof(); }; - test = [this](Session session) -> awaitable - { + 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!"); - test = [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. // - 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); 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..a9c4394 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); @@ -252,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")); } diff --git a/test/test_headers.cpp b/test/test_headers.cpp index ebe410a..5207e24 100644 --- a/test/test_headers.cpp +++ b/test/test_headers.cpp @@ -82,8 +82,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); auto fields = sent; @@ -91,8 +90,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); expect_contains(message, sent); @@ -117,8 +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); auto fields = sent; @@ -126,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(); }; - 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,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); - 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,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() { - 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); co_await drain(request); @@ -229,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(); - 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,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(); - 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,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(); - test = [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); @@ -273,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(); - test = [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); @@ -290,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(); - 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); EXPECT_EQ(handled, 1); @@ -303,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(); - 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,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(); - test = [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, {}); @@ -334,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(); - 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))); 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 ===");