Bun + Playwright 在 Windows 上卡死 180s:ws 解析 101 + 5-fd 管道双重陷阱
一个桌面 RPA 工具,Tauri sidecar 把 bun.exe 重命名成业务名称跑 Playwright 自动化。Mac 端完全正常,Windows 客户端打开自动化功能后黑屏等待整整 5 分钟,最终报错 launch: Timeout 180000ms exceeded。日志里找不到 chrome 崩溃、权限拒绝、路径问题——chrome 进程明明 spawn 成功了,但 Playwright 就是永远启动不起来。
排查过程里最令人迷惑的一点是:PowerShell 原生 WebSocket 客户端连同一个 chrome ws 端口完全正常,Bun 自身的 globalThis.WebSocket 也能连——偏偏是走 Playwright 就不行。这不是 chrome 的问题,是 Bun 和 Playwright 之间的兼容性问题,而且不是一个 bug,是两个。
现象
DEBUG=pw:browser日志显示两种模式轮流出现:- 模式 A:
<ws connecting>→<ws unexpected response> 101 WebSocket Protocol Handshake→code=1006 reason=→ 超时 - 模式 B:
<launching>chrome 命令 →<launched> pid=xxxxx→ 完全静默 → 180s 超时
- 模式 A:
- Mac / Linux 同款 sidecar 跑正常,只有 Windows 复现
- Bun 1.3.x(1.3.9 / 1.3.14 均中招)
node --version在进程环境里不存在——确认是纯 Bun runtime
根因
Bug 1:bundled ws 包解析 chrome 101 响应失败
Playwright 1.59 把 ws npm 包 bundle 进了 playwright-core/lib/utilsBundle.js,transport.ts 里的 WebSocket 客户端取的是这个内置版本:
// playwright-core 内部
ws = require("./utilsBundle").ws;Bun 对 ws 包做了 runtime 兼容层,但这个 bundle 走的是 Bun 对 require("./utilsBundle") 的解析路径——Bun runtime 没有机会用自己的 native WebSocket 替换掉 bundle 里的 ws 实现。Bun 1.3.x 的这条 ws compat 路径在解析 chrome 返回的 101 Switching Protocols upgrade response 时有 bug,导致 unexpected-response 事件立即触发,连接 code=1006 关闭。
相关 issue:Bun #5951 ws upgrade / unexpected-response 事件未实现。
Bug 2:5-fd 子进程 stdio 在 Windows 不可靠
Playwright headless launch 默认走 --remote-debugging-pipe --no-startup-window 模式,CDP 通信走 stdio 的 fd 3 和 fd 4(共 5 个 fd)。Bun 在 Windows spawn 子进程时,['ignore','pipe','pipe','pipe','pipe'] 这种 5-fd 配置不可靠——chrome 进程确实被 spawn 出来了(有 pid),但 playwright 一侧永远收不到 CDP 的第一个 hello 包,于是静默等待直到 180s 超时。
相关 issue:Bun #4670 extra stdio streams not implemented。
Bun 在 Windows 上的子进程 stdio 实现和 ws compat 层都与 macOS / Linux 不一致。用 Bun 做跨平台 sidecar 时,如果 playwright 在 Windows 上无论如何都超时,要同时排查这两条路径,不要只改其中一个。
正解
两层 fix 都要上,缺一不可。
Fix 1:postinstall patch 让 Bun runtime 劫持 require('ws')
思路来自社区方案(Mateusz Kelner blog):在 postinstall 里把 coreBundle.js 里的 bundled ws 引用改成真实的 require("ws"),这样 Bun runtime 就能用自己的 native WebSocket polyfill 接管,绕开 bundle 里的有 bug 的实现。
// scripts/patch-playwright-ws.mjs
// idempotent + sentinel,npm install / build 时自动跑
const TARGETS = [
{
orig: 'ws = require("./utilsBundle").ws;',
patched: `ws = /*PATCHED_BUN_REQUIRE_WS*/ (typeof Bun !== "undefined" ? require("ws") : require("./utilsBundle").ws);`
},
{
orig: 'ws2 = require("./utilsBundle").ws;',
patched: `ws2 = /*PATCHED_BUN_REQUIRE_WS*/ (typeof Bun !== "undefined" ? require("ws") : require("./utilsBundle").ws);`
},
]
// 1. readFileSync · 检查 sentinel,已 patch 直接跳过
// 2. 依次 replace,记录替换数
// 3. 0 次替换 → console.warn(playwright 升级可能换了 bundle 结构,不要 throw)
// 4. writeFileSyncpackage.json 里挂上:
{
"scripts": {
"postinstall": "node scripts/patch-playwright-ws.mjs",
"build": "tsc && node scripts/patch-playwright-ws.mjs"
}
}patch 后运行时会看到 [bun] Warning: ws.WebSocket 'upgrade' event is not implemented in bun,这是正常的——localhost CDP 不会触发 upgrade redirect,忽略即可。
Fix 2:Windows 上改用 --remote-debugging-port=0 + connectOverCDP 绕开 5-fd
自己 spawn chrome,从 stderr 解析 DevTools listening on ws://...,再用 chromium.connectOverCDP(wsUrl) 接入——完全绕开那条不可靠的 5-fd pipe 路径:
const SHOULD_FORCE_RDP =
process.platform === 'win32' || process.env.PW_FORCE_REMOTE_DEBUGGING_PORT === '1'
async function launchChromium(opts: LaunchOptions): Promise<Browser> {
if (SHOULD_FORCE_RDP && opts.headless !== false) {
return launchViaRemoteDebuggingPort(opts)
}
try {
return await chromium.launch({ ...opts, timeout: 30_000 })
} catch (err) {
// mac/linux pipe 失败也 fallback
if (/Timeout|exceeded|remote-debugging-pipe|disconnected/i.test(String(err))) {
return launchViaRemoteDebuggingPort(opts)
}
throw err
}
}
async function launchViaRemoteDebuggingPort(opts: LaunchOptions): Promise<Browser> {
const exe = resolveHeadlessShellPath()
const userDataDir = await fs.mkdtemp(path.join(process.env.TEMP || '/tmp', 'chromium-rdp-'))
const child = spawn(exe, [
...DEFAULT_LAUNCH_ARGS,
'--headless=new', '--disable-gpu',
`--user-data-dir=${userDataDir}`,
'--remote-debugging-port=0', // OS 分配随机端口
], { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
// 从 stderr 正则匹配 'DevTools listening on (ws:\\S+)'
const wsUrl = await waitForDevToolsUrl(child, 30_000)
const browser = await chromium.connectOverCDP(wsUrl, { timeout: 30_000 })
// 必须双向兜底:browser 断开时 kill chrome + 清临时目录
browser.on('disconnected', () => {
child.kill()
fs.rm(userDataDir, { recursive: true, force: true }).catch(() => {})
})
child.on('exit', () => { browser.close().catch(() => {}) })
return browser
}Fix 1(patch bundled ws)解决 WebSocket 握手问题;Fix 2(自 spawn + connectOverCDP)解决 5-fd stdio 问题。实测 elapsed=1.1s gotResp=True,之前是 elapsed=31.2s gotResp=False (timeout),最差情况等 180s。
Fix 1 的第一个版本是手写 130 行 BunWsAdapter class 包装 globalThis.WebSocket。调研后才发现社区早有「让 Bun runtime 劫持 require('ws')」这条更干净的路——简洁 10 倍,且跟 Bun 版本演进自动对齐。任何需要 patch 第三方包的场景,先去 GitHub Issues 和博客搜一圈再动手。
一句话外卖
Bun Windows runtime 的 5-fd 子进程 stdio 和 bundled ws 解析都有 bug,跨平台 sidecar 用 Playwright 时必须同时修这两条路径,Mac 跑通不等于 Windows 跑通。