|
9 | 9 | import signal |
10 | 10 | import subprocess |
11 | 11 | import sys |
| 12 | +from collections.abc import AsyncGenerator |
12 | 13 | from dataclasses import fields |
13 | 14 | from pathlib import Path |
14 | 15 | from typing import Any, cast |
@@ -801,6 +802,64 @@ async def create_output_heavy_subprocess( |
801 | 802 | await process.wait() |
802 | 803 |
|
803 | 804 |
|
| 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 | + |
804 | 863 | @pytest.mark.skipif(sys.platform != "win32", reason="Windows process-tree behavior") |
805 | 864 | def test_terminate_windows_process_tree_stops_descendant() -> None: |
806 | 865 | import ctypes |
|
0 commit comments