child.kill() 杀不死 Chrome:僵尸进程占端口导致 CDP 第二次跑必挂
有一个 Node 截图脚本,直接 spawn Chromium binary,走裸 CDP(/json/version + WebSocket),不依赖 playwright。逻辑很简单:拉起来、截图、chrome.kill()、退出。
第一次跑:完美。第二次跑:轮询 30 秒,CDP not ready,脚本超时崩。
手动在同端口启一个 chrome 试试,也是哑的——/json/version 返回空,却没有任何 stderr。
现象
- 第 1 次调用截图成功,第 2 次起每次都超时
http://127.0.0.1:9333/json/version返回空 body,不报错pgrep -fl "Chrome for Testing"能看到上一次脚本留下的主进程还活着,命令行里还带着--user-data-dir=/tmp/myprofile- 脚本里明明调了
chrome.kill(),进程就是没死
根因
child.kill() 只给 Node spawn 直接创建的那个 PID 发一个 SIGTERM。
但 headless Chrome 一启动就 fork 出一棵进程树:gpu-process、crashpad_handler、renderer……在 macOS 上,向父进程发 SIGTERM 不能可靠地级联终止整棵树。父进程可能自己就活下来,也可能某个辅助进程把它拉起来。
这个幸存者持有两把锁:
--remote-debugging-port监听 socket(端口被占)--user-data-dir的 singleton lock(同一个 profile 目录,Chrome 拒绝再起第二个实例)
新一轮脚本再 spawn 同端口 + 同 profile 目录时,新进程绑不上端口、过不了 singleton 检查,默默退出——因为 stdio: "ignore",你看不到任何报错,只有一个始终不响应的 CDP 端点。
在 macOS 上,child.kill() 只向直接子进程发 SIGTERM,Chrome 的 gpu-process / crashpad 等子树不会跟着死。下一次运行 spawn 同端口 + 同 profile 时,新实例会因端口被占 / singleton lock 而静默退出——你看到的是"CDP not ready",永远看不到"端口已被占用"。
正解
方案一:按 profile 路径精准 pkill
在脚本结束时(同时也在脚本开头保险地清一次):
pkill -9 -f "/tmp/myprofile" # 只杀 cmdline 含这个路径的进程
rm -rf /tmp/myprofile # 也清掉 singleton lock 文件绝不能 pkill -f "Google Chrome for Testing"——用 app 名做模式会把同机其他 CDP 会话(比如 MCP 浏览器插件开的 Chromium)一起杀掉。一定要用你自己那个唯一的 profile 路径做匹配串。
方案二:spawn detached,用负 PID 杀进程组
const chrome = spawn(CHROME, [...], { stdio: "ignore", detached: true });
// ... CDP 截图逻辑 ...
try {
process.kill(-chrome.pid, "SIGKILL"); // 负号 = 杀整个 process group
} catch {}detached: true 会让 Chrome 在独立 process group 里启动,-pid 把整组一锅端,不留僵尸。
两选一:
pkill -9 -f "<你的唯一 profile 路径>"+ 删 profile 目录(简单,脚本结尾加两行)spawn(..., { detached: true })+process.kill(-chrome.pid, "SIGKILL")(干净,不留 profile 垃圾)
两种方案都要确保每次脚本用独立的 port + 独立的 profile 路径,避免与同机其他 Chrome / CDP session 碰撞。
排查路径(已经卡住时)
# 1. 看有没有幸存进程
pgrep -fl "Chrome for Testing"
# 2. 确认端口占用情况(可能什么也不显示,但 /json/version 就是不响应)
lsof -iTCP:9333 -P
# 3. 按 profile 路径手动杀
kill -9 <上面找到的 PID>
rm -rf /tmp/myprofile
# 4. 验证端口干净
lsof -iTCP:9333 -P # → 空,可以重跑了补记
如果能加依赖,直接 npm i playwright-core 然后用 chromium.launch({ executablePath }) 是最省心的选项——playwright 的 browser.close() 会帮你把进程树完整清掉,这个坑根本踩不到。裸 spawn 只在真的不能加这个包时才值得用。
一句话外卖
child.kill()只是给父进程发信号,不是给进程树下毒;要彻底终止 Chrome,用进程组 kill 或按唯一 profile 路径 pkill。