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))

关键信息:

字段值含义
StateESTABTCP 连接已建立
Recv-Q0没有待读数据
Send-Q293076 (~286KB)发送缓冲区积压了 286KB
目的地址20.205.243.166:22GitHub 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

关键数据:

线程分布

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),像是一个线程池任务队列上的锁。

结论:惊群效应 + 严重锁争抢。

可能诱因:

  1. 线程池过大(40 线程 vs 实际 CPU 核数),过多线程争抢同一把锁
  2. big-pickle 本地 LLM 推理慢,IO 线程在等待结果期间造成锁堆积
  3. 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
fdTCP 状态本地远端说明
38ESTABLISHED192.168.5.99:53102172.67.69.147:443Cloudflare IPv4 ✅
40ESTABLISHED192.168.5.99:5955820.205.243.165:443Microsoft/GitHub ✅
35SYN-SENT[3000::5054:ff:fe87:f354]:38682[2606:4700::6810:a22]:443Cloudflare IPv6 ❌
36SYN-SENT[3000::5054:ff:fe87:f354]:60690[2606:4700::6810:422]:443Cloudflare IPv6 ❌
37SYN-SENT[3000::5054:ff:fe87:f354]:46750[2606:4700::6810:222]:443Cloudflare IPv6 ❌
39SYN-SENT[3000::5054:ff:fe87:f354]:49118[2606:4700::6810:322]:443Cloudflare 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。

问题在于:

  1. DNS 返回了多个 Cloudflare IPv6 地址
  2. Node.js 尝试并发连接所有 IPv6 地址
  3. 内核发出 SYN 后收不到 SYN-ACK,连接进入 SYN-SENT 状态
  4. 内核每 20 秒重传一次 SYN,总超时可达 120 秒以上
  5. 在此之前,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

结果:

也就是说 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。

卡死链条

  1. 远端 openshell forward start 9119 pi -d 卡住不退出 → SSH channel 不关
  2. 3 分钟超时到点:opencode kill 子进程组(packages/opencode/src/tool/shell.ts 的 Effect.raceAll timeout 分支)
  3. ssh 子进程死了 → exit 触发
  4. 但 stdout 的子端还在 mux master 手里 → opencode 的 fd 43 等不到 EOF → close 不触发
  5. spawner 的 Deferred 永不完成;超时收尾的 handle.kill() 发完信号后 await 的也是它 → 永久挂起
  6. 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 环境变量,自动走代理。