[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
@@ -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"
|
||||
@@ -552,3 +552,90 @@ async def test_registry_resume_cancelled_mid_delivery_rolls_back_like_a_failure(
|
||||
close=close,
|
||||
)
|
||||
assert recovered.attachment_generation == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_release_hands_back_the_lease_generation_the_dropped_connection_was_serving(mocker) -> None:
|
||||
"""The lease generation travels with the attachment, so a detach is fenced on the right lease."""
|
||||
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=4096)
|
||||
send = mocker.AsyncMock()
|
||||
created = await registry.create("sid-lease", send=send, close=mocker.AsyncMock(), lease_generation=0)
|
||||
|
||||
# An abandoned takeover hands its generation to the connection still attached.
|
||||
assert await registry.settle_lease_generation("sid-lease", 1) is None
|
||||
assert await registry.settle_lease_generation("sid-lease", 0) is None, "generations only move forward"
|
||||
|
||||
released = await registry.release_attachment("sid-lease", attachment_generation=created.attachment_generation)
|
||||
assert released is not None
|
||||
assert (released.attachment_generation, released.lease_generation) == (1, 1)
|
||||
assert await registry.release_attachment("sid-lease", attachment_generation=None) is None
|
||||
assert await registry.settle_lease_generation("sid-lease", 2) == 2, "nobody is attached: the caller detaches it"
|
||||
|
||||
resumed = await registry.resume(
|
||||
"sid-lease",
|
||||
resume_token=created.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=send,
|
||||
close=mocker.AsyncMock(),
|
||||
lease_generation=3,
|
||||
)
|
||||
released = await registry.release_attachment("sid-lease", attachment_generation=resumed.attachment_generation)
|
||||
assert released is not None and released.lease_generation == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_orphaned_generation_waits_for_the_resume_that_is_about_to_activate(mocker) -> None:
|
||||
"""While a resume is pending, an orphan goes to its activation, not to the socket it will replace."""
|
||||
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=4096)
|
||||
send = mocker.AsyncMock()
|
||||
created = await registry.create("sid-pending", send=send, close=mocker.AsyncMock(), lease_generation=0)
|
||||
|
||||
await registry.begin_resume("sid-pending")
|
||||
assert await registry.settle_lease_generation("sid-pending", 2) is None, "parked for the pending activation"
|
||||
released = await registry.release_attachment("sid-pending", attachment_generation=created.attachment_generation)
|
||||
assert released is not None and released.lease_generation == 0, "the replaced socket keeps its own lease"
|
||||
|
||||
resumed = await registry.resume(
|
||||
"sid-pending",
|
||||
resume_token=created.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=send,
|
||||
close=mocker.AsyncMock(),
|
||||
lease_generation=1,
|
||||
)
|
||||
assert await registry.end_resume("sid-pending") is None, "the activation absorbed the orphan"
|
||||
released = await registry.release_attachment("sid-pending", attachment_generation=resumed.attachment_generation)
|
||||
assert released is not None and released.lease_generation == 2
|
||||
|
||||
# A pending resume that never activates leaves the orphan to whoever ends the claim.
|
||||
await registry.begin_resume("sid-pending")
|
||||
assert await registry.settle_lease_generation("sid-pending", 3) is None
|
||||
assert await registry.end_resume("sid-pending") == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_rolled_back_activation_orphans_the_lease_it_was_handed(mocker) -> None:
|
||||
"""A provisional attachment may own a newer generation than its own resume produced."""
|
||||
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=4096)
|
||||
created = await registry.create("sid-rollback", send=mocker.AsyncMock(), close=mocker.AsyncMock())
|
||||
await registry.detach("sid-rollback", attachment_generation=created.attachment_generation)
|
||||
|
||||
async def failing_activation(payload):
|
||||
raise RuntimeError("socket closed during replay")
|
||||
|
||||
await registry.begin_resume("sid-rollback")
|
||||
with pytest.raises(RuntimeError):
|
||||
await registry.resume(
|
||||
"sid-rollback",
|
||||
resume_token=created.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=failing_activation,
|
||||
close=mocker.AsyncMock(),
|
||||
activation_payload_factory=lambda token, generation: {"type": "session.resumed"},
|
||||
lease_generation=1,
|
||||
)
|
||||
# Meanwhile a resume abandoned mid-RPC handed generation 2 to the provisional attachment.
|
||||
# (Order does not matter: both land in the orphan slot while the claim is open.)
|
||||
assert await registry.settle_lease_generation("sid-rollback", 2) is None
|
||||
assert await registry.settle_lease_generation("sid-rollback", 1) is None
|
||||
assert await registry.end_resume("sid-rollback") == 2, "the newest lease is the one to detach"
|
||||
@@ -43,7 +43,20 @@ class FakeDuplexEngine(FakeAsyncOmniEngine):
|
||||
self.opened: list[tuple[str, DuplexSessionConfig]] = []
|
||||
self.closed: list[tuple[str, str]] = []
|
||||
self.resumed: list[tuple[str, int]] = []
|
||||
#: The control id of every resume RPC, in order (a replay repeats one).
|
||||
self.resume_control_ids: list[str | None] = []
|
||||
#: Per session: the control id that produced the current lease generation, and that generation.
|
||||
self.lease: dict[str, tuple[str | None, int]] = {}
|
||||
#: How many resume RPCs, after applying the resume, answer with a
|
||||
#: timeout: the engine landed it but the waiter never saw the result.
|
||||
self.resume_timeouts_remaining = 0
|
||||
self.touched: list[tuple[str, str]] = []
|
||||
#: The lease-generation fence of every detach, in order (``None`` = unfenced).
|
||||
self.detach_generations: list[int | None] = []
|
||||
#: When set, ``resume_session_async`` parks on it after signalling ``resume_started``:
|
||||
#: the engine has applied the resume, its result has not reached the caller.
|
||||
self.resume_gate: asyncio.Event | None = None
|
||||
self.resume_started = asyncio.Event()
|
||||
self.commands: list[tuple[str, commands.DuplexCommand]] = []
|
||||
self.open_error: DuplexSessionError | None = None
|
||||
self.close_error: DuplexSessionError | None = None
|
||||
@@ -71,12 +84,33 @@ class FakeDuplexEngine(FakeAsyncOmniEngine):
|
||||
self.emit(session_id, SessionClosed(reason=reason))
|
||||
return self._result("close", session_id)
|
||||
|
||||
async def resume_session_async(self, session_id, *, expected_lease_generation, timeout=None):
|
||||
async def resume_session_async(self, session_id, *, expected_lease_generation, control_id=None, timeout=None):
|
||||
self.resumed.append((session_id, expected_lease_generation))
|
||||
return self._result("resume", session_id, lease_generation=expected_lease_generation + 1)
|
||||
self.resume_control_ids.append(control_id)
|
||||
resumed_by, generation = self.lease.get(session_id, (None, 0))
|
||||
if control_id is not None and control_id == resumed_by:
|
||||
# The engine's replay answer: the generation this resume produced.
|
||||
# Its answer can be lost to a timeout exactly like the first one.
|
||||
if self.resume_timeouts_remaining > 0:
|
||||
self.resume_timeouts_remaining -= 1
|
||||
raise DuplexSessionError("duplex resume timed out", code="timeout", retryable=True)
|
||||
return self._result("resume", session_id, lease_generation=generation)
|
||||
if expected_lease_generation != generation:
|
||||
raise DuplexSessionError("duplex lease generation mismatch", code="session_resume_conflict")
|
||||
generation += 1
|
||||
self.lease[session_id] = (control_id, generation)
|
||||
if self.resume_gate is not None:
|
||||
self.resume_started.set()
|
||||
await self.resume_gate.wait()
|
||||
if self.resume_timeouts_remaining > 0:
|
||||
self.resume_timeouts_remaining -= 1
|
||||
raise DuplexSessionError("duplex resume timed out", code="timeout", retryable=True)
|
||||
return self._result("resume", session_id, lease_generation=generation)
|
||||
|
||||
async def touch_session_async(self, session_id, *, activity, timeout=None):
|
||||
async def touch_session_async(self, session_id, *, activity, expected_lease_generation=None, timeout=None):
|
||||
self.touched.append((session_id, activity))
|
||||
if activity == "detach":
|
||||
self.detach_generations.append(expected_lease_generation)
|
||||
return self._result("touch", session_id)
|
||||
|
||||
async def submit_command_async(self, session_id, command):
|
||||
@@ -324,3 +358,141 @@ async def test_shutdown_closes_every_handle(monkeypatch) -> None:
|
||||
assert first.close_reason == "shutdown"
|
||||
assert omni.sessions == {}
|
||||
assert engine.shutdown_called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detach_session_forwards_the_lease_generation_fence(monkeypatch) -> None:
|
||||
omni, engine = _make_omni(monkeypatch)
|
||||
try:
|
||||
handle = await omni.open_session()
|
||||
await omni.detach_session(handle.session_id, expected_lease_generation=3)
|
||||
await omni.detach_session(handle.session_id)
|
||||
assert engine.detach_generations == [3, None]
|
||||
finally:
|
||||
omni.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_resume_cancelled_mid_rpc_is_adopted_and_detached_once_it_lands(monkeypatch) -> None:
|
||||
"""The resume RPC runs in an executor: cancelling its waiter does not stop the engine.
|
||||
|
||||
Without compensation the engine ends up with the lease resumed (no
|
||||
disconnect grace) for a connection that is gone, and the handle keeps the
|
||||
old generation, so a later reconnect resumes against a stale lease. The
|
||||
outcome has to be observed off the cancelled task: adopt the generation
|
||||
the resume produced and detach exactly that lease.
|
||||
"""
|
||||
omni, engine = _make_omni(monkeypatch)
|
||||
try:
|
||||
handle = await omni.open_session()
|
||||
engine.resume_gate = asyncio.Event()
|
||||
resume = asyncio.create_task(omni.resume_session(handle.session_id, expected_lease_generation=0))
|
||||
await asyncio.wait_for(engine.resume_started.wait(), timeout=2.0)
|
||||
|
||||
resume.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await resume
|
||||
# Nothing to give back yet: the engine has not answered.
|
||||
assert handle.lease_generation == 0
|
||||
assert engine.detach_generations == []
|
||||
|
||||
engine.resume_gate.set()
|
||||
await asyncio.gather(*list(omni._resume_compensations))
|
||||
assert handle.lease_generation == 1, "the generation the resume produced is adopted"
|
||||
assert engine.touched == [(handle.session_id, "detach")]
|
||||
assert engine.detach_generations == [1], "the detach is fenced on that generation"
|
||||
finally:
|
||||
omni.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_resume_cancelled_then_timed_out_is_settled_by_replaying_it(monkeypatch) -> None:
|
||||
"""A timeout after the cancel leaves the outcome unknown: the resume is replayed under its id.
|
||||
|
||||
The engine may have applied the resume after the waiter gave up, and the
|
||||
RPC router drops the late answer. Replaying the same control id makes the
|
||||
engine report the generation that resume produced (or land it now), so
|
||||
the handle adopts the real generation and the lease is detached again.
|
||||
"""
|
||||
omni, engine = _make_omni(monkeypatch)
|
||||
try:
|
||||
handle = await omni.open_session()
|
||||
engine.resume_gate = asyncio.Event()
|
||||
engine.resume_timeouts_remaining = 1
|
||||
resume = asyncio.create_task(omni.resume_session(handle.session_id, expected_lease_generation=0))
|
||||
await asyncio.wait_for(engine.resume_started.wait(), timeout=2.0)
|
||||
|
||||
resume.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await resume
|
||||
engine.resume_gate.set()
|
||||
await asyncio.gather(*list(omni._resume_compensations))
|
||||
|
||||
assert len(engine.resume_control_ids) == 2, "the resume was replayed once"
|
||||
assert engine.resume_control_ids[0] == engine.resume_control_ids[1] is not None
|
||||
assert engine.lease[handle.session_id][1] == 1, "the replay did not resume a second time"
|
||||
assert handle.lease_generation == 1
|
||||
assert engine.detach_generations == [1]
|
||||
finally:
|
||||
omni.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_abandoned_resume_is_settled_through_the_callers_callback(monkeypatch) -> None:
|
||||
"""A caller that owns attachments decides what the landed generation means; DuplexOmni does not detach."""
|
||||
omni, engine = _make_omni(monkeypatch)
|
||||
try:
|
||||
handle = await omni.open_session()
|
||||
settled: list[int] = []
|
||||
|
||||
async def on_abandoned(lease_generation: int) -> None:
|
||||
settled.append(lease_generation)
|
||||
|
||||
engine.resume_gate = asyncio.Event()
|
||||
resume = asyncio.create_task(
|
||||
omni.resume_session(handle.session_id, expected_lease_generation=0, on_abandoned=on_abandoned)
|
||||
)
|
||||
await asyncio.wait_for(engine.resume_started.wait(), timeout=2.0)
|
||||
resume.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await resume
|
||||
engine.resume_gate.set()
|
||||
await asyncio.gather(*list(omni._resume_compensations))
|
||||
|
||||
assert settled == [1]
|
||||
assert handle.lease_generation == 1
|
||||
assert engine.detach_generations == [], "the callback owns the decision"
|
||||
finally:
|
||||
omni.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_abandoned_resume_keeps_replaying_across_repeated_timeouts(monkeypatch) -> None:
|
||||
"""A replay can time out too; giving up then would reopen the lost-answer window.
|
||||
|
||||
The engine applied the resume, the abandoned RPC and the first replay both
|
||||
lose their answers to timeouts, the second replay is answered: the handle
|
||||
adopts the generation and the lease is settled, with the lease bumped once.
|
||||
"""
|
||||
omni, engine = _make_omni(monkeypatch)
|
||||
try:
|
||||
omni._resume_replay_backoff_s = (0.01, 0.02)
|
||||
handle = await omni.open_session()
|
||||
engine.resume_gate = asyncio.Event()
|
||||
engine.resume_timeouts_remaining = 2
|
||||
resume = asyncio.create_task(omni.resume_session(handle.session_id, expected_lease_generation=0))
|
||||
await asyncio.wait_for(engine.resume_started.wait(), timeout=2.0)
|
||||
|
||||
resume.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await resume
|
||||
engine.resume_gate.set()
|
||||
await asyncio.wait_for(asyncio.gather(*list(omni._resume_compensations)), timeout=5.0)
|
||||
|
||||
assert len(engine.resume_control_ids) == 3, "the abandoned RPC, a replay that timed out, the replay answered"
|
||||
assert len(set(engine.resume_control_ids)) == 1
|
||||
assert engine.lease[handle.session_id][1] == 1, "the lease was bumped once"
|
||||
assert handle.lease_generation == 1
|
||||
assert engine.detach_generations == [1]
|
||||
finally:
|
||||
omni.shutdown()
|
||||
Reference in new issue
Block a user