From 5dda6c926d41d5d5cdcd2d113838bf92e374a326 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 16:42:01 +0000 Subject: [PATCH] Cache get_event_loop()'s per-thread loop instead of re-resolving every call get_event_loop() previously fell all the way through to asyncio.get_event_loop() (raise-then-catch RuntimeError, then new_event_loop()+set_event_loop()) on every single call once there was no running loop -- the common case for calls made outside of a running loop, e.g. at import time when services/agents are declared at module level. Cache the resolved loop in thread-local storage and reuse it on later calls from the same thread, skipping the asyncio.get_event_loop() dance entirely once a loop has been resolved. asyncio.get_running_loop() is still checked unconditionally on every call and can't be cached -- whether a loop is currently *running* changes on every call by definition -- but that's the cheap path (no exception when a loop is actually running); the expensive not-running path is what benefits from caching. The cache must be thread-local, not a single global/module-level singleton: ServiceThread runs a dedicated event loop per worker thread (mode/threads.py), including code that compares get_event_loop()'s result by identity against a specific thread's loop. A plain global would return the wrong thread's loop across worker threads. threading.local() gives each thread independent storage with no cross-thread leakage and no explicit cleanup needed when a thread exits. Adds tests: caching skips a repeated asyncio.get_event_loop() call, and a second thread's cached loop is independent of (and doesn't leak into) the main thread's. Assisted-by: Claude Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HgnKFtXZbCjXNoVNWa5JLd --- mode/utils/loops.py | 30 ++++++++++++++++++++-- tests/unit/utils/test_loops.py | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/mode/utils/loops.py b/mode/utils/loops.py index 785b06b..87a818f 100644 --- a/mode/utils/loops.py +++ b/mode/utils/loops.py @@ -1,10 +1,19 @@ """Event loop utilities.""" import asyncio +import threading from typing import Any, Callable, Optional __all__ = ["call_asap", "clone_loop", "get_event_loop"] +#: Per-thread cache of the loop resolved by :func:`get_event_loop` for when +#: there is no *running* loop. ``threading.local`` gives every thread its +#: own attribute storage for free (no cross-thread leakage, no explicit +#: cleanup required when a thread exits) -- required for correctness, not +#: just performance, since Mode creates and runs a dedicated event loop per +#: :class:`~mode.threads.ServiceThread` worker thread. +_current_loop = threading.local() + def get_event_loop() -> asyncio.AbstractEventLoop: """Return the current event loop, creating one if necessary. @@ -20,17 +29,34 @@ def get_event_loop() -> asyncio.AbstractEventLoop: loop -- e.g. at import time, when agents/services are declared at module level -- so it needs the historical "get or create" semantics. This restores them in a way that works across Python 3.9-3.14. + + Whether a loop is currently *running* can change on every call (that's + the whole point of an event loop), so :func:`asyncio.get_running_loop` + must always be re-checked and can't be cached. When nothing is running, + though, the resolved loop is stable for the lifetime of the thread that + resolved it, so it's cached in thread-local storage: once a thread has + created (or found) its loop, later calls on that thread return the + cached reference directly instead of re-running the + ``asyncio.get_event_loop()``-raises-then-create dance every time. """ try: return asyncio.get_running_loop() except RuntimeError: pass + + loop: Optional[asyncio.AbstractEventLoop] = getattr( + _current_loop, "loop", None + ) + if loop is not None and not loop.is_closed(): + return loop + try: - return asyncio.get_event_loop() + loop = asyncio.get_event_loop() except RuntimeError: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) - return loop + _current_loop.loop = loop + return loop def _is_unix_loop(loop: asyncio.AbstractEventLoop) -> bool: diff --git a/tests/unit/utils/test_loops.py b/tests/unit/utils/test_loops.py index c199c33..14f661f 100644 --- a/tests/unit/utils/test_loops.py +++ b/tests/unit/utils/test_loops.py @@ -1,4 +1,6 @@ import asyncio +import threading +from unittest.mock import patch from mode.utils.loops import get_event_loop @@ -46,3 +48,47 @@ def test_get_event_loop__is_idempotent_without_running_loop(): finally: asyncio.set_event_loop(None) first.close() + + +def test_get_event_loop__caches_loop_without_repeated_lookup(): + # The whole point of the thread-local cache: once a loop has been + # resolved for a thread, later calls on that thread must not re-invoke + # (and potentially re-raise/re-catch RuntimeError from) + # asyncio.get_event_loop() again. + asyncio.set_event_loop(None) + first = get_event_loop() + try: + with patch( + "mode.utils.loops.asyncio.get_event_loop" + ) as get_event_loop_mock: + second = get_event_loop() + assert second is first + get_event_loop_mock.assert_not_called() + finally: + asyncio.set_event_loop(None) + first.close() + + +def test_get_event_loop__caches_per_thread_not_globally(): + # Mode runs a dedicated event loop per ServiceThread worker thread + # (see mode.threads.ServiceThread), so the cache must be thread-local: + # one thread's cached loop must never leak into another thread's call. + asyncio.set_event_loop(None) + main_loop = get_event_loop() + other_loop_holder: dict = {} + + def other_thread() -> None: + other_loop_holder["loop"] = get_event_loop() + + try: + thread = threading.Thread(target=other_thread) + thread.start() + thread.join() + + other_loop = other_loop_holder["loop"] + assert other_loop is not main_loop + assert get_event_loop() is main_loop + finally: + asyncio.set_event_loop(None) + main_loop.close() + other_loop_holder["loop"].close()