A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 6, 23:00 UTC
[Frontend][Core] Revert "Add shutdown timeout" (#34730 and #36270) (#36628)
Signed-off-by: Mark McLoughlin <markmc@redhat.com>
Mark McLoughlin committed
Mar 10, 2026 at 13:20 UTC
234860399b9d390bf59bfe1f19c2e2304ac5c806
14 files changed
+95
-761
tests/entrypoints/openai/test_shutdown.py
-459
@@ -1,20 +1,14 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-"""Integration tests for shutdown behavior, timeout, and signal handling."""
3
5
-import asyncio
4
import signal
5
import subprocess
6
import sys
7
import time
10
-from dataclasses import dataclass, field
8
12
-import httpx
9
import openai
14
-import psutil
10
import pytest
11
17
-from tests.utils import RemoteOpenAIServer
12
from vllm.platforms import current_platform
13
from vllm.utils.network_utils import get_open_port
14
@@ -24,101 +18,6 @@ MODEL_NAME = "hmellor/tiny-random-LlamaForCausalLM"
18
_IS_ROCM = current_platform.is_rocm()
19
_SERVER_STARTUP_TIMEOUT = 120
20
_PROCESS_EXIT_TIMEOUT = 15
27
-_SHUTDOWN_DETECTION_TIMEOUT = 10
28
-_CHILD_CLEANUP_TIMEOUT = 10
29
-
30
-
31
-def _get_child_pids(parent_pid: int) -> list[int]:
32
- try:
33
- parent = psutil.Process(parent_pid)
34
- return [c.pid for c in parent.children(recursive=True)]
35
- except psutil.NoSuchProcess:
36
- return []
37
-
38
-
39
-async def _assert_children_cleaned_up(
40
- child_pids: list[int],
41
- timeout: float = _CHILD_CLEANUP_TIMEOUT,
42
-):
43
- """Wait for child processes to exit and fail if any remain."""
44
- if not child_pids:
45
- return
46
-
47
- deadline = time.time() + timeout
48
- while time.time() < deadline:
49
- still_alive = []
50
- for pid in child_pids:
51
- try:
52
- p = psutil.Process(pid)
53
- if p.is_running() and p.status() != psutil.STATUS_ZOMBIE:
54
- still_alive.append(pid)
55
- except psutil.NoSuchProcess:
56
- pass
57
- if not still_alive:
58
- return
59
- await asyncio.sleep(0.5)
60
-
61
- pytest.fail(
62
- f"Child processes {still_alive} still alive after {timeout}s. "
63
- f"Process cleanup may not be working correctly."
64
- )
65
-
66
-
67
-@dataclass
68
-class ShutdownState:
69
- got_503: bool = False
70
- got_500: bool = False
71
- requests_after_sigterm: int = 0
72
- aborted_requests: int = 0
73
- connection_errors: int = 0
74
- stop_requesting: bool = False
75
- errors: list[str] = field(default_factory=list)
76
-
77
-
78
-async def _concurrent_request_loop(
79
- client: openai.AsyncOpenAI,
80
- state: ShutdownState,
81
- sigterm_sent: asyncio.Event | None = None,
82
- concurrency: int = 10,
83
-):
84
- """Run multiple concurrent requests to keep the server busy."""
85
-
86
- async def single_request():
87
- while not state.stop_requesting:
88
- try:
89
- response = await client.completions.create(
90
- model=MODEL_NAME,
91
- prompt="Write a story: ",
92
- max_tokens=200,
93
- )
94
- if sigterm_sent is not None and sigterm_sent.is_set():
95
- state.requests_after_sigterm += 1
96
- # Check if any choice has finish_reason='abort'
97
- if any(choice.finish_reason == "abort" for choice in response.choices):
98
- state.aborted_requests += 1
99
- except openai.APIStatusError as e:
100
- if e.status_code == 503:
101
- state.got_503 = True
102
- elif e.status_code == 500:
103
- state.got_500 = True
104
- else:
105
- state.errors.append(f"API error: {e}")
106
- except (openai.APIConnectionError, httpx.RemoteProtocolError):
107
- state.connection_errors += 1
108
- if sigterm_sent is not None and sigterm_sent.is_set():
109
- break
110
- except Exception as e:
111
- state.errors.append(f"Unexpected error: {e}")
112
- break
113
- await asyncio.sleep(0.01)
114
-
115
- tasks = [asyncio.create_task(single_request()) for _ in range(concurrency)]
116
- try:
117
- await asyncio.gather(*tasks, return_exceptions=True)
118
- finally:
119
- for t in tasks:
120
- if not t.done():
121
- t.cancel()
21
22
23
@pytest.mark.asyncio
@@ -204,361 +103,3 @@ async def test_shutdown_on_engine_failure():
103
104
return_code = proc.wait(timeout=_PROCESS_EXIT_TIMEOUT)
105
assert return_code is not None
207
-
208
-
209
-@pytest.mark.asyncio
210
-async def test_wait_timeout_completes_requests():
211
- """Verify wait timeout: new requests rejected, in-flight requests complete."""
212
- server_args = [
213
- "--dtype",
214
- "bfloat16",
215
- "--max-model-len",
216
- "256",
217
- "--enforce-eager",
218
- "--gpu-memory-utilization",
219
- "0.05",
220
- "--max-num-seqs",
221
- "4",
222
- "--shutdown-timeout",
223
- "30",
224
- ]
225
-
226
- with RemoteOpenAIServer(MODEL_NAME, server_args) as remote_server:
227
- client = remote_server.get_async_client()
228
- proc = remote_server.proc
229
- child_pids = _get_child_pids(proc.pid)
230
-
231
- state = ShutdownState()
232
- sigterm_sent = asyncio.Event()
233
-
234
- request_task = asyncio.create_task(
235
- _concurrent_request_loop(client, state, sigterm_sent, concurrency=10)
236
- )
237
-
238
- await asyncio.sleep(0.5)
239
- proc.send_signal(signal.SIGTERM)
240
- sigterm_sent.set()
241
-
242
- try:
243
- await asyncio.wait_for(request_task, timeout=_SHUTDOWN_DETECTION_TIMEOUT)
244
- except asyncio.TimeoutError:
245
- pass
246
- finally:
247
- state.stop_requesting = True
248
- if not request_task.done():
249
- request_task.cancel()
250
- await asyncio.gather(request_task, return_exceptions=True)
251
-
252
- # wait timeout should complete in-flight requests
253
- assert state.requests_after_sigterm > 0, (
254
- f"Wait timeout should complete in-flight requests. "
255
- f"503: {state.got_503}, 500: {state.got_500}, "
256
- f"conn_errors: {state.connection_errors}, errors: {state.errors}"
257
- )
258
- # server must stop accepting new requests (503, 500, or connection close)
259
- assert state.got_503 or state.got_500 or state.connection_errors > 0, (
260
- f"Server should stop accepting requests. "
261
- f"completed: {state.requests_after_sigterm}, errors: {state.errors}"
262
- )
263
-
264
- await _assert_children_cleaned_up(child_pids)
265
-
266
-
267
-@pytest.mark.asyncio
268
-@pytest.mark.parametrize("wait_for_engine_idle", [0.0, 2.0])
269
-async def test_abort_timeout_exits_quickly(wait_for_engine_idle: float):
270
- server_args = [
271
- "--dtype",
272
- "bfloat16",
273
- "--max-model-len",
274
- "256",
275
- "--enforce-eager",
276
- "--gpu-memory-utilization",
277
- "0.05",
278
- "--max-num-seqs",
279
- "4",
280
- "--shutdown-timeout",
281
- "0",
282
- ]
283
-
284
- with RemoteOpenAIServer(MODEL_NAME, server_args) as remote_server:
285
- proc = remote_server.proc
286
- child_pids = _get_child_pids(proc.pid)
287
-
288
- if wait_for_engine_idle > 0:
289
- client = remote_server.get_async_client()
290
- # Send requests to ensure engine is fully initialized
291
- for _ in range(2):
292
- await client.completions.create(
293
- model=MODEL_NAME,
294
- prompt="Test request: ",
295
- max_tokens=10,
296
- )
297
- # Wait for engine to become idle
298
- await asyncio.sleep(wait_for_engine_idle)
299
-
300
- start_time = time.time()
301
- proc.send_signal(signal.SIGTERM)
302
-
303
- # abort timeout (0) should exit promptly
304
- for _ in range(20):
305
- if proc.poll() is not None:
306
- break
307
- time.sleep(0.1)
308
-
309
- if proc.poll() is None:
310
- proc.kill()
311
- proc.wait(timeout=5)
312
- pytest.fail("Process did not exit after SIGTERM with abort timeout")
313
-
314
- exit_time = time.time() - start_time
315
- assert exit_time < 2, f"Default shutdown took too long: {exit_time:.1f}s"
316
- assert proc.returncode in (0, -15, None), f"Unexpected: {proc.returncode}"
317
-
318
- await _assert_children_cleaned_up(child_pids)
319
-
320
-
321
-@pytest.mark.asyncio
322
-async def test_wait_timeout_with_short_duration():
323
- """Verify server exits cleanly with a short wait timeout."""
324
- wait_timeout = 3
325
- server_args = [
326
- "--dtype",
327
- "bfloat16",
328
- "--max-model-len",
329
- "256",
330
- "--enforce-eager",
331
- "--gpu-memory-utilization",
332
- "0.05",
333
- "--max-num-seqs",
334
- "4",
335
- "--shutdown-timeout",
336
- str(wait_timeout),
337
- ]
338
-
339
- with RemoteOpenAIServer(MODEL_NAME, server_args) as remote_server:
340
- client = remote_server.get_async_client()
341
- proc = remote_server.proc
342
- child_pids = _get_child_pids(proc.pid)
343
-
344
- state = ShutdownState()
345
- request_task = asyncio.create_task(
346
- _concurrent_request_loop(client, state, concurrency=3)
347
- )
348
-
349
- await asyncio.sleep(0.5)
350
-
351
- start_time = time.time()
352
- proc.send_signal(signal.SIGTERM)
353
-
354
- # server should exit within wait_timeout + buffer
355
- max_wait = wait_timeout + 15
356
- for _ in range(int(max_wait * 10)):
357
- if proc.poll() is not None:
358
- break
359
- time.sleep(0.1)
360
-
361
- exit_time = time.time() - start_time
362
-
363
- state.stop_requesting = True
364
- if not request_task.done():
365
- request_task.cancel()
366
- await asyncio.gather(request_task, return_exceptions=True)
367
-
368
- if proc.poll() is None:
369
- proc.kill()
370
- proc.wait(timeout=5)
371
- pytest.fail(f"Process did not exit within {max_wait}s after SIGTERM")
372
-
373
- assert exit_time < wait_timeout + 10, (
374
- f"Took too long to exit ({exit_time:.1f}s), expected <{wait_timeout + 10}s"
375
- )
376
- assert proc.returncode in (0, -15, None), f"Unexpected: {proc.returncode}"
377
-
378
- await _assert_children_cleaned_up(child_pids)
379
-
380
-
381
-@pytest.mark.asyncio
382
-async def test_abort_timeout_fails_inflight_requests():
383
- """Verify abort timeout (0) immediately aborts in-flight requests."""
384
- server_args = [
385
- "--dtype",
386
- "bfloat16",
387
- "--max-model-len",
388
- "256",
389
- "--enforce-eager",
390
- "--gpu-memory-utilization",
391
- "0.05",
392
- "--max-num-seqs",
393
- "4",
394
- "--shutdown-timeout",
395
- "0",
396
- ]
397
-
398
- with RemoteOpenAIServer(MODEL_NAME, server_args) as remote_server:
399
- client = remote_server.get_async_client()
400
- proc = remote_server.proc
401
- child_pids = _get_child_pids(proc.pid)
402
-
403
- state = ShutdownState()
404
- sigterm_sent = asyncio.Event()
405
-
406
- request_task = asyncio.create_task(
407
- _concurrent_request_loop(client, state, sigterm_sent, concurrency=10)
408
- )
409
-
410
- await asyncio.sleep(0.5)
411
-
412
- proc.send_signal(signal.SIGTERM)
413
- sigterm_sent.set()
414
-
415
- try:
416
- await asyncio.wait_for(request_task, timeout=5)
417
- except asyncio.TimeoutError:
418
- pass
419
- finally:
420
- state.stop_requesting = True
421
- if not request_task.done():
422
- request_task.cancel()
423
- await asyncio.gather(request_task, return_exceptions=True)
424
-
425
- # With abort timeout (0), requests should be aborted (finish_reason='abort')
426
- # or rejected (connection errors or API errors)
427
- assert (
428
- state.aborted_requests > 0
429
- or state.connection_errors > 0
430
- or state.got_500
431
- or state.got_503
432
- ), (
433
- f"Abort timeout should cause request aborts or failures. "
434
- f"aborted: {state.aborted_requests}, "
435
- f"503: {state.got_503}, 500: {state.got_500}, "
436
- f"conn_errors: {state.connection_errors}, "
437
- f"completed: {state.requests_after_sigterm}"
438
- )
439
-
440
- # Verify fast shutdown
441
- start_time = time.time()
442
- for _ in range(100):
443
- if proc.poll() is not None:
444
- break
445
- time.sleep(0.1)
446
-
447
- exit_time = time.time() - start_time
448
- assert exit_time < 10, f"Abort timeout shutdown took too long: {exit_time:.1f}s"
449
-
450
- await _assert_children_cleaned_up(child_pids)
451
-
452
-
453
-@pytest.mark.asyncio
454
-async def test_request_rejection_during_shutdown():
455
- """Verify new requests are rejected with error during shutdown."""
456
- server_args = [
457
- "--dtype",
458
- "bfloat16",
459
- "--max-model-len",
460
- "256",
461
- "--enforce-eager",
462
- "--gpu-memory-utilization",
463
- "0.05",
464
- "--max-num-seqs",
465
- "4",
466
- "--shutdown-timeout",
467
- "30",
468
- ]
469
-
470
- with RemoteOpenAIServer(MODEL_NAME, server_args) as remote_server:
471
- client = remote_server.get_async_client()
472
- proc = remote_server.proc
473
- child_pids = _get_child_pids(proc.pid)
474
-
475
- proc.send_signal(signal.SIGTERM)
476
-
477
- await asyncio.sleep(1.0)
478
-
479
- # Try to send new requests - they should be rejected
480
- rejected_count = 0
481
- for _ in range(10):
482
- try:
483
- await client.completions.create(
484
- model=MODEL_NAME, prompt="Hello", max_tokens=10
485
- )
486
- except (
487
- openai.APIStatusError,
488
- openai.APIConnectionError,
489
- httpx.RemoteProtocolError,
490
- ):
491
- rejected_count += 1
492
- await asyncio.sleep(0.1)
493
-
494
- assert rejected_count > 0, (
495
- f"Expected requests to be rejected during shutdown, "
496
- f"but {rejected_count} were rejected out of 10"
497
- )
498
-
499
- await _assert_children_cleaned_up(child_pids)
500
-
501
-
502
-@pytest.mark.asyncio
503
-async def test_multi_api_server_shutdown():
504
- """Verify shutdown works with multiple API servers."""
505
- server_args = [
506
- "--dtype",
507
- "bfloat16",
508
- "--max-model-len",
509
- "256",
510
- "--enforce-eager",
511
- "--gpu-memory-utilization",
512
- "0.05",
513
- "--max-num-seqs",
514
- "4",
515
- "--shutdown-timeout",
516
- "30",
517
- "--api-server-count",
518
- "2",
519
- ]
520
-
521
- with RemoteOpenAIServer(MODEL_NAME, server_args, auto_port=True) as remote_server:
522
- client = remote_server.get_async_client()
523
- proc = remote_server.proc
524
- child_pids = _get_child_pids(proc.pid)
525
-
526
- assert len(child_pids) >= 2, (
527
- f"Expected at least 2 child processes, got {len(child_pids)}"
528
- )
529
-
530
- state = ShutdownState()
531
- sigterm_sent = asyncio.Event()
532
-
533
- # Start concurrent requests across both API servers
534
- request_task = asyncio.create_task(
535
- _concurrent_request_loop(client, state, sigterm_sent, concurrency=8)
536
- )
537
-
538
- await asyncio.sleep(0.5)
539
-
540
- # Send SIGTERM to parent - should propagate to all children
541
- proc.send_signal(signal.SIGTERM)
542
- sigterm_sent.set()
543
-
544
- try:
545
- await asyncio.wait_for(request_task, timeout=_SHUTDOWN_DETECTION_TIMEOUT)
546
- except asyncio.TimeoutError:
547
- pass
548
- finally:
549
- state.stop_requesting = True
550
- if not request_task.done():
551
- request_task.cancel()
552
- await asyncio.gather(request_task, return_exceptions=True)
553
-
554
- for _ in range(300): # up to 30 seconds
555
- if proc.poll() is not None:
556
- break
557
- time.sleep(0.1)
558
-
559
- if proc.poll() is None:
560
- proc.kill()
561
- proc.wait(timeout=5)
562
- pytest.fail("Process did not exit after SIGTERM")
563
-
564
- await _assert_children_cleaned_up(child_pids)
tests/entrypoints/test_api_server_process_manager.py
+7
-15
@@ -79,7 +79,7 @@ def test_api_server_process_manager_init(api_server_args, with_stats_update):
79
finally:
80
# Always clean up the processes
81
print("Cleaning up processes...")
82
- manager.shutdown()
82
+ manager.close()
83
84
# Give processes time to terminate
85
time.sleep(0.2)
@@ -111,8 +111,6 @@ def test_wait_for_completion_or_failure(api_server_args):
111
wait_for_completion_or_failure(api_server_manager=manager)
112
except Exception as e:
113
result["exception"] = e
114
- finally:
115
- manager.shutdown()
114
115
# Start a thread to run wait_for_completion_or_failure
116
wait_thread = threading.Thread(target=run_with_exception_capture, daemon=True)
@@ -145,7 +143,7 @@ def test_wait_for_completion_or_failure(api_server_args):
143
assert not proc.is_alive(), f"Process {i} should not be alive"
144
145
finally:
148
- manager.shutdown()
146
+ manager.close()
147
time.sleep(0.2)
148
149
@@ -176,14 +174,11 @@ def test_normal_completion(api_server_args):
174
# since all processes have already
175
# terminated, it should return immediately
176
# with no error
179
- try:
180
- wait_for_completion_or_failure(api_server_manager=manager)
181
- finally:
182
- manager.shutdown()
177
+ wait_for_completion_or_failure(api_server_manager=manager)
178
179
finally:
180
# Clean up just in case
186
- manager.shutdown()
181
+ manager.close()
182
time.sleep(0.2)
183
184
@@ -206,7 +201,7 @@ def test_external_process_monitoring(api_server_args):
201
def __init__(self, proc):
202
self.proc = proc
203
209
- def shutdown(self):
204
+ def close(self):
205
if self.proc.is_alive():
206
self.proc.terminate()
207
self.proc.join(timeout=0.5)
@@ -231,9 +226,6 @@ def test_external_process_monitoring(api_server_args):
226
)
227
except Exception as e:
228
result["exception"] = e
234
- finally:
235
- manager.shutdown()
236
- mock_coordinator.shutdown()
229
230
# Start a thread to run wait_for_completion_or_failure
231
wait_thread = threading.Thread(target=run_with_exception_capture, daemon=True)
@@ -267,6 +259,6 @@ def test_external_process_monitoring(api_server_args):
259
260
finally:
261
# Clean up
270
- manager.shutdown()
271
- mock_coordinator.shutdown()
262
+ manager.close()
263
+ mock_coordinator.close()
264
time.sleep(0.2)
vllm/config/vllm.py
-6
@@ -327,12 +327,6 @@ class VllmConfig:
327
weight_transfer_config: WeightTransferConfig | None = None
328
"""The configurations for weight transfer during RL training."""
329
330
- shutdown_timeout: int = Field(default=0, ge=0)
331
- """Shutdown grace period for in-flight requests. Shutdown will be delayed for
332
- up to this amount of time to allow already-running requests to complete. Any
333
- remaining requests are aborted once the timeout is reached.
334
- """
335
-
330
def compute_hash(self) -> str:
331
"""
332
WARNING: Whenever a new field is added to this config,
vllm/engine/arg_utils.py
-11
@@ -606,8 +606,6 @@ class EngineArgs:
606
kv_offloading_backend: KVOffloadingBackend = CacheConfig.kv_offloading_backend
607
tokens_only: bool = False
608
609
- shutdown_timeout: int = 0
610
-
609
weight_transfer_config: WeightTransferConfig | None = get_field(
610
VllmConfig,
611
"weight_transfer_config",
@@ -1310,14 +1308,6 @@ class EngineArgs:
1308
default=False,
1309
action=argparse.BooleanOptionalAction,
1310
)
1313
-
1314
- parser.add_argument(
1315
- "--shutdown-timeout",
1316
- type=int,
1317
- default=0,
1318
- help="Shutdown timeout in seconds. 0 = abort, >0 = wait.",
1319
- )
1320
-
1311
return parser
1312
1313
@classmethod
@@ -1926,7 +1916,6 @@ class EngineArgs:
1916
optimization_level=self.optimization_level,
1917
performance_mode=self.performance_mode,
1918
weight_transfer_config=self.weight_transfer_config,
1929
- shutdown_timeout=self.shutdown_timeout,
1919
)
1920
1921
return config
vllm/engine/protocol.py
-5
@@ -200,11 +200,6 @@ class EngineClient(ABC):
200
"""Return whether the engine is currently paused."""
201
...
202
203
- @abstractmethod
204
- def shutdown(self, timeout: float | None = None) -> None:
205
- """Shutdown the engine with optional timeout."""
206
- ...
207
-
203
async def scale_elastic_ep(
204
self, new_data_parallel_size: int, drain_timeout: int = 300
205
) -> None:
vllm/entrypoints/cli/serve.py
+6
-42
@@ -3,7 +3,6 @@
3
4
import argparse
5
import signal
6
-import time
6
7
import uvloop
8
@@ -225,12 +224,8 @@ def run_headless(args: argparse.Namespace):
224
try:
225
engine_manager.join_first()
226
finally:
228
- timeout = None
229
- if shutdown_requested:
230
- timeout = vllm_config.shutdown_timeout
231
- logger.info("Waiting up to %d seconds for processes to exit", timeout)
232
- engine_manager.shutdown(timeout=timeout)
227
logger.info("Shutting down.")
228
+ engine_manager.close()
229
230
231
def run_multi_api_server(args: argparse.Namespace):
@@ -241,19 +236,6 @@ def run_multi_api_server(args: argparse.Namespace):
236
if num_api_servers > 1:
237
setup_multiprocess_prometheus()
238
244
- shutdown_requested = False
245
-
246
- # Catch SIGTERM and SIGINT to allow graceful shutdown.
247
- def signal_handler(signum, frame):
248
- nonlocal shutdown_requested
249
- logger.debug("Received %d signal.", signum)
250
- if not shutdown_requested:
251
- shutdown_requested = True
252
- raise SystemExit
253
-
254
- signal.signal(signal.SIGTERM, signal_handler)
255
- signal.signal(signal.SIGINT, signal_handler)
256
-
239
listen_address, sock = setup_server(args)
240
241
engine_args = vllm.AsyncEngineArgs.from_cli_args(args)
@@ -315,29 +297,11 @@ def run_multi_api_server(args: argparse.Namespace):
297
api_server_manager = APIServerProcessManager(**api_server_manager_kwargs)
298
299
# Wait for API servers
318
- try:
319
- wait_for_completion_or_failure(
320
- api_server_manager=api_server_manager,
321
- engine_manager=local_engine_manager,
322
- coordinator=coordinator,
323
- )
324
- finally:
325
- timeout = shutdown_by = None
326
- if shutdown_requested:
327
- timeout = vllm_config.shutdown_timeout
328
- shutdown_by = time.monotonic() + timeout
329
- logger.info("Waiting up to %d seconds for processes to exit", timeout)
330
-
331
- def to_timeout(deadline: float | None) -> float | None:
332
- return (
333
- deadline if deadline is None else max(deadline - time.monotonic(), 0.0)
334
- )
335
-
336
- api_server_manager.shutdown(timeout=timeout)
337
- if local_engine_manager:
338
- local_engine_manager.shutdown(timeout=to_timeout(shutdown_by))
339
- if coordinator:
340
- coordinator.shutdown(timeout=to_timeout(shutdown_by))
300
+ wait_for_completion_or_failure(
301
+ api_server_manager=api_server_manager,
302
+ engine_manager=local_engine_manager,
303
+ coordinator=coordinator,
304
+ )
305
306
307
def run_api_server_worker_proc(
vllm/entrypoints/launcher.py
+5
-23
@@ -4,7 +4,6 @@
4
import asyncio
5
import signal
6
import socket
7
-from functools import partial
7
from typing import Any
8
9
import uvicorn
@@ -92,10 +91,12 @@ async def serve_http(
91
)
92
)
93
95
- shutdown_event = asyncio.Event()
96
-
94
def signal_handler() -> None:
98
- shutdown_event.set()
95
+ # prevents the uvicorn signal handler to exit early
96
+ server_task.cancel()
97
+ watchdog_task.cancel()
98
+ if ssl_cert_refresher:
99
+ ssl_cert_refresher.stop()
100
101
async def dummy_shutdown() -> None:
102
pass
@@ -103,24 +104,6 @@ async def serve_http(
104
loop.add_signal_handler(signal.SIGINT, signal_handler)
105
loop.add_signal_handler(signal.SIGTERM, signal_handler)
106
106
- async def handle_shutdown() -> None:
107
- await shutdown_event.wait()
108
-
109
- engine_client = app.state.engine_client
110
- timeout = engine_client.vllm_config.shutdown_timeout
111
-
112
- await loop.run_in_executor(
113
- None, partial(engine_client.shutdown, timeout=timeout)
114
- )
115
-
116
- server.should_exit = True
117
- server_task.cancel()
118
- watchdog_task.cancel()
119
- if ssl_cert_refresher:
120
- ssl_cert_refresher.stop()
121
-
122
- shutdown_task = loop.create_task(handle_shutdown())
123
-
107
try:
108
await server_task
109
return dummy_shutdown()
@@ -137,7 +120,6 @@ async def serve_http(
120
logger.info("Shutting down FastAPI HTTP server.")
121
return server.shutdown()
122
finally:
140
- shutdown_task.cancel()
123
watchdog_task.cancel()
124
125
vllm/v1/engine/__init__.py
-2
@@ -226,8 +226,6 @@ class EngineCoreRequestType(enum.Enum):
226
UTILITY = b"\x03"
227
# Sentinel used within EngineCoreProc.
228
EXECUTOR_FAILED = b"\x04"
229
- # Sentinel to wake up input_queue.get() during shutdown.
230
- WAKEUP = b"\x05"
229
230
231
class ReconfigureDistributedRequest(msgspec.Struct):
vllm/v1/engine/async_llm.py
+3
-2
@@ -264,15 +264,16 @@ class AsyncLLM(EngineClient):
264
def __del__(self):
265
self.shutdown()
266
267
- def shutdown(self, timeout: float | None = None) -> None:
267
+ def shutdown(self):
268
"""Shutdown, cleaning up the background proc and IPC."""
269
+
270
shutdown_prometheus()
271
272
if renderer := getattr(self, "renderer", None):
273
renderer.shutdown()
274
275
if engine_core := getattr(self, "engine_core", None):
275
- engine_core.shutdown(timeout=timeout)
276
+ engine_core.shutdown()
277
278
handler = getattr(self, "output_handler", None)
279
if handler is not None:
vllm/v1/engine/coordinator.py
+2
-4
@@ -104,10 +104,8 @@ class DPCoordinator:
104
"""Returns tuple of ZMQ input address, output address."""
105
return self.coord_in_address, self.coord_out_address
106
107
- def shutdown(self, timeout: float | None = None) -> None:
108
- """Shutdown coordinator process with configurable timeout."""
109
- if self._finalizer.detach() is not None:
110
- shutdown([self.proc], timeout=timeout)
107
+ def close(self):
108
+ self._finalizer()
109
110
111
class EngineState:
vllm/v1/engine/core.py
+43
-127
@@ -9,7 +9,6 @@ from collections import defaultdict, deque
9
from collections.abc import Callable, Generator
10
from concurrent.futures import Future
11
from contextlib import ExitStack, contextmanager
12
-from enum import IntEnum
12
from functools import partial
13
from inspect import isclass, signature
14
from logging import DEBUG
@@ -62,7 +61,6 @@ from vllm.v1.engine import (
61
from vllm.v1.engine.utils import (
62
EngineHandshakeMetadata,
63
EngineZmqAddresses,
65
- SignalCallback,
64
get_device_indices,
65
)
66
from vllm.v1.executor import Executor
@@ -773,12 +771,6 @@ class EngineCore:
771
raise NotImplementedError
772
773
776
-class EngineShutdownState(IntEnum):
777
- RUNNING = 0
778
- REQUESTED = 1
779
- SHUTTING_DOWN = 2
780
-
781
-
774
class EngineCoreProc(EngineCore):
775
"""ZMQ-wrapper for running EngineCore in background process."""
776
@@ -806,7 +798,6 @@ class EngineCoreProc(EngineCore):
798
self.engine_index = engine_index
799
identity = self.engine_index.to_bytes(length=2, byteorder="little")
800
self.engines_running = False
809
- self.shutdown_state = EngineShutdownState.RUNNING
801
802
with self._perform_handshakes(
803
handshake_address,
@@ -1037,11 +1028,25 @@ class EngineCoreProc(EngineCore):
1028
def run_engine_core(*args, dp_rank: int = 0, local_dp_rank: int = 0, **kwargs):
1029
"""Launch EngineCore busy loop in background process."""
1030
1031
+ # Signal handler used for graceful termination.
1032
+ # SystemExit exception is only raised once to allow this and worker
1033
+ # processes to terminate without error
1034
+ shutdown_requested = False
1035
+
1036
# Ensure we can serialize transformer config after spawning
1037
maybe_register_config_serialize_by_value()
1038
1039
+ def signal_handler(signum, frame):
1040
+ nonlocal shutdown_requested
1041
+ if not shutdown_requested:
1042
+ shutdown_requested = True
1043
+ raise SystemExit()
1044
+
1045
+ # Either SIGTERM or SIGINT will terminate the engine_core
1046
+ signal.signal(signal.SIGTERM, signal_handler)
1047
+ signal.signal(signal.SIGINT, signal_handler)
1048
+
1049
engine_core: EngineCoreProc | None = None
1044
- signal_callback: SignalCallback | None = None
1050
try:
1051
vllm_config: VllmConfig = kwargs["vllm_config"]
1052
parallel_config: ParallelConfig = vllm_config.parallel_config
@@ -1089,22 +1094,6 @@ class EngineCoreProc(EngineCore):
1094
engine_core = EngineCoreProc(*args, engine_index=dp_rank, **kwargs)
1095
1096
assert engine_core is not None
1092
-
1093
- def wakeup_engine():
1094
- # Wakes up idle engine via input_queue when shutdown is requested
1095
- # Not safe in a signal handler - we may interrupt the main thread
1096
- # while it is holding the non-reentrant input_queue.mutex
1097
- engine_core.input_queue.put_nowait((EngineCoreRequestType.WAKEUP, None))
1098
-
1099
- signal_callback = SignalCallback(wakeup_engine)
1100
-
1101
- def signal_handler(signum, frame):
1102
- engine_core.shutdown_state = EngineShutdownState.REQUESTED
1103
- signal_callback.trigger()
1104
-
1105
- signal.signal(signal.SIGTERM, signal_handler)
1106
- signal.signal(signal.SIGINT, signal_handler)
1107
-
1097
engine_core.run_busy_loop()
1098
1099
except SystemExit:
@@ -1118,10 +1107,6 @@ class EngineCoreProc(EngineCore):
1107
engine_core._send_engine_dead()
1108
raise e
1109
finally:
1121
- signal.signal(signal.SIGTERM, signal.SIG_DFL)
1122
- signal.signal(signal.SIGINT, signal.SIG_DFL)
1123
- if signal_callback is not None:
1124
- signal_callback.stop()
1110
if engine_core is not None:
1111
engine_core.shutdown()
1112
@@ -1136,25 +1121,21 @@ class EngineCoreProc(EngineCore):
1121
or bool(self.batch_queue)
1122
)
1123
1139
- def is_running(self) -> bool:
1140
- """Returns true if shutdown has not been requested."""
1141
- return self.shutdown_state == EngineShutdownState.RUNNING
1142
-
1124
def run_busy_loop(self):
1125
"""Core busy loop of the EngineCore."""
1145
- while self._handle_shutdown():
1126
+
1127
+ # Loop until process is sent a SIGINT or SIGTERM
1128
+ while True:
1129
# 1) Poll the input queue until there is work to do.
1130
self._process_input_queue()
1131
# 2) Step the engine core and return the outputs.
1132
self._process_engine_step()
1133
1151
- raise SystemExit
1152
-
1134
def _process_input_queue(self):
1135
"""Exits when an engine step needs to be performed."""
1136
1137
waited = False
1157
- while not self.has_work() and self.is_running():
1138
+ while not self.has_work():
1139
# Notify callbacks waiting for engine to become idle.
1140
self._notify_idle_state_callbacks()
1141
if self.input_queue.empty():
@@ -1206,60 +1187,18 @@ class EngineCoreProc(EngineCore):
1187
callback = self._idle_state_callbacks.pop()
1188
callback(self)
1189
1209
- def _handle_shutdown(self) -> bool:
1210
- # Check if shutdown was requested and handle it
1211
- if self.shutdown_state == EngineShutdownState.RUNNING:
1212
- return True
1213
-
1214
- if self.shutdown_state == EngineShutdownState.REQUESTED:
1215
- shutdown_timeout = self.vllm_config.shutdown_timeout
1216
-
1217
- logger.info("Shutdown initiated (timeout=%d)", shutdown_timeout)
1218
-
1219
- if shutdown_timeout == 0:
1220
- num_requests = self.scheduler.get_num_unfinished_requests()
1221
- if num_requests > 0:
1222
- logger.info("Aborting %d requests", num_requests)
1223
- aborted_reqs = self.scheduler.finish_requests(
1224
- None, RequestStatus.FINISHED_ABORTED
1225
- )
1226
- self._send_abort_outputs(aborted_reqs)
1227
- else:
1228
- num_requests = self.scheduler.get_num_unfinished_requests()
1229
- if num_requests > 0:
1230
- logger.info(
1231
- "Draining %d in-flight requests (timeout=%ds)",
1232
- num_requests,
1233
- shutdown_timeout,
1234
- )
1235
-
1236
- self.shutdown_state = EngineShutdownState.SHUTTING_DOWN
1237
-
1238
- # Exit when no work remaining
1239
- if not self.has_work():
1240
- logger.info("Shutdown complete")
1241
- return False
1242
-
1243
- return True
1244
-
1190
def _handle_client_request(
1191
self, request_type: EngineCoreRequestType, request: Any
1192
) -> None:
1193
"""Dispatch request from client."""
1194
1250
- if request_type == EngineCoreRequestType.WAKEUP:
1251
- return
1252
- elif request_type == EngineCoreRequestType.ADD:
1195
+ if request_type == EngineCoreRequestType.ADD:
1196
req, request_wave = request
1254
- if self._reject_add_in_shutdown(req):
1255
- return
1197
self.add_request(req, request_wave)
1198
elif request_type == EngineCoreRequestType.ABORT:
1199
self.abort_requests(request)
1200
elif request_type == EngineCoreRequestType.UTILITY:
1201
client_idx, call_id, method_name, args = request
1261
- if self._reject_utility_in_shutdown(client_idx, call_id, method_name):
1262
- return
1202
output = UtilityOutput(call_id)
1203
# Lazily look-up utility method so that failure will be handled/returned.
1204
get_result = lambda: (method := getattr(self, method_name)) and method(
@@ -1276,27 +1215,6 @@ class EngineCoreProc(EngineCore):
1215
"Unrecognized input request type encountered: %s", request_type
1216
)
1217
1279
- def _reject_add_in_shutdown(self, request: Request) -> bool:
1280
- if self.shutdown_state == EngineShutdownState.RUNNING:
1281
- return False
1282
-
1283
- logger.info("Rejecting request %s (server shutting down)", request.request_id)
1284
- self._send_abort_outputs_to_client([request.request_id], request.client_index)
1285
- return True
1286
-
1287
- def _reject_utility_in_shutdown(
1288
- self, client_idx: int, call_id: int, method_name: str
1289
- ) -> bool:
1290
- if self.shutdown_state == EngineShutdownState.RUNNING:
1291
- return False
1292
-
1293
- logger.warning("Rejecting utility call %s (server shutting down)", method_name)
1294
- output = UtilityOutput(call_id, failure_message="Server shutting down")
1295
- self.output_queue.put_nowait(
1296
- (client_idx, EngineCoreOutputs(utility_output=output))
1297
- )
1298
- return True
1299
-
1218
@staticmethod
1219
def _invoke_utility_method(
1220
name: str, get_result: Callable, output: UtilityOutput, enqueue_output: Callable
@@ -1510,7 +1428,22 @@ class EngineCoreProc(EngineCore):
1428
logger.exception(
1429
"Unexpected error pre-processing request %s", request.request_id
1430
)
1513
- self._send_error_outputs_to_client([request.request_id], request.client_index)
1431
+ self.output_queue.put_nowait(
1432
+ (
1433
+ request.client_index,
1434
+ EngineCoreOutputs(
1435
+ engine_index=self.engine_index,
1436
+ finished_requests={request.request_id},
1437
+ outputs=[
1438
+ EngineCoreOutput(
1439
+ request_id=request.request_id,
1440
+ new_token_ids=[],
1441
+ finish_reason=FinishReason.ERROR,
1442
+ )
1443
+ ],
1444
+ ),
1445
+ )
1446
+ )
1447
1448
def pause_scheduler(
1449
self, mode: PauseMode = "abort", clear_cache: bool = True
@@ -1553,26 +1486,6 @@ class EngineCoreProc(EngineCore):
1486
self._idle_state_callbacks.append(partial(engine_idle_callback, future=future))
1487
return future
1488
1556
- def _send_finish_outputs_to_client(
1557
- self, req_ids: list[str], client_index: int, finish_reason: FinishReason
1558
- ) -> None:
1559
- outputs = [
1560
- EngineCoreOutput(req_id, [], finish_reason=finish_reason)
1561
- for req_id in req_ids
1562
- ]
1563
- eco = EngineCoreOutputs(finished_requests=req_ids, outputs=outputs)
1564
- self.output_queue.put_nowait((client_index, eco))
1565
-
1566
- def _send_abort_outputs_to_client(
1567
- self, req_ids: list[str], client_index: int
1568
- ) -> None:
1569
- self._send_finish_outputs_to_client(req_ids, client_index, FinishReason.ABORT)
1570
-
1571
- def _send_error_outputs_to_client(
1572
- self, req_ids: list[str], client_index: int
1573
- ) -> None:
1574
- self._send_finish_outputs_to_client(req_ids, client_index, FinishReason.ERROR)
1575
-
1489
def _send_abort_outputs(self, aborted_reqs: list[tuple[str, int]]) -> None:
1490
# TODO(nick) this will be moved inside the scheduler
1491
if aborted_reqs:
@@ -1581,7 +1494,12 @@ class EngineCoreProc(EngineCore):
1494
for req_id, client_index in aborted_reqs:
1495
by_client[client_index].add(req_id)
1496
for client_index, req_ids in by_client.items():
1584
- self._send_abort_outputs_to_client(list(req_ids), client_index)
1497
+ outputs = [
1498
+ EngineCoreOutput(req_id, [], finish_reason=FinishReason.ABORT)
1499
+ for req_id in req_ids
1500
+ ]
1501
+ eco = EngineCoreOutputs(finished_requests=req_ids, outputs=outputs)
1502
+ self.output_queue.put_nowait((client_index, eco))
1503
1504
1505
class DPEngineCoreProc(EngineCoreProc):
@@ -1699,7 +1617,7 @@ class DPEngineCoreProc(EngineCoreProc):
1617
"""Core busy loop of the EngineCore for data parallel case."""
1618
1619
# Loop until process is sent a SIGINT or SIGTERM
1702
- while self._handle_shutdown():
1620
+ while True:
1621
# 1) Poll the input queue until there is work to do.
1622
self._process_input_queue()
1623
@@ -1747,8 +1665,6 @@ class DPEngineCoreProc(EngineCoreProc):
1665
self.current_wave += 1
1666
self.step_counter = 0
1667
1750
- raise SystemExit
1751
-
1668
def _has_global_unfinished_reqs(self, local_unfinished: bool) -> bool:
1669
# Optimization - only perform finish-sync all-reduce every 32 steps.
1670
self.step_counter += 1
vllm/v1/engine/core_client.py
+12
-12
@@ -128,7 +128,7 @@ class EngineCoreClient(ABC):
128
return AsyncMPClient(*client_args)
129
130
@abstractmethod
131
- def shutdown(self, timeout: float | None = None) -> None: ...
131
+ def shutdown(self): ...
132
133
def get_output(self) -> EngineCoreOutputs:
134
raise NotImplementedError
@@ -298,7 +298,7 @@ class InprocClient(EngineCoreClient):
298
if len(request_ids) > 0:
299
self.engine_core.abort_requests(request_ids)
300
301
- def shutdown(self, timeout: float | None = None) -> None:
301
+ def shutdown(self) -> None:
302
self.engine_core.shutdown()
303
304
def profile(self, is_start: bool = True, profile_prefix: str | None = None) -> None:
@@ -390,9 +390,9 @@ class BackgroundResources:
390
391
self.engine_dead = True
392
if self.engine_manager is not None:
393
- self.engine_manager.shutdown()
393
+ self.engine_manager.close()
394
if self.coordinator is not None:
395
- self.coordinator.shutdown()
395
+ self.coordinator.close()
396
397
if isinstance(self.output_socket, zmq.asyncio.Socket):
398
# Async case.
@@ -568,7 +568,10 @@ class MPClient(EngineCoreClient):
568
)
569
570
with launch_core_engines(
571
- vllm_config, executor_class, log_stats, addresses
571
+ vllm_config,
572
+ executor_class,
573
+ log_stats,
574
+ addresses,
575
) as (engine_manager, coordinator, addresses):
576
self.resources.coordinator = coordinator
577
self.resources.engine_manager = engine_manager
@@ -634,12 +637,9 @@ class MPClient(EngineCoreClient):
637
if not success:
638
self._finalizer()
639
637
- def shutdown(self, timeout: float | None = None) -> None:
638
- """Shutdown engine manager under timeout and clean up resources."""
639
- if self._finalizer.detach() is not None:
640
- if self.resources.engine_manager is not None:
641
- self.resources.engine_manager.shutdown(timeout=timeout)
642
- self.resources()
640
+ def shutdown(self):
641
+ # Terminate background resources.
642
+ self._finalizer()
643
644
def _format_exception(self, e: Exception) -> Exception:
645
"""If errored, use EngineDeadError so root cause is clear."""
@@ -683,7 +683,7 @@ class MPClient(EngineCoreClient):
683
sentinels = [proc.sentinel for proc in engine_processes]
684
died = multiprocessing.connection.wait(sentinels)
685
_self = self_ref()
686
- if not _self or not _self._finalizer.alive or _self.resources.engine_dead:
686
+ if not _self or _self.resources.engine_dead:
687
return
688
_self.resources.engine_dead = True
689
proc_name = next(
vllm/v1/engine/utils.py
+5
-34
@@ -3,7 +3,6 @@
3
4
import contextlib
5
import os
6
-import threading
6
import weakref
7
from collections.abc import Callable, Iterator
8
from dataclasses import dataclass
@@ -152,12 +151,11 @@ class CoreEngineProcManager:
151
finally:
152
# Kill other procs if not all are running.
153
if self.finished_procs():
155
- self.shutdown()
154
+ self.close()
155
157
- def shutdown(self, timeout: float | None = None) -> None:
158
- """Shutdown engine core processes with configurable timeout."""
159
- if self._finalizer.detach() is not None:
160
- shutdown(self.processes, timeout=timeout)
156
+ def close(self):
157
+ """Shutdown all procs."""
158
+ self._finalizer()
159
160
def join_first(self):
161
"""Wait for any process to exit."""
@@ -175,33 +173,6 @@ class CoreEngineProcManager:
173
}
174
175
178
-class SignalCallback:
179
- """Safely trigger a callback from signal handler context via a dedicated thread."""
180
-
181
- def __init__(self, callback: Callable[[], None]):
182
- self._callback = callback
183
- self._event = threading.Event()
184
- self._stopped = False
185
- self._thread = threading.Thread(
186
- target=self._run,
187
- daemon=True,
188
- name="signal-callback",
189
- )
190
- self._thread.start()
191
-
192
- def _run(self):
193
- self._event.wait()
194
- if not self._stopped:
195
- self._callback()
196
-
197
- def trigger(self):
198
- self._event.set()
199
-
200
- def stop(self):
201
- self._stopped = True
202
- self._event.set()
203
-
204
-
176
@contextlib.contextmanager
177
def set_device_control_env_var(
178
vllm_config: VllmConfig, local_dp_rank: int
@@ -797,7 +768,7 @@ class CoreEngineActorManager:
768
def get_run_refs(self):
769
return self.run_refs
770
800
- def shutdown(self, timeout: float | None = None) -> None:
771
+ def close(self):
772
import ray
773
774
for actor in self.local_engine_actors + self.remote_engine_actors:
vllm/v1/utils.py
+12
-19
@@ -220,10 +220,8 @@ class APIServerProcessManager:
220
# The extra processes are managed by their owners
221
self._finalizer = weakref.finalize(self, shutdown, self.processes)
222
223
- def shutdown(self, timeout: float | None = None) -> None:
224
- """Shutdown API server processes with configurable timeout"""
225
- if self._finalizer.detach() is not None:
226
- shutdown(self.processes, timeout=timeout)
223
+ def close(self) -> None:
224
+ self._finalizer()
225
226
227
def wait_for_completion_or_failure(
@@ -290,30 +288,25 @@ def wait_for_completion_or_failure(
288
except Exception as e:
289
logger.exception("Exception occurred while running API servers: %s", str(e))
290
raise
291
+ finally:
292
+ logger.info("Terminating remaining processes ...")
293
+ api_server_manager.close()
294
+ if coordinator:
295
+ coordinator.close()
296
+ if engine_manager:
297
+ engine_manager.close()
298
299
300
# Note(rob): shutdown function cannot be a bound method,
301
# else the gc cannot collect the object.
297
-def shutdown(procs: list[BaseProcess], timeout: float | None = None) -> None:
298
- """Shutdown processes with timeout.
299
-
300
- Args:
301
- procs: List of processes to shutdown
302
- timeout: Maximum time in seconds to wait for graceful shutdown
303
- """
304
- if timeout is None:
305
- timeout = 0.0
306
-
307
- # Allow at least 5 seconds for remaining procs to terminate.
308
- timeout = max(timeout, 5.0)
309
-
302
+def shutdown(procs: list[BaseProcess]):
303
# Shutdown the process.
304
for proc in procs:
305
if proc.is_alive():
306
proc.terminate()
307
315
- # Allow time for remaining procs to terminate.
316
- deadline = time.monotonic() + timeout
308
+ # Allow 5 seconds for remaining procs to terminate.
309
+ deadline = time.monotonic() + 5
310
for proc in procs:
311
remaining = deadline - time.monotonic()
312
if remaining <= 0: