Android VpnService:TUN 读取卡死与 ENOBUFS 写入错误
永不返回的读取与以 ENOBUFS 失败的写入,成因正好相反。测量已通告的窗口与数据包长度,才能选对修复方式。
同一个 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 see | Where to look |
|---|---|
| Reader parked, peer send queue empty, last advertised window zero | Window updates in your ACK path |
| Write fails on one length and succeeds on a smaller one | MSS clamp and tunnel MTU |
| Both directions look fine, app still sees no data | Protected 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,是路由问题而不是流量控制问题。