Skip to content

bridge: registerHandlerImpl reads binding->contextKey under neither lock, violating the invariant _attachMtx's own comment states #505

Description

@Yaraslaut

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:1760binding->contextKey passed as the third argument to backend->registerModelAsync(...).
  • bridge.hpp:1814backend->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:

  1. 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).
  2. 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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreSubsystem: coretriage: rescopeReal problem, wrong framing; rewrite before building

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions