<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dmytro Tishchenko</title>
    <description>The latest articles on DEV Community by Dmytro Tishchenko (@dtisch).</description>
    <link>https://dev.to/dtisch</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3942885%2F7a15f5d0-d6ca-44f9-8822-4ba564ac2601.png</url>
      <title>DEV Community: Dmytro Tishchenko</title>
      <link>https://dev.to/dtisch</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dtisch"/>
    <language>en</language>
    <item>
      <title>0.0000008 Mbps: the silent 403 that hid half of my bufferbloat test</title>
      <dc:creator>Dmytro Tishchenko</dc:creator>
      <pubDate>Tue, 29 Sep 2026 15:38:43 +0000</pubDate>
      <link>https://dev.to/dtisch/00000008-mbps-the-silent-403-that-hid-half-of-my-bufferbloat-test-516e</link>
      <guid>https://dev.to/dtisch/00000008-mbps-the-silent-403-that-hid-half-of-my-bufferbloat-test-516e</guid>
      <description>&lt;p&gt;The download half of my bufferbloat test was measuring an idle line. The code asked Cloudflare's speed endpoint for a gigabyte, got back a 403 with a one-byte body, and never looked at the status. The pings kept running, the grades kept coming out, and every result looked like data.&lt;/p&gt;

&lt;p&gt;I build NetDiag+, an iOS network toolkit, and wrote here before about &lt;a href="https://dev.to/dtisch/how-i-implemented-ping-and-traceroute-on-ios-without-entitlements-2fm"&gt;ping and traceroute without entitlements&lt;/a&gt; and &lt;a href="https://dev.to/dtisch/your-speed-test-lies-measuring-latency-on-iphone-without-root-and-the-icmp-bug-that-made-two-5dmp"&gt;measuring latency without root&lt;/a&gt;. In that last post I showed the bufferbloat classifier, including this branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dnN&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;upN&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ispDownstream&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Download latency rose much more than upload: the queue is on the provider's downstream path." It reads well. It could almost never run.&lt;/p&gt;

&lt;p&gt;This post is about how that happened, how I found it, and the lesson I took from it: a load generator has to measure its own load. The feature that exposed the bug is a Health Score that turns one run of about 40 seconds into a number and a verdict on who is to blame.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a bufferbloat test works
&lt;/h2&gt;

&lt;p&gt;Bufferbloat is latency that appears only when the line is busy. An idle ping to 1.1.1.1 says 14 ms; start a big download on the same connection and the same ping says 180 ms, because packets are now waiting in an oversized buffer somewhere between you and the internet. That is what makes a video call stutter while someone else in the house updates a game.&lt;/p&gt;

&lt;p&gt;The test is simple in principle:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Baseline&lt;/strong&gt;: ping the home gateway and an internet target for five seconds with the line idle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Download&lt;/strong&gt;: saturate the downlink for ten seconds while the same two pings keep running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recovery&lt;/strong&gt;: two seconds of nothing, so buffers drain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upload&lt;/strong&gt;: saturate the uplink for ten seconds, pings still running.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The grade (A+ to F) comes from how far the internet ping rises over its baseline under load. The attribution comes from the &lt;em&gt;gateway&lt;/em&gt; ping: if the gateway climbs too, the queue is between the phone and the router, i.e. Wi-Fi; if the gateway stays flat and the internet leg climbs, the queue is past the router. Comparing the two load phases then separates the router's upload buffer from the provider's downstream queue.&lt;/p&gt;

&lt;p&gt;The physics behind the attribution is the old argument from every bufferbloat thread: &lt;strong&gt;the queue forms at the bottleneck.&lt;/strong&gt; Toward your home, the bottleneck is usually the provider's equipment, so download bloat lives there. Toward the internet, the bottleneck is usually your own modem or router, so upload bloat is yours. That asymmetry is why comparing the two load phases can separate them at all.&lt;/p&gt;

&lt;p&gt;It also shapes the fix: upload bloat is solved on your router with fq_codel or cake; download bloat can be tamed from home by shaping inbound traffic a few percent below line rate, or handed to the provider as evidence. (Details in the &lt;a href="https://netdiag.online/guides/bufferbloat/" rel="noopener noreferrer"&gt;bufferbloat guide&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;Every one of those steps assumes the load phases actually load something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The load that wasn't
&lt;/h2&gt;

&lt;p&gt;Here is the download saturation as it shipped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;downloadURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;string&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"https://speed.cloudflare.com/__down?bytes=1073741824"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;  &lt;span class="c1"&gt;// 1 GB — we cancel by time&lt;/span&gt;

&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;saturateDownload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimeInterval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;URLSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ephemeral&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invalidateAndCancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;downloadURL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ContinuousClock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;advanced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kt"&gt;ContinuousClock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="kt"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isCancelled&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// network error — measurement still yields whatever ICMP got.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ask for a gigabyte, drain it for ten seconds, cancel. Nothing in it is wrong as Swift. Three things in it are wrong as a measurement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The response is never looked at.&lt;/strong&gt; &lt;code&gt;(bytes, _)&lt;/code&gt; throws away the &lt;code&gt;URLResponse&lt;/code&gt;. Whatever status came back, the loop ran.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing is counted.&lt;/strong&gt; The function returns &lt;code&gt;Void&lt;/code&gt;. There is no way for the caller to learn whether ten seconds of saturation moved ten gigabits or ten bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The failure path is silent by design.&lt;/strong&gt; The comment even says so: whatever happens, "the measurement still yields whatever ICMP got".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I asked the endpoint directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s -o /dev/null -w "%{http_code} %{size_download}\n" \
    "https://speed.cloudflare.com/__down?bytes=99999999"
200 99999999

$ curl -s -o /dev/null -w "%{http_code} %{size_download}\n" \
    "https://speed.cloudflare.com/__down?bytes=104857600"
403 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything from 100 MB up gets a 403 with a one-byte body, with any user agent. I cannot say whether that limit was already there when I wrote the code. The code gave itself no way to notice either way. The "download phase" had been ten seconds of pings over an idle line.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it did to the verdicts
&lt;/h2&gt;

&lt;p&gt;Tracing it through the classifier is the uncomfortable part. With no download load, the download rise over baseline is roughly zero. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;worstRise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;dnNetRise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;upNetRise&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;compactMap&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;grade&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Grade&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;latencyRiseMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;worstRise&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// effectively: upload only&lt;/span&gt;
&lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;grade&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aPlus&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;grade&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;grade&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clean&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;upN&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;dnN&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uplinkQueue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;          &lt;span class="c1"&gt;// true for any upload bloat over ~0&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dnN&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;upN&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ispDownstream&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;        &lt;span class="c1"&gt;// unreachable&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modemOrLine&lt;/span&gt;                                 &lt;span class="c1"&gt;// nearly unreachable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;grade&lt;/strong&gt; was an upload-only grade. A line that was clean under upload and awful under download got an A.&lt;/li&gt;
&lt;li&gt;Any real bloat was blamed on the &lt;strong&gt;router's upload queue&lt;/strong&gt;, because "upload rose at least twice as much as download" is true when download rose by nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"ISP downstream"&lt;/strong&gt;, the verdict the tool exists to produce for a support ticket, could not fire.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Gateway attribution still worked, since the upload phase did load the line, so Wi-Fi faults were caught. But the one number people screenshot for their provider was half a measurement.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it surfaced
&lt;/h2&gt;

&lt;p&gt;Not from a user report. It surfaced when I started reusing the load phases for something new.&lt;/p&gt;

&lt;p&gt;The new feature, Health Score, wants a speed number, and running a separate speed test next to a bufferbloat test is wasteful: both saturate the same line. So the plan was to count the bytes the load phases already move. The first simulator run came back with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score 89/100 · Excellent · grade A+
download 0.0000008 Mbps · upload 65.0 Mbps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An excellent connection that downloads at less than one bit per second is a contradiction you cannot miss. The upload number was plausible; the download number was the single byte the 403 carried, spread over ten seconds. The moment the load had to report its own size, the lie had nowhere to go.&lt;/p&gt;

&lt;p&gt;That is the general lesson I took: &lt;strong&gt;a load generator has to measure its own load.&lt;/strong&gt; If the part of a test that is supposed to stress the system cannot fail visibly, it will eventually stress nothing, and every number downstream of it will still look like data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Download is now 50 MB requests back to back until the window closes, with bytes counted in the data delegate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;downloadURL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="kt"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;string&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"https://speed.cloudflare.com/__down?bytes=52428800"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;  &lt;span class="c1"&gt;// 50 MB, under the limit&lt;/span&gt;

&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;saturateDownload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimeInterval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ByteCounter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;URLSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;configuration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ephemeral&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;delegate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;delegateQueue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ContinuousClock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;advanced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;by&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;loop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="kt"&gt;ContinuousClock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="kt"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isCancelled&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dataTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;downloadURL&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;// 50 MB chunk&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;nanoseconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;UInt64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1_000_000_000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;loop&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invalidateAndCancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;elapsedSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;since&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The delegate counts whole buffers.&lt;/strong&gt; &lt;code&gt;didReceive data:&lt;/code&gt; hands over chunks of tens of kilobytes. The old &lt;code&gt;for try await _ in bytes&lt;/code&gt; iterated one byte at a time, which on a fast line is a CPU benchmark, not a network one: the phone gives up well before a gigabit connection does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The function returns a rate, or nil.&lt;/strong&gt; A nil propagates into the result as "no speed measured", and the grade logic can see it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Upload got the same treatment for a different reason. It used to POST one 32 MB body. On anything faster than about 25 Mbps that finishes early and the uplink sits idle for the rest of the phase, exactly when the test wants the queue full. It is now 8 MB POSTs back to back until the window closes, counted in &lt;code&gt;didSendBodyData&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;After the fix, on the same Wi-Fi: 455 Mbps down, 62 Mbps up, grade A instead of A+, because the download phase now finally adds a few milliseconds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0w1x3nhopy9xf6aaj7cq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0w1x3nhopy9xf6aaj7cq.png" alt="Health Score result: the score ring and the Wi-Fi, Router and ISP rows" width="800" height="265"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Health Score: one run, one number, one culprit
&lt;/h2&gt;

&lt;p&gt;With the load phases honest, Health Score is mostly orchestration of tools that already existed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;What runs&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Idle&lt;/td&gt;
&lt;td&gt;ICMP to 1.1.1.1 and 8.8.8.8, TCP connect to 1.1.1.1:443, gateway ping&lt;/td&gt;
&lt;td&gt;~8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load&lt;/td&gt;
&lt;td&gt;the Bufferbloat probe, now counting bytes&lt;/td&gt;
&lt;td&gt;~27 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recovery&lt;/td&gt;
&lt;td&gt;ten more pings to 1.1.1.1&lt;/td&gt;
&lt;td&gt;~3 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The phases are sequential on purpose. Throughput and latency cannot be measured cleanly at the same time: the throughput test fills the queue, and a filled queue corrupts the latency number. That corruption &lt;em&gt;is&lt;/em&gt; bufferbloat. So the latency you care about for gaming is measured idle, and the latency you care about for "everything lags when someone downloads" is measured under load.&lt;/p&gt;

&lt;p&gt;The score is a weighted sum of five components:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Max&lt;/th&gt;
&lt;th&gt;What earns the points&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Responsiveness under load&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;the Bufferbloat grade: A+ 35, A 30, B 22, C 12, D 5, F 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle latency&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;under 20 ms 20, under 40 ms 16, under 80 ms 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stability&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;idle jitter under 5 ms 20; any loss −6, 10% or more zeroes it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;download tiers up to 12, upload up to 3; gentler curve on cellular&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local link&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;gateway RTT and jitter; points given on cellular&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Responsiveness gets the biggest weight deliberately. Most people's complaint is not "my internet is slow" in the speed-test sense; it is that it becomes unusable at certain moments, and that is a queueing problem, not a bandwidth one.&lt;/p&gt;

&lt;p&gt;Under the number there are three rows: &lt;strong&gt;Wi-Fi&lt;/strong&gt;, &lt;strong&gt;Router&lt;/strong&gt;, &lt;strong&gt;ISP&lt;/strong&gt;, each ok, suspect or at fault. They come from the same rules as the Bufferbloat verdict, plus the idle numbers that load never touches: a gateway that already answers in 30 ms is Wi-Fi's fault, loss on the idle path with a healthy gateway is the provider's.&lt;/p&gt;

&lt;p&gt;One more lesson from the first runs. Idle loss was originally the worst row: a single refused TCP handshake out of ten gave that row 10% "loss", which cost eight points and blamed the ISP for one dropped SYN. It is now the mean over the ICMP rows, twenty scored samples. Averaging where you have the samples and taking the worst where you do not is a choice worth writing down.&lt;/p&gt;

&lt;p&gt;Nothing leaves the phone. The scoring is a pure function over the numbers in one run, and every row expands on tap to show its thresholds, so a user can see why they got 64 and not 80.&lt;/p&gt;

&lt;p&gt;This is what the Health Score detail rows look like, from another run on the same Wi-Fi:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bufferbloat grade&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle latency&lt;/td&gt;
&lt;td&gt;14 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jitter&lt;/td&gt;
&lt;td&gt;1 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Loss&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Download&lt;/td&gt;
&lt;td&gt;447.5 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upload&lt;/td&gt;
&lt;td&gt;68.4 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gateway&lt;/td&gt;
&lt;td&gt;3 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Three iOS networking gotchas from the same release
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VPN Check.&lt;/strong&gt; Is a tunnel up, what is your public identity, what NAT type does STUN see, and does the system resolver agree with Cloudflare DoH. On iOS, the presence of &lt;code&gt;utun&lt;/code&gt; interfaces tells you nothing, since every iPhone has several for system services; a VPN shows up as a &lt;code&gt;utun&lt;/code&gt;/&lt;code&gt;ipsec&lt;/code&gt; key under &lt;code&gt;__SCOPED__&lt;/code&gt; in &lt;code&gt;CFNetworkCopySystemProxySettings()&lt;/code&gt;, or as a tunnel interface in the &lt;em&gt;current&lt;/em&gt; &lt;code&gt;NWPath&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP Headers&lt;/strong&gt; with the full redirect chain. App Transport Security refuses plain &lt;code&gt;http://&lt;/code&gt; in &lt;code&gt;URLSession&lt;/code&gt;, and the http→https hop is the one people most want to see, so that single hop is a raw HTTP/1.1 exchange over &lt;code&gt;NWConnection&lt;/code&gt;. ATS stays on for everything else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reverse DNS&lt;/strong&gt; via &lt;code&gt;getnameinfo&lt;/code&gt; with &lt;code&gt;NI_NAMEREQD&lt;/code&gt;, forward-resolving hostnames first and showing the &lt;code&gt;in-addr.arpa&lt;/code&gt; name so the answer can be repeated with &lt;code&gt;dig -x&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The released version still doesn't check the response code.&lt;/strong&gt; 50 MB chunks fit under today's Cloudflare limit, but if it drops, the download would read near zero again. That is the exact failure this post is about, so in the next version a non-2xx answer stops the phase and the speed is marked "not measured" instead of a tiny number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare is the load target.&lt;/strong&gt; If a network throttles or blocks &lt;code&gt;speed.cloudflare.com&lt;/code&gt;, the speeds in the report are low, and they are speeds to Cloudflare, not your plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One stream.&lt;/strong&gt; Chunks go one after another over a single connection. Speed tests usually open several in parallel; on high-RTT lines one stream may not reach the plan's rate, which understates both speed and queueing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The phone is the bottleneck on very fast lines.&lt;/strong&gt; Above roughly a gigabit, Wi-Fi and the device limit throughput before the line does. The score rewards 100 Mbps and up the same way for that reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health Score is a heuristic.&lt;/strong&gt; The weights are opinions written down as code. They are public in the app's help screen, and I would rather be argued with over a table than hide it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Earlier Bufferbloat grades should be read as upload-only.&lt;/strong&gt; If you saved results from 1.4.5 to 1.4.8, don't rely on the download rows: I don't know when the limit appeared, and in the latest of those versions they certainly measured an idle line.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;NetDiag+ on the App Store (free; $2.99 one-time premium): &lt;a href="https://apps.apple.com/app/apple-store/id6761954529?pt=128748487&amp;amp;ct=devto&amp;amp;mt=8" rel="noopener noreferrer"&gt;https://apps.apple.com/app/apple-store/id6761954529?pt=128748487&amp;amp;ct=devto&amp;amp;mt=8&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;What bufferbloat is and how to fix it: &lt;a href="https://netdiag.online/guides/bufferbloat/" rel="noopener noreferrer"&gt;https://netdiag.online/guides/bufferbloat/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Have you had a benchmark, load test or health check that quietly stopped doing its job while still producing plausible numbers? I'm curious how you found out. And if you think the Health Score weights are wrong, the table above is there to be argued with.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>networking</category>
      <category>testing</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Your speed test lies: measuring latency on iPhone without root, and the ICMP bug that made two pings swap replies</title>
      <dc:creator>Dmytro Tishchenko</dc:creator>
      <pubDate>Tue, 22 Sep 2026 13:32:28 +0000</pubDate>
      <link>https://dev.to/dtisch/your-speed-test-lies-measuring-latency-on-iphone-without-root-and-the-icmp-bug-that-made-two-5dmp</link>
      <guid>https://dev.to/dtisch/your-speed-test-lies-measuring-latency-on-iphone-without-root-and-the-icmp-bug-that-made-two-5dmp</guid>
      <description>&lt;p&gt;NetDiag+ 1.4.7 went live this week — the iOS network toolkit from my &lt;a href="https://dev.to/dtisch/how-i-implemented-ping-and-traceroute-on-ios-without-entitlements-2fm"&gt;v1.3&lt;/a&gt; and &lt;a href="https://dev.to/dtisch/what-i-learned-building-9-network-tools-on-ios-without-entitlements-10ii"&gt;v1.4&lt;/a&gt; posts, now 27 tools, 13 languages, BSD sockets through C-interop, no private entitlements. This release adds one tool, a Latency Dashboard, and fixes one bug that had been shipping quietly since 1.3. Both taught me more about measurement than the other 25 tools combined.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a speed test does not answer "why do I lag"
&lt;/h2&gt;

&lt;p&gt;Most of my users are in Saudi Arabia, Turkey, Egypt and Russia, and the most common support question is some version of "the speed test says 180 Mbps but PUBG is unplayable". Both are true at once. A speed test measures throughput to a CDN edge placed deliberately close to you. A game talks UDP to a datacentre in a &lt;em&gt;region&lt;/em&gt; — Bahrain, Frankfurt, Mumbai — and what hurts there is round-trip time and how much it wobbles between packets. Throughput to a nearby edge says nothing about either.&lt;/p&gt;

&lt;p&gt;My own app had the same blind spot. Site Reach, the censorship tool from 1.4, measures TLS handshake time to 35 web frontends and sorts them alphabetically; in the Gulf nothing is blocked, so it returns 35 green rows and the worst latency hides mid-alphabet. The Latency Dashboard is the opposite question with the same machinery: &lt;strong&gt;what is far from here, and what is unstable?&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The catalog is the product
&lt;/h3&gt;

&lt;p&gt;The value is in &lt;em&gt;which endpoints&lt;/em&gt; get measured, and that has to be per market — a Saudi gamer does not care about Steam's Frankfurt CM; they care about AWS &lt;code&gt;me-south-1&lt;/code&gt;, where PUBG Mobile's Middle East matchmaking lives.&lt;/p&gt;

&lt;p&gt;So the table is keyed off the App Store storefront (&lt;code&gt;SKStorefront.countryCode&lt;/code&gt;), not the UI language — a Saudi expat in London still wants the Gulf table. Five tables, 13–14 rows each across cloud regions, game ping hosts, CDN edges and carrier resolvers, plus four global anchors as a baseline. Every row carries a &lt;code&gt;port&lt;/code&gt; (0 = ICMP, otherwise TCP connect — AWS/GCP/Azure drop ICMP by policy), a &lt;code&gt;confidence&lt;/code&gt; and a &lt;code&gt;note&lt;/code&gt; saying what the number actually measures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"aws-me-south-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"AWS Bahrain"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;hostOrIP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"ec2.me-south-1.amazonaws.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cloudRegion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;medium&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Degraded post Mar 2026 — PUBG-M ME hosted here"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"fortnite-me"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"Fortnite Middle East"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;hostOrIP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"ping-me.ds.on.epicgames.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gaming&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nv"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;high&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two curation rules. &lt;strong&gt;Do not invent hosts&lt;/strong&gt;: PUBG Mobile, Free Fire, CoD Mobile, Discord voice and Riot Direct publish no per-region ping hostnames, so the nearest cloud region stands in as a labelled proxy. &lt;strong&gt;A failing row is data&lt;/strong&gt;: AWS Bahrain has been degraded since March 2026 and Valorant MENA moved to Mumbai; a red Bahrain row next to a green Mumbai row &lt;em&gt;is&lt;/em&gt; the diagnosis.&lt;/p&gt;

&lt;p&gt;Rows sort worst-first by median plus jitter, and the gateway is measured &lt;em&gt;before&lt;/em&gt; everything else, alone. If the router already answers in 30 ms, every remote number inherits that and a banner says so instead of blaming geography.&lt;/p&gt;

&lt;p&gt;[SCREENSHOT: Latency Dashboard, SA storefront — gateway header, worst-first rows with median / jitter / loss columns, Bahrain "No reply" next to a green Mumbai row]&lt;/p&gt;

&lt;h2&gt;
  
  
  Measurement engineering: seven rounds against a Mac
&lt;/h2&gt;

&lt;p&gt;I no longer trust a phone-only latency number. The tool was tuned against a MacBook on the same Wi-Fi running the identical probe sequence from a script, in the same minute. Seven device rounds, five defects, each one looked like "the network" until the Mac said otherwise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Warm-up.&lt;/strong&gt; First run: 8 of 9 rows "Unstable", jitter 384–402 ms on paths with 56–195 ms medians. The first concurrent chunk was paying ~1.5 s for Network.framework path setup and the radio leaving power save. Fix: probes are lists of &lt;em&gt;attempts&lt;/em&gt; (&lt;code&gt;[Double?]&lt;/code&gt;, nil = no answer) and attempt #1 is discarded whether it answered or not — otherwise a warm-up that misses the 2 s timeout becomes 20% fake loss. One throwaway TCP connect warms the path before the fan-out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wi-Fi power-save doze.&lt;/strong&gt; Gateway 3 ± 1 ms, yet Frankfurt 68 ± 36 against 29 ± 3 from the Mac, and one target swung 38 → 2 → 1 ms of jitter across three back-to-back runs. A gateway reply at 3 ms lands while the radio is still awake; a reply from 30–170 ms away lands after it dozed and waits on the AP for the next beacon. No gateway statistic can predict that, so the fix removes the cause: a second &lt;code&gt;PingService&lt;/code&gt; fires ICMP at the gateway every 50 ms during the fan-out (started &lt;em&gt;after&lt;/em&gt; the gateway row so the baseline stays untouched). Not cheating: game and voice traffic hold the radio awake anyway, so those are the numbers the user plays on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Address alternation.&lt;/strong&gt; Long paths settled (Tokyo jitter 42 → 6) but CDN rows went bimodal: Snapchat median 24, jitter &lt;strong&gt;230&lt;/strong&gt; — samples alternating 24, 250, 24, 250. A radio does not do that. A fresh &lt;code&gt;NWConnection&lt;/code&gt; by hostname per attempt does: short-TTL CDN names plus Happy Eyeballs racing v6/v4, the winner changing between attempts. The Mac script had resolved once and connected to the IP, so now the app does too (&lt;code&gt;pinnedIPv4&lt;/code&gt;: resolve once, connect to the literal, hostname fallback when there is no A record). A TCP-connect time is only an RTT if every attempt hits the same address.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A statistics defect.&lt;/strong&gt; Residual short-path jitter 16–29 ms vs the Mac's 2–6. TCP rows had 6 attempts → 5 samples → 4 deltas, and &lt;code&gt;sorted[count / 2]&lt;/code&gt; on four values is the &lt;em&gt;upper&lt;/em&gt; median — the second-largest. One residual spike became "jitter". Fix: 11 attempts everywhere (10 scored, 9 deltas) and a true median. Jitter is the &lt;strong&gt;median&lt;/strong&gt; of consecutive |Δ|, not the mean, and "Unstable" is relative — 12 ms of wobble matters on a 30 ms path and is noise on a 260 ms one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;median&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;trueMedian&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;deltas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dropFirst&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;jitter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isEmpty&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;trueMedian&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deltas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;unstableAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;measurement&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tcpConnect&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;median&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;LatencyResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;Verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;loss&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;losing&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;jitter&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;unstableAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unstable&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;median&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;far&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;closeStable&lt;/span&gt;
&lt;span class="p"&gt;}()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more rule: an ICMP target with zero replies is re-asked over TCP on the port it actually speaks (:53 for resolvers, :443 otherwise) and, if that answers, marked &lt;em&gt;ICMP filtered&lt;/em&gt; rather than unreachable. After that the TCP rows matched the Mac to within a few ms. Then round 6 happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: two pings that swapped replies
&lt;/h2&gt;

&lt;p&gt;Round 6: every ICMP row read &lt;strong&gt;median 0 ms, loss 0%, "Close &amp;amp; stable"&lt;/strong&gt;. Including Fortnite Middle East, which had honestly said "No reply" for the previous five rounds.&lt;/p&gt;

&lt;p&gt;The v1.3 post showed how iOS lets you ping without entitlements: &lt;code&gt;socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP)&lt;/code&gt;. What I did not know then, and what the 50 ms keepalive made impossible to miss, is how Darwin demultiplexes those sockets. On Linux, an unprivileged ICMP socket receives replies matching &lt;em&gt;its&lt;/em&gt; identifier. On Darwin, &lt;strong&gt;every echo reply reaching the host is delivered to every unprivileged ICMP socket in the process&lt;/strong&gt;; the kernel does not filter on the identifier you put in the packet.&lt;/p&gt;

&lt;p&gt;The 1.3–1.4.5 receive path did one &lt;code&gt;receive()&lt;/code&gt; per send and accepted any echo reply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// PingService, 1.3 – 1.4.5&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;packet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;fromIP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;// one receive per send&lt;/span&gt;
&lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ICMPHeader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kt"&gt;ICMPType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;echoReply&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rawValue&lt;/span&gt; &lt;span class="c1"&gt;// any echo reply will do&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With one ping running, that is fine. With a gateway keepalive answering every 50 ms, each row's &lt;code&gt;receive()&lt;/code&gt; returned the gateway's reply microseconds after its own send: 0 ms RTT, zero loss, for a host that was not answering at all.&lt;/p&gt;

&lt;p&gt;The uncomfortable part: this was not a new bug. Bufferbloat (1.4.5) runs two &lt;code&gt;PingService&lt;/code&gt; instances concurrently — gateway and 1.1.1.1 — precisely to compare them, and under load their samples could be swapped: a 3 ms gateway reply booked to the internet leg and vice versa.&lt;/p&gt;

&lt;p&gt;The fix lives in &lt;code&gt;PingService&lt;/code&gt; so every caller inherits it. Loop on &lt;code&gt;receive()&lt;/code&gt; until identifier &lt;em&gt;and&lt;/em&gt; sequence match, re-arming &lt;code&gt;SO_RCVTIMEO&lt;/code&gt; with the remaining time each iteration — otherwise every skipped foreign reply resets the full timeout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;sendTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CFAbsoluteTimeGetCurrent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sendTime&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;

&lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;packet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// Accept only our identifier + this sequence; skip the rest until deadline.&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="kt"&gt;CFAbsoluteTimeGetCurrent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;SocketError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;remaining&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.001&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;// 0 would block forever&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;fromIP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;sock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;recvTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CFAbsoluteTimeGetCurrent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ICMPHeader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
              &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kt"&gt;ICMPType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;echoReply&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rawValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identifier&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sequenceNumber&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kt"&gt;UInt16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;continuation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;PingResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                      &lt;span class="nv"&gt;rtt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;recvTime&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;sendTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fromIP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                      &lt;span class="nv"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="kt"&gt;SocketError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// don't yield, count as lost&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;max(remaining, 0.001)&lt;/code&gt; matters: a zero &lt;code&gt;timeval&lt;/code&gt; means "no timeout" to &lt;code&gt;SO_RCVTIMEO&lt;/code&gt;. Found out the hard way.&lt;/p&gt;

&lt;p&gt;Round 7, after the fix: Fortnite Europe 41/4, Fortnite Asia 257/6, Bahrain back to "No reply", TCP rows within 2–5 ms of the Mac. Closed. If you run concurrent ICMP anywhere on Darwin and do not check identifier + sequence on receive, you have this bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bufferbloat: attribution, not just a grade
&lt;/h2&gt;

&lt;p&gt;The bufferbloat tool from 1.4.5 inherits the reply-matching fix, which matters because its whole point is two pings at once. Baseline 5 s, saturate download 10 s, recover 2 s, saturate upload 10 s — with ICMP to the gateway &lt;em&gt;and&lt;/em&gt; to 1.1.1.1 throughout at 200 ms spacing. Grading is the Waveform-style scale on latency rise under load (under 5 ms A+, 30 A, 60 B, 200 C, 400 D, else F), but the grade is not the interesting output. The gateway differential is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Gateway itself climbs (&amp;gt;30 ms) under load: the queue is between the&lt;/span&gt;
&lt;span class="c1"&gt;// phone and the router. Overrides any ISP-side interpretation.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;dnGw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dnGwRise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dnGw&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;localWifi&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;upGw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;upGwRise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;upGw&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;localWifi&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;grade&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;aPlus&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;grade&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;grade&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clean&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Asymmetric: whichever direction is worse ≥ 2x names the queue.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;upN&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;dnN&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uplinkQueue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dnN&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;upN&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ispDownstream&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;modemOrLine&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gateway flat while the internet leg climbs: the queue is past the router, ISP side. Gateway itself climbs: it is your Wi-Fi, and complaining to the ISP will not help. Medians and p95 rises only, so one stray 400 ms spike cannot flip a diagnosis. Results are stored as structured History entries and read back oldest-first as a trend, so you can see whether the router firmware update did anything.&lt;/p&gt;

&lt;p&gt;[SCREENSHOT: Bufferbloat result — grade, gateway vs internet latency-under-load chart, verdict "Queue is on the ISP side"]&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations, and what iOS forbids
&lt;/h2&gt;

&lt;p&gt;Honest list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ICMP RTT is not in-game ping.&lt;/strong&gt; Protocol overhead, server tick rate and matchmaking region sit on top. The row says "network latency to the region where those servers run", never "your PUBG ping". TCP-connect rows are labelled as such and get a higher Unstable floor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No raw sockets&lt;/strong&gt;, so no TTL on replies (the IP header is stripped) and no kernel-side identifier filtering — hence the check in user space. No background sampling either; everything runs while the tool is open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No cellular keepalive.&lt;/strong&gt; The 50 ms trick targets Wi-Fi power save; cellular DRX has no gateway to ping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Carrier portal rows are web front doors.&lt;/strong&gt; &lt;code&gt;stc.com.sa&lt;/code&gt; answers from a CDN edge 17 ms from Slovenia — not Saudi Arabia. They carry a "not the carrier's network" note until an in-country tester finds real on-net hops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A phone-only reading is not evidence.&lt;/strong&gt; Run the same probe from a laptop on the same Wi-Fi before touching a threshold; all five of my defects were invisible without it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;NetDiag+ on the App Store (free, $2.99 one-time premium): &lt;a href="https://apps.apple.com/app/apple-store/id6761954529?pt=128748487&amp;amp;ct=devto&amp;amp;mt=8" rel="noopener noreferrer"&gt;https://apps.apple.com/app/apple-store/id6761954529?pt=128748487&amp;amp;ct=devto&amp;amp;mt=8&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Bufferbloat guide: &lt;a href="https://netdiag.online/guides/bufferbloat/" rel="noopener noreferrer"&gt;https://netdiag.online/guides/bufferbloat/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Game ping and jitter guide: &lt;a href="https://netdiag.online/guides/game-ping-jitter/" rel="noopener noreferrer"&gt;https://netdiag.online/guides/game-ping-jitter/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No accounts; results never leave the device. AdMob + Firebase aggregate analytics as before, gated on consent where required. If you have a Darwin ICMP war story of your own, the comments are open.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>networking</category>
      <category>performance</category>
    </item>
    <item>
      <title>What I learned building 9 network tools on iOS without entitlements</title>
      <dc:creator>Dmytro Tishchenko</dc:creator>
      <pubDate>Thu, 04 Jun 2026 09:08:23 +0000</pubDate>
      <link>https://dev.to/dtisch/what-i-learned-building-9-network-tools-on-ios-without-entitlements-10ii</link>
      <guid>https://dev.to/dtisch/what-i-learned-building-9-network-tools-on-ios-without-entitlements-10ii</guid>
      <description>&lt;p&gt;A few weeks ago I shipped NetDiag+, an iOS network toolkit — ping, traceroute,&lt;br&gt;
DNS, whois, port/LAN scan — built entirely on BSD sockets through C-interop, with&lt;br&gt;
&lt;strong&gt;no private entitlements&lt;/strong&gt;. &lt;a href="https://dev.to/dtisch/how-i-implemented-ping-and-traceroute-on-ios-without-entitlements-2fm"&gt;I wrote up the ping/traceroute internals here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Then I spent a month adding &lt;strong&gt;9 more tools&lt;/strong&gt;. I expected "more of the same" — wrap a&lt;br&gt;
socket, draw a SwiftUI list. Instead almost every tool turned into its own little&lt;br&gt;
research project. Here are the parts that surprised me.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. MTR — a continuous traceroute that can't be slow
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;mtr&lt;/code&gt; keeps probing every hop and accumulates per-hop loss + RTT stats. My first&lt;br&gt;
version probed hops &lt;strong&gt;sequentially&lt;/strong&gt;: TTL=1, wait for reply or timeout, TTL=2, wait…&lt;br&gt;
On a 12-hop path where a couple of routers rate-limit ICMP, one cycle took &lt;strong&gt;5-7&lt;br&gt;
seconds&lt;/strong&gt;. Set the interval to "1 second" and the cycle counter ticks every five.&lt;/p&gt;

&lt;p&gt;Fix: fire all probes in a cycle &lt;strong&gt;at once&lt;/strong&gt; and collect replies in a single window.&lt;br&gt;
The trick is matching an incoming ICMP reply to the TTL that triggered it. I give&lt;br&gt;
each probe a unique destination UDP port (&lt;code&gt;base + ttl + cycle_offset&lt;/code&gt;). When a router&lt;br&gt;
returns ICMP Time Exceeded it includes the first bytes of the original packet — UDP&lt;br&gt;
header included — so I demultiplex on that port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;/// Pull the dest port out of the UDP header embedded in an ICMP Time-Exceeded&lt;/span&gt;
&lt;span class="c1"&gt;/// error, so we know which outstanding probe this reply belongs to.&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;extractInnerDestPort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;UInt16&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;icmpHeader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;icmpHeader&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;ihlBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;icmpHeader&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0x0F&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;   &lt;span class="c1"&gt;// inner IP header length&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;ihlBytes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;innerUDP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;icmpHeader&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ihlBytes&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;innerUDP&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UInt16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;innerUDP&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kt"&gt;UInt16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;innerUDP&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now cycle time ≈ &lt;code&gt;probeTimeout&lt;/code&gt;, independent of how many hops are timing out.&lt;/p&gt;

&lt;p&gt;For the stats, storing every RTT and recomputing stddev leaks memory in a tool that&lt;br&gt;
runs forever. &lt;strong&gt;Welford's online algorithm&lt;/strong&gt; updates mean + variance in O(1):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="n"&gt;recv&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rttMs&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;mean&lt;/span&gt;
&lt;span class="n"&gt;mean&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;m2&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rttMs&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;stdDevMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;recv&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m2&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recv&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;squareRoot&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Path MTU — the obvious approach silently lied
&lt;/h3&gt;

&lt;p&gt;Path MTU = the largest packet that reaches the destination unfragmented. Classic VPN&lt;br&gt;
debugging: if your tunnel caps MTU at 1420 and the app sends 1500 with DF set, packets&lt;br&gt;
vanish and "the internet works but half the sites don't load."&lt;/p&gt;

&lt;p&gt;The algorithm is a binary search: send a Don't-Fragment packet of growing size, watch&lt;br&gt;
for ICMP "Fragmentation Needed." My first version sent &lt;strong&gt;UDP&lt;/strong&gt; to a random high port&lt;br&gt;
and treated ICMP Port Unreachable as the "it arrived" signal.&lt;/p&gt;

&lt;p&gt;Testing against &lt;code&gt;1.1.1.1&lt;/code&gt; it reported &lt;strong&gt;Path MTU = 688 bytes&lt;/strong&gt; — nonsense. Cloudflare&lt;br&gt;
(like most well-run hosts) &lt;strong&gt;silently drops&lt;/strong&gt; UDP to random ports, so Port Unreachable&lt;br&gt;
never comes back. Every timeout looked like "too big" and the search converged on junk.&lt;/p&gt;

&lt;p&gt;Rewrote it on &lt;strong&gt;ICMP Echo with DF&lt;/strong&gt;. Public hosts answer pings reliably, so "it fit"&lt;br&gt;
became a real signal. And when a router returns Frag-Needed it includes its &lt;strong&gt;next-hop&lt;br&gt;
MTU&lt;/strong&gt; (RFC 1191), so you can jump straight to the answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;extractNextHopMTU&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from&lt;/span&gt; &lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;mtu&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;UInt16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kt"&gt;UInt16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;mtu&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mtu&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bonus: the discovered value hints at the link type — 1492 → PPPoE, 1480 → GRE,&lt;br&gt;
1420-1440 → WireGuard, 1400 → OpenVPN/IPSec.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Site Reach — TCP success doesn't mean "reachable"
&lt;/h3&gt;

&lt;p&gt;A tool that checks whether popular sites are reachable. Naive version: TCP-connect to&lt;br&gt;
443, success = "reachable." That &lt;strong&gt;misses the dominant modern block&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;National/corporate DPI often lets the TCP handshake complete and only injects RST after&lt;br&gt;
seeing the forbidden hostname in the &lt;strong&gt;SNI&lt;/strong&gt; field of the TLS Client Hello. From the&lt;br&gt;
device's view, TCP &lt;strong&gt;connected&lt;/strong&gt; — so a TCP-only probe reports "reachable" while HTTPS&lt;br&gt;
is actually dead.&lt;/p&gt;

&lt;p&gt;To catch it, go all the way to the TLS handshake. Two probes, compared:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;TCP&lt;/th&gt;
&lt;th&gt;TLS&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;fail&lt;/td&gt;
&lt;td&gt;fail&lt;/td&gt;
&lt;td&gt;IP block (or DNS pointing at a dead IP)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ok&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;fail&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;SNI / TLS block&lt;/strong&gt; — DPI killed the Client Hello by hostname&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;ok&lt;/td&gt;
&lt;td&gt;actually reachable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The TLS probe uses &lt;code&gt;NWConnection&lt;/code&gt; with &lt;code&gt;NWProtocolTLS&lt;/code&gt;, which sets SNI to the&lt;br&gt;
connection's hostname automatically — exactly what trips SNI-aware DPI. Flagged sites&lt;br&gt;
get an "SNI" badge so they're distinct from plain unreachable ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  The other six, briefly
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TLS Inspector&lt;/strong&gt; — probes TLS 1.0–1.3 in parallel (one &lt;code&gt;NWConnection&lt;/code&gt; per version,
min/max pinned), shows the negotiated cipher, flags deprecated 1.0/1.1 and weak ciphers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypted DNS&lt;/strong&gt; — resolves a domain over DoH and DoT at Cloudflare/Google/Quad9/
NextDNS, compares answers + latency. Hand-rolled minimal DNS wire codec.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;STUN / NAT&lt;/strong&gt; — real RFC 5389 Binding Request to several servers, parses
XOR-Mapped-Address, classifies NAT (Open / Cone / Symmetric) by whether the external
port differs per server. The signal for whether VoIP/WebRTC/P2P will work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bonjour browser&lt;/strong&gt; — &lt;code&gt;NWBrowser&lt;/code&gt; over ~20 mDNS service types (AirPlay, Chromecast,
HomeKit, printers) with TXT-record parsing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP/3 (QUIC)&lt;/strong&gt; — real QUIC handshake via &lt;code&gt;NWConnection&lt;/code&gt; + &lt;code&gt;NWProtocolQUIC&lt;/code&gt; with
ALPN "h3" on UDP/443. URLSession won't attempt QUIC without a cached Alt-Svc hint, so
this is the only honest way to check whether your network passes QUIC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IPv6 Check&lt;/strong&gt; — local v6 enumeration with tunnel awareness (utun → iCloud Private
Relay / NAT66), NAT64/DNS64 detection (RFC 7050), v4-vs-v6 latency.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Four takeaways
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The "obvious" probe usually lies.&lt;/strong&gt; UDP-to-random-port (Path MTU), TCP-only (Site
Reach) — both give false results on real networks. Only a real protocol exchange —
ICMP Echo with DF, a full TLS handshake — tells the truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw ICMP/UDP on iOS is more accessible than you'd think&lt;/strong&gt; — a &lt;code&gt;SOCK_DGRAM&lt;/code&gt; ICMP
socket needs no entitlements and covers ping, traceroute, MTR, Path MTU. Raw SYN,
ARP, and packet capture are still off-limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test on two networks.&lt;/strong&gt; Half my bugs only showed up comparing a clean European
Wi-Fi against a cellular CGNAT/symmetric-NAT link — the false "blocks" lived there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Network.framework&lt;/code&gt; is the right tool for TLS/QUIC.&lt;/strong&gt; Where ICMP means BSD sockets,
TLS Inspector and HTTP/3 are cleaner with &lt;code&gt;NWConnection&lt;/code&gt;: version pinning, ALPN, a
real QUIC handshake — all built in.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything still runs without private entitlements, and your diagnostic results never leave the device. The app does use Google AdMob and — added after this post — Google Analytics for Firebase for aggregate usage, both gated on consent where required. Full details: &lt;a href="https://netdiag.online/privacy/" rel="noopener noreferrer"&gt;https://netdiag.online/privacy/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;NetDiag+ on the App Store: &lt;a href="https://apps.apple.com/app/apple-store/id6761954529?pt=128748487&amp;amp;ct=devto&amp;amp;mt=8" rel="noopener noreferrer"&gt;https://apps.apple.com/app/apple-store/id6761954529?pt=128748487&amp;amp;ct=devto&amp;amp;mt=8&lt;/a&gt; (13 languages, 25 tools)&lt;/p&gt;

&lt;p&gt;Two write-ups from the same work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Finding your Path MTU on iPhone — with a reference table of common values and the manual &lt;code&gt;ping -f -l&lt;/code&gt; method: &lt;a href="https://netdiag.online/guides/path-mtu-iphone/" rel="noopener noreferrer"&gt;https://netdiag.online/guides/path-mtu-iphone/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Where the MTU field hides on Huawei / ZTE / TP-Link / Zyxel / Nokia routers: &lt;a href="https://netdiag.online/guides/router-mtu/" rel="noopener noreferrer"&gt;https://netdiag.online/guides/router-mtu/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to answer implementation questions in the comments.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>networking</category>
      <category>swift</category>
      <category>security</category>
    </item>
    <item>
      <title>How I implemented ping and traceroute on iOS without entitlements</title>
      <dc:creator>Dmytro Tishchenko</dc:creator>
      <pubDate>Thu, 21 May 2026 10:51:49 +0000</pubDate>
      <link>https://dev.to/dtisch/how-i-implemented-ping-and-traceroute-on-ios-without-entitlements-2fm</link>
      <guid>https://dev.to/dtisch/how-i-implemented-ping-and-traceroute-on-ios-without-entitlements-2fm</guid>
      <description>&lt;p&gt;I just shipped v1.3 of a network diagnostics app I've been building on evenings and weekends — NetDiag+. It does ping, traceroute, DNS lookups, whois, LAN scanning, port scanning, SSL cert checking, BGP/ASN lookups, plus a host monitor that does background pings and pushes a notification when something goes down.&lt;/p&gt;

&lt;p&gt;Stack: pure SwiftUI, iOS 16+, Swift Concurrency throughout, Darwin C-interop where it has to be. No third-party networking libraries — everything on raw BSD sockets through C-interop.&lt;/p&gt;

&lt;p&gt;Wanted to share the implementation notes I would have appreciated finding myself when I started. A few things on iOS work differently than you'd expect from a Unix background, and the gotchas aren't where you think they are.&lt;/p&gt;

&lt;p&gt;Spoiler on conclusions: the most painful part wasn't networking, it was integrating Google's UMP consent SDK for AdMob.&lt;/p&gt;

&lt;h2&gt;
  
  
  ICMP ping without entitlements
&lt;/h2&gt;

&lt;p&gt;The first surprise: on iOS, you can open an ICMP socket &lt;strong&gt;without any special entitlement and without root&lt;/strong&gt;, which is something you'd need &lt;code&gt;CAP_NET_RAW&lt;/code&gt; or setuid for on Linux. Apple exposed this to regular sandboxed processes through &lt;code&gt;SOCK_DGRAM&lt;/code&gt; (not &lt;code&gt;SOCK_RAW&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;ICMPSocket&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int32&lt;/span&gt;

    &lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;throws&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;AF_INET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;SOCK_DGRAM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;IPPROTO_ICMP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="kt"&gt;SocketError&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;creationFailed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errno&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;deinit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same model macOS &lt;code&gt;ping(8)&lt;/code&gt; has used since some old version when Apple dropped the setuid bit. The kernel writes the correct ICMP header for you (type/code/checksum for echo request), and rewrites the identifier to one it generates. That last bit is the first gotcha: on recv you don't get back the identifier you sent, so the standard logic of matching responses by identifier doesn't work.&lt;/p&gt;

&lt;p&gt;The fix: match on &lt;strong&gt;sequence number and payload&lt;/strong&gt;. I put my own marker in the payload and verify it on receive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Send&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ICMPHeader&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ICMPType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;echoRequest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rawValue&lt;/span&gt;
&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;   &lt;span class="c1"&gt;// kernel will overwrite&lt;/span&gt;
&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sequence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;currentSequence&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bigEndian&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;makePayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;sequence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;currentSequence&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;packet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;packet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Receive&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;fromIP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;received&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ICMPHeader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="n"&gt;received&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kt"&gt;ICMPType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;echoReply&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rawValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;received&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sequence&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;currentSequence&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bigEndian&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second gotcha: timeouts. &lt;code&gt;recvfrom&lt;/code&gt; without &lt;code&gt;SO_RCVTIMEO&lt;/code&gt; blocks indefinitely, which in Swift Concurrency means a stuck Task you then can't cancel cleanly. So I set a recv timeout on the socket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;tv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;timeval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nv"&gt;tv_sec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nv"&gt;tv_usec&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int32&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;truncatingRemainder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;dividingBy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;setsockopt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;SOL_SOCKET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;SO_RCVTIMEO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;tv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;socklen_t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;MemoryLayout&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;timeval&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;.&lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On &lt;code&gt;EAGAIN&lt;/code&gt;/&lt;code&gt;EWOULDBLOCK&lt;/code&gt; I throw my own &lt;code&gt;SocketError.timeout&lt;/code&gt;, which higher up in the stack becomes a normal "timeout" hop in the result list.&lt;/p&gt;

&lt;p&gt;Apple's SimplePing sample is the OG reference here — it's Objective-C, dated, but you can crib a lot from it. I ended up writing my own thin wrapper because SimplePing doesn't play nicely with modern Swift Concurrency and gets awkward for multi-host pings. No regrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Traceroute that actually works
&lt;/h2&gt;

&lt;p&gt;Standard Unix traceroute has three flavors: ICMP echo (the Windows &lt;code&gt;tracert&lt;/code&gt; style), UDP to closed ports (classic BSD), or TCP SYN (paris-traceroute, traceroute-tcp).&lt;/p&gt;

&lt;p&gt;I tried the ICMP variant first. The idea is straightforward — send ICMP echo with a low TTL, listen for ICMP Time Exceeded from an intermediate hop, increment TTL. On paper, fine. In practice I hit two problems on iOS:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;setsockopt(IP_TTL)&lt;/code&gt; on a SOCK_DGRAM ICMP socket behaves inconsistently.&lt;/strong&gt; Some networks worked, others wouldn't return Time Exceeded reliably.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matching incoming responses.&lt;/strong&gt; On receive you get back an ICMP Time Exceeded, and the inner payload contains the original packet you sent. Matching the response to a specific TTL step means parsing inner payloads, which felt fragile.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So I switched to the classic BSD approach: UDP packets to closed ports with incrementing TTL, with a passive ICMP socket listening for Time Exceeded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;icmpSock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;ICMPSocket&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;// receive-only&lt;/span&gt;
&lt;span class="n"&gt;icmpSock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="n"&gt;maxHops&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;udpSock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;UDPSocket&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;udpSock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTTL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;dest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;
    &lt;span class="n"&gt;dest&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sin_port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="kt"&gt;UInt16&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bigEndian&lt;/span&gt;  &lt;span class="c1"&gt;// 33434, 33435, ...&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;probe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;repeating&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mh"&gt;0x40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;sendTime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CFAbsoluteTimeGetCurrent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;udpSock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;fromIP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;icmpSock&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;rtt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;CFAbsoluteTimeGetCurrent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;sendTime&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 33434+TTL port choice is &lt;code&gt;traceroute(8)&lt;/code&gt;'s historical pick from the 80s — those ports are unlikely to be open on the destination, so you're guaranteed to get either ICMP Time Exceeded from an intermediate hop or ICMP Port Unreachable from the destination, and both cases are handled uniformly.&lt;/p&gt;

&lt;p&gt;Key insight: &lt;strong&gt;UDP_TTL works fine on iOS&lt;/strong&gt; via &lt;code&gt;setsockopt(IP_TTL)&lt;/code&gt;, unlike the ICMP variant. The ICMP socket is purely a passive receiver — we never send from it, only read incoming Time Exceeded packets.&lt;/p&gt;

&lt;p&gt;Final hop detection is two-way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;isFinal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fromIP&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;destIP&lt;/span&gt;
    &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kt"&gt;ICMPType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;destinationUnreachable&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rawValue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either the response came from the destination IP (meaning it sent us Port Unreachable), or the ICMP type is Destination Unreachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  LAN scanner: no ARP for you
&lt;/h2&gt;

&lt;p&gt;This is where iOS cuts harder. You &lt;strong&gt;don't get the system ARP table&lt;/strong&gt; in userspace. There's no &lt;code&gt;getarp()&lt;/code&gt;, no &lt;code&gt;/proc/net/arp&lt;/code&gt; like on Linux. So the obvious "read the ARP table and list neighbors" idea is dead on arrival.&lt;/p&gt;

&lt;p&gt;What you do get:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Concurrent TCP connect&lt;/strong&gt; on common ports (80, 443, 22, 8080, etc). If a host responds (SYN+ACK or RST), it's alive. Raw SYN is locked down — only a full TCP handshake works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mDNS / Bonjour discovery&lt;/strong&gt; via &lt;code&gt;NetServiceBrowser&lt;/code&gt; or &lt;code&gt;NWBrowser&lt;/code&gt;. Partial coverage — only sees devices that advertise services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NSLocalNetworkUsageDescription&lt;/code&gt;&lt;/strong&gt; in Info.plist is &lt;strong&gt;mandatory&lt;/strong&gt;. Without it, the first attempt to connect to a local IP triggers a "Local Network Access" alert, and nothing works until the user answers. Burned half an evening figuring this out the first time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I went with the TCP connect approach via &lt;code&gt;withThrowingTaskGroup&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;maxConcurrentProbes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;

&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;discoverHosts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;localIP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;subnetMask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;AsyncThrowingStream&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;LANDevice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Error&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;AsyncThrowingStream&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;continuation&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
        &lt;span class="kt"&gt;Task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;detached&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;range&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;calculateSubnetRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;localIP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;subnetMask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withThrowingTaskGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;of&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;LANDevice&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
                &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;activeCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;range&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;activeCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maxConcurrentProbes&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;device&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;continuation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                        &lt;span class="p"&gt;}&lt;/span&gt;
                        &lt;span class="n"&gt;activeCount&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;addTask&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;probeHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="n"&gt;activeCount&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;continuation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;yield&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;continuation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finish&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One &lt;code&gt;connect()&lt;/code&gt; to port 80 with a 500ms timeout per IP across a /24 — the host responds with SYN+ACK (alive), RST (alive but port closed, also counts as alive), or times out (dead or filtered). With &lt;code&gt;maxConcurrentProbes = 20&lt;/code&gt; the whole /24 finishes in 2-3 seconds.&lt;/p&gt;

&lt;p&gt;iOS 17 gotcha: the "Local Network Access" alert shows only once. If the user denies it, subsequent &lt;code&gt;connect()&lt;/code&gt; calls just silently time out, with no clear error. Had to add a UI hint nudging the user to Settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Host monitor: BGTaskScheduler as it is
&lt;/h2&gt;

&lt;p&gt;The most engineering-painful part of the app. The user-facing requirement: "I add hosts to monitor, and if one goes down I get a push, like UptimeRobot." On iOS you can't do this properly because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No persistent background thread.&lt;/strong&gt; iOS kills your app ~30 seconds after backgrounding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent push as a wake mechanism&lt;/strong&gt; exists but needs a server, and I wanted everything local.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;BGTaskScheduler&lt;/code&gt;&lt;/strong&gt; gives you ~30 sec of CPU &lt;strong&gt;maybe&lt;/strong&gt; once every 15+ minutes. iOS decides when based on user behavior patterns.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So monitoring precision on iOS is fundamentally worse than on a server. You have to accept that and design around it.&lt;/p&gt;

&lt;p&gt;What I do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;BGTaskScheduler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;forTaskWithIdentifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"com.netdiag.hostmonitor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;using&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="kt"&gt;Task&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="kt"&gt;HostMonitorService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;runMonitoringPass&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTaskCompleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;submitNext&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;BGAppRefreshTaskRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"com.netdiag.hostmonitor"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;earliestBeginDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;timeIntervalSinceNow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="kt"&gt;BGTaskScheduler&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside &lt;code&gt;runMonitoringPass()&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fetch all monitored hosts&lt;/li&gt;
&lt;li&gt;Ping them &lt;strong&gt;in parallel&lt;/strong&gt; (&lt;code&gt;withTaskGroup&lt;/code&gt;) — fit within the 30-second budget&lt;/li&gt;
&lt;li&gt;For each: if state changed (up→down or down→up), fire a local notification&lt;/li&gt;
&lt;li&gt;If state unchanged, stay quiet&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No "host still up every 5 minutes" pings. Only state transitions. This both helps with iOS background limits and respects the user.&lt;/p&gt;

&lt;p&gt;Debugging gotcha: &lt;strong&gt;BGTaskScheduler doesn't fire in the simulator&lt;/strong&gt; unless you manually trigger it via debugger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight objective_c"&gt;&lt;code&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;-&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="n"&gt;objc&lt;/span&gt; &lt;span class="o"&gt;--&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="p"&gt;)[[&lt;/span&gt;&lt;span class="n"&gt;BGTaskScheduler&lt;/span&gt; &lt;span class="nf"&gt;sharedScheduler&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="nf"&gt;_simulateLaunchForTaskWithIdentifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;@"com.netdiag.hostmonitor"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Found this in some old DTS thread. Without that command, debugging background logic is impossible. On real devices the system runs your tasks unpredictably.&lt;/p&gt;

&lt;h2&gt;
  
  
  AdMob and UMP — the part that actually hurt
&lt;/h2&gt;

&lt;p&gt;Networking was a pleasure to write. AdMob integration was a separate ordeal.&lt;/p&gt;

&lt;p&gt;AdMob via the &lt;code&gt;GoogleMobileAds&lt;/code&gt; Swift Package itself is fine — &lt;code&gt;MobileAds.shared.start()&lt;/code&gt;, banners through &lt;code&gt;BannerView&lt;/code&gt; in a &lt;code&gt;UIViewRepresentable&lt;/code&gt;, standard stuff.&lt;/p&gt;

&lt;p&gt;The pain begins with &lt;strong&gt;GDPR / CCPA consent&lt;/strong&gt;, which Google requires through their UMP SDK (&lt;code&gt;GoogleUserMessagingPlatform&lt;/code&gt;). Two problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;SwiftUI documentation&lt;/strong&gt; is essentially nonexistent. Every Google example is Objective-C or old UIKit. You figure out the SwiftUI integration by trial and error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AdMob won't activate your privacy message until you ship a "revocation link" in the live app&lt;/strong&gt; — that is, a button in Settings that re-opens the consent form. The business logic is: build the revocation link in code → &lt;strong&gt;ship that build&lt;/strong&gt; → then go back to AdMob and confirm "yes, my app has the revocation link, please activate the message." Nowhere is this clearly stated. I sat on a configured but inactive consent message for a week before figuring it out.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The integration looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;@MainActor&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="kt"&gt;ConsentManager&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;ObservableObject&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;shared&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;ConsentManager&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="kd"&gt;@Published&lt;/span&gt; &lt;span class="kd"&gt;private(set)&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;adsStarted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="kd"&gt;@Published&lt;/span&gt; &lt;span class="kd"&gt;private(set)&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;privacyOptionsRequired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;requestConsentAndStartAds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;parameters&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;RequestParameters&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="kt"&gt;ConsentInformation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestConsentInfoUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="kt"&gt;ConsentForm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loadAndPresentIfRequired&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;topViewController&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;formError&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
                &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updatePrivacyOptionsAvailability&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startAdsIfNeeded&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;presentPrivacyOptionsForm&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;Self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;topViewController&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="kt"&gt;ConsentForm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;presentPrivacyOptionsForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updatePrivacyOptionsAvailability&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;startAdsIfNeeded&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="kt"&gt;ConsentInformation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;canRequestAds&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="kt"&gt;MobileAds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;weak&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
            &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;adsStarted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key part: &lt;code&gt;MobileAds.shared.start()&lt;/code&gt; is only called &lt;strong&gt;after&lt;/strong&gt; the UMP flow returns &lt;code&gt;canRequestAds == true&lt;/code&gt;. If a user in the EEA refuses, &lt;code&gt;canRequestAds&lt;/code&gt; stays true anyway, ads just become non-personalized. The same flow covers both GDPR (EEA/UK) and US state privacy (CCPA in California and other regulated states), because UMP under the hood inspects geo and serves the matching message.&lt;/p&gt;

&lt;p&gt;Another gotcha: &lt;code&gt;ATTrackingManager.requestTrackingAuthorization&lt;/code&gt; (the ATT prompt) &lt;strong&gt;must come before&lt;/strong&gt; the UMP flow, because UMP checks the ATT state when building the request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kt"&gt;ATTManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;requestTrackingIfNeeded&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;ConsentManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shared&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestConsentAndStartAds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wrong order and UMP might think tracking is granted, then ATT denies, creating a mismatch AdMob can later flag.&lt;/p&gt;

&lt;p&gt;For debug builds I force the geography to test the flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="cp"&gt;#if DEBUG&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;debugSettings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;DebugSettings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;debugSettings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;geography&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;EEA&lt;/span&gt;  &lt;span class="c1"&gt;// or .regulatedUSState&lt;/span&gt;
&lt;span class="n"&gt;parameters&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;debugSettings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;debugSettings&lt;/span&gt;
&lt;span class="cp"&gt;#endif&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, from a non-EEA IP the consent form never shows and you can't validate the flow at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Localization
&lt;/h2&gt;

&lt;p&gt;12 languages (en, ru, uk, de, es, pt-BR, fr, it, pl, ja, ko, tr) using the new Xcode &lt;strong&gt;String Catalogs&lt;/strong&gt; (&lt;code&gt;.xcstrings&lt;/code&gt;). After years of &lt;code&gt;Localizable.strings&lt;/code&gt;, the Catalog format is just nice — JSON, diffs reasonably in git, Xcode has a usable GUI for translation, automatic pluralization. If you're still on &lt;code&gt;.strings&lt;/code&gt;, migrate, you're losing nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don't skimp on tests&lt;/strong&gt; for ICMP header parsing. I have decent tests on DNS resolver and whois parser, but I debugged the ICMP path against live networks. It worked for 30 hosts; then two days after release I found a bug with big-endian sequence numbers. Embarrassing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Build UMP from day one&lt;/strong&gt;, before submitting to TestFlight. Retrofitting UMP after the fact rewrites your AdMob initialization order, ATT timing, etc. Just bake it in.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don't trust the simulator&lt;/strong&gt; for anything touching background tasks, mDNS, or push notifications. I had several "green in simulator, dead on device" moments.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Numbers and context
&lt;/h2&gt;

&lt;p&gt;App went live a few weeks ago. ~30 total installs at the time of writing — basically zero marketing happened. This post is part of changing that.&lt;/p&gt;

&lt;p&gt;AdMob after the first week post-approval: $1.19 eCPM, 85% match rate — "normal for a utility at launch" according to indie folks I've asked.&lt;/p&gt;

&lt;p&gt;If you've done iOS network programming and hit different walls, I'd love to compare notes. Apple's networking stack isn't where most iOS devs spend their time and good references are scattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;App Store: &lt;a href="https://apps.apple.com/app/apple-store/id6761954529?pt=128748487&amp;amp;ct=devto&amp;amp;mt=8" rel="noopener noreferrer"&gt;https://apps.apple.com/app/apple-store/id6761954529?pt=128748487&amp;amp;ct=devto&amp;amp;mt=8&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;How to find your Path MTU on iPhone — reference table of common values and the manual &lt;code&gt;ping -f -l&lt;/code&gt; method: &lt;a href="https://netdiag.online/guides/path-mtu-iphone/" rel="noopener noreferrer"&gt;https://netdiag.online/guides/path-mtu-iphone/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Where the MTU field hides on Huawei / ZTE / TP-Link / Zyxel / Nokia routers: &lt;a href="https://netdiag.online/guides/router-mtu/" rel="noopener noreferrer"&gt;https://netdiag.online/guides/router-mtu/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Apple's SimplePing sample (historical reference): &lt;a href="https://developer.apple.com/library/archive/samplecode/SimplePing/Introduction/Intro.html" rel="noopener noreferrer"&gt;https://developer.apple.com/library/archive/samplecode/SimplePing/Introduction/Intro.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;BGTaskScheduler docs: &lt;a href="https://developer.apple.com/documentation/backgroundtasks" rel="noopener noreferrer"&gt;https://developer.apple.com/documentation/backgroundtasks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google UMP SDK docs: &lt;a href="https://developers.google.com/admob/ios/privacy" rel="noopener noreferrer"&gt;https://developers.google.com/admob/ios/privacy&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;App is free with a banner, one-time IAP to remove ads. No accounts. Analytics is Google Analytics for Firebase (aggregate usage only, gated on the same consent flow as ads) — added after this post was written; diagnostic results never leave the device. Full details: &lt;a href="https://netdiag.online/privacy/" rel="noopener noreferrer"&gt;https://netdiag.online/privacy/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ios</category>
      <category>swift</category>
      <category>networking</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
