opencode 启动慢排查:plugin 触发 60MB npm 依赖下载

七月 21, 2026 [debug, linux] #opencode #debug #troubleshooting #strace #npm #bun #plugin #startup

现象

wfuzz agent --agent-config=wfuzz-utils,wfuzz-db 执行后,opencode 长时间无响应。没有报错,没有 crash,进程在 /proc 中显示为 S(sleeping)状态,CPU 低,但终端就是不动。

单独执行 opencode 也一样:只要设置了 OPENCODE_CONFIG 环境变量,启动就卡。

第一轮排查:以为是死锁

strace 看系统调用

strace -f -p <pid> 跟踪进程。主线程在 epoll_wait 上等待,worker 线程(40+ 个)全部卡在 futex

[pid 10] futex(..., FUTEX_WAIT_BITSET_PRIVATE, ...) = 0
[pid 11] futex(..., FUTEX_WAIT_BITSET_PRIVATE, ...) = 0
[pid 12] futex(..., FUTEX_WAKE_PRIVATE, 1) = 1
...

Worker 线程在 work-stealing 模式下互相唤醒,但没有一个线程在做实际工作。strace 最后一行出现:

[pid 9] futex(...) = -1 ETIMEDOUT (Connection timed out)

看起来像是 Bun worker pool 在进行网络操作时超时,之后所有 worker 陷入 livelock —— 被不断唤醒但无事可做。

对比实验

场景结果
OPENCODE_CONFIG正常启动,TUI 渲染
OPENCODE_CONFIG 指向 {} 空配置正常启动
OPENCODE_CONFIG 指向含 plugin 字段的配置卡住
最小插件(return {}卡住
--pure 模式卡住
serve 模式(headless)终端不卡,但端口不监听、WebSocket 无响应

关键发现:只要配置中有 "plugin": [...],不论插件内容是什么,TUI 和 serve 模式都卡。 一度怀疑是 opencode 1.17.12 的 plugin 加载 bug。

第二轮排查:找到真正的根因

换观察角度:文件系统 + 网络

回头检查 /root/.npm/_cacache/

$ du -sh /root/.npm/
83M    /root/.npm/

83M!进程并不是死锁,而是在下载 npm 包

再看 opencode 的文件描述符:

$ ls -la /proc/<pid>/fd/ | grep npm
l-wx------ 30 -> /root/.npm/_cacache/tmp/de55a9bf-...
lrwx------ 31 -> socket:[...]    # 连接 npm registry (Cloudflare 104.16.x.34)
lrwx------ 32 -> socket:[...]

opencode 生成了 ~/.config/opencode/package.json

{
  "dependencies": {
    "@opencode-ai/plugin": "1.17.12"
  }
}

然后自动调用 @npmcli/arborist.reify() 安装依赖。@opencode-ai/plugin 的依赖树非常大:

@opencode-ai/plugin@1.17.12
├── effect@4.0.0-beta.83        ← 最大,带 12 个子依赖
│   ├── fast-check + pure-rand
│   ├── find-my-way-ts
│   ├── kubernetes-types
│   ├── msgpackr + msgpackr-extract (native .node 二进制)
│   ├── multipasta
│   ├── toml / uuid / yaml / ini
│   └── @standard-schema/spec
├── zod@4.1.8
├── @ai-sdk/provider@3.0.8
│   └── json-schema
└── @opencode-ai/sdk@1.17.12
    └── cross-spawn → path-key → shebang-command → which → isexe

总共 27 个包,解压后 63MB。在容器环境中无 npm 缓存,下载需要数十秒到数分钟。期间 opencode 不监听端口、不响应 WebSocket、TUI 不渲染 —— 宏观表现就是"卡死了"。

验证

手动模拟 opencode 的行为:

$ echo '{"dependencies":{"@opencode-ai/plugin":"1.17.12"}}' > package.json
$ time npm install --production
added 27 packages in 51s
$ du -sh node_modules/
63M    node_modules/

51 秒。这解释了为什么启动"时快时慢" —— 有缓存的机器几秒,无缓存的容器一分钟以上。

为什么在容器里尤其严重

  1. 每次启动都是新容器,/root/.npm/~/.config/opencode/ 都是空的
  2. 没有 Bun 的 package 缓存
  3. npm registry 从容器网络访问可能更慢

平台侧虽然设置了 OPENCODE_DISABLE_MODELS_FETCH=1(跳过 AI 模型列表拉取),但这只影响 model fetching,不影响 plugin 系统的 npm 依赖安装(使用 @npmcli/arborist)。

解决方案

方案一:去掉 plugin(最快)

opencode.jsonc 中删除 "plugin" 字段。当前 wfuzz-agent.ts 主要是调试日志 hook,唯一有实际功能的 experimental.chat.system.transform opencode 原生就支持(自动读取 AGENTS.md)。后续如果需要 hook 功能再考虑其他注入方式。

方案二:预装 node_modules(保持 plugin 可用)

在构建时(build.sh)提前执行 npm install,把 63MB 的 node_modules/ 打进发布包。运行时由 wfuzz-agent(Rust 侧)在 exec opencode 之前把 node_modules + package.json 复制到 ~/.config/opencode/

构建流程改动(build.sh):

# 下载 opencode 后
OPENCODE_VERSION=$("$BIN_DIR/opencode" --version)
cat > agent-config/package.json <<EOF
{"dependencies":{"@opencode-ai/plugin":"$OPENCODE_VERSION"}}
EOF
(cd agent-config && npm install --production)
# → node_modules/ 被打进发布包

运行时改动(main.rs):

// exec opencode 之前
let src = bin_dir.parent().join("agent-config/node_modules");
let dst = PathBuf::from(env::var("HOME")?).join(".config/opencode/node_modules");
if src.exists() && !dst.exists() {
    fs::create_dir_all(dst.parent())?;
    copy_dir_recursive(&src, &dst)?;
    fs::write(dst.parent().join("package.json"), bundled_pkg_json)?;
}
// opencode 启动时 bun install 发现 node_modules 已存在 → 跳过下载

复制 63MB 本地文件的耗时远小于网络下载(毫秒级 vs 几十秒),且不受网络环境、npm registry 可用性的影响。

后续分析源码分析篇 深入了 ConfigPaths.directories()Npm.install()waitForDependencies() 的调用链,解释了为什么每个 .opencode 目录都会触发依赖安装。验证篇 通过注入调试日志实测了完整耗时。

方案三:升级 opencode

测试了 1.18.3 版本,行为完全一致。看起来这个机制是 opencode 设计如此 —— 有 plugin 就必须装依赖。只能从工程侧规避。

排查方法论小结

这次排查犯了一个典型错误:看到 strace 输出 futex + ETIMEDOUT 就下了"死锁"的结论。实际是 Bun 的 worker pool 正常工作模式 —— 线程在等待 I/O(npm 下载),被唤醒处理数据包,然后继续等待。livelock 只是假象。

教训:

  1. strace 看系统调用只能告诉你进程在"做什么类型的等待",不能直接告诉你"在等什么"
  2. 怀疑死锁前,先检查文件系统和网络 —— 进程可能在静静地下载东西
  3. 对比实验是定位根因的最可靠手段 —— 控制变量(有无 plugin、有无 config)快速排除干扰因素
  4. /proc/<pid>/fd/ 是看进程 IO 目标的第一手资料,比 strace 更直观

工具速查

# 查看进程打开的文件
ls -la /proc/<pid>/fd/

# 查看进程网络连接
ss -tnp | grep <pid>

# 查看 npm 缓存大小
du -sh ~/.npm/

# 查看 opencode 日志
cat ~/.local/share/opencode/log/opencode.log

# 查看进程线程数和等待原因
ls /proc/<pid>/task/ | wc -l
cat /proc/<pid>/wchan

# 测试 WebSocket 连通性(bash)
exec 3<>/dev/tcp/127.0.0.1/4096
echo -en "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n" >&3
head -c 100 <&3