@@ -134,6 +134,22 @@ class CyclingMachine(StateChart):
134134
135135 return CyclingMachine ()
136136
137+ @pytest .fixture ()
138+ def parallel_machine (self ):
139+ class TwoRegions (StateChart ):
140+ class both (State .Parallel ):
141+ class left (State .Compound ):
142+ l1 = State (initial = True )
143+ l2 = State ()
144+ tick_l = l1 .to (l2 ) | l2 .to (l1 )
145+
146+ class right (State .Compound ):
147+ r1 = State (initial = True )
148+ r2 = State ()
149+ tick_r = r1 .to (r2 ) | r2 .to (r1 )
150+
151+ return TwoRegions ()
152+
137153 @pytest .mark .parametrize ("num_threads" , [4 , 8 ])
138154 def test_concurrent_sends_no_lost_events (self , cycling_machine , num_threads ):
139155 """All events sent concurrently must be processed — none lost."""
@@ -294,6 +310,67 @@ def reader():
294310
295311 assert not errors , f"Thread errors: { errors } "
296312
313+ def test_concurrent_parallel_region_send_and_read (self , parallel_machine ):
314+ """Reading configuration while parallel-region events mutate it must not raise.
315+
316+ Regresses an in-place mutation of the model's ``OrderedSet`` during
317+ ``Configuration.add()`` / ``discard()``: a concurrent reader iterating
318+ ``.configuration`` could crash with
319+ ``RuntimeError: Set changed size during iteration`` or briefly observe
320+ a stale cached set missing the newly entered state.
321+ """
322+ num_senders = 4
323+ events_per_sender = 400
324+ barrier = threading .Barrier (num_senders + 1 )
325+ stop_event = threading .Event ()
326+ errors = []
327+
328+ def sender (event_name ):
329+ try :
330+ barrier .wait (timeout = 5 )
331+ for _ in range (events_per_sender ):
332+ parallel_machine .send (event_name )
333+ except Exception as e :
334+ errors .append (e )
335+
336+ def reader ():
337+ barrier .wait (timeout = 5 )
338+ while not stop_event .is_set ():
339+ try :
340+ # Force resolution + iteration each loop.
341+ _ = list (parallel_machine .configuration )
342+ _ = [s .id for s in parallel_machine .configuration ]
343+ except Exception as e :
344+ errors .append (e )
345+
346+ senders = []
347+ # Alternate tick_l / tick_r across threads so both regions mutate concurrently.
348+ for i in range (num_senders ):
349+ event = "tick_l" if i % 2 == 0 else "tick_r"
350+ senders .append (threading .Thread (target = sender , args = (event ,)))
351+ reader_thread = threading .Thread (target = reader )
352+
353+ for t in senders :
354+ t .start ()
355+ reader_thread .start ()
356+
357+ for t in senders :
358+ t .join (timeout = 30 )
359+ stop_event .set ()
360+ reader_thread .join (timeout = 5 )
361+
362+ assert not errors , f"Thread errors: { errors } "
363+
364+ def test_add_discard_produce_fresh_orderedset (self , parallel_machine ):
365+ """``add`` / ``discard`` must produce a fresh ``OrderedSet`` ref each call.
366+
367+ Pins the copy-on-write contract independently of timing: otherwise a
368+ reader holding the prior ref could observe mid-mutation.
369+ """
370+ snapshot = parallel_machine ._config .values
371+ parallel_machine .send ("tick_l" )
372+ assert parallel_machine ._config .values is not snapshot
373+
297374
298375async def test_regression_443_with_modifications_for_async_engine ():
299376 """
0 commit comments