From d6d328197a823620f4c695429373697c9635d3eb Mon Sep 17 00:00:00 2001 From: TheLingeringWill Date: Thu, 5 Nov 2026 01:19:51 +0100 Subject: [PATCH 1/9] docs: warn that fragmented UDP datagrams may never arrive A receiver only has to reassemble 576 bytes for IPv4 and 1500 bytes for IPv6, and may silently discard a larger fragmented datagram. Present fragmentation as a correctness hazard rather than only a cost of losing a fragment. Refs #353 --- doc/modules/ROOT/pages/2.networking-tutorial/2g.udp.adoc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/doc/modules/ROOT/pages/2.networking-tutorial/2g.udp.adoc b/doc/modules/ROOT/pages/2.networking-tutorial/2g.udp.adoc index 24a43a3f2..057198686 100644 --- a/doc/modules/ROOT/pages/2.networking-tutorial/2g.udp.adoc +++ b/doc/modules/ROOT/pages/2.networking-tutorial/2g.udp.adoc @@ -63,9 +63,11 @@ A UDP datagram can theoretically be up to 65,535 bytes -- UDP's own 16-bit lengt When a UDP datagram exceeds the path MTU, IP fragments it into smaller pieces. These fragments travel independently through the network. If every fragment arrives, the destination reassembles the original datagram and delivers it to your application. If *any* fragment is lost, the entire datagram is discarded. Your application receives nothing -- not even the fragments that did arrive. +Losing a fragment is not the only way to lose the datagram. A receiver only has to reassemble packets up to a minimum size. That size is 576 bytes for IPv4 (RFC 791) and 1500 bytes for IPv6 (RFC 8200). A receiver may silently discard a larger fragmented datagram, and many firewalls drop fragments outright. A fragmented datagram may therefore never arrive, even on a network that loses nothing. Fragmentation is a correctness hazard, not just a performance cost. + On a typical internet path with a 1500-byte MTU, the practical limit for a UDP datagram you can send without risking fragmentation is about 1472 bytes (1500 minus the 20-byte IP header and 8-byte UDP header). Many applications choose an even smaller limit to account for paths with lower MTUs or encapsulation overhead from VPNs and tunnels. -The takeaway: keep UDP datagrams small. If your message does not fit in a single unfragmented packet, you either need to split it yourself or consider whether TCP is a better fit. +The takeaway: keep UDP datagrams small enough that they are never fragmented. If your message does not fit in a single unfragmented packet, you either need to split it yourself or consider whether TCP is a better fit. == No Connection, No State From 4e7b673deb3fba336463f0289a771bd525e74c56 Mon Sep 17 00:00:00 2001 From: TheLingeringWill Date: Thu, 5 Nov 2026 01:21:04 +0100 Subject: [PATCH 2/9] docs: pass started coroutines what they read by value Add style guide rule D6 so examples follow the lifetime rules: a coroutine started with run_async takes by value whatever it reads after it suspends, and references only what outlives run(). Point the Dangling References section at the Lifetime and Teardown page. Refs #353 --- doc/STYLE_GUIDE.md | 5 +++++ .../ROOT/pages/4.guide/4b.concurrent-programming.adoc | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/doc/STYLE_GUIDE.md b/doc/STYLE_GUIDE.md index 786ac7d91..a22f90fa8 100644 --- a/doc/STYLE_GUIDE.md +++ b/doc/STYLE_GUIDE.md @@ -219,6 +219,11 @@ it only in the sense the row names. **scheduler** is approved only in its P2300 - **D4. Document thread-safety *and* executor affinity** at the class level where relevant. - **D5. No unexplained qualifiers.** Hedges like "even on X" or "where available" either get explained or get cut. +- **D6. Examples follow the coroutine lifetime rules** of `4.guide/4t.lifetime.adoc`. A + coroutine an example starts with `run_async` takes by value whatever it reads after it + suspends. It takes by reference only what outlives `run()`: the `io_context`, or an object + `main` owns for the whole program. A child coroutine awaited in the same expression may + take references. ## Part E — Presentation & Tooling diff --git a/doc/modules/ROOT/pages/4.guide/4b.concurrent-programming.adoc b/doc/modules/ROOT/pages/4.guide/4b.concurrent-programming.adoc index 6b8dc8648..8eb3362c4 100644 --- a/doc/modules/ROOT/pages/4.guide/4b.concurrent-programming.adoc +++ b/doc/modules/ROOT/pages/4.guide/4b.concurrent-programming.adoc @@ -498,7 +498,9 @@ include::example$snippets/4b_concurrent_programming.cpp[tag=dangling_reference,i ---- A coroutine may outlive the scope that started it. Ensure captured data lives -long enough. +long enough. Take by value whatever a started coroutine reads after it +suspends; xref:4.guide/4t.lifetime.adoc[Lifetime and Teardown] gives the full +rules. === Cross-Executor Access From 053d99859b593dd8b2273acfc172c1b2ca41ed98 Mon Sep 17 00:00:00 2001 From: TheLingeringWill Date: Thu, 5 Nov 2026 01:21:59 +0100 Subject: [PATCH 3/9] docs: show the two ways a TCP connection closes The state list read as one linear path, but closing branches on which side sends the first FIN. Add a figure of the closing paths, a short explanation of them, and the CLOSING state of a simultaneous close, which the list left out. Refs #353 --- .../2i.tcp-connections.adoc | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/doc/modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc b/doc/modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc index eac349a48..868f4271d 100644 --- a/doc/modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc +++ b/doc/modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc @@ -95,12 +95,33 @@ FIN_WAIT_1:: This side has sent a FIN and is waiting for an acknowledgment. FIN_WAIT_2:: The other side acknowledged the FIN, but has not yet sent its own FIN. This side is waiting for the remote FIN. +CLOSING:: Both sides sent a FIN at about the same time. This side has received the remote FIN and is waiting for the acknowledgment of its own. + CLOSE_WAIT:: This side has received a FIN from the remote end but has not yet sent its own FIN. If your server has many connections in CLOSE_WAIT, it means the application is not closing sockets promptly after the remote end has disconnected. LAST_ACK:: This side has sent its FIN (after receiving the remote FIN) and is waiting for the final ACK. TIME_WAIT:: The connection is fully closed, but the socket lingers for a period (typically 60 seconds to 2 minutes) before returning to CLOSED. This is the state that causes the most confusion. +Closing takes one of two paths, depending on which side sends the first FIN. The side that closes first moves through FIN_WAIT_1 and FIN_WAIT_2 to TIME_WAIT. The other side moves through CLOSE_WAIT and LAST_ACK straight to CLOSED. If both sides close at the same time, each goes from FIN_WAIT_1 through CLOSING to TIME_WAIT. Each arrow names what moves the connection on: the application calling `close()`, or the segment that arrives. + +[role=figure] +---- + ESTABLISHED + close() / \ FIN + v v + FIN_WAIT_1 CLOSE_WAIT + ACK / \ FIN | close() + v v v + FIN_WAIT_2 CLOSING LAST_ACK + FIN | | ACK | ACK + v v v + TIME_WAIT <+ CLOSED + | 2MSL timeout + v + CLOSED +---- + == TIME_WAIT After both sides have exchanged FINs and ACKs, you might expect the connection to disappear immediately. Instead, the side that initiated the close enters TIME_WAIT and holds the socket open for a period called 2MSL (twice the Maximum Segment Lifetime, typically 60 seconds). From 9dd418f5118948e034209c11ec1ac81118d9ee9c Mon Sep 17 00:00:00 2001 From: TheLingeringWill Date: Thu, 5 Nov 2026 01:22:33 +0100 Subject: [PATCH 4/9] docs: state which executor resumes an awaiting coroutine The class docs said operations resume coroutines "on the correct executor" without saying which one. Say it: the awaiting coroutine's own executor, not the executor of the context that owns the I/O object. Add the rule to io_object as an Executor Affinity paragraph, as style guide rule D4 asks. Refs #353 --- include/boost/corosio/io/io_object.hpp | 5 +++++ include/boost/corosio/local_datagram_socket.hpp | 5 +++-- include/boost/corosio/local_stream_socket.hpp | 5 +++-- include/boost/corosio/resolver.hpp | 4 +++- include/boost/corosio/tcp_acceptor.hpp | 5 +++-- include/boost/corosio/tcp_socket.hpp | 3 ++- include/boost/corosio/udp_socket.hpp | 5 +++-- 7 files changed, 22 insertions(+), 10 deletions(-) diff --git a/include/boost/corosio/io/io_object.hpp b/include/boost/corosio/io/io_object.hpp index 33cd4cc37..a7fc8ea29 100644 --- a/include/boost/corosio/io/io_object.hpp +++ b/include/boost/corosio/io/io_object.hpp @@ -41,6 +41,11 @@ namespace boost::corosio { Shared objects: Unsafe. All operations on a single I/O object must be serialized. + @par Executor Affinity + An operation resumes the awaiting coroutine through that + coroutine's executor, not through the executor of the context + that owns the I/O object. + @note Intended as a protected base class. The handle member `h_` is accessible to derived classes. diff --git a/include/boost/corosio/local_datagram_socket.hpp b/include/boost/corosio/local_datagram_socket.hpp index c7409988d..d5060a9e9 100644 --- a/include/boost/corosio/local_datagram_socket.hpp +++ b/include/boost/corosio/local_datagram_socket.hpp @@ -48,8 +48,9 @@ namespace boost::corosio { This class provides asynchronous Unix domain datagram socket operations that return awaitable types. Each operation - participates in the affine awaitable protocol, ensuring - coroutines resume on the correct executor. + participates in the affine awaitable protocol. It resumes the + awaiting coroutine through that coroutine's executor, not through + the executor of the context that owns the socket. Supports two modes of operation: diff --git a/include/boost/corosio/local_stream_socket.hpp b/include/boost/corosio/local_stream_socket.hpp index 107fdfcb5..d66088fd0 100644 --- a/include/boost/corosio/local_stream_socket.hpp +++ b/include/boost/corosio/local_stream_socket.hpp @@ -44,8 +44,9 @@ namespace boost::corosio { This class provides asynchronous Unix domain stream socket operations that return awaitable types. Each operation - participates in the affine awaitable protocol, ensuring - coroutines resume on the correct executor. + participates in the affine awaitable protocol. It resumes the + awaiting coroutine through that coroutine's executor, not through + the executor of the context that owns the socket. The socket must be opened before performing I/O operations. Operations support cancellation through `std::stop_token` via diff --git a/include/boost/corosio/resolver.hpp b/include/boost/corosio/resolver.hpp index 5e639316e..b9ea6ad9c 100644 --- a/include/boost/corosio/resolver.hpp +++ b/include/boost/corosio/resolver.hpp @@ -177,7 +177,9 @@ struct endpoint_name This class provides asynchronous DNS resolution operations that return awaitable types. Each operation participates in the affine awaitable - protocol, ensuring coroutines resume on the correct executor. + protocol. It resumes the awaiting coroutine through that coroutine's + executor, not through the executor of the context that owns the + resolver. @par Thread Safety Distinct objects: Safe.@n diff --git a/include/boost/corosio/tcp_acceptor.hpp b/include/boost/corosio/tcp_acceptor.hpp index 238552cc6..13b8d9e1e 100644 --- a/include/boost/corosio/tcp_acceptor.hpp +++ b/include/boost/corosio/tcp_acceptor.hpp @@ -46,8 +46,9 @@ namespace boost::corosio { awaitable types. The acceptor binds to a local endpoint and listens for incoming connections. - Each accept operation participates in the affine awaitable protocol, - ensuring coroutines resume on the correct executor. + Each accept operation participates in the affine awaitable protocol. + It resumes the awaiting coroutine through that coroutine's executor, + not through the executor of the context that owns the acceptor. @par Thread Safety Distinct objects: Safe.@n diff --git a/include/boost/corosio/tcp_socket.hpp b/include/boost/corosio/tcp_socket.hpp index 1b1046e5d..05f5e01cd 100644 --- a/include/boost/corosio/tcp_socket.hpp +++ b/include/boost/corosio/tcp_socket.hpp @@ -46,7 +46,8 @@ namespace boost::corosio { This class provides asynchronous TCP socket operations that return awaitable types. Each operation participates in the affine awaitable - protocol, ensuring coroutines resume on the correct executor. + protocol. It resumes the awaiting coroutine through that coroutine's + executor, not through the executor of the context that owns the socket. The socket must be opened before performing I/O operations. Operations support cancellation through `std::stop_token` via the affine protocol, diff --git a/include/boost/corosio/udp_socket.hpp b/include/boost/corosio/udp_socket.hpp index 6cae2cf06..4e4d4840c 100644 --- a/include/boost/corosio/udp_socket.hpp +++ b/include/boost/corosio/udp_socket.hpp @@ -46,8 +46,9 @@ namespace boost::corosio { This class provides asynchronous UDP datagram operations that return awaitable types. Each operation participates in the affine - awaitable protocol, ensuring coroutines resume on the correct - executor. + awaitable protocol. It resumes the awaiting coroutine through that + coroutine's executor, not through the executor of the context that + owns the socket. Supports two modes of operation: From 4dca7145caeeeefdbda754b03334f8a8e37c7d72 Mon Sep 17 00:00:00 2001 From: TheLingeringWill Date: Thu, 5 Nov 2026 01:22:54 +0100 Subject: [PATCH 5/9] docs: explain why io_context also runs coroutines Running coroutines elsewhere and calling into the context only for I/O would cost a queue and a cross-thread wakeup on every completion. Keeping both in one place lets dispatch resume the awaiting coroutine inline by symmetric transfer. Say so in the I/O Context guide. Refs #353 --- doc/modules/ROOT/pages/4.guide/4c.io-context.adoc | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/doc/modules/ROOT/pages/4.guide/4c.io-context.adoc b/doc/modules/ROOT/pages/4.guide/4c.io-context.adoc index 58726ad27..5dbb84d52 100644 --- a/doc/modules/ROOT/pages/4.guide/4c.io-context.adoc +++ b/doc/modules/ROOT/pages/4.guide/4c.io-context.adoc @@ -39,6 +39,18 @@ The cpp:io_context[]: * Provides an executor for coroutine dispatch * Tracks outstanding work to know when to stop +== Why the Context Also Runs Coroutines + +The cpp:io_context[] both waits for I/O and runs the coroutines that wait on +it. Coroutines could instead run elsewhere, such as on a thread pool, and call +into the context only to read or write. Every completion would then cost a +hand-off: queue the coroutine for another thread and wake that thread. + +Keeping both in one place removes that hand-off. An operation often completes +on a thread that runs the context its awaiting coroutine belongs to. The +executor's `dispatch` then resumes the coroutine right there, by symmetric +transfer. + == Construction === Default Construction From 48ba6c2dc50ce61b528b11d2d1d6c3e9e2870488 Mon Sep 17 00:00:00 2001 From: TheLingeringWill Date: Thu, 5 Nov 2026 01:23:31 +0100 Subject: [PATCH 6/9] docs: make the Hash Server's compute_fnv1a a plain function compute_fnv1a used no coroutine machinery and was a task only so it could be handed to capy::run directly. Make it a plain, reusable function and wrap it in a coroutine lambda at the call site, passing the buffer as parameters rather than captures. Update the tutorial to match. Refs #353 --- .../ROOT/pages/3.tutorials/3e.hash-server.adoc | 14 +++++++++----- example/hash-server/hash_server.cpp | 11 +++++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/doc/modules/ROOT/pages/3.tutorials/3e.hash-server.adoc b/doc/modules/ROOT/pages/3.tutorials/3e.hash-server.adoc index ec3fdd2a1..bb64806cf 100644 --- a/doc/modules/ROOT/pages/3.tutorials/3e.hash-server.adoc +++ b/doc/modules/ROOT/pages/3.tutorials/3e.hash-server.adoc @@ -55,9 +55,8 @@ work justifies leaving the event loop. include::example$hash-server/hash_server.cpp[tag=hash_function] ---- -This is a `capy::task` — a lazy coroutine that doesn't start until someone -awaits it. That matters because `run()` needs to control which executor the -task runs on. +This is a plain function. It knows nothing about coroutines or executors, so +any code can reuse it. The session decides where it runs. == Session Coroutine @@ -72,8 +71,8 @@ Three things happen in sequence, but on two different executors: 1. **Read** — runs on the cpp:io_context[] thread. The socket awaitable suspends the coroutine until data arrives from the kernel. -2. **Hash** — `capy::run( pool.get_executor() )` posts `compute_fnv1a` to the - thread pool. The coroutine suspends on the cpp:io_context[] and resumes on a +2. **Hash** — `capy::run( pool.get_executor() )` posts a task that calls + `compute_fnv1a` to the thread pool. The coroutine suspends on the cpp:io_context[] and resumes on a pool thread. When the task completes, a trampoline posts the coroutine back to the cpp:io_context[]. 3. **Write** — back on the cpp:io_context[] thread, the hex result is sent to the @@ -91,6 +90,11 @@ When you write: include::example$hash-server/hash_server.cpp[tag=run_switch,indent=0] ---- +`run()` takes a task, so a coroutine lambda wraps the plain function. Following +the xref:../4.guide/4t.lifetime.adoc[lifetime rules], it gets `buf` and `n` as +parameters, not captures. `buf` stays valid because `do_session` stays +suspended until the hash completes. + Behind the scenes: 1. `run()` creates an awaitable that stores the pool executor. diff --git a/example/hash-server/hash_server.cpp b/example/hash-server/hash_server.cpp index 0cb6946e9..badcbed91 100644 --- a/example/hash-server/hash_server.cpp +++ b/example/hash-server/hash_server.cpp @@ -29,9 +29,9 @@ namespace corosio = boost::corosio; namespace capy = boost::capy; // end::assume[] -/// Compute FNV-1a hash on the thread pool. +/// Compute the FNV-1a hash of a buffer. // tag::hash_function[] -capy::task +std::uint64_t compute_fnv1a(char const* data, std::size_t len) { constexpr std::uint64_t basis = 14695981039346656037ULL; @@ -43,7 +43,7 @@ compute_fnv1a(char const* data, std::size_t len) h ^= static_cast(data[i]); h *= prime; } - co_return h; + return h; } // end::hash_function[] @@ -81,7 +81,10 @@ do_session(corosio::tcp_socket sock, capy::thread_pool& pool) // Switch to thread pool for CPU-bound hash computation, // then automatically resume on io_context when done // tag::run_switch[] - auto hash = co_await capy::run(pool.get_executor())(compute_fnv1a(buf, n)); + auto hash = co_await capy::run(pool.get_executor())( + [](char const* data, std::size_t len) -> capy::task { + co_return compute_fnv1a(data, len); + }(buf, n)); // end::run_switch[] // Send hex result back to client (on io_context) From abf29a220a23dfc22dbab65620c01d7686d61715 Mon Sep 17 00:00:00 2001 From: TheLingeringWill Date: Sat, 7 Nov 2026 02:42:28 +0100 Subject: [PATCH 7/9] docs: fix the examples and wording the PR review flagged The Signals examples now shut down through a stop token instead of ioc.stop(). The signal coroutine no longer references a local of run_server, so a signal that arrives after run_server ends cannot write freed memory, and run() returns once the coroutines finish. The acceptor's accept loop owns its acceptor and stops on a stop token, so it cannot outlive the acceptor. D6 now says "after it may be stored", which covers reads before a lazy task's first co_await, and joins the Review tier and the doc PR checklist. The Hash Server tutorial says do_session stays suspended while the hash runs on the pool. Refs #353 --- doc/STYLE_GUIDE.md | 12 ++--- .../pages/3.tutorials/3e.hash-server.adoc | 8 ++-- .../4.guide/4b.concurrent-programming.adoc | 4 +- .../ROOT/pages/4.guide/4e.tcp-acceptor.adoc | 1 + .../ROOT/pages/4.guide/4i.signals.adoc | 12 +++++ test/doc/snippets/4e_tcp_acceptor.cpp | 15 ++++--- test/doc/snippets/4i_signals.cpp | 45 ++++++++++--------- 7 files changed, 56 insertions(+), 41 deletions(-) diff --git a/doc/STYLE_GUIDE.md b/doc/STYLE_GUIDE.md index a22f90fa8..2fc3b86c5 100644 --- a/doc/STYLE_GUIDE.md +++ b/doc/STYLE_GUIDE.md @@ -220,10 +220,10 @@ it only in the sense the row names. **scheduler** is approved only in its P2300 - **D5. No unexplained qualifiers.** Hedges like "even on X" or "where available" either get explained or get cut. - **D6. Examples follow the coroutine lifetime rules** of `4.guide/4t.lifetime.adoc`. A - coroutine an example starts with `run_async` takes by value whatever it reads after it - suspends. It takes by reference only what outlives `run()`: the `io_context`, or an object - `main` owns for the whole program. A child coroutine awaited in the same expression may - take references. + coroutine an example starts with `run_async` takes by value whatever it reads after it may + be stored, which for a lazy task includes reads before its first `co_await`. It takes by + reference only what outlives `run()`: the `io_context`, or an object `main` owns for the + whole program. A child coroutine awaited in the same expression may take references. ## Part E — Presentation & Tooling @@ -266,7 +266,7 @@ Not every rule is machine-checkable. Each rule sits in one of three tiers: |---|---| | **Gate** | A1, A6, A7, B2, B3, B6, C2, C4, C9, C10, D2 | | **Warning** | A2, B1, C1, C3, C5, C6, D4, D5, E1 | -| **Review** | A3, A4, A5, B4, B5, C7, C8, C11, D1, D3, E2, E3, E4 | +| **Review** | A3, A4, A5, B4, B5, C7, C8, C11, D1, D3, D6, E2, E3, E4 | The accuracy gates (B2, B3, D2 correctness) are enforced by the snippet-compile job, not by Vale — that job is what makes examples unable to drift. @@ -335,7 +335,7 @@ max: 25 compiled file (Part B2). - **F3.** Doc PR checklist: mode declared (A1)? no hand-typed signatures (B1)? example compiled (B2)? namespace alias, not a using-directive (B5)? terminology clean (`vale`)? - rationale present (D3)? + rationale present (D3)? examples follow the lifetime rules (D6)? ### F4 — A check is not adopted until a planted violation has failed it diff --git a/doc/modules/ROOT/pages/3.tutorials/3e.hash-server.adoc b/doc/modules/ROOT/pages/3.tutorials/3e.hash-server.adoc index bb64806cf..b5e5d8210 100644 --- a/doc/modules/ROOT/pages/3.tutorials/3e.hash-server.adoc +++ b/doc/modules/ROOT/pages/3.tutorials/3e.hash-server.adoc @@ -34,8 +34,8 @@ inline blocks the event loop and starves every other connection. The solution is to keep I/O on the cpp:io_context[] and offload heavy computation to a `thread_pool`. Capy's `run()` function makes this seamless. A single -`co_await` switches the coroutine to the pool, runs the work, and resumes -back on the original executor when it finishes. +`co_await` runs the work on the pool and resumes the coroutine on the original +executor when the work finishes. This tutorial demonstrates: @@ -72,8 +72,8 @@ Three things happen in sequence, but on two different executors: 1. **Read** — runs on the cpp:io_context[] thread. The socket awaitable suspends the coroutine until data arrives from the kernel. 2. **Hash** — `capy::run( pool.get_executor() )` posts a task that calls - `compute_fnv1a` to the thread pool. The coroutine suspends on the cpp:io_context[] and resumes on a - pool thread. When the task completes, a trampoline posts the + `compute_fnv1a` to the thread pool. The coroutine stays suspended while a + pool thread runs the task. When the task completes, a trampoline posts the coroutine back to the cpp:io_context[]. 3. **Write** — back on the cpp:io_context[] thread, the hex result is sent to the client. diff --git a/doc/modules/ROOT/pages/4.guide/4b.concurrent-programming.adoc b/doc/modules/ROOT/pages/4.guide/4b.concurrent-programming.adoc index 8eb3362c4..5cf21c97d 100644 --- a/doc/modules/ROOT/pages/4.guide/4b.concurrent-programming.adoc +++ b/doc/modules/ROOT/pages/4.guide/4b.concurrent-programming.adoc @@ -498,8 +498,8 @@ include::example$snippets/4b_concurrent_programming.cpp[tag=dangling_reference,i ---- A coroutine may outlive the scope that started it. Ensure captured data lives -long enough. Take by value whatever a started coroutine reads after it -suspends; xref:4.guide/4t.lifetime.adoc[Lifetime and Teardown] gives the full +long enough. Take by value whatever a started coroutine reads after it may be +stored; xref:4.guide/4t.lifetime.adoc[Lifetime and Teardown] gives the full rules. === Cross-Executor Access diff --git a/doc/modules/ROOT/pages/4.guide/4e.tcp-acceptor.adoc b/doc/modules/ROOT/pages/4.guide/4e.tcp-acceptor.adoc index 70c5a0082..5335f0cf6 100644 --- a/doc/modules/ROOT/pages/4.guide/4e.tcp-acceptor.adoc +++ b/doc/modules/ROOT/pages/4.guide/4e.tcp-acceptor.adoc @@ -263,6 +263,7 @@ include::example$snippets/4e_tcp_acceptor.cpp[tag=accept_loop] Key points: +* The loop owns the acceptor, so the acceptor lives as long as the loop * Create a fresh socket for each accept * Move the socket into the handler coroutine * Continue accepting after non-fatal errors diff --git a/doc/modules/ROOT/pages/4.guide/4i.signals.adoc b/doc/modules/ROOT/pages/4.guide/4i.signals.adoc index b60dabe68..0d7a9d8cc 100644 --- a/doc/modules/ROOT/pages/4.guide/4i.signals.adoc +++ b/doc/modules/ROOT/pages/4.guide/4i.signals.adoc @@ -214,11 +214,19 @@ when `request_stop()` is called from another thread. === Graceful Shutdown +Request a stop on the token the server runs with. Its pending operations +complete with `capy::error::canceled`, and `run()` returns once its coroutines +finish. Copies of a `std::stop_source` share one stop state, so the coroutine +takes it by value. + [source,cpp] ---- include::example$snippets/4i_signals.cpp[tag=graceful_shutdown] ---- +Calling `ioc.stop()` instead leaves the server's coroutines suspended; see +xref:4.guide/4t.lifetime.adoc#_stopping_work[Stopping Work]. + === Multiple Signal Waits You can wait for signals multiple times: @@ -274,6 +282,10 @@ concurrently on the same cpp:signal_set[]. == Example: Server with Graceful Shutdown +The accept loop runs with a stop token. When a signal arrives, `run_server` +requests a stop, the pending accept completes with `canceled`, and the loop +ends. + [source,cpp] ---- include::example$snippets/4i_signals.cpp[tag=server_shutdown] diff --git a/test/doc/snippets/4e_tcp_acceptor.cpp b/test/doc/snippets/4e_tcp_acceptor.cpp index 4f6fec2cf..3dd744e89 100644 --- a/test/doc/snippets/4e_tcp_acceptor.cpp +++ b/test/doc/snippets/4e_tcp_acceptor.cpp @@ -244,7 +244,7 @@ handle_connection(corosio::tcp_socket peer) // tag::accept_loop[] capy::task -accept_loop(corosio::io_context& ioc, corosio::tcp_acceptor& acc) +accept_loop(corosio::io_context& ioc, corosio::tcp_acceptor acc) { for (;;) { @@ -286,18 +286,19 @@ run_server(corosio::io_context& ioc) } corosio::signal_set signals(ioc, SIGINT, SIGTERM); + std::stop_source stop_src; - // Spawn accept loop - capy::run_async(ioc.get_executor())(accept_loop(ioc, acc)); + // Spawn accept loop; it owns the acceptor + capy::run_async(ioc.get_executor(), stop_src.get_token())( + accept_loop(ioc, std::move(acc))); // Wait for shutdown signal auto [ec, signum] = co_await signals.wait(); if (!ec) - { std::cout << "Received signal " << signum << ", shutting down\n"; - acc.cancel(); // Stop accepting - // Existing connections continue until complete - } + + stop_src.request_stop(); // Stop accepting + // Existing connections continue until complete } // end::graceful_shutdown[] diff --git a/test/doc/snippets/4i_signals.cpp b/test/doc/snippets/4i_signals.cpp index 4b152ae88..9e5a450ad 100644 --- a/test/doc/snippets/4i_signals.cpp +++ b/test/doc/snippets/4i_signals.cpp @@ -51,7 +51,6 @@ namespace capy = boost::capy; #include #include -#include #include #include #include @@ -141,17 +140,15 @@ stop_token_frag(corosio::signal_set& signals, std::error_code& out) // tag::graceful_shutdown[] capy::task -shutdown_handler(corosio::io_context& ioc, std::atomic& running) +shutdown_on_signal(corosio::io_context& ioc, std::stop_source stop_src) { corosio::signal_set signals(ioc, SIGINT, SIGTERM); auto [ec, signum] = co_await signals.wait(); if (!ec) - { std::cout << "Shutdown signal received\n"; - running = false; - ioc.stop(); - } + + stop_src.request_stop(); // Also stops the server if the wait failed } // end::graceful_shutdown[] @@ -227,33 +224,37 @@ child_reaper(corosio::io_context& ioc) // tag::server_shutdown[] capy::task -run_server(corosio::io_context& ioc) +accept_loop(corosio::io_context& ioc) { - std::atomic running{true}; - - // Start signal handler - capy::run_async(ioc.get_executor())( - [](corosio::io_context& ioc, - std::atomic& running) -> capy::task { - corosio::signal_set signals(ioc, SIGINT, SIGTERM); - co_await signals.wait(); - running = false; - ioc.stop(); - }(ioc, running)); - - // Accept loop corosio::tcp_acceptor acc(ioc, corosio::endpoint(8080)); - while (running) + for (;;) { corosio::tcp_socket peer(ioc); auto [ec] = co_await acc.accept(peer); if (ec) - break; + break; // Stop requested, or accept failed // Handle connection... } } + +capy::task +run_server(corosio::io_context& ioc) +{ + std::stop_source stop_src; + + // Start the accept loop with a stop token + capy::run_async(ioc.get_executor(), stop_src.get_token())( + accept_loop(ioc)); + + // Wait for SIGINT or SIGTERM + corosio::signal_set signals(ioc, SIGINT, SIGTERM); + co_await signals.wait(); + + // Stop the accept loop; run() returns once it has finished + stop_src.request_stop(); +} // end::server_shutdown[] struct signals_test From 8ad6d99b6cce0a4b82f7add22805d1240c1cd58e Mon Sep 17 00:00:00 2001 From: TheLingeringWill Date: Mon, 9 Nov 2026 16:51:33 +0100 Subject: [PATCH 8/9] docs: correct the resume wording and other points the PR review flagged The I/O Context guide said dispatch resumes a completed operation's coroutine "by symmetric transfer". It does not: the event loop calls dispatch_coro() and then resume() on the result, a plain inline resume. Say it resumes the coroutine inline on that thread, with no queue or wakeup. The message of 4dca7145 has the same wrong wording. Also: - UDP: say what a receiver is required to accept, and the UDP payload those sizes leave after the headers. - Hash Server: captures would also be safe for the run() lambda; parameters are the habit, not a requirement here. - Style guide D6: say "after the caller's scope may have ended" instead of the unclear "after it may be stored". - timer.hpp: replace the last "correct executor" phrase with which executor resumes the coroutine. - TCP closing: note the FIN_WAIT_1 to TIME_WAIT shortcut the figure leaves out. Refs #353 --- doc/STYLE_GUIDE.md | 9 +++++---- .../ROOT/pages/2.networking-tutorial/2g.udp.adoc | 2 +- .../2.networking-tutorial/2i.tcp-connections.adoc | 2 +- .../ROOT/pages/3.tutorials/3e.hash-server.adoc | 11 +++++++---- doc/modules/ROOT/pages/4.guide/4c.io-context.adoc | 4 ++-- include/boost/corosio/detail/timer.hpp | 5 +++-- 6 files changed, 19 insertions(+), 14 deletions(-) diff --git a/doc/STYLE_GUIDE.md b/doc/STYLE_GUIDE.md index 2fc3b86c5..033d5d2e1 100644 --- a/doc/STYLE_GUIDE.md +++ b/doc/STYLE_GUIDE.md @@ -220,10 +220,11 @@ it only in the sense the row names. **scheduler** is approved only in its P2300 - **D5. No unexplained qualifiers.** Hedges like "even on X" or "where available" either get explained or get cut. - **D6. Examples follow the coroutine lifetime rules** of `4.guide/4t.lifetime.adoc`. A - coroutine an example starts with `run_async` takes by value whatever it reads after it may - be stored, which for a lazy task includes reads before its first `co_await`. It takes by - reference only what outlives `run()`: the `io_context`, or an object `main` owns for the - whole program. A child coroutine awaited in the same expression may take references. + coroutine an example starts with `run_async` takes by value whatever it reads after the + caller's scope may have ended, which for a lazy task includes reads before its first + `co_await`. It takes by reference only what outlives `run()`: the `io_context`, or an + object `main` owns for the whole program. A child coroutine awaited in the same expression + may take references. ## Part E — Presentation & Tooling diff --git a/doc/modules/ROOT/pages/2.networking-tutorial/2g.udp.adoc b/doc/modules/ROOT/pages/2.networking-tutorial/2g.udp.adoc index 057198686..d0fb51783 100644 --- a/doc/modules/ROOT/pages/2.networking-tutorial/2g.udp.adoc +++ b/doc/modules/ROOT/pages/2.networking-tutorial/2g.udp.adoc @@ -63,7 +63,7 @@ A UDP datagram can theoretically be up to 65,535 bytes -- UDP's own 16-bit lengt When a UDP datagram exceeds the path MTU, IP fragments it into smaller pieces. These fragments travel independently through the network. If every fragment arrives, the destination reassembles the original datagram and delivers it to your application. If *any* fragment is lost, the entire datagram is discarded. Your application receives nothing -- not even the fragments that did arrive. -Losing a fragment is not the only way to lose the datagram. A receiver only has to reassemble packets up to a minimum size. That size is 576 bytes for IPv4 (RFC 791) and 1500 bytes for IPv6 (RFC 8200). A receiver may silently discard a larger fragmented datagram, and many firewalls drop fragments outright. A fragmented datagram may therefore never arrive, even on a network that loses nothing. Fragmentation is a correctness hazard, not just a performance cost. +Losing a fragment is not the only way to lose the datagram. A receiver is only required to accept a reassembled datagram of up to 576 bytes for IPv4 (RFC 791) or 1500 bytes for IPv6 (RFC 8200). Both sizes include the IP header, so they leave at most 548 bytes of UDP payload for IPv4 and 1452 for IPv6. A receiver may silently discard a larger fragmented datagram, and many firewalls drop fragments outright. A fragmented datagram may therefore never arrive, even on a network that loses nothing. Fragmentation is a correctness hazard, not just a performance cost. On a typical internet path with a 1500-byte MTU, the practical limit for a UDP datagram you can send without risking fragmentation is about 1472 bytes (1500 minus the 20-byte IP header and 8-byte UDP header). Many applications choose an even smaller limit to account for paths with lower MTUs or encapsulation overhead from VPNs and tunnels. diff --git a/doc/modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc b/doc/modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc index 868f4271d..bf6b6f4ab 100644 --- a/doc/modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc +++ b/doc/modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc @@ -103,7 +103,7 @@ LAST_ACK:: This side has sent its FIN (after receiving the remote FIN) and is wa TIME_WAIT:: The connection is fully closed, but the socket lingers for a period (typically 60 seconds to 2 minutes) before returning to CLOSED. This is the state that causes the most confusion. -Closing takes one of two paths, depending on which side sends the first FIN. The side that closes first moves through FIN_WAIT_1 and FIN_WAIT_2 to TIME_WAIT. The other side moves through CLOSE_WAIT and LAST_ACK straight to CLOSED. If both sides close at the same time, each goes from FIN_WAIT_1 through CLOSING to TIME_WAIT. Each arrow names what moves the connection on: the application calling `close()`, or the segment that arrives. +Closing takes one of two paths, depending on which side sends the first FIN. The side that closes first moves through FIN_WAIT_1 and FIN_WAIT_2 to TIME_WAIT. The other side moves through CLOSE_WAIT and LAST_ACK straight to CLOSED. If both sides close at the same time, each goes from FIN_WAIT_1 through CLOSING to TIME_WAIT. Each arrow names what moves the connection on: the application calling `close()`, or the segment that arrives. The figure leaves out one shortcut. When the peer's ACK and FIN arrive in one segment, the side that closes first goes straight from FIN_WAIT_1 to TIME_WAIT. [role=figure] ---- diff --git a/doc/modules/ROOT/pages/3.tutorials/3e.hash-server.adoc b/doc/modules/ROOT/pages/3.tutorials/3e.hash-server.adoc index b5e5d8210..fcb101b51 100644 --- a/doc/modules/ROOT/pages/3.tutorials/3e.hash-server.adoc +++ b/doc/modules/ROOT/pages/3.tutorials/3e.hash-server.adoc @@ -90,10 +90,13 @@ When you write: include::example$hash-server/hash_server.cpp[tag=run_switch,indent=0] ---- -`run()` takes a task, so a coroutine lambda wraps the plain function. Following -the xref:../4.guide/4t.lifetime.adoc[lifetime rules], it gets `buf` and `n` as -parameters, not captures. `buf` stays valid because `do_session` stays -suspended until the hash completes. +`run()` takes a task, so a coroutine lambda wraps the plain function. The +lambda gets `buf` and `n` as parameters, not captures. The lambda object lives +until the end of the full expression, which includes the `co_await`, so +captures would also be safe here. Parameters are still the habit the +xref:../4.guide/4t.lifetime.adoc[lifetime rules] teach, because they live in +the coroutine frame rather than in the lambda object. `buf` stays valid +because `do_session` stays suspended until the hash completes. Behind the scenes: diff --git a/doc/modules/ROOT/pages/4.guide/4c.io-context.adoc b/doc/modules/ROOT/pages/4.guide/4c.io-context.adoc index 5dbb84d52..d778b9272 100644 --- a/doc/modules/ROOT/pages/4.guide/4c.io-context.adoc +++ b/doc/modules/ROOT/pages/4.guide/4c.io-context.adoc @@ -48,8 +48,8 @@ hand-off: queue the coroutine for another thread and wake that thread. Keeping both in one place removes that hand-off. An operation often completes on a thread that runs the context its awaiting coroutine belongs to. The -executor's `dispatch` then resumes the coroutine right there, by symmetric -transfer. +executor's `dispatch` then resumes the coroutine inline on that thread, with no +queue or wakeup. == Construction diff --git a/include/boost/corosio/detail/timer.hpp b/include/boost/corosio/detail/timer.hpp index a1ee5a67f..817dc2d8c 100644 --- a/include/boost/corosio/detail/timer.hpp +++ b/include/boost/corosio/detail/timer.hpp @@ -54,8 +54,9 @@ struct wait_awaitable; completes with success; a cancelled wait completes with an error that compares equal to `capy::cond::canceled`. - Each timer operation participates in the affine awaitable protocol, - ensuring coroutines resume on the correct executor. + Each timer operation participates in the affine awaitable protocol. + It resumes the awaiting coroutine through that coroutine's executor, + not through the executor of the context that owns the timer. @par Thread Safety Distinct objects: Safe.@n From 7201a94f94c740528dd738c11f5f3c50156cec78 Mon Sep 17 00:00:00 2001 From: TheLingeringWill Date: Mon, 9 Nov 2026 17:50:05 +0100 Subject: [PATCH 9/9] docs: draw the TCP connection figures with Mermaid The handshake, the teardown and the closing states on "Opening and Closing TCP Connections" were ASCII art. Draw them with Mermaid, the way Capy draws its diagrams: two sequence diagrams and a state diagram. The state diagram now shows the FIN_WAIT_1 to TIME_WAIT shortcut that the ASCII figure had to leave out. - Load @sntke/antora-mermaid-extension in the local playbook, with the same settings as boost.org's libs playbook, which already loads it. - Add partials/diagram-style.adoc, adapted from Capy's. It styles the diagrams in the palette of the benchmark charts in light and dark mode, and adds rules for state diagrams. - Port Capy's doc-lint exemption and self-test: a [mermaid] block is diagram source, so B2 and SHAPE skip it, but it never clears a [source,*] block. Refs #353 --- doc/STYLE_GUIDE.md | 3 + doc/lint/doc-lint.mjs | 11 ++ doc/lint/selftest.mjs | 37 +++++ doc/local-playbook.yml | 5 + .../2i.tcp-connections.adoc | 75 +++++---- doc/modules/ROOT/partials/diagram-style.adoc | 156 ++++++++++++++++++ doc/package-lock.json | 9 +- doc/package.json | 3 +- 8 files changed, 262 insertions(+), 37 deletions(-) create mode 100644 doc/modules/ROOT/partials/diagram-style.adoc diff --git a/doc/STYLE_GUIDE.md b/doc/STYLE_GUIDE.md index 033d5d2e1..8de654746 100644 --- a/doc/STYLE_GUIDE.md +++ b/doc/STYLE_GUIDE.md @@ -76,6 +76,9 @@ The Antora pipeline provides two mechanisms; use them instead of hand-authoring: launder real code past B2. `doc-lint.mjs`'s SHAPE check runs a content heuristic over every role=output/role=figure block and reports (non-gated, advisory) any whose content looks like code, so a wrong tag is not silently permanent — see the check's header comment. + A `[mermaid]` block is diagram source for the Mermaid extension, so B2 and SHAPE skip it + too; like role=output/role=figure, it never clears a `[source,*]` block. Prefer one to a + hand-drawn `[role=figure]`, and include `partial$diagram-style.adoc` once on its page. Note `role=` on a bare listing emits a real CSS class (`class="listingblock output"` / `...figure`) with no stylesheet rule behind it today; a future UI bundle that styles `.output`/`.figure` will change how every such block renders, project-wide, in one step. diff --git a/doc/lint/doc-lint.mjs b/doc/lint/doc-lint.mjs index 07b1cba22..060d15ada 100644 --- a/doc/lint/doc-lint.mjs +++ b/doc/lint/doc-lint.mjs @@ -38,6 +38,8 @@ // `[quote,...]`) — a first cut that merged any `[...]`-shaped line // made one of those, sitting directly above an exempt // `[source,...,role=pseudocode]` block, defeat that exemption. +// A `[mermaid]` block (diagram source, not code) is exempt from B2 +// and SHAPE both, unless it is also `[source,...]`. // SHAPE — advisory only, NEVER gated (not in the summary the CI gate // spec reads by rule prefix). A role=output/role=figure block is a // permanent B2 exemption, so a block wrongly marked non-code would @@ -207,6 +209,15 @@ function scanBlocks(lines, ch) { // on a [source,*] block. if (isSource && hasClearingRole) continue; + // A [mermaid] block is diagram source for the mermaid Antora extension: + // neither B2 nor SHAPE applies (sequence-diagram messages routinely say + // `co_await`, which SHAPE matches). Code pasted into one cannot hide, as + // mermaid fails to parse it and the page shows an error box. The style + // line sits directly above the walked [source,...]/[role=...] stack, and + // the exemption is isSource-gated for the same reason as role=output. + const isMermaid = a >= 0 && /^\[mermaid\b/i.test(lines[a].trim()); + if (!isSource && isMermaid) continue; + if (!isSource && hasNonCodeRole) { // Bare listing explicitly marked as program output / a figure: not a // B2 candidate, but SHAPE still looks at its content (advisory). diff --git a/doc/lint/selftest.mjs b/doc/lint/selftest.mjs index 851ca0d6b..fb321a957 100644 --- a/doc/lint/selftest.mjs +++ b/doc/lint/selftest.mjs @@ -386,6 +386,31 @@ try { '', ].join('\n')); + // A `[mermaid]` block is diagram source rendered by the mermaid Antora + // extension, not code: B2 and SHAPE must both leave it alone (a sequence + // diagram's messages routinely say `co_await`, which SHAPE matches). + // The exemption must not reach a block that is ALSO `[source,...]`, or + // a `[mermaid]` line stacked above a real C++ block would clear it. + // Line numbers are asserted below, so keep them stable. + write('mermaid.adoc', [ + ':page-mode: how-to', // 1 + '', // 2 + '= Mermaid', // 3 + '', // 4 + '[mermaid]', // 5 + '----', // 6 + 'sequenceDiagram', // 7 + ' A->>B: co_await child()', // 8 + '----', // 9 + '', // 10 + '[mermaid]', // 11 + '[source,cpp]', // 12 <- must fire + '----', // 13 + 'int w = 4;', // 14 + '----', // 15 + '', + ].join('\n')); + // ANCHOR fixture. Line numbers are asserted below, so keep them stable: // 3 = the prose violation, 5-8 = the in-block negative, 10 = the // passthrough negative. @@ -512,6 +537,18 @@ try { anchorHits.length === 0, `anchor-above-pseudocode.adoc should have 0 B2 findings, got: ${JSON.stringify(anchorHits)}`); + const mermaidB2 = dOut.findings.B2.filter((f) => f.file === 'mermaid.adoc'); + check('B2 leaves a [mermaid] diagram block alone', + !mermaidB2.some((f) => f.line === 5 || f.line === 6), + `mermaid.adoc:5 is a diagram, not code; got: ${JSON.stringify(mermaidB2)}`); + check('a [mermaid] line stacked above [source,cpp] does not clear the C++ block', + mermaidB2.length === 1 && mermaidB2[0].line === 12, + `expected exactly 1 mermaid.adoc B2 finding at line 12, got: ${JSON.stringify(mermaidB2)}`); + const mermaidShape = dOut.findings.SHAPE.filter((f) => f.file === 'mermaid.adoc'); + check('SHAPE leaves a [mermaid] diagram block alone even when a message says co_await', + mermaidShape.length === 0, + `mermaid.adoc should have 0 SHAPE findings, got: ${JSON.stringify(mermaidShape)}`); + // ANCHOR: a C++ attribute in a prose code span. Asciidoctor's // inline-anchor substitution runs inside a backtick span, so // `` `[[nodiscard]]` `` renders as an EMPTY -- the attribute diff --git a/doc/local-playbook.yml b/doc/local-playbook.yml index 3b4775b67..fcdb90313 100644 --- a/doc/local-playbook.yml +++ b/doc/local-playbook.yml @@ -22,6 +22,11 @@ antora: - require: '@antora/lunr-extension' # https://gitlab.com/antora/antora-lunr-extension index_latest_only: true - require: '@antora/collector-extension' + - require: '@sntke/antora-mermaid-extension' + mermaid_library_url: https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs + script_stem: header-scripts + mermaid_initialize_options: + start_on_load: true - require: '@cppalliance/antora-cpp-tagfiles-extension' cpp-tagfiles: using-namespaces: diff --git a/doc/modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc b/doc/modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc index bf6b6f4ab..03d23df39 100644 --- a/doc/modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc +++ b/doc/modules/ROOT/pages/2.networking-tutorial/2i.tcp-connections.adoc @@ -10,6 +10,8 @@ = Opening and Closing TCP Connections :page-mode: explanation +include::partial$diagram-style.adoc[] + A TCP connection is not a physical wire. It is an agreement between two machines to track a shared conversation. That agreement begins with a handshake, ends with a teardown, and passes through a series of well-defined states in between. Knowing these states explains errors you encounter in every networked application you write. == The Three-Way Handshake @@ -20,17 +22,18 @@ Before any data flows, TCP establishes the connection with three segments: . *SYN-ACK*: The server responds with both the SYN and ACK flags set. It acknowledges the client's ISN (by setting the acknowledgment number to the client's ISN + 1) and provides its own ISN. This says "I accept, I acknowledge your starting number, and my byte numbering starts here." . *ACK*: The client sends a final acknowledgment of the server's ISN. At this point both sides have agreed on initial sequence numbers, and the connection is established. -[role=figure] +[mermaid] ---- -Client Server - | | - |--- SYN (seq=100) ----------------->| - | | - |<-- SYN-ACK (seq=300, ack=101) -----| - | | - |--- ACK (ack=301) ----------------->| - | | - | Connection established | +sequenceDiagram + autonumber + accTitle: The three-way handshake + accDescr: The client sends a SYN with sequence number 100. The server answers with a SYN-ACK that has sequence number 300 and acknowledges 101. The client sends an ACK that acknowledges 301, and the connection is established. + participant C as Client + participant S as Server + C->>S: SYN (seq=100) + S->>C: SYN-ACK (seq=300, ack=101) + C->>S: ACK (ack=301) + Note over C,S: Connection established ---- Why three steps instead of two? Both sides need to agree on two things: the client's ISN and the server's ISN. A two-step handshake would let the server accept without the client confirming the server's ISN. The third step completes the exchange. @@ -54,17 +57,19 @@ Closing a TCP connection takes four segments because each direction of the strea . *FIN*: The other side sends its own FIN when it, too, has finished sending. . *ACK*: The first side acknowledges the second FIN. -[role=figure] +[mermaid] ---- -Client Server - | | - |--- FIN --------------------------->| - |<-- ACK ----------------------------| - | | - |<-- FIN ----------------------------| - |--- ACK --------------------------->| - | | - | Connection closed | +sequenceDiagram + autonumber + accTitle: Graceful teardown + accDescr: The client sends a FIN, and the server acknowledges it. Then the server sends its own FIN, and the client acknowledges it. The connection is closed. + participant C as Client + participant S as Server + C->>S: FIN + S->>C: ACK + S->>C: FIN + C->>S: ACK + Note over C,S: Connection closed ---- In practice, the server's ACK and FIN are often combined into a single segment, reducing the exchange to three segments. But logically, the four steps reflect two independent half-closes. @@ -103,23 +108,23 @@ LAST_ACK:: This side has sent its FIN (after receiving the remote FIN) and is wa TIME_WAIT:: The connection is fully closed, but the socket lingers for a period (typically 60 seconds to 2 minutes) before returning to CLOSED. This is the state that causes the most confusion. -Closing takes one of two paths, depending on which side sends the first FIN. The side that closes first moves through FIN_WAIT_1 and FIN_WAIT_2 to TIME_WAIT. The other side moves through CLOSE_WAIT and LAST_ACK straight to CLOSED. If both sides close at the same time, each goes from FIN_WAIT_1 through CLOSING to TIME_WAIT. Each arrow names what moves the connection on: the application calling `close()`, or the segment that arrives. The figure leaves out one shortcut. When the peer's ACK and FIN arrive in one segment, the side that closes first goes straight from FIN_WAIT_1 to TIME_WAIT. +Closing takes one of two paths, depending on which side sends the first FIN. The side that closes first moves through FIN_WAIT_1 and FIN_WAIT_2 to TIME_WAIT. The other side moves through CLOSE_WAIT and LAST_ACK straight to CLOSED. If both sides close at the same time, each goes from FIN_WAIT_1 through CLOSING to TIME_WAIT. When the peer's ACK and FIN arrive in one segment, the side that closes first skips FIN_WAIT_2 and goes straight from FIN_WAIT_1 to TIME_WAIT. Each arrow names what moves the connection on: the application calling `close()`, or the segment that arrives. -[role=figure] +[mermaid] ---- - ESTABLISHED - close() / \ FIN - v v - FIN_WAIT_1 CLOSE_WAIT - ACK / \ FIN | close() - v v v - FIN_WAIT_2 CLOSING LAST_ACK - FIN | | ACK | ACK - v v v - TIME_WAIT <+ CLOSED - | 2MSL timeout - v - CLOSED +stateDiagram-v2 + accTitle: The two ways a TCP connection closes + accDescr: From ESTABLISHED, the side that calls close() goes to FIN_WAIT_1, and the side that receives a FIN goes to CLOSE_WAIT. From FIN_WAIT_1, an ACK leads to FIN_WAIT_2, a FIN leads to CLOSING, and an ACK and FIN in one segment lead to TIME_WAIT. FIN_WAIT_2 goes to TIME_WAIT on a FIN, and CLOSING goes to TIME_WAIT on an ACK. TIME_WAIT goes to CLOSED after the 2MSL timeout. CLOSE_WAIT goes to LAST_ACK when the application calls close(), and LAST_ACK goes to CLOSED on an ACK. + ESTABLISHED --> FIN_WAIT_1: close() + ESTABLISHED --> CLOSE_WAIT: FIN + FIN_WAIT_1 --> FIN_WAIT_2: ACK + FIN_WAIT_1 --> CLOSING: FIN + FIN_WAIT_1 --> TIME_WAIT: ACK and FIN + FIN_WAIT_2 --> TIME_WAIT: FIN + CLOSING --> TIME_WAIT: ACK + TIME_WAIT --> CLOSED: 2MSL timeout + CLOSE_WAIT --> LAST_ACK: close() + LAST_ACK --> CLOSED: ACK ---- == TIME_WAIT diff --git a/doc/modules/ROOT/partials/diagram-style.adoc b/doc/modules/ROOT/partials/diagram-style.adoc new file mode 100644 index 000000000..9cecde122 --- /dev/null +++ b/doc/modules/ROOT/partials/diagram-style.adoc @@ -0,0 +1,156 @@ +// +// Copyright (c) 2026 Michael Vandeberg +// +// Distributed under the Boost Software License, Version 1.0. (See accompanying +// file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) +// +// Official repository: https://github.com/cppalliance/corosio +// + +// Page styles for mermaid diagrams, adapted from Capy's partial of the same +// name (cppalliance/capy doc/modules/ROOT/partials/diagram-style.adoc). +// Include this once on every page that has a diagram. +// +// A +++++ diff --git a/doc/package-lock.json b/doc/package-lock.json index 79bf0851b..e749bdfe1 100644 --- a/doc/package-lock.json +++ b/doc/package-lock.json @@ -12,7 +12,8 @@ "@cppalliance/antora-cpp-reference-extension": "^0.1.1", "@cppalliance/antora-cpp-tagfiles-extension": "^0.1.1", "@cppalliance/antora-downloads-extension": "^0.0.2", - "@cppalliance/asciidoctor-boost-links": "^0.0.2" + "@cppalliance/asciidoctor-boost-links": "^0.0.2", + "@sntke/antora-mermaid-extension": "^0.0.8" }, "devDependencies": { "@antora/cli": "3.1.14", @@ -551,6 +552,12 @@ "node": ">= 8" } }, + "node_modules/@sntke/antora-mermaid-extension": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/@sntke/antora-mermaid-extension/-/antora-mermaid-extension-0.0.8.tgz", + "integrity": "sha512-tTGNECQJcJaz2m/W2izgVNLO78LBq1OyNxIpTYU/IslkRjN62ghZfK25sZTfpvJQjKeNTOnx+SmFcCpq/Sn3FQ==", + "license": "MIT" + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", diff --git a/doc/package.json b/doc/package.json index bc341ae06..332832075 100644 --- a/doc/package.json +++ b/doc/package.json @@ -13,6 +13,7 @@ "@cppalliance/antora-cpp-reference-extension": "^0.1.1", "@cppalliance/antora-cpp-tagfiles-extension": "^0.1.1", "@cppalliance/antora-downloads-extension": "^0.0.2", - "@cppalliance/asciidoctor-boost-links": "^0.0.2" + "@cppalliance/asciidoctor-boost-links": "^0.0.2", + "@sntke/antora-mermaid-extension": "^0.0.8" } }