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