[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
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user