Skip to content

Commit dfbf782

Browse files
authored
Break cyclic references when there is an exception handling a request (#10569)
<!-- Thank you for your contribution! --> ## What do these changes do? This is a partial fix for #10548 - There is still another case for `SystemRoute`s that needs to be addressed. No reproducer available yet. - There is also another case on the client side on connection refused that still needs to be addressed #10548 (comment) ## Are there changes in behavior for the user? fixes memory leak ## Is it a substantial burden for the maintainers to support this? no
1 parent 9d4e116 commit dfbf782

File tree

4 files changed

+70
-3
lines changed

4 files changed

+70
-3
lines changed

CHANGES/10569.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Break cyclic references when there is an exception handling a request -- by :user:`bdraco`.

aiohttp/web_protocol.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -543,8 +543,6 @@ async def start(self) -> None:
543543
keep_alive(True) specified.
544544
"""
545545
loop = self._loop
546-
handler = asyncio.current_task(loop)
547-
assert handler is not None
548546
manager = self._manager
549547
assert manager is not None
550548
keepalive_timeout = self._keepalive_timeout
@@ -574,7 +572,16 @@ async def start(self) -> None:
574572
request_handler = self._make_error_handler(message)
575573
message = ERROR
576574

577-
request = self._request_factory(message, payload, self, writer, handler)
575+
# Important don't hold a reference to the current task
576+
# as on traceback it will prevent the task from being
577+
# collected and will cause a memory leak.
578+
request = self._request_factory(
579+
message,
580+
payload,
581+
self,
582+
writer,
583+
self._task_handler or asyncio.current_task(loop), # type: ignore[arg-type]
584+
)
578585
try:
579586
# a new task is used for copy context vars (#3406)
580587
coro = self._handle_request(request, start, request_handler)
@@ -642,6 +649,7 @@ async def start(self) -> None:
642649
self.force_close()
643650
raise
644651
finally:
652+
request._task = None # type: ignore[assignment] # Break reference cycle in case of exception
645653
if self.transport is None and resp is not None:
646654
self.log_debug("Ignored premature client disconnection.")
647655

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import asyncio
2+
import gc
3+
import sys
4+
from typing import NoReturn
5+
6+
from aiohttp import ClientSession, web
7+
from aiohttp.test_utils import get_unused_port_socket
8+
9+
gc.set_debug(gc.DEBUG_LEAK)
10+
11+
12+
async def main() -> None:
13+
app = web.Application()
14+
15+
async def handler(request: web.Request) -> NoReturn:
16+
await request.json()
17+
assert False
18+
19+
app.router.add_route("GET", "/json", handler)
20+
sock = get_unused_port_socket("127.0.0.1")
21+
port = sock.getsockname()[1]
22+
23+
runner = web.AppRunner(app)
24+
await runner.setup()
25+
site = web.SockSite(runner, sock)
26+
await site.start()
27+
28+
async with ClientSession() as session:
29+
async with session.get(f"http://127.0.0.1:{port}/json") as resp:
30+
await resp.read()
31+
32+
# Give time for the cancelled task to be collected
33+
await asyncio.sleep(0.5)
34+
gc.collect()
35+
request_present = any(type(obj).__name__ == "Request" for obj in gc.garbage)
36+
await session.close()
37+
await runner.cleanup()
38+
sys.exit(1 if request_present else 0)
39+
40+
41+
asyncio.run(main())

tests/test_leaks.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,20 @@ def test_client_response_does_not_leak_on_server_disconnected_error() -> None:
2323
stdout=subprocess.PIPE,
2424
) as proc:
2525
assert proc.wait() == 0, "ClientResponse leaked"
26+
27+
28+
@pytest.mark.skipif(IS_PYPY, reason="gc.DEBUG_LEAK not available on PyPy")
29+
def test_request_does_not_leak_when_request_handler_raises() -> None:
30+
"""Test that the Request object is collected when the handler raises.
31+
32+
https://github.com/aio-libs/aiohttp/issues/10548
33+
"""
34+
leak_test_script = pathlib.Path(__file__).parent.joinpath(
35+
"isolated", "check_for_request_leak.py"
36+
)
37+
38+
with subprocess.Popen(
39+
[sys.executable, "-u", str(leak_test_script)],
40+
stdout=subprocess.PIPE,
41+
) as proc:
42+
assert proc.wait() == 0, "Request leaked"

0 commit comments

Comments
 (0)