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 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/configuration.py b/statemachine/configuration.py index c17123aa..c5b47290 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() @@ -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) 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/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(): """ 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]