Skip to content

Commit 2440c92

Browse files
authored
Merge pull request #76 from oremanj/cleanup-handles
2 parents fb4772d + 047d912 commit 2440c92

File tree

6 files changed

+285
-218
lines changed

6 files changed

+285
-218
lines changed

newsfragments/76.bugfix.rst

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
On Python versions with native contextvars support (3.7+), a Trio task
2+
started from asyncio context (using :func:`trio_as_aio`,
3+
:meth:`~BaseTrioEventLoop.trio_as_future`, etc) will now properly
4+
inherit the contextvars of its caller. Also, if the entire
5+
trio-asyncio loop is cancelled, such tasks will no longer let
6+
`trio.Cancelled` exceptions leak into their asyncio caller.

tests/test_misc.py

+135
Original file line numberDiff line numberDiff line change
@@ -351,3 +351,138 @@ async def run_asyncio_loop(nursery, *, task_status=trio.TASK_STATUS_IGNORED):
351351
await nursery.start(run_asyncio_loop, nursery)
352352
# Trigger KeyboardInterrupt that should propagate accross the coroutines
353353
signal.pthread_kill(threading.get_ident(), signal.SIGINT)
354+
355+
356+
@pytest.mark.trio
357+
@pytest.mark.parametrize("throw_another", [False, True])
358+
async def test_cancel_loop(throw_another):
359+
"""Regression test for #76: ensure that cancelling a trio-asyncio loop
360+
does not cause any of the tasks running within it to yield a
361+
result of Cancelled.
362+
"""
363+
async def manage_loop(task_status):
364+
try:
365+
with trio.CancelScope() as scope:
366+
async with trio_asyncio.open_loop() as loop:
367+
task_status.started((loop, scope))
368+
await trio.sleep_forever()
369+
finally:
370+
assert scope.cancelled_caught
371+
372+
# Trio-flavored async function. Runs as a trio-aio loop task
373+
# and gets cancelled when the loop does.
374+
async def trio_task():
375+
async with trio.open_nursery() as nursery:
376+
nursery.start_soon(trio.sleep_forever)
377+
try:
378+
await trio.sleep_forever()
379+
except trio.Cancelled:
380+
if throw_another:
381+
# This will combine with the Cancelled from the
382+
# background sleep_forever task to create a
383+
# MultiError escaping from trio_task
384+
raise ValueError("hi")
385+
386+
async with trio.open_nursery() as nursery:
387+
loop, scope = await nursery.start(manage_loop)
388+
fut = loop.trio_as_future(trio_task)
389+
await trio.testing.wait_all_tasks_blocked()
390+
scope.cancel()
391+
assert fut.done()
392+
if throw_another:
393+
with pytest.raises(ValueError, match="hi"):
394+
fut.result()
395+
else:
396+
assert fut.cancelled()
397+
398+
399+
@pytest.mark.trio
400+
async def test_trio_as_fut_throws_after_cancelled():
401+
"""If a trio_as_future() future is cancelled, any exception
402+
thrown by the Trio task as it unwinds is ignored. (This is
403+
somewhat infelicitous, but the asyncio Future API doesn't allow
404+
a future to go from cancelled to some other outcome.)
405+
"""
406+
407+
async def trio_task():
408+
try:
409+
await trio.sleep_forever()
410+
finally:
411+
raise ValueError("hi")
412+
413+
async with trio_asyncio.open_loop() as loop:
414+
fut = loop.trio_as_future(trio_task)
415+
await trio.testing.wait_all_tasks_blocked()
416+
fut.cancel()
417+
with pytest.raises(asyncio.CancelledError):
418+
await fut
419+
420+
421+
@pytest.mark.trio
422+
async def test_run_trio_task_errors(monkeypatch):
423+
async with trio_asyncio.open_loop() as loop:
424+
# Test never getting to start the task
425+
handle = loop.run_trio_task(trio.sleep_forever)
426+
handle.cancel()
427+
428+
# Test cancelling the task
429+
handle = loop.run_trio_task(trio.sleep_forever)
430+
await trio.testing.wait_all_tasks_blocked()
431+
handle.cancel()
432+
433+
# Helper for the rest of this test, which covers cases where
434+
# the Trio task raises an exception
435+
async def raise_in_aio_loop(exc):
436+
async def raise_it():
437+
raise exc
438+
439+
async with trio_asyncio.open_loop() as loop:
440+
loop.run_trio_task(raise_it)
441+
442+
# We temporarily modify the default exception handler to collect
443+
# the exceptions instead of logging or raising them
444+
445+
exceptions = []
446+
447+
def collect_exceptions(loop, context):
448+
if context.get("exception"):
449+
exceptions.append(context["exception"])
450+
else:
451+
exceptions.append(RuntimeError(context.get("message") or "unknown"))
452+
453+
monkeypatch.setattr(
454+
trio_asyncio.TrioEventLoop, "default_exception_handler", collect_exceptions
455+
)
456+
expected = [
457+
ValueError("hi"), ValueError("lo"), KeyError(), IndexError()
458+
]
459+
await raise_in_aio_loop(expected[0])
460+
with pytest.raises(SystemExit):
461+
await raise_in_aio_loop(SystemExit(0))
462+
with pytest.raises(SystemExit):
463+
await raise_in_aio_loop(trio.MultiError([expected[1], SystemExit()]))
464+
await raise_in_aio_loop(trio.MultiError(expected[2:]))
465+
assert exceptions == expected
466+
467+
468+
@pytest.mark.trio
469+
@pytest.mark.skipif(sys.version_info < (3, 7), reason="needs asyncio contextvars")
470+
async def test_contextvars():
471+
import contextvars
472+
473+
cvar = contextvars.ContextVar("test_cvar")
474+
cvar.set("outer")
475+
476+
async def fudge_in_aio():
477+
assert cvar.get() == "outer"
478+
cvar.set("middle")
479+
await trio_asyncio.trio_as_aio(fudge_in_trio)()
480+
assert cvar.get() == "middle"
481+
482+
async def fudge_in_trio():
483+
assert cvar.get() == "middle"
484+
cvar.set("inner")
485+
486+
async with trio_asyncio.open_loop() as loop:
487+
await trio_asyncio.aio_as_trio(fudge_in_aio)()
488+
assert cvar.get() == "outer"

trio_asyncio/_async.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import trio
2+
import asyncio
23

34
from ._base import BaseTrioEventLoop
4-
from ._handles import Handle
55

66

77
class TrioEventLoop(BaseTrioEventLoop):
@@ -69,7 +69,7 @@ def stop_me():
6969
if self._stopped.is_set():
7070
waiter.set()
7171
else:
72-
self._queue_handle(Handle(stop_me, (), self, context=None, is_sync=True))
72+
self._queue_handle(asyncio.Handle(stop_me, (), self))
7373
return waiter
7474

7575
def _close(self):

0 commit comments

Comments
 (0)