[Core][Frontend] Unified Full-duplex Framework (#7413)
Signed-off-by: chickeyton <ngton2014@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9005d78903
commit
99ff4f3070
182 files changed
+26951
-34743
No files matched your search
@@ -7,7 +7,6 @@ Focuses on the critical behavior: when the orchestrator thread dies,
|
||||
subsequent attempts to collect output raise RuntimeError.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import queue
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -18,16 +17,6 @@ from pytest_mock import MockerFixture
|
||||
|
||||
from vllm_omni.engine.async_engine_utils import weak_shutdown_async_omni_engine
|
||||
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
|
||||
from vllm_omni.engine.duplex.control_client import (
|
||||
DuplexControlClient,
|
||||
DuplexControlRequestError,
|
||||
)
|
||||
from vllm_omni.engine.duplex.messages import (
|
||||
AppendDuplexInputMessage,
|
||||
DuplexControlResultMessage,
|
||||
DuplexFence,
|
||||
SignalDuplexTurnMessage,
|
||||
)
|
||||
from vllm_omni.engine.messages import (
|
||||
CollectiveRPCResultMessage,
|
||||
ErrorMessage,
|
||||
@@ -166,9 +155,9 @@ def test_output_remains_on_shared_output_path(mocker: MockerFixture):
|
||||
raw_queue = queue.Queue()
|
||||
raw_queue.put_nowait(
|
||||
OutputMessage(
|
||||
request_id="legacy-duplex-request",
|
||||
request_id="shared-path-request",
|
||||
stage_id=0,
|
||||
engine_outputs=OmniRequestOutput(request_id="legacy-duplex-request"),
|
||||
engine_outputs=OmniRequestOutput(request_id="shared-path-request"),
|
||||
finished=False,
|
||||
)
|
||||
)
|
||||
@@ -176,293 +165,7 @@ def test_output_remains_on_shared_output_path(mocker: MockerFixture):
|
||||
|
||||
output = engine.try_get_output(timeout=0.01)
|
||||
|
||||
assert output.request_id == "legacy-duplex-request"
|
||||
|
||||
|
||||
def test_duplex_control_client_uses_engine_owned_correlated_transport():
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine._duplex_control_client = None
|
||||
engine._correlated_rpc_client = CorrelatedRpcClient(queue.Queue(), queue.Queue())
|
||||
|
||||
try:
|
||||
client = engine._get_duplex_control_client()
|
||||
|
||||
assert isinstance(client, DuplexControlClient)
|
||||
assert client._transport is engine._correlated_rpc_client
|
||||
assert not hasattr(engine, "_submit_rpc_and_wait")
|
||||
finally:
|
||||
engine._correlated_rpc_client.close()
|
||||
|
||||
|
||||
def test_duplex_control_api_requires_explicit_fence():
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
|
||||
with pytest.raises(TypeError, match="fence"):
|
||||
engine.open_duplex_session("sid")
|
||||
with pytest.raises(TypeError, match="fence"):
|
||||
engine.append_duplex_input("sid", mode="append_audio_chunk", payload={})
|
||||
with pytest.raises(TypeError, match="fence"):
|
||||
engine.signal_duplex_turn("sid", event="turn.end")
|
||||
with pytest.raises(TypeError, match="fence"):
|
||||
engine.close_duplex_session("sid")
|
||||
|
||||
|
||||
def test_open_duplex_session_waits_for_control_ack(mocker: MockerFixture):
|
||||
request_q = queue.Queue()
|
||||
rpc_q = queue.Queue()
|
||||
control_result = DuplexControlResultMessage(
|
||||
control_id="ctrl-1",
|
||||
fence=DuplexFence("sid"),
|
||||
operation="open",
|
||||
session_id="sid",
|
||||
ok=False,
|
||||
stage_results=[{"stage_id": 0, "replica_id": 0, "result": {"supported": False}}],
|
||||
unsupported_count=1,
|
||||
error_count=0,
|
||||
)
|
||||
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine.request_queue = SimpleNamespace(sync_q=request_q)
|
||||
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
|
||||
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
|
||||
engine._duplex_control_client = None
|
||||
mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="ctrl-1"))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
pending = executor.submit(engine.open_duplex_session, "sid", fence=DuplexFence("sid"), timeout=1)
|
||||
msg = request_q.get(timeout=1)
|
||||
assert msg.type == "open_duplex_session"
|
||||
assert msg.control_id == "ctrl-1"
|
||||
rpc_q.put(control_result)
|
||||
with pytest.raises(DuplexControlRequestError) as exc_info:
|
||||
pending.result(timeout=1)
|
||||
|
||||
assert exc_info.value.result["unsupported_count"] == 1
|
||||
assert exc_info.value.result["stage_results"][0]["result"]["supported"] is False
|
||||
engine._correlated_rpc_client.close()
|
||||
|
||||
|
||||
def test_signal_duplex_turn_message_carries_next_fence(mocker: MockerFixture):
|
||||
request_q = queue.Queue()
|
||||
rpc_q = queue.Queue()
|
||||
cancelled_fence = DuplexFence("sid-cancel", epoch=4, turn_id=2)
|
||||
next_fence = DuplexFence("sid-cancel", epoch=5, turn_id=2)
|
||||
control_result = DuplexControlResultMessage(
|
||||
control_id="ctrl-signal",
|
||||
fence=cancelled_fence,
|
||||
operation="signal",
|
||||
session_id="sid-cancel",
|
||||
ok=True,
|
||||
stage_results=[],
|
||||
)
|
||||
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine.request_queue = SimpleNamespace(sync_q=request_q)
|
||||
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
|
||||
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
|
||||
engine._duplex_control_client = None
|
||||
mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="ctrl-signal"))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
pending = executor.submit(
|
||||
engine.signal_duplex_turn,
|
||||
"sid-cancel",
|
||||
event="input.cancel",
|
||||
fence=cancelled_fence,
|
||||
next_fence=next_fence,
|
||||
session_config={"temperature": 0.0},
|
||||
timeout=1,
|
||||
)
|
||||
msg = request_q.get(timeout=1)
|
||||
assert isinstance(msg, SignalDuplexTurnMessage)
|
||||
assert msg.fence == cancelled_fence
|
||||
assert msg.next_fence == next_fence
|
||||
assert msg.session_config == {"temperature": 0.0}
|
||||
rpc_q.put(control_result)
|
||||
assert pending.result(timeout=1)["ok"] is True
|
||||
|
||||
engine._correlated_rpc_client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_async_append_does_not_stop_executor_callable(mocker: MockerFixture):
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
completed = threading.Event()
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
|
||||
def blocking_append(*args, **kwargs):
|
||||
del args, kwargs
|
||||
entered.set()
|
||||
release.wait(timeout=1)
|
||||
completed.set()
|
||||
return {}
|
||||
|
||||
mocker.patch.object(engine, "append_duplex_input", side_effect=blocking_append)
|
||||
task = asyncio.create_task(
|
||||
engine.append_duplex_input_async(
|
||||
"sid-executor-cancel",
|
||||
mode="append_audio_chunk",
|
||||
payload=b"audio",
|
||||
fence=DuplexFence("sid-executor-cancel"),
|
||||
)
|
||||
)
|
||||
assert await asyncio.to_thread(entered.wait, 1)
|
||||
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
release.set()
|
||||
|
||||
assert await asyncio.to_thread(completed.wait, 1)
|
||||
|
||||
|
||||
def test_duplex_controls_route_out_of_order_without_global_lock():
|
||||
request_q = queue.Queue()
|
||||
rpc_q = queue.Queue()
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine.request_queue = SimpleNamespace(sync_q=request_q)
|
||||
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
|
||||
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
|
||||
engine._duplex_control_client = None
|
||||
first_fence = DuplexFence("sid", epoch=1)
|
||||
second_fence = DuplexFence("sid", epoch=2)
|
||||
first = AppendDuplexInputMessage(
|
||||
control_id="first",
|
||||
fence=first_fence,
|
||||
session_id="sid",
|
||||
expected_epoch=1,
|
||||
mode="audio",
|
||||
payload=b"first",
|
||||
)
|
||||
second = AppendDuplexInputMessage(
|
||||
control_id="second",
|
||||
fence=second_fence,
|
||||
session_id="sid",
|
||||
expected_epoch=2,
|
||||
mode="audio",
|
||||
payload=b"second",
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
client = engine._get_duplex_control_client()
|
||||
first_result = executor.submit(client.execute, first, timeout=1)
|
||||
second_result = executor.submit(client.execute, second, timeout=1)
|
||||
submitted = {request_q.get(timeout=1).control_id, request_q.get(timeout=1).control_id}
|
||||
assert submitted == {"first", "second"}
|
||||
rpc_q.put(
|
||||
DuplexControlResultMessage(
|
||||
control_id="second",
|
||||
fence=second_fence,
|
||||
operation="append",
|
||||
session_id="sid",
|
||||
ok=True,
|
||||
stage_results=[],
|
||||
)
|
||||
)
|
||||
rpc_q.put(
|
||||
DuplexControlResultMessage(
|
||||
control_id="first",
|
||||
fence=first_fence,
|
||||
operation="append",
|
||||
session_id="sid",
|
||||
ok=True,
|
||||
stage_results=[],
|
||||
)
|
||||
)
|
||||
|
||||
assert first_result.result(timeout=1)["fence"] == first_fence
|
||||
assert second_result.result(timeout=1)["fence"] == second_fence
|
||||
|
||||
assert not hasattr(engine, "_rpc_lock")
|
||||
engine._correlated_rpc_client.close()
|
||||
|
||||
|
||||
def test_terminal_rpc_router_rejects_without_enqueuing_new_control():
|
||||
request_q = queue.Queue()
|
||||
rpc_q = queue.Queue()
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine.request_queue = SimpleNamespace(sync_q=request_q)
|
||||
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
|
||||
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
|
||||
engine._duplex_control_client = None
|
||||
pending_message = AppendDuplexInputMessage(
|
||||
control_id="pending",
|
||||
fence=DuplexFence("sid"),
|
||||
session_id="sid",
|
||||
mode="audio",
|
||||
payload=b"audio",
|
||||
)
|
||||
message = AppendDuplexInputMessage(
|
||||
control_id="after-fatal",
|
||||
fence=DuplexFence("sid"),
|
||||
session_id="sid",
|
||||
mode="audio",
|
||||
payload=b"audio",
|
||||
)
|
||||
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
pending = executor.submit(engine._get_duplex_control_client().execute, pending_message, timeout=1)
|
||||
assert request_q.get(timeout=1).control_id == "pending"
|
||||
rpc_q.put(ErrorMessage(error="orchestrator failed", fatal=True))
|
||||
with pytest.raises(RuntimeError, match="orchestrator failed"):
|
||||
pending.result(timeout=1)
|
||||
|
||||
with pytest.raises(RuntimeError, match="orchestrator failed"):
|
||||
engine._get_duplex_control_client().execute(message, timeout=1)
|
||||
|
||||
assert request_q.empty()
|
||||
finally:
|
||||
engine._correlated_rpc_client.close()
|
||||
|
||||
|
||||
def test_collective_and_duplex_results_share_router_without_swapping(mocker: MockerFixture):
|
||||
request_q = queue.Queue()
|
||||
rpc_q = queue.Queue()
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine.request_queue = SimpleNamespace(sync_q=request_q)
|
||||
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
|
||||
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
|
||||
engine._duplex_control_client = None
|
||||
fence = DuplexFence("sid")
|
||||
duplex = AppendDuplexInputMessage(
|
||||
control_id="duplex-control",
|
||||
fence=fence,
|
||||
session_id="sid",
|
||||
mode="audio",
|
||||
payload=b"audio",
|
||||
)
|
||||
mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="collective-rpc"))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
duplex_result = executor.submit(engine._get_duplex_control_client().execute, duplex, timeout=1)
|
||||
collective_result = executor.submit(engine.collective_rpc, "health", timeout=1)
|
||||
submitted_types = {request_q.get(timeout=1).type, request_q.get(timeout=1).type}
|
||||
assert submitted_types == {"append_duplex_input", "collective_rpc"}
|
||||
rpc_q.put(
|
||||
CollectiveRPCResultMessage(
|
||||
rpc_id="collective-rpc",
|
||||
method="health",
|
||||
stage_ids=[0],
|
||||
results=["healthy"],
|
||||
)
|
||||
)
|
||||
rpc_q.put(
|
||||
DuplexControlResultMessage(
|
||||
control_id="duplex-control",
|
||||
fence=fence,
|
||||
operation="append",
|
||||
session_id="sid",
|
||||
ok=True,
|
||||
stage_results=[],
|
||||
)
|
||||
)
|
||||
|
||||
assert collective_result.result(timeout=1) == ["healthy"]
|
||||
assert duplex_result.result(timeout=1)["fence"] == fence
|
||||
|
||||
engine._correlated_rpc_client.close()
|
||||
assert output.request_id == "shared-path-request"
|
||||
|
||||
|
||||
def test_collective_rpc_preserves_request_queue_backpressure(mocker: MockerFixture):
|
||||
@@ -483,8 +186,7 @@ def test_collective_rpc_preserves_request_queue_backpressure(mocker: MockerFixtu
|
||||
engine.request_queue = SimpleNamespace(sync_q=request_q)
|
||||
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
|
||||
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
|
||||
engine._duplex_control_client = None
|
||||
mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="blocked-rpc"))
|
||||
mocker.patch("vllm_omni.engine.omni_engine_base.uuid.uuid4", return_value=SimpleNamespace(hex="blocked-rpc"))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
pending = executor.submit(engine.collective_rpc, "health", timeout=1)
|
||||
@@ -506,36 +208,6 @@ def test_collective_rpc_preserves_request_queue_backpressure(mocker: MockerFixtu
|
||||
engine._correlated_rpc_client.close()
|
||||
|
||||
|
||||
def test_open_duplex_session_raises_on_stage_control_error(mocker: MockerFixture):
|
||||
request_q = queue.Queue()
|
||||
rpc_q = queue.Queue()
|
||||
control_result = DuplexControlResultMessage(
|
||||
control_id="ctrl-error",
|
||||
fence=DuplexFence("sid"),
|
||||
operation="open",
|
||||
session_id="sid",
|
||||
ok=False,
|
||||
stage_results=[{"stage_id": 0, "replica_id": 0, "result": {"supported": False, "error": "boom"}}],
|
||||
unsupported_count=1,
|
||||
error_count=1,
|
||||
)
|
||||
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine.request_queue = SimpleNamespace(sync_q=request_q)
|
||||
engine.rpc_output_queue = SimpleNamespace(sync_q=rpc_q)
|
||||
engine._correlated_rpc_client = CorrelatedRpcClient(request_q, rpc_q)
|
||||
engine._duplex_control_client = None
|
||||
mocker.patch("vllm_omni.engine.async_omni_engine.uuid.uuid4", return_value=SimpleNamespace(hex="ctrl-error"))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
pending = executor.submit(engine.open_duplex_session, "sid", fence=DuplexFence("sid"), timeout=1)
|
||||
request_q.get(timeout=1)
|
||||
rpc_q.put(control_result)
|
||||
with pytest.raises(RuntimeError, match="duplex open failed"):
|
||||
pending.result(timeout=1)
|
||||
engine._correlated_rpc_client.close()
|
||||
|
||||
|
||||
def test_shutdown_does_not_race_runtime_cleanup_with_live_orchestrator(mocker: MockerFixture):
|
||||
engine = object.__new__(AsyncOmniEngine)
|
||||
engine._shutdown_called = False
|
||||
|
||||
Reference in new issue
Block a user