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

545 lines
17 KiB
Python

import multiprocessing
import os
import platform
import signal
import sys
import time
import pytest
import ray
from ray._common.test_utils import SignalActor, wait_for_condition
from ray._private import ray_constants
from ray._private.test_utils import (
run_string_as_driver_nonblocking,
)
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
from ray.util.state import get_worker, list_tasks
import psutil # We must import psutil after ray because we bundle it with ray.
WAIT_TIMEOUT = 20
def get_all_ray_worker_processes():
processes = psutil.process_iter(attrs=["pid", "name", "cmdline", "status"])
result = []
for p in processes:
cmdline = p.info["cmdline"]
# Skip agent processes. cmdline might be truncated on linux so match by substring.
if (
cmdline is not None
and len(cmdline) > 0
and "ray::" in cmdline[0]
and not any(
cmdline[0] in agent for agent in ray_constants.AGENT_PROCESS_LIST
)
):
result.append(p)
print(f"all ray worker processes: {result}")
return result
@pytest.fixture
def short_gcs_publish_timeout(monkeypatch):
monkeypatch.setenv("RAY_MAX_GCS_PUBLISH_RETRIES", "3")
monkeypatch.setenv("RAY_gcs_rpc_server_reconnect_timeout_s", "1")
yield
@pytest.mark.skipif(platform.system() == "Windows", reason="Hang on Windows.")
def test_ray_shutdown(short_gcs_publish_timeout, shutdown_only):
"""Make sure all ray workers are shutdown when driver is done."""
signal_actor = SignalActor.remote()
@ray.remote
def f():
ray.get(signal_actor.wait.remote())
num_cpus = int(ray.available_resources()["CPU"])
for _ in range(num_cpus):
f.remote()
wait_for_condition(
lambda: ray.get(signal_actor.cur_num_waiters.remote()) == num_cpus,
timeout=20,
)
assert len(get_all_ray_worker_processes()) > 0
ray.shutdown()
wait_for_condition(
lambda: len(get_all_ray_worker_processes()) == 0, timeout=WAIT_TIMEOUT
)
@pytest.mark.skipif(platform.system() == "Windows", reason="Hang on Windows.")
def test_ray_shutdown_then_call(short_gcs_publish_timeout, shutdown_only):
"""Make sure ray will not kill cpython when using unrecognized ObjectId"""
# Set include_dashboard=False to have faster startup.
ray.init(num_cpus=1, include_dashboard=False)
my_ref = ray.put("anystring")
@ray.remote
def f(s):
print(s)
ray.shutdown()
ray.init(num_cpus=1, include_dashboard=False)
with pytest.raises(ValueError, match="Ray object whose owner is unknown"):
f.remote(my_ref) # This would cause full CPython death.
ray.shutdown()
wait_for_condition(lambda: len(get_all_ray_worker_processes()) == 0)
@pytest.mark.skipif(platform.system() == "Windows", reason="Hang on Windows.")
def test_ray_shutdown_then_call_list(short_gcs_publish_timeout, shutdown_only):
"""Make sure ray will not kill cpython when using unrecognized ObjectId"""
# Set include_dashboard=False to have faster startup.
ray.init(num_cpus=1, include_dashboard=False)
my_ref = ray.put("anystring")
@ray.remote
def f(s):
print(s)
ray.shutdown()
ray.init(num_cpus=1, include_dashboard=False)
with pytest.raises(ValueError, match="Ray object whose owner is unknown"):
f.remote([my_ref]) # This would cause full CPython death.
ray.shutdown()
wait_for_condition(lambda: len(get_all_ray_worker_processes()) == 0)
@pytest.mark.skipif(platform.system() == "Windows", reason="Hang on Windows.")
def test_ray_shutdown_then_get(short_gcs_publish_timeout, shutdown_only):
"""Make sure ray will not hang when trying to Get an unrecognized Obj."""
# Set include_dashboard=False to have faster startup.
ray.init(num_cpus=1, include_dashboard=False)
my_ref = ray.put("anystring")
ray.shutdown()
ray.init(num_cpus=1, include_dashboard=False)
with pytest.raises(ValueError, match="Ray objects whose owner is unknown"):
# This used to cause ray to hang indefinitely (without timeout) or
# throw a timeout exception if a timeout was provided. Now it is expected to
# throw an exception reporting the unknown object.
ray.get(my_ref, timeout=30)
ray.shutdown()
wait_for_condition(lambda: len(get_all_ray_worker_processes()) == 0)
@pytest.mark.skipif(platform.system() == "Windows", reason="Hang on Windows.")
def test_ray_shutdown_then_wait(short_gcs_publish_timeout, shutdown_only):
"""Make sure ray will not hang when trying to Get an unrecognized Obj."""
# Set include_dashboard=False to have faster startup.
ray.init(num_cpus=1, include_dashboard=False)
my_ref = ray.put("anystring")
ray.shutdown()
ray.init(num_cpus=1, include_dashboard=False)
my_new_ref = ray.put("anyotherstring")
# If we have some known and some unknown references, we allow the
# function to wait for the valid references; however, if all the
# references are unknown, we expect an error.
ready, not_ready = ray.wait([my_new_ref, my_ref])
with pytest.raises(ValueError, match="Ray object whose owner is unknown"):
# This used to cause ray to hang indefinitely (without timeout) or
# forever return all tasks as not-ready if a timeout was provided.
# Now it is expected to throw an exception reporting if all objects are
# unknown.
ray.wait(not_ready, timeout=30)
ray.shutdown()
wait_for_condition(lambda: len(get_all_ray_worker_processes()) == 0)
@pytest.mark.skipif(platform.system() == "Windows", reason="Hang on Windows.")
def test_driver_dead(short_gcs_publish_timeout, shutdown_only):
"""Make sure all ray workers are shutdown when driver is killed."""
driver = """
import ray
ray.init(_system_config={"gcs_rpc_server_reconnect_timeout_s": 1})
@ray.remote
def f():
import time
time.sleep(10)
num_cpus = int(ray.available_resources()["CPU"])
tasks = [f.remote() for _ in range(num_cpus)]
"""
p = run_string_as_driver_nonblocking(driver)
# Make sure the driver is running.
time.sleep(1)
assert p.poll() is None
wait_for_condition(lambda: len(get_all_ray_worker_processes()) > 0)
# Kill the driver process.
p.kill()
p.wait()
time.sleep(0.1)
wait_for_condition(
lambda: len(get_all_ray_worker_processes()) == 0, timeout=WAIT_TIMEOUT
)
@pytest.mark.skipif(platform.system() == "Windows", reason="Hang on Windows.")
def test_node_killed(short_gcs_publish_timeout, ray_start_cluster):
"""Make sure all ray workers when nodes are dead."""
cluster = ray_start_cluster
# head node.
cluster.add_node(
num_cpus=0, _system_config={"gcs_rpc_server_reconnect_timeout_s": 1}
)
ray.init(address="auto")
num_worker_nodes = 2
workers = []
for _ in range(num_worker_nodes):
workers.append(cluster.add_node(num_cpus=2))
cluster.wait_for_nodes()
@ray.remote
def f():
import time
time.sleep(100)
num_cpus = int(ray.available_resources()["CPU"])
tasks = [f.remote() for _ in range(num_cpus)] # noqa
wait_for_condition(lambda: len(get_all_ray_worker_processes()) > 0)
for worker in workers:
cluster.remove_node(worker)
wait_for_condition(
lambda: len(get_all_ray_worker_processes()) == 0, timeout=WAIT_TIMEOUT
)
@pytest.mark.skipif(platform.system() == "Windows", reason="Hang on Windows.")
def test_head_node_down(short_gcs_publish_timeout, ray_start_cluster):
"""Make sure all ray workers when head node is dead."""
cluster = ray_start_cluster
# head node.
head = cluster.add_node(
num_cpus=2, _system_config={"gcs_rpc_server_reconnect_timeout_s": 1}
)
# worker nodes.
num_worker_nodes = 2
for _ in range(num_worker_nodes):
cluster.add_node(num_cpus=2)
cluster.wait_for_nodes()
# Start a driver.
driver = """
import ray
ray.init(address="{}")
@ray.remote
def f():
import time
time.sleep(10)
num_cpus = int(ray.available_resources()["CPU"])
tasks = [f.remote() for _ in range(num_cpus)]
import time
time.sleep(100)
""".format(
cluster.address
)
p = run_string_as_driver_nonblocking(driver)
# Make sure the driver is running.
time.sleep(1)
wait_for_condition(lambda: p.poll() is None)
wait_for_condition(lambda: len(get_all_ray_worker_processes()) > 0)
cluster.remove_node(head)
wait_for_condition(
lambda: len(get_all_ray_worker_processes()) == 0, timeout=WAIT_TIMEOUT
)
def test_raylet_graceful_exit_upon_agent_exit(ray_start_cluster):
cluster = ray_start_cluster
# head
cluster.add_node(num_cpus=0)
def get_raylet_agent_procs(worker):
raylet = None
for p in worker.live_processes():
if p[0] == "raylet":
raylet = p[1]
assert raylet is not None
agent = None
def verify():
nonlocal agent
children = psutil.Process(raylet.pid).children()
# in case linux truncates the proctitle
target_path = ray_constants.AGENT_PROCESS_TYPE_DASHBOARD_AGENT[:15]
for child in children:
if target_path in " ".join(child.cmdline()):
agent = child
return True
return False
# wait until proctitle is updated
wait_for_condition(verify, timeout=1, retry_interval_ms=100)
if agent is not None:
return raylet, agent
raise ValueError("dashboard agent not found")
# Make sure raylet exits gracefully upon agent terminated by SIGTERM.
worker = cluster.add_node(num_cpus=0)
raylet, agent = get_raylet_agent_procs(worker)
agent.terminate()
exit_code = raylet.wait()
# When the agent is terminated
assert exit_code == 0
# Make sure raylet exits gracefully upon agent terminated by SIGKILL.
# TODO(sang): Make raylet exits ungracefully in this case. It is currently
# not possible because we cannot detect the exit code of children process
# from cpp code.
worker = cluster.add_node(num_cpus=0)
raylet, agent = get_raylet_agent_procs(worker)
agent.kill()
exit_code = raylet.wait()
# When the agent is terminated
assert exit_code == 0
def test_raylet_graceful_exit_upon_runtime_env_agent_exit(ray_start_cluster):
cluster = ray_start_cluster
# head
cluster.add_node(num_cpus=0)
def get_raylet_runtime_env_agent_procs(worker):
raylet = None
for p in worker.live_processes():
if p[0] == "raylet":
raylet = p[1]
assert raylet is not None
agent = None
def verify():
nonlocal agent
children = psutil.Process(raylet.pid).children()
# in case linux truncates the proctitle
target_path = ray_constants.AGENT_PROCESS_TYPE_RUNTIME_ENV_AGENT[:15]
for child in children:
if target_path in " ".join(child.cmdline()):
agent = child
return True
return False
# wait until proctitle is updated
wait_for_condition(verify, timeout=1, retry_interval_ms=100)
if agent is not None:
return raylet, agent
raise ValueError("runtime env agent not found")
# Make sure raylet exits gracefully upon agent terminated by SIGKILL.
# TODO(sang): Make raylet exits ungracefully in this case. It is currently
# not possible because we cannot detect the exit code of children process
# from cpp code.
worker = cluster.add_node(num_cpus=0)
raylet, agent = get_raylet_runtime_env_agent_procs(worker)
agent.kill()
exit_code = raylet.wait()
# When the agent is terminated
assert exit_code == 0
@pytest.mark.skipif(platform.system() == "Windows", reason="Hang on Windows.")
def test_worker_sigterm(shutdown_only):
"""Verify a worker process is killed by a sigterm."""
ray.init(num_cpus=1)
@ray.remote
def f():
return os.getpid(), ray.get_runtime_context().get_worker_id()
pid, worker_id = ray.get(f.remote())
os.kill(pid, signal.SIGTERM)
def verify():
w = get_worker(id=worker_id)
alive = w.is_alive
assert not alive
assert "SIGTERM" in w.exit_detail
assert w.exit_type == "SYSTEM_ERROR"
return True
wait_for_condition(verify)
@ray.remote(num_cpus=1)
class Actor:
def pid(self):
return os.getpid(), ray.get_runtime_context().get_worker_id()
a = Actor.remote()
pid, worker_id = ray.get(a.pid.remote())
os.kill(pid, signal.SIGTERM)
def verify():
w = get_worker(id=worker_id)
alive = w.is_alive
assert not alive
assert "SIGTERM" in w.exit_detail
assert w.exit_type == "SYSTEM_ERROR"
return True
wait_for_condition(verify)
@pytest.mark.skipif(platform.system() == "Windows", reason="Hang on Windows.")
def test_worker_proc_child_no_leak(shutdown_only):
"""Verify a worker process is not leaked when placement group is removed"""
ray.init(num_cpus=1)
@ray.remote(num_cpus=1)
class Actor:
def __init__(self) -> None:
self.p = None
def sleep(self):
mp_context = multiprocessing.get_context("spawn")
self.p = mp_context.Process(target=time.sleep, args=(1000,))
self.p.daemon = True
self.p.start()
print(f"[pid={os.getpid()}ppid={os.getppid()}]sleeping for 1")
time.sleep(1)
return ray.get_runtime_context().get_worker_id(), self.p.pid
# Create a placement group
pg = ray.util.placement_group([{"CPU": 1}])
ray.get(pg.ready())
# Create an actor to a placement group.
actor = Actor.options(
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=pg,
)
).remote()
ray.get(actor.__ray_ready__.remote())
worker_id, child_pid = ray.get(actor.sleep.remote())
# Remove the placement group
ray.util.remove_placement_group(pg)
def verify():
try:
psutil.Process(pid=child_pid).status()
except psutil.NoSuchProcess:
return True
wait_for_condition(verify)
def verify():
w = get_worker(id=worker_id)
assert "placement group was removed" in w.exit_detail
assert w.exit_type == "INTENDED_SYSTEM_EXIT"
return True
wait_for_condition(verify)
@pytest.mark.skipif(platform.system() == "Windows", reason="Hang on Windows.")
@pytest.mark.parametrize("is_get", [False, True])
def test_sigterm_while_ray_get_and_wait(shutdown_only, is_get):
"""Verify when sigterm is received while running
ray.get, it will clean up the worker process properly.
"""
@ray.remote(max_retries=0)
def f():
time.sleep(300)
@ray.remote(max_retries=0)
def ray_get_wait_task():
ref = f.remote()
if is_get:
ray.get(ref)
else:
ray.wait([ref])
# TODO(sang): The task failure is not properly propagated.
r = ray_get_wait_task.remote()
def wait_for_task():
t = list_tasks(filters=[("name", "=", "ray_get_wait_task")])[0]
return t.state == "RUNNING"
wait_for_condition(wait_for_task)
# Kill a task that's blocked by ray.get
t = list_tasks(filters=[("name", "=", "ray_get_wait_task")])[0]
os.kill(t.worker_pid, signal.SIGTERM)
with pytest.raises(ray.exceptions.WorkerCrashedError):
ray.get(r)
def verify():
t = list_tasks(filters=[("name", "=", "ray_get_wait_task")])[0]
w = get_worker(t.worker_id)
assert t.state == "FAILED"
assert w.exit_type == "SYSTEM_ERROR"
assert "SIGTERM" in w.exit_detail
return True
wait_for_condition(verify)
@pytest.mark.skipif(platform.system() == "Windows", reason="Hang on Windows.")
def test_kill_actor_after_restart(shutdown_only):
"""Test that killing an actor from a previous session raises a helpful error."""
# Set include_dashboard=False to have faster startup.
ray.init(num_cpus=1, include_dashboard=False)
@ray.remote
class A:
pass
a = A.remote()
# Restart ray
ray.shutdown()
ray.init(num_cpus=1, include_dashboard=False)
# Attempting to kill an actor from the previous session should raise
# a helpful error message instead of crashing the interpreter.
with pytest.raises(
ray.exceptions.ActorHandleNotFoundError,
match="ActorHandle objects are not valid across Ray sessions",
):
ray.kill(a)
ray.shutdown()
wait_for_condition(lambda: len(get_all_ray_worker_processes()) == 0)
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))