From fce50a07c8f8785a75adcb7551edc57a11197f29 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Sun, 30 Aug 2026 18:25:15 +0000 Subject: [PATCH 1/8] h3: consolidate the HTTP/3 server and client onto shared code The QUIC/HTTP3 server and client were two near-identical files: the read path, the write path, flow control, the ngtcp2/nghttp3 callback bridges and the write loop existed twice, and every fix had to be applied twice, in parallel, to both. A QUIC connection is symmetric, and so is an HTTP/3 stream -- what differs between the roles is only which half of the exchange travels in which direction. So both now share one implementation, phrased in terms of the *incoming* and the *outgoing* message: * http3::Http3Stream (anyhttp/http3_stream.hpp, src/http3_stream.cpp) -- one request/response exchange: read path, write path, header parsing, lifecycle, plus the Http3Reader/Http3Writer adapter templates. Role hooks: on_pseudo_header(), on_headers_complete(), on_failed(), submit_response(). * http3::Http3Session (anyhttp/http3_session.hpp, src/http3_session.cpp) -- one QUIC connection: every callback bridge, write_pkt/write_streams, the timers, flow control, setup_http3(), setup_tls(). Role hooks: handle_error(), send_datagrams(), make_stream(), on_http3_ready(), on_new_cid()/on_remove_cid(). * anyhttp/http3_common.hpp -- make_nv(), log_headers(), the ngtcp2 log callback and the shared constants. What is left in server_impl_udp.cpp is what is genuinely server-side: the TLS server context, the UDP demux (many connections over one socket, a CID table, a strand and a dup()ed fd per session) and the closing/draining bookkeeping. In client_impl_udp.cpp: the TLS client context, the connect()ed socket and its receive loop, wait_ready() and async_submit(). 4902 lines became 3900, of which 2250 are now shared. Making the two sides one implementation also made them behave alike: the client now uses the server's ngtcp2_conn_write_aggregate_pkt2() write path (splitting the buffer by gso_size, without UDP_SEGMENT), both roles register nghttp3's acked_stream_data, both send STOP_SENDING when a reader is dropped early, both support read cancellation, and stream failure reporting goes through one Http3Stream::fail(). The one thing that stayed apart is how a body is handed to nghttp3, now spelled out as WriteMode: the server points nghttp3 straight into the caller's buffer and completes the write on acknowledgement, while the client stages through a bounded copy so that cancelling a write leaves the stream intact. Everything around it is shared. Three bugs found on the way, fixed here: * async_get_response() on a stream that had already failed waited forever, because only a handler installed *before* the failure was ever completed. The stream now remembers why it died and answers a late caller right away. * Write completions ran the application from inside a nghttp3 callback, i.e. from inside ngtcp2: a handler destroying the session there wrote a CONNECTION_CLOSE with a fresh timestamp and the enclosing write pass then continued with its own, stale one, tripping ngtcp2's "conn->log.last_ts <= ts" assertion. Completions are posted now, and write_pkt() stops packing when the connection went away underneath it. * Delivering a response can drop the last reference to its stream, so async_get_response() holds one of its own (found by ASAN). Co-Authored-By: Claude Opus 5 --- include/anyhttp/http3_common.hpp | 53 + include/anyhttp/http3_session.hpp | 249 ++++ include/anyhttp/http3_stream.hpp | 358 +++++ include/anyhttp/server_impl.hpp | 10 +- src/client_impl_udp.cpp | 2130 +++++------------------------ src/http3_common.cpp | 57 + src/http3_session.cpp | 879 ++++++++++++ src/http3_stream.cpp | 750 ++++++++++ src/server_impl_udp.cpp | 1973 +++----------------------- 9 files changed, 2863 insertions(+), 3596 deletions(-) create mode 100644 include/anyhttp/http3_common.hpp create mode 100644 include/anyhttp/http3_session.hpp create mode 100644 include/anyhttp/http3_stream.hpp create mode 100644 src/http3_common.cpp create mode 100644 src/http3_session.cpp create mode 100644 src/http3_stream.cpp diff --git a/include/anyhttp/http3_common.hpp b/include/anyhttp/http3_common.hpp new file mode 100644 index 0000000..e78816c --- /dev/null +++ b/include/anyhttp/http3_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/server_impl_udp.cpp) and the client (src/client_impl_udp.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/http3_session.hpp b/include/anyhttp/http3_session.hpp new file mode 100644 index 0000000..9067b8f --- /dev/null +++ b/include/anyhttp/http3_session.hpp @@ -0,0 +1,249 @@ +#pragma once + +#include "anyhttp/common.hpp" +#include "anyhttp/http3_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/http3_stream.hpp b/include/anyhttp/http3_stream.hpp new file mode 100644 index 0000000..9cf7984 --- /dev/null +++ b/include/anyhttp/http3_stream.hpp @@ -0,0 +1,358 @@ +#pragma once + +#include "anyhttp/common.hpp" +#include "anyhttp/http3_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/server_impl.hpp b/include/anyhttp/server_impl.hpp index 6f1e49e..6b308f8 100644 --- a/include/anyhttp/server_impl.hpp +++ b/include/anyhttp/server_impl.hpp @@ -52,7 +52,7 @@ class Response::Impl : public impl::Writer // ================================================================================================= struct Endpoint; -class Http3Session; +class Http3ServerSession; struct QuicBatch; class Server::Impl : public std::enable_shared_from_this @@ -84,7 +84,7 @@ class Server::Impl : public std::enable_shared_from_this asio::awaitable udp_receive_loop(); int udp_on_read(Endpoint& ep); - void process_quic_batch(const std::shared_ptr& session, QuicBatch&& batch); + 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, @@ -92,9 +92,9 @@ class Server::Impl : public std::enable_shared_from_this // 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). // - void associate_quic_cid(const ngtcp2_cid& cid, Http3Session* session); + void associate_quic_cid(const ngtcp2_cid& cid, Http3ServerSession* session); void dissociate_quic_cid(const ngtcp2_cid& cid); - void erase_quic_session(Http3Session* h); + void erase_quic_session(Http3ServerSession* h); private: Config m_config; @@ -107,7 +107,7 @@ class Server::Impl : public std::enable_shared_from_this std::set> m_sessions; std::mutex m_quicMutex; - std::unordered_map> m_quic_handlers; + std::unordered_map> m_quic_handlers; RequestHandler m_requestHandler; bool m_destroyed = false; diff --git a/src/client_impl_udp.cpp b/src/client_impl_udp.cpp index 0769a00..2d66553 100644 --- a/src/client_impl_udp.cpp +++ b/src/client_impl_udp.cpp @@ -1,12 +1,15 @@ // // 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. +// Almost all of it is shared with the server: `Http3ClientSession` is an `http3::Http3Session` +// (see anyhttp/http3_session.hpp) that knows how packets reach it and how it is torn down, and +// `Http3ClientStream` is an `http3::Http3Stream` (anyhttp/http3_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. @@ -14,9 +17,11 @@ #include "anyhttp/client_impl.hpp" #include "anyhttp/formatter.hpp" // IWYU pragma: keep +#include "anyhttp/http3_common.hpp" +#include "anyhttp/http3_session.hpp" +#include "anyhttp/http3_stream.hpp" #include "anyhttp/literals.hpp" #include "anyhttp/session_impl.hpp" -#include "anyhttp/tls.hpp" #include #include @@ -46,23 +51,22 @@ #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; + namespace anyhttp::client { @@ -121,191 +125,76 @@ TlsClientContext& tls_context() 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. +// Http3ClientStream / Http3ClientSession: the client's end of the shared HTTP/3 implementation. // ================================================================================================= 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 +class Http3ClientStream : public http3::Http3Stream { public: Http3ClientStream(Http3ClientSession& session, int64_t id); - ~Http3ClientStream(); - - int64_t id; - Http3ClientSession& session; - std::string log_prefix; + ~Http3ClientStream() override; // - // Request state, set once by async_submit() before headers are sent. + // 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. // - boost::urls::url url; + 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; - // - // Response state (populated by nghttp3 header callbacks). - // - unsigned int status_code = 0; - Fields response_fields; - std::optional content_length; + /// The request headers went out with the stream itself, see Http3ClientSession::async_submit(). + void submit_response(unsigned int, const Fields&) override {} - // - // 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; + /// 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); - // - // 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; + void async_get_response(client::Request::GetResponseHandler&& handler); + void deliver_response(); + void deliver_failure(); - // - // 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() + bool response_delivered = false; + client::Request::GetResponseHandler response_handler; // - // Lifecycle. + // 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. // - 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); + boost::system::error_code failure_ec; +}; -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 +// ------------------------------------------------------------------------------------------------- +// +// 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: - // async_get_response() - void async_get_response(client::Request::GetResponseHandler&& handler); - void deliver_response(); + using http3::Http3Writer::Http3Writer; - // 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(); + 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)); + } }; -// ================================================================================================= -// Http3ClientSession: one QUIC connection, one anyhttp Session::Impl. -// ================================================================================================= +// ------------------------------------------------------------------------------------------------- -class Http3ClientSession : public Session::Impl +class Http3ClientSession : public http3::Http3Session { public: explicit Http3ClientSession(asio::any_io_executor executor); @@ -314,7 +203,6 @@ class Http3ClientSession : public Session::Impl // // 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; @@ -336,1714 +224,418 @@ class Http3ClientSession : public Session::Impl { 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); + bool ready() const noexcept { return h3() != nullptr; } - // - // 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*); +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 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. +// Http3ClientStream implementation // ================================================================================================= -class Http3ClientWriter : public client::Request::Impl +Http3ClientStream::Http3ClientStream(Http3ClientSession& s, int64_t stream_id) + : http3::Http3Stream(s, stream_id, http3::WriteMode::Staged) { -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(); - } +Http3ClientStream::~Http3ClientStream() +{ + if (!response_delivered && response_handler) + swap_and_invoke(response_handler, errc::make_error_code(errc::connection_reset), + client::Response{nullptr}); +} - void content_length(std::optional /*len*/) override +void Http3ClientStream::on_pseudo_header(std::string_view name, std::string_view value) +{ + if (name == ":status") { - // Request headers (including any content-length the user set beforehand) are already - // submitted synchronously in Http3ClientSession::async_submit(); nothing to do here. + unsigned int status = 0; + if (std::from_chars(value.begin(), value.end(), status).ec == std::errc{}) + status_code = status; } +} - 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 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) +{ // - // 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. + // 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. // - 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)); - } + failure_ec = ec ? ec : boost::beast::http::error::end_of_stream; + deliver_failure(); +} - void detach() override { stream = nullptr; } +void Http3ClientStream::deliver_failure() +{ + if (headers_received || response_delivered || !response_handler) + return; - Http3ClientStream* stream; -}; + response_delivered = true; + swap_and_invoke(response_handler, failure_ec, client::Response{nullptr}); +} -class Http3ClientReader : public client::Response::Impl +bool Http3ClientStream::submit_request(const boost::urls::url& request_url, const Fields& headers) { -public: - explicit Http3ClientReader(Http3ClientStream& s) : stream(&s) { s.reader = this; } - ~Http3ClientReader() override - { - if (stream) - { - stream->reader = nullptr; - stream->delete_reader(); - } - } + url = request_url; - asio::any_io_executor get_executor() const noexcept override - { - assert(stream); - return stream->get_executor(); - } + // + // 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)); - std::optional content_length() const noexcept override + for (auto&& item : headers) { - return stream ? stream->content_length : std::nullopt; + 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())); } - unsigned int status_code() const noexcept override { return stream ? stream->status_code : 0; } + return submit_headers(nva, true /* request */); +} - boost::url_view url() const override +void Http3ClientStream::async_get_response(client::Request::GetResponseHandler&& handler) +{ + if (response_delivered) { - assert(stream); - return stream->url; + 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; } - void async_read_some(asio::mutable_buffer buffer, ReadSomeHandler&& handler) override + auto cs = handler.get_cancellation_slot(); + if (cs.is_connected()) { - 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 ct) { - cs.assign([this](asio::cancellation_type_t) + logd("[{}] async_get_response: cancelled ({})", log_prefix, ct); + if (response_handler) { - if (stream && stream->read_handler) + asio::post(get_executor(), [handler = std::move(response_handler)]() mutable { - 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(); + std::move(handler)(errc::make_error_code(errc::operation_canceled), + client::Response{nullptr}); + }); + } + }); } - void detach() override { stream = 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(); - Http3ClientStream* stream; -}; + response_handler = std::move(handler); + deliver_response(); -// ================================================================================================= -// Http3ClientStream implementation -// ================================================================================================= + // + // 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(); +} -Http3ClientStream::Http3ClientStream(Http3ClientSession& s, int64_t stream_id) - : id(stream_id), session(s) +void Http3ClientStream::deliver_response() { - log_prefix = std::format("{}.{}", session.logPrefix(), id); - logd("[{}] stream created", log_prefix); + 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)); } -Http3ClientStream::~Http3ClientStream() +// ================================================================================================= +// Http3ClientSession implementation +// ================================================================================================= + +Http3ClientSession::Http3ClientSession(asio::any_io_executor executor) + : http3::Http3Session(executor), socket_(get_executor()), ready_signal_(get_executor()) { - 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); + // 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"); } -asio::any_io_executor Http3ClientStream::get_executor() const noexcept +Http3ClientSession::~Http3ClientSession() { - return session.get_executor(); + // + // 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"); } // ------------------------------------------------------------------------------------------------- -void Http3ClientStream::on_data_chunk(const uint8_t* data, size_t len) +int Http3ClientSession::init(asio::ip::udp::endpoint remote) { - 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(); + 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); - if (incoming.size() > 0) + auto local = socket_.local_endpoint(ec); + if (ec) { - 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 = {}; + loge("Http3ClientSession::init: local_endpoint: {}", ec.message()); + return -1; } -} -void Http3ClientStream::on_eof() -{ - eof_received = true; - call_read_handler(); -} + log_prefix_ = std::format("h3c:{}", ngtcp2::util::straddr(remote.data(), remote.size())); -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) + ngtcp2_cid scid{}; + scid.datalen = 17; + if (RAND_bytes(scid.data, static_cast(scid.datalen)) != 1) { - timer_.cancel(); - return; + loge("[{}] init: RAND_bytes for SCID failed", log_prefix_); + return -1; } - - 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_cid dcid{}; + dcid.datalen = http3::QUIC_SCIDLEN; + if (RAND_bytes(dcid.data, static_cast(dcid.datalen)) != 1) { - // - // 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); + loge("[{}] init: RAND_bytes for DCID failed", log_prefix_); + return -1; } - if (auto rv = write_streams(); rv != 0) - return rv; - update_timer(); - return 0; -} - -// ------------------------------------------------------------------------------------------------- -int Http3ClientSession::handle_error(int /*rv*/) -{ - close(); - 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 callback implementations -// ------------------------------------------------------------------------------------------------- + ngtcp2_settings settings; + ngtcp2_transport_params params; + fill_settings(settings, params, 30s); -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; -} + ngtcp2_path path{ + {local.data(), static_cast(local.size())}, + {remote.data(), static_cast(remote.size())}, + nullptr, + }; -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) + if (auto rv = ngtcp2_conn_client_new(&conn_, &dcid, &scid, &path, NGTCP2_PROTO_VER_V1, + &callbacks, &settings, ¶ms, nullptr, this); + rv != 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; + loge("[{}] ngtcp2_conn_client_new: {}", log_prefix_, ngtcp2_strerror(rv)); + return -1; } - ngtcp2_conn_extend_max_stream_offset(self->conn_, stream_id, static_cast(nread)); - ngtcp2_conn_extend_max_offset(self->conn_, static_cast(nread)); + 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; } -int Http3ClientSession::cb_acked_stream_data_offset(ngtcp2_conn*, int64_t stream_id, - uint64_t /*offset*/, uint64_t datalen, - void* user, void*) +// ------------------------------------------------------------------------------------------------- + +awaitable Http3ClientSession::do_session(Buffer&&) { - 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) + if (flush_write() != 0) { - loge("[{}] nghttp3_conn_add_ack_offset: {}", self->log_prefix_, nghttp3_strerror(rv)); - return NGTCP2_ERR_CALLBACK_FAILURE; + signal_ready(); + co_return; } - 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_) + std::array buf; + for (;;) { - if (auto rv = nghttp3_conn_close_stream(self->h3_, stream_id, app_error_code); rv != 0) + boost::system::error_code ec; + size_t n = + co_await socket_.async_receive(asio::buffer(buf), redirect_error(use_awaitable, ec)); + if (ec) { - 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; + if (ec != asio::error::operation_aborted) + logw("[{}] receive: {}", log_prefix_, ec.message()); + break; } - } - 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; -} + if (on_read({buf.data(), n}) != 0) + break; // handle_error() already tore things down. -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; + // + // 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; } - 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; + // + // 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; } -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; -} +void Http3ClientSession::destroy() noexcept { close(); } -int Http3ClientSession::cb_recv_rx_key(ngtcp2_conn*, ngtcp2_encryption_level level, void* user) +void Http3ClientSession::close() { - 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; -} - -// ------------------------------------------------------------------------------------------------- + if (std::exchange(closed_, true)) + return; -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; - } + // + // 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)); - 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; - } + // + // 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; - 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) + if (conn_ && !silent && !ngtcp2_conn_in_closing_period(conn_) && + !ngtcp2_conn_in_draining_period(conn_)) { - loge("[{}] nghttp3_conn_bind_qpack_streams: {}", log_prefix_, nghttp3_strerror(rv)); - return -1; + std::array closebuf; + ngtcp2_path_storage ps; + if (auto packet = write_connection_close(closebuf, ps); !packet.empty()) + send_datagrams(ps.path, packet, packet.size()); } - logi("[{}] HTTP/3 ready (ctrl={} qpack_enc={} qpack_dec={})", log_prefix_, ctrl_stream_id, - qpack_enc_stream_id, qpack_dec_stream_id); + boost::system::error_code ec; + socket_.cancel(ec); + timer_.cancel(); 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*) +void Http3ClientSession::signal_ready() { - 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; + ready_signal_.expires_at(asio::steady_timer::time_point::min()); } -int Http3ClientSession::h3_cb_recv_data(nghttp3_conn*, int64_t stream_id, const uint8_t* data, - size_t datalen, void* user, void*) +int Http3ClientSession::handle_error(int /*rv*/) { - // - // 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; + close(); + return -1; } -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*) +std::shared_ptr Http3ClientSession::make_stream(int64_t id) { - // 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; + return std::make_shared(*this, id); } -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*) +// +// 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) { - 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 + while (!data.empty()) { - 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") + 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) { - size_t len = 0; - if (std::from_chars(value_view.begin(), value_view.end(), len).ec == std::errc{}) - s->content_length = len; + logw("[{}] send: {}", log_prefix_, ec.message()); + return 0; // best-effort; ngtcp2 will retransmit } - 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()); + data = data.subspan(len); } 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*) +int Http3ClientSession::on_read(std::span data) { - auto self = static_cast(user); - ngtcp2_conn_shutdown_stream_read(self->conn_, 0, stream_id, app_error_code); - return 0; -} + ngtcp2_pkt_info pi{}; + auto* path = ngtcp2_conn_get_path(conn_); + if (http3::Http3Session::on_read(*path, pi, data) != 0) + return -1; -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; + // + // 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(); } // ------------------------------------------------------------------------------------------------- -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_) + if (closed() || !h3()) { loge("[{}] async_submit: session not ready", log_prefix_); std::move(handler)(errc::make_error_code(errc::operation_canceled), client::Request{nullptr}); @@ -2058,47 +650,15 @@ void Http3ClientSession::async_submit(SubmitHandler&& handler, boost::urls::url 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) + auto* stream = static_cast(create_stream(stream_id)); + if (!stream->submit_request(url, headers)) { - 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), @@ -2123,7 +683,6 @@ awaitable> async_connect_http3(asio::any_io_execu std::shared_ptr impl = session; -#if 1 co_spawn(executor, impl->do_session(Buffer{}), [impl](const std::exception_ptr& ex) mutable { if (ex) @@ -2132,7 +691,6 @@ awaitable> async_connect_http3(asio::any_io_execu logi("client run: done"); impl.reset(); }); -#endif // // Note: wait_ready() uses a sentinel steady_timer as a one-shot gate (see the comment on diff --git a/src/http3_common.cpp b/src/http3_common.cpp new file mode 100644 index 0000000..bc99e33 --- /dev/null +++ b/src/http3_common.cpp @@ -0,0 +1,57 @@ +// +// Small helpers shared by the HTTP/3 server and client, see anyhttp/http3_common.hpp. +// +#include "anyhttp/http3_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/http3_session.cpp b/src/http3_session.cpp new file mode 100644 index 0000000..3a69caf --- /dev/null +++ b/src/http3_session.cpp @@ -0,0 +1,879 @@ +// +// Http3Session: one QUIC connection carrying HTTP/3, shared by the server and the client. +// See anyhttp/http3_session.hpp; the role-specific ends live in server_impl_udp.cpp and +// client_impl_udp.cpp. +// +#include "anyhttp/http3_session.hpp" +#include "anyhttp/http3_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()); + 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 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; + } + if (auto s = find_stream(stream_id)) + s->on_write_offered(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 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; + } + if (auto s = find_stream(stream_id)) + s->on_write_offered(static_cast(ndatalen)); + } + + return nwrite; + } +} + +int Http3Session::write_streams() +{ + if (!conn_) + return 0; + 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; + + 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) + 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::on_read(const ngtcp2_path& path, const ngtcp2_pkt_info& pi, + std::span data) +{ + logd("[{}] on_read: {} bytes", log_prefix_, 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) + 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: 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) + { + 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_); + + 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) + { + loge("[{}] ngtcp2_crypto_ossl_configure_{}_session failed", log_prefix_, + is_server ? "server" : "client"); + 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_); + 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) + { + 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); + } + else 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); + + 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/http3_stream.cpp b/src/http3_stream.cpp new file mode 100644 index 0000000..697d3f5 --- /dev/null +++ b/src/http3_stream.cpp @@ -0,0 +1,750 @@ +// +// Http3Stream: one HTTP/3 request/response exchange, shared by the server and the client. +// See anyhttp/http3_stream.hpp for the model; the role-specific ends of it live in +// server_impl_udp.cpp and client_impl_udp.cpp. +// +#include "anyhttp/http3_stream.hpp" +#include "anyhttp/formatter.hpp" // IWYU pragma: keep +#include "anyhttp/http3_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_udp.cpp b/src/server_impl_udp.cpp index 5df5a26..01ea944 100644 --- a/src/server_impl_udp.cpp +++ b/src/server_impl_udp.cpp @@ -1,31 +1,39 @@ // // 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. +// Nearly everything that makes a QUIC connection work is shared with the client and lives in +// anyhttp/http3_session.hpp and anyhttp/http3_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. // -// 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 -- +// 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. +// +// 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 +// 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). +// migration, ECN. // #include "anyhttp/client_impl.hpp" #include "anyhttp/formatter.hpp" // IWYU pragma: keep +#include "anyhttp/http3_common.hpp" +#include "anyhttp/http3_session.hpp" +#include "anyhttp/http3_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 "anyhttp/tls.hpp" #include #include @@ -62,16 +70,12 @@ #include #include -#include -#include #include -#include #include #include #include #include #include -#include #include #include "ngtcp2/shared.h" @@ -81,6 +85,10 @@ 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 { @@ -103,8 +111,6 @@ struct Endpoint 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. @@ -294,190 +300,46 @@ int send_udp_gso(const Endpoint& ep, const sockaddr* sa, socklen_t salen, } } -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. +// Http3ServerStream / Http3ServerSession: the server's end of the shared HTTP/3 implementation. // ================================================================================================= -class Http3Session; -class Http3Stream; +class Http3ServerSession; -class Http3Stream : public std::enable_shared_from_this +class Http3ServerStream : public http3::Http3Stream { 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 + Http3ServerStream(Http3ServerSession& session, int64_t id); // - // Lifecycle. + // 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. // - 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(); + 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; }; -// ================================================================================================= -// Http3Session: one QUIC connection, one anyhttp Session::Impl. -// ================================================================================================= +// ------------------------------------------------------------------------------------------------- -class Http3Session : public Session::Impl +class Http3ServerSession : public http3::Http3Session { public: - Http3Session(Server::Impl& server, Endpoint ep, ngtcp2::Address remote); - ~Http3Session() override; + Http3ServerSession(Server::Impl& 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 h3_cb_end_headers() -- runs through + // 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. // - 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; @@ -489,143 +351,22 @@ class Http3Session : public Session::Impl 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(); + /// Called from process_quic_batch() when a packet arrives during the closing period. + void resend_conn_close(); 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*); +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: - 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_ @@ -635,338 +376,70 @@ class Http3Session : public Session::Impl 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. +// Http3ServerStream implementation // ================================================================================================= -template -class Http3Reader : public Interface +Http3ServerStream::Http3ServerStream(Http3ServerSession& s, int64_t stream_id) + : http3::Http3Stream(s, stream_id, http3::WriteMode::ZeroCopy) { -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 +void Http3ServerStream::on_pseudo_header(std::string_view name, std::string_view value) { -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 (name == ":method") + method = value; + else if (name == ":path") { - if (!stream || stream->closed) + if (auto parsed = boost::urls::parse_relative_ref(value); parsed.has_value()) { - std::move(handler)(errc::make_error_code(errc::connection_reset)); - return; + url.set_path(parsed->path()); + if (parsed->has_query()) + url.set_query(parsed->query()); + if (parsed->has_fragment()) + url.set_fragment(parsed->fragment()); } - 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 = {}; } + else if (name == ":scheme") + url.set_scheme(value); + else if (name == ":authority") + url.set_encoded_authority(value); } -void Http3Stream::on_eof() -{ - eof_received = true; - call_read_handler(); -} - -void Http3Stream::call_read_handler() +void Http3ServerStream::on_headers_complete() { - // - // 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; + logd("[{}] {} {}", log_prefix, method, url.buffer()); + log_headers(log_prefix, std::exchange(received_headers, {})); // - // 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. + // Build the user-facing Request/Response and dispatch through the shared handler. // - auto self = shared_from_this(); - auto session_guard = session.shared_from_this(); + server::Request request(std::make_unique>(*this)); + server::Response response(std::make_unique>(*this)); - call_read_handler_active = true; - size_t consumed = 0; - while (read_handler) + auto& sv = static_cast(session).server(); + if (auto& handler = sv.requestHandler()) + co_spawn(get_executor(), handler(std::move(request), std::move(response)), detached); + else { - 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; + loge("[{}] no request handler set", log_prefix); + co_spawn(get_executor(), not_found(std::move(response)), detached); } - 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 +void Http3ServerStream::submit_response(unsigned int status, const Fields& user_fields) { -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 + assert(!headers_submitted); -void Http3Stream::submit_response() -{ - assert(!response_submitted); + response_status = status; + response_fields = user_fields; auto status_str = std::to_string(response_status); std::vector nva; @@ -992,394 +465,68 @@ void Http3Stream::submit_response() 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); + + if (submit_headers(nva, false /* response */)) + session.wake_write(); } -void Http3Stream::start_write(WriteHandler&& handler, asio::const_buffer buffer) +// ================================================================================================= +// Http3ServerSession implementation +// ================================================================================================= + +Http3ServerSession::Http3ServerSession(Server::Impl& 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()) { - auto n = asio::buffer_size(buffer); - const bool is_eof = (n == 0); - logd("[{}] start_write: n={} is_eof={}", log_prefix, n, is_eof); + log_prefix_ = std::format("h3:{}", ngtcp2::util::straddr(&remote_.su.sa, remote_.len)); // - // 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. + // 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 (is_eof && eof_submitted) + if (int fd = ::dup(ep_.fd); fd >= 0) { - // - // 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; + 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() +{ // - // 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. + // Tear the streams down while this object is still whole: destroying a stream fires pending + // handlers, which reach back into the session. // - 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{}); + done_signal_.cancel(); + clear_streams(); + if (owns_fd_) + ::close(ep_.fd); + mlogi("session destroyed"); } // ------------------------------------------------------------------------------------------------- -void Http3Stream::delete_reader() +void Http3ServerSession::async_submit(SubmitHandler&& handler, boost::urls::url, const Fields&) { - 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. + // 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 Http3Session::do_session(Buffer&&) +awaitable Http3ServerSession::do_session(Buffer&&) { boost::system::error_code ec; co_await done_signal_.async_wait(redirect_error(use_awaitable, ec)); @@ -1388,7 +535,7 @@ awaitable Http3Session::do_session(Buffer&&) co_return; } -void Http3Session::destroy() noexcept +void Http3ServerSession::destroy() noexcept { // // Called from wherever the Server is being torn down -- under multithreading that is some @@ -1397,12 +544,11 @@ void Http3Session::destroy() noexcept // this session's executor first; with use_strand off and the caller already inside the // io_context, dispatch() degenerates to an inline call. // - asio::dispatch(executor_, [self = shared_from_this()] { - static_cast(*self).do_destroy(); - }); + asio::dispatch(get_executor(), [self = shared_from_this()] + { static_cast(*self).do_destroy(); }); } -void Http3Session::do_destroy() noexcept +void Http3ServerSession::do_destroy() noexcept { // // Tear the streams down here, on the session's executor, rather than leaving it to the @@ -1413,7 +559,7 @@ void Http3Session::do_destroy() noexcept // with them; a handler that resumes afterwards finds its Reader/Writer detached and fails // cleanly, exactly as in the single-threaded case. // - streams_.clear(); + clear_streams(); if (std::exchange(closed_, true)) { @@ -1432,22 +578,15 @@ void Http3Session::do_destroy() noexcept { 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)}); + 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 Http3Session::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()); @@ -1455,57 +594,28 @@ void Http3Session::signal_done() // ------------------------------------------------------------------------------------------------- -std::shared_ptr Http3Session::find_stream(int64_t id) +std::shared_ptr Http3ServerSession::make_stream(int64_t id) { - auto it = streams_.find(id); - return it == streams_.end() ? nullptr : it->second; + return std::make_shared(*this, id); } -Http3Stream* Http3Session::create_stream(int64_t id) +void Http3ServerSession::on_new_cid(const ngtcp2_cid& cid) { - auto [it, inserted] = streams_.emplace(id, std::make_shared(*this, id)); - return it->second.get(); + server_.associate_quic_cid(cid, this); } -void Http3Session::erase_stream(int64_t id) { streams_.erase(id); } +void Http3ServerSession::on_remove_cid(const ngtcp2_cid& cid) { server_.dissociate_quic_cid(cid); } -void Http3Session::wake_write() +int Http3ServerSession::send_datagrams(const ngtcp2_path& path, std::span data, + size_t gso_size) { - // 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(); - }); + return send_udp_gso(ep_, path.remote.addr, path.remote.addrlen, data, gso_size, no_gso_); } // ------------------------------------------------------------------------------------------------- -int Http3Session::init(const ngtcp2_cid& dcid, const ngtcp2_cid& scid, uint32_t version, - const ngtcp2_pkt_info& pi, std::span data) +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) @@ -1515,51 +625,12 @@ int Http3Session::init(const ngtcp2_cid& dcid, const ngtcp2_cid& scid, uint32_t } ngtcp2_callbacks callbacks{}; + fill_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(); + fill_settings(settings, params, server_.config().idle_timeout); params.original_dcid = dcid; params.original_dcid_present = 1; @@ -1577,33 +648,8 @@ int Http3Session::init(const ngtcp2_cid& dcid, const ngtcp2_cid& scid, uint32_t 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); + if (setup_tls(tls_context().ctx, true /* server */) != 0) 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); @@ -1613,266 +659,21 @@ int Http3Session::init(const ngtcp2_cid& dcid, const ngtcp2_cid& scid, uint32_t // ------------------------------------------------------------------------------------------------- -int Http3Session::on_read(const ngtcp2_pkt_info& pi, std::span data, - const ngtcp2::Address& remote) +int Http3ServerSession::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; + return http3::Http3Session::on_read(path, pi, data); } // ------------------------------------------------------------------------------------------------- -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*/) +int Http3ServerSession::handle_error(int /*rv*/) { if (closed_) return -1; @@ -1880,8 +681,8 @@ int Http3Session::handle_error(int /*rv*/) // // 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 + // no closing period either, so none of the cleanup in Server::Impl::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 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(). @@ -1902,18 +703,12 @@ int Http3Session::handle_error(int /*rv*/) { 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) + auto packet = write_connection_close(conn_closebuf_, ps); + if (!packet.empty()) { - conn_closebuf_.resize(static_cast(nwrite)); + conn_closebuf_.resize(packet.size()); logi("[{}] sending CONNECTION_CLOSE", log_prefix_); - send_udp(ep_, ps.path.remote.addr, ps.path.remote.addrlen, - {conn_closebuf_.data(), conn_closebuf_.size()}); + send_udp(ep_, ps.path.remote.addr, ps.path.remote.addrlen, conn_closebuf_); } else { @@ -1929,25 +724,25 @@ int Http3Session::handle_error(int /*rv*/) return -1; } -void Http3Session::schedule_close_timer() +void Http3ServerSession::schedule_close_timer() { auto delay = conn_ ? std::chrono::nanoseconds{ngtcp2_conn_get_pto(conn_) * 3} - : std::chrono::milliseconds{100}; + : 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()); + auto session = std::static_pointer_cast(self.lock()); if (!session) return; - logd("[{}] closing/draining period over", session->log_prefix_); + logd("[{}] closing/draining period over", session->logPrefix()); session->server_.erase_quic_session(session.get()); session->signal_done(); }); } -void Http3Session::resend_conn_close() +void Http3ServerSession::resend_conn_close() { if (conn_closebuf_.empty()) return; @@ -1955,439 +750,7 @@ void Http3Session::resend_conn_close() 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; + send_udp(ep_, path->remote.addr, path->remote.addrlen, conn_closebuf_); } // ================================================================================================= @@ -2427,11 +790,11 @@ struct QuicBatch boost::container::small_vector datagrams; }; -void Server::Impl::associate_quic_cid(const ngtcp2_cid& cid, Http3Session* h) +void Server::Impl::associate_quic_cid(const ngtcp2_cid& cid, Http3ServerSession* h) { auto lock = std::lock_guard(m_quicMutex); m_quic_handlers.emplace(cid_key(cid), - std::static_pointer_cast(h->shared_from_this())); + std::static_pointer_cast(h->shared_from_this())); } void Server::Impl::dissociate_quic_cid(const ngtcp2_cid& cid) @@ -2440,7 +803,7 @@ void Server::Impl::dissociate_quic_cid(const ngtcp2_cid& cid) m_quic_handlers.erase(cid_key(cid)); } -void Server::Impl::erase_quic_session(Http3Session* h) +void Server::Impl::erase_quic_session(Http3ServerSession* h) { auto lock = std::lock_guard(m_quicMutex); std::erase_if(m_quic_handlers, [h](const auto& kv) { return kv.second.get() == h; }); @@ -2470,7 +833,7 @@ int Server::Impl::udp_on_read(Endpoint& ep) // 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; + boost::container::small_flat_map, QuicBatch, 32> batches; for (size_t pktcnt = 0; pktcnt < 32; ++pktcnt) { if (pktcnt) @@ -2547,7 +910,7 @@ int Server::Impl::udp_on_read(Endpoint& ep) } auto key = cid_key(vc.dcid, vc.dcidlen); - std::shared_ptr session; + std::shared_ptr session; { auto lock = std::lock_guard(m_quicMutex); if (auto it = m_quic_handlers.find(key); it != m_quic_handlers.end()) @@ -2560,7 +923,7 @@ int Server::Impl::udp_on_read(Endpoint& ep) if (ngtcp2_accept(&hd, data.data(), data.size()) != 0) continue; - session = std::make_shared(*this, ep, *remote); + session = std::make_shared(*this, ep, *remote); // // Publish the client-chosen DCID right away, so retransmitted Initials and @@ -2588,7 +951,7 @@ int Server::Impl::udp_on_read(Endpoint& ep) { asio::post(session->get_executor(), [self = shared_from_this(), session, batch = std::move(batch)]() mutable - { self->process_quic_batch(session, std::move(batch)); }); + { self->process_quic_batch(session, std::move(batch)); }); } return 0; @@ -2601,7 +964,7 @@ int Server::Impl::udp_on_read(Endpoint& ep) // 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, +void Server::Impl::process_quic_batch(const std::shared_ptr& session, QuicBatch&& batch) { size_t next = 0; From 2cb3c7fd737ff299a593d72c1bb828a5a540105a Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Sun, 30 Aug 2026 18:28:36 +0000 Subject: [PATCH 2/8] refactor: rename TCP listening methods for clarity and consistency --- include/anyhttp/server_impl.hpp | 4 ++-- src/server_impl.cpp | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/include/anyhttp/server_impl.hpp b/include/anyhttp/server_impl.hpp index 6b308f8..604193e 100644 --- a/include/anyhttp/server_impl.hpp +++ b/include/anyhttp/server_impl.hpp @@ -70,8 +70,8 @@ class Server::Impl : public std::enable_shared_from_this const Config& config() const { return m_config; } boost::asio::any_io_executor get_executor() const noexcept { return m_executor; } - 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 { diff --git a/src/server_impl.cpp b/src/server_impl.cpp index 1e1172e..07214a2 100644 --- a/src/server_impl.cpp +++ b/src/server_impl.cpp @@ -74,7 +74,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)); @@ -152,17 +152,17 @@ 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()) + ip::tcp::endpoint ep(address, config().port); + if (ep.protocol() == ip::tcp::v6()) std::ignore = 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); + ep = acceptor.local_endpoint(); + logi("Server: TCP listening on {}", ep); } // ------------------------------------------------------------------------------------------------- @@ -264,7 +264,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); @@ -388,7 +388,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 +397,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 +443,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 +459,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 +473,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); } // ================================================================================================= From 6244dcb019a693bac404199f02781e4a760a6de4 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Mon, 31 Aug 2026 12:58:32 +0000 Subject: [PATCH 3/8] refactor: remove unused include and simplify UDP socket initialization --- src/server_impl.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/server_impl.cpp b/src/server_impl.cpp index 07214a2..f28fab3 100644 --- a/src/server_impl.cpp +++ b/src/server_impl.cpp @@ -1,5 +1,4 @@ #include "anyhttp/server_impl.hpp" -#include "anyhttp/literals.hpp" #include "anyhttp/any_async_stream.hpp" #include "anyhttp/beast_session.hpp" @@ -183,7 +182,7 @@ void Server::Impl::listen_udp() // 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_udp_socket.emplace(config().use_strand ? asio::make_strand(m_executor) : m_executor); m_udp_socket->open(is_v6 ? ip::udp::v6() : ip::udp::v4()); From 779bf770c8ba9411cd5f4266c5b072237d53a72b Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Mon, 31 Aug 2026 17:50:14 +0000 Subject: [PATCH 4/8] refactor: separate h1/h2/h3 implementations behind protocol backends Each protocol implementation now lives in files prefixed h1_ (beast), h2_ (nghttp2) and h3_ (ngtcp2/nghttp3), and the protocol libraries no longer leak out of them: is included only by h2_* files, / only by h3_* ones. Four places needed more than a rename: * formatter.hpp pulled in for the nghttp2_nv formatter, which moves to h2_common.hpp (absorbing nghttp2_common.hpp). * server_impl.cpp / client_impl.cpp instantiated the h1 and h2 session templates themselves. They now go through make_server_session() / make_client_session(), declared in h1_backend.hpp / h2_backend.hpp in terms of asio types only and defined in the backends' own translation units, which are the only ones instantiating those templates. * the server's ALPN callback called nghttp2_select_next_protocol(), replaced by a preference-ordered selection over the ALPN wire format. * Server::Impl held the whole QUIC demux -- UDP socket, receive loop, connection-ID table, an ngtcp2_cid forward declaration. That is now Http3ServerImpl in h3_server.cpp, behind the abstract Http3Server and make_http3_server() in h3_backend.hpp. What is left in Server::Impl is m_http3 plus add_session()/remove_session(), the session registry all three protocols share. Also drops the stray NGHTTP2 link from the server executable. Co-Authored-By: Claude Opus 5 --- ...ion_details.hpp => h2_session_details.hpp} | 8 +- include/anyhttp/formatter.hpp | 56 ---- include/anyhttp/h1_backend.hpp | 46 +++ .../{beast_session.hpp => h1_session.hpp} | 0 include/anyhttp/h2_backend.hpp | 46 +++ include/anyhttp/h2_common.hpp | 95 ++++++ .../{detect_http2.hpp => h2_detect.hpp} | 0 .../{nghttp2_session.hpp => h2_session.hpp} | 2 +- .../{nghttp2_stream.hpp => h2_stream.hpp} | 0 include/anyhttp/h3_backend.hpp | 65 ++++ .../{http3_common.hpp => h3_common.hpp} | 2 +- .../{http3_session.hpp => h3_session.hpp} | 2 +- .../{http3_stream.hpp => h3_stream.hpp} | 2 +- include/anyhttp/nghttp2_common.hpp | 28 -- include/anyhttp/server_impl.hpp | 34 +- src/CMakeLists.txt | 4 +- src/client_impl.cpp | 20 +- src/{beast_session.cpp => h1_session.cpp} | 43 ++- src/{nghttp2_session.cpp => h2_session.cpp} | 46 ++- src/{nghttp2_stream.cpp => h2_stream.cpp} | 6 +- src/{client_impl_udp.cpp => h3_client.cpp} | 11 +- src/{http3_common.cpp => h3_common.cpp} | 4 +- src/{server_impl_udp.cpp => h3_server.cpp} | 293 +++++++++++++----- src/{http3_session.cpp => h3_session.cpp} | 8 +- src/{http3_stream.cpp => h3_stream.cpp} | 8 +- src/server_impl.cpp | 172 +++++----- test/test_formatter.cpp | 2 +- 27 files changed, 667 insertions(+), 336 deletions(-) rename include/anyhttp/detail/{nghttp2_session_details.hpp => h2_session_details.hpp} (97%) create mode 100644 include/anyhttp/h1_backend.hpp rename include/anyhttp/{beast_session.hpp => h1_session.hpp} (100%) create mode 100644 include/anyhttp/h2_backend.hpp create mode 100644 include/anyhttp/h2_common.hpp rename include/anyhttp/{detect_http2.hpp => h2_detect.hpp} (100%) rename include/anyhttp/{nghttp2_session.hpp => h2_session.hpp} (99%) rename include/anyhttp/{nghttp2_stream.hpp => h2_stream.hpp} (100%) create mode 100644 include/anyhttp/h3_backend.hpp rename include/anyhttp/{http3_common.hpp => h3_common.hpp} (95%) rename include/anyhttp/{http3_session.hpp => h3_session.hpp} (99%) rename include/anyhttp/{http3_stream.hpp => h3_stream.hpp} (99%) delete mode 100644 include/anyhttp/nghttp2_common.hpp rename src/{beast_session.cpp => h1_session.cpp} (93%) rename src/{nghttp2_session.cpp => h2_session.cpp} (90%) rename src/{nghttp2_stream.cpp => h2_stream.cpp} (99%) rename src/{client_impl_udp.cpp => h3_client.cpp} (98%) rename src/{http3_common.cpp => h3_common.cpp} (96%) rename src/{server_impl_udp.cpp => h3_server.cpp} (80%) rename src/{http3_session.cpp => h3_session.cpp} (99%) rename src/{http3_stream.cpp => h3_stream.cpp} (99%) 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..dcbe969 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 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..e9d8df6 --- /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/client_impl.hpp" +#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 +// demultiplexing 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/http3_common.hpp b/include/anyhttp/h3_common.hpp similarity index 95% rename from include/anyhttp/http3_common.hpp rename to include/anyhttp/h3_common.hpp index e78816c..7bcbb41 100644 --- a/include/anyhttp/http3_common.hpp +++ b/include/anyhttp/h3_common.hpp @@ -12,7 +12,7 @@ // // Shared HTTP/3 building blocks. Everything in this namespace is used by both roles: the server -// (src/server_impl_udp.cpp) and the client (src/client_impl_udp.cpp) differ only in the direction +// (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 diff --git a/include/anyhttp/http3_session.hpp b/include/anyhttp/h3_session.hpp similarity index 99% rename from include/anyhttp/http3_session.hpp rename to include/anyhttp/h3_session.hpp index 9067b8f..d47106e 100644 --- a/include/anyhttp/http3_session.hpp +++ b/include/anyhttp/h3_session.hpp @@ -1,7 +1,7 @@ #pragma once #include "anyhttp/common.hpp" -#include "anyhttp/http3_common.hpp" +#include "anyhttp/h3_common.hpp" #include "anyhttp/session_impl.hpp" #include diff --git a/include/anyhttp/http3_stream.hpp b/include/anyhttp/h3_stream.hpp similarity index 99% rename from include/anyhttp/http3_stream.hpp rename to include/anyhttp/h3_stream.hpp index 9cf7984..0936e15 100644 --- a/include/anyhttp/http3_stream.hpp +++ b/include/anyhttp/h3_stream.hpp @@ -1,7 +1,7 @@ #pragma once #include "anyhttp/common.hpp" -#include "anyhttp/http3_common.hpp" +#include "anyhttp/h3_common.hpp" #include #include 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 604193e..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 Http3ServerSession; -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,7 +63,6 @@ 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; } @@ -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, Http3ServerSession* session); - void dissociate_quic_cid(const ngtcp2_cid& cid); - void erase_quic_session(Http3ServerSession* 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/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..8662d56 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 @@ -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/client_impl_udp.cpp b/src/h3_client.cpp similarity index 98% rename from src/client_impl_udp.cpp rename to src/h3_client.cpp index 2d66553..9f8a72e 100644 --- a/src/client_impl_udp.cpp +++ b/src/h3_client.cpp @@ -2,8 +2,8 @@ // anyhttp QUIC / HTTP/3 client. // // Almost all of it is shared with the server: `Http3ClientSession` is an `http3::Http3Session` -// (see anyhttp/http3_session.hpp) that knows how packets reach it and how it is torn down, and -// `Http3ClientStream` is an `http3::Http3Stream` (anyhttp/http3_stream.hpp) that writes a request +// (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 @@ -17,9 +17,10 @@ #include "anyhttp/client_impl.hpp" #include "anyhttp/formatter.hpp" // IWYU pragma: keep -#include "anyhttp/http3_common.hpp" -#include "anyhttp/http3_session.hpp" -#include "anyhttp/http3_stream.hpp" +#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" diff --git a/src/http3_common.cpp b/src/h3_common.cpp similarity index 96% rename from src/http3_common.cpp rename to src/h3_common.cpp index bc99e33..fd4b361 100644 --- a/src/http3_common.cpp +++ b/src/h3_common.cpp @@ -1,7 +1,7 @@ // -// Small helpers shared by the HTTP/3 server and client, see anyhttp/http3_common.hpp. +// Small helpers shared by the HTTP/3 server and client, see anyhttp/h3_common.hpp. // -#include "anyhttp/http3_common.hpp" +#include "anyhttp/h3_common.hpp" #include diff --git a/src/server_impl_udp.cpp b/src/h3_server.cpp similarity index 80% rename from src/server_impl_udp.cpp rename to src/h3_server.cpp index 01ea944..2d3ad37 100644 --- a/src/server_impl_udp.cpp +++ b/src/h3_server.cpp @@ -2,7 +2,7 @@ // anyhttp QUIC / HTTP/3 server. // // Nearly everything that makes a QUIC connection work is shared with the client and lives in -// anyhttp/http3_session.hpp and anyhttp/http3_stream.hpp: `Http3ServerSession` is an +// 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` @@ -10,7 +10,9 @@ // // 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. +// 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 @@ -18,7 +20,7 @@ // 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 -- +// 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 @@ -27,9 +29,10 @@ #include "anyhttp/client_impl.hpp" #include "anyhttp/formatter.hpp" // IWYU pragma: keep -#include "anyhttp/http3_common.hpp" -#include "anyhttp/http3_session.hpp" -#include "anyhttp/http3_stream.hpp" +#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" @@ -306,6 +309,7 @@ int send_udp_gso(const Endpoint& ep, const sockaddr* sa, socklen_t salen, // Http3ServerStream / Http3ServerSession: the server's end of the shared HTTP/3 implementation. // ================================================================================================= +class Http3ServerImpl; class Http3ServerSession; class Http3ServerStream : public http3::Http3Stream @@ -328,7 +332,7 @@ class Http3ServerStream : public http3::Http3Stream class Http3ServerSession : public http3::Http3Session { public: - Http3ServerSession(Server::Impl& server, Endpoint ep, ngtcp2::Address remote); + Http3ServerSession(Http3ServerImpl& server, Endpoint ep, ngtcp2::Address remote); ~Http3ServerSession() override; // @@ -352,11 +356,12 @@ class Http3ServerSession : public http3::Http3Session int on_read(const ngtcp2_pkt_info& pi, std::span data, const ngtcp2::Address& remote); - /// Called from process_quic_batch() when a packet arrives during the closing period. + /// 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_; } - Server::Impl& server() noexcept { return server_; } + Http3ServerImpl& server() noexcept { return server_; } protected: int handle_error(int rv) override; @@ -372,7 +377,7 @@ class Http3ServerSession : public http3::Http3Session void do_destroy() noexcept; // the body of destroy(), always run on executor_ private: - Server::Impl& server_; + Http3ServerImpl& server_; Endpoint ep_; bool owns_fd_ = false; // ep_.fd was dup()ed in the ctor, close it in the dtor ngtcp2::Address remote_; @@ -383,6 +388,98 @@ class Http3ServerSession : public http3::Http3Session 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 // ================================================================================================= @@ -476,7 +573,7 @@ void Http3ServerStream::submit_response(unsigned int status, const Fields& user_ // Http3ServerSession implementation // ================================================================================================= -Http3ServerSession::Http3ServerSession(Server::Impl& server, Endpoint ep, ngtcp2::Address remote) +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()), @@ -681,10 +778,10 @@ int Http3ServerSession::handle_error(int /*rv*/) // // 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::process_quic_batch() can + // 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 m_quic_handlers for the lifetime of the server, holding streams whose request handlers + // 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 || @@ -754,64 +851,92 @@ void Http3ServerSession::resend_conn_close() } // ================================================================================================= -// Server::Impl QUIC glue. +// Http3ServerImpl: the UDP socket, the receive loop and the connection-ID demux. // ================================================================================================= -namespace -{ -std::optional to_ngtcp2_address(const sockaddr_storage& src, socklen_t len) +Http3ServerImpl::Http3ServerImpl(Server::Impl& parent, const asio::ip::udp::endpoint& endpoint) + : parent_(parent) { - ngtcp2::Address addr{}; - if (len > sizeof(addr.su)) - return std::nullopt; - std::memcpy(&addr.su, &src, len); - addr.len = len; - return addr; + 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; + std::ignore = 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); } -} // 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 +// ------------------------------------------------------------------------------------------------- + +void Http3ServerImpl::start() { - struct Datagram + // 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) { - ngtcp2_pkt_info pi; - ngtcp2::Address remote; - std::vector data; - }; + if (ex) + logw("UDP receive loop: {}", what(ex)); + else + logi("UDP receive loop: done"); + }); +} - bool is_new = false; - ngtcp2_pkt_hd hd{}; // decoded Initial packet header, only valid when is_new - boost::container::small_vector datagrams; -}; +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 Server::Impl::associate_quic_cid(const ngtcp2_cid& cid, Http3ServerSession* h) +void Http3ServerImpl::associate_quic_cid(const ngtcp2_cid& cid, Http3ServerSession* h) { - auto lock = std::lock_guard(m_quicMutex); - m_quic_handlers.emplace(cid_key(cid), - std::static_pointer_cast(h->shared_from_this())); + auto lock = std::lock_guard(mutex_); + sessions_.emplace(cid_key(cid), + std::static_pointer_cast(h->shared_from_this())); } -void Server::Impl::dissociate_quic_cid(const ngtcp2_cid& cid) +void Http3ServerImpl::dissociate_quic_cid(const ngtcp2_cid& cid) { - auto lock = std::lock_guard(m_quicMutex); - m_quic_handlers.erase(cid_key(cid)); + auto lock = std::lock_guard(mutex_); + sessions_.erase(cid_key(cid)); } -void Server::Impl::erase_quic_session(Http3ServerSession* h) +void Http3ServerImpl::erase_quic_session(Http3ServerSession* h) { - auto lock = std::lock_guard(m_quicMutex); - std::erase_if(m_quic_handlers, [h](const auto& kv) { return kv.second.get() == h; }); + auto lock = std::lock_guard(mutex_); + std::erase_if(sessions_, [h](const auto& kv) { return kv.second.get() == h; }); } // ------------------------------------------------------------------------------------------------- -int Server::Impl::udp_on_read(Endpoint& ep) +int Http3ServerImpl::udp_on_read(Endpoint& ep) { ngtcp2::sockaddr_union su; std::array buf; @@ -912,8 +1037,8 @@ int Server::Impl::udp_on_read(Endpoint& ep) 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()) + auto lock = std::lock_guard(mutex_); + if (auto it = sessions_.find(key); it != sessions_.end()) session = it->second; } @@ -932,8 +1057,8 @@ int Server::Impl::udp_on_read(Endpoint& ep) // 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 lock = std::lock_guard(mutex_); + sessions_.emplace(std::move(key), session); auto& batch = batches[session]; batch.is_new = true; batch.hd = hd; @@ -950,7 +1075,8 @@ int Server::Impl::udp_on_read(Endpoint& ep) for (auto& [session, batch] : batches) { asio::post(session->get_executor(), - [self = shared_from_this(), session, batch = std::move(batch)]() mutable + [self = shared_from_this(), owner = owner(), session, + batch = std::move(batch)]() mutable { self->process_quic_batch(session, std::move(batch)); }); } @@ -964,8 +1090,8 @@ int Server::Impl::udp_on_read(Endpoint& ep) // 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) +void Http3ServerImpl::process_quic_batch(const std::shared_ptr& session, + QuicBatch&& batch) { size_t next = 0; bool read_ok = false; @@ -992,29 +1118,24 @@ void Server::Impl::process_quic_batch(const std::shared_ptr& } // - // 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. + // 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)) { - auto lock = std::lock_guard(m_sessionMutex); - if (m_destroyed) - { - erase_quic_session(session.get()); - session->destroy(); - return; - } - m_sessions.emplace(session); + erase_quic_session(session.get()); + session->destroy(); + return; } + co_spawn(session->get_executor(), session->do_session({}), - [self = shared_from_this(), session](const std::exception_ptr& ex) + [self = shared_from_this(), owner = owner(), 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); + self->parent_.remove_session(session); }); } @@ -1026,7 +1147,7 @@ void Server::Impl::process_quic_batch(const std::shared_ptr& // 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 + // 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()) @@ -1077,13 +1198,13 @@ void Server::Impl::process_quic_batch(const std::shared_ptr& // ------------------------------------------------------------------------------------------------- -awaitable Server::Impl::udp_receive_loop() +awaitable Http3ServerImpl::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)); + 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) @@ -1094,10 +1215,10 @@ awaitable Server::Impl::udp_receive_loop() } 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(); + 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(); @@ -1108,4 +1229,12 @@ awaitable Server::Impl::udp_receive_loop() // ================================================================================================= +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/http3_session.cpp b/src/h3_session.cpp similarity index 99% rename from src/http3_session.cpp rename to src/h3_session.cpp index 3a69caf..c8c4d7d 100644 --- a/src/http3_session.cpp +++ b/src/h3_session.cpp @@ -1,10 +1,10 @@ // // Http3Session: one QUIC connection carrying HTTP/3, shared by the server and the client. -// See anyhttp/http3_session.hpp; the role-specific ends live in server_impl_udp.cpp and -// client_impl_udp.cpp. +// See anyhttp/h3_session.hpp; the role-specific ends live in h3_server.cpp and +// h3_client.cpp. // -#include "anyhttp/http3_session.hpp" -#include "anyhttp/http3_stream.hpp" +#include "anyhttp/h3_session.hpp" +#include "anyhttp/h3_stream.hpp" #include "anyhttp/literals.hpp" #include "anyhttp/tls.hpp" diff --git a/src/http3_stream.cpp b/src/h3_stream.cpp similarity index 99% rename from src/http3_stream.cpp rename to src/h3_stream.cpp index 697d3f5..3792660 100644 --- a/src/http3_stream.cpp +++ b/src/h3_stream.cpp @@ -1,11 +1,11 @@ // // Http3Stream: one HTTP/3 request/response exchange, shared by the server and the client. -// See anyhttp/http3_stream.hpp for the model; the role-specific ends of it live in -// server_impl_udp.cpp and client_impl_udp.cpp. +// 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/http3_stream.hpp" +#include "anyhttp/h3_stream.hpp" #include "anyhttp/formatter.hpp" // IWYU pragma: keep -#include "anyhttp/http3_session.hpp" +#include "anyhttp/h3_session.hpp" #include #include diff --git a/src/server_impl.cpp b/src/server_impl.cpp index f28fab3..1a56de6 100644 --- a/src/server_impl.cpp +++ b/src/server_impl.cpp @@ -1,12 +1,12 @@ #include "anyhttp/server_impl.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 @@ -28,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 { @@ -60,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()}); } // ------------------------------------------------------------------------------------------------- @@ -81,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(); } // ------------------------------------------------------------------------------------------------- @@ -119,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(); } // ------------------------------------------------------------------------------------------------- @@ -139,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() @@ -164,51 +169,11 @@ void Server::Impl::listen_tcp() logi("Server: TCP listening on {}", ep); } -// ------------------------------------------------------------------------------------------------- - -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::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); -} - // ================================================================================================= +// +// 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 // @@ -222,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; } // ------------------------------------------------------------------------------------------------- @@ -328,13 +302,9 @@ awaitable Server::Impl::handle_connection(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)); } // @@ -345,11 +315,9 @@ awaitable Server::Impl::handle_connection(ip::tcp::socket socket) logi("[{}] detected HTTP2 client preface, {} bytes in buffer", prefix, buffer.size()); #if 1 AnyAsyncStream stream(std::make_unique(std::move(socket))); - session = 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 } @@ -361,25 +329,25 @@ awaitable Server::Impl::handle_connection(ip::tcp::socket socket) logi("[{}] no HTTP2 client preface, assuming HTTP/1.x", prefix); #if 1 AnyAsyncStream stream(std::make_unique(std::move(socket))); - session = 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); } 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 From 376f7c82c45c663df4eac04717911ff0cbc5f068 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Mon, 31 Aug 2026 17:57:42 +0000 Subject: [PATCH 5/8] build: define BOOST_ASIO_NO_DEPRECATED The error_code overloads of shutdown()/set_option() now return void, so drop the std::ignore = in front of them. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 3 +++ include/anyhttp/detail/h2_session_details.hpp | 2 +- src/h1_session.cpp | 4 ++-- src/h3_server.cpp | 2 +- src/server_impl.cpp | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) 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/detail/h2_session_details.hpp b/include/anyhttp/detail/h2_session_details.hpp index dcbe969..03c1155 100644 --- a/include/anyhttp/detail/h2_session_details.hpp +++ b/include/anyhttp/detail/h2_session_details.hpp @@ -45,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/src/h1_session.cpp b/src/h1_session.cpp index 8662d56..22118c9 100644 --- a/src/h1_session.cpp +++ b/src/h1_session.cpp @@ -606,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()); // }); } @@ -769,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"); } diff --git a/src/h3_server.cpp b/src/h3_server.cpp index 2d3ad37..fef5e93 100644 --- a/src/h3_server.cpp +++ b/src/h3_server.cpp @@ -868,7 +868,7 @@ Http3ServerImpl::Http3ServerImpl(Server::Impl& parent, const asio::ip::udp::endp if (is_v6) { boost::system::error_code ec; - std::ignore = socket_->set_option(ip::v6_only(false), 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)); diff --git a/src/server_impl.cpp b/src/server_impl.cpp index 1a56de6..06ddc0c 100644 --- a/src/server_impl.cpp +++ b/src/server_impl.cpp @@ -158,7 +158,7 @@ void Server::Impl::listen_tcp() ip::tcp::endpoint ep(address, config().port); if (ep.protocol() == ip::tcp::v6()) - std::ignore = acceptor.set_option(ip::v6_only(false), ec); + acceptor.set_option(ip::v6_only(false), ec); acceptor.open(ep.protocol()); acceptor.set_option(asio::socket_base::reuse_address(true)); From 69eca3bd44aa7700a738e5b37b818511bfdfa5f9 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Tue, 1 Sep 2026 11:47:32 +0000 Subject: [PATCH 6/8] h3: use mlog* in Http3Session member functions Replaces the hand-written "[{}] " prefix and log_prefix_ argument with the mlog* macros in the member functions of h3_session.cpp. The static ngtcp2/nghttp3 callbacks keep the explicit form: mlog* expands to an unqualified logPrefix(), which has nothing to bind to there. Co-Authored-By: Claude Opus 5 --- src/h3_session.cpp | 55 +++++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/src/h3_session.cpp b/src/h3_session.cpp index c8c4d7d..48aaa77 100644 --- a/src/h3_session.cpp +++ b/src/h3_session.cpp @@ -183,12 +183,11 @@ ngtcp2_ssize Http3Session::write_pkt(ngtcp2_path* path, ngtcp2_pkt_info* pi, uin 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); + mlogd("write_pkt: nghttp3_conn_writev_stream -> stream={} sveccnt={} fin={}", stream_id, + sveccnt, fin); if (sveccnt < 0) { - loge("[{}] nghttp3_conn_writev_stream: {}", log_prefix_, - nghttp3_strerror(static_cast(sveccnt))); + 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); @@ -227,8 +226,7 @@ ngtcp2_ssize Http3Session::write_pkt(ngtcp2_path* path, ngtcp2_pkt_info* pi, uin // 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); + 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; @@ -242,7 +240,7 @@ ngtcp2_ssize Http3Session::write_pkt(ngtcp2_path* path, ngtcp2_pkt_info* pi, uin nghttp3_conn_add_write_offset(h3_, stream_id, static_cast(ndatalen)); rv != 0) { - loge("[{}] nghttp3_conn_add_write_offset: {}", log_prefix_, nghttp3_strerror(rv)); + mloge("nghttp3_conn_add_write_offset: {}", nghttp3_strerror(rv)); return NGTCP2_ERR_CALLBACK_FAILURE; } if (auto s = find_stream(stream_id)) @@ -250,8 +248,7 @@ ngtcp2_ssize Http3Session::write_pkt(ngtcp2_path* path, ngtcp2_pkt_info* pi, uin } continue; default: - loge("[{}] ngtcp2_conn_writev_stream: {}", log_prefix_, - ngtcp2_strerror(static_cast(nwrite))); + 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; } @@ -262,7 +259,7 @@ ngtcp2_ssize Http3Session::write_pkt(ngtcp2_path* path, ngtcp2_pkt_info* pi, uin 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)); + mloge("nghttp3_conn_add_write_offset: {}", nghttp3_strerror(rv)); return NGTCP2_ERR_CALLBACK_FAILURE; } if (auto s = find_stream(stream_id)) @@ -280,7 +277,7 @@ 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_)); + mlogd("write_streams: max_data_left={}", ngtcp2_conn_get_max_data_left(conn_)); ngtcp2_path_storage ps; ngtcp2_pkt_info pi; @@ -292,8 +289,7 @@ int Http3Session::write_streams() &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))); + 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)); @@ -355,9 +351,9 @@ int Http3Session::handle_expiry() // special is that the connection is then discarded silently, see there. // if (rv == NGTCP2_ERR_IDLE_CLOSE) - logi("[{}] idle timeout, dropping connection", log_prefix_); + mlogi("idle timeout, dropping connection"); else - logw("[{}] ngtcp2_conn_handle_expiry: {}", log_prefix_, ngtcp2_strerror(rv)); + mlogw("ngtcp2_conn_handle_expiry: {}", ngtcp2_strerror(rv)); ngtcp2_ccerr_set_liberr(&last_error_, rv, nullptr, 0); return handle_error(rv); @@ -370,16 +366,16 @@ int Http3Session::handle_expiry() int Http3Session::on_read(const ngtcp2_path& path, const ngtcp2_pkt_info& pi, std::span data) { - logd("[{}] on_read: {} bytes", log_prefix_, data.size()); + 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) - logd("[{}] ngtcp2_conn_read_pkt: draining", log_prefix_); + mlogd("ngtcp2_conn_read_pkt: draining"); else - logw("[{}] ngtcp2_conn_read_pkt: {}", log_prefix_, ngtcp2_strerror(rv)); + 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); @@ -465,7 +461,7 @@ int Http3Session::setup_tls(SSL_CTX* ssl_ctx, bool is_server) auto* ssl = SSL_new(ssl_ctx); if (!ssl) { - loge("[{}] SSL_new failed", log_prefix_); + mloge("SSL_new failed"); return -1; } @@ -482,15 +478,14 @@ int Http3Session::setup_tls(SSL_CTX* ssl_ctx, bool is_server) : &ngtcp2_crypto_ossl_configure_client_session; if (configure(ssl) != 0) { - loge("[{}] ngtcp2_crypto_ossl_configure_{}_session failed", log_prefix_, - is_server ? "server" : "client"); + 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) { - loge("[{}] ngtcp2_crypto_ossl_ctx_new failed", log_prefix_); + mloge("ngtcp2_crypto_ossl_ctx_new failed"); SSL_free(ssl); return -1; } @@ -527,7 +522,7 @@ int Http3Session::setup_http3() { if (auto rv = nghttp3_conn_server_new(&h3_, &h3cb, &settings, nullptr, this); rv != 0) { - loge("[{}] nghttp3_conn_server_new: {}", log_prefix_, nghttp3_strerror(rv)); + mloge("nghttp3_conn_server_new: {}", nghttp3_strerror(rv)); return -1; } auto params = ngtcp2_conn_get_local_transport_params(conn_); @@ -535,19 +530,19 @@ int Http3Session::setup_http3() } else if (auto rv = nghttp3_conn_client_new(&h3_, &h3cb, &settings, nullptr, this); rv != 0) { - loge("[{}] nghttp3_conn_client_new: {}", log_prefix_, nghttp3_strerror(rv)); + 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) { - loge("[{}] open control stream: {}", log_prefix_, ngtcp2_strerror(rv)); + mloge("open control stream: {}", 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)); + mloge("nghttp3_conn_bind_control_stream: {}", nghttp3_strerror(rv)); return -1; } @@ -556,18 +551,18 @@ int Http3Session::setup_http3() 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_); + 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) { - loge("[{}] nghttp3_conn_bind_qpack_streams: {}", log_prefix_, nghttp3_strerror(rv)); + mloge("nghttp3_conn_bind_qpack_streams: {}", 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); + 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; From bcde221a0a6e10848226158677f588d32ef3d25e Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Tue, 1 Sep 2026 11:47:37 +0000 Subject: [PATCH 7/8] chore: cosmetic touch-ups Comment wording and formatting only, no behaviour change: - h3_session: half-open range as [dest, dest+destlen[ - h3_backend: reflow the Http3Server comment, "de-multiplexing" - common: section separator before the ReadSome aliases - cspell: sveccnt Co-Authored-By: Claude Opus 5 --- .cspell.json | 1 + include/anyhttp/common.hpp | 2 ++ include/anyhttp/h3_backend.hpp | 8 ++++---- src/h3_session.cpp | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) 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/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/h3_backend.hpp b/include/anyhttp/h3_backend.hpp index e9d8df6..b8b4d65 100644 --- a/include/anyhttp/h3_backend.hpp +++ b/include/anyhttp/h3_backend.hpp @@ -8,7 +8,6 @@ // session running on it (src/h3_client.cpp). // -#include "anyhttp/client_impl.hpp" #include "anyhttp/server_impl.hpp" #include "anyhttp/session_impl.hpp" @@ -26,9 +25,10 @@ namespace anyhttp::server // // The server's HTTP/3 half: one UDP socket shared by all QUIC connections, the receive loop -// demultiplexing 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. +// 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 { diff --git a/src/h3_session.cpp b/src/h3_session.cpp index 48aaa77..4948165 100644 --- a/src/h3_session.cpp +++ b/src/h3_session.cpp @@ -153,7 +153,7 @@ ngtcp2_ssize Http3Session::write_pkt_cb(ngtcp2_conn*, ngtcp2_path* path, ngtcp2_ } // -// Writes a single QUIC packet's worth of stream data into [dest, dest+destlen). Called repeatedly +// 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. From ff6dfb1a55ff8d96818d4bb72ecf8caa6099c657 Mon Sep 17 00:00:00 2001 From: Peter Eisenlohr Date: Tue, 1 Sep 2026 14:20:09 +0000 Subject: [PATCH 8/8] refactor: update log prefix format and clean up write_pkt function --- src/h3_client.cpp | 2 +- src/h3_session.cpp | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/h3_client.cpp b/src/h3_client.cpp index 9f8a72e..fc700aa 100644 --- a/src/h3_client.cpp +++ b/src/h3_client.cpp @@ -435,7 +435,7 @@ int Http3ClientSession::init(asio::ip::udp::endpoint remote) return -1; } - log_prefix_ = std::format("h3c:{}", ngtcp2::util::straddr(remote.data(), remote.size())); + log_prefix_ = std::format("h3:{}", ngtcp2::util::straddr(remote.data(), remote.size())); ngtcp2_cid scid{}; scid.datalen = 17; diff --git a/src/h3_session.cpp b/src/h3_session.cpp index 4948165..cf34568 100644 --- a/src/h3_session.cpp +++ b/src/h3_session.cpp @@ -270,6 +270,7 @@ ngtcp2_ssize Http3Session::write_pkt(ngtcp2_path* path, ngtcp2_pkt_info* pi, uin } } +// https://nghttp2.org/ngtcp2/programmers-guide.html#pseudo-code-for-writing-packets-with-gso int Http3Session::write_streams() { if (!conn_) @@ -295,8 +296,6 @@ int Http3Session::write_streams() return handle_error(static_cast(nwrite)); } - ngtcp2_conn_update_pkt_tx_time(conn_, ngtcp2::util::timestamp()); - if (nwrite == 0) return 0;