core: HasLifetime and lifetime-bound Completion overloads (stage 1, draft) - #150
Closed
Yaraslaut wants to merge 3 commits into
Closed
core: HasLifetime and lifetime-bound Completion overloads (stage 1, draft)#150Yaraslaut wants to merge 3 commits into
Yaraslaut wants to merge 3 commits into
Conversation
A Completion always resolves through an executor -- even local mode's
immediate resolution is posted, not inline -- so a receiver can always be
destroyed before its callback runs, and `completion.then([this]{...})` is
silently wrong. Correctness currently depends on every author remembering a
two-part incantation: declare a shared_ptr<const void> token, capture its
weak form, re-check before touching `this`. Five classes reimplement it
across 23 call sites, two more use QPointer for the same hazard, and #137
was a real use-after-free from forgetting it.
This is stage 1 of the staging #138 itself proposes -- the primitive plus
the explicit unguarded spelling, no caller migration:
- morph::async::HasLifetime: opt-in base owning the token. Not
enable_shared_from_this (would force heap allocation and shared ownership
on stack-allocated presenters and QML-owned bridges) and not QPointer
(would force Qt into morph core, which Bridge and EventPoller must not
depend on).
- Completion<T>::then(Owner*, Handler) and onError(Owner*, Handler),
constrained on a LifetimeBound concept so a receiver that has not opted
in is a compile error rather than a silent fall-through to the unguarded
overload.
- thenDetached/onErrorDetached, exact aliases of the unguarded overloads,
so a genuinely detached callback says so and the unguarded spelling stays
greppable.
Copy and move give the new object its own token: a token is an identity,
not a value, and sharing one would let a copy's death silence the
original's callbacks.
The tests hold call counters in shared_ptrs that outlive the receiver, so
"the callback body did not run" is directly observable rather than resting
on undefined behaviour being detected. Mutation-verified: disabling the
expiry check fails 3 assertions.
Documented in docs/spec/core/completion.md, including the boundary of the
guarantee -- it closes the "destroyed before the reply arrived" hole, not a
cross-thread race.
Refs #138
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oncept Two -Werror failures from the docs gates, both in the new header: - Doxygen's WARN_AS_ERROR rejected the undocumented copy/move members. They are user-declared rather than defaulted precisely because each gives the new object its own token, so they needed the reasoning written down anyway. - clang's -Wdocumentation rejects @tparam on a concept, which it does not treat as a template declaration. Removed, with a note so it does not come back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ion itself The note explaining why the concept carries no per-parameter doc command spelled that command out literally, and clang parses it inside the comment regardless of the surrounding backticks -- so the explanation reproduced the very error it was explaining. Verified with a direct clang++ -Wdocumentation -Werror syntax-only compile of both headers, which is what the ordinary local build was not exercising. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Member
Author
|
Closed in favour of a generalised design; see the rewritten #138. Two problems with what this PR shipped:
The replacement is a separate token type held as a data member, which the handler captures and checks, and which also carries an explicit stop mechanism. Design is in #138. |
This was referenced Aug 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft — stage 1 of #138 only. The primitive and its tests, no caller migration. Opened as a draft per your instruction that library features come to you unfinished.
What's here
morph::async::HasLifetime(include/morph/core/lifetime.hpp) — opt-in base owning a lifetime token.Completion<T>::then(Owner*, Handler)/onError(Owner*, Handler)— constrained on aLifetimeBoundconcept, so a receiver that hasn't opted in is a compile error rather than a silent fall-through to the unguarded overload.thenDetached/onErrorDetached— exact aliases of the unguarded overloads, so a genuinely detached callback says so at the call site and the unguarded spelling stays greppable.docs/spec/core/completion.md.Design choices, and why not the alternatives
enable_shared_from_thiswould force heap allocation and shared ownership on every receiver — stack-allocated presenters and QML-owned bridges can't comply, and #137'sBoardBridgeis a stack object in the failing test.QPointerwould force Qt into morph core, andBridge/EventPollerare notQObjects. A returned cancellation handle would have to be stored and reset by the caller: the same "remember to do it" failure mode the guard exists to remove.Copy and move give the new object its own token — a token is an identity, not a value. Sharing one would let a copy's destruction silence the original's callbacks.
On the guarantee's boundary
The spec section states it plainly: this closes the ordinary "destroyed before the reply arrived" hole. A receiver destroyed on a different thread from its callback executor still needs external synchronisation — the token check is not a substitute for one. Worth your eye, since it's the kind of thing an API like this can be assumed to promise and doesn't.
Verification
Tests hold their call counters in
shared_ptrs that outlive the receiver, so "the callback body did not run" is directly observable rather than resting on undefined behaviour being detected. My first draft asserted onlyREQUIRE_NOTHROW, which would have passed with no guard at all. Mutation-verified: disabling the expiry check fails 3 assertions.I tried to confirm under ASan as well, but the ASan-instrumented
morph_testsbinary hangs on macOS here, unrelated to this change. The counter-based assertions don't depend on a sanitizer, so the evidence stands without it — but CI's ASan job is worth watching.What's left (stage 2+, yours)
_livenesstokens collapse into this.Bridge's own guards become uses of the primitive rather than a private reimplementation.docs/spec/core/bridge.mdanddocs/spec/VERSIONING.md.then(std::function)gets deprecated or kept.