# The port that moved: how an auto-update broke a user's AI agent for three hours

> Source: <https://dev.to/perfectoweb/the-port-that-moved-how-an-auto-update-broke-a-users-ai-agent-for-three-hours-1kfc>
> Published: 2026-08-30 09:55:04+00:00

A friend sent me two screenshots. His terminal was full of this:

```
Hook error: POST http://127.0.0.1:61716/hook?src=belay
connect ECONNREFUSED 127.0.0.1:61716
```

Every tool call Claude Code made printed one of those. It had been doing it for about three hours.

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 `127.0.0.1:<port>`

, and Belay uses those exact signals to decide whether the machine is allowed to sleep.

So `ECONNREFUSED`

meant something specific: the agent was talking, and nobody was listening.

I asked for his `belay.log`

and pulled the system log around the incident. The timeline was short and damning:

One port apart. Off by one, in production, delivered by my own auto-updater.

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.

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 `settings.json`

while a session was running, and the running session followed the change within seconds. No cache. Hypothesis dead.

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

**Why does the port move at all?**

Belay's receiver was an `NWListener`

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 `settings.json`

once, as a literal number.

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.

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 *guaranteed* to be mid-session (that's what Belay is for – long unattended runs), the port is *guaranteed* to move, and the human is *guaranteed* to be away. I reproduced the move locally in one try: restart Belay, watch 49680 become 49683.

The fix was obvious. Remember the port and bind it again.

Network.framework has an API that looks purpose-built for this:

``` js
let parameters = NWParameters(tls: nil, tcp: options)
parameters.requiredLocalEndpoint = .hostPort(
    host: .ipv4(.loopback),
    port: NWEndpoint.Port(rawValue: wanted)!)
let listener = try NWListener(using: parameters)
```

`requiredLocalEndpoint`

. 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.

Three restarts later the log said `bridge up port=49731`

, then `49734`

, then `49738`

.

** NWListener ignores requiredLocalEndpoint.** Not errors – ignores. A

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.

The API that actually works is the other initializer:

``` js
let listener = try NWListener(using: parameters, on: wanted)
```

And the honest test plants a port nobody is near and insists on it:

``` js
let asked = free > 40_000 ? free - 7_000 : free + 7_000
try store.save(BridgeEndpoint(port: asked, token: token))
let bound = try await receiver.start()
#expect(bound.port == asked)
```

If the code stops asking, this fails. The old test never could.

There's a wrinkle that makes updates special: when the new instance launches, the *old* 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.

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.

One more realization arrived late: even a *remembered* port is fragile if it came from the ephemeral range. That range is where macOS assigns ports to **outgoing connections** – 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.

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.

`NWListener`

silently ignores `requiredLocalEndpoint`

.`NWListener(using:on:)`

to bind a specific port. A connection honors the parameter; a listener does not.The fixes shipped in Belay 1.6.3. The friend's terminal has been quiet since – the good kind of quiet.

*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: github.com/PerfectoWeb/Belay*
