statig

repository·main·Indexed 21 days ago

https://github.com/mdeloof/statig

A Rust library for designing efficient, hierarchical, event-driven state machines. It supports #![no_std] environments with no heap allocations, async handlers and actions, and provides a proc-macro system to reduce boilerplate. Key features include state-local and shared storage, superstates for event deferral, and entry/exit actions for transition logic.

Tokens
11.8K
Snippets
33
Records
43
Agent score
72%

What's inside statig

  1. How states and outcomes work

    main

    States are methods in your impl block decorated with #[state]. When an event is handled, the method receives the event as an argument. Every state method must return an Outcome<State>, which determines the next step in the state machine lifecycle:

    • Handled: The event was processed, and the machine remains in the current state.
    • Transition(State): The machine transitions to the specified new state.
    • Super: The event is not handled by the current state and is deferred to its parent superstate.
    #[state]
    fn led_on(event: &Event) -> Outcome<State> {
        Transition(State::led_off())
    }
  2. How hierarchical state machines work in statig

    main

    statig represents a hierarchical state machine as a tree structure.

    • Leaf-states: Nodes at the edge of the tree. They are represented by an enum and can own their own data (referred to as state-local storage).
    • Superstates: States that define shared behavior for their child states. They are also represented by an enum, but instead of owning data, they borrow it from the underlying leaf-state.
    • Top state: An implicit root state that considers every event as handled.

    When an event arrives, statig first dispatches it to the current leaf state. If the state returns a Super outcome, the event is dispatched to its superstate. This process continues upwards until the event is handled or the Top state is reached.

    // Example of state-local storage in leaf-states
    enum State {
        LedOn { counter: usize },
        LedOff { counter: usize },
        NotBlinking
    }
    
    // Example of superstates borrowing data from leaf-states
    enum Superstate<'sub> {
        Blinking { counter: &'sub usize }
    }
  3. How transitions and entry/exit actions work

    main

    When a state returns a Transition outcome, statig performs a transition sequence by finding the shortest path between the source state and the target state.

    • Exit Actions: Executed for every state passed while moving upwards from the source state.
    • Entry Actions: Executed for every state passed while moving downwards to the target state.

    Important Note on State-Local Storage:

    • Exit actions operate on the state-local storage of the source state.
    • Entry actions operate on the state-local storage of the target state.
    • Modifying data in an exit action does not affect the data in the target state's storage.

    Example Transition Sequences:

    • From LedOn to NotBlinking (requires moving up to a common ancestor):
      1. Exit LedOn
      2. Exit Blinking
      3. Enter NotBlinking
    • From LedOn to LedOff (siblings under the same superstate):
      1. Exit LedOn
      2. Enter LedOff (The shared superstate Blinking is not exited or re-entered).
  4. Understand the #[state_machine] macro

    main

    The #[state_machine] proc-macro is a code generator. It parses your impl blocks and derives the necessary boilerplate to make the state machine functional.

    Key characteristics:

    • It does not change your existing code; it simply adds generated code to your source file.
    • It is used instead of a derive macro because Rust currently only allows derive macros on enums and structs, whereas statig needs to operate on impl blocks.
    • You can avoid the macro entirely by writing the State and Superstate trait implementations by hand.
  5. Manage data with shared and state-local storage

    main

    Statig provides two ways to manage data:

    1. Shared Storage: Any field present on the struct implementing the state machine is accessible to all states, superstates, and actions via &mut self.
    2. State-local Storage: Data that only exists within a specific state can be passed as an argument to the state handler. This data is also accessible to that state's superstates and actions. This avoids the need for Option<T> in shared storage.

    Note: When using state-local storage, you transition into the state by passing the data as an argument to the state constructor (e.g., Transition(State::led_on(10)).)

    // Shared storage example
    #[state]
    fn led_on(&mut self, event: &Event) -> Outcome<State> {
        self.led = false;
        Transition(State::led_off())
    }
    
    // State-local storage example
    #[state]
    fn led_on(counter: &mut u32, event: &Event) -> Outcome<State> {
        match event {
            Event::TimerElapsed => {
                *counter -= 1;
                if *counter == 0 { Transition(State::led_off()) } else { Handled }
            }
            Event::ButtonPressed => Transition(State::led_on(10))
        }
    }
  6. How superstates and state hierarchies work

    main

    Statig supports hierarchical state machines. You can define a hierarchy by using the superstate argument in the #[state] attribute. A state can defer an event to its parent by returning Outcome::Super. Superstates themselves are defined using the #[superstate] attribute and can also have their own superstates.

    #[state(superstate = "blinking")]
    fn led_on(event: &Event) -> Outcome<State> {
        match event {
            Event::TimerElapsed => Transition(State::led_off()),
            Event::ButtonPressed => Super // Defer to 'blinking'
        }
    }
    
    #[superstate]
    fn blinking(event: &Event) -> Outcome<State> {
        match event {
            Event::ButtonPressed => Transition(State::not_blinking()),
            _ => Super
        }
    }
  7. Enable async support for handlers and actions

    main

    By enabling the async feature, you can define async fn for your states and actions. The #[state_machine] macro will automatically detect these and generate an async state machine. When using async, you must .await the .handle() calls.

    #[state_machine(initial = "State::led_on()")]
    impl Blinky {
        #[state]
        async fn led_on(event: &Event) -> Outcome<State> {
            // ...
        }
    }
    
    async fn main() {
        let mut state_machine = Blinky::default().state_machine();
        state_machine.handle(&Event::TimerElapsed).await;
    }
  8. Implement a state machine with the `#[state_machine]` macro

    main

    To create a state machine, implement a struct and use the #[state_machine] attribute on its impl block. You must specify an initial state. Each state is defined as a method marked with #[state]. The macro generates a state_machine() method for your struct which returns a state machine instance that can process events via .handle().

    #[derive(Default)]
    pub struct Blinky;
    
    pub enum Event {
        TimerElapsed,
        ButtonPressed
    }
    
    #[state_machine(initial = "State::led_on()", ...)]
    impl Blinky {
        #[state]
        fn led_on(event: &Event) -> Outcome<State> {
            match event {
                Event::TimerElapsed => Transition(State::led_off()),
                _ => Super
            }
        }
        // ... other states
    }
    
    fn main() {
        let mut state_machine = Blinky::default().state_machine();
        state_machine.handle(&Event::TimerElapsed);
    }
  9. How state machine initialization works

    main

    In statig, state machines follow a lifecycle that requires explicit initialization to execute entry actions for the initial state. You can manage this lifecycle using three different types of state machine wrappers:

    1. UninitializedStateMachine<M>: Created via M::uninitialized_state_machine(). It has not yet executed any entry actions. You must call .init() or .init_with_context(&mut context) to consume it and produce an InitializedStateMachine<M>.
    2. InitializedStateMachine<M>: The standard running state machine. It is ready to handle events immediately. It is produced by calling .init() on an UninitializedStateMachine or by using a StateMachine that has already been initialized.
    3. StateMachine<M>: A lazy state machine. It tracks whether it has been initialized. If you call .handle() or .handle_with_context() on an uninitialized StateMachine, it will automatically initialize itself before processing the event.

    Note on Serialization: When using the serde feature, a serialized state machine (whether Initialized or not) will always deserialize into an UninitializedStateMachine. You must then call .init() to resume its execution.

    // Example: Transitioning from uninitialized to initialized
    let uninitialized = Blinky::default().uninitialized_state_machine();
    let mut initialized = uninitialized.init().await;
    
    // Example: Using a lazy StateMachine
    let mut lazy_sm = Blinky::default().state_machine();
    lazy_sm.handle(&Event).await; // Automatically initializes on first handle
  10. Serialize and Deserialize state machines with Serde

    main

    If the serde feature is enabled, state machines can be serialized and deserialized.

    Important Lifecycle Note:

    • An InitializedStateMachine serializes its state and storage.
    • When deserialized, it results in an UninitializedStateMachine.
    • You must call .init() or .init_with_context() on the deserialized UninitializedStateMachine to restore the machine to a functional, initialized state.
  11. Manage state machine lifecycle with UninitializedStateMachine

    main

    An UninitializedStateMachine<M> represents a state machine that has been constructed but whose entry actions have not yet been executed. To transition to an InitializedStateMachine<M>, you must call one of the following:

    • init(): Consumes the uninitialized machine and returns an InitializedStateMachine using a default context ().
    • init_with_context(context): Consumes the uninitialized machine and returns an InitializedStateMachine using the provided context.

    Note that UninitializedStateMachine can be deserialized (if serde is enabled), but it must be initialized before it can be used to handle events.

    let uninitialized = Blinky::default().uninitialized_state_machine();
    
    // Transition to InitializedStateMachine
    let mut initialized = uninitialized.init_with_context(&mut my_context);
  12. How StateMachine handles lazy initialization

    main

    A StateMachine<M> is a wrapper around your state machine logic that tracks whether it has been initialized.

    • Lazy Initialization: If you call handle() or handle_with_context() on a StateMachine that hasn't been initialized yet, it will automatically call the internal initialization logic (executing entry actions for the initial state) before processing the event.
    • Manual Initialization: You can force initialization by calling init() or init_with_context(context).
    // The first handle() call triggers initialization automatically
    state_machine.handle(&event);