main
py 918 lines 30.1 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 """Tests for DPSupervisor: unit tests and lifecycle integration tests.
5
6 Lifecycle integration tests replace child vLLM servers with lightweight
7 aiohttp "fake" servers controlled by the test, so the suite runs without GPUs.
8 _start_children is monkeypatched to install FakeProcess objects (with
9 controllable liveness/timing) alongside those fake HTTP servers.
10
11 Port allocation (kept far from default vLLM ports to avoid conflicts):
12 Supervisor : 19256
13 Children : 18000, 18001
14 """
15
16 from __future__ import annotations
17
18 import argparse
19 import asyncio
20 import contextlib
21 import os
22 import signal
23 import subprocess
24 import tempfile
25 import time
26 from pathlib import Path
27 from types import SimpleNamespace
28
29 import aiohttp
30 import pytest
31 import uvicorn
32 from fastapi import FastAPI, Response
33
34 import vllm.entrypoints.launchers.dp_supervisor as dp_sup
35 from vllm.entrypoints.launchers.dp_supervisor import (
36 CHILD_EXIT_GRACE_S,
37 DPSupervisor,
38 _build_vllm_dp_server_args,
39 infer_multi_port_external_lb_start_rank,
40 validate_multi_port_external_lb_args,
41 )
42 from vllm.logger import init_logger
43
44 logger = init_logger(__name__)
45
46 _SUPERVISOR_PORT = 19256
47 _CHILD_PORT_BASE = 18000
48 _N_CHILDREN = 2
49 _PROBE_INTERVAL = 1.0
50
51
52 # ---------------------------------------------------------------------------
53 # Args factories
54 # ---------------------------------------------------------------------------
55
56
57 def _make_unit_args(**overrides) -> argparse.Namespace:
58 """Minimal args for unit tests (no real network activity)."""
59 base = {
60 "host": None,
61 "port": 8000,
62 "data_parallel_multi_port_external_lb": True,
63 "data_parallel_supervisor_port": 9256,
64 "dp_supervisor_probe_interval_s": 5.0,
65 "dp_supervisor_probe_timeout_s": 5.0,
66 "dp_supervisor_probe_failure_threshold": 3,
67 "data_parallel_size": 8,
68 "data_parallel_size_local": 4,
69 "data_parallel_start_rank": None,
70 "data_parallel_rank": None,
71 "data_parallel_external_lb": False,
72 "data_parallel_hybrid_lb": False,
73 "api_server_count": None,
74 "headless": False,
75 "grpc": False,
76 "uds": None,
77 "ssl_keyfile": None,
78 "ssl_certfile": None,
79 "ssl_ca_certs": None,
80 "ssl_cert_reqs": 0,
81 "ssl_ciphers": None,
82 "node_rank": 1,
83 "tensor_parallel_size": 1,
84 "pipeline_parallel_size": 1,
85 "uvicorn_log_level": "info",
86 "shutdown_timeout": 5.0,
87 "disable_uvicorn_access_log": False,
88 "disable_access_log_for_endpoints": None,
89 "log_config_file": None,
90 }
91 base.update(overrides)
92 return argparse.Namespace(**base)
93
94
95 def _make_args(**overrides) -> argparse.Namespace:
96 """Args for lifecycle integration tests (real loopback servers)."""
97 base: dict = dict(
98 host="127.0.0.1",
99 port=_CHILD_PORT_BASE,
100 data_parallel_multi_port_external_lb=True,
101 data_parallel_supervisor_port=_SUPERVISOR_PORT,
102 dp_supervisor_probe_interval_s=_PROBE_INTERVAL,
103 dp_supervisor_probe_timeout_s=1.0,
104 dp_supervisor_probe_failure_threshold=3,
105 data_parallel_size=_N_CHILDREN,
106 data_parallel_size_local=_N_CHILDREN,
107 data_parallel_start_rank=0,
108 data_parallel_rank=None,
109 data_parallel_external_lb=False,
110 data_parallel_hybrid_lb=False,
111 api_server_count=None,
112 headless=False,
113 grpc=False,
114 uds=None,
115 ssl_keyfile=None,
116 ssl_certfile=None,
117 ssl_ca_certs=None,
118 ssl_cert_reqs=0,
119 ssl_ciphers=None,
120 node_rank=0,
121 tensor_parallel_size=1,
122 pipeline_parallel_size=1,
123 uvicorn_log_level="warning",
124 shutdown_timeout=0.0,
125 disable_uvicorn_access_log=False,
126 disable_access_log_for_endpoints=None,
127 log_config_file=None,
128 )
129 base.update(overrides)
130 return argparse.Namespace(**base)
131
132
133 def _generate_self_signed_cert(cert_dir: Path) -> tuple[Path, Path]:
134 """Generate a self-signed certificate for HTTPS lifecycle tests."""
135 cert_file = cert_dir / "cert.pem"
136 key_file = cert_dir / "key.pem"
137 subprocess.run(
138 [
139 "openssl",
140 "req",
141 "-x509",
142 "-newkey",
143 "rsa:2048",
144 "-keyout",
145 str(key_file),
146 "-out",
147 str(cert_file),
148 "-days",
149 "1",
150 "-nodes",
151 "-subj",
152 "/CN=localhost",
153 ],
154 check=True,
155 capture_output=True,
156 )
157 return cert_file, key_file
158
159
160 # ---------------------------------------------------------------------------
161 # Unit tests
162 # ---------------------------------------------------------------------------
163
164
165 def test_infer_multi_port_external_lb_start_rank_uses_node_rank():
166 args = _make_unit_args()
167 assert infer_multi_port_external_lb_start_rank(args) == 4
168
169
170 def test_build_multi_port_external_lb_child_args_sets_external_rank_server():
171 args = _make_unit_args(data_parallel_start_rank=8, api_server_count=None)
172 child_args = _build_vllm_dp_server_args(args, local_rank=2)
173
174 assert child_args.port == 8002
175 assert child_args.data_parallel_rank == 10
176 assert child_args.data_parallel_size_local == 1
177 assert child_args.data_parallel_external_lb is True
178 assert child_args.data_parallel_hybrid_lb is False
179 assert child_args.data_parallel_multi_port_external_lb is False
180 assert child_args.api_server_count == 1
181
182
183 def test_run_vllm_dp_server_uses_python_server_by_default(monkeypatch):
184 calls: list[str] = []
185
186 monkeypatch.setattr(dp_sup.os, "setpgrp", lambda: None)
187 monkeypatch.setattr(dp_sup, "set_process_title", lambda *_args: None)
188 monkeypatch.setattr(dp_sup, "decorate_logs", lambda *_args: None)
189 monkeypatch.setattr(dp_sup.envs, "VLLM_RUST_FRONTEND_PATH", None, raising=False)
190 monkeypatch.setattr(
191 dp_sup, "_run_python_vllm_dp_server", lambda _args: calls.append("python")
192 )
193 monkeypatch.setattr(
194 dp_sup, "_run_rust_vllm_dp_server", lambda _args: calls.append("rust")
195 )
196
197 dp_sup._run_vllm_dp_server(_make_unit_args(data_parallel_rank=4))
198
199 assert calls == ["python"]
200
201
202 def test_run_vllm_dp_server_uses_rust_frontend_when_enabled(monkeypatch):
203 calls: list[str] = []
204
205 monkeypatch.setattr(dp_sup.os, "setpgrp", lambda: None)
206 monkeypatch.setattr(dp_sup, "set_process_title", lambda *_args: None)
207 monkeypatch.setattr(dp_sup, "decorate_logs", lambda *_args: None)
208 monkeypatch.setattr(dp_sup.envs, "VLLM_USE_RUST_FRONTEND", True, raising=False)
209 monkeypatch.setattr(
210 dp_sup.envs,
211 "VLLM_RUST_FRONTEND_PATH",
212 "/tmp/vllm-rs",
213 raising=False,
214 )
215 monkeypatch.setattr(
216 dp_sup, "_run_python_vllm_dp_server", lambda _args: calls.append("python")
217 )
218 monkeypatch.setattr(
219 dp_sup, "_run_rust_vllm_dp_server", lambda _args: calls.append("rust")
220 )
221
222 dp_sup._run_vllm_dp_server(_make_unit_args(data_parallel_rank=4))
223
224 assert calls == ["rust"]
225
226
227 def test_validate_multi_port_external_lb_args_allows_ssl():
228 args = _make_unit_args(
229 ssl_keyfile="/tmp/server.key",
230 ssl_certfile="/tmp/server.crt",
231 ssl_ca_certs="/tmp/ca.crt",
232 )
233 validate_multi_port_external_lb_args(args)
234
235
236 def test_aggregates_health():
237 supervisor = DPSupervisor(_make_unit_args())
238 supervisor._is_ready = True
239 assert supervisor.is_ready is True
240
241
242 def test_handles_shutdown_event():
243 supervisor = DPSupervisor(_make_unit_args())
244 supervisor._is_ready = True
245 supervisor._shutdown_event.set()
246 assert supervisor.is_ready is False
247
248
249 @pytest.mark.asyncio
250 async def test_shutdown_children_uses_engine_process_timeout(
251 monkeypatch: pytest.MonkeyPatch,
252 ):
253 supervisor = DPSupervisor(_make_unit_args(shutdown_timeout=0.0))
254 supervisor._processes = [
255 SimpleNamespace(name="APIServer_DPRank_4", pid=None, is_alive=lambda: False)
256 ]
257 calls = []
258 timeout_calls = []
259
260 def get_process_timeout(request_timeout, manager_timeout):
261 timeout_calls.append((request_timeout, manager_timeout))
262 return 15.0
263
264 monkeypatch.setattr(
265 dp_sup,
266 "get_engine_process_shutdown_timeout",
267 get_process_timeout,
268 )
269 monkeypatch.setattr(
270 dp_sup,
271 "_join_processes_with_timeout",
272 lambda processes, timeout: calls.append((processes, timeout)),
273 )
274
275 await supervisor._shutdown_children()
276
277 assert timeout_calls == [(0.0, 0.0)]
278 assert calls == [
279 (supervisor._processes, 15.0 + CHILD_EXIT_GRACE_S),
280 ]
281
282
283 @pytest.mark.asyncio
284 async def test_handles_child_exit(
285 monkeypatch: pytest.MonkeyPatch,
286 ):
287 supervisor = DPSupervisor(_make_unit_args())
288 supervisor._processes = [
289 SimpleNamespace(
290 name="APIServer_DPRank_4", exitcode=None, is_alive=lambda: True
291 ),
292 SimpleNamespace(name="APIServer_DPRank_5", exitcode=17, is_alive=lambda: False),
293 ]
294
295 async def fake_probe(*_args, **_kwargs) -> bool:
296 return True
297
298 monkeypatch.setattr(dp_sup, "_probe_endpoint", fake_probe)
299
300 await supervisor._monitor_children()
301 assert supervisor._is_ready is False
302
303
304 @pytest.mark.asyncio
305 async def test_handles_probe_failure(
306 monkeypatch: pytest.MonkeyPatch,
307 ):
308 supervisor = DPSupervisor(_make_unit_args(dp_supervisor_probe_interval_s=0.0))
309 supervisor.child_ports = [8000]
310 probe_results = iter([True, False])
311
312 async def fake_probe(*_args, **_kwargs) -> bool:
313 return next(probe_results)
314
315 monkeypatch.setattr(dp_sup, "_probe_endpoint", fake_probe)
316
317 await supervisor._monitor_children()
318 assert supervisor._is_ready is False
319
320
321 @pytest.mark.asyncio
322 async def test_shutdown_if_supervisor_server_error_on_startup(
323 monkeypatch: pytest.MonkeyPatch,
324 ):
325 class FakeLoop:
326 def add_signal_handler(self, *_args, **_kwargs):
327 pass
328
329 def remove_signal_handler(self, *_args, **_kwargs):
330 pass
331
332 class FakeServer:
333 def __init__(self, _config):
334 self.started = False
335 self.should_exit = False
336
337 async def serve(self):
338 raise ValueError("supervisor boom")
339
340 async def fake_shutdown_children(self):
341 return None
342
343 def fake_start_children(self):
344 return None
345
346 async def fake_monitor_children(self):
347 # Mark ready so the supervisor server is started, then block until
348 # shutdown (triggered when the failing server task exits).
349 self._is_ready = True
350 await self._shutdown_event.wait()
351
352 monkeypatch.setattr(dp_sup.asyncio, "get_running_loop", lambda: FakeLoop())
353 monkeypatch.setattr(dp_sup, "NoSignalServer", FakeServer)
354 monkeypatch.setattr(DPSupervisor, "_shutdown_children", fake_shutdown_children)
355 monkeypatch.setattr(DPSupervisor, "_start_children", fake_start_children)
356 monkeypatch.setattr(DPSupervisor, "_monitor_children", fake_monitor_children)
357
358 supervisor = DPSupervisor(_make_unit_args())
359
360 with pytest.raises(ValueError, match="supervisor boom"):
361 await supervisor.run()
362
363
364 # ---------------------------------------------------------------------------
365 # Access-log filtering unit tests
366 # ---------------------------------------------------------------------------
367
368
369 @pytest.mark.asyncio
370 async def test_start_server_applies_access_log_filter(monkeypatch):
371 """get_uvicorn_log_config result is forwarded to uvicorn.Config."""
372 sentinel_config = {"version": 1, "loggers": {}}
373 captured_config: list[uvicorn.Config] = []
374
375 monkeypatch.setattr(dp_sup, "get_uvicorn_log_config", lambda _args: sentinel_config)
376
377 class FakeServer:
378 def __init__(self, config):
379 captured_config.append(config)
380 self.started = True
381 self.should_exit = False
382
383 async def serve(self):
384 pass
385
386 monkeypatch.setattr(dp_sup, "NoSignalServer", FakeServer)
387
388 args = _make_unit_args(
389 host="127.0.0.1",
390 disable_access_log_for_endpoints="/health,/readyz",
391 )
392 supervisor = DPSupervisor(args)
393 await supervisor._start_server()
394
395 assert len(captured_config) == 1
396 config = captured_config[0]
397 assert config.log_config is sentinel_config
398 assert config.access_log is True
399
400
401 @pytest.mark.asyncio
402 async def test_start_server_disables_access_log(monkeypatch):
403 """disable_uvicorn_access_log=True sets access_log=False."""
404 captured_config: list[uvicorn.Config] = []
405
406 monkeypatch.setattr(dp_sup, "get_uvicorn_log_config", lambda _args: None)
407
408 class FakeServer:
409 def __init__(self, config):
410 captured_config.append(config)
411 self.started = True
412 self.should_exit = False
413
414 async def serve(self):
415 pass
416
417 monkeypatch.setattr(dp_sup, "NoSignalServer", FakeServer)
418
419 args = _make_unit_args(
420 host="127.0.0.1",
421 disable_uvicorn_access_log=True,
422 )
423 supervisor = DPSupervisor(args)
424 await supervisor._start_server()
425
426 assert len(captured_config) == 1
427 assert captured_config[0].access_log is False
428
429
430 @pytest.mark.asyncio
431 async def test_start_server_no_log_config_when_no_filter(monkeypatch):
432 """Without filtering options, default uvicorn log_config is used."""
433 captured_config: list[uvicorn.Config] = []
434
435 monkeypatch.setattr(dp_sup, "get_uvicorn_log_config", lambda _args: None)
436
437 class FakeServer:
438 def __init__(self, config):
439 captured_config.append(config)
440 self.started = True
441 self.should_exit = False
442
443 async def serve(self):
444 pass
445
446 monkeypatch.setattr(dp_sup, "NoSignalServer", FakeServer)
447
448 args = _make_unit_args(host="127.0.0.1")
449 supervisor = DPSupervisor(args)
450 await supervisor._start_server()
451
452 assert len(captured_config) == 1
453 config = captured_config[0]
454 assert config.log_config == uvicorn.Config(app=None).log_config
455 assert config.access_log is True
456
457
458 # ---------------------------------------------------------------------------
459 # Lifecycle integration tests – MockVLLMServer
460 # ---------------------------------------------------------------------------
461
462
463 class MockVLLMServer:
464 """Minimal FastAPI server that mimics one vLLM replica.
465 GET /health returns 200 when healthy, 503 otherwise.
466 Health state is toggled by the test via set_healthy().
467 """
468
469 def __init__(
470 self,
471 port: int,
472 drain_seconds: float = 0.0,
473 ssl_keyfile: str | None = None,
474 ssl_certfile: str | None = None,
475 ) -> None:
476 self.port = port
477 self._healthy = False
478 self._drain_seconds = drain_seconds
479 self._ssl_keyfile = ssl_keyfile
480 self._ssl_certfile = ssl_certfile
481 self._server: uvicorn.Server | None = None
482 self._serve_task: asyncio.Task | None = None
483
484 async def start(self) -> None:
485 app = FastAPI()
486
487 @app.get("/health")
488 async def health() -> Response:
489 print(f"MockServer {self.port}: /health: {self._healthy}")
490 return Response(status_code=200 if self._healthy else 503)
491
492 @app.get("/set_healthy")
493 async def set_healthy() -> Response:
494 print(f"MockServer {self.port}: /set_healthy")
495 self._healthy = True
496 return Response(status_code=200)
497
498 @app.get("/set_unhealthy")
499 async def set_unhealthy() -> Response:
500 print(f"MockServer {self.port}: /set_unhealthy")
501 self._healthy = False
502 return Response(status_code=200)
503
504 @app.get("/kill")
505 async def kill() -> Response:
506 print(f"MockServer {self.port}: /kill")
507 os.kill(os.getpid(), signal.SIGKILL)
508
509 config = uvicorn.Config(
510 app,
511 host="127.0.0.1",
512 port=self.port,
513 log_level="warning",
514 lifespan="off",
515 ssl_keyfile=self._ssl_keyfile,
516 ssl_certfile=self._ssl_certfile,
517 )
518 self._server = uvicorn.Server(config)
519
520 # Configure request draining if needed.
521 # Uvicorn's capture_signals() installs signal.signal(SIGTERM, self.handle_exit),
522 # which sets should_exit=True immediately. Override handle_exit on the instance
523 # so capture_signals() picks up our version that drains first.
524 if self._drain_seconds > 0:
525 self._shutdown_event = asyncio.Event()
526 loop = asyncio.get_running_loop()
527
528 async def _drain_and_stop() -> None:
529 await self._shutdown_event.wait()
530 print(f"MockServer {self.port}: draining for {self._drain_seconds}s.")
531 await asyncio.sleep(self._drain_seconds)
532 print("Setting should_exit")
533 if self._server is not None:
534 self._server.should_exit = True
535
536 self._drain_task = asyncio.create_task(_drain_and_stop())
537
538 def _custom_handle_exit(sig: int, frame: object) -> None:
539 print("Got SIGTERM, setting shutdown.")
540 if not self._shutdown_event.is_set():
541 loop.call_soon_threadsafe(self._shutdown_event.set)
542
543 self._server.handle_exit = _custom_handle_exit
544
545 self._serve_task = asyncio.create_task(self._server.serve())
546 while not self._server.started:
547 await asyncio.sleep(0.01)
548 print(f"Mock DP Server on port {self.port} started")
549
550 await self._serve_task
551
552
553 def launch_mock_vllm(child_args: argparse.Namespace):
554 logger.info("Launching mock vLLM on port %s", child_args.port)
555 mock_vllm = MockVLLMServer(
556 port=child_args.port,
557 ssl_keyfile=child_args.ssl_keyfile,
558 ssl_certfile=child_args.ssl_certfile,
559 )
560 asyncio.run(mock_vllm.start())
561
562
563 def launch_mock_vllm_with_drain(
564 child_args: argparse.Namespace,
565 ):
566 logger.info("Launching mock vLLM with 15s drain on port %s", child_args.port)
567 mock_vllm = MockVLLMServer(
568 port=child_args.port,
569 drain_seconds=10.0,
570 ssl_keyfile=child_args.ssl_keyfile,
571 ssl_certfile=child_args.ssl_certfile,
572 )
573 asyncio.run(mock_vllm.start())
574
575
576 # ---------------------------------------------------------------------------
577 # Lifecycle test helpers
578 # ---------------------------------------------------------------------------
579
580
581 async def _poll_supervisor_health(expected_status: int, use_ssl: bool = False) -> bool:
582 """GET /health on the supervisor once and check for expected_status.
583
584 Pass expected_status=-1 to assert the supervisor is not listening yet
585 (a connection error is expected). The supervisor only starts its HTTP
586 server once every child is ready, so /health is refused until then.
587 """
588 scheme = "https" if use_ssl else "http"
589 url = f"{scheme}://127.0.0.1:{_SUPERVISOR_PORT}/health"
590 async with aiohttp.ClientSession() as session:
591 try:
592 async with session.get(url, ssl=False if use_ssl else None) as resp:
593 if resp.status != expected_status:
594 print(f"expected: {expected_status=}, got: {resp.status=}")
595 return False
596 return True
597 except aiohttp.ClientError:
598 if expected_status != -1:
599 print(f"expected: {expected_status=}, got: aiohttp.ClientError")
600 return False
601 return True
602
603
604 async def _await_supervisor_health(
605 expected_status: int, use_ssl: bool = False, retries: int = 20
606 ) -> bool:
607 """Retry _poll_supervisor_health, tolerating supervisor server startup."""
608 for _ in range(retries):
609 if await _poll_supervisor_health(expected_status, use_ssl=use_ssl):
610 return True
611 await asyncio.sleep(0.5)
612 return False
613
614
615 async def _poll_until_api_server_running(
616 port: int, timeout_s: float = 30.0, use_ssl: bool = False
617 ) -> None:
618 """Return once the child accepts a request; it reports 503 until healthy."""
619 scheme = "https" if use_ssl else "http"
620 url = f"{scheme}://127.0.0.1:{port}/health"
621 deadline = time.monotonic() + timeout_s
622 last_exc: Exception | None = None
623 async with aiohttp.ClientSession() as session:
624 while (remaining_s := deadline - time.monotonic()) > 0:
625 try:
626 request_timeout = aiohttp.ClientTimeout(total=min(2.0, remaining_s))
627 async with session.get(
628 url,
629 ssl=False if use_ssl else None,
630 timeout=request_timeout,
631 ):
632 return
633 except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
634 last_exc = exc
635 print("Test detected not started yet, sleeping for 1s")
636 remaining_s = deadline - time.monotonic()
637 if remaining_s > 0:
638 await asyncio.sleep(min(1.0, remaining_s))
639 raise TimeoutError(
640 f"API server on port {port} did not start within {timeout_s}s"
641 ) from last_exc
642
643
644 async def _set_healthy(port: int, use_ssl: bool = False) -> None:
645 scheme = "https" if use_ssl else "http"
646 url = f"{scheme}://127.0.0.1:{port}/set_healthy"
647 async with (
648 aiohttp.ClientSession() as session,
649 session.get(url, ssl=False if use_ssl else None) as resp,
650 ):
651 assert resp.status == 200
652
653
654 async def _set_unhealthy(port: int, use_ssl: bool = False) -> None:
655 scheme = "https" if use_ssl else "http"
656 url = f"{scheme}://127.0.0.1:{port}/set_unhealthy"
657 async with (
658 aiohttp.ClientSession() as session,
659 session.get(url, ssl=False if use_ssl else None) as resp,
660 ):
661 assert resp.status == 200
662
663
664 async def _kill_server(port: int, use_ssl: bool = False) -> None:
665 scheme = "https" if use_ssl else "http"
666 url = f"{scheme}://127.0.0.1:{port}/kill"
667 try:
668 async with (
669 aiohttp.ClientSession() as session,
670 session.get(url, ssl=False if use_ssl else None) as resp,
671 ):
672 assert resp.status != 200
673 except aiohttp.ClientConnectionError:
674 return
675
676
677 @contextlib.asynccontextmanager
678 async def _run_supervisor(
679 args: argparse.Namespace,
680 monkeypatch: pytest.MonkeyPatch,
681 launch_fn=None,
682 ):
683 if launch_fn is None:
684 launch_fn = launch_mock_vllm
685 monkeypatch.setattr(dp_sup, "_run_vllm_dp_server", launch_fn)
686 supervisor = DPSupervisor(args)
687 task = asyncio.create_task(supervisor.run())
688 await asyncio.sleep(1.0)
689 try:
690 yield supervisor, task
691 finally:
692 task.cancel()
693 with contextlib.suppress(asyncio.CancelledError):
694 await task
695
696
697 # ---------------------------------------------------------------------------
698 # Lifecycle integration tests
699 # ---------------------------------------------------------------------------
700
701
702 @pytest.mark.asyncio
703 async def test_basic_lifecycle(monkeypatch):
704 """A) Supervisor is not listening while children are unhealthy.
705 B) /health returns 200 once every child reports healthy.
706 C) SIGTERM and shutdown
707 """
708 args = _make_args()
709
710 vllm_server_ports = [_CHILD_PORT_BASE + i for i in range(_N_CHILDREN)]
711
712 async with _run_supervisor(args, monkeypatch) as (supervisor, _task):
713 assert await _poll_supervisor_health(-1)
714 assert not supervisor.is_ready
715
716 for port in vllm_server_ports:
717 assert await _poll_supervisor_health(-1)
718 assert not supervisor.is_ready
719 await _poll_until_api_server_running(port)
720
721 await _set_healthy(vllm_server_ports[0])
722 await asyncio.sleep(1.0)
723 assert await _poll_supervisor_health(-1)
724 assert not supervisor.is_ready
725 print("supervisor not listening --- expected!")
726
727 for port in vllm_server_ports:
728 await _set_healthy(port)
729 assert await _await_supervisor_health(200)
730 assert supervisor.is_ready
731 print("/health is 200 --- expected!")
732
733 await asyncio.sleep(1.0)
734 assert await _poll_supervisor_health(200)
735 assert supervisor.is_ready
736 print("/health is 200 --- expected!")
737
738 os.kill(os.getpid(), signal.SIGTERM)
739
740 await asyncio.wait_for(_task, timeout=5.0)
741 for p in supervisor._processes:
742 assert not p.is_alive()
743 print("everything was cleaned up!")
744
745
746 @pytest.mark.asyncio
747 async def test_basic_lifecycle_with_ssl(monkeypatch):
748 with tempfile.TemporaryDirectory() as cert_dir:
749 cert_file, key_file = _generate_self_signed_cert(Path(cert_dir))
750 args = _make_args(
751 ssl_keyfile=str(key_file),
752 ssl_certfile=str(cert_file),
753 )
754
755 vllm_server_ports = [_CHILD_PORT_BASE + i for i in range(_N_CHILDREN)]
756
757 async with _run_supervisor(args, monkeypatch) as (supervisor, _task):
758 assert await _poll_supervisor_health(-1, use_ssl=True)
759 assert not supervisor.is_ready
760
761 for port in vllm_server_ports:
762 assert await _poll_supervisor_health(-1, use_ssl=True)
763 assert not supervisor.is_ready
764 await _poll_until_api_server_running(port, use_ssl=True)
765
766 for port in vllm_server_ports:
767 await _set_healthy(port, use_ssl=True)
768
769 assert await _await_supervisor_health(200, use_ssl=True)
770 assert supervisor.is_ready
771
772
773 @pytest.mark.asyncio
774 async def test_failed_startup(monkeypatch):
775 """A) One of the vLLM servers crashes during startup.
776 B) DPSupervisor detects this, and cleans up resources.
777 """
778 args = _make_args()
779
780 vllm_server_ports = [_CHILD_PORT_BASE + i for i in range(_N_CHILDREN)]
781
782 async with _run_supervisor(args, monkeypatch) as (supervisor, _task):
783 assert await _poll_supervisor_health(-1)
784 assert not supervisor.is_ready
785
786 for port in vllm_server_ports:
787 await _poll_until_api_server_running(port)
788
789 await _kill_server(port)
790
791 await asyncio.wait_for(_task, timeout=5.0)
792 for p in supervisor._processes:
793 assert not p.is_alive()
794
795
796 @pytest.mark.asyncio
797 async def test_becomes_unhealthy(monkeypatch):
798 """A) Supervisor is not listening while children are unhealthy.
799 B) /health returns 200 once every child reports healthy.
800 C) Child process becomes unhealthy.
801 D) Detected and shutdown.
802 """
803 args = _make_args()
804
805 vllm_server_ports = [_CHILD_PORT_BASE + i for i in range(_N_CHILDREN)]
806
807 async with _run_supervisor(args, monkeypatch) as (supervisor, _task):
808 assert await _poll_supervisor_health(-1)
809 assert not supervisor.is_ready
810
811 for port in vllm_server_ports:
812 assert await _poll_supervisor_health(-1)
813 assert not supervisor.is_ready
814 await _poll_until_api_server_running(port)
815
816 await _set_healthy(vllm_server_ports[0])
817 await asyncio.sleep(1.0)
818 assert await _poll_supervisor_health(-1)
819 assert not supervisor.is_ready
820 print("supervisor not listening --- expected!")
821
822 for port in vllm_server_ports:
823 await _set_healthy(port)
824 assert await _await_supervisor_health(200)
825 assert supervisor.is_ready
826 print("/health is 200 --- expected!")
827
828 await _set_unhealthy(port)
829
830 await asyncio.wait_for(_task, timeout=5.0)
831 for p in supervisor._processes:
832 assert not p.is_alive()
833 print("everything was cleaned up!")
834
835
836 @pytest.mark.asyncio
837 async def test_dp_server_fails(monkeypatch):
838 """A) Supervisor is not listening while children are unhealthy.
839 B) /health returns 200 once every child reports healthy.
840 C) Child process fails.
841 D) Detected and shutdown.
842 """
843 args = _make_args()
844
845 vllm_server_ports = [_CHILD_PORT_BASE + i for i in range(_N_CHILDREN)]
846
847 async with _run_supervisor(args, monkeypatch) as (supervisor, _task):
848 assert await _poll_supervisor_health(-1)
849 assert not supervisor.is_ready
850
851 for port in vllm_server_ports:
852 assert await _poll_supervisor_health(-1)
853 assert not supervisor.is_ready
854 await _poll_until_api_server_running(port)
855
856 await _set_healthy(vllm_server_ports[0])
857 await asyncio.sleep(1.0)
858 assert await _poll_supervisor_health(-1)
859 assert not supervisor.is_ready
860 print("supervisor not listening --- expected!")
861
862 for port in vllm_server_ports:
863 await _set_healthy(port)
864 assert await _await_supervisor_health(200)
865 assert supervisor.is_ready
866 print("/health is 200 --- expected!")
867
868 dp_mock_server_process = supervisor._processes[0]
869 os.kill(dp_mock_server_process.pid, signal.SIGKILL)
870 await asyncio.sleep(1.0)
871 assert not dp_mock_server_process.is_alive()
872
873 await asyncio.wait_for(_task, timeout=5.0)
874 for p in supervisor._processes:
875 assert not p.is_alive()
876 print("everything was cleaned up!")
877
878
879 @pytest.mark.asyncio
880 async def test_shutdown_timeout(monkeypatch: pytest.MonkeyPatch):
881 """Child mock servers delay shutdown by 10s on SIGTERM (simulating in-flight
882 request drain). The supervisor is configured with shutdown_timeout=10,
883 so its total wait budget is 10 + CHILD_EXIT_GRACE_S seconds. The
884 children exit naturally within that window, so no force-kill should occur
885 and the measured wall-clock time must be >= 10s.
886 """
887 _DRAIN_SECONDS = 10.0
888 _SHUTDOWN_TIMEOUT = 10.0
889
890 args = _make_args(shutdown_timeout=_SHUTDOWN_TIMEOUT)
891 vllm_server_ports = [_CHILD_PORT_BASE + i for i in range(_N_CHILDREN)]
892
893 async with _run_supervisor(
894 args, monkeypatch, launch_fn=launch_mock_vllm_with_drain
895 ) as (supervisor, _task):
896 for port in vllm_server_ports:
897 await _poll_until_api_server_running(port)
898
899 for port in vllm_server_ports:
900 await _set_healthy(port)
901 assert await _await_supervisor_health(200)
902 assert supervisor.is_ready
903
904 start_t = time.perf_counter()
905 os.kill(os.getpid(), signal.SIGTERM)
906
907 print(f"DRAINING FOR {_DRAIN_SECONDS}")
908 await asyncio.wait_for(_task, timeout=_DRAIN_SECONDS + CHILD_EXIT_GRACE_S + 5.0)
909 elapsed = time.perf_counter() - start_t
910
911 assert elapsed >= _DRAIN_SECONDS, (
912 f"Supervisor exited after only {elapsed:.1f}s; "
913 f"expected >= {_DRAIN_SECONDS}s for request draining"
914 )
915
916 for p in supervisor._processes:
917 assert not p.is_alive()
918 print(f"Supervisor waited {elapsed:.1f}s for children to drain — expected!")