Skip to content

Commit de0039e

Browse files
committed
fix(lifecycle): run transition callbacks outside the state-machine lock
`ZLifecycleNode::trigger_transition` invoked the user's transition callback from inside `state_machine.lock().unwrap().trigger(..)`. That mutex is shared with the node's own `~/get_state`, `~/change_state` and `~/get_available_transitions` handlers and with `get_current_state()`, so a node could not answer any question about its own state while a transition callback ran — and a callback that asks waits forever. Split `StateMachine::trigger` into `begin` (validate, enter the intermediate "busy" state) and `finish` (apply the callback's verdict), with `trigger` kept as a wrapper for callers that own the machine outright. The error path gets the same `finish_error_processing` split. The callback now runs between the two locked steps, with no guard held, and observers see the genuine intermediate state while it runs. The `begin` call is bound to its own `let` on purpose: writing it as a `match` scrutinee keeps the temporary guard alive for the whole `match` body and silently reinstates the deadlock. That mistake was made and caught by these tests during development. Also fixes `state_from_lc`, which mapped every transition state onto `Unconfigured`. A node genuinely reports `configuring` / `activating` while a callback runs; telling a lifecycle manager it had reset itself instead is wrong and was indistinguishable from the real thing. Without this the intermediate state is unobservable over the wire, so the deadlock test could not check it. Managed entities get the same collect-then-invoke treatment: `trigger_transition` called `e.on_activate()` / `e.on_deactivate()` while iterating under `managed_entities.lock()`, which `create_publisher` also takes. This is not reachable today — the only way to register an entity is `create_publisher`, so every element is a `ZLifecyclePublisher` whose activate/deactivate only flip an atomic — and it ships without a test on purpose, because a test for it would have to fabricate a registration path that does not exist, and a detector that can only fail against invented code proves nothing. The comment says exactly that so the next reader does not re-derive it. Detector evidence, both directions. Two deadline-guarded tests in `crates/hiroz-tests/tests/reentrant_lifecycle.rs`. With the fix reverted and the tests kept, both fail: transition_callback_querying_own_state_does_not_deadlock ~/get_state did not answer while the transition callback was running - the state-machine mutex was held across the callback: Timeout(8s) failing_transition_callback_still_observes_intermediate_state (same) With the fix, both pass, and `lifecycle` stays at 30/30.
1 parent 65781c0 commit de0039e

4 files changed

Lines changed: 335 additions & 32 deletions

File tree

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
//! Re-entrancy audit for lifecycle transition callbacks.
2+
//!
3+
//! `ZLifecycleNode::trigger_transition` used to invoke the user's transition
4+
//! callback from inside `self.state_machine.lock().unwrap().trigger(..)`, i.e.
5+
//! with the state-machine mutex held for the whole duration of the callback.
6+
//!
7+
//! That mutex is not private to the transition: it is shared with the node's
8+
//! own `~/get_state`, `~/change_state` and `~/get_available_transitions` service
9+
//! handlers, and with `get_current_state()`. So while a transition callback ran,
10+
//! the node could not answer any question about its own state. A callback that
11+
//! asks — directly, or by waiting on anything that asks — waits forever.
12+
//!
13+
//! The fix splits `StateMachine::trigger` into `begin` (enter the intermediate
14+
//! "busy" state) and `finish` (apply the callback's verdict), so the node drops
15+
//! the guard while the callback runs. Observers keep seeing exactly what they
16+
//! saw before: the intermediate state (`configuring`, `activating`, …).
17+
//!
18+
//! Every scenario runs on a dedicated thread behind a hard deadline, so a
19+
//! re-entrancy deadlock fails the test instead of wedging the suite — the same
20+
//! shape as `reentrant_publish.rs` and `reentrant_service.rs`.
21+
22+
mod common;
23+
24+
use std::{
25+
sync::{
26+
Arc, Mutex,
27+
atomic::{AtomicBool, Ordering},
28+
mpsc,
29+
},
30+
thread,
31+
time::Duration,
32+
};
33+
34+
use common::{TestRouter, create_hiroz_context_with_endpoint};
35+
use hiroz::{
36+
Builder,
37+
lifecycle::{CallbackReturn, LifecycleState, ZLifecycleClient},
38+
};
39+
use serial_test::serial;
40+
41+
/// Budget for one scenario. Generous relative to the work done — anything slower
42+
/// than this is a hang, not slowness.
43+
const SCENARIO_TIMEOUT: Duration = Duration::from_secs(45);
44+
45+
/// How long the in-callback service call is allowed to take. Shorter than the
46+
/// scenario budget so a blocked handler surfaces as a failed assertion rather
47+
/// than as an unhelpful whole-scenario timeout.
48+
const CALL_TIMEOUT: Duration = Duration::from_secs(8);
49+
50+
fn with_deadline(name: &'static str, scenario: impl FnOnce() + Send + 'static) {
51+
let (tx, rx) = mpsc::channel();
52+
thread::spawn(move || {
53+
scenario();
54+
let _ = tx.send(());
55+
});
56+
match rx.recv_timeout(SCENARIO_TIMEOUT) {
57+
Ok(()) => {}
58+
Err(mpsc::RecvTimeoutError::Disconnected) => {
59+
panic!("{name}: scenario panicked — see the worker thread's panic above")
60+
}
61+
Err(mpsc::RecvTimeoutError::Timeout) => {
62+
panic!("{name}: scenario did not finish within {SCENARIO_TIMEOUT:?} — deadlock")
63+
}
64+
}
65+
}
66+
67+
/// Query `~/get_state` from a thread with its own runtime and return the answer.
68+
///
69+
/// The transition callback runs on the caller's thread, which may already be
70+
/// inside a runtime, so the query gets a plain std thread of its own — the same
71+
/// pattern `reentrant_service.rs` uses for a nested service call.
72+
fn get_state_blocking(client: Arc<ZLifecycleClient>) -> zenoh::Result<LifecycleState> {
73+
thread::spawn(move || {
74+
let rt = tokio::runtime::Builder::new_current_thread()
75+
.enable_all()
76+
.build()
77+
.unwrap();
78+
rt.block_on(async { client.get_state(CALL_TIMEOUT).await })
79+
})
80+
.join()
81+
.expect("get_state worker panicked")
82+
}
83+
84+
/// A transition callback that asks its own node what state it is in.
85+
///
86+
/// The `~/get_state` handler runs on a zenoh RX thread and takes the same
87+
/// state-machine mutex the transition holds. Under the old code it blocks until
88+
/// the transition completes — but the transition cannot complete, because its
89+
/// callback is waiting for that very reply. Deadlock, broken only by the client
90+
/// timeout, which then fails the assertion.
91+
///
92+
/// Introspecting your own lifecycle state during a transition is ordinary
93+
/// (rclcpp exposes `get_current_state()` for exactly this), and a lifecycle
94+
/// manager polling `~/get_state` while a node configures is even more ordinary.
95+
#[test]
96+
#[serial]
97+
fn transition_callback_querying_own_state_does_not_deadlock() {
98+
with_deadline("lifecycle_transition_get_state", || {
99+
const NODE_NAME: &str = "lc_reentrant_get_state";
100+
101+
let router = TestRouter::new();
102+
103+
let ctx_node = create_hiroz_context_with_endpoint(router.endpoint()).expect("node ctx");
104+
let mut lc_node = ctx_node
105+
.create_lifecycle_node(NODE_NAME)
106+
.build()
107+
.expect("lifecycle node");
108+
109+
// The querying side lives in its own context, as a real lifecycle
110+
// manager would.
111+
let ctx_client = create_hiroz_context_with_endpoint(router.endpoint()).expect("client ctx");
112+
let mgr_node = ctx_client
113+
.create_node("lc_manager")
114+
.build()
115+
.expect("mgr node");
116+
117+
thread::sleep(Duration::from_millis(1000));
118+
let client =
119+
Arc::new(ZLifecycleClient::new(&mgr_node, NODE_NAME).expect("lifecycle client"));
120+
thread::sleep(Duration::from_millis(1000));
121+
122+
let ran = Arc::new(AtomicBool::new(false));
123+
let seen: Arc<Mutex<Option<zenoh::Result<LifecycleState>>>> = Arc::new(Mutex::new(None));
124+
125+
let ran_c = ran.clone();
126+
let seen_c = seen.clone();
127+
let client_c = client.clone();
128+
lc_node.on_configure = Box::new(move |_prev| {
129+
ran_c.store(true, Ordering::SeqCst);
130+
// Re-entrant query of this node's own state, from inside its own
131+
// transition callback.
132+
*seen_c.lock().unwrap() = Some(get_state_blocking(client_c.clone()));
133+
CallbackReturn::Success
134+
});
135+
136+
let final_state = lc_node.configure().expect("configure");
137+
138+
assert!(ran.load(Ordering::SeqCst), "on_configure never ran");
139+
140+
let seen = seen.lock().unwrap().take().expect("no state recorded");
141+
let seen = seen.expect(
142+
"~/get_state did not answer while the transition callback was running — \
143+
the state-machine mutex was held across the callback",
144+
);
145+
assert_eq!(
146+
seen,
147+
LifecycleState::Configuring,
148+
"the callback must observe the intermediate transition state"
149+
);
150+
assert_eq!(final_state, LifecycleState::Inactive);
151+
});
152+
}
153+
154+
/// The same hazard on the failure path: a callback that returns `Failure` must
155+
/// still have been able to introspect, and the node must still land on the
156+
/// pre-transition state.
157+
///
158+
/// This pins the ordering the split introduced: `begin` publishes the
159+
/// intermediate state, `finish` applies the verdict *after* the callback
160+
/// returns. If the two halves were reordered, the observed state here would be
161+
/// `Unconfigured` rather than `Activating`.
162+
#[test]
163+
#[serial]
164+
fn failing_transition_callback_still_observes_intermediate_state() {
165+
with_deadline("lifecycle_failing_transition_get_state", || {
166+
const NODE_NAME: &str = "lc_reentrant_fail";
167+
168+
let router = TestRouter::new();
169+
170+
let ctx_node = create_hiroz_context_with_endpoint(router.endpoint()).expect("node ctx");
171+
let mut lc_node = ctx_node
172+
.create_lifecycle_node(NODE_NAME)
173+
.build()
174+
.expect("lifecycle node");
175+
176+
let ctx_client = create_hiroz_context_with_endpoint(router.endpoint()).expect("client ctx");
177+
let mgr_node = ctx_client
178+
.create_node("lc_manager_fail")
179+
.build()
180+
.expect("mgr node");
181+
182+
thread::sleep(Duration::from_millis(1000));
183+
let client =
184+
Arc::new(ZLifecycleClient::new(&mgr_node, NODE_NAME).expect("lifecycle client"));
185+
thread::sleep(Duration::from_millis(1000));
186+
187+
// Get to Inactive first, so `activate` is a legal transition.
188+
assert_eq!(
189+
lc_node.configure().expect("configure"),
190+
LifecycleState::Inactive
191+
);
192+
193+
let seen: Arc<Mutex<Option<zenoh::Result<LifecycleState>>>> = Arc::new(Mutex::new(None));
194+
let seen_c = seen.clone();
195+
let client_c = client.clone();
196+
lc_node.on_activate = Box::new(move |_prev| {
197+
*seen_c.lock().unwrap() = Some(get_state_blocking(client_c.clone()));
198+
CallbackReturn::Failure
199+
});
200+
201+
let final_state = lc_node.activate().expect("activate");
202+
203+
let seen = seen.lock().unwrap().take().expect("no state recorded");
204+
let seen = seen.expect(
205+
"~/get_state did not answer while the transition callback was running — \
206+
the state-machine mutex was held across the callback",
207+
);
208+
assert_eq!(
209+
seen,
210+
LifecycleState::Activating,
211+
"the callback must observe the intermediate transition state"
212+
);
213+
// Failure reverts to the start state.
214+
assert_eq!(final_state, LifecycleState::Inactive);
215+
assert_eq!(lc_node.get_current_state(), LifecycleState::Inactive);
216+
});
217+
}

crates/hiroz/src/lifecycle/client.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,16 @@ fn state_from_lc(s: &LcState) -> LifecycleState {
173173
2 => LifecycleState::Inactive,
174174
3 => LifecycleState::Active,
175175
4 => LifecycleState::Finalized,
176+
// Transition ("busy") states. A node genuinely reports these while a
177+
// transition callback is running; mapping them onto `Unconfigured` told
178+
// a lifecycle manager the node had reset itself, which is both wrong and
179+
// indistinguishable from the real thing.
180+
10 => LifecycleState::Configuring,
181+
11 => LifecycleState::CleaningUp,
182+
12 => LifecycleState::ShuttingDown,
183+
13 => LifecycleState::Activating,
184+
14 => LifecycleState::Deactivating,
185+
15 => LifecycleState::ErrorProcessing,
176186
_ => LifecycleState::Unconfigured,
177187
}
178188
}

crates/hiroz/src/lifecycle/node.rs

Lines changed: 60 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -129,44 +129,80 @@ impl ZLifecycleNode {
129129
let start = self.get_current_state();
130130
debug!(node=%self.inner.entity.name, ?transition, ?start, "triggering lifecycle transition");
131131

132-
let cb_result = {
133-
let callback: &dyn Fn(State) -> CallbackReturn = match transition {
134-
TransitionId::Configure => self.on_configure.as_ref(),
135-
TransitionId::Activate => self.on_activate.as_ref(),
136-
TransitionId::Deactivate => self.on_deactivate.as_ref(),
137-
TransitionId::Cleanup => self.on_cleanup.as_ref(),
138-
TransitionId::UnconfiguredShutdown
139-
| TransitionId::InactiveShutdown
140-
| TransitionId::ActiveShutdown => self.on_shutdown.as_ref(),
141-
};
142-
self.state_machine
143-
.lock()
144-
.unwrap()
145-
.trigger(transition, callback)
132+
let callback: &dyn Fn(State) -> CallbackReturn = match transition {
133+
TransitionId::Configure => self.on_configure.as_ref(),
134+
TransitionId::Activate => self.on_activate.as_ref(),
135+
TransitionId::Deactivate => self.on_deactivate.as_ref(),
136+
TransitionId::Cleanup => self.on_cleanup.as_ref(),
137+
TransitionId::UnconfiguredShutdown
138+
| TransitionId::InactiveShutdown
139+
| TransitionId::ActiveShutdown => self.on_shutdown.as_ref(),
140+
};
141+
142+
// The transition is driven in two locked steps with the user callback in
143+
// between, *outside* the lock. `state_machine` is shared with the
144+
// `~/get_state` / `~/change_state` service handlers and with
145+
// `get_current_state`, so running the callback under the guard would
146+
// deadlock any callback that inspects its own node.
147+
//
148+
// NOTE: the `begin` call is bound to its own `let` on purpose. Writing
149+
// `match self.state_machine.lock().unwrap().begin(..) { .. }` keeps the
150+
// temporary guard alive for the whole `match` body — which silently
151+
// reintroduces exactly the deadlock this split exists to remove.
152+
let begun = self.state_machine.lock().unwrap().begin(transition);
153+
let cb_result = match begun {
154+
Some(start_state) => {
155+
// Lock released; observers see the intermediate ("busy") state.
156+
let ret = callback(start_state);
157+
self.state_machine
158+
.lock()
159+
.unwrap()
160+
.finish(transition, start_state, ret)
161+
}
162+
// Invalid transition from the current state: nothing changed.
163+
None => self.get_current_state(),
146164
};
147165

148166
let final_state = if cb_result == State::ErrorProcessing {
167+
// Same split for the error path.
168+
let ret = (self.on_error)(State::ErrorProcessing);
149169
self.state_machine
150170
.lock()
151171
.unwrap()
152-
.trigger_error_processing(|prev| (self.on_error)(prev))
172+
.finish_error_processing(ret)
153173
} else {
154174
cb_result
155175
};
156176

157-
// Bulk-activate / bulk-deactivate managed entities
158-
match (start, final_state) {
159-
(_, State::Active) if start != State::Active => {
160-
for e in self.managed_entities.lock().unwrap().iter() {
177+
// Bulk-activate / bulk-deactivate managed entities.
178+
//
179+
// Snapshot the list under the guard and notify after dropping it, the
180+
// same collect-then-invoke shape used for event callbacks. Today every
181+
// registered entity is a `ZLifecyclePublisher` created by
182+
// `create_publisher`, whose `on_activate`/`on_deactivate` only flip an
183+
// atomic and cannot re-enter — so this is preventive, not a live fix.
184+
// It stops being preventive the moment `ManagedEntity` becomes
185+
// registerable from outside this module, because arbitrary
186+
// implementations would then run under a guard that `create_publisher`
187+
// also takes.
188+
let activate = match (start, final_state) {
189+
(_, State::Active) if start != State::Active => Some(true),
190+
(State::Active, _) if final_state != State::Active => Some(false),
191+
_ => None,
192+
};
193+
if let Some(activate) = activate {
194+
// The snapshot is its own `let` statement so the guard is dropped
195+
// before the loop — writing the lock inline in the `for` header
196+
// would hold it across every notification instead.
197+
let entities: Vec<Arc<dyn ManagedEntity>> =
198+
self.managed_entities.lock().unwrap().clone();
199+
for e in entities {
200+
if activate {
161201
e.on_activate();
162-
}
163-
}
164-
(State::Active, _) if final_state != State::Active => {
165-
for e in self.managed_entities.lock().unwrap().iter() {
202+
} else {
166203
e.on_deactivate();
167204
}
168205
}
169-
_ => {}
170206
}
171207

172208
// Publish transition event

0 commit comments

Comments
 (0)