diff --git a/.cspell.json b/.cspell.json index 3126895..dbeb092 100644 --- a/.cspell.json +++ b/.cspell.json @@ -1,5 +1,9 @@ { "words": [ + "dedent", + "doxygen", + "literalinclude", + "toctree", "64kminus1", "ALTSVC", "anyhttp", diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 5eff7b1..01a989e 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -16,6 +16,20 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive && \ # ================================================================================================== +# +# What makes the documentation (cmake --build build --target docs): Doxygen for the comments in the +# headers, Sphinx for the pages. These are the versions of the release of Debian that the base image +# is on, which are known to work together. The Documentation job in .github/workflows/release.yml +# installs the same. +# +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive && \ + apt-get -y install --no-install-recommends \ + doxygen python3-sphinx python3-breathe python3-sphinx-rtd-theme && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# ================================================================================================== + # # enable persistent bash history in container # diff --git a/.github/instructions/anyhttp.instructions.md b/.github/instructions/anyhttp.instructions.md index 172bba3..6d972cb 100644 --- a/.github/instructions/anyhttp.instructions.md +++ b/.github/instructions/anyhttp.instructions.md @@ -37,7 +37,7 @@ Builds are out-of-source, one tree per configuration (`build/` Debug, `build-rel Run the tests from the repository root -- they load TLS material through relative paths -- and prefer `gtest-parallel build/test/test_all`. -[README.md](../../README.md) documents the API, the concurrency model and the reasoning behind it: +[docs/](../../docs/README.md) documents the API, the concurrency model and the reasoning behind it: read it before changing anything about request/response semantics. [CLAUDE.md](../../CLAUDE.md) covers working in the tree in detail -- the build trees and their options, how to run and filter the tests, the sanitizer recipes, the source layout and the traps. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c846e2..a44ac9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -132,8 +132,45 @@ jobs: - name: Check installing and embedding run: scripts/check_package.sh + # + # The guide and the reference, with Doxygen and Sphinx, where warnings are errors: this is where a + # public symbol without a comment shows, and a reference that goes nowhere. The tools are the + # ones .devcontainer/Dockerfile installs, as the image does not have them. Nothing is compiled. + # + docs: + name: Documentation + runs-on: *runs_on + container: *container + + steps: + - *checkout + + - name: Install the tools + run: | + apt-get update + apt-get -y install --no-install-recommends \ + doxygen python3-sphinx python3-breathe python3-sphinx-rtd-theme + + - name: Make the documentation + run: | + cmake --preset build + cmake --build --preset build --target docs + + - name: Upload the documentation + uses: actions/upload-artifact@v7 + with: + name: documentation + path: build/docs/html/ + retention-days: 30 + + # + # A site has one deployment, so this is what publishes the documentation as well: it goes to the + # root of the site, and the coverage report next to it. There is no point in publishing a report + # without the documentation, so a failure over there holds this back. + # coverage: name: Coverage + needs: docs runs-on: *runs_on container: *container permissions: @@ -160,6 +197,13 @@ jobs: mkdir -p pages/coverage cp -r build/coverage_report/* pages/coverage/ + - name: Get the documentation + if: github.ref == 'refs/heads/master' && github.event_name == 'push' + uses: actions/download-artifact@v8 + with: + name: documentation + path: pages/ + - name: Upload Coverage Report uses: actions/upload-artifact@v7 with: @@ -167,7 +211,7 @@ jobs: path: build/coverage_report/ retention-days: 30 - - name: Upload coverage to GitHub Pages + - name: Upload coverage and documentation to GitHub Pages if: github.ref == 'refs/heads/master' && github.event_name == 'push' uses: actions/upload-pages-artifact@v5 with: diff --git a/CLAUDE.md b/CLAUDE.md index 739bf4a..c1f0c2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,10 @@ # Working on anyhttp -C++23 HTTP/1.1 + HTTP/2 + HTTP/3 server and client on top of Boost.Asio. [README.md](README.md) -describes the API, the concurrency model and the reasoning behind it -- read it before changing -anything about request/response semantics. This file is about *how to work in the tree*. +C++23 HTTP/1.1 + HTTP/2 + HTTP/3 server and client on top of Boost.Asio. [docs/](docs/README.md) +has the API, the concurrency model and the reasoning behind it, in +[programmers-guide.rst](docs/programmers-guide.rst), [end-of-body.rst](docs/end-of-body.rst) and +[concurrent-requests.rst](docs/concurrent-requests.rst) -- read them before changing anything about +request/response semantics. This file is about *how to work in the tree*. ## Build and test @@ -89,6 +91,15 @@ Parametrized tests are suffixed `/HTTP11`, `/HTTP2`, `/HTTP3` -- not h2/h3. `--g only `*` and `?`, and a filter that matches nothing exits 0 with no output, which reads like a pass: check for `[ OK ]` lines. +### Documentation + +`cmake --build build --target docs` makes the site in `build/docs/html`; the pages are `docs/*.rst`, +the reference comes from the comments in the public headers. [docs/README.md](docs/README.md) has +how to write it. Warnings are errors in Doxygen and Sphinx, so changing a public header can fail this +target: run it, a new public symbol also needs an entry in `docs/reference.rst`. The code the guide +shows is `docs/snippets.cpp` and `docs/examples/`, compiled in every tree with +`ANYHTTP_BUILD_DOCS` and run by `test/test_docs.cpp`. Change it there, never in a page. + ### Sanitizers Separate trees; the sanitizer options in the Debug branch of `CMakeLists.txt` are commented out on diff --git a/CMakeLists.txt b/CMakeLists.txt index 6687297..6f04de7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,6 +17,9 @@ option(ENABLE_COVERAGE "Enable coverage flags and llvm-cov target" OFF) option(ANYHTTP_BUILD_TESTS "Build the tests" ${PROJECT_IS_TOP_LEVEL}) option(ANYHTTP_BUILD_APPS "Build the programs" ${PROJECT_IS_TOP_LEVEL}) option(ANYHTTP_INSTALL "Generate install rules" ${PROJECT_IS_TOP_LEVEL}) +option(ANYHTTP_BUILD_DOCS + "Build the code of the guide, and add the 'docs' target where Doxygen and Sphinx are there" + ${PROJECT_IS_TOP_LEVEL}) if (DEFINED ENV{GITHUB_ACTIONS}) add_compile_definitions(GITHUB_ACTIONS) @@ -241,6 +244,10 @@ if (ANYHTTP_BUILD_TESTS) add_subdirectory(test) endif() +if (ANYHTTP_BUILD_DOCS) + add_subdirectory(docs) +endif() + if (ANYHTTP_INSTALL) include(cmake/install.cmake) endif() diff --git a/CMakePresets.json b/CMakePresets.json index b2f28d0..2216823 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -278,14 +278,15 @@ { "name": "build-deploy", "displayName": "For cmake --install", - "description": "Release, without the tests and thus without cfssl and the test PKI. On AWS-LC, which links statically: a TLS library the target machine does not have to provide", + "description": "Release, without the tests and thus without cfssl and the test PKI, and without the code of the guide. On AWS-LC, which links statically: a TLS library the target machine does not have to provide", "inherits": [ "clang", "base" ], "cacheVariables": { "CMAKE_BUILD_TYPE": "Release", - "ANYHTTP_BUILD_TESTS": "OFF" + "ANYHTTP_BUILD_TESTS": "OFF", + "ANYHTTP_BUILD_DOCS": "OFF" } } ], diff --git a/README.md b/README.md index fb9f277..4fd12be 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # Overview [![Build and run tests](https://github.com/pgit/anyhttp/actions/workflows/release.yml/badge.svg)](https://github.com/pgit/anyhttp/actions/workflows/release.yml) +[![Documentation](https://img.shields.io/badge/documentation-read-blue)](https://pgit.github.io/anyhttp/) [![Coverage](https://img.shields.io/badge/coverage-report-blue)](https://pgit.github.io/anyhttp/coverage/) Low-level C++23 HTTP client and server library, in one of two API styles, chosen at configure time: -* **ASIO**, on Boost.Asio: every operation is an ASIO asynchronous operation and takes any - completion token. -* **COROSIO**, on [capy](https://github.com/cppalliance/capy) and - [corosio](https://github.com/cppalliance/corosio): coroutines only. +* **ASIO**, on [Asio](https://www.boost.org/doc/libs/release/doc/html/boost_asio.html): every + operation is an asynchronous operation of Asio and takes any completion token. +* **COROSIO**, on [Corosio](https://github.com/cppalliance/corosio): coroutines only. > [!WARNING] > This repository is purely experimental. It is not meant for production use, and anything in it @@ -24,130 +24,18 @@ None of those protocols are implemented from scratch. Instead, it is a wrapper a This project started as a hand-written replacement for [nghttp2-asio](https://github.com/nghttp2/nghttp2-asio). Once the concept was working, HTTP/1.1 was added, and both protocols were hidden behind a type-erased interface. Finally, HTTP/3 was added with the help of agentic coding. -## API styles +## Documentation -| | ASIO | COROSIO | -|---|---|---| -| CMake | `-DANYHTTP_API=ASIO` (default) | `-DANYHTTP_API=COROSIO`
`-DTLS_LIBRARY=OpenSSL` | -| `Task` | `asio::awaitable` | `capy::task` | -| `Executor` | `asio::any_io_executor` | `capy::any_executor` | -| `error_code` | `boost::system::error_code` | `std::error_code` | -| `TcpEndpoint`, `UdpEndpoint` | `asio::ip::tcp::endpoint`, `asio::ip::udp::endpoint` | `corosio::endpoint` | -| Operations | `async_x(..., token)` and `x()` | `x()` | -| TLS | AWS-LC (default) or OpenSSL 3.5+ | OpenSSL 3.5+ | -| Programs | `server`, `client` | `server` | +**** has the [programmer's guide](docs/programmers-guide.rst), which +is the place to start, the API reference and how it all works: +[API styles](docs/api-styles.rst), [the end of a body](docs/end-of-body.rst), +[concurrent requests](docs/concurrent-requests.rst), [moving to HTTP/3](docs/alt-svc.rst) and +[the HTTP/2 library](docs/http2-library.rst). [docs/README.md](docs/README.md) says how to make it. -Both styles have every operation as a coroutine -- `read_some`, `write`, `write_eof`, `submit`, -`get_response`, `connect`, `get` -- that yields a tuple and reports errors instead of throwing. -This README uses that spelling. Buffers are Boost.Asio's in both, headers are Beast's `Fields`, URLs -Boost.URL's. +## Example -Where the two runtimes spell an error differently, `anyhttp::errors` names it: +A server, which echoes every request, over HTTP/1.1, HTTP/2 and HTTP/3 on one port: -| `errors::` | ASIO | COROSIO | -|---|---|---| -| `eof` | `asio::error::eof` | `capy::error::eof` | -| `partial_message` | `beast::http::error::partial_message` | `capy::error::stream_truncated` | -| `canceled` | `errc::operation_canceled` | `capy::error::canceled` | -| `would_block` | `asio::error::would_block` | `errc::operation_would_block` | -| `connection_aborted` | `asio::error::connection_aborted` | `errc::connection_aborted` | -| `header_limit` | `beast::http::error::header_limit` | the same | - -Anything else compares against `errc::` (`boost::system::errc` or `std::errc`), with one exception: -a host that does not resolve is `netdb_errors::host_not_found` in ASIO and -`errc::no_such_device_or_address` in COROSIO. - -## HTTP/2 library - -HTTP/2, server and client both, runs on either nghttp2 or its ground-up rewrite, -[nghttp2 version 2](https://github.com/nghttp2/nghttp2-v2), by the same author. The rewrite's API is -much closer to nghttp3's than to version 1's: one `nghttp2_conn` instead of a session and an option -object, settings and callbacks as plain structs, callbacks for connection and HTTP events rather -than for frames, bodies taken by reference, and flow control credit extended by hand. - -| | nghttp2 | nghttp2 version 2 | -|---|---|---| -| CMake | `-DH2_LIBRARY=nghttp2` (default) | `-DH2_LIBRARY=nghttp2v2` | -| Source | `src/h2/`, `include/anyhttp/h2/` | `src/h2v2/`, `include/anyhttp/h2v2/` | - -This is a configure-time choice, not a runtime one, because the two cannot share a process: -version 2 installs as its own package but still exports nine of version 1's symbol names, among -them the four `nghttp2_rcbuf_*` whose structs differ. A build links one or the other, and compiles -only the backend directory that goes with it. - -Nothing above the backend changes: the same `Protocol::http2`, the same API, and the same behaviour -wherever the two libraries leave anyhttp the choice. Where they do not: - -* The `Connection: Upgrade` from HTTP/1.1 to h2c - ([RFC 7540, section 3.2](https://www.rfc-editor.org/rfc/rfc7540#section-3.2)) went with - [RFC 9113](https://www.rfc-editor.org/rfc/rfc9113) and has no counterpart in version 2, so such a - request is declined and served as HTTP/1.1 instead -- always a valid answer to an upgrade. -* The `ALTSVC` frame is gone. An alternative service still arrives as a response header field, which - is how a server sends one anyway, see below. - -Version 2 has had no release yet, so a build pins a commit of it. - -## Using it from a project - -C++23, CMake 3.30 and Boost 1.88 or later, spdlog, nghttp2, ngtcp2 and nghttp3 -- the Dockerfiles in -[.devcontainer](.devcontainer) build all of them, and those are the versions that are tested. Whatever else goes into -the process has to agree with anyhttp on the standard library and the TLS library. - -### As a subproject - -```cmake -include(FetchContent) -FetchContent_Declare(anyhttp - GIT_REPOSITORY https://github.com/pgit/anyhttp.git - GIT_TAG - SYSTEM) -set(TLS_LIBRARY OpenSSL) # the options of the table above, if the defaults won't do -FetchContent_MakeAvailable(anyhttp) - -target_link_libraries(my_program PRIVATE anyhttp::anyhttp) -``` - -This builds the library and nothing else; `ANYHTTP_BUILD_TESTS`, `ANYHTTP_BUILD_APPS` and -`ANYHTTP_INSTALL` default to `OFF` here. Works in both API styles. To develop against a checkout, set -`FETCHCONTENT_SOURCE_DIR_ANYHTTP` to it. - -### Installed - -```sh -cmake --preset build-deploy -cmake --build --preset build-deploy -cmake --install build-deploy --prefix /opt/anyhttp -``` - -```cmake -find_package(anyhttp 0.0 REQUIRED) # with /opt/anyhttp in CMAKE_PREFIX_PATH -target_link_libraries(my_program PRIVATE anyhttp::anyhttp) -``` - -`anyhttp_API`, `anyhttp_TLS_LIBRARY` and `anyhttp_H2_LIBRARY` then say what that copy was built with. -The package has the public headers only, those of the protocol backends stay behind. It looks for its -dependencies where they were when it was built. **ASIO only:** corosio writes install rules only when it is the -top-level project, so a library that links it can't be exported; use FetchContent for COROSIO. - -### Deploying the programs - -`cmake --install build-deploy --prefix --component Runtime` gives `bin/anyhttp-server` and -`bin/anyhttp-client`, plus every shared library they need in `lib/`, except for glibc and `libgcc_s`. -The tree can be moved or copied to another machine that has a glibc at least as new as the build -host's. The bundled libraries have licenses of their own, to be passed on along with them. - -```sh -anyhttp-server -p 443 --cert /etc/anyhttp/chain.pem --key /etc/anyhttp/key.pem -``` - -The server always loads a certificate, also to serve only cleartext: without `--cert` and `--key` it looks for the -test PKI (`pki/out/`, relative to the working directory), and exits with an error if that isn't there. -The library and the programs log through spdlog's default logger, at `info` unless told otherwise. - -`scripts/check_package.sh` verifies all of this from the outside. - -## Synopsis -### Server ```C++ Task echo(server::Request request, server::Response response) { @@ -174,214 +62,52 @@ Task echo(server::Request request, server::Response response) } ``` -The end of an incoming body is reported the way the runtime reports it everywhere else: -`errors::eof` with zero bytes. A body cut short -- a reset stream, a connection that went away -mid-message -- completes with `errors::partial_message` instead, so the two stay distinguishable. - -The end of an *outgoing* body is stated explicitly, with `write_eof()`. It takes a buffer of -its own, so the last of the body and the end of it go out together -- one DATA frame with -END_STREAM, one QUIC STREAM frame with FIN, one last chunk -- instead of costing a second, empty -write: +A client, for which a plain GET is one operation, and anything else a few more: ```C++ - co_await response.submit(200, fields({{"Content-Length", body.size()}})); - co_await response.write_eof(asio::buffer(body)); -``` -### Client -```c++ Task do_session(client::Client& client, boost::urls::url url) { auto [ec1, session] = co_await client.connect(); - auto [ec2, request] = co_await session.submit(url, {}); - auto [ec3] = co_await request.write_eof(); - auto [ec4, response] = co_await request.get_response(); -} -``` -A plain GET needs none of those four steps spelled out. `get()` does the whole request as one -operation and hands back the response as a plain Beast message -- status, fields and the body as a -`std::string`: - -```c++ - auto [ec, message] = co_await session.get(url); + auto [ec2, message] = co_await session.get(url); std::println("{}: {} bytes", message.result_int(), message.body().size()); -``` - -The convenience is paid for with memory, as the body is buffered in full: anything that wants to -look at the body while it arrives, or to send a body of its own, still goes through -`submit()`. -`submit()` is a POST unless it is given a method. Reading a response from a server that may send -more than you want then stops wherever you do, with status and header fields in hand: - -```c++ - auto [ec1, request] = co_await session.submit("GET", url); - auto [ec2] = co_await request.write_eof(); - auto [ec3, response] = co_await request.get_response(); - std::println("{} {}", response.status_code(), response.fields()["content-type"]); - - std::array buffer; - for (size_t total = 0; total < limit;) - { - auto [ec, n] = co_await response.read_some(asio::buffer(buffer)); - total += n; - if (ec) - break; - } - session.reset(); // the rest of the body is never read -``` - -What the server gets to send in the meantime is bounded by flow control, and over HTTP/1.1 by the -socket buffers. Releasing the response early also resets its stream with HTTP/2 and HTTP/3, leaving -the session usable, but it is the end of an HTTP/1.1 connection. - -An `https` URL means TLS, with the server's certificate verified against `tls_ca_file`, or the -default trust store of the TLS library. `protocol` then says what to offer with ALPN: `h2` also -offers HTTP/1.1, and the server picks, so ask `session.protocol()` if it matters -- HTTP/1.1 takes -only one request at a time, see [Concurrent Requests](#concurrent-requests). Any other scheme means -cleartext, and exactly the protocol given. HTTP/3 is TLS over QUIC with any scheme. - -```c++ - client::Client client(executor, {.url = boost::urls::url("https://example.org")}); -``` - -`url` says what the connection is for, and unless told otherwise, also where it goes. A caller with -its own resolver, or following a DNS HTTPS record, gives the addresses in `endpoints`, which are -tried in order. Nothing is resolved then, while the certificate still has to be valid for the host -of the URL, and requests still say they are for it: - -```c++ - client::Client client(executor, {.url = boost::urls::url("https://example.org"), - .endpoints = {TcpEndpoint(address, 8443)}}); + auto [ec3, request] = co_await session.submit(url, {}); + auto [ec4] = co_await request.write_eof(); + auto [ec5, response] = co_await request.get_response(); +} ``` -Over TLS, a body that ends with the connection is complete only if the server has said so with a -`close_notify`: otherwise, it may have been cut by anyone, and reading it fails with -`errors::partial_message`. +Both API styles have every operation as such a coroutine, that yields a tuple and reports errors +instead of throwing. On ASIO, each is also an asynchronous operation that takes any completion token. +The whole programs are [hello_server.cpp](docs/examples/hello_server.cpp) and +[hello_client.cpp](docs/examples/hello_client.cpp). -There are two ways to end a session, and neither waits for what is in progress, which fails right -away: +## Using it from a project -* `co_await session.shutdown()` (or `async_shutdown()`) ends the connection in an orderly way: over - HTTP/2 with a GOAWAY, over TLS with a `close_notify` in both directions, then a FIN in both, with - each wait limited to two seconds. It fails with `errors::connection_aborted` only if a write had to - be interrupted, which rules out a `close_notify`. HTTP/3 sends its CONNECTION_CLOSE. -* Destroying the session, or `session.reset()`, is over as fast as possible, and does nothing - asynchronous. The peer sees a connection that was cut. +C++23, CMake 3.30 and Boost 1.88 or later, spdlog, nghttp2, ngtcp2 and nghttp3 -- the Dockerfiles in +[.devcontainer](.devcontainer) build all of them, and those are the versions that are tested. -### Plain QUIC streams -QUIC streams without HTTP in them -- for a protocol of your own, told apart by its ALPN -- run on -the UDP port HTTP/3 is on. A `quic::Stream` is a `Reader` and a `Writer` at once: +```cmake +include(FetchContent) +FetchContent_Declare(anyhttp + GIT_REPOSITORY https://github.com/pgit/anyhttp.git + GIT_TAG + SYSTEM) +FetchContent_MakeAvailable(anyhttp) -```c++ -server.on_quic({.alpn = "echo"}, [](quic::Connection connection) -> Task { - for (;;) - { - auto [ec, stream] = co_await connection.accept_stream(); - if (ec) - co_return; // the connection is over - launch(connection.get_executor(), echo(std::move(stream))); - } -}); -``` -```c++ - auto [ec1, connection] = co_await quic::connect(executor, "localhost", "8080", - {.alpn = "echo", .tls_ca_file = "root.pem"}); - auto [ec2, stream] = connection.open_stream(); - auto [ec3] = co_await stream.write_eof(asio::buffer("hello", 5)); - auto [ec4, n] = co_await stream.read_some(asio::buffer(buffer)); +target_link_libraries(my_program PRIVATE anyhttp::anyhttp) ``` -The ends map onto the Reader/Writer contract below: a FIN is `write_eof()` on one side and -`errors::eof` on the other, a stream its writer gave up on (`reset_stream()`, or a Writer dropped -before the end) reads as `errors::partial_message`, and writing to a peer that asked us to stop -(`stop_sending()`) fails with `errc::connection_reset`. The two directions are independent. Opening -a stream never waits: at the peer's limit, `open_stream()` fails with `errors::would_block`. A -connection is closed when its handle goes away -- on the server, when the handler returns. See -[anyhttp/quic.hpp](include/anyhttp/quic.hpp) and [docs/quic-streams-plan.md](docs/quic-streams-plan.md). - -# Class Hierarchy - -![Class hierarchy](docs/overview.drawio.svg) - -# Implementation - -Both styles build the same library and protocol sources; only a thin runtime layer -(`anyhttp/runtime.hpp` and `anyhttp/net.hpp`, implemented in `asio/` and `corosio/`) differs. See -[docs/corosio-port-plan.md](docs/corosio-port-plan.md). - -In ASIO, the operations exposed by server and client are [ASIO asynchronous operations](https://think-async.com/Asio/asio-1.30.2/doc/asio/reference/asynchronous_operations.html). As such, they support a range of [completion tokens](https://think-async.com/Asio/asio-1.30.2/doc/asio/overview/model/completion_tokens.html) like [use_awaitable](https://think-async.com/Asio/asio-1.30.2/doc/asio/reference/use_awaitable.html) or plain callbacks. - -The implementation is hidden behind [any_completion_handler](https://www.boost.org/doc/libs/1_86_0/doc/html/boost_asio/reference/any_completion_handler.html) so that it can be compiled separately. - -In COROSIO, they are capy coroutines, cancelled through the awaiting chain's `std::stop_token`. -`Reader` and `Writer` model `capy::ReadStream` and `capy::WriteStream`. - -This work is partly inspired by [asio-grpc](https://github.com/Tradias/asio-grpc), which takes the idea even one step further and also supports the upcoming sender/receiver model of execution. - - -## The End of a Body - -A body ends where the protocol says it ends, and never at a zero-sized transfer. `read_some()` -*reports* the end, with `errors::eof` for a complete body and `errors::partial_message` for one cut -short; `write_eof()` *states* it, while `write({})` is a no-op that leaves the body open. See -[docs/end-of-body.md](docs/end-of-body.md) for the full rules -- what every operation answers after -the end, and why cancelling `write_eof()` does not take the end back. - -## Concurrent Requests - -HTTP/2 and HTTP/3 multiplex requests: each one is a stream of its own, and streams make progress independently. HTTP/1.1 has a single connection instead, which requests are written to and responses read from, one after the other. anyhttp treats HTTP/1.1 as a protocol with **"max concurrent streams = 1"**, and makes that explicit in the client API instead of hiding it. - -### Complete requests and responses - -A request is *complete* when all of it -- header and body -- has been written to the connection: - -* A request **without a body** is complete as soon as `submit()` has succeeded. Whether a request has a body is a matter of its framing only ([RFC 9112, section 6.3](https://www.rfc-editor.org/rfc/rfc9112#section-6.3)), never of its method: it has none without `Transfer-Encoding` and with a `Content-Length` of zero or none. As the HTTP/1.1 client sends every request without `Content-Length` chunked, that means `Content-Length: 0`. -* **Any other request** is complete when `write_eof()` has succeeded -- even if all of a `Content-Length` has been written before. The body of a request without one is ended implicitly: `write_eof()` is an idempotent no-op on it, and writing data fails with `broken_pipe`. - -A response is *complete* when it has been read to its end. - -### Rules for HTTP/1.1 - -1. `submit()` fails with `errors::would_block` while the previous request is not complete. -2. `get_response()` fails with `errors::would_block` while the responses to earlier requests have not been read completely -- otherwise, it would read one of those. -3. When a request can not be completed -- a write fails, or it is released before it is complete -- the connection is stuck in the middle of a message: every later `submit()` fails with `errors::connection_aborted`. -4. When a response can not be read completely -- it is released before its end, or its request is released without asking for it -- every later `get_response()` fails with `errors::connection_aborted`. - -Pipelining is still possible: complete requests can be sent before any of their responses have been read. - -### Design decisions - -* **Fail instead of waiting.** An operation that has to wait for an earlier request or response does not wait. Very often, the caller waiting is the one who has to finish that earlier request or response, after the operation returns -- waiting would deadlock. Failing immediately with `would_block` turns a hang into an error that can be handled: finish the earlier one, then retry. -* **No queueing of submitted requests.** An earlier version queued the header of a request submitted while the previous one was still incomplete, and sent it as soon as that was complete. That allows code written for HTTP/2 -- submit a couple of requests first, write their bodies later -- to work unchanged. But it needed a queue of pending requests, writes waiting for a header still on its way (and not cancellable while doing so), and failures cascading to queued requests. Refusing the submission keeps it at a single incomplete request per session, which is exactly what the protocol allows. -* **Requests and responses may outlive their session.** The session keeps track of all of its readers and writers, and detaches them when it goes away. Everything a detached request or response is asked to do after that completes with an error (`connection_aborted` for HTTP/1.1 and HTTP/2, `connection_reset` for HTTP/3), without touching the connection that is gone. - -### Stream limits in HTTP/2 and HTTP/3 - -HTTP/2 and HTTP/3 have a limit of their own: the peer's `SETTINGS_MAX_CONCURRENT_STREAMS`, or the QUIC limit on bidirectional streams. Reaching it is answered the way HTTP/1.1 answers its limit of one -- `submit()` fails with `would_block` instead of waiting -- so one and the same caller works over all three. A server says what it allows with `server::Config::max_concurrent_streams`; set it to 1, and HTTP/2 and HTTP/3 behave exactly like HTTP/1.1, which is what `OneStream.WHEN_limit_is_one_THEN_second_request_reports_would_block` checks for each of them. - -Two things to know about the slot a request occupies: - -* **It is held longer than in HTTP/1.1.** A stream counts against the limit until it is closed in *both* directions, that is, until its response has been read as well, where HTTP/1.1 is free again once the request has been written. So "max concurrent streams = 1" is stricter here than HTTP/1.1 pipelining: the next request has to wait for the previous *response*, not just for the previous request. -* **In HTTP/3 it comes free one round trip late.** The credit for another stream is granted as the last one closes, and that `MAX_STREAMS` has to reach the client, so a `submit()` immediately after reading a response may still report `would_block`. Retrying it is what the contract asks for anyway. - -Only the number is the peer's to choose; what to do when it is reached is anyhttp's, and the libraries do not agree. nghttp2 version 2 and ngtcp2 refuse the stream, which is reported as `would_block`; nghttp2 version 1 would accept it and hold it back internally, telling the caller nothing, so streams in progress are counted here instead. - -## Moving to HTTP/3: Alt-Svc - -There is no `Connection: Upgrade` on the way to HTTP/3, the way there is one from HTTP/1.1 to -HTTP/2. Instead, the server says where else it can be reached -([RFC 7838](https://www.rfc-editor.org/rfc/rfc7838)) -- `Alt-Svc: h3=":8080"; ma=86400`, in every -response over HTTP/1.1 and HTTP/2 -- and a client with `client::Config::follow_alt_svc` makes its -*next* connection there. See [docs/alt-svc.md](docs/alt-svc.md). +An installed package works as well, on ASIO, see +[getting the library](docs/programmers-guide.rst#getting-the-library). ## Links For now, this section contains just a set of random links collected during development. * [Beast Example using Type Erasure](https://www.boost.org/doc/libs/develop/boost/beast/http/message_generator.hpp) -* [asio-grpc](https://github.com/Tradias/asio-grpc) -* [Development container](docs/devcontainer.md) -- how the build environment is put together -* [Capturing traffic](docs/capture.md) -- tcpdump and tshark, decrypting TLS and QUIC with a key log +* [Development container](docs/devcontainer.rst) -- how the build environment is put together +* [Capturing traffic](docs/capture.rst) -- tcpdump and tshark, decrypting TLS and QUIC with a key log * [cpp-devcontainer](https://github.com/pgit/cpp-devcontainer) -- the generic C++ base image it builds on -* [c-ares-asio](https://github.com/pgit/c-ares-asio) -- asynchronous DNS, to resolve HTTPS resource records and start off with HTTP/3 +* [c-ares-async](https://github.com/pgit/c-ares-async) -- asynchronous DNS, to resolve HTTPS resource records and start off with HTTP/3 diff --git a/docs/CMakeLists.txt b/docs/CMakeLists.txt new file mode 100644 index 0000000..4fe6a0e --- /dev/null +++ b/docs/CMakeLists.txt @@ -0,0 +1,55 @@ +# +# The code that the programmer's guide shows is part of every build, as that is what keeps it true: +# two whole programs, and a file of the pieces. +# +add_library(anyhttp_doc_snippets OBJECT snippets.cpp) +target_link_libraries(anyhttp_doc_snippets PRIVATE anyhttp) + +add_executable(hello_server examples/hello_server.cpp) +target_link_libraries(hello_server PRIVATE anyhttp) + +add_executable(hello_client examples/hello_client.cpp) +target_link_libraries(hello_client PRIVATE anyhttp) + +# +# The 'docs' target: the guide and the reference, as HTML. Sphinx makes the pages, and Breathe gets +# what goes into the reference from Doxygen, which reads the comments in the public headers. It is +# not part of 'all', and not there at all where one of the two is not. +# +# cmake --build build --target docs +# +# Doxygen does not compile the headers, so unlike the snippets the documentation depends on neither +# the API style of the tree nor on what that style needs. See Doxyfile.in. +# +find_program(DOXYGEN_EXECUTABLE doxygen DOC "Doxygen, which reads the comments in the headers") +find_program(SPHINX_EXECUTABLE sphinx-build DOC "Sphinx, which makes the documentation") + +if (NOT DOXYGEN_EXECUTABLE OR NOT SPHINX_EXECUTABLE) + message(STATUS "anyhttp: no 'docs' target, as that takes both doxygen and sphinx-build") + return() +endif() + +configure_file(Doxyfile.in Doxyfile @ONLY) + +set(ANYHTTP_DOCS_DIR "${CMAKE_CURRENT_BINARY_DIR}/html" + CACHE PATH "Where the 'docs' target puts the documentation") + +# +# With -E, as a warning in a page that a previous run has read already would not show otherwise: +# warnings are errors on both sides, which is where a public symbol without a comment shows, and a +# reference that goes nowhere. +# +add_custom_target( + docs + COMMAND "${CMAKE_COMMAND}" -E rm -rf "${ANYHTTP_DOCS_DIR}" + "${CMAKE_CURRENT_BINARY_DIR}/doxygen" + COMMAND "${DOXYGEN_EXECUTABLE}" "${CMAKE_CURRENT_BINARY_DIR}/Doxyfile" + COMMAND + "${SPHINX_EXECUTABLE}" -q -E -W --keep-going -b html + -d "${CMAKE_CURRENT_BINARY_DIR}/doctrees" + -D "breathe_projects.anyhttp=${CMAKE_CURRENT_BINARY_DIR}/doxygen/xml" + -D "release=${PROJECT_VERSION}" + -D "version=${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}" + "${CMAKE_CURRENT_SOURCE_DIR}" "${ANYHTTP_DOCS_DIR}" + COMMENT "Making the documentation in ${ANYHTTP_DOCS_DIR}" + VERBATIM) diff --git a/docs/Doxyfile.in b/docs/Doxyfile.in new file mode 100644 index 0000000..1fd2a8b --- /dev/null +++ b/docs/Doxyfile.in @@ -0,0 +1,56 @@ +# +# What Doxygen makes of the comments in the public headers: XML, for Breathe to hand to Sphinx. The +# HTML is Sphinx's, see conf.py. CMake fills this in, see CMakeLists.txt in here. +# +PROJECT_NAME = anyhttp +OUTPUT_DIRECTORY = @CMAKE_CURRENT_BINARY_DIR@/doxygen +STRIP_FROM_PATH = @PROJECT_SOURCE_DIR@/include + +# +# The public interface, and nothing else. The comments on the vocabulary of the two runtimes are in +# asio/runtime.hpp, and corosio/runtime.hpp has the same names in the same order -- api-styles.rst +# has the table of what they map to. What these headers have for the library's own use is between +# \cond and \endcond. +# +INPUT = @PROJECT_SOURCE_DIR@/include/anyhttp/common.hpp \ + @PROJECT_SOURCE_DIR@/include/anyhttp/reader.hpp \ + @PROJECT_SOURCE_DIR@/include/anyhttp/writer.hpp \ + @PROJECT_SOURCE_DIR@/include/anyhttp/session.hpp \ + @PROJECT_SOURCE_DIR@/include/anyhttp/client.hpp \ + @PROJECT_SOURCE_DIR@/include/anyhttp/server.hpp \ + @PROJECT_SOURCE_DIR@/include/anyhttp/quic.hpp \ + @PROJECT_SOURCE_DIR@/include/anyhttp/alt_svc.hpp \ + @PROJECT_SOURCE_DIR@/include/anyhttp/asio/runtime.hpp + +# the plumbing that the handles and the operations with completion tokens are built from +EXCLUDE_SYMBOLS = Impl \ + ReadSomeHandler WriteSome WriteSomeHandler WriteHandler StatusHandler \ + SubmitHandler GetHandler SessionShutdownHandler \ + GetResponseHandler ConnectHandler AcceptStreamHandler + +# the first sentence of a comment is its summary, there is no need to say so with \brief +JAVADOC_AUTOBRIEF = YES + +# Doxygen does not compile anything, so what a compiler would get from config.hpp and from Asio's +# headers is said here -- which is also why the reference takes neither Boost nor a configured +# runtime, and comes out the same from a tree of either style +MACRO_EXPANSION = YES +EXPAND_ONLY_PREDEF = YES +PREDEFINED = ANYHTTP_ASIO=1 \ + ANYHTTP_COROSIO=0 \ + "BOOST_ASIO_COMPLETION_TOKEN_FOR(x)=typename" +SEARCH_INCLUDES = NO + +# what is not documented is not finished +QUIET = YES +WARN_IF_UNDOCUMENTED = YES +WARN_IF_DOC_ERROR = YES +WARN_NO_PARAMDOC = YES +WARN_AS_ERROR = FAIL_ON_WARNINGS + +# Doxygen 1.9.8 only finds out that parameters are documented while it makes its HTML, and without +# that warns about every one of them. So it makes some, which nobody looks at. +GENERATE_HTML = YES +GENERATE_LATEX = NO +GENERATE_XML = YES +XML_PROGRAMLISTING = NO diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..24ef562 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,54 @@ +# Documentation + +The documentation has two parts, and is made into one site, published at +: + +* the **guide**: the `.rst` files in here, written by hand. + [`programmers-guide.rst`](programmers-guide.rst) is the place to start, and the others are about + how it all works. Its code is in [`snippets.cpp`](snippets.cpp) and [`examples/`](examples), which + are compiled with every build, and [`test/test_docs.cpp`](../test/test_docs.cpp) runs it. +* the **reference**: [`reference.rst`](reference.rst) says what goes where, and the content comes + from the comments in the public headers in [`include/anyhttp`](../include/anyhttp). + +The `.md` files in here are working notes, which the site links to rather than includes, see +[`notes.rst`](notes.rst). + +## Making it + +```sh +cmake --preset build +cmake --build build --target docs +``` + +The pages end up in `build/docs/html`, with `index.html` to start from. It takes +[Doxygen](https://www.doxygen.nl), [Sphinx](https://www.sphinx-doc.org), +[Breathe](https://breathe.readthedocs.io) and the +[Read the Docs theme](https://sphinx-rtd-theme.readthedocs.io), which the devcontainer has. On +Debian that is: + +```sh +apt-get install doxygen python3-sphinx python3-breathe python3-sphinx-rtd-theme +``` + +The target is not part of a regular build, and not there at all where CMake finds no `doxygen` or no +`sphinx-build`, or was told `ANYHTTP_BUILD_DOCS=OFF`. It can be made from a tree of either API style +and gives the same result: the comments on the types of the two runtimes are in `asio/runtime.hpp`. + +## Writing it + +Warnings are errors in both tools, so a public symbol without a comment, a parameter without a +`\param` or a reference that goes nowhere fails the build, and with it a pull request. + +* A new public symbol goes into [`reference.rst`](reference.rst), as nothing puts it there by itself. + What a public header has for the library's own use goes between `\cond internal` and `\endcond`. +* In a comment, Doxygen takes `http::...` and `https://` for a link, and `\c` of either, or of a word + that starts with `[`, comes out empty, which neither tool warns about. So write `errors::...`, as + that is the spelling that holds in both API styles anyway, and `[...]`. +* Code in the guide comes from `snippets.cpp`, between a `// [name]` and a `// [name-end]` comment, + and is shown with `literalinclude`. Only pseudo code is written in place. +* In a page, names link to the reference with `` :cpp:func:`Session::submit` `` and the like, and + files of the repository with `` :src:`src/server.cpp` ``. +* In prose, the libraries are Asio, Corosio and Capy. `ASIO` and `COROSIO` are the API styles, the + values of `ANYHTTP_API`: "an ASIO build". Capy is named only where Capy itself is meant. + +[`Doxyfile.in`](Doxyfile.in) configures Doxygen and [`conf.py`](conf.py) configures Sphinx. diff --git a/docs/alt-svc.md b/docs/alt-svc.md deleted file mode 100644 index 518efce..0000000 --- a/docs/alt-svc.md +++ /dev/null @@ -1,53 +0,0 @@ -# Moving to HTTP/3: Alt-Svc - -HTTP/3 runs on QUIC, and QUIC is not something a TCP connection can turn into: there is no -`Connection: Upgrade` on the way to HTTP/3, the way there is one from HTTP/1.1 to HTTP/2. What -there is instead is the server saying where else it can be reached -([RFC 7838](https://www.rfc-editor.org/rfc/rfc7838)), and the client making its *next* connection -there. - -The server does that for its own HTTP/3 endpoint, which shares the address and port the TCP -acceptor is listening on, so the advertised alt-authority is a port and nothing else -- an empty -host in one means "the host of the origin": - -``` -Alt-Svc: h3=":8080"; ma=86400 -``` - -It goes into every response sent over HTTP/1.1 and HTTP/2, but never over HTTP/3, which is already -there. `server::Config::alt_svc_max_age` is how long a client may remember it, and `0s` advertises -nothing at all. - -A client only acts on it with `client::Config::follow_alt_svc` set, and then it takes precedence -over `client::Config::protocol`: - -```c++ - client::Client client(executor, {.url = url, .protocol = Protocol::h2, .follow_alt_svc = true}); - - auto [ec1, first] = co_await client.connect(); // HTTP/2, and learns about the alternative - auto [ec2, second] = co_await client.connect(); // HTTP/3 -``` - -The session that learns about the alternative keeps speaking what it speaks -- a connection in the -middle of a request can not be moved -- and the alternative is remembered for as long as `ma` says, -but only for the lifetime of the `Client`: there is no cache on disk. The origin does not change -with any of this, only where it is reached: requests still go out with the authority of -`Config::url`. - -Over HTTP/2, an alternative may also arrive in an `ALTSVC` frame instead of a header field, which -lets a server advertise before the first request has even been sent. anyhttp's client reads both; -its server sends the header field only. - -`curl` does the same thing, which is the easy way to watch it happen -- it honours `Alt-Svc` for -`https://` origins only, so this needs TLS, and a cache file to remember the alternative between -invocations: - -```sh -./build/src/server -p 8080 -``` - -```sh -curl --alt-svc altsvc.txt --cacert pki/out/root.pem https://localhost:8080/echo -d hello -so/dev/null -w '%{http_version}\n' -``` - -The first run answers `2`, and every one after it `3`. diff --git a/docs/alt-svc.rst b/docs/alt-svc.rst new file mode 100644 index 0000000..64545a2 --- /dev/null +++ b/docs/alt-svc.rst @@ -0,0 +1,55 @@ +.. cpp:namespace:: anyhttp + +Moving to HTTP/3: Alt-Svc +========================= + +HTTP/3 runs on QUIC, and QUIC is not something a TCP connection can turn into: there is no +``Connection: Upgrade`` on the way to HTTP/3, the way there is one from HTTP/1.1 to HTTP/2. What there +is instead is the server saying where else it can be reached (`RFC 7838 +`__), and the client making its *next* connection there. + +The server does that for its own HTTP/3 endpoint, which shares the address and port the TCP acceptor +is listening on, so the advertised alt-authority is a port and nothing else. An empty host in one +means "the host of the origin": + +.. code-block:: text + + Alt-Svc: h3=":8080"; ma=86400 + +It goes into every response sent over HTTP/1.1 and HTTP/2, but never over HTTP/3, which is already +there. :cpp:member:`server::Config::alt_svc_max_age` is how long a client may remember it, and ``0s`` +advertises nothing at all. + +A client only acts on it with :cpp:member:`client::Config::follow_alt_svc` set, and then it takes +precedence over :cpp:member:`client::Config::protocol`: + +.. code-block:: cpp + + client::Client client(executor, {.url = url, .protocol = Protocol::h2, .follow_alt_svc = true}); + + auto [ec1, first] = co_await client.connect(); // HTTP/2, and learns about the alternative + auto [ec2, second] = co_await client.connect(); // HTTP/3 + +The session that learns about the alternative keeps speaking what it speaks, as a connection in the +middle of a request can not be moved, and the alternative is remembered for as long as ``ma`` says, +but only for the lifetime of the :cpp:class:`client::Client`: there is no cache on disk. The origin +does not change with any of this, only where it is reached: requests still go out with the authority +of :cpp:member:`client::Config::url`. + +Over HTTP/2, an alternative may also arrive in an ``ALTSVC`` frame instead of a header field, which +lets a server advertise before the first request has even been sent. anyhttp's client reads both, and +its server sends the header field only. :cpp:func:`parse_alt_svc` is the parser of either. + +``curl`` does the same thing, which is the easy way to watch it happen. It honours ``Alt-Svc`` for +``https://`` origins only, so this needs TLS, and a cache file to remember the alternative between +invocations: + +.. code-block:: sh + + build/src/server -p 8080 + +.. code-block:: sh + + curl --alt-svc altsvc.txt --cacert pki/out/root.pem https://localhost:8080/echo -d hello -so/dev/null -w '%{http_version}\n' + +The first run answers ``2``, and every one after it ``3``. diff --git a/docs/api-styles.rst b/docs/api-styles.rst new file mode 100644 index 0000000..a131e35 --- /dev/null +++ b/docs/api-styles.rst @@ -0,0 +1,106 @@ +.. cpp:namespace:: anyhttp + +API styles +========== + +A build of the library is of one of two API styles, chosen when it is configured: + +* **ASIO**, on `Asio `__: every + operation is an asynchronous operation of Asio, and takes any completion token. It is also there + as a coroutine. +* **COROSIO**, on `Corosio `__: coroutines only. + +Both styles build the same library and protocol sources. Only a thin runtime layer differs, +``anyhttp/runtime.hpp`` and ``anyhttp/net.hpp`` with what they include from ``asio/`` or ``corosio/``. +:src:`docs/corosio-port-plan.md` has how that came about. + +.. _api-styles-vocabulary: + +What the names stand for +------------------------ + +.. list-table:: + :header-rows: 1 + :stub-columns: 1 + + * - + - ASIO + - COROSIO + * - CMake + - ``-DANYHTTP_API=ASIO`` (default) + - ``-DANYHTTP_API=COROSIO -DTLS_LIBRARY=OpenSSL`` + * - :cpp:type:`Task\ ` + - ``asio::awaitable`` + - ``capy::task`` + * - :cpp:type:`Executor` + - ``asio::any_io_executor`` + - ``capy::any_executor`` + * - :cpp:type:`error_code` + - ``boost::system::error_code`` + - ``std::error_code`` + * - :cpp:type:`TcpEndpoint`, :cpp:type:`UdpEndpoint` + - ``asio::ip::tcp::endpoint``, ``asio::ip::udp::endpoint`` + - ``corosio::endpoint`` + * - Operations + - ``async_x(..., token)`` and ``x()`` + - ``x()`` + * - TLS + - AWS-LC (default) or OpenSSL 3.5+ + - OpenSSL 3.5+ + * - Programs + - ``server``, ``client`` + - ``server`` + +Both styles have every operation as a coroutine, ``read_some``, ``write``, ``write_eof``, ``submit``, +``get_response``, ``connect``, ``get``, that yields a tuple and reports errors instead of throwing. +Code written like that builds with either, which is what the guide does. Buffers are Asio's in both, +headers are Beast's :cpp:type:`Fields`, URLs Boost.URL's. + +The reference shows what the headers have for ASIO, as Doxygen is told to read them that way. So the +reference has the operations with completion tokens that a COROSIO build does not have, and the types +as they are in the table. ``corosio/runtime.hpp`` has the same names as ``asio/runtime.hpp``, in the +same order. + +On ASIO, the implementation of each operation is hidden behind `any_completion_handler +`__, +so that it can be compiled separately. On COROSIO, they are coroutines of `Capy +`__, Corosio's base, cancelled through the awaiting chain's +``std::stop_token``. :cpp:class:`Reader` and :cpp:class:`Writer` model ``capy::ReadStream`` and +``capy::WriteStream``. + +.. _api-styles-errors: + +What the errors are +------------------- + +Where the two runtimes spell an error differently, the namespace ``anyhttp::errors`` names it: + +.. list-table:: + :header-rows: 1 + :stub-columns: 1 + + * - + - ASIO + - COROSIO + * - :cpp:var:`errors::eof` + - ``asio::error::eof`` + - ``capy::error::eof`` + * - :cpp:var:`errors::partial_message` + - ``beast::http::error::partial_message`` + - ``capy::error::stream_truncated`` + * - :cpp:var:`errors::canceled` + - ``errc::operation_canceled`` + - ``capy::error::canceled`` + * - :cpp:var:`errors::would_block` + - ``asio::error::would_block`` + - ``errc::operation_would_block`` + * - :cpp:var:`errors::connection_aborted` + - ``asio::error::connection_aborted`` + - ``errc::connection_aborted`` + * - :cpp:var:`errors::header_limit` + - ``beast::http::error::header_limit`` + - the same + +Anything else compares against ``errc::`` (``boost::system::errc`` or ``std::errc``), with one +exception: a host that does not resolve is ``netdb_errors::host_not_found`` on ASIO and +``errc::no_such_device_or_address`` on COROSIO. diff --git a/docs/capture.md b/docs/capture.md deleted file mode 100644 index e6480f6..0000000 --- a/docs/capture.md +++ /dev/null @@ -1,96 +0,0 @@ -# Capturing traffic - -How to look at what the server and its clients actually put on the wire, TLS and QUIC included: -`tcpdump` captures, `tshark` decodes, and a key log written by the client lets it decrypt. - -## Capture - -``` -sudo tcpdump -i lo -U -w ssl.pcap 'port 18080' -``` - -- `port 18080` matches TCP and UDP, so HTTP/1.1, HTTP/2 and HTTP/3 alike. -- Start it before the client connects, and wait for `listening on lo`: decryption needs the - handshake. Stop it with Ctrl-C. -- tcpdump drops root before it writes, so the file belongs to the user `tcpdump`. `*.pcap` and - `keylog.log` are in `.gitignore`. -- In a script, add `--immediate-mode`. Otherwise libpcap hands packets over in batches, about a - second apart, and stopping tcpdump right after the client has finished loses the last batch: "62 - packets received by filter, 0 packets captured". -- For a live view, pipe it: `sudo tcpdump -i lo -U -w - 'port 18080' | tshark -r -`. - -Plain HTTP/2 needs no options: tshark recognizes the connection preface (`Magic`). Only a capture -that starts in the middle of a connection needs `-d tcp.port==18080,http2` -- and that option then -breaks TLS on the same port, which the server also accepts. - -## Decrypting TLS and QUIC - -Have the client write its session secrets to a key log (the NSS format), and give that to tshark: - -``` -export SSLKEYLOGFILE=keylog.log -sudo tcpdump -i lo -U -w ssl.pcap 'port 18080' # in a second terminal, until done -curl -v --cacert pki/out/root.pem https://localhost:18080/test/data/64kminus1 -o /dev/null -tshark -r ssl.pcap -o tls.keylog_file:keylog.log -``` - -- curl, h2load and nghttp honour `SSLKEYLOGFILE`, and so do browsers and Python's - `ssl.create_default_context()`. Our own client and server do not write a key log. -- The same key log decrypts HTTP/3: `curl --http3-only ...`. -- The client appends to the file, and secrets of earlier sessions do no harm. But `export` makes - every TLS client started from that shell write its secrets there: `unset SSLKEYLOGFILE` - afterwards, or set it for the one command only (`SSLKEYLOGFILE=keylog.log curl ...`). -- Live decryption works too: tshark reads the key log again whenever it needs keys it does not - have, so `... | tshark -r - -o tls.keylog_file:keylog.log` picks up a session that starts after - it. - -## Useful views - -The one-line summary of a frame carrying HTTP/2 or TLS does not show its TCP flags, so a FIN that -rides on the last data segment is easy to miss. To list them: - -``` -tshark -r ssl.pcap -o tls.keylog_file:keylog.log \ - -T fields -e frame.number -e tcp.srcport -e tcp.flags.str -e _ws.col.Info -``` - -Narrower views, each added to the `tshark -r ssl.pcap -o tls.keylog_file:keylog.log` above: - -- TLS alerts, close_notify among them: `-Y tls.alert_message` -- GOAWAY frames, with their last stream ID and error code: - `-Y 'http2.type == 7' -T fields -e frame.number -e tcp.srcport -e http2.goaway.last_stream_id -e http2.goaway.error` -- One frame in full: `-Y frame.number==25 -O http2 -V` - -## Without a key log - -TLS 1.3 encrypts the record type, so an alert looks like `Application Data`. Its size gives it away -all the same: 2 bytes of alert, 1 byte of inner type and a 16-byte AEAD tag make a record of length -19, 24 bytes on the wire. An HTTP/2 frame alone is 9 bytes, so no record carrying HTTP/2 is that -short: - -``` -tshark -r ssl.pcap -Y 'tls.record.length == 19' -``` - -That tells an alert from data, not which alert it is -- but the last one before a FIN is the -close_notify. It does not hold for a peer that pads its records (OpenSSL and AWS-LC do not by -default). - -## What a clean end looks like - -Each side sends its GOAWAY, its close_notify (TLS only) and its FIN, and keeps reading until the -peer's FIN before it closes the socket. The server does that (`ServerSession::do_session()`, tested -in `test/test_connection_end.cpp`), and with a client that does the same, a TLS connection ends: - -``` -client GOAWAY, close_notify, FIN -server GOAWAY(last_stream_id=1, NO_ERROR) -server close_notify -server FIN -client ACK -``` - -Many clients do not wait: h2load, curl and nghttp close their socket right after their own GOAWAY -and close_notify. Whatever the server still sends -- its GOAWAY, its close_notify -- then reaches a -closed socket, and the client's kernel answers it with an RST. That RST is the client's doing; it -happens with nghttpd as well. diff --git a/docs/capture.rst b/docs/capture.rst new file mode 100644 index 0000000..b8885b4 --- /dev/null +++ b/docs/capture.rst @@ -0,0 +1,101 @@ +Capturing traffic +================= + +How to look at what the server and its clients actually put on the wire, TLS and QUIC included: +``tcpdump`` captures, ``tshark`` decodes, and a key log written by the client lets it decrypt. + +Capture +------- + +.. code-block:: sh + + sudo tcpdump -i lo -U -w ssl.pcap 'port 18080' + +* ``port 18080`` matches TCP and UDP, so HTTP/1.1, HTTP/2 and HTTP/3 alike. +* Start it before the client connects, and wait for ``listening on lo``: decryption needs the + handshake. Stop it with Ctrl-C. +* tcpdump drops root before it writes, so the file belongs to the user ``tcpdump``. ``*.pcap`` and + ``keylog.log`` are in ``.gitignore``. +* In a script, add ``--immediate-mode``. Otherwise libpcap hands packets over in batches, about a + second apart, and stopping tcpdump right after the client has finished loses the last batch: "62 + packets received by filter, 0 packets captured". +* For a live view, pipe it: ``sudo tcpdump -i lo -U -w - 'port 18080' | tshark -r -``. + +Plain HTTP/2 needs no options: tshark recognizes the connection preface (``Magic``). Only a capture +that starts in the middle of a connection needs ``-d tcp.port==18080,http2``, and that option then +breaks TLS on the same port, which the server also accepts. + +Decrypting TLS and QUIC +----------------------- + +Have the client write its session secrets to a key log (the NSS format), and give that to tshark: + +.. code-block:: sh + + export SSLKEYLOGFILE=keylog.log + sudo tcpdump -i lo -U -w ssl.pcap 'port 18080' # in a second terminal, until done + curl -v --cacert pki/out/root.pem https://localhost:18080/test/data/64kminus1 -o /dev/null + tshark -r ssl.pcap -o tls.keylog_file:keylog.log + +* curl, h2load and nghttp honour ``SSLKEYLOGFILE``, and so do browsers and Python's + ``ssl.create_default_context()``. Our own client and server do not write a key log. +* The same key log decrypts HTTP/3: ``curl --http3-only ...``. +* The client appends to the file, and secrets of earlier sessions do no harm. But ``export`` makes + every TLS client started from that shell write its secrets there: ``unset SSLKEYLOGFILE`` + afterwards, or set it for the one command only (``SSLKEYLOGFILE=keylog.log curl ...``). +* Live decryption works too: tshark reads the key log again whenever it needs keys it does not have, + so ``... | tshark -r - -o tls.keylog_file:keylog.log`` picks up a session that starts after it. + +Useful views +------------ + +The one-line summary of a frame carrying HTTP/2 or TLS does not show its TCP flags, so a FIN that +rides on the last data segment is easy to miss. To list them: + +.. code-block:: sh + + tshark -r ssl.pcap -o tls.keylog_file:keylog.log \ + -T fields -e frame.number -e tcp.srcport -e tcp.flags.str -e _ws.col.Info + +Narrower views, each added to the ``tshark -r ssl.pcap -o tls.keylog_file:keylog.log`` above: + +* TLS alerts, close_notify among them: ``-Y tls.alert_message`` +* GOAWAY frames, with their last stream ID and error code: + ``-Y 'http2.type == 7' -T fields -e frame.number -e tcp.srcport -e http2.goaway.last_stream_id -e http2.goaway.error`` +* One frame in full: ``-Y frame.number==25 -O http2 -V`` + +Without a key log +----------------- + +TLS 1.3 encrypts the record type, so an alert looks like ``Application Data``. Its size gives it away +all the same: 2 bytes of alert, 1 byte of inner type and a 16-byte AEAD tag make a record of length +19, 24 bytes on the wire. An HTTP/2 frame alone is 9 bytes, so no record carrying HTTP/2 is that +short: + +.. code-block:: sh + + tshark -r ssl.pcap -Y 'tls.record.length == 19' + +That tells an alert from data, not which alert it is, but the last one before a FIN is the +close_notify. It does not hold for a peer that pads its records (OpenSSL and AWS-LC do not by +default). + +What a clean end looks like +--------------------------- + +Each side sends its GOAWAY, its close_notify (TLS only) and its FIN, and keeps reading until the +peer's FIN before it closes the socket. The server does that (``ServerSession::do_session()``, tested +in :src:`test/test_connection_end.cpp`), and with a client that does the same, a TLS connection ends: + +.. code-block:: text + + client GOAWAY, close_notify, FIN + server GOAWAY(last_stream_id=1, NO_ERROR) + server close_notify + server FIN + client ACK + +Many clients do not wait: h2load, curl and nghttp close their socket right after their own GOAWAY and +close_notify. Whatever the server still sends, its GOAWAY and its close_notify, then reaches a closed +socket, and the client's kernel answers it with an RST. That RST is the client's doing, and it happens +with nghttpd as well. diff --git a/docs/capy-corosio-lessons.md b/docs/capy-corosio-lessons.md index 556d2ee..32c450e 100644 --- a/docs/capy-corosio-lessons.md +++ b/docs/capy-corosio-lessons.md @@ -166,7 +166,7 @@ biggest difference between the two engines: | | nghttp2 (pull, copies) | nghttp3 (pull, *borrows until ACK*) | |---|---|---| -| Engine behaviour | the data provider copies into the frame immediately | `read_data` hands out pointers that stay live until `acked_stream_data` | +| Engine behavior | the data provider copies into the frame immediately | `read_data` hands out pointers that stay live until `acked_stream_data` | | What we did | borrowed the caller's buffer descriptors, zero-copy (`f0f5859`) | **copy into a stream-owned staging area** (`kMaxStagedBytes = 256 KiB`) | | `write_some` completes | after one provider pull (a true partial write) | as soon as the bytes are staged | | Backpressure from | the peer's flow-control window | the staging bound | @@ -301,7 +301,7 @@ recvmsg(socket.native_handle(), ...); // transfer by hand; EAGAIN -> ngtcp2 and collapses the congestion window over what is only local backpressure. (anyhttp drops here.) - Make optional kernel features best-effort, with **sticky per-session fallbacks** (`no_gso_`, - `no_ecn_`). Each degradation lands exactly on the previous behaviour. + `no_ecn_`). Each degradation lands exactly on the previous behavior. - Value-initialise the send-side control buffer (`control[kControlSize]{}`). `CMSG_SPACE` pads beyond `CMSG_LEN`, `msg_controllen` covers that padding, and valgrind caught the kernel reading uninitialised stack on every GSO send. diff --git a/docs/concurrent-requests.rst b/docs/concurrent-requests.rst new file mode 100644 index 0000000..e342d02 --- /dev/null +++ b/docs/concurrent-requests.rst @@ -0,0 +1,91 @@ +.. cpp:namespace:: anyhttp + +Concurrent requests +=================== + +HTTP/2 and HTTP/3 multiplex requests: each one is a stream of its own, and streams make progress +independently. HTTP/1.1 has a single connection instead, which requests are written to and responses +read from, one after the other. anyhttp treats HTTP/1.1 as a protocol with **"max concurrent streams += 1"**, and makes that explicit in the client API instead of hiding it. + +Complete requests and responses +------------------------------- + +A request is *complete* when all of it, header and body, has been written to the connection: + +* A request **without a body** is complete as soon as :cpp:func:`Session::submit` has succeeded. + Whether a request has a body is a matter of its framing only (`RFC 9112, section 6.3 + `__), never of its method: it has none without + ``Transfer-Encoding`` and with a ``Content-Length`` of zero or none. As the HTTP/1.1 client sends + every request without ``Content-Length`` chunked, that means ``Content-Length: 0``. +* **Any other request** is complete when :cpp:func:`Writer::write_eof` has succeeded, even if all of + a ``Content-Length`` has been written before. The body of a request without one is ended + implicitly: ``write_eof()`` is an idempotent no-op on it, and writing data fails with + ``broken_pipe``. + +A response is *complete* when it has been read to its end. + +Rules for HTTP/1.1 +------------------ + +1. ``submit()`` fails with :cpp:var:`errors::would_block` while the previous request is not complete. +2. :cpp:func:`client::Request::get_response` fails with :cpp:var:`errors::would_block` while the + responses to earlier requests have not been read completely: otherwise, it would read one of those. +3. When a request can not be completed, as a write fails, or it is released before it is complete, + the connection is stuck in the middle of a message: every later ``submit()`` fails with + :cpp:var:`errors::connection_aborted`. +4. When a response can not be read completely, as it is released before its end, or its request is + released without asking for it, every later ``get_response()`` fails with + :cpp:var:`errors::connection_aborted`. + +Pipelining is still possible: complete requests can be sent before any of their responses have been +read. + +Design decisions +---------------- + +**Fail instead of waiting.** An operation that has to wait for an earlier request or response does +not wait. Very often, the caller waiting is the one who has to finish that earlier request or +response, after the operation returns, and waiting would deadlock. Failing immediately with +``would_block`` turns a hang into an error that can be handled: finish the earlier one, then retry. + +**No queueing of submitted requests.** An earlier version queued the header of a request submitted +while the previous one was still incomplete, and sent it as soon as that was complete. That allows +code written for HTTP/2, which submits a couple of requests first and writes their bodies later, to +work unchanged. But it needed a queue of pending requests, writes waiting for a header still on its +way (and not cancellable while doing so), and failures cascading to queued requests. Refusing the +submission keeps it at a single incomplete request per session, which is exactly what the protocol +allows. + +**Requests and responses may outlive their session.** The session keeps track of all of its readers +and writers, and detaches them when it goes away. Everything a detached request or response is asked +to do after that completes with an error (``connection_aborted`` for HTTP/1.1 and HTTP/2, +``connection_reset`` for HTTP/3), without touching the connection that is gone. + +Stream limits in HTTP/2 and HTTP/3 +---------------------------------- + +HTTP/2 and HTTP/3 have a limit of their own: the peer's ``SETTINGS_MAX_CONCURRENT_STREAMS``, or the +QUIC limit on bidirectional streams. Reaching it is answered the way HTTP/1.1 answers its limit of +one, ``submit()`` failing with ``would_block`` instead of waiting, so one and the same caller works +over all three. A server says what it allows with +:cpp:member:`server::Config::max_concurrent_streams`. Set it to 1, and HTTP/2 and HTTP/3 behave +exactly like HTTP/1.1, which is what ``OneStream.WHEN_limit_is_one_THEN_second_request_reports_would_block`` +checks for each of them. + +Two things to know about the slot a request occupies: + +* **It is held longer than in HTTP/1.1.** A stream counts against the limit until it is closed in + *both* directions, that is, until its response has been read as well, where HTTP/1.1 is free again + once the request has been written. So "max concurrent streams = 1" is stricter here than HTTP/1.1 + pipelining: the next request has to wait for the previous *response*, not just for the previous + request. +* **In HTTP/3 it comes free one round trip late.** The credit for another stream is granted as the + last one closes, and that ``MAX_STREAMS`` has to reach the client, so a ``submit()`` immediately + after reading a response may still report ``would_block``. Retrying it is what the contract asks + for anyway. + +Only the number is the peer's to choose. What to do when it is reached is anyhttp's, and the libraries +do not agree: nghttp2 version 2 and ngtcp2 refuse the stream, which is reported as ``would_block``, +while nghttp2 version 1 would accept it and hold it back internally, telling the caller nothing, so +streams in progress are counted here instead. diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..b8835af --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,57 @@ +# +# Sphinx makes the HTML, of the guide as well as of the reference, whose content Breathe takes from +# the XML that Doxygen makes of the public headers. 'cmake --build build --target docs' runs both +# and says where the XML is and which version this is, see CMakeLists.txt in here. +# +from docutils import nodes + +project = "anyhttp" +author = "Peter Eisenlohr" +copyright = "2026, Peter Eisenlohr" + +extensions = ["breathe", "sphinx.ext.extlinks"] + +# a file of the repository, which the pages link to rather than copy +extlinks = {"src": ("https://github.com/pgit/anyhttp/blob/master/%s", "%s")} + +breathe_default_project = "anyhttp" +breathe_default_members = () + +primary_domain = "cpp" +highlight_language = "cpp" +cpp_index_common_prefix = ["anyhttp::"] + +html_theme = "sphinx_rtd_theme" +html_theme_options = {"navigation_depth": 3} + +# the Markdown files in here are working notes, which the pages link to on GitHub +exclude_patterns = ["*.md", "_build"] + +# +# A reference that goes nowhere is an error, but only for what is there to go to: the names of the +# standard library, Boost and the runtimes are not documented here, and a namespace is only ever +# part of the name of what is in it. +# +nitpicky = True +nitpick_ignore_regex = [ + (r"cpp:.*", r"(std|boost|asio|capy|corosio)(::.*)?"), + (r"cpp:.*", r"(u?int\d+_t|size_t)"), + (r"cpp:.*", r"(errc(::.*)?|make_error_code)"), + (r"cpp:identifier", r"anyhttp(::(client|server|quic|errors))?"), + (r"cpp:identifier", r"(client|server|quic|errors)"), +] + + +def fix_dashes(app, doctree): + """Breathe 4.35 turns a "--" in a comment into the text "—", which then shows as just that. + + What it stands for is lost by then, but Doxygen would only make an em dash of "---", which the + comments do not use. + """ + for node in list(doctree.findall(nodes.Text)): + if "—" in node: + node.parent.replace(node, nodes.Text(node.replace("—", "\N{EN DASH}"))) + + +def setup(app): + app.connect("doctree-read", fix_dashes) diff --git a/docs/corosio-port-plan.md b/docs/corosio-port-plan.md index 3dba7cf..50e5edb 100644 --- a/docs/corosio-port-plan.md +++ b/docs/corosio-port-plan.md @@ -57,7 +57,7 @@ Ranked by how much each one threatens the approach, the most threatening first. | 1 | **Parked operations and their cancellation** | Backends park an `any_completion_handler` (read, write, get_response) and complete it from engine callbacks. Cancellation is per operation, through its cancellation slot (terminal/partial/total), and is what `cancel_after` drives. | A parked `coroutine_handle` plus the `await_context` from `await_suspend`. Cancellation comes from the *chain's* `std::stop_token`, which a `std::stop_callback` observes and which may fire on any thread. There is no per-operation slot: per-operation cancellation means `when_any` or `corosio::timeout` around the one operation. | A `Completion` type: `any_completion_handler` in ASIO, a continuation + env + result slot + `stop_callback` in COROSIO. It offers `complete(...)`, `complete_immediately(...)` and `on_cancel(fn)`. The backends' `Reader::Impl`/`Writer::Impl` virtuals take it in place of today's handler aliases. Stop maps to *terminal* cancellation, which is what the README's `async_write_eof()` rules are written for. | | 2 | **Completion resumes inline vs. posted** | `swap_and_invoke()` resumes the waiting coroutine inline, from inside nghttp2/nghttp3 callbacks. The recursion guards (`inside_call_read_handler_`) exist because of that. | Lessons §3.2: never resume application code inside an engine callback; `async_event::set()` posts. | In COROSIO, `Completion` posts to `env->executor`. The guards stay; they just never trigger there. Note that ASIO's `co_spawn` *also* starts inline (it `dispatch`es), so "spawn starts inline" (lessons §4.4) is not a new hazard. | | 3 | **Error vocabulary** | `boost::system::error_code`. The README contract is `asio::error::eof`, `http::error::partial_message`, `asio::error::would_block`/`connection_aborted`, `errc::broken_pipe`. | `std::error_code`; `capy::error::eof`, `capy::error::stream_truncated`, compared through `capy::cond::*`. | An `anyhttp::error_code` alias plus named constants per mode (`errors::eof`, `errors::truncated`, ...). Beast's own codes (`header_limit`, parser errors) cross over through Boost.System's `std::error_code` interop. Needs a test that compares through the conversion. The README gets a per-mode table. | -| 4 | **HTTP/1.1** | `h1/session.cpp` (1.4k lines) uses `http::async_read`/`async_write`, which need an ASIO AsyncStream. | No AsyncStream exists. | Move **both** modes onto Beast's parser/serializer, driven by read/write loops on the runtime layer (spike-proven, §0). This is the one step that changes ASIO behaviour-bearing code substantially, so it comes before any capy work, with the full suite green. | +| 4 | **HTTP/1.1** | `h1/session.cpp` (1.4k lines) uses `http::async_read`/`async_write`, which need an ASIO AsyncStream. | No AsyncStream exists. | Move **both** modes onto Beast's parser/serializer, driven by read/write loops on the runtime layer (spike-proven, §0). This is the one step that changes ASIO behavior-bearing code substantially, so it comes before any capy work, with the full suite green. | | 5 | **TLS over TCP, detection** | `asio::ssl::stream`. `detect_ssl`/`detect_h2` sniff into a `flat_buffer`, which is handed to the SSL handshake or the session. | `openssl_stream` + `tls_context` (`set_alpn`, `alpn_protocol()`). Its handshake takes no pre-read bytes, and it does not build against AWS-LC. | Detection becomes a portable prefix sniff. In COROSIO, a `PrefixedStream` replays the sniffed bytes beneath `openssl_stream` (lessons §9.2). Sessions already accept a pre-read `Buffer`. Spike the TLS-over-prefix stack first. | | 6 | **Timers** | `steady_timer`, re-armed for ngtcp2's expiry and elsewhere. | Public API: `corosio::delay` and `corosio::timeout`. A re-armable `timer` exists, but only as `corosio::detail::timer`. | The runtime layer offers a `Timer` with `expires_at`, `wait` and `cancel`. Build the COROSIO one on `detail::timer`, with a regression test, or restructure the h3 expiry loop the way lessons §3.1 does (`timeout(event.wait(), deadline)`). Decide at the h3 step. | | 7 | **Threads and strands** | `use_strand`, a per-connection strand, `server_main --threads`. Tests are single-threaded (`MULTITHREADED` is off). | `capy::strand` exists and corosio's `io_context` is thread-safe by default. But `async_event` is single-threaded by design, and nothing in the sibling project ran multi-threaded. | COROSIO started single-threaded. *Since 2026-10-03 it runs threaded too, see step 8:* `new_strand()` makes a `capy::strand`, and corosio resumes a coroutine after I/O through the coroutine's own executor, so a connection launched on its strand stays there. | @@ -103,7 +103,7 @@ Task when_both(Task, Task); ``` In ASIO mode each helper is a one-line forward (`s.async_read_some(b, as_tuple(use_awaitable))`), -so it adds no coroutine frame, and ASIO behaviour does not change. In COROSIO mode the public front +so it adds no coroutine frame, and ASIO behavior does not change. In COROSIO mode the public front ends become awaitables whose `await_suspend` builds a `Completion` and calls the same `*_any` entry point the token front ends call today. That is the pattern of the old `capy` branch, with corosio underneath instead of ASIO. @@ -137,7 +137,7 @@ the h3 write path also run under ASAN. From step 5 on, COROSIO's own tests must - The h2 receive loop stopped only because `start_write()` used to drain the GOAWAY inline. The send loop now cancels the pending read when it ends. 3. **HTTP/1.1 on Beast's parser and serializer**, in ASIO mode. This has the largest - behavioural risk: h2c upgrade, chunked bodies, 431, the "max concurrent streams = 1" rules. + behavioral risk: h2c upgrade, chunked bodies, 431, the "max concurrent streams = 1" rules. *Done 2026-09-30 (`8d11278`).* `h1/io.hpp` holds Beast's four composed operations as coroutines. The implementation interfaces have each operation in two shapes: handler (`async_*`) and coroutine (`read_some`, `write`, `submit`, `get_response`). A backend implements one and gets @@ -300,7 +300,7 @@ the h3 write path also run under ASAN. From step 5 on, COROSIO's own tests must Out of scope for the port, and kept as separate commits if wanted: what lessons §9.1 lists as "what this project does better" in h3 (parking on `EAGAIN`, `write_aggregate_pkt`, and so on). A -port that also changes behaviour cannot be verified against the old tests. +port that also changes behavior cannot be verified against the old tests. --------------------------------------------------------------------------------------------------- @@ -311,7 +311,7 @@ port that also changes behaviour cannot be verified against the old tests. - **The COROSIO API is capy-idiomatic**: `read_some`, `write`, `write_eof`, `submit`, `get_response`, `connect`, `get`. `Reader` and `Writer` model `capy::ReadStream`/`capy::WriteStream`. -- **Tests are shared, plus per mode.** Protocol-behaviour tests are written once, against thin +- **Tests are shared, plus per mode.** Protocol-behavior tests are written once, against thin per-mode helpers in the fixtures. Token and `cancel_after` tests stay ASIO-only; `stop_token` and `timeout` tests are COROSIO-only. While the port is under way, `test/CMakeLists.txt` lists the files COROSIO builds explicitly. That list grows until it equals the glob. diff --git a/docs/design.rst b/docs/design.rst new file mode 100644 index 0000000..dba6985 --- /dev/null +++ b/docs/design.rst @@ -0,0 +1,68 @@ +.. cpp:namespace:: anyhttp + +How it is put together +====================== + +What a program sees is a handful of move-only handles, each a pointer to an implementation that the +protocol backend provides: that is the type erasure. So a :cpp:class:`server::Request` and a +:cpp:class:`client::Response` are a :cpp:class:`Reader`, a :cpp:class:`server::Response` and a +:cpp:class:`client::Request` are a :cpp:class:`Writer`, and all of them have no data members but that +pointer. Moving one into a ``Reader`` or ``Writer`` hands off that half with its ownership intact. + +.. image:: overview.drawio.svg + :alt: The classes of the library and how they relate + +Layers +------ + +Each protocol lives in a directory of its own, in both ``include/anyhttp/`` and ``src/``. Only these +may include the header of the protocol library that goes with them, which is what keeps nghttp2, +ngtcp2 and nghttp3 from being compile-time dependencies of anyone who uses anyhttp: + +.. list-table:: + :header-rows: 1 + + * - Directory + - Protocol + - On top of + * - ``h1/`` + - HTTP/1.1 + - Beast's parser and serializer + * - ``h2/`` + - HTTP/2 + - nghttp2 + * - ``h2v2/`` + - HTTP/2, an alternative to ``h2/``, see :doc:`http2-library` + - nghttp2 version 2 + * - ``quic/`` + - QUIC transport, and plain QUIC streams + - ngtcp2 + * - ``h3/`` + - HTTP/3 + - nghttp3, and ``quic/`` + * - ``asio/``, ``corosio/`` + - the runtime, see :doc:`api-styles` + - Asio, or Corosio and Capy + +Generic code (``server_impl.*``, ``client_impl.*``) reaches a backend only through +``h1/backend.hpp``, ``h2/backend.hpp``, ``quic/backend.hpp`` and ``h3/backend.hpp``, and the runtime +only through ``anyhttp/runtime.hpp`` and ``anyhttp/net.hpp``. + +QUIC and HTTP/3 are two layers, and each shares one implementation between server and client. +``quic/session.*`` has all ngtcp2 callbacks, packet writing, timers and connection flow control, and +``quic/stream.*`` what every stream has. What runs on a connection is a ``QuicApplication``, picked by +ALPN: HTTP/3 is one (``h3/application.*``), and plain QUIC streams are the other +(``quic/streams.*``). That no HTTP is in the transport is something the build can verify, see +:src:`CLAUDE.md`. + +What gets installed is the public headers, the ones reachable from ``client.hpp``, ``server.hpp``, +``quic.hpp``, ``session.hpp`` and the like: :src:`cmake/install.cmake` leaves out the protocol +directories, ``net.hpp`` and ``*_impl.hpp``. + +History +------- + +This project started as a hand-written replacement for `nghttp2-asio +`__. Once the concept was working, HTTP/1.1 was added, and +both protocols were hidden behind a type-erased interface. Finally, HTTP/3 was added with the help of +agentic coding. diff --git a/docs/devcontainer.md b/docs/devcontainer.md deleted file mode 100644 index f7272a2..0000000 --- a/docs/devcontainer.md +++ /dev/null @@ -1,80 +0,0 @@ -# Development container - -anyhttp is developed and tested in a devcontainer. The same image runs the CI jobs in -[`.github/workflows/release.yml`](../.github/workflows/release.yml), so a build that works in the -container is the build CI does. - -There are three image layers. Only the last one is built locally when you open the project: - -| Image | Built from | Contents | -| ------------------------- | ------------------------------------------------------------------ | ------------------------------------------ | -| `psedoc/cpp-devcontainer` | [pgit/cpp-devcontainer](https://github.com/pgit/cpp-devcontainer) | generic C++ toolchain and libraries | -| `psedoc/anyhttp` | [`.devcontainer/base/Dockerfile`](../.devcontainer/base/Dockerfile) | the HTTP/2 and HTTP/3 stack anyhttp needs | -| (local, per checkout) | [`.devcontainer/Dockerfile`](../.devcontainer/Dockerfile) | interactive tools, shell settings | - -`psedoc/anyhttp` changes rarely and takes a while to build, so it is built once and pushed to -Docker Hub. Opening the project (or a codespace) then only pulls it and adds the thin top layer. - -## What cpp-devcontainer provides - -See [its repository](https://github.com/pgit/cpp-devcontainer) for details. In short: a Debian -trixie image with GCC, a current clang from apt.llvm.org, CMake, Ninja, gdb, and -`gtest-parallel`. The C++ libraries are built twice, once for each standard library, because a -C++ library built against libstdc++ cannot be linked into a libc++ program or vice versa: - -| Prefix | Standard library | Libraries | -| ------------- | ---------------- | ------------------------------- | -| `/usr/local` | libstdc++ (GCC) | Boost, GTest/GMock, fmt, spdlog | -| `/opt/libc++` | libc++ (clang) | the same set | - -With clang, anyhttp builds against libc++ and must pick up the libraries from `/opt/libc++`; with -GCC it uses libstdc++ and the ones in `/usr/local`. `USE_LIBCXX` in the top level -[`CMakeLists.txt`](../CMakeLists.txt) selects which (default: on for clang), and `LIBCXX_ROOT` -(default: `/opt/libc++`) where the libc++ builds are. - -## What anyhttp adds - -[`.devcontainer/base/Dockerfile`](../.devcontainer/base/Dockerfile) adds the protocol stack. All of -it is C, so a single build in `/usr/local` serves both standard libraries. - -- **AWS-LC** in `/opt/boringssl`, static libraries only. It is API-compatible with BoringSSL, - which is what ngtcp2 calls it. It is deliberately kept out of `/usr/local` so it does not - shadow the system OpenSSL; see "One TLS library per process" in [CLAUDE.md](../CLAUDE.md). -- **nghttp3** (lib only) in `/usr/local`. -- **ngtcp2** in `/usr/local`, with both crypto backends: `ngtcp2_crypto_boringssl` (what anyhttp - links by default) and `ngtcp2_crypto_ossl` (with `-DTLS_LIBRARY=OpenSSL`). Its OpenSSL example programs are installed as `osslclient` - and `osslserver` for interop tests. -- **urlparse** (from ngtcp2's third-party tree). -- **nghttp2** with HTTP/3 support, built against the OpenSSL variant: the library for the HTTP/2 - backend, plus `nghttp`, `nghttpd`, `nghttpx` and `h2load` for testing and benchmarking. - **libbpf** is built first so that `nghttpx` can use it for QUIC connection steering. -- **curl** from git master with nghttp2, ngtcp2 and nghttp3 over OpenSSL, so `curl --http3` - works. The apt package is removed so the tests cannot pick up an older curl. -- `libev` (needed by the nghttp2 and ngtcp2 tools) and `golang-cfssl` (used to generate the - test PKI in `pki/`). -- The Debian `libnghttp2-dev` / `libnghttp3-dev` packages are removed, so CMake and pkg-config - cannot find a stale system copy instead of the one in `/usr/local`. -- gdb helpers for the `vscode` user: libc++ pretty printers and Asio's debugger extensions - (backtraces across `co_await`), both registered in `~/.gdbinit`. - -The top layer, [`.devcontainer/Dockerfile`](../.devcontainer/Dockerfile), adds only things for -interactive use: `tmux`, `btop`, `valgrind`, `ping`, persistent bash history (a volume mounted at -`/commandhistory`), and the aliases in `.devcontainer/.bash_aliases`. -[`devcontainer.json`](../.devcontainer/devcontainer.json) adds the VS Code extensions (clangd, -CMake Tools, TestMate, CodeLLDB, ...), `SYS_PTRACE` for debuggers and sanitizers, and bind-mounts -`~/.claude` from the host. - -## Updating the images - -Bump a dependency by changing its `ARG ..._VERSION` in the base Dockerfile, then: - -``` -cd .devcontainer/base -docker build -t psedoc/anyhttp:testing . -``` - -`.devcontainer/Dockerfile` builds `FROM` that tag, so rebuilding the devcontainer ("Dev -Containers: Rebuild Container" in VS Code) picks it up. Once the tests pass in it, tag and push a -numbered version and point both `.devcontainer/Dockerfile` and the `image:` entries in -`.github/workflows/release.yml` at it. Keep the `psedoc/anyhttp` and `psedoc/cpp-devcontainer` -version numbers in step, so it is clear which base a given anyhttp image was built on. diff --git a/docs/devcontainer.rst b/docs/devcontainer.rst new file mode 100644 index 0000000..29dfa61 --- /dev/null +++ b/docs/devcontainer.rst @@ -0,0 +1,101 @@ +Development container +===================== + +anyhttp is developed and tested in a devcontainer. The same image runs the CI jobs in +:src:`.github/workflows/release.yml`, so a build that works in the container is the build CI does. + +There are three image layers. Only the last one is built locally when you open the project: + +.. list-table:: + :header-rows: 1 + + * - Image + - Built from + - Contents + * - ``psedoc/cpp-devcontainer`` + - `pgit/cpp-devcontainer `__ + - generic C++ toolchain and libraries + * - ``psedoc/anyhttp`` + - :src:`.devcontainer/base/Dockerfile` + - the HTTP/2 and HTTP/3 stack anyhttp needs + * - (local, per checkout) + - :src:`.devcontainer/Dockerfile` + - interactive tools, shell settings, what makes this documentation + +``psedoc/anyhttp`` changes rarely and takes a while to build, so it is built once and pushed to Docker +Hub. Opening the project (or a codespace) then only pulls it and adds the thin top layer. + +What cpp-devcontainer provides +------------------------------ + +See `its repository `__ for details. In short: a Debian +trixie image with GCC, a current clang from apt.llvm.org, CMake, Ninja, gdb, and ``gtest-parallel``. +The C++ libraries are built twice, once for each standard library, because a C++ library built against +libstdc++ cannot be linked into a libc++ program or vice versa: + +.. list-table:: + :header-rows: 1 + + * - Prefix + - Standard library + - Libraries + * - ``/usr/local`` + - libstdc++ (GCC) + - Boost, GTest/GMock, fmt, spdlog + * - ``/opt/libc++`` + - libc++ (clang) + - the same set + +With clang, anyhttp builds against libc++ and must pick up the libraries from ``/opt/libc++``; with GCC +it uses libstdc++ and the ones in ``/usr/local``. ``USE_LIBCXX`` in the top level +:src:`CMakeLists.txt` selects which (default: on for clang), and ``LIBCXX_ROOT`` (default: +``/opt/libc++``) where the libc++ builds are. + +What anyhttp adds +----------------- + +:src:`.devcontainer/base/Dockerfile` adds the protocol stack. All of it is C, so a single build in +``/usr/local`` serves both standard libraries. + +* **AWS-LC** in ``/opt/boringssl``, static libraries only. It is API-compatible with BoringSSL, which + is what ngtcp2 calls it. It is deliberately kept out of ``/usr/local`` so it does not shadow the + system OpenSSL; see "One TLS library per process" in :src:`CLAUDE.md`. +* **nghttp3** (lib only) in ``/usr/local``. +* **ngtcp2** in ``/usr/local``, with both crypto backends: ``ngtcp2_crypto_boringssl`` (what anyhttp + links by default) and ``ngtcp2_crypto_ossl`` (with ``-DTLS_LIBRARY=OpenSSL``). Its OpenSSL example + programs are installed as ``osslclient`` and ``osslserver`` for interop tests. +* **urlparse** (from ngtcp2's third-party tree). +* **nghttp2** with HTTP/3 support, built against the OpenSSL variant: the library for the HTTP/2 + backend, plus ``nghttp``, ``nghttpd``, ``nghttpx`` and ``h2load`` for testing and benchmarking. + **libbpf** is built first so that ``nghttpx`` can use it for QUIC connection steering. +* **curl** from git master with nghttp2, ngtcp2 and nghttp3 over OpenSSL, so ``curl --http3`` works. + The apt package is removed so the tests cannot pick up an older curl. +* ``libev`` (needed by the nghttp2 and ngtcp2 tools) and ``golang-cfssl`` (used to generate the test + PKI in ``pki/``). +* The Debian ``libnghttp2-dev`` / ``libnghttp3-dev`` packages are removed, so CMake and pkg-config + cannot find a stale system copy instead of the one in ``/usr/local``. +* gdb helpers for the ``vscode`` user: libc++ pretty printers and Asio's debugger extensions + (backtraces across ``co_await``), both registered in ``~/.gdbinit``. + +The top layer, :src:`.devcontainer/Dockerfile`, adds things for interactive use: ``tmux``, ``btop``, +``valgrind``, ``ping``, persistent bash history (a volume mounted at ``/commandhistory``), and the +aliases in ``.devcontainer/.bash_aliases``. It also has Doxygen, Sphinx and Breathe, for this +documentation. :src:`.devcontainer/devcontainer.json` adds the VS Code extensions (clangd, CMake Tools, +TestMate, CodeLLDB, ...), ``SYS_PTRACE`` for debuggers and sanitizers, and bind-mounts ``~/.claude`` +from the host. + +Updating the images +------------------- + +Bump a dependency by changing its ``ARG ..._VERSION`` in the base Dockerfile, then: + +.. code-block:: sh + + cd .devcontainer/base + docker build -t psedoc/anyhttp:testing . + +``.devcontainer/Dockerfile`` builds ``FROM`` that tag, so rebuilding the devcontainer ("Dev Containers: +Rebuild Container" in VS Code) picks it up. Once the tests pass in it, tag and push a numbered version +and point both ``.devcontainer/Dockerfile`` and the ``image:`` entries in +``.github/workflows/release.yml`` at it. Keep the ``psedoc/anyhttp`` and ``psedoc/cpp-devcontainer`` +version numbers in step, so it is clear which base a given anyhttp image was built on. diff --git a/docs/end-of-body.md b/docs/end-of-body.md deleted file mode 100644 index 6164676..0000000 --- a/docs/end-of-body.md +++ /dev/null @@ -1,47 +0,0 @@ -# The End of a Body - -A body ends where the protocol says it ends, and never at a zero-sized transfer. Reading and -writing are not symmetric here: the end of an incoming body is *reported*, the end of an outgoing -one is *stated*. - -## Reading - -`read_some()` completes with `errors::eof` and zero bytes at the end of the body, and -keeps reporting that for every read after it -- there is no state in which a finished body starts -looking unfinished again. A body cut short completes with `errors::partial_message` instead: -the two are the whole difference between "the peer is done" and "the peer is gone", and neither -degrades into the other. - -Reading into a zero-length buffer completes immediately, with success, and says nothing at all -about the body. It is not a way to poll for the end. - -## Writing - -`write_eof()` ends the body, with or without a buffer of its own (see [Server](../README.md#server) -in the README). `write({})` is a no-op: it succeeds and leaves the body open. - -That an empty write means nothing is deliberate. Generic code that forwards whatever it just read -will hand over an empty buffer eventually, and a message that ends by accident is a bug that only -shows up under load. Ending a body is an explicit operation. - -## After the end - -All three protocols answer in the same order, whatever the state of the connection underneath: - -1. An empty non-EOF write succeeds -- always, even after the body has ended. -2. Once the body has ended, writing data fails with `errc::broken_pipe`, whether it comes - through `write()` or `write_eof(buffer)`. Ending an already ended body without data - succeeds: it is idempotent. -3. Only then do the errors of a closed or cancelled stream apply. Their codes differ per protocol - (`connection_aborted` for HTTP/1.1 and HTTP/2, `connection_reset` for HTTP/3). - -This order survives the session going away. A detached reader or writer answers from the state it -latched, so a read past a detached stream still distinguishes a clean end (`eof`) from a truncated -one (`partial_message`), without touching the connection that is gone. - -## Cancellation - -Cancelling `write_eof()` does not take the end of the body back. The end is declared the -moment the operation starts, and no protocol can un-send a FIN; cancellation detaches the handler, -and issuing `write_eof()` again adopts the end that is already on its way rather than -failing. Intent and delivery are tracked separately for exactly this reason. diff --git a/docs/end-of-body.rst b/docs/end-of-body.rst new file mode 100644 index 0000000..3a3d3a0 --- /dev/null +++ b/docs/end-of-body.rst @@ -0,0 +1,57 @@ +.. cpp:namespace:: anyhttp + +The end of a body +================= + +A body ends where the protocol says it ends, and never at a zero-sized transfer. Reading and writing +are not symmetric here: the end of an incoming body is *reported*, the end of an outgoing one is +*stated*. + +Reading +------- + +:cpp:func:`Reader::read_some` completes with :cpp:var:`errors::eof` and zero bytes at the end of the +body, and keeps reporting that for every read after it: there is no state in which a finished body +starts looking unfinished again. A body cut short completes with +:cpp:var:`errors::partial_message` instead. The two are the whole difference between "the peer is +done" and "the peer is gone", and neither degrades into the other. + +Reading into a zero-length buffer completes immediately, with success, and says nothing at all about +the body. It is not a way to poll for the end. + +Writing +------- + +:cpp:func:`Writer::write_eof` ends the body, with or without a buffer of its own, see +:doc:`programmers-guide`. :cpp:func:`write({}) ` is a no-op: it succeeds and leaves the +body open. + +That an empty write means nothing is deliberate. Generic code that forwards whatever it just read +will hand over an empty buffer eventually, and a message that ends by accident is a bug that only +shows up under load. Ending a body is an explicit operation. + +After the end +------------- + +All three protocols answer in the same order, whatever the state of the connection underneath: + +1. An empty non-EOF write succeeds, always, even after the body has ended. +2. Once the body has ended, writing data fails with ``errc::broken_pipe``, whether it comes through + ``write()`` or ``write_eof(buffer)``. Ending an already ended body without data succeeds: it is + idempotent. +3. Only then do the errors of a closed or cancelled stream apply. Their codes differ per protocol: + ``connection_aborted`` for HTTP/1.1 and HTTP/2, ``connection_reset`` for HTTP/3. + +This order survives the session going away. A detached reader or writer answers from the state it +latched, so a read past a detached stream still distinguishes a clean end (``eof``) from a truncated +one (``partial_message``), without touching the connection that is gone. + +.. _end-of-body-cancellation: + +Cancellation +------------ + +Cancelling ``write_eof()`` does not take the end of the body back. The end is declared the moment the +operation starts, and no protocol can un-send a FIN. Cancellation detaches the handler, and issuing +``write_eof()`` again adopts the end that is already on its way rather than failing. Intent and +delivery are tracked separately for exactly this reason. diff --git a/docs/examples/hello_client.cpp b/docs/examples/hello_client.cpp new file mode 100644 index 0000000..9366d80 --- /dev/null +++ b/docs/examples/hello_client.cpp @@ -0,0 +1,61 @@ +// +// The second program of the programmer's guide: one GET, over HTTP/2 unless told otherwise, which +// prints what comes back. Takes the URL, and for a server with a certificate of a private CA, the +// file that has that CA in it. +// +#include +#include + +#if ANYHTTP_COROSIO +#include +using IoContext = boost::corosio::io_context; +#else +#include +using IoContext = boost::asio::io_context; +#endif + +#include +#include + +using namespace anyhttp; + +static Task fetch(client::Client& client, boost::urls::url url, int& result) +{ + auto [connect_ec, session] = co_await client.connect(); + if (connect_ec) + { + std::println(stderr, "connect: {}", connect_ec.message()); + co_return; + } + + auto [get_ec, message] = co_await session.get(url); + if (get_ec) + { + std::println(stderr, "get: {}", get_ec.message()); + co_return; + } + + std::println("{} over {}: {}", message.result_int(), to_string(session.protocol()), + message.body()); + result = 0; +} + +int main(int argc, char* argv[]) +{ + if (argc < 2 || argc > 3) + { + std::println(stderr, "usage: {} []", argv[0]); + return 2; + } + + IoContext context; + + boost::urls::url url(argv[1]); + client::Client client(context.get_executor(), + {.url = url, .tls_ca_file = argc == 3 ? argv[2] : ""}); + + int result = 1; + launch(context.get_executor(), fetch(client, url, result)); + context.run(); + return result; +} diff --git a/docs/examples/hello_server.cpp b/docs/examples/hello_server.cpp new file mode 100644 index 0000000..f2539d2 --- /dev/null +++ b/docs/examples/hello_server.cpp @@ -0,0 +1,45 @@ +// +// The first program of the programmer's guide: answers every request, over HTTP/1.1, HTTP/2 and +// HTTP/3, on one port. Takes the certificate chain and the key to use, and if there are none, the +// test PKI that the build makes, relative to the working directory. +// +#include + +#if ANYHTTP_COROSIO +#include +using IoContext = boost::corosio::io_context; +#else +#include +using IoContext = boost::asio::io_context; +#endif + +#include + +using namespace anyhttp; + +static Task hello(server::Request request, server::Response response) +{ + constexpr std::string_view body = "Hello, World!\n"; + + if (auto [ec] = co_await response.submit(200, fields({{"Content-Length", body.size()}})); ec) + co_return; + + std::ignore = co_await response.write_eof(asio::buffer(body)); +} + +int main(int argc, char* argv[]) +{ + IoContext context; + + server::Config config{.port = 8080}; + if (argc == 3) + { + config.tls_certificate_chain = argv[1]; + config.tls_private_key = argv[2]; + } + + server::Server server(context.get_executor(), config); + server.on_request(hello); + + context.run(); +} diff --git a/docs/http2-library.rst b/docs/http2-library.rst new file mode 100644 index 0000000..38db967 --- /dev/null +++ b/docs/http2-library.rst @@ -0,0 +1,49 @@ +.. cpp:namespace:: anyhttp + +The HTTP/2 library +================== + +HTTP/2, server and client both, runs on either nghttp2 or its ground-up rewrite, `nghttp2 version 2 +`__, by the same author. The rewrite's API is much closer to +nghttp3's than to version 1's: one ``nghttp2_conn`` instead of a session and an option object, +settings and callbacks as plain structs, callbacks for connection and HTTP events rather than for +frames, bodies taken by reference, and flow control credit extended by hand. + +.. list-table:: + :header-rows: 1 + :stub-columns: 1 + + * - + - nghttp2 + - nghttp2 version 2 + * - CMake + - ``-DH2_LIBRARY=nghttp2`` (default) + - ``-DH2_LIBRARY=nghttp2v2`` + * - Source + - ``src/h2/``, ``include/anyhttp/h2/`` + - ``src/h2v2/``, ``include/anyhttp/h2v2/`` + +This is a choice when a build is configured, not at runtime, because the two cannot share a process: +version 2 installs as its own package but still exports nine of version 1's symbol names, among them +the four ``nghttp2_rcbuf_*`` whose structs differ. A build links one or the other, and compiles only +the backend directory that goes with it. + +Nothing above the backend changes: the same :cpp:enumerator:`Protocol::h2`, the same API, and the same +behavior wherever the two libraries leave anyhttp the choice. Where they do not: + +* The ``Connection: Upgrade`` from HTTP/1.1 to h2c (`RFC 7540, section 3.2 + `__) went with `RFC 9113 + `__ and has no counterpart in version 2, so such a request is + declined and served as HTTP/1.1 instead, which is always a valid answer to an upgrade. +* The ``ALTSVC`` frame is gone. An alternative service still arrives as a response header field, which + is how a server sends one anyway, see :doc:`alt-svc`. + +Version 2 has had no release yet, so a build pins a commit of it. + +What ``h2v2/`` has that ``h2/`` has no counterpart for is its write mode, because version 2 takes an +outgoing body by reference where version 1 copied it. nghttp2 keeps reading from the vector it was +handed until it has framed all of it, which flow control can postpone indefinitely. So a response is +handed over straight out of the caller's buffer, and a write cancelled with bytes still outstanding +resets the stream, there being no way to un-offer that memory. A request body is copied through a +bounded buffer of the stream's, so that cancelling a write leaves the stream usable and the body can +still be ended afterwards. This is the same split, for the same reason, as HTTP/3 has. diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..999ebbf --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,57 @@ +anyhttp +======= + +A low-level C++23 HTTP client and server library: HTTP/1.1, HTTP/2 and HTTP/3 behind one +type-erasing interface, hence the *any* in the name, and plain QUIC streams next to HTTP/3 for +protocols of your own. It runs on either `Asio +`__ or `Corosio +`__, chosen when a build is configured. + +.. warning:: + **THIS REPOSITORY IS PURELY EXPERIMENTAL.** It is not meant for production use, and anything in it + can change or break at any time, without notice. + +None of the protocols is implemented from scratch. The library is a wrapper around `Beast +`__ (its parser and serializer), `nghttp2 +`__ or `nghttp2 version 2 +`__, and `ngtcp2 `__ with +`nghttp3 `__. + +:doc:`programmers-guide` is the place to start. It goes through what a program does with the +library, in the order it does it, and links to :doc:`reference` for everything it mentions. + +The `source is on GitHub `__, and so is `its CI +`__, which also makes the `coverage +report `__. + +.. toctree:: + :maxdepth: 2 + :caption: Guide + + programmers-guide + +.. toctree:: + :maxdepth: 1 + :caption: How it works + + api-styles + end-of-body + concurrent-requests + alt-svc + http2-library + design + +.. toctree:: + :maxdepth: 2 + :caption: Reference + + reference + +.. toctree:: + :maxdepth: 1 + :caption: Working on anyhttp + + testing + capture + devcontainer + notes diff --git a/docs/notes.rst b/docs/notes.rst new file mode 100644 index 0000000..de6611d --- /dev/null +++ b/docs/notes.rst @@ -0,0 +1,15 @@ +Working notes +============= + +These stay in Markdown next to the pages, as they are records of how something was done or measured +rather than documentation of how it is. So they say what was true when they were written. + +* :src:`docs/benchmark.md`: how to measure, the tools that work on the development host, and what has + been measured so far. +* :src:`docs/corosio-port-plan.md`: how the COROSIO API style came about, step by step, and what is + left of it. +* :src:`docs/capy-corosio-lessons.md`: what the port taught about Capy and Corosio. +* :src:`docs/quic-streams-plan.md`: how plain QUIC streams got separated from HTTP/3. + +:src:`CLAUDE.md` is how to work in the tree: the build trees and what each is for, the rules for +what may include what, and the traps. diff --git a/docs/programmers-guide.rst b/docs/programmers-guide.rst new file mode 100644 index 0000000..4cf963f --- /dev/null +++ b/docs/programmers-guide.rst @@ -0,0 +1,521 @@ +.. cpp:namespace:: anyhttp + +Programmer's guide +================== + +This goes through what a program does with the library, from its first request to shutting down. +The code is taken from :src:`docs/examples` and :src:`docs/snippets.cpp`, which are compiled with +every build, and :src:`test/test_docs.cpp` runs it over all three protocols. What is about how it +all works rather than how it is used has a page of its own, starting with :doc:`api-styles`. + +.. contents:: On this page + :local: + :depth: 2 + +Getting the library +------------------- + +It takes C++23, CMake 3.30 and Boost 1.88 or later, spdlog, nghttp2, ngtcp2 and nghttp3. The +Dockerfiles in :src:`.devcontainer` build all of them, and those are the versions that are tested, +see :doc:`devcontainer`. Whatever else goes into the process has to agree with anyhttp on the +standard library and the TLS library. + +As a subproject, which builds the library and nothing else, in both API styles: + +.. code-block:: cmake + + include(FetchContent) + FetchContent_Declare(anyhttp + GIT_REPOSITORY https://github.com/pgit/anyhttp.git + GIT_TAG + SYSTEM) + set(TLS_LIBRARY OpenSSL) # the options of the API style and the libraries, if the defaults won't do + FetchContent_MakeAvailable(anyhttp) + + target_link_libraries(my_program PRIVATE anyhttp::anyhttp) + +``ANYHTTP_BUILD_TESTS``, ``ANYHTTP_BUILD_APPS``, ``ANYHTTP_BUILD_DOCS`` and ``ANYHTTP_INSTALL`` +default to ``OFF`` that way. To develop against a checkout, set ``FETCHCONTENT_SOURCE_DIR_ANYHTTP`` +to it. + +Or installed, which is for the ASIO style only: Corosio writes install rules only when it is the +top-level project, so a library that links it can't be exported. + +.. code-block:: sh + + cmake --preset build-deploy + cmake --build --preset build-deploy + cmake --install build-deploy --prefix /opt/anyhttp + +.. code-block:: cmake + + find_package(anyhttp 0.0 REQUIRED) # with /opt/anyhttp in CMAKE_PREFIX_PATH + target_link_libraries(my_program PRIVATE anyhttp::anyhttp) + +``anyhttp_API``, ``anyhttp_TLS_LIBRARY`` and ``anyhttp_H2_LIBRARY`` then say what that copy was +built with. The package has the public headers only, those of the protocol backends stay behind. +It looks for its dependencies where they were when it was built. + +A build is of one API style or the other, ``-DANYHTTP_API=ASIO``, the default, or ``COROSIO``, which +also takes ``-DTLS_LIBRARY=OpenSSL``. :doc:`api-styles` has the difference. Everything below uses +what both have and builds with either, and what is for ASIO only says so. + +A first server +-------------- + +The whole of a program, which answers every request: + +.. literalinclude:: examples/hello_server.cpp + :language: cpp + :start-at: #include + +A :cpp:class:`server::Server` is made on an executor, and from then on everything it does happens +there. There is no thread behind it, so nothing happens unless the context runs. It starts +listening as it is made, on TCP and UDP, on the same port, and serves HTTP/1.1, HTTP/2 and HTTP/3 +from there, with TLS and without. So there is nothing to choose: try it with ``curl``, which takes +the test PKI that the build makes in ``pki/out``. + +.. code-block:: sh + + build/docs/hello_server & + curl http://localhost:8080/ + curl --cacert pki/out/root.pem https://localhost:8080/ + curl --cacert pki/out/root.pem --http3-only https://localhost:8080/ + +The handler gets a :cpp:class:`server::Request` and a :cpp:class:`server::Response`, by value: each +one a move-only handle, whose end is what the peer gets to see. Each request is a coroutine of its +own, and on HTTP/2 and HTTP/3 any number of them run at the same time. + +Every server loads a certificate, also one that is meant to serve cleartext only. The defaults of +:cpp:member:`tls_certificate_chain ` and +:cpp:member:`tls_private_key ` are the test PKI, relative to the +working directory, which is good for development and for nothing else. Without either, making the +server throws. + +A first client +-------------- + +The whole of a program, which makes one GET and prints what comes back: + +.. literalinclude:: examples/hello_client.cpp + :language: cpp + :start-at: #include + +A :cpp:class:`client::Client` makes connections, and :cpp:func:`connect() ` +gives each as a :cpp:class:`Session`, which is what requests are submitted on. There is no error to +catch: like every operation that both API styles have, these yield a tuple with the error first, and +report what goes wrong instead of throwing it. The library has something to say about it all, too, +which goes to the default logger of spdlog, see `Logging`_. + +.. code-block:: sh + + build/docs/hello_client http://localhost:8080/ + build/docs/hello_client https://localhost:8080/ pki/out/root.pem + +Serving requests +---------------- + +Reading and writing a body +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This echoes what it gets, as it gets it: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [echo] + :end-before: // [echo-end] + +:cpp:func:`Writer::content_length` says how long the body will be, before +:cpp:func:`submit() ` sends the header. It can be said with a header field +as well, and what is said neither way goes out as a chunked body over HTTP/1.1. + +The end of an incoming body is reported the way the runtime reports it everywhere else: +:cpp:var:`errors::eof` with zero bytes. A body cut short by a reset stream, or a connection that +went away in the middle of a message, completes with :cpp:var:`errors::partial_message` instead, so +the two stay distinguishable. + +The end of an *outgoing* body is stated explicitly, with :cpp:func:`Writer::write_eof`. It takes a +buffer of its own, so the last of the body and the end of it go out together: one DATA frame with +END_STREAM, one QUIC STREAM frame with FIN, one last chunk, instead of costing a second, empty write. + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [write-at-once] + :end-before: // [write-at-once-end] + +:cpp:func:`fields` is how to write a few header fields in place, and takes numbers as well as +strings. :doc:`end-of-body` has what every operation answers after the end. + +Telling requests apart +^^^^^^^^^^^^^^^^^^^^^^ + +A server has one handler, and what it does with the method, the URL and the fields of a request is +up to it. :cpp:func:`get_param_as() ` looks up a parameter of the +query and converts it: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [route] + :end-before: // [route-end] + +Handing the request and the response on, as this does, is all it takes, as they are nothing but +handles. So a handler may also give the reading half to one coroutine and the writing half to +another, and :cpp:class:`server::Request` converts to a :cpp:class:`Reader` for the purpose, and +:cpp:class:`server::Response` to a :cpp:class:`Writer`. + +Limits +^^^^^^ + +:cpp:member:`max_header_size ` bounds the header section of a +request, which is answered with 431 if it has more, without the handler seeing it. +:cpp:member:`max_concurrent_streams ` bounds the requests +that one connection has in progress over HTTP/2 and HTTP/3, see :doc:`concurrent-requests`. Bodies +are bounded by flow control: what nobody reads, nobody sends more of. + +Making requests +--------------- + +One request, one operation +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:cpp:func:`Session::get` does a whole GET as one operation, and hands back the response as a plain +Beast message, with status, fields and the body as a ``std::string``: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [get] + :end-before: // [get-end] + +A response that says 404 is not an error but a response, and arrives as one. The convenience is paid +for with memory, as the body is buffered in full. Anything that wants to look at the body while it +arrives, or to send a body of its own, goes through :cpp:func:`Session::submit`. + +A request in four steps +^^^^^^^^^^^^^^^^^^^^^^^ + +:cpp:func:`Session::submit` sends the header and yields a :cpp:class:`client::Request`, whose body +:cpp:func:`Writer::write_eof` ends, and :cpp:func:`client::Request::get_response` waits for the +header of the response. That gives a :cpp:class:`client::Response`, which is a +:cpp:class:`Reader` for the rest: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [post] + :end-before: // [post-end] + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [read-all] + :end-before: // [read-all-end] + +``submit()`` is a POST unless it is given a method. A request without a ``Content-Length`` has a +chunked body over HTTP/1.1, except for GET and HEAD, which go out with ``Content-Length: 0`` unless +their fields frame a body. + +Stopping early +^^^^^^^^^^^^^^ + +Reading a response from a server that may send more than you want stops wherever you do, with status +and header fields in hand: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [partial] + :end-before: // [partial-end] + +What the server gets to send in the meantime is bounded by flow control, and over HTTP/1.1 by the +socket buffers. Releasing the response early also resets its stream with HTTP/2 and HTTP/3, leaving +the session usable, but it is the end of an HTTP/1.1 connection. + +Writing and reading at the same time +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Writing a whole body and only then reading the response is fine for a body that is small, or a +server that reads it to its end first. For one that answers while it reads, as the echo above does, +the two would wait for each other as soon as all the buffers on the way are full, so the two have to +happen at the same time, which :cpp:func:`when_both` is for: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [both-ways] + :end-before: // [both-ways-end] + +The helpers in here report what goes wrong by throwing, as that is what makes ``when_both()`` cancel +the other one. + +Several requests on one session +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +HTTP/2 and HTTP/3 multiplex requests, up to a limit that the peer sets, and HTTP/1.1 has a limit of +one, with some pipelining. In all of them, ``submit()`` at the limit does not wait: the caller may be +the one who has to finish the request that is in the way. It fails with +:cpp:var:`errors::would_block`, and a caller who knows that something else will finish that request +can retry later: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [would-block] + :end-before: // [would-block-end] + +:doc:`concurrent-requests` has the rules, and over TLS, :cpp:func:`Session::protocol` says what the +server has picked. There is also a limit on the header section of a response, +:cpp:member:`client::Config::max_header_size`. + +What a client connects to +------------------------- + +An ``https`` URL means TLS, with the server's certificate verified against the default trust store +of the TLS library: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [tls] + :end-before: // [tls-end] + :dedent: + +or against :cpp:member:`tls_ca_file `, as a PEM file, which is read on +every connect: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [private-ca] + :end-before: // [private-ca-end] + :dedent: + +:cpp:member:`protocol ` then says what to offer with ALPN. The default +``h2`` also offers HTTP/1.1, and the server picks. Any other scheme means cleartext, and exactly the +protocol given, which for HTTP/2 means prior knowledge. HTTP/3 is TLS over QUIC with any scheme: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [cleartext] + :end-before: // [cleartext-end] + :dedent: + +The URL says what the connection is for and, unless told otherwise, also where it goes. A caller with +a resolver of its own, or following a DNS HTTPS record, gives the addresses in +:cpp:member:`endpoints `, which are tried in order. Nothing is resolved +then, while the certificate still has to be valid for the host of the URL, and requests still say +they are for it: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [endpoints] + :end-before: // [endpoints-end] + :dedent: + +To get from HTTP/1.1 or HTTP/2 to HTTP/3, see :doc:`alt-svc`. Over TLS, a body that ends with the +connection is complete only if the server has said so with a ``close_notify``: otherwise, it may +have been cut by anyone, and reading it fails with :cpp:var:`errors::partial_message`. + +Errors +------ + +What goes wrong is an :cpp:type:`error_code`. Where the two runtimes spell one differently, the +namespace ``errors::`` names it, see :ref:`the table `, and anything else compares +against ``errc::``: ``ec == errc::connection_reset``. The ones that belong to the contract +of an operation are in its reference and in :doc:`end-of-body` and :doc:`concurrent-requests`. + +Requests and responses may outlive their session. It keeps track of them, and detaches them when it +goes away, and from then on everything they are asked to do completes with an error, without touching +the connection that is gone. + +What a handler throws ends the coroutine of that request. On HTTP/1.1, which has nothing smaller to +abort, it also ends the connection, so a handler that means to go on has to catch it. + +Cancellation +------------ + +How an operation is cancelled depends on the API style: with a cancellation slot, from a completion +token, or through the ``std::stop_token`` of the awaiting coroutine. What works in both is racing it +against something else, with :cpp:func:`when_either`, which cancels the loser: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [timeout] + :end-before: // [timeout-end] + +A cancelled operation completes with an error that compares equal to ``errc::operation_canceled``, +which is the way to ask, as a timer of the runtime has its own spelling of it. What that leaves behind +depends on how far it had come, and on the protocol: + +* A cancelled :cpp:func:`get_response() ` can be started again. +* Cancelling :cpp:func:`Writer::write_eof` does not take the end of the body back, see + :ref:`end-of-body-cancellation`. +* A write that has started putting data on a TCP connection leaves part of a message behind. Over + HTTP/1.1, that is the end of the connection for further requests, see + :doc:`concurrent-requests`. +* The response of a server over HTTP/2 version 2 and HTTP/3 is handed to the protocol stack by + reference, without a copy, until the peer has it. As that can't be taken back, cancelling a write + with bytes outstanding resets the stream. A request body is copied, so it can go on afterwards. + +A coroutine that has been cancelled and wants to clean up by awaiting something else, as a last +``write_eof()``, needs :cpp:func:`reset_cancellation` and :cpp:func:`shielded` to get there. + +Completion tokens +----------------- + +**ASIO only.** Every operation is also an asynchronous operation of Asio, named ``async_...``, which +takes any completion token. There is no need for one in a coroutine. Without one, what goes wrong is +thrown: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [token-exception] + :end-before: // [token-exception-end] + +With ``as_tuple`` it is reported, the way the coroutine spelling does: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [token-tuple] + :end-before: // [token-tuple-end] + +A callback is a token as well: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [token-callback] + :end-before: // [token-callback-end] + +So is ``cancel_after``, which is the short way to a timeout on this API style: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [cancel-after] + :end-before: // [cancel-after-end] + +Ending a session +---------------- + +There are two ways to end a session, and neither waits for what is in progress, which fails right +away: + +* :cpp:func:`Session::shutdown` ends the connection in an orderly way: over HTTP/2 with a GOAWAY, over + TLS with a ``close_notify`` in both directions, then a FIN in both, with each wait limited to two + seconds. It fails with :cpp:var:`errors::connection_aborted` only if a write had to be interrupted, + which rules out a ``close_notify``. HTTP/3 sends its CONNECTION_CLOSE. +* Destroying the session, or :cpp:func:`Session::reset`, is over as fast as possible, and does nothing + asynchronous. The peer sees a connection that was cut. + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [shutdown] + :end-before: // [shutdown-end] + +Running it +---------- + +Stopping +^^^^^^^^ + +Destroying the :cpp:class:`server::Server` stops it listening and ends every connection it has. Do +that on the server's own executor, and then let the context run on until it returns: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [stop] + :end-before: // [stop-end] + +A context has to run dry, in both API styles. What is suspended when it is destroyed mostly gets +unwound, but not what waits on an object that only comes apart by running, and sessions do. HTTP/2 +and HTTP/3 leak without that drain. + +Threads +^^^^^^^ + +Everything is on one thread unless told otherwise, which needs no locks and no strands. With +several, there are two ways: + +* one context, run by all threads, and :cpp:member:`use_strand ` to give + each connection a strand of its own, on which its handlers run: + + .. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [threads] + :end-before: // [threads-end] + +* a context on each thread, each with a server of its own, and + :cpp:member:`reuse_port ` to have them all on the same port. On Asio, + this is what scales best, see :src:`docs/benchmark.md`. The kernel spreads connections and QUIC datagrams + over them, by address, so all of them have to be bound before traffic arrives, and a QUIC client + whose address changes lands on another server, which does not know it. + +Either way, a :cpp:class:`Session` and everything it gives out are to be used on its executor only. +The ``server`` program does both, see ``--threads`` and ``--independent`` in +:src:`src/apps/server_main.cpp`. + +On Corosio, one context on several threads has a use-after-free in its reactor, whose window is +narrow but real, see step 8 of :src:`docs/corosio-port-plan.md`. + +Logging +^^^^^^^ + +The library and the programs log through spdlog's default logger, so its level and its sinks are what +to set. The logger is also where to look first: each line carries the protocol, the peer and the +stream it belongs to, which is the same tag as :cpp:func:`Reader::log_prefix` gives. + +Plain QUIC streams +------------------ + +QUIC streams without HTTP in them, for a protocol of your own told apart by its ALPN, run on the UDP +port HTTP/3 is on. A :cpp:class:`quic::Stream` is a :cpp:class:`Reader` and a :cpp:class:`Writer` at +once, and :cpp:func:`server::Server::on_quic` is what gets each connection: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [quic-server] + :end-before: // [quic-server-end] + :dedent: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [quic-echo] + :end-before: // [quic-echo-end] + +and :cpp:func:`quic::connect` is the other side of that: + +.. literalinclude:: snippets.cpp + :language: cpp + :start-after: // [quic-client] + :end-before: // [quic-client-end] + +The ends map onto the contract of :doc:`end-of-body`: + +.. list-table:: + :header-rows: 1 + + * - QUIC + - Writer's side + - Reader's side + * - FIN + - :cpp:func:`write_eof() ` + - :cpp:var:`errors::eof` + * - RESET_STREAM + - :cpp:func:`reset_stream() `, or dropping the Writer before the end + - :cpp:var:`errors::partial_message` + * - STOP_SENDING + - writes fail with ``errc::connection_reset`` + - :cpp:func:`stop_sending() `, or dropping the Reader before the end + +The two directions are independent. Opening a stream never waits: at the peer's limit, +:cpp:func:`open_stream() ` fails with :cpp:var:`errors::would_block`. +A connection is closed when its handle goes away, on the server when the handler returns. +:cpp:struct:`quic::Config` has the flow control limits and how writes reach the stack, and +:src:`docs/quic-streams-plan.md` has how all this came about. + +Deploying the programs +---------------------- + +``cmake --install build-deploy --prefix --component Runtime`` gives ``bin/anyhttp-server`` and +``bin/anyhttp-client``, plus every shared library they need in ``lib/``, except for glibc and +``libgcc_s``. The tree can be moved or copied to another machine that has a glibc at least as new as +the build host's. The bundled libraries have licenses of their own, to be passed on along with them. + +.. code-block:: sh + + anyhttp-server -p 443 --cert /etc/anyhttp/chain.pem --key /etc/anyhttp/key.pem + +The server always loads a certificate, also to serve only cleartext: without ``--cert`` and ``--key`` +it looks for the test PKI (``pki/out/``, relative to the working directory), and exits with an error +if that isn't there. :src:`scripts/check_package.sh` verifies all of this from the outside. diff --git a/docs/reference.rst b/docs/reference.rst new file mode 100644 index 0000000..62cd150 --- /dev/null +++ b/docs/reference.rst @@ -0,0 +1,202 @@ +API reference +============= + +Made from the comments in the public headers. :doc:`programmers-guide` is the place to start, and +says how these go together. + +Everything is in the namespace ``anyhttp``, with what is for one side in ``anyhttp::client``, +``anyhttp::server`` and ``anyhttp::quic``. This shows the headers of an ASIO build: on COROSIO, there +are no operations with completion tokens, and the types are the other column of +:ref:`the table `. + +Client +------ + +.. code-block:: cpp + + #include + +.. doxygenclass:: anyhttp::client::Client + :members: + +.. doxygenstruct:: anyhttp::client::Config + :members: + +.. doxygenclass:: anyhttp::client::Request + :members: + +.. doxygenclass:: anyhttp::client::Response + :members: + +.. doxygentypedef:: anyhttp::client::Message + +Session +------- + +.. code-block:: cpp + + #include + +.. doxygenclass:: anyhttp::Session + :members: + +Server +------ + +.. code-block:: cpp + + #include + +.. doxygenclass:: anyhttp::server::Server + :members: + +.. doxygenstruct:: anyhttp::server::Config + :members: + +.. doxygentypedef:: anyhttp::server::RequestHandler + +.. doxygenclass:: anyhttp::server::Request + :members: + +.. doxygenclass:: anyhttp::server::Response + :members: + +Reading and writing +------------------- + +.. code-block:: cpp + + #include + #include + +.. doxygenclass:: anyhttp::Reader + :members: + +.. doxygenclass:: anyhttp::Writer + :members: + +Headers and protocols +--------------------- + +.. code-block:: cpp + + #include + +.. doxygenenum:: anyhttp::Protocol + +.. doxygenfunction:: anyhttp::to_string + +.. doxygentypedef:: anyhttp::Fields + +.. doxygenfunction:: anyhttp::fields + +.. doxygenclass:: anyhttp::FieldValue + :members: + +.. doxygenvariable:: anyhttp::default_max_header_size + +.. doxygenfunction:: anyhttp::header_field_size + +Plain QUIC streams +------------------ + +.. code-block:: cpp + + #include + +.. doxygenfunction:: anyhttp::quic::connect + +.. doxygenfunction:: anyhttp::quic::async_connect + +.. doxygenstruct:: anyhttp::quic::Config + :members: + +.. doxygenclass:: anyhttp::quic::Connection + :members: + +.. doxygenclass:: anyhttp::quic::Stream + :members: + +.. doxygentypedef:: anyhttp::quic::ConnectionHandler + +Alternative services +-------------------- + +.. code-block:: cpp + + #include + +.. doxygenfunction:: anyhttp::parse_alt_svc + +.. doxygenstruct:: anyhttp::AltSvc + :members: + +.. doxygenstruct:: anyhttp::AltService + :members: + +The runtime +----------- + +.. code-block:: cpp + + #include + +What the interface is written in, and what each name is on ASIO. On COROSIO the same names are +something else, which :ref:`the table ` has. + +.. doxygentypedef:: anyhttp::Task + +.. doxygentypedef:: anyhttp::Executor + +.. doxygentypedef:: anyhttp::IpAddress + +.. doxygentypedef:: anyhttp::TcpEndpoint + +.. doxygentypedef:: anyhttp::UdpEndpoint + +.. doxygentypedef:: anyhttp::error_code + +.. doxygentypedef:: anyhttp::system_error + +.. doxygentypedef:: anyhttp::DefaultCompletionToken + +.. doxygenfunction:: anyhttp::throw_error + +Errors +^^^^^^ + +Where the two runtimes spell an error differently, see :ref:`the table `. Anything +else compares against ``errc::``. + +.. doxygenvariable:: anyhttp::errors::eof + +.. doxygenvariable:: anyhttp::errors::partial_message + +.. doxygenvariable:: anyhttp::errors::canceled + +.. doxygenvariable:: anyhttp::errors::would_block + +.. doxygenvariable:: anyhttp::errors::connection_aborted + +.. doxygenvariable:: anyhttp::errors::bad_descriptor + +.. doxygenvariable:: anyhttp::errors::already_started + +.. doxygenvariable:: anyhttp::errors::header_limit + +Coroutines +^^^^^^^^^^ + +.. doxygenfunction:: anyhttp::launch(const Executor &executor, Task task) + +.. doxygenfunction:: anyhttp::launch(const Executor &executor, Task task, F &&on_done) + +.. doxygenfunction:: anyhttp::delay + +.. doxygenfunction:: anyhttp::when_both + +.. doxygenfunction:: anyhttp::when_either + +.. doxygenfunction:: anyhttp::reset_cancellation + +.. doxygenfunction:: anyhttp::shielded diff --git a/docs/snippets.cpp b/docs/snippets.cpp new file mode 100644 index 0000000..71e33f9 --- /dev/null +++ b/docs/snippets.cpp @@ -0,0 +1,451 @@ +// +// The code of the programmer's guide. The guide takes it from here, from one '[name]' comment to +// the next, and it is compiled along with everything else, so what the guide shows is what the +// library takes. test/test_docs.cpp runs it as well, so it also does what the guide says it does. +// +#include +#include +#include +#include +#include + +#if ANYHTTP_ASIO +#include +#include +#include +#else +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace snippets +{ +using namespace anyhttp; +using namespace std::chrono_literals; + +#if ANYHTTP_ASIO +using IoContext = boost::asio::io_context; +#else +using IoContext = boost::corosio::io_context; +#endif + +// ------------------------------------------------------------------------------------------------- +// +// Serving requests. +// + +// [echo] +Task serve_echo(server::Request request, server::Response response) +{ + if (request.content_length()) + response.content_length(request.content_length().value()); + + if (auto [ec] = co_await response.submit(200, {}); ec) + co_return; + + std::array buffer; + for (;;) + { + auto [ec, n] = co_await request.read_some(asio::buffer(buffer)); + if (ec == errors::eof) + break; + if (ec) + co_return; + + if (auto [write_ec] = co_await response.write(asio::buffer(buffer, n)); write_ec) + co_return; + } + + std::ignore = co_await response.write_eof(); +} +// [echo-end] + +// [write-at-once] +Task respond_with(server::Response response, std::string body) +{ + if (auto [ec] = co_await response.submit( + 200, fields({{"Content-Length", body.size()}, {"Content-Type", "text/plain"}})); + ec) + co_return; + + std::ignore = co_await response.write_eof(asio::buffer(body)); +} +// [write-at-once-end] + +// [route] +Task route(server::Request request, server::Response response) +{ + const std::string path = request.url().path(); + if (path == "/echo") + co_await serve_echo(std::move(request), std::move(response)); + else if (path == "/repeat") + { + const auto count = request.get_param_as("count").value_or(1); + co_await respond_with(std::move(response), std::string(count, '*')); + } + else + { + std::ignore = co_await response.submit(404, fields({{"Content-Length", 0}})); + std::ignore = co_await response.write_eof(); + } +} +// [route-end] + +// ------------------------------------------------------------------------------------------------- +// +// Making requests. +// + +// [read-all] +Task> read_all(Reader& reader) +{ + std::string body; + std::array buffer; + for (;;) + { + auto [ec, n] = co_await reader.read_some(asio::buffer(buffer)); + body.append(buffer.data(), n); + if (ec == errors::eof) + co_return std::tuple{error_code{}, std::move(body)}; + if (ec) + co_return std::tuple{ec, std::move(body)}; + } +} +// [read-all-end] + +// [post] +Task> post(Session& session, boost::urls::url url, + std::string body) +{ + auto [submit_ec, request] = + co_await session.submit(url, fields({{"Content-Length", body.size()}})); + if (submit_ec) + co_return std::tuple{submit_ec, std::string{}}; + + if (auto [ec] = co_await request.write_eof(asio::buffer(body)); ec) + co_return std::tuple{ec, std::string{}}; + + auto [response_ec, response] = co_await request.get_response(); + if (response_ec) + co_return std::tuple{response_ec, std::string{}}; + + co_return co_await read_all(response); +} +// [post-end] + +// [get] +Task get_body(Session& session, boost::urls::url url) +{ + auto [ec, message] = co_await session.get(url); + if (ec) + throw_error(ec); + + std::println("{}: {} bytes", message.result_int(), message.body().size()); + co_return std::move(message.body()); +} +// [get-end] + +// [partial] +Task read_at_most(Session session, boost::urls::url url, size_t limit) +{ + auto [submit_ec, request] = co_await session.submit("GET", url); + if (submit_ec) + co_return 0; + std::ignore = co_await request.write_eof(); + + auto [response_ec, response] = co_await request.get_response(); + if (response_ec) + co_return 0; + std::println("{} {}", response.status_code(), + std::string_view(response.fields()["content-type"])); + + size_t total = 0; + std::array buffer; + while (total < limit) + { + auto [ec, n] = co_await response.read_some(asio::buffer(buffer)); + total += n; + if (ec) + break; + } + + session.reset(); // the rest of the body is never read + co_return total; +} +// [partial-end] + +// [both-ways] +Task upload(Writer& writer, std::string_view body) +{ + for (; !body.empty(); body.remove_prefix(std::min(body.size(), size_t{16_k}))) + if (auto [ec] = co_await writer.write(asio::buffer(body.substr(0, 16_k))); ec) + throw_error(ec); + + if (auto [ec] = co_await writer.write_eof(); ec) + throw_error(ec); +} + +Task download(client::Request& request) +{ + auto [response_ec, response] = co_await request.get_response(); + if (response_ec) + throw_error(response_ec); + + auto [ec, body] = co_await read_all(response); + if (ec) + throw_error(ec); + co_return std::move(body); +} + +Task round_trip(Session& session, boost::urls::url url, std::string_view body) +{ + auto [ec, request] = co_await session.submit(url); + if (ec) + throw_error(ec); + + co_return co_await when_both(upload(request, body), download(request)); +} +// [both-ways-end] + +// [would-block] +Task> submit_patiently(Session& session, + boost::urls::url url) +{ + for (;;) + { + auto [ec, request] = co_await session.submit(url); + if (ec != errors::would_block) + co_return std::tuple{ec, std::move(request)}; + + std::ignore = co_await delay(10ms); + } +} +// [would-block-end] + +// [timeout] +Task> get_or_give_up(Session& session, boost::urls::url url, + std::chrono::milliseconds limit) +{ + auto result = co_await when_either(session.get(url), delay(limit)); + if (result.index() == 1) + co_return std::nullopt; // out of time, and the request has been cancelled + + auto [ec, message] = std::get<0>(std::move(result)); + if (ec) + co_return std::nullopt; + co_return std::move(message); +} +// [timeout-end] + +// [shutdown] +Task say_goodbye(Session session) +{ + if (auto [ec] = co_await session.shutdown(); ec) + std::println("the connection could not be ended in an orderly way: {}", ec.message()); +} +// [shutdown-end] + +// ------------------------------------------------------------------------------------------------- +// +// What a client connects to. These are kept short, and all they do is make the client. +// + +inline client::Client tls_client(Executor executor) +{ + // [tls] + client::Client client(executor, {.url = boost::urls::url("https://example.org")}); + // [tls-end] + return client; +} + +inline client::Client private_ca_client(Executor executor) +{ + // [private-ca] + client::Client client(executor, {.url = boost::urls::url("https://localhost:8080"), + .tls_ca_file = "pki/out/root.pem"}); + // [private-ca-end] + return client; +} + +inline client::Client h1_client(Executor executor) +{ + // [cleartext] + client::Client client( + executor, {.url = boost::urls::url("http://localhost:8080"), .protocol = Protocol::h1}); + // [cleartext-end] + return client; +} + +inline client::Client endpoints_client(Executor executor, IpAddress address) +{ + // [endpoints] + client::Client client(executor, {.url = boost::urls::url("https://example.org"), + .endpoints = {TcpEndpoint(address, 8443)}}); + // [endpoints-end] + return client; +} + +// ------------------------------------------------------------------------------------------------- +// +// Running it. +// + +// [stop] +Task serve_for(std::optional& server, std::chrono::milliseconds duration) +{ + std::ignore = co_await delay(duration); + server.reset(); // on the server's own executor, which this coroutine has been started on +} + +inline void run_for_a_while(server::Config config) +{ + IoContext context; + + std::optional server; + server.emplace(context.get_executor(), config); + server->on_request(route); + + launch(context.get_executor(), serve_for(server, 100ms)); + context.run(); // returns when all of it has come to an end +} +// [stop-end] + +// [threads] +inline void run_on_threads(server::Config config, size_t count) +{ + IoContext context(static_cast(count)); + + config.use_strand = true; + std::optional server; + server.emplace(context.get_executor(), config); + server->on_request(route); + + launch(context.get_executor(), serve_for(server, 100ms)); + + std::vector threads; + for (size_t i = 1; i < count; ++i) + threads.emplace_back([&context] { context.run(); }); + context.run(); + for (auto& thread : threads) + thread.join(); +} +// [threads-end] + +// ------------------------------------------------------------------------------------------------- +// +// Plain QUIC streams. +// + +// [quic-echo] +Task quic_echo(quic::Stream stream) +{ + std::array buffer; + for (;;) + { + auto [ec, n] = co_await stream.read_some(asio::buffer(buffer)); + if (ec == errors::eof) + break; + if (ec) + co_return; + + if (auto [write_ec] = co_await stream.write(asio::buffer(buffer, n)); write_ec) + co_return; + } + std::ignore = co_await stream.write_eof(); +} +// [quic-echo-end] + +inline void quic_serve(server::Server& server) +{ + // [quic-server] + server.on_quic({.alpn = "echo"}, [](quic::Connection connection) -> Task { + for (;;) + { + auto [ec, stream] = co_await connection.accept_stream(); + if (ec) + co_return; // the connection is over + launch(connection.get_executor(), quic_echo(std::move(stream))); + } + }); + // [quic-server-end] +} + +// [quic-client] +Task> quic_hello(Executor executor, std::string host, + std::string port, std::string ca_file) +{ + auto [connect_ec, connection] = co_await quic::connect( + executor, std::move(host), std::move(port), {.alpn = "echo", .tls_ca_file = ca_file}); + if (connect_ec) + co_return std::tuple{connect_ec, std::string{}}; + + auto [open_ec, stream] = connection.open_stream(); + if (open_ec) + co_return std::tuple{open_ec, std::string{}}; + + if (auto [ec] = co_await stream.write_eof(asio::buffer("hello", 5)); ec) + co_return std::tuple{ec, std::string{}}; + + co_return co_await read_all(stream); +} +// [quic-client-end] + +// ------------------------------------------------------------------------------------------------- +// +// What only the ASIO style has. +// + +#if ANYHTTP_ASIO + +// [token-exception] +Task token_exception(Session& session, boost::urls::url url) +{ + auto message = co_await session.async_get(url); + co_return std::move(message.body()); +} +// [token-exception-end] + +// [token-tuple] +Task token_tuple(Session& session, boost::urls::url url) +{ + auto [ec, message] = co_await session.async_get(url, {}, asio::as_tuple); + if (ec) + co_return ec.message(); + co_return std::move(message.body()); +} +// [token-tuple-end] + +// [token-callback] +void token_callback(Session& session, boost::urls::url url) +{ + session.async_get(url, {}, [](error_code ec, client::Message message) { + if (!ec) + std::println("{}", message.body()); + }); +} +// [token-callback-end] + +// [cancel-after] +Task response_within(client::Request& request, std::chrono::milliseconds limit) +{ + auto [ec, response] = + co_await request.async_get_response(asio::cancel_after(limit, asio::as_tuple)); + co_return ec != errc::operation_canceled; +} +// [cancel-after-end] + +#endif + +} // namespace snippets diff --git a/docs/test.md b/docs/test.md deleted file mode 100644 index 977acf4..0000000 --- a/docs/test.md +++ /dev/null @@ -1,104 +0,0 @@ -# Unit tests - -The tests live in [`test/`](../test) and share their fixtures through -[`test_fixtures.hpp`](../test/test_fixtures.hpp). Two ideas carry most of the suite: every -client/server test runs once per protocol, and an asynchronous test is written as two coroutines, -one for the server and one for the client, which the fixture then runs to completion. - -## One test, three protocols - -The fixtures are parametrized on `anyhttp::Protocol`. The server accepts all protocols at runtime -(HTTP/1.1 and HTTP/2 over TCP, HTTP/3 over UDP on the same port), so the parameter only selects -what the *client* speaks: - -``` -Server testing::TestWithParam; server on 127.0.0.2, ephemeral port -└── Client adds a client::Client, configured with .protocol = GetParam() - └── ClientAsync runs the test as coroutines, see below -``` - -A test suite instantiates the fixture for the protocols it covers: - -```cpp -INSTANTIATE_TEST_SUITE_P(ClientAsync, ClientAsync, - Values(anyhttp::Protocol::h1, - anyhttp::Protocol::h2, - anyhttp::Protocol::h3), - NameGenerator); -``` - -`NameGenerator` names the instances `/HTTP11`, `/HTTP2` and `/HTTP3`, so a single protocol can be -selected with `--gtest_filter='*/HTTP3'`. A test body can branch on `GetParam()` where the -protocols genuinely differ, typically to skip: - -```cpp -if (GetParam() == anyhttp::Protocol::h1) - GTEST_SKIP(); // a chunked body cannot be cancelled correctly --> disconnects -``` - -Protocol-specific suites (for example the HTTP/2 `AltSvcFrame` tests) instantiate with a single -value instead. - -A derived fixture adjusts the configuration by overriding `configure_server()` or -`configure_client()`, which are called before the server and client are created: - -```cpp -class HeaderLimits : public ClientAsync -{ -protected: - void configure_server(server::Config& config) override { config.max_header_size = 4_k; } - void configure_client(client::Config& config) override { config.max_header_size = 4_k; } -}; -``` - -## Asynchronous tests: `requestHandler` and `clientSession` - -A `ClientAsync` test body does not do any I/O itself. It only assigns up to two coroutines: - -- **`requestHandler`** -- the server side. The fixture's server routes a few fixed paths to - built-in handlers (`/echo`, `/eat_request`, `/discard`, `/detach`, `/dump`, ...) and everything - under `/custom` to `requestHandler`. The client's default URL is `http://127.0.0.2/custom`, so a - test that sets `requestHandler` and submits to `url` reaches it without further ado. -- **`clientSession`** -- the client side. It receives a connected `Session` and runs the test - scenario against it. - -```cpp -TEST_P(ClientAsync, WHEN_server_discards_request_and_response_THEN_completes_anyway) -{ - requestHandler = [this](server::Request request, server::Response response) -> awaitable { - co_return; // drops both - }; - clientSession = [this](Session session) -> awaitable { - auto request = co_await session.async_submit(url); - auto [ec, _] = co_await request.async_get_response(as_tuple); - EXPECT_EQ(ec, boost::beast::http::error::end_of_stream); - }; -} -``` - -Tests that only need the built-in handlers leave `requestHandler` unset and change the path, e.g. -`session.async_submit(url.set_path("echo"), {})`. - -### How it runs - -1. `SetUp()` creates server and client and `co_spawn`s the test coroutine on the client's - executor. Nothing runs yet -- the `io_context` has not been started. -2. The test body assigns `requestHandler` and `clientSession`. -3. `TearDown()` runs the `io_context`. The spawned coroutine connects, calls `clientSession`, and - the server calls `requestHandler` for each request under `/custom`. -4. When `clientSession` finishes, the completion token calls `on_complete(ec)`, destroys the - server and releases the work guard, so `run()` returns once everything has shut down. - -`on_complete` is a gmock method, and `TearDown()` expects it to be called exactly once with -success. A `clientSession` that throws -- for example because an operation without `as_tuple` -failed -- therefore fails the test, as does one that never finishes (it hangs, in practice). To -test for an error, catch it with `as_tuple` and check it with `EXPECT_*` inside the coroutine; -by default the fixture is single-threaded, so gtest assertions work there as usual. - -The helpers in [`request_handlers.hpp`](../include/anyhttp/request_handlers.hpp) (`generate`, -`send`, `drain`, `count_response`, `sleep`, ...) keep both coroutines short, and composing them -with `&&` runs the upload and the download concurrently: - -```cpp -auto count = co_await (generate(request, bytes) && count_response(request)); -``` diff --git a/docs/testing.rst b/docs/testing.rst new file mode 100644 index 0000000..60affbd --- /dev/null +++ b/docs/testing.rst @@ -0,0 +1,120 @@ +.. cpp:namespace:: anyhttp + +Unit tests +========== + +The tests live in :src:`test` and share their fixtures through :src:`test/test_fixtures.hpp`. Two +ideas carry most of the suite: every client/server test runs once per protocol, and an asynchronous +test is written as two coroutines, one for the server and one for the client, which the fixture then +runs to completion. How to build and run them is in :src:`CLAUDE.md`. + +One test, three protocols +------------------------- + +The fixtures are parametrized on :cpp:enum:`Protocol`. The server accepts all protocols at runtime +(HTTP/1.1 and HTTP/2 over TCP, HTTP/3 over UDP on the same port), so the parameter only selects what +the *client* speaks: + +.. code-block:: text + + Server testing::TestWithParam; server on 127.0.0.2, ephemeral port + └── Client adds a client::Client, configured with .protocol = GetParam() + └── ClientAsync runs the test as coroutines, see below + +A test suite instantiates the fixture for the protocols it covers: + +.. code-block:: cpp + + INSTANTIATE_TEST_SUITE_P(ClientAsync, ClientAsync, + Values(anyhttp::Protocol::h1, + anyhttp::Protocol::h2, + anyhttp::Protocol::h3), + NameGenerator); + +``NameGenerator`` names the instances ``/HTTP11``, ``/HTTP2`` and ``/HTTP3``, so a single protocol +can be selected with ``--gtest_filter='*/HTTP3'``. A test body can branch on ``GetParam()`` where the +protocols genuinely differ, typically to skip: + +.. code-block:: cpp + + if (GetParam() == anyhttp::Protocol::h1) + GTEST_SKIP(); // a chunked body cannot be cancelled correctly --> disconnects + +Protocol-specific suites (for example the HTTP/2 ``AltSvcFrame`` tests) instantiate with a single +value instead. + +A derived fixture adjusts the configuration by overriding ``configure_server()`` or +``configure_client()``, which are called before the server and client are created: + +.. code-block:: cpp + + class HeaderLimits : public ClientAsync + { + protected: + void configure_server(server::Config& config) override { config.max_header_size = 4_k; } + void configure_client(client::Config& config) override { config.max_header_size = 4_k; } + }; + +The fixtures themselves differ by API style, in :src:`test/test_fixtures_asio.hpp` and +:src:`test/test_fixtures_corosio.hpp`. + +Asynchronous tests: ``requestHandler`` and ``clientSession`` +------------------------------------------------------------ + +A ``ClientAsync`` test body does not do any I/O itself. It only assigns up to two coroutines: + +* **requestHandler** is the server side. The fixture's server routes a few fixed paths to built-in + handlers (``/echo``, ``/eat_request``, ``/discard``, ``/detach``, ``/dump``, ...) and everything + under ``/custom`` to ``requestHandler``. The client's default URL is ``http://127.0.0.2/custom``, so + a test that sets ``requestHandler`` and submits to ``url`` reaches it without further ado. +* **clientSession** is the client side. It receives a connected :cpp:class:`Session` and runs the + test scenario against it. + +.. code-block:: cpp + + TEST_P(ClientAsync, WHEN_server_discards_request_and_response_THEN_completes_anyway) + { + requestHandler = [this](server::Request request, server::Response response) -> Task { + co_return; // drops both + }; + clientSession = [this](Session session) -> Task { + auto [ec, request] = co_await session.submit(url); + auto [response_ec, response] = co_await request.get_response(); + EXPECT_EQ(response_ec, boost::beast::http::error::end_of_stream); + }; + } + +Tests that only need the built-in handlers leave ``requestHandler`` unset and change the path, as in +``session.submit(url.set_path("echo"))``. + +How it runs +^^^^^^^^^^^ + +1. ``SetUp()`` creates server and client and starts the test coroutine on the client's executor. + Nothing runs yet, as the context has not been started. +2. The test body assigns ``requestHandler`` and ``clientSession``. +3. ``TearDown()`` runs the context. The test coroutine connects, calls ``clientSession``, and the + server calls ``requestHandler`` for each request under ``/custom``. +4. When ``clientSession`` finishes, the completion token calls ``on_complete(ec)``, destroys the + server and releases the work guard, so ``run()`` returns once everything has shut down. + +``on_complete`` is a gmock method, and ``TearDown()`` expects it to be called exactly once with +success. A ``clientSession`` that throws, for example because an operation with a completion token +failed, therefore fails the test, as does one that never finishes (it hangs, in practice). To test for +an error, use the coroutine spelling and check it with ``EXPECT_*`` inside the coroutine. By default +the fixture is single-threaded, so gtest assertions work there as usual. + +The helpers in :src:`include/anyhttp/request_handlers.hpp` (``generate``, ``send``, ``drain``, +``count_response``, ``sleep``, ...) keep both coroutines short, and composing them with +:cpp:func:`when_both` runs the upload and the download concurrently: + +.. code-block:: cpp + + auto count = co_await when_both(generate(request, bytes), count_response(request)); + +The guide +--------- + +:src:`test/test_docs.cpp` runs the code of the :doc:`programmers-guide` over all three protocols, with +these fixtures where it can. It is why a snippet that changes what it does fails a test, rather than +just a reader. diff --git a/include/anyhttp/alt_svc.hpp b/include/anyhttp/alt_svc.hpp index 3417b86..5dc3969 100644 --- a/include/anyhttp/alt_svc.hpp +++ b/include/anyhttp/alt_svc.hpp @@ -49,13 +49,12 @@ struct AltSvc /// The field value was "clear": everything known about the origin is to be forgotten. bool clear = false; - // - // The advertised alternatives, in the order they were given, which is the order of the - // server's preference (RFC 7838, section 3). - // + /// The advertised alternatives, in the order they were given, which is the order of the + /// server's preference (RFC 7838, section 3). std::vector services; - /// The first alternative offering \p protocol, or nullptr if there is none. + /// \param protocol The ALPN protocol name to look for. + /// \return The first alternative offering \p protocol, or nullptr if there is none. const AltService* find(std::string_view protocol) const noexcept; }; @@ -66,6 +65,9 @@ struct AltSvc * parameters are malformed -- a client is free to ignore what it does not understand (RFC 7838, * section 3), and the alternatives it *can* read are still worth having. The result is empty for * a field value that yields nothing at all. + * + * \param value The field value. + * \return What could be made of it. */ AltSvc parse_alt_svc(std::string_view value); diff --git a/include/anyhttp/asio/runtime.hpp b/include/anyhttp/asio/runtime.hpp index 58a038d..9544a16 100644 --- a/include/anyhttp/asio/runtime.hpp +++ b/include/anyhttp/asio/runtime.hpp @@ -45,6 +45,7 @@ #include #include +/// \cond internal /// The error code of what \p ptr holds, as thrown by the runtime. inline boost::system::error_code code(const std::exception_ptr& ptr) { @@ -64,6 +65,8 @@ inline boost::system::error_code code(const std::exception_ptr& ptr) } } +/// \endcond + namespace anyhttp { namespace asio = boost::asio; @@ -75,12 +78,17 @@ using Task = asio::awaitable; /// What sessions, streams and their readers and writers run on. using Executor = asio::any_io_executor; -/// The runtime's own IP address and endpoints, which the API speaks, too. +/// The runtime's own IP address, which the API speaks, too. using IpAddress = asio::ip::address; + +/// The runtime's own TCP endpoint. using TcpEndpoint = asio::ip::tcp::endpoint; + +/// The runtime's own UDP endpoint. using UdpEndpoint = asio::ip::udp::endpoint; -using boost::system::error_code; +/// What every operation reports its outcome with. +using error_code = boost::system::error_code; // ================================================================================================= @@ -93,7 +101,7 @@ namespace errc = boost::system::errc; /** * The error codes anyhttp reports where the two runtimes spell them differently. Most of them - * are part of the API contract (README.md, "The End of a Body" and "Concurrent Requests"). Every + * are part of the API contract (docs/end-of-body.rst and docs/concurrent-requests.rst). Every * other error is made with `make_error_code(errc::...)`. */ namespace errors @@ -123,18 +131,22 @@ inline const error_code already_started = asio::error::already_started; inline const error_code header_limit = boost::beast::http::error::header_limit; } // namespace errors +/// \cond internal /// Whether the runtime supports running a server or client on several threads (with strands). inline constexpr bool multithreaded_runtime = true; +/// \endcond /// What the runtime throws an error_code as. using system_error = boost::system::system_error; /// Throws \p ec, for code that reports errors as exceptions, as the runtime's own does. +/// \param ec The error. [[noreturn]] inline void throw_error(const error_code& ec) { throw boost::system::system_error(ec); } +/// \cond internal /// What `errno` holds, for code that calls the operating system itself. inline error_code last_error() noexcept { return {errno, boost::system::system_category()}; } @@ -237,7 +249,11 @@ auto initiate(Init&& init) asio::as_tuple(asio::deferred)); } +/// \endcond + /// Starts \p task on \p executor, detached: nobody waits for it, and what it throws is dropped. +/// \param executor What to run it on. +/// \param task What to run. inline void launch(const Executor& executor, Task task) { asio::co_spawn(executor, std::move(task), asio::detached); @@ -245,12 +261,16 @@ inline void launch(const Executor& executor, Task task) /// Starts \p task on \p executor and calls \p on_done with what it threw, if anything, when it /// is done: void(const std::exception_ptr&). +/// \param executor What to run it on. +/// \param task What to run. +/// \param on_done What to call when it is done. template inline void launch(const Executor& executor, Task task, F&& on_done) { asio::co_spawn(executor, std::move(task), std::forward(on_done)); } +/// \cond internal /** * Runs \p task on \p executor as the operation \p handler stands for: the task's result * completes that operation, and cancelling it cancels the task. A task that throws completes it @@ -306,8 +326,12 @@ inline void dispatch_to(const Executor& executor, F&& function) /// while the io_context runs on several threads (\c server::Config::use_strand). inline Executor new_strand(const Executor& executor) { return asio::make_strand(executor); } +/// \endcond + /// Waits for \p duration to pass. Cancelling the wait completes it early, with the error code /// the runtime's timers report for that. +/// \param duration How long to wait. +/// \return The error, if the wait has been cancelled. inline Task delay(std::chrono::steady_clock::duration duration) { asio::steady_timer timer(co_await asio::this_coro::executor); @@ -320,6 +344,10 @@ inline Task delay(std::chrono::steady_clock::duration duration) * Runs \p a and \p b concurrently, until both are done, and yields what they return: nothing, * the one value, or both as a tuple. If one of them throws, the other is cancelled, and the * exception is rethrown once both are done. + * + * \param a One of the two. + * \param b The other one. + * \return What to await. */ template auto when_both(Task a, Task b) @@ -333,6 +361,10 @@ auto when_both(Task a, Task b) * what it returned as a variant, with \c std::monostate for nothing. One that throws does not * succeed: only when both have thrown is one of the exceptions rethrown (wrapped in ASIO's * multiple_exceptions). Waits for the other one to finish all the same. + * + * \param a One of the two. + * \param b The other one. + * \return What to await. */ template auto when_either(Task a, Task b) @@ -346,6 +378,8 @@ auto when_either(Task a, Task b) * ASIO throws from everything it awaits after that otherwise -- a nested coroutine included, which * is why this has to be awaited in the cancelled coroutine itself. In COROSIO, there is nothing to * reset, see shielded(). + * + * \return What to await. */ inline auto reset_cancellation() { return asio::this_coro::reset_cancellation_state(); } @@ -353,6 +387,9 @@ inline auto reset_cancellation() { return asio::this_coro::reset_cancellation_st * Awaits \p task although the caller has been cancelled already, once reset_cancellation() has * been awaited. With ASIO, that is all it takes. A stop request in COROSIO can not be taken back, * and there, the task runs with a stop token of its own instead. + * + * \param task What to await. + * \return What to await instead. */ template Task shielded(Task task) @@ -362,6 +399,8 @@ Task shielded(Task task) // ================================================================================================= +/// \cond internal + /** * Wakes up the coroutine that waits for it, as capy's \c async_event does. The event is latched: * one that is set while nobody waits is still set when the next \c wait() comes along, which then @@ -518,4 +557,6 @@ auto wait_readable(Socket& socket) // ================================================================================================= -} // namespace anyhttp \ No newline at end of file +/// \endcond + +} // namespace anyhttp diff --git a/include/anyhttp/client.hpp b/include/anyhttp/client.hpp index c90d209..927c971 100644 --- a/include/anyhttp/client.hpp +++ b/include/anyhttp/client.hpp @@ -22,6 +22,7 @@ namespace client // ================================================================================================= +/// What a \ref Client is made with. struct Config { /// What this client is for: the server's certificate has to be valid for the host of this URL. @@ -58,7 +59,7 @@ struct Config /** * The largest header section of a response the client accepts, in bytes, counted as for * \c server::Config::max_header_size. For a response with more, \c async_get_response() fails - * with \c boost::beast::http::error::header_limit and the stream is reset -- with HTTP/1.1, + * with \c errors::header_limit and the stream is reset -- with HTTP/1.1, * which has no streams, the connection can not be used any more. */ size_t max_header_size = default_max_header_size; @@ -76,6 +77,8 @@ struct Config * receive buffer, and whatever the server echoes back fills the other direction too. */ int socket_send_buffer_size = 0; + + /// See \ref socket_send_buffer_size. int socket_receive_buffer_size = 0; /** @@ -104,32 +107,46 @@ struct Config // ================================================================================================= -// -// A response received in one piece: status, header fields and the whole body as a string. -// -// This is a plain Beast message, with nothing of anyhttp left in it -- what \c Session::async_get() -// hands back. Its version is 11 whatever the protocol was: HTTP/2 and HTTP/3 have no version on -// the wire, and a Beast message has nowhere else to put one. -// +/** + * A response received in one piece: status, header fields and the whole body as a string. + * + * This is a plain Beast message, with nothing of anyhttp left in it -- what \c Session::async_get() + * hands back. Its version is 11 whatever the protocol was: HTTP/2 and HTTP/3 have no version on + * the wire, and a Beast message has nowhere else to put one. + */ using Message = boost::beast::http::response; // ------------------------------------------------------------------------------------------------- +/** + * The response to a request, as a handle: its status and header fields, and a \ref Reader for its + * body. It can be moved but not copied. + * + * Releasing one before the end of the body resets its stream with HTTP/2 and HTTP/3, which leaves + * the session usable. Over HTTP/1.1, it is the end of the connection for further responses. + */ class Response : public Reader { public: + /// \cond internal class Impl; - Response(); explicit Response(std::unique_ptr impl); Response(Response&& other) noexcept; Response& operator=(Response&& other) noexcept; - void reset() noexcept; ~Response(); + /// \endcond + + /// A handle without a response, as the one that comes with an error. + Response(); + + /// Releases the response, as the destructor does. + void reset() noexcept; public: + /// \return The status code of the response. int status_code() const noexcept; - /// The response header fields, without HTTP/2 and HTTP/3 pseudo-headers. + /// \return The response header fields, without HTTP/2 and HTTP/3 pseudo-headers. const Fields& fields() const; private: @@ -139,18 +156,33 @@ class Response : public Reader // ------------------------------------------------------------------------------------------------- +/** + * A request that has been submitted, as a handle: a \ref Writer for its body, and the way to its + * response. It can be moved but not copied. + * + * The body is ended with \ref Writer::async_write_eof, which does no harm where there is none. + * What releasing a request before that means, or before asking for its response, is on the page on + * concurrent requests. + */ class Request : public Writer { public: + /// \cond internal class Impl; - Request(); explicit Request(std::unique_ptr impl); Request(Request&& other) noexcept; Request& operator=(Request&& other) noexcept; - void reset() noexcept; ~Request(); + /// \endcond + + /// A handle without a request, as the one that comes with an error. + Request(); + + /// Releases the request, as the destructor does. + void reset() noexcept; public: + /// The completion signature of \c async_get_response: the error and the response. using GetResponse = void(error_code, Response); using GetResponseHandler = Completion; @@ -160,9 +192,12 @@ class Request : public Writer * With HTTP/1.1, responses arrive in the order the requests were sent, one after the other. * Getting the response to a request whose predecessors' responses have not been read to their * end does not wait for that to happen, but fails immediately with - * \c asio::error::would_block. After a response could not be read -- it was released before + * \c errors::would_block. After a response could not be read -- it was released before * its end, or its request was released without asking for it -- getting any later response - * fails with \c asio::error::connection_aborted. See README.md, "Concurrent Requests". + * fails with \c errors::connection_aborted. See the page on concurrent requests. + * + * \param token The completion token, for a handler of the signature \c GetResponse. + * \return What \p token makes of the operation. */ #if ANYHTTP_ASIO template @@ -180,6 +215,7 @@ class Request : public Writer /// \c async_get_response() as a coroutine: /// auto [ec, response] = co_await request.get_response(); + /// \return The error and the response. Task> get_response(); private: @@ -195,19 +231,33 @@ class Request : public Writer // ================================================================================================= +/// The completion signature of \c Client::async_connect: the error and the session. using Connect = void(error_code, Session); using ConnectHandler = Completion; +/** + * Makes connections to the one server of its \ref Config, each of them a \ref Session. It can be + * moved but not copied. + */ class Client { public: + /// \cond internal class Impl; - Client(Executor executor, Config config); Client(Client&& other) noexcept; Client& operator=(Client&& other) noexcept; ~Client(); + /// \endcond + /// A client on \p executor, which does nothing until it is told to connect. + /// \param executor What the client and its sessions run on. + /// \param config What it connects to, and how. + Client(Executor executor, Config config); + + /// The executor type, for what looks for one. using executor_type = Executor; + + /// \return The executor the client runs on. executor_type get_executor() const noexcept; /** @@ -218,6 +268,9 @@ class Client * \c getaddrinfo(), which is a blocking system call. This is done from a separate thread, * so the users executor is not blocked, but this still means that the operation cannot be * interrupted. + * + * \param token The completion token, for a handler of the signature \c Connect. + * \return What \p token makes of the operation. */ #if ANYHTTP_ASIO template @@ -231,6 +284,7 @@ class Client #endif /// \c async_connect() as a coroutine: auto [ec, session] = co_await client.connect(); + /// \return The error and the session. Task> connect(); private: diff --git a/include/anyhttp/common.hpp b/include/anyhttp/common.hpp index f723da7..912f20a 100644 --- a/include/anyhttp/common.hpp +++ b/include/anyhttp/common.hpp @@ -25,18 +25,28 @@ namespace anyhttp { // ================================================================================================= +/// What a connection speaks. enum class Protocol { - h1, - h2, - h3 + h1, ///< HTTP/1.1, over TCP, with or without TLS + h2, ///< HTTP/2, over TCP, with or without TLS + h3 ///< HTTP/3, over QUIC }; +/// The name of a protocol, as it appears in the logs. +/// \param protocol The protocol. +/// \return "HTTP11", "HTTP2" or "HTTP3". std::string to_string(Protocol protocol); + +/// Writes what \c to_string() says. +/// \param str The stream to write to. +/// \param protocol The protocol. +/// \return \p str std::ostream& operator<<(std::ostream& str, Protocol protocol); // ================================================================================================= +/// The header fields of a message, in both directions: Beast's container for them. using Fields = boost::beast::http::fields; static_assert(boost::beast::http::is_fields::value); @@ -47,22 +57,28 @@ inline constexpr size_t default_max_header_size = 64 * 1024; * What a received header field counts against the limit on the size of a header section: its name * and value plus 32 bytes of overhead, as for SETTINGS_MAX_HEADER_LIST_SIZE in HTTP/2 (RFC 9113, * section 6.5.2) and SETTINGS_MAX_FIELD_SECTION_SIZE in HTTP/3 (RFC 9114, section 4.2.2). + * + * \param name The name of the field. + * \param value The value of the field. + * \return The size it counts as, in bytes. */ constexpr size_t header_field_size(std::string_view name, std::string_view value) noexcept { return name.size() + value.size() + 32; } -// -// 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. -// +/** + * A header value as passed to \c fields(): either something string-like, or anything + * \c 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 + * \c fields() to hand the bytes to Beast, which copies them. Only formatted values need storage. + */ class FieldValue { public: + /// Takes \p value by reference if it is string-like, and formats it otherwise. + /// \param value The value of the header field. template requires std::formattable FieldValue(const T& value) @@ -81,9 +97,12 @@ class FieldValue } } + /// \cond internal /// 'view' may point into 'buffer', so a copy would alias the original's storage. FieldValue(const FieldValue&) = delete; + /// \endcond + /// \return The value, which lives as long as the FieldValue and what it refers to. operator std::string_view() const noexcept { return view; } private: @@ -91,12 +110,17 @@ class FieldValue 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"}}) -// +/** + * 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: + * + * \code + * fields({{"Content-Length", body.size()}, {"Content-Type", "text/plain"}}) + * \endcode + * + * \param headers The names and values of the header fields. + * \return The header fields, which own their copy of all of that. + */ inline Fields fields(std::initializer_list> headers) { Fields result; @@ -107,24 +131,30 @@ inline Fields fields(std::initializer_list; using WriteSome = void(error_code, size_t); using WriteSomeHandler = Completion; +/// The completion signature of \c Writer::async_write and \ref Writer::async_write_eof. using Write = void(error_code); using WriteHandler = Completion; +/// The completion signature of an operation that has nothing to report but how it went. using Status = void(error_code); using StatusHandler = Completion; #if ANYHTTP_ASIO +/// What an operation of a style that has completion tokens takes when it is given none. using DefaultCompletionToken = asio::default_completion_token_t; #endif // ================================================================================================= +/// \cond internal + /** * Custom invoke template function that moves the invoked function away before actually calling it. * This is important in places where the user-provided handler may re-install itself again. @@ -191,8 +221,12 @@ std::string log_prefix(Role role, std::string_view protocol, const IpAddress& ad std::string log_prefix(Role role, std::string_view protocol, const std::optional& remote); +/// \endcond + }; // namespace anyhttp +/// \cond internal + // ------------------------------------------------------------------------------------------------- /// Get error message from exception pointer, as used in the completion signature of \c co_spawn(). @@ -223,4 +257,6 @@ HttpDate format_http_date(std::chrono::system_clock::time_point tp); /// The current time as an HTTP date, for the \c Date header of a response. HttpDate http_date_now(); +/// \endcond + // ================================================================================================= diff --git a/include/anyhttp/h1/session.hpp b/include/anyhttp/h1/session.hpp index c75a5c6..45cb61e 100644 --- a/include/anyhttp/h1/session.hpp +++ b/include/anyhttp/h1/session.hpp @@ -201,7 +201,7 @@ class RequestWriter; * only a single connection to write requests to and read responses from, without any means to * interleave them. Pipelining is supported: requests can be written before the responses to the * earlier ones have been read. But everything that goes onto the connection, or comes off it, has - * to be done one after the other, and completely. See README.md, "Concurrent Requests", for the + * to be done one after the other, and completely. See docs/concurrent-requests.rst for the * reasoning behind this. * * A request is \e complete when all of it has been written to the connection, header and body: diff --git a/include/anyhttp/quic.hpp b/include/anyhttp/quic.hpp index 1bee4e9..c0f9ddc 100644 --- a/include/anyhttp/quic.hpp +++ b/include/anyhttp/quic.hpp @@ -6,8 +6,7 @@ // request and response -- only what QUIC itself has: a bidirectional stream with an end in each // direction (FIN), and two ways of aborting it early (RESET_STREAM, STOP_SENDING). // -// A Stream is a Reader and a Writer at once, with the contract of both (README.md, "The End of a -// Body"): +// A Stream is a Reader and a Writer at once, with the contract of both (docs/end-of-body.rst): // // QUIC Writer side Reader side // FIN write_eof() errors::eof @@ -45,41 +44,53 @@ namespace anyhttp::quic /// What plain QUIC connections are made with, on either side. struct Config { - /// The application protocol, as negotiated by ALPN: what the client offers and what the - /// server registers it under. Required, at most 255 bytes. + /** + * The application protocol, as negotiated by ALPN: what the client offers and what the + * server registers it under. Required, at most 255 bytes. + */ std::string alpn; - /// Client: the CA certificates to verify the server with, see client::Config::tls_ca_file. + /** + * Client: the CA certificates to verify the server with, see client::Config::tls_ca_file. + */ std::string tls_ca_file; - /// Client: how long the connection may be silent. The server's is server::Config::idle_timeout. + /** + * Client: how long the connection may be silent. The server's is server::Config::idle_timeout. + */ std::chrono::nanoseconds idle_timeout = std::chrono::seconds{30}; - // - // Flow control, as announced to the peer: how much it may send on one stream, and on all of - // them together, before we read, and how many streams it may have open at once. A stream's - // window opens up as its Reader reads, so a stream nobody reads stops its sender at - // `max_stream_data`. - // + /** + * Flow control, as announced to the peer: how much it may send on one stream, and on all of + * them together, before we read, and how many streams it may have open at once. A stream's + * window opens up as its Reader reads, so a stream nobody reads stops its sender at + * \c max_stream_data. + */ uint64_t max_stream_data = 256 * 1024; + + /// How much the peer may send on all streams together, see \ref max_stream_data. uint64_t max_data = 1024 * 1024; + + /// How many streams the peer may have open at once, see \ref max_stream_data. uint64_t max_streams = 100; - // - // The application error codes for aborts the library makes on the user's behalf: RESET_STREAM - // when a Writer goes away before write_eof() (or, with `zero_copy`, a write with unacknowledged - // bytes is cancelled), STOP_SENDING when a Reader goes away before the end. Meaning is up to - // the protocol. - // + /** + * The application error codes for aborts the library makes on the user's behalf: RESET_STREAM + * when a Writer goes away before write_eof() (or, with \c zero_copy, a write with unacknowledged + * bytes is cancelled), STOP_SENDING when a Reader goes away before the end. Meaning is up to + * the protocol. + */ uint64_t reset_error_code = 0; + + /// The application error code for STOP_SENDING, see \ref reset_error_code. uint64_t stop_sending_error_code = 0; - // - // How writes reach the stack. By default they are copied, in chunks of 16 KiB, and complete - // once handed over. With `zero_copy`, the stack reads straight from the caller's buffer and a - // write completes only when the peer has acknowledged all of it -- which saves the copy but - // costs a round trip per write, and cancelling such a write resets the stream. - // + /** + * How writes reach the stack. By default they are copied, in chunks of 16 KiB, and complete + * once handed over. With \c zero_copy, the stack reads straight from the caller's buffer and a + * write completes only when the peer has acknowledged all of it -- which saves the copy but + * costs a round trip per write, and cancelling such a write resets the stream. + */ bool zero_copy = false; }; @@ -97,21 +108,27 @@ struct Config class Stream : public Reader, public Writer { public: - Stream() noexcept; + /// \cond internal Stream(std::shared_ptr reader, std::shared_ptr writer) noexcept; Stream(Stream&& other) noexcept; Stream& operator=(Stream&& other) noexcept; ~Stream(); + /// \endcond + /// A handle without a stream, as the one that comes with an error. + Stream() noexcept; + + /// The executor type, for what looks for one. using executor_type = Executor; - /// The executor of the connection, or an empty one after \c reset(). + /// \return The executor of the connection, or an empty one after \c reset(). executor_type get_executor() const noexcept; - /// The \c [quic:address:port.stream] tag the library's log lines carry for this stream. + /// \return The [quic:address:port.stream] tag the library's log lines carry for this + /// stream. std::string log_prefix() const; - /// Whether either half is still there. + /// \return Whether either half is still there. explicit operator bool() const noexcept { return Reader::operator bool() || Writer::operator bool(); @@ -120,19 +137,23 @@ class Stream : public Reader, public Writer /// Releases both halves, as the destructor does. void reset() noexcept; - /// The QUIC stream ID, or -1 after \c reset(). + /// \return The QUIC stream ID, or -1 after \c reset(). int64_t id() const noexcept; /** * Aborts the sending side: RESET_STREAM with \p code. A write in progress completes with * \c errors::canceled, and every write from then on with \c errc::connection_reset; the peer's * reads report \c errors::partial_message. + * + * \param code The application error code. */ void reset_stream(uint64_t code); /** * Asks the peer to stop sending: STOP_SENDING with \p code. What has not been read is dropped, * and reads report \c errors::partial_message from then on. + * + * \param code The application error code. */ void stop_sending(uint64_t code); @@ -142,6 +163,7 @@ class Stream : public Reader, public Writer // ------------------------------------------------------------------------------------------------- +/// The completion signature of \c Connection::async_accept_stream: the error and the stream. using AcceptStream = void(error_code, Stream); using AcceptStreamHandler = Completion; @@ -155,20 +177,29 @@ using AcceptStreamHandler = Completion; class Connection { public: + /// \cond internal class Impl; - Connection() noexcept; explicit Connection(std::shared_ptr impl) noexcept; Connection(Connection&& other) noexcept; Connection& operator=(Connection&& other) noexcept; ~Connection(); + /// \endcond + + /// A handle without a connection, as the one that comes with an error. + Connection() noexcept; + /// \return Whether this handle has a connection. explicit operator bool() const noexcept { return static_cast(impl_); } + /// The executor type, for what looks for one. using executor_type = Executor; + + /// \return The executor of the connection, which all of it is to be used on. executor_type get_executor() const noexcept; - /// The \c [quic:address:port] tag the library's log lines carry for this connection. + /// \return The [quic:address:port] tag the library's log lines carry for this + /// connection. std::string log_prefix() const; /** @@ -177,12 +208,16 @@ class Connection * Opening a stream never waits: at the limit of concurrent streams the peer allows, this fails * with \c errors::would_block -- the caller may be the one who has to finish a stream first. * On a connection that is over, it fails with \c errors::connection_aborted. + * + * \return The error and the stream. */ std::tuple open_stream(); #if ANYHTTP_ASIO /// Waits for the next stream the peer opens. Fails with \c errors::connection_aborted once the /// connection is over. + /// \param token The completion token, for a handler of the signature \c AcceptStream. + /// \return What \p token makes of the operation. template auto async_accept_stream(CompletionToken&& token = CompletionToken()) { @@ -197,10 +232,12 @@ class Connection /// \c async_accept_stream() as a coroutine: auto [ec, stream] = co_await /// connection.accept_stream(); + /// \return The error and the stream. Task> accept_stream(); /// Closes the connection: CONNECTION_CLOSE with the application error \p code. What is still /// open fails with \c errc::connection_reset. + /// \param code The application error code. void close(uint64_t code = 0) noexcept; /// Releases the connection, closing it, as the destructor does. @@ -218,19 +255,33 @@ using ConnectionHandler = std::function(Connection connection)>; // ------------------------------------------------------------------------------------------------- +/// The completion signature of \c async_connect: the error and the connection. using Connect = void(error_code, Connection); using ConnectHandler = Completion; /// Connects to \p host : \p port, negotiating \c config.alpn, and returns the connection. /// The server's certificate has to be valid for \p host. +/// \param executor What the connection runs on. +/// \param host The name or address of the server. +/// \param port The port of the server. +/// \param config The protocol, and how to speak it. +/// \return The error and the connection. Task> connect(Executor executor, std::string host, std::string port, Config config); #if ANYHTTP_ASIO +/// \cond internal void async_connect_any(Executor executor, std::string host, std::string port, Config config, ConnectHandler&& handler); +/// \endcond /// \c connect() with a completion token. +/// \param executor What the connection runs on. +/// \param host The name or address of the server. +/// \param port The port of the server. +/// \param config The protocol, and how to speak it. +/// \param token The completion token, for a handler of the signature \c Connect. +/// \return What \p token makes of the operation. template auto async_connect(Executor executor, std::string host, std::string port, Config config, CompletionToken&& token = CompletionToken()) diff --git a/include/anyhttp/reader.hpp b/include/anyhttp/reader.hpp index c4c5980..78caeb7 100644 --- a/include/anyhttp/reader.hpp +++ b/include/anyhttp/reader.hpp @@ -40,39 +40,50 @@ namespace anyhttp class Reader { public: + /// \cond internal /// The implementation, defined in \c reader_impl.hpp: what a protocol backend implements to /// be read from. Only code that implements or narrows one needs to see it. class Impl; - using executor_type = Executor; - explicit Reader(std::shared_ptr impl) noexcept; Reader(Reader&&) noexcept; Reader& operator=(Reader&&) noexcept; ~Reader(); + /// \endcond + + /// The executor type, for what looks for one. + using executor_type = Executor; /// Releases the implementation, as the destructor does. Reading afterwards fails. void reset() noexcept; + /// \return Whether this handle has an implementation: not after \c reset(), or being moved from. explicit constexpr operator bool() const noexcept { return static_cast(impl_); } - /// The executor of the session this message belongs to, or an empty one after \c reset(). + /// \return The executor of the session this message belongs to, or an empty one after + /// \c reset(). executor_type get_executor() const noexcept; - /// The \c [proto:address:port.stream] tag the library's log lines carry for this message, - /// without the brackets, or an empty string after \c reset(). + /// \return The [proto:address:port.stream] tag the library's log lines carry for this + /// message, without the brackets, or an empty string after \c reset(). std::string log_prefix() const; - /// What the incoming message announced as its body length, if it announced one. + /// \return What the incoming message announced as its body length, if it announced one. std::optional content_length() const noexcept; #if ANYHTTP_ASIO /** * Reads a part of the incoming body. * - * The end of the body is reported as \c asio::error::eof with zero bytes, as ASIO does + * The end of the body is reported as \c errors::eof with zero bytes, as the runtime does * everywhere else, and so is every read after it. A body cut short by a reset stream or a lost - * connection completes with \c http::error::partial_message instead. + * connection completes with \c errors::partial_message instead. See the page on the end of + * a body. + * + * \param buffer Where to put what has been read, which has to stay valid until the operation + * completes. + * \param token The completion token, for a handler of the signature \c ReadSome. + * \return What \p token makes of the operation. */ template auto async_read_some(asio::mutable_buffer buffer, CompletionToken&& token = CompletionToken()) @@ -92,7 +103,12 @@ class Reader /** * \overload * - * FIXME: When given an actual sequence of buffers, this fills only the first non-empty one. + * When given an actual sequence of buffers, this fills only the first non-empty one. + * + * \param buffers Where to put what has been read, which has to stay valid until the operation + * completes. + * \param token The completion token, for a handler of the signature \c ReadSome. + * \return What \p token makes of the operation. */ template @@ -116,11 +132,16 @@ class Reader // /// Reads a part of the incoming body: auto [ec, n] = co_await reader.read_some(buffer); + /// \param buffer Where to put what has been read. + /// \return The error, and how many bytes have been read. Task> read_some(asio::mutable_buffer buffer); /// \overload /// - /// FIXME: As with async_read_some(), this fills only the first non-empty buffer. + /// As with async_read_some(), this fills only the first non-empty buffer. + /// + /// \param buffers Where to put what has been read. + /// \return The error, and how many bytes have been read. template requires(asio::is_mutable_buffer_sequence::value && !std::convertible_to) @@ -147,8 +168,10 @@ class Reader #endif protected: + /// \cond internal /// The implementation, for the derived handle to narrow to its own \c Impl. Never null. Impl& pimpl() const noexcept { return *impl_; } + /// \endcond private: #if ANYHTTP_ASIO diff --git a/include/anyhttp/server.hpp b/include/anyhttp/server.hpp index d3c0fa1..cfaeeca 100644 --- a/include/anyhttp/server.hpp +++ b/include/anyhttp/server.hpp @@ -19,135 +19,160 @@ namespace anyhttp::server // ================================================================================================= +/// What a \ref Server is made with. struct Config { + /// The address to listen on, for TCP and for HTTP/3 alike. std::string listen_address = "::"; + + /// The port to listen on, for TCP and for HTTP/3 alike. Zero has the kernel pick one, see + /// \ref Server::local_endpoint. uint16_t port = 8080; + + /// Give every connection a strand of its own, for a server whose context runs on several + /// threads. The handler of a request then runs on the strand of its connection. bool use_strand = false; - // - // Bind the TCP acceptor and the HTTP/3 UDP socket with SO_REUSEPORT, so that several servers, - // each on an executor of its own, can listen on the same port and the kernel spreads the load - // over them: new TCP connections, and UDP datagrams by their address and port. A QUIC - // connection whose client address changes (migration, NAT rebinding) lands on another server - // then, which does not know it. All of them have to be bound before traffic arrives -- each one - // that joins reshuffles where the datagrams go. - // + /** + * Bind the TCP acceptor and the HTTP/3 UDP socket with SO_REUSEPORT, so that several servers, + * each on an executor of its own, can listen on the same port and the kernel spreads the load + * over them: new TCP connections, and UDP datagrams by their address and port. A QUIC + * connection whose client address changes (migration, NAT rebinding) lands on another server + * then, which does not know it. All of them have to be bound before traffic arrives -- each one + * that joins reshuffles where the datagrams go. + */ bool reuse_port = false; - // - // The kernel's send and receive buffer, in bytes, for every TCP connection the server accepts; - // zero (the default) leaves the kernel's autotuning alone, which grows them into the megabytes. - // TCP only -- HTTP/3 has its own flow control, and the UDP socket is shared by every connection - // on it. - // - // This is a testing aid, not a tuning knob: pinning them small bounds how much a peer can write - // before it has to wait, which is the only way to make a write block at a predictable point - // over HTTP/1.1, where there is no flow control of its own. Small buffers cost throughput -- - // 8 KiB of receive buffer makes the 'PostRange' tests very slow -- so leave them at zero - // unless a test needs the back pressure. - // + /** + * The kernel's send and receive buffer, in bytes, for every TCP connection the server accepts; + * zero (the default) leaves the kernel's autotuning alone, which grows them into the megabytes. + * TCP only -- HTTP/3 has its own flow control, and the UDP socket is shared by every connection + * on it. + * + * This is a testing aid, not a tuning knob: pinning them small bounds how much a peer can write + * before it has to wait, which is the only way to make a write block at a predictable point + * over HTTP/1.1, where there is no flow control of its own. Small buffers cost throughput -- + * 8 KiB of receive buffer makes the 'PostRange' tests very slow -- so leave them at zero + * unless a test needs the back pressure. + */ int socket_send_buffer_size = 0; + + /// See \ref socket_send_buffer_size. int socket_receive_buffer_size = 0; - // - // The server's certificate chain and private key, as PEM files, for TLS over TCP and for - // HTTP/3. The chain starts with the server certificate, followed by its intermediates. Both - // files are read when the Server is constructed, which fails if they can't be, and not again: - // replacing them on disk takes effect with the next Server. Relative paths are resolved against - // the working directory. - // - // The defaults are the test PKI generated by the 'pki' target (see cmake/pki.cmake) -- good - // for development and the tests, and for nothing else. - // + /** + * The server's certificate chain, and with \ref tls_private_key its key, as PEM files, for TLS + * over TCP and for HTTP/3. The chain starts with the server certificate, followed by its + * intermediates. Both files are read when the Server is constructed, which fails if they can't + * be, and not again: replacing them on disk takes effect with the next Server. Relative paths + * are resolved against the working directory. + * + * The defaults are the test PKI generated by the 'pki' target (see cmake/pki.cmake) -- good + * for development and the tests, and for nothing else. + */ std::string tls_certificate_chain = "pki/out/server-chain.pem"; + + /// The private key that goes with \ref tls_certificate_chain, read at the same time. std::string tls_private_key = "pki/out/server-key.pem"; - // - // The largest header section of a request the server accepts, in bytes. A request with more is - // answered with 431 (Request Header Fields Too Large) and never reaches the request handler. - // HTTP/1.1 counts the request line and header lines as received, and closes the connection - // after the 431 response. HTTP/2 and HTTP/3 count each field as its name and value plus 32 - // bytes (pseudo-headers included) and announce the limit to the client in their SETTINGS. - // - // Fields beyond the limit are not stored, so this bounds the memory a request can take up with - // its headers. A single field is also limited by the protocol libraries: 64 KiB for HTTP/2 and - // HTTP/3, where a larger one fails the whole connection. So does an HTTP/2 header block that - // takes up more CONTINUATION frames than a header section of this size needs (but at least 8). - // + /** + * The largest header section of a request the server accepts, in bytes. A request with more is + * answered with 431 (Request Header Fields Too Large) and never reaches the request handler. + * HTTP/1.1 counts the request line and header lines as received, and closes the connection + * after the 431 response. HTTP/2 and HTTP/3 count each field as its name and value plus 32 + * bytes (pseudo-headers included) and announce the limit to the client in their SETTINGS. + * + * Fields beyond the limit are not stored, so this bounds the memory a request can take up with + * its headers. A single field is also limited by the protocol libraries: 64 KiB for HTTP/2 and + * HTTP/3, where a larger one fails the whole connection. So does an HTTP/2 header block that + * takes up more CONTINUATION frames than a header section of this size needs (but at least 8). + */ size_t max_header_size = default_max_header_size; - // - // How many requests a client may have in progress at once on one connection, announced to it as - // SETTINGS_MAX_CONCURRENT_STREAMS over HTTP/2 and as the limit on bidirectional streams over - // HTTP/3. A client that has reached it is refused the next request rather than kept waiting for - // a stream: client::Session::async_submit() fails with \c would_block, the way HTTP/1.1 does - // with its limit of one, see README.md, "Concurrent Requests". - // - // HTTP/1.1 has no use for this: one request at a time is what the protocol gives it. - // + /** + * How many requests a client may have in progress at once on one connection, announced to it as + * SETTINGS_MAX_CONCURRENT_STREAMS over HTTP/2 and as the limit on bidirectional streams over + * HTTP/3. A client that has reached it is refused the next request rather than kept waiting for + * a stream: Session::async_submit() fails with \c would_block, the way HTTP/1.1 does with its + * limit of one, see the page on concurrent requests. + * + * HTTP/1.1 has no use for this: one request at a time is what the protocol gives it. + */ uint64_t max_concurrent_streams = 100; - // - // How long a client may remember the HTTP/3 endpoint this server advertises in every response - // it sends over HTTP/1.1 and HTTP/2, as "Alt-Svc: h3=\":\"; ma=" (RFC 7838). - // A client that takes it up makes its *next* connection over QUIC -- there is no in-band - // upgrade to HTTP/3, so this is the whole of it. HTTP/3 shares the endpoint the TCP acceptor - // is listening on, so what is advertised is the port and nothing else: the same host, over - // QUIC. Zero sends no "Alt-Svc" at all. - // - // Advertising over cleartext HTTP is of no use to browsers and curl, which honour "Alt-Svc" - // for https:// origins only -- the alternative has to be at least as secure as the origin. - // + /** + * How long a client may remember the HTTP/3 endpoint this server advertises in every response + * it sends over HTTP/1.1 and HTTP/2, as Alt-Svc: h3=":8080"; ma=86400 (RFC 7838). + * A client that takes it up makes its *next* connection over QUIC -- there is no in-band + * upgrade to HTTP/3, so this is the whole of it. HTTP/3 shares the endpoint the TCP acceptor + * is listening on, so what is advertised is the port and nothing else: the same host, over + * QUIC. Zero sends no "Alt-Svc" at all. + * + * Advertising over cleartext HTTP is of no use to browsers and curl, which honour "Alt-Svc" + * for HTTPS origins only -- the alternative has to be at least as secure as the origin. + */ std::chrono::seconds alt_svc_max_age = std::chrono::hours{24}; - // - // HTTP/3 only: how long a QUIC connection may go without a packet from its peer before it is - // dropped. This is the only way a peer that vanished without a CONNECTION_CLOSE -- a killed - // client, a machine that went to sleep -- is ever noticed, so it also bounds how long its - // session and streams stay around. 30s is what the ngtcp2 examples use. - // + /** + * HTTP/3 only: how long a QUIC connection may go without a packet from its peer before it is + * dropped. This is the only way a peer that vanished without a CONNECTION_CLOSE -- a killed + * client, a machine that went to sleep -- is ever noticed, so it also bounds how long its + * session and streams stay around. 30s is what the ngtcp2 examples use. + */ std::chrono::nanoseconds idle_timeout = std::chrono::seconds{30}; - // - // HTTP/3 only, testing aid: probability (0.0 ... 1.0) with which an individual QUIC datagram - // is thrown away instead of being processed (rx) or actually sent (tx). This exercises loss - // recovery -- retransmits, PTO, ACK handling -- without needing a lossy network. Dropping - // happens per QUIC packet, i.e. GRO-coalesced datagrams are dropped individually and TX - // GSO batching is bypassed while `drop_rate_tx` is non-zero. - // + /** + * HTTP/3 only, testing aid: probability (0.0 ... 1.0) with which an individual QUIC datagram + * is thrown away instead of being processed (rx) or actually sent (tx). This exercises loss + * recovery -- retransmits, PTO, ACK handling -- without needing a lossy network. Dropping + * happens per QUIC packet, i.e. GRO-coalesced datagrams are dropped individually and TX + * GSO batching is bypassed while \c drop_rate_tx is non-zero. + */ double drop_rate_rx = 0.0; + + /// See \ref drop_rate_rx. double drop_rate_tx = 0.0; - // - // HTTP/3 only, for benchmarking: turn off the kernel's UDP offloads. Without GRO, every received - // datagram costs a recvmsg() of its own; without GSO, every QUIC packet costs a sendto() of its - // own instead of a whole same-sized run going out in one sendmsg(). - // + /** + * HTTP/3 only, for benchmarking: turn off the kernel's UDP offloads. Without GRO, every received + * datagram costs a recvmsg() of its own; without GSO, every QUIC packet costs a sendto() of its + * own instead of a whole same-sized run going out in one sendmsg(). + */ bool disable_gro = false; + + /// See \ref disable_gro. bool disable_gso = false; }; // ================================================================================================= +/** + * A request as a handler gets it, as a handle: its method, URL and header fields, and a + * \ref Reader for its body. It can be moved but not copied, and the handler may let go of it + * before it is done with the \ref Response. + */ class Request : public Reader { public: + /// \cond internal class Impl; explicit Request(std::shared_ptr impl); Request(Request&& other) noexcept; Request& operator=(Request&& other) noexcept; - void reset() noexcept; ~Request(); + /// \endcond + + /// Releases the request, as the destructor does. + void reset() noexcept; public: - /// The request method, as it arrived: "GET", "POST", ... + /// \return The request method, as it arrived: "GET", "POST", ... std::string_view method() const noexcept; - /// The requested URL: scheme, authority (host and port) and path. + /// \return The requested URL: scheme, authority (host and port) and path. boost::url_view url() const; - /// The request header fields, without HTTP/2 and HTTP/3 pseudo-headers. + /// \return The request header fields, without HTTP/2 and HTTP/3 pseudo-headers. const Fields& fields() const; /** @@ -160,6 +185,10 @@ class Request : public Reader * \code * auto delay = request.get_param_as("delay").value_or(0); * \endcode + * + * \tparam T What to convert the value to. + * \param name The name of the parameter. + * \return The value, if there is one. */ template std::optional get_param_as(std::string_view name) const @@ -186,27 +215,38 @@ class Request : public Reader } private: - // - // Hides Reader::pimpl(), narrowing it to the implementation this handle was built from. - // + /// Hides Reader::pimpl(), narrowing it to the implementation this handle was built from. Impl& pimpl() const noexcept; }; // ------------------------------------------------------------------------------------------------- +/** + * The response to a request as a handler gets it, as a handle: \ref async_submit for its header, + * and a \ref Writer for its body. It can be moved but not copied. + */ class Response : public Writer { public: + /// \cond internal class Impl; explicit Response(std::shared_ptr impl); Response(Response&& other) noexcept; Response& operator=(Response&& other) noexcept; - void reset() noexcept; ~Response(); + /// \endcond + + /// Releases the response, as the destructor does. + void reset() noexcept; public: /** * Sends the response header, which opens the body for writing. + * + * \param status_code The status code of the response. + * \param headers The header fields of the response. + * \param token The completion token, for a handler of the signature \c Status. + * \return What \p token makes of the operation. */ #if ANYHTTP_ASIO template @@ -225,6 +265,9 @@ class Response : public Writer #endif /// \c async_submit() as a coroutine: auto [ec] = co_await response.submit(200, {}); + /// \param status_code The status code of the response. + /// \param headers The header fields of the response. + /// \return The error. Task> submit(unsigned int status_code, Fields headers); private: @@ -238,33 +281,58 @@ class Response : public Writer // ================================================================================================= +/// What serves a request, see \ref Server::on_request. using RequestHandler = std::function(Request, Response)>; +/** + * Listens on a port, TCP and UDP both, and serves every protocol on it: HTTP/1.1 and HTTP/2, with + * or without TLS, told apart by what the client sends first, and HTTP/3 over UDP. + * + * It starts listening when it is made, and stops when it is destroyed, which also ends every + * connection it has. It can be moved but not copied. + */ class Server { public: + /// \cond internal class Impl; - Server(Executor executor, Config config); Server(Server&& other) noexcept; Server& operator=(Server&& other) noexcept; ~Server(); + /// \endcond + + /// Starts listening. + /// \param executor What the server and its connections run on. + /// \param config Where it listens, and how it serves. + /// \throws std::exception for a certificate that can't be read or a port that is taken. + Server(Executor executor, Config config); + /// The executor type, for what looks for one. using executor_type = Executor; + + /// \return The executor the server runs on. executor_type get_executor() const noexcept; + /// Sets what serves the requests from now on, each in a coroutine of its own on the executor of + /// its connection. Set it before the first request comes in, as requests go unserved without. + /// \param handler What serves a request. void on_request(RequestHandler&& handler); /** - * Serves plain QUIC streams (see anyhttp/quic.hpp) to clients that negotiate \c config.alpn, + * Serves plain QUIC streams (see \ref quic::Stream) to clients that negotiate \c config.alpn, * on the UDP port HTTP/3 is on: each such connection is handed to \p handler, on its own * executor, and closed when the handler returns. Registering an ALPN again replaces what was * registered for it, from the next connection on. * * Of \p config, the server uses the flow control limits, the error codes and the write mode; * its idle timeout is Config::idle_timeout. + * + * \param config The protocol, and how to serve it. + * \param handler What each connection is handed to. */ void on_quic(quic::Config config, quic::ConnectionHandler handler); + /// \return Where the server listens, with the actual port if \ref Config::port was zero. TcpEndpoint local_endpoint() const; private: diff --git a/include/anyhttp/session.hpp b/include/anyhttp/session.hpp index 65dba5f..24010d7 100644 --- a/include/anyhttp/session.hpp +++ b/include/anyhttp/session.hpp @@ -13,26 +13,45 @@ namespace anyhttp // ================================================================================================= +/// The completion signature of \c Session::async_submit: the error and the request. using Submit = void(error_code, client::Request); using SubmitHandler = Completion; +/// The completion signature of \c Session::async_get: the error and the whole response. using Get = void(error_code, client::Message); using GetHandler = Completion; +/// The completion signature of \c Session::async_shutdown. using SessionShutdown = void(error_code); using SessionShutdownHandler = Completion; +/** + * One connection of a client, as a handle: what \ref client::Client::async_connect gives. Requests + * are submitted on it, as many at a time as the protocol and the peer allow. + * + * It can be moved but not copied, and destroying it ends the connection the abrupt way, see + * \ref async_shutdown. It is to be used on its executor only. The requests and responses it has + * given out may outlive it, and fail from then on. + */ class Session { public: + /// \cond internal class Impl; - Session() = default; explicit Session(std::shared_ptr impl); Session(Session&& other) noexcept; Session& operator=(Session&& other) noexcept; - void reset() noexcept; ~Session(); + /// \endcond + + /// A handle without a connection. + Session() = default; + /// Ends the connection the abrupt way, as the destructor does: nothing asynchronous, and the + /// peer sees a connection that was cut. + void reset() noexcept; + + /// \return Whether this handle has a connection. explicit constexpr operator bool() const noexcept { return static_cast(impl); } public: @@ -44,28 +63,33 @@ class Session * is true if you start an asynchronous write operation on the request object. The request may * just be queued for later transmission. * - * Use \ref Request::async_get_response() on the request to wait for the response. + * Use \ref client::Request::async_get_response on the request to wait for the response. * * A session may limit the number of requests in progress. When that limit is reached, this * does not wait for a request to finish -- the caller might be the one who has to finish it -- - * but fails immediately with \c asio::error::would_block. HTTP/1.1 is such a protocol, with a + * but fails immediately with \c errors::would_block. HTTP/1.1 is such a protocol, with a * limit of one: a request is in progress until it is \e complete, that is, until all of it, - * header and body, has been written. See README.md, "Concurrent Requests". + * header and body, has been written. See the page on concurrent requests. * * After a request could not be completed, an HTTP/1.1 session can't send any more requests, - * and this fails with \c asio::error::connection_aborted. + * and this fails with \c errors::connection_aborted. * * Without a method, this is a POST. There is an overload for any other, see below. * - * TODO: There is only a single Session interface for both server and client. This even might - * make sense for HTTP/2, where the server can also (sort of) submit a push promise to the - * client. But in general, it may be better to separate them. - * - * TODO: Look at - * https://www.boost.org/doc/libs/latest/doc/html/boost_asio/example/cpp20/operations/composed_5.cpp - * and fully implement all requirements for asynchronous operations. - * + * \param target The URL of the request. + * \param headers The header fields of the request. + * \param token The completion token, for a handler of the signature \c Submit. + * \return What \p token makes of the operation. */ + // + // TODO: There is only a single Session interface for both server and client. This even might + // make sense for HTTP/2, where the server can also (sort of) submit a push promise to the + // client. But in general, it may be better to separate them. + // + // TODO: Look at + // https://www.boost.org/doc/libs/latest/doc/html/boost_asio/example/cpp20/operations/composed_5.cpp + // and fully implement all requirements for asynchronous operations. + // #if ANYHTTP_ASIO template auto async_submit(boost::urls::url target, const Fields& headers = {}, @@ -93,7 +117,13 @@ class Session * This is the way to have the body of a response without keeping all of it, as * \ref async_get() does, when a peer may send more than the caller wants to read. What it gets * to send is bounded by flow control, and over HTTP/1.1 by the socket buffers. Releasing the - * session ends the rest of it, see README.md, "Client". + * session ends the rest of it, see the programmer's guide. + * + * \param method The method of the request. + * \param target The URL of the request. + * \param headers The header fields of the request. + * \param token The completion token, for a handler of the signature \c Submit. + * \return What \p token makes of the operation. */ template auto async_submit(std::string_view method, boost::urls::url target, const Fields& headers = {}, @@ -133,11 +163,16 @@ class Session * that HTTP/1.1 does not have to make it chunked. * * Any error along the way completes this operation: the ones \ref async_submit() describes, - * \c http::error::header_limit for a response header section over - * \c client::Config::max_header_size, and \c http::error::partial_message for a body cut + * \c errors::header_limit for a response header section over + * \c client::Config::max_header_size, and \c errors::partial_message for a body cut * short. The message that comes with an error is empty, and says \c status::unknown rather * than the 200 a default-constructed Beast response would claim. A response that says 404, on * the other hand, is not an error -- it is a response, and arrives as one. + * + * \param url The URL of the request. + * \param headers The header fields of the request. + * \param token The completion token, for a handler of the signature \c Get. + * \return What \p token makes of the operation. */ #if ANYHTTP_ASIO template @@ -169,11 +204,14 @@ class Session * It does not wait for anything in progress: a request or response that is not complete yet * fails, right away, and so does anything else tried with this session afterwards. * - * The one error is \c asio::error::connection_aborted, for an end that could not be orderly: a + * The one error is \c errors::connection_aborted, for an end that could not be orderly: a * write has been interrupted, leaving part of a TLS record behind, for which a "close_notify" * would come too late. The connection is closed in that case as well. * * Calling this again does nothing, and neither does destroying the session afterwards. + * + * \param token The completion token, for a handler of the signature \c SessionShutdown. + * \return What \p token makes of the operation. */ #if ANYHTTP_ASIO template auto [ec, request] = co_await session.submit(url, headers); + /// \param target The URL of the request. + /// \param headers The header fields of the request. + /// \return The error and the request. Task> submit(boost::urls::url target, Fields headers = {}); /// \c async_submit() with a method, as a coroutine: /// auto [ec, request] = co_await session.submit("GET", url, headers); + /// \param method The method of the request. + /// \param target The URL of the request. + /// \param headers The header fields of the request. + /// \return The error and the request. Task> submit(std::string_view method, boost::urls::url target, Fields headers = {}); /// \c async_get() as a coroutine: auto [ec, message] = co_await session.get(url); + /// \param url The URL of the request. + /// \param headers The header fields of the request. + /// \return The error and the whole response. Task> get(boost::urls::url url, Fields headers = {}); /// \c async_shutdown() as a coroutine: auto [ec] = co_await session.shutdown(); + /// \return The error. Task> shutdown(); /** * The protocol this session speaks, which over TLS the server has a say in: see * \c client::Config::protocol. It tells how many requests the session can take at a time, as * HTTP/1.1 takes only one, see \ref async_submit(). + * + * \return What it speaks. */ Protocol protocol() const noexcept; + /// \return The executor the session runs on, and is to be used on. Executor get_executor() const noexcept; private: diff --git a/include/anyhttp/writer.hpp b/include/anyhttp/writer.hpp index 28b30ce..1945b29 100644 --- a/include/anyhttp/writer.hpp +++ b/include/anyhttp/writer.hpp @@ -34,30 +34,36 @@ namespace anyhttp class Writer { public: + /// \cond internal /// The implementation, defined in \c writer_impl.hpp: what a protocol backend implements to /// be written to. Only code that implements or narrows one needs to see it. class Impl; - using executor_type = Executor; - explicit Writer(std::shared_ptr impl) noexcept; Writer(Writer&&) noexcept; Writer& operator=(Writer&&) noexcept; ~Writer(); + /// \endcond + + /// The executor type, for what looks for one. + using executor_type = Executor; /// Releases the implementation, as the destructor does. Writing afterwards fails. void reset() noexcept; + /// \return Whether this handle has an implementation: not after \c reset(), or being moved from. explicit constexpr operator bool() const noexcept { return static_cast(impl_); } - /// The executor of the session this message belongs to, or an empty one after \c reset(). + /// \return The executor of the session this message belongs to, or an empty one after + /// \c reset(). executor_type get_executor() const noexcept; - /// The \c [proto:address:port.stream] tag the library's log lines carry for this message, - /// without the brackets, or an empty string after \c reset(). + /// \return The [proto:address:port.stream] tag the library's log lines carry for this + /// message, without the brackets, or an empty string after \c reset(). std::string log_prefix() const; /// Announces the length of the outgoing body, before its header is submitted. + /// \param content_length The length, or nothing to take back what has been announced. void content_length(std::optional content_length); #if ANYHTTP_ASIO @@ -66,6 +72,10 @@ class Writer * * An empty buffer writes nothing and completes immediately -- use \c async_write_eof() to end * the body. + * + * \param buffer What to write, which has to stay valid until the operation completes. + * \param token The completion token, for a handler of the signature \c Write. + * \return What \p token makes of the operation. */ template auto async_write(asio::const_buffer buffer, CompletionToken&& token = CompletionToken()) @@ -86,6 +96,10 @@ class Writer * stack. Re-ending an already-ended body with an empty buffer completes immediately and * changes nothing; with data attached it completes with \c errc::broken_pipe, just as writing * that data would -- there is no body left for it to belong to. + * + * \param buffer What to write, which has to stay valid until the operation completes. + * \param token The completion token, for a handler of the signature \c Write. + * \return What \p token makes of the operation. */ template auto async_write_eof(asio::const_buffer buffer, CompletionToken&& token = CompletionToken()) @@ -99,6 +113,8 @@ class Writer } /// Ends the outgoing body without writing anything more. + /// \param token The completion token, for a handler of the signature \c Write. + /// \return What \p token makes of the operation. template auto async_write_eof(CompletionToken&& token = CompletionToken()) { @@ -113,10 +129,14 @@ class Writer // /// Writes \p buffer as part of the body: auto [ec] = co_await writer.write(buffer); + /// \param buffer What to write. + /// \return The error. Task> write(asio::const_buffer buffer); /// Writes \p buffer, if any, and ends the body: auto [ec] = co_await /// writer.write_eof(); + /// \param buffer What to write. + /// \return The error. Task> write_eof(asio::const_buffer buffer = {}); #if ANYHTTP_COROSIO @@ -138,8 +158,10 @@ class Writer #endif protected: + /// \cond internal /// The implementation, for the derived handle to narrow to its own \c Impl. Never null. Impl& pimpl() const noexcept { return *impl_; } + /// \endcond private: #if ANYHTTP_ASIO diff --git a/src/h2v2/session.cpp b/src/h2v2/session.cpp index 8168d97..a64ac59 100644 --- a/src/h2v2/session.cpp +++ b/src/h2v2/session.cpp @@ -474,7 +474,7 @@ void NGHttp2Session::async_submit(SubmitHandler&& handler, std::string_view meth // // The peer's MAX_CONCURRENT_STREAMS is reached. Version 1 queued such a request and sent it - // once a stream came free; version 2 refuses it, which is the behaviour Session::async_submit + // once a stream came free; version 2 refuses it, which is the behavior Session::async_submit // describes for a protocol that limits concurrency -- the caller may be the one who has to // finish a request first, so waiting here could deadlock. // diff --git a/test/test_connection_end.cpp b/test/test_connection_end.cpp index b58362c..1fb9c4b 100644 --- a/test/test_connection_end.cpp +++ b/test/test_connection_end.cpp @@ -36,7 +36,7 @@ namespace h1 = anyhttp::beast_impl::h1; // A clean end has each side send its GOAWAY (HTTP/2 only), its "close_notify" (TLS only) and its // FIN, in this order, and keep reading until the peer's FIN before it closes the socket: closing // earlier makes the kernel answer whatever still arrives with an RST, which may cost the peer what -// it has not read yet. See docs/capture.md, "What a clean end looks like". +// it has not read yet. See docs/capture.rst, "What a clean end looks like". // // The client side is driven by hand, nghttp2 for HTTP/2 included, so that the tests see what goes // over the wire. It ends the way the server does, with one constraint over TLS: it sends its diff --git a/test/test_corosio.cpp b/test/test_corosio.cpp index 30c53eb..0dbb2f0 100644 --- a/test/test_corosio.cpp +++ b/test/test_corosio.cpp @@ -161,7 +161,8 @@ TEST(Corosio, BeastParserAndSerializer) // // Why each API style needs error constants of its own: a Boost.System code survives the conversion // to std::error_code -- Beast's errors stay comparable across it -- but asio's eof does not become -// capy's. The README's "asio::error::eof at the end of a body" is capy::error::eof in COROSIO. +// capy's. What docs/end-of-body.rst calls errors::eof at the end of a body is capy::error::eof in +// COROSIO. // TEST(Corosio, ErrorCodeInterop) { diff --git a/test/test_docs.cpp b/test/test_docs.cpp new file mode 100644 index 0000000..e3a4ec0 --- /dev/null +++ b/test/test_docs.cpp @@ -0,0 +1,194 @@ +// +// Runs the code of the programmer's guide, docs/snippets.cpp, so that what the guide says it does +// is what it does. It compiling at all is seen to by the documentation's own object library. +// +#include "test_fixtures.hpp" + +#include "../docs/snippets.cpp" + +#include + +using namespace anyhttp; +using namespace testing; +using namespace std::chrono_literals; + +class Guide : public ClientAsync +{ +}; + +INSTANTIATE_TEST_SUITE_P(Guide, Guide, Values(Protocol::h1, Protocol::h2, Protocol::h3), + NameGenerator); + +// ================================================================================================= + +TEST_P(Guide, Route) +{ + server->on_request(snippets::route); + clientSession = [this](Session session) -> Task { + auto repeat = url; + repeat.set_path("/repeat"); + repeat.set_encoded_query("count=5"); + auto [repeat_ec, repeated] = co_await session.get(repeat); + EXPECT_FALSE(repeat_ec) << repeat_ec.message(); + EXPECT_EQ(repeated.body(), "*****"); + + auto [echo_ec, echoed] = co_await snippets::post(session, url.set_path("/echo"), "hello"); + EXPECT_FALSE(echo_ec) << echo_ec.message(); + EXPECT_EQ(echoed, "hello"); + + auto [missing_ec, missing] = co_await session.get(url.set_path("/missing")); + EXPECT_FALSE(missing_ec) << missing_ec.message(); + EXPECT_EQ(missing.result_int(), 404); + }; +} + +TEST_P(Guide, EchoAndPost) +{ + requestHandler = snippets::serve_echo; + clientSession = [this](Session session) -> Task { + auto [ec, body] = co_await snippets::post(session, url, "hello"); + EXPECT_FALSE(ec); + EXPECT_EQ(body, "hello"); + }; +} + +TEST_P(Guide, Get) +{ + requestHandler = [](server::Request, server::Response response) -> Task { + co_await snippets::respond_with(std::move(response), "Hello, World!"); + }; + clientSession = [this](Session session) -> Task { + const auto body = co_await snippets::get_body(session, url); + EXPECT_EQ(body, "Hello, World!"); + }; +} + +TEST_P(Guide, ReadAtMost) +{ + requestHandler = [](server::Request, server::Response response) -> Task { + co_await snippets::respond_with(std::move(response), std::string(1024 * 1024, '*')); + }; + clientSession = [this](Session session) -> Task { + auto total = co_await snippets::read_at_most(std::move(session), url, 1024); + EXPECT_GE(total, 1024); + EXPECT_LT(total, 1024 * 1024); + }; +} + +// +// More than fits into the flow control windows and socket buffers, which is what the guide says +// takes writing and reading at the same time. +// +TEST_P(Guide, RoundTrip) +{ + requestHandler = snippets::serve_echo; + clientSession = [this](Session session) -> Task { + const std::string body(1024 * 1024, '*'); + const auto received = co_await snippets::round_trip(session, url, body); + EXPECT_EQ(received, body); + }; +} + +TEST_P(Guide, GetOrGiveUp) +{ + requestHandler = [](server::Request, server::Response response) -> Task { + std::ignore = co_await delay(200ms); + co_await snippets::respond_with(std::move(response), "too late"); + }; + clientSession = [this](Session session) -> Task { + auto message = co_await snippets::get_or_give_up(session, url, 50ms); + EXPECT_FALSE(message); + }; +} + +TEST_P(Guide, SubmitPatiently) +{ + requestHandler = snippets::serve_echo; + clientSession = [this](Session session) -> Task { + auto [ec, request] = co_await snippets::submit_patiently(session, url); + EXPECT_FALSE(ec); + std::ignore = co_await request.write_eof(); + auto [response_ec, response] = co_await request.get_response(); + EXPECT_FALSE(response_ec); + auto [read_ec, body] = co_await snippets::read_all(response); + EXPECT_FALSE(read_ec); + }; +} + +TEST_P(Guide, Shutdown) +{ + requestHandler = snippets::serve_echo; + clientSession = [this](Session session) -> Task { + co_await snippets::say_goodbye(std::move(session)); + }; +} + +#if ANYHTTP_ASIO +TEST_P(Guide, Tokens) +{ + requestHandler = [](server::Request, server::Response response) -> Task { + co_await snippets::respond_with(std::move(response), "tokens"); + }; + clientSession = [this](Session session) -> Task { + const auto thrown = co_await snippets::token_exception(session, url); + EXPECT_EQ(thrown, "tokens"); + const auto reported = co_await snippets::token_tuple(session, url); + EXPECT_EQ(reported, "tokens"); + }; +} + +TEST_P(Guide, CancelAfter) +{ + requestHandler = [](server::Request, server::Response response) -> Task { + std::ignore = co_await delay(200ms); + co_await snippets::respond_with(std::move(response), "too late"); + }; + clientSession = [this](Session session) -> Task { + auto [ec, request] = co_await session.submit(url); + EXPECT_FALSE(ec); + std::ignore = co_await request.write_eof(); + const auto answered = co_await snippets::response_within(request, 50ms); + EXPECT_FALSE(answered); + }; +} +#endif + +// ------------------------------------------------------------------------------------------------- + +TEST(GuideRunning, Stop) { snippets::run_for_a_while({.listen_address = "127.0.0.2", .port = 0}); } + +// not on COROSIO: docs/corosio-port-plan.md, step 8, has why this is for the threaded TSAN tree +// only +#if !ANYHTTP_COROSIO +TEST(GuideRunning, Threads) +{ + snippets::run_on_threads({.listen_address = "127.0.0.2", .port = 0}, 4); +} +#endif + +// ------------------------------------------------------------------------------------------------- + +TEST(GuideQuic, Echo) +{ + snippets::IoContext context; + + std::optional server; + server.emplace(context.get_executor(), server::Config{.listen_address = "127.0.0.2", .port = 0}); + snippets::quic_serve(*server); + + const auto port = std::to_string(server->local_endpoint().port()); + + std::string received; + launch(context.get_executor(), + [](Executor executor, std::string port, std::string& received, + std::optional& server) -> Task { + auto [ec, body] = + co_await snippets::quic_hello(executor, "127.0.0.2", port, "pki/out/root.pem"); + EXPECT_FALSE(ec) << ec.message(); + received = std::move(body); + server.reset(); + }(context.get_executor(), port, received, server)); + + context.run(); + EXPECT_EQ(received, "hello"); +}