Skip to content

Sibling Compound states with same-named inner attributes break in 3.1.0+ (regression bisected to #592) #624

@AndreyNenashev

Description

@AndreyNenashev

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.ag1.g1_a, g2.ag2.g2_a. Persisted value= strings can stay the
same, so the public state contract is preserved.

Possible fixes

In rough order of scope:

  1. 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.

  2. 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.

  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions