| |
| |
|
|
| import multiprocessing |
| import socket |
| import threading |
| import time |
| from unittest.mock import patch |
|
|
| import pytest |
| import zmq |
|
|
| from vllm.utils.network_utils import make_zmq_socket, split_zmq_path |
| from vllm.v1.utils import ( |
| APIServerProcessManager, |
| get_engine_client_zmq_addr, |
| wait_for_completion_or_failure, |
| ) |
|
|
| |
| WORKER_RUNTIME_SECONDS = 0.5 |
|
|
|
|
| |
| def mock_run_api_server_worker(listen_address, sock, args, client_config=None): |
| """Mock run_api_server_worker that runs for a specific time.""" |
| print(f"Mock worker started with client_config: {client_config}") |
| time.sleep(WORKER_RUNTIME_SECONDS) |
| print("Mock worker completed successfully") |
|
|
|
|
| |
| |
| def defer_addresses_stub_worker(listen_address, sock, args, client_config): |
| """Bind ROUTER/PULL with a kernel-assigned port, report the actual |
| endpoints back via the pipe, then exit.""" |
| ctx = zmq.Context() |
| try: |
| in_sock = make_zmq_socket( |
| ctx, client_config["input_address"], zmq.ROUTER, bind=True |
| ) |
| out_sock = make_zmq_socket( |
| ctx, client_config["output_address"], zmq.PULL, bind=True |
| ) |
| try: |
| pipe = client_config["actual_address_pipe"] |
| try: |
| pipe.send( |
| { |
| "input_address": in_sock.getsockopt(zmq.LAST_ENDPOINT).decode(), |
| "output_address": out_sock.getsockopt( |
| zmq.LAST_ENDPOINT |
| ).decode(), |
| } |
| ) |
| finally: |
| pipe.close() |
| finally: |
| in_sock.close(linger=0) |
| out_sock.close(linger=0) |
| finally: |
| ctx.term() |
|
|
|
|
| @pytest.fixture |
| def api_server_args(): |
| """Fixture to provide arguments for APIServerProcessManager.""" |
| sock = socket.socket() |
| return { |
| "target_server_fn": mock_run_api_server_worker, |
| "listen_address": "localhost:8000", |
| "sock": sock, |
| "args": "test_args", |
| "num_servers": 3, |
| "input_addresses": [ |
| "tcp://127.0.0.1:5001", |
| "tcp://127.0.0.1:5002", |
| "tcp://127.0.0.1:5003", |
| ], |
| "output_addresses": [ |
| "tcp://127.0.0.1:6001", |
| "tcp://127.0.0.1:6002", |
| "tcp://127.0.0.1:6003", |
| ], |
| "stats_update_address": "tcp://127.0.0.1:7000", |
| } |
|
|
|
|
| @pytest.mark.parametrize("with_stats_update", [True, False]) |
| def test_api_server_process_manager_init(api_server_args, with_stats_update): |
| """Test initializing the APIServerProcessManager.""" |
| |
| global WORKER_RUNTIME_SECONDS |
| WORKER_RUNTIME_SECONDS = 0.5 |
|
|
| |
| args = api_server_args.copy() |
|
|
| if not with_stats_update: |
| args.pop("stats_update_address") |
| manager = APIServerProcessManager(**args) |
|
|
| try: |
| |
| assert len(manager.processes) == 3 |
|
|
| |
| for proc in manager.processes: |
| assert proc.is_alive() |
|
|
| print("Waiting for processes to run...") |
| time.sleep(WORKER_RUNTIME_SECONDS / 2) |
|
|
| |
| for proc in manager.processes: |
| assert proc.is_alive() |
|
|
| finally: |
| |
| print("Cleaning up processes...") |
| manager.shutdown() |
|
|
| |
| time.sleep(0.2) |
|
|
| |
| for proc in manager.processes: |
| assert not proc.is_alive() |
|
|
|
|
| @patch("vllm.v1.utils.run_api_server_worker_proc", mock_run_api_server_worker) |
| def test_wait_for_completion_or_failure(api_server_args): |
| """Test that wait_for_completion_or_failure works with failures.""" |
| global WORKER_RUNTIME_SECONDS |
| WORKER_RUNTIME_SECONDS = 1.0 |
|
|
| |
| manager = APIServerProcessManager(**api_server_args) |
|
|
| try: |
| assert len(manager.processes) == 3 |
|
|
| |
| result: dict[str, Exception | None] = {"exception": None} |
|
|
| def run_with_exception_capture(): |
| try: |
| wait_for_completion_or_failure(api_server_manager=manager) |
| except Exception as e: |
| result["exception"] = e |
| finally: |
| manager.shutdown() |
|
|
| |
| wait_thread = threading.Thread(target=run_with_exception_capture, daemon=True) |
| wait_thread.start() |
|
|
| |
| time.sleep(0.2) |
|
|
| |
| assert all(proc.is_alive() for proc in manager.processes) |
|
|
| |
| print("Simulating process failure...") |
| manager.processes[0].terminate() |
|
|
| |
| |
| |
| wait_thread.join(timeout=1.0) |
|
|
| |
| assert not wait_thread.is_alive() |
|
|
| |
| assert result["exception"] is not None |
| assert "died with exit code" in str(result["exception"]) |
|
|
| |
| for i, proc in enumerate(manager.processes): |
| assert not proc.is_alive(), f"Process {i} should not be alive" |
|
|
| finally: |
| manager.shutdown() |
| time.sleep(0.2) |
|
|
|
|
| @pytest.mark.timeout(30) |
| def test_normal_completion(api_server_args): |
| """Test that wait_for_completion_or_failure works in normal completion.""" |
| global WORKER_RUNTIME_SECONDS |
| WORKER_RUNTIME_SECONDS = 0.1 |
|
|
| |
| manager = APIServerProcessManager(**api_server_args) |
|
|
| try: |
| |
| |
| remaining_processes = manager.processes.copy() |
| while remaining_processes: |
| for proc in remaining_processes: |
| if not proc.is_alive(): |
| remaining_processes.remove(proc) |
| time.sleep(0.1) |
|
|
| |
| for i, proc in enumerate(manager.processes): |
| assert not proc.is_alive(), f"Process {i} still alive after terminate()" |
|
|
| |
| |
| |
| |
| try: |
| wait_for_completion_or_failure(api_server_manager=manager) |
| finally: |
| manager.shutdown() |
|
|
| finally: |
| |
| manager.shutdown() |
| time.sleep(0.2) |
|
|
|
|
| @pytest.mark.timeout(30) |
| def test_external_process_monitoring(api_server_args): |
| """Test that wait_for_completion_or_failure handles additional processes.""" |
| global WORKER_RUNTIME_SECONDS |
| WORKER_RUNTIME_SECONDS = 100 |
|
|
| |
| |
| spawn_context = multiprocessing.get_context("spawn") |
| external_proc = spawn_context.Process( |
| target=mock_run_api_server_worker, name="MockExternalProcess" |
| ) |
| external_proc.start() |
|
|
| |
| class MockCoordinator: |
| def __init__(self, proc): |
| self.proc = proc |
|
|
| def shutdown(self): |
| if self.proc.is_alive(): |
| self.proc.terminate() |
| self.proc.join(timeout=0.5) |
|
|
| |
| mock_coordinator = MockCoordinator(external_proc) |
|
|
| |
| manager = APIServerProcessManager(**api_server_args) |
|
|
| try: |
| |
| assert len(manager.processes) == 3 |
|
|
| |
| result: dict[str, Exception | None] = {"exception": None} |
|
|
| def run_with_exception_capture(): |
| try: |
| wait_for_completion_or_failure( |
| api_server_manager=manager, coordinator=mock_coordinator |
| ) |
| except Exception as e: |
| result["exception"] = e |
| finally: |
| manager.shutdown() |
| mock_coordinator.shutdown() |
|
|
| |
| wait_thread = threading.Thread(target=run_with_exception_capture, daemon=True) |
| wait_thread.start() |
|
|
| |
| time.sleep(0.2) |
| external_proc.terminate() |
|
|
| |
| wait_thread.join(timeout=1.0) |
|
|
| |
| assert not wait_thread.is_alive(), ( |
| "wait_for_completion_or_failure thread still running" |
| ) |
|
|
| |
| assert result["exception"] is not None, "No exception was raised" |
| error_message = str(result["exception"]) |
| assert "died with exit code" in error_message, ( |
| f"Unexpected error message: {error_message}" |
| ) |
| assert "MockExternalProcess" in error_message, ( |
| f"Error doesn't mention external process: {error_message}" |
| ) |
|
|
| |
| for i, proc in enumerate(manager.processes): |
| assert not proc.is_alive(), f"API server process {i} was not terminated" |
|
|
| finally: |
| |
| manager.shutdown() |
| mock_coordinator.shutdown() |
| time.sleep(0.2) |
|
|
|
|
| @pytest.mark.timeout(60) |
| def test_gather_actual_addresses_end_to_end(): |
| """Each child binds ROUTER/PULL with a kernel-picked port and reports |
| the bound endpoints back via its per-child pipe; the manager surfaces |
| them via :py:meth:`gather_actual_addresses`.""" |
| host = "127.0.0.1" |
| num_servers = 4 |
|
|
| placeholder_inputs = [ |
| get_engine_client_zmq_addr(local_only=False, host=host) |
| for _ in range(num_servers) |
| ] |
| placeholder_outputs = [ |
| get_engine_client_zmq_addr(local_only=False, host=host) |
| for _ in range(num_servers) |
| ] |
| for addr in placeholder_inputs + placeholder_outputs: |
| assert addr == f"tcp://{host}:0", addr |
|
|
| sock = socket.socket() |
| manager = APIServerProcessManager( |
| listen_address=f"tcp://{host}:0", |
| sock=sock, |
| args="test_args", |
| num_servers=num_servers, |
| input_addresses=placeholder_inputs, |
| output_addresses=placeholder_outputs, |
| target_server_fn=defer_addresses_stub_worker, |
| ) |
|
|
| try: |
| assert len(manager.processes) == num_servers |
| actual_inputs, actual_outputs = manager.gather_actual_addresses(timeout=15.0) |
| finally: |
| manager.shutdown() |
| time.sleep(0.2) |
| sock.close() |
|
|
| assert len(actual_inputs) == num_servers |
| assert len(actual_outputs) == num_servers |
|
|
| for addr in actual_inputs + actual_outputs: |
| scheme, parsed_host, port = split_zmq_path(addr) |
| assert scheme == "tcp", addr |
| assert parsed_host == host, addr |
| assert port and int(port) > 0, addr |
|
|
| all_addrs = actual_inputs + actual_outputs |
| assert len(set(all_addrs)) == len(all_addrs), all_addrs |
|
|
|
|
| @pytest.mark.timeout(30) |
| def test_gather_actual_addresses_child_crash_before_report(): |
| """A child that exits before sending its endpoints must surface a |
| clear ``RuntimeError`` rather than hang or return ``None`` slots.""" |
| host = "127.0.0.1" |
| num_servers = 2 |
| placeholder_inputs = [ |
| get_engine_client_zmq_addr(local_only=False, host=host) |
| for _ in range(num_servers) |
| ] |
| placeholder_outputs = [ |
| get_engine_client_zmq_addr(local_only=False, host=host) |
| for _ in range(num_servers) |
| ] |
|
|
| sock = socket.socket() |
| manager = APIServerProcessManager( |
| listen_address=f"tcp://{host}:0", |
| sock=sock, |
| args="test_args", |
| num_servers=num_servers, |
| input_addresses=placeholder_inputs, |
| output_addresses=placeholder_outputs, |
| |
| |
| |
| target_server_fn=mock_run_api_server_worker, |
| ) |
| try: |
| |
| with pytest.raises(RuntimeError, match="reporting"): |
| manager.gather_actual_addresses(timeout=10.0) |
| finally: |
| manager.shutdown() |
| time.sleep(0.2) |
| sock.close() |
|
|