Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -183,19 +183,20 @@ jobs:

- name: Configure CMake with Coverage
run: |
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DENABLE_COVERAGE=ON
cmake --preset build-coverage

- *build
- name: Build
run: cmake --build --preset build-coverage

- name: Generate Coverage Report
run: |
cmake --build build --target coverage
cmake --build --preset build-coverage --target coverage

- name: Prepare Pages directory
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
run: |
mkdir -p pages/coverage
cp -r build/coverage_report/* pages/coverage/
cp -r build-coverage/coverage_report/* pages/coverage/

- name: Get the documentation
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
Expand All @@ -208,7 +209,7 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: coverage-report
path: build/coverage_report/
path: build-coverage/coverage_report/
retention-days: 30

- name: Upload coverage and documentation to GitHub Pages
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ FetchContent subproject, in `build-package/`. Run it after changing anything in
or what a public header includes: each tree here builds the library with the tree's own directory
flags, so a usage requirement that is missing from the `anyhttp` target shows up only out there.

`build-coverage/` is the report CI publishes at https://pgit.github.io/anyhttp/coverage/: Debug,
clang with `ENABLE_COVERAGE`. `cmake --build build-coverage --target coverage` runs the suite and
writes `build-coverage/coverage_report/index.html`. It counts the library only: `test/` and `docs/`
(the snippets are linked into `test_all`) are left out.

Every tree is a preset of its own name in [CMakePresets.json](CMakePresets.json) -- that is where
the options of each one live, and the options of a new tree belong there rather than in a command
line. `cmake --list-presets` names them all. Do not configure a tree by hand: `cc` and `c++` are
Expand Down
5 changes: 4 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,10 @@ if (ENABLE_COVERAGE)
set(COVERAGE_HTML_DIR "${PROJECT_BINARY_DIR}/coverage_report")
set(COVERAGE_MERGE_SCRIPT "${PROJECT_SOURCE_DIR}/cmake/merge_profraw.cmake")
set(COVERAGE_TEST_BIN "${PROJECT_BINARY_DIR}/test/test_all")
set(LLVM_COV_IGNORE_REGEX "/usr/|${PROJECT_BINARY_DIR}/|${PROJECT_SOURCE_DIR}/test/")
# The code the guide shows (docs/snippets.cpp) is linked into test_all, but it is there to be
# read, not tested, much like test/: a snippet may be compiled and never run.
set(LLVM_COV_IGNORE_REGEX
"/usr/|${PROJECT_BINARY_DIR}/|${PROJECT_SOURCE_DIR}/test/|${PROJECT_SOURCE_DIR}/docs/")

add_custom_target(coverage
COMMAND ${CMAKE_COMMAND} -E rm -rf ${COVERAGE_RAW_DIR} ${COVERAGE_HTML_DIR}
Expand Down
22 changes: 22 additions & 0 deletions CMakePresets.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,19 @@
"base"
]
},
{
"name": "build-coverage",
"displayName": "Coverage",
"description": "What CI's Coverage job publishes: 'cmake --build build-coverage --target coverage' runs the tests and writes build-coverage/coverage_report/",
"inherits": [
"clang",
"base"
],
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug",
"ENABLE_COVERAGE": "ON"
}
},
{
"name": "build-openssl",
"displayName": "OpenSSL",
Expand Down Expand Up @@ -315,6 +328,10 @@
"name": "build-ubsan",
"configurePreset": "build-ubsan"
},
{
"name": "build-coverage",
"configurePreset": "build-coverage"
},
{
"name": "build-openssl",
"configurePreset": "build-openssl"
Expand Down Expand Up @@ -403,6 +420,11 @@
"configurePreset": "build-ubsan",
"inherits": "base"
},
{
"name": "build-coverage",
"configurePreset": "build-coverage",
"inherits": "base"
},
{
"name": "build-openssl",
"configurePreset": "build-openssl",
Expand Down
66 changes: 66 additions & 0 deletions docs/benchmark.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ never against a number in this file.
- [HTTP/2: nghttpd vs anyhttp](#http2-nghttpd-vs-anyhttp)
- [Threads: one shared context or one context per thread](#threads-one-shared-context-or-one-context-per-thread)
- [ASIO vs COROSIO](#asio-vs-corosio)
- [Completing an operation inline or posted](#completing-an-operation-inline-or-posted)
- [Earlier findings](#earlier-findings)
- [Bugs found while benchmarking](#bugs-found-while-benchmarking)

Expand Down Expand Up @@ -374,6 +375,71 @@ posts everything. `server --inline-budget N` / `--unassisted-budget N` (COROSIO

---------------------------------------------------------------------------------------------------

## Completing an operation inline or posted

*2026-11-06, on `b489baa` plus the uncommitted completion-token work.* What an operation that has
nothing left to wait for costs, depending on how it completes: by calling its handler from the
initiating function, or through `complete_immediately()` / `complete()`, which post.

### What `complete_immediately()` does

It completes through the handler's own immediate executor, so the caller decides: a handler that
has one bound (`bind_immediate_executor()`) may be completed inline, any other is posted to the
fallback with `blocking.never`. From `44851a7` until now it handed `asio::async_immediate()` a
lambda that wraps the handler instead, and `async_immediate()` asks what it is given for its
immediate executor: the lambda has none, so everything was posted and a caller's binding was
ignored. `experiments/immediate_binding.cpp` shows the difference; the comment in
`complete_immediately()` says why it does not use `async_immediate()`. The `Recursion` test in
`test_client_async.cpp` passed throughout, through the extra frames of the binder's path; it now
reads twice and requires the second read to nest deeper than the first.

### Results

`experiments/immediate_completion.cpp`, Release, one thread: a coroutine awaits a trivial
operation in a loop, as a reader does that drains a body which has already arrived. Three runs
each, ns per operation:

| completion | back to back | with a post after each |
|---|---|---|
| handler called inline | 133-169 | 146-148 |
| `complete_immediately()` before the fix (`async_immediate()`) | 69-75 | 167-174 |
| `complete()` | 73-76 | 171-174 |
| `complete_immediately()` now, nothing bound | 81-82 | 190-192 |

- **Inline nests.** An ASIO coroutine resumed from inside the initiating function runs on top of
it: about 416 bytes of stack per operation with `as_tuple(use_awaitable)`, 576 with an immediate
executor bound, 752 through the `#else` branch with one. Back to back, the stack is gone after
some 20,000 operations (8 MiB); the inline numbers in that column include faulting in fresh
stack. Every posted mode stays flat.
- **Where nothing nests, inline saves about 25 ns** per operation over a post (the second
column: each operation is followed by a real post, as a server's coroutine waits for the
network sooner or later). A request costs about 3 us of CPU, so that is under 1% even for an
operation that every request goes through.
- **Honouring the binding costs 10-20 ns** per operation over a plain post, for the
`any_completion_executor` in between. The server does not show it: five interleaved rounds of
`h2load -c 32` (HTTP/1.1 `-m 1`, HTTP/2 and HTTP/3 `-m 10`) on `/`, and three of a 64 KiB upload
to `/upload`, were within noise of the build that still used `async_immediate()` (2026-11-07).

### What it means

Calling a handler inline is safe where the caller cannot loop on it without waiting for something
real in between: once per response or per request, not once per read of a buffered body. What it
buys there is not the post, but ordering. `Response::async_submit()` on HTTP/2 and HTTP/3 completes
inline because the requests that arrive together on a connection start their handlers together,
and their responses go out in one send only if every handler has written before the write pass
runs. A queued completion, ahead of the write pass or behind it, lets the pass run in the middle:
with `h2load -c 32 -m 10`, sends per request went from 0.11 to 0.31, and throughput fell by 19-27%
(HTTP/2) and 16-41% (HTTP/3). With `-m 1`, one stream per connection, it made no difference
(1.01 sends per request either way).

### Reproduce

The command is in the header of `experiments/immediate_completion.cpp`. Arguments: the number of
operations, then `posted` (leave out the modes that nest, so that the count can be large) or
`unwind` (a post after each operation).

---------------------------------------------------------------------------------------------------

## Earlier findings

ASIO, single-threaded, before `bench.sh` existed: plain h2load runs against
Expand Down
2 changes: 1 addition & 1 deletion docs/corosio-port-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ the h3 write path also run under ASAN. From step 5 on, COROSIO's own tests must
use it.

*2a–2c done 2026-09-30* (`fa8b4d5`..`63d0894`). `runtime.hpp` has `Task`, `Executor`,
`error_code`, `Completion`, `errc`/`errors`, `complete_immediately`/`complete_later`,
`error_code`, `Completion`, `errc`/`errors`, `complete_immediately`/`complete`,
`on_cancel`, `run_later`, `dispatch_to`, `new_strand`, `launch`, `when_both`, `Event`, `Timer`,
and `io::read_some`/`write`/`receive`/`wait_readable`. Two findings:
- `Event::set()` posts in ASIO too, as capy's does, because h3 signals from inside ngtcp2.
Expand Down
12 changes: 2 additions & 10 deletions include/anyhttp/asio/any_async_stream_impl.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

#include "anyhttp/asio/any_async_stream.hpp"
#include "anyhttp/asio/runtime.hpp"
#include "anyhttp/net.hpp"

#include <boost/asio/associated_immediate_executor.hpp>
Expand Down Expand Up @@ -37,16 +38,7 @@ class any_async_stream::Impl
virtual void async_read_some(ReadWriteHandler handler, MutableBufferVector buffer) = 0;
virtual void async_shutdown_impl(ShutdownHandler handler)
{
#if 1
boost::asio::async_immediate(get_executor(), [handler = std::move(handler)]() mutable {
std::move(handler)(boost::system::error_code());
});
#else
auto ex = boost::asio::get_associated_immediate_executor(handler, get_executor());
ex.execute([handler = std::move(handler)]() mutable { //
std::move(handler)(boost::system::error_code());
});
#endif
complete_immediately(std::move(handler), get_executor(), boost::system::error_code{});
}
};

Expand Down
18 changes: 12 additions & 6 deletions include/anyhttp/asio/runtime.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -215,9 +215,15 @@ inline void complete_immediately(Handler&& handler, const Executor& fallback, Ar
if (!handler)
return;

#if 1
boost::asio::async_immediate(fallback, [handler = std::forward<Handler>(handler),
... args = std::forward<Args>(args)]() mutable { //
//
// Not async_immediate(): it asks the handler it is given for its immediate executor, and that is
// the lambda here, which has none -- the one bound to \p handler is never fetched, so the caller
// could not choose to be completed inline. Both post to \p fallback for a handler without one.
// See experiments/immediate_binding.cpp.
//
#if 0
asio::async_immediate(fallback, [handler = std::forward<Handler>(handler),
... args = std::forward<Args>(args)]() mutable { //
std::move(handler)(std::move(args)...);
});
#else
Expand All @@ -236,7 +242,7 @@ inline void complete_immediately(Handler&& handler, const Executor& fallback, Ar
* application inside nghttp2 or ngtcp2 would let it call back into the engine from inside it.
*/
template <typename Handler, typename... Args>
inline void complete_later(Handler&& handler, const Executor& executor, Args&&... args)
inline void complete(Handler&& handler, const Executor& executor, Args&&... args)
{
boost::asio::post(executor, [handler = std::forward<Handler>(handler),
... args = std::forward<Args>(args)]() mutable { //
Expand All @@ -247,7 +253,7 @@ inline void complete_later(Handler&& handler, const Executor& executor, Args&&..
/**
* Calls \p on_cancel when the caller of the operation \p handler stands for cancels it. The
* callback runs on the caller's side, which may be another thread with a strand, so all it may do
* is take the parked handler back and complete it with \ref complete_later. Does nothing if the
* is take the parked handler back and complete it with \ref complete. Does nothing if the
* caller has no way of cancelling.
*/
template <typename Signature, typename F>
Expand Down Expand Up @@ -494,7 +500,7 @@ class Event
}

private:
void wake(error_code ec) { complete_later(std::move(waiter_), executor_, ec); }
void wake(error_code ec) { complete(std::move(waiter_), executor_, ec); }

bool set_ = false;
Completion<void(error_code)> waiter_;
Expand Down
2 changes: 1 addition & 1 deletion include/anyhttp/corosio/runtime.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ inline void complete_immediately(Handler&& handler, const Executor&, Args&&... a
}

template <typename Handler, typename... Args>
inline void complete_later(Handler&& handler, const Executor&, Args&&... args)
inline void complete(Handler&& handler, const Executor&, Args&&... args)
{
if (handler)
std::move(handler)(std::forward<Args>(args)...);
Expand Down
7 changes: 6 additions & 1 deletion include/anyhttp/h3/stream.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,15 @@ class Http3Writer : public quic::QuicWriter<Base, Http3Stream>
{
if (!stream || stream->closed)
{
std::move(handler)(make_error_code(errc::connection_reset));
std::move(handler)(make_error_code(errc::connection_reset)); // inline like success
return;
}
stream->submit_response(status_code, fields);
//
// Inline, against the rule of complete_immediately(), so that the responses of the requests
// that arrived together go out together, as with HTTP/2 (NGHttp2Writer::async_submit()).
// Queueing the completion cost 16-41% of throughput (h2load, 2026-11-06).
//
std::move(handler)(error_code{});
}
};
Expand Down
6 changes: 4 additions & 2 deletions include/anyhttp/quic.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ class Stream : public Reader, public Writer
/// The executor type, for what looks for one.
using executor_type = Executor;

/// \return 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(), with what that
/// means for an operation started then, see \c Reader::get_executor().
executor_type get_executor() const noexcept;

/// \return The <tt>[quic:address:port.stream]</tt> tag the library's log lines carry for this
Expand Down Expand Up @@ -193,7 +194,8 @@ class Connection
/// 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.
/// \return The executor of the connection, which all of it is to be used on, or an empty one
/// for a handle without a connection, see \c Reader::get_executor().
executor_type get_executor() const noexcept;

/// \return The <tt>[quic:address:port]</tt> tag the library's log lines carry for this
Expand Down
4 changes: 2 additions & 2 deletions include/anyhttp/quic/stream.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,8 @@ class QuicReader : public Interface

on_cancel(handler, [this] {
if (stream && stream->read_handler)
complete_later(std::move(stream->read_handler), stream->get_executor(),
errors::canceled, size_t{0});
complete(std::move(stream->read_handler), stream->get_executor(), errors::canceled,
size_t{0});
});

assert(!stream->read_handler);
Expand Down
11 changes: 9 additions & 2 deletions include/anyhttp/reader.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,15 @@ class Reader
/// \return Whether this handle has an implementation: not after \c reset(), or being moved from.
explicit constexpr operator bool() const noexcept { return static_cast<bool>(impl_); }

/// \return 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().
*
* Without one, what is started on an empty handle with a completion token cannot complete the
* way ASIO promises: it fails with \c errors::bad_descriptor from inside the call, and a token
* that needs an executor of the operation, as \c cancel_after does for its timer, throws
* \c bad_executor instead. Awaited, it just fails.
*/
executor_type get_executor() const noexcept;

/// \return The <tt>[proto:address:port.stream]</tt> tag the library's log lines carry for this
Expand Down
26 changes: 12 additions & 14 deletions include/anyhttp/runtime.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,19 @@
//
// Apart from the runtime layer (this header, anyhttp/net.hpp and what they include), only the
// public API front-ends and the test fixtures test ANYHTTP_ASIO or ANYHTTP_COROSIO: the protocol
// backends are written once, against these names.
// backends are written once, against these names. asio/runtime.hpp declares them in these
// sections and documents each, corosio/runtime.hpp follows it:
//
// Task<T>, IoTask<Ts...>, results<Ts...>, Executor, error_code, Completion<Signature>,
// system_error
// IpAddress, TcpEndpoint, UdpEndpoint -- the runtime's own
// errc (portable error conditions), errors:: (the codes of the API contract), throw_error()
// last_error() -- errno, as an error_code
// complete_immediately(), complete_later(), on_cancel() -- parked operations
// initiate<Signature>(), launch() -- the two directions between them
// yield_now(), run_later(), dispatch_to(), new_strand(), delay(), when_both(), when_either()
// reset_cancellation(), shielded()
// Event, Timer
// io::read_some(), io::write(), io::peek(), io::receive(), io::wait_readable()
//
// asio/runtime.hpp has the documentation of each.
// types Task<T>, Executor, IpAddress, TcpEndpoint, UdpEndpoint, error_code,
// results<Ts...>, IoTask<Ts...>; asio:: for the buffers, which stay Asio's
// errors errc, errors::, system_error, throw_error(), last_error(), and ::code() of an
// exception_ptr; multithreaded_runtime
// completions Completion<Signature>, complete_immediately(), complete(), on_cancel(),
// and to and from coroutines: initiate<Signature>(), launch()
// scheduling yield_now(), run_later(), dispatch_to(), new_strand(), delay(), when_both(),
// when_either(), reset_cancellation(), shielded()
// engines Event, Timer
// I/O io::read_some(), io::write(), io::peek(), io::receive(), io::wait_readable()
//

#include "anyhttp/config.hpp"
Expand Down
3 changes: 2 additions & 1 deletion include/anyhttp/writer.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ class Writer
explicit constexpr operator bool() const noexcept { return static_cast<bool>(impl_); }

/// \return The executor of the session this message belongs to, or an empty one after
/// \c reset().
/// \c reset(), with what that means for an operation started then, see
/// \c Reader::get_executor().
executor_type get_executor() const noexcept;

/// \return The <tt>[proto:address:port.stream]</tt> tag the library's log lines carry for this
Expand Down
Loading