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):