Shut down entities on the application thread when deleting them - #994
BOURBONCASK wants to merge 4 commits into
Conversation
|
@BOURBONCASK thanks for reporting the issue and opening a PR! Do you mind updating your PR description to follow the default template? |
done — updated the description to follow the template. Thanks for taking a look! |
|
Tick the box to add this pull request to the merge queue (same as
|
|
We hit the same deadlock and tested this PR against a reproducer that destroys subscriptions and Runs that deadlocked / runs:
Reproducer: https://gist.github.com/otamachan/515e3e699dbfe592207894789782eeb1 It runs in one process with 1. Subscriptions: the self-deadlock becomes an AB-BA
2. Services:
|
Deterministic regression test for the self-deadlock where the last shared_ptr<ClientData> reference is dropped inside the client reply closure: ~ClientData() then runs the blocking querier undeclare (which drains in-flight callbacks) on the very thread executing that callback. The test blocks inside the new-response callback (invoked synchronously from ClientData::add_new_reply while the reply closure holds its strong reference), destroys the client from the main thread, releases the callback, and then verifies the callback thread can still serve a probe request. Signed-off-by: Yifei Ma <yifeima98@gmail.com>
NodeData::delete_{pub,sub,service,client}_data only erased the owning
shared_ptr from the map, leaving shutdown() to run from whichever thread
drops the last reference. When an entity is destroyed while one of its
zenoh callbacks is in flight, that callback's transient shared_ptr (the
locked weak_ptr) is the last reference, so the destructor runs the
blocking undeclare on the zenoh callback thread itself. The undeclare
waits for in-flight callbacks to finish, i.e. for itself: the thread
deadlocks permanently, taking the entity mutex (and on a real system the
whole session inbound path) with it.
Take the entity out of the map under the lock, then shut it down on the
calling (application) thread outside the lock. The destructor's own
shutdown() then no-ops via the is_shutdown_ CAS regardless of which
thread runs it.
ClientData::shutdown() and ServiceData::shutdown() become public to
match PublisherData and SubscriptionData.
Observed in production as a zenoh client whose transport rx thread
deadlocked in z_undeclare_querier inside its own reply callback after
rmw_destroy_client raced an in-flight service reply; reproduced
deterministically by test_client_destroy_during_reply.
Signed-off-by: Yifei Ma <yifeima98@gmail.com>
ServiceData::initialized_ was only ever set to false in the constructor; ServiceData::make() never set it to true, so both undeclares in ServiceData::shutdown() were unreachable. The queryable and the liveliness token were still released by the member destructors, i.e. on whichever thread dropped the last reference. That defeats shutting the entity down on the application thread in NodeData::delete_service_data(): with an in-flight query, the callback thread still ran the blocking queryable undeclare on itself and deadlocked. Co-authored-by: otamachan <536660+otamachan@users.noreply.github.com> Claude-Session: https://claude.ai/code/session_01317fxjjrM6WrQipxhN8qtm
…ust not deadlock test_service_destroy_during_request mirrors the client test: it blocks inside the new-request callback, which ServiceData::add_new_query() invokes while the query closure still holds its strong reference, destroys the service from the main thread, releases the callback and checks that the callback thread returns. test_subscription_destroy_during_sample pins the lock ordering of SubscriptionData::shutdown(): one publisher thread is parked inside the new-message callback with mutex_ held, the destroyer queues on mutex_, and a second publisher thread queues a second in-flight sample callback. shutdown() must release mutex_ before it undeclares the subscriber, or the undeclare waits for a callback that is waiting for mutex_. Both tests use the same-process delivery path (the sending thread is the zenoh callback thread), so the races are deterministic. Claude-Session: https://claude.ai/code/session_01317fxjjrM6WrQipxhN8qtm
32cbb90 to
536c1dd
Compare
|
@otamachan thanks for the reproducer and the measurements. I was able to confirm both on my side:
I also added regression tests for the service and subscription paths next to the client one. The subscription test deadlocks against the pre-#930 Rebased onto current |
|
Thanks for the regression tests and for tracking down #930. I will open the We hit this deadlock in the field, so I hope this PR can be merged soon. |
Description
Fixes #993.
NodeData::delete_{pub,sub,service,client}_dataonly erased the owningshared_ptrfrom the map, leavingshutdown()to run from whichever thread drops the last reference. If an entity is destroyed while one of its zenoh callbacks is in flight, the callback's transientshared_ptr(locked from the closure'sweak_ptr) is the last reference, so the destructor runs the blocking undeclare on the zenoh callback thread itself — which waits for that same callback to finish. The thread deadlocks permanently and takes the entity mutex (and, on a deployed system, the whole session inbound path) with it. Full stacks and a field incident are in #993.This PR:
shutdown()becomes a no-op via the existingis_shutdown_CAS regardless of which thread runs it (rmw_node_data.cpp);ClientData::shutdown()andServiceData::shutdown()public, matchingPublisherData/SubscriptionData;initialized_ = trueat the end ofServiceData::make(). It was only ever initialized tofalse, so both undeclares inServiceData::shutdown()were unreachable and the queryable was still undeclared by the member destructor — i.e. on the callback thread — even after the change above (found by @otamachan, see below);test_rmw_zenoh_cpp, one per entity kind that has a zenoh callback:test_client_destroy_during_reply: blocks inside the new-response callback (invoked synchronously fromClientData::add_new_replywhile the reply closure still holds its strong reference), destroys the client from the main thread, releases the callback, and verifies the callback thread can still serve a probe request;test_service_destroy_during_request: same shape for a service, blocking inside the new-request callback invoked fromServiceData::add_new_querywhile the query closure holds its strong reference;test_subscription_destroy_during_sample: parks one publishing thread inside the new-message callback (invoked fromadd_new_message()withmutex_held), starts the destroy soshutdown()queues onmutex_, then sends a second sample from another thread so a second callback is in flight when the undeclare runs. This guards the lock-release-before-undeclare ordering inSubscriptionData::shutdown()that Add support for rosidl::Buffer-aware per-endpoint pub/sub #930 introduced onrolling; it is the AB-BA @otamachan hit onjazzy, where that ordering does not exist yet.Rebased onto current
rolling.Verification (Docker
ros:rolling-ros-base, Ubuntu 26.04 / x86_64, rolling binaries fromros2-testing, rebased ontorollingat 7fe3e65):colcon testforrmw_zenoh_cpp+test_rmw_zenoh_cpp: 236 tests, 0 failures, linters included. The three regression tests take ~1.1 s each and passed 3/3 reruns.test_service_destroy_during_requestfails after its 10 s watchdog ("sender thread never returned … deadlocked") without theinitialized_ = trueline;test_subscription_destroy_during_samplefails with "destroy returned: false, publisher B returned: false" whenSubscriptionData::shutdown()is changed back to holdmutex_across the undeclare (the pre-Add support for rosidl::Buffer-aware per-endpoint pub/sub #930 shape @otamachan hit onjazzy);test_client_destroy_during_replyhangs with the originaldelete_client_data(caught by the test timeout), as in the previous revision of this PR.Is this user-facing behavior change?
No public API changes. It fixes a permanent deadlock/hang when a client, service or subscription is destroyed concurrently with an in-flight callback.
Did you use Generative AI?
Yes. Claude (claude-fable-5 / claude-fable-5-1 and claude-opus-4-8) via Claude Code was used to assist with root cause analysis, reproducing the deadlock, and creating an initial prototype of the changes in this PR.
https://claude.ai/code/session_01317fxjjrM6WrQipxhN8qtm