Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix potential deadlock if open_loop() is cancelled #128

Merged
merged 2 commits into from
Dec 1, 2023
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
4 changes: 4 additions & 0 deletions newsfragments/115.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
A deadlock will no longer occur if :func:`trio_asyncio.open_loop`
is cancelled before its first checkpoint. We also now cancel and wait on
all asyncio tasks even if :func:`~trio_asyncio.open_loop` terminates due
to an exception that was raised within the ``async with`` block.
81 changes: 49 additions & 32 deletions trio_asyncio/_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,40 +456,57 @@ async def async_main(*args):
try:
loop._closed = False
async with trio.open_nursery() as tasks_nursery:
await loop._main_loop_init(tasks_nursery)
await loop_nursery.start(loop._main_loop)
yield loop

# Allow all already-submitted tasks a chance to start
# (and then immediately be cancelled), unless the loop
# stops (due to someone else calling stop()) before
# that.
async with trio.open_nursery() as sync_nursery:
sync_nursery.cancel_scope.shield = True

@sync_nursery.start_soon
async def wait_for_sync():
if not loop.is_closed():
await loop.synchronize()
# There are not actually any unshielded checkpoints in
# either of the following async functions, so the
# shield doesn't do much. However, it is necessary to
# make sure that start() actually moves the _main_loop
# task into the tasks_nursery if this call to
# open_loop() is cancelled. TaskStatus.started()
# doesn't complete Nursery.start() if there's a
# cancellation pending, because it figures the task
# will be cancelled soon enough and doesn't want to
# worry about Cancelled exceptions propagating to the
# wrong place; but _main_loop shields everything it does
# after started(), so this just results in start() never
# completing. With the shield here, started() can't see
# the outer cancellation, which avoids the deadlock.
with trio.CancelScope(shield=True):
await loop._main_loop_init(tasks_nursery)
await loop_nursery.start(loop._main_loop)

try:
yield loop
finally:
# Allow all already-submitted tasks a chance to start
# (and then immediately be cancelled), unless the loop
# stops (due to someone else calling stop()) before
# that.
async with trio.open_nursery() as sync_nursery:
sync_nursery.cancel_scope.shield = True

@sync_nursery.start_soon
async def wait_for_sync():
if not loop.is_closed():
await loop.synchronize()
sync_nursery.cancel_scope.cancel()

await loop.wait_stopped()
sync_nursery.cancel_scope.cancel()

await loop.wait_stopped()
sync_nursery.cancel_scope.cancel()

# Cancel and wait on all currently-running tasks.
# Exiting the tasks_nursery will wait for the Trio tasks
# automatically; we mix in the asyncio tasks by scheduling
# a call to run_aio_future() for each one. It's important
# not to wait on one kind of task before the other, so that
# we support Trio tasks that need to run some asyncio
# code during teardown as well as the opposite.
# Like asyncio.run(), we don't bother cancelling and waiting
# on any additional asyncio tasks that these tasks start as they
# unwind.
aio_tasks = asyncio.all_tasks(loop)
for task in aio_tasks:
tasks_nursery.start_soon(run_aio_future, task)
tasks_nursery.cancel_scope.cancel()
# Cancel and wait on all currently-running tasks.
# Exiting the tasks_nursery will wait for the Trio tasks
# automatically; we mix in the asyncio tasks by scheduling
# a call to run_aio_future() for each one. It's important
# not to wait on one kind of task before the other, so that
# we support Trio tasks that need to run some asyncio
# code during teardown as well as the opposite.
# Like asyncio.run(), we don't bother cancelling and waiting
# on any additional asyncio tasks that these tasks start
# as they unwind.
aio_tasks = asyncio.all_tasks(loop)
for task in aio_tasks:
tasks_nursery.start_soon(run_aio_future, task)
tasks_nursery.cancel_scope.cancel()
finally:
try:
await loop._main_loop_exit()
Expand Down