[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(