diff --git a/.cspell.json b/.cspell.json index c351490..3b7b7f4 100644 --- a/.cspell.json +++ b/.cspell.json @@ -55,6 +55,7 @@ "spdlog", "SSLKEYLOGFILE", "STREQUAL", + "sveccnt", "testcases", "TLSEXT", "TSAN", diff --git a/CMakeLists.txt b/CMakeLists.txt index c3233b0..e4c361a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,6 +53,9 @@ add_compile_definitions(BOOST_PROCESS_V2_DISABLE_NOTIFY_FORK) # we don't use fmtlib any more add_compile_definitions(SPDLOG_USE_STD_FORMAT) +# don't allow use of deprecated ASIO interfaces +add_compile_definitions(BOOST_ASIO_NO_DEPRECATED) + # needed for ranges/v3 starting with clang 19 add_compile_options(-Wno-deprecated-declarations) diff --git a/include/anyhttp/common.hpp b/include/anyhttp/common.hpp index 7fa6e51..eb87ef6 100644 --- a/include/anyhttp/common.hpp +++ b/include/anyhttp/common.hpp @@ -98,6 +98,8 @@ inline Fields fields(std::initializer_list; diff --git a/include/anyhttp/detail/nghttp2_session_details.hpp b/include/anyhttp/detail/h2_session_details.hpp similarity index 97% rename from include/anyhttp/detail/nghttp2_session_details.hpp rename to include/anyhttp/detail/h2_session_details.hpp index 5e225bd..03c1155 100644 --- a/include/anyhttp/detail/nghttp2_session_details.hpp +++ b/include/anyhttp/detail/h2_session_details.hpp @@ -1,6 +1,12 @@ +#pragma once + +// +// Definitions of the HTTP/2 session templates, instantiated only by src/h2_session.cpp -- the +// factories in anyhttp/h2_backend.hpp are what the generic server and client use instead. +// #include "anyhttp/any_async_stream.hpp" -#include "anyhttp/nghttp2_session.hpp" +#include "anyhttp/h2_session.hpp" #include "anyhttp/session.hpp" #include @@ -39,7 +45,7 @@ void NGHttp2SessionImpl::destroy() noexcept { // post(get_executor(), [this, self]() mutable { boost::system::error_code ec; - std::ignore = get_socket(m_stream).shutdown(socket_base::shutdown_both, ec); + get_socket(m_stream).shutdown(socket_base::shutdown_both, ec); logwi(ec, "[{}] destroy: socket shutdown: {}", m_logPrefix, ec.message()); // }); } diff --git a/include/anyhttp/formatter.hpp b/include/anyhttp/formatter.hpp index db4bbf9..a6df6b6 100644 --- a/include/anyhttp/formatter.hpp +++ b/include/anyhttp/formatter.hpp @@ -1,7 +1,5 @@ #pragma once -#include - #include #include #include @@ -122,57 +120,3 @@ struct std::formatter : std::formatter -struct std::formatter -{ - enum class part - { - name_and_value, - name, - value - } what = part::name_and_value; - - constexpr auto parse(std::format_parse_context& ctx) - { - auto it = ctx.begin(); - if (it == ctx.end()) - return it; - - if (*it == 'n') - { - what = part::name; - ++it; - } - else if (*it == 'v') - { - what = part::value; - ++it; - } - - if (it != ctx.end() && *it != '}') - throw std::format_error("invalid format args for nghttp2_nv, expected 'n' or 'v'"); - - return it; - } - - auto format(const nghttp2_nv& nv, std::format_context& ctx) const - { - auto out = ctx.out(); - if (what == part::name || what == part::name_and_value) - std::ranges::copy(rv::counted(nv.name, nv.namelen), out); - - if (what == part::name_and_value) - *out++ = '='; - - if (what == part::value || what == part::name_and_value) - std::ranges::copy(rv::counted(nv.value, nv.valuelen), out); - - return out; - } -}; - -// ================================================================================================= diff --git a/include/anyhttp/h1_backend.hpp b/include/anyhttp/h1_backend.hpp new file mode 100644 index 0000000..05985ce --- /dev/null +++ b/include/anyhttp/h1_backend.hpp @@ -0,0 +1,46 @@ +#pragma once + +// +// The HTTP/1.1 backend as seen from the generic server and client: factories that turn a stream +// that is ready to carry HTTP/1.1 into a Session::Impl. Everything else about the backend -- +// beast's parser, serializer and the session templates driving them -- stays in h1_session.hpp +// and h1_session.cpp, which are the only places instantiating them. +// + +#include "anyhttp/any_async_stream.hpp" +#include "anyhttp/client_impl.hpp" +#include "anyhttp/server_impl.hpp" +#include "anyhttp/session_impl.hpp" + +#include +#include +#include + +#include + +namespace anyhttp::beast_impl +{ + +// ================================================================================================= + +using SslStream = boost::asio::ssl::stream; + +std::shared_ptr make_server_session(server::Server::Impl& server, + boost::asio::any_io_executor executor, + SslStream&& stream); + +std::shared_ptr make_server_session(server::Server::Impl& server, + boost::asio::any_io_executor executor, + AnyAsyncStream&& stream); + +std::shared_ptr make_server_session(server::Server::Impl& server, + boost::asio::any_io_executor executor, + boost::asio::ip::tcp::socket&& socket); + +std::shared_ptr make_client_session(client::Client::Impl& client, + boost::asio::any_io_executor executor, + boost::asio::ip::tcp::socket&& socket); + +// ================================================================================================= + +} // namespace anyhttp::beast_impl diff --git a/include/anyhttp/beast_session.hpp b/include/anyhttp/h1_session.hpp similarity index 100% rename from include/anyhttp/beast_session.hpp rename to include/anyhttp/h1_session.hpp diff --git a/include/anyhttp/h2_backend.hpp b/include/anyhttp/h2_backend.hpp new file mode 100644 index 0000000..adf5f75 --- /dev/null +++ b/include/anyhttp/h2_backend.hpp @@ -0,0 +1,46 @@ +#pragma once + +// +// The HTTP/2 backend as seen from the generic server and client: factories that turn a stream +// that is ready to carry HTTP/2 into a Session::Impl. Everything else about the backend -- +// nghttp2 and the session templates driving it -- stays behind h2_session.hpp +// and h2_session.cpp, so that dispatching to HTTP/2 needs no nghttp2 type here. +// + +#include "anyhttp/any_async_stream.hpp" +#include "anyhttp/client_impl.hpp" +#include "anyhttp/server_impl.hpp" +#include "anyhttp/session_impl.hpp" + +#include +#include +#include + +#include + +namespace anyhttp::nghttp2 +{ + +// ================================================================================================= + +using SslStream = boost::asio::ssl::stream; + +std::shared_ptr make_server_session(server::Server::Impl& server, + boost::asio::any_io_executor executor, + SslStream&& stream); + +std::shared_ptr make_server_session(server::Server::Impl& server, + boost::asio::any_io_executor executor, + AnyAsyncStream&& stream); + +std::shared_ptr make_server_session(server::Server::Impl& server, + boost::asio::any_io_executor executor, + boost::asio::ip::tcp::socket&& socket); + +std::shared_ptr make_client_session(client::Client::Impl& client, + boost::asio::any_io_executor executor, + boost::asio::ip::tcp::socket&& socket); + +// ================================================================================================= + +} // namespace anyhttp::nghttp2 diff --git a/include/anyhttp/h2_common.hpp b/include/anyhttp/h2_common.hpp new file mode 100644 index 0000000..34a1fb8 --- /dev/null +++ b/include/anyhttp/h2_common.hpp @@ -0,0 +1,95 @@ +#pragma once + +// +// Shared HTTP/2 building blocks: the bits of nghttp2 glue that more than one of the h2_* files +// needs. This is the only place outside them where is pulled in -- the +// generic server, client and formatter code knows nothing about nghttp2. +// + +#include + +#include +#include +#include +#include + +namespace anyhttp +{ + +// ================================================================================================= + +// Create nghttp2_nv from string literal |name| and std::string |value|. +// FIXME: don't use this, it is dangerous (prone to dangling string references) +template +nghttp2_nv make_nv_ls(const char (&name)[N], std::string_view value) +{ + return {(uint8_t*)name, (uint8_t*)value.data(), N - 1, value.size(), + NGHTTP2_NV_FLAG_NO_COPY_NAME}; +} + +inline nghttp2_nv make_nv_ls(std::string_view key, std::string_view value) +{ + return {(uint8_t*)key.data(), (uint8_t*)value.data(), key.size(), value.size(), 0}; +} + +// ================================================================================================= + +} // namespace anyhttp + +// ================================================================================================= + +/// Formats an HTTP/2 name-value pair (nghttp2_nv). +/// Format specifiers: 'n' = name only, 'v' = value only, default = "name=value". +/// Example: {:n} → "content-type", {:v} → "application/json", {} → "content-type=application/json" +template <> +struct std::formatter +{ + enum class part + { + name_and_value, + name, + value + } what = part::name_and_value; + + constexpr auto parse(std::format_parse_context& ctx) + { + auto it = ctx.begin(); + if (it == ctx.end()) + return it; + + if (*it == 'n') + { + what = part::name; + ++it; + } + else if (*it == 'v') + { + what = part::value; + ++it; + } + + if (it != ctx.end() && *it != '}') + throw std::format_error("invalid format args for nghttp2_nv, expected 'n' or 'v'"); + + return it; + } + + auto format(const nghttp2_nv& nv, std::format_context& ctx) const + { + namespace rv = std::ranges::views; + + auto out = ctx.out(); + if (what == part::name || what == part::name_and_value) + std::ranges::copy(rv::counted(nv.name, nv.namelen), out); + + if (what == part::name_and_value) + *out++ = '='; + + if (what == part::value || what == part::name_and_value) + std::ranges::copy(rv::counted(nv.value, nv.valuelen), out); + + return out; + } +}; + +// ================================================================================================= diff --git a/include/anyhttp/detect_http2.hpp b/include/anyhttp/h2_detect.hpp similarity index 100% rename from include/anyhttp/detect_http2.hpp rename to include/anyhttp/h2_detect.hpp diff --git a/include/anyhttp/nghttp2_session.hpp b/include/anyhttp/h2_session.hpp similarity index 99% rename from include/anyhttp/nghttp2_session.hpp rename to include/anyhttp/h2_session.hpp index 668296a..fd2c360 100644 --- a/include/anyhttp/nghttp2_session.hpp +++ b/include/anyhttp/h2_session.hpp @@ -2,7 +2,7 @@ #include "anyhttp/common.hpp" #include "client_impl.hpp" -#include "nghttp2_stream.hpp" +#include "h2_stream.hpp" #include "server_impl.hpp" #include "session_impl.hpp" diff --git a/include/anyhttp/nghttp2_stream.hpp b/include/anyhttp/h2_stream.hpp similarity index 100% rename from include/anyhttp/nghttp2_stream.hpp rename to include/anyhttp/h2_stream.hpp diff --git a/include/anyhttp/h3_backend.hpp b/include/anyhttp/h3_backend.hpp new file mode 100644 index 0000000..b8b4d65 --- /dev/null +++ b/include/anyhttp/h3_backend.hpp @@ -0,0 +1,65 @@ +#pragma once + +// +// The HTTP/3 backend as seen from the generic server and client. ngtcp2 and nghttp3 stay behind +// this header, inside the h3_* files: on the server side as an opaque Http3Server owning the +// shared UDP socket, its receive loop and the QUIC connection-ID demux (src/h3_server.cpp), on +// the client side as a single coroutine that establishes a QUIC connection and hands back the +// session running on it (src/h3_client.cpp). +// + +#include "anyhttp/server_impl.hpp" +#include "anyhttp/session_impl.hpp" + +#include +#include +#include + +#include +#include + +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. +// +class Http3Server +{ +public: + virtual ~Http3Server() = default; + + /// Starts the UDP receive loop. + virtual void start() = 0; + + /// Closes the socket and tears down all QUIC connections, each sending a CONNECTION_CLOSE. + virtual void destroy() = 0; +}; + +/// Binds the UDP socket for HTTP/3 to `endpoint`, usually the address and port the TCP acceptor +/// is already listening on, so that all three protocols share one endpoint. +std::shared_ptr make_http3_server(Server::Impl& server, + const boost::asio::ip::udp::endpoint& endpoint); + +// ================================================================================================= + +} // namespace anyhttp::server + +namespace anyhttp::client +{ + +// ================================================================================================= + +/// Connects to `host`:`port` over QUIC and returns the HTTP/3 session running on it. +boost::asio::awaitable> +async_connect_http3(boost::asio::any_io_executor executor, std::string host, std::string port); + +// ================================================================================================= + +} // namespace anyhttp::client diff --git a/include/anyhttp/h3_common.hpp b/include/anyhttp/h3_common.hpp new file mode 100644 index 0000000..7bcbb41 --- /dev/null +++ b/include/anyhttp/h3_common.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include "anyhttp/common.hpp" + +#include + +#include +#include +#include +#include +#include + +// +// Shared HTTP/3 building blocks. Everything in this namespace is used by both roles: the server +// (src/h3_server.cpp) and the client (src/h3_client.cpp) differ only in the direction +// their messages travel, not in how a QUIC connection or an HTTP/3 stream is driven. +// +namespace anyhttp::http3 +{ + +// ================================================================================================= + +/// Length of the connection IDs we mint for ourselves; also what the server's demux decodes with. +constexpr size_t QUIC_SCIDLEN = 18; + +// +// Bound on how much of the caller's async_write() buffer a stream in WriteMode::Staged copies at +// a time -- copying is paced by how much nghttp3/ngtcp2 actually drains, rather than copying a +// huge caller buffer (e.g. 50MB) in one synchronous allocation+memcpy, mirroring nghttp2's own +// per-call copy into its frame buffer. +// +inline constexpr size_t kWriteChunkSize = 16 * 1024; + +// ================================================================================================= + +/// Builds a nghttp3 name/value pair referencing (not copying) both strings. +nghttp3_nv make_nv(std::string_view name, std::string_view value); + +/// Logs a block of headers, one per line, in the same style as the received ones. +void log_headers(std::string_view log_prefix, std::span nva); + +/// Same, for a header block buffered up by the recv_header callback. +void log_headers(std::string_view log_prefix, + const std::vector>& headers); + +/// Installed as ngtcp2_settings::log_printf, but only when trace logging is enabled -- ngtcp2 +/// formats every frame of every packet before calling it, so a callback that discards its input +/// still pays for the formatting, while a NULL one makes ngtcp2 skip that work entirely. +void ngtcp2_log_printf(void* user, const char* fmt, ...) noexcept; + +// ================================================================================================= + +} // namespace anyhttp::http3 diff --git a/include/anyhttp/h3_session.hpp b/include/anyhttp/h3_session.hpp new file mode 100644 index 0000000..d47106e --- /dev/null +++ b/include/anyhttp/h3_session.hpp @@ -0,0 +1,249 @@ +#pragma once + +#include "anyhttp/common.hpp" +#include "anyhttp/h3_common.hpp" +#include "anyhttp/session_impl.hpp" + +#include +#include + +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace anyhttp::http3 +{ + +class Http3Stream; + +// ================================================================================================= + +// +// One QUIC connection carrying HTTP/3, as one anyhttp Session::Impl -- the same class on both +// sides. A QUIC connection is symmetric: past ngtcp2_conn_server_new()/ngtcp2_conn_client_new() +// and the handshake, both ends drive the very same ngtcp2/nghttp3 pair the same way, so the read +// loop, the write loop, the timers, the flow control and every callback bridge below are shared. +// +// What the roles still own themselves is how datagrams reach the connection (the server +// demultiplexes many connections over one shared socket by connection ID, the client owns a +// connect()ed socket with exactly one peer), how a dead connection is torn down, and how streams +// come into being (accepted from the peer vs. opened by async_submit()). Those are the virtuals +// at the bottom. +// +class Http3Session : public Session::Impl +{ +public: + explicit Http3Session(asio::any_io_executor executor); + ~Http3Session() override; + + // + // Session::Impl + // + asio::any_io_executor get_executor() const noexcept override { return executor_; } + + ngtcp2_conn* conn() const noexcept { return conn_; } + nghttp3_conn* h3() const noexcept { return h3_; } + bool closed() const noexcept { return closed_; } + const std::string& logPrefix() const noexcept { return log_prefix_; } + + // + // Returns a shared_ptr, not a raw pointer: callers routinely invoke user handlers on the + // stream they looked up, and those can drop the last reference to it (the coroutine they + // resume destroying its Request/Response), which erases the stream from streams_. Holding + // an owning reference for the duration of the lookup keeps that from becoming a + // use-after-free. + // + std::shared_ptr find_stream(int64_t id); + Http3Stream* create_stream(int64_t id); + void erase_stream(int64_t id); + + // + // Grants the peer more *stream*-level send credit for `n` bytes of body just delivered to the + // application. Deliberately NOT called as data arrives (see h3_cb_recv_data) -- only once + // call_read_handler() actually hands bytes to the app, so a slow/absent reader keeps the + // peer's flow control window for *this stream* genuinely constrained instead of nghttp3 + // buffering an unbounded backlog in pending_read. Connection-level credit is granted eagerly + // regardless (see h3_cb_recv_data) since it's a pool shared with control/QPACK streams + // nghttp3 manages on its own. + // + void consume_stream(int64_t stream_id, size_t n); + + // + // Abort both directions of the stream (RESET_STREAM + STOP_SENDING), the QUIC equivalent of + // HTTP/2's RST_STREAM. nghttp3 learns of the dead write side through the existing + // NGTCP2_ERR_STREAM_SHUT_WR handling in write_pkt(). + // + void reset_stream(int64_t stream_id, uint64_t app_error_code); + + // + // Half-close just our read direction (STOP_SENDING), telling the peer to stop sending the + // body while whatever we are still writing keeps flowing. Fires the local stream_stop_sending + // callback, which is what tells nghttp3 about it. + // + void stop_reading(int64_t stream_id, uint64_t app_error_code); + + // + // Called whenever new data was queued outside of a packet arriving or a timer firing; makes + // sure the write loop runs. One flush per wake, not one per submission: a response submits + // its headers, its body and its EOF separately, and posting for each means the first pass + // writes everything and the rest walk the connection for nothing. + // + void wake_write(); + + // + // Writes out whatever ngtcp2 has queued and re-arms the expiry timer. Reading does not write: + // the server feeds a whole batch of datagrams to ngtcp2 and flushes once at the end, so a + // response goes out as one big GSO batch instead of several small ones. + // + int flush_write(); + + int write_streams(); + ngtcp2_ssize write_pkt(ngtcp2_path* path, ngtcp2_pkt_info* pi, uint8_t* dest, size_t destlen, + ngtcp2_tstamp ts); + void update_timer(); + int handle_expiry(); + + // + // ngtcp2 <-> ngtcp2_crypto_ossl bridge. + // + static ngtcp2_conn* get_conn(ngtcp2_crypto_conn_ref* ref) + { + return static_cast(ref->user_data)->conn_; + } + + // + // ngtcp2 callback bridges + // + static int cb_handshake_completed(ngtcp2_conn*, void* user); + static int cb_recv_stream_data(ngtcp2_conn*, uint32_t flags, int64_t stream_id, uint64_t offset, + const uint8_t* data, size_t datalen, void* user, void*); + static int cb_acked_stream_data_offset(ngtcp2_conn*, int64_t stream_id, uint64_t offset, + uint64_t datalen, void* user, void*); + static int cb_stream_open(ngtcp2_conn*, int64_t stream_id, void* user); + static int cb_stream_close(ngtcp2_conn*, uint32_t flags, int64_t stream_id, + uint64_t app_error_code, void* user, void*); + static void cb_rand(uint8_t* dest, size_t destlen, const ngtcp2_rand_ctx*); + static int cb_get_new_connection_id(ngtcp2_conn*, ngtcp2_cid* cid, uint8_t* token, size_t cidlen, + void* user); + static int cb_remove_connection_id(ngtcp2_conn*, const ngtcp2_cid* cid, void* user); + static int cb_extend_max_streams_bidi(ngtcp2_conn*, uint64_t max_streams, void* user); + static int cb_stream_stop_sending(ngtcp2_conn*, int64_t stream_id, uint64_t app_error_code, + void* user, void*); + static int cb_stream_reset(ngtcp2_conn*, int64_t stream_id, uint64_t final_size, + uint64_t app_error_code, void* user, void*); + static int cb_extend_max_stream_data(ngtcp2_conn*, int64_t stream_id, uint64_t max_data, + void* user, void*); + static int cb_recv_rx_key(ngtcp2_conn*, ngtcp2_encryption_level level, void* user); + + // + // nghttp3 callback bridges + // + static int h3_cb_acked_stream_data(nghttp3_conn*, int64_t stream_id, uint64_t datalen, + void* user, void*); + static int h3_cb_stream_close(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, + void* user, void*); + static int h3_cb_recv_data(nghttp3_conn*, int64_t stream_id, const uint8_t* data, size_t datalen, + void* user, void*); + static int h3_cb_deferred_consume(nghttp3_conn*, int64_t stream_id, size_t nconsumed, void* user, + void*); + static int h3_cb_begin_headers(nghttp3_conn*, int64_t stream_id, void* user, void*); + static int h3_cb_recv_header(nghttp3_conn*, int64_t stream_id, int32_t token, + nghttp3_rcbuf* name, nghttp3_rcbuf* value, uint8_t flags, + void* user, void*); + static int h3_cb_end_headers(nghttp3_conn*, int64_t stream_id, int fin, void* user, void*); + static int h3_cb_end_stream(nghttp3_conn*, int64_t stream_id, void* user, void*); + static int h3_cb_stop_sending(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, + void* user, void*); + static int h3_cb_reset_stream(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, + void* user, void*); + +protected: + // + // Connection setup, shared by both roles' init(). fill_callbacks() installs everything that + // isn't role-specific; the role adds its own (recv_client_initial / client_initial + recv_retry) + // before handing the table to ngtcp2_conn_server_new()/ngtcp2_conn_client_new(). + // + void fill_callbacks(ngtcp2_callbacks& callbacks); + void fill_settings(ngtcp2_settings& settings, ngtcp2_transport_params& params, + std::chrono::nanoseconds idle_timeout); + int setup_tls(SSL_CTX* ssl_ctx, bool is_server); + + /// Feeds one received datagram to ngtcp2. Marks the connection for writing, but does not write. + int on_read(const ngtcp2_path& path, const ngtcp2_pkt_info& pi, std::span data); + + /// Creates the HTTP/3 layer (control + QPACK streams) on top of the QUIC connection. + int setup_http3(); + + /// Writes a CONNECTION_CLOSE frame into `buf`, returning what of it to send (may be empty). + std::span write_connection_close(std::span buf, ngtcp2_path_storage& ps); + + void arm_timer_from_ngtcp2(); + + // + // Tears down all streams. Called by both roles while their own state is still alive, because + // destroying a stream fires pending handlers, which may reach back into the session. + // + void clear_streams(); + + // + // Role-specific: everything a QUIC connection cannot decide on its own. + // + /// The connection is dead or dying; the role decides how it goes away (closing period and a + /// buffered CONNECTION_CLOSE on the server, plain teardown on the client). + virtual int handle_error(int rv) = 0; + /// Puts the packets ngtcp2 just produced on the wire. `data` holds one or more QUIC packets, + /// all but the last exactly `gso_size` bytes long. + virtual int send_datagrams(const ngtcp2_path& path, std::span data, + size_t gso_size) = 0; + /// Creates the role's stream type (Http3ServerStream / Http3ClientStream). + virtual std::shared_ptr make_stream(int64_t id) = 0; + /// The HTTP/3 layer is up and requests can flow. + virtual void on_http3_ready() {} + /// A connection ID was minted for / retired from this connection (the server's demux table). + virtual void on_new_cid(const ngtcp2_cid& cid) { (void)cid; } + virtual void on_remove_cid(const ngtcp2_cid& cid) { (void)cid; } + + static ngtcp2_ssize write_pkt_cb(ngtcp2_conn*, ngtcp2_path* path, ngtcp2_pkt_info* pi, + uint8_t* dest, size_t destlen, ngtcp2_tstamp ts, + void* user_data); + +protected: + asio::any_io_executor executor_; + + ngtcp2_conn* conn_ = nullptr; + ngtcp2_crypto_ossl_ctx* ossl_ctx_ = nullptr; + ngtcp2_crypto_conn_ref conn_ref_{}; + + nghttp3_conn* h3_ = nullptr; + + asio::steady_timer timer_; // ngtcp2 expiry (handshake / idle / PTO) + ngtcp2_ccerr last_error_{}; + bool closed_ = false; + + std::string log_prefix_; + + bool write_posted_ = false; // a wake_write() flush is already on the way + + // + // Aggregated TX buffer: ngtcp2_conn_write_aggregate_pkt2() packs as many same-sized packets as + // it can (control/QPACK streams, body data, ...) into this buffer so they can all be flushed + // with a single sendmsg()+UDP_SEGMENT (GSO) call instead of one send per QUIC packet. + // + std::vector tx_buf_; + + std::unordered_map> streams_; +}; + +// ================================================================================================= + +} // namespace anyhttp::http3 diff --git a/include/anyhttp/h3_stream.hpp b/include/anyhttp/h3_stream.hpp new file mode 100644 index 0000000..0936e15 --- /dev/null +++ b/include/anyhttp/h3_stream.hpp @@ -0,0 +1,358 @@ +#pragma once + +#include "anyhttp/common.hpp" +#include "anyhttp/h3_common.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace anyhttp::http3 +{ + +class Http3Session; + +// ================================================================================================= + +// +// How a stream hands the caller's async_write() buffer down to nghttp3. +// +enum class WriteMode +{ + // + // Point the nghttp3_vec straight into the caller's buffer: the body is never copied on its way + // to the nghttp3/ngtcp2 boundary, however large it is -- the mmap()ed file of serve_file() + // travels from the page cache into QUIC packets without an intermediate byte. What that costs + // is *when* the write completes: ngtcp2 keeps pointing into that memory for as long as the + // bytes may still have to be retransmitted, so the handler -- which releases the buffer -- can + // only run once they are acknowledged (nghttp3's acked_stream_data callback). Cancelling a + // write with unacknowledged bytes therefore has to reset the stream, there being no way to + // un-offer memory ngtcp2 may still read from. + // + ZeroCopy, + + // + // Copy through a bounded, stream-owned staging buffer (write_chunk, <= kWriteChunkSize), + // refilled as nghttp3 drains it. That costs a copy but completes a write as soon as the bytes + // are handed over, and makes cancellation instantaneous: nothing points into the caller's + // buffer, so the un-copied remainder is simply abandoned and the *stream survives* -- which is + // what lets a cancelled write be followed by another one on the same stream. + // + Staged +}; + +// ================================================================================================= + +// +// One HTTP/3 request/response exchange: a bidirectional stream that can be read from and written +// to. Both roles use this same class; what differs is only which half of the exchange travels in +// which direction -- the server reads a request and writes a response, the client writes a request +// and reads a response -- so everything below is phrased as the *incoming* and the *outgoing* +// message, and the handful of genuinely role-specific steps (which pseudo-headers to parse, what +// to do once the incoming headers are complete, how the outgoing headers are submitted) are +// virtual hooks implemented by Http3ServerStream / Http3ClientStream. +// +class Http3Stream : public std::enable_shared_from_this +{ +public: + Http3Stream(Http3Session& session, int64_t id, WriteMode write_mode); + virtual ~Http3Stream(); + + int64_t id; + Http3Session& session; + std::string log_prefix; + + // + // Incoming message (request on the server, response on the client), populated by the nghttp3 + // header callbacks. Only one of method/status_code is ever meaningful, depending on the role. + // + std::string method; + boost::urls::url url; + unsigned int status_code = 0; + std::optional content_length; + Fields fields; + + // + // The header block as it arrived, buffered so that end_headers can log it in one go, below the + // request/status line, instead of one stray line per header as they come in. Only filled when + // debug logging is on, and dropped again as soon as it has been logged. + // + std::vector> received_headers; + bool headers_received = false; + + // + // Outgoing message: the response on the server (set by the user through Http3Writer), the + // request on the client (set once by async_submit(), before this stream even exists as far as + // the user is concerned). + // + unsigned int response_status = 0; + Fields response_fields; + std::optional response_content_length; + std::string response_content_length_str; // storage backing a nghttp3_nv + bool headers_submitted = false; + + // + // Incoming body. What nghttp3 delivers is parked here only if no reader was waiting for it. + // + std::deque> pending_read; + asio::const_buffer read_head; // view of pending_read.front() not yet delivered + asio::const_buffer incoming; // chunk on_data_chunk() is delivering, not yet taken + bool eof_received = false; + ReadSomeHandler read_handler; + asio::mutable_buffer read_handler_buffer; + bool call_read_handler_active = false; // re-entrancy guard, see call_read_handler() + + // + // Outgoing body. Only one async_write() may be active at a time -- callers must wait for its + // handler before issuing another (same contract as e.g. Beast) -- so this is flat per-stream + // state rather than a queue of pending writes. + // + // write_source is the caller's buffer, referenced, not copied, the way asio::async_write + // generally requires: it must stay valid until write_handler fires. write_offered tracks how + // much of it has been handed to nghttp3, which may ask again before any of it goes out and + // would take a repeated offer as *additional*, distinct stream bytes -- duplicating the body on + // the wire -- so a repeat call gets NGHTTP3_ERR_WOULDBLOCK instead. + // + // What completes the write depends on write_mode: bytes acknowledged (write_acked) in + // ZeroCopy, bytes copied out and handed over (write_source_copied / write_confirmed) in + // Staged. See WriteMode. + // + const WriteMode write_mode; + bool write_active = false; + asio::const_buffer write_source; + size_t write_offered = 0; + size_t write_acked = 0; // ZeroCopy: reported by nghttp3's acked_stream_data + size_t write_source_copied = 0; // Staged: how much of write_source went into a chunk + std::vector write_chunk; // Staged: the staging buffer itself + size_t write_confirmed = 0; // Staged: how much of write_chunk ngtcp2 has taken + std::vector> in_flight_writes; // Staged: retired chunks, kept alive for + // the stream's lifetime because ngtcp2 may + // still retransmit from them + bool write_is_eof = false; + WriteHandler write_handler; + uint64_t write_token = 0; + uint64_t next_write_token = 1; + bool eof_submitted = false; // user signalled EOF via an empty write + + // + // Lifecycle. + // + impl::Reader* reader = nullptr; // the Http3Reader, while attached + impl::Writer* writer = nullptr; // the Http3Writer, while attached + bool closed = false; + + asio::any_io_executor get_executor() const noexcept; + const std::string& logPrefix() const noexcept { return log_prefix; } + + // + // Data flow into user land (incoming body). + // + void on_data_chunk(const uint8_t* data, size_t len); + void on_eof(); + void call_read_handler(); + + // + // Data flow from user land back to nghttp3 (outgoing body). + // + void start_write(WriteHandler&& handler, asio::const_buffer buffer); + nghttp3_ssize data_reader(nghttp3_vec* vec, size_t veccnt, uint32_t* pflags); + void on_write_acked(size_t n); // ZeroCopy: nghttp3 acked_stream_data + void on_write_offered(size_t n); // Staged: bytes ngtcp2 committed to a packet + + // + // Headers. + // + void on_header(std::string_view name, std::string_view value); + void on_end_headers(); + + /// Hands an assembled header block to nghttp3, as a request (client) or response (server). + bool submit_headers(std::span nva, bool is_request); + + // + // Called when the stream dies before its exchange completed, and from either the reader's or + // the writer's destructor. + // + void fail(boost::system::error_code ec); + void delete_reader(); + void delete_writer(); + void maybe_close(); + +protected: + // + // Role-specific pieces. Everything else above is shared verbatim between server and client. + // + /// Parse a `:`-prefixed pseudo-header of the incoming message. + virtual void on_pseudo_header(std::string_view name, std::string_view value) = 0; + /// The incoming header block is complete: dispatch the request (server) / response (client). + virtual void on_headers_complete() = 0; + /// The stream failed or closed early; fail whatever else the role has pending. + virtual void on_failed(boost::system::error_code ec) { (void)ec; } + +public: + /// Submit the outgoing response headers. A no-op on the client, whose request headers went out + /// with the stream itself, see Http3ClientSession::async_submit(). + virtual void submit_response(unsigned int status_code, const Fields& fields) = 0; + +private: + void bind_write_cancellation(WriteHandler& handler, uint64_t token); // arms cancellation + void finish_active_write(); // completes the active write and releases the caller's buffer +}; + +// ================================================================================================= +// Http3Reader / Http3Writer: adapters plugging an Http3Stream into the anyhttp Reader/Writer +// interfaces. The same pair serves both roles -- server::Request/server::Response and +// client::Response/client::Request are the same two halves seen from the other end. +// ================================================================================================= + +template +class Http3Reader : public Interface +{ +public: + explicit Http3Reader(Http3Stream& s) : stream(&s) { s.reader = this; } + ~Http3Reader() override + { + if (stream) + { + stream->reader = nullptr; + stream->delete_reader(); + } + } + + asio::any_io_executor get_executor() const noexcept override + { + assert(stream); + return stream->get_executor(); + } + + std::optional content_length() const noexcept override + { + 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; + } + + void async_read_some(asio::mutable_buffer buffer, ReadSomeHandler&& handler) override + { + if (!stream) + { + std::move(handler)(boost::beast::http::error::partial_message, 0); + return; + } + if (asio::buffer_size(buffer) == 0) + { + asio::any_completion_executor ex = + asio::get_associated_immediate_executor(handler, stream->get_executor()); + ex.execute([handler = std::move(handler)]() mutable + { std::move(handler)(boost::system::error_code{}, 0); }); + return; + } + + auto cs = asio::get_associated_cancellation_slot(handler); + if (cs.is_connected() && !cs.has_handler()) + { + 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); + }); + } + }); + } + + assert(!stream->read_handler); + stream->read_handler = std::move(handler); + stream->read_handler_buffer = buffer; + stream->call_read_handler(); + } + + void detach() override { stream = nullptr; } + + Http3Stream* stream; +}; + +// ------------------------------------------------------------------------------------------------- + +template +class Http3Writer : public Base +{ +public: + explicit Http3Writer(Http3Stream& s) : stream(&s) { s.writer = this; } + ~Http3Writer() override + { + if (stream) + { + stream->writer = nullptr; + stream->delete_writer(); + } + } + + asio::any_io_executor get_executor() const noexcept override + { + assert(stream); + return stream->get_executor(); + } + + void content_length(std::optional len) override + { + assert(stream); + stream->response_content_length = len; + } + + void async_write(WriteHandler&& handler, asio::const_buffer buffer) override + { + if (!stream || stream->closed) + { + std::move(handler)( + boost::system::errc::make_error_code(boost::system::errc::connection_reset)); + return; + } + + stream->start_write(std::move(handler), buffer); + } + + void async_submit(StatusHandler&& handler, unsigned int status_code, const Fields& fields) + { + if (!stream || stream->closed) + { + std::move(handler)( + boost::system::errc::make_error_code(boost::system::errc::connection_reset)); + return; + } + stream->submit_response(status_code, fields); + std::move(handler)(boost::system::error_code{}); + } + + void detach() override { stream = nullptr; } + + Http3Stream* stream; +}; + +// ================================================================================================= + +} // namespace anyhttp::http3 diff --git a/include/anyhttp/nghttp2_common.hpp b/include/anyhttp/nghttp2_common.hpp deleted file mode 100644 index 40b5d57..0000000 --- a/include/anyhttp/nghttp2_common.hpp +++ /dev/null @@ -1,28 +0,0 @@ -#pragma once - -#include - -#include - -namespace anyhttp -{ - -// ================================================================================================= - -// Create nghttp2_nv from string literal |name| and std::string |value|. -// FIXME: don't use this, it is dangerous (prone to dangling string references) -template -nghttp2_nv make_nv_ls(const char (&name)[N], std::string_view value) -{ - return {(uint8_t*)name, (uint8_t*)value.data(), N - 1, value.size(), - NGHTTP2_NV_FLAG_NO_COPY_NAME}; -} - -inline nghttp2_nv make_nv_ls(std::string_view key, std::string_view value) -{ - return {(uint8_t*)key.data(), (uint8_t*)value.data(), key.size(), value.size(), 0}; -} - -// ================================================================================================= - -} // namespace anyhttp diff --git a/include/anyhttp/server_impl.hpp b/include/anyhttp/server_impl.hpp index 6f1e49e..c546012 100644 --- a/include/anyhttp/server_impl.hpp +++ b/include/anyhttp/server_impl.hpp @@ -7,10 +7,6 @@ #include #include -#include - -// Forward declaration so we don't drag into every translation unit. -struct ngtcp2_cid; namespace anyhttp { @@ -51,9 +47,11 @@ class Response::Impl : public impl::Writer // ================================================================================================= -struct Endpoint; -class Http3Session; -struct QuicBatch; +// +// The HTTP/3 half of the server, behind anyhttp/h3_backend.hpp: it owns the UDP socket and +// everything QUIC, so that nothing of ngtcp2/nghttp3 reaches this header. +// +class Http3Server; class Server::Impl : public std::enable_shared_from_this { @@ -65,13 +63,12 @@ class Server::Impl : public std::enable_shared_from_this void destroy(); void listen_tcp(); - void listen_udp(); const Config& config() const { return m_config; } boost::asio::any_io_executor get_executor() const noexcept { return m_executor; } - asio::awaitable tcp_listen_loop(); - asio::awaitable handleConnection(asio::ip::tcp::socket socket); + asio::awaitable tcp_accept_loop(); + asio::awaitable handle_connection(asio::ip::tcp::socket socket); asio::ip::tcp::endpoint local_endpoint() const { @@ -82,32 +79,25 @@ class Server::Impl : public std::enable_shared_from_this void setRequestHandler(RequestHandler&& handler) { m_requestHandler = std::move(handler); } const RequestHandler& requestHandler() const { return m_requestHandler; } - asio::awaitable udp_receive_loop(); - int udp_on_read(Endpoint& ep); - void process_quic_batch(const std::shared_ptr& session, QuicBatch&& batch); - // - // QUIC connection-ID demux table. Populated by QuicHandler as new source CIDs are minted, - // consulted by udp_on_read() to route packets to the right connection. Guarded by - // m_quicMutex: the receive loop reads it while sessions mutate it from their own strands - // (get_new_connection_id/remove_connection_id callbacks, close timers). + // Session registry, shared by all three protocols: every session is destroyed from here when + // the server goes away. Adding fails once destroy() has swept the registry -- a session + // registered after that sweep would never be torn down -- and the caller has to destroy the + // session itself. // - void associate_quic_cid(const ngtcp2_cid& cid, Http3Session* session); - void dissociate_quic_cid(const ngtcp2_cid& cid); - void erase_quic_session(Http3Session* h); + [[nodiscard]] bool add_session(std::shared_ptr session); + void remove_session(const std::shared_ptr& session); private: Config m_config; boost::asio::any_io_executor m_executor; std::optional m_acceptor; - std::optional m_udp_socket; std::mutex m_sessionMutex; std::set> m_sessions; - std::mutex m_quicMutex; - std::unordered_map> m_quic_handlers; + std::shared_ptr m_http3; RequestHandler m_requestHandler; bool m_destroyed = false; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index cc79a01..780e5c8 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -11,7 +11,8 @@ pkg_check_modules(NGHTTP3 REQUIRED IMPORTED_TARGET libnghttp3) pkg_check_modules(NGTCP2_CRYPTO_OSSL REQUIRED IMPORTED_TARGET libngtcp2_crypto_ossl) # -# lib +# lib -- the protocol backends are the h1_*, h2_* and h3_* sources; only the h2_* ones use +# nghttp2 and only the h3_* ones ngtcp2/nghttp3, everything else is protocol-agnostic. # add_library(anyhttp) file(GLOB anyhttp_sources CONFIGURE_DEPENDS *.cpp) @@ -40,7 +41,6 @@ add_executable(server) target_sources(server PRIVATE "server_main.cpp") target_link_libraries(server PRIVATE anyhttp) target_link_libraries(server PRIVATE Boost::program_options) -target_link_libraries(server PRIVATE PkgConfig::NGHTTP2) # # client diff --git a/src/client_impl.cpp b/src/client_impl.cpp index 1c31fd5..ef35c77 100644 --- a/src/client_impl.cpp +++ b/src/client_impl.cpp @@ -1,10 +1,9 @@ #include "anyhttp/client_impl.hpp" -#include "anyhttp/beast_session.hpp" #include "anyhttp/common.hpp" #include "anyhttp/formatter.hpp" // IWYU pragma: keep -#include "anyhttp/nghttp2_session.hpp" - -#include "anyhttp/detail/nghttp2_session_details.hpp" +#include "anyhttp/h1_backend.hpp" +#include "anyhttp/h2_backend.hpp" +#include "anyhttp/h3_backend.hpp" #include #include @@ -34,13 +33,6 @@ namespace anyhttp::client { using namespace asio::experimental::awaitable_operators; -// -// Defined in client_impl_udp.cpp -- kept out of client_impl.hpp so that this file doesn't need to -// drag in ngtcp2/nghttp3 headers just to declare it. -// -awaitable> async_connect_http3(asio::any_io_executor executor, - std::string host, std::string port); - // ================================================================================================= #if 0 @@ -163,13 +155,11 @@ awaitable Client::Impl::async_connect() switch (config().protocol) { case Protocol::http11: - impl = std::make_shared>( - *this, m_executor, boost::beast::tcp_stream(std::move(socket))); + impl = beast_impl::make_client_session(*this, m_executor, std::move(socket)); break; case Protocol::h2: - impl = std::make_shared>(*this, m_executor, - std::move(socket)); + impl = nghttp2::make_client_session(*this, m_executor, std::move(socket)); break; case anyhttp::Protocol::h3: diff --git a/src/client_impl_udp.cpp b/src/client_impl_udp.cpp deleted file mode 100644 index 0769a00..0000000 --- a/src/client_impl_udp.cpp +++ /dev/null @@ -1,2154 +0,0 @@ -// -// anyhttp QUIC / HTTP/3 client. -// -// One `Http3ClientSession` per QUIC connection implements `Session::Impl`. Unlike the server -// (`server_impl_udp.cpp`), which multiplexes many connections over one shared UDP socket demuxed -// by connection ID, each client session owns its own `connect()`-ed UDP socket -- there is exactly -// one peer, so no demux table is needed. Per-request `Http3ClientStream` state feeds an -// `Http3ClientWriter` (client::Request) and `Http3ClientReader` (client::Response), mirroring the -// server-side Http3Writer/Http3Reader adapters. -// -// Not yet implemented: certificate verification, 0-RTT, connection migration, GSO/ECN, retry -// tokens, graceful (multi-PTO) close. -// - -#include "anyhttp/client_impl.hpp" -#include "anyhttp/formatter.hpp" // IWYU pragma: keep -#include "anyhttp/literals.hpp" -#include "anyhttp/session_impl.hpp" -#include "anyhttp/tls.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 -#include -#include -#include -#include -#include -#include -#include - -#include "ngtcp2/shared.h" -#include "ngtcp2/util.h" - -using namespace std::chrono_literals; -using namespace boost::asio; -namespace errc = boost::system::errc; - -namespace anyhttp::client -{ - -// ================================================================================================= -// Free-standing helpers -// ================================================================================================= - -namespace -{ - -// -// One-shot process-wide initialization of ngtcp2_crypto_ossl and the client-role OpenSSL SSL_CTX -// used for every outgoing QUIC connection. -// -struct TlsClientContext -{ - TlsClientContext() - { - static const int init_once = [] - { - if (ngtcp2_crypto_ossl_init() != 0) - throw std::runtime_error("ngtcp2_crypto_ossl_init"); - return 0; - }(); - (void)init_once; - - ctx = SSL_CTX_new(TLS_client_method()); - if (!ctx) - throw std::runtime_error("SSL_CTX_new"); - - static constexpr unsigned char alpn[] = "\x02h3"; - SSL_CTX_set_alpn_protos(ctx, alpn, sizeof(alpn) - 1); - - // - // TODO: verify the server certificate (e.g. against pki/out/root.pem) instead of accepting - // anything. - // - SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, nullptr); - } - - ~TlsClientContext() - { - if (ctx) - SSL_CTX_free(ctx); - } - - TlsClientContext(const TlsClientContext&) = delete; - TlsClientContext& operator=(const TlsClientContext&) = delete; - - SSL_CTX* ctx = nullptr; -}; - -TlsClientContext& tls_context() -{ - static TlsClientContext instance; - return instance; -} - -// ------------------------------------------------------------------------------------------------- - -nghttp3_nv make_nv(std::string_view name, std::string_view value) -{ - nghttp3_nv nv{}; - nv.name = reinterpret_cast(const_cast(name.data())); - nv.namelen = name.size(); - nv.value = reinterpret_cast(const_cast(value.data())); - nv.valuelen = value.size(); - nv.flags = NGHTTP3_NV_FLAG_NONE; - return nv; -} - -/// Logs a block of headers, one per line, in the same style as the received ones. -void log_headers(std::string_view log_prefix, const std::vector& nva) -{ - for (const auto& nv : nva) - logd("[{}] \x1b[1;34m{}\x1b[0m: {}", log_prefix, - std::string_view(reinterpret_cast(nv.name), nv.namelen), - std::string_view(reinterpret_cast(nv.value), nv.valuelen)); -} - -/// Same, for a header block buffered up by the recv_header callback. -void log_headers(std::string_view log_prefix, - const std::vector>& headers) -{ - for (const auto& [name, value] : headers) - logd("[{}] \x1b[1;34m{}\x1b[0m: {}", log_prefix, name, value); -} - -void ngtcp2_log_printf(void* /*user*/, const char* fmt, ...) noexcept -{ - if (!spdlog::default_logger()->should_log(spdlog::level::trace)) - return; - std::array buf; - va_list ap; - va_start(ap, fmt); - std::vsnprintf(buf.data(), buf.size(), fmt, ap); - va_end(ap); - spdlog::trace("{}", buf.data()); -} - -} // namespace - -// ================================================================================================= -// Http3ClientStream: per-request state. -// ================================================================================================= - -class Http3ClientSession; -class Http3ClientStream; - -// -// Bound on how much of the caller's async_write() buffer we copy into write_chunk at a time (see -// Http3ClientStream's write_* members) -- copying is paced by how much nghttp3/ngtcp2 actually -// drains, rather than copying a huge caller buffer (e.g. 50MB) in one synchronous -// allocation+memcpy, mirroring nghttp2's own per-call copy into its frame buffer. -// -inline constexpr size_t kWriteChunkSize = 16 * 1024; - -class Http3ClientStream : public std::enable_shared_from_this -{ -public: - Http3ClientStream(Http3ClientSession& session, int64_t id); - ~Http3ClientStream(); - - int64_t id; - Http3ClientSession& session; - std::string log_prefix; - - // - // Request state, set once by async_submit() before headers are sent. - // - boost::urls::url url; - - // - // Response state (populated by nghttp3 header callbacks). - // - unsigned int status_code = 0; - Fields response_fields; - std::optional content_length; - - // - // The header block as it arrived, buffered so that h3_cb_end_headers() can log it in one go, - // below the status line, instead of one stray line per header as they come in. Only filled - // when debug logging is on, and dropped again as soon as it has been logged. - // - std::vector> received_headers; - bool headers_received = false; - bool response_delivered = false; - client::Request::GetResponseHandler response_handler; - - // - // Request body plumbing (client -> server). Only one async_write() may be active at a time -- - // callers must wait for its handler before issuing another (same contract as e.g. Beast) -- so - // this is flat per-stream state rather than a queue of pending writes. - // - // write_source is the caller's buffer, referenced (not copied) the way asio::async_write - // generally requires -- it must stay valid until write_handler fires (and no longer: nghttp3 - // only ever gets pointers into write_chunk, our own copy, so once the handler has fired -- - // including via cancellation -- the caller's buffer is no longer touched), matching what - // NGHttp2Stream::async_write() already relies on for HTTP/2 via its own `write_buffer` - // reference. - // - // write_chunk is a bounded (<= kWriteChunkSize) slice of write_source, lazily refilled by - // data_reader() as it's drained, rather than copying all of write_source up front. write_offered - // and write_confirmed are tracked separately because nghttp3 may call data_reader() several - // times in a row for the same stream before ever reporting consumption back via - // on_write_consumed() -- e.g. to gather more vecs than fit in a single call. If data_reader() - // just kept re-handing out write_chunk[0, write_chunk.size()) unconditionally (tracking only - // write_confirmed), nghttp3 would treat each repeat offer as *additional*, distinct stream - // bytes and duplicate the content on the wire. write_offered marks how much has already been - // handed to nghttp3 (whether or not it has been placed in a packet yet) so a repeat call sees - // nothing new and gets NGHTTP3_ERR_WOULDBLOCK instead. - // - bool write_active = false; - asio::const_buffer write_source; - size_t write_source_copied = 0; - std::vector write_chunk; - size_t write_offered = 0; - size_t write_confirmed = 0; - bool write_is_eof = false; - WriteHandler write_handler; - uint64_t write_token = 0; - uint64_t next_write_token = 1; - - std::vector> in_flight_writes; // kept alive for the stream's lifetime -- - // ngtcp2 may still need this memory for - // retransmission until acked - bool eof_submitted = false; - bool eof_sent_to_h3 = false; - - // - // Response body plumbing (server -> client). - // - std::deque> pending_read; - asio::const_buffer read_head; - asio::const_buffer incoming; // chunk on_data_chunk() is delivering, not yet taken - bool eof_received = false; - ReadSomeHandler read_handler; - asio::mutable_buffer read_handler_buffer; - bool call_read_handler_active = false; // re-entrancy guard, see call_read_handler() - - // - // Lifecycle. - // - impl::Writer* writer = nullptr; // Http3ClientWriter, the client::Request - impl::Reader* reader = nullptr; // Http3ClientReader, the client::Response - bool closed = false; - - asio::any_io_executor get_executor() const noexcept; - const std::string& logPrefix() const noexcept { return log_prefix; } - - // Data flow into user land (response body). - void on_data_chunk(const uint8_t* data, size_t len); - void on_eof(); - void call_read_handler(); - - // Data flow from user land back to nghttp3 (request body). - void start_write(WriteHandler&& handler, asio::const_buffer buffer); - nghttp3_ssize data_reader(nghttp3_vec* vec, size_t veccnt, uint32_t* pflags); - void on_write_consumed(size_t n); - -private: - void bind_write_cancellation(WriteHandler& handler, uint64_t token); // arms cancellation - void finish_active_write(); // completes the active write once fully handed to nghttp3 - -public: - // async_get_response() - void async_get_response(client::Request::GetResponseHandler&& handler); - void deliver_response(); - - // Called on abrupt stream close/reset before completion. - void fail(boost::system::error_code ec); - - // Called from either reader or writer destructor. - void delete_reader(); - void delete_writer(); - void maybe_close(); -}; - -// ================================================================================================= -// Http3ClientSession: one QUIC connection, one anyhttp Session::Impl. -// ================================================================================================= - -class Http3ClientSession : public Session::Impl -{ -public: - explicit Http3ClientSession(asio::any_io_executor executor); - ~Http3ClientSession() override; - - // - // Session::Impl - // - asio::any_io_executor get_executor() const noexcept override { return executor_; } - void async_submit(SubmitHandler&& handler, boost::urls::url url, const Fields& headers) override; - awaitable do_session(Buffer&& data) override; - void destroy() noexcept override; - - // - // Connect-time setup. Returns 0 on success. - // - int init(asio::ip::udp::endpoint remote); - - // - // Awaited by client::Client::Impl::async_connect() before handing the Session back to the - // caller. Fires once the QUIC handshake has progressed far enough to create the HTTP/3 layer - // (see setup_http3()), or once the connection has failed/closed before getting that far -- in - // which case `ready()` is still false and the caller should synthesize an error. - // - template - auto wait_ready(CompletionToken&& token = CompletionToken()) - { - return ready_signal_.async_wait(std::forward(token)); - } - bool ready() const noexcept { return h3_ != nullptr; } - - const std::string& logPrefix() const noexcept { return log_prefix_; } - nghttp3_conn* h3() const noexcept { return h3_; } - - // - // Returns a shared_ptr, not a raw pointer: callers routinely invoke user handlers on the - // stream they looked up, and those can drop the last reference to it (the coroutine they - // resume destroying its Request/Response), which erases the stream from streams_. Holding - // an owning reference for the duration of the lookup keeps that from becoming a - // use-after-free. - // - std::shared_ptr find_stream(int64_t id); - Http3ClientStream* create_stream(int64_t id); - void erase_stream(int64_t id); - - // Called by Http3ClientWriter to make sure the write loop runs after new data was queued. - void wake_write(); - - // - // Grants the peer more *stream*-level send credit for `n` bytes of response body just - // delivered to the application. Deliberately NOT called as data arrives (see h3_cb_recv_data) - // -- only once call_read_handler() actually hands bytes to the app, so a slow/absent reader - // keeps the peer's flow control window for *this stream* genuinely constrained instead of - // nghttp3 buffering an unbounded backlog in pending_read. Connection-level credit is granted - // eagerly regardless (see h3_cb_recv_data) since it's a pool shared with control/QPACK - // streams nghttp3 manages on its own. - // - void consume_stream(int64_t stream_id, size_t n) - { - if (n == 0) - return; - ngtcp2_conn_extend_max_stream_offset(conn_, stream_id, n); - wake_write(); // a WINDOW_UPDATE-equivalent frame needs to go out - } - - // - // Abort both directions of the stream (RESET_STREAM + STOP_SENDING), the QUIC equivalent of - // HTTP/2's RST_STREAM. nghttp3 learns of the dead write side through the existing - // NGTCP2_ERR_STREAM_SHUT_WR handling in write_streams(). - // - void reset_stream(int64_t stream_id, uint64_t app_error_code) - { - ngtcp2_conn_shutdown_stream(conn_, 0, stream_id, app_error_code); - wake_write(); - } - - // - // ngtcp2 <-> ngtcp2_crypto_ossl bridge. - // - static ngtcp2_conn* get_conn(ngtcp2_crypto_conn_ref* ref) - { - return static_cast(ref->user_data)->conn_; - } - - // - // ngtcp2 callback bridges - // - static int cb_handshake_completed(ngtcp2_conn*, void* user); - static int cb_recv_stream_data(ngtcp2_conn*, uint32_t flags, int64_t stream_id, uint64_t offset, - const uint8_t* data, size_t datalen, void* user, void*); - static int cb_acked_stream_data_offset(ngtcp2_conn*, int64_t stream_id, uint64_t offset, - uint64_t datalen, void* user, void*); - static int cb_stream_close(ngtcp2_conn*, uint32_t flags, int64_t stream_id, - uint64_t app_error_code, void* user, void*); - static void cb_rand(uint8_t* dest, size_t destlen, const ngtcp2_rand_ctx*); - static int cb_get_new_connection_id(ngtcp2_conn*, ngtcp2_cid* cid, uint8_t* token, size_t cidlen, - void* user); - static int cb_remove_connection_id(ngtcp2_conn*, const ngtcp2_cid* cid, void* user); - static int cb_extend_max_local_streams_bidi(ngtcp2_conn*, uint64_t max_streams, void* user); - static int cb_stream_stop_sending(ngtcp2_conn*, int64_t stream_id, uint64_t app_error_code, - void* user, void*); - static int cb_stream_reset(ngtcp2_conn*, int64_t stream_id, uint64_t final_size, - uint64_t app_error_code, void* user, void*); - static int cb_extend_max_stream_data(ngtcp2_conn*, int64_t stream_id, uint64_t max_data, - void* user, void*); - static int cb_recv_rx_key(ngtcp2_conn*, ngtcp2_encryption_level level, void* user); - - // - // nghttp3 callback bridges - // - static int h3_cb_stream_close(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, - void* user, void*); - static int h3_cb_recv_data(nghttp3_conn*, int64_t stream_id, const uint8_t* data, size_t datalen, - void* user, void*); - static int h3_cb_deferred_consume(nghttp3_conn*, int64_t stream_id, size_t nconsumed, void* user, - void*); - static int h3_cb_begin_headers(nghttp3_conn*, int64_t stream_id, void* user, void*); - static int h3_cb_recv_header(nghttp3_conn*, int64_t stream_id, int32_t token, - nghttp3_rcbuf* name, nghttp3_rcbuf* value, uint8_t flags, - void* user, void*); - static int h3_cb_end_headers(nghttp3_conn*, int64_t stream_id, int fin, void* user, void*); - static int h3_cb_end_stream(nghttp3_conn*, int64_t stream_id, void* user, void*); - static int h3_cb_stop_sending(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, - void* user, void*); - static int h3_cb_reset_stream(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, - void* user, void*); - -private: - int setup_http3(); - int on_read(std::span data); - int write_streams(); - void send_udp(std::span data); - void update_timer(); - void arm_timer_from_ngtcp2(); - int handle_expiry(); - int handle_error(int rv); - void close(); - void signal_ready(); - -private: - asio::any_io_executor executor_; - asio::ip::udp::socket socket_; - - ngtcp2_conn* conn_ = nullptr; - ngtcp2_crypto_ossl_ctx* ossl_ctx_ = nullptr; - ngtcp2_crypto_conn_ref conn_ref_{}; - - nghttp3_conn* h3_ = nullptr; - - asio::steady_timer timer_; // ngtcp2 expiry (handshake / idle / PTO) - asio::steady_timer ready_signal_; // sentinel timer, see wait_ready() - ngtcp2_ccerr last_error_{}; - bool closed_ = false; - - std::string log_prefix_; - - std::unordered_map> streams_; -}; - -// ================================================================================================= -// Http3ClientWriter / Http3ClientReader: adapters plugging Http3ClientStream into client::Request -// / client::Response. -// ================================================================================================= - -class Http3ClientWriter : public client::Request::Impl -{ -public: - explicit Http3ClientWriter(Http3ClientStream& s) : stream(&s) { s.writer = this; } - ~Http3ClientWriter() override - { - if (stream) - { - stream->writer = nullptr; - stream->delete_writer(); - } - } - - asio::any_io_executor get_executor() const noexcept override - { - assert(stream); - return stream->get_executor(); - } - - void content_length(std::optional /*len*/) override - { - // Request headers (including any content-length the user set beforehand) are already - // submitted synchronously in Http3ClientSession::async_submit(); nothing to do here. - } - - void async_write(WriteHandler&& handler, asio::const_buffer buffer) override - { - if (!stream || stream->closed) - { - std::move(handler)(errc::make_error_code(errc::connection_reset)); - return; - } - - stream->start_write(std::move(handler), buffer); - } - - // - // Part of the shared Writer-based interface (mirrors server::Response::Impl), but never - // actually invoked for a client::Request -- client.hpp does not expose async_submit() - // publicly. Kept only to satisfy the pure virtual. - // - void async_submit(StatusHandler&& handler, unsigned int /*status_code*/, - const Fields& /*headers*/) override - { - std::move(handler)(boost::system::error_code{}); - } - - void async_get_response(client::Request::GetResponseHandler&& handler) override - { - if (!stream) - { - std::move(handler)(errc::make_error_code(errc::connection_aborted), - client::Response{nullptr}); - return; - } - stream->async_get_response(std::move(handler)); - } - - void detach() override { stream = nullptr; } - - Http3ClientStream* stream; -}; - -class Http3ClientReader : public client::Response::Impl -{ -public: - explicit Http3ClientReader(Http3ClientStream& s) : stream(&s) { s.reader = this; } - ~Http3ClientReader() override - { - if (stream) - { - stream->reader = nullptr; - stream->delete_reader(); - } - } - - asio::any_io_executor get_executor() const noexcept override - { - assert(stream); - return stream->get_executor(); - } - - std::optional content_length() const noexcept override - { - return stream ? stream->content_length : std::nullopt; - } - - unsigned int status_code() const noexcept override { return stream ? stream->status_code : 0; } - - boost::url_view url() const override - { - assert(stream); - return stream->url; - } - - void async_read_some(asio::mutable_buffer buffer, ReadSomeHandler&& handler) override - { - if (!stream) - { - std::move(handler)(boost::beast::http::error::partial_message, 0); - return; - } - if (asio::buffer_size(buffer) == 0) - { - asio::any_completion_executor ex = - asio::get_associated_immediate_executor(handler, stream->get_executor()); - ex.execute([handler = std::move(handler)]() mutable - { std::move(handler)(boost::system::error_code{}, 0); }); - return; - } - - auto cs = asio::get_associated_cancellation_slot(handler); - if (cs.is_connected() && !cs.has_handler()) - { - 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)(errc::make_error_code(errc::operation_canceled), 0); }); - } - }); - } - - assert(!stream->read_handler); - stream->read_handler = std::move(handler); - stream->read_handler_buffer = buffer; - stream->call_read_handler(); - } - - void detach() override { stream = nullptr; } - - Http3ClientStream* stream; -}; - -// ================================================================================================= -// Http3ClientStream implementation -// ================================================================================================= - -Http3ClientStream::Http3ClientStream(Http3ClientSession& s, int64_t stream_id) - : id(stream_id), session(s) -{ - log_prefix = std::format("{}.{}", session.logPrefix(), id); - logd("[{}] stream created", log_prefix); -} - -Http3ClientStream::~Http3ClientStream() -{ - logd("[{}] stream destroyed...", log_prefix); - // A Http3ClientWriter/Http3ClientReader (owned by the user-visible Request/Response) can - // outlive this stream, e.g. when the session tears down streams_ while a suspended coroutine - // still holds one. Detach them so their destructors don't dereference a freed stream. - if (reader) - reader->detach(); - if (writer) - writer->detach(); - if (read_handler) - swap_and_invoke(read_handler, errc::make_error_code(errc::connection_reset), 0); - if (!response_delivered && response_handler) - swap_and_invoke(response_handler, errc::make_error_code(errc::connection_reset), - client::Response{nullptr}); - if (write_active && write_handler) - swap_and_invoke(write_handler, errc::make_error_code(errc::connection_reset)); - logd("[{}] stream destroyed... done", log_prefix); -} - -asio::any_io_executor Http3ClientStream::get_executor() const noexcept -{ - return session.get_executor(); -} - -// ------------------------------------------------------------------------------------------------- - -void Http3ClientStream::on_data_chunk(const uint8_t* data, size_t len) -{ - if (len == 0) - return; - - // - // nghttp3 hands us a view into the packet it is parsing, valid only until this callback - // returns. Offer it to a waiting reader as it stands before copying it anywhere: a handler - // that keeps a read outstanding -- the usual shape -- takes the bytes with a single copy, and - // the vector that would otherwise carry them (a malloc, a copy in, a copy out and a free, per - // QUIC packet, so about fifty of each per 64k of request body) is never created at all. Only - // what the reader could not take is parked for later. - // - auto self = shared_from_this(); // a resumed reader may drop the last reference to this stream - incoming = asio::const_buffer{data, len}; - call_read_handler(); - - if (incoming.size() > 0) - { - auto* rest = static_cast(incoming.data()); - pending_read.emplace_back(rest, rest + incoming.size()); - if (read_head.size() == 0) - read_head = asio::buffer(pending_read.front()); - incoming = {}; - } -} - -void Http3ClientStream::on_eof() -{ - eof_received = true; - call_read_handler(); -} - -void Http3ClientStream::call_read_handler() -{ - // - // swap_and_invoke() below may resume a user coroutine that calls async_read_some() again - // before returning, which re-enters this function. Letting that nested call do real work would - // recurse once per buffered chunk -- with enough data queued up (e.g. after a large backlog - // drains), that blows the C++ stack. Instead, the nested call just re-arms read_handler and - // returns; the outer call's loop below picks it up and keeps going without growing the stack. - // - if (!read_handler || call_read_handler_active) - return; - - // - // The loop below may resume a coroutine that drops the last owning reference to this stream - // (e.g. the Response gets destroyed once EOF is delivered) -- or to the whole Session, when - // that coroutine was the last user of the connection. Keep both alive until this function - // returns: consume_stream() at the bottom dereferences the session, so outliving the stream - // alone is not enough. - // - auto self = shared_from_this(); - auto session_guard = session.shared_from_this(); - - call_read_handler_active = true; - size_t consumed = 0; - while (read_handler) - { - if (read_head.size() > 0 || incoming.size() > 0) - { - // - // Fill the caller's buffer from as many queued chunks as it takes, rather than stopping - // at the end of the first one. Each chunk is what arrived in a single QUIC packet -- a - // little over a kilobyte -- so handing them out one per read turns a 64k body into ~48 - // reads, and a handler that answers every read with a write (an echo) pays a full - // round trip for each of them, because a body write only completes once the peer has - // acknowledged it (see the comment above write_active). - // - auto dest = read_handler_buffer; - size_t copied = 0; - while (dest.size() > 0 && read_head.size() > 0) - { - auto n = asio::buffer_copy(dest, read_head); - dest += n; - read_head += n; - copied += n; - if (read_head.size() == 0) - { - pending_read.pop_front(); - read_head = - pending_read.empty() ? asio::const_buffer{} : asio::buffer(pending_read.front()); - } - } - - // - // ... and last from the chunk being delivered right now, which on_data_chunk() offers - // through `incoming` instead of parking it in a vector of its own first. Queued chunks - // go first: they arrived earlier. - // - if (dest.size() > 0 && incoming.size() > 0) - { - auto n = asio::buffer_copy(dest, incoming); - incoming += n; - copied += n; - } - - consumed += copied; - swap_and_invoke(read_handler, boost::system::error_code{}, copied); - continue; - } - - if (eof_received) - { - swap_and_invoke(read_handler, boost::system::error_code{}, 0); - continue; - } - - if (closed) - { - // - // The stream died before the response body was complete, and this read was issued after - // fail() had already run -- there is nothing left that could ever complete it, so report - // the truncation now rather than leaving it pending forever. - // - swap_and_invoke(read_handler, boost::beast::http::error::partial_message, 0); - continue; - } - - break; - } - call_read_handler_active = false; - - // - // Grant the peer more send credit only for what was actually delivered to the app -- see - // Http3ClientSession::consume_stream() for why this must not happen any earlier. - // - session.consume_stream(id, consumed); -} - -// ------------------------------------------------------------------------------------------------- - -void Http3ClientStream::start_write(WriteHandler&& handler, asio::const_buffer buffer) -{ - auto n = asio::buffer_size(buffer); - const bool is_eof = (n == 0); - logd("[{}] start_write: n={} is_eof={}", log_prefix, n, is_eof); - - // - // Once accepted, the caller's intent to end the request body is final: this is what tells - // delete_writer() the body ended where it was meant to, so it need not reset the stream. An - // earlier cancellation just makes for a shorter body than planned -- legitimate here, and a - // declared content-length is still enforced by the peer. - // - if (is_eof && eof_submitted) - { - // - // The body was already ended. If that FIN is still pending (its handler was detached by - // cancellation, see bind_write_cancellation()), adopt this handler so it completes when the - // FIN actually goes out; otherwise the FIN is long gone and there is nothing left to do. - // - if (write_active && write_is_eof) - { - logd("[{}] start_write: FIN already pending, adopting handler", log_prefix); - bind_write_cancellation(handler, write_token); - write_handler = std::move(handler); - } - else if (handler) - { - asio::any_completion_executor ex = - asio::get_associated_immediate_executor(handler, get_executor()); - ex.execute([handler = std::move(handler)]() mutable - { std::move(handler)(boost::system::error_code{}); }); - } - return; - } - - // - // Only one async_write() may be active at a time -- see the class comment above write_active. - // The re-issued EOF handled above is not an exception to that: it adopts the FIN that is - // already in flight instead of starting a write of its own, and has returned by now. - // - assert(!write_active); - - if (is_eof) - eof_submitted = true; - - const uint64_t token = next_write_token++; - bind_write_cancellation(handler, token); - - write_active = true; - write_source = buffer; // referenced, not copied -- see class comment above write_active - write_source_copied = 0; - write_chunk.clear(); - write_offered = 0; - write_confirmed = 0; - write_is_eof = is_eof; - write_token = token; - write_handler = std::move(handler); - - if (auto h3 = session.h3()) - nghttp3_conn_resume_stream(h3, id); - session.wake_write(); -} - -void Http3ClientStream::bind_write_cancellation(WriteHandler& handler, uint64_t token) -{ - // Nothing to bind for a caller that passed no completion handler. - if (!handler) - return; - - auto cs = asio::get_associated_cancellation_slot(handler); - if (!cs.is_connected() || cs.has_handler()) - return; - - cs.assign([this, token](asio::cancellation_type_t ct) - { - // - // Cancellation completes the write immediately: nghttp3/ngtcp2 only ever hold pointers into - // write_chunk (our own copy), never into the caller's buffer, so the un-copied remainder of - // write_source can simply be abandoned -- same as HTTP/2, where cancelling drops the unsent - // remainder of write_buffer. Bytes already offered to nghttp3 still go out (they can't be - // un-offered), so write_chunk is retired to in_flight_writes to keep that memory alive. The - // caller may issue a fresh async_write() as soon as the handler fires. - // - if (write_token != token || !write_handler) - return; // already completed naturally before the cancellation was delivered - - if (write_is_eof) - { - // - // The body has already been declared ended, and a FIN cannot be un-sent -- it may just - // still be waiting for flow control credit. Detach the handler but leave the write - // active so it still goes out: abandoning it would leave the stream half-open forever, - // with the peer waiting for an end that never comes. - // - logd("[{}] async_write: \x1b[1;31mcancelled\x1b[0m ({}), FIN still pending", log_prefix, - ct); - asio::post(get_executor(), [handler = std::move(write_handler)]() mutable { // - std::move(handler)(errc::make_error_code(errc::operation_canceled)); - }); - return; - } - logd("[{}] async_write: \x1b[1;31m{}\x1b[0m ({})", log_prefix, "cancelled", ct); - if (!write_chunk.empty()) - in_flight_writes.emplace_back(std::move(write_chunk)); - write_chunk.clear(); // moved-from - write_active = false; - // make sure to post this -- otherwise "MAIN COROUTINE DID NOT COMPLETE" happens - asio::post(get_executor(), [handler = std::move(write_handler)]() mutable { // - std::move(handler)(errc::make_error_code(errc::operation_canceled)); - }); - }); -} - -nghttp3_ssize Http3ClientStream::data_reader(nghttp3_vec* vec, size_t veccnt, uint32_t* pflags) -{ - if (veccnt == 0) - return 0; - - if (!write_active) - return NGHTTP3_ERR_WOULDBLOCK; - - if (write_offered < write_chunk.size()) - { - vec[0].base = write_chunk.data() + write_offered; - vec[0].len = write_chunk.size() - write_offered; - write_offered = write_chunk.size(); // don't re-offer these bytes on a repeat call -- see - // class comment above write_active - return 1; - } - - // - // Current chunk fully offered. If it hasn't been confirmed yet (on_write_consumed()), there's - // nothing new until that happens -- see class comment above write_active on why we can't just - // carve off the next slice of write_source early. - // - if (write_confirmed < write_chunk.size()) - return NGHTTP3_ERR_WOULDBLOCK; - - // - // The current chunk is fully drained; retire it (ngtcp2 may still need this exact memory for - // retransmission until acked) and pull the next bounded slice out of write_source, if any. - // - if (!write_chunk.empty()) - in_flight_writes.emplace_back(std::move(write_chunk)); - - const size_t remaining = asio::buffer_size(write_source) - write_source_copied; - if (remaining > 0) - { - const size_t take = std::min(remaining, kWriteChunkSize); - auto* src = static_cast(write_source.data()) + write_source_copied; - write_chunk.assign(src, src + take); - write_source_copied += take; - write_offered = write_chunk.size(); - write_confirmed = 0; - vec[0].base = write_chunk.data(); - vec[0].len = write_chunk.size(); - return 1; - } - - // - // Nothing left in write_source either. If this is the EOF marker (write_source is always - // empty), retire it now -- a FIN carries no stream bytes, so there is nothing for - // on_write_consumed() to report back. A non-EOF write with nothing left to offer is instead - // retired from on_write_consumed() once its last chunk is confirmed (see there). - // - if (!write_is_eof) - return NGHTTP3_ERR_WOULDBLOCK; - - *pflags |= NGHTTP3_DATA_FLAG_EOF; - eof_sent_to_h3 = true; - finish_active_write(); - return 0; -} - -void Http3ClientStream::on_write_consumed(size_t n) -{ - // - // n is the number of bytes of *stream* data ngtcp2 just committed to a packet, which also - // includes the HTTP/3 HEADERS frame nghttp3 sends ahead of any body -- e.g. the very first - // write_streams() call after async_submit() drains the headers before there's an active write - // yet. Only attribute bytes once there is an active, non-EOF write to charge them against; - // clamp defensively in case a single packet still straddles the header/body boundary. - // - if (n == 0 || !write_active || write_is_eof) - return; - - n = std::min(n, write_chunk.size() - write_confirmed); - write_confirmed += n; - - if (write_confirmed < write_chunk.size()) - return; - - // The write is fully done once its current chunk is confirmed and there is no more of - // write_source left to carve into further chunks -- data_reader() advances write_chunk/ - // write_source_copied otherwise, so this is the terminal state. - if (write_source_copied == asio::buffer_size(write_source)) - { - finish_active_write(); - return; - } - - // - // There is more of write_source to carve into chunks, but nghttp3 may have asked for data - // while this chunk was offered and still unconfirmed, in which case data_reader() answered - // NGHTTP3_ERR_WOULDBLOCK -- and a blocked stream is never polled again until it is explicitly - // resumed. Now that the chunk is confirmed, there is something new to hand out, so unblock - // the stream. Without this, any single async_write() larger than kWriteChunkSize stalls here - // forever, with the response body truncated and no FIN. - // - if (auto h3 = session.h3()) - nghttp3_conn_resume_stream(h3, id); - session.wake_write(); -} - -void Http3ClientStream::finish_active_write() -{ - assert(write_active); - - // - // ngtcp2 may still need this memory for retransmission until the bytes are acked; rather than - // tracking acks precisely, keep every chunk alive for the life of the stream (in_flight_writes - // is freed on stream destruction). - // - if (!write_chunk.empty()) - in_flight_writes.emplace_back(std::move(write_chunk)); - write_chunk.clear(); // moved-from - auto handler = std::move(write_handler); - write_active = false; - - if (handler) - swap_and_invoke(handler, boost::system::error_code{}); -} - -// ------------------------------------------------------------------------------------------------- - -void Http3ClientStream::async_get_response(client::Request::GetResponseHandler&& handler) -{ - if (response_delivered) - { - auto ec = asio::error::basic_errors::already_started; - asio::any_completion_executor ex = - asio::get_associated_immediate_executor(handler, get_executor()); - ex.execute([handler = std::move(handler), ec]() mutable - { std::move(handler)(ec, client::Response{nullptr}); }); - return; - } - - auto cs = handler.get_cancellation_slot(); - if (cs.is_connected()) - { - 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 - { - std::move(handler)(errc::make_error_code(errc::operation_canceled), - client::Response{nullptr}); - }); - } - }); - } - - response_handler = std::move(handler); - deliver_response(); -} - -void Http3ClientStream::deliver_response() -{ - if (!headers_received || !response_handler) - return; - - response_delivered = true; - auto response = client::Response{std::make_unique(*this)}; - swap_and_invoke(response_handler, boost::system::error_code{}, std::move(response)); -} - -void Http3ClientStream::fail(boost::system::error_code ec) -{ - // read_handler/response_handler may run synchronously and drop the last owning reference to - // this stream (e.g. the coroutine they resume destroys its Request/Response), reentrantly - // erasing it from Http3ClientSession::streams_. Keep it alive until fail() itself returns. - auto self = shared_from_this(); - closed = true; - if (read_handler) - { - // - // The stream died before the response body was complete. What the reader cares about is - // that it will never see the rest of it, not which QUIC error code carried that news -- - // report the truncation, matching what the HTTP/2 side delivers for a stream closing early. - // - auto read_ec = (ec && !eof_received) ? boost::beast::http::error::partial_message : ec; - swap_and_invoke(read_handler, read_ec, 0); - } - if (!headers_received && !response_delivered && response_handler) - { - response_delivered = true; - // A stream closing gracefully (ec success, e.g. NGHTTP3_H3_NO_ERROR) still means no - // response ever arrived if headers were never received -- never report success with a - // null Response. - swap_and_invoke(response_handler, ec ? ec : boost::beast::http::error::end_of_stream, - client::Response{nullptr}); - } - if (write_active && write_handler) - swap_and_invoke(write_handler, ec); - maybe_close(); -} - -// ------------------------------------------------------------------------------------------------- - -void Http3ClientStream::delete_reader() -{ - auto self = shared_from_this(); // see delete_writer() - pending_read.clear(); - read_head = {}; - incoming = {}; - maybe_close(); -} - -void Http3ClientStream::delete_writer() -{ - // - // reset_stream()/fail() below can run handlers that drop the last reference to this stream, - // erasing it from the session -- keep it alive until this function returns. - // - auto self = shared_from_this(); - - // - // Nothing to finalize on a stream ngtcp2 has already torn down (peer reset it, or we did): - // there is nothing left to reset, and submitting anything would leave nghttp3 holding data for - // a stream that no longer exists, which it would then offer for sending forever. - // - if (closed) - { - logd("[{}] delete_writer: stream already closed", log_prefix); - maybe_close(); - return; - } - - if (!eof_submitted) - { - // - // The Request was dropped without ever ending the body (async_write({})), so wherever it - // stopped is not where it was meant to stop. Sending a FIN here would present that partial - // upload to the server as a complete one -- reset the stream instead, the way the HTTP/2 - // side submits RST_STREAM once its writer is gone with no EOF submitted, and fail the local - // read the same way nghttp2's stream close does, with partial_message. - // - logw("[{}] delete_writer: request body never ended, resetting stream", log_prefix); - session.reset_stream(id, NGHTTP3_H3_REQUEST_CANCELLED); - fail(boost::beast::http::error::partial_message); - } - maybe_close(); -} - -void Http3ClientStream::maybe_close() -{ - if (reader || writer) - return; - if (!closed) - return; - session.erase_stream(id); -} - -// ================================================================================================= -// Http3ClientSession implementation -// ================================================================================================= - -Http3ClientSession::Http3ClientSession(asio::any_io_executor executor) - : executor_(executor), socket_(executor), timer_(executor), ready_signal_(executor) -{ - ngtcp2_ccerr_default(&last_error_); - // Sentinel timers: expires_at(max) means "not yet"; a wait completes once moved to "min". - ready_signal_.expires_at(asio::steady_timer::time_point::max()); - logi("Http3ClientSession: ctor"); -} - -Http3ClientSession::~Http3ClientSession() -{ - timer_.cancel(); - ready_signal_.cancel(); - streams_.clear(); - if (h3_) - nghttp3_conn_del(h3_); - if (conn_) - ngtcp2_conn_del(conn_); - if (ossl_ctx_) - { - if (auto ssl = ngtcp2_crypto_ossl_ctx_get_ssl(ossl_ctx_)) - { - SSL_set_app_data(ssl, nullptr); - SSL_free(ssl); - } - ngtcp2_crypto_ossl_ctx_del(ossl_ctx_); - } - logi("Http3ClientSession: dtor"); -} - -// ------------------------------------------------------------------------------------------------- - -int Http3ClientSession::init(asio::ip::udp::endpoint remote) -{ - boost::system::error_code ec; - socket_.open(remote.protocol(), ec); - if (ec) - { - loge("Http3ClientSession::init: open: {}", ec.message()); - return -1; - } - socket_.connect(remote, ec); - if (ec) - { - loge("Http3ClientSession::init: connect: {}", ec.message()); - return -1; - } - socket_.non_blocking(true, ec); - - auto local = socket_.local_endpoint(ec); - if (ec) - { - loge("Http3ClientSession::init: local_endpoint: {}", ec.message()); - return -1; - } - - log_prefix_ = std::format("h3c:{}", ngtcp2::util::straddr(remote.data(), remote.size())); - - ngtcp2_cid scid{}; - scid.datalen = 17; - if (RAND_bytes(scid.data, static_cast(scid.datalen)) != 1) - { - loge("[{}] init: RAND_bytes for SCID failed", log_prefix_); - return -1; - } - ngtcp2_cid dcid{}; - dcid.datalen = 18; - if (RAND_bytes(dcid.data, static_cast(dcid.datalen)) != 1) - { - loge("[{}] init: RAND_bytes for DCID failed", log_prefix_); - return -1; - } - - ngtcp2_callbacks callbacks{}; - callbacks.client_initial = ngtcp2_crypto_client_initial_cb; - callbacks.recv_crypto_data = ngtcp2_crypto_recv_crypto_data_cb; - callbacks.handshake_completed = &Http3ClientSession::cb_handshake_completed; - callbacks.encrypt = ngtcp2_crypto_encrypt_cb; - callbacks.decrypt = ngtcp2_crypto_decrypt_cb; - callbacks.hp_mask = ngtcp2_crypto_hp_mask_cb; - callbacks.recv_stream_data = &Http3ClientSession::cb_recv_stream_data; - callbacks.acked_stream_data_offset = &Http3ClientSession::cb_acked_stream_data_offset; - callbacks.stream_close = &Http3ClientSession::cb_stream_close; - callbacks.recv_retry = ngtcp2_crypto_recv_retry_cb; - callbacks.extend_max_local_streams_bidi = &Http3ClientSession::cb_extend_max_local_streams_bidi; - callbacks.rand = &Http3ClientSession::cb_rand; - callbacks.get_new_connection_id = &Http3ClientSession::cb_get_new_connection_id; - callbacks.remove_connection_id = &Http3ClientSession::cb_remove_connection_id; - callbacks.update_key = ngtcp2_crypto_update_key_cb; - callbacks.stream_stop_sending = &Http3ClientSession::cb_stream_stop_sending; - callbacks.stream_reset = &Http3ClientSession::cb_stream_reset; - callbacks.extend_max_stream_data = &Http3ClientSession::cb_extend_max_stream_data; - callbacks.delete_crypto_aead_ctx = ngtcp2_crypto_delete_crypto_aead_ctx_cb; - callbacks.delete_crypto_cipher_ctx = ngtcp2_crypto_delete_crypto_cipher_ctx_cb; - callbacks.get_path_challenge_data = ngtcp2_crypto_get_path_challenge_data_cb; - callbacks.version_negotiation = ngtcp2_crypto_version_negotiation_cb; - callbacks.recv_rx_key = &Http3ClientSession::cb_recv_rx_key; - - ngtcp2_settings settings; - ngtcp2_settings_default(&settings); - settings.initial_ts = ngtcp2::util::timestamp(); - - // - // See the server-side counterpart: ngtcp2 does the full frame formatting before calling this, - // so only install it when trace logging is actually enabled. - // - if (spdlog::default_logger_raw()->should_log(spdlog::level::trace)) - settings.log_printf = &ngtcp2_log_printf; - - ngtcp2_transport_params params; - ngtcp2_transport_params_default(¶ms); - params.initial_max_stream_data_bidi_local = 256_k; - params.initial_max_stream_data_bidi_remote = 256_k; - params.initial_max_stream_data_uni = 256_k; - params.initial_max_data = 1_m; - params.initial_max_streams_bidi = 100; - params.initial_max_streams_uni = 3; - params.max_idle_timeout = std::chrono::nanoseconds(30s).count(); - - ngtcp2_path path{ - {local.data(), static_cast(local.size())}, - {remote.data(), static_cast(remote.size())}, - nullptr, - }; - - if (auto rv = ngtcp2_conn_client_new(&conn_, &dcid, &scid, &path, NGTCP2_PROTO_VER_V1, - &callbacks, &settings, ¶ms, nullptr, this); - rv != 0) - { - loge("[{}] ngtcp2_conn_client_new: {}", log_prefix_, ngtcp2_strerror(rv)); - return -1; - } - - auto* ssl = SSL_new(tls_context().ctx); - if (!ssl) - { - loge("[{}] SSL_new failed", log_prefix_); - return -1; - } - - conn_ref_.get_conn = &Http3ClientSession::get_conn; - conn_ref_.user_data = this; - SSL_set_app_data(ssl, &conn_ref_); - SSL_set_connect_state(ssl); - - if (ngtcp2_crypto_ossl_configure_client_session(ssl) != 0) - { - loge("[{}] ngtcp2_crypto_ossl_configure_client_session failed", log_prefix_); - SSL_free(ssl); - return -1; - } - - if (ngtcp2_crypto_ossl_ctx_new(&ossl_ctx_, ssl) != 0) - { - loge("[{}] ngtcp2_crypto_ossl_ctx_new failed", log_prefix_); - SSL_free(ssl); - return -1; - } - - ngtcp2_conn_set_tls_native_handle(conn_, ossl_ctx_); - - logi("[{}] connecting, scid={}", log_prefix_, ngtcp2::util::format_hex(scid.data, scid.datalen)); - return 0; -} - -// ------------------------------------------------------------------------------------------------- - -awaitable Http3ClientSession::do_session(Buffer&&) -{ - if (write_streams() != 0) - { - signal_ready(); - co_return; - } - update_timer(); - - std::array buf; - for (;;) - { - boost::system::error_code ec; - size_t n = - co_await socket_.async_receive(asio::buffer(buf), redirect_error(use_awaitable, ec)); - if (ec) - { - if (ec != asio::error::operation_aborted) - logw("[{}] receive: {}", log_prefix_, ec.message()); - break; - } - - if (on_read({buf.data(), n}) != 0) - break; // handle_error() already tore things down. - - // - // close() may have run from inside on_read(): handing a response chunk or EOF to the - // application resumes its coroutine, which may drop the last reference to the Session - // right there. Its socket_.cancel() then found no receive pending -- we are between two - // of them -- so nothing would stop us from arming a fresh one that no peer will ever - // complete. The server, already draining because it got our CONNECTION_CLOSE, does not - // even answer it. - // - if (closed_) - break; - } - - // - // The receive loop only ever ends because this connection is over: the socket errored out (ICMP - // reporting the peer's port unreachable, say), close() cancelled it, or on_read() hit a protocol - // error. Tear the session down in every case -- nothing else is running that could ever complete - // the requests still waiting on it, so leaving them pending hangs them forever. close() is - // idempotent, so the paths that already tore things down are unaffected, and it signals ready to - // unblock a waiter whose handshake never finished. - // - close(); - co_return; -} - -void Http3ClientSession::destroy() noexcept { close(); } - -void Http3ClientSession::close() -{ - if (std::exchange(closed_, true)) - return; - - // - // The connection is going away (user-initiated destroy(), or a protocol/transport error via - // handle_error()) -- fail every request that hasn't completed yet instead of leaving its - // async_get_response()/async_read_some() hanging forever. Streams may erase themselves from - // streams_ as a side effect of fail() (via maybe_close()), so snapshot first. - // - std::vector> streams; - streams.reserve(streams_.size()); - for (auto& [id, stream] : streams_) - streams.push_back(stream); - for (auto& stream : streams) - stream->fail(errc::make_error_code(errc::connection_reset)); - - // - // An idle-timed-out (or dropped) connection is discarded silently: RFC 9000 has no - // CONNECTION_CLOSE for it, and there is nobody left listening anyway -- writing one would - // just put a packet on a path whose peer has been gone for a full idle period. - // - const bool silent = last_error_.type == NGTCP2_CCERR_TYPE_IDLE_CLOSE || - last_error_.type == NGTCP2_CCERR_TYPE_DROP_CONN; - - if (conn_ && !silent && !ngtcp2_conn_in_closing_period(conn_) && - !ngtcp2_conn_in_draining_period(conn_)) - { - std::array closebuf; - ngtcp2_path_storage ps; - ngtcp2_pkt_info pi; - ngtcp2_path_storage_zero(&ps); - - auto nwrite = - ngtcp2_conn_write_connection_close(conn_, &ps.path, &pi, closebuf.data(), closebuf.size(), - &last_error_, ngtcp2::util::timestamp()); - if (nwrite > 0) - send_udp({closebuf.data(), static_cast(nwrite)}); - } - - boost::system::error_code ec; - socket_.cancel(ec); - timer_.cancel(); - signal_ready(); -} - -void Http3ClientSession::signal_ready() -{ - ready_signal_.expires_at(asio::steady_timer::time_point::min()); -} - -// ------------------------------------------------------------------------------------------------- - -std::shared_ptr Http3ClientSession::find_stream(int64_t id) -{ - auto it = streams_.find(id); - return it == streams_.end() ? nullptr : it->second; -} - -Http3ClientStream* Http3ClientSession::create_stream(int64_t id) -{ - auto [it, inserted] = streams_.emplace(id, std::make_shared(*this, id)); - return it->second.get(); -} - -void Http3ClientSession::erase_stream(int64_t id) { streams_.erase(id); } - -void Http3ClientSession::wake_write() -{ - // Capture a weak_ptr, not shared_from_this(): wake_write() can be reached from a - // Reader/Writer destructor that runs as part of *this* session's own teardown, at which - // point shared_from_this() would throw bad_weak_ptr. See the matching comment in the - // server's Http3Session::wake_write() (server_impl_udp.cpp). - asio::post(get_executor(), [self = weak_from_this()] - { - auto session = std::static_pointer_cast(self.lock()); - if (!session || session->closed_) - return; - if (session->write_streams() == 0) - session->update_timer(); - }); -} - -// ------------------------------------------------------------------------------------------------- - -void Http3ClientSession::send_udp(std::span data) -{ - boost::system::error_code ec; - socket_.send(asio::buffer(data.data(), data.size()), 0, ec); - if (ec && ec != asio::error::would_block && ec != asio::error::try_again) - logw("[{}] send: {}", log_prefix_, ec.message()); -} - -int Http3ClientSession::on_read(std::span data) -{ - logd("[{}] on_read: {} bytes", log_prefix_, data.size()); - - ngtcp2_pkt_info pi{}; - auto* path = ngtcp2_conn_get_path(conn_); - auto rv = - ngtcp2_conn_read_pkt(conn_, path, &pi, data.data(), data.size(), ngtcp2::util::timestamp()); - if (rv != 0) - { - if (rv == NGTCP2_ERR_DRAINING) - logd("[{}] ngtcp2_conn_read_pkt: draining", log_prefix_); - else - { - logw("[{}] ngtcp2_conn_read_pkt: {}", log_prefix_, ngtcp2_strerror(rv)); - if (rv == NGTCP2_ERR_CRYPTO && !last_error_.error_code) - ngtcp2_ccerr_set_tls_alert(&last_error_, ngtcp2_conn_get_tls_alert(conn_), nullptr, 0); - else if (!last_error_.error_code) - ngtcp2_ccerr_set_liberr(&last_error_, rv, nullptr, 0); - } - return handle_error(rv); - } - - if (auto wrv = write_streams(); wrv != 0) - return wrv; - - update_timer(); - return 0; -} - -// ------------------------------------------------------------------------------------------------- - -int Http3ClientSession::write_streams() -{ - if (ngtcp2_conn_in_closing_period(conn_) || ngtcp2_conn_in_draining_period(conn_)) - return 0; - - std::array buf; - ngtcp2_path_storage ps; - ngtcp2_pkt_info pi; - ngtcp2_path_storage_zero(&ps); - - std::array vec; - int64_t shut_down_stream = -1; // see NGTCP2_ERR_STREAM_NOT_FOUND below - - for (;;) - { - int64_t stream_id = -1; - int fin = 0; - nghttp3_ssize sveccnt = 0; - - if (h3_ && ngtcp2_conn_get_max_data_left(conn_)) - { - sveccnt = nghttp3_conn_writev_stream(h3_, &stream_id, &fin, vec.data(), vec.size()); - if (sveccnt < 0) - { - loge("[{}] nghttp3_conn_writev_stream: {}", log_prefix_, - nghttp3_strerror(static_cast(sveccnt))); - ngtcp2_ccerr_set_application_error( - &last_error_, nghttp3_err_infer_quic_app_error_code(static_cast(sveccnt)), - nullptr, 0); - return handle_error(NGTCP2_ERR_CALLBACK_FAILURE); - } - } - - ngtcp2_ssize ndatalen; - uint32_t flags = NGTCP2_WRITE_STREAM_FLAG_MORE; - if (fin) - flags |= NGTCP2_WRITE_STREAM_FLAG_FIN; - - auto nwrite = - ngtcp2_conn_writev_stream(conn_, &ps.path, &pi, buf.data(), buf.size(), &ndatalen, flags, - stream_id, reinterpret_cast(vec.data()), - static_cast(sveccnt), ngtcp2::util::timestamp()); - - if (nwrite < 0) - { - switch (nwrite) - { - case NGTCP2_ERR_STREAM_DATA_BLOCKED: - if (h3_ && stream_id >= 0) - nghttp3_conn_block_stream(h3_, stream_id); - continue; - case NGTCP2_ERR_STREAM_SHUT_WR: - if (h3_ && stream_id >= 0) - nghttp3_conn_shutdown_stream_write(h3_, stream_id); - continue; - case NGTCP2_ERR_STREAM_NOT_FOUND: - // - // ngtcp2 has already torn the stream down (the peer reset it, or we did) while - // nghttp3 still had request data queued for it. That's a dead stream, not a dead - // connection -- tell nghttp3 so it stops offering it and keep serving the others. - // Should nghttp3 offer the same stream again anyway, stop writing rather than - // spinning here forever. - // - if (h3_ && stream_id >= 0 && stream_id != shut_down_stream) - { - logw("[{}] write_streams: stream {} is gone, shutting down its write side", - log_prefix_, stream_id); - nghttp3_conn_shutdown_stream_write(h3_, stream_id); - nghttp3_conn_block_stream(h3_, stream_id); - shut_down_stream = stream_id; - continue; - } - return 0; - case NGTCP2_ERR_WRITE_MORE: - if (h3_ && stream_id >= 0 && ndatalen > 0) - { - if (auto rv = - nghttp3_conn_add_write_offset(h3_, stream_id, static_cast(ndatalen)); - rv != 0) - { - loge("[{}] nghttp3_conn_add_write_offset: {}", log_prefix_, nghttp3_strerror(rv)); - return handle_error(NGTCP2_ERR_CALLBACK_FAILURE); - } - if (auto s = find_stream(stream_id)) - s->on_write_consumed(static_cast(ndatalen)); - } - continue; - default: - loge("[{}] ngtcp2_conn_writev_stream: {}", log_prefix_, - ngtcp2_strerror(static_cast(nwrite))); - ngtcp2_ccerr_set_liberr(&last_error_, static_cast(nwrite), nullptr, 0); - return handle_error(static_cast(nwrite)); - } - } - - if (ndatalen > 0 && h3_ && stream_id >= 0) - { - if (auto rv = nghttp3_conn_add_write_offset(h3_, stream_id, static_cast(ndatalen)); - rv != 0) - { - loge("[{}] nghttp3_conn_add_write_offset: {}", log_prefix_, nghttp3_strerror(rv)); - return handle_error(NGTCP2_ERR_CALLBACK_FAILURE); - } - if (auto s = find_stream(stream_id)) - s->on_write_consumed(static_cast(ndatalen)); - } - - if (nwrite == 0) - { - ngtcp2_conn_update_pkt_tx_time(conn_, ngtcp2::util::timestamp()); - return 0; - } - - send_udp({buf.data(), static_cast(nwrite)}); - } -} - -// ------------------------------------------------------------------------------------------------- - -void Http3ClientSession::update_timer() { arm_timer_from_ngtcp2(); } - -void Http3ClientSession::arm_timer_from_ngtcp2() -{ - if (closed_) - return; - - auto expiry = ngtcp2_conn_get_expiry(conn_); - if (expiry == UINT64_MAX) - { - timer_.cancel(); - return; - } - - auto now = ngtcp2::util::timestamp(); - asio::steady_timer::duration delay = - 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) - { - if (ec) - return; - if (auto session = std::static_pointer_cast(self.lock())) - session->handle_expiry(); - }); -} - -int Http3ClientSession::handle_expiry() -{ - auto now = ngtcp2::util::timestamp(); - if (auto rv = ngtcp2_conn_handle_expiry(conn_, now); rv != 0) - { - // - // NGTCP2_ERR_IDLE_CLOSE is how a connection whose peer stopped talking ends: a normal - // end of life, not a failure worth a warning. close() then discards it silently, see - // there. - // - if (rv == NGTCP2_ERR_IDLE_CLOSE) - logi("[{}] idle timeout, dropping connection", log_prefix_); - else - logw("[{}] ngtcp2_conn_handle_expiry: {}", log_prefix_, ngtcp2_strerror(rv)); - - ngtcp2_ccerr_set_liberr(&last_error_, rv, nullptr, 0); - return handle_error(rv); - } - if (auto rv = write_streams(); rv != 0) - return rv; - update_timer(); - return 0; -} - -// ------------------------------------------------------------------------------------------------- - -int Http3ClientSession::handle_error(int /*rv*/) -{ - close(); - return -1; -} - -// ------------------------------------------------------------------------------------------------- -// ngtcp2 callback implementations -// ------------------------------------------------------------------------------------------------- - -int Http3ClientSession::cb_handshake_completed(ngtcp2_conn*, void* user) -{ - auto self = static_cast(user); - logi("[{}] TLS handshake completed: {}", self->log_prefix_, - tls_handshake_info(ngtcp2_crypto_ossl_ctx_get_ssl(self->ossl_ctx_))); - if (!self->h3_ && self->setup_http3() != 0) - return NGTCP2_ERR_CALLBACK_FAILURE; - return 0; -} - -int Http3ClientSession::cb_recv_stream_data(ngtcp2_conn*, uint32_t flags, int64_t stream_id, - uint64_t offset, const uint8_t* data, size_t datalen, - void* user, void*) -{ - auto self = static_cast(user); - logd("[{}] cb_recv_stream_data: stream={} offset={} datalen={} fin={}", self->log_prefix_, - stream_id, offset, datalen, !!(flags & NGTCP2_STREAM_DATA_FLAG_FIN)); - if (!self->h3_) - return 0; - - auto nread = nghttp3_conn_read_stream(self->h3_, stream_id, data, datalen, - (flags & NGTCP2_STREAM_DATA_FLAG_FIN) ? 1 : 0); - if (nread < 0) - { - loge("[{}] nghttp3_conn_read_stream({}): {}", self->log_prefix_, stream_id, - nghttp3_strerror(static_cast(nread))); - ngtcp2_ccerr_set_application_error( - &self->last_error_, nghttp3_err_infer_quic_app_error_code(static_cast(nread)), - nullptr, 0); - return NGTCP2_ERR_CALLBACK_FAILURE; - } - - ngtcp2_conn_extend_max_stream_offset(self->conn_, stream_id, static_cast(nread)); - ngtcp2_conn_extend_max_offset(self->conn_, static_cast(nread)); - return 0; -} - -int Http3ClientSession::cb_acked_stream_data_offset(ngtcp2_conn*, int64_t stream_id, - uint64_t /*offset*/, uint64_t datalen, - void* user, void*) -{ - auto self = static_cast(user); - if (!self->h3_) - return 0; - if (auto rv = nghttp3_conn_add_ack_offset(self->h3_, stream_id, datalen); rv != 0) - { - loge("[{}] nghttp3_conn_add_ack_offset: {}", self->log_prefix_, nghttp3_strerror(rv)); - return NGTCP2_ERR_CALLBACK_FAILURE; - } - return 0; -} - -int Http3ClientSession::cb_stream_close(ngtcp2_conn*, uint32_t flags, int64_t stream_id, - uint64_t app_error_code, void* user, void*) -{ - auto self = static_cast(user); - if (!(flags & NGTCP2_STREAM_CLOSE_FLAG_APP_ERROR_CODE_SET)) - app_error_code = NGHTTP3_H3_NO_ERROR; - if (self->h3_) - { - if (auto rv = nghttp3_conn_close_stream(self->h3_, stream_id, app_error_code); rv != 0) - { - if (rv == NGHTTP3_ERR_STREAM_NOT_FOUND) - return 0; - loge("[{}] nghttp3_conn_close_stream({}): {}", self->log_prefix_, stream_id, - nghttp3_strerror(rv)); - return NGTCP2_ERR_CALLBACK_FAILURE; - } - } - return 0; -} - -void Http3ClientSession::cb_rand(uint8_t* dest, size_t destlen, const ngtcp2_rand_ctx*) -{ - if (RAND_bytes(dest, static_cast(destlen)) != 1) - std::memset(dest, 0, destlen); -} - -int Http3ClientSession::cb_get_new_connection_id(ngtcp2_conn*, ngtcp2_cid* cid, uint8_t* token, - size_t cidlen, void* /*user*/) -{ - if (RAND_bytes(cid->data, static_cast(cidlen)) != 1) - return NGTCP2_ERR_CALLBACK_FAILURE; - cid->datalen = cidlen; - if (RAND_bytes(token, NGTCP2_STATELESS_RESET_TOKENLEN) != 1) - return NGTCP2_ERR_CALLBACK_FAILURE; - return 0; -} - -int Http3ClientSession::cb_remove_connection_id(ngtcp2_conn*, const ngtcp2_cid*, void* /*user*/) -{ - return 0; -} - -int Http3ClientSession::cb_extend_max_local_streams_bidi(ngtcp2_conn*, uint64_t /*max_streams*/, - void* /*user*/) -{ - return 0; -} - -int Http3ClientSession::cb_stream_stop_sending(ngtcp2_conn*, int64_t stream_id, uint64_t /*ec*/, - void* user, void*) -{ - auto self = static_cast(user); - if (!self->h3_) - return 0; - if (auto rv = nghttp3_conn_shutdown_stream_read(self->h3_, stream_id); rv != 0) - { - loge("[{}] nghttp3_conn_shutdown_stream_read({}): {}", self->log_prefix_, stream_id, - nghttp3_strerror(rv)); - return NGTCP2_ERR_CALLBACK_FAILURE; - } - return 0; -} - -int Http3ClientSession::cb_stream_reset(ngtcp2_conn*, int64_t stream_id, uint64_t /*final_size*/, - uint64_t /*ec*/, void* user, void*) -{ - auto self = static_cast(user); - if (!self->h3_) - return 0; - if (auto rv = nghttp3_conn_shutdown_stream_read(self->h3_, stream_id); rv != 0) - { - loge("[{}] nghttp3_conn_shutdown_stream_read({}): {}", self->log_prefix_, stream_id, - nghttp3_strerror(rv)); - return NGTCP2_ERR_CALLBACK_FAILURE; - } - return 0; -} - -int Http3ClientSession::cb_extend_max_stream_data(ngtcp2_conn*, int64_t stream_id, - uint64_t /*max_data*/, void* user, void*) -{ - auto self = static_cast(user); - if (!self->h3_) - return 0; - if (auto rv = nghttp3_conn_unblock_stream(self->h3_, stream_id); rv != 0) - { - loge("[{}] nghttp3_conn_unblock_stream({}): {}", self->log_prefix_, stream_id, - nghttp3_strerror(rv)); - return NGTCP2_ERR_CALLBACK_FAILURE; - } - return 0; -} - -int Http3ClientSession::cb_recv_rx_key(ngtcp2_conn*, ngtcp2_encryption_level level, void* user) -{ - if (level != NGTCP2_ENCRYPTION_LEVEL_1RTT) - return 0; - auto self = static_cast(user); - if (!self->h3_ && self->setup_http3() != 0) - return NGTCP2_ERR_CALLBACK_FAILURE; - return 0; -} - -// ------------------------------------------------------------------------------------------------- - -int Http3ClientSession::setup_http3() -{ - if (h3_) - return 0; - - nghttp3_callbacks h3cb{}; - h3cb.stream_close = &Http3ClientSession::h3_cb_stream_close; - h3cb.recv_data = &Http3ClientSession::h3_cb_recv_data; - h3cb.deferred_consume = &Http3ClientSession::h3_cb_deferred_consume; - h3cb.begin_headers = &Http3ClientSession::h3_cb_begin_headers; - h3cb.recv_header = &Http3ClientSession::h3_cb_recv_header; - h3cb.end_headers = &Http3ClientSession::h3_cb_end_headers; - h3cb.end_stream = &Http3ClientSession::h3_cb_end_stream; - h3cb.stop_sending = &Http3ClientSession::h3_cb_stop_sending; - h3cb.reset_stream = &Http3ClientSession::h3_cb_reset_stream; - - nghttp3_settings settings; - nghttp3_settings_default(&settings); - settings.qpack_max_dtable_capacity = 4096; - settings.qpack_blocked_streams = 100; - - if (auto rv = nghttp3_conn_client_new(&h3_, &h3cb, &settings, nullptr, this); rv != 0) - { - loge("[{}] nghttp3_conn_client_new: {}", log_prefix_, nghttp3_strerror(rv)); - return -1; - } - - int64_t ctrl_stream_id = -1; - if (auto rv = ngtcp2_conn_open_uni_stream(conn_, &ctrl_stream_id, nullptr); rv != 0) - { - loge("[{}] open control stream: {}", log_prefix_, ngtcp2_strerror(rv)); - return -1; - } - if (auto rv = nghttp3_conn_bind_control_stream(h3_, ctrl_stream_id); rv != 0) - { - loge("[{}] nghttp3_conn_bind_control_stream: {}", log_prefix_, nghttp3_strerror(rv)); - return -1; - } - - int64_t qpack_enc_stream_id = -1; - int64_t qpack_dec_stream_id = -1; - if (ngtcp2_conn_open_uni_stream(conn_, &qpack_enc_stream_id, nullptr) != 0 || - ngtcp2_conn_open_uni_stream(conn_, &qpack_dec_stream_id, nullptr) != 0) - { - loge("[{}] open qpack streams failed", log_prefix_); - return -1; - } - if (auto rv = nghttp3_conn_bind_qpack_streams(h3_, qpack_enc_stream_id, qpack_dec_stream_id); - rv != 0) - { - loge("[{}] nghttp3_conn_bind_qpack_streams: {}", log_prefix_, nghttp3_strerror(rv)); - return -1; - } - - logi("[{}] HTTP/3 ready (ctrl={} qpack_enc={} qpack_dec={})", log_prefix_, ctrl_stream_id, - qpack_enc_stream_id, qpack_dec_stream_id); - signal_ready(); - return 0; -} - -// ------------------------------------------------------------------------------------------------- -// nghttp3 callbacks -// ------------------------------------------------------------------------------------------------- - -int Http3ClientSession::h3_cb_stream_close(nghttp3_conn*, int64_t stream_id, - uint64_t app_error_code, void* user, void*) -{ - auto self = static_cast(user); - logd("[{}] h3 stream {} closed", self->log_prefix_, stream_id); - if (auto s = self->find_stream(stream_id)) - { - auto ec = (app_error_code == NGHTTP3_H3_NO_ERROR) - ? boost::system::error_code{} - : errc::make_error_code(errc::connection_reset); - s->fail(ec); - } - return 0; -} - -int Http3ClientSession::h3_cb_recv_data(nghttp3_conn*, int64_t stream_id, const uint8_t* data, - size_t datalen, void* user, void*) -{ - // - // Connection-level credit is granted immediately: it is a single pool shared with control/QPACK - // streams that nghttp3 manages on its own (the app never "reads" those), so withholding it here - // would stall unrelated traffic whenever this one stream's reader is slow. Only the *stream*- - // level credit for these bytes is deliberately deferred -- see - // Http3ClientSession::consume_stream(). Granting it only once the application actually reads - // the data (in Http3ClientStream::call_read_handler()) is what makes response-body - // backpressure real instead of nghttp3 buffering an unbounded backlog in pending_read while - // the peer keeps sending on *this* stream. - // - auto self = static_cast(user); - ngtcp2_conn_extend_max_offset(self->conn_, datalen); - if (auto s = self->find_stream(stream_id)) - s->on_data_chunk(data, datalen); - return 0; -} - -int Http3ClientSession::h3_cb_deferred_consume(nghttp3_conn*, int64_t stream_id, size_t nconsumed, - void* user, void*) -{ - auto self = static_cast(user); - ngtcp2_conn_extend_max_stream_offset(self->conn_, stream_id, nconsumed); - ngtcp2_conn_extend_max_offset(self->conn_, nconsumed); - return 0; -} - -int Http3ClientSession::h3_cb_begin_headers(nghttp3_conn*, int64_t /*stream_id*/, void* /*user*/, - void*) -{ - // Nothing to do: the stream (and its Http3ClientStream) was already created synchronously in - // async_submit(), before the request headers were even submitted to nghttp3. Compare the - // server, where begin_headers is what creates the stream for a newly-received request. - return 0; -} - -int Http3ClientSession::h3_cb_recv_header(nghttp3_conn*, int64_t stream_id, int32_t /*token*/, - nghttp3_rcbuf* name, nghttp3_rcbuf* value, - uint8_t /*flags*/, void* user, void*) -{ - auto self = static_cast(user); - auto n = nghttp3_rcbuf_get_buf(name); - auto v = nghttp3_rcbuf_get_buf(value); - auto name_view = std::string_view{reinterpret_cast(n.base), n.len}; - auto value_view = std::string_view{reinterpret_cast(v.base), v.len}; - - auto s = self->find_stream(stream_id); - if (!s) - return 0; - - if (spdlog::default_logger_raw()->should_log(spdlog::level::debug)) - s->received_headers.emplace_back(name_view, value_view); - - try - { - if (name_view == ":status") - { - unsigned int status = 0; - if (std::from_chars(value_view.begin(), value_view.end(), status).ec == std::errc{}) - s->status_code = status; - } - else if (name_view == "content-length") - { - size_t len = 0; - if (std::from_chars(value_view.begin(), value_view.end(), len).ec == std::errc{}) - s->content_length = len; - } - else - s->response_fields.set(name_view, value_view); - } - catch (const std::exception& ex) - { - logw("[{}] ignoring invalid header: {} ({})", s->log_prefix, value_view, ex.what()); - } - return 0; -} - -int Http3ClientSession::h3_cb_end_headers(nghttp3_conn*, int64_t stream_id, int /*fin*/, void* user, - void*) -{ - auto self = static_cast(user); - auto s = self->find_stream(stream_id); - if (!s) - return 0; - - logd("[{}] response headers: status={}", s->log_prefix, s->status_code); - log_headers(s->log_prefix, std::exchange(s->received_headers, {})); - s->headers_received = true; - s->deliver_response(); - return 0; -} - -int Http3ClientSession::h3_cb_end_stream(nghttp3_conn*, int64_t stream_id, void* user, void*) -{ - auto self = static_cast(user); - if (auto s = self->find_stream(stream_id)) - s->on_eof(); - return 0; -} - -int Http3ClientSession::h3_cb_stop_sending(nghttp3_conn*, int64_t stream_id, - uint64_t app_error_code, void* user, void*) -{ - auto self = static_cast(user); - ngtcp2_conn_shutdown_stream_read(self->conn_, 0, stream_id, app_error_code); - return 0; -} - -int Http3ClientSession::h3_cb_reset_stream(nghttp3_conn*, int64_t stream_id, - uint64_t app_error_code, void* user, void*) -{ - auto self = static_cast(user); - ngtcp2_conn_shutdown_stream_write(self->conn_, 0, stream_id, app_error_code); - return 0; -} - -// ------------------------------------------------------------------------------------------------- - -namespace -{ -nghttp3_ssize client_stream_read_data(nghttp3_conn*, int64_t /*stream_id*/, nghttp3_vec* vec, - size_t veccnt, uint32_t* pflags, void* /*conn_user*/, - void* stream_user) -{ - auto s = static_cast(stream_user); - return s->data_reader(vec, veccnt, pflags); -} -} // namespace - -void Http3ClientSession::async_submit(SubmitHandler&& handler, boost::urls::url url, - const Fields& headers) -{ - if (closed_ || !h3_) - { - loge("[{}] async_submit: session not ready", log_prefix_); - std::move(handler)(errc::make_error_code(errc::operation_canceled), client::Request{nullptr}); - return; - } - - int64_t stream_id = -1; - if (auto rv = ngtcp2_conn_open_bidi_stream(conn_, &stream_id, nullptr); rv != 0) - { - loge("[{}] async_submit: ngtcp2_conn_open_bidi_stream: {}", log_prefix_, ngtcp2_strerror(rv)); - std::move(handler)(errc::make_error_code(errc::invalid_argument), client::Request{nullptr}); - return; - } - - auto* stream = create_stream(stream_id); - stream->url = url; - - // - // TODO: CONNECT / other methods -- mirrors the h2 client's NGHttp2Session::async_submit(), - // which is likewise hard-coded to POST. - // - std::string method("POST"); - std::string scheme(url.scheme()); - std::string target(url.encoded_target()); - std::string authority(url.host_address()); - - std::vector nva; - nva.reserve(16); // small typical header count; vector will grow if needed - nva.push_back(make_nv(":method", method)); - nva.push_back(make_nv(":scheme", scheme)); - nva.push_back(make_nv(":path", target)); - nva.push_back(make_nv(":authority", authority)); - - for (auto&& item : headers) - { - if (item.name_string().starts_with(':')) - logw("[{}] async_submit: invalid header '{}': setting pseudo headers is not allowed", - stream->log_prefix, item.name_string()); - nva.push_back(make_nv(item.name_string(), item.value())); - } - - nghttp3_data_reader dr{}; - dr.read_data = &client_stream_read_data; - - if (auto rv = nghttp3_conn_submit_request(h3_, stream_id, nva.data(), nva.size(), &dr, stream); - rv != 0) - { - loge("[{}] nghttp3_conn_submit_request: {}", log_prefix_, nghttp3_strerror(rv)); - erase_stream(stream_id); - std::move(handler)(errc::make_error_code(errc::invalid_argument), client::Request{nullptr}); - return; - } - - logd("[{}] async_submit: new stream ID: {}", stream->log_prefix, stream_id); - log_headers(stream->log_prefix, nva); - wake_write(); - - 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)}); }); -} - -// ================================================================================================= -// Entry point used by Client::Impl::async_connect() for Protocol::h3. -// ================================================================================================= - -awaitable> async_connect_http3(asio::any_io_executor executor, - std::string host, std::string port) -{ - boost::asio::ip::udp::resolver resolver(executor); - auto flags = boost::asio::ip::udp::resolver::numeric_service; - auto results = co_await resolver.async_resolve(host, port, flags); // may throw - - auto session = std::make_shared(executor); - if (session->init(results.begin()->endpoint()) != 0) - throw boost::system::system_error(errc::make_error_code(errc::connection_refused)); - - std::shared_ptr impl = session; - -#if 1 - co_spawn(executor, impl->do_session(Buffer{}), [impl](const std::exception_ptr& ex) mutable - { - if (ex) - logw("client run: {}", what(ex)); - else - logi("client run: done"); - impl.reset(); - }); -#endif - - // - // Note: wait_ready() uses a sentinel steady_timer as a one-shot gate (see the comment on - // ready_signal_ / signal_ready()). Rearming a timer that already has a pending async_wait() - // cancels that wait with operation_aborted rather than completing it successfully -- so the - // *error code* here doesn't tell us anything; whether the handshake actually succeeded is - // reflected in ready() instead. - // - boost::system::error_code ec; - co_await session->wait_ready(redirect_error(use_awaitable, ec)); - if (!session->ready()) - throw boost::system::system_error(errc::make_error_code(errc::connection_refused)); - - co_return std::static_pointer_cast(session); -} - -// ================================================================================================= - -} // namespace anyhttp::client diff --git a/src/beast_session.cpp b/src/h1_session.cpp similarity index 93% rename from src/beast_session.cpp rename to src/h1_session.cpp index 0a0841e..22118c9 100644 --- a/src/beast_session.cpp +++ b/src/h1_session.cpp @@ -1,7 +1,9 @@ -#include "anyhttp/beast_session.hpp" +#include "anyhttp/h1_session.hpp" + #include "anyhttp/any_async_stream.hpp" #include "anyhttp/common.hpp" #include "anyhttp/formatter.hpp" // IWYU pragma: keep +#include "anyhttp/h1_backend.hpp" #include "anyhttp/server.hpp" #include @@ -604,7 +606,7 @@ void BeastSession::destroy() noexcept // post(get_executor(), [this, self]() mutable { boost::system::error_code ec; - std::ignore = get_socket(m_stream).shutdown(socket_base::shutdown_both, ec); + get_socket(m_stream).shutdown(socket_base::shutdown_both, ec); logwi(ec, "[{}] destroy: socket shutdown: {}", m_logPrefix, ec.message()); // }); } @@ -767,7 +769,7 @@ awaitable ServerSession::do_session(Buffer&& buffer) // FIXME: close() before shutdown()?! get_socket(m_stream).close(); - std::ignore = get_socket(m_stream).shutdown(asio::ip::tcp::socket::shutdown_send, ec); + get_socket(m_stream).shutdown(asio::ip::tcp::socket::shutdown_send, ec); mlogd("session done"); } @@ -869,4 +871,43 @@ template class ServerSession; template class ServerSession>; template class ServerSession; +// ================================================================================================= +// Factories, see anyhttp/h1_backend.hpp. Instantiating the session templates is kept to this +// translation unit, so that the generic server and client stay free of beast's HTTP machinery. +// ================================================================================================= + +std::shared_ptr make_server_session(server::Server::Impl& server, + asio::any_io_executor executor, + SslStream&& stream) +{ + return std::make_shared>(server, std::move(executor), + std::move(stream)); +} + +std::shared_ptr make_server_session(server::Server::Impl& server, + asio::any_io_executor executor, + AnyAsyncStream&& stream) +{ + return std::make_shared>(server, std::move(executor), + std::move(stream)); +} + +std::shared_ptr make_server_session(server::Server::Impl& server, + asio::any_io_executor executor, + asio::ip::tcp::socket&& socket) +{ + return std::make_shared>( + server, std::move(executor), boost::beast::tcp_stream(std::move(socket))); +} + +std::shared_ptr make_client_session(client::Client::Impl& client, + asio::any_io_executor executor, + asio::ip::tcp::socket&& socket) +{ + return std::make_shared>( + client, std::move(executor), boost::beast::tcp_stream(std::move(socket))); +} + +// ================================================================================================= + } // namespace anyhttp::beast_impl diff --git a/src/nghttp2_session.cpp b/src/h2_session.cpp similarity index 90% rename from src/nghttp2_session.cpp rename to src/h2_session.cpp index 6aec43b..a776e55 100644 --- a/src/nghttp2_session.cpp +++ b/src/h2_session.cpp @@ -1,11 +1,12 @@ -#include "anyhttp/nghttp2_session.hpp" +#include "anyhttp/h2_session.hpp" +#include "anyhttp/h2_backend.hpp" #include "anyhttp/client.hpp" #include "anyhttp/common.hpp" -#include "anyhttp/detail/nghttp2_session_details.hpp" +#include "anyhttp/detail/h2_session_details.hpp" #include "anyhttp/formatter.hpp" // IWYU pragma: keep -#include "anyhttp/nghttp2_common.hpp" -#include "anyhttp/nghttp2_stream.hpp" +#include "anyhttp/h2_common.hpp" +#include "anyhttp/h2_stream.hpp" #include #include @@ -609,6 +610,43 @@ void NGHttp2Session::start_write() } } +// ================================================================================================= +// Factories, see anyhttp/h2_backend.hpp. Instantiating the session templates is kept to this +// translation unit, so that the generic server and client never see an nghttp2 type. +// ================================================================================================= + +std::shared_ptr make_server_session(server::Server::Impl& server, + asio::any_io_executor executor, + SslStream&& stream) +{ + return std::make_shared>(server, std::move(executor), + std::move(stream)); +} + +std::shared_ptr make_server_session(server::Server::Impl& server, + asio::any_io_executor executor, + AnyAsyncStream&& stream) +{ + return std::make_shared>(server, std::move(executor), + std::move(stream)); +} + +std::shared_ptr make_server_session(server::Server::Impl& server, + asio::any_io_executor executor, + asio::ip::tcp::socket&& socket) +{ + return std::make_shared>(server, std::move(executor), + std::move(socket)); +} + +std::shared_ptr make_client_session(client::Client::Impl& client, + asio::any_io_executor executor, + asio::ip::tcp::socket&& socket) +{ + return std::make_shared>(client, std::move(executor), + std::move(socket)); +} + // ================================================================================================= } // namespace anyhttp::nghttp2 diff --git a/src/nghttp2_stream.cpp b/src/h2_stream.cpp similarity index 99% rename from src/nghttp2_stream.cpp rename to src/h2_stream.cpp index cb8a099..a654c4a 100644 --- a/src/nghttp2_stream.cpp +++ b/src/h2_stream.cpp @@ -1,10 +1,10 @@ -#include "anyhttp/nghttp2_stream.hpp" +#include "anyhttp/h2_stream.hpp" #include "anyhttp/client.hpp" #include "anyhttp/common.hpp" #include "anyhttp/formatter.hpp" // IWYU pragma: keep -#include "anyhttp/nghttp2_common.hpp" -#include "anyhttp/nghttp2_session.hpp" +#include "anyhttp/h2_common.hpp" +#include "anyhttp/h2_session.hpp" #include "anyhttp/request_handlers.hpp" // IWYU pragma: keep #include diff --git a/src/h3_client.cpp b/src/h3_client.cpp new file mode 100644 index 0000000..fc700aa --- /dev/null +++ b/src/h3_client.cpp @@ -0,0 +1,713 @@ +// +// anyhttp QUIC / HTTP/3 client. +// +// Almost all of it is shared with the server: `Http3ClientSession` is an `http3::Http3Session` +// (see anyhttp/h3_session.hpp) that knows how packets reach it and how it is torn down, and +// `Http3ClientStream` is an `http3::Http3Stream` (anyhttp/h3_stream.hpp) that writes a request +// and reads a response, where the server's does the opposite. +// +// What is genuinely client-side here: the TLS client context, one `connect()`ed UDP socket per +// session -- there is exactly one peer, so no connection-ID demux table is needed, unlike the +// server's shared socket -- the receive loop feeding it, and async_submit(), which opens a stream +// and puts the request headers on it before handing a client::Request back to the caller. +// +// Not yet implemented: certificate verification, 0-RTT, connection migration, GSO/ECN, retry +// tokens, graceful (multi-PTO) close. +// + +#include "anyhttp/client_impl.hpp" +#include "anyhttp/formatter.hpp" // IWYU pragma: keep +#include "anyhttp/h3_backend.hpp" +#include "anyhttp/h3_common.hpp" +#include "anyhttp/h3_session.hpp" +#include "anyhttp/h3_stream.hpp" +#include "anyhttp/literals.hpp" +#include "anyhttp/session_impl.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 +#include +#include +#include +#include + +#include "ngtcp2/util.h" + +using namespace std::chrono_literals; +using namespace boost::asio; +namespace errc = boost::system::errc; + +using anyhttp::http3::log_headers; +using anyhttp::http3::make_nv; + +namespace anyhttp::client +{ + +// ================================================================================================= +// Free-standing helpers +// ================================================================================================= + +namespace +{ + +// +// One-shot process-wide initialization of ngtcp2_crypto_ossl and the client-role OpenSSL SSL_CTX +// used for every outgoing QUIC connection. +// +struct TlsClientContext +{ + TlsClientContext() + { + static const int init_once = [] + { + if (ngtcp2_crypto_ossl_init() != 0) + throw std::runtime_error("ngtcp2_crypto_ossl_init"); + return 0; + }(); + (void)init_once; + + ctx = SSL_CTX_new(TLS_client_method()); + if (!ctx) + throw std::runtime_error("SSL_CTX_new"); + + static constexpr unsigned char alpn[] = "\x02h3"; + SSL_CTX_set_alpn_protos(ctx, alpn, sizeof(alpn) - 1); + + // + // TODO: verify the server certificate (e.g. against pki/out/root.pem) instead of accepting + // anything. + // + SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, nullptr); + } + + ~TlsClientContext() + { + if (ctx) + SSL_CTX_free(ctx); + } + + TlsClientContext(const TlsClientContext&) = delete; + TlsClientContext& operator=(const TlsClientContext&) = delete; + + SSL_CTX* ctx = nullptr; +}; + +TlsClientContext& tls_context() +{ + static TlsClientContext instance; + return instance; +} + +} // namespace + +// ================================================================================================= +// Http3ClientStream / Http3ClientSession: the client's end of the shared HTTP/3 implementation. +// ================================================================================================= + +class Http3ClientSession; + +class Http3ClientStream : public http3::Http3Stream +{ +public: + Http3ClientStream(Http3ClientSession& session, int64_t id); + ~Http3ClientStream() override; + + // + // Writes a request, reads a response -- the mirror image of Http3ServerStream. The request + // body goes through the staging buffer (WriteMode::Staged) rather than being handed to nghttp3 + // by reference: that keeps cancellation instantaneous and, more importantly, leaves the stream + // intact afterwards, so a cancelled write can be followed by another one on the same request. + // + void on_pseudo_header(std::string_view name, std::string_view value) override; + void on_headers_complete() override; + void on_failed(boost::system::error_code ec) override; + + /// The request headers went out with the stream itself, see Http3ClientSession::async_submit(). + void submit_response(unsigned int, const Fields&) override {} + + /// Assembles and submits the request headers. Called once, right after the stream is created. + bool submit_request(const boost::urls::url& url, const Fields& headers); + + void async_get_response(client::Request::GetResponseHandler&& handler); + void deliver_response(); + void deliver_failure(); + + bool response_delivered = false; + client::Request::GetResponseHandler response_handler; + + // + // Why the stream died, remembered because async_get_response() may well be called only + // afterwards -- a response that can never arrive must not leave its caller waiting forever. + // + boost::system::error_code failure_ec; +}; + +// ------------------------------------------------------------------------------------------------- + +// +// The client's Request needs one thing the shared writer does not have: async_get_response(), +// which client.hpp exposes and server::Response has no counterpart for. +// +class Http3ClientWriter : public http3::Http3Writer +{ +public: + using http3::Http3Writer::Http3Writer; + + void async_get_response(client::Request::GetResponseHandler&& handler) override + { + if (!stream) + { + std::move(handler)(errc::make_error_code(errc::connection_aborted), + client::Response{nullptr}); + return; + } + static_cast(stream)->async_get_response(std::move(handler)); + } +}; + +// ------------------------------------------------------------------------------------------------- + +class Http3ClientSession : public http3::Http3Session +{ +public: + explicit Http3ClientSession(asio::any_io_executor executor); + ~Http3ClientSession() override; + + // + // Session::Impl + // + void async_submit(SubmitHandler&& handler, boost::urls::url url, const Fields& headers) override; + awaitable do_session(Buffer&& data) override; + void destroy() noexcept override; + + // + // Connect-time setup. Returns 0 on success. + // + int init(asio::ip::udp::endpoint remote); + + // + // Awaited by client::Client::Impl::async_connect() before handing the Session back to the + // caller. Fires once the QUIC handshake has progressed far enough to create the HTTP/3 layer + // (see setup_http3()), or once the connection has failed/closed before getting that far -- in + // which case `ready()` is still false and the caller should synthesize an error. + // + template + auto wait_ready(CompletionToken&& token = CompletionToken()) + { + return ready_signal_.async_wait(std::forward(token)); + } + bool ready() const noexcept { return h3() != nullptr; } + +protected: + int handle_error(int rv) override; + int send_datagrams(const ngtcp2_path& path, std::span data, + size_t gso_size) override; + std::shared_ptr make_stream(int64_t id) override; + void on_http3_ready() override { signal_ready(); } + +private: + int on_read(std::span data); + void close(); + void signal_ready(); + +private: + asio::ip::udp::socket socket_; + asio::steady_timer ready_signal_; // sentinel timer, see wait_ready() +}; + +// ================================================================================================= +// Http3ClientStream implementation +// ================================================================================================= + +Http3ClientStream::Http3ClientStream(Http3ClientSession& s, int64_t stream_id) + : http3::Http3Stream(s, stream_id, http3::WriteMode::Staged) +{ +} + +Http3ClientStream::~Http3ClientStream() +{ + if (!response_delivered && response_handler) + swap_and_invoke(response_handler, errc::make_error_code(errc::connection_reset), + client::Response{nullptr}); +} + +void Http3ClientStream::on_pseudo_header(std::string_view name, std::string_view value) +{ + if (name == ":status") + { + unsigned int status = 0; + if (std::from_chars(value.begin(), value.end(), status).ec == std::errc{}) + status_code = status; + } +} + +void Http3ClientStream::on_headers_complete() +{ + logd("[{}] response headers: status={}", log_prefix, status_code); + log_headers(log_prefix, std::exchange(received_headers, {})); + deliver_response(); +} + +void Http3ClientStream::on_failed(boost::system::error_code ec) +{ + // + // A stream closing gracefully (ec success, e.g. NGHTTP3_H3_NO_ERROR) still means no response + // ever arrived if headers were never received -- never report success with a null Response. + // + failure_ec = ec ? ec : boost::beast::http::error::end_of_stream; + deliver_failure(); +} + +void Http3ClientStream::deliver_failure() +{ + if (headers_received || response_delivered || !response_handler) + return; + + response_delivered = true; + swap_and_invoke(response_handler, failure_ec, client::Response{nullptr}); +} + +bool Http3ClientStream::submit_request(const boost::urls::url& request_url, const Fields& headers) +{ + url = request_url; + + // + // TODO: CONNECT / other methods -- mirrors the h2 client's NGHttp2Session::async_submit(), + // which is likewise hard-coded to POST. + // + std::string method_str("POST"); + std::string scheme(request_url.scheme()); + std::string target(request_url.encoded_target()); + std::string authority(request_url.host_address()); + + std::vector nva; + nva.reserve(16); // small typical header count; vector will grow if needed + nva.push_back(make_nv(":method", method_str)); + nva.push_back(make_nv(":scheme", scheme)); + nva.push_back(make_nv(":path", target)); + nva.push_back(make_nv(":authority", authority)); + + for (auto&& item : headers) + { + if (item.name_string().starts_with(':')) + logw("[{}] async_submit: invalid header '{}': setting pseudo headers is not allowed", + log_prefix, item.name_string()); + nva.push_back(make_nv(item.name_string(), item.value())); + } + + return submit_headers(nva, true /* request */); +} + +void Http3ClientStream::async_get_response(client::Request::GetResponseHandler&& handler) +{ + if (response_delivered) + { + auto ec = asio::error::basic_errors::already_started; + asio::any_completion_executor ex = + asio::get_associated_immediate_executor(handler, get_executor()); + ex.execute([handler = std::move(handler), ec]() mutable + { std::move(handler)(ec, client::Response{nullptr}); }); + return; + } + + auto cs = handler.get_cancellation_slot(); + if (cs.is_connected()) + { + 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 + { + std::move(handler)(errc::make_error_code(errc::operation_canceled), + client::Response{nullptr}); + }); + } + }); + } + + // + // Delivering the response resumes the caller, which may drop the last reference to this stream + // right there (its Request going out of scope with the Response never read). Keep it alive + // until this function returns. + // + auto self = shared_from_this(); + + response_handler = std::move(handler); + deliver_response(); + + // + // Nothing will ever arrive on a stream that is already dead, so answer right away instead of + // waiting for a response that cannot come -- the same reasoning that makes call_read_handler() + // report the truncation to a read issued after the close. + // + if (closed) + deliver_failure(); +} + +void Http3ClientStream::deliver_response() +{ + if (!headers_received || !response_handler) + return; + + response_delivered = true; + auto response = + client::Response{std::make_unique>(*this)}; + swap_and_invoke(response_handler, boost::system::error_code{}, std::move(response)); +} + +// ================================================================================================= +// Http3ClientSession implementation +// ================================================================================================= + +Http3ClientSession::Http3ClientSession(asio::any_io_executor executor) + : http3::Http3Session(executor), socket_(get_executor()), ready_signal_(get_executor()) +{ + // Sentinel timers: expires_at(max) means "not yet"; a wait completes once moved to "min". + ready_signal_.expires_at(asio::steady_timer::time_point::max()); + logi("Http3ClientSession: ctor"); +} + +Http3ClientSession::~Http3ClientSession() +{ + // + // Tear the streams down while this object is still whole: destroying a stream fires pending + // handlers, which reach back into the session. + // + ready_signal_.cancel(); + clear_streams(); + logi("Http3ClientSession: dtor"); +} + +// ------------------------------------------------------------------------------------------------- + +int Http3ClientSession::init(asio::ip::udp::endpoint remote) +{ + boost::system::error_code ec; + socket_.open(remote.protocol(), ec); + if (ec) + { + loge("Http3ClientSession::init: open: {}", ec.message()); + return -1; + } + socket_.connect(remote, ec); + if (ec) + { + loge("Http3ClientSession::init: connect: {}", ec.message()); + return -1; + } + socket_.non_blocking(true, ec); + + auto local = socket_.local_endpoint(ec); + if (ec) + { + loge("Http3ClientSession::init: local_endpoint: {}", ec.message()); + return -1; + } + + log_prefix_ = std::format("h3:{}", ngtcp2::util::straddr(remote.data(), remote.size())); + + ngtcp2_cid scid{}; + scid.datalen = 17; + if (RAND_bytes(scid.data, static_cast(scid.datalen)) != 1) + { + loge("[{}] init: RAND_bytes for SCID failed", log_prefix_); + return -1; + } + ngtcp2_cid dcid{}; + dcid.datalen = http3::QUIC_SCIDLEN; + if (RAND_bytes(dcid.data, static_cast(dcid.datalen)) != 1) + { + loge("[{}] init: RAND_bytes for DCID failed", log_prefix_); + return -1; + } + + ngtcp2_callbacks callbacks{}; + fill_callbacks(callbacks); + callbacks.client_initial = ngtcp2_crypto_client_initial_cb; + callbacks.recv_retry = ngtcp2_crypto_recv_retry_cb; + + ngtcp2_settings settings; + ngtcp2_transport_params params; + fill_settings(settings, params, 30s); + + ngtcp2_path path{ + {local.data(), static_cast(local.size())}, + {remote.data(), static_cast(remote.size())}, + nullptr, + }; + + if (auto rv = ngtcp2_conn_client_new(&conn_, &dcid, &scid, &path, NGTCP2_PROTO_VER_V1, + &callbacks, &settings, ¶ms, nullptr, this); + rv != 0) + { + loge("[{}] ngtcp2_conn_client_new: {}", log_prefix_, ngtcp2_strerror(rv)); + return -1; + } + + if (setup_tls(tls_context().ctx, false /* client */) != 0) + return -1; + + logi("[{}] connecting, scid={}", log_prefix_, ngtcp2::util::format_hex(scid.data, scid.datalen)); + return 0; +} + +// ------------------------------------------------------------------------------------------------- + +awaitable Http3ClientSession::do_session(Buffer&&) +{ + if (flush_write() != 0) + { + signal_ready(); + co_return; + } + + std::array buf; + for (;;) + { + boost::system::error_code ec; + size_t n = + co_await socket_.async_receive(asio::buffer(buf), redirect_error(use_awaitable, ec)); + if (ec) + { + if (ec != asio::error::operation_aborted) + logw("[{}] receive: {}", log_prefix_, ec.message()); + break; + } + + if (on_read({buf.data(), n}) != 0) + break; // handle_error() already tore things down. + + // + // close() may have run from inside on_read(): handing a response chunk or EOF to the + // application resumes its coroutine, which may drop the last reference to the Session + // right there. Its socket_.cancel() then found no receive pending -- we are between two + // of them -- so nothing would stop us from arming a fresh one that no peer will ever + // complete. The server, already draining because it got our CONNECTION_CLOSE, does not + // even answer it. + // + if (closed()) + break; + } + + // + // The receive loop only ever ends because this connection is over: the socket errored out (ICMP + // reporting the peer's port unreachable, say), close() cancelled it, or on_read() hit a protocol + // error. Tear the session down in every case -- nothing else is running that could ever complete + // the requests still waiting on it, so leaving them pending hangs them forever. close() is + // idempotent, so the paths that already tore things down are unaffected, and it signals ready to + // unblock a waiter whose handshake never finished. + // + close(); + co_return; +} + +void Http3ClientSession::destroy() noexcept { close(); } + +void Http3ClientSession::close() +{ + if (std::exchange(closed_, true)) + return; + + // + // The connection is going away (user-initiated destroy(), or a protocol/transport error via + // handle_error()) -- fail every request that hasn't completed yet instead of leaving its + // async_get_response()/async_read_some() hanging forever. Streams may erase themselves from + // streams_ as a side effect of fail() (via maybe_close()), so snapshot first. + // + std::vector> streams; + streams.reserve(streams_.size()); + for (auto& [id, stream] : streams_) + streams.push_back(stream); + for (auto& stream : streams) + stream->fail(errc::make_error_code(errc::connection_reset)); + + // + // An idle-timed-out (or dropped) connection is discarded silently: RFC 9000 has no + // CONNECTION_CLOSE for it, and there is nobody left listening anyway -- writing one would + // just put a packet on a path whose peer has been gone for a full idle period. + // + const bool silent = last_error_.type == NGTCP2_CCERR_TYPE_IDLE_CLOSE || + last_error_.type == NGTCP2_CCERR_TYPE_DROP_CONN; + + if (conn_ && !silent && !ngtcp2_conn_in_closing_period(conn_) && + !ngtcp2_conn_in_draining_period(conn_)) + { + std::array closebuf; + ngtcp2_path_storage ps; + if (auto packet = write_connection_close(closebuf, ps); !packet.empty()) + send_datagrams(ps.path, packet, packet.size()); + } + + boost::system::error_code ec; + socket_.cancel(ec); + timer_.cancel(); + signal_ready(); +} + +void Http3ClientSession::signal_ready() +{ + ready_signal_.expires_at(asio::steady_timer::time_point::min()); +} + +int Http3ClientSession::handle_error(int /*rv*/) +{ + close(); + return -1; +} + +// ------------------------------------------------------------------------------------------------- + +std::shared_ptr Http3ClientSession::make_stream(int64_t id) +{ + return std::make_shared(*this, id); +} + +// +// The connected socket has exactly one peer, so the path is of no interest here. What +// ngtcp2_conn_write_aggregate_pkt2() produced may be several QUIC packets, all but the last +// exactly `gso_size` bytes long -- without UDP_SEGMENT (which the server uses on its shared, +// unconnected socket) they go out one send() at a time. +// +int Http3ClientSession::send_datagrams(const ngtcp2_path& /*path*/, std::span data, + size_t gso_size) +{ + while (!data.empty()) + { + auto len = std::min(gso_size, data.size()); + boost::system::error_code ec; + socket_.send(asio::buffer(data.data(), len), 0, ec); + if (ec && ec != asio::error::would_block && ec != asio::error::try_again) + { + logw("[{}] send: {}", log_prefix_, ec.message()); + return 0; // best-effort; ngtcp2 will retransmit + } + data = data.subspan(len); + } + return 0; +} + +int Http3ClientSession::on_read(std::span data) +{ + ngtcp2_pkt_info pi{}; + auto* path = ngtcp2_conn_get_path(conn_); + if (http3::Http3Session::on_read(*path, pi, data) != 0) + return -1; + + // + // Unlike the server, which reads a whole batch of datagrams before answering it in one pass, + // there is only ever one packet in flight here -- flush right away. + // + return flush_write(); +} + +// ------------------------------------------------------------------------------------------------- + +void Http3ClientSession::async_submit(SubmitHandler&& handler, boost::urls::url url, + const Fields& headers) +{ + if (closed() || !h3()) + { + loge("[{}] async_submit: session not ready", log_prefix_); + std::move(handler)(errc::make_error_code(errc::operation_canceled), client::Request{nullptr}); + return; + } + + int64_t stream_id = -1; + if (auto rv = ngtcp2_conn_open_bidi_stream(conn_, &stream_id, nullptr); rv != 0) + { + loge("[{}] async_submit: ngtcp2_conn_open_bidi_stream: {}", log_prefix_, ngtcp2_strerror(rv)); + std::move(handler)(errc::make_error_code(errc::invalid_argument), client::Request{nullptr}); + return; + } + + auto* stream = static_cast(create_stream(stream_id)); + if (!stream->submit_request(url, headers)) + { + erase_stream(stream_id); + std::move(handler)(errc::make_error_code(errc::invalid_argument), client::Request{nullptr}); + return; + } + + logd("[{}] async_submit: new stream ID: {}", stream->log_prefix, stream_id); + wake_write(); + + 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)}); }); +} + +// ================================================================================================= +// Entry point used by Client::Impl::async_connect() for Protocol::h3. +// ================================================================================================= + +awaitable> async_connect_http3(asio::any_io_executor executor, + std::string host, std::string port) +{ + boost::asio::ip::udp::resolver resolver(executor); + auto flags = boost::asio::ip::udp::resolver::numeric_service; + auto results = co_await resolver.async_resolve(host, port, flags); // may throw + + auto session = std::make_shared(executor); + if (session->init(results.begin()->endpoint()) != 0) + throw boost::system::system_error(errc::make_error_code(errc::connection_refused)); + + std::shared_ptr impl = session; + + co_spawn(executor, impl->do_session(Buffer{}), [impl](const std::exception_ptr& ex) mutable + { + if (ex) + logw("client run: {}", what(ex)); + else + logi("client run: done"); + impl.reset(); + }); + + // + // Note: wait_ready() uses a sentinel steady_timer as a one-shot gate (see the comment on + // ready_signal_ / signal_ready()). Rearming a timer that already has a pending async_wait() + // cancels that wait with operation_aborted rather than completing it successfully -- so the + // *error code* here doesn't tell us anything; whether the handshake actually succeeded is + // reflected in ready() instead. + // + boost::system::error_code ec; + co_await session->wait_ready(redirect_error(use_awaitable, ec)); + if (!session->ready()) + throw boost::system::system_error(errc::make_error_code(errc::connection_refused)); + + co_return std::static_pointer_cast(session); +} + +// ================================================================================================= + +} // namespace anyhttp::client diff --git a/src/h3_common.cpp b/src/h3_common.cpp new file mode 100644 index 0000000..fd4b361 --- /dev/null +++ b/src/h3_common.cpp @@ -0,0 +1,57 @@ +// +// Small helpers shared by the HTTP/3 server and client, see anyhttp/h3_common.hpp. +// +#include "anyhttp/h3_common.hpp" + +#include + +#include +#include +#include + +namespace anyhttp::http3 +{ + +// ================================================================================================= + +nghttp3_nv make_nv(std::string_view name, std::string_view value) +{ + nghttp3_nv nv{}; + nv.name = reinterpret_cast(const_cast(name.data())); + nv.namelen = name.size(); + nv.value = reinterpret_cast(const_cast(value.data())); + nv.valuelen = value.size(); + nv.flags = NGHTTP3_NV_FLAG_NONE; + return nv; +} + +void log_headers(std::string_view log_prefix, std::span nva) +{ + for (const auto& nv : nva) + logd("[{}] \x1b[1;34m{}\x1b[0m: {}", log_prefix, + std::string_view(reinterpret_cast(nv.name), nv.namelen), + std::string_view(reinterpret_cast(nv.value), nv.valuelen)); +} + +void log_headers(std::string_view log_prefix, + const std::vector>& headers) +{ + for (const auto& [name, value] : headers) + logd("[{}] \x1b[1;34m{}\x1b[0m: {}", log_prefix, name, value); +} + +void ngtcp2_log_printf(void* /*user*/, const char* fmt, ...) noexcept +{ + if (!spdlog::default_logger()->should_log(spdlog::level::trace)) + return; + std::array buf; + va_list ap; + va_start(ap, fmt); + std::vsnprintf(buf.data(), buf.size(), fmt, ap); + va_end(ap); + spdlog::trace("{}", buf.data()); +} + +// ================================================================================================= + +} // namespace anyhttp::http3 diff --git a/src/h3_server.cpp b/src/h3_server.cpp new file mode 100644 index 0000000..fef5e93 --- /dev/null +++ b/src/h3_server.cpp @@ -0,0 +1,1240 @@ +// +// anyhttp QUIC / HTTP/3 server. +// +// Nearly everything that makes a QUIC connection work is shared with the client and lives in +// anyhttp/h3_session.hpp and anyhttp/h3_stream.hpp: `Http3ServerSession` is an +// `http3::Http3Session` that knows how packets reach it and how it dies, and `Http3ServerStream` +// is an `http3::Http3Stream` that reads a request and writes a response, where the client's does +// the opposite. Per-request state feeds an `Http3Reader` (server::Request) and `Http3Writer` +// (server::Response) into the same `RequestHandler` used by the HTTP/1.1 and HTTP/2 backends. +// +// What is genuinely server-side here: the TLS server context, the UDP receive path (many +// connections over one socket, demultiplexed by connection ID) and the closing/draining period +// bookkeeping that goes with being the endpoint that stays around. All of it sits behind +// `Http3Server` (anyhttp/h3_backend.hpp), so the generic server in server_impl.cpp dispatches to +// HTTP/3 without ever seeing an ngtcp2 or nghttp3 type. +// +// Threading: with Config::use_strand, each Http3ServerSession lives on its own strand -- the unit +// of serialization is the QUIC *connection* (one ngtcp2_conn/nghttp3_conn pair), not the CID: many +// CIDs alias one connection. udp_receive_loop() is a single coroutine that only demultiplexes: it +// copies each datagram, groups them by session and posts one batch per session to that session's +// strand (process_quic_batch()), where all ngtcp2/nghttp3 work, the timers and the request +// handlers run. The CID demux table is the only cross-connection state and is guarded by +// Http3ServerImpl::mutex_. Sends go straight out via a per-session dup() of the UDP fd -- +// sendto()/sendmsg() are atomic per datagram, so they need no serialization. +// +// Not yet implemented: retry tokens, version negotiation, stateless reset, connection +// migration, ECN. +// + +#include "anyhttp/client_impl.hpp" +#include "anyhttp/formatter.hpp" // IWYU pragma: keep +#include "anyhttp/h3_backend.hpp" +#include "anyhttp/h3_common.hpp" +#include "anyhttp/h3_session.hpp" +#include "anyhttp/h3_stream.hpp" +#include "anyhttp/literals.hpp" +#include "anyhttp/request_handlers.hpp" // IWYU pragma: keep +#include "anyhttp/server_impl.hpp" +#include "anyhttp/session_impl.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 +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ngtcp2/shared.h" +#include "ngtcp2/util.h" + +using namespace std::chrono_literals; +using namespace boost::asio; +namespace errc = boost::system::errc; + +using anyhttp::http3::log_headers; +using anyhttp::http3::make_nv; +using anyhttp::http3::QUIC_SCIDLEN; + +namespace anyhttp::server +{ + +// ================================================================================================= + +struct Endpoint +{ + ngtcp2::Address addr; + int fd; + + // Testing aid, see server::Config::drop_rate_rx/tx. + double drop_rate_rx = 0.0; + double drop_rate_tx = 0.0; +}; + +// ================================================================================================= +// Free-standing helpers +// ================================================================================================= + +namespace +{ + +// +// One-shot process-wide initialization of ngtcp2_crypto_ossl and the OpenSSL SSL_CTX +// used for every QUIC connection. +// +struct TlsServerContext +{ + TlsServerContext() + { + static const int init_once = [] + { + if (ngtcp2_crypto_ossl_init() != 0) + throw std::runtime_error("ngtcp2_crypto_ossl_init"); + return 0; + }(); + (void)init_once; + + ctx = SSL_CTX_new(TLS_server_method()); + if (!ctx) + throw std::runtime_error("SSL_CTX_new"); + + SSL_CTX_set_options(ctx, (SSL_OP_ALL & ~SSL_OP_DONT_INSERT_EMPTY_FRAGMENTS) | + SSL_OP_SINGLE_ECDH_USE | SSL_OP_CIPHER_SERVER_PREFERENCE | + SSL_OP_NO_ANTI_REPLAY); + SSL_CTX_set_mode(ctx, SSL_MODE_RELEASE_BUFFERS); + + SSL_CTX_set_alpn_select_cb(ctx, &TlsServerContext::alpn_select_cb, nullptr); + + if (SSL_CTX_use_PrivateKey_file(ctx, "pki/out/server-key.pem", SSL_FILETYPE_PEM) != 1) + throw std::runtime_error(std::string{"SSL_CTX_use_PrivateKey_file: "} + + ERR_error_string(ERR_get_error(), nullptr)); + + if (SSL_CTX_use_certificate_chain_file(ctx, "pki/out/server-chain.pem") != 1) + throw std::runtime_error(std::string{"SSL_CTX_use_certificate_chain_file: "} + + ERR_error_string(ERR_get_error(), nullptr)); + + if (SSL_CTX_check_private_key(ctx) != 1) + throw std::runtime_error("SSL_CTX_check_private_key"); + } + + ~TlsServerContext() + { + if (ctx) + SSL_CTX_free(ctx); + } + + TlsServerContext(const TlsServerContext&) = delete; + TlsServerContext& operator=(const TlsServerContext&) = delete; + + static int alpn_select_cb(SSL*, const unsigned char** out, unsigned char* outlen, + const unsigned char* in, unsigned int inlen, void*) + { + for (auto s = std::span{in, inlen}; s.size() >= 3; s = s.subspan(s[0] + 1)) + { + if (s[0] == 2 && s[1] == 'h' && s[2] == '3') + { + *out = &s[1]; + *outlen = 2; + return SSL_TLSEXT_ERR_OK; + } + } + return SSL_TLSEXT_ERR_ALERT_FATAL; + } + + SSL_CTX* ctx = nullptr; +}; + +TlsServerContext& tls_context() +{ + static TlsServerContext instance; + return instance; +} + +// ------------------------------------------------------------------------------------------------- + +std::string cid_key(const ngtcp2_cid& cid) +{ + return std::string{reinterpret_cast(cid.data), cid.datalen}; +} + +std::string cid_key(const uint8_t* data, size_t len) +{ + return std::string{reinterpret_cast(data), len}; +} + +// ------------------------------------------------------------------------------------------------- + +// +// Testing aid: rolls the dice for a single QUIC packet. `rate` is the probability of the packet +// being dropped, 0.0 (never, the default) to 1.0 (always). The generator is deliberately not +// seeded deterministically -- this is meant to shake out loss handling over many runs, not to +// reproduce one exact sequence. +// +bool drop_packet(double rate) +{ + if (rate <= 0.0) + return false; + + static thread_local std::mt19937 rng{std::random_device{}()}; + return std::uniform_real_distribution{0.0, 1.0}(rng) < rate; +} + +// ------------------------------------------------------------------------------------------------- + +int send_udp(const Endpoint& ep, const sockaddr* sa, socklen_t salen, std::span data) +{ + if (drop_packet(ep.drop_rate_tx)) + { + // logw("*** dropping outgoing packet ({} bytes) ***", data.size()); + return 0; // pretend it went out; ngtcp2 will retransmit + } + + for (;;) + { + auto n = ::sendto(ep.fd, data.data(), data.size(), 0, sa, salen); + if (n == -1) + { + if (errno == EINTR) + continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) + return 0; // best-effort; ngtcp2 will retransmit + loge("sendto: {}", strerror(errno)); + return -1; + } + return 0; + } +} + +// ------------------------------------------------------------------------------------------------- + +// Sends a run of same-sized packets (as produced by ngtcp2_conn_write_aggregate_pkt2(), all but +// the last exactly `gso_size` bytes) with a single sendmsg() using UDP_SEGMENT (GSO), so N QUIC +// packets cost one syscall instead of N. Falls back to one sendto() per segment -- and remembers +// to do so from then on -- if the kernel/NIC doesn't support UDP_SEGMENT here. +int send_udp_gso(const Endpoint& ep, const sockaddr* sa, socklen_t salen, + std::span data, size_t gso_size, bool& no_gso) +{ + // With TX dropping enabled, go packet by packet so each one can be dropped individually. + if (no_gso || data.size() <= gso_size || ep.drop_rate_tx > 0.0) + { + for (; !data.empty();) + { + auto len = std::min(gso_size, data.size()); + if (send_udp(ep, sa, salen, data.first(len)) != 0) + return -1; + data = data.subspan(len); + } + return 0; + } + + iovec msg_iov{const_cast(data.data()), data.size()}; + uint8_t msg_ctrl[CMSG_SPACE(sizeof(uint16_t))]; + msghdr msg{}; + msg.msg_name = const_cast(sa); + msg.msg_namelen = salen; + msg.msg_iov = &msg_iov; + msg.msg_iovlen = 1; + msg.msg_control = msg_ctrl; + msg.msg_controllen = sizeof(msg_ctrl); + + auto* cm = CMSG_FIRSTHDR(&msg); + cm->cmsg_level = SOL_UDP; + cm->cmsg_type = UDP_SEGMENT; + cm->cmsg_len = CMSG_LEN(sizeof(uint16_t)); + auto seg = static_cast(gso_size); + memcpy(CMSG_DATA(cm), &seg, sizeof(seg)); + + for (;;) + { + auto n = ::sendmsg(ep.fd, &msg, 0); + if (n == -1) + { + if (errno == EINTR) + continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) + return 0; // best-effort; ngtcp2 will retransmit + if (errno == EINVAL || errno == EOPNOTSUPP) + { + // GSO unsupported on this socket/NIC: fall back permanently and resend as + // individual datagrams. + no_gso = true; + return send_udp_gso(ep, sa, salen, data, gso_size, no_gso); + } + loge("sendmsg (GSO): {}", strerror(errno)); + return -1; + } + return 0; + } +} + +} // namespace + +// ================================================================================================= +// Http3ServerStream / Http3ServerSession: the server's end of the shared HTTP/3 implementation. +// ================================================================================================= + +class Http3ServerImpl; +class Http3ServerSession; + +class Http3ServerStream : public http3::Http3Stream +{ +public: + Http3ServerStream(Http3ServerSession& session, int64_t id); + + // + // Reads a request, writes a response -- the mirror image of Http3ClientStream. The response + // body is handed to nghttp3 by reference (WriteMode::ZeroCopy): a file served through + // serve_file() travels from the page cache into QUIC packets without an intermediate copy. + // + void on_pseudo_header(std::string_view name, std::string_view value) override; + void on_headers_complete() override; + void submit_response(unsigned int status_code, const Fields& fields) override; +}; + +// ------------------------------------------------------------------------------------------------- + +class Http3ServerSession : public http3::Http3Session +{ +public: + Http3ServerSession(Http3ServerImpl& server, Endpoint ep, ngtcp2::Address remote); + ~Http3ServerSession() override; + + // + // Session::Impl + // + // The executor is this connection's strand (when Config::use_strand is set): every touch of + // ngtcp2/nghttp3 state -- datagram batches from the UDP demux, the expiry timer, wake_write() + // flushes, and the request-handler coroutines spawned in on_headers_complete() -- runs through + // it, giving one QUIC connection the same single-threaded world a TCP connection gets from + // the strand its socket lives on. + // + void async_submit(SubmitHandler&& handler, boost::urls::url, const Fields&) override; + awaitable do_session(Buffer&& data) override; + void destroy() noexcept override; + + // + // Interface used by the UDP demux in Server::Impl. + // + int init(const ngtcp2_cid& dcid, const ngtcp2_cid& scid, uint32_t version, + const ngtcp2_pkt_info& pi, std::span data); + int on_read(const ngtcp2_pkt_info& pi, std::span data, + const ngtcp2::Address& remote); + + /// Called from Http3ServerImpl::process_quic_batch() when a packet arrives during the + /// closing period. + void resend_conn_close(); + + const ngtcp2_cid& scid() const noexcept { return scid_; } + Http3ServerImpl& server() noexcept { return server_; } + +protected: + int handle_error(int rv) override; + int send_datagrams(const ngtcp2_path& path, std::span data, + size_t gso_size) override; + std::shared_ptr make_stream(int64_t id) override; + void on_new_cid(const ngtcp2_cid& cid) override; + void on_remove_cid(const ngtcp2_cid& cid) override; + +private: + void signal_done(); + void schedule_close_timer(); + void do_destroy() noexcept; // the body of destroy(), always run on executor_ + +private: + Http3ServerImpl& server_; + Endpoint ep_; + bool owns_fd_ = false; // ep_.fd was dup()ed in the ctor, close it in the dtor + ngtcp2::Address remote_; + ngtcp2_cid scid_{}; + + asio::steady_timer done_signal_; // used to wake do_session() on connection close + std::vector conn_closebuf_; // buffered CONNECTION_CLOSE packet + bool no_gso_ = false; +}; + +namespace +{ +std::optional to_ngtcp2_address(const sockaddr_storage& src, socklen_t len) +{ + ngtcp2::Address addr{}; + if (len > sizeof(addr.su)) + return std::nullopt; + std::memcpy(&addr.su, &src, len); + addr.len = len; + return addr; +} +} // namespace + +// +// What one pass of udp_on_read() hands a session: every datagram of the receive batch that was +// addressed to it, copied out of the receive buffer because the session consumes them on its own +// strand, after udp_on_read() has moved on. `is_new` marks a batch whose first datagram is the +// client Initial that created the session -- process_quic_batch() runs init() with it. +// +struct QuicBatch +{ + struct Datagram + { + ngtcp2_pkt_info pi; + ngtcp2::Address remote; + std::vector data; + }; + + bool is_new = false; + ngtcp2_pkt_hd hd{}; // decoded Initial packet header, only valid when is_new + boost::container::small_vector datagrams; +}; + +// ------------------------------------------------------------------------------------------------- + +// +// The server's HTTP/3 half (see anyhttp/h3_backend.hpp): the UDP socket every QUIC connection +// shares, the receive loop demultiplexing datagrams onto them by connection ID, and the table +// doing that lookup. The sessions themselves are owned by Server::Impl's session registry, like +// the TCP-based ones -- what is kept here is only what routing packets needs. +// +class Http3ServerImpl : public Http3Server, public std::enable_shared_from_this +{ +public: + Http3ServerImpl(Server::Impl& parent, const asio::ip::udp::endpoint& endpoint); + + // + // Http3Server + // + void start() override; + void destroy() override; + + // + // The server this is a part of. Sessions reach the configuration, the request handler and the + // session registry through here. + // + Server::Impl& parent() noexcept { return parent_; } + const Config& config() const noexcept { return parent_.config(); } + const RequestHandler& requestHandler() const { return parent_.requestHandler(); } + asio::any_io_executor get_executor() const noexcept { return parent_.get_executor(); } + + // + // QUIC connection-ID demux table. Populated as new source CIDs are minted, consulted by + // udp_on_read() to route packets to the right connection. Guarded by mutex_: the receive loop + // reads it while sessions mutate it from their own strands (get_new_connection_id / + // remove_connection_id callbacks, close timers). + // + void associate_quic_cid(const ngtcp2_cid& cid, Http3ServerSession* session); + void dissociate_quic_cid(const ngtcp2_cid& cid); + void erase_quic_session(Http3ServerSession* session); + +private: + awaitable udp_receive_loop(); + int udp_on_read(Endpoint& ep); + void process_quic_batch(const std::shared_ptr& session, QuicBatch&& batch); + + /// Keeps the owning Server::Impl alive for as long as a pending operation of ours runs. + std::shared_ptr owner() { return parent_.shared_from_this(); } + +private: + Server::Impl& parent_; + + // + // The socket gets its own strand: udp_receive_loop() runs on it, and destroy() dispatches the + // shutdown close() through it, so the two never touch the socket concurrently. + // + std::optional socket_; + + std::mutex mutex_; + std::unordered_map> sessions_; +}; + +// ================================================================================================= +// Http3ServerStream implementation +// ================================================================================================= + +Http3ServerStream::Http3ServerStream(Http3ServerSession& s, int64_t stream_id) + : http3::Http3Stream(s, stream_id, http3::WriteMode::ZeroCopy) +{ +} + +void Http3ServerStream::on_pseudo_header(std::string_view name, std::string_view value) +{ + if (name == ":method") + method = value; + else if (name == ":path") + { + if (auto parsed = boost::urls::parse_relative_ref(value); parsed.has_value()) + { + url.set_path(parsed->path()); + if (parsed->has_query()) + url.set_query(parsed->query()); + if (parsed->has_fragment()) + url.set_fragment(parsed->fragment()); + } + } + else if (name == ":scheme") + url.set_scheme(value); + else if (name == ":authority") + url.set_encoded_authority(value); +} + +void Http3ServerStream::on_headers_complete() +{ + logd("[{}] {} {}", log_prefix, method, url.buffer()); + log_headers(log_prefix, std::exchange(received_headers, {})); + + // + // Build the user-facing Request/Response and dispatch through the shared handler. + // + server::Request request(std::make_unique>(*this)); + server::Response response(std::make_unique>(*this)); + + auto& sv = static_cast(session).server(); + if (auto& handler = sv.requestHandler()) + co_spawn(get_executor(), handler(std::move(request), std::move(response)), detached); + else + { + loge("[{}] no request handler set", log_prefix); + co_spawn(get_executor(), not_found(std::move(response)), detached); + } +} + +void Http3ServerStream::submit_response(unsigned int status, const Fields& user_fields) +{ + assert(!headers_submitted); + + response_status = status; + response_fields = user_fields; + + auto status_str = std::to_string(response_status); + std::vector nva; + nva.reserve(16); // small typical header count; vector will grow if needed + auto date_str = format_http_date(std::chrono::system_clock::now()); + nva.push_back(make_nv(":status", status_str)); + nva.push_back(make_nv("date", date_str)); + nva.push_back(make_nv("server", "anyhttp-quic/0.1")); + + if (response_content_length) + { + response_content_length_str = std::to_string(*response_content_length); + nva.push_back(make_nv("content-length", response_content_length_str)); + } + + for (auto&& item : response_fields) + { + if (item.name_string().starts_with(':')) + { + logw("[{}] submit_response: dropping pseudo-header '{}'", log_prefix, item.name_string()); + continue; + } + nva.push_back(make_nv(item.name_string(), item.value())); + } + + using namespace boost::beast::http; + logd("[{}] {} {}", log_prefix, response_status, obsolete_reason(int_to_status(response_status))); + + if (submit_headers(nva, false /* response */)) + session.wake_write(); +} + +// ================================================================================================= +// Http3ServerSession implementation +// ================================================================================================= + +Http3ServerSession::Http3ServerSession(Http3ServerImpl& server, Endpoint ep, ngtcp2::Address remote) + : http3::Http3Session(server.config().use_strand + ? asio::any_io_executor{asio::make_strand(server.get_executor())} + : server.get_executor()), + server_(server), ep_(ep), remote_(remote), done_signal_(get_executor()) +{ + log_prefix_ = std::format("h3:{}", ngtcp2::util::straddr(&remote_.su.sa, remote_.len)); + + // + // Own a dup() of the shared UDP fd rather than borrowing the server's. Sends happen from this + // session's strand, concurrently with everything else -- sendto()/sendmsg() on a shared + // datagram fd is fine, each call is atomic -- but at shutdown the server closes its socket + // right after posting destroy() to every session, and the final CONNECTION_CLOSE would + // otherwise race that close (and, worse, a recycled fd number). + // + if (int fd = ::dup(ep_.fd); fd >= 0) + { + ep_.fd = fd; + owns_fd_ = true; + } + else + loge("[{}] dup: {}", log_prefix_, strerror(errno)); + + // done_signal_ is armed at "never" until signal_done() moves it to the past. + done_signal_.expires_at(asio::steady_timer::time_point::max()); + mlogd("session created"); +} + +Http3ServerSession::~Http3ServerSession() +{ + // + // Tear the streams down while this object is still whole: destroying a stream fires pending + // handlers, which reach back into the session. + // + done_signal_.cancel(); + clear_streams(); + if (owns_fd_) + ::close(ep_.fd); + mlogi("session destroyed"); +} + +// ------------------------------------------------------------------------------------------------- + +void Http3ServerSession::async_submit(SubmitHandler&& handler, boost::urls::url, const Fields&) +{ + // A server does not initiate requests; see Http3ClientSession::async_submit(). + std::move(handler)(errc::make_error_code(errc::operation_not_supported), + client::Request{nullptr}); +} + +awaitable Http3ServerSession::do_session(Buffer&&) +{ + boost::system::error_code ec; + co_await done_signal_.async_wait(redirect_error(use_awaitable, ec)); + // ec is boost::asio::error::operation_aborted (from destroy()) or a spurious + // wake-up; either way, this coroutine's job is done. + co_return; +} + +void Http3ServerSession::destroy() noexcept +{ + // + // Called from wherever the Server is being torn down -- under multithreading that is some + // other thread's strand (e.g. the signal handler in server_main), while this session's own + // strand may be mid-flush. Everything below touches ngtcp2 state and the timers, so hop onto + // 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(); }); +} + +void Http3ServerSession::do_destroy() noexcept +{ + // + // Tear the streams down here, on the session's executor, rather than leaving it to the + // destructor. The destructor runs wherever the last shared_ptr happens to drop -- at server + // shutdown that can be Server::Impl::~Impl() on a foreign thread -- and destroying streams + // detaches readers/writers and fires pending handlers, state that request-handler coroutines + // still running on this session's strand look at. Done here, that teardown is serialized + // with them; a handler that resumes afterwards finds its Reader/Writer detached and fails + // cleanly, exactly as in the single-threaded case. + // + clear_streams(); + + if (std::exchange(closed_, true)) + { + timer_.cancel(); + signal_done(); + return; + } + + // + // Explicit shutdown (e.g. the whole Server::Impl going away): let the peer know right + // away instead of leaving it to find out via idle timeout (up to 30s). Unlike + // handle_error(), this doesn't linger for 3 PTO to handle retransmits of the CLOSE -- + // this is a clean, voluntary shutdown, not an error condition worth that effort. + // + if (conn_ && !ngtcp2_conn_in_closing_period(conn_) && !ngtcp2_conn_in_draining_period(conn_)) + { + std::array closebuf; + ngtcp2_path_storage ps; + if (auto packet = write_connection_close(closebuf, ps); !packet.empty()) + send_udp(ep_, ps.path.remote.addr, ps.path.remote.addrlen, packet); + } + + timer_.cancel(); + signal_done(); +} + +void Http3ServerSession::signal_done() +{ + // Move the sentinel timer to the past so any waiter wakes up. + done_signal_.expires_at(asio::steady_timer::time_point::min()); +} + +// ------------------------------------------------------------------------------------------------- + +std::shared_ptr Http3ServerSession::make_stream(int64_t id) +{ + return std::make_shared(*this, id); +} + +void Http3ServerSession::on_new_cid(const ngtcp2_cid& cid) +{ + server_.associate_quic_cid(cid, this); +} + +void Http3ServerSession::on_remove_cid(const ngtcp2_cid& cid) { server_.dissociate_quic_cid(cid); } + +int Http3ServerSession::send_datagrams(const ngtcp2_path& path, std::span data, + size_t gso_size) +{ + return send_udp_gso(ep_, path.remote.addr, path.remote.addrlen, data, gso_size, no_gso_); +} + +// ------------------------------------------------------------------------------------------------- + +int Http3ServerSession::init(const ngtcp2_cid& dcid, const ngtcp2_cid& scid, uint32_t version, + const ngtcp2_pkt_info& pi, std::span data) +{ + scid_.datalen = QUIC_SCIDLEN; + if (RAND_bytes(scid_.data, static_cast(scid_.datalen)) != 1) + { + loge("[{}] init: RAND_bytes for SCID failed", log_prefix_); + return -1; + } + + ngtcp2_callbacks callbacks{}; + fill_callbacks(callbacks); + callbacks.recv_client_initial = ngtcp2_crypto_recv_client_initial_cb; + + ngtcp2_settings settings; + ngtcp2_transport_params params; + fill_settings(settings, params, server_.config().idle_timeout); + params.original_dcid = dcid; + params.original_dcid_present = 1; + + ngtcp2_path path{ + {const_cast(&ep_.addr.su.sa), ep_.addr.len}, + {const_cast(&remote_.su.sa), remote_.len}, + &ep_, + }; + + if (auto rv = ngtcp2_conn_server_new(&conn_, &scid, &scid_, &path, version, &callbacks, + &settings, ¶ms, nullptr, this); + rv != 0) + { + loge("[{}] ngtcp2_conn_server_new: {}", log_prefix_, ngtcp2_strerror(rv)); + return -1; + } + + if (setup_tls(tls_context().ctx, true /* server */) != 0) + return -1; + + logi("[{}] new connection, scid={} version=0x{:x}", log_prefix_, + ngtcp2::util::format_hex(scid_.data, scid_.datalen), version); + + return on_read(pi, data, remote_); +} + +// ------------------------------------------------------------------------------------------------- + +int Http3ServerSession::on_read(const ngtcp2_pkt_info& pi, std::span data, + const ngtcp2::Address& remote) +{ + ngtcp2_path path{ + {const_cast(&ep_.addr.su.sa), ep_.addr.len}, + {const_cast(&remote.su.sa), remote.len}, + &ep_, + }; + + return http3::Http3Session::on_read(path, pi, data); +} + +// ------------------------------------------------------------------------------------------------- + +int Http3ServerSession::handle_error(int /*rv*/) +{ + if (closed_) + return -1; + closed_ = true; + + // + // Idle timeout and drop-conn need no CONNECTION_CLOSE packet -- and with no packet there is + // no closing period either, so none of the cleanup in Http3ServerImpl::process_quic_batch() can + // ever run for this session: it is reached from the expiry timer precisely because nothing is + // arriving any more. Drop the session from the demux map right here instead, or it would sit + // in sessions_ for the lifetime of the server, holding streams whose request handlers + // are still waiting on a peer that went away. What is left of it then dies with do_session(). + // + if (last_error_.type == NGTCP2_CCERR_TYPE_IDLE_CLOSE || + last_error_.type == NGTCP2_CCERR_TYPE_DROP_CONN) + { + auto self = weak_from_this().lock(); // erase_quic_session() may drop the last reference + timer_.cancel(); + server_.erase_quic_session(this); + signal_done(); + return -1; + } + + // If already in draining (peer sent CONNECTION_CLOSE), don't reply. + // If already in closing, the buffered packet is still valid. + if (conn_ && !ngtcp2_conn_in_draining_period(conn_) && !ngtcp2_conn_in_closing_period(conn_)) + { + conn_closebuf_.resize(NGTCP2_MAX_UDP_PAYLOAD_SIZE); + ngtcp2_path_storage ps; + auto packet = write_connection_close(conn_closebuf_, ps); + if (!packet.empty()) + { + conn_closebuf_.resize(packet.size()); + logi("[{}] sending CONNECTION_CLOSE", log_prefix_); + send_udp(ep_, ps.path.remote.addr, ps.path.remote.addrlen, conn_closebuf_); + } + else + { + conn_closebuf_.clear(); + signal_done(); + return -1; + } + } + + // Stay alive for 3 PTO so we can resend the CONNECTION_CLOSE if the + // peer retransmits, or absorb late packets during the draining period. + schedule_close_timer(); + return -1; +} + +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) + { + if (ec) + return; + auto session = std::static_pointer_cast(self.lock()); + if (!session) + return; + logd("[{}] closing/draining period over", session->logPrefix()); + session->server_.erase_quic_session(session.get()); + session->signal_done(); + }); +} + +void Http3ServerSession::resend_conn_close() +{ + if (conn_closebuf_.empty()) + return; + auto* path = ngtcp2_conn_get_path(conn_); + if (!path) + return; + logd("[{}] resending CONNECTION_CLOSE", log_prefix_); + send_udp(ep_, path->remote.addr, path->remote.addrlen, conn_closebuf_); +} + +// ================================================================================================= +// Http3ServerImpl: the UDP socket, the receive loop and the connection-ID demux. +// ================================================================================================= + +Http3ServerImpl::Http3ServerImpl(Server::Impl& parent, const asio::ip::udp::endpoint& endpoint) + : parent_(parent) +{ + namespace socket_option = boost::asio::detail::socket_option; + + const bool is_v6 = endpoint.protocol() == ip::udp::v6(); + + socket_.emplace(config().use_strand ? asio::make_strand(parent_.get_executor()) + : parent_.get_executor()); + socket_->open(is_v6 ? ip::udp::v6() : ip::udp::v4()); + + if (is_v6) + { + boost::system::error_code ec; + socket_->set_option(ip::v6_only(false), ec); + socket_->set_option(socket_option::integer(1)); + socket_->set_option(socket_option::integer(1)); + socket_->set_option(socket_option::integer(1)); + } + else + { + socket_->set_option(socket_option::integer(1)); + socket_->set_option(socket_option::integer(1)); + } + socket_->set_option(socket_option::integer(1)); + socket_->non_blocking(true); + + socket_->bind(endpoint); + logi("Server: UDP listening on {}", endpoint); +} + +// ------------------------------------------------------------------------------------------------- + +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"); + }); +} + +void Http3ServerImpl::destroy() +{ + // + // The socket lives on its own strand and udp_receive_loop() keeps re-arming async_wait() on + // it there -- asio sockets are not thread-safe, so the close has to go through the same + // strand instead of racing that from here. The QUIC sessions have already been destroyed by + // 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() +} + +// ------------------------------------------------------------------------------------------------- + +void Http3ServerImpl::associate_quic_cid(const ngtcp2_cid& cid, Http3ServerSession* h) +{ + auto lock = std::lock_guard(mutex_); + sessions_.emplace(cid_key(cid), + std::static_pointer_cast(h->shared_from_this())); +} + +void Http3ServerImpl::dissociate_quic_cid(const ngtcp2_cid& cid) +{ + auto lock = std::lock_guard(mutex_); + sessions_.erase(cid_key(cid)); +} + +void Http3ServerImpl::erase_quic_session(Http3ServerSession* h) +{ + auto lock = std::lock_guard(mutex_); + std::erase_if(sessions_, [h](const auto& kv) { return kv.second.get() == h; }); +} + +// ------------------------------------------------------------------------------------------------- + +int Http3ServerImpl::udp_on_read(Endpoint& ep) +{ + ngtcp2::sockaddr_union su; + std::array buf; + ngtcp2_pkt_info pi{}; + + iovec msg_iov{buf.data(), buf.size()}; + msghdr msg{}; + msg.msg_name = &su; + msg.msg_iov = &msg_iov; + msg.msg_iovlen = 1; + + uint8_t + msg_ctrl[CMSG_SPACE(sizeof(int)) + CMSG_SPACE(sizeof(in6_pktinfo)) + CMSG_SPACE(sizeof(int))]; + msg.msg_control = msg_ctrl; + + // + // Datagrams collected per session over the whole batch. Each session gets its accumulated + // batch posted to its strand once, below, after every datagram the socket had queued has been + // demultiplexed -- so one aggregate pass on the strand can pack a whole response into a + // single GSO sendmsg() instead of dribbling it out per datagram. + // + boost::container::small_flat_map, QuicBatch, 32> batches; + for (size_t pktcnt = 0; pktcnt < 32; ++pktcnt) + { + if (pktcnt) + logd("- - {} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ", pktcnt); + + msg.msg_namelen = sizeof(su); + msg.msg_controllen = sizeof(msg_ctrl); + + auto nread = recvmsg(ep.fd, &msg, 0); + if (nread == -1) + { + if (errno != EAGAIN && errno != EWOULDBLOCK && errno != ENOTCONN) + loge("recvmsg: {}", strerror(errno)); + break; // socket drained (or broken): fall through to the write pass + } + + if (nread < 22) + continue; + + auto local_addr = ngtcp2::msghdr_get_local_addr(&msg, su.storage.ss_family); + if (!local_addr) + { + logw("could not obtain local address from cmsg"); + continue; + } + ngtcp2::set_port(*local_addr, ep.addr); + ep.addr = *local_addr; + + // When UDP_GRO is enabled the kernel may coalesce several datagrams + // from the same 4-tuple into one recvmsg result. The GRO cmsg carries + // the uniform segment size so we can split the buffer back into + // individual QUIC datagrams before handing them to ngtcp2. + uint16_t gro_size = 0; + for (auto* cm = CMSG_FIRSTHDR(&msg); cm; cm = CMSG_NXTHDR(&msg, cm)) + { + if (cm->cmsg_level == SOL_UDP && cm->cmsg_type == UDP_GRO) + { + memcpy(&gro_size, CMSG_DATA(cm), sizeof(gro_size)); + break; + } + } + + auto remote = to_ngtcp2_address(su.storage, msg.msg_namelen); + if (!remote) + { + logw("unsupported remote address family"); + continue; + } + + auto all_data = std::span{buf.data(), static_cast(nread)}; + const size_t seg_size = gro_size > 0 ? gro_size : all_data.size(); + + for (size_t segcnt = 0; !all_data.empty(); ++segcnt) + { + if (segcnt) + logd("- {} - - - - - - - - - - - - - - -", segcnt); + + auto data = all_data.subspan(0, std::min(seg_size, all_data.size())); + all_data = all_data.subspan(data.size()); + + if (drop_packet(ep.drop_rate_rx)) + { + // logw("*** dropping received packet ({} bytes) ***", data.size()); + continue; + } + + ngtcp2_version_cid vc; + auto rv = ngtcp2_pkt_decode_version_cid(&vc, data.data(), data.size(), QUIC_SCIDLEN); + if (rv != 0) + { + if (rv != NGTCP2_ERR_VERSION_NEGOTIATION) + logw("could not decode version/cid: {}", ngtcp2_strerror(rv)); + continue; + } + + auto key = cid_key(vc.dcid, vc.dcidlen); + std::shared_ptr session; + { + auto lock = std::lock_guard(mutex_); + if (auto it = sessions_.find(key); it != sessions_.end()) + session = it->second; + } + + if (!session) + { + ngtcp2_pkt_hd hd; + if (ngtcp2_accept(&hd, data.data(), data.size()) != 0) + continue; + + session = std::make_shared(*this, ep, *remote); + + // + // Publish the client-chosen DCID right away, so retransmitted Initials and + // follow-up packets -- in this batch or a later one -- find the session and queue + // up behind init() on its strand instead of spawning a duplicate session. init() + // itself, like everything that touches the connection, runs on the strand in + // process_quic_batch(). + // + auto lock = std::lock_guard(mutex_); + sessions_.emplace(std::move(key), session); + auto& batch = batches[session]; + batch.is_new = true; + batch.hd = hd; + } + + batches[session].datagrams.push_back({pi, *remote, {data.begin(), data.end()}}); + } + } + + // + // One job per session: its whole share of the receive batch, processed -- and answered with + // a single write pass -- on its own strand. + // + 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)); }); + } + + return 0; +} + +// ------------------------------------------------------------------------------------------------- + +// +// Runs on the session's strand: consumes the datagrams udp_on_read() collected for this session, +// serialized against the session's timers, wake_write() flushes and request handlers. This is +// what the demux loop used to do inline back when everything shared one implicit thread. +// +void Http3ServerImpl::process_quic_batch(const std::shared_ptr& session, + QuicBatch&& batch) +{ + size_t next = 0; + bool read_ok = false; + + if (batch.is_new) + { + const auto& first = batch.datagrams[next++]; + if (session->init(batch.hd.dcid, batch.hd.scid, batch.hd.version, first.pi, first.data) != 0) + { + // Matches the old inline behavior: a connection that failed at its Initial is + // forgotten; a retransmitted Initial starts over from scratch. + erase_quic_session(session.get()); + return; + } + read_ok = true; + + std::array scids; + auto num_scid = ngtcp2_conn_get_scid(session->conn(), nullptr); + if (num_scid <= scids.size()) + { + ngtcp2_conn_get_scid(session->conn(), scids.data()); + for (size_t i = 0; i < num_scid; ++i) + associate_quic_cid(scids[i], session.get()); + } + + // + // Register with the server's session registry + spawn the do_session() task so the + // session participates in server-wide shutdown, exactly like the TCP-based ones. + // Registration fails if the server was destroyed between udp_on_read() accepting this + // connection and this job running; the session is then ours to tear down. + // + if (!parent_.add_session(session)) + { + erase_quic_session(session.get()); + session->destroy(); + return; + } + + co_spawn(session->get_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); + }); + } + + for (; next < batch.datagrams.size(); ++next) + { + const auto& d = batch.datagrams[next]; + + // + // Handle closing / draining periods. During closing we resend the + // buffered CONNECTION_CLOSE so the peer can tear down cleanly. + // During draining (peer sent CONNECTION_CLOSE) we just drop the packet. + // In both cases the session stays in sessions_ until the 3-PTO + // close timer fires and calls erase_quic_session(). + // + if (auto* conn = session->conn()) + { + if (ngtcp2_conn_in_closing_period(conn)) + { + session->resend_conn_close(); + continue; + } + if (ngtcp2_conn_in_draining_period(conn)) + continue; + } + + // A session that died earlier in this very batch has nothing left to feed. + if (session->closed()) + continue; + + if (session->on_read(d.pi, d.data, d.remote) == 0) + { + read_ok = true; + } + else if (session->closed()) + { + // + // Only erase immediately when not in closing/draining period. + // If we are, the 3-PTO close timer in handle_error() will call + // erase_quic_session() once the period expires. + // + auto* conn = session->conn(); + if (!conn || + (!ngtcp2_conn_in_closing_period(conn) && !ngtcp2_conn_in_draining_period(conn))) + erase_quic_session(session.get()); + } + } + + // + // One write pass for the whole batch, mirroring the old per-receive-pass flush. + // + if (!read_ok) + return; + if (session->flush_write() == 0 || !session->closed()) + return; + + auto* conn = session->conn(); + if (!conn || (!ngtcp2_conn_in_closing_period(conn) && !ngtcp2_conn_in_draining_period(conn))) + erase_quic_session(session.get()); +} + +// ------------------------------------------------------------------------------------------------- + +awaitable Http3ServerImpl::udp_receive_loop() +{ + for (;;) + { + boost::system::error_code ec; + co_await socket_->async_wait(boost::asio::socket_base::wait_read, + redirect_error(use_awaitable, ec)); + if (ec) + { + if (ec == boost::asio::error::operation_aborted) + logi("UDP receive: {}", ec.message()); + else + logw("UDP receive: {}", ec.message()); + co_return; + } + + Endpoint ep{}; + ep.fd = socket_->native_handle(); + ep.drop_rate_rx = config().drop_rate_rx; + ep.drop_rate_tx = config().drop_rate_tx; + auto local = socket_->local_endpoint(); + auto data = local.data(); + std::memcpy(&ep.addr.su, data, local.size()); + ep.addr.len = local.size(); + + udp_on_read(ep); + } +} + +// ================================================================================================= + +std::shared_ptr make_http3_server(Server::Impl& server, + const asio::ip::udp::endpoint& endpoint) +{ + return std::make_shared(server, endpoint); +} + +// ================================================================================================= + +} // namespace anyhttp::server diff --git a/src/h3_session.cpp b/src/h3_session.cpp new file mode 100644 index 0000000..cf34568 --- /dev/null +++ b/src/h3_session.cpp @@ -0,0 +1,873 @@ +// +// Http3Session: one QUIC connection carrying HTTP/3, shared by the server and the client. +// See anyhttp/h3_session.hpp; the role-specific ends live in h3_server.cpp and +// h3_client.cpp. +// +#include "anyhttp/h3_session.hpp" +#include "anyhttp/h3_stream.hpp" +#include "anyhttp/literals.hpp" +#include "anyhttp/tls.hpp" + +#include +#include + +#include + +#include +#include +#include + +#include "ngtcp2/util.h" + +using namespace std::chrono_literals; +using namespace boost::asio; + +namespace anyhttp::http3 +{ + +// ================================================================================================= + +Http3Session::Http3Session(asio::any_io_executor executor) + : executor_(std::move(executor)), timer_(executor_), tx_buf_(64_k) +{ + ngtcp2_ccerr_default(&last_error_); +} + +Http3Session::~Http3Session() +{ + timer_.cancel(); + clear_streams(); + if (h3_) + nghttp3_conn_del(h3_); + if (conn_) + ngtcp2_conn_del(conn_); + if (ossl_ctx_) + { + if (auto ssl = ngtcp2_crypto_ossl_ctx_get_ssl(ossl_ctx_)) + { + SSL_set_app_data(ssl, nullptr); + SSL_free(ssl); + } + ngtcp2_crypto_ossl_ctx_del(ossl_ctx_); + } +} + +void Http3Session::clear_streams() { streams_.clear(); } + +// ------------------------------------------------------------------------------------------------- + +std::shared_ptr Http3Session::find_stream(int64_t id) +{ + auto it = streams_.find(id); + return it == streams_.end() ? nullptr : it->second; +} + +Http3Stream* Http3Session::create_stream(int64_t id) +{ + auto [it, inserted] = streams_.emplace(id, make_stream(id)); + return it->second.get(); +} + +void Http3Session::erase_stream(int64_t id) { streams_.erase(id); } + +// ------------------------------------------------------------------------------------------------- + +void Http3Session::consume_stream(int64_t stream_id, size_t n) +{ + if (n == 0 || !conn_) + return; + ngtcp2_conn_extend_max_stream_offset(conn_, stream_id, n); + wake_write(); // a WINDOW_UPDATE-equivalent frame needs to go out +} + +void Http3Session::reset_stream(int64_t stream_id, uint64_t app_error_code) +{ + if (!conn_) + return; + ngtcp2_conn_shutdown_stream(conn_, 0, stream_id, app_error_code); + wake_write(); +} + +void Http3Session::stop_reading(int64_t stream_id, uint64_t app_error_code) +{ + if (!conn_) + return; + ngtcp2_conn_shutdown_stream_read(conn_, 0, stream_id, app_error_code); + wake_write(); +} + +// ------------------------------------------------------------------------------------------------- + +void Http3Session::wake_write() +{ + // + // The write loop is normally only run in reaction to a packet arriving or a timer firing. When + // the application submits data outside those events, we need to kick it ourselves. + // + // Capture a weak_ptr, not shared_from_this(): wake_write() can be reached from a Reader/Writer + // destructor that runs as part of *this* session's own teardown (e.g. a still-in-flight + // request/response destroyed when everything is cancelled on shutdown), at which point + // shared_from_this() would throw bad_weak_ptr. + // + // One flush per wake, not one per submission. A response submits its headers, its body and its + // EOF separately, and posting for each means the first pass writes everything and the rest + // walk the connection for nothing -- and still re-arm the timer on the way out. Arming once + // and clearing when the flush runs is what ngtcp2's example server gets for free from + // ev_io_start() on an already-active watcher. + // + if (write_posted_) + return; + write_posted_ = true; + + asio::post(get_executor(), [self = weak_from_this()] + { + auto session = std::static_pointer_cast(self.lock()); + if (!session) + return; + session->write_posted_ = false; + if (session->closed_) + return; + session->flush_write(); + }); +} + +int Http3Session::flush_write() +{ + if (closed_ || !conn_) + return 0; + + if (auto rv = write_streams(); rv != 0) + return rv; + + update_timer(); + return 0; +} + +// ------------------------------------------------------------------------------------------------- + +ngtcp2_ssize Http3Session::write_pkt_cb(ngtcp2_conn*, ngtcp2_path* path, ngtcp2_pkt_info* pi, + uint8_t* dest, size_t destlen, ngtcp2_tstamp ts, + void* user_data) +{ + return static_cast(user_data)->write_pkt(path, pi, dest, destlen, ts); +} + +// +// Writes a single QUIC packet's worth of stream data into [dest, dest+destlen[. Called repeatedly +// by ngtcp2_conn_write_aggregate_pkt2() (once per packet it wants to pack into the shared TX +// buffer), so this must never send anything itself -- write_streams() decides when and how the +// accumulated packets go out. +// +ngtcp2_ssize Http3Session::write_pkt(ngtcp2_path* path, ngtcp2_pkt_info* pi, uint8_t* dest, + size_t destlen, ngtcp2_tstamp ts) +{ + std::array vec; + int64_t shut_down_stream = -1; // see NGTCP2_ERR_STREAM_NOT_FOUND below + + for (;;) + { + // + // Everything below runs inside ngtcp2_conn_write_aggregate_pkt2(), which hands us the one + // timestamp of this whole write pass and calls back once per packet it wants to pack. Should + // the connection have been closed in between -- a handler resumed from a stream close or a + // deferred consume can get there -- stop packing rather than feeding ngtcp2 a `ts` that is + // now in its past, which it asserts on. + // + if (closed_ || !conn_) + return 0; + + int64_t stream_id = -1; + int fin = 0; + nghttp3_ssize sveccnt = 0; + + if (h3_ && ngtcp2_conn_get_max_data_left(conn_)) + { + sveccnt = nghttp3_conn_writev_stream(h3_, &stream_id, &fin, vec.data(), vec.size()); + mlogd("write_pkt: nghttp3_conn_writev_stream -> stream={} sveccnt={} fin={}", stream_id, + sveccnt, fin); + if (sveccnt < 0) + { + mloge("nghttp3_conn_writev_stream: {}", nghttp3_strerror(static_cast(sveccnt))); + ngtcp2_ccerr_set_application_error( + &last_error_, nghttp3_err_infer_quic_app_error_code(static_cast(sveccnt)), + nullptr, 0); + return NGTCP2_ERR_CALLBACK_FAILURE; + } + } + + ngtcp2_ssize ndatalen; + uint32_t flags = NGTCP2_WRITE_STREAM_FLAG_MORE | NGTCP2_WRITE_STREAM_FLAG_PADDING; + if (fin) + flags |= NGTCP2_WRITE_STREAM_FLAG_FIN; + + auto nwrite = ngtcp2_conn_writev_stream( + conn_, path, pi, dest, destlen, &ndatalen, flags, stream_id, + reinterpret_cast(vec.data()), static_cast(sveccnt), ts); + + if (nwrite < 0) + { + switch (nwrite) + { + case NGTCP2_ERR_STREAM_DATA_BLOCKED: + if (h3_ && stream_id >= 0) + nghttp3_conn_block_stream(h3_, stream_id); + continue; + case NGTCP2_ERR_STREAM_SHUT_WR: + if (h3_ && stream_id >= 0) + nghttp3_conn_shutdown_stream_write(h3_, stream_id); + continue; + case NGTCP2_ERR_STREAM_NOT_FOUND: + // + // ngtcp2 has already torn the stream down (the peer reset it, or we did) while + // nghttp3 still had data queued for it. That's a dead stream, not a dead connection + // -- tell nghttp3 so it stops offering it and keep serving the others. Should nghttp3 + // offer the same stream again anyway, stop packing this packet rather than spinning + // here forever. + // + if (h3_ && stream_id >= 0 && stream_id != shut_down_stream) + { + mlogw("write_pkt: stream {} is gone, shutting down its write side", stream_id); + nghttp3_conn_shutdown_stream_write(h3_, stream_id); + nghttp3_conn_block_stream(h3_, stream_id); + shut_down_stream = stream_id; + continue; + } + return 0; + case NGTCP2_ERR_WRITE_MORE: + if (h3_ && stream_id >= 0 && ndatalen > 0) + { + if (auto rv = + nghttp3_conn_add_write_offset(h3_, stream_id, static_cast(ndatalen)); + rv != 0) + { + mloge("nghttp3_conn_add_write_offset: {}", nghttp3_strerror(rv)); + return NGTCP2_ERR_CALLBACK_FAILURE; + } + if (auto s = find_stream(stream_id)) + s->on_write_offered(static_cast(ndatalen)); + } + continue; + default: + mloge("ngtcp2_conn_writev_stream: {}", ngtcp2_strerror(static_cast(nwrite))); + ngtcp2_ccerr_set_liberr(&last_error_, static_cast(nwrite), nullptr, 0); + return NGTCP2_ERR_CALLBACK_FAILURE; + } + } + + if (ndatalen > 0 && h3_ && stream_id >= 0) + { + if (auto rv = nghttp3_conn_add_write_offset(h3_, stream_id, static_cast(ndatalen)); + rv != 0) + { + mloge("nghttp3_conn_add_write_offset: {}", nghttp3_strerror(rv)); + return NGTCP2_ERR_CALLBACK_FAILURE; + } + if (auto s = find_stream(stream_id)) + s->on_write_offered(static_cast(ndatalen)); + } + + return nwrite; + } +} + +// https://nghttp2.org/ngtcp2/programmers-guide.html#pseudo-code-for-writing-packets-with-gso +int Http3Session::write_streams() +{ + if (!conn_) + return 0; + if (ngtcp2_conn_in_closing_period(conn_) || ngtcp2_conn_in_draining_period(conn_)) + return 0; + + mlogd("write_streams: max_data_left={}", ngtcp2_conn_get_max_data_left(conn_)); + + ngtcp2_path_storage ps; + ngtcp2_pkt_info pi; + ngtcp2_path_storage_zero(&ps); + + size_t gso_size = 0; + auto nwrite = + ngtcp2_conn_write_aggregate_pkt2(conn_, &ps.path, &pi, tx_buf_.data(), tx_buf_.size(), + &gso_size, &write_pkt_cb, 0, ngtcp2::util::timestamp()); + if (nwrite < 0) + { + mloge("ngtcp2_conn_write_aggregate_pkt2: {}", ngtcp2_strerror(static_cast(nwrite))); + if (!last_error_.error_code) + ngtcp2_ccerr_set_liberr(&last_error_, static_cast(nwrite), nullptr, 0); + return handle_error(static_cast(nwrite)); + } + + if (nwrite == 0) + return 0; + + auto data = std::span{tx_buf_.data(), static_cast(nwrite)}; + return send_datagrams(ps.path, data, gso_size ? gso_size : data.size()); +} + +// ------------------------------------------------------------------------------------------------- + +void Http3Session::update_timer() { arm_timer_from_ngtcp2(); } + +void Http3Session::arm_timer_from_ngtcp2() +{ + if (closed_ || !conn_) + return; + + auto expiry = ngtcp2_conn_get_expiry(conn_); + if (expiry == UINT64_MAX) + { + // + // ngtcp2 has no pending timer. Cancel the current one so we don't accidentally keep an old + // retransmission timer alive past its purpose, and don't keep the io_context alive + // indefinitely. + // + timer_.cancel(); + return; + } + + auto now = ngtcp2::util::timestamp(); + asio::steady_timer::duration delay = + 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) + { + if (ec) + return; + if (auto session = std::static_pointer_cast(self.lock())) + session->handle_expiry(); + }); +} + +int Http3Session::handle_expiry() +{ + auto now = ngtcp2::util::timestamp(); + if (auto rv = ngtcp2_conn_handle_expiry(conn_, now); rv != 0) + { + // + // NGTCP2_ERR_IDLE_CLOSE is how a connection whose peer simply stopped talking ends -- an + // interrupted client leaves one behind per connection it had open -- so it is a normal end + // of life, not a failure worth a warning. handle_error() takes it from here; what makes it + // special is that the connection is then discarded silently, see there. + // + if (rv == NGTCP2_ERR_IDLE_CLOSE) + mlogi("idle timeout, dropping connection"); + else + mlogw("ngtcp2_conn_handle_expiry: {}", ngtcp2_strerror(rv)); + + ngtcp2_ccerr_set_liberr(&last_error_, rv, nullptr, 0); + return handle_error(rv); + } + return flush_write(); +} + +// ------------------------------------------------------------------------------------------------- + +int Http3Session::on_read(const ngtcp2_path& path, const ngtcp2_pkt_info& pi, + std::span data) +{ + mlogd("on_read: {} bytes", data.size()); + + auto rv = + ngtcp2_conn_read_pkt(conn_, &path, &pi, data.data(), data.size(), ngtcp2::util::timestamp()); + if (rv != 0) + { + if (rv == NGTCP2_ERR_DRAINING) + mlogd("ngtcp2_conn_read_pkt: draining"); + else + mlogw("ngtcp2_conn_read_pkt: {}", ngtcp2_strerror(rv)); + + if (rv == NGTCP2_ERR_CRYPTO && !last_error_.error_code) + ngtcp2_ccerr_set_tls_alert(&last_error_, ngtcp2_conn_get_tls_alert(conn_), nullptr, 0); + else if (!last_error_.error_code) + ngtcp2_ccerr_set_liberr(&last_error_, rv, nullptr, 0); + return handle_error(rv); + } + + // + // Deliberately no write here: the caller flushes once it has fed us everything that arrived, + // see flush_write(). + // + return 0; +} + +std::span Http3Session::write_connection_close(std::span buf, + ngtcp2_path_storage& ps) +{ + if (!conn_) + return {}; + + ngtcp2_pkt_info pi; + ngtcp2_path_storage_zero(&ps); + + auto nwrite = ngtcp2_conn_write_connection_close(conn_, &ps.path, &pi, buf.data(), buf.size(), + &last_error_, ngtcp2::util::timestamp()); + if (nwrite <= 0) + return {}; + return buf.first(static_cast(nwrite)); +} + +// ================================================================================================= +// Connection setup +// ================================================================================================= + +void Http3Session::fill_callbacks(ngtcp2_callbacks& callbacks) +{ + callbacks.recv_crypto_data = ngtcp2_crypto_recv_crypto_data_cb; + callbacks.handshake_completed = &Http3Session::cb_handshake_completed; + callbacks.encrypt = ngtcp2_crypto_encrypt_cb; + callbacks.decrypt = ngtcp2_crypto_decrypt_cb; + callbacks.hp_mask = ngtcp2_crypto_hp_mask_cb; + callbacks.recv_stream_data = &Http3Session::cb_recv_stream_data; + callbacks.acked_stream_data_offset = &Http3Session::cb_acked_stream_data_offset; + callbacks.stream_open = &Http3Session::cb_stream_open; + callbacks.stream_close = &Http3Session::cb_stream_close; + callbacks.rand = &Http3Session::cb_rand; + callbacks.get_new_connection_id = &Http3Session::cb_get_new_connection_id; + callbacks.remove_connection_id = &Http3Session::cb_remove_connection_id; + callbacks.update_key = ngtcp2_crypto_update_key_cb; + callbacks.stream_stop_sending = &Http3Session::cb_stream_stop_sending; + callbacks.stream_reset = &Http3Session::cb_stream_reset; + callbacks.extend_max_stream_data = &Http3Session::cb_extend_max_stream_data; + callbacks.extend_max_local_streams_bidi = &Http3Session::cb_extend_max_streams_bidi; + callbacks.extend_max_remote_streams_bidi = &Http3Session::cb_extend_max_streams_bidi; + callbacks.delete_crypto_aead_ctx = ngtcp2_crypto_delete_crypto_aead_ctx_cb; + callbacks.delete_crypto_cipher_ctx = ngtcp2_crypto_delete_crypto_cipher_ctx_cb; + callbacks.get_path_challenge_data = ngtcp2_crypto_get_path_challenge_data_cb; + callbacks.version_negotiation = ngtcp2_crypto_version_negotiation_cb; + callbacks.recv_rx_key = &Http3Session::cb_recv_rx_key; +} + +void Http3Session::fill_settings(ngtcp2_settings& settings, ngtcp2_transport_params& params, + std::chrono::nanoseconds idle_timeout) +{ + ngtcp2_settings_default(&settings); + settings.initial_ts = ngtcp2::util::timestamp(); + if (spdlog::default_logger_raw()->should_log(spdlog::level::trace)) + settings.log_printf = &http3::ngtcp2_log_printf; + + ngtcp2_transport_params_default(¶ms); + params.initial_max_stream_data_bidi_local = 256_k; + params.initial_max_stream_data_bidi_remote = 256_k; + params.initial_max_stream_data_uni = 256_k; + params.initial_max_data = 1_m; + params.initial_max_streams_bidi = 100; + params.initial_max_streams_uni = 3; + params.max_idle_timeout = static_cast(idle_timeout.count()); +} + +int Http3Session::setup_tls(SSL_CTX* ssl_ctx, bool is_server) +{ + auto* ssl = SSL_new(ssl_ctx); + if (!ssl) + { + mloge("SSL_new failed"); + return -1; + } + + conn_ref_.get_conn = &Http3Session::get_conn; + conn_ref_.user_data = this; + SSL_set_app_data(ssl, &conn_ref_); + + if (is_server) + SSL_set_accept_state(ssl); + else + SSL_set_connect_state(ssl); + + auto configure = is_server ? &ngtcp2_crypto_ossl_configure_server_session + : &ngtcp2_crypto_ossl_configure_client_session; + if (configure(ssl) != 0) + { + mloge("ngtcp2_crypto_ossl_configure_{}_session failed", is_server ? "server" : "client"); + SSL_free(ssl); + return -1; + } + + if (ngtcp2_crypto_ossl_ctx_new(&ossl_ctx_, ssl) != 0) + { + mloge("ngtcp2_crypto_ossl_ctx_new failed"); + SSL_free(ssl); + return -1; + } + + ngtcp2_conn_set_tls_native_handle(conn_, ossl_ctx_); + return 0; +} + +int Http3Session::setup_http3() +{ + if (h3_) + return 0; + + const bool is_server = ngtcp2_conn_is_server(conn_) != 0; + + nghttp3_callbacks h3cb{}; + h3cb.acked_stream_data = &Http3Session::h3_cb_acked_stream_data; + h3cb.stream_close = &Http3Session::h3_cb_stream_close; + h3cb.recv_data = &Http3Session::h3_cb_recv_data; + h3cb.deferred_consume = &Http3Session::h3_cb_deferred_consume; + h3cb.begin_headers = &Http3Session::h3_cb_begin_headers; + h3cb.recv_header = &Http3Session::h3_cb_recv_header; + h3cb.end_headers = &Http3Session::h3_cb_end_headers; + h3cb.end_stream = &Http3Session::h3_cb_end_stream; + h3cb.stop_sending = &Http3Session::h3_cb_stop_sending; + h3cb.reset_stream = &Http3Session::h3_cb_reset_stream; + + nghttp3_settings settings; + nghttp3_settings_default(&settings); + settings.qpack_max_dtable_capacity = 4096; + settings.qpack_blocked_streams = 100; + + if (is_server) + { + if (auto rv = nghttp3_conn_server_new(&h3_, &h3cb, &settings, nullptr, this); rv != 0) + { + mloge("nghttp3_conn_server_new: {}", nghttp3_strerror(rv)); + return -1; + } + auto params = ngtcp2_conn_get_local_transport_params(conn_); + nghttp3_conn_set_max_client_streams_bidi(h3_, params->initial_max_streams_bidi); + } + else if (auto rv = nghttp3_conn_client_new(&h3_, &h3cb, &settings, nullptr, this); rv != 0) + { + mloge("nghttp3_conn_client_new: {}", nghttp3_strerror(rv)); + return -1; + } + + int64_t ctrl_stream_id = -1; + if (auto rv = ngtcp2_conn_open_uni_stream(conn_, &ctrl_stream_id, nullptr); rv != 0) + { + mloge("open control stream: {}", ngtcp2_strerror(rv)); + return -1; + } + if (auto rv = nghttp3_conn_bind_control_stream(h3_, ctrl_stream_id); rv != 0) + { + mloge("nghttp3_conn_bind_control_stream: {}", nghttp3_strerror(rv)); + return -1; + } + + int64_t qpack_enc_stream_id = -1; + int64_t qpack_dec_stream_id = -1; + if (ngtcp2_conn_open_uni_stream(conn_, &qpack_enc_stream_id, nullptr) != 0 || + ngtcp2_conn_open_uni_stream(conn_, &qpack_dec_stream_id, nullptr) != 0) + { + mloge("open qpack streams failed"); + return -1; + } + if (auto rv = nghttp3_conn_bind_qpack_streams(h3_, qpack_enc_stream_id, qpack_dec_stream_id); + rv != 0) + { + mloge("nghttp3_conn_bind_qpack_streams: {}", nghttp3_strerror(rv)); + return -1; + } + + mlogi("HTTP/3 ready (ctrl={} qpack_enc={} qpack_dec={})", ctrl_stream_id, qpack_enc_stream_id, + qpack_dec_stream_id); + + on_http3_ready(); + return 0; +} + +// ================================================================================================= +// ngtcp2 callback implementations +// ================================================================================================= + +int Http3Session::cb_handshake_completed(ngtcp2_conn*, void* user) +{ + auto self = static_cast(user); + logi("[{}] TLS handshake completed: {}", self->log_prefix_, + tls_handshake_info(ngtcp2_crypto_ossl_ctx_get_ssl(self->ossl_ctx_))); + if (self->setup_http3() != 0) + return NGTCP2_ERR_CALLBACK_FAILURE; + return 0; +} + +int Http3Session::cb_recv_stream_data(ngtcp2_conn*, uint32_t flags, int64_t stream_id, + uint64_t offset, const uint8_t* data, size_t datalen, + void* user, void*) +{ + auto self = static_cast(user); + logd("[{}] cb_recv_stream_data: stream={} offset={} datalen={} fin={} h3_={}", self->log_prefix_, + stream_id, offset, datalen, !!(flags & NGTCP2_STREAM_DATA_FLAG_FIN), !!self->h3_); + if (!self->h3_) + { + logw("[{}] cb_recv_stream_data: DROPPING {} bytes on stream {} (h3 not ready)", + self->log_prefix_, datalen, stream_id); + return 0; + } + + auto nread = nghttp3_conn_read_stream(self->h3_, stream_id, data, datalen, + (flags & NGTCP2_STREAM_DATA_FLAG_FIN) ? 1 : 0); + if (nread < 0) + { + loge("[{}] nghttp3_conn_read_stream({}): {}", self->log_prefix_, stream_id, + nghttp3_strerror(static_cast(nread))); + ngtcp2_ccerr_set_application_error( + &self->last_error_, nghttp3_err_infer_quic_app_error_code(static_cast(nread)), + nullptr, 0); + return NGTCP2_ERR_CALLBACK_FAILURE; + } + + ngtcp2_conn_extend_max_stream_offset(self->conn_, stream_id, static_cast(nread)); + ngtcp2_conn_extend_max_offset(self->conn_, static_cast(nread)); + return 0; +} + +int Http3Session::cb_acked_stream_data_offset(ngtcp2_conn*, int64_t stream_id, uint64_t /*offset*/, + uint64_t datalen, void* user, void*) +{ + auto self = static_cast(user); + if (!self->h3_) + return 0; + if (auto rv = nghttp3_conn_add_ack_offset(self->h3_, stream_id, datalen); rv != 0) + { + loge("[{}] nghttp3_conn_add_ack_offset: {}", self->log_prefix_, nghttp3_strerror(rv)); + return NGTCP2_ERR_CALLBACK_FAILURE; + } + return 0; +} + +int Http3Session::cb_stream_open(ngtcp2_conn*, int64_t /*stream_id*/, void* /*user*/) { return 0; } + +int Http3Session::cb_stream_close(ngtcp2_conn*, uint32_t flags, int64_t stream_id, + uint64_t app_error_code, void* user, void*) +{ + auto self = static_cast(user); + if (!(flags & NGTCP2_STREAM_CLOSE_FLAG_APP_ERROR_CODE_SET)) + app_error_code = NGHTTP3_H3_NO_ERROR; + if (self->h3_) + { + if (auto rv = nghttp3_conn_close_stream(self->h3_, stream_id, app_error_code); rv != 0) + { + if (rv == NGHTTP3_ERR_STREAM_NOT_FOUND) + return 0; + loge("[{}] nghttp3_conn_close_stream({}): {}", self->log_prefix_, stream_id, + nghttp3_strerror(rv)); + return NGTCP2_ERR_CALLBACK_FAILURE; + } + } + return 0; +} + +void Http3Session::cb_rand(uint8_t* dest, size_t destlen, const ngtcp2_rand_ctx*) +{ + if (RAND_bytes(dest, static_cast(destlen)) != 1) + std::memset(dest, 0, destlen); +} + +int Http3Session::cb_get_new_connection_id(ngtcp2_conn*, ngtcp2_cid* cid, uint8_t* token, + size_t cidlen, void* user) +{ + auto self = static_cast(user); + if (RAND_bytes(cid->data, static_cast(cidlen)) != 1) + return NGTCP2_ERR_CALLBACK_FAILURE; + cid->datalen = cidlen; + if (RAND_bytes(token, NGTCP2_STATELESS_RESET_TOKENLEN) != 1) + return NGTCP2_ERR_CALLBACK_FAILURE; + self->on_new_cid(*cid); + return 0; +} + +int Http3Session::cb_remove_connection_id(ngtcp2_conn*, const ngtcp2_cid* cid, void* user) +{ + static_cast(user)->on_remove_cid(*cid); + return 0; +} + +int Http3Session::cb_extend_max_streams_bidi(ngtcp2_conn*, uint64_t /*max_streams*/, void* /*user*/) +{ + return 0; +} + +int Http3Session::cb_stream_stop_sending(ngtcp2_conn*, int64_t stream_id, uint64_t /*ec*/, + void* user, void*) +{ + auto self = static_cast(user); + if (!self->h3_) + return 0; + if (auto rv = nghttp3_conn_shutdown_stream_read(self->h3_, stream_id); rv != 0) + { + loge("[{}] nghttp3_conn_shutdown_stream_read({}): {}", self->log_prefix_, stream_id, + nghttp3_strerror(rv)); + return NGTCP2_ERR_CALLBACK_FAILURE; + } + return 0; +} + +int Http3Session::cb_stream_reset(ngtcp2_conn*, int64_t stream_id, uint64_t /*final_size*/, + uint64_t /*ec*/, void* user, void*) +{ + auto self = static_cast(user); + if (!self->h3_) + return 0; + if (auto rv = nghttp3_conn_shutdown_stream_read(self->h3_, stream_id); rv != 0) + { + loge("[{}] nghttp3_conn_shutdown_stream_read({}): {}", self->log_prefix_, stream_id, + nghttp3_strerror(rv)); + return NGTCP2_ERR_CALLBACK_FAILURE; + } + return 0; +} + +int Http3Session::cb_extend_max_stream_data(ngtcp2_conn*, int64_t stream_id, uint64_t /*max_data*/, + void* user, void*) +{ + auto self = static_cast(user); + if (!self->h3_) + return 0; + if (auto rv = nghttp3_conn_unblock_stream(self->h3_, stream_id); rv != 0) + { + loge("[{}] nghttp3_conn_unblock_stream({}): {}", self->log_prefix_, stream_id, + nghttp3_strerror(rv)); + return NGTCP2_ERR_CALLBACK_FAILURE; + } + return 0; +} + +int Http3Session::cb_recv_rx_key(ngtcp2_conn*, ngtcp2_encryption_level level, void* user) +{ + if (level != NGTCP2_ENCRYPTION_LEVEL_1RTT) + return 0; + auto self = static_cast(user); + if (!self->h3_ && self->setup_http3() != 0) + return NGTCP2_ERR_CALLBACK_FAILURE; + return 0; +} + +// ================================================================================================= +// nghttp3 callback implementations +// ================================================================================================= + +// +// The only notification that the peer is done with body bytes we handed out by reference, and +// hence that the caller's buffer may be released -- see WriteMode::ZeroCopy. +// +int Http3Session::h3_cb_acked_stream_data(nghttp3_conn*, int64_t stream_id, uint64_t datalen, + void* user, void*) +{ + auto self = static_cast(user); + auto stream = self->find_stream(stream_id); + if (!stream) + return 0; + + // + // Completing a write resumes the application, which may drop the last reference to this + // session -- while ngtcp2 is still in the middle of processing the ACK that got us here. + // weak_from_this(), not shared_from_this(): the ACK may well arrive during teardown. + // + auto session_guard = self->weak_from_this().lock(); + stream->on_write_acked(static_cast(datalen)); + return 0; +} + +int Http3Session::h3_cb_stream_close(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, + void* user, void*) +{ + auto self = static_cast(user); + logd("[{}] h3 stream {} closed", self->log_prefix_, stream_id); + if (auto s = self->find_stream(stream_id)) + { + // + // Anything still pending on this stream must be completed now: nothing will ever arrive for + // it again, and a stream that ended in an error has, by definition, not delivered its whole + // message. + // + auto ec = (app_error_code == NGHTTP3_H3_NO_ERROR) + ? boost::system::error_code{} + : boost::system::errc::make_error_code(boost::system::errc::connection_reset); + s->fail(ec); + } + if (ngtcp2_conn_is_server(self->conn_)) + ngtcp2_conn_extend_max_streams_bidi(self->conn_, 1); + return 0; +} + +int Http3Session::h3_cb_recv_data(nghttp3_conn*, int64_t stream_id, const uint8_t* data, + size_t datalen, void* user, void*) +{ + // + // Connection-level credit is granted immediately: it is a single pool shared with control/QPACK + // streams that nghttp3 manages on its own (the app never "reads" those), so withholding it here + // would stall unrelated traffic whenever this one stream's reader is slow. Only the *stream*- + // level credit for these bytes is deliberately deferred -- see Http3Session::consume_stream(). + // Granting it only once the application actually reads the data (in + // Http3Stream::call_read_handler()) is what makes body backpressure real instead of nghttp3 + // buffering an unbounded backlog in pending_read while the peer keeps sending on *this* stream. + // + auto self = static_cast(user); + ngtcp2_conn_extend_max_offset(self->conn_, datalen); + if (auto s = self->find_stream(stream_id)) + s->on_data_chunk(data, datalen); + return 0; +} + +int Http3Session::h3_cb_deferred_consume(nghttp3_conn*, int64_t stream_id, size_t nconsumed, + void* user, void*) +{ + auto self = static_cast(user); + ngtcp2_conn_extend_max_stream_offset(self->conn_, stream_id, nconsumed); + ngtcp2_conn_extend_max_offset(self->conn_, nconsumed); + return 0; +} + +int Http3Session::h3_cb_begin_headers(nghttp3_conn*, int64_t stream_id, void* user, void*) +{ + // + // On the server this is where a stream comes into being: the peer opened it. On the client the + // stream was created by async_submit() long before its response arrives, so this finds it. + // + auto self = static_cast(user); + if (!self->find_stream(stream_id)) + self->create_stream(stream_id); + return 0; +} + +int Http3Session::h3_cb_recv_header(nghttp3_conn*, int64_t stream_id, int32_t /*token*/, + nghttp3_rcbuf* name, nghttp3_rcbuf* value, uint8_t /*flags*/, + void* user, void*) +{ + auto self = static_cast(user); + auto n = nghttp3_rcbuf_get_buf(name); + auto v = nghttp3_rcbuf_get_buf(value); + + if (auto s = self->find_stream(stream_id)) + s->on_header(std::string_view{reinterpret_cast(n.base), n.len}, + std::string_view{reinterpret_cast(v.base), v.len}); + return 0; +} + +int Http3Session::h3_cb_end_headers(nghttp3_conn*, int64_t stream_id, int /*fin*/, void* user, + void*) +{ + auto self = static_cast(user); + if (auto s = self->find_stream(stream_id)) + s->on_end_headers(); + return 0; +} + +int Http3Session::h3_cb_end_stream(nghttp3_conn*, int64_t stream_id, void* user, void*) +{ + auto self = static_cast(user); + if (auto s = self->find_stream(stream_id)) + s->on_eof(); + return 0; +} + +int Http3Session::h3_cb_stop_sending(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, + void* user, void*) +{ + auto self = static_cast(user); + ngtcp2_conn_shutdown_stream_read(self->conn_, 0, stream_id, app_error_code); + return 0; +} + +int Http3Session::h3_cb_reset_stream(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, + void* user, void*) +{ + auto self = static_cast(user); + ngtcp2_conn_shutdown_stream_write(self->conn_, 0, stream_id, app_error_code); + return 0; +} + +// ================================================================================================= + +} // namespace anyhttp::http3 diff --git a/src/h3_stream.cpp b/src/h3_stream.cpp new file mode 100644 index 0000000..3792660 --- /dev/null +++ b/src/h3_stream.cpp @@ -0,0 +1,750 @@ +// +// Http3Stream: one HTTP/3 request/response exchange, shared by the server and the client. +// See anyhttp/h3_stream.hpp for the model; the role-specific ends of it live in +// h3_server.cpp and h3_client.cpp. +// +#include "anyhttp/h3_stream.hpp" +#include "anyhttp/formatter.hpp" // IWYU pragma: keep +#include "anyhttp/h3_session.hpp" + +#include +#include +#include + +#include + +using namespace boost::asio; +namespace errc = boost::system::errc; + +namespace anyhttp::http3 +{ + +// ================================================================================================= + +Http3Stream::Http3Stream(Http3Session& s, int64_t stream_id, WriteMode mode) + : id(stream_id), session(s), write_mode(mode) +{ + log_prefix = std::format("{}.{}", session.logPrefix(), id); + mlogd("\x1b[1;33mStream: ctor\x1b[0m"); +} + +Http3Stream::~Http3Stream() +{ + mlogd("\x1b[33mStream: dtor... \x1b[0m"); + // + // A Http3Writer/Http3Reader (owned by the user-visible Request/Response) can outlive this + // stream, e.g. when the session tears down its streams while a suspended coroutine still holds + // one. Detach them so their destructors don't dereference a freed stream. + // + if (reader) + reader->detach(); + if (writer) + writer->detach(); + if (read_handler) + swap_and_invoke(read_handler, errc::make_error_code(errc::connection_reset), 0); + if (write_active && write_handler) + swap_and_invoke(write_handler, errc::make_error_code(errc::connection_reset)); + mlogd("\x1b[33mStream: dtor... done\x1b[0m"); +} + +asio::any_io_executor Http3Stream::get_executor() const noexcept { return session.get_executor(); } + +// ================================================================================================= +// Incoming body +// ================================================================================================= + +void Http3Stream::on_data_chunk(const uint8_t* data, size_t len) +{ + if (len == 0) + return; + + // + // nghttp3 hands us a view into the packet it is parsing, valid only until this callback + // returns. Offer it to a waiting reader as it stands before copying it anywhere: a handler + // that keeps a read outstanding -- the usual shape -- takes the bytes with a single copy, and + // the vector that would otherwise carry them (a malloc, a copy in, a copy out and a free, per + // QUIC packet, so about fifty of each per 64k of body) is never created at all. Only what the + // reader could not take is parked for later. + // + auto self = shared_from_this(); // a resumed reader may drop the last reference to this stream + incoming = asio::const_buffer{data, len}; + call_read_handler(); + + if (incoming.size() > 0) + { + auto* rest = static_cast(incoming.data()); + pending_read.emplace_back(rest, rest + incoming.size()); + if (read_head.size() == 0) + read_head = asio::buffer(pending_read.front()); + incoming = {}; + } +} + +void Http3Stream::on_eof() +{ + eof_received = true; + call_read_handler(); +} + +void Http3Stream::call_read_handler() +{ + // + // swap_and_invoke() below may resume a user coroutine that calls async_read_some() again + // before returning, which re-enters this function. Letting that nested call do real work would + // recurse once per buffered chunk -- with enough data queued up (e.g. after a large backlog + // drains), that blows the C++ stack. Instead, the nested call just re-arms read_handler and + // returns; the outer call's loop below picks it up and keeps going without growing the stack. + // + if (!read_handler || call_read_handler_active) + return; + + // + // The loop below may resume a coroutine that drops the last owning reference to this stream + // (e.g. the Response gets destroyed once EOF is delivered) -- or to the whole Session, when + // that coroutine was the last user of the connection. Keep both alive until this function + // returns: consume_stream() at the bottom dereferences the session, so outliving the stream + // alone is not enough. + // + auto self = shared_from_this(); + auto session_guard = session.shared_from_this(); + + call_read_handler_active = true; + size_t consumed = 0; + while (read_handler) + { + if (read_head.size() > 0 || incoming.size() > 0) + { + // + // Fill the caller's buffer from as many queued chunks as it takes, rather than stopping + // at the end of the first one. Each chunk is what arrived in a single QUIC packet -- a + // little over a kilobyte -- so handing them out one per read turns a 64k body into ~48 + // reads, and a handler that answers every read with a write (an echo) pays a full round + // trip for each of them when the write only completes on acknowledgement (see + // WriteMode::ZeroCopy). + // + auto dest = read_handler_buffer; + size_t copied = 0; + while (dest.size() > 0 && read_head.size() > 0) + { + auto n = asio::buffer_copy(dest, read_head); + dest += n; + read_head += n; + copied += n; + if (read_head.size() == 0) + { + pending_read.pop_front(); + read_head = + pending_read.empty() ? asio::const_buffer{} : asio::buffer(pending_read.front()); + } + } + + // + // ... and last from the chunk being delivered right now, which on_data_chunk() offers + // through `incoming` instead of parking it in a vector of its own first. Queued chunks + // go first: they arrived earlier. + // + if (dest.size() > 0 && incoming.size() > 0) + { + auto n = asio::buffer_copy(dest, incoming); + incoming += n; + copied += n; + } + + consumed += copied; + swap_and_invoke(read_handler, boost::system::error_code{}, copied); + continue; + } + + if (eof_received) + { + // 0-byte read = EOF, matching the beast/nghttp2 convention. + swap_and_invoke(read_handler, boost::system::error_code{}, 0); + continue; + } + + if (closed) + { + // + // The stream died before the body was complete, and this read was issued after that -- + // there is nothing left that could ever complete it, so report the truncation now rather + // than leaving it pending forever. + // + swap_and_invoke(read_handler, boost::beast::http::error::partial_message, 0); + continue; + } + + break; + } + call_read_handler_active = false; + + // + // Grant the peer more send credit only for what was actually delivered to the app -- see + // Http3Session::consume_stream() for why this must not happen any earlier. + // + session.consume_stream(id, consumed); +} + +// ================================================================================================= +// Outgoing body +// ================================================================================================= + +void Http3Stream::start_write(WriteHandler&& handler, asio::const_buffer buffer) +{ + auto n = asio::buffer_size(buffer); + const bool is_eof = (n == 0); + logd("[{}] start_write: n={} is_eof={}", log_prefix, n, is_eof); + + // + // Once accepted, the caller's intent to end the body is final: this is what tells + // delete_writer() the body ended where it was meant to, so it need not reset the stream. An + // earlier cancellation just makes for a shorter body than planned -- legitimate here, and a + // declared content-length is still enforced by the peer. + // + if (is_eof && eof_submitted) + { + // + // The body was already ended. If that FIN is still pending (its handler was detached by + // cancellation, see bind_write_cancellation()), adopt this handler so it completes when the + // FIN actually goes out; otherwise the FIN is long gone and there is nothing left to do. + // + if (write_active && write_is_eof) + { + logd("[{}] start_write: FIN already pending, adopting handler", log_prefix); + bind_write_cancellation(handler, write_token); + write_handler = std::move(handler); + } + else if (handler) + { + asio::any_completion_executor ex = + asio::get_associated_immediate_executor(handler, get_executor()); + ex.execute([handler = std::move(handler)]() mutable + { std::move(handler)(boost::system::error_code{}); }); + } + return; + } + + // + // Only one async_write() may be active at a time -- see the class comment above write_active. + // The re-issued EOF handled above is not an exception to that: it adopts the FIN that is + // already in flight instead of starting a write of its own, and has returned by now. + // + assert(!write_active); + + if (is_eof) + eof_submitted = true; + + const uint64_t token = next_write_token++; + bind_write_cancellation(handler, token); + + write_active = true; + write_source = buffer; // referenced, not copied -- see class comment above write_active + write_offered = 0; + write_acked = 0; + write_source_copied = 0; + write_chunk.clear(); + write_confirmed = 0; + write_is_eof = is_eof; + write_token = token; + write_handler = std::move(handler); + + if (auto h3 = session.h3()) + nghttp3_conn_resume_stream(h3, id); + session.wake_write(); +} + +void Http3Stream::bind_write_cancellation(WriteHandler& handler, uint64_t token) +{ + // Nothing to bind for a caller that passed no completion handler. + if (!handler) + return; + + auto cs = asio::get_associated_cancellation_slot(handler); + if (!cs.is_connected() || cs.has_handler()) + return; + + 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. + // + if (write_token != token || !write_handler) + return; // already completed naturally before the cancellation was delivered + + if (write_is_eof) + { + // + // The body has already been declared ended, and a FIN cannot be un-sent -- it may just + // still be waiting for flow control credit. Detach the handler but leave the write + // active so it still goes out: abandoning it would leave the stream half-open forever, + // with the peer waiting for an end that never comes. + // + logd("[{}] async_write: \x1b[1;31mcancelled\x1b[0m ({}), FIN still pending", log_prefix, + ct); + asio::post(get_executor(), [handler = std::move(write_handler)]() mutable { // + std::move(handler)(errc::make_error_code(errc::operation_canceled)); + }); + return; + } + logd("[{}] async_write: \x1b[1;31mcancelled\x1b[0m ({})", log_prefix, ct); + + if (write_mode == WriteMode::ZeroCopy) + { + // + // The handler runs now, and the caller is free to destroy its buffer the moment it does + // -- but nghttp3/ngtcp2 point straight into that buffer (see WriteMode::ZeroCopy), so + // whatever was offered and is not acknowledged yet has to stop being referenced first. + // Only a reset can guarantee that: RESET_STREAM makes ngtcp2 drop the stream's queued + // data and keeps it from reclaiming in-flight bytes for retransmission. That costs + // nothing in expressiveness -- a body cut short mid-write is truncated, which is exactly + // what delete_writer() resets the stream for as well. + // + // A write that never got to offer a byte, or whose bytes are all acknowledged already, + // leaves nothing behind and lets the stream carry on unharmed. + // + if (write_offered > write_acked && !closed) + { + logw("[{}] async_write: cancelled with {} bytes unacknowledged, resetting stream", + log_prefix, write_offered - write_acked); + session.reset_stream(id, NGHTTP3_H3_REQUEST_CANCELLED); + closed = true; + } + } + else + { + // + // Staged mode never lets nghttp3 see the caller's buffer, only our own copy of it, so + // the un-copied remainder can simply be abandoned -- same as HTTP/2, where cancelling + // drops the unsent remainder of write_buffer. Bytes already offered still go out (they + // can't be un-offered), so write_chunk is retired to in_flight_writes to keep that + // memory alive. The stream stays healthy, and the caller may issue a fresh async_write() + // as soon as the handler fires. + // + if (!write_chunk.empty()) + in_flight_writes.emplace_back(std::move(write_chunk)); + write_chunk.clear(); // moved-from + } + + write_active = false; + write_source = {}; + // make sure to post this -- otherwise "MAIN COROUTINE DID NOT COMPLETE" happens + asio::post(get_executor(), [handler = std::move(write_handler)]() mutable { // + std::move(handler)(errc::make_error_code(errc::operation_canceled)); + }); + }); +} + +nghttp3_ssize Http3Stream::data_reader(nghttp3_vec* vec, size_t veccnt, uint32_t* pflags) +{ + if (veccnt == 0) + return 0; + + if (!write_active) + return NGHTTP3_ERR_WOULDBLOCK; + + if (write_mode == WriteMode::ZeroCopy) + { + // + // Hand out what is left of the caller's buffer, by reference and in one go: a nghttp3_vec + // is just a pointer and a length, so there is nothing to be gained from slicing it up, and + // nghttp3 gets to frame the whole thing as a single DATA frame. It picks up the rest by + // itself as packets are filled -- see Http3Session::write_pkt(), which keeps feeding the + // same vec to ngtcp2_conn_writev_stream() and advances nghttp3 by whatever went into the + // packet. + // + const size_t total = asio::buffer_size(write_source); + if (write_offered < total) + { + auto* base = static_cast(write_source.data()) + write_offered; + vec[0].base = const_cast(base); // nghttp3 reads through this, never writes + vec[0].len = total - write_offered; + write_offered = total; // don't offer these bytes twice -- see class comment above + // write_active + return 1; + } + } + else + { + if (write_offered < write_chunk.size()) + { + vec[0].base = write_chunk.data() + write_offered; + vec[0].len = write_chunk.size() - write_offered; + write_offered = write_chunk.size(); // don't re-offer these bytes on a repeat call + return 1; + } + + // + // Current chunk fully offered. If it hasn't been confirmed yet (on_write_offered()), there + // is nothing new until that happens -- carving off the next slice of write_source early + // would have nghttp3 take the repeat offer as additional stream bytes. + // + if (write_confirmed < write_chunk.size()) + return NGHTTP3_ERR_WOULDBLOCK; + + // + // The current chunk is fully drained; retire it (ngtcp2 may still need this exact memory + // for retransmission until acked) and pull the next bounded slice out of write_source, if + // any. + // + if (!write_chunk.empty()) + in_flight_writes.emplace_back(std::move(write_chunk)); + + const size_t remaining = asio::buffer_size(write_source) - write_source_copied; + if (remaining > 0) + { + const size_t take = std::min(remaining, kWriteChunkSize); + auto* src = static_cast(write_source.data()) + write_source_copied; + write_chunk.assign(src, src + take); + write_source_copied += take; + write_offered = write_chunk.size(); + write_confirmed = 0; + vec[0].base = write_chunk.data(); + vec[0].len = write_chunk.size(); + return 1; + } + } + + // + // Everything has been offered. For a body write there is nothing new until the caller starts + // the next one (which resumes the stream), so block here rather than returning 0 bytes -- + // returning 0 without NGHTTP3_DATA_FLAG_EOF would tell nghttp3 the body ended. + // + if (!write_is_eof) + return NGHTTP3_ERR_WOULDBLOCK; + + // + // The EOF marker (write_source is always empty for it) completes as soon as nghttp3 has taken + // the FIN: unlike body data, a FIN carries no memory of the caller's that we would have to + // keep alive until it is acknowledged. + // + *pflags |= NGHTTP3_DATA_FLAG_EOF; + finish_active_write(); + return 0; +} + +void Http3Stream::on_write_acked(size_t n) +{ + // + // n counts *application* data acknowledged on this stream -- nghttp3 accounts for the HTTP/3 + // framing it puts around the body itself, so unlike ngtcp2's stream offsets these bytes are + // exactly the ones the caller handed us. All of them belong to the write currently active: a + // write only completes once every byte it offered is acknowledged, so nothing can still be + // outstanding from an earlier one. Clamp defensively anyway -- an accounting mismatch should + // complete the write early, not run write_acked past the end of the buffer. + // + if (write_mode != WriteMode::ZeroCopy) + return; // a staged write is long done by the time its bytes are acknowledged + if (n == 0 || !write_active || write_is_eof) + return; + + write_acked = std::min(write_acked + n, asio::buffer_size(write_source)); + logd("[{}] on_write_acked: {} bytes, {}/{} acknowledged", log_prefix, n, write_acked, + asio::buffer_size(write_source)); + + if (write_acked == asio::buffer_size(write_source)) + finish_active_write(); +} + +void Http3Stream::on_write_offered(size_t n) +{ + // + // n is the number of bytes of *stream* data ngtcp2 just committed to a packet, which also + // includes the HTTP/3 HEADERS frame nghttp3 sends ahead of any body -- e.g. the very first + // write pass after async_submit() drains the headers before there is an active write yet. + // Only attribute bytes once there is an active, non-EOF write to charge them against; clamp + // defensively in case a single packet still straddles the header/body boundary. + // + if (write_mode != WriteMode::Staged) + return; // a zero-copy write completes on acknowledgement, not on handover + if (n == 0 || !write_active || write_is_eof) + return; + + n = std::min(n, write_chunk.size() - write_confirmed); + write_confirmed += n; + + if (write_confirmed < write_chunk.size()) + return; + + // + // The write is fully done once its current chunk is confirmed and there is no more of + // write_source left to carve into further chunks -- data_reader() advances write_chunk/ + // write_source_copied otherwise, so this is the terminal state. + // + if (write_source_copied == asio::buffer_size(write_source)) + { + finish_active_write(); + return; + } + + // + // There is more of write_source to carve into chunks, but nghttp3 may have asked for data + // while this chunk was offered and still unconfirmed, in which case data_reader() answered + // NGHTTP3_ERR_WOULDBLOCK -- and a blocked stream is never polled again until it is explicitly + // resumed. Now that the chunk is confirmed, there is something new to hand out, so unblock the + // stream. Without this, any single async_write() larger than kWriteChunkSize stalls here + // forever, with the body truncated and no FIN. + // + if (auto h3 = session.h3()) + nghttp3_conn_resume_stream(h3, id); + session.wake_write(); +} + +void Http3Stream::finish_active_write() +{ + assert(write_active); + + // + // Invoking the handler hands the caller's buffer back to it, so this must only ever run when + // nothing points into it any more: every offered byte acknowledged (ZeroCopy), copied out + // (Staged), or no bytes offered at all (the EOF marker). A staged chunk stays alive past that + // in in_flight_writes -- ngtcp2 may still retransmit from it. + // + if (write_mode == WriteMode::Staged && !write_chunk.empty()) + in_flight_writes.emplace_back(std::move(write_chunk)); + write_chunk.clear(); // moved-from + write_source = {}; + auto handler = std::move(write_handler); + write_active = false; + + if (!handler) + return; + + // + // Every path here runs inside a nghttp3 callback and hence inside an ngtcp2 call: the EOF + // marker completes from data_reader() and a staged chunk from on_write_offered(), both while + // ngtcp2 is packing a packet, and acknowledged data from acked_stream_data while it is parsing + // one. Resuming the application there would let it call back into ngtcp2 from inside ngtcp2 + // (destroying the session writes a CONNECTION_CLOSE, say), which at best confuses the write + // pass this is nested in and at worst trips ngtcp2's own "time must not go backwards" + // assertion. Post instead -- one hop, on a path that is not latency critical. + // + asio::post(get_executor(), [self = shared_from_this(), handler = std::move(handler)]() mutable + { swap_and_invoke(handler, boost::system::error_code{}); }); +} + +// ================================================================================================= +// Headers +// ================================================================================================= + +void Http3Stream::on_header(std::string_view name, std::string_view value) +{ + if (spdlog::default_logger_raw()->should_log(spdlog::level::debug)) + received_headers.emplace_back(name, value); + + try + { + if (name.starts_with(':')) + on_pseudo_header(name, value); + else if (name == "content-length") + { + size_t len = 0; + if (std::from_chars(value.begin(), value.end(), len).ec == std::errc{}) + content_length = len; + } + else + fields.set(name, value); + } + catch (const std::exception& ex) + { + logw("[{}] ignoring invalid header: {} ({})", log_prefix, value, ex.what()); + } +} + +void Http3Stream::on_end_headers() +{ + headers_received = true; + on_headers_complete(); +} + +namespace +{ +nghttp3_ssize stream_read_data(nghttp3_conn*, int64_t /*stream_id*/, nghttp3_vec* vec, + size_t veccnt, uint32_t* pflags, void* /*conn_user*/, + void* stream_user) +{ + auto s = static_cast(stream_user); + return s->data_reader(vec, veccnt, pflags); +} +} // namespace + +bool Http3Stream::submit_headers(std::span nva, bool is_request) +{ + auto* h3 = session.h3(); + if (!h3) + { + loge("[{}] submit_headers: HTTP/3 layer is gone", log_prefix); + return false; + } + + // + // nghttp3 pulls the body out of this stream through data_reader(), with the stream itself as + // the per-stream user data it hands back. + // + nghttp3_data_reader dr{}; + dr.read_data = &stream_read_data; + + auto* nv = const_cast(nva.data()); + if (is_request) + { + if (auto rv = nghttp3_conn_submit_request(h3, id, nv, nva.size(), &dr, this); rv != 0) + { + loge("[{}] nghttp3_conn_submit_request: {}", log_prefix, nghttp3_strerror(rv)); + return false; + } + } + else + { + if (auto rv = nghttp3_conn_set_stream_user_data(h3, id, this); rv != 0) + { + loge("[{}] nghttp3_conn_set_stream_user_data: {}", log_prefix, nghttp3_strerror(rv)); + return false; + } + if (auto rv = nghttp3_conn_submit_response(h3, id, nv, nva.size(), &dr); rv != 0) + { + loge("[{}] nghttp3_conn_submit_response: {}", log_prefix, nghttp3_strerror(rv)); + return false; + } + } + + headers_submitted = true; + log_headers(log_prefix, nva); + return true; +} + +// ================================================================================================= +// Lifecycle +// ================================================================================================= + +void Http3Stream::fail(boost::system::error_code ec) +{ + // + // The handlers below may run synchronously and drop the last owning reference to this stream + // (e.g. the coroutine they resume destroys its Request/Response), reentrantly erasing it from + // the session. Keep it alive until fail() itself returns. + // + auto self = shared_from_this(); + closed = true; + + if (read_handler) + { + // + // The stream died before the body was complete. What the reader cares about is that it will + // never see the rest of it, not which QUIC error code carried that news -- report the + // truncation, matching what the HTTP/2 side delivers for a stream closing early. + // + auto read_ec = (ec && !eof_received) ? boost::beast::http::error::partial_message : ec; + swap_and_invoke(read_handler, read_ec, 0); + } + + on_failed(ec); + + // + // A write waiting for its data to be acknowledged will never see those acknowledgements now: + // ngtcp2 drops whatever of this stream is still in flight. It also stops touching the caller's + // buffer, which is all the wait was ever for, so complete the write -- as failed, because the + // body did not make it -- instead of leaving it pending forever. + // + if (write_active && write_handler) + { + write_active = false; + write_source = {}; + swap_and_invoke(write_handler, ec ? ec : errc::make_error_code(errc::connection_reset)); + } + + maybe_close(); +} + +void Http3Stream::delete_reader() +{ + auto self = shared_from_this(); // see delete_writer() + pending_read.clear(); + read_head = {}; + incoming = {}; + + // + // The application dropped its side of the exchange without reading the body to its end (e.g. + // not_found(), which never looks at the request). Stream-level flow control credit is only + // granted as the application actually reads (see Http3Session::consume_stream()), so a peer + // with more body to send would stall forever against a window that will now never reopen. + // Tell it to stop instead: STOP_SENDING half-closes only our read direction, leaving whatever + // we are still writing to flow normally -- HTTP/2 has to submit a full RST_STREAM here for + // lack of a half-close. + // + if (!eof_received && !closed) + { + logd("[{}] delete_reader: body not read to end, sending STOP_SENDING", log_prefix); + session.stop_reading(id, NGHTTP3_H3_NO_ERROR); + } + + maybe_close(); +} + +void Http3Stream::delete_writer() +{ + // + // The teardown paths below can run handlers that drop the last reference to this stream, + // erasing it from the session -- keep it alive until this function returns. + // + auto self = shared_from_this(); + + // + // Nothing to finalize on a stream ngtcp2 has already torn down (peer reset it, or we did): + // there is nothing left to reset, and submitting anything would leave nghttp3 holding data for + // a stream that no longer exists, which it would then offer for sending forever. + // + if (closed) + { + logd("[{}] delete_writer: stream already closed", log_prefix); + maybe_close(); + return; + } + + if (!headers_submitted) + { + // + // The handler never even started its message (a server handler that returned, or whose + // request was reset, before calling response.async_submit()). There is no HEADERS frame for + // nghttp3 to close out, so ending the body has nothing to act on and the peer would be left + // waiting forever. Abort the stream at the transport level instead, mirroring what + // h3_cb_stop_sending/h3_cb_reset_stream already do for nghttp3-initiated aborts. NO_ERROR + // here (rather than e.g. INTERNAL_ERROR): choosing not to respond isn't itself a protocol + // error -- the peer just needs to be told the stream is over so it doesn't wait forever. + // + logd("[{}] delete_writer: no headers were ever submitted, shutting stream down", log_prefix); + if (auto* conn = session.conn()) + ngtcp2_conn_shutdown_stream(conn, 0, id, NGHTTP3_H3_NO_ERROR); + closed = true; + session.wake_write(); + maybe_close(); + return; + } + + if (!eof_submitted) + { + // + // The Request/Response was dropped without ever ending the body (async_write({})), so + // wherever it stopped is not where it was meant to stop. Sending a FIN here would present + // that partial message to the peer as a complete one -- reset the stream instead, the way + // the HTTP/2 side submits RST_STREAM once its writer is gone with no EOF submitted, and + // fail the local read the same way nghttp2's stream close does, with partial_message. + // + logw("[{}] delete_writer: body never ended, resetting stream", log_prefix); + session.reset_stream(id, NGHTTP3_H3_REQUEST_CANCELLED); + fail(boost::beast::http::error::partial_message); + } + + maybe_close(); +} + +void Http3Stream::maybe_close() +{ + if (reader || writer) + return; + if (!closed) + return; + session.erase_stream(id); +} + +// ================================================================================================= + +} // namespace anyhttp::http3 diff --git a/src/server_impl.cpp b/src/server_impl.cpp index 1e1172e..06ddc0c 100644 --- a/src/server_impl.cpp +++ b/src/server_impl.cpp @@ -1,13 +1,12 @@ #include "anyhttp/server_impl.hpp" -#include "anyhttp/literals.hpp" #include "anyhttp/any_async_stream.hpp" -#include "anyhttp/beast_session.hpp" -#include "anyhttp/detail/nghttp2_session_details.hpp" -#include "anyhttp/detect_http2.hpp" #include "anyhttp/detect_ssl.hpp" #include "anyhttp/formatter.hpp" // IWYU pragma: keep -#include "anyhttp/nghttp2_session.hpp" +#include "anyhttp/h1_backend.hpp" +#include "anyhttp/h2_backend.hpp" +#include "anyhttp/h2_detect.hpp" +#include "anyhttp/h3_backend.hpp" #include "anyhttp/tls.hpp" #include @@ -29,11 +28,11 @@ #include #include -#include +#include +#include using namespace std::chrono_literals; using namespace boost::asio; -namespace socket_option = boost::asio::detail::socket_option; namespace anyhttp::server { @@ -61,7 +60,13 @@ Server::Impl::Impl(boost::asio::any_io_executor executor, Config config) { logi("Server: ctor"); listen_tcp(); - listen_udp(); + + // + // HTTP/3 shares the endpoint the TCP acceptor is listening on, so it has to be set up after + // listen_tcp(): with port=0 the actual port is only known once the acceptor is bound. + // + auto tcp_ep = m_acceptor->local_endpoint(); + m_http3 = make_http3_server(*this, ip::udp::endpoint{tcp_ep.address(), tcp_ep.port()}); } // ------------------------------------------------------------------------------------------------- @@ -74,7 +79,7 @@ Server::Impl::Impl(boost::asio::any_io_executor executor, Config config) */ void Server::Impl::start() { - co_spawn(m_executor, tcp_listen_loop(), [self = shared_from_this()](const std::exception_ptr& ex) + co_spawn(m_executor, tcp_accept_loop(), [self = shared_from_this()](const std::exception_ptr& ex) { if (ex) logw("TCP accept loop: {}", what(ex)); @@ -82,18 +87,8 @@ void Server::Impl::start() logi("TCP accept loop: done"); }); - if (m_udp_socket) - { - // On the socket's strand -- see listen_udp(). - co_spawn(m_udp_socket->get_executor(), udp_receive_loop(), - [self = shared_from_this()](const std::exception_ptr& ex) - { - if (ex) - logw("UDP receive loop: {}", what(ex)); - else - logi("UDP receive loop: done"); - }); - } + if (m_http3) + m_http3->start(); } // ------------------------------------------------------------------------------------------------- @@ -120,16 +115,8 @@ void Server::Impl::destroy() session->destroy(); } - // - // The socket lives on its own strand (see listen_udp()) and udp_receive_loop() keeps - // re-arming async_wait() on it there -- asio sockets are not thread-safe, so the close - // has to go through the same strand instead of racing that from here. - // - if (m_udp_socket) - { - asio::dispatch(m_udp_socket->get_executor(), [self = shared_from_this()] - { self->m_udp_socket->close(); }); // breaks udp_receive_loop() - } + if (m_http3) + m_http3->destroy(); } // ------------------------------------------------------------------------------------------------- @@ -140,6 +127,23 @@ Server::Impl::~Impl() assert(m_destroyed); } +// ------------------------------------------------------------------------------------------------- + +bool Server::Impl::add_session(std::shared_ptr session) +{ + auto lock = std::lock_guard(m_sessionMutex); + if (m_destroyed) + return false; + m_sessions.emplace(std::move(session)); + return true; +} + +void Server::Impl::remove_session(const std::shared_ptr& session) +{ + auto lock = std::lock_guard(m_sessionMutex); + m_sessions.erase(session); +} + // ================================================================================================= void Server::Impl::listen_tcp() @@ -152,64 +156,24 @@ void Server::Impl::listen_tcp() if (ec) logw("Server: error resolving '{}': {}", config().listen_address, ec.what()); - ip::tcp::endpoint endpoint(address, config().port); - if (endpoint.protocol() == ip::tcp::v6()) - std::ignore = acceptor.set_option(ip::v6_only(false), ec); + ip::tcp::endpoint ep(address, config().port); + if (ep.protocol() == ip::tcp::v6()) + acceptor.set_option(ip::v6_only(false), ec); - acceptor.open(endpoint.protocol()); + acceptor.open(ep.protocol()); acceptor.set_option(asio::socket_base::reuse_address(true)); - acceptor.bind(endpoint); + acceptor.bind(ep); acceptor.listen(); - endpoint = acceptor.local_endpoint(); - logi("Server: TCP listening on {}", endpoint); -} - -// ------------------------------------------------------------------------------------------------- - -void Server::Impl::listen_udp() -{ - // - // Bind the UDP socket to the same address and port as the TCP acceptor so - // HTTP/3 and HTTP/1.1/2 can share one endpoint. Requires listen_tcp() to - // have run first, since we may have been given port=0 and want to reuse the - // kernel-assigned port here. - // - assert(m_acceptor); - auto tcp_ep = m_acceptor->local_endpoint(); - const bool is_v6 = tcp_ep.protocol() == ip::tcp::v6(); - - // - // The socket gets its own strand: udp_receive_loop() runs on it (see start()), and destroy() - // dispatches the shutdown close() through it, so the two never touch the socket concurrently. - // - m_udp_socket.emplace(config().use_strand ? asio::any_io_executor{asio::make_strand(m_executor)} - : m_executor); - m_udp_socket->open(is_v6 ? ip::udp::v6() : ip::udp::v4()); - - if (is_v6) - { - boost::system::error_code ec; - std::ignore = m_udp_socket->set_option(ip::v6_only(false), ec); - m_udp_socket->set_option(socket_option::integer(1)); - m_udp_socket->set_option(socket_option::integer(1)); - m_udp_socket->set_option(socket_option::integer(1)); - } - else - { - m_udp_socket->set_option(socket_option::integer(1)); - m_udp_socket->set_option(socket_option::integer(1)); - } - m_udp_socket->set_option(socket_option::integer(1)); - m_udp_socket->non_blocking(true); - - ip::udp::endpoint udp_ep(tcp_ep.address(), tcp_ep.port()); - m_udp_socket->bind(udp_ep); - logi("Server: UDP listening on {}", udp_ep); + ep = acceptor.local_endpoint(); + logi("Server: TCP listening on {}", ep); } // ================================================================================================= +// +// The protocols we speak over TLS on TCP, in descending order of preference. HTTP/3 is not in +// here: it is offered on the UDP endpoint instead, see anyhttp/h3_backend.hpp. // // https://nghttp2.org/documentation/tutorial-server.html // @@ -223,20 +187,29 @@ static int next_proto_cb(SSL* s, const unsigned char** data, unsigned int* len, return SSL_TLSEXT_ERR_OK; } +// +// ALPN, picking the first protocol of ours the client offers -- our preference wins, not the +// client's order. +// static int alpn_select_proto_cb(SSL* ssl, const unsigned char** out, unsigned char* outlen, const unsigned char* in, unsigned int inlen, void* arg) { - int rv = nghttp2_select_next_protocol((unsigned char**)out, outlen, in, inlen); - switch (rv) + for (std::string_view wanted : {"h2", "http/1.1"}) { - case 0: - return SSL_TLSEXT_ERR_OK; // http/1.1 - case 1: - return SSL_TLSEXT_ERR_OK; // h2 - case -1: - default: - return SSL_TLSEXT_ERR_NOACK; + // The wire format is a sequence of length-prefixed, non-empty protocol names. + for (auto list = std::span{in, inlen}; !list.empty() && list.size() > list[0]; + list = list.subspan(1 + list[0])) + { + if (std::string_view{reinterpret_cast(&list[1]), list[0]} != wanted) + continue; + + *out = &list[1]; + *outlen = list[0]; + return SSL_TLSEXT_ERR_OK; + } } + + return SSL_TLSEXT_ERR_NOACK; } // ------------------------------------------------------------------------------------------------- @@ -264,7 +237,7 @@ class TestStream : public AnyAsyncStream::Impl // ------------------------------------------------------------------------------------------------- -awaitable Server::Impl::handleConnection(ip::tcp::socket socket) +awaitable Server::Impl::handle_connection(ip::tcp::socket socket) { const auto prefix = normalize(socket.remote_endpoint()); logi("[{}] new connection", prefix); @@ -329,13 +302,9 @@ awaitable Server::Impl::handleConnection(ip::tcp::socket socket) tls_handshake_info(ssl_stream->native_handle())); if (alpn == "h2") - session = - std::make_shared>> // - (*this, executor, std::move(*ssl_stream)); + session = nghttp2::make_server_session(*this, executor, std::move(*ssl_stream)); else if (alpn == "http/1.1") - session = - std::make_shared>> // - (*this, executor, std::move(*ssl_stream)); + session = beast_impl::make_server_session(*this, executor, std::move(*ssl_stream)); } // @@ -346,11 +315,9 @@ awaitable Server::Impl::handleConnection(ip::tcp::socket socket) logi("[{}] detected HTTP2 client preface, {} bytes in buffer", prefix, buffer.size()); #if 1 AnyAsyncStream stream(std::make_unique(std::move(socket))); - session = std::make_shared> // - (*this, executor, std::move(stream)); + session = nghttp2::make_server_session(*this, executor, std::move(stream)); #else - session = std::make_shared> // - (*this, executor, std::move(socket)); + session = nghttp2::make_server_session(*this, executor, std::move(socket)); #endif } @@ -362,25 +329,25 @@ awaitable Server::Impl::handleConnection(ip::tcp::socket socket) logi("[{}] no HTTP2 client preface, assuming HTTP/1.x", prefix); #if 1 AnyAsyncStream stream(std::make_unique(std::move(socket))); - session = std::make_shared> // - (*this, executor, std::move(stream)); + session = beast_impl::make_server_session(*this, executor, std::move(stream)); #else - session = std::make_shared> // - (*this, executor, boost::beast::tcp_stream(std::move(socket))); + session = beast_impl::make_server_session(*this, executor, std::move(socket)); #endif } + // + // Registration fails only if the server is already being destroyed, in which case this + // session has to go away right here: nothing else knows about it any more. + // + if (!add_session(session)) { - auto lock = std::lock_guard(m_sessionMutex); - m_sessions.emplace(session); + logi("[{}] server is shutting down, dropping connection", prefix); + session->destroy(); + co_return; } co_await session->do_session(std::move(buffer)); - - { - auto lock = std::lock_guard(m_sessionMutex); - m_sessions.erase(session); - } + remove_session(session); logi("[{}] session finished", prefix); } @@ -388,7 +355,7 @@ awaitable Server::Impl::handleConnection(ip::tcp::socket socket) // ------------------------------------------------------------------------------------------------- /** - * Typically, a listen loop "spawns" a new thread of execution for each connection it accepts. + * Typically, an accept loop "spawns" a new thread of execution for each connection it accepts. * Doing that in a "detached" fashion violates the principles of structured concurrency, as we * don't have a clear way of cancelling those threads. * @@ -397,7 +364,7 @@ awaitable Server::Impl::handleConnection(ip::tcp::socket socket) * https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2024/p3149r5.html#listener-loop-in-an-http-server * */ -awaitable Server::Impl::tcp_listen_loop() +awaitable Server::Impl::tcp_accept_loop() { assert(m_acceptor); auto& acceptor = *m_acceptor; @@ -443,7 +410,7 @@ awaitable Server::Impl::tcp_listen_loop() // or explicit thread pools where really needed. // co_spawn(config().use_strand ? boost::asio::make_strand(executor) : executor, - handleConnection(std::move(socket)), [&, ep](const std::exception_ptr& ex) mutable + handle_connection(std::move(socket)), [&, ep](const std::exception_ptr& ex) mutable { auto lock = std::lock_guard(m_sessionMutex); --sessionCounter; @@ -459,7 +426,7 @@ awaitable Server::Impl::tcp_listen_loop() // auto lock = std::unique_lock(m_sessionMutex); const auto waitingFor = sessionCounter; - logi("listen loop terminated, waiting for {} sessions...", waitingFor); + logi("accept terminated, waiting for {} sessions...", waitingFor); size_t i = 0; for (; sessionCounter; ++i) @@ -473,7 +440,7 @@ awaitable Server::Impl::tcp_listen_loop() lock.lock(); } - logi("listen loop terminated, waiting for {} sessions... done, {} iterations", waitingFor, i); + logi("accept terminated, waiting for {} sessions... done, {} iterations", waitingFor, i); } // ================================================================================================= diff --git a/src/server_impl_udp.cpp b/src/server_impl_udp.cpp deleted file mode 100644 index 5df5a26..0000000 --- a/src/server_impl_udp.cpp +++ /dev/null @@ -1,2748 +0,0 @@ -// -// anyhttp QUIC / HTTP/3 server. -// -// One `Http3Session` per QUIC connection implements `Session::Impl`, and per-request -// `Http3Stream` state feeds an `Http3Reader` (server::Request) and `Http3Writer` -// (server::Response) into the same `RequestHandler` used by the HTTP/1.1 and HTTP/2 -// backends. -// -// Threading: with Config::use_strand, each Http3Session lives on its own strand -- the unit of -// serialization is the QUIC *connection* (one ngtcp2_conn/nghttp3_conn pair), not the CID: many -// CIDs alias one connection. udp_receive_loop() is a single coroutine that only demultiplexes: -// it copies each datagram, groups them by session and posts one batch per session to that -// session's strand (process_quic_batch()), where all ngtcp2/nghttp3 work, the timers and the -// request handlers run. The CID demux table is the only cross-connection state and is guarded -// by Server::Impl::m_quicMutex. Sends go straight out via a per-session dup() of the UDP fd -- -// sendto()/sendmsg() are atomic per datagram, so they need no serialization. -// -// Not yet implemented: retry tokens, version negotiation, stateless reset, connection -// migration, ECN, client-side (async_submit is a no-op). -// - -#include "anyhttp/client_impl.hpp" -#include "anyhttp/formatter.hpp" // IWYU pragma: keep -#include "anyhttp/literals.hpp" -#include "anyhttp/request_handlers.hpp" // IWYU pragma: keep -#include "anyhttp/server_impl.hpp" -#include "anyhttp/session_impl.hpp" -#include "anyhttp/tls.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 -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "ngtcp2/shared.h" -#include "ngtcp2/util.h" - -using namespace std::chrono_literals; -using namespace boost::asio; -namespace errc = boost::system::errc; - -namespace anyhttp::server -{ - -// ================================================================================================= - -struct Endpoint -{ - ngtcp2::Address addr; - int fd; - - // Testing aid, see server::Config::drop_rate_rx/tx. - double drop_rate_rx = 0.0; - double drop_rate_tx = 0.0; -}; - -// ================================================================================================= -// Free-standing helpers -// ================================================================================================= - -namespace -{ - -constexpr size_t QUIC_SCIDLEN = 18; - -// -// One-shot process-wide initialization of ngtcp2_crypto_ossl and the OpenSSL SSL_CTX -// used for every QUIC connection. -// -struct TlsServerContext -{ - TlsServerContext() - { - static const int init_once = [] - { - if (ngtcp2_crypto_ossl_init() != 0) - throw std::runtime_error("ngtcp2_crypto_ossl_init"); - return 0; - }(); - (void)init_once; - - ctx = SSL_CTX_new(TLS_server_method()); - if (!ctx) - throw std::runtime_error("SSL_CTX_new"); - - SSL_CTX_set_options(ctx, (SSL_OP_ALL & ~SSL_OP_DONT_INSERT_EMPTY_FRAGMENTS) | - SSL_OP_SINGLE_ECDH_USE | SSL_OP_CIPHER_SERVER_PREFERENCE | - SSL_OP_NO_ANTI_REPLAY); - SSL_CTX_set_mode(ctx, SSL_MODE_RELEASE_BUFFERS); - - SSL_CTX_set_alpn_select_cb(ctx, &TlsServerContext::alpn_select_cb, nullptr); - - if (SSL_CTX_use_PrivateKey_file(ctx, "pki/out/server-key.pem", SSL_FILETYPE_PEM) != 1) - throw std::runtime_error(std::string{"SSL_CTX_use_PrivateKey_file: "} + - ERR_error_string(ERR_get_error(), nullptr)); - - if (SSL_CTX_use_certificate_chain_file(ctx, "pki/out/server-chain.pem") != 1) - throw std::runtime_error(std::string{"SSL_CTX_use_certificate_chain_file: "} + - ERR_error_string(ERR_get_error(), nullptr)); - - if (SSL_CTX_check_private_key(ctx) != 1) - throw std::runtime_error("SSL_CTX_check_private_key"); - } - - ~TlsServerContext() - { - if (ctx) - SSL_CTX_free(ctx); - } - - TlsServerContext(const TlsServerContext&) = delete; - TlsServerContext& operator=(const TlsServerContext&) = delete; - - static int alpn_select_cb(SSL*, const unsigned char** out, unsigned char* outlen, - const unsigned char* in, unsigned int inlen, void*) - { - for (auto s = std::span{in, inlen}; s.size() >= 3; s = s.subspan(s[0] + 1)) - { - if (s[0] == 2 && s[1] == 'h' && s[2] == '3') - { - *out = &s[1]; - *outlen = 2; - return SSL_TLSEXT_ERR_OK; - } - } - return SSL_TLSEXT_ERR_ALERT_FATAL; - } - - SSL_CTX* ctx = nullptr; -}; - -TlsServerContext& tls_context() -{ - static TlsServerContext instance; - return instance; -} - -// ------------------------------------------------------------------------------------------------- - -std::string cid_key(const ngtcp2_cid& cid) -{ - return std::string{reinterpret_cast(cid.data), cid.datalen}; -} - -std::string cid_key(const uint8_t* data, size_t len) -{ - return std::string{reinterpret_cast(data), len}; -} - -// ------------------------------------------------------------------------------------------------- - -// -// Testing aid: rolls the dice for a single QUIC packet. `rate` is the probability of the packet -// being dropped, 0.0 (never, the default) to 1.0 (always). The generator is deliberately not -// seeded deterministically -- this is meant to shake out loss handling over many runs, not to -// reproduce one exact sequence. -// -bool drop_packet(double rate) -{ - if (rate <= 0.0) - return false; - - static thread_local std::mt19937 rng{std::random_device{}()}; - return std::uniform_real_distribution{0.0, 1.0}(rng) < rate; -} - -// ------------------------------------------------------------------------------------------------- - -int send_udp(const Endpoint& ep, const sockaddr* sa, socklen_t salen, std::span data) -{ - if (drop_packet(ep.drop_rate_tx)) - { - // logw("*** dropping outgoing packet ({} bytes) ***", data.size()); - return 0; // pretend it went out; ngtcp2 will retransmit - } - - for (;;) - { - auto n = ::sendto(ep.fd, data.data(), data.size(), 0, sa, salen); - if (n == -1) - { - if (errno == EINTR) - continue; - if (errno == EAGAIN || errno == EWOULDBLOCK) - return 0; // best-effort; ngtcp2 will retransmit - loge("sendto: {}", strerror(errno)); - return -1; - } - return 0; - } -} - -// ------------------------------------------------------------------------------------------------- - -// Sends a run of same-sized packets (as produced by ngtcp2_conn_write_aggregate_pkt2(), all but -// the last exactly `gso_size` bytes) with a single sendmsg() using UDP_SEGMENT (GSO), so N QUIC -// packets cost one syscall instead of N. Falls back to one sendto() per segment -- and remembers -// to do so from then on -- if the kernel/NIC doesn't support UDP_SEGMENT here. -int send_udp_gso(const Endpoint& ep, const sockaddr* sa, socklen_t salen, - std::span data, size_t gso_size, bool& no_gso) -{ - // With TX dropping enabled, go packet by packet so each one can be dropped individually. - if (no_gso || data.size() <= gso_size || ep.drop_rate_tx > 0.0) - { - for (; !data.empty();) - { - auto len = std::min(gso_size, data.size()); - if (send_udp(ep, sa, salen, data.first(len)) != 0) - return -1; - data = data.subspan(len); - } - return 0; - } - - iovec msg_iov{const_cast(data.data()), data.size()}; - uint8_t msg_ctrl[CMSG_SPACE(sizeof(uint16_t))]; - msghdr msg{}; - msg.msg_name = const_cast(sa); - msg.msg_namelen = salen; - msg.msg_iov = &msg_iov; - msg.msg_iovlen = 1; - msg.msg_control = msg_ctrl; - msg.msg_controllen = sizeof(msg_ctrl); - - auto* cm = CMSG_FIRSTHDR(&msg); - cm->cmsg_level = SOL_UDP; - cm->cmsg_type = UDP_SEGMENT; - cm->cmsg_len = CMSG_LEN(sizeof(uint16_t)); - auto seg = static_cast(gso_size); - memcpy(CMSG_DATA(cm), &seg, sizeof(seg)); - - for (;;) - { - auto n = ::sendmsg(ep.fd, &msg, 0); - if (n == -1) - { - if (errno == EINTR) - continue; - if (errno == EAGAIN || errno == EWOULDBLOCK) - return 0; // best-effort; ngtcp2 will retransmit - if (errno == EINVAL || errno == EOPNOTSUPP) - { - // GSO unsupported on this socket/NIC: fall back permanently and resend as - // individual datagrams. - no_gso = true; - return send_udp_gso(ep, sa, salen, data, gso_size, no_gso); - } - loge("sendmsg (GSO): {}", strerror(errno)); - return -1; - } - return 0; - } -} - -nghttp3_nv make_nv(std::string_view name, std::string_view value) -{ - nghttp3_nv nv{}; - nv.name = reinterpret_cast(const_cast(name.data())); - nv.namelen = name.size(); - nv.value = reinterpret_cast(const_cast(value.data())); - nv.valuelen = value.size(); - nv.flags = NGHTTP3_NV_FLAG_NONE; - return nv; -} - -/// Logs a block of headers, one per line, in the same style as the received ones. -void log_headers(std::string_view log_prefix, const std::vector& nva) -{ - for (const auto& nv : nva) - logd("[{}] \x1b[1;34m{}\x1b[0m: {}", log_prefix, - std::string_view(reinterpret_cast(nv.name), nv.namelen), - std::string_view(reinterpret_cast(nv.value), nv.valuelen)); -} - -/// Same, for a header block buffered up by the recv_header callback. -void log_headers(std::string_view log_prefix, - const std::vector>& headers) -{ - for (const auto& [name, value] : headers) - logd("[{}] \x1b[1;34m{}\x1b[0m: {}", log_prefix, name, value); -} - -void ngtcp2_log_printf(void* /*user*/, const char* fmt, ...) noexcept -{ - if (!spdlog::default_logger()->should_log(spdlog::level::trace)) - return; - std::array buf; - va_list ap; - va_start(ap, fmt); - std::vsnprintf(buf.data(), buf.size(), fmt, ap); - va_end(ap); - spdlog::trace("{}", buf.data()); -} - -} // namespace - -// ================================================================================================= -// Http3Stream: per-request state. -// ================================================================================================= - -class Http3Session; -class Http3Stream; - -class Http3Stream : public std::enable_shared_from_this -{ -public: - Http3Stream(Http3Session& session, int64_t id); - ~Http3Stream(); - - int64_t id; - Http3Session& session; - std::string log_prefix; - - // - // Request state (populated by nghttp3 header callbacks). - // - std::string method; - boost::urls::url url; - std::optional content_length; - Fields request_fields; - - // - // The header block as it arrived, buffered so that h3_cb_end_headers() can log it in one go, - // below the request line, instead of one stray line per header as they come in. Only filled - // when debug logging is on, and dropped again as soon as it has been logged. - // - std::vector> received_headers; - - // - // Response state (populated by user via Http3Writer). - // - unsigned int response_status = 0; - Fields response_fields; - std::optional response_content_length; - std::string response_content_length_str; // storage for nghttp3_nv - bool response_submitted = false; - - // - // Request body plumbing (client → server). - // - std::deque> pending_read; - asio::const_buffer read_head; // view of pending_read.front() not yet delivered - asio::const_buffer incoming; // chunk on_data_chunk() is delivering, not yet taken - bool eof_received = false; - ReadSomeHandler read_handler; - asio::mutable_buffer read_handler_buffer; - bool call_read_handler_active = false; // re-entrancy guard, see call_read_handler() - - // - // Response body plumbing (server → client). Only one async_write() may be active at a time -- - // callers must wait for its handler before issuing another (same contract as e.g. Beast) -- so - // this is flat per-stream state rather than a queue of pending writes. See the client-side - // counterpart (Http3ClientStream in client_impl_udp.cpp) for the fuller rationale. - // - // write_source is the caller's buffer, passed on to nghttp3 by reference: data_reader() points - // the nghttp3_vec straight into it, so the response body is never copied on its way down to the - // nghttp3/ngtcp2 boundary, however large it is -- the mmap()ed file of serve_file() travels - // from the page cache into QUIC packets without an intermediate byte. - // - // What that costs is *when* the write completes. ngtcp2 keeps pointing into this memory for as - // long as the bytes may still have to be retransmitted (it only ever copies the nghttp3_vec - // descriptors, never the payload), and running the write handler is what releases the caller's - // buffer -- so the handler has to wait for the data to be acknowledged. This is the model - // nghttp3 documents for its read_data callback: "the application must retain data until they - // are safe to free; it is notified by nghttp3_acked_stream_data". HTTP/2 completes a write as - // soon as nghttp2 has copied it into its own frame buffer, so a single async_write() there is - // done roughly a memcpy later, and here roughly a round trip later. - // - // write_offered tracks how much of write_source has been handed to nghttp3, which may ask - // again before any of it goes out and would take a repeated offer as *additional*, distinct - // stream bytes -- duplicating the body on the wire -- so a repeat call gets - // NGHTTP3_ERR_WOULDBLOCK instead. write_acked tracks what came back through nghttp3's - // acked_stream_data callback; the write is complete once that has caught up with write_source. - // - bool write_active = false; - asio::const_buffer write_source; - size_t write_offered = 0; - size_t write_acked = 0; - bool write_is_eof = false; - WriteHandler write_handler; - uint64_t write_token = 0; - uint64_t next_write_token = 1; - bool eof_submitted = false; // user signalled EOF via empty write - bool eof_sent_to_h3 = false; // NGHTTP3_DATA_FLAG_EOF returned - - // - // Lifecycle. - // - impl::Reader* reader = nullptr; // pointer back to the Http3Reader when attached - impl::Writer* writer = nullptr; // pointer back to the Http3Writer when attached - bool closed = false; // set in h3_cb_stream_close - - asio::any_io_executor get_executor() const noexcept; - - const std::string& logPrefix() const noexcept { return log_prefix; } - - // Data flow into user land. - void on_data_chunk(const uint8_t* data, size_t len); - void on_eof(); - void call_read_handler(); - - // Data flow from user land back to nghttp3. - void submit_response(); - void start_write(WriteHandler&& handler, asio::const_buffer buffer); - nghttp3_ssize data_reader(nghttp3_vec* vec, size_t veccnt, uint32_t* pflags); - void on_write_acked(size_t n); - -private: - void bind_write_cancellation(WriteHandler& handler, uint64_t token); // arms cancellation - void finish_active_write(); // completes the active write and releases the caller's buffer - -public: - // Called from either reader or writer destructor. - void delete_reader(); - void delete_writer(); - void maybe_close(); -}; - -// ================================================================================================= -// Http3Session: one QUIC connection, one anyhttp Session::Impl. -// ================================================================================================= - -class Http3Session : public Session::Impl -{ -public: - Http3Session(Server::Impl& server, Endpoint ep, ngtcp2::Address remote); - ~Http3Session() override; - - // - // Session::Impl - // - // The executor is this connection's strand (when Config::use_strand is set): every touch of - // ngtcp2/nghttp3 state -- datagram batches from the UDP demux, the expiry timer, wake_write() - // flushes, and the request-handler coroutines spawned in h3_cb_end_headers() -- runs through - // it, giving one QUIC connection the same single-threaded world a TCP connection gets from - // the strand its socket lives on. - // - asio::any_io_executor get_executor() const noexcept override { return executor_; } - void async_submit(SubmitHandler&& handler, boost::urls::url, const Fields&) override; - awaitable do_session(Buffer&& data) override; - void destroy() noexcept override; - - // - // Interface used by the UDP demux in Server::Impl. - // - int init(const ngtcp2_cid& dcid, const ngtcp2_cid& scid, uint32_t version, - const ngtcp2_pkt_info& pi, std::span data); - int on_read(const ngtcp2_pkt_info& pi, std::span data, - const ngtcp2::Address& remote); - int write_streams(); - - // - // on_read() does not write; it only marks the session here, and Server::Impl calls - // flush_write() once the whole receive batch has been fed to ngtcp2. Writing per datagram - // means every aggregate pass only sees what happened to be queued at that instant, so a - // response goes out as several small GSO batches instead of one big one. - // - void defer_write() noexcept { write_pending_ = true; } - int flush_write(); - - ngtcp2_ssize write_pkt(ngtcp2_path* path, ngtcp2_pkt_info* pi, uint8_t* dest, size_t destlen, - ngtcp2_tstamp ts); - void update_timer(); - int handle_expiry(); - - const ngtcp2_cid& scid() const noexcept { return scid_; } - ngtcp2_conn* conn() const noexcept { return conn_; } - bool closed() const noexcept { return closed_; } - const std::string& logPrefix() const noexcept { return log_prefix_; } - Server::Impl& server() noexcept { return server_; } - nghttp3_conn* h3() const noexcept { return h3_; } - - // Called by Http3Writer/Reader to make sure the write loop runs after new data was queued. - void wake_write(); - - // - // Grants the peer more *stream*-level send credit for `n` bytes of request body just delivered - // to the application. Deliberately NOT called as data arrives (see h3_cb_recv_data) -- only - // once call_read_handler() actually hands bytes to the app, so a slow/absent reader keeps the - // peer's flow control window for *this stream* genuinely constrained instead of nghttp3 - // buffering an unbounded backlog in pending_read. Connection-level credit is granted eagerly - // regardless (see h3_cb_recv_data) since it's a pool shared with control/QPACK streams nghttp3 - // manages on its own. - // - void consume_stream(int64_t stream_id, size_t n) - { - if (n == 0) - return; - ngtcp2_conn_extend_max_stream_offset(conn_, stream_id, n); - wake_write(); // a WINDOW_UPDATE-equivalent frame needs to go out - } - - // - // Abort both directions of the stream (RESET_STREAM + STOP_SENDING), the QUIC equivalent of - // HTTP/2's RST_STREAM. nghttp3 learns of the dead write side through the existing - // NGTCP2_ERR_STREAM_SHUT_WR handling in write_streams(). - // - void reset_stream(int64_t stream_id, uint64_t app_error_code) - { - ngtcp2_conn_shutdown_stream(conn_, 0, stream_id, app_error_code); - wake_write(); - } - - // - // Half-close just our read direction (STOP_SENDING), telling the peer to stop sending the - // request body while the response we are still writing keeps flowing. Fires the local - // stream_stop_sending callback, which is what tells nghttp3 about it. - // - void stop_reading(int64_t stream_id, uint64_t app_error_code) - { - ngtcp2_conn_shutdown_stream_read(conn_, 0, stream_id, app_error_code); - wake_write(); - } - - // Called from udp_on_read() when a packet arrives during the closing period. - void resend_conn_close(); - - // - // Returns a shared_ptr, not a raw pointer: callers routinely invoke user handlers on the - // stream they looked up, and those can drop the last reference to it (the coroutine they - // resume destroying its Request/Response), which erases the stream from streams_. Holding - // an owning reference for the duration of the lookup keeps that from becoming a - // use-after-free. - // - std::shared_ptr find_stream(int64_t id); - Http3Stream* create_stream(int64_t id); - void erase_stream(int64_t id); - - // - // ngtcp2 <-> ngtcp2_crypto_ossl bridge. - // - static ngtcp2_conn* get_conn(ngtcp2_crypto_conn_ref* ref) - { - return static_cast(ref->user_data)->conn_; - } - - // - // ngtcp2 callback bridges - // - static int cb_handshake_completed(ngtcp2_conn*, void* user); - static int cb_recv_stream_data(ngtcp2_conn*, uint32_t flags, int64_t stream_id, uint64_t offset, - const uint8_t* data, size_t datalen, void* user, void*); - static int cb_acked_stream_data_offset(ngtcp2_conn*, int64_t stream_id, uint64_t offset, - uint64_t datalen, void* user, void*); - static int cb_stream_open(ngtcp2_conn*, int64_t stream_id, void* user); - static int cb_stream_close(ngtcp2_conn*, uint32_t flags, int64_t stream_id, - uint64_t app_error_code, void* user, void*); - static void cb_rand(uint8_t* dest, size_t destlen, const ngtcp2_rand_ctx*); - static int cb_get_new_connection_id(ngtcp2_conn*, ngtcp2_cid* cid, uint8_t* token, size_t cidlen, - void* user); - static int cb_remove_connection_id(ngtcp2_conn*, const ngtcp2_cid* cid, void* user); - static int cb_extend_max_remote_streams_bidi(ngtcp2_conn*, uint64_t max_streams, void* user); - static int cb_stream_stop_sending(ngtcp2_conn*, int64_t stream_id, uint64_t app_error_code, - void* user, void*); - static int cb_stream_reset(ngtcp2_conn*, int64_t stream_id, uint64_t final_size, - uint64_t app_error_code, void* user, void*); - static int cb_extend_max_stream_data(ngtcp2_conn*, int64_t stream_id, uint64_t max_data, - void* user, void*); - static int cb_recv_rx_key(ngtcp2_conn*, ngtcp2_encryption_level level, void* user); - - // - // nghttp3 callback bridges - // - static int h3_cb_acked_stream_data(nghttp3_conn*, int64_t stream_id, uint64_t datalen, - void* user, void*); - static int h3_cb_stream_close(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, - void* user, void*); - static int h3_cb_recv_data(nghttp3_conn*, int64_t stream_id, const uint8_t* data, size_t datalen, - void* user, void*); - static int h3_cb_deferred_consume(nghttp3_conn*, int64_t stream_id, size_t nconsumed, void* user, - void*); - static int h3_cb_begin_headers(nghttp3_conn*, int64_t stream_id, void* user, void*); - static int h3_cb_recv_header(nghttp3_conn*, int64_t stream_id, int32_t token, - nghttp3_rcbuf* name, nghttp3_rcbuf* value, uint8_t flags, - void* user, void*); - static int h3_cb_end_headers(nghttp3_conn*, int64_t stream_id, int fin, void* user, void*); - static int h3_cb_end_stream(nghttp3_conn*, int64_t stream_id, void* user, void*); - static int h3_cb_stop_sending(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, - void* user, void*); - static int h3_cb_reset_stream(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, - void* user, void*); - -private: - int setup_http3(); - int handle_error(int rv); - void arm_timer_from_ngtcp2(); - void signal_done(); - void schedule_close_timer(); - void do_destroy() noexcept; // the body of destroy(), always run on executor_ - -private: - Server::Impl& server_; - Endpoint ep_; - bool owns_fd_ = false; // ep_.fd was dup()ed in the ctor, close it in the dtor - ngtcp2::Address remote_; - asio::any_io_executor executor_; - ngtcp2_cid scid_{}; - - ngtcp2_conn* conn_ = nullptr; - ngtcp2_crypto_ossl_ctx* ossl_ctx_ = nullptr; - ngtcp2_crypto_conn_ref conn_ref_{}; - - nghttp3_conn* h3_ = nullptr; - - asio::steady_timer timer_; - asio::steady_timer done_signal_; // used to wake do_session() on connection close - ngtcp2_ccerr last_error_{}; - bool closed_ = false; - - std::string log_prefix_; - - bool write_pending_ = false; // set by on_read(), acted on by flush_write() - bool write_posted_ = false; // a wake_write() flush is already on the way - - std::vector conn_closebuf_; // buffered CONNECTION_CLOSE packet - - // Aggregated TX buffer: ngtcp2_conn_write_aggregate_pkt2() packs as many same-sized - // packets as it can (control/QPACK streams, response data, ...) into this buffer so - // they can all be flushed with a single sendmsg()+UDP_SEGMENT (GSO) call instead of - // one sendto() per QUIC packet. - std::vector tx_buf_ = std::vector(64_k); - bool no_gso_ = false; - - std::unordered_map> streams_; -}; - -// ================================================================================================= -// Http3Reader / Http3Writer: adapter classes that plug Http3Stream into the anyhttp -// Reader/Writer interfaces. Server-side only; the client-side templates come later. -// ================================================================================================= - -template -class Http3Reader : public Interface -{ -public: - explicit Http3Reader(Http3Stream& s) : stream(&s) { s.reader = this; } - ~Http3Reader() override - { - if (stream) - { - stream->reader = nullptr; - stream->delete_reader(); - } - } - - asio::any_io_executor get_executor() const noexcept override - { - assert(stream); - return stream->get_executor(); - } - - std::optional content_length() const noexcept override - { - return stream ? stream->content_length : std::nullopt; - } - - unsigned int status_code() const noexcept override - { - // Server-side Request; status doesn't apply, but the interface requires it. - return 0; - } - - boost::url_view url() const override - { - assert(stream); - return stream->url; - } - - void async_read_some(asio::mutable_buffer buffer, ReadSomeHandler&& handler) override - { - if (!stream) - { - std::move(handler)(boost::beast::http::error::partial_message, 0); - return; - } - if (asio::buffer_size(buffer) == 0) - { - std::move(handler)(boost::system::error_code{}, 0); - return; - } - - assert(!stream->read_handler); - stream->read_handler = std::move(handler); - stream->read_handler_buffer = buffer; - stream->call_read_handler(); - } - - void detach() override { stream = nullptr; } - - Http3Stream* stream; -}; - -template -class Http3Writer : public Base -{ -public: - explicit Http3Writer(Http3Stream& s) : stream(&s) { s.writer = this; } - ~Http3Writer() override - { - if (stream) - { - stream->writer = nullptr; - stream->delete_writer(); - } - } - - asio::any_io_executor get_executor() const noexcept override - { - assert(stream); - return stream->get_executor(); - } - - void content_length(std::optional len) override - { - assert(stream); - stream->response_content_length = len; - } - - void async_write(WriteHandler&& handler, asio::const_buffer buffer) override - { - if (!stream || stream->closed) - { - std::move(handler)(errc::make_error_code(errc::connection_reset)); - return; - } - - stream->start_write(std::move(handler), buffer); - } - - void async_submit(StatusHandler&& handler, unsigned int status_code, const Fields& fields) - { - if (!stream || stream->closed) - { - std::move(handler)(errc::make_error_code(errc::connection_reset)); - return; - } - stream->response_status = status_code; - stream->response_fields = fields; - stream->submit_response(); - stream->session.wake_write(); - - std::move(handler)(boost::system::error_code{}); - } - - void detach() override { stream = nullptr; } - - Http3Stream* stream; -}; - -// ================================================================================================= -// Http3Stream implementation -// ================================================================================================= - -Http3Stream::Http3Stream(Http3Session& s, int64_t stream_id) : id(stream_id), session(s) -{ - log_prefix = std::format("{}.{}", session.logPrefix(), id); - logd("\x1b[1;33mStream: ctor\x1b[0m"); -} - -Http3Stream::~Http3Stream() -{ - mlogd("\x1b[33mStream: dtor... \x1b[0m"); - // A Http3Writer/Http3Reader (owned by the user-visible Request/Response) can outlive this - // stream, e.g. when the session tears down streams_ while a suspended coroutine still holds - // one. Detach them so their destructors don't dereference a freed stream. - if (reader) - reader->detach(); - if (writer) - writer->detach(); - if (read_handler) - swap_and_invoke(read_handler, errc::make_error_code(errc::connection_reset), 0); - if (write_active && write_handler) - swap_and_invoke(write_handler, errc::make_error_code(errc::connection_reset)); - mlogd("\x1b[33mStream: dtor... done\x1b[0m"); -} - -asio::any_io_executor Http3Stream::get_executor() const noexcept { return session.get_executor(); } - -// ------------------------------------------------------------------------------------------------- - -void Http3Stream::on_data_chunk(const uint8_t* data, size_t len) -{ - if (len == 0) - return; - - // - // nghttp3 hands us a view into the packet it is parsing, valid only until this callback - // returns. Offer it to a waiting reader as it stands before copying it anywhere: a handler - // that keeps a read outstanding -- the usual shape -- takes the bytes with a single copy, and - // the vector that would otherwise carry them (a malloc, a copy in, a copy out and a free, per - // QUIC packet, so about fifty of each per 64k of request body) is never created at all. Only - // what the reader could not take is parked for later. - // - auto self = shared_from_this(); // a resumed reader may drop the last reference to this stream - incoming = asio::const_buffer{data, len}; - call_read_handler(); - - if (incoming.size() > 0) - { - auto* rest = static_cast(incoming.data()); - pending_read.emplace_back(rest, rest + incoming.size()); - if (read_head.size() == 0) - read_head = asio::buffer(pending_read.front()); - incoming = {}; - } -} - -void Http3Stream::on_eof() -{ - eof_received = true; - call_read_handler(); -} - -void Http3Stream::call_read_handler() -{ - // - // swap_and_invoke() below may resume a user coroutine that calls async_read_some() again - // before returning, which re-enters this function. Letting that nested call do real work would - // recurse once per buffered chunk -- with enough data queued up, that blows the C++ stack. - // Instead, the nested call just re-arms read_handler and returns; the outer call's loop below - // picks it up and keeps going without growing the stack. See the client-side counterpart, - // Http3ClientStream::call_read_handler() in client_impl_udp.cpp. - // - if (!read_handler || call_read_handler_active) - return; - - // - // The loop below may resume a coroutine that drops the last owning reference to this stream -- - // or to the whole Session, when that coroutine was the last user of the connection. Keep both - // alive until this function returns: consume_stream() at the bottom dereferences the session, - // so outliving the stream alone is not enough. - // - auto self = shared_from_this(); - auto session_guard = session.shared_from_this(); - - call_read_handler_active = true; - size_t consumed = 0; - while (read_handler) - { - if (read_head.size() > 0 || incoming.size() > 0) - { - // - // Fill the caller's buffer from as many queued chunks as it takes, rather than stopping - // at the end of the first one. Each chunk is what arrived in a single QUIC packet -- a - // little over a kilobyte -- so handing them out one per read turns a 64k body into ~48 - // reads, and a handler that answers every read with a write (an echo) pays a full - // round trip for each of them, because a body write only completes once the peer has - // acknowledged it (see the comment above write_active). - // - auto dest = read_handler_buffer; - size_t copied = 0; - while (dest.size() > 0 && read_head.size() > 0) - { - auto n = asio::buffer_copy(dest, read_head); - dest += n; - read_head += n; - copied += n; - if (read_head.size() == 0) - { - pending_read.pop_front(); - read_head = - pending_read.empty() ? asio::const_buffer{} : asio::buffer(pending_read.front()); - } - } - - // - // ... and last from the chunk being delivered right now, which on_data_chunk() offers - // through `incoming` instead of parking it in a vector of its own first. Queued chunks - // go first: they arrived earlier. - // - if (dest.size() > 0 && incoming.size() > 0) - { - auto n = asio::buffer_copy(dest, incoming); - incoming += n; - copied += n; - } - - consumed += copied; - swap_and_invoke(read_handler, boost::system::error_code{}, copied); - continue; - } - - if (eof_received) - { - // 0-byte read = EOF, matching the beast/nghttp2 convention. - swap_and_invoke(read_handler, boost::system::error_code{}, 0); - continue; - } - - if (closed) - { - // - // The stream died before the request body was complete, and this read was issued after - // the close -- there is nothing left that could ever complete it, so report the - // truncation now rather than leaving it pending forever. - // - swap_and_invoke(read_handler, boost::beast::http::error::partial_message, 0); - continue; - } - - break; - } - call_read_handler_active = false; - - // - // Grant the peer more send credit only for what was actually delivered to the app -- see - // Http3Session::consume_stream() for why this must not happen any earlier. - // - session.consume_stream(id, consumed); -} - -// ------------------------------------------------------------------------------------------------- - -namespace -{ -nghttp3_ssize stream_read_data(nghttp3_conn*, int64_t /*stream_id*/, nghttp3_vec* vec, - size_t veccnt, uint32_t* pflags, void* /*conn_user*/, - void* stream_user) -{ - auto s = static_cast(stream_user); - return s->data_reader(vec, veccnt, pflags); -} -} // namespace - -void Http3Stream::submit_response() -{ - assert(!response_submitted); - - auto status_str = std::to_string(response_status); - std::vector nva; - nva.reserve(16); // small typical header count; vector will grow if needed - auto date_str = format_http_date(std::chrono::system_clock::now()); - nva.push_back(make_nv(":status", status_str)); - nva.push_back(make_nv("date", date_str)); - nva.push_back(make_nv("server", "anyhttp-quic/0.1")); - - if (response_content_length) - { - response_content_length_str = std::to_string(*response_content_length); - nva.push_back(make_nv("content-length", response_content_length_str)); - } - - for (auto&& item : response_fields) - { - if (item.name_string().starts_with(':')) - { - logw("[{}] submit_response: dropping pseudo-header '{}'", log_prefix, item.name_string()); - continue; - } - nva.push_back(make_nv(item.name_string(), item.value())); - } - - nghttp3_data_reader dr{}; - dr.read_data = &stream_read_data; - - if (auto rv = nghttp3_conn_set_stream_user_data(session.h3(), id, this); rv != 0) - { - loge("[{}] nghttp3_conn_set_stream_user_data: {}", log_prefix, nghttp3_strerror(rv)); - return; - } - - if (auto rv = nghttp3_conn_submit_response(session.h3(), id, nva.data(), nva.size(), &dr); - rv != 0) - { - loge("[{}] nghttp3_conn_submit_response: {}", log_prefix, nghttp3_strerror(rv)); - return; - } - response_submitted = true; - - using namespace boost::beast::http; - logd("[{}] {} {}", log_prefix, response_status, obsolete_reason(int_to_status(response_status))); - log_headers(log_prefix, nva); -} - -void Http3Stream::start_write(WriteHandler&& handler, asio::const_buffer buffer) -{ - auto n = asio::buffer_size(buffer); - const bool is_eof = (n == 0); - logd("[{}] start_write: n={} is_eof={}", log_prefix, n, is_eof); - - // - // Once accepted, the caller's intent to end the response body is final: this is what tells - // delete_writer() the body ended where it was meant to, so it need not reset the stream. An - // earlier cancellation just makes for a shorter body than planned -- legitimate here, and a - // declared content-length is still enforced by the peer. - // - if (is_eof && eof_submitted) - { - // - // The body was already ended. If that FIN is still pending (its handler was detached by - // cancellation, see bind_write_cancellation()), adopt this handler so it completes when the - // FIN actually goes out; otherwise the FIN is long gone and there is nothing left to do. - // - if (write_active && write_is_eof) - { - logd("[{}] start_write: FIN already pending, adopting handler", log_prefix); - bind_write_cancellation(handler, write_token); - write_handler = std::move(handler); - } - else if (handler) - { - asio::any_completion_executor ex = - asio::get_associated_immediate_executor(handler, get_executor()); - ex.execute([handler = std::move(handler)]() mutable - { std::move(handler)(boost::system::error_code{}); }); - } - return; - } - - // - // Only one async_write() may be active at a time -- see the class comment above write_active. - // The re-issued EOF handled above is not an exception to that: it adopts the FIN that is - // already in flight instead of starting a write of its own, and has returned by now. - // - assert(!write_active); - - if (is_eof) - eof_submitted = true; - - const uint64_t token = next_write_token++; - bind_write_cancellation(handler, token); - - write_active = true; - write_source = buffer; // referenced, not copied -- see class comment above write_active - write_offered = 0; - write_acked = 0; - write_is_eof = is_eof; - write_token = token; - write_handler = std::move(handler); - - if (auto h3 = session.h3()) - nghttp3_conn_resume_stream(h3, id); - session.wake_write(); -} - -void Http3Stream::bind_write_cancellation(WriteHandler& handler, uint64_t token) -{ - // Nothing to bind for a caller that passed no completion handler. - if (!handler) - return; - - auto cs = asio::get_associated_cancellation_slot(handler); - if (!cs.is_connected() || cs.has_handler()) - return; - - cs.assign([this, token](asio::cancellation_type_t ct) - { - // - // Cancellation completes the write immediately, without waiting for the acknowledgements - // it would normally complete on -- see below for what that costs. - // - if (write_token != token || !write_handler) - return; // already completed naturally before the cancellation was delivered - - if (write_is_eof) - { - // - // The body has already been declared ended, and a FIN cannot be un-sent -- it may just - // still be waiting for flow control credit. Detach the handler but leave the write - // active so it still goes out: abandoning it would leave the stream half-open forever, - // with the peer waiting for an end that never comes. - // - logd("[{}] async_write: \x1b[1;31mcancelled\x1b[0m ({}), FIN still pending", log_prefix, - ct); - asio::post(get_executor(), [handler = std::move(write_handler)]() mutable { // - std::move(handler)(errc::make_error_code(errc::operation_canceled)); - }); - return; - } - logd("[{}] async_write: \x1b[1;31mcancelled\x1b[0m ({})", log_prefix, ct); - - // - // The handler runs now, and the caller is free to destroy its buffer the moment it does -- - // but nghttp3/ngtcp2 point straight into that buffer (see the class comment above - // write_active), so whatever was offered and is not acknowledged yet has to stop being - // referenced first. Only a reset can guarantee that: RESET_STREAM makes ngtcp2 drop the - // stream's queued data and keeps it from reclaiming in-flight bytes for retransmission. - // That costs nothing in expressiveness -- a body cut short mid-write is truncated, which - // is exactly what delete_writer() resets the stream for as well. - // - // A write that never got to offer a byte, or whose bytes are all acknowledged already, - // leaves nothing behind and lets the stream carry on unharmed. - // - if (write_offered > write_acked && !closed) - { - logw("[{}] async_write: cancelled with {} bytes unacknowledged, resetting stream", - log_prefix, write_offered - write_acked); - session.reset_stream(id, NGHTTP3_H3_REQUEST_CANCELLED); - closed = true; - } - write_active = false; - write_source = {}; - // make sure to post this -- otherwise "MAIN COROUTINE DID NOT COMPLETE" happens - asio::post(get_executor(), [handler = std::move(write_handler)]() mutable - { std::move(handler)(errc::make_error_code(errc::operation_canceled)); }); - }); -} - -nghttp3_ssize Http3Stream::data_reader(nghttp3_vec* vec, size_t veccnt, uint32_t* pflags) -{ - if (veccnt == 0) - return 0; - - if (!write_active) - return NGHTTP3_ERR_WOULDBLOCK; - - // - // Hand out what is left of the caller's buffer, by reference and in one go: a nghttp3_vec is - // just a pointer and a length, so there is nothing to be gained from slicing it up, and - // nghttp3 gets to frame the whole thing as a single DATA frame. It picks up the rest by - // itself as packets are filled -- see write_pkt(), which keeps feeding the same vec to - // ngtcp2_conn_writev_stream() and advances nghttp3 by whatever went into the packet. - // - const size_t total = asio::buffer_size(write_source); - if (write_offered < total) - { - auto* base = static_cast(write_source.data()) + write_offered; - vec[0].base = const_cast(base); // nghttp3 reads through this, never writes - vec[0].len = total - write_offered; - write_offered = total; // don't offer these bytes twice -- see class comment above - // write_active - return 1; - } - - // - // Everything has been offered. For a body write there is nothing new until the caller starts - // the next one (which resumes the stream), so block here rather than returning 0 bytes -- - // returning 0 without NGHTTP3_DATA_FLAG_EOF would tell nghttp3 the body ended. - // - if (!write_is_eof) - return NGHTTP3_ERR_WOULDBLOCK; - - // - // The EOF marker (write_source is always empty for it) completes as soon as nghttp3 has taken - // the FIN: unlike body data, a FIN carries no memory of the caller's that we would have to - // keep alive until it is acknowledged. - // - *pflags |= NGHTTP3_DATA_FLAG_EOF; - eof_sent_to_h3 = true; - finish_active_write(); - return 0; -} - -void Http3Stream::on_write_acked(size_t n) -{ - // - // n counts *application* data acknowledged on this stream -- nghttp3 accounts for the HTTP/3 - // framing it puts around the body itself, so unlike ngtcp2's stream offsets these bytes are - // exactly the ones the caller handed us. All of them belong to the write currently active: a - // write only completes once every byte it offered is acknowledged, so nothing can still be - // outstanding from an earlier one. Clamp defensively anyway -- an accounting mismatch should - // complete the write early, not run write_acked past the end of the buffer. - // - if (n == 0 || !write_active || write_is_eof) - return; - - write_acked = std::min(write_acked + n, asio::buffer_size(write_source)); - logd("[{}] on_write_acked: {} bytes, {}/{} acknowledged", log_prefix, n, write_acked, - asio::buffer_size(write_source)); - - if (write_acked == asio::buffer_size(write_source)) - finish_active_write(); -} - -void Http3Stream::finish_active_write() -{ - assert(write_active); - - // - // Invoking the handler hands the caller's buffer back to it, so this must only ever run when - // nothing points into it any more: every offered byte acknowledged (on_write_acked()), or no - // bytes offered at all (the EOF marker). - // - write_source = {}; - auto handler = std::move(write_handler); - write_active = false; - - if (handler) - swap_and_invoke(handler, boost::system::error_code{}); -} - -// ------------------------------------------------------------------------------------------------- - -void Http3Stream::delete_reader() -{ - auto self = shared_from_this(); // see delete_writer() - pending_read.clear(); - read_head = {}; - incoming = {}; - - // - // The handler dropped the Request without reading the body to its end (e.g. not_found(), which - // never looks at it). Stream-level flow control credit is only granted as the application - // actually reads (see Http3Session::consume_stream()), so a peer with more body to send would - // stall forever against a window that will now never reopen. Tell it to stop instead: - // STOP_SENDING half-closes only our read direction, leaving the response we are still writing - // to flow normally -- HTTP/2 has to submit a full RST_STREAM here for lack of a half-close. - // - if (!eof_received && !closed) - { - logd("[{}] delete_reader: request body not read to end, sending STOP_SENDING", log_prefix); - session.stop_reading(id, NGHTTP3_H3_NO_ERROR); - } - - maybe_close(); -} - -void Http3Stream::delete_writer() -{ - // - // The teardown paths below can run handlers that drop the last reference to this stream, - // erasing it from the session -- keep it alive until this function returns. - // - auto self = shared_from_this(); - - // - // Nothing to finalize on a stream ngtcp2 has already torn down (peer reset it, or we did): - // there is nothing left to reset, and submitting anything would leave nghttp3 holding data for - // a stream that no longer exists, which it would then offer for sending forever. - // - if (closed) - { - logd("[{}] delete_writer: stream already closed", log_prefix); - maybe_close(); - return; - } - - if (!response_submitted) - { - // - // The handler never even started a response (e.g. it returned, or the request was - // reset, before calling response.async_submit()). There is no HEADERS frame for - // nghttp3 to close out, so "synthesize EOF" (below) has nothing to act on and the - // peer would be left waiting forever. Abort the stream at the transport level - // instead, mirroring what h3_cb_stop_sending/h3_cb_reset_stream already do for - // nghttp3-initiated aborts. NO_ERROR here (rather than e.g. INTERNAL_ERROR): the - // handler choosing not to respond isn't itself a protocol error -- the client just - // needs to be told the stream is over so it doesn't wait forever. - // - ngtcp2_conn_shutdown_stream(session.conn(), 0, id, NGHTTP3_H3_NO_ERROR); - closed = true; - session.wake_write(); - maybe_close(); - return; - } - - if (!eof_submitted) - { - // - // The Response was dropped without ever ending the body (async_write({})), so wherever it - // stopped is not where it was meant to stop. Sending a FIN here would present that partial - // response to the client as a complete one -- reset the stream instead, the same way the - // no-response case above aborts at the transport level, and matching the client's - // Http3ClientStream::delete_writer(). - // - logw("[{}] delete_writer: response body never ended, resetting stream", log_prefix); - session.reset_stream(id, NGHTTP3_H3_REQUEST_CANCELLED); - closed = true; - maybe_close(); - return; - } - maybe_close(); -} - -void Http3Stream::maybe_close() -{ - if (reader || writer) - return; - if (!closed) - return; - session.erase_stream(id); -} - -// ================================================================================================= -// Http3Session implementation -// ================================================================================================= - -Http3Session::Http3Session(Server::Impl& server, Endpoint ep, ngtcp2::Address remote) - : server_(server), ep_(ep), remote_(remote), - executor_(server.config().use_strand - ? asio::any_io_executor{asio::make_strand(server.get_executor())} - : server.get_executor()), - timer_(executor_), done_signal_(executor_) -{ - ngtcp2_ccerr_default(&last_error_); - log_prefix_ = std::format("h3:{}", ngtcp2::util::straddr(&remote_.su.sa, remote_.len)); - - // - // Own a dup() of the shared UDP fd rather than borrowing the server's. Sends happen from this - // session's strand, concurrently with everything else -- sendto()/sendmsg() on a shared - // datagram fd is fine, each call is atomic -- but at shutdown the server closes its socket - // right after posting destroy() to every session, and the final CONNECTION_CLOSE would - // otherwise race that close (and, worse, a recycled fd number). - // - if (int fd = ::dup(ep_.fd); fd >= 0) - { - ep_.fd = fd; - owns_fd_ = true; - } - else - loge("[{}] dup: {}", log_prefix_, strerror(errno)); - - // done_signal_ is armed at "never" until signal_done() moves it to the past. - done_signal_.expires_at(asio::steady_timer::time_point::max()); - mlogd("session created"); -} - -Http3Session::~Http3Session() -{ - timer_.cancel(); - done_signal_.cancel(); - streams_.clear(); - if (h3_) - nghttp3_conn_del(h3_); - if (conn_) - ngtcp2_conn_del(conn_); - if (ossl_ctx_) - { - if (auto ssl = ngtcp2_crypto_ossl_ctx_get_ssl(ossl_ctx_)) - { - SSL_set_app_data(ssl, nullptr); - SSL_free(ssl); - } - ngtcp2_crypto_ossl_ctx_del(ossl_ctx_); - } - if (owns_fd_) - ::close(ep_.fd); - mlogi("session destroyed"); -} - -// ------------------------------------------------------------------------------------------------- - -void Http3Session::async_submit(SubmitHandler&& handler, boost::urls::url, const Fields&) -{ - // Client-side submit is not implemented yet. - std::move(handler)(errc::make_error_code(errc::operation_not_supported), - client::Request{nullptr}); -} - -awaitable Http3Session::do_session(Buffer&&) -{ - boost::system::error_code ec; - co_await done_signal_.async_wait(redirect_error(use_awaitable, ec)); - // ec is boost::asio::error::operation_aborted (from destroy()) or a spurious - // wake-up; either way, this coroutine's job is done. - co_return; -} - -void Http3Session::destroy() noexcept -{ - // - // Called from wherever the Server is being torn down -- under multithreading that is some - // other thread's strand (e.g. the signal handler in server_main), while this session's own - // strand may be mid-flush. Everything below touches ngtcp2 state and the timers, so hop onto - // 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(executor_, [self = shared_from_this()] { - static_cast(*self).do_destroy(); - }); -} - -void Http3Session::do_destroy() noexcept -{ - // - // Tear the streams down here, on the session's executor, rather than leaving it to the - // destructor. The destructor runs wherever the last shared_ptr happens to drop -- at server - // shutdown that can be Server::Impl::~Impl() on a foreign thread -- and destroying streams - // detaches readers/writers and fires pending handlers, state that request-handler coroutines - // still running on this session's strand look at. Done here, that teardown is serialized - // with them; a handler that resumes afterwards finds its Reader/Writer detached and fails - // cleanly, exactly as in the single-threaded case. - // - streams_.clear(); - - if (std::exchange(closed_, true)) - { - timer_.cancel(); - signal_done(); - return; - } - - // - // Explicit shutdown (e.g. the whole Server::Impl going away): let the peer know right - // away instead of leaving it to find out via idle timeout (up to 30s). Unlike - // handle_error(), this doesn't linger for 3 PTO to handle retransmits of the CLOSE -- - // this is a clean, voluntary shutdown, not an error condition worth that effort. - // - if (conn_ && !ngtcp2_conn_in_closing_period(conn_) && !ngtcp2_conn_in_draining_period(conn_)) - { - std::array closebuf; - ngtcp2_path_storage ps; - ngtcp2_pkt_info pi; - ngtcp2_path_storage_zero(&ps); - - auto nwrite = - ngtcp2_conn_write_connection_close(conn_, &ps.path, &pi, closebuf.data(), closebuf.size(), - &last_error_, ngtcp2::util::timestamp()); - if (nwrite > 0) - send_udp(ep_, ps.path.remote.addr, ps.path.remote.addrlen, - {closebuf.data(), static_cast(nwrite)}); - } - - timer_.cancel(); - signal_done(); -} - -void Http3Session::signal_done() -{ - // Move the sentinel timer to the past so any waiter wakes up. - done_signal_.expires_at(asio::steady_timer::time_point::min()); -} - -// ------------------------------------------------------------------------------------------------- - -std::shared_ptr Http3Session::find_stream(int64_t id) -{ - auto it = streams_.find(id); - return it == streams_.end() ? nullptr : it->second; -} - -Http3Stream* Http3Session::create_stream(int64_t id) -{ - auto [it, inserted] = streams_.emplace(id, std::make_shared(*this, id)); - return it->second.get(); -} - -void Http3Session::erase_stream(int64_t id) { streams_.erase(id); } - -void Http3Session::wake_write() -{ - // The session write loop is only run in reaction to a packet arriving or a timer - // firing. When the user submits response data outside those events, we need to - // kick the write loop ourselves. - // - // Capture a weak_ptr, not shared_from_this(): wake_write() can be reached from a - // Reader/Writer destructor that runs as part of *this* session's own teardown (e.g. a - // still-in-flight request/response destroyed by Server::Impl cancelling everything on - // shutdown), at which point shared_from_this() would throw bad_weak_ptr. - // - // One flush per wake, not one per submission. A response submits its headers, its body and - // its EOF separately, and posting for each means the first pass writes everything and the - // rest walk the connection for nothing -- and still re-arm the timer on the way out. Arming - // once and clearing when the flush runs is what ngtcp2's example server gets for free from - // ev_io_start() on an already-active watcher. - // - if (write_posted_) - return; - write_posted_ = true; - - asio::post(get_executor(), [self = weak_from_this()] - { - auto session = std::static_pointer_cast(self.lock()); - if (!session) - return; - session->write_posted_ = false; - if (session->closed_) - return; - session->flush_write(); - }); -} - -// ------------------------------------------------------------------------------------------------- - -int Http3Session::init(const ngtcp2_cid& dcid, const ngtcp2_cid& scid, uint32_t version, - const ngtcp2_pkt_info& pi, std::span data) -{ - scid_.datalen = QUIC_SCIDLEN; - if (RAND_bytes(scid_.data, static_cast(scid_.datalen)) != 1) - { - loge("[{}] init: RAND_bytes for SCID failed", log_prefix_); - return -1; - } - - ngtcp2_callbacks callbacks{}; - callbacks.recv_client_initial = ngtcp2_crypto_recv_client_initial_cb; - callbacks.recv_crypto_data = ngtcp2_crypto_recv_crypto_data_cb; - callbacks.handshake_completed = &Http3Session::cb_handshake_completed; - callbacks.encrypt = ngtcp2_crypto_encrypt_cb; - callbacks.decrypt = ngtcp2_crypto_decrypt_cb; - callbacks.hp_mask = ngtcp2_crypto_hp_mask_cb; - callbacks.recv_stream_data = &Http3Session::cb_recv_stream_data; - callbacks.acked_stream_data_offset = &Http3Session::cb_acked_stream_data_offset; - callbacks.stream_open = &Http3Session::cb_stream_open; - callbacks.stream_close = &Http3Session::cb_stream_close; - callbacks.rand = &Http3Session::cb_rand; - callbacks.get_new_connection_id = &Http3Session::cb_get_new_connection_id; - callbacks.remove_connection_id = &Http3Session::cb_remove_connection_id; - callbacks.update_key = ngtcp2_crypto_update_key_cb; - callbacks.stream_reset = &Http3Session::cb_stream_reset; - callbacks.extend_max_remote_streams_bidi = &Http3Session::cb_extend_max_remote_streams_bidi; - callbacks.extend_max_stream_data = &Http3Session::cb_extend_max_stream_data; - callbacks.delete_crypto_aead_ctx = ngtcp2_crypto_delete_crypto_aead_ctx_cb; - callbacks.delete_crypto_cipher_ctx = ngtcp2_crypto_delete_crypto_cipher_ctx_cb; - callbacks.get_path_challenge_data = ngtcp2_crypto_get_path_challenge_data_cb; - callbacks.stream_stop_sending = &Http3Session::cb_stream_stop_sending; - callbacks.version_negotiation = ngtcp2_crypto_version_negotiation_cb; - callbacks.recv_rx_key = &Http3Session::cb_recv_rx_key; - - ngtcp2_settings settings; - ngtcp2_settings_default(&settings); - settings.initial_ts = ngtcp2::util::timestamp(); - - // - // Only install the log callback when trace logging is actually enabled: ngtcp2 formats every - // frame of every packet into a string *before* invoking it, so a callback that discards its - // input still pays for the full formatting. A NULL log_printf makes ngtcp2 skip that work. - // - if (spdlog::default_logger_raw()->should_log(spdlog::level::trace)) - settings.log_printf = &ngtcp2_log_printf; - - ngtcp2_transport_params params; - ngtcp2_transport_params_default(¶ms); - params.initial_max_stream_data_bidi_local = 256_k; - params.initial_max_stream_data_bidi_remote = 256_k; - params.initial_max_stream_data_uni = 256_k; - params.initial_max_data = 1_m; - params.initial_max_streams_bidi = 100; - params.initial_max_streams_uni = 3; - params.max_idle_timeout = server_.config().idle_timeout.count(); - params.original_dcid = dcid; - params.original_dcid_present = 1; - - ngtcp2_path path{ - {const_cast(&ep_.addr.su.sa), ep_.addr.len}, - {const_cast(&remote_.su.sa), remote_.len}, - &ep_, - }; - - if (auto rv = ngtcp2_conn_server_new(&conn_, &scid, &scid_, &path, version, &callbacks, - &settings, ¶ms, nullptr, this); - rv != 0) - { - loge("[{}] ngtcp2_conn_server_new: {}", log_prefix_, ngtcp2_strerror(rv)); - return -1; - } - - auto* ssl = SSL_new(tls_context().ctx); - if (!ssl) - { - loge("[{}] SSL_new failed", log_prefix_); - return -1; - } - - conn_ref_.get_conn = &Http3Session::get_conn; - conn_ref_.user_data = this; - SSL_set_app_data(ssl, &conn_ref_); - SSL_set_accept_state(ssl); - - if (ngtcp2_crypto_ossl_configure_server_session(ssl) != 0) - { - loge("[{}] ngtcp2_crypto_ossl_configure_server_session failed", log_prefix_); - SSL_free(ssl); - return -1; - } - - if (ngtcp2_crypto_ossl_ctx_new(&ossl_ctx_, ssl) != 0) - { - loge("[{}] ngtcp2_crypto_ossl_ctx_new failed", log_prefix_); - SSL_free(ssl); - return -1; - } - - ngtcp2_conn_set_tls_native_handle(conn_, ossl_ctx_); - - logi("[{}] new connection, scid={} version=0x{:x}", log_prefix_, - ngtcp2::util::format_hex(scid_.data, scid_.datalen), version); - - return on_read(pi, data, remote_); -} - -// ------------------------------------------------------------------------------------------------- - -int Http3Session::on_read(const ngtcp2_pkt_info& pi, std::span data, - const ngtcp2::Address& remote) -{ - logd("[{}] on_read: {} bytes", log_prefix_, data.size()); - - ngtcp2_path path{ - {const_cast(&ep_.addr.su.sa), ep_.addr.len}, - {const_cast(&remote.su.sa), remote.len}, - &ep_, - }; - - auto rv = - ngtcp2_conn_read_pkt(conn_, &path, &pi, data.data(), data.size(), ngtcp2::util::timestamp()); - if (rv != 0) - { - if (rv == NGTCP2_ERR_DRAINING) - logd("[{}] ngtcp2_conn_read_pkt: draining", log_prefix_); - else - logw("[{}] ngtcp2_conn_read_pkt: {}", log_prefix_, ngtcp2_strerror(rv)); - - if (rv == NGTCP2_ERR_CRYPTO && !last_error_.error_code) - ngtcp2_ccerr_set_tls_alert(&last_error_, ngtcp2_conn_get_tls_alert(conn_), nullptr, 0); - else if (!last_error_.error_code) - ngtcp2_ccerr_set_liberr(&last_error_, rv, nullptr, 0); - return handle_error(rv); - } - - // - // Deliberately no write here -- see defer_write(). - // - defer_write(); - return 0; -} - -// ------------------------------------------------------------------------------------------------- - -namespace -{ -ngtcp2_ssize write_pkt_cb(ngtcp2_conn*, ngtcp2_path* path, ngtcp2_pkt_info* pi, uint8_t* dest, - size_t destlen, ngtcp2_tstamp ts, void* user_data) -{ - return static_cast(user_data)->write_pkt(path, pi, dest, destlen, ts); -} -} // namespace - -// Writes a single QUIC packet's worth of stream data into [dest, dest+destlen). Called -// repeatedly by ngtcp2_conn_write_aggregate_pkt2() (once per packet it wants to pack into the -// shared TX buffer), so unlike the old single-packet write_streams() this must never call -// send_udp() itself -- the caller decides when/how the accumulated packets go out. -ngtcp2_ssize Http3Session::write_pkt(ngtcp2_path* path, ngtcp2_pkt_info* pi, uint8_t* dest, - size_t destlen, ngtcp2_tstamp ts) -{ - std::array vec; - int64_t shut_down_stream = -1; // see NGTCP2_ERR_STREAM_NOT_FOUND below - - for (;;) - { - int64_t stream_id = -1; - int fin = 0; - nghttp3_ssize sveccnt = 0; - - if (h3_ && ngtcp2_conn_get_max_data_left(conn_)) - { - sveccnt = nghttp3_conn_writev_stream(h3_, &stream_id, &fin, vec.data(), vec.size()); - logd("[{}] write_pkt: nghttp3_conn_writev_stream -> stream={} sveccnt={} fin={}", - log_prefix_, stream_id, sveccnt, fin); - if (sveccnt < 0) - { - loge("[{}] nghttp3_conn_writev_stream: {}", log_prefix_, - nghttp3_strerror(static_cast(sveccnt))); - ngtcp2_ccerr_set_application_error( - &last_error_, nghttp3_err_infer_quic_app_error_code(static_cast(sveccnt)), - nullptr, 0); - return NGTCP2_ERR_CALLBACK_FAILURE; - } - } - - ngtcp2_ssize ndatalen; - uint32_t flags = NGTCP2_WRITE_STREAM_FLAG_MORE | NGTCP2_WRITE_STREAM_FLAG_PADDING; - if (fin) - flags |= NGTCP2_WRITE_STREAM_FLAG_FIN; - - auto nwrite = ngtcp2_conn_writev_stream( - conn_, path, pi, dest, destlen, &ndatalen, flags, stream_id, - reinterpret_cast(vec.data()), static_cast(sveccnt), ts); - - if (nwrite < 0) - { - switch (nwrite) - { - case NGTCP2_ERR_STREAM_DATA_BLOCKED: - if (h3_ && stream_id >= 0) - nghttp3_conn_block_stream(h3_, stream_id); - continue; - case NGTCP2_ERR_STREAM_SHUT_WR: - if (h3_ && stream_id >= 0) - nghttp3_conn_shutdown_stream_write(h3_, stream_id); - continue; - case NGTCP2_ERR_STREAM_NOT_FOUND: - // - // ngtcp2 has already torn the stream down (the peer reset it, or we did) while - // nghttp3 still had response data queued for it. That's a dead stream, not a dead - // connection -- tell nghttp3 so it stops offering it and keep serving the others. - // Should nghttp3 offer the same stream again anyway, stop packing this packet rather - // than spinning here forever. - // - if (h3_ && stream_id >= 0 && stream_id != shut_down_stream) - { - logw("[{}] write_pkt: stream {} is gone, shutting down its write side", log_prefix_, - stream_id); - nghttp3_conn_shutdown_stream_write(h3_, stream_id); - nghttp3_conn_block_stream(h3_, stream_id); - shut_down_stream = stream_id; - continue; - } - return 0; - case NGTCP2_ERR_WRITE_MORE: - if (h3_ && stream_id >= 0 && ndatalen > 0) - { - if (auto rv = - nghttp3_conn_add_write_offset(h3_, stream_id, static_cast(ndatalen)); - rv != 0) - { - loge("[{}] nghttp3_conn_add_write_offset: {}", log_prefix_, nghttp3_strerror(rv)); - return NGTCP2_ERR_CALLBACK_FAILURE; - } - } - continue; - default: - loge("[{}] ngtcp2_conn_writev_stream: {}", log_prefix_, - ngtcp2_strerror(static_cast(nwrite))); - ngtcp2_ccerr_set_liberr(&last_error_, static_cast(nwrite), nullptr, 0); - return NGTCP2_ERR_CALLBACK_FAILURE; - } - } - - if (ndatalen > 0 && h3_ && stream_id >= 0) - { - if (auto rv = nghttp3_conn_add_write_offset(h3_, stream_id, static_cast(ndatalen)); - rv != 0) - { - loge("[{}] nghttp3_conn_add_write_offset: {}", log_prefix_, nghttp3_strerror(rv)); - return NGTCP2_ERR_CALLBACK_FAILURE; - } - } - - return nwrite; - } -} - -// ------------------------------------------------------------------------------------------------- - -int Http3Session::write_streams() -{ - if (ngtcp2_conn_in_closing_period(conn_) || ngtcp2_conn_in_draining_period(conn_)) - return 0; - - logd("[{}] write_streams: max_data_left={}", log_prefix_, ngtcp2_conn_get_max_data_left(conn_)); - - ngtcp2_path_storage ps; - ngtcp2_pkt_info pi; - ngtcp2_path_storage_zero(&ps); - - size_t gso_size = 0; - auto nwrite = - ngtcp2_conn_write_aggregate_pkt2(conn_, &ps.path, &pi, tx_buf_.data(), tx_buf_.size(), - &gso_size, &write_pkt_cb, 0, ngtcp2::util::timestamp()); - if (nwrite < 0) - { - loge("[{}] ngtcp2_conn_write_aggregate_pkt2: {}", log_prefix_, - ngtcp2_strerror(static_cast(nwrite))); - if (!last_error_.error_code) - ngtcp2_ccerr_set_liberr(&last_error_, static_cast(nwrite), nullptr, 0); - return handle_error(static_cast(nwrite)); - } - - ngtcp2_conn_update_pkt_tx_time(conn_, ngtcp2::util::timestamp()); - - if (nwrite == 0) - return 0; - - return send_udp_gso(ep_, ps.path.remote.addr, ps.path.remote.addrlen, - {tx_buf_.data(), static_cast(nwrite)}, gso_size, no_gso_); -} - -// ------------------------------------------------------------------------------------------------- - -int Http3Session::flush_write() -{ - write_pending_ = false; - - if (closed_ || !conn_) - return 0; - - if (auto rv = write_streams(); rv != 0) - return rv; - - update_timer(); - return 0; -} - -// ------------------------------------------------------------------------------------------------- - -void Http3Session::update_timer() { arm_timer_from_ngtcp2(); } - -void Http3Session::arm_timer_from_ngtcp2() -{ - if (closed_) - return; - - auto expiry = ngtcp2_conn_get_expiry(conn_); - if (expiry == UINT64_MAX) - { - // ngtcp2 has no pending timer. Cancel the current one so we don't - // accidentally keep an old retransmission timer alive past its purpose - // and don't keep the io_context alive indefinitely. - timer_.cancel(); - return; - } - - auto now = ngtcp2::util::timestamp(); - asio::steady_timer::duration delay = - 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) - { - if (ec) - return; - if (auto session = std::static_pointer_cast(self.lock())) - session->handle_expiry(); - }); -} - -int Http3Session::handle_expiry() -{ - auto now = ngtcp2::util::timestamp(); - if (auto rv = ngtcp2_conn_handle_expiry(conn_, now); rv != 0) - { - // - // NGTCP2_ERR_IDLE_CLOSE is how a connection whose peer simply stopped talking ends -- - // an interrupted client leaves one behind per connection it had open -- so it is a - // normal end of life, not a failure worth a warning. handle_error() takes it from here - // either way; what makes it special is that it discards the connection silently, see - // there. - // - if (rv == NGTCP2_ERR_IDLE_CLOSE) - logi("[{}] idle timeout, dropping connection", log_prefix_); - else - logw("[{}] ngtcp2_conn_handle_expiry: {}", log_prefix_, ngtcp2_strerror(rv)); - - ngtcp2_ccerr_set_liberr(&last_error_, rv, nullptr, 0); - return handle_error(rv); - } - return flush_write(); -} - -// ------------------------------------------------------------------------------------------------- - -int Http3Session::handle_error(int /*rv*/) -{ - if (closed_) - return -1; - closed_ = true; - - // - // Idle timeout and drop-conn need no CONNECTION_CLOSE packet -- and with no packet there is - // no closing period either, so none of the cleanup in Server::Impl::udp_on_read() can ever - // run for this session: it is reached from the expiry timer precisely because nothing is - // arriving any more. Drop the session from the demux map right here instead, or it would sit - // in m_quic_handlers for the lifetime of the server, holding streams whose request handlers - // are still waiting on a peer that went away. What is left of it then dies with do_session(). - // - if (last_error_.type == NGTCP2_CCERR_TYPE_IDLE_CLOSE || - last_error_.type == NGTCP2_CCERR_TYPE_DROP_CONN) - { - auto self = weak_from_this().lock(); // erase_quic_session() may drop the last reference - timer_.cancel(); - server_.erase_quic_session(this); - signal_done(); - return -1; - } - - // If already in draining (peer sent CONNECTION_CLOSE), don't reply. - // If already in closing, the buffered packet is still valid. - if (conn_ && !ngtcp2_conn_in_draining_period(conn_) && !ngtcp2_conn_in_closing_period(conn_)) - { - conn_closebuf_.resize(NGTCP2_MAX_UDP_PAYLOAD_SIZE); - ngtcp2_path_storage ps; - ngtcp2_pkt_info pi; - ngtcp2_path_storage_zero(&ps); - - auto nwrite = ngtcp2_conn_write_connection_close(conn_, &ps.path, &pi, conn_closebuf_.data(), - conn_closebuf_.size(), &last_error_, - ngtcp2::util::timestamp()); - if (nwrite > 0) - { - conn_closebuf_.resize(static_cast(nwrite)); - logi("[{}] sending CONNECTION_CLOSE", log_prefix_); - send_udp(ep_, ps.path.remote.addr, ps.path.remote.addrlen, - {conn_closebuf_.data(), conn_closebuf_.size()}); - } - else - { - conn_closebuf_.clear(); - signal_done(); - return -1; - } - } - - // Stay alive for 3 PTO so we can resend the CONNECTION_CLOSE if the - // peer retransmits, or absorb late packets during the draining period. - schedule_close_timer(); - return -1; -} - -void Http3Session::schedule_close_timer() -{ - auto delay = conn_ ? std::chrono::nanoseconds{ngtcp2_conn_get_pto(conn_) * 3} - : std::chrono::milliseconds{100}; - timer_.expires_after(delay); - timer_.async_wait([self = weak_from_this()](const boost::system::error_code& ec) - { - if (ec) - return; - auto session = std::static_pointer_cast(self.lock()); - if (!session) - return; - logd("[{}] closing/draining period over", session->log_prefix_); - session->server_.erase_quic_session(session.get()); - session->signal_done(); - }); -} - -void Http3Session::resend_conn_close() -{ - if (conn_closebuf_.empty()) - return; - auto* path = ngtcp2_conn_get_path(conn_); - if (!path) - return; - logd("[{}] resending CONNECTION_CLOSE", log_prefix_); - send_udp(ep_, path->remote.addr, path->remote.addrlen, - {conn_closebuf_.data(), conn_closebuf_.size()}); -} - -// ------------------------------------------------------------------------------------------------- -// ngtcp2 callback implementations -// ------------------------------------------------------------------------------------------------- - -int Http3Session::cb_handshake_completed(ngtcp2_conn*, void* user) -{ - auto self = static_cast(user); - logi("[{}] TLS handshake completed: {}", self->log_prefix_, - tls_handshake_info(ngtcp2_crypto_ossl_ctx_get_ssl(self->ossl_ctx_))); - if (self->setup_http3() != 0) - return NGTCP2_ERR_CALLBACK_FAILURE; - return 0; -} - -int Http3Session::cb_recv_stream_data(ngtcp2_conn*, uint32_t flags, int64_t stream_id, - uint64_t offset, const uint8_t* data, size_t datalen, - void* user, void*) -{ - auto self = static_cast(user); - logd("[{}] cb_recv_stream_data: stream={} offset={} datalen={} fin={} h3_={}", self->log_prefix_, - stream_id, offset, datalen, !!(flags & NGTCP2_STREAM_DATA_FLAG_FIN), !!self->h3_); - if (!self->h3_) - { - logw("[{}] cb_recv_stream_data: DROPPING {} bytes on stream {} (h3 not ready)", - self->log_prefix_, datalen, stream_id); - return 0; - } - - auto nread = nghttp3_conn_read_stream(self->h3_, stream_id, data, datalen, - (flags & NGTCP2_STREAM_DATA_FLAG_FIN) ? 1 : 0); - if (nread < 0) - { - loge("[{}] nghttp3_conn_read_stream({}): {}", self->log_prefix_, stream_id, - nghttp3_strerror(static_cast(nread))); - ngtcp2_ccerr_set_application_error( - &self->last_error_, nghttp3_err_infer_quic_app_error_code(static_cast(nread)), - nullptr, 0); - return NGTCP2_ERR_CALLBACK_FAILURE; - } - - ngtcp2_conn_extend_max_stream_offset(self->conn_, stream_id, static_cast(nread)); - ngtcp2_conn_extend_max_offset(self->conn_, static_cast(nread)); - return 0; -} - -int Http3Session::cb_acked_stream_data_offset(ngtcp2_conn*, int64_t stream_id, uint64_t /*offset*/, - uint64_t datalen, void* user, void*) -{ - auto self = static_cast(user); - if (!self->h3_) - return 0; - if (auto rv = nghttp3_conn_add_ack_offset(self->h3_, stream_id, datalen); rv != 0) - { - loge("[{}] nghttp3_conn_add_ack_offset: {}", self->log_prefix_, nghttp3_strerror(rv)); - return NGTCP2_ERR_CALLBACK_FAILURE; - } - return 0; -} - -int Http3Session::cb_stream_open(ngtcp2_conn*, int64_t /*stream_id*/, void* /*user*/) { return 0; } - -int Http3Session::cb_stream_close(ngtcp2_conn*, uint32_t flags, int64_t stream_id, - uint64_t app_error_code, void* user, void*) -{ - auto self = static_cast(user); - if (!(flags & NGTCP2_STREAM_CLOSE_FLAG_APP_ERROR_CODE_SET)) - app_error_code = NGHTTP3_H3_NO_ERROR; - if (self->h3_) - { - if (auto rv = nghttp3_conn_close_stream(self->h3_, stream_id, app_error_code); rv != 0) - { - if (rv == NGHTTP3_ERR_STREAM_NOT_FOUND) - return 0; - loge("[{}] nghttp3_conn_close_stream({}): {}", self->log_prefix_, stream_id, - nghttp3_strerror(rv)); - return NGTCP2_ERR_CALLBACK_FAILURE; - } - } - return 0; -} - -void Http3Session::cb_rand(uint8_t* dest, size_t destlen, const ngtcp2_rand_ctx*) -{ - if (RAND_bytes(dest, static_cast(destlen)) != 1) - std::memset(dest, 0, destlen); -} - -int Http3Session::cb_get_new_connection_id(ngtcp2_conn*, ngtcp2_cid* cid, uint8_t* token, - size_t cidlen, void* user) -{ - auto self = static_cast(user); - if (RAND_bytes(cid->data, static_cast(cidlen)) != 1) - return NGTCP2_ERR_CALLBACK_FAILURE; - cid->datalen = cidlen; - if (RAND_bytes(token, NGTCP2_STATELESS_RESET_TOKENLEN) != 1) - return NGTCP2_ERR_CALLBACK_FAILURE; - self->server_.associate_quic_cid(*cid, self); - return 0; -} - -int Http3Session::cb_remove_connection_id(ngtcp2_conn*, const ngtcp2_cid* cid, void* user) -{ - auto self = static_cast(user); - self->server_.dissociate_quic_cid(*cid); - return 0; -} - -int Http3Session::cb_extend_max_remote_streams_bidi(ngtcp2_conn*, uint64_t /*max_streams*/, - void* /*user*/) -{ - return 0; -} - -int Http3Session::cb_stream_stop_sending(ngtcp2_conn*, int64_t stream_id, uint64_t /*ec*/, - void* user, void*) -{ - auto self = static_cast(user); - if (!self->h3_) - return 0; - if (auto rv = nghttp3_conn_shutdown_stream_read(self->h3_, stream_id); rv != 0) - { - loge("[{}] nghttp3_conn_shutdown_stream_read({}): {}", self->log_prefix_, stream_id, - nghttp3_strerror(rv)); - return NGTCP2_ERR_CALLBACK_FAILURE; - } - return 0; -} - -int Http3Session::cb_stream_reset(ngtcp2_conn*, int64_t stream_id, uint64_t /*final_size*/, - uint64_t /*ec*/, void* user, void*) -{ - auto self = static_cast(user); - if (!self->h3_) - return 0; - if (auto rv = nghttp3_conn_shutdown_stream_read(self->h3_, stream_id); rv != 0) - { - loge("[{}] nghttp3_conn_shutdown_stream_read({}): {}", self->log_prefix_, stream_id, - nghttp3_strerror(rv)); - return NGTCP2_ERR_CALLBACK_FAILURE; - } - return 0; -} - -int Http3Session::cb_extend_max_stream_data(ngtcp2_conn*, int64_t stream_id, uint64_t /*max_data*/, - void* user, void*) -{ - auto self = static_cast(user); - if (!self->h3_) - return 0; - if (auto rv = nghttp3_conn_unblock_stream(self->h3_, stream_id); rv != 0) - { - loge("[{}] nghttp3_conn_unblock_stream({}): {}", self->log_prefix_, stream_id, - nghttp3_strerror(rv)); - return NGTCP2_ERR_CALLBACK_FAILURE; - } - return 0; -} - -int Http3Session::cb_recv_rx_key(ngtcp2_conn*, ngtcp2_encryption_level level, void* user) -{ - if (level != NGTCP2_ENCRYPTION_LEVEL_1RTT) - return 0; - auto self = static_cast(user); - if (!self->h3_ && self->setup_http3() != 0) - return NGTCP2_ERR_CALLBACK_FAILURE; - return 0; -} - -// ------------------------------------------------------------------------------------------------- - -int Http3Session::setup_http3() -{ - if (h3_) - return 0; - - nghttp3_callbacks h3cb{}; - h3cb.acked_stream_data = &Http3Session::h3_cb_acked_stream_data; - h3cb.stream_close = &Http3Session::h3_cb_stream_close; - h3cb.recv_data = &Http3Session::h3_cb_recv_data; - h3cb.deferred_consume = &Http3Session::h3_cb_deferred_consume; - h3cb.begin_headers = &Http3Session::h3_cb_begin_headers; - h3cb.recv_header = &Http3Session::h3_cb_recv_header; - h3cb.end_headers = &Http3Session::h3_cb_end_headers; - h3cb.end_stream = &Http3Session::h3_cb_end_stream; - h3cb.stop_sending = &Http3Session::h3_cb_stop_sending; - h3cb.reset_stream = &Http3Session::h3_cb_reset_stream; - - nghttp3_settings settings; - nghttp3_settings_default(&settings); - settings.qpack_max_dtable_capacity = 4096; - settings.qpack_blocked_streams = 100; - - if (auto rv = nghttp3_conn_server_new(&h3_, &h3cb, &settings, nullptr, this); rv != 0) - { - loge("[{}] nghttp3_conn_server_new: {}", log_prefix_, nghttp3_strerror(rv)); - return -1; - } - - auto params = ngtcp2_conn_get_local_transport_params(conn_); - nghttp3_conn_set_max_client_streams_bidi(h3_, params->initial_max_streams_bidi); - - int64_t ctrl_stream_id = -1; - if (auto rv = ngtcp2_conn_open_uni_stream(conn_, &ctrl_stream_id, nullptr); rv != 0) - { - loge("[{}] open control stream: {}", log_prefix_, ngtcp2_strerror(rv)); - return -1; - } - if (auto rv = nghttp3_conn_bind_control_stream(h3_, ctrl_stream_id); rv != 0) - { - loge("[{}] nghttp3_conn_bind_control_stream: {}", log_prefix_, nghttp3_strerror(rv)); - return -1; - } - - int64_t qpack_enc_stream_id = -1; - int64_t qpack_dec_stream_id = -1; - if (ngtcp2_conn_open_uni_stream(conn_, &qpack_enc_stream_id, nullptr) != 0 || - ngtcp2_conn_open_uni_stream(conn_, &qpack_dec_stream_id, nullptr) != 0) - { - loge("[{}] open qpack streams failed", log_prefix_); - return -1; - } - if (auto rv = nghttp3_conn_bind_qpack_streams(h3_, qpack_enc_stream_id, qpack_dec_stream_id); - rv != 0) - { - loge("[{}] nghttp3_conn_bind_qpack_streams: {}", log_prefix_, nghttp3_strerror(rv)); - return -1; - } - - logi("[{}] HTTP/3 ready (ctrl={} qpack_enc={} qpack_dec={})", log_prefix_, ctrl_stream_id, - qpack_enc_stream_id, qpack_dec_stream_id); - return 0; -} - -// ------------------------------------------------------------------------------------------------- -// nghttp3 callbacks -// ------------------------------------------------------------------------------------------------- - -// -// The only notification that the peer is done with response body bytes we handed out by -// reference, and hence that the caller's buffer may be released -- see the comment above -// Http3Stream::write_active. -// -int Http3Session::h3_cb_acked_stream_data(nghttp3_conn*, int64_t stream_id, uint64_t datalen, - void* user, void*) -{ - auto self = static_cast(user); - auto stream = self->find_stream(stream_id); - if (!stream) - return 0; - - // - // Completing a write resumes the application, which may drop the last reference to this - // session -- while ngtcp2 is still in the middle of processing the ACK that got us here. - // weak_from_this(), not shared_from_this(): the ACK may well arrive during teardown. - // - auto session_guard = self->weak_from_this().lock(); - stream->on_write_acked(static_cast(datalen)); - return 0; -} - -int Http3Session::h3_cb_stream_close(nghttp3_conn*, int64_t stream_id, uint64_t /*app_error*/, - void* user, void*) -{ - auto self = static_cast(user); - logd("[{}] h3 stream {} closed", self->log_prefix_, stream_id); - if (auto s = self->find_stream(stream_id)) - { - s->closed = true; - // Waiting readers/writers should see the close now. - if (s->read_handler) - swap_and_invoke(s->read_handler, boost::system::error_code{}, 0); - - // - // A write waiting for its data to be acknowledged will never see those acknowledgements - // now: ngtcp2 drops whatever of this stream is still in flight. It also stops touching the - // caller's buffer, which is all the wait was ever for, so complete the write -- as failed, - // because the body did not make it -- instead of leaving it pending forever. - // - if (s->write_active && s->write_handler) - { - s->write_active = false; - s->write_source = {}; - swap_and_invoke(s->write_handler, errc::make_error_code(errc::connection_reset)); - } - s->maybe_close(); - } - if (ngtcp2_conn_is_server(self->conn_)) - ngtcp2_conn_extend_max_streams_bidi(self->conn_, 1); - return 0; -} - -int Http3Session::h3_cb_recv_data(nghttp3_conn*, int64_t stream_id, const uint8_t* data, - size_t datalen, void* user, void*) -{ - // - // Connection-level credit is granted immediately: it is a single pool shared with control/QPACK - // streams that nghttp3 manages on its own (the app never "reads" those), so withholding it here - // would stall unrelated traffic whenever this one stream's reader is slow. Only the *stream*- - // level credit for these bytes is deliberately deferred -- see Http3Session::consume_stream(). - // Granting it only once the application actually reads the data (in - // Http3Stream::call_read_handler()) is what makes request-body backpressure real instead of - // nghttp3 buffering an unbounded backlog in pending_read while the peer keeps sending on *this* - // stream. - // - auto self = static_cast(user); - ngtcp2_conn_extend_max_offset(self->conn_, datalen); - if (auto s = self->find_stream(stream_id)) - s->on_data_chunk(data, datalen); - return 0; -} - -int Http3Session::h3_cb_deferred_consume(nghttp3_conn*, int64_t stream_id, size_t nconsumed, - void* user, void*) -{ - auto self = static_cast(user); - ngtcp2_conn_extend_max_stream_offset(self->conn_, stream_id, nconsumed); - ngtcp2_conn_extend_max_offset(self->conn_, nconsumed); - return 0; -} - -int Http3Session::h3_cb_begin_headers(nghttp3_conn*, int64_t stream_id, void* user, void*) -{ - auto self = static_cast(user); - self->create_stream(stream_id); - return 0; -} - -int Http3Session::h3_cb_recv_header(nghttp3_conn*, int64_t stream_id, int32_t /*token*/, - nghttp3_rcbuf* name, nghttp3_rcbuf* value, uint8_t /*flags*/, - void* user, void*) -{ - auto self = static_cast(user); - auto n = nghttp3_rcbuf_get_buf(name); - auto v = nghttp3_rcbuf_get_buf(value); - auto name_view = std::string_view{reinterpret_cast(n.base), n.len}; - auto value_view = std::string_view{reinterpret_cast(v.base), v.len}; - - auto s = self->find_stream(stream_id); - if (!s) - return 0; - - if (spdlog::default_logger_raw()->should_log(spdlog::level::debug)) - s->received_headers.emplace_back(name_view, value_view); - - try - { - if (name_view == ":method") - s->method = value_view; - else if (name_view == ":path") - { - if (auto url = boost::urls::parse_relative_ref(value_view); url.has_value()) - { - s->url.set_path(url->path()); - if (url->has_query()) - s->url.set_query(url->query()); - if (url->has_fragment()) - s->url.set_fragment(url->fragment()); - } - } - else if (name_view == ":scheme") - s->url.set_scheme(value_view); - else if (name_view == ":authority") - s->url.set_encoded_authority(value_view); - else if (name_view == "content-length") - { - size_t len = 0; - if (std::from_chars(value_view.begin(), value_view.end(), len).ec == std::errc{}) - s->content_length = len; - } - else - s->request_fields.set(name_view, value_view); - } - catch (const std::exception& ex) - { - logw("[{}] ignoring invalid header: {} ({})", s->log_prefix, value_view, ex.what()); - } - return 0; -} - -int Http3Session::h3_cb_end_headers(nghttp3_conn*, int64_t stream_id, int /*fin*/, void* user, - void*) -{ - auto self = static_cast(user); - auto s = self->find_stream(stream_id); - if (!s) - return 0; - - logd("[{}] {} {}", s->log_prefix, s->method, s->url.buffer()); - log_headers(s->log_prefix, std::exchange(s->received_headers, {})); - - // - // Build user-facing Request/Response and dispatch through the shared handler. - // - server::Request request(std::make_unique>(*s)); - server::Response response(std::make_unique>(*s)); - - auto& sv = self->server_; - if (auto& handler = sv.requestHandler()) - co_spawn(self->get_executor(), handler(std::move(request), std::move(response)), detached); - else - { - loge("[{}] no request handler set", s->log_prefix); - co_spawn(self->get_executor(), not_found(std::move(response)), detached); - } - return 0; -} - -int Http3Session::h3_cb_end_stream(nghttp3_conn*, int64_t stream_id, void* user, void*) -{ - auto self = static_cast(user); - if (auto s = self->find_stream(stream_id)) - s->on_eof(); - return 0; -} - -int Http3Session::h3_cb_stop_sending(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, - void* user, void*) -{ - auto self = static_cast(user); - ngtcp2_conn_shutdown_stream_read(self->conn_, 0, stream_id, app_error_code); - return 0; -} - -int Http3Session::h3_cb_reset_stream(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, - void* user, void*) -{ - auto self = static_cast(user); - ngtcp2_conn_shutdown_stream_write(self->conn_, 0, stream_id, app_error_code); - return 0; -} - -// ================================================================================================= -// Server::Impl QUIC glue. -// ================================================================================================= - -namespace -{ -std::optional to_ngtcp2_address(const sockaddr_storage& src, socklen_t len) -{ - ngtcp2::Address addr{}; - if (len > sizeof(addr.su)) - return std::nullopt; - std::memcpy(&addr.su, &src, len); - addr.len = len; - return addr; -} -} // namespace - -// -// What one pass of udp_on_read() hands a session: every datagram of the receive batch that was -// addressed to it, copied out of the receive buffer because the session consumes them on its own -// strand, after udp_on_read() has moved on. `is_new` marks a batch whose first datagram is the -// client Initial that created the session -- process_quic_batch() runs init() with it. -// -struct QuicBatch -{ - struct Datagram - { - ngtcp2_pkt_info pi; - ngtcp2::Address remote; - std::vector data; - }; - - bool is_new = false; - ngtcp2_pkt_hd hd{}; // decoded Initial packet header, only valid when is_new - boost::container::small_vector datagrams; -}; - -void Server::Impl::associate_quic_cid(const ngtcp2_cid& cid, Http3Session* h) -{ - auto lock = std::lock_guard(m_quicMutex); - m_quic_handlers.emplace(cid_key(cid), - std::static_pointer_cast(h->shared_from_this())); -} - -void Server::Impl::dissociate_quic_cid(const ngtcp2_cid& cid) -{ - auto lock = std::lock_guard(m_quicMutex); - m_quic_handlers.erase(cid_key(cid)); -} - -void Server::Impl::erase_quic_session(Http3Session* h) -{ - auto lock = std::lock_guard(m_quicMutex); - std::erase_if(m_quic_handlers, [h](const auto& kv) { return kv.second.get() == h; }); -} - -// ------------------------------------------------------------------------------------------------- - -int Server::Impl::udp_on_read(Endpoint& ep) -{ - ngtcp2::sockaddr_union su; - std::array buf; - ngtcp2_pkt_info pi{}; - - iovec msg_iov{buf.data(), buf.size()}; - msghdr msg{}; - msg.msg_name = &su; - msg.msg_iov = &msg_iov; - msg.msg_iovlen = 1; - - uint8_t - msg_ctrl[CMSG_SPACE(sizeof(int)) + CMSG_SPACE(sizeof(in6_pktinfo)) + CMSG_SPACE(sizeof(int))]; - msg.msg_control = msg_ctrl; - - // - // Datagrams collected per session over the whole batch. Each session gets its accumulated - // batch posted to its strand once, below, after every datagram the socket had queued has been - // demultiplexed -- so one aggregate pass on the strand can pack a whole response into a - // single GSO sendmsg() instead of dribbling it out per datagram. - // - boost::container::small_flat_map, QuicBatch, 32> batches; - for (size_t pktcnt = 0; pktcnt < 32; ++pktcnt) - { - if (pktcnt) - logd("- - {} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ", pktcnt); - - msg.msg_namelen = sizeof(su); - msg.msg_controllen = sizeof(msg_ctrl); - - auto nread = recvmsg(ep.fd, &msg, 0); - if (nread == -1) - { - if (errno != EAGAIN && errno != EWOULDBLOCK && errno != ENOTCONN) - loge("recvmsg: {}", strerror(errno)); - break; // socket drained (or broken): fall through to the write pass - } - - if (nread < 22) - continue; - - auto local_addr = ngtcp2::msghdr_get_local_addr(&msg, su.storage.ss_family); - if (!local_addr) - { - logw("could not obtain local address from cmsg"); - continue; - } - ngtcp2::set_port(*local_addr, ep.addr); - ep.addr = *local_addr; - - // When UDP_GRO is enabled the kernel may coalesce several datagrams - // from the same 4-tuple into one recvmsg result. The GRO cmsg carries - // the uniform segment size so we can split the buffer back into - // individual QUIC datagrams before handing them to ngtcp2. - uint16_t gro_size = 0; - for (auto* cm = CMSG_FIRSTHDR(&msg); cm; cm = CMSG_NXTHDR(&msg, cm)) - { - if (cm->cmsg_level == SOL_UDP && cm->cmsg_type == UDP_GRO) - { - memcpy(&gro_size, CMSG_DATA(cm), sizeof(gro_size)); - break; - } - } - - auto remote = to_ngtcp2_address(su.storage, msg.msg_namelen); - if (!remote) - { - logw("unsupported remote address family"); - continue; - } - - auto all_data = std::span{buf.data(), static_cast(nread)}; - const size_t seg_size = gro_size > 0 ? gro_size : all_data.size(); - - for (size_t segcnt = 0; !all_data.empty(); ++segcnt) - { - if (segcnt) - logd("- {} - - - - - - - - - - - - - - -", segcnt); - - auto data = all_data.subspan(0, std::min(seg_size, all_data.size())); - all_data = all_data.subspan(data.size()); - - if (drop_packet(ep.drop_rate_rx)) - { - // logw("*** dropping received packet ({} bytes) ***", data.size()); - continue; - } - - ngtcp2_version_cid vc; - auto rv = ngtcp2_pkt_decode_version_cid(&vc, data.data(), data.size(), QUIC_SCIDLEN); - if (rv != 0) - { - if (rv != NGTCP2_ERR_VERSION_NEGOTIATION) - logw("could not decode version/cid: {}", ngtcp2_strerror(rv)); - continue; - } - - auto key = cid_key(vc.dcid, vc.dcidlen); - std::shared_ptr session; - { - auto lock = std::lock_guard(m_quicMutex); - if (auto it = m_quic_handlers.find(key); it != m_quic_handlers.end()) - session = it->second; - } - - if (!session) - { - ngtcp2_pkt_hd hd; - if (ngtcp2_accept(&hd, data.data(), data.size()) != 0) - continue; - - session = std::make_shared(*this, ep, *remote); - - // - // Publish the client-chosen DCID right away, so retransmitted Initials and - // follow-up packets -- in this batch or a later one -- find the session and queue - // up behind init() on its strand instead of spawning a duplicate session. init() - // itself, like everything that touches the connection, runs on the strand in - // process_quic_batch(). - // - auto lock = std::lock_guard(m_quicMutex); - m_quic_handlers.emplace(std::move(key), session); - auto& batch = batches[session]; - batch.is_new = true; - batch.hd = hd; - } - - batches[session].datagrams.push_back({pi, *remote, {data.begin(), data.end()}}); - } - } - - // - // One job per session: its whole share of the receive batch, processed -- and answered with - // a single write pass -- on its own strand. - // - for (auto& [session, batch] : batches) - { - asio::post(session->get_executor(), - [self = shared_from_this(), session, batch = std::move(batch)]() mutable - { self->process_quic_batch(session, std::move(batch)); }); - } - - return 0; -} - -// ------------------------------------------------------------------------------------------------- - -// -// Runs on the session's strand: consumes the datagrams udp_on_read() collected for this session, -// serialized against the session's timers, wake_write() flushes and request handlers. This is -// what the demux loop used to do inline back when everything shared one implicit thread. -// -void Server::Impl::process_quic_batch(const std::shared_ptr& session, - QuicBatch&& batch) -{ - size_t next = 0; - bool read_ok = false; - - if (batch.is_new) - { - const auto& first = batch.datagrams[next++]; - if (session->init(batch.hd.dcid, batch.hd.scid, batch.hd.version, first.pi, first.data) != 0) - { - // Matches the old inline behavior: a connection that failed at its Initial is - // forgotten; a retransmitted Initial starts over from scratch. - erase_quic_session(session.get()); - return; - } - read_ok = true; - - std::array scids; - auto num_scid = ngtcp2_conn_get_scid(session->conn(), nullptr); - if (num_scid <= scids.size()) - { - ngtcp2_conn_get_scid(session->conn(), scids.data()); - for (size_t i = 0; i < num_scid; ++i) - associate_quic_cid(scids[i], session.get()); - } - - // - // Register with the shared session set + spawn the do_session() task so the session - // participates in server-wide shutdown, exactly like the TCP-based sessions. Re-check - // m_destroyed under the lock: destroy() may have swept m_sessions between udp_on_read() - // accepting this connection and this job running, and a session registered after that - // sweep would never be destroyed. - // - { - auto lock = std::lock_guard(m_sessionMutex); - if (m_destroyed) - { - erase_quic_session(session.get()); - session->destroy(); - return; - } - m_sessions.emplace(session); - } - co_spawn(session->get_executor(), session->do_session({}), - [self = shared_from_this(), session](const std::exception_ptr& ex) - { - if (ex) - logw("[{}] {}", session->logPrefix(), what(ex)); - auto lock = std::lock_guard(self->m_sessionMutex); - self->m_sessions.erase(session); - }); - } - - for (; next < batch.datagrams.size(); ++next) - { - const auto& d = batch.datagrams[next]; - - // - // Handle closing / draining periods. During closing we resend the - // buffered CONNECTION_CLOSE so the peer can tear down cleanly. - // During draining (peer sent CONNECTION_CLOSE) we just drop the packet. - // In both cases the session stays in m_quic_handlers until the 3-PTO - // close timer fires and calls erase_quic_session(). - // - if (auto* conn = session->conn()) - { - if (ngtcp2_conn_in_closing_period(conn)) - { - session->resend_conn_close(); - continue; - } - if (ngtcp2_conn_in_draining_period(conn)) - continue; - } - - // A session that died earlier in this very batch has nothing left to feed. - if (session->closed()) - continue; - - if (session->on_read(d.pi, d.data, d.remote) == 0) - { - read_ok = true; - } - else if (session->closed()) - { - // - // Only erase immediately when not in closing/draining period. - // If we are, the 3-PTO close timer in handle_error() will call - // erase_quic_session() once the period expires. - // - auto* conn = session->conn(); - if (!conn || - (!ngtcp2_conn_in_closing_period(conn) && !ngtcp2_conn_in_draining_period(conn))) - erase_quic_session(session.get()); - } - } - - // - // One write pass for the whole batch, mirroring the old per-receive-pass flush. - // - if (!read_ok) - return; - if (session->flush_write() == 0 || !session->closed()) - return; - - auto* conn = session->conn(); - if (!conn || (!ngtcp2_conn_in_closing_period(conn) && !ngtcp2_conn_in_draining_period(conn))) - erase_quic_session(session.get()); -} - -// ------------------------------------------------------------------------------------------------- - -awaitable Server::Impl::udp_receive_loop() -{ - for (;;) - { - boost::system::error_code ec; - co_await m_udp_socket->async_wait(boost::asio::socket_base::wait_read, - redirect_error(use_awaitable, ec)); - if (ec) - { - if (ec == boost::asio::error::operation_aborted) - logi("UDP receive: {}", ec.message()); - else - logw("UDP receive: {}", ec.message()); - co_return; - } - - Endpoint ep{}; - ep.fd = m_udp_socket->native_handle(); - ep.drop_rate_rx = m_config.drop_rate_rx; - ep.drop_rate_tx = m_config.drop_rate_tx; - auto local = m_udp_socket->local_endpoint(); - auto data = local.data(); - std::memcpy(&ep.addr.su, data, local.size()); - ep.addr.len = local.size(); - - udp_on_read(ep); - } -} - -// ================================================================================================= - -} // namespace anyhttp::server diff --git a/test/test_formatter.cpp b/test/test_formatter.cpp index 743c909..82121c8 100644 --- a/test/test_formatter.cpp +++ b/test/test_formatter.cpp @@ -1,12 +1,12 @@ #include #include +#include // the nghttp2_nv formatter lives with the rest of the h2 glue #include #include #include #include #include -#include #include #include