返回博客

Windows 构建机 npm ci EPERM:是僵尸 node 进程锁住了原生 addon

早上推了一次 Tauri 桌面构建,控制端脚本一路顺畅,到装依赖那步突然卡死——npm ci 报了一个 EPERM: operation not permitted, unlink,指向 @tauri-apps/cli-win32-x64-msvc.node。 重跑三次、换 npm install、删 package-lock.json,全部原地复现。

现象

npm error code EPERM
npm error syscall unlink
npm error path C:\src\desktop\node_modules\@tauri-apps\cli-win32-x64-msvc\cli.win32-x64-msvc.node
npm error errno -4048
npm error [Error: EPERM: operation not permitted, unlink ...]

或者在 cleanup 阶段出现兄弟形态:

npm warn cleanup Failed to remove some directories [
npm warn cleanup   [Error: EPERM: operation not permitted, rmdir '...\entities\dist\commonjs']

脚本里已经有预清理步骤,看起来万无一失:

if (Test-Path 'node_modules') {
  Remove-Item -Recurse -Force 'node_modules' -ErrorAction SilentlyContinue
}
npm ci

根因

Windows 对 PE 格式二进制文件强制排他锁:.node 文件本质上是一个 DLL,被宿主 node.exe 通过 LoadLibrary 加载到进程地址空间后,只要该进程存活,这个文件就不可 unlink、不可 rename、不可替换——这是 Windows 文件系统的内核语义,不是 npm 的 bug。

SSH 驱动的 Windows 构建机上,以下链条制造僵尸:

  1. 控制端(Mac/Linux)通过 SSH 触发 PowerShell → PowerShell 启动 node.exe → node 再衍生 cargo.exelink.exeesbuild.exe 等子树
  2. 控制端被 Ctrl-C 中断,SSH 会话结束,PowerShell 退出
  3. Windows 不向孤儿进程发送 SIGHUP(与 POSIX 不同),子树全部成为僵尸,继续持有文件句柄
  4. 下一次构建的 Remove-Item -ErrorAction SilentlyContinue 静默失败在那个被锁的 .node 文件
  5. npm ci 随后尝试写入同一文件 → EPERM 4048
坑:-ErrorAction SilentlyContinue 是放大器

Remove-Item -Recurse -Force node_modules -ErrorAction SilentlyContinue 的本意是「目录不存在时不报错」,但它同时吞掉了「目录存在但文件被锁」的失败。结果是 node_modules 只删了一半,npm ci 接着在残留的锁文件上报 EPERM,而且报错指向 npm,完全看不出根因是前一次的僵尸进程。

正解

三层防御,缺一不可。

第一层:npm ci 前先杀掉残留构建进程

function Kill-StaleBuildProcs {
  $names = @('node','cargo','link','rustc','tauri','esbuild','rollup','vite')
  $procs = Get-Process -Name $names -ErrorAction SilentlyContinue
  if ($procs) {
    Write-Host "kill stale: $(($procs | ForEach-Object { $_.ProcessName + '(' + $_.Id + ')' }) -join ', ')"
    $procs | Stop-Process -Force -ErrorAction SilentlyContinue
    Start-Sleep -Milliseconds 500   # 等句柄释放
  }
}

在每个 npm ci 之前调用。仅适用于专用构建机;若机器上还跑着开发服务,请按 Path 过滤(只 kill 构建 nvm 目录下的 node),避免误杀 IDE LSP 或本地 API。

第二层:让清理失败时立即抛出,不要静默

function Remove-NodeModulesOrThrow {
  param([Parameter(Mandatory=$true)][string]$Path)
  if (-not (Test-Path $Path)) { return }
  Remove-Item -Recurse -Force $Path -ErrorAction SilentlyContinue
  if (Test-Path $Path) {
    Start-Sleep -Milliseconds 800   # 等杀毒软件 / 句柄排水
    Remove-Item -Recurse -Force $Path -ErrorAction SilentlyContinue
  }
  if (Test-Path $Path) {
    throw "can't delete $Path · 仍被锁定 · 请用 Resource Monitor → Associated Handles 排查"
  }
}

关键反转:清理失败时报错指向清理本身,而不是等到 npm ci 时才爆,让问题定位在第一现场。

第三层:全局 grep 清理脚本中的 SilentlyContinue

Remove-Item ... -ErrorAction SilentlyContinue

凡是出现在 node_modules / target / dist 删除路径上的,都用 Remove-NodeModulesOrThrow 替换。SilentlyContinue 适合「文件可能不存在」的场景,不适合「删完必须确认消失」的场景。

诊断一行命令(不确定是不是同一个坑时,先跑这个):

ssh user@win 'powershell -Command "
  Get-Process node,cargo,link,rustc,esbuild -ErrorAction SilentlyContinue |
    Select-Object Id,ProcessName,
      @{n=\"AgeMin\";e={[int]((Get-Date) - $_.StartTime).TotalMinutes}},Path |
    Format-Table -AutoSize | Out-String -Width 200
"'

AgeMin 明显大于当前构建启动时间的进程 = 僵尸,即本坑。

正解:先杀僵尸再清理,清理失败要抛出而非吞掉

在 SSH 驱动的 Windows 构建流水线里,每次 npm ci 前加一步 Kill-StaleBuildProcs,并用 会在锁定残留时 throwRemove-NodeModulesOrThrow 替代 -ErrorAction SilentlyContinue 的静默删除。发现问题的时间从「npm 报错后无头苍蝇排查 npm flag」缩短到「清理阶段直接报锁定」。

一句话外卖

Windows 不会在 SSH 会话结束时杀掉子进程树;-ErrorAction SilentlyContinue 会把文件锁失败藏进黑洞——这两个特性叠在一起,才能造出这种让人对着 npm 发呆数小时的假错误。