Skip to content

Commit 0265af1

Browse files
author
Huan Nguyen
committed
bugfix: fix cancelled tasks pending forever
When an AsyncHandle is cancelled, _main_loop_one drops it at `if obj._cancelled: return` without running `_run`, leaving the result_future pending forever. The fix is to always run `AsyncHandle._run`.
1 parent 0856870 commit 0265af1

4 files changed

Lines changed: 74 additions & 5 deletions

File tree

newsfragments/161.bugfix.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
When an ``AsyncHandle`` is cancelled before the ``_main_loop`` is able to
2+
dequeue it (often due to heavy CPU load), ``trio-asyncio`` currently leaves
3+
the underlying ``asyncio.Future`` hanging in the ``PENDING`` state forever,
4+
preventing the cancellation to travel upwards back into the trio loop,
5+
leading to us missing task cancellation/completion. Specifically,
6+
``_main_loop_one`` greedily drops it at ``if obj._cancelled: return``
7+
without running ``_run``, leaving the ``result_future`` pending forever.
8+
The fix is to always run ``AsyncHandle._run``.

tests/test_trio_asyncio.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import sys
33
import types
44
import asyncio
5+
import threading
56
import trio
67
import trio.testing
78
import trio_asyncio
@@ -307,3 +308,62 @@ async def iterate_one(label, extra=""):
307308
}
308309
finally:
309310
sys.unraisablehook = prev_hook
311+
312+
313+
def test_async_handle_cancelled_before_dequeue():
314+
"""
315+
Regression test for the wedge where an AsyncHandle queued onto _q_send
316+
and then cancelled before _main_loop_one dequeues it would never resolve
317+
its result_future.
318+
319+
Run trio.run in a daemon thread with a hard 5s join timeout: when the bug
320+
is present, both run_aio_future and the open_loop shutdown wedge on the
321+
dropped AsyncHandle, so an in-trio timeout cannot recover the test.
322+
"""
323+
324+
async def trio_main():
325+
async with trio_asyncio.open_loop() as loop:
326+
# Define a trio function for the AsyncHandle to wrap.
327+
async def trio_proc():
328+
return 42
329+
330+
# Queue an AsyncHandle on _q_send. trio_as_future is sync — it
331+
# creates the AsyncHandle, calls _queue_handle (= _q_send.send_nowait),
332+
# and returns the asyncio.Future immediately. _main_loop hasn't
333+
# had a chance to run yet because we haven't yielded.
334+
f = loop.trio_as_future(trio_proc)
335+
assert not f.done()
336+
337+
# Cancel the future BEFORE yielding. wrapped_cancel marks the
338+
# AsyncHandle._cancelled = True but leaves f PENDING.
339+
assert f.cancel() is True
340+
assert not f.done() # the bug is here: f is PENDING despite cancel
341+
342+
# Now yield. _main_loop dequeues the cancelled handle. Without
343+
# the fix, _main_loop_one drops it at `if obj._cancelled: return`
344+
# and f stays PENDING forever. With the fix, _run is called and
345+
# marks f cancelled.
346+
try:
347+
await loop.run_aio_future(f)
348+
except asyncio.CancelledError:
349+
pass
350+
351+
assert f.done()
352+
assert f.cancelled()
353+
354+
error = []
355+
356+
def runner():
357+
try:
358+
trio.run(trio_main)
359+
except BaseException as e:
360+
error.append(e)
361+
362+
thread = threading.Thread(target=runner, daemon=True)
363+
thread.start()
364+
thread.join(timeout=5.0)
365+
366+
if thread.is_alive():
367+
pytest.fail("AsyncHandle.result_future was never resolved within 5s")
368+
if error:
369+
raise error[0]

trio_asyncio/_base.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -763,18 +763,14 @@ async def _main_loop_one(self, no_wait=False):
763763
# Hopefully one of ours
764764
# but it might be a standard asyncio handle
765765

766-
if obj._cancelled:
767-
# simply skip cancelled handlers
768-
return
769-
770766
# Don't go through the expensive nursery dance
771767
# if this is a sync function.
772768
if isinstance(obj, AsyncHandle):
773769
# AsyncHandle is only used to run Trio tasks, so no need to set the
774770
# sniffio library
775771
obj._context.run(self._nursery.start_soon, obj._run, name=obj._callback)
776772
await obj._started.wait()
777-
else:
773+
elif not obj._cancelled:
778774
prev_library, sniffio_library.name = sniffio_library.name, "asyncio"
779775
try:
780776
obj._run()

trio_asyncio/_handles.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,13 @@ def wrapped_cancel(msg=None):
161161

162162
async def _run(self):
163163
self._started.set()
164+
164165
if self._cancelled:
166+
# asyncio.Handle.cancel() nulls out _callback and _args, so we
167+
# cannot run the callback. We however must resolve the result_future as cancelled
165168
self._finished = True
169+
if self._fut is not None and not self._fut.done():
170+
self._fut.cancel()
166171
return
167172

168173
try:

0 commit comments

Comments
 (0)