메뉴
HN
Hacker News 48일 전

아이패드에 Tailscale이 켜져 있었다: WebRTC 디버깅기

IMP
6/10
핵심 요약

아이패드에서만 특정 웹앱이 하얀 화면으로 멈추는 원인을 추적하는 과정을 담은 기술 블로그 글입니다. WebRTC 핸드셰이크는 정상적으로 완료되었으나 데이터 채널을 통해 전송된 일정 크기 이상의 청크가 아이패드 브라우저에 도달하지 못하는 문제였습니다. 최종적으로 VPN이었던 Tailscale의 MTU 설정과 WebRTC 라이브러리의 하드코딩된 버퍼 상수가 결합된 '트렌치코트를 입은 두 마리의 벌레' 같은 복합 버그가 원인으로 밝혀졌습니다.

번역된 본문

← 모든 글

아이패드에 Tailscale이 켜져 있었다 2026-06-10

p2claw가 어떻게 작동하는지 잘 모른다면, 이 글을 읽기 전에 '작동 원리' 블로그 글을 먼저 확인해 보는 것이 좋습니다.

아이패드에서 p2claw 앱 중 하나를 열었더니 빈 페이지가 나왔습니다. 동일한 URL은 Mac, 리눅스 박스, 그리고 휴대폰에서는 정상적으로 작동했습니다. 같은 Wi-Fi, 같은 브라우저 엔진, 같은 네트워크 환경이었습니다. 좋은 탐정 소설처럼, 우리는 [아이패드, WebKit, Tailscale] 등 무려 여러 명의 용의자를 떠올렸고, 그들이 모두 무죄라는 결론을 내렸습니다. 적어도 그런 줄 알았죠.

결과적으로 이는 트렌치코트를 입고 서 있는 두 마리의 벌레(two bugs wearing a trenchcoat)였습니다. webrtc-rs의 하드코딩된 상수와, 우리의 완고한 끈기 끝에 발견한 Tailscale의 단 한 줄짜리 설계 결정이었습니다. 우리는 당일 바로 우회 패치를 적용했지만, 실제로 무엇을 패치한 것인지 이해하는 데는 2주가 더 걸렸습니다.

불만 사항 앱이 로딩 상태를 렌더링할 만큼의 HTML을 불러온 다음 멈췄습니다. 콘솔에 관련된 에러는 없었고, 서비스 워커(Service Worker)는 등록되었으며, WebRTC 핸드셰이크는 완료되었고, 데이터 채널이 열렸습니다 [ dc.readyState === "open" ]. 그리고 아무 일도 일어나지 않았습니다. 브라우저는 데이터 채널을 통해 첫 번째 GET / 요청을 보냈고 응답을 영원히 기다렸습니다. 반대편에 있는 박스 에이전트는 모든 것이 정상이라고 생각했습니다. 응답을 처리하고 바이트들을 채널에 밀어 넣었으니까요. 그저 그 데이터가 아이패드에는 결코 도달하지 않았을 뿐이었습니다. 여기서 끝이 아니라, 이건 하이젠버그(Heisenbug)였습니다. 미친 듯이 새로고침을 하면 가끔씩 페이지가 로드되기도 했습니다.

의심이 들 때는 계측(Instrument)을 하라 우리가 한 번째 유용한 일은 양쪽 연결 끝점의 로그를 남기고 시간별로 정렬하는 것이었습니다. 박스가 보낸 모든 청크, 브라우저가 받은 모든 청크, 그리고 가장 중요한 전달 확인을 대기하며 박스가 아웃바운드 버퍼에 쥐고 있던 데이터의 양을 확인했습니다. 이를 통해 데이터가 어느 지점에서 상대방에게 전달되지 못하고 있는지 파악할 수 있었습니다.

막다른 길 WebRTC 핸드셰이크를 포함해 모든 것을 의심해 보고 배제한 후, 우리는 지푸라기라도 잡는 심정이었습니다. WebRTC와 관련된 몇 가지 제한을 확인하고 네트워크 안정성을 두 번씩이나 점검했습니다.

메시지 크기 제한. WebRTC에서는 두 피어가 데이터를 교환하기 전에 각각 수용할 수 있는 가장 큰 단일 청크 크기에 합의합니다. 이보다 큰 데이터를 보내면 일부 브라우저는 조용히 연결을 끊어버립니다. 우리는 두 기기에서 이 제한( maxMessageSize )을 읽어들였습니다. 아이패드는 Mac과 정확히 똑같이 64kb를 보고했으며, 이는 우리가 보내던 7~8kb 크기의 청크보다 훨씬 컸습니다. 이후 우리는 메시지 청크 크기가 범인이라는 생각을 버리게 되었고, 이는 진짜 원인을 파악하는 것을 더 어렵게 만들었습니다.

불안정한 Wi-Fi. 가장 단순한 설명은 공기 중에 패킷이 손실되었다는 것입니다. 박스에서 ifstat과 tcpdump를 확인한 결과 문제가 없었고, (같은 Wi-Fi를 사용하는) 제 휴대폰에서도 같은 문제가 나타나지 않았습니다. 이는 아이패드에 특정된 문제여야 했지만, 그게 뭔지 알 도리가 없었습니다.

숫자들이 실제로 말해준 것 요청당 박스는 세 개의 청크를 보냈습니다. 220바이트 헤더, 7,874바이트 본문, 그리고 199바이트의 꼬리였습니다. 새로운 계측 도구는 발신자의 아웃바운드 버퍼가 약 8kb까지 올라간 뒤 멈추는 것을 보여주었습니다. 발신은 했지만 도착 확인을 영원히 받지 못한 본문 데이터를 쥐고 있었던 것입니다. 아이패드를 새로고침했을 때도 완전히 똑같은 패턴이 반복되었습니다. WebRTC 데이터 채널은 패킷 손실이 발생하기 쉬운 UDP 위에서 순서를 보장하는 전송을 수행하므로, 하나의 청크가 누락되면 그 이후의 모든 메시지가 차단(block)됩니다. 아이패드의 브라우저 JS 콘솔에서는 오직 하나의 청크(220바이트 헤더)만 수신된 것을 확인할 수 있었고 이후로는 아무것도 없었습니다. 본문이나 이후 요청의 작은 헤더들도 보이지 않았습니다. iOS의 모든 브라우저가 내부적으로 WebKit을 사용한다는 점을 생각하여 Mac의 Safari에서 테스트를 진행해 보았지만, Mac은 8kb 및 11kb 청크를 아무런 문제 없이 받았습니다.

"범인은 Tailscale이었다" 2시간 동안 WebKit 관련 가설을 세우던 중, 나는 Mac과 달리 아이패드에는 Tailscale이 활성화되어 있다는 사실을 깨달았습니다. Tailscale은 일종의 VPN이며, VPN은 트래픽을 추가적인 레이어로 감싸 패킷 하나당 담을 수 있는 데이터의 공간을 줄여버립니다. 결국 Mac에 비해 아이패드로 전송될 때 커다란 응답들이 더 많고 작은 조각으로 쪼개지게 된 것입니다. WebKit은 유저스페이스에서 데이터 채널을 직접 구현하며, incl

원문 보기
원문 보기 (영어)
← all posts The iPad was on Tailscale 2026-06-10 If you're not familiar with how p2claw works, it's worth checking out the how it works blog post before diving into this one. I opened one of my p2claw apps on my iPad and got a blank page. The same URL was working on my Mac, my linux box and my phone. On the same wifi, same browser engine, same network. Like in a good detective story, we came up with a bunch of suspects [the iPad, then WebKit, then Tailscale] and they all turned out to be innocent. Sort of. It turned out to be two bugs wearing a trenchcoat: a hardcoded constant in webrtc-rs, and a one-line design decision in Tailscale that we found through sheer stubbornness. We had a workaround patched the same day, but understanding what we had actually patched took two more weeks. The complaint The app loaded enough HTML to paint the loading state and then hung. There were no relevant console errors, the Service Worker registered, the WebRTC handshake finished, the data channel opened [ dc.readyState === "open" ], and then nothing. The browser sent its first GET / over the data channel and waited forever for the response. The box agent on the other end thought everything was fine. It had served the response and pushed the bytes onto the channel. They just never made it to the iPad. If that wasn't tricky enough, it was a heisenbug: if I refreshed like crazy, the page would sometimes load. When in doubt, instrument The first useful thing we did was log both ends of the connection and line the logs up by clock time: every chunk the box sent, every chunk the browser received, and, crucially, how much data the box was holding in its outbound buffer waiting to be confirmed delivered. That helped us figure out where the data was not making it to the other end. Dead ends After discarding everything up to and including the webrtc handshake, we were grasping at straws. We checked some webrtc specific limits and double checked network stability. A message-size limit. In WebRTC, before two peers start exchanging data they agree on the largest single chunk each will accept. If you send something bigger, some browsers just silently hang up. We read that limit ( maxMessageSize ) off both devices. The iPad reported 64kb, exactly the same as the Mac, and far above the 7-8kb chunks we were sending. After this, we felt like we had discarded message chunk size as a culprit, which ended up making the true diagnosis harder to arrive at. Flaky wifi. The cheapest explanation: packets getting lost over the air. ifstat and tcpdump were clean on the box, and my phone [on the same wifi] did not exhibit the same problem. It had to be something specific to the iPad, but we had no idea what. What the numbers actually said Per request, the box sent three chunks: a 220 byte header, a 7,874 byte body, and a 199 byte tail. Our new instrumentation showed the sender's outbound buffer climb to about 8kb and stop. It was holding the body it had "sent" but could never get confirmation it had arrived. When the ipad refreshed, we saw the same identical pattern. WebRTC data channels guarantee in-order delivery on top of lossy UDP, so one missing chunk blocks subsequent messages. On the iPad, in the browser's js console, we saw exactly one chunk being received [the 220 byte header] and then nothing. We didn't see the body or the small headers of the following requests. We tested on Safari on the Mac, guessing the issue might be WebKit since it happened on every ios browser [and all ios browsers are webkit under the hood], but the Mac was receiving 8kb and 11kb chunks without a hiccup. "It was Tailscale" After two hours of WebKit theories, I realized that, unlike the Mac, the iPad had Tailscale enabled. Tailscale is a VPN, and a VPN wraps your traffic in an extra layer that leaves less room in each packet. So the big responses got sliced into more, smaller pieces on the way to the iPad than they did to the Mac. WebKit implements data channels itself, in userspace, including reassembling big messages from the packets that carry them. Our theory evolved toward a bug in webkit message reassembly. We capped the box's messages at 800 bytes, small enough that each one rode a single packet, and the iPad loaded instantly, Tailscale on or off. It felt like case closed [actually a first attempt at 1,200 bytes, which Claude helped me calculate should fit, mysteriously didn't work. Hold that thought]. In hindsight, we had just discovered that the issue was the VPN, and yet we stuck to our WebKit theory. Given our context bloat [both mine and the agents', this is troubleshooting in the age of AI after all], the Tailscale discovery got absorbed into the WebKit theory instead of challenging it. We could have looked at the network and the webrtc sender, but instead we took it as one more reason the browser was at fault. So we wrote the incident up as an iOS Safari bug [ the device gets the packets but never reassembles them for the app ] and started building a standalone reproduction to prove it. The repro that wouldn't repro For the next two weeks, the bug didn't repro with a JavaScript sender, so we turned to a webrtc-rs based Rust sender. Still nothing. We matched the data channel chunk shapes and sizes, and used a real browser receiver both on Linux and on the iPad, with and without Tailscale. It delivered everything, every time. Eventually we had to re-read our own evidence [actually Anthropic released Fable and I had it dig up the jsonl logs from the original debugging session]. The decisive numbers were in WebRTC's own getStats() counters, which our client logs to the console and which we'd captured in photos of the screen during the incident. The iPad's candidate pair froze at 2,144 bytes received across 18 packets, while the data channel had delivered exactly one message [266 bytes, our 220 byte header plus framing]. The box was retransmitting the big packet the whole time. If Safari were getting those packets and merely failing to stitch the message back together , the transport counter should have climbed by another kilobyte-plus with every retransmission while the message stalled. It never moved. The packets were not arriving at all. Actual photo from the night of the incident. Every number that mattered is in frame, but it took us two weeks to understand them. So we stopped trying to reproduce a browser bug and reproduced the network instead. Suspect number one: webrtc-rs webrtc-rs , the Rust WebRTC stack our box uses, cuts its outgoing data-channel messages into packets sized against this: // sctp/src/association/mod.rs pub(crate) const INITIAL_MTU: u32 = 1228; It's not configurable and nothing ever updates it. The 1,228 byte packet plus the encryption layer that wraps it comes out to 1,265 bytes on the wire. Add the 28 bytes of UDP and IPv4 headers, or 48 for IPv6, and that's a 1,293 byte packet over IPv4, or 1,313 bytes over IPv6. Tailscale's tunnel carries at most 1,280. It turns out that the packet being too big is not fatal by itself. When the kernel routes a large packet into the tunnel, it does the polite thing the IP layer has done since the eighties: it fragments. It sends two pieces over the wire, each under the limit, and they get reassembled on the other side. We confirmed this with tcpdump. The fragments leave the box. On a healthy path everything arrives and the bug is invisible, which is exactly why our standalone repro kept passing. We were back to the drawing board. In the repro, the packets fragmented and reassembled neatly; in the incident, the iPad froze. So the question wasn't why the packet was too big. It was: where did the fragments go? Back to the actual box agent To answer that, we went back to the real thing. We cranked the box agent's chunk cap back up to 8kb, served a real app through it, and loaded it on the iPad over Tailscale while capturing on the tunnel interface. It wedged on cue, and this time we were watching both layers at once. The agent's outbound buffe