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/// One transition this state machine has performed and not yet ANNOUNCED on
227/// `~/transition_event` (REP-2002's communication interface).
228///
229/// Crate-visible: this is the MECHANISM between the state machine and the
230/// executor that owns the publisher, not a surface a user drives. A node that
231/// wants to know the state asks [`LifecyclePollingNodeCtx::state`]; a node
232/// that wants to watch another node's transitions subscribes to the topic.
233///
234/// Recorded by [`LifecyclePollingNodeCtx::trigger_transition`] and drained by
235/// the executor, which owns the publisher. Recording where the transition
236/// HAPPENS rather than at each caller is the whole point: `ros2 lifecycle
237/// set`, `rclc_executor_lifecycle_change_state`,
238/// `nros_cpp_lifecycle_change_state`, `nros_cpp_lifecycle_autostart` and the
239/// safe `LifecycleCallbacks` road all funnel through that one function, so a
240/// new caller is announced without being told to announce.
241#[cfg(feature = "lifecycle-services")]
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub(crate) struct TransitionAnnouncement {
244    /// Steady-clock nanoseconds at the moment the transition completed.
245    ///
246    /// rcl stamps `TransitionEvent.timestamp` from `rcutils_steady_time_now`
247    /// in `rcl_lifecycle`'s `com_interface.c`, not from the wall clock, so
248    /// this reads [`nros_core::clock::Clock::steady`].
249    pub timestamp_ns: u64,
250    /// The transition that ran.
251    pub transition: LifecycleTransition,
252    /// The state the machine was in before it ran.
253    pub start_state: LifecycleState,
254    /// The state the machine reached, whether the callback succeeded or not.
255    pub goal_state: LifecycleState,
256}
257
258/// How many [`TransitionAnnouncement`]s a state machine holds between drains.
259///
260/// The drain is the executor's lifecycle step, so the bound is "the longest
261/// burst a transition source can produce before the executor next spins".
262/// Measured against the sources in this tree the longest is TWO —
263/// `nros_cpp_lifecycle_autostart(exec, 2)` runs `configure` then `activate`
264/// from `__nros_entry_setup`, before the spin loop starts. Four is that with
265/// headroom for a caller driving its own sequence; beyond it a record is
266/// dropped and COUNTED, never silently lost
267/// ([`LifecyclePollingNodeCtx::take_dropped_announcements`]) — the posture the
268/// zpico session's `reply_slot_refusals` already takes for the other bounded
269/// table in this stack.
270#[cfg(feature = "lifecycle-services")]
271pub(crate) const TRANSITION_ANNOUNCEMENT_QUEUE: usize = 4;
272
273/// Lifecycle state machine with `unsafe fn(*mut c_void) -> TransitionResult` callbacks.
274///
275/// Thin counterpart to [`LifecyclePollingNode`] for bridging the C FFI: each
276/// callback slot stores a pointer to `extern "C"` user code plus a single
277/// shared `*mut c_void` context that is passed on every invocation. The core
278/// state machine logic comes from [`nros_core::lifecycle`], same as
279/// `LifecyclePollingNode`.
280pub struct LifecyclePollingNodeCtx {
281    state: LifecycleState,
282    on_configure: Option<LifecycleCallbackFnCtx>,
283    on_activate: Option<LifecycleCallbackFnCtx>,
284    on_deactivate: Option<LifecycleCallbackFnCtx>,
285    on_cleanup: Option<LifecycleCallbackFnCtx>,
286    on_shutdown: Option<LifecycleCallbackFnCtx>,
287    on_error: Option<LifecycleCallbackFnCtx>,
288    context: *mut c_void,
289    /// Transitions performed and not yet announced on `~/transition_event`.
290    ///
291    /// Behind `lifecycle-services` with everything else in this mechanism: the
292    /// drain is the executor's publisher, so an image that registers no
293    /// lifecycle services has nowhere to announce TO, and recording into a
294    /// queue nobody reads is bytes and a clock read per transition bought for
295    /// nothing.
296    #[cfg(feature = "lifecycle-services")]
297    announcements: heapless::Deque<TransitionAnnouncement, TRANSITION_ANNOUNCEMENT_QUEUE>,
298    /// How many announcements the queue above had to drop. Saturating: the
299    /// number is a signal, and a wrap would turn it into a lie.
300    #[cfg(feature = "lifecycle-services")]
301    announcements_dropped: u32,
302}
303
304// `*mut c_void` is `!Sync` + `!Send`; that's the correct posture for a
305// state machine owned by one task. No auto-impl needed.
306
307impl LifecyclePollingNodeCtx {
308    /// Create a new standalone lifecycle state machine. Starts in `Unconfigured`.
309    pub const fn new() -> Self {
310        Self {
311            state: LifecycleState::Unconfigured,
312            on_configure: None,
313            on_activate: None,
314            on_deactivate: None,
315            on_cleanup: None,
316            on_shutdown: None,
317            on_error: None,
318            context: core::ptr::null_mut(),
319            #[cfg(feature = "lifecycle-services")]
320            announcements: heapless::Deque::new(),
321            #[cfg(feature = "lifecycle-services")]
322            announcements_dropped: 0,
323        }
324    }
325
326    /// Get the current lifecycle state.
327    pub const fn state(&self) -> LifecycleState {
328        self.state
329    }
330
331    /// Take the oldest unannounced transition, if any.
332    ///
333    /// The executor calls this until it returns `None` and publishes each one
334    /// on `~/transition_event`. A state machine nobody drains fills its queue
335    /// and counts the overflow; it never blocks a transition.
336    #[cfg(all(feature = "lifecycle-services", any(has_rmw, test)))]
337    pub(crate) fn take_announcement(&mut self) -> Option<TransitionAnnouncement> {
338        self.announcements.pop_front()
339    }
340
341    /// How many announcements have been dropped since this was last called —
342    /// and reset the counter.
343    ///
344    /// Read-and-clear rather than read, so a drainer reports a burst once
345    /// instead of once per spin for the rest of the image's life.
346    #[cfg(all(feature = "lifecycle-services", any(has_rmw, test)))]
347    pub(crate) fn take_dropped_announcements(&mut self) -> u32 {
348        core::mem::replace(&mut self.announcements_dropped, 0)
349    }
350
351    /// Record a completed transition for the executor to announce.
352    #[cfg(feature = "lifecycle-services")]
353    fn record_announcement(
354        &mut self,
355        transition: LifecycleTransition,
356        start_state: LifecycleState,
357        goal_state: LifecycleState,
358    ) {
359        let record = TransitionAnnouncement {
360            timestamp_ns: nros_core::clock::Clock::steady().now().to_nanos().max(0) as u64,
361            transition,
362            start_state,
363            goal_state,
364        };
365        if self.announcements.push_back(record).is_err() {
366            self.announcements_dropped = self.announcements_dropped.saturating_add(1);
367        }
368    }
369
370    /// Set the user context pointer passed to every callback.
371    pub fn set_context(&mut self, ctx: *mut c_void) {
372        self.context = ctx;
373    }
374
375    /// Get the user context pointer.
376    pub fn context(&self) -> *mut c_void {
377        self.context
378    }
379
380    /// Register / clear the callback for a given transition slot.
381    pub fn register(&mut self, slot: LifecycleCallbackSlot, cb: Option<LifecycleCallbackFnCtx>) {
382        match slot {
383            LifecycleCallbackSlot::Configure => self.on_configure = cb,
384            LifecycleCallbackSlot::Activate => self.on_activate = cb,
385            LifecycleCallbackSlot::Deactivate => self.on_deactivate = cb,
386            LifecycleCallbackSlot::Cleanup => self.on_cleanup = cb,
387            LifecycleCallbackSlot::Shutdown => self.on_shutdown = cb,
388            LifecycleCallbackSlot::Error => self.on_error = cb,
389        }
390    }
391
392    /// Clear every registered callback. Used on fini.
393    pub fn clear_callbacks(&mut self) {
394        self.on_configure = None;
395        self.on_activate = None;
396        self.on_deactivate = None;
397        self.on_cleanup = None;
398        self.on_shutdown = None;
399        self.on_error = None;
400        self.context = core::ptr::null_mut();
401    }
402
403    /// Force the state to `Finalized`. Used on fini.
404    pub fn finalize(&mut self) {
405        self.state = LifecycleState::Finalized;
406    }
407
408    /// Trigger a lifecycle transition.
409    ///
410    /// # Safety
411    /// The registered callback (if any) is called via a raw `unsafe fn` pointer
412    /// with the stored `*mut c_void` context. The caller must guarantee that
413    /// any registered callback / context pair remains valid.
414    pub unsafe fn trigger_transition(
415        &mut self,
416        transition: LifecycleTransition,
417    ) -> Result<LifecycleState, LifecycleError> {
418        if self.state.is_terminal() {
419            return Err(LifecycleError::NodeFinalized);
420        }
421
422        if !can_transition(self.state, transition) {
423            return Err(LifecycleError::InvalidTransition {
424                from: self.state,
425                transition,
426            });
427        }
428
429        let cb = match transition {
430            LifecycleTransition::Configure => self.on_configure,
431            LifecycleTransition::Activate => self.on_activate,
432            LifecycleTransition::Deactivate => self.on_deactivate,
433            LifecycleTransition::Cleanup => self.on_cleanup,
434            LifecycleTransition::ShutdownUnconfigured
435            | LifecycleTransition::ShutdownInactive
436            | LifecycleTransition::ShutdownActive => self.on_shutdown,
437            LifecycleTransition::ErrorRecovery => self.on_error,
438        };
439
440        let result = match cb {
441            Some(f) => {
442                let raw = unsafe { f(self.context) };
443                TransitionResult::from_u8(raw).unwrap_or(TransitionResult::Error)
444            }
445            None => TransitionResult::Success,
446        };
447
448        let start_state = self.state;
449        self.state = apply_transition(self.state, transition, result);
450
451        // REP-2002's communication interface is the five services PLUS a
452        // `~/transition_event` publisher, and this is the ONE place a
453        // transition happens (issue 1587). Recorded on the FAILURE path too:
454        // rcl publishes the failure transition as well, and a supervisor
455        // watching passively is exactly the reader who needs to see a
456        // `Configure` that landed in `ErrorProcessing` rather than `Inactive`.
457        // A REJECTED transition publishes nothing, because none ran — the two
458        // early returns above are before this point, as they are in rcl.
459        #[cfg(feature = "lifecycle-services")]
460        self.record_announcement(transition, start_state, self.state);
461        // No lifecycle services in this build means no publisher to announce
462        // on, so there is nothing to record and nothing to read `start_state`.
463        #[cfg(not(feature = "lifecycle-services"))]
464        let _ = start_state;
465
466        if result == TransitionResult::Success {
467            Ok(self.state)
468        } else {
469            Err(LifecycleError::CallbackFailed { transition, result })
470        }
471    }
472}
473
474impl Default for LifecyclePollingNodeCtx {
475    fn default() -> Self {
476        Self::new()
477    }
478}
479
480/// Which transition callback slot to register in [`LifecyclePollingNodeCtx::register`].
481#[derive(Debug, Clone, Copy, PartialEq, Eq)]
482pub enum LifecycleCallbackSlot {
483    /// `Unconfigured -> Inactive`
484    Configure,
485    /// `Inactive -> Active`
486    Activate,
487    /// `Active -> Inactive`
488    Deactivate,
489    /// `Inactive -> Unconfigured`
490    Cleanup,
491    /// any state -> `Finalized`
492    Shutdown,
493    /// `ErrorProcessing -> Unconfigured`
494    Error,
495}
496
497// ═══════════════════════════════════════════════════════════════════════════
498// SAFE LIFECYCLE-CALLBACK TRAIT (issue 0335 / phase-317)
499// ═══════════════════════════════════════════════════════════════════════════
500
501/// Safe lifecycle-callback surface, symmetric with the C++ `nros::LifecycleNode`
502/// (rclcpp `LifecycleNodeInterface`). Implement the transitions you need; the
503/// rest default to `Success` (`on_error` to `Failure`), matching rclcpp's
504/// non-pure-virtual defaults. Register the node with
505/// `Executor::register_lifecycle_node`,
506/// which binds the five REP-2002 services and wires each transition here — no
507/// `unsafe` in user code.
508///
509/// # Example
510/// ```ignore
511/// struct MyNode { configured: bool }
512/// impl LifecycleCallbacks for MyNode {
513///     fn on_configure(&mut self) -> TransitionResult {
514///         self.configured = true;
515///         TransitionResult::Success
516///     }
517/// }
518/// executor.register_lifecycle_node(&mut my_node)?;
519/// ```
520///
521/// Unlike rclcpp's `on_*(const State& previous)`, the callbacks take no
522/// `previous` argument: the FFI callback boundary ([`LifecycleCallbackFnCtx`])
523/// carries only the user context, and the [`LifecyclePollingNode`] fn-pointer
524/// API is likewise state-less. Read the current state via
525/// `Executor::lifecycle_state_machine`
526/// `.state()` if a transition needs it.
527pub trait LifecycleCallbacks {
528    /// `Unconfigured -> Inactive`. Default: `Success`.
529    fn on_configure(&mut self) -> TransitionResult {
530        TransitionResult::Success
531    }
532    /// `Inactive -> Active`. Default: `Success`.
533    fn on_activate(&mut self) -> TransitionResult {
534        TransitionResult::Success
535    }
536    /// `Active -> Inactive`. Default: `Success`.
537    fn on_deactivate(&mut self) -> TransitionResult {
538        TransitionResult::Success
539    }
540    /// `Inactive -> Unconfigured`. Default: `Success`.
541    fn on_cleanup(&mut self) -> TransitionResult {
542        TransitionResult::Success
543    }
544    /// any state `-> Finalized`. Default: `Success`.
545    fn on_shutdown(&mut self) -> TransitionResult {
546        TransitionResult::Success
547    }
548    /// `ErrorProcessing -> Unconfigured`. Default: `Failure` (matching rclcpp).
549    fn on_error(&mut self) -> TransitionResult {
550        TransitionResult::Failure
551    }
552}
553
554/// Monomorphized `extern "C"` trampolines that recover `&mut T` from the FFI
555/// context pointer and dispatch to the [`LifecycleCallbacks`] method. rustc
556/// emits one per `T`, so there is no closure box — `no_std`-safe. Registered by
557/// `Executor::register_lifecycle_node`;
558/// not meant to be called directly.
559pub mod trampolines {
560    use super::{LifecycleCallbacks, TransitionResult};
561    use core::ffi::c_void;
562
563    macro_rules! trampoline {
564        ($name:ident, $method:ident) => {
565            /// # Safety
566            /// `ctx` must be a `*mut T` that outlives the registration and is not
567            /// aliased for the duration of the call (the executor spins
568            /// single-threaded, so no concurrent `&mut T` exists).
569            pub unsafe extern "C" fn $name<T: LifecycleCallbacks>(ctx: *mut c_void) -> u8 {
570                if ctx.is_null() {
571                    return TransitionResult::Error as u8;
572                }
573                // SAFETY: caller (`register_lifecycle_node`) set `ctx` to a live
574                // `&mut T`; the spin loop invokes this synchronously, so the
575                // reference is unique for the call.
576                let node = unsafe { &mut *(ctx as *mut T) };
577                node.$method() as u8
578            }
579        };
580    }
581
582    trampoline!(on_configure, on_configure);
583    trampoline!(on_activate, on_activate);
584    trampoline!(on_deactivate, on_deactivate);
585    trampoline!(on_cleanup, on_cleanup);
586    trampoline!(on_shutdown, on_shutdown);
587    trampoline!(on_error, on_error);
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    // ═══════════════════════════════════════════════════════════════════════
595    // Transition announcements (issue 1587)
596    // ═══════════════════════════════════════════════════════════════════════
597
598    #[cfg(feature = "lifecycle-services")]
599    #[test]
600    fn every_executed_transition_is_recorded_once() {
601        let mut sm = LifecyclePollingNodeCtx::new();
602        // SAFETY: no callbacks registered, so no FFI pointer is invoked.
603        unsafe { sm.trigger_transition(LifecycleTransition::Configure) }.expect("configure");
604
605        let ev = sm.take_announcement().expect("configure was recorded");
606        assert_eq!(ev.transition, LifecycleTransition::Configure);
607        assert_eq!(ev.start_state, LifecycleState::Unconfigured);
608        assert_eq!(ev.goal_state, LifecycleState::Inactive);
609        assert!(
610            sm.take_announcement().is_none(),
611            "one transition, one record"
612        );
613        assert_eq!(sm.take_dropped_announcements(), 0);
614    }
615
616    #[cfg(feature = "lifecycle-services")]
617    #[test]
618    fn a_rejected_transition_records_nothing() {
619        let mut sm = LifecyclePollingNodeCtx::new();
620        // Activate is illegal from Unconfigured: no callback runs and no
621        // state changes, so there is nothing to announce.
622        // SAFETY: no callbacks registered.
623        assert!(unsafe { sm.trigger_transition(LifecycleTransition::Activate) }.is_err());
624        assert!(sm.take_announcement().is_none());
625        assert_eq!(sm.take_dropped_announcements(), 0);
626    }
627
628    #[cfg(feature = "lifecycle-services")]
629    #[test]
630    fn an_undrained_queue_counts_its_losses_instead_of_hiding_them() {
631        let mut sm = LifecyclePollingNodeCtx::new();
632        // Configure/Cleanup cycles indefinitely between Unconfigured and
633        // Inactive, so this drives more transitions than the queue holds
634        // without ever draining it.
635        let runs = TRANSITION_ANNOUNCEMENT_QUEUE + 3;
636        for i in 0..runs {
637            let t = if i % 2 == 0 {
638                LifecycleTransition::Configure
639            } else {
640                LifecycleTransition::Cleanup
641            };
642            // SAFETY: no callbacks registered.
643            unsafe { sm.trigger_transition(t) }.expect("legal transition");
644        }
645        let mut drained = 0;
646        while sm.take_announcement().is_some() {
647            drained += 1;
648        }
649        assert_eq!(drained, TRANSITION_ANNOUNCEMENT_QUEUE);
650        assert_eq!(
651            sm.take_dropped_announcements() as usize,
652            runs - TRANSITION_ANNOUNCEMENT_QUEUE,
653            "the overflow is COUNTED — a bounded table that discards silently is \
654             the defect the zpico reply slots already answered"
655        );
656        assert_eq!(
657            sm.take_dropped_announcements(),
658            0,
659            "read-and-clear: a burst is reported once, not every spin thereafter"
660        );
661    }
662
663    // ═══════════════════════════════════════════════════════════════════════
664    // Safe LifecycleCallbacks trait (issue 0335 / phase-317)
665    // ═══════════════════════════════════════════════════════════════════════
666
667    struct DemoNode {
668        configured: bool,
669    }
670    impl LifecycleCallbacks for DemoNode {
671        fn on_configure(&mut self) -> TransitionResult {
672            self.configured = true;
673            TransitionResult::Success
674        }
675        fn on_error(&mut self) -> TransitionResult {
676            TransitionResult::Error
677        }
678        // on_activate / on_deactivate / on_cleanup / on_shutdown use the defaults.
679    }
680
681    #[test]
682    fn trampoline_dispatches_to_trait_impl_and_defaults() {
683        let mut n = DemoNode { configured: false };
684        let ctx = &mut n as *mut DemoNode as *mut c_void;
685
686        // Overridden method runs and mutates through the recovered &mut.
687        let rc = unsafe { trampolines::on_configure::<DemoNode>(ctx) };
688        assert_eq!(rc, TransitionResult::Success as u8);
689        assert!(n.configured);
690
691        // Defaulted method returns Success without an override.
692        let rc = unsafe { trampolines::on_activate::<DemoNode>(&mut n as *mut _ as *mut c_void) };
693        assert_eq!(rc, TransitionResult::Success as u8);
694
695        // Overridden on_error returns Error.
696        let rc = unsafe { trampolines::on_error::<DemoNode>(&mut n as *mut _ as *mut c_void) };
697        assert_eq!(rc, TransitionResult::Error as u8);
698
699        // Null ctx is Error, never a deref.
700        let rc = unsafe { trampolines::on_configure::<DemoNode>(core::ptr::null_mut()) };
701        assert_eq!(rc, TransitionResult::Error as u8);
702    }
703
704    #[test]
705    fn ctx_state_machine_drives_trait_through_a_transition() {
706        // The seam `register_lifecycle_node` builds on: set_context + register a
707        // monomorphized trampoline, then a transition dispatches to the trait.
708        let mut n = DemoNode { configured: false };
709        let mut sm = LifecyclePollingNodeCtx::new();
710        sm.set_context(&mut n as *mut DemoNode as *mut c_void);
711        sm.register(
712            LifecycleCallbackSlot::Configure,
713            Some(trampolines::on_configure::<DemoNode>),
714        );
715
716        // SAFETY: `n` outlives `sm`; the transition runs synchronously here.
717        let new_state =
718            unsafe { sm.trigger_transition(LifecycleTransition::Configure) }.expect("configure");
719
720        assert!(n.configured, "the trait's on_configure ran");
721        assert_eq!(new_state, LifecycleState::Inactive);
722    }
723
724    #[test]
725    fn raw_extern_c_callbacks_drive_the_ctx_state_machine() {
726        // Issue 0335 — the raw-FFI shape the `linux/rust/lifecycle-node` example
727        // used to demonstrate (a user hand-writing `extern "C"` callbacks against
728        // the C-parity seam). Relocated here as automated coverage so the example
729        // can show the safe `LifecycleCallbacks` trait instead of an FFI test.
730        unsafe extern "C" fn cb_success(_ctx: *mut c_void) -> u8 {
731            TransitionResult::Success as u8
732        }
733        let mut sm = LifecyclePollingNodeCtx::new();
734        sm.register(LifecycleCallbackSlot::Configure, Some(cb_success));
735        sm.register(LifecycleCallbackSlot::Activate, Some(cb_success));
736
737        // SAFETY: the callbacks ignore the (null) context.
738        let s =
739            unsafe { sm.trigger_transition(LifecycleTransition::Configure) }.expect("configure");
740        assert_eq!(s, LifecycleState::Inactive);
741        let s = unsafe { sm.trigger_transition(LifecycleTransition::Activate) }.expect("activate");
742        assert_eq!(s, LifecycleState::Active);
743    }
744
745    // ═══════════════════════════════════════════════════════════════════════
746    // LifecyclePollingNode tests (no_std, always available)
747    // ═══════════════════════════════════════════════════════════════════════
748
749    #[test]
750    fn test_polling_node_initial_state() {
751        let node = LifecyclePollingNode::new();
752        assert_eq!(node.state(), LifecycleState::Unconfigured);
753    }
754
755    #[test]
756    fn test_polling_node_default() {
757        let node = LifecyclePollingNode::default();
758        assert_eq!(node.state(), LifecycleState::Unconfigured);
759    }
760
761    #[test]
762    fn test_polling_node_happy_path() {
763        let mut node = LifecyclePollingNode::new();
764
765        assert_eq!(node.configure().unwrap(), LifecycleState::Inactive);
766        assert_eq!(node.activate().unwrap(), LifecycleState::Active);
767        assert_eq!(node.deactivate().unwrap(), LifecycleState::Inactive);
768        assert_eq!(node.shutdown().unwrap(), LifecycleState::Finalized);
769    }
770
771    #[test]
772    fn test_polling_node_cleanup_cycle() {
773        let mut node = LifecyclePollingNode::new();
774
775        node.configure().unwrap();
776        assert_eq!(node.cleanup().unwrap(), LifecycleState::Unconfigured);
777
778        // Can configure again
779        assert_eq!(node.configure().unwrap(), LifecycleState::Inactive);
780    }
781
782    #[test]
783    fn test_polling_node_invalid_transition() {
784        let mut node = LifecyclePollingNode::new();
785
786        let err = node.activate().unwrap_err();
787        assert_eq!(
788            err,
789            LifecycleError::InvalidTransition {
790                from: LifecycleState::Unconfigured,
791                transition: LifecycleTransition::Activate,
792            }
793        );
794    }
795
796    #[test]
797    fn test_polling_node_finalized_rejection() {
798        let mut node = LifecyclePollingNode::new();
799        node.shutdown().unwrap();
800
801        assert_eq!(node.configure().unwrap_err(), LifecycleError::NodeFinalized);
802        assert_eq!(node.shutdown().unwrap_err(), LifecycleError::NodeFinalized);
803    }
804
805    fn on_configure_success() -> TransitionResult {
806        TransitionResult::Success
807    }
808
809    fn on_configure_failure() -> TransitionResult {
810        TransitionResult::Failure
811    }
812
813    fn on_configure_error() -> TransitionResult {
814        TransitionResult::Error
815    }
816
817    #[test]
818    fn test_polling_node_callback_success() {
819        let mut node = LifecyclePollingNode::new();
820        node.register_on_configure(on_configure_success);
821
822        assert_eq!(node.configure().unwrap(), LifecycleState::Inactive);
823    }
824
825    #[test]
826    fn test_polling_node_callback_failure_rollback() {
827        let mut node = LifecyclePollingNode::new();
828        node.register_on_configure(on_configure_failure);
829
830        let err = node.configure().unwrap_err();
831        assert_eq!(
832            err,
833            LifecycleError::CallbackFailed {
834                transition: LifecycleTransition::Configure,
835                result: TransitionResult::Failure,
836            }
837        );
838        // State rolled back to Unconfigured
839        assert_eq!(node.state(), LifecycleState::Unconfigured);
840    }
841
842    #[test]
843    fn test_polling_node_callback_error() {
844        let mut node = LifecyclePollingNode::new();
845        node.register_on_configure(on_configure_error);
846
847        let err = node.configure().unwrap_err();
848        assert_eq!(
849            err,
850            LifecycleError::CallbackFailed {
851                transition: LifecycleTransition::Configure,
852                result: TransitionResult::Error,
853            }
854        );
855        // State moved to ErrorProcessing
856        assert_eq!(node.state(), LifecycleState::ErrorProcessing);
857    }
858
859    #[test]
860    fn test_polling_node_error_recovery() {
861        let mut node = LifecyclePollingNode::new();
862        node.register_on_configure(on_configure_error);
863
864        let _ = node.configure();
865        assert_eq!(node.state(), LifecycleState::ErrorProcessing);
866
867        // Cannot shutdown from error processing
868        assert!(node.shutdown().is_err());
869
870        // Can recover
871        node.register_on_error(on_configure_success);
872        let result = node.trigger_transition(LifecycleTransition::ErrorRecovery);
873        assert_eq!(result.unwrap(), LifecycleState::Unconfigured);
874    }
875
876    #[test]
877    fn test_polling_node_bring_up() {
878        let mut node = LifecyclePollingNode::new();
879        assert_eq!(node.bring_up().unwrap(), LifecycleState::Active);
880    }
881
882    #[test]
883    fn test_polling_node_bring_up_stops_on_configure_failure() {
884        let mut node = LifecyclePollingNode::new();
885        node.register_on_configure(on_configure_failure);
886
887        let err = node.bring_up().unwrap_err();
888        assert_eq!(
889            err,
890            LifecycleError::CallbackFailed {
891                transition: LifecycleTransition::Configure,
892                result: TransitionResult::Failure,
893            }
894        );
895        // State is still Unconfigured, activate was never attempted
896        assert_eq!(node.state(), LifecycleState::Unconfigured);
897    }
898
899    #[test]
900    fn test_polling_node_shutdown_from_each_state() {
901        // From Unconfigured
902        let mut node = LifecyclePollingNode::new();
903        assert_eq!(node.shutdown().unwrap(), LifecycleState::Finalized);
904
905        // From Inactive
906        let mut node = LifecyclePollingNode::new();
907        node.configure().unwrap();
908        assert_eq!(node.shutdown().unwrap(), LifecycleState::Finalized);
909
910        // From Active
911        let mut node = LifecyclePollingNode::new();
912        node.bring_up().unwrap();
913        assert_eq!(node.shutdown().unwrap(), LifecycleState::Finalized);
914    }
915
916    #[test]
917    fn test_polling_node_no_callback_defaults_success() {
918        // Without any callbacks registered, transitions should succeed
919        let mut node = LifecyclePollingNode::new();
920        assert_eq!(node.configure().unwrap(), LifecycleState::Inactive);
921        assert_eq!(node.activate().unwrap(), LifecycleState::Active);
922        assert_eq!(node.deactivate().unwrap(), LifecycleState::Inactive);
923        assert_eq!(node.cleanup().unwrap(), LifecycleState::Unconfigured);
924        assert_eq!(node.shutdown().unwrap(), LifecycleState::Finalized);
925    }
926
927    fn on_shutdown_success() -> TransitionResult {
928        TransitionResult::Success
929    }
930
931    #[test]
932    fn test_polling_node_shutdown_callback_invoked() {
933        let mut node = LifecyclePollingNode::new();
934        node.register_on_shutdown(on_shutdown_success);
935
936        // Shutdown from Unconfigured should invoke the shutdown callback
937        assert_eq!(node.shutdown().unwrap(), LifecycleState::Finalized);
938    }
939
940    // ═══════════════════════════════════════════════════════════════════════
941    // LifecyclePollingNodeCtx tests (C FFI shape)
942    // ═══════════════════════════════════════════════════════════════════════
943
944    unsafe extern "C" fn ctx_cb_success(_: *mut c_void) -> u8 {
945        TransitionResult::Success as u8
946    }
947    unsafe extern "C" fn ctx_cb_failure(_: *mut c_void) -> u8 {
948        TransitionResult::Failure as u8
949    }
950    unsafe extern "C" fn ctx_cb_error(_: *mut c_void) -> u8 {
951        TransitionResult::Error as u8
952    }
953
954    #[test]
955    fn test_ctx_node_happy_path() {
956        unsafe {
957            let mut node = LifecyclePollingNodeCtx::new();
958            node.register(LifecycleCallbackSlot::Configure, Some(ctx_cb_success));
959            node.register(LifecycleCallbackSlot::Activate, Some(ctx_cb_success));
960            node.register(LifecycleCallbackSlot::Deactivate, Some(ctx_cb_success));
961            node.register(LifecycleCallbackSlot::Shutdown, Some(ctx_cb_success));
962
963            assert_eq!(
964                node.trigger_transition(LifecycleTransition::Configure)
965                    .unwrap(),
966                LifecycleState::Inactive
967            );
968            assert_eq!(
969                node.trigger_transition(LifecycleTransition::Activate)
970                    .unwrap(),
971                LifecycleState::Active
972            );
973            assert_eq!(
974                node.trigger_transition(LifecycleTransition::Deactivate)
975                    .unwrap(),
976                LifecycleState::Inactive
977            );
978            assert_eq!(
979                node.trigger_transition(LifecycleTransition::ShutdownInactive)
980                    .unwrap(),
981                LifecycleState::Finalized
982            );
983        }
984    }
985
986    #[test]
987    fn test_ctx_node_invalid_transition() {
988        unsafe {
989            let mut node = LifecyclePollingNodeCtx::new();
990            let err = node
991                .trigger_transition(LifecycleTransition::Activate)
992                .unwrap_err();
993            assert_eq!(
994                err,
995                LifecycleError::InvalidTransition {
996                    from: LifecycleState::Unconfigured,
997                    transition: LifecycleTransition::Activate,
998                }
999            );
1000            assert_eq!(node.state(), LifecycleState::Unconfigured);
1001        }
1002    }
1003
1004    #[test]
1005    fn test_ctx_node_callback_failure_rolls_back() {
1006        unsafe {
1007            let mut node = LifecyclePollingNodeCtx::new();
1008            node.register(LifecycleCallbackSlot::Configure, Some(ctx_cb_failure));
1009            assert!(
1010                node.trigger_transition(LifecycleTransition::Configure)
1011                    .is_err()
1012            );
1013            assert_eq!(node.state(), LifecycleState::Unconfigured);
1014        }
1015    }
1016
1017    #[test]
1018    fn test_ctx_node_callback_error_enters_error_processing() {
1019        unsafe {
1020            let mut node = LifecyclePollingNodeCtx::new();
1021            node.register(LifecycleCallbackSlot::Configure, Some(ctx_cb_error));
1022            assert!(
1023                node.trigger_transition(LifecycleTransition::Configure)
1024                    .is_err()
1025            );
1026            assert_eq!(node.state(), LifecycleState::ErrorProcessing);
1027        }
1028    }
1029
1030    #[test]
1031    fn test_ctx_node_finalized_rejects() {
1032        unsafe {
1033            let mut node = LifecyclePollingNodeCtx::new();
1034            node.finalize();
1035            let err = node
1036                .trigger_transition(LifecycleTransition::Configure)
1037                .unwrap_err();
1038            assert_eq!(err, LifecycleError::NodeFinalized);
1039        }
1040    }
1041
1042    #[test]
1043    fn test_ctx_node_context_passed() {
1044        use core::sync::atomic::{AtomicU32, Ordering};
1045        static SEEN: AtomicU32 = AtomicU32::new(0);
1046        unsafe extern "C" fn cb_record(ctx: *mut c_void) -> u8 {
1047            SEEN.store(ctx as usize as u32, Ordering::Relaxed);
1048            TransitionResult::Success as u8
1049        }
1050
1051        unsafe {
1052            let mut node = LifecyclePollingNodeCtx::new();
1053            node.set_context(0xBEEFu32 as usize as *mut c_void);
1054            node.register(LifecycleCallbackSlot::Configure, Some(cb_record));
1055            let _ = node.trigger_transition(LifecycleTransition::Configure);
1056            assert_eq!(SEEN.load(Ordering::Relaxed), 0xBEEF);
1057        }
1058    }
1059
1060    #[test]
1061    fn test_ctx_node_clear_callbacks_resets() {
1062        unsafe {
1063            let mut node = LifecyclePollingNodeCtx::new();
1064            node.set_context(core::ptr::dangling_mut::<c_void>());
1065            node.register(LifecycleCallbackSlot::Configure, Some(ctx_cb_success));
1066            node.clear_callbacks();
1067            assert!(node.context().is_null());
1068            // With no callback, transition still succeeds (default = Success).
1069            assert_eq!(
1070                node.trigger_transition(LifecycleTransition::Configure)
1071                    .unwrap(),
1072                LifecycleState::Inactive
1073            );
1074        }
1075    }
1076}