Files
vllm-omni/vllm_omni/engine/duplex/session/append_task.py
T

254 lines
12 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""One append in flight, and what its failure owes the session.
An append is not a call: it is a task queued behind the previous append, which
may be cancelled, may find the session closed before it starts, and on any of
those paths has to give back what it reserved --- the PCM bytes it took from the
input budget, the committed audio it was going to consume, and the response it
precreated.
That compensation is the reason this is an object. The five steps of an append
all need the same eleven values, so as closures over ``_start_append`` they read
as one function with five entry points and no way to test any of them. Named
fields make the captured state explicit and the compensation a method.
The precreated response has two other owners the compensation defers to. When
the runner cancels the append on purpose (``DuplexSessionTasks.cancel_append_tasks``,
for a ``response.cancel``, a barge-in or a close), the runner ends that response
itself right after, with the cancel status the client asked for. When the
session is already closing, the close ends it. Everywhere else, the append is
the last thing that knows the response will never be filled, and it fails it.
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from vllm.logger import init_logger
from vllm_omni.engine.duplex.config import DuplexSessionState, DuplexTurnEventType
from vllm_omni.engine.duplex.plugin import PcmAppendReservation
from vllm_omni.engine.duplex.session import helpers
from vllm_omni.engine.duplex.session.context import DuplexSessionContext
from vllm_omni.engine.duplex.session.emitter import SessionEmitter
from vllm_omni.engine.duplex.session.model_channel import ModelChannel
logger = init_logger(__name__)
@dataclass(slots=True)
class AppendAttempt:
"""One queued append to the model, with the rollback each failure path owes."""
ctx: DuplexSessionContext
out: SessionEmitter
model: ModelChannel
#: Marks the session closing before the close coroutine gets to run.
fail_session: Callable[[str], None]
payload: dict[str, object]
epoch: int
request_id: str
final: bool
pcm_reservation: PcmAppendReservation | None
operation_id: str | None
#: Committed audio this append consumes; released once it is safely submitted.
retained_committed_payload: dict[str, object] | None
#: Response reserved before submission, to be failed if submission does not happen.
precreated_response_id: str | None
#: Whether ``_start_append`` bound the session's active request to this
#: append (a final or response-bound one), so an append that never happens
#: has to unbind it again.
owns_request: bool = False
#: Commits timing state once the runtime accepts the append (before any
#: returned output event can clear the continuation chain).
on_append_accepted: Callable[[float], None] | None = None
#: Last chance to call the append off, checked once the predecessor is done.
before_append: Callable[[], bool] | None = None
# ------------------------------------------------------------------ #
# Compensation #
# ------------------------------------------------------------------ #
def discard_retained_audio(self) -> None:
"""Release the committed audio this append was going to consume.
Only if it is still the session's: a later commit may have replaced it,
and that one belongs to the append that will carry it.
"""
model_state = self.ctx.model_state
if self.retained_committed_payload is not None and (
model_state.committed_audio_payload is self.retained_committed_payload
):
self.ctx.session.release_input_bytes(model_state.clear_committed_audio())
def abandon(self, *, reason: str = "runtime_append_failed", response_taken_over: bool = False) -> None:
"""Give back everything this append reserved but never used.
That includes the response it precreated: once the append is off,
nothing will ever fill it, and a client that saw ``response.created``
is owed a ``response.done``. It is left alone only when somebody else
ends it: the runner that cancelled this append on purpose
(``response_taken_over``) or the close already under way.
"""
if self.pcm_reservation is not None:
self.pcm_reservation.rollback()
self.discard_retained_audio()
if response_taken_over:
return
session = self.ctx.session
if self.ctx.run.closing or session.state != DuplexSessionState.OPEN:
# The close ends the active response and request itself, with the
# session's one terminal event after them.
return
if self.owns_request and session.epoch == self.epoch:
# Compare-before-clear: only if the session still points at this
# append's request, exactly as the submitted path does.
session.clear_request(self.request_id)
if self._precreated_response_still_active():
self._fail_precreated_response(reason)
def release_on_failure(self, done: asyncio.Task[bool]) -> None:
"""Done-callback: a cancelled or failed append must not hold the audio."""
if done.cancelled():
self.discard_retained_audio()
return
try:
append_ok = done.result()
except Exception:
append_ok = False
if not append_ok:
self.discard_retained_audio()
def clear_pending_silence(self, done: asyncio.Task[bool]) -> None:
"""Done-callback of a silence continuation: it is no longer pending."""
model_state = self.ctx.model_state
if model_state.pending_silence_task is done:
model_state.pending_silence_task = None
model_state.pending_silence_owner_id = None
# ------------------------------------------------------------------ #
# Running #
# ------------------------------------------------------------------ #
async def run_in_wire_order(self, predecessor: asyncio.Task[bool] | None) -> bool:
"""Wait for the previous append, then submit this one if it still applies.
A cancellation is compensated here, wherever it lands (waiting for the
predecessor or mid-submit): the reservations go back, and the
precreated response is failed unless the runner cancelled the append
itself, in which case it ends the response with the status the
cancellation was for.
"""
try:
return await self._run(predecessor)
except asyncio.CancelledError:
taken_over = self.ctx.tasks.cancelled_by_runner(asyncio.current_task())
self.abandon(reason="append_cancelled", response_taken_over=taken_over)
raise
async def _run(self, predecessor: asyncio.Task[bool] | None) -> bool:
if predecessor is not None:
try:
predecessor_ok = await predecessor
except asyncio.CancelledError:
if helpers.task_is_cancelling(asyncio.current_task()):
raise
predecessor_ok = False
except Exception:
predecessor_ok = False
if not predecessor_ok:
self.abandon()
return False
run = self.ctx.run
if run.closing or run.runtime_closed or self.ctx.session.state != DuplexSessionState.OPEN:
self.abandon()
return False
if self.before_append is not None and not self.before_append():
self.abandon()
# Called off, not failed: the chain behind it still runs.
return True
if self.pcm_reservation is not None and not self.pcm_reservation.active:
# The client cleared the input buffer while this append waited its
# turn: the audio is gone, so is the response it was going to
# answer, and that is the client's doing, not a runtime failure.
# The reservation rollback itself is already a no-op.
self.abandon(reason="input_cleared")
return False
return await self._submit()
async def _submit(self) -> bool:
session = self.ctx.session
model_state = self.ctx.model_state
try:
append_ok, emitted_response = await self.model.append_runtime_input(
self.payload,
operation_id=(
self.pcm_reservation.operation_id if self.pcm_reservation is not None else self.operation_id
),
final=self.final,
expected_epoch=self.epoch,
on_append_accepted=self.on_append_accepted,
)
if append_ok:
model_state.context_locked = True
if self.pcm_reservation is not None:
self.pcm_reservation.commit()
session.release_input_bytes(self.pcm_reservation.byte_count)
self.discard_retained_audio()
else:
self.abandon()
if not append_ok and session.state == DuplexSessionState.CLOSED:
self.ctx.run.runtime_closed = True
return False
if not emitted_response and session.epoch == self.epoch:
# Resident Stage0 (``…r.stage0``): clear the listen-only bind.
# Ephemeral ``…r.stage{N}-turn{T}`` must stay bound after a
# listen-only append (``clear_request`` compares before clear).
if session.capabilities.supports_core_resumable_request:
session.clear_request(self.request_id)
else:
active = session.active_request_id
if isinstance(active, str) and active.endswith(".r.stage0"):
session.clear_request(active)
if self.final:
self.out.emit_events([session.signal_turn(DuplexTurnEventType.USER_STARTED.value)])
return append_ok
except asyncio.CancelledError:
raise
except Exception as exc:
# Compensate while the session is still open, so the client gets
# the failed ``response.done`` before the error and the close.
self.abandon(reason="runtime_append_task_failed")
logger.exception("Native duplex append task failed: %s", exc)
self.model.send_runtime_error("runtime_append_task_failed", exc)
if session.state != DuplexSessionState.CLOSED:
self.fail_session("runtime_append_task_failed")
return False
def _precreated_response_still_active(self) -> bool:
return (
self.precreated_response_id is not None
and self.ctx.session.active_response_id == self.precreated_response_id
)
def _fail_precreated_response(self, reason: str) -> None:
session = self.ctx.session
session.end_response(commit_text=False)
self.out.emit(
{
"type": "response.done",
"session_id": session.session_id,
"response_id": self.precreated_response_id,
"epoch": session.epoch,
"committed": False,
"status": "failed",
"status_details": {"type": "failed", "reason": reason},
"playback": session.playback.as_dict(),
}
)