Skip to content

Commit af00f1e

Browse files
authored
fix(parameter): stop deadlocking on re-entry from an on_set callback (#257)
1 parent b0efcbe commit af00f1e

2 files changed

Lines changed: 402 additions & 7 deletions

File tree

Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
1+
//! Re-entrancy audit for the subsystems the pub/sub deadlock fix did *not* touch.
2+
//!
3+
//! The pub/sub bug was specific: zenoh-ext's `AdvancedSubscriber::sub_callback`
4+
//! takes a non-reentrant `std::sync::Mutex` and invokes the user callback under
5+
//! that guard, so a callback that published back into its own session re-entered
6+
//! a mutex its own thread already held.
7+
//!
8+
//! Services, actions and parameters use plain zenoh (`declare_queryable`), and
9+
//! zenoh core deliberately clones the queryable callbacks and `drop(state)`s
10+
//! before invoking them (`zenoh/src/api/session.rs`, `handle_query`) — exactly as
11+
//! it does for subscribers in `resolve_put`. That makes them *likely* safe, but
12+
//! "likely, by analogy" is not evidence. These tests are the evidence.
13+
//!
14+
//! Every scenario runs on a dedicated thread behind a hard deadline, so a
15+
//! re-entrancy deadlock fails the test instead of wedging the suite — the same
16+
//! shape as `reentrant_publish.rs`.
17+
//!
18+
//! # What each test is evidence *of*
19+
//!
20+
//! Only one of the four detects the defect this change fixes. Measured by
21+
//! reverting `parameter/service.rs` and re-running:
22+
//!
23+
//! * `parameter_on_set_callback_reregistering_does_not_deadlock` — **detector**.
24+
//! Fails on its deadline without the fix.
25+
//! * `parameter_on_set_callback_setting_another_parameter_does_not_deadlock` —
26+
//! guard, not detector: recursive `read()` usually succeeds unless a writer is
27+
//! queued, so against unfixed source it is a coin flip. See its own note.
28+
//! * the two service scenarios — the audit's **negative result**. They show
29+
//! services are clean; they pass with or without the fix, because the fix does
30+
//! not touch them. Their value is that "services came back clean" is a claim
31+
//! this file substantiates rather than asserts.
32+
//!
33+
//! Note on which API each test exercises. hiroz's *ergonomic* service server
34+
//! (`create_service(..).build()`) is queue-mode: the query is pushed onto a
35+
//! `BoundedQueue` and the user drains it with `take_request()` from their own
36+
//! thread, so ordinary service handlers never run on a zenoh RX thread at all.
37+
//! `build_with_callback` is the raw escape hatch that does put user code on the
38+
//! RX thread (it is what the parameter service and the action server use
39+
//! internally), so that is the path these tests target — it is the only place a
40+
//! service-side re-entrancy deadlock could exist.
41+
42+
#![cfg(feature = "ros-msgs")]
43+
44+
mod common;
45+
46+
use std::{
47+
sync::{
48+
Arc,
49+
atomic::{AtomicBool, AtomicUsize, Ordering},
50+
mpsc,
51+
},
52+
thread,
53+
time::Duration,
54+
};
55+
56+
use common::{TestRouter, create_hiroz_context_with_endpoint};
57+
use hiroz::{
58+
Builder,
59+
msg::{SerdeCdrSerdes, ZSerializer},
60+
parameter::{Parameter, ParameterValue, SetParametersResult},
61+
};
62+
use hiroz_msgs::example_interfaces::{AddTwoIntsRequest, AddTwoIntsResponse, srv::AddTwoInts};
63+
use serial_test::serial;
64+
use zenoh::{Wait, query::Query};
65+
66+
/// Budget for one scenario. Generous relative to the work done — anything slower
67+
/// than this is a hang, not slowness.
68+
const SCENARIO_TIMEOUT: Duration = Duration::from_secs(30);
69+
70+
/// Run `scenario` on its own thread; fail (rather than hang) past the deadline.
71+
///
72+
/// On timeout the worker is deliberately left running: it is blocked on a lock
73+
/// that will never be released, and there is no sound way to unwind it.
74+
fn with_deadline(name: &'static str, scenario: impl FnOnce() + Send + 'static) {
75+
let (tx, rx) = mpsc::channel();
76+
thread::spawn(move || {
77+
scenario();
78+
let _ = tx.send(());
79+
});
80+
match rx.recv_timeout(SCENARIO_TIMEOUT) {
81+
Ok(()) => {}
82+
// The worker panicked and dropped the sender. That is an assertion
83+
// failure inside the scenario, NOT a deadlock — reporting it as one
84+
// would turn every ordinary test failure into a false deadlock report.
85+
Err(mpsc::RecvTimeoutError::Disconnected) => {
86+
panic!("{name}: scenario panicked — see the worker thread's panic above")
87+
}
88+
Err(mpsc::RecvTimeoutError::Timeout) => {
89+
panic!("{name}: scenario did not finish within {SCENARIO_TIMEOUT:?} — deadlock")
90+
}
91+
}
92+
}
93+
94+
/// Reply to a query, echoing the attachment so the hiroz client can match it.
95+
fn reply_sum(query: &Query, sum: i64) {
96+
let bytes = SerdeCdrSerdes::<AddTwoIntsResponse>::serialize(&AddTwoIntsResponse { sum });
97+
let mut reply = query.reply(query.key_expr().clone(), bytes);
98+
if let Some(att) = query.attachment() {
99+
reply = reply.attachment(att.clone());
100+
}
101+
let _ = reply.wait();
102+
}
103+
104+
/// A service handler that publishes on the same session.
105+
///
106+
/// The handler runs on the zenoh RX thread, inside the queryable callback. If
107+
/// hiroz held any lock across `handler.handle(query)` — as the pub/sub path used
108+
/// to — this publish would re-enter it.
109+
#[test]
110+
#[serial]
111+
fn service_handler_publishing_does_not_deadlock() {
112+
with_deadline("service_handler_publishing", || {
113+
let router = TestRouter::new();
114+
let ctx = create_hiroz_context_with_endpoint(router.endpoint()).expect("ctx");
115+
let node = ctx.create_node("svc_pub").build().expect("node");
116+
117+
let published = Arc::new(AtomicUsize::new(0));
118+
let seen = Arc::new(AtomicUsize::new(0));
119+
120+
let sub_seen = seen.clone();
121+
let _sub = node
122+
.create_sub::<hiroz_msgs::std_msgs::String>("/svc_side_effect")
123+
.build_with_callback(move |_m| {
124+
sub_seen.fetch_add(1, Ordering::SeqCst);
125+
})
126+
.expect("sub");
127+
128+
let side_pub = Arc::new(
129+
node.create_pub::<hiroz_msgs::std_msgs::String>("/svc_side_effect")
130+
.build()
131+
.expect("pub"),
132+
);
133+
134+
let pub_for_handler = side_pub.clone();
135+
let published_c = published.clone();
136+
let _server = node
137+
.create_service::<AddTwoInts>("add_two_ints")
138+
.build_with_callback(move |query: Query| {
139+
// The re-entrant side effect: publish from inside the queryable
140+
// callback, on the same session.
141+
pub_for_handler
142+
.publish(&hiroz_msgs::std_msgs::String {
143+
data: "from-service-handler".into(),
144+
})
145+
.expect("publish from service handler");
146+
published_c.fetch_add(1, Ordering::SeqCst);
147+
reply_sum(&query, 42);
148+
})
149+
.expect("server");
150+
151+
let client = node
152+
.create_client::<AddTwoInts>("add_two_ints")
153+
.build()
154+
.expect("client");
155+
156+
thread::sleep(Duration::from_millis(1000));
157+
158+
let rt = tokio::runtime::Runtime::new().unwrap();
159+
let resp = rt.block_on(async {
160+
client
161+
.call_with_timeout(&AddTwoIntsRequest { a: 1, b: 2 }, Duration::from_secs(10))
162+
.await
163+
});
164+
assert!(resp.is_ok(), "service call failed: {:?}", resp.err());
165+
166+
thread::sleep(Duration::from_millis(500));
167+
assert_eq!(
168+
published.load(Ordering::SeqCst),
169+
1,
170+
"handler did not complete its publish"
171+
);
172+
assert!(
173+
seen.load(Ordering::SeqCst) >= 1,
174+
"the publish issued from the service handler was never delivered"
175+
);
176+
});
177+
}
178+
179+
/// A service handler that calls a *second* service on the same session.
180+
///
181+
/// The nested call is issued from the zenoh RX thread. This is the "service
182+
/// handler calls another service" hazard: if the queryable dispatch path held a
183+
/// lock, or if the inner query could only be answered by the very thread that is
184+
/// blocked, this never returns.
185+
#[test]
186+
#[serial]
187+
fn service_handler_calling_another_service_does_not_deadlock() {
188+
with_deadline("service_handler_nested_call", || {
189+
let router = TestRouter::new();
190+
let ctx = create_hiroz_context_with_endpoint(router.endpoint()).expect("ctx");
191+
let node = ctx.create_node("svc_nested").build().expect("node");
192+
193+
let _inner = node
194+
.create_service::<AddTwoInts>("inner")
195+
.build_with_callback(move |query: Query| reply_sum(&query, 7))
196+
.expect("inner server");
197+
198+
let inner_client = Arc::new(
199+
node.create_client::<AddTwoInts>("inner")
200+
.build()
201+
.expect("inner client"),
202+
);
203+
204+
let nested_ok = Arc::new(AtomicBool::new(false));
205+
let nested_ok_c = nested_ok.clone();
206+
let inner_for_handler = inner_client.clone();
207+
208+
let _outer = node
209+
.create_service::<AddTwoInts>("outer")
210+
.build_with_callback(move |query: Query| {
211+
// Nested service call from inside a queryable callback.
212+
//
213+
// The callback runs on one of zenoh's own tokio worker threads,
214+
// so `Runtime::block_on` here panics with "Cannot start a
215+
// runtime from within a runtime". The nested call therefore runs
216+
// on a plain std thread with its own runtime, and this callback
217+
// *joins* it — which is the hazard being tested: the zenoh RX
218+
// thread is blocked for the whole duration of the inner query.
219+
// If serving that inner query required this very thread, the
220+
// join never returns.
221+
let client = inner_for_handler.clone();
222+
let worker = thread::spawn(move || {
223+
let rt = tokio::runtime::Builder::new_current_thread()
224+
.enable_all()
225+
.build()
226+
.unwrap();
227+
rt.block_on(async {
228+
client
229+
.call_with_timeout(
230+
&AddTwoIntsRequest { a: 3, b: 4 },
231+
Duration::from_secs(10),
232+
)
233+
.await
234+
})
235+
});
236+
let inner = worker.join().expect("nested-call worker panicked");
237+
if inner.is_ok() {
238+
nested_ok_c.store(true, Ordering::SeqCst);
239+
}
240+
reply_sum(&query, inner.map(|r| r.sum).unwrap_or(-1));
241+
})
242+
.expect("outer server");
243+
244+
let outer_client = node
245+
.create_client::<AddTwoInts>("outer")
246+
.build()
247+
.expect("outer client");
248+
249+
thread::sleep(Duration::from_millis(1000));
250+
251+
let rt = tokio::runtime::Runtime::new().unwrap();
252+
let resp = rt.block_on(async {
253+
outer_client
254+
.call_with_timeout(&AddTwoIntsRequest { a: 1, b: 2 }, Duration::from_secs(20))
255+
.await
256+
});
257+
assert!(resp.is_ok(), "outer service call failed: {:?}", resp.err());
258+
assert!(
259+
nested_ok.load(Ordering::SeqCst),
260+
"the nested service call from inside the handler did not complete"
261+
);
262+
assert_eq!(resp.unwrap().sum, 7, "nested result not propagated");
263+
});
264+
}
265+
266+
/// A parameter `on_set` callback that sets another parameter.
267+
///
268+
/// `ParameterState::validate_and_apply` invokes the user callback while holding
269+
/// `on_set_callback.read()` (an `std::sync::RwLock` read guard). A callback that
270+
/// calls `set_parameter` re-entered `validate_and_apply` on the same thread and
271+
/// therefore took that same read lock recursively. Recursive read acquisition on
272+
/// `std::sync::RwLock` is explicitly not guaranteed by the standard library — it
273+
/// deadlocks if a writer is queued between the two acquisitions — so this was the
274+
/// closest analogue to the pub/sub bug outside pub/sub.
275+
///
276+
/// Note what this test does and does not detect. Recursive `read()` on one
277+
/// thread usually *succeeds* unless a writer is queued in between, and nothing
278+
/// here queues one — so against unfixed source it is a coin flip, not a
279+
/// detector. The re-registering case below is the deterministic one. This is a
280+
/// regression test for the fixed behaviour, not proof the defect existed.
281+
#[test]
282+
#[serial]
283+
fn parameter_on_set_callback_setting_another_parameter_does_not_deadlock() {
284+
with_deadline("parameter_on_set_reentrant", || {
285+
let router = TestRouter::new();
286+
let ctx = create_hiroz_context_with_endpoint(router.endpoint()).expect("ctx");
287+
let node = Arc::new(ctx.create_node("param_reentrant").build().expect("node"));
288+
289+
node.declare_parameter("a", ParameterValue::Integer(0), Default::default())
290+
.expect("declare a");
291+
node.declare_parameter("b", ParameterValue::Integer(0), Default::default())
292+
.expect("declare b");
293+
294+
let reentered = Arc::new(AtomicBool::new(false));
295+
let reentered_c = reentered.clone();
296+
// Weak, so the callback (owned by the node) does not keep the node alive.
297+
let node_weak = Arc::downgrade(&node);
298+
299+
node.on_set_parameters(move |changed: &[Parameter]| {
300+
// Only re-enter for "a", or this recurses forever.
301+
if changed.iter().any(|p| p.name == "a")
302+
&& !reentered_c.swap(true, Ordering::SeqCst)
303+
&& let Some(n) = node_weak.upgrade()
304+
{
305+
// Re-entrant set from inside the on_set callback.
306+
let _ = n.set_parameter(Parameter::new("b", ParameterValue::Integer(99)));
307+
}
308+
SetParametersResult::success()
309+
});
310+
311+
node.set_parameter(Parameter::new("a", ParameterValue::Integer(1)))
312+
.expect("set a");
313+
314+
assert!(
315+
reentered.load(Ordering::SeqCst),
316+
"the on_set callback never ran"
317+
);
318+
assert_eq!(
319+
node.get_parameter("b"),
320+
Some(ParameterValue::Integer(99)),
321+
"the re-entrant set_parameter did not take effect"
322+
);
323+
});
324+
}
325+
326+
/// A parameter `on_set` callback that replaces the callback registration.
327+
///
328+
/// This is the deterministic form of the same defect — stated in the past tense
329+
/// because this branch is what removes it. `validate_and_apply` *used to* hold
330+
/// `on_set_callback.read()` across the user callback, while `on_set_parameters`
331+
/// takes `on_set_callback.write()`. A callback that re-registered therefore
332+
/// asked the same thread for a write lock while it still held a read lock on
333+
/// the same `std::sync::RwLock` — a guaranteed self-deadlock, no race required.
334+
/// The fix clones the callback `Arc` out and drops the guard before invoking,
335+
/// so no lock is held when the callback runs; this test holds that line.
336+
///
337+
/// "Swap out the validator once the node is configured" is an ordinary thing to
338+
/// want, and rclcpp supports it (`remove_on_set_parameters_callback` /
339+
/// `add_on_set_parameters_callback` are callable from within a callback), so
340+
/// this is a reachable API shape rather than a contrived one.
341+
#[test]
342+
#[serial]
343+
fn parameter_on_set_callback_reregistering_does_not_deadlock() {
344+
with_deadline("parameter_on_set_reregister", || {
345+
let router = TestRouter::new();
346+
let ctx = create_hiroz_context_with_endpoint(router.endpoint()).expect("ctx");
347+
let node = Arc::new(ctx.create_node("param_rereg").build().expect("node"));
348+
349+
node.declare_parameter("a", ParameterValue::Integer(0), Default::default())
350+
.expect("declare a");
351+
352+
let ran = Arc::new(AtomicBool::new(false));
353+
let ran_c = ran.clone();
354+
let node_weak = Arc::downgrade(&node);
355+
356+
node.on_set_parameters(move |_changed: &[Parameter]| {
357+
if !ran_c.swap(true, Ordering::SeqCst)
358+
&& let Some(n) = node_weak.upgrade()
359+
{
360+
// Re-register from inside the callback. Pre-fix this asked
361+
// for a write lock while the same thread still held the read
362+
// lock; post-fix no guard is live here at all.
363+
n.on_set_parameters(|_| SetParametersResult::success());
364+
}
365+
SetParametersResult::success()
366+
});
367+
368+
node.set_parameter(Parameter::new("a", ParameterValue::Integer(1)))
369+
.expect("set a");
370+
371+
assert!(ran.load(Ordering::SeqCst), "the on_set callback never ran");
372+
});
373+
}

0 commit comments

Comments
 (0)