Skip to content

Commit 8337927

Browse files
[PR #10569/dfbf782b backport][3.12] Break cyclic references when there is an exception handling a request (#10572)
**This is a backport of PR #10569 as merged into master (dfbf782).** <!-- Thank you for your contribution! --> ## What do these changes do? fixes #10548 ## Are there changes in behavior for the user? fixes a potential memory leak ## Is it a substantial burden for the maintainers to support this? no Co-authored-by: J. Nick Koston <[email protected]>
1 parent 077e4fa commit 8337927

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
@@ -520,8 +520,6 @@ async def start(self) -> None:
520520
keep_alive(True) specified.
521521
"""
522522
loop = self._loop
523-
handler = asyncio.current_task(loop)
524-
assert handler is not None
525523
manager = self._manager
526524
assert manager is not None
527525
keepalive_timeout = self._keepalive_timeout
@@ -551,7 +549,16 @@ async def start(self) -> None:
551549
else:
552550
request_handler = self._request_handler
553551

554-
request = self._request_factory(message, payload, self, writer, handler)
552+
# Important don't hold a reference to the current task
553+
# as on traceback it will prevent the task from being
554+
# collected and will cause a memory leak.
555+
request = self._request_factory(
556+
message,
557+
payload,
558+
self,
559+
writer,
560+
self._task_handler or asyncio.current_task(loop), # type: ignore[arg-type]
561+
)
555562
try:
556563
# a new task is used for copy context vars (#3406)
557564
coro = self._handle_request(request, start, request_handler)
@@ -617,6 +624,7 @@ async def start(self) -> None:
617624
self.force_close()
618625
raise
619626
finally:
627+
request._task = None # type: ignore[assignment] # Break reference cycle in case of exception
620628
if self.transport is None and resp is not None:
621629
self.log_debug("Ignored premature client disconnection.")
622630

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)