1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+"""
5
+Test for the fix in PR #29987: Eagerly abort cancelled final-step requests.
6
+
7
+This test verifies that when a request is aborted during its final execution
8
+step (when it would naturally complete), it is properly marked as aborted
9
+rather than being treated as normally completed.
10
+
11
+The test uses a dummy KV connector to verify that the connector receives
12
+the correct finish status (FINISHED_ABORTED, not FINISHED_LENGTH_CAPPED).
13
+"""
14
+
15
+import asyncio
16
+import tempfile
17
+import time
18
+from pathlib import Path
19
+from typing import Any
20
+from unittest.mock import patch
21
+
22
+import pytest
23
+
24
+from vllm import SamplingParams
25
+from vllm.config import KVTransferConfig, VllmConfig
26
+from vllm.distributed.kv_transfer.kv_connector.factory import KVConnectorFactory
27
+from vllm.distributed.kv_transfer.kv_connector.v1.base import (
28
+ KVConnectorBase_V1,
29
+ KVConnectorMetadata,
30
+ KVConnectorRole,
31
+)
32
+from vllm.engine.arg_utils import AsyncEngineArgs
33
+from vllm.platforms import current_platform
34
+from vllm.sampling_params import RequestOutputKind
35
+from vllm.utils.torch_utils import set_default_torch_num_threads
36
+from vllm.v1.core.sched.output import SchedulerOutput
37
+from vllm.v1.engine.async_llm import AsyncLLM
38
+from vllm.v1.kv_cache_interface import KVCacheConfig
39
+from vllm.v1.request import Request
40
+
41
+if not current_platform.is_cuda():
42
+ pytest.skip(reason="V1 currently only supported on CUDA.", allow_module_level=True)
43
+
44
+TEXT_PROMPT = "Hello"
45
+
46
+
47
+class DummyKVConnectorMetadata(KVConnectorMetadata):
48
+ """Dummy metadata for the test connector."""
49
+
50
+ def __init__(self):
51
+ self.requests: list = []
52
+
53
+
54
+class DummyKVConnector(KVConnectorBase_V1):
55
+ """
56
+ Dummy KV connector that captures request finish statuses to a file.
57
+ This is used to verify the fix - without the fix, a request aborted
58
+ during its final step would be captured as FINISHED_LENGTH_CAPPED
59
+ instead of FINISHED_ABORTED.
60
+
61
+ The connector runs in a separate process, so we write statuses to a file
62
+ that can be read by the test process.
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ vllm_config: VllmConfig,
68
+ role: KVConnectorRole,
69
+ kv_cache_config: KVCacheConfig | None = None,
70
+ ):
71
+ super().__init__(vllm_config, role, kv_cache_config)
72
+ # Get the status file path from extra config
73
+ extra_config = vllm_config.kv_transfer_config.kv_connector_extra_config or {}
74
+ self.status_file = extra_config.get("status_file")
75
+ # Log that we were initialized
76
+ if self.status_file:
77
+ try:
78
+ with open(self.status_file, "a") as f:
79
+ f.write(f"INIT:{role.name}\n")
80
+ except Exception:
81
+ pass
82
+
83
+ def get_num_new_matched_tokens(
84
+ self,
85
+ request: Request,
86
+ num_computed_tokens: int,
87
+ ) -> tuple[int | None, bool]:
88
+ return (0, False)
89
+
90
+ def update_state_after_alloc(
91
+ self,
92
+ request: Request,
93
+ blocks: Any,
94
+ num_external_tokens: int,
95
+ ):
96
+ pass
97
+
98
+ def build_connector_meta(
99
+ self, scheduler_output: SchedulerOutput
100
+ ) -> KVConnectorMetadata:
101
+ return DummyKVConnectorMetadata()
102
+
103
+ def request_finished(
104
+ self,
105
+ request: Request,
106
+ block_ids: list[int],
107
+ ) -> tuple[bool, dict[str, Any] | None]:
108
+ """Capture the request status when finished by writing to a file."""
109
+ if self.status_file:
110
+ try:
111
+ with open(self.status_file, "a") as f:
112
+ # Write the status name (e.g., "FINISHED_ABORTED")
113
+ f.write(f"{request.status.name}\n")
114
+ except Exception as e:
115
+ # Log but don't fail - this is just test instrumentation
116
+ print(f"[DummyKVConnector] Failed to write status: {e}")
117
+ return False, None
118
+
119
+ def start_load_kv(self, forward_context: Any, **kwargs: Any) -> None:
120
+ pass
121
+
122
+ def wait_for_layer_load(self, layer_name: str) -> None:
123
+ pass
124
+
125
+ def save_kv_layer(
126
+ self,
127
+ layer_name: str,
128
+ kv_layer: Any,
129
+ attn_metadata: Any,
130
+ **kwargs: Any,
131
+ ) -> None:
132
+ pass
133
+
134
+ def wait_for_save(self):
135
+ pass
136
+
137
+
138
+# Register the dummy connector
139
+KVConnectorFactory.register_connector(
140
+ "DummyKVConnector", __name__, DummyKVConnector.__name__
141
+)
142
+
143
+
144
+@pytest.mark.parametrize("async_scheduling", [False, True])
145
+@pytest.mark.asyncio
146
+async def test_abort_during_final_step(async_scheduling: bool):
147
+ """
148
+ Test that a request aborted during its final execution step is treated as
149
+ aborted rather than completed.
150
+
151
+ This test:
152
+ 1. Monkeypatches execute_model to wait for a file to be deleted
153
+ 2. Configures a dummy KV connector to capture finish statuses
154
+ 3. Starts a request with max_tokens=1 (will complete on first decode step)
155
+ 4. Aborts the request, then deletes the file to unblock execute_model
156
+ 5. Verifies the KV connector received FINISHED_ABORTED not FINISHED_LENGTH_CAPPED
157
+
158
+ See https://github.com/vllm-project/vllm/pull/29987.
159
+
160
+ Without the fix, the KV connector would see FINISHED_LENGTH_CAPPED because
161
+ update_from_output() would mark the request as completed before processing
162
+ the abort. This causes KV cache blocks to not be freed properly in
163
+ disaggregated prefill scenarios.
164
+
165
+ With the fix, _process_aborts_queue() runs before update_from_output(), so the
166
+ abort takes precedence and the KV connector sees FINISHED_ABORTED.
167
+ """
168
+
169
+ # Create three temporary files:
170
+ # 1. ready_file: deleted by execute_model to signal it has started
171
+ # 2. block_file: execute_model waits for this to be deleted
172
+ # 3. status_file: KV connector writes finish statuses here
173
+ with tempfile.NamedTemporaryFile(delete=False) as f:
174
+ ready_file = Path(f.name)
175
+ with tempfile.NamedTemporaryFile(delete=False) as f2:
176
+ block_file = Path(f2.name)
177
+ with tempfile.NamedTemporaryFile(delete=False, mode="w") as f3:
178
+ status_file = Path(f3.name)
179
+
180
+ try:
181
+ # Get the original execute_model method
182
+ from vllm.v1.worker.gpu_worker import Worker
183
+
184
+ original_execute_model = Worker.execute_model
185
+
186
+ def execute_model_with_wait(self, scheduler_output):
187
+ # Signal that execute_model has been called by deleting ready_file
188
+ if ready_file.exists():
189
+ ready_file.unlink()
190
+
191
+ # Wait for the block file to be deleted (triggered from test after abort)
192
+ # This runs in the worker process (after fork), so we poll the filesystem
193
+ while block_file.exists():
194
+ time.sleep(0.01)
195
+ return original_execute_model(self, scheduler_output)
196
+
197
+ # Patch execute_model to inject the wait
198
+ # This happens before the worker process is forked, so the patch applies there
199
+ with patch.object(Worker, "execute_model", execute_model_with_wait):
200
+ request_id = "test-abort-final-step"
201
+
202
+ # Configure engine with dummy KV connector
203
+ # Pass the status file path so the connector can write to it
204
+ kv_transfer_config = KVTransferConfig(
205
+ kv_connector="DummyKVConnector",
206
+ kv_role="kv_both",
207
+ kv_connector_extra_config={"status_file": str(status_file)},
208
+ )
209
+ engine_args = AsyncEngineArgs(
210
+ model="meta-llama/Llama-3.2-1B-Instruct",
211
+ enforce_eager=True,
212
+ async_scheduling=async_scheduling,
213
+ kv_transfer_config=kv_transfer_config,
214
+ )
215
+
216
+ with set_default_torch_num_threads(1):
217
+ engine = AsyncLLM.from_engine_args(engine_args)
218
+
219
+ try:
220
+ # Create a request that will complete after just 1 token
221
+ sampling_params = SamplingParams(
222
+ max_tokens=1,
223
+ ignore_eos=True,
224
+ output_kind=RequestOutputKind.DELTA,
225
+ )
226
+
227
+ # Start generation in a task
228
+ outputs = []
229
+
230
+ async def generate():
231
+ async for output in engine.generate(
232
+ request_id=request_id,
233
+ prompt=TEXT_PROMPT,
234
+ sampling_params=sampling_params,
235
+ ):
236
+ outputs.append(output)
237
+
238
+ gen_task = asyncio.create_task(generate())
239
+
240
+ # Wait for execute_model to signal it has started (with timeout)
241
+ timeout = 5.0 # 5 second timeout
242
+ start_time = time.time()
243
+ while ready_file.exists():
244
+ if time.time() - start_time > timeout:
245
+ raise TimeoutError(
246
+ "Timeout waiting for execute_model to start. "
247
+ "The monkeypatch may not be working correctly, "
248
+ "for example if spawn was used instead of fork."
249
+ )
250
+ await asyncio.sleep(0.01)
251
+
252
+ # Abort the request while execute_model is blocked
253
+ await engine.abort(request_id)
254
+
255
+ # Now unblock execute_model by deleting the file
256
+ # The abort should be processed before the model output
257
+ block_file.unlink()
258
+
259
+ # Wait for generation to complete
260
+ await gen_task
261
+
262
+ # Give the scheduler a moment to finish cleanup
263
+ await asyncio.sleep(0.1)
264
+
265
+ # Verify we got output
266
+ assert len(outputs) > 0, "Should have received at least one output"
267
+
268
+ # The final output should have finish_reason="abort"
269
+ final_output = outputs[-1]
270
+ assert final_output.finished, (
271
+ "Final output should be marked as finished"
272
+ )
273
+ assert final_output.outputs[0].finish_reason == "abort", (
274
+ f"Expected finish_reason='abort' but got "
275
+ f"'{final_output.outputs[0].finish_reason}'. "
276
+ )
277
+
278
+ with open(status_file) as f4:
279
+ status_lines = f4.read().strip().split("\n")
280
+ # Filter for actual finish statuses (not INIT or empty lines)
281
+ captured_statuses = [
282
+ line
283
+ for line in status_lines
284
+ if line and line.startswith("FINISHED_")
285
+ ]
286
+
287
+ assert len(captured_statuses) >= 1, (
288
+ f"Expected at least 1 captured finish status, got "
289
+ f"{len(captured_statuses)}. File content: {status_lines}"
290
+ )
291
+
292
+ assert "FINISHED_ABORTED" in captured_statuses, (
293
+ f"KV connector should see FINISHED_ABORTED but got "
294
+ f"{captured_statuses}. "
295
+ )
296
+
297
+ # Verify cleanup
298
+ assert not engine.output_processor.has_unfinished_requests()
299
+
300
+ finally:
301
+ # Shutdown the engine
302
+ engine.shutdown()
303
+
304
+ finally:
305
+ # Clean up temporary files if they still exist
306
+ if ready_file.exists():
307
+ ready_file.unlink()
308
+ if block_file.exists():
309
+ block_file.unlink()
310
+ if status_file.exists():
311
+ status_file.unlink()