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、几分钟没有任何输出。
两段症状截然不同,排查结论也完全不同:
- 仓库内失败:npm exec 把 workspace 里的同名包误判为"已安装",跳过下载直接去找 PATH 里的
dsh - 仓库外卡死: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),于是:
- npm 认为已安装 → 跳过 npx 缓存安装
args[0]被替换为 bin 名dsh- 直接
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?
要区分两层:
| 层面 | 语言/生态 | 循环是否允许 |
|---|---|---|
| 源码 import | JS/TS (ESM) | 允许(live binding + 函数提升;注意顶层 const 会 TDZ) |
| 源码 import | Python | 允许但 import 期穿越环就 ImportError,需延迟 import 补丁 |
| 源码 import | Rust | 同 crate 允许;跨 crate 报 cyclic package dependency detected |
| 源码 include | C++ | 允许(头文件守卫 + 前置声明 + 链接器处理互引) |
| 包清单依赖 | npm/pnpm | 允许——依赖树被扁平化切断,环在安装期就是合法图 |
而 cordis 的环在包清单层(peerDependencies),是元数据不是执行图,连"编译错误"都不涉及,只是 npm 解析慢。
dependencies vs peerDependencies
dependencies | peerDependencies | |
|---|---|---|
| 含义 | 自己运行要用的库,自动装 | 不装自己的副本,要求宿主提供,必须共享同一个实例 |
| 安装 | 自动(可多版本共存) | 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 都必须由已放置的节点满足。每放置一个包:
- 在整棵已建的树里搜索满足 peerDep 的现成节点
- 找到就挂上去,找不到就新建节点
- 新建节点可能让已满足的 peer 边失效 → 重新解析 → 连锁反应
复杂度 O(n²) 起步。有环时不存在拓扑序,算法只能反复迭代到不动点,环上的包互相触发,轮次随树增长——这就是 CPU 100% 每行日志几十秒的来源。
pnpm 快是因为它根本没解同一个难题:
strict-peer-dependencies=false(默认)→ peerDep 当普通依赖自动装一份,不参与反复求解- 内容寻址 store + 硬链接,多版本共存免费
- 代价:不保证全局单例——两个插件要不同版本 cordis 就装两份,运行时悄悄出 bug(身份分裂),出错时机从安装期推迟到运行期
所以现状是分工: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执行项目内已装的。
总结
- 仓库内
npx失败是 npm 行为缺陷:rawSpec === '*'分支不做版本比对就返回 workspace 同名节点,误判"已安装"后跳过安装,bin 又不在 PATH → 127。钉版本或到仓库外可绕过。 - 仓库外卡死不是网络、不是死循环,是 npm arborist 对合法但病态的循环 peerDependencies 图做全局满足验证时的性能爆炸;图本身可解(pnpm 秒级),问题在解析器算法。
- 循环依赖不是 bug:
dependencies环被树扁平化切断,peerDependencies环是插件框架的标准形态(cordis 全员 peerDep 核心是刻意设计)。 - npm 与 pnpm 的取舍是正确性 vs 性能的分工:npm 保证单实例与大声失败,pnpm 默认静默容忍版本冲突。