fix(agent): reap macOS kernels before retrying group termination

This commit is contained in:
InfinityPacer
2026-10-06 02:54:56 +08:00
committed by InfinityPacer
parent 8b4b5eb4fe
commit 7e76239c86
2 changed files with 101 additions and 4 deletions

View File

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

View File

@@ -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()