[Bugfix][NIXL] Don't assert when a failed transfer is cleaned up twice (#54518)

Signed-off-by: Yizheng Jiao <jyizheng@gmail.com>
Signed-off-by: NickLucche <nicolo.lucchesi@mistral.ai>
Signed-off-by: Nick Hill <nickhill123@gmail.com>
Co-authored-by: NickLucche <nicolo.lucchesi@mistral.ai>
Co-authored-by: Nick Hill <nickhill123@gmail.com>
This commit is contained in:
Yizheng Jiao
2026-09-04 23:20:32 +00:00
committed by GitHub
co-authored by NickLucche Nick Hill
parent 685074cd61
commit eb74fbb3e7
2 changed files with 94 additions and 5 deletions
@@ -2874,6 +2874,87 @@ def test_failed_request_skips_kv_postprocessing(
assert invalid_blocks == {1, 2, 3}
@patch(
"vllm.distributed.kv_transfer.kv_connector.v1.nixl.base_worker.NixlWrapper",
FailingNixlWrapper,
)
def test_handles_failing_in_separate_polls_do_not_kill_the_engine(
default_vllm_config, dist_init
):
"""A request whose handles fail in different polls must not crash the engine.
One transfer handle is created per remote rank, and when a peer goes away
they do not all fail in the same poll: one errors while another is still
PROC. The first failure reports the request via _failed_recv_reqs and
get_finished() pops its metadata; when the remaining handles fail in a
later poll, the request must be cleaned up without being reported again.
Reporting twice kills the EngineCore: the scheduler's assert in
_update_from_kv_xfer_finished only expects a finished recv for a request
still waiting for KVs, having moved this one out of
WAITING_FOR_REMOTE_KVS to recompute locally after the first report.
"""
vllm_config = create_vllm_config()
connector = NixlConnector(
vllm_config, KVConnectorRole.WORKER, make_kv_cache_config(block_size=16)
)
connector.connector_worker = FakeNixlConnectorWorker(
vllm_config, connector.engine_id, hand_shake_latency=0
)
worker = connector.connector_worker
request_id = "test_two_handles_failing_in_separate_polls"
metadata = NixlConnectorMetadata()
metadata.add_new_req_to_recv(
request_id=request_id,
local_block_ids=([1, 2, 3],),
kv_transfer_params={
"remote_block_ids": ([4, 5, 6],),
"remote_engine_id": FakeNixlConnectorWorker.REMOTE_ENGINE_ID,
"remote_request_id": f"prefill-{request_id}",
"remote_host": "localhost",
"remote_port": 1234,
"remote_tp_size": 1,
},
)
connector.bind_connector_metadata(metadata)
dummy_ctx = ForwardContext(no_compile_layers={}, attn_metadata={}, slot_mapping={})
connector.start_load_kv(dummy_ctx)
connector.bind_connector_metadata(NixlConnectorMetadata())
time.sleep(0.1)
connector.start_load_kv(dummy_ctx)
# Give the request a second handle, as a second remote rank would.
handles = worker._recving_transfers[request_id]
assert len(handles) == 1
first, second = handles[0], object()
worker._recving_transfers[request_id] = [first, second]
# Poll 1: the first handle has failed, the second is still running.
def first_failed(handle):
return "ERR" if handle is first else "PROC"
with patch.object(
worker.nixl_wrapper, "check_xfer_state", side_effect=first_failed
):
_, done_recving = connector.get_finished(finished_req_ids=set())
assert request_id in done_recving
assert request_id not in worker._recving_metadata
assert worker._recving_transfers[request_id] == [second]
# Poll 2: the second handle fails too. The request was already reported,
# so it must not be reported a second time: the scheduler has since moved
# it out of WAITING_FOR_REMOTE_KVS to recompute locally, and asserts on a
# finished recv for a request in that state. Its remaining handles are
# still released and the transfer entry removed.
with patch.object(worker.nixl_wrapper, "check_xfer_state", return_value="ERR"):
_, done_recving = connector.get_finished(finished_req_ids=set())
assert request_id not in done_recving
assert request_id not in worker._recving_transfers
def _set_test_speculative_config(
vllm_config,
*,
@@ -2521,7 +2521,11 @@ class NixlBaseConnectorWorker:
if not in_progress:
# Only report request as completed when all transfers are done.
done_req_ids.add(req_id)
# A request failed in an earlier poll was already reported via
# _failed_recv_reqs and its metadata popped by get_finished();
# don't report it again, just drop the remaining handles.
if req_id in self._recving_metadata:
done_req_ids.add(req_id)
del transfers[req_id]
else:
transfers[req_id] = in_progress
@@ -2536,11 +2540,15 @@ class NixlBaseConnectorWorker:
req_id: The request ID.
handle: The transfer handle.
"""
# Use .get() here as the metadata cleanup is handled by get_finished()
# (multi-read) One handle is created per remote rank, and they do not
# all fail in the same _pop_done_transfers poll. The request is
# reported failed on the first one, which pops its metadata in
# get_finished(); on the later failures only the handle cleanup is left.
# TODO (NickLucche) handle failed transfer for HMA.
if (meta := self._recving_metadata.get(req_id)) and not self._is_hma_required:
self._invalid_block_ids.put(set(meta.local_block_ids[0]))
self._failed_recv_reqs.put(req_id)
if (meta := self._recving_metadata.get(req_id)) is not None:
if not self._is_hma_required:
self._invalid_block_ids.put(set(meta.local_block_ids[0]))
self._failed_recv_reqs.put(req_id)
if handle is not None:
self.nixl_wrapper.release_xfer_handle(handle)
self.xfer_stats.record_failed_transfer()