Skip to content
Open
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
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ jobs:
python -m pip install --upgrade tox tox-py

- name: Run tox targets for ${{ matrix.python-version }}
timeout-minutes: 10

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran into a failing test that caused an endless loop, so I added a timeout to automatically cancel such pipelines.

Image

run: tox --py current

lint:
Expand Down
74 changes: 61 additions & 13 deletions asgiref/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import threading
import warnings
import weakref
from asyncio import constants
from concurrent.futures import Future, ThreadPoolExecutor
from typing import (
TYPE_CHECKING,
Expand All @@ -32,10 +33,16 @@
else:
from typing_extensions import ParamSpec

THREAD_JOIN_TIMEOUT = getattr(constants, "THREAD_JOIN_TIMEOUT", 0)

if TYPE_CHECKING:
# This is not available to import at runtime
from _typeshed import OptExcInfo

AsyncSingleThreadContextMapType = weakref.WeakKeyDictionary[
"AsyncSingleThreadContext", tuple[ThreadPoolExecutor, asyncio.AbstractEventLoop]
]

_F = TypeVar("_F", bound=Callable[..., Any])
_P = ParamSpec("_P")
_R = TypeVar("_R")
Expand Down Expand Up @@ -97,11 +104,48 @@ def __enter__(self):

return self

def _cancel_all_tasks(self, loop):
to_cancel = asyncio.all_tasks(loop)
if not to_cancel:
return

for task in to_cancel:
task.cancel()

loop.run_until_complete(asyncio.gather(*to_cancel, return_exceptions=True))

for task in to_cancel:
if task.cancelled():
continue
if task.exception() is not None:
loop.call_exception_handler(
{
"message": "unhandled exception during AsyncSingleThreadContext shutdown",
"exception": task.exception(),
"task": task,
}
)

def __exit__(self, exc, value, tb):
if not self.token:
return

executor = AsyncToSync.context_to_thread_executor.pop(self, None)
executor, loop = AsyncToSync.async_single_thread_context_map.pop(
self, (None, None)
)
if loop:
try:
self._cancel_all_tasks(loop)
loop.run_until_complete(loop.shutdown_asyncgens())
if THREAD_JOIN_TIMEOUT:
loop.run_until_complete(
loop.shutdown_default_executor(THREAD_JOIN_TIMEOUT)
)
else:
loop.run_until_complete(loop.shutdown_default_executor())
finally:
loop.close()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using asyncio.run, we rely on loop.run_until_complete(asyncio.run closes the event loop), which means we need to perform the additional cleanup that asyncio.run normally handles for us.

Internally, asyncio.run is implemented using asyncio.Runner. Below is a simplified version of Runner, with parts removed that are not relevant to our use case:

class Runner:
    def close(self):
        try:
            _cancel_all_tasks(self._loop)
            self._loop.run_until_complete(self._loop.shutdown_asyncgens())
            self._loop.run_until_complete(
                self._loop.shutdown_default_executor(constants.THREAD_JOIN_TIMEOUT))
        finally:
            if self._set_event_loop:
                events.set_event_loop(None)
            loop.close()

    def run(self, coro, *, context=None):
        self._loop = events.new_event_loop()
        events.set_event_loop(self._loop)

        task = self._loop.create_task(coro)

        return self._loop.run_until_complete(task)

I added everything except the set_event_loop logic. In our case, when running code via loop.run_until_complete, asyncio.get_event_loop() already returns the correct loop, so there is no need to set it explicitly. Instead, we reuse the same new_loop_wrap.

if executor:
executor.shutdown()

Expand Down Expand Up @@ -174,7 +218,7 @@ class AsyncToSync(Generic[_P, _R]):
contextvars.ContextVar("async_single_thread_context")
)

context_to_thread_executor: "weakref.WeakKeyDictionary[AsyncSingleThreadContext, ThreadPoolExecutor]" = (
async_single_thread_context_map: "AsyncSingleThreadContextMapType" = (
weakref.WeakKeyDictionary()
)

Expand Down Expand Up @@ -293,25 +337,29 @@ async def new_loop_wrap() -> None:
running_in_main_event_loop = False

if not running_in_main_event_loop:
loop_executor = None

if self.async_single_thread_context.get(None):
single_thread_context = self.async_single_thread_context.get()

if single_thread_context in self.context_to_thread_executor:
loop_executor = self.context_to_thread_executor[
single_thread_context
]
if single_thread_context in self.async_single_thread_context_map:
(
loop_executor,
context_loop,
) = self.async_single_thread_context_map[single_thread_context]
else:
context_loop = asyncio.new_event_loop()
loop_executor = ThreadPoolExecutor(max_workers=1)
self.context_to_thread_executor[
single_thread_context
] = loop_executor
self.async_single_thread_context_map[single_thread_context] = (
loop_executor,
context_loop,
)

loop_future = loop_executor.submit(
context_loop.run_until_complete, new_loop_wrap()
)
else:
# Make our own event loop - in a new thread - and run inside that.
loop_executor = ThreadPoolExecutor(max_workers=1)

loop_future = loop_executor.submit(asyncio.run, new_loop_wrap())
loop_future = loop_executor.submit(asyncio.run, new_loop_wrap())
# Run the CurrentThreadExecutor until the future is done.
current_executor.run_until_future(loop_future)
# Wait for future and/or allow for exception propagation
Expand Down
65 changes: 55 additions & 10 deletions tests/test_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

from asgiref.sync import (
AsyncSingleThreadContext,
AsyncToSync,
ThreadSensitiveContext,
async_to_sync,
iscoroutinefunction,
Expand Down Expand Up @@ -549,21 +550,24 @@ def inner(result):
def test_async_single_thread_context_matches():
"""
Tests that functions wrapped with async_to_sync and executed within an
AsyncSingleThreadContext run on the same thread, even without a main_event_loop.
AsyncSingleThreadContext run on the same thread/loop, even without a
main_event_loop.
"""
result_1 = {}
result_2 = {}

async def store_thread_async(result):
async def store_thread_info_async(result):
result["thread"] = threading.current_thread()
result["loop"] = asyncio.get_running_loop()

with AsyncSingleThreadContext():
async_to_sync(store_thread_async)(result_1)
async_to_sync(store_thread_async)(result_2)
async_to_sync(store_thread_info_async)(result_1)
async_to_sync(store_thread_info_async)(result_2)

# They should not have run in the main thread, and on the same threads
assert result_1["thread"] != threading.current_thread()
assert result_1["thread"] == result_2["thread"]
assert result_1["loop"] == result_2["loop"]


def test_async_single_thread_nested_context():
Expand All @@ -572,20 +576,28 @@ def test_async_single_thread_nested_context():
"""
result_1 = {}
result_2 = {}
result_3 = {}

@async_to_sync
async def store_thread(result):
async def store_thread_info(result):
result["thread"] = threading.current_thread()
result["loop"] = asyncio.get_running_loop()

with AsyncSingleThreadContext():
store_thread(result_1)
store_thread_info(result_1)

with AsyncSingleThreadContext():
store_thread(result_2)
store_thread_info(result_2)

# verify that the context does not shut down the executor or event loop
store_thread_info(result_3)

# They should not have run in the main thread, and on the same threads
assert result_1["thread"] != threading.current_thread()
assert result_1["thread"] == result_2["thread"]
assert result_1["thread"] == result_3["thread"]
assert result_1["loop"] == result_2["loop"]
assert result_1["loop"] == result_3["loop"]


def test_async_single_thread_context_without_async_work():
Expand Down Expand Up @@ -621,21 +633,54 @@ async def test_async_single_thread_context_matches_from_async_thread():
"""
result_1 = {}
result_2 = {}
result_3 = {}

@async_to_sync
async def store_thread_async(result):
async def store_thread_info_async(result):
result["thread"] = threading.current_thread()
result["loop"] = asyncio.get_running_loop()

def inner():
with AsyncSingleThreadContext():
store_thread_async(result_1)
store_thread_async(result_2)
store_thread_info_async(result_1)
store_thread_info_async(result_2)

# verify that the context does not shut down the executor or event loop
store_thread_info_async(result_3)

await sync_to_async(inner)()

# They should both have run in the current thread.
assert result_1["thread"] == threading.current_thread()
assert result_1["thread"] == result_2["thread"]
assert result_1["thread"] == result_3["thread"]
assert result_1["loop"] == result_2["loop"]
assert result_1["loop"] == result_3["loop"]


def test_async_single_thread_context_shutdown():
"""
Verifies the shutdown process works as expected.
"""
result = {}

async def inner_long_running_task():
await asyncio.sleep(5)
result["inner_long_running_task"] = True

async def handler():
# start but do not await, so it runs in the background
asyncio.create_task(inner_long_running_task())
return True

with AsyncSingleThreadContext() as ctx:
assert async_to_sync(handler)()

executor, loop = AsyncToSync.async_single_thread_context_map[ctx]

assert loop.is_closed(), "Event loop was not closed"
assert executor._shutdown, "Executor was not shutdown"
assert not result, "Background tasks should have been canceled"


@pytest.mark.asyncio
Expand Down