Skip to content

[Bugfix][KV Offload][P2P] Stop DEALER monitor before closing its reader - #63207

Merged
orozery merged 2 commits into
vllm-project:mainfrom
vMaroon:p2p-zmq-dealer-linger
Oct 28, 2026
Merged

orozery merged 2 commits into
vllm-project:mainfrom
vMaroon:p2p-zmq-dealer-linger

Conversation

@vMaroon

@vMaroon vMaroon commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Overview

ZmqConnection.close() closes the monitor's reader while the DEALER can still raise monitor events. libzmq sends those events with a blocking send from its only I/O thread, so the send never returns and every P2P control socket in the engine stops until restart; P2P lookups to all peers then wait out their 30 s deadline before falling back to local prefill. Two paths raise such an event: a DEALER kept alive by its LINGER after its peer stalls, and a DEALER whose TCP connect is still in progress when it is closed (a peer address with no host behind it, as after a pod leaves or a node is lost). This stops the DEALER's monitor before closing its reader. The DEALER keeps the bounded 500 ms LINGER from #63708, so a final DisconnectMsg still reaches the peer.

Found while qualifying the P2P tier for a GLM-5.3 P/D deployment. Rebased on #63708, which bounds the DEALER LINGER at 500 ms; the wedge reproduces on main with that linger.

Claims

  • A peer that stalls for longer than the ZMQ heartbeat timeout (10 s) no longer wedges the P2P control plane; lookups resume once the peer answers again.
  • A request naming a peer address that never answers the SYN no longer wedges the control plane; the request still waits the 30 s handshake deadline, then prefills locally.
  • No behavior change for connections that close normally. A DisconnectMsg sent right before close() still reaches the peer, including on a connect still in progress.

Validation

Unit tests. Each closes one connection, then checks that a new connection on the same transport still delivers a message. A wedged transport is kept referenced until interpreter exit, since terminating its context blocks forever, so a regression fails the test instead of hanging the run.

  • test_close_with_unsent_message_keeps_transport_usable: a message is queued to a listener that never answers the ZMTP greeting.
  • test_close_while_connecting_keeps_transport_usable: the TCP connect is still in progress. A listener with a full accept queue stands in for an address with no host, since Linux drops SYNs to it; the test skips when the kernel completes the connect anyway.

vLLM arm64 CPU CI image (074c83a09a, pyzmq 27.1.0, libzmq 4.3.5) with each arm's vllm/v1/kv_offload and this branch's tests mounted, 3 runs per arm:

test main 51d2a85594 (LINGER 500 ms) head b6c8ba4bd6
close_with_unsent_message fail 3/3 pass 3/3
close_while_connecting fail 3/3 pass 3/3
pytest tests/v1/kv_offload/tiering/p2p    # 326 passed (head b6c8ba4bd6)

Final message on close (same image, two transports, 30 trials: send a message right after connect(), then close(); count trials where the peer receives it within 3 s). First measured by @liranschour in review.

arm delivered
main 51d2a85594 30/30
478d86b21d (disable_monitor() with LINGER 0) 0/30
head b6c8ba4bd6 30/30

Minimal pyzmq repro (pyzmq 27.2.0, libzmq 4.3.5, in the deployment image). One context with a ROUTER and a DEALER monitored with vLLM's fatal-event mask and heartbeat options; the DEALER connects, then is closed; afterwards a raw TCP client checks that the ROUTER still sends its ZMTP greeting.

DEALER peer close sequence ROUTER still answers
address with no host (connect pending) reader closed, then close(linger=0) 0/4
address with no host (connect pending) disable_monitor(), reader closed, close(linger=0) (478d86b21d) 4/4
listener that never speaks ZMTP reader closed, then close(linger=0) 1/1

Deployment check (GLM-5.3 P/D, 4 prefill pods with the P2P tier, libzmq 4.3.5).

  • Before: after a rolling update, 3 of 4 prefill pods stopped all P2P within 5 minutes. On each, the first handshake timed out against a departed pod's IP was followed 8 s later by its peers logging control connection lost; from then on its P2P port accepted TCP but never sent the ZMTP greeting. One request naming a departed IP reproduced it on the fourth pod.
  • With 478d86b21d (disable_monitor() with LINGER 0): all 4 pods at once named a departed IP, 2 rounds. 8/8 requests completed by local prefill (43-51 s, 74-76 s for two cold first requests), all 4 pods kept sending the greeting, and a pull ring right after pulled 29,696 of about 30.4K prompt tokens on 8/8 requests.

Two-process P2P harness (CPU, measured on 42b418aca8, which had LINGER 0 and no disable_monitor(); on head the stall path is covered by disable_monitor(), as close_with_unsent_message shows). Peer A runs a real P2PSecondaryTierManager and ZmqTransport, opens a request to peer B every 200 ms and polls lookup() until it resolves; peer B answers every lookup with MISS (NIXL data plane faked as in the unit tests). B is SIGSTOPed at 5 s and SIGCONTed at 65 s; the run ends at 160 s. 6 runs per arm:

arm runs that recover first answer after SIGCONT lookups started after 100 s that B answers
main 2/6 0.01 s, 9.3 s; never in 4 runs 0/297 in each of the 4 wedged runs, every lookup ends by the 30 s deadline
42b418aca8 6/6 0.05 s, 11.3 s (x4), 30.3 s 297/297 in every run, max wait 46 ms

The harness ran on the 10-17 main plus #51504, #56836, #57139, #58168 and #60374, all merged since. The remaining delay after SIGCONT is a session opened during the stall waiting out VLLM_P2P_HANDSHAKE_TIMEOUT_S (30 s); with it set to 5 s, 3 runs recover in 1.7 s to 4.7 s.

Details

Mechanism in libzmq 4.3.5 (src/socket_base.cpp, src/stream_connecter_base.cpp):

  1. socket_base_t::event() takes _monitor_sync and calls monitor_event(), which sends each event to the monitor's PAIR socket with a blocking zmq_msg_send (no ZMQ_DONTWAIT). These calls run on the I/O thread.
  2. Once the reader PAIR is closed, that send never returns. The context has one I/O thread, so the ROUTER and every other peer's DEALER stop moving messages, and ctx.term() at shutdown hangs.
  3. Stall path: zmq_close hands a DEALER with queued messages to the reaper, which keeps it reconnecting for the LINGER period; each reconnect or handshake failure raises an event. The 500 ms LINGER from [Bugfix][KV Offload][P2P] Never block in ZmqConnection.send() #63708 bounds that window, and an event inside it still blocks the I/O thread (close_with_unsent_message fails on main).
  4. No-host path: a connect still in progress is torn down at close. stream_connecter_base_t::process_term() calls close(), which closes the socket and raises EVENT_CLOSED (in vLLM's mask) before the DEALER's monitor is stopped. This happens with any linger.
  5. disable_monitor() is zmq_socket_monitor(s, NULL, 0), which runs stop_monitor() under _monitor_sync: it closes the internal PAIR and clears _monitor_events, so no event is sent after the reader closes. With the bounded LINGER, a dead session's DEALER stops reconnecting to its peer after 500 ms.
  6. disable_monitor() waits for _monitor_sync, which the I/O thread holds only while sending an event. That send completes: the reader is still open, and its pipe (default HWM) holds far more than the few fatal events a dead connection raises before ZmqTransport.poll() closes it.

Not in scope: a request naming a peer that never answers still waits the 30 s handshake deadline (VLLM_P2P_HANDSHAKE_TIMEOUT_S) before prefilling locally, and nothing backs off from such a peer across requests.


Pull Request Checklist
  • I used vLLM's /pr-checklist skill. (Mandatory for agents, optional for humans).

  • AI assistance was used during the creation of this PR.

  • Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.

  • Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.

  • Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.

  • Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.

@nilig

nilig commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Validated 478d86b21 on GPUs with real engines. Without the PR, every case that removes or restarts a peer freezes the consumer's P2P control plane. With it, none do.

Setup: three single-GPU engines (Qwen3-0.6B) on nightly 8e2beaa3b, NIXL 1.5.0 over mlx5 RDMA. Consumer A, a source B that the fault hits, and a healthy source C. A probe seeds a fresh prefix on B or C every 200 ms and asks A to pull it.

fault on B nightly nightly + #63207
pod force-deleted (address gone, connect pending) A frozen 3/3 0/3
pod deleted gracefully A frozen 1/1 0/3
B's processes killed, restart at same IP A frozen 1/1 0/1
rolling replacement (B2 up, then B deleted) A frozen 1/1 0/1
SIGSTOP 60 s, then SIGCONT 0/3 0/3
no fault / NetworkPolicy partition 0/1 / 0/1 0/1 / 0/1

"Frozen" matches your description: once B goes away, A's ROUTER receives no messages from any peer, A to C pulls drop to near 0, and every lookup ends at the 30 s deadline for the rest of the run (about 190 s). With the PR, no A to C lookup hits the deadline, p50 0.05-0.26 s. On this setup the stall path never wedged, even on the nightly. It was the removal paths (force delete, graceful delete, kill, roll) that froze every time. These runs don't isolate which of the two commits is responsible: I didn't run either alone, and the logs don't show whether a message was still unsent in the DEALER queue at close, which is the stall path's precondition.

With the freeze gone, a second failure shows up: over RDMA, a peer that stalled or was replaced was not usable again during the run. Either registration fails (the UCX rc_gda lane connect fails with Remote I/O error, and NIXL 1.5.0 then loops on INVALID_PARAM), or, after a graceful replacement, the new peer's WRITEs stall without an error. Filed as #63499, fix in #63498. Your ai-dynamo/nixl#2337 removes the INVALID_PARAM loop but not the failed connect.

@vMaroon vMaroon changed the title [Bugfix][KV Offload][P2P] Close P2P DEALERs with LINGER 0 [Bugfix][KV Offload][P2P] Stop DEALER monitor before closing its reader Oct 25, 2026
@vMaroon
vMaroon marked this pull request as ready for review October 25, 2026 04:11

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

self._sockets.dealer.close()
# A lingering DEALER outlives close() and keeps reconnecting to an
# unresponsive peer to deliver its queued messages.
self._sockets.dealer.close(linger=0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: use a short bounded linger instead of linger=0

linger=0 also drops the DisconnectMsg that the send-then-close callers rely on. In _accept_new_peers, the reconnect-backoff branch does:

# Tell the peer, so its lookups fall back now rather than
# at the handshake deadline.
with contextlib.suppress(Exception):
    conn.send({TYPE_KEY: DisconnectMsg.TYPE})
conn.close()

That conn is the inbound DEALER opened in the same poll(), so its TCP connect hasn't finished when close() runs. With linger=0 the queued message is discarded, so the rejected peer waits out VLLM_P2P_HANDSHAKE_TIMEOUT_S (30 s), which is what that comment is there to prevent. session.close() sends the same message the same way, so it's exposed when the session is new or the peer is slow.

I measured this with two ZmqTransports on one host (pyzmq 27.1.0, libzmq 4.3.5). Each trial sends DisconnectMsg and then calls close(), and counts whether the peer's ROUTER receives it within 1–3 s:

close() variant right after connect() on an established connection
main (default linger) 30/30 30/30
this PR (linger=0) 0/30 30/30
disable_monitor() + linger=500 30/30 —

With disable_monitor() + close(linger=500), both new tests pass 3/3 locally. I didn't use the default infinite linger: disable_monitor() with the default linger hangs test_close_with_unsent_message_keeps_transport_usable in 3/3 runs, so some limit is needed.

So I'd suggest something like:

# Bounded so a final DisconnectMsg still flushes, including on a connect
# still in progress, without leaving a DEALER reconnecting to a dead peer.
self._sockets.dealer.close(linger=_CLOSE_LINGER_MS)

with _CLOSE_LINGER_MS = 500 next to the heartbeat constants. close() still returns immediately; only the background teardown waits, for at most 500 ms.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, linger=0 drops it: a message sent right after connect() and then close() arrives 0/30 on 478d86b21d and 30/30 on main. #63708 already sets LINGER to _DEALER_LINGER_MS on every DEALER, so I rebased onto it and dropped the close(linger=0) change; the PR is now only the disable_monitor() call and the two tests. The 500 ms linger alone doesn't stop the wedge: on main 51d2a85594 both new tests fail 3/3. With disable_monitor() they pass 3/3, the final-message check is 30/30, and the P2P suite is 326 passed.

@liranschour liranschour left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. I think that we need to close with linger=500 (wait for 500 ms before closing).
Look at the inline comment below

libzmq sends monitor events with a blocking send from its only I/O
thread. ZmqConnection.close() closed the monitor's reader while the
DEALER could still raise events: EVENT_CLOSED when a connect still in
progress is torn down (a departed pod or lost node), and handshake or
reconnect events while the DEALER lingers to flush queued messages. The
send then never returns and every P2P control socket in the engine
stops until restart. Stop the DEALER's monitor before closing the
reader. The DEALER keeps its bounded LINGER, so a final DisconnectMsg
still reaches the peer.

The close tests keep a wedged transport referenced: garbage collection
would terminate its context, which blocks forever, so a regression hung
the test run instead of failing.

Signed-off-by: Maroon Ayoub <mayoub@redhat.com>
@nilig

nilig commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

This PR is still needed on top of #63708: at b6c8ba4bd it stops the freeze in every peer-removal case we ran (0 of 8 runs frozen), while main without it froze in all 4.

Re-ran the GPU fault cases against b6c8ba4bd (only disable_monitor(), on top of #63708's 500 ms linger). Both arms run nightly 8e2beaa3b with the P2P zmq.py, nixl.py and session.py replaced by the files at the named commit, so both include #63498, #63628, #63664 and #63708, and the only difference is disable_monitor(). Same setup as before: three single-GPU Qwen3-0.6B engines on one H200 node over mlx5 RDMA, consumer A, a source B that the fault hits and a healthy source C, with a probe seeding a fresh prefix on B or C every 200 ms and asking A to pull it.

fault on B main 51d2a8559 this PR b6c8ba4bd
pod force-deleted, replacement started A frozen 1/1: A to C 0/837 pulled, p50 16.7 s A frozen 0/3: A to C 2,534/2,534 pulled, p50 0.09-0.10 s; replacement 1,200/1,200
pod deleted gracefully, replacement started A frozen 1/1: A to C 0/852, p50 16.8 s A frozen 0/3: A to C 2,542/2,542, p50 0.09-0.10 s; replacement 1,200/1,200
processes killed, restart at same IP A frozen 1/1: A to C 0/499, p50 16.3 s A frozen 0/1: A to C 492/492, p50 0.05 s; B 400/400
rolling replacement A frozen 1/1: A to C 92/593, p50 14.5 s A frozen 0/1: A to C 591/591, p50 0.06 s; replacement 400/400
SIGSTOP 60 s, then SIGCONT - A frozen 0/1: A to C 618/618; B 239/239 from SIGCONT + 40 s
no fault - A to C 625/625; B 700/700

So the bounded linger alone does not stop the freeze on GPUs. On 51d2a8559 every removal case freezes A with the same signature as the nightly: A's session to the healthy C times out once, then every reconnect handshake to C times out until the run ends. With disable_monitor() none freeze, and the replacement or restarted peer is pulled from again. This head no longer carries linger=0, so these runs also settle the attribution the earlier ones left open.

@liranschour liranschour left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. With #63708's 500 ms linger on main, this is now just disable_monitor(), and that's the part that fixes the wedge.

Checked at b6c8ba4bd6 on current main (pyzmq 27.1.0, libzmq 4.3.5):

  • test_zmq_transport.py: 22 passed, 3/3 runs.
  • The two new tests against main's zmq.py (500 ms linger, no disable_monitor()): both fail, 3/3.
  • DisconnectMsg sent then close() right after connect(): 30/30 delivered (0/30 at the old head with linger=0); 30/30 on an established connection.
  • tests/v1/kv_offload/tiering/p2p: 327 passed.

My earlier linger=0 thread is addressed by the rebase onto #63708; feel free to resolve it.

@orozery orozery added the ready ONLY add when PR is ready to merge/full CI is needed label Oct 28, 2026
@orozery

orozery commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

/ci run

@github-actions

Copy link
Copy Markdown

✅ @vMaroon, CI is now available for this PR.

  • /ci run starts upstream CI; /amd-ci run starts AMD CI only.
  • Run commands test your branch with every commit currently on its upstream target branch. If it is behind, the bot merges the target branch into it first (pull before pushing again). Append --allow-stale to a run command to test an outdated branch as is, at your own risk.
  • /ci retry retries failed jobs in the CI build for the current PR head. If the current head has no CI build, it starts a new CI build for the current head containing only jobs that failed in the latest earlier CI build for this PR.
  • /amd-ci retry retries failed jobs in AMD CI for the current PR head. Use /amd-ci run when the current head has no AMD CI build.
  • /ci cancel cancels scheduled or running CI builds for this PR branch; /amd-ci cancel does the same for AMD CI only.

@orozery
orozery enabled auto-merge (squash) October 28, 2026 09:01
@github-actions

Copy link
Copy Markdown

✅ Triggered Buildkite CI #97923 for commit 127b733c99b3.

@orozery
orozery merged commit f4fd1ab into vllm-project:main Oct 28, 2026
85 of 86 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working ready ONLY add when PR is ready to merge/full CI is needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants