Skip to content

Fix: reap test_remote_endpoint's server threads on the unwind path - #1578

Merged
ChaoWao merged 1 commit into
hw-native-sys:mainfrom
ChaoWao:fix/remote-endpoint-joinable-thread-abort
Jul 29, 2026
Merged

Fix: reap test_remote_endpoint's server threads on the unwind path#1578
ChaoWao merged 1 commit into
hw-native-sys:mainfrom
ChaoWao:fix/remote-endpoint-joinable-thread-abort

Conversation

@ChaoWao

@ChaoWao ChaoWao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

RemoteSocketTransport tests start a helper server thread, then construct a RemoteL3SocketTransport whose constructor throws on a connect or HELLO timeout — routine on a loaded box. The unwind destroyed the local std::thread while it was still joinable, so std::terminate aborted the whole binary mid-suite, taking every other case in it down too.

Core dump from a reproduced abort:

#5  std::terminate ()
#6  std::thread::~thread() ()
#7  RemoteSocketTransport_ClosedPeerWriteDoesNotRaiseSigpipe_Test::TestBody()

Reproduced at 2/96 with 32 concurrent copies of the binary on a loaded box. The printed message — terminate called without an active exception — is misleading: the exception is in flight, but no handler has caught it yet, so libstdc++'s verbose terminate handler reports no active exception. It reads like a hang rather than a timeout, which is part of why this looked like unexplained flakiness.

A second, latent bug in the same code: start_stalling_server captured the test's stack std::atomic<bool> stop by reference, so the same unwind left a running thread polling a destroyed object.

Fix

ScopedServerThread owns both the thread and the stop flag and joins in its destructor. The flag moving inside is what fixes the dangling reference; joining from the destructor is what stops an unwind ever reaching a joinable std::thread. It is declared before the transport in each test, so the transport is destroyed first.

accept_until_stop replaces the bare blocking accept() in all three server helpers. Joining alone would have swapped the abort for a hang: a thread parked in accept() never returns when the client's connect is exactly what failed. It polls the listener in 20 ms slices and rechecks the stop flag, so stop_and_join always makes progress, with a 10 s cap.

Ordering inside that loop is load-bearing: the stop flag is read only after a poll slice, so a connection already pending still wins. ClosedPeerWriteDoesNotRaiseSigpipe calls stop_and_join immediately after a successful connect and depends on that connection being accepted and RST — reading the flag first would have introduced a fresh flake.

Regression barrier

ServerThreadIsJoinedWhenTestBodyUnwinds throws out of a scope holding a live server thread that never sees a client, covering both halves:

  • Reverted to the old bare-std::thread idiom it aborts with the production message (verified).
  • A join that could not reap a parked accept() would trip its elapsed bound rather than hang silently.

No timing bound was widened. The 1.0 s attach timeout in the aborting test is incidental to what it asserts, but raising it would only paper over the crash; the two tests where a bound is the subject (HelloReadBoundedByAttachTimeout, RuntimeWriteToStalledReaderTimesOut) are untouched.

Testing

  • ctest -LE requires_hardware — 66/66 pass
  • 128 concurrent runs of test_remote_endpoint post-fix: 0 aborts, 0 failures (was 2/96)
  • New test verified to abort against the pre-fix idiom
  • Hardware tests — not applicable, host-only unit test change

RemoteSocketTransport tests start a helper server thread, then construct a
RemoteL3SocketTransport whose constructor throws on a connect or HELLO
timeout — routine on a loaded box. The unwind destroyed the local
std::thread while it was still joinable, so std::terminate aborted the whole
binary mid-suite:

    hw-native-sys#5  std::terminate ()
    hw-native-sys#6  std::thread::~thread() ()
    hw-native-sys#7  RemoteSocketTransport_ClosedPeerWriteDoesNotRaiseSigpipe_Test::TestBody()

Reproduced at 2/96 with 32 concurrent copies on a loaded 320-core box; the
run that aborts takes every other case in the binary with it, and the
message ("terminate called without an active exception", printed because no
handler has caught the in-flight exception yet) reads like a hang rather
than a timeout.

start_stalling_server also captured the test's stack std::atomic<bool> stop
flag by reference, so the same unwind left a running thread polling a dead
object.

ScopedServerThread owns both the thread and the stop flag, and joins in its
destructor, which closes both holes: the flag now outlives the thread, and
no unwind can reach a joinable std::thread.

Joining alone would swap the abort for a hang, because a thread parked in
accept() never returns when the client's connect is exactly what failed.
accept_until_stop polls the listener in 20 ms slices and rechecks the stop
flag, so stop_and_join always makes progress. It reads the flag only after
a poll slice, so a connection already pending still wins:
ClosedPeerWriteDoesNotRaiseSigpipe calls stop_and_join right after a
successful connect and depends on that connection being accepted and RST.

Add ServerThreadIsJoinedWhenTestBodyUnwinds, which throws out of a scope
holding a live server thread that never sees a client. It covers both
halves — reverted to the old bare-std::thread idiom it aborts with the
production message, and a join that could not reap a parked accept() would
trip its elapsed bound instead.

ut-cpp 65/65 green; 192 concurrent runs of the binary now abort 0 times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@ChaoWao, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ac3abcc0-2616-441d-bca7-b143bab575b2

📥 Commits

Reviewing files that changed from the base of the PR and between ca22a8f and 58caa7f.

📒 Files selected for processing (1)
  • tests/ut/cpp/hierarchical/test_remote_endpoint.cpp

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ChaoWao
ChaoWao merged commit 98f5093 into hw-native-sys:main Jul 29, 2026
18 checks passed
@ChaoWao
ChaoWao deleted the fix/remote-endpoint-joinable-thread-abort branch July 29, 2026 09:27
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