Skip to content

Commit 21d640d

Browse files
authored
Avoid creating closed futures that will never be awaited (#11107)
1 parent cfb9931 commit 21d640d

File tree

5 files changed

+107
-23
lines changed

5 files changed

+107
-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: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,26 @@ def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
4646

4747
self._timeout_ceil_threshold: Optional[float] = 5
4848

49-
self.closed: asyncio.Future[None] = self._loop.create_future()
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
5069

5170
@property
5271
def upgraded(self) -> bool:
@@ -80,30 +99,31 @@ def is_connected(self) -> bool:
8099
return self.transport is not None and not self.transport.is_closing()
81100

82101
def connection_lost(self, exc: Optional[BaseException]) -> None:
102+
self._connection_lost_called = True
83103
self._drop_timeout()
84104

85105
original_connection_error = exc
86106
reraised_exc = original_connection_error
87107

88108
connection_closed_cleanly = original_connection_error is None
89109

90-
if connection_closed_cleanly:
91-
set_result(self.closed, None)
92-
else:
93-
assert original_connection_error is not None
94-
set_exception(
95-
self.closed,
96-
ClientConnectionError(
97-
f"Connection lost: {original_connection_error !s}",
98-
),
99-
original_connection_error,
100-
)
101-
# Mark the exception as retrieved to prevent
102-
# "Future exception was never retrieved" warnings
103-
# The exception is always passed on through
104-
# other means, so this is safe
105-
with suppress(Exception):
106-
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+
)
107127

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

aiohttp/connector.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -462,13 +462,15 @@ def _close_immediately(self) -> List[Awaitable[object]]:
462462
self._cleanup_closed_handle.cancel()
463463

464464
for data in self._conns.values():
465-
for proto, t0 in data:
465+
for proto, _ in data:
466466
proto.close()
467-
waiters.append(proto.closed)
467+
if closed := proto.closed:
468+
waiters.append(closed)
468469

469470
for proto in self._acquired:
470471
proto.close()
471-
waiters.append(proto.closed)
472+
if closed := proto.closed:
473+
waiters.append(closed)
472474

473475
# TODO (A.Yushovskiy, 24-May-2019) collect transp. closing futures
474476
for transport in self._cleanup_closed_transports:

tests/test_client_proto.py

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

264+
# Access closed property before connection_lost to ensure future is created
265+
closed_future = proto.closed
266+
assert closed_future is not None
267+
264268
# Simulate an SSL shutdown timeout error
265269
ssl_error = TimeoutError("SSL shutdown timed out")
266270
proto.connection_lost(ssl_error)
267271

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

tests/test_connector.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,30 @@ async def test_close(key: ConnectionKey) -> None:
315315
assert conn.closed
316316

317317

318+
async def test_close_with_proto_closed_none(key: ConnectionKey) -> None:
319+
"""Test close when protocol.closed is None."""
320+
# Create protocols where closed property returns None
321+
proto1 = mock.create_autospec(ResponseHandler, instance=True)
322+
proto1.closed = None
323+
proto1.close = mock.Mock()
324+
325+
proto2 = mock.create_autospec(ResponseHandler, instance=True)
326+
proto2.closed = None
327+
proto2.close = mock.Mock()
328+
329+
conn = aiohttp.BaseConnector()
330+
conn._conns[key] = deque([(proto1, 0)])
331+
conn._acquired.add(proto2)
332+
333+
# Close the connector - this should handle the case where proto.closed is None
334+
await conn.close()
335+
336+
# Verify close was called on both protocols
337+
assert proto1.close.called
338+
assert proto2.close.called
339+
assert conn.closed
340+
341+
318342
async def test_get(loop: asyncio.AbstractEventLoop, key: ConnectionKey) -> None:
319343
conn = aiohttp.BaseConnector()
320344
try:

0 commit comments

Comments
 (0)