跑完还剩一堆浏览器进程:Playwright 资源没释放的几处常见原因
现象
脚本明明跑完了,进程列表里还挂着一排浏览器进程;连着跑几轮之后机器开始变慢,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.md 与 docs/src/api/class-browsercontext.md 里都有明确签名:
Browser.contexts()返回当前所有打开的 browser context 数组。文档里的示例写得很直白:新创建的 browser 打印出来是0,newContext()之后是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.md 里 browser 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.md 里 BrowserType.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 实例,并会关掉所有已创建、仍在运行的 browser;Playwright.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 的直觉去推。
第四步:处置完怎么验证
按顺序做三件事:
- 代码侧:在退出前把当前 context 的个数打出来,应当是 0;
Browser.isConnected()在close()之后应当反映为已断连。这里注意语言差异——class-browser.md的js代码块里写的是browser.contexts().length,python async/python sync两个块里写的是len(browser.contexts)(Python 侧是属性不是方法),Java 块里是browser.contexts().size(),C# 块里是browser.Contexts.Count。照你用的那个语言的块抄。也可以挂上Browser.disconnected与BrowserContext.close两个事件的监听,确认它们真的触发过——文档已经写明这两个事件各自的触发原因,对不上就说明你关的不是你以为的那个东西。 - 产物侧:这一步比进程列表更能说明问题。
videos.md写明录像只有在 page 或 browser context 关闭之后才可用;如果录像和 HAR 都正常落盘了,说明 context 是走优雅关闭路径退出的,而不是被Browser.close()连根拔掉的。 - 系统侧:再跑一遍前面那条
Get-Process/ps,确认进程数回到基线。持久化目录那条路,额外再验一次同一个userDataDir能不能立刻重新启动。
什么情况说明不是这个原因
最后这一段比前面都重要,免得你在关闭责任上死磕一个别的毛病:
- 你用的是
connect()或connectOverCDP()。那个浏览器进程不是你的脚本起的,close()按文档就只断开连接、清理 context,它继续存在是预期行为。要终止进程得从起它的那一端下手。 - 你走的是 Test Runner,并且没有手动
newContext()/newPage()。此时 context 由框架按测试创建、browser是 worker 级共享的,关闭责任不在你的测试代码里,往这个方向改不会有效果。 launchPersistentContext的userDataDir指向了 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 两条命令为通用系统排查做法,非该项目官方内容。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。