opencode 界面卡住不动排查
七月 15, 2026 更新: 九月 11, 2026 [C/C++] #Linux #调试 #strace #SSH #Git #lock #ipv6 #opencode现象
opencode 交互界面一直显示运行中,不输出任何新内容,像是卡死了一样。
第一反应:strace
strace -p <pid> 看一下卡在哪个系统调用上:
strace -p 4188775
结果有大量输出,epoll_wait → read → write → epoll_wait 循环不断,并没有阻塞在某个系统调用上。
这说明进程本身在运行,事件循环在持续工作。
换个思路:进程状态 + 子进程树
strace 输出太多看不出问题,那就从最常规的进程状态入手。
看进程在等什么
ps -p 4188775 -o pid,stat,wchan,cmd
# → 4188775 Sl+ do_epoll_wait opencode --log-level DEBUG
do_epoll_wait 只是某个采样时刻的快照。opencode 是事件循环模型,绝大多数时间都短暂地阻塞在 epoll_wait 上等待 I/O 事件,ps 拍到的 wchan 几乎永远是 do_epoll_wait,不代表一直卡在同一个调用上。
但这个信息告诉我们一件事:进程是 S (sleeping) 状态,不是 R (running),也不是 D (uninterruptible sleep)。没有死循环,没有内核态阻塞。
看子进程树
pstree -p 4188775
输出:
opencode(4188775)───git(2867)───ssh(2868)
一目了然:opencode 下面挂着 git push,git 下面挂着 ssh。阻塞链条是 opencode → git → ssh。
再看 git 进程的状态:
ps -p 2867 -o pid,stat,wchan,etime,cmd
# → 2867 Ssl do_wait 04:13 git push -u myself dev
git push 已经跑了 4 分多钟,在 do_wait 等待子进程 ssh 结束。
定位叶子进程:SSH 连接出了什么问题
看网络连接
ss -tnp | grep 2868
# → ESTAB 0 293076 192.168.5.99:60362 → 20.205.243.166:22 users:(("ssh",pid=2868,fd=3))
关键信息:
| 字段 | 值 | 含义 |
|---|---|---|
| State | ESTAB | TCP 连接已建立 |
| Recv-Q | 0 | 没有待读数据 |
| Send-Q | 293076 (~286KB) | 发送缓冲区积压了 286KB |
| 目的地址 | 20.205.243.166:22 | GitHub SSH 端口 |
TCP 三次握手已完成,但 Send-Q 积压 286KB,说明本地发出了数据而 GitHub 端没有确认 TCP 报文。这是典型的网络不通畅:连接能建起来,但数据传不过去。
看环境变量
cat /proc/2868/environ | tr '\0' '\n' | grep -i proxy
HTTPS_PROXY=http://192.168.5.244:10808
HTTP_PROXY=http://192.168.5.244:10808
all_proxy=socks5://192.168.5.244:10808
注意:SSH 不使用 HTTP/SOCKS 环境变量,它会直连目标主机的 22 端口。HTTP/SOCKS 代理只对 HTTP 客户端和部分应用生效,SSH 不受影响。
根因
git push 使用 SSH 协议推送到 GitHub(git@github.com:...),SSH 直连 github.com:22。本地网络到 GitHub 22 端口的 TCP 路径不畅通(可能被防火墙阻断或被 QoS 限速)。
排查思路总结
进程状态 (ps) ← 宏观:是 sleep / run / 内核阻塞?
│
▼
子进程树 (pstree) ← 谁在等谁?
│
▼
叶子进程网络状态 (ss -tnp) ← 网络有没有问题?
│
▼
环境变量 (/proc/pid/environ) ← 代理/环境因素
strace看到大量事件循环输出时,不要被干扰。事件循环模型下ps看到的epoll_wait几乎永远不变,关键是它等的那件事有没有进展——用pstree一层层追到最底层,看叶子进程的状态。
第二次排查:锁争抢(Lock Contention)
同一天稍晚,另一个 opencode 进程(PID 138479)又卡住了。这次表现不同:CPU 占用 18%,看起来"活着"但响应极慢。
初步观察
ps -p 138479 -o pid,stat,wchan,pcpu,cmd
# → 138479 Sl+ do_epoll_wait 18.0 opencode --log-level DEBUG
cat /proc/138479/status | grep -E 'State|VmRSS|Threads'
# → State: R (running)
# → VmRSS: 790120 kB
# → Threads: 40
注意 ps 显示 Sl+(sleep),但 /proc/pid/status 显示 R(running)。790MB RSS,40 个线程,18% CPU —— 进程在干活,但效率极低。
strace 采样
strace -p 138479 -e trace=all -c -S time 2>&1 &
sleep 5; kill %1 2>/dev/null; wait 2>/dev/null
5 秒内的系统调用分布:
% time seconds calls errors syscall
79.64 0.097539 20345 252 futex
16.14 0.019772 2649 sched_yield
2.56 0.003137 389 epoll_pwait2
0.67 0.000821 1616 clock_gettime
0.29 0.000359 277 pread64
0.18 0.000215 281 read
关键数据:
- 79.6% 时间花在 futex — 20,345 次调用,线程在抢锁
- 16.1% 花在 sched_yield — 2,649 次,争不到锁主动让出 CPU
- 真正的 I/O(read/write)占比可以忽略
线程分布
ps -Lp 138479 -o lwp,psr,pcpu,stat,wchan:32 --no-headers | sort -k3 -rn | head -10
lwp psr pcpu stat wchan
138479 0 7.3 Rl+ - # 主线程 running
142051 3 3.4 Sl+ do_epoll_wait
138494 2 2.2 Sl+ do_epoll_wait
138487 14 0.9 Sl+ futex_wait_queue # 7个线程等锁
138484 0 0.9 Rl+ futex_wait_queue # 1个线程在 futex 中 running
...
~20 个线程 在 futex_wait_queue,1 个主线程在用户态跑,其余在 do_epoll_wait。
根因分析
进一步跟踪 futex 操作:
strace -p 138479 -e trace=futex 2>&1 | head -20
futex(0x7f601c0b4154, FUTEX_WAKE_PRIVATE, 1) = 1
futex(0x7f601c0c0154, FUTEX_WAKE_PRIVATE, 1) = 1
futex(0x7f601c0e0150, FUTEX_WAKE_PRIVATE, 1) = 1
futex(0x7f601c0b4154, FUTEX_WAKE_PRIVATE, 1) = 1
...
futex(0x7f601c01f154, FUTEX_WAIT_BITSET_PRIVATE|FUTEX_CLOCK_REALTIME,
125123, NULL, FUTEX_BITSET_MATCH_ANY) = -1 EAGAIN
模式:大量 FUTEX_WAKE_PRIVATE 唤醒 + 偶尔 FUTEX_WAIT_BITSET_PRIVATE 超时(EAGAIN)。多个 futex 地址集中在同一内存区(0x7f601c0bxxx-0x7f601c0fxxx),像是一个线程池任务队列上的锁。
结论:惊群效应 + 严重锁争抢。
- 有大量线程同时被唤醒去竞争少数任务或资源
- 大部分线程发现没活可干又 sleep,如此循环
- CPU 大部分消耗在 futex 调度上而非实际工作
- 进程"活着"(能响应请求),但有效吞吐极低
可能诱因:
- 线程池过大(40 线程 vs 实际 CPU 核数),过多线程争抢同一把锁
big-pickle本地 LLM 推理慢,IO 线程在等待结果期间造成锁堆积- 39 个 LSP server 同时启动,大量并发初始化请求冲击线程池
第三次排查:IPv6 连接卡住 SYN-SENT
解决第一个进程后,又启动了一个新的 opencode(PID 183287)。这次 CPU 很低(2.8%),但也不响应。
基础信息
ps -p 183287 -o pid,stat,wchan,pcpu,cmd
# → 183287 Sl+ do_epoll_wait 2.8 opencode --log-level DEBUG
cat /proc/183287/status | grep -E 'State|VmRSS|Threads'
# → State: S (sleeping)
# → VmRSS: 511812 kB
# → Threads: 16
16 线程,512MB,2.8% CPU,状态 S(sleeping)。和上一个完全不同。
线程分布
lwp pcpu wchan
183348 0.8 do_epoll_wait
183287 0.6 do_epoll_wait # 主线程
183295 0.1 futex_wait_queue
...其他线程都在 sleep...
所有线程 ≤ 0.8% CPU,大部分在 futex 上 sleep。进程完全空闲,不是在忙,是真的在等什么。
看文件描述符
ls -la /proc/183287/fd/ | grep socket
35 -> socket:[56963599]
36 -> socket:[56963601]
37 -> socket:[56963600]
38 -> socket:[56962309]
39 -> socket:[56963602]
40 -> socket:[56963603]
共 6 个 socket。用 ss 看它们的连接状态:
ss -npep | grep 183287
| fd | TCP 状态 | 本地 | 远端 | 说明 |
|---|---|---|---|---|
| 38 | ESTABLISHED | 192.168.5.99:53102 | 172.67.69.147:443 | Cloudflare IPv4 ✅ |
| 40 | ESTABLISHED | 192.168.5.99:59558 | 20.205.243.165:443 | Microsoft/GitHub ✅ |
| 35 | SYN-SENT | [3000::5054:ff:fe87:f354]:38682 | [2606:4700::6810:a22]:443 | Cloudflare IPv6 ❌ |
| 36 | SYN-SENT | [3000::5054:ff:fe87:f354]:60690 | [2606:4700::6810:422]:443 | Cloudflare IPv6 ❌ |
| 37 | SYN-SENT | [3000::5054:ff:fe87:f354]:46750 | [2606:4700::6810:222]:443 | Cloudflare IPv6 ❌ |
| 39 | SYN-SENT | [3000::5054:ff:fe87:f354]:49118 | [2606:4700::6810:322]:443 | Cloudflare IPv6 ❌ |
4 个连接卡在 SYN-SENT,目标全是 Cloudflare 的 IPv6 地址段 2606:4700::/32。
验证 IPv6 不通
ip -6 route show default
# → default via fe80::c0b1:65ff:fe3e:68c3 dev enp1s0
ping6 -c 2 -W 2 2606:4700::6810:a22
# → 2 packets transmitted, 0 received, 100% packet loss
IPv6 路由存在,但实际不通 —— ping Cloudflare IPv6 地址 100% 丢包。
根因分析
big-pickle 模型 API 通过 Cloudflare 托管,DNS 同时返回了 A 和 AAAA 记录。Node.js HTTP 客户端默认执行 Happy Eyeballs(RFC 8305):优先尝试 IPv6,如果连接建立缓慢,才回退到 IPv4。
问题在于:
- DNS 返回了多个 Cloudflare IPv6 地址
- Node.js 尝试并发连接所有 IPv6 地址
- 内核发出 SYN 后收不到 SYN-ACK,连接进入 SYN-SENT 状态
- 内核每 20 秒重传一次 SYN,总超时可达 120 秒以上
- 在此之前,IPv4 的请求即使已成功,部分客户端逻辑可能仍在等待 IPv6 尝试完成
这解释了为什么进程看起来"卡住了":它不是在死循环,而是事件循环被大量 pending 的 TCP 连接超时拖着,无法及时处理已就绪的请求。
临时缓解
# 方式 1:Node.js 层优先 IPv4
NODE_OPTIONS="--dns-result-order=ipv4first" opencode
# 方式 2:系统层禁用 IPv6
sudo sysctl -w net.ipv6.conf.all.disable_ipv6=1
根本修复
排查 IPv6 路由/网关问题,或确保 big-pickle API 域名在本地只解析到 IPv4。
第四次排查:shell tool 超时失效,子进程退出后仍卡死
现象:opencode 卡在一条 ssh dut ... 命令上,TUI 一直显示运行中。命令显式传了 timeout: 180000(3 分钟),但 20 分钟后仍未恢复。
确认 tool 的真实状态
会话数据在 SQLite 里,可以只读查工具调用的状态:
sqlite3 "file:$HOME/.local/share/opencode/opencode.db?mode=ro" \
"select id, substr(data,1,400) from part where message_id='msg_xxx';"
关键输出:
{"type":"tool","tool":"bash","state":{
"metadata":{"output":"=== 宿主 9119 占用检查:\nfree\n=== 建立 forward:\n"},
"status":"running",
"input":{"command":"...","timeout":180000}}}
status: running。再对照日志,只有 tool.execute.before,没有 tool.execute.after:
rg -a "run=<run-id>" ~/.local/share/opencode/log/opencode.log | tail -30
说明不是命令还在跑,而是工具调用本身没有返回。
进程现场:没有子进程,线程全空闲
ps -eo pid,ppid,etime,stat,pcpu,args | grep opencode
ps --ppid <opencode-pid> -o pid,etime,stat,args
cat /proc/<opencode-pid>/task/*/children
ps -L -p <opencode-pid> -o tid,stat,wchan:30,pcpu
结果:
- 子进程已经不存在(超时确实把 ssh 杀了)
- 所有线程停在
do_epoll_wait/futex_wait,CPU 空闲 - 没有 pending 的定时器
也就是说 opencode 不是在执行,而是在等一个永远不会完成的事件。
看 fd:socket 的对端是一个 ssh mux master
ls -l /proc/<opencode-pid>/fd
ss -xp | grep <socket-inode>
lr-x------ ... 32 -> pipe:[68117385]
l-wx------ ... 34 -> pipe:[68117385]
lrwx------ ... 43 -> socket:[69068876]
u_str ESTAB 0 0 * 69068877 * 69068876 users:(("ssh",pid=475007,fd=16))
socket 的另一端不在已经死掉的子进程里,而在一个持久进程里:
ssh: /home/peter/.ssh/socket/smyl@192.168.7.1-22 [mux]
用 ps -o lstart 对比启动时间,确认 master 正是同一个 opencode run 第一次 ssh dut 时创建的。
两个关键机制
1. 进程退出 ≠ stdio 关闭
Node/Bun 的 child_process 有两个事件:
| 事件 | 触发条件 |
|---|---|
exit | 进程结束 |
close | 进程结束 且 stdout/stderr 全部 EOF |
opencode 的 spawner 只在 close 上完成内部 Deferred(packages/core/src/cross-spawn-spawner.ts):
proc.on("exit", (...args) => { exit = args }) // 只记录
proc.on("close", (...args) => { // 只有这里完成 Deferred
if (end) return
end = true
Deferred.doneUnsafe(signal, Exit.succeed(exit ?? args))
})
exitCode 和 kill 都 await 这个 Deferred。子进程死了只触发 exit,不触发 close → 永远等不到。
2. SSH ControlMaster 把客户端的 stdio fd 传给了 master
~/.ssh/my_config:
ControlMaster auto
ControlPath ~/.ssh/socket/%r@%h-%p
ControlPersist 1h
ssh 复用已有 master 时,会把客户端自己的 stdin/stdout/stderr 通过 SCM_RIGHTS 发给 master(OpenSSH mux.c):
/* Send the stdio file descriptors */
if (mm_send_fd(fd, STDIN_FILENO) == -1 ||
mm_send_fd(fd, STDOUT_FILENO) == -1 ||
mm_send_fd(fd, STDERR_FILENO) == -1)
之后数据通路是 master ↔ 远端,本地客户端只是等待者。客户端死了,master 还拿着这些 fd。
卡死链条
- 远端
openshell forward start 9119 pi -d卡住不退出 → SSH channel 不关 - 3 分钟超时到点:opencode kill 子进程组(
packages/opencode/src/tool/shell.ts的Effect.raceAlltimeout 分支) - ssh 子进程死了 →
exit触发 - 但 stdout 的子端还在 mux master 手里 → opencode 的 fd 43 等不到 EOF →
close不触发 - spawner 的 Deferred 永不完成;超时收尾的
handle.kill()发完信号后 await 的也是它 → 永久挂起 - tool part 停在
running,界面看起来卡死
为什么超时兜不住
默认超时(2 分钟,可用 OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS 覆盖)只保证发出 kill 信号,不保证 kill 返回。正常命令子进程一死、stdio 关闭,工具立刻返回;这个 case 的等待条件(close)被 master 破坏,超时链路自己挂在收尾阶段。
另外 kill 打的是子进程组(detached: true 让子进程自成一组),mux master 是 setsid 出去的独立进程(PPID=1),不在组里,杀不到。
解卡与验证
# 让 master 退出,释放它持有的 fd
GPG_TTY=/dev/nonexistent ssh -O exit dut
# → Exit request sent.
master 退出后立刻验证:
ps -o pid,ppid,etime,args -p <mux-master-pid>
# → 进程已不存在
同时 SQLite 里卡了 35 分钟的 tool part 瞬间变成 completed,输出里补上了超时提示:
{"state":{"status":"completed",
"time":{"start":1789109953224,"end":1789112057634},
"metadata":{"output":"=== 宿主 9119 占用检查:\nfree\n=== 建立 forward:\n","exit":null},
"output":"...\n<shell_metadata>\nshell tool terminated command after exceeding timeout 180000 ms. ...\n</shell_metadata>"}}
日志里也补上了缺失的 tool.execute.after,session 继续往下走:
07:34:17 tool.execute.after messageId=msg_08f434c290015k7qJN7Xkp3Haa
07:34:17 loop step=13
时间线对照:
14:59:13 命令启动(timeout=180000)
15:02:13 超时到点,子进程被杀,但工具挂在收尾
15:34:17 ssh -O exit dut → master 退出,fd 释放
15:34:17 close 触发,工具立即返回,session 恢复
也就是说超时后它又挂了 32 分钟,直到 master 释放 fd 才收尾。代价是复用该 master 的其它 ssh 会话(两个交互式 ssh dut)一起断。
预防:在 opencode 里执行的 ssh 命令显式禁用复用:
ssh -o ControlMaster=no -o ControlPath=none dut ...
根治方向(opencode 侧):spawner 用 exit 而不是 close 完成 Deferred,或给 close 等待加兜底超时。
本次用到的工具
| 工具 | 用途 |
|---|---|
sqlite3 ...?mode=ro | 查 tool part 的真实状态与超时参数 |
rg + opencode.log | 确认 before/after 钩子缺了哪个 |
ps / pstree / /proc/<pid>/task/*/children | 确认子进程已退出 |
ps -L -o wchan | 区分「忙」与「等事件」 |
ls -l /proc/<pid>/fd + ss -xp | 按 inode 把 socket 对端对到 mux master |
ps -o lstart | 时间关联,证明 master 由同一 run 创建 |
| 读源码 | spawner 的 close/kill 逻辑、OpenSSH mm_send_fd |
全程没有 dump 堆栈。
ps的wchan只是内核等待点,不是调用栈;结论来自「状态机 + 进程树 + fd 拓扑 + 源码」四路证据交叉验证。
排查思路总结(更新版)
加上后续的排查,归纳出 opencode 卡住的四种模式:
卡住了?
│
├─ CPU 高 (>10%) + 大量 futex/sched_yield
│ → 锁争抢 / 惊群效应
│ → 检查线程池大小、LSP server 数量
│
├─ CPU 低 (<5%) + 进程 idle (S/sleeping)
│ → 看 fd 中的 socket
│ ├─ SYN-SENT → IPv6 不通 / 防火墙
│ └─ ESTABLISH + Send-Q 积压 → 网络拥塞
│
├─ CPU 低 + 有子进程 (pstree)
│ → 看 pstree 找到叶子进程
│ → git? ssh? → 网络问题 / 代理配置
│
└─ CPU 低 + 无子进程 + tool 状态 running
→ 子进程已退出但收尾没完成
→ sqlite 查 part 状态/超时参数,日志查 after 钩子
→ ss -xp 把 socket 对端对到进程
→ 持久 ssh mux master 持有 stdio fd
→ close 事件不触发 → 超时也兜不住
解决方案(git push SSH)
两种方式让 git push 走代理:
方案一:SSH 配代理
~/.ssh/config 中添加:
Host github.com
ProxyCommand nc -X 5 -x 192.168.5.244:10808 %h %p
方案二:改用 HTTPS 远程
git remote set-url myself https://github.com/moxuetianya/learn-opencode.git
HTTPS 协议会使用 HTTPS_PROXY 环境变量,自动走代理。