include/morph/forms/flows.hpp's destructor comment makes two claims in one
paragraph that cannot both be true:
/// @brief Refuses every callback this session installed.
///
/// There is nothing to detach. A step's callbacks are `.then`/`.onError`
/// continuations on the `Completion` that `BridgeHandler::execute<A>()`
/// returned — already owned by the in-flight dispatch, not held in a map
/// this session could remove itself from, and possibly running on another
/// thread the moment this destructor starts. `_callbacks` gates every
/// installed callback on a token the callback checks *before* touching
/// `this`, so a callback still in flight when this destructor runs is
/// refused instead of touching a partially- or fully-destroyed object.
~FlowSession() { _callbacks.requestStop(); }
The first half is right: a continuation may be running on another thread when
the destructor starts. The second half — that such a callback "is refused
instead of touching a partially- or fully-destroyed object" — is stronger than
anything CallbackScope provides in that situation.
Why it is not true as written
requestStop() is a single store (include/morph/core/callback_scope.hpp):
void requestStop() noexcept {
if (_state != nullptr) {
_state->stopped.store(true, std::memory_order_release);
}
}
and the token's own documentation is explicit that the check is not a barrier:
/// @brief Whether a callback gated on this token may run right now.
///
/// Advisory across threads: true here means "was active at the moment of
/// the check". See `CallbackScope`'s "Boundary of the guarantee".
[[nodiscard]] bool active() const noexcept { ... }
Nothing drains. A continuation that read Active a moment before
requestStop() lands goes on to run captureResult, which takes
FlowSession::_mtx and writes _resolvedValues — on an object whose
destructor has already returned.
The full check-then-run guarantee holds only for executor-affine teardown:
destroy the session on the thread the continuations are delivered on, and the
delivery cannot be concurrent with the destructor. FlowSession is not
constrained to that. Its own member comment says as much, twenty lines below:
// ...the state a step's result/error continuation also
// touches, which runs on whatever thread/executor resolves the underlying
// BridgeHandler completion -- not necessarily this same thread.
So the class knows its continuations are cross-thread, and its destructor
comment promises a guarantee that only applies when they are not.
Why it is worth fixing rather than leaving
A reader who takes the comment at face value destroys a FlowSession from a
GUI thread while a pool-thread continuation is mid-captureResult, and gets a
data race and a use-after-free that no test will reliably show. That reader
already existed: tests/test_flows_apps.cpp's no-coalescing case was written
this way and fixed in 820b2953 on lane/d-framework — five dispatches in
flight, an inline executor putting their continuations on pool threads, and the
scope ending without draining them.
Also stale in the same comment
/// `requestStop()` is called explicitly rather than left to the member's own
/// destruction ... so anything this body does that can pump an event loop
/// (a `sendSync`-style blocking call) would otherwise deliver into a
/// half-dead session.
The body is _callbacks.requestStop(); and nothing else, so it pumps no event
loop. The rationale describes a hazard the code no longer has.
Suggested shape
Qualify it the way callback_scope.hpp does rather than deleting it: full
refusal when the session is destroyed on the delivery executor's thread;
advisory otherwise, with the caller responsible for not destroying the session
while a reply may still be delivered. If FlowSession is meant to be
destroyable from any thread with a real guarantee, that is a different and much
larger change — a drain — and should be its own ticket.
Verification status
Read, not run. The claim is about what requestStop() does and what
CallbackToken::active() documents; both are quoted above from
origin/master as merged. No race was reproduced under TSan, and none is
needed to see that the comment overstates the API it is built on.
What would close this: the comment says what CallbackScope guarantees,
including which thread the session must be destroyed on for the strong reading
to hold — or ~FlowSession acquires a drain that makes the strong reading true.
Found while working morph#432. Filed rather than folded in, per AGENTS.md.
🤖 Generated with Claude Code
https://claude.ai/code/session_0154xzWuBMPveLcdeUgydifb
include/morph/forms/flows.hpp's destructor comment makes two claims in oneparagraph that cannot both be true:
The first half is right: a continuation may be running on another thread when
the destructor starts. The second half — that such a callback "is refused
instead of touching a partially- or fully-destroyed object" — is stronger than
anything
CallbackScopeprovides in that situation.Why it is not true as written
requestStop()is a single store (include/morph/core/callback_scope.hpp):and the token's own documentation is explicit that the check is not a barrier:
Nothing drains. A continuation that read
Activea moment beforerequestStop()lands goes on to runcaptureResult, which takesFlowSession::_mtxand writes_resolvedValues— on an object whosedestructor has already returned.
The full check-then-run guarantee holds only for executor-affine teardown:
destroy the session on the thread the continuations are delivered on, and the
delivery cannot be concurrent with the destructor.
FlowSessionis notconstrained to that. Its own member comment says as much, twenty lines below:
So the class knows its continuations are cross-thread, and its destructor
comment promises a guarantee that only applies when they are not.
Why it is worth fixing rather than leaving
A reader who takes the comment at face value destroys a
FlowSessionfrom aGUI thread while a pool-thread continuation is mid-
captureResult, and gets adata race and a use-after-free that no test will reliably show. That reader
already existed:
tests/test_flows_apps.cpp's no-coalescing case was writtenthis way and fixed in
820b2953onlane/d-framework— five dispatches inflight, an inline executor putting their continuations on pool threads, and the
scope ending without draining them.
Also stale in the same comment
The body is
_callbacks.requestStop();and nothing else, so it pumps no eventloop. The rationale describes a hazard the code no longer has.
Suggested shape
Qualify it the way
callback_scope.hppdoes rather than deleting it: fullrefusal when the session is destroyed on the delivery executor's thread;
advisory otherwise, with the caller responsible for not destroying the session
while a reply may still be delivered. If
FlowSessionis meant to bedestroyable from any thread with a real guarantee, that is a different and much
larger change — a drain — and should be its own ticket.
Verification status
Read, not run. The claim is about what
requestStop()does and whatCallbackToken::active()documents; both are quoted above fromorigin/masteras merged. No race was reproduced under TSan, and none isneeded to see that the comment overstates the API it is built on.
What would close this: the comment says what
CallbackScopeguarantees,including which thread the session must be destroyed on for the strong reading
to hold — or
~FlowSessionacquires a drain that makes the strong reading true.Found while working morph#432. Filed rather than folded in, per AGENTS.md.
🤖 Generated with Claude Code
https://claude.ai/code/session_0154xzWuBMPveLcdeUgydifb