From 8ae3ba4f29eba4bd5672b086a151bbeea94b6f18 Mon Sep 17 00:00:00 2001 From: annguyenNous Date: Thu, 4 Jun 2026 13:42:43 +0700 Subject: [PATCH] fix(auxiliary): close evicted clients in cache to prevent fd leaks Three resource leak fixes in the auxiliary client cache: 1. _evict_cached_client_instance: evicted clients were deleted from cache without closing their httpx transport, leaking sockets/fds. Now calls _force_close_async_httpx + .close() matching the by-provider _evict_cached_clients() cleanup. 2. _get_cached_client TOCTOU race: when two threads concurrently create a client for the same cache key, the losing thread's freshly-created client was silently discarded without close(). Now closes the redundant client before replacing. 3. FIFO eviction: the cache-size cap path only called _force_close_async_httpx (which marks httpx state as CLOSED) but skipped .close() for sync OpenAI clients, leaving their TCP connection pools open. Now matches shutdown_cached_clients() cleanup pattern. These leaks accumulate in long-running gateway processes where connection errors trigger frequent evictions (#10200). --- agent/auxiliary_client.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 961e30313de67..27f9536a57988 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -2583,6 +2583,16 @@ def _evict_cached_client_instance(target: Any) -> bool: continue real = getattr(cached, "_real_client", None) if cached is target or real is target: + # Close the evicted client's transport to release sockets/fds. + # _evict_cached_clients() (by-provider) already does this; + # the instance variant must match. See #10200. + _force_close_async_httpx(cached) + try: + close_fn = getattr(cached, "close", None) + if callable(close_fn): + close_fn() + except Exception: + pass del _client_cache[key] evicted = True return evicted @@ -4562,9 +4572,24 @@ def _get_cached_client( while len(_client_cache) >= _CLIENT_CACHE_MAX_SIZE: evict_key, evict_entry = next(iter(_client_cache.items())) _force_close_async_httpx(evict_entry[0]) + try: + close_fn = getattr(evict_entry[0], "close", None) + if callable(close_fn): + close_fn() + except Exception: + pass del _client_cache[evict_key] _client_cache[cache_key] = (client, default_model, bound_loop) else: + # Another thread won the race — close the redundant client + # to avoid leaking its httpx transport (sockets/fds). + _force_close_async_httpx(client) + try: + close_fn = getattr(client, "close", None) + if callable(close_fn): + close_fn() + except Exception: + pass client, default_model, _ = _client_cache[cache_key] return client, model or default_model