Angular由一个bug说起之二十五:CI中PDF测试清理踩坑记录

一、CI 上不定时的崩溃

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

不是每次必现,但一出现就直接挂 CI,连失败截图都来不及生成。排查调用链发现:readdirSyncunlinkSyncrmdirSync 之间没有任何原子性保证。

这不是 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-pdfensureAndCleanupPath 内部调用 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();

为什么 jasmineDoneafterAll 更可靠? 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 并发、长期维护的项目。


以上三种解法中的工具函数(safeEmptyDirregisterCleanupPathcreateTempDiremptyDirWithRetry)可封装为 test/helpers/fs-utils.js 直接 require 复用。

通用方法论:当围绕着工具修补费劲时,可以接管控制权

这类问题本质是「抽象泄漏」:compare-pdf 把文件系统操作封装成 cleanPngPaths 开关,但没暴露清理时机、重试策略、错误容忍度。我们的应对:

  • 降级抽象:关掉自动模式,回到原始 API(rimraf
  • 提升控制粒度:从「库决定何时清」变成「我们决定清什么、何时清、清几次」
  • 绑定生命周期:选择 jasmine.execute() 而非 afterAll

这套思路能迁移到:

  • Cypress 的 cy.task() 文件清理
  • Puppeteer 截图临时目录管理
  • 任何基于 fs-extragraceful-fs 的并发文件操作场景

附参考链接:

相关推荐
xutSwIpZotzM2 个月前
永磁同步模型转矩预测控制(三矢量):消除权重系数策略的探索
angular
KenkoTech2 个月前
Angular进阶之十七:PostgreSQL View层性能优化:正则提取 vs 直接存储ID
angular
KenkoTech3 个月前
Angular进阶之十六:使用 mat-button 替换 Bootstrap button 二:借助 AI 提升效率
angular
DEMO派3 个月前
前端如何防止接口重复请求方案解析
前端·vue.js·react.js·前端框架·angular
KenkoTech3 个月前
Angular进阶之十五:使用 mat-button 替换 Bootstrap button 一:实战迁移与落地
angular
KenkoTech3 个月前
Angular由一个bug说起之二十三:记一次“时好时坏”的CI测试的debug过程
angular
添加shujuqudong1如果未回复4 个月前
Comsol多场耦合:解锁地质能源开采新视野
angular