# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project """Regression tests for the busy-loop-based RPC routing in DiffusionEngine. These tests guard the invariants established when ``collective_rpc`` was moved off ``_rpc_lock`` mutual exclusion and onto a queue drained by the engine's busy-loop thread: * Only the busy-loop thread ever calls ``executor.collective_rpc`` after the loop starts. * Concurrent ``engine.collective_rpc`` and per-request ``execute_fn`` invocations never overlap on the executor. * Results are routed back to the correct caller (sync, async, and per-request paths). * Pending RPCs are failed cleanly on shutdown. * The bootstrap path (busy loop not yet started) calls the executor directly so ``_dummy_run`` keeps working. """ from __future__ import annotations import asyncio import queue import threading import time from dataclasses import fields as _dc_fields from types import SimpleNamespace from typing import Any import pytest from vllm_omni.diffusion.data import DiffusionOutput from vllm_omni.diffusion.diffusion_engine import DiffusionEngine, _RpcTask from vllm_omni.diffusion.sched import RequestScheduler from vllm_omni.diffusion.sched.interface import SamplingParamsKey from vllm_omni.diffusion.worker.utils import RunnerOutput # Default values for every batch-key field, so SimpleNamespace-based # sampling_params satisfy ``get_sampling_params_key``'s attribute lookups. _SAMPLING_KEY_DEFAULTS = {f.name: f.default for f in _dc_fields(SamplingParamsKey)} pytestmark = [pytest.mark.diffusion, pytest.mark.cpu] # ───────────────────────────────────────── helpers ───────────────────────── class _ConcurrencyTrackingExecutor: """Fake executor that records every ``collective_rpc`` invocation and flags any overlap. Used to assert structural serialization. """ def __init__(self, rpc_delay: float = 0.0): self._active = 0 self._lock = threading.Lock() self.max_concurrent = 0 self.calls: list[dict[str, Any]] = [] self.thread_ids: set[int] = set() self.rpc_delay = rpc_delay self.is_failed = False self._closed = False def collective_rpc( self, method: str, timeout: float | None = None, args: tuple = (), kwargs: dict | None = None, unique_reply_rank: int | None = None, exec_all_ranks: bool = False, ) -> Any: with self._lock: self._active += 1 self.max_concurrent = max(self.max_concurrent, self._active) self.thread_ids.add(threading.get_ident()) self.calls.append( { "method": method, "args": args, "kwargs": kwargs, "unique_reply_rank": unique_reply_rank, "thread": threading.get_ident(), } ) try: if self.rpc_delay: time.sleep(self.rpc_delay) # Distinguish per-request execution from raw RPC by method name. if method in {"execute_model", "execute_stepwise", "generate"}: # args[0] is the request-like object for execute_model. req = args[0] if args else None tag = req.request_id if req is not None and hasattr(req, "request_id") else "unknown" return DiffusionOutput(error=f"result_for_{tag}") tag = args[0] if args else method return DiffusionOutput(error=f"rpc_result_for_{tag}") finally: with self._lock: self._active -= 1 def execute_request(self, scheduler_output) -> RunnerOutput: # Mimic the real MultiprocDiffusionExecutor.execute_request: it # forwards a single request through collective_rpc. new_req = scheduler_output.scheduled_new_reqs[0] result = self.collective_rpc( "execute_model", args=(new_req.req,), unique_reply_rank=0, exec_all_ranks=True, ) return RunnerOutput( request_id=new_req.request_id, step_index=None, finished=True, result=result, ) def shutdown(self) -> None: self._closed = True def _make_request(tag: str): return SimpleNamespace( request_id=tag, prompts=[f"prompt_{tag}"], sampling_params=SimpleNamespace(num_inference_steps=1, **_SAMPLING_KEY_DEFAULTS), ) def _make_engine_with_loop( loop: asyncio.AbstractEventLoop, rpc_delay: float = 0.0, ): """Construct a ``DiffusionEngine`` skeleton with a real busy loop. The engine is wired with a fake executor that asserts no concurrent calls and a real ``RequestScheduler``. """ engine = DiffusionEngine.__new__(DiffusionEngine) engine._closed = False engine.executor = _ConcurrencyTrackingExecutor(rpc_delay=rpc_delay) sched = RequestScheduler() sched.initialize(SimpleNamespace(max_num_seqs=1)) engine.scheduler = sched engine.step_execution = False engine.execute_fn = engine.executor.execute_request engine._rpc_lock = threading.RLock() engine._cv = threading.Condition(engine._rpc_lock) engine._out_queue = {} engine._closed = False engine.abort_queue = queue.Queue() engine._rpc_queue = queue.Queue() engine.main_loop = loop engine.stop_event = threading.Event() engine._loop_started = True engine.worker_thread = threading.Thread(target=engine._busy_loop, daemon=True) engine.worker_thread.start() return engine def _stop_engine(engine: DiffusionEngine) -> None: with engine._cv: engine.stop_event.set() engine._cv.notify_all() # Race-proof shutdown for the test: drain any RPCs still queued and # fail them with the documented shutdown error before the busy loop # has a chance to pick them up after its current in-flight call # returns. The engine's own ``_fail_pending_rpcs`` then has nothing # left to do. while True: try: task = engine._rpc_queue.get_nowait() except queue.Empty: break if not task.future.done(): task.future.set_exception(RuntimeError("DiffusionEngine is shutting down.")) engine.worker_thread.join(timeout=5) assert not engine.worker_thread.is_alive(), "Busy loop thread did not stop" # ─────────────────────── single-thread invariant ─────────────────────────── @pytest.mark.asyncio async def test_executor_only_called_from_busy_loop_thread(): """All executor calls — both per-request and raw RPC — must come from the busy-loop thread, never from a caller's thread.""" loop = asyncio.get_running_loop() engine = _make_engine_with_loop(loop) busy_tid = engine.worker_thread.ident try: # Per-request path await engine.async_add_req_and_wait_for_response(_make_request("req1")) # Raw RPC path (sync from a worker thread) result = await asyncio.to_thread(engine.collective_rpc, "ping", args=("a",), unique_reply_rank=0) assert result.error == "rpc_result_for_a" # Raw RPC path (async) result_async = await engine.async_collective_rpc("ping", args=("b",), unique_reply_rank=0) assert result_async.error == "rpc_result_for_b" finally: _stop_engine(engine) # Every recorded call ran on the busy-loop thread. assert engine.executor.thread_ids == {busy_tid}, ( f"executor.collective_rpc must run only on the busy-loop thread " f"(expected {{{busy_tid}}}, got {engine.executor.thread_ids})" ) @pytest.mark.asyncio async def test_executor_calls_never_overlap_under_load(): """Stress: many concurrent ``collective_rpc`` callers + a request must never produce overlapping executor calls.""" loop = asyncio.get_running_loop() engine = _make_engine_with_loop(loop, rpc_delay=0.005) try: async def _rpc(i: int): return await engine.async_collective_rpc("ping", args=(f"x{i}",), unique_reply_rank=0) async def _request(i: int): return await engine.async_add_req_and_wait_for_response(_make_request(f"r{i}")) tasks = [_rpc(i) for i in range(20)] + [_request(i) for i in range(5)] results = await asyncio.gather(*tasks) finally: _stop_engine(engine) assert engine.executor.max_concurrent == 1, ( f"Detected concurrent executor calls (max_concurrent={engine.executor.max_concurrent})" ) # All 25 calls should have a result. assert len(results) == 25 assert all(r is not None for r in results) # ─────────────────────────── result routing ──────────────────────────────── @pytest.mark.asyncio async def test_collective_rpc_results_routed_to_correct_caller(): """Under concurrent calls with distinct args, each caller must get its own result back — not another caller's.""" loop = asyncio.get_running_loop() engine = _make_engine_with_loop(loop, rpc_delay=0.01) try: async def _call(tag: str): res = await engine.async_collective_rpc("ping", args=(tag,), unique_reply_rank=0) return tag, res tags = [f"t{i}" for i in range(15)] results = await asyncio.gather(*[_call(t) for t in tags]) finally: _stop_engine(engine) for tag, res in results: assert res.error == f"rpc_result_for_{tag}", f"caller for {tag!r} received {res.error!r}" @pytest.mark.asyncio async def test_sync_collective_rpc_from_worker_thread(): """Sync ``collective_rpc`` from non-event-loop threads still receives the correct result via the busy loop.""" loop = asyncio.get_running_loop() engine = _make_engine_with_loop(loop) try: results: list[Any] = [None] * 8 def _worker(idx: int): results[idx] = engine.collective_rpc("ping", args=(f"s{idx}",), unique_reply_rank=0) threads = [threading.Thread(target=_worker, args=(i,)) for i in range(8)] for t in threads: t.start() for t in threads: t.join(5) assert not t.is_alive() finally: _stop_engine(engine) for i, r in enumerate(results): assert r is not None assert r.error == f"rpc_result_for_s{i}" # ─────────────────────────── timeout behaviour ───────────────────────────── @pytest.mark.asyncio async def test_collective_rpc_times_out_when_busy_loop_busy(): """If the busy loop is occupied, ``collective_rpc(timeout=...)`` must raise ``TimeoutError`` rather than block indefinitely.""" loop = asyncio.get_running_loop() # 2-second per-RPC delay simulates a busy worker. engine = _make_engine_with_loop(loop, rpc_delay=2.0) try: # Kick off one slow RPC to occupy the busy loop. slow = asyncio.create_task(engine.async_collective_rpc("slow", args=("s",), unique_reply_rank=0)) # Yield so the slow task is enqueued. await asyncio.sleep(0.05) with pytest.raises(TimeoutError): await asyncio.to_thread( engine.collective_rpc, "ping", args=("x",), unique_reply_rank=0, timeout=0.2, ) # The slow call should still complete normally. result = await slow assert result.error == "rpc_result_for_s" finally: _stop_engine(engine) @pytest.mark.asyncio async def test_async_collective_rpc_times_out_when_busy_loop_busy(): loop = asyncio.get_running_loop() engine = _make_engine_with_loop(loop, rpc_delay=2.0) try: slow = asyncio.create_task(engine.async_collective_rpc("slow", args=("s",), unique_reply_rank=0)) await asyncio.sleep(0.05) with pytest.raises(TimeoutError): await engine.async_collective_rpc("ping", args=("x",), unique_reply_rank=0, timeout=0.2) result = await slow assert result.error == "rpc_result_for_s" finally: _stop_engine(engine) # ─────────────────────────── shutdown handling ───────────────────────────── @pytest.mark.asyncio async def test_pending_rpcs_failed_on_shutdown(): """Shutdown must fail any RPCs still queued so callers don't hang.""" loop = asyncio.get_running_loop() engine = _make_engine_with_loop(loop, rpc_delay=0.5) try: # Start a slow RPC that occupies the busy loop, then queue more. in_flight = asyncio.create_task(engine.async_collective_rpc("slow", args=("s",), unique_reply_rank=0)) await asyncio.sleep(0.05) pending = [ asyncio.create_task(engine.async_collective_rpc("ping", args=(f"p{i}",), unique_reply_rank=0)) for i in range(3) ] # Give them a moment to enqueue. await asyncio.sleep(0.05) finally: _stop_engine(engine) # The in-flight one finishes normally; pending ones should fail. assert (await in_flight).error == "rpc_result_for_s" for t in pending: with pytest.raises(RuntimeError, match="shutting down"): await t # ─────────────────────────── bootstrap path ──────────────────────────────── def test_collective_rpc_before_loop_starts_calls_executor_directly(): """When ``_loop_started`` is False (e.g. inside ``_dummy_run`` during ``__init__``), ``collective_rpc`` must call the executor synchronously on the caller's thread without enqueueing. """ engine = DiffusionEngine.__new__(DiffusionEngine) engine._closed = False engine._loop_started = False engine._rpc_lock = threading.RLock() engine._cv = threading.Condition(engine._rpc_lock) engine.executor = _ConcurrencyTrackingExecutor() caller_tid = threading.get_ident() result = engine.collective_rpc("ping", args=("boot",), unique_reply_rank=0) assert result.error == "rpc_result_for_boot" # Critically: ran on the caller's thread, not a busy-loop thread. assert engine.executor.thread_ids == {caller_tid} def test_collective_rpc_before_loop_starts_serializes_concurrent_callers(): """Regression: between ``__init__`` returning and the first async request starting the busy loop, multiple threads may call ``collective_rpc`` concurrently. The pre-loop fast-path must serialize them so they cannot race on the shared executor MQ pair. """ engine = DiffusionEngine.__new__(DiffusionEngine) engine._closed = False engine._loop_started = False engine._rpc_lock = threading.RLock() engine._cv = threading.Condition(engine._rpc_lock) # Non-trivial delay forces overlap if the lock is missing. engine.executor = _ConcurrencyTrackingExecutor(rpc_delay=0.02) n = 8 results: list[Any] = [None] * n def _call(i: int) -> None: results[i] = engine.collective_rpc("ping", args=(f"x{i}",), unique_reply_rank=0) threads = [threading.Thread(target=_call, args=(i,)) for i in range(n)] for t in threads: t.start() for t in threads: t.join() assert engine.executor.max_concurrent == 1, "Pre-loop collective_rpc must serialize concurrent callers" for i, r in enumerate(results): assert r is not None and r.error == f"rpc_result_for_x{i}" @pytest.mark.asyncio async def test_cancelled_future_does_not_kill_busy_loop(): """Regression: if a queued RPC future is cancelled while the executor call is in flight, ``_process_rpc_queue`` must not raise ``InvalidStateError`` when trying to set the result/exception. Doing so would crash the busy-loop thread and stall all later requests. """ loop = asyncio.get_running_loop() # Slow executor so we can cancel mid-flight. engine = _make_engine_with_loop(loop, rpc_delay=0.3) try: # Submit an RPC and immediately cancel its future to simulate # a sync timeout / asyncio cancellation racing the worker. with pytest.raises(TimeoutError): await asyncio.to_thread( engine.collective_rpc, "slow", args=("cancelme",), unique_reply_rank=0, timeout=0.05, ) # Give the busy loop time to finish the in-flight slow call and # attempt to set state on the cancelled future. await asyncio.sleep(0.5) # If the busy loop crashed, this follow-up RPC would hang / # never complete. Bound it with a timeout to fail fast. result = await asyncio.wait_for( engine.async_collective_rpc("ping", args=("after",), unique_reply_rank=0), timeout=3.0, ) assert result.error == "rpc_result_for_after" assert engine.worker_thread.is_alive() finally: _stop_engine(engine) # ─────────────────────────── _RpcTask basics ─────────────────────────────── def test_rpc_task_default_future_is_unique_per_instance(): """Regression: ``_RpcTask.future`` must default to a *new* Future per instance, not a shared one (would cross-resolve all callers). """ a = _RpcTask(method="m", args=(), kwargs=None, deadline=None, unique_reply_rank=0) b = _RpcTask(method="m", args=(), kwargs=None, deadline=None, unique_reply_rank=0) assert a.future is not b.future a.future.set_result("a") assert not b.future.done() # ──────────────── busy-loop drains RPCs without scheduler work ───────────── @pytest.mark.asyncio async def test_busy_loop_handles_rpc_without_pending_requests(): """The wait predicate must wake on RPC submissions even when the scheduler queue is empty (regression: previously the loop only woke on ``has_requests()``). """ loop = asyncio.get_running_loop() engine = _make_engine_with_loop(loop) try: # No request has been added; only RPC. result = await asyncio.wait_for( engine.async_collective_rpc("ping", args=("alone",), unique_reply_rank=0), timeout=3.0, ) assert result.error == "rpc_result_for_alone" finally: _stop_engine(engine) # ──────────────── interleaved RPC + request integrity ────────────────────── @pytest.mark.asyncio async def test_rpc_and_request_results_do_not_swap(): """A concurrent RPC and request execution must each receive their own result (regression for the original race the refactor fixed). """ loop = asyncio.get_running_loop() engine = _make_engine_with_loop(loop, rpc_delay=0.02) try: rpc_task = asyncio.create_task(engine.async_collective_rpc("ping", args=("rpc1",), unique_reply_rank=0)) req_task = asyncio.create_task(engine.async_add_req_and_wait_for_response(_make_request("req1"))) rpc_res, req_res = await asyncio.gather(rpc_task, req_task) finally: _stop_engine(engine) assert rpc_res.error == "rpc_result_for_rpc1" assert req_res.error == "result_for_req1" # Belt and braces: never overlapped on the executor. assert engine.executor.max_concurrent == 1