审阅反馈:Accept 数据竞争、零窗口 FIN / TIME-WAIT 行为、队列配额与性能热点(附实测证据)

オープン
#4 コメント 1 件 リアクション 1 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

評価

難易度
5/5
見積もり時間
1週間以上
初心者へのやさしさ
25/100
issue の種類
バグ
明瞭さ
説明が足りない
活発さ
活発
技術スタック
go

調査の方向性

最小の報告項目である tcp.go:4126 と tcp.go:4290 の Accept/Close race から始め、提供されている TestAcceptCloseRace を go test -race で実行して再現してください。stack.go と tcp.go の関連する close パスを確認し、その後、対象を絞った回帰テストを追加してください。完了条件には、race が発生しない結果と、変更されていない close セマンティクスを含める必要があります。残りの調査結果には、個別の issue または設計上の判断が必要です。

索引モデルが issue の本文から書いたものです。

説明

背景

mipstack 做了一轮完整审阅(生命周期正确性 / 协议正确性 / 性能 / Go API),基线是最新 master ba762df(也就是 mihomo Alpha ab405bad 里 pin 的 v0.0.0-20260910230046-ba762df4c91d)。方法:代码审阅 + 独立探针复现,未改动仓库任何文件。

先说结论:没有 CRITICAL。README 里 27 条可证伪承诺(15 条设备语义 + 12 条出站语义)逐条验证全部成立;协议解析在 2300 万次 fuzz 执行 + 数百组恶意输入下零 panic、零越界、零无界增长;3 秒过载压测 116,424 包 / 73,244 次 drop 下 33,959,838 / 33,959,838 字节按序送达、零损坏,且 OutboundPackets − OutboundQueueDrops − consumed == 0 严格配平。下面是实际发现的问题,按优先级排列。


1. TCPListener.Accept 与关闭路径构成数据竞争(建议优先修,3 行)

Accept 在放锁之后才解引用 channel 字段:

  • 读:tcp.go:4126 case connection := <-l.accept:l.mu 已在 4124 解锁)
  • 写:tcp.go:4290 l.accept = nil(在 l.mu 保护下,由 TCPListener.Close tcp.go:4148 / Stack.Close → closeFromStack stack.go:4052 触达)

两者之间没有 happens-before 边。最小复现(go test -race):

func TestAcceptCloseRace(t *testing.T) {
	stack, _ := mipstack.New(mipstack.Config{
		LocalAddresses: []netip.Prefix{netip.MustParsePrefix("192.0.2.1/24")},
		MTU:            1500,
	})
	_ = stack.Start()
	defer stack.Close()
	listener, _ := stack.ListenTCP(context.Background(), "tcp4", netip.MustParseAddrPort("192.0.2.1:9040"))

	done := make(chan error, 1)
	go func() { _, err := listener.Accept(); done <- err }()
	time.Sleep(50 * time.Millisecond) // 让 Accept 阻塞在 select 上
	_ = listener.Close()
	<-done
}
WARNING: DATA RACE
Write at 0x00c000002330 by goroutine 8:
  mipstack.(*Stack).closeTCPListener.(*TCPListener).closeFromStack.func1()  tcp.go:4290
Previous read at 0x00c000002330 by goroutine 12:
  mipstack.(*TCPListener).Accept()                                          tcp.go:4126

稳定复现(同机多次、另有三种变体各自命中)。模块自身的测试跑不出来(关闭是串行的),但任何嵌入方的 -race CI 都会中。弱内存序下 select 还可能 park 在两个 nil case 上(只剩 deadline 兜底),或返回一个已被 abort 的连接。

建议修法(语义不变):持锁把 channel 拷出来再 select,或让字段不可变(关闭时只 close(l.closed),不再置 nil,Accept 返回前本来就会检查 closed)。


2. 全局控制响应限流:单源可吃光配额,饿死所有对端的 ICMP 错误

allowControlResponsestack.go:2702-2724)对每个 class 只有一个 tokenBucket,常量 stack.go:54-59 = 100/s、burst 200。任何一个源往未绑定 UDP 端口打,就能在 2 秒内耗尽整个类别的配额,其他所有对端的 port-unreachable 一起被丢。

实测:5000 个不同源(= 5000 个同源的效果)→ RateLimitedControlResponses = 4762,实际只发出 238 条错误。

Linux/RFC 4443 §2.4(f) 是按目的地址限流的。建议改成 per-destination(LRU 化)令牌桶,或至少给未绑定端口错误单独分桶。


3. 零接收窗口下收到的 FIN 不被消费 → EOF 要等对端 RTO

tcpSegmentAcceptabletcp.go:11059-11068)在 receiveWindow == 0 时只接受 length == 0 && sequence == receiveNext,于是窗口为 0 时到达的 FIN 落入「不可接受 → 回 ACK」分支(tcp.go:8411-8419),不推进 RCV.NXT

实测(应用不读、窗口填满后对端发 FIN):

after filling the buffer:            seq=… ack=… flags=ACK window=0
response to FIN in a zero window:    seq=… ack=… flags=ACK window=0   # 无进度
FIN consumed=false; read after drain: n=0 err=… i/o timeout           # 读空后仍无 EOF

即对端必须自己 RTO 重传 FIN 才关流,本地连接和缓冲在这期间一直挂着。当前行为符合 RFC 9293 表 5("no segments should be acceptable except ACK segments"),但 Linux 的 tcp_sequence() 是刻意更宽松的(!before(end_seq, rcv_nxt) && !after(seq, rcv_nxt + rcv_wnd))。建议采用 Linux 谓词——现有 receiveTCPData/appendReadBuffer 的 trim 逻辑已经会丢弃窗口外数据,不会造成缓冲越界。


4. TIME-WAIT 不接受同 4 元组的新 SYN,重连至少多付 1 个 RTO

TIME-WAIT 收到新 SYN 时走 tcp.go:8455-8459 的 challenge ACK 分支。实测:

seq=<old SND.NXT> ack=<old RCV.NXT> flags=ACK window=2047   # 既非 SYN-ACK 也非 RST

对端(本栈作为客户端时也一样)必须先自己发 RST 杀掉这个 TCB,然后靠 SYN 重传才被应答——至少 1 s RTO;不理会 unacceptable ACK 的客户端要等满 2 MSL = 60 s。短连接复用同一本地端口(HTTP/1.0、DoH over TCP、批量 curl)会经常踩到。

RFC 6191 允许用 Timestamp 回收;Linux tcp_timewait_state_processts_recent < rcv_tsval 且本端口有 listener 时直接回收,FIN-WAIT-2 则回 RST 让对方快速失败。建议对齐。


5. 输出队列:只按包数封顶不按字节记账;淘汰策略偏向保留旧积压

  • 容量只有包数上界(stack.go:39-43、记账 2016-2029),没有字节配额。MTU 65535 时实测单队列 16,776,960 B(16 MiB),两个队列约 32 MiB。
  • replaceBestEffortPacket 的受害者选择(stack.go:1243-12621119-1131)等价于「流表下标最小」,于是旧积压被反复保留、最新的单包流被淘汰。1000 次到达实测:retained 254 stale + 1 fresh, 1000 drops
  • loopback 只有一个 drainer(stack.go:2745-2761),一个卡住的 forwarder handler 会让所有本地投递停住:400 个本地数据报静默丢 145,而发送方仍看到成功。

(对照:队列机制本身没问题——soak 测试的账目严格配平、零损坏,上面三条是策略/配额层面的改进。)


6. 性能(本机基线,i7-14650HX / 12 CPU,可逐条回归对比)

实测 位置
每入站 TCP 段一次堆分配 1.008 allocs/op(-cpu 1 1.001);-memprofilerate=1 下 61,866 / 73,256 个对象(84.5%)、43% 字节 tcp.go:933-954q.spare 单缓冲)
Stack.mu 每入站包取 3 次 RLock+计数器 16.55 ns(1P) → 42.27 ns(12P) stack.go:3894tcp.go:4535tcp.go:4545
37 个共享 atomic.Uint64 挤在约 5 条 cache line 共享 10.65 ns vs 分片 4.97 ns @12P stack.go:484-522,3840,3822
多核扩展性 入站 UDP 并行 581 → 269.5 → 295.5 ns(12P 回落);16 流 TCP echo 688 → 1,595 MB/s(2.32×
分配/传输字节 传 80 MB 产生 90.4 MB 入站拷贝 + 75.6 MB 分片 = 2.6 B/B tcp.go:3460-3490
每包一次 time.Now() runtimeNow 为最大单帧,占 CPU 12.6% stack.go:1898

另附正向对照(说明热路径本身写得很好,不必动):ParseIPPacket 9.2 ns / 0 B;设备 Read 64 包批量 97.3 ns/包 / 0 alloc;出站 UDP 写 1400 B 0 alloc;结构体显著小于 gVisor(TCPConn 696 B vs 2,504 B)。


7. 小契约问题

  • nil ctx 会原始空指针 panicstack.go:3122tcp.go:4329 直接 ctx.Err();而同包 forwarder.go:756 是干净地 panic "nil Context"。建议统一(返回错误或统一文案)。
  • ErrClosed 语义在两套接口间不一致ErrClosed = net.ErrClosedstack.go:76),但设备面 Read/Write 返回 os.ErrClosed"file already closed"),errors.Is(err, mipstack.ErrClosed) 在设备面为 false、在 socket 面为 true。
  • 用法错误用格式化字符串而非哨兵stack.go:3297/3304/3308/3362),与包内 ErrNotStarted/ErrNoPorts 风格不一致,errors.Is 无法匹配。
  • CloseRead 返回 net.ErrClosed 而非 io.EOFtcp.go:5305-5310):io.Copy 类循环会报错而不是正常结束。
  • accept 队列溢出回 RST + ECONNABORTEDtcp.go:6163-6167):Linux 是静默丢最后一个 ACK,客户端还能重试。
  • TCPForwarder.Close() 不释放已 park 的 Acceptforwarder.go:774-790 只 select result/ctx/stack.closeCh):每个 park 的 handler 泄漏一个 goroutine,直到整栈关闭。
  • RestrictToReplies 无同步地置 nil responder 快照forwarder.go:1886-1888),与 README 同节承诺的并发回复语义有出入。

8. 验证方式与残余不确定性

复现环境:linux/amd64、go1.26.8;另有 go1.20.14 真机 build+vet+全测试通过、linux/windows/darwin/freebsd × amd64/arm64/386/arm 交叉编译通过。基线门禁:go test ./... 57.5s、go test -race -count=2 ./... 两轮全绿、go vet/gofmt -l 干净。

两点残余不确定性,未在真实 WAN 隧道与 32 位宿主上验证:性能数字是本机 benchmark;第 6 节的收益需在目标平台复测。另外 interop/gvisor 是独立 module,根目录 go test ./... 不会执行它(go list ./... 里没有该包)——这套对拍是公共契约最强的验证,建议 CI 显式加一步 cd interop/gvisor && go test ./...


9. 不在本仓库范围、但同一轮审阅发现的相关问题

  • mihomo(Alpha ab405bad):NewWireGuarddns.ParseNameServer 失败时直接返回(adapter/outbound/wireguard.go:508-512),而栈在构造期就已 Start(),导致整栈泄漏(实测 5 次失败重载 = +21 MB / +220 goroutine);MASQUE 构造函数同族。另外 device.BatchSize()=64 会让 wireguard-go 按 64 × [MaxMessageSize]byte 预取缓冲(Linux 上约 4 MiB/实例,gVisor 是 139 KiB);IPStackOption.validate() 也建议夹一下 MTU 上限,否则 mips 的 io.ErrShortBufferstack.go:3311-3315,且该包已被消费)会被 wireguard-go 当成致命错误永久关闭设备。
  • sing-tun v0.4.24 的 mips 适配器:透传 MTU 不归一(stack_mipstack.go:46/71),0 值时缓冲区按 0 字节分配 → 入站静默丢包 + 真实 TUN 的 0 长度读导致空转(实测 50 ms 内 455 万次 read);writeLoop 遇任何非关闭类 Read 错误就 return(出站永久停止,而 readLoop 会继续);Start/Close 存在竞争且 Start 不幂等。mihomo 侧因把 MTU 0 归一成 9000 打不到,但库 API 的其他使用方会中。

如果需要,我可以把上述探针整理成 PR 形式的回归测试(Accept 竞争那条最小、可直接进 tcp_test.go);第 1、7 两条我也可以直接提 PR。

主要言語
Go
スター
18
フォーク
2
PR マージ指標
30日以内にマージされた PR はありません

コントリビューションガイド

このリポジトリのコントリビューションガイドは索引されていません

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

似ている issue

Go の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。