[vlm] fix: recover multimodal decode and processor failures (#36983)

This commit is contained in:
Mick
2026-08-30 20:50:19 +08:00
committed by GitHub
parent aa483ab782
commit 26c754e06e
11 changed files with 392 additions and 39 deletions
@@ -1,4 +1,5 @@
import asyncio
import base64
import pickle
import sys
import threading
@@ -27,7 +28,7 @@ from sglang.srt.disaggregation.encoder.receiver import (
_encoder_media_item,
_select_mm_processor_prompt,
)
from sglang.srt.disaggregation.encoder.server import MMEncoder
from sglang.srt.disaggregation.encoder.server import BadRequestError, MMEncoder
from sglang.srt.managers.schedule_batch import Modality, MultimodalDataItem
from sglang.srt.managers.tokenizer_manager import (
_reject_missing_dispatched_encoder_embedding,
@@ -504,6 +505,17 @@ def test_kimi_k3_epd_selects_matching_jpeg_decode_mode(
load.assert_called_once_with(b"jpeg", expected_decode_mode)
def test_kimi_k3_epd_rejects_lazy_pil_decode_failure():
malformed_png = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLJSwAAAABJRU5ErkJggg=="
)
encoder = _encoder()
encoder.preprocessor.use_image_processor_gpu = False
with pytest.raises(BadRequestError, match="Could not decode image"):
encoder.preprocessor._load_single_item(malformed_png, Modality.IMAGE)
def test_kimi_k3_epd_verifies_content_hash_before_decode():
payload = b"jpeg"
digest = snapshot_media(payload).content_digest
+21
View File
@@ -1,7 +1,10 @@
import unittest
from unittest.mock import patch
from PIL import UnidentifiedImageError
from sglang.srt.models.llava import AutoModel, LlavaForConditionalGeneration
from sglang.srt.multimodal.processors.llava import LlavaImageProcessor
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
@@ -87,5 +90,23 @@ class TestLlavaForConditionalGeneration(CustomTestCase):
self._build_mapping(FakeMapping(ValueError("some other failure")))
class TestLlavaImageProcessor(CustomTestCase):
@patch("sglang.srt.multimodal.processors.llava.load_image")
def test_preprocess_reports_invalid_media_as_client_error(self, mock_load_image):
media_error = UnidentifiedImageError("invalid image payload")
mock_load_image.side_effect = media_error
with self.assertRaisesRegex(
ValueError, "Error while processing image: invalid image payload"
) as raised:
LlavaImageProcessor._preprocess_image_task(
b"invalid",
image_hash=1,
processor=unittest.mock.Mock(),
)
self.assertIs(raised.exception.__cause__, media_error)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,114 @@
import asyncio
import concurrent.futures
import os
import threading
from concurrent.futures.process import BrokenProcessPool
from unittest.mock import Mock
import pytest
from sglang.srt.multimodal.processors.llava import LlavaImageProcessor
from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=1, suite="base-a-test-cpu")
class _BrokenExecutor(concurrent.futures.Executor):
def submit(self, _fn, /, *_args, **_kwargs):
raise BrokenProcessPool("worker exited")
class _BlockingExecutor(concurrent.futures.Executor):
def __init__(self):
self.release = threading.Event()
def submit(self, _fn, /, *_args, **_kwargs):
self.release.wait(timeout=5)
return concurrent.futures.Future()
def _exit_worker_process():
os._exit(1)
def test_llava_replaces_broken_pool_without_replaying_request():
processor = object.__new__(LlavaImageProcessor)
processor.cpu_executor = _BrokenExecutor()
processor._processor = Mock()
processor._replace_broken_cpu_executor = Mock()
with pytest.raises(BrokenProcessPool, match="worker exited"):
asyncio.run(processor._process_single_image(b"image", "pad", None))
processor._replace_broken_cpu_executor.assert_called_once_with(
processor.cpu_executor
)
def test_llava_times_out_blocked_pool_submission_without_freezing_loop(monkeypatch):
monkeypatch.setenv("REQUEST_TIMEOUT", "1")
processor = object.__new__(LlavaImageProcessor)
processor.cpu_executor = _BlockingExecutor()
processor._processor = Mock()
processor._replace_broken_cpu_executor = Mock()
async def run_test():
heartbeat = asyncio.Event()
async def keep_loop_responsive():
await asyncio.sleep(0.01)
heartbeat.set()
heartbeat_task = asyncio.create_task(keep_loop_responsive())
try:
with pytest.raises(asyncio.TimeoutError):
await processor._process_single_image(b"image", "pad", None)
assert heartbeat.is_set()
finally:
processor.cpu_executor.release.set()
await heartbeat_task
asyncio.run(run_test())
processor._replace_broken_cpu_executor.assert_called_once_with(
processor.cpu_executor
)
def test_broken_pool_is_replaced_once_for_concurrent_failures():
processor = object.__new__(LlavaImageProcessor)
failed_executor = Mock()
replacement_executor = Mock()
processor.cpu_executor = failed_executor
processor._cpu_executor_lock = threading.Lock()
processor._create_cpu_executor = Mock(return_value=replacement_executor)
shutdown_called = threading.Event()
failed_executor.shutdown.side_effect = lambda **_kwargs: shutdown_called.set()
processor._replace_broken_cpu_executor(failed_executor)
processor._replace_broken_cpu_executor(failed_executor)
assert processor.cpu_executor is replacement_executor
processor._create_cpu_executor.assert_called_once_with()
assert shutdown_called.wait(timeout=5)
failed_executor.shutdown.assert_called_once_with(wait=False, cancel_futures=True)
def test_replacement_pool_runs_after_real_worker_exit(monkeypatch):
monkeypatch.setenv("SGLANG_CPU_WORKERS", "1")
processor = object.__new__(LlavaImageProcessor)
processor.mm_feature_transport = "cpu"
processor._cpu_executor_lock = threading.Lock()
failed_executor = processor._create_cpu_executor()
processor.cpu_executor = failed_executor
try:
with pytest.raises(BrokenProcessPool):
failed_executor.submit(_exit_worker_process).result(timeout=5)
processor._replace_broken_cpu_executor(failed_executor)
assert processor.cpu_executor.submit(abs, -1).result(timeout=5) == 1
finally:
processor.cpu_executor.shutdown(wait=True, cancel_futures=True)
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))
@@ -1,4 +1,7 @@
import asyncio
import re
import threading
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
import pytest
@@ -81,5 +84,63 @@ def test_moss_vl_accepts_matching_vision_metadata_and_tokens():
assert rope_deltas.shape == (1,)
def test_video_normalization_cleans_sibling_temp_file_on_failure(tmp_path):
processor = _processor()
processor.io_executor = ThreadPoolExecutor(max_workers=2)
temp_path = tmp_path / "normalized.mp4"
created = threading.Event()
def normalize(value):
if value == "good":
temp_path.write_bytes(b"video")
created.set()
return str(temp_path), [str(temp_path)]
assert created.wait(timeout=5)
raise ValueError("invalid video")
processor._normalize_single_video_input = normalize
try:
with pytest.raises(ValueError, match="invalid video"):
asyncio.run(processor._normalize_video_inputs_async(["good", "bad"]))
finally:
processor.io_executor.shutdown()
assert not temp_path.exists()
def test_video_normalization_waits_for_worker_cleanup_when_cancelled(tmp_path):
processor = _processor()
processor.io_executor = ThreadPoolExecutor(max_workers=1)
temp_path = tmp_path / "cancelled.mp4"
created = threading.Event()
finish = threading.Event()
def normalize(_value):
temp_path.write_bytes(b"video")
created.set()
assert finish.wait(timeout=5)
return str(temp_path), [str(temp_path)]
processor._normalize_single_video_input = normalize
async def run():
task = asyncio.create_task(
processor._normalize_video_inputs_async(["cancelled"])
)
assert await asyncio.to_thread(created.wait, 5)
task.cancel()
finish.set()
with pytest.raises(asyncio.CancelledError):
await task
try:
asyncio.run(run())
finally:
finish.set()
processor.io_executor.shutdown()
assert not temp_path.exists()
if __name__ == "__main__":
raise SystemExit(pytest.main([__file__, "-v"]))
@@ -8,11 +8,14 @@ from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=10, suite="base-a-test-cpu")
import base64
import binascii
import io
import unittest
from unittest.mock import MagicMock, patch
import requests
from PIL import Image
from sglang.srt.managers.schedule_batch import Modality
from sglang.srt.multimodal.processors.base_processor import BaseMultimodalProcessor
@@ -62,6 +65,18 @@ class TestBadInputIsClientError(CustomTestCase):
# PIL raises UnidentifiedImageError, an OSError -- not a ValueError.
self._assert_client_error(b"definitely not an image", Modality.IMAGE)
def test_lazy_pil_decode_failure(self):
malformed_png = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLJSwAAAABJRU5ErkJggg=="
)
for media in (malformed_png, Image.open(io.BytesIO(malformed_png))):
with self.subTest(media_type=type(media).__name__):
with self.assertRaisesRegex(
ValueError, "Could not decode image"
) as ctx:
_StubProcessor._load_single_item(media, Modality.IMAGE)
self.assertIsInstance(ctx.exception.__cause__.__cause__, OSError)
def test_undecodable_audio_bytes(self):
# soundfile raises LibsndfileError, a RuntimeError -- not a ValueError.
self._assert_client_error(b"definitely not audio", Modality.AUDIO)
@@ -91,6 +106,14 @@ class TestServerFaultStaysServerError(CustomTestCase):
def test_decoder_oom(self):
self._assert_server_error(MemoryError("out of memory"))
def test_image_source_os_error(self):
with patch(
"sglang.srt.utils.common.get_image_bytes",
side_effect=OSError("too many open files"),
):
with self.assertRaisesRegex(RuntimeError, "too many open files"):
_StubProcessor._load_single_item("file:///image.png", Modality.IMAGE)
class TestClientMediaExceptions(CustomTestCase):
def test_tuple_covers_the_documented_families(self):
@@ -1,9 +1,13 @@
import asyncio
import base64
import io
import unittest
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, replace
from typing import Optional
from PIL import Image
from sglang.srt.managers.schedule_batch import Modality
from sglang.srt.multimodal.cache import MultimodalPreprocessCache, snapshot_media
from sglang.srt.multimodal.media_artifacts import (
@@ -85,6 +89,22 @@ class _Processor(MediaArtifactCacheMixin):
class TestMediaArtifactProcessor(unittest.TestCase):
def test_default_image_decoder_rejects_lazy_pil_failure(self):
malformed_png = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLJSwAAAABJRU5ErkJggg=="
)
processor = MediaArtifactCacheMixin()
processor.gpu_image_decode = False
with self.assertRaisesRegex(ValueError, "Could not decode image"):
processor.decode_media_snapshot(
snapshot_media(malformed_png), Modality.IMAGE
)
lazy_image = Image.open(io.BytesIO(malformed_png))
with self.assertRaisesRegex(ValueError, "Could not decode image"):
snapshot_media(lazy_image)
def test_unknown_model_option_is_part_of_artifact_identity(self):
processor = _Processor()
digest = snapshot_media(b"image").content_digest