{"slug": "the-port-that-moved-how-an-auto-update-broke-a-user-s-ai-agent-for-three-hours", "title": "The port that moved: how an auto-update broke a user's AI agent for three hours", "summary": "A developer's auto-update for Belay, a macOS utility that keeps Macs awake during AI coding sessions, broke a user's Claude Code agent for three hours by changing the loopback port the agent's hooks post to. The developer traced the issue to Belay's listener using an ephemeral port that changed on every launch, and found that NWListener ignores requiredLocalEndpoint, requiring the on: initializer instead. The fix was to bind to a fixed port and the developer shipped a corrected version.", "body_md": "A friend sent me two screenshots. His terminal was full of this:\n\n```\nHook error: POST http://127.0.0.1:61716/hook?src=belay\nconnect ECONNREFUSED 127.0.0.1:61716\n```\n\nEvery tool call Claude Code made printed one of those. It had been doing it for about three hours.\n\nThe 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>`\n\n, and Belay uses those exact signals to decide whether the machine is allowed to sleep.\n\nSo `ECONNREFUSED`\n\nmeant something specific: the agent was talking, and nobody was listening.\n\nI asked for his `belay.log`\n\nand pulled the system log around the incident. The timeline was short and damning:\n\nOne port apart. Off by one, in production, delivered by my own auto-updater.\n\nMy 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.\n\nSecond 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`\n\nwhile a session was running, and the running session followed the change within seconds. No cache. Hypothesis dead.\n\nI'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:\n\n**Why does the port move at all?**\n\nBelay's receiver was an `NWListener`\n\ncreated 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`\n\nonce, as a literal number.\n\nAn 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.\n\nFor 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.\n\nThe fix was obvious. Remember the port and bind it again.\n\nNetwork.framework has an API that looks purpose-built for this:\n\n``` js\nlet parameters = NWParameters(tls: nil, tcp: options)\nparameters.requiredLocalEndpoint = .hostPort(\n    host: .ipv4(.loopback),\n    port: NWEndpoint.Port(rawValue: wanted)!)\nlet listener = try NWListener(using: parameters)\n```\n\n`requiredLocalEndpoint`\n\n. 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.\n\nThree restarts later the log said `bridge up port=49731`\n\n, then `49734`\n\n, then `49738`\n\n.\n\n** NWListener ignores requiredLocalEndpoint.** Not errors – ignores. A\n\nAnd 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.\n\nThe API that actually works is the other initializer:\n\n``` js\nlet listener = try NWListener(using: parameters, on: wanted)\n```\n\nAnd the honest test plants a port nobody is near and insists on it:\n\n``` js\nlet asked = free > 40_000 ? free - 7_000 : free + 7_000\ntry store.save(BridgeEndpoint(port: asked, token: token))\nlet bound = try await receiver.start()\n#expect(bound.port == asked)\n```\n\nIf the code stops asking, this fails. The old test never could.\n\nThere'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.\n\nSo 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.\n\nOne 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.\n\nSo 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.\n\n`NWListener`\n\nsilently ignores `requiredLocalEndpoint`\n\n.`NWListener(using:on:)`\n\nto 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.\n\n*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*", "url": "https://wpnews.pro/news/the-port-that-moved-how-an-auto-update-broke-a-user-s-ai-agent-for-three-hours", "canonical_source": "https://dev.to/perfectoweb/the-port-that-moved-how-an-auto-update-broke-a-users-ai-agent-for-three-hours-1kfc", "published_at": "2026-08-30 09:55:04+00:00", "updated_at": "2026-08-30 10:23:32.997193+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["Belay", "Claude Code", "NWListener", "macOS"], "alternates": {"html": "https://wpnews.pro/news/the-port-that-moved-how-an-auto-update-broke-a-user-s-ai-agent-for-three-hours", "markdown": "https://wpnews.pro/news/the-port-that-moved-how-an-auto-update-broke-a-user-s-ai-agent-for-three-hours.md", "text": "https://wpnews.pro/news/the-port-that-moved-how-an-auto-update-broke-a-user-s-ai-agent-for-three-hours.txt", "jsonld": "https://wpnews.pro/news/the-port-that-moved-how-an-auto-update-broke-a-user-s-ai-agent-for-three-hours.jsonld"}}