Skip to content

Fix result.log() failing with "Event loop is closed" in plain scripts - #577

Merged
cachafla merged 1 commit into
mainfrom
fix/run-async-session-event-loop
Oct 23, 2026
Merged

cachafla merged 1 commit into
mainfrom
fix/run-async-session-event-loop

Conversation

@cachafla

Copy link
Copy Markdown
Contributor

Pull Request Description

What and why?

Using the library from a plain Python script (not Jupyter), any second API call failed. The typical case is vm.init() followed by result.log():

RuntimeError: Event loop is closed
  validmind/api_client.py _post -> aiohttp ... _check_closed()

Outside a notebook, run_async runs each call with asyncio.run(...), which creates and closes a new event loop every time. api_client kept one module-level aiohttp.ClientSession. It was created on the first loop and reused on later loops after the first one had closed.

Fix (validmind/api_client.py):

  • _get_session() remembers the loop its session was created on. When it's called from a different loop, it closes and drops the old session (through the existing _invalidate_async_session()) and creates a new one.
  • The atexit handler _close_session() now just calls _invalidate_async_session(). Before, it used asyncio.get_event_loop(), which in a plain script after asyncio.run raises "no current event loop". That error was swallowed, so the session was never closed and every script printed Unclosed client session / Unclosed connector at exit.

Notebooks: run_async's is_notebook() path is unchanged. There, every call runs on the library's own nest_asyncio loop (or the kernel loop), so the loop never changes and one session is reused, as before.

Found while running an end-to-end check for validmind/backend#3888, whose script needed a workaround (vm.api_client.__api_session = None before log()).

How to test

  • New test TestAsyncSessionAcrossEventLoops.test_post_works_on_each_new_event_loop in tests/test_api_client.py. It starts a local stdlib HTTP server and calls run_async(api_client._post, ...) twice, the way a script calls vm.init() and then result.log(). It fails on main with RuntimeError: Event loop is closed and passes with this change.
  • python -m unittest tests.test_api_client tests.test_results tests.test_client: 102 tests pass.
  • Run against a local backend stack (http://localhost:5000/api/v1/tracking):
    • Plain script (vm.init → run_test(ClassImbalance) → result.log(), no workaround): logs successfully, with no Unclosed client session output during the run or at exit.
    • Notebook, run through nbclient with this checkout: the same cells plus a second result.log() succeed. The session stays bound to the library's notebook loop and is reused across calls (same object).

What needs special review?

  • When the loop changes, the old session belongs to a closed loop. It's closed on the new loop, which marks it closed and removes the aiohttp Unclosed client session errors. Its sockets came from the dead loop, so Python can still emit ResourceWarnings for them, but those are hidden by default.

Dependencies, breaking changes, and deployment notes

None.

Release notes

Fixed result.log() and other API calls failing with RuntimeError: Event loop is closed when the ValidMind Library is used from a Python script instead of a Jupyter notebook. Scripts also no longer print Unclosed client session warnings on exit.

Checklist

  • What and why
  • Screenshots or videos (Frontend)
  • How to test
  • What needs special review
  • Dependencies, breaking changes, and deployment notes
  • Labels applied
  • PR linked to Shortcut
  • Unit tests added (Backend)
  • Tested locally
  • Documentation updated (if required)
  • Environment variable additions/changes documented (if required)

🤖 Generated with Claude Code

Outside a notebook, run_async runs each call on a new event loop
(asyncio.run), but api_client kept one module-level aiohttp session that
stayed bound to the first loop. Any second API call in a script, such as
result.log() after vm.init(), failed with "Event loop is closed".

_get_session now remembers the loop its session was created on and
replaces the session when called from a different loop. The atexit
handler reuses _invalidate_async_session, so plain scripts no longer
print "Unclosed client session" on exit. Notebooks run every call on
the same loop, so they keep reusing one session as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cachafla cachafla added bug Something isn't working python Pull requests that update Python code labels Oct 23, 2026
@cachafla
cachafla enabled auto-merge (squash) October 23, 2026 19:58

@jamadriz jamadriz 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.

Approved. I reproduced the bug and confirmed the fix with a plain script that calls run_async(api_client._post, …) three times against a local keep-alive (HTTP/1.1) server:

  • On main: the second call fails with RuntimeError: Event loop is closed and the script prints ERROR:asyncio:Unclosed client session.
  • On this branch: all three calls succeed, and the output is clean with default warnings, including at exit. Under -X dev -W default only the ResourceWarning: unclosed transport lines from the description appear.
  • tests/test_api_client.py passes, including the new test (51 tests).

The notebook path is unchanged: there every call runs on the same loop, so the session is reused. The one background thread in the library (ai/test_descriptions.py) uses requests, not this session, so threads can't swap the session out from under each other.

Non-blocking notes:

  1. Test coverage: BaseHTTPRequestHandler defaults to HTTP/1.0, which closes the connection after each request, so the new test never leaves a pooled keep-alive connection on a closed loop. A real backend does. Adding protocol_version = "HTTP/1.1" to the test's Handler covers that case; my keep-alive run passed on this branch, so the test should stay green.
  2. Fire-and-forget close: _invalidate_async_session() calls loop.create_task(sess.close()) and keeps no reference to the task. This was already the case before this PR, and the close runs at the next await, so it works in practice. Keeping a reference to the task until it finishes would be more robust.

🤖 Generated with Claude Code

@cachafla
cachafla merged commit df8838c into main Oct 23, 2026
28 checks passed
@cachafla
cachafla deleted the fix/run-async-session-event-loop branch October 23, 2026 20:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working python Pull requests that update Python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants