This bug shipped in macOS 26.0 and, somewhere before version 26.5.2, Apple silently fixed it. I re-verified both of those facts this week. There is no release note, nor any acknowledgment anywhere I can find, which is most of why I am writing this down.
If you are still running an early build of macOS 26 and your machine hangs for minutes at a time on connections to localhost, this is for you.
I was creating a training dataset on the night of July 17th, running an inference server locally. The generation was done with three worker threads on a Mac Studio machine using a 120B model streaming tokens into JSONL shards. Every twenty minutes or so everything came to a stop without any errors, there were no log lines on either end for three and a half to five minutes until everything started back up again like nothing had happened.
At every layer that I knew of, the failure was invisible, and in fact the repro ended up being ten lines of Python, with the root cause being something I didn't believe that kernels did, until I watched it happen.
The server was fine
The inference server, oMLX serving over HTTP on localhost:8000 was an obvious suspect. A 120B model running on unified memory has plenty of ways to stall, from memory pressure or cache spills to a wedged scheduler or even thermal throttling. But when the server stalls its event loop is idle, and during these stalls the scheduler queue was empty. It wasn't doing anything; CPU usage dropped to 0.6% and there were no model load or unload events. What's more, all three workers stalled at exactly the same moment and then cleared simultaneously.
What finally put the theory to sleep for good was when I would send probe requests while stalled, some of them would go through instantly, others would hang up. Same endpoint, same second. Actual capture:
23:02:19 probe[get-models] code=200 took=0s
23:02:19 probe[get-admin-stats] code=200 took=0s
23:02:29 probe[post-chat] code=000 took=10s <- curl gave up at its own timeout
23:02:29 probe[post-login] code=200 took=0s
23:02:39 probe[get-chat-noauth] code=000 took=10s
If it was the server itself that had wedged then every connection would have hung. Some were being answered in zero seconds while others were not, so whatever was deciding which requests lived and which died was doing it on a per connection basis before the server ever saw them. The hung connections were sitting in SYN_SENT over loopback, which meant the SYN was going out and nothing was answering it on the same machine even though other connections were being accepted by the same port at the same time.
The stuck number
Every connection that closed left its four-tuple in TIME_WAIT, to be kept for 2×MSL or thirty seconds on macOS (net.inet.tcp.msl is 15000, I confirmed that it was), and on any healthy machine the TIME_WAIT count will ebb and flow with churn in connections, but mine was not healthy. The TIME_WAIT count rose and did not fall, and by 23:10 there were about 17,800 TIME_WAIT entries machine-wide, some 6000 of them pointed at :8000, the oldest were hours old, well past their expiry. The kernel was not reaping them.
The repro, roughly:
import socket
for _ in range(10):
s = socket.create_connection(("127.0.0.1", 9999)) # any listening port
s.close()
# netstat -an | grep TIME_WAIT
# waited 30 seconds... waited an hour... still there
Ten connections, ten TIME_WAIT entries that refused to vacate the premises. Thirty seconds later they were still there, an hour later still there, on macOS 26.0 (Darwin 25.0.0). On a healthy build they would have been gone in thirty seconds or less.
The failure chain
My workers were slowly poisoning my machine's address space, filling it with TIME_WAIT entries that never expired. They were using urllib, which opens a new TCP connection for each request, and they were making thousands of requests an hour. Every one of those closes burns its (client port, 127.0.0.1, 8000) tuple forever. And there are only so many ephemeral ports.
Eventually, one of my workers got handed a port it had already used at some point in the past few hours. The kernel saw the SYN, decided that tuple was still in TIME_WAIT and dropped it without a word. On a working kernel this situation would have resolved itself in under thirty seconds, but on mine it never resolved at all. The client just sat there retransmitting SYNs at nothing until its connection timeout finally gave up.
The stalls I was measuring were 3.5 to 4.9 minutes long, and if you add up the default keepinit retry ladder that's roughly the number you get. Which request drew a bad port was pure luck, which is why two probes fired in the same second could go 200, hang, 200, hang.
The server was never really involved. It was just the busiest port on that machine, so it ended up with all those burned tuples piled up in its lap.
The workaround
I stopped opening connections. I rewrote the workers' HTTP layer to keep a single connection alive per thread, plus one silent retry if it needed to reconnect since uvicorn hangs up on idle keep-alive connections after five seconds or so. That brought the connection churn down to almost nothing and the corpus run went through overnight at full speed with no stalls. The pile of dead tuples stopped growing. It didn't shrink, though. Nothing made it shrink. The seventeen thousand corpses sat there until I rebooted the machine.
It made sense that no one else was complaining, since any client which pooled its connections would never open enough sockets to run into the wall. It's what well-written clients do, anyway. Only something naïve - a polite word for badly written - would open and close thousands of loopback connections an hour, enough to make the kernel's bookkeeping slip. I just happened to be running exactly such a hastily hacked script, so I was the one who saw it.
The silent fix
On July 27th, when I re-ran the above ten-connection repro on macOS 26.5.2, expecting to grab a fresh screenshot of immortal sockets for posterity, the entries reaped normally in under 45 seconds. Somewhere between macOS 26.0 and 26.5.2, the bug went away; I went looking for the build in which it died, and found nothing: no release note mentions TIME_WAIT or TCP at all. I also never filed the Apple Feedback, which I regret, and which would have made this bug immortal in a database somewhere. As it stands, the only paper trail is this post. If you know which build fixed this bug (or if you can still reproduce it on an intermediate version), I want to hear about it. As for the above ten-line repro, it will answer the question on any machine in under a minute - ping me if you have insight!