背景
对 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 错误
allowControlResponse(stack.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
tcpSegmentAcceptable(tcp.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_process 在 ts_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-1262、1119-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-954(q.spare 单缓冲) |
Stack.mu 每入站包取 3 次 |
RLock+计数器 16.55 ns(1P) → 42.27 ns(12P) |
stack.go:3894、tcp.go:4535、tcp.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 会原始空指针 panic:
stack.go:3122、tcp.go:4329 直接 ctx.Err();而同包 forwarder.go:756 是干净地 panic "nil Context"。建议统一(返回错误或统一文案)。
ErrClosed 语义在两套接口间不一致:ErrClosed = net.ErrClosed(stack.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.EOF(tcp.go:5305-5310):io.Copy 类循环会报错而不是正常结束。
- accept 队列溢出回 RST +
ECONNABORTED(tcp.go:6163-6167):Linux 是静默丢最后一个 ACK,客户端还能重试。
TCPForwarder.Close() 不释放已 park 的 Accept(forwarder.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):NewWireGuard 在 dns.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.ErrShortBuffer(stack.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。
背景
对
mipstack做了一轮完整审阅(生命周期正确性 / 协议正确性 / 性能 / Go API),基线是最新 masterba762df(也就是 mihomo Alphaab405bad里 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:4126case connection := <-l.accept:(l.mu已在 4124 解锁)tcp.go:4290l.accept = nil(在l.mu保护下,由TCPListener.Closetcp.go:4148/Stack.Close → closeFromStackstack.go:4052触达)两者之间没有 happens-before 边。最小复现(
go test -race):稳定复现(同机多次、另有三种变体各自命中)。模块自身的测试跑不出来(关闭是串行的),但任何嵌入方的
-raceCI 都会中。弱内存序下 select 还可能 park 在两个 nil case 上(只剩 deadline 兜底),或返回一个已被 abort 的连接。建议修法(语义不变):持锁把 channel 拷出来再 select,或让字段不可变(关闭时只
close(l.closed),不再置 nil,Accept返回前本来就会检查closed)。2. 全局控制响应限流:单源可吃光配额,饿死所有对端的 ICMP 错误
allowControlResponse(stack.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
tcpSegmentAcceptable(tcp.go:11059-11068)在receiveWindow == 0时只接受length == 0 && sequence == receiveNext,于是窗口为 0 时到达的 FIN 落入「不可接受 → 回 ACK」分支(tcp.go:8411-8419),不推进RCV.NXT。实测(应用不读、窗口填满后对端发 FIN):
即对端必须自己 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 分支。实测:对端(本栈作为客户端时也一样)必须先自己发 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_process在ts_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-1262、1119-1131)等价于「流表下标最小」,于是旧积压被反复保留、最新的单包流被淘汰。1000 次到达实测:retained 254 stale + 1 fresh, 1000 drops。stack.go:2745-2761),一个卡住的 forwarder handler 会让所有本地投递停住:400 个本地数据报静默丢 145,而发送方仍看到成功。(对照:队列机制本身没问题——soak 测试的账目严格配平、零损坏,上面三条是策略/配额层面的改进。)
6. 性能(本机基线,i7-14650HX / 12 CPU,可逐条回归对比)
-cpu 11.001);-memprofilerate=1下 61,866 / 73,256 个对象(84.5%)、43% 字节tcp.go:933-954(q.spare单缓冲)Stack.mu每入站包取 3 次RLock+计数器 16.55 ns(1P) → 42.27 ns(12P)stack.go:3894、tcp.go:4535、tcp.go:4545atomic.Uint64挤在约 5 条 cache linestack.go:484-522,3840,3822tcp.go:3460-3490time.Now()runtimeNow为最大单帧,占 CPU 12.6%stack.go:1898另附正向对照(说明热路径本身写得很好,不必动):
ParseIPPacket9.2 ns / 0 B;设备Read64 包批量 97.3 ns/包 / 0 alloc;出站 UDP 写 1400 B 0 alloc;结构体显著小于 gVisor(TCPConn 696 B vs 2,504 B)。7. 小契约问题
stack.go:3122、tcp.go:4329直接ctx.Err();而同包forwarder.go:756是干净地 panic"nil Context"。建议统一(返回错误或统一文案)。ErrClosed语义在两套接口间不一致:ErrClosed = net.ErrClosed(stack.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.EOF(tcp.go:5305-5310):io.Copy类循环会报错而不是正常结束。ECONNABORTED(tcp.go:6163-6167):Linux 是静默丢最后一个 ACK,客户端还能重试。TCPForwarder.Close()不释放已 park 的Accept(forwarder.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. 不在本仓库范围、但同一轮审阅发现的相关问题
ab405bad):NewWireGuard在dns.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.ErrShortBuffer(stack.go:3311-3315,且该包已被消费)会被 wireguard-go 当成致命错误永久关闭设备。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。