[Bugfix] Compensate cancelled duplex open, append and resume; fix serving README drift (#7636 Issues 1, 2, 3, 21) (#7785)
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>
This commit is contained in:
19 files changed
+1697
-96
No files matched your search
@@ -13,6 +13,13 @@ 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
|
||||
@@ -53,6 +60,10 @@ class AppendAttempt:
|
||||
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
|
||||
@@ -75,11 +86,31 @@ class AppendAttempt:
|
||||
):
|
||||
self.ctx.session.release_input_bytes(model_state.clear_committed_audio())
|
||||
|
||||
def abandon(self) -> None:
|
||||
"""Give back everything this append reserved but never used."""
|
||||
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."""
|
||||
@@ -105,7 +136,22 @@ class AppendAttempt:
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
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."""
|
||||
"""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
|
||||
@@ -127,7 +173,11 @@ class AppendAttempt:
|
||||
# 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:
|
||||
self.discard_retained_audio()
|
||||
# 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()
|
||||
|
||||
@@ -152,8 +202,6 @@ class AppendAttempt:
|
||||
self.discard_retained_audio()
|
||||
else:
|
||||
self.abandon()
|
||||
if not append_ok and self._precreated_response_still_active():
|
||||
self._fail_precreated_response()
|
||||
if not append_ok and session.state == DuplexSessionState.CLOSED:
|
||||
self.ctx.run.runtime_closed = True
|
||||
return False
|
||||
@@ -171,11 +219,11 @@ class AppendAttempt:
|
||||
self.out.emit_events([session.signal_turn(DuplexTurnEventType.USER_STARTED.value)])
|
||||
return append_ok
|
||||
except asyncio.CancelledError:
|
||||
if self.pcm_reservation is not None:
|
||||
self.pcm_reservation.rollback()
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.abandon()
|
||||
# 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:
|
||||
@@ -188,7 +236,7 @@ class AppendAttempt:
|
||||
and self.ctx.session.active_response_id == self.precreated_response_id
|
||||
)
|
||||
|
||||
def _fail_precreated_response(self) -> None:
|
||||
def _fail_precreated_response(self, reason: str) -> None:
|
||||
session = self.ctx.session
|
||||
session.end_response(commit_text=False)
|
||||
self.out.emit(
|
||||
@@ -199,7 +247,7 @@ class AppendAttempt:
|
||||
"epoch": session.epoch,
|
||||
"committed": False,
|
||||
"status": "failed",
|
||||
"status_details": {"type": "failed", "reason": "runtime_append_failed"},
|
||||
"status_details": {"type": "failed", "reason": reason},
|
||||
"playback": session.playback.as_dict(),
|
||||
}
|
||||
)
|
||||
Reference in new issue
Block a user