Skip to content

Commit 6eb7ac2

Browse files
author
rodrigo.nogueira
committed
Fix SSL context memory issue by breaking reference cycles (#3734)
Use weakref.ref in BoundSyncStream and BoundAsyncStream to hold the response reference, breaking the reference cycle that prevents timely garbage collection of SSL contexts.
1 parent ae1b9f6 commit 6eb7ac2

2 files changed

Lines changed: 111 additions & 4 deletions

File tree

httpx/_client.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import time
77
import typing
88
import warnings
9+
import weakref
910
from contextlib import asynccontextmanager, contextmanager
1011
from types import TracebackType
1112

@@ -140,13 +141,14 @@ class BoundSyncStream(SyncByteStream):
140141
"""
141142
A byte stream that is bound to a given response instance, and that
142143
ensures the `response.elapsed` is set once the response is closed.
144+
Uses weakref to avoid reference cycles with the response object.
143145
"""
144146

145147
def __init__(
146148
self, stream: SyncByteStream, response: Response, start: float
147149
) -> None:
148150
self._stream = stream
149-
self._response = response
151+
self._response_ref: weakref.ref[Response] = weakref.ref(response)
150152
self._start = start
151153

152154
def __iter__(self) -> typing.Iterator[bytes]:
@@ -155,21 +157,24 @@ def __iter__(self) -> typing.Iterator[bytes]:
155157

156158
def close(self) -> None:
157159
elapsed = time.perf_counter() - self._start
158-
self._response.elapsed = datetime.timedelta(seconds=elapsed)
160+
response = self._response_ref()
161+
if response is not None:
162+
response.elapsed = datetime.timedelta(seconds=elapsed)
159163
self._stream.close()
160164

161165

162166
class BoundAsyncStream(AsyncByteStream):
163167
"""
164168
An async byte stream that is bound to a given response instance, and that
165169
ensures the `response.elapsed` is set once the response is closed.
170+
Uses weakref to avoid reference cycles with the response object.
166171
"""
167172

168173
def __init__(
169174
self, stream: AsyncByteStream, response: Response, start: float
170175
) -> None:
171176
self._stream = stream
172-
self._response = response
177+
self._response_ref: weakref.ref[Response] = weakref.ref(response)
173178
self._start = start
174179

175180
async def __aiter__(self) -> typing.AsyncIterator[bytes]:
@@ -178,7 +183,9 @@ async def __aiter__(self) -> typing.AsyncIterator[bytes]:
178183

179184
async def aclose(self) -> None:
180185
elapsed = time.perf_counter() - self._start
181-
self._response.elapsed = datetime.timedelta(seconds=elapsed)
186+
response = self._response_ref()
187+
if response is not None:
188+
response.elapsed = datetime.timedelta(seconds=elapsed)
182189
await self._stream.aclose()
183190

184191

tests/test_bound_stream.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""
2+
Tests for BoundSyncStream and BoundAsyncStream weakref behavior.
3+
These tests verify that the streams properly break reference cycles
4+
to allow garbage collection.
5+
"""
6+
7+
import gc
8+
import typing
9+
import weakref
10+
11+
import pytest
12+
13+
import httpx
14+
from httpx._client import BoundAsyncStream, BoundSyncStream
15+
from httpx._types import AsyncByteStream, SyncByteStream
16+
17+
18+
class MockSyncStream(SyncByteStream):
19+
def __init__(self) -> None:
20+
self.closed = False
21+
22+
def __iter__(self) -> typing.Iterator[bytes]:
23+
yield b"test"
24+
25+
def close(self) -> None:
26+
self.closed = True
27+
28+
29+
class MockAsyncStream(AsyncByteStream):
30+
def __init__(self) -> None:
31+
self.closed = False
32+
33+
async def __aiter__(self) -> typing.AsyncIterator[bytes]:
34+
yield b"test"
35+
36+
async def aclose(self) -> None:
37+
self.closed = True
38+
39+
40+
def test_bound_sync_stream_sets_elapsed():
41+
response = httpx.Response(200, content=b"")
42+
stream = MockSyncStream()
43+
bound_stream = BoundSyncStream(stream, response=response, start=0.0)
44+
bound_stream.close()
45+
assert hasattr(response, "_elapsed")
46+
assert response.elapsed.total_seconds() >= 0
47+
48+
49+
def test_bound_sync_stream_handles_collected_response():
50+
response = httpx.Response(200, content=b"")
51+
stream = MockSyncStream()
52+
bound_stream = BoundSyncStream(stream, response=response, start=0.0)
53+
del response
54+
gc.collect()
55+
bound_stream.close()
56+
assert stream.closed
57+
58+
59+
def test_bound_sync_stream_no_reference_cycle():
60+
response = httpx.Response(200, content=b"")
61+
response_ref = weakref.ref(response)
62+
stream = MockSyncStream()
63+
bound_stream = BoundSyncStream(stream, response=response, start=0.0)
64+
response.stream = bound_stream
65+
del response
66+
gc.collect()
67+
assert response_ref() is None, "Response should have been garbage collected"
68+
69+
70+
@pytest.mark.anyio
71+
async def test_bound_async_stream_sets_elapsed():
72+
response = httpx.Response(200, content=b"")
73+
stream = MockAsyncStream()
74+
bound_stream = BoundAsyncStream(stream, response=response, start=0.0)
75+
await bound_stream.aclose()
76+
assert hasattr(response, "_elapsed")
77+
assert response.elapsed.total_seconds() >= 0
78+
79+
80+
@pytest.mark.anyio
81+
async def test_bound_async_stream_handles_collected_response():
82+
response = httpx.Response(200, content=b"")
83+
stream = MockAsyncStream()
84+
bound_stream = BoundAsyncStream(stream, response=response, start=0.0)
85+
del response
86+
gc.collect()
87+
await bound_stream.aclose()
88+
assert stream.closed
89+
90+
91+
@pytest.mark.anyio
92+
async def test_bound_async_stream_no_reference_cycle():
93+
response = httpx.Response(200, content=b"")
94+
response_ref = weakref.ref(response)
95+
stream = MockAsyncStream()
96+
bound_stream = BoundAsyncStream(stream, response=response, start=0.0)
97+
response.stream = bound_stream
98+
del response
99+
gc.collect()
100+
assert response_ref() is None, "Response should have been garbage collected"

0 commit comments

Comments
 (0)