Skip to content

Commit b9043db

Browse files
committed
fix(lifecycle): settle the state machine when a callback panics
Moving the transition callback out of the state-machine lock left a worse failure mode than the deadlock it removed. `begin` enters the intermediate state and drops its guard; if the callback then panics, the unwind escapes `trigger_transition` before `finish` runs, so `current` stays at `Configuring`/`Activating`/... forever. Because the guard was already gone, the mutex is not poisoned either, so nothing reports it: every later `begin` returns `None` and `~/get_state` answers `configuring` for the life of the process. While the callback ran under the guard, the same panic unwound through a live `MutexGuard` and poisoned the mutex, so the next access failed loudly. Fail-fast had become a silent permanent wedge. Catch the unwind, settle on `Failure` — revert to the start state, the same "nothing changed" outcome as the invalid-transition arm — and re-raise the payload so the panic is still as loud as before. `on_error` is deliberately not run on that path: we are already unwinding, and running more user code on the way out risks a second panic. The `on_error` invocation gets the same guard, since a panic there would otherwise strand the node in `ErrorProcessing`.
1 parent de0039e commit b9043db

2 files changed

Lines changed: 122 additions & 4 deletions

File tree

crates/hiroz-tests/tests/reentrant_lifecycle.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,3 +215,75 @@ fn failing_transition_callback_still_observes_intermediate_state() {
215215
assert_eq!(lc_node.get_current_state(), LifecycleState::Inactive);
216216
});
217217
}
218+
219+
/// A transition callback that panics must not leave the node wedged.
220+
///
221+
/// This is the hazard the `begin`/`finish` split introduced. `begin` moves the
222+
/// state machine into the intermediate ("busy") state and releases the guard;
223+
/// the callback then runs unlocked. If it panics, the unwind used to escape
224+
/// `trigger_transition` before `finish` ran — so `current` stayed at
225+
/// `Configuring` forever. Worse, because the guard had already been dropped,
226+
/// the mutex was *not* poisoned, so nothing ever reported it: every later
227+
/// `begin` returned `None` and `~/get_state` answered `configuring` for the
228+
/// life of the process.
229+
///
230+
/// That is strictly worse than the behaviour it replaced. While the callback
231+
/// ran under the guard, a panic unwound through a live `MutexGuard` and
232+
/// poisoned the mutex, so the very next `lock().unwrap()` panicked loudly.
233+
/// Fail-fast became a silent permanent wedge.
234+
///
235+
/// The fix catches the unwind, settles the state machine on `Failure` (revert
236+
/// to the start state — "the transition did not happen"), and re-raises the
237+
/// payload, so the panic is still as loud as before while the node stays
238+
/// usable.
239+
///
240+
/// Reverting the `catch_unwind` in `ZLifecycleNode::trigger_transition` makes
241+
/// both assertions below fail: the state reads `Configuring`, and the recovery
242+
/// transition returns `Configuring` because `begin` refuses to start.
243+
#[test]
244+
#[serial]
245+
fn panicking_transition_callback_does_not_wedge_the_node() {
246+
with_deadline("lifecycle_transition_panic", || {
247+
const NODE_NAME: &str = "lc_panicking_callback";
248+
249+
let router = TestRouter::new();
250+
let ctx_node = create_hiroz_context_with_endpoint(router.endpoint()).expect("node ctx");
251+
let mut lc_node = ctx_node
252+
.create_lifecycle_node(NODE_NAME)
253+
.build()
254+
.expect("lifecycle node");
255+
256+
lc_node.on_configure = Box::new(|_prev| panic!("deliberate panic from on_configure"));
257+
258+
// The panic must still propagate — silently swallowing it would be its
259+
// own defect. Keep the default hook quiet for this one call so the
260+
// expected backtrace does not look like a test failure.
261+
let prev_hook = std::panic::take_hook();
262+
std::panic::set_hook(Box::new(|_| {}));
263+
let outcome =
264+
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| lc_node.configure()));
265+
std::panic::set_hook(prev_hook);
266+
267+
assert!(
268+
outcome.is_err(),
269+
"the panic was swallowed; it must still reach the caller"
270+
);
271+
272+
// The node must be back at its pre-transition primary state, not
273+
// stranded in the intermediate one.
274+
assert_eq!(
275+
lc_node.get_current_state(),
276+
LifecycleState::Unconfigured,
277+
"node wedged in an intermediate state after a panicking callback"
278+
);
279+
280+
// And it must still be usable: a subsequent transition has to work.
281+
lc_node.on_configure = Box::new(|_prev| CallbackReturn::Success);
282+
let recovered = lc_node.configure().expect("configure after panic");
283+
assert_eq!(
284+
recovered,
285+
LifecycleState::Inactive,
286+
"node could not transition after a panicking callback"
287+
);
288+
});
289+
}

crates/hiroz/src/lifecycle/node.rs

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use std::sync::{Arc, Mutex};
1+
use std::{
2+
panic::{AssertUnwindSafe, catch_unwind, resume_unwind},
3+
sync::{Arc, Mutex},
4+
};
25

36
use tracing::{debug, info, warn};
47
use zenoh::{Result, Wait, query::Query};
@@ -153,7 +156,38 @@ impl ZLifecycleNode {
153156
let cb_result = match begun {
154157
Some(start_state) => {
155158
// Lock released; observers see the intermediate ("busy") state.
156-
let ret = callback(start_state);
159+
//
160+
// A panic here must not escape without settling the state
161+
// machine. `begin` has already moved `current` to the
162+
// intermediate state, and the guard it took is gone — so an
163+
// unwind straight out of this function would leave `current`
164+
// at `Configuring`/`Activating`/... permanently, *and* leave
165+
// the mutex unpoisoned, so nothing would ever report it. Every
166+
// later `begin` returns `None` and `~/get_state` answers
167+
// `configuring` for the life of the process: a silent wedge.
168+
// Before the callback was moved out of the lock, the unwind
169+
// passed through a live guard and poisoned the mutex, so the
170+
// next access failed loudly.
171+
//
172+
// Settle on `Failure`, which reverts to `start_state` — the
173+
// same "nothing changed" outcome as the invalid-transition arm
174+
// below, and the only result that is both well-defined and
175+
// leaves the node usable. `on_error` is deliberately not run:
176+
// we are already unwinding, and running more user code on the
177+
// way out would risk a second panic. The payload is then
178+
// re-raised, so a panicking callback still fails exactly as
179+
// loudly as it did before the split.
180+
let ret = match catch_unwind(AssertUnwindSafe(|| callback(start_state))) {
181+
Ok(ret) => ret,
182+
Err(payload) => {
183+
self.state_machine.lock().unwrap().finish(
184+
transition,
185+
start_state,
186+
CallbackReturn::Failure,
187+
);
188+
resume_unwind(payload);
189+
}
190+
};
157191
self.state_machine
158192
.lock()
159193
.unwrap()
@@ -164,8 +198,20 @@ impl ZLifecycleNode {
164198
};
165199

166200
let final_state = if cb_result == State::ErrorProcessing {
167-
// Same split for the error path.
168-
let ret = (self.on_error)(State::ErrorProcessing);
201+
// Same split, and the same unwind guard, for the error path: a
202+
// panic in `on_error` would otherwise strand the node in
203+
// `ErrorProcessing` with no way out.
204+
let cb = AssertUnwindSafe(|| (self.on_error)(State::ErrorProcessing));
205+
let ret = match catch_unwind(cb) {
206+
Ok(ret) => ret,
207+
Err(payload) => {
208+
self.state_machine
209+
.lock()
210+
.unwrap()
211+
.finish_error_processing(CallbackReturn::Error);
212+
resume_unwind(payload);
213+
}
214+
};
169215
self.state_machine
170216
.lock()
171217
.unwrap()

0 commit comments

Comments
 (0)