Skip to content

Commit fc3df8b

Browse files
committed
test(fault_manager): make the storm test overflow and assert the bound
The previous storm test sent 8 faults against the default capacity-18 pool, so the drop path never fired and the assertion (some capture landed) was near-tautological. Cap the pool small in the launch (pool_size=1, queue_depth=2) and keep the publisher idle during the burst so each on-demand capture blocks the full timeout; a 12-fault burst then overflows capacity deterministically. Assert the bound engaged via the logged 'Capture queue full' drop plus node liveness.
1 parent be03e88 commit fc3df8b

1 file changed

Lines changed: 42 additions & 52 deletions

File tree

src/ros2_medkit_fault_manager/test/test_integration.test.py

Lines changed: 42 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)