Skip to content

[Bugfix][NIXL] Harden handshake listener against bad requests - #54757

Open
jdonkervliet wants to merge 2 commits into
vllm-project:mainfrom
jdonkervliet:jesse/nixl-handshake-hardening
Open

jdonkervliet wants to merge 2 commits into
vllm-project:mainfrom
jdonkervliet:jesse/nixl-handshake-hardening

Conversation

@jdonkervliet

@jdonkervliet jdonkervliet commented Sep 1, 2026 •

Copy link
Copy Markdown

Purpose

The prefill-side NIXL handshake listener (_nixl_handshake_listener in base_scheduler.py) is a single background thread, per engine, that lives for the whole process and answers metadata queries from every decode worker that needs to pull KV cache from it. Its only exception handling covered the idle-poll timeout; any exception raised while actually processing a received message killed the thread silently, with nothing to restart or report it.

This fails whenever anything reaches the side-channel port that isn't a well-formed request from a real REQ client: a malformed message, a request for a pp/tp rank this engine doesn't serve, or a peer that doesn't speak the REQ/ROUTER envelope (a REQ socket always auto-adds an empty delimiter frame; anything that doesn't, such as a bare DEALER socket, produces a different frame count on the ROUTER side). Once the thread dies, every subsequent handshake against that engine times out and fails for every decode worker until the process is restarted: a single bad request causing a lasting, silent, engine-wide DoS.

This isn't tied to an existing filed issue. It was found via production investigation, reproduced locally, and checked against vllm-project/vllm (issues, PRs, current main) for existing work on this listener's exception handling; nothing addresses it. The closest related work, #48633 and #49238 / #50047, covers different NIXL reliability gaps (push-mode handshake latency, stale peer state after a restart), not this one.

Changes:

  • base_scheduler.py: move the frame unpacking and message handling into a guarded block, narrowed to the concrete failure modes a bad request can trigger (msgspec.DecodeError, ValueError/TypeError, KeyError, zmq.ZMQError). A bad request is now logged (with the raw bytes that triggered it, safely escaped and capped) and dropped, instead of taking the whole side channel down. Anything outside that list still crashes the thread loudly, since that's a genuine bug rather than a bad peer.
  • base_worker.py: wrap the decode-side handshake timeout (zmq.error.Again) with the remote address/rank and a pointer to the existing side-channel probe script (tests/v1/kv_connector/nixl_integration/nixl_side_channel_probe.py), since ZMQ hides the underlying connection state and the bare timeout gives no way to triage which of several possible causes it was.
  • tests/v1/kv_connector/unit/test_nixl_connector.py: two new regression tests (test_handshake_listener_survives_bad_envelope, test_handshake_listener_survives_malformed_content) that run the real listener against a live socket and a stray non-REQ client. Verified both fail against the unpatched listener (thread dies) and pass against the fix.

AI assistance was used to investigate, reproduce, and implement this fix.

Test Plan

  • ruff check / ruff format --check / pyright on changed files.
  • python -m pytest tests/v1/kv_connector/unit/test_nixl_connector.py -v (full file).
  • python -m pytest tests/v1/kv_connector/unit/test_bidirectional_kv_transfer.py -v (full file; calls _nixl_handshake and _nixl_handshake_listener directly).
  • Live reproduction: ran the actual (unpatched) _nixl_handshake_listener against a real ROUTER socket, connected with a DEALER client (no REQ envelope) to reproduce the crash, then reran the same scenario against the patched listener to confirm it survives and keeps serving; now captured permanently as the two new regression tests above.

Test Result

  • ruff/pyright: clean (0 errors).
  • Full suites, run on NVIDIA GPU hardware:
    python -m pytest tests/v1/kv_connector/unit/test_nixl_connector.py tests/v1/kv_connector/unit/test_bidirectional_kv_transfer.py -v
    ================= 140 passed, 16 warnings in 207.38s (0:03:27) =================
    
  • New regression tests confirmed to fail against the unpatched listener (ValueError/msgspec.DecodeError, thread dies) and pass against the fix.

Essential Elements of an Effective PR Description Checklist
  • The purpose of the PR.
  • The test plan, with test commands.
  • The test results, including full-suite results on GPU hardware.
  • (N/A) Documentation update.

@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.

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment /ci run for upstream CI or /amd-ci run for AMD CI only whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use the corresponding /ci run, /ci retry, and /ci cancel commands, or their /amd-ci variants. New commits do not start upstream CI automatically.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

@mergify mergify Bot added bug Something isn't working kv-connector labels Sep 1, 2026
# for the lifetime of the process. Anything outside this
# list is a genuine bug and should still crash the thread
# loudly rather than be swallowed here.
logger.exception(

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.

stop_event is only checked after a receive timeout. If a client keeps sending bad requests, the listener never reaches that check, so shutdown() can block on join(). I reproduced this with a DEALER continuously sending bad frames. Could we check stop_event on every loop iteration?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I also reproduced this and added a test to catch this behavior. stop_event is now checked every loop iteration, which fixes the problem.

@jdonkervliet

jdonkervliet commented Sep 2, 2026 via email

Copy link
Copy Markdown
Author

@mergify

mergify Bot commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @jdonkervliet.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@localityv-hash

Copy link
Copy Markdown

We hit this in production. A stray frame killed the handshake listener with not enough values to unpack (expected 3, got 2) on three different model deployments. In every case it was a single unexpected two-part message on the listener's port.

We backported this PR onto a recent main and replayed the frames our deployments had received at a real NixlBaseConnectorScheduler listener bound to TCP:

  • Before the PR, the listener thread dies on the first frame.
  • With it, every frame is logged and dropped, and a genuine GET_META_MSG request afterwards still gets its reply.

The three tests in this PR pass in our image. We had to run them from a file of their own, because test_nixl_connector.py imports ray and our serving image doesn't have it. That doesn't matter for upstream CI.

One gap we found and closed on our side: a request whose content is nested past the recursion limit (for example a well-formed REQ envelope carrying b"\x91" * 20000 + b"\x01") makes the decode raise RecursionError, a RuntimeError, which the except tuple does not catch, so the listener thread still exits. Adding RecursionError to that tuple, plus a test that sends such a message and then checks a real GET_META_MSG still gets its reply, closes it.

This fix has been in one of our production images since 2026-10-08. It would be great to see it merged once the conflict is resolved.

(AI assistance was used to investigate this and draft this comment; I have checked the findings myself.)

jdonkervliet and others added 2 commits October 9, 2026 08:37
The prefill-side handshake listener is a single background thread that
lives for the whole process, answering metadata queries from every
decode worker that needs to pull KV cache from this engine. Its only
exception handling was for the idle-poll timeout; anything raised while
actually processing a request (a malformed message, a request for a
pp/tp rank this engine doesn't serve, or a peer that simply doesn't
speak the REQ/ROUTER envelope this listener expects) propagated out of
the thread's target function and killed it silently, with nothing to
restart or report it. Once dead, every subsequent handshake against
this engine times out and fails until the process is restarted, no
matter which decode worker is asking.

Move the frame-count unpacking and message handling into a guarded
block, narrowed to the concrete failure modes a bad request can
trigger: msgspec.DecodeError (malformed msgpack), ValueError/TypeError
(wrong message shape, e.g. a non-REQ client that doesn't send the
expected 3-part envelope), KeyError (unknown rank), and zmq.ZMQError
(reply-side socket errors). A single bad request is now logged (with
the raw bytes it sent, safely escaped and capped) and dropped, instead
of taking the side channel down for every decode worker talking to
this engine. Anything outside that list still crashes the thread
loudly, since that indicates a genuine bug rather than a bad peer.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Jesse Donkervliet <jesse@ubicloud.com>
A decode worker's handshake request times out identically whether the
remote's listener never started, is too busy to respond, or has died:
ZMQ hides the underlying connection state, so a bare "Resource
temporarily unavailable" is all that ever reaches the caller. Wrap the
timeout with the remote address/rank and a pointer to the existing
side-channel probe script, so the failure can be triaged directly
instead of guessing from logs that show nothing useful.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Jesse Donkervliet <jesse@ubicloud.com>
@jdonkervliet
jdonkervliet force-pushed the jesse/nixl-handshake-hardening branch from 9464261 to 4a4f748 Compare October 9, 2026 08:42
@jdonkervliet
jdonkervliet requested a review from wzhao18 as a code owner October 9, 2026 08:42
@jdonkervliet

Copy link
Copy Markdown
Author

I resolved the merge conflicts and added RecursionError to the except block.

@mergify mergify Bot removed the needs-rebase label Oct 9, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working kv-connector

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants