Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
cancellation.hpp
Go to the documentation of this file.
1#pragma once
2
3// Cooperative cancellation — inspired by Kotlin Coroutines `Job` / Swift's
4// `Task.checkCancellation()` / std::stop_token.
5//
6// CancellationSource src;
7// auto token = src.token();
8//
9// Task<int> work(CancellationToken tok) {
10// co_await schedule_on(pool);
11// tok.throw_if_cancelled(); // probe at safe points
12// co_return heavy_computation();
13// }
14//
15// src.cancel(); // any work() that probes will throw OperationCancelled
16//
17// Tokens are thread-safe and copyable; cancellation propagates to all copies.
18
19#include <atomic>
20#include <coroutine>
21#include <exception>
22#include <functional>
23#include <memory>
24#include <mutex>
25#include <stdexcept>
26#include <utility>
27#include <vector>
28
29namespace aria::async {
30
31class OperationCancelled : public std::exception {
32public:
33 [[nodiscard]] const char* what() const noexcept override {
34 return "operation was cancelled";
35 }
36};
37
38namespace detail {
39struct CancellationState {
40 std::atomic<bool> cancelled{false};
41 std::mutex m;
42 std::vector<std::function<void()>> callbacks; // fired on cancel
43};
44
45// ---------------------------------------------------------------------
46// Deferred-resume queue
47//
48// Cancellation callbacks must not call `coroutine_handle::resume()` from
49// inside the cancellation broadcast loop. Doing so resumes the parked
50// coroutine on a stack like
51//
52// cancel()
53// └─ for c in cbs
54// └─ std::function::operator()
55// └─ cb lambda
56// └─ h.resume() <- coroutine body runs here
57//
58// MSVC release builds were observed to silently bypass coroutine-internal
59// `try/catch` handlers when an exception unwinds out of `h.resume()` on
60// this stack shape; MinGW UCRT64 went further and SIGSEGV'd inside the
61// exception unwind. Both are symptoms of the SEH / DWARF personality
62// routine getting confused about which try/catch ranges are live when
63// the throwing PC sits inside a coroutine frame whose execution was
64// re-entered through an `std::function::operator()` indirection layered
65// on top of a `std::vector` iterator.
66//
67// The fix is to *defer* the resume: callbacks push their handle into a
68// thread-local pending list, and the topmost `cancel()` on the thread
69// drains that list once all callbacks have returned. Resumption then
70// runs on the cancel() function's own stack frame, with no
71// `std::function` indirection in between, and exception unwind out of
72// the resumed coroutine sees a vanilla call stack that every supported
73// toolchain handles correctly.
74//
75// The design is reentrancy-safe: a callback may itself trigger another
76// `cancel()`. The inner cancel() observes that drain ownership is
77// already taken by an outer cancel() and refrains from draining; the
78// outer (topmost) cancel() picks up the union of pending handles and
79// drains them in FIFO order.
80//
81// Cross-DLL note: `deferred_resume_context()` is `inline` and uses a
82// function-local `thread_local`. Under C++17 inline-variable rules the
83// per-thread storage is unique per (thread, DLL) pair on Windows. As
84// long as a single `cancel()` call is contained inside one DLL
85// boundary the deferred-resume protocol is honoured exactly. If a cb
86// crosses into another DLL whose awaiter then calls
87// `schedule_deferred_resume`, that call lands in the *other* DLL's
88// per-thread context; if no outer cancel() is active there, it falls
89// back to the inline-drain path documented below — still safe, just
90// not deferred. Keeping a single CancellationSource within one
91// translation-unit boundary is the recommended pattern.
92struct DeferredResumeContext {
93 std::vector<std::coroutine_handle<>> pending;
94 bool draining = false;
95};
96
97inline DeferredResumeContext& deferred_resume_context() noexcept {
98 thread_local DeferredResumeContext ctx;
99 return ctx;
100}
101
102inline void schedule_deferred_resume(std::coroutine_handle<> h) noexcept {
103 auto& ctx = deferred_resume_context();
104 if (ctx.draining) {
105 // Inside the broadcast loop of a cancel() higher up the stack —
106 // queue the handle; that cancel() will drain it on its way out.
107 ctx.pending.push_back(h);
108 return;
109 }
110 // Defensive fallback: a deferred resume was scheduled outside any
111 // active cancel() broadcast. Drain it inline (still on the caller's
112 // own frame, not nested inside `std::function::operator()`).
113 ctx.draining = true;
114 ctx.pending.push_back(h);
115 // Drain in FIFO order; new entries pushed during a resume are
116 // appended to the same vector and processed before we exit the loop.
117 // A resume can throw (foreign promises may have non-noexcept
118 // unhandled_exception); we are noexcept ourselves and must not let
119 // the exception escape — if it did, the caller (a cancellation
120 // callback path) would call std::terminate. Swallow it after
121 // restoring `draining = false` so the thread-local context is left
122 // in a clean state for the next cancel().
123 for (std::size_t i = 0; i < ctx.pending.size(); ++i) {
124 auto coro = ctx.pending[i];
125 if (!coro) continue;
126 try {
127 coro.resume();
128 } catch (...) {
129 // Drop — deferred-resume is a transport, not the right place
130 // to attribute exceptions to a specific awaiter.
131 }
132 }
133 ctx.pending.clear();
134 ctx.draining = false;
135}
136
137// RAII helper used by CancellationSource::cancel() to make itself the
138// owner of the drain phase if no outer cancel() is already active.
139class DrainScope {
140public:
141 DrainScope() noexcept {
142 auto& ctx = deferred_resume_context();
143 is_owner_ = !ctx.draining;
144 if (is_owner_) {
145 ctx.draining = true;
146 }
147 }
148 ~DrainScope() noexcept {
149 if (!is_owner_) return;
150 auto& ctx = deferred_resume_context();
151 // Drain in FIFO order. Resuming a coroutine may push new
152 // handles via further deferred resumes nested under it; the
153 // index-based loop naturally picks those up.
154 //
155 // We are noexcept; a foreign coroutine handle whose
156 // unhandled_exception is not noexcept could in theory throw
157 // out of resume(). Swallow it so the thread-local context is
158 // restored cleanly for the next cancel(); the exception is
159 // dropped because the deferred-resume queue is just a
160 // transport — not the right place to attribute failures to a
161 // specific awaiter.
162 for (std::size_t i = 0; i < ctx.pending.size(); ++i) {
163 auto coro = ctx.pending[i];
164 if (!coro) continue;
165 try {
166 coro.resume();
167 } catch (...) {
168 // intentional drop
169 }
170 }
171 ctx.pending.clear();
172 ctx.draining = false;
173 }
174 DrainScope(const DrainScope&) = delete;
175 DrainScope& operator=(const DrainScope&) = delete;
176 [[nodiscard]] bool is_owner() const noexcept { return is_owner_; }
177private:
178 bool is_owner_ = false;
179};
180} // namespace detail
181
183public:
184 CancellationToken() = default;
185 explicit CancellationToken(std::shared_ptr<detail::CancellationState> s)
186 : state_(std::move(s)) {}
187
188 [[nodiscard]] bool is_cancelled() const noexcept {
189 return state_ && state_->cancelled.load(std::memory_order_acquire);
190 }
191
193 void throw_if_cancelled() const {
194 if (is_cancelled()) throw OperationCancelled{};
195 }
196
199 void on_cancel(std::function<void()> cb) {
200 if (!state_) return;
201 if (is_cancelled()) { cb(); return; }
202 std::lock_guard lk(state_->m);
203 if (state_->cancelled.load(std::memory_order_acquire)) {
204 // Race: cancellation happened while we were locking — fire now.
205 cb();
206 } else {
207 state_->callbacks.push_back(std::move(cb));
208 }
209 }
210
212 [[nodiscard]] static CancellationToken none() noexcept { return {}; }
213
214private:
215 std::shared_ptr<detail::CancellationState> state_;
216};
217
219public:
220 CancellationSource() : state_(std::make_shared<detail::CancellationState>()) {}
221
222 [[nodiscard]] CancellationToken token() const noexcept {
223 return CancellationToken{state_};
224 }
225
239 void cancel() {
240 if (!state_) return;
241 bool expected = false;
242 if (!state_->cancelled.compare_exchange_strong(
243 expected, true, std::memory_order_acq_rel)) {
244 return; // already cancelled
245 }
246 // Become the drain owner if no outer cancel() is already in the
247 // broadcast phase on this thread. The destructor of `drain`
248 // will run pending coroutine resumes on this function's frame
249 // — not nested inside any `std::function` invocation.
250 detail::DrainScope drain;
251 std::vector<std::function<void()>> cbs;
252 {
253 std::lock_guard lk(state_->m);
254 cbs.swap(state_->callbacks);
255 }
256 for (auto& c : cbs) {
257 try { c(); } catch (...) {}
258 }
259 // ~DrainScope here resumes any deferred coroutine handles.
260 }
261
262 [[nodiscard]] bool is_cancelled() const noexcept {
263 return state_ && state_->cancelled.load(std::memory_order_acquire);
264 }
265
268
273 if (this != &other) {
274 // Release the previous source just as destruction would: its
275 // tokens may still own the state and have parked waiters.
276 cancel();
277 state_ = std::move(other.state_);
278 }
279 return *this;
280 }
281
282private:
283 std::shared_ptr<detail::CancellationState> state_;
284};
285
286} // namespace aria::async
CancellationSource(const CancellationSource &)=delete
CancellationSource()
Definition cancellation.hpp:220
CancellationSource & operator=(CancellationSource &&other) noexcept
Definition cancellation.hpp:272
~CancellationSource()
Auto-cancel on destruction — perfect for ViewModelScope.
Definition cancellation.hpp:267
CancellationSource(CancellationSource &&)=default
bool is_cancelled() const noexcept
Definition cancellation.hpp:262
CancellationSource & operator=(const CancellationSource &)=delete
void cancel()
Trigger cancellation.
Definition cancellation.hpp:239
CancellationToken token() const noexcept
Definition cancellation.hpp:222
Definition cancellation.hpp:182
void on_cancel(std::function< void()> cb)
Register a callback fired (synchronously) when source is cancelled.
Definition cancellation.hpp:199
CancellationToken(std::shared_ptr< detail::CancellationState > s)
Definition cancellation.hpp:185
void throw_if_cancelled() const
Throw OperationCancelled if cancelled. Call at safe await points.
Definition cancellation.hpp:193
bool is_cancelled() const noexcept
Definition cancellation.hpp:188
static CancellationToken none() noexcept
Always-cancellable empty token (useful as a default).
Definition cancellation.hpp:212
Definition cancellation.hpp:31
const char * what() const noexcept override
Definition cancellation.hpp:33
Definition async_command.hpp:118
Definition validation_key.hpp:110