[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

+422 -3
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import asyncio
import base64
import json
from collections.abc import Awaitable, Callable
from contextlib import suppress
from typing import Any
@@ -131,7 +132,15 @@ class FakeOmni:
self.handles: dict[str, FakeHandle] = {}
self.resumed: list[tuple[str, int]] = []
self.detached: list[str] = []
#: Detaches the engine refused because the caller's lease generation was stale.
self.detach_refused: list[tuple[str, int]] = []
self.open_error: DuplexSessionError | None = None
#: When set, ``resume_session`` parks on it once the engine applied the
#: resume: the lease generation is bumped, the result is still in flight.
self.resume_gate: asyncio.Event | None = None
self.resume_started = asyncio.Event()
#: Settlements of resumes whose caller was cancelled mid-RPC (DuplexOmni's contract).
self.compensations: list[asyncio.Task[None]] = []
async def open_session(self, config: Any) -> FakeHandle:
self.opened.append(dict(config))
@@ -146,13 +155,46 @@ class FakeOmni:
def get_session(self, session_id: str) -> FakeHandle | None:
return self.handles.get(session_id)
async def resume_session(self, session_id: str, *, expected_lease_generation: int) -> FakeHandle:
async def resume_session(
self,
session_id: str,
*,
expected_lease_generation: int,
on_abandoned: Callable[[int], Awaitable[None]] | None = None,
) -> FakeHandle:
self.resumed.append((session_id, expected_lease_generation))
handle = self.handles[session_id]
handle.lease_generation = expected_lease_generation + 1
lease_generation = expected_lease_generation + 1
handle.lease_generation = lease_generation
if self.resume_gate is not None:
self.resume_started.set()
gate = self.resume_gate
try:
await gate.wait()
except asyncio.CancelledError:
# DuplexOmni's contract: the engine applied the resume; once
# its answer arrives the landed generation is settled off the
# cancelled task, through the caller's callback if it gave one.
async def settle() -> None:
await gate.wait()
if on_abandoned is not None:
await on_abandoned(lease_generation)
else:
with suppress(DuplexSessionError):
await self.detach_session(session_id, expected_lease_generation=lease_generation)
self.compensations.append(asyncio.create_task(settle()))
raise
return handle
async def detach_session(self, session_id: str) -> None:
async def detach_session(self, session_id: str, *, expected_lease_generation: int | None = None) -> None:
handle = self.handles[session_id]
if expected_lease_generation is not None and expected_lease_generation != handle.lease_generation:
# The engine's fence: a detach of a lease the caller no longer holds is refused.
self.detach_refused.append((session_id, expected_lease_generation))
raise DuplexSessionError(
"duplex lease generation mismatch", code="session_resume_conflict", session_id=session_id
)
self.detached.append(session_id)
@@ -607,3 +649,380 @@ async def test_a_resume_that_fails_to_activate_does_not_detach_the_live_attachme
pending.cancel()
with suppress(asyncio.CancelledError, Exception):
await pending
# --------------------------------------------------------------------------- #
# Cancelled resume and the engine disconnect grace (#7636 Issue 3) #
# --------------------------------------------------------------------------- #
@pytest.mark.asyncio
async def test_a_resume_cancelled_during_activation_puts_the_engine_lease_back_into_grace() -> None:
"""``_resume`` resumes the engine lease first; a cancel mid-activation must detach it again.
The rollback used to catch ``Exception`` only. A handler task cancelled
while sending ``session.resumed`` or a replay entry left the engine with
``detached_at=None`` and the outer handler with no attachment to clean up:
the reaper reclaimed nothing after the grace, and with one slot the next
open was refused with ``resource_exhausted``.
"""
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
ws.disconnect()
await asyncio.wait_for(task, timeout=2.0)
assert omni.detached == [handle.session_id]
registry = handler._attachment_registry
activating = asyncio.Event()
async def parked_resume(*args, **kwargs):
activating.set()
await asyncio.Event().wait()
original_resume = registry.resume
registry.resume = parked_resume # type: ignore[method-assign]
try:
ws2, task2 = await _resume(handler, handle.session_id, token)
await asyncio.wait_for(activating.wait(), timeout=2.0)
assert omni.resumed == [(handle.session_id, 0)], "the engine lease was resumed before activation"
task2.cancel()
with suppress(asyncio.CancelledError):
await task2
finally:
registry.resume = original_resume # type: ignore[method-assign]
# The lease is detached again, so the disconnect grace runs for it.
assert omni.detached == [handle.session_id, handle.session_id]
assert not await registry.has_attachment(handle.session_id)
assert handle.close_reasons == []
# And the session is still resumable with the token the cancelled attempt presented.
ws3, task3 = await _resume(handler, handle.session_id, token)
resumed = await ws3.wait_for("session.resumed")
assert resumed["session_id"] == handle.session_id
ws3.disconnect()
await asyncio.wait_for(task3, timeout=2.0)
@pytest.mark.asyncio
async def test_a_resume_cancelled_after_activation_detaches_the_attachment_it_made() -> None:
"""A cancel after the registry activated the new attachment must undo that attachment too.
Otherwise the session stays attached to a socket nobody serves, with the
engine lease resumed: no grace, no expiry, until the idle TTL.
"""
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
registry = handler._attachment_registry
# The takeover notifies the replaced socket after activation; park there.
notifying = asyncio.Event()
async def parked_send(payload: dict[str, Any]) -> None:
notifying.set()
await asyncio.Event().wait()
ws.send_json = parked_send # type: ignore[method-assign]
ws2, task2 = await _resume(handler, handle.session_id, token)
await asyncio.wait_for(notifying.wait(), timeout=2.0)
assert await registry.is_current_attachment(handle.session_id, 2), "the new socket is attached"
task2.cancel()
with suppress(asyncio.CancelledError):
await task2
assert not await registry.has_attachment(handle.session_id)
assert omni.detached == [handle.session_id]
assert handle.close_reasons == []
task.cancel()
with suppress(asyncio.CancelledError, Exception):
await task
@pytest.mark.asyncio
async def test_an_abandoned_resume_never_detaches_the_lease_a_later_resume_owns() -> None:
"""Two connections resuming the same session: the loser's rollback must not touch the winner's lease.
Interleaving: A has activated and is notifying the socket it replaced; B,
holding A's rotated token, has completed its engine resume but its RPC
result has not reached the handler yet, so nothing is attached for B.
Cancelling A detaches A's registry generation, which used to make the
engine detach unconditional: it landed on the lease B had just resumed,
and the reaper expired B after the grace even though it heartbeats. The
engine now refuses a detach fenced on a generation it has moved past.
"""
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
registry = handler._attachment_registry
# A takes over and parks while notifying the replaced socket (after activation).
notifying = asyncio.Event()
async def parked_send(payload: dict[str, Any]) -> None:
notifying.set()
await asyncio.Event().wait()
ws.send_json = parked_send # type: ignore[method-assign]
ws_a, task_a = await _resume(handler, handle.session_id, token)
await asyncio.wait_for(notifying.wait(), timeout=2.0)
resumed_a = await ws_a.wait_for("session.resumed")
assert handle.lease_generation == 1
# B resumes with A's rotated token: the engine applies it, the result is still in flight.
omni.resume_gate = asyncio.Event()
ws_b, task_b = await _resume(handler, handle.session_id, resumed_a["resume_token"])
await asyncio.wait_for(omni.resume_started.wait(), timeout=2.0)
assert handle.lease_generation == 2
task_a.cancel()
with suppress(asyncio.CancelledError):
await task_a
assert omni.detached == [], "A's rollback must not detach the lease B resumed"
assert omni.detach_refused == [(handle.session_id, 1)]
assert not await registry.has_attachment(handle.session_id), "A's attachment is gone"
omni.resume_gate.set()
resumed_b = await ws_b.wait_for("session.resumed")
assert resumed_b["attachment_generation"] == 3
assert await registry.is_current_attachment(handle.session_id, 3)
assert omni.detached == []
# B's own disconnect detaches the lease B holds.
ws_b.disconnect()
await asyncio.wait_for(task_b, timeout=2.0)
assert omni.detached == [handle.session_id]
assert omni.detach_refused == [(handle.session_id, 1)]
task.cancel()
with suppress(asyncio.CancelledError, Exception):
await task
@pytest.mark.asyncio
async def test_an_abandoned_takeover_leaves_the_live_connection_serving_and_owning_the_lease() -> None:
"""A takeover cancelled mid-RPC must not put the connection it never replaced into disconnect grace.
A is attached at generation 0. B's engine resume lands generation 1 and B
is cancelled before it reaches the registry, so A stays the current
attachment. Settling B's generation must hand it to A rather than detach
it: A keeps serving, and A's own disconnect later detaches generation 1,
which is the lease the engine actually holds.
"""
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
registry = handler._attachment_registry
omni.resume_gate = asyncio.Event()
ws_b, task_b = await _resume(handler, handle.session_id, token)
await asyncio.wait_for(omni.resume_started.wait(), timeout=2.0)
assert handle.lease_generation == 1
task_b.cancel()
with suppress(asyncio.CancelledError):
await task_b
omni.resume_gate.set()
await asyncio.gather(*omni.compensations)
assert omni.detached == [], "A is still serving: its lease must not enter disconnect grace"
assert omni.detach_refused == []
assert await registry.is_current_attachment(handle.session_id, 1), "A is still the attachment"
handle.deliver(AudioDelta(session_id=handle.session_id, response_id="r1", delta="aGk="))
await ws.wait_for("response.output_audio.delta")
# A now owns generation 1: its disconnect detaches the lease the engine holds.
ws.disconnect()
await asyncio.wait_for(task, timeout=2.0)
assert omni.detached == [handle.session_id]
assert omni.detach_refused == []
@pytest.mark.asyncio
async def test_a_send_failure_detaches_the_failed_sockets_own_lease_not_a_pending_resumes() -> None:
"""The pump's detach is fenced on the lease of the socket whose send failed, captured with it.
The pump holds the registry's outbound lock while sending to A. B's
engine resume lands generation 1 meanwhile and B waits for that lock to
activate. When A's send fails, the pump drops A and must detach A's
generation 0, which the engine refuses, not B's generation 1, which would
put B into disconnect grace the moment it activates.
"""
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
registry = handler._attachment_registry
sending = asyncio.Event()
fail_send = asyncio.Event()
async def parked_failing_send(payload: dict[str, Any]) -> None:
sending.set()
await fail_send.wait()
raise RuntimeError("socket closed")
ws.send_json = parked_failing_send # type: ignore[method-assign]
handle.deliver(AudioDelta(session_id=handle.session_id, response_id="r1", delta="aGk="))
await asyncio.wait_for(sending.wait(), timeout=2.0)
# B: the engine resume lands; activation waits for the outbound lock the pump holds.
ws_b, task_b = await _resume(handler, handle.session_id, token)
await asyncio.sleep(0.05)
assert omni.resumed == [(handle.session_id, 0)]
assert handle.lease_generation == 1
assert await registry.is_current_attachment(handle.session_id, 1), "B has not activated yet"
fail_send.set()
resumed_b = await ws_b.wait_for("session.resumed")
assert resumed_b["attachment_generation"] == 2
assert omni.detached == [], "A's lease (generation 0) is refused, B's lease is untouched"
assert omni.detach_refused == [(handle.session_id, 0)]
assert await registry.is_current_attachment(handle.session_id, 2)
# B serves; its own disconnect detaches the lease it resumed.
ws_b.disconnect()
await asyncio.wait_for(task_b, timeout=2.0)
assert omni.detached == [handle.session_id]
ws.disconnect()
with suppress(asyncio.CancelledError, Exception):
await asyncio.wait_for(task, timeout=2.0)
@pytest.mark.asyncio
async def test_an_abandoned_resume_goes_to_the_resume_waiting_to_activate_not_the_socket_it_replaces() -> None:
"""Three connections: A sending, B resumed and waiting for the outbound lock, C abandoned mid-RPC.
C's generation 2 must be parked for B's activation. If it were handed to
A instead, A's send failure would detach generation 2 (accepted by the
engine), and B would then activate on a lease already in disconnect grace
and expire despite heartbeating.
"""
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
registry = handler._attachment_registry
sending = asyncio.Event()
fail_send = asyncio.Event()
async def parked_failing_send(payload: dict[str, Any]) -> None:
sending.set()
await fail_send.wait()
raise RuntimeError("socket closed")
ws.send_json = parked_failing_send # type: ignore[method-assign]
handle.deliver(AudioDelta(session_id=handle.session_id, response_id="r1", delta="aGk="))
await asyncio.wait_for(sending.wait(), timeout=2.0)
# B: engine resume lands generation 1; activation waits for the lock the pump holds.
ws_b, task_b = await _resume(handler, handle.session_id, token)
await asyncio.sleep(0.05)
assert handle.lease_generation == 1
# C: same unrotated token, engine resume lands generation 2, cancelled mid-RPC.
omni.resume_gate = asyncio.Event()
ws_c, task_c = await _resume(handler, handle.session_id, token)
await asyncio.wait_for(omni.resume_started.wait(), timeout=2.0)
assert handle.lease_generation == 2
task_c.cancel()
with suppress(asyncio.CancelledError):
await task_c
omni.resume_gate.set()
await asyncio.gather(*omni.compensations)
assert omni.detached == [] and omni.detach_refused == [], "generation 2 is parked for B"
# A's send fails: the pump drops A and detaches A's own generation 0, refused.
fail_send.set()
resumed_b = await ws_b.wait_for("session.resumed")
assert resumed_b["attachment_generation"] == 2
assert omni.detach_refused == [(handle.session_id, 0)]
assert omni.detached == [], "the lease B serves is untouched"
assert await registry.is_current_attachment(handle.session_id, 2)
# B serves generation 2 (inherited from C): its disconnect detaches that lease.
ws_b.disconnect()
await asyncio.wait_for(task_b, timeout=2.0)
assert omni.detached == [handle.session_id]
assert omni.detach_refused == [(handle.session_id, 0)]
ws.disconnect()
with suppress(asyncio.CancelledError, Exception):
await asyncio.wait_for(task, timeout=2.0)
@pytest.mark.asyncio
async def test_a_resume_cancelled_during_replay_settles_the_newer_lease_it_was_handed() -> None:
"""Activation rollback must give back the lease the provisional attachment actually owned.
A resumed generation 1, delivered ``session.resumed`` and is blocked
replaying events. B, with A's rotated token, resumed generation 2 and was
cancelled mid-RPC; its settlement handed 2 to A. When A is then cancelled
during replay, the registry rolls A back; detaching A's own generation 1
would be refused and generation 2 would never enter disconnect grace.
"""
omni = FakeOmni()
handler = _handler(omni)
ws, handle, task = await _open(handler, omni)
token = ws.sent[0]["resume_token"]
registry = handler._attachment_registry
ws.disconnect()
await asyncio.wait_for(task, timeout=2.0)
assert omni.detached == [handle.session_id]
# Journaled while detached: A's activation has something to replay.
handle.deliver(AudioDelta(session_id=handle.session_id, response_id="r1", delta="one"))
await asyncio.sleep(0.05)
# A: session.resumed goes out, the replay entry parks.
ws_a = FakeWebSocket({"duplex": "1", "resume": "1"})
replaying = asyncio.Event()
sends = 0
original_send = ws_a.send_json
async def send_parking_on_replay(payload: dict[str, Any]) -> None:
nonlocal sends
sends += 1
if sends == 2:
replaying.set()
await asyncio.Event().wait()
await original_send(payload)
ws_a.send_json = send_parking_on_replay # type: ignore[method-assign]
task_a = asyncio.create_task(handler.handle_realtime_session(ws_a))
ws_a.feed(
{
"type": "session.resume",
"session_id": handle.session_id,
"resume_token": token,
"last_received_server_event_seq": 0,
}
)
await asyncio.wait_for(replaying.wait(), timeout=2.0)
resumed_a = await ws_a.wait_for("session.resumed")
assert handle.lease_generation == 1
# B: A's rotated token, engine resume lands generation 2, cancelled mid-RPC.
omni.resume_gate = asyncio.Event()
ws_b, task_b = await _resume(handler, handle.session_id, resumed_a["resume_token"])
await asyncio.wait_for(omni.resume_started.wait(), timeout=2.0)
assert handle.lease_generation == 2
task_b.cancel()
with suppress(asyncio.CancelledError):
await task_b
omni.resume_gate.set()
await asyncio.gather(*omni.compensations)
assert omni.detached == [handle.session_id], "generation 2 went to A's pending activation"
# A is cancelled during replay: its rollback settles generation 2, the lease A owned.
task_a.cancel()
with suppress(asyncio.CancelledError):
await task_a
assert not await registry.has_attachment(handle.session_id)
assert omni.detached == [handle.session_id, handle.session_id]
assert omni.detach_refused == [], "generation 1 was never detached: the engine would refuse it"