run_async leaves unawaited Python callbacks running after completion
Filed by Codex on behalf of @zzstoatzz while investigating PrefectHQ/fastmcp#5004.
With pydantic-monty 0.0.18 on Python 3.12.13 (macOS arm64), Monty.run_async() returns while an unawaited external callback remains suspended. Its async cleanup has not run. Without the diagnostic task reference below, we also observed abandoned-coroutine warnings during later garbage collection in FastMCP.
I expected unfinished callbacks belonging to the completed run to be cancelled and joined. This reproducer uses only Monty and asyncio; it retains the task so the outstanding work is observable and explicitly cleans it up afterward.
import asyncio
from pydantic_monty import Monty
async def main():
started = asyncio.Event()
cleaned_up = asyncio.Event()
callback_tasks = []
async def background():
# Retain the task in this diagnostic so GC cannot hide the lifecycle bug.
callback_tasks.append(asyncio.current_task())
try:
started.set()
await asyncio.Event().wait()
finally:
await asyncio.sleep(0)
cleaned_up.set()
async def wait_until_started():
await started.wait()
await Monty('background()\nawait wait_until_started()').run_async(
external_functions={
'background': background,
'wait_until_started': wait_until_started,
}
)
await asyncio.sleep(0)
print(f'After sandbox exit: task done={callback_tasks[0].done()}, cleanup done={cleaned_up.is_set()}')
# Test-only cleanup; this is not a proposed library workaround.
for task in callback_tasks:
task.cancel()
await asyncio.gather(*callback_tasks, return_exceptions=True)
print(f'After explicit cancellation and join: cleanup done={cleaned_up.is_set()}')
asyncio.run(main())Output:
After sandbox exit: task done=False, cleanup done=False
After explicit cancellation and join: cleanup done=TrueIn 0.0.18, dispatch_loop_run returns on RunProgress::Complete and drops its Rust JoinSet; the into_future bridge does not appear to cancel and join the corresponding Python tasks when its Rust waiter is dropped.
Source: pydantic/monty