Summary
Bridge gates five of its own callbacks on CallbackToken::active() and then
touches this. That is the same check-then-act shape as morph#486, which was a
use-after-free — and #486's fix (PR #488) deliberately covers only
~BridgeHandler, because that is the one site where a documented guarantee was
being broken. The other sites are untouched and are recorded here rather than
folded into that PR.
Verification status
Inferred from reading the code. Not reproduced, not measured. I did not
construct a case that lands in any of these windows, and I do not know their
rates. What is measured is that the identical shape one function away
(~BridgeHandler → Bridge::deregisterHandler) faulted 26 times in 200
unsanitized runs — see #486 and PR #488. That is evidence about the shape, not
about these five call sites.
Revision: include/morph/core/bridge.hpp at origin/master fdbfd65, line
numbers as of that revision.
The sites
Each does alive.active() / weakLiveness.active() and then dereferences
this. Between the two, another thread can complete ~Bridge.
executeVia's .then continuation (bridge.hpp:1495-1520). Reads
bool const bridgeAlive = alive.active(); then this->_pendingCalls,
onResult(...) (which for a result-keyed action reaches back into the bridge
through a captured raw pointer), hasSubscribers() and publishResult(...).
Runs on whichever thread the backend resolved on.
executeVia's .onError continuation (bridge.hpp:1555-1561). Same
check, then this->_pendingCalls.fetch_sub(...).
registerHandlerImpl's onRegistered callback (bridge.hpp:1629-1646).
Checks weakLiveness.active(), then takes _mtx and calls loadBackend() —
both members of a possibly-destroyed Bridge.
installReconnectHandler's handler (bridge.hpp:1740-1775). Checks the
token, then std::scoped_lock{_mtx, _attachMtx} and iterates _handlers.
Explicitly documented as running "on the backend's transport thread".
switchBackend's and the whenBound paths were not audited beyond the
above; treat the list as "at least these", not "exactly these".
The comments at sites 1 and 2 already reason carefully about whether the
check is needed and get that part right — the schedulerRef capture next to
them is a correct, pin-based solution to the same problem for the
TimeoutScheduler. What none of them addresses is that the check and the use
are two separate steps.
Why PR #488 did not extend the gate to these
detail::BridgeLifetime (added by #488) would close every one of these
mechanically. It was not applied here because it makes ~Bridge block, and
each site needs its own deadlock argument before that is safe:
- Site 4 is the clear counter-example.
QtWebSocketBackend::registerModel
blocks the calling thread on a nested QEventLoop, and the reconnect handler
calls registerModelWithContext. Holding a gate across that, while ~Bridge
on the Qt thread waits for it, is a deadlock by construction.
- Sites 1 and 2 call
publishResult, which reaches subscription sinks. Blocking
~Bridge behind consumer-supplied code is a different risk from blocking it
behind deregisterModel, which is provably non-blocking on every shipped
backend.
So the honest position is: the gate is the right primitive, but "apply it
everywhere" is not a safe mechanical edit, and the alternative for these sites
may be pinning (as schedulerRef already does) rather than gating.
What would change the verdict
- Close it if someone shows these windows are unreachable — e.g. that every
path into them is already ordered behind something that outlives ~Bridge.
- Raise it if any of them is reproduced. Site 4 is the most promising to
attack: a QtWebSocketBackend reconnect firing on the transport thread while
the owner destroys the Bridge.
Related
🤖 Filed with Claude Code
https://claude.ai/code/session_01C6uB9zxdSG8qp3VNAAyFvF
Summary
Bridgegates five of its own callbacks onCallbackToken::active()and thentouches
this. That is the same check-then-act shape as morph#486, which was ause-after-free — and #486's fix (PR #488) deliberately covers only
~BridgeHandler, because that is the one site where a documented guarantee wasbeing broken. The other sites are untouched and are recorded here rather than
folded into that PR.
Verification status
Inferred from reading the code. Not reproduced, not measured. I did not
construct a case that lands in any of these windows, and I do not know their
rates. What is measured is that the identical shape one function away
(
~BridgeHandler→Bridge::deregisterHandler) faulted 26 times in 200unsanitized runs — see #486 and PR #488. That is evidence about the shape, not
about these five call sites.
Revision:
include/morph/core/bridge.hppatorigin/masterfdbfd65, linenumbers as of that revision.
The sites
Each does
alive.active()/weakLiveness.active()and then dereferencesthis. Between the two, another thread can complete~Bridge.executeVia's.thencontinuation (bridge.hpp:1495-1520). Readsbool const bridgeAlive = alive.active();thenthis->_pendingCalls,onResult(...)(which for a result-keyed action reaches back into the bridgethrough a captured raw pointer),
hasSubscribers()andpublishResult(...).Runs on whichever thread the backend resolved on.
executeVia's.onErrorcontinuation (bridge.hpp:1555-1561). Samecheck, then
this->_pendingCalls.fetch_sub(...).registerHandlerImpl'sonRegisteredcallback (bridge.hpp:1629-1646).Checks
weakLiveness.active(), then takes_mtxand callsloadBackend()—both members of a possibly-destroyed
Bridge.installReconnectHandler's handler (bridge.hpp:1740-1775). Checks thetoken, then
std::scoped_lock{_mtx, _attachMtx}and iterates_handlers.Explicitly documented as running "on the backend's transport thread".
switchBackend's and thewhenBoundpaths were not audited beyond theabove; treat the list as "at least these", not "exactly these".
The comments at sites 1 and 2 already reason carefully about whether the
check is needed and get that part right — the
schedulerRefcapture next tothem is a correct, pin-based solution to the same problem for the
TimeoutScheduler. What none of them addresses is that the check and the useare two separate steps.
Why PR #488 did not extend the gate to these
detail::BridgeLifetime(added by #488) would close every one of thesemechanically. It was not applied here because it makes
~Bridgeblock, andeach site needs its own deadlock argument before that is safe:
QtWebSocketBackend::registerModelblocks the calling thread on a nested
QEventLoop, and the reconnect handlercalls
registerModelWithContext. Holding a gate across that, while~Bridgeon the Qt thread waits for it, is a deadlock by construction.
publishResult, which reaches subscription sinks. Blocking~Bridgebehind consumer-supplied code is a different risk from blocking itbehind
deregisterModel, which is provably non-blocking on every shippedbackend.
So the honest position is: the gate is the right primitive, but "apply it
everywhere" is not a safe mechanical edit, and the alternative for these sites
may be pinning (as
schedulerRefalready does) rather than gating.What would change the verdict
path into them is already ordered behind something that outlives
~Bridge.attack: a
QtWebSocketBackendreconnect firing on the transport thread whilethe owner destroys the
Bridge.Related
~BridgeHandler, reproduced and fixed.docs/spec/core/callback_scope.md, "Boundary of the guarantee" — whyCallbackToken::active()cannot carry this weight.🤖 Filed with Claude Code
https://claude.ai/code/session_01C6uB9zxdSG8qp3VNAAyFvF