[Bugfix][Frontend] Return 400 for invalid PyNvVideoCodec video input (#51120)
Signed-off-by: Dat Mai <dmai@nvidia.com>
dmai-afk committed
Aug 12, 2026 at 17:58 UTC
86e2ab50aa24ed8be79e7b6b73da025d997992eb
3 files changed
+110
-5
tests/multimodal/media/test_video.py
+28
@@ -1,7 +1,9 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
import io
4
+import sys
5
from pathlib import Path
6
+from types import SimpleNamespace
7
8
import numpy as np
9
import numpy.typing as npt
@@ -19,6 +21,7 @@ from vllm.multimodal.media import ImageMediaIO, VideoMediaIO
21
from vllm.multimodal.video import (
22
PYNVVIDEOCODEC_VIDEO_BACKEND,
23
VIDEO_LOADER_REGISTRY,
24
+ PyNvVideoCodecVideoBackend,
25
VideoLoader,
26
_pynvvc_frames_to_nhwc,
27
)
@@ -364,6 +367,31 @@ def test_load_base64_jpeg_raises_on_zero_num_frames():
367
videoio.load_base64("video/jpeg", data)
368
369
370
+def test_pynvvideocodec_unrelated_error_propagates(
371
+ monkeypatch: pytest.MonkeyPatch,
372
+):
373
+ class FakePyNvVCException(Exception):
374
+ pass
375
+
376
+ fake_nvc = SimpleNamespace(PyNvVCException=FakePyNvVCException)
377
+ monkeypatch.setitem(sys.modules, "PyNvVideoCodec", fake_nvc)
378
+ original_error = RuntimeError("GPU decoder unavailable")
379
+
380
+ def raise_unrelated_error(cls, file_path, nvc):
381
+ raise original_error
382
+
383
+ monkeypatch.setattr(
384
+ PyNvVideoCodecVideoBackend,
385
+ "_read_source_metadata",
386
+ classmethod(raise_unrelated_error),
387
+ )
388
+
389
+ with pytest.raises(RuntimeError) as exc_info:
390
+ PyNvVideoCodecVideoBackend.decode_frames_pynvvideocodec(b"video", None)
391
+
392
+ assert exc_info.value is original_error
393
+
394
+
395
# ---------------------------------------------------------------------------
396
# GPU video backend policy tests
397
# ---------------------------------------------------------------------------
tests/multimodal/test_video.py
+54
@@ -200,6 +200,60 @@ def test_pynvvideocodec_codec_uses_dynamic_sampling_strategy(
200
assert metadata["frames_indices"] == [0, 9]
201
202
203
+@pytest.mark.skipif(not current_platform.is_cuda(), reason="Requires CUDA")
204
+def test_pynvvideocodec_corrupted_videos_raise_value_error():
205
+ valid_video = create_long_gop_video(num_frames=2, width=64, height=64)
206
+ corrupted_video = (ASSETS_DIR / "corrupted.mp4").read_bytes()
207
+ malformed_video = corrupted_video[:128]
208
+
209
+ old_slots = PyNvVideoCodecVideoBackend._decoder_slots
210
+ old_active_slots = PyNvVideoCodecVideoBackend._active_decoder_slots
211
+ old_cond = PyNvVideoCodecVideoBackend._decoder_slot_cond
212
+ old_max_slots = PyNvVideoCodecVideoBackend._max_decoder_slots
213
+ try:
214
+ PyNvVideoCodecVideoBackend._decoder_slots = []
215
+ PyNvVideoCodecVideoBackend._active_decoder_slots = 0
216
+ PyNvVideoCodecVideoBackend._decoder_slot_cond = threading.Condition()
217
+ PyNvVideoCodecVideoBackend._max_decoder_slots = None
218
+
219
+ loader = VIDEO_LOADER_REGISTRY.load(PYNVVIDEOCODEC_VIDEO_BACKEND)
220
+ with pytest.raises(
221
+ ValueError,
222
+ match=r"^Invalid or unsupported video file\.$",
223
+ ) as malformed_exc:
224
+ loader.load_bytes(
225
+ malformed_video,
226
+ num_frames=1,
227
+ hw_decoders=1,
228
+ )
229
+
230
+ assert malformed_exc.value.__cause__ is not None
231
+
232
+ with pytest.raises(
233
+ ValueError,
234
+ match=r"^Invalid or unsupported video file\.$",
235
+ ) as exc_info:
236
+ loader.load_bytes(
237
+ corrupted_video,
238
+ num_frames=-1,
239
+ hw_decoders=1,
240
+ )
241
+
242
+ assert exc_info.value.__cause__ is not None
243
+
244
+ frames, _ = loader.load_bytes(
245
+ valid_video,
246
+ num_frames=1,
247
+ hw_decoders=1,
248
+ )
249
+ assert frames.shape[0] == 1
250
+ finally:
251
+ PyNvVideoCodecVideoBackend._decoder_slots = old_slots
252
+ PyNvVideoCodecVideoBackend._active_decoder_slots = old_active_slots
253
+ PyNvVideoCodecVideoBackend._decoder_slot_cond = old_cond
254
+ PyNvVideoCodecVideoBackend._max_decoder_slots = old_max_slots
255
+
256
+
257
@pytest.mark.parametrize("hw_decoders", [1, 3])
258
def test_pynvvideocodec_decoder_slots_are_bounded(
259
monkeypatch: pytest.MonkeyPatch,
vllm/multimodal/video.py
+28
-5
@@ -234,6 +234,16 @@ def validate_pynvvideocodec_hw_decoders(hw_decoders: object) -> int:
234
return hw_decoders
235
236
237
+def _pynvvideocodec_exception_types(nvc) -> tuple[type[Exception], ...]:
238
+ return tuple(
239
+ exception_type
240
+ for name in dir(nvc)
241
+ if name.startswith("PyNvVCException")
242
+ and isinstance((exception_type := getattr(nvc, name)), type)
243
+ and issubclass(exception_type, Exception)
244
+ )
245
+
246
+
247
class PyNvVideoCodecDecoderSlot:
248
"""A retained PyNv decoder slot and its CUDA stream.
249
@@ -806,10 +816,18 @@ class PyNvVideoCodecVideoBackendMixin:
816
with cls._borrow_decoder_slot() as decoder_slot:
817
stream = decoder_slot.stream
818
with cls._torch_stream_context(stream):
809
- decoder = decoder_slot.get_decoder(
810
- file_path, nvc, device_index=cls._DEVICE_INDEX
811
- )
812
- decoded_frames = decoder.get_batch_frames_by_index(frame_idx)
819
+ try:
820
+ decoder = decoder_slot.get_decoder(
821
+ file_path, nvc, device_index=cls._DEVICE_INDEX
822
+ )
823
+ decoded_frames = decoder.get_batch_frames_by_index(frame_idx)
824
+ except Exception as exc:
825
+ if not isinstance(
826
+ exc,
827
+ _pynvvideocodec_exception_types(nvc) + (IndexError,),
828
+ ):
829
+ raise
830
+ raise ValueError("Invalid or unsupported video file.") from exc
831
if len(decoded_frames) < len(frame_idx):
832
logger.warning(
833
"pynvvideocodec video loading: expected %d frames but got %d.",
@@ -854,7 +872,12 @@ class PyNvVideoCodecVideoBackendMixin:
872
with os.fdopen(temp_fd, "wb") as temp_file:
873
temp_file.write(data)
874
857
- gpu_source = cls._read_source_metadata(temp_path, nvc)
875
+ try:
876
+ gpu_source = cls._read_source_metadata(temp_path, nvc)
877
+ except Exception as exc:
878
+ if not isinstance(exc, _pynvvideocodec_exception_types(nvc)):
879
+ raise
880
+ raise ValueError("Invalid or unsupported video file.") from exc
881
_check_frame_pixel_limit(gpu_source.width, gpu_source.height)
882
source = cls._prepare_source(gpu_source.source)
883
frame_idx = cls.compute_frames_index_to_sample(