Skip to content

Commit 06887a9

Browse files
authored
[PR #11107/21d640d backport][3.12] Avoid creating closed futures that will never be awaited (#11110)
1 parent b1aa238 commit 06887a9

File tree

5 files changed

+106
-23
lines changed

5 files changed

+106
-23
lines changed

CHANGES/11107.misc.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Avoided creating closed futures in ``ResponseHandler`` that will never be awaited -- by :user:`bdraco`.

aiohttp/client_proto.py

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import asyncio
22
from contextlib import suppress
3-
from typing import Any, Optional, Tuple
3+
from typing import Any, Optional, Tuple, Union
44

55
from .base_protocol import BaseProtocol
66
from .client_exceptions import (
@@ -45,7 +45,27 @@ def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
4545
self._read_timeout_handle: Optional[asyncio.TimerHandle] = None
4646

4747
self._timeout_ceil_threshold: Optional[float] = 5
48-
self.closed: asyncio.Future[None] = self._loop.create_future()
48+
49+
self._closed: Union[None, asyncio.Future[None]] = None
50+
self._connection_lost_called = False
51+
52+
@property
53+
def closed(self) -> Union[None, asyncio.Future[None]]:
54+
"""Future that is set when the connection is closed.
55+
56+
This property returns a Future that will be completed when the connection
57+
is closed. The Future is created lazily on first access to avoid creating
58+
futures that will never be awaited.
59+
60+
Returns:
61+
- A Future[None] if the connection is still open or was closed after
62+
this property was accessed
63+
- None if connection_lost() was already called before this property
64+
was ever accessed (indicating no one is waiting for the closure)
65+
"""
66+
if self._closed is None and not self._connection_lost_called:
67+
self._closed = self._loop.create_future()
68+
return self._closed
4969

5070
@property
5171
def upgraded(self) -> bool:
@@ -79,30 +99,31 @@ def is_connected(self) -> bool:
7999
return self.transport is not None and not self.transport.is_closing()
80100

81101
def connection_lost(self, exc: Optional[BaseException]) -> None:
102+
self._connection_lost_called = True
82103
self._drop_timeout()
83104

84105
original_connection_error = exc
85106
reraised_exc = original_connection_error
86107

87108
connection_closed_cleanly = original_connection_error is None
88109

89-
if connection_closed_cleanly:
90-
set_result(self.closed, None)
91-
else:
92-
assert original_connection_error is not None
93-
set_exception(
94-
self.closed,
95-
ClientConnectionError(
96-
f"Connection lost: {original_connection_error !s}",
97-
),
98-
original_connection_error,
99-
)
100-
# Mark the exception as retrieved to prevent
101-
# "Future exception was never retrieved" warnings
102-
# The exception is always passed on through
103-
# other means, so this is safe
104-
with suppress(Exception):
105-
self.closed.exception()
110+
if self._closed is not None:
111+
# If someone is waiting for the closed future,
112+
# we should set it to None or an exception. If
113+
# self._closed is None, it means that
114+
# connection_lost() was called already
115+
# or nobody is waiting for it.
116+
if connection_closed_cleanly:
117+
set_result(self._closed, None)
118+
else:
119+
assert original_connection_error is not None
120+
set_exception(
121+
self._closed,
122+
ClientConnectionError(
123+
f"Connection lost: {original_connection_error !s}",
124+
),
125+
original_connection_error,
126+
)
106127

107128
if self._payload_parser is not None:
108129
with suppress(Exception): # FIXME: log this somehow?

aiohttp/connector.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -507,11 +507,13 @@ def _close(self) -> List[Awaitable[object]]:
507507
for data in self._conns.values():
508508
for proto, _ in data:
509509
proto.close()
510-
waiters.append(proto.closed)
510+
if closed := proto.closed:
511+
waiters.append(closed)
511512

512513
for proto in self._acquired:
513514
proto.close()
514-
waiters.append(proto.closed)
515+
if closed := proto.closed:
516+
waiters.append(closed)
515517

516518
for transport in self._cleanup_closed_transports:
517519
if transport is not None:

tests/test_client_proto.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -256,13 +256,50 @@ async def test_connection_lost_exception_is_marked_retrieved(
256256
proto = ResponseHandler(loop=loop)
257257
proto.connection_made(mock.Mock())
258258

259+
# Access closed property before connection_lost to ensure future is created
260+
closed_future = proto.closed
261+
assert closed_future is not None
262+
259263
# Simulate an SSL shutdown timeout error
260264
ssl_error = TimeoutError("SSL shutdown timed out")
261265
proto.connection_lost(ssl_error)
262266

263267
# Verify the exception was set on the closed future
264-
assert proto.closed.done()
265-
exc = proto.closed.exception()
268+
assert closed_future.done()
269+
exc = closed_future.exception()
266270
assert exc is not None
267271
assert "Connection lost: SSL shutdown timed out" in str(exc)
268272
assert exc.__cause__ is ssl_error
273+
274+
275+
async def test_closed_property_lazy_creation(
276+
loop: asyncio.AbstractEventLoop,
277+
) -> None:
278+
"""Test that closed future is created lazily."""
279+
proto = ResponseHandler(loop=loop)
280+
281+
# Initially, the closed future should not be created
282+
assert proto._closed is None
283+
284+
# Accessing the property should create the future
285+
closed_future = proto.closed
286+
assert closed_future is not None
287+
assert isinstance(closed_future, asyncio.Future)
288+
assert not closed_future.done()
289+
290+
# Subsequent access should return the same future
291+
assert proto.closed is closed_future
292+
293+
294+
async def test_closed_property_after_connection_lost(
295+
loop: asyncio.AbstractEventLoop,
296+
) -> None:
297+
"""Test that closed property returns None after connection_lost if never accessed."""
298+
proto = ResponseHandler(loop=loop)
299+
proto.connection_made(mock.Mock())
300+
301+
# Don't access proto.closed before connection_lost
302+
proto.connection_lost(None)
303+
304+
# After connection_lost, closed should return None if it was never accessed
305+
assert proto.closed is None

tests/test_connector.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,28 @@ async def test_close_with_exception_during_closing(
331331
assert "Error while closing connector" in caplog.records[0].message
332332
assert "RuntimeError('Connection close failed')" in caplog.records[0].message
333333

334+
335+
async def test_close_with_proto_closed_none(key: ConnectionKey) -> None:
336+
"""Test close when protocol.closed is None."""
337+
# Create protocols where closed property returns None
338+
proto1 = mock.create_autospec(ResponseHandler, instance=True)
339+
proto1.closed = None
340+
proto1.close = mock.Mock()
341+
342+
proto2 = mock.create_autospec(ResponseHandler, instance=True)
343+
proto2.closed = None
344+
proto2.close = mock.Mock()
345+
346+
conn = aiohttp.BaseConnector()
347+
conn._conns[key] = deque([(proto1, 0)])
348+
conn._acquired.add(proto2)
349+
350+
# Close the connector - this should handle the case where proto.closed is None
351+
await conn.close()
352+
353+
# Verify close was called on both protocols
354+
assert proto1.close.called
355+
assert proto2.close.called
334356
assert conn.closed
335357

336358

0 commit comments

Comments
 (0)