pnpm递归执行报错:ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL排查与解决
前两天测试环境的发布窗口差点被两个测试工程师联手搞出事故。他们一个在 A 终端、一个在 B 终端几乎同一秒按下了前端发布脚本的启动键结果其中一方的终端就砸下来一行刺眼的报错ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL。第一眼看到这个错误码我以为是 pnpm 下载失败之类的网络波动但仔细一琢磨不对——这个错误码跟“递归执行”强相关而且前缀ERR_PNPM_RECURSIVE已经明明白白指向了 pnpm 的递归命令执行机制。这个场景其实特别典型前端发布、多人协作、同时操作只要没有做好并发保护早晚会撞出问题。这篇文章就结合这次事故把ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL的前因后果讲透同时把排查思路和解决方案完整还原出来。如果你是前端工程师、测试工程师或者负责维护前端构建发布流程的 DevOps这篇文章值得仔细看一遍。1. 事故现场还原两个终端同时重启到底发生了什么1.1 操作背景与报错现象我们团队的前端测试环境是典型的 monorepo 结构根目录用 pnpm workspace 管理多个子包。发布流程很简单在项目根目录执行pnpm install然后执行pnpm build最后把产物同步到测试服务器。为了解决依赖重复安装的问题整个团队都启用了 pnpm 的全局 store所有项目共享同一个依赖缓存目录。那天的情况是这样的测试工程师 A 准备回归某个功能在终端里跑起了发布脚本与此同时测试工程师 B 从自己电脑上通过 SSH 登录到同一台测试服务器也手动执行了同一套发布命令。两个人几乎在同一时间点按下了回车。结果就很有意思了A 的终端一切正常B 的终端直接抛出了ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL整个发布流程中断。B 找到我的时候还以为是自己的终端环境坏了甚至怀疑是网络问题导致 pnpm 下载失败。1.2 我的第一判断这不是普通的依赖安装失败看到这个错误码我的第一反应是去翻 pnpm 的文档和源码里的错误定义。ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL这个错误码的触发机制很明确当 pnpm 以递归模式也就是pnpm -r或在 workspace 根目录执行批量命令运行脚本时只要其中任何一个子进程执行失败pnpm 就会终止整批调度并把第一个失败的原因封装成这个错误码抛出来。所以在 B 的案例里这个错误码不是“根因”而是“结果”。真正的问题是他执行的递归构建任务中某个子任务失败了。至于子任务为什么失败需要往更底层挖。我当时在群里跟两位测试工程师确认了三件事是否共享同一台服务器是否在同一时间执行了发布脚本是否用了同一个 pnpm store答案全部是肯定的。到这里我基本可以锁定问题的方向并发执行导致的资源争抢。2. 深入理解 pnpm 递归执行与 store 锁的底细2.1 pnpm 的递归执行机制在 monorepo 中pnpm -r run build是再常见不过的命令。它的工作方式是扫描 workspace 下所有子包然后根据 CPU 核心数和配置的并发数同时启动多个子进程去执行对应的脚本。每个子进程都是一个独立的 node 进程拥有自己独立的脚本执行上下问。这个设计本身没有错但问题在于多个子进程在并发执行时会对底层资源产生竞争。最常见的竞争点有两个第一node_modules/.pnpm目录。pnpm 在安装依赖时会在node_modules/.pnpm下创建符号链接和硬链接结构如果两个递归任务同时对这个目录做写入文件级别就会产生冲突。第二全局 store 目录。pnpm 为了保证安装效率把依赖包统一存放在全局 store 中项目只是通过硬链接引用 store 中的文件。当两个 pnpm 进程同时向 store 写入、处理或校验文件时store 对应的锁文件就会被占用另一个进程只能等待。2.2 为什么并发重启会触发ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL回到事故现场。测试工程师 A 和 B 同时执行发布脚本由于他们共享同一个全局 store 和同一份 node_modules那么下面这个顺序就很有可能发生A 的 pnpm 实例先获得了 store 的写锁开始安装或构建。B 的 pnpm 实例尝试访问 store发现锁被占用进入等待或直接报错。B 的 pnpm 实例在某个子包上执行install或build时因为底层文件被 A 锁定或正在被修改操作失败。这个失败被 B 的递归调度器捕获由于RECURSIVE_EXEC_FIRST_FAIL是首个失败的信号pnpm 直接中止了 B 的整个发布流程。整个过程用一句话概括就是并发执行同一个 workspace 的递归命令时子任务的失败被递归调度器包装成了统一的错误码而真正的冲突发生在 pnpm 的 store 和 node_modules 层面。当然还有一种可能两个测试工程师在完全相同的路径下执行了构建脚本其中一方删除了某个文件比如.pnpm下的临时文件而这个文件恰好是另一方正在读取的。这种文件级竞争同样会导致子进程失败。2.3 为什么“重启”这个词会误导人标题里用到了“重启”这个词这非常容易让人联想到服务器重启、系统重启然后误以为是操作系统层面的问题。但在这个场景里“重启”其实是测试工程师对“重新执行发布脚本”的简称意思是重新启动发布流程。如果只盯着“重启”两个字很容易走偏去查什么开机自启、进程守护、系统日志白白浪费时间。所以我想特别强调遇到 pnpm 的递归执行错误不要被“重启”带偏优先把焦点放在共享资源与并发冲突上。3. 排查链路从报错到锁定真凶的完整步骤3.1 第一步抓取完整错误日志而不是只看错误码B 最初截图给我看的时候只截了最后一行错误码。我让他把完整的终端滚动区域全部重新截图尤其要看错误码上面打印的具体内容。这是排查所有异常的第一步也是最容易被忽略的一步。pnpm 递归执行失败时通常会先打印出是哪一个子包、执行了哪一个脚本、退出码是什么然后才是最终的ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL。如果这些上下文被忽略排查方向很容易跑偏。B 补发了完整日志之后我注意到一个关键细节日志倒数第三行显示的是某个子包执行pnpm install --frozen-lockfile时的ENOTEMPTY错误。这说明 B 的进程在安装依赖时去写某个目录结果发现目录非空而且内容已经变了。这正是并发写入冲突的典型特征。3.2 第二步确认是否有多个 pnpm 进程同时存在在服务器上直接执行下面这条命令可以快速确认是否有并发的 pnpm 任务ps aux | grep pnpm | grep -v grep我当时在测试服务器上跑了一下结果看到一堆 pnpm 相关进程其中既有 A 的递归调度进程也有 B 那边残留的子进程。这个结果是并发争抢最直接的证据。顺便提醒一句ps aux的输出里会混杂很多 node 子进程因为它们是通过 node 跑起来的。想看得更干净可以加上项目路径过滤ps aux | grep /path/to/your/monorepo | grep -v grep3.3 第三步检查 pnpm store 锁文件pnpm 的全局 store 在读写时会生成锁文件。不同版本和不同操作系统锁文件的存放位置和名字可能有所不同。最常见的是在 store 目录下生成一个.lock或类似命名的锁文件。如果你的 store 目录配置在默认位置可以直接这样查ls -la ~/.pnpm-store/v3/ | head -20如果当前有另一个 pnpm 进程正在写 store你会看到锁文件处于活跃状态。再配合lsof查一下是哪个进程占用了它lsof D ~/.pnpm-store/v3/ | grep -E lock|pnpm这一步能把“锁被谁占用了”这件事坐实。3.4 第四步复现实验验证并发冲突为了确认问题不是偶发的我在本地复现了一次。我开了两个终端在同一个 monorepo 目录下同时执行pnpm install --frozen-lockfile两边几乎同时启动结果大约五秒后其中一个终端就出现了与 B 几乎一样的错误错误码同样是ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL底层日志同样包含ENOTEMPTY或EEXIST。到这里整个因果链就完整了两个测试工程师同时重启发布脚本 → 两个 pnpm 递归任务并发执行 → 访问同一个 pnpm store 和同一份 node_modules → 子任务在文件层面产生竞争 → 递归调度器中止任务并报ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL。4. 五种立竿见影的解决方案4.1 方案一直接终止占用锁的进程快速恢复现场如果你是值班工程师遇到线上或测试环境正在发布最快速的处理方式就是判断哪个进程是“多余的”然后终止它。# 查看所有 pnpm 相关进程 ps aux | grep pnpm | grep -v grep # 找到占用 store 锁的进程 PID确认后终止 kill -9 PID这个方案只是止血不是根治。万一杀错了进程可能导致另一个本来正常的发布任务被中断。所以操作前一定要确认 PID 对应的命令行参数是否属于你要终止的那次操作。4.2 方案二限制 pnpm 的 workspace 并发数pnpm 的递归命令默认会根据 CPU 核心数来决定并发子进程数量。为了让它在同一时刻少碰点资源可以显式降低并发数pnpm -r --workspace-concurrency1 run build如果你是在根目录执行pnpm build需要先把参数透传进去。具体用哪种写法取决于 pnpm 版本。较新的 pnpm 版本支持在 workspace 根目录的.npmrc中配置workspace-concurrency1或者直接在命令行里指定pnpm --workspace-concurrency1 -r run build这样做能明显降低子任务之间互相抢文件的概率但要注意并发数设为 1 后构建时间会明显变长尤其是子包很多的 monorepo 项目。如果只是临时应急可以接受如果作为长期方案建议配合更细粒度的构建缓存来弥补性能损失。4.3 方案三调整 pnpm store 的并发策略或使用独立 storepnpm 本身在操作全局 store 时是有锁机制的但锁机制并不总能完全规避高并发下的竞争。如果你的团队经常有多人同时发版可以考虑给每个发布流程指定一个独立的 store 目录从源头上避免共享 store 的争抢。pnpm install --store-dir /tmp/frontend-publish-store这个方案的缺点也很明显每次发布都会重新填充 store失去了 pnpm 的缓存优势安装时间会大幅增加。所以这更适合作为应急手段而不是长期推荐做法。更好的变体是保留共享 store但通过 pnpm 的配置把同一时刻的写操作尽可能串行化。比如在.npmrc里调整并发网络请求数、文件操作的重试次数等。不过这些参数对ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL的免疫力有限真正有效的还是把并发发布进程控制住。4.4 方案四在发布脚本里加互斥锁从根本上禁止并发发布这是我最推荐的做法。既然问题出在“多个发布任务同时跑”那就让同一时刻只允许一个发布任务存在。在 Bash 脚本里用flock实现互斥锁非常方便。下面是我在项目里实际用过的发布脚本模板#!/bin/bash LOCKFILE/tmp/frontend-publish.lock # 尝试获取锁拿不到就退出 exec 9$LOCKFILE if ! flock -n 9; then echo 检测到已有发布任务正在执行请稍后再试。 exit 1 fi # 锁获取成功开始执行发布流程 echo 开始安装依赖... pnpm install --frozen-lockfile echo 开始构建... pnpm -r --workspace-concurrency1 run build echo 发布流程完成。 # 释放锁 flock -u 9这样无论有多少个测试工程师同时按下回车也只有第一次能成功获取锁后续的所有尝试都会直接退出并给出友好提示。这个方案的优点是不依赖任何第三方工具、不牵扯权限配置一个脚本文件就能搞定。4.5 方案五统一走 CI/CD禁止在服务器上手动执行发布从更长期的治理视角看问题不在于 pnpm 本身而在于“让多个工程师直接操作共享服务器”。测试环境虽然没生产环境那么严格但发布动作依然应该收敛到一个固定的入口。把发布脚本接入 CI/CD 平台之后每次发布都会由平台统一调度天然就避免了并发冲突。而且 CI 上还能做构建缓存比在服务器裸敲命令更快更可控。方案五和方案四并不冲突可以同时实施CI 里作为主入口服务器脚本加锁作为兜底。5. 各方案对比与选型建议为了让你能根据自己团队的实际情况快速选型我把上面几种方案的使用场景和效果汇总成一张表方案解决并发冲突效果落地成本适用场景终止占用进程立即恢复现场极低事故应急值班处理限制 workspace 并发数中等降低冲突概率低临时发布、小型 monorepo独立 store 目录彻底避免 store 争抢中短期的多人手动发版场景发布脚本加互斥锁彻底阻止并发发布低所有需要手动发版的环境统一走 CI/CD彻底根治较高长期规范化的团队个人建议的落地顺序是先用方案一处理眼前事故接着给发布脚本加上互斥锁最后把发布入口收敛到 CI。很多人喜欢一上来就折腾 CI但 CI 的改造周期往往比想象中长期间如果测试环境还在频繁发布锁脚本是最快见效的保护伞。6. 预防与治理怎么让这类问题不再发生6.1 在发布脚本中增加锁检测与友好提示很多时候测试工程师并不会意识到自己的操作会影响别人。与其靠口头同步不如让脚本主动检测。上面方案四里的flock -n已经能处理这个问题关键是要把提示写得足够友好让人一眼就懂当前已有发布任务在执行为避免冲突请等待其完成后再发布。这样的提示比ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL对非开发人员友好得多。6.2 规范前端发布流程明确责任人测试环境虽然“测试”二字当头但多人共享时依然需要流程约束。我在团队里做了这么几件事指定一名发布协调人需要发版时先找他确认当前是否空闲发布操作集中在固定的脚本入口不鼓励手动敲 pnpm 命令每次发布完成后在团队的协同工具里留一条记录避免别人不知道刚发完。这些习惯建立之后测试工程师之间的相互干扰会少很多。6.3 监控关键资源提前发现并发隐患对于规模大一点的前端团队可以在测试服务器上加一个简单的监控统计 pnpm 进程数量超过阈值就告警。这个成本不高但对提前发现并发风险很有帮助。有一个很简单的思路定一个 cron 任务每分钟检查一次当前跑动的 pnpm/发布相关进程数超过 2 个就发告警通知到运维群里。不用依赖复杂的监控系统一条命令就能实现/bin/sh -c ps aux | grep -c pnpm.*publish展开一点说告警的意义不只是预防ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL这一个错误而是帮你建立对发布环境的整体掌控感。很多故障之所以难排查就是因为没有日志、没有告警、没有进程画像出了问题全凭猜。7. 复盘与后续把错误码当作信号而不是敌人这次事故的根因并不复杂但我复盘时最大的收获是像ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL这种错误码表面上看起来非常唬人似乎是什么高端的环境问题或 pnpm 内部 bug实际上它只是一层包装。真正该关注的是包装之下的原始日志里暴露了哪个文件、哪个目录、哪个子进程出了问题。我现在处理 pnpm 报错时会先问自己三个问题而不是直接百度错误码这个错误码是底层原因还是上层封装我这次是否有并发操作同一个 workspace 或同一个 store完整日志里错误码之前的那几行内容是什么三个问题问完百分之八十的 pnpm 疑难杂症都能定位到方向。如果你也遇到了类似的报错别急着删node_modules别急着重启服务器先按文章里的排查链路走一遍看完整日志、查并发进程、查 store 锁、复现验证。然后再决定是加锁、降并发还是收口发布入口。我个人的体会是这类问题最终考验的不是对 pnpm 某个参数的记忆而是对“共享资源必然存在竞争”这件事的敏感度。只要意识到并发是根因你的解决方案就会很自然地涌现出来。