Files
ray-project--ray/python/ray/tests/test_streaming_generator_regression.py
T
2026-07-13 13:17:40 +08:00

140 lines
4.6 KiB
Python

import sys
import time
from concurrent.futures import ThreadPoolExecutor
import pytest
import ray
from ray.actor import ActorHandle
from ray.exceptions import RayTaskError, TaskCancelledError
from ray.util.state import list_workers
@ray.remote(num_cpus=1)
class EndpointActor:
def __init__(self, *, injected_executor_delay_s: float, tokens_per_request: int):
self._tokens_per_request = tokens_per_request
# In this test we simulate conditions leading to use-after-free conditions,
# by injecting delays into worker's thread-pool executor
self._inject_delay_in_core_worker_executor(
target_delay_s=injected_executor_delay_s,
max_workers=1,
)
async def aio_stream(self):
for i in range(self._tokens_per_request):
yield i
@classmethod
def _inject_delay_in_core_worker_executor(
cls, target_delay_s: float, max_workers: int
):
if target_delay_s > 0:
class DelayedThreadPoolExecutor(ThreadPoolExecutor):
def submit(self, fn, /, *args, **kwargs):
def __slowed_fn():
print(
f">>> [DelayedThreadPoolExecutor] Starting executing "
f"function with delay {target_delay_s}s"
)
time.sleep(target_delay_s)
fn(*args, **kwargs)
return super().submit(__slowed_fn)
executor = DelayedThreadPoolExecutor(max_workers=max_workers)
ray._private.worker.global_worker.core_worker.reset_event_loop_executor(
executor
)
@ray.remote(num_cpus=1)
class CallerActor:
def __init__(
self,
downstream: ActorHandle,
):
self._h = downstream
async def run(self):
print(">>> [Caller] Starting consuming stream")
async_obj_ref_gen = self._h.aio_stream.options(num_returns="streaming").remote()
async for ref in async_obj_ref_gen:
r = await ref
if r == 1:
print(">>> [Caller] Cancelling generator")
ray.cancel(async_obj_ref_gen, recursive=False)
# NOTE: This delay is crucial to let already scheduled task to report
# generated item (report_streaming_generator_output) before we
# will tear down this stream
delay_after_cancellation_s = 2
print(f">>> [Caller] **Sleeping** {delay_after_cancellation_s}s")
time.sleep(delay_after_cancellation_s)
else:
print(f">>> [Caller] Received {r}")
print(">>> [Caller] Completed consuming stream")
@pytest.mark.parametrize("injected_executor_delay_s", [0, 2])
@pytest.mark.parametrize(
"ray_start_cluster",
[
{
"num_nodes": 2,
"num_cpus": 1,
}
],
indirect=True,
)
def test_segfault_report_streaming_generator_output(
ray_start_cluster, injected_executor_delay_s: float
):
"""
This is a "smoke" test attempting to emulate condition, when using Ray's async
streaming generator, that leads to worker crashing with SIGSEGV.
For more details summarizing these conditions, please refer to
https://github.com/ray-project/ray/issues/43771#issuecomment-1982301654
"""
caller = CallerActor.remote(
EndpointActor.remote(
injected_executor_delay_s=injected_executor_delay_s,
tokens_per_request=100,
),
)
worker_state_before = [(a.worker_id, a.exit_type) for a in list_workers()]
print(">>> Workers state before: ", worker_state_before)
try:
ray.get(caller.run.remote())
except Exception as exc:
# There is a small chance that the task cancellation signal will arrive
# late at the executor, after the task has already finished. In that
# case, the task will complete normally, with no exception thrown.
# Thus, we wrap ray.get in a try-catch instead of asserting an
# exception.
assert isinstance(exc, RayTaskError)
assert isinstance(exc.cause, TaskCancelledError)
worker_state_after = [(a.worker_id, a.exit_type) for a in list_workers()]
print(">>> Workers state after: ", worker_state_after)
worker_ids, worker_exit_types = zip(*worker_state_after)
# Make sure no workers crashed
assert (
"SYSTEM_ERROR" not in worker_exit_types
), f"Unexpected crashed worker(s) in {worker_ids}"
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))