Skip to content

PTY teardown can be abandoned by cancellation after registry removal #4747

Description

@fscfede-beep

Please read this first

  • Have you searched for related issues? Yes. I did not find an issue covering PTY entries that are removed from the registry before cancellation-settled backend teardown.
  • Scope: this reproduces on current main at 89c02c828ee8510fe9a84ee6675608193aa13b02. It is pre-existing and is not being reported as a regression from PR fix: keep a utf-8 character that straddles two pty collection windows #4745.

Describe the bug

The PTY finalizers remove a finished entry from the session registry and then await backend teardown:

async with self._pty_lock:
    removed = self._pty_processes.pop(process_id, None)
    self._reserved_pty_process_ids.discard(process_id)

if removed is not None:
    await self._terminate_pty_entry(removed)

Once the pop commits, pty_terminate_all() can no longer reach that entry. If the caller is cancelled while _terminate_pty_entry() is suspended, teardown is cancelled too and there is no registry owner left to retry it.

Blaxel is the clearest concrete resource leak: its terminate path awaits cleanup that closes the PTY/WebSocket-side resources and the aiohttp.ClientSession. Other backends also have awaitful teardown paths, although I would not claim identical runtime exposure for every provider without provider-specific integration tests.

Why this matters

Registry removal transfers ownership of cleanup away from the session map. After that point, caller cancellation should be allowed to cancel delivery of the result, but should not be able to abandon resource teardown.

The repository already has the stronger ownership pattern in sandbox/session/mount_lifecycle.py::_settle_mount_transition: create and own the work task, await a separate completion task under asyncio.shield, keep waiting across repeated caller cancellations, consume the work result, and only then propagate the caller's cancellation state.

Deterministic regression shape

This does not require a real provider. A unit test can monkeypatch termination so it blocks after starting:

terminated = []
terminate_started = asyncio.Event()
release_terminate = asyncio.Event()

async def blocked_terminate(target):
    terminate_started.set()
    await release_terminate.wait()
    terminated.append(target)

session._terminate_pty_entry = blocked_terminate

# register a finished entry, then:
task = asyncio.create_task(
    session._finalize_pty_update(
        process_id=1,
        entry=entry,
        output=b"",
        original_token_count=None,
    )
)

await terminate_started.wait()

# The registry removal has already committed here.
assert 1 not in session._pty_processes

task.cancel()
with contextlib.suppress(asyncio.CancelledError):
    await task

release_terminate.set()
await asyncio.sleep(0)

assert terminated == [entry]

On the current ownership shape, the final assertion fails: cancellation propagates into the awaited terminate coroutine after the entry has already become unreachable from the registry.

A dependency-free control-flow reproduction of the same transaction gives:

  • registry removed: yes
  • cancellation during teardown: yes
  • cleanup completed: no
  • registry can recover entry: no

A settled-owner version modeled after _settle_mount_transition survives repeated cancellation and propagates cancellation only after cleanup completes.

Expected behavior

Once PTY registry removal commits, backend teardown should have an owner that survives caller cancellation until cleanup reaches a terminal state.

A narrow implementation could factor a PTY cleanup helper analogous to _settle_mount_transition rather than sprinkling bare asyncio.shield() calls that leave background task ownership ambiguous.

Relationship to PR #4745

During review of #4745 I initially treated a repeated-cancellation version of this as part of that PR's new final-tail path. Comparing against the exact PR base showed that this post-pop terminate-await ownership boundary already exists on main, so I corrected the review and withdrew it as a blocker on that PR.

PR #4745 has a separate, PR-specific single-cancellation regression around its newly-added tail-drain await; that is distinct from this pre-existing lifecycle-hardening issue.

Debug information

  • Repository: openai/openai-agents-python
  • Main SHA: 89c02c828ee8510fe9a84ee6675608193aa13b02
  • Provider/model: n/a for the deterministic ownership repro
  • Network/provider integration execution for this report: not required / not claimed

Second manifestation: pty_terminate_all() clears ownership before batch cleanup

The same ownership problem exists in every current pty_terminate_all() implementation. All seven backends copy the entries, clear the registry, and then await teardown sequentially:

async with self._pty_lock:
    entries = list(registry.values())
    registry.clear()
    reserved_ids.clear()

for entry in entries:
    await self._terminate_pty_entry(entry)

If caller cancellation lands while cleaning the first entry, the current cleanup is cancelled and every later entry is already unreachable from the registry, so their cleanup never starts.

A deterministic two-entry control-flow reproduction gives:

  • registry cleared: yes
  • cancellation during cleanup of A: yes
  • cleanup completed: none
  • cleanup of B started: no

A settled-owner batch preserving the current sequential order cleans A and B first, then re-propagates the caller's cancellation.

This means the shared helper should own an arbitrary cleanup operation, so finalizers can settle one terminate call and pty_terminate_all() can settle a sequential cleanup_all() coroutine.

Backend scope verified on main

At 89c02c828ee8510fe9a84ee6675608193aa13b02:

  • Unix local: non-TTY finished finalization has awaitful teardown; TTY schedules fd close and returns without the same post-pop await.
  • Docker: awaitful teardown.
  • Blaxel: awaitful reader/WebSocket/aiohttp.ClientSession teardown; clearest concrete resource leak.
  • Cloudflare: awaitful WebSocket/pump teardown.
  • Daytona: awaitful provider session + worker teardown.
  • E2B: a finished entry with an exit code returns immediately from _terminate_pty_entry; do not claim that finished-finalizer path is exposed by this exact mechanism.
  • Modal: awaitful process/read-task teardown.

pty_terminate_all() is broader: because it can include running entries, all seven batch implementations need cancellation-settled ownership after the registry is cleared.

Candidate implementation contract

A narrow helper can mirror the repository's existing mount_lifecycle._settle_mount_transition pattern:

async def settle_pty_cleanup(operation):
    task = asyncio.create_task(operation, name="agents.pty_cleanup")
    completion = asyncio.create_task(asyncio.wait((task,)))
    caller_cancelled = False

    while not completion.done():
        try:
            await asyncio.shield(completion)
        except asyncio.CancelledError:
            caller_cancelled = True

    completion.result()
    task.result()

    if caller_cancelled:
        raise asyncio.CancelledError()

Local reference checks cover normal completion, single cancellation, repeated cancellation, cleanup-exception preservation, and sequential batch cleanup under cancellation: 5/5 PASS.

This remains an implementation proposal; no upstream patch or provider integration execution is claimed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions