fix: prevent PyNvVideoCodec decoder slot limit bypass via ClassVar shadowing (#52126)

Signed-off-by: jperezde <jperezde@redhat.com>

Juan Pérez de Algaba committed Aug 17, 2026 at 19:45 UTC ceb340e2eb5b4c8edee8d0dbd71195389ebd86b5
2 files changed +151 -82
tests/multimodal/test_video.py
+107 -56
@@ -24,12 +24,14 @@ from vllm.multimodal.video import (
24 Molmo2VideoBackend,
25 PyNvVideoCodecDecoderSlot,
26 PyNvVideoCodecVideoBackend,
27 + PyNvVideoCodecVideoBackendMixin,
28 Qwen2VLVideoBackend,
29 Qwen3VLVideoBackend,
30 VideoBackend,
31 VideoLoader,
32 VideoSourceMetadata,
33 VideoTargetMetadata,
34 + _pynv_decoder_pool,
35 get_video_loader_backend_for_processor,
36 )
37 from vllm.platforms import current_platform
@@ -47,6 +49,27 @@ FAKE_OUTPUT_1 = np.random.rand(NUM_FRAMES, 1280, 720, 3)
49 FAKE_OUTPUT_2 = np.random.rand(NUM_FRAMES, 1280, 720, 3)
50
51
52 +@contextmanager
53 +def _fresh_decoder_pool():
54 + """Reset module-level decoder pool for isolated test runs."""
55 + pool = _pynv_decoder_pool
56 + old_slots = pool.slots
57 + old_active = pool.active
58 + old_cond = pool.cond
59 + old_max = pool.max_slots
60 + pool.slots = []
61 + pool.active = 0
62 + pool.cond = threading.Condition()
63 + pool.max_slots = None
64 + try:
65 + yield pool
66 + finally:
67 + pool.slots = old_slots
68 + pool.active = old_active
69 + pool.cond = old_cond
70 + pool.max_slots = old_max
71 +
72 +
73 @VIDEO_LOADER_REGISTRY.register("test_video_loader_1")
74 class TestVideoLoader1(VideoLoader):
75 @classmethod
@@ -206,16 +229,7 @@ def test_pynvvideocodec_corrupted_videos_raise_value_error():
229 corrupted_video = (ASSETS_DIR / "corrupted.mp4").read_bytes()
230 malformed_video = corrupted_video[:128]
231
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 -
232 + with _fresh_decoder_pool():
233 loader = VIDEO_LOADER_REGISTRY.load(PYNVVIDEOCODEC_VIDEO_BACKEND)
234 with pytest.raises(
235 ValueError,
@@ -247,11 +261,6 @@ def test_pynvvideocodec_corrupted_videos_raise_value_error():
261 hw_decoders=1,
262 )
263 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
264
265
266 @pytest.mark.parametrize("hw_decoders", [1, 3])
@@ -263,15 +272,7 @@ def test_pynvvideocodec_decoder_slots_are_bounded(
272 pass
273
274 create_count = 0
266 - old_slots = PyNvVideoCodecVideoBackend._decoder_slots
267 - old_active_slots = PyNvVideoCodecVideoBackend._active_decoder_slots
268 - old_cond = PyNvVideoCodecVideoBackend._decoder_slot_cond
269 - old_max_slots = PyNvVideoCodecVideoBackend._max_decoder_slots
270 - try:
271 - PyNvVideoCodecVideoBackend._decoder_slots = []
272 - PyNvVideoCodecVideoBackend._active_decoder_slots = 0
273 - PyNvVideoCodecVideoBackend._decoder_slot_cond = threading.Condition()
274 - PyNvVideoCodecVideoBackend._max_decoder_slots = None
275 + with _fresh_decoder_pool():
276 PyNvVideoCodecVideoBackend._configure_decoder_slots(hw_decoders)
277
278 def fake_create_slot(cls):
@@ -309,17 +310,12 @@ def test_pynvvideocodec_decoder_slots_are_bounded(
310
311 assert seen_slots[0] in retained_slots
312 assert create_count == hw_decoders
312 - finally:
313 - PyNvVideoCodecVideoBackend._decoder_slots = old_slots
314 - PyNvVideoCodecVideoBackend._active_decoder_slots = old_active_slots
315 - PyNvVideoCodecVideoBackend._decoder_slot_cond = old_cond
316 - PyNvVideoCodecVideoBackend._max_decoder_slots = old_max_slots
313
314
315 def test_pynvvideocodec_decoder_slots_are_configured_once(
316 monkeypatch: pytest.MonkeyPatch,
317 ):
322 - monkeypatch.setattr(PyNvVideoCodecVideoBackend, "_max_decoder_slots", None)
318 + monkeypatch.setattr(_pynv_decoder_pool, "max_slots", None)
319
320 PyNvVideoCodecVideoBackend._configure_decoder_slots(2)
321 PyNvVideoCodecVideoBackend._configure_decoder_slots(2)
@@ -358,15 +354,16 @@ def test_pynvvideocodec_failed_rebuild_invalidates_decoder_slot():
354 assert slot.source_path is None
355 raise RuntimeError("construct failed")
356
361 - old_slots = PyNvVideoCodecVideoBackend._decoder_slots
362 - old_active_slots = PyNvVideoCodecVideoBackend._active_decoder_slots
363 - old_cond = PyNvVideoCodecVideoBackend._decoder_slot_cond
364 - old_max_slots = PyNvVideoCodecVideoBackend._max_decoder_slots
357 + pool = _pynv_decoder_pool
358 + old_slots = pool.slots
359 + old_active = pool.active
360 + old_cond = pool.cond
361 + old_max = pool.max_slots
362 try:
366 - PyNvVideoCodecVideoBackend._decoder_slots = [slot]
367 - PyNvVideoCodecVideoBackend._active_decoder_slots = 1
368 - PyNvVideoCodecVideoBackend._decoder_slot_cond = threading.Condition()
369 - PyNvVideoCodecVideoBackend._max_decoder_slots = 1
363 + pool.slots = [slot]
364 + pool.active = 1
365 + pool.cond = threading.Condition()
366 + pool.max_slots = 1
367
368 with (
369 pytest.raises(RuntimeError, match="construct failed"),
@@ -386,12 +383,12 @@ def test_pynvvideocodec_failed_rebuild_invalidates_decoder_slot():
383 assert old_decoder.poisoned
384 assert slot.decoder is None
385 assert slot.source_path is None
389 - assert PyNvVideoCodecVideoBackend._decoder_slots == [slot]
386 + assert pool.slots == [slot]
387 finally:
391 - PyNvVideoCodecVideoBackend._decoder_slots = old_slots
392 - PyNvVideoCodecVideoBackend._active_decoder_slots = old_active_slots
393 - PyNvVideoCodecVideoBackend._decoder_slot_cond = old_cond
394 - PyNvVideoCodecVideoBackend._max_decoder_slots = old_max_slots
388 + pool.slots = old_slots
389 + pool.active = old_active
390 + pool.cond = old_cond
391 + pool.max_slots = old_max
392
393
394 @pytest.mark.skipif(not current_platform.is_cuda(), reason="Requires CUDA")
@@ -405,15 +402,15 @@ def test_pynvvideocodec_h200_recovers_after_unsupported_8k():
402 valid_video = create_long_gop_video(num_frames=2, width=64, height=64)
403 unsupported_video = (ASSETS_DIR / "unsupported_8k_h264.mp4").read_bytes()
404
408 - old_slots = PyNvVideoCodecVideoBackend._decoder_slots
409 - old_active_slots = PyNvVideoCodecVideoBackend._active_decoder_slots
410 - old_cond = PyNvVideoCodecVideoBackend._decoder_slot_cond
411 - old_max_slots = PyNvVideoCodecVideoBackend._max_decoder_slots
405 + old_slots = _pynv_decoder_pool.slots
406 + old_active = _pynv_decoder_pool.active
407 + old_cond = _pynv_decoder_pool.cond
408 + old_max = _pynv_decoder_pool.max_slots
409 try:
413 - PyNvVideoCodecVideoBackend._decoder_slots = []
414 - PyNvVideoCodecVideoBackend._active_decoder_slots = 0
415 - PyNvVideoCodecVideoBackend._decoder_slot_cond = threading.Condition()
416 - PyNvVideoCodecVideoBackend._max_decoder_slots = None
410 + _pynv_decoder_pool.slots = []
411 + _pynv_decoder_pool.active = 0
412 + _pynv_decoder_pool.cond = threading.Condition()
413 + _pynv_decoder_pool.max_slots = None
414
415 loader = VIDEO_LOADER_REGISTRY.load(PYNVVIDEOCODEC_VIDEO_BACKEND)
416 frames_before, _ = loader.load_bytes(
@@ -443,12 +440,66 @@ def test_pynvvideocodec_h200_recovers_after_unsupported_8k():
440
441 assert frames_after.shape == frames_before.shape
442 finally:
446 - for slot in PyNvVideoCodecVideoBackend._decoder_slots:
443 + for slot in _pynv_decoder_pool.slots:
444 slot.invalidate()
448 - PyNvVideoCodecVideoBackend._decoder_slots = old_slots
449 - PyNvVideoCodecVideoBackend._active_decoder_slots = old_active_slots
450 - PyNvVideoCodecVideoBackend._decoder_slot_cond = old_cond
451 - PyNvVideoCodecVideoBackend._max_decoder_slots = old_max_slots
445 + _pynv_decoder_pool.slots = old_slots
446 + _pynv_decoder_pool.active = old_active
447 + _pynv_decoder_pool.cond = old_cond
448 + _pynv_decoder_pool.max_slots = old_max
449 +
450 +
451 +def test_pynvvideocodec_cross_subclass_shares_single_pool():
452 + """Regression test for GHSA-j682-9xp5-rrf3.
453 +
454 + Multiple subclasses of PyNvVideoCodecVideoBackendMixin must share the
455 + same process-wide decoder slot limit rather than getting independent
456 + counters via ClassVar shadowing.
457 + """
458 +
459 + class FakeSlot:
460 + pass
461 +
462 + create_count = 0
463 +
464 + def fake_create_slot(cls):
465 + nonlocal create_count
466 + create_count += 1
467 + return FakeSlot()
468 +
469 + with _fresh_decoder_pool() as pool:
470 + pool.max_slots = 2
471 +
472 + orig_create = PyNvVideoCodecVideoBackendMixin._create_decoder_slot
473 + PyNvVideoCodecVideoBackendMixin._create_decoder_slot = classmethod(
474 + fake_create_slot
475 + )
476 + try:
477 + with ExitStack() as stack:
478 + stack.enter_context(VideoBackend._borrow_decoder_slot())
479 + stack.enter_context(Qwen3VLVideoBackend._borrow_decoder_slot())
480 + assert pool.active == 2
481 +
482 + blocked = threading.Event()
483 + acquired = threading.Event()
484 +
485 + def try_borrow():
486 + blocked.set()
487 + with Qwen2VLVideoBackend._borrow_decoder_slot():
488 + acquired.set()
489 +
490 + t = threading.Thread(target=try_borrow)
491 + t.start()
492 + blocked.wait(timeout=2.0)
493 + assert not acquired.wait(timeout=0.3)
494 +
495 + assert acquired.wait(timeout=2.0)
496 + t.join(timeout=2.0)
497 + assert not t.is_alive()
498 +
499 + assert create_count == 2
500 + assert len(pool.slots) == 2
501 + finally:
502 + PyNvVideoCodecVideoBackendMixin._create_decoder_slot = orig_create
503
504
505 @pytest.mark.parametrize("hw_decoders", [0, -1, 1.5, True, "2"])
vllm/multimodal/video.py
+44 -26
@@ -671,13 +671,38 @@ def _pynvvc_frames_to_nhwc(frames: torch.Tensor) -> torch.Tensor:
671 return frames.contiguous()
672
673
674 +class _PyNvDecoderPool:
675 + """Process-wide singleton managing PyNvVideoCodec decoder slot state.
676 +
677 + Prevents subclass counter shadowing (GHSA-j682-9xp5-rrf3) by storing
678 + all mutable pool state in a single module-level instance rather than
679 + in ClassVar attributes that get shadowed by Python's augmented
680 + assignment semantics on subclasses.
681 + """
682 +
683 + def __init__(self) -> None:
684 + self.slots: list[PyNvVideoCodecDecoderSlot] = []
685 + self.active: int = 0
686 + self.cond: threading.Condition = threading.Condition()
687 + self.max_slots: int | None = None
688 +
689 + def configure(self, hw_decoders: int) -> None:
690 + with self.cond:
691 + if self.max_slots is None:
692 + self.max_slots = hw_decoders
693 + elif self.max_slots != hw_decoders:
694 + raise RuntimeError(
695 + "PyNvVideoCodec decoder count is already configured as "
696 + f"{self.max_slots}, got {hw_decoders}"
697 + )
698 +
699 +
700 +_pynv_decoder_pool = _PyNvDecoderPool()
701 +
702 +
703 class PyNvVideoCodecVideoBackendMixin:
704 """PyNvVideoCodec utilities for GPU-backed frame decode."""
705
677 - _decoder_slots: ClassVar[list[PyNvVideoCodecDecoderSlot]] = []
678 - _active_decoder_slots: ClassVar[int] = 0
679 - _decoder_slot_cond: ClassVar[threading.Condition] = threading.Condition()
680 - _max_decoder_slots: ClassVar[int | None] = None
706 _DEVICE_INDEX: ClassVar[int] = 0
707
708 @classmethod
@@ -704,14 +729,7 @@ class PyNvVideoCodecVideoBackendMixin:
729 @classmethod
730 def _configure_decoder_slots(cls, hw_decoders: object) -> None:
731 hw_decoders = validate_pynvvideocodec_hw_decoders(hw_decoders)
707 - with cls._decoder_slot_cond:
708 - if cls._max_decoder_slots is None:
709 - cls._max_decoder_slots = hw_decoders
710 - elif cls._max_decoder_slots != hw_decoders:
711 - raise RuntimeError(
712 - "PyNvVideoCodec decoder count is already configured as "
713 - f"{cls._max_decoder_slots}, got {hw_decoders}"
714 - )
732 + _pynv_decoder_pool.configure(hw_decoders)
733
734 @staticmethod
735 @contextmanager
@@ -729,28 +747,28 @@ class PyNvVideoCodecVideoBackendMixin:
747 @classmethod
748 @contextmanager
749 def _borrow_decoder_slot(cls):
750 + pool = _pynv_decoder_pool
751 create_slot = False
733 - with cls._decoder_slot_cond:
734 - max_decoder_slots = cls._max_decoder_slots
735 - if max_decoder_slots is None:
752 + with pool.cond:
753 + if pool.max_slots is None:
754 raise RuntimeError("PyNvVideoCodec decoder slots are not configured")
755 while True:
738 - if cls._decoder_slots:
739 - slot = cls._decoder_slots.pop()
756 + if pool.slots:
757 + slot = pool.slots.pop()
758 break
741 - if cls._active_decoder_slots < max_decoder_slots:
742 - cls._active_decoder_slots += 1
759 + if pool.active < pool.max_slots:
760 + pool.active += 1
761 create_slot = True
762 break
745 - cls._decoder_slot_cond.wait()
763 + pool.cond.wait()
764
765 if create_slot:
766 try:
767 slot = cls._create_decoder_slot()
768 except Exception:
751 - with cls._decoder_slot_cond:
752 - cls._active_decoder_slots -= 1
753 - cls._decoder_slot_cond.notify()
769 + with pool.cond:
770 + pool.active -= 1
771 + pool.cond.notify()
772 raise
773
774 borrow_succeeded = False
@@ -760,9 +778,9 @@ class PyNvVideoCodecVideoBackendMixin:
778 finally:
779 if not borrow_succeeded:
780 slot.invalidate()
763 - with cls._decoder_slot_cond:
764 - cls._decoder_slots.append(slot)
765 - cls._decoder_slot_cond.notify()
781 + with pool.cond:
782 + pool.slots.append(slot)
783 + pool.cond.notify()
784
785 @staticmethod
786 def _metadata_value(metadata, *names: str, default=None):