morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
callback_scope.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <atomic>
5#include <core/async/StopToken.hpp>
6#include <cstdint>
7#include <functional>
8#include <memory>
9#include <type_traits>
10#include <utility>
11
12namespace morph::async {
13
14namespace detail {
15
16/// Shared state behind one `CallbackScope` generation. The control block
17/// carries liveness, the atomic carries stop for gating, and `stop` carries it
18/// to the calls the scope owns: a `Completion` attached through a token of
19/// this generation links the call's own stop source to it.
20struct CallbackScopeState {
21 std::atomic<bool> stopped{false};
22 ::core::async::StopSource stop;
23};
24
25} // namespace detail
26
27/// @brief Three-way answer to "may this callback still be delivered?".
28///
29/// Liveness and stop are *different* states and are kept distinguishable:
30/// "the owner is gone" and "the owner is still here but cancelled this" have
31/// the same effect on delivery but very different meanings for diagnostics,
32/// logging and tests. `CallbackToken::status()` reports which one applies.
33enum class CallbackStatus : std::uint8_t {
34 /// The owning `CallbackScope` still exists and has not been stopped —
35 /// callbacks gated on this token run.
36 Active,
37 /// The owning `CallbackScope` still exists but `requestStop()` was called
38 /// on this generation — the owner is alive and no longer interested.
39 Stopped,
40 /// The owning `CallbackScope` was destroyed, or `reset()` retired the
41 /// generation this token was issued from.
42 Expired,
43};
44
45/// @brief Weak, copyable observer of a `CallbackScope` — the half a callback captures.
46///
47/// A token keeps nothing alive. It answers whether the scope that issued it is
48/// still both present and interested, and it can wrap an arbitrary callable so
49/// that the callable no-ops once the answer turns negative.
50///
51/// A default-constructed token is permanently `CallbackStatus::Expired`:
52/// gating is fail-closed, so a token that was never bound to a scope suppresses
53/// rather than admits. That trades a silently-dropped callback for never
54/// dereferencing a freed receiver, which is the trade this whole type exists to
55/// make.
56///
57/// @par Thread safety
58/// Every member is safe to call from any thread, concurrently with the
59/// scope's `requestStop()` / `reset()` / destruction. What the *result* is worth
60/// across threads is a separate question — see `CallbackScope`'s
61/// "Boundary of the guarantee".
63public:
64 /// @brief Constructs an unbound token, permanently `CallbackStatus::Expired`.
65 CallbackToken() = default;
66
67 /// @brief Reports whether the issuing scope is alive, stopped, or gone.
68 /// @return `Active`, `Stopped` or `Expired` — see `CallbackStatus`.
69 [[nodiscard]] CallbackStatus status() const noexcept {
70 auto const state = _state.lock();
71 if (state == nullptr) {
72 return CallbackStatus::Expired;
73 }
74 return state->stopped.load(std::memory_order_acquire) ? CallbackStatus::Stopped : CallbackStatus::Active;
75 }
76
77 /// @brief Whether a callback gated on this token may run right now.
78 ///
79 /// Advisory across threads: true here means "was active at the moment of
80 /// the check". See `CallbackScope`'s "Boundary of the guarantee".
81 /// @return `true` iff `status() == CallbackStatus::Active`.
82 [[nodiscard]] bool active() const noexcept { return status() == CallbackStatus::Active; }
83
84 /// @brief Whether the issuing scope generation is gone (destroyed or `reset()`).
85 ///
86 /// Cheaper than `status()` — no strong reference is taken — and unlike
87 /// `active()` it does not conflate "gone" with "stopped".
88 /// @return `true` iff the issuing generation no longer exists.
89 [[nodiscard]] bool expired() const noexcept { return _state.expired(); }
90
91 /// @brief The stop token of the issuing scope's generation: stop is
92 /// requested on it when the scope is stopped, reset or destroyed.
93 ///
94 /// What a `Completion` attached through this token links its call's stop
95 /// source to, so stopping the scope stops the call as well as refusing its
96 /// callbacks. See `docs/spec/core/callback_scope.md`, "Stopping the calls a
97 /// scope owns".
98 /// @return The generation's token, or a token with no stop state once the
99 /// generation is gone.
100 [[nodiscard]] ::core::async::StopToken stopToken() const noexcept {
101 auto const state = _state.lock();
102 return state == nullptr ? ::core::async::StopToken{} : state->stop.get_token();
103 }
104
105 /// @brief Wraps @p fn so it runs only while this token is `Active`.
106 ///
107 /// The returned callable forwards every argument to @p fn and returns
108 /// nothing. When the token is not `Active` at invocation time it does
109 /// nothing at all — @p fn is not called. The wrapper holds a *copy of the
110 /// token*, never a strong reference, so wrapping a callback cannot keep the
111 /// receiver's scope alive.
112 ///
113 /// A suppressed callback is destroyed, not leaked: the wrapper (and with it
114 /// @p fn and its captures) is released wherever the refused task is
115 /// discarded — normally on the delivery executor's thread.
116 ///
117 /// @tparam F Callable type to wrap. Must return `void` for every argument
118 /// list it is invoked with; a value-returning callable has no
119 /// defensible answer for the suppressed case. Rejected by a
120 /// `static_assert` in the returned wrapper's body, so it fires
121 /// when the wrapper is *invoked* -- a wrapper that is created and
122 /// never called compiles either way.
123 /// @param fn Callable to gate. Moved into the returned wrapper.
124 /// @return A callable with @p fn's argument list and a `void` return.
125 template <typename F>
126 [[nodiscard]] auto guard(F&& fn) const {
127 return [token = *this, fn = std::forward<F>(fn)](auto&&... args) mutable -> void {
128 static_assert(std::is_void_v<std::invoke_result_t<F&, decltype(args)...>>,
129 "CallbackToken::guard() only wraps void-returning callables: there is no correct value to "
130 "return when delivery is suppressed.");
131 if (token.status() != CallbackStatus::Active) {
132 return;
133 }
134 std::invoke(fn, std::forward<decltype(args)>(args)...);
135 };
136 }
137
138private:
139 friend class CallbackScope;
140
141 explicit CallbackToken(std::weak_ptr<detail::CallbackScopeState> state) noexcept : _state{std::move(state)} {}
142
143 std::weak_ptr<detail::CallbackScopeState> _state;
144};
145
146/// @brief Owned lifetime-and-stop gate for asynchronous callbacks — a data member, not a base class.
147///
148/// A receiver that attaches callbacks to a `Completion<T>`, a `BridgeHandler`
149/// subscription, a timer tick or any other posted closure holds one of these as
150/// an ordinary member and hands out `CallbackToken`s. A gated callback runs only
151/// while the scope is **both alive and not stopped**.
152///
153/// Composition is the point. Requiring every consumer to derive from a framework
154/// base class constrains hierarchies that are already `QObject`s, already have a
155/// base, or are aggregates — for what is an implementation detail of one screen.
156/// A member composes with all of them, and code that never opts in is unaffected.
157///
158/// @code
159/// class BoardPresenter {
160/// void load() {
161/// _handler.execute(GetBoard{}).then(_callbacks, [this](GetBoardResult r) { render(r); });
162/// }
163/// void onUserNavigatedAway() { _callbacks.requestStop(); } // alive, but no longer interested
164/// void onNewQuery() { _callbacks.reset(); } // supersede: old replies dead, new ones live
165///
166/// morph::async::CallbackScope _callbacks; // declared LAST — see below
167/// };
168/// @endcode
169///
170/// @par Three verbs, two states
171/// - `requestStop()` — "I no longer care". The owner is still here; pending and
172/// future callbacks on this generation are refused. Idempotent.
173/// - `reset()` — "supersede". Every token issued so far goes permanently dead
174/// and the scope becomes deliverable again under a fresh generation, so
175/// "a new query cancels the old one" needs no heap-reallocated scope.
176/// - destruction — the same refusal as `requestStop()`, plus the owner is gone.
177///
178/// Destruction and `requestStop()` are indistinguishable *to a callback*; they
179/// differ only in whether the owner still exists, which `CallbackToken::status()`
180/// reports as `Expired` versus `Stopped`.
181///
182/// @par Declared last, destroyed first
183/// Put the member **after** (below) everything its callbacks touch. Members are
184/// destroyed in reverse declaration order, so a last-declared scope is the first
185/// thing to die and every gated callback is already refused before the fields it
186/// would have touched are torn down. This replaces the per-class "token must
187/// stay last-declared" convention that used to be re-derived and re-documented
188/// in every class that hand-rolled the pattern.
189///
190/// @par Teardown that pumps
191/// Members are destroyed *after* the destructor body runs. A destructor body
192/// that can pump a nested event loop (a blocking call that spins a
193/// `QEventLoop`) can therefore still deliver into a half-destroyed receiver.
194/// Such a destructor must call `requestStop()` as its first statement.
195///
196/// @par Boundary of the guarantee
197/// - **Executor-affine use gets the full guarantee.** When the scope is
198/// destroyed / stopped / reset on the *same* thread the delivery executor runs
199/// on — the normal case, where receiver and callbacks both live on the GUI
200/// thread — check-then-run is atomic with respect to those operations, and a
201/// gated callback never touches a dead or stopped receiver.
202/// - **Cross-thread stop is advisory.** A scope torn down on a different thread
203/// from the delivery executor can turn inactive between the token's check and
204/// the callback body. That is exactly the boundary of the hand-rolled
205/// `weak_ptr` idiom this type replaces — locking the token pins the *token*,
206/// never the receiver — and external synchronisation there remains the
207/// caller's job.
208/// - **Deliberately no block-until-drained.** Neither `requestStop()` nor the
209/// destructor waits for an in-flight callback to finish, `QObject::disconnect`
210/// style. A GUI-thread destructor blocking on a pool-thread callback that is
211/// itself blocked posting back to the GUI executor is a deadlock by
212/// construction.
213///
214/// @par Thread safety
215/// `token()`, `guard()`, `requestStop()`, `stopRequested()` and the destructor
216/// are safe to call concurrently from any thread. `stopRequested()` is a
217/// lock-free atomic load; `requestStop()` stores the same atomic and then
218/// requests stop on the generation's `core::async::StopSource`, which runs the
219/// stop of every call the scope owns on the calling thread.
220///
221/// **`reset()` is the exception and must be externally synchronised** against
222/// the others — call it from the thread that owns the scope. It replaces the
223/// `_state` handle itself, and reading a `shared_ptr` while another thread
224/// assigns it is a data race on the handle: the control block's refcount is
225/// atomic, the pointer object is not. That narrowing is a portability
226/// constraint, not taste — see `_state`'s own comment. In the usage `reset()` exists for
227/// (`void onNewQuery() { _callbacks.reset(); }`, the supersede verb) the owning
228/// thread is the caller anyway. Within a single generation the guarantee is
229/// unconditional: `reset()` stops the outgoing generation before releasing it,
230/// so a token holder that pinned it still observes refusal.
231///
232/// Identity, not a value: neither copyable nor movable. A moved-from scope would
233/// have to either strand or silently retarget tokens already captured in flight;
234/// `reset()` covers the one case (regeneration) that motivates a move at all.
236public:
237 /// @brief Constructs a live, un-stopped scope.
238 CallbackScope() : _state{std::make_shared<detail::CallbackScopeState>()} {}
239
240 /// @brief Refuses every outstanding token, then releases the state.
241 ///
242 /// Equivalent, from a pending callback's point of view, to `requestStop()`.
243 /// Does not wait for in-flight callbacks — see "Boundary of the guarantee".
245
246 CallbackScope(const CallbackScope&) = delete;
247 CallbackScope& operator=(const CallbackScope&) = delete;
248 CallbackScope(CallbackScope&&) = delete;
249 CallbackScope& operator=(CallbackScope&&) = delete;
250
251 /// @brief Marks this generation stopped: gated callbacks stop being
252 /// delivered, and every call the scope owns is asked to stop.
253 ///
254 /// A call is owned by the scope once a `Completion` carrying a stop source
255 /// (a `Bridge` call whose handler returns a `Task`) has a callback attached
256 /// through one of its tokens; its stop is requested here, on this thread,
257 /// before this returns. Idempotent and safe from any thread. The owner
258 /// remains alive, so tokens report `CallbackStatus::Stopped` rather than
259 /// `Expired`. Undone only by `reset()`, which starts a new generation.
260 void requestStop() const noexcept {
261 // `_state` is never null: the sole constructor make_shared's it, the
262 // class is non-copyable and non-movable, and `reset()` always assigns a
263 // fresh value. A `!= nullptr` guard here would be an unreachable branch.
264 _state->stopped.store(true, std::memory_order_release);
265 static_cast<void>(_state->stop.request_stop());
266 }
267
268 /// @brief Retires every token issued so far and starts a fresh, live generation.
269 ///
270 /// The supersede verb: an old in-flight reply can no longer be delivered
271 /// (its token reports `Expired`), while callbacks attached *after* this call
272 /// are deliverable again even if `requestStop()` had been called before it.
273 /// The outgoing generation is stopped before it is released, so a token
274 /// holder that pinned it while racing this call still observes refusal.
275 void reset() {
276 auto fresh = std::make_shared<detail::CallbackScopeState>();
277 requestStop();
278 _state = std::move(fresh);
279 }
280
281 /// @brief Whether this generation has been stopped.
282 /// @return `true` after `requestStop()`, until the next `reset()`.
283 [[nodiscard]] bool stopRequested() const noexcept { return _state->stopped.load(std::memory_order_acquire); }
284
285 /// @brief Issues a weak token for the current generation.
286 /// @return A `CallbackToken` observing this scope; keeps nothing alive.
287 [[nodiscard]] CallbackToken token() const noexcept { return CallbackToken{_state}; }
288
289 /// @brief Wraps @p fn so it runs only while this scope is alive and un-stopped.
290 ///
291 /// Forwards to `CallbackToken::guard()` on a token for the *current*
292 /// generation, so a later `reset()` retires the wrapper. This is the
293 /// general-purpose form for the callbacks that are not `Completion`
294 /// attachments — timer ticks, `IExecutor::post` closures, poller dispatch,
295 /// event sinks.
296 ///
297 /// @tparam F Callable type to wrap; must return `void` (see `CallbackToken::guard()`).
298 /// @param fn Callable to gate. Moved into the returned wrapper.
299 /// @return A callable with @p fn's argument list and a `void` return.
300 template <typename F>
301 [[nodiscard]] auto guard(F&& fn) const {
302 return token().guard(std::forward<F>(fn));
303 }
304
305private:
306 /// Written by `reset()` and read by every other member. **Not** atomic, and
307 /// that is a constraint rather than a preference: `std::atomic<std::shared_ptr<T>>`
308 /// is a C++20 library feature libstdc++ provides and libc++ (as shipped with
309 /// emscripten, which this project builds for) does not -- it falls back to
310 /// the primary template and hard-errors on `is_trivially_copyable`. The
311 /// alternative, a mutex, would cost `token()`/`stopRequested()` their
312 /// `noexcept`. So the concurrency contract is narrowed instead; see this
313 /// class's own Thread safety paragraph.
314 std::shared_ptr<detail::CallbackScopeState> _state;
315};
316
317} // namespace morph::async
Owned lifetime-and-stop gate for asynchronous callbacks — a data member, not a base class.
auto guard(F &&fn) const
Wraps fn so it runs only while this scope is alive and un-stopped.
~CallbackScope()
Refuses every outstanding token, then releases the state.
CallbackToken token() const noexcept
Issues a weak token for the current generation.
CallbackScope()
Constructs a live, un-stopped scope.
bool stopRequested() const noexcept
Whether this generation has been stopped.
void requestStop() const noexcept
Marks this generation stopped: gated callbacks stop being delivered, and every call the scope owns is...
void reset()
Retires every token issued so far and starts a fresh, live generation.
Weak, copyable observer of a CallbackScope — the half a callback captures.
CallbackStatus status() const noexcept
Reports whether the issuing scope is alive, stopped, or gone.
bool expired() const noexcept
Whether the issuing scope generation is gone (destroyed or reset()).
auto guard(F &&fn) const
Wraps fn so it runs only while this token is Active.
::core::async::StopToken stopToken() const noexcept
The stop token of the issuing scope's generation: stop is requested on it when the scope is stopped,...
bool active() const noexcept
Whether a callback gated on this token may run right now.
CallbackToken()=default
Constructs an unbound token, permanently CallbackStatus::Expired.