Windows: dial failed on localhost¶
If a Desi program that connects to 127.0.0.1 intermittently fails —
net.dial returning -1, or a server that never accepts — the cause is
usually local port exhaustion on Windows, not your program.
This page explains how to confirm that, because the symptom looks like a bug in your code and is not one.
What actually happens¶
When you connect() without binding first, the OS picks a local port for
you from the dynamic port range — by default 49152–65535, so 16,384
ports total:
Every closed TCP connection then sits in TIME_WAIT for ~120 seconds
before its port can be reused. That is required by TCP, not a leak. But it
means a busy machine can hold tens of thousands of TIME_WAIT entries at
once, and once the pool is starved, connect() fails with
WSAEADDRINUSE (10048) — "address already in use" — even though the
server port is free and listening.
The error name is misleading: it is the client's local port that could not be allocated, not the address you are connecting to.
Confirming it¶
Count the sockets parked in TIME_WAIT:
Against a 16,384-port range, a five-figure number means the pool is starved. To find what is responsible, group by remote port:
netstat -ano -p tcp | Select-String "TIME_WAIT" | ForEach-Object {
if ($_ -match '\d+\.\d+\.\d+\.\d+:(\d+)\s+\d+\.\d+\.\d+\.\d+:(\d+)\s+TIME_WAIT') { $matches[2] }
} | Group-Object | Sort-Object Count -Descending | Select-Object -First 5
If one remote port dominates, a background service is churning connections. Identify its owner:
On one development machine this surfaced a vendor agent holding ~7,100 loopback connections to a single port, keeping the ephemeral pool ~82% exhausted indefinitely. No amount of application-side retrying fixes that; the ports genuinely do not exist.
This is not language-specific¶
It is an OS-level condition, and every runtime hits it identically. The same connect/accept sequence, written four ways and run on a machine in the state described above:
| Implementation | Completed | Could not get a local port |
|---|---|---|
Desi (net module) |
1 / 8 | 7 |
| C, raw WinSock2 | 1 / 8 | 7 |
Go (net.Dial) |
0 / 8 | 8 |
.NET (TcpClient) |
0 / 8 | 8 |
The point is not that one language beats another — at this sample size the differences are noise. The point is that all four behave the same. If you see this in Desi, you would see it in C, Go, or .NET on the same machine. Desi's socket layer is a thin wrapper over the platform sockets and inherits platform behavior exactly.
What Desi does about it¶
net.dial retries a bounded number of times when Windows reports
WSAEADDRINUSE, because a fresh socket gets a different local port. This
absorbs the transient collisions a moderately busy machine produces —
including the ones a repeated test run creates against itself. It cannot
manufacture ports on a machine that has none.
The retry is narrow on purpose: it triggers only on WSAEADDRINUSE. Any
real failure — connection refused, host unreachable — returns immediately
rather than spinning. On macOS and Linux the single-attempt behavior is
unchanged.
Fixing your machine¶
- Wait.
TIME_WAITentries expire in ~120 seconds. If your own test loop starved the pool, it recovers on its own. - Stop the offending service, if you identified one above and do not need it running.
- Widen the dynamic range (administrator, system-wide):
- In your own code, prefer one long-lived connection over many short-lived ones. Connection churn is what consumes the pool.
Write networking code that fails cleanly¶
A blocking accept() waits forever for a peer that will never arrive, so
an unchecked dial failure becomes a hang rather than an error. Always
check the descriptor:
let srv = net.listen("127.0.0.1", 19876)
if srv < 0:
print("listen failed")
return 1
let client = net.dial("127.0.0.1", 19876)
if client < 0:
# No local socket available — bail out instead of blocking forever
# in accept() waiting for a peer that will never connect.
print("dial failed")
net.close(srv)
return 1
examples/429_net_module.desi in the repository follows this pattern.