跑完还剩一堆浏览器进程:Playwright 资源没释放的几处常见原因

2026-08-18

现象

脚本明明跑完了,进程列表里还挂着一排浏览器进程;连着跑几轮之后机器开始变慢,userDataDir 那种带持久化目录的用法还会直接报「目录被占用」跑不起来。更隐蔽的一种:进程看着像是退了,但你配置的录像文件、HAR 文件在磁盘上是空的或者压根没生成。

这几个现象往往指向同一件事——你没有按 Playwright 文档写明的责任归属去关 context 和 browser。下面按「怎么确认 → 谁该关 → 异常路径怎么兜 → 怎么验证 → 什么时候不是这个原因」走一遍。所有结论都来自仓库文档,该项目持续更新,以仓库最新内容为准。

第一步:先确认残留的到底是谁

先做系统侧的判定动作。下面这两条是通用的系统命令,不是 Playwright 官方内容,只是用来看清进程还在不在:

Windows(PowerShell):

Get-Process chrome, firefox -ErrorAction SilentlyContinue | Select-Object Id, ProcessName, StartTime

Linux / macOS:

ps -ef | grep -i -E "chromium|firefox|webkit" | grep -v grep

系统侧只能告诉你「有残留」,说不清是哪一层没关。真正有用的是代码侧的判定,这几个 API 在 docs/src/api/class-browser.mddocs/src/api/class-browsercontext.md 里都有明确签名:

  • Browser.contexts() 返回当前所有打开的 browser context 数组。文档里的示例写得很直白:新创建的 browser 打印出来是 0newContext() 之后是 1
  • Browser.isConnected() 返回布尔值,表示 browser 是否还连着。
  • BrowserContext.pages() 返回该 context 里所有打开的 page。
  • 事件层面:Browser.disconnected 在浏览器断连时触发,文档列了两种触发原因——浏览器应用被关闭或崩溃、Browser.close() 被调用;BrowserContext.close 事件的触发原因文档列了三种——context 被关闭、浏览器应用被关闭或崩溃、Browser.close() 被调用。

判定动作就是:在你以为「跑完了」的那个位置,把 contexts() 的长度和 isConnected() 打出来。如果退出前 contexts() 还不是空的,那残留就是你自己创建、又没关掉的那些 context。

第二步:搞清楚这次是谁的关闭责任

同一个 Playwright,四种用法的责任归属完全不同,混起来记就一定会漏。

Test Runner 模式:默认不用你关

docs/src/browser-contexts.md 写明:Playwright 用 browser context 实现测试隔离,每个测试有自己的 browser context,运行测试时每次都会新建一个;当你把 Playwright 当 Test Runner 用时,browser context 是默认创建的,否则你要手动创建。

docs/src/test-api/class-fixtures.md 补上了另一半:browser fixture 的实例是在同一个 worker 内的所有测试之间共享的,而 context fixture 是为每个测试单独创建的隔离实例,默认的 page 属于这个 context。

所以在 Test Runner 里,框架给你的 page / context / browser 都不用你收尾。会出问题的是你自己动手创建的那些。class-fixtures.mdbrowser fixture 的用法示例恰好就是这么一段:

test.beforeAll(async ({ browser }) => {
  const page = await browser.newPage();
  // ...
});

这里 browser.newPage()docs/src/api/class-browser.md 里那个便捷 API,文档写明它会在一个新的 browser context 里创建 page,关掉这个 page 也会连带关掉那个 context,并且明确说它只应该用于单页场景和短代码片段,生产代码和测试框架应当显式地先 Browser.newContext()BrowserContext.newPage(),以便控制它们各自的生命周期。在 beforeAll 里开的这类 page,谁开谁收。

library 模式:文档 note 说得很直白

不走 Test Runner 时,Browser.newContext() 的文档挂了一条 note:如果直接用这个方法创建 context,最佳实践是在用完之后、并且在调用 Browser.close() 之前,显式地用 BrowserContext.close() 关掉它;这样能保证 context 被优雅关闭,并且 HAR、录像这类产物被完整刷盘保存。

Browser.close() 自己那条 note 说的是同一件事的反面:它类似于强制退出浏览器;想让 page 优雅关闭、想收到 page 的 close 事件,就要在调用 Browser.close() 之前,对你早先用 Browser.newContext() 显式创建的每个 context 调用 BrowserContext.close()

这就解释了前面那个「进程没了但录像是空的」的现象。docs/src/videos.md 里逐字写着:录像是在 browser context 关闭时保存的,手动创建 context 的话要记得 await BrowserContext.close()。文档里给的片段是这样的:

context = await browser.new_context(record_video_dir="videos/")
# Make sure to await close, so that videos are saved.
await context.close()

上面是 videos.md 里的 python async 代码块原文,videos/ 只是仓库示例里的取值。同一节还给了 python sync、Java、C# 的对应写法,写哪个语言就照那个语言的块抄,别拿 JS 的驼峰命名往 Python 上套。

另外,BrowserContext.close() 有一条限制要记住:文档 note 写明默认的 browser context 不能被关闭Browser.close()BrowserContext.close() 都带一个 reason 选项(文档标注 since: v1.40),语义是把这个原因报告给被关闭动作中断掉的那些操作——它是给你留排查线索用的,不改变关闭行为。

launchPersistentContext:没有 Browser 给你关

docs/src/api/class-browsertype.mdBrowserType.launchPersistentContext() 的返回类型是 BrowserContext 而不是 Browser。文档写明:它启动一个使用 userDataDir 持久化存储的浏览器,返回唯一的那个 context关闭这个 context 会自动关闭浏览器

所以这条路上你手里根本没有 Browser 对象可关,去找 browser.close() 是找不到的,关 context 就是全部。文档还提醒:浏览器不允许用同一个 User Data Directory 启动多个实例——这正是残留进程会让下一轮直接起不来的原因。

connect / connectOverCDP:close 不终止对面的进程

Browser.close() 的文档把两种情形分开写了:如果这个 browser 是通过 BrowserType.launch() 拿到的,close() 关掉浏览器和它已经打开的所有 page;如果是连接上去的close() 做的是清理属于这个 browser 的所有已创建 context,然后从 browser server 断开连接。

也就是说,connect() / connectOverCDP() 场景下,对面那个浏览器进程本来就不归你的脚本管,close() 之后它还在是文档写明的语义,不是泄漏。真正负责那个进程的是 docs/src/api/class-browserserver.md 里的 BrowserServer(该类文档标注 langs: js):close() 优雅关闭浏览器并确保进程被终止,kill() 直接杀掉浏览器进程并等待其退出,process() 返回被 spawn 出来的浏览器应用进程。

driver 进程是另一层

浏览器之外还有一层。Java 的 Playwright.close()(文档标注 langs: java)说明是:终止这个 Playwright 实例,并会关掉所有已创建、仍在运行的 browserPlaywright.create() 的说明里也写了「实例不再需要时应调用 Playwright.close()」。Python 侧对应的是 Playwright.stop()(文档标注 langs: python),说明写的是:在绕过 Python context manager 创建实例的情况下终止这个实例,对 REPL 场景有用。

第三步:异常路径上的清理

上面所有的 close 调用,只要写在「正常流程的最后一行」,一旦中间抛异常就会被跳过——这是残留进程最常见的来源。仓库各语言入门文档给的写法本身就带了兜底:

docs/src/library-python.md 的示例用的是 with sync_playwright() as p:async with async_playwright() as p:docs/src/api/class-browser.md 的 Java 示例用的是 try (Playwright playwright = Playwright.create()) { ... }docs/src/library-csharp.md 用的是 await using var browser = await playwright.Chromium.LaunchAsync();。这三种都是语言自带的作用域退出即释放机制,异常路径上照样触发。

JS 侧没有对应的语法糖,得自己写 finally

const browser = await chromium.launch();
try {
  const context = await browser.newContext();
  const page = await context.newPage();
  // ...
  await context.close();
} finally {
  await browser.close();
}

以上为按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。注意 context.close() 放在 try 内、browser.close() 放在 finally,顺序上仍然满足前面那条「先 context 后 browser」。

还有一类是 Ctrl-C 打断。docs/src/api/params.md 里定义了三个启动选项:handleSIGINT(在 Ctrl-C 时关闭浏览器进程)、handleSIGTERM(在 SIGTERM 时关闭)、handleSIGHUP(在 SIGHUP 时关闭),三个的默认值文档都写的是 true——这是仓库当前文档里的默认值,随版本可能变动。至于这三个选项在 Windows 上具体如何映射,我们在仓库文档里没有找到相关说明,别按 POSIX 的直觉去推。

第四步:处置完怎么验证

按顺序做三件事:

  1. 代码侧:在退出前把当前 context 的个数打出来,应当是 0;Browser.isConnected()close() 之后应当反映为已断连。这里注意语言差异——class-browser.mdjs 代码块里写的是 browser.contexts().lengthpython async / python sync 两个块里写的是 len(browser.contexts)(Python 侧是属性不是方法),Java 块里是 browser.contexts().size(),C# 块里是 browser.Contexts.Count。照你用的那个语言的块抄。也可以挂上 Browser.disconnectedBrowserContext.close 两个事件的监听,确认它们真的触发过——文档已经写明这两个事件各自的触发原因,对不上就说明你关的不是你以为的那个东西。
  2. 产物侧:这一步比进程列表更能说明问题。videos.md 写明录像只有在 page 或 browser context 关闭之后才可用;如果录像和 HAR 都正常落盘了,说明 context 是走优雅关闭路径退出的,而不是被 Browser.close() 连根拔掉的。
  3. 系统侧:再跑一遍前面那条 Get-Process / ps,确认进程数回到基线。持久化目录那条路,额外再验一次同一个 userDataDir 能不能立刻重新启动。

什么情况说明不是这个原因

最后这一段比前面都重要,免得你在关闭责任上死磕一个别的毛病:

  • 你用的是 connect()connectOverCDP()。那个浏览器进程不是你的脚本起的,close() 按文档就只断开连接、清理 context,它继续存在是预期行为。要终止进程得从起它的那一端下手。
  • 你走的是 Test Runner,并且没有手动 newContext() / newPage()。此时 context 由框架按测试创建、browser 是 worker 级共享的,关闭责任不在你的测试代码里,往这个方向改不会有效果。
  • launchPersistentContextuserDataDir 指向了 Chrome 主用户目录class-browsertype.md 挂了一条 warning:由于近期的 Chrome 策略变化,自动化默认 Chrome 用户配置文件是不被支持的,把 userDataDir 指向 Chrome 主 “User Data” 目录可能导致页面加载不了或浏览器直接退出。这是另一个问题,不是你忘了 close。
  • 残留的进程不是浏览器。Playwright 还有一层 driver 进程(Java 的 Playwright.close()、Python 的 Playwright.stop() 管的就是它)。分不清进程属于哪一层,就先看进程名,再决定该调哪一个 close。

一句话收尾:Test Runner 帮你关,library 模式你自己关且顺序是「先 context 后 browser」,持久化模式关 context 就等于关浏览器,连接模式 close 不杀进程。这四条各自都在仓库文档里有出处,记混了就会在残留进程上反复吃亏。


本文依据 github.com/microsoft/playwright 仓库于 2026-08-18 的公开内容整理, 事实来自仓库内的文档与源码。我们没有对文中涉及的功能做过实测, 因此不涉及运行速度、稳定性与实际表现的任何描述。 该项目迭代频繁,文中涉及的 API 签名、配置项与默认值随版本变动,请以仓库最新内容为准。 本文不涉及浏览器版本矩阵与版本清单,相关信息请以官方发布说明为准。

文中出现的 Get-Process / ps 两条命令为通用系统排查做法,非该项目官方内容。 安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。