Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions mode/utils/loops.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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:
Expand Down
46 changes: 46 additions & 0 deletions tests/unit/utils/test_loops.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import asyncio
import threading
from unittest.mock import patch

from mode.utils.loops import get_event_loop

Expand Down Expand Up @@ -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()
Loading