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 3 commits into
mainfrom
fix/cancel-outcomes-under-tsan
Open

test: keep the TSan job green on a future teardown report and a fan-out read#687
bburda wants to merge 3 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 one more line in the existing block for this class of report, anchored on the libstdc++ read-side frame, because the freeing frame is inside a stripped library and prints as <null>. Reproduced under TSan with a standalone reproducer against the real suppressions file: reported 5 of 5 runs before the line, 0 of 21 after.

test_peer_failure_reasons failed on an assertion, not a report. 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 waits until the answer carries the fan-out before reading the reason, with every original assertion kept. 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. The file's TIMEOUT also applied get_time_scale() on top of the already scaled DISCOVERY_TIMEOUT; it now scales once, so the longest single wait under TSan is 180 s inside the test's 900 s ctest budget instead of 540 s.


Issue

No issue. Two independent failures of one CI job, each with its own commit.


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 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.
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.

1 participant