[Security] Prefer fresh multimodal payloads over a stale receiver cache (#57833)
Signed-off-by: Juan Pérez de Algaba <jperezde@redhat.com> Co-authored-by: Cursor <cursoragent@cursor.com>
Juan Pérez de Algaba committed
Sep 21, 2026 at 14:50 UTC
7268f6e38ebbb3d61700f16c638c6eba0c88bb0c
2 files changed
+61
-8
tests/multimodal/test_cache.py
+45
@@ -330,6 +330,51 @@ def test_mm_cache_miss_raises_and_recovers():
330
assert p1.get_and_update_item(None, mm_hash) == item
331
332
333
+def test_receiver_cache_replaces_stale_item_when_payload_resent():
334
+ """A P0 miss can resend a different item under the same identity.
335
+
336
+ Independent LRU eviction leaves P1 holding the old tensor. Substituting it
337
+ for the new payload pairs the request's placeholders with the wrong item
338
+ and kills EngineCore. Prefer the fresh payload and keep it for later hits.
339
+ """
340
+ model_config = _StubModelConfig(mm_processor_cache_gb=1)
341
+ p1 = MultiModalReceiverCache(model_config) # type: ignore[arg-type]
342
+ small = MultiModalKwargsItem.dummy(nbytes=64)
343
+ large = MultiModalKwargsItem.dummy(nbytes=256)
344
+ mm_hash = "shared-id"
345
+
346
+ assert p1.get_and_update_item(small, mm_hash) is small
347
+ assert p1.get_and_update_item(large, mm_hash) is large
348
+ assert p1.get_and_update_item(None, mm_hash) is large
349
+
350
+
351
+def test_receiver_cache_features_keep_resent_payload():
352
+ """EngineCore updates features through get_and_update_features."""
353
+ model_config = _StubModelConfig(mm_processor_cache_gb=1)
354
+ p1 = MultiModalReceiverCache(model_config) # type: ignore[arg-type]
355
+ small = MultiModalKwargsItem.dummy(nbytes=64)
356
+ large = MultiModalKwargsItem.dummy(nbytes=256)
357
+ mm_hash = "shared-id"
358
+
359
+ def _feature(
360
+ data: MultiModalKwargsItem | None, length: int
361
+ ) -> MultiModalFeatureSpec:
362
+ return MultiModalFeatureSpec(
363
+ data=data,
364
+ modality="image",
365
+ identifier=mm_hash,
366
+ mm_position=PlaceholderRange(offset=0, length=length),
367
+ mm_hash=mm_hash,
368
+ )
369
+
370
+ seeded = p1.get_and_update_features([_feature(small, length=4)])
371
+ assert seeded[0].data is small
372
+
373
+ updated = p1.get_and_update_features([_feature(large, length=2048)])
374
+ assert updated[0].data is large
375
+ assert updated[0].mm_position.length == 2048
376
+
377
+
378
def test_mm_cache_miss_batches_all_drifted_hashes():
379
"""All hashes drifted within one request must surface in a single error.
380
vllm/multimodal/cache.py
+16
-8
@@ -710,9 +710,12 @@ class MultiModalReceiverCache(BaseMultiModalReceiverCache):
710
711
How to update each item:
712
713
- - If the item is in the cache, replace the input with the cached item.
714
- - If the item is not in the cache, store that item (which includes tensor
715
- data) into the cache, and return the input.
713
+ - If the caller sent tensor data, store it (replacing any cached item
714
+ under the same key) and return that data. P0 can miss after independent
715
+ LRU eviction and resend a different item for the same identity.
716
+ - If the caller sent no data and the item is cached, return the cached item.
717
+ - If the caller sent no data and the item is not cached, raise
718
+ ``MultiModalCacheMissError``.
719
"""
720
721
def __init__(self, model_config: "ModelConfig") -> None:
@@ -731,17 +734,22 @@ class MultiModalReceiverCache(BaseMultiModalReceiverCache):
734
mm_item: MultiModalKwargsItem | None,
735
mm_hash: str,
736
) -> MultiModalKwargsItem:
737
+ if mm_item is not None:
738
+ # P0 sent a payload. Never keep a stale cached tensor under this
739
+ # identity: a later P0 hit would pair new placeholders with the old
740
+ # item and crash the engine. Drop first so a too-large replacement
741
+ # cannot leave the previous entry in place.
742
+ self._cache.pop(mm_hash, None)
743
+ self.cache_if_fits(self._cache, mm_hash, mm_item)
744
+ return mm_item
745
+
746
if (cached_item := self._cache.get(mm_hash)) is not None:
747
return cached_item
748
749
# No data and not cached here: P0 sent data=None trusting its shadow, but
750
# the P0/P1 caches have drifted. Raise a retryable error (not assert) so the
751
# engine can have P0 drop the stale entry and the client resend the data.
740
- if mm_item is None:
741
- raise MultiModalCacheMissError([mm_hash])
742
-
743
- self.cache_if_fits(self._cache, mm_hash, mm_item)
744
- return mm_item
752
+ raise MultiModalCacheMissError([mm_hash])
753
754
@override
755
def touch_receiver_cache_item(