diff --git a/doc/lint/README.md b/doc/lint/README.md index 37431f293..be512692a 100644 --- a/doc/lint/README.md +++ b/doc/lint/README.md @@ -152,7 +152,7 @@ the gated slice contains 0 `advisory-C2` fingerprints. ### Run `selftest.mjs` after touching `sentence-length.mjs` or `doc-lint.mjs` ``` -node lint/selftest.mjs # exit 0 = 35 assertions hold; exit 1 names what broke +node lint/selftest.mjs # exit 0 = 40 assertions hold; exit 1 names what broke ``` It exercises the properties whose failure is **silent**, because nothing in the real corpus @@ -178,7 +178,8 @@ that `role=output`/`role=figure` clears only a *bare* listing and never a `[sour mutation that ORs the clearing-role and non-code-role checks together, ignoring which kind of block it is, makes `role=output` a blanket exemption for real C++; this file catches it. It also asserts the SHAPE advisory sidecar (doc/STYLE_GUIDE.md B3) fires on a role=output/role=figure block whose -content looks like code and stays silent on a genuine one. +content looks like code and stays silent on a genuine one, and that a `[mermaid]` diagram block +is exempt from both B2 and SHAPE without clearing a `[source,*]` block stacked under it. ### `sentence_length` is gated, and its backlog is now grandfathered diff --git a/doc/lint/doc-lint.mjs b/doc/lint/doc-lint.mjs index 46f6bdbb4..1268a2005 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 @@ -189,6 +191,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 0010ebfad..df623857b 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. @@ -475,6 +500,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/modules/ROOT/pages/4.coroutines/4c.launching.adoc b/doc/modules/ROOT/pages/4.coroutines/4c.launching.adoc index 9e97774b1..7d8f5366a 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4c.launching.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4c.launching.adoc @@ -10,6 +10,8 @@ Capy tasks are lazy—they do not execute until something drives them. Two mecha When a task is awaited, the awaiting coroutine provides context: an executor for dispatching completion and a stop token for cancellation. Non-coroutine code must start the first task in a chain explicitly. +xref:index.adoc#how-a-task-runs[How a Task Runs] shows both mechanisms as a sequence diagram. + == run_async: Entry from Non-Coroutine Code cpp:run_async[] takes an executor, creates the necessary context, and starts the task executing. diff --git a/doc/modules/ROOT/pages/4.coroutines/4e.context-awaitable.adoc b/doc/modules/ROOT/pages/4.coroutines/4e.context-awaitable.adoc index 045105bf5..05f25545f 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4e.context-awaitable.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4e.context-awaitable.adoc @@ -133,6 +133,7 @@ The key points: 2. To resume the caller, wrap its handle in a cpp:continuation[] and pass that to the executor's `post` (or `dispatch`). These take a `continuation&`, not a raw `coroutine_handle`. Store the `continuation` in the awaitable so it keeps a stable address until the executor dequeues and resumes it. The executor links continuations intrusively, so a temporary would dangle. 3. Respect the stop token for cancellation +[#stop-callbacks-must-post-not-resume] === Stop Callbacks Must Post, Not Resume When implementing a stoppable awaitable, you may register a `std::stop_callback` to wake the coroutine when cancellation is requested. The callback fires synchronously on whatever thread calls `request_stop()`, which is typically *not* the executor's thread. @@ -142,12 +143,10 @@ When implementing a stoppable awaitable, you may register a `std::stop_callback` *Never resume a coroutine handle directly from a stop_callback.* Doing so executes the coroutine on the wrong thread, corrupting the thread-local frame allocator. This causes use-after-free on the next coroutine allocation—potentially in completely unrelated code. ==== -Post the resume through the executor instead of resuming inline: - -[source,cpp] ----- -include::example$snippets/4e_context_awaitable.cpp[tag=stoppable_awaitable] ----- +Post the resume through the executor instead of resuming inline. +The stop callback also races the completion of the operation, and only one of them may resume the coroutine. +Each must first win an atomic _claim_ on the operation. +xref:4.coroutines/4f.cancellation.adoc#stoppable-awaitables[Implementing Stoppable Awaitables] shows a complete I/O awaitable that handles both. The incorrect pattern—which compiles and appears to work but causes memory corruption—looks like this: @@ -156,9 +155,7 @@ The incorrect pattern—which compiles and appears to work but causes memory cor include::example$snippets/4e_context_awaitable.cpp[tag=wrong_stop_callback,indent=0] ---- -See xref:4.coroutines/4f.cancellation.adoc#stoppable-awaitables[Implementing Stoppable Awaitables] for a complete example. - -For a production implementation of this exact pattern, read the source of cpp:async_waker::wait_awaiter[]. It registers a stop callback that posts the resume through the executor, and arbitrates between wakeup and cancellation with a single atomic claim. +For a production implementation of the claim, read the source of cpp:async_waker::wait_awaiter[]. It registers a stop callback that posts the resume through the executor, and arbitrates between wakeup and cancellation with a single atomic claim. Unlike the example in xref:4.coroutines/4f.cancellation.adoc#stoppable-awaitables[Implementing Stoppable Awaitables], it does not make the winner wait for `await_suspend`. It requires a single-threaded executor or a strand instead, because each resumes one coroutine at a time. == Running a Custom ContextAwaitable diff --git a/doc/modules/ROOT/pages/4.coroutines/4f.cancellation.adoc b/doc/modules/ROOT/pages/4.coroutines/4f.cancellation.adoc index a3c708333..909f316c2 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4f.cancellation.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4f.cancellation.adoc @@ -1,6 +1,8 @@ = Stop Tokens and Cancellation :page-mode: explanation +include::partial$diagram-style.adoc[] + == The Problem * A user clicks "Cancel" on a download dialog @@ -197,6 +199,85 @@ When Capy's I/O is provided by Corosio, requesting stop cancels work in progress The mechanism depends on the backend. Completion-based backends (Windows IOCP, and io_uring when enabled on Linux) cancel the operation in the kernel. Readiness-based backends (Linux epoll, kqueue on the BSDs and macOS, and the portable select fallback) remove it from the reactor before its system call runs. Either way the operation is reported as cancelled instead of blocking until the I/O would have completed. +[#the-cancellation-sequence] +== The Cancellation Sequence + +The diagram follows a stop request from the caller to a suspended task. +The task awaits an I/O awaitable that observes the stop token. +xref:index.adoc#how-a-task-runs[How a Task Runs] shows the same task when it completes normally. + +[mermaid] +---- +sequenceDiagram + autonumber + accTitle: Cancelling a task with a stop token + accDescr: The caller starts a task with a stop token from a stop_source. The task awaits an I/O awaitable, which registers a stop callback on the token and suspends. The caller requests a stop, and the stop callback runs on the caller's thread. If the stop request claims the operation, the I/O awaitable posts the task to the executor, which resumes it with a canceled result. If the operation completed first, the stop callback does nothing. The task finishes, and the completion handler receives its result. + participant C as Caller + participant R as run_async + participant X as Executor + participant T as my_task + participant A as I/O awaitable + C->>R: run_async(ex, source.get_token())(my_task()) + R->>T: start, with the stop token in the environment + T->>A: co_await an I/O operation + A->>A: register a stop callback on the stop token + A-->>X: suspend, the thread is free + C->>A: source.request_stop() runs the stop callback + alt the stop request claims the operation + A->>X: post(my_task) + X->>T: resume, the result is cond::canceled + else the operation completed first + Note over A: the stop callback does nothing + end + T->>R: co_return + R->>R: call the completion handler +---- + +. The caller owns a `std::stop_source`. + It passes the token of the source to cpp:run_async[], which puts the token in the environment of the task. +. The task starts, as in xref:index.adoc#how-a-task-runs[How a Task Runs]. + Every task that it awaits gets the same stop token. +. The task awaits an I/O awaitable. +. The I/O awaitable registers a `std::stop_callback` on the stop token. +. The task suspends, and the thread goes back to the executor. +. The caller calls `request_stop()` on the source. + The stop callback runs at once, on the thread of the caller. +. The stop request and the completion of the operation race. + The I/O awaitable uses one atomic claim to pick the winner. + If the stop request wins, the I/O awaitable posts the task to the executor. + It does not resume the task on the thread of the caller. +. The executor resumes the task. + The result of the operation compares equal to cpp:cond::canceled[cond::canceled]. +. The task finishes. + It can return the error, throw, or handle the cancellation itself. +. cpp:run_async[] calls the completion handler with the result of the task. + +If the operation completed first, the stop callback does nothing. +The completion resumes the task with the result of the operation, as in +xref:index.adoc#how-a-task-runs[How a Task Runs]. + +In both cases the task resumes and returns through its own code. +Abandoning a chain is different: its frames are destroyed without resuming. +xref:4.coroutines/4j.lifetimes.adoc#_who_owns_a_suspended_coroutine[Who Owns a Suspended Coroutine] explains when that happens. + +[WARNING] +==== +The stop callback runs on the thread that calls `request_stop()`, at the same time as code on the executor. +Do not assume that all code in an I/O awaitable runs on the executor. + +* In the stop callback, touch the state of the I/O awaitable only through the atomic claim. +* Only the side that wins the claim posts the task. + The side that loses does not touch the I/O awaitable again. + If both sides post the task, the task resumes twice. +* After the operation can be claimed, the winner can resume the task on another thread before `await_suspend` returns. + The task can then finish and destroy the I/O awaitable and its environment while `await_suspend` still uses them. + The example in the next section makes the winner wait until `await_suspend` is done. +==== + +cpp:async_waker[] is a complete I/O awaitable that implements this sequence, including the atomic claim. +It does not make the winner wait for `await_suspend`, so it requires a single-threaded executor or a strand. +The next section shows the stop callback in detail. + [[stoppable-awaitables]] == Implementing Stoppable Awaitables @@ -204,25 +285,44 @@ The examples above show *polling* for cancellation with `token.stop_requested()` === The Dangerous Pattern -A `std::stop_callback` fires synchronously on whatever thread calls `request_stop()`. If the callback resumes the coroutine directly, the coroutine runs on the wrong thread: +A `std::stop_callback` fires synchronously on whatever thread calls `request_stop()`. +The callback must not call `resume()` on the coroutine handle, because every resumption goes through the executor. +xref:4.coroutines/4e.context-awaitable.adoc#stop-callbacks-must-post-not-resume[Stop Callbacks Must Post, Not Resume] explains why. + +The next obvious solution, posting the coroutine through the executor, is also wrong: [source,cpp] ---- -include::example$snippets/4f_cancellation.cpp[tag=inline_resume_wrong,indent=0] +include::example$snippets/4f_cancellation.cpp[tag=post_without_claim,indent=0] ---- -When an external thread calls `request_stop()`, `h.resume()` executes the coroutine on that thread. The coroutine machinery sets the thread-local frame allocator to the executor's allocator—poisoning the calling thread's TLS. When the executor's pool destructs, the TLS pointer becomes dangling. The next coroutine allocation on that thread dereferences freed memory. +The stop callback posts the coroutine without checking whether the read already completed. +The completion posts it without checking whether a stop request already did. +When the read completes at about the same time as a stop request, both post, and the coroutine resumes twice. +The second resumption runs a coroutine that has already moved on, or whose frame is already destroyed. + +This bug is a race. +Tests rarely trigger it, and under load it shows up as a crash or as memory corruption in unrelated code. -This bug is deterministic, not a race condition. It manifests as a heap-use-after-free in *unrelated* code—wherever the next coroutine frame happens to be allocated on the poisoned thread. +=== The Correct Pattern: Claim, Then Post -=== The Correct Pattern: Post Through the Executor +Posting the coroutine through the executor is necessary, but it is not enough. +The stop callback and the completion of the operation race, so each one must first win the claim: -Post the coroutine handle through the executor instead of resuming it inline. This ensures the coroutine runs on the correct thread: +* `read_op::claim` is the atomic claim. Only the winner writes the result. +* `arrivals_` makes the winner wait for `await_suspend`. + The winner and the end of `await_suspend` both arrive, and the second to arrive resumes the coroutine. + If `await_suspend` arrives second, it resumes the coroutine directly, because it already runs on the executor. + If the winner arrives second, it posts the coroutine through the executor. +.A stoppable I/O awaitable that claims, then posts +[%collapsible] +==== [source,cpp] ---- include::example$snippets/4f_cancellation.cpp[tag=stoppable_awaitable,indent=0] ---- +==== NOTE: Capy's built-in I/O awaitables (via Corosio) already use the post-back pattern internally. This guidance applies when writing your own custom awaitables. diff --git a/doc/modules/ROOT/pages/8.examples/8.intro.adoc b/doc/modules/ROOT/pages/8.examples/8.intro.adoc index 8102ca673..adcb1b267 100644 --- a/doc/modules/ROOT/pages/8.examples/8.intro.adoc +++ b/doc/modules/ROOT/pages/8.examples/8.intro.adoc @@ -1,5 +1,6 @@ // // Copyright (c) 2025 Vinnie Falco (vinnie.falco@gmail.com) +// 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) @@ -14,6 +15,9 @@ These examples demonstrate how the pieces you have learned--tasks, buffers, stre Every example is self-contained and compiles as a standalone program. +For a reading order for your role, start with xref:index.adoc#protocol-anatomy[Protocol Anatomy]. +It lists what to read next for application developers, framework authors, and I/O library authors. + == What This Section Covers * xref:8.examples/8a.hello-task.adoc[Hello Task] -- The minimal Capy program: a task that diff --git a/doc/modules/ROOT/pages/index.adoc b/doc/modules/ROOT/pages/index.adoc index c0d7854d5..7e310cfcc 100644 --- a/doc/modules/ROOT/pages/index.adoc +++ b/doc/modules/ROOT/pages/index.adoc @@ -1,9 +1,11 @@ = Capy :page-mode: explanation -Capy is a compiled C++20 library providing abstractions, vocabulary types, and idioms necessary -to write programs with event-driven control flows --- such as I/O -- in a way that makes them efficient and manageable: structured and hard to get wrong. +include::partial$diagram-style.adoc[] + +Capy is a compiled C++20 library providing abstractions, vocabulary types, and idioms needed +to write programs with event-driven control flows, such as I/O. +It makes those programs efficient and manageable: structured and hard to get wrong. Coroutines are used to represent the control flow. The program logic is described in a sequential way, familiar to programmers, @@ -13,12 +15,13 @@ I/O operations are represented via the concept of a _stream_ which consumes and You also get type-erased wrappers for streams, so that programs can compile quickly, without triggering excessive template instantiations. -It offers a number of task synchronization mechanisms, ranging from low-level, like async mutexes and events, -to high-level, like _strands_, `when_all`/`when_any` "joins", and the resume-on-the-same-executor guarantee. +It offers task synchronization mechanisms at several levels. +Low-level ones include async mutexes and events. +High-level ones include _strands_, cpp:when_all[]/cpp:when_any[] "joins", and the resume-on-the-same-executor guarantee. -Libraries that want to provide concrete I/O implementations -- like those based on `epoll` or `io_uring` -- -can plug into Capy's system via the `ContextAwaitable` protocol, which describes how tasks can be scheduled, -cancelled, and allocated in memory. +Libraries that want to provide concrete I/O implementations, like those based on `epoll` or `io_uring`, +can plug into Capy through the cpp:ContextAwaitable[] protocol. +The protocol describes how tasks can be scheduled, cancelled, and allocated in memory. You also get the testing tools that allow you to test your asynchronous flows in a deterministic way. @@ -27,7 +30,7 @@ You also get the testing tools that allow you to test your asynchronous flows in While Capy offers algorithms and interfaces for dealing with I/O, it does not itself provide any I/O backend (like a wrapper over `epoll` or `io_uring`). -For that, you will need to use Capy in tandem with a Capy-conformant library. +For that, you need to use Capy in tandem with a Capy-conformant library. This can be either your home-grown one or Corosio. @@ -44,10 +47,217 @@ include::example$programs/index_page_echo.cpp[tag=full] <.> The condition `cond::eof` indicates reaching the end of the stream. -== Next Steps +[#protocol-anatomy] +== Protocol Anatomy + +Capy serves three audiences: + +* *Application developers* write tasks that do the work of a program. + Some of them write protocol libraries, such as HTTP or WebSocket, on top of streams. +* *Framework authors* write task types and the functions that start them. +* *I/O library authors* write the I/O awaitables that connect Capy to an I/O backend, an event loop, or another async library. + +All three share one model of how a task runs. +This section shows that model, and then what each audience needs from it. + +[#how-a-task-runs] +=== How a Task Runs + +The diagram follows one task from start to finish. +The task, `my_task`, awaits a child task. +The child awaits an I/O operation, such as a socket read. + +[mermaid] +---- +sequenceDiagram + autonumber + accTitle: How a task runs + accDescr: A caller starts my_task through run_async. my_task awaits a child task, which awaits an I/O awaitable. The thread returns to the executor while the operation is pending. When the I/O backend completes the operation, the I/O awaitable posts the child to the executor, which resumes it. The child returns to my_task, and my_task's result goes to the completion handler. + participant C as Caller + participant R as run_async + participant X as Executor + participant P as my_task + participant K as child + participant A as I/O awaitable + participant B as I/O backend + C->>R: run_async(ex)(my_task()) + R->>X: dispatch(my_task) + X->>P: resume + P->>K: co_await child() + K->>A: co_await read_some(buf) + A->>B: start the operation + A-->>X: suspend, the thread is free + B->>A: the operation completes + A->>X: post(child) + X->>K: resume with the result + K->>P: co_return + P->>R: co_return + R->>R: call the completion handler +---- + +. The caller passes an executor and a task to cpp:run_async[]. + The task does not run yet, because every task starts suspended. +. cpp:run_async[] gives the task its environment, an cpp:await_context[]. + The environment holds the executor, a stop token, a frame allocator, and the owner of the chain. + The owner unwinds the chain if it is abandoned, as xref:4.coroutines/4j.lifetimes.adoc#_who_owns_a_suspended_coroutine[Who Owns a Suspended Coroutine] explains. + Then cpp:run_async[] calls cpp:Executor::dispatch[dispatch] on the executor. +. The executor resumes `my_task`. + If it is safe to resume the task at once, for example because the call is on the executor's own thread, the executor does so. + If it is not safe, the executor queues the task to resume later. +. `my_task` awaits `child()`. + The child gets the same environment. + Control transfers directly to the child, without the executor. + This direct transfer is called _symmetric transfer_. +. The child awaits an I/O awaitable. + The I/O awaitable keeps the executor from the environment. +. The I/O awaitable starts the operation in the I/O backend. +. The child suspends, and the thread goes back to the executor. + The executor can run other work while the operation is pending. +. Later, the I/O backend reports that the operation is complete. + This report can arrive on any thread. +. The I/O awaitable posts the child to the executor with cpp:Executor::post[post]. + It never resumes the child directly. +. The executor resumes the child. + The `co_await` expression gives the result of the operation to the child. +. The child finishes, and control transfers directly back to `my_task`. + `my_task` is on the same executor, because the child used the environment of `my_task`. +. `my_task` finishes, and control goes back to cpp:run_async[]. +. cpp:run_async[] calls the completion handler with the result of `my_task`. + +Steps 9 and 10 keep the +xref:4.coroutines/4d.executors.adoc#the-same-executor-invariant[same-executor invariant]: +a task always resumes on the executor it started with. +Cancellation follows the same path, with a stop request in place of the completion. +xref:4.coroutines/4f.cancellation.adoc#the-cancellation-sequence[The Cancellation Sequence] +shows it. + +The program below runs this sequence and prints one line for each arrow. +It exits with an error if the order differs from the diagram, so the test suite checks the diagram. + +.A program that runs the sequence +[%collapsible] +==== +[source,cpp] +---- +include::example$programs/index_page_trace.cpp[tag=full] +---- +==== + +=== Application Developers + +You write `my_task` and `child`. +The rest of the diagram is Capy, or an I/O library such as Corosio. + +* Code between two `co_await` expressions runs like ordinary sequential code. + Each `co_await` is a point where the task can suspend. +* A task resumes on the executor it started with. + State that only one task uses needs no lock. + State that tasks share on a thread pool needs a strand or an cpp:async_mutex[]. +* Start top-level work from `main` with cpp:run_async[]. + Inside a task, await other tasks directly. + To run a child task on a different executor, await it through cpp:run[]. +* Run tasks concurrently with cpp:when_all[] and cpp:when_any[]. +* To cancel work, pass a stop token to cpp:run_async[]. + Operations that stop early report cpp:cond::canceled[cond::canceled]. + To check for a stop request inside a task, read the token with `co_await` cpp:this_coro::stop_token[this_coro::stop_token]. +* An object that a task refers to must stay alive until the task finishes. + xref:4.coroutines/4j.lifetimes.adoc[Lifetime and Ownership] gives the rules for parameters, lambda captures, and buffers. +* Stop work and wait for it to finish before you destroy the execution context that it runs on. + Destroying the context abandons the chains that are still suspended in it. + Their frames are destroyed without resuming, so they deliver no result and call no completion handler. + +Read next: + +* xref:4.coroutines/4b.tasks.adoc[The task Type] +* xref:4.coroutines/4c.launching.adoc[Starting Coroutines] +* xref:4.coroutines/4g.composition.adoc[Concurrent Composition] +* xref:4.coroutines/4i.lambda-captures.adoc[Lambda Coroutine Captures] +* xref:4.coroutines/4j.lifetimes.adoc[Lifetime and Ownership] +* The xref:8.examples/8a.hello-task.adoc[Hello Task], xref:8.examples/8g.parallel-fetch.adoc[Parallel Fetch], and xref:8.examples/8f.timeout-cancellation.adoc[Timeout with Cancellation] examples + +==== Protocol Libraries + +If you write a protocol library, such as HTTP or WebSocket, your `child` implements the protocol on a stream. +Your caller supplies the stream, and an I/O library supplies the I/O awaitables. + +* Write the protocol against the cpp:ReadStream[] and cpp:WriteStream[] concepts. + To keep the protocol out of headers, write it against the type-erased cpp:any_stream[] instead. +* The executor and the stop token reach your task through `co_await`. + Your functions do not take them as parameters. +* Each stream operation returns a cpp:results[] that holds an error code and a byte count. + A read can return bytes and an error together. + The stream only reports what happened. + Your protocol decides whether the result is a success or a failure, for example whether a partial message is usable. +* Compare the error code with the conditions in cpp:cond[], for example cpp:cond::eof[cond::eof] at the end of the data. +* When a stop is requested, a pending operation fails with an error that compares equal to cpp:cond::canceled[cond::canceled]. + This error means that a caller asked for the stop, not that the protocol failed. + Do not retry the operation. + Return the error to your caller. +* Test the protocol with the mock streams in `capy::test`. + The mock streams run without an I/O backend and can inject errors. + +Read next: + +* xref:6.streams/6a.concepts.adoc[Stream Concepts] +* xref:6.streams/6b.wrappers.adoc[Type-Erased Wrappers] +* xref:7.testing/7.intro.adoc[Testing] +* xref:9.design/9a.CapyLayering.adoc[Layered Abstractions] +* The xref:8.examples/8d.mock-stream-testing.adoc[Mock Stream Testing] and xref:8.examples/8e.type-erased-echo.adoc[Type-Erased Echo] examples + +=== Framework Authors + +You write the task type, such as cpp:task[task], and the functions that start tasks, such as cpp:run_async[]. +In the diagram, you write the `run_async` participant and the transfers between tasks: steps 2, 4, and 11 to 13. + +* A task type is an cpp:ContextAwaitable[], so that other tasks can await it. + It is also an cpp:ContextRunnable[], so that functions such as cpp:run_async[] can start it. +* Derive the promise type from cpp:context_awaitable_promise_base[]. + The base allocates the coroutine frame with the current frame allocator and stores the environment. + It also answers `co_await this_coro::...` queries, and passes every other awaitable to the `transform_awaitable` of your promise. +* In `transform_awaitable`, pass the environment to each I/O awaitable through its two-argument `await_suspend`. +* An awaited child task gets the same environment, and it returns to its parent by symmetric transfer. + So the parent continues on the same executor. +* A function that starts tasks creates the environment: an executor, a stop token, a frame allocator, and an owner. + It must set the frame allocator before the task's frame is allocated. + xref:4.coroutines/4h.allocators.adoc#two-call-rationale[Frame Allocators] explains why cpp:run_async[] uses two calls for this. +* The function must also own the chain that it starts, by binding a cpp:chain_owner[], so that a holder can abandon the chain. + The alternative is to make sure that nothing awaits a coroutine that could be abandoned. + cpp:run_async[], cpp:run[] on another executor, and each branch of cpp:when_all[] and cpp:when_any[] own their chains already. +* To store an executor without its type, use cpp:executor_ref[]. + +Read next: + +* xref:4.coroutines/4b.tasks.adoc[The task Type] +* xref:4.coroutines/4e.context-awaitable.adoc[The ContextAwaitable Protocol] +* xref:4.coroutines/4h.allocators.adoc[Frame Allocators] +* xref:4.coroutines/4j.lifetimes.adoc#_who_owns_a_suspended_coroutine[Who Owns a Suspended Coroutine] +* xref:9.design/9l.RunApi.adoc[Run API] + +=== I/O Library Authors + +In the diagram, you write the I/O awaitable, and sometimes the I/O backend and the executor. + +* The I/O awaitable implements the cpp:ContextAwaitable[] protocol. + Its `await_suspend` receives the coroutine handle and the cpp:await_context[]. +* Keep the executor from the environment. + When the operation completes, resume the coroutine through that executor. + Wrap the handle in a cpp:continuation[] that lives in the awaitable, and pass it to cpp:Executor::post[post] or cpp:Executor::dispatch[dispatch]. + Copy the `owner` of the environment into cpp:continuation::owner[owner], so that the chain can be abandoned while the coroutine waits. + Never call `resume()` on the handle directly. +* Register a `std::stop_callback` on the stop token of the environment. + The callback runs on the thread that requests the stop. + If a stop was already requested, it runs at once, inside the constructor of the callback. + It can run at the same time as the completion of the operation. + Only one of them may resume the coroutine, so each one must first win an atomic claim. +* An executor implements the cpp:Executor[] concept. + Its `context()` returns the cpp:execution_context[] that created it. + An event loop that runs coroutines resumes them with cpp:safe_resume[], so that each coroutine keeps its own frame allocator. +* An execution context that is destroyed with work still queued passes each queued continuation to cpp:abandon[] instead of resuming it. +* To start Capy tasks from another event loop, use cpp:run_async[] with an executor for that loop. + +Read next: -* xref:quick-start.adoc[Quick Start] — Set up your first Capy project -* xref:2.cpp20-coroutines/2a.foundations.adoc[{cpp}20 Coroutines Tutorial] — Learn coroutines from the ground up -* xref:4.coroutines/4b.tasks.adoc[Coroutines in Capy] — Deep dive into cpp:task[task] and the ContextAwaitable protocol -* xref:5.buffers/5.intro.adoc[Buffer Sequences] — Contiguous buffers, composite buffers, system I/O, and the algorithms over them -* xref:6.streams/6a.concepts.adoc[Stream Concepts] — Understand the three stream concepts +* xref:4.coroutines/4e.context-awaitable.adoc[The ContextAwaitable Protocol] +* xref:4.coroutines/4f.cancellation.adoc#stoppable-awaitables[Implementing Stoppable Awaitables] +* xref:4.coroutines/4d.executors.adoc[Executors and Execution Contexts] +* The xref:8.examples/8n.custom-executor.adoc[Custom Executor], xref:8.examples/8q.gui-integration.adoc[GUI Integration], xref:8.examples/8o.sender-bridge.adoc[Bridging a P2300 Sender], and xref:8.examples/8p.asio-use-capy.adoc[Calling Asio from a Capy Coroutine] examples diff --git a/doc/modules/ROOT/partials/diagram-style.adoc b/doc/modules/ROOT/partials/diagram-style.adoc new file mode 100644 index 000000000..93a22a143 --- /dev/null +++ b/doc/modules/ROOT/partials/diagram-style.adoc @@ -0,0 +1,127 @@ +// +// 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/capy +// + +// Page styles for mermaid sequence diagrams and collapsible blocks. +// Include this once on every page that has either. +// +// A +++++ diff --git a/test/doc/programs/index_page_trace.cpp b/test/doc/programs/index_page_trace.cpp new file mode 100644 index 000000000..c2c86d649 --- /dev/null +++ b/test/doc/programs/index_page_trace.cpp @@ -0,0 +1,244 @@ +// +// 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/capy +// + +// Full program shown in pages/index.adoc, section Protocol Anatomy. + +/* + Runs the sequence drawn in the "How a Task Runs" diagram and records one + trace entry per diagram arrow: the executor logs every dispatch, post and + resume, the I/O awaitable logs its protocol steps, and the tasks log + around each co_await. main() exits non-zero if the recorded order differs + from the diagram, so a library change that alters the sequence (an extra + executor hop, an inline resume) fails this test instead of silently + falsifying the page. + + Everything runs on the main thread. The executor never resumes inline: + dispatch() and post() both queue, and main() drains the queue. The fake + I/O backend completes an operation only when the queue is empty, which is + the "thread is free" state the diagram shows. +*/ + +// tag::full[] +#include +#include +#include +#include +#include +#include +#include + +namespace capy = boost::capy; + +std::vector trace; + +void step(std::string what) +{ + std::cout << trace.size() + 1 << ". " << what << "\n"; + trace.push_back(std::move(what)); +} + +// A fake I/O backend: pending operations complete when main() polls. +std::queue> backend; + +class trace_loop : public capy::execution_context +{ + std::queue queue_; + +public: + class executor_type; + + trace_loop() + : execution_context(this) + { + } + + // Nothing will run what is still queued: give it up, so its chain is + // unwound. Destroying a service, or unwinding a chain, can queue more + // work, so repeat until a drain finds nothing. + ~trace_loop() + { + auto drain = [this] + { + bool any = false; + while (!queue_.empty()) + { + auto c = queue_.front(); + queue_.pop(); + any = true; + capy::abandon(c); + } + return any; + }; + shutdown(); + drain(); + do + { + destroy(); + } + while (drain()); + } + + // A copy keeps the owner, which abandon needs. + void enqueue(capy::continuation const& c) + { + queue_.push(c); + } + + // Run queued coroutines. Poll the backend only when the queue is empty. + void run() + { + while (!queue_.empty() || !backend.empty()) + { + if (queue_.empty()) + { + auto complete = std::move(backend.front()); + backend.pop(); + complete(); + continue; + } + auto c = queue_.front(); + queue_.pop(); + step("executor: resume"); + capy::safe_resume(c.h); + } + } + + executor_type get_executor() noexcept; +}; + +class trace_loop::executor_type +{ + trace_loop* loop_ = nullptr; + +public: + executor_type() = default; + + explicit executor_type(trace_loop& loop) noexcept + : loop_(&loop) + { + } + + capy::execution_context& context() const noexcept + { + return *loop_; + } + + void on_work_started() const noexcept {} + void on_work_finished() const noexcept {} + + std::coroutine_handle<> dispatch(capy::continuation& c) const + { + step("executor: dispatch"); + loop_->enqueue(c); + return std::noop_coroutine(); + } + + void post(capy::continuation& c) const + { + step("executor: post"); + loop_->enqueue(c); + } + + bool operator==(executor_type const& other) const noexcept + { + return loop_ == other.loop_; + } +}; + +trace_loop::executor_type +trace_loop::get_executor() noexcept +{ + return executor_type{*this}; +} + +// An I/O awaitable for a read that the fake backend completes with 5 bytes. +struct read_awaitable +{ + capy::continuation cont_; + capy::executor_ref ex_; + std::size_t bytes_ = 0; + + bool await_ready() const noexcept + { + return false; + } + + std::coroutine_handle<> await_suspend( + std::coroutine_handle<> h, capy::await_context const* env) + { + cont_.h = h; + cont_.owner = env->owner; + ex_ = env->executor; + step("I/O awaitable: start the operation"); + backend.push([this] { + step("I/O backend: the operation completes"); + bytes_ = 5; + ex_.post(cont_); // never cont_.h.resume() + }); + step("I/O awaitable: suspend"); + return std::noop_coroutine(); + } + + capy::results await_resume() const noexcept + { + return {std::error_code(), bytes_}; + } +}; + +capy::task child() +{ + step("child: co_await read_some"); + auto [ec, n] = co_await read_awaitable{}; + step("child: co_return"); + co_return ec ? 0 : n; +} + +capy::task my_task() +{ + step("my_task: co_await child()"); + std::size_t n = co_await child(); + step("my_task: co_return"); + co_return n; +} + +int main() +{ + trace_loop loop; + + step("caller: run_async(ex)(my_task())"); + capy::run_async(loop.get_executor(), [](std::size_t n) { + step("run_async: call the completion handler with " + + std::to_string(n)); + })(my_task()); + + loop.run(); + + // One entry per arrow in the diagram, in the same order. + std::vector const diagram = { + "caller: run_async(ex)(my_task())", // 1 + "executor: dispatch", // 2 + "executor: resume", // 3 + "my_task: co_await child()", // 4 + "child: co_await read_some", // 5 + "I/O awaitable: start the operation", // 6 + "I/O awaitable: suspend", // 7 + "I/O backend: the operation completes", // 8 + "executor: post", // 9 + "executor: resume", // 10 + "child: co_return", // 11 + "my_task: co_return", // 12 + "run_async: call the completion handler with 5", // 13 + }; + if (trace != diagram) + { + std::cout << "trace does not match the diagram\n"; + return 1; + } +} +// end::full[] diff --git a/test/doc/snippets/4e_context_awaitable.cpp b/test/doc/snippets/4e_context_awaitable.cpp index 07c1fe6c7..eff7ad0b5 100644 --- a/test/doc/snippets/4e_context_awaitable.cpp +++ b/test/doc/snippets/4e_context_awaitable.cpp @@ -113,7 +113,6 @@ using result_type = int; // Stand-ins for the async machinery the page leaves unspecified. void start_operation() {} -void start_async_operation() {} void store_result() {} // tag::my_awaitable[] @@ -196,34 +195,6 @@ capy::task add_via_awaitable() } // end::runnable_awaitable[] -// tag::stoppable_awaitable[] -struct stoppable_awaitable -{ - mutable capy::continuation cont_; - std::optional>> stop_cb_; - - bool await_ready() { return false; } - - std::coroutine_handle<> await_suspend( - std::coroutine_handle<> h, capy::await_context const* env) - { - if (env->stop_token.stop_requested()) - return h; // Already cancelled, resume immediately - - // Post through executor when stop is requested - cont_.h = h; - auto ex = env->executor; - stop_cb_.emplace(env->stop_token, - [this, ex]() mutable noexcept { ex.post(cont_); }); - - start_async_operation(); - return std::noop_coroutine(); - } - - void await_resume() { /* ... */ } -}; -// end::stoppable_awaitable[] - // Compiles on purpose: the page shows this to warn that the broken // pattern is not caught by the compiler. struct wrong_stop_callback_demo diff --git a/test/doc/snippets/4f_cancellation.cpp b/test/doc/snippets/4f_cancellation.cpp index 986f637f8..477437bca 100644 --- a/test/doc/snippets/4f_cancellation.cpp +++ b/test/doc/snippets/4f_cancellation.cpp @@ -12,6 +12,13 @@ #include "../doc_warnings.hpp" +// GCC false positive at -O2/-O3: read_op::awaiter lives in a coroutine +// frame, and GCC cannot prove that std::optional +// is only destroyed after emplace() has initialized it. +#if defined(__GNUC__) && !defined(__clang__) +#pragma GCC diagnostic ignored "-Wmaybe-uninitialized" +#endif + #include #include #include @@ -27,15 +34,15 @@ #include #include -#include +#include #include -#include #include // tag::include_stop_token[] #include // end::include_stop_token[] #include #include +#include #include #include @@ -170,58 +177,200 @@ capy::task fetch_with_cancel() } // end::canceled_convention[] -// tag::inline_resume_wrong[] -// WRONG — causes use-after-free -std::optional>> stop_cb; +// Compile-only bug demo: running it would resume a coroutine twice, so +// nothing awaits it. start_read stands in for the I/O backend. +struct racy_read; +void start_read(racy_read*) {} -std::coroutine_handle<> await_suspend( - std::coroutine_handle<> h, capy::await_context const* env) +// tag::post_without_claim[] +// WRONG: the completion and the stop callback can both resume the +// coroutine. +struct racy_read { - stop_cb.emplace(env->stop_token, h); // Resumes inline! - return std::noop_coroutine(); -} -// end::inline_resume_wrong[] + struct cancel_fn + { + racy_read* self; + + void operator()() const noexcept + { + self->canceled_ = true; + self->ex_.post(self->cont_); // even if the read completed + } + }; -// Compile-only bug demo: firing the callback would resume the -// coroutine on the stopping thread, so nothing ever calls it. -[[maybe_unused]] std::coroutine_handle<> (* const wrong_pattern)( - std::coroutine_handle<>, capy::await_context const*) = &await_suspend; + capy::continuation cont_; + capy::executor_ref ex_; + bool canceled_ = false; + std::optional> stop_cb_; -void start_async_operation(std::coroutine_handle<>, capy::await_context const*) {} + bool await_ready() const noexcept { return false; } + + std::coroutine_handle<> await_suspend( + std::coroutine_handle<> h, capy::await_context const* env) + { + cont_.h = h; + cont_.owner = env->owner; + ex_ = env->executor; + stop_cb_.emplace(env->stop_token, cancel_fn{this}); + start_read(this); // the backend calls on_complete() later + return std::noop_coroutine(); + } + + // Called by the I/O backend, on any thread, when the read finishes. + void on_complete() noexcept + { + ex_.post(cont_); // even if the stop callback posted already + } + + void await_resume() noexcept { stop_cb_.reset(); } +}; +// end::post_without_claim[] // tag::stoppable_awaitable[] -struct my_stoppable_awaitable +// An I/O object with one pending read. The I/O backend calls complete() +// from any thread when the read finishes. +class read_op +{ +public: + struct awaiter; + + awaiter wait() noexcept; + + // Called by the I/O backend. Ignored if no wait is armed, or if a + // stop request already claimed the wait. + void complete(std::size_t n) noexcept; + + bool waiting() const noexcept + { + return armed_.load(std::memory_order_acquire); + } + +private: + // The claim: whoever moves armed_ from true to false owns the + // result. The loser does not touch the waiter. + bool claim() noexcept + { + bool expected = true; + return armed_.compare_exchange_strong( + expected, false, std::memory_order_acq_rel); + } + + std::atomic armed_{false}; + awaiter* waiter_ = nullptr; +}; + +struct read_op::awaiter { - using stop_cb_t = std::stop_callback>; + // Runs on the thread that calls request_stop(). + struct cancel_fn + { + awaiter* self; - mutable capy::continuation cont_; - std::unique_ptr stop_cb_; - // ... other members for the async operation ... + void operator()() const noexcept + { + if (!self->op_->claim()) + return; // complete() won, or nothing is armed yet + self->canceled_ = true; + self->arrive(); + } + }; + + read_op* op_; + capy::continuation cont_; + capy::executor_ref ex_; + std::size_t bytes_ = 0; + bool canceled_ = false; + // The winner and the end of await_suspend both arrive here. The + // second to arrive resumes the coroutine, so it cannot resume while + // await_suspend is still running. + std::atomic arrivals_{0}; + std::optional> stop_cb_; + + explicit awaiter(read_op* op) noexcept : op_(op) {} + + // co_await can move the awaiter, but only before await_suspend, + // when there is no stop callback yet. + awaiter(awaiter&& other) noexcept : op_(other.op_) {} + + // Called by the winner. If await_suspend has returned, post the + // coroutine; otherwise await_suspend resumes it. + void arrive() noexcept + { + if (arrivals_.fetch_add(1, std::memory_order_acq_rel) == 1) + ex_.post(cont_); // last action: *this can be destroyed now + } - bool await_ready() { return false; } + bool await_ready() const noexcept { return false; } std::coroutine_handle<> await_suspend( std::coroutine_handle<> h, capy::await_context const* env) { if (env->stop_token.stop_requested()) - return h; // Already cancelled + { + canceled_ = true; + return h; + } + // 1. Write everything the winner reads, before anyone can win. + // The owner lets a holder abandon the chain while it waits. cont_.h = h; - auto ex = env->executor; - stop_cb_ = std::make_unique(env->stop_token, - [this, ex]() mutable noexcept { ex.post(cont_); }); + cont_.owner = env->owner; + ex_ = env->executor; + op_->waiter_ = this; + + // 2. If a stop is requested now, the callback runs here and + // loses, because nothing is armed yet. Step 4 catches it. + stop_cb_.emplace(env->stop_token, cancel_fn{this}); - start_async_operation(h, env); + // 3. Arm. complete() or the stop callback can win from here on. + op_->armed_.store(true, std::memory_order_release); + + // 4. Claim a stop request that arrived during step 2. + if (env->stop_token.stop_requested() && op_->claim()) + { + canceled_ = true; + return h; + } + + // 5. Arrive. If the winner came first, resume now, on this + // thread. Otherwise the winner posts the coroutine later. + if (arrivals_.fetch_add(1, std::memory_order_acq_rel) == 1) + return h; return std::noop_coroutine(); } - void await_resume() { /* check result or throw */ } + capy::results await_resume() noexcept + { + // Waits for a stop callback that is still running to return. + stop_cb_.reset(); + if (canceled_) + return {capy::make_error_code(capy::error::canceled), 0}; + return {std::error_code(), bytes_}; + } }; + +inline read_op::awaiter read_op::wait() noexcept +{ + return awaiter{this}; +} + +inline void read_op::complete(std::size_t n) noexcept +{ + if (!claim()) + return; + waiter_->bytes_ = n; + waiter_->arrive(); +} // end::stoppable_awaitable[] -capy::task<> use_stoppable() +capy::task<> read_once( + read_op& op, std::atomic& resumed, std::atomic& canceled) { - co_await my_stoppable_awaitable{}; + auto [ec, n] = co_await op.wait(); + if (ec == capy::cond::canceled) + canceled.fetch_add(1); + else if (!ec && n == 5) + resumed.fetch_add(1); } // tag::racing_deadline[] @@ -453,15 +602,48 @@ struct cancellation_test void testStoppableAwaitable() { - // A pre-signaled token exercises the already-cancelled fast - // path; the armed path would wait on an operation that this - // sketch never starts. - capy::thread_pool pool(1); - std::stop_source source; - source.request_stop(); - capy::run_async( - pool.get_executor(), source.get_token())(use_stoppable()); + // Declared before the pool, so they outlive its work. + std::atomic resumed{0}; + std::atomic canceled{0}; + read_op stopped_op; + std::stop_source stopped; + int const rounds = 500; + + capy::thread_pool pool(2); + + // Stop requested before the wait: the fast path. + stopped.request_stop(); + capy::run_async(pool.get_executor(), stopped.get_token())( + read_once(stopped_op, resumed, canceled)); + + // complete() racing request_stop(). Exactly one of them wins + // each round, so every round resumes the task exactly once. + for (int i = 0; i < rounds; ++i) + { + read_op op; + std::stop_source source; + std::atomic done{false}; + // The backend completes the read the moment the wait is + // armed, which can be before await_suspend returns. + std::thread backend([&op, &done] { + while (!op.waiting() && !done.load()) + ; + op.complete(5); // a no-op if the stop request won + }); + capy::run_async(pool.get_executor(), source.get_token(), + [&done] { done.store(true); })( + read_once(op, resumed, canceled)); + while (!op.waiting() && !done.load()) + std::this_thread::yield(); + source.request_stop(); + backend.join(); + while (!done.load()) + std::this_thread::yield(); + } + pool.join(); + BOOST_TEST_EQ(resumed.load() + canceled.load(), rounds + 1); + BOOST_TEST(canceled.load() >= 1); } void