main
py 264 lines 10.4 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import contextlib
5 import io
6 import threading
7 from concurrent.futures import ThreadPoolExecutor
8
9 import pybase64
10 import pytest
11 import torch
12
13 from vllm.config import ModelConfig
14 from vllm.exceptions import VLLMValidationError
15 from vllm.multimodal.media import AudioEmbeddingMediaIO, ImageEmbeddingMediaIO
16 from vllm.renderers.embed_utils import safe_load_prompt_embeds
17 from vllm.utils.sparse_utils import check_sparse_tensor_invariants_threadsafe
18
19
20 def _encode_tensor(tensor: torch.Tensor) -> bytes:
21 buffer = io.BytesIO()
22 torch.save(tensor, buffer)
23 buffer.seek(0)
24 return pybase64.b64encode(buffer.read())
25
26
27 def _create_malicious_sparse_tensor() -> torch.Tensor:
28 indices = torch.tensor([[10], [10]])
29 values = torch.tensor([1.0])
30 shape = (3, 3)
31 return torch.sparse_coo_tensor(
32 indices, values, shape, dtype=torch.float32, check_invariants=False
33 )
34
35
36 def _create_valid_dense_tensor(hidden_size: int = 768) -> torch.Tensor:
37 return torch.randn(4, hidden_size, dtype=torch.float32)
38
39
40 @pytest.fixture
41 def model_config():
42 return ModelConfig(
43 model="facebook/opt-125m",
44 tokenizer="facebook/opt-125m",
45 tokenizer_mode="auto",
46 trust_remote_code=False,
47 dtype="float32",
48 seed=0,
49 enable_prompt_embeds=True,
50 )
51
52
53 class TestNegativeControlWithoutRace:
54 """The invalid payload must be rejected even without concurrency."""
55
56 def test_malicious_sparse_rejected_by_prompt_loader(self, model_config):
57 encoded = _encode_tensor(_create_malicious_sparse_tensor())
58 with pytest.raises((RuntimeError, ValueError, VLLMValidationError)):
59 safe_load_prompt_embeds(model_config, encoded)
60
61 def test_malicious_sparse_rejected_by_image_loader(self):
62 io_handler = ImageEmbeddingMediaIO()
63 encoded = _encode_tensor(_create_malicious_sparse_tensor())
64 with pytest.raises((RuntimeError, ValueError)):
65 io_handler.load_base64("", encoded.decode("utf-8"))
66
67 def test_malicious_sparse_rejected_by_audio_loader(self):
68 io_handler = AudioEmbeddingMediaIO()
69 encoded = _encode_tensor(_create_malicious_sparse_tensor())
70 with pytest.raises((RuntimeError, ValueError)):
71 io_handler.load_base64("", encoded.decode("utf-8"))
72
73
74 class TestConcurrentRaceProtection:
75 """Verify the lock prevents the A-enter, B-enter, A-exit, B-load race."""
76
77 def test_forced_interleaving_still_rejects_invalid(self, model_config):
78 """Force the exact interleaving that bypasses the guard without a lock.
79
80 Thread A (benign): enters context, loads valid tensor, exits context.
81 Thread B (malicious): enters context after A, but loads after A exits.
82
83 Without _SPARSE_LOAD_LOCK the flag would be False when B loads.
84 With the lock, B cannot enter until A fully completes.
85 """
86 barrier = threading.Barrier(2, timeout=5)
87 results: dict[str, object] = {}
88
89 valid_encoded = _encode_tensor(_create_valid_dense_tensor())
90 malicious_encoded = _encode_tensor(_create_malicious_sparse_tensor())
91
92 def thread_a_benign():
93 """Enter context, signal B, load, exit."""
94 try:
95 with check_sparse_tensor_invariants_threadsafe():
96 barrier.wait() # signal B that A holds the lock
97 tensor = torch.load(
98 io.BytesIO(pybase64.b64decode(valid_encoded, validate=True)),
99 weights_only=True,
100 map_location=torch.device("cpu"),
101 )
102 results["a_loaded"] = True
103 results["a_tensor_valid"] = isinstance(tensor, torch.Tensor)
104 except threading.BrokenBarrierError:
105 results["a_barrier_broken"] = True
106
107 def thread_b_malicious():
108 """Wait for A to hold the lock, then try to acquire it."""
109 try:
110 barrier.wait() # wait until A is inside the lock
111 with check_sparse_tensor_invariants_threadsafe():
112 tensor = torch.load(
113 io.BytesIO(
114 pybase64.b64decode(malicious_encoded, validate=True)
115 ),
116 weights_only=True,
117 map_location=torch.device("cpu"),
118 )
119 tensor.to_dense()
120 results["b_bypass"] = True
121 except (RuntimeError, ValueError):
122 results["b_rejected"] = True
123 except threading.BrokenBarrierError:
124 results["b_barrier_broken"] = True
125
126 t_a = threading.Thread(target=thread_a_benign)
127 t_b = threading.Thread(target=thread_b_malicious)
128 t_a.start()
129 t_b.start()
130 t_a.join(timeout=10)
131 t_b.join(timeout=10)
132
133 assert results.get("a_loaded") is True
134 assert results.get("a_tensor_valid") is True
135 assert results.get("b_rejected") is True, (
136 "Malicious tensor was NOT rejected under concurrency"
137 )
138 assert results.get("b_bypass") is not True
139
140 def test_concurrent_loads_all_reject_invalid(self, model_config):
141 """Multiple concurrent malicious loads must all be rejected."""
142 malicious_encoded = _encode_tensor(_create_malicious_sparse_tensor())
143 num_workers = 4
144 rejected = []
145 bypassed = []
146
147 def attempt_load(_):
148 try:
149 safe_load_prompt_embeds(model_config, malicious_encoded)
150 bypassed.append(True)
151 except (RuntimeError, ValueError, VLLMValidationError):
152 rejected.append(True)
153
154 with ThreadPoolExecutor(max_workers=num_workers) as pool:
155 list(pool.map(attempt_load, range(num_workers)))
156
157 assert len(rejected) == num_workers
158 assert len(bypassed) == 0
159
160 def test_concurrent_valid_loads_succeed(self, model_config):
161 """Concurrent valid loads must all succeed (no false rejections)."""
162 valid_encoded = _encode_tensor(_create_valid_dense_tensor())
163 num_workers = 4
164 successes = []
165 failures = []
166
167 def attempt_load(_):
168 try:
169 result = safe_load_prompt_embeds(model_config, valid_encoded)
170 successes.append(result.shape)
171 except Exception as e:
172 failures.append(str(e))
173
174 with ThreadPoolExecutor(max_workers=num_workers) as pool:
175 list(pool.map(attempt_load, range(num_workers)))
176
177 assert len(successes) == num_workers
178 assert len(failures) == 0
179
180
181 class TestGlobalFlagRestoration:
182 """The global invariant flag must be correctly restored after use."""
183
184 def test_flag_restored_after_success(self, model_config):
185 initial = torch.sparse.check_sparse_tensor_invariants.is_enabled()
186 valid_encoded = _encode_tensor(_create_valid_dense_tensor())
187
188 safe_load_prompt_embeds(model_config, valid_encoded)
189
190 assert torch.sparse.check_sparse_tensor_invariants.is_enabled() == initial
191
192 def test_flag_restored_after_exception(self, model_config):
193 initial = torch.sparse.check_sparse_tensor_invariants.is_enabled()
194 malicious_encoded = _encode_tensor(_create_malicious_sparse_tensor())
195
196 with pytest.raises((RuntimeError, ValueError, VLLMValidationError)):
197 safe_load_prompt_embeds(model_config, malicious_encoded)
198
199 assert torch.sparse.check_sparse_tensor_invariants.is_enabled() == initial
200
201 def test_flag_restored_after_concurrent_exceptions(self, model_config):
202 """Flag must be restored even when multiple threads raise."""
203 initial = torch.sparse.check_sparse_tensor_invariants.is_enabled()
204 malicious_encoded = _encode_tensor(_create_malicious_sparse_tensor())
205
206 def attempt_load(_):
207 with contextlib.suppress(RuntimeError, ValueError, VLLMValidationError):
208 safe_load_prompt_embeds(model_config, malicious_encoded)
209
210 with ThreadPoolExecutor(max_workers=4) as pool:
211 list(pool.map(attempt_load, range(4)))
212
213 assert torch.sparse.check_sparse_tensor_invariants.is_enabled() == initial
214
215
216 class TestCrossLoaderLockSharing:
217 """All loaders must share the same lock to prevent cross-loader races."""
218
219 def test_prompt_and_image_share_lock(self, model_config):
220 """Prompt and image loaders cannot run their sparse guards
221 concurrently."""
222 valid_prompt = _encode_tensor(_create_valid_dense_tensor())
223 valid_image = _encode_tensor(_create_valid_dense_tensor(hidden_size=10))
224
225 io_handler = ImageEmbeddingMediaIO()
226
227 def load_prompt():
228 safe_load_prompt_embeds(model_config, valid_prompt)
229
230 def load_image():
231 io_handler.load_base64("", valid_image.decode("utf-8"))
232
233 # If both threads could hold the lock simultaneously, the barrier
234 # would succeed. Since the lock serializes them, the barrier will
235 # time out for the second thread (proving mutual exclusion).
236 t1 = threading.Thread(target=load_prompt)
237 t2 = threading.Thread(target=load_image)
238
239 t1.start()
240 t2.start()
241 t1.join(timeout=10)
242 t2.join(timeout=10)
243
244 # The key assertion: both completed (barrier didn't deadlock due to
245 # serialization — one finishes before the other starts), AND invalid
246 # tensors are still rejected.
247 malicious = _encode_tensor(_create_malicious_sparse_tensor())
248 with pytest.raises((RuntimeError, ValueError, VLLMValidationError)):
249 safe_load_prompt_embeds(model_config, malicious)
250 with pytest.raises((RuntimeError, ValueError)):
251 io_handler.load_base64("", malicious.decode("utf-8"))
252
253 def test_all_loaders_use_same_context_manager(self):
254 """Verify all modules reference the same context manager function."""
255 import vllm.multimodal.media.audio as audio_mod
256 import vllm.multimodal.media.image as image_mod
257 import vllm.renderers.embed_utils as embed_mod
258 from vllm.utils.sparse_utils import (
259 check_sparse_tensor_invariants_threadsafe as cm_from_utils,
260 )
261
262 assert embed_mod.check_sparse_tensor_invariants_threadsafe is cm_from_utils
263 assert image_mod.check_sparse_tensor_invariants_threadsafe is cm_from_utils
264 assert audio_mod.check_sparse_tensor_invariants_threadsafe is cm_from_utils