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#[cfg(test)]
379mod tests {
380    use super::*;
381
382    // ═══════════════════════════════════════════════════════════════════════
383    // LifecyclePollingNode tests (no_std, always available)
384    // ═══════════════════════════════════════════════════════════════════════
385
386    #[test]
387    fn test_polling_node_initial_state() {
388        let node = LifecyclePollingNode::new();
389        assert_eq!(node.state(), LifecycleState::Unconfigured);
390    }
391
392    #[test]
393    fn test_polling_node_default() {
394        let node = LifecyclePollingNode::default();
395        assert_eq!(node.state(), LifecycleState::Unconfigured);
396    }
397
398    #[test]
399    fn test_polling_node_happy_path() {
400        let mut node = LifecyclePollingNode::new();
401
402        assert_eq!(node.configure().unwrap(), LifecycleState::Inactive);
403        assert_eq!(node.activate().unwrap(), LifecycleState::Active);
404        assert_eq!(node.deactivate().unwrap(), LifecycleState::Inactive);
405        assert_eq!(node.shutdown().unwrap(), LifecycleState::Finalized);
406    }
407
408    #[test]
409    fn test_polling_node_cleanup_cycle() {
410        let mut node = LifecyclePollingNode::new();
411
412        node.configure().unwrap();
413        assert_eq!(node.cleanup().unwrap(), LifecycleState::Unconfigured);
414
415        // Can configure again
416        assert_eq!(node.configure().unwrap(), LifecycleState::Inactive);
417    }
418
419    #[test]
420    fn test_polling_node_invalid_transition() {
421        let mut node = LifecyclePollingNode::new();
422
423        let err = node.activate().unwrap_err();
424        assert_eq!(
425            err,
426            LifecycleError::InvalidTransition {
427                from: LifecycleState::Unconfigured,
428                transition: LifecycleTransition::Activate,
429            }
430        );
431    }
432
433    #[test]
434    fn test_polling_node_finalized_rejection() {
435        let mut node = LifecyclePollingNode::new();
436        node.shutdown().unwrap();
437
438        assert_eq!(node.configure().unwrap_err(), LifecycleError::NodeFinalized);
439        assert_eq!(node.shutdown().unwrap_err(), LifecycleError::NodeFinalized);
440    }
441
442    fn on_configure_success() -> TransitionResult {
443        TransitionResult::Success
444    }
445
446    fn on_configure_failure() -> TransitionResult {
447        TransitionResult::Failure
448    }
449
450    fn on_configure_error() -> TransitionResult {
451        TransitionResult::Error
452    }
453
454    #[test]
455    fn test_polling_node_callback_success() {
456        let mut node = LifecyclePollingNode::new();
457        node.register_on_configure(on_configure_success);
458
459        assert_eq!(node.configure().unwrap(), LifecycleState::Inactive);
460    }
461
462    #[test]
463    fn test_polling_node_callback_failure_rollback() {
464        let mut node = LifecyclePollingNode::new();
465        node.register_on_configure(on_configure_failure);
466
467        let err = node.configure().unwrap_err();
468        assert_eq!(
469            err,
470            LifecycleError::CallbackFailed {
471                transition: LifecycleTransition::Configure,
472                result: TransitionResult::Failure,
473            }
474        );
475        // State rolled back to Unconfigured
476        assert_eq!(node.state(), LifecycleState::Unconfigured);
477    }
478
479    #[test]
480    fn test_polling_node_callback_error() {
481        let mut node = LifecyclePollingNode::new();
482        node.register_on_configure(on_configure_error);
483
484        let err = node.configure().unwrap_err();
485        assert_eq!(
486            err,
487            LifecycleError::CallbackFailed {
488                transition: LifecycleTransition::Configure,
489                result: TransitionResult::Error,
490            }
491        );
492        // State moved to ErrorProcessing
493        assert_eq!(node.state(), LifecycleState::ErrorProcessing);
494    }
495
496    #[test]
497    fn test_polling_node_error_recovery() {
498        let mut node = LifecyclePollingNode::new();
499        node.register_on_configure(on_configure_error);
500
501        let _ = node.configure();
502        assert_eq!(node.state(), LifecycleState::ErrorProcessing);
503
504        // Cannot shutdown from error processing
505        assert!(node.shutdown().is_err());
506
507        // Can recover
508        node.register_on_error(on_configure_success);
509        let result = node.trigger_transition(LifecycleTransition::ErrorRecovery);
510        assert_eq!(result.unwrap(), LifecycleState::Unconfigured);
511    }
512
513    #[test]
514    fn test_polling_node_bring_up() {
515        let mut node = LifecyclePollingNode::new();
516        assert_eq!(node.bring_up().unwrap(), LifecycleState::Active);
517    }
518
519    #[test]
520    fn test_polling_node_bring_up_stops_on_configure_failure() {
521        let mut node = LifecyclePollingNode::new();
522        node.register_on_configure(on_configure_failure);
523
524        let err = node.bring_up().unwrap_err();
525        assert_eq!(
526            err,
527            LifecycleError::CallbackFailed {
528                transition: LifecycleTransition::Configure,
529                result: TransitionResult::Failure,
530            }
531        );
532        // State is still Unconfigured, activate was never attempted
533        assert_eq!(node.state(), LifecycleState::Unconfigured);
534    }
535
536    #[test]
537    fn test_polling_node_shutdown_from_each_state() {
538        // From Unconfigured
539        let mut node = LifecyclePollingNode::new();
540        assert_eq!(node.shutdown().unwrap(), LifecycleState::Finalized);
541
542        // From Inactive
543        let mut node = LifecyclePollingNode::new();
544        node.configure().unwrap();
545        assert_eq!(node.shutdown().unwrap(), LifecycleState::Finalized);
546
547        // From Active
548        let mut node = LifecyclePollingNode::new();
549        node.bring_up().unwrap();
550        assert_eq!(node.shutdown().unwrap(), LifecycleState::Finalized);
551    }
552
553    #[test]
554    fn test_polling_node_no_callback_defaults_success() {
555        // Without any callbacks registered, transitions should succeed
556        let mut node = LifecyclePollingNode::new();
557        assert_eq!(node.configure().unwrap(), LifecycleState::Inactive);
558        assert_eq!(node.activate().unwrap(), LifecycleState::Active);
559        assert_eq!(node.deactivate().unwrap(), LifecycleState::Inactive);
560        assert_eq!(node.cleanup().unwrap(), LifecycleState::Unconfigured);
561        assert_eq!(node.shutdown().unwrap(), LifecycleState::Finalized);
562    }
563
564    fn on_shutdown_success() -> TransitionResult {
565        TransitionResult::Success
566    }
567
568    #[test]
569    fn test_polling_node_shutdown_callback_invoked() {
570        let mut node = LifecyclePollingNode::new();
571        node.register_on_shutdown(on_shutdown_success);
572
573        // Shutdown from Unconfigured should invoke the shutdown callback
574        assert_eq!(node.shutdown().unwrap(), LifecycleState::Finalized);
575    }
576
577    // ═══════════════════════════════════════════════════════════════════════
578    // LifecyclePollingNodeCtx tests (C FFI shape)
579    // ═══════════════════════════════════════════════════════════════════════
580
581    unsafe extern "C" fn ctx_cb_success(_: *mut c_void) -> u8 {
582        TransitionResult::Success as u8
583    }
584    unsafe extern "C" fn ctx_cb_failure(_: *mut c_void) -> u8 {
585        TransitionResult::Failure as u8
586    }
587    unsafe extern "C" fn ctx_cb_error(_: *mut c_void) -> u8 {
588        TransitionResult::Error as u8
589    }
590
591    #[test]
592    fn test_ctx_node_happy_path() {
593        unsafe {
594            let mut node = LifecyclePollingNodeCtx::new();
595            node.register(LifecycleCallbackSlot::Configure, Some(ctx_cb_success));
596            node.register(LifecycleCallbackSlot::Activate, Some(ctx_cb_success));
597            node.register(LifecycleCallbackSlot::Deactivate, Some(ctx_cb_success));
598            node.register(LifecycleCallbackSlot::Shutdown, Some(ctx_cb_success));
599
600            assert_eq!(
601                node.trigger_transition(LifecycleTransition::Configure)
602                    .unwrap(),
603                LifecycleState::Inactive
604            );
605            assert_eq!(
606                node.trigger_transition(LifecycleTransition::Activate)
607                    .unwrap(),
608                LifecycleState::Active
609            );
610            assert_eq!(
611                node.trigger_transition(LifecycleTransition::Deactivate)
612                    .unwrap(),
613                LifecycleState::Inactive
614            );
615            assert_eq!(
616                node.trigger_transition(LifecycleTransition::ShutdownInactive)
617                    .unwrap(),
618                LifecycleState::Finalized
619            );
620        }
621    }
622
623    #[test]
624    fn test_ctx_node_invalid_transition() {
625        unsafe {
626            let mut node = LifecyclePollingNodeCtx::new();
627            let err = node
628                .trigger_transition(LifecycleTransition::Activate)
629                .unwrap_err();
630            assert_eq!(
631                err,
632                LifecycleError::InvalidTransition {
633                    from: LifecycleState::Unconfigured,
634                    transition: LifecycleTransition::Activate,
635                }
636            );
637            assert_eq!(node.state(), LifecycleState::Unconfigured);
638        }
639    }
640
641    #[test]
642    fn test_ctx_node_callback_failure_rolls_back() {
643        unsafe {
644            let mut node = LifecyclePollingNodeCtx::new();
645            node.register(LifecycleCallbackSlot::Configure, Some(ctx_cb_failure));
646            assert!(
647                node.trigger_transition(LifecycleTransition::Configure)
648                    .is_err()
649            );
650            assert_eq!(node.state(), LifecycleState::Unconfigured);
651        }
652    }
653
654    #[test]
655    fn test_ctx_node_callback_error_enters_error_processing() {
656        unsafe {
657            let mut node = LifecyclePollingNodeCtx::new();
658            node.register(LifecycleCallbackSlot::Configure, Some(ctx_cb_error));
659            assert!(
660                node.trigger_transition(LifecycleTransition::Configure)
661                    .is_err()
662            );
663            assert_eq!(node.state(), LifecycleState::ErrorProcessing);
664        }
665    }
666
667    #[test]
668    fn test_ctx_node_finalized_rejects() {
669        unsafe {
670            let mut node = LifecyclePollingNodeCtx::new();
671            node.finalize();
672            let err = node
673                .trigger_transition(LifecycleTransition::Configure)
674                .unwrap_err();
675            assert_eq!(err, LifecycleError::NodeFinalized);
676        }
677    }
678
679    #[test]
680    fn test_ctx_node_context_passed() {
681        use core::sync::atomic::{AtomicU32, Ordering};
682        static SEEN: AtomicU32 = AtomicU32::new(0);
683        unsafe extern "C" fn cb_record(ctx: *mut c_void) -> u8 {
684            SEEN.store(ctx as usize as u32, Ordering::Relaxed);
685            TransitionResult::Success as u8
686        }
687
688        unsafe {
689            let mut node = LifecyclePollingNodeCtx::new();
690            node.set_context(0xBEEFu32 as usize as *mut c_void);
691            node.register(LifecycleCallbackSlot::Configure, Some(cb_record));
692            let _ = node.trigger_transition(LifecycleTransition::Configure);
693            assert_eq!(SEEN.load(Ordering::Relaxed), 0xBEEF);
694        }
695    }
696
697    #[test]
698    fn test_ctx_node_clear_callbacks_resets() {
699        unsafe {
700            let mut node = LifecyclePollingNodeCtx::new();
701            node.set_context(core::ptr::dangling_mut::<c_void>());
702            node.register(LifecycleCallbackSlot::Configure, Some(ctx_cb_success));
703            node.clear_callbacks();
704            assert!(node.context().is_null());
705            // With no callback, transition still succeeds (default = Success).
706            assert_eq!(
707                node.trigger_transition(LifecycleTransition::Configure)
708                    .unwrap(),
709                LifecycleState::Inactive
710            );
711        }
712    }
713}