Skip to content

core: HasLifetime and lifetime-bound Completion overloads (stage 1, draft) - #150

Closed
Yaraslaut wants to merge 3 commits into
masterfrom
completion-lifetime
Closed

core: HasLifetime and lifetime-bound Completion overloads (stage 1, draft)#150
Yaraslaut wants to merge 3 commits into
masterfrom
completion-lifetime

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

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 a LifetimeBound concept, 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.
  • Tests, and a new section in docs/spec/core/completion.md.
completion.then(this, [this](GetBoardResult r) { ... });  // runs only while `this` lives
completion.thenDetached([](GetBoardResult r) { ... });    // deliberately unguarded

Design choices, and why not the alternatives

enable_shared_from_this would force heap allocation and shared ownership on every receiver — stack-allocated presenters and QML-owned bridges can't comply, and #137's BoardBridge is a stack object in the failing test. QPointer would force Qt into morph core, and Bridge/EventPoller are not QObjects. 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 only REQUIRE_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_tests binary 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)

  • Migrate ~25 call sites; the 5 hand-rolled _liveness tokens collapse into this.
  • Bridge's own guards become uses of the primitive rather than a private reimplementation.
  • docs/spec/core/bridge.md and docs/spec/VERSIONING.md.
  • Decide whether the unguarded then(std::function) gets deprecated or kept.

Yaraslaut and others added 3 commits August 22, 2026 10:03
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

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 72.72727% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
include/morph/core/completion.hpp 80.00% 2 Missing and 1 partial ⚠️
include/morph/core/lifetime.hpp 57.14% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@Yaraslaut

Copy link
Copy Markdown
Member Author

Closed in favour of a generalised design; see the rewritten #138.

Two problems with what this PR shipped:

  • It used inheritance. HasLifetime as a base class is a large demand on every consumer of BridgeHandler — it constrains the type's hierarchy for what should be a plain implementation detail, and a type that already has a base (or is a QObject, or is an aggregate) pays for it.
  • It only covered liveness. "The receiver was destroyed" is one reason to drop a callback; "the GUI decided this result is no longer wanted" is another, and is not expressible at all today.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant