Skip to content

fix: internal transitions enter and exit the right states - #696

Open
fgmacedo wants to merge 4 commits into
mainfrom
fix/internal-self-transitions
Open

fgmacedo wants to merge 4 commits into
mainfrom
fix/internal-self-transitions

Conversation

@fgmacedo

Copy link
Copy Markdown
Owner

Why

Internal transitions (internal=True) produced wrong traces or corrupt configurations on main (bugs B3, B4, B7 of the UML/PSSM plan, Refs #692):

  • B3: on StateChart, shopping.to.itself(internal=True) ran on_enter_shopping without on_exit_shopping.
  • B4: on StateMachine (enable_self_transition_entries=False), open.to(open.review, internal=True) left the configuration as ['open'] (target never entered, the next event raised TransitionNotAllowed), and open.to.itself(internal=True) exited open without re-entering it, leaving ['draft'] without its parent.
  • B7: the flag docstring said "If False (default)" while the StateChart default is True, and docs/transitions.md said internal=True never runs exit/entry.

What changes

  • Atomic state, internal=True, targeting itself: no exit, no entry, only the effect, on StateChart and StateMachine, at any nesting depth (ancestors and sibling regions are left untouched).
  • Compound state, internal=True, targeting a descendant: the source stays active, the active child exits, the target and its default descendants enter, on both classes.
  • Compound or parallel state targeting itself with internal=True, and a parallel source targeting a descendant: exited and re-entered (SCXML, W3C test533 unchanged). Docs point to a targetless to(None) for an effect without leaving a compound state.
  • A multi-target transition (SCXML or native JSON/YAML) is never taken as the atomic no-op, and the SCXML reader treats type="internal" on an atomic source as external, as SCXML 3.13 specifies (onexit and onentry run).
  • enable_self_transition_entries no longer changes any outcome: it is deprecated, setting it on a subclass emits a DeprecationWarning at class creation, and StateMachine no longer overrides it (maintainer decision).
  • docs/transitions.md, docs/behaviour.md, docs/diagram.md, the upgrade guide and the Transition docstring describe the behavior above.

Evidence

New regression tests run on both engines (sm_runner) and both classes. Against the main engine and SCXML reader, with this branch's tests and docs:

$ git show main:statemachine/engines/base.py > statemachine/engines/base.py
$ git show main:statemachine/io/scxml/reader.py > statemachine/io/scxml/reader.py
$ timeout 60 uv run pytest tests/test_internal_transitions.py tests/scxml/test_internal_transitions.py docs/transitions.md -q --no-cov
11 failed, 20 passed

On this branch:

$ timeout 180 uv run pytest -n 4 --cov=statemachine --cov-branch --cov-report=term:skip-covered
TOTAL    6311      0   1988      0   100%
2808 passed, 25 skipped, 44 xfailed

ruff check, ruff format --check, mypy and pyright pass through the pre-commit hooks on every commit. The W3C test files are untouched.

Adversarial review by fresh subagents with no shared context, three rounds:

  1. Found a nested atomic internal self re-entering every ancestor (inside a parallel state it activated two states of one region, a regression for StateChart), and an internal transition to the source's own empty history state dropping the source. Fixed the first with a test; the second is pre-existing and belongs to the history work (below).
  2. Found a multi-target transition with an atomic first target dropping its other targets, the SCXML type="internal" deviation on atomic sources, and doc sentences claiming the source is never exited. All fixed with tests; the SCXML mapping was decided by the maintainer.
  3. Nothing material in the changed code. It reported that the diagram renderers drop internal transitions of compound and parallel sources (below).

Not verified

  • Diagram renderers (contrib/diagram/extract.py) still treat every internal=True transition as an in-state action, so open.to(open.escalated, internal=True) and open.to.itself(internal=True) draw no edge. Pre-existing on main, not changed here; a follow-up should mark as internal only an atomic single-target self-transition.
  • An internal transition to the source's own HistoryState with no recorded history and no default transition leaves the configuration inconsistent (main drops the source too). Left to the history work.
  • No 3.3.0 release notes entry for the deprecation and the behavior change; the release notes file does not exist yet.
  • docs/releases/upgrade_2x_to_3.md still says 2.x self-transitions skipped entry/exit; not checked against 2.x.

An atomic state targeting itself with internal=True ran its entry
actions without its exit actions on StateChart. With
enable_self_transition_entries=False (the StateMachine default), an
internal transition to a descendant never entered the target, and an
internal self-transition on a compound state exited it without
re-entering it, leaving the configuration without the parent.

The entry set now skips a target only when it is the transition domain,
the one state the transition never exits. Document internal transitions
on compound states and targetless transitions.

Refs #692

Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
With internal transitions fixed, the attribute no longer changes any
outcome: an external self-transition always runs exit and entry
actions, and an internal one is decided by the source kind. Keep the
attribute for compatibility, warn when a subclass sets it, and drop it
from StateMachine and from the docs comparison table.

Refs #692

Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
A nested atomic state targeting itself with internal=True re-entered
every ancestor, and inside a parallel state it also re-entered the
sibling regions, activating two states of the same region. Skip the
entry set of a transition whose domain is its own target.

Refs #692

Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
A multi-target transition whose first target is its atomic source was
taken as an atomic internal self-transition, dropping the other
targets; the no-op domain now requires a single target. The SCXML
reader ignores type="internal" on an atomic source, which SCXML treats
as external. Document which internal transitions exit their source.

Refs #692

Signed-off-by: Fernando Macedo <fgmacedo@gmail.com>
@fgmacedo fgmacedo added this to the 3.3.0 milestone Oct 14, 2026
@sonarqubecloud

Copy link
Copy Markdown

@codecov

codecov Bot commented Oct 14, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (d5282e0) to head (70a7c32).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #696   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           53        53           
  Lines         5897      5903    +6     
  Branches       940       941    +1     
=========================================
+ Hits          5897      5903    +6     
Flag Coverage Δ
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant