Skip to content

BaseApiClient.__del__ schedules aclose() on whatever loop is current; on Python 3.14 a cross-loop close leaves the aiohttp connector's _wait_for_close task destroyed while pending #2995

Description

@emhagman

AWHR: AI Written, Human Reviewed by me

Environment

  • google-genai 1.75.0 (the __del__ and aiohttp selection are unchanged on main as of today)
  • aiohttp 3.13.4
  • CPython 3.14.7, Linux (uvicorn API process)

What happens

BaseApiClient.__del__ (and AsyncHttpxClient.__del__) do:

asyncio.get_running_loop().create_task(self.aclose())

That schedules the close on whichever loop is running on the thread where garbage collection happens to fire, not on the loop that created the aiohttp ClientSession. In our process a second thread runs its own event loop (weaviate-client's sync client does this; any loop.run_forever() helper thread will), so GC regularly runs there.

On Python 3.13 this was a swallowed warning. On 3.14 asyncio's current-task bookkeeping moved onto the thread state, and aiohttp's BaseConnector.close() starts its cleanup with asyncio.Task(..., loop=self._loop, eager_start=True). When self._loop is not the calling thread's running loop, 3.14 raises RuntimeError: loop <...> is not the running loop from inside the eager start, and asyncio then logs:

Task was destroyed but it is pending!
task: <Task pending name='Task-...' coro=<_wait_for_close() running at .../aiohttp/connector.py:136> ...>

once per garbage-collected client. We see this on every deploy since moving to 3.14.

Minimal reproduction of the 3.14 behavior (no API key needed)

import asyncio, threading, aiohttp
from aiohttp import web

async def handler(request):
    return web.Response(text="ok")

def start_bg_loop():
    loop = asyncio.new_event_loop()
    threading.Thread(target=loop.run_forever, daemon=True).start()
    return loop

async def main():
    app = web.Application(); app.router.add_get("/", handler)
    runner = web.AppRunner(app); await runner.setup()
    site = web.TCPSite(runner, "127.0.0.1", 0); await site.start()
    port = site._server.sockets[0].getsockname()[1]

    session = aiohttp.ClientSession()            # created on the main loop
    async with session.get(f"http://127.0.0.1:{port}/") as resp:
        await resp.text()

    bg = start_bg_loop()
    fut = asyncio.run_coroutine_threadsafe(session.close(), bg)   # what __del__ effectively does
    await asyncio.wrap_future(fut)
    await asyncio.sleep(0.2)
    await runner.cleanup()

asyncio.run(main())

Python 3.13: RuntimeError: Task ... attached to a different loop. Python 3.14: RuntimeError: loop ... is not the running loop plus the Task was destroyed but it is pending! log above.

Suggested fix

Remember the loop the session was created on and close on that loop (loop.call_soon_threadsafe(loop.create_task, self.aclose()) when get_running_loop() differs, or skip the close entirely when the owning loop is closed), rather than using whatever loop is current in __del__. Alternatively, drop the __del__-time close and rely on aclose()/context managers, which is what aiohttp itself recommends.

Workaround we're using

Passing a custom transport in HttpOptions.async_client_args makes _use_aiohttp() return False, so the client stays on httpx and the cross-loop close is harmless. It would help to document this as the supported way to opt out of aiohttp.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

priority: p2Moderately-important priority. Fix may not be included in next release.type: bugError or flaw in code with unintended results or allowing sub-optimal usage patterns.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions