返回博客

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 Handshakecode=1006 reason= → 超时
    • 模式 B:<launching> chrome 命令 → <launched> pid=xxxxx完全静默 → 180s 超时
  • 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.jstransport.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。

坑:Mac 正常不代表 Windows 正常,Bun Windows 有两个并发 bug

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. writeFileSync

package.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 缺一不可,elapsed 从 31s 降到 1s

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。

调研优先:先搜社区方案再写 monkey-patch

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 跑通。