From 6413ba1560be590edda9d3773ac98030e1ae8f01 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 12 Sep 2026 20:37:21 +0200 Subject: [PATCH 1/6] test(tsan): suppress the GenericClient future result teardown race The future shared state behind a GenericClient response is freed by whichever thread drops the last reference to it. When that is a gateway executor thread, ~_State_base and the _Result::_M_destroy() it runs happen inside librclcpp, which carries no TSan instrumentation: the operator delete is intercepted in the allocator, but the acquire on the shared_ptr refcount that orders it after the caller's future::get() is not, so TSan pairs the free with the caller's read of _Result::_M_error. Anchor the entry on the libstdc++ frame that performs that read. It is the only frame of the pair that is reliably symbolized: librclcpp is stripped, so the freeing frame carries no symbol name for a race: pattern to match. --- tsan_suppressions.txt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tsan_suppressions.txt b/tsan_suppressions.txt index 1d66c38be..9682d0b24 100644 --- a/tsan_suppressions.txt +++ b/tsan_suppressions.txt @@ -79,6 +79,18 @@ deadlock:rclcpp::Context* race:std::__future_base::_State_baseV2* race:std::__uniq_ptr_impl*_Result_base* race:std::unique_ptr*_Result_base* +# Same shared state, its _Result object rather than the state itself. The last +# reference to the state is dropped by whichever thread finishes with the +# promise; when that is the rclcpp executor thread, ~_State_base and the +# _Result::_M_destroy() it runs live inside librclcpp, which is not +# instrumented. TSan sees the operator delete, because that is intercepted in +# the allocator, but not the acquire on the shared_ptr refcount that orders it +# after the caller's future::get(), so it pairs the free with the caller's read +# of _Result::_M_error. Anchored on the libstdc++ frame that performs that read: +# it is the only frame of the pair that is reliably symbolized, since librclcpp +# is stripped and the freeing frame prints as at librclcpp.so+0x..., +# which no race: pattern can match. +race:std::__basic_future*_M_get_result* # rclcpp GenericClient response staging: the executor thread fills the # generic response via rclcpp::GenericClient::create_response() before From f79bed404a63d91ebb11a3f4474ee49652ab6545 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 12 Sep 2026 21:20:53 +0200 Subject: [PATCH 2/6] test(aggregation): wait for the fan-out before reading a peer failure reason An aggregator answers a fanned-out listing 200 with no `partial` until a discovery pass has marked the peer healthy and published it as a contributor to the entity. Peer health does not imply the second: the two are set at opposite ends of the same pass, with the peer metadata fetch and its whole budget in between. Reading why a peer dropped out therefore waits for the fan-out to be in the answer rather than for health alone. The reason and shape assertions stay as they were, so a wrong reason or a reshaped failed_peers still fails; only the moment of reading moves. --- .../test_peer_failure_reasons.test.py | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/ros2_medkit_integration_tests/test/features/test_peer_failure_reasons.test.py b/src/ros2_medkit_integration_tests/test/features/test_peer_failure_reasons.test.py index c57c49c90..026fc5d1e 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_peer_failure_reasons.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_peer_failure_reasons.test.py @@ -55,6 +55,7 @@ from ros2_medkit_test_utils.constants import ( ALLOWED_EXIT_CODES, API_BASE_PATH, + DISCOVERY_INTERVAL, DISCOVERY_TIMEOUT, get_test_domain_id, get_test_port, @@ -240,13 +241,29 @@ def _wait_for_healthy_peer_or_self(cls, base_url, label): raise AssertionError(f'{label} gateway never saw a healthy peer; last saw {last}') def _fan_out_failure(self, base_url, timeout): - response = requests.get(_config_url(base_url), timeout=timeout) - self.assertEqual( - response.status_code, 200, - f'a fanned-out listing whose peer failed must still answer 200: ' - f'{response.status_code} {response.text[:400]}') - ext = response.json().get('x-medkit', {}) - self.assertTrue(ext.get('partial'), f'the answer does not admit it is partial: {ext}') + # Until a discovery pass has both marked the peer healthy and published + # it as a contributor to this entity, the aggregator answers 200 with no + # `partial` - a correct answer about a peer it does not yet know + # contributes here. Health alone does not imply it: the two are set at + # opposite ends of the same background pass, and the fetch between them + # spends the aggregator's whole metadata budget. So a reason is readable + # only once the fan-out is in the answer, and this waits for that on the + # discovery budget, which is what it is waiting for. + deadline = time.time() + DISCOVERY_TIMEOUT + ext = {} + while True: + response = requests.get(_config_url(base_url), timeout=timeout) + self.assertEqual( + response.status_code, 200, + f'a fanned-out listing whose peer failed must still answer 200: ' + f'{response.status_code} {response.text[:400]}') + ext = response.json().get('x-medkit', {}) + if ext.get('partial') or time.time() >= deadline: + break + time.sleep(DISCOVERY_INTERVAL) + self.assertTrue( + ext.get('partial'), + f'no answer admitted it was partial within {DISCOVERY_TIMEOUT:.0f}s: {ext}') # R4: the existing key keeps its existing shape. self.assertEqual( ext.get('failed_peers'), ['remote_gateway'], From bac66b92c60f3e3fe7a18a3a039a4ca59e5d37eb Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sat, 12 Sep 2026 21:36:31 +0200 Subject: [PATCH 3/6] test(aggregation): scale the peer failure reason wait once DISCOVERY_TIMEOUT already carries the sanitizer time scale, so multiplying it by the scale again squared it: one wait became 540 s under a scale of 3 instead of 180 s. setUpClass runs three of those waits back to back, so the class asked for more wall clock than this test's 300 s ctest budget grants even after the sanitizer jobs stretch it. --- .../test/features/test_peer_failure_reasons.test.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ros2_medkit_integration_tests/test/features/test_peer_failure_reasons.test.py b/src/ros2_medkit_integration_tests/test/features/test_peer_failure_reasons.test.py index 026fc5d1e..5848cc64c 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_peer_failure_reasons.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_peer_failure_reasons.test.py @@ -59,7 +59,6 @@ DISCOVERY_TIMEOUT, get_test_domain_id, get_test_port, - get_time_scale, ) from ros2_medkit_test_utils.launch_helpers import create_gateway_node @@ -81,7 +80,10 @@ TIGHT_METADATA_MS = 800 PATIENT_METADATA_MS = 20000 -TIMEOUT = DISCOVERY_TIMEOUT * get_time_scale() +# DISCOVERY_TIMEOUT already carries the sanitizer time scale. Applying the scale +# again squares it, and setUpClass runs three of these waits back to back, so the +# class would ask for more wall clock than this test's ctest budget grants. +TIMEOUT = DISCOVERY_TIMEOUT PEER_COMPONENT = 'remote-ecu' UNRESPONSIVE_APP = 'remote_unresponsive_param' From 2b4b130d90ed330aeccee5dc7f7a4fcafcfa7ba3 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 14 Sep 2026 17:28:36 +0200 Subject: [PATCH 4/6] test(tsan): suppress the client-future teardown report on rclcpp's wrapper Two patterns anchor on the read side of the report, FutureAndRequestId::get(): the rclcpp one for Jazzy and newer, and the compat::GenericServiceClient one the gateway uses on Humble. The read is header code compiled into our binaries, so the frame is always symbolized, and the pattern matches only a future rclcpp handed us for a client request. The wrapper's destructor and wait_for stay unmatched. The comment blocks around it describe the synchronisation the way libstdc++ implements it: the setter runs under call_once and release-stores _M_status, wait() acquires the same atomic, and for GenericClient that setter chain is compiled into librclcpp, which is uninstrumented. --- tsan_suppressions.txt | 62 ++++++++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/tsan_suppressions.txt b/tsan_suppressions.txt index 9682d0b24..44e964327 100644 --- a/tsan_suppressions.txt +++ b/tsan_suppressions.txt @@ -70,27 +70,50 @@ deadlock:rclcpp::Context* # rclcpp GenericClient: std::future shared state racing between the # executor thread that fulfils the response promise (_M_do_set -> # unique_ptr::swap) and the caller thread reading via future::get(). -# The happens-before is established by rclcpp's pthread_once around -# _State_baseV2::wait, but TSan does not see pthread_once as -# synchronisation when the wait completes after the promise was -# already satisfied. Suppress the standard-library frames that surface -# the rclcpp/glibc internal synchronisation - the race lives in the -# stdc++ future implementation, not in our handlers. +# The setter runs under libstdc++'s call_once and then release-stores +# _State_baseV2::_M_status; wait() synchronises against that with an +# acquire load on the same atomic. TSan does model call_once and atomic +# acquire/release, but for GenericClient the setter chain +# (handle_response -> promise::set_value -> _M_set_result) is compiled +# into librclcpp, so the release store is uninstrumented and that side +# of the edge is invisible to it - the race lives in the rclcpp/glibc +# internal synchronisation, not in our handlers. rclcpp::Client's own +# handle_response is a header template instantiated in our binaries, +# where the same store IS instrumented, so these reports do not arise +# there. Suppress the standard-library frames that surface the +# GenericClient case. race:std::__future_base::_State_baseV2* race:std::__uniq_ptr_impl*_Result_base* race:std::unique_ptr*_Result_base* -# Same shared state, its _Result object rather than the state itself. The last -# reference to the state is dropped by whichever thread finishes with the -# promise; when that is the rclcpp executor thread, ~_State_base and the -# _Result::_M_destroy() it runs live inside librclcpp, which is not -# instrumented. TSan sees the operator delete, because that is intercepted in -# the allocator, but not the acquire on the shared_ptr refcount that orders it -# after the caller's future::get(), so it pairs the free with the caller's read -# of _Result::_M_error. Anchored on the libstdc++ frame that performs that read: -# it is the only frame of the pair that is reliably symbolized, since librclcpp -# is stripped and the freeing frame prints as at librclcpp.so+0x..., -# which no race: pattern can match. -race:std::__basic_future*_M_get_result* +# Same shared state, its _Result object rather than the state itself. The +# executor thread drops the last reference to a client future's shared +# state, and the report shows the freeing frame unsymbolized inside +# librclcpp. TSan intercepts the operator delete, since that is instrumented +# in the allocator, but never saw the acquire on the shared_ptr refcount +# that would have ordered it after the caller's read, so it pairs the free +# with the caller's read of _Result_base::_M_error inside _M_get_result. +# Anchored on rclcpp::detail::FutureAndRequestId::get() (client.hpp): it is +# the read side of this report, and of any get() through the wrapper, it is +# header code compiled into our binaries so it is always symbolized, and it +# names exactly the class - a future rclcpp handed us for a client request - +# without matching any std::future the gateway builds itself (run_sync, the +# aggregation and configuration fan-outs, the topic sampler). The pattern is +# anchored on ::get() rather than left open at the end: the wrapper's other +# members (the destructor, wait_for) are deliberately not matched here - a +# report on them would be a different report and gets its own line with its +# own evidence. A module pattern such as race:librclcpp.so would also match +# the unsymbolized frame - TSan matches function, file and module name as an +# unanchored substring - but it would suppress every report with any +# librclcpp frame on either stack, ours or not. +race:rclcpp::detail::FutureAndRequestId*::get() +# On Humble, rclcpp::GenericClient does not exist (Iron+ only); the +# gateway's own compat::GenericServiceClient stands in for it +# (generic_client_compat.hpp), and its FutureAndRequestId::get() is the +# same read through the same library state as the pattern above. Named +# explicitly rather than left to the general rule above - the same +# narrow, named exception to the "never suppress our code" rule as the +# OperationManager::call_service suppression below. +race:ros2_medkit_gateway::compat::GenericServiceClient::FutureAndRequestId::get() # rclcpp GenericClient response staging: the executor thread fills the # generic response via rclcpp::GenericClient::create_response() before @@ -129,7 +152,8 @@ race:rclcpp::GenericClient::create_response* # racing WRITES live in rclcpp/libstdc++ (create_response, std::string mutate); # our OperationManager::call_service frame is only the consumer of a value the # std::future contract hands us once the promise is satisfied - the -# happens-before that rclcpp establishes via pthread_once is invisible to TSan. +# release-store side of that handoff runs inside uninstrumented librclcpp, +# so TSan never sees it and pairs the write with our read instead. # We must match on this frame because the adjacent librclcpp create_response # frame is frequently UNSYMBOLIZED ( at librclcpp.so+0x14xxxx), so the # create_response symbol line above does not catch those reports. A library From f527e17ecada9e81d6e1e91ecbf96a2ed4a639dc Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 14 Sep 2026 17:28:38 +0200 Subject: [PATCH 5/6] test(integration): scale each wall-clock budget once DISCOVERY_TIMEOUT already carries the sanitizer time scale. Seven feature tests multiplied it by the scale again, and test_aggregation_time_budgets scaled the already scaled GENEROUS_FORWARD_MS a second time in two request timeouts. Every budget is now scaled once. test_peer_failure_reasons: _fan_out_failure polls on TIMEOUT, retries a request that raised, keeps the 200 assertion on every answer, and names the last transport error when the answer never carries the fan-out. --- .../test_aggregation_time_budgets.test.py | 8 +++--- .../test_aggregator_fault_stream.test.py | 4 ++- .../features/test_manifest_config_key.test.py | 5 ++-- .../test_manifest_malformed_config.test.py | 5 ++-- .../features/test_merge_provenance.test.py | 5 ++-- .../test_peer_failure_reasons.test.py | 26 +++++++++++++------ ...test_plugin_entity_survives_ignore.test.py | 5 ++-- .../test_relay_peer_credential.test.py | 4 ++- 8 files changed, 41 insertions(+), 21 deletions(-) diff --git a/src/ros2_medkit_integration_tests/test/features/test_aggregation_time_budgets.test.py b/src/ros2_medkit_integration_tests/test/features/test_aggregation_time_budgets.test.py index c827d9726..71362de47 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_aggregation_time_budgets.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_aggregation_time_budgets.test.py @@ -111,7 +111,9 @@ LIDAR_APP = 'remote_lidar' PEER_NAMESPACE = '/chassis/sensors' -TIMEOUT = DISCOVERY_TIMEOUT * get_time_scale() +# DISCOVERY_TIMEOUT already carries the sanitizer time scale; applying the +# scale again would square it. +TIMEOUT = DISCOVERY_TIMEOUT PEER_MANIFEST = f"""\ manifest_version: "1.0" @@ -288,7 +290,7 @@ def test_b1_operation_past_the_metadata_budget_returns_the_peers_result(self): response = requests.post( f'{GENEROUS_URL}/apps/{SLOW_APP}/operations/calibrate/executions', json={}, - timeout=(GENEROUS_FORWARD_MS / 1000.0 + 10) * get_time_scale(), + timeout=GENEROUS_FORWARD_MS / 1000.0 + 10 * get_time_scale(), ) elapsed = time.monotonic() - started @@ -367,7 +369,7 @@ def test_b2_large_resource_arrives_whole_through_the_aggregator(self): through = requests.get( f'{GENEROUS_URL}/apps/{LIDAR_APP}/data/{resource}', - timeout=(GENEROUS_FORWARD_MS / 1000.0 + 20) * get_time_scale(), + timeout=GENEROUS_FORWARD_MS / 1000.0 + 20 * get_time_scale(), ) self.assertEqual( through.status_code, 200, diff --git a/src/ros2_medkit_integration_tests/test/features/test_aggregator_fault_stream.test.py b/src/ros2_medkit_integration_tests/test/features/test_aggregator_fault_stream.test.py index a17e09d7f..b76389a24 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_aggregator_fault_stream.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_aggregator_fault_stream.test.py @@ -90,7 +90,9 @@ SERVICE_NAME = '/fault_manager/report_fault' FAULT_MANAGER_NODE = 'fault_manager' -TIMEOUT = DISCOVERY_TIMEOUT * get_time_scale() +# DISCOVERY_TIMEOUT already carries the sanitizer time scale; applying the +# scale again would square it. +TIMEOUT = DISCOVERY_TIMEOUT # Both gateways run with this, so a disconnect is seen in about a second rather # than thirty. The test asserts on WHETHER a slot is released, not on how fast, diff --git a/src/ros2_medkit_integration_tests/test/features/test_manifest_config_key.test.py b/src/ros2_medkit_integration_tests/test/features/test_manifest_config_key.test.py index 9aaa1db1e..04bcd78fd 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_manifest_config_key.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_manifest_config_key.test.py @@ -129,8 +129,9 @@ def _base_url(port): # seconds. The gateways refresh every 1000 ms, so this spans several passes. # Soak duration, not a budget - see the note above; left unscaled on purpose. HOLD_SEC = 4.0 -# Was a private 60.0; this is the shared discovery budget, scaled. -POLL_TIMEOUT_SEC = DISCOVERY_TIMEOUT * TIME_SCALE +# DISCOVERY_TIMEOUT already carries the sanitizer time scale; applying the +# scale again would square it. +POLL_TIMEOUT_SEC = DISCOVERY_TIMEOUT POLL_INTERVAL_SEC = 0.5 # How long a single HTTP call has to come back. HTTP_TIMEOUT_SEC = 5.0 * TIME_SCALE diff --git a/src/ros2_medkit_integration_tests/test/features/test_manifest_malformed_config.test.py b/src/ros2_medkit_integration_tests/test/features/test_manifest_malformed_config.test.py index d3d76b7e7..28a358aec 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_manifest_malformed_config.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_manifest_malformed_config.test.py @@ -119,8 +119,9 @@ def _base_url(port): # expensive - which is load this file would be adding, not absorbing. TIME_SCALE = get_time_scale() -# Was a private 60.0; this is the shared discovery budget, scaled. -POLL_TIMEOUT_SEC = DISCOVERY_TIMEOUT * TIME_SCALE +# DISCOVERY_TIMEOUT already carries the sanitizer time scale; applying the +# scale again would square it. +POLL_TIMEOUT_SEC = DISCOVERY_TIMEOUT POLL_INTERVAL_SEC = 0.5 # How long a single HTTP call has to come back. HTTP_TIMEOUT_SEC = 5.0 * TIME_SCALE diff --git a/src/ros2_medkit_integration_tests/test/features/test_merge_provenance.test.py b/src/ros2_medkit_integration_tests/test/features/test_merge_provenance.test.py index 9892a4e43..691052dba 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_merge_provenance.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_merge_provenance.test.py @@ -163,8 +163,9 @@ def _base_url(port): SAMPLE_COUNT = 12 SAMPLE_DEADLINE_SEC = 70.0 * TIME_SCALE -# Was a private 60.0; this is the shared discovery budget, scaled. -POLL_TIMEOUT_SEC = DISCOVERY_TIMEOUT * TIME_SCALE +# DISCOVERY_TIMEOUT already carries the sanitizer time scale; applying the +# scale again would square it. +POLL_TIMEOUT_SEC = DISCOVERY_TIMEOUT POLL_INTERVAL_SEC = 0.5 # How long a single HTTP call has to come back. HTTP_TIMEOUT_SEC = 5.0 * TIME_SCALE diff --git a/src/ros2_medkit_integration_tests/test/features/test_peer_failure_reasons.test.py b/src/ros2_medkit_integration_tests/test/features/test_peer_failure_reasons.test.py index 5848cc64c..0d2275864 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_peer_failure_reasons.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_peer_failure_reasons.test.py @@ -80,9 +80,8 @@ TIGHT_METADATA_MS = 800 PATIENT_METADATA_MS = 20000 -# DISCOVERY_TIMEOUT already carries the sanitizer time scale. Applying the scale -# again squares it, and setUpClass runs three of these waits back to back, so the -# class would ask for more wall clock than this test's ctest budget grants. +# DISCOVERY_TIMEOUT already carries the sanitizer time scale; applying the +# scale again would square it. TIMEOUT = DISCOVERY_TIMEOUT PEER_COMPONENT = 'remote-ecu' @@ -251,21 +250,32 @@ def _fan_out_failure(self, base_url, timeout): # spends the aggregator's whole metadata budget. So a reason is readable # only once the fan-out is in the answer, and this waits for that on the # discovery budget, which is what it is waiting for. - deadline = time.time() + DISCOVERY_TIMEOUT + deadline = time.time() + TIMEOUT ext = {} - while True: - response = requests.get(_config_url(base_url), timeout=timeout) + response = None + last_error = None + while time.time() < deadline: + try: + response = requests.get(_config_url(base_url), timeout=timeout) + except requests.RequestException as exc: + last_error = str(exc) + time.sleep(DISCOVERY_INTERVAL) + continue self.assertEqual( response.status_code, 200, f'a fanned-out listing whose peer failed must still answer 200: ' f'{response.status_code} {response.text[:400]}') ext = response.json().get('x-medkit', {}) - if ext.get('partial') or time.time() >= deadline: + if ext.get('partial'): break time.sleep(DISCOVERY_INTERVAL) + if response is None: + raise AssertionError( + f'no response arrived from {base_url} within {TIMEOUT:.0f}s: {last_error}') self.assertTrue( ext.get('partial'), - f'no answer admitted it was partial within {DISCOVERY_TIMEOUT:.0f}s: {ext}') + f'no answer admitted it was partial within {TIMEOUT:.0f}s: {ext}' + + (f' (last request error: {last_error})' if last_error else '')) # R4: the existing key keeps its existing shape. self.assertEqual( ext.get('failed_peers'), ['remote_gateway'], diff --git a/src/ros2_medkit_integration_tests/test/features/test_plugin_entity_survives_ignore.test.py b/src/ros2_medkit_integration_tests/test/features/test_plugin_entity_survives_ignore.test.py index 6ad203e85..0cfbd7da1 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_plugin_entity_survives_ignore.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_plugin_entity_survives_ignore.test.py @@ -77,8 +77,9 @@ # expensive - which is load this file would be adding, not absorbing. TIME_SCALE = get_time_scale() -# Was a private 60.0; this is the shared discovery budget, scaled. -POLL_TIMEOUT_SEC = DISCOVERY_TIMEOUT * TIME_SCALE +# DISCOVERY_TIMEOUT already carries the sanitizer time scale; applying the +# scale again would square it. +POLL_TIMEOUT_SEC = DISCOVERY_TIMEOUT # The plugin's entities appear within a pass or two when protection works. A # short budget for "is it there" keeps a genuine deletion failing fast with a # diagnostic, instead of every test burning the long timeout and the whole diff --git a/src/ros2_medkit_integration_tests/test/features/test_relay_peer_credential.test.py b/src/ros2_medkit_integration_tests/test/features/test_relay_peer_credential.test.py index 9657c8216..9d3669288 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_relay_peer_credential.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_relay_peer_credential.test.py @@ -98,7 +98,9 @@ SERVICE_NAME = '/fault_manager/report_fault' FAULT_MANAGER_NODE = 'fault_manager' -TIMEOUT = DISCOVERY_TIMEOUT * get_time_scale() +# DISCOVERY_TIMEOUT already carries the sanitizer time scale; applying the +# scale again would square it. +TIMEOUT = DISCOVERY_TIMEOUT KEEPALIVE_SEC = 2 From fef130d81f9e35d5fd9e9f377c9cb5839c612a86 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 14 Sep 2026 17:28:44 +0200 Subject: [PATCH 6/6] build(integration-tests): give test_relay_peer_credential a 300 s budget The test starts three gateways and a fault_manager on three extra domains, waits for both aggregators, then waits a scaled 60 s for the fault_manager on the peer's domain, before the first case runs. That does not fit the 120 s feature default. --- src/ros2_medkit_integration_tests/CMakeLists.txt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ros2_medkit_integration_tests/CMakeLists.txt b/src/ros2_medkit_integration_tests/CMakeLists.txt index dab635448..d0c44b9c0 100644 --- a/src/ros2_medkit_integration_tests/CMakeLists.txt +++ b/src/ros2_medkit_integration_tests/CMakeLists.txt @@ -321,6 +321,13 @@ if(BUILD_TESTING) # round trip on the patient aggregator, twice, and then polls after killing # the peer. Two of those plus three gateway startups exceed the default. # + # test_relay_peer_credential launches three gateways plus a fault_manager, + # holding three extra domains for its two aggregators and the peer beside + # the launcher's own, and setUpClass waits for both aggregators to answer + # before separately holding a 60s wait, scaled, for the fault_manager node + # to appear on the peer's domain - before a single test case runs. Three + # startups plus those two sequential waits exceed the default. + # # test_aggregator_fault_stream spends most of its budget waiting rather than # working: a closed SSE connection is noticed at the peer's next keepalive, # 30s away, and the test settles the peer's occupancy before each measurement @@ -338,6 +345,7 @@ if(BUILD_TESTING) set(_MEDKIT_TEST_TIMEOUT_OVERRIDES test_aggregation_time_budgets 300 test_peer_failure_reasons 300 + test_relay_peer_credential 300 test_aggregator_fault_stream 420 test_auth_policy_contract 300 test_rosbag_boundary_download 180