diff --git a/.cspell.json b/.cspell.json index b8bed01..c351490 100644 --- a/.cspell.json +++ b/.cspell.json @@ -6,9 +6,14 @@ "ASAN", "ASIO", "callgrind", + "ccerr", "cfssl", "coro", "darkbase", + "dcid", + "dcidlen", + "demuxed", + "ECDH", "Eisenlohr", "epoll", "gencert", @@ -32,22 +37,28 @@ "mylogd", "nghttp", "ngtcp", + "ossl", "PROFDATA", "PROFRAW", "psedoc", "pynghttp", + "qpack", "QUIC", "rcbuf", "RECVTOS", "respawn", "RESPAWNED", "respawning", + "scid", + "SCIDLEN", + "scids", "spdlog", "SSLKEYLOGFILE", "STREQUAL", "testcases", "TLSEXT", - "TSAN" + "TSAN", + "WOULDBLOCK" ], "ignorePaths": [ ".devcontainer/*", diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f1f3fc1..299dc64 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -2,7 +2,7 @@ # The dependencies of this project have been moved into another Dockerfile that can be built # separately. This image can be pushed to the HUB, speeding up codespace creation considerably. # -FROM docker.io/psedoc/anyhttp:0.26 +FROM docker.io/psedoc/anyhttp:0.27 # # install some more interactive utils in the devcontainer @@ -16,13 +16,13 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive && \ # TEST: claude code # # Install Node.js, npm, and git -RUN apt-get update && export DEBIAN_FRONTEND=noninteractive && \ - apt-get -y install nodejs npm git && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive && \ +# apt-get -y install nodejs npm git && \ +# apt-get clean && \ +# rm -rf /var/lib/apt/lists/* # Install Claude Code -RUN npm install -g @anthropic-ai/claude-code +# RUN npm install -g @anthropic-ai/claude-code # # enable persistent bash history in container diff --git a/.devcontainer/base/Dockerfile b/.devcontainer/base/Dockerfile index daf009c..6536404 100644 --- a/.devcontainer/base/Dockerfile +++ b/.devcontainer/base/Dockerfile @@ -2,7 +2,7 @@ # https://github.com/microsoft/vscode-dev-containers/tree/main/containers/debian # https://github.com/pgit/cpp-devcontainer # -FROM docker.io/psedoc/cpp-devcontainer:0.26 +FROM docker.io/psedoc/cpp-devcontainer:0.27 # # GDB libc++ pretty printer (works for string, but not for map with llvm-18) @@ -66,7 +66,7 @@ ENV LD_LIBRARY_PATH=/usr/local/lib # With debian trixie, we now have OpenSSL 3 with QUIC support. Whether or not that is any good, # seems to be up for debate: https://www.haproxy.com/blog/state-of-ssl-stacks # -ARG AWS_LC_VERSION=v1.72.0 +ARG AWS_LC_VERSION=v5.5.0 RUN git clone --depth 1 -b ${AWS_LC_VERSION} https://github.com/aws/aws-lc && \ cd aws-lc && \ cmake -B build -DDISABLE_GO=ON --install-prefix=/opt/boringssl && \ @@ -75,7 +75,7 @@ RUN git clone --depth 1 -b ${AWS_LC_VERSION} https://github.com/aws/aws-lc && \ cd .. && \ rm -rf aws-lc -ARG NGHTTP3_VERSION=v1.15.0 +ARG NGHTTP3_VERSION=v1.18.0 RUN git clone --recursive --depth 1 --branch ${NGHTTP3_VERSION} https://github.com/ngtcp2/nghttp3 && \ cd nghttp3 && \ autoreconf -i && \ @@ -86,7 +86,7 @@ RUN git clone --recursive --depth 1 --branch ${NGHTTP3_VERSION} https://github.c # # nghtcp2 is built with both 'boringssl' (actually, aws-lc) and 'ossl' support # -ARG NGTCP2_VERSION=v1.22.1 +ARG NGTCP2_VERSION=v1.25.0 RUN git clone --recursive --depth 1 --branch ${NGTCP2_VERSION} https://github.com/ngtcp2/ngtcp2 && \ cd ngtcp2 && \ autoreconf -i && \ @@ -112,7 +112,7 @@ RUN git clone --depth 1 -b v1.6.3 https://github.com/libbpf/libbpf && \ # # nghttp2 with HTTP/3 support (based on OpenSSL) # -ARG NGHTTP2_VERSION=v1.69.0 +ARG NGHTTP2_VERSION=v1.70.0 RUN git clone --recursive --depth 1 --branch ${NGHTTP2_VERSION} https://github.com/nghttp2/nghttp2.git && \ cd nghttp2 && \ autoreconf -i && \ @@ -130,15 +130,15 @@ RUN git clone --recursive --depth 1 --branch ${NGHTTP2_VERSION} https://github.c # https://curl.se/docs/http3.html # -# RUN cd opt && \ -# git clone --depth 1 https://github.com/curl/curl.git && \ -# cd curl && autoreconf -fi && \ -# ./configure --with-openssl --without-libpsl --with-nghttp2 \ -# --with-ngtcp2 --with-nghttp3 \ -# --prefix /usr/local && \ -# make -j$(nproc) && make install && \ -# cd .. && rm -rf curl -# RUN apt-get -y remove curl +RUN cd opt && \ + git clone --depth 1 https://github.com/curl/curl.git && \ + cd curl && autoreconf -fi && \ + ./configure --with-openssl --without-libpsl --with-nghttp2 \ + --with-ngtcp2 --with-nghttp3 \ + --prefix /usr/local && \ + make -j$(nproc) && make install && \ + cd .. && rm -rf curl +RUN apt-get -y remove curl # ================================================================================================== diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5689b24..003262c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -43,7 +43,8 @@ "usernamehw.errorlens", "vadimcn.vscode-lldb", "xaver.clang-format", - "github.vscode-github-actions" + "github.vscode-github-actions", + "anthropic.claude-code" ] } }, @@ -55,10 +56,11 @@ // "postCreateCommand": "gcc -v", "mounts": [ - "source=cpp-devcontainer-bashhistory,target=/commandhistory,type=volume" + "source=cpp-devcontainer-bashhistory,target=/commandhistory,type=volume", // "source=${localEnv:HOME}/src/nghttp2,target=/workspaces/nghttp2,type=bind,consistency=cached", // "source=${localEnv:HOME}/src/ngtcp2,target=/workspaces/ngtcp2,type=bind,consistency=cached" - // "source=/home/peter/.claude,target=/home/vscode/.claude,type=bind,consistency=cached" + "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached", + "source=${localEnv:HOME}/.claude.json,target=/home/vscode/.claude.json,type=bind,consistency=cached" ], // Comment out this line to run as root instead. @@ -66,4 +68,4 @@ "remoteEnv": { // "PATH": "${containerEnv:PATH}:/workspaces/nghttp2/install/bin" } -} \ No newline at end of file +} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51d3147..2587400 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: name: RelWithDebInfo runs-on: ubuntu-latest container: - image: docker.io/psedoc/anyhttp:0.25 + image: docker.io/psedoc/anyhttp:0.27 steps: - &checkout @@ -37,7 +37,7 @@ jobs: name: ASAN runs-on: ubuntu-latest container: - image: docker.io/psedoc/anyhttp:0.25 + image: docker.io/psedoc/anyhttp:0.27 steps: - *checkout @@ -50,13 +50,13 @@ jobs: - name: Run tests (if available) run: | - ASAN_OPTIONS=detect_leaks=1 build/test/test_all + ASAN_OPTIONS=detect_leaks=1:detect_container_overflow=0 build/test/test_all tsan: name: TSAN runs-on: ubuntu-latest container: - image: docker.io/psedoc/anyhttp:0.25 + image: docker.io/psedoc/anyhttp:0.27 steps: - *checkout @@ -75,7 +75,7 @@ jobs: name: Coverage runs-on: ubuntu-latest container: - image: docker.io/psedoc/anyhttp:0.25 + image: docker.io/psedoc/anyhttp:0.27 permissions: contents: read pages: write diff --git a/.gitignore b/.gitignore index d5bf554..a99041f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ .cache .vscode pki/out/* -!pki/out/server.pem -!pki/out/server-key.pem docs/_build gtest-parallel-logs report.xml @@ -15,3 +13,4 @@ Testing callgrind.* cmake_test_discovery_*.json googletest_discovery_*.json +keylog.log diff --git a/CMakeLists.txt b/CMakeLists.txt index 5ef7bf9..b696593 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -102,6 +102,7 @@ check_include_file("linux/rtnetlink.h" HAVE_LINUX_RTNETLINK_H) # -------------------------------------------------------------------------------------------------- enable_testing(false) +include(cmake/pki.cmake) add_subdirectory(src) add_subdirectory(test) diff --git a/HTTP3.md b/HTTP3.md new file mode 100644 index 0000000..47bccdf --- /dev/null +++ b/HTTP3.md @@ -0,0 +1,9 @@ +# HTTP/3 Manual Testing + +```sh +cmake --build build && build/src/ngtcp2/ngtcp-server ::1 8080 pki/out/server-key.pem pki/out/server-chain.pem +``` + +```sh +curl --http3-only --cacert pki/out/root.pem https://[::1]:8080/CMakeLists.txt -vv +``` diff --git a/cmake/pki.cmake b/cmake/pki.cmake new file mode 100644 index 0000000..62d286f --- /dev/null +++ b/cmake/pki.cmake @@ -0,0 +1,51 @@ +# +# Test PKI generation. +# +# The server and the tests open their certificates through paths relative to the project root +# (see src/server_impl.cpp and test/test_server.cpp), so the PKI has to be generated into the +# source tree at pki/out rather than into the build directory. +# +# Nothing below pki/out is tracked by git: the whole chain is generated here at build time. +# +find_program(CFSSL_EXECUTABLE cfssl REQUIRED) +find_program(CFSSLJSON_EXECUTABLE cfssljson REQUIRED) + +set(PKI_DIR "${CMAKE_SOURCE_DIR}/pki") +set(PKI_OUT_DIR "${PKI_DIR}/out") + +set(PKI_INPUTS + "${PKI_DIR}/create.sh" + "${PKI_DIR}/config.json" + "${PKI_DIR}/root.json" + "${PKI_DIR}/intermediate.json" + "${PKI_DIR}/server.json" + "${PKI_DIR}/client.json" +) + +set(PKI_OUTPUTS + "${PKI_OUT_DIR}/root.pem" + "${PKI_OUT_DIR}/intermediate.pem" + "${PKI_OUT_DIR}/server.pem" + "${PKI_OUT_DIR}/server-key.pem" + "${PKI_OUT_DIR}/server-chain.pem" + "${PKI_OUT_DIR}/client.pem" + "${PKI_OUT_DIR}/client-key.pem" +) + +# +# create.sh skips any stage whose output already exists, which would leave a stale leaf signed by +# a CA that is no longer around after one of the .json profiles changes. Wipe pki/out first so a +# regeneration always produces one self-consistent chain. +# +add_custom_command( + OUTPUT ${PKI_OUTPUTS} + COMMAND ${CMAKE_COMMAND} -E rm -rf "${PKI_OUT_DIR}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${PKI_OUT_DIR}" + COMMAND "${PKI_DIR}/create.sh" + WORKING_DIRECTORY "${PKI_DIR}" + DEPENDS ${PKI_INPUTS} + COMMENT "Generating test PKI in pki/out" + VERBATIM +) + +add_custom_target(pki ALL DEPENDS ${PKI_OUTPUTS}) diff --git a/include/anyhttp/common.hpp b/include/anyhttp/common.hpp index 33cf271..7fa6e51 100644 --- a/include/anyhttp/common.hpp +++ b/include/anyhttp/common.hpp @@ -20,8 +20,10 @@ #include #include +#include #include #include +#include // ================================================================================================= @@ -36,8 +38,7 @@ using error_code = boost::system::error_code; enum class Protocol { - h1, - http11 = h1, + http11, h2, h3 }; @@ -50,6 +51,53 @@ std::ostream& operator<<(std::ostream& str, Protocol protocol); using Fields = boost::beast::http::fields; static_assert(boost::beast::http::is_fields::value); +// +// A header value as passed to fields() below: either something string-like, or anything +// std::format can turn into a string, so sizes and counts need no conversion at the call site. +// +// String-like values are only referenced, never copied: a FieldValue lives just long enough for +// fields() to hand the bytes to Beast, which copies them. Only formatted values need storage. +// +class FieldValue +{ +public: + template + requires std::formattable + FieldValue(const T& value) + { + if constexpr (std::convertible_to) + view = value; + else + { + buffer = std::format("{}", value); + view = buffer; + } + } + + /// 'view' may point into 'buffer', so a copy would alias the original's storage. + FieldValue(const FieldValue&) = delete; + + operator std::string_view() const noexcept { return view; } + +private: + std::string_view view; + std::string buffer; // only ever used for a value that had to be formatted +}; + +// +// Beast's fields have no initializer-list constructor, so building a small, fixed set of headers +// takes a statement per header. This lets it be spelled inline: +// +// fields({{"Content-Length", body.size()}, {"Content-Type", "text/plain"}}) +// +inline Fields fields(std::initializer_list> headers) +{ + Fields result; + for (auto&& [name, value] : headers) + result.set(name, std::string_view(value)); + return result; +} + using ReadSome = void(boost::system::error_code, size_t); using ReadSomeHandler = asio::any_completion_handler; @@ -94,7 +142,7 @@ class Reader : public std::enable_shared_from_this virtual void destroy() {}; }; -class Writer: public std::enable_shared_from_this +class Writer : public std::enable_shared_from_this { public: virtual ~Writer() = default; diff --git a/include/anyhttp/formatter.hpp b/include/anyhttp/formatter.hpp index 39e2aac..db4bbf9 100644 --- a/include/anyhttp/formatter.hpp +++ b/include/anyhttp/formatter.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -55,13 +56,13 @@ struct std::formatter : public std::formatter -struct std::formatter +template +struct std::formatter> { constexpr auto parse(std::format_parse_context& ctx) { return ctx.begin(); } template - auto format(const boost::asio::ip::tcp::endpoint& endpoint, FormatContext& ctx) const + auto format(const boost::asio::ip::basic_endpoint& endpoint, FormatContext& ctx) const { const auto address = endpoint.address(); if (address.is_v6()) diff --git a/include/anyhttp/request_handlers.hpp b/include/anyhttp/request_handlers.hpp index 1cea080..8a806ce 100644 --- a/include/anyhttp/request_handlers.hpp +++ b/include/anyhttp/request_handlers.hpp @@ -3,8 +3,10 @@ #include "anyhttp/client.hpp" #include "anyhttp/server.hpp" +#include #include #include +#include #include #include @@ -89,23 +91,23 @@ concept ByteRange = // FIXME: Do we really need to restrict to "borrowed range" here? The range is kept alive in // the coroutine frame, so we do not need to worry about it's lifetime. // -template +template requires std::ranges::contiguous_range -awaitable send(client::Request& request, Range range) +awaitable send(Writer& request, Range range) { - logi("send: (contiguous range)..."); + logd("send: (contiguous range)..."); co_await request.async_write(asio::buffer(range.data(), range.size())); - logi("send: (contiguous range)... done"); + logd("send: (contiguous range)... done"); } // // For a non-contiguous range, we need to copy into a buffer first. // -template +template requires (!std::ranges::contiguous_range) -awaitable send(client::Request& request, Range range) +awaitable send(Writer& request, Range range) { - logi("send:"); + logd("send:"); size_t bytes = 0; std::array buffer; for (auto chunk : range | ranges::views::chunk(buffer.size())) @@ -144,13 +146,12 @@ awaitable send(client::Request& request, Range range) #endif } - logi("send: (range) sent {} bytes", bytes); + logd("send: (range) sent {} bytes", bytes); } // ------------------------------------------------------------------------------------------------- -template - requires ByteRange +template awaitable sendAndDrop(client::Request request, Range range) { #if 0 @@ -176,21 +177,9 @@ awaitable sendAndDrop(client::Request request, Range range) // ------------------------------------------------------------------------------------------------- -template - requires ByteRange -awaitable sendAndForceEOF(client::Request& request, Range range) +template +awaitable sendAndForceEOF(Writer& request, Range range) { -#if 0 - try - { - co_await send(request, range); - } - catch (const boost::system::system_error& ec) - { - loge("sendAndForceEOF: {}", ec.code().message()); - } - co_await asio::this_coro::reset_cancellation_state(); -#else using namespace asio; auto ex = co_await this_coro::executor; if (auto [ep] = co_await co_spawn(ex, send(request, std::move(range)), as_tuple); ep) @@ -198,9 +187,33 @@ awaitable sendAndForceEOF(client::Request& request, Range range) loge("sendAndForceEOF: {}", what(ep)); co_await asio::this_coro::reset_cancellation_state(); } -#endif auto [ec] = co_await request.async_write({}, as_tuple(deferred)); - // co_await send_eof(request); +} + +// ------------------------------------------------------------------------------------------------- + +// +// Generate a body of the requested length, e.g. "/generate?length=1000000". The payload is a +// repeating 0..255 byte pattern. +// +inline awaitable generate(server::Request request, server::Response response) +{ + namespace rv = std::ranges::views; + + size_t length = 0; + const auto param = request.url().params().get_or("length"); + auto [ptr, ec] = std::from_chars(param.data(), param.data() + param.size(), length); + if (ec != std::errc{} || ptr != param.data() + param.size()) + { + logw("generate: invalid length '{}'", param); + co_await response.async_submit(400, {}); + co_await response.async_write({}); + co_return; + } + + logd("generate: {} bytes", length); + co_await response.async_submit(200, fields({{"Content-Length", length}})); + co_await sendAndForceEOF(response, rv::iota(uint8_t(0)) | rv::take(length)); } // ------------------------------------------------------------------------------------------------- diff --git a/include/anyhttp/server_impl.hpp b/include/anyhttp/server_impl.hpp index d3695dd..7744dd9 100644 --- a/include/anyhttp/server_impl.hpp +++ b/include/anyhttp/server_impl.hpp @@ -7,6 +7,10 @@ #include #include +#include + +// Forward declaration so we don't drag into every translation unit. +struct ngtcp2_cid; namespace anyhttp { @@ -48,6 +52,7 @@ class Response::Impl : public impl::Writer // ================================================================================================= struct Endpoint; +class Http3Session; class Server::Impl : public std::enable_shared_from_this { @@ -84,6 +89,14 @@ class Server::Impl : public std::enable_shared_from_this asio::awaitable udp_receive_loop(); int udp_on_read(Endpoint& ep); + // + // 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. + // + void associate_quic_cid(const ngtcp2_cid& cid, Http3Session* session); + void dissociate_quic_cid(const ngtcp2_cid& cid); + void erase_quic_session(Http3Session* h); + private: Config m_config; @@ -94,6 +107,8 @@ class Server::Impl : public std::enable_shared_from_this std::mutex m_sessionMutex; std::set> m_sessions; + std::unordered_map> m_quic_handlers; + RequestHandler m_requestHandler; RequestHandlerCoro m_requestHandlerCoro; bool m_stopped = false; diff --git a/pki/client.json b/pki/client.json index 671c989..84f2551 100644 --- a/pki/client.json +++ b/pki/client.json @@ -1,12 +1,13 @@ { - "CN": "Test Server Certificate", - "key": { - "algo": "ecdsa", - "size": 256 - }, - "hosts": [ + "CN": "Test Server Certificate", + "key": { + "algo": "ecdsa", + "size": 256 + }, + "hosts": [ "localhost", "::1", - "127.0.0.1" - ] + "127.0.0.1", + "127.0.0.2" + ] } \ No newline at end of file diff --git a/pki/config.json b/pki/config.json index 1e91fdb..7ac9d51 100644 --- a/pki/config.json +++ b/pki/config.json @@ -1,6 +1,20 @@ { "signing": { "profiles": { + "intermediate_ca": { + "expiry": "87600h", + "usages": [ + "signing", + "digital signature", + "key encipherment", + "cert sign", + "crl sign" + ], + "is_ca": true, + "ca_constraint": { + "is_ca": true + } + }, "server": { "expiry": "8760h", "usages": [ diff --git a/pki/create.sh b/pki/create.sh index 447399e..266b76a 100755 --- a/pki/create.sh +++ b/pki/create.sh @@ -19,6 +19,11 @@ then cfssl sign -ca out/intermediate.pem -ca-key out/intermediate-key.pem -config config.json -profile server out/server.csr | cfssljson -bare out/server fi +if ! [[ -f out/server-chain.pem ]] +then + cat out/server.pem out/intermediate.pem > out/server-chain.pem +fi + if ! [[ -f out/client.pem ]] then cfssl gencert -initca client.json | cfssljson -bare out/client diff --git a/pki/out/server-key.pem b/pki/out/server-key.pem deleted file mode 100644 index f1ef087..0000000 --- a/pki/out/server-key.pem +++ /dev/null @@ -1,5 +0,0 @@ ------BEGIN EC PRIVATE KEY----- -MHcCAQEEICj6r89u6DJHOX7CDBTju8feXv4UljZaTG4npJPGAeAioAoGCCqGSM49 -AwEHoUQDQgAEXrbK91OZ8exIFBz0+kBMqPF/jboRJ8CLEQMepJKPW339wLhEBcu2 -LtxVdxMYkAt2QeRMn/hX3ZSDNK7tniUzUQ== ------END EC PRIVATE KEY----- diff --git a/pki/out/server.pem b/pki/out/server.pem deleted file mode 100644 index d89f9f7..0000000 --- a/pki/out/server.pem +++ /dev/null @@ -1,13 +0,0 @@ ------BEGIN CERTIFICATE----- -MIICBzCCAa2gAwIBAgIUcNcxo+N/BEY2+tk9KtZA7qvY51owCgYIKoZIzj0EAwIw -PjELMAkGA1UEBhMCREUxEDAOBgNVBAoTB0FueUhUVFAxHTAbBgNVBAMTFFRlc3Qg -SW50ZXJtZWRpYXRlIENBMB4XDTI1MTAxMjIwNDkwMFoXDTI2MTAxMjIwNDkwMFow -IjEgMB4GA1UEAxMXVGVzdCBTZXJ2ZXIgQ2VydGlmaWNhdGUwWTATBgcqhkjOPQIB -BggqhkjOPQMBBwNCAARetsr3U5nx7EgUHPT6QEyo8X+NuhEnwIsRAx6kko9bff3A -uEQFy7Yu3FV3ExiQC3ZB5Eyf+FfdlIM0ru2eJTNRo4GkMIGhMA4GA1UdDwEB/wQE -AwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMB0GA1UdDgQW -BBQMKLx2Gou310gFUMxd3BI4pITazzAfBgNVHSMEGDAWgBSJlDwKJvbRyjQq7pOS -8AsatOBPtjAsBgNVHREEJTAjgglsb2NhbGhvc3SHEAAAAAAAAAAAAAAAAAAAAAGH -BH8AAAEwCgYIKoZIzj0EAwIDSAAwRQIhAKNzIcaWV/5WYsfmNZNX/kjkBlUxvd/U -iG17sx/VKusmAiBaondPYz+nzic8MaIEWwkEYTAR02xQXcEBBZpXcAKCSQ== ------END CERTIFICATE----- diff --git a/pki/server.json b/pki/server.json index 671c989..b8531c4 100644 --- a/pki/server.json +++ b/pki/server.json @@ -7,6 +7,7 @@ "hosts": [ "localhost", "::1", - "127.0.0.1" + "127.0.0.1", + "127.0.0.2" ] } \ No newline at end of file diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 90b3fb1..562e614 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -3,6 +3,13 @@ include_directories(${CMAKE_SOURCE_DIR}/include) # nice sample: # https://github.com/andrewmd5/libwtf/blob/main/CMakeLists.txt +# +# QUIC / HTTP3 libraries -- shared between the anyhttp library below and the +# stand-alone ngtcp2 example binaries in the ngtcp2/ subdirectory. +# +pkg_check_modules(NGTCP2 REQUIRED IMPORTED_TARGET libngtcp2) +pkg_check_modules(NGHTTP3 REQUIRED IMPORTED_TARGET libnghttp3) +pkg_check_modules(NGTCP2_CRYPTO_OSSL REQUIRED IMPORTED_TARGET libngtcp2_crypto_ossl) # # lib @@ -23,6 +30,7 @@ target_link_libraries(anyhttp PRIVATE Boost::thread Boost::atomic Boost::url Boo target_link_libraries(anyhttp PRIVATE spdlog::spdlog_header_only) target_link_libraries(anyhttp PRIVATE PkgConfig::NGHTTP2) target_link_libraries(anyhttp PRIVATE ngtcp2_common) +target_link_libraries(anyhttp PRIVATE PkgConfig::NGTCP2 PkgConfig::NGHTTP3 PkgConfig::NGTCP2_CRYPTO_OSSL) # # server diff --git a/src/beast_session.cpp b/src/beast_session.cpp index 666ed34..003f42c 100644 --- a/src/beast_session.cpp +++ b/src/beast_session.cpp @@ -72,7 +72,7 @@ class BeastReader : public Interface void destroy() noexcept override { - logw("destroy: reader destroyed, is_done={}", parser.is_done()); + logd("destroy: reader destroyed, is_done={}", parser.is_done()); if (!parser.is_done() && session) { logw("destroy: reader destroyed, but parser not done yet... closing socket"); diff --git a/src/client_impl.cpp b/src/client_impl.cpp index 061f721..1c31fd5 100644 --- a/src/client_impl.cpp +++ b/src/client_impl.cpp @@ -25,6 +25,8 @@ #include #include +#include + using namespace std::chrono_literals; using namespace boost::asio; @@ -32,6 +34,13 @@ 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 @@ -92,6 +101,13 @@ awaitable Client::Impl::async_connect() std::string host = config().url.host_address(); std::string port = config().url.port(); + // + // HTTP/3 runs over QUIC (UDP), so it needs an entirely different transport setup (TLS, + // handshake, ...) than the TCP-based http11/h2 paths below. + // + if (config().protocol == Protocol::h3) + co_return Session{co_await async_connect_http3(m_executor, host, port)}; + std::vector endpoints; logd("Client: resolving {}:{} ...", host, port); { @@ -157,8 +173,8 @@ awaitable Client::Impl::async_connect() break; case anyhttp::Protocol::h3: - using namespace boost::system; - throw system_error(errc::make_error_code(errc::invalid_argument)); + // handled above, before the TCP resolve/connect + std::unreachable(); }; // diff --git a/src/client_impl_udp.cpp b/src/client_impl_udp.cpp new file mode 100644 index 0000000..7eba893 --- /dev/null +++ b/src/client_impl_udp.cpp @@ -0,0 +1,2022 @@ +// +// anyhttp QUIC / HTTP/3 client. +// +// One `Http3ClientSession` per QUIC connection implements `Session::Impl`. Unlike the server +// (`server_impl_udp.cpp`), which multiplexes many connections over one shared UDP socket demuxed +// by connection ID, each client session owns its own `connect()`-ed UDP socket -- there is exactly +// one peer, so no demux table is needed. Per-request `Http3ClientStream` state feeds an +// `Http3ClientWriter` (client::Request) and `Http3ClientReader` (client::Response), mirroring the +// server-side Http3Writer/Http3Reader adapters. +// +// Not yet implemented: certificate verification, 0-RTT, connection migration, GSO/ECN, retry +// tokens, graceful (multi-PTO) close. +// + +#include "anyhttp/client_impl.hpp" +#include "anyhttp/formatter.hpp" // IWYU pragma: keep +#include "anyhttp/literals.hpp" +#include "anyhttp/session_impl.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ngtcp2/shared.h" +#include "ngtcp2/util.h" + +using namespace std::chrono_literals; +using namespace boost::asio; +namespace errc = boost::system::errc; + +namespace anyhttp::client +{ + +// ================================================================================================= +// Free-standing helpers +// ================================================================================================= + +namespace +{ + +// +// One-shot process-wide initialization of ngtcp2_crypto_ossl and the client-role OpenSSL SSL_CTX +// used for every outgoing QUIC connection. +// +struct TlsClientContext +{ + TlsClientContext() + { + static const int init_once = [] + { + if (ngtcp2_crypto_ossl_init() != 0) + throw std::runtime_error("ngtcp2_crypto_ossl_init"); + return 0; + }(); + (void)init_once; + + ctx = SSL_CTX_new(TLS_client_method()); + if (!ctx) + throw std::runtime_error("SSL_CTX_new"); + + static constexpr unsigned char alpn[] = "\x02h3"; + SSL_CTX_set_alpn_protos(ctx, alpn, sizeof(alpn) - 1); + + // + // TODO: verify the server certificate (e.g. against pki/out/root.pem) instead of accepting + // anything. + // + SSL_CTX_set_verify(ctx, SSL_VERIFY_NONE, nullptr); + } + + ~TlsClientContext() + { + if (ctx) + SSL_CTX_free(ctx); + } + + TlsClientContext(const TlsClientContext&) = delete; + TlsClientContext& operator=(const TlsClientContext&) = delete; + + SSL_CTX* ctx = nullptr; +}; + +TlsClientContext& tls_context() +{ + static TlsClientContext instance; + return instance; +} + +// ------------------------------------------------------------------------------------------------- + +nghttp3_nv make_nv(std::string_view name, std::string_view value) +{ + nghttp3_nv nv{}; + nv.name = reinterpret_cast(const_cast(name.data())); + nv.namelen = name.size(); + nv.value = reinterpret_cast(const_cast(value.data())); + nv.valuelen = value.size(); + nv.flags = NGHTTP3_NV_FLAG_NONE; + return nv; +} + +void ngtcp2_log_printf(void* /*user*/, const char* fmt, ...) noexcept +{ + if (!spdlog::default_logger()->should_log(spdlog::level::trace)) + return; + std::array buf; + va_list ap; + va_start(ap, fmt); + std::vsnprintf(buf.data(), buf.size(), fmt, ap); + va_end(ap); + spdlog::trace("{}", buf.data()); +} + +} // namespace + +// ================================================================================================= +// Http3ClientStream: per-request state. +// ================================================================================================= + +class Http3ClientSession; +class Http3ClientStream; + +// +// Bound on how much of the caller's async_write() buffer we copy into write_chunk at a time (see +// Http3ClientStream's write_* members) -- copying is paced by how much nghttp3/ngtcp2 actually +// drains, rather than copying a huge caller buffer (e.g. 50MB) in one synchronous allocation+memcpy, +// mirroring nghttp2's own per-call copy into its frame buffer. +// +inline constexpr size_t kWriteChunkSize = 16 * 1024; + +class Http3ClientStream : public std::enable_shared_from_this +{ +public: + Http3ClientStream(Http3ClientSession& session, int64_t id); + ~Http3ClientStream(); + + int64_t id; + Http3ClientSession& session; + std::string log_prefix; + + // + // Request state, set once by async_submit() before headers are sent. + // + boost::urls::url url; + + // + // Response state (populated by nghttp3 header callbacks). + // + unsigned int status_code = 0; + Fields response_fields; + std::optional content_length; + bool headers_received = false; + bool response_delivered = false; + client::Request::GetResponseHandler response_handler; + + // + // Request body plumbing (client -> server). Only one async_write() may be active at a time -- + // callers must wait for its handler before issuing another (same contract as e.g. Beast) -- so + // this is flat per-stream state rather than a queue of pending writes. + // + // write_source is the caller's buffer, referenced (not copied) the way asio::async_write + // generally requires -- it must stay valid until write_handler fires (and no longer: nghttp3 + // only ever gets pointers into write_chunk, our own copy, so once the handler has fired -- + // including via cancellation -- the caller's buffer is no longer touched), matching what + // NGHttp2Stream::async_write() already relies on for HTTP/2 via its own `write_buffer` + // reference. + // + // write_chunk is a bounded (<= kWriteChunkSize) slice of write_source, lazily refilled by + // data_reader() as it's drained, rather than copying all of write_source up front. write_offered + // and write_confirmed are tracked separately because nghttp3 may call data_reader() several + // times in a row for the same stream before ever reporting consumption back via + // on_write_consumed() -- e.g. to gather more vecs than fit in a single call. If data_reader() + // just kept re-handing out write_chunk[0, write_chunk.size()) unconditionally (tracking only + // write_confirmed), nghttp3 would treat each repeat offer as *additional*, distinct stream + // bytes and duplicate the content on the wire. write_offered marks how much has already been + // handed to nghttp3 (whether or not it has been placed in a packet yet) so a repeat call sees + // nothing new and gets NGHTTP3_ERR_WOULDBLOCK instead. + // + bool write_active = false; + asio::const_buffer write_source; + size_t write_source_copied = 0; + std::vector write_chunk; + size_t write_offered = 0; + size_t write_confirmed = 0; + bool write_is_eof = false; + WriteHandler write_handler; + uint64_t write_token = 0; + uint64_t next_write_token = 1; + + std::vector> in_flight_writes; // kept alive for the stream's lifetime -- + // ngtcp2 may still need this memory for + // retransmission until acked + bool eof_submitted = false; + bool eof_sent_to_h3 = false; + + // + // Response body plumbing (server -> client). + // + std::deque> pending_read; + asio::const_buffer read_head; + bool eof_received = false; + ReadSomeHandler read_handler; + asio::mutable_buffer read_handler_buffer; + bool call_read_handler_active = false; // re-entrancy guard, see call_read_handler() + + // + // Lifecycle. + // + impl::Writer* writer = nullptr; // Http3ClientWriter, the client::Request + impl::Reader* reader = nullptr; // Http3ClientReader, the client::Response + bool closed = false; + + asio::any_io_executor get_executor() const noexcept; + const std::string& logPrefix() const noexcept { return log_prefix; } + + // Data flow into user land (response body). + void on_data_chunk(const uint8_t* data, size_t len); + void on_eof(); + void call_read_handler(); + + // Data flow from user land back to nghttp3 (request body). + void start_write(WriteHandler&& handler, asio::const_buffer buffer); + nghttp3_ssize data_reader(nghttp3_vec* vec, size_t veccnt, uint32_t* pflags); + void on_write_consumed(size_t n); + +private: + void bind_write_cancellation(WriteHandler& handler, uint64_t token); // arms cancellation + void finish_active_write(); // completes the active write once fully handed to nghttp3 + +public: + // async_get_response() + void async_get_response(client::Request::GetResponseHandler&& handler); + void deliver_response(); + + // Called on abrupt stream close/reset before completion. + void fail(boost::system::error_code ec); + + // Called from either reader or writer destructor. + void delete_reader(); + void delete_writer(); + void maybe_close(); +}; + +// ================================================================================================= +// Http3ClientSession: one QUIC connection, one anyhttp Session::Impl. +// ================================================================================================= + +class Http3ClientSession : public Session::Impl +{ +public: + explicit Http3ClientSession(asio::any_io_executor executor); + ~Http3ClientSession() override; + + // + // Session::Impl + // + asio::any_io_executor get_executor() const noexcept override { return executor_; } + void async_submit(SubmitHandler&& handler, boost::urls::url url, const Fields& headers) override; + awaitable do_session(Buffer&& data) override; + void destroy() noexcept override; + + // + // Connect-time setup. Returns 0 on success. + // + int init(asio::ip::udp::endpoint remote); + + // + // Awaited by client::Client::Impl::async_connect() before handing the Session back to the + // caller. Fires once the QUIC handshake has progressed far enough to create the HTTP/3 layer + // (see setup_http3()), or once the connection has failed/closed before getting that far -- in + // which case `ready()` is still false and the caller should synthesize an error. + // + template + auto wait_ready(CompletionToken&& token = CompletionToken()) + { + return ready_signal_.async_wait(std::forward(token)); + } + bool ready() const noexcept { return h3_ != nullptr; } + + const std::string& logPrefix() const noexcept { return log_prefix_; } + nghttp3_conn* h3() const noexcept { return h3_; } + + // + // Returns a shared_ptr, not a raw pointer: callers routinely invoke user handlers on the + // stream they looked up, and those can drop the last reference to it (the coroutine they + // resume destroying its Request/Response), which erases the stream from streams_. Holding + // an owning reference for the duration of the lookup keeps that from becoming a + // use-after-free. + // + std::shared_ptr find_stream(int64_t id); + Http3ClientStream* create_stream(int64_t id); + void erase_stream(int64_t id); + + // Called by Http3ClientWriter to make sure the write loop runs after new data was queued. + void wake_write(); + + // + // Grants the peer more *stream*-level send credit for `n` bytes of response body just + // delivered to the application. Deliberately NOT called as data arrives (see h3_cb_recv_data) + // -- only once call_read_handler() actually hands bytes to the app, so a slow/absent reader + // keeps the peer's flow control window for *this stream* genuinely constrained instead of + // nghttp3 buffering an unbounded backlog in pending_read. Connection-level credit is granted + // eagerly regardless (see h3_cb_recv_data) since it's a pool shared with control/QPACK + // streams nghttp3 manages on its own. + // + void consume_stream(int64_t stream_id, size_t n) + { + if (n == 0) + return; + ngtcp2_conn_extend_max_stream_offset(conn_, stream_id, n); + wake_write(); // a WINDOW_UPDATE-equivalent frame needs to go out + } + + // + // Abort both directions of the stream (RESET_STREAM + STOP_SENDING), the QUIC equivalent of + // HTTP/2's RST_STREAM. nghttp3 learns of the dead write side through the existing + // NGTCP2_ERR_STREAM_SHUT_WR handling in write_streams(). + // + void reset_stream(int64_t stream_id, uint64_t app_error_code) + { + ngtcp2_conn_shutdown_stream(conn_, 0, stream_id, app_error_code); + wake_write(); + } + + // + // ngtcp2 <-> ngtcp2_crypto_ossl bridge. + // + static ngtcp2_conn* get_conn(ngtcp2_crypto_conn_ref* ref) + { + return static_cast(ref->user_data)->conn_; + } + + // + // ngtcp2 callback bridges + // + static int cb_handshake_completed(ngtcp2_conn*, void* user); + static int cb_recv_stream_data(ngtcp2_conn*, uint32_t flags, int64_t stream_id, uint64_t offset, + const uint8_t* data, size_t datalen, void* user, void*); + static int cb_acked_stream_data_offset(ngtcp2_conn*, int64_t stream_id, uint64_t offset, + uint64_t datalen, void* user, void*); + static int cb_stream_close(ngtcp2_conn*, uint32_t flags, int64_t stream_id, + uint64_t app_error_code, void* user, void*); + static void cb_rand(uint8_t* dest, size_t destlen, const ngtcp2_rand_ctx*); + static int cb_get_new_connection_id(ngtcp2_conn*, ngtcp2_cid* cid, uint8_t* token, size_t cidlen, + void* user); + static int cb_remove_connection_id(ngtcp2_conn*, const ngtcp2_cid* cid, void* user); + static int cb_extend_max_local_streams_bidi(ngtcp2_conn*, uint64_t max_streams, void* user); + static int cb_stream_stop_sending(ngtcp2_conn*, int64_t stream_id, uint64_t app_error_code, + void* user, void*); + static int cb_stream_reset(ngtcp2_conn*, int64_t stream_id, uint64_t final_size, + uint64_t app_error_code, void* user, void*); + static int cb_extend_max_stream_data(ngtcp2_conn*, int64_t stream_id, uint64_t max_data, + void* user, void*); + static int cb_recv_rx_key(ngtcp2_conn*, ngtcp2_encryption_level level, void* user); + + // + // nghttp3 callback bridges + // + static int h3_cb_stream_close(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, + void* user, void*); + static int h3_cb_recv_data(nghttp3_conn*, int64_t stream_id, const uint8_t* data, size_t datalen, + void* user, void*); + static int h3_cb_deferred_consume(nghttp3_conn*, int64_t stream_id, size_t nconsumed, void* user, + void*); + static int h3_cb_begin_headers(nghttp3_conn*, int64_t stream_id, void* user, void*); + static int h3_cb_recv_header(nghttp3_conn*, int64_t stream_id, int32_t token, + nghttp3_rcbuf* name, nghttp3_rcbuf* value, uint8_t flags, + void* user, void*); + static int h3_cb_end_headers(nghttp3_conn*, int64_t stream_id, int fin, void* user, void*); + static int h3_cb_end_stream(nghttp3_conn*, int64_t stream_id, void* user, void*); + static int h3_cb_stop_sending(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, + void* user, void*); + static int h3_cb_reset_stream(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, + void* user, void*); + +private: + int setup_http3(); + int on_read(std::span data); + int write_streams(); + void send_udp(std::span data); + void update_timer(); + void arm_timer_from_ngtcp2(); + int handle_expiry(); + int handle_error(int rv); + void close(); + void signal_ready(); + +private: + asio::any_io_executor executor_; + asio::ip::udp::socket socket_; + + ngtcp2_conn* conn_ = nullptr; + ngtcp2_crypto_ossl_ctx* ossl_ctx_ = nullptr; + ngtcp2_crypto_conn_ref conn_ref_{}; + + nghttp3_conn* h3_ = nullptr; + + asio::steady_timer timer_; // ngtcp2 expiry (handshake / idle / PTO) + asio::steady_timer ready_signal_; // sentinel timer, see wait_ready() + ngtcp2_ccerr last_error_{}; + bool closed_ = false; + + std::string log_prefix_; + + std::unordered_map> streams_; +}; + +// ================================================================================================= +// Http3ClientWriter / Http3ClientReader: adapters plugging Http3ClientStream into client::Request +// / client::Response. +// ================================================================================================= + +class Http3ClientWriter : public client::Request::Impl +{ +public: + explicit Http3ClientWriter(Http3ClientStream& s) : stream(&s) { s.writer = this; } + ~Http3ClientWriter() override + { + if (stream) + { + stream->writer = nullptr; + stream->delete_writer(); + } + } + + asio::any_io_executor get_executor() const noexcept override + { + assert(stream); + return stream->get_executor(); + } + + void content_length(std::optional /*len*/) override + { + // Request headers (including any content-length the user set beforehand) are already + // submitted synchronously in Http3ClientSession::async_submit(); nothing to do here. + } + + void async_write(WriteHandler&& handler, asio::const_buffer buffer) override + { + if (!stream || stream->closed) + { + std::move(handler)(errc::make_error_code(errc::connection_reset)); + return; + } + + stream->start_write(std::move(handler), buffer); + } + + // + // Part of the shared Writer-based interface (mirrors server::Response::Impl), but never + // actually invoked for a client::Request -- client.hpp does not expose async_submit() + // publicly. Kept only to satisfy the pure virtual. + // + void async_submit(StatusHandler&& handler, unsigned int /*status_code*/, + const Fields& /*headers*/) override + { + std::move(handler)(boost::system::error_code{}); + } + + void async_get_response(client::Request::GetResponseHandler&& handler) override + { + if (!stream) + { + std::move(handler)(errc::make_error_code(errc::connection_aborted), + client::Response{nullptr}); + return; + } + stream->async_get_response(std::move(handler)); + } + + void detach() override { stream = nullptr; } + + Http3ClientStream* stream; +}; + +class Http3ClientReader : public client::Response::Impl +{ +public: + explicit Http3ClientReader(Http3ClientStream& s) : stream(&s) { s.reader = this; } + ~Http3ClientReader() override + { + if (stream) + { + stream->reader = nullptr; + stream->delete_reader(); + } + } + + asio::any_io_executor get_executor() const noexcept override + { + assert(stream); + return stream->get_executor(); + } + + std::optional content_length() const noexcept override + { + return stream ? stream->content_length : std::nullopt; + } + + unsigned int status_code() const noexcept override { return stream ? stream->status_code : 0; } + + boost::url_view url() const override + { + assert(stream); + return stream->url; + } + + void async_read_some(asio::mutable_buffer buffer, ReadSomeHandler&& handler) override + { + if (!stream) + { + std::move(handler)(boost::beast::http::error::partial_message, 0); + return; + } + if (asio::buffer_size(buffer) == 0) + { + asio::any_completion_executor ex = + asio::get_associated_immediate_executor(handler, stream->get_executor()); + ex.execute([handler = std::move(handler)]() mutable + { std::move(handler)(boost::system::error_code{}, 0); }); + return; + } + + auto cs = asio::get_associated_cancellation_slot(handler); + if (cs.is_connected() && !cs.has_handler()) + { + cs.assign([this](asio::cancellation_type_t) + { + if (stream && stream->read_handler) + { + asio::post(stream->get_executor(), + [handler = std::move(stream->read_handler)]() mutable + { std::move(handler)(errc::make_error_code(errc::operation_canceled), 0); }); + } + }); + } + + assert(!stream->read_handler); + stream->read_handler = std::move(handler); + stream->read_handler_buffer = buffer; + stream->call_read_handler(); + } + + void detach() override { stream = nullptr; } + + Http3ClientStream* stream; +}; + +// ================================================================================================= +// Http3ClientStream implementation +// ================================================================================================= + +Http3ClientStream::Http3ClientStream(Http3ClientSession& s, int64_t stream_id) + : id(stream_id), session(s) +{ + log_prefix = std::format("{}.{}", session.logPrefix(), id); + logd("[{}] stream created", log_prefix); +} + +Http3ClientStream::~Http3ClientStream() +{ + logd("[{}] stream destroyed...", log_prefix); + // A Http3ClientWriter/Http3ClientReader (owned by the user-visible Request/Response) can + // outlive this stream, e.g. when the session tears down streams_ while a suspended coroutine + // still holds one. Detach them so their destructors don't dereference a freed stream. + if (reader) + reader->detach(); + if (writer) + writer->detach(); + if (read_handler) + swap_and_invoke(read_handler, errc::make_error_code(errc::connection_reset), 0); + if (!response_delivered && response_handler) + swap_and_invoke(response_handler, errc::make_error_code(errc::connection_reset), + client::Response{nullptr}); + if (write_active && write_handler) + swap_and_invoke(write_handler, errc::make_error_code(errc::connection_reset)); + logd("[{}] stream destroyed... done", log_prefix); +} + +asio::any_io_executor Http3ClientStream::get_executor() const noexcept +{ + return session.get_executor(); +} + +// ------------------------------------------------------------------------------------------------- + +void Http3ClientStream::on_data_chunk(const uint8_t* data, size_t len) +{ + if (len == 0) + return; + pending_read.emplace_back(data, data + len); + if (read_head.size() == 0) + read_head = asio::buffer(pending_read.front()); + call_read_handler(); +} + +void Http3ClientStream::on_eof() +{ + eof_received = true; + call_read_handler(); +} + +void Http3ClientStream::call_read_handler() +{ + // + // swap_and_invoke() below may resume a user coroutine that calls async_read_some() again + // before returning, which re-enters this function. Letting that nested call do real work would + // recurse once per buffered chunk -- with enough data queued up (e.g. after a large backlog + // drains), that blows the C++ stack. Instead, the nested call just re-arms read_handler and + // returns; the outer call's loop below picks it up and keeps going without growing the stack. + // + if (!read_handler || call_read_handler_active) + return; + + // + // The loop below may resume a coroutine that drops the last owning reference to this stream + // (e.g. the Response gets destroyed once EOF is delivered) -- or to the whole Session, when + // that coroutine was the last user of the connection. Keep both alive until this function + // returns: consume_stream() at the bottom dereferences the session, so outliving the stream + // alone is not enough. + // + auto self = shared_from_this(); + auto session_guard = session.shared_from_this(); + + call_read_handler_active = true; + size_t consumed = 0; + while (read_handler) + { + if (asio::buffer_size(read_head) > 0) + { + auto copied = asio::buffer_copy(read_handler_buffer, read_head); + read_head += copied; + consumed += copied; + if (read_head.size() == 0) + { + pending_read.pop_front(); + read_head = + pending_read.empty() ? asio::const_buffer{} : asio::buffer(pending_read.front()); + } + 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); + + // Only one async_write() may be active at a time -- see the class comment above write_active. + assert(!write_active); + + // + // 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; + } + + 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; + + // 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_confirmed == write_chunk.size() && write_source_copied == asio::buffer_size(write_source)) + finish_active_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 = {}; + 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(); + 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. + } + + // + // 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)); + + if (conn_ && !ngtcp2_conn_in_closing_period(conn_) && !ngtcp2_conn_in_draining_period(conn_)) + { + std::array closebuf; + ngtcp2_path_storage ps; + ngtcp2_pkt_info pi; + ngtcp2_path_storage_zero(&ps); + + auto nwrite = + ngtcp2_conn_write_connection_close(conn_, &ps.path, &pi, closebuf.data(), closebuf.size(), + &last_error_, ngtcp2::util::timestamp()); + if (nwrite > 0) + send_udp({closebuf.data(), static_cast(nwrite)}); + } + + boost::system::error_code ec; + socket_.cancel(ec); + timer_.cancel(); + signal_ready(); +} + +void Http3ClientSession::signal_ready() +{ + ready_signal_.expires_at(asio::steady_timer::time_point::min()); +} + +// ------------------------------------------------------------------------------------------------- + +std::shared_ptr Http3ClientSession::find_stream(int64_t id) +{ + auto it = streams_.find(id); + return it == streams_.end() ? nullptr : it->second; +} + +Http3ClientStream* Http3ClientSession::create_stream(int64_t id) +{ + auto [it, inserted] = streams_.emplace(id, std::make_shared(*this, id)); + return it->second.get(); +} + +void Http3ClientSession::erase_stream(int64_t id) { streams_.erase(id); } + +void Http3ClientSession::wake_write() +{ + // Capture a weak_ptr, not shared_from_this(): wake_write() can be reached from a + // Reader/Writer destructor that runs as part of *this* session's own teardown, at which + // point shared_from_this() would throw bad_weak_ptr. See the matching comment in the + // server's Http3Session::wake_write() (server_impl_udp.cpp). + asio::post(get_executor(), [self = weak_from_this()] + { + auto session = std::static_pointer_cast(self.lock()); + if (!session || session->closed_) + return; + if (session->write_streams() == 0) + session->update_timer(); + }); +} + +// ------------------------------------------------------------------------------------------------- + +void Http3ClientSession::send_udp(std::span data) +{ + boost::system::error_code ec; + socket_.send(asio::buffer(data.data(), data.size()), 0, ec); + if (ec && ec != asio::error::would_block && ec != asio::error::try_again) + logw("[{}] send: {}", log_prefix_, ec.message()); +} + +int Http3ClientSession::on_read(std::span data) +{ + logd("[{}] on_read: {} bytes", log_prefix_, data.size()); + + ngtcp2_pkt_info pi{}; + auto* path = ngtcp2_conn_get_path(conn_); + auto rv = + ngtcp2_conn_read_pkt(conn_, path, &pi, data.data(), data.size(), ngtcp2::util::timestamp()); + if (rv != 0) + { + if (rv == NGTCP2_ERR_DRAINING) + logd("[{}] ngtcp2_conn_read_pkt: draining", log_prefix_); + else + { + logw("[{}] ngtcp2_conn_read_pkt: {}", log_prefix_, ngtcp2_strerror(rv)); + if (rv == NGTCP2_ERR_CRYPTO && !last_error_.error_code) + ngtcp2_ccerr_set_tls_alert(&last_error_, ngtcp2_conn_get_tls_alert(conn_), nullptr, 0); + else if (!last_error_.error_code) + ngtcp2_ccerr_set_liberr(&last_error_, rv, nullptr, 0); + } + return handle_error(rv); + } + + if (auto wrv = write_streams(); wrv != 0) + return wrv; + + update_timer(); + return 0; +} + +// ------------------------------------------------------------------------------------------------- + +int Http3ClientSession::write_streams() +{ + if (ngtcp2_conn_in_closing_period(conn_) || ngtcp2_conn_in_draining_period(conn_)) + return 0; + + std::array buf; + ngtcp2_path_storage ps; + ngtcp2_pkt_info pi; + ngtcp2_path_storage_zero(&ps); + + std::array vec; + int64_t shut_down_stream = -1; // see NGTCP2_ERR_STREAM_NOT_FOUND below + + for (;;) + { + int64_t stream_id = -1; + int fin = 0; + nghttp3_ssize sveccnt = 0; + + if (h3_ && ngtcp2_conn_get_max_data_left(conn_)) + { + sveccnt = nghttp3_conn_writev_stream(h3_, &stream_id, &fin, vec.data(), vec.size()); + if (sveccnt < 0) + { + loge("[{}] nghttp3_conn_writev_stream: {}", log_prefix_, + nghttp3_strerror(static_cast(sveccnt))); + ngtcp2_ccerr_set_application_error( + &last_error_, nghttp3_err_infer_quic_app_error_code(static_cast(sveccnt)), + nullptr, 0); + return handle_error(NGTCP2_ERR_CALLBACK_FAILURE); + } + } + + ngtcp2_ssize ndatalen; + uint32_t flags = NGTCP2_WRITE_STREAM_FLAG_MORE; + if (fin) + flags |= NGTCP2_WRITE_STREAM_FLAG_FIN; + + auto nwrite = + ngtcp2_conn_writev_stream(conn_, &ps.path, &pi, buf.data(), buf.size(), &ndatalen, flags, + stream_id, reinterpret_cast(vec.data()), + static_cast(sveccnt), ngtcp2::util::timestamp()); + + if (nwrite < 0) + { + switch (nwrite) + { + case NGTCP2_ERR_STREAM_DATA_BLOCKED: + if (h3_ && stream_id >= 0) + nghttp3_conn_block_stream(h3_, stream_id); + continue; + case NGTCP2_ERR_STREAM_SHUT_WR: + if (h3_ && stream_id >= 0) + nghttp3_conn_shutdown_stream_write(h3_, stream_id); + continue; + case NGTCP2_ERR_STREAM_NOT_FOUND: + // + // ngtcp2 has already torn the stream down (the peer reset it, or we did) while + // nghttp3 still had request data queued for it. That's a dead stream, not a dead + // connection -- tell nghttp3 so it stops offering it and keep serving the others. + // Should nghttp3 offer the same stream again anyway, stop writing rather than + // spinning here forever. + // + if (h3_ && stream_id >= 0 && stream_id != shut_down_stream) + { + logw("[{}] write_streams: stream {} is gone, shutting down its write side", + log_prefix_, stream_id); + nghttp3_conn_shutdown_stream_write(h3_, stream_id); + nghttp3_conn_block_stream(h3_, stream_id); + shut_down_stream = stream_id; + continue; + } + return 0; + case NGTCP2_ERR_WRITE_MORE: + if (h3_ && stream_id >= 0 && ndatalen > 0) + { + if (auto rv = + nghttp3_conn_add_write_offset(h3_, stream_id, static_cast(ndatalen)); + rv != 0) + { + loge("[{}] nghttp3_conn_add_write_offset: {}", log_prefix_, nghttp3_strerror(rv)); + return handle_error(NGTCP2_ERR_CALLBACK_FAILURE); + } + if (auto s = find_stream(stream_id)) + s->on_write_consumed(static_cast(ndatalen)); + } + continue; + default: + loge("[{}] ngtcp2_conn_writev_stream: {}", log_prefix_, + ngtcp2_strerror(static_cast(nwrite))); + ngtcp2_ccerr_set_liberr(&last_error_, static_cast(nwrite), nullptr, 0); + return handle_error(static_cast(nwrite)); + } + } + + if (ndatalen > 0 && h3_ && stream_id >= 0) + { + if (auto rv = nghttp3_conn_add_write_offset(h3_, stream_id, static_cast(ndatalen)); + rv != 0) + { + loge("[{}] nghttp3_conn_add_write_offset: {}", log_prefix_, nghttp3_strerror(rv)); + return handle_error(NGTCP2_ERR_CALLBACK_FAILURE); + } + if (auto s = find_stream(stream_id)) + s->on_write_consumed(static_cast(ndatalen)); + } + + if (nwrite == 0) + { + ngtcp2_conn_update_pkt_tx_time(conn_, ngtcp2::util::timestamp()); + return 0; + } + + send_udp({buf.data(), static_cast(nwrite)}); + } +} + +// ------------------------------------------------------------------------------------------------- + +void Http3ClientSession::update_timer() { arm_timer_from_ngtcp2(); } + +void Http3ClientSession::arm_timer_from_ngtcp2() +{ + if (closed_) + return; + + auto expiry = ngtcp2_conn_get_expiry(conn_); + if (expiry == UINT64_MAX) + { + timer_.cancel(); + return; + } + + auto now = ngtcp2::util::timestamp(); + asio::steady_timer::duration delay = + expiry <= now ? std::chrono::nanoseconds{1} : std::chrono::nanoseconds{expiry - now}; + + timer_.expires_after(delay); + timer_.async_wait([self = weak_from_this()](const boost::system::error_code& ec) + { + if (ec) + return; + if (auto session = std::static_pointer_cast(self.lock())) + session->handle_expiry(); + }); +} + +int Http3ClientSession::handle_expiry() +{ + auto now = ngtcp2::util::timestamp(); + if (auto rv = ngtcp2_conn_handle_expiry(conn_, now); rv != 0) + { + logw("[{}] ngtcp2_conn_handle_expiry: {}", log_prefix_, ngtcp2_strerror(rv)); + ngtcp2_ccerr_set_liberr(&last_error_, rv, nullptr, 0); + return handle_error(rv); + } + if (auto rv = write_streams(); rv != 0) + return rv; + update_timer(); + return 0; +} + +// ------------------------------------------------------------------------------------------------- + +int Http3ClientSession::handle_error(int /*rv*/) +{ + close(); + return -1; +} + +// ------------------------------------------------------------------------------------------------- +// ngtcp2 callback implementations +// ------------------------------------------------------------------------------------------------- + +int Http3ClientSession::cb_handshake_completed(ngtcp2_conn*, void* user) +{ + auto self = static_cast(user); + logi("[{}] TLS handshake complete", self->log_prefix_); + if (!self->h3_ && self->setup_http3() != 0) + return NGTCP2_ERR_CALLBACK_FAILURE; + return 0; +} + +int Http3ClientSession::cb_recv_stream_data(ngtcp2_conn*, uint32_t flags, int64_t stream_id, + uint64_t offset, const uint8_t* data, size_t datalen, + void* user, void*) +{ + auto self = static_cast(user); + logd("[{}] cb_recv_stream_data: stream={} offset={} datalen={} fin={}", self->log_prefix_, + stream_id, offset, datalen, !!(flags & NGTCP2_STREAM_DATA_FLAG_FIN)); + if (!self->h3_) + return 0; + + auto nread = nghttp3_conn_read_stream(self->h3_, stream_id, data, datalen, + (flags & NGTCP2_STREAM_DATA_FLAG_FIN) ? 1 : 0); + if (nread < 0) + { + loge("[{}] nghttp3_conn_read_stream({}): {}", self->log_prefix_, stream_id, + nghttp3_strerror(static_cast(nread))); + ngtcp2_ccerr_set_application_error( + &self->last_error_, nghttp3_err_infer_quic_app_error_code(static_cast(nread)), + nullptr, 0); + return NGTCP2_ERR_CALLBACK_FAILURE; + } + + ngtcp2_conn_extend_max_stream_offset(self->conn_, stream_id, static_cast(nread)); + ngtcp2_conn_extend_max_offset(self->conn_, static_cast(nread)); + return 0; +} + +int Http3ClientSession::cb_acked_stream_data_offset(ngtcp2_conn*, int64_t stream_id, + uint64_t /*offset*/, uint64_t datalen, + void* user, void*) +{ + auto self = static_cast(user); + if (!self->h3_) + return 0; + if (auto rv = nghttp3_conn_add_ack_offset(self->h3_, stream_id, datalen); rv != 0) + { + loge("[{}] nghttp3_conn_add_ack_offset: {}", self->log_prefix_, nghttp3_strerror(rv)); + return NGTCP2_ERR_CALLBACK_FAILURE; + } + return 0; +} + +int Http3ClientSession::cb_stream_close(ngtcp2_conn*, uint32_t flags, int64_t stream_id, + uint64_t app_error_code, void* user, void*) +{ + auto self = static_cast(user); + if (!(flags & NGTCP2_STREAM_CLOSE_FLAG_APP_ERROR_CODE_SET)) + app_error_code = NGHTTP3_H3_NO_ERROR; + if (self->h3_) + { + if (auto rv = nghttp3_conn_close_stream(self->h3_, stream_id, app_error_code); rv != 0) + { + if (rv == NGHTTP3_ERR_STREAM_NOT_FOUND) + return 0; + loge("[{}] nghttp3_conn_close_stream({}): {}", self->log_prefix_, stream_id, + nghttp3_strerror(rv)); + return NGTCP2_ERR_CALLBACK_FAILURE; + } + } + return 0; +} + +void Http3ClientSession::cb_rand(uint8_t* dest, size_t destlen, const ngtcp2_rand_ctx*) +{ + if (RAND_bytes(dest, static_cast(destlen)) != 1) + std::memset(dest, 0, destlen); +} + +int Http3ClientSession::cb_get_new_connection_id(ngtcp2_conn*, ngtcp2_cid* cid, uint8_t* token, + size_t cidlen, void* /*user*/) +{ + if (RAND_bytes(cid->data, static_cast(cidlen)) != 1) + return NGTCP2_ERR_CALLBACK_FAILURE; + cid->datalen = cidlen; + if (RAND_bytes(token, NGTCP2_STATELESS_RESET_TOKENLEN) != 1) + return NGTCP2_ERR_CALLBACK_FAILURE; + return 0; +} + +int Http3ClientSession::cb_remove_connection_id(ngtcp2_conn*, const ngtcp2_cid*, void* /*user*/) +{ + return 0; +} + +int Http3ClientSession::cb_extend_max_local_streams_bidi(ngtcp2_conn*, uint64_t /*max_streams*/, + void* /*user*/) +{ + return 0; +} + +int Http3ClientSession::cb_stream_stop_sending(ngtcp2_conn*, int64_t stream_id, uint64_t /*ec*/, + void* user, void*) +{ + auto self = static_cast(user); + if (!self->h3_) + return 0; + if (auto rv = nghttp3_conn_shutdown_stream_read(self->h3_, stream_id); rv != 0) + { + loge("[{}] nghttp3_conn_shutdown_stream_read({}): {}", self->log_prefix_, stream_id, + nghttp3_strerror(rv)); + return NGTCP2_ERR_CALLBACK_FAILURE; + } + return 0; +} + +int Http3ClientSession::cb_stream_reset(ngtcp2_conn*, int64_t stream_id, uint64_t /*final_size*/, + uint64_t /*ec*/, void* user, void*) +{ + auto self = static_cast(user); + if (!self->h3_) + return 0; + if (auto rv = nghttp3_conn_shutdown_stream_read(self->h3_, stream_id); rv != 0) + { + loge("[{}] nghttp3_conn_shutdown_stream_read({}): {}", self->log_prefix_, stream_id, + nghttp3_strerror(rv)); + return NGTCP2_ERR_CALLBACK_FAILURE; + } + return 0; +} + +int Http3ClientSession::cb_extend_max_stream_data(ngtcp2_conn*, int64_t stream_id, + uint64_t /*max_data*/, void* user, void*) +{ + auto self = static_cast(user); + if (!self->h3_) + return 0; + if (auto rv = nghttp3_conn_unblock_stream(self->h3_, stream_id); rv != 0) + { + loge("[{}] nghttp3_conn_unblock_stream({}): {}", self->log_prefix_, stream_id, + nghttp3_strerror(rv)); + return NGTCP2_ERR_CALLBACK_FAILURE; + } + return 0; +} + +int Http3ClientSession::cb_recv_rx_key(ngtcp2_conn*, ngtcp2_encryption_level level, void* user) +{ + if (level != NGTCP2_ENCRYPTION_LEVEL_1RTT) + return 0; + auto self = static_cast(user); + if (!self->h3_ && self->setup_http3() != 0) + return NGTCP2_ERR_CALLBACK_FAILURE; + return 0; +} + +// ------------------------------------------------------------------------------------------------- + +int Http3ClientSession::setup_http3() +{ + if (h3_) + return 0; + + nghttp3_callbacks h3cb{}; + h3cb.stream_close = &Http3ClientSession::h3_cb_stream_close; + h3cb.recv_data = &Http3ClientSession::h3_cb_recv_data; + h3cb.deferred_consume = &Http3ClientSession::h3_cb_deferred_consume; + h3cb.begin_headers = &Http3ClientSession::h3_cb_begin_headers; + h3cb.recv_header = &Http3ClientSession::h3_cb_recv_header; + h3cb.end_headers = &Http3ClientSession::h3_cb_end_headers; + h3cb.end_stream = &Http3ClientSession::h3_cb_end_stream; + h3cb.stop_sending = &Http3ClientSession::h3_cb_stop_sending; + h3cb.reset_stream = &Http3ClientSession::h3_cb_reset_stream; + + nghttp3_settings settings; + nghttp3_settings_default(&settings); + settings.qpack_max_dtable_capacity = 4096; + settings.qpack_blocked_streams = 100; + + if (auto rv = nghttp3_conn_client_new(&h3_, &h3cb, &settings, nullptr, this); rv != 0) + { + loge("[{}] nghttp3_conn_client_new: {}", log_prefix_, nghttp3_strerror(rv)); + return -1; + } + + int64_t ctrl_stream_id = -1; + if (auto rv = ngtcp2_conn_open_uni_stream(conn_, &ctrl_stream_id, nullptr); rv != 0) + { + loge("[{}] open control stream: {}", log_prefix_, ngtcp2_strerror(rv)); + return -1; + } + if (auto rv = nghttp3_conn_bind_control_stream(h3_, ctrl_stream_id); rv != 0) + { + loge("[{}] nghttp3_conn_bind_control_stream: {}", log_prefix_, nghttp3_strerror(rv)); + return -1; + } + + int64_t qpack_enc_stream_id = -1; + int64_t qpack_dec_stream_id = -1; + if (ngtcp2_conn_open_uni_stream(conn_, &qpack_enc_stream_id, nullptr) != 0 || + ngtcp2_conn_open_uni_stream(conn_, &qpack_dec_stream_id, nullptr) != 0) + { + loge("[{}] open qpack streams failed", log_prefix_); + return -1; + } + if (auto rv = nghttp3_conn_bind_qpack_streams(h3_, qpack_enc_stream_id, qpack_dec_stream_id); + rv != 0) + { + loge("[{}] nghttp3_conn_bind_qpack_streams: {}", log_prefix_, nghttp3_strerror(rv)); + return -1; + } + + logi("[{}] HTTP/3 ready (ctrl={} qpack_enc={} qpack_dec={})", log_prefix_, ctrl_stream_id, + qpack_enc_stream_id, qpack_dec_stream_id); + signal_ready(); + return 0; +} + +// ------------------------------------------------------------------------------------------------- +// nghttp3 callbacks +// ------------------------------------------------------------------------------------------------- + +int Http3ClientSession::h3_cb_stream_close(nghttp3_conn*, int64_t stream_id, + uint64_t app_error_code, void* user, void*) +{ + auto self = static_cast(user); + logd("[{}] h3 stream {} closed", self->log_prefix_, stream_id); + if (auto s = self->find_stream(stream_id)) + { + auto ec = (app_error_code == NGHTTP3_H3_NO_ERROR) + ? boost::system::error_code{} + : errc::make_error_code(errc::connection_reset); + s->fail(ec); + } + return 0; +} + +int Http3ClientSession::h3_cb_recv_data(nghttp3_conn*, int64_t stream_id, const uint8_t* data, + size_t datalen, void* user, void*) +{ + // + // Connection-level credit is granted immediately: it is a single pool shared with control/QPACK + // streams that nghttp3 manages on its own (the app never "reads" those), so withholding it here + // would stall unrelated traffic whenever this one stream's reader is slow. Only the *stream*- + // level credit for these bytes is deliberately deferred -- see + // Http3ClientSession::consume_stream(). Granting it only once the application actually reads + // the data (in Http3ClientStream::call_read_handler()) is what makes response-body + // backpressure real instead of nghttp3 buffering an unbounded backlog in pending_read while + // the peer keeps sending on *this* stream. + // + auto self = static_cast(user); + ngtcp2_conn_extend_max_offset(self->conn_, datalen); + if (auto s = self->find_stream(stream_id)) + s->on_data_chunk(data, datalen); + return 0; +} + +int Http3ClientSession::h3_cb_deferred_consume(nghttp3_conn*, int64_t stream_id, size_t nconsumed, + void* user, void*) +{ + auto self = static_cast(user); + ngtcp2_conn_extend_max_stream_offset(self->conn_, stream_id, nconsumed); + ngtcp2_conn_extend_max_offset(self->conn_, nconsumed); + return 0; +} + +int Http3ClientSession::h3_cb_begin_headers(nghttp3_conn*, int64_t /*stream_id*/, void* /*user*/, + void*) +{ + // Nothing to do: the stream (and its Http3ClientStream) was already created synchronously in + // async_submit(), before the request headers were even submitted to nghttp3. Compare the + // server, where begin_headers is what creates the stream for a newly-received request. + return 0; +} + +int Http3ClientSession::h3_cb_recv_header(nghttp3_conn*, int64_t stream_id, int32_t /*token*/, + nghttp3_rcbuf* name, nghttp3_rcbuf* value, + uint8_t /*flags*/, void* user, void*) +{ + auto self = static_cast(user); + auto n = nghttp3_rcbuf_get_buf(name); + auto v = nghttp3_rcbuf_get_buf(value); + auto name_view = std::string_view{reinterpret_cast(n.base), n.len}; + auto value_view = std::string_view{reinterpret_cast(v.base), v.len}; + + auto s = self->find_stream(stream_id); + if (!s) + return 0; + + logd("[{}] \x1b[1;34m{}\x1b[0m: {}", s->log_prefix, name_view, value_view); + + try + { + if (name_view == ":status") + { + unsigned int status = 0; + if (std::from_chars(value_view.begin(), value_view.end(), status).ec == std::errc{}) + s->status_code = status; + } + else if (name_view == "content-length") + { + size_t len = 0; + if (std::from_chars(value_view.begin(), value_view.end(), len).ec == std::errc{}) + s->content_length = len; + } + else + s->response_fields.set(name_view, value_view); + } + catch (const std::exception& ex) + { + logw("[{}] ignoring invalid header: {} ({})", s->log_prefix, value_view, ex.what()); + } + return 0; +} + +int Http3ClientSession::h3_cb_end_headers(nghttp3_conn*, int64_t stream_id, int /*fin*/, void* user, + void*) +{ + auto self = static_cast(user); + auto s = self->find_stream(stream_id); + if (!s) + return 0; + + logd("[{}] response headers: status={}", s->log_prefix, s->status_code); + s->headers_received = true; + s->deliver_response(); + return 0; +} + +int Http3ClientSession::h3_cb_end_stream(nghttp3_conn*, int64_t stream_id, void* user, void*) +{ + auto self = static_cast(user); + if (auto s = self->find_stream(stream_id)) + s->on_eof(); + return 0; +} + +int Http3ClientSession::h3_cb_stop_sending(nghttp3_conn*, int64_t stream_id, + uint64_t app_error_code, void* user, void*) +{ + auto self = static_cast(user); + ngtcp2_conn_shutdown_stream_read(self->conn_, 0, stream_id, app_error_code); + return 0; +} + +int Http3ClientSession::h3_cb_reset_stream(nghttp3_conn*, int64_t stream_id, + uint64_t app_error_code, void* user, void*) +{ + auto self = static_cast(user); + ngtcp2_conn_shutdown_stream_write(self->conn_, 0, stream_id, app_error_code); + return 0; +} + +// ------------------------------------------------------------------------------------------------- + +namespace +{ +nghttp3_ssize client_stream_read_data(nghttp3_conn*, int64_t /*stream_id*/, nghttp3_vec* vec, + size_t veccnt, uint32_t* pflags, void* /*conn_user*/, + void* stream_user) +{ + auto s = static_cast(stream_user); + return s->data_reader(vec, veccnt, pflags); +} +} // namespace + +void Http3ClientSession::async_submit(SubmitHandler&& handler, boost::urls::url url, + const Fields& headers) +{ + if (closed_ || !h3_) + { + loge("[{}] async_submit: session not ready", log_prefix_); + std::move(handler)(errc::make_error_code(errc::operation_canceled), client::Request{nullptr}); + return; + } + + int64_t stream_id = -1; + if (auto rv = ngtcp2_conn_open_bidi_stream(conn_, &stream_id, nullptr); rv != 0) + { + loge("[{}] async_submit: ngtcp2_conn_open_bidi_stream: {}", log_prefix_, ngtcp2_strerror(rv)); + std::move(handler)(errc::make_error_code(errc::invalid_argument), client::Request{nullptr}); + return; + } + + auto* stream = create_stream(stream_id); + stream->url = url; + + // + // TODO: CONNECT / other methods -- mirrors the h2 client's NGHttp2Session::async_submit(), + // which is likewise hard-coded to POST. + // + std::string method("POST"); + std::string scheme(url.scheme()); + std::string target(url.encoded_target()); + std::string authority(url.host_address()); + + std::vector nva; + nva.reserve(16); // small typical header count; vector will grow if needed + nva.push_back(make_nv(":method", method)); + nva.push_back(make_nv(":scheme", scheme)); + nva.push_back(make_nv(":path", target)); + nva.push_back(make_nv(":authority", authority)); + + for (auto&& item : headers) + { + if (item.name_string().starts_with(':')) + logw("[{}] async_submit: invalid header '{}': setting pseudo headers is not allowed", + stream->log_prefix, item.name_string()); + nva.push_back(make_nv(item.name_string(), item.value())); + } + + nghttp3_data_reader dr{}; + dr.read_data = &client_stream_read_data; + + if (auto rv = nghttp3_conn_submit_request(h3_, stream_id, nva.data(), nva.size(), &dr, stream); + rv != 0) + { + loge("[{}] nghttp3_conn_submit_request: {}", log_prefix_, nghttp3_strerror(rv)); + erase_stream(stream_id); + std::move(handler)(errc::make_error_code(errc::invalid_argument), client::Request{nullptr}); + return; + } + + logd("[{}] async_submit: new stream ID: {}", stream->log_prefix, stream_id); + wake_write(); + + post(get_executor(), [handler = std::move(handler), + writer = std::make_unique(*stream)]() mutable + { std::move(handler)(boost::system::error_code{}, client::Request{std::move(writer)}); }); +} + +// ================================================================================================= +// Entry point used by Client::Impl::async_connect() for Protocol::h3. +// ================================================================================================= + +awaitable> async_connect_http3(asio::any_io_executor executor, + std::string host, std::string port) +{ + boost::asio::ip::udp::resolver resolver(executor); + auto flags = boost::asio::ip::udp::resolver::numeric_service; + auto results = co_await resolver.async_resolve(host, port, flags); // may throw + + auto session = std::make_shared(executor); + if (session->init(results.begin()->endpoint()) != 0) + throw boost::system::system_error(errc::make_error_code(errc::connection_refused)); + + std::shared_ptr impl = session; + +#if 1 + co_spawn(executor, impl->do_session(Buffer{}), [impl](const std::exception_ptr& ex) mutable + { + if (ex) + logw("client run: {}", what(ex)); + else + logi("client run: done"); + impl.reset(); + }); +#endif + + // + // Note: wait_ready() uses a sentinel steady_timer as a one-shot gate (see the comment on + // ready_signal_ / signal_ready()). Rearming a timer that already has a pending async_wait() + // cancels that wait with operation_aborted rather than completing it successfully -- so the + // *error code* here doesn't tell us anything; whether the handshake actually succeeded is + // reflected in ready() instead. + // + boost::system::error_code ec; + co_await session->wait_ready(redirect_error(use_awaitable, ec)); + if (!session->ready()) + throw boost::system::system_error(errc::make_error_code(errc::connection_refused)); + + co_return std::static_pointer_cast(session); +} + +// ================================================================================================= + +} // namespace anyhttp::client diff --git a/src/common.cpp b/src/common.cpp index 374b0a8..1ff2de6 100644 --- a/src/common.cpp +++ b/src/common.cpp @@ -13,6 +13,8 @@ std::string to_string(Protocol protocol) return "HTTP11"; case Protocol::h2: return "HTTP2"; + case Protocol::h3: + return "HTTP3"; default: return std::format("UNKNOWN ({})", std::to_underlying(protocol)); } diff --git a/src/nghttp2_session.cpp b/src/nghttp2_session.cpp index 54bf883..6aec43b 100644 --- a/src/nghttp2_session.cpp +++ b/src/nghttp2_session.cpp @@ -423,7 +423,7 @@ void NGHttp2Session::async_submit(SubmitHandler&& handler, boost::urls::url url, } for (auto nv : nva) - mlogd(" {}", nv); + logd("[{0}] \x1b[1;34m{1:n}\x1b[0m: {1:v}", stream->logPrefix, nv); // // https://nghttp2.org/documentation/types.html#c.nghttp2_data_source_read_callback diff --git a/src/ngtcp2/CMakeLists.txt b/src/ngtcp2/CMakeLists.txt index 74ec2f6..b923e3c 100644 --- a/src/ngtcp2/CMakeLists.txt +++ b/src/ngtcp2/CMakeLists.txt @@ -1,6 +1,5 @@ -pkg_check_modules(NGHTTP3 REQUIRED IMPORTED_TARGET libnghttp3) -pkg_check_modules(NGTCP2 REQUIRED IMPORTED_TARGET libngtcp2) -pkg_check_modules(NGTCP2_CRYPTO_OSSL REQUIRED IMPORTED_TARGET libngtcp2_crypto_ossl) +# NGTCP2, NGHTTP3, NGTCP2_CRYPTO_OSSL are declared at parent scope in ../CMakeLists.txt +# so they can be linked into the main anyhttp library as well. pkg_check_modules(NGTCP2_CRYPTO_BORINGSSL REQUIRED IMPORTED_TARGET libngtcp2_crypto_boringssl) # diff --git a/src/request_handlers.cpp b/src/request_handlers.cpp index 95723a2..0c62b12 100644 --- a/src/request_handlers.cpp +++ b/src/request_handlers.cpp @@ -50,9 +50,8 @@ namespace anyhttp awaitable yield(size_t count) { - auto executor = co_await asio::this_coro::executor; for (size_t i = 0; i < count; ++i) - co_await post(executor, asio::deferred); + co_await post(asio::deferred); } awaitable dump(server::Request request, server::Response response) @@ -72,11 +71,10 @@ awaitable dump(server::Request request, server::Response response) std::println(str, " {}={} ({})", key, EscapedString(value), _); std::println(str, "fragment: {} ({})", url.fragment(), url.encoded_fragment()); - Fields fields; - fields.set("Content-Length", std::format("{}", str.str().size())); - fields.set("Content-Type", "text/plain"); - co_await response.async_submit(200, fields); - co_await response.async_write(asio::buffer(str.str())); + auto body = str.str(); + co_await response.async_submit( + 200, fields({{"Content-Length", body.size()}, {"Content-Type", "text/plain"}})); + co_await response.async_write(asio::buffer(body)); co_await response.async_write({}, deferred); } @@ -281,11 +279,13 @@ awaitable send_eof(client::Request& request) awaitable h2spec(server::Request request, server::Response response) { - co_await yield(10); + co_await yield(10); // FIXME: without this, one more testcase fails std::array buffer; size_t n = co_await request.async_read_some(asio::buffer(buffer)); - co_await response.async_submit(200, {}); - co_await response.async_write(asio::buffer("Hello, World!\n"sv)); + + constexpr auto hello = "Hello, World!\n"sv; + co_await response.async_submit(200, fields({{"Content-Length", hello.size()}})); + co_await response.async_write(asio::buffer(hello)); co_await response.async_write({}); while (co_await request.async_read_some(asio::buffer(buffer)) > 0) ; diff --git a/src/server_impl.cpp b/src/server_impl.cpp index 9db6575..ccbfe5d 100644 --- a/src/server_impl.cpp +++ b/src/server_impl.cpp @@ -103,6 +103,18 @@ void Server::Impl::destroy() if (m_acceptor) m_acceptor->close(); // breaks listen_loop() + // + // Destroy all active sessions (TCP and QUIC) so their timers and async operations are + // cancelled, allowing the io_context to drain. QUIC sessions send a final CONNECTION_CLOSE + // through the (still shared) UDP socket as part of destroy(), so this has to happen before + // that socket is closed below -- otherwise the peer only finds out via idle timeout. + // + { + auto lock = std::lock_guard(m_sessionMutex); + for (auto& session : m_sessions) + session->destroy(); + } + if (m_udp_socket) m_udp_socket->close(); // breaks udp_receive_loop() @@ -147,21 +159,37 @@ void Server::Impl::listen_tcp() void Server::Impl::listen_udp() { // - // QUIC test -- open a UDP port + // 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. // - // m_udp_socket.emplace(m_executor, ip::udp::endpoint(ip::udp::v6(), config().port)); + assert(m_acceptor); + auto tcp_ep = m_acceptor->local_endpoint(); + const bool is_v6 = tcp_ep.protocol() == ip::tcp::v6(); + m_udp_socket.emplace(m_executor); - m_udp_socket->open(ip::udp::v6()); - // 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->set_option(boost::asio::ip::v6_only{true}); - 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->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); - m_udp_socket->bind(ip::udp::endpoint(ip::udp::v6(), config().port)); + + ip::udp::endpoint udp_ep(tcp_ep.address(), tcp_ep.port()); + m_udp_socket->bind(udp_ep); + logi("Server: UDP listening on {}", udp_ep); } // ================================================================================================= @@ -242,7 +270,6 @@ awaitable Server::Impl::handleConnection(ip::tcp::socket socket) // socket.set_option(sb::send_buffer_size(8192)); // socket.set_option(sb::receive_buffer_size(8192)); // makes 'PostRange' testcases very slow - auto executor = co_await boost::asio::this_coro::executor; auto buffer = boost::beast::flat_buffer(); @@ -260,10 +287,10 @@ awaitable Server::Impl::handleConnection(ip::tcp::socket socket) SSL_CTX_set_alpn_select_cb(ctx.native_handle(), alpn_select_proto_cb, NULL); // - // TODO: This is a testing key only. Still, we might want to remove it from the repository - // to avoid flagging repository scanners. + // This is a testing key only. It is not in the repository, but generated at build time + // by the 'pki' target (see cmake/pki.cmake). // - ctx.use_certificate_chain_file("pki/out/server.pem"); + ctx.use_certificate_chain_file("pki/out/server-chain.pem"); ctx.use_private_key_file("pki/out/server-key.pem", asio::ssl::context::pem); ssl_stream.emplace(std::move(socket), ctx); diff --git a/src/server_impl_udp.cpp b/src/server_impl_udp.cpp index 06243a1..fd0d07e 100644 --- a/src/server_impl_udp.cpp +++ b/src/server_impl_udp.cpp @@ -1,81 +1,2173 @@ +// +// 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. +// +// Not yet implemented: retry tokens, version negotiation, stateless reset, connection +// migration, ECN, client-side (async_submit is a no-op). +// + +#include "anyhttp/client_impl.hpp" +#include "anyhttp/formatter.hpp" // IWYU pragma: keep #include "anyhttp/literals.hpp" +#include "anyhttp/request_handlers.hpp" // IWYU pragma: keep #include "anyhttp/server_impl.hpp" +#include "anyhttp/session_impl.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include + +#include +#include + +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ngtcp2/shared.h" +#include "ngtcp2/util.h" + +using namespace std::chrono_literals; +using namespace boost::asio; +namespace errc = boost::system::errc; + +namespace anyhttp::server +{ + +// ================================================================================================= + +struct Endpoint +{ + ngtcp2::Address addr; + int fd; +}; + +// ================================================================================================= +// Free-standing helpers +// ================================================================================================= + +namespace +{ + +constexpr size_t QUIC_SCIDLEN = 18; + +// +// One-shot process-wide initialization of ngtcp2_crypto_ossl and the OpenSSL SSL_CTX +// used for every QUIC connection. +// +struct TlsServerContext +{ + TlsServerContext() + { + static const int init_once = [] + { + if (ngtcp2_crypto_ossl_init() != 0) + throw std::runtime_error("ngtcp2_crypto_ossl_init"); + return 0; + }(); + (void)init_once; + + ctx = SSL_CTX_new(TLS_server_method()); + if (!ctx) + throw std::runtime_error("SSL_CTX_new"); + + SSL_CTX_set_options(ctx, (SSL_OP_ALL & ~SSL_OP_DONT_INSERT_EMPTY_FRAGMENTS) | + SSL_OP_SINGLE_ECDH_USE | SSL_OP_CIPHER_SERVER_PREFERENCE | + SSL_OP_NO_ANTI_REPLAY); + SSL_CTX_set_mode(ctx, SSL_MODE_RELEASE_BUFFERS); + + SSL_CTX_set_alpn_select_cb(ctx, &TlsServerContext::alpn_select_cb, nullptr); + + if (SSL_CTX_use_PrivateKey_file(ctx, "pki/out/server-key.pem", SSL_FILETYPE_PEM) != 1) + throw std::runtime_error(std::string{"SSL_CTX_use_PrivateKey_file: "} + + ERR_error_string(ERR_get_error(), nullptr)); + + if (SSL_CTX_use_certificate_chain_file(ctx, "pki/out/server-chain.pem") != 1) + throw std::runtime_error(std::string{"SSL_CTX_use_certificate_chain_file: "} + + ERR_error_string(ERR_get_error(), nullptr)); + + if (SSL_CTX_check_private_key(ctx) != 1) + throw std::runtime_error("SSL_CTX_check_private_key"); + } + + ~TlsServerContext() + { + if (ctx) + SSL_CTX_free(ctx); + } + + TlsServerContext(const TlsServerContext&) = delete; + TlsServerContext& operator=(const TlsServerContext&) = delete; + + static int alpn_select_cb(SSL*, const unsigned char** out, unsigned char* outlen, + const unsigned char* in, unsigned int inlen, void*) + { + for (auto s = std::span{in, inlen}; s.size() >= 3; s = s.subspan(s[0] + 1)) + { + if (s[0] == 2 && s[1] == 'h' && s[2] == '3') + { + *out = &s[1]; + *outlen = 2; + return SSL_TLSEXT_ERR_OK; + } + } + return SSL_TLSEXT_ERR_ALERT_FATAL; + } + + SSL_CTX* ctx = nullptr; +}; + +TlsServerContext& tls_context() +{ + static TlsServerContext instance; + return instance; +} + +// ------------------------------------------------------------------------------------------------- + +std::string cid_key(const ngtcp2_cid& cid) +{ + return std::string{reinterpret_cast(cid.data), cid.datalen}; +} + +std::string cid_key(const uint8_t* data, size_t len) +{ + return std::string{reinterpret_cast(data), len}; +} + +// ------------------------------------------------------------------------------------------------- + +int send_udp(int fd, const sockaddr* sa, socklen_t salen, std::span data) +{ + for (;;) + { + auto n = ::sendto(fd, data.data(), data.size(), 0, sa, salen); + if (n == -1) + { + if (errno == EINTR) + continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) + return 0; // best-effort; ngtcp2 will retransmit + loge("sendto: {}", strerror(errno)); + return -1; + } + return 0; + } +} + +// ------------------------------------------------------------------------------------------------- + +// Sends a run of same-sized packets (as produced by ngtcp2_conn_write_aggregate_pkt2(), all but +// the last exactly `gso_size` bytes) with a single sendmsg() using UDP_SEGMENT (GSO), so N QUIC +// packets cost one syscall instead of N. Falls back to one sendto() per segment -- and remembers +// to do so from then on -- if the kernel/NIC doesn't support UDP_SEGMENT here. +int send_udp_gso(int fd, const sockaddr* sa, socklen_t salen, std::span data, + size_t gso_size, bool& no_gso) +{ + if (no_gso || data.size() <= gso_size) + { + for (; !data.empty();) + { + auto len = std::min(gso_size, data.size()); + if (send_udp(fd, sa, salen, data.first(len)) != 0) + return -1; + data = data.subspan(len); + } + return 0; + } + + iovec msg_iov{const_cast(data.data()), data.size()}; + uint8_t msg_ctrl[CMSG_SPACE(sizeof(uint16_t))]; + msghdr msg{}; + msg.msg_name = const_cast(sa); + msg.msg_namelen = salen; + msg.msg_iov = &msg_iov; + msg.msg_iovlen = 1; + msg.msg_control = msg_ctrl; + msg.msg_controllen = sizeof(msg_ctrl); + + auto* cm = CMSG_FIRSTHDR(&msg); + cm->cmsg_level = SOL_UDP; + cm->cmsg_type = UDP_SEGMENT; + cm->cmsg_len = CMSG_LEN(sizeof(uint16_t)); + auto seg = static_cast(gso_size); + memcpy(CMSG_DATA(cm), &seg, sizeof(seg)); + + for (;;) + { + auto n = ::sendmsg(fd, &msg, 0); + if (n == -1) + { + if (errno == EINTR) + continue; + if (errno == EAGAIN || errno == EWOULDBLOCK) + return 0; // best-effort; ngtcp2 will retransmit + if (errno == EINVAL || errno == EOPNOTSUPP) + { + // GSO unsupported on this socket/NIC: fall back permanently and resend as + // individual datagrams. + no_gso = true; + return send_udp_gso(fd, sa, salen, data, gso_size, no_gso); + } + loge("sendmsg (GSO): {}", strerror(errno)); + return -1; + } + return 0; + } +} + +nghttp3_nv make_nv(std::string_view name, std::string_view value) +{ + nghttp3_nv nv{}; + nv.name = reinterpret_cast(const_cast(name.data())); + nv.namelen = name.size(); + nv.value = reinterpret_cast(const_cast(value.data())); + nv.valuelen = value.size(); + nv.flags = NGHTTP3_NV_FLAG_NONE; + return nv; +} + +void ngtcp2_log_printf(void* /*user*/, const char* fmt, ...) noexcept +{ + if (!spdlog::default_logger()->should_log(spdlog::level::trace)) + return; + std::array buf; + va_list ap; + va_start(ap, fmt); + std::vsnprintf(buf.data(), buf.size(), fmt, ap); + va_end(ap); + spdlog::trace("{}", buf.data()); +} + +} // namespace + +// ================================================================================================= +// Http3Stream: per-request state. +// ================================================================================================= + +class Http3Session; +class Http3Stream; + +// +// Bound on how much of the caller's async_write() buffer we copy into write_chunk at a time (see +// Http3Stream's write_* members) -- copying is paced by how much nghttp3/ngtcp2 actually drains, +// rather than copying a huge caller buffer in one synchronous allocation+memcpy, mirroring +// nghttp2's own per-call copy into its frame buffer. +// +inline constexpr size_t kWriteChunkSize = 16 * 1024; + +class Http3Stream : public std::enable_shared_from_this +{ +public: + Http3Stream(Http3Session& session, int64_t id); + ~Http3Stream(); + + int64_t id; + Http3Session& session; + std::string log_prefix; + + // + // Request state (populated by nghttp3 header callbacks). + // + std::string method; + boost::urls::url url; + std::optional content_length; + Fields request_fields; + + // + // 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 + 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, 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). + // + // write_chunk is a bounded (<= kWriteChunkSize) slice of write_source, lazily refilled by + // data_reader() as it's drained. 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; // user signalled EOF via empty write + bool eof_sent_to_h3 = false; // NGHTTP3_DATA_FLAG_EOF returned + + // + // Lifecycle. + // + impl::Reader* reader = nullptr; // pointer back to the Http3Reader when attached + impl::Writer* writer = nullptr; // pointer back to the Http3Writer when attached + bool closed = false; // set in h3_cb_stream_close + + asio::any_io_executor get_executor() const noexcept; + + const std::string& logPrefix() const noexcept { return log_prefix; } + + // Data flow into user land. + void on_data_chunk(const uint8_t* data, size_t len); + void on_eof(); + void call_read_handler(); + + // Data flow from user land back to nghttp3. + void submit_response(); + void start_write(WriteHandler&& handler, asio::const_buffer buffer); + nghttp3_ssize data_reader(nghttp3_vec* vec, size_t veccnt, uint32_t* pflags); + void on_write_consumed(size_t n); + +private: + void bind_write_cancellation(WriteHandler& handler, uint64_t token); // arms cancellation + void finish_active_write(); // completes the active write once fully handed to nghttp3 + +public: + // Called from either reader or writer destructor. + void delete_reader(); + void delete_writer(); + void maybe_close(); +}; + +// ================================================================================================= +// Http3Session: one QUIC connection, one anyhttp Session::Impl. +// ================================================================================================= + +class Http3Session : public Session::Impl +{ +public: + Http3Session(Server::Impl& server, Endpoint ep, ngtcp2::Address remote); + ~Http3Session() override; + + // + // Session::Impl + // + asio::any_io_executor get_executor() const noexcept override { return server_.get_executor(); } + void async_submit(SubmitHandler&& handler, boost::urls::url, const Fields&) override; + awaitable do_session(Buffer&& data) override; + void destroy() noexcept override; + + // + // Interface used by the UDP demux in Server::Impl. + // + int init(const ngtcp2_cid& dcid, const ngtcp2_cid& scid, uint32_t version, + const ngtcp2_pkt_info& pi, std::span data); + int on_read(const ngtcp2_pkt_info& pi, std::span data, + const ngtcp2::Address& remote); + int write_streams(); + ngtcp2_ssize write_pkt(ngtcp2_path* path, ngtcp2_pkt_info* pi, uint8_t* dest, size_t destlen, + ngtcp2_tstamp ts); + void update_timer(); + int handle_expiry(); + + const ngtcp2_cid& scid() const noexcept { return scid_; } + ngtcp2_conn* conn() const noexcept { return conn_; } + bool closed() const noexcept { return closed_; } + const std::string& logPrefix() const noexcept { return log_prefix_; } + Server::Impl& server() noexcept { return server_; } + nghttp3_conn* h3() const noexcept { return h3_; } + + // Called by Http3Writer/Reader to make sure the write loop runs after new data was queued. + void wake_write(); + + // + // Grants the peer more *stream*-level send credit for `n` bytes of request body just delivered + // to the application. Deliberately NOT called as data arrives (see h3_cb_recv_data) -- only + // once call_read_handler() actually hands bytes to the app, so a slow/absent reader keeps the + // peer's flow control window for *this stream* genuinely constrained instead of nghttp3 + // buffering an unbounded backlog in pending_read. Connection-level credit is granted eagerly + // regardless (see h3_cb_recv_data) since it's a pool shared with control/QPACK streams nghttp3 + // manages on its own. + // + void consume_stream(int64_t stream_id, size_t n) + { + if (n == 0) + return; + ngtcp2_conn_extend_max_stream_offset(conn_, stream_id, n); + wake_write(); // a WINDOW_UPDATE-equivalent frame needs to go out + } + + // + // Abort both directions of the stream (RESET_STREAM + STOP_SENDING), the QUIC equivalent of + // HTTP/2's RST_STREAM. nghttp3 learns of the dead write side through the existing + // NGTCP2_ERR_STREAM_SHUT_WR handling in write_streams(). + // + void reset_stream(int64_t stream_id, uint64_t app_error_code) + { + ngtcp2_conn_shutdown_stream(conn_, 0, stream_id, app_error_code); + wake_write(); + } + + // + // Half-close just our read direction (STOP_SENDING), telling the peer to stop sending the + // request body while the response we are still writing keeps flowing. Fires the local + // stream_stop_sending callback, which is what tells nghttp3 about it. + // + void stop_reading(int64_t stream_id, uint64_t app_error_code) + { + ngtcp2_conn_shutdown_stream_read(conn_, 0, stream_id, app_error_code); + wake_write(); + } + + // Called from udp_on_read() when a packet arrives during the closing period. + void resend_conn_close(); + + // + // Returns a shared_ptr, not a raw pointer: callers routinely invoke user handlers on the + // stream they looked up, and those can drop the last reference to it (the coroutine they + // resume destroying its Request/Response), which erases the stream from streams_. Holding + // an owning reference for the duration of the lookup keeps that from becoming a + // use-after-free. + // + std::shared_ptr find_stream(int64_t id); + Http3Stream* create_stream(int64_t id); + void erase_stream(int64_t id); + + // + // ngtcp2 <-> ngtcp2_crypto_ossl bridge. + // + static ngtcp2_conn* get_conn(ngtcp2_crypto_conn_ref* ref) + { + return static_cast(ref->user_data)->conn_; + } + + // + // ngtcp2 callback bridges + // + static int cb_handshake_completed(ngtcp2_conn*, void* user); + static int cb_recv_stream_data(ngtcp2_conn*, uint32_t flags, int64_t stream_id, uint64_t offset, + const uint8_t* data, size_t datalen, void* user, void*); + static int cb_acked_stream_data_offset(ngtcp2_conn*, int64_t stream_id, uint64_t offset, + uint64_t datalen, void* user, void*); + static int cb_stream_open(ngtcp2_conn*, int64_t stream_id, void* user); + static int cb_stream_close(ngtcp2_conn*, uint32_t flags, int64_t stream_id, + uint64_t app_error_code, void* user, void*); + static void cb_rand(uint8_t* dest, size_t destlen, const ngtcp2_rand_ctx*); + static int cb_get_new_connection_id(ngtcp2_conn*, ngtcp2_cid* cid, uint8_t* token, size_t cidlen, + void* user); + static int cb_remove_connection_id(ngtcp2_conn*, const ngtcp2_cid* cid, void* user); + static int cb_extend_max_remote_streams_bidi(ngtcp2_conn*, uint64_t max_streams, void* user); + static int cb_stream_stop_sending(ngtcp2_conn*, int64_t stream_id, uint64_t app_error_code, + void* user, void*); + static int cb_stream_reset(ngtcp2_conn*, int64_t stream_id, uint64_t final_size, + uint64_t app_error_code, void* user, void*); + static int cb_extend_max_stream_data(ngtcp2_conn*, int64_t stream_id, uint64_t max_data, + void* user, void*); + static int cb_recv_rx_key(ngtcp2_conn*, ngtcp2_encryption_level level, void* user); + + // + // nghttp3 callback bridges + // + static int h3_cb_stream_close(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, + void* user, void*); + static int h3_cb_recv_data(nghttp3_conn*, int64_t stream_id, const uint8_t* data, size_t datalen, + void* user, void*); + static int h3_cb_deferred_consume(nghttp3_conn*, int64_t stream_id, size_t nconsumed, void* user, + void*); + static int h3_cb_begin_headers(nghttp3_conn*, int64_t stream_id, void* user, void*); + static int h3_cb_recv_header(nghttp3_conn*, int64_t stream_id, int32_t token, + nghttp3_rcbuf* name, nghttp3_rcbuf* value, uint8_t flags, + void* user, void*); + static int h3_cb_end_headers(nghttp3_conn*, int64_t stream_id, int fin, void* user, void*); + static int h3_cb_end_stream(nghttp3_conn*, int64_t stream_id, void* user, void*); + static int h3_cb_stop_sending(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, + void* user, void*); + static int h3_cb_reset_stream(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, + void* user, void*); + +private: + int setup_http3(); + int handle_error(int rv); + void arm_timer_from_ngtcp2(); + void signal_done(); + void schedule_close_timer(); + +private: + Server::Impl& server_; + Endpoint ep_; + ngtcp2::Address remote_; + 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_; + + std::vector conn_closebuf_; // buffered CONNECTION_CLOSE packet + + // Aggregated TX buffer: ngtcp2_conn_write_aggregate_pkt2() packs as many same-sized + // packets as it can (control/QPACK streams, response data, ...) into this buffer so + // they can all be flushed with a single sendmsg()+UDP_SEGMENT (GSO) call instead of + // one sendto() per QUIC packet. + std::vector tx_buf_ = std::vector(64_k); + bool no_gso_ = false; + + std::unordered_map> streams_; +}; + +// ================================================================================================= +// Http3Reader / Http3Writer: adapter classes that plug Http3Stream into the anyhttp +// Reader/Writer interfaces. Server-side only; the client-side templates come later. +// ================================================================================================= + +template +class Http3Reader : public Interface +{ +public: + explicit Http3Reader(Http3Stream& s) : stream(&s) { s.reader = this; } + ~Http3Reader() override + { + if (stream) + { + stream->reader = nullptr; + stream->delete_reader(); + } + } + + asio::any_io_executor get_executor() const noexcept override + { + assert(stream); + return stream->get_executor(); + } + + std::optional content_length() const noexcept override + { + return stream ? stream->content_length : std::nullopt; + } + + unsigned int status_code() const noexcept override + { + // Server-side Request; status doesn't apply, but the interface requires it. + return 0; + } + + boost::url_view url() const override + { + assert(stream); + return stream->url; + } + + void async_read_some(asio::mutable_buffer buffer, ReadSomeHandler&& handler) override + { + if (!stream) + { + std::move(handler)(boost::beast::http::error::partial_message, 0); + return; + } + if (asio::buffer_size(buffer) == 0) + { + std::move(handler)(boost::system::error_code{}, 0); + return; + } + + assert(!stream->read_handler); + stream->read_handler = std::move(handler); + stream->read_handler_buffer = buffer; + stream->call_read_handler(); + } + + void detach() override { stream = nullptr; } + + Http3Stream* stream; +}; + +template +class Http3Writer : public Base +{ +public: + explicit Http3Writer(Http3Stream& s) : stream(&s) { s.writer = this; } + ~Http3Writer() override + { + if (stream) + { + stream->writer = nullptr; + stream->delete_writer(); + } + } + + asio::any_io_executor get_executor() const noexcept override + { + assert(stream); + return stream->get_executor(); + } + + void content_length(std::optional len) override + { + assert(stream); + stream->response_content_length = len; + } + + void async_write(WriteHandler&& handler, asio::const_buffer buffer) override + { + if (!stream || stream->closed) + { + std::move(handler)(errc::make_error_code(errc::connection_reset)); + return; + } + + stream->start_write(std::move(handler), buffer); + } + + void async_submit(StatusHandler&& handler, unsigned int status_code, const Fields& fields) + { + if (!stream || stream->closed) + { + std::move(handler)(errc::make_error_code(errc::connection_reset)); + return; + } + stream->response_status = status_code; + stream->response_fields = fields; + stream->submit_response(); + stream->session.wake_write(); + + std::move(handler)(boost::system::error_code{}); + } + + void detach() override { stream = nullptr; } + + Http3Stream* stream; +}; + +// ================================================================================================= +// Http3Stream implementation +// ================================================================================================= + +Http3Stream::Http3Stream(Http3Session& s, int64_t stream_id) : id(stream_id), session(s) +{ + log_prefix = std::format("{}.{}", session.logPrefix(), id); + logd("\x1b[1;33mStream: ctor\x1b[0m"); +} + +Http3Stream::~Http3Stream() +{ + mlogd("\x1b[33mStream: dtor... \x1b[0m"); + // A Http3Writer/Http3Reader (owned by the user-visible Request/Response) can outlive this + // stream, e.g. when the session tears down streams_ while a suspended coroutine still holds + // one. Detach them so their destructors don't dereference a freed stream. + if (reader) + reader->detach(); + if (writer) + writer->detach(); + if (read_handler) + swap_and_invoke(read_handler, errc::make_error_code(errc::connection_reset), 0); + if (write_active && write_handler) + swap_and_invoke(write_handler, errc::make_error_code(errc::connection_reset)); + mlogd("\x1b[33mStream: dtor... done\x1b[0m"); +} + +asio::any_io_executor Http3Stream::get_executor() const noexcept { return session.get_executor(); } + +// ------------------------------------------------------------------------------------------------- + +void Http3Stream::on_data_chunk(const uint8_t* data, size_t len) +{ + if (len == 0) + return; + pending_read.emplace_back(data, data + len); + if (read_head.size() == 0) + read_head = asio::buffer(pending_read.front()); + call_read_handler(); +} + +void Http3Stream::on_eof() +{ + eof_received = true; + call_read_handler(); +} + +void Http3Stream::call_read_handler() +{ + // + // swap_and_invoke() below may resume a user coroutine that calls async_read_some() again + // before returning, which re-enters this function. Letting that nested call do real work would + // recurse once per buffered chunk -- with enough data queued up, that blows the C++ stack. + // Instead, the nested call just re-arms read_handler and returns; the outer call's loop below + // picks it up and keeps going without growing the stack. See the client-side counterpart, + // Http3ClientStream::call_read_handler() in client_impl_udp.cpp. + // + if (!read_handler || call_read_handler_active) + return; + + // + // The loop below may resume a coroutine that drops the last owning reference to this stream -- + // or to the whole Session, when that coroutine was the last user of the connection. Keep both + // alive until this function returns: consume_stream() at the bottom dereferences the session, + // so outliving the stream alone is not enough. + // + auto self = shared_from_this(); + auto session_guard = session.shared_from_this(); + + call_read_handler_active = true; + size_t consumed = 0; + while (read_handler) + { + if (asio::buffer_size(read_head) > 0) + { + auto copied = asio::buffer_copy(read_handler_buffer, read_head); + read_head += copied; + consumed += copied; + if (read_head.size() == 0) + { + pending_read.pop_front(); + read_head = + pending_read.empty() ? asio::const_buffer{} : asio::buffer(pending_read.front()); + } + swap_and_invoke(read_handler, boost::system::error_code{}, copied); + continue; + } + + if (eof_received) + { + // 0-byte read = EOF, matching the beast/nghttp2 convention. + swap_and_invoke(read_handler, boost::system::error_code{}, 0); + continue; + } + + if (closed) + { + // + // The stream died before the request body was complete, and this read was issued after + // the close -- there is nothing left that could ever complete it, so report the + // truncation now rather than leaving it pending forever. + // + swap_and_invoke(read_handler, boost::beast::http::error::partial_message, 0); + continue; + } + + break; + } + call_read_handler_active = false; + + // + // Grant the peer more send credit only for what was actually delivered to the app -- see + // Http3Session::consume_stream() for why this must not happen any earlier. + // + session.consume_stream(id, consumed); +} + +// ------------------------------------------------------------------------------------------------- + +namespace +{ +nghttp3_ssize stream_read_data(nghttp3_conn*, int64_t /*stream_id*/, nghttp3_vec* vec, + size_t veccnt, uint32_t* pflags, void* /*conn_user*/, + void* stream_user) +{ + auto s = static_cast(stream_user); + return s->data_reader(vec, veccnt, pflags); +} +} // namespace + +void Http3Stream::submit_response() +{ + assert(!response_submitted); + + auto status_str = std::to_string(response_status); + std::vector nva; + nva.reserve(16); // small typical header count; vector will grow if needed + nva.push_back(make_nv(":status", status_str)); + nva.push_back(make_nv("server", "anyhttp-quic/0.1")); + + if (response_content_length) + { + response_content_length_str = std::to_string(*response_content_length); + nva.push_back(make_nv("content-length", response_content_length_str)); + } + + for (auto&& item : response_fields) + { + if (item.name_string().starts_with(':')) + { + logw("[{}] submit_response: dropping pseudo-header '{}'", log_prefix, item.name_string()); + continue; + } + nva.push_back(make_nv(item.name_string(), item.value())); + } + + nghttp3_data_reader dr{}; + dr.read_data = &stream_read_data; + + if (auto rv = nghttp3_conn_set_stream_user_data(session.h3(), id, this); rv != 0) + { + loge("[{}] nghttp3_conn_set_stream_user_data: {}", log_prefix, nghttp3_strerror(rv)); + return; + } + + if (auto rv = nghttp3_conn_submit_response(session.h3(), id, nva.data(), nva.size(), &dr); + rv != 0) + { + loge("[{}] nghttp3_conn_submit_response: {}", log_prefix, nghttp3_strerror(rv)); + return; + } + response_submitted = true; + logd("[{}] response submitted (status={})", log_prefix, response_status); +} + +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); + + // Only one async_write() may be active at a time -- see the class comment above write_active. + assert(!write_active); + + // + // Once accepted, the caller's intent to end the response body is final: this is what tells + // delete_writer() the body ended where it was meant to, so it need not reset the stream. An + // earlier cancellation just makes for a shorter body than planned -- legitimate here, and a + // declared content-length is still enforced by the peer. + // + if (is_eof && eof_submitted) + { + // + // The body was already ended. If that FIN is still pending (its handler was detached by + // cancellation, see bind_write_cancellation()), adopt this handler so it completes when the + // FIN actually goes out; otherwise the FIN is long gone and there is nothing left to do. + // + if (write_active && write_is_eof) + { + logd("[{}] start_write: FIN already pending, adopting handler", log_prefix); + bind_write_cancellation(handler, write_token); + write_handler = std::move(handler); + } + else if (handler) + { + asio::any_completion_executor ex = + asio::get_associated_immediate_executor(handler, get_executor()); + ex.execute([handler = std::move(handler)]() mutable + { std::move(handler)(boost::system::error_code{}); }); + } + return; + } + + 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 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: 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. 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;31mcancelled\x1b[0m ({})", log_prefix, 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 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_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 the 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 Http3Stream::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_pkt() call after submit_response() 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 (n == 0 || !write_active || write_is_eof) + return; + + n = std::min(n, write_chunk.size() - write_confirmed); + write_confirmed += n; + + // 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_confirmed == write_chunk.size() && + write_source_copied == asio::buffer_size(write_source)) + finish_active_write(); +} + +void Http3Stream::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 Http3Stream::delete_reader() +{ + auto self = shared_from_this(); // see delete_writer() + pending_read.clear(); + read_head = {}; + + // + // 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), timer_(server.get_executor()), + done_signal_(server.get_executor()) +{ + ngtcp2_ccerr_default(&last_error_); + log_prefix_ = std::format("h3:{}", ngtcp2::util::straddr(&remote_.su.sa, remote_.len)); + // done_signal_ is armed at "never" until signal_done() moves it to the past. + done_signal_.expires_at(asio::steady_timer::time_point::max()); + mlogi("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_); + } + mlogi("session destroyed"); +} + +// ------------------------------------------------------------------------------------------------- + +void Http3Session::async_submit(SubmitHandler&& handler, boost::urls::url, const Fields&) +{ + // Client-side submit is not implemented yet. + std::move(handler)(errc::make_error_code(errc::operation_not_supported), + client::Request{nullptr}); +} + +awaitable Http3Session::do_session(Buffer&&) +{ + boost::system::error_code ec; + co_await done_signal_.async_wait(redirect_error(use_awaitable, ec)); + // ec is boost::asio::error::operation_aborted (from destroy()) or a spurious + // wake-up; either way, this coroutine's job is done. + co_return; +} + +void Http3Session::destroy() noexcept +{ + if (std::exchange(closed_, true)) + { + timer_.cancel(); + signal_done(); + return; + } + + // + // Explicit shutdown (e.g. the whole Server::Impl going away): let the peer know right + // away instead of leaving it to find out via idle timeout (up to 30s). Unlike + // handle_error(), this doesn't linger for 3 PTO to handle retransmits of the CLOSE -- + // this is a clean, voluntary shutdown, not an error condition worth that effort. + // + if (conn_ && !ngtcp2_conn_in_closing_period(conn_) && !ngtcp2_conn_in_draining_period(conn_)) + { + std::array closebuf; + ngtcp2_path_storage ps; + ngtcp2_pkt_info pi; + ngtcp2_path_storage_zero(&ps); + + auto nwrite = ngtcp2_conn_write_connection_close(conn_, &ps.path, &pi, closebuf.data(), + closebuf.size(), &last_error_, + ngtcp2::util::timestamp()); + if (nwrite > 0) + send_udp(ep_.fd, ps.path.remote.addr, ps.path.remote.addrlen, + {closebuf.data(), static_cast(nwrite)}); + } + + timer_.cancel(); + signal_done(); +} + +void Http3Session::signal_done() +{ + // Move the sentinel timer to the past so any waiter wakes up. + done_signal_.expires_at(asio::steady_timer::time_point::min()); +} + +// ------------------------------------------------------------------------------------------------- + +std::shared_ptr Http3Session::find_stream(int64_t id) +{ + auto it = streams_.find(id); + return it == streams_.end() ? nullptr : it->second; +} + +Http3Stream* Http3Session::create_stream(int64_t id) +{ + auto [it, inserted] = streams_.emplace(id, std::make_shared(*this, id)); + return it->second.get(); +} + +void Http3Session::erase_stream(int64_t id) { streams_.erase(id); } + +void Http3Session::wake_write() +{ + // The session write loop is only run in reaction to a packet arriving or a timer + // firing. When the user submits response data outside those events, we need to + // kick the write loop ourselves. + // + // Capture a weak_ptr, not shared_from_this(): wake_write() can be reached from a + // Reader/Writer destructor that runs as part of *this* session's own teardown (e.g. a + // still-in-flight request/response destroyed by Server::Impl cancelling everything on + // shutdown), at which point shared_from_this() would throw bad_weak_ptr. + 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(); + }); +} + +// ------------------------------------------------------------------------------------------------- + +int Http3Session::init(const ngtcp2_cid& dcid, const ngtcp2_cid& scid, uint32_t version, + const ngtcp2_pkt_info& pi, std::span data) +{ + scid_.datalen = QUIC_SCIDLEN; + if (RAND_bytes(scid_.data, static_cast(scid_.datalen)) != 1) + { + loge("[{}] init: RAND_bytes for SCID failed", log_prefix_); + return -1; + } + + ngtcp2_callbacks callbacks{}; + callbacks.recv_client_initial = ngtcp2_crypto_recv_client_initial_cb; + callbacks.recv_crypto_data = ngtcp2_crypto_recv_crypto_data_cb; + callbacks.handshake_completed = &Http3Session::cb_handshake_completed; + callbacks.encrypt = ngtcp2_crypto_encrypt_cb; + callbacks.decrypt = ngtcp2_crypto_decrypt_cb; + callbacks.hp_mask = ngtcp2_crypto_hp_mask_cb; + callbacks.recv_stream_data = &Http3Session::cb_recv_stream_data; + callbacks.acked_stream_data_offset = &Http3Session::cb_acked_stream_data_offset; + callbacks.stream_open = &Http3Session::cb_stream_open; + callbacks.stream_close = &Http3Session::cb_stream_close; + callbacks.rand = &Http3Session::cb_rand; + callbacks.get_new_connection_id = &Http3Session::cb_get_new_connection_id; + callbacks.remove_connection_id = &Http3Session::cb_remove_connection_id; + callbacks.update_key = ngtcp2_crypto_update_key_cb; + callbacks.stream_reset = &Http3Session::cb_stream_reset; + callbacks.extend_max_remote_streams_bidi = &Http3Session::cb_extend_max_remote_streams_bidi; + callbacks.extend_max_stream_data = &Http3Session::cb_extend_max_stream_data; + callbacks.delete_crypto_aead_ctx = ngtcp2_crypto_delete_crypto_aead_ctx_cb; + callbacks.delete_crypto_cipher_ctx = ngtcp2_crypto_delete_crypto_cipher_ctx_cb; + callbacks.get_path_challenge_data = ngtcp2_crypto_get_path_challenge_data_cb; + callbacks.stream_stop_sending = &Http3Session::cb_stream_stop_sending; + callbacks.version_negotiation = ngtcp2_crypto_version_negotiation_cb; + callbacks.recv_rx_key = &Http3Session::cb_recv_rx_key; + + ngtcp2_settings settings; + ngtcp2_settings_default(&settings); + settings.initial_ts = ngtcp2::util::timestamp(); + 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(); + params.original_dcid = dcid; + params.original_dcid_present = 1; + + ngtcp2_path path{ + {const_cast(&ep_.addr.su.sa), ep_.addr.len}, + {const_cast(&remote_.su.sa), remote_.len}, + &ep_, + }; + + if (auto rv = ngtcp2_conn_server_new(&conn_, &scid, &scid_, &path, version, &callbacks, + &settings, ¶ms, nullptr, this); + rv != 0) + { + loge("[{}] ngtcp2_conn_server_new: {}", log_prefix_, ngtcp2_strerror(rv)); + return -1; + } + + auto* ssl = SSL_new(tls_context().ctx); + if (!ssl) + { + loge("[{}] SSL_new failed", log_prefix_); + return -1; + } + + conn_ref_.get_conn = &Http3Session::get_conn; + conn_ref_.user_data = this; + SSL_set_app_data(ssl, &conn_ref_); + SSL_set_accept_state(ssl); + + if (ngtcp2_crypto_ossl_configure_server_session(ssl) != 0) + { + loge("[{}] ngtcp2_crypto_ossl_configure_server_session failed", log_prefix_); + SSL_free(ssl); + return -1; + } + + if (ngtcp2_crypto_ossl_ctx_new(&ossl_ctx_, ssl) != 0) + { + loge("[{}] ngtcp2_crypto_ossl_ctx_new failed", log_prefix_); + SSL_free(ssl); + return -1; + } + + ngtcp2_conn_set_tls_native_handle(conn_, ossl_ctx_); + + logi("[{}] new connection, scid={} version=0x{:x}", log_prefix_, + ngtcp2::util::format_hex(scid_.data, scid_.datalen), version); + + return on_read(pi, data, remote_); +} + +// ------------------------------------------------------------------------------------------------- + +int Http3Session::on_read(const ngtcp2_pkt_info& pi, std::span data, + const ngtcp2::Address& remote) +{ + logd("[{}] on_read: {} bytes", log_prefix_, data.size()); + + ngtcp2_path path{ + {const_cast(&ep_.addr.su.sa), ep_.addr.len}, + {const_cast(&remote.su.sa), remote.len}, + &ep_, + }; + + auto rv = + ngtcp2_conn_read_pkt(conn_, &path, &pi, data.data(), data.size(), ngtcp2::util::timestamp()); + if (rv != 0) + { + if (rv == NGTCP2_ERR_DRAINING) + logd("[{}] ngtcp2_conn_read_pkt: draining", log_prefix_); + else + logw("[{}] ngtcp2_conn_read_pkt: {}", log_prefix_, ngtcp2_strerror(rv)); + + if (rv == NGTCP2_ERR_CRYPTO && !last_error_.error_code) + ngtcp2_ccerr_set_tls_alert(&last_error_, ngtcp2_conn_get_tls_alert(conn_), nullptr, 0); + else if (!last_error_.error_code) + ngtcp2_ccerr_set_liberr(&last_error_, rv, nullptr, 0); + return handle_error(rv); + } + + if (auto wrv = write_streams(); wrv != 0) + return wrv; + + update_timer(); + 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; + } + 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 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_consumed(static_cast(ndatalen)); + } -#include "anyhttp/formatter.hpp" // IWYU pragma: keep + return nwrite; + } +} -#include -#include -#include -#include -#include +// ------------------------------------------------------------------------------------------------- -#include -#include +int Http3Session::write_streams() +{ + if (ngtcp2_conn_in_closing_period(conn_) || ngtcp2_conn_in_draining_period(conn_)) + return 0; -#include -#include -#include + logd("[{}] write_streams: max_data_left={}", log_prefix_, ngtcp2_conn_get_max_data_left(conn_)); -#include + ngtcp2_path_storage ps; + ngtcp2_pkt_info pi; + ngtcp2_path_storage_zero(&ps); -#include + 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)); + } -#define IPTOS_ECN_MASK 0x03 + ngtcp2_conn_update_pkt_tx_time(conn_, ngtcp2::util::timestamp()); -#include "ngtcp2/shared.h" -#include "ngtcp2/util.h" + if (nwrite == 0) + return 0; -using namespace std::chrono_literals; -using namespace boost::asio; + return send_udp_gso(ep_.fd, ps.path.remote.addr, ps.path.remote.addrlen, + {tx_buf_.data(), static_cast(nwrite)}, gso_size, no_gso_); +} -namespace anyhttp::server +// ------------------------------------------------------------------------------------------------- + +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(); + }); +} -boost::asio::ip::tcp::endpoint sockaddr_to_endpoint(const sockaddr_storage& addr) +int Http3Session::handle_expiry() { - using namespace boost::asio::ip; - if (addr.ss_family == AF_INET) + auto now = ngtcp2::util::timestamp(); + if (auto rv = ngtcp2_conn_handle_expiry(conn_, now); rv != 0) { - const auto& sa = reinterpret_cast(addr); - return tcp::endpoint(make_address_v4(ntohl(sa.sin_addr.s_addr)), ntohs(sa.sin_port)); + logw("[{}] ngtcp2_conn_handle_expiry: {}", log_prefix_, ngtcp2_strerror(rv)); + ngtcp2_ccerr_set_liberr(&last_error_, rv, nullptr, 0); + return handle_error(rv); } - else if (addr.ss_family == AF_INET6) + if (auto rv = write_streams(); rv != 0) + return rv; + update_timer(); + return 0; +} + +// ------------------------------------------------------------------------------------------------- + +int Http3Session::handle_error(int /*rv*/) +{ + if (closed_) + return -1; + closed_ = true; + + // Idle timeout and drop-conn need no CONNECTION_CLOSE packet. + if (last_error_.type == NGTCP2_CCERR_TYPE_IDLE_CLOSE || + last_error_.type == NGTCP2_CCERR_TYPE_DROP_CONN) { - const auto& sa6 = reinterpret_cast(addr); - const auto& bytes = reinterpret_cast(sa6.sin6_addr); - return tcp::endpoint(make_address_v6(bytes, sa6.sin6_scope_id), ntohs(sa6.sin6_port)); + signal_done(); + return -1; } - else + + // If already in draining (peer sent CONNECTION_CLOSE), don't reply. + // If already in closing, the buffered packet is still valid. + if (conn_ && !ngtcp2_conn_in_draining_period(conn_) && !ngtcp2_conn_in_closing_period(conn_)) { - throw std::invalid_argument("Unsupported address family"); + conn_closebuf_.resize(NGTCP2_MAX_UDP_PAYLOAD_SIZE); + ngtcp2_path_storage ps; + ngtcp2_pkt_info pi; + ngtcp2_path_storage_zero(&ps); + + auto nwrite = ngtcp2_conn_write_connection_close(conn_, &ps.path, &pi, conn_closebuf_.data(), + conn_closebuf_.size(), &last_error_, + ngtcp2::util::timestamp()); + if (nwrite > 0) + { + conn_closebuf_.resize(static_cast(nwrite)); + logi("[{}] sending CONNECTION_CLOSE", log_prefix_); + send_udp(ep_.fd, ps.path.remote.addr, ps.path.remote.addrlen, + {conn_closebuf_.data(), conn_closebuf_.size()}); + } + else + { + conn_closebuf_.clear(); + signal_done(); + return -1; + } } + + // Stay alive for 3 PTO so we can resend the CONNECTION_CLOSE if the + // peer retransmits, or absorb late packets during the draining period. + schedule_close_timer(); + return -1; +} + +void Http3Session::schedule_close_timer() +{ + auto delay = conn_ ? std::chrono::nanoseconds{ngtcp2_conn_get_pto(conn_) * 3} + : std::chrono::milliseconds{100}; + timer_.expires_after(delay); + timer_.async_wait([self = weak_from_this()](const boost::system::error_code& ec) + { + if (ec) + return; + auto session = std::static_pointer_cast(self.lock()); + if (!session) + return; + logi("[{}] closing/draining period over", session->log_prefix_); + session->server_.erase_quic_session(session.get()); + session->signal_done(); + }); } +void Http3Session::resend_conn_close() +{ + if (conn_closebuf_.empty()) + return; + auto* path = ngtcp2_conn_get_path(conn_); + if (!path) + return; + logd("[{}] resending CONNECTION_CLOSE", log_prefix_); + send_udp(ep_.fd, path->remote.addr, path->remote.addrlen, + {conn_closebuf_.data(), conn_closebuf_.size()}); +} + +// ------------------------------------------------------------------------------------------------- +// ngtcp2 callback implementations // ------------------------------------------------------------------------------------------------- -using namespace ngtcp2; +int Http3Session::cb_handshake_completed(ngtcp2_conn*, void* user) +{ + auto self = static_cast(user); + logi("[{}] TLS handshake complete", self->log_prefix_); + if (self->setup_http3() != 0) + return NGTCP2_ERR_CALLBACK_FAILURE; + return 0; +} -// Endpoint is a local endpoint. -struct Endpoint +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*) { - Address addr; - int fd; -}; + 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.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 +// ------------------------------------------------------------------------------------------------- + +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); + 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; + + logd("[{}] \x1b[1;34m{}\x1b[0m: {}", s->log_prefix, 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()); + + // + // 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.requestHandlerCoro()) + co_spawn(self->get_executor(), handler(std::move(request), std::move(response)), detached); + else if (auto& handler = sv.requestHandler()) + handler(std::move(request), std::move(response)); + else + { + loge("[{}] no request handler set", s->log_prefix); + co_spawn(self->get_executor(), not_found(std::move(response)), detached); + } + return 0; +} + +int Http3Session::h3_cb_end_stream(nghttp3_conn*, int64_t stream_id, void* user, void*) +{ + auto self = static_cast(user); + if (auto s = self->find_stream(stream_id)) + s->on_eof(); + return 0; +} + +int Http3Session::h3_cb_stop_sending(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, + void* user, void*) +{ + auto self = static_cast(user); + ngtcp2_conn_shutdown_stream_read(self->conn_, 0, stream_id, app_error_code); + return 0; +} + +int Http3Session::h3_cb_reset_stream(nghttp3_conn*, int64_t stream_id, uint64_t app_error_code, + void* user, void*) +{ + auto self = static_cast(user); + ngtcp2_conn_shutdown_stream_write(self->conn_, 0, stream_id, app_error_code); + return 0; +} + +// ================================================================================================= +// Server::Impl QUIC glue. +// ================================================================================================= + +namespace +{ +std::optional to_ngtcp2_address(const sockaddr_storage& src, socklen_t len) +{ + ngtcp2::Address addr{}; + if (len > sizeof(addr.su)) + return std::nullopt; + std::memcpy(&addr.su, &src, len); + addr.len = len; + return addr; +} +} // namespace + +void Server::Impl::associate_quic_cid(const ngtcp2_cid& cid, Http3Session* h) +{ + m_quic_handlers.emplace(cid_key(cid), + std::static_pointer_cast(h->shared_from_this())); +} + +void Server::Impl::dissociate_quic_cid(const ngtcp2_cid& cid) +{ + m_quic_handlers.erase(cid_key(cid)); +} + +void Server::Impl::erase_quic_session(Http3Session* h) +{ + std::erase_if(m_quic_handlers, [h](const auto& kv) { return kv.second.get() == h; }); +} + +// ------------------------------------------------------------------------------------------------- int Server::Impl::udp_on_read(Endpoint& ep) { -#if 1 - sockaddr_union su; + ngtcp2::sockaddr_union su; std::array buf; - size_t pktcnt = 0; - ngtcp2_pkt_info pi; - - iovec msg_iov; - msg_iov.iov_base = buf.data(); - msg_iov.iov_len = buf.size(); + ngtcp2_pkt_info pi{}; + iovec msg_iov{buf.data(), buf.size()}; msghdr msg{}; msg.msg_name = &su; msg.msg_iov = &msg_iov; @@ -85,94 +2177,181 @@ int Server::Impl::udp_on_read(Endpoint& ep) msg_ctrl[CMSG_SPACE(sizeof(int)) + CMSG_SPACE(sizeof(in6_pktinfo)) + CMSG_SPACE(sizeof(int))]; msg.msg_control = msg_ctrl; - for ( ;pktcnt < 10; ) + for (size_t pktcnt = 0; pktcnt < 32; ++pktcnt) { + if (pktcnt) + logd("- - {} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ", pktcnt); + msg.msg_namelen = sizeof(su); msg.msg_controllen = sizeof(msg_ctrl); auto nread = recvmsg(ep.fd, &msg, 0); if (nread == -1) { - if (!(errno == EAGAIN || errno == ENOTCONN)) - { - std::cerr << "recvmsg: " << strerror(errno) << std::endl; - } + if (errno != EAGAIN && errno != EWOULDBLOCK && errno != ENOTCONN) + loge("recvmsg: {}", strerror(errno)); return 0; } - // Packets less than 22 bytes never be a valid QUIC packet. if (nread < 22) - { - ++pktcnt; continue; - } - if (util::prohibited_port(util::port(&su))) + auto local_addr = ngtcp2::msghdr_get_local_addr(&msg, su.storage.ss_family); + if (!local_addr) { - ++pktcnt; + logw("could not obtain local address from cmsg"); continue; } + ngtcp2::set_port(*local_addr, ep.addr); + ep.addr = *local_addr; - pi.ecn = msghdr_get_ecn(&msg, su.storage.ss_family); - auto local_addr = msghdr_get_local_addr(&msg, su.storage.ss_family); - if (!local_addr) + // When UDP_GRO is enabled the kernel may coalesce several datagrams + // from the same 4-tuple into one recvmsg result. The GRO cmsg carries + // the uniform segment size so we can split the buffer back into + // individual QUIC datagrams before handing them to ngtcp2. + uint16_t gro_size = 0; + for (auto* cm = CMSG_FIRSTHDR(&msg); cm; cm = CMSG_NXTHDR(&msg, cm)) { - ++pktcnt; - std::cerr << "Unable to obtain local address" << std::endl; - continue; + if (cm->cmsg_level == SOL_UDP && cm->cmsg_type == UDP_GRO) + { + memcpy(&gro_size, CMSG_DATA(cm), sizeof(gro_size)); + break; + } } - auto gso_size = msghdr_get_udp_gro(&msg); - if (gso_size == 0) + auto remote = to_ngtcp2_address(su.storage, msg.msg_namelen); + if (!remote) { - gso_size = static_cast(nread); + logw("unsupported remote address family"); + continue; } - set_port(*local_addr, ep.addr); - - auto data = std::span{buf.data(), static_cast(nread)}; + auto all_data = std::span{buf.data(), static_cast(nread)}; + const size_t seg_size = gro_size > 0 ? gro_size : all_data.size(); - for (; !data.empty();) + while (!all_data.empty()) { - auto datalen = std::min(data.size(), gso_size); - - ++pktcnt; + auto data = all_data.subspan(0, std::min(seg_size, all_data.size())); + all_data = all_data.subspan(data.size()); - if (true /* !config.quiet */) + ngtcp2_version_cid vc; + auto rv = ngtcp2_pkt_decode_version_cid(&vc, data.data(), data.size(), QUIC_SCIDLEN); + if (rv != 0) { - std::array ifname; - std::cerr << "Received packet: local=" - << util::straddr(&local_addr->su.sa, local_addr->len) - << " remote=" << util::straddr(&su.sa, msg.msg_namelen) - << " if=" << if_indextoname(local_addr->ifindex, ifname.data()) << " ecn=0x" - << std::hex << static_cast(pi.ecn) << std::dec << " " << datalen - << " bytes" << std::endl; + if (rv != NGTCP2_ERR_VERSION_NEGOTIATION) + logw("could not decode version/cid: {}", ngtcp2_strerror(rv)); + continue; } - // Packets less than 22 bytes never be a valid QUIC packet. - if (datalen < 22) + auto key = cid_key(vc.dcid, vc.dcidlen); + auto it = m_quic_handlers.find(key); + + if (it == m_quic_handlers.end()) { - break; + ngtcp2_pkt_hd hd; + if (ngtcp2_accept(&hd, data.data(), data.size()) != 0) + continue; + + auto session = std::make_shared(*this, ep, *remote); + if (session->init(hd.dcid, hd.scid, hd.version, pi, data) != 0) + continue; + + m_quic_handlers.emplace(std::move(key), session); + std::array scids; + auto num_scid = ngtcp2_conn_get_scid(session->conn(), nullptr); + if (num_scid <= scids.size()) + { + ngtcp2_conn_get_scid(session->conn(), scids.data()); + for (size_t i = 0; i < num_scid; ++i) + m_quic_handlers.emplace(cid_key(scids[i]), session); + } + + // + // 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. + // + { + auto lock = std::lock_guard(m_sessionMutex); + m_sessions.emplace(session); + } + co_spawn(get_executor(), session->do_session({}), + [self = shared_from_this(), session](const std::exception_ptr& ex) + { + if (ex) + logw("[{}] {}", session->logPrefix(), what(ex)); + auto lock = std::lock_guard(self->m_sessionMutex); + self->m_sessions.erase(session); + }); } + else + { + auto session = it->second; - // read_pkt(ep, *local_addr, &su.sa, msg.msg_namelen, &pi, {data.data(), datalen}); + // + // Handle closing / draining periods. During closing we resend the + // buffered CONNECTION_CLOSE so the peer can tear down cleanly. + // During draining (peer sent CONNECTION_CLOSE) we just drop the packet. + // In both cases the session stays in m_quic_handlers until the 3-PTO + // close timer fires and calls erase_quic_session(). + // + if (auto* conn = session->conn()) + { + if (ngtcp2_conn_in_closing_period(conn)) + { + session->resend_conn_close(); + continue; + } + if (ngtcp2_conn_in_draining_period(conn)) + continue; + } - data = data.subspan(datalen); + if (session->on_read(pi, data, *remote) != 0 && session->closed()) + { + // + // Only erase immediately when not in closing/draining period. + // If we are, the 3-PTO close timer in handle_error() will call + // erase_quic_session() once the period expires. + // + auto* conn = session->conn(); + if (!conn || + (!ngtcp2_conn_in_closing_period(conn) && !ngtcp2_conn_in_draining_period(conn))) + { + std::erase_if(m_quic_handlers, + [&](const auto& kv) { return kv.second.get() == session.get(); }); + } + } + } } } -#endif return 0; } +// ------------------------------------------------------------------------------------------------- + awaitable Server::Impl::udp_receive_loop() { for (;;) { - co_await m_udp_socket->async_wait(boost::asio::socket_base::wait_read); + boost::system::error_code ec; + co_await m_udp_socket->async_wait(boost::asio::socket_base::wait_read, + redirect_error(use_awaitable, ec)); + if (ec) + { + if (ec == boost::asio::error::operation_aborted) + logi("UDP receive: {}", ec.message()); + else + logw("UDP receive: {}", ec.message()); + co_return; + } - Endpoint ep; + Endpoint ep{}; ep.fd = m_udp_socket->native_handle(); - ep.addr.su.sa = *m_udp_socket->local_endpoint().data(); + auto local = m_udp_socket->local_endpoint(); + auto data = local.data(); + std::memcpy(&ep.addr.su, data, local.size()); + ep.addr.len = local.size(); + udp_on_read(ep); } } diff --git a/src/server_main.cpp b/src/server_main.cpp index 4716888..5de244e 100644 --- a/src/server_main.cpp +++ b/src/server_main.cpp @@ -14,8 +14,8 @@ #include #include -#include #include +#include namespace rv = std::ranges::views; @@ -103,6 +103,8 @@ int main(int argc, char* argv[]) std::string path = request.url().path(); if (path == "/echo") co_await echo(std::move(request), std::move(response)); + else if (path == "/generate") + co_await generate(std::move(request), std::move(response)); else if (path == "/dump") co_await dump(std::move(request), std::move(response)); else if (path == "/dump space") diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index b3329b7..1de2268 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -8,6 +8,9 @@ add_executable(test_all ${SRC_FILES}) target_link_libraries(test_all PRIVATE GTest::GTest GTest::gmock anyhttp) target_link_libraries(test_all PRIVATE spdlog::spdlog_header_only) +# the tests connect over TLS and pass pki/out/root.pem to curl, so the PKI must exist first +add_dependencies(test_all pki) + # include(CTest) # Fetch Google Test # include(FetchContent) diff --git a/test/test_formatter.cpp b/test/test_formatter.cpp index 92dcb6e..743c909 100644 --- a/test/test_formatter.cpp +++ b/test/test_formatter.cpp @@ -89,6 +89,14 @@ TEST(FormatterTest, EndpointIPv6) EXPECT_EQ(formatted, "[::1]:9090"); } +TEST(FormatterTest, EndpointUDP) +{ + auto addr = boost::asio::ip::make_address_v6("::1"); + boost::asio::ip::udp::endpoint endpoint(addr, 9090); + auto formatted = std::format("{}", endpoint); + EXPECT_EQ(formatted, "[::1]:9090"); +} + TEST(FormatterTest, EndpointIPv6Full) { auto addr = boost::asio::ip::make_address_v6("2001:db8::1"); diff --git a/test/test_server.cpp b/test/test_server.cpp index cacc09d..5a12afd 100644 --- a/test/test_server.cpp +++ b/test/test_server.cpp @@ -70,7 +70,8 @@ namespace rv = std::ranges::views; using namespace anyhttp; -#define CURL_PATH "/usr/bin/curl" +// https://github.com/curl/curl/issues/10634 --> use custom built curl +#define CURL_PATH "/usr/local/bin/curl" #define NGHTTP_PATH "/usr/local/bin/nghttp" #define H2LOAD_PATH "/usr/local/bin/h2load" @@ -351,7 +352,7 @@ class External : public Server co_await (log("STDERR", err) && log("STDOUT", out)); auto result = std::string(); #endif - logi("spawn: starting to communicate... done"); + logi("spawn: starting to communicate... done, read {} bytes", result.size()); co_await child.async_wait(); if (child.exit_code()) @@ -362,7 +363,9 @@ class External : public Server if (--numSpawned <= 0) { co_await post(server->get_executor()); + logi("all processes exited, stopping server..."); server.reset(); + logi("all processes exited, stopping server... done"); } co_return result; @@ -374,49 +377,68 @@ class External : public Server std::promise promise; auto future = promise.get_future(); co_spawn(strand, spawn_process(std::move(path), std::move(args)), - bind_executor(strand, [promise = std::move(promise)](const std::exception_ptr& ex, - std::string str) mutable + bind_executor(strand, [this, promise = std::move(promise)]( + const std::exception_ptr& ex, std::string str) mutable { if (ex) { - str = what(ex); - loge("{}", str); + loge("{}", what(ex)); + server.reset(); } promise.set_value(std::move(str)); })); return std::move(future); } + // + // Like spawn(CURL_PATH, args), but for Protocol::h3: QUIC handshakes can hang in ways + // http11/h2 curl invocations don't, so wrap in a hard `timeout 5` safety net. + // + std::future spawn_curl(std::vector args) + { + if (GetParam() == anyhttp::Protocol::h3) + { + args.insert(args.begin(), {"5", CURL_PATH}); + return spawn("/usr/bin/timeout", std::move(args)); + } + return spawn(CURL_PATH, std::move(args)); + } + any_io_executor strand{make_strand(context.get_executor())}; bp::filesystem::path testFile{"CMakeLists.txt"}; size_t testFileSize = file_size(testFile); std::atomic numSpawned = 0; }; +using Args = std::vector; + +// ================================================================================================= + INSTANTIATE_TEST_SUITE_P(External, External, - ::testing::Values(anyhttp::Protocol::http11, anyhttp::Protocol::h2), + ::testing::Values(anyhttp::Protocol::http11, // HTTP/1.1 + anyhttp::Protocol::h2), // HTTP/2 NameGenerator); // ------------------------------------------------------------------------------------------------- -TEST_P(External, nghttp2) +TEST_P(External, curl) { - if (GetParam() == anyhttp::Protocol::http11) - GTEST_SKIP(); // no --nghttp2-prior-knowledge for 'nghttp', re-enable when ALPN works - auto url = std::format("http://127.0.0.2:{}/echo", server->local_endpoint().port()); - auto future = spawn(NGHTTP_PATH, {"-d", testFile.string(), url}); + Args args = {"-sS", "-v", "--data-binary", std::format("@{}", testFile.string()), url}; + + if (GetParam() == anyhttp::Protocol::h2) + args.insert(args.begin(), "--http2-prior-knowledge"); + + auto future = spawn(CURL_PATH, std::move(args)); run(); EXPECT_EQ(future.get().size(), testFileSize); } -using Args = std::vector; - -TEST_P(External, curl) +TEST_P(External, curl_multiple) { auto url = std::format("http://127.0.0.2:{}/echo", server->local_endpoint().port()); - Args args = {"-sS", "-v", "--data-binary", std::format("@{}", testFile.string()), url}; + Args args = {"-sS", "-v", "--data-binary", std::format("@{}", testFile.string()), url, url}; if (GetParam() == anyhttp::Protocol::h2) args.insert(args.begin(), "--http2-prior-knowledge"); @@ -424,23 +446,68 @@ TEST_P(External, curl) auto future = spawn(CURL_PATH, std::move(args)); run(); + EXPECT_EQ(future.get().size(), testFileSize * 2); +} + +// ================================================================================================= + +class ExternalTLS : public External +{ +protected: + std::string curlProtocolParam() + { + switch (GetParam()) + { + case anyhttp::Protocol::http11: + return "--http1.1"; + case anyhttp::Protocol::h2: + return "--http2"; + case anyhttp::Protocol::h3: + return "--http3-only"; + } + } +}; + +INSTANTIATE_TEST_SUITE_P(ExternalTLS, ExternalTLS, + ::testing::Values(anyhttp::Protocol::http11, // HTTP/1.1 + anyhttp::Protocol::h2, // HTTP/2 + anyhttp::Protocol::h3), // HTTP/3 (QUIC) + NameGenerator); + +// ------------------------------------------------------------------------------------------------- + +TEST_P(ExternalTLS, curl) +{ + auto url = std::format("https://127.0.0.2:{}/echo", server->local_endpoint().port()); + // clang-format off + Args args = {curlProtocolParam(), "-sS", "-v", + "--cacert", "pki/out/root.pem", + "--data-binary", std::format("@{}", testFile.string()), + url}; + // clang-format off + + auto future = spawn_curl(std::move(args)); + run(); + EXPECT_EQ(future.get().size(), testFileSize); } -TEST_P(External, curl_many) +TEST_P(ExternalTLS, curl_many) { std::vector> futures; futures.reserve(10); for (size_t i = 0; i < futures.capacity(); ++i) { - auto url = std::format("http://127.0.0.2:{}/echo", server->local_endpoint().port()); - Args args = {"-sS", "-v", "--data-binary", std::format("@{}", testFile.string()), url}; - - if (GetParam() == anyhttp::Protocol::h2) - args.insert(args.begin(), "--http2-prior-knowledge"); - - futures.emplace_back(spawn(CURL_PATH, std::move(args))); + auto url = std::format("https://127.0.0.2:{}/echo", server->local_endpoint().port()); + // clang-format off + Args args = {curlProtocolParam(), "-sS", "-v", + "--cacert", "pki/out/root.pem", + "--data-binary", std::format("@{}", testFile.string()), + url}; + // clang-format off + + futures.emplace_back(spawn_curl(std::move(args))); } run(); @@ -449,59 +516,72 @@ TEST_P(External, curl_many) EXPECT_EQ(future.get().size(), testFileSize); } -TEST_P(External, curl_https) +TEST_P(ExternalTLS, curl_multiple) { auto url = std::format("https://127.0.0.2:{}/echo", server->local_endpoint().port()); - Args args = {"-sS", "-v", "-k", "--data-binary", std::format("@{}", testFile.string()), url}; - - if (GetParam() == anyhttp::Protocol::h2) - args.insert(args.begin(), "--http2"); - else - args.insert(args.begin(), "--http1.1"); // not implemented, yet - - auto future = spawn(CURL_PATH, std::move(args)); + // clang-format off + Args args = {curlProtocolParam(), "-sS", "-v", + "--cacert", "pki/out/root.pem", + "--data-binary", std::format("@{}", testFile.string()), + url, url, url, url}; + // clang-format off + + auto future = spawn_curl(std::move(args)); run(); - EXPECT_EQ(future.get().size(), testFileSize); + EXPECT_EQ(future.get().size(), testFileSize * 4); } -TEST_P(External, curl_multiple) +// ------------------------------------------------------------------------------------------------- + +TEST_P(ExternalTLS, h2load) { + const size_t n = 100; // number of requests, echoing 65535 bytes each + const size_t data_size = 65535; auto url = std::format("http://127.0.0.2:{}/echo", server->local_endpoint().port()); - Args args = {"-sS", "-v", "--data-binary", std::format("@{}", testFile.string()), url, url}; + Args args = {"-d", "test/data/64kminus1", "-n", std::to_string(n), "-c", "4", "-m", "3", url}; - if (GetParam() == anyhttp::Protocol::h2) - args.insert(args.begin(), "--http2-prior-knowledge"); + switch (GetParam()) + { + case anyhttp::Protocol::http11: + args.insert(args.begin(), "--h1"); + break; + case anyhttp::Protocol::h3: + args.insert(args.begin(), "--h3"); // h2load negotiates h3 itself, http:// URL is fine + break; + default: + break; // h2load defaults to HTTP/2 + } - // https://github.com/curl/curl/issues/10634 --> use custom built curl - auto future = spawn(CURL_PATH, std::move(args)); + auto future = spawn(H2LOAD_PATH, std::move(args)); run(); - EXPECT_EQ(future.get().size(), testFileSize * 2); -} + const std::string output = future.get(); + std::smatch match; + std::regex regex( + R"((\d+) total, \d+ started, (\d+) done, (\d+) succeeded, (\d+) failed, \d+ errored)"); + ASSERT_TRUE(std::regex_search(output.begin(), output.end(), match, regex)) << output; + EXPECT_EQ(std::stoul(match[3].str()), n) << match[1]; + EXPECT_EQ(std::stoul(match[4].str()), 0) << match[1]; -TEST_P(External, curl_multiple_https) -{ - auto url = std::format("https://127.0.0.2:{}/echo", server->local_endpoint().port()); - Args args = {"-sS", "-v", "-k", "--data-binary", std::format("@{}", testFile.string()), - url, url}; + regex = std::regex(R"(\((\d+)\) data)"); + ASSERT_TRUE(std::regex_search(output.begin(), output.end(), match, regex)) << output; + EXPECT_EQ(std::stoul(match[1].str()), n * data_size) << match[1]; +} - if (GetParam() == anyhttp::Protocol::h2) - args.insert(args.begin(), "--http2"); - else - args.insert(args.begin(), "--http1.1"); +// ================================================================================================= - auto future = spawn(CURL_PATH, std::move(args)); - run(); +// +// Non-parametrized fixture for external tests that are tied to a specific protocol. +// +class ExternalCustom : public External +{ +}; - EXPECT_EQ(future.get().size(), testFileSize * 2); -} +// ------------------------------------------------------------------------------------------------- -TEST_P(External, nc_crazy_chunked) +TEST_F(ExternalCustom, netcat_crazy_chunked) { - if (GetParam() == anyhttp::Protocol::h2) - GTEST_SKIP(); - auto cmd = std::format("nc 127.0.0.2 {} local_endpoint().port()); auto future = spawn("/usr/bin/bash", {"-c", cmd}); @@ -512,11 +592,17 @@ TEST_P(External, nc_crazy_chunked) EXPECT_TRUE(out.contains("Hello, World!\n")); } -TEST_P(External, h2spec) +TEST_F(ExternalCustom, nghttp2) { - if (GetParam() != anyhttp::Protocol::h2) - GTEST_SKIP(); + auto url = std::format("http://127.0.0.2:{}/echo", server->local_endpoint().port()); + auto future = spawn(NGHTTP_PATH, {"-d", testFile.string(), url}); + run(); + + EXPECT_EQ(future.get().size(), testFileSize); +} +TEST_F(ExternalCustom, h2spec) +{ auto future = spawn("bin/h2spec", {"--host", server->local_endpoint().address().to_string(), "--port", std::to_string(server->local_endpoint().port()), "--path", "/h2spec", "--timeout", "1", "--verbose"}); @@ -543,39 +629,6 @@ TEST_P(External, h2spec) EXPECT_EQ(std::stoi(match[3].str()), expected_ok) << output; } -TEST_P(External, h2load) -{ - const size_t n = 100; // number of requests, echoing 65535 bytes each - auto url = std::format("http://127.0.0.2:{}/echo", server->local_endpoint().port()); - Args args = {"-d", "test/data/64kminus1", "-n", std::to_string(n), "-c", "4", "-m", "3", url}; - - if (GetParam() == anyhttp::Protocol::http11) - args.insert(args.begin(), "--h1"); - - auto future = spawn(H2LOAD_PATH, std::move(args)); - run(); - - const std::string output = future.get(); - std::smatch match; - std::regex regex( - R"((\d+) total, \d+ started, (\d+) done, (\d+) succeeded, (\d+) failed, \d+ errored)"); - ASSERT_TRUE(std::regex_search(output.begin(), output.end(), match, regex)) << output; - EXPECT_EQ(std::stoul(match[3].str()), n) << match[1]; - EXPECT_EQ(std::stoul(match[4].str()), 0) << match[1]; - - regex = std::regex(R"(\((\d+)\) data)"); - ASSERT_TRUE(std::regex_search(output.begin(), output.end(), match, regex)) << output; - EXPECT_EQ(std::stoul(match[1].str()), n * 65535) << match[1]; -} - -// ------------------------------------------------------------------------------------------------- - -TEST_P(External, echo) -{ - co_spawn(context.get_executor(), spawn_process("/usr/bin/echo", {"Hello, World!"}), detached); - run(); -} - // ================================================================================================= class Client : public Server @@ -654,7 +707,8 @@ class ClientAsync : public Client }; INSTANTIATE_TEST_SUITE_P(ClientAsync, ClientAsync, - ::testing::Values(anyhttp::Protocol::http11, anyhttp::Protocol::h2), + ::testing::Values(anyhttp::Protocol::http11, anyhttp::Protocol::h2, + anyhttp::Protocol::h3), NameGenerator); // ------------------------------------------------------------------------------------------------- @@ -701,8 +755,6 @@ TEST_P(ClientAsync, WHEN_server_discards_request_THEN_error_500) co_await send(request, 1024); auto [ec, response] = co_await request.async_get_response(as_tuple); EXPECT_TRUE(ec); - // EXPECT_EQ(response.status_code(), 500); - // auto received = co_await receive(response); }; } @@ -752,8 +804,11 @@ TEST_P(ClientAsync, WHEN_get_response_is_called_twice_THEN_reports_error) }; } -TEST_P(ClientAsync, DISABLED_WHEN_get_response_is_detached_THEN_does_not_crash) +TEST_P(ClientAsync, WHEN_get_response_is_detached_THEN_does_not_crash) { + if (GetParam() == anyhttp::Protocol::http11) + GTEST_SKIP(); + test = [this](Session session) -> awaitable { auto request = co_await session.async_submit(url.set_path("echo")); @@ -814,13 +869,26 @@ TEST_P(ClientAsync, WHEN_client_cancels_write_THEN_can_resume) cancel_after(1s, as_tuple)); EXPECT_EQ(code(ep), boost::system::errc::operation_canceled); - // now, with a closed window, we cannot even end the upload - std::tie(ep) = co_await co_spawn(executor, send_eof(request), cancel_after(1ms, as_tuple)); - EXPECT_EQ(code(ep), boost::system::errc::operation_canceled); + if (GetParam() == anyhttp::Protocol::h3) + { + // + // QUIC: whether the FIN can slip out while the send window is closed depends on flow + // control timing, so don't assert either way here. What matters is that ending the + // upload and draining the response together complete the exchange. + // + auto received = co_await (send_eof(request) && count(response)); + EXPECT_GT(received, 0); + } + else + { + // now, with a closed window, we cannot even end the upload + std::tie(ep) = co_await co_spawn(executor, send_eof(request), cancel_after(1ms, as_tuple)); + EXPECT_EQ(code(ep), boost::system::errc::operation_canceled); - // as we have no control over when the send window is re-opened, wait for it in parallel - auto received = co_await (send_eof(request) && count(response)); - EXPECT_GT(received, 0); + // as we have no control over when the send window is re-opened, wait for it in parallel + auto received = co_await (send_eof(request) && count(response)); + EXPECT_GT(received, 0); + } }; } @@ -877,7 +945,6 @@ TEST_P(ClientAsync, HelloWorld) { co_await response.async_submit(200, {}); co_await response.async_write_eof(asio::buffer(hello)); - // co_await response.async_write({}); }; test = [this](Session session) -> awaitable { @@ -904,7 +971,7 @@ TEST_P(ClientAsync, ServerYieldFirst) { auto request = co_await session.async_submit(url); co_await request.async_write({}); - co_await (read_response(request) || sleep(2s)); + co_await read_response(request); }; } @@ -1140,9 +1207,14 @@ TEST_P(ClientAsync, Backpressure) // FIXME: count bytes sent, just like asio::async_write() does // FIXME: or even use asio::async_write() on top of a async_write_some() implementation - // Now that the flow control window is 0, we can't even send an EOF any more: + // + // Now that the flow control window is 0, we can't even send an EOF any more -- except over + // QUIC, where whether the FIN slips out without credit depends on flow control timing, so + // only assert that for the stream protocols. + // auto rc = co_await (send_eof(request) || sleep(100ms)); - EXPECT_EQ(rc.index(), 1); + if (GetParam() != anyhttp::Protocol::h3) + EXPECT_EQ(rc.index(), 1); // So instead, we start doing this in background, to be resumed as soon as the window reopens. co_spawn(co_await this_coro::executor, send_eof(request), detached); // FIXME: join @@ -1318,10 +1390,7 @@ TEST_P(ClientAsync, CancelAfter) }; } -// -// Send more than content length allows. -// -TEST_P(ClientAsync, SendMoreThanContentLength) +TEST_P(ClientAsync, WHEN_send_more_than_content_length_THEN_connection_is_reset) { test = [this](Session session) -> awaitable { @@ -1330,7 +1399,10 @@ TEST_P(ClientAsync, SendMoreThanContentLength) auto request = co_await session.async_submit(url.set_path("eat_request"), fields); auto response = co_await request.async_get_response(); co_await count(response); - co_await send(request, rv::iota(uint8_t(0)) | rv::take(10 * 1024 + 1)); + + auto ex = co_await this_coro::executor; + auto [ep] = co_await co_spawn(ex, send(request, rv::iota(uint8_t(0))), as_tuple); + EXPECT_EQ(code(ep), boost::system::errc::connection_reset); }; }