
It Worked Before: How an OS Upgrade Broke My Rust Sockets
- February 23, 2026
- 6 min read
- Operating systems , Rust programming , Debugging and troubleshooting
Table of Contents
I didn’t change a single line of code. I just upgraded my operating system (OS), and suddenly my Rust tool stopped working.
The error was blunt: Error: Address family not supported by protocol (os error 97).
I was building a simple distributed test—a server binding to 0.0.0.0 and a client connecting to localhost.
It’s about as standard as it gets.
But after the OS upgrade, the client refused to even start the handshake. The connection didn’t time out. It failed instantly.
I spent time debugging the OS and the network—only to realize the problem wasn’t there at all.
It was the data my OS was feeding my code.
Newsletter
Subscribe to our newsletter and stay updated.
The “Localhost” Trap
Here is the reality check: localhost is a name, not an IP.
We’ve spent decades conditioned to think localhost == 127.0.0.1.
But modern OSs (Windows 11, recent macOS, and most Linux distros) and networking stacks now often prioritize IPv6 resolution.
In most cases, the OS returns both IPv6 and IPv4 addresses — just ordered by preference.
Before OS upgrade, it was IPV4 first:
1$ getent ahosts localhost
2127.0.0.1 STREAM localhost
3127.0.0.1 DGRAM
4127.0.0.1 RAW
5::1 STREAM
6::1 DGRAM
7::1 RAW
After OS upgrade, it was IPV6 first:
1$ getent ahosts localhost
2::1 STREAM localhost
3::1 DGRAM
4::1 RAW
5127.0.0.1 STREAM
6127.0.0.1 DGRAM
7127.0.0.1 RAW
The bug comes from blindly taking the first result:
1let addr = "localhost:8080".to_socket_addrs()?.next().unwrap();
2let stream = TcpStream::connect(addr)?;
In Rust, we usually resolve addresses using ToSocketAddrs trait.
The next().unwrap() is the culprit. It just grabs the first thing the OS gives it.
When my client code asked the OS to resolve localhost, the new OS handed back a list starting with ::1 (IPv6) instead of the IPv4 loopback address I was expecting.
It turned out IPv6 was disabled on my system — which caused the Error: Address family not supported by protocol (os error 97) error.
I then re-enabled IPv6 and tried again.
This time I got a different error: Error: Connection refused (os error 111).
That was even more confusing.
A “connection refused” usually means nothing is listening on that port.
But I did have a server running — bound to 0.0.0.0.
The answer is simple: an IPv6 client cannot connect to an IPv4-only listener.
This happens because the OS keeps IPv4 and IPv6 in completely separate socket tables.
When my server bound to an IPv4 address, it registered a listener in the IPv4 table. But my client was trying to connect using IPv6, so the OS looked in the IPv6 table for a matching listener. It found nothing—because the IPv4 and IPv6 tables don’t overlap—and returned the error.
Under the hood, this comes down to how the OS tracks connections using a 5-tuple (source IP, source port, destination IP, destination port, protocol). If you want a deeper breakdown of that model, I cover it in detail here: Binds and Connections
The Fix: Stop Trusting DNS Order
To make the code robust, I had to stop trusting the order of the addresses returned by the OS. I needed to explicitly find the family my server actually supports.
1// Don't just take the first result; find the one that matches your socket family
2let addr = "localhost:8080"
3 .to_socket_addrs()?
4 .find(|a| a.is_ipv4())
5 .expect("No IPv4 address found for localhost");
6
7let stream = TcpStream::connect(addr)?;
This fix assumes your server only supports IPv4. If your server supports IPv6, you should instead attempt both families by using all returned addresses:
1let addrs: Vec<_> = "localhost:8080"
2 .to_socket_addrs()?
3 .collect();
4
5let stream = TcpStream::connect(addrs.as_slice())?;
Why Doesn’t Rust Handle This Automatically?
Some runtimes (like Go, and certain networking libraries in other languages) implement strategies like “Happy Eyeballs” (RFC 8305).
Those runtimes will resolve the hostname (localhost) and try to connect to both returned addresses (IPv4 and IPv6) simultaneously.
Then use whichever one responds first.
In Rust, you have total control, which means you have to handle the protocol mismatch yourself.
A Better Server-Side Fix
If you’re writing the server, stop binding to 0.0.0.0.
That limits you to the IPv4 world.
Instead, bind to the IPv6 “any” address ::.
On many systems, this creates a dual-stack socket, meaning it can accept both IPv6 and IPv4 connections (IPv4 addresses are mapped into IPv6 space, e.g. ::ffff:127.0.0.1).
However, this behavior is OS-dependent.
Some systems require explicitly disabling the IPV6_V6ONLY option to accept IPv4 connections.
Note that this behavior is not universal — some systems default to IPv6-only sockets unless explicitly configured otherwise. More about that in my guide on Binds and Connections.
Newsletter
Subscribe to our newsletter and stay updated.
The Takeaway
localhostis not an address — it’s a lookup.- The OS may return multiple addresses — order matters.
.next().unwrap()is not harmless — it encodes assumptions.- IPv4 and IPv6 are separate worlds unless you bridge them explicitly.
If your code “suddenly broke” after an OS upgrade, chances are: nothing changed in your code — your assumptions just got exposed.
This wasn’t a networking bug.
It was a wrong assumption about how the OS resolves and routes addresses.


