Skip to content

Commit f1bfde9

Browse files
committed
fix(parameter): stop deadlocking on re-entry from an on_set callback
`ParameterState::validate_and_apply` bound the `on_set_callback.read()` guard to a named local and invoked the user callback under it. Two re-entrant paths, both reachable from the public API: - `on_set_parameters` from inside the callback takes `on_set_callback.write()` while the same thread still holds the read guard — a guaranteed self-deadlock, no race required. - `set_parameter` from inside the callback re-enters `validate_and_apply` and takes the read lock recursively, which `std::sync::RwLock` does not guarantee: it deadlocks if a writer is queued between the two acquisitions. `SetCallback` is already an `Arc`, so the fix is to clone it out and let the guard drop before the call — one refcount bump, no structural change. The store guard in the same function was already correctly scoped; only the callback guard was wrong. The lock becomes a `TrackedRwLock` and both call sites dispatch through `invoke_user_callback!`, so a reintroduction panics in debug naming the site instead of hanging. Detector evidence, both directions. Against the unfixed source `parameter_on_set_callback_reregistering_does_not_deadlock` fails on its 30s deadline (3 passed; 1 failed, 35.59s); with the fix all four pass in 6.59s. Separately, reverting only the guard-drop and keeping the tripwire makes the violation fire on `test_parameter_validation_callback` — an *ordinary* test, not a deadlock test — naming the site and the live guard count. Services and actions were audited for the same shape and are clean. `ZServer::build_internal` declares a plain zenoh queryable and calls `handler.handle(query)` with no hiroz lock held; the action server's user handler is awaited with no guard on the stack, and the action client never runs user code on a zenoh thread at all.
1 parent 9bc6dfd commit f1bfde9

2 files changed

Lines changed: 382 additions & 7 deletions

File tree

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

0 commit comments

Comments
 (0)