import asyncio import collections import logging import os import signal import subprocess import sys import tempfile import threading import time from typing import Optional from unittest import mock import pytest import pytest_asyncio import requests import uvicorn from fastapi import FastAPI, Request, Response from ray._common.network_utils import find_free_port from ray._common.test_utils import async_wait_for_condition, wait_for_condition from ray.serve._private.constants import ( PROXY_MIN_DRAINING_PERIOD_S, RAY_SERVE_ENABLE_HA_PROXY, ) from ray.serve._private.haproxy import ( BackendConfig, HAProxyApi, HAProxyConfig, HAProxyManager, ServerConfig, ) from ray.serve.config import HTTPOptions logger = logging.getLogger(__name__) # Skip all tests in this module if the HAProxy feature flag is not enabled pytestmark = pytest.mark.skipif( not RAY_SERVE_ENABLE_HA_PROXY, reason="RAY_SERVE_ENABLE_HA_PROXY not set.", ) EXCLUDED_ACL_NAMES = ("healthcheck", "routes") def check_haproxy_ready(stats_port: int, timeout: int = 2) -> bool: """Check if HAProxy is ready by verifying the stats endpoint is accessible.""" try: response = requests.get(f"http://127.0.0.1:{stats_port}/stats", timeout=timeout) return response.status_code == 200 except Exception: return False def _serve_fastapi_app( app: FastAPI, port: int, ready_check, timeout_keep_alive: int = 60 ): """Run `app` on uvicorn in a daemon thread; block until `ready_check()` is True.""" config = uvicorn.Config( app=app, host="127.0.0.1", port=port, log_level="error", access_log=False, timeout_keep_alive=timeout_keep_alive, ) server = uvicorn.Server(config) thread = threading.Thread(target=lambda: asyncio.run(server.serve()), daemon=True) thread.start() wait_for_condition(ready_check) return server, thread def _healthz_ready(port: int): return ( lambda: requests.get( f"http://127.0.0.1:{port}/-/healthz", timeout=2 ).status_code == 200 ) def create_test_backend_server(port: int): """Create a test backend server with slow and fast endpoints using uvicorn.""" app = FastAPI() @app.get("/-/healthz") async def health_endpoint(): return {"status": "OK"} @app.get("/slow") async def slow_endpoint(): await asyncio.sleep(3) # 3-second delay return "Slow response completed" @app.get("/fast") async def fast_endpoint(req: Request, res: Response): res.headers["x-haproxy-reload-id"] = req.headers.get("x-haproxy-reload-id", "") return "Fast response" return _serve_fastapi_app(app, port, _healthz_ready(port)) def process_exists(pid: int) -> bool: """Check if a process with the given PID exists.""" try: # Send signal 0 to check if process exists without actually sending a signal os.kill(pid, 0) return True except (OSError, ProcessLookupError): return False def make_test_request( url: str, track_results: list = None, signal_started: threading.Event = None, timeout: int = 10, ): """Unified function to make test requests with optional result tracking.""" try: if signal_started: signal_started.set() # Signal that request has started start_time = time.time() response = requests.get(url, timeout=timeout) end_time = time.time() if track_results is not None: track_results.append( { "status": response.status_code, "duration": end_time - start_time, "content": response.content, } ) except Exception as ex: if track_results is not None: track_results.append({"error": str(ex)}) @pytest.fixture(autouse=True) def clean_up_haproxy_processes(): """Clean up haproxy processes before and after each test.""" subprocess.run( ["pkill", "-x", "haproxy"], capture_output=True, text=True, check=False ) yield # After test: verify no haproxy processes are running result = subprocess.run( ["pgrep", "-x", "haproxy"], capture_output=True, text=True, check=False ) assert ( result.returncode != 0 or not result.stdout.strip() ), f"HAProxy processes still running after test: {result.stdout.strip()}" @pytest_asyncio.fixture async def haproxy_api_cleanup(): registered_apis = [] def register(api: Optional[HAProxyApi]) -> None: if api is not None: registered_apis.append(api) yield register for api in registered_apis: proc = getattr(api, "_proc", None) if proc and proc.returncode is None: try: await api.stop() except Exception as exc: # pragma: no cover - best effort cleanup logger.warning(f"Failed to stop HAProxy API cleanly: {exc}") try: proc.kill() await proc.wait() except Exception as kill_exc: logger.error( f"Failed to kill HAProxy process {proc.pid}: {kill_exc}" ) elif proc and proc.returncode is not None: continue def test_generate_config_file_internal(haproxy_api_cleanup): """Test that initialize writes the correct config_stub file content using the actual template.""" with tempfile.TemporaryDirectory() as temp_dir: config_file_path = os.path.join(temp_dir, "haproxy.cfg") socket_path = os.path.join(temp_dir, "admin.sock") config_stub = HAProxyConfig( socket_path=socket_path, maxconn=1000, nbthread=2, timeout_connect_s=5, timeout_client_s=30, timeout_server_s=30, timeout_http_request_s=10, timeout_queue_s=1, stats_port=8080, stats_uri="/mystats", health_check_fall=3, health_check_rise=2, health_check_inter="2s", http_health_check_path="/health", http_options=HTTPOptions( host="0.0.0.0", port=8000, keep_alive_timeout_s=55, ), has_received_routes=True, has_received_servers=True, enable_hap_optimization=True, ) backend_config_stub = { "api_backend": BackendConfig( name="api_backend", path_prefix="/api", app_name="api_backend", timeout_http_keep_alive_s=60, timeout_tunnel_s=60, http_health_check_path="/api/health", health_check_fall=2, health_check_rise=3, health_check_inter="5s", servers=[ ServerConfig(name="api_server1", host="127.0.0.1", port=8001), ServerConfig(name="api_server2", host="127.0.0.1", port=8002), ], fallback_server=ServerConfig( name="api_fallback_server", host="127.0.0.1", port=8500 ), ), "web_backend": BackendConfig( name="web_backend", path_prefix="/web", app_name="web_backend", timeout_connect_s=3, timeout_server_s=25, timeout_http_keep_alive_s=45, timeout_tunnel_s=45, servers=[ ServerConfig(name="web_server1", host="127.0.0.1", port=8003), ] # No health check overrides - should use global defaults ), } with mock.patch( "ray.serve._private.constants.RAY_SERVE_HAPROXY_CONFIG_FILE_LOC", config_file_path, ): api = HAProxyApi( cfg=config_stub, backend_configs=backend_config_stub, config_file_path=config_file_path, ) api.cfg.balance_algorithm = "random(2)" try: api._generate_config_file_internal() # Read and verify the generated file with open(config_file_path, "r") as f: actual_content = f.read() routes = '{\\"/api\\":\\"api_backend\\",\\"/web\\":\\"web_backend\\"}' # Expected configuration stub (matching the actual template output) expected_config = f""" global # Access/event log at `info`. The system endpoints (/-/healthz, /-/routes) # tag their logs `debug` so they are dropped here but still reach the # rfc5424 metrics socket (level `debug`) when metrics are enabled -- mirroring # the proxy, which records their metric but not their access log. log 127.0.0.1:514 local0 info stats socket {socket_path} mode 666 level admin expose-fd listeners stats timeout 30s maxconn 1000 nbthread 2 tune.bufsize 16384 server-state-base /tmp/haproxy-serve server-state-file /tmp/haproxy-serve/server-state hard-stop-after 120s defaults mode http option log-health-checks timeout connect 5s timeout client 30s timeout server 30s timeout http-request 10s timeout http-keep-alive 55s timeout queue 1s log global option httplog option abortonclose option splice-request option splice-response # On a retry, use a different slot (`1`). retry-on defaults to connect # failures only (nothing was sent → safe to replay); override globally via # RAY_SERVE_HAPROXY_RETRY_ON. Inherited by every backend. option redispatch 1 retry-on conn-failure # Set TCP_NODELAY on all connections option http-no-delay option idle-close-on-response # Normalize 502/503/504 to 500 per Serve's default behavior. 503 # covers HAProxy's own "all retries exhausted / no server" response. errorfile 502 {temp_dir}/500.http errorfile 503 {temp_dir}/500.http errorfile 504 {temp_dir}/500.http load-server-state-from-file global balance random(2) frontend prometheus bind :9101 mode http http-request use-service prometheus-exporter if {{ path /metrics }} no log frontend http_frontend bind *:8000 log global # Per-request HTTP ingress metrics. One RFC 5424 line per request matched to # a Serve app backend, scraped into the serve_num_http_* / # serve_http_request_latency_ms families (the metrics the Python proxy emits # in non-HAProxy mode). Goes only to the rfc5424 target below; the inherited # rfc3164 targets do not include the SD section, so their byte stream is # unchanged. The general fields come from txn.serve_* vars set per backend # below; %ST/%Ta/%ts render unquoted (HAProxy does not quote those aliases). # term_state (%ts) is HAProxy's 2-char session termination state; a leading "C" # means the client aborted, which the collector maps to status 499 to match the # Python proxy's client-disconnect convention. When ingress-request-router # metrics are also enabled, the router-specific fields are appended to the same # line. log /tmp/haproxy-serve/metrics.sock len 8192 format rfc5424 local1 debug log-format-sd "%{{+Q,+E}}o [serve@1 app=%[var(txn.serve_app)] route=%[var(txn.serve_route)] method=%HM status=%ST latency_ms=%Ta deployment=%[var(txn.serve_deployment)] term_state=%ts]" # Health check endpoint acl healthcheck path -i /-/healthz # Keep health checks out of the access log but still record their metric: # tag them `debug` so the access-log target (level info) drops them while the # metrics socket (level debug) keeps them. Mirrors the proxy, which records # the healthz metric with should_record_access_log=False. http-request set-log-level debug if healthcheck http-request set-var-fmt(txn.serve_route) "/-/healthz" if healthcheck # 200 if any backend has at least one server UP acl backend_api_backend_server_up nbsrv(api_backend) ge 1 acl backend_web_backend_server_up nbsrv(web_backend) ge 1 # Any backend with a server UP passes the health check (OR logic) http-request return status 200 content-type text/plain string "success" if healthcheck backend_api_backend_server_up http-request return status 200 content-type text/plain string "success" if healthcheck backend_web_backend_server_up http-request return status 503 content-type text/plain string "Service Unavailable" if healthcheck # Routes endpoint acl routes path -i /-/routes # Like health checks: kept out of the access log (tagged `debug`); its metric # is recorded (route=/-/routes, app unset) when metrics are enabled. http-request set-log-level debug if routes http-request set-var-fmt(txn.serve_route) "/-/routes" if routes http-request return status 200 content-type application/json string "{routes}" if routes # Per-backend path ACLs (used for both ingress-request-router dispatch # and static use_backend selection below). acl is_api_backend path_beg /api/ acl is_api_backend path /api acl is_web_backend path_beg /web/ acl is_web_backend path /web # Per-request HTTP metric vars (app / route / ingress deployment), set on the # first matching backend. Backends are sorted longest-prefix-first and the # !found guard makes the longest match win, mirroring the use_backend rules # below. Requests that match no app backend (e.g. /-/routes, 404s) leave # these unset, so the collector can skip them. http-request set-var-fmt(txn.serve_app) "api_backend" if is_api_backend !{{ var(txn.serve_app) -m found }} http-request set-var-fmt(txn.serve_route) "/api" if is_api_backend !{{ var(txn.serve_route) -m found }} http-request set-var-fmt(txn.serve_app) "web_backend" if is_web_backend !{{ var(txn.serve_app) -m found }} http-request set-var-fmt(txn.serve_route) "/web" if is_web_backend !{{ var(txn.serve_route) -m found }} # Static routing based on path prefixes in decreasing length then alphabetical order use_backend api_backend if is_api_backend use_backend web_backend if is_web_backend default_backend default_backend backend default_backend http-request return status 404 content-type text/plain lf-string "Path \'%[path]\' not found. Ping http://.../-/routes for available routes." backend api_backend log global # Enable HTTP connection reuse for better performance http-reuse always # Set backend-specific timeouts, overriding defaults if specified # Set timeouts to support keep-alive connections timeout http-keep-alive 60s timeout tunnel 60s # Health check configuration - use backend-specific or global defaults # HTTP health check with custom path option httpchk GET /api/health http-check expect status 200 default-server fastinter 250ms downinter 250ms fall 2 rise 3 inter 5s check # Servers in this backend server api_server1 127.0.0.1:8001 check server api_server2 127.0.0.1:8002 check # Fallback to head node's Serve proxy when no ingress replicas are available server api_fallback_server 127.0.0.1:8500 check backup backend web_backend log global # Enable HTTP connection reuse for better performance http-reuse always # Set backend-specific timeouts, overriding defaults if specified timeout connect 3s timeout server 25s # Set timeouts to support keep-alive connections timeout http-keep-alive 45s timeout tunnel 45s # Health check configuration - use backend-specific or global defaults # HTTP health check with custom path option httpchk GET /-/healthz http-check expect status 200 default-server fastinter 250ms downinter 250ms fall 3 rise 2 inter 2s check # Servers in this backend server web_server1 127.0.0.1:8003 check listen stats bind *:8080 stats enable stats uri /mystats stats refresh 1s """ # Compare the entire configuration assert actual_content.strip() == expected_config.strip() finally: # Clean up any temporary files created by initialize() temp_files = ["haproxy.cfg", "routes.map"] for temp_file in temp_files: try: if os.path.exists(temp_file): os.remove(temp_file) except (FileNotFoundError, OSError): pass # File already removed or doesn't exist def test_config_escapes_special_characters_in_names(haproxy_api_cleanup): """App / deployment names can contain any character (see test_deploy_with_any_characters). They must be escaped when rendered into the metric set-var-fmt values so a '#' (HAProxy comment char) or other special character can't corrupt the config (regression for the set-var(...) str(...) injection).""" with tempfile.TemporaryDirectory() as temp_dir: config_file_path = os.path.join(temp_dir, "haproxy.cfg") config = HAProxyConfig( http_options=HTTPOptions(host="127.0.0.1", port=8000), stats_port=8404, socket_path=os.path.join(temp_dir, "admin.sock"), metrics_enabled=True, has_received_routes=True, has_received_servers=True, ) backend_configs = { "http-test": BackendConfig( name="http-test", path_prefix="/test", app_name="app#name", ingress_deployment_name="dep#123", servers=[ServerConfig(name="s1", host="127.0.0.1", port=8001)], ) } api = HAProxyApi( cfg=config, backend_configs=backend_configs, config_file_path=config_file_path, ) api._generate_config_file_internal() with open(config_file_path, "r") as f: cfg = f.read() # Escaped set-var-fmt form (double-quoted), not the injection-prone # unquoted str(...) form that treats '#' as a comment. assert 'set-var-fmt(txn.serve_deployment) "dep#123"' in cfg assert 'set-var-fmt(txn.serve_app) "app#name"' in cfg assert "str(dep#123)" not in cfg assert "str(app#name)" not in cfg def test_generate_backends_in_order(haproxy_api_cleanup): """Test that the backends are generated in the correct order.""" with tempfile.TemporaryDirectory() as temp_dir: config_file_path = os.path.join(temp_dir, "haproxy.cfg") backend_config_stub = { "foo": BackendConfig( name="foo", path_prefix="/foo", app_name="foo", ), "foobar": BackendConfig( name="foobar", path_prefix="/foo/bar", app_name="foobar", ), "bar": BackendConfig( name="bar", path_prefix="/bar", app_name="bar", ), "default": BackendConfig( name="default", path_prefix="/", app_name="default", ), } with mock.patch( "ray.serve._private.constants.RAY_SERVE_HAPROXY_CONFIG_FILE_LOC", config_file_path, ): api = HAProxyApi( cfg=HAProxyConfig(), config_file_path=config_file_path, backend_configs=backend_config_stub, ) api._generate_config_file_internal() # Read and verify the generated file lines = [] with open(config_file_path, "r") as f: lines = f.readlines() acl_names = [] path_begs = [] paths = [] backend_lines = [] for line in lines: line = line.strip() if line.startswith("acl"): acl_name = line.split(" ")[1] if acl_name in EXCLUDED_ACL_NAMES: continue acl_names.append(acl_name) # strip prefix/suffix added for acl checks backend_name = ( acl_name.lstrip("is_") .replace("backend_", "") .replace("_server_up", "") ) assert backend_name in backend_config_stub condition = line.split(" ")[-2] if condition == "path_beg": path_prefix = line.split(" ")[-1].rstrip("/") or "/" assert backend_config_stub[backend_name].path_prefix == path_prefix path_begs.append(path_prefix) elif condition == "path": path_prefix = line.split(" ")[-1] assert backend_config_stub[backend_name].path_prefix == path_prefix paths.append(path_prefix) else: # gt condition is used for health check, no need to check. continue if line.startswith("use_backend"): acl_name = line.split(" ")[-1] assert acl_name in acl_names backend_lines.append(acl_name) expected_order = ["is_foobar", "is_bar", "is_foo", "is_default"] assert backend_lines == expected_order def _make_api(temp_dir, backend_configs): config_file_path = os.path.join(temp_dir, "haproxy.cfg") return HAProxyApi( cfg=HAProxyConfig(socket_path=os.path.join(temp_dir, "admin.sock")), config_file_path=config_file_path, backend_configs=backend_configs, ) def test_write_ingress_request_router_lua_no_routers(haproxy_api_cleanup): """No backend has ingress_request_router_servers -> returns None and writes no Lua file.""" with tempfile.TemporaryDirectory() as temp_dir: backend = BackendConfig( name="plain", path_prefix="/", app_name="plain", servers=[ ServerConfig( name="s1", host="10.0.0.1", port=8001, replica_id="replica_1" ), ], ) api = _make_api(temp_dir, {"plain": backend}) result = api._write_ingress_request_router_lua([backend]) assert result is None assert not os.path.exists(os.path.join(temp_dir, "ingress_request_router.lua")) def test_router_servers_without_replica_ids_emits_no_lua_directives( haproxy_api_cleanup, ): """Backend with router servers but no replica IDs must emit neither the global `lua-load-per-thread` nor the frontend `lua.route_*` action.""" with tempfile.TemporaryDirectory() as temp_dir: api = _make_api( temp_dir, { "llm": BackendConfig( name="llm", path_prefix="/", app_name="llm", # Router is configured, but no replica has a replica_id. servers=[ ServerConfig( name="r1", host="10.0.0.1", port=30001, replica_id=None ) ], ingress_request_router_servers=[ ServerConfig(name="router", host="10.0.0.10", port=9000) ], ), }, ) with mock.patch( "ray.serve._private.constants.RAY_SERVE_HAPROXY_CONFIG_FILE_LOC", api.config_file_path, ): api._generate_config_file_internal() with open(api.config_file_path) as f: cfg = f.read() assert "lua.route_via_ingress_request_router" not in cfg assert not os.path.exists(os.path.join(temp_dir, "ingress_request_router.lua")) def test_ingress_request_router_does_not_leak_into_other_backends( haproxy_api_cleanup, ): """Pin the Jinja guard: a backend without ingress_request_router_servers gets no via-ingress-request-router companion and doesn't contribute to is_via_ingress_request_router. The end-to-end test only exercises a single router-routed backend, so this regression isn't caught there.""" with tempfile.TemporaryDirectory() as temp_dir: api = _make_api( temp_dir, { "llm": BackendConfig( name="llm", path_prefix="/", app_name="llm", servers=[ ServerConfig( name="r1", host="10.0.0.1", port=30001, replica_id="rid_1", ) ], ingress_request_router_servers=[ ServerConfig(name="router", host="10.0.0.10", port=9000) ], ), "api": BackendConfig( name="api", path_prefix="/api", app_name="api", servers=[ServerConfig(name="api1", host="10.0.0.20", port=8001)], ), }, ) with mock.patch( "ray.serve._private.constants.RAY_SERVE_HAPROXY_CONFIG_FILE_LOC", api.config_file_path, ): api._generate_config_file_internal() with open(api.config_file_path) as f: cfg = f.read() assert "backend llm-via-ingress-request-router" in cfg assert "backend api-via-ingress-request-router" not in cfg assert "option http-buffer-request" not in cfg direct_backend = cfg.split("backend llm-via-ingress-request-router", 1)[1] direct_backend = direct_backend.split("listen stats", 1)[0] assert "http-reuse always" in direct_backend assert "option http-server-close" not in direct_backend # Only router-bearing backends contribute a set-var directive that # arms the Lua dispatch; the plain `api` backend must not. assert "set-var(txn.ingress_request_router_app) str(llm)" in cfg assert "set-var(txn.ingress_request_router_app) str(api)" not in cfg def test_router_failure_503_rule_appears_before_use_backend(haproxy_api_cleanup): """The 503-on-router-failure rule must be rendered before any ``use_backend`` directive. If it isn't, a failed Lua dispatch would silently fall through to the primary backend and the router policy the operator opted into would be invisibly bypassed.""" with tempfile.TemporaryDirectory() as temp_dir: api = _make_api( temp_dir, { "llm": BackendConfig( name="llm", path_prefix="/", app_name="llm", servers=[ ServerConfig( name="r1", host="10.0.0.1", port=30001, replica_id="rid_1", ) ], ingress_request_router_servers=[ ServerConfig(name="router", host="10.0.0.10", port=9000) ], ), }, ) with mock.patch( "ray.serve._private.constants.RAY_SERVE_HAPROXY_CONFIG_FILE_LOC", api.config_file_path, ): api._generate_config_file_internal() with open(api.config_file_path) as f: cfg = f.read() # Parse line-by-line so we don't accidentally match the substring # "use_backend" inside an explanatory comment block rendered above. lines = cfg.splitlines() sentinel_line = next( ( i for i, ln in enumerate(lines) if "var(txn.ingress_request_router_failed) -m found" in ln ), None, ) first_use_backend_line = next( (i for i, ln in enumerate(lines) if ln.strip().startswith("use_backend ")), None, ) assert sentinel_line is not None, cfg assert first_use_backend_line is not None, cfg assert sentinel_line < first_use_backend_line, ( "503-on-router-failure rule must precede every use_backend so a " "failed dispatch does not silently fall through to the primary " "backend.\n" + cfg ) # Spot-check rule shape. assert "status 503" in cfg, cfg assert "X-Serve-Reason" in cfg, cfg def test_ingress_backend_inherits_global_retry_policy(haproxy_api_cleanup): """The ``-via-ingress-request-router`` backend defines no retry directives of its own — it inherits retry-on / retries / redispatch from the defaults block (one policy everywhere). Only timeout server remains a per-ingress override.""" backends = { "llm": BackendConfig( name="llm", path_prefix="/", app_name="llm", servers=[ ServerConfig(name="r1", host="10.0.0.1", port=30001, replica_id="rid_1") ], ingress_request_router_servers=[ ServerConfig(name="router", host="10.0.0.10", port=9000) ], ), } def render(cfg_overrides): with tempfile.TemporaryDirectory() as temp_dir: api = HAProxyApi( cfg=HAProxyConfig( socket_path=os.path.join(temp_dir, "admin.sock"), **cfg_overrides, ), config_file_path=os.path.join(temp_dir, "haproxy.cfg"), backend_configs=backends, ) with mock.patch( "ray.serve._private.constants.RAY_SERVE_HAPROXY_CONFIG_FILE_LOC", api.config_file_path, ): api._generate_config_file_internal() with open(api.config_file_path) as f: return f.read() # retry-on is defined once (defaults block); the ingress backend inherits it. unset = render({}) assert "ingress-request-router" in unset # backend still rendered assert unset.count("\n retry-on ") == 1 assert "\n retries " not in unset # ingress retry knobs are inherited, not re-emitted in the ingress backend; # only timeout server renders there as a per-ingress override. set_cfg = render( { "ingress_retry_on": "conn-failure empty-response response-timeout", "ingress_retries": 4, "ingress_timeout_server_s": 5, } ) assert set_cfg.count("\n retry-on ") == 1 # still only the defaults block assert "empty-response" not in set_cfg assert "\n retries 4\n" not in set_cfg assert "\n timeout server 5s\n" in set_cfg def test_global_retry_knobs_render(haproxy_api_cleanup): """RAY_SERVE_HAPROXY_RETRY_ON / RETRIES drive the defaults block (inherited by every backend). Defaults to `conn-failure` with no explicit `retries`.""" def render(overrides): with tempfile.TemporaryDirectory() as temp_dir: api = HAProxyApi( cfg=HAProxyConfig( socket_path=os.path.join(temp_dir, "admin.sock"), **overrides ), config_file_path=os.path.join(temp_dir, "haproxy.cfg"), ) with mock.patch( "ray.serve._private.constants.RAY_SERVE_HAPROXY_CONFIG_FILE_LOC", api.config_file_path, ): api._generate_config_file_internal() with open(api.config_file_path) as f: return f.read() # Default: conn-failure only, no explicit retries line. default_cfg = render({}) assert "\n retry-on conn-failure\n" in default_cfg assert "\n retries " not in default_cfg # Both knobs override the defaults block. overridden = render({"retry_on": "conn-failure junk-response", "retries": 2}) assert "\n retry-on conn-failure junk-response\n" in overridden assert "\n retries 2\n" in overridden @pytest.mark.parametrize("forward_body", [True, False]) def test_ingress_request_router_forward_body_gate_renders( haproxy_api_cleanup, monkeypatch, forward_body ): """The FORWARD_BODY escape hatch must drive both: - HAProxy ``wait-for-body`` + ``tune.bufsize`` directives (memory cost and the per-request body round-trip), and - the Lua ``FORWARD_BODY`` constant (whether the action reads the body and forwards it to ``/internal/route``). Off by default: round-robin ignores the body, so neither cost is paid. """ monkeypatch.setattr( "ray.serve._private.haproxy.RAY_SERVE_INGRESS_REQUEST_ROUTER_FORWARD_BODY", forward_body, ) with tempfile.TemporaryDirectory() as temp_dir: api = _make_api( temp_dir, { "llm": BackendConfig( name="llm", path_prefix="/", app_name="llm", servers=[ ServerConfig( name="r1", host="10.0.0.1", port=30001, replica_id="rid_1", ) ], ingress_request_router_servers=[ ServerConfig(name="router", host="10.0.0.10", port=9000) ], ), }, ) with mock.patch( "ray.serve._private.constants.RAY_SERVE_HAPROXY_CONFIG_FILE_LOC", api.config_file_path, ): api._generate_config_file_internal() with open(api.config_file_path) as f: cfg = f.read() with open(os.path.join(temp_dir, "ingress_request_router.lua")) as f: lua = f.read() if forward_body: assert "wait-for-body" in cfg, cfg assert "local FORWARD_BODY = true" in lua, lua else: assert "wait-for-body" not in cfg, cfg assert "local FORWARD_BODY = false" in lua, lua def _create_replica_server(port: int, replica_id_header: str): """Fake data-plane replica that echoes its identity in a response header.""" app = FastAPI() @app.get("/-/healthz") async def health(): return {"status": "OK"} @app.post("/{path:path}") async def root(path: str, req: Request, res: Response): res.headers["x-replica-id"] = replica_id_header body = await req.body() return {"replica": replica_id_header, "echo": body.decode("utf-8")} return _serve_fastapi_app(app, port, _healthz_ready(port)) def _create_router_server(port: int, replica_id_to_return: str): """Fake /internal/route. Captures bodies so tests can verify HAProxy forwards the buffered request body prefix to the router.""" app = FastAPI() captured = {"bodies": []} @app.post("/internal/route") async def route(req: Request): body = await req.body() captured["bodies"].append(body.decode("utf-8")) return {"replica_id": replica_id_to_return} def ready(): return ( requests.post( f"http://127.0.0.1:{port}/internal/route", json={}, timeout=2 ).status_code == 200 ) server, thread = _serve_fastapi_app(app, port, ready) # Discard the readiness-probe body so callers see only client traffic. captured["bodies"].clear() return server, thread, captured async def _start_router_haproxy( temp_dir, haproxy_port, stats_port, backend_configs, haproxy_api_cleanup ): """Build, start, and await readiness of an HAProxyApi for the ingress request router e2e tests. These tests share this scaffold and differ only in their backends, router behavior, and assertions.""" config = HAProxyConfig( http_options=HTTPOptions( host="127.0.0.1", port=haproxy_port, keep_alive_timeout_s=58, ), stats_port=stats_port, socket_path=os.path.join(temp_dir, "admin.sock"), has_received_routes=True, has_received_servers=True, http_health_check_path="/-/healthz", health_check_inter="500ms", health_check_rise=1, health_check_fall=2, ) api = HAProxyApi( cfg=config, backend_configs=backend_configs, config_file_path=os.path.join(temp_dir, "haproxy.cfg"), ) haproxy_api_cleanup(api) await api.start() wait_for_condition(lambda: check_haproxy_ready(stats_port), timeout=10) # Wait for primary-backend health checks to mark the replicas UP. await async_wait_for_condition( lambda: requests.get( f"http://127.0.0.1:{haproxy_port}/-/healthz", timeout=2 ).status_code == 200, timeout=10, ) return api def _shutdown_fake_servers(servers, threads): """Signal uvicorn fake servers to exit and join their threads. Mirrors the teardown each router test runs in its finally block.""" for srv in servers: try: srv.should_exit = True except Exception: pass for thr in threads: try: thr.join(timeout=5) except Exception: pass @pytest.mark.asyncio async def test_ingress_request_router_end_to_end(haproxy_api_cleanup, monkeypatch): """Run actual HAProxy against a fake router + two replicas; verify a POST is pinned to the replica the router selects, while a GET (which doesn't trigger the router-routed path) is not.""" monkeypatch.setattr( "ray.serve._private.haproxy.RAY_SERVE_INGRESS_REQUEST_ROUTER_FORWARD_BODY", True, ) with tempfile.TemporaryDirectory() as temp_dir: haproxy_port = find_free_port() stats_port = find_free_port() replica_a_port = find_free_port() replica_b_port = find_free_port() router_port = find_free_port() actor_name_a = "SERVE_REPLICA::app#dep#aaa" actor_name_b = "SERVE_REPLICA::app#dep#bbb" replica_a, replica_a_thread = _create_replica_server( replica_a_port, replica_id_header="A" ) replica_b, replica_b_thread = _create_replica_server( replica_b_port, replica_id_header="B" ) router, router_thread, router_captured = _create_router_server( router_port, replica_id_to_return=actor_name_b # always pick B ) try: backend = BackendConfig( name="llm", path_prefix="/", app_name="llm", http_health_check_path="/-/healthz", servers=[ ServerConfig( name="A", host="127.0.0.1", port=replica_a_port, replica_id=actor_name_a, ), ServerConfig( name="B", host="127.0.0.1", port=replica_b_port, replica_id=actor_name_b, ), ], ingress_request_router_servers=[ ServerConfig(name="router", host="127.0.0.1", port=router_port), ], ) await _start_router_haproxy( temp_dir, haproxy_port, stats_port, {"llm": backend}, haproxy_api_cleanup, ) # POST goes through the router. Router returns B's actor name, # so the request must land on replica B regardless of LB ordering. payload = {"prompt": "hello"} resp = requests.post( f"http://127.0.0.1:{haproxy_port}/predict", json=payload, timeout=5, ) assert resp.status_code == 200, resp.text assert resp.headers.get("x-replica-id") == "B" # Direct streaming keeps a bounded request-body path for # prefix-cache-aware routing. assert router_captured["bodies"] == ['{"prompt": "hello"}'] # Repeat to confirm the pin holds across requests. for _ in range(3): resp = requests.post( f"http://127.0.0.1:{haproxy_port}/predict", json=payload, timeout=5, ) assert resp.headers.get("x-replica-id") == "B" assert router_captured["bodies"] == ['{"prompt": "hello"}'] * 4 # GET is not POST, so Lua routing never runs; the router should # have seen exactly the four POSTs above and nothing more. n_router_calls_before_get = len(router_captured["bodies"]) requests.get( f"http://127.0.0.1:{haproxy_port}/health-passthrough", timeout=5 ) assert ( len(router_captured["bodies"]) == n_router_calls_before_get ), "GET must not invoke /internal/route" finally: _shutdown_fake_servers( (replica_a, replica_b, router), (replica_a_thread, replica_b_thread, router_thread), ) def _create_broken_router_server(port: int, status_code: int = 500): """Fake /internal/route that always returns ``status_code`` (default 500). Used to verify the fail-loud path: a router non-200 must surface to the client as 5xx with X-Serve-Reason, not silently fall back to a primary backend.""" app = FastAPI() @app.get("/-/healthz") async def health(): return {"status": "OK"} @app.post("/internal/route") async def route(res: Response): res.status_code = status_code return {"error": "broken"} return _serve_fastapi_app(app, port, _healthz_ready(port)) def _backend_stot(stats_csv: str, backend_name: str) -> int: """Pull ``stot`` (cumulative sessions) for the BACKEND aggregate row of ``backend_name`` from HAProxy's CSV stats. Returns -1 if not found.""" lines = stats_csv.splitlines() if not lines: return -1 header = lines[0].lstrip("# ").split(",") pxname_idx = header.index("pxname") svname_idx = header.index("svname") stot_idx = header.index("stot") for row in lines[1:]: parts = row.split(",") if ( len(parts) > stot_idx and parts[pxname_idx] == backend_name and parts[svname_idx] == "BACKEND" ): return int(parts[stot_idx] or 0) return -1 @pytest.mark.asyncio async def test_router_failure_fails_loud_with_reason(haproxy_api_cleanup): """When ``/internal/route`` returns non-200, HAProxy must return 5xx with ``X-Serve-Reason`` rather than silently falling back to the primary backend. The primary backend's cumulative session count must stay 0.""" with tempfile.TemporaryDirectory() as temp_dir: haproxy_port = find_free_port() stats_port = find_free_port() replica_port = find_free_port() router_port = find_free_port() actor_name = "SERVE_REPLICA::app#dep#aaa" replica, replica_thread = _create_replica_server( replica_port, replica_id_header="A" ) broken_router, broken_router_thread = _create_broken_router_server(router_port) try: backend = BackendConfig( name="llm", path_prefix="/", app_name="llm", http_health_check_path="/-/healthz", servers=[ ServerConfig( name="A", host="127.0.0.1", port=replica_port, replica_id=actor_name, ), ], ingress_request_router_servers=[ ServerConfig(name="router", host="127.0.0.1", port=router_port), ], ) await _start_router_haproxy( temp_dir, haproxy_port, stats_port, {"llm": backend}, haproxy_api_cleanup, ) # Every dispatch failure must surface as 5xx with a reason # label, never as a silent primary-backend fallback. The broken # router returns 500 for both empty and non-empty bodies, so # both shapes surface the same ``router_non_200`` reason; the # body shape is parametrized to pin that empty-body POSTs are # routed through the router and not silently bypassed. for body_kwargs in (dict(json={"prompt": "hi"}), dict(data="")): for _ in range(3): resp = requests.post( f"http://127.0.0.1:{haproxy_port}/predict", timeout=5, **body_kwargs, ) assert resp.status_code == 503, resp.text assert ( resp.headers.get("X-Serve-Reason") == "router_non_200" ), resp.headers stats_csv = requests.get( f"http://127.0.0.1:{stats_port}/stats;csv", timeout=5 ).text assert _backend_stot(stats_csv, "llm") == 0, stats_csv assert ( _backend_stot(stats_csv, "llm-via-ingress-request-router") == 0 ), stats_csv finally: _shutdown_fake_servers( (replica, broken_router), (replica_thread, broken_router_thread) ) @pytest.mark.asyncio async def test_pin_miss_falls_back_to_fallback_server(haproxy_api_cleanup): """When the router pins a replica_id that is not in HAProxy's server map (the brief membership gap right after an app becomes RUNNING, where the router's in-process view runs ahead of HAProxy's config reload), HAProxy must hand the request to the fallback Serve proxy instead of returning 503. The primary backend must not be load-balanced into, since that would break session affinity.""" with tempfile.TemporaryDirectory() as temp_dir: haproxy_port = find_free_port() stats_port = find_free_port() replica_port = find_free_port() fallback_port = find_free_port() router_port = find_free_port() actor_name = "SERVE_REPLICA::app#dep#aaa" # The router names a replica that is NOT among the configured servers, # simulating HAProxy lagging the router's freshly-updated view. unknown_actor_name = "SERVE_REPLICA::app#dep#not_loaded_yet" replica, replica_thread = _create_replica_server( replica_port, replica_id_header="A" ) fallback, fallback_thread = _create_replica_server( fallback_port, replica_id_header="FALLBACK" ) router, router_thread, _ = _create_router_server( router_port, replica_id_to_return=unknown_actor_name ) try: backend = BackendConfig( name="llm", path_prefix="/", app_name="llm", http_health_check_path="/-/healthz", servers=[ ServerConfig( name="A", host="127.0.0.1", port=replica_port, replica_id=actor_name, ), ], ingress_request_router_servers=[ ServerConfig(name="router", host="127.0.0.1", port=router_port), ], fallback_server=ServerConfig( name="fallback", host="127.0.0.1", port=fallback_port, ), ) await _start_router_haproxy( temp_dir, haproxy_port, stats_port, {"llm": backend}, haproxy_api_cleanup, ) # The fallback is a `backup` server, so the /-/healthz wait above # (satisfied by the primary replica alone) does not gate it. Before # its health check passes, a pin-miss use-server is skipped and the # request load-balances onto the primary replica; once it passes, # every pin-miss POST lands on the fallback proxy. Require several # consecutive FALLBACK responses so a transient health-check flap # during warmup retries here instead of failing an assertion. def _pin_miss_consistently_reaches_fallback(): for _ in range(3): resp = requests.post( f"http://127.0.0.1:{haproxy_port}/predict", json={"prompt": "hi"}, timeout=5, ) if ( resp.status_code != 200 or resp.headers.get("x-replica-id") != "FALLBACK" ): return False return True await async_wait_for_condition( _pin_miss_consistently_reaches_fallback, timeout=10 ) # A pin-miss must route via the router backend, never through the # plain primary backend (a silent router bypass). The router backend # carries the fallback-served sessions; the plain backend stays 0. stats_csv = requests.get( f"http://127.0.0.1:{stats_port}/stats;csv", timeout=5 ).text assert _backend_stot(stats_csv, "llm") == 0, stats_csv assert ( _backend_stot(stats_csv, "llm-via-ingress-request-router") >= 3 ), stats_csv finally: _shutdown_fake_servers( (replica, fallback, router), (replica_thread, fallback_thread, router_thread), ) @pytest.mark.asyncio async def test_graceful_reload(haproxy_api_cleanup): """Test that graceful reload preserves long-running connections.""" with tempfile.TemporaryDirectory() as temp_dir: # Setup ports haproxy_port = 8000 backend_port = 8404 stats_port = 8405 # Create and start a backend server backend_server, backend_thread = create_test_backend_server(backend_port) # Configure HAProxy config = HAProxyConfig( http_options=HTTPOptions( host="127.0.0.1", port=haproxy_port, keep_alive_timeout_s=58, ), stats_port=stats_port, inject_process_id_header=True, # Enable for testing graceful reload reload_id=f"initial-{int(time.time() * 1000)}", # Set initial reload ID socket_path=os.path.join(temp_dir, "admin.sock"), ) backend_config = BackendConfig( name="test_backend", path_prefix="/", app_name="test_app", servers=[ServerConfig(name="backend", host="127.0.0.1", port=backend_port)], timeout_http_keep_alive_s=58, ) config_file_path = os.path.join(temp_dir, "haproxy.cfg") api = HAProxyApi( cfg=config, backend_configs={"test_backend": backend_config}, config_file_path=config_file_path, ) haproxy_api_cleanup(api) try: await api.start() # Wait for HAProxy to be ready (check stat endpoint) def check_stats_ready(): try: response = requests.get( f"http://127.0.0.1:{config.stats_port}/stats", timeout=2 ) return response.status_code == 200 except Exception: return False wait_for_condition(check_stats_ready, timeout=10, retry_interval_ms=100) # Track slow request results slow_results = [] request_started = threading.Event() slow_thread = threading.Thread( target=make_test_request, args=[f"http://127.0.0.1:{haproxy_port}/slow"], kwargs={ "track_results": slow_results, "signal_started": request_started, }, ) slow_thread.start() wait_for_condition( lambda: request_started.is_set(), timeout=5, retry_interval_ms=10 ) assert api._proc is not None original_pid = api._proc.pid await api._graceful_reload() assert api._proc is not None new_pid = api._proc.pid def check_for_new_reload_id(): fast_response = requests.get( f"http://127.0.0.1:{haproxy_port}/fast", timeout=5 ) # Reload ID should always match what exists in the config. return ( fast_response.headers.get("x-haproxy-reload-id") == api.cfg.reload_id and fast_response.status_code == 200 ) wait_for_condition( check_for_new_reload_id, timeout=5, retry_interval_ms=100 ) slow_thread.join(timeout=10) assert ( original_pid != new_pid ), "Process should have been reloaded with new PID" wait_for_condition( lambda: not process_exists(original_pid), timeout=15, retry_interval_ms=100, ) assert len(slow_results) == 1, "Slow request should have completed" result = slow_results[0] assert "error" not in result, f"Slow request failed: {result.get('error')}" assert result["status"] == 200, "Slow request should have succeeded" assert result["duration"] >= 3.0, "Slow request should have taken full time" assert ( b"Slow response completed" in result["content"] ), "Slow request should have completed" finally: # Backend server cleanup try: backend_server.should_exit = True backend_thread.join(timeout=5) # Wait for thread to finish except Exception as e: print(f"Error occurred while shutting down server stub. Error: {e}") @pytest.mark.asyncio async def test_start(haproxy_api_cleanup): """Test HAProxy start functionality.""" with tempfile.TemporaryDirectory() as temp_dir: config_file_path = os.path.join(temp_dir, "haproxy.cfg") socket_path = os.path.join(temp_dir, "admin.sock") # Create HAProxy config config = HAProxyConfig( http_options=HTTPOptions( host="127.0.0.1", port=8000, keep_alive_timeout_s=58, ), stats_port=8404, pass_health_checks=True, socket_path=socket_path, has_received_routes=True, has_received_servers=True, ) # Add a backend so routes are populated backend = BackendConfig( name="test_backend", path_prefix="/", app_name="test_app", servers=[ServerConfig(name="server", host="127.0.0.1", port=9999)], ) api = HAProxyApi( cfg=config, backend_configs={"test_backend": backend}, config_file_path=config_file_path, ) haproxy_api_cleanup(api) await api.start() assert api._proc is not None, "HAProxy process should exist" assert api._is_running(), "HAProxy should be running" # Verify config file contains expected content with open(config_file_path, "r") as f: config_content = f.read() assert "frontend http_frontend" in config_content assert f"bind 127.0.0.1:{config.frontend_port}" in config_content assert "acl healthcheck path -i /-/healthz" in config_content health_response = requests.get( f"http://127.0.0.1:{config.frontend_port}/-/healthz", timeout=5 ) assert ( health_response.status_code == 503 ), "Health check with no servers up should return 503" await api.stop() assert api._proc is None assert not api._is_running() @pytest.mark.asyncio async def test_get_running_pid_matches_live_proc(haproxy_api_cleanup): """`_get_running_pid` parses real `show info` and returns the forked pid. Guards the reload-takeover gate against a `show info` format change that the hard-coded fakes in test_haproxy_process_manager.py can't catch. """ with tempfile.TemporaryDirectory() as temp_dir: config_file_path = os.path.join(temp_dir, "haproxy.cfg") socket_path = os.path.join(temp_dir, "admin.sock") config = HAProxyConfig( http_options=HTTPOptions(host="127.0.0.1", port=8000), stats_port=8404, socket_path=socket_path, has_received_routes=True, has_received_servers=True, ) backend = BackendConfig( name="test_backend", path_prefix="/", app_name="test_app", servers=[ServerConfig(name="server", host="127.0.0.1", port=9999)], ) api = HAProxyApi( cfg=config, backend_configs={"test_backend": backend}, config_file_path=config_file_path, ) haproxy_api_cleanup(api) await api.start() # The socket's `show info` must report the pid we forked. assert await api._get_running_pid() == api._proc.pid await api.stop() # Socket is gone once stopped, so the pid is unknown. assert await api._get_running_pid() is None @pytest.mark.asyncio async def test_stop(haproxy_api_cleanup): """Test HAProxy stop functionality.""" with tempfile.TemporaryDirectory() as temp_dir: config_file_path = os.path.join(temp_dir, "haproxy.cfg") config = HAProxyConfig( http_options=HTTPOptions( host="127.0.0.1", port=8000, ), stats_port=8404, socket_path=os.path.join(temp_dir, "admin.sock"), ) api = HAProxyApi(cfg=config, config_file_path=config_file_path) haproxy_api_cleanup(api) # Start HAProxy await api.start() haproxy_api_cleanup(api) await api.stop() # Verify it's stopped assert not api._is_running(), "HAProxy should be stopped after shutdown" @pytest.mark.asyncio async def test_stop_kills_haproxy_process(haproxy_api_cleanup): """Test that stop() properly kills the HAProxy subprocess.""" with tempfile.TemporaryDirectory() as temp_dir: config_file_path = os.path.join(temp_dir, "haproxy.cfg") config = HAProxyConfig( http_options=HTTPOptions( host="127.0.0.1", port=8000, ), stats_port=8404, socket_path=os.path.join(temp_dir, "admin.sock"), ) api = HAProxyApi(cfg=config, config_file_path=config_file_path) haproxy_api_cleanup(api) # Start HAProxy await api.start() assert api._proc is not None, "HAProxy process should exist after start" haproxy_pid = api._proc.pid assert process_exists(haproxy_pid), "HAProxy process should be running" # Stop HAProxy await api.stop() # Verify the process is killed assert api._proc is None, "HAProxy proc should be None after stop" # Wait a bit for process cleanup def haproxy_process_killed(): return not process_exists(haproxy_pid) wait_for_condition( haproxy_process_killed, timeout=1, retry_interval_ms=100, ) @pytest.mark.asyncio async def test_get_stats_integration(haproxy_api_cleanup): with tempfile.TemporaryDirectory() as temp_dir: config_file_path = os.path.join(temp_dir, "haproxy.cfg") socket_path = os.path.join(temp_dir, "admin.sock") # Create test backend servers backend_port1 = 9900 backend_port2 = 9901 backend_server1, backend_thread1 = create_test_backend_server(backend_port1) backend_server2, backend_thread2 = create_test_backend_server(backend_port2) # Configure HAProxy with multiple backends config = HAProxyConfig( http_options=HTTPOptions( port=8000, keep_alive_timeout_s=58, ), socket_path=socket_path, stats_port=8404, ) backend_configs = { "test_backend1": BackendConfig( name="test_backend1", path_prefix="/api", app_name="test_app1", servers=[ ServerConfig(name="server1", host="127.0.0.1", port=backend_port1) ], timeout_http_keep_alive_s=58, ), "test_backend2": BackendConfig( name="test_backend2", path_prefix="/web", app_name="test_app2", servers=[ ServerConfig(name="server2", host="127.0.0.1", port=backend_port2) ], timeout_http_keep_alive_s=58, ), } api = HAProxyApi( cfg=config, backend_configs=backend_configs, config_file_path=config_file_path, ) haproxy_api_cleanup(api) try: # Start HAProxy await api.start() # Wait for HAProxy to be ready wait_for_condition( lambda: check_haproxy_ready(config.stats_port), timeout=10, retry_interval_ms=500, ) # Make some API calls to generate sessions and traffic request_threads = [] for i in range(3): thread = threading.Thread( target=make_test_request, args=[f"http://127.0.0.1:{config.frontend_port}/api/slow"], ) thread.start() request_threads.append(thread) for i in range(3): thread = threading.Thread( target=make_test_request, args=[f"http://127.0.0.1:{config.frontend_port}/web/slow"], ) thread.start() request_threads.append(thread) # Get actual stats async def two_servers_up(): stats = await api.get_haproxy_stats() return stats.active_servers == 2 await async_wait_for_condition( two_servers_up, timeout=10, retry_interval_ms=200 ) async def wait_for_running(): return await api.is_running() await async_wait_for_condition( wait_for_running, timeout=10, retry_interval_ms=200 ) all_stats = await api.get_all_stats() haproxy_stats = await api.get_haproxy_stats() # Assert against the expected stub with exact values assert ( len(all_stats) == 2 ), f"Should have exactly 2 backends, got {len(all_stats)}" assert ( haproxy_stats.total_backends == 2 ), f"Should have exactly 2 backends, got {haproxy_stats.total_backends}" assert ( haproxy_stats.total_servers == 2 ), f"Should have exactly 2 servers, got {haproxy_stats.total_servers}" assert ( haproxy_stats.active_servers == 2 ), f"Should have exactly 2 active servers, got {haproxy_stats.active_servers}" # Wait for request threads to complete for thread in request_threads: thread.join(timeout=1) finally: # Stop HAProxy await api.stop() # Cleanup backend servers try: backend_server1.should_exit = True backend_server2.should_exit = True backend_thread1.join(timeout=5) # Wait for the thread to finish backend_thread2.join(timeout=5) # Wait for the thread to finish except Exception as e: print(f"Error cleaning up backend servers: {e}") @pytest.mark.asyncio async def test_update_and_reload(haproxy_api_cleanup): with tempfile.TemporaryDirectory() as temp_dir: config_file_path = os.path.join(temp_dir, "haproxy.cfg") socket_path = os.path.join(temp_dir, "admin.sock") backend = BackendConfig( name="backend", path_prefix="/", app_name="backend_app", servers=[ServerConfig(name="server", host="127.0.0.1", port=9999)], ) config = HAProxyConfig( http_options=HTTPOptions( host="127.0.0.1", port=8000, ), stats_port=8404, socket_path=socket_path, ) api = HAProxyApi( cfg=config, backend_configs={backend.name: backend}, config_file_path=config_file_path, ) await api.start() haproxy_api_cleanup(api) with open(config_file_path, "r") as f: actual_content = f.read() assert "backend_2" not in actual_content original_proc = api._proc original_pid = original_proc.pid # Add another backend backend2 = BackendConfig( name="backend_2", path_prefix="/", app_name="backend_app_2", servers=[ServerConfig(name="server", host="127.0.0.1", port=9999)], ) api.set_backend_configs({backend.name: backend, backend2.name: backend2}) await api.reload() assert api._proc is not None assert api._proc.pid != original_pid with open(config_file_path, "r") as f: actual_content = f.read() assert "backend_2" in actual_content wait_for_condition( lambda: not process_exists(original_pid), timeout=5, retry_interval_ms=100, ) @pytest.mark.asyncio async def test_haproxy_start_should_throw_error_when_already_running( haproxy_api_cleanup, ): """Test that HAProxy throws an error when trying to start on an already-used port (SO_REUSEPORT disabled).""" with tempfile.TemporaryDirectory() as temp_dir: config_file_path = os.path.join(temp_dir, "haproxy.cfg") socket_path = os.path.join(temp_dir, "admin.sock") config = HAProxyConfig( http_options=HTTPOptions( host="127.0.0.1", port=8000, ), stats_port=8404, socket_path=socket_path, enable_so_reuseport=False, # Disable SO_REUSEPORT ) api = HAProxyApi(cfg=config, config_file_path=config_file_path) haproxy_api_cleanup(api) # Start HAProxy with SO_REUSEPORT disabled await api.start() assert api._proc is not None, "HAProxy process should be running" first_pid = api._proc.pid # Verify we can't start another instance on the same port (SO_REUSEPORT disabled) config2 = HAProxyConfig( http_options=HTTPOptions( host="127.0.0.1", port=config.frontend_port, # Same port ), stats_port=8404, socket_path=os.path.join(temp_dir, "admin2.sock"), enable_so_reuseport=False, # Disable SO_REUSEPORT ) api2 = HAProxyApi( cfg=config2, config_file_path=os.path.join(temp_dir, "haproxy2.cfg") ) # This should fail because SO_REUSEPORT is disabled with pytest.raises(RuntimeError, match="(Address already in use)"): await api2.start() # Cleanup first instance await api.stop() assert not process_exists(first_pid), "HAProxy process should be stopped" @pytest.mark.asyncio async def test_toggle_health_checks(haproxy_api_cleanup): """Test that disable()/enable() toggle HAProxy health checks end-to-end.""" with tempfile.TemporaryDirectory() as temp_dir: config_file_path = os.path.join(temp_dir, "haproxy.cfg") socket_path = os.path.join(temp_dir, "admin.sock") backend = BackendConfig( name="backend", path_prefix="/", app_name="backend_app", servers=[ServerConfig(name="server", host="127.0.0.1", port=9999)], ) config = HAProxyConfig( http_options=HTTPOptions( host="127.0.0.1", port=8000, ), stats_port=8404, socket_path=socket_path, inject_process_id_header=True, has_received_routes=True, has_received_servers=True, ) # Start a real backend server so HAProxy can mark the server UP backend_server, backend_thread = create_test_backend_server(9999) try: api = HAProxyApi( cfg=config, backend_configs={backend.name: backend}, config_file_path=config_file_path, ) await api.start() haproxy_api_cleanup(api) # Verify HAProxy is running assert api._is_running(), "HAProxy should be running" # Health requires servers; wait until health passes def health_ok(): resp = requests.get( f"http://127.0.0.1:{config.frontend_port}{config.health_check_endpoint}", timeout=5, ) return resp.status_code == 200 wait_for_condition(health_ok, timeout=10) # Verify a config file contains health check enabled with open(api.config_file_path, "r") as f: config_content = f.read() assert ( "http-request return status 200" in config_content ), "Health checks should be enabled in config" # Disable health checks await api.disable() # Verify HAProxy is still running after calling disable() assert api._is_running(), "HAProxy should still be running after disable" # Config should now deny the health endpoint with open(api.config_file_path, "r") as f: config_content = f.read() assert ( "http-request return status 503" in config_content ), "Health checks should be disabled in config" def health_check_condition(status_code: int): # Test health check endpoint now fails health_response = requests.get( f"http://127.0.0.1:{config.frontend_port}{config.health_check_endpoint}", timeout=5, ) return health_response.status_code == status_code wait_for_condition(health_check_condition, timeout=2, status_code=503) # Re-enable health checks await api.enable() # Config should contain the 200 response again with open(api.config_file_path, "r") as f: config_content = f.read() assert ( "http-request return status 200" in config_content ), "Health checks should be re-enabled in config" wait_for_condition(health_check_condition, timeout=5, status_code=200) finally: backend_server.should_exit = True backend_thread.join(timeout=5) @pytest.mark.asyncio async def test_health_endpoint_or_logic_multiple_backends(haproxy_api_cleanup): """Test that the health endpoint returns 200 if ANY backend has at least one server UP (OR logic).""" with tempfile.TemporaryDirectory() as temp_dir: config_file_path = os.path.join(temp_dir, "haproxy.cfg") socket_path = os.path.join(temp_dir, "admin.sock") backend1_port = 9996 backend2_port = 9997 config = HAProxyConfig( http_options=HTTPOptions( host="127.0.0.1", port=8000, ), stats_port=8404, socket_path=socket_path, has_received_routes=True, has_received_servers=True, ) backend1 = BackendConfig( name="backend1", path_prefix="/api1", servers=[ ServerConfig(name="server1", host="127.0.0.1", port=backend1_port) ], health_check_fall=1, health_check_rise=1, health_check_inter="1s", ) backend2 = BackendConfig( name="backend2", path_prefix="/api2", servers=[ ServerConfig(name="server2", host="127.0.0.1", port=backend2_port) ], health_check_fall=1, health_check_rise=1, health_check_inter="1s", ) backend1_server, backend1_thread = create_test_backend_server(backend1_port) backend2_server, backend2_thread = create_test_backend_server(backend2_port) try: api = HAProxyApi( cfg=config, backend_configs={backend1.name: backend1, backend2.name: backend2}, config_file_path=config_file_path, ) await api.start() haproxy_api_cleanup(api) # Wait for health check to pass (both servers are UP) def health_ok(): resp = requests.get( f"http://127.0.0.1:{config.frontend_port}{config.health_check_endpoint}", timeout=5, ) return resp.status_code == 200 wait_for_condition(health_ok, timeout=10, retry_interval_ms=200) # Verify health check returns 200 when both servers are UP health_response = requests.get( f"http://127.0.0.1:{config.frontend_port}{config.health_check_endpoint}", timeout=5, ) assert ( health_response.status_code == 200 ), "Health check should return 200 when both servers are UP" assert b"success" in health_response.content # Stop backend1 server backend1_server.should_exit = True backend1_thread.join(timeout=5) # Wait a bit for HAProxy to detect backend1 is down await asyncio.sleep(2) # Verify health check STILL returns 200 (backend2 is still UP - OR logic) health_response = requests.get( f"http://127.0.0.1:{config.frontend_port}{config.health_check_endpoint}", timeout=5, ) assert ( health_response.status_code == 200 ), "Health check should return 200 when at least one backend (backend2) is UP (OR logic)" assert b"success" in health_response.content # Stop backend2 server as well backend2_server.should_exit = True backend2_thread.join(timeout=5) # Wait for health check to fail (both servers are DOWN) def health_fails(): resp = requests.get( f"http://127.0.0.1:{config.frontend_port}{config.health_check_endpoint}", timeout=5, ) return resp.status_code == 503 wait_for_condition(health_fails, timeout=10, retry_interval_ms=200) # Verify health check returns 503 when ALL servers are DOWN health_response = requests.get( f"http://127.0.0.1:{config.frontend_port}{config.health_check_endpoint}", timeout=5, ) assert ( health_response.status_code == 503 ), "Health check should return 503 when all servers are DOWN" assert b"Service Unavailable" in health_response.content await api.stop() finally: # Cleanup try: if not backend1_server.should_exit: backend1_server.should_exit = True backend1_thread.join(timeout=5) except Exception: pass try: if not backend2_server.should_exit: backend2_server.should_exit = True backend2_thread.join(timeout=5) except Exception: pass @pytest.mark.asyncio async def test_errorfile_creation_and_config(haproxy_api_cleanup): """Test that the errorfile is created and configured correctly for both 502 and 504.""" with tempfile.TemporaryDirectory() as temp_dir: config_file_path = os.path.join(temp_dir, "haproxy.cfg") socket_path = os.path.join(temp_dir, "admin.sock") # Launch a simple backend server with /fast endpoint backend_port = 9107 backend_server, backend_thread = create_test_backend_server(backend_port) # Configure HAProxy with one backend under root ('/') so upstream sees '/fast' config = HAProxyConfig( http_options=HTTPOptions( host="127.0.0.1", port=8000, keep_alive_timeout_s=58, ), stats_port=8404, socket_path=socket_path, ) api = HAProxyApi(cfg=config, config_file_path=config_file_path) haproxy_api_cleanup(api) # Verify the error file was created during initialization expected_error_file_path = os.path.join(temp_dir, "500.http") assert os.path.exists( expected_error_file_path ), "Error file 500.http should be created" assert ( api.cfg.error_file_path == expected_error_file_path ), "Error file path should be set in config" # Verify the error file content with open(expected_error_file_path, "r") as ef: error_content = ef.read() assert ( "HTTP/1.1 500 Internal Server Error" in error_content ), "Error file should contain 500 status" assert ( "Content-Type: text/plain" in error_content ), "Error file should contain content-type header" assert ( "Internal Server Error" in error_content ), "Error file should contain error message" # Start HAProxy and verify config contains errorfile directives await api.start() # Verify config file contains errorfile directives for 502, 503 and 504 pointing to the same file with open(config_file_path, "r") as f: config_content = f.read() assert ( f"errorfile 502 {expected_error_file_path}" in config_content ), "HAProxy config should contain 502 errorfile directive" assert ( f"errorfile 503 {expected_error_file_path}" in config_content ), "HAProxy config should contain 503 errorfile directive" assert ( f"errorfile 504 {expected_error_file_path}" in config_content ), "HAProxy config should contain 504 errorfile directive" await api.stop() backend = BackendConfig( name="app_backend", path_prefix="/", app_name="app", servers=[ServerConfig(name="server1", host="127.0.0.1", port=backend_port)], timeout_http_keep_alive_s=58, ) api = HAProxyApi( cfg=config, backend_configs={backend.name: backend}, config_file_path=config_file_path, ) haproxy_api_cleanup(api) try: await api.start() # Ensure HAProxy is up (stats endpoint reachable) wait_for_condition( lambda: check_haproxy_ready(config.stats_port), timeout=10, retry_interval_ms=100, ) # Route exists -> expect 200 r = requests.get("http://127.0.0.1:8000/fast", timeout=5) assert r.status_code == 200 # Remove backend (no targets for /app) and reload api.set_backend_configs({}) await api.reload() # After removal, route should fall back to default backend -> 404 def get_status(): resp = requests.get("http://127.0.0.1:8000/fast", timeout=5) return resp.status_code # Allow a brief window for reload to take effect wait_for_condition( lambda: get_status() == 404, timeout=5, retry_interval_ms=100 ) finally: try: await api.stop() except Exception: pass try: backend_server.should_exit = True backend_thread.join(timeout=5) except Exception: pass @pytest.mark.asyncio async def test_routes_endpoint_returns_backends_and_respects_health( haproxy_api_cleanup, ): with tempfile.TemporaryDirectory() as temp_dir: config_file_path = os.path.join(temp_dir, "haproxy.cfg") socket_path = os.path.join(temp_dir, "admin.sock") # Start two backend servers; health endpoint exists at '/-/healthz'. backend_port1 = 9910 backend_port2 = 9911 backend_server1, backend_thread1 = create_test_backend_server(backend_port1) backend_server2, backend_thread2 = create_test_backend_server(backend_port2) # Configure HAProxy with two prefixed backends config = HAProxyConfig( http_options=HTTPOptions( host="127.0.0.1", port=8013, keep_alive_timeout_s=58, ), stats_port=8413, socket_path=socket_path, ) backend_api = BackendConfig( name="api_backend", path_prefix="/api", app_name="api_app", servers=[ ServerConfig(name="server1", host="127.0.0.1", port=backend_port1) ], timeout_http_keep_alive_s=58, ) backend_web = BackendConfig( name="web_backend", path_prefix="/web", app_name="web_app", servers=[ ServerConfig(name="server2", host="127.0.0.1", port=backend_port2) ], timeout_http_keep_alive_s=58, ) api = HAProxyApi( cfg=config, backend_configs={ backend_api.name: backend_api, backend_web.name: backend_web, }, config_file_path=config_file_path, ) haproxy_api_cleanup(api) try: await api.start() # Wait for HAProxy to be ready wait_for_condition( lambda: check_haproxy_ready(config.stats_port), timeout=10, retry_interval_ms=100, ) # Helper to get fresh routes response (avoids connection reuse) def get_routes(): with requests.Session() as session: return session.get("http://127.0.0.1:8013/-/routes", timeout=1) # Initial state: no routes r = requests.get("http://127.0.0.1:8013/-/routes", timeout=5) assert r.status_code == 503 assert r.headers.get("content-type", "").startswith("text/plain") assert r.text == "Route table is not populated yet." # Set has_received_routes but not has_received_servers -> should show "No replicas available" api.cfg.has_received_routes = True api.cfg.has_received_servers = False await api.reload() get_routes().text == "No replicas are available yet.", r = get_routes() assert r.status_code == 503 assert r.headers.get("content-type", "").startswith("text/plain") # Set both flags -> should show routes JSON api.cfg.has_received_routes = True api.cfg.has_received_servers = True await api.reload() # Reload is not synchronous, so we need to wait for the config to be applied def check_json_routes(): r = get_routes() return r.status_code == 200 and r.headers.get( "content-type", "" ).startswith("application/json") wait_for_condition(check_json_routes, timeout=5, retry_interval_ms=50) r = get_routes() data = r.json() assert data == {"/api": "api_app", "/web": "web_app"} # Disable (simulate draining/unhealthy) -> wait for healthz to flip, then routes 503 await api.disable() def health_is(code: int): resp = requests.get("http://127.0.0.1:8013/-/healthz", timeout=5) return resp.status_code == code wait_for_condition(health_is, timeout=5, retry_interval_ms=100, code=503) r = requests.get("http://127.0.0.1:8013/-/routes", timeout=5) assert r.status_code == 503 assert r.headers.get("content-type", "").startswith("text/plain") assert r.text == "This node is being drained." # Re-enable -> wait for healthz to flip back, then routes 200 await api.enable() wait_for_condition(health_is, timeout=5, retry_interval_ms=100, code=200) r = requests.get("http://127.0.0.1:8013/-/routes", timeout=5) assert r.status_code == 200 finally: try: await api.stop() except Exception: pass @pytest.mark.asyncio async def test_routes_endpoint_no_routes(haproxy_api_cleanup): """When no backends are configured, /-/routes should return {} and respect health gating.""" with tempfile.TemporaryDirectory() as temp_dir: config_file_path = os.path.join(temp_dir, "haproxy.cfg") socket_path = os.path.join(temp_dir, "admin.sock") config = HAProxyConfig( http_options=HTTPOptions( host="127.0.0.1", port=8014, keep_alive_timeout_s=58, ), stats_port=8414, socket_path=socket_path, ) api = HAProxyApi( cfg=config, backend_configs={}, config_file_path=config_file_path, ) haproxy_api_cleanup(api) try: await api.start() # Wait for HAProxy to be ready wait_for_condition( lambda: check_haproxy_ready(config.stats_port), timeout=10, retry_interval_ms=100, ) # Healthy -> expect 200 and empty JSON r = requests.get( f"http://127.0.0.1:{config.frontend_port}/-/routes", timeout=5 ) assert r.status_code == 503 assert r.headers.get("content-type", "").startswith("text/plain") assert r.text == "Route table is not populated yet." # Disable -> wait for healthz to flip, then expect 503 with draining message await api.disable() def health_is(code: int): resp = requests.get( f"http://127.0.0.1:{config.frontend_port}/-/healthz", timeout=5 ) return resp.status_code == code wait_for_condition(health_is, timeout=5, retry_interval_ms=100, code=503) # Wait for routes endpoint to also return draining message (graceful reload might take a moment) def routes_is_draining(): try: resp = requests.get( f"http://127.0.0.1:{config.frontend_port}/-/routes", timeout=5 ) return ( resp.status_code == 503 and resp.text == "This node is being drained." ) except Exception: return False wait_for_condition(routes_is_draining, timeout=5, retry_interval_ms=100) r = requests.get( f"http://127.0.0.1:{config.frontend_port}/-/routes", timeout=5 ) assert r.status_code == 503 assert r.headers.get("content-type", "").startswith("text/plain") assert r.text == "This node is being drained." # Re-enable -> wait for healthz back to 200, then routes 200 await api.enable() wait_for_condition(health_is, timeout=5, retry_interval_ms=100, code=503) def routes_is_healthy(): try: r = requests.get( f"http://127.0.0.1:{config.frontend_port}/-/routes", timeout=5 ) return ( r.status_code == 503 and r.text == "Route table is not populated yet." ) except Exception: return False wait_for_condition(routes_is_healthy, timeout=5, retry_interval_ms=100) finally: try: await api.stop() except Exception: pass @pytest.mark.asyncio async def test_404_error_message(haproxy_api_cleanup): """Test that HAProxy returns the correct 404 error message for non-existent paths.""" with tempfile.TemporaryDirectory() as temp_dir: config_file_path = os.path.join(temp_dir, "haproxy.cfg") socket_path = os.path.join(temp_dir, "admin.sock") # Create a backend that serves /api backend = BackendConfig( name="api_backend", path_prefix="/api", servers=[], # No servers, but we're testing the 404 path anyway ) config = HAProxyConfig( http_options=HTTPOptions( host="127.0.0.1", port=8000, ), stats_port=8404, socket_path=socket_path, ) api = HAProxyApi( cfg=config, backend_configs={"api_backend": backend}, config_file_path=config_file_path, ) await api.start() haproxy_api_cleanup(api) # Verify HAProxy is running assert api._is_running(), "HAProxy should be running" # Wait for HAProxy to be ready wait_for_condition( lambda: check_haproxy_ready(config.stats_port), timeout=10, retry_interval_ms=500, ) # Request a non-existent path and verify the error message response = requests.get( f"http://127.0.0.1:{config.frontend_port}/nonexistent", timeout=5, ) assert response.status_code == 404, "Should return 404 for non-existent path" assert ( "Path '/nonexistent' not found" in response.text ), f"Error message should contain path. Got: {response.text}" assert ( "Ping http://.../-/routes for available routes" in response.text ), f"Error message should contain routes hint. Got: {response.text}" @pytest.mark.asyncio async def test_start_with_tcp_nodelay(haproxy_api_cleanup): """Test that HAProxy starts successfully with tcp_nodelay enabled.""" with tempfile.TemporaryDirectory() as temp_dir: config_file_path = os.path.join(temp_dir, "haproxy.cfg") socket_path = os.path.join(temp_dir, "admin.sock") config = HAProxyConfig( http_options=HTTPOptions( host="127.0.0.1", port=8000, keep_alive_timeout_s=58, ), stats_port=8404, pass_health_checks=True, socket_path=socket_path, has_received_routes=True, has_received_servers=True, tcp_nodelay=True, ) backend = BackendConfig( name="test_backend", path_prefix="/", app_name="test_app", servers=[ServerConfig(name="server", host="127.0.0.1", port=9999)], ) api = HAProxyApi( cfg=config, backend_configs={"test_backend": backend}, config_file_path=config_file_path, ) haproxy_api_cleanup(api) await api.start() assert api._proc is not None, "HAProxy process should exist" assert api._is_running(), "HAProxy should be running" # Verify config file contains the tcp_nodelay directive with open(config_file_path, "r") as f: config_content = f.read() assert ( "option http-no-delay" in config_content ), "Config should contain 'option http-no-delay' when tcp_nodelay=True" await api.stop() @pytest.mark.asyncio async def test_std_streams_redirected_to_files(haproxy_api_cleanup): """Both HAProxy stdout and stderr must be files (not PIPEs) so a full 64KB kernel pipe buffer can never block admin-socket threads under load. Each spawn gets its own files so a reload doesn't lose the prior worker's diagnostics. """ with tempfile.TemporaryDirectory() as temp_dir: config = HAProxyConfig( http_options=HTTPOptions(host="127.0.0.1", port=8000), stats_port=8404, pass_health_checks=True, socket_path=os.path.join(temp_dir, "admin.sock"), has_received_routes=True, has_received_servers=True, reload_id=f"initial-{int(time.time() * 1000)}", ) backend = BackendConfig( name="test_backend", path_prefix="/", app_name="test_app", servers=[ServerConfig(name="server", host="127.0.0.1", port=9999)], ) api = HAProxyApi( cfg=config, backend_configs={"test_backend": backend}, config_file_path=os.path.join(temp_dir, "haproxy.cfg"), ) haproxy_api_cleanup(api) await api.start() first_stderr = api._proc._stderr_path first_stdout = api._proc._stdout_path # No pipes — both streams went to files. assert api._proc.stderr is None assert api._proc.stdout is None # HAProxy's -db startup banner landed on stderr. assert os.path.getsize(first_stderr) > 0 # stdout file exists even if it stays empty by default. assert os.path.exists(first_stdout) # Reload must open new files so the prior worker's logs survive. config.reload_id = f"reload-{int(time.time() * 1000)}" await api._graceful_reload() assert api._proc.stderr is None assert api._proc.stdout is None assert api._proc._stderr_path != first_stderr assert api._proc._stdout_path != first_stdout assert os.path.exists(first_stderr) assert os.path.exists(first_stdout) await api.stop() def _bare_haproxy_manager(): """An uninitialized HAProxyManager for unit-testing its methods. HAProxyManager is an ``@ray.remote`` actor, so the imported name is an ActorClass, not a plain type. Reach the underlying Python class via ``__ray_metadata__.modified_class`` and instantiate it with ``__new__`` so ``__init__`` (actor base class + event loop) is skipped. """ cls = HAProxyManager.__ray_metadata__.modified_class return cls.__new__(cls) @pytest.mark.asyncio async def test_is_drained_waits_for_old_procs(): """is_drained() stays False while a soft-stopping old worker from a prior reload is still alive, even past the drain period with an idle current worker; it flips True once the old worker exits.""" # Set only the two attributes is_drained() reads. manager = _bare_haproxy_manager() manager._draining_start_time = time.time() - PROXY_MIN_DRAINING_PERIOD_S - 1 manager._haproxy = mock.Mock() manager._haproxy.get_haproxy_stats = mock.AsyncMock( return_value=mock.Mock(is_system_idle=True) ) # Old worker still serving -> not drained despite idle current worker. manager._haproxy.has_alive_old_procs = mock.Mock(return_value=True) assert await manager.is_drained() is False # Old worker has exited -> drained. manager._haproxy.has_alive_old_procs = mock.Mock(return_value=False) assert await manager.is_drained() is True def test_soft_stop_old_procs_signals_live_workers(): """_soft_stop_old_procs re-delivers SIGUSR1 to our live displaced workers, healing a worker whose `-sf` signal was lost, and skips exited procs and a pid that is no longer one of ours (recycled).""" api = HAProxyApi.__new__(HAProxyApi) alive_ours = mock.Mock(pid=111, returncode=None) exited = mock.Mock(pid=222, returncode=0) alive_recycled = mock.Mock(pid=333, returncode=None) api._old_procs = [alive_ours, exited, alive_recycled] api._retired_logs = collections.deque() api._max_retained_logs = 10 # 333's pid was recycled onto an unrelated process. api._is_our_haproxy = lambda pid: pid != 333 with mock.patch("ray.serve._private.haproxy.os.kill") as mock_kill: api._soft_stop_old_procs() # Only the live worker that is still ours is signaled; the exited one is # pruned and the recycled pid is left alone. mock_kill.assert_called_once_with(111, signal.SIGUSR1) @pytest.mark.asyncio async def test_is_drained_false_before_min_period(): """is_drained() is False until PROXY_MIN_DRAINING_PERIOD_S has elapsed, regardless of old-proc / idle state.""" manager = _bare_haproxy_manager() manager._draining_start_time = time.time() # just started draining manager._haproxy = mock.Mock() manager._haproxy.has_alive_old_procs = mock.Mock(return_value=False) manager._haproxy.get_haproxy_stats = mock.AsyncMock( return_value=mock.Mock(is_system_idle=True) ) assert await manager.is_drained() is False @pytest.mark.asyncio async def test_failed_spawn_retires_log_files(monkeypatch): """A spawn that fails startup must not orphan its std-stream log files — they should be retired into the bounded ring like an exited worker's.""" class _FakeProc: def __init__(self): self.returncode = None self.pid = 4321 def kill(self): self.returncode = -9 async def wait(self): return self.returncode async def _fake_exec(*args, **kwargs): return _FakeProc() with tempfile.TemporaryDirectory() as temp_dir: api = HAProxyApi( cfg=HAProxyConfig(socket_path=os.path.join(temp_dir, "admin.sock")), config_file_path=os.path.join(temp_dir, "haproxy.cfg"), ) monkeypatch.setattr( "ray.serve._private.haproxy.get_haproxy_binary", lambda: "haproxy" ) monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_exec) async def _boom(proc, timeout_s=5): raise RuntimeError("startup failed") monkeypatch.setattr(api, "_wait_for_hap_availability", _boom) with pytest.raises(RuntimeError, match="startup failed"): await api._start_and_wait_for_haproxy() # The failed spawn's files were created then retired into the ring, # not left orphaned on disk. assert len(api._retired_logs) == 1 stdout_path, stderr_path = api._retired_logs[0] assert stdout_path.endswith(".stdout.log") assert stderr_path.endswith(".stderr.log") if __name__ == "__main__": sys.exit(pytest.main(["-v", "-s", __file__]))