返回博客

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 不能可靠地级联终止整棵树。父进程可能自己就活下来,也可能某个辅助进程把它拉起来。

这个幸存者持有两把锁:

  1. --remote-debugging-port 监听 socket(端口被占)
  2. --user-data-dirsingleton lock(同一个 profile 目录,Chrome 拒绝再起第二个实例)

新一轮脚本再 spawn 同端口 + 同 profile 目录时,新进程绑不上端口、过不了 singleton 检查,默默退出——因为 stdio: "ignore",你看不到任何报错,只有一个始终不响应的 CDP 端点。

坑:child.kill() 不等于杀死 Chrome 进程树

在 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 按 profile 路径,或 detached + 负 PID 杀 group

两选一:

  • 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。