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 14, 05:30 UTC
[CI] Fail subprocess tests with root-cause error (#23795)
Signed-off-by: Nick Hill <nhill@redhat.com>
Nick Hill committed
Sep 10, 2025 at 13:53 UTC
4db44264047c2d30d9ba4eac55acaef821ae18b8
6 files changed
+138
-33
requirements/test.in
+1
@@ -21,6 +21,7 @@ ray[cgraph,default]>=2.48.0 # Ray Compiled Graph, required by pipeline paralleli
21
sentence-transformers # required for embedding tests
22
soundfile # required for audio tests
23
jiwer # required for audio tests
24
+tblib # for pickling test exceptions
25
timm >=1.0.17 # required for internvl and gemma3n-mm test
26
torch==2.8.0
27
torchaudio==2.8.0
requirements/test.txt
+3
-1
@@ -137,7 +137,7 @@ contourpy==1.3.0
137
# via matplotlib
138
cramjam==2.9.0
139
# via fastparquet
140
-cupy-cuda12x==13.3.0
140
+cupy-cuda12x==13.6.0
141
# via ray
142
cycler==0.12.1
143
# via matplotlib
@@ -1032,6 +1032,8 @@ tabledata==1.3.3
1032
# via pytablewriter
1033
tabulate==0.9.0
1034
# via sacrebleu
1035
+tblib==3.1.0
1036
+ # via -r requirements/test.in
1037
tcolorpy==0.1.6
1038
# via pytablewriter
1039
tenacity==9.0.0
tests/async_engine/test_api_server.py
+26
@@ -1,6 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+import copyreg
5
import os
6
import subprocess
7
import sys
@@ -10,6 +11,30 @@ from pathlib import Path
11
12
import pytest
13
import requests
14
+import urllib3.exceptions
15
+
16
+
17
+def _pickle_new_connection_error(obj):
18
+ """Custom pickler for NewConnectionError to fix tblib compatibility."""
19
+ # Extract the original message by removing the "conn: " prefix
20
+ full_message = obj.args[0] if obj.args else ""
21
+ if ': ' in full_message:
22
+ # Split off the connection part and keep the actual message
23
+ _, actual_message = full_message.split(': ', 1)
24
+ else:
25
+ actual_message = full_message
26
+ return _unpickle_new_connection_error, (actual_message, )
27
+
28
+
29
+def _unpickle_new_connection_error(message):
30
+ """Custom unpickler for NewConnectionError."""
31
+ # Create with None as conn and the actual message
32
+ return urllib3.exceptions.NewConnectionError(None, message)
33
+
34
+
35
+# Register the custom pickle/unpickle functions for tblib compatibility
36
+copyreg.pickle(urllib3.exceptions.NewConnectionError,
37
+ _pickle_new_connection_error)
38
39
40
def _query_server(prompt: str, max_tokens: int = 5) -> dict:
@@ -52,6 +77,7 @@ def api_server(distributed_executor_backend: str):
77
uvicorn_process.terminate()
78
79
80
+@pytest.mark.timeout(300)
81
@pytest.mark.parametrize("distributed_executor_backend", ["mp", "ray"])
82
def test_api_server(api_server, distributed_executor_backend: str):
83
"""
tests/conftest.py
+10
@@ -1,5 +1,15 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+# ruff: noqa
5
+
6
+from tblib import pickling_support
7
+
8
+# Install support for pickling exceptions so that we can nicely propagate
9
+# failures from tests running in a subprocess.
10
+# This should be run before any custom exception subclasses are defined.
11
+pickling_support.install()
12
+
13
import http.server
14
import json
15
import math
tests/utils.py
+92
-28
@@ -2,6 +2,7 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
import asyncio
5
+import contextlib
6
import copy
7
import functools
8
import importlib
@@ -13,7 +14,7 @@ import sys
14
import tempfile
15
import time
16
import warnings
16
-from contextlib import contextmanager, suppress
17
+from contextlib import ExitStack, contextmanager, suppress
18
from multiprocessing import Process
19
from pathlib import Path
20
from typing import Any, Callable, Literal, Optional, Union
@@ -800,43 +801,106 @@ _P = ParamSpec("_P")
801
802
803
def fork_new_process_for_each_test(
803
- f: Callable[_P, None]) -> Callable[_P, None]:
804
+ func: Callable[_P, None]) -> Callable[_P, None]:
805
"""Decorator to fork a new process for each test function.
806
See https://github.com/vllm-project/vllm/issues/7053 for more details.
807
"""
808
808
- @functools.wraps(f)
809
+ @functools.wraps(func)
810
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None:
811
# Make the process the leader of its own process group
812
# to avoid sending SIGTERM to the parent process
813
os.setpgrp()
814
from _pytest.outcomes import Skipped
814
- pid = os.fork()
815
- print(f"Fork a new process to run a test {pid}")
816
- if pid == 0:
817
- try:
818
- f(*args, **kwargs)
819
- except Skipped as e:
820
- # convert Skipped to exit code 0
821
- print(str(e))
822
- os._exit(0)
823
- except Exception:
824
- import traceback
825
- traceback.print_exc()
826
- os._exit(1)
815
+
816
+ # Create a unique temporary file to store exception info from child
817
+ # process. Use test function name and process ID to avoid collisions.
818
+ with tempfile.NamedTemporaryFile(
819
+ delete=False,
820
+ mode='w+b',
821
+ prefix=f"vllm_test_{func.__name__}_{os.getpid()}_",
822
+ suffix=".exc") as exc_file, ExitStack() as delete_after:
823
+ exc_file_path = exc_file.name
824
+ delete_after.callback(os.remove, exc_file_path)
825
+
826
+ pid = os.fork()
827
+ print(f"Fork a new process to run a test {pid}")
828
+ if pid == 0:
829
+ # Parent process responsible for deleting, don't delete
830
+ # in child.
831
+ delete_after.pop_all()
832
+ try:
833
+ func(*args, **kwargs)
834
+ except Skipped as e:
835
+ # convert Skipped to exit code 0
836
+ print(str(e))
837
+ os._exit(0)
838
+ except Exception as e:
839
+ import traceback
840
+ tb_string = traceback.format_exc()
841
+
842
+ # Try to serialize the exception object first
843
+ exc_to_serialize: dict[str, Any]
844
+ try:
845
+ # First, try to pickle the actual exception with
846
+ # its traceback.
847
+ exc_to_serialize = {'pickled_exception': e}
848
+ # Test if it can be pickled
849
+ cloudpickle.dumps(exc_to_serialize)
850
+ except (Exception, KeyboardInterrupt):
851
+ # Fall back to string-based approach.
852
+ exc_to_serialize = {
853
+ 'exception_type': type(e).__name__,
854
+ 'exception_msg': str(e),
855
+ 'traceback': tb_string,
856
+ }
857
+ try:
858
+ with open(exc_file_path, 'wb') as f:
859
+ cloudpickle.dump(exc_to_serialize, f)
860
+ except Exception:
861
+ # Fallback: just print the traceback.
862
+ print(tb_string)
863
+ os._exit(1)
864
+ else:
865
+ os._exit(0)
866
else:
828
- os._exit(0)
829
- else:
830
- pgid = os.getpgid(pid)
831
- _pid, _exitcode = os.waitpid(pid, 0)
832
- # ignore SIGTERM signal itself
833
- old_signal_handler = signal.signal(signal.SIGTERM, signal.SIG_IGN)
834
- # kill all child processes
835
- os.killpg(pgid, signal.SIGTERM)
836
- # restore the signal handler
837
- signal.signal(signal.SIGTERM, old_signal_handler)
838
- assert _exitcode == 0, (f"function {f} failed when called with"
839
- f" args {args} and kwargs {kwargs}")
867
+ pgid = os.getpgid(pid)
868
+ _pid, _exitcode = os.waitpid(pid, 0)
869
+ # ignore SIGTERM signal itself
870
+ old_signal_handler = signal.signal(signal.SIGTERM,
871
+ signal.SIG_IGN)
872
+ # kill all child processes
873
+ os.killpg(pgid, signal.SIGTERM)
874
+ # restore the signal handler
875
+ signal.signal(signal.SIGTERM, old_signal_handler)
876
+ if _exitcode != 0:
877
+ # Try to read the exception from the child process
878
+ exc_info = {}
879
+ if os.path.exists(exc_file_path):
880
+ with contextlib.suppress(Exception), \
881
+ open(exc_file_path, 'rb') as f:
882
+ exc_info = cloudpickle.load(f)
883
+
884
+ if (original_exception :=
885
+ exc_info.get('pickled_exception')) is not None:
886
+ # Re-raise the actual exception object if it was
887
+ # successfully pickled.
888
+ assert isinstance(original_exception, Exception)
889
+ raise original_exception
890
+
891
+ if (original_tb := exc_info.get("traceback")) is not None:
892
+ # Use string-based traceback for fallback case
893
+ raise AssertionError(
894
+ f"Test {func.__name__} failed when called with"
895
+ f" args {args} and kwargs {kwargs}"
896
+ f" (exit code: {_exitcode}):\n{original_tb}"
897
+ ) from None
898
+
899
+ # Fallback to the original generic error
900
+ raise AssertionError(
901
+ f"function {func.__name__} failed when called with"
902
+ f" args {args} and kwargs {kwargs}"
903
+ f" (exit code: {_exitcode})") from None
904
905
return wrapper
906
vllm/executor/ray_distributed_executor.py
+6
-4
@@ -117,10 +117,12 @@ class RayDistributedExecutor(DistributedExecutorBase):
117
self.driver_worker.execute_method)
118
119
def shutdown(self) -> None:
120
- logger.info(
121
- "Shutting down Ray distributed executor. If you see error log "
122
- "from logging.cc regarding SIGTERM received, please ignore because "
123
- "this is the expected termination process in Ray.")
120
+ if logger:
121
+ # Somehow logger can be None here.
122
+ logger.info(
123
+ "Shutting down Ray distributed executor. If you see error log "
124
+ "from logging.cc regarding SIGTERM received, please ignore "
125
+ "because this is the expected termination process in Ray.")
126
if hasattr(self, "forward_dag") and self.forward_dag is not None:
127
self.forward_dag.teardown()
128
import ray