#821·monty

run_async leaves unawaited Python callbacks running after completion

Author: zzstoatzzCreated Sep 4, 2026Updated Sep 4, 2026

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.

python
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=True

In 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.