From d2ddf1e5f01ae2f369383fe83fd8d03483a4c07c Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Fri, 15 May 2026 12:42:59 -0300 Subject: [PATCH 1/4] fix: race in Configuration.states cache invalidation under concurrent reads Snapshot the cache fields locally before the freshness check. Without the snapshot, another thread invalidating the cache between the ``self._cached is not None`` check and the ``return self._cached`` could cause the getter to return ``None``, leading to ``TypeError`` in callers that iterate the result (e.g., ``list(machine.configuration)``). Surfaced intermittently by tests/test_threading.py ::TestThreadSafety::test_concurrent_send_and_read_configuration on slower CI runners. Signed-off-by: Fernando Macedo --- statemachine/configuration.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/statemachine/configuration.py b/statemachine/configuration.py index c17123aa..6f1497cf 100644 --- a/statemachine/configuration.py +++ b/statemachine/configuration.py @@ -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() From 38f8eef2401cdbd02f78d622f23a07e82713701c Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Fri, 15 May 2026 18:27:14 -0300 Subject: [PATCH 2/4] fix: in-place mutation race in Configuration.add/discard ``Configuration.add()`` and ``discard()`` previously mutated the ``OrderedSet`` stored on the model in place, then rewrote the same reference. Two thread-safety problems followed: 1. A concurrent reader iterating ``.configuration`` (cache miss path inside the ``states`` getter) could crash with ``RuntimeError: Set changed size during iteration``. 2. Because ``setattr`` re-stored the *same* OrderedSet ref, the cache identity check ``self._cached_value is raw`` stayed True after the mutation, so a reader could briefly receive the stale cached ``OrderedSet[State]`` missing the new state. Both ``add`` and ``discard`` now copy the underlying set before mutating, producing a fresh ref each call. Readers either continue iterating the prior ref (no concurrent mutation) or recompute against the new ref (cache identity check naturally fails on the new object). Affects only ``StateChart``-style configurations where ``atomic_configuration_update=False`` (the default for parallel regions). The atomic update path used by ``StateMachine`` was never affected because it always builds a fresh OrderedSet. Regression tests in ``tests/test_threading.py::TestThreadSafety``: ``test_concurrent_parallel_region_send_and_read`` and the deterministic contract pin ``test_add_discard_produce_fresh_orderedset``. Signed-off-by: Fernando Macedo --- statemachine/configuration.py | 11 +++-- tests/test_threading.py | 77 +++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/statemachine/configuration.py b/statemachine/configuration.py index 6f1497cf..c5b47290 100644 --- a/statemachine/configuration.py +++ b/statemachine/configuration.py @@ -97,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) diff --git a/tests/test_threading.py b/tests/test_threading.py index b2d305e8..3cdbb343 100644 --- a/tests/test_threading.py +++ b/tests/test_threading.py @@ -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.""" @@ -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(): """ From 1cee1ec75e8d4d50660ca22fb81f01c488c49bdd Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Fri, 15 May 2026 18:27:36 -0300 Subject: [PATCH 3/4] docs: 3.1.1 release notes for thread-safety hardening Two cache-related races in ``Configuration`` introduced indirectly by the 3.1.0 performance optimisation are fixed in 3.1.1. Includes a performance comparison table; the 4.7x-7.7x speedup vs 3.0.0 declared in 3.1.0 release notes is unchanged. Signed-off-by: Fernando Macedo --- docs/releases/3.1.1.md | 58 ++++++++++++++++++++++++++++++++++++++++++ docs/releases/index.md | 1 + 2 files changed, 59 insertions(+) create mode 100644 docs/releases/3.1.1.md diff --git a/docs/releases/3.1.1.md b/docs/releases/3.1.1.md new file mode 100644 index 00000000..223c722b --- /dev/null +++ b/docs/releases/3.1.1.md @@ -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. diff --git a/docs/releases/index.md b/docs/releases/index.md index 3eeef892..f005f4d5 100644 --- a/docs/releases/index.md +++ b/docs/releases/index.md @@ -16,6 +16,7 @@ Requires Python 3.9+. ```{toctree} :maxdepth: 2 +3.1.1 3.1.0 3.0.0 From b242a3b8b517e552815dfc8f1a5f3909edd0588a Mon Sep 17 00:00:00 2001 From: Fernando Macedo Date: Fri, 15 May 2026 18:27:41 -0300 Subject: [PATCH 4/4] chore: prepare release 3.1.1 - Bump version in pyproject.toml, ``statemachine.__version__``, and uv.lock - Update Project-Id-Version in locale catalogs (en, pt_BR, hi_IN, zh_CN) Signed-off-by: Fernando Macedo --- pyproject.toml | 2 +- statemachine/__init__.py | 2 +- statemachine/locale/en/LC_MESSAGES/statemachine.po | 2 +- statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po | 2 +- statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po | 2 +- statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po | 2 +- uv.lock | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e48e1894..bc7c5447 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" }] diff --git a/statemachine/__init__.py b/statemachine/__init__.py index 7e0deac6..9a61ebb5 100644 --- a/statemachine/__init__.py +++ b/statemachine/__init__.py @@ -8,7 +8,7 @@ __author__ = """Fernando Macedo""" __email__ = "fgmacedo@gmail.com" -__version__ = "3.1.0" +__version__ = "3.1.1" __all__ = [ "StateChart", diff --git a/statemachine/locale/en/LC_MESSAGES/statemachine.po b/statemachine/locale/en/LC_MESSAGES/statemachine.po index cf6fb7ff..940cc12d 100644 --- a/statemachine/locale/en/LC_MESSAGES/statemachine.po +++ b/statemachine/locale/en/LC_MESSAGES/statemachine.po @@ -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" diff --git a/statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po b/statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po index b8abb746..fcc05628 100644 --- a/statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po +++ b/statemachine/locale/hi_IN/LC_MESSAGES/statemachine.po @@ -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" diff --git a/statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po b/statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po index 96803608..83afecb1 100644 --- a/statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po +++ b/statemachine/locale/pt_BR/LC_MESSAGES/statemachine.po @@ -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" diff --git a/statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po b/statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po index efd439df..44ba1f43 100644 --- a/statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po +++ b/statemachine/locale/zh_CN/LC_MESSAGES/statemachine.po @@ -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" diff --git a/uv.lock b/uv.lock index 64a9551e..92216220 100644 --- a/uv.lock +++ b/uv.lock @@ -1865,7 +1865,7 @@ wheels = [ [[package]] name = "python-statemachine" -version = "3.1.0" +version = "3.1.1" source = { editable = "." } [package.optional-dependencies]