@@ -90,6 +90,11 @@ def generate_test_description():
9090 'snapshots.config_file' : snapshot_config ,
9191 'snapshots.timeout_sec' : 3.0 ,
9292 'snapshots.background_capture' : False , # On-demand for testing
93+ # Small bound so test_27's burst genuinely overflows the queue and
94+ # exercises the drop path (cap = pool_size + queue_depth = 3).
95+ 'snapshots.capture_pool_size' : 1 ,
96+ 'snapshots.capture_queue_depth' : 2 ,
97+ 'snapshots.capture_queue_full_policy' : 'reject_newest' ,
9398 'correlation.config_file' : correlation_config , # Enable correlation
9499 }],
95100 )
@@ -956,52 +961,52 @@ def test_26_cleared_fault_can_be_reactivated(self):
956961 self .assertEqual (len (fault .reporting_sources ), 2 )
957962 print (f'Cleared fault reactivated: occurrence_count={ fault .occurrence_count } ' )
958963
959- def test_27_capture_pool_survives_fault_storm (self ):
964+ def test_27_capture_pool_bounds_fault_storm (self , proc_output ):
960965 """
961- Burst of distinct CRITICAL faults; the node must stay responsive (no crash/UAF).
962-
963- Exercises concurrent capture through the bounded pool and clean teardown (issue #441).
964- We assert liveness + that snapshots land for at least some faults. Iterate ALL faults
965- when reading snapshots (rosbag uses a single ring buffer; only one of N gets rosbag
966- data, so do not assert all N captured rosbag data).
966+ Burst of distinct CRITICAL faults that overflows the bounded pool (issue #441).
967+
968+ The launch caps the pool small (pool_size=1, queue_depth=2 -> capacity 3). The
969+ STORM_FAULT_* codes capture /test/default_data; the publisher exists but is
970+ idle during the burst, so each on-demand capture blocks for the full snapshot
971+ timeout (3s). With a single worker stuck on each capture, a 12-fault burst
972+ overflows capacity deterministically (independent of how fast a capture would
973+ otherwise complete) and exercises the reject_newest drop path.
974+
975+ We assert the bound engaged (a "Capture queue full" drop was logged) and the
976+ node stayed responsive (no crash/UAF). That drop signal is the real regression
977+ guard: it is absent on the old unbounded thread-per-fault model. Capture-still-
978+ works is already covered by the snapshot tests above, so we deliberately do not
979+ re-assert a near-tautological "something captured" here.
967980 """
968- fault_codes = [f'STORM_FAULT_{ i } ' for i in range (8 )]
981+ fault_codes = [f'STORM_FAULT_{ i } ' for i in range (12 )]
969982
970983 # STORM_FAULT_* codes fall through to default_topics: [/test/default_data].
971- # Publish on that topic so the snapshot capture has data to collect.
984+ # Keep the publisher alive so the topic type resolves and the on-demand capture
985+ # subscribes - but do NOT publish during the burst, so each capture waits the
986+ # full timeout and the single worker stays busy long enough to overflow.
972987 default_pub = self .node .create_publisher (Temperature , '/test/default_data' , 10 )
973-
974988 temp_msg = Temperature ()
975989 temp_msg .temperature = 70.0
976990 temp_msg .variance = 0.1
991+ # Establish topic presence (let discovery propagate) before confirming faults.
992+ self ._publish_and_spin (default_pub , temp_msg , count = 10 , interval = 0.05 )
977993
978- # Establish topic presence before faults are confirmed.
979- self ._publish_and_spin (default_pub , temp_msg , count = 20 , interval = 0.05 )
980-
981- stop_publishing = threading .Event ()
982-
983- def keep_publishing ():
984- while not stop_publishing .is_set ():
985- default_pub .publish (temp_msg )
986- time .sleep (0.05 )
994+ # Synchronous sends (no lost responses): report_fault replies immediately after
995+ # enqueue, on the executor thread, independently of the busy capture worker.
996+ for code in fault_codes :
997+ request = ReportFault .Request ()
998+ request .fault_code = code
999+ request .event_type = ReportFault .Request .EVENT_FAILED
1000+ request .severity = Fault .SEVERITY_CRITICAL # immediate confirm
1001+ request .description = 'storm'
1002+ request .source_id = '/test_node'
1003+ response = self ._call_service (self .report_fault_client , request )
1004+ self .assertTrue (response .accepted )
9871005
988- pub_thread = threading .Thread (target = keep_publishing )
989- pub_thread .start ()
990- try :
991- for code in fault_codes :
992- request = ReportFault .Request ()
993- request .fault_code = code
994- request .event_type = ReportFault .Request .EVENT_FAILED
995- request .severity = Fault .SEVERITY_CRITICAL # immediate confirm
996- request .description = 'storm'
997- request .source_id = '/test_node'
998- response = self ._call_service (self .report_fault_client , request )
999- self .assertTrue (response .accepted )
1000-
1001- time .sleep (5.0 ) # let the bounded pool drain (snapshot timeout is 3s)
1002- finally :
1003- stop_publishing .set ()
1004- pub_thread .join ()
1006+ # The bound engaged: with capacity 3 and the worker stuck on 3s captures, the
1007+ # 12-fault burst overflowed and the pool dropped captures. The first drop logs
1008+ # immediately (later drops are throttled).
1009+ proc_output .assertWaitFor ('Capture queue full' , timeout = 15.0 )
10051010
10061011 # Node is still responsive after the storm (no crash/UAF).
10071012 list_request = ListFaults .Request ()
@@ -1011,22 +1016,7 @@ def keep_publishing():
10111016 list_response = self ._call_service (self .list_faults_client , list_request )
10121017 storm = [f for f in list_response .faults if f .fault_code .startswith ('STORM_FAULT_' )]
10131018 self .assertGreater (len (storm ), 0 , 'Storm faults not persisted' )
1014-
1015- # At least some faults captured topic data through the bounded pool.
1016- # Count only faults with a non-empty topics dict - the meaningful signal
1017- # that the capture path actually ran and received messages on /test/default_data.
1018- captured = 0
1019- for code in fault_codes :
1020- snap_request = GetSnapshots .Request ()
1021- snap_request .fault_code = code
1022- snap_request .topic = ''
1023- snap_response = self ._call_service (self .get_snapshots_client , snap_request )
1024- if snap_response .success and len (snap_response .data ) > 0 :
1025- data = json .loads (snap_response .data )
1026- if data .get ('topics' ):
1027- captured += 1
1028- print (f'Storm capture: { captured } /{ len (fault_codes )} faults captured topic data' )
1029- self .assertGreater (captured , 0 , 'No storm fault captured topic data on /test/default_data' )
1019+ print (f'Storm bounded: { len (storm )} faults confirmed, reject_newest drop path exercised' )
10301020
10311021
10321022@launch_testing .post_shutdown_test ()
0 commit comments