pdf.js detach 血案:structuredClone transfer 如何吃掉你的 ArrayBuffer

这是一个「测试全绿、真机全灭」的经典坑:罪魁祸首不是我们的代码,而是一个对库行为的错误假设------传给 pdf.js 的输入缓冲,默认会被它「吃掉」。

案件现场:单测全绿,真机全灭

给我们的桌面 AI 应用做图片型 PDF OCR 时,链路是:pdf.js 渲染页面成 PNG → 视觉模型/tesseract 逐页识别。开发完成后单测 9 例全绿,合并前做真机端到端验证------两份真实扫描件,全部失败。

现象非常诡异:

  • 单页 PDF 正常;
  • 多页 PDF 第一页正常,第二页起全部抛错;
  • 视觉路线和 tesseract 路线同时失败------两条路唯一的公共部分就是 pdf.js 渲染。

报错信息是 DataCloneError: Cannot transfer object of unsupported type。这个错误名当时完全没引起警觉------我们没有手动 transfer 任何东西。

定位:插桩 Worker.prototype.postMessage

排查的第一步是把「哪一层挂的」切清楚。OCR 链路有四层:runner 编排 → pdf.js 渲染 → canvas 光栅化 → 模型识别。单测里 pdf.js 和 canvas 都是 mock 的,所以单测绿不能证明渲染层健康------mock 全绿只证明你的编排逻辑对,不证明你的依赖行为对。

在 Node 脚本里独立复现后,对 Worker.prototype.postMessage 插桩打调用栈,真相浮出:

text 复制代码
pdf.js 在 Node 环境没有真 worker,走 fake-worker 的 LoopbackPort
  → LoopbackPort.postMessage 内部执行:
      structuredClone(obj, { transfer: [data.buffer] })
  → transfer 语义:缓冲的所有权被移交,原对象被 detach
  → 输入的 Uint8Array 在第一次 getDocument 之后 byteLength 变成 0

关键在 structuredClone 的 transfer 选项:它不是「拷贝」,是移交 。被 transfer 的 ArrayBuffer 在原侧立即失效(detached),byteLength 归零,任何后续读写都抛错。

而 runner 初版是这样写的:

typescript 复制代码
// ❌ 每页渲染重新打开同一份 PDF 数据
for (const pageNumber of pageNumbers) {
  const doc = await pdfjs.getDocument({ data }).promise;  // 第二次起 data 已 detach
  const page = await doc.getPage(pageNumber);
  // ... render
}

第一页:getDocument(data) → pdf.js 内部 transfer → data 被吃掉,但这次渲染用掉了,正常产出。第二页:再传同一个 data------已经是 byteLength=0 的空壳,structuredClone 尝试 transfer 一个 detached buffer → DataCloneError。

独立脚本验证只用了五行:

javascript 复制代码
const data = new Uint8Array(await fs.readFile('scan.pdf'));
const doc1 = await pdfjs.getDocument({ data }).promise;
console.log(data.byteLength);  // 0  ← 已被吃掉
await pdfjs.getDocument({ data }).promise;  // 💥 DataCloneError

修复:两个层面

修复一(止血):所有 getDocument 传副本。

typescript 复制代码
// pdf-render.ts(修复后)
const doc = await pdfjs.getDocument({ data: data.slice(), isEvalSupported: false }).promise;

data.slice() 产生一个新缓冲,detach 的是副本,调用方持有的原件安然无恙。文字层提取路径同样修。

修复二(结构):整份 PDF 只打开一次。

typescript 复制代码
export async function renderPdfPagesToPng(
  data: Uint8Array,
  pageNumbers: readonly number[],
  scale: number,
  maxPixels?: number,
): Promise<RenderedPdfPage[]> {
  const doc = await pdfjs.getDocument({ data: data.slice(), ... }).promise;
  try {
    for (const pageNumber of pageNumbers) { /* 逐页渲染,复用同一个 doc */ }
  } finally {
    await doc.destroy();
  }
}

即使有了副本兜底,「每页重新 getDocument」也是错的:PDF 解析(xref 表、字体、对象图)是有成本的,20 页文档解析 20 次纯属浪费。重构后 runner 每引擎只打开一次文档,批量渲染全部目标页(产出 Map<pageNumber, png>),再逐页送识别。ocr-runner.test.ts 里有一条专门的防回归断言:「整份 PDF 只批量渲染一次」------把这次事故的教训固化成了测试。

为什么 mock 测不出来

这个 bug 值得深想的一层是:它在单测体系里是结构性不可见的。

单测约定是「外部调用一律 mock」------pdf.js 被 mock 成一个返回固定 page 对象的对象。mock 的行为是我们想象的行为:拿数据、返回文档、随便调用多少次都行。而真实 pdf.js 的 transfer 语义是一个我们没想象到的行为。

这不是说 mock 错了------mock 仍然是编排逻辑的最佳测试方式。而是说:凡是带所有权语义的 API(transfer、stream、文件描述符、锁),mock 一定会把所有权语义抹平。这类 bug 只有两种防线:

  1. 真机端到端验证留在交付流程里。这次教训之后,项目宪法加了一条:涉及原生模块和本地模型的链路,mock 全绿只是入场券,真机实测才是交付线。OCR 修复后真实扫描件 8 秒识别双页、文本完整入库,这个验收动作后来写进了每一个涉及摄入链路的交付清单。
  2. 输入缓冲的所有权视为移交而非出借。调用任何带 worker/transfer 语义的库,传入的数据默认当作「会被吃掉」------要么传副本,要么调用方不再使用原件。这条已写进项目记忆。

同案的另外两个小坑

真机排查过程顺带修了两个同模块的问题:

  • OcrFailedError 吞掉 cause :自定义错误构造只存 message,底层的 DataCloneError 被吃掉------排查初期看到的报错完全没有上下文。修复为 (message, { cause }) 透传错误链。错误类不透传 cause,等于主动掐断未来的排查线索。
  • tesseract 重复 terminate 抛异常 :worker 在正常结束路径和超时兜底路径都会被 terminate,第二次调用抛 rejection。修复为吞掉重复终止。清理逻辑必须幂等------超时兜底和正常收尾同时触发清理是并发系统的常态。

小结

这个 bug 的完整画像:一个正确的库行为(structuredClone transfer 是标准语义)、一个错误的调用假设(输入缓冲可复用)、一套恰好掩盖它的测试策略(mock 抹平了所有权语义)、一次兜底了它的流程(真机端到端验证)。四层任何一层变化都会改变故事走向------这也是它值得单独成篇的原因:它是「测试策略边界」的最佳标本。

下一篇 B08 讲 OCR 链路的另一个真机坑:本地视觉模型的像素预算------870 万像素的 A4 扫描件如何撞穿 qwen2.5-vl 的 4096 上下文。

相关推荐
狂师11 小时前
GitHub上开源真机测试工具mobile-mcp,说句话就能让AI在手机上跑用例!
人工智能·开源·测试
三翼鸟数字化技术团队1 天前
测试人员代码阅读实战指南
测试
得物技术1 天前
Jev 实战:为AI工作流引入轻量决策层 x10倍提速|得物技术
python·llm·测试
挖掘狂人1 天前
AI 代码上线前,真正的瓶颈不在 "写",而在 "验"
ai编程·测试·代码规范
hpoenixf1 天前
测试比实现还多之后:怎样让文档、代码和测试一起向前
测试
七夜zippoe2 天前
软件工程3.0时代,大模型驱动测试实战指南
大模型·实战·软件工程·测试·驱动软件
小凯在掘金3 天前
测试规范:为什么优先用 data-testid,而不是 CSS 选择器?
单元测试·测试