fix(zenoh-ext): stop calling user code while holding subscriber state locks - #2744
fix(zenoh-ext): stop calling user code while holding subscriber state locks#2744YuanYuYuan wants to merge 5 commits into
Conversation
AdvancedSubscriber advanced its state and delivered samples in the same walk, with the user's sample callback invoked under zlock!(statesref). Any callback re-entering an AdvancedSubscriber API taking that mutex deadlocked against the guard its own caller held - one thread, no race. SampleMissListener::drop and SampleMissListenerBuilder::wait both do. Delivery is interleaved with the state walk, so it cannot simply be hoisted out of the locked region: it is recorded there and replayed outside it. State gains a FIFO outbox and a delivering marker; calls are staged under the lock and drained outside it by whichever thread claims the role. A per-call-site buffer would have been wrong. Callback is Send + Sync, so two threads can be in the sample callback at once, and with local buffers one could advance the state to sn=5 and the other to sn=6 and then deliver 6 before 5. Holding the mutex across advance-and-deliver is what used to prevent that, and ordered delivery is the feature AdvancedSubscriber exists to provide. One FIFO with one active deliverer restores it, and keeps callbacks mutually excluded as before. wait_callbacks() needed real synchronisation as a result. It was a no-op, and its comment said why: "no particular synchronization is required as of now since miss listener callbacks are always executed under state lock". Acquiring the lock during undeclare was the wait. It now blocks on the delivering marker, which is a thread id rather than a bool so that a callback undeclaring from inside its own delivery does not wait on itself. Dropping a user callback runs user Drop code, so unregister_miss_callback returns it for the caller to drop unlocked, and the subscriber's drop callback takes its handles out before releasing them. Tests: both re-entry shapes now complete, and a control that publishes where the subscriber does not match proves the scenarios' own liveness guard actually fires rather than the timeouts being vacuous.
… state lock Same defect as the previous commit, in querying_subscriber.rs. Two sites called the user callback under zlock!(state): the sample callback, and RepliesHandler::drop draining the merge queue. FetchingSubscriber::fetch resolves to register_handler, which takes that same mutex, so a callback that fetches self-deadlocked on a non-reentrant std::sync::Mutex. A callback that publishes re-entered through zenoh's synchronous local delivery instead. The fix is the one applied to AdvancedSubscriber: stage the samples in a FIFO under the guard, drain them once it is released, with a single active deliverer so ordering and mutual exclusion survive. A per-call-site buffer would not do: this subscriber exists to merge fetched replies with live samples in order, and the mutex is what serialised that merge. This type has no wait_callbacks-style API, so no condition variable is needed.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #2744 +/- ##
==========================================
+ Coverage 74.67% 74.86% +0.19%
==========================================
Files 425 419 -6
Lines 63930 63960 +30
==========================================
+ Hits 47739 47883 +144
+ Misses 16191 16077 -114 ☔ View full report in Codecov by Harness. |
be2cad3 to
902c094
Compare
…butes exist Both dispatch_outbox doc comments claimed this was the only place in the file that calls into code Zenoh does not control. That is not true: dropping a Callback runs the user's Drop, and advanced_subscriber.rs does that in three places outside dispatch_outbox. The claim is now scoped to invoking a callback, with a note that drops are user code too and are handled by taking the value out of the state before the guard is released. The reentrancy tests now state why they carry their attributes, because a reviewer had to ask. Every type under test is unstable-gated, so without the crate-level cfg the file fails to compile in the no-features build CI runs. The deprecated imports would be a hard error under -D warnings, and they keep their own use statement so the allow does not also cover the advanced types, whose future deprecation should still fail the build.
802f63a to
193dfa7
Compare
Three defects in the deferred-delivery mechanism, found in review. wait_for_delivery only watched the delivering marker. A staging thread releases the guard before it claims that marker, so the wait returned while the outbox still held committed calls. wait_callbacks could therefore return and let its listener fire afterwards. A non-empty outbox now counts as outstanding work. A staged Call::Sample resolved its target at drain time, so an undeclare landing between the stage and the drain discarded it. Each Call now carries its own callback handle, as Call::Miss already did. The drop callback justified not draining the outbox partly on grounds that four *RepliesHandler::drop impls in the same file contradict. Restate the real reason and name them. Also document what the outbox does not do: it is not backpressure, and an unconditionally republishing callback livelocks where it used to deadlock.
There was a problem hiding this comment.
Pull request overview
This PR fixes deterministic self-deadlocks in zenoh-ext subscribers caused by invoking (or dropping) user-provided callbacks while holding a non-reentrant std::sync::Mutex, by deferring callback execution until after the state lock is released. The approach introduces an outbox FIFO plus a single “delivering role” marker to preserve ordering and mutual exclusion while preventing re-entrant lock acquisition.
Changes:
- Defers
AdvancedSubscribercallback/miss-callback delivery by stagingCalls into a FIFO outbox under the lock and draining it after releasing the lock (including updatedwait_callbackssemantics). - Applies the same “stage then drain” mechanism to
FetchingSubscriber/QueryingSubscriber, including avoiding dropping user callbacks while holding the state lock. - Adds
zenoh-ext/tests/reentrancy.rsto reproduce and prevent regressions for the re-entrancy deadlock class.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
zenoh-ext/src/advanced_subscriber.rs |
Introduces outbox-based deferred delivery + delivering role + wait_callbacks support to avoid calling/dropping user code under the subscriber state mutex. |
zenoh-ext/src/querying_subscriber.rs |
Defers sample delivery (and callback drops) outside the InnerState mutex via an outbox and delivering role to prevent re-entrant deadlocks. |
zenoh-ext/tests/reentrancy.rs |
Adds deterministic re-entrancy tests ensuring callbacks are not invoked while holding subscriber state locks. |
Suppressed comments (1)
zenoh-ext/src/advanced_subscriber.rs:810
wait_for_deliveryuseszlock!(statesref)to acquire the mutex, which unwraps poisoning. If this is ever called from a destructor/unwind path while the mutex is poisoned, it can trigger a panic-in-drop abort. Since this helper already treats poisoning as "nothing useful to do" later in the loop, it should also avoid unwrapping on the initial lock acquisition.
fn wait_for_delivery(statesref: &Arc<Mutex<State>>) {
let me = std::thread::current().id();
let mut guard = zlock!(statesref);
let condvar = guard.delivery_done.clone();
loop {
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Copilot review. Three sites, one class: a lock acquisition on a destructor path that either panics or silently skips its work when the state mutex is poisoned. DeliveringRole::drop cleared the delivering marker only on Ok, so a poisoned mutex left the role claimed permanently. The guard exists to release it unconditionally; recover the state with into_inner instead. wait_for_delivery and the subscriber's drop callback both took the lock with zlock!, which unwraps. Both run inside CallbackDrop's destructor, where a panic aborts the process.
Summary
Two
zenoh-extsubscribers call user code while holding a non-reentrantstd::sync::Mutex. A callback that re-enters the subscriber therefore takes a mutex its own thread already holds, and deadlocks against itself. This PR fixes both.advanced_subscriber.rsquerying_subscriber.rsRepliesHandler::dropBoth use the same mechanism, described below.
std::sync::Mutexis not reentrant, so neither deadlock is a race — they reproduce on the first sample, every time.This is an established class in zenoh, and the rule already exists
The project has recognised and fixed "a lock is held while user code runs" more than once.
zenoh-extwas never brought in line.Four examples follow. Each row was checked against the tracker; the list is illustrative rather than exhaustive, since issue search is keyword-based.
putinside a subscriber callbackmatching_listenercallbackQuerierwhose completion callback re-enters theSession#1092 states the rule this PR applies, in its own words:
and names both failure modes it prevents — Zenoh operations performed inside the callback, and user locks taken inside the callback while another task holds them and calls into Zenoh. Both apply here unchanged.
#2634 is the same mechanism as this PR's second commit. A lock held while a callback is dropped, on a non-reentrant primitive, re-entered on the same thread.
So this PR adds no new rule. It applies the existing one to the last two places that do not follow it.
Zenoh core already follows it
Read at
8aaf85c60f378fc9136511c49f90c9194f91b0ad:Session::execute_subscriber_callbacksdrop(state)Session::send_push_consumedrop(state)Why it deadlocks
sequenceDiagram autonumber participant App as Publishing thread participant Sub as zenoh-ext subscriber participant Core as Session App->>Sub: sample arrives activate Sub Sub->>Sub: zlock!(state) Sub->>App: user callback runs, guard held App->>Core: publish, fetch, or drop a handle Core->>Sub: re-enters on the same thread Sub--xSub: zlock!(state) — this thread holds it Note over Sub: the thread waits on itself deactivate SubStep 6 has several shapes. For
AdvancedSubscriber,SampleMissListener::dropandSampleMissListenerBuilder::waitboth takestatesref. ForQueryingSubscriber,FetchingSubscriber::fetchresolves toregister_handler, which takesstate. For both, a callback that publishes re-enters through core's synchronous local delivery.The
Dropshapes are the ones that reach a user unannounced. Dropping a value inside a callback does not read as re-entering the middleware.What fails without this
Each fix was measured against a control that carries the tests and not the fix.
main+ the test fileAdvancedSubscriberscenariosGot Deadlocked.querying_subscriber.rsuntouched3 passed, 2 failed— exactly the two new ones, bothDeadlockedThe second control is the narrow one: it keeps the first fix and removes only the second, so the two new tests pin the second commit specifically rather than re-proving the first.
Note
Commit 3 has no failing baseline, and a test cannot honestly give it one. It closes two windows that are races:
wait_callbacks()returning while a staged call is still owed, and a staged sample discarded by an undeclare landing between the stage and the drain. Both need the undeclare to land inside a window bounded by one guard release and the next acquisition. A test that hit it would be timing-dependent, and a test that missed it would read as proof of absence.The argument is the control flow instead, and it is stated in
wait_for_delivery's doc comment: a staging thread releases the guard before it claims the delivering role, sodelivering.is_some()isfalsewhile the outbox is non-empty. Anything that watched onlydeliveringreturned early in that window.Commit 4 has none either, for a different reason. It only changes what happens when the state mutex is poisoned, which requires a thread to panic while holding the state guard. No test in this suite does that, and one written to force it would be exercising
std::sync::Mutex's poisoning contract rather than this code. The argument is the table below.Commit 4 is the Copilot review's finding, extended to the one site it did not name. Four lock acquisitions can run inside a
Drop; each mishandled poisoning, in one of two ways:DeliveringRole::drop, both filesif let Ok(..)— silently skips, leaving the role claimedinto_inner(), marker cleared unconditionallywait_for_delivery, initial acquisitionzlock!— unwraps and panicszlock!— unwraps and panicsinto_inner(); the handles still have to be releasedThe two
zlock!sites are the sharper ones: both run insideCallbackDrop's destructor, and a panic there is a panic in aDrop, which aborts the process. The twoif let Ok(..)sites cannot abort, but they defeat the point of an RAII guard whose contract is to release the marker however it is left.The fix
Delivery is interleaved with the state walk, so it cannot simply be hoisted out of the locked region. It is recorded inside the lock and replayed outside it.
Each subscriber's state gains a FIFO outbox and a
deliveringmarker. Calls are staged under the guard. Whichever thread claims the delivering role drains the outbox with the guard released.deliveringmarker excludes themThe shape becomes
acquire · stage · release · call, and the re-entrant call lands on an unlocked mutex:sequenceDiagram autonumber participant App as Delivering thread participant Sub as zenoh-ext subscriber participant Out as outbox FIFO App->>Sub: sample arrives activate Sub Sub->>Sub: zlock!(state) Sub->>Out: stage the call Sub->>Sub: release the guard deactivate Sub Sub->>Out: claim the delivering role Out->>App: user callback runs, no guard held App->>Sub: publish, fetch, or drop a handle Sub->>Sub: zlock!(state) — free, taken and released Sub->>Out: stage, then return Note over Out: the outer lap delivers itStep 9 is the whole point. The re-entrant call now finds the mutex unlocked, does its work and returns, instead of waiting on a guard its own thread holds.
A staged call carries its own target
Staging is a commitment to deliver, so each
Callcarries the callback it is for rather than looking one up when it is drained.State::callbackis gone, the sample is discardedCall::Missalready worked this way, because a miss listener can be unregistered independently.Call::Samplenow does too. The cost is one handle clone per staged call.The delivering role
One thread drains at a time. That is what keeps callbacks mutually excluded and delivery ordered now that the state guard no longer does it.
stateDiagram-v2 direction LR Idle: delivering is None Draining: delivering is Some thread id [*] --> Idle Idle --> Draining: outbox non-empty, claim the role Draining --> Draining: batch done, outbox refilled, take it Draining --> Idle: batch done, outbox empty, release Draining --> Idle: callback unwound, the role guard clears itTwo edges are load-bearing.
Draining --> Idleon the empty outbox happens in a single lock acquisition. The "is the outbox empty" test and the release must not be split. If they were, a sample staged in between would finddeliveringstill set, decline to deliver, and sit in the outbox until some later sample happened to flush it.Draining --> Idleon unwind exists because a user callback can panic.DeliveringRoleis an RAII guard whoseDropclears the marker. Without it the role would stay claimed forever and the subscriber would stop delivering for the rest of its life. It runs on the unwind path, so it takes the mutex without unwrapping — a panic in a destructor aborts the process.Why not a per-call-site buffer
CallbackisSend + Sync, so two threads can be inside a sample callback at once. With buffers local to each call site, one thread could advance the state to sn=5 and another to sn=6, then deliver 6 before 5. Holding the mutex across advance-and-deliver is exactly what used to prevent that.Rejected, and why:
sequenceDiagram autonumber participant A as Thread A participant B as Thread B Note over A,B: per-call-site buffers A->>A: advance state to sn=5, buffer locally B->>B: advance state to sn=6, buffer locally B-->>B: deliver 6 A-->>A: deliver 5 Note over A,B: the subscriber observes 6 before 5What shipped instead:
sequenceDiagram autonumber participant A as Thread A participant O as outbox FIFO participant B as Thread B A->>O: stage 5 B->>O: stage 6 A->>O: claim the delivering role B--xB: role already claimed, return O-->>A: deliver 5, then 6 Note over A,B: the subscriber observes 5 then 6Thread B does not block and does not lose its sample. It stages and returns; A picks the work up on its next lap.
Ordered delivery is the feature both types exist to provide —
AdvancedSubscriberreorders by sequence number, andQueryingSubscribermerges fetched replies with live samples. One shared FIFO with one active deliverer restores it: calls leave in the order the state machine produced them, whichever thread drains them, and callbacks stay mutually excluded as before.How the two commits align
The second commit reuses the first's mechanism rather than inventing one. The staging structure, the single-deliverer marker and the unwind-safe role guard are the same shape in both files.
They are deliberately not shared.
QueryingSubscriberhas nowait_callbacks-style API, so its copy needs no condition variable, and the type is deprecated — factoring the two together would tie a deprecated type's lifetime to the current one's. Thequerying_subscriber.rscopy says so at the type.Two quieter call-outs
Dropping a user callback runs user
Dropcode. Sounregister_miss_callbackreturns the callback for the caller to drop with the guard released, and the subscriber's drop callback takes its handles out before it releases them.Behaviour changes
wait_callbacks()on a sample-miss listener now genuinely blocksB1 needs explanation.
wait_callbacks()was a no-op, and its comment said why:Acquiring the state lock during undeclare was the wait. Removing callbacks-under-the-lock silently disarmed it: the method still returned
Ok, just without the guarantee it documents. It now blocks on the delivering marker.That marker is a thread id rather than a bool, and that is load-bearing. A callback that undeclares while it is itself being delivered would otherwise wait on its own delivery — the original defect wearing a different hat.
The thread id is what makes
wait_callbackssafe to call from inside a callback:sequenceDiagram autonumber participant T as Delivering thread participant S as State T->>T: draining, delivering = this thread T->>T: user callback runs T->>S: undeclare, with wait_callbacks alt if delivering were a bool S--xT: block until nobody is delivering Note over T: nobody is this very thread else delivering is a ThreadId S-->>T: owner is me, do not wait endThe regression was confirmed before it was fixed, not assumed.
test_callback_drop_on_undeclare_advanced_sample_miss_listenercatches it, because it counts callback drops as well as calls.What the wait covers. Watching the
deliveringmarker alone is not sufficient, and commit 3 fixes that. A staging thread releases the guard before it claims the role, so there is a window in whichdeliveringisNonewhile the outbox holds calls the subscriber has already committed to:deliveringdeliveringaloneNoneSome(A)Some(A)NoneIn row 1 the listener could still fire after
wait_callbacks()had returned, because each stagedCallcarries its own handle and unregistering does not reach it. A non-empty outbox now counts as outstanding work. That is safe to wait for: every site that stages callsdispatch_outboxon the way out, so a non-empty outbox always has a thread on its way to drain it.The wait is subscriber-wide, not per-listener: a sample callback in flight is waited out too. The staged calls share one queue, so a listener's own cannot be separated from them.
B3 is new with the outbox. A thread that finds another already draining stages its sample and returns, so the active deliverer runs that callback. Zenoh already invoked subscriber callbacks from arbitrary RX workers, so no thread identity was ever promised — but one thread delivering another thread's sample is new, and a callback keyed on thread-local state would notice.
B4 — the outbox is not backpressure. The state mutex used to throttle producers: a thread that arrived while a callback was running blocked on the guard until it finished. Staging returns immediately, so a producer faster than the callback grows the outbox instead of being slowed by it. Nothing bounds that.
Bounding it means either dropping samples or reintroducing a block, both larger semantic changes than removing the deadlock. This PR states the property rather than deciding it. If a bound is wanted, it deserves its own change and its own argument.
B5 — a re-entrant publish is safe only if it is conditional. A callback that publishes to its own subscriber stages and returns, and the outer lap delivers it; that is the fix working. A callback that republishes on every sample refills the outbox as fast as the drain empties it, so
dispatch_outboxnever leaves its refill loop. It livelocks where it used to deadlock.Both are the callback's own unbounded recursion, and the old code caught it only by deadlocking. The tests keep it in check with
once.swap(true, ..), which is the same discipline stated explicitly.The only public signature edit is
self→mut selfonwait_callbacks. That is a binding mode, not part of the callable API, so it is not source-breaking.The internal surface does change:
State::unregister_miss_callbacknow returnsOption<Callback<Miss>>and is#[must_use], so the caller drops the callback with the guard released. It is a private method, so no downstream code sees it.Testing
The tests avoid the trap that a timeout alone proves nothing: "the callback ran and wedged" and "the callback never ran" look identical from outside.
run_scenarioreturns a three-wayCompleted/Deadlocked/Panickedrather than a bool, so an assertion failure inside a scenario cannot be reported as a hang.the_callback_guard_fires_when_the_callback_never_runsis the control. It publishes where the subscriber does not match and asserts the guard fires, which proves the guard executes at all.Full
zenoh-extsuite, measured on46934fc7c(the current tip, with commits 3 and 4): 30 passed, 0 failed — 5 reentrancy, 12 advanced, 4 liveliness, 9 serialization and utils.cargo fmt --checkandcargo clippy --all-targets --features unstable -- -D warningsare clean on the same commit.The advanced and liveliness suites are the ones that would catch a delivery-ordering regression, and the liveliness suite exercises
QueryingSubscriberandFetchingSubscriberdirectly. Commit 3 changes what a staged call carries and what the wait covers, so those two suites passing on the current tip is the evidence that neither change reordered or dropped anything. Commit 4 changes only poisoned-mutex behaviour, so it is invisible to them by construction — that is stated rather than implied.One
#[allow]is added, on theCallenum.clippy::large_enum_variantwantsSampleboxed; boxing it would put a heap allocation on every delivered sample to shrink a queue that is empty whenever delivery keeps up. The reason is stated at the attribute.The three red
Lints and doc testsjobs are not from this PRNote
They fail on
zenoh/src/net/runtime/adminspace.rs, which this PR does not touch. Rust 1.98.0 addedclippy::useless_borrows_in_formatting, and the offending line is onmainunchanged. #2756 fixes it repo-wide; its own lint jobs are green. This branch needs a rebase once that merges, and no change of its own.zenoh-ext/src/advanced_subscriber.rs,zenoh-ext/src/querying_subscriber.rs,zenoh-ext/tests/reentrancy.rs— nothing elseversion = &*LONG_VERSION,inmetrics, identical onmain🏷️ Label-Based Checklist
Based on the labels applied to this PR, please complete these additional requirements:
Labels:
bug🐛 Bug Fix Requirements
Since this PR is labeled as a bug fix, please ensure:
Why this matters: Bugs without tests often reoccur.
Instructions:
- [ ]to- [x])This checklist updates automatically when labels change, but preserves your checked boxes.