pnpm 全局安装 fail-closed 修复:`add -g` / `update -g` / `remove -g` 如何避免半读取损坏全局环境
2026/9/19 5:51:47 网站建设 项目流程

pnpm 全局安装 fail-closed 修复:add -g/update -g/remove -g如何避免半读取损坏全局环境

【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm

本篇技术指南围绕 pnpm 仓库中 fail-closed-global-bin-enumeration.md 这一 changeset 记录的缺陷修复展开,深入讲解pnpm add -gpnpm update -gpnpm remove -g三条命令在枚举全局安装包时由"容错继续"改为"遇到问题即中止"(fail-closed)的行为变更。读完本文,你将理解全局包组(package group)的存储与激活机制、包清单缺失/损坏/不可读时旧版为何会破坏全局 bin 目录,以及新版如何保证在失败时完整保留现有全局安装。

修复背景:全局安装的"包组"结构与两条读取路径

pnpm 的全局安装与普通项目安装不同:pnpm add -g会把每个安装参数(或逗号分隔的一组选择器)解析成一个独立的安装组(install group),每组都拥有自己的独立安装目录,而全局目录(globalPkgDir)下的哈希链接(hash link,即指向安装目录的符号链接)负责把各组"登记"进全局命名空间。

在 pnpm11/global/packages/src/scanGlobalPackages.ts 中可以清楚看到这套模型:scanGlobalPackages读取全局目录下所有符号链接条目,对每个链接执行fs.realpathSync找到真实安装目录,再读取该目录下的package.json,把其中经过isValidGlobalDependencyAlias校验的依赖别名整理成GlobalPackageInfo。也就是说,一个全局组的信息完全来自"安装目录里的 package.json + 安装目录/node_modules 下的各依赖 manifest"。

这个修复涉及两条关键读取路径:

  • 扫描路径(scan)scanGlobalPackagesgetGlobalPackageDetails用于列出、冲突检查、归属计算等"只读盘点"场景;
  • 组内清单读取(read):readInstalledPackages.ts 中readInstalledPackages用于在激活(activation)之前读取新安装组内每个包的完整DependencyManifest

缺陷本质:只"部分读取"就动刀

旧实现的问题在于:枚举全局 bin 或读取组内 manifest 时,遇到缺失、损坏、不可读的包清单往往选择跳过或继续,而后续步骤(激活新组、移除旧组、清理被替换的 bin)又依赖枚举结果。一旦某个包清单无法读取,命令就会在"信息不完整"的状态下继续执行,导致:

  • pnpm add -g:新组激活后,把旧组替换/清理掉时依据的是不完整的 bin 清单,可能误删其他全局包仍然拥有的 bin(shim);
  • pnpm update -g:更新组并切换哈希链接后,依据不完整清单移除"消失的 bin",可能把仍被引用的命令入口删掉;
  • pnpm remove -g:计算 bin 归属时缺了某个组的清单,把本该保留的 bin 一并 unlink,甚至移除整个安装目录。

简而言之:读得越少,删得越多。这正是 changeset 中"mutating global bins or install directories after only partially reading an installed package group"所描述的缺陷(对应上游 issue pnpm/pnpm#13796 的记录,文章不展开外部链接细节)。

修复方案:fail-closed —— 读不全就拒绝动手

changeset 给出的修复策略非常直接:如果任何一个声明的包清单缺失、格式错误或不可读,pnpm 就在激活或移除之前失败,并让现有全局安装保持原样。用安全术语讲,就是从"fail-open(读不到就跳过继续)"切换为"fail-closed(读不到就整体中止)"。

行为变化可归纳为下表:

命令修复前(fail-open)修复后(fail-closed)
pnpm add -g部分读取新组清单后继续激活、替换旧组新组内任一清单不可读即中止,新目录被清理,旧安装不变
pnpm update -g部分读取后切换哈希链接并清理被替换组更新组清单不可读即中止,旧哈希链接与旧组保持原样
pnpm remove -g部分读取后 unlink bin、删除安装目录目标组清单不可读即中止,任何 bin 与目录都不被触碰

源码级剖析:fail-closed 在三条命令中的落点

1. 激活前的严格读取:readInstalledPackages

readInstalledPackages.ts 是整个修复的核心新增点。它先读取安装目录的package.json,用isValidGlobalDependencyAlias过滤出合法的依赖别名,然后并发调用readPackageJsonFromDir(来自@pnpm/pkg-manifest.reader严格读取器)读取node_modules下每个依赖的 manifest:

export async function readInstalledPackages (installDir: string): Promise<InstalledGroupPackage[]> { const pkgJson = readPackageJsonFromDirRawSync(installDir) const depNames = Object.keys(pkgJson.dependencies ?? {}).filter(isValidGlobalDependencyAlias) const manifests = await Promise.all( depNames.map((depName) => readPackageJsonFromDir(path.join(installDir, 'node_modules', depName))) ) return depNames.map((depName, i) => ({ alias: depName, manifest: manifests[i] as DependencyManifest, location: path.join(installDir, 'node_modules', depName), })) }

关键点在于使用了readPackageJsonFromDir(严格版)而非safeReadPackageJsonFromDir(容错版):只要某个依赖目录里没有package.json、JSON 解析失败或不可读,Promise 就会 reject,调用方在激活前拿到错误并中止。readInstalledPackages的调用方包括:

  • globalAdd.ts 的installGroup:在checkGlobalBinConflictsgetActualBinNamesactivateGlobalInstall之前调用(第 142 行附近);
  • globalUpdate.ts 的updateGlobalPackageGroup:同样在冲突检查与激活之前调用(第 107 行附近)。

这两处都属于"新组已经装好、但还没对外激活"的阶段,因此失败时还有退路:调用cleanupFailedGlobalInstall把新目录删掉即可。

2. 移除/更新前的归属计算:scanGlobalPackagesgetInstalledBinNames

移除和更新场景面向的是已存在的全局组,读取入口在 scanGlobalPackages.ts:

  • scanGlobalPackages对每个哈希链接解析真实路径并读取package.json,单组读取失败时旧版会continue静默跳过(第 83-88 行的 try/catch),这是 fail-open 的一个典型来源;
  • 真正决定"哪些 bin 属于哪个组"的是getInstalledBinNames(第 155-169 行):它遍历组的dependencies,对每个别名调用readPackageJsonFromDir(严格版)并交给getBinsFromPackageManifest展开 bin 清单。这里只要有一个包的 manifest 读不到就会抛错,Promise.all直接 reject。

getInstalledBinNames被 binOwnership.ts 的getGlobalBinOwnership使用:它同时计算"目标组拥有的 bin"与"幸存组(不在本次操作范围内的其他全局组)拥有的 bin",进而得出protectedBins(被幸存组共享、不得删除的 bin)。如果枚举在读取环节就失败,归属计算根本不会返回,globalRemove.ts 会在任何removeBinfs.promises.rm执行之前直接抛出GLOBAL_PKG_NOT_FOUND之外的读取错误——这就是 remove 场景的 fail-closed 落点。

3. 失败后的善后:cleanupFailedGlobalInstall与激活回滚

对于add -g/update -g,fail-closed 的保证依赖两段清理逻辑:

  • cleanupFailedGlobalInstall.ts:删除新安装目录后重新抛出原始错误。注释写得很清楚——"Nothing outside the directory has been touched yet, so removing it leaves the global installation exactly as the command found it"(目录之外尚未被触碰,删除它即可让全局安装与命令执行前完全一致)。若删除失败,会构造AggregateError并把原始错误的code透传出去,保证报错输出可被解析;
  • globalActivation.ts 的activateGlobalInstall:真正激活前先prepareGlobalInstall备份 bin 槽位(.pnpm-bin-backup-*)、记录旧哈希链接目标,切换链接失败时执行restoreGlobalInstall回滚,回滚失败才抛出GLOBAL_BIN_ROLLBACK_FAILED。这意味着即使失败发生在激活阶段,也存在恢复机制。

值得一提的是,globalAdd.ts 与 globalUpdate.ts 中多处对枚举类调用(checkGlobalBinConflictsgetActualBinNamescollectExistingGlobalInstalls)都包在try/catch中并统一走cleanupFailedGlobalInstall,正是"读不全即中止且不留副作用"策略的执行骨架。

防御纵深:不止"读严格",还有"名字严格"

与 fail-closed 修复配套的还有输入校验层面的防御。scanGlobalPackages.ts 中的isValidGlobalDependencyAlias(第 21-33 行)解释了为什么依赖别名会被过滤:全局组的package.json依赖别名会在 list、冲突检查、remove、update 等环节被拼进node_modules下的目录名,被篡改的 manifest 完全可以用../x或绝对路径让路径逃离安装目录。因此该函数内联实现了validate-npm-package-namevalidForOldPackages规则(拒绝空名、前导._-、首尾空白、保留名node_modules/favicon.ico、URL 非友好字符等),只信任合法的 npm 包名。这属于同一"信任边界收紧"修复思路的另一半,与 fail-closed 共同构成对全局安装完整性的保护。

受影响包与验证方式

changeset 的 frontmatter 声明该 patch 影响以下包:

"@pnpm/global.commands": patch "@pnpm/global.packages": patch "pnpm": patch "pacquet": patch

即修复同时作用于@pnpm/global.commands(命令编排层)与@pnpm/global.packages(扫描/枚举层)两个工作区包,并随之发布到 pnpm 主包与 pacquet。

仓库中已有对应的测试覆盖,可作行为佐证:

  • getInstalledBinNames.test.ts:验证 bin 枚举在 manifest 缺失/异常时的行为;
  • globalAdd.test.ts、globalUpdate.test.ts、globalRemove.test.ts:分别覆盖三条命令在异常输入下不破坏现有全局安装的路径;
  • globalActivation.test.ts:验证激活失败时的回滚路径。

升级与自查建议

  • 升级到包含该 changeset 的 pnpm 版本后,如果全局目录中存在历史遗留的"半损坏"安装组(例如某个组node_modules/<pkg>下丢了package.json),add -g/update -g/remove -g可能比旧版更容易报错——这是 fail-closed 的预期行为,提示你需要先修复或清理对应组,而不是让命令在残缺状态下继续动 bin;
  • 排查时可先执行pnpm list -g观察输出,再从globalPkgDir下手动检查异常组;
  • 需要说明的是,本文所有行为描述均以当前仓库源码为准:pnpm 采用"先装入新目录、验证清单、再原子切换哈希链接、最后清理被替换组"的流水线(见 globalActivation.ts 的swapHashLink注释),fail-closed 修复正是为这条流水线补上了"前置校验"这一环,确保任何一步读到坏数据时整条流水线都不会触碰已有全局 bin 与安装目录。

【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询