Skip to main content

nros_node/
lifecycle.rs

1//! Lifecycle node API (REP-2002)
2//!
3//! Provides managed lifecycle state machines for nros nodes.
4//!
5//! - [`LifecyclePollingNode`] — standalone state machine with plain function pointers (`no_std`)
6//! - [`LifecyclePollingNodeCtx`] — standalone state machine with `unsafe fn(*mut c_void) -> TransitionResult`
7//!   callbacks, for bridging the C FFI (`no_std`)
8
9use core::ffi::c_void;
10use nros_core::lifecycle::{
11    LifecycleState, LifecycleTransition, TransitionResult, apply_transition, can_transition,
12};
13
14/// Error type for lifecycle transitions.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum LifecycleError {
17    /// The requested transition is not valid from the current state.
18    InvalidTransition {
19        /// The state the node was in when the transition was attempted.
20        from: LifecycleState,
21        /// The transition that was requested.
22        transition: LifecycleTransition,
23    },
24    /// The transition callback returned a non-success result.
25    CallbackFailed {
26        /// The transition that was attempted.
27        transition: LifecycleTransition,
28        /// The result returned by the callback.
29        result: TransitionResult,
30    },
31    /// The node is in the Finalized state and cannot transition.
32    NodeFinalized,
33}
34
35// ═══════════════════════════════════════════════════════════════════════════
36// LIFECYCLE POLLING NODE (no_std — function pointers, no NodeHandle)
37// ═══════════════════════════════════════════════════════════════════════════
38
39/// Lifecycle callback function pointer (`no_std` compatible).
40pub type LifecycleCallbackFn = fn() -> TransitionResult;
41
42/// Standalone lifecycle state machine for `no_std` environments.
43///
44/// Uses function pointers instead of boxed closures. Does not wrap a
45/// `NodeHandle` — the user manages the node separately.
46///
47/// # Example
48///
49/// ```ignore
50/// fn on_configure() -> TransitionResult {
51///     // Initialize hardware...
52///     TransitionResult::Success
53/// }
54///
55/// let mut lifecycle = LifecyclePollingNode::new();
56/// lifecycle.register_on_configure(on_configure);
57/// lifecycle.configure()?;
58/// ```
59pub struct LifecyclePollingNode {
60    state: LifecycleState,
61    on_configure: Option<LifecycleCallbackFn>,
62    on_activate: Option<LifecycleCallbackFn>,
63    on_deactivate: Option<LifecycleCallbackFn>,
64    on_cleanup: Option<LifecycleCallbackFn>,
65    on_shutdown: Option<LifecycleCallbackFn>,
66    on_error: Option<LifecycleCallbackFn>,
67}
68
69impl LifecyclePollingNode {
70    /// Create a new standalone lifecycle state machine.
71    ///
72    /// Starts in the `Unconfigured` state.
73    pub const fn new() -> Self {
74        Self {
75            state: LifecycleState::Unconfigured,
76            on_configure: None,
77            on_activate: None,
78            on_deactivate: None,
79            on_cleanup: None,
80            on_shutdown: None,
81            on_error: None,
82        }
83    }
84
85    /// Get the current lifecycle state.
86    pub const fn state(&self) -> LifecycleState {
87        self.state
88    }
89
90    /// Trigger a lifecycle transition.
91    pub fn trigger_transition(
92        &mut self,
93        transition: LifecycleTransition,
94    ) -> Result<LifecycleState, LifecycleError> {
95        if self.state.is_terminal() {
96            return Err(LifecycleError::NodeFinalized);
97        }
98
99        if !can_transition(self.state, transition) {
100            return Err(LifecycleError::InvalidTransition {
101                from: self.state,
102                transition,
103            });
104        }
105
106        let result = self.invoke_callback(transition);
107        self.state = apply_transition(self.state, transition, result);
108
109        if result == TransitionResult::Success {
110            Ok(self.state)
111        } else {
112            Err(LifecycleError::CallbackFailed { transition, result })
113        }
114    }
115
116    /// Convenience: configure (Unconfigured -> Inactive)
117    pub fn configure(&mut self) -> Result<LifecycleState, LifecycleError> {
118        self.trigger_transition(LifecycleTransition::Configure)
119    }
120
121    /// Convenience: activate (Inactive -> Active)
122    pub fn activate(&mut self) -> Result<LifecycleState, LifecycleError> {
123        self.trigger_transition(LifecycleTransition::Activate)
124    }
125
126    /// Convenience: deactivate (Active -> Inactive)
127    pub fn deactivate(&mut self) -> Result<LifecycleState, LifecycleError> {
128        self.trigger_transition(LifecycleTransition::Deactivate)
129    }
130
131    /// Convenience: cleanup (Inactive -> Unconfigured)
132    pub fn cleanup(&mut self) -> Result<LifecycleState, LifecycleError> {
133        self.trigger_transition(LifecycleTransition::Cleanup)
134    }
135
136    /// Convenience: shutdown from the current state.
137    pub fn shutdown(&mut self) -> Result<LifecycleState, LifecycleError> {
138        let transition = match self.state {
139            LifecycleState::Unconfigured => LifecycleTransition::ShutdownUnconfigured,
140            LifecycleState::Inactive => LifecycleTransition::ShutdownInactive,
141            LifecycleState::Active => LifecycleTransition::ShutdownActive,
142            LifecycleState::Finalized => return Err(LifecycleError::NodeFinalized),
143            LifecycleState::ErrorProcessing => {
144                return Err(LifecycleError::InvalidTransition {
145                    from: self.state,
146                    transition: LifecycleTransition::ShutdownUnconfigured,
147                });
148            }
149        };
150        self.trigger_transition(transition)
151    }
152
153    /// Convenience: configure then activate (stops on failure).
154    pub fn bring_up(&mut self) -> Result<LifecycleState, LifecycleError> {
155        self.configure()?;
156        self.activate()
157    }
158
159    /// Register a callback for the `configure` transition.
160    pub fn register_on_configure(&mut self, cb: LifecycleCallbackFn) {
161        self.on_configure = Some(cb);
162    }
163
164    /// Register a callback for the `activate` transition.
165    pub fn register_on_activate(&mut self, cb: LifecycleCallbackFn) {
166        self.on_activate = Some(cb);
167    }
168
169    /// Register a callback for the `deactivate` transition.
170    pub fn register_on_deactivate(&mut self, cb: LifecycleCallbackFn) {
171        self.on_deactivate = Some(cb);
172    }
173
174    /// Register a callback for the `cleanup` transition.
175    pub fn register_on_cleanup(&mut self, cb: LifecycleCallbackFn) {
176        self.on_cleanup = Some(cb);
177    }
178
179    /// Register a callback for the `shutdown` transition.
180    pub fn register_on_shutdown(&mut self, cb: LifecycleCallbackFn) {
181        self.on_shutdown = Some(cb);
182    }
183
184    /// Register a callback for the `error` transition (error recovery).
185    pub fn register_on_error(&mut self, cb: LifecycleCallbackFn) {
186        self.on_error = Some(cb);
187    }
188
189    fn invoke_callback(&mut self, transition: LifecycleTransition) -> TransitionResult {
190        let cb = match transition {
191            LifecycleTransition::Configure => self.on_configure,
192            LifecycleTransition::Activate => self.on_activate,
193            LifecycleTransition::Deactivate => self.on_deactivate,
194            LifecycleTransition::Cleanup => self.on_cleanup,
195            LifecycleTransition::ShutdownUnconfigured
196            | LifecycleTransition::ShutdownInactive
197            | LifecycleTransition::ShutdownActive => self.on_shutdown,
198            LifecycleTransition::ErrorRecovery => self.on_error,
199        };
200
201        match cb {
202            Some(f) => f(),
203            None => TransitionResult::Success,
204        }
205    }
206}
207
208impl Default for LifecyclePollingNode {
209    fn default() -> Self {
210        Self::new()
211    }
212}
213
214// ═══════════════════════════════════════════════════════════════════════════
215// LIFECYCLE POLLING NODE WITH CONTEXT (no_std — C FFI compatible)
216// ═══════════════════════════════════════════════════════════════════════════
217
218/// Lifecycle callback taking a user context pointer (`no_std`, C FFI shape).
219///
220/// Returns a `u8` matching the C `NROS_LIFECYCLE_RET_*` constants
221/// (`0 = Success`, `1 = Failure`, `2 = Error`). Any unknown value is
222/// coerced to [`TransitionResult::Error`] inside
223/// [`LifecyclePollingNodeCtx::trigger_transition`].
224pub type LifecycleCallbackFnCtx = unsafe extern "C" fn(ctx: *mut c_void) -> u8;
225
226/// Lifecycle state machine with `unsafe fn(*mut c_void) -> TransitionResult` callbacks.
227///
228/// Thin counterpart to [`LifecyclePollingNode`] for bridging the C FFI: each
229/// callback slot stores a pointer to `extern "C"` user code plus a single
230/// shared `*mut c_void` context that is passed on every invocation. The core
231/// state machine logic comes from [`nros_core::lifecycle`], same as
232/// `LifecyclePollingNode`.
233pub struct LifecyclePollingNodeCtx {
234    state: LifecycleState,
235    on_configure: Option<LifecycleCallbackFnCtx>,
236    on_activate: Option<LifecycleCallbackFnCtx>,
237    on_deactivate: Option<LifecycleCallbackFnCtx>,
238    on_cleanup: Option<LifecycleCallbackFnCtx>,
239    on_shutdown: Option<LifecycleCallbackFnCtx>,
240    on_error: Option<LifecycleCallbackFnCtx>,
241    context: *mut c_void,
242}
243
244// `*mut c_void` is `!Sync` + `!Send`; that's the correct posture for a
245// state machine owned by one task. No auto-impl needed.
246
247impl LifecyclePollingNodeCtx {
248    /// Create a new standalone lifecycle state machine. Starts in `Unconfigured`.
249    pub const fn new() -> Self {
250        Self {
251            state: LifecycleState::Unconfigured,
252            on_configure: None,
253            on_activate: None,
254            on_deactivate: None,
255            on_cleanup: None,
256            on_shutdown: None,
257            on_error: None,
258            context: core::ptr::null_mut(),
259        }
260    }
261
262    /// Get the current lifecycle state.
263    pub const fn state(&self) -> LifecycleState {
264        self.state
265    }
266
267    /// Set the user context pointer passed to every callback.
268    pub fn set_context(&mut self, ctx: *mut c_void) {
269        self.context = ctx;
270    }
271
272    /// Get the user context pointer.
273    pub fn context(&self) -> *mut c_void {
274        self.context
275    }
276
277    /// Register / clear the callback for a given transition slot.
278    pub fn register(&mut self, slot: LifecycleCallbackSlot, cb: Option<LifecycleCallbackFnCtx>) {
279        match slot {
280            LifecycleCallbackSlot::Configure => self.on_configure = cb,
281            LifecycleCallbackSlot::Activate => self.on_activate = cb,
282            LifecycleCallbackSlot::Deactivate => self.on_deactivate = cb,
283            LifecycleCallbackSlot::Cleanup => self.on_cleanup = cb,
284            LifecycleCallbackSlot::Shutdown => self.on_shutdown = cb,
285            LifecycleCallbackSlot::Error => self.on_error = cb,
286        }
287    }
288
289    /// Clear every registered callback. Used on fini.
290    pub fn clear_callbacks(&mut self) {
291        self.on_configure = None;
292        self.on_activate = None;
293        self.on_deactivate = None;
294        self.on_cleanup = None;
295        self.on_shutdown = None;
296        self.on_error = None;
297        self.context = core::ptr::null_mut();
298    }
299
300    /// Force the state to `Finalized`. Used on fini.
301    pub fn finalize(&mut self) {
302        self.state = LifecycleState::Finalized;
303    }
304
305    /// Trigger a lifecycle transition.
306    ///
307    /// # Safety
308    /// The registered callback (if any) is called via a raw `unsafe fn` pointer
309    /// with the stored `*mut c_void` context. The caller must guarantee that
310    /// any registered callback / context pair remains valid.
311    pub unsafe fn trigger_transition(
312        &mut self,
313        transition: LifecycleTransition,
314    ) -> Result<LifecycleState, LifecycleError> {
315        if self.state.is_terminal() {
316            return Err(LifecycleError::NodeFinalized);
317        }
318
319        if !can_transition(self.state, transition) {
320            return Err(LifecycleError::InvalidTransition {
321                from: self.state,
322                transition,
323            });
324        }
325
326        let cb = match transition {
327            LifecycleTransition::Configure => self.on_configure,
328            LifecycleTransition::Activate => self.on_activate,
329            LifecycleTransition::Deactivate => self.on_deactivate,
330            LifecycleTransition::Cleanup => self.on_cleanup,
331            LifecycleTransition::ShutdownUnconfigured
332            | LifecycleTransition::ShutdownInactive
333            | LifecycleTransition::ShutdownActive => self.on_shutdown,
334            LifecycleTransition::ErrorRecovery => self.on_error,
335        };
336
337        let result = match cb {
338            Some(f) => {
339                let raw = unsafe { f(self.context) };
340                TransitionResult::from_u8(raw).unwrap_or(TransitionResult::Error)
341            }
342            None => TransitionResult::Success,
343        };
344
345        self.state = apply_transition(self.state, transition, result);
346
347        if result == TransitionResult::Success {
348            Ok(self.state)
349        } else {
350            Err(LifecycleError::CallbackFailed { transition, result })
351        }
352    }
353}
354
355impl Default for LifecyclePollingNodeCtx {
356    fn default() -> Self {
357        Self::new()
358    }
359}
360
361/// Which transition callback slot to register in [`LifecyclePollingNodeCtx::register`].
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub enum LifecycleCallbackSlot {
364    /// `Unconfigured -> Inactive`
365    Configure,
366    /// `Inactive -> Active`
367    Activate,
368    /// `Active -> Inactive`
369    Deactivate,
370    /// `Inactive -> Unconfigured`
371    Cleanup,
372    /// any state -> `Finalized`
373    Shutdown,
374    /// `ErrorProcessing -> Unconfigured`
375    Error,
376}
377
378// ═══════════════════════════════════════════════════════════════════════════
379// SAFE LIFECYCLE-CALLBACK TRAIT (issue 0335 / phase-317)
380// ═══════════════════════════════════════════════════════════════════════════
381
382/// Safe lifecycle-callback surface, symmetric with the C++ `nros::LifecycleNode`
383/// (rclcpp `LifecycleNodeInterface`). Implement the transitions you need; the
384/// rest default to `Success` (`on_error` to `Failure`), matching rclcpp's
385/// non-pure-virtual defaults. Register the node with
386/// [`Executor::register_lifecycle_node`](crate::Executor::register_lifecycle_node),
387/// which binds the five REP-2002 services and wires each transition here — no
388/// `unsafe` in user code.
389///
390/// # Example
391/// ```ignore
392/// struct MyNode { configured: bool }
393/// impl LifecycleCallbacks for MyNode {
394///     fn on_configure(&mut self) -> TransitionResult {
395///         self.configured = true;
396///         TransitionResult::Success
397///     }
398/// }
399/// executor.register_lifecycle_node(&mut my_node)?;
400/// ```
401///
402/// Unlike rclcpp's `on_*(const State& previous)`, the callbacks take no
403/// `previous` argument: the FFI callback boundary ([`LifecycleCallbackFnCtx`])
404/// carries only the user context, and the [`LifecyclePollingNode`] fn-pointer
405/// API is likewise state-less. Read the current state via
406/// [`Executor::lifecycle_state_machine`](crate::Executor::lifecycle_state_machine)
407/// `.state()` if a transition needs it.
408pub trait LifecycleCallbacks {
409    /// `Unconfigured -> Inactive`. Default: `Success`.
410    fn on_configure(&mut self) -> TransitionResult {
411        TransitionResult::Success
412    }
413    /// `Inactive -> Active`. Default: `Success`.
414    fn on_activate(&mut self) -> TransitionResult {
415        TransitionResult::Success
416    }
417    /// `Active -> Inactive`. Default: `Success`.
418    fn on_deactivate(&mut self) -> TransitionResult {
419        TransitionResult::Success
420    }
421    /// `Inactive -> Unconfigured`. Default: `Success`.
422    fn on_cleanup(&mut self) -> TransitionResult {
423        TransitionResult::Success
424    }
425    /// any state `-> Finalized`. Default: `Success`.
426    fn on_shutdown(&mut self) -> TransitionResult {
427        TransitionResult::Success
428    }
429    /// `ErrorProcessing -> Unconfigured`. Default: `Failure` (matching rclcpp).
430    fn on_error(&mut self) -> TransitionResult {
431        TransitionResult::Failure
432    }
433}
434
435/// Monomorphized `extern "C"` trampolines that recover `&mut T` from the FFI
436/// context pointer and dispatch to the [`LifecycleCallbacks`] method. rustc
437/// emits one per `T`, so there is no closure box — `no_std`-safe. Registered by
438/// [`Executor::register_lifecycle_node`](crate::Executor::register_lifecycle_node);
439/// not meant to be called directly.
440pub mod trampolines {
441    use super::{LifecycleCallbacks, TransitionResult};
442    use core::ffi::c_void;
443
444    macro_rules! trampoline {
445        ($name:ident, $method:ident) => {
446            /// # Safety
447            /// `ctx` must be a `*mut T` that outlives the registration and is not
448            /// aliased for the duration of the call (the executor spins
449            /// single-threaded, so no concurrent `&mut T` exists).
450            pub unsafe extern "C" fn $name<T: LifecycleCallbacks>(ctx: *mut c_void) -> u8 {
451                if ctx.is_null() {
452                    return TransitionResult::Error as u8;
453                }
454                // SAFETY: caller (`register_lifecycle_node`) set `ctx` to a live
455                // `&mut T`; the spin loop invokes this synchronously, so the
456                // reference is unique for the call.
457                let node = unsafe { &mut *(ctx as *mut T) };
458                node.$method() as u8
459            }
460        };
461    }
462
463    trampoline!(on_configure, on_configure);
464    trampoline!(on_activate, on_activate);
465    trampoline!(on_deactivate, on_deactivate);
466    trampoline!(on_cleanup, on_cleanup);
467    trampoline!(on_shutdown, on_shutdown);
468    trampoline!(on_error, on_error);
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    // ═══════════════════════════════════════════════════════════════════════
476    // Safe LifecycleCallbacks trait (issue 0335 / phase-317)
477    // ═══════════════════════════════════════════════════════════════════════
478
479    struct DemoNode {
480        configured: bool,
481    }
482    impl LifecycleCallbacks for DemoNode {
483        fn on_configure(&mut self) -> TransitionResult {
484            self.configured = true;
485            TransitionResult::Success
486        }
487        fn on_error(&mut self) -> TransitionResult {
488            TransitionResult::Error
489        }
490        // on_activate / on_deactivate / on_cleanup / on_shutdown use the defaults.
491    }
492
493    #[test]
494    fn trampoline_dispatches_to_trait_impl_and_defaults() {
495        let mut n = DemoNode { configured: false };
496        let ctx = &mut n as *mut DemoNode as *mut c_void;
497
498        // Overridden method runs and mutates through the recovered &mut.
499        let rc = unsafe { trampolines::on_configure::<DemoNode>(ctx) };
500        assert_eq!(rc, TransitionResult::Success as u8);
501        assert!(n.configured);
502
503        // Defaulted method returns Success without an override.
504        let rc = unsafe { trampolines::on_activate::<DemoNode>(&mut n as *mut _ as *mut c_void) };
505        assert_eq!(rc, TransitionResult::Success as u8);
506
507        // Overridden on_error returns Error.
508        let rc = unsafe { trampolines::on_error::<DemoNode>(&mut n as *mut _ as *mut c_void) };
509        assert_eq!(rc, TransitionResult::Error as u8);
510
511        // Null ctx is Error, never a deref.
512        let rc = unsafe { trampolines::on_configure::<DemoNode>(core::ptr::null_mut()) };
513        assert_eq!(rc, TransitionResult::Error as u8);
514    }
515
516    #[test]
517    fn ctx_state_machine_drives_trait_through_a_transition() {
518        // The seam `register_lifecycle_node` builds on: set_context + register a
519        // monomorphized trampoline, then a transition dispatches to the trait.
520        let mut n = DemoNode { configured: false };
521        let mut sm = LifecyclePollingNodeCtx::new();
522        sm.set_context(&mut n as *mut DemoNode as *mut c_void);
523        sm.register(
524            LifecycleCallbackSlot::Configure,
525            Some(trampolines::on_configure::<DemoNode>),
526        );
527
528        // SAFETY: `n` outlives `sm`; the transition runs synchronously here.
529        let new_state =
530            unsafe { sm.trigger_transition(LifecycleTransition::Configure) }.expect("configure");
531
532        assert!(n.configured, "the trait's on_configure ran");
533        assert_eq!(new_state, LifecycleState::Inactive);
534    }
535
536    #[test]
537    fn raw_extern_c_callbacks_drive_the_ctx_state_machine() {
538        // Issue 0335 — the raw-FFI shape the `native/rust/lifecycle-node` example
539        // used to demonstrate (a user hand-writing `extern "C"` callbacks against
540        // the C-parity seam). Relocated here as automated coverage so the example
541        // can show the safe `LifecycleCallbacks` trait instead of an FFI test.
542        unsafe extern "C" fn cb_success(_ctx: *mut c_void) -> u8 {
543            TransitionResult::Success as u8
544        }
545        let mut sm = LifecyclePollingNodeCtx::new();
546        sm.register(LifecycleCallbackSlot::Configure, Some(cb_success));
547        sm.register(LifecycleCallbackSlot::Activate, Some(cb_success));
548
549        // SAFETY: the callbacks ignore the (null) context.
550        let s =
551            unsafe { sm.trigger_transition(LifecycleTransition::Configure) }.expect("configure");
552        assert_eq!(s, LifecycleState::Inactive);
553        let s = unsafe { sm.trigger_transition(LifecycleTransition::Activate) }.expect("activate");
554        assert_eq!(s, LifecycleState::Active);
555    }
556
557    // ═══════════════════════════════════════════════════════════════════════
558    // LifecyclePollingNode tests (no_std, always available)
559    // ═══════════════════════════════════════════════════════════════════════
560
561    #[test]
562    fn test_polling_node_initial_state() {
563        let node = LifecyclePollingNode::new();
564        assert_eq!(node.state(), LifecycleState::Unconfigured);
565    }
566
567    #[test]
568    fn test_polling_node_default() {
569        let node = LifecyclePollingNode::default();
570        assert_eq!(node.state(), LifecycleState::Unconfigured);
571    }
572
573    #[test]
574    fn test_polling_node_happy_path() {
575        let mut node = LifecyclePollingNode::new();
576
577        assert_eq!(node.configure().unwrap(), LifecycleState::Inactive);
578        assert_eq!(node.activate().unwrap(), LifecycleState::Active);
579        assert_eq!(node.deactivate().unwrap(), LifecycleState::Inactive);
580        assert_eq!(node.shutdown().unwrap(), LifecycleState::Finalized);
581    }
582
583    #[test]
584    fn test_polling_node_cleanup_cycle() {
585        let mut node = LifecyclePollingNode::new();
586
587        node.configure().unwrap();
588        assert_eq!(node.cleanup().unwrap(), LifecycleState::Unconfigured);
589
590        // Can configure again
591        assert_eq!(node.configure().unwrap(), LifecycleState::Inactive);
592    }
593
594    #[test]
595    fn test_polling_node_invalid_transition() {
596        let mut node = LifecyclePollingNode::new();
597
598        let err = node.activate().unwrap_err();
599        assert_eq!(
600            err,
601            LifecycleError::InvalidTransition {
602                from: LifecycleState::Unconfigured,
603                transition: LifecycleTransition::Activate,
604            }
605        );
606    }
607
608    #[test]
609    fn test_polling_node_finalized_rejection() {
610        let mut node = LifecyclePollingNode::new();
611        node.shutdown().unwrap();
612
613        assert_eq!(node.configure().unwrap_err(), LifecycleError::NodeFinalized);
614        assert_eq!(node.shutdown().unwrap_err(), LifecycleError::NodeFinalized);
615    }
616
617    fn on_configure_success() -> TransitionResult {
618        TransitionResult::Success
619    }
620
621    fn on_configure_failure() -> TransitionResult {
622        TransitionResult::Failure
623    }
624
625    fn on_configure_error() -> TransitionResult {
626        TransitionResult::Error
627    }
628
629    #[test]
630    fn test_polling_node_callback_success() {
631        let mut node = LifecyclePollingNode::new();
632        node.register_on_configure(on_configure_success);
633
634        assert_eq!(node.configure().unwrap(), LifecycleState::Inactive);
635    }
636
637    #[test]
638    fn test_polling_node_callback_failure_rollback() {
639        let mut node = LifecyclePollingNode::new();
640        node.register_on_configure(on_configure_failure);
641
642        let err = node.configure().unwrap_err();
643        assert_eq!(
644            err,
645            LifecycleError::CallbackFailed {
646                transition: LifecycleTransition::Configure,
647                result: TransitionResult::Failure,
648            }
649        );
650        // State rolled back to Unconfigured
651        assert_eq!(node.state(), LifecycleState::Unconfigured);
652    }
653
654    #[test]
655    fn test_polling_node_callback_error() {
656        let mut node = LifecyclePollingNode::new();
657        node.register_on_configure(on_configure_error);
658
659        let err = node.configure().unwrap_err();
660        assert_eq!(
661            err,
662            LifecycleError::CallbackFailed {
663                transition: LifecycleTransition::Configure,
664                result: TransitionResult::Error,
665            }
666        );
667        // State moved to ErrorProcessing
668        assert_eq!(node.state(), LifecycleState::ErrorProcessing);
669    }
670
671    #[test]
672    fn test_polling_node_error_recovery() {
673        let mut node = LifecyclePollingNode::new();
674        node.register_on_configure(on_configure_error);
675
676        let _ = node.configure();
677        assert_eq!(node.state(), LifecycleState::ErrorProcessing);
678
679        // Cannot shutdown from error processing
680        assert!(node.shutdown().is_err());
681
682        // Can recover
683        node.register_on_error(on_configure_success);
684        let result = node.trigger_transition(LifecycleTransition::ErrorRecovery);
685        assert_eq!(result.unwrap(), LifecycleState::Unconfigured);
686    }
687
688    #[test]
689    fn test_polling_node_bring_up() {
690        let mut node = LifecyclePollingNode::new();
691        assert_eq!(node.bring_up().unwrap(), LifecycleState::Active);
692    }
693
694    #[test]
695    fn test_polling_node_bring_up_stops_on_configure_failure() {
696        let mut node = LifecyclePollingNode::new();
697        node.register_on_configure(on_configure_failure);
698
699        let err = node.bring_up().unwrap_err();
700        assert_eq!(
701            err,
702            LifecycleError::CallbackFailed {
703                transition: LifecycleTransition::Configure,
704                result: TransitionResult::Failure,
705            }
706        );
707        // State is still Unconfigured, activate was never attempted
708        assert_eq!(node.state(), LifecycleState::Unconfigured);
709    }
710
711    #[test]
712    fn test_polling_node_shutdown_from_each_state() {
713        // From Unconfigured
714        let mut node = LifecyclePollingNode::new();
715        assert_eq!(node.shutdown().unwrap(), LifecycleState::Finalized);
716
717        // From Inactive
718        let mut node = LifecyclePollingNode::new();
719        node.configure().unwrap();
720        assert_eq!(node.shutdown().unwrap(), LifecycleState::Finalized);
721
722        // From Active
723        let mut node = LifecyclePollingNode::new();
724        node.bring_up().unwrap();
725        assert_eq!(node.shutdown().unwrap(), LifecycleState::Finalized);
726    }
727
728    #[test]
729    fn test_polling_node_no_callback_defaults_success() {
730        // Without any callbacks registered, transitions should succeed
731        let mut node = LifecyclePollingNode::new();
732        assert_eq!(node.configure().unwrap(), LifecycleState::Inactive);
733        assert_eq!(node.activate().unwrap(), LifecycleState::Active);
734        assert_eq!(node.deactivate().unwrap(), LifecycleState::Inactive);
735        assert_eq!(node.cleanup().unwrap(), LifecycleState::Unconfigured);
736        assert_eq!(node.shutdown().unwrap(), LifecycleState::Finalized);
737    }
738
739    fn on_shutdown_success() -> TransitionResult {
740        TransitionResult::Success
741    }
742
743    #[test]
744    fn test_polling_node_shutdown_callback_invoked() {
745        let mut node = LifecyclePollingNode::new();
746        node.register_on_shutdown(on_shutdown_success);
747
748        // Shutdown from Unconfigured should invoke the shutdown callback
749        assert_eq!(node.shutdown().unwrap(), LifecycleState::Finalized);
750    }
751
752    // ═══════════════════════════════════════════════════════════════════════
753    // LifecyclePollingNodeCtx tests (C FFI shape)
754    // ═══════════════════════════════════════════════════════════════════════
755
756    unsafe extern "C" fn ctx_cb_success(_: *mut c_void) -> u8 {
757        TransitionResult::Success as u8
758    }
759    unsafe extern "C" fn ctx_cb_failure(_: *mut c_void) -> u8 {
760        TransitionResult::Failure as u8
761    }
762    unsafe extern "C" fn ctx_cb_error(_: *mut c_void) -> u8 {
763        TransitionResult::Error as u8
764    }
765
766    #[test]
767    fn test_ctx_node_happy_path() {
768        unsafe {
769            let mut node = LifecyclePollingNodeCtx::new();
770            node.register(LifecycleCallbackSlot::Configure, Some(ctx_cb_success));
771            node.register(LifecycleCallbackSlot::Activate, Some(ctx_cb_success));
772            node.register(LifecycleCallbackSlot::Deactivate, Some(ctx_cb_success));
773            node.register(LifecycleCallbackSlot::Shutdown, Some(ctx_cb_success));
774
775            assert_eq!(
776                node.trigger_transition(LifecycleTransition::Configure)
777                    .unwrap(),
778                LifecycleState::Inactive
779            );
780            assert_eq!(
781                node.trigger_transition(LifecycleTransition::Activate)
782                    .unwrap(),
783                LifecycleState::Active
784            );
785            assert_eq!(
786                node.trigger_transition(LifecycleTransition::Deactivate)
787                    .unwrap(),
788                LifecycleState::Inactive
789            );
790            assert_eq!(
791                node.trigger_transition(LifecycleTransition::ShutdownInactive)
792                    .unwrap(),
793                LifecycleState::Finalized
794            );
795        }
796    }
797
798    #[test]
799    fn test_ctx_node_invalid_transition() {
800        unsafe {
801            let mut node = LifecyclePollingNodeCtx::new();
802            let err = node
803                .trigger_transition(LifecycleTransition::Activate)
804                .unwrap_err();
805            assert_eq!(
806                err,
807                LifecycleError::InvalidTransition {
808                    from: LifecycleState::Unconfigured,
809                    transition: LifecycleTransition::Activate,
810                }
811            );
812            assert_eq!(node.state(), LifecycleState::Unconfigured);
813        }
814    }
815
816    #[test]
817    fn test_ctx_node_callback_failure_rolls_back() {
818        unsafe {
819            let mut node = LifecyclePollingNodeCtx::new();
820            node.register(LifecycleCallbackSlot::Configure, Some(ctx_cb_failure));
821            assert!(
822                node.trigger_transition(LifecycleTransition::Configure)
823                    .is_err()
824            );
825            assert_eq!(node.state(), LifecycleState::Unconfigured);
826        }
827    }
828
829    #[test]
830    fn test_ctx_node_callback_error_enters_error_processing() {
831        unsafe {
832            let mut node = LifecyclePollingNodeCtx::new();
833            node.register(LifecycleCallbackSlot::Configure, Some(ctx_cb_error));
834            assert!(
835                node.trigger_transition(LifecycleTransition::Configure)
836                    .is_err()
837            );
838            assert_eq!(node.state(), LifecycleState::ErrorProcessing);
839        }
840    }
841
842    #[test]
843    fn test_ctx_node_finalized_rejects() {
844        unsafe {
845            let mut node = LifecyclePollingNodeCtx::new();
846            node.finalize();
847            let err = node
848                .trigger_transition(LifecycleTransition::Configure)
849                .unwrap_err();
850            assert_eq!(err, LifecycleError::NodeFinalized);
851        }
852    }
853
854    #[test]
855    fn test_ctx_node_context_passed() {
856        use core::sync::atomic::{AtomicU32, Ordering};
857        static SEEN: AtomicU32 = AtomicU32::new(0);
858        unsafe extern "C" fn cb_record(ctx: *mut c_void) -> u8 {
859            SEEN.store(ctx as usize as u32, Ordering::Relaxed);
860            TransitionResult::Success as u8
861        }
862
863        unsafe {
864            let mut node = LifecyclePollingNodeCtx::new();
865            node.set_context(0xBEEFu32 as usize as *mut c_void);
866            node.register(LifecycleCallbackSlot::Configure, Some(cb_record));
867            let _ = node.trigger_transition(LifecycleTransition::Configure);
868            assert_eq!(SEEN.load(Ordering::Relaxed), 0xBEEF);
869        }
870    }
871
872    #[test]
873    fn test_ctx_node_clear_callbacks_resets() {
874        unsafe {
875            let mut node = LifecyclePollingNodeCtx::new();
876            node.set_context(core::ptr::dangling_mut::<c_void>());
877            node.register(LifecycleCallbackSlot::Configure, Some(ctx_cb_success));
878            node.clear_callbacks();
879            assert!(node.context().is_null());
880            // With no callback, transition still succeeds (default = Success).
881            assert_eq!(
882                node.trigger_transition(LifecycleTransition::Configure)
883                    .unwrap(),
884                LifecycleState::Inactive
885            );
886        }
887    }
888}