Repository navigation
Conversation
…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>
There was a problem hiding this comment.
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).
| void OnWriteDone(bool ok) override { | ||
| io_context_.dispatch( | ||
| [this, disconnected = IsDisconnected(), ok]() { | ||
| [this, self = self_ref_, disconnected = IsDisconnected(), ok]() { | ||
| if (*disconnected) { |
There was a problem hiding this comment.
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.
| 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) { |
There was a problem hiding this comment.
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.
| io_context_.dispatch( | ||
| [this, status]() { | ||
| [this, self = self_ref_, status]() { | ||
| cleanup_cb_(this, !status.ok()); | ||
| self_ref_.reset(); |
There was a problem hiding this comment.
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();There was a problem hiding this comment.
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.
| 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(); |
There was a problem hiding this comment.
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();There was a problem hiding this comment.
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.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: lonexreb <reach2shubhankar@gmail.com>
hikaru-212
left a comment
There was a problem hiding this comment.
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.
Description
RayClientBidiReactor::OnDone()andRayServerBidiReactor::OnDone()release the reactor's self-owning reference (self_ref_, introduced in #57641) from a handler posted toio_context_. gRPC can still invokeOnReadDone/OnWriteDoneright before that, and the syncer can callDoDisconnect/OnCancel; each of those posts a handler capturing a rawthisto the sameio_context_. If theOnDonehandler 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 inRaySyncer::Connect(), free atray_syncer_client.cc:60(self_ref_.reset()), use from a laterio_context_handler.#57641 guarded only the batch timer (with a
weak_ptr). This PR captures a strong copy ofself_ref_in every other dispatched handler (OnWriteDone, both dispatches inOnReadDone, the client'sDoDisconnect, the server'sDoDisconnect/OnCancel) so none of them can outlive the reactor. The twoOnDonehandlers also captureself, so the reactor stays alive throughcleanup_cb_and the last owning reference is dropped when that handler returns rather than in the middle of it. The batch timer keeps itsweak_ptrso 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 touchesray_syncerlifetime handling (gh pr list --state open --search "62008 in:body"is empty; recentray_syncerPRs 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
OnDoneand its posted handler), so this PR is justified by the ownership analysis above rather than a new test. The existing syncer tests pass; they setself_ref_to a non-owningshared_ptron stack reactors, which the added captures tolerate.Test commands run locally (macOS arm64, bazel 7.5.0):
AI assistance (Claude Code) was used to draft this change; every line was reviewed and tested by the submitter.
🤖 Generated with Claude Code