From 1f21ef7488002b9cf826a1885b15d8254234ca5c Mon Sep 17 00:00:00 2001 From: Teknium Date: Sun, 22 Mar 2026 15:31:54 -0700 Subject: [PATCH] fix(cli): prevent 'Press ENTER to continue...' on exit When AsyncOpenAI clients are garbage-collected after the event loop closes, their AsyncHttpxClientWrapper.__del__ tries to schedule aclose() on the dead loop, causing RuntimeError: Event loop is closed. prompt_toolkit catches this as an unhandled exception and shows 'Press ENTER to continue...' which blocks CLI exit. Fix: Add shutdown_cached_clients() to auxiliary_client.py that marks all cached async clients' underlying httpx transport as CLOSED before GC runs. This prevents __del__ from attempting the aclose() call. - _force_close_async_httpx(): sets httpx AsyncClient._state to CLOSED - shutdown_cached_clients(): iterates _client_cache, closes sync clients normally and marks async clients as closed - Also fix stale client eviction in _get_cached_client to mark evicted async clients as closed (was just del-ing them, triggering __del__) - Call shutdown_cached_clients() from _run_cleanup() in cli.py --- agent/auxiliary_client.py | 48 +++++++++++++++++++++++++++++++++++++++ cli.py | 8 +++++++ 2 files changed, 56 insertions(+) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 9589edadb..5d147e430 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -1204,6 +1204,53 @@ _client_cache: Dict[tuple, tuple] = {} _client_cache_lock = threading.Lock() +def _force_close_async_httpx(client: Any) -> None: + """Mark the httpx AsyncClient inside an AsyncOpenAI client as closed. + + This prevents ``AsyncHttpxClientWrapper.__del__`` from scheduling + ``aclose()`` on a (potentially closed) event loop, which causes + ``RuntimeError: Event loop is closed`` → prompt_toolkit's + "Press ENTER to continue..." handler. + + We intentionally do NOT run the full async close path — the + connections will be dropped by the OS when the process exits. + """ + try: + from httpx._client import ClientState + inner = getattr(client, "_client", None) + if inner is not None and not getattr(inner, "is_closed", True): + inner._state = ClientState.CLOSED + except Exception: + pass + + +def shutdown_cached_clients() -> None: + """Close all cached clients (sync and async) to prevent event-loop errors. + + Call this during CLI shutdown, *before* the event loop is closed, to + avoid ``AsyncHttpxClientWrapper.__del__`` raising on a dead loop. + """ + import inspect + + with _client_cache_lock: + for key, entry in list(_client_cache.items()): + client = entry[0] + if client is None: + continue + # Mark any async httpx transport as closed first (prevents __del__ + # from scheduling aclose() on a dead event loop). + _force_close_async_httpx(client) + # Sync clients: close the httpx connection pool cleanly. + # Async clients: skip — we already neutered __del__ above. + try: + close_fn = getattr(client, "close", None) + if close_fn and not inspect.iscoroutinefunction(close_fn): + close_fn() + except Exception: + pass + _client_cache.clear() + + def _get_cached_client( provider: str, model: str = None, @@ -1222,6 +1269,7 @@ def _get_cached_client( # "Event loop is closed" when httpx tries to clean up its # transport. Discard the stale client and create a fresh one. if cached_loop is not None and cached_loop.is_closed(): + _force_close_async_httpx(cached_client) del _client_cache[cache_key] else: return cached_client, model or cached_default diff --git a/cli.py b/cli.py index 9ca51023e..43ae081ee 100644 --- a/cli.py +++ b/cli.py @@ -498,6 +498,14 @@ def _run_cleanup(): shutdown_mcp_servers() except Exception: pass + # Close cached auxiliary LLM clients (sync + async) so that + # AsyncHttpxClientWrapper.__del__ doesn't fire on a closed event loop + # and trigger prompt_toolkit's "Press ENTER to continue..." handler. + try: + from agent.auxiliary_client import shutdown_cached_clients + shutdown_cached_clients() + except Exception: + pass # =============================================================================