ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Windows 内存飙升排查实录:4641 个 pnpm 进程背后的 Volta 循环陷阱

2026/8/9 15:39:37 拓冰建站 浏览量
Windows 内存飙升排查实录:4641 个 pnpm 进程背后的 Volta 循环陷阱

环境:Windows 10 22H2 / Volta 2.0.2(通过 Scoop 安装)/ Node.js 22.x LTS / pnpm 10.11.1(npm 全局安装)

现象

某日开机后,Windows 任务管理器显示内存占用异常偏高。32GB 物理内存,已用超过 22GB,使用率 70.8%。系统并未运行大型应用,但内存持续被吞噬。

第一轮排查:谁在吃内存

编写 PowerShell 脚本采集系统内存信息和进程列表。脚本分六个部分:系统内存概览、内存占用 Top 30 进程、同名进程聚合统计、句柄数 Top 15、非分页池 Top 15、系统信息。

# 1. 系统内存概览
Write-Host "========== 系统内存概览 ==========" -ForegroundColor Cyan
$os = Get-CimInstance Win32_OperatingSystem
$totalGB = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
$freeGB = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
$usedGB = [math]::Round(($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / 1MB, 2)
$pct = [math]::Round(($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize * 100, 1)
Write-Host "总内存: ${totalGB} GB"
Write-Host "已使用: ${usedGB} GB"
Write-Host "空闲:   ${freeGB} GB"
Write-Host "使用率: ${pct}%"
Write-Host ""# 2. 内存占用 Top 30 进程
Write-Host "========== 内存占用 Top 30 进程 ==========" -ForegroundColor Cyan
Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 30 | ForEach-Object {[PSCustomObject]@{Name      = $_.NamePID       = $_.IdWS_MB     = [math]::Round($_.WorkingSet64 / 1MB, 1)PM_MB     = [math]::Round($_.PrivateMemorySize / 1MB, 1)Threads   = $_.Threads.CountHandles   = $_.HandleCount}
} | Format-Table -AutoSize | Out-String | Write-Host# 3. 按进程名聚合(同名进程合计)
Write-Host "========== 同名进程内存合计 Top 20 ==========" -ForegroundColor Cyan
Get-Process | Group-Object Name | ForEach-Object {[PSCustomObject]@{Name        = $_.NameCount       = $_.CountTotalWS_MB  = [math]::Round(($_.Group | Measure-Object WorkingSet64 -Sum).Sum / 1MB, 1)TotalPM_MB  = [math]::Round(($_.Group | Measure-Object PrivateMemorySize -Sum).Sum / 1MB, 1)TotalHandles = ($_.Group | Measure-Object HandleCount -Sum).Sum}
} | Sort-Object TotalWS_MB -Descending | Select-Object -First 20 | Format-Table -AutoSize | Out-String | Write-Host# 4. 句柄数 Top 15(句柄泄露指标)
Write-Host "========== 句柄数 Top 15 (可能的句柄泄露) ==========" -ForegroundColor Cyan
Get-Process | Sort-Object HandleCount -Descending | Select-Object -First 15 | ForEach-Object {[PSCustomObject]@{Name     = $_.NamePID      = $_.IdHandles  = $_.HandleCountWS_MB    = [math]::Round($_.WorkingSet64 / 1MB, 1)}
} | Format-Table -AutoSize | Out-String | Write-Host# 5. 非分页池/分页池 Top 15(内核内存泄露指标)
Write-Host "========== 非分页池 Top 15 (内核内存) ==========" -ForegroundColor Cyan
Get-Process | Sort-Object NonpagedSystemMemorySize64 -Descending | Select-Object -First 15 | ForEach-Object {[PSCustomObject]@{Name       = $_.NamePID        = $_.IdNP_MB      = [math]::Round($_.NonpagedSystemMemorySize64 / 1MB, 1)Paged_MB   = [math]::Round($_.PagedMemorySize64 / 1MB, 1)WS_MB      = [math]::Round($_.WorkingSet64 / 1MB, 1)}
} | Format-Table -AutoSize | Out-String | Write-Host# 6. 系统信息
Write-Host "========== 系统信息 ==========" -ForegroundColor Cyan
$computer = Get-CimInstance Win32_OperatingSystem
Write-Host "OS: $($computer.Caption) $($computer.Version)"
Write-Host "Last Boot: $($computer.LastBootUpTime)"

执行结果令人震惊。按同名进程聚合后:

进程名 进程数 工作集合计 私有内存合计 句柄总数
pnpm 4641 27.37 GB 5.66 GB 32,793,200
cmd 4640 21.63 GB - -
Qoder 23 3.26 GB 3.35 GB 11,594
chrome 16 2.08 GB 1.57 GB 8,962

4641 个 pnpm 进程,4640 个 cmd 进程。两者数量几乎相等,合计占用工作集近 49GB,远超物理内存。系统被迫大量使用内存压缩,Memory Compression 进程占用了 685MB。

每个 pnpm 进程仅占约 6MB 工作集,但 4641 个进程累积起来就是 27GB。每个进程持有约 7000 个句柄,总句柄数超过 3200 万。

进一步编写脚本统计 pnpm 和 cmd 进程的总数、总内存和启动时间。

Write-Host "========== pnpm 进程统计 ==========" -ForegroundColor Cyan
$pnpm = Get-Process -Name pnpm -ErrorAction SilentlyContinue
Write-Host "pnpm 进程数: $($pnpm.Count)"
if ($pnpm.Count -gt 0) {$totalWS = [math]::Round(($pnpm | Measure-Object WorkingSet64 -Sum).Sum / 1GB, 2)$totalPM = [math]::Round(($pnpm | Measure-Object PrivateMemorySize -Sum).Sum / 1GB, 2)$totalHandles = ($pnpm | Measure-Object HandleCount -Sum).SumWrite-Host "pnpm 总工作集: ${totalWS} GB"Write-Host "pnpm 总私有内存: ${totalPM} GB"Write-Host "pnpm 总句柄数: ${totalHandles}"Write-Host ""Write-Host "pnpm 进程启动时间 (前10个):"$pnpm | Sort-Object StartTime | Select-Object -First 10 | ForEach-Object {$ws = [math]::Round($_.WorkingSet64/1MB,1)Write-Host "  PID=$($_.Id) 启动时间=$($_.StartTime) WS=${ws}MB Handles=$($_.HandleCount)"}
}Write-Host ""
Write-Host "========== cmd 进程统计 ==========" -ForegroundColor Cyan
$cmd = Get-Process -Name cmd -ErrorAction SilentlyContinue
Write-Host "cmd 进程数: $($cmd.Count)"
if ($cmd.Count -gt 0) {$totalWS = [math]::Round(($cmd | Measure-Object WorkingSet64 -Sum).Sum / 1GB, 2)Write-Host "cmd 总工作集: ${totalWS} GB"
}Write-Host ""
Write-Host "========== 系统提交内存 (Commit) ==========" -ForegroundColor Cyan
$cs = Get-CimInstance Win32_OperatingSystem
Write-Host "Commit Total: $([math]::Round($cs.TotalVirtualMemorySize/1MB,2)) GB"
Write-Host "Commit Free:  $([math]::Round($cs.FreeVirtualMemory/1MB,2)) GB"

输出显示这些进程的启动时间全部集中在 11:20:55 左右,即开机后不久。说明是某个操作一次性触发了批量进程创建。

第二轮排查:追踪进程树

尝试终止进程后发现仍有残留,说明有东西在持续重新生成。于是编写进程追踪脚本,通过 Win32_ProcessParentProcessId 向上追溯每个 pnpm 进程的父进程链。

# 追踪 pnpm/cmd 进程的父进程
Write-Host "========== pnpm 进程父进程分析 ==========" -ForegroundColor Cyan$pnpmProcs = Get-Process -Name pnpm -ErrorAction SilentlyContinue | Select-Object -First 20
foreach ($p in $pnpmProcs) {$procInfo = Get-CimInstance Win32_Process -Filter "ProcessId = $($p.Id)" -ErrorAction SilentlyContinue$parentPid = $procInfo.ParentProcessId$parentProc = Get-CimInstance Win32_Process -Filter "ProcessId = $parentPid" -ErrorAction SilentlyContinueWrite-Host "pnpm PID=$($p.Id) ParentPID=$parentPid ParentName=$($parentProc.Name) CmdLine=$($parentProc.CommandLine)"
}Write-Host ""
Write-Host "========== cmd 进程父进程分析 ==========" -ForegroundColor Cyan$cmdProcs = Get-Process -Name cmd -ErrorAction SilentlyContinue | Select-Object -First 20
foreach ($p in $cmdProcs) {$procInfo = Get-CimInstance Win32_Process -Filter "ProcessId = $($p.Id)" -ErrorAction SilentlyContinue$parentPid = $procInfo.ParentProcessId$parentProc = Get-CimInstance Win32_Process -Filter "ProcessId = $parentPid" -ErrorAction SilentlyContinueWrite-Host "cmd PID=$($p.Id) ParentPID=$parentPid ParentName=$($parentProc.Name) CmdLine=$($parentProc.CommandLine)"
}Write-Host ""
Write-Host "========== 按父进程名聚合 pnpm ==========" -ForegroundColor Cyan
$allPnpm = Get-CimInstance Win32_Process -Filter "Name = 'pnpm.exe'" -ErrorAction SilentlyContinue
$allPnpm | Group-Object { $_.ParentProcessId } | Sort-Object Count -Descending | Select-Object -First 10 | ForEach-Object {$ppid = $_.Name$parentProc = Get-CimInstance Win32_Process -Filter "ProcessId = $ppid" -ErrorAction SilentlyContinueWrite-Host "ParentPID=$ppid ($($parentProc.Name)) 子进程数=$($_.Count) CmdLine=$($parentProc.CommandLine)"
}Write-Host ""
Write-Host "========== 按父进程名聚合 cmd ==========" -ForegroundColor Cyan
$allCmd = Get-CimInstance Win32_Process -Filter "Name = 'cmd.exe'" -ErrorAction SilentlyContinue
$allCmd | Group-Object { $_.ParentProcessId } | Sort-Object Count -Descending | Select-Object -First 10 | ForEach-Object {$ppid = $_.Name$parentProc = Get-CimInstance Win32_Process -Filter "ProcessId = $ppid" -ErrorAction SilentlyContinueWrite-Host "ParentPID=$ppid ($($parentProc.Name)) 子进程数=$($_.Count) CmdLine=$($parentProc.CommandLine)"
}

输出揭示了关键线索:

pnpm PID=308  ParentPID=144612 ParentName=cmd.exe  CmdLine="cmd.exe" /C pnpm --version
pnpm PID=632  ParentPID=94068  ParentName=cmd.exe  CmdLine="cmd.exe" /C pnpm --version
pnpm PID=1456 ParentPID=114532 ParentName=cmd.exe  CmdLine="cmd.exe" /C pnpm --versioncmd PID=312  ParentPID=135628 ParentName=pnpm.exe  CmdLine=pnpm  --version
cmd PID=1956 ParentPID=87324  ParentName=pnpm.exe  CmdLine=pnpm  --version
cmd PID=2352 ParentPID=92052  ParentName=pnpm.exe  CmdLine=pnpm  --version

所有泄漏的进程都在执行同一件事:pnpm --version。并且形成了一个循环链:

cmd.exe /C pnpm --version-> pnpm.exe --version-> cmd.exe (pnpm 内部 spawn)-> pnpm.exe-> cmd.exe-> ...无限循环

pnpm 的父进程是 cmd.exe,cmd.exe 的父进程又是 pnpm.exe。两者互相生成,形成无限递归。

为了确认这个循环链的源头,进一步编写脚本对每个 pnpm 进程向上追溯完整父进程链,找到第一个非 pnpm/cmd 的根进程。

Write-Host "========== 追踪 pnpm/cmd 根进程 ==========" -ForegroundColor Cyan$allPnpm = Get-CimInstance Win32_Process -Filter "Name = 'pnpm.exe'" -ErrorAction SilentlyContinue
$allCmd = Get-CimInstance Win32_Process -Filter "Name = 'cmd.exe'" -ErrorAction SilentlyContinueWrite-Host "pnpm 总数: $($allPnpm.Count)"
Write-Host "cmd 总数: $($allCmd.Count)"
Write-Host ""# 对每个 pnpm 进程,向上追溯父进程链
$rootSummary = @{}
foreach ($p in $allPnpm) {$current = $p$depth = 0while ($current -and $depth -lt 20) {$ppid = $current.ParentProcessIdif ($ppid -eq 0 -or $ppid -eq $null) { break }$parent = Get-CimInstance Win32_Process -Filter "ProcessId = $ppid" -ErrorAction SilentlyContinueif (-not $parent) { break }# 如果父进程不是 pnpm/cmd,那就是根进程if ($parent.Name -ne 'pnpm.exe' -and $parent.Name -ne 'cmd.exe') {$rootKey = "$($parent.Name)|$($parent.CommandLine)"if (-not $rootSummary.ContainsKey($rootKey)) {$rootSummary[$rootKey] = @{ Name = $parent.Name; PID = $ppid; CmdLine = $parent.CommandLine; Count = 0 }}$rootSummary[$rootKey].Count++break}$current = $parent$depth++}if ($depth -ge 20) {$key = "CIRCULAR_CHAIN"if (-not $rootSummary.ContainsKey($key)) {$rootSummary[$key] = @{ Name = "CIRCULAR"; PID = 0; CmdLine = "circular chain > 20 levels"; Count = 0 }}$rootSummary[$key].Count++}
}Write-Host "========== pnpm 进程根进程汇总 ==========" -ForegroundColor Cyan
$rootSummary.GetEnumerator() | Sort-Object { $_.Value.Count } -Descending | Select-Object -First 20 | ForEach-Object {$v = $_.ValueWrite-Host "  根进程: $($v.Name) (PID=$($v.PID))"Write-Host "  命令行: $($v.CmdLine)"Write-Host "  导致的 pnpm 进程数: $($v.Count)"Write-Host ""
}

运行后发现所有进程链最终都收敛到同一个循环模式,不存在一个外部的"根进程"。每个 pnpm 的父进程是 cmd.exe,每个 cmd.exe 的父进程又是 pnpm.exe,形成了一个封闭的环。

第三轮排查:pnpm 从哪来

既然进程链是封闭的,那最初的触发点是什么?检查系统上 pnpm 的实际来源。

系统上存在两套 pnpm:

1. npm 全局安装的 pnpm(真实 pnpm 10.11.1)

位置:C:\Users\Administrator\pnpm.cmd

@ECHO off
...
"%_prog%"  "%dp0%\node_modules\pnpm\bin\pnpm.cjs" %*

2. Volta 的 pnpm shim(pnpm 未通过 Volta 安装)

位置:D:\SystemProgram\scoop\apps\volta\current\appdata\bin\

  • pnpm(bash shim,43 字节):volta run "$(basename $0)" "$@"
  • pnpm.cmd(cmd shim,27 字节):"%~dpn0.exe" %*
  • pnpm.exe(Volta 原生二进制,5.6MB):Volta 的 shim 执行器

关键发现:PowerShell 和 cmd 解析 pnpm 的结果不同!

环境 解析结果 原因
PowerShell pnpm.exe(Volta shim) PowerShell 优先解析 .exe > .cmd
cmd C:\Users\Administrator\pnpm.cmd(真实 pnpm) cmd 按 PATH 顺序找 .cmd

验证:

# PowerShell 中
Get-Command pnpm | Select-Object Source
# 输出: D:\SystemProgram\scoop\apps\volta\current\appdata\bin\pnpm.exe# cmd 中
where pnpm
# 输出: C:\Users\Administrator\pnpm (第一行)

关键证据:Volta 错误日志

检查 Volta 的错误日志(%VOLTA_HOME%\log\volta-error-*.log),发现明确的报错信息:

"pnpm" "--version"
Volta v2.0.2Could not find executable "pnpm"Use `volta install` to add a package to your toolchain (see `volta help install` for more info).Error cause: 页面文件太小,无法完成操作。 (os error 1455)

这证实了:

  1. pnpm 未通过 Volta 安装(Could not find executable "pnpm"
  2. Volta 尝试执行 pnpm 但失败
  3. os error 1455(页面文件太小)说明无限循环导致系统资源耗尽

同时检查 Volta 的配置(%VOLTA_HOME%\tools\user\platform.json):

{"node": {"runtime": "22.23.2","npm": null},"pnpm": null,"yarn": null
}

"pnpm": null 确认 pnpm 未通过 Volta 安装,但 Volta 却为它创建了 shim。

当在 PowerShell 中执行 pnpm --version 时,完整的执行链如下:

  1. PowerShell 解析 pnpm,优先匹配到 pnpm.exe(Volta shim)
  2. pnpm.exe 内部执行 volta run pnpm --version
  3. Volta 发现 pnpm 未通过 Volta 安装(platform.json"pnpm": null
  4. Volta 的设计缺陷:报错前通过 PATH 解析 pnpm(可能是为了查找非 Volta 管理的安装)
  5. PATH 搜索没有排除 Volta 自己的 shim 目录
  6. 解析结果找到了 pnpm.exe(Volta shim 自己)
  7. 执行 pnpm.exe -> 回到步骤 2
  8. 无限循环

Volta 的预期行为 vs 实际行为:

阶段 预期行为 实际行为
检查工具是否安装 发现 "pnpm": null 发现 "pnpm": null
工具未安装时 直接报错退出 继续通过 PATH 搜索
PATH 搜索 应排除自身 shim 目录 未排除,找到自己的 shim
找到 shim 检测到循环,报错退出 执行 shim,形成闭环

Volta 源码分析 - 实际执行流程:

通过分析 Volta 源码,发现实际的执行逻辑如下:

  1. 入口判断run/mod.rs):
Some("pnpm") => {if env::var_os(VOLTA_FEATURE_PNPM).is_some() {pnpm::command(args, session)  // pnpm 专用处理器} else {binary::command(exe, args, session)  // 通用二进制处理器}
}
  1. 关键问题:当 VOLTA_FEATURE_PNPM 环境变量未设置时,Volta 使用 binary::command 而非 pnpm::command

  2. 递归检测差异

    • pnpm::command 会检查 RECURSION_ENV_VAR_VOLTA_TOOL_RECURSION
    • binary::command 不检查 RECURSION_ENV_VAR
  3. 当 pnpm 未安装时的执行流程

    • binary::command 找不到 pnpm 的配置文件
    • 创建 ToolCommandplatform = None
    • default_execution_context 返回 System::path()(系统 PATH)
    • 系统 PATH 包含 Volta 的 shim 目录
    • 执行命令时设置 RECURSION_ENV_VAR=1,但 binary::command 不检查它
    • 系统找到 pnpm.exe(Volta shim)并执行
    • shim 再次调用 binary::command
    • 无限循环

Volta 的设计意图分析:

  1. VOLTA_FEATURE_PNPM 是功能开关

Volta 把 pnpm 当作"实验性功能",默认情况下 pnpm 被视为普通第三方工具(和 ember-clitypescript 等一样),而非内置工具。

  1. binary::command 的 "pass-through" 设计

当工具未通过 Volta 安装时,Volta 的意图是回退到系统 PATH,让系统找到非 Volta 管理的安装。这是一种"优雅降级",允许用户使用系统安装的 pnpm。

  1. binary::command 缺少递归检测

binary::command 是通用二进制处理器,它假设:

  • 如果工具通过 Volta 安装 → 找到配置,直接执行
  • 如果工具未通过 Volta 安装 → 使用系统 PATH,找到系统安装

但它没考虑:系统 PATH 中可能包含 Volta 自己的 shim!

  1. Volta 的 pnpm shim 本身就有问题

Volta 目录中的 pnpm.cmdpnpm.exe 都会触发无限循环

:: pnpm.cmd 内容
@echo off
"%~dpn0.exe" %*

pnpm.cmd 只是调用 pnpm.exe 的包装,两者最终都执行同样的 Volta shim 逻辑。递归检测(RECURSION_ENV_VAR)是在 Rust 代码中实现的,不是在 .cmd 文件中。

无论调用 .cmd 还是 .exe,第一次调用时 RECURSION_ENV_VAR 未设置,无法触发保护。

  1. 为什么 pnpm::command 不会有问题?

pnpm::command 是 pnpm 的专用处理器,有递归检测:

let platform = match env::var_os(RECURSION_ENV_VAR) {Some(_) => None,  // 检测到递归,使用 pass-throughNone => Platform::current(session)?,
};

但当 VOLTA_FEATURE_PNPM 未设置时,pnpm 走的是 binary::command,而不是 pnpm::command

总结:Volta 的设计缺陷

设计意图 实际结果
pnpm 作为可选功能,默认当普通工具处理 普通工具处理器缺少递归检测
工具未安装时回退到系统 PATH(pass-through) 系统 PATH 包含 Volta shim,导致循环
为常用工具预创建 shim shim 在工具未安装时会触发无限循环
pass-through 使用系统 PATH 没有排除自己的 shim 目录

Volta 不是故意要循环,而是:

  1. 为未安装的工具(pnpm)创建了 shim
  2. binary::command 缺少递归检测
  3. pass-through 逻辑没有排除自己的 shim 目录
  4. shim 本身(无论 .cmd 还是 .exe)都会触发无限循环

这些因素组合导致了这个 Bug。

为什么 cmd 中可能正常工作?

如果 cmd 中执行 pnpm 时按 PATH 顺序先找到 C:\Users\Administrator\pnpm.cmd(npm 全局安装的真实 pnpm),则不会触发 Volta 的 shim,因此不会循环。但这取决于 PATH 顺序,不是可靠的保证。

核心问题:Volta 目录中的 pnpm shim 本身就有问题,无论通过什么方式调用都会触发无限循环。

终止泄漏进程

由于进程数量巨大(数千个),且终止过程中仍有新进程被循环生成出来,需要一次性批量终止。编写清理脚本,记录终止前后的进程数和内存变化。

Write-Host "========== 清理前 ==========" -ForegroundColor Cyan
$pnpmBefore = (Get-Process -Name pnpm -ErrorAction SilentlyContinue).Count
$cmdBefore = (Get-Process -Name cmd -ErrorAction SilentlyContinue).Count
Write-Host "pnpm 进程: $pnpmBefore"
Write-Host "cmd 进程: $cmdBefore"$os1 = Get-CimInstance Win32_OperatingSystem
$usedBefore = [math]::Round(($os1.TotalVisibleMemorySize - $os1.FreePhysicalMemory) / 1MB, 2)
Write-Host "已用内存: ${usedBefore} GB"
Write-Host ""Write-Host "========== 开始清理 ==========" -ForegroundColor Cyan
if ($pnpmBefore -gt 0) {Get-Process -Name pnpm -ErrorAction SilentlyContinue | Stop-Process -ForceWrite-Host "已终止所有 pnpm 进程"
}
if ($cmdBefore -gt 0) {Get-Process -Name cmd -ErrorAction SilentlyContinue | Stop-Process -ForceWrite-Host "已终止所有 cmd 进程"
}Start-Sleep -Seconds 3Write-Host ""
Write-Host "========== 清理后 ==========" -ForegroundColor Cyan
$pnpmAfter = (Get-Process -Name pnpm -ErrorAction SilentlyContinue).Count
$cmdAfter = (Get-Process -Name cmd -ErrorAction SilentlyContinue).Count
Write-Host "pnpm 进程: $pnpmAfter"
Write-Host "cmd 进程: $cmdAfter"$os2 = Get-CimInstance Win32_OperatingSystem
$usedAfter = [math]::Round(($os2.TotalVisibleMemorySize - $os2.FreePhysicalMemory) / 1MB, 2)
$freed = [math]::Round($usedBefore - $usedAfter, 2)
Write-Host "已用内存: ${usedAfter} GB"
Write-Host "释放内存: ${freed} GB"

清理前 pnpm 进程 3505 个,cmd 进程 3507 个,已用内存 19.74GB。清理后释放约 0.85GB 内存(部分进程在终止前已自行退出,且循环生成的速度在终止操作期间仍在产生新进程)。

最小复现

将本次泄漏的核心机制抽象出来,其实就是一个极简的模式:通过 cmd /c 调用 pnpm --version,Volta 的 shim 介入后触发 pnpm <-> cmd 的循环生成。以下脚本可直接运行复现,并验证僵尸进程确实存在。

#
# Volta pnpm 循环进程生成 - 最小复现
#
# 触发原理:
#   cmd /c pnpm --version
#   -> cmd.exe 在 PATH 中找到 pnpm.cmd
#   -> pnpm.cmd 调用 pnpm.exe (Volta shim)
#   -> pnpm.exe 内部通过 PATH 解析 pnpm
#   -> 又找到 pnpm.cmd -> pnpm.exe -> 无限循环
#
# 用法: pwsh -File volta_pnpm_leak_reproduce.ps1
#Write-Host "============================================" -ForegroundColor Cyan
Write-Host "  Volta pnpm 循环进程生成 - 最小复现" -ForegroundColor Cyan
Write-Host "============================================" -ForegroundColor Cyan
Write-Host ""# ============================================
# Step 1: 清理残留进程(多轮)
# ============================================
Write-Host "[Step 1] 清理残留 pnpm/cmd 进程 ..." -ForegroundColor Yellow
for ($round = 1; $round -le 10; $round++) {$p = (Get-Process -Name pnpm -ErrorAction SilentlyContinue).Count$c = (Get-Process -Name cmd -ErrorAction SilentlyContinue).Countif ($p -eq 0 -and $c -eq 0) {Write-Host "  第${round}轮: 已清零" -ForegroundColor Greenbreak}Get-Process -Name pnpm -ErrorAction SilentlyContinue | Stop-Process -ForceGet-Process -Name cmd -ErrorAction SilentlyContinue | Stop-Process -ForceStart-Sleep -Milliseconds 500Write-Host "  第${round}轮: pnpm=$p cmd=$c"
}
Write-Host ""# ============================================
# Step 2: 触发循环
# ============================================
Write-Host "[Step 2] 触发 Volta pnpm 循环 ..." -ForegroundColor Yellow
Write-Host "  启动 3 个 cmd /c pnpm --version ..."# 用 Start-Process 启动
for ($i = 1; $i -le 3; $i++) {Start-Process cmd.exe -ArgumentList '/c pnpm --version' -WindowStyle Hidden
}# 等待 5 秒让循环生成进程
Write-Host "  等待 5 秒 ..."
Start-Sleep -Seconds 5
Write-Host ""# ============================================
# Step 3: 验证僵尸进程
# ============================================
Write-Host "[Step 3] 验证僵尸进程 ..." -ForegroundColor Yellow$pnpmProcs = Get-Process -Name pnpm -ErrorAction SilentlyContinue
$cmdProcs = Get-Process -Name cmd -ErrorAction SilentlyContinue$pnpmCount = if ($pnpmProcs) { $pnpmProcs.Count } else { 0 }
$cmdCount = if ($cmdProcs) { $cmdProcs.Count } else { 0 }Write-Host "  pnpm 进程数: $pnpmCount" -ForegroundColor $(if ($pnpmCount -gt 10) { 'Red' } else { 'Green' })
Write-Host "  cmd  进程数: $cmdCount" -ForegroundColor $(if ($cmdCount -gt 10) { 'Red' } else { 'Green' })
Write-Host ""if ($pnpmCount -gt 10) {Write-Host "  *** 僵尸进程确认! pnpm <-> cmd 循环生成! ***" -ForegroundColor RedWrite-Host ""# 显示前 10 个 pnpm 进程详情Write-Host "  pnpm 进程详情 (前 10 个):" -ForegroundColor Cyan$pnpmProcs | Sort-Object StartTime | Select-Object -First 10 | ForEach-Object {$ws = [math]::Round($_.WorkingSet64 / 1MB, 1)Write-Host "    PID=$($_.Id) 启动=$($_.StartTime) 内存=${ws}MB 句柄=$($_.HandleCount)"}Write-Host ""# 显示进程树关系Write-Host "  进程父子关系 (前 5 组):" -ForegroundColor Cyan$pnpmProcs | Select-Object -First 5 | ForEach-Object {$procInfo = Get-CimInstance Win32_Process -Filter "ProcessId = $($_.Id)" -ErrorAction SilentlyContinue$parentPid = $procInfo.ParentProcessId$parentProc = Get-CimInstance Win32_Process -Filter "ProcessId = $parentPid" -ErrorAction SilentlyContinueWrite-Host "    pnpm PID=$($_.Id) <- $($parentProc.Name) PID=$parentPid"Write-Host "      命令行: $($parentProc.CommandLine)"}Write-Host ""# 内存统计$totalPnpmWS = [math]::Round(($pnpmProcs | Measure-Object WorkingSet64 -Sum).Sum / 1MB, 1)$totalPnpmHandles = ($pnpmProcs | Measure-Object HandleCount -Sum).SumWrite-Host "  内存统计:" -ForegroundColor CyanWrite-Host "    pnpm 总工作集: ${totalPnpmWS} MB"Write-Host "    pnpm 总句柄数: $totalPnpmHandles"
} else {Write-Host "  未检测到大量僵尸进程,可能 Volta 版本已修复此问题。" -ForegroundColor Green
}
Write-Host ""# ============================================
# Step 4: 清理
# ============================================
Write-Host "[Step 4] 清理僵尸进程 ..." -ForegroundColor Yellow
for ($round = 1; $round -le 10; $round++) {Get-Process -Name pnpm -ErrorAction SilentlyContinue | Stop-Process -ForceGet-Process -Name cmd -ErrorAction SilentlyContinue | Stop-Process -ForceStart-Sleep -Milliseconds 500$checkPnpm = (Get-Process -Name pnpm -ErrorAction SilentlyContinue).Count$checkCmd = (Get-Process -Name cmd -ErrorAction SilentlyContinue).CountWrite-Host "  第${round}轮: pnpm=$checkPnpm cmd=$checkCmd"if ($checkPnpm -eq 0 -and $checkCmd -eq 0) { break }
}
Write-Host ""Write-Host "============================================" -ForegroundColor Cyan
Write-Host "  复现完成" -ForegroundColor Cyan
Write-Host "============================================" -ForegroundColor Cyan

总结

本次内存泄漏的完整因果链:

某个行为在 PowerShell 中触发了 pnpm 调用 -> PowerShell 优先解析到 Volta 的 pnpm.exe(而非 npm 以前全局安装的 pnpm.cmd) -> Volta shim 执行 volta run pnpm -> Volta 发现 pnpm 未通过 Volta 安装(platform.json"pnpm": null) -> Volta 报错前通过 PATH 解析 pnpm -> 找到 Volta 自己的 pnpm.exe -> 无限循环 -> 数秒内生成数千个 pnpm 和 cmd 进程 -> 内存耗尽

根本原因是三个因素的组合:

  1. Volta 的 shim 机制缺陷:为未安装的工具(pnpm)创建了 shim,且在工具未安装时进入无限循环而非优雅失败
  2. PowerShell 的命令解析顺序:优先选择 .exe > .cmd,导致命中 Volta 的 shim 而非真正的 pnpm
  3. PATH 配置:Volta 的 shim 目录在 PATH 中,且 pnpm.exe 被 PowerShell 优先解析

为什么 cmd 中正常,PowerShell 中崩溃:

  • cmd 按 PATH 顺序找到 C:\Users\Administrator\pnpm.cmd(npm 以前全局安装的真实 pnpm)
  • PowerShell 优先找到 D:\...\volta\...\pnpm.exe(Volta shim)

建议的处理方式:

  1. 删除 Volta 的 pnpm shim(推荐):

    Remove-Item D:\SystemProgram\scoop\apps\volta\current\appdata\bin\pnpm*
    
  2. 或通过 Volta 安装 pnpm

    volta install pnpm
    
  3. 或调整 PATH 顺序:确保 npm 全局安装目录在 Volta shim 目录之前

注意: 直接调用 pnpm.cmd 也有同样的问题,因为 pnpm.cmd 只是调用 pnpm.exe 的包装:

@echo off
"%~dpn0.exe" %*

.cmd.exe 都会触发同样的无限循环,因为递归检测是在 Volta 的 Rust 代码中实现的,不是在 .cmd 文件中。第一次调用时 RECURSION_ENV_VAR 未设置,无法触发保护。