Skip to content

Commit c3bcd63

Browse files
test: cover reentrant handler shutdown and external cleanup controls
AI-assisted regression tests, independently verified with AI. No human code review claimed. Eight bounded self-close cases reproduce RecursionError on baseline; two external-shutdown controls preserve sibling cleanup.
1 parent 1854e24 commit c3bcd63

1 file changed

Lines changed: 120 additions & 0 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Closing a connection from its own task must not cancel/gather that task."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import subprocess
7+
import sys
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
13+
@pytest.mark.parametrize("caller", ["notification", "request", "observer", "supervisor"])
14+
@pytest.mark.parametrize("siblings", [False, True])
15+
def test_shutdown_from_owned_task(caller: str, siblings: bool) -> None:
16+
# A regression can poison cancellation cleanup with a self-await cycle.
17+
# Run the real asyncio loop in a bounded child, not pytest's own loop.
18+
source = r"""
19+
import asyncio
20+
from acp.connection import Connection
21+
from acp._transport import memory_transport_pair
22+
from acp.task import TaskSupervisor
23+
24+
async def main():
25+
finished = asyncio.Event()
26+
sibling_started = asyncio.Event()
27+
sibling_stopped = asyncio.Event()
28+
supervisor = TaskSupervisor(source='test')
29+
async def sibling():
30+
sibling_started.set()
31+
try:
32+
await asyncio.Event().wait()
33+
finally:
34+
sibling_stopped.set()
35+
if CALLER == 'supervisor':
36+
async def owned():
37+
if SIBLINGS:
38+
supervisor.create(sibling())
39+
await sibling_started.wait()
40+
await supervisor.shutdown()
41+
await asyncio.sleep(0)
42+
assert not asyncio.current_task().cancelled()
43+
if SIBLINGS:
44+
assert sibling_stopped.is_set()
45+
finished.set()
46+
task = supervisor.create(owned())
47+
await asyncio.wait_for(finished.wait(), 2)
48+
await task
49+
await supervisor.shutdown()
50+
assert not supervisor._tasks
51+
print('closed cleanly')
52+
return
53+
local, peer = memory_transport_pair()
54+
async def handler(method, params, is_notification):
55+
if CALLER == 'observer':
56+
return None
57+
await close_here()
58+
async def observer(event):
59+
if CALLER == 'observer':
60+
await close_here()
61+
connection = Connection(handler, local, observers=[observer] if CALLER == 'observer' else [])
62+
async def close_here():
63+
if SIBLINGS:
64+
connection._tasks.create(sibling())
65+
await sibling_started.wait()
66+
await connection.close()
67+
await asyncio.sleep(0)
68+
assert not asyncio.current_task().cancelled()
69+
if SIBLINGS:
70+
assert sibling_stopped.is_set()
71+
finished.set()
72+
message = {'jsonrpc': '2.0', 'method': 'stop'}
73+
if CALLER == 'request':
74+
message['id'] = 1
75+
await peer.send(message)
76+
await asyncio.wait_for(finished.wait(), 2)
77+
await asyncio.sleep(0)
78+
await connection.close()
79+
await peer.close()
80+
assert not connection._tasks._tasks
81+
print('closed cleanly')
82+
asyncio.run(main())
83+
"""
84+
source = source.replace("CALLER", repr(caller)).replace("SIBLINGS", repr(siblings))
85+
result = subprocess.run( # noqa: S603 - bounded, fixed-source asyncio regression probe
86+
[sys.executable, "-c", source],
87+
capture_output=True,
88+
text=True,
89+
timeout=8,
90+
env={**os.environ, "PYTHONPATH": str(Path(__file__).resolve().parents[1] / "src")},
91+
)
92+
assert result.returncode == 0, result.stdout + result.stderr
93+
assert "closed cleanly" in result.stdout
94+
assert "RecursionError" not in result.stderr
95+
assert "Task was destroyed" not in result.stderr
96+
97+
98+
@pytest.mark.asyncio
99+
@pytest.mark.parametrize("siblings", [0, 2])
100+
async def test_external_shutdown_still_awaits_owned_task_cleanup(siblings: int) -> None:
101+
import asyncio
102+
103+
from acp.task import TaskSupervisor
104+
105+
supervisor = TaskSupervisor(source="external-test")
106+
stopped: list[int] = []
107+
108+
async def owned(index: int) -> None:
109+
try:
110+
await asyncio.Event().wait()
111+
finally:
112+
stopped.append(index)
113+
114+
tasks = [supervisor.create(owned(index)) for index in range(siblings)]
115+
await asyncio.sleep(0)
116+
await supervisor.shutdown()
117+
assert sorted(stopped) == list(range(siblings))
118+
assert all(task.cancelled() for task in tasks)
119+
assert not supervisor._tasks
120+
await supervisor.shutdown()

0 commit comments

Comments
 (0)