""" The test file for all standalone tests that doesn't requires a shared Serve instance. """ import asyncio import logging import os import random import socket import sys import time import httpx import pytest import ray import ray._private.state as state from ray import serve from ray._common.test_utils import run_string_as_driver, wait_for_condition from ray._raylet import GcsClient from ray.cluster_utils import Cluster, cluster_not_supported from ray.serve._private.api import serve_start_async from ray.serve._private.constants import ( RAY_SERVE_ENABLE_HA_PROXY, SERVE_DEFAULT_APP_NAME, SERVE_NAMESPACE, SERVE_PROXY_NAME, ) from ray.serve._private.default_impl import create_cluster_node_info_cache from ray.serve._private.http_util import set_socket_reuse_port from ray.serve._private.test_utils import expected_proxy_actors from ray.serve._private.utils import block_until_http_ready, format_actor_name from ray.serve.config import ( ControllerOptions, GangSchedulingConfig, HTTPOptions, ProxyLocation, ) from ray.serve.context import _get_global_client from ray.serve.schema import ServeApplicationSchema, ServeDeploySchema from ray.util.state import list_actors def _get_random_port() -> int: return random.randint(10000, 65535) @pytest.fixture def ray_cluster(): if cluster_not_supported: pytest.skip("Cluster not supported") cluster = Cluster() yield Cluster() serve.shutdown() ray.shutdown() cluster.shutdown() @pytest.fixture() def lower_slow_startup_threshold_and_reset(): original_slow_startup_warning_s = os.environ.get("SERVE_SLOW_STARTUP_WARNING_S") original_slow_startup_warning_period_s = os.environ.get( "SERVE_SLOW_STARTUP_WARNING_PERIOD_S" ) # Lower slow startup warning threshold to 1 second to reduce test duration os.environ["SERVE_SLOW_STARTUP_WARNING_S"] = "1" os.environ["SERVE_SLOW_STARTUP_WARNING_PERIOD_S"] = "1" ray.init(num_cpus=2) serve.start() client = _get_global_client() yield client serve.shutdown() ray.shutdown() # Reset slow startup warning threshold to prevent state sharing across unit # tests if original_slow_startup_warning_s is not None: os.environ["SERVE_SLOW_STARTUP_WARNING_S"] = original_slow_startup_warning_s if original_slow_startup_warning_period_s is not None: os.environ[ "SERVE_SLOW_STARTUP_WARNING_PERIOD_S" ] = original_slow_startup_warning_period_s def test_deployment(ray_cluster): # https://github.com/ray-project/ray/issues/11437 cluster = ray_cluster head_node = cluster.add_node(num_cpus=6) # Create first job, check we can run a simple serve endpoint ray.init(head_node.address, namespace=SERVE_NAMESPACE) first_job_id = ray.get_runtime_context().get_job_id() serve.start() @serve.deployment def f(*args): return "from_f" handle = serve.run(f.bind(), name="f", route_prefix="/say_hi_f") assert handle.remote().result() == "from_f" assert httpx.get("http://localhost:8000/say_hi_f").text == "from_f" serve.context._global_client = None ray.shutdown() # Create the second job, make sure we can still create new deployments. ray.init(head_node.address, namespace="serve") assert ray.get_runtime_context().get_job_id() != first_job_id @serve.deployment def g(*args): return "from_g" handle = serve.run(g.bind(), name="g", route_prefix="/say_hi_g") assert handle.remote().result() == "from_g" assert httpx.get("http://localhost:8000/say_hi_g").text == "from_g" assert httpx.get("http://localhost:8000/say_hi_f").text == "from_f" def test_connect(ray_shutdown): # Check that you can make API calls from within a deployment. ray.init(num_cpus=8, namespace="serve") serve.start() @serve.deployment def connect_in_deployment(*args): serve.run( connect_in_deployment.options(name="deployment-ception").bind(), name="app2", route_prefix="/app2", ) handle = serve.run(connect_in_deployment.bind()) handle.remote().result() assert "deployment-ception" in serve.status().applications["app2"].deployments def test_set_socket_reuse_port(): sock = socket.socket() if hasattr(socket, "SO_REUSEPORT"): # If the flag exists, we should be able to to use it assert set_socket_reuse_port(sock) elif sys.platform == "linux": # If the flag doesn't exist, but we are only mordern version # of linux, we should be able to force set this flag. assert set_socket_reuse_port(sock) else: # Otherwise, it should graceful fail without exception. assert not set_socket_reuse_port(sock) def _reuse_port_is_available(): sock = socket.socket() return set_socket_reuse_port(sock) @pytest.mark.skipif( not _reuse_port_is_available(), reason=( "Port sharing only works on newer verion of Linux. " "This test can only be ran when port sharing is supported." ), ) def test_multiple_routers(ray_cluster): cluster = ray_cluster head_node = cluster.add_node(num_cpus=4) cluster.add_node(num_cpus=4) ray.init(head_node.address) assert len(ray.nodes()) == 2 serve.start(http_options=dict(port=8005, location="EveryNode")) @serve.deployment( num_replicas=2, ray_actor_options={"num_cpus": 3}, ) class A: def __call__(self, *args): return "hi" serve.run(A.bind()) gcs_client = GcsClient(address=ray.get_runtime_context().gcs_address) cluster_node_info_cache = create_cluster_node_info_cache(gcs_client) cluster_node_info_cache.update() def get_proxy_names(): proxy_names = [] for node_id in cluster_node_info_cache.get_alive_node_ids(): proxy_names.append( format_actor_name( SERVE_PROXY_NAME, node_id, ) ) return proxy_names wait_for_condition(lambda: len(get_proxy_names()) == 2) original_proxy_names = get_proxy_names() # Two actors should be started. def get_first_two_actors(): try: ray.get_actor(original_proxy_names[0], namespace=SERVE_NAMESPACE) ray.get_actor(original_proxy_names[1], namespace=SERVE_NAMESPACE) return True except ValueError: return False wait_for_condition(get_first_two_actors) # Wait for the actors to come up. ray.get(block_until_http_ready.remote("http://127.0.0.1:8005/-/routes")) # Kill one of the servers, the HTTP server should still function. ray.kill( ray.get_actor(get_proxy_names()[0], namespace=SERVE_NAMESPACE), no_restart=True ) ray.get(block_until_http_ready.remote("http://127.0.0.1:8005/-/routes")) # Add a new node to the cluster. This should trigger a new router to get # started. new_node = cluster.add_node(num_cpus=4) cluster_node_info_cache.update() wait_for_condition(lambda: len(get_proxy_names()) == 3) (third_proxy,) = set(get_proxy_names()) - set(original_proxy_names) serve.run(A.options(num_replicas=3).bind()) def get_third_actor(): try: ray.get_actor(third_proxy, namespace=SERVE_NAMESPACE) return True # IndexErrors covers when cluster resources aren't updated yet. except (IndexError, ValueError): return False wait_for_condition(get_third_actor) # Remove the newly-added node from the cluster. The corresponding actor # should be removed as well. cluster.remove_node(new_node) cluster_node_info_cache.update() def third_actor_removed(): try: ray.get_actor(third_proxy, namespace=SERVE_NAMESPACE) return False except ValueError: return True # Check that the actor is gone and the HTTP server still functions. wait_for_condition(third_actor_removed) ray.get(block_until_http_ready.remote("http://127.0.0.1:8005/-/routes")) @pytest.mark.skipif( RAY_SERVE_ENABLE_HA_PROXY, reason="HAProxy ingress: user HTTP middleware runs on the replica, but the " "/-/routes endpoint is served by HAProxy, so middleware-injected headers " "are absent there.", ) def test_middleware(ray_shutdown): from starlette.middleware import Middleware from starlette.middleware.cors import CORSMiddleware port = _get_random_port() # `middlewares` in HTTPOptions has been removed; passing it raises an error. # Use Serve's FastAPI integration to configure middlewares instead. with pytest.raises(ValueError, match="`middlewares` in HTTPOptions"): serve.start( http_options=dict( port=port, middlewares=[ Middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"]) ], ) ) @pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows") def test_http_root_path(ray_shutdown): @serve.deployment def hello(): return "hello" port = _get_random_port() root_path = "/serve" serve.start(http_options=dict(root_path=root_path, port=port)) serve.run(hello.bind(), route_prefix="/hello") # check routing works as expected resp = httpx.get(f"http://127.0.0.1:{port}{root_path}/hello") assert resp.status_code == 200 assert resp.text == "hello" # check advertized routes are prefixed correctly resp = httpx.get(f"http://127.0.0.1:{port}{root_path}/-/routes") assert resp.status_code == 200 assert resp.json() == {"/hello": "default"} @pytest.mark.skipif(sys.platform == "win32", reason="Failing on Windows") def test_http_proxy_fail_loudly(ray_shutdown): # Test that if the http server fail to start, serve.start should fail. with pytest.raises(RuntimeError): serve.start(http_options={"host": "bad.ip.address"}) def test_no_http(ray_shutdown): # The following should have the same effect. # All of these should disable the HTTP proxy. options = [ {"http_options": {"host": None}}, {"proxy_location": "Disabled"}, {"http_options": {"location": "NoServer"}}, # deprecated override ] address = ray.init(num_cpus=8)["address"] for i, option in enumerate(options): print(f"[{i + 1}/{len(options)}] Running with {option}") serve.start(**option) # Only controller actor should exist live_actors = list_actors( address=address, filters=[("state", "=", "ALIVE")], ) assert len(live_actors) == 1 controller = serve.context._global_client._controller assert len(ray.get(controller.get_proxies.remote())) == 0 # Test that the handle still works. @serve.deployment def hello(*args): return "hello" handle = serve.run(hello.bind()) assert handle.remote().result() == "hello" serve.shutdown() def test_http_head_only(ray_cluster): cluster = ray_cluster head_node = cluster.add_node(num_cpus=4, dashboard_port=_get_random_port()) cluster.add_node(num_cpus=4) ray.init(head_node.address) assert len(ray.nodes()) == 2 serve.start(proxy_location="HeadOnly", http_options={"port": _get_random_port()}) # Controller and proxy on the head node. Under HAProxy the proxy is the # HAProxyManager alongside the fallback ProxyActor, which registers asynchronously. expected_classes = {"ServeController", *expected_proxy_actors()} def check_head_only_actors(): actors = list_actors( address=head_node.address, filters=[("state", "=", "ALIVE")] ) assert {actor.class_name for actor in actors} == expected_classes assert all(actor.node_id == head_node.node_id for actor in actors) return True wait_for_condition(check_head_only_actors) def test_instance_in_non_anonymous_namespace(ray_shutdown): # Can start instance in non-anonymous namespace. ray.init(namespace="foo") serve.start() def test_checkpoint_isolation_namespace(ray_shutdown): info = ray.init(namespace="test_namespace1") address = info["address"] driver_template = """ import ray from ray import serve ray.init(address="{address}", namespace="{namespace}") serve.start(http_options={{"port": {port}}}) @serve.deployment class A: pass serve.run(A.bind())""" run_string_as_driver( driver_template.format(address=address, namespace="test_namespace1", port=8000) ) run_string_as_driver( driver_template.format(address=address, namespace="test_namespace2", port=8001) ) def test_serve_start_different_http_checkpoint_options_warning( ray_shutdown, propagate_logs, caplog ): logger = logging.getLogger("ray.serve") caplog.set_level(logging.WARNING, logger="ray.serve") warning_msg = [] class WarningHandler(logging.Handler): def emit(self, record): warning_msg.append(self.format(record)) logger.addHandler(WarningHandler()) ray.init(namespace="serve-test") serve.start() # create a different config test_http = dict(host="127.1.1.8", port=_get_random_port()) serve.start(http_options=test_http) for test_config, msg in zip([["host", "port"]], warning_msg): for test_msg in test_config: if "Autoscaling metrics pusher thread" in msg: continue assert test_msg in msg def test_recovering_controller_no_redeploy(): """Ensure controller doesn't redeploy running deployments when recovering.""" ray_context = ray.init(namespace="x") address = ray_context.address_info["address"] serve.start() client = _get_global_client() @serve.deployment def f(): pass serve.run(f.bind()) num_actors = len(list_actors(address, filters=[("state", "=", "ALIVE")])) assert num_actors > 0 pid = ray.get(client._controller.get_pid.remote()) ray.kill(client._controller, no_restart=False) wait_for_condition(lambda: ray.get(client._controller.get_pid.remote()) != pid) # Confirm that no new deployment is deployed over the next 5 seconds with pytest.raises(RuntimeError): wait_for_condition( lambda: len(list_actors(address, filters=[("state", "=", "ALIVE")])) > num_actors, timeout=5, ) serve.shutdown() ray.shutdown() def test_updating_status_message(lower_slow_startup_threshold_and_reset): """Check if status message says if a serve deployment has taken a long time""" @serve.deployment( num_replicas=5, ray_actor_options={"num_cpus": 1}, ) def f(*args): pass serve._run(f.bind(), _blocking=False) def updating_message(): deployment_status = ( serve.status().applications[SERVE_DEFAULT_APP_NAME].deployments["f"] ) message_substring = "more than 1s to be scheduled." return (deployment_status.status == "UPDATING") and ( message_substring in deployment_status.message ) wait_for_condition(updating_message, timeout=20) def test_gang_updating_status_message(lower_slow_startup_threshold_and_reset): @serve.deployment( num_replicas=4, ray_actor_options={"num_cpus": 1}, gang_scheduling_config=GangSchedulingConfig(gang_size=2), ) def g(*args): pass serve._run(g.bind(), _blocking=False) def updating_message(): deployment_status = ( serve.status().applications[SERVE_DEFAULT_APP_NAME].deployments["g"] ) message_substring = "more than 1s to be scheduled." return (deployment_status.status == "UPDATING") and ( message_substring in deployment_status.message ) wait_for_condition(updating_message, timeout=20) def test_unhealthy_override_updating_status(lower_slow_startup_threshold_and_reset): """ Check that if status is UNHEALTHY and there is a resource availability issue, the status should not change. The issue that caused the deployment to be unhealthy should be prioritized over this resource availability issue. """ @serve.deployment class f: def __init__(self): self.num = 1 / 0 def __call__(self, request): pass serve._run(f.bind(), _blocking=False) wait_for_condition( lambda: serve.status() .applications[SERVE_DEFAULT_APP_NAME] .deployments["f"] .status == "DEPLOY_FAILED", timeout=20, ) with pytest.raises(RuntimeError): wait_for_condition( lambda: serve.status() .applications[SERVE_DEFAULT_APP_NAME] .deployments["f"] .status == "UPDATING", timeout=10, ) @serve.deployment(ray_actor_options={"num_cpus": 0}) class Waiter: def __init__(self): time.sleep(5) def __call__(self, *args): return "May I take your order?" WaiterNode = Waiter.bind() def test_build_app_task_uses_zero_cpus(ray_shutdown): """Check that the task to build an app uses zero CPUs.""" ray.init(num_cpus=0) serve.start() _get_global_client().deploy_apps( ServeDeploySchema( applications=[ ServeApplicationSchema( name="default", route_prefix="/", import_path="ray.serve.tests.test_standalone.WaiterNode", ) ], ) ) # If the task required any resources, this would fail. wait_for_condition( lambda: httpx.get("http://localhost:8000/").text == "May I take your order?" ) serve.shutdown() ray.shutdown() def _deploy_flaky_app(counter_file, fail_count: int): os.environ["FLAKY_BUILD_COUNTER_FILE"] = str(counter_file) os.environ["FLAKY_BUILD_FAIL_COUNT"] = str(fail_count) counter_file.write_text("0") ray.init(num_cpus=1) serve.start() _get_global_client().deploy_apps( ServeDeploySchema( applications=[ ServeApplicationSchema( name="flaky_app", route_prefix="/flaky", import_path="ray.serve.tests.test_config_files.flaky_build.node", ) ] ) ) def test_build_app_retries_until_success(ray_shutdown, tmp_path): """A flaky build that succeeds on the 4th attempt deploys cleanly.""" counter_file = tmp_path / "counter.txt" try: _deploy_flaky_app(counter_file, fail_count=3) wait_for_condition( lambda: serve.status().applications["flaky_app"].status == "RUNNING", timeout=60, ) assert int(counter_file.read_text()) == 4 finally: os.environ.pop("FLAKY_BUILD_COUNTER_FILE", None) os.environ.pop("FLAKY_BUILD_FAIL_COUNT", None) def test_build_app_fails_after_retries_exhausted(ray_shutdown, tmp_path): """If the build keeps failing, the app status surfaces the user error.""" counter_file = tmp_path / "counter.txt" try: _deploy_flaky_app(counter_file, fail_count=10) wait_for_condition( lambda: serve.status().applications["flaky_app"].status == "DEPLOY_FAILED", timeout=60, ) assert "flaky build failure" in serve.status().applications["flaky_app"].message assert int(counter_file.read_text()) == 4 finally: os.environ.pop("FLAKY_BUILD_COUNTER_FILE", None) os.environ.pop("FLAKY_BUILD_FAIL_COUNT", None) @pytest.mark.parametrize( "options", [ # No proxy_location and no location -> default EveryNode. { "proxy_location": None, "http_options": None, "expected": ProxyLocation.EveryNode, }, { "proxy_location": None, "http_options": {"test": "test"}, "expected": ProxyLocation.EveryNode, }, # Explicit (deprecated) `location` is a back-compat override. { "proxy_location": None, "http_options": {"location": "NoServer"}, "expected": ProxyLocation.Disabled, }, # location=None now means "unset" -> defers to proxy_location (EveryNode), # NOT Disabled (the old None-means-Disabled overload is gone). { "proxy_location": None, "http_options": {"location": None}, "expected": ProxyLocation.EveryNode, }, # Bare HTTPOptions() defers to proxy_location -> EveryNode (was HeadOnly). { "proxy_location": None, "http_options": HTTPOptions(), "expected": ProxyLocation.EveryNode, }, { "proxy_location": "Disabled", "http_options": None, "expected": ProxyLocation.Disabled, }, { "proxy_location": "Disabled", "http_options": {}, "expected": ProxyLocation.Disabled, }, { "proxy_location": "Disabled", "http_options": {"host": "foobar"}, "expected": ProxyLocation.Disabled, }, # Explicit `location` overrides `proxy_location`. { "proxy_location": "Disabled", "http_options": {"location": "HeadOnly"}, "expected": ProxyLocation.HeadOnly, }, ], ) def test_serve_start_proxy_location(ray_shutdown, options): expected = options.pop("expected") serve.start(**options) client = _get_global_client() assert client.get_serve_details()["proxy_location"] == expected @pytest.mark.parametrize( "controller_options", [ ControllerOptions( runtime_env={ "env_vars": { "RAY_SERVE_TEST_CONTROLLER_ENV": "from-model", "RAY_SERVE_TEST_CONTROLLER_ENV_2": "second", } } ), # Same options passed as a plain dict -- the API coerces it # through ``ControllerOptions.model_validate``. { "runtime_env": { "env_vars": { "RAY_SERVE_TEST_CONTROLLER_ENV": "from-model", "RAY_SERVE_TEST_CONTROLLER_ENV_2": "second", } } }, ], ) def test_serve_start_controller_options(ray_shutdown, controller_options): """``ControllerOptions.runtime_env.env_vars`` lands on the controller actor. Uses a custom RAY_SERVE_TEST_CONTROLLER_ENV var (not a real Serve knob) so the assertion is decoupled from whatever the Anyscale env hook auto-injects. The merge semantics are the env_hook's contract; this test only asserts that *our* requested env_vars made it through. """ serve.start(controller_options=controller_options) client = _get_global_client() # Reach into the controller actor to read its own os.environ; the # controller is a singleton named actor on the head node, so a remote # task on the same handle runs in the same process. def _read_env(self, *, keys): import os as _os return {k: _os.environ.get(k) for k in keys} env_seen = ray.get( client._controller.__ray_call__.remote( _read_env, keys=[ "RAY_SERVE_TEST_CONTROLLER_ENV", "RAY_SERVE_TEST_CONTROLLER_ENV_2", ], ) ) assert env_seen["RAY_SERVE_TEST_CONTROLLER_ENV"] == "from-model" assert env_seen["RAY_SERVE_TEST_CONTROLLER_ENV_2"] == "second" def test_serve_start_controller_options_rejects_disallowed_runtime_env( ray_shutdown, ): """Bad runtime_env fails at the caller, not from a Ray task.""" from pydantic import ValidationError with pytest.raises(ValidationError) as exc: serve.start(controller_options={"runtime_env": {"pip": ["numpy"]}}) assert "only supports ['env_vars']" in str(exc.value) def test_serve_start_does_not_leak_idle_worker(ray_shutdown): """Regression test for #63596 / PR #63597. Before the fix, ``serve_start_async`` ran ``_start_controller`` as a remote Ray task and returned the controller ``ActorHandle`` cross-process to the caller (the Dashboard Agent). That transfer inserted the handle's ObjectRefs into the executor worker's ``stored_in_objects``, pinning the worker IDLE forever (it could never drain). Accumulated across calls in a long-lived caller, this eventually OOM'd the head node. With the inline fix, controller creation runs in the caller process — there is no executor worker to pin. This test drives ``serve_start_async`` (the exact #63596 path the Dashboard Agent uses) and asserts the symptom. ``ray._private.state.workers()`` reports all WORKER-type processes, which includes the actor-hosting workers for the controller and HTTP proxies. Those are created on ``serve_start_async`` and reaped on ``serve.shutdown()`` each cycle, so they do not accumulate. The leaked ``_start_controller`` executor, by contrast, was pinned IDLE and SURVIVED ``serve.shutdown()`` (its ``object_id_refs_`` never drained) — so it accumulated across cycles and grew the count. The non-growth assertion below catches exactly that. """ def _worker_count() -> int: return len(state.workers()) def _settled_worker_count() -> int: # ``state.workers()`` reflects the GCS worker table, which updates # asynchronously after processes exit and the raylet deregisters them. # A fixed sleep is unsafe under CI contention: a not-yet-deregistered # worker would false-fail the fixed code. Settle by polling until two # consecutive reads agree, so reaping of the controller/proxy workers # is complete before sampling. After shutdown only a leaked (pinned) # executor survives. seen = {"prev": None, "stable": False} def _stable() -> bool: cur = _worker_count() if seen["prev"] is not None and cur == seen["prev"]: seen["stable"] = True seen["prev"] = cur return seen["stable"] wait_for_condition(_stable, timeout=30) return _worker_count() cycles = 3 counts = [] for _ in range(cycles): asyncio.run(serve_start_async()) serve.shutdown() counts.append(_settled_worker_count()) # The count must not grow across cycles — that growth was the leak. # Allow equal-or-fewer (reaping may reduce it); forbid growth. assert counts[-1] <= counts[0], ( f"Idle WORKER count grew across {cycles} serve_start_async()/shutdown() " f"cycles: {counts}. Growth indicates a pinned (leaked) executor worker " f"— the #63596 regression." ) if __name__ == "__main__": sys.exit(pytest.main(["-v", "-s", __file__]))