|
| 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. |
0 commit comments