Skip to content

fix: suppress 'Event loop is closed' RuntimeError on Ctrl+D exit - #5177

Open
zgqq wants to merge 1 commit into
NousResearch:mainfrom
zgqq:fix/event-loop-closed-on-exit
Open

fix: suppress 'Event loop is closed' RuntimeError on Ctrl+D exit#5177
zgqq wants to merge 1 commit into
NousResearch:mainfrom
zgqq:fix/event-loop-closed-on-exit

Conversation

@zgqq

@zgqq zgqq commented Apr 5, 2026

Copy link
Copy Markdown
Contributor

Problem

On Python 3.10+, pressing Ctrl+D to exit the CLI produces a RuntimeError: Event loop is closed traceback. This was not fully resolved by the three-layer fix introduced in #3398.

Root Cause

Python 3.10+ performs a synchronous _check_closed() call inside call_soon() when the event loop is already closed. This raises RuntimeError before any exception handler can intercept it — it bypasses the existing _suppress_closed_loop_errors handler entirely because it occurs inside the C-level call_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 loop
  • cleanup_stale_async_clients() — proactively closes clients before exit

But 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_closed to silently suppress RuntimeError with message "Event loop is closed". This is the correct suppression point because:

  1. By the time _check_closed is called during cleanup, the loop is already stopped — there is no intent to schedule new work
  2. The error is purely a guard to prevent programming mistakes, not a condition that indicates a real problem
  3. This matches the intent of the existing _suppress_closed_loop_errors handler, but operates at the correct layer

The patch is wrapped in try/except so it gracefully degrades if the asyncio API changes in future versions.

Testing

  • python3 -m py_compile cli.py — passes
  • Manual: run python cli.py, then press Ctrl+D — no traceback

Related: #3398 (original three-layer fix)

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.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard labels May 1, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:53 replaces BaseEventLoop._check_closed process-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.

Comment thread cli.py
if "Event loop is closed" in str(e):
return # silently suppress
raise
BaseEventLoop._check_closed = _silence_check_closed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants