Skip to content

fix(sandbox): reap killed subprocess on UnixLocal exec timeout#3208

Draft
adityasingh2400 wants to merge 3 commits into
openai:mainfrom
adityasingh2400:fix/unix-sandbox-audit
Draft

fix(sandbox): reap killed subprocess on UnixLocal exec timeout#3208
adityasingh2400 wants to merge 3 commits into
openai:mainfrom
adityasingh2400:fix/unix-sandbox-audit

Conversation

@adityasingh2400

Copy link
Copy Markdown
Contributor

Summary

UnixLocalSandboxSession._exec_internal handles asyncio.TimeoutError by SIGKILL-ing the subprocess group, but never awaits the killed asyncio.subprocess.Process. Two issues fall out of that:

  1. Transport not torn down inside the failing call. Because proc.wait() is never awaited, proc.returncode stays None and the underlying SubprocessTransport (pipes + child-watcher entry) hangs around until GC eventually finds it. Repeated timeouts queue up live transports for the lifetime of the event loop.
  2. Stale-pid signal. os.killpg(proc.pid, SIGKILL) runs unconditionally. If the process happened to exit between wait_for cancellation and the kill, the pid may already have been reused, and we'd send SIGKILL to an unrelated process group. The except Exception: pass masks this.

The fix mirrors what _terminate_pty_entry already does for PTY entries (if process.returncode is None ... ProcessLookupError suppressed):

  • Only signal when proc.returncode is None (and proc.pid is not None).
  • Narrow the swallowed exception to ProcessLookupError instead of Exception.
  • After the kill, await asyncio.wait_for(proc.wait(), timeout=5.0) so asyncio actually closes the transport before ExecTimeoutError is raised.

Two regression tests cover both behaviors:

  • test_timeout_reaps_subprocess_so_returncode_is_set captures the asyncio Process used by _exec_internal and asserts returncode is populated after the timeout fires (fails on main: returncode is None).
  • test_timeout_skips_killpg_for_already_exited_pid simulates the "process exited just before the timeout handler" race and asserts no killpg is issued (fails on main: a stale killpg is recorded).

Test plan

  • pytest tests/sandbox/test_unix_local.py -v (all 10 pass)
  • pytest tests/sandbox/ (765 passed, 19 skipped)
  • ruff check + ruff format --check
  • pyright src/agents/sandbox/sandboxes/unix_local.py tests/sandbox/test_unix_local.py
  • Both new tests fail on main (bug repros), pass with the fix

@github-actions github-actions Bot added bug Something isn't working feature:sandboxes labels May 8, 2026
@seratch
seratch marked this pull request as draft May 8, 2026 16:13

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

Can you fix the CI errors?

@adityasingh2400

Copy link
Copy Markdown
Contributor Author

Fixed CI - typecheck and 3.10 test pass locally now. Thanks for the heads up.

@seratch
seratch marked this pull request as ready for review May 9, 2026 02:24
@seratch
seratch marked this pull request as draft May 9, 2026 02:24
@seratch seratch removed the bug Something isn't working label May 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR is stale because it has been open for 10 days with no activity.

@github-actions github-actions Bot added the stale label May 23, 2026
When an exec timeout fires, the killed subprocess is never awaited,
leaving the asyncio SubprocessTransport (pipes, child-watcher entry)
attached to a returncode-still-None Process object until GC eventually
collects it. The unconditional os.killpg also signals a pid that may
already have exited and been reused, hitting an unrelated process group.

Skip the kill when proc.returncode is already set, narrow the swallowed
exception to ProcessLookupError, and await proc.wait() (bounded) so the
transport tears down inside the failing call.
- raise asyncio.TimeoutError (not bare TimeoutError) in the wait_for
  fixture: on Python 3.10 these are distinct classes, so the source's
  except asyncio.TimeoutError clause was missing the bare one and the
  exception was upgraded to ExecTransportError.
- import asyncio/os directly instead of reaching through
  unix_local_module.asyncio / .os, which mypy strict export rejects
  with attr-defined errors.
- type captured[] as list[asyncio.subprocess.Process] so proc.returncode
  is statically known and the type: ignore comments are no longer needed.
@adityasingh2400
adityasingh2400 force-pushed the fix/unix-sandbox-audit branch from 69bc8f7 to d01e5d1 Compare May 24, 2026 09:10
@adityasingh2400

Copy link
Copy Markdown
Contributor Author

CI is green now. I rebased onto current main and re-ran the checks locally: ruff format, ruff lint, and mypy all pass, and the 10 tests in tests/sandbox/test_unix_local.py pass. The earlier failures were the py3.10 typing compat and a strict mypy issue in the test, both fixed. The fix itself reaps the killed subprocess on a UnixLocal exec timeout so it does not leave a zombie. Ready for review when you have time.

@adityasingh2400

Copy link
Copy Markdown
Contributor Author

Re-verified today: the branch merges cleanly against main and the CI errors that prompted the change-request are fixed. The earlier failures were a py3.10 typing-compat issue and a strict mypy issue in the test, both resolved. ruff format, ruff lint, and mypy are clean locally, and the 10 tests in tests/sandbox/test_unix_local.py pass. The fix in _exec_internal in src/agents/sandbox/sandboxes/unix_local.py does two things on an exec timeout: it only sends os.killpg when proc.returncode is None and proc.pid is not None, suppressing ProcessLookupError, so it cannot signal an already-exited and possibly reused pid, and it then awaits proc.wait with a bounded timeout to reap the killed subprocess so asyncio releases the transport instead of leaving a delayed-GC warning. Ready for another look when you have time.

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.

2 participants