npx 安装卡死排查:workspace 误匹配与循环 peerDependencies

八月 24, 2026 [杂项] #npm #arborist

现象

在 deepseek-harness 仓库(pnpm workspace)根目录执行:

$ npx @deepseek-ai/dsh web
sh: 1: dsh: not found

瞬间失败,退出码 127。同样的命令换到仓库外(如 /home/peter)执行,则是另一个极端:CPU 100%、内存 1.9GB、几分钟没有任何输出。

两段症状截然不同,排查结论也完全不同:

  1. 仓库内失败:npm exec 把 workspace 里的同名包误判为"已安装",跳过下载直接去找 PATH 里的 dsh
  2. 仓库外卡死:npm arborist 在解析互相 peerDependencies 的循环依赖图时性能爆炸

以下按排查顺序记录完整过程,所有命令均可复现。


第一段:仓库内 dsh: not found

初始排查

$ npx --loglevel verbose @deepseek-ai/dsh web
npm verbose title npm exec @deepseek-ai/dsh web
...
sh: 1: dsh: not found
npm verbose exit 127

关键线索:verbose 日志只有 19 行,没有任何安装动作——没下载、没建缓存目录。~/.npm/_npx/ 下也没有新的缓存目录产生:

$ which dsh                    # PATH 中没有 dsh
dsh not found
$ ls ~/.npm/_npx/              # 无新增缓存目录

说明 npm 根本没走"安装到 npx 缓存"的路径,而是直接去执行一个名为 dsh 的命令——但 PATH 里没有它,于是 shell 报 command not found(127)。

插桩 libnpmexec

npm 11 的 npx 实现位于 npm 自带的 libnpmexec:

/home/peter/.nvm/versions/node/v26.3.0/lib/node_modules/npm/node_modules/libnpmexec/lib/index.js

备份后插入 console.error 调试标记,跟踪关键分支:

let pkgPaths = opts.pkgPath
console.error('DBG pkgPaths=', JSON.stringify(pkgPaths))
// ...
console.error('DBG needPackageCommandSwap=', needPackageCommandSwap)
console.error('DBG before loadActual')
console.error('DBG after loadActual')
console.error('DBG needInstall=', JSON.stringify(needInstall.map(x=>x.spec.raw)), 'commandManifest=', commandManifest && commandManifest._id)

重跑后拿到决定性输出:

DBG pkgPaths= "/home/peter/project/learn-dsh"
DBG needPackageCommandSwap= true
DBG before loadActual
DBG after loadActual
DBG needInstall= [] commandManifest= @deepseek-ai/dsh@0.1.0-rc.8
sh: 1: dsh: not found

needInstall=[],而 commandManifest 是本地 workspace 的版本 0.1.0-rc.8——npm 认为 @deepseek-ai/dsh 已经"装好了"(实际用的是仓库里 apps/cli 这个 workspace 包),于是跳过安装,把命令翻译成 dsh 直接执行。

根因:libnpmexec 的 workspace 误匹配

libnpmexec/lib/index.js 中 missingFromTree() 的这段逻辑:

if (spec.registry && spec.type !== 'tag' && !npxByNameOnly) {
  const nodesBySpec = tree.inventory.query('packageName', spec.name)
  for (const node of nodesBySpec) {
    if (shallow && node.depth) continue
    if (spec.rawSpec === '*') {
      return { node }          // ← 命中这里
    }
    ...

npx @deepseek-ai/dsh 被 npm-package-arg 解析成 rawSpec: '*' 的 range 规格,命中 spec.rawSpec === '*' 分支——不做任何版本比对,直接返回本地树中同名节点。本仓库的 workspace 恰好包含 apps/cli(包名 @deepseek-ai/dsh),于是:

  1. npm 认为已安装 → 跳过 npx 缓存安装
  2. args[0] 被替换为 bin 名 dsh
  3. 直接 sh -c 'dsh web'——但 pnpm workspace 不会把该包的 bin 链到根 node_modules/.bin(根 package.json 没有依赖它),PATH 中不存在 dsh → 127

用独立脚本验证了树中的实际节点状态(脚本需放在 npm 的 node_modules 内以解析 @npmcli/arborist 等依赖):

const spec = npa('@deepseek-ai/dsh', '/home/peter/project/learn-dsh')
console.log('spec:', JSON.stringify(spec))       // rawSpec: '*'

const manifest = await pacote.manifest(spec, flatOptions)
console.log('manifest._resolved:', manifest._resolved)
// https://registry.npmmirror.com/@deepseek-ai/dsh/-/dsh-0.1.1-rc.2.tgz

const localTree = await new Arborist({ path: '/home/peter/project/learn-dsh' }).loadActual()
for (const node of localTree.inventory.query('packageName', '@deepseek-ai/dsh')) {
  console.log('node:', node.pkgid)               // cli@npm:@deepseek-ai/dsh@0.1.0-rc.8
  console.log('package.resolved:', node.package.resolved)  // undefined
  console.log('realpath:', node.realpath)        // .../apps/cli
}

绕过方案(仓库内)

钉版本后 rawSpec 不再是 '*',会走版本比对分支,从而绕过 workspace 误匹配:

npx --yes @deepseek-ai/dsh@latest web

验证有效(开始正常安装)。其他两个等价方案:

# 1. 到仓库外执行(无同名 workspace 包)
cd /home/peter && npx @deepseek-ai/dsh web

# 2. 仓库内开发直接用本地源码入口(root script)
pnpm dsh

注意:npx --yes 才会跳过交互确认;@latest 钉版本是为了跳过 workspace 匹配。pnpm dsh 是仓库自身定义的 node --import tsx/esm apps/cli/src/bin.ts,才是仓库内开发的正道。


第二段:仓库外 CPU 100% 卡死

症状分类:CPU 密集 ≠ 网络等待

$ ps aux | grep "npm exec"
peter  1422431  98.2  7.3  3569764 1948164 pts/8  Rl+ 13:27  2:13 npm exec @deepseek-ai/dsh web

100% CPU + 1.9GB 内存。网络下载时 CPU 应该很低,这个组合指向纯计算。

排除法:确认没在下载、也不是死循环

$ ss -tnp | grep 1422431                    # 无任何 TCP 连接
$ find ~/.npm/_cacache -newermt "13:25"     # 无新增下载
$ timeout 3 strace -p 1422431 -f -e trace=write,read,epoll_wait,futex,connect,recvfrom
# 只有 futex(线程间同步),没有 connect/read

strace 里没有网络和磁盘系统调用。再观察 npm 日志:

$ wc -l ~/.npm/_logs/2026-08-24T05_27_24_258Z-debug-0.log
597
$ sleep 20 && wc -l ...                     # → 600 → 601

行数在增长、mtime 在更新——不是死循环,是极慢地推进。

日志定位阶段:卡在 idealTree 构建

$ head -30 <最新日志>
silly idealTree buildDeps
silly fetch manifest @deepseek-ai/dsh@0.1.1-rc.2
silly placeDep ROOT @deepseek-ai/dsh-client-ui-theme@0.1.1-rc.2 OK for: @deepseek-ai/dsh-client-ui-layout@0.1.1-rc.2 want: ^0.1.1-rc.2

placeDep 每放置一个依赖节点写一行日志。正常包几百行几秒完成;这里每行要几十秒——每个节点放置都要对已建的部分树做大量重复校验,强烈指向循环引用 / peerDependencies 爆炸。

验依赖图:发布包确实存在循环 peerDependencies

$ npm view @deepseek-ai/cordis@4.0.1 peerDependencies
{
  '@deepseek-ai/cordis-plugin-loader': '^1.0.2',
  '@deepseek-ai/cordis-plugin-include': '^1.0.6'
}

$ npm view @deepseek-ai/cordis-plugin-loader@1.0.2 peerDependencies
{ '@deepseek-ai/cordis': '^4.0.1', 'node-addon-require-builtin': '^0.1.4' }

$ npm view @deepseek-ai/cordis-plugin-include@1.0.6 peerDependencies
{ '@deepseek-ai/cordis-plugin-loader': '^1.0.2', '@deepseek-ai/cordis': '^4.0.1' }

$ npm view @deepseek-ai/cordis-plugin-hmr@1.0.16 peerDependencies
{ '@deepseek-ai/cordis-plugin-timer': '^1.1.3', '@deepseek-ai/cordis': '^4.0.1' }

环坐实:cordis ⇄ loader ⇄ include,加上 60+ 个 @deepseek-ai/dsh-* 包全部 peerDepend 回 @deepseek-ai/cordis(本仓库 AGENTS.md 的约定:"cordis 是每个 harness 包的 peerDependency")。

对照组:证明"图可解,只是 npm 慢"

$ cd /tmp && npx @deepseek-ai/dsh@latest --version   # 同样卡 5 分钟+

与目录无关,是 npm 解析器对 peer 环的通病。而仓库自己的 pnpm-lock.yaml 对同一张图有完整解析——pnpm 秒级。


概念澄清

循环依赖是不是 bug?

要区分两层:

层面语言/生态循环是否允许
源码 importJS/TS (ESM)允许(live binding + 函数提升;注意顶层 const 会 TDZ)
源码 importPython允许但 import 期穿越环就 ImportError,需延迟 import 补丁
源码 importRust同 crate 允许;跨 crate 报 cyclic package dependency detected
源码 includeC++允许(头文件守卫 + 前置声明 + 链接器处理互引)
包清单依赖npm/pnpm允许——依赖树被扁平化切断,环在安装期就是合法图

而 cordis 的环在包清单层(peerDependencies),是元数据不是执行图,连"编译错误"都不涉及,只是 npm 解析慢。

dependencies vs peerDependencies

dependenciespeerDependencies
含义自己运行要用的库,自动装不装自己的副本,要求宿主提供,必须共享同一个实例
安装自动(可多版本共存)npm 7+ 自动装顶层;npm 6 只警告
场景普通工具库插件/框架扩展、单例需求

插件为什么必须 peerDep:两份拷贝破坏身份——instanceof、事件总线、全局注册表全部分家。cordis 是 "everything is a plugin" 架构,连"加载插件"本身都是插件(cordis-plugin-loader),所以 core 需要 loader、loader 又必须共享同一个 core 实例——互相 peerDep 是这个架构的必然产物,环合法且是刻意的。

为什么 npm 这么慢、pnpm 这么快

npm arborist 的 idealTree 构建目标苛刻:同一个包尽量只装一份,且每个 peerDep 都必须由已放置的节点满足。每放置一个包:

  1. 在整棵已建的树里搜索满足 peerDep 的现成节点
  2. 找到就挂上去,找不到就新建节点
  3. 新建节点可能让已满足的 peer 边失效 → 重新解析 → 连锁反应

复杂度 O(n²) 起步。有环时不存在拓扑序,算法只能反复迭代到不动点,环上的包互相触发,轮次随树增长——这就是 CPU 100% 每行日志几十秒的来源。

pnpm 快是因为它根本没解同一个难题:

所以现状是分工:npm 慢而严格(正确优先,ERESOLVE 大声失败),pnpm 快而宽松(能用优先,静默容忍)。npm 不抄 pnpm 的原因:扁平 node_modules 物理上难容纳多版本、历史语义承诺、以及病态图在真实世界极少见(npm 优化了常见场景)。


解决与对比

# 仓库内(绕过 workspace 误匹配)
npx --yes @deepseek-ai/dsh@latest web

# 仓库外
cd /home/peter && npx @deepseek-ai/dsh web        # npm:会卡在 peer 环解析
cd /home/peter && pnpm dlx @deepseek-ai/dsh@latest web   # 推荐:pnpm 秒级

pnpm dlx 与 npx 对比:

npx (npm exec)pnpm dlx
解析目标先查本地 tree/workspace 同名包,再查全局,最后装 ~/.npm/_npx/<hash>直接按 registry 解析,不查 workspace/全局
确认提示交互询问 "Ok to proceed?"无提示
缓存npm 缓存,每命令独立目录pnpm 内容寻址 store,临时目录跑完即弃
依赖解析npm arborist(peer 环会卡)pnpm 解析器(秒级),peer 自动补装

注意区分:pnpm dlx 取远程包执行;pnpm exec / pnpm dsh 执行项目内已装的。


总结

  1. 仓库内 npx 失败是 npm 行为缺陷:rawSpec === '*' 分支不做版本比对就返回 workspace 同名节点,误判"已安装"后跳过安装,bin 又不在 PATH → 127。钉版本或到仓库外可绕过。
  2. 仓库外卡死不是网络、不是死循环,是 npm arborist 对合法但病态的循环 peerDependencies 图做全局满足验证时的性能爆炸;图本身可解(pnpm 秒级),问题在解析器算法。
  3. 循环依赖不是 bug:dependencies 环被树扁平化切断,peerDependencies 环是插件框架的标准形态(cordis 全员 peerDep 核心是刻意设计)。
  4. npm 与 pnpm 的取舍是正确性 vs 性能的分工:npm 保证单实例与大声失败,pnpm 默认静默容忍版本冲突。