当前位置: 首页 > news >正文

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个快照,模式完全一致:

指标 值 含义 ================ ============ ========================== 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 的状态分布:

数量 状态 含义 ===== ===================== ================================ 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()上永久阻塞,连接永远不释放。

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 也永久阻塞:

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 原子标志压制所有恢复尝试
func (g *URLTestGroup) CheckOutbounds(...) { if g.checking.Swap(true) { return result, nil // 已有检查在进行,静默跳过 } }

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

完整因果链
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。这个假设被运行时证据彻底排除。

出口节点存活时间

节点 存活时间 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 触发了,但底层 TCPRead()继续阻塞。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(全部未修复)

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,从未修复。

证据置信度

结论 来源 置信度 ======================================= ============================== ======== 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 未知

链接

  • Issue: sing-box #4255
  • PR: sing-box #4256
  • Goroutine dump: gist
  • 关联 Issue: #4144, #1620
http://www.cnnetsun.cn/news/3056462.html

相关文章:

  • 夯爆了,基于世界模型的强化学习登Nature正刊
  • AI 生成 HTML 后怎么分享?用 ShareOne Skill 一键发布网页、Markdown 和文档
  • 从手动到秒过:安卓模拟器封包抓取与解密实战指南
  • RACAM架构:突破DRAM-PIM性能瓶颈的位串行加速方案
  • A股量化策略日报(2026年06月29日)
  • Claude 断供之后,我的代码是怎么跑起来的
  • 手提蓝牙平板保护壳外观设计-结构设计-工业设计要点
  • 如何从三星帐户恢复联系人?分步指南
  • Unity引擎概览:想造一个世界,你需要一座“梦工厂“
  • 【软件测试】day04 Fiddler抓包工具
  • 资深测试工程师指南:如何系统化创建测试用例并生成测试技能
  • 软硬一体销售会话分析软硬件一体方案选型与落地参考
  • vitest + vue3 踩坑记录
  • 质量好的号卡随身wifi哪家靠谱
  • 地陪APP平台系统开发公司,陪玩平台酒店渠道价值深度解读
  • 独立站建站,有哪些靠谱服务商?
  • 郑州电脑租赁哪家靠谱?办公本租赁避坑与挑选指南
  • 14901黄大年茶思屋榜文第149期 第1题 视频通话场景下的基于3DGS的人体重建
  • 你的REST接口还在“过度投喂”数据吗?——FastAPI + GraphQL实战避坑指南
  • ChatGPT写代码总翻车?这5个底层提示词逻辑漏洞,92%开发者至今未察觉:你中招了吗?
  • GPT-5.6发布前被叫停
  • 社评:筑牢思想主权之基,开启文明认知跃迁——论“贾子理论大厦”在人工智能时代的范式革命与时代价值
  • ESP32闪存管理深度解析:高级擦除技术与性能优化实战
  • 中山汽车音响老店实战测评2026
  • 深入探索 C++20 与 C++23 新特性:从缩写函数模板到模块系统的全面解析
  • Java毕设选题推荐:基于 SpringBoot 的用户权限博客内容管理系统 图文博客资讯发布与留言交互系统设计【附源码、mysql、文档、调试+代码讲解+全bao等】
  • Navicat重置工具:3步实现Mac版Navicat无限试用终极指南
  • 基于SpringBoot3+Vue3的图书漂流管理系统的设计与实现(AI问答、协同过滤算法、Echarts图形化分析)
  • VirtualBox + Ubuntu22.04 虚拟机完整安装教程(以Ubuntu22.04为例)/打开虚拟机,在终端输入命令ls-l出现的结果/Linux相关职业 招聘要求
  • 高效获取网盘真实下载地址:LinkSwift直链解析工具深度解析