From 7e76239c86e8a35ee40e83e116e5f0158115934e Mon Sep 17 00:00:00 2001 From: InfinityPacer Date: Tue, 6 Oct 2026 02:54:56 +0800 Subject: [PATCH] fix(agent): reap macOS kernels before retrying group termination --- app/adapters/system/code/process.py | 21 ++++++-- tests/test_agent_code_process.py | 84 +++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 tests/test_agent_code_process.py diff --git a/app/adapters/system/code/process.py b/app/adapters/system/code/process.py index 2b0f1ad79..509da428b 100644 --- a/app/adapters/system/code/process.py +++ b/app/adapters/system/code/process.py @@ -4,6 +4,7 @@ import asyncio import os import signal import subprocess +import sys from typing import Any _SECRET_PARTS = ('KEY', 'TOKEN', 'SECRET', 'PASSWORD', 'CREDENTIAL', 'PASSWD', 'AUTH', 'DSN', @@ -88,8 +89,20 @@ async def kill_tree(process: asyncio.subprocess.Process) -> None: stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) await asyncio.wait_for(killer.wait(), 5) else: - try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - pass + for attempt in range(2): + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + break + except PermissionError as error: + if sys.platform != 'darwin' or attempt: + raise + # macOS 对只剩未回收僵尸的组返回 EPERM。先让 asyncio 回收根进程, + # 再发一次组信号,不能因根已退出就漏掉仍存活的后代或吞掉真实权限错误。 + try: + await asyncio.wait_for(process.wait(), 5) + except TimeoutError: + raise error from None + else: + break await asyncio.wait_for(process.wait(), 5) diff --git a/tests/test_agent_code_process.py b/tests/test_agent_code_process.py new file mode 100644 index 000000000..7de5222bf --- /dev/null +++ b/tests/test_agent_code_process.py @@ -0,0 +1,84 @@ +"""进程组回收兼容 macOS 僵尸状态,同时保留真实权限失败。""" + +import errno +import os +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from app.adapters.system.code import process as code_process + +pytestmark = pytest.mark.skipif(os.name == 'nt', reason='仅验证 POSIX 进程组信号') + + +@pytest.mark.asyncio +@pytest.mark.parametrize('retry_error', [None, ProcessLookupError(errno.ESRCH, 'group gone')]) +async def test_macos_reaps_root_then_signals_remaining_group(monkeypatch, retry_error): + """根回收后仍须发送组信号,兼顾存活后代和已消失的进程组。""" + process = SimpleNamespace(pid=12345, wait=AsyncMock(return_value=-9)) + denied = PermissionError(errno.EPERM, 'zombie group') + + def signal_group(_pid, _signal): + if process.wait.await_count == 0: + raise denied + if retry_error is not None: + raise retry_error + + signal = Mock(side_effect=signal_group) + monkeypatch.setattr(code_process, 'sys', SimpleNamespace(platform='darwin')) + monkeypatch.setattr(code_process.os, 'killpg', signal) + + await code_process.kill_tree(process) + + assert signal.call_count == 2 + process.wait.assert_awaited() + + +@pytest.mark.asyncio +async def test_macos_repeated_permission_error_is_not_swallowed(monkeypatch): + """即使根已退出,组内其他进程的权限失败仍必须交回调用方。""" + process = SimpleNamespace(pid=12345, wait=AsyncMock(return_value=0)) + denied = PermissionError(errno.EPERM, 'live descendant denied') + signal = Mock(side_effect=denied) + monkeypatch.setattr(code_process, 'sys', SimpleNamespace(platform='darwin')) + monkeypatch.setattr(code_process.os, 'killpg', signal) + + with pytest.raises(PermissionError) as raised: + await code_process.kill_tree(process) + + assert raised.value is denied + assert signal.call_count == 2 + + +@pytest.mark.asyncio +async def test_macos_unreaped_root_keeps_original_permission_error(monkeypatch): + """根无法回收时不能把清理权限失败伪装成脚本执行超时。""" + process = SimpleNamespace(pid=12345, wait=AsyncMock(side_effect=TimeoutError)) + denied = PermissionError(errno.EPERM, 'live root denied') + signal = Mock(side_effect=denied) + monkeypatch.setattr(code_process, 'sys', SimpleNamespace(platform='darwin')) + monkeypatch.setattr(code_process.os, 'killpg', signal) + + with pytest.raises(PermissionError) as raised: + await code_process.kill_tree(process) + + assert raised.value is denied + signal.assert_called_once() + + +@pytest.mark.asyncio +async def test_other_platform_permission_error_is_not_retried(monkeypatch): + """macOS 兼容分支不改变其他 POSIX 平台的权限错误处理。""" + process = SimpleNamespace(pid=12345, wait=AsyncMock()) + denied = PermissionError(errno.EPERM, 'permission denied') + signal = Mock(side_effect=denied) + monkeypatch.setattr(code_process, 'sys', SimpleNamespace(platform='linux')) + monkeypatch.setattr(code_process.os, 'killpg', signal) + + with pytest.raises(PermissionError) as raised: + await code_process.kill_tree(process) + + assert raised.value is denied + signal.assert_called_once() + process.wait.assert_not_awaited()