Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/releases/3.1.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# StateChart 3.1.1

*May 15, 2026*

## Bug fixes in 3.1.1

### Thread-safety hardening of the configuration cache

Two races in `Configuration` (introduced indirectly by the cache + no-copy
design in 3.1.0) have been fixed. Both surfaced under concurrent reads of
`machine.configuration` while another thread is sending events to the same
state machine instance, a scenario explicitly supported by the sync engine.

1. **Cache read race.** `Configuration.states` checked
`self._cached is not None` and then returned `self._cached`. Another
thread invalidating between the check and the return could cause the
property to return `None`, leading to a `TypeError` in callers that
iterate the result (e.g., `list(machine.configuration)`). The getter now
snapshots the cache fields locally before the freshness check.
[#620](https://github.com/fgmacedo/python-statemachine/pull/620).

2. **In-place mutation race.** `Configuration.add()` and
`Configuration.discard()` mutated the `OrderedSet` stored on the model
in place and rewrote the same reference. A concurrent reader iterating
`.configuration` could observe a partially mutated set (raising
`RuntimeError: Set changed size during iteration`) or read back a stale
cached resolution missing the new state. Both methods now use
copy-on-write, producing a fresh `OrderedSet` per call. This affects
only `StateChart` (where `atomic_configuration_update=False` is the
default to support parallel regions). The atomic update path used by
`StateMachine` was never affected.
[#620](https://github.com/fgmacedo/python-statemachine/pull/620).

Both fixes are covered by new stress tests in
`tests/test_threading.py::TestThreadSafety`:
`test_concurrent_send_and_read_configuration` and
`test_concurrent_parallel_region_send_and_read`, plus a deterministic
copy-on-write contract test `test_add_discard_produce_fresh_orderedset`.

### Performance impact

Copy-on-write in `add()` / `discard()` reintroduces an O(n) shallow copy of
the active configuration on every state entry and exit. For the typical
configuration sizes used in practice (1–7 states), this is sub-microsecond.

Measured on macOS / Python 3.14, pytest-benchmark median, vs 3.1.0:

| Benchmark | 3.1.0 | 3.1.1 | Δ |
|------------------------------------|-------------|-------------|--------|
| `test_parallel_region_events` | 175.2 μs | 184.5 μs | +5.3% |
| `test_many_transitions_reset` | 125.9 μs | 139.5 μs | +10.9% |
| `test_guarded_transitions` | 70.0 μs | 75.7 μs | +8.2% |
| `test_history_pause_resume` | 88.4 μs | 91.4 μs | +3.4% |
| `test_many_transitions_full_cycle` | 156.9 μs | 162.1 μs | +3.3% |
| `test_flat_self_transition` | 38.7 μs | 39.1 μs | +1.0% |

Overall 4.7x–7.7x event throughput improvement vs 3.0.0 (declared in
[3.1.0 release notes](3.1.0.md)) is unchanged.
1 change: 1 addition & 0 deletions docs/releases/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Requires Python 3.9+.
```{toctree}
:maxdepth: 2

3.1.1
3.1.0
3.0.0

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "python-statemachine"
version = "3.1.0"
version = "3.1.1"
description = "Python Finite State Machines made easy."
authors = [{ name = "Fernando Macedo", email = "fgmacedo@gmail.com" }]
maintainers = [{ name = "Fernando Macedo", email = "fgmacedo@gmail.com" }]
Expand Down
2 changes: 1 addition & 1 deletion statemachine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

__author__ = """Fernando Macedo"""
__email__ = "fgmacedo@gmail.com"
__version__ = "3.1.0"
__version__ = "3.1.1"

__all__ = [
"StateChart",
Expand Down
20 changes: 14 additions & 6 deletions statemachine/configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,13 @@ def values(self) -> OrderedSet[Any]:
def states(self) -> "OrderedSet[State]":
"""The set of currently active :class:`State` instances (cached)."""
raw = self.value
if self._cached is not None and self._cached_value is raw:
return self._cached
# Snapshot the cache fields locally — another thread may call
# ``_invalidate()`` between the freshness check and the return,
# so reading ``self._cached`` twice would risk returning ``None``.
cached = self._cached
cached_value = self._cached_value
if cached is not None and cached_value is raw:
return cached
if raw is None:
return OrderedSet()

Expand All @@ -92,14 +97,17 @@ def states(self, new_configuration: "OrderedSet[State]"):
# -- Incremental mutation (used by the engine) -----------------------------

def add(self, state: "State"):
"""Add *state* to the configuration."""
values = self._read_from_model()
"""Add *state* to the configuration (copy-on-write for thread safety)."""
# Copy so we never mutate the OrderedSet still held by concurrent
# readers or by the cache identity check. ``_read_from_model`` may
# return the same ref stored on the model.
values = OrderedSet(self._read_from_model())
values.add(state.value)
self._write_to_model(values)

def discard(self, state: "State"):
"""Remove *state* from the configuration."""
values = self._read_from_model()
"""Remove *state* from the configuration (copy-on-write for thread safety)."""
values = OrderedSet(self._read_from_model())
values.discard(state.value)
self._write_to_model(values)

Expand Down
2 changes: 1 addition & 1 deletion statemachine/locale/en/LC_MESSAGES/statemachine.po
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
#
msgid ""
msgstr ""
"Project-Id-Version: 3.1.0\n"
"Project-Id-Version: 3.1.1\n"
"Report-Msgid-Bugs-To: fgmacedo@gmail.com\n"
"POT-Creation-Date: 2026-05-15 12:08-0300\n"
"PO-Revision-Date: 2026-02-24 14:31-0300\n"
Expand Down
2 changes: 1 addition & 1 deletion statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
#
msgid ""
msgstr ""
"Project-Id-Version: 3.1.0\n"
"Project-Id-Version: 3.1.1\n"
"Report-Msgid-Bugs-To: fgmacedo@gmail.com\n"
"POT-Creation-Date: 2026-05-15 12:08-0300\n"
"PO-Revision-Date: 2024-06-07 17:41-0300\n"
Expand Down
2 changes: 1 addition & 1 deletion statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
#
msgid ""
msgstr ""
"Project-Id-Version: 3.1.0\n"
"Project-Id-Version: 3.1.1\n"
"Report-Msgid-Bugs-To: fgmacedo@gmail.com\n"
"POT-Creation-Date: 2026-05-15 12:08-0300\n"
"PO-Revision-Date: 2024-06-07 17:41-0300\n"
Expand Down
2 changes: 1 addition & 1 deletion statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
#
msgid ""
msgstr ""
"Project-Id-Version: 3.1.0\n"
"Project-Id-Version: 3.1.1\n"
"Report-Msgid-Bugs-To: fgmacedo@gmail.com\n"
"POT-Creation-Date: 2026-05-15 12:08-0300\n"
"PO-Revision-Date: 2024-06-07 17:41-0300\n"
Expand Down
77 changes: 77 additions & 0 deletions tests/test_threading.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,22 @@ class CyclingMachine(StateChart):

return CyclingMachine()

@pytest.fixture()
def parallel_machine(self):
class TwoRegions(StateChart):
class both(State.Parallel):
class left(State.Compound):
l1 = State(initial=True)
l2 = State()
tick_l = l1.to(l2) | l2.to(l1)

class right(State.Compound):
r1 = State(initial=True)
r2 = State()
tick_r = r1.to(r2) | r2.to(r1)

return TwoRegions()

@pytest.mark.parametrize("num_threads", [4, 8])
def test_concurrent_sends_no_lost_events(self, cycling_machine, num_threads):
"""All events sent concurrently must be processed — none lost."""
Expand Down Expand Up @@ -294,6 +310,67 @@ def reader():

assert not errors, f"Thread errors: {errors}"

def test_concurrent_parallel_region_send_and_read(self, parallel_machine):
"""Reading configuration while parallel-region events mutate it must not raise.

Regresses an in-place mutation of the model's ``OrderedSet`` during
``Configuration.add()`` / ``discard()``: a concurrent reader iterating
``.configuration`` could crash with
``RuntimeError: Set changed size during iteration`` or briefly observe
a stale cached set missing the newly entered state.
"""
num_senders = 4
events_per_sender = 400
barrier = threading.Barrier(num_senders + 1)
stop_event = threading.Event()
errors = []

def sender(event_name):
try:
barrier.wait(timeout=5)
for _ in range(events_per_sender):
parallel_machine.send(event_name)
except Exception as e:
errors.append(e)

def reader():
barrier.wait(timeout=5)
while not stop_event.is_set():
try:
# Force resolution + iteration each loop.
_ = list(parallel_machine.configuration)
_ = [s.id for s in parallel_machine.configuration]
except Exception as e:
errors.append(e)

senders = []
# Alternate tick_l / tick_r across threads so both regions mutate concurrently.
for i in range(num_senders):
event = "tick_l" if i % 2 == 0 else "tick_r"
senders.append(threading.Thread(target=sender, args=(event,)))
reader_thread = threading.Thread(target=reader)

for t in senders:
t.start()
reader_thread.start()

for t in senders:
t.join(timeout=30)
stop_event.set()
reader_thread.join(timeout=5)

assert not errors, f"Thread errors: {errors}"

def test_add_discard_produce_fresh_orderedset(self, parallel_machine):
"""``add`` / ``discard`` must produce a fresh ``OrderedSet`` ref each call.

Pins the copy-on-write contract independently of timing: otherwise a
reader holding the prior ref could observe mid-mutation.
"""
snapshot = parallel_machine._config.values
parallel_machine.send("tick_l")
assert parallel_machine._config.values is not snapshot


async def test_regression_443_with_modifications_for_async_engine():
"""
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading