Skip to content

fix(sandbox): settle PTY cleanup before cancellation - #4751

Open
mikemikimike wants to merge 12 commits into
openai:mainfrom
mikemikimike:fix/pty-cleanup-cancellation-4747
Open

fix(sandbox): settle PTY cleanup before cancellation#4751
mikemikimike wants to merge 12 commits into
openai:mainfrom
mikemikimike:fix/pty-cleanup-cancellation-4747

Conversation

@mikemikimike

@mikemikimike mikemikimike commented Aug 29, 2026

Copy link
Copy Markdown

Summary

PTY ownership is removed from the session registry before asynchronous teardown. If the caller is cancelled during that gap, cleanup can be abandoned. pty_terminate_all() also stopped at the first cleanup error, leaving later detached PTYs unreachable.

Changes

  • Add an internal BaseSandboxSession helper that owns asynchronous PTY cleanup, survives repeated caller cancellation until cleanup reaches a terminal state, preserves cleanup errors, and then re-propagates the first caller cancellation.
  • Use the helper for prune-after-pop, finalizer-after-pop, and registry-clear batch cleanup in all seven PTY backends.
  • Make batch cleanup attempt every entry and re-raise the first cleanup error after the batch.
  • Keep existing cleanup behavior for entries that fail before registration unchanged.
  • Add base-session regressions and provider-free Blaxel regressions for registry-cleared cancellation/resource settlement and first-entry cleanup failure.

Tests

  • uv run --frozen --no-sync pytest tests/sandbox/test_base_sandbox_session.py tests/extensions/sandbox/test_blaxel.py -q — 254 passed, 1 skipped.
  • uv run --frozen --no-sync ruff format --check on the 10 changed files — 10 files already formatted.
  • uv run --frozen --no-sync ruff check on the 10 changed files — passed.
  • uv run --frozen --no-sync mypy src — success for 308 source files.
  • uv run --frozen --no-sync pyright --project pyrightconfig.json --threads 4 — 0 errors, 0 warnings, 0 informations.
  • git diff --check — passed.

Compatibility / Known limitations

  • The helper is internal; no public API, provider ordering, or registry format changes are introduced.
  • The full non-serial suite was run locally. It reported 23 unrelated failures caused by Windows symlink privileges, a local MCP 502, workspace skill state, and example-file encoding; 8,332 tests passed and 75 were skipped.
  • GitHub Actions has not reported PR checks for this fork branch. The corresponding Tests workflow run is action_required with no jobs, so upstream approval/permissions still need to be handled by a maintainer.

Fixes #4747

@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: ffa2214289

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/sandbox/sandboxes/unix_local.py Outdated

This comment was marked as off-topic.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: 8fc7aab210

ℹ️ 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".

fscfede-beep

This comment was marked as off-topic.

@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: 3e9f727088

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/sandbox/session/base_sandbox_session.py Outdated
Signed-off-by: mikemikimike <13286568797@163.com>
@mikemikimike

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep them coming!

Reviewed commit: 9ce15f766d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@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: 9ce15f766d

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.

@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: 68036ee68d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread tests/sandbox/test_base_sandbox_session.py Outdated
Comment thread tests/extensions/sandbox/test_blaxel.py Outdated

@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: 0708d65538

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread tests/sandbox/test_base_sandbox_session.py Outdated
@mikemikimike

Copy link
Copy Markdown
Author

当前 head 已在 0708d65 保留调用方 cancellation reason,并补充版本兼容、资源清理和聚焦测试覆盖。关闭这些已处理线程。

@sylvesterkaczmarek sylvesterkaczmarek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

pty_terminate_all() clears the registry before cleanup_all() runs, but cleanup_all() stops on the first _terminate_pty_entry exception. Any later PTYs are then left alive with no registry owner and cannot be retried. Could we attempt cleanup for every entry, preserve/re-raise the first error afterward, and add a first-cleanup-fails regression?

Copy link
Copy Markdown

The new review is valid against current head 16960bd2: each backend's pty_terminate_all() clears registry ownership first, then its sequential cleanup_all() still aborts on the first _terminate_pty_entry() exception.

A fail-closed shape that preserves the existing cancellation settlement contract is:

async def cleanup_all() -> None:
    first_error: BaseException | None = None
    for entry in entries:
        try:
            await self._terminate_pty_entry(entry)
        except BaseException as error:
            if first_error is None:
                first_error = error
    if first_error is not None:
        raise first_error

Then keep await self._settle_pty_cleanup(cleanup_all()) unchanged. This ensures all de-owned PTYs receive a cleanup attempt before the first cleanup error is re-raised. The regression should use two entries where the first termination raises and the second records successful cleanup; assert both attempts occurred, the registry remains empty, and the first exception is propagated.

I have verified this is still required by inspection of the current PR diff; I have not executed the upstream test matrix or modified the author's branch.

@mikemikimike

mikemikimike commented Sep 3, 2026

Copy link
Copy Markdown
Author

Addressed in commit 10d6f2d. PTY batch cleanup now attempts every detached entry, re-raises the first cleanup failure after the batch, and all seven providers use the shared typed helper for prune, finalizer, and registry-clear cleanup. The adjacent unregistered-startup changes were removed to keep this PR focused on #4747. Added base-session, Blaxel registry-clear cancellation, and Blaxel first-cleanup-fails regressions. Validation: focused sandbox tests 254 passed, 1 skipped; mypy 308 source files passed; Pyright passed; Ruff format/lint and git diff --check passed. The full local suite remains limited by unrelated Windows symlink privileges, MCP 502, skills workspace state, and example encoding issues. No GitHub checks are currently reported for this branch.

Copy link
Copy Markdown

@codex review

Please review current head 3fb9e81f737e097993ef36ab316459c3751abc14, specifically the new shared _cleanup_pty_entries() attempt-all/first-error semantics and the added unregistered setup-cancellation cleanup paths.

@chatgpt-codex-connector

Copy link
Copy Markdown

Note

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

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

Fresh re-audit of current head 10d6f2dc627a8959a602356bd67d98cf680e1083 against #4747: I do not see a new runtime blocker.

Independent control-flow reproduction of the current helper confirms the intended invariants: cleanup completes before caller cancellation is re-propagated; repeated cancellation preserves the first cancellation; registry-cleared batch cleanup attempts every entry even when the first cleanup fails; and cleanup exceptions take precedence over caller cancellation, matching the existing mount_lifecycle error-before-cancellation policy. The latest batch implementation also fixes the Sept 3 first-error gap by collecting the first error while continuing through all entries.

Scope remains correctly limited to post-registry-removal ownership in finalizer/prune/pty_terminate_all() across the seven backends. I would keep #4749/#4750 separate because that PR addresses pre-registration startup ownership instead.

External gate remains: GitHub Actions for this fork head is still action_required, so this review is not claiming upstream CI green or maintainer acceptance.

@sylvesterkaczmarek sylvesterkaczmarek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

PTY cleanup now attempts every detached entry before re-raising the first failure; the added regression covers the ownership-loss case I raised. No remaining blocker from me.

@seratch seratch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the update. There is still a shutdown-liveness issue in the Docker path.

_settle_pty_cleanup() suppresses caller cancellation until cleanup finishes, but Docker's _terminate_pty_entry() awaits kill/PID-file cleanup through exec_run() without a deadline. If that attached exec stream stalls, cancellation no longer releases the caller, the entry socket is never closed, and later entries in the cleared batch are never attempted. An outer asyncio.wait_for() also waits for cancellation to finish.

Please bound the Docker backend waits and ensure local resource cleanup and subsequent batch entries still run when that deadline expires. Add a regression through the Docker cleanup path with a stalled backend operation, asserting bounded completion and resource cleanup. The existing cancellation tests always release the blocked operation, so they do not cover this case.

@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: 54a942db5f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/agents/sandbox/session/base_sandbox_session.py Outdated
Comment thread src/agents/sandbox/sandboxes/docker.py Outdated
Comment thread tests/sandbox/test_docker.py Outdated

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

Auditoría independiente del head 54a942db5f8580417687df140247655a0868c009 contra #4747: la arquitectura base es correcta, pero no considero el PR merge-ready todavía por los 3 bloqueos P1 actualmente abiertos.

  1. Liveness del owner-settled cleanup: _settle_pty_cleanup() debe tener un límite de espera de proveedor antes de absorber cancelación indefinidamente. La cancelación del caller no puede convertir una caída de proveedor en un stop()/shutdown() que nunca termina. Recomiendo que el helper acepte timeout, y que cada backend pase su límite de cleanup; al expirar, registrar/propagar el timeout según el contrato existente y continuar con la liberación local/batch.

  2. Docker PID-file race: el cambio a wait_for(run_in_executor(...)) puede cancelar el Future asyncio mientras el worker del executor sigue ejecutando exec_run. Después, _rm_best_effort(pid_path) puede borrar el PID file antes de que el kill tardío lo lea, dejando vivo el proceso cuyo entry ya perdió ownership. Kill + PID-file removal deben ser una única operación ordenada en el mismo worker, o el PID file debe conservarse hasta que el worker confirme terminalidad.

  3. Regresión Docker: el readiness wait de first_kill_started ocurre antes del try/finally; si scheduling/executor starvation hace vencer ese wait, release_first_kill nunca se establece y el worker puede quedar bloqueado durante teardown. Crear/start/wait debe quedar dentro de un try/finally que siempre libere y drene el worker.

La parte positiva: #4751 sí cubre correctamente el ownership transferido después de pop/clear, aplica el patrón a los siete backends y ya corrige el primer-entry-error para que el batch continúe con las entradas restantes. El siguiente paso correcto es resolver estos tres bloqueos, volver a ejecutar la matriz focal y solo entonces cerrar #4747. #4749/#4750 sigue siendo un problema distinto (ownership antes de registro).

This comment was marked as off-topic.

This comment was marked as off-topic.

This comment was marked as off-topic.

@chatgpt-codex-connector

This comment was marked as off-topic.

This comment was marked as off-topic.

This comment was marked as off-topic.

This comment was marked as off-topic.

@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: 3678c6364a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

first_error: BaseException | None = None
for entry in entries:
try:
await self._settle_pty_cleanup(cleanup_entry(entry), timeout=timeout)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Use one deadline for the entire PTY cleanup batch

When pty_terminate_all() has several entries and each provider close or kill stalls, cancellation caught by the first _settle_pty_cleanup is saved as first_error, but this loop then gives every later entry a fresh full timeout before re-raising it. With 64 supported PTYs, cancellation can therefore take up to 64 × cleanup_s (32 minutes with Blaxel's 30-second default), so an outer timeout still cannot bound stop() or shutdown(). Fresh evidence in this revision is the per-entry settlement call here rather than one deadline around the batch; use a shared batch deadline while still scheduling cleanup for every entry.

AGENTS.md reference: AGENTS.md:L149-L149

Useful? React with 👍 / 👎.

Comment on lines +849 to +850
if pruned is not None:
await self._terminate_pty_entry(pruned)
await self._settle_pty_cleanup(self._terminate_pty_entry(pruned))

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 Roll back the newly registered PTY when prune cleanup times out

When the PTY table is full and closing the pruned entry exceeds cleanup_s, the new command has already been sent and its entry registered, but this newly timed call raises TimeoutError before pty_exec_start() returns its process_id. The caller therefore observes a failed start while the command continues running in _pty_sessions; retrying can duplicate command side effects. Remove and terminate the new entry on this failure, or keep prune-cleanup timeout from invalidating an otherwise successful start.

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

Useful? React with 👍 / 👎.

Comment on lines +782 to +783
elif timed_out:
raise asyncio.TimeoutError()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve snapshots when PTY cleanup reaches its deadline

For a runner-owned session with a non-noop snapshot, a PTY close or kill that exceeds this new deadline raises TimeoutError from _before_stop(), so BaseSandboxSession.stop() skips _persist_snapshot(). _SandboxSessionResources.cleanup() still proceeds through shutdown() and client.delete() after that stop failure, which can delete the backend containing the only current workspace copy. Continue snapshot persistence before deleting the provider resource, or otherwise retain the backend when timed-out PTY cleanup prevents persistence.

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

Useful? React with 👍 / 👎.

Comment on lines +1358 to +1362
await self._exec_run(
cmd=command,
workdir=None,
user=None,
timeout=_PTY_CLEANUP_TIMEOUT_S,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not cancel a queued Docker kill

When all eight shared _DOCKER_EXECUTOR workers are occupied, this kill command remains queued until _exec_run()'s five-second wait_for expires; cancellation of a not-yet-started executor future prevents the command from ever running. _kill_pty_pid_path() suppresses the resulting timeout and termination returns after the registry entry was removed, leaving both the detached process and its PID file without an owner or retry path. Fresh evidence beyond the earlier PID-file ordering concern is this queued-executor path, where the combined kill-and-remove command never starts at all; keep the queued kill scheduled independently of the caller deadline.

AGENTS.md reference: AGENTS.md:L149-L149

Useful? React with 👍 / 👎.

@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: f5653ef8f6

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +432 to +436
while not snapshot_task.done():
try:
await asyncio.shield(snapshot_task)
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.

P1 Badge Bound fallback snapshot persistence

When _before_stop() fails or is cancelled and a supported non-noop snapshot operation stalls—for example, an unbounded RemoteSnapshot.upload()—this loop absorbs every subsequent cancellation until the snapshot task finishes. Consequently, an outer asyncio.wait_for() cannot terminate runner cleanup, and shutdown(), provider deletion, and dependency closure are never reached; bound this fallback persistence or retain the backend without making cleanup permanently uncancellable.

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

Useful? React with 👍 / 👎.

await asyncio.shield(snapshot_task)
except asyncio.CancelledError:
continue
snapshot_task.result()

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 original stop failure

When PTY cleanup has already raised or cancelled stop() and the fallback snapshot persistence also fails, snapshot_task.result() raises the snapshot exception before control can re-raise before_stop_error. This replaces the primary cleanup failure—and even converts caller cancellation into an ordinary snapshot error—so preserve the original failure while retaining the persistence failure as chained diagnostic context.

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

Useful? React with 👍 / 👎.

Comment on lines +872 to +875
while pending:
remaining = deadline - loop.time()
if remaining <= 0:
break

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Prevent snapshotting while timed-out PTYs are still running

When a provider kill or close exceeds the shared deadline, this branch exits without reporting that any cleanup remains pending, so pty_terminate_all() returns and stop() immediately starts snapshot persistence while the detached PTY can still modify the workspace. A stalled Daytona/E2B kill or queued Docker kill can therefore race the archive and produce a partial snapshot before runner cleanup deletes the backend; do not advance to persistence until process termination is confirmed, or fail cleanup while retaining the backend instead of treating this deadline as successful termination.

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

Useful? React with 👍 / 👎.

self._reserved_pty_process_ids.discard(process_id)
if removed is not None:
await self._terminate_pty_entry(removed)
await self._settle_pty_cleanup(self._terminate_pty_entry(removed))

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 Do not fail completed PTY calls on cleanup timeout

When Docker has already obtained the command's exit code but exec_inspect, PID-file removal, or reader-thread cleanup consumes the five-second cleanup budget, this default-propagating settlement raises TimeoutError instead of returning the completed PtyExecUpdate. The entry and process ID have already been removed, so the caller sees a failed command and cannot poll the result again; detach timed-out best-effort finalization as the prune path does rather than replacing the successful command outcome.

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

Useful? React with 👍 / 👎.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants