python-trio / trio-asyncio

a re-implementation of the asyncio mainloop on top of Trio

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

When used with `tricycle.BackgroundObject`, parent scope cancellations leak through prematurely to `trio-asyncio`-managed asyncio tasks.

mikenerone opened this issue · comments

python 3.12.1 + trio 0.24.0 + trio-asyncio 0.13.0 + tricycle 0.4.0

When used with tricycle.BackgroundObject, parent scope cancellations leak through prematurely to trio-asyncio-managed asyncio tasks. I personally see this as a trio-asyncio bug, because in principal its adapter should transparently handle any differences so that tricycle.BackgroundObject isn't affected, but since you're the maintainer of both (at least it seems so), your opinion on that matters more than mine. :)

This repro script illustrates the problem in detail:

import asyncio

import trio
import trio_asyncio
from tricycle import BackgroundObject


class AvailabilityBob(BackgroundObject):
    trio_resource_available = False
    aio_resource_available = False

    async def __open__(self) -> None:
        print(
            'What this "AvailabilityBob" object is doing is setting up two tasks in its service nursery that\n'
            "represent hypothetical maintainers of some resource that should remain available until the object's\n"
            "context has been exited (this is what service nurseries are supposed to do for us). The only\n"
            "difference between these two maintainer functions is that one is Trio-native and the other is\n"
            "trio-asyncio-wrapped asyncio. The availability of the hypothetical resources is simulated by\n"
            "maintaining two boolean attributes on the object. If both trio-asyncio and tricycle.BackgroundObject\n"
            "are working correctly, their behavior should be the same.\n"
        )
        await self.nursery.start(self.trio_maintain_availability)
        await self.nursery.start(self.aio_maintain_availability)

    async def trio_maintain_availability(self, *, task_status: trio.TaskStatus[None] = trio.TASK_STATUS_IGNORED) -> None:
        try:
            self.trio_resource_available = True
            task_status.started()
            await trio.sleep(float("inf"))
        except* trio.Cancelled:
            print("Trio maintainer cancelled!\n")
            raise
        finally:
            self.trio_resource_available = False

    @trio_asyncio.aio_as_trio
    async def aio_maintain_availability(self, *, task_status: trio.TaskStatus[None] = trio.TASK_STATUS_IGNORED) -> None:
        try:
            self.aio_resource_available = True
            task_status.started()
            await asyncio.sleep(float("inf"))
        except asyncio.CancelledError:
            print("AsyncIO maintainer cancelled!\n")
            raise
        finally:
            self.aio_resource_available = False


async def main() -> None:
    with trio.CancelScope() as cancel_scope:
        async with trio_asyncio.open_loop(), AvailabilityBob() as bob:
            print(f"Entered bob's context: {bob.trio_resource_available=} {bob.aio_resource_available=}\n")
            try:
                await trio.sleep(0)
                cancel_scope.cancel()
                print("Cancelled bob's cancel scope.\n")
                await trio.sleep(0)
            finally:
                with trio.CancelScope() as err_cancel_scope:
                    err_cancel_scope.shield = True
                    print(
                        "Entering the `finally` within bob's context, the asyncio maintainer has already been\n"
                        "cancelled, and usually already actually exited by this point:\n"
                        f"{bob.trio_resource_available=} {bob.aio_resource_available=}\n"
                    )
                    await trio.sleep(0)
                    print(
                        "...but if it survived to that point, then certainly after a checkpoint, the asyncio\n"
                        "maintainer is gone, while the trio-native one continues to live (as both should):\n"
                        f"{bob.trio_resource_available=} {bob.aio_resource_available=}\n"
                    )


trio.run(main, restrict_keyboard_interrupt_to_checkpoints=True, strict_exception_groups=True)

Incidentally, you may recall that we recently saw a similar behavior with asyncio async generators in the same context. I thought I'd mention it because it could point toward a common path where a holistic fix might be possible.