同一个 VpnService 循环里会出现两种症状,而且常被归咎于同一件事:读取再也不返回,以及写入以 ENOBUFS 失败。它们不是同一个缺陷的变体。读取卡死是与对端在流量控制上的分歧;写入失败是接口拒绝接收的数据包。把两者当成一个问题,你会花一整天去调整从未参与的缓冲区。下面的划分就是我决定先测量循环哪一半的依据。

一个循环,两个独立的故障点

服务从 TUN 文件描述符读取 IP 数据包,检查后再把响应写回同一个描述符。这条路径上任何环节出问题,都会从这两个调用之一暴露出来,因此两者都被报告成隧道坏了。但这两个调用的契约不同。读取等待内核把数据包交给你,写入则把数据包交给内核,而内核有权拒绝。

读取是容易推理的那一半。什么也不返回的读取,要么在等永远不会到来的流量,要么在等对端停止发送之后的时间。

读取卡死是流量控制上的分歧

读取 TUN 设备会阻塞,直到有数据包可用。这是设计行为,也是空闲链路上停在 read 里的线程看起来正常的原因。当你的 TCP 栈继续接收本地应用的数据,却从不通告足以让远端对端继续发送的窗口时,它就变成卡死。对端填满你通告的窗口,等待 ACK 和窗口更新,然后停下。此时读取方无内容可返回,应用看到的是一个已打开却沉默的连接。

常见原因有两类:一是写入套接字却不跟踪已通告窗口的栈,二是排空缓冲区后忘记发送窗口更新的栈。从外面看两者完全一样:字节发出去了,什么也没回来,也没有抛出错误。把读取线程阻塞在解析、DNS 解析或磁盘 I/O 上,也会因无关原因造成同样形态的症状,所以在归咎流量控制之前,先确认线程确实停在 read 里。

检查对端的发送队列,以及它最后看到的窗口。如果对端因为你的窗口为零而压着数据,修复点在 ACK 路径上,而不在 TUN 读取上。

ENOBUFS 是长度问题,不是缓冲区不足

在 TUN 接口上返回 ENOBUFS 的写入,通常是在拒绝数据包长度。接口有 MTU,超过它的数据包无法入队,重试同一个数据包每次都会以同样方式失败。这种确定性就是线索。真正的缓冲区不足会随压力下降而消失,而长度拒绝不在乎你等多久。

TCP 正好为此提供了机制,而这一步常被跳过:MSS 钳制。在用户态合成报文段时,负载长度由你决定;如果从本地接口的 MTU 而不是隧道的 MTU 取值,就会发出隧道承载不了的报文段。取两者中较小者进行钳制。钳制只覆盖 TCP,因此过大的 UDP 数据报必须由你的代码分片或丢弃。

调试时记录你写出的每个数据包的长度。失败的那个长度就是答案,而且它不会在重试之间变化。

区分两类问题的三个测量

两种故障都不需要调试器。三个观察就够把你引到正确的小节,而且比重新构建更省时间。

What you seeWhere to look
Reader parked, peer send queue empty, last advertised window zeroWindow updates in your ACK path
Write fails on one length and succeeds on a smaller oneMSS clamp and tunnel MTU
Both directions look fine, app still sees no dataProtected socket and routing loop

如果第三行吻合,循环本身没问题,问题在隧道看不到的地方:一个未受保护的传输套接字把你自己的 VPN 流量又送回隧道,通常表现为连接成功建立却什么也传不过去。

修好两条路径共同依赖的管道

接口没有正确构建之前,追任何症状都是白费。真正重要的 builder 调用依次是地址、路由、MTU 和用户同意:

Builder()
  .addAddress(10.8.0.2, 32)
  .addRoute(0.0.0.0, 0)
  .setMtu(1400)
  .establish()

把 MTU 设为隧道的真实值,而不是沿用示例里的值。之后你写出的所有数据都受这个数字约束,而所设 MTU 与所钳制 MSS 不匹配,是修复后重新引入写入失败最常见的方式。然后保护承载通往 VPN 服务器本身上行流量的那个套接字。没有 protect,这些数据包会被路由回你自己的隧道,连接永远建立不起来,看起来像读取卡死,实际是路由环路。

用户同意无法绕过。没有让 prepare 返回 null 就启动的服务拿不到接口,而 establish 会返回 null 而不是抛出异常,所以启动循环前先检查描述符。VpnService 参考文档才是确认 builder 契约的地方,而不是照抄示例。

每次只验证一个方向

在碰 TCP 之前,先通过隧道发一个 UDP 回显并观察数据包长度。然后打开一次握手,确认报文段始终低于你算出的 MSS。计数数据包而不是连接数,这样卡顿会表现为计数器里的空缺,而不是挂住的套接字。如果写入爆发之后读取停止,嫌疑在窗口;如果写入立刻失败,嫌疑在长度。

然后重启设备再重复一次,因为只在第一次网络变化之前可用的 VpnService,是路由问题而不是流量控制问题。

相关文章