From 523cb5cb981b5e8ffa4c94622f29e8764c9a185c Mon Sep 17 00:00:00 2001 From: Michael Vandeberg Date: Thu, 29 Oct 2026 12:46:25 -0600 Subject: [PATCH] feat!: stop a quitter by abandoning its frames A stop no longer resumes a quitter to throw an internal exception. Each co_await in a quitter now completes through a gate. When a stop is requested, the gate abandons the quitter's segment. The segment destroys the quitter frames from the top down, and no quitter frame resumes. The first awaiting coroutine that is not a quitter receives the stopped value: - results and std::error_code: canceled - any other T, void included: the co_await throws a private type derived from std::system_error, with canceled. A void return would look the same as success. run_async and run deliver the same stopped value. run_async and run(ex) start a quitter through its gate, so a stop that arrives before the body starts keeps the body from starting. The run(st) overloads that keep the caller's executor check the stop when the quitter is awaited. run_async's default handler drops a quitter's stop, also when it escapes a task that awaited the quitter. It terminates on any other exception, a real ECANCELED included. An operation that completes synchronously gives its result to the body. The exception is a results<> that holds canceled while the stop is pending. The operation acquired nothing, so the quitter stops at that co_await. In debug builds, run_async and run assert when a quitter's stop reaches the segment of a task that they started. bench/quitter.cpp compares quitter with task. Breaking changes: - A task that awaits a stopped results or error_code quitter continues after the co_await. Before, the stop unwound the task as an exception. A stopped quitter<> or plain-T quitter still unwinds the task, but with a std::system_error, which catch(std::exception&) also catches. A task that throws a std::system_error copy of the stop makes run_async's default handler terminate. - when_all and when_any receive the stopped value of a quitter branch. Before, they received the internal exception. - run_async gives the stopped value of a results or error_code quitter to its value handler, and the std::system_error of any other quitter to its error handler. Before, the error handler received the internal exception. - When q stops, co_await run(st)(q) in a quitter does not stop the outer quitter. The outer quitter receives the stopped value, or for quitter<> and a plain T, the std::system_error. - catch(...) in a quitter does not see the quitter's own stop. - Each Ts of an awaited quitter> must be default-constructible. --- bench/CMakeLists.txt | 7 +- bench/quitter.cpp | 477 ++++ .../pages/4.coroutines/4f.cancellation.adoc | 2 +- .../ROOT/pages/4.coroutines/4j.lifetimes.adoc | 18 +- .../ROOT/pages/9.design/9l.RunApi.adoc | 2 +- doc/quitter.md | 656 ++--- example/quitter-shutdown/quitter_shutdown.cpp | 7 +- .../boost/capy/concept/context_runnable.hpp | 4 + .../capy/detail/await_suspend_helper.hpp | 40 +- include/boost/capy/detail/quitter_stopped.hpp | 45 + include/boost/capy/detail/run.hpp | 12 + include/boost/capy/detail/run_callbacks.hpp | 9 +- .../capy/detail/stop_requested_exception.hpp | 28 - include/boost/capy/ex/abandon.hpp | 79 +- include/boost/capy/ex/await_context.hpp | 15 + include/boost/capy/ex/run.hpp | 63 +- include/boost/capy/ex/run_async.hpp | 181 +- include/boost/capy/quitter.hpp | 1143 ++++++--- test/unit/ex/abandon.cpp | 83 + test/unit/quitter.cpp | 2220 ++++++++++++++++- 20 files changed, 4111 insertions(+), 980 deletions(-) create mode 100644 bench/quitter.cpp create mode 100644 include/boost/capy/detail/quitter_stopped.hpp delete mode 100644 include/boost/capy/detail/stop_requested_exception.hpp 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 } };