Skip to content

Commit 7c495bb

Browse files
committed
Merge branch 'release/3.1.1'
2 parents c6f6262 + e33f34a commit 7c495bb

12 files changed

Lines changed: 161 additions & 15 deletions

File tree

docs/releases/3.1.1.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# StateChart 3.1.1
2+
3+
*May 15, 2026*
4+
5+
## Bug fixes in 3.1.1
6+
7+
### Thread-safety hardening of the configuration cache
8+
9+
Two races in `Configuration` (introduced indirectly by the cache + no-copy
10+
design in 3.1.0) have been fixed. Both surfaced under concurrent reads of
11+
`machine.configuration` while another thread is sending events to the same
12+
state machine instance, a scenario explicitly supported by the sync engine.
13+
14+
1. **Cache read race.** `Configuration.states` checked
15+
`self._cached is not None` and then returned `self._cached`. Another
16+
thread invalidating between the check and the return could cause the
17+
property to return `None`, leading to a `TypeError` in callers that
18+
iterate the result (e.g., `list(machine.configuration)`). The getter now
19+
snapshots the cache fields locally before the freshness check.
20+
[#620](https://github.com/fgmacedo/python-statemachine/pull/620).
21+
22+
2. **In-place mutation race.** `Configuration.add()` and
23+
`Configuration.discard()` mutated the `OrderedSet` stored on the model
24+
in place and rewrote the same reference. A concurrent reader iterating
25+
`.configuration` could observe a partially mutated set (raising
26+
`RuntimeError: Set changed size during iteration`) or read back a stale
27+
cached resolution missing the new state. Both methods now use
28+
copy-on-write, producing a fresh `OrderedSet` per call. This affects
29+
only `StateChart` (where `atomic_configuration_update=False` is the
30+
default to support parallel regions). The atomic update path used by
31+
`StateMachine` was never affected.
32+
[#620](https://github.com/fgmacedo/python-statemachine/pull/620).
33+
34+
Both fixes are covered by new stress tests in
35+
`tests/test_threading.py::TestThreadSafety`:
36+
`test_concurrent_send_and_read_configuration` and
37+
`test_concurrent_parallel_region_send_and_read`, plus a deterministic
38+
copy-on-write contract test `test_add_discard_produce_fresh_orderedset`.
39+
40+
### Performance impact
41+
42+
Copy-on-write in `add()` / `discard()` reintroduces an O(n) shallow copy of
43+
the active configuration on every state entry and exit. For the typical
44+
configuration sizes used in practice (1–7 states), this is sub-microsecond.
45+
46+
Measured on macOS / Python 3.14, pytest-benchmark median, vs 3.1.0:
47+
48+
| Benchmark | 3.1.0 | 3.1.1 | Δ |
49+
|------------------------------------|-------------|-------------|--------|
50+
| `test_parallel_region_events` | 175.2 μs | 184.5 μs | +5.3% |
51+
| `test_many_transitions_reset` | 125.9 μs | 139.5 μs | +10.9% |
52+
| `test_guarded_transitions` | 70.0 μs | 75.7 μs | +8.2% |
53+
| `test_history_pause_resume` | 88.4 μs | 91.4 μs | +3.4% |
54+
| `test_many_transitions_full_cycle` | 156.9 μs | 162.1 μs | +3.3% |
55+
| `test_flat_self_transition` | 38.7 μs | 39.1 μs | +1.0% |
56+
57+
Overall 4.7x–7.7x event throughput improvement vs 3.0.0 (declared in
58+
[3.1.0 release notes](3.1.0.md)) is unchanged.

docs/releases/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Requires Python 3.9+.
1616
```{toctree}
1717
:maxdepth: 2
1818
19+
3.1.1
1920
3.1.0
2021
3.0.0
2122

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "python-statemachine"
3-
version = "3.1.0"
3+
version = "3.1.1"
44
description = "Python Finite State Machines made easy."
55
authors = [{ name = "Fernando Macedo", email = "fgmacedo@gmail.com" }]
66
maintainers = [{ name = "Fernando Macedo", email = "fgmacedo@gmail.com" }]

statemachine/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
__author__ = """Fernando Macedo"""
1010
__email__ = "fgmacedo@gmail.com"
11-
__version__ = "3.1.0"
11+
__version__ = "3.1.1"
1212

1313
__all__ = [
1414
"StateChart",

statemachine/configuration.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,13 @@ def values(self) -> OrderedSet[Any]:
7373
def states(self) -> "OrderedSet[State]":
7474
"""The set of currently active :class:`State` instances (cached)."""
7575
raw = self.value
76-
if self._cached is not None and self._cached_value is raw:
77-
return self._cached
76+
# Snapshot the cache fields locally — another thread may call
77+
# ``_invalidate()`` between the freshness check and the return,
78+
# so reading ``self._cached`` twice would risk returning ``None``.
79+
cached = self._cached
80+
cached_value = self._cached_value
81+
if cached is not None and cached_value is raw:
82+
return cached
7883
if raw is None:
7984
return OrderedSet()
8085

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

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

100108
def discard(self, state: "State"):
101-
"""Remove *state* from the configuration."""
102-
values = self._read_from_model()
109+
"""Remove *state* from the configuration (copy-on-write for thread safety)."""
110+
values = OrderedSet(self._read_from_model())
103111
values.discard(state.value)
104112
self._write_to_model(values)
105113

statemachine/locale/en/LC_MESSAGES/statemachine.po

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
#
44
msgid ""
55
msgstr ""
6-
"Project-Id-Version: 3.1.0\n"
6+
"Project-Id-Version: 3.1.1\n"
77
"Report-Msgid-Bugs-To: fgmacedo@gmail.com\n"
88
"POT-Creation-Date: 2026-05-15 12:08-0300\n"
99
"PO-Revision-Date: 2026-02-24 14:31-0300\n"

statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
#
44
msgid ""
55
msgstr ""
6-
"Project-Id-Version: 3.1.0\n"
6+
"Project-Id-Version: 3.1.1\n"
77
"Report-Msgid-Bugs-To: fgmacedo@gmail.com\n"
88
"POT-Creation-Date: 2026-05-15 12:08-0300\n"
99
"PO-Revision-Date: 2024-06-07 17:41-0300\n"

statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
#
44
msgid ""
55
msgstr ""
6-
"Project-Id-Version: 3.1.0\n"
6+
"Project-Id-Version: 3.1.1\n"
77
"Report-Msgid-Bugs-To: fgmacedo@gmail.com\n"
88
"POT-Creation-Date: 2026-05-15 12:08-0300\n"
99
"PO-Revision-Date: 2024-06-07 17:41-0300\n"

statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
#
44
msgid ""
55
msgstr ""
6-
"Project-Id-Version: 3.1.0\n"
6+
"Project-Id-Version: 3.1.1\n"
77
"Report-Msgid-Bugs-To: fgmacedo@gmail.com\n"
88
"POT-Creation-Date: 2026-05-15 12:08-0300\n"
99
"PO-Revision-Date: 2024-06-07 17:41-0300\n"

tests/test_invoke.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -614,8 +614,10 @@ class SM(StateChart):
614614
done_invoke_loading = loading.to(ready)
615615

616616
sm = await sm_runner.start(SM)
617-
await sm_runner.sleep(0.15)
618-
await sm_runner.processing_loop(sm)
617+
deadline = time.monotonic() + 2.0
618+
while "ready" not in sm.configuration_values and time.monotonic() < deadline:
619+
await sm_runner.sleep(0.02)
620+
await sm_runner.processing_loop(sm)
619621

620622
assert "ready" in sm.configuration_values
621623
# All invocations should be terminated by now

0 commit comments

Comments
 (0)