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.