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] = ist
Configuration.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_states by state.value instead of state.id in
_build_configuration() and Configuration.states. state.value is already
the key of states_map and is unique by construction, so this restores the
3.0.0 resolution path without touching any other behaviour.
-
Raise InvalidDefinition at metaclass build time when two states resolve to
the same id. Surfaces the constraint at class-definition time rather than
at first dispatch.
-
Auto-qualify state.id for nested states (e.g. g1.a rather than a).
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.
Upgrading from 3.0.0 to 3.1.x breaks any
StateChartwhere two or more siblingState.Compoundclasses declare innerStateattributes under the same Pythonname. Event dispatch evaluates against the wrong active state and either raises
TransitionNotAllowedor silently transitions into the wrong leaf. Distinctvalue=strings on the inner States don't help, the resolution path no longeruses them as the lookup key.
Affected: 3.1.0, 3.1.1, 3.1.2. Works on 3.0.0.
Environment
Reproduction
repro.py:On 3.0.0 the same script prints:
Cause
Bisected to
5a852098(PR #592, "perf: optimize engine hot paths: 5x-7x eventthroughput improvement", first released in 3.1.0).
That commit adds
statemachine/configuration.pyand a_build_configuration()on
StateMachine:Configuration.statesthen resolves active states withself._instance_states[self._states_map[v].id], a dict keyed bystate.id-the Python attribute name (
a,b,running, …), rather than the qualifiedpath or the value. When sibling Compounds each declare an inner
Stateunderthe same attribute name, the build loop overwrites the same key repeatedly and
only the last-declared sibling's
InstanceStateends up in the dict.Subsequent resolutions return that one instance for both compounds, and
transition selection runs against the wrong state.
Before #592,
StateMachine.configurationlooked up active states viastates_map[value].for_instance(...), keyed bystate.value, which is theuser-supplied persisted string and unique by construction. The follow-up
refactor in
67f2b0d(PR #599) kept the newinstance_states[state.id]path,which is why the same symptom persists in 3.1.1 and 3.1.2.
The duplicate
idisn't checked during chart construction, so the classcompiles 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
Stateattribute name unique across the whole chart, e.g.g1.a→g1.g1_a,g2.a→g2.g2_a. Persistedvalue=strings can stay thesame, 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 alreadythe key of
states_mapand is unique by construction, so this restores the3.0.0 resolution path without touching any other behaviour.
Raise
InvalidDefinitionat metaclass build time when two states resolve tothe same
id. Surfaces the constraint at class-definition time rather thanat 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.