Skip to content

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

Description

@olicesx

背景

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。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions