Skip to content

Commit b6bd302

Browse files
committed
fix(codex): terminate descendants after clean exit
1 parent 378c14f commit b6bd302

2 files changed

Lines changed: 64 additions & 6 deletions

File tree

‎src/agents/extensions/experimental/codex/exec.py‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -243,12 +243,11 @@ async def _cleanup_resources() -> None:
243243
except BaseException as exc:
244244
cleanup_error = exc
245245

246-
if original_error is not None or process.returncode is None:
247-
try:
248-
await _terminate_process_tree(process)
249-
except BaseException as exc:
250-
if cleanup_error is None:
251-
cleanup_error = exc
246+
try:
247+
await _terminate_process_tree(process)
248+
except BaseException as exc:
249+
if cleanup_error is None:
250+
cleanup_error = exc
252251

253252
try:
254253
await _cleanup_process()

‎tests/extensions/experiemental/codex/test_codex_exec_thread.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import signal
1010
import subprocess
1111
import sys
12+
from collections.abc import AsyncGenerator
1213
from dataclasses import fields
1314
from pathlib import Path
1415
from typing import Any, cast
@@ -801,6 +802,64 @@ async def create_output_heavy_subprocess(
801802
await process.wait()
802803

803804

805+
@pytest.mark.skipif(sys.platform == "win32", reason="SelectorEventLoop lacks subprocess support")
806+
@pytest.mark.asyncio
807+
async def test_codex_exec_run_terminates_descendant_after_clean_parent_exit(
808+
monkeypatch: pytest.MonkeyPatch,
809+
) -> None:
810+
original_create_subprocess_exec = asyncio.create_subprocess_exec
811+
process: asyncio.subprocess.Process | None = None
812+
helper_pid: int | None = None
813+
helper_code = "import time; time.sleep(10)"
814+
child_code = (
815+
"import os\n"
816+
"import subprocess\n"
817+
"import sys\n"
818+
"sys.stdin.buffer.read()\n"
819+
f"helper = subprocess.Popen([sys.executable, '-c', {helper_code!r}], "
820+
"stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=sys.stderr)\n"
821+
"os.write(1, f'ready {helper.pid}\\n'.encode())\n"
822+
)
823+
824+
async def create_process_with_descendant(
825+
*_args: Any, **kwargs: Any
826+
) -> asyncio.subprocess.Process:
827+
nonlocal process
828+
process = await original_create_subprocess_exec(sys.executable, "-c", child_code, **kwargs)
829+
return process
830+
831+
async def collect_output(stream: AsyncGenerator[str, None]) -> list[str]:
832+
return [line async for line in stream]
833+
834+
monkeypatch.setattr(
835+
exec_module.asyncio, "create_subprocess_exec", create_process_with_descendant
836+
)
837+
838+
stream = exec_module.CodexExec(executable_path="unused").run(
839+
exec_module.CodexExecArgs(input="hello")
840+
)
841+
842+
try:
843+
output = await asyncio.wait_for(collect_output(stream), timeout=5)
844+
helper_pid = int(output[0].removeprefix("ready "))
845+
assert process is not None
846+
assert process.returncode == 0
847+
for _ in range(500):
848+
if not _process_is_running(helper_pid):
849+
break
850+
await asyncio.sleep(0.01)
851+
else:
852+
pytest.fail("Codex subprocess descendant remained alive after clean parent exit")
853+
finally:
854+
if helper_pid is not None:
855+
with contextlib.suppress(ProcessLookupError, PermissionError):
856+
os.kill(helper_pid, signal.SIGTERM)
857+
if process is not None:
858+
if process.returncode is None:
859+
process.kill()
860+
await process.wait()
861+
862+
804863
@pytest.mark.skipif(sys.platform != "win32", reason="Windows process-tree behavior")
805864
def test_terminate_windows_process_tree_stops_descendant() -> None:
806865
import ctypes

0 commit comments

Comments
 (0)