fix(auxiliary): close the underlying client of cached async wrappers - #67262
Open
Frowtek wants to merge 2 commits into
Open
fix(auxiliary): close the underlying client of cached async wrappers#67262Frowtek wants to merge 2 commits into
Frowtek wants to merge 2 commits into
Conversation
`AsyncCodexAuxiliaryClient`, `AsyncAnthropicAuxiliaryClient` and
`AsyncBedrockAuxiliaryClient` had no `close()`, while all three sync twins
do. That matters because the async shim is the object that gets CACHED:
`_to_async_client` builds it from a sync wrapper that is a local transient,
so no sync cache entry exists to release the transport later.
`_close_cached_client` reaches a client two ways, and a shim answers
neither:
* `getattr(client, "close", None)` - absent on the shims.
* `_force_close_async_httpx` - looks for `client._client`, the httpx
client inside an `AsyncOpenAI`. A shim only carries `.chat`,
`.api_key`, `.base_url` and the mirrored `._real_client`.
Both lookups miss, so closing a cached async wrapper is a complete no-op.
Measured before the fix, on the same underlying client:
_close_cached_client(async wrapper) -> underlying closed: False
_close_cached_client(sync wrapper) -> underlying closed: True
That silently defeats two documented behaviours: `shutdown_cached_clients`
("Close all cached clients (sync and async)") and `_evict_cached_clients`,
which fires on every credential refresh - so each refresh leaks the
transport it was meant to release, in exactly the long-running-gateway fd
class `_get_cached_client` cites (NousResearch#10200).
Give each async wrapper the same `close()` as its sync twin, delegating to
the already-mirrored `_real_client` (Bedrock stays a no-op - it builds
per-call - but now answers the protocol). The methods are deliberately
synchronous so the eviction guard that skips coroutine `close()` still runs
them.
teknium1
reviewed
Jul 19, 2026
teknium1
left a comment
Contributor
There was a problem hiding this comment.
Thanks for tracing the cached-wrapper lifecycle; the Codex and Anthropic cases are real on the inspected checkout.
Problems
agent/auxiliary_client.py:4597-4600also createsAsyncGeminiNativeClientfor native Gemini. Itsclose()is a coroutine (agent/gemini_native_adapter.py:1034), while the canonical cache closer intentionally skips coroutine close methods (agent/auxiliary_client.py:6022-6024). Native Gemini cached async transports therefore retain the same shutdown/credential-eviction leak.- The new “every sync wrapper” parity test omits the Gemini pair at
tests/agent/test_aux_async_wrapper_close.py:85-92, so it cannot catch that sibling path.
Suggested changes
- Add a safe synchronous cache-close path for native Gemini and exercise it through the actual cached shutdown or eviction flow.
Automated hermes-sweeper review.
|
|
||
| def test_async_wrappers_mirror_the_sync_close_surface(): | ||
| """Every sync wrapper's close() must have an async counterpart.""" | ||
| pairs = [ |
Contributor
There was a problem hiding this comment.
This parity list omits GeminiNativeClient / AsyncGeminiNativeClient. _to_async_client creates that wrapper (agent/auxiliary_client.py:4597-4600), but its close() is coroutine-based (agent/gemini_native_adapter.py:1034) and _close_cached_client skips coroutine closers (agent/auxiliary_client.py:6022-6024). Include a synchronous cache-close solution and a Gemini pair here so the same leak is covered.
Collaborator
_to_async_client also wraps GeminiNativeClient in AsyncGeminiNativeClient,
and that shim leaks its transport for the same reason as the other three —
by a different path.
Its close() exists, but it is a coroutine. _close_cached_client runs from
synchronous callers (CLI shutdown, credential-refresh eviction) with no loop
to await on, so the iscoroutinefunction guard deliberately skips it. The shim
also carries no _client, so _force_close_async_httpx finds nothing either.
Both lookups miss and the native-Gemini transport survives every shutdown and
every credential rotation.
Measured through the real cache flows before this change:
shutdown_cached_clients -> leaf closed: False
_evict_cached_clients -> leaf closed: False
Fall back to the leaf the shim already mirrors as _real_client for
eviction-by-leaf (NousResearch#23482). GeminiNativeClient.close() IS synchronous, so the
sync closer can release it directly. The public async close() is untouched
and still awaits.
Tests: drive the actual cached shutdown and credential-eviction flows (both
fail without the fallback), assert the async close() still awaits, add the
Gemini pair to the sync/async parity list, and add a reachability invariant —
a cacheable wrapper must expose either a synchronous close() or a mirrored
_real_client leaf, otherwise the canonical closer silently does nothing.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
AsyncCodexAuxiliaryClient,AsyncAnthropicAuxiliaryClientandAsyncBedrockAuxiliaryClienthave noclose(), while all three sync twins do.That matters because the async shim is the object that gets cached:
_to_async_clientbuilds it from a sync wrapper that is a local transient, so no sync cache entry exists to release the transport later._close_cached_clientreaches a client two ways, and a shim answers neither:getattr(client, "close", None)— absent on the shims._force_close_async_httpx— looks forclient._client, the httpx client inside anAsyncOpenAI. A shim only carries.chat,.api_key,.base_urland the mirrored._real_client.Both lookups miss, so closing a cached async wrapper is a complete no-op. Measured before the fix, on the same underlying client:
This silently defeats two documented behaviours:
shutdown_cached_clients("Close all cached clients (sync and async)") and_evict_cached_clients, which fires on every credential refresh — so each refresh leaks the transport it was meant to release, in exactly the long-running-gateway fd class_get_cached_clientcites (#10200).Related Issue
Fixes #
Type of Change
Changes Made
agent/auxiliary_client.py— give each async wrapper the sameclose()as its sync twin, delegating to the already-mirrored_real_client. Bedrock stays a no-op (it builds per-call) but now answers the protocol. The methods are deliberately synchronous so the eviction guard that skips coroutineclose()still runs them.tests/agent/test_aux_async_wrapper_close.py— new: closing a cached async wrapper releases the underlying client,close()is not a coroutine, Bedrock answers the protocol without raising, and every syncclose()has an async counterpart so the surface can't diverge again.How to Test
Run:
All 6 tests fail without the fix and pass with it.
No regressions:
pytest tests/agent/ -k "auxiliary or aux_"gives554 passed, 0 failedon cleanmainand560 passed, 0 failedwith this change (554 + the 6 added tests).Checklist
Code
fix(auxiliary):)mainbaselineDocumentation & Housekeeping
close()documents why the cached shim must release its own clientcli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/A