Prhub

#26964 Fix kill_process_tree reap wait crashing on pidfd EINVAL

原始 PR 作者 hnyls2002 合并时间 2026-06-02 04:56 文件变更 1 提交数 2 评论 3 代码增减 +45 / -19

执行摘要

修复 kill_process_tree 因 pidfd EINVAL 崩溃

psutil.wait_procs 内部对非子进程使用 os.pidfd_open,在某些内核上对刚被 SIGKILL 的进程会抛出 OSError(EINVAL),导致测试 tearDownClass 中的 reap 等待意外中止,即使测试本身已通过。PR body 明确描述了这一问题。

该 PR 是典型的系统兼容性修复,值得合并。建议精读 _still_holding_resources 和新的 _wait_for_reap_or_raise 实现,理解如何用轮询替代 psutil.wait_procs 绕过内核限制。该模式在未来处理类似 os.pidfd_open 兼容性问题时可复用。

讨论亮点

PR 没有 review 评论或讨论线程。开发者独立完成了分析和修复。

实现拆解

  1. 新增 _still_holding_resources 函数python/sglang/srt/utils/common.py):遍历进程列表,通过 p.is_running()p.status() != psutil.STATUS_ZOMBIE 判断进程是否仍持有资源。僵尸进程(Zombie)视为资源已释放,因为内核已回收其 GPU context 等。对 psutil.NoSuchProcessOSError 静默跳过。
  2. 重写 _wait_for_reap_or_raise 函数:将原本基于 psutil.wait_procs 的两阶段等待替换为轮询循环。计算 deadlinewarn_deadline,每轮调用 _still_holding_resources 检查存活进程。若超过 deadline 仍有进程未退出则抛出 RuntimeError;若首次达到 warn_deadline 则打印警告日志。每次轮询后 time.sleep(0.1) 避免忙等待。
  3. 更新函数文档字符串:在 _wait_for_reap_or_raise 的 docstring 中说明改用轮询 /proc 而非 psutil.wait_procs 的原因(绕过 pidfd EINVAL)。
  4. 调用方 kill_process_tree 无需修改:其接口与行为完全不变,仅底层等待逻辑被替换。
文件 模块 状态 重要度
python/sglang/srt/utils/common.py 工具函数 modified 7.5

关键符号

_still_holding_resources _wait_for_reap_or_raise kill_process_tree

关键源码片段

python/sglang/srt/utils/common.py core-logic

包含全部变更:新增 `_still_holding_resources` 函数,重写 `_wait_for_reap_or_raise` 用轮询替代 `psutil.wait_procs`,修复 `OSError(EINVAL)` 崩溃。

def _still_holding_resources(procs):
    """Procs still holding GPU context, pinned memory or fds.    A zombie has already had its resources freed by the kernel (only the exit
    status lingers), so it counts as gone; NoSuchProcess / OSError (see
    _wait_for_reap_or_raise) mean the same.
    """
    alive = []
    for p in procs:
        try:
            # 调用 psutil.Process.is_running() 会检查 /proc/<pid> 是否存在;
            # status() 读取 /proc/<pid>/status,Zombie 状态表示进程已死但保留退出码。
            # 只有真正还活着的非 Zombie 进程才视为仍持有资源。
            if p.is_running() and p.status() != psutil.STATUS_ZOMBIE:
                alive.append(p)
        except (psutil.NoSuchProcess, OSError):
            # 进程已消失或 pidfd_open 失败(如 EINVAL),视为已释放。
            pass
    return alive
​
​
def _wait_for_reap_or_raise(procs, wait_timeout: float) -> None:
    """Wait for `procs` to exit; warn at ~10s, raise on `wait_timeout`.    SIGKILL is asynchronous -- children hold GPU context, pinned memory and
    fds until the kernel reaps them. Raise on timeout so a stuck process
    surfaces instead of leaving a latent race.    Polls /proc via is_running()/status() rather than psutil.wait_procs, whose
    os.pidfd_open path (used for non-child procs) raises OSError(EINVAL) against
    a just-killed process on some kernels and aborts the whole wait.
    """
    warn_at = min(10.0, wait_timeout / 2)
    deadline = time.monotonic() + wait_timeout
    warn_deadline = time.monotonic() + warn_at
    warned = False
    while True:
        alive = _still_holding_resources(procs)
        if not alive:
            return
        now = time.monotonic()
        if now >= deadline:
            raise RuntimeError(
                f"kill_process_tree: {len(alive)} process(es) not reaped within "
                f"{wait_timeout}s after SIGKILL; pids={[p.pid for p in alive]}"
            )
        if not warned and now >= warn_deadline:
            logger.warning(
                "kill_process_tree: %d process(es) still alive after %.1fs SIGKILL; "
                "continuing to wait up to %.1fs total. pids=%s",
                len(alive),
                warn_at,
                wait_timeout,
                [p.pid for p in alive],
            )
            warned = True
        # 短 sleep 避免忙等;轮询间隔 100ms 可接受。
        time.sleep(0.1)

评论区精华

没有提炼出高价值讨论线程

当前评论区没有形成足够清晰的争议点或结论,后续有更多讨论时会体现在这里。

风险与影响

  1. 轮询开销_still_holding_resourceswhile True 循环中每 0.1 秒调用一次 is_running()status(),在进程数量多时可能有一定 CPU 开销,但通常 kill_process_tree 的调用频率很低(仅测试清理场景)。
  2. 行为改变:新逻辑采用固定轮询间隔而非内核通知,可能导致释放检测延迟(最多 100ms),但绝大多数场景仍能正常工作。
  3. psutil.wait_procs 被完全替换:原本 wait_procs 利用内核事件驱动,新实现为纯用户态轮询,但考虑到原行为在特定内核上根本不可用,这一 trade-off 可以接受。
  4. 僵尸进程处理:Zombie 被视为已释放资源(status() == psutil.STATUS_ZOMBIE 时跳过),这是正确的——僵尸进程只保留 exit status,不持有 GPU 资源。

影响范围仅限于 python/sglang/srt/utils/common.py 中的 _wait_for_reap_or_raise 和新增的 _still_holding_resources。影响用户为所有调用 kill_process_tree(wait_timeout=...) 的代码路径,主要是测试清理和进程管理场景。修复了在某些内核版本上测试 teardown 因 OSError(EINVAL) 意外失败的问题,提升测试稳定性和开发体验。

缺少测试覆盖

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论