fix: suppress 'Event loop is closed' RuntimeError on Ctrl+D exit - #5177
Open
zgqq wants to merge 1 commit into
Open
fix: suppress 'Event loop is closed' RuntimeError on Ctrl+D exit#5177zgqq wants to merge 1 commit into
zgqq wants to merge 1 commit into
Conversation
Python 3.10+ raises RuntimeError synchronously from call_soon() when the event loop is already closed. This occurs during cleanup (__del__ or close() calls that run after the loop has stopped) and happens before any exception handler can intercept it, bypassing the existing three-layer fix (neuter_async_httpx_del, custom exception handler, stale client cleanup). This patch adds a last-resort monkey-patch to BaseEventLoop._check_closed that silently suppresses 'Event loop is closed' RuntimeErrors during late cleanup, matching the intent of the existing _suppress_closed_loop_errors handler but operating at the correct layer.
teknium1
reviewed
Jul 12, 2026
teknium1
left a comment
Contributor
There was a problem hiding this comment.
Thanks for investigating the shutdown traceback. Current main already targets the known AsyncOpenAI/httpx finalizer path: cli.py:813-822 patches AsyncHttpxClientWrapper.__del__ at import time, with stale-client cleanup at cli.py:12501-12504 and an event-loop handler at cli.py:15466-15475.
Problems
cli.py:53replacesBaseEventLoop._check_closedprocess-wide.call_soon()depends on that guard before it queues a callback, so suppressing it changes closed-loop behavior rather than merely suppressing displayed cleanup noise.- There is no current-main reproducer or regression test showing which source still escapes the existing targeted mitigation.
Suggested changes
- Reproduce the traceback on current main and identify the concrete finalizer or cleanup source.
- Scope the fix to that source and add a regression test for the shutdown path instead of overriding asyncio's global closed-loop invariant.
This is an automated hermes-sweeper review.
| if "Event loop is closed" in str(e): | ||
| return # silently suppress | ||
| raise | ||
| BaseEventLoop._check_closed = _silence_check_closed |
Contributor
There was a problem hiding this comment.
call_soon() uses _check_closed() as its guard before enqueueing work. Replacing it globally means late cleanup can queue callbacks on a closed loop instead of failing, so this is not a safe output-only suppression point; please identify and fix the specific remaining finalizer path.
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.
Problem
On Python 3.10+, pressing Ctrl+D to exit the CLI produces a
RuntimeError: Event loop is closedtraceback. This was not fully resolved by the three-layer fix introduced in #3398.Root Cause
Python 3.10+ performs a synchronous
_check_closed()call insidecall_soon()when the event loop is already closed. This raisesRuntimeErrorbefore any exception handler can intercept it — it bypasses the existing_suppress_closed_loop_errorshandler entirely because it occurs inside the C-levelcall_soon()implementation itself.The existing fixes work at the right layers for most cases:
neuter_async_httpx_del()— prevents httpx SDK from scheduling cleanup on a dead loop_suppress_closed_loop_errors— catches exceptions dispatched by the loopcleanup_stale_async_clients()— proactively closes clients before exitBut any third-party
__del__,close(), or GC-triggered cleanup that runs after the loop has stopped will still trigger the synchronous check and crash.Fix
Monkey-patch
asyncio.BaseEventLoop._check_closedto silently suppressRuntimeErrorwith message "Event loop is closed". This is the correct suppression point because:_check_closedis called during cleanup, the loop is already stopped — there is no intent to schedule new work_suppress_closed_loop_errorshandler, but operates at the correct layerThe patch is wrapped in
try/exceptso it gracefully degrades if the asyncio API changes in future versions.Testing
python3 -m py_compile cli.py— passespython cli.py, then press Ctrl+D — no tracebackRelated: #3398 (original three-layer fix)