Aria 2.0.0
C++23 MVVM framework (C++20 minimum) — reactive, coroutine-first, ABI-layered
Loading...
Searching...
No Matches
scope.hpp
Go to the documentation of this file.
1#pragma once
2
3// CoroutineScope — structured concurrency primitive, modelled after
4// Kotlin's `CoroutineScope` / `viewModelScope` and Swift's `TaskGroup`.
5//
6// Contract (the things a "global-class C++ framework" must guarantee):
7//
8// * Every coroutine launched into a scope is *owned* by the scope.
9// * `cancel()` is non-blocking: it requests cancellation, no more.
10// * `cancel_and_join()` (sync) and `co_await join()` (async) wait until
11// every in-flight coroutine has exited. Tests, ViewModel teardown,
12// app shutdown — all use one of these to drain.
13// * The destructor MUST NOT let a launched coroutine outlive the scope.
14// It therefore performs `cancel_and_join()` with a bounded wait
15// (5 s by default). If the wait times out (a coroutine is stuck on
16// a non-cancellable await), the leak is reported through the async
17// error sink — we never block process exit indefinitely.
18// * Unhandled exceptions on the detached path do NOT vanish: any
19// non-`OperationCancelled` exception is forwarded to the async
20// error sink (see <aria/async/async_error_sink.hpp>).
21// * Scopes nest: a child scope constructed with a parent token is
22// automatically cancelled when the parent cancels.
23//
24// Backward-compatible API: existing `scope.launch(factory)` and
25// `scope.launch_simple(task)` calls keep working unchanged.
26//
27// CoroutineScope scope;
28// scope.launch([](CancellationToken t) -> Task<void> {
29// while (!t.is_cancelled()) {
30// co_await schedule_on(pool);
31// do_work();
32// }
33// });
34// co_await scope.join(); // wait for everyone to drain (cooperatively)
35//
36// // Or, in a synchronous teardown path:
37// scope.cancel_and_join();
38
41#include "aria/async/task.hpp"
42
43#include <atomic>
44#include <chrono>
45#include <condition_variable>
46#include <coroutine>
47#include <cstddef>
48#include <exception>
49#include <functional>
50#include <memory>
51#include <mutex>
52#include <string>
53#include <utility>
54#include <vector>
55
56namespace aria::async {
57
58namespace detail {
59
65struct ScopeState {
66 std::atomic<std::size_t> inflight{0};
67 std::mutex mu;
68 std::condition_variable cv;
69 // Awaiters waiting for `inflight == 0` (registered by `join()`).
70 std::vector<std::function<void()>> drain_waiters;
71};
72
73} // namespace detail
74
75class CoroutineScope : public std::enable_shared_from_this<CoroutineScope> {
76public:
78 CoroutineScope() : state_(std::make_shared<detail::ScopeState>()) {}
79
84 : state_(std::make_shared<detail::ScopeState>()) {
85 register_parent_link_(std::move(parent));
86 }
87
89 // Structured-concurrency invariant: no coroutine outlives the scope.
90 //
91 // `cancel_and_join` is itself `noexcept`, but the diagnostic path
92 // it routes through (`report_async_error` -> user-installed sink)
93 // can in principle throw `bad_alloc` from within the sink's
94 // implementation. A destructor is implicitly `noexcept`, so any
95 // escaping exception during stack unwinding would call
96 // `std::terminate`. Wrap defensively.
97 try {
99 } catch (...) {
100 // Last line of defence — an exception from the async error
101 // sink is itself a (non-fatal) async error; we can't report
102 // it back through the same channel without recursing, so
103 // we silently drop. The leak (if any) was already accounted
104 // for via `inflight_` reads.
105 }
106 }
107
112
113 // ── Inspection ────────────────────────────────────────────────────
114
115 [[nodiscard]] CancellationToken token() const noexcept { return src_->token(); }
116 [[nodiscard]] bool is_cancelled() const noexcept { return src_->is_cancelled(); }
117
120 [[nodiscard]] std::size_t inflight_count() const noexcept {
121 return state_->inflight.load(std::memory_order_acquire);
122 }
123
124 // ── Cancellation / join ───────────────────────────────────────────
125
128 void cancel() noexcept {
129 auto source = src_;
130 try { source->cancel(); } catch (...) {}
131 }
132
138 bool cancel_and_join(std::chrono::milliseconds timeout =
139 std::chrono::milliseconds{5000}) noexcept {
140 cancel();
141 std::unique_lock lk(state_->mu);
142 const bool drained = state_->cv.wait_for(lk, timeout, [this] {
143 return state_->inflight.load(std::memory_order_acquire) == 0;
144 });
145 if (!drained) {
146 const auto leaked =
147 state_->inflight.load(std::memory_order_acquire);
148 // A bounded synchronous wait must not complete asynchronous
149 // joiners early. Their shared state remains alive until the last
150 // task exits and resumes them through decrement_inflight_.
151 lk.unlock();
153 std::string("CoroutineScope: dtor leaked ") +
154 std::to_string(leaked) +
155 " task(s) (cancellation observed but coroutines did not "
156 "exit within the timeout)");
157 }
158 return drained;
159 }
160
165 struct JoinAwaiter {
166 std::shared_ptr<detail::ScopeState> st;
167
168 bool await_ready() const noexcept {
169 return st->inflight.load(std::memory_order_acquire) == 0;
170 }
171
189 bool await_suspend(std::coroutine_handle<> h) {
190 std::unique_lock lk(st->mu);
191 if (st->inflight.load(std::memory_order_acquire) == 0) {
192 return false;// resume in place, on the caller's frame
193 }
194 st->drain_waiters.emplace_back([h]() mutable { h.resume(); });
195 return true;
196 }
197
198 void await_resume() const noexcept {}
199 };
200
205 [[nodiscard]] JoinAwaiter join() noexcept {
206 cancel();
207 return JoinAwaiter{state_};
208 }
209
212 [[nodiscard]] JoinAwaiter join_existing() noexcept {
213 return JoinAwaiter{state_};
214 }
215
216 // ── Launch ────────────────────────────────────────────────────────
217
262 template<typename Fn>
263 void launch(Fn&& factory) {
264 using FnDecay = std::decay_t<Fn>;
265 spawn_tracked_(
266 launch_owner_coro_<FnDecay>(std::forward<Fn>(factory), token()));
267 }
268
288 spawn_tracked_(std::move(task));
289 }
290
291private:
292 // ── Tracked spawn ─────────────────────────────────────────────────
293
302 static void decrement_inflight_(
303 const std::shared_ptr<detail::ScopeState>& st) noexcept {
304 if (!st) return;
305 if (st->inflight.fetch_sub(1, std::memory_order_acq_rel) == 1) {
306 // Last one out: fire drain waiters and notify cv.
307 std::vector<std::function<void()>> waiters;
308 {
309 std::lock_guard lk(st->mu);
310 waiters.swap(st->drain_waiters);
311 }
312 st->cv.notify_all();
313 for (auto& w : waiters) {
314 try { w(); } catch (...) {}
315 }
316 } else {
317 // Not the last; still wake any timed waiters that may
318 // want to observe progress (cheap).
319 st->cv.notify_all();
320 }
321 }
322
328 template<typename Fn>
329 static Task<void> launch_owner_coro_(Fn factory, CancellationToken tok) {
330 co_await factory(std::move(tok));
331 }
332
341 void spawn_tracked_(Task<void> task) {
342 // Snapshot the shared accounting block — wrapper holds it by
343 // shared_ptr so even if the scope object is destroyed first,
344 // the bookkeeping completes safely.
345 auto st = state_;
346 st->inflight.fetch_add(1, std::memory_order_acq_rel);
347 // Detached driver coroutine — captures `st` by value. If
348 // building or starting the driver throws (e.g. bad_alloc when
349 // the coroutine frame can't be allocated), we must roll back
350 // the inflight increment so the scope can still be drained.
351 try {
352 spawn_driver_(std::move(task), std::move(st)).start_detached_();
353 } catch (...) {
354 decrement_inflight_(state_);
355 throw;
356 }
357 }
358
359 static Task<void> spawn_driver_(Task<void> body,
360 std::shared_ptr<detail::ScopeState> st) {
361 // Local guard: even if the body throws synchronously before its
362 // first co_await (which a properly written Task should not, but
363 // we don't want to rely on it), the inflight counter still
364 // decrements via the destructor.
365 struct InflightGuard {
366 std::shared_ptr<detail::ScopeState> st;
367 ~InflightGuard() { CoroutineScope::decrement_inflight_(st); }
368 };
369 InflightGuard guard{std::move(st)};
370 try {
371 co_await std::move(body);
372 } catch (const OperationCancelled&) {
373 // Expected on scope.cancel(); do nothing.
374 } catch (const std::exception& e) {
376 std::string("CoroutineScope: unhandled exception in launched task: ") +
377 e.what());
378 } catch (...) {
380 "CoroutineScope: unhandled non-std exception in launched task");
381 }
382 co_return;
383 }
384
385 // ── Parent linkage ────────────────────────────────────────────────
386
391 void register_parent_link_(CancellationToken parent) {
392 // Keep the source alive for the entire broadcast, even if a child
393 // cancellation callback destroys the scope. No proxy lock may be held
394 // while invoking callbacks supplied by the application.
395 parent.on_cancel([weak = std::weak_ptr<CancellationSource>(src_)] {
396 if (auto source = weak.lock()) source->cancel();
397 });
398 }
399
400 std::shared_ptr<CancellationSource> src_ =
401 std::make_shared<CancellationSource>();
402 std::shared_ptr<detail::ScopeState> state_;
403};
404
477inline auto operator co_await(CancellationToken tok) {
478 struct Latch {
479 enum State : int {
480 preparing = 0,
481 suspended = 1,
482 cancellation_before_suspend = 2,
483 resumed = 3,
484 };
485
486 std::atomic<int> state{preparing};
487 };
488 struct Awaiter {
490 std::shared_ptr<Latch> latch;
491
492 bool await_ready() const noexcept { return tok.is_cancelled(); }
493
494 bool await_suspend(std::coroutine_handle<> h) {
495 auto l = latch;
496 // Register the cancellation callback. It may fire
497 // synchronously right here if `tok` is already cancelled,
498 // or asynchronously later from another thread. Synchronous
499 // firing while we are still preparing records cancellation
500 // without resuming on this stack.
501 tok.on_cancel([h, l]() mutable {
502 int observed = l->state.load(std::memory_order_acquire);
503 for (;;) {
504 if (observed == Latch::preparing) {
505 if (l->state.compare_exchange_weak(
506 observed,
507 Latch::cancellation_before_suspend,
508 std::memory_order_acq_rel,
509 std::memory_order_acquire)) {
510 return;
511 }
512 continue;
513 }
514 if (observed == Latch::suspended) {
515 if (l->state.compare_exchange_weak(
516 observed,
517 Latch::resumed,
518 std::memory_order_acq_rel,
519 std::memory_order_acquire)) {
520 // Defer the resume: see contract (1)/(2)
521 // above. The topmost cancel() on this
522 // thread will drain the pending list once
523 // all callbacks return.
524 detail::schedule_deferred_resume(h);
525 return;
526 }
527 continue;
528 }
529 return;
530 }
531 });
532
533 // Commit to suspension only if no cancellation callback won
534 // while we were registering it. If the callback already moved
535 // the state to `cancellation_before_suspend`, returning false
536 // lets the compiler resume safely on the caller's stack.
537 int expected = Latch::preparing;
538 return l->state.compare_exchange_strong(
539 expected,
540 Latch::suspended,
541 std::memory_order_acq_rel,
542 std::memory_order_acquire);
543 }
544
545 // Intentionally non-throwing. The body must follow up with an
546 // explicit `tok.throw_if_cancelled()` probe (see the class
547 // comment for why we don't throw here).
548 void await_resume() noexcept {}
549 };
550 return Awaiter{std::move(tok), std::make_shared<Latch>()};
551}
552
553} // namespace aria::async
Definition cancellation.hpp:182
void on_cancel(std::function< void()> cb)
Register a callback fired (synchronously) when source is cancelled.
Definition cancellation.hpp:199
bool is_cancelled() const noexcept
Definition cancellation.hpp:188
CoroutineScope(CancellationToken parent)
Child scope: cancelling parent cancels this scope as well.
Definition scope.hpp:83
bool is_cancelled() const noexcept
Definition scope.hpp:116
~CoroutineScope()
Definition scope.hpp:88
CoroutineScope(const CoroutineScope &)=delete
CoroutineScope & operator=(const CoroutineScope &)=delete
CoroutineScope(CoroutineScope &&)=delete
void cancel() noexcept
Request cancellation.
Definition scope.hpp:128
CoroutineScope()
Default scope — fully independent, no parent linkage.
Definition scope.hpp:78
void launch(Fn &&factory)
Launch a coroutine factory Task<void> fn(CancellationToken).
Definition scope.hpp:263
bool cancel_and_join(std::chrono::milliseconds timeout=std::chrono::milliseconds{5000}) noexcept
Synchronously: cancel + wait for all in-flight coroutines to finish, with a bounded timeout (default ...
Definition scope.hpp:138
JoinAwaiter join_existing() noexcept
Like join() but does NOT request cancellation first — it just waits for whatever is currently in flig...
Definition scope.hpp:212
CoroutineScope & operator=(CoroutineScope &&)=delete
JoinAwaiter join() noexcept
Awaitable equivalent of cancel_and_join() — request cancellation then suspend the calling coroutine u...
Definition scope.hpp:205
CancellationToken token() const noexcept
Definition scope.hpp:115
void launch_simple(Task< void > task)
Convenience overload for a fully-formed Task<void> whose body already captures the cancellation token...
Definition scope.hpp:287
std::size_t inflight_count() const noexcept
Number of coroutines currently in flight (launched but not yet returned).
Definition scope.hpp:120
Definition task.hpp:78
Definition async_command.hpp:118
void report_async_error(std::string_view msg) noexcept
Definition async_error_sink.hpp:59
Definition validation_key.hpp:110
Awaitable resumed when in-flight count reaches zero.
Definition scope.hpp:165
std::shared_ptr< detail::ScopeState > st
Definition scope.hpp:166
bool await_ready() const noexcept
Definition scope.hpp:168
void await_resume() const noexcept
Definition scope.hpp:198
bool await_suspend(std::coroutine_handle<> h)
Returns false when the scope already drained between await_ready() and here, telling the compiler to ...
Definition scope.hpp:189