diff --git a/bench/CMakeLists.txt b/bench/CMakeLists.txt index 01b6b2d4d..2cf780631 100644 --- a/bench/CMakeLists.txt +++ b/bench/CMakeLists.txt @@ -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) @@ -8,7 +9,7 @@ # source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} PREFIX "" FILES - CMakeLists.txt bench.cpp allocation.cpp + CMakeLists.txt bench.cpp allocation.cpp quitter.cpp beman/main.cpp beman/allocation_tracker.hpp beman/awaitable_sender.hpp @@ -45,6 +46,10 @@ target_include_directories(boost_capy_bench PRIVATE .) add_executable(boost_capy_bench_allocation allocation.cpp) target_link_libraries(boost_capy_bench_allocation PRIVATE Boost::capy) +add_executable(boost_capy_bench_quitter quitter.cpp) +target_link_libraries(boost_capy_bench_quitter PRIVATE Boost::capy) +target_include_directories(boost_capy_bench_quitter PRIVATE .) + if(BOOST_CAPY_BUILD_P2300_EXAMPLES) include(FetchContent) FetchContent_Declare( diff --git a/bench/quitter.cpp b/bench/quitter.cpp new file mode 100644 index 000000000..ac316c84d --- /dev/null +++ b/bench/quitter.cpp @@ -0,0 +1,477 @@ +// +// 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 +// + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + + +/* Benchmarks of quitter against task. + + Four families, each run for task and for quitter: + + loop One coroutine doing N `co_await yield_awaitable{}`. Every await + is a real executor round trip, so this is the worst case for a + per-suspension overhead. + entry A task awaiting K short children, each doing one yield. This + measures what it costs to start and finish a child. + ready One coroutine doing N `co_await`s that complete without + suspending and return a results, as an + uncontended async_mutex lock does. A quitter takes such a + result early, to check it for canceled while a stop is + pending, then moves it to the body. + stop A chain of depth D whose leaf waits on an async_waker that is + never woken. Only the time from request_stop() until run() + returns is measured; building and parking the chain is not. + + queue_context is a single-threaded FIFO: post() links the continuation + through its `reserved` slot and run() drains the queue. dispatch() + returns the handle, so a completion on the right executor resumes + inline, as in a real I/O context. + + The first line says whether BOOST_CAPY_SYMMETRIC_TRANSFER_WORKAROUND + is on, since it changes how every co_await suspends. Each result line + prints ` ns/op`, where one op is one whole loop or ready + run, one whole entry run, or one stop. */ + +using namespace boost::capy; + +//----------------------------------------------- +// +// Queue Context +// +//----------------------------------------------- + +class queue_context : public execution_context +{ + continuation* head_ = nullptr; + continuation* tail_ = nullptr; + +public: + class executor_type; + + queue_context() + : execution_context(this) + { + } + + ~queue_context() + { + // Anything still queued will never run. + while(auto* c = pop()) + boost::capy::abandon(*c); + shutdown(); + destroy(); + } + + executor_type + get_executor() noexcept; + + void + enqueue(continuation& c) noexcept + { + c.reserved = nullptr; + if(tail_) + tail_->reserved = &c; + else + head_ = &c; + tail_ = &c; + } + + std::size_t + run_one() + { + auto* c = pop(); + if(!c) + return 0; + c->h.resume(); + return 1; + } + + void + run() + { + while(run_one()) + { + } + } + +private: + continuation* + pop() noexcept + { + auto* c = head_; + if(!c) + return nullptr; + head_ = static_cast(c->reserved); + if(!head_) + tail_ = nullptr; + return c; + } +}; + +class queue_context::executor_type +{ + queue_context* ctx_; + +public: + explicit executor_type(queue_context& ctx) noexcept + : ctx_(&ctx) + { + } + + queue_context& context() const noexcept + { + return *ctx_; + } + + bool operator==(executor_type const& other) const noexcept + { + return ctx_ == other.ctx_; + } + + void on_work_started() const noexcept {} + void on_work_finished() const noexcept {} + + std::coroutine_handle<> dispatch(continuation& c) const + { + return c.h; + } + + void post(continuation& c) const + { + ctx_->enqueue(c); + } +}; + +inline queue_context::executor_type +queue_context::get_executor() noexcept +{ + return executor_type(*this); +} + +static_assert(Executor); +static_assert(ExecutionContext); + +//----------------------------------------------- +// +// Yield Awaitable +// +// Always suspends and posts the awaiting +// coroutine to its executor: exactly one real +// executor round trip. +// +//----------------------------------------------- + +struct yield_awaitable +{ + continuation c_; + + bool await_ready() const noexcept + { + return false; + } + + std::coroutine_handle<> await_suspend( + std::coroutine_handle<> h, + await_context const* env) + { + c_.h = h; + c_.owner = env->owner; + env->executor.post(c_); + return std::noop_coroutine(); + } + + void await_resume() const noexcept + { + } +}; + +static_assert(ContextAwaitable); + +//----------------------------------------------- +// +// Ready Awaitable +// +// Completes without suspending, with a value in +// a results<>. +// +//----------------------------------------------- + +struct ready_awaitable +{ + std::size_t n_; + + bool await_ready() const noexcept + { + return true; + } + + std::coroutine_handle<> await_suspend( + std::coroutine_handle<> h, + await_context const*) noexcept + { + return h; + } + + results await_resume() const noexcept + { + return {std::error_code(), n_}; + } +}; + +static_assert(ContextAwaitable); + +//----------------------------------------------- +// +// Harness (as in bench.cpp) +// +//----------------------------------------------- + +static void +print_result(char const* name, double per_op) +{ + std::cout << std::left << std::setw(40) << name + << std::right << std::setw(10) << std::fixed << std::setprecision(1) + << per_op << " ns/op\n"; +} + +template +void run_benchmark( + char const* name, + std::size_t iterations, + F&& f) +{ + // Warmup + for(std::size_t i = 0; i < 1000; ++i) + f(); + + auto start = std::chrono::high_resolution_clock::now(); + + for(std::size_t i = 0; i < iterations; ++i) + f(); + + auto end = std::chrono::high_resolution_clock::now(); + auto duration = std::chrono::duration_cast(end - start); + print_result(name, duration.count() / static_cast(iterations)); +} + +// Like run_benchmark, but `f` returns the part of its run to count, so +// that setup is excluded. +template +void run_timed_benchmark( + char const* name, + std::size_t iterations, + F&& f) +{ + for(std::size_t i = 0; i < 1000; ++i) + f(); + + std::chrono::nanoseconds total{0}; + for(std::size_t i = 0; i < iterations; ++i) + total += f(); + print_result(name, total.count() / static_cast(iterations)); +} + +//----------------------------------------------- +// +// Coroutines under test +// +// Each body is a template on its own return +// type, so task and every quitter run the same +// code. +// +//----------------------------------------------- + +// Set when a loop body runs with a token that can be stopped, as a +// real quitter always does; bench_loop refuses to report otherwise. +bool loop_saw_stop_token = false; + +template +Coro loop_body(std::size_t n) +{ + auto st = co_await this_coro::stop_token; + loop_saw_stop_token = st.stop_possible(); + for(std::size_t i = 0; i < n; ++i) + co_await yield_awaitable{}; + co_return results<>{}; +} + +// Read after each run, so the loop is not optimized away. +std::size_t ready_sum = 0; + +template +Coro ready_body(std::size_t n) +{ + std::size_t sum = 0; + for(std::size_t i = 0; i < n; ++i) + { + auto r = co_await ready_awaitable{i}; + sum += std::get<1>(r); + } + ready_sum = sum; + co_return results<>{}; +} + +template +Coro entry_child() +{ + co_await yield_awaitable{}; + co_return results<>{}; +} + +template +task<> entry_parent(std::size_t k) +{ + for(std::size_t i = 0; i < k; ++i) + (void)co_await entry_child(); +} + +// The leaf waits on a waker nobody wakes; only a stop ends it. A task +// frame checks the error code and returns it, as cooperative code does. +template +Coro stop_chain(async_waker& w, int depth) +{ + if(depth == 1) + { + auto [ec] = co_await w.wait(); + co_return results<>{ec}; + } + auto [ec] = co_await stop_chain(w, depth - 1); + if(ec) + co_return results<>{ec}; + co_return results<>{}; +} + +template +task<> drive(Coro c) +{ + (void)co_await std::move(c); +} + +//----------------------------------------------- +// +// Families +// +//----------------------------------------------- + +constexpr std::size_t loop_n = 1000; +constexpr std::size_t entry_k = 1000; +constexpr int stop_depths[] = {1, 8, 64}; + +template +void bench_loop(queue_context& ctx, char const* variant) +{ + char name[64]; + std::snprintf(name, sizeof(name), "loop %s N=%zu", variant, loop_n); + auto ex = ctx.get_executor(); + std::stop_source ss; + loop_saw_stop_token = false; + run_benchmark(name, 20000, [&]{ + run_async(ex, ss.get_token())(drive(loop_body(loop_n))); + ctx.run(); + }); + if(!loop_saw_stop_token) + { + std::cout << "FAIL " << name << ": ran without a stop token\n"; + std::exit(1); + } +} + +template +void bench_ready(queue_context& ctx, char const* variant) +{ + char name[64]; + std::snprintf(name, sizeof(name), "ready %s N=%zu", variant, loop_n); + auto ex = ctx.get_executor(); + std::stop_source ss; + run_benchmark(name, 20000, [&]{ + run_async(ex, ss.get_token())(drive(ready_body(loop_n))); + ctx.run(); + }); + if(ready_sum != loop_n * (loop_n - 1) / 2) + { + std::cout << "FAIL " << name << ": wrong sum\n"; + std::exit(1); + } +} + +template +void bench_entry(queue_context& ctx, char const* variant) +{ + char name[64]; + std::snprintf(name, sizeof(name), "entry %s K=%zu", variant, entry_k); + auto ex = ctx.get_executor(); + run_benchmark(name, 5000, [&]{ + run_async(ex)(entry_parent(entry_k)); + ctx.run(); + }); +} + +template +void bench_stop(queue_context& ctx, char const* variant) +{ + auto ex = ctx.get_executor(); + for(int d : stop_depths) + { + char name[64]; + std::snprintf(name, sizeof(name), "stop %s D=%d", variant, d); + run_timed_benchmark(name, 10000, [&]{ + std::stop_source ss; + async_waker w; + run_async(ex, ss.get_token())(drive(stop_chain(w, d))); + ctx.run(); + auto start = std::chrono::steady_clock::now(); + ss.request_stop(); + ctx.run(); + return std::chrono::steady_clock::now() - start; + }); + } +} + +int main() +{ + queue_context ctx; + + std::cout << "BOOST_CAPY_SYMMETRIC_TRANSFER_WORKAROUND=" + << BOOST_CAPY_SYMMETRIC_TRANSFER_WORKAROUND << "\n"; + + for(int rep = 0; rep < 5; ++rep) + { + std::cout << "=== repetition " << rep + 1 << " ===\n"; + + bench_loop>>(ctx, "task"); + bench_loop>>(ctx, "quitter"); + + bench_ready>>(ctx, "task"); + bench_ready>>(ctx, "quitter"); + + bench_entry>>(ctx, "task"); + bench_entry>>(ctx, "quitter"); + + bench_stop>>(ctx, "task-ec"); + bench_stop>>(ctx, "quitter"); + } + return 0; +} diff --git a/doc/modules/ROOT/pages/4.coroutines/4f.cancellation.adoc b/doc/modules/ROOT/pages/4.coroutines/4f.cancellation.adoc index 22f65b69a..82f39c827 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4f.cancellation.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4f.cancellation.adoc @@ -159,7 +159,7 @@ include::example$snippets/4f_cancellation.cpp[tag=access_stop_token,indent=0] `std::coroutine_handle::destroy()` is the {cpp}20 primitive that frees a xref:glossary.adoc#coroutine-frame[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 xref:glossary.adoc#executor[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 xref:glossary.adoc#execution-context[execution context] being torn down, passes its xref:glossary.adoc#continuation[continuation] to cpp:abandon[] instead, which unwinds the chain from the top on its owner's executor. +Capy exposes cpp:task::handle[handle]`()` so that Capy's own launchers (cpp:run_async[], cpp:run[]) and custom integrations can dispatch tasks through xref:glossary.adoc#executor[executors]. A cpp:quitter[] also has a cpp:quitter::handle[handle]`()`, but code that runs a quitter for an awaiting coroutine calls the quitter's `await_suspend`, and Capy's launchers start it through a gate of their own. Resuming a quitter's handle with the awaiting coroutine's environment lets a stop tear down that coroutine's chain. 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 xref:glossary.adoc#execution-context[execution context] being torn down, passes its xref:glossary.adoc#continuation[continuation] to cpp:abandon[] instead, which unwinds the chain from the top on its owner's executor. The rule: diff --git a/doc/modules/ROOT/pages/4.coroutines/4j.lifetimes.adoc b/doc/modules/ROOT/pages/4.coroutines/4j.lifetimes.adoc index 249aebea4..eaddb03ff 100644 --- a/doc/modules/ROOT/pages/4.coroutines/4j.lifetimes.adoc +++ b/doc/modules/ROOT/pages/4.coroutines/4j.lifetimes.adoc @@ -1,5 +1,6 @@ // // Copyright (c) 2026 Steve Gerbino +// 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) @@ -135,8 +136,9 @@ lists these rules, and the rules for stop callbacks. A chain of coroutines awaiting one another is split into _segments_. A segment is the run of coroutines started together: the task cpp:run_async[] launches -and everything it awaits, a task cpp:run[] starts on another executor, or one -branch of cpp:when_all[] or cpp:when_any[]. The coroutine that started the +and everything it awaits, a task cpp:run[] starts on another executor, one +branch of cpp:when_all[] or cpp:when_any[], or a cpp:quitter[] awaited by a +coroutine that is not a quitter. The coroutine that started the segment is its _owner_. It is suspended above the segment until the segment's top task finishes. @@ -145,10 +147,18 @@ executor's queue, a xref:glossary.adoc#strand[strand], a mutex, a pending I/O op holder resumes it. A holder that will never resume it, because it is being torn down, passes the continuation to cpp:abandon[] instead. The segment's owner then destroys the segment from its top frame down, on its own executor, and -passes the unwind to the segment above. Nothing in an abandoned chain is -resumed: no code after a suspension point runs, no result is delivered, and +passes the unwind to the segment above. Nothing in a chain abandoned this way +is resumed: no code after a suspension point runs, no result is delivered, and cpp:run_async[] calls neither of its handlers. +A stopped cpp:quitter[] is abandoned too, but the unwind stops at the owner of +its segment. The quitter frames are destroyed without being resumed. The owner +then resumes the coroutine that awaited the quitter, which receives the +stopped value that cpp:quitter[] describes. When cpp:run_async[] or cpp:run[] +started the quitter, the launcher reports the stop in the same way: through +`co_await run(ex)(q)`, or to the handlers of cpp:run_async[] as cpp:quitter[] +describes. + What does run is destructors. Each frame's locals are destroyed in the usual order, so RAII still releases what the chain held: diff --git a/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc b/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc index 0b5adf4e3..87747e6b7 100644 --- a/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc +++ b/doc/modules/ROOT/pages/9.design/9l.RunApi.adoc @@ -9,7 +9,7 @@ Capy has two launcher functions: cpp:run_async[] (fire-and-forget from non-corou === `run_async` -- Fire-and-Forget -cpp:run_async[] starts any _ContextRunnable_ from non-coroutine code: `main()`, callback handlers, event loops. cpp:task[task] is the most common conforming type, but any user-defined type satisfying the concept works. The function does not return a value to the caller. Handlers receive the task's result or exception after completion, as data; they should not throw. An exception that no handler consumes (none was supplied, or a handler let one escape) calls `std::terminate`; it is never silently discarded. To catch an error instead, `co_await` the work inside a coroutine. +cpp:run_async[] starts any _ContextRunnable_ from non-coroutine code: `main()`, callback handlers, event loops. cpp:task[task] is the most common conforming type, but any user-defined type satisfying the concept works. The function does not return a value to the caller. Handlers receive the task's result or exception after completion, as data; they should not throw. An exception that no handler consumes (none was supplied, or a handler let one escape) calls `std::terminate`; it is never silently discarded. Only a cpp:quitter[]'s stop is discarded, because a stop is not an error. A stopped quitter of `void` or a plain `T` reports its stop to the error handler as a `std::system_error` whose code compares equal to `cond::canceled`. With no error handler, the stop is discarded, also when it escapes a task that awaited the quitter. To catch an error instead, `co_await` the work inside a coroutine. [source,cpp] ---- diff --git a/doc/quitter.md b/doc/quitter.md index 3a4130a4c..9636b3ab0 100644 --- a/doc/quitter.md +++ b/doc/quitter.md @@ -2,12 +2,17 @@ ## Summary -`quitter` is a task type that satisfies `ContextRunnable` and provides -transparent cancellation. When the stop token is triggered, the -coroutine body never sees the cancellation. The promise intercepts -it on resume and short-circuits to `final_suspend`. RAII cleanup -runs normally. The parent sees a "stopped" completion. The coroutine -author writes zero cancellation-handling code. +`quitter` is a task type that satisfies `ContextRunnable`. A stop +request ends it without resuming it. When the stop token fires, the +quitter's frames are destroyed from the top down, as an abandoned +chain's are (`capy::abandon`). Only destructors run. Nothing in the +body observes the stop, and nothing can intercept it, not even +`catch(...)`. The stop is reported, as a value, to the first coroutine +above that is not a quitter. The coroutine author writes no +cancellation-handling code. + +The header's docstrings are the reference. This note explains the +design. ## Motivation @@ -15,283 +20,183 @@ Ville Voutilainen (the emeritus chair of EWG in WG21) identified a gap in Capy's when an application needs to shut down (e.g. Qt "close all windows"), every in-flight coroutine should exit cleanly without the programmer writing `if (ec) co_return;` after every `co_await`. The sender model -achieves this via `set_stopped` - the coroutine never resumes. Capy's -`task` resumes with `operation_aborted` and requires explicit -handling. +achieves this via `set_stopped`: the coroutine never resumes. Capy's +`task` resumes with an error equal to `cond::canceled` and requires +explicit handling. `quitter` closes this gap. It is a policy-level task type: same -ContextAwaitable protocol, same await_context propagation, same frame allocator, -same symmetric transfer - but with automatic stop-on-resume behavior. +ContextAwaitable protocol, same await_context propagation, same frame +allocator, same symmetric transfer. But a stop request ends it instead +of resuming it. ## Behavior ### Normal operation (stop not requested) -Identical to `task`. The coroutine runs, co_awaits I/O operations, -returns a value or throws. No observable difference. +As `task`. The coroutine runs, awaits operations, and returns a +value or throws. ### When stop is requested -1. The stop token in `await_context` is triggered (from a parent, a scope, - or application shutdown). -2. The in-flight I/O operation observes the token and completes - (typically with `operation_aborted`). -3. The I/O awaitable resumes the coroutine via symmetric transfer. -4. **Before the coroutine body sees the result**, the promise's - `transform_awaiter::await_resume()` checks the stop token. -5. If `env_->stop_token.stop_requested()` is true, the awaiter - throws a sentinel exception (`detail::stop_requested_exception`). -6. `unhandled_exception()` catches it and sets a `stopped_` flag - instead of storing the exception. -7. `final_suspend` returns the continuation via symmetric transfer. -8. The parent's `await_resume()` observes the stopped flag. - -### Why throw internally? - -Throwing inside the coroutine is the only portable mechanism to -jump from an arbitrary suspension point to `final_suspend` while -running destructors for all in-scope locals. The exception never -escapes the coroutine - `unhandled_exception()` catches it. The -user never sees it. No `exception_ptr` is stored. The cost is one -throw + catch per cancellation, which happens once per coroutine -lifetime (not per operation). - -Alternative: a compiler-level `coroutine_handle<>::destroy()` from -outside would also run destructors, but the parent loses the -completion signal. The internal-throw approach preserves structured -completion. - -## Interface - -### Header +1. The stop token in `await_context` is triggered (from a parent, a + combinator, or application shutdown). +2. The in-flight operation observes the token and completes, typically + with `cond::canceled`. +3. The operation does not resume the quitter's frame. Every quitter + frame hands its operations a *gate* as the continuation, so the + operation resumes the gate. +4. The gate sees the stop and abandons the quitter's *segment*. Its + owner destroys the quitter frames, on the executor, from the top + down. No quitter frame is resumed. +5. The coroutine that awaited the quitter is resumed. Its `co_await` + yields the stopped value. + +A stop that is already requested when the quitter is awaited does not +start the body; the `co_await` yields the stopped value at once. +`run_async` and `run(ex)` start a quitter through its gate, so the +stop is checked when the body would start, not when the launcher is +called: a stop that arrives in between does not start the body either. + +### What a stopped quitter delivers + +| `T` | stopped value | +|-----|---------------| +| `results` | `make_error_code(error::canceled)`, each `Ts` value-initialized | +| `std::error_code` | `make_error_code(error::canceled)` | +| any other `T`, `void` included | no value; `std::system_error` with a `code()` equal to `cond::canceled` is thrown at the `co_await` | + +Every `Ts` of a `results` must be default-constructible. A +`quitter<>` or a plain `T` cannot be used without exceptions; use +`quitter>` there. The exception is thrown in the coroutine +that awaits the stopped quitter, never in the stopped quitter's own +frames. That coroutine can be a quitter's body: inside a quitter, +`co_await run(st)(q)` throws a stop of `q` there. + +`run_async` reports a stopped quitter to its handlers: for `results` +and `error_code` the value handler gets the stopped value, and for +`void` or a plain `T` the error handler gets the `std::system_error`. +With no error handler, no handler is called and the stop is discarded. +The same holds for a stop that escapes a task: a task that awaits a +stopped `quitter<>` or plain-`T` quitter and does not catch the stop +is unwound, and with no error handler the stop is discarded. Any other exception a task +throws, a `std::system_error` with `canceled` included, still +terminates. `run(ex)` and the other +`run` overloads +yield the stopped value to their caller. + +## Segments and the gate + +A quitter awaited by anything other than a quitter opens a *segment* +in its `await_suspend`. The segment is one small coroutine frame that +is both the segment's owner (a `chain_owner`, the `await_context::owner` +every frame in the segment sees) and the gate. A quitter awaited by a +quitter joins its parent's segment, so a stop ends both together. A +quitter started directly by `run_async` or `run(ex)` runs in the +launcher's segment and gets a gate of its own at launch. The launcher +resumes that gate to start the body, so the stop is checked first. + +A stop abandons only the segment, so it destroys quitter frames only. +A `task` below a quitter receives the stop request as usual and +returns through its own code before the quitter is destroyed. A +`task` above it receives the stopped value. + +A gate is resumed by the completion of the one operation a quitter +frame of its segment waits on, or, for the gate through which +`run_async` or `run(ex)` starts a quitter, by the launcher's dispatch. +An owner is resumed by the post that follows an abandon. A gate decides +only once it is resumed, when no operation holds a quitter frame's +continuation, so no frame is destroyed while an operation still holds +its continuation. + +## Synchronous completions + +The stop token is checked before each awaited operation starts, so a +pending stop starts no work. + +An operation that completes without going through the gate (its +`await_ready` returns `true`, or its `await_suspend` returns `false` +or the handle it was given) always has its `await_resume` called. Its +`await_ready` or `await_suspend` may already have acquired something, +such as `async_mutex`'s lock, that only `await_resume` hands over. The +body receives the result, and the stop takes effect at the next +suspension. + +The exception is a `results<>` holding `cond::canceled` while the +quitter's stop is pending. The operation saw the stop first and +acquired nothing, so the quitter is stopped at that `co_await`. +`async_mutex`, `async_event` and `async_waker` all answer a stop that +lands between the quitter's check and theirs this way. -``` - -``` - -### Synopsis - -```cpp -namespace boost::capy { - -template -struct [[nodiscard]] quitter -{ - struct promise_type; - - // ContextAwaitable - bool await_ready() const noexcept; - auto await_resume(); - std::coroutine_handle<> await_suspend( - std::coroutine_handle<> cont, - await_context const* env); - - // ContextRunnable - std::coroutine_handle handle() const noexcept; - void release() noexcept; - - ~quitter(); - quitter(quitter&&) noexcept; - quitter& operator=(quitter&&) noexcept; - quitter(quitter const&) = delete; - quitter& operator=(quitter const&) = delete; -}; - -} // namespace boost::capy -``` - -### `promise_type` - -```cpp -struct quitter::promise_type - : context_awaitable_promise_base - , detail::task_return_base -{ - // Completion state - enum class completion { running, value, exception, stopped }; - - completion state_ = completion::running; - union { std::exception_ptr ep_; }; - - promise_type() noexcept; - ~promise_type(); - - quitter get_return_object(); - auto initial_suspend() noexcept; // same as task - auto final_suspend() noexcept; // same as task - - void unhandled_exception(); - std::exception_ptr exception() const noexcept; - bool stopped() const noexcept; - - template - auto transform_awaitable(Awaitable&& a); -}; -``` - -## Implementation Details - -### `detail::stop_requested_exception` - -```cpp -namespace boost::capy::detail { - -struct stop_requested_exception {}; - -} // namespace boost::capy::detail -``` - -A lightweight sentinel type. Not derived from `std::exception`. -Never escapes the coroutine. Never stored in `exception_ptr`. - -### `promise_type::unhandled_exception()` - -```cpp -void unhandled_exception() -{ - try { - throw; - } - catch (detail::stop_requested_exception const&) { - state_ = completion::stopped; - } - catch (...) { - new (&ep_) std::exception_ptr(std::current_exception()); - state_ = completion::exception; - } -} -``` - -### `promise_type::exception()` and `stopped()` - -```cpp -std::exception_ptr exception() const noexcept -{ - if (state_ == completion::exception) - return ep_; - return {}; -} - -bool stopped() const noexcept -{ - return state_ == completion::stopped; -} -``` - -### `transform_awaiter` (the key difference from `task`) - -```cpp -template -struct transform_awaiter -{ - std::decay_t a_; - promise_type* p_; - - bool await_ready() noexcept - { - return a_.await_ready(); - } - - template - auto await_suspend(std::coroutine_handle h) noexcept - { - using R = decltype( - a_.await_suspend(h, p_->environment())); - if constexpr (std::is_same_v< - R, std::coroutine_handle<>>) - return detail::symmetric_transfer( - a_.await_suspend(h, p_->environment())); - else - return a_.await_suspend( - h, p_->environment()); - } - - decltype(auto) await_resume() - { - set_current_frame_allocator( - p_->environment()->frame_allocator); - - // THE KEY DIFFERENCE: check stop token before - // returning to the coroutine body - if (p_->environment()->stop_token.stop_requested()) - throw detail::stop_requested_exception{}; - - return a_.await_resume(); - } -}; -``` - -### `quitter::await_resume()` - -```cpp -auto await_resume() -{ - if (h_.promise().stopped()) - throw detail::stop_requested_exception{}; - if (h_.promise().state_ == promise_type::completion::exception) - std::rethrow_exception(h_.promise().ep_); - if constexpr (!std::is_void_v) - return std::move(*h_.promise().result_); -} -``` - -When a `quitter` is awaited inside another `quitter`, the stopped -state propagates upward: the parent's `await_resume` throws -`stop_requested_exception`, which the parent's `unhandled_exception` -catches and sets `stopped_`. The chain unwinds automatically. - -When a `quitter` is awaited inside a regular `task`, the -`stop_requested_exception` becomes an unhandled exception in the -`task`. The `task`'s `await_resume` rethrows it. The caller must -handle it. This is by design: `task` does not opt into automatic -cancellation. - -### `quitter::await_suspend()` +## Interaction with `when_all` / `when_any` -Identical to `task::await_suspend()`: +A quitter child opens its own segment, never the combinator's. When +the combinator stops its children, each quitter child is abandoned and +delivers its stopped value to the combinator, which handles it as that +child's result. Both combinators take only `results` children, so a +quitter child delivers `canceled` as a value and throws nothing. + +## Restrictions + +**Run a quitter through `await_suspend`, not `handle()`.** A custom +awaitable that runs a quitter for an awaiting coroutine calls the +quitter's `await_suspend`, which opens a segment. Resuming +`handle()` with the awaiting coroutine's environment puts the quitter +in that coroutine's segment, so a stop abandons the awaiting +coroutine's own segment, and a `task` chain there is torn down with no +handler called. A debug build asserts when `run_async` or `run` owns +that segment. A launcher that resumes the handle itself must bind a +`chain_owner` of its own. + +**An environment with no owner cannot stop a quitter.** A debug build +asserts; otherwise the quitter runs on as a `task` would. + +**Structured bindings at a `co_await`.** A stop destroys quitter frames +that are suspended at a `co_await`. GCC 15.2, and GCC 16 before +2026-07-02, at `-O0`, run the destructor of a never-constructed +structured binding when such a frame is destroyed (GCC PR c++/124584). +When an element is not trivially destructible, bind the result to a +named variable and destructure it afterwards: ```cpp -std::coroutine_handle<> await_suspend( - std::coroutine_handle<> cont, - await_context const* env) -{ - h_.promise().set_continuation(cont); - h_.promise().set_environment(env); - return h_; -} +auto r = co_await mtx.scoped_lock(); +auto& [ec, guard] = r; ``` -### Everything else - -`initial_suspend`, `final_suspend`, `get_return_object`, -`return_value`/`return_void`, frame allocation (`operator -new`/`delete`), `handle()`, `release()`, move operations, -destructor - all identical to `task`. - -## Concept Satisfaction - -`quitter` satisfies: - -- `ContextAwaitable` - has `await_suspend(coroutine_handle<>, await_context const*)` -- `ContextRunnable` - has `promise_type`, `handle()`, `release()`, - `exception()`, `set_continuation()`, `set_environment()`, - `result()` (non-void) - -`quitter` is usable with `run`, `run_async`, `when_all`, -`when_any`, and any launcher that accepts `ContextRunnable`. - -## Interaction with `when_all` / `when_any` - -`when_all` and `when_any` create a child `stop_source` and register -a `stop_callback` on the parent token. When the parent requests -stop, the child token is triggered. Each child `quitter` observes -the token on its next resume and short-circuits. All children exit -cleanly. The parent combinator sees the stopped completions and -propagates accordingly. - -No changes to `when_all` or `when_any` are needed. The stop -propagation already works through `await_context`. The only new behavior -is inside `quitter`'s `transform_awaiter::await_resume()`. +**Shutdown.** A stop that arrives while the execution context is being +shut down may end the quitter as a teardown instead, with nothing +delivered. Stop the work, wait for it to finish, and only then destroy +the context. + +**Out of memory.** Opening a segment allocates its frame. When the +awaiting coroutine is a `task`, its awaiter is `noexcept`, so a failed +allocation calls `std::terminate`. + +## Cost + +When no stop is requested: + +- one segment frame per segment, and one gate frame per quitter that + `run_async` or `run(ex)` starts. Both are allocated from capy's + process-wide recycling memory resource rather than the frame + allocator, because a teardown can free them after the chain that + owns the frame allocator is gone; +- one extra resume per `co_await` that suspends, through the gate, and + one at launch, when `run_async` or `run(ex)` starts the quitter + through its gate; +- a stop-token check at every `co_await`, before the operation starts; +- for an operation that completes without suspending and returns a + `results<>`: the result is taken into the awaiter, then moved to the + body, two moves more than in a `task`. While a stop is pending, it + is also checked for `canceled` first; +- a larger object: `sizeof(quitter)` is 32 bytes on 64-bit targets, + against 16 for `task`. A quitter also holds the handle of the + segment frame it opened, a function pointer used to unwind it, and a + second flag. + +`bench/quitter.cpp` compares `quitter` with `task`, including the +synchronous `results<>` completion. It prints whether +`BOOST_CAPY_SYMMETRIC_TRANSFER_WORKAROUND` is on, since that changes +how every `co_await` suspends. ## Usage @@ -302,9 +207,9 @@ quitter<> session(tcp_socket& sock) { char buf[1024]; auto [ec, n] = co_await sock.read_some(buf); - // If stop was requested, we never reach here. - // RAII cleanup for buf (trivial) and sock (if owned) - // runs automatically. + // A stop ends the quitter at the co_await above. We get here + // after a stop only with a result the read had already produced + // (see Synchronous completions). if (ec) co_return; co_await process(buf, n); @@ -313,118 +218,28 @@ quitter<> session(tcp_socket& sock) ### Application shutdown -```cpp -quitter<> run_server(tcp_acceptor& acc) -{ - while (true) - { - auto [ec, sock] = co_await acc.accept(); - if (ec) - co_return; - co_await scope.spawn(session(sock)); - } - // When the application calls stop_source.request_stop(), - // the next co_await in this coroutine (or any child) - // short-circuits. All sessions exit. All sockets close - // via RAII. The server exits cleanly. -} -``` +`example/quitter-shutdown` runs four `quitter<>` workers with no +cancellation code. A Ctrl+C requests the stop: every worker ends at the +`co_await` it is waiting in, its RAII guard runs, and its handler is +called. ### Mixing with `task` ```cpp +quitter> some_quitter_function(); + task<> outer() { - // If the quitter is stopped, its await_resume throws - // stop_requested_exception. The task sees it as an - // unhandled exception. The task's caller must handle it. - co_await some_quitter_function(); + // If the quitter is stopped, ec is cond::canceled. The task + // decides what to do with it. + auto [ec] = co_await some_quitter_function(); + if (ec == cond::canceled) + co_return; } ``` -This is intentional: `task` does not opt into automatic -cancellation. The programmer who uses `task` has chosen -explicit error handling. - -## Test Plan - -### Unit tests (in `test/unit/quitter.cpp`) - -1. **Normal completion** - `quitter` returns a value. - Verify `await_resume()` returns the value. Verify - `stopped()` is false. - -2. **Void completion** - `quitter<>` completes normally. - -3. **Exception propagation** - `quitter<>` throws. Verify - `exception()` is non-null. Verify `stopped()` is false. - -4. **Stop before first co_await** - Request stop before the - quitter starts its first I/O operation. Verify the first - `co_await` short-circuits. Verify `stopped()` is true. - Verify RAII destructors ran (use a counting guard). - -5. **Stop during I/O** - Start an I/O operation, request stop - while it is in flight. Verify the coroutine does not resume - into the body. Verify `stopped()` is true. Verify RAII - destructors ran. - -6. **Stop propagation through chain** - `quitter` awaits - `quitter` awaits `quitter`. Request stop. Verify all three - report `stopped()`. Verify all RAII destructors ran. - -7. **Stop propagation with when_all** - `when_all` of two - `quitter` tasks. Request stop. Verify both stop. Verify - the `when_all` completes (does not hang). - -8. **Stop propagation with when_any** - `when_any` of two - `quitter` tasks. One completes normally. Verify the other - is stopped. - -9. **Mixing quitter and task** - `task<>` awaits `quitter<>`. - Request stop. Verify the `quitter` stops. Verify the - `task` sees `stop_requested_exception` as an unhandled - exception. - -10. **No stop requested** - Run a `quitter` to completion - without ever requesting stop. Verify identical behavior - to `task`. - -11. **RAII verification** - Use a struct with a destructor - that increments a counter. Declare it in the coroutine - body before a `co_await`. Request stop. Verify the - counter was incremented (destructor ran). - -12. **Multiple co_await** - `quitter` does three sequential - I/O operations. Request stop after the second completes. - Verify the third `co_await` short-circuits. Verify the - first two results were processed normally. - -### Integration tests - -13. **TCP echo with shutdown** - Start a TCP echo server - using `quitter`. Connect a client. Exchange data. Request - stop. Verify the server exits cleanly and the socket is - closed. - -14. **Timer cancellation** - `quitter` awaits a long timer. - Request stop. Verify the timer is cancelled and the - coroutine exits promptly. - -## Files to Create - -| File | Contents | -|------|----------| -| `include/boost/capy/quitter.hpp` | `quitter` class template | -| `include/boost/capy/detail/stop_requested_exception.hpp` | Sentinel exception type | -| `test/unit/quitter.cpp` | Unit tests | - -## Files to Modify - -| File | Change | -|------|--------| -| `include/boost/capy.hpp` | Add `#include ` | -| `test/unit/CMakeLists.txt` | Add `quitter.cpp` to test sources | +A `task` keeps explicit error handling: it sees the stop as a +value, and its own code decides. ## Design Decisions @@ -435,47 +250,54 @@ handling use `task`. Users who want transparent cancellation use `quitter`. The ContextAwaitable protocol does not mandate a task. This is the design P4003 enables. -**Why throw internally instead of `goto final_suspend`?** - -C++ coroutines have no mechanism to jump from an arbitrary -suspension point to `final_suspend` other than throwing. The -throw runs destructors for all in-scope locals. The exception -is caught in `unhandled_exception()` and never escapes. The -cost is one throw per cancellation per coroutine lifetime. - -**Why check in `await_resume` instead of `await_suspend`?** - -`await_suspend` has already committed to suspending. The I/O -operation is in flight. Checking the stop token in `await_suspend` -would race with the I/O completion. Checking in `await_resume` -is safe: the operation has completed, the coroutine is about to -resume, and we intercept before the body sees the result. - -**Why not check in `initial_suspend::await_resume`?** - -We could, and should. If the stop token is already triggered -when the quitter starts, the first `initial_suspend::await_resume` -should throw `stop_requested_exception` immediately. Add this -check to `initial_suspend`'s awaiter: - -```cpp -void await_resume() const noexcept(false) -{ - set_current_frame_allocator( - p_->environment()->frame_allocator); - if (p_->environment()->stop_token.stop_requested()) - throw detail::stop_requested_exception{}; -} -``` - -Note: this changes `initial_suspend::await_resume` from `noexcept` -to potentially-throwing. This is safe because `unhandled_exception()` -will catch it. - -## Execution Protocol - -Save output after each complete semantic unit or to-do item (never -mid-paragraph). Always save output BEFORE marking plan items done - -never the reverse. On resumption: read the plan and last ~30 lines -of the output file. Repair any truncated tail. Continue from where -output ends, matching existing style. Never rewrite prior content. +**Why abandonment instead of an internal exception?** + +The first implementation threw a sentinel exception at the next resume +point and caught it in `unhandled_exception`. That design had four +problems: + +- any `catch(...)` around a `co_await` could intercept the stop; +- every quitter frame was resumed only to throw, and every frame above + it rethrew; +- it could not be used without exceptions; +- a throw after the inner `await_ready` had acquired a resource skipped + the `await_resume` that hands it over, so a stop could leak + `async_mutex`'s lock. + +Abandonment (`capy::abandon`, `chain_owner`) already ends a suspended +chain by destroying its frames from the top down. A stop now uses the +same cleanup, and only the gate decides between resuming and +abandoning. + +**Why a gate instead of checking in `await_resume`?** + +By the time `await_resume` runs, the frame has been resumed, and the +only way back out is to throw. The gate decides before any quitter +frame resumes. + +**Why check the stop before the inner `await_ready`?** + +A pending stop then starts no work. Checking later would let an +operation start, and possibly acquire something, that the body would +then receive after the stop. + +**Why does a stopped `quitter<>` throw?** + +A `void` return would read as success. In +`co_await save(); co_await notify_saved();`, the second line would run +even when `save()` was stopped. So `void` follows the plain-`T` rule: +a `T` that can hold `canceled` receives it as a value, and any other +`T` receives a `std::system_error`. The throw happens only where +something other than a quitter awaits one: a `task` or `run`. A +quitter awaiting a quitter shares its segment, and both end together +without a throw. + +**Why does the stop have a private exception type?** + +A task that awaits a stopped `quitter<>` and does not catch the stop +is unwound by it, as a stop unwound the task before. `run_async`'s +default handler must then drop that stop, but not a real `ECANCELED` +that a task throws. The stop is thrown as a private type derived from +`std::system_error`, so callers still catch it as one, and the default +handler drops only that type. A task that catches the stop and throws +a `std::system_error` copy of it makes it an ordinary exception. diff --git a/example/quitter-shutdown/quitter_shutdown.cpp b/example/quitter-shutdown/quitter_shutdown.cpp index afa4bec67..1d1312aa3 100644 --- a/example/quitter-shutdown/quitter_shutdown.cpp +++ b/example/quitter-shutdown/quitter_shutdown.cpp @@ -31,8 +31,9 @@ if(ec) co_return; // <-- cancellation boilerplate This is repeated at every suspension point. - With quitter<>, the promise intercepts the stop token - automatically. The worker body is pure business logic. + With quitter<>, a stop request ends the worker without + resuming it: its frame is destroyed, its RAII guards run, and + the handler is called. The worker body is pure business logic. */ #include @@ -123,7 +124,7 @@ capy::quitter<> worker( } // Never reached — the loop is infinite. - // quitter exits at the next co_await after stop is requested. + // A stop request ends the worker at the co_await it is waiting in. } int main() diff --git a/include/boost/capy/concept/context_runnable.hpp b/include/boost/capy/concept/context_runnable.hpp index 1dae1e862..6889a20d3 100644 --- a/include/boost/capy/concept/context_runnable.hpp +++ b/include/boost/capy/concept/context_runnable.hpp @@ -78,6 +78,10 @@ namespace capy { @li Sets the `await_context` pointer that propagates executor, stop token, and allocator through the coroutine chain. @li The pointed-to `await_context` must outlive the coroutine. + @li For a @ref quitter, the environment's `owner` is the owner that a + stop abandons. It must belong to whatever started the quitter, + never to the segment of an awaiting @ref task: see + @ref quitter::handle. @par Requirements diff --git a/include/boost/capy/detail/await_suspend_helper.hpp b/include/boost/capy/detail/await_suspend_helper.hpp index 46f457ca0..088b3e735 100644 --- a/include/boost/capy/detail/await_suspend_helper.hpp +++ b/include/boost/capy/detail/await_suspend_helper.hpp @@ -145,18 +145,13 @@ symmetric_transfer(std::coroutine_handle<> h) noexcept The awaiting coroutine continues without suspending when the wrapped awaitable is ready, or when its `await_suspend` returns - `false`. When the wrapped `await_suspend` returns a handle, that - handle is resumed through `symmetric_transfer` and the awaiting - coroutine stays suspended. Returning `false` instead of resuming - inline keeps immediate completions from growing the stack. - - When `completed` is given, a returned handle equal to `h` is - treated as a synchronous completion as well: it is not resumed, - `*completed` is set, and `false` is returned. `*completed` is set - on every synchronous completion and is never written after a real - suspension, when the frame may already be running elsewhere. - `quitter` uses this to hand over a synchronous result even if a - stop arrives. `task` passes nothing and keeps the inline resume. + `false` or returns `h` itself. When the wrapped `await_suspend` + returns any other handle, that handle is resumed through + `symmetric_transfer` and the awaiting coroutine stays suspended. + Returning `false` instead of resuming inline keeps immediate + completions from growing the stack. `quitter` passes its gate as + `h`, so a wrapped awaitable that completes by returning the gate is + a synchronous completion, not a pass through the gate. @param a The wrapped awaitable. @@ -165,9 +160,6 @@ symmetric_transfer(std::coroutine_handle<> h) noexcept @param env The execution environment to pass to the wrapped awaitable's `await_suspend`. - @param completed If not null, set to `true` when the wrapped - awaitable completes synchronously. - @return `false` to continue without suspending, otherwise `true`. */ template @@ -175,24 +167,15 @@ bool await_ready_then_suspend( Awaitable& a, std::coroutine_handle<> h, - await_context const* env, - bool* completed = nullptr) noexcept + await_context const* env) noexcept { // Called exactly once: await_ready may consume state. if(a.await_ready()) - { - if(completed) - *completed = true; return false; - } using R = decltype(a.await_suspend(h, env)); if constexpr (std::is_same_v) { - if(a.await_suspend(h, env)) - return true; - if(completed) - *completed = true; - return false; + return a.await_suspend(h, env); } else if constexpr (std::is_void_v) { @@ -202,11 +185,8 @@ await_ready_then_suspend( else { std::coroutine_handle<> next = a.await_suspend(h, env); - if(completed && next == h) - { - *completed = true; + if(next == h) return false; - } symmetric_transfer(next); return true; } diff --git a/include/boost/capy/detail/quitter_stopped.hpp b/include/boost/capy/detail/quitter_stopped.hpp new file mode 100644 index 000000000..960ac1057 --- /dev/null +++ b/include/boost/capy/detail/quitter_stopped.hpp @@ -0,0 +1,45 @@ +// +// 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 +// + +#ifndef BOOST_CAPY_DETAIL_QUITTER_STOPPED_HPP +#define BOOST_CAPY_DETAIL_QUITTER_STOPPED_HPP + +#include +#include + +#include + +/* What a stopped quitter throws when T cannot hold canceled (void, + or a plain T). It is a std::system_error with canceled, so callers + catch it as one. Its own type lets run_async's default_handler drop + a quitter's stop that escapes a task, while it still terminates on + any other exception, a real ECANCELED included. A handler that + catches it and throws a std::system_error copy loses the type. + The type is visible so that catch still matches a stop thrown from + another shared library in a hidden-visibility build. */ + +namespace boost { +namespace capy { +namespace detail { + +struct BOOST_CAPY_SYMBOL_VISIBLE + quitter_stopped + : std::system_error +{ + quitter_stopped() + : std::system_error(make_error_code(error::canceled)) + { + } +}; + +} // namespace detail +} // namespace capy +} // namespace boost + +#endif diff --git a/include/boost/capy/detail/run.hpp b/include/boost/capy/detail/run.hpp index a84d58ad5..b87ccc9bb 100644 --- a/include/boost/capy/detail/run.hpp +++ b/include/boost/capy/detail/run.hpp @@ -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) @@ -13,6 +14,8 @@ #include #include +#include +#include #include #include @@ -20,6 +23,15 @@ namespace boost { namespace capy { namespace detail { +// A runnable that a stop ends by abandonment (quitter): its promise +// says what it delivers instead of its own result, and gives the +// handle a launcher resumes to start it. +template +concept stops_by_abandonment = requires(typename Task::promise_type& p) { + Task::promise_type::stopped_result(); + { p.launch_handle() } -> std::convertible_to>; +}; + /// Concept for standard Allocator types. template concept Allocator = requires(A a, std::size_t n) { diff --git a/include/boost/capy/detail/run_callbacks.hpp b/include/boost/capy/detail/run_callbacks.hpp index db8206135..74e12940b 100644 --- a/include/boost/capy/detail/run_callbacks.hpp +++ b/include/boost/capy/detail/run_callbacks.hpp @@ -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) @@ -11,7 +12,7 @@ #define BOOST_CAPY_DETAIL_RUN_CALLBACKS_HPP #include -#include +#include #include #include @@ -41,11 +42,11 @@ struct default_handler { std::rethrow_exception(ep); } - catch(stop_requested_exception const&) + catch(quitter_stopped const&) { - // Cancellation is a normal completion, not an error. + // A quitter's stop is not an error. } - // A real unhandled exception propagates to the trampoline's + // Any other exception propagates to the trampoline's // unhandled_exception, which terminates. } }; diff --git a/include/boost/capy/detail/stop_requested_exception.hpp b/include/boost/capy/detail/stop_requested_exception.hpp deleted file mode 100644 index f5bc47e98..000000000 --- a/include/boost/capy/detail/stop_requested_exception.hpp +++ /dev/null @@ -1,28 +0,0 @@ -// -// 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 -// - -#ifndef BOOST_CAPY_DETAIL_STOP_REQUESTED_EXCEPTION_HPP -#define BOOST_CAPY_DETAIL_STOP_REQUESTED_EXCEPTION_HPP - -namespace boost { -namespace capy { -namespace detail { - -/* Lightweight sentinel thrown inside quitter when the stop token - is triggered. Not derived from std::exception. Never escapes the - coroutine — unhandled_exception() catches it and sets the stopped - flag. The cost is one throw+catch per cancellation per coroutine - lifetime. */ -struct stop_requested_exception {}; - -} // namespace detail -} // namespace capy -} // namespace boost - -#endif diff --git a/include/boost/capy/ex/abandon.hpp b/include/boost/capy/ex/abandon.hpp index 65ba87332..c502b70ce 100644 --- a/include/boost/capy/ex/abandon.hpp +++ b/include/boost/capy/ex/abandon.hpp @@ -1,5 +1,6 @@ // // Copyright (c) 2026 Steve Gerbino +// 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) @@ -24,7 +25,8 @@ namespace capy { A segment is a run of coroutines started together: the task `run_async` launches and everything it awaits, a task `run` starts - on another executor, or one branch of `when_all` or `when_any`. Its + on another executor, one branch of `when_all` or `when_any`, or a + @ref quitter awaited by a coroutine that is not a quitter. Its owner is the coroutine that started it, suspended above the segment's top frame until that frame completes. Every coroutine in the segment reaches the owner through its @ref await_context. @@ -32,22 +34,43 @@ namespace capy { When the holder of a suspended coroutine in the segment gives it up with @ref abandon, the owner posts its own continuation to its executor. There it unwinds the segment from the top: it destroys - the frame it awaits, which destroys the frames below it in turn, and - finishes without delivering a result. An executor that destroys the - posted continuation instead, as a closed one does, unwinds the - segment the same way. + the frame it awaits, which destroys the frames below it in turn. + After this teardown, the owner delivers no result. An executor that + destroys the posted continuation instead, as a closed one does, + unwinds the segment the same way. + + The owner also records why the segment was abandoned. Teardown + calls @ref abandon. A stopped @ref quitter calls + @ref abandon_for_stop instead. Its owner unwinds the segment the + same way, then reports the stop to its parent. An owner that never + asks @ref stopped treats a stop as teardown: it delivers nothing. A + launcher written by a user must therefore check @ref stopped if it + runs quitters and has to report their stops. @par Thread Safety - @ref abandon may be called from any thread. Only one holder can - abandon a segment, since it is suspended at one point; a repeated - call before the unwind runs does nothing. + @ref abandon and @ref abandon_for_stop may be called from any + thread. Only the first call takes effect, and decides the cause; a + later call of either before the unwind runs does nothing. */ class chain_owner { - std::atomic abandoned_{false}; + enum : unsigned char { not_abandoned, by_teardown, by_stop }; + + std::atomic cause_{not_abandoned}; continuation self_; executor_ref ex_; + void abandon_as(unsigned char cause) noexcept + { + unsigned char expected = not_abandoned; + if(!cause_.compare_exchange_strong( + expected, cause, + std::memory_order_acq_rel, + std::memory_order_acquire)) + return; + ex_.post(self_); + } + public: /** Bind the owner to its coroutine and executor. @@ -67,7 +90,17 @@ class chain_owner /// Return true once the segment has been abandoned. bool abandoned() const noexcept { - return abandoned_.load(std::memory_order_acquire); + return cause_.load(std::memory_order_acquire) != not_abandoned; + } + + /** Return true if the segment was abandoned because of a stop. + + @return `true` only if the first abandon of the segment was + @ref abandon_for_stop. + */ + bool stopped() const noexcept + { + return cause_.load(std::memory_order_acquire) == by_stop; } /** Unwind the segment on the owner's executor. @@ -80,9 +113,23 @@ class chain_owner */ void abandon() noexcept { - if(abandoned_.exchange(true, std::memory_order_acq_rel)) - return; - ex_.post(self_); + abandon_as(by_teardown); + } + + /** Unwind the segment on the owner's executor, as stopped. + + The same as @ref abandon, but records the cause as a stop, so + @ref stopped returns `true` if this call was the first. Called + when a @ref quitter in the segment is stopped: at a `co_await` + in its body, when an operation completes into its segment's + gate, or when the gate through which `run_async` or `run` + starts a quitter does not start it. + + @note A post that throws calls `std::terminate`. + */ + void abandon_for_stop() noexcept + { + abandon_as(by_stop); } }; @@ -92,8 +139,10 @@ class chain_owner it, such as an execution context, strand or primitive being torn down. The coroutine's segment is unwound from its owner: on the owner's executor, from the top frame down to this coroutine, without - any frame delivering a result. Ordinary cancellation does not use - this; it resumes the coroutine with an error instead. + any frame delivering a result. Ordinary cancellation of a @ref task + does not use this; it resumes the coroutine with an error instead. + A @ref quitter's stop does use abandonment, through + @ref chain_owner::abandon_for_stop. A continuation with no owner, such as an owner's own continuation, is destroyed directly. diff --git a/include/boost/capy/ex/await_context.hpp b/include/boost/capy/ex/await_context.hpp index 0db7c8185..01451e7ed 100644 --- a/include/boost/capy/ex/await_context.hpp +++ b/include/boost/capy/ex/await_context.hpp @@ -71,6 +71,21 @@ struct await_context nothing awaits the coroutines that could be abandoned. With no owner, @ref abandon destroys only the frame it is given, and any frame awaiting it is left holding a destroyed frame. + + Such a launcher that runs a @ref quitter must bind an owner, + since a quitter stops by abandoning its segment; with none, a + stop is not carried out. It must also ask + @ref chain_owner::stopped when its segment is abandoned. A stopped + quitter's segment is abandoned for the stop, and its result is + then the quitter's stopped value, which the launcher reports. + A launcher that does not ask treats the stop as teardown and + reports nothing. + + The owner must be the launcher's own. An awaiting task's owner, + passed on with the rest of its environment, makes a stop tear + that task's chain down with no handler called. A quitter run + for an awaiting coroutine is started with its `await_suspend`, + which opens a segment of its own. */ chain_owner* owner = nullptr; diff --git a/include/boost/capy/ex/run.hpp b/include/boost/capy/ex/run.hpp index 362da498f..b3820165f 100644 --- a/include/boost/capy/ex/run.hpp +++ b/include/boost/capy/ex/run.hpp @@ -76,6 +76,9 @@ struct boundary_trampoline executor_ref caller_ex_; continuation parent_; chain_owner segment_; + // The inner task reports a stop-caused abandon (a quitter). For + // any other task such an abandon is torn down like any other. + bool stoppable_ = false; // The awaitable's trampoline object until the inner task // completes or is unwound; null afterward. boundary_trampoline* owner_ = nullptr; @@ -125,12 +128,25 @@ struct boundary_trampoline { // Nothing here touches p_ after the post or the // unwind: either can destroy this frame. - if(p_->segment_.abandoned()) + auto& segment = p_->segment_; + // See run_async's trampoline: only a quitter run + // here is stopped through this owner. + BOOST_CAPY_ASSERT( + (p_->stoppable_ || !segment.stopped()) && + "a quitter stopped a task's segment: " + "see quitter::handle"); + if(segment.abandoned() && + !(p_->stoppable_ && segment.stopped())) { p_->unwind(); } else { + // A quitter's stop: its frames are destroyed + // here, and the parent resumes to receive the + // stopped value. + if(segment.abandoned()) + p_->destroy_inner_(p_->awaitable_); p_->owner_ = nullptr; auto ex = p_->caller_ex_; ex.post(p_->parent_); @@ -291,6 +307,11 @@ struct [[nodiscard]] run_awaitable_ex { if(std::exchange(holds_work_, false)) caller_ex_.on_work_finished(); + if constexpr(stops_by_abandonment) + { + if(tr_.h_ && tr_.h_.promise().segment_.stopped()) + return Task::promise_type::stopped_result(); + } return inner_.await_resume(); } @@ -305,6 +326,7 @@ struct [[nodiscard]] run_awaitable_ex tp.destroy_inner_ = &run_awaitable_ex::destroy_inner; tp.awaitable_ = this; tp.segment_.bind(tr_.h_, ex_); + tp.stoppable_ = stops_by_abandonment; caller_ex_ = caller_env->executor; // Released once the parent resumes or is unwound. Not on the // post back: the parent's chain may own the executor object and @@ -329,10 +351,17 @@ struct [[nodiscard]] run_awaitable_ex env_.frame_allocator = caller_env->frame_allocator; p.set_environment(&env_); + task_cont_.h = h; task_cont_.owner = &tp.segment_; try { + // A quitter starts through its gate, which checks the stop + // when the body would start: a stopped quitter's segment is + // abandoned for the stop, and the parent receives the + // stopped value. + if constexpr(stops_by_abandonment) + task_cont_.h = p.launch_handle(); ex_.post(task_cont_); } catch(...) @@ -437,11 +466,6 @@ struct [[nodiscard]] run_awaitable std::coroutine_handle<> await_suspend(std::coroutine_handle<> cont, await_context const* caller_env) { - auto h = inner_.handle(); - auto& p = h.promise(); - p.set_continuation(cont); - lent_ = true; - env_.owner = caller_env->owner; env_.executor = caller_env->executor; if constexpr (InheritStopToken) @@ -454,8 +478,26 @@ struct [[nodiscard]] run_awaitable else env_.frame_allocator = caller_env->frame_allocator; - p.set_environment(&env_); - return h; + // A quitter opens a segment of its own, so a stop on this run's + // token never abandons the caller's segment. + if constexpr(stops_by_abandonment) + { + // Lent only once the segment is open: opening it can throw + // into a quitter's body, and a lent inner task terminates + // in our destructor. + auto h = inner_.await_suspend(cont, &env_); + lent_ = true; + return h; + } + else + { + auto h = inner_.handle(); + auto& p = h.promise(); + p.set_continuation(cont); + p.set_environment(&env_); + lent_ = true; + return h; + } } // Non-copyable @@ -795,6 +837,11 @@ namespace boost::capy { without the caller resuming. The awaitable holds work on the caller's executor until the caller resumes or is unwound. + A @ref quitter's stop is not such an abandon. When the inner task + is a quitter and a stop ends it, the caller resumes on its own + executor, and the `co_await` yields the quitter's stopped value, as + described in @ref quitter's Await-returns. + @par Preconditions The caller's execution context outlives the inner task and the unwind of the caller's chain. This is not detected. diff --git a/include/boost/capy/ex/run_async.hpp b/include/boost/capy/ex/run_async.hpp index 533132d7f..fbd6ce339 100644 --- a/include/boost/capy/ex/run_async.hpp +++ b/include/boost/capy/ex/run_async.hpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include @@ -51,6 +52,53 @@ concept RunAsyncHandler = /// Function pointer type for type-erased frame deallocation. using dealloc_fn = void(*)(void*, std::size_t); +// Report a stopped quitter to the handlers: its stopped value to the +// value handler, or what computing that value threw to the error +// handler. default_handler drops a quitter_stopped. A handler that +// throws is not caught here. +template +void invoke_stopped(Handlers& h) +{ + using P = typename Task::promise_type; + using R = decltype(std::declval().await_resume()); + if constexpr(std::is_void_v) + { + // A stopped quitter<> has no value: stopped_result only throws. + // Nothing follows the call, or MSVC warns it is unreachable + // (C4702). +#if defined(__cpp_exceptions) + try + { + P::stopped_result(); + } + catch(...) + { + h(std::current_exception()); + } +#else + P::stopped_result(); +#endif + } + else + { + std::optional v; +#if defined(__cpp_exceptions) + try + { + v.emplace(P::stopped_result()); + } + catch(...) + { + h(std::current_exception()); + return; + } +#else + v.emplace(P::stopped_result()); +#endif + h(std::move(*v)); + } +} + /// Type-erased deallocator implementation for trampoline frames. template void dealloc_impl(void* raw, std::size_t total) @@ -83,6 +131,12 @@ struct get_promise_awaiter } }; +// The promise holds Ex, Handlers and the allocator by value. Whether +// an over-aligned one pads the promise depends on the other members' +// sizes, so C4324 is off for both trampolines. +BOOST_CAPY_MSVC_WARNING_PUSH +BOOST_CAPY_MSVC_WARNING_DISABLE(4324) // padded due to alignas + /** Internal run_async_trampoline coroutine for run_async. The run_async_trampoline is allocated BEFORE the task (via C++17 postfix evaluation @@ -99,7 +153,7 @@ struct get_promise_awaiter template struct run_async_trampoline { - using invoke_fn = void(*)(void*, Handlers&); + using invoke_fn = void(*)(void*, Handlers&, bool); struct promise_type { @@ -110,13 +164,16 @@ struct run_async_trampoline invoke_fn invoke_ = nullptr; void* task_promise_ = nullptr; // task_h_: raw handle for frame_guard cleanup in make_trampoline. - // task_cont_: continuation wrapping the same handle for executor dispatch. - // Both must reference the same coroutine and be kept in sync. + // task_cont_: continuation dispatched to start the task: the same + // handle, or for a quitter the gate that starts it. std::coroutine_handle<> task_h_; continuation task_cont_; // Owns the task's segment: abandoning it resumes this // trampoline, or has it destroyed, to unwind the task. chain_owner owner_; + // The task reports a stop-caused abandon (a quitter). For any + // other task such an abandon is torn down like any other. + bool stoppable_ = false; ~promise_type() { @@ -218,17 +275,26 @@ struct run_async_trampoline // An exception reaches here only by escaping a handler: a handler // that threw, or the default handler rethrowing an otherwise - // unhandled task exception. Cancellation is filtered out earlier - // by default_handler, so this is always a genuine error with no - // owner to receive it: fail fast. + // unhandled task exception. default_handler drops a quitter's + // stop, so this is always a genuine error with no owner to + // receive it: fail fast. void unhandled_exception() noexcept { std::terminate(); } // LCOV_EXCL_LINE }; std::coroutine_handle h_; template - static void invoke_impl(void* p, Handlers& h) + static void invoke_impl(void* p, Handlers& h, bool stopped) { + if constexpr(stops_by_abandonment) + { + if(stopped) + { + invoke_stopped(h); + return; + } + } + (void)stopped; using R = decltype(std::declval().await_resume()); auto& promise = *static_cast(p); if(promise.exception()) @@ -247,7 +313,7 @@ struct run_async_trampoline template struct run_async_trampoline { - using invoke_fn = void(*)(void*, Handlers&); + using invoke_fn = void(*)(void*, Handlers&, bool); struct promise_type { @@ -258,13 +324,16 @@ struct run_async_trampoline invoke_fn invoke_ = nullptr; void* task_promise_ = nullptr; // task_h_: raw handle for frame_guard cleanup in make_trampoline. - // task_cont_: continuation wrapping the same handle for executor dispatch. - // Both must reference the same coroutine and be kept in sync. + // task_cont_: continuation dispatched to start the task: the same + // handle, or for a quitter the gate that starts it. std::coroutine_handle<> task_h_; continuation task_cont_; // Owns the task's segment: abandoning it resumes this // trampoline, or has it destroyed, to unwind the task. chain_owner owner_; + // The task reports a stop-caused abandon (a quitter). For any + // other task such an abandon is torn down like any other. + bool stoppable_ = false; ~promise_type() { @@ -332,8 +401,17 @@ struct run_async_trampoline std::coroutine_handle h_; template - static void invoke_impl(void* p, Handlers& h) + static void invoke_impl(void* p, Handlers& h, bool stopped) { + if constexpr(stops_by_abandonment) + { + if(stopped) + { + invoke_stopped(h); + return; + } + } + (void)stopped; using R = decltype(std::declval().await_resume()); auto& promise = *static_cast(p); if(promise.exception()) @@ -344,6 +422,7 @@ struct run_async_trampoline h(std::move(promise.result())); } }; +BOOST_CAPY_MSVC_WARNING_POP /// Coroutine body for run_async_trampoline - invokes handlers then destroys task. template @@ -359,13 +438,28 @@ make_trampoline(Ex, Handlers, Alloc) struct frame_guard { std::coroutine_handle<>& h; - ~frame_guard() { std::exchange(h, nullptr).destroy(); } + ~frame_guard() + { + if(h) + std::exchange(h, nullptr).destroy(); + } } guard{p.task_h_}; - // Resumed with the task unfinished: the segment was abandoned, and - // destroying the task unwinds it without calling a handler. - if(!p.owner_.abandoned()) - p.invoke_(p.task_promise_, p.handlers_); + // Resumed with the task unfinished: the segment was abandoned. A + // teardown destroys the task without calling a handler; a quitter's + // stop reports its stopped value. + // A stopped quitter's frames are destroyed before its handler runs, + // as a finished task's destructors have run by then. + // Only a quitter run by this trampoline is stopped through its + // owner. A quitter driven through its handle() in a task's segment + // would tear the task down here with no handler called. + BOOST_CAPY_ASSERT((p.stoppable_ || !p.owner_.stopped()) && + "a quitter stopped a task's segment: see quitter::handle"); + bool const stopped = p.stoppable_ && p.owner_.stopped(); + if(stopped) + std::exchange(p.task_h_, nullptr).destroy(); + if(!p.owner_.abandoned() || stopped) + p.invoke_(p.task_promise_, p.handlers_, stopped); } } // namespace detail @@ -527,9 +621,10 @@ class [[nodiscard]] run_async_wrapper in its chain. Awaitables may store `await_context const*` without concern for dangling references. - @throws Any exception thrown by the executor's `dispatch`. The - task is then destroyed without running, and the executor's - work is released. + @throws Any exception thrown by the executor's `dispatch`, or + `std::bad_alloc` if the gate of a @ref quitter cannot be + allocated. The task is then destroyed without running, and + the executor's work is released. @tparam Task The ContextRunnable type. @@ -549,6 +644,7 @@ class [[nodiscard]] run_async_wrapper p.invoke_ = detail::run_async_trampoline::template invoke_impl; p.task_promise_ = &task_promise; p.task_h_ = task_h; + p.stoppable_ = detail::stops_by_abandonment; // Setup task's continuation to return to run_async_trampoline task_promise.set_continuation(tr_.h_); @@ -561,8 +657,14 @@ class [[nodiscard]] run_async_wrapper // constructor (saved_tls_) and restored in the destructor. p.task_cont_.h = task_h; p.task_cont_.owner = &p.owner_; - // If dispatch throws, the destructor destroys the trampoline - // and the unstarted task with it. + + // A quitter starts through its gate, which checks the stop when + // the body would start: a stopped quitter's segment is abandoned + // for the stop, and the trampoline reports the stopped value. + // If this or dispatch throws, the destructor destroys the + // trampoline and the unstarted task with it. + if constexpr(detail::stops_by_abandonment) + p.task_cont_.h = task_promise.launch_handle(); auto next = p.wg_.executor().dispatch(p.task_cont_); // From here the trampoline destroys itself when the task ends. tr_.h_ = nullptr; @@ -582,7 +684,11 @@ class [[nodiscard]] run_async_wrapper With no handlers, the result is discarded. An unhandled exception thrown by the task calls `std::terminate`. To catch it instead, pass an error handler that receives it as an `exception_ptr`, or `co_await` - the work inside a coroutine. + the work inside a coroutine. A stopped @ref quitter whose `T` is not + `results` or `std::error_code` reports its stop to the error + handler as a `std::system_error` whose `code()` compares equal to + `cond::canceled`. With no error handler, that stop is discarded, + also when it escapes a task that awaited the quitter. Construct the task as the direct argument of the two-call expression `run_async(ex)(task)`. @@ -618,7 +724,9 @@ run_async(Ex ex) The handler `h1` is called with the task's result on success. If `h1` is also invocable with `std::exception_ptr`, it handles exceptions too. - Otherwise, an unhandled exception calls `std::terminate`. + Otherwise, an unhandled exception calls `std::terminate`, except a + @ref quitter's stop, which is discarded. That includes a stop that + escapes a task that awaited the quitter. If the task is abandoned before it completes, as a context's teardown does, no handler is called; the handlers are destroyed with it. @@ -702,7 +810,8 @@ run_async(Ex ex, H1 h1, H2 h2) The stop token is propagated to the task, enabling cooperative cancellation. With no handlers, the result is discarded and an - unhandled exception calls `std::terminate`. + unhandled exception calls `std::terminate`. A stopped @ref quitter + reports its stopped value to the handlers: see @ref quitter. Construct the task as the direct argument of the two-call expression `run_async(ex)(task)`. @@ -741,6 +850,9 @@ run_async(Ex ex, std::stop_token st) The handler `h1` is called with the result on success, and optionally with exception_ptr if it accepts that type. + A stopped @ref quitter reports its stopped value to the handlers: + see @ref quitter. + Construct the task as the direct argument of the two-call expression `run_async(ex)(task)`. @@ -776,6 +888,9 @@ run_async(Ex ex, std::stop_token st, H1 h1) The stop token is propagated to the task for cooperative cancellation. The handler `h1` is called on success, `h2` on failure. + A stopped @ref quitter reports its stopped value to the handlers: + see @ref quitter. + Construct the task as the direct argument of the two-call expression `run_async(ex)(task)`. @@ -910,6 +1025,9 @@ run_async(Ex ex, std::pmr::memory_resource* mr, H1 h1, H2 h2) /** Bind an executor, a stop token, and a memory resource to produce a launcher. Invoke the launcher with a task to start it. + A stopped @ref quitter reports its stopped value to the handlers: + see @ref quitter. + Construct the task as the direct argument of the two-call expression `run_async(ex)(task)`. @@ -941,6 +1059,9 @@ run_async(Ex ex, std::stop_token st, std::pmr::memory_resource* mr) /** Bind an executor, a stop token, a memory resource, and a result handler to produce a launcher. Invoke the launcher with a task to start it. + A stopped @ref quitter reports its stopped value to the handlers: + see @ref quitter. + Construct the task as the direct argument of the two-call expression `run_async(ex)(task)`. @@ -974,6 +1095,9 @@ run_async(Ex ex, std::stop_token st, std::pmr::memory_resource* mr, H1 h1) /** Bind an executor, a stop token, a memory resource, and separate result and error handlers to produce a launcher. Invoke the launcher with a task to start it. + A stopped @ref quitter reports its stopped value to the handlers: + see @ref quitter. + Construct the task as the direct argument of the two-call expression `run_async(ex)(task)`. @@ -1104,6 +1228,9 @@ run_async(Ex ex, Alloc alloc, H1 h1, H2 h2) /** Bind an executor, a stop token, and an allocator to produce a launcher. Invoke the launcher with a task to start it. + A stopped @ref quitter reports its stopped value to the handlers: + see @ref quitter. + Construct the task as the direct argument of the two-call expression `run_async(ex)(task)`. @@ -1133,6 +1260,9 @@ run_async(Ex ex, std::stop_token st, Alloc alloc) /** Bind an executor, a stop token, an allocator, and a result handler to produce a launcher. Invoke the launcher with a task to start it. + A stopped @ref quitter reports its stopped value to the handlers: + see @ref quitter. + Construct the task as the direct argument of the two-call expression `run_async(ex)(task)`. @@ -1164,6 +1294,9 @@ run_async(Ex ex, std::stop_token st, Alloc alloc, H1 h1) /** Bind an executor, a stop token, an allocator, and separate result and error handlers to produce a launcher. Invoke the launcher with a task to start it. + A stopped @ref quitter reports its stopped value to the handlers: + see @ref quitter. + Construct the task as the direct argument of the two-call expression `run_async(ex)(task)`. diff --git a/include/boost/capy/quitter.hpp b/include/boost/capy/quitter.hpp index f6b8341aa..b12978ad8 100644 --- a/include/boost/capy/quitter.hpp +++ b/include/boost/capy/quitter.hpp @@ -11,48 +11,94 @@ #define BOOST_CAPY_QUITTER_HPP #include -#include -#include -#include #include #include +#include +#include +#include +#include #include #include +#include #include #include +#include +#include +#include #include #include -#include +#include #include #include /* Stop-aware coroutine task. - quitter is identical to task except that a stop request ends - the body without the body checking an error code. The promise's - transform_awaiter checks the stop token before it touches an awaited - operation, and again when an operation that completed - asynchronously resumes the body. A pending stop there throws a - sentinel exception that unwinds through RAII destructors to - final_suspend, and the parent sees a "stopped" completion. An - operation that completes synchronously always has its await_resume - called, because its await_ready or await_suspend may already have - acquired something. A results<> it returns holding cond::canceled - while the stop is pending acquired nothing, so the stop throws there; - any other result reaches the body, and the stop takes effect at the - next co_await. - - See doc/quitter.md for the full design rationale. */ + quitter is task, except that a stop request ends its frames + by abandonment instead of resuming them. Nothing in the body runs + after the stop, so nothing can intercept it, and no exception is + thrown inside the quitter. + + Segment. A quitter awaited by anything other than a quitter opens a + segment in its await_suspend. The segment is one coroutine frame, + quitter_segment, that is both the segment's owner (its chain_owner is + the await_context::owner every frame in the segment sees) and the + gate. A quitter awaited by a quitter joins the parent's segment. A + quitter started directly by a launcher (run_async, run(ex)) runs in + the launcher's segment; it still gets a gate, and the launcher's + chain_owner is the one the gate abandons. The launcher creates the + gate with launch_handle() and resumes the gate, not the quitter, so + the gate checks the stop before the body starts. A launcher that + resumes the quitter's own handle instead gets a gate when the body + starts, with no such check. + + Gate. A quitter frame never hands its own handle to an operation. Its + transform_awaiter records the frame as the gate's target and passes + the gate's handle instead, so an operation completes into the gate. + The gate then resumes the target, or, if a stop was requested, + abandons the segment with chain_owner::abandon_for_stop. The + operation has finished with the frame by then (the corosio rule: no + frame is destroyed while an operation holds its continuation). + + Synchronous completions. The stop is checked before the + inner await_ready, so a pending stop starts no work. An inner + awaitable that completes without going through the gate (ready, + await_suspend returning false or the gate's own handle) always has + its await_resume called, so anything it acquired is handed over; the + stop takes effect at the next suspension. The exception is a + results<> holding cond::canceled while our stop is pending: the + inner awaitable saw the stop first and acquired nothing (async_mutex, + async_event and async_waker all answer a stop that lands between our + check and theirs this way), so the segment is abandoned there. That + result is taken early and kept in the awaiter, because await_resume + runs in the resumed body, too late to abandon. + + Unwind. When the segment's owner is resumed after an abandon, it + releases the opening quitter's handle and destroys the top frame, + which destroys the frames below it. After a stop it then resumes the + parent, whose co_await yields the stopped value (stopped_value). + After a teardown it abandons the parent, as run's boundary_trampoline + does. A segment frame destroyed instead of resumed (posted to a + closed executor) unwinds as teardown from its destructor. + + Why the gate is never resumed while it runs. Its body is + `for(;;) co_await hop{}`, and every decision is made in + hop::await_suspend, after the frame has suspended. Its handle is + reachable only from the one operation a quitter frame of the segment + is waiting on, and from the owner's post, which happens only when no + operation holds it. Once await_suspend has handed the handle on, by + returning it, resuming a target inline or posting, it touches nothing + of the frame again, because another thread may already be running or + destroying it. */ namespace boost { namespace capy { +template +struct quitter; + namespace detail { -// Reuse the same return-value storage as task. -// task_return_base is defined in task.hpp, but quitter needs its own -// copy to avoid a header dependency on task.hpp. template struct quitter_return_base { @@ -77,95 +123,334 @@ struct quitter_return_base } }; +/* What a stopped quitter delivers to its parent. + + results: canceled, with each payload value-initialized. + std::error_code: canceled. + Any other T, void included: quitter_stopped, a std::system_error + with canceled, thrown at the boundary. A void return would read as + success. */ +template +struct stopped_value +{ + static T get() + { +#if defined(__cpp_exceptions) + throw quitter_stopped(); +#else + // sizeof(T*): T may be void. + static_assert(sizeof(T*) == 0, + "a stopped quitter for a T other than results or " + "std::error_code reports the stop by throwing, " + "which needs exceptions"); +#endif + } +}; + +template +struct stopped_value> +{ + static_assert( + (std::is_default_constructible_v && ...), + "a stopped quitter> value-initializes every Ts"); + + static results get() + { + return results(make_error_code(error::canceled), Ts{}...); + } +}; + +template<> +struct stopped_value +{ + static std::error_code get() noexcept + { + return make_error_code(error::canceled); + } +}; + +template +struct is_quitter : std::false_type {}; + +template +struct is_quitter> : std::true_type {}; + +// A stop that can be carried out: requested, with an owner to abandon. +// With no owner a quitter cannot stop, and runs on as a task would; a +// launcher that runs quitters must bind a chain_owner. +inline bool +quitter_stop_pending(await_context const* env) noexcept +{ + if(!env->stop_token.stop_requested()) + return false; + BOOST_CAPY_ASSERT(env->owner && + "a quitter's environment needs an owner to stop"); + return env->owner != nullptr; +} + +struct quitter_opener; + +// One per segment: the owner and the gate in one coroutine frame. +struct quitter_segment +{ + struct promise_type; + + std::coroutine_handle h_; +}; + +// The part of a quitter that opened a segment, without its T. +struct quitter_opener +{ + // Takes the quitter's frame handle, leaving the quitter empty. + std::coroutine_handle<> (*release_top_)(quitter_opener&) noexcept = + nullptr; + quitter_segment seg_{}; + bool stopped_ = false; + // Set while a parent is suspended awaiting the quitter. + bool lent_ = false; +}; + +// Not the chain's frame allocator: after a teardown this frame is freed +// once the parent has been abandoned, when that allocator may be gone. +// run's boundary_trampoline does the same. +struct quitter_segment::promise_type + : process_frame_mixin +{ + // The segment's owner, when a quitter opened the segment. + chain_owner owner_; + // The environment of the segment's frames, when a quitter opened it. + await_context env_; + // The environment the frames run in: env_, or the launcher's. + await_context const* frame_env_ = nullptr; + // The awaiting coroutine, and its own owner for a teardown. + continuation parent_; + // The quitter frame waiting in the gate. + std::coroutine_handle<> target_; + // The quitter that opened the segment; null for a gate only. + quitter_opener* opener_ = nullptr; + // An unwind is still owed. Cleared when the top frame completes, + // when the unwind runs, and by the quitter when it destroys this. + bool active_ = false; + + ~promise_type() + { + // Destroyed instead of resumed: a closed executor discarded the + // owner's post. That is teardown, whatever the cause. + if(active_) + { + active_ = false; + // This frame is going away; the quitter must not destroy it + // again when the parent's unwind reaches it. + opener_->seg_.h_ = nullptr; + opener_->release_top_(*opener_).destroy(); + auto parent = parent_; + capy::abandon(parent); + } + } + + quitter_segment get_return_object() noexcept + { + return quitter_segment{ + std::coroutine_handle::from_promise(*this)}; + } + + std::suspend_always initial_suspend() noexcept { return {}; } + std::suspend_always final_suspend() noexcept { return {}; } // LCOV_EXCL_LINE the body never ends + void return_void() noexcept {} // LCOV_EXCL_LINE the body never ends + void unhandled_exception() noexcept { std::terminate(); } // LCOV_EXCL_LINE the body never throws + + std::coroutine_handle<> handle() noexcept + { + return std::coroutine_handle::from_promise(*this); + } + + // Abandon the segment for a stop. The caller holds the waiting + // frame and touches nothing of it afterwards. + void stop() noexcept + { + frame_env_->owner->abandon_for_stop(); + } + + std::coroutine_handle<> decide() noexcept + { + if(opener_ && owner_.abandoned()) + { + active_ = false; + auto* op = opener_; + op->release_top_(*op).destroy(); + if(owner_.stopped()) + { + op->stopped_ = true; + return parent_.h; + } + auto parent = parent_; + capy::abandon(parent); + return std::noop_coroutine(); + } + if(quitter_stop_pending(frame_env_)) + { + stop(); + return std::noop_coroutine(); + } + return target_; + } +}; + +struct quitter_hop +{ + bool await_ready() const noexcept { return false; } + + auto await_suspend( + std::coroutine_handle h) noexcept + { + return detail::symmetric_transfer(h.promise().decide()); + } + + void await_resume() const noexcept {} +}; + +inline quitter_segment +make_quitter_segment() +{ + for(;;) + co_await quitter_hop{}; +} + } // namespace detail -/** Defers a coroutine body until awaited, then unwinds it early on a stop request. +/** Defers a coroutine body until awaited, and ends it on a stop request without resuming it. - When the stop token is triggered, the body is unwound at a - `co_await` instead of resuming, and RAII destructors run normally. - Await-effects below says at which one. The parent observes a "stopped" - completion via @ref promise_type::stopped. + A `quitter` is a @ref task whose author has chosen that a stop + request ends it. When the stop token fires, the quitter's frames are + not resumed: they are destroyed, from the top down, as an abandoned + chain is (see @ref abandon). Only destructors run. Nothing in the + body can observe or intercept the stop, not even `catch(...)`, and no + exception is thrown inside the quitter. The stop is reported to the + first coroutine above that is not a quitter, as a value: see + Await-returns. - Everything else — frame allocation, environment propagation, - symmetric transfer, move semantics — is identical to @ref task. + A quitter stops only quitter frames. A @ref task below a quitter + receives the stop request as usual, and returns through its own code + before the quitter is destroyed. A task above a quitter receives the + stopped value from `co_await`. A quitter awaited by a quitter is + stopped together with it. + + Everything else (frame allocation, environment propagation, + symmetric transfer, move semantics) is as for @ref task. @par Await-effects - Let `q` be a `quitter`. `co_await q` always suspends the awaiting - coroutine, then transfers control directly into the quitter's - coroutine body on the current thread; no executor operation is - posted. The quitter records the caller's environment (executor, stop - token, and frame allocator) by pointer rather than copying it. It - propagates that environment to every `co_await` inside the body. - - Unlike @ref task, the stop token is checked before the body's first - statement, before each awaited operation is started, and when an - operation that completed asynchronously resumes the body. If a stop - request is pending at those points, the body is not resumed. An - internal sentinel exception unwinds it instead, so RAII destructors - run, and the coroutine completes as stopped. - - An awaited operation that completes synchronously always has its - `await_resume` called, even if a stop request arrives at the same - time. Its `await_ready` or `await_suspend` may already have acquired - a resource, such as `async_mutex`'s lock, that only its - `await_resume` hands over. If that result is a @ref results holding - `cond::canceled` while a stop is pending, the operation saw the stop - first and acquired nothing, so the body is unwound there. Any other - result is handed to the body, and the stop takes effect at the next - `co_await`. An operation whose result is not a @ref results can + Let `q` be a `quitter`. If the stop token in the awaiting + environment has already been triggered, `co_await q` does not start + the body, and await-returns the stopped value. Otherwise it suspends + the awaiting coroutine and transfers control directly into the + quitter's body on the current thread; no executor operation is + posted. The quitter propagates the caller's executor, stop token and + frame allocator to every `co_await` inside the body. + + At every `co_await` in the body, the stop token is checked before + the awaited operation is started. An operation that completes + asynchronously does not resume the body directly: the stop token is + checked again first. If either check finds a stop, the body is not + resumed. The quitter's frames are destroyed on the executor, and + the coroutine that awaited the quitter is resumed with the stopped + value. When `run_async` started the quitter, it reports the stop to + its handlers instead, as Await-returns describes. + + An operation that completes without suspending always has + its `await_resume` called, because its `await_ready` or + `await_suspend` may already have acquired a resource, such as + `async_mutex`'s lock, that only its `await_resume` hands over. If + that result is a @ref results holding `cond::canceled` while a stop + is pending, the operation saw the stop first and acquired nothing, + so the quitter is stopped at that `co_await`. Any other result is + handed to the body, and the stop takes effect at the next + suspension. An operation whose result is not a @ref results can therefore hand the body one result it produced after seeing the stop. - The body runs until it returns, exits via an exception, or is unwound - by a stop request. Control then transfers directly back to the - awaiting coroutine, again without an executor operation. + When the body returns or exits via an exception, control transfers + directly back to the awaiting coroutine. @par Await-returns - The value the body passed to `co_return`, moved out of the quitter, - or nothing when `T` is `void`. - If the body exits via an unhandled exception, that exception is - rethrown instead. - - If the coroutine completed as stopped, the internal sentinel - exception is thrown instead of await-returning. Awaiting a stopped - `quitter` from another `quitter` therefore stops that one too. A - @ref task awaiting it sees the sentinel as an unhandled exception in - its own body. When a quitter is started by `run_async`, a stopped - completion reaches the error handler as the sentinel - `std::exception_ptr`, not the value handler. + The value the body passed to `co_return`, moved out of the quitter, + or nothing when `T` is `void`. If the body exits via an unhandled + exception, that exception is rethrown instead. + + If the quitter was stopped, the stopped value instead: + + @li for `results`: `make_error_code(error::canceled)` with + each `Ts` value-initialized; it compares equal to + `cond::canceled`. Every `Ts` must be default-constructible. + @li for `std::error_code`: `make_error_code(error::canceled)`. + @li for any other `T`, `void` included: no value; an exception of + an unspecified type derived from `std::system_error`, whose + `code()` compares equal to `cond::canceled`, is thrown. A `void` + return would read as success. Such a `quitter` cannot be used + where exceptions are disabled: use `quitter>` there. + + `co_await run(ex)(q)`, and the other @ref run overloads, yield the + same stopped value. A quitter started by `run_async` reports a stop + the same way. For `results` and `std::error_code`, the value + handler receives the stopped value. For any other `T`, the error + handler receives the exception. With no error handler, no handler + is called and the stop is discarded. This also holds when the + exception escapes a @ref task that awaited the quitter: the task is + unwound, and with no error handler `run_async` discards the stop. + Any other exception, a `std::system_error` with `cond::canceled` + included, still calls `std::terminate`. That includes a + `std::system_error` copy of the stop that a task throws. @par Await-postcondition - The quitter's coroutine has run to completion and is suspended at its - final suspend point; the body's RAII destructors have run. Exactly - one of the following holds: the body returned a value; the body - exited via an exception; or `handle().promise().stopped()` returns - `true`. When the body returned a value, the await moved it out, so a - quitter must not be awaited twice. + The quitter's coroutine has completed, has been destroyed by a + stop, or, when the stop was requested before it was awaited, was + never started. A quitter must not be awaited twice. @par Remarks - Supports _ContextAwaitable cancellation_. - - @tparam T The result type. Use `quitter<>` for `quitter`. - - @see @ref task, @ref ContextRunnable, @ref ContextAwaitable + Supports _ContextAwaitable cancellation_. A quitter stops through the + owner of its segment (@ref await_context::owner). A launcher that + starts a quitter in an environment with no owner cannot stop it: a + debug build asserts, and otherwise the quitter runs on as a + @ref task would. A stop that arrives while + the execution context is being shut down may end the quitter as a + teardown instead, with nothing delivered. Stop the work, wait for it + to finish, and only then destroy the context. + + A stop destroys quitter frames that are suspended at a `co_await`. + GCC 15.2, and GCC 16 before 2026-07-02, at `-O0`, miscompile such a + destruction when the `co_await` initializes a structured binding + with an element that is not trivially destructible: the destructor + of the never-constructed binding runs (GCC PR c++/124584). Bind the + result to a named variable, and destructure it after the + `co_await`: + + @code + auto r = co_await mtx.scoped_lock(); + auto& [ec, guard] = r; + @endcode + + @tparam T The result type. Use `quitter<>` for `quitter`. + + @see @ref task, @ref ContextRunnable, @ref ContextAwaitable, + @ref chain_owner */ template struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE quitter + : private detail::quitter_opener { - /** Stores `quitter`'s result and unwinds the body when the stop token fires. + /** Stores `quitter`'s result and routes its awaits through the segment's gate. This is the promise object the compiler associates with a `quitter` coroutine. It satisfies the coroutine promise requirements and participates in the I/O awaitable protocol via @ref context_awaitable_promise_base. Unlike @ref task::promise_type, its `transform_awaitable` checks the stop token before each - awaited result reaches the body. A pending stop request throws an - internal sentinel exception that unwinds to a "stopped" - completion. It is part of the coroutine machinery and is not - intended to be used directly by callers. + awaited operation starts. It hands the operation the segment's + gate as the continuation, not this coroutine. It is part of + the coroutine machinery and is not intended to be used directly + by callers. Result storage and `return_value`/`return_void` are provided by `detail::quitter_return_base`. @@ -179,55 +464,89 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE private: friend quitter; - enum class completion { running, value, exception, stopped }; + template + friend struct quitter; - union { std::exception_ptr ep_; }; - completion state_; + std::exception_ptr ep_; - public: - /// Construct the promise in the running state. - promise_type() noexcept - : state_(completion::running) + // The gate of the segment this frame runs in. + detail::quitter_segment::promise_type* seg_ = nullptr; + + // The gate this frame owns, when a launcher started it. + detail::quitter_segment own_gate_{}; + + // This frame opened its segment. + bool top_ = false; + + void open_gate() { + own_gate_ = detail::make_quitter_segment(); + seg_ = &own_gate_.h_.promise(); + seg_->frame_env_ = this->environment(); } - /// Destroy the promise, releasing any stored exception. + public: + /// Construct the promise. + promise_type() noexcept = default; + + /// Destroy the promise, and the gate it owns, if any. ~promise_type() { - if(state_ == completion::exception || - state_ == completion::stopped) - ep_.~exception_ptr(); + if(own_gate_.h_) + own_gate_.h_.destroy(); } - /** Return a non-null exception_ptr when the coroutine threw - or was stopped. - - Stopped quitters report the sentinel - stop_requested_exception so that run_async routes to - the error handler instead of accessing a non-existent - result. + /** Return the exception the body exited with, if any. @return The stored exception if the coroutine exited via an - exception or was stopped, otherwise a null - `std::exception_ptr`. + exception, otherwise a null `std::exception_ptr`. */ std::exception_ptr exception() const noexcept { - if(state_ == completion::exception || - state_ == completion::stopped) - return ep_; - return {}; + return ep_; } - /** True when the coroutine was stopped via the stop token. + /** Return what a stopped `quitter` delivers. - @return `true` if the body was unwound by a stop request; - `false` if it returned a value or exited via any other - exception. + Launchers that start a quitter directly call this when its + segment was abandoned for a stop: see + @ref chain_owner::stopped. Its result, or the exception it + throws, is reported in place of the coroutine's own result. + + @return `make_error_code(error::canceled)` with value-initialized + payloads for `results`, or + `make_error_code(error::canceled)` for `std::error_code`. + + @throws std::system_error For any other `T`, `void` included, + with a `code()` that compares equal to `cond::canceled`. + */ + static T stopped_result() + { + return detail::stopped_value::get(); + } + + /** Return the handle a launcher resumes to start the quitter. + + Launchers that start a quitter directly resume this handle, + not the coroutine's own, so the stop is checked when the body + would start rather than when the launcher was called. The + handle is the quitter's gate, which either starts the body + or, if a stop was requested by then, abandons the segment for + the stop: see @ref chain_owner::abandon_for_stop. + + @par Preconditions + The coroutine has not started, and its environment is set. + + @return The handle of the quitter's gate. + + @throws std::bad_alloc If the gate cannot be allocated. */ - bool stopped() const noexcept + std::coroutine_handle<> launch_handle() { - return state_ == completion::stopped; + open_gate(); + seg_->target_ = + std::coroutine_handle::from_promise(*this); + return seg_->handle(); } /** Return the owning `quitter` for this coroutine. @@ -246,11 +565,11 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE /** Return the initial-suspend awaiter. The coroutine always suspends at the initial suspend point, - so the body does not start until the quitter is awaited. When - the body is resumed, the awaiter restores the thread-local - frame allocator. It then throws the internal sentinel - exception if stop is already requested, so the body never - runs and the coroutine completes as stopped. + so the body does not start until the quitter is awaited or + started. When the body is resumed, the awaiter restores the + thread-local frame allocator. A quitter started directly by a + launcher that did not call @ref launch_handle creates its + gate at that point. @return An awaiter that suspends unconditionally. */ @@ -269,14 +588,15 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE { } - // Potentially-throwing: checks the stop token before - // the coroutine body executes its first statement. + // Allocates the gate of a quitter that a launcher started + // without launch_handle(); a failure is reported as the + // body's exception. void await_resume() const { set_current_frame_allocator( p_->environment()->frame_allocator); - if(p_->environment()->stop_token.stop_requested()) - throw detail::stop_requested_exception{}; + if(!p_->seg_) + p_->open_gate(); } }; return awaiter{this}; @@ -305,6 +625,10 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE std::coroutine_handle<> await_suspend( std::coroutine_handle<>) const noexcept { + // The top frame completed: its segment owes no + // unwind. + if(p_->top_) + p_->seg_->active_ = false; return p_->continuation(); } @@ -316,49 +640,23 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE /** Capture the in-flight exception from the coroutine body. Called by the compiler when the coroutine body exits via an - unhandled exception. The internal stop sentinel is recorded as - a stopped completion; any other exception is recorded as an - exception completion. The stored exception is surfaced (or - routed to the error handler) when the quitter is awaited or run. + unhandled exception. The exception is rethrown when the + quitter is awaited, or routed to the error handler when it is + run. */ - void unhandled_exception() + void unhandled_exception() noexcept { - try - { - throw; - } - catch(detail::stop_requested_exception const&) - { - // Store the exception_ptr so that run_async's - // invoke_impl routes to the error handler - // instead of accessing a non-existent result. - new (&ep_) std::exception_ptr( - std::current_exception()); - state_ = completion::stopped; - } - catch(...) - { - new (&ep_) std::exception_ptr( - std::current_exception()); - state_ = completion::exception; - } + ep_ = std::current_exception(); } - //------------------------------------------------------ - // transform_awaitable — the key difference from task - //------------------------------------------------------ - /** Awaiter wrapping a nested `co_await` of an @ref ContextAwaitable. Forwards the environment to the inner awaitable's - environment-taking `await_suspend` and restores the + environment-taking `await_suspend`, and restores the thread-local frame allocator before the body resumes. Unlike - `task`'s, it also checks the stop token. A stop pending before - the wrapped awaitable is touched, or on resumption after an - asynchronous completion, throws the internal sentinel. The - body then unwinds before it observes the operation's result. - A synchronous completion is always handed over through the - wrapped awaitable's `await_resume`. + `task`'s, it checks the stop token before the inner awaitable + is touched. It passes the inner awaitable the segment's gate + as the continuation, not this coroutine. @tparam Awaitable The awaitable being transformed. */ @@ -371,207 +669,307 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE /// The promise of the coroutine performing the `co_await`. promise_type* p_; - /// True when a stop was pending at the `co_await`, so the - /// wrapped awaitable was never touched. - bool skipped_ = false; - - /// True when the wrapped awaitable completed synchronously, so - /// its `await_resume` must run to hand over what it acquired. - bool completed_ = false; + /// The wrapped awaitable's await-result type. + using result_type = decltype( + std::declval&>().await_resume()); + + /// True when the await-result is a @ref results, which is + /// checked for a canceled stop after a synchronous completion. + static constexpr bool checks_canceled = + detail::is_results_v; + + /// True when the checks that may call the wrapped awaitable's + /// `await_resume` early cannot throw. + static constexpr bool checks_noexcept = + !checks_canceled || noexcept( + std::declval&>().await_resume()); + + /// Empty stand-in for @ref result_ when nothing is checked. + struct no_result {}; + + /// The result of a synchronous completion, taken early by + /// @ref stops_on_sync_completion and handed over by + /// @ref await_resume. + std::conditional_t, no_result> result_{}; + + /// True when the stop takes effect at this `co_await` without + /// suspending: a stop was pending before the wrapped awaitable + /// was touched, or it completed synchronously with a result + /// canceled by that stop. + bool stop_here_ = false; + + /** Take the result of a synchronous completion, and report + whether the stop takes effect here instead. + + The wrapped awaitable's `await_resume` is always called, + because its `await_ready` or `await_suspend` may already + have acquired something that only `await_resume` hands + over. A @ref results holding `cond::canceled` while our + stop is pending saw the stop first and acquired nothing, + so the quitter can be abandoned. Any other result is kept + for @ref await_resume. A result that is not a + @ref results is not taken early. + + @return `true` if the quitter is abandoned at this + `co_await`. + */ + bool stops_on_sync_completion() noexcept(checks_noexcept) + { + if constexpr (checks_canceled) + { + result_.emplace(a_.await_resume()); + // The stop check first: comparing with + // cond::canceled goes through the error category's + // virtual calls, and a stop is rarely pending. + return detail::quitter_stop_pending(p_->environment()) && + std::get<0>(*result_) == cond::canceled; + } + else + { + return false; + } + } /** Report whether the `co_await` can continue without suspending. The stop token is checked first. If a stop is pending, the - wrapped awaitable is never touched: this returns `true`, and - @ref await_resume throws. Otherwise the wrapped awaitable's - own `await_ready` decides. A `true` result there means it - completed synchronously, so @ref await_resume calls its - `await_resume` even if a stop arrives afterwards. That hands - over anything it acquired, such as `async_mutex`'s lock. + wrapped awaitable is never touched: this returns `false`, + and @ref await_suspend ends the quitter. Otherwise the + wrapped awaitable's own `await_ready` decides. A `true` + result means it completed synchronously. Its + `await_resume` then hands over its result, even if a stop + arrives afterwards, unless that result is a @ref results + canceled by the stop (see @ref stops_on_sync_completion). Where `BOOST_CAPY_SYMMETRIC_TRANSFER_WORKAROUND` is on, this always returns `false`, and @ref await_suspend does both steps instead. See `detail::await_ready_then_suspend`. - @return `true` if no suspension is needed. Always `false` - where the workaround is on. + @return `true` if no suspension is needed. */ - bool await_ready() noexcept + bool await_ready() noexcept(checks_noexcept) { #if BOOST_CAPY_SYMMETRIC_TRANSFER_WORKAROUND return false; #else - if(p_->environment()->stop_token.stop_requested()) + if(detail::quitter_stop_pending(p_->environment())) { - skipped_ = true; - return true; + stop_here_ = true; + return false; } - completed_ = a_.await_ready(); - return completed_; + if(!a_.await_ready()) + return false; + stop_here_ = stops_on_sync_completion(); + return !stop_here_; #endif } - /** Restore the frame allocator, check for stop, then resume the - wrapped awaitable. - - Reinstalls the thread-local frame allocator from the stored - environment. The internal sentinel exception is then thrown - in three cases: a stop was pending before the wrapped - awaitable was touched; a stop is pending now and the - wrapped awaitable completed asynchronously; or a stop is - pending now and the wrapped awaitable's result is a - @ref results holding `cond::canceled`. The body therefore - never observes that operation's result, and unwinds through - its RAII destructors to a stopped completion. A wrapped - awaitable that completed synchronously always has its - `await_resume` called, and any other result it gives is - returned, with the stop taking effect at the next - `co_await`. This is the one place `quitter` differs from - @ref task::promise_type::transform_awaiter. - - After an asynchronous completion with a stop pending, the - operation's result is dropped. Releasing anything it held is - then the wrapped awaiter's destructor's job: `async_mutex` - releases a lock it granted, and `async_waker` drops the - wakeup. The body is unwinding, so nothing observes the loss. + /** Restore the frame allocator, then return the wrapped + awaitable's result. - @return The wrapped awaitable's await-result, forwarded - unchanged, when no sentinel is thrown. + The body is resumed only when the wrapped awaitable + completed synchronously, or completed through the gate + with no stop requested. - @par Exception Safety - Throws the library's internal stop sentinel in the three - cases above. The wrapped awaitable's `await_resume` is not - called in the first two; in the third, its canceled result - is dropped. + @return The wrapped awaitable's await-result, forwarded + unchanged, or the result already taken from it. */ decltype(auto) await_resume() { set_current_frame_allocator( p_->environment()->frame_allocator); - if(skipped_ || - (!completed_ && - p_->environment()->stop_token.stop_requested())) - throw detail::stop_requested_exception{}; - using R = decltype(a_.await_resume()); - if constexpr (detail::is_results_v) + if constexpr (checks_canceled) { - // A result canceled by our own stop acquired - // nothing, so the stop can take effect here. - R r = a_.await_resume(); - if(std::get<0>(r) == cond::canceled && - p_->environment()->stop_token.stop_requested()) - throw detail::stop_requested_exception{}; - return r; - } - else - { - return a_.await_resume(); + if(result_) + return result_type(std::move(*result_)); } + return a_.await_resume(); } - /** Suspend by calling the wrapped awaitable with the - environment. - - This is the plain `await_suspend` the compiler calls for the - nested `co_await`. It forwards to the wrapped awaitable's - @ref ContextAwaitable overload, supplying the promise's stored - environment as the second argument. It keeps the wrapped - awaitable's suspension decision, whatever form it takes. - When that decision is to continue without suspending, the - wrapped awaitable completed synchronously, and - @ref await_resume will call its `await_resume`. Returning - `h` itself counts as continuing without suspending. + /** Start the wrapped awaitable with the gate as its + continuation, or end the quitter on a pending stop. + + If a stop is pending, the segment is abandoned for the + stop and this coroutine stays suspended until it is + destroyed. Otherwise this coroutine becomes the gate's + target, and the wrapped awaitable's @ref ContextAwaitable + overload is called with the gate's handle and the + promise's environment. If that call completes without + suspending (returning `false`, or the gate's own handle), + this coroutine continues to @ref await_resume without + passing through the gate, unless its result is a + @ref results canceled by the stop (see + @ref stops_on_sync_completion). @param h The coroutine performing the `co_await`. - @return Whatever the wrapped awaitable's `await_suspend` - returns. When that is a `std::coroutine_handle<>`, the - handle is routed through `detail::symmetric_transfer`. - Where `BOOST_CAPY_SYMMETRIC_TRANSFER_WORKAROUND` is on, - this function instead returns `bool`. It first checks the - stop token, as @ref await_ready does where the workaround is - off, and returns `false` without touching the wrapped - awaitable if a stop is pending. Otherwise it returns the - result of `detail::await_ready_then_suspend`. Where the - workaround is off, a returned handle is passed back - unchanged for symmetric transfer. + @return Where the workaround is off, the handle to resume: + `h` to continue, a handle the wrapped awaitable returned, + or `std::noop_coroutine()`. Where + `BOOST_CAPY_SYMMETRIC_TRANSFER_WORKAROUND` is on, `false` + to continue and `true` to stay suspended; the stop check + and the wrapped awaitable's `await_ready` both run here, + through `detail::await_ready_then_suspend`. */ template auto await_suspend( - std::coroutine_handle h) noexcept + std::coroutine_handle h) noexcept(checks_noexcept) { - // completed_ is written only where nothing else can have - // resumed this frame: a frame that really suspended may - // already be running, or gone, on another thread. + // After the wrapped awaitable's await_suspend, the gate + // may already have run and destroyed this frame on + // another thread: only locals are used from then on, + // except after a synchronous completion, which leaves + // the frame to us. + auto* seg = p_->seg_; + auto const* env = p_->environment(); #if BOOST_CAPY_SYMMETRIC_TRANSFER_WORKAROUND - if(p_->environment()->stop_token.stop_requested()) + if(detail::quitter_stop_pending(env)) { - skipped_ = true; - return false; + seg->stop(); + return true; } - return detail::await_ready_then_suspend( - a_, h, p_->environment(), &completed_); + seg->target_ = h; + if(detail::await_ready_then_suspend( + a_, seg->handle(), env)) + return true; + if(stops_on_sync_completion()) + { + seg->stop(); + return true; + } + return false; #else - using R = decltype( - a_.await_suspend(h, p_->environment())); - if constexpr (std::is_same_v< - R, std::coroutine_handle<>>) + if(stop_here_) { - std::coroutine_handle<> next = - a_.await_suspend(h, p_->environment()); - // Returning h means no one else can resume this - // frame: a synchronous completion. -// GCC falsely warns that writing completed_ overruns the noop -// coroutine's frame: when the wrapped await_suspend returns -// std::noop_coroutine(), it assumes the branch below can be taken -// and treats this frame as the noop frame. A noop handle never -// equals h, so the branch is dead on that path. -#if defined(__GNUC__) && !defined(__clang__) -#pragma GCC diagnostic push -#pragma GCC diagnostic ignored "-Warray-bounds" -#endif - if(next == h) - completed_ = true; -#if defined(__GNUC__) && !defined(__clang__) -#pragma GCC diagnostic pop -#endif - return detail::symmetric_transfer(next); + seg->stop(); + return std::coroutine_handle<>(std::noop_coroutine()); } - else if constexpr (std::is_same_v) + seg->target_ = h; + std::coroutine_handle<> gate = seg->handle(); + using R = decltype(a_.await_suspend(gate, env)); + if constexpr (std::is_same_v) { - if(!a_.await_suspend(h, p_->environment())) - { - completed_ = true; - return false; - } - return true; + if(!a_.await_suspend(gate, env)) + return continue_or_stop(h); + return std::coroutine_handle<>(std::noop_coroutine()); + } + else if constexpr (std::is_void_v) + { + a_.await_suspend(gate, env); + return std::coroutine_handle<>(std::noop_coroutine()); } else { - return a_.await_suspend( - h, p_->environment()); + std::coroutine_handle<> r = a_.await_suspend(gate, env); + if(r == gate) + return continue_or_stop(h); + return r; } #endif } + +#if !BOOST_CAPY_SYMMETRIC_TRANSFER_WORKAROUND + /** After a synchronous completion in @ref await_suspend, + continue to @ref await_resume, or end the quitter. + + @param h The coroutine performing the `co_await`. + + @return `h`, or `std::noop_coroutine()` when the quitter is + abandoned here (see @ref stops_on_sync_completion). + */ + std::coroutine_handle<> + continue_or_stop(std::coroutine_handle<> h) + noexcept(checks_noexcept) + { + if(!stops_on_sync_completion()) + return h; + p_->seg_->stop(); + return std::noop_coroutine(); + } +#endif + }; + + /** Awaiter for a nested `co_await` of a quitter. + + The awaited quitter joins this coroutine's segment instead of + opening one: it runs in the same environment and gate, and a + stop ends both together. Its completion transfers straight + back to this coroutine. + + @tparam Q The awaited quitter type. + */ + template + struct join_awaiter + { + /// The awaited quitter, stored by value. + Q q_; + + /// The promise of the coroutine performing the `co_await`. + promise_type* p_; + + /// Return `false`; the joined quitter has not started. + bool await_ready() const noexcept + { + return false; + } + + /** Start the joined quitter, or end the segment on a pending + stop. + + @param h The coroutine performing the `co_await`. + + @return The joined quitter's handle, for symmetric + transfer, or `std::noop_coroutine()` after a stop. Where + the workaround is on, `void`. + */ + template + auto await_suspend(std::coroutine_handle h) noexcept + { + auto const* env = p_->environment(); + if(detail::quitter_stop_pending(env)) + { + p_->seg_->stop(); + return detail::symmetric_transfer( + std::noop_coroutine()); + } + return detail::symmetric_transfer( + q_.join(h, p_->seg_, env)); + } + + /** Restore the frame allocator, then return the joined + quitter's result. + + @return The joined quitter's await-result. + */ + decltype(auto) await_resume() + { + set_current_frame_allocator( + p_->environment()->frame_allocator); + return q_.await_resume(); + } }; /** Transform a nested awaitable before `co_await`. - Wraps an @ref ContextAwaitable in a @ref transform_awaiter so the - coroutine's environment is propagated into it and the stop - token is checked on resumption. A diagnostic is emitted if the - awaitable does not satisfy @ref ContextAwaitable. + A quitter is wrapped in a @ref join_awaiter. Any other + @ref ContextAwaitable is wrapped in a @ref transform_awaiter. + A diagnostic is emitted for anything else. @param a The awaitable expression from `co_await a`. - @return A @ref transform_awaiter wrapping `a`. + @return The awaiter wrapping `a`. */ template auto transform_awaitable(Awaitable&& a) { using A = std::decay_t; - if constexpr (ContextAwaitable) + if constexpr (detail::is_quitter::value) + { + return join_awaiter{std::forward(a), this}; + } + else if constexpr (ContextAwaitable) { return transform_awaiter{ std::forward(a), this}; @@ -586,9 +984,10 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE /** Handle to the owned coroutine frame. - Null when the quitter is empty (for example after a move or after - @ref release). Prefer @ref handle to read this; the member is - public for use by the coroutine machinery. + Null when the quitter is empty (for example after a move, after + @ref release, or once a stop has destroyed the frame). Prefer + @ref handle to read this; the member is public for use by the + coroutine machinery. */ std::coroutine_handle h_; @@ -618,54 +1017,79 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE return false; } - /** Return the result, rethrow exception, or propagate stop. - - When stopped, throws stop_requested_exception so that a - parent quitter also stops. A parent task sees this - as an unhandled exception — by design. + /** Return the result, rethrow the body's exception, or deliver the stopped value. @return The result value for non-void `T`, moved out of the - quitter; otherwise `void`. + quitter, or the stopped value if the quitter was stopped. @par Exception Safety - If the coroutine was stopped, the library's internal stop sentinel - is thrown. If the body exited via any other exception, that - exception is rethrown. + If the body exited via an exception, that exception is rethrown. + If the quitter was stopped and `T` is not `results` or + `std::error_code`, a `std::system_error` whose `code()` + compares equal to `cond::canceled` is thrown. */ - auto await_resume() + T await_resume() { lent_ = false; - if(h_.promise().stopped()) - throw detail::stop_requested_exception{}; - if(h_.promise().state_ == promise_type::completion::exception) - std::rethrow_exception(h_.promise().ep_); + if(stopped_) + return promise_type::stopped_result(); + auto& p = h_.promise(); + if(p.ep_) + std::rethrow_exception(p.ep_); if constexpr (! std::is_void_v) - return std::move(*h_.promise().result_); - else - return; + return std::move(*p.result_); } - /** Start execution with the caller's context. + /** Open a segment and start the quitter with the caller's context. + + If the stop token in `env` has already been triggered, the body + is not started. `cont` is returned, so the caller resumes at once + with the stopped value. - Stores `cont` as the continuation to resume on completion. - Stores `env` as the execution environment propagated to nested - `co_await` expressions. Then transfers control into the quitter's - coroutine body via the returned handle. + Otherwise the quitter opens a segment of its own. The segment's + owner destroys the quitter's frames if the segment is abandoned. + Its gate is what the quitter's awaits complete through. The body + runs with `env`'s executor, stop token and frame allocator, and + the segment's owner. Control then transfers into the body. @param cont The awaiting coroutine to resume when the quitter - completes. + completes or is stopped. + + @param env The execution environment. It must outlive the + quitter. - @param env The execution environment (executor, stop token, and - frame allocator). It must outlive the quitter. + @return The quitter's coroutine handle for symmetric transfer, or + `cont`. - @return The quitter's coroutine handle, for symmetric transfer. + @throws std::bad_alloc If the segment's frame cannot be + allocated. When the awaiting coroutine is a @ref task, its + awaiter's `await_suspend` is `noexcept`, so `std::terminate` is + called instead. */ std::coroutine_handle<> await_suspend( std::coroutine_handle<> cont, await_context const* env) { - h_.promise().set_continuation(cont); - h_.promise().set_environment(env); + if(env->stop_token.stop_requested()) + { + stopped_ = true; + return cont; + } + seg_ = detail::make_quitter_segment(); + auto& sp = seg_.h_.promise(); + sp.owner_.bind(seg_.h_, env->executor); + sp.env_ = *env; + sp.env_.owner = &sp.owner_; + sp.frame_env_ = &sp.env_; + sp.parent_.h = cont; + sp.parent_.owner = env->owner; + sp.opener_ = this; + sp.active_ = true; + auto& p = h_.promise(); + p.seg_ = &sp; + p.top_ = true; + p.set_environment(&sp.env_); + p.set_continuation(cont); lent_ = true; return h_; } @@ -680,13 +1104,21 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE move-assigning over the quitter object itself while its frame is lent is a different operation, and calls `std::terminate`.) The quitter's lifetime is normally managed by `run_async`, `run`, - or the awaiting parent. For cooperative cancellation, use - `std::stop_token`. + or the awaiting parent. @note The operation, queue or primitive holding a suspended coroutine gives it up by passing the continuation it keeps to @ref abandon. `handle()` belongs to the owning quitter object. + @note To run the quitter for an awaiting coroutine, call its + `await_suspend`, which opens a segment for it. Do not resume + this handle with the awaiting coroutine's environment: a stop + then abandons the awaiting coroutine's segment, and a @ref task + chain in it is torn down with no handler called. When + `run_async` or `run` owns that segment, a debug build asserts. + A launcher that resumes the handle itself must own the segment: + see @ref await_context::owner. + @return The coroutine handle. */ std::coroutine_handle handle() const noexcept @@ -727,6 +1159,9 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE /** Construct by moving, transferring ownership. + @par Preconditions + `other` is not being awaited. + @par Postconditions `other` is empty and must not be awaited. @@ -735,6 +1170,7 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE quitter(quitter&& other) noexcept : h_(std::exchange(other.h_, nullptr)) { + release_top_ = other.release_top_; } /** Assign by moving, transferring ownership. @@ -745,6 +1181,7 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE @par Preconditions The frame being replaced is not lent: no parent is suspended awaiting it. Replacing a lent frame calls `std::terminate`. + `other` is not being awaited. @par Postconditions `other` is empty and must not be awaited. @@ -759,32 +1196,60 @@ struct [[nodiscard]] BOOST_CAPY_CORO_AWAIT_ELIDABLE { destroy_frame(); h_ = std::exchange(other.h_, nullptr); + stopped_ = false; } return *this; } private: - // Set while a parent is suspended awaiting this quitter. - bool lent_ = false; + template + friend struct quitter; explicit quitter(std::coroutine_handle h) : h_(h) { + release_top_ = &release_top; + } + + static std::coroutine_handle<> + release_top(detail::quitter_opener& o) noexcept + { + return std::exchange(static_cast(o).h_, nullptr); + } + + // Start as a quitter joining the segment of the quitter `parent`. + std::coroutine_handle<> join( + std::coroutine_handle<> parent, + detail::quitter_segment::promise_type* seg, + await_context const* env) noexcept + { + auto& p = h_.promise(); + p.seg_ = seg; + p.set_environment(env); + p.set_continuation(parent); + lent_ = true; + return h_; } void destroy_frame() noexcept { - if(!h_) - return; - if(lent_ && !h_.done()) + if(h_) + { + if(lent_ && !h_.done()) + { + // Only the unwind of an abandoned chain destroys a frame + // that is still awaited. + auto* owner = h_.promise().environment()->owner; + if(!owner || !owner->abandoned()) + std::terminate(); // LCOV_EXCL_LINE reached only in a death test's child process + } + std::exchange(h_, nullptr).destroy(); + } + if(seg_.h_) { - // Only the unwind of an abandoned chain destroys a frame - // that is still awaited. - auto* owner = h_.promise().environment()->owner; - if(!owner || !owner->abandoned()) - std::terminate(); // LCOV_EXCL_LINE reached only in a death test's child process + seg_.h_.promise().active_ = false; + std::exchange(seg_.h_, nullptr).destroy(); } - std::exchange(h_, nullptr).destroy(); lent_ = false; } }; diff --git a/test/unit/ex/abandon.cpp b/test/unit/ex/abandon.cpp index 730f9e748..d73162860 100644 --- a/test/unit/ex/abandon.cpp +++ b/test/unit/ex/abandon.cpp @@ -291,6 +291,34 @@ struct counting_executor } }; +// Counts posts and records the thread that made the last one. +struct recording_executor +{ + int* posts; + std::thread::id* poster; + + bool operator==(recording_executor const& o) const noexcept + { + return posts == o.posts; + } + test_io_context& context() const noexcept + { + return default_test_io_context(); + } + void on_work_started() const noexcept {} + void on_work_finished() const noexcept {} + std::coroutine_handle<> dispatch(continuation& c) const + { + post(c); + return std::noop_coroutine(); + } + void post(continuation&) const + { + ++*posts; + *poster = std::this_thread::get_id(); + } +}; + } // namespace struct abandon_test @@ -597,6 +625,58 @@ struct abandon_test BOOST_TEST_EQ(posts, 1); } + // An abandon caused by a stop is recorded as one. + void testAbandonForStopSetsStopped() + { + int posts = 0; + counting_executor ex{&posts}; + chain_owner owner; + owner.bind(std::noop_coroutine(), ex); + owner.abandon_for_stop(); + BOOST_TEST(owner.abandoned()); + BOOST_TEST(owner.stopped()); + BOOST_TEST_EQ(posts, 1); + } + + // A teardown abandon is not a stop. + void testAbandonLeavesStoppedFalse() + { + int posts = 0; + counting_executor ex{&posts}; + chain_owner owner; + owner.bind(std::noop_coroutine(), ex); + owner.abandon(); + BOOST_TEST(owner.abandoned()); + BOOST_TEST(!owner.stopped()); + owner.abandon_for_stop(); + BOOST_TEST(!owner.stopped()); + BOOST_TEST_EQ(posts, 1); + } + + // A stop and a teardown racing on one segment unwind it once, and + // the cause is whichever came first. + void testStopAndTeardownRace() + { + for(int i = 0; i < 10000; ++i) + { + int posts = 0; + std::thread::id poster; + recording_executor ex{&posts, &poster}; + chain_owner owner; + owner.bind(std::noop_coroutine(), ex); + std::thread::id stop_thread; + std::thread teardown([&]{ owner.abandon(); }); + std::thread stop([&]{ owner.abandon_for_stop(); }); + stop_thread = stop.get_id(); + teardown.join(); + stop.join(); + BOOST_TEST_EQ(posts, 1); + BOOST_TEST_EQ(owner.stopped(), poster == stop_thread); + if(posts != 1) + break; + } + } + // A continuation with no owner is destroyed directly. void testAbandonWithoutOwnerDestroys() { @@ -622,6 +702,9 @@ struct abandon_test testUnwindAcrossRunBoundary(); testSecondAbandonDoesNothing(); testAbandonWithoutOwnerDestroys(); + testAbandonForStopSetsStopped(); + testAbandonLeavesStoppedFalse(); + testStopAndTeardownRace(); } }; diff --git a/test/unit/quitter.cpp b/test/unit/quitter.cpp index 7dd9447bd..90ee5382e 100644 --- a/test/unit/quitter.cpp +++ b/test/unit/quitter.cpp @@ -17,7 +17,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -32,14 +34,21 @@ #include +#include #include +#include +#include #include +#include +#include +#include #include #include #include #include #include #include +#include // GCC gives false positive -Wmaybe-uninitialized on structured bindings // via the tuple protocol inside coroutine frames. @@ -57,6 +66,26 @@ static_assert(ContextRunnable>); struct quitter_test { + // True when `ep` holds what a stopped quitter<> or plain-T quitter + // reports: a std::system_error that compares equal to + // cond::canceled. + static bool + is_stop(std::exception_ptr ep) + { + try + { + std::rethrow_exception(ep); + } + catch(std::system_error const& e) + { + return e.code() == cond::canceled; + } + catch(...) + { + return false; + } + } + // Assigning over a quitter that is not awaited destroys the frame // it held. void testMoveAssignDestroysHeldFrame() @@ -230,10 +259,9 @@ struct quitter_test void testStopBeforeFirstAwait() { - // When stop is already requested, initial_suspend throws - // before the coroutine body starts. Body locals are never - // constructed, so dtor_count stays 0. The stopped state - // routes to the error handler via exception(). + // When stop is already requested, run_async never starts the + // body. Body locals are never constructed, so dtor_count stays + // 0. A stopped quitter<> reaches the error handler. int dispatch_count = 0; test_executor ex(dispatch_count); std::stop_source source; @@ -243,10 +271,9 @@ struct quitter_test bool got_stopped = false; run_async(ex, source.get_token(), - [](){ BOOST_TEST(false); }, - [&](std::exception_ptr ep) { - got_stopped = (ep != nullptr); - })(quitter_with_raii(dtor_count)); + []() { BOOST_TEST(false); }, + [&](std::exception_ptr ep) { got_stopped = is_stop(ep); })( + quitter_with_raii(dtor_count)); BOOST_TEST(got_stopped); // Body never started, so no destructors ran @@ -262,8 +289,8 @@ struct quitter_test // A stop that stop_only_awaitable sees in await_suspend makes it // return the awaiting coroutine: a synchronous completion, whose // result quitter inspects. So await_resume reports that stop as - // canceled, as capy's own awaitables do, and quitter unwinds the - // body there, just as for a stop that arrives while suspended. + // canceled, as capy's own awaitables do, and quitter is abandoned + // there, just as for a stop that arrives while suspended. // // `registered`, when given, is counted down once the awaitable has // registered for the stop, so a stop the test requests after @@ -722,13 +749,14 @@ struct quitter_test run_async(ex, source.get_token(), [&]() { completed = true; }, - [&](std::exception_ptr) { ++stopped; })( + [&](std::exception_ptr ep) { stopped += is_stop(ep); })( co_awaits_bool_park(parked, resumed)); BOOST_TEST(parked); source.request_stop(); parked.resume(); + // A stopped quitter<> reaches the error handler. BOOST_TEST(!resumed); BOOST_TEST(!completed); BOOST_TEST_EQ(stopped, 1); @@ -781,10 +809,10 @@ struct quitter_test // Two quitter> children inside when_all. // Both block on parked_until_stop. when_all creates a // child stop_source; when the parent stop fires, when_all - // propagates it. Each quitter child intercepts the stop - // in transform_awaiter::await_resume and short-circuits - // via stop_requested_exception — it never reaches the - // co_return. Verify both stop and when_all completes. + // propagates it. Each quitter child is abandoned by its + // gate, so it never reaches the co_return, and delivers + // canceled to its branch. Verify both stop and when_all + // completes. //---------------------------------------------------------- static quitter> @@ -793,8 +821,7 @@ struct quitter_test std::latch* registered = nullptr) { co_await parked_until_stop{registered}; - // If quitter's transform_awaiter intercepted the stop, - // we never reach here. + // A stopped quitter is abandoned, so we never reach here. reached_co_return = true; co_return results{ make_error_code(error::canceled), 0}; @@ -828,8 +855,8 @@ struct quitter_test source.request_stop(); done.wait(); - // Both quitter children were stopped by transform_awaiter - // before reaching co_return. + // Both quitter children were abandoned before reaching + // co_return. BOOST_TEST(!child1_returned); BOOST_TEST(!child2_returned); } @@ -837,40 +864,58 @@ struct quitter_test //---------------------------------------------------------- // 8. Stop propagation with when_any // - // Two quitter children. One succeeds immediately, - // when_any stops the sibling. The sibling quitter - // intercepts the stop and exits cleanly. + // Two quitter children. One succeeds once the other is + // waiting, and when_any stops the sibling. The sibling + // quitter is abandoned and delivers canceled. //---------------------------------------------------------- + // Succeeds once the sibling has registered for the stop, so the + // stop always finds the sibling suspended. static quitter> - quitter_success_size(std::size_t n) + quitter_success_after_registered( + std::size_t n, std::latch& registered) { + registered.wait(); co_return results{std::error_code(), n}; } + static quitter> + quitter_pending_size_registered( + bool& reached_co_return, std::latch& registered) + { + co_await parked_until_stop{®istered}; + // A stopped quitter is abandoned, so we never reach here. + reached_co_return = true; + co_return results{ + make_error_code(error::canceled), 0}; + } + void testWhenAnyWithStop() { - // One child succeeds immediately. when_any stops - // the pending sibling quitter. The sibling must - // be intercepted by transform_awaiter (never reach - // co_return). + // One child succeeds once its sibling waits. when_any + // stops the pending sibling quitter, which is abandoned + // and never reaches co_return. { thread_pool pool(2); std::latch done(1); + std::latch registered(1); bool sibling_returned = false; + // The sibling comes first, so the winner never blocks the + // thread that would start it. auto outer = [&]() -> task<> { auto result = co_await when_any( - quitter_success_size(42), - quitter_pending_size(sibling_returned)); + quitter_pending_size_registered( + sibling_returned, registered), + quitter_success_after_registered(42, registered)); // Variadic when_any returns // variant. - // Index 1 = first child won. - BOOST_TEST(result.index() == 1); - if(result.index() == 1) - BOOST_TEST_EQ(std::get<1>(result), + // Index 2 = second child won. + BOOST_TEST(result.index() == 2); + if(result.index() == 2) + BOOST_TEST_EQ(std::get<2>(result), std::size_t(42)); }; @@ -959,7 +1004,7 @@ struct quitter_test auto elapsed = std::chrono::steady_clock::now() - start; // Should complete promptly BOOST_TEST(elapsed < std::chrono::seconds(1)); - // Quitter intercepted the stop — body did not continue + // The quitter was abandoned: the body did not continue BOOST_TEST(!reached_end); } @@ -1062,9 +1107,9 @@ struct quitter_test BOOST_TEST(completed); } - // A stop request while parked: quitter's await_resume throws its - // stop sentinel, which the catch(...) in the body takes, as in - // the documentation's catch(...) pitfall. + // A stop request while parked: the frame is abandoned, never + // resumed, so the catch(...) in the body cannot take the stop. A + // stopped quitter<> reaches the error handler. void testCatchStopAfterRmwAwaitReady() { @@ -1076,12 +1121,12 @@ struct quitter_test std::atomic state{0}; bool marker = false; bool caught = false; - bool escaped = false; + bool stopped = false; bool completed = false; run_async(ex, source.get_token(), [&]() { completed = true; }, - [&](std::exception_ptr) { escaped = true; })( + [&](std::exception_ptr ep) { stopped = is_stop(ep); })( rmw_ready_quitter(parked, state, false, marker, caught)); BOOST_TEST(static_cast(parked)); @@ -1090,9 +1135,9 @@ struct quitter_test if(parked) parked.resume(); - BOOST_TEST(caught); - BOOST_TEST(!escaped); - BOOST_TEST(completed); + BOOST_TEST(!caught); + BOOST_TEST(stopped); + BOOST_TEST(!completed); } //---------------------------------------------------------- @@ -1100,8 +1145,8 @@ struct quitter_test // // A quitter echo loop over a mock stream pair. // The client exchanges data, then requests stop. - // The echo quitter exits cleanly via stop interception, - // RAII runs, and the echoed data was correct. + // The echo quitter is abandoned on the stop, RAII runs, + // and the echoed data was correct. // // The mock stream's read_some is not stop-aware, so we // wrap each read in when_any with a stop_only_awaitable @@ -1115,8 +1160,8 @@ struct quitter_test // Echo server over a mock stream pair. The server // reads pre-provided data, echoes it back, then // waits for shutdown via stop_only_awaitable. When - // stop fires, the quitter intercepts at the co_await - // and exits cleanly. All stream access is on the + // stop fires, the quitter is abandoned at that co_await + // and RAII runs. All stream access is on the // jthread's synchronous executor — no cross-thread // stream use. test::fuse f; @@ -1150,7 +1195,7 @@ struct quitter_test // until the stop token fires. co_await parked_until_stop{&suspended}; - // Should never reach here — quitter intercepts + // Never reached: the stop abandons the quitter reached_end = true; }; @@ -1191,15 +1236,18 @@ struct quitter_test // awaitable untouched, and once the inner awaitable reports // ready, its await_resume must run so that what it acquired is // handed over. Only a result canceled by the stop, which - // acquired nothing, unwinds the body there. + // acquired nothing, abandons the quitter there. //---------------------------------------------------------- static quitter<> lock_after_stop(async_mutex& mtx, std::stop_source& source) { source.request_stop(); - auto [ec, guard] = co_await mtx.scoped_lock(); - (void)ec; + // Not a structured binding: GCC 15 at -O0 destroys the never + // constructed binding when the frame is destroyed at this + // co_await (GCC PR c++/124584). + auto r = co_await mtx.scoped_lock(); + (void)r; } // The stop is already pending when the body reaches the co_await. @@ -1216,9 +1264,10 @@ struct quitter_test run_async(ex, source.get_token(), [&]() { completed = true; }, - [&](std::exception_ptr) { ++stopped; })( + [&](std::exception_ptr ep) { stopped += is_stop(ep); })( lock_after_stop(mtx, source)); + // A stopped quitter<> reaches the error handler. BOOST_TEST(!mtx.is_locked()); BOOST_TEST(!completed); BOOST_TEST_EQ(stopped, 1); @@ -1334,11 +1383,16 @@ struct quitter_test BOOST_TEST_EQ(stopped, 0); } - // Reports ready, after requesting the stop when given a source, - // with a results holding `ec`: a synchronous completion like - // async_mutex's or async_event's when they see the stop first. + // How a sync_results_awaitable completes without suspending. + enum class completes { ready, by_false, by_handle }; + + // Completes synchronously, after requesting the stop when given a + // source, with a results holding `ec`: like async_mutex or + // async_event when they see the stop first. It reports ready, or + // its await_suspend returns false or the handle it was given. // The payload counts its destruction, as a lock_guard releases. - struct ready_results_awaitable + template + struct sync_results_awaitable { std::stop_source* source; std::error_code ec; @@ -1347,16 +1401,28 @@ struct quitter_test bool await_ready() noexcept { - if(source) - source->request_stop(); - return true; + if constexpr (How != completes::ready) + { + return false; + } + else + { + if(source) + source->request_stop(); + return true; + } } - std::coroutine_handle<> + auto await_suspend( std::coroutine_handle<> h, await_context const*) noexcept { - return h; // LCOV_EXCL_LINE never called: await_ready is true + if(source) + source->request_stop(); + if constexpr (How == completes::by_false) + return false; + else + return h; } results await_resume() noexcept @@ -1375,19 +1441,21 @@ struct quitter_test int destroyed = 0; }; + template static quitter<> ready_results_body( std::stop_source* source, std::error_code ec, ready_results_outcome& out) { - auto [seen, n] = co_await ready_results_awaitable{ + // Not a structured binding: see lock_after_stop. + auto r = co_await sync_results_awaitable{ source, ec, out.resumed, out.destroyed}; - (void)n; - out.seen = seen; + out.seen = std::get<0>(r); out.continued = true; } + template static ready_results_outcome run_ready_results(bool stop, std::error_code ec) { @@ -1399,14 +1467,15 @@ struct quitter_test run_async(ex, source.get_token(), []() {}, - [&](std::exception_ptr) { ++out.stopped; })( - ready_results_body( + [&](std::exception_ptr ep) { out.stopped += is_stop(ep); })( + ready_results_body( stop ? &source : nullptr, ec, out)); return out; } - // A canceled result under our stop acquired nothing, so the body - // is unwound at that co_await. + // A canceled result under our stop acquired nothing, so the + // quitter is abandoned at that co_await. A stopped quitter<> + // reaches the error handler. void testReadyCanceledThenStopStops() { @@ -1419,6 +1488,30 @@ struct quitter_test BOOST_TEST_EQ(out.destroyed, 1); } + // The same when await_suspend completes synchronously, by + // returning false or the handle it was given. + void + testSuspendCanceledThenStopStops() + { + auto by_false = run_ready_results( + true, make_error_code(error::canceled)); + BOOST_TEST(by_false.resumed); + BOOST_TEST(!by_false.continued); + BOOST_TEST_EQ(by_false.destroyed, 1); + + auto by_handle = run_ready_results( + true, make_error_code(error::canceled)); + BOOST_TEST(by_handle.resumed); + BOOST_TEST(!by_handle.continued); + BOOST_TEST_EQ(by_handle.destroyed, 1); + + auto success = run_ready_results( + true, {}); + BOOST_TEST(success.continued); + BOOST_TEST(!success.seen); + BOOST_TEST_EQ(success.destroyed, 1); + } + // A successful result is handed over even with a stop pending. void testReadySuccessThenStopDelivers() @@ -1443,48 +1536,1965 @@ struct quitter_test } //---------------------------------------------------------- - // run() + // 17. Stop by abandonment + // + // A stop ends a quitter's frames without resuming any of + // them. The first frame above that is not a quitter gets the + // stopped value: canceled for results and error_code, and a + // std::system_error for any other T, void included. //---------------------------------------------------------- + // What the parent of a quitter saw when it resumed. + struct seen + { + bool resumed = false; + bool threw = false; + std::error_code ec; + }; + + // Awaits `q` and records what it delivered. The quitter object is + // moved into the co_await, so its frame is gone once that + // statement ends. + template + static task<> + await_into(Q q, seen& s) + { + try + { + auto r = co_await std::move(q); + s.ec = std::get<0>(r); + } + catch(...) + { + s.threw = true; + } + s.resumed = true; + } + + // Runs `t` on a queue until nothing is runnable, requests the + // stop, and runs it again. Frames come from new_delete_resource, + // so ASan sees any frame used after it was freed. + static void + run_then_stop(std::stop_source& ss, task<> t) + { + std::queue q; + queuing_executor ex(q); + run_async(ex, ss.get_token(), + std::pmr::new_delete_resource())(std::move(t)); + pump(q); + ss.request_stop(); + pump(q); + } + + static quitter> + three_yields() + { + co_await yield_awaitable{}; + co_await yield_awaitable{}; + co_await yield_awaitable{}; + co_return results{std::error_code(), 42}; + } + void - run() + testUnstoppedValue() { - testMoveAssignDestroysHeldFrame(); -#if !defined(_WIN32) - testDestroyLentTaskTerminates(); - testMoveAssignOverLentTaskTerminates(); - testDestroyUnstartedOrFinishedTaskIsFine(); -#endif - testTeardownThroughQuitter(); - testNormalCompletion(); - testVoidCompletion(); - testExceptionPropagation(); - testStopBeforeFirstAwait(); - testStopDuringIO(); - testStopPropagationChain(); - testMixingQuitterAndTask(); - testNoStopRequested(); - testRAIIVerification(); - testMultipleCoAwait(); - testMoveOperations(); - testReturnString(); - testExceptionInValueQuitter(); - testBoolSuspendAwaitable(); - testBoolSuspendThenStop(); - testCoAwaitVoidQuitter(); - testCoAwaitThrowingQuitter(); - testWhenAllWithStop(); - testWhenAnyWithStop(); - testWakerCancellation(); - testCatchAfterRmwAwaitReady(); - testCatchStopAfterRmwAwaitReady(); - testStopPendingLeavesMutexUntouched(); - testReadyThenStopRunsAwaitResume(); - testReturnSelfThenStopRunsAwaitResume(); - testReadyCanceledThenStopStops(); - testReadySuccessThenStopDelivers(); - testReadyCanceledWithoutStopDelivers(); - testEchoWithShutdown(); + std::stop_source ss; + std::error_code ec = make_error_code(error::canceled); + std::size_t n = 0; + auto parent = [&]() -> task<> + { + auto [e, v] = co_await three_yields(); + ec = e; + n = v; + }; + run_then_stop(ss, parent()); + BOOST_TEST(!ec); + BOOST_TEST_EQ(n, 42u); + } + + static quitter> + guarded_wait(async_waker& w, int& down, bool& reached) + { + raii_counter g(down); + auto [ec] = co_await w.wait(); + (void)ec; + reached = true; + co_return results<>{}; + } + + void + testStopDeliversCanceled() + { + async_waker w; + std::stop_source ss; + seen s; + int down = 0; + bool reached = false; + run_then_stop(ss, await_into(guarded_wait(w, down, reached), s)); + BOOST_TEST(s.resumed); + BOOST_TEST(!s.threw); + BOOST_TEST(s.ec == cond::canceled); + BOOST_TEST(!reached); + } + + void + testFramesNotResumed() + { + async_waker w; + std::stop_source ss; + int down = 0; + bool reached = false; + int down_at_resume = -1; + auto parent = [&]() -> task<> + { + auto [ec] = co_await guarded_wait(w, down, reached); + (void)ec; + down_at_resume = down; + }; + run_then_stop(ss, parent()); + BOOST_TEST(!reached); + BOOST_TEST_EQ(down_at_resume, 1); + } + + static quitter> + catch_all_wait(async_waker& w, bool& hit) + { + try + { + auto [ec] = co_await w.wait(); + (void)ec; + } + catch(...) + { + hit = true; + } + co_return results<>{}; + } + + void + testCatchAllNeverRuns() + { + async_waker w; + std::stop_source ss; + seen s; + bool hit = false; + run_then_stop(ss, await_into(catch_all_wait(w, hit), s)); + BOOST_TEST(!hit); + BOOST_TEST(s.ec == cond::canceled); + } + + struct coop_state + { + bool task_finished = false; + std::error_code task_ec; + bool quitter_alive_then = false; + int quitter_down = 0; + bool quitter_reached = false; + }; + + static task> + coop_task(async_waker& w, coop_state& st) + { + auto [ec] = co_await w.wait(); + st.task_finished = true; + st.task_ec = ec; + st.quitter_alive_then = st.quitter_down == 0; + co_return results<>{ec}; + } + + static quitter> + coop_quitter(async_waker& w, coop_state& st) + { + raii_counter g(st.quitter_down); + auto [ec] = co_await coop_task(w, st); + (void)ec; + st.quitter_reached = true; + co_return results<>{}; + } + + // A task below a quitter stops through its own code; the quitter + // is destroyed only once the task has returned into it. + void + testTaskBelowCooperates() + { + async_waker w; + std::stop_source ss; + seen s; + coop_state st; + run_then_stop(ss, await_into(coop_quitter(w, st), s)); + BOOST_TEST(st.task_finished); + BOOST_TEST(st.task_ec == cond::canceled); + BOOST_TEST(st.quitter_alive_then); + BOOST_TEST_EQ(st.quitter_down, 1); + BOOST_TEST(!st.quitter_reached); + BOOST_TEST(s.ec == cond::canceled); + } + + static quitter> + join_guarded_wait(async_waker& w, int& down, bool& reached) + { + raii_counter g(down); + auto [ec] = co_await guarded_wait(w, down, reached); + (void)ec; + reached = true; + co_return results<>{}; + } + + // A quitter awaited by a quitter joins its segment: one stop + // destroys both frames, and the outer one never sees a value. + void + testQuitterJoinsQuitterSegment() + { + async_waker w; + std::stop_source ss; + seen s; + int down = 0; + bool reached = false; + run_then_stop(ss, await_into(join_guarded_wait(w, down, reached), s)); + BOOST_TEST_EQ(down, 2); + BOOST_TEST(!reached); + BOOST_TEST(s.ec == cond::canceled); + } + + static task> + task_between(async_waker& w, coop_state& st) + { + int down = 0; + bool reached = false; + auto [ec] = co_await guarded_wait(w, down, reached); + st.task_finished = true; + st.task_ec = ec; + st.quitter_alive_then = st.quitter_down == 0; + co_return results<>{ec}; + } + + static quitter> + quitter_over_task(async_waker& w, coop_state& st) + { + raii_counter g(st.quitter_down); + auto [ec] = co_await task_between(w, st); + (void)ec; + st.quitter_reached = true; + co_return results<>{}; + } + + // Q1 -> task -> Q2: the task gets canceled from Q2 as a value and + // returns through its own code; only then is Q1 destroyed. + void + testTaskBetweenQuittersCooperates() + { + async_waker w; + std::stop_source ss; + seen s; + coop_state st; + run_then_stop(ss, await_into(quitter_over_task(w, st), s)); + BOOST_TEST(st.task_finished); + BOOST_TEST(st.task_ec == cond::canceled); + BOOST_TEST(st.quitter_alive_then); + BOOST_TEST_EQ(st.quitter_down, 1); + BOOST_TEST(!st.quitter_reached); + BOOST_TEST(s.ec == cond::canceled); + } + + static quitter> + lock_after_stop_results( + async_mutex& mtx, std::stop_source& ss, bool& reached) + { + ss.request_stop(); + // Not a structured binding: see lock_after_stop. + auto r = co_await mtx.scoped_lock(); + (void)r; + reached = true; + co_return results<>{}; + } + + void + testStopPendingAtAwaitAbandons() + { + async_mutex mtx; + std::stop_source ss; + seen s; + bool reached = false; + run_then_stop(ss, + await_into(lock_after_stop_results(mtx, ss, reached), s)); + BOOST_TEST(!mtx.is_locked()); + BOOST_TEST(!reached); + BOOST_TEST(s.ec == cond::canceled); + } + + static quitter> + flag_and_return(bool& started) + { + started = true; + co_return results<>{}; + } + + static quitter> + join_after_stop(std::stop_source& ss, bool& started, bool& reached) + { + ss.request_stop(); + auto r = co_await flag_and_return(started); + (void)r; + reached = true; + co_return results<>{}; + } + + // The same for a quitter awaited by a quitter: a pending stop + // abandons the segment before the joined quitter's body starts. + void + testStopPendingAtJoinAbandons() + { + std::stop_source ss; + seen s; + bool started = false; + bool reached = false; + run_then_stop(ss, + await_into(join_after_stop(ss, started, reached), s)); + BOOST_TEST(!started); + BOOST_TEST(!reached); + BOOST_TEST(s.ec == cond::canceled); + } + + // Completes inside await_suspend, after requesting a stop, by + // returning the handle it was given. + struct inline_after_stop_awaitable + { + std::stop_source& ss; + bool& resumed; + + bool await_ready() const noexcept { return false; } + + std::coroutine_handle<> + await_suspend( + std::coroutine_handle<> h, await_context const*) noexcept + { + ss.request_stop(); + return h; + } + + void await_resume() noexcept { resumed = true; } + }; + + // The same, by returning false. + struct false_after_stop_awaitable + { + std::stop_source& ss; + bool& resumed; + + bool await_ready() const noexcept { return false; } + + bool + await_suspend( + std::coroutine_handle<>, await_context const*) noexcept + { + ss.request_stop(); + return false; + } + + void await_resume() noexcept { resumed = true; } + }; + + // Not a template: GCC 11 applies await_transform twice to a + // non-dependent co_await in a coroutine template. + static quitter> + complete_then_yield( + bool by_false, std::stop_source& ss, bool& resumed, bool& reached) + { + if(by_false) + co_await false_after_stop_awaitable{ss, resumed}; + else + co_await inline_after_stop_awaitable{ss, resumed}; + co_await yield_awaitable{}; + reached = true; + co_return results<>{}; + } + + // A completion that does not go through the gate is handed over, + // and the stop takes effect at the next suspension. + void + testInlineCompletionContinues() + { + std::stop_source ss; + seen s; + bool resumed = false; + bool reached = false; + run_then_stop(ss, await_into( + complete_then_yield(false, ss, resumed, reached), s)); + BOOST_TEST(resumed); + BOOST_TEST(!reached); + BOOST_TEST(s.ec == cond::canceled); + } + + void + testBoolFalseCompletionContinues() + { + std::stop_source ss; + seen s; + bool resumed = false; + bool reached = false; + run_then_stop(ss, await_into( + complete_then_yield(true, ss, resumed, reached), s)); + BOOST_TEST(resumed); + BOOST_TEST(!reached); + BOOST_TEST(s.ec == cond::canceled); + } + + static quitter> + first_statement(bool& ran) + { + ran = true; + co_await yield_awaitable{}; + co_return results<>{}; + } + + // A stop already requested when the quitter is awaited: its body + // never starts. + void + testStopBeforeFirstResume() + { + std::stop_source ss; + ss.request_stop(); + seen s; + bool ran = false; + run_then_stop(ss, await_into(first_statement(ran), s)); + BOOST_TEST(!ran); + BOOST_TEST(s.resumed); + BOOST_TEST(s.ec == cond::canceled); + } + + static task> + task_wait(async_waker& w) + { + auto [ec] = co_await w.wait(); + co_return results<>{ec}; + } + + // The quitter branch is abandoned under its own owner, so the join + // sees canceled from it instead of a lost branch. + void + testWhenAllBranchStopNotLost() + { + async_waker w1; + async_waker w2; + std::stop_source ss; + seen s; + int down = 0; + bool reached = false; + auto parent = [&]() -> task<> + { + auto r = co_await when_all( + guarded_wait(w1, down, reached), task_wait(w2)); + s.ec = std::get<0>(r); + s.resumed = true; + }; + run_then_stop(ss, parent()); + BOOST_TEST(s.resumed); + BOOST_TEST(s.ec == cond::canceled); + BOOST_TEST_EQ(down, 1); + BOOST_TEST(!reached); + } + + // The stop's unwind is posted to an executor that has closed: it + // ends as teardown, with no handler. + void + testOwnerDestroyedByClosedExecutorIsTeardown() + { + std::queue q; + bool closed = false; + closing_executor ex{&q, &closed}; + async_waker w; + std::stop_source ss; + seen s; + int down = 0; + bool reached = false; + bool value = false; + bool error = false; + run_async(ex, ss.get_token(), std::pmr::new_delete_resource(), + [&]() { value = true; }, + [&](std::exception_ptr) { error = true; })( + await_into(guarded_wait(w, down, reached), s)); + ss.request_stop(); + closed = true; + pump(q); + BOOST_TEST(!value); + BOOST_TEST(!error); + BOOST_TEST(!s.resumed); + BOOST_TEST_EQ(down, 1); + BOOST_TEST(!reached); + } + + static quitter> + counted_wait(async_waker& w, destroy_count_t) + { + auto [ec] = co_await w.wait(); + co_return results<>{ec}; + } + + void + testNoDoubleDestroyOnStop() + { + async_waker w; + std::stop_source ss; + seen s; + int down = 0; + run_then_stop(ss, + await_into(counted_wait(w, destroy_count_t(down)), s)); + BOOST_TEST_EQ(down, 1); + BOOST_TEST(s.ec == cond::canceled); + } + + // async_waker::wait(), setting `armed` once the waiter has + // registered, so a wake or stop that follows finds it suspended. + struct armed_wait + { + async_waker::wait_awaiter a; + std::atomic* armed; + + bool await_ready() noexcept { return a.await_ready(); } + + std::coroutine_handle<> + await_suspend( + std::coroutine_handle<> h, await_context const* env) noexcept + { + auto next = a.await_suspend(h, env); + armed->store(true, std::memory_order_release); + return next; + } + + results<> await_resume() noexcept { return a.await_resume(); } + }; + + static quitter> + counted_reaching_wait( + async_waker& w, + std::atomic& armed, + destroy_count_t, + bool& reached) + { + auto [ec] = co_await armed_wait{w.wait(), &armed}; + (void)ec; + reached = true; + co_return results<>{}; + } + + // A wake on another thread races the stop: either outcome is + // delivered exactly once, and nothing leaks. Both threads start + // once the waiter has registered, and one of them is held back by + // a varying delay, so both outcomes occur. + void + testStopRacesCompletion() + { + std::atomic total_handlers{0}; + int woken = 0; + int canceled = 0; + { + thread_pool pool(2); + for(int i = 0; i < 10000; ++i) + { + async_waker w; + std::stop_source ss; + std::atomic armed{false}; + std::atomic go{false}; + // A mutex, not a latch: TSan does not see the latch order + // the handler's writes before a wait that returns at once. + std::mutex m; + std::condition_variable cv; + bool done = false; + seen s; + // Counts the frame's destruction even when a stop that + // lands first keeps the body from starting. + int down = 0; + bool reached = false; + int handlers = 0; + // The parent finished before its handler ran. + bool finished_first = false; + auto handled = [&] + { + finished_first = s.resumed; + ++handlers; + ++total_handlers; + std::lock_guard lock(m); + done = true; + cv.notify_one(); + }; + run_async(pool.get_executor(), ss.get_token(), + std::pmr::new_delete_resource(), + [&]() { handled(); }, + [&](std::exception_ptr) { handled(); })( + await_into(counted_reaching_wait( + w, armed, destroy_count_t(down), reached), s)); + // Without the delay the stop nearly always wins, since the + // woken path also needs the gate to run before the stop. + auto const delay = std::chrono::microseconds(i % 64); + bool const hold_stop = i % 2 == 0; + auto start = [&](bool held) + { + while(!go.load(std::memory_order_acquire)) + std::this_thread::yield(); + if(!held) + return; + auto const until = std::chrono::steady_clock::now() + delay; + while(std::chrono::steady_clock::now() < until) + ; + }; + std::thread waker([&] + { + start(!hold_stop); + w.wake(); + }); + std::thread stopper([&] + { + start(hold_stop); + ss.request_stop(); + }); + while(!armed.load(std::memory_order_acquire)) + std::this_thread::yield(); + go.store(true, std::memory_order_release); + waker.join(); + stopper.join(); + { + std::unique_lock lock(m); + cv.wait(lock, [&] { return done; }); + } + BOOST_TEST_EQ(handlers, 1); + BOOST_TEST(finished_first); + BOOST_TEST_EQ(down, 1); + BOOST_TEST(!s.threw); + if(s.ec == cond::canceled) + { + ++canceled; + BOOST_TEST(!reached); + } + else + { + ++woken; + BOOST_TEST(!s.ec); + BOOST_TEST(reached); + } + } + } + BOOST_TEST_EQ(total_handlers.load(), 10000); + BOOST_TEST_EQ(woken + canceled, 10000); + BOOST_TEST(woken > 0); + BOOST_TEST(canceled > 0); + } + + // The race above, made deterministic: the wake has queued the gate, + // and the stop arrives before the gate runs. The gate abandons the + // segment, so the body is not resumed. + void + testStopAfterWakeBeforeGate() + { + std::queue q; + queuing_executor ex(q); + async_waker w; + std::stop_source ss; + seen s; + int down = 0; + bool reached = false; + run_async(ex, ss.get_token(), std::pmr::new_delete_resource())( + await_into(guarded_wait(w, down, reached), s)); + pump(q); + w.wake(); + BOOST_TEST(!q.empty()); + ss.request_stop(); + pump(q); + BOOST_TEST(s.resumed); + BOOST_TEST(s.ec == cond::canceled); + BOOST_TEST(!reached); + BOOST_TEST_EQ(down, 1); + } + + static quitter> + guarded_lock(async_mutex& mtx, bool& reached) + { + // Not a structured binding: see lock_after_stop. + auto r = co_await mtx.scoped_lock(); + (void)r; + reached = true; + co_return results<>{}; + } + + // The same for a lock: unlock() hands it to the waiter and queues + // the gate, and the stop arrives before the gate runs. The lock the + // abandoned waiter was handed is released, not leaked. + void + testStopAfterGrantBeforeGate() + { + std::queue q; + queuing_executor ex(q); + async_mutex mtx; + std::stop_source ss; + seen s; + bool reached = false; + auto holder = [&]() -> task<> + { + auto r = co_await mtx.lock(); + (void)r; + }; + run_async(ex)(holder()); + pump(q); + BOOST_TEST(mtx.is_locked()); + run_async(ex, ss.get_token(), std::pmr::new_delete_resource())( + await_into(guarded_lock(mtx, reached), s)); + pump(q); + mtx.unlock(); + BOOST_TEST(!q.empty()); + ss.request_stop(); + pump(q); + BOOST_TEST(s.resumed); + BOOST_TEST(s.ec == cond::canceled); + BOOST_TEST(!reached); + BOOST_TEST(!mtx.is_locked()); + } + + static quitter> + deep_chain(int depth, int& down, async_waker& w) + { + raii_counter g(down); + if(depth == 1) + { + auto [ec] = co_await w.wait(); + (void)ec; + co_return results<>{}; + } + auto [ec] = co_await deep_chain(depth - 1, down, w); + (void)ec; + co_return results<>{}; + } + + void + testDeepQuitterChainUnwinds() + { + async_waker w; + std::stop_source ss; + seen s; + int down = 0; + // The unwind recurses once per frame. A depth of 1000 crashed + // the MSVC 19.51 x64 Debug build, whose main thread has 1 MB. + constexpr int depth = 100; + run_then_stop(ss, await_into(deep_chain(depth, down, w), s)); + BOOST_TEST_EQ(down, depth); + BOOST_TEST(s.ec == cond::canceled); + } + + // A quitter's segment frame does not come from the chain's frame + // allocator, which may be gone by the time the segment is freed. + void + testSegmentNotOnChainAllocator() + { + struct counting_resource : std::pmr::memory_resource + { + std::size_t allocations = 0; + + void* do_allocate(std::size_t n, std::size_t a) override + { + ++allocations; + return std::pmr::new_delete_resource()->allocate(n, a); + } + + void do_deallocate( + void* p, std::size_t n, std::size_t a) override + { + std::pmr::new_delete_resource()->deallocate(p, n, a); + } + + bool do_is_equal( + std::pmr::memory_resource const& o) const noexcept override + { + return this == &o; + } + }; + + auto count = [](auto make) + { + counting_resource mr; + std::queue q; + queuing_executor ex(q); + seen s; + run_async(ex, &mr)(await_into(make(), s)); + pump(q); + BOOST_TEST(s.resumed); + return mr.allocations; + }; + auto via_task = count([]() -> task> + { + co_await yield_awaitable{}; + co_return results<>{}; + }); + auto via_quitter = count([]() -> quitter> + { + co_await yield_awaitable{}; + co_return results<>{}; + }); + BOOST_TEST_EQ(via_quitter, via_task); + } + + //---------------------------------------------------------- + // 18. What a stopped quitter delivers, by T + //---------------------------------------------------------- + + static quitter> + stopped_payload(async_waker& w) + { + auto [ec] = co_await w.wait(); + (void)ec; + co_return results{std::error_code(), 7, "x"}; + } + + void + testStoppedResultsDelivered() + { + async_waker w; + std::stop_source ss; + std::error_code ec; + int i = -1; + std::string str = "unset"; + auto parent = [&]() -> task<> + { + auto [e, v, t] = co_await stopped_payload(w); + ec = e; + i = v; + str = t; + }; + run_then_stop(ss, parent()); + BOOST_TEST(ec == cond::canceled); + BOOST_TEST_EQ(i, 0); + BOOST_TEST(str.empty()); + } + + // Returns success even after the waker's canceled, so only the + // stopped value can make the parent see canceled. + static quitter + stopped_ec(async_waker& w, bool& reached) + { + auto [ec] = co_await w.wait(); + (void)ec; + reached = true; + co_return std::error_code(); + } + + void + testStoppedErrorCodeDelivered() + { + async_waker w; + std::stop_source ss; + std::error_code ec; + bool reached = false; + auto parent = [&]() -> task<> + { + ec = co_await stopped_ec(w, reached); + }; + run_then_stop(ss, parent()); + BOOST_TEST(ec == cond::canceled); + BOOST_TEST(!reached); + } + + static quitter<> + stopped_void(async_waker& w, bool& reached) + { + auto [ec] = co_await w.wait(); + (void)ec; + reached = true; + } + + // A void return would read as success, so a stopped quitter<> + // throws as a plain T does. + void + testStoppedVoidThrows() + { + async_waker w; + std::stop_source ss; + bool reached = false; + bool after = false; + bool caught = false; + auto parent = [&]() -> task<> + { + try + { + co_await stopped_void(w, reached); + after = true; + } + catch(std::system_error const& e) + { + caught = e.code() == cond::canceled; + } + }; + run_then_stop(ss, parent()); + BOOST_TEST(caught); + BOOST_TEST(!after); + BOOST_TEST(!reached); + } + + static quitter + stopped_int(async_waker& w) + { + auto [ec] = co_await w.wait(); + (void)ec; + co_return 1; + } + + void + testStoppedPlainThrows() + { + async_waker w; + std::stop_source ss; + bool caught = false; + auto parent = [&]() -> task<> + { + try + { + (void)co_await stopped_int(w); + } + catch(std::system_error const& e) + { + caught = e.code() == cond::canceled; + } + }; + run_then_stop(ss, parent()); + BOOST_TEST(caught); + } + + //---------------------------------------------------------- + // 19. Launchers that drive a quitter directly + //---------------------------------------------------------- + + // Starts `q` with run_async and the given handlers, then stops it. + template + static void + run_async_then_stop(std::stop_source& ss, Q q, H... h) + { + std::queue queue; + queuing_executor ex(queue); + run_async(ex, ss.get_token(), h...)(std::move(q)); + pump(queue); + ss.request_stop(); + pump(queue); + } + + void + testRunAsyncStoppedResults() + { + async_waker w; + std::stop_source ss; + int down = 0; + bool reached = false; + std::error_code ec; + int values = 0; + int errors = 0; + run_async_then_stop(ss, guarded_wait(w, down, reached), + [&](results<> r) { ++values; ec = std::get<0>(r); }, + [&](std::exception_ptr) { ++errors; }); + BOOST_TEST_EQ(values, 1); + BOOST_TEST_EQ(errors, 0); + BOOST_TEST(ec == cond::canceled); + BOOST_TEST_EQ(down, 1); + BOOST_TEST(!reached); + } + + void + testRunAsyncStoppedResultsPayload() + { + async_waker w; + std::stop_source ss; + std::error_code ec; + int i = -1; + std::string str = "unset"; + int errors = 0; + run_async_then_stop(ss, stopped_payload(w), + [&](results r) + { + ec = std::get<0>(r); + i = std::get<1>(r); + str = std::get<2>(r); + }, + [&](std::exception_ptr) { ++errors; }); + BOOST_TEST(ec == cond::canceled); + BOOST_TEST_EQ(i, 0); + BOOST_TEST(str.empty()); + BOOST_TEST_EQ(errors, 0); + } + + void + testRunAsyncStoppedVoidCallsErrorHandler() + { + async_waker w; + std::stop_source ss; + bool reached = false; + int values = 0; + int stops = 0; + run_async_then_stop(ss, stopped_void(w, reached), + [&]() { ++values; }, + [&](std::exception_ptr ep) { stops += is_stop(ep); }); + BOOST_TEST_EQ(values, 0); + BOOST_TEST_EQ(stops, 1); + BOOST_TEST(!reached); + } + + void + testRunAsyncStoppedPlainCallsErrorHandler() + { + async_waker w; + std::stop_source ss; + int values = 0; + bool canceled = false; + run_async_then_stop(ss, stopped_int(w), + [&](int) { ++values; }, + [&](std::exception_ptr ep) + { + try + { + std::rethrow_exception(ep); + } + catch(std::system_error const& e) + { + canceled = e.code() == cond::canceled; + } + }); + BOOST_TEST_EQ(values, 0); + BOOST_TEST(canceled); + } + + static task<> + throws_code(std::error_code ec) + { + throw std::system_error(ec); + co_return; + } + + // With no error handler, a stopped plain-T quitter calls no + // handler and does not terminate. A value handler that does not + // take an exception_ptr is not called either; one that does + // receives the std::system_error. + void + testDefaultHandlerDiscardsStop() + { + async_waker w; + std::stop_source ss; + run_async_then_stop(ss, stopped_int(w)); + + async_waker w2; + std::stop_source ss2; + int values = 0; + run_async_then_stop(ss2, stopped_int(w2), + [&](int) { ++values; }); + BOOST_TEST_EQ(values, 0); + + async_waker w3; + std::stop_source ss3; + int errors = 0; + run_async_then_stop(ss3, stopped_int(w3), + [&](auto&& v) + { + if constexpr(std::is_same_v< + std::decay_t, std::exception_ptr>) + ++errors; + }); + BOOST_TEST_EQ(errors, 1); + } + + // A quitter's stop that escapes a task is dropped by the default + // handler, as on develop: a task that awaits quitter<> workers + // and does not catch the stop must not terminate. The task is + // unwound by the stop. + void + testDefaultHandlerDiscardsStopEscapingTask() + { + // The task awaits the quitter. + { + async_waker w; + std::stop_source ss; + bool reached = false; + bool after = false; + auto parent = [&]() -> task<> + { + co_await stopped_void(w, reached); + after = true; + }; + run_then_stop(ss, parent()); + BOOST_TEST(!reached); + BOOST_TEST(!after); + } + + // The task awaits run(ex)(q). + { + std::queue queue; + queuing_executor ex(queue); + async_waker w; + std::stop_source ss; + bool reached = false; + bool after = false; + auto parent = [&]() -> task<> + { + co_await capy::run(ex)(stopped_void(w, reached)); + after = true; + }; + run_async(ex, ss.get_token())(parent()); + pump(queue); + ss.request_stop(); + pump(queue); + BOOST_TEST(!reached); + BOOST_TEST(!after); + } + } + +#if !defined(_WIN32) + // Only a quitter's stop is discarded: canceled thrown by a task is + // an unhandled exception like any other, whatever its category. + void + testDefaultHandlerTerminatesOnOtherErrors() + { + BOOST_TEST(terminates([] { + int n = 0; + test_executor ex(n); + run_async(ex)(throws_code( + std::make_error_code(std::errc::io_error))); + })); + BOOST_TEST(terminates([] { + int n = 0; + test_executor ex(n); + run_async(ex)(throws_code(make_error_code(error::canceled))); + })); + BOOST_TEST(terminates([] { + int n = 0; + test_executor ex(n); + run_async(ex)(throws_code( + std::make_error_code(std::errc::operation_canceled))); + })); + } +#endif + + void + testUserCanceledReachesErrorHandler() + { + int n = 0; + test_executor ex(n); + bool canceled = false; + run_async(ex, + [] {}, + [&](std::exception_ptr ep) + { + try + { + std::rethrow_exception(ep); + } + catch(std::system_error const& e) + { + canceled = e.code() == cond::canceled; + } + })(throws_code(make_error_code(error::canceled))); + BOOST_TEST(canceled); + } + + // Q1 -> run(st2)(Q2): stopping st2 ends Q2 only. Q1 gets canceled + // as a value and keeps running. + void + testRunNoExecutorStopScoped() + { + async_waker w; + std::stop_source outer; + std::stop_source inner; + int down = 0; + bool reached = false; + int q1_down = 0; + std::error_code inner_ec; + bool q1_finished = false; + seen s; + auto q1 = [&]() -> quitter> + { + raii_counter g(q1_down); + auto [ec] = co_await capy::run(inner.get_token())( + guarded_wait(w, down, reached)); + inner_ec = ec; + q1_finished = true; + co_return results<>{}; + }; + std::queue queue; + queuing_executor ex(queue); + run_async(ex, outer.get_token())(await_into(q1(), s)); + pump(queue); + inner.request_stop(); + pump(queue); + BOOST_TEST(inner_ec == cond::canceled); + BOOST_TEST(q1_finished); + BOOST_TEST_EQ(down, 1); + BOOST_TEST(!reached); + BOOST_TEST_EQ(q1_down, 1); + BOOST_TEST(s.resumed); + BOOST_TEST(!s.ec); + } + + static quitter> + guarded_park(parked& slot, int& down) + { + raii_counter g(down); + co_await park_awaitable{&slot}; + co_return results<>{}; + } + + // A teardown with no stop requested unwinds silently, through a + // segment the quitter opened and through one it was launched in. + void + testTeardownStaysSilent() + { + int values = 0; + int errors = 0; + auto value = [&](auto&&...) { ++values; }; + auto error = [&](std::exception_ptr) { ++errors; }; + + // Opened by the quitter's await_suspend. + { + parked slot; + int down = 0; + seen s; + int n = 0; + test_executor ex(n); + run_async(ex, value, error)( + await_into(guarded_park(slot, down), s)); + BOOST_TEST(slot); + capy::abandon(slot); + BOOST_TEST_EQ(down, 1); + BOOST_TEST(!s.resumed); + } + + // Launched directly. + { + parked slot; + int down = 0; + int n = 0; + test_executor ex(n); + run_async(ex, value, error)(guarded_park(slot, down)); + BOOST_TEST(slot); + capy::abandon(slot); + BOOST_TEST_EQ(down, 1); + } + + BOOST_TEST_EQ(values, 0); + BOOST_TEST_EQ(errors, 0); + } + + // run(ex)(q) for a plain T or void throws the stop to its awaiting + // parent. + template + static void + run_stopped_throws_to_parent(Q (*make)(async_waker&, bool&)) + { + std::queue queue; + queuing_executor ex(queue); + async_waker w; + std::stop_source ss; + bool reached = false; + bool caught = false; + bool after = false; + auto parent = [&]() -> task<> + { + try + { + (void)co_await capy::run(ex)(make(w, reached)); + } + catch(std::system_error const& e) + { + caught = e.code() == cond::canceled; + } + after = true; + }; + run_async(ex, ss.get_token())(parent()); + pump(queue); + ss.request_stop(); + pump(queue); + BOOST_TEST(caught); + BOOST_TEST(after); + BOOST_TEST(!reached); + } + + static quitter + stopped_int_reaching(async_waker& w, bool& reached) + { + auto [ec] = co_await w.wait(); + (void)ec; + reached = true; + co_return 1; + } + + void + testRunStoppedPlainThrowsToParent() + { + run_stopped_throws_to_parent(&stopped_int_reaching); + run_stopped_throws_to_parent(&stopped_void); + } + + // run(ex)(q) with a stop already requested: the body never starts. + void + testRunStopBeforeStart() + { + std::queue queue; + queuing_executor ex(queue); + std::stop_source ss; + ss.request_stop(); + bool ran = false; + seen s; + auto parent = [&]() -> task<> + { + auto [ec] = co_await capy::run(ex)(first_statement(ran)); + s.ec = ec; + s.resumed = true; + }; + run_async(ex, ss.get_token())(parent()); + pump(queue); + BOOST_TEST(!ran); + BOOST_TEST(s.resumed); + BOOST_TEST(s.ec == cond::canceled); + } + + // run(st)(q) with st already stopped: the body never starts, and + // the awaiter gets canceled at once, from a task and from a quitter. + void + testRunTokenStopBeforeStart() + { + std::queue queue; + queuing_executor ex(queue); + std::stop_source ss; + ss.request_stop(); + bool ran = false; + seen s; + auto from_task = [&]() -> task<> + { + auto [ec] = co_await capy::run(ss.get_token())( + first_statement(ran)); + s.ec = ec; + s.resumed = true; + }; + run_async(ex)(from_task()); + pump(queue); + BOOST_TEST(!ran); + BOOST_TEST(s.resumed); + BOOST_TEST(s.ec == cond::canceled); + + // The awaiting quitter's own token is not stopped, so it + // receives canceled as a value and keeps running. + seen inner; + auto from_quitter = [&]() -> quitter> + { + auto [ec] = co_await capy::run(ss.get_token())( + first_statement(ran)); + inner.ec = ec; + inner.resumed = true; + co_return results<>{}; + }; + s = {}; + run_async(ex)(await_into(from_quitter(), s)); + pump(queue); + BOOST_TEST(!ran); + BOOST_TEST(inner.resumed); + BOOST_TEST(inner.ec == cond::canceled); + BOOST_TEST(s.resumed); + BOOST_TEST(!s.ec); + } + + // run_async with a stop requested after the launch has posted the + // quitter, before it runs: the body never starts. + void + testRunAsyncStopAfterLaunch() + { + std::queue queue; + queuing_executor ex(queue); + std::stop_source ss; + bool ran = false; + int values = 0; + int errors = 0; + std::error_code ec; + run_async(ex, ss.get_token(), + [&](results<> r) { ++values; ec = std::get<0>(r); }, + [&](std::exception_ptr) { ++errors; })(first_statement(ran)); + ss.request_stop(); + pump(queue); + BOOST_TEST(!ran); + BOOST_TEST_EQ(values, 1); + BOOST_TEST_EQ(errors, 0); + BOOST_TEST(ec == cond::canceled); + } + + // The same for run(ex)(q): the stop arrives after the quitter is + // posted to the other executor, before it runs. + void + testRunStopAfterPost() + { + std::queue outer_queue; + std::queue inner_queue; + queuing_executor outer_ex(outer_queue); + queuing_executor inner_ex(inner_queue); + std::stop_source ss; + bool ran = false; + seen s; + auto parent = [&]() -> task<> + { + auto [ec] = co_await capy::run(inner_ex)(first_statement(ran)); + s.ec = ec; + s.resumed = true; + }; + run_async(outer_ex, ss.get_token())(parent()); + pump(outer_queue); + BOOST_TEST(!inner_queue.empty()); + ss.request_stop(); + while(!outer_queue.empty() || !inner_queue.empty()) + { + pump(inner_queue); + pump(outer_queue); + } + BOOST_TEST(!ran); + BOOST_TEST(s.resumed); + BOOST_TEST(s.ec == cond::canceled); + } + + static quitter<> + shutdown_worker( + async_waker& w, + std::atomic& cleaned, + std::latch& started) + { + struct cleanup + { + std::atomic& n; + ~cleanup() { ++n; } + } guard{cleaned}; + started.count_down(); + for(;;) + (void)co_await w.wait(); + } + + // The shape of example/quitter-shutdown: workers with no + // cancellation code, a stop, and a latch counted by the handlers. + void + testQuitterShutdownExample() + { + constexpr int workers = 4; + thread_pool pool(workers); + std::latch done(workers); + std::latch started(workers); + std::atomic cleaned{0}; + std::stop_source ss; + std::array wakers; + std::vector> strands; + for(int i = 0; i < workers; ++i) + strands.emplace_back(pool.get_executor()); + for(int i = 0; i < workers; ++i) + run_async(strands[i], ss.get_token(), + [&]() { done.count_down(); }, + [&](std::exception_ptr) { done.count_down(); })( + shutdown_worker(wakers[i], cleaned, started)); + started.wait(); + ss.request_stop(); + done.wait(); + BOOST_TEST_EQ(cleaned.load(), workers); + } + + //---------------------------------------------------------- + // 20. Stops across executors and threads + //---------------------------------------------------------- + + // Blocks until a handler reports, without std::latch, whose + // ordering TSan does not see. + struct completion + { + std::mutex m; + std::condition_variable cv; + bool done = false; + + void signal() + { + std::lock_guard lock(m); + done = true; + cv.notify_one(); + } + + void wait() + { + std::unique_lock lock(m); + cv.wait(lock, [&] { return done; }); + } + }; + + struct hop_state + { + bool task_finished = false; + bool task_on_inner = false; + int quitter_down = 0; + bool quitter_reached = false; + bool parent_on_outer = false; + seen s; + }; + + // Waits for the stop, then returns canceled through its own code. + static task> + task_until_stop( + std::latch& registered, + thread_pool::executor_type inner, + hop_state& st) + { + co_await parked_until_stop{®istered}; + st.task_finished = true; + st.task_on_inner = + (co_await this_coro::executor) == executor_ref(inner); + co_return results<>{make_error_code(error::canceled)}; + } + + static quitter> + quitter_over_run( + thread_pool& inner, std::latch& registered, hop_state& st) + { + raii_counter g(st.quitter_down); + auto [ec] = co_await capy::run(inner.get_executor())( + task_until_stop(registered, inner.get_executor(), st)); + (void)ec; + st.quitter_reached = true; + co_return results<>{}; + } + + // Records what it got, and whether it resumed on `outer`. The + // executor is held by value: an executor_ref does not own it. Not a + // template, as complete_then_yield is not. + static task<> + await_on( + quitter> q, + thread_pool::executor_type outer, + hop_state& st) + { + auto r = co_await std::move(q); + st.s.ec = std::get<0>(r); + st.s.resumed = true; + st.parent_on_outer = + (co_await this_coro::executor) == executor_ref(outer); + } + + // quitter -> run(other executor) -> task: the task stops through + // its own code on the other executor, and the quitter's parent gets + // canceled back on its own. + void + testStopAcrossRunToTask() + { + thread_pool outer(1); + thread_pool inner(1); + std::latch registered(1); + std::stop_source ss; + hop_state st; + completion c; + run_async(outer.get_executor(), ss.get_token(), + [&]() { c.signal(); }, + [&](std::exception_ptr) { c.signal(); })( + await_on(quitter_over_run(inner, registered, st), + outer.get_executor(), st)); + registered.wait(); + ss.request_stop(); + c.wait(); + BOOST_TEST(st.task_finished); + BOOST_TEST(st.task_on_inner); + BOOST_TEST(st.s.resumed); + BOOST_TEST(st.s.ec == cond::canceled); + BOOST_TEST(st.parent_on_outer); + BOOST_TEST(!st.quitter_reached); + BOOST_TEST_EQ(st.quitter_down, 1); + } + + static quitter> + inner_quitter_until_stop( + std::latch& registered, int& down, bool& reached) + { + raii_counter g(down); + co_await parked_until_stop{®istered}; + reached = true; + co_return results<>{}; + } + + static quitter> + quitter_over_run_quitter( + thread_pool& inner, std::latch& registered, hop_state& st, + int& inner_down, bool& inner_reached) + { + raii_counter g(st.quitter_down); + auto [ec] = co_await capy::run(inner.get_executor())( + inner_quitter_until_stop( + registered, inner_down, inner_reached)); + (void)ec; + st.quitter_reached = true; + co_return results<>{}; + } + + // quitter -> run(other executor) -> quitter: two segments unwind, + // and the outer parent gets canceled. + void + testStopAcrossRunToQuitter() + { + thread_pool outer(1); + thread_pool inner(1); + std::latch registered(1); + std::stop_source ss; + hop_state st; + int inner_down = 0; + bool inner_reached = false; + completion c; + run_async(outer.get_executor(), ss.get_token(), + [&]() { c.signal(); }, + [&](std::exception_ptr) { c.signal(); })( + await_on(quitter_over_run_quitter( + inner, registered, st, inner_down, inner_reached), + outer.get_executor(), st)); + registered.wait(); + ss.request_stop(); + c.wait(); + BOOST_TEST(st.s.resumed); + BOOST_TEST(st.s.ec == cond::canceled); + BOOST_TEST(st.parent_on_outer); + BOOST_TEST(!st.quitter_reached); + BOOST_TEST_EQ(st.quitter_down, 1); + BOOST_TEST(!inner_reached); + BOOST_TEST_EQ(inner_down, 1); + } + + // Q1 -> run(ex, st2)(Q2): stopping st2 gives Q1 canceled, and Q1 + // keeps running, because its own token was not stopped. + void + testStopScopedToToken() + { + std::queue queue; + queuing_executor ex(queue); + async_waker w; + std::stop_source outer; + std::stop_source inner; + int down = 0; + bool reached = false; + int q1_down = 0; + bool q1_finished = false; + std::error_code inner_ec; + seen s; + auto q1 = [&]() -> quitter> + { + raii_counter g(q1_down); + auto [ec] = co_await capy::run(ex, inner.get_token())( + guarded_wait(w, down, reached)); + inner_ec = ec; + q1_finished = true; + co_return results<>{}; + }; + run_async(ex, outer.get_token())(await_into(q1(), s)); + pump(queue); + inner.request_stop(); + pump(queue); + BOOST_TEST(inner_ec == cond::canceled); + BOOST_TEST(q1_finished); + BOOST_TEST_EQ(down, 1); + BOOST_TEST(!reached); + BOOST_TEST_EQ(q1_down, 1); + BOOST_TEST(s.resumed); + BOOST_TEST(!s.ec); + } + + // Records the thread that destroys the frame holding it. + struct destroyed_on + { + std::thread::id* where; + + explicit destroyed_on(std::thread::id& w) noexcept + : where(&w) + { + } + + destroyed_on(destroyed_on&& o) noexcept + : where(std::exchange(o.where, nullptr)) + { + } + + ~destroyed_on() + { + if(where) + *where = std::this_thread::get_id(); + } + }; + + static quitter> + busy_then_yield( + std::atomic& started, + std::atomic& stop_requested, + bool& ran_after_stop, + bool& reached, + std::thread::id& destroyed) + { + destroyed_on mark(destroyed); + started = true; + // Busy, without suspending, until the stop has been requested. + while(!stop_requested.load()) + std::this_thread::yield(); + ran_after_stop = true; + co_await yield_awaitable{}; + reached = true; + co_return results<>{}; + } + + // A stop requested from another thread while the body runs does + // nothing until the body next suspends, and no frame is touched on + // the requesting thread. + void + testStopWhileRunning() + { + thread_pool pool(1); + std::stop_source ss; + std::atomic started{false}; + std::atomic stop_requested{false}; + bool ran_after_stop = false; + bool reached = false; + std::thread::id destroyed; + seen s; + completion c; + run_async(pool.get_executor(), ss.get_token(), + [&]() { c.signal(); }, + [&](std::exception_ptr) { c.signal(); })( + await_into(busy_then_yield(started, stop_requested, + ran_after_stop, reached, destroyed), s)); + while(!started.load()) + std::this_thread::yield(); + ss.request_stop(); + stop_requested = true; + c.wait(); + BOOST_TEST(ran_after_stop); + BOOST_TEST(!reached); + BOOST_TEST(s.ec == cond::canceled); + BOOST_TEST(destroyed != std::thread::id()); + BOOST_TEST(destroyed != std::this_thread::get_id()); + } + + //---------------------------------------------------------- + // 21. Misuse that must not corrupt memory + //---------------------------------------------------------- + + // Drives a quitter through its handle in the caller's own segment, + // as a user-written wrapper might: the quitter's gate then abandons + // the segment of a task. + template + struct drive_in_callers_segment + { + Q q; + + bool await_ready() const noexcept { return false; } + + std::coroutine_handle<> + await_suspend(std::coroutine_handle<> cont, await_context const* env) + { + auto h = q.handle(); + h.promise().set_continuation(cont); + h.promise().set_environment(env); + return h; + } + + decltype(auto) await_resume() { return q.await_resume(); } + }; + + static task<> + task_driving_quitter(async_waker& w, int& down, bool& reached) + { + // A named awaitable: GCC 11 crashes destroying this frame, + // suspended here, when the awaitable is an aggregate temporary. + drive_in_callers_segment>> d{ + guarded_wait(w, down, reached)}; + auto r = co_await std::move(d); + (void)r; + } + + struct misuse_outcome + { + int down = 0; + bool reached = false; + bool parent_resumed = false; + int handlers = 0; + }; + + // Stops a quitter that a task drives in its own segment, started + // directly by run_async, or by run_async through run(ex). + static void + stop_in_task_segment(misuse_outcome& out, bool through_run) + { + std::queue queue; + queuing_executor ex(queue); + async_waker w; + std::stop_source ss; + auto parent = [&]() -> task<> + { + co_await capy::run(ex)( + task_driving_quitter(w, out.down, out.reached)); + out.parent_resumed = true; + }; + auto on_value = [&]() { ++out.handlers; }; + auto on_error = [&](std::exception_ptr) { ++out.handlers; }; + if(through_run) + run_async(ex, ss.get_token(), std::pmr::new_delete_resource(), + on_value, on_error)(parent()); + else + run_async(ex, ss.get_token(), std::pmr::new_delete_resource(), + on_value, on_error)( + task_driving_quitter(w, out.down, out.reached)); + pump(queue); + ss.request_stop(); + pump(queue); + } + + // The stop reaches the owner of a task's segment. A debug build + // asserts in the launcher. Otherwise the task is torn down: no + // handler reads its destroyed promise. + void + testStopInTaskSegmentIsTeardownUnderRunAsync() + { +#ifdef NDEBUG + misuse_outcome out; + stop_in_task_segment(out, false); + BOOST_TEST_EQ(out.handlers, 0); + BOOST_TEST_EQ(out.down, 1); + BOOST_TEST(!out.reached); +#elif !defined(_WIN32) + BOOST_TEST(aborts([] { + misuse_outcome out; + stop_in_task_segment(out, false); + })); +#endif + } + + void + testStopInTaskSegmentIsTeardownUnderRun() + { +#ifdef NDEBUG + misuse_outcome out; + stop_in_task_segment(out, true); + BOOST_TEST(!out.parent_resumed); + BOOST_TEST_EQ(out.handlers, 0); + BOOST_TEST_EQ(out.down, 1); + BOOST_TEST(!out.reached); +#elif !defined(_WIN32) + BOOST_TEST(aborts([] { + misuse_outcome out; + stop_in_task_segment(out, true); + })); +#endif + } + +#if !defined(_WIN32) && !defined(NDEBUG) + static quitter<> + yields_once() + { + co_await yield_awaitable{}; + } + + // A quitter run with no chain_owner cannot be stopped; a debug + // build says so when a stop arrives instead of ignoring it. + void + testOwnerlessStopAsserts() + { + BOOST_TEST(aborts([] { + int n = 0; + test_executor ex(n); + std::stop_source ss; + await_context env{executor_ref(ex), ss.get_token(), nullptr}; + auto q = yields_once(); + auto h = q.handle(); + q.release(); + h.promise().set_environment(&env); + ss.request_stop(); + h.resume(); + })); + } +#endif + + //---------------------------------------------------------- + // run() + //---------------------------------------------------------- + + void + run() + { + testMoveAssignDestroysHeldFrame(); +#if !defined(_WIN32) + testDestroyLentTaskTerminates(); + testMoveAssignOverLentTaskTerminates(); + testDestroyUnstartedOrFinishedTaskIsFine(); +#endif + testTeardownThroughQuitter(); + testNormalCompletion(); + testVoidCompletion(); + testExceptionPropagation(); + testStopBeforeFirstAwait(); + testStopDuringIO(); + testStopPropagationChain(); + testMixingQuitterAndTask(); + testNoStopRequested(); + testRAIIVerification(); + testMultipleCoAwait(); + testMoveOperations(); + testReturnString(); + testExceptionInValueQuitter(); + testBoolSuspendAwaitable(); + testBoolSuspendThenStop(); + testCoAwaitVoidQuitter(); + testCoAwaitThrowingQuitter(); + testWhenAllWithStop(); + testWhenAnyWithStop(); + testWakerCancellation(); + testCatchAfterRmwAwaitReady(); + testCatchStopAfterRmwAwaitReady(); + testStopPendingLeavesMutexUntouched(); + testReadyThenStopRunsAwaitResume(); + testReturnSelfThenStopRunsAwaitResume(); + testReadyCanceledThenStopStops(); + testSuspendCanceledThenStopStops(); + testReadySuccessThenStopDelivers(); + testReadyCanceledWithoutStopDelivers(); + testEchoWithShutdown(); + testUnstoppedValue(); + testStopDeliversCanceled(); + testFramesNotResumed(); + testCatchAllNeverRuns(); + testTaskBelowCooperates(); + testQuitterJoinsQuitterSegment(); + testTaskBetweenQuittersCooperates(); + testStopPendingAtAwaitAbandons(); + testStopPendingAtJoinAbandons(); + testInlineCompletionContinues(); + testBoolFalseCompletionContinues(); + testStopBeforeFirstResume(); + testWhenAllBranchStopNotLost(); + testOwnerDestroyedByClosedExecutorIsTeardown(); + testNoDoubleDestroyOnStop(); + testStopRacesCompletion(); + testStopAfterWakeBeforeGate(); + testStopAfterGrantBeforeGate(); + testDeepQuitterChainUnwinds(); + testSegmentNotOnChainAllocator(); + testStoppedResultsDelivered(); + testStoppedErrorCodeDelivered(); + testStoppedVoidThrows(); + testStoppedPlainThrows(); + testRunAsyncStoppedResults(); + testRunAsyncStoppedResultsPayload(); + testRunAsyncStoppedVoidCallsErrorHandler(); + testRunAsyncStoppedPlainCallsErrorHandler(); + testDefaultHandlerDiscardsStop(); + testDefaultHandlerDiscardsStopEscapingTask(); +#if !defined(_WIN32) + testDefaultHandlerTerminatesOnOtherErrors(); +#endif + testUserCanceledReachesErrorHandler(); + testRunNoExecutorStopScoped(); + testTeardownStaysSilent(); + testRunStoppedPlainThrowsToParent(); + testRunStopBeforeStart(); + testRunTokenStopBeforeStart(); + testRunAsyncStopAfterLaunch(); + testRunStopAfterPost(); + testQuitterShutdownExample(); + testStopAcrossRunToTask(); + testStopAcrossRunToQuitter(); + testStopScopedToToken(); + testStopWhileRunning(); + testStopInTaskSegmentIsTeardownUnderRunAsync(); + testStopInTaskSegmentIsTeardownUnderRun(); +#if !defined(_WIN32) && !defined(NDEBUG) + testOwnerlessStopAsserts(); +#endif } };