Skip to content

test: keep the TSan job green on a future teardown report and a fan-out read - #687

Open
bburda wants to merge 6 commits into
mainfrom
fix/cancel-outcomes-under-tsan
Open

bburda wants to merge 6 commits into
mainfrom
fix/cancel-outcomes-under-tsan

Conversation

@bburda

@bburda bburda commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Pull Request

Summary

The sanitizer-tsan job on main is red on two tests that share nothing but the job.

test_cancel_outcomes passes all 20 cases; the process exits non-zero because TSan reports one race. The last reference to a GenericClient future's shared state is dropped by the rclcpp executor thread, inside librclcpp, which is not instrumented. TSan sees the operator delete there but not the acquire on the shared_ptr refcount that orders it after our future::get(), so it pairs the free with the caller's earlier read of the result. Our use of the API is correct and the reference is held for the whole get(). tsan_suppressions.txt gets two lines for this class of report, both anchored on the read side, the wrapper's get(): rclcpp::detail::FutureAndRequestId on Jazzy and newer, and the gateway's compat::GenericServiceClient on Humble. The wrapper's destructor and wait_for are not matched, and no std::future the gateway builds itself is matched. The comment blocks around the pattern describe the synchronisation the way libstdc++ implements it. Reproduced under TSan with a standalone reproducer against the real suppressions file: 5 of 5 runs reported the race with no suppression. The sanitizer-tsan job of this pull request checks the pattern against the real gateway.

test_peer_failure_reasons failed on an assertion. Between the aggregator marking a peer online and publishing its contributor map for an entity, a read answers 200 with no fan-out at all, which is the documented purpose of the contributor map. The test's readiness gate waited for the first signal and read the failure reason from a single response, so it asserted a transient state; the window is about 0.9 s without instrumentation and wider under TSan. _fan_out_failure now polls until the answer carries the fan-out before reading the reason, retries a request that raised, and keeps every original assertion. With the readiness gate reduced to "the HTTP server answers", the old helper fails 5 of 5 runs with the exact CI message and the new one passes 5 of 5.

Seven feature tests applied get_time_scale() on top of DISCOVERY_TIMEOUT, which already carries the scale, and test_aggregation_time_budgets scaled GENEROUS_FORWARD_MS twice in two request timeouts. Every budget now scales once, so the longest single wait under TSan is 180 s. test_relay_peer_credential gets a 300 s ctest budget like the other multi-gateway tests; it starts three gateways and a fault manager and holds two sequential waits before its first case.


Issue

No issue. Two independent failures of one CI job.


Type

  • Bug fix
  • New feature or tests
  • Breaking change
  • Documentation only

Testing

  • test_cancel_outcomes and test_peer_failure_reasons on jazzy, plain build: 10 of 10 and 10 of 10, plus the aggregation sweep (ctest -R "peer|aggregat", 11 of 11).
  • Red-green for both changes as described above; a mutation of the expected reason in test_1 still fails the fixed test ('timeout' != 'unreachable').
  • The seven touched feature tests and test_relay_peer_credential pass on jazzy, plain build, with the new budgets in place; the integration package's linters pass.
  • The sanitizer-tsan job of this pull request is the only place the suppression is verified against the real gateway under TSan.

Checklist

  • Breaking changes are clearly described (and announced in docs / changelog if needed)
  • Tests were added or updated if needed
  • Docs were updated if behavior or public API changed

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.
… 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.
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.
@bburda bburda self-assigned this Sep 13, 2026
# 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The squaring you removed here is still in three siblings: test_aggregation_time_budgets.test.py:114, test_relay_peer_credential.test.py:101 and test_aggregator_fault_stream.test.py:93 all do DISCOVERY_TIMEOUT * get_time_scale(). test_relay_peer_credential has no ctest override, so under TSan its _wait_for_gateways and _wait_for_peer_status deadlines are 540 s inside a 360 s ctest budget, and a stuck wait there gets killed with no test name in the output, the exact failure this comment says it avoids. Fix all four here or say why the other three stay out.

Comment thread tsan_suppressions.txt Outdated
# 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 <null> at librclcpp.so+0x...,
# which no race: pattern can match.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This last clause is false: TSan matches race: patterns against function, file and module name, the module is filled in even when symbolization fails (that is where the (librclcpp.so+0x...) suffix comes from), and matching is an unanchored substring, so race:librclcpp.so would catch that <null> frame. The real reason to anchor on _M_get_result is scope: a module pattern suppresses every report with any librclcpp frame on either stack. Reword to that, and note the pattern also covers the gateway's own std::future<NodeQueryResult> in the configuration fan-out, so it is wider than the one GenericClient report.

…apper

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.
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants