Skip to content

defer within nested state machines isn't dropped upon exit of nested state machine #253

Closed

Description

@chris-se

I'm trying to use defer within a nested state machine. In the simplest sequence of events, this will work, but the following example shows behavior that is undesirable for me:

Example:

#include <boost/sml.hpp>
#include <iostream>
#include <deque>
#include <queue>
#include <cassert>

namespace sml = boost::sml;

using namespace sml;

struct e1 {};
struct TO_S2 {};
struct TO_S1 {};

struct ENTER {};
struct EXIT {};

struct Nested {
    auto operator()() const noexcept {
        return make_transition_table(
             *"s1"_s + event<TO_S2> / [] () { std::cout << "s1 + TO_S2 = s2" << std::endl; } = "s2"_s
            , "s2"_s + event<e1> / defer
            , "s1"_s + event<e1> / [] () { std::cout << "s1 + e1 [internal]" << std::endl; }
            , "s2"_s + event<TO_S1> / [] () { std::cout << "s2 + TO_S1 = s1" << std::endl; } = "s1"_s
        );
    }
};

struct Outer {
    auto operator()() const noexcept {
        return make_transition_table(
             *"Initial"_s + event<ENTER> / [] () { std::cout << "Initial + ENTER = Nested" << std::endl; } = state<Nested>
            , state<Nested> + event<EXIT> / [] () { std::cout << "Nested + EXIT = Initial" << std::endl; } = "Initial"_s
            , "Initial"_s + event<e1> / [] () { std::cout << "Initial: processing E1" << std::endl; }
        );
    }
};

int main()
{
    sm<Outer, sml::defer_queue<std::deque>, sml::process_queue<std::queue>> sm{};

    // Works as expected
    sm.process_event(e1{});
    sm.process_event(ENTER{});
    assert(sm.is<decltype(state<Nested>)>("s1"_s));
    sm.process_event(TO_S2{});
    sm.process_event(e1{});
    sm.process_event(TO_S1{});
    sm.process_event(EXIT{});
    
    // Undesirable Behavior
    sm.process_event(ENTER{});
    assert(sm.is<decltype(state<Nested>)>("s1"_s));
    sm.process_event(TO_S2{});
    sm.process_event(e1{}); // remove this line to see a difference
    sm.process_event(EXIT{});
    sm.process_event(ENTER{});
    assert(sm.is<decltype(state<Nested>)>("s1"_s));
#if 1
    sm.process_event(TO_S2{});
    sm.process_event(TO_S1{});
#endif
    return 0;
}

There is a transition from the state s1 of the nested state machine Nested when the event e1 occurs. There is also a deferment of the event when the Nested state machine is in the state s2. When the Nested state machine is entered and the event is generated in s2, and then a transition to s1 is made, this works perfectly fine.

However, if I defer an event e1 while the Nested state machine is in s2, and then leave the nested state machine to return to the outer state machine, the following happens:

  • The state machine does not handle the event e1 in the outer state machine. (The "Initial: processing E1" action is not executed.) This could be expected behavior, if defer is defined to only affect the context of the state machine it's placed in.
  • When I enter the Nested state machine again, by default it will be in state s1, but the previously deferred event is not triggered. I believe that to be correct, because the event should have been dropped once the Nested state machine was exited.
  • However, if I perform a transition within the Nested state machine back to the s1 state (by first transitioning to another state), the previously queued event will now in fact be handled -- the last line of the example code is s1 + e1 [internal], which is the previously deferred event.

(Change #if 1 to #if 0 to see that in that case the event e1 is not handled at the end of the code, even though it's the first time that the Nested state machine is in s1 again after the defer.)

My intuition tells me that the defer queue of a Nested state machine should be cleared once the state machine is exited, so that re-entering the state machine really starts fresh in its initial state. Or that if the queue isn't cleared, that it should be applied to the main state machine instead, when the transition out of the nested state machine occurs, so I could at least add some handlers to clean up these kinds of events if they leak out. (Though that would break abstraction to a certain extent.)

However, even if you were to argue that the nested state machine were to keep the deferred events (which I don't think is useful), then the event should be handled when the nested state machine is entered (the first time it's in s1 again after the deferment), not only after an internal transition to s1.

I'd write a patch to drop the deferred queue upon entering a sub state machine, but I have to admit that I'm a bit lost in the internal design of Boost.SML. Is there any design document out there that describes the basic structure in form of an overview?

Activity

  1. changed the title [-]defer within nested state machines doesn't track nested sub-state[/-] [+]defer within nested state machines isn't dropped upon exit of nested state machine[/+] on Feb 25, 2019
  2. added a commit that references this issue on Feb 23, 2026
  3. PavelGuzenfeld commented on May 28, 2026

    @PavelGuzenfeld
    Contributor

    Status on HEAD 84dbf15 (2026-05-28): STILL PRESENT

    The stale-deferred-event bug is reproducible on current master.

    Minimal reproducer:

    #include <boost/sml.hpp>
    #include <deque>
    #include <queue>
    #include <cassert>
    namespace sml = boost::sml;
    using namespace sml;
    
    struct e1 {};
    struct TO_S2 {};
    struct TO_S1 {};
    struct ENTER {};
    struct EXIT {};
    
    static int e1_fires = 0;
    
    struct Nested {
      auto operator()() const noexcept {
        return make_transition_table(
           *"s1"_s + event<TO_S2>                              = "s2"_s,
            "s2"_s + event<e1>    / defer,
            "s1"_s + event<e1>    / [] { e1_fires++; },
            "s2"_s + event<TO_S1>                              = "s1"_s
        );
      }
    };
    struct Outer {
      auto operator()() const noexcept {
        return make_transition_table(
           *"Init"_s + event<ENTER>  = state<Nested>,
            state<Nested> + event<EXIT> = "Init"_s
        );
      }
    };
    
    int main() {
      sm<Outer, defer_queue<std::deque>, process_queue<std::queue>> sm;
    
      // Session 1: defer e1 in s2, then EXIT the nested SM
      sm.process_event(ENTER{});   // → Nested/s1
      sm.process_event(TO_S2{});   // s1 → s2
      sm.process_event(e1{});      // deferred in s2 (stays in outer defer queue)
      sm.process_event(EXIT{});    // exit Nested → Init; defer queue still contains e1
    
      // Session 2: re-enter, do s1→s2→s1 round-trip
      sm.process_event(ENTER{});   // → Nested/s1
      e1_fires = 0;
      sm.process_event(TO_S2{});
      sm.process_event(TO_S1{});   // s2→s1 → stale deferred e1 re-fires (BUG)
    
      assert(e1_fires == 0);       // FAILS: e1_fires == 1
    }

    Root cause:

    defer always pushes to the root SM's defer queue (the one declared via defer_queue<std::deque> on the outer SM). The nested SM has no queue of its own. When the outer SM exits the Nested composite state, the defer queue is not cleared or trimmed.

    process_defer_events replays all queued events on the next outer-SM event dispatch. The replay ignores which state or sub-SM enqueued each event — it simply dispatches to the current state. When the outer SM is back in Nested/s1, the old deferred e1 is dispatched and handled by s1 + event<e1>.

    Three distinct behaviors described in the original report — all confirmed:

    1. After EXIT, the deferred e1 is not replayed in Init (correct — Init has no handler).
    2. When re-entering Nested and staying in s1, the deferred e1 is not replayed immediately (incorrect deferral semantics; it should fire here if it's going to fire at all).
    3. After doing an internal s1→s2→s1 round-trip inside the re-entered Nested, the stale e1 is replayed — because the internal transition triggers process_defer_events.

    Proposed fix direction:

    Record a "high-water mark" (queue size) when entering a composite sub-state, and truncate the defer queue back to that mark when exiting the composite state. This would discard all events that were deferred within the sub-SM's lifetime.

    This requires threading the enter/exit hooks for composite states through to the queue trim, which is non-trivial but confined to update_current_state / on_exit logic.

    A simpler stop-gap: expose a way to clear the defer queue from user code (e.g., as an action), so users can manually drain it on exit from a composite state.

  4. PavelGuzenfeld commented on Oct 17, 2026

    @PavelGuzenfeld
    Contributor

    Fixed by #705, the repro no longer fires the stale deferred event on master bc13fcb.

  5. PavelGuzenfeld commented on Oct 17, 2026

    @PavelGuzenfeld
    Contributor

    Fixed by #705. The repro no longer fires the stale deferred event on master bc13fcb. Can be closed.

  6. PavelGuzenfeld commented on Oct 17, 2026

    @PavelGuzenfeld
    Contributor

    @kris-jusiak can you close this? Fixed or answered, see the comment above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions