[Core][Frontend] Unified Full-duplex Framework (#7413)
Signed-off-by: chickeyton <ngton2014@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
9005d78903
commit
99ff4f3070
182 files changed
+26951
-34743
No files matched your search
@@ -133,7 +133,7 @@ async def test_registry_failed_send_is_accepted_only_when_journaled(mocker, jour
|
||||
assert accepted.call_args_list == expected_calls
|
||||
raise RuntimeError("transport lost")
|
||||
|
||||
await registry.create("sid-send-failure", incarnation=0, send=failing_send, close=mocker.AsyncMock())
|
||||
await registry.create("sid-send-failure", send=failing_send, close=mocker.AsyncMock())
|
||||
|
||||
with pytest.raises(RuntimeError, match="transport lost"):
|
||||
await registry.send_event(
|
||||
@@ -152,7 +152,7 @@ async def test_registry_detached_event_is_accepted_only_when_journaled(mocker, j
|
||||
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=4096)
|
||||
send = mocker.AsyncMock()
|
||||
accepted = mocker.Mock()
|
||||
created = await registry.create("sid-detached-acceptance", incarnation=0, send=send, close=mocker.AsyncMock())
|
||||
created = await registry.create("sid-detached-acceptance", send=send, close=mocker.AsyncMock())
|
||||
await registry.detach("sid-detached-acceptance", attachment_generation=created.attachment_generation)
|
||||
|
||||
entry = await registry.send_event(
|
||||
@@ -175,7 +175,7 @@ async def test_registry_unjournaled_event_is_accepted_after_successful_send(mock
|
||||
async def send(payload):
|
||||
accepted.assert_not_called()
|
||||
|
||||
await registry.create("sid-live-acceptance", incarnation=0, send=send, close=mocker.AsyncMock())
|
||||
await registry.create("sid-live-acceptance", send=send, close=mocker.AsyncMock())
|
||||
|
||||
entry = await registry.send_event(
|
||||
"sid-live-acceptance",
|
||||
@@ -193,7 +193,7 @@ async def test_registry_overflow_does_not_accept_or_send_event(mocker) -> None:
|
||||
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=100)
|
||||
accepted = mocker.Mock()
|
||||
send = mocker.AsyncMock()
|
||||
await registry.create("sid-overflow-acceptance", incarnation=0, send=send, close=mocker.AsyncMock())
|
||||
await registry.create("sid-overflow-acceptance", send=send, close=mocker.AsyncMock())
|
||||
|
||||
with pytest.raises(DuplexJournalOverflowError, match="byte limit"):
|
||||
await registry.send_event(
|
||||
@@ -231,13 +231,12 @@ async def test_registry_resume_rotates_token_replays_and_atomically_replaces_att
|
||||
async def close_b(reason):
|
||||
closes_b.append(reason)
|
||||
|
||||
created = await registry.create("sid", incarnation=2, send=send_a, close=close_a)
|
||||
created = await registry.create("sid", send=send_a, close=close_a)
|
||||
await registry.send_event("sid", {"type": "event-1"})
|
||||
await registry.send_event("sid", {"type": "event-2"})
|
||||
|
||||
resumed = await registry.resume(
|
||||
"sid",
|
||||
incarnation=2,
|
||||
resume_token=created.resume_token.plaintext,
|
||||
last_received_server_event_seq=1,
|
||||
send=send_b,
|
||||
@@ -260,6 +259,34 @@ async def test_registry_resume_rotates_token_replays_and_atomically_replaces_att
|
||||
assert closes_a == [] and sends_b == [] and closes_b == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_detach_without_a_generation_targets_the_current_attachment_once() -> None:
|
||||
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=4096)
|
||||
sends: list[dict] = []
|
||||
|
||||
async def send(payload):
|
||||
sends.append(payload)
|
||||
|
||||
async def close(reason):
|
||||
return None
|
||||
|
||||
await registry.create("sid-current", send=send, close=close)
|
||||
|
||||
# The outbound pump outlives any one connection and only knows the session:
|
||||
# ``None`` means "whichever socket is attached right now".
|
||||
assert await registry.detach("sid-current", attachment_generation=None) is True
|
||||
# A second report of the same dead socket is not a new detach, so it cannot
|
||||
# restart the engine's disconnect grace window.
|
||||
assert await registry.detach("sid-current", attachment_generation=None) is False
|
||||
assert await registry.detach("sid-current", attachment_generation=1) is False
|
||||
assert await registry.is_current_attachment("sid-current", 1) is False
|
||||
|
||||
# A detached session still journals for a later resume.
|
||||
await registry.send_event("sid-current", {"type": "event-1"})
|
||||
assert sends == []
|
||||
assert await registry.detach("missing-session", attachment_generation=None) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_resume_sends_activation_then_replay_before_new_live_events() -> None:
|
||||
registry = DuplexSessionAttachmentRegistry(
|
||||
@@ -282,13 +309,12 @@ async def test_registry_resume_sends_activation_then_replay_before_new_live_even
|
||||
activation_started.set()
|
||||
await release_activation.wait()
|
||||
|
||||
created = await registry.create("sid-order", incarnation=0, send=old_send, close=close)
|
||||
created = await registry.create("sid-order", send=old_send, close=close)
|
||||
await registry.detach("sid-order", attachment_generation=1)
|
||||
await registry.send_event("sid-order", {"type": "replayed"})
|
||||
resume_task = asyncio.create_task(
|
||||
registry.resume(
|
||||
"sid-order",
|
||||
incarnation=0,
|
||||
resume_token=created.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=new_send,
|
||||
@@ -322,13 +348,12 @@ async def test_registry_resume_delivery_failure_keeps_one_shot_old_token_recover
|
||||
async def close(reason):
|
||||
del reason
|
||||
|
||||
created = await registry.create("sid-recovery", incarnation=0, send=send, close=close)
|
||||
created = await registry.create("sid-recovery", send=send, close=close)
|
||||
await registry.detach("sid-recovery", attachment_generation=1)
|
||||
|
||||
with pytest.raises(RuntimeError, match="transport lost"):
|
||||
await registry.resume(
|
||||
"sid-recovery",
|
||||
incarnation=0,
|
||||
resume_token=created.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=failing_send,
|
||||
@@ -342,7 +367,6 @@ async def test_registry_resume_delivery_failure_keeps_one_shot_old_token_recover
|
||||
|
||||
recovered = await registry.resume(
|
||||
"sid-recovery",
|
||||
incarnation=0,
|
||||
resume_token=created.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=send,
|
||||
@@ -353,7 +377,6 @@ async def test_registry_resume_delivery_failure_keeps_one_shot_old_token_recover
|
||||
with pytest.raises(InvalidResumeTokenError):
|
||||
await registry.resume(
|
||||
"sid-recovery",
|
||||
incarnation=0,
|
||||
resume_token=created.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=send,
|
||||
@@ -374,13 +397,12 @@ async def test_registry_concurrent_resume_allows_exactly_one_rotated_token_winne
|
||||
async def close(reason):
|
||||
del reason
|
||||
|
||||
created = await registry.create("sid-race", incarnation=0, send=send, close=close)
|
||||
created = await registry.create("sid-race", send=send, close=close)
|
||||
|
||||
async def attempt():
|
||||
try:
|
||||
return await registry.resume(
|
||||
"sid-race",
|
||||
incarnation=0,
|
||||
resume_token=created.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=send,
|
||||
@@ -396,7 +418,7 @@ async def test_registry_concurrent_resume_allows_exactly_one_rotated_token_winne
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_keeps_sessions_journals_and_incarnations_isolated() -> None:
|
||||
async def test_registry_keeps_sessions_journals_and_tokens_isolated() -> None:
|
||||
registry = DuplexSessionAttachmentRegistry(
|
||||
replay_ttl_s=60.0,
|
||||
replay_max_bytes_per_session=4096,
|
||||
@@ -408,8 +430,8 @@ async def test_registry_keeps_sessions_journals_and_incarnations_isolated() -> N
|
||||
async def close(reason):
|
||||
del reason
|
||||
|
||||
created_a = await registry.create("sid-a", incarnation=1, send=send, close=close)
|
||||
created_b = await registry.create("sid-b", incarnation=4, send=send, close=close)
|
||||
created_a = await registry.create("sid-a", send=send, close=close)
|
||||
created_b = await registry.create("sid-b", send=send, close=close)
|
||||
await registry.detach("sid-a", attachment_generation=1)
|
||||
await registry.detach("sid-b", attachment_generation=1)
|
||||
await registry.send_event("sid-a", {"type": "a-only"})
|
||||
@@ -417,7 +439,6 @@ async def test_registry_keeps_sessions_journals_and_incarnations_isolated() -> N
|
||||
|
||||
resumed_a = await registry.resume(
|
||||
"sid-a",
|
||||
incarnation=1,
|
||||
resume_token=created_a.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=send,
|
||||
@@ -425,7 +446,6 @@ async def test_registry_keeps_sessions_journals_and_incarnations_isolated() -> N
|
||||
)
|
||||
resumed_b = await registry.resume(
|
||||
"sid-b",
|
||||
incarnation=4,
|
||||
resume_token=created_b.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=send,
|
||||
@@ -434,11 +454,10 @@ async def test_registry_keeps_sessions_journals_and_incarnations_isolated() -> N
|
||||
|
||||
assert [entry.payload["type"] for entry in resumed_a.replay_entries] == ["a-only"]
|
||||
assert [entry.payload["type"] for entry in resumed_b.replay_entries] == ["b-only"]
|
||||
with pytest.raises(ValueError, match="incarnation mismatch"):
|
||||
with pytest.raises(InvalidResumeTokenError):
|
||||
await registry.resume(
|
||||
"sid-a",
|
||||
incarnation=2,
|
||||
resume_token=resumed_a.resume_token.plaintext,
|
||||
resume_token=resumed_b.resume_token.plaintext,
|
||||
last_received_server_event_seq=1,
|
||||
send=send,
|
||||
close=close,
|
||||
@@ -458,7 +477,7 @@ async def test_registry_repr_never_contains_plaintext_tokens() -> None:
|
||||
async def close(reason):
|
||||
del reason
|
||||
|
||||
created = await registry.create("sid-repr", incarnation=0, send=send, close=close)
|
||||
created = await registry.create("sid-repr", send=send, close=close)
|
||||
await registry.detach("sid-repr", attachment_generation=1)
|
||||
entry = await registry.send_event(
|
||||
"sid-repr",
|
||||
@@ -469,3 +488,67 @@ async def test_registry_repr_never_contains_plaintext_tokens() -> None:
|
||||
assert created.resume_token.plaintext not in repr(created)
|
||||
assert created.resume_token.plaintext not in repr(entry)
|
||||
assert created.resume_token.plaintext not in repr(registry)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("cancel_on", ["activation", "replay"])
|
||||
async def test_registry_resume_cancelled_mid_delivery_rolls_back_like_a_failure(cancel_on: str) -> None:
|
||||
"""Cancellation must take the same rollback as an ordinary delivery failure.
|
||||
|
||||
``CancelledError`` derives from ``BaseException``, so an ``except
|
||||
Exception`` around the activation and replay sends let a cancelled resume
|
||||
keep its attachment as the current generation while the token it rotated
|
||||
past was already gone — the session left attached to an unusable transport
|
||||
with no way back. Both send points are covered because either can be the
|
||||
one that gets cancelled.
|
||||
"""
|
||||
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=4096)
|
||||
|
||||
async def send(payload):
|
||||
del payload
|
||||
|
||||
async def close(reason):
|
||||
del reason
|
||||
|
||||
created = await registry.create("sid-cancel", send=send, close=close)
|
||||
# One journaled event, so the replay loop has something to send.
|
||||
await registry.send_event("sid-cancel", {"type": "response.output_text.delta", "delta": "a"})
|
||||
await registry.detach("sid-cancel", attachment_generation=1)
|
||||
|
||||
sends = 0
|
||||
cancel_at = 1 if cancel_on == "activation" else 2
|
||||
|
||||
async def cancelling_send(payload):
|
||||
del payload
|
||||
nonlocal sends
|
||||
sends += 1
|
||||
if sends == cancel_at:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await registry.resume(
|
||||
"sid-cancel",
|
||||
resume_token=created.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=cancelling_send,
|
||||
close=close,
|
||||
activation_payload_factory=lambda token, generation: {
|
||||
"type": "session.resumed",
|
||||
"resume_token": token.plaintext,
|
||||
"attachment_generation": generation,
|
||||
},
|
||||
)
|
||||
|
||||
assert sends == cancel_at, "the send that was cancelled is the one under test"
|
||||
# The cancelled attachment must not remain current...
|
||||
assert not await registry.has_attachment("sid-cancel")
|
||||
# ...and the token the caller presented has to work again, or the session
|
||||
# is unreachable: resume() had already rotated past it.
|
||||
recovered = await registry.resume(
|
||||
"sid-cancel",
|
||||
resume_token=created.resume_token.plaintext,
|
||||
last_received_server_event_seq=0,
|
||||
send=send,
|
||||
close=close,
|
||||
)
|
||||
assert recovered.attachment_generation == 3
|
||||
Reference in new issue
Block a user