Repository navigation
Fix result.log() failing with "Event loop is closed" in plain scripts - #577
Merged
Merged
Conversation
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
enabled auto-merge (squash)
October 23, 2026 19:58
jamadriz
approved these changes
Oct 23, 2026
jamadriz
left a comment
Contributor
There was a problem hiding this comment.
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 withRuntimeError: Event loop is closedand the script printsERROR: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 defaultonly theResourceWarning: unclosed transportlines from the description appear. tests/test_api_client.pypasses, 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:
- Test coverage:
BaseHTTPRequestHandlerdefaults 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. Addingprotocol_version = "HTTP/1.1"to the test'sHandlercovers that case; my keep-alive run passed on this branch, so the test should stay green. - Fire-and-forget close:
_invalidate_async_session()callsloop.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 nextawait, so it works in practice. Keeping a reference to the task until it finishes would be more robust.
🤖 Generated with Claude Code
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.
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 byresult.log():Outside a notebook,
run_asyncruns each call withasyncio.run(...), which creates and closes a new event loop every time.api_clientkept one module-levelaiohttp.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.atexithandler_close_session()now just calls_invalidate_async_session(). Before, it usedasyncio.get_event_loop(), which in a plain script afterasyncio.runraises "no current event loop". That error was swallowed, so the session was never closed and every script printedUnclosed client session/Unclosed connectorat exit.Notebooks:
run_async'sis_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 = Nonebeforelog()).How to test
TestAsyncSessionAcrossEventLoops.test_post_works_on_each_new_event_loopintests/test_api_client.py. It starts a local stdlib HTTP server and callsrun_async(api_client._post, ...)twice, the way a script callsvm.init()and thenresult.log(). It fails onmainwithRuntimeError: Event loop is closedand passes with this change.python -m unittest tests.test_api_client tests.test_results tests.test_client: 102 tests pass.http://localhost:5000/api/v1/tracking):vm.init→run_test(ClassImbalance)→result.log(), no workaround): logs successfully, with noUnclosed client sessionoutput during the run or at exit.result.log()succeed. The session stays bound to the library's notebook loop and is reused across calls (same object).What needs special review?
Unclosed client sessionerrors. Its sockets came from the dead loop, so Python can still emitResourceWarnings 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 withRuntimeError: Event loop is closedwhen the ValidMind Library is used from a Python script instead of a Jupyter notebook. Scripts also no longer printUnclosed client sessionwarnings on exit.Checklist
🤖 Generated with Claude Code