Commit 1f04eb3
committed
fix(pubsub): stop running subscriber callbacks on the publishing thread
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.1 parent 8c7920e commit 1f04eb3
9 files changed
Lines changed: 2190 additions & 40 deletions
File tree
- crates
- hiroz-py
- src
- tests
- hiroz-tests/tests
- hiroz/src
- ffi
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
| 19 | + | |
19 | 20 | | |
20 | 21 | | |
21 | 22 | | |
| |||
233 | 234 | | |
234 | 235 | | |
235 | 236 | | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
236 | 245 | | |
237 | | - | |
238 | | - | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
239 | 256 | | |
240 | 257 | | |
241 | 258 | | |
| |||
248 | 265 | | |
249 | 266 | | |
250 | 267 | | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
251 | 271 | | |
252 | 272 | | |
253 | 273 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
24 | 24 | | |
25 | 25 | | |
26 | 26 | | |
27 | | - | |
28 | | - | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
29 | 30 | | |
30 | 31 | | |
31 | | - | |
32 | | - | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
33 | 39 | | |
34 | 40 | | |
35 | 41 | | |
36 | 42 | | |
37 | 43 | | |
38 | 44 | | |
39 | | - | |
40 | | - | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
41 | 51 | | |
42 | 52 | | |
43 | 53 | | |
| |||
0 commit comments