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

1660 lines
54 KiB
Python

"""
Some instructions on writing CLI tests:
1. Look at test_ray_start for a simple output test example.
2. To get a valid regex, start with copy-pasting your output from a captured
version (no formatting). Then escape ALL regex characters (parenthesis,
brackets, dots, etc.). THEN add ".+" to all the places where info might
change run to run.
3. Look at test_ray_up for an example of how to mock AWS, commands,
and autoscaler config.
4. Print your outputs!!!! Tests are impossible to debug if they fail
and you did not print anything. Since command output is captured by click,
MAKE SURE YOU print(result.output) when tests fail!!!
WARNING: IF YOU MOCK AWS, DON'T FORGET THE AWS_CREDENTIALS FIXTURE.
THIS IS REQUIRED SO BOTO3 DOES NOT ACCESS THE ACTUAL AWS SERVERS.
Note: config cache does not work with AWS mocks since the AWS resource ids are
randomized each time.
"""
import glob
import json
import multiprocessing as mp
import multiprocessing.connection
import os
import re
import sys
import tempfile
import threading
import time
import uuid
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Optional
from unittest import mock
from unittest.mock import MagicMock, patch
import pytest
import yaml
from click.testing import CliRunner
from moto import mock_aws
from testfixtures import Replacer
from testfixtures.popen import MockPopen, PopenBehaviour
import ray
import ray._private.ray_constants as ray_constants
import ray.autoscaler._private.aws.config as aws_config
import ray.autoscaler._private.constants as autoscaler_constants
import ray.scripts.scripts as scripts
import ray.tests.aws.utils.helpers as aws_helpers
from ray._common.network_utils import build_address, parse_address
from ray._common.test_utils import wait_for_condition
from ray._common.utils import get_default_ray_temp_dir
from ray.autoscaler._private.providers import _get_node_provider
from ray.autoscaler._private.util import prepare_config
from ray.cluster_utils import cluster_not_supported
from ray.util.check_open_ports import check_open_ports
from ray.util.state import list_nodes
import psutil
boto3_list = [
{
"InstanceType": "t1.micro",
"VCpuInfo": {"DefaultVCpus": 1},
"MemoryInfo": {"SizeInMiB": 627},
},
{
"InstanceType": "t3a.small",
"VCpuInfo": {"DefaultVCpus": 2},
"MemoryInfo": {"SizeInMiB": 2048},
},
{
"InstanceType": "m4.4xlarge",
"VCpuInfo": {"DefaultVCpus": 16},
"MemoryInfo": {"SizeInMiB": 65536},
},
{
"InstanceType": "p3.8xlarge",
"VCpuInfo": {"DefaultVCpus": 32},
"MemoryInfo": {"SizeInMiB": 249856},
"GpuInfo": {"Gpus": [{"Name": "V100", "Count": 4}]},
},
]
@pytest.fixture
def configure_lang():
"""Configure output for travis + click."""
if sys.platform != "darwin":
os.environ["LC_ALL"] = "C.UTF-8"
os.environ["LANG"] = "C.UTF-8"
@pytest.fixture
def cleanup_ray():
"""Shutdown all ray instances"""
yield
runner = CliRunner()
runner.invoke(scripts.stop, ["--force"])
ray.shutdown()
@pytest.fixture
def configure_aws():
"""Mocked AWS Credentials for moto."""
os.environ["LC_ALL"] = "C.UTF-8"
os.environ["LANG"] = "C.UTF-8"
os.environ["AWS_ACCESS_KEY_ID"] = "testing"
os.environ["AWS_SECRET_ACCESS_KEY"] = "testing"
os.environ["AWS_SECURITY_TOKEN"] = "testing"
os.environ["AWS_SESSION_TOKEN"] = "testing"
# moto (boto3 mock) only allows a hardcoded set of AMIs
# Use mock_aws context manager and boto3 to find the AMI
import boto3
# In moto 5.x, AWS managed policies (e.g., AmazonEC2FullAccess) are not
# loaded by default for performance. Enable them since the autoscaler
# attaches these policies to the IAM role.
with mock_aws(config={"iam": {"load_aws_managed_policies": True}}):
ec2_client = boto3.client("ec2", region_name="us-west-2")
images = ec2_client.describe_images(
Filters=[{"Name": "name", "Values": ["Deep Learning AMI Ubuntu*"]}]
)["Images"]
dlami = images[0]["ImageId"]
aws_config.DEFAULT_AMI["us-west-2"] = dlami
list_instances_mock = MagicMock(return_value=boto3_list)
with patch(
"ray.autoscaler._private.aws.node_provider.list_ec2_instances",
list_instances_mock,
):
yield
@pytest.fixture(scope="function")
def _unlink_test_ssh_key():
"""Use this to remove the keys spawned by ray up."""
yield
try:
for path in glob.glob(os.path.expanduser("~/.ssh/__test-cli_key*")):
os.remove(path)
except FileNotFoundError:
pass
def _start_ray_and_block(
runner, child_conn: multiprocessing.connection.Connection, as_head: bool
):
"""Utility function to start a CLI command with `ray start --block`
This function is expected to be run in another process, where `child_conn` is used
for IPC with the parent.
"""
args = ["--block"]
if as_head:
args.append("--head")
else:
# Worker node
args.append(f"--address=localhost:{ray_constants.DEFAULT_PORT}")
result = runner.invoke(
scripts.start,
args,
)
# Should be blocked until stopped by signals (SIGTERM)
child_conn.send(result.output)
def _debug_die(output, assert_msg: str = ""):
print("!!!!")
print(output)
print("!!!!")
assert False, assert_msg
def _fail_if_false(
predicate: bool, stdout: Optional[str] = "", assert_msg: Optional[str] = ""
):
if not predicate:
_debug_die(stdout, assert_msg)
def _die_on_error(result):
_fail_if_false(result.exit_code == 0, result.output)
def test_log_unexpected_subprocess_exit_details(monkeypatch, tmp_path):
logs_dir = tmp_path
(logs_dir / "gcs_server.err").write_text("sentinel_tail_line\n", encoding="utf-8")
emitted_errors = []
class _IndentedContext:
def __enter__(self):
return None
def __exit__(self, exc_type, exc, tb):
return False
def _render_message(message, args):
try:
return str(message).format(*args)
except Exception:
return f"{message} {' '.join(map(str, args))}"
def _capture_error(message, *args, **kwargs):
emitted_errors.append(_render_message(message, args))
monkeypatch.setattr(scripts.cli_logger, "indented", lambda: _IndentedContext())
monkeypatch.setattr(scripts.cli_logger, "error", _capture_error)
monkeypatch.setattr(scripts.cli_logger, "warning", lambda *args, **kwargs: None)
monkeypatch.setattr(scripts.cli_logger, "newline", lambda *args, **kwargs: None)
process_exit_logger = MagicMock()
fake_process = MagicMock(returncode=137)
scripts._log_unexpected_subprocess_exit_details(
[("gcs_server", fake_process)],
str(logs_dir),
process_exit_logger,
)
process_exit_logger.error.assert_called_once()
logged_message = process_exit_logger.error.call_args.args[1]
assert "Some Ray subprocesses exited unexpectedly:" in logged_message
assert "gcs_server [exit code=" in logged_message
assert any("BEGIN gcs_server.err tail (exit code(s)=" in x for x in emitted_errors)
assert any("sentinel_tail_line" in x for x in emitted_errors)
def _debug_check_line_by_line(result, expected_lines):
"""Print the result and expected output line-by-line."""
output_lines = result.output.split("\n")
i = 0
for out in output_lines:
if i >= len(expected_lines):
i += 1
print("!!!!!! Expected fewer lines")
context = [f"CONTEXT: {line}" for line in output_lines[i - 3 : i]]
print("\n".join(context))
extra = [f"-- {line}" for line in output_lines[i:]]
print("\n".join(extra))
break
exp = expected_lines[i]
matched = re.fullmatch(exp + r" *", out) is not None
if not matched:
print(f"{i:>3}: {out}")
print(f"!!! ^ ERROR: Expected (regex): {repr(exp)}")
else:
print(f"{i:>3}: {out}")
i += 1
if i < len(expected_lines):
print("!!! ERROR: Expected extra lines (regex):")
for line in expected_lines[i:]:
print(repr(line))
assert False, (result.output, expected_lines)
@contextmanager
def _setup_popen_mock(commands_mock, commands_verifier=None):
"""
Mock subprocess.Popen's behavior and if applicable, intercept the commands
received by Popen and check if they are as expected using
commands_verifier provided by caller.
TODO(xwjiang): Ideally we should write a lexical analyzer that can parse
in a more intelligent way.
"""
Popen = MockPopen()
Popen.set_default(behaviour=commands_mock)
with Replacer() as replacer:
replacer.replace("subprocess.Popen", Popen)
yield
if commands_verifier:
assert commands_verifier(Popen.all_calls)
def _load_output_pattern(name):
pattern_dir = Path(__file__).parent / "test_cli_patterns"
with open(str(pattern_dir / name)) as f:
# Remove \n from each line.
# Substitute the Ray version in each line containing the string
# {ray_version}.
out = []
for x in f.readlines():
if "{ray_version}" in x:
out.append(x[:-1].format(ray_version=ray.__version__))
else:
out.append(x[:-1])
return out
def _check_output_via_pattern(name, result):
expected_lines = _load_output_pattern(name)
if result.exception is not None:
raise result.exception from None
print(result.output)
expected = r" *\n".join(expected_lines) + "\n?"
if re.fullmatch(expected, result.output) is None:
_debug_check_line_by_line(result, expected_lines)
assert result.exit_code == 0
DEFAULT_TEST_CONFIG_PATH = str(
Path(__file__).parent / "test_cli_patterns" / "test_ray_up_config.yaml"
)
MISSING_MAX_WORKER_CONFIG_PATH = str(
Path(__file__).parent
/ "test_cli_patterns"
/ "test_ray_up_no_max_worker_config.yaml"
)
DOCKER_TEST_CONFIG_PATH = str(
Path(__file__).parent / "test_cli_patterns" / "test_ray_up_docker_config.yaml"
)
def test_enable_usage_stats(monkeypatch, tmp_path):
tmp_usage_stats_config_path = tmp_path / "config.json"
monkeypatch.setenv("RAY_USAGE_STATS_CONFIG_PATH", str(tmp_usage_stats_config_path))
runner = CliRunner()
runner.invoke(scripts.enable_usage_stats, [])
assert '{"usage_stats": true}' == tmp_usage_stats_config_path.read_text()
def test_disable_usage_stats(monkeypatch, tmp_path):
tmp_usage_stats_config_path = tmp_path / "config.json"
monkeypatch.setenv("RAY_USAGE_STATS_CONFIG_PATH", str(tmp_usage_stats_config_path))
runner = CliRunner()
runner.invoke(scripts.disable_usage_stats, [])
assert '{"usage_stats": false}' == tmp_usage_stats_config_path.read_text()
# We add`env={"RAY_USAGE_STATS_PROMPT_ENABLED": "0"}` in these tests because
# it seems like interactive terminal detection works differently in python 3.8
# compared to 3.7. Without this, tests would fail.
# Todo: This should be removed again. Also, some tests are currently skipped.
@pytest.mark.skipif(sys.platform == "darwin", reason="Currently failing on OSX")
def test_ray_start(configure_lang, monkeypatch, tmp_path, cleanup_ray):
monkeypatch.setenv("RAY_USAGE_STATS_CONFIG_PATH", str(tmp_path / "config.json"))
runner = CliRunner(env={"RAY_USAGE_STATS_PROMPT_ENABLED": "0"})
temp_dir = os.path.join("/tmp", uuid.uuid4().hex)
result = runner.invoke(
scripts.start,
[
"--head",
"--log-style=pretty",
"--log-color",
"False",
"--port",
"0",
"--temp-dir",
temp_dir,
],
)
# Check that --temp-dir arg worked:
assert os.path.isfile(os.path.join(temp_dir, "ray_current_cluster"))
assert os.path.isdir(os.path.join(temp_dir, "session_latest"))
_die_on_error(result)
_die_on_error(runner.invoke(scripts.stop))
if ray_constants.IS_WINDOWS_OR_OSX:
_check_output_via_pattern("test_ray_start_windows_osx.txt", result)
else:
_check_output_via_pattern("test_ray_start.txt", result)
# Check that we can rerun `ray start` even though the cluster address file
# is already written.
_die_on_error(
runner.invoke(
scripts.start,
[
"--head",
"--log-style=pretty",
"--log-color",
"False",
"--port",
"0",
"--temp-dir",
temp_dir,
],
)
)
@pytest.mark.skipif(
sys.platform == "darwin" and "travis" in os.environ.get("USER", ""),
reason=("Mac builds don't provide proper locale support"),
)
def test_ray_start_worker_can_specify_temp_dir(configure_lang, tmp_path, cleanup_ray):
"""
Verify that ray start --temp-dir works on worker nodes independently of head node.
"""
runner = CliRunner(env={"RAY_USAGE_STATS_PROMPT_ENABLED": "0"})
# Start head node without specifying temp-dir
result = runner.invoke(
scripts.start,
[
"--head",
],
)
print(result.output)
_die_on_error(result)
# Start worker node with temp-dir specified
worker_temp_dir = os.path.join("/tmp", uuid.uuid4().hex)
result = runner.invoke(
scripts.start,
[
f"--address=localhost:{ray_constants.DEFAULT_PORT}",
f"--temp-dir={worker_temp_dir}",
],
)
_die_on_error(result)
# Check that worker temp-dir was created at the specified location
assert os.path.isfile(os.path.join(worker_temp_dir, "ray_current_cluster"))
assert os.path.isdir(os.path.join(worker_temp_dir, "session_latest"))
# Check that we can rerun `ray start` even though the cluster address file
# is already written.
_die_on_error(
runner.invoke(
scripts.start,
[
f"--address=localhost:{ray_constants.DEFAULT_PORT}",
f"--temp-dir={worker_temp_dir}",
],
)
)
def _ray_start_hook(ray_params, head):
os.makedirs(ray_params.temp_dir, exist_ok=True)
with open(os.path.join(ray_params.temp_dir, "ray_hook_ok"), "w") as f:
f.write("HOOK_OK")
@pytest.mark.skipif(
sys.platform == "darwin" and "travis" in os.environ.get("USER", ""),
reason=("Mac builds don't provide proper locale support"),
)
def test_ray_start_hook(configure_lang, monkeypatch, cleanup_ray):
monkeypatch.setenv("RAY_START_HOOK", "ray.tests.test_cli._ray_start_hook")
runner = CliRunner(env={"RAY_USAGE_STATS_PROMPT_ENABLED": "0"})
temp_dir = os.path.join("/tmp", uuid.uuid4().hex)
runner.invoke(
scripts.start,
[
"--head",
"--log-style=pretty",
"--log-color",
"False",
"--port",
"0",
"--temp-dir",
temp_dir,
],
)
# Check that the hook executed.
assert os.path.exists(temp_dir)
assert os.path.exists(os.path.join(temp_dir, "ray_hook_ok"))
_die_on_error(runner.invoke(scripts.stop))
@pytest.mark.skipif(
sys.version_info.minor >= 8, reason="Currently fails with Python 3.8+"
)
@pytest.mark.skipif(
sys.platform == "darwin",
reason=("Mac builds don't provide proper locale support. "),
)
@pytest.mark.skipif(
sys.platform == "win32", reason="Windows signal handling not compatible"
)
def test_ray_start_head_block_and_signals(
configure_lang, monkeypatch, tmp_path, cleanup_ray
):
"""Test `ray start` with `--block` as heads and workers and signal handles"""
monkeypatch.setenv("RAY_USAGE_STATS_CONFIG_PATH", str(tmp_path / "config.json"))
runner = CliRunner(env={"RAY_USAGE_STATS_PROMPT_ENABLED": "0"})
head_parent_conn, head_child_conn = mp.Pipe()
# Run `ray start --block --head` in another process and blocks
head_proc = mp.Process(
target=_start_ray_and_block,
kwargs={"runner": runner, "child_conn": head_child_conn, "as_head": True},
)
head_proc._start_method = "spawn"
# Run
head_proc.start()
# Give it some time to start various subprocesses and `ray stop`
# A smaller interval seems to cause occasional failure as the head process
# was stopped too early before spawning all the subprocesses.
time.sleep(5)
# Terminate some of the children process
children = psutil.Process(head_proc.pid).children()
# Terminate everyone other than GCS
# NOTE(rickyyx): The choice of picking GCS is arbitrary.
gcs_proc = None
for child in children:
if "gcs_server" in child.name():
gcs_proc = child
continue
child.terminate()
child.wait(5)
if not head_proc.is_alive():
# NOTE(rickyyx): call recv() here is safe since the process
# is guaranteed to be terminated.
_fail_if_false(
False,
head_parent_conn.recv(),
(
"`ray start --head --block` should not exit"
f"({head_proc.exitcode}) when a subprocess is "
"terminated with SIGTERM."
),
)
# Kill the GCS last should unblock the CLI
if gcs_proc:
gcs_proc.kill()
gcs_proc.wait(10)
# NOTE(rickyyx): The wait here is needed for the `head_proc`
# process to exit
head_proc.join(5)
exit_log = Path(
os.path.join(
get_default_ray_temp_dir(),
ray_constants.SESSION_LATEST,
"logs",
"ray_process_exit.log",
)
)
assert exit_log.exists(), f"ray_process_exit.log not found at {exit_log}"
content = exit_log.read_text(encoding="utf-8")
assert (
"Some Ray subprocesses exited unexpectedly:" in content
), "Expected message not found in ray_process_exit.log."
# Process with "--block" should be dead with a subprocess killed
if head_proc.is_alive() or head_proc.exitcode == 0:
# NOTE(rickyyx): call recv() here is safe since the process
# is guaranteed to be terminated thus invocation is non-blocking.
_fail_if_false(
False,
head_parent_conn.recv() if not head_proc.is_alive() else "still alive",
(
"Head process should have exited with errors when one of"
f" subprocesses killed. But exited={head_proc.exitcode}"
),
)
@pytest.mark.skipif(
sys.version_info.minor >= 8, reason="Currently fails with Python 3.8+"
)
@pytest.mark.skipif(
sys.platform == "darwin",
reason=("Mac builds don't provide proper locale support. "),
)
@pytest.mark.skipif(
sys.platform == "win32", reason="Windows signal handling not compatible"
)
def test_ray_start_block_and_stop(configure_lang, monkeypatch, tmp_path, cleanup_ray):
"""Test `ray start` with `--block` as heads and workers and `ray stop`"""
monkeypatch.setenv("RAY_USAGE_STATS_CONFIG_PATH", str(tmp_path / "config.json"))
runner = CliRunner(env={"RAY_USAGE_STATS_PROMPT_ENABLED": "0"})
head_parent_conn, head_child_conn = mp.Pipe()
worker_parent_conn, worker_child_conn = mp.Pipe()
# Run `ray start --block --head` in another process and blocks
head_proc = mp.Process(
target=_start_ray_and_block,
kwargs={"runner": runner, "child_conn": head_child_conn, "as_head": True},
)
head_proc._start_method = "spawn"
# Run `ray start --block --address=localhost:DEFAULT_PORT`
worker_proc = mp.Process(
target=_start_ray_and_block,
kwargs={"runner": runner, "child_conn": worker_child_conn, "as_head": False},
)
worker_proc._start_method = "spawn"
try:
# Run
head_proc.start()
worker_proc.start()
print("head pid ", head_proc.pid)
print("worker pid: ", worker_proc.pid)
# Without sleep list_nodes sometimes failed.
time.sleep(5)
# Wait until all nodes are registered and started.
wait_for_condition(lambda: len(list_nodes()) == 2)
# When ray start --block is called, before it blocks,
# it pings GCS to get the Ray & Python version. If GCS
# is killed before that by ray stop, it can trigger GCS
# timeout error which can break `ray start --block`.
# To avoid this issue, we sleep enough before we stop ray.
time.sleep(5)
stop_result = runner.invoke(scripts.stop)
print("result, ", stop_result.output)
print("exit code, ", stop_result.exit_code)
_die_on_error(stop_result)
# Process with "--block" should be blocked forever w/o
# termination by signals
if not head_proc.is_alive():
# NOTE(rickyyx): call recv() here is safe since the process
# is guaranteed to be terminated.
if head_proc.exitcode == 1:
assert False, (
"ray start --head --block is failed "
"due to unexpected component failures."
)
_fail_if_false(
False,
head_parent_conn.recv(),
(
"`ray start --head --block` (head) should block forever even"
" though Ray subprocesses are stopped normally. But "
f"it exited with {head_proc.exitcode} early. \n"
f"Stop command: {stop_result.output}"
),
)
if not worker_proc.is_alive():
if worker_proc.exitcode == 1:
assert False, (
"ray start --address=<head_ip> --block` (worker) is failed "
"due to unexpected component failures."
)
_fail_if_false(
False,
worker_parent_conn.recv(),
(
"`ray start --block` should block forever even"
" though Ray subprocesses are stopped normally. But"
f"it exited with {worker_proc.exitcode} already. \n"
f"Stop command: {stop_result.output}"
),
)
# Stop both worker and head with SIGTERM
head_proc.terminate()
worker_proc.terminate()
head_proc.join(15)
worker_proc.join(15)
worker_output = "cannot poll"
head_output = "cannot poll"
if head_parent_conn.poll(5):
head_output = head_parent_conn.recv()
if worker_parent_conn.poll(5):
worker_output = worker_parent_conn.recv()
print("head ", head_output)
print("worker ", worker_output)
assert not head_proc.is_alive(), "head node is not killed."
assert not worker_proc.is_alive(), "worker node is not killed."
_fail_if_false(
head_proc.exitcode == 0,
head_output,
f"Head process failed unexpectedly({head_proc.exitcode})",
)
_fail_if_false(
worker_proc.exitcode == 0,
worker_output,
f"Worker process failed unexpectedly({worker_proc.exitcode})",
)
finally:
head_proc.kill()
worker_proc.kill()
@pytest.mark.skipif(
sys.platform == "darwin" and "travis" in os.environ.get("USER", ""),
reason=("Mac builds don't provide proper locale support"),
)
def test_ray_up(
configure_lang, _unlink_test_ssh_key, configure_aws, monkeypatch, tmp_path
):
monkeypatch.setenv("RAY_USAGE_STATS_CONFIG_PATH", str(tmp_path / "config.json"))
def commands_mock(command, stdin):
# if we want to have e.g. some commands fail,
# we can have overrides happen here.
# unfortunately, cutting out SSH prefixes and such
# is, to put it lightly, non-trivial
if "uptime" in command:
return PopenBehaviour(stdout=b"MOCKED uptime")
if "rsync" in command:
return PopenBehaviour(stdout=b"MOCKED rsync")
if "ray" in command:
return PopenBehaviour(stdout=b"MOCKED ray")
return PopenBehaviour(stdout=b"MOCKED GENERIC")
with _setup_popen_mock(commands_mock):
# config cache does not work with mocks
runner = CliRunner(env={"RAY_USAGE_STATS_PROMPT_ENABLED": "0"})
result = runner.invoke(
scripts.up,
[
DEFAULT_TEST_CONFIG_PATH,
"--no-config-cache",
"-y",
"--log-style=pretty",
"--log-color",
"False",
],
)
_check_output_via_pattern("test_ray_up.txt", result)
@pytest.mark.skipif(
sys.platform == "darwin" and "travis" in os.environ.get("USER", ""),
reason=("Mac builds don't provide proper locale support"),
)
def test_ray_up_docker(
configure_lang, _unlink_test_ssh_key, configure_aws, monkeypatch, tmp_path
):
monkeypatch.setenv("RAY_USAGE_STATS_CONFIG_PATH", str(tmp_path / "config.json"))
def commands_mock(command, stdin):
# if we want to have e.g. some commands fail,
# we can have overrides happen here.
# unfortunately, cutting out SSH prefixes and such
# is, to put it lightly, non-trivial
if ".Config.Env" in command:
return PopenBehaviour(stdout=b"{}")
if "uptime" in command:
return PopenBehaviour(stdout=b"MOCKED uptime")
if "rsync" in command:
return PopenBehaviour(stdout=b"MOCKED rsync")
if "ray" in command:
return PopenBehaviour(stdout=b"MOCKED ray")
return PopenBehaviour(stdout=b"MOCKED GENERIC")
with _setup_popen_mock(commands_mock):
# config cache does not work with mocks
runner = CliRunner(env={"RAY_USAGE_STATS_PROMPT_ENABLED": "0"})
result = runner.invoke(
scripts.up,
[
DOCKER_TEST_CONFIG_PATH,
"--no-config-cache",
"-y",
"--log-style=pretty",
"--log-color",
"False",
],
)
_check_output_via_pattern("test_ray_up_docker.txt", result)
@pytest.mark.skipif(
sys.platform == "darwin" and "travis" in os.environ.get("USER", ""),
reason=("Mac builds don't provide proper locale support"),
)
def test_ray_up_record(
configure_lang, _unlink_test_ssh_key, configure_aws, monkeypatch, tmp_path
):
monkeypatch.setenv("RAY_USAGE_STATS_CONFIG_PATH", str(tmp_path / "config.json"))
def commands_mock(command, stdin):
# if we want to have e.g. some commands fail,
# we can have overrides happen here.
# unfortunately, cutting out SSH prefixes and such
# is, to put it lightly, non-trivial
if "uptime" in command:
return PopenBehaviour(stdout=b"MOCKED uptime")
if "rsync" in command:
return PopenBehaviour(stdout=b"MOCKED rsync")
if "ray" in command:
return PopenBehaviour(stdout=b"MOCKED ray")
return PopenBehaviour(stdout=b"MOCKED GENERIC")
with _setup_popen_mock(commands_mock):
# config cache does not work with mocks
runner = CliRunner(env={"RAY_USAGE_STATS_PROMPT_ENABLED": "0"})
result = runner.invoke(
scripts.up,
[DEFAULT_TEST_CONFIG_PATH, "--no-config-cache", "-y", "--log-style=record"],
)
_check_output_via_pattern("test_ray_up_record.txt", result)
@pytest.mark.skipif(
sys.platform == "darwin" and "travis" in os.environ.get("USER", ""),
reason=("Mac builds don't provide proper locale support"),
)
def test_ray_attach(configure_lang, configure_aws, _unlink_test_ssh_key):
def commands_mock(command, stdin):
# TODO(maximsmol): this is a hack since stdout=sys.stdout
# doesn't work with the mock for some reason
print("ubuntu@ip-.+:~$ exit")
return PopenBehaviour(stdout="ubuntu@ip-.+:~$ exit")
with _setup_popen_mock(commands_mock):
runner = CliRunner()
result = runner.invoke(
scripts.up,
[
DEFAULT_TEST_CONFIG_PATH,
"--no-config-cache",
"-y",
"--log-style=pretty",
"--log-color",
"False",
],
)
_die_on_error(result)
result = runner.invoke(
scripts.attach,
[
DEFAULT_TEST_CONFIG_PATH,
"--no-config-cache",
"--log-style=pretty",
"--log-color",
"False",
],
)
_check_output_via_pattern("test_ray_attach.txt", result)
@pytest.mark.skipif(
sys.platform == "darwin" and "travis" in os.environ.get("USER", ""),
reason=("Mac builds don't provide proper locale support"),
)
def test_ray_attach_with_ip(configure_lang, configure_aws, _unlink_test_ssh_key):
from ray.autoscaler._private.commands import get_worker_node_ips
worker_ip_to_verify = None
def commands_mock(command, stdin):
# TODO(maximsmol): this is a hack since stdout=sys.stdout
# doesn't work with the mock for some reason
print("ubuntu@ip-.+:~$ exit")
return PopenBehaviour(stdout="ubuntu@ip-.+:~$ exit")
def commands_verifier(calls):
for call in calls:
if len(call[1]) > 0:
cmd = (
" ".join(call[1][0]) if isinstance(call[1][0], list) else call[1][0]
)
if "ssh" in cmd and worker_ip_to_verify and worker_ip_to_verify in cmd:
return True
return False
with _setup_popen_mock(commands_mock, commands_verifier):
runner = CliRunner()
result = runner.invoke(
scripts.up,
[
DEFAULT_TEST_CONFIG_PATH,
"--no-config-cache",
"-y",
"--log-style=pretty",
"--log-color",
"False",
],
)
_die_on_error(result)
# Manually create a worker node to test attaching to non-head nodes.
# ray up only creates the head node; workers are launched asynchronously
# by the autoscaler which doesn't run in this mocked test environment.
config = yaml.safe_load(open(DEFAULT_TEST_CONFIG_PATH).read())
config["cluster_name"] = "test-cli"
config = prepare_config(config)
config = aws_config.bootstrap_aws(config)
provider = _get_node_provider(config["provider"], config["cluster_name"])
worker_node_config = config["available_node_types"]["worker_nodes"][
"node_config"
]
tags = aws_helpers.node_provider_tags(config, "worker_nodes")
provider.create_node(worker_node_config, tags, 1)
worker_ips = get_worker_node_ips(
DEFAULT_TEST_CONFIG_PATH, override_cluster_name="test-cli"
)
assert len(worker_ips) > 0, "Worker node should have been created"
worker_ip_to_verify = worker_ips[0]
result = runner.invoke(
scripts.attach,
[
DEFAULT_TEST_CONFIG_PATH,
"--no-config-cache",
"--log-style=pretty",
"--log-color",
"False",
"--node-ip",
worker_ip_to_verify,
],
)
_check_output_via_pattern("test_ray_attach_with_ip.txt", result)
@pytest.mark.skipif(
sys.platform == "darwin" and "travis" in os.environ.get("USER", ""),
reason=("Mac builds don't provide proper locale support"),
)
def test_ray_dashboard(configure_lang, configure_aws, _unlink_test_ssh_key):
def commands_mock(command, stdin):
# TODO(maximsmol): this is a hack since stdout=sys.stdout
# doesn't work with the mock for some reason
print("ubuntu@ip-.+:~$ exit")
return PopenBehaviour(stdout="ubuntu@ip-.+:~$ exit")
with _setup_popen_mock(commands_mock):
runner = CliRunner()
result = runner.invoke(
scripts.up,
[
DEFAULT_TEST_CONFIG_PATH,
"--no-config-cache",
"-y",
"--log-style=pretty",
"--log-color",
"False",
],
)
_die_on_error(result)
result = runner.invoke(
scripts.dashboard, [DEFAULT_TEST_CONFIG_PATH, "--no-config-cache"]
)
_check_output_via_pattern("test_ray_dashboard.txt", result)
@pytest.mark.skipif(
sys.platform == "darwin" and "travis" in os.environ.get("USER", ""),
reason=("Mac builds don't provide proper locale support"),
)
def test_ray_exec(configure_lang, configure_aws, _unlink_test_ssh_key):
def commands_mock(command, stdin):
# TODO(maximsmol): this is a hack since stdout=sys.stdout
# doesn't work with the mock for some reason
print("This is a test!")
return PopenBehaviour(stdout=b"This is a test!")
def commands_verifier(calls):
for call in calls:
if len(call[1]) > 0:
if any(" ray stop; " in token for token in call[1][0]):
return True
return False
with _setup_popen_mock(commands_mock, commands_verifier):
runner = CliRunner()
result = runner.invoke(
scripts.up,
[
DEFAULT_TEST_CONFIG_PATH,
"--no-config-cache",
"-y",
"--log-style=pretty",
"--log-color",
"False",
],
)
_die_on_error(result)
result = runner.invoke(
scripts.exec,
[
DEFAULT_TEST_CONFIG_PATH,
"--no-config-cache",
"--log-style=pretty",
'"echo This is a test!"',
"--stop",
],
)
_check_output_via_pattern("test_ray_exec.txt", result)
# Try to check if we are running in travis. Bazel overrides and controls
# env vars, so the typical travis env-vars don't help.
# Unfortunately it will not be nice if your username is travis
# and you're running on a Mac.
@pytest.mark.skipif(
sys.platform == "darwin" and "travis" in os.environ.get("USER", ""),
reason=("Mac builds don't provide proper locale support"),
)
def test_ray_submit(configure_lang, configure_aws, _unlink_test_ssh_key):
def commands_mock(command, stdin):
# TODO(maximsmol): this is a hack since stdout=sys.stdout
# doesn't work with the mock for some reason
if "rsync" not in command:
print("This is a test!")
return PopenBehaviour(stdout=b"This is a test!")
with _setup_popen_mock(commands_mock):
runner = CliRunner()
result = runner.invoke(
scripts.up,
[
DEFAULT_TEST_CONFIG_PATH,
"--no-config-cache",
"-y",
"--log-style=pretty",
"--log-color",
"False",
],
)
_die_on_error(result)
with tempfile.NamedTemporaryFile(suffix="test.py", mode="w") as f:
f.write("print('This is a test!')\n")
result = runner.invoke(
scripts.submit,
[
DEFAULT_TEST_CONFIG_PATH,
"--no-config-cache",
"--log-style=pretty",
"--log-color",
"False",
# this is somewhat misleading, since the file
# actually never gets run
# TODO(maximsmol): make this work properly one day?
f.name,
],
)
_check_output_via_pattern("test_ray_submit.txt", result)
@pytest.mark.parametrize("enable_v2", [True, False])
def test_ray_status(shutdown_only, monkeypatch, enable_v2):
address = ray.init(
num_cpus=3, _system_config={"enable_autoscaler_v2": enable_v2}
).get("address")
runner = CliRunner()
def output_ready():
result = runner.invoke(scripts.status)
_ = result.stdout
if not result.exception and "memory" in result.output:
return True
raise RuntimeError(
f"result.exception={result.exception} " f"result.output={result.output}"
)
wait_for_condition(output_ready)
# Wait one whole reporting cycle
time.sleep(autoscaler_constants.AUTOSCALER_UPDATE_INTERVAL_S)
if enable_v2:
filename_expected = "test_ray_status.txt"
else:
filename_expected = "test_ray_status_v1.txt"
result = runner.invoke(scripts.status, [])
_check_output_via_pattern(filename_expected, result)
result_arg = runner.invoke(scripts.status, ["--address", address])
_check_output_via_pattern(filename_expected, result_arg)
# Try to check status with RAY_ADDRESS set
monkeypatch.setenv("RAY_ADDRESS", address)
result_env = runner.invoke(scripts.status)
_check_output_via_pattern(filename_expected, result_env)
result_env_arg = runner.invoke(scripts.status, ["--address", address])
_check_output_via_pattern(filename_expected, result_env_arg)
@pytest.mark.xfail(cluster_not_supported, reason="cluster not supported on Windows")
@pytest.mark.parametrize("enable_v2", [True, False])
def test_ray_status_multinode(ray_start_cluster, enable_v2):
cluster = ray_start_cluster
cluster.add_node(num_cpus=2, _system_config={"enable_autoscaler_v2": enable_v2})
ray.init(address=cluster.address)
for _ in range(3):
cluster.add_node(num_cpus=2)
wait_for_condition(lambda: len(list_nodes()) == 4)
runner = CliRunner()
# Wait one whole reporting cycle
time.sleep(autoscaler_constants.AUTOSCALER_UPDATE_INTERVAL_S)
def output_ready():
result = runner.invoke(scripts.status)
_ = result.stdout
if not result.exception and "memory" in result.output:
return True
raise RuntimeError(
f"result.exception={result.exception} " f"result.output={result.output}"
)
wait_for_condition(output_ready)
result = runner.invoke(scripts.status, [])
if enable_v2:
_check_output_via_pattern("test_ray_status_multinode.txt", result)
else:
_check_output_via_pattern("test_ray_status_multinode_v1.txt", result)
@pytest.fixture
def start_open_port_check_server():
class OpenPortCheckServer(BaseHTTPRequestHandler):
request_ports = None
response_open_ports = []
def do_POST(self):
content_length = int(self.headers["Content-Length"])
post_data = self.rfile.read(content_length)
payload = json.loads(post_data)
OpenPortCheckServer.request_ports = payload["ports"]
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(
json.dumps(
{
"open_ports": OpenPortCheckServer.response_open_ports,
"checked_ports": payload["ports"],
}
).encode("utf-8")
)
server = HTTPServer(("127.0.0.1", 0), OpenPortCheckServer)
server_thread = threading.Thread(target=server.serve_forever)
server_thread.start()
yield (
OpenPortCheckServer,
f"http://{build_address(server.server_address[0], server.server_address[1])}",
)
server.shutdown()
server_thread.join()
def test_ray_check_open_ports(shutdown_only, start_open_port_check_server):
context = ray.init()
open_port_check_server, url = start_open_port_check_server
runner = CliRunner()
result = runner.invoke(
check_open_ports,
[
"-y",
"--service-url",
url,
],
)
assert result.exit_code == 0
assert (
int(parse_address(context.address_info["gcs_address"])[1])
in open_port_check_server.request_ports
)
assert "[🟢] No open ports detected" in result.output
open_port_check_server.response_open_ports = [
context.address_info["metrics_export_port"]
]
result = runner.invoke(
check_open_ports,
[
"-y",
"--service-url",
url,
],
)
assert result.exit_code == 0
assert "[🛑] open ports detected" in result.output
def test_ray_drain_node(monkeypatch, shutdown_only):
monkeypatch.setenv("RAY_py_gcs_connect_timeout_s", "1")
ray.init()
runner = CliRunner()
result = runner.invoke(
scripts.drain_node,
[
"--node-id",
"0db0438b5cfd6e84d7ec07212ed76b23be7886cbd426ef4d1879bebf",
"--reason",
"DRAIN_NODE_REASON_IDLE_TERMINATION",
"--reason-message",
"idle termination",
"--deadline-remaining-seconds",
"-1",
],
)
assert result.exit_code != 0
assert "--deadline-remaining-seconds cannot be negative, got -1" in result.output
# Test invalid drain reason.
result = runner.invoke(
scripts.drain_node,
[
"--node-id",
"0db0438b5cfd6e84d7ec07212ed76b23be7886cbd426ef4d1879bebf",
"--reason",
"DRAIN_NODE_REASON_INVALID",
"--reason-message",
"idle termination",
],
)
assert result.exit_code != 0
assert (
"is not one of 'DRAIN_NODE_REASON_IDLE_TERMINATION', "
"'DRAIN_NODE_REASON_PREEMPTION'" in result.output
)
result = runner.invoke(
scripts.drain_node,
[
"--address",
"127.0.0.2:8888",
"--node-id",
"0db0438b5cfd6e84d7ec07212ed76b23be7886cbd426ef4d1879bebf",
"--reason",
"DRAIN_NODE_REASON_IDLE_TERMINATION",
"--reason-message",
"idle termination",
],
)
assert result.exit_code != 0
assert "Timed out while waiting for GCS to become available" in str(
result.exception
)
result = runner.invoke(
scripts.drain_node,
[
"--node-id",
"invalid-node-id",
"--reason",
"DRAIN_NODE_REASON_IDLE_TERMINATION",
"--reason-message",
"idle termination",
],
)
assert result.exit_code != 0
assert "Invalid hex ID of a Ray node, got invalid-node-id" in result.output
with patch("ray._raylet.GcsClient") as MockGcsClient:
mock_gcs_client = MockGcsClient.return_value
mock_gcs_client.internal_kv_get.return_value = (
'{"ray_version": "ray_version_mismatch"}'.encode()
)
result = runner.invoke(
scripts.drain_node,
[
"--address",
"127.0.0.1:6543",
"--node-id",
"0db0438b5cfd6e84d7ec07212ed76b23be7886cbd426ef4d1879bebf",
"--reason",
"DRAIN_NODE_REASON_IDLE_TERMINATION",
"--reason-message",
"idle termination",
],
)
assert result.exit_code != 0
assert "Ray version mismatch" in str(result.exception)
with patch("ray._raylet.GcsClient") as MockGcsClient:
mock_gcs_client = MockGcsClient.return_value
mock_gcs_client.internal_kv_get.return_value = (
f'{{"ray_version": "{ray.__version__}"}}'.encode()
)
mock_gcs_client.drain_node.return_value = (True, "")
result = runner.invoke(
scripts.drain_node,
[
"--address",
"127.0.0.1:6543",
"--node-id",
"0db0438b5cfd6e84d7ec07212ed76b23be7886cbd426ef4d1879bebf",
"--reason",
"DRAIN_NODE_REASON_IDLE_TERMINATION",
"--reason-message",
"idle termination",
],
)
assert result.exit_code == 0
assert mock_gcs_client.mock_calls[1] == mock.call.drain_node(
"0db0438b5cfd6e84d7ec07212ed76b23be7886cbd426ef4d1879bebf",
1,
"idle termination",
0,
)
with patch("ray._raylet.GcsClient") as MockGcsClient:
mock_gcs_client = MockGcsClient.return_value
mock_gcs_client.internal_kv_get.return_value = (
f'{{"ray_version": "{ray.__version__}"}}'.encode()
)
mock_gcs_client.drain_node.return_value = (False, "Node not idle")
result = runner.invoke(
scripts.drain_node,
[
"--address",
"127.0.0.1:6543",
"--node-id",
"0db0438b5cfd6e84d7ec07212ed76b23be7886cbd426ef4d1879bebf",
"--reason",
"DRAIN_NODE_REASON_IDLE_TERMINATION",
"--reason-message",
"idle termination",
],
)
assert result.exit_code != 0
assert "The drain request is not accepted: Node not idle" in result.output
# Test without node-id
with patch("ray._raylet.GcsClient") as MockGcsClient:
mock_gcs_client = MockGcsClient.return_value
mock_gcs_client.internal_kv_get.return_value = (
f'{{"ray_version": "{ray.__version__}"}}'.encode()
)
mock_gcs_client.drain_node.return_value = (True, "")
result = runner.invoke(
scripts.drain_node,
[
"--address",
"127.0.01:6543",
"--reason",
"DRAIN_NODE_REASON_PREEMPTION",
"--reason-message",
"spot preemption",
],
)
assert result.exit_code == 0
assert mock_gcs_client.mock_calls[1] == mock.call.drain_node(
ray.get_runtime_context().get_node_id(), 2, "spot preemption", 0
)
with patch("time.time_ns", return_value=1000000000), patch(
"ray._raylet.GcsClient"
) as MockGcsClient:
mock_gcs_client = MockGcsClient.return_value
mock_gcs_client.internal_kv_get.return_value = (
f'{{"ray_version": "{ray.__version__}"}}'.encode()
)
mock_gcs_client.drain_node.return_value = (True, "")
result = runner.invoke(
scripts.drain_node,
[
"--address",
"127.0.0.1:6543",
"--node-id",
"0db0438b5cfd6e84d7ec07212ed76b23be7886cbd426ef4d1879bebf",
"--reason",
"DRAIN_NODE_REASON_PREEMPTION",
"--reason-message",
"spot preemption",
"--deadline-remaining-seconds",
"1",
],
)
assert result.exit_code == 0
assert mock_gcs_client.mock_calls[1] == mock.call.drain_node(
"0db0438b5cfd6e84d7ec07212ed76b23be7886cbd426ef4d1879bebf",
2,
"spot preemption",
2000,
)
@pytest.mark.skipif(
sys.platform == "darwin" and "travis" in os.environ.get("USER", ""),
reason=("Mac builds don't provide proper locale support"),
)
def test_ray_cluster_dump(configure_lang, configure_aws, _unlink_test_ssh_key):
def commands_mock(command, stdin):
print("This is a test!")
return PopenBehaviour(stdout=b"This is a test!")
with _setup_popen_mock(commands_mock):
runner = CliRunner()
result = runner.invoke(
scripts.up,
[
DEFAULT_TEST_CONFIG_PATH,
"--no-config-cache",
"-y",
"--log-style=pretty",
"--log-color",
"False",
],
)
_die_on_error(result)
result = runner.invoke(
scripts.cluster_dump, [DEFAULT_TEST_CONFIG_PATH, "--no-processes"]
)
_check_output_via_pattern("test_ray_cluster_dump.txt", result)
def test_kill_actor_by_name_via_cli(ray_start_regular):
"""Test killing a named actor via CLI (both force and graceful termination). Covers regular and detached actors."""
address = ray_start_regular["address"]
runner = CliRunner()
# Kill non-existing actor
result = runner.invoke(
scripts.kill_actor,
[
"--address",
address,
"--name",
"non_existing_actor",
"--namespace",
"ns",
"--force",
],
)
assert result.exit_code == 1
assert "No named actor found: non_existing_actor (namespace=ns)" in result.output
@ray.remote(max_restarts=-1)
class Actor:
def ping(self):
return "pong"
def crash(self):
raise RuntimeError("intentional")
# Regular named actor with namespace
# Kill without --no-restart flag
actor = Actor.options(name="test_actor", namespace="ns").remote()
assert ray.util.list_named_actors(all_namespaces=True) == [
{"name": "test_actor", "namespace": "ns"}
]
try:
ray.get(actor.crash.remote())
except ray.exceptions.RayTaskError:
pass
wait_for_condition(lambda: ray.get(actor.ping.remote()) == "pong", timeout=10)
result = runner.invoke(
scripts.kill_actor,
[
"--address",
address,
"--name",
"test_actor",
"--namespace",
"ns",
"--force",
],
)
_die_on_error(result)
wait_for_condition(
lambda: ray.util.list_named_actors(all_namespaces=True)
== [{"name": "test_actor", "namespace": "ns"}],
timeout=10,
)
wait_for_condition(lambda: ray.get(actor.ping.remote()) == "pong", timeout=10)
# Kill with --no-restart flag
result = runner.invoke(
scripts.kill_actor,
[
"--address",
address,
"--name",
"test_actor",
"--namespace",
"ns",
"--force",
"--no-restart",
],
)
_die_on_error(result)
wait_for_condition(
lambda: ray.util.list_named_actors(all_namespaces=True) == [], timeout=10
)
# Detached named actor
detached_actor = Actor.options(
name="detached_test_actor", namespace="ns", lifetime="detached"
).remote()
assert ray.get(detached_actor.ping.remote()) == "pong"
assert ray.util.list_named_actors(all_namespaces=True) == [
{"name": "detached_test_actor", "namespace": "ns"}
]
result = runner.invoke(
scripts.kill_actor,
[
"--address",
address,
"--name",
"detached_test_actor",
"--namespace",
"ns",
"--force",
"--no-restart",
],
)
_die_on_error(result)
wait_for_condition(
lambda: ray.util.list_named_actors(all_namespaces=True) == [], timeout=10
)
# Test graceful termination of named actor
def check_killed(actorhandle):
try:
ray.get(actorhandle.ping.remote(), timeout=1)
return False
except Exception:
return True
graceful_actor = Actor.options(name="graceful_actor", namespace="ns").remote()
assert ray.get(graceful_actor.ping.remote()) == "pong"
result = runner.invoke(
scripts.kill_actor,
[
"--address",
address,
"--name",
"graceful_actor",
"--namespace",
"ns",
],
)
_die_on_error(result)
wait_for_condition(lambda: check_killed(graceful_actor), timeout=10)
wait_for_condition(
lambda: ray.util.list_named_actors(all_namespaces=True) == [], timeout=10
)
# Check if ray.serve is available (it's an optional dependency)
try:
import ray.serve.scripts # noqa: F401
SERVE_AVAILABLE = True
except ImportError:
SERVE_AVAILABLE = False
@pytest.mark.skipif(not SERVE_AVAILABLE, reason="ray.serve is not installed")
def test_serve_cli_registered():
"""Test that serve CLI is properly registered with the main ray CLI."""
# Verify 'serve' command is registered
assert "serve" in scripts.cli.commands, "serve command should be registered"
# Verify serve subcommands are accessible
serve_cli = scripts.cli.commands["serve"]
expected_commands = {
"start",
"deploy",
"run",
"config",
"status",
"shutdown",
"build",
}
assert expected_commands.issubset(set(serve_cli.commands.keys()))
@pytest.mark.skipif(not SERVE_AVAILABLE, reason="ray.serve is not installed")
def test_ray_serve_help():
"""Test that 'ray serve --help' works correctly."""
runner = CliRunner()
result = runner.invoke(scripts.cli, ["serve", "--help"])
assert result.exit_code == 0, result.output
assert "CLI for managing Serve applications" in result.output
assert "deploy" in result.output
assert "run" in result.output
@pytest.mark.skipif(sys.platform != "win32", reason="Windows-only agent cleanup test")
def test_ray_stop_terminates_agents_on_windows(cleanup_ray):
"""Verify that `ray stop` terminates dashboard and runtime_env agent
processes on Windows, where setproctitle does not change the process
name or command line visible to psutil.
See https://github.com/ray-project/ray/issues/61452
"""
runner = CliRunner(env={"RAY_USAGE_STATS_PROMPT_ENABLED": "0"})
# Start a Ray head node with the dashboard enabled.
result = runner.invoke(
scripts.start,
["--head", "--include-dashboard=true"],
)
_die_on_error(result)
agent_keywords = [
os.path.join("dashboard", "agent.py"),
os.path.join("runtime_env", "agent", "main.py"),
]
def _find_agent_procs():
"""Return agent processes whose cmdline contains one of the keywords."""
agents = []
for proc in psutil.process_iter(["pid", "cmdline"]):
try:
cmdline_str = " ".join(proc.cmdline())
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
if any(kw in cmdline_str for kw in agent_keywords):
agents.append(proc)
return agents
# Wait for both agents to be running.
wait_for_condition(lambda: len(_find_agent_procs()) >= 2, timeout=30)
# Stop Ray.
stop_result = runner.invoke(scripts.stop)
_die_on_error(stop_result)
# Verify that all agent processes have exited.
def _agents_gone():
remaining = _find_agent_procs()
return len(remaining) == 0
wait_for_condition(_agents_gone, timeout=30)
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))