It Worked Before: How an OS Upgrade Broke My Rust Sockets
- February 23, 2026
- 3 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 OS, and suddenly my Rust tool was dead.
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 the most standard setup imaginable. But after the upgrade, the client refused to even start the handshake. I spent twenty minutes checking my firewall before I realized the problem wasn’t the network—it was the data my OS was feeding my code.
​
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) now prioritize IPv6. When my client code asked the OS to resolve localhost, the OS handed back ::1 (IPv6) instead of the IPv4 address I was expecting.
My code was creating an IPv4 socket but trying to shove an IPv6 address into it. The CPU basically told me I was speaking the wrong language.
​
The Problem with the “Lazy” Rust Pattern
In Rust, we usually resolve addresses using ToSocketAddrs trait.
If you look at most tutorials, they show you this “lazy” pattern:
1// This is a ticking time bomb
2let addr = "localhost:8080".to_socket_addrs()?.next().unwrap();
3let stream = TcpStream::connect(addr)?;
The next().unwrap() is the culprit. It just grabs the first thing the OS gives it. If your OS decides ::1 is the priority, that’s what addr becomes. If your server is only listening on IPv4 (0.0.0.0), the connection dies instantly because the “families” don’t match.
IPv4 and IPv6 are not compatible. They are two separate lanes on the highway. You can’t send an IPv6 packet to an IPv4-only listener.
This happens because of how the OS manages the socket table. As I broke down in my guide on Binds and Connections, a connection is defined by a specific 5-tuple. When my server bound to an IPv4 address, it created a listener in the IPv4 table. When my client tried to connect via IPv6, the OS looked at the IPv6 table, found nothing, and threw the error. They were effectively invisible to each other
​
The Fix: Filtering for Reality
To make the code robust, you have to stop trusting the order of the addresses returned by the OS. You need to explicitly find the family your 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)?;
​
Should this be automatic?
In high-level languages like Go or Python, this often is handled for you via an algorithm called “Happy Eyeballs” (RFC 8305). Those runtimes will try to connect to IPv4 and IPv6 simultaneously and just use whichever one responds first.
In Rust, you have total control, which means you have to handle the protocol mismatch yourself.
​
How to avoid this entirely
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 [::]. Most modern systems use “Dual-Stack” sockets, meaning a server listening on :: will automatically catch IPv4 traffic too (mapped as ::ffff:127.0.0.1).
The Takeaway:
- Stop assuming
localhostis127.0.0.1. - Stop using
.next().unwrap()on socket iterators. - If you support IPv4 only, filter for it. If you want to be modern, bind your server to
[::].