Repository navigation
Sibling Compound states with same-named inner attributes break in 3.1.0+ (regression bisected to #592) #624
Description
Upgrading from 3.0.0 to 3.1.x breaks any StateChart where two or more sibling
State.Compound classes declare inner State attributes under the same Python
name. Event dispatch evaluates against the wrong active state and either raises
TransitionNotAllowed or silently transitions into the wrong leaf. Distinct
value= strings on the inner States don't help, the resolution path no longer
uses them as the lookup key.
Affected: 3.1.0, 3.1.1, 3.1.2. Works on 3.0.0.
Environment
- macOS 15.5 (Darwin 25.5.0), arm64
- Python 3.13.2
- python-statemachine installed from PyPI, no optional extras
Reproduction
repro.py:
import asyncio
from statemachine import State, StateChart
class Chart(StateChart):
allow_event_without_transition = False
class g1(State.Compound):
a = State(initial=True, value="g1__a")
b = State(final=True, value="g1__b")
to_b = a.to(b, event="g1__b")
class g2(State.Compound):
a = State(initial=True, value="g2__a")
b = State(final=True, value="g2__b")
to_b = a.to(b, event="g2__b")
advance = g1.to(g2)
async def on_enter_state(self):
pass
async def main():
sm = Chart()
await sm.activate_initial_state()
print("Before:", list(sm.configuration_values))
await sm.send("g1__b")
print("After: ", list(sm.configuration_values))
asyncio.run(main())$ python repro.py
Before: ['g1', 'g1__a']
Traceback (most recent call last):
...
statemachine.exceptions.TransitionNotAllowed: Can't G1 b when in G1, A.
On 3.0.0 the same script prints:
Before: ['g1', 'g1__a']
After: ['g1', 'g1__b']
Cause
Bisected to 5a852098 (PR #592, "perf: optimize engine hot paths: 5x-7x event
throughput improvement", first released in 3.1.0).
That commit adds statemachine/configuration.py and a _build_configuration()
on StateMachine:
for state in self.states_map.values():
ist = InstanceState(state, self)
instance_states[state.id] = istConfiguration.states then resolves active states with
self._instance_states[self._states_map[v].id], a dict keyed by state.id -
the Python attribute name (a, b, running, …), rather than the qualified
path or the value. When sibling Compounds each declare an inner State under
the same attribute name, the build loop overwrites the same key repeatedly and
only the last-declared sibling's InstanceState ends up in the dict.
Subsequent resolutions return that one instance for both compounds, and
transition selection runs against the wrong state.
Before #592, StateMachine.configuration looked up active states via
states_map[value].for_instance(...), keyed by state.value, which is the
user-supplied persisted string and unique by construction. The follow-up
refactor in 67f2b0d (PR #599) kept the new instance_states[state.id] path,
which is why the same symptom persists in 3.1.1 and 3.1.2.
The duplicate id isn't checked during chart construction, so the class
compiles successfully and the collision only shows up at runtime - which makes
it easy to miss in topologies that exercise only one sibling at a time.
Workaround
Make every inner State attribute name unique across the whole chart, e.g.
g1.a → g1.g1_a, g2.a → g2.g2_a. Persisted value= strings can stay the
same, so the public state contract is preserved.
Possible fixes
In rough order of scope:
-
Key
instance_statesbystate.valueinstead ofstate.idin
_build_configuration()andConfiguration.states.state.valueis already
the key ofstates_mapand is unique by construction, so this restores the
3.0.0 resolution path without touching any other behaviour. -
Raise
InvalidDefinitionat metaclass build time when two states resolve to
the sameid. Surfaces the constraint at class-definition time rather than
at first dispatch. -
Auto-qualify
state.idfor nested states (e.g.g1.arather thana).
Closer to SCXML semantics, but visible in user output and event traces, so a
larger change.
I'm happy to put up a PR for any of these if it would help.
Activity
fix: key instance states by state.value, not state.id (#624) (#625)
chore: ignore .env and document 3.1.x fixes in the 3.2.0 notes