fix(pubsub): stop running subscriber callbacks on the publishing thread - #250
Open
YuanYuYuan wants to merge 22 commits into
Open
fix(pubsub): stop running subscriber callbacks on the publishing thread#250YuanYuYuan wants to merge 22 commits into
YuanYuYuan wants to merge 22 commits into
Conversation
YuanYuYuan
force-pushed
the
pr/2-pubsub-reentrancy
branch
from
July 28, 2026 08:06
1f04eb3 to
1006b7d
Compare
There was a problem hiding this comment.
Pull request overview
Fixes subscriber callback deadlocks by moving hazardous delivery off publishing threads and updating Python bindings accordingly.
Changes:
- Adds QoS-aware subscriber selection and callback dispatch queues.
- Releases Python’s GIL during publishing and removes a receive-side payload copy.
- Adds Rust and Python regression and backpressure tests.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
crates/hiroz/src/pubsub.rs |
Implements dispatching and QoS-aware subscribers. |
crates/hiroz/src/node.rs |
Applies dispatching to raw subscribers. |
crates/hiroz/src/ffi/subscriber.rs |
Stores generalized subscriber handles. |
crates/hiroz/src/common.rs |
Identifies handlers that execute user code. |
crates/hiroz-tests/tests/reentrant_publish.rs |
Tests reentrant publishing and teardown. |
crates/hiroz-tests/tests/dispatch_backpressure.rs |
Tests dispatcher queue bounds. |
crates/hiroz-py/tests/test_reentrant_publish.py |
Tests Python reentrancy and thread cleanup. |
crates/hiroz-py/src/pubsub.rs |
Releases the GIL while publishing. |
crates/hiroz-py/src/node.rs |
Uses borrowed sample payloads in callbacks. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
crates/hiroz/src/pubsub.rs:1325
- This branch runs before
runs_user_codeis checked, so every TransientLocal queue/notifier subscriber (including rmw'sbuild_with_notifierpath) gets an unbounded dispatcher even though its handler only enqueues. That delays the wait-set notification and permits an unbounded backlog ahead of the already boundedBoundedQueue, contradicting the queue-mode contract below. Split the advanced path onruns_user_codeand wire queue handlers directly to the advanced subscriber callback.
let inner = if qos_needs_advanced(&self.entity.qos) {
crates/hiroz/src/pubsub.rs:1526
build_internalalready performs this encoding validation for everyDataHandlerat lines 1300–1320. Wrapping the new callback again parses each encoding twice and emits duplicate mismatch/unknown-format logs. Pass the callback directly tobuild_internal.
let expected_encoding = self.expected_encoding.clone();
let callback = Arc::new(move |sample: Sample| {
crates/hiroz-py/tests/test_reentrant_publish.py:132
- This makes the Python suite fail unconditionally on macOS, although macOS Python wheels are supported (
docs/bindings/python.md:13-14)./proc/self/taskis Linux-only and is needed solely by the drain-thread leak detector; skip that one test on unsupported platforms (a skip is not a false pass) while still running the portable re-entrancy tests.
# The leak test can only see the Rust drain thread through procfs.
assert os.path.isdir("/proc/self/task"), (
"/proc/self/task is unavailable - the drain-thread leak test cannot run "
"on this platform and must not be reported as passing"
)
crates/hiroz-py/src/pubsub.rs:38
- The watchdog test does not independently exercise this GIL release: with dispatcher delivery enabled, the seed
publish()returns quickly, and the test then sleeps for 300 ms before measuring progress, so reverting onlyallow_threadsstill passes. Add a detector that keeps the zenoh publish blocked while another Python thread must make progress; otherwise this regression can return unnoticed.
py.allow_threads(|| self.inner.publish(zbuf.into()))
.map_err(|e| e.into_pyerr())
YuanYuYuan
force-pushed
the
pr/2-pubsub-reentrancy
branch
4 times, most recently
from
July 29, 2026 09:13
90092b2 to
db3c6cc
Compare
This was referenced Jul 30, 2026
YuanYuYuan
force-pushed
the
pr/2-pubsub-reentrancy
branch
2 times, most recently
from
August 5, 2026 16:54
85fb828 to
60a8dcd
Compare
This was referenced Aug 5, 2026
Open
YuanYuYuan
force-pushed
the
pr/2-pubsub-reentrancy
branch
from
August 6, 2026 10:35
12a968a to
d11d710
Compare
This was referenced Aug 11, 2026
Publishing from inside a subscriber callback on the same session deadlocked deterministically, with no error and no traceback. Three separate mechanisms had to be removed. 1. Every subscriber was declared as a zenoh-ext `AdvancedSubscriber`, unconditionally. Its sample callback takes a `std::sync::Mutex` and then invokes the user callback under that guard. `std::sync::Mutex` is not reentrant, so a callback that published into its own topic graph re-entered a mutex its own thread already held further up the stack. Declare the advanced subscriber only when the QoS profile actually configures advanced features: for Volatile — the ROS 2 default — an `AdvancedSubscriber` declares no liveliness subscriber, no heartbeat subscriber and no detection token, so it was pure per-sample overhead plus the fatal lock. 2. Zenoh delivers a same-session sample synchronously, inline on the thread that called `put`. With the lock gone, a callback that publishes therefore *recursed* instead of iterating, until the stack overflowed. Adopt the shape zenoh's own `FifoChannel` uses, and that zenoh-python installs by default for a Python callable: delivery enqueues and returns, user code runs elsewhere. `ZPub`'s four publish paths hold a thread-local marker across the zenoh `put`; the plain subscriber's shim enqueues when that marker is set and invokes inline when it is not. An inter-process sample arrives on a zenoh RX worker, which is never inside a hiroz publish, so that path keeps its inline call and pays one thread-local read. Queue-mode subscribers — every rmw subscription, and every `recv()`-based user — already enqueue and return, so they get no dispatcher at all. The marker keys on the publishing thread rather than on zenoh's `Locality` on purpose: two sessions in one process with a direct route deliver a `Locality::Remote` sample inline on the publisher's thread, so an `allowed_origin(SessionLocal)` split would miss that case. With no path left on which a callback is reachable from inside `put`, re-entrancy is structurally impossible rather than depth-bounded, so the interim `MAX_CALLBACK_REENTRY_DEPTH` cap, `CallbackDepthGuard` and `InheritedCallbackDepth` are removed rather than left as unreachable defensive code. 3. `hiroz-py`'s `ZPublisher.publish`/`publish_raw` release the GIL across the zenoh publish. This does not fix the deadlock, but it downgrades a whole-interpreter freeze — no exception, no traceback, only an external kill — to a single blocked thread, which is the difference between an undiagnosable hang and a diagnosable one. Queue policy is split per path. The plain dispatcher takes its capacity from the same history-QoS expression `build()` uses to size `BoundedQueue` and drops the oldest on overflow; the advanced dispatcher stays unbounded. Bounded and blocking recreates the deadlock on both paths — the blocked producer sits inside the callback holding the very lock the drain thread needs. Bounded and dropping is correct for the plain path, which is Volatile with KEEP_LAST(depth) and already promises no more than `depth` undelivered samples, and wrong for the advanced path, where loss would discard samples miss-detection went out of its way to recover, mid-reorder. Also adds `ZSubBuilder::build_with_sample_callback`, which hands the callback the `Sample` rather than a decoded message, and uses it in `hiroz-py`. The Python callback path routed every sample through an identity codec whose `Output` carries no lifetime, so it had to `to_vec()` the whole payload before the callback ran, only for msgspec to decode out of it and drop it — one full payload copy per message. Measured as an interleaved paired A/B (two release wheels differing only in this call site, 5+6 reps, 16k timed round trips each, half-trip p50): 64 B 114.5 -> 114.1 (noise), 4 KB 120.9 -> 121.0 (noise), 64 KB 224.4 -> 221.1, -3.4 us with 10 of 11 reps in [-2.6, -5.1]. The size-dependence is the point: it is what distinguishes removing a payload-sized memcpy from removing a fixed per-message cost. Wire format is unchanged: an `AdvancedPublisher` with no cache, no publisher_detection and no sample_miss_detection puts on the plain key expression, so Volatile publishers and subscribers stay byte-identical and interop is unaffected. Volatile subscribers no longer get zenoh-ext's HLC-timestamp de-duplication. Ordering is preserved where it was ever guaranteed: one FIFO queue, one drain thread, and drop-oldest preserves the relative order of what survives. Not preserved, and documented at the type: a plain subscriber receiving both local and remote publications on one topic now runs them on two different threads, so their relative order is not guaranteed. Neither ROS 2 nor zenoh guarantees ordering across publishers, and a plain zenoh subscriber could already be invoked concurrently from several RX workers. Detector evidence, both directions. Reverting the dispatch decision (`local_publish_active() -> false`) and keeping the tests, all three self-feeding loop tests die with `fatal runtime error: stack overflow, aborting` (rc=101), including `intra_closed_loop_runs_iteratively`, which drives 2_000 round trips through a two-topic one-session callback cycle that could not previously be expressed as a loop. Against the unfixed sources the original three scenarios fail on their 20s deadline (`0 passed; 3 failed`). Every one of the six Python cells hangs with rc=124 under an external wall-clock timeout on a wheel built from unfixed sources — not merely fails, hangs — and all six pass on this branch.
Three defects, all in this branch's own new machinery. **The FFI publish path was never marked local.** `local_only_shim` hands a sample to the drain thread only while `LOCAL_PUBLISH_DEPTH` is set, and that flag comes from `LocalPublishGuard`. All four `ZPub` publish methods take the guard; `RawPublisher::publish_bytes` did not. A same-process raw publish was therefore delivered inline on the publishing thread, so a raw callback that publishes back into its own topic recurses until the stack is gone -- the exact defect the dispatcher exists to prevent, still reachable through the FFI door, which is the door `rmw-zenoh-rs` uses. **A bounded queue claimed to be lossless.** The backlog warning fired whenever the pending count crossed `DISPATCH_BACKLOG_WARN_AT`, and told the user the queue "is lossless, so the backlog costs memory". Both nearby comments assert that only unbounded dispatchers reach it, but nothing enforced that: capacity comes from `KeepLast(depth)`, and a `KeepLast(1024)` subscriber has exactly that capacity. It would warn that it cannot lose samples immediately before dropping one. Gate the warning on `DISPATCH_UNBOUNDED`; bounded queues already have an accurate drop warning. **A doc link pointed at a function that does not exist.** `LocalPublishGuard` claimed every publish "funnels through `ZPub::finish_put`". There is no such method and no choke point -- four call sites each enter the guard. The next author to add a fifth publish path would have gone looking for a funnel, not found one, and shipped without the guard, silently reinstating the bug above. `hiroz-tests` now enables `hiroz/ffi`. Without it the raw API is not compiled into the test crate at all, so the FFI surface had no coverage and any test written against it would have compiled away to nothing. The new test asserts thread identity rather than absence of a crash: proving the recursion directly needs an unbounded feedback loop, which aborts the runner and explains nothing. Verified in both directions -- with the guard the callback lands on the drain thread and it passes; with the guard removed the callback and the publisher report the same `ThreadId(2)` and it fails.
Enabling `hiroz/ffi` for `hiroz-tests` makes `clippy-tests` lint `crates/hiroz/src/ffi/*` for the first time, and that module has 22 pre-existing `missing_safety_doc` violations across `action.rs`, `serialize.rs` and `service.rs`. Under `-D warnings` the job fails. The guard fix in `RawPublisher::publish_bytes` stays -- it is the actual defect fix, and it was verified in both directions locally: with the guard removed, the raw subscriber callback and the publisher report the same `ThreadId`, meaning delivery happens inline on the publishing thread and a callback that republishes recurses until the stack is gone. What is lost is the CI regression test for that path, and the honest reason is scope: making it runnable requires documenting the safety contract of 22 `pub unsafe extern "C"` functions, which does not belong in a pull request about subscriber-callback re-entrancy. Writing 22 perfunctory `# Safety` blocks without establishing each contract would be worse than leaving them. Follow-up, worth filing: the FFI surface is entirely unlinted and untested because the feature is off everywhere. That is its own defect, and it is why this fix could ship unnoticed in the first place.
Two defects in the new dispatcher, both on teardown, both found by an
adversarial review pass.
**The Python bindings deadlocked the interpreter on teardown.** Dropping a
`ZSub` joins its delivery thread, and that thread's callback body is
`Python::with_gil`. `destroy_subscriber` is a `#[pymethods]` fn, so it runs
with the GIL held: it waits for the thread, the thread waits for the GIL, and
the interpreter freezes with no exception and no traceback. Reachable from
`destroy_subscriber`, `del node`, or interpreter exit -- `tp_dealloc` holds
the GIL too, and `PyZNode` had no `Drop`. This is the same failure class the
PR removes, relocated from `publish()` to teardown, and newly reachable
because nothing joined a GIL-needing thread before. `destroy_subscriber` now
takes a `Python` token and drops under `py.allow_threads`; `PyZNode` gets a
`Drop` that does the same for the subscribers it owns.
The existing `test_transient_local_dispatcher_threads_do_not_leak` passes
either way: it calls `_settle(...)` first, so the drain thread is parked in
`dequeue` and the hazard window is closed before the drop.
**`drop(subscriber)` could block for minutes, or forever.** `dequeue` popped
`pending` before honouring `closed`, so `Drop` ran a user callback for every
queued sample before returning. On the unbounded TransientLocal path that is
`backlog x callback_duration` with no ceiling -- a 1 kHz publisher against a
5 ms callback leaves ~30 000 samples queued after 30 s, blocking the drop for
~150 s with no log line and no way to cancel. It could block forever if a
callback waited on anything the dropping thread had to supply.
`closed` is now checked first. Dropping a subscriber means "stop delivering to
me", so the undelivered backlog is discarded rather than forced through a
callback the caller has already disposed of -- what destroying an rclcpp
subscription does. Teardown costs at most one in-flight callback.
That last point is a deliberate reversal of the previous documented intent
("drain what is queued, then exit"); it is now declared in Breaking Changes,
along with two breaks the description had omitted: Volatile subscriber
callbacks are no longer mutually excluded (they were, via zenoh-ext's
`Mutex<State>`, since every subscriber used to be an `AdvancedSubscriber`),
and `RawSubscriber::inner` changed type.
reentrant_publish 10/10 and dispatch_backpressure 2/2 still pass, including
both teardown scenarios.
The ROS interop step captured nextest's output with `complete` and never printed it, so a green job showed the command echo followed by "All ROS 2 <distro> tests passed!" and nothing in between. That banner could not be falsified: nextest exits 0 having run zero tests, and each interop test returns early -- still passing -- when check_ros2_available says no. Print the captured output and require a nextest summary reporting a non-zero count. Also correct two doc claims in pubsub.rs: the dispatcher and queue-mode capacities are not the same expression (they differ at a zero depth, harmlessly -- now pinned by two queue tests), and catch_unwind around a user callback is inert under the abort-on-panic opt profile.
Every other scenario in this file drives the synchronous publish, so the async path was unpinned. It is guarded differently and the difference is load-bearing: the guard is scoped to into_future() rather than held across the await, which is only sufficient because zenoh resolves a put eagerly there (IntoFuture = ready(self.wait()), zenoh 1.9.0). That is an upstream implementation detail, not a contract -- if the put ever became lazy it would move outside the guard and every deadlock this file prevents would return on the async path unnoticed. Asserts on thread identity rather than waiting for a hang, so it fails in a second with a legible message. Proven in both directions: dropping the guard from the async path fails it on the assertion.
Two changes here were not about subscriber re-entrancy and are moved to their own pull requests: - scripts/test-ros.nu, the non-vacuous interop gate. CI hygiene, found while gathering evidence for this fix. - ZSubBuilder::build_with_sample_callback and its hiroz-py call site, a payload-copy removal. It shared a call site with the re-entrancy fix, which is proximity, not a reason to review them together. Nothing else changes. The 13 tests in reentrant_publish and dispatch_backpressure still pass, and the GIL-release and teardown fixes in hiroz-py stay -- those are the same defect as the deadlock, reached from Python.
Review found the advanced branch was taken on `qos_needs_advanced` alone, before `runs_user_code` was consulted, so every TransientLocal queue-mode subscriber -- /tf_static, /robot_description, every latched rmw subscription -- got a dispatcher thread it does not need. That is an extra OS thread per subscription, an extra thread hop and condvar wake per sample on the inter-process path, and an unbounded queue in front of the bounded one. A queue-mode handler runs no user code, so zenoh-ext's state lock is not a hazard for it. Note the fix is NOT to gate the whole branch on runs_user_code, as first suggested: TransientLocal needs the AdvancedSubscriber for history replay and miss recovery whether or not user code runs. Only the dispatcher is conditional, so `SubscriberHandle::Advanced::dispatcher` becomes an Option, mirroring the Plain variant. Also removes an invented history from two shipped test files: MAX_CALLBACK_REENTRY_DEPTH and its depth cap of 16 never existed on main, so "this caps out at 16" was asserting a behaviour that never shipped. On main the same loop deadlocks -- which is the defect being fixed.
always_shim returns an opaque impl Fn, so it cannot share a match arm with a plain closure -- E0308 on the previous commit. Box both to Box<dyn Fn(Sample) + Send + Sync>.
The second SubscriberHandle::Advanced construction site is in the raw FFI subscriber path, behind #[cfg(feature = "ffi")]. ci.yml never builds with that feature, so it compiled clean there and only test.yml's "Build Rust FFI library" step caught it -- which is exactly the gap issue #270 describes.
This branch predates #271 and carried an older scripts/test-ros.nu. Rebasing replayed it, deleting the two `print` lines #271 added -- so merging would have restored a banner that cannot fail: nextest exits 0 when it runs zero tests, and without the output nothing distinguishes 57 passing interop tests from a binary that matched none. Restores the file to main's version. The extraction commit's own message says it split the CI gate out to #271; the file did not follow.
The advanced (TransientLocal) construction site passed DISPATCH_UNBOUNDED unconditionally, on the argument that dropping would discard the samples miss-detection recovered. That argument holds for KeepAll -- which dispatch_capacity still maps to DISPATCH_UNBOUNDED -- but it was applied to every profile, so a KeepLast(10) subscriber got an unbounded queue. Because the advanced path's shim enqueues remote samples too, that traded zenoh's transport backpressure for unbounded in-process growth: a remote publisher outpacing a slow callback grew the backlog until the process died, with only a doubling-threshold warn! for a signal. Both paths now pass dispatch_capacity, so a callback subscriber retains what its history QoS declares regardless of which path it takes -- which is what the PR description already claimed. Adds the two advanced-path scenarios the file was missing; every existing test in it takes the plain path, so nothing detected this.
… depth" This reverts aa7b66d. Bounding the advanced queue at the declared depth breaks transient_local_delivery_preserves_order, which publishes 500 samples through a KeepLast(10) TransientLocal subscriber and asserts all 500 arrive in order. At depth 10 the queue drops 490. That test is not incidental -- it encodes what the CallbackDispatcher doc states outright: on the advanced path, loss is a correctness bug rather than a QoS allowance, because a TransientLocal subscriber exists to replay history and recover samples zenoh-ext went out of its way to fetch. So the adversarial finding stands (an unbounded queue fed by remote samples has no backpressure and can grow until OOM) but the remedy does not: a thread-handoff buffer sized by the history depth conflates two different things. A burst-tolerant buffer with an absolute cap is the shape that satisfies both; that needs its own design and its own number.
…y depth" This reverts commit 4cb3424.
…ded path transient_local_delivery_preserves_order published 500 samples through a KeepLast(10) subscriber and asserted all 500 arrived. That asserts a promise no RMW makes: rmw_zenoh_cpp's add_new_message drops the oldest once message_queue_.size() >= adapted_qos_profile.depth, for every arriving sample, with no TransientLocal exemption -- the check reads the history policy only. The test's stated property is ordering, and its own doc says so. Assert that instead: a strictly increasing subsequence of what was published. That catches reordering whether or not anything was dropped, where an equality check conflated the two failures. Losslessness is still covered, on the profile that actually promises it, by keep_all_delivers_every_local_sample. Also records in the dispatcher docs that both implementations drop silently w.r.t. the ROS event API: upstream's MESSAGE_LOST comes from sequence-number gaps among arriving messages, which a depth-drop cannot produce, so bounding introduces no reporting gap.
The conversion to dispatch_capacity covered three of four CallbackDispatcher::spawn sites. node.rs's advanced (TransientLocal) arm still passed DISPATCH_UNBOUNDED, so an FFI raw subscriber declaring KeepLast(n) got an unbounded queue fed by remote samples -- the exact defect the other three sites were changed to remove. Two shipped doc comments and the PR description asserted 'both paths pass dispatch_capacity'. There are four paths, and one did not. Nothing caught it because the ffi feature is enabled by no crate, so this arm is never compiled on the PR gate (#291). Found by an audit agent reading the diff against its own description.
The comment said the ffi arm 'is not compiled on the PR gate at all'. It is compiled -- test.yml builds --features ffi on pull_request. What is missing is narrower: it is never linted and never tested, and its re-entrancy detector had been deleted. A wrong constant is neither a compile error nor a lint, so nothing was left to catch it. Building is not testing. The same false explanation was corrected in #291 and in this PR's description; the source comment was written in the same commit and missed.
One idea per sentence, active voice, consistent terms. Longest sentence in the rewritten blocks drops to 13 words. Three statements were corrected rather than rephrased, because a clearer sentence must not preserve a claim the code refutes: - the Backpressure lead-in said the two paths get different answers while both bullets said the same bound; they take the same bound and differ in which samples reach it - the field-order rationale said the dispatcher drains before it joins; it discards - qos_needs_advanced said subscriber/publisher; no publisher calls it Also drops a paragraph narrating an earlier revision of this branch, which will not exist after a squash merge.
YuanYuYuan
force-pushed
the
pr/2-pubsub-reentrancy
branch
from
August 14, 2026 13:09
8724f1a to
d6d0f2d
Compare
Rewrites the comments this branch adds under crates/hiroz/src/ to the ASD-STE100 sentence rules: one idea per sentence, 25 words maximum, active voice with a named actor, present tense, no clause joined by "and". No executable line changes. Also cuts comment text that a linked issue already owns and cites the issue instead: the "which arm was missed / building is not testing" narrative (#291), the superseded-revision rationale for the advanced dispatcher capacity (restated by CallbackDispatcher's "Backpressure" section), and the MESSAGE_LOST plumbing detail (#292). Adds pointers to #249 at each site that describes the deadlock, and to #290 at the notifier exemption whose claim nothing enforces. Measured over the added comment paragraphs of the production diff. before: 90 paragraphs, longest sentence 49 words, 29 over 25, 30 passive, 3 joined clauses after: 102 paragraphs, longest sentence 33 words, 1 over 25, 1 passive, 0 joined clauses The one remaining over-25 hit is a four-item list that the extraction script flattens into a single line. The one remaining passive hit reads "bounded *and* blocking" as a participle; both words are adjectives there and no actor is hidden.
build_with_callback's # Ownership section said only that dropping undeclares the subscriber. It now states that dropping joins a drain thread which may be inside the caller's callback, so the drop blocks for as long as that callback runs. That is the contract D1 needs before this ships to Rust callers. Two unit tests pin qos_needs_advanced for both durabilities. Reverting it to return true unconditionally is what #249 was, and nothing detected it. They do not pin the wiring to SubscriberHandle::Plain, because ZSub holds its handle privately; that gap stays #296's first item. Also reconciles three comments that said the plain and queue-mode bounds come from 'the same expression' with dispatch_capacity's own doc, which says in bold that they do not.
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.
Part of #282 — that issue states the defect class, the shared fix shape and the merge order.
Summary
Publishing from inside a subscriber callback on the same session deadlocked deterministically. This PR moves session-local delivery off the publishing thread onto a bounded per-subscriber drain queue. It also stops declaring zenoh-ext's
AdvancedSubscriberfor QoS profiles that do not need it.Role in #282: instance fix. It targets
mainand uses no tracked lock types, so it can merge in any order relative to the keystone (#255). Its locks are candidates for conversion in the follow-up that closes #282.Issue
Fixes #249. zenoh-ext's
AdvancedSubscriberinvokes the user callback while holding its own state mutex. That mutex is astd::sync::Mutex, which is not re-entrant. zenoh delivers a session-local sample inline on the publishing thread. A callback that publishes to its own topic therefore blocks on a lock its own thread already holds.After this PR the callout runs on a separate drain thread, holding nothing — the
acq · rel · calloutshape #282 describes:sequenceDiagram autonumber participant App as User thread participant Q as Bounded drain queue participant Drain as hiroz-sub-drain thread participant Cb as User callback App->>Q: publish(topic, m1) → enqueue, evicting if full Note over App: publish() returns immediately Q->>Drain: dequeue (state lock released before the callout) Drain->>Cb: invoke callback — holding nothing activate Cb Cb->>Q: publish(topic, m2) → enqueue Note over Q,Cb: no lock is held, so this returns.<br/>The loop iterates instead of recursing. deactivate CbBefore / after
What a user observes, whatever the implementation:
AdvancedSubscriber, which holds a non-reentrant mutex across the calloutpublish()returningwarn!drop(subscriber)The last three rows are breaking changes. The Breaking changes section states each one in full.
What this PR does
Three stacked mechanisms produced the hang. This PR removes all three.
AdvancedSubscriberonly when QoS needs it (qos_needs_advanced)CallbackDispatcher::spawn)Drop,dequeuechecksclosedbeforepending)drop(subscriber)run a callback per queued sample —backlog × callback_duration, unbounded.All four
CallbackDispatcher::spawnsites passdispatch_capacity— the plain and advanced arms of both the typed builder (pubsub.rs) and the FFI raw subscriber (node.rs).DISPATCH_UNBOUNDEDsurvives only as theKeepAllarm ofdispatch_capacityitself.Breaking changes
Seven. BC1–BC6 follow from moving session-local delivery off the publishing thread. BC7 is API surface.
publish()returning meant the subscriber had run → it does notKeepLast(depth)warn!KeepAllif losslessness is requiredMutex<State>, so callbacks were serialised by construction → they are not nowmainhas no queue and noDropimpl; every accepted sample was delivered inline beforepublishreturned, anddrop(sub)joined nothing → the backlog is discarded (as destroying an rclcpp subscription does), butDropnow joins the drain threadfficonsumersSubscriberHandleandCallbackDispatcherare new public items;RawSubscriber::innerchanges fromAdvancedSubscriber<()>toSubscriberHandleinnerrmw-zenoh-rsusesbuild_with_notifier, andwait_for_subscriptionreads the graph rather than local deliveryImportant
Scope qualifier on BC2. It applies to every sample only on the advanced (TransientLocal) path, where
always_shimenqueues unconditionally. On the plain path only locally published samples pass through the queue; a remote sample runs the callback inline on an RX worker and is neither queued nor dropped. The capacity constant is the same on both paths; which samples pass through it is not — see D2.Queue bound versus
rmw_zenoh_cppRead from source at
rmw_zenoh_cpp/src. Upstream's zenoh sample callback only callsSubscriptionData::add_new_message, which locks, bounds, enqueues and notifies. User code runs later on the rclcpp executor thread, never on a zenoh delivery thread, on any profile.rmw_zenoh_cppadapted_qos_profile.depthdispatch_capacity:KeepLast(depth).max(1),KeepAll→ unboundedpop_frontbeforeemplace_backpop_frontbeforepush_backatlen >= capacityKEEP_ALLDISPATCH_UNBOUNDEDTransientLocalexemptionadv_sub_opts.history->max_samples = qos_.depthcache_depth_from_historyis_shutdown_set, undeclare, queue never drained, takes short-circuitclosedset, drain loop exits, backlog discardedtrigger_callback()and the wait-set notify run undermutex_DataHandler::handlecallsnotifier()afterpushreturns, holding nothingMESSAGE_LOSTfrom arrival sequence-number gaps; depth drops are debug-log onlywarn!; noMESSAGE_LOSTat allMESSAGE_LOSTis pre-existing (#292)BLOCKonly forRELIABLE && KEEP_ALL, elseDROPBLOCKfor everyReliableifonly adds optionsNote
The "zero depth" divergence is not reachable and is not a behavioural difference. Upstream replaces a zero depth with 42;
dispatch_capacityfloors it at 1. Every constructor of the value it receives already normalises zero away:QosHistory::KeepLastholds aNonZeroUsize,from_depthmaps 0 → 10, the FFI conversion maps<= 0→ 10, and the default isKeepLast(10). All four call sites pass a locally declared entity's QoS, sodepth.max(1)cannot observe a zero.Two caveats stand: hiroz answers the same question three ways (10, 42, 1), of which only the 42 matches upstream; and upstream's in-tree comment claiming a floor of 1 in
rmw_create_subscriptionhas no matching code.This PR changes no existing test. Both test files it touches are new.
transient_local_delivery_preserves_orderasserts a strictly increasing subsequence of what the publisher sent. It does not assert that every sample arrives, becauseKeepLast(10)promises no such thing.keep_all_delivers_every_local_samplecovers losslessness on the profile that does promise it.Evidence
New tests: 15 Rust integration tests across
reentrant_publish.rsanddispatch_backpressure.rs, 2qos_needs_advancedunit tests inpubsub.rs, and 9 Python cases intest_reentrant_publish.py.license/claSUCCESS5aa7e383qos_needs_advancedto returntrue— the shape of #249 — failsvolatile_does_not_need_an_advanced_subscriberand passes 1 of 2. The unmodified code passes 2 of 2f724a0a5, forward re-run on5aa7e383(292 lib tests, both detectors)DISPATCH_UNBOUNDEDfails exactly one test:transient_local_keep_last_drops_the_oldest_local_samples2376a01adispatch_backpressurepasses 4/4 with the bound in place2376a01akeep_last_drops_the_oldest_local_samplesdetects unbounded growth and drop-newest, because it asserts values ([0, 46, 47, 48, 49]) rather than counts. Its determinism is structural, not slept-on: the callback setsparkedbefore it waits on the latch, and the publisher spins onparkedwith a deadline. The drain thread therefore cannot pop during the burst.What the tests actually pin
self_feeding_callback_loop_iterates_without_a_depth_cap,transient_local_self_feeding_callback_loop_iterates,intra_closed_loop_runs_iterativelytransient_local_subscriber_dropped_inside_its_own_callback_does_not_deadlocktransient_local_subscriber_drop_shuts_down_delivery_thread,test_transient_local_dispatcher_threads_do_not_leakrun_with_deadline, so a blocked publisher fails the deadline rather than being asserted against directlytest_interpreter_stays_alive_during_reentrant_publish— a watchdog thread must keep ticking. On an unfixed build it hangs the process rather than failing, so it detects only under an external wall-clock timeoutProperties no test pins
These are argued in doc comments at their call sites. They are not evidence. A reviewer should read them as design intent that a future regression would not trip:
no lost wakeup between
enqueueanddequeue;Dropnot holding the queue lock acrossjoin(); no thread leaked whendeclare_subscriberfails; field order undeclaring before it joins; the plain path holding no zenoh lock inline; no lock-order cycle with zenoh-ext'sstatesref; the panic guard;KeepLast(usize::MAX); counter saturation; action-client teardown.They are coverage gaps of the same kind #296 records, not claims this PR demonstrates.
Known defects in this change, disclosed
D1 is the one that changes what a reviewer must decide.
Drop for CallbackDispatcherjoins a thread that is running user code. Concretely:ZSubownsSubscriberHandleby value, and both variants owndispatcherby value.drop(sub)never returns. No timeout, no log, no panic.mainthere is noDropimpl and no thread, sodrop(sub)always returned. This is Re-entrancy: user callbacks invoked while a lock is held #282's own shape relocated to teardown.build_with_callback's# Ownershipsection discloses it: dropping a subscriber blocks for as long as the callback runs, with no timeout. The hazard stands; only the surprise is removed.hiroz-pyalready patches the two instances it hit, both viapy.allow_threads— direct evidence the hazard is real. Every Rust caller remains exposed.The rest are pre-existing or enforcement gaps:
LocalPublishGuardispub(crate), so its doc's demand that "a fifth publish path must do the same" is unenforceable outsidehiroz. One out-of-cratesession.putexists, in the WASM plugin host transportZSubs. Enforcement gap, not a live defectReliablemaps toCongestionControl::Blockon every history policy, where upstream usesBlockonly forRELIABLE && KEEP_ALLputinside a callback now stalls that subscriber's drain loop, and the queue then drop-oldests silently. Before this PR the same publish stalled the caller's own thread, which was visibledestroy_subscriberresolves by a per-node id carrying no node identityowned_id == 0.n1.destroy_subscriber(s2)tears down n1's subscription and returnsOk(())baseline + N, and per subscriber this costs +12 KB RSS and +66 MB of virtual address space (a 2 MB stack plus a 64 MB glibc per-thread malloc arena)rmw-zenoh-rsbuilds subscriptions withbuild_with_notifier, soruns_user_codeis false and no dispatcher is created. The cost falls on native Rust andhiroz-pycallback subscribers only. Address space, not RSS, is the binding limit;MALLOC_ARENA_MAX=2removes 64 of the 66 MBWhat green CI does not prove
#296 records reverts that should fail the suite and do not — places where a future regression would pass unnoticed. None is a defect in this PR.
Note
The gating decision has a detector; the wiring does not.
volatile_does_not_need_an_advanced_subscriberandtransient_local_needs_an_advanced_subscriberpinqos_needs_advancedin both directions. Forcing it to returntrue— the shape of #249 — fails the first of the two.No test asserts
matches!(handle, SubscriberHandle::Plain { .. })for a Volatile subscriber, becauseZSubholds its handle privately. A rewiring that ignoredqos_needs_advancedwould therefore still pass. That assertion stays #296's first item.Filed rather than fixed here:
ffibut never lints or tests it, and it lost its re-entrancy detector.MESSAGE_LOST.Important
D1 is disclosed, not fixed.
build_with_callbackdocuments that dropping a subscriber blocks for as long as its callback runs. A bounded teardown is the remaining work, and it is what a Rust caller needs before relying ondrop(sub)returning.