*It Worked Before*: How an OS Upgrade Broke My Rust Sockets

It Worked Before: How an OS Upgrade Broke My Rust Sockets

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.

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.

The Takeaway

  • localhost is 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.

Related Posts

The `1ms` Lie: Why `sleep()` Breaks Real-Time Code—Resolution & Jitter Fixed

The 1ms Lie: Why sleep() Breaks Real-Time Code—Resolution & Jitter Fixed

If you’ve ever built a real-time system, a data simulator, or a game loop, you’ve tried using sleep() to control timing. And if …

Read More
How a Program Binary Becomes a Running Process

How a Program Binary Becomes a Running Process

Have you ever stopped to think about what really happens when you run a program? Not just clicking “Run” or executing a command in the …

Read More
Beyond 127.0.0.1: You Own 16 Million Loopback Addresses

Beyond 127.0.0.1: You Own 16 Million Loopback Addresses

Many developers use localhost and 127.0.0.1 interchangeably, assuming loopback is limited to a single address. However, the IPv4 specification …

Read More