Skip to content

Realtime cleanup should await cancelled background tasks #3334

@Aphroq

Description

@Aphroq

Please read this first

  • Have you read the docs? Yes. This concerns Realtime session cleanup behavior rather than a documented usage option.
  • Have you searched for related issues? Yes. I searched upstream issues and PRs. The closest prior work I found is Fix: Properly await cancelled guardrail tasks during cleanup #1976, which covered guardrail cleanup only and was closed without merge.

Describe the bug

RealtimeSession._cleanup() cancels pending Realtime guardrail and tool-call background tasks, but the current cleanup helpers clear the tracking sets immediately after task.cancel(). They do not await the cancelled tasks with asyncio.gather(..., return_exceptions=True) or an equivalent exception-collecting wait.

Impact:

  • _cleanup() can return before cancelled task finally blocks have run.
  • Cancellation exceptions or task callbacks can be processed after the session has already moved on to model close.
  • Guardrail and tool-call task cleanup behaves differently from the usual asyncio cancel-and-await cleanup pattern.

Relevant edge cases considered:

  • Guardrail background tasks and tool-call background tasks should both be awaited during cleanup.
  • Cancelled task finally blocks should complete before _cleanup() returns.
  • Cancellation exceptions should be collected with return_exceptions=True.
  • Tracking sets should be cleared only after the cancelled tasks have been awaited.

Debug information

  • Agents SDK version: upstream main at 479640e
  • Python version: Python 3.12.1

Repro steps

Run this minimal script from a repository checkout.

import asyncio
from typing import Any

from agents.realtime.agent import RealtimeAgent
from agents.realtime.model import RealtimeModel
from agents.realtime.session import RealtimeSession


class FakeRealtimeModel(RealtimeModel):
    def add_listener(self, listener: Any) -> None:
        pass

    def remove_listener(self, listener: Any) -> None:
        pass

    async def connect(self, options: Any) -> None:
        pass

    async def send_event(self, event: Any) -> None:
        pass

    async def close(self) -> None:
        pass


async def main() -> None:
    session = RealtimeSession(FakeRealtimeModel(), RealtimeAgent(name="agent"), None)
    guardrail_started = asyncio.Event()
    guardrail_finished = asyncio.Event()
    tool_started = asyncio.Event()
    tool_finished = asyncio.Event()

    async def guardrail_task() -> None:
        guardrail_started.set()
        try:
            await asyncio.Event().wait()
        finally:
            await asyncio.sleep(0)
            guardrail_finished.set()

    async def tool_call_task() -> None:
        tool_started.set()
        try:
            await asyncio.Event().wait()
        finally:
            await asyncio.sleep(0)
            tool_finished.set()

    guardrail = asyncio.create_task(guardrail_task())
    tool_call = asyncio.create_task(tool_call_task())
    session._guardrail_tasks.add(guardrail)
    session._tool_call_tasks.add(tool_call)

    await guardrail_started.wait()
    await tool_started.wait()

    await session._cleanup()

    print(f"guardrail_done_after_cleanup: {guardrail.done()}")
    print(f"tool_done_after_cleanup: {tool_call.done()}")
    print(f"guardrail_finally_ran: {guardrail_finished.is_set()}")
    print(f"tool_finally_ran: {tool_finished.is_set()}")
    print(f"tracked_guardrail_tasks: {len(session._guardrail_tasks)}")
    print(f"tracked_tool_call_tasks: {len(session._tool_call_tasks)}")


asyncio.run(main())

Actual result on upstream main at 479640e:

guardrail_done_after_cleanup: False
tool_done_after_cleanup: False
guardrail_finally_ran: False
tool_finally_ran: False
tracked_guardrail_tasks: 0
tracked_tool_call_tasks: 0

Expected behavior

RealtimeSession._cleanup() should cancel pending guardrail and tool-call background tasks, await the tracked tasks while collecting cancellation exceptions, and only then clear the tracking sets and continue shutdown.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions