☰
pnpm递归执行报错:ERR_PNPM_RECURSIVE_EXEC_FIRST_FAIL排查与解决
2026/10/8 4:08:10 网站建设 项目流程

前两天,测试环境的发布窗口差点被两个测试工程师联手搞出事故。他们一个在 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,那么下面这个顺序就很有可能发生:

  1. A 的 pnpm 实例先获得了 store 的写锁,开始安装或构建。
  2. B 的 pnpm 实例尝试访问 store,发现锁被占用,进入等待或直接报错。
  3. B 的 pnpm 实例在某个子包上执行install或build时,因为底层文件被 A 锁定或正在被修改,操作失败。
  4. 这个失败被 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 grep

3.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-concurrency=1 run build

如果你是在根目录执行pnpm build,需要先把参数透传进去。具体用哪种写法,取决于 pnpm 版本。较新的 pnpm 版本支持在 workspace 根目录的.npmrc中配置:

workspace-concurrency=1

或者直接在命令行里指定:

pnpm --workspace-concurrency=1 -r run build

这样做能明显降低子任务之间互相抢文件的概率,但要注意:并发数设为 1 后,构建时间会明显变长,尤其是子包很多的 monorepo 项目。如果只是临时应急,可以接受;如果作为长期方案,建议配合更细粒度的构建缓存来弥补性能损失。

4.3 方案三:调整 pnpm store 的并发策略或使用独立 store

pnpm 本身在操作全局 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-concurrency=1 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 报错时,会先问自己三个问题,而不是直接百度错误码:

  1. 这个错误码是底层原因,还是上层封装?
  2. 我这次是否有并发操作同一个 workspace 或同一个 store?
  3. 完整日志里,错误码之前的那几行内容是什么?

三个问题问完,百分之八十的 pnpm 疑难杂症都能定位到方向。

如果你也遇到了类似的报错,别急着删node_modules,别急着重启服务器,先按文章里的排查链路走一遍:看完整日志、查并发进程、查 store 锁、复现验证。然后再决定是加锁、降并发,还是收口发布入口。

我个人的体会是,这类问题最终考验的不是对 pnpm 某个参数的记忆,而是对“共享资源必然存在竞争”这件事的敏感度。只要意识到并发是根因,你的解决方案就会很自然地涌现出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询