sing-box 透明网关冻结:从 SIGQUIT Goroutine Dump 定位三重自锁 Bug

sing-box 透明网关冻结:从 SIGQUIT Goroutine Dump 定位三重自锁 Bug

摘要:本文详细分析了 sing-box 透明网关在特定配置下(tproxy inbound + urltest outbound)出现的周期性完全冻结问题。通过自动触发的诊断脚本和 SIGQUIT Goroutine Dump,我们定位到一个三重自锁循环 Bug:1) bufio.CopyConn Read() 无 idle 超时导致连接永久阻塞;2) URLTest batch.Wait() 无超时导致选路冻结;3) checking 原子标志压制所有恢复尝试。这三层缺陷共同形成了不可自愈的死锁状态。文章提供了完整的诊断过程、根因分析、修复方案(PR #4256)以及热修复部署指南,并指出该问题自 1.8.x 版本以来在多个 Issue 中持续存在但从未被彻底修复。

2026-06-29 | sing-box 1.10.7 | iStoreOS N100 | tproxy + urltest


背景

家用 N100 路由器运行 sing-box 作为透明代理(tproxy inbound + urltest outbound,6个出口节点通过 relay 中继)。从某天下午开始,网关每隔几分钟就会完全冻结:进程存活,TCP accept() 在内核层正常,但所有连接都不通。外部看门狗通过 Probe 7(tproxy 路径探测)检测到后重连恢复,但几分钟后再次冻结。2小时内观测到8次冻结,间隔从13分钟缩短到57秒。

诊断工具链

为了在冻结瞬间捕获进程内部状态,我在看门狗的 PROXY_BROKEN 检测点加了一个自动触发的诊断脚本 diag-proxy-broken.sh,在重连杀死 sing-box 之前采集11维度的系统快照。后来又加了第12步:利用 Go 运行时的 SIGQUIT 机制(GOTRACEBACK=all),在冻结时刻打印所有 goroutine 的完整栈追踪到 stderr(即 proxy log 文件),然后通过 log 文件偏移量差提取 dump。

这个 goroutine dump(588KB,7256行,205个 goroutine)是定位根因的决定性证据。

七个诊断快照的一致模式

在不同出口节点(LA、Chicago、NY、Atlanta、Miami)捕获了7个快照,模式完全一致:

text 复制代码
指标              值           含义
================ ============ ==========================
proxy ESTAB      112-136      连接已建立,内核 accept 正常
Send-Q           全部 = 0     sing-box 没有写入任何数据
CLOSE-WAIT       1-5          远端发了 FIN,sing-box 没调用 close()
conntrack        76-105/全部  TCP 层全部 ASSURED(健康)
tproxy rules     2 + 2(QUIC)  未被 tombstone,路由正常
proxy log        100/100      最近100行全是错误
direct probe     000, 0.88s   快速失败(非超时)

TCP 层健康但应用层完全不响应。不是网络问题,不是路由问题,不是 nftables 问题。

Goroutine Dump 揭示的真相

205个 goroutine 的状态分布:

text 复制代码
数量  状态                  含义
===== ===================== ================================
  66  IO wait (<1 min)      最近一分钟涌入的阻塞连接
  26  IO wait, 1 min        中期积累
  18  IO wait, 2 min        早期积累
  10  IO wait, 4 min        冻结开始前就存在的连接
  67  select (各时长)        task.Group.Run 等 copy 完成
   1  semacquire, 3 min     batch.Wait() -- 关键阻塞点
  16  runtime/infra          GC, finalizer, signal 等
===== ===================== ================================
 168  CopyConn 相关总计      每连接消耗4个 goroutine

根因:三重自锁循环

同一个缺失的 Read() 超时在三个层面同时生效,形成无法自愈的死循环:

第一层:bufio.CopyConn Read() 无 idle 超时

relay 在 TCP 层保持连接活跃(keepalive ACK 正常,conntrack 显示 ASSURED),但在应用层停止转发数据。bufio.CopyConn 没有在 relay 侧 socket 上设置 read deadline,goroutine 在 Read() 上永久阻塞,连接永远不释放。

text 复制代码
goroutine 1151 [IO wait, 1 minutes]:
  bufio.splice -> rawConn.Read     <-- 永远阻塞

随着时间推移,被阻塞的 goroutine 持续堆积:10个/4分钟前 -> 26个/1分钟前 -> 66个/最近1分钟,加速度达6倍。

第二层:URLTest batch.Wait() 无超时

URLTestGroup.urlTest() 为每个 outbound 生成一个 batch worker 进行健康探测。batch.Wait() 调用 WaitGroup.Wait(),要求所有 worker 完成。当其中一个 worker 的 relay 接受了 TCP 但不返回 HTTP 响应时,该 worker 永久阻塞,整个 batch 也永久阻塞:

text 复制代码
goroutine 103 [semacquire, 3 minutes]:
  sync.(*WaitGroup).Wait()
    sync/waitgroup.go:118
  batch.(*Batch).Wait()
    sing@v0.5.1/common/batch/batch.go:77
  URLTestGroup.urlTest()
    outbound/urltest.go:407

batch 阻塞期间,selectedOutbound 永远不更新,所有新连接继续路由到死掉的 relay。

第三层:checking 原子标志压制所有恢复尝试
text 复制代码
func (g *URLTestGroup) CheckOutbounds(...) {
    if g.checking.Swap(true) {
        return result, nil  // 已有检查在进行,静默跳过
    }
}

goroutine 103 卡在 batch.Wait() 后,checking 永远为 true。后续所有定时触发的健康检查全部静默返回,没有错误日志,没有任何外部信号。urltest 的120秒定时器在正常触发,但每次都被 checking 挡回。

完整因果链
text 复制代码
relay 停止转发数据(TCP keepalive 保持连接存活)
  |
  +--[第一层] bufio.CopyConn Read() 无 idle 超时
  |           goroutine 堆积加速:10/min -> 66/min (6x)
  |           每连接 = 4 goroutine,连接不释放
  |
  +--[第二层] urltest probe Read() 同样无超时
  |           goroutine 381 [IO wait, 1 min]
  |           batch.Wait() 永久阻塞
  |           goroutine 103 [semacquire, 3 min]
  |           selectedOutbound 锁定在死节点
  |
  +--[第三层] checking = true 永久
              所有后续健康检查静默跳过
              无错误日志,无外部信号
                    |
                    v
              三重自锁:
              - 连接堆积(第一层)
              - 选路冻结(第二层)
              - 健康检查被压制(第三层)
              - 进程无法自愈,只能 kill
                    |
                    v
              网关完全无响应(100% 错误日志)
              看门狗检测 -> 重连 -> 临时恢复
              relay 再次降级 -> 循环重复

三层缺一不可。没有第一层,连接会超时释放,损害有限;没有第二层,URLTest 会检测到死 relay 并切换;没有第三层,定时器会重新触发检查。三层同时存在才形成不可逆的死锁。

排除的假设:interrupt.Group mutex 死锁

静态代码分析曾怀疑 interrupt.Group.access 互斥锁在 Interrupt() 持锁期间调用 conn.Close() 可能导致死锁。但 goroutine dump 明确显示:0个 goroutine 阻塞在 sync.Mutex.Lock。这个假设被运行时证据彻底排除。

出口节点存活时间

text 复制代码
节点      存活时间               relay 数量  备注
========= ====================== ========== ==============
LA        789s (13min)            5 个 IP    正常冻结
Chicago   1162s (19min)           10 个 IP   relay 最多
NY        393s (6.5min)           ?          -
Atlanta   2942s (49min) / 57s     ?          57s 是级联退化
Miami     268s (4.5min)           ?          -

Atlanta 的57秒发生在连续5次 PROXY_BROKEN 的级联退化中,正常存活时间是2942秒。relay 数量越多的出口节点存活越久(所有 relay 停止转发需要更长时间)。

context.WithTimeout 为什么不够

sing-box 已经在 URL test 请求上设置了 C.TCPTimeout(15秒)的 context 超时。但 context 取消不会中断 net.Conn.Read()。当连接通过自定义 DialContext 获取时,http.Transport 不管理连接生命周期。context deadline 触发了,但底层 TCP Read() 继续阻塞。goroutine dump 证实:goroutine 381 在 TCPConn.Read 上阻塞超过1分钟,远超15秒 context 超时。

修复必须使用 conn.SetReadDeadline() 直接在 net.Conn 上设置内核级超时。

修复 (PR #4256)

两个文件,30行改动:

common/urltest/urltest.go :在 DialContext 返回的连接上调用 SetReadDeadline(time.Now().Add(C.TCPTimeout))。使用相对超时而非 ctx.Deadline(),因为 context deadline 是绝对时间,包含 dial 阶段已消耗的时间,极端情况已过期会立即超时。

protocol/group/urltest.go

  • batchCtx 派生 testCtx(原来用 g.ctx),使 batch 取消能传播到各 probe
  • batch.Wait() 外包 time.NewTimer(2*TCPTimeout) 硬超时(30秒),超时后以已有结果继续
  • 超时后清理未完成 probe 的 stale history,防止 performUpdateCheck 选中死节点

N100 Hotfix 部署

surflare-proxy 是独立的 sing-box 二进制文件(非嵌入闭源 surflare),surflare 通过 --config stdin 启动它。从 v1.10.7 源码(同 revision、同 build tags)构建 patched 版本,直接替换 /usr/bin/surflare-proxy。原版备份为 surflare-proxy.orig

历史 Issues(全部未修复)

text 复制代码
Issue   日期     版本      平台                   状态
======= ======== ========= ====================== ===========
#1620   2024-03  1.8.x     N100 NUC tproxy         closed-stale
#1607   2024-03  1.8.9     OpenWrt tun              closed-stale
#1738   2024-05  1.9.0-rc  Ubuntu                   open
#2027   2024     1.9.3-10  -                        open
#4144   2026-05  1.13.11   Ubuntu tun + OpenWrt     open
#4255   2026-06  1.10.7    iStoreOS tproxy          open (本文)
#4256   2026-06  testing   PR fix                   open (本文)

共同基底:urltest + 透明代理(tun/tproxy)+ 持续真实流量。跨版本 1.8 至 1.13,从未修复。

证据置信度

text 复制代码
结论                                    来源                           置信度
======================================= ============================== ========
batch.Wait() 永久阻塞(上层)            goroutine 103 semacquire 3min  已确认
bufio.CopyConn Read() 永久阻塞(下层)   168 IO wait goroutines         已确认
三重自锁循环                             两层同在一个 dump 中            已确认
interrupt.Group mutex 未参与             dump 中 0 个 mutex 阻塞        已排除
relay 不响应是触发条件                   proxy log + socket + conntrack 已确认
出口存活时间与 relay 容量相关            dmesg 时间戳                   推断(中)
升级 sing-box 能修复                     changelog 1.11-1.13            未知

链接