Engineering Notes

The bug that didn't stay fixed

Edited by Jay AhnMay 27, 20268 min read1,563 words
The bug that didn't stay fixed

I shut my computer off on a Sunday evening. I turned it back on Monday morning. The Claude Bridge — the small Node server that the rest of my automation calls when it wants Claude — refused to start.

The error was:

[ERROR] Server error: listen EACCES: permission denied 127.0.0.1:5690

Port 5690 is the port the Bridge has bound to since the day I wrote it, on this same machine, with these same permissions. Nothing about my code had changed between Sunday evening and Monday morning. Something about the machine had.

I want to write about how I diagnosed this, the fix I applied, the fact that I had to apply the same fix again four days later, and the actual fix that I should have applied the first time. This is a Windows-specific post in the way that R2 was an MCP-specific post: the particular bug is narrow, the meta-point is not.

The diagnosis takes one command

Windows reserves ranges of TCP ports for dynamic allocation by certain system services. Most of the time you do not notice this. When you do notice this, it is usually because a port you have been using is suddenly inside one of those ranges and you can no longer bind to it.

The command that shows you the current ranges:

netsh interface ipv4 show excludedportrange protocol=tcp

The output on my machine that Monday morning included this block, among others:

Start Port    End Port
----------    --------
      ...
      5680        5779
      5834        5933
      5934        6033
      6034        6133
      6134        6233
      6234        6333
      ...

There were more — roughly fifteen contiguous and near-contiguous blocks of a hundred ports each, plus a handful of singletons. The relevant fact: 5690 was inside 5680–5779, and "inside an excluded range" is what EACCES permission denied translates to here. The port wasn't being used by anything — it was being reserved, by Hyper-V's dynamic port allocator, for possible future use.

So the bug wasn't a port conflict. It was a port reservation.

This is a well-documented Windows footgun, and it is more common after major Windows updates and after every reboot, because Hyper-V can choose slightly different ranges each time. Mine had been quietly compatible with port 5690 for weeks. One reboot was all it took to land 5690 inside a reservation.

The fix that worked, sort of

The standard quick fix is to restart the Windows NAT service, which releases its current dynamic reservations:

net stop winnat
net start winnat

Both require an administrator shell. I opened one, ran them, watched the "Windows NAT Driver service was started successfully" line print, and the excluded ranges shifted. 5680–5779 was no longer in the output, and 5690 was free again. The Bridge bound to it on the next attempt and the whole pipeline came back up. Total time from "Bridge won't start" to "Bridge is healthy": about twelve minutes, most of it spent on the diagnosis command rather than the fix.

I closed the terminal. I wrote down what had happened. I moved on.

That was a Monday. Four days later, on a Friday morning, I turned my computer on after another shutdown — and the Bridge wouldn't start. Same error:

[ERROR] Server error: listen EACCES: permission denied 127.0.0.1:5690

I ran netsh interface ipv4 show excludedportrange protocol=tcp. The output this time included 5689–5788. Slightly different range. Same outcome — 5690 was reserved. Same net stop winnat / net start winnat fix released it. Bridge bound. Pipeline came back.

The total time was now closer to four minutes, because I recognized the symptom immediately. And that recognition is what made me realize what I had actually done the first time.

The thing the first fix did not do

net stop winnat / net start winnat releases the current set of dynamic reservations and lets Windows recompute them. After it runs, Hyper-V's allocator picks a new random starting point and reserves a different block of ports. The block usually does not include 5690 right after the reset — which is why the fix appears to work. But the next time Hyper-V's allocator runs, which is usually the next reboot, it might pick a range that re-includes 5690. Mine did, exactly four days later, with the range shifted up by nine ports.

The first time around, what looked like "I fixed it" was actually "I rolled the dice and got a different number." The second occurrence wasn't a new bug. It was the same bug, with the same root cause, and the same temporary fix would keep working — temporarily — for as long as I was willing to apply it. The Hyper-V allocator does not remember which ranges it picked last time and does not care which ports my software happens to bind to; it picks at boot, from a pool that includes 5690, and the probability that it lands on a block containing 5690 is small but nonzero on every single boot. Multiply a small nonzero probability by the number of times I reboot in a year and the bug becomes a recurring tax, not a freak event.

This is the part of debugging that doesn't get written about enough. The signal that you've fixed the cause and not just the symptom isn't that the problem goes away. The problem will always go away when you apply a fix; that's what makes it feel like a fix. The signal is whether the problem stays gone after the conditions that produced it recur. For a reboot-triggered bug, that means the next reboot is the only honest test, and the first reboot is a four-day silence I should have been less reassured by.

The fix that does fix it

What I wanted, but didn't realize I wanted until the second occurrence, was to claim port 5690 as my own static reservation, so that Hyper-V's dynamic allocator would see it as already taken and skip it forever. That command exists, and it is one line:

netsh int ipv4 add excludedportrange protocol=tcp startport=5690 numberofports=1 store=persistent

The name excludedportrange is doing some work here that I had to read twice. "Exclude this from the range" sounds like the opposite of what I want — like it would block me from using the port. What it actually means in this context is "exclude this port from the dynamic-allocation pool that Hyper-V draws from." My static claim wins. Hyper-V's dynamic allocator now skips 5690 the way it already skips ports that user processes are explicitly listening on.

The store=persistent flag is the difference between a fix that survives a reboot and a fix that doesn't. Without it, the reservation lives only until the next NAT service restart. With it, the reservation is written to a place Windows reads at boot, and the exclusion is reinstated automatically. Run it once, with admin, never deal with this again.

I ran it. The output was a single word: 확인됨 ("Confirmed", in Korean Windows). The next time I rebooted — which happened a couple of days later, for unrelated reasons — netsh interface ipv4 show excludedportrange protocol=tcp showed 5690 5690 *, with the asterisk indicating the persistent flag. Bridge bound on first try, before I'd even thought to check whether it would.

That was three reboots ago. Bridge has bound on first try every time. The bug is gone in the way that bugs become actually gone — not because the conditions that triggered it stopped happening, but because the conditions no longer matter.

What the two occurrences taught me

The first occurrence taught me a Windows-internals fact I hadn't known. That was the visible lesson, and it was the smaller one.

The second occurrence taught me about the gap between knowing how to make a problem go away and knowing how to make a problem stay gone. I had applied the same temporary fix twice and would have kept applying it indefinitely, four days at a time, treating each occurrence as a fresh inconvenience instead of recognizing it as the same inconvenience asking the same question. The temporary fix worked well enough that it was easy to mistake "the problem is currently absent" for "the problem is solved." Those aren't the same condition, and the test that distinguishes them is the next time the trigger fires. For reboot bugs, that's the next reboot, which is hours or days away.

A noisy bug that comes back is, in this sense, a friendly bug. Mine forced me back to the same diagnosis screen with the same netsh output and the same realization. After the second occurrence, I could no longer pretend the diagnosis was the fix. The third occurrence — the one that didn't happen, because by then I had run the one-liner with store=persistent — would have been the bug's last chance to teach me, and I would prefer not to need that lesson taught a fourth time.

Next week's post is back on the data side, with Ravi: real yields, what the 10-year actually pays you once you subtract expected inflation, and why the inflation-adjusted version of the 10-year is the number that more directly shapes most economic decisions. Different costume, same general lesson the rebuild keeps repeating — most of what looks like a one-off fact is actually a system you didn't notice was a system.

— Jay

ℹ How this was written: AI-assisted and edited by Jay Ahn. See our AI Disclosure and Editorial Policy for details. This article is for informational and educational purposes only and does not constitute professional advice. AI tools, automation platforms, and technology evolve rapidly — verify information independently before making decisions based on this content.
windowshyper-vdebuggingportsnetshclaude bridgefounder log
SharePost on X