Summary
Bridge's _attachMtx member comment states a hard invariant:
HandlerBinding::primary/contextKey are therefore mutated (and must be read) only under _attachMtx — never under _mtx alone.
registerHandlerImpl reads binding->contextKey twice while holding neither _attachMtx nor _mtx.
Verification status
Verified by reading; not reproduced. I enumerated every access and every lock acquisition in the function rather than sampling. Revision: branch framework-comment-audit (= origin/master adfe8e5f plus doc-only commits); line numbers as of that revision.
The two reads:
bridge.hpp:1760 — binding->contextKey passed as the third argument to backend->registerModelAsync(...).
bridge.hpp:1814 — backend->registerModelWithContext(binding->typeId, binding->modelFactory, binding->contextKey), the synchronous fallback.
Every lock acquisition in the enclosing range, by line:
1742: std::scoped_lock const lock{_mtx}; <- block ends before 1760
1753: std::scoped_lock const lock{binding->registrationMtx}; <- separate scope, not _attachMtx
1774: std::shared_lock const gate{lifetime->mtx}; <- inside the callback, not this frame
1776: std::scoped_lock const lock{_mtx}; <- inside the callback
Neither read is covered. By contrast every other attach/assign site is: :507-509, :692-694, :716, :750, :826, :888, :927-931, :943-945, :955, :1298-1307, :1906-1913 all hold _attachMtx.
Why it is reachable rather than theoretical
The registerHandler() overload that takes a pre-built binding hands the caller the shared_ptr before registerHandlerImpl is ever called — registerHandlerImpl's own fallback comment says so, in the course of explaining a different race it does defend against:
"a whenBound() call from another thread that already holds this same binding (the pre-built-binding registerHandler() overload hands the caller the shared_ptr before this function is even called) could have raced in during the window above"
So a second thread can hold the binding and call attachHandler/assignHandlerPrimary on it — both of which write contextKey under _attachMtx — concurrently with these unlocked reads. std::string is not atomic; this is a data race by the invariant's own reasoning ("publishing them without it would be a data race, not just a stale read").
Which is wrong: the code or the invariant?
Both readings are defensible and the fix differs, which is why this is filed rather than patched:
- The invariant is right and the code is wrong — take
_attachMtx around both reads (or copy the string out under it first, as attachHandlerAsync does with primaryCopy).
- The invariant is overstated — if initial registration is defined as happening-before any other thread can reach the binding, then these reads are safe and the member comment needs the carve-out. But the pre-built-binding overload appears to defeat that argument.
I did not resolve which, because it is a design question about the pre-built-binding overload's contract, not a mechanical fix.
What would change the verdict
- Close it by showing the pre-built-binding overload cannot deliver a binding to another thread before
registerHandlerImpl returns — then correct the _attachMtx comment to say so.
- Raise it with a TSan test: construct a binding, hand it to a second thread that calls
attachHandler on it, and call registerHandler(binding) on the first.
Context
Found while correcting a stale comment at bridge.hpp:615 (PR #503), which claimed "five other sites read under _attachMtx". The count was wrong; my first correction said "every other site", which is also wrong because of exactly these two reads. The comment now names them as the exception and points here.
Summary
Bridge's_attachMtxmember comment states a hard invariant:registerHandlerImplreadsbinding->contextKeytwice while holding neither_attachMtxnor_mtx.Verification status
Verified by reading; not reproduced. I enumerated every access and every lock acquisition in the function rather than sampling. Revision: branch
framework-comment-audit(=origin/masteradfe8e5fplus doc-only commits); line numbers as of that revision.The two reads:
bridge.hpp:1760—binding->contextKeypassed as the third argument tobackend->registerModelAsync(...).bridge.hpp:1814—backend->registerModelWithContext(binding->typeId, binding->modelFactory, binding->contextKey), the synchronous fallback.Every lock acquisition in the enclosing range, by line:
Neither read is covered. By contrast every other attach/assign site is:
:507-509,:692-694,:716,:750,:826,:888,:927-931,:943-945,:955,:1298-1307,:1906-1913all hold_attachMtx.Why it is reachable rather than theoretical
The
registerHandler()overload that takes a pre-built binding hands the caller theshared_ptrbeforeregisterHandlerImplis ever called —registerHandlerImpl's own fallback comment says so, in the course of explaining a different race it does defend against:So a second thread can hold the binding and call
attachHandler/assignHandlerPrimaryon it — both of which writecontextKeyunder_attachMtx— concurrently with these unlocked reads.std::stringis not atomic; this is a data race by the invariant's own reasoning ("publishing them without it would be a data race, not just a stale read").Which is wrong: the code or the invariant?
Both readings are defensible and the fix differs, which is why this is filed rather than patched:
_attachMtxaround both reads (or copy the string out under it first, asattachHandlerAsyncdoes withprimaryCopy).I did not resolve which, because it is a design question about the pre-built-binding overload's contract, not a mechanical fix.
What would change the verdict
registerHandlerImplreturns — then correct the_attachMtxcomment to say so.attachHandleron it, and callregisterHandler(binding)on the first.Context
Found while correcting a stale comment at
bridge.hpp:615(PR #503), which claimed "five other sites read under_attachMtx". The count was wrong; my first correction said "every other site", which is also wrong because of exactly these two reads. The comment now names them as the exception and points here.