Skip to content

[core] Keep RaySyncer reactors alive for every handler dispatched to io_context - #67399

Open
lonexreb wants to merge 2 commits into
ray-project:masterfrom
lonexreb:fix/62008-ray-syncer-reactor-lifetime
Open

lonexreb wants to merge 2 commits into
ray-project:masterfrom
lonexreb:fix/62008-ray-syncer-reactor-lifetime

Conversation

@lonexreb

Copy link
Copy Markdown
Contributor

Description

RayClientBidiReactor::OnDone() and RayServerBidiReactor::OnDone() release the reactor's self-owning reference (self_ref_, introduced in #57641) from a handler posted to io_context_. gRPC can still invoke OnReadDone/OnWriteDone right before that, and the syncer can call DoDisconnect/OnCancel; each of those posts a handler capturing a raw this to the same io_context_. If the OnDone handler happens to run first, self_ref_.reset() destroys the reactor and the handler queued earlier then dereferences freed memory. That matches both ASAN reports in #62008: allocation in RaySyncer::Connect(), free at ray_syncer_client.cc:60 (self_ref_.reset()), use from a later io_context_ handler.

#57641 guarded only the batch timer (with a weak_ptr). This PR captures a strong copy of self_ref_ in every other dispatched handler (OnWriteDone, both dispatches in OnReadDone, the client's DoDisconnect, the server's DoDisconnect/OnCancel) so none of them can outlive the reactor. The two OnDone handlers also capture self, so the reactor stays alive through cleanup_cb_ and the last owning reference is dropped when that handler returns rather than in the middle of it. The batch timer keeps its weak_ptr so a pending timer does not extend the lifetime of a finished reactor. No behavior changes otherwise.

Related issues

Fixes #62008

@Kunchd asked for a PR on the issue; the reporter declined. The maintainer refactor in #58856 (making reactors shared_from_this) was closed by the stale bot in Dec 2025, and nothing open touches ray_syncer lifetime handling (gh pr list --state open --search "62008 in:body" is empty; recent ray_syncer PRs are about batching/leader election).

Additional information

I could not reproduce the race deterministically in a unit test (it needs gRPC to deliver a callback between OnDone and its posted handler), so this PR is justified by the ownership analysis above rather than a new test. The existing syncer tests pass; they set self_ref_ to a non-owning shared_ptr on stack reactors, which the added captures tolerate.

Test commands run locally (macOS arm64, bazel 7.5.0):

bazel test --dynamic_mode=off //src/ray/ray_syncer/tests:ray_syncer_test   # PASSED

AI assistance (Claude Code) was used to draft this change; every line was reviewed and tested by the submitter.

🤖 Generated with Claude Code

…io_context

RayClientBidiReactor::OnDone() and RayServerBidiReactor::OnDone() release
the reactor's self-owning reference (self_ref_) from a handler posted to
io_context_. gRPC can invoke OnReadDone/OnWriteDone, and the syncer can
call DoDisconnect/OnCancel, right before that, each of which posts a handler
capturing a raw this to the same io_context_. If the OnDone handler ran
first the reactor was destroyed, and the earlier queued handler then
dereferenced freed memory (ASAN heap-use-after-free reports in ray-project#62008
pointing at ray_syncer_client.cc:60 as the free site).

Only the batch timer was lifetime-guarded (weak_ptr). Capture a strong
reference to self_ref_ in every dispatched handler so none of them can
outlive the reactor; the OnDone handlers hold one through cleanup_cb_ as
well, so the last owning reference is dropped when that handler returns.

Fixes ray-project#62008

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: lonexreb <reach2shubhankar@gmail.com>
@lonexreb
lonexreb requested a review from a team as a code owner October 25, 2026 08:29

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request prevents potential use-after-free issues in RaySyncerBidiReactor and its subclasses by capturing a strong reference (self = self_ref_) in lambdas dispatched to io_context_, ensuring the reactor remains alive during asynchronous callbacks. The reviewer recommends adding (void)self; inside these lambda bodies to avoid potential compiler warnings about unused lambda captures (e.g., -Wunused-lambda-capture in Clang).

Comment on lines 241 to 244
void OnWriteDone(bool ok) override {
io_context_.dispatch(
[this, disconnected = IsDisconnected(), ok]() {
[this, self = self_ref_, disconnected = IsDisconnected(), ok]() {
if (*disconnected) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

To prevent potential compiler warnings about unused lambda captures (such as -Wunused-lambda-capture in Clang), it is recommended to explicitly cast the captured self to void inside the lambda body. This also clearly documents that the capture is intended solely for lifetime management.

Suggested change
void OnWriteDone(bool ok) override {
io_context_.dispatch(
[this, disconnected = IsDisconnected(), ok]() {
[this, self = self_ref_, disconnected = IsDisconnected(), ok]() {
if (*disconnected) {
void OnWriteDone(bool ok) override {
io_context_.dispatch(
[this, self = self_ref_, disconnected = IsDisconnected(), ok]() {
(void)self;
if (*disconnected) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not adding (void)self;: clang's -Wunused-lambda-capture doesn't fire for captures whose type has a non-trivial destructor like std::shared_ptr, since holding the object alive is a legitimate use. The macOS build compiles with -Werror (.bazelrc) and bazel test //src/ray/ray_syncer/tests:ray_syncer_test built and passed cleanly with clang. The comment in ray_syncer_bidi_reactor.h documents that these captures exist for lifetime.

Comment on lines 61 to 64
io_context_.dispatch(
[this, status]() {
[this, self = self_ref_, status]() {
cleanup_cb_(this, !status.ok());
self_ref_.reset();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

To prevent potential compiler warnings about unused lambda captures (such as -Wunused-lambda-capture in Clang), it is recommended to explicitly cast the captured self to void inside the lambda body. This also clearly documents that the capture is intended solely for lifetime management.

  io_context_.dispatch(
      [this, self = self_ref_, status]() {
        (void)self;
        cleanup_cb_(this, !status.ok());
        self_ref_.reset();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not adding (void)self;: clang's -Wunused-lambda-capture doesn't fire for captures whose type has a non-trivial destructor like std::shared_ptr, since holding the object alive is a legitimate use. The macOS build compiles with -Werror (.bazelrc) and bazel test //src/ray/ray_syncer/tests:ray_syncer_test built and passed cleanly with clang. The comment in ray_syncer_bidi_reactor.h documents that these captures exist for lifetime.

Comment on lines 121 to 127
io_context_.dispatch(
[this, cleanup_cb = cleanup_cb_, remote_node_id = GetRemoteNodeID()]() {
[this,
self = self_ref_,
cleanup_cb = cleanup_cb_,
remote_node_id = GetRemoteNodeID()]() {
cleanup_cb(this, false);
self_ref_.reset();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

To prevent potential compiler warnings about unused lambda captures (such as -Wunused-lambda-capture in Clang), it is recommended to explicitly cast the captured self to void inside the lambda body. This also clearly documents that the capture is intended solely for lifetime management.

  io_context_.dispatch(
      [this,
       self = self_ref_,
       cleanup_cb = cleanup_cb_,
       remote_node_id = GetRemoteNodeID()]() {
        (void)self;
        cleanup_cb(this, false);
        self_ref_.reset();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not adding (void)self;: clang's -Wunused-lambda-capture doesn't fire for captures whose type has a non-trivial destructor like std::shared_ptr, since holding the object alive is a legitimate use. The macOS build compiles with -Werror (.bazelrc) and bazel test //src/ray/ray_syncer/tests:ray_syncer_test built and passed cleanly with clang. The comment in ray_syncer_bidi_reactor.h documents that these captures exist for lifetime.

@ray-gardener ray-gardener Bot added core Issues that should be addressed in Ray Core community-contribution Contributed by the community labels Oct 25, 2026
@Yicheng-Lu-llll Yicheng-Lu-llll self-assigned this Oct 26, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: lonexreb <reach2shubhankar@gmail.com>

@hikaru-212 hikaru-212 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Independently reviewed the reactor ownership changes at 2011efd5a.

The source audit confirms that all eight dispatched handlers retain an owning shared_ptr under the normal factory paths, including through OnDone cleanup. The batch timer uses weak_ptr.lock() before accessing the reactor.

I also rebuilt and ran ray_syncer_test on Linux ARM64 with test caching disabled: 24/24 passed.

Specifically, five SyncerTest cases exercised real gRPC connections, message propagation, explicit reconnection, and disconnect cleanup. Seven authentication tests covered the production factory's acceptance and rejection paths.

No reactor ownership gap was identified within the reviewed paths. The tests do not directly verify destructor timing or reproduce the original #62008 ASAN UAF.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-contribution Contributed by the community core Issues that should be addressed in Ray Core

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Core] Potential UAF in RaySyncer client bidi reactor after batching changes

3 participants