[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

+26
View File
@@ -85,6 +85,32 @@ def test_detach_grace_and_resume_advance_the_lease_generation() -> None:
session.resume_lease(expected_lease_generation=0)
def test_a_resume_replayed_under_its_control_id_answers_without_resuming_again() -> None:
"""A caller that lost the answer (cancelled or timed-out waiter) asks again with the same id.
The engine must tell it the generation that resume produced, not bump the
lease a second time, and must still refuse any other resume against the
old generation.
"""
clock = FakeMonotonicClock(10.0)
session = _session("sid-replay", clock)
session.detach_lease()
assert session.resume_lease(expected_lease_generation=0, control_id="rpc-a") == 1
clock.advance(5.0)
session.detach_lease()
assert session.resume_lease(expected_lease_generation=0, control_id="rpc-a") == 1
assert session.lease_generation == 1
assert session.lease.detached_at == 15.0, "a replay is an answer, not a new resume"
with pytest.raises(ValueError, match="lease generation mismatch"):
session.resume_lease(expected_lease_generation=0, control_id="rpc-b")
assert session.resume_lease(expected_lease_generation=1, control_id="rpc-b") == 2
with pytest.raises(ValueError, match="lease generation mismatch"):
session.resume_lease(expected_lease_generation=0, control_id="rpc-a")
def test_active_operation_prevents_mid_transaction_expiry() -> None:
clock = FakeMonotonicClock(0.0)
session = _session("sid-operation", clock, idle_ttl_s=5.0)
+194 -4
View File
@@ -296,6 +296,7 @@ class Harness:
clock: FakeClock
output_sink: asyncio.Queue = field(default_factory=asyncio.Queue)
result_sink: asyncio.Queue = field(default_factory=asyncio.Queue)
_control_seq: int = 0
@classmethod
def create(
@@ -344,20 +345,30 @@ class Harness:
)
return await self.result()
async def resume(self, session_id: str, *, expected_lease_generation: int) -> DuplexControlResultMessage:
async def resume(
self, session_id: str, *, expected_lease_generation: int, control_id: str | None = None
) -> DuplexControlResultMessage:
# Distinct by default: the engine answers a repeated control id as a
# replay of the same resume, which only the replay test wants.
self._control_seq += 1
await self.manager.handle(
ResumeDuplexSessionMessage(
control_id=f"resume-{session_id}-{expected_lease_generation}",
control_id=control_id or f"resume-{session_id}-{expected_lease_generation}-{self._control_seq}",
session_id=session_id,
expected_lease_generation=expected_lease_generation,
)
)
return await self.result()
async def touch(self, session_id: str, activity: str) -> DuplexControlResultMessage:
async def touch(
self, session_id: str, activity: str, *, expected_lease_generation: int | None = None
) -> DuplexControlResultMessage:
await self.manager.handle(
TouchDuplexSessionMessage(
control_id=f"touch-{session_id}-{activity}", session_id=session_id, activity=activity
control_id=f"touch-{session_id}-{activity}-{expected_lease_generation}",
session_id=session_id,
activity=activity,
expected_lease_generation=expected_lease_generation,
)
)
return await self.result()
@@ -1656,6 +1667,168 @@ async def test_reaper_loop_survives_one_cleanup_failure(first_cleanup_delay: flo
await asyncio.wait_for(task, timeout=5.0)
# --------------------------------------------------------------------------- #
# Cancelled open (#7636 Issue 1) #
# --------------------------------------------------------------------------- #
class _BlockingResultSink(asyncio.Queue):
"""A result sink whose ``put`` parks, so an open can be cancelled after it admitted the session."""
def __init__(self) -> None:
super().__init__()
self.entered = asyncio.Event()
self.gate = asyncio.Event()
async def put(self, item: object) -> None:
self.entered.set()
await self.gate.wait()
await super().put(item)
async def test_an_open_cancelled_while_awaiting_the_plugin_frees_the_admission_slot() -> None:
"""``CancelledError`` is not an ``Exception``: the rollback used to be skipped.
Admission counts ``runners | closing | admitting``, so every open cancelled
mid-flight (engine teardown, task cancellation) burned one slot for good;
after ``max_sessions`` of them every new session was refused.
"""
async with Harness.create(max_sessions=1) as harness:
harness.plugin.blocked_session_ids.add("blocked")
config = DuplexSessionConfig(model="fake-model", instructions="blocked")
open_task = asyncio.create_task(harness.open("sid-cancelled", config))
await asyncio.wait_for(harness.plugin.runtime_config_started.wait(), timeout=1.0)
open_task.cancel()
with pytest.raises(asyncio.CancelledError):
await open_task
assert harness.manager.get("sid-cancelled") is None
assert harness.manager.active_count() == 0
assert harness.result_sink.empty(), "a cancelled open answers nobody"
assert (await harness.open("sid-replacement")).ok is True
async def test_an_open_cancelled_after_admission_releases_the_runner_and_its_stage_reservation() -> None:
"""The worst landing spot for the cancel: the runner is registered and Stage0 is reserved.
Both have to be undone, or the runner stays in ``runners`` (one slot gone)
and the Stage0 request stays reserved in the orchestrator.
"""
async with Harness.create(max_sessions=1) as harness:
sink = _BlockingResultSink()
original_sink = harness.manager._result_sink
harness.manager._result_sink = sink
open_task = asyncio.create_task(
harness.manager.handle(
OpenDuplexSessionMessage(
control_id="open-cancelled",
session_id="sid-cancelled",
session_config=DuplexSessionConfig(model="fake-model"),
)
)
)
await asyncio.wait_for(sink.entered.wait(), timeout=1.0)
assert "sid-cancelled" in harness.manager.runners
assert [context.request_id for context in harness.stage_port.ensure_calls] == [
stage0_request_id("sid-cancelled")
]
open_task.cancel()
with pytest.raises(asyncio.CancelledError):
await open_task
harness.manager._result_sink = original_sink
assert "sid-cancelled" not in harness.manager.runners
assert harness.manager.active_count() == 0
assert harness.stage_port.cleanup_calls == [([stage0_request_id("sid-cancelled")], False)]
assert stage0_request_id("sid-cancelled") not in harness.manager._request_index
assert (await harness.open("sid-replacement")).ok is True
async def test_an_open_cancelled_again_during_its_rollback_leaves_the_stage_cleanup_to_the_reaper() -> None:
"""A second cancellation landing in the rollback's awaits must not orphan the Stage0 request.
The runner, the reservations and the request index are already gone by
then, so nothing would ever clean the orchestrator's request state up.
The rollback records the ids as a pending request cleanup before it
awaits anything, and the reaper's retry finishes the job.
"""
async with Harness.create(max_sessions=1) as harness:
sink = _BlockingResultSink()
original_sink = harness.manager._result_sink
harness.manager._result_sink = sink
open_task = asyncio.create_task(
harness.manager.handle(
OpenDuplexSessionMessage(
control_id="open-cancelled-twice",
session_id="sid-cancelled",
session_config=DuplexSessionConfig(model="fake-model"),
)
)
)
await asyncio.wait_for(sink.entered.wait(), timeout=1.0)
runner = harness.manager.runners["sid-cancelled"]
shutdown_started = asyncio.Event()
async def parked_shutdown() -> None:
shutdown_started.set()
await asyncio.Event().wait()
runner.shutdown = parked_shutdown # type: ignore[method-assign]
open_task.cancel()
await asyncio.wait_for(shutdown_started.wait(), timeout=1.0)
open_task.cancel() # lands inside the rollback, before the stage cleanup
with pytest.raises(asyncio.CancelledError):
await open_task
harness.manager._result_sink = original_sink
assert "sid-cancelled" not in harness.manager.runners
assert harness.manager.active_count() == 0
assert harness.stage_port.cleanup_calls == [], "the cleanup await was never reached"
key = ("sid-cancelled", 0)
assert key in harness.manager._pending_request_cleanups
await harness.manager.reap_expired()
assert harness.stage_port.cleanup_calls == [([stage0_request_id("sid-cancelled")], False)]
assert key not in harness.manager._pending_request_cleanups
assert (await harness.open("sid-replacement")).ok is True
# --------------------------------------------------------------------------- #
# Detach fenced on the lease generation #
# --------------------------------------------------------------------------- #
async def test_detach_is_refused_for_a_lease_the_caller_no_longer_holds() -> None:
"""A connection giving up its lease must not start the grace for the lease a later resume owns.
Resume is a CAS on the lease generation; detach is now fenced the same
way, so a stale caller (its resume was superseded by another connection's)
is refused instead of detaching the winner.
"""
async with Harness.create() as harness:
await harness.open("sid-fence")
session = harness.session("sid-fence")
assert (await harness.resume("sid-fence", expected_lease_generation=0)).ok is True
assert session.lease_generation == 1
stale = await harness.touch("sid-fence", "detach", expected_lease_generation=0)
assert stale.ok is False
assert stale.error_code == "session_resume_conflict"
assert session.lease.detached_at is None, "the current lease is untouched"
current = await harness.touch("sid-fence", "detach", expected_lease_generation=1)
assert current.ok is True
assert session.lease.detached_at is not None
# An unfenced detach keeps its meaning: whatever generation is current.
assert (await harness.resume("sid-fence", expected_lease_generation=1)).ok is True
assert (await harness.touch("sid-fence", "detach")).ok is True
assert session.lease.detached_at is not None
# --------------------------------------------------------------------------- #
# Cancellation waits #
# --------------------------------------------------------------------------- #
@@ -1685,3 +1858,20 @@ async def test_cancel_append_tasks_absorbs_a_task_that_outlives_the_wait() -> No
assert await tasks.cancel_append_tasks(timeout_s=0.05) is True
assert tasks.append_tail is None
await asyncio.wait_for(asyncio.gather(task, return_exceptions=True), timeout=5.0)
async def test_a_resume_replayed_under_its_control_id_reports_the_generation_it_produced() -> None:
"""The client side of an abandoned resume: it lost the answer and asks again with the same id."""
async with Harness.create() as harness:
await harness.open("sid-replay")
session = harness.session("sid-replay")
first = await harness.resume("sid-replay", expected_lease_generation=0, control_id="rpc-a")
assert first.ok is True and first.lease_generation == 1
replay = await harness.resume("sid-replay", expected_lease_generation=0, control_id="rpc-a")
assert replay.ok is True
assert replay.lease_generation == 1
assert session.lease_generation == 1, "a replay does not resume again"
other = await harness.resume("sid-replay", expected_lease_generation=0, control_id="rpc-b")
assert other.ok is False and other.error_code == "session_resume_conflict"
+138 -2
View File
@@ -73,6 +73,9 @@ class RecordingStagePort(DuplexStagePort):
self.cleanups: list[tuple[list[str], bool]] = []
self.aborts: list[list[str]] = []
self.fail_submit: Exception | None = None
#: When set, ``submit`` parks on it after signalling ``submit_started``.
self.submit_gate: asyncio.Event | None = None
self.submit_started = asyncio.Event()
@property
def stage_count(self) -> int:
@@ -87,6 +90,9 @@ class RecordingStagePort(DuplexStagePort):
async def submit(self, submission: DuplexStageSubmission) -> DuplexStageSubmissionResult:
if self.fail_submit is not None:
raise self.fail_submit
if self.submit_gate is not None:
self.submit_started.set()
await self.submit_gate.wait()
self.submissions.append(submission)
return DuplexStageSubmissionResult(
request_id=submission.context.request_id,
@@ -163,7 +169,7 @@ class Harness:
def deliver(
self,
output: object,
output: SimpleNamespace,
*,
stage_id: int = 1,
segment_finished: bool = False,
@@ -186,7 +192,7 @@ class Harness:
raise AssertionError("stage output is missing request_id")
return self.runner.on_stage_output(stage_id, output, metrics, request_id=request_id, context=context)
async def deliver_and_settle(self, output: object, **kwargs: Any) -> list[DuplexEvent]:
async def deliver_and_settle(self, output: SimpleNamespace, **kwargs: Any) -> list[DuplexEvent]:
self.deliver(output, **kwargs)
return await self.settle()
@@ -1624,3 +1630,133 @@ async def test_server_vad_speech_stopped_still_commits_a_turn_mode_session() ->
assert len(_final_submissions(h)) == 1, "the detector's stop commits the turn and starts the response"
finally:
await close_harness(h)
# --------------------------------------------------------------------------- #
# Cancelled append and its precreated response (#7636 Issue 2) #
# --------------------------------------------------------------------------- #
async def _commit_with_pending_response(h: Harness) -> str:
"""Commit a turn whose final append parks in the stage port; return the precreated response id."""
await h.run(append_audio())
h.port.submit_gate = asyncio.Event()
h.submit(commands.Commit(create_response=True))
await asyncio.wait_for(h.port.submit_started.wait(), timeout=2.0)
# The parked append keeps the runner "busy": settle only until the mailbox is quiet.
events = await h.settle(timeout_s=0.3)
assert "response.created" in types(events)
response_id = h.session.active_response_id
assert response_id is not None
assert h.runner.tasks.append_tail is not None and not h.runner.tasks.append_tail.done()
return response_id
@pytest.mark.asyncio
async def test_an_append_cancelled_from_outside_fails_the_response_it_precreated() -> None:
"""A cancelled append gives back the response it precreated, as its docstring promises.
The ``CancelledError`` branch used to roll back only the PCM reservation,
so ``active_response_id`` stayed pinned to a response nobody would ever
finish: the client had seen ``response.created`` and waited forever.
"""
h = await open_harness(auto_response=False)
try:
response_id = await _commit_with_pending_response(h)
pending = h.runner.tasks.append_tail
assert pending is not None
pending.cancel()
events = await h.settle()
done = find(events, "response.done")
assert done.response_id == response_id
assert done.status == "failed"
assert done.response["status_details"]["reason"] == "append_cancelled"
assert h.session.active_response_id is None
assert h.session.state == DuplexSessionState.OPEN
# The cancelled append counts as a failed one: the chain behind it is
# refused with the documented error, not left hanging.
h.port.submit_gate = None
await h.run(append_audio())
events = await h.run(commands.Commit(create_response=True))
assert "response.created" not in types(events)
assert find(events, "error").code == "commit_aborted"
assert h.session.active_response_id is None
finally:
await close_harness(h)
@pytest.mark.asyncio
async def test_response_cancel_of_a_pending_append_ends_the_response_once_as_cancelled() -> None:
"""When the runner cancels the append itself, it owns the response: one terminal, status cancelled.
The append must not also fail the response on its way out, or the client
would get a failed ``response.done`` for a response it cancelled.
"""
h = await open_harness(auto_response=False)
try:
response_id = await _commit_with_pending_response(h)
events = await h.run(commands.CancelResponse())
terminals = [event for event in events if event.type == "response.done"]
assert [event.response_id for event in terminals] == [response_id]
assert terminals[0].status == "cancelled"
assert h.session.active_response_id is None
assert h.session.state == DuplexSessionState.OPEN
finally:
await close_harness(h)
@pytest.mark.asyncio
async def test_closing_the_session_with_a_pending_append_emits_no_failed_response() -> None:
"""A close ends the active response itself; the append it cancels stays quiet."""
h = await open_harness(auto_response=False)
try:
await _commit_with_pending_response(h)
events = await h.run(commands.CloseSession(reason="client_close"))
events += await h.settle()
assert [event.type for event in events if event.type == "response.done"] == []
assert "session.closed" in types(events)
assert h.session.active_response_id is None
finally:
await close_harness(h)
@pytest.mark.asyncio
async def test_input_clear_racing_a_queued_append_drops_it_as_the_clients_doing() -> None:
"""An ``input_audio_buffer.clear`` that lands while an append waits its turn is not a runtime failure.
The clear deactivates the queued append's reservation; when its turn
comes it gives everything back and stops, without an error and without
failing the session, and its reason is the client's clear rather than
``runtime_append_failed``.
"""
h = await open_harness()
try:
h.port.submit_gate = asyncio.Event()
h.submit(append_audio())
await asyncio.wait_for(h.port.submit_started.wait(), timeout=2.0)
h.submit(append_audio())
await h.settle(timeout_s=0.3)
assert len(h.runner.tasks.append_tasks) == 2, "the second append is queued behind the parked one"
queued = h.runner.tasks.append_tail
assert queued is not None and not queued.done()
h.submit(commands.ClearInput())
events = await h.settle(timeout_s=0.3)
assert types(events) == ["input_audio_buffer.cleared"]
h.port.submit_gate.set()
events = await h.settle()
assert queued.done() and queued.result() is False, "the cleared append never ran"
assert len(h.port.submissions) == 1, "only the append that was already in flight reached the stage"
assert [event.type for event in events if event.type in {"error", "response.done", "session.closed"}] == []
assert h.session.pending_input_bytes == 0
assert h.session.state == DuplexSessionState.OPEN
finally:
await close_harness(h)