Skip to content

fix(sandbox): clean up PTY startup cancellation - #4750

Open
Hughhhhcoder wants to merge 5 commits into
openai:mainfrom
Hughhhhcoder:codex/openai-agents-pty-startup-cancel
Open

fix(sandbox): clean up PTY startup cancellation#4750
Hughhhhcoder wants to merge 5 commits into
openai:mainfrom
Hughhhhcoder:codex/openai-agents-pty-startup-cancel

Conversation

@Hughhhhcoder

Copy link
Copy Markdown
Contributor

Summary

Fixes the PTY startup ownership boundary described in #4749. UnixLocal and Blaxel can create processes/provider resources before registering their entries; caller cancellation in that window previously bypassed cleanup and left the resources unreachable from the session registries.

The change cleanup-settles unregistered PTY entries before re-raising cancellation, and makes the UnixLocal TTY descriptor cleanup cover cancellation during subprocess creation.

Test plan

  • Added deterministic cancellation regressions for UnixLocal process startup, UnixLocal TTY descriptor cleanup, and Blaxel HTTP session cleanup.
  • The new regressions failed on the parent commit and pass with this change.
  • pytest -q tests/sandbox/test_unix_local.py tests/extensions/sandbox/test_blaxel.py — 264 passed, 2 skipped
  • ruff check on all changed source and test files — passed
  • ruff format --check on all changed source and test files — passed
  • mypy on all changed source files — passed
  • pyright on all changed source and test files — passed
  • git diff --check — passed

The repository verification wrapper was also attempted. In this local environment it stops before lint/typecheck/tests because the installed uv cannot parse the repository's exclude-newer = "7 days" setting and, in locked mode, requests a lockfile rewrite. The lockfile was not changed. Running the equivalent checks directly with the existing virtual environment produced the results above; the full repository run also exposed unrelated baseline optional-dependency/type-check failures outside this change.

Issue number

Closes #4749

Checks

  • I've added new tests, if relevant
  • I've run .agents/skills/code-change-verification/scripts/run.sh
  • I've confirmed all verification steps pass
  • If using Codex, I've run /review before submitting this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-08-29T07:55:07.610785Z cbaa66e Manual request
🔒 Security Review Completed 2026-08-29T07:55:43.120717Z cbaa66e Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 26ffd7de91

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agents/sandbox/session/pty_types.py Outdated
Comment on lines +27 to +28
except asyncio.CancelledError:
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve cancellation after settling cleanup

When a Blaxel startup timeout or transport failure has entered a slow cleanup and the caller then cancels pty_exec_start(), asyncio.shield() raises CancelledError here, but the loop discards it; after cleanup, the caller raises the preexisting ExecTimeoutError or ExecTransportError, leaving a task with a nonzero cancellation count that is not cancelled. Remember cancellation observed while settling and re-raise it after cleanup so shutdown and structured-concurrency callers receive the requested cancellation.

AGENTS.md reference: AGENTS.md:L150-L150

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 5cd36bf and 2720f1a. _settle_pty_cleanup now records the first caller cancellation while cleanup is shielded and re-raises it after cleanup completes, including when cleanup itself fails. Added test_pty_exec_start_preserves_cancellation_during_cleanup, which verifies the caller receives CancelledError and the task is cancelled. The focused sandbox suite passes: 265 passed, 2 skipped; Ruff, mypy, and pyright also pass.

@Hughhhhcoder

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 2720f1a5a1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

Security review completed. No security issues were found in this pull request.

Reviewed commit: 2720f1a5a1

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@fscfede-beep fscfede-beep left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current head fixes the reported startup ownership bug by inspection and the Codex P2 is addressed. One regression boundary is still worth pinning.


class TestPtyExec:
@pytest.mark.asyncio
async def test_pty_exec_start_cancellation_closes_unregistered_http_session(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage gap, not a runtime blocker: this cancels inside ws_connect, before the WebSocket and reader task are owned. #4749 also covers cancellation after connect while command send / registry transfer is still pending. A deterministic fake can let ws_connect return, start a blocking reader, block send_str, cancel there, then assert reader cancellation + WebSocket close + HTTP-session close + empty registry. That pins the stronger pre-registration resource state and guards against a future narrowing of the cleanup handler.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed this is a useful coverage enhancement and not a runtime blocker. I kept it out of this focused fix because the reported issue is the cleanup exception/cancellation precedence; the existing related scenarios cover the other ownership timing. I can add the broader pre-registration send-path coverage separately if maintainers want it.

@fscfede-beep fscfede-beep left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found one remaining cancellation-precedence defect in the current helper.

Comment thread src/agents/sandbox/session/pty_types.py Outdated
cancellation: asyncio.CancelledError | None = None
while not cleanup_task.done():
try:
await asyncio.shield(cleanup_task)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cancellation is still lost if cleanup itself fails after the caller cancellation was caught. Deterministic ordering: cleanup sets started then waits; caller cancels pty_exec_start; this loop catches CancelledError, records it, and re-enters await shield(cleanup_task); release cleanup and have it raise RuntimeError. That second shield() propagates the cleanup RuntimeError directly, so execution never reaches the cleanup_task.result() block below that is supposed to give the recorded cancellation priority. Result: caller sees RuntimeError, not CancelledError. A separate completion waiter (the pattern used by mount_lifecycle._settle_mount_transition, and also in #4751) avoids letting the cleanup task's exception escape the shield wait; alternatively catch inner-task exceptions here and defer them to cleanup_task.result(). Please add a regression with caller cancellation followed by a failing cleanup.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ran the exact helper control-flow locally with caller cancellation delivered while cleanup was blocked, then released cleanup with RuntimeError. Result:

current_p4750_outcome=RuntimeError
current_p4750_cleanup_terminal=True
reference_outcome=CancelledError
reference_cleanup_terminal=True
V51_P4750_FAILURE_AFTER_CANCEL_REPRO=PASS

The reference differs only by shielding a separate asyncio.wait((cleanup_task,)) completion waiter, so the inner cleanup exception is inspected after settlement instead of escaping the second shield() await. This confirms the remaining issue is exception/cancellation precedence, not cleanup abandonment.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9a94bd7. _settle_pty_cleanup now waits on a separate completion task created from asyncio.wait((cleanup_task,)), instead of shielding the cleanup task directly. This prevents a cleanup exception from escaping the post-cancellation wait; after settlement, cleanup_task.result() is inspected and the caller's cancellation takes priority.

Added test_pty_exec_start_preserves_cancellation_when_cleanup_fails, which cancels the caller while close() is blocked and then makes cleanup raise RuntimeError. The caller now receives CancelledError. The relevant Blaxel and UnixLocal test files pass with 260 passed and 8 skipped; Ruff, formatting, and Pyright also pass.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reported reproduction was valid and is fixed in 9a94bd7. The separate completion waiter prevents the cleanup task's RuntimeError from escaping the shield, so the original caller cancellation is preserved. The new cleanup-failure regression covers this exact ordering.

@Hughhhhcoder

Copy link
Copy Markdown
Contributor Author

@codex review

@Hughhhhcoder

Copy link
Copy Markdown
Contributor Author

@codex security review

@fscfede-beep fscfede-beep left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The completion-waiter fix resolves the failure-after-cancel interleaving from my previous review. One smaller cancellation-propagation defect remains on the new head.

Comment thread src/agents/sandbox/session/pty_types.py Outdated
cleanup_task.result()
except BaseException:
if caller_cancelled:
raise asyncio.CancelledError() from None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

task.cancel("reason") loses its cancellation payload here because the caught CancelledError is reduced to a boolean and a fresh empty CancelledError() is raised after cleanup. Deterministic repro on this exact helper: cancel with "route-A" while cleanup is blocked, let cleanup complete, then inspect the awaited exception; current exc.args == (), while retaining and re-raising the first caught exception gives ("route-A",). This is the same propagation invariant Codex just flagged on #4751. Please retain the first CancelledError object (or its args) and re-raise it after settlement, including the cleanup-failure branch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cbaa66e.

_settle_pty_cleanup() now retains the first CancelledError object instead of reducing cancellation to a boolean, so task.cancel("route-A") keeps its payload. The retained cancellation is re-raised after cleanup settlement, including when cleanup fails, so a secondary cleanup exception cannot replace the caller cancellation.

Added regressions for both cancellation-during-cleanup and cleanup-failure paths. The affected tests pass, along with Ruff and Pyright.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9a94bd7b23

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +34 to +38
cleanup_task.result()
except BaseException:
if caller_cancelled:
raise asyncio.CancelledError() from None
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the cancellation that initiated cleanup

When cancellation enters UnixLocal's new except BaseException while waiting for _pty_lock, caller_cancelled is initialized only after that cancellation has already been caught. If _terminate_pty_entry() then fails—for example, its unsuppressed os.killpg() raises PermissionErrorcleanup_task.result() propagates the cleanup failure, the outer bare raise is never reached, and the task finishes non-cancelled despite retaining a cancellation count. Fresh evidence beyond the resolved thread is this initial-cancellation path: the added cleanup-failure test instead begins with TimeoutError and cancels only after cleanup starts. Preserve the original cancellation when entering the helper, including when cleanup fails.

AGENTS.md reference: AGENTS.md:L150-L150

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed with an exact control-flow repro for the initial-cancellation path. Cancel the startup task with "startup-cancel", enter the current helper from except CancelledError, let cleanup block and then raise PermissionError. Current result is ('PermissionError', ('killpg denied',), cancelling=1, cancelled=False); a reference that seeds the helper with the original caught cancellation returns CancelledError('startup-cancel') and cancelled=True. So the missing state is the cancellation that existed before helper entry. A narrow repair is to pass the caught CancelledError into the settlement helper (while still retaining any later cancellation), and give that stored cancellation priority after cleanup settlement/failure.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reconciled this P2 with the payload-loss finding in 3885911655; they have the same missing state. The narrow fix is to let _settle_pty_cleanup() accept the already-caught CancelledError as initial_cancellation, retain the first actual cancellation object (not a bool), and re-raise that object after cleanup settlement/failure. Blaxel should pass the exception from its explicit except asyncio.CancelledError as e; UnixLocal should split CancelledError from the broader BaseException handler so it can do the same. This simultaneously preserves task.cancel("reason") args and prevents an unsuppressed cleanup failure (e.g. killpg PermissionError) from replacing the cancellation that initiated cleanup. I exercised both interleavings dependency-free and reviewed the candidate helper independently; no new issue was found. A regression for UnixLocal can hold _pty_lock, cancel with "startup-cancel", make killpg raise PermissionError, and assert the awaited task still raises CancelledError("startup-cancel") and is marked cancelled.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in cbaa66e.

UnixLocal now handles CancelledError separately from the broader BaseException path and passes the already-caught cancellation into _settle_pty_cleanup(). Blaxel does the same in its explicit cancellation handler. This preserves an initial task.cancel("startup-cancel") even if cleanup later fails.

Added a UnixLocal caller-boundary regression that holds the PTY lock, cancels with startup-cancel, makes killpg() raise PermissionError, and verifies the awaited task still raises CancelledError("startup-cancel"). The affected tests pass: 266 passed, 8 skipped; Ruff and Pyright are clean.

@chatgpt-codex-connector

Copy link
Copy Markdown

Security review completed. No security issues were found in this pull request.

Reviewed commit: 9a94bd7b23

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@Hughhhhcoder

Copy link
Copy Markdown
Contributor Author

@codex review

@Hughhhhcoder

Copy link
Copy Markdown
Contributor Author

@codex security review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: cbaa66e2de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

Security review completed. No security issues were found in this pull request.

Reviewed commit: cbaa66e2de

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PTY startup cancellation can leak unregistered UnixLocal and Blaxel resources

2 participants