一个基于 Claude Code、LangGraph 或 Temporal 的 Agent 启动了 npm run dev 来测试自己写出来的代码,结果在关掉服务器之前因为预算上限崩溃了。Node 进程继续运行,3000 端口一直被占着,下一次任务一启动就立刻报 Error: listen EADDRINUSE: address already in use :::3000。或者一个构建 Agent 跑了 tsc --watch,退出时没杀掉它,监听进程就在后台不停地写文件。孤儿进程在它引发下一次可见的失败之前,一直是隐形的。
最快的修复(先做这个): 先把残留进程现在就杀掉,再去改启动代码防止复发。先释放被占的端口,macOS 和 Linux 上执行 kill -9 $(lsof -t -i :3000)。然后把每一处子进程启动都包进一个上下文管理器里,用 start_new_session=True 让子进程进入自己的进程组,并在 finally 块里把整个组干掉。完整写法见下面的 Step 1。
先判断你属于哪一类
| 症状 | 最可能的原因 | 跳到 |
|---|---|---|
下一次任务报 EADDRINUSE,旧进程还在 ps 里 | 清理逻辑没跑(没放 finally,或 Agent 被 SIGKILL) | 原因 1-3,Step 1-2 |
调了 .terminate() 但子进程还活着 | 用了 shell=True,或没建进程组 | 原因 4,Step 3 |
| 进程已经没了,端口却还占着约 30-120 秒 | TCP TIME_WAIT,不是孤儿 | 原因 5,Step 4 |
子进程只在 docker stop 之后才残留 | PID 1 / 信号转发缺失 | 原因 6,Step 5 |
下一次任务报 database is locked | 孤儿进程还持着 SQLite 写锁 | 见 FAQ |
常见原因
1. Agent 退出时没调清理逻辑——没有任何信号发出去
Agent 调了 subprocess.Popen(["npm", "run", "dev"]) 并保存了 Popen 对象。如果 Agent 之后因为任何原因退出(命中预算、任务完成、未捕获的异常),却没对这个对象调 .terminate(),Python 进程一死,子进程就被重新挂到 PID 1(init 系统)名下。没有任何信号到它那里,它就这么一直跑着。
怎么判断: 在 Agent 代码里 grep subprocess.Popen 和 asyncio.create_subprocess_exec。每一处都检查返回值有没有被保存,以及 .terminate() 或 .kill() 有没有在 finally 块或上下文管理器里被调用。
2. 异常路径绕过了清理
Agent 启动了子进程,然后在启动和清理调用之间抛了异常。try: run_server() except: raise 这种写法里清理不在 finally,异常就把它绕过去了。这是实践中产生孤儿进程最常见的一条路径。
怎么判断: 看每一处在 try 块里启动子进程的地方。只要 terminate() 调用不在 finally 里,Popen() 到 terminate() 之间任何异常都会把子进程变成孤儿。
3. Agent 在清理前被 SIGKILL——没有进程组追踪
Agent 自己被 SIGKILL 杀掉了(OOM killer、watchdog、容器驱逐)。SIGKILL 无法被捕获,所以 atexit 钩子和 finally 块根本不会运行。子进程被重新挂到 PID 1 上继续跑,除非它们和 Agent 在同一个进程组、且这个组也收到了 kill。事后可以确认是不是 SIGKILL:被信号 9 杀掉的进程退出码是 137(128 + 9)。
怎么判断: 检查子进程是不是用 start_new_session=True 启动到了新会话里(它等价于老式的 preexec_fn=os.setsid,但线程安全)。如果它们和父进程共用同一个组,kill -9 -<pgid> 能把它们一起带走;如果没建组,父进程被 SIGKILL 就会把它们变成孤儿。
4. shell=True 把真正的进程树藏起来了
subprocess.Popen("npm run dev", shell=True) 会先起一个 shell,shell 再 fork 出 npm,npm 再 fork 出 node。对 Popen 对象调 .terminate() 只会把 SIGTERM 发给那个 shell,发不到 npm 或 node。shell 退出了,子进程还活着。
怎么判断: grep shell=True。任何这样启动的长期进程,都需要显式做进程组管理才能终止整棵树。
5. 进程已经没了,端口却还被占着(TIME_WAIT)
这一类不是孤儿。一个 TCP 监听套接字干净关闭后,内核会让端口在 TIME_WAIT 状态停留两倍的最大段生存期——通常 30 到 120 秒(Linux 上由 net.ipv4.tcp_fin_timeout 控制)。在这个窗口内重启的任务,即使没有任何进程占着端口,也会拿到 EADDRINUSE。用 lsof -i :3000 确认:如果没有列出任何进程但 bind 仍然失败,那就是 TIME_WAIT,不是泄漏。
怎么判断: ss -tan | grep :3000(Linux)或 netstat -an | grep 3000 会显示套接字处于 TIME_WAIT。修复办法是给监听套接字设 SO_REUSEADDR(Node 默认就开了;Python 的 http.server 没有),或者给一次性测试服务器用临时端口(PORT=0)。
6. 容器停止时信号没传到子进程
在 Docker 里,docker stop 先给 PID 1 发 SIGTERM,等 10 秒,再发 SIGKILL。如果你的入口是个朴素的 CMD ["python", "agent.py"],Agent 是以 PID 1 运行的,但一个裸的 Python 进程不会把信号转发给孙进程,也不会收割僵尸进程,所以 Agent 启动的子进程会残留,或堆积成僵尸。
怎么判断: 执行 docker stop <container>,然后 docker exec <container> ps aux(或趁它没死之前查)。如果停止之后子进程残留、或堆积成 Z(僵尸)状态,你就有信号转发缺口。
最短修复路径
Step 1:把每一处启动都包进会杀掉整个进程组的上下文管理器
这是核心修复。start_new_session=True 让子进程进入自己的进程组;os.killpg 随后把子进程及它派生的一切都干掉,而且放在一个 finally 块里——成功、异常、正常退出三种情况下都会执行。
import subprocess
import signal
import os
from contextlib import contextmanager
@contextmanager
def managed_subprocess(args: list, **kwargs):
"""启动子进程,并保证退出上下文时整个进程组都被终止。"""
proc = subprocess.Popen(
args,
start_new_session=True, # 子进程领导自己的进程组
**kwargs
)
try:
yield proc
finally:
if proc.poll() is None: # 还在运行
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGTERM) # 请整个组停下
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(pgid, signal.SIGKILL) # 强制干掉整个组
proc.wait()
# 用法:
with managed_subprocess(["npm", "run", "dev"]) as server:
run_tests_against_server(server)
# 到这里 server(以及 node、npm 和任何子进程)都已经死了,即使中途抛了异常
注意:start_new_session=True 和 os.killpg 只在 POSIX 上有效。Windows 上要传 creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,并用 proc.send_signal(signal.CTRL_BREAK_EVENT) 或 taskkill /T /F /PID <pid> 才能波及整棵树。
Step 2:把每个 PID 登记到注册表,在 Agent 退出时统一清理
上下文管理器负责正常路径。要扛住 SIGTERM(docker stop 和大多数调度器先发的就是它),注册一个会清空注册表的处理器。SIGKILL 仍然没法捕获——那是 Step 5 要解决的。
import atexit
_subprocess_registry: list[subprocess.Popen] = []
def register_subprocess(proc: subprocess.Popen) -> subprocess.Popen:
_subprocess_registry.append(proc)
return proc
def cleanup_all_subprocesses():
for proc in _subprocess_registry:
if proc.poll() is None:
try:
pgid = os.getpgid(proc.pid)
os.killpg(pgid, signal.SIGTERM)
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except ProcessLookupError:
pass # 已经没了
atexit.register(cleanup_all_subprocesses)
signal.signal(signal.SIGTERM, lambda *_: (cleanup_all_subprocesses(), os._exit(0)))
Step 3:用 exec 形式,别用 shell=True
# 错误——shell=True:.terminate() 只杀 shell,node 活下来
proc = subprocess.Popen("npm run dev", shell=True)
# 正确——exec 形式 + 独立会话,killpg 能够到整棵树
proc = subprocess.Popen(["npm", "run", "dev"], start_new_session=True)
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
如果你确实需要 shell(为了管道或通配符),那就保留 start_new_session=True,并且永远按组杀,别只杀 proc.pid。
Step 4:退出前确认端口真的空了
终止服务器后,别想当然以为端口立刻就能复用。缓慢的优雅关闭可能占着它好几秒,而 TIME_WAIT(原因 5)能占到 30-120 秒。轮询确认一下,并且对一次性测试服务器优先用 SO_REUSEADDR 或临时端口,这样下一次任务永远不会撞车。
import socket
import time
def is_port_in_use(port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
return s.connect_ex(("localhost", port)) == 0
def wait_for_port_free(port: int, timeout: float = 10.0):
deadline = time.time() + timeout
while time.time() < deadline:
if not is_port_in_use(port):
return
time.sleep(0.5)
raise RuntimeError(f"端口 {port} 在清理 {timeout} 秒后仍被占用")
with managed_subprocess(["npm", "run", "dev"]) as server:
run_tests()
wait_for_port_free(3000, timeout=10)
Step 5:要防住 SIGKILL,把长期进程交给进程监控器
atexit 和信号处理器对 SIGKILL 或容器硬驱逐毫无作用。任何需要活过单次 Agent 调用的子进程,都让进程监控器来掌管生命周期,Agent 只负责给它发信号(「给 run-42 启动服务器」)。
# 测试服务器的 supervisord 配置
[program:test_server]
command=npm run dev
autostart=false
autorestart=false
stopwaitsecs=5
killasgroup=true # 把停止信号发给整个组
stopasgroup=true # 让 node 及其子进程都一起下线
在 Docker 里,加一个真正的 init,让 PID 1 转发信号并收割僵尸:用 docker run --init 启动(它会把内置的 tini 注入为 PID 1,你的 Agent 变成 PID 2),或者在 Compose 服务里设 init: true。没有它,PID 1 上的裸进程不会收割你的子进程留下的僵尸。
怎么确认已经修好了
跑一个故意杀进程的测试,因为这个故障只在清理被跳过时才暴露:
- 记下 Agent 用的端口,启动一次真实任务。
- 在它跑到一半时硬杀掉 Agent:
kill -9 <agent_pid>(模拟 OOM / 驱逐)。 - 约 10 秒后查残留:
lsof -i :3000和pgrep -f "npm run dev"都应该什么也返回不出来。 - 启动下一次任务,它应该能正常绑定端口,没有
EADDRINUSE。
如果在 SIGKILL 掉 Agent 之后仍有残留,说明你只靠 atexit/SIGTERM,需要上 Step 5 的进程监控器方案。
预防建议
- 把每一处
subprocess.Popen都包进会在finally里终止进程组的上下文管理器。 - 一律用
start_new_session=True启动,并用os.killpg(os.getpgid(pid), ...)清理,而不是只调proc.terminate()。 - 长期进程绝不要用
shell=True,除非你仍然按组杀。 - 注册
atexit加一个 SIGTERM 处理器,统一清空子进程注册表。 - 给每个子进程打上
AGENT_TASK_ID环境变量,方便任务后用一条命令找出漏网的:ps -eww -o pid,command | grep AGENT_TASK_ID=<id>。 - 干净退出后出现的
EADDRINUSE要先怀疑是TIME_WAIT,不一定是泄漏——给测试服务器设SO_REUSEADDR或用临时端口。 - 在 Docker 里始终用
docker run --init(或 Compose 的init: true)启动,让 PID 1 转发信号并收割僵尸。 - 把上面那个 SIGKILL 冒烟测试加进 CI,这样清理逻辑一旦回退就会让构建失败。
常见问答 (FAQ)
Q:怎么找出上一次 Agent 任务遗留的孤儿子进程?
A:用 lsof -i :<port> 列出占端口的进程,再用 kill -9 $(lsof -t -i :<port>) 杀掉。要找被重新挂到 init 名下的进程(真正的孤儿):Linux 上用 ps --ppid 1 -u <agent_user>;macOS 的 BSD 版 ps 没有 --ppid,得用 ps -eo pid,ppid,command | awk '$2 == 1'。如果你给子进程打过 AGENT_TASK_ID,直接 grep 这个环境变量更省事。
Q:可以不先发 SIGTERM 就直接发 SIGKILL 吗?
A:测试服务器和 --watch 编译器可以——它们没什么重要数据要刷盘。但任何会写磁盘的进程(数据库、日志轮转)一律先发 SIGTERM 并给 3-5 秒缓冲;在写入过程中 SIGKILL 可能损坏文件或留下死锁。
Q:进程已经没了,我却还在收到 EADDRINUSE,为什么?
A:那是 TCP TIME_WAIT,不是孤儿。套接字干净关闭后,内核会把端口保留 30-120 秒。用 lsof -i :<port> 看不到任何进程、而 ss -tan | grep :<port> 显示 TIME_WAIT,就能确认。给监听套接字设 SO_REUSEADDR,或者一次性服务器绑定到端口 0。
Q:孤儿进程持着 SQLite 锁,下一次任务报 database is locked,怎么解?
A:用 lsof <db_file> 找出持有者,再 kill -9 <pid>。如果是 WAL 模式的数据库,在确认没有别的进程在用之后,把残留的 -wal 和 -shm 旁文件删掉,让下一个打开它的进程从干净状态开始。
Q:Temporal 或 LangGraph——子进程会比 workflow 活得久吗?
A:会。Activity 和节点都跑在你的 worker 进程里,所以 worker 一旦被杀,它的子进程就成了孤儿。框架不会替你管你自己启动的进程。在 worker 里同样套上下文管理器加 atexit/SIGTERM 清理,长期进程则交给进程监控器(Step 5)。
Q:我的子进程无视 SIGTERM,怎么办?
A:等约 5 秒还不退出,就对进程组发 SIGKILL:os.killpg(pgid, signal.SIGKILL)。如果连 SIGKILL 都扛得住,那它卡在 D(不可中断睡眠)状态等待挂起的 I/O——通常是 NFS 或设备驱动问题。这种情况只能杀容器或重启主机才能清掉。