[Bugfix] Do not block diffusion client shutdown on a dead subprocess (#7812)

Signed-off-by: cr-gao <gaochenrui@sjtu.edu.cn>
This commit is contained in:
vOv authored and GitHub committed 2026-09-30 11:25:37 +08:00
1 parent 146ccc133a
commit 25bdb38b11
3 files changed
+174 -13

No files matched your search

@@ -1431,7 +1431,7 @@ class TestStageDiffusionClientErrorPropagation:
)
assert result is None
client._request_socket.send.assert_called_once_with(b"encoded-rpc")
client._request_socket.send.assert_called_once_with(b"encoded-rpc", flags=zmq.NOBLOCK)
assert rpc_id not in client._pending_rpcs
@@ -0,0 +1,142 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
import asyncio
import threading
from types import SimpleNamespace
import pytest
import zmq
from vllm.utils.network_utils import get_open_zmq_ipc_path
from vllm.v1.engine.exceptions import EngineDeadError
from vllm_omni.diffusion.stage_diffusion_client import StageDiffusionClient
from vllm_omni.distributed.omni_connectors.utils.serialization import OmniMsgpackDecoder, OmniMsgpackEncoder
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
def _client_without_subprocess() -> StageDiffusionClient:
# A PUSH socket with no peer blocks on send, which is what the client is
# left with after its subprocess died.
client = object.__new__(StageDiffusionClient)
client._zmq_ctx = zmq.Context()
client._request_socket = client._zmq_ctx.socket(zmq.PUSH)
client.request_address = get_open_zmq_ipc_path()
client._request_socket.bind(client.request_address)
client._response_socket = client._zmq_ctx.socket(zmq.PULL)
client._response_socket.bind(get_open_zmq_ipc_path())
client._encoder = OmniMsgpackEncoder()
client._proc_manager = None
client._engine_dead = False
client.stage_id, client.replica_id = 0, 0
client._pending_rpcs = set()
return client
def _with_proc(client: StageDiffusionClient, alive: bool) -> StageDiffusionClient:
client._proc_manager = SimpleNamespace(proc=SimpleNamespace(is_alive=lambda: alive, exitcode=None if alive else -9))
return client
def _run_in_thread(coro) -> tuple[threading.Thread, dict]:
outcome = {}
def run():
try:
outcome["result"] = asyncio.run(coro)
except Exception as e:
outcome["error"] = e
thread = threading.Thread(target=run, daemon=True)
thread.start()
return thread, outcome
def _close(client: StageDiffusionClient, thread: threading.Thread) -> None:
# Left to garbage collection, the context can be terminated at exit before its sockets are
# closed and hang. A thread still stuck in a send owns the socket, so leave it alone then.
if thread.is_alive():
return
client._request_socket.close(linger=0)
client._response_socket.close(linger=0)
client._zmq_ctx.term()
def test_shutdown_returns_when_subprocess_is_gone():
client = _client_without_subprocess()
shutdown = threading.Thread(target=client.shutdown, daemon=True)
shutdown.start()
shutdown.join(timeout=5)
assert not shutdown.is_alive()
# Terminating the context is the last step, so shutdown() ran to the end.
assert client._zmq_ctx.closed
def test_abort_returns_when_engine_is_dead():
client = _client_without_subprocess()
client._engine_dead = True
thread, outcome = _run_in_thread(client.abort_requests_async(["req-0"]))
thread.join(timeout=5)
try:
assert not thread.is_alive()
assert "error" not in outcome
finally:
_close(client, thread)
def test_abort_returns_when_subprocess_is_gone_before_engine_dead_is_set():
client = _client_without_subprocess()
thread, outcome = _run_in_thread(client.abort_requests_async(["req-0"]))
thread.join(timeout=5)
try:
assert not thread.is_alive()
assert "error" not in outcome
finally:
_close(client, thread)
@pytest.mark.parametrize("call", ["add_request", "collective_rpc"])
def test_send_raises_when_subprocess_died_before_engine_dead_is_set(call):
client = _with_proc(_client_without_subprocess(), alive=False)
if call == "add_request":
coro = client.add_request_async("req-0", "a cat", OmniDiffusionSamplingParams())
else:
coro = client.collective_rpc_async("sleep")
thread, outcome = _run_in_thread(coro)
thread.join(timeout=5)
try:
assert not thread.is_alive()
assert isinstance(outcome.get("error"), EngineDeadError)
assert client._engine_dead
assert not client._pending_rpcs
finally:
_close(client, thread)
def test_add_request_waits_for_a_live_subprocess_to_connect():
client = _with_proc(_client_without_subprocess(), alive=True)
thread, outcome = _run_in_thread(client.add_request_async("req-0", "a cat", OmniDiffusionSamplingParams()))
thread.join(timeout=0.5)
assert thread.is_alive()
peer = client._zmq_ctx.socket(zmq.PULL)
peer.connect(client.request_address)
try:
assert peer.poll(5000)
message = OmniMsgpackDecoder().decode(peer.recv())
thread.join(timeout=5)
assert not thread.is_alive()
assert "error" not in outcome
assert message["type"] == "add_request"
assert message["request_id"] == "req-0"
finally:
peer.close(linger=0)
_close(client, thread)
+31 -12
View File
@@ -350,7 +350,7 @@ class StageDiffusionClient(StageClientBase):
self.replica_id,
request_id,
)
self._request_socket.send(
self._send_request(
self._encoder.encode(
{
"type": "add_request",
@@ -364,6 +364,18 @@ class StageDiffusionClient(StageClientBase):
)
)
def _send_request(self, data: bytes) -> None:
# The subprocess can die before _engine_dead is set, and a blocking send to it never returns.
while True:
try:
self._request_socket.send(data, flags=zmq.NOBLOCK)
return
except zmq.Again:
if self._proc_manager is not None and not self._proc_manager.proc.is_alive():
self._engine_dead = True
raise EngineDeadError() from None
self._request_socket.poll(100, zmq.POLLOUT)
def get_diffusion_output_nowait(self) -> OmniRequestOutput | None:
self._drain_responses()
try:
@@ -395,14 +407,21 @@ class StageDiffusionClient(StageClientBase):
return None
async def abort_requests_async(self, request_ids: list[str]) -> None:
self._request_socket.send(
self._encoder.encode(
{
"type": "abort",
"request_ids": list(request_ids),
}
if self._engine_dead:
return
try:
# The subprocess can die before _engine_dead is set, and a blocking send to it never returns.
self._request_socket.send(
self._encoder.encode(
{
"type": "abort",
"request_ids": list(request_ids),
}
),
flags=zmq.NOBLOCK,
)
)
except zmq.Again:
pass
async def submit_interaction_async(
self,
@@ -443,9 +462,7 @@ class StageDiffusionClient(StageClientBase):
kwargs = kwargs or {}
rpc_id = uuid.uuid4().hex
self._pending_rpcs.add(rpc_id)
self._request_socket.send(
self._send_request(
self._encoder.encode(
{
"type": "collective_rpc",
@@ -457,6 +474,7 @@ class StageDiffusionClient(StageClientBase):
}
)
)
self._pending_rpcs.add(rpc_id)
deadline = time.monotonic() + timeout if timeout else None
# Wait for the matching RPC response, buffering result messages.
@@ -507,7 +525,8 @@ class StageDiffusionClient(StageClientBase):
def shutdown(self) -> None:
self._shutting_down = True
try:
self._request_socket.send(self._encoder.encode({"type": "shutdown"}))
# A blocking send never returns once the subprocess is gone.
self._request_socket.send(self._encoder.encode({"type": "shutdown"}), flags=zmq.NOBLOCK)
except Exception:
pass