Skip to content

fix(pubsub): stop running subscriber callbacks on the publishing thread - #250

Open
YuanYuYuan wants to merge 18 commits into
mainfrom
pr/2-pubsub-reentrancy
Open

fix(pubsub): stop running subscriber callbacks on the publishing thread#250
YuanYuYuan wants to merge 18 commits into
mainfrom
pr/2-pubsub-reentrancy

Conversation

@YuanYuYuan

@YuanYuYuan YuanYuYuan commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Part of #282 — the defect class, the shared fix shape and the merge order are stated there.

Summary

Publishing from inside a subscriber callback on the same session deadlocked deterministically. This moves session-local delivery off the publishing thread onto a bounded per-subscriber drain queue, and stops declaring zenoh-ext's AdvancedSubscriber for QoS profiles that do not need it.

Role in #282: instance fix. Independent of the keystone (#255): it targets main, uses no tracked lock types, and can merge in any order. Its locks are candidates for conversion in the follow-up that closes #282.

The tip is d11d710c, merge-base ae4f0f8f (origin/main after #260 merged). Of 2,525 changed lines (2,485 added, 40 removed), 1,670 (66%) are the three new test files: crates/hiroz-tests/tests/reentrant_publish.rs (904), crates/hiroz-py/tests/test_reentrant_publish.py (435), crates/hiroz-tests/tests/dispatch_backpressure.rs (331) — 15 new Rust tests and 9 Python cases.

Issue

Fixes #249. There was no error and no traceback. Through the Python bindings it froze the whole interpreter.

What this PR does

Three stacked mechanisms produced the hang. This removes all three.

# Change Why
1 Declare zenoh-ext's AdvancedSubscriber only when QoS needs it (qos_needs_advanced, pubsub.rs:527) Its sample callback invokes user code under a non-reentrant std::sync::Mutex. For Volatile — the ROS 2 default — it declared no liveliness subscriber, heartbeat or detection token, so it was pure overhead plus the fatal lock. TransientLocal keeps it: history replay and miss recovery live there.
2 Move session-local delivery off the publishing thread onto a bounded drain queue (CallbackDispatcher, pubsub.rs:381) The publishing thread no longer runs the callback, so it cannot re-enter its own lock.
3 Teardown discards the backlog instead of running it inline (impl Drop for CallbackDispatcher, pubsub.rs:492; DispatchQueue::dequeue checks closed before pending, pubsub.rs:251-263) Draining first meant drop(subscriber) ran a user callback for every queued sample — backlog × callback_duration, with no ceiling.

All four CallbackDispatcher::spawn sites pass dispatch_capacity: pubsub.rs:1417, pubsub.rs:1449, node.rs:684, node.rs:702. DISPATCH_UNBOUNDED survives only as the KeepAll arm of dispatch_capacity itself. (Counted with a context grep — every call spans four lines, so a same-line match reports zero.)

Known defects in this change, disclosed

# Defect Where Consequence
D1 Drop for CallbackDispatcher calls thread.join() on a thread that is running user code, with the only escape being a self-join from inside that callback. Dropping a subscriber is therefore an unbounded callout to user code, made while the dropping thread holds whatever it holds. pubsub.rs:492-513; reachable synchronously because ZSub owns SubscriberHandle by value and both variants own dispatcher by value let m = Mutex::new(0); callback body takes m; main thread takes m, then drops the subscriber while one sample is in flight → drop(sub) never returns. No timeout, no log, no panic. On main there is no Drop impl in pubsub.rs and no thread, so drop(sub) always returned. This is #282's own shape relocated to teardown.
D2 CallbackDispatcher::spawn's doc says "the ffi feature is enabled by no crate, so that arm is not compiled on the PR gate at all (#291)". The premise is true; the conclusion is false. pubsub.rs:401-403, against .github/workflows/test.yml:179-183 (FEATURES="ffi,<distro>,rmw-zenoh", cargo build --no-default-features --features "$FEATURES" --lib, all four distro legs) A maintainer editing the FFI advanced arm concludes it has no automated coverage of any kind including compilation, and either skips a local --features ffi build or mis-scopes #291. The real reason nothing caught the wrong constant is that a wrong constant is neither a compile error nor a lint.
D3 Two other doc comments contradict the shipped behaviour. SubscriberHandle::Advanced::subscriber's field-order rationale (pubsub.rs:557-559) says the subscriber is undeclared first "before the dispatcher drains and joins" — it does not drain, it discards (breaking change #5). The "Backpressure" section's lead-in (pubsub.rs:329-330) says "The two paths get different answers", and the bullet 15 lines below (:345) says "same bound, and the same expression"; the same paragraph says the queue-mode BoundedQueue is sized "from the same expression" while dispatch_capacity's own doc (:139) says in bold that they are not. as cited Documentation only, but the drop-order sentence is the one a reader hits while reasoning about exactly the property it gets wrong.
D4 qos_needs_advanced's summary line says "subscriber/publisher" and closes "only pay for it when the QoS actually asks for it" (pubsub.rs:515-526), but no publisher calls it — ZPubBuilder::build does pub_builder.advanced() unconditionally (pubsub.rs:879). Its only two callers are the subscriber paths (pubsub.rs:1388, node.rs:679). as cited No runtime defect: for Volatile the wrapper is inert (Sequencing::None → no seqnum, no cache, no liveliness token). The doc promises a decision the code does not make.
D5 The plain path's bound applies only to locally published samples. local_only_shim enqueues a sample only when a hiroz publish is on this thread; a remote sample runs the callback inline on a zenoh RX worker and never enters the queue. pubsub.rs:474-490; correctly stated in-code at pubsub.rs:342-349 Breaking change #2 below carries the qualifier. Upstream bounds every arriving sample; hiroz's plain path bounds the local half. Over-warning, not under-warning, but the plain path is not upstream-aligned on this property.
D6 LocalPublishGuard is pub(crate) (pubsub.rs:54), so its own doc's demand — "a fifth publish path added later must do the same" — is unenforceable outside hiroz, and one out-of-crate session.put already exists (hiroz-union/src/plugin/wasm/host/transport.rs:58). as cited No reachable failure today: that host's subscribers are raw session.declare_subscriber handles, not hiroz ZSubs, so no dispatcher-backed callback sits on those key expressions, and a callback publishing back does so through ZPub, which is guarded. Enforcement gap, not a live defect.
D7 Pre-existing, but this PR gives it new reach: Reliable maps to CongestionControl::Block on every history policy (pubsub.rs:866-870), where upstream uses Block only for RELIABLE && KEEP_ALL and Drop otherwise (detail/rmw_publisher_data.cpp:179-184). as cited A callback that publishes now runs on the drain thread, so a blocked put inside a callback stalls that subscriber's drain loop entirely and its queue then silently drop-oldests, with only the escalating warn! as a signal. Before this PR the same blocked publish stalled the publishing/RX thread, which was at least visible to the caller.
D8 Pre-existing: PyZNode::destroy_subscriber resolves by a per-node id with no node identity (hiroz-py/src/node.rs:144 next_sub_id: 0 per node, :157 Vec<(u64, Box<dyn Any>)>, :428-443). as cited Two nodes in one interpreter both mint owned_id == 0; n1.destroy_subscriber(s2) tears down n1's subscription and returns Ok(()). Destroying an id the node does not own also returns Ok(()).

D1 is the one that changes what a reviewer must decide: it is a genuine new hazard on a public API with no disclosure in the Rust docs (build_with_callback's "# Ownership" section tells callers only that dropping undeclares). hiroz-py already patches the two instances it hit — destroy_subscriber (hiroz-py/src/node.rs:434-442) and the new impl Drop for PyZNode (:161-175), both via py.allow_threads — which is direct evidence the hazard is real; every Rust caller is left exposed.

Evidence

Measurement Commit How
CI green — 26/26 check runs SUCCESS/SKIPPED, 0 failed, 0 pending; license/cla SUCCESS d11d710c (the tip) check rollup on the current head, filtered on __typename == "CheckRun" and status == "COMPLETED"
Reverting the advanced-path queue bound (pubsub.rs:1417DISPATCH_UNBOUNDED) makes exactly one test fail: transient_local_keep_last_drops_the_oldest_local_samples pre-rebase local run
dispatch_backpressure passes 4/4 with the bound in place pre-rebase local run
Merge-base is exactly origin/main (ae4f0f8f) — no unintended reversion in the range d11d710c git merge-base origin/main HEAD

keep_last_drops_the_oldest_local_samples (dispatch_backpressure.rs:215) detects 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 sets parked before waiting on the latch and the publisher spins on parked with a deadline, so the drain thread provably cannot pop during the burst.

Established by reading the code, not by executing it:

Claim Basis
No lost wakeup between enqueue and dequeue dequeue holds the state mutex continuously from the closed/pending check into Condvar::wait, which releases it atomically; one consumer, so notify_one suffices; Drop uses notify_all
Drop never holds the queue lock across join() self.queue.lock().closed = true; is a statement-scoped temporary, dropped at the ;
Self-join is handled thread.thread().id() == current().id() early return (pubsub.rs:499-503); self.thread.take() detaches, so the drain thread exits when the in-flight callback returns
A failed declare_subscriber leaks no thread dispatcher is a local binding at all four sites and wait()? returns past it, running Drop
Field order undeclares before it joins in both SubscriberHandle variants subscriber before dispatcher at pubsub.rs:543-546 and :554-562; Rust drops fields in declaration order. Weaker than the comment implies — zenoh undeclares with wait_callbacks: false — but enqueue's early return on closed makes a late sample harmless
A callback that publishes to its own topic iterates rather than recursing, on both paths, with memory bounded at the history depth plain: guard → local_only_shim enqueues; advanced: always_shim enqueues unconditionally; the queue evicts instead of blocking
The plain path holds no zenoh lock when the callback runs inline Session::send_push_consume drops the session state lock before callbacks.call; route_data drops rtables before send_push on both branches
No lock-order cycle with zenoh-ext's statesref RX thread takes statesref then the dispatch state; the drain thread releases the dispatch state before running the callback, so a republishing callback takes statesref holding nothing. One-directional
The queue never blocks its producer enqueue evicts before inserting and returns unconditionally, so neither the publishing thread nor an RX thread can be parked by a slow callback
A panicking callback cannot wedge the subscriber catch_unwind at pubsub.rs:439, poison-tolerant lock(); the doc states its own limitation (inert under this workspace's panic = "abort" profile)
KeepLast(usize::MAX) is safe end to end maps to DISPATCH_UNBOUNDED; cache_depth_from_history forwards it to zenoh-ext, which stores it as a NonZeroUsize and compares rather than preallocating; BoundedQueue::new caps its with_capacity at 1024
The escalating warn counters cannot wrap saturating_add / saturating_mul; at saturation it warns on every drop — degraded logging, not wrong delivery
No hiroz-py path holds the GIL across a callout publish/publish_raw release before the put; destroy_subscriber and Drop for PyZNode release before dropping owned callback subscribers; queue-mode PyZSubscribers carry dispatcher: None
The action client's two dispatcher threads cannot hang teardown feedback_tx is an unbounded mpsc::UnboundedSender and status_tx a watch::Sender, neither of which blocks; watch keeps only the newest value, so a depth-drop cannot lose a terminal GoalStatus

Breaking changes

Seven. Six follow from moving session-local delivery off the publishing thread. The seventh is API surface.

# What changes Who is affected Before → after Action
1 Session-local delivery is asynchronous Anyone publishing and then asserting on a same-session side effect publish() returning meant the subscriber had run → it does not Synchronise explicitly
2 Same-session samples can be dropped Callback subscribers with KeepLast(depth) Inline delivery made loss structurally impossible → the queue is bounded at the history depth and drops oldest, with an escalating warn! Use KeepAll if losslessness is required
3 Subscriber callbacks are no longer mutually excluded Callbacks doing a non-atomic read-modify-write zenoh-ext invoked the callback under its Mutex<State>, so callbacks were serialised by construction → they are not now Add your own synchronisation
4 Local and remote publications on one topic are no longer ordered relative to each other Plain (non-TransientLocal) subscribers Single interleaved order → two independent paths Do not rely on cross-source ordering
5 Dropping a subscriber discards its undelivered backlog, and blocks on any in-flight callback without bound Anyone dropping a subscriber; anyone relying on teardown draining main has no queue and no Drop impl in pubsub.rs; every accepted sample was delivered inline before publish returned, and drop(sub) joined nothing → the backlog is discarded (as destroying an rclcpp subscription does), but Drop now joins the drain thread Never drop a subscriber while holding a lock, GIL or channel its callback also touches — see D1. Drain before dropping if the backlog matters
6 New public types ffi consumers SubscriberHandle and CallbackDispatcher are new public items; RawSubscriber::inner changes from AdvancedSubscriber<()> to SubscriberHandle (ffi/subscriber.rs:13) Update any direct use of inner
7 Synchronous local delivery is gone as a guarantee Downstream code only Nothing in this repo depended on it — rmw-zenoh-rs uses build_with_notifier (rmw.rs:574), and wait_for_subscription reads the graph rather than local delivery (pubsub.rs:945)

⚠️ Scope qualifier on #2. It applies to every sample only on the advanced (TransientLocal) path, where always_shim enqueues 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 (D5).

⚠️ An earlier revision left the advanced path unbounded on every profile, replacing zenoh's transport backpressure with unbounded in-process growth. c62f3e26 and d11d710c closed that on the typed and FFI arms respectively.

Queue bound versus rmw_zenoh_cpp

Read from source at rmw_zenoh_cpp/src. Upstream's model: the zenoh sample callback does nothing but SubscriptionData::add_new_message, which locks, bounds, enqueues and notifies; user code runs later on the rclcpp executor thread. No user code ever runs on a zenoh delivery thread, on any profile.

Property rmw_zenoh_cpp hiroz after this PR Verdict
bound adapted_qos_profile.depth (detail/rmw_subscription_data.cpp:1125-1126) dispatch_capacity: KeepLast(depth).max(1), KeepAll → unbounded aligned
drop policy drop oldest, pop_front before emplace_back (:1140-1143, :1167) pop_front before push_back at len >= capacity aligned
KEEP_ALL unbounded (:1125) DISPATCH_UNBOUNDED aligned
TransientLocal exemption none — the check reads history policy only (:1125) none aligned
applies to every arriving sample, remote included (:1110, called from :420, :803) advanced path yes; plain path local samples only divergent — D5
advanced-subscriber cache adv_sub_opts.history->max_samples = qos_.depth (:379, :728) cache_depth_from_history (pubsub.rs:580-590) aligned
teardown is_shutdown_ set, undeclare, queue never drained, takes short-circuit (:843-899, :952, :1062) closed set, drain loop exits, backlog discarded aligned
notifier/wake on the delivery thread trigger_callback() and the wait-set notify run under mutex_ (:1114 guard covers :1170-1175) DataHandler::handle calls notifier() after push returns, holding nothing (common.rs:44-48) hiroz-better; upstream has the #282 shape here
loss reporting MESSAGE_LOST from arrival sequence-number gaps (:1146-1163); depth drops are debug-log only (:1128-1134) escalating warn!; no MESSAGE_LOST at all aligned on the narrow claim (a pop_front at depth cannot create an arrival gap upstream either); hiroz-better on visibility; the absent MESSAGE_LOST is pre-existing, #292
publisher backpressure BLOCK only for RELIABLE && KEEP_ALL, else DROP (detail/rmw_publisher_data.cpp:179-184) BLOCK for every Reliable hiroz-worse, pre-existing — D7
advanced subscriber gating declared unconditionally; the durability if only adds options (:371-387, :427) plain subscriber for Volatile divergent by design — the point of change #1

⚠️ The "zero depth" divergence previously claimed here is not reachable and should not be read as a behavioural difference. Upstream replaces a zero depth with 42 (detail/qos.cpp:27, :109-110); dispatch_capacity floors it at 1 (pubsub.rs:132). dispatch_capacity is called at exactly four sites, all with a locally declared entity's QoS, and every constructor of that value already normalises zero away: hiroz::qos::QosHistory::KeepLast holds a NonZeroUsize, QosHistory::from_depth maps 0 → 10 (the rmw path, rmw-zenoh-rs/src/qos.rs:241), the FFI conversion maps history_depth <= 0 → 10, and the unspecified case defaults to KeepLast(10). So depth.max(1) cannot observe a zero and the divergence cannot manifest. hiroz also answers the same question three ways — 10 in QosHistory::from_depth, 42 in cache_depth_from_history, 1 in dispatch_capacity — of which only the 42 matches upstream. Upstream's in-tree comment claiming a floor of 1 in rmw_create_subscription (:1137-1139) has no matching code: rmw_zenoh.cpp does not touch depth at all.

One existing test changed. transient_local_delivery_preserves_order published 500 samples through a KeepLast(10) subscriber and asserted all 500 arrived — a promise no RMW makes. Its stated property is ordering, so it now asserts a strictly increasing subsequence of what was published. Losslessness is still covered on the profile that promises it, by keep_all_delivers_every_local_sample.

Coverage this does not have

⚠️ The headline change has no detector. This is measured, not suspected.

Probe Result
Force qos_needs_advanced to return true unconditionally — so every subscriber declares an AdvancedSubscriber, exactly what this PR exists to stop — and re-run the suite the whole suite passed

So change #1, which carries the "pure overhead" argument, is unpinned in both directions. A test asserting matches!(handle, SubscriberHandle::Plain { .. }) for a Volatile subscriber would close it.

Everything else that is unpinned, with the one-line revert that should fail and does not:

Production change Revert that stays green Why nothing catches it
Teardown discards the backlog (breaking change #5) Reorder dequeue (pubsub.rs:251-263) to pop pending before checking closed The reordered loop still terminates, because enqueue early-returns on closed. No test drops a dispatcher with a non-empty queue: the one drop-under-deadline test publishes once and calls await_deliveries(&seen, 1) first, and all four dispatch_backpressure tests release the latch and wait for delivery to be stable for 500 ms before their subscriber goes out of scope. No test asserts on teardown duration.
destroy_subscriber releasing the GIL before dropping (hiroz-py/src/node.rs:434-442) Restore the bare swap_remove The only test that calls it uses lambda _msg: None on freshly-minted topics no publisher writes to, so the drain thread never enters a callback and the join never needs the GIL. The pre-existing basic-pubsub test sleeps 0.3 s after its single publish first.
impl Drop for PyZNode releasing the GIL (hiroz-py/src/node.rs:161-175) Delete the impl No Python test deletes a node or forces interpreter shutdown while a callback is in flight. The closest — the self-feeding loop test, which drops its node right after the final callback sets its event — is a rare race in the unfixed build, not a check.
publish_raw releasing the GIL (hiroz-py/src/pubsub.rs:49) Delete py.allow_threads No test calls publish_raw at all; it appears only in the type stub and a benchmark. (publish is pinned, by test_interpreter_stays_alive_during_reentrant_publish.)
LocalPublishGuard on publish_serialized (pubsub.rs:1133) and publish_sample (pubsub.rs:1150) Delete either guard Neither method is called by any test or example, so #249 remains reachable through two of the five public publish entry points with nothing failing. async_publish got a dedicated detector precisely because it was unpinned; the same argument applies here.
catch_unwind around the user callback (pubsub.rs:439) Call (*handler)(sample) directly Nothing in the suite panics inside a subscriber callback. Without the guard the drain thread dies, enqueue keeps accepting into a queue nobody drains, and the subscriber goes silent forever with no log.
Teardown and self-drop on the plain/Volatile path Both teardown tests are transient_local_*. On the advanced path a callback dropping its own subscriber always takes the self-join early return; on the plain path a remote sample runs the callback on an RX worker, so the same user code drops from a thread that is not the drain thread — the join actually runs, concurrently with a possible second callback invocation (breaking change #3). I checked that path and it does not deadlock (zenoh holds no session or routing lock across the callout), but nothing pins it and no test exercises it. Volatile is the ROS 2 default.
Drop-order of SubscriberHandle's fields Swap the two fields Swapping does not produce a failure I could construct: enqueue returns early once closed is set, and the plain path's inline branch bypasses the queue. Untested, not defective.
The escalating drop/backlog warn! Remove either warning No test asserts any warning is emitted. This is the only user-visible signal for the silent loss introduced by breaking change #2.

Correction to a claim this description previously made: "Nothing detects a regression of d40b5443" is too strong. Inverting either if runs_user_code branch is detected — flipping pubsub.rs:1413 leaves a TransientLocal callback subscriber with dispatcher: None, so it runs under zenoh-ext's state mutex and transient_local_callback_republishing_on_same_topic_does_not_deadlock deadlocks; flipping pubsub.rs:1438 sends a Volatile callback subscriber into the inline queue-mode arm and self_feeding_callback_loop_iterates_without_a_depth_cap dies of stack overflow at 2 000 frames. What is genuinely undetected is the narrower direction: making runs_user_code() also return true for the queue variants adds a needless thread and nothing fails.

Two test-quality gaps worth fixing but not blocking:

Gap Effect
transient_local_delivery_preserves_order (reentrant_publish.rs:688-700) is vacuous at one delivery — received.windows(2).all(..) yields no windows on a 0- or 1-element slice, and the only floor is !is_empty() A run that delivered 1 of 500 published samples passes and reports that ordering is preserved, having observed no pair. A received.len() >= 2 floor restores the claim without re-asserting the losslessness this PR correctly removed.
Both new test harnesses use rx.recv_timeout(..).is_err() and then panic with a fixed "deadlocked" message (reentrant_publish.rs:103-108, dispatch_backpressure.rs:87-89) An assert! failing inside a scenario closure drops the sender, so recv_timeout returns Disconnected immediately and an ordinary assertion failure is reported as a deadlock. #260 fixed exactly this on main by matching RecvTimeoutError::Timeout and Disconnected separately; these two files reintroduce the older form. The test still fails, so no verdict is wrong — only the message.
async_publish_delivers_off_the_publishing_thread asserts assert_ne!(callback_thread, publishing_thread) where the thread name is available and is what the comment claims If session-local inline delivery stopped entirely and the sample came back on a zenoh RX worker, the assertion still passes. Sending thread::current().name() and asserting Some("hiroz-sub-drain") asserts the value rather than a difference. The revert it exists to catch is caught either way.
The two new queue.rs unit tests pin BoundedQueue, not DispatchQueue zero_capacity_retains_one_sample's doc says a reorder to insert-then-evict makes it fail — true of BoundedQueue::push, which it constructs, and false of DispatchQueue::enqueue, which is what dispatch_capacity sizes. dispatch_capacity's doc cites it as evidence its zero-depth behaviour is harmless; it is not evidence about that type. They are correct tests of the wrong type for the claim they are cited for.

Filed rather than fixed here:

Gap Issue
Notifier subscribers skip the dispatcher on an unenforced claim #290
The ffi feature is built on the PR gate but never linted and never tested, and its re-entrancy detector was deleted in 1dcf3736 #291
hiroz never raises MESSAGE_LOST #292

Checklist

  • Added/updated tests and documentation
  • CI green on the tip d11d710c — 26/26 check runs SUCCESS/SKIPPED, 0 failed, 0 pending, license/cla SUCCESS. Verified on the current head SHA, filtering CheckRun and requiring status == "COMPLETED" before reading conclusion.
  • The full local suite (fmt + clippy + tests) was last run before the rebase onto ae4f0f8f, on a commit that no longer exists in this range. The test count previously quoted here has been removed rather than restated, because it was not re-measured. CI covers the same gates.
  • ./scripts/check-local.sh has not been re-run since the rebase.
  • D1 (unbounded teardown callout) and D2/D3/D4 (doc comments that contradict the code) are disclosed above and not fixed in this PR. D1 in particular needs either a documented contract on build_with_callback or a bounded/detaching teardown before this ships to Rust callers; D2's sentence is wrong on a verifiable fact and only the doc comment, not this description, survives the merge.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/hiroz/src/node.rs
Comment thread crates/hiroz/src/pubsub.rs Outdated
Comment thread crates/hiroz/src/pubsub.rs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_code is checked, so every TransientLocal queue/notifier subscriber (including rmw's build_with_notifier path) 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 bounded BoundedQueue, contradicting the queue-mode contract below. Split the advanced path on runs_user_code and 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_internal already performs this encoding validation for every DataHandler at lines 1300–1320. Wrapping the new callback again parses each encoding twice and emits duplicate mismatch/unknown-format logs. Pass the callback directly to build_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/task is 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 only allow_threads still 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
YuanYuYuan force-pushed the pr/2-pubsub-reentrancy branch 4 times, most recently from 90092b2 to db3c6cc Compare July 29, 2026 09:13
YuanYuYuan added a commit that referenced this pull request Jul 29, 2026
The doc block moved here when this was split out of #250 referenced
`CallbackDispatcher`, which #250 introduces and main does not have, so
rustdoc could not resolve it and check-rustdoc-links failed. The sentence
was also meaningless here for the same reason.
@YuanYuYuan
YuanYuYuan force-pushed the pr/2-pubsub-reentrancy branch 2 times, most recently from 85fb828 to 60a8dcd Compare August 5, 2026 16:54
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.
…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.
@YuanYuYuan
YuanYuYuan force-pushed the pr/2-pubsub-reentrancy branch from 12a968a to d11d710 Compare August 6, 2026 10:35
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Re-entrancy: user callbacks invoked while a lock is held Publishing from inside a subscriber callback hangs forever

2 participants