85 lines
3.2 KiB
Python
85 lines
3.2 KiB
Python
"""Regression: cancellation during the final seal must not orphan the
|
|
remote stream (PR 85796 review round 2, finding 4).
|
|
|
|
_seal_open_draft pops the open entry and writes the local tombstone
|
|
BEFORE awaiting transport I/O. CancelledError bypasses the failure
|
|
handling (it is not an Exception), so a cancel mid-seal left:
|
|
|
|
remote stream: OPEN (live indicator until connector eviction)
|
|
adapter._open_draft_by_chat: {} <- abandon finds nothing
|
|
adapter._sealed_draft_by_chat: {key: id} <- premature tombstone
|
|
|
|
The seal now restores the open entry and drops its premature tombstone
|
|
on CancelledError before re-raising, so the consumer's abandon pass can
|
|
seal the stream in place.
|
|
"""
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from tests.gateway.relay.test_relay_live_cards import _connected_adapter
|
|
|
|
|
|
class HangOnSealTransport:
|
|
def __init__(self):
|
|
self.ops = []
|
|
self.hang_seal = True
|
|
|
|
async def send_outbound(self, payload, platform=None):
|
|
final = bool(payload.get("final"))
|
|
self.ops.append((payload.get("op"), final, str(payload.get("content"))[:25]))
|
|
if payload.get("op") == "draft" and final and self.hang_seal:
|
|
await asyncio.sleep(30)
|
|
return {"success": True, "message_id": "ts.1"}
|
|
|
|
|
|
class TestCancelDuringSeal:
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_mid_seal_restores_open_state(self):
|
|
adapter, _ = _connected_adapter()
|
|
t = HangOnSealTransport()
|
|
adapter._transport = t
|
|
md = {"message_id": "m.1"}
|
|
await adapter.send_draft("C1", 11, "partial", metadata=md)
|
|
key = adapter._draft_key("C1", md)
|
|
|
|
task = asyncio.create_task(adapter.send("C1", "complete", metadata=dict(md)))
|
|
await asyncio.sleep(0.05)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
assert adapter._open_draft_by_chat.get(key) == 11, (
|
|
"cancelled seal must restore the open entry so abandon can close it"
|
|
)
|
|
assert key not in adapter._sealed_draft_by_chat, (
|
|
"premature tombstone must not survive a cancelled seal"
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_abandon_after_cancelled_seal_closes_remote_stream(self):
|
|
"""End-to-end: cancel mid-seal, then the abandon pass (what the
|
|
consumer's CancelledError handler runs) seals the stream."""
|
|
adapter, _ = _connected_adapter()
|
|
t = HangOnSealTransport()
|
|
adapter._transport = t
|
|
md = {"message_id": "m.2"}
|
|
await adapter.send_draft("C1", 12, "partial on screen", metadata=md)
|
|
|
|
task = asyncio.create_task(adapter.send("C1", "complete", metadata=dict(md)))
|
|
await asyncio.sleep(0.05)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
t.hang_seal = False # transport recovers for the abandon pass
|
|
r = await adapter.abandon_open_draft(
|
|
"C1", "partial on screen", metadata=dict(md)
|
|
)
|
|
assert r.success
|
|
seals = [o for o in t.ops if o[0] == "draft" and o[1]]
|
|
# First seal attempt hung (cancelled); the abandon's seal landed.
|
|
assert seals[-1][2] == "partial on screen"
|
|
assert not adapter._open_draft_by_chat
|