[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:
authored and GitHub committed 2026-09-24 19:23:08 -07:00
1 parent 551a03102f
commit 9e7e3a802c
19 files changed
+1697 -96

No files matched your search

+59 -11
View File
@@ -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(),
}
)