Scoop 安装 Volta 的坑:手动设置 VOLTA_HOME 导致 pnpm/yarn 无限递归

之前在新的 Windows 设备上安装开发环境的时候,我踩过这么一个坑:

打开 opencode 过一会儿电脑就开始卡,任务管理器甚至都能一起卡死。

真正的问题既不是 opencode 本身,也不是单纯的 pnpm 报错,而是 Scoop 安装 Volta 之后,又手动设置了 VOLTA_HOME,结果把 pnpm / yarn 启动成了无限递归

现象

我一开始完全没往 Volta 上想。

因为前台几乎没有什么明确报错,只有机器越来越卡。更离谱的是,卡到后面连任务管理器都很难正常打开,点出来也基本是一起卡死的状态。

后来我用 btop 和 Process Explorer 去看进程树,才发现后台一直在不断生成新的 cmd.exe,并且伴随着一串 yarn / pnpm 相关进程。

进程链路大概是这样:

cmd.exe /C pnpm
  -> ...\volta\bin\pnpm(.cmd/.exe)
    -> volta run pnpm
      -> cmd.exe /C pnpm
        -> ...\volta\bin\pnpm(.cmd/.exe)
          -> volta run pnpm
            -> ...

也就是说,系统本来只是想执行一次 pnpm,最后却不断重新命中了 Volta 自己的 shim,整条链路开始一层一层重复下去。

先看 opencode

我最开始其实也没直接怀疑是 Volta。

因为从现象上看,更像是 opencode 在启动时做了什么额外操作,所以我先去看了 opencode 仓库里的启动逻辑,想确认它到底在后台跑了什么。

packages/opencode/src/installation/index.ts 里,Installation.method() 会按顺序尝试多个包管理器命令,用来判断当前到底是通过什么方式安装的:

const checks = [
  { name: "npm", command: () => $`npm list -g --depth=0`.throws(false).quiet().text() },
  { name: "yarn", command: () => $`yarn global list`.throws(false).quiet().text() },
  { name: "pnpm", command: () => $`pnpm list -g --depth=0`.throws(false).quiet().text() },
  { name: "bun", command: () => $`bun pm ls -g`.throws(false).quiet().text() },
  { name: "brew", command: () => $`brew list --formula opencode`.throws(false).quiet().text() },
]

从这里其实就能看出来,opencode 为了判断自己的安装方式,会把几种包管理器都试着跑一遍。

这就解释了为什么我明明没有主动执行 yarnpnpm,它们却会在后台突然冒出来。

但查到这里的时候,我还只是知道 opencode 会去调用 yarn / pnpm,并不知道问题为什么会这么严重。

所以我接着做了一步很直接的验证:我手动在终端里运行了一下 yarnpnpm。结果发现,不用等 opencode 来触发,它们自己就能把这个递归问题跑出来。

也就是说,opencode 只是刚好在启动时碰到了这两个命令,把问题提前暴露了出来。真正要查的,已经不是 opencode 了,而是为什么我机器上的 yarn / pnpm 会自己把自己不断拉起来。

如果把这条触发链单独拎出来,大概就是这样:

所以这里要先说清楚一件事:

opencode 不是根因,它只是刚好把这个问题引了出来。

再看 Volta

这个问题最迷惑的地方就在这里。

我当时没有直接去猜源码细节,而是先做了一个对照。

我先在沙盒环境里单独装了一套 Volta,然后用正常流程去装 Node 和包管理器。这时候一切都是正常的。没有递归,也没有卡死。如果没装对应工具,Volta 还会老老实实提示:

pnpm is not available.

Use `volta install pnpm` to select a default version.

或者:

Yarn is not available.

Use `volta install yarn` to select a default version.

这说明 Volta 本身不是一装上就会出问题。

后面我开始按自己当时装环境的步骤去 1 重放,结果很快就把问题缩小了:先手动设置 VOLTA_HOME,再用 Scoop 安装 Volta,就能把这个问题重新复现出来。

复现出来之后,我又去看了机器上 VOLTA_HOME 指向的目录,结果发现它基本是空的。至少从现象上看,Volta 运行时参考的是一套路径,但系统真正命中的命令入口又是另一套路径。

也正因为有了这层对照,我才开始怀疑:问题不是单纯的“Volta 有 bug”,而是 VOLTA_HOME 和 Scoop 实际接管的路径没有对上

我继续翻 Volta 的源码之后,发现它其实并不是完全没考虑递归。它在 crates/volta-core/src/run/executor.rs 里执行工具前,会打一个 _VOLTA_TOOL_RECURSION 环境变量,作为“已经递归进入过一次”的标记:

self.command.env(RECURSION_ENV_VAR, "1");
self.command.env("PATH", path);

pnpm / yarn 的执行分支也会先检查这个变量。如果已经处在递归场景里,就不再重新解析 Volta 当前要使用的工具环境,而是直接退回系统 PATH。对应逻辑分别在 crates/volta-core/src/run/pnpm.rscrates/volta-core/src/run/yarn.rs

let platform = match env::var_os(RECURSION_ENV_VAR) {
    Some(_) => None,
    None => Platform::current(session)?,
};

按理说,这套逻辑就是为了避免下面这种情况:

shim -> volta -> shim -> volta -> ...

但问题偏偏出在 Windows 这层 shim 的实现上。

真相

我继续去看了 Volta 的 Windows shim 实现,结果发现问题的关键点就在这里。它在 crates/volta-core/src/shim.rs 里生成的 shim 脚本本质上只有一句话:

@echo off
volta run %~n0 %*

也就是说,在 Windows 下,只要你命中了 Volta 的 shim,本质上就一定会进入一次 volta run <tool>

volta runcrates/volta-core/src/run/mod.rs 里真正执行工具前,又会先把 _VOLTA_TOOL_RECURSION 清掉:

env::remove_var(RECURSION_ENV_VAR);

这一步本来是合理的,因为 volta run 的目标本来就是“重新评估环境后再执行命令”。

可一旦它后面执行 pnpm / yarn 时,没有命中真正的命令文件,而是 又回到了 shim 本身,流程就会变成:

  1. 命中 shim。
  2. 进入 volta run pnpm
  3. 递归标记被清掉。
  4. 再次执行 pnpm
  5. PATH 又解析回 shim。
  6. 再来一轮。

于是递归保护每一轮都会被重置,最后完全起不到作用。

这条递归链如果画出来,会更直观一点:

更关键的是,Volta 在递归时并不是“清理所有可能路径”,它只会移除自己认定的两条 Volta 路径。Windows 下这部分逻辑在 crates/volta-core/src/layout/windows.rs

Ok(vec![home.shim_dir().to_owned(), install.root().to_owned()])

也就是:

  • VOLTA_HOME 对应的 shim 目录
  • Volta 自己的安装根目录

如果你的实际 PATH 命中的那个 shim,不在这两条路径里,那 Volta 就算做了 PATH 清理,也等于没有清到真正的目标。

这也和我前面的复现过程对上了:正常安装没问题,一旦把 VOLTA_HOME 提前改掉,再让 Scoop 去接管 Volta,路径就有机会分成两套。

这里再用一张图来看“为什么会清错路径”,就更容易理解了:

结论

问题到这里,基本就能串起来了。

我这次踩坑的关键,不在于“用了 Scoop”,而在于 用了 Scoop 之后,又手动设置了 VOLTA_HOME,试图把 Volta 的数据目录单独挪走

这样一来,就很容易出现两套对不上号的路径:

  1. Volta 自己根据 VOLTA_HOME 认为 shim 目录在 A。
  2. 但 PATH 实际优先命中的 shim 在 Scoop 管理的 B。
  3. 递归保护删掉的是 A。
  4. 实际反复命中的却还是 B。

如果用脱敏后的例子来表示,大概会像这样:

VOLTA_HOME=C:\Path\To\VoltaData

Volta 认为的 shim 目录:
C:\Path\To\VoltaData\bin

PATH 实际优先命中的路径:
C:\Path\To\Scoop\apps\volta\current\appdata\bin\pnpm.cmd

这两套路径只要不是同一套,Volta 的递归保护就有可能删错目标。

最后就会出现我一开始遇到的那个现象:你明明没主动用 yarn / pnpm 做任何事,后台却能不断拉起一串 cmd.exeyarnpnpm,直到整台机器一起变慢。

所以这篇文章最想留下的结论其实只有一句:

如果你是用 Scoop 安装 Volta,就不要再额外设置 VOLTA_HOME

至少在 Windows 这套路径布局里,这种“看起来更整洁”的目录拆分,很可能反而会让 Volta 的递归保护失效。

怎么确认

如果你现在也遇到了类似问题,可以先检查这几件事:

  1. 打开进程树,看是不是有大量重复的 cmd.exe
  2. 看命令链里是否反复出现 volta run pnpmvolta run yarn
  3. 检查自己是否是通过 Scoop 安装的 Volta。
  4. 检查环境变量里是否手动设置过 VOLTA_HOME
  5. 对比一下 VOLTA_HOME 指向的位置,和 PATH 里实际命中的 volta / pnpm / yarn 路径是不是同一套。

如果这几项能对上,基本就八九不离十了。

如果你也想自己顺着源码核对,可以优先看这几个文件:

  1. packages/opencode/src/installation/index.ts
  2. crates/volta-core/src/run/mod.rs
  3. crates/volta-core/src/run/executor.rs
  4. crates/volta-core/src/run/pnpm.rs
  5. crates/volta-core/src/run/yarn.rs
  6. crates/volta-core/src/shim.rs

小结

这次问题最容易让人绕进去的地方在于,表面上看是 opencode 打开后越来越卡,再往里看像是 pnpm / yarn 自己在抽风,但真正的问题其实是 手动设置 VOLTA_HOME 之后,Volta 参考的路径和 Scoop 实际接管的路径没有对上,最后把递归保护绕过去了

我这次最后得出的结论也很简单:如果你是用 Scoop 安装 Volta,就不要再额外给它指定一个单独的 VOLTA_HOME

评论

评论加载中…

输入关键词开始搜索。

选择Enter 打开Esc 关闭CtrlK 唤起