fix: release lock before calling into DataCallbackManager - #1061
Open
YuanYuYuan wants to merge 1 commit into
Open
YuanYuYuan wants to merge 1 commit into
YuanYuYuan wants to merge 1 commit into
Conversation
SubscriptionData, ClientData, and ServiceData each held their own mutex_ across the call into data_callback_mgr_ -- both when new data arrives (trigger_callback()) and when a user registers a new callback (set_callback(), which can fire retroactively for already-queued data). data_callback_mgr_'s own callback can run arbitrary user code, including code that re-enters this class, and set_callback() doesn't need mutex_ at all: data_callback_mgr_ has its own internal locking and no other state guarded by mutex_ is touched there. If that user code ever runs on a thread also contended for mutex_ (for example a language binding whose callback runs while holding its own interpreter lock, and a concurrent thread wants both that lock and mutex_), this is a genuine self-deadlock/AB-BA hazard. Narrows mutex_'s scope in all three entities to just the queue mutation, releasing it before trigger_callback()/set_callback() run.
This was referenced Sep 11, 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.
Summary
SubscriptionData,ClientData, andServiceDataeach hold their ownmutex_across the call intodata_callback_mgr_— both when a new message/reply/query arrives (trigger_callback()) and when a user registers a new callback (set_callback(), which can fire retroactively for already-queued data).data_callback_mgr_'s own callback can run arbitrary user code, including code that re-enters this class (e.g. to read queued data or to clear/replace the callback), andset_callbackdoes not needmutex_in the first place —data_callback_mgr_has its own internal locking, and no other state guarded bymutex_is touched in that function.If the user code invoked from the callback ever executes on a thread that is also, directly or indirectly, contended for
mutex_— for example a Python binding where the callback runs while holding the GIL and a concurrent thread wants both the GIL andmutex_— this is a genuine self-deadlock/AB-BA hazard: the same shape as a lock held across a callback that can call back into the same object.A concrete, real trigger for this:
rclpy'sAsyncNode.SubscriptionData::add_new_messageholdsmutex_while callingtrigger_callback(), which (viaAsyncNode) enters Python and blocks on the GIL. The asyncio event loop thread separately holds the GIL and blocks intake_one_messagewaiting for the samemutex_. This is a textbook cross-thread AB-BA cycle, not a hypothetical: measured on this exact path, unpatched code hangs on 12 of 30 runs at 200 Hz (Fisher exact, one-sided, p = 6.2e-05), against 0 of 30 with the fix applied.This fixes all three entities by narrowing
mutex_'s scope to just the data-structure mutation (queue push/pop, sequence-number bookkeeping) and releasing it beforetrigger_callback()/set_callback()run.This PR is part of a small, independent set of public fixes for the same underlying defect shape (a lock held across a call into user/foreign code) across several projects in the zenoh/ROS 2 ecosystem, using lock-tripwire — a small debug-time detector for exactly this pattern — as the common diagnostic tool where the target language is Rust:
rmw-zenoh-rs'sSubscription/Service/Clienttypes (a Rust RMW implementation of this same interface)This PR is the C++ instance of that same fix pattern applied to
rmw_zenoh_cppdirectly; the tripwire itself doesn't run against C++ locks, so this fix was re-derived by reading the current source, not generated by the tool.Before and after
add_new_message/add_new_reply/add_new_querymutex_for the whole function body, includingtrigger_callback()mutex_only around the queue mutation; releases it beforetrigger_callback()set_on_new_message_callback/set_on_new_response_callback/set_on_new_request_callbackmutex_(unused inside the function) arounddata_callback_mgr_.set_callback()mutex_at all —data_callback_mgr_already serializes itselfmutex_mutex_(see Residual gap below)is_shutdown_,is_shutdown_.load(...))trigger_callback()What fails without this
No test in this repository currently reproduces the hang (see Verification below for why). The failure mode itself is not synthetic:
rmw-zenoh-rs'sSubscription/Service/Clienttypes (linked above, ZettaScaleLabs/hiroz#351) had the structurally identical defect — a callback-invoking mutex held across a call into a GIL-holding Python runtime — reproduced with a deterministic hang, and fixed with the identical release-before-call-out pattern this PR applies here.Residual, disclosed gap
DataCallbackManager(event.hpp/event.cpp) holds its own privateevent_mutex_for the full duration ofset_callback()andtrigger_callback(), including while invoking the stored callback. This PR does not touch that lock. If a user callback re-entersDataCallbackManageron the same object (e.g. callingset_on_new_message_callbackagain from inside the callback it just fired),event_mutex_itself could still self-deadlock. This is a narrower, harder-to-hit case (it requires the same callback to re-register itself), and fixing it is a larger change toDataCallbackManager's own locking discipline — left out of this PR to keep it minimal and reviewable. Happy to open a follow-up if maintainers want it addressed.Verification
No full
colcon buildwas performed for this PR. Buildingrmw_zenoh_cpprequireszenoh-cpp/zenoh-c, vendored from source via this repository's ownzenoh_cpp_vendorpackage — disproportionate to stand up just to validate a lock-scoping change. Instead, this was verified by manually re-reading every changed function line by line, confirming brace balance and that every field accessed inside the original lock scope stays inside the new (narrower) one; confirming the early-return paths (is_shutdown_inadd_new_message,is_shutdown_.load(...)inadd_new_query) are unchanged in effect — they still exit beforetrigger_callback()runs, now via the nested block ending rather than the whole function ending, and thelock_guardstill releases correctly on that path via normal C++ scope-exit semantics; andgit diff --check(no whitespace/tab issues).This is weaker evidence than a compiled, executed test, and is stated here plainly rather than implied to be equivalent to one. If a maintainer would rather see this validated against an actual build/test, I'm happy to follow up.
Breaking changes
None. This changes only lock scope, not any function's signature, return value, or externally observable ordering guarantee.
Did you use Generative AI?
Yes. Claude Code was used for the whole thing: reading the current source to find and re-derive this defect shape, writing the fix across all three entities, and drafting this description. All source changes and claims here were reviewed line by line before pushing — see the Verification section above for exactly what was and wasn't checked.