Skip to content

Commit ce86c6e

Browse files
committed
fix: Correct pool ownership and close pooled connections synchronously
_HttpClientImpl owned the wrong pool: the ownership flag was inverted in the 2023 ConnectStrategy refactor (c516f1f), so close() cleared a caller-supplied pool instead of one it created. On urllib3 2.x, PoolManager.clear() no longer closes connections synchronously (sockets close at GC via weakref.finalize), so a caller's streaming connection lingered until garbage collection. - Restore conventional ownership: close only a pool we created (params.pool is None), matching the async client's existing behavior. - Close each HTTPConnectionPool synchronously before clear() so the TCP FIN is sent immediately instead of at GC. - Log close errors at debug instead of swallowing them. Adds regression tests covering both halves of the ownership contract.
1 parent 648df2c commit ce86c6e

2 files changed

Lines changed: 44 additions & 1 deletion

File tree

ld_eventsource/http.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ class _HttpClientImpl:
5757
def __init__(self, params: _HttpConnectParams, logger: Logger):
5858
self.__params = params
5959
self.__pool = params.pool or PoolManager()
60-
self.__should_close_pool = params.pool is not None
60+
self.__should_close_pool = params.pool is None
6161
self.__logger = logger
6262

6363
def connect(self, last_event_id: Optional[str]) -> Tuple[Iterator[bytes], Callable, Dict[str, Any]]:
@@ -125,4 +125,14 @@ def close():
125125

126126
def close(self):
127127
if self.__should_close_pool:
128+
# Close pooled connections (sends the TCP FIN) before dropping the pool.
129+
# PoolManager.clear() alone drops the pool dict without closing the
130+
# underlying sockets, leaving the connection open until garbage collection.
131+
for key in list(self.__pool.pools.keys()):
132+
connection_pool = self.__pool.pools.get(key)
133+
if connection_pool is not None:
134+
try:
135+
connection_pool.close()
136+
except Exception:
137+
self.__logger.debug("Error closing connection pool", exc_info=True)
128138
self.__pool.clear()

ld_eventsource/testing/test_http_connect_strategy.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import logging
2+
from unittest import mock
23

4+
from urllib3 import PoolManager
35
from urllib3.exceptions import ProtocolError
46

57
from ld_eventsource import *
@@ -239,3 +241,34 @@ def test_fault_exposes_headers_from_http_error():
239241
assert fault.headers is not None
240242
assert fault.headers.get('Retry-After') == '60'
241243
assert fault.headers.get('X-Error-Code') == 'SERVICE_UNAVAILABLE'
244+
245+
246+
def test_close_leaves_caller_supplied_pool_open():
247+
# The caller owns the lifecycle of a pool it supplies, so close() must not
248+
# tear it down. (Regression: this ownership flag was inverted, causing the
249+
# client to clear() a caller-supplied pool out from under the caller.)
250+
pool = mock.MagicMock(spec=PoolManager)
251+
client = ConnectStrategy.http("http://test", pool=pool).create_client(logger())
252+
253+
client.close()
254+
255+
pool.clear.assert_not_called()
256+
257+
258+
def test_close_closes_pool_it_created():
259+
# When the client creates its own pool (no pool supplied), close() must close
260+
# the pooled connections synchronously -- sending the TCP FIN now -- rather
261+
# than leaving the sockets open until garbage collection. PoolManager.clear()
262+
# alone does not close them on urllib3 2.x.
263+
connection_pool = mock.Mock()
264+
created_pool = mock.MagicMock()
265+
created_pool.pools.keys.return_value = ['poolkey']
266+
created_pool.pools.get.return_value = connection_pool
267+
268+
with mock.patch('ld_eventsource.http.PoolManager', return_value=created_pool):
269+
client = ConnectStrategy.http("http://test").create_client(logger())
270+
271+
client.close()
272+
273+
connection_pool.close.assert_called_once()
274+
created_pool.clear.assert_called_once()

0 commit comments

Comments
 (0)