<?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: David Build</title>
    <description>The latest articles on DEV Community by David Build (@perfectoweb).</description>
    <link>https://dev.to/perfectoweb</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%2F4101214%2F770b4f76-2c61-4b47-8699-bbad50550a61.png</url>
      <title>DEV Community: David Build</title>
      <link>https://dev.to/perfectoweb</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/perfectoweb"/>
    <language>en</language>
    <item>
      <title>The port that moved: how an auto-update broke a user's AI agent for three hours</title>
      <dc:creator>David Build</dc:creator>
      <pubDate>Sun, 30 Aug 2026 09:55:04 +0000</pubDate>
      <link>https://dev.to/perfectoweb/the-port-that-moved-how-an-auto-update-broke-a-users-ai-agent-for-three-hours-1kfc</link>
      <guid>https://dev.to/perfectoweb/the-port-that-moved-how-an-auto-update-broke-a-users-ai-agent-for-three-hours-1kfc</guid>
      <description>&lt;p&gt;A friend sent me two screenshots. His terminal was full of this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;Hook error: POST http://127.0.0.1:61716/hook?src=belay
connect ECONNREFUSED 127.0.0.1:61716
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every tool call Claude Code made printed one of those. It had been doing it for about three hours.&lt;/p&gt;

&lt;p&gt;The app on the other end of that URL is mine. It's called Belay – a macOS menu bar utility that keeps the Mac awake while local AI coding agents are working. One of its detection tiers is a tiny loopback HTTP receiver: the agent's hooks POST lifecycle events ("a tool call started", "the turn finished") to &lt;code&gt;127.0.0.1:&amp;lt;port&amp;gt;&lt;/code&gt;, and Belay uses those exact signals to decide whether the machine is allowed to sleep.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;ECONNREFUSED&lt;/code&gt; meant something specific: the agent was talking, and nobody was listening.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the logs said
&lt;/h2&gt;

&lt;p&gt;I asked for his &lt;code&gt;belay.log&lt;/code&gt; and pulled the system log around the incident. The timeline was short and damning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;11:19 UTC&lt;/strong&gt; – Sparkle auto-updated Belay to 1.6.2. The old instance quit, the new one launched.&lt;/li&gt;
&lt;li&gt;The new instance bound its receiver and reported a port: &lt;strong&gt;61717&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;His running Claude Code session kept posting to &lt;strong&gt;61716&lt;/strong&gt; – the port the &lt;em&gt;previous&lt;/em&gt; instance had held.&lt;/li&gt;
&lt;li&gt;Three hours of refused connections.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One port apart. Off by one, in production, delivered by my own auto-updater.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two wrong answers first
&lt;/h2&gt;

&lt;p&gt;My first hypothesis was the obvious one: Belay crashed during the update and nothing was listening at all. The log killed that in a minute – the bridge was up and healthy on 61717. Something was alive; it was just living at a different address.&lt;/p&gt;

&lt;p&gt;Second hypothesis: the CLI must cache its hook configuration per session – it read the port once at session start and never looked again. That sounded so plausible I almost shipped a workaround for it. Then I tested it on my own machine: I edited the hook URL in &lt;code&gt;settings.json&lt;/code&gt; while a session was running, and the running session followed the change within seconds. No cache. Hypothesis dead.&lt;/p&gt;

&lt;p&gt;I'd now been publicly wrong twice in one bug report, which is usually a sign the question is wrong. I was asking &lt;em&gt;"why didn't the agent follow the new port?"&lt;/em&gt; The better question was:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does the port move at all?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual defect
&lt;/h2&gt;

&lt;p&gt;Belay's receiver was an &lt;code&gt;NWListener&lt;/code&gt; created without a port, which means macOS hands it an ephemeral one – whatever is free in the 49152+ range. Every launch, a new port. The installer writes that port into the agent's &lt;code&gt;settings.json&lt;/code&gt; once, as a literal number.&lt;/p&gt;

&lt;p&gt;An address that changes on every launch, written into someone else's config file as if it were permanent. In hindsight it's the kind of sentence you can't type without wincing.&lt;/p&gt;

&lt;p&gt;For ordinary restarts this mostly went unnoticed, because Belay re-pointed the config files at launch and the window of mismatch was seconds. But an auto-update is the worst case wrapped in a bow: the agent is &lt;em&gt;guaranteed&lt;/em&gt; to be mid-session (that's what Belay is for – long unattended runs), the port is &lt;em&gt;guaranteed&lt;/em&gt; to move, and the human is &lt;em&gt;guaranteed&lt;/em&gt; to be away. I reproduced the move locally in one try: restart Belay, watch 49680 become 49683.&lt;/p&gt;

&lt;p&gt;The fix was obvious. Remember the port and bind it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that didn't fix anything
&lt;/h2&gt;

&lt;p&gt;Network.framework has an API that looks purpose-built for 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="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;NWParameters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;tls&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="nv"&gt;tcp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&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;requiredLocalEndpoint&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hostPort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ipv4&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loopback&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="kt"&gt;NWEndpoint&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;Port&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;rawValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wanted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&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;listener&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;NWListener&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="n"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;requiredLocalEndpoint&lt;/code&gt;. Required. Local. Endpoint. I set it, wrote a test – start a receiver, stop it, start another, assert the port came back – and the test went green. Shipped it to my own machine for a soak.&lt;/p&gt;

&lt;p&gt;Three restarts later the log said &lt;code&gt;bridge up port=49731&lt;/code&gt;, then &lt;code&gt;49734&lt;/code&gt;, then &lt;code&gt;49738&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;NWListener&lt;/code&gt; ignores &lt;code&gt;requiredLocalEndpoint&lt;/code&gt;.&lt;/strong&gt; Not errors – ignores. A &lt;em&gt;connection&lt;/em&gt; honors it; a listener silently binds an ephemeral port anyway. Nothing in the documentation says so, and the API accepts the value without complaint.&lt;/p&gt;

&lt;p&gt;And my test? It passed by coincidence. When you release an ephemeral port and immediately ask the OS for "any port", you usually get the same one back – it's at the front of the free list. My test was green whether or not the code asked for anything. A restart-and-compare test for port stability is a test of the kernel's allocator mood.&lt;/p&gt;

&lt;p&gt;The API that actually works is the other initializer:&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;listener&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;NWListener&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="n"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wanted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the honest test plants a port nobody is near and insists on it:&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;asked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;free&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;40_000&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;free&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;7_000&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;free&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;7_000&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;BridgeEndpoint&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="n"&gt;asked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token&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;bound&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;receiver&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="cp"&gt;#expect(bound.port == asked)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the code stops asking, this fails. The old test never could.&lt;/p&gt;

&lt;h2&gt;
  
  
  The update race, and the retry ladder
&lt;/h2&gt;

&lt;p&gt;There's a wrinkle that makes updates special: when the new instance launches, the &lt;em&gt;old&lt;/em&gt; instance is often still holding the socket – it hasn't finished quitting yet. Binding the remembered port fails for a moment through no fault of anyone.&lt;/p&gt;

&lt;p&gt;So the receiver asks for the recorded port four times, 250 ms apart – an outgoing process releases its socket in well under a second – and only then looks elsewhere. A bridge on an awkward port beats no bridge, but the recorded port beats both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't record an ephemeral port in the first place
&lt;/h2&gt;

&lt;p&gt;One more realization arrived late: even a &lt;em&gt;remembered&lt;/em&gt; port is fragile if it came from the ephemeral range. That range is where macOS assigns ports to &lt;strong&gt;outgoing connections&lt;/strong&gt; – every browser tab, every build tool. While Belay is closed, any process on the machine can be handed "Belay's" port for a few minutes, and the relaunch walks into an occupied address.&lt;/p&gt;

&lt;p&gt;So a first run now picks from a quiet band – 41000–42999 – below the ephemeral range, above the well-known services, clear of the ports development tools squat on. The recorded address is one the rest of the system has no reason to touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd underline
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A port you write into someone else's config file is a promise.&lt;/strong&gt; Treat it like one: keep it stable, re-point on drift, and log every move.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;NWListener&lt;/code&gt; silently ignores &lt;code&gt;requiredLocalEndpoint&lt;/code&gt;.&lt;/strong&gt; Use &lt;code&gt;NWListener(using:on:)&lt;/code&gt; to bind a specific port. A connection honors the parameter; a listener does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A restart-and-compare test can pass for the wrong reason.&lt;/strong&gt; If the OS's default behavior can produce your expected value, your test is measuring the OS. Make the test want something the default would never give it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-updates are a scheduled production incident.&lt;/strong&gt; Whatever state your app hands to other processes, an update will invalidate it at the worst possible moment, with the user away.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fixes shipped in Belay 1.6.3. The friend's terminal has been quiet since – the good kind of quiet.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Belay is a free, source-available macOS menu bar app that keeps your Mac awake while Claude Code, Codex, Cline and Copilot CLI are working: &lt;a href="https://github.com/PerfectoWeb/Belay" rel="noopener noreferrer"&gt;github.com/PerfectoWeb/Belay&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>swift</category>
      <category>ai</category>
      <category>debugging</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
