Signed-off-by: chickeyton <ngton2014@gmail.com> Signed-off-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
254 lines
12 KiB
Python
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(),
|
|
}
|
|
)
|