feat(engineering): add boost-asio-pro skill for async C++ networking

This commit is contained in:
Alex Chupryna
2026-08-18 16:50:46 +02:00
parent 6972e654ca
commit 08e1b77bd4
6 changed files with 882 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
---
name: "boost-asio-pro"
description: "Use when writing or reviewing asynchronous C++ networking code with Boost.Asio or standalone Asio — TCP/UDP servers and clients, SSL/TLS, timers, strands, io_context, co_spawn, awaitable, async_read/async_write, asio::spawn, yield_context, or pre-C++20 completion-handler callbacks."
---
# Boost.Asio / standalone Asio
## Overview
Write async C++ networking code that compiles on the *user's* Boost, not the newest one. Asio's API changed shape three times (classic `io_service``io_context` → C++20 coroutines) and most Asio code on the internet is from the first era, so **pick the style from the toolchain first**, then follow that style's reference file.
**References:** [Boost.Asio](https://www.boost.org/doc/libs/latest/doc/html/boost_asio.html) · [standalone Asio](https://think-async.com/Asio/)
Use this skill whenever async C++ networking code is being written or reviewed — and especially when the target toolchain is old, where coroutine examples simply will not compile. The three worked implementations it references are CI-verified from Boost 1.62 (2016) through 1.90.
## Step 1: pick the style (do this before writing code)
Determine the Boost (or Asio) version and the C++ standard actually in use — `find_package(Boost)` output, `dpkg -l libboost-dev`, `brew info boost`, `CMAKE_CXX_STANDARD`, or ask. Do not assume the newest.
| Boost | C++ std | Style | Read |
|-------|---------|-------|------|
| ≥ 1.77 | C++20 | Coroutines (`co_await` + `awaitable<T>`) — preferred | [references/coroutines.md](references/coroutines.md) |
| ≥ 1.74 | C++1117 | Completion handlers (callbacks) — the portable baseline | [references/pre-cpp20.md](references/pre-cpp20.md) |
| ≥ 1.80 | C++1117 | Stackful `asio::spawn` + `yield_context` (links Boost.Coroutine — not header-only) | [references/pre-cpp20.md](references/pre-cpp20.md) |
| 1.621.65 | C++11 | Classic `io_service` / `strand.wrap` / `expires_from_now` | [references/classic-boost.md](references/classic-boost.md) |
SSL/TLS in any style: [references/ssl.md](references/ssl.md). CMake for any style: [references/build.md](references/build.md).
`io_context`, `make_strand`, `bind_executor`, `steady_timer`, `signal_set`, `async_read`/`async_write`/`async_read_until`, buffers and `resolver` are **library** features — identical in the coroutine and callback styles. Only the suspension mechanism differs.
## Step 2: version floors (verified by compiling, not from docs)
Reach for one of these and the build breaks on older distros:
| Feature | Floor |
|---------|-------|
| `experimental/awaitable_operators.hpp` (the `\|\|` / `&&` operators) | **Boost ≥ 1.77** / Asio ≥ 1.20 |
| `as_tuple` completion token | **Boost ≥ 1.79** / Asio ≥ 1.21 |
| `co_composed` (custom composed ops) | **Boost ≥ 1.85** / Asio ≥ 1.30 |
| 3-arg `asio::spawn(ex, fn, token)` | **Boost ≥ 1.80** (older Boost has only `spawn(ex, fn)`) |
| `any_io_executor` (`strand<any_io_executor>`, `tcp::socket`'s default executor) | **Boost ≥ 1.74** — the floor for the callback style; below it, use legacy `io_context::strand` |
| `io_context`, `make_strand`, `expires_after` | **Boost ≥ 1.66** — below it, classic `io_service` |
Distro floors that bite: **Debian bookworm ships Boost 1.74** (no `awaitable_operators.hpp``#include` fails outright), Ubuntu 20.04 ships 1.71 (no `any_io_executor`), Debian 9 ships 1.62.
Language, not library: the chrono literals `250ms` / `30s` are **C++14**. For a true C++11 build write `std::chrono::milliseconds(250)`.
## Step 3: the rules that are actually easy to get wrong
**A strand does not serialize writes.** A strand serializes handler *execution*, not whole composed operations. Two `async_write`s in flight on the same strand still **interleave bytes on the wire**. Full-duplex (a read loop plus concurrent pushes/replies) needs a per-connection strand **and** an outbound queue with an in-flight flag, so at most one `async_write` exists at a time. This is the single most common wrong answer about Asio.
**Buffers do not own memory.** `asio::buffer()` is a view. Storage must outlive the operation: coroutine locals are fine across `co_await` in the same frame; in callback style the same data must become a **member**, not a local.
**Connections must outlive their handlers.** `enable_shared_from_this`, and capture `self` in *every* `co_spawn` / handler — read loop, write loop, and each timer.
**Frame with composed reads.** `async_read` (fills the buffer exactly) for a length prefix and then the body; never `async_read_some`, which returns short.
**Wrap `as_tuple`.** Always `as_tuple(use_awaitable)`. Bare `as_tuple` resolves against the operation's default token and compiles in some contexts, fails in others.
**`async_accept(make_strand(...))` changes two things**: it forces an explicit completion token back on the call, and the accepted socket is `basic_stream_socket<tcp, strand<...>>`, not `tcp::socket`. Take it **by value** or with `auto` — binding it to `tcp::socket&` will not compile.
**Re-arming a timer resolves the pending wait with `operation_aborted`.** In an idle-timeout loop that is the signal to keep waiting, not an error.
**GCC needs `-fcoroutines`** for the C++20 style, and header-only Boost needs `BOOST_ERROR_CODE_HEADER_ONLY` defined in exactly one place (CMake).
## Anti-Patterns
| Mistake | Fix |
|---------|-----|
| Buffer dangling (local goes out of scope during async op) | Ensure buffer lifetime ≥ operation lifetime; coroutine locals or members, not callback locals |
| Forgetting `io.run()` | No handlers dispatch without `run()` / `run_one()` |
| Concurrent socket access without strand | Wrap in `strand<>` or serialize via one coroutine chain |
| Assuming a strand prevents interleaved writes | Add a write queue — see Step 3 |
| Using `use_awaitable` where `deferred` suffices | Omit the token (default is `deferred`) unless using `\|\|` / `&&` |
| Ignoring short reads/writes | Use composed `async_read` / `async_write` / `async_read_until`, not `async_read_some` |
| Not setting `reuse_address` on the acceptor | Set before `bind`/`listen` or restarts hit "address in use" |
| SSL operations without a strand | *All* `ssl::stream` ops need strand synchronization |
| Blocking inside a handler | Never block in a completion handler |
| Accepting a socket with the wrong executor type | See `async_accept(make_strand(...))` in Step 3 |
| Requiring the `Boost::system` component | Header-only since 1.74: `Boost::headers` + `BOOST_ERROR_CODE_HEADER_ONLY`. Only classic (pre-1.66) needs the link |
| Missing `-fcoroutines` on GCC | Build fails — add `$<$<CXX_COMPILER_ID:GNU>:-fcoroutines>` |
| Writing coroutine code for a Boost that predates it | Do Step 1 first |
## Boost.Asio vs standalone Asio
Same author, same API — namespace and includes differ.
| Aspect | Boost.Asio | Standalone Asio |
|--------|-----------|-----------------|
| Namespace / include | `boost::asio` / `<boost/asio.hpp>` | `asio` / `<asio.hpp>` |
| Error code | `boost::system::error_code` | `asio::error_code` (or `std::error_code`) |
| Install (brew) | `brew install boost` | `brew install asio` |
| CMake | `Boost::headers` | manual include path |
| Version (2025) | 1.871.90 (with Boost) | 1.301.36 (independent) |
| Macro prefix | `BOOST_ASIO_` | `ASIO_` |
Support both with a shim, then use `net::` throughout:
```cpp
#ifdef USE_STANDALONE_ASIO
#include <asio.hpp>
namespace net = asio;
using error_code = asio::error_code;
#else
#include <boost/asio.hpp>
namespace net = boost::asio;
using error_code = boost::system::error_code;
#endif
namespace ssl = net::ssl;
using tcp = net::ip::tcp;
```
## Before you call it done
Check the code you just wrote against this list:
- [ ] Style matches the target Boost version and C++ standard (Step 1), and every API used clears its floor (Step 2).
- [ ] Every buffer passed to an async op outlives that op — no callback locals, no dangling `string_view`.
- [ ] At most one `async_write` per socket in flight, enforced by a queue + flag, if anything writes concurrently with reading.
- [ ] Every async chain on a shared object runs on the same strand; `self` captured in every handler and `co_spawn`.
- [ ] Framing / delimited reads use composed `async_read` / `async_read_until`.
- [ ] Errors are handled, not swallowed: `as_tuple(use_awaitable)` destructured, or the callback's `ec` checked, on every op.
- [ ] `operation_aborted` distinguished from real errors wherever a timer is re-armed or an op is cancelled.
- [ ] Acceptor sets `reuse_address`; shutdown path closes the acceptor and drains sessions.
- [ ] CMake has the standard, `-fcoroutines` for GCC (C++20 only), `BOOST_ERROR_CODE_HEADER_ONLY` in one place, and `Boost::coroutine` only if using stackful `spawn`.
- [ ] It compiles. Build it — most of the mistakes above are compile-time, and the version floors are only real once tested.
## Worked examples
Three CI-verified implementations of the same full-duplex framed-protocol server, one per style — copy from the one matching Step 1. All three live in the upstream repository and are built by CI on every push.
- [market-data-feed](https://github.com/alexprivalov/boost-asio-skill/tree/main/examples/market-data-feed) — C++20 coroutines (Boost 1.77+; verified 1.831.90)
- [market-data-feed-precpp20](https://github.com/alexprivalov/boost-asio-skill/tree/main/examples/market-data-feed-precpp20) — callbacks, C++11-clean (verified Boost 1.74+, incl. Windows/MSVC)
- [market-data-feed-classic](https://github.com/alexprivalov/boost-asio-skill/tree/main/examples/market-data-feed-classic) — classic `io_service` (verified back to Boost 1.62 / Debian 9)
## Official documentation
- Overview: https://www.boost.org/doc/libs/latest/doc/html/boost_asio/overview.html
- Reference: https://www.boost.org/doc/libs/latest/doc/html/boost_asio/reference.html
- Examples: https://www.boost.org/doc/libs/latest/doc/html/boost_asio/examples.html
## Cross-References
- `engineering/docker-development` — the old-Boost verification lanes this skill's floors come from are containerised builds (Debian 9 / bookworm, Fedora).
- `engineering/chaos-engineering` — for exercising the failure paths this skill tells you to handle: half-open sockets, idle timeouts, partial frames.
- `engineering-team/playwright-pro` — the client-side counterpart when the server built here is driven from browser-based integration tests.
@@ -0,0 +1,89 @@
# Build Configuration
### Boost.Asio (header-only since Boost 1.74+)
```cmake
find_package(Boost REQUIRED)
find_package(OpenSSL REQUIRED) # if using SSL
find_package(Threads REQUIRED)
target_link_libraries(myapp PRIVATE
Boost::headers # header-only Asio
OpenSSL::SSL OpenSSL::Crypto # if using SSL
Threads::Threads
)
target_compile_features(myapp PRIVATE cxx_std_20)
# REQUIRED for GCC coroutine support — build will fail without this
target_compile_options(myapp PRIVATE
$<$<CXX_COMPILER_ID:GNU>:-fcoroutines>
)
# Optional: truly header-only (no Boost.System link needed)
target_compile_definitions(myapp PRIVATE BOOST_ERROR_CODE_HEADER_ONLY)
```
### Standalone Asio (always header-only)
```cmake
# Standalone Asio has no CMake config — use pkg-config or manual path
find_package(OpenSSL REQUIRED)
find_package(Threads REQUIRED)
# If installed via brew:
find_path(ASIO_INCLUDE_DIR asio.hpp HINTS /opt/homebrew/include)
target_include_directories(myapp PRIVATE ${ASIO_INCLUDE_DIR})
target_link_libraries(myapp PRIVATE OpenSSL::SSL OpenSSL::Crypto Threads::Threads)
target_compile_features(myapp PRIVATE cxx_std_20)
target_compile_definitions(myapp PRIVATE ASIO_STANDALONE)
target_compile_options(myapp PRIVATE
$<$<CXX_COMPILER_ID:GNU>:-fcoroutines>
)
```
### Dual-mode CMake (supports both)
```cmake
option(USE_STANDALONE_ASIO "Use standalone Asio instead of Boost.Asio" OFF)
find_package(OpenSSL REQUIRED)
find_package(Threads REQUIRED)
if(USE_STANDALONE_ASIO)
find_path(ASIO_INCLUDE_DIR asio.hpp HINTS /opt/homebrew/include)
target_include_directories(myapp PRIVATE ${ASIO_INCLUDE_DIR})
target_compile_definitions(myapp PRIVATE USE_STANDALONE_ASIO ASIO_STANDALONE)
else()
find_package(Boost REQUIRED)
target_link_libraries(myapp PRIVATE Boost::headers)
target_compile_definitions(myapp PRIVATE BOOST_ERROR_CODE_HEADER_ONLY)
endif()
target_link_libraries(myapp PRIVATE OpenSSL::SSL OpenSSL::Crypto Threads::Threads)
target_compile_features(myapp PRIVATE cxx_std_20)
target_compile_options(myapp PRIVATE $<$<CXX_COMPILER_ID:GNU>:-fcoroutines>)
```
## Header-Only Usage
**Boost.Asio:** Asio is header-only by default. The only thing that pulls in a Boost library to link is `boost::system::error_code`'s out-of-line symbols, so for a truly link-free build define **`BOOST_ERROR_CODE_HEADER_ONLY`**. `BOOST_ASIO_HEADER_ONLY` is rarely needed and only relevant if separate compilation was previously enabled; you do **not** normally need both.
**Define `BOOST_ERROR_CODE_HEADER_ONLY` in exactly ONE place — prefer CMake** (`target_compile_definitions`, as shown above). Defining it in CMake *and* with a source `#define` triggers `-Wmacro-redefined`. So in source, just include — no `#define`:
```cpp
#include <boost/asio.hpp>
#include <boost/asio/ssl.hpp>
#include <boost/asio/experimental/awaitable_operators.hpp>
```
**Standalone Asio:**
```cpp
#include <asio.hpp>
#include <asio/ssl.hpp>
#include <asio/experimental/awaitable_operators.hpp>
// No macros needed — always header-only
```
@@ -0,0 +1,33 @@
# Classic Boost (pre-1.66, the `io_service` era — verified to 1.62)
To support Boost older than 1.66 (no `io_context`, no `make_strand`, no `any_io_executor`), drop to the classic API — verified building **back to Boost 1.62** (Debian 9) while still compiling on current Boost via a tiny shim:
| Modern (1.66+) | Classic (pre-1.66) |
|----------------|--------------------|
| `io_context` | `io_service` |
| `make_strand(ex)` / `strand<any_io_executor>` | `io_service::strand strand(io)` |
| `bind_executor(strand, h)` | `strand.wrap(h)` |
| `timer.expires_after(d)` | `timer.expires_from_now(d)` |
| move-return `async_accept()` | `async_accept(socket_, handler)` |
| header-only `error_code` | link **Boost.System** (`find_package(Boost COMPONENTS system)`) |
Only the `io_service`/`io_context` name and the `expires_after`/`expires_from_now` call actually differ across 1.62…1.90 — isolate both behind `#if BOOST_VERSION >= 106600`:
```cpp
#include <boost/version.hpp>
#include <boost/asio/steady_timer.hpp> // not pulled in by <boost/asio.hpp> on old Boost
#if BOOST_VERSION >= 106600
using io_service_t = boost::asio::io_context;
#else
using io_service_t = boost::asio::io_service;
#endif
template <class T, class Rep, class Period>
void timer_expires_in(T& t, std::chrono::duration<Rep,Period> d) {
#if BOOST_VERSION >= 106600
t.expires_after(d);
#else
t.expires_from_now(d);
#endif
}
```
CMake for this range: `cmake_minimum_required(VERSION 3.5)` (Debian 9 ships cmake 3.7), link `Boost::system` only if the component is found (modern Boost is header-only and has no such component), and use the classic out-of-source build (`mkdir build && cd build && cmake ..`) since `-S`/`-B` need cmake ≥ 3.13.
@@ -0,0 +1,412 @@
# C++20 Coroutine Style (Boost ≥ 1.77)
The preferred style when the toolchain allows it. Read `SKILL.md` first — the rules there (write queue, buffer lifetime, version floors) apply here and are not repeated.
## Core Architecture
Boost.Asio uses the **Proactor pattern**: async operations run in the background, completion handlers are invoked with results.
```
Program → I/O Object → Execution Context → OS → (completion) → Handler
```
**Execution contexts:** `io_context` (single/multi-thread event loop), `thread_pool`, `system_context`
**I/O objects:** `tcp::socket`, `tcp::acceptor`, `udp::socket`, `steady_timer`, `ssl::stream<>`
**Completion tokens:** Control how async results are delivered — `use_awaitable`, `deferred` (default), `detached`, callbacks, futures.
## C++20 Coroutines (Preferred Style)
```cpp
#include <boost/asio.hpp>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/use_awaitable.hpp>
namespace asio = boost::asio;
using tcp = asio::ip::tcp;
asio::awaitable<void> echo_session(tcp::socket socket) {
try {
char data[1024];
for (;;) {
std::size_t n = co_await socket.async_read_some(asio::buffer(data));
co_await async_write(socket, asio::buffer(data, n));
}
} catch (std::exception&) {
// Connection closed or error — coroutine ends
}
}
asio::awaitable<void> listener(tcp::acceptor acceptor) {
for (;;) {
auto socket = co_await acceptor.async_accept();
co_spawn(acceptor.get_executor(), echo_session(std::move(socket)), asio::detached);
}
}
int main() {
asio::io_context io(1); // concurrency_hint=1 for single-threaded
tcp::acceptor acceptor(io, {tcp::v4(), 8080});
co_spawn(io, listener(std::move(acceptor)), asio::detached);
io.run();
}
```
**Key rules:**
- `co_spawn(executor, coroutine, completion_token)` launches a coroutine
- Without explicit token, async ops use `deferred` (returns awaitable object for `co_await`)
- Errors become `system_error` exceptions by default inside coroutines
- Use `asio::detached` when you don't need the coroutine's result
## Error Handling in Coroutines
**Default:** Errors throw `boost::system::system_error`.
**Explicit error handling with `as_tuple`:**
```cpp
auto [ec, n] = co_await socket.async_read_some(
asio::buffer(data), asio::as_tuple(asio::use_awaitable));
if (ec) { /* handle error, no exception */ }
```
**Wrap, don't use bare `as_tuple`.** Always write `as_tuple(use_awaitable)`. Bare `asio::as_tuple` resolves against the operation's *default* completion token (often `deferred`), which compiles in some contexts but fails in others — wrapping an explicit base token is unambiguous everywhere.
**With `redirect_error`:**
```cpp
boost::system::error_code ec;
std::size_t n = co_await socket.async_read_some(
asio::buffer(data), asio::redirect_error(ec));
```
## Strands (Thread Safety)
**Rule: All async operations on a shared object MUST execute on the same strand.**
```cpp
// Per-connection strand
asio::strand<asio::io_context::executor_type> strand(io.get_executor());
co_spawn(strand, session(std::move(socket)), asio::detached);
// Bind handler to strand
socket.async_read_some(asio::buffer(data),
asio::bind_executor(strand, [](error_code ec, size_t n) { /*...*/ }));
```
**Implicit strands (no explicit strand needed):**
- Single-threaded `io_context::run()` — all handlers are sequential
- Single chain of async ops on one connection (half-duplex)
**Explicit strand required when:**
- Multiple threads call `io_context::run()`
- Full-duplex read+write on same socket
- Shared state accessed from multiple async chains
## Full-Duplex: Strand + Write Queue
**A strand serializes handler *execution*, NOT whole composed operations.** Two `async_write`s started "concurrently" on the same strand still overlap and **interleave bytes on the wire** — the strand only orders the intermediate handlers, not the byte stream. For full-duplex (a read loop plus pushes/replies writing at the same time on one socket), a strand alone is **not** enough: you must serialize outbound writes yourself with a queue.
```cpp
// Give each accepted socket its OWN strand, then run every chain (read loop,
// pushes, replies) on that strand. Passing an executor to async_accept means you
// must ALSO pass an explicit completion token — the default-deferred shortcut on
// the zero-arg form no longer applies.
auto socket = co_await acceptor.async_accept(asio::make_strand(io), asio::use_awaitable);
std::make_shared<connection>(std::move(socket))->start();
class connection : public std::enable_shared_from_this<connection> {
tcp::socket socket_; // bound to its own strand
std::deque<std::string> outbox_;
bool writing_ = false;
public:
explicit connection(tcp::socket s) : socket_(std::move(s)) {}
void start() {
// Each chain captures `self` so the connection outlives all its coroutines.
co_spawn(socket_.get_executor(),
[self = shared_from_this()] { return self->read_loop(); }, asio::detached);
}
// Call ONLY from the connection's strand (e.g. from its own coroutines).
// From another thread/strand: asio::dispatch(socket_.get_executor(), ...).
void send(std::string frame) {
outbox_.push_back(std::move(frame));
if (!writing_)
co_spawn(socket_.get_executor(),
[self = shared_from_this()] { return self->write_loop(); }, asio::detached);
}
private:
asio::awaitable<void> write_loop() {
writing_ = true;
while (!outbox_.empty()) {
co_await async_write(socket_, asio::buffer(outbox_.front()));
outbox_.pop_front(); // pop only AFTER the write completes
}
writing_ = false;
}
asio::awaitable<void> read_loop(); // reads frames, calls send() for replies
};
```
**Why each rule matters:**
- One strand per connection → read loop and write loop never run their handlers concurrently.
- Write queue + `writing_` flag → at most one `async_write` in flight, so frames never interleave.
- `enable_shared_from_this` + capturing `self` in every `co_spawn` → the connection survives until all of its read/write/timer chains finish.
- The accepted socket from `async_accept(make_strand(...))` is `basic_stream_socket<tcp, strand<...>>`, **not** `tcp::socket`. Take it **by value** (`connection(tcp::socket s)`, store `tcp::socket socket_`) — the strand executor type-erases into `any_io_executor` on the move. Passing that accepted socket to a `tcp::socket&` (by reference) instead will **fail to compile** — use `auto` or accept by value.
**Strand from inside a coroutine** (when `io` isn't a captured local): get the executor from the coroutine and make a strand off it — no `io_context&` needed:
```cpp
auto ex = co_await asio::this_coro::executor;
auto socket = co_await acceptor.async_accept(asio::make_strand(ex), asio::use_awaitable);
```
**Run the read loop and idle watch together** — two `awaitable<void>` branches; don't inspect the result, the first to finish unwinds the other:
```cpp
using namespace asio::experimental::awaitable_operators;
co_await (read_loop() || idle_watch(socket_, timer_)); // either returning tears down the connection
```
**Stopping a detached side-coroutine** (e.g. a per-symbol ticker that must end on unsubscribe/close): a detached `co_spawn` won't stop itself. Either (a) have its loop re-check a flag each iteration and `co_return` when gone:
```cpp
while (subscriptions_.contains(symbol) && socket_.is_open()) {
timer.expires_after(250ms);
co_await timer.async_wait(asio::as_tuple(asio::use_awaitable));
if (/* still subscribed */) send(make_tick(symbol));
}
```
or (b) spawn it with a `cancellation_signal` and `emit()` cancellation on unsubscribe. The flag approach is simpler for per-subscription tickers.
## Timers and Timeouts
```cpp
asio::awaitable<void> with_timeout(tcp::socket& socket) {
asio::steady_timer timer(co_await asio::this_coro::executor);
timer.expires_after(std::chrono::seconds(30));
// Race: read vs timeout (requires awaitable_operators)
using namespace asio::experimental::awaitable_operators;
auto result = co_await (
socket.async_read_some(asio::buffer(data), asio::use_awaitable)
|| timer.async_wait(asio::use_awaitable)
);
if (result.index() == 0) { /* read completed */ }
else { /* timeout — cancel the socket */ socket.close(); }
}
```
**Re-armable idle timeout** (reset on every received frame — the common server pattern):
```cpp
// Run as a long-lived parallel branch. Calling expires_after() again cancels the
// pending wait, resolving the in-flight async_wait with operation_aborted — that
// is the signal to keep waiting, NOT an error. Genuine expiry resolves with no error.
asio::awaitable<void> idle_watch(tcp::socket& sock, asio::steady_timer& timer) {
for (;;) {
auto [ec] = co_await timer.async_wait(asio::as_tuple(asio::use_awaitable));
if (ec == asio::error::operation_aborted) continue; // re-armed → keep waiting
if (ec) co_return; // timer error
sock.close(); // real timeout fired
co_return;
}
}
// On every frame received from the peer: timer.expires_after(30s);
```
**Parallel operations (`&&` and `||`):**
```cpp
#include <boost/asio/experimental/awaitable_operators.hpp>
using namespace asio::experimental::awaitable_operators;
// Wait for both (AND) — cancels other on failure
auto [read_n, write_n] = co_await (
async_read(sock, in_buf, use_awaitable) &&
async_write(sock, out_buf, use_awaitable)
);
// Wait for first (OR) — cancels other on success
auto result = co_await (
async_read(sock, buf, use_awaitable) ||
timer.async_wait(use_awaitable)
);
```
**Note:** `||` and `&&` operators require explicit `use_awaitable` token, and the `awaitable_operators.hpp` header (Boost ≥ 1.77 — see the version floors in SKILL.md).
**Void branches:** when a branch returns `void` (e.g. two `awaitable<void>` chains), that arm contributes `std::monostate` to the result variant. If *both* branches are void the result is `variant<monostate, monostate>` — don't inspect `.index()`; just `co_await` the expression and let whichever finishes first unwind the other.
## Cancellation
```cpp
asio::awaitable<void> cancellable_work() {
// Check cancellation state
auto cs = co_await asio::this_coro::cancellation_state;
if (cs.cancelled() != asio::cancellation_type::none) {
co_return;
}
// Enable cancellation types
co_await asio::this_coro::reset_cancellation_state(
asio::enable_total_cancellation());
}
```
## TCP Server Pattern
```cpp
asio::awaitable<void> server(asio::io_context& io, unsigned short port) {
tcp::acceptor acceptor(io, {tcp::v4(), port});
acceptor.set_option(tcp::acceptor::reuse_address(true));
for (;;) {
auto socket = co_await acceptor.async_accept();
co_spawn(
io.get_executor(), // or a strand for multi-threaded
handle_client(std::move(socket)),
[](std::exception_ptr ep) {
if (ep) std::rethrow_exception(ep);
}
);
}
}
```
## Buffers
| Type | Use |
|------|-----|
| `asio::buffer(data, size)` | Wrap existing memory (no ownership) |
| `asio::dynamic_buffer(vec)` | Growable buffer over `vector`/`string` |
| `asio::streambuf` | Legacy stream buffer |
| `asio::const_buffer` | Read-only view |
| `asio::mutable_buffer` | Writable view |
**Critical:** `asio::buffer()` does NOT own memory. The underlying storage must outlive the async operation.
## Resolver (DNS)
```cpp
asio::awaitable<void> connect_to(asio::io_context& io,
std::string host, std::string port) {
tcp::resolver resolver(io);
auto endpoints = co_await resolver.async_resolve(host, port);
tcp::socket socket(io);
co_await asio::async_connect(socket, endpoints);
// socket is now connected
}
```
## Multi-Threaded io_context
```cpp
asio::io_context io;
std::vector<std::thread> threads;
for (int i = 0; i < std::thread::hardware_concurrency(); ++i) {
threads.emplace_back([&io] { io.run(); });
}
// All handlers MUST be strand-protected when sharing state
for (auto& t : threads) t.join();
```
## Composed Async Operations (Custom)
```cpp
template <typename CompletionToken>
auto async_echo(tcp::socket& socket, CompletionToken&& token) {
return asio::async_initiate<CompletionToken, void(boost::system::error_code)>(
asio::co_composed<void(boost::system::error_code)>(
[](auto state, tcp::socket& socket) -> void {
state.throw_if_cancelled(true);
state.reset_cancellation_state(asio::enable_terminal_cancellation());
try {
char data[1024];
for (;;) {
std::size_t n = co_await socket.async_read_some(asio::buffer(data));
co_await async_write(socket, asio::buffer(data, n));
}
} catch (const boost::system::system_error& e) {
co_return {e.code()};
}
}, socket),
token, std::ref(socket));
}
```
## Line-Based Protocols
For newline-delimited protocols, prefer `async_read_until` over manual `async_read_some` + buffer parsing:
```cpp
asio::awaitable<void> line_echo(tcp::socket socket) {
asio::streambuf buf;
for (;;) {
std::size_t n = co_await asio::async_read_until(socket, buf, '\n');
std::string line(asio::buffers_begin(buf.data()),
asio::buffers_begin(buf.data()) + n);
buf.consume(n);
co_await async_write(socket, asio::buffer(line));
}
}
```
Or with `dynamic_buffer` over a `std::string`:
```cpp
std::string buf;
std::size_t n = co_await asio::async_read_until(socket, asio::dynamic_buffer(buf), '\n');
std::string line = buf.substr(0, n);
buf.erase(0, n);
```
## Length-Prefixed Binary Framing
For binary protocols, read the fixed-size header fully, then the body fully — two sequential **composed** reads (`async_read` fills the whole buffer, handling short reads). Do NOT use `async_read_some` for framing.
```cpp
// Frame: [4-byte big-endian length N][N-byte body]
asio::awaitable<std::string> read_frame(tcp::socket& sock) {
uint32_t len_be = 0;
co_await async_read(sock, asio::buffer(&len_be, sizeof len_be)); // exactly 4 bytes
uint32_t n = ntohl(len_be); // <arpa/inet.h>; or hand-roll endian swap
std::string body(n, '\0');
co_await async_read(sock, asio::buffer(body)); // exactly n bytes
co_return body;
}
asio::awaitable<void> write_frame(tcp::socket& sock, std::string_view body) {
uint32_t len_be = htonl(static_cast<uint32_t>(body.size()));
std::array<asio::const_buffer, 2> bufs{
asio::buffer(&len_be, sizeof len_be), asio::buffer(body)};
co_await async_write(sock, bufs); // gather-write header + body atomically
// len_be and body must outlive the write — they do here (co_await suspends in-frame).
}
```
## Graceful Shutdown (signal_set)
```cpp
asio::signal_set signals(io, SIGINT, SIGTERM);
signals.async_wait([&](const boost::system::error_code&, int /*signo*/) {
acceptor.close(); // stop accepting; let in-flight sessions drain, then io.run() returns
// or, for an immediate stop: io.stop();
});
```
For coroutine-style shutdown, `co_await signals.async_wait()` in a dedicated coroutine instead of a callback.
## Quick Reference
| Operation | Function |
|-----------|----------|
| Launch coroutine | `co_spawn(executor, coro, token)` |
| Accept connection | `co_await acceptor.async_accept()` |
| Read some bytes | `co_await socket.async_read_some(buffer)` |
| Read exact/until | `co_await async_read(stream, buf)` / `async_read_until(stream, buf, delim)` |
| Write all | `co_await async_write(stream, buffer)` |
| Connect | `co_await async_connect(socket, endpoints)` |
| Resolve DNS | `co_await resolver.async_resolve(host, port)` |
| Wait timer | `co_await timer.async_wait()` |
| TLS handshake | `co_await stream.async_handshake(type)` |
| Get executor | `co_await asio::this_coro::executor` |
@@ -0,0 +1,164 @@
# Pre-C++20 Styles (C++1117, Boost ≥ 1.74)
If you can't use C++20 `co_await`, the **same Asio library** (modern Boost or standalone) still works — only the *async style* changes. Compile with C++11 or later. Two pre-C++20 styles:
1. **Completion handlers (callbacks)** — header-only, C++11, no extra dependencies. The recommended baseline.
2. **Stackful coroutines** (`asio::spawn` + `yield_context`) — synchronous-looking like `co_await`, but built on Boost.Coroutine/Boost.Context, so it **must be linked** (not header-only) — see build note below.
**Unchanged from the coroutine style** (these are library, not language, features): `io_context`, `make_strand`, `bind_executor`, `steady_timer`, `ssl::stream`, `signal_set`, `async_read`/`async_write`/`async_read_until`, buffers, `resolver`. Use them exactly as shown in [coroutines.md](coroutines.md).
**Not available pre-C++20:** `co_await`/`awaitable<T>`, `co_spawn`, `use_awaitable`, the `||`/`&&` `awaitable_operators`, `as_tuple`, and `co_composed`. The table below gives the equivalent.
### C++20 → pre-C++20 mapping
| C++20 coroutine | Pre-C++20 equivalent |
|-----------------|----------------------|
| `co_await op(use_awaitable)` | callback: `op(handler)` · stackful: `op(yield)` |
| `awaitable<T>` function | member `do_x()` callback chain · or `spawn(strand, fn)` |
| `co_spawn(ex, coro, tok)` | start the callback chain · or `asio::spawn(ex, fn, tok)` |
| `as_tuple(use_awaitable)``[ec,n]` | callback's `(ec, n)` params · stackful: `op(yield[ec])` |
| `a() \|\| b()` (first-wins race) | a **watchdog timer** that closes the socket; the other op fails with `operation_aborted` |
| `a() && b()` (wait both) | launch both, count completions in a shared `shared_ptr<int>` |
| `co_composed<>` custom op | `asio::async_compose<>` (C++11) |
| `co_await this_coro::executor` | `socket_.get_executor()` / a passed-in executor |
### Callback style: full-duplex + write queue
The full-duplex write-queue rule is identical — a strand alone doesn't stop interleaved writes — just expressed with chained handlers. Capture `self = shared_from_this()` in **every** handler to keep the connection alive.
```cpp
class connection : public std::enable_shared_from_this<connection> {
tcp::socket socket_;
asio::strand<asio::any_io_executor> strand_; // tcp::socket's executor is any_io_executor
std::deque<std::string> outbox_;
bool writing_ = false;
char buf_[1024];
public:
explicit connection(tcp::socket s)
: socket_(std::move(s)), strand_(asio::make_strand(socket_.get_executor())) {}
void start() { do_read(); }
void send(std::string frame) { // call on the strand only
outbox_.push_back(std::move(frame));
if (!writing_) do_write();
}
private:
void do_read() {
auto self = shared_from_this();
socket_.async_read_some(asio::buffer(buf_),
asio::bind_executor(strand_, // serialize handler execution
[this, self](boost::system::error_code ec, std::size_t n) {
if (ec) return; // self drops here → socket closes
/* parse buf_[0..n]; call send() for replies */
do_read();
}));
}
void do_write() { // at most one async_write in flight
writing_ = true;
auto self = shared_from_this();
asio::async_write(socket_, asio::buffer(outbox_.front()),
asio::bind_executor(strand_,
[this, self](boost::system::error_code ec, std::size_t) {
if (ec) { writing_ = false; return; }
outbox_.pop_front();
if (!outbox_.empty()) do_write();
else writing_ = false;
}));
}
};
```
### Stackful style: spawn + yield_context
`yield` is a completion token: `op(socket, ..., yield)` suspends until done and returns the result; errors **throw** by default, or use `yield[ec]` for an `error_code`. Run each chain on a per-connection strand.
```cpp
asio::spawn(strand, // executor or strand
[self](asio::yield_context yield) { // capture self for lifetime
try {
char data[1024];
for (;;) {
std::size_t n = self->socket_.async_read_some(asio::buffer(data), yield);
asio::async_write(self->socket_, asio::buffer(data, n), yield);
}
} catch (const std::exception&) { self->socket_.close(); }
},
asio::detached); // completion token (3rd arg)
```
### Timeout without `||` (watchdog timer)
Replace the `read || timer` race with a separate watchdog: reset the timer on each read; a second chain waits on it and closes the socket on expiry, which makes the read fail with `operation_aborted`.
```cpp
// callback watchdog
void arm_timeout() {
timer_.expires_after(std::chrono::seconds(30));
auto self = shared_from_this();
timer_.async_wait(asio::bind_executor(strand_,
[this, self](boost::system::error_code ec) {
if (!ec) socket_.close(); // fired → drop; reset cancels with ec
}));
}
// call arm_timeout() again on every frame received to re-arm
```
### Callback multi-step reads + recurring side-tasks
**Lifetime shift:** coroutine *stack locals* become *member variables* in callback style — a header/body buffer must outlive each async op or it dangles. Chain a composed read of the length, then the body:
```cpp
// members, NOT locals — they must survive until the handler runs
uint32_t len_be_;
std::string body_;
void read_frame() {
auto self = shared_from_this();
asio::async_read(socket_, asio::buffer(&len_be_, sizeof len_be_),
asio::bind_executor(strand_, [this, self](boost::system::error_code ec, std::size_t) {
if (ec) return;
body_.assign(ntohl(len_be_), '\0');
asio::async_read(socket_, asio::buffer(body_), // read exactly N bytes
asio::bind_executor(strand_, [this, self](boost::system::error_code ec2, std::size_t) {
if (ec2) return;
handle_frame(body_); // dispatch on type byte
read_frame(); // next frame
}));
}));
}
```
**Recurring side-task** (e.g. push every 250ms) running concurrently with the read loop — there is no detached coroutine to stop, so use a self-rescheduling timer and `cancel()` it to stop:
```cpp
void schedule_tick(std::string symbol, std::shared_ptr<asio::steady_timer> t) {
t->expires_after(std::chrono::milliseconds(250));
auto self = shared_from_this();
t->async_wait(asio::bind_executor(strand_,
[this, self, symbol, t](boost::system::error_code ec) {
if (ec) return; // cancelled on unsubscribe/close → stops
send(make_tick(symbol)); // enqueue on the write queue
schedule_tick(symbol, t); // reschedule itself
}));
}
// start: keep one timer per subscription alive (e.g. in a map); stop: erase + t->cancel()
```
### Build difference (stackful spawn only)
Callbacks need no change beyond the standard (drop `-fcoroutines`; it's only for C++20 `co_await`):
```cmake
set(CMAKE_CXX_STANDARD 11) # or 14 / 17
set(CMAKE_CXX_EXTENSIONS OFF) # else CMake emits -std=gnu++NN, not literal -std=c++NN
target_link_libraries(app PRIVATE Boost::headers Threads::Threads)
target_compile_definitions(app PRIVATE BOOST_ERROR_CODE_HEADER_ONLY)
```
Stackful `spawn` additionally requires Boost.Coroutine (which uses Boost.Context) — **not header-only**:
```cmake
find_package(Boost REQUIRED COMPONENTS coroutine)
target_link_libraries(app PRIVATE Boost::coroutine) # pulls in Boost.Context
```
> Standalone Asio's `spawn` also depends on Boost.Coroutine/Context — it drags Boost into an otherwise Boost-free build. If you want zero Boost, use the **callback** style.
>
> The 3-arg `spawn(ex, fn, token)` form needs **Boost ≥ 1.80** (older Boost has only `spawn(ex, fn)`). On old distros like Debian bookworm (Boost 1.74), the **callback** style compiles cleanly while stackful `spawn` does not — verified.
@@ -0,0 +1,39 @@
# SSL/TLS
Applies to every style — `ssl::stream<>` is a library feature, not a language one.
```cpp
#include <boost/asio.hpp>
#include <boost/asio/ssl.hpp>
namespace asio = boost::asio;
namespace ssl = asio::ssl;
using tcp = asio::ip::tcp;
asio::awaitable<void> tls_client(asio::io_context& io) {
ssl::context ctx(ssl::context::tlsv13_client);
ctx.set_default_verify_paths();
ssl::stream<tcp::socket> stream(io, ctx);
// Connect underlying TCP socket
auto& sock = stream.lowest_layer();
co_await sock.async_connect(endpoint);
// Set SNI hostname (required for most servers)
SSL_set_tlsext_host_name(stream.native_handle(), "example.com");
stream.set_verify_mode(ssl::verify_peer);
stream.set_verify_callback(ssl::host_name_verification("example.com"));
// TLS handshake
co_await stream.async_handshake(ssl::stream_base::client);
// Read/write as normal stream
co_await async_write(stream, asio::buffer(request));
co_await async_read_until(stream, response_buf, "\r\n");
}
```
**Critical:** SSL streams require strand-based synchronization for all async operations — no concurrent reads/writes without a strand.