[Bugfix][NIXL] Release a dead peer's NIXL state without waiting for TTL (#50047)

Signed-off-by: Yannik Hinteregger <37209495+YannikHinteregger@users.noreply.github.com> Co-authored-by: xijiade.aihemaiti <3146335281@qq.com>

YannikHinteregger committed Sep 27, 2026 at 05:30 UTC fff03267dae61d03615225c2f48a06082d842e2d
5 files changed +237 -3
tests/v1/kv_connector/unit/test_nixl_connector.py
+175
@@ -7,6 +7,7 @@ import os
7 import queue
8 import tempfile
9 import textwrap
10 +import threading
11 import time
12 import uuid
13 from collections import defaultdict
@@ -49,6 +50,8 @@ from vllm.distributed.kv_transfer.kv_connector.v1.nixl import (
50 NixlKVConnectorStats,
51 )
52 from vllm.distributed.kv_transfer.kv_connector.v1.nixl.metadata import (
53 + RemoteMeta,
54 + ReqMeta,
55 compute_nixl_compatibility_hash,
56 )
57 from vllm.distributed.kv_transfer.kv_transfer_state import (
@@ -2087,6 +2090,10 @@ def recv_worker():
2090 worker._recving_transfers = defaultdict(list)
2091 worker._failed_recv_reqs = queue.Queue()
2092 worker._recv_failures = set()
2093 + worker._handshake_lock = threading.RLock()
2094 + worker._handshake_futures = {}
2095 + worker._remote_agents = {}
2096 + worker._engine_by_address = {}
2097 worker._replicated_pcp_done_sending = set()
2098 worker._invalid_block_ids = queue.Queue()
2099 worker._pending_recv_notifs = {}
@@ -2681,6 +2688,174 @@ def test_engine_ttl_disabled(default_vllm_config, dist_init):
2688 assert engine_id in worker.dst_xfer_side_handles
2689
2690
2691 +@pytest.mark.cpu_test
2692 +class TestPeerReplacement:
2693 + @pytest.fixture(autouse=True)
2694 + def setup(self, monkeypatch):
2695 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl import base_worker as bw
2696 + from vllm.v1.attention.backends.flash_attn import FlashAttentionBackend
2697 +
2698 + config = create_vllm_config(kv_connector_extra_config={"engine_ttl": 0})
2699 + config.kv_transfer_config.kv_buffer_device = "cpu"
2700 + platform = SimpleNamespace(
2701 + device_type="cpu",
2702 + discover_numa_topology=lambda: [],
2703 + get_nixl_memory_type=lambda: "DRAM",
2704 + is_rocm=lambda: False,
2705 + )
2706 + with (
2707 + patch.object(bw, "NixlWrapper", FakeNixlWrapper),
2708 + patch.object(bw, "current_platform", platform),
2709 + patch.object(bw, "get_tensor_model_parallel_rank", return_value=0),
2710 + patch.object(bw, "get_tensor_model_parallel_world_size", return_value=1),
2711 + patch.object(
2712 + bw, "get_current_attn_backends", return_value=[FlashAttentionBackend]
2713 + ),
2714 + ):
2715 + self.worker = FakeNixlConnectorWorker(config, "local", hand_shake_latency=0)
2716 + self.nixl = self.worker.nixl_wrapper
2717 + self.transport = MagicMock(wraps=self.nixl)
2718 + self.worker.nixl_wrapper = self.transport
2719 + monkeypatch.setattr(
2720 + self.worker._handshake_initiation_executor,
2721 + "submit",
2722 + lambda *args: Future(),
2723 + )
2724 + self._connect("old")
2725 + yield
2726 + self.worker.shutdown()
2727 +
2728 + def _connect(self, engine_id="new", host="localhost", port=1234):
2729 + future = self.worker._ensure_handshake(engine_id, host, port, 1)
2730 + self.worker.REMOTE_ENGINE_ID = engine_id
2731 + self.nixl.REMOTE_AGENT_NAME = engine_id
2732 + future.set_result(self.worker._nixl_handshake(host, port, 1, engine_id))
2733 +
2734 + def _request(self, engine_id="new", blocks=(1,), awaiting_kvs=True):
2735 + metadata = NixlConnectorMetadata()
2736 + metadata.reqs_to_recv[f"{engine_id}-req"] = ReqMeta(
2737 + local_block_ids=(blocks,),
2738 + local_physical_block_ids=(blocks,),
2739 + tp_size=1,
2740 + remote=RemoteMeta(
2741 + ([2],), "localhost", 1234, engine_id, f"{engine_id}-prefill"
2742 + ),
2743 + awaiting_kvs=awaiting_kvs,
2744 + )
2745 + return metadata
2746 +
2747 + def test_cleans_old_peer_after_confirmed_handshake(self):
2748 + self._connect("healthy", host="other-host")
2749 + old_handle = self.worker.dst_xfer_side_handles["old"][0]
2750 + self.worker.start_load_kv(self._request())
2751 + self.worker.get_transfer_results()
2752 + self.transport.remove_remote_agent.assert_not_called()
2753 +
2754 + self._connect()
2755 + self.worker.get_transfer_results()
2756 + self.transport.release_dlist_handle.assert_called_once_with(old_handle)
2757 + self.transport.remove_remote_agent.assert_called_once_with("old")
2758 + assert set(self.worker._remote_agents) == {"new", "healthy"}
2759 + assert self.worker._engine_by_address == {
2760 + ("localhost", 1234): "new",
2761 + ("other-host", 1234): "healthy",
2762 + }
2763 + with pytest.raises(KeyError):
2764 + self.worker.transfer_topo.get_engine_info("old")
2765 +
2766 + self.worker.start_load_kv(NixlConnectorMetadata())
2767 + assert self.worker.get_transfer_results().finished_recving == {"new-req"}
2768 + self.transport.remove_remote_agent.assert_called_once_with("old")
2769 +
2770 + @pytest.mark.parametrize("host,port", [("other-host", 1234), ("localhost", 5678)])
2771 + def test_preserves_other_addresses(self, host, port):
2772 + self._connect(host=host, port=port)
2773 + self.worker.get_transfer_results()
2774 + assert "old" in self.worker._remote_agents
2775 + self.transport.remove_remote_agent.assert_not_called()
2776 +
2777 + def test_preserves_old_peer_when_handshake_fails(self):
2778 + self.worker.start_load_kv(self._request())
2779 + self.worker._handshake_futures["new"].set_exception(
2780 + RuntimeError("handshake failed")
2781 + )
2782 + assert self.worker.get_transfer_results().failed_recving == {"new-req"}
2783 + assert "old" in self.worker._remote_agents
2784 + assert self.worker._engine_by_address == {("localhost", 1234): "old"}
2785 + self.transport.remove_remote_agent.assert_not_called()
2786 +
2787 + @pytest.mark.parametrize(
2788 + "states,failed",
2789 + [
2790 + (["PROC", "DONE"], False),
2791 + (["ERR", "PROC", "DONE"], True),
2792 + (["ERR", "PROC", "ERR"], True),
2793 + ],
2794 + ids=["healthy", "failed-sibling-completes", "failed-sibling-fails"],
2795 + )
2796 + def test_waits_for_reads_before_cleanup_and_failure_reporting(self, states, failed):
2797 + self.worker._recving_metadata.update(self._request("old").reqs_to_recv)
2798 + self.worker._recving_transfers["old-req"] = [1, 2] if failed else [1]
2799 + self.transport.check_xfer_state.side_effect = states
2800 + self._connect()
2801 +
2802 + result = self.worker.get_transfer_results()
2803 + assert result.finished_recving == result.failed_recving == set()
2804 + assert self.worker.get_block_ids_with_load_errors() == set()
2805 + self.transport.remove_remote_agent.assert_not_called()
2806 +
2807 + result = self.worker.get_transfer_results()
2808 + assert result.finished_recving == {"old-req"}
2809 + assert result.failed_recving == ({"old-req"} if failed else set())
2810 + assert self.worker.get_block_ids_with_load_errors() == (
2811 + {1} if failed else set()
2812 + )
2813 + assert self.transport.release_xfer_handle.call_count == (2 if failed else 1)
2814 + self.transport.remove_remote_agent.assert_called_once_with("old")
2815 +
2816 + def test_waits_for_queued_notification(self):
2817 + metadata = self._request("old", blocks=())
2818 + self.worker._recving_metadata.update(metadata.reqs_to_recv)
2819 + self.worker._background_nixl_handshake(
2820 + "old-req", "old", metadata.reqs_to_recv["old-req"]
2821 + )
2822 + self._connect()
2823 + self.worker.get_transfer_results()
2824 + self.transport.remove_remote_agent.assert_not_called()
2825 +
2826 + self.worker.start_load_kv(NixlConnectorMetadata())
2827 + self.transport.send_notif.assert_called_once_with(
2828 + "old", notif_msg=b"old-prefill:1"
2829 + )
2830 + result = self.worker.get_transfer_results()
2831 + assert result.finished_recving == {"old-req"}
2832 + assert result.failed_recving == set()
2833 + self.transport.remove_remote_agent.assert_called_once_with("old")
2834 +
2835 + def test_waits_for_other_handshakes(self):
2836 + self.worker._ensure_handshake("other", "other-host", 1234, 1)
2837 + self._connect()
2838 + self.worker.get_transfer_results()
2839 + self.transport.remove_remote_agent.assert_not_called()
2840 +
2841 + self._connect("other", host="other-host")
2842 + self.worker.get_transfer_results()
2843 + self.transport.remove_remote_agent.assert_called_once_with("old")
2844 +
2845 + def test_rehandshakes_queued_request_for_released_engine(self):
2846 + # A request queued after its engine's handshake, but drained only after
2847 + # that engine was replaced and released, must not read from it.
2848 + metadata = self._request("old", blocks=(), awaiting_kvs=False)
2849 + self.worker._ready_requests.put(("old-req", metadata.reqs_to_recv["old-req"]))
2850 + self._connect()
2851 + self.worker.get_transfer_results()
2852 + self.transport.remove_remote_agent.assert_called_once_with("old")
2853 +
2854 + self.worker.start_load_kv(NixlConnectorMetadata())
2855 + assert "old" in self.worker._handshake_futures
2856 + self.transport.send_notif.assert_not_called()
2857 +
2858 +
2859 def test_transfer_topology_unregister():
2860 """TransferTopology.unregister_remote_engine removes the engine."""
2861 from vllm.v1.attention.backends.flash_attn import FlashAttentionBackend
tests/v1/kv_connector/unit/test_nixl_connector_hma.py
+5
@@ -4,6 +4,7 @@
4
5 import gc
6 import queue
7 +import threading
8 from types import SimpleNamespace
9 from unittest.mock import MagicMock, patch
10
@@ -54,6 +55,9 @@ def region_pull_worker():
55 worker.enable_permute_local_kv = False
56 worker.enable_heterogeneous_attn_post_process = False
57 worker._engine_last_active = {}
58 + worker._handshake_lock = threading.RLock()
59 + worker._handshake_futures = {}
60 + worker._transfer_layer_group_ids = ()
61 worker._bidirectional_kv_xfer_enabled = False
62 worker._recving_transfers = {}
63 worker.use_mla, worker._has_mamba = True, False
@@ -94,6 +98,7 @@ def region_pull_worker():
98 worker.dst_num_blocks = {"P": 100, "D": 100}
99 worker.dst_region_num_blocks = {"P": [100, 100], "D": [100, 100]}
100 worker._remote_agents = {"P": {(0, 0): "P-rank0"}}
101 + worker._engine_by_address = {("localhost", 1): "P"}
102 worker._read_blocks_mixed = MagicMock()
103 worker.nixl_wrapper = MagicMock()
104 return worker
tests/v1/kv_connector/unit/test_nixl_push_connector.py
+1
@@ -395,6 +395,7 @@ class _StubWriterWorker(NixlPushConnectorWorker):
395 w._group_spec_types = (FullAttentionSpec,)
396 w._engine_ttl = 0.0
397 w._engine_last_active = {}
398 + w._engine_by_address = {}
399
400 # Track _do_start_push_kv invocations.
401 calls: list[tuple[str, Any, dict[str, Any]]] = []
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_worker.py
+48 -2
@@ -839,6 +839,8 @@ class NixlBaseConnectorWorker:
839 self._engine_ttl: float = vllm_config.kv_transfer_config.get_from_extra_config(
840 "engine_ttl", 3600.0
841 )
842 + # Map of pull peer (host, port) -> engine_id currently serving it.
843 + self._engine_by_address: dict[tuple[str, int], EngineId] = {}
844
845 self.model_config = vllm_config.model_config
846
@@ -1300,6 +1302,7 @@ class NixlBaseConnectorWorker:
1302 self._remote_agents[eid] = remote_agents
1303 self._engine_clock_offset[eid] = clock_offset
1304 self._engine_last_active[eid] = time.perf_counter()
1305 + self._track_remote_engine_replacement(eid, host, port)
1306 except Exception as e:
1307 self._log_failure(
1308 failure_type="handshake_setup_failed",
@@ -2953,6 +2956,7 @@ class NixlBaseConnectorWorker:
2956 # Handle timeout to avoid stranding blocks on remote.
2957 self._reap_expired_send_leases(done_sending)
2958
2959 + self._cleanup_replaced_remote_engines()
2960 return KVConnectorTransferResults(
2961 finished_sending=done_sending,
2962 finished_recving=done_recving,
@@ -3541,14 +3545,53 @@ class NixlBaseConnectorWorker:
3545 and meta.remote is not None
3546 }
3547
3548 + def _track_remote_engine_replacement(
3549 + self, engine_id: EngineId, host: str, port: int
3550 + ) -> None:
3551 + """Record the engine currently serving a pull peer address."""
3552 + # TODO: Also handle push mode, which handshakes in both directions.
3553 + if self._TRANSFER_MODE != "pull":
3554 + return
3555 + previous = self._engine_by_address.get((host, port))
3556 + self._engine_by_address[(host, port)] = engine_id
3557 + if previous is None or previous == engine_id:
3558 + return
3559 + logger.info(
3560 + "Remote engine at %s:%d changed from %s to %s.",
3561 + host,
3562 + port,
3563 + previous,
3564 + engine_id,
3565 + )
3566 +
3567 + def _cleanup_replaced_remote_engines(self) -> None:
3568 + """Release replaced pull peers once requests and handshakes have drained."""
3569 + if self._TRANSFER_MODE != "pull":
3570 + return
3571 + with self._handshake_lock:
3572 + if self._handshake_futures:
3573 + return
3574 + replaced = self._remote_agents.keys() - self._engine_by_address.values()
3575 + if not replaced:
3576 + return
3577 + # _recving_metadata is only accessed from this (main) thread.
3578 + busy = {
3579 + meta.remote.engine_id
3580 + for meta in self._recving_metadata.values()
3581 + if meta.remote is not None
3582 + }
3583 + for engine_id in replaced - busy:
3584 + self._cleanup_remote_engine(engine_id, log_eviction=False)
3585 + logger.info("Released NIXL state for replaced remote engine %s.", engine_id)
3586 +
3587 def _cleanup_remote_engine(
3588 self, engine_id: EngineId, *, log_eviction: bool = True
3589 ) -> None:
3590 """Remove all state for a single remote engine.
3591
3592 Releases NIXL resources (dlist handles, remote agents) and clears
3550 - all per-engine data structures. Used by both TTL eviction and
3551 - shutdown.
3593 + all per-engine data structures. Used by TTL eviction, replaced peer
3594 + cleanup and shutdown.
3595 """
3596 assert engine_id in self._remote_agents
3597
@@ -3558,6 +3601,9 @@ class NixlBaseConnectorWorker:
3601 # Pop under the handshake lock; NIXL teardown stays outside it.
3602 with self._handshake_lock:
3603 agents = self._remote_agents.pop(engine_id)
3604 + for address, eid in list(self._engine_by_address.items()):
3605 + if eid == engine_id:
3606 + del self._engine_by_address[address]
3607 for agent_name in agents.values():
3608 self.nixl_wrapper.remove_remote_agent(agent_name)
3609
vllm/distributed/kv_transfer/kv_connector/v1/nixl/pull_worker.py
+8 -1
@@ -84,7 +84,14 @@ class NixlPullConnectorWorker(NixlBaseConnectorWorker):
84
85 # Start transfers for requests whose handshakes have now finished.
86 while not self._ready_requests.empty():
87 - self._read_blocks_for_req(*self._ready_requests.get_nowait())
87 + req_id, meta = self._ready_requests.get_nowait()
88 + assert meta.remote is not None
89 + if meta.remote.engine_id not in self._remote_agents:
90 + # The engine was released after its handshake completed, so
91 + # handshake again. This fails if the engine is gone.
92 + self._background_nixl_handshake(req_id, meta.remote.engine_id, meta)
93 + continue
94 + self._read_blocks_for_req(req_id, meta)
95
96 if self.pcp_rank > 0 and not self.pcp_dcp_sharded:
97 # Replicated-KV PCP: only PCP rank 0 serves the KV, so this rank