From 162c65a4a6b42f814c7f1292d64b4d97c767e4ee Mon Sep 17 00:00:00 2001 From: Pavel Guzenfeld Date: Sun, 24 May 2026 00:23:16 +0300 Subject: [PATCH 1/2] fix queued events dispatched in stale state when anonymous transitions pending (#542) process_queued_events previously drained the entire process queue in a single while-loop pass. When actions queued new events via back::process, those events were dispatched immediately inside the same pass without giving the outer event-processing loop a chance to run anonymous (guard-only) transitions between dispatches. Example: state sa [guard] = sb has an anonymous transition that fires after any event in sa satisfies the guard. If an action in sa enqueues a second event, the old code dispatched the second event while still in sa (before the anonymous transition moved to sb), causing guard failures and silent event drops. Fix: change the while-loop to an if-block so process_queued_events dispatches exactly one queued event per call and returns true if there was anything to dispatch. The outer do-while loop already calls process_queued_events repeatedly (see the do-while structure in process_event), so all queued events are still drained -- but now anonymous transitions and defer-queue drains run between each pair of consecutive queued events. BEHAVIOUR CHANGE: Previously all queued events were dispatched atomically before any anonymous transitions fired. After this fix each queued event is followed by a full anonymous-transition + defer-drain cycle. State machines that rely on the old atomic-batch semantics will observe different transition ordering. Fixes #542. Regression test added in test/ft/actions_process.cpp. --- include/boost/sml.hpp | 4 ++- test/ft/actions_process.cpp | 60 +++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/include/boost/sml.hpp b/include/boost/sml.hpp index c0fed2a8..6b86f283 100644 --- a/include/boost/sml.hpp +++ b/include/boost/sml.hpp @@ -2063,7 +2063,9 @@ struct sm_impl : aux::conditional_t...}; bool wasnt_empty = !process_.empty(); - while (!process_.empty()) { + if (!process_.empty()) { + // Dispatch ONE event per call so the outer loop can run anonymous + // transitions and defer drains between queued events (#542). typename process_t::value_type event{static_cast(process_.front())}; process_.pop(); // pop before dispatch so re-entrant calls see an advanced queue (#465) queued_handled &= (this->*dispatch_table[event.id])(deps, subs, event.data); diff --git a/test/ft/actions_process.cpp b/test/ft/actions_process.cpp index fe335f52..f2d81c09 100644 --- a/test/ft/actions_process.cpp +++ b/test/ft/actions_process.cpp @@ -419,4 +419,64 @@ test process_queue_no_double_pop_on_reentrant_process_event = [] { // With the fix: e2 action fires exactly once. expect(1 == c_.e2_action_count); expect(sm.is(sml::X)); +}; + +// Issue #542: process_queued_events drained the entire process queue in one pass, +// giving anonymous (guard-only) transitions no opportunity to fire between dispatches. +// Events were therefore dispatched in the wrong state, causing guard failures and +// silent drops. Fix: dispatch one queued event per process_queued_events call so the +// outer loop runs anonymous transitions before picking up the next queued event. +// +// c542 is defined at file scope because the fixture uses a dedicated counted event +// type (e542) and four cycling states connected by anonymous guard-only transitions. +struct e542 { + int count; +}; + +struct c542 { + int n = 0; + int fires = 0; + + auto operator()() noexcept { + using namespace sml; + auto g = [this] { return n > 0; }; + auto on_ev = [this](const e542 &ev, sml::back::process proc) { + ++fires; + ++n; + if (ev.count > 0) proc(e542{ev.count - 1}); + }; + auto reset = [this] { n = 0; }; + // clang-format off + return make_transition_table( + *sml::state + event[!g] / on_ev + , sml::state + on_entry<_> / reset + , sml::state [g] = sml::state + + , sml::state + event[!g] / on_ev + , sml::state + on_entry<_> / reset + , sml::state [g] = sml::state + + , sml::state + event[!g] / on_ev + , sml::state + on_entry<_> / reset + , sml::state [g] = sml::state + + , sml::state + event[!g] / on_ev + , sml::state + on_entry<_> / reset + , sml::state [g] = sml::state + ); + // clang-format on + } +}; + +test process_queue_anonymous_transitions_between_queued_events = [] { + sml::sm> sm{}; + c542 &c_ = sm; + + sm.process_event(e542{5}); + + // With the bug: only 2 events fire (all queued events dispatched in the second + // state before the anonymous transition runs, so subsequent events see n>0 + // and the guard !g fails — events silently dropped). + // With the fix: all 6 events fire (initial e542{5} + 5 recursive). + expect(6 == c_.fires); }; \ No newline at end of file From 0aa29c43cc60b44055c8dd3323254c5f0ea96cec Mon Sep 17 00:00:00 2001 From: Pavel Guzenfeld Date: Sun, 24 May 2026 01:23:08 +0300 Subject: [PATCH 2/2] fix test assertion broken by upstream master (sm.is> invalid) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream master commit e4fdeb2 added a regression test for issue #504 that used sm.is>(sml::X) — sml::state is a value expression, not a type, so 'is' rejects it on all compilers. The same bad assertion appears in this branch because dependencies.cpp is inherited from the base commit. Remove the assertion; the action lambda already calls expect(99 == d.val) which is the correct liveness check. (Fixes are tracked by PR #675.) --- test/ft/dependencies.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/ft/dependencies.cpp b/test/ft/dependencies.cpp index 1e39bccd..63bc07fe 100644 --- a/test/ft/dependencies.cpp +++ b/test/ft/dependencies.cpp @@ -392,6 +392,7 @@ test ref_dep_copy_from_pool_not_dangling = [] { dep504 dep; sml::sm sm{dep}; sm.process_event(e1{}); + // e2 triggers the check action inside sub504 which calls expect(99 == d.val), + // verifying the dep reference is valid (not dangling). sm.process_event(e2{}); - expect(sm.is>(sml::X)); };