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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions doc/lint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
11 changes: 11 additions & 0 deletions doc/lint/doc-lint.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down
37 changes: 37 additions & 0 deletions doc/lint/selftest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <code> -- the attribute
Expand Down
2 changes: 2 additions & 0 deletions doc/modules/ROOT/pages/4.coroutines/4c.launching.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 6 additions & 9 deletions doc/modules/ROOT/pages/4.coroutines/4e.context-awaitable.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:

Expand All @@ -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

Expand Down
112 changes: 106 additions & 6 deletions doc/modules/ROOT/pages/4.coroutines/4f.cancellation.adoc
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -157,7 +159,7 @@

`std::coroutine_handle::destroy()` is the {cpp}20 primitive that frees a coroutine frame. It is not a cancellation mechanism. It has the same flaw as forceful thread interruption. The coroutine is torn down with no opportunity to complete pending I/O, release locks, or run RAII destructors in the expected order.

Capy exposes cpp:task::handle[handle]`()` and cpp:quitter::handle[handle]`()` so that Capy's own launchers (cpp:run_async[], cpp:run[]) and custom integrations can dispatch coroutines through executors. Calling `destroy()` on such a handle while the coroutine is being awaited by a parent produces undefined behavior: the parent still holds the frame and will destroy it again. A holder that must give up a suspended coroutine for good, such as an execution context being torn down, passes its continuation to cpp:abandon[] instead, which unwinds the chain from the top on its owner's executor.

Check warning on line 162 in doc/modules/ROOT/pages/4.coroutines/4f.cancellation.adoc

View workflow job for this annotation

GitHub Actions / Antora Docs

[vale_adoc Capy.SimpleTense] Avoid needless future/perfect tense — prefer present simple (style guide C4): 'will '.

The rule:

Expand Down Expand Up @@ -197,32 +199,130 @@

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

The examples above show *polling* for cancellation with `token.stop_requested()`. For awaitables that suspend indefinitely—waiting for I/O, a lock, or an external event—you need a `std::stop_callback` to wake the coroutine when cancellation arrives.

=== 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.

Expand Down
4 changes: 4 additions & 0 deletions doc/modules/ROOT/pages/8.examples/8.intro.adoc
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
Expand Down
Loading
Loading