[Core][Frontend] Bound duplex output delivery and reject cancelled audio (#7644)

Signed-off-by: Nolen Liang <nliang@nvidia.com>
Signed-off-by: bcsdhjew <nliang@nvidia.com>
This commit is contained in:
bcsdhjew authored and GitHub committed 2026-10-04 14:06:04 -07:00
1 parent 3f20252151
commit f781f7bb08
26 files changed
+1170 -147

No files matched your search

@@ -18,6 +18,7 @@ from fastapi import WebSocketDisconnect
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig
from vllm_omni.engine.duplex import commands
from vllm_omni.engine.duplex.config import DuplexCapabilities
from vllm_omni.engine.duplex.delivery import DuplexOutputBuffer
from vllm_omni.engine.duplex.events import AudioDelta, DuplexEvent, SessionClosed, SessionCreated
from vllm_omni.engine.duplex.messages import DuplexSessionError
from vllm_omni.entrypoints.duplex.realtime_input import RealtimeEnvelope, parse_resume_request
@@ -94,10 +95,13 @@ class FakeHandle:
self.closed = False
self.close_reasons: list[str] = []
self.commands: list[commands.DuplexCommand] = []
self._outbox: asyncio.Queue[DuplexEvent] = asyncio.Queue()
self._outbox = DuplexOutputBuffer(max_bytes=2 * 1024 * 1024, max_events=512)
def deliver(self, event: DuplexEvent) -> None:
self._outbox.put_nowait(event)
self._outbox.put(event)
def output_guard(self, event: DuplexEvent):
return self._outbox.guard(event)
async def submit(self, command: commands.DuplexCommand) -> None:
if self.closed:
@@ -107,6 +111,10 @@ class FakeHandle:
async def events(self):
while True:
event = await self._outbox.get()
if event is None:
return
if not self._outbox.is_valid(event):
continue
yield event
if event.is_terminal:
return
@@ -9,6 +9,8 @@ import json
import pytest
from vllm_omni.engine.duplex.delivery import DuplexOutputBuffer
from vllm_omni.engine.duplex.events import AudioDelta, ResponseDone
from vllm_omni.entrypoints.duplex.session_attachment import (
DuplexEventJournal,
DuplexJournalGapError,
@@ -22,6 +24,36 @@ from vllm_omni.entrypoints.duplex.session_attachment import (
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.mark.asyncio
@pytest.mark.parametrize("journal", [False, True])
async def test_audio_invalidated_while_waiting_for_send_is_not_sequenced_or_journaled(mocker, journal: bool):
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=4096)
output = DuplexOutputBuffer(max_bytes=4096, max_events=8)
sent: list[dict[str, object]] = []
accepted = mocker.Mock()
await registry.create("s", send=mocker.AsyncMock(side_effect=sent.append), close=mocker.AsyncMock())
audio = AudioDelta(session_id="s", response_id="r", epoch=0, delta="AAAA")
output.put(audio)
assert await output.get() is audio
state = registry._sessions["s"]
async with state.outbound_lock:
delivery = asyncio.create_task(
registry.send_event(
"s", audio.to_realtime(), journal=journal, on_accepted=accepted, event_guard=lambda: output.guard(audio)
)
)
await asyncio.sleep(0)
assert not delivery.done()
output.invalidate("r", through_epoch=0)
assert await delivery is None
accepted.assert_not_called()
done = ResponseDone(response_id="r", response={"status": "cancelled"})
await registry.send_event("s", done.to_realtime())
assert [(entry["type"], entry["server_event_seq"]) for entry in sent] == [("response.done", 1)]
assert [entry.payload["event_id"] for entry in state.journal.replay_after(0)] == [done.event_id]
class _Clock:
def __init__(self) -> None:
self.value = 0.0
+36 -2
View File
@@ -15,6 +15,7 @@ from tests.entrypoints.test_omni_entrypoints import FakeAsyncOmniEngine
from vllm_omni.config.stage_config import DuplexSessionRuntimeConfig
from vllm_omni.engine.duplex import commands
from vllm_omni.engine.duplex.config import DuplexCapabilities, DuplexSessionConfig
from vllm_omni.engine.duplex.delivery import DuplexOutputBuffer
from vllm_omni.engine.duplex.events import (
AudioDelta,
DuplexEvent,
@@ -41,6 +42,7 @@ class FakeDuplexEngine(FakeAsyncOmniEngine):
self.duplex_session_config = DuplexSessionRuntimeConfig(max_sessions=2)
self.duplex_capabilities = DuplexCapabilities(supports_session_resume=True)
self.opened: list[tuple[str, DuplexSessionConfig]] = []
self.outputs: dict[str, DuplexOutputBuffer] = {}
self.closed: list[tuple[str, str]] = []
self.resumed: list[tuple[str, int]] = []
#: The control id of every resume RPC, in order (a replay repeats one).
@@ -64,10 +66,11 @@ class FakeDuplexEngine(FakeAsyncOmniEngine):
def _result(self, operation: str, session_id: str, **fields: Any) -> DuplexControlResultMessage:
return DuplexControlResultMessage(control_id="c", operation=operation, session_id=session_id, ok=True, **fields)
async def open_session_async(self, session_id, session_config, *, timeout=None):
async def open_session_async(self, session_id, session_config, *, output_buffer, timeout=None):
self.opened.append((session_id, session_config))
if self.open_error is not None:
raise self.open_error
self.outputs[session_id] = output_buffer
return self._result(
"open",
session_id,
@@ -119,7 +122,11 @@ class FakeDuplexEngine(FakeAsyncOmniEngine):
def emit(self, session_id: str, event: DuplexEvent) -> None:
# The session manager binds the session identity to every event it emits.
event = replace(event, session_id=session_id)
self.output_q.put(DuplexSessionEventMessage(session_id=session_id, event=event))
if isinstance(event, SessionClosed):
self.outputs.pop(session_id, None)
self.output_q.put(DuplexSessionEventMessage(session_id=session_id, event=event))
elif session_id in self.outputs:
self.outputs[session_id].put(event)
def _make_omni(monkeypatch: pytest.MonkeyPatch) -> tuple[DuplexOmni, FakeDuplexEngine]:
@@ -152,6 +159,7 @@ async def test_open_session_allocates_the_id_and_ignores_client_ids(monkeypatch)
assert handle.session_id != "mine"
((session_id, config),) = engine.opened
assert session_id == handle.session_id
assert engine.outputs[session_id] is handle._outbox
assert isinstance(config, DuplexSessionConfig)
assert config.model == "dummy-model" and config.instructions == "hi"
assert handle.capabilities is engine.duplex_capabilities
@@ -169,6 +177,32 @@ async def test_open_session_allocates_the_id_and_ignores_client_ids(monkeypatch)
omni.shutdown()
@pytest.mark.asyncio
async def test_events_skips_audio_invalidated_after_dequeue(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)
try:
handle = await omni.open_session()
output = engine.outputs[handle.session_id]
original_get = output.get
async def get_then_invalidate() -> DuplexEvent | None:
event = await original_get()
if isinstance(event, AudioDelta) and event.response_id == "cancelled":
output.invalidate("cancelled", through_epoch=0)
return event
monkeypatch.setattr(output, "get", get_then_invalidate)
engine.emit(handle.session_id, AudioDelta(response_id="cancelled", epoch=0, delta="old"))
engine.emit(handle.session_id, AudioDelta(response_id="current", epoch=1, delta="new"))
events = await _collect(handle, 1)
assert isinstance(events[0], AudioDelta)
assert events[0].response_id == "current" and events[0].delta == "new"
finally:
omni.shutdown()
@pytest.mark.asyncio
async def test_open_failure_drops_the_pending_handle_and_raises_the_engine_code(monkeypatch) -> None:
omni, engine = _make_omni(monkeypatch)