
一、CI 上不定时的崩溃
最近在 CircleCI 上跑 PDF 视觉回归测试,不定时就挂一次:

不是每次必现,但一出现就直接挂 CI,连失败截图都来不及生成。排查调用链发现:readdirSync → unlinkSync → rmdirSync 之间没有任何原子性保证。
这不是 bug,是「并发裸奔」。
二、为什么 rimraf 在并发下会挂
Node.js 的 fs.rmdirSync 直接映射 POSIX rmdir(2) ------ 只能删空目录。社区用 rimraf 补了这个缺:先递归删文件,再逐层 rmdirSync:
js
// rimraf 的核心逻辑(简化版)
function rimrafSync(dir) {
const entries = fs.readdirSync(dir); // 1. 列出所有子项
for (const entry of entries) {
const full = path.join(dir, entry);
if (fs.lstatSync(full).isDirectory()) {
rimrafSync(full); // 2. 递归处理子目录
} else {
fs.unlinkSync(full); // 3. 删除文件
}
}
fs.rmdirSync(dir); // 4. 此时目录应该为空
}
单线程下没问题。但在并发 CI 里,步骤 3 和步骤 4 之间有一个微秒级窗口:别的 worker 可能往刚清空的目录里写入新文件,导致步骤 4 抛出 ENOTEMPTY。
Node.js v14.14 补上了原生方案:fs.rmSync(dir, { recursive: true, force: true }),递归删除委托给 OS 内核,消除了用户空间的竞态窗口。
注意:
fs-extra从 v10 起内部已迁移至fs.rm。但如果你依赖的第三方库捆绑了旧版fs-extra,升级你自己的fs-extra并不能解决问题。多进程共享同一目录的竞态,需要后文的解法二或解法三。
TOCTOU(Time-Of-Check, Time-Of-Use)是一类经典并发 bug:在你检测状态和使用该状态之间,系统状态已经被其他进程改变了。
回到案例:compare-pdf 的 ensureAndCleanupPath 内部调用 fse.emptyDirSync,而 emptyDirSync 委托给捆绑的旧版 rimraf。当两个 Jasmine 测试并行执行、共享同一个临时目录时,竞态窗口就被撕开了
方案一:重试 + 退避(应急)
最低成本方案:捕获 ENOTEMPTY 后用 Atomics.wait 做跨平台同步等待,重试 2-3 次。治标不治本,但如果竞态偶发且今天就要上线,这是最快的应急方案。核心简单:
js
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); // 同步等 100ms 后重试
方案二:关掉库的自动清理(推荐大多数场景)
compare-pdf 文档里有这个配置:
cleanPngPaths: boolean --- Whether to automatically clean up PNG directories before comparison. Default:true.
设成 false,让 compare-pdf 只管比图,我们自己在所有比对结束后统一清理。
但 "所有比对结束后" 具体是什么时机?afterAll 不够。多个 describe block 的 afterAll 可能互相交叉调用,而且它在同一个 spec 文件的生命周期内------如果有多个 spec 文件,afterAll 触发时其他 spec 可能还在跑。
真正的 "最后一刻" 是 Jasmine 的 jasmineDone 回调------它在所有 spec 文件、所有 describe block、所有 afterAll 钩子全部执行完毕后才触发。
js
// util.js
const path = require('path');
const fs = require('fs');
const ComparePdf = require('compare-pdf');
const fse = require('fs-extra');
...
// Since cleanPngPaths is false, we need to clean actualPngRootFolder and baselinePngRootFolder.
function cleanPngs() {
fse.emptyDirSync(config.paths.actualPngRootFolder);
fse.emptyDirSync(config.paths.baselinePngRootFolder);
}
...
js
// pdf-test.js
var Jasmine = require('jasmine');
var jasmine = new Jasmine();
var util = require('/util');
jasmine.loadConfig({ /* ... */ });
// jasmineDone:所有 spec → 所有 afterAll → 这里
// 这是 Jasmine 生命周期中最晚的同步回调
jasmine.addReporter({
jasmineDone: function() {
util.cleanPngs();
}
});
jasmine.env.clearReporters();
jasmine.addReporter(reporter);
jasmine.execute();
为什么 jasmineDone 比 afterAll 更可靠? Jasmine 的执行时序是:
spec 1 → spec 2 → ... → spec N
→ afterAll(关闭 jsreport 等资源)
→ jasmineDone(cleanPngs)
优点: 不涉及第三方源码,不 fork 不 patch;清理时序由框架保证------jasmineDone 是 Jasmine 最终的同步栅栏;附带产物筛选逻辑,CI 只保留失败用例的 diff 截图。
缺点: 测试运行期间 PNG 目录会累积中间文件,占用磁盘(通常可忽略)。
适用场景: 单容器内的并行测试,所有 worker 共享同一临时目录。
解法三:隔离目录
从架构上消除竞态------让每个 worker 拥有独立的临时目录,彻底避开共享路径的竞争。用 CIRCLE_NODE_INDEX(CircleCI)、JEST_WORKER_ID(Jest)或 fs.mkdtempSync 生成进程级隔离路径,然后把这个路径注入 compare-pdf 的 config:
js
const os = require('os');
const fs = require('fs');
const path = require('path');
// OS 级唯一临时目录,进程退出时自动清理
const isolated = fs.mkdtempSync(path.join(os.tmpdir(), 'pdf-test-'));
process.on('exit', () => fs.rmSync(isolated, { recursive: true, force: true }));
const config = {
paths: {
actualPdfRootFolder: `${isolated}/actualPdfs`,
baselinePdfRootFolder: `${isolated}/baselinePdfs`,
actualPngRootFolder: `${isolated}/actualPngs`,
baselinePngRootFolder: `${isolated}/baselinePngs`,
diffPngRootFolder: `${isolated}/diffPngs`
},
settings: {
cleanPngPaths: true, // 可以开回来------每个 worker 独占目录,不存在竞态
// ...
}
};
每个 worker 在自己的 /test/pdf-test-XXXXXX/ 下工作,互不干扰。cleanPngPaths 可以保持 true------因为不再有跨进程共享,emptyDirSync 不会被别人踩到。
每个 worker 都用自己的 /test/pdf-test-XXXXXX/ 目录,各跑各的,互不干扰。也正因为没有跨进程共享,cleanPngPaths 开着是安全的。
优点: 从架构上根治竞态,cleanPngPaths 都能安全打开;不 fork 不 patch,只改 compare-pdf 的路径,可通过 config 注入。
缺点: CI 产物散落在多个临时目录,需额外收集合并;如果第三方库硬编码了路径则仍需 fork。
适用场景: 高并行度 CI、多 worker 并发、长期维护的项目。
以上三种解法中的工具函数(
safeEmptyDir、registerCleanupPath、createTempDir、emptyDirWithRetry)可封装为test/helpers/fs-utils.js直接require复用。
通用方法论:当围绕着工具修补费劲时,可以接管控制权
这类问题本质是「抽象泄漏」:compare-pdf 把文件系统操作封装成 cleanPngPaths 开关,但没暴露清理时机、重试策略、错误容忍度。我们的应对:
- ✅ 降级抽象:关掉自动模式,回到原始 API(
rimraf) - ✅ 提升控制粒度:从「库决定何时清」变成「我们决定清什么、何时清、清几次」
- ✅ 绑定生命周期:选择
jasmine.execute()而非afterAll
这套思路能迁移到:
- Cypress 的
cy.task()文件清理 - Puppeteer 截图临时目录管理
- 任何基于
fs-extra或graceful-fs的并发文件操作场景
附参考链接: