python-trio / trio-asyncio

a re-implementation of the asyncio mainloop on top of Trio
Other
187 stars 37 forks source link

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

Closed mikenerone closed 5 months ago

mikenerone commented 6 months ago

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.

oremanj commented 5 months ago

This is because a function wrapped in aio_as_trio runs in a different task from Trio's perspective (the task that's managing the asyncio loop), but tricycle was assuming that TaskStatus.started() would always run from within the new task. I uploaded oremanj/tricycle#27 which removes that assumption and fixes this example.