iPad 在 Tailscale 上:一个 WebRTC 调试故事

Hacker News Top 新闻

摘要

一位开发者的调试历程,揭示了基于 WebRTC 的应用 (p2claw) 在 iPad 上因两个 bug 而失败:webrtc-rs 中的一个硬编码常量,以及 Tailscale 中的一个单行设计决策,导致数据通道消息丢失。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/06/10 17:46

# iPad 连接了Tailscale 来源:https://p2claw.com/blog/2026-06-09-the-ipad-was-on-tailscale 如果你不熟悉 p2claw 的工作方式,建议先阅读[工作原理](https://p2claw.com/blog/2026-05-12-how-it-works/)这篇博客,再来读本文。 我在 iPad 上打开一个 p2claw 应用,结果只看到了空白页面。同样的 URL 在 Mac、Linux 主机和手机上都能正常工作。同一 Wi-Fi、同一浏览器引擎、同一网络。就像优秀的侦探故事一样,我们列出了一堆嫌疑人——先是 iPad,然后是 WebKit,最后是 Tailscale——结果它们全都被证明是清白的。严格来说,是“两个bug穿着同一件风衣”:一个是 webrtc-rs 中硬编码的常量,另一个是 Tailscale 中只有一行代码的设计决策,我们纯粹靠着固执才发现了它。当天我们就打上了补丁作为变通方案,但要彻底理解打补丁的原理,又花了两个星期。 ## 问题描述 应用加载了足够的 HTML 来显示加载状态,然后就卡住了。控制台没有任何相关报错,Service Worker 注册成功,WebRTC 握手完成,数据通道成功打开(`dc.readyState === "open"`),然后就没有然后了。浏览器通过数据通道发送了第一个 `GET /` 请求,然后等待响应——永远等不到。另一端的盒子代理认为一切正常:它已经发出了响应,并将数据推送到通道里,但数据从未到达 iPad。 更棘手的是,这是个海森堡bug:如果我疯狂刷新,页面*偶尔*会加载成功。 ## 怀疑时,先打点 我们做的第一件有用的事是记录连接两端的数据,并按时钟时间对齐日志:盒子发送的每个数据块、浏览器接收的每个数据块,以及最关键的是——盒子输出缓冲区中等待确认发送的数据量。这帮助我们定位数据到底在哪里丢失了。 ## 死胡同 排除了从 WebRTC 握手及之前的所有环节后,我们几乎是在绝望中寻找线索。我们检查了一些 WebRTC 特定的限制,也反复确认了网络稳定性。 - **消息大小限制**。在 WebRTC 中,两个对等端在开始交换数据之前,会协商各自能接受的最大单个数据块大小。如果发送的内容超过这个限制,某些浏览器会直接静默断开。我们从两个设备上读取了这个限制(`maxMessageSize`)。iPad 报告的是 64KB,和 Mac 完全一样,远大于我们发送的 7-8KB 数据块。这之后,我们自认为排除了数据块大小的问题,结果却让真正的诊断变得更加困难。 - **不稳定的 Wi-Fi**。最简单的解释:空中丢包。盒子上的 ifstat 和 tcpdump 都正常,我的手机(连接同一 Wi-Fi)也没有出现同样的问题。问题一定出在 iPad 自身,但我们毫无头绪。 ## 数字到底说了什么 每次请求,盒子发送三个数据块:一个 220 字节的头部、一个 7,874 字节的主体和一个 199 字节的尾部。我们新增的监控显示,发送方的输出缓冲区上升到约 8KB 后停止。它一直持有已经“发送”但从未收到确认的主体。当 iPad 刷新时,我们看到完全相同的模式。 WebRTC 数据通道在不可靠的 UDP 之上保证有序送达,因此一个数据块丢失就会阻塞后续消息。在 iPad 浏览器 JS 控制台中,我们看到只收到了一个数据块(220 字节的头部),然后就什么都没有了。既没有收到主体,也没有收到后续请求的*小*头部。我们在 Mac 的 Safari 上测试,猜测问题可能出在 WebKit——因为所有 iOS 浏览器(底层都是 WebKit)都出现了这个问题——但 Mac 却能毫无问题地接收 8KB 和 11KB 的数据块。 ## “就是 Tailscale 的问题” 两个小时的 WebKit 理论探讨之后,我意识到:和 Mac 不同,iPad 启用了 Tailscale。Tailscale 是一个 VPN,VPN 会在流量外面多包一层,导致每个数据包可用空间变小。因此,大的响应数据在发往 iPad 时会被切分成更多、更小的片段,而在 Mac 上则不会。WebKit 在用户空间自行实现数据通道,包括将承载消息的数据包重组成完整消息。我们的理论逐渐倾向于 WebKit 消息重组的一个 bug。我们将盒子发送的消息大小限制在 800 字节(小到每个消息只占用一个数据包),iPad 瞬间加载成功,无论 Tailscale 开启还是关闭。感觉案子结了(其实第一次尝试 1200 字节,用 Claude 帮我算应该能装下,但神秘地不行。先记住这个细节)。 事后看来,我们当时其实已经发现了问题是 VPN 导致的,却仍然坚持 WebKit 理论。考虑到上下文的膨胀(包括我的和代理的——毕竟这是 AI 时代的排故),Tailscale 的发现被吸收进了 WebKit 理论,而不是挑战它。我们本可以检查网络和 WebRTC 发送方,却把这一点当作浏览器有问题的另一个佐证。于是我们把事故记录为 iOS Safari 的 bug(*设备收到了数据包但从未将它们重组给应用*),并开始构建独立的复现项目来证明这一点。 ## 无法复现的复现 接下来的两周,用 JavaScript 发送端无法复现这个 bug,于是我们转而使用基于 webrtc-rs 的 Rust 发送端。仍然失败。我们匹配了数据通道数据块的形状和大小,在 Linux 和 iPad 上使用真实浏览器接收端,无论是否开启 Tailscale,每次都能完整交付。最终我们不得不重新审视自己的证据(其实是 Anthropic 发布了 Fable,我让它从原始调试会话中挖出了 jsonl 日志)。 决定性的数字来自 WebRTC 自身的 `getStats()` 计数器,我们的客户端会将其记录到控制台,并且在事故当晚我们用屏幕照片拍下了这些数据。iPad 的候选连接对在收到 18 个数据包共计 2,144 字节后冻结,而数据通道已成功交付了恰好一条消息(266 字节,我们的 220 字节头部加上帧头)。盒子一直在重传那个大的数据包。如果 Safari 确实收到了那些数据包,只是在*拼接消息*时失败,那么传输计数器应该随着每次重传再增加一千多字节,然而它从未变化。数据包根本没有到达。 冻结期间 Web Inspector 控制台:dc.messagesReceived=1, candidatePair.bytesReceived=2144, packetsReceived=18,以及主体推送卡住的错误。 *事故当晚的实际照片。每个关键数字都在画面中,但我们花了两周才理解它们的含义。* 所以我们不再尝试复现浏览器 bug,而是复现*网络*本身。 ## 嫌疑人一号:webrtc-rs `webrtc-rs`(https://github.com/webrtc-rs/webrtc),我们的盒子使用的 Rust WebRTC 栈,它按照以下常量分割输出的数据通道消息: ``` // sctp/src/association/mod.rs pub(crate) const INITIAL_MTU: u32 = 1228; ``` 这个值不可配置,而且之后也从未被更新过。1228 字节的数据包加上外层的加密层,实际在线上占 1265 字节。再加上 UDP 和 IPv4 头部的 28 字节,或者 IPv6 的 48 字节,在 IPv4 上就是 1293 字节,IPv6 上则是 1313 字节。Tailscale 的隧道最大支持 1280 字节。 事实证明,数据包太大本身*并非*致命。当内核将一个大包路由进隧道时,它会做 IP 层自八十年代以来就有的礼貌行为:分片。它会将数据包分成两个不超过限制的片段,并在另一侧重组。我们通过 tcpdump 确认了这一点。片段离开了盒子。在健康的路径上,所有片段都能到达,bug 不可见——这正解释了我们的独立复现为何一直通过。在复现中,数据包分片并干净地重组;而在事故中,iPad 冻结了。所以问题不在于数据包为什么太大,而在于:片段去了哪里? ## 回到实际的盒子代理 为了回答这个问题,我们回到了真实的系统。我们将盒子代理的数据块上限重新调高到 8KB,通过它提供真实应用,并在 iPad 上通过 Tailscale 加载,同时在隧道接口上抓包。它按照预期卡住了,而这次我们同时观察两个层面。 代理的输出缓冲区在 13KB 处冻结(不是 8KB,因为应用和负载不同)。在线上,同样的 1265 字节负载以两个 IPv6 片段形式发出,并按照 SCTP 教科书式的退避策略重传:+1.2秒、+2秒、+4秒、+8秒。每次都是相同的片段,从未被确认。而在此期间,小数据包在双向正常流动:心跳、旧数据的确认、连通性检查,一切正常。连接看起来完全健康,除了实际的数据负载。 然后,同一 tailnet 上的 Linux 笔记本通过同样的隧道成功加载了同一个应用。这给了我们一个破局的实验。 ## 不需要 WebRTC 的 Ping 如果片段在前往 iPad 的路上某个地方丢失了,我们不需要 WebRTC 来证明。我们尝试了 ping。一个 1400 字节的 ping 会强制通过 1280 字节的隧道进行分片。一个 100 字节的 ping 则不会。同时在两种地址族上运行,得到一张真值表: ``` ping -s 100 3/3 收到 ping -s 1400 3/3 收到 分片正常 ping -s 100 3/3 收到 ping -s 1400 0/3, 100% 丢包 分片消失 ``` IPv4 分片正常重组。IPv6 分片凭空消失。每次运行都确定性地如此。这并非 iOS 特有的问题:我们指向的每一台 Tailscale 设备都表现出相同的丢包率。Tailscale 自身在每一个平台上都存在吃 IPv6 分片的问题。 ## 招供的计数器 Tailscale 客户端维护诊断计数器,当我们在 IPv6 上运行 `ping -s 1400` 时,接收端有一个计数器会增加。`tailscale metrics print` 的输出包括: ``` tailscaled_inbound_dropped_packets_total{reason="acl"} 6 ``` 三次 ping,每个两次分片,共丢弃六次。在我们检查的每一台机器上,这个算术都吻合。内核自身的 IPv6 重组计数器全程为零——片段在被操作系统看到之前就已经被丢弃了。 `reason="acl"` 意味着数据包过滤器将其作为策略拒绝而丢弃。在个人 tailnet 上看到这一点很奇怪,因为访问策略是*允许一切*。 于是我们到 GitHub 上查看源代码(Tailscale 客户端是开源的,这使得整个排查成为可能)。在那里我们发现,它们的 IPv6 解析器不解析分片。任何携带 IPv6 Fragment 头的数据包都被归类为“未知协议”,而未知协议的数据包无法匹配任何允许规则,于是默认拒绝规则生效。代码中的注释写道: > 注意,这意味着我们不支持 IPv6 分片。这没问题,因为 IPv6 强烈建议你不应该分片。 这句话听起来合理,但我认为它是一个误读。IPv6 禁止*路由器*在传输过程中分片数据包。但它完全允许*发送方*分片,并且规范要求接收端将碎片拼合回去。我们的发送内核遵循了规范。Tailscale 的过滤器设计性地静默丢弃了内核产生的数据,并将其归为“acl”。值得一提的是,IPv4 分片得到了正确处理并通过了,这加剧了我们的海森堡bug。 ## 所有线索都串联起来了 - **为什么是 iPad 而不是 Mac?** Mac 没有连接 Tailscale。不是苹果的问题,而是选择了哪条路由的问题。 - **为什么同样是 Tailscale,iPad 出问题而 Linux 笔记本没问题?** 在 WebRTC 握手中,两个对等端各自公布多个地址,WebRTC 选择其中一对。问题只发生在 Tailscale 的*IPv6*对等端上。iPad 每次都选择了 IPv6 对;而 Linux 浏览器每次都落在 IPv4 或普通局域网上,一切正常。 - **为什么刷新有时会成功?** 每次重新加载都会重新执行握手。在五月份,iPad 偶尔会画出一条非 v6 隧道路由,页面就能加载。真正的浏览器 bug 不会在不同连接之间时有时无。 - **为什么连接从未恢复或报错?** 因为所有*测试*路径的数据包都很小。连通性检查、心跳、确认都在限制以下,并能成功送达。每一层的健康检查都通过,而实际负载却被卡住。 - **为什么 1200 字节的消息仍然失败?** webrtc-rs 将其第一个数据包填充到完整的 1228 字节,这就超过了限制(这就是之前“先记住这个细节”所指的)。 - **为什么 800 字节能工作?** 无论哪种地址族,加上所有开销后都远低于限制。无需分片,因此没有分片被丢弃。 - **为什么 VPN 上的其他一切都不会这样出问题?** 普通的 `https://` 流量会在开始时协商数据包大小(TCP MSS clamping),因此不会发得过多。WebRTC 这类流量没有这种协商,而且几乎没有其他东西会在没有自己的 MTU 处理的情况下通过 v6 发送大 UDP 包。所以这个陷阱一直没触发,直到 webrtc-rs 这样的东西撞上来。 - **事后看来,为什么花了这么久才解决?** 这个故事里有两层重组:WebKit 在第七层将数据包拼成消息,以及内核作为 IP 协议的一部分将分片拼回数据包。我们花了两周指责第一层。真正的肇事者位于下面一层,而且它甚至根本没被运行——因为 Tailscale 吃掉了它的输入。 ## 自己复现 最简单的两命令版本只需要一个包含两台设备的 tailnet: ``` ping -s 100 可以工作 ping -s 1400 100% 丢包 ``` 完整的 WebRTC 版本在 github.com/phact/mtu-webrtc-bug(https://github.com/phact/mtu-webrtc-bug):一个小型中继,会丢弃过大的数据包(作为吃分片路径的确定性替身),以及真实隧道抓包记录和本地化的说明文档。 下一步,我们将向 webrtc-rs 维护者报告这个常量并提出修复建议,同时向 tailscale 仓库提交分片丢弃的问题。 ## 我从中得到的教训 数据包对于路径来说过大,却静默消失,且无人知晓原因——这是互联网最古老的问题之一。它从未被真正解决,只是被掩盖起来;每当新软件在不检查路径允许大小的情况下自行发送数据包时,它就会重新浮出水面。如果你正在构建任何类似的东西(视频通话、游戏、点对点应用),请假设你的用户中有相当一部分正在使用比你预期更小的路径,要么保守地保持数据包小巧,要么在信任路径之前先进行探测。 这两个项目都没有做什么极端的事。webrtc-rs 选了一个常量(顺便说一句,只比 Chrome 的常量乐观了 28 字节),然后相信网络能处理。Tailscale 决定不支持 IPv6 分片,并相信没有任何合法流量会发送它们。单独来看,两个决定都站得住脚。但它们共同构成了一个没有错误信息的陷阱,唯一的症状是在某台特定设备上出现空白页面,并且很可能让你白白浪费一两周时间。 我内心有一部分想说,我们之所以碰到这个问题,是因为 p2claw 以奇怪的方式使用了这些东西。这是事实,但这也是 p2claw 的全部意义所在。p2claw 的存在是为了让代理无需注册即可自托管,让氛围程序员可以用 OAuth 通过一行 CLI 调用部署,让 Web 应用可以点对点。为了做到这些,我们绕过了大多数软件赖以参与互联网的许多机制。这就是编程的全部意义:让系统和标准服从你的意志。你弯曲得越多,遇到的 bug 就越离奇。 我要记住两个排错教训。第一:发送端抓包只能证明数据包*发出了*。我们在盒子上用 tcpdump “验证”了分片正在流动,就断定路径是健康的;但分片要成功,不仅需要发送,还需要到达、被接收并重组。如果调试时只看一端,很容易做出错误假设;尤其是当另一端是别人的代码——比如 Tailscale 的开源客户端时,不要假设它做了你认为它该做的事。而是要去验证它到底做了什么。 第二:如果一个独立复现始终无法重现同一个 bug,有两种可能性,而且它们看起来完全一样。要么是原始 bug 比你想的更复杂,你漏掉了某些微妙的条件;要么是原始 bug 根本不存在,你一直在测试错误的东西。在这两种情况下,唯一正确的事情是找出那个缺失的条件,而不是重复你已经明确知道能正常工作的事情,希望这次能有所不同。

相似文章

引用 Luke Curley

Simon Willison's Blog

技术评论:Luke Curley探讨WebRTC的设计如何通过激进丢弃音频数据包来优先保障低延迟,这与LLM语音应用中提示词准确度比速度更重要的需求相矛盾。他讲述了在浏览器限制下在Discord实现重传所面临的挑战。

OpenAI 的 WebRTC 问题

Hacker News Top

一篇技术博客文章中,一位自称 WebRTC 专家的作者批评了 OpenAI 将 WebRTC 应用于语音 AI 的做法,认为该协议设计用于实时会议,采用激进的丢包机制,这与语音 AI 的应用场景相悖——在语音 AI 中,准确性比极低延迟更为关键。