From 86e2ab50aa24ed8be79e7b6b73da025d997992eb Mon Sep 17 00:00:00 2001 From: dmai-afk Date: Wed, 12 Aug 2026 17:58:46 +0700 Subject: [PATCH] [Bugfix][Frontend] Return 400 for invalid PyNvVideoCodec video input (#51120) Signed-off-by: Dat Mai --- tests/multimodal/media/test_video.py | 28 +++++++++++++++ tests/multimodal/test_video.py | 54 ++++++++++++++++++++++++++++ vllm/multimodal/video.py | 33 ++++++++++++++--- 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/tests/multimodal/media/test_video.py b/tests/multimodal/media/test_video.py index 6ce20f6621..186678f3c1 100644 --- a/tests/multimodal/media/test_video.py +++ b/tests/multimodal/media/test_video.py @@ -1,7 +1,9 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import io +import sys from pathlib import Path +from types import SimpleNamespace import numpy as np import numpy.typing as npt @@ -19,6 +21,7 @@ from vllm.multimodal.media import ImageMediaIO, VideoMediaIO from vllm.multimodal.video import ( PYNVVIDEOCODEC_VIDEO_BACKEND, VIDEO_LOADER_REGISTRY, + PyNvVideoCodecVideoBackend, VideoLoader, _pynvvc_frames_to_nhwc, ) @@ -364,6 +367,31 @@ def test_load_base64_jpeg_raises_on_zero_num_frames(): videoio.load_base64("video/jpeg", data) +def test_pynvvideocodec_unrelated_error_propagates( + monkeypatch: pytest.MonkeyPatch, +): + class FakePyNvVCException(Exception): + pass + + fake_nvc = SimpleNamespace(PyNvVCException=FakePyNvVCException) + monkeypatch.setitem(sys.modules, "PyNvVideoCodec", fake_nvc) + original_error = RuntimeError("GPU decoder unavailable") + + def raise_unrelated_error(cls, file_path, nvc): + raise original_error + + monkeypatch.setattr( + PyNvVideoCodecVideoBackend, + "_read_source_metadata", + classmethod(raise_unrelated_error), + ) + + with pytest.raises(RuntimeError) as exc_info: + PyNvVideoCodecVideoBackend.decode_frames_pynvvideocodec(b"video", None) + + assert exc_info.value is original_error + + # --------------------------------------------------------------------------- # GPU video backend policy tests # --------------------------------------------------------------------------- diff --git a/tests/multimodal/test_video.py b/tests/multimodal/test_video.py index 1a7b63ae92..b5d05301b5 100644 --- a/tests/multimodal/test_video.py +++ b/tests/multimodal/test_video.py @@ -200,6 +200,60 @@ def test_pynvvideocodec_codec_uses_dynamic_sampling_strategy( assert metadata["frames_indices"] == [0, 9] +@pytest.mark.skipif(not current_platform.is_cuda(), reason="Requires CUDA") +def test_pynvvideocodec_corrupted_videos_raise_value_error(): + valid_video = create_long_gop_video(num_frames=2, width=64, height=64) + corrupted_video = (ASSETS_DIR / "corrupted.mp4").read_bytes() + malformed_video = corrupted_video[:128] + + old_slots = PyNvVideoCodecVideoBackend._decoder_slots + old_active_slots = PyNvVideoCodecVideoBackend._active_decoder_slots + old_cond = PyNvVideoCodecVideoBackend._decoder_slot_cond + old_max_slots = PyNvVideoCodecVideoBackend._max_decoder_slots + try: + PyNvVideoCodecVideoBackend._decoder_slots = [] + PyNvVideoCodecVideoBackend._active_decoder_slots = 0 + PyNvVideoCodecVideoBackend._decoder_slot_cond = threading.Condition() + PyNvVideoCodecVideoBackend._max_decoder_slots = None + + loader = VIDEO_LOADER_REGISTRY.load(PYNVVIDEOCODEC_VIDEO_BACKEND) + with pytest.raises( + ValueError, + match=r"^Invalid or unsupported video file\.$", + ) as malformed_exc: + loader.load_bytes( + malformed_video, + num_frames=1, + hw_decoders=1, + ) + + assert malformed_exc.value.__cause__ is not None + + with pytest.raises( + ValueError, + match=r"^Invalid or unsupported video file\.$", + ) as exc_info: + loader.load_bytes( + corrupted_video, + num_frames=-1, + hw_decoders=1, + ) + + assert exc_info.value.__cause__ is not None + + frames, _ = loader.load_bytes( + valid_video, + num_frames=1, + hw_decoders=1, + ) + assert frames.shape[0] == 1 + finally: + PyNvVideoCodecVideoBackend._decoder_slots = old_slots + PyNvVideoCodecVideoBackend._active_decoder_slots = old_active_slots + PyNvVideoCodecVideoBackend._decoder_slot_cond = old_cond + PyNvVideoCodecVideoBackend._max_decoder_slots = old_max_slots + + @pytest.mark.parametrize("hw_decoders", [1, 3]) def test_pynvvideocodec_decoder_slots_are_bounded( monkeypatch: pytest.MonkeyPatch, diff --git a/vllm/multimodal/video.py b/vllm/multimodal/video.py index 068071dfe0..4af65dea29 100644 --- a/vllm/multimodal/video.py +++ b/vllm/multimodal/video.py @@ -234,6 +234,16 @@ def validate_pynvvideocodec_hw_decoders(hw_decoders: object) -> int: return hw_decoders +def _pynvvideocodec_exception_types(nvc) -> tuple[type[Exception], ...]: + return tuple( + exception_type + for name in dir(nvc) + if name.startswith("PyNvVCException") + and isinstance((exception_type := getattr(nvc, name)), type) + and issubclass(exception_type, Exception) + ) + + class PyNvVideoCodecDecoderSlot: """A retained PyNv decoder slot and its CUDA stream. @@ -806,10 +816,18 @@ class PyNvVideoCodecVideoBackendMixin: with cls._borrow_decoder_slot() as decoder_slot: stream = decoder_slot.stream with cls._torch_stream_context(stream): - decoder = decoder_slot.get_decoder( - file_path, nvc, device_index=cls._DEVICE_INDEX - ) - decoded_frames = decoder.get_batch_frames_by_index(frame_idx) + try: + decoder = decoder_slot.get_decoder( + file_path, nvc, device_index=cls._DEVICE_INDEX + ) + decoded_frames = decoder.get_batch_frames_by_index(frame_idx) + except Exception as exc: + if not isinstance( + exc, + _pynvvideocodec_exception_types(nvc) + (IndexError,), + ): + raise + raise ValueError("Invalid or unsupported video file.") from exc if len(decoded_frames) < len(frame_idx): logger.warning( "pynvvideocodec video loading: expected %d frames but got %d.", @@ -854,7 +872,12 @@ class PyNvVideoCodecVideoBackendMixin: with os.fdopen(temp_fd, "wb") as temp_file: temp_file.write(data) - gpu_source = cls._read_source_metadata(temp_path, nvc) + try: + gpu_source = cls._read_source_metadata(temp_path, nvc) + except Exception as exc: + if not isinstance(exc, _pynvvideocodec_exception_types(nvc)): + raise + raise ValueError("Invalid or unsupported video file.") from exc _check_frame_pixel_limit(gpu_source.width, gpu_source.height) source = cls._prepare_source(gpu_source.source) frame_idx = cls.compute_frames_index_to_sample(