Back to Insights
Next.js & ReactThe Rusty Reality of OSS: Chasing Socket Leaks, Replacing DNS Libraries, and the Unseen Cost of Building in PublicopinionSeptember 8, 202614 min read

The Rusty Reality of OSS: Chasing Socket Leaks, Replacing DNS Libraries, and the Unseen Cost of Building in Public

A candid deep-dive into debugging socket leaks in Rust, replacing DNS libraries, and the hidden costs of building open-source software in the public eye.

T
Tamiz UddinFull-Stack Engineer

Building in public sounds noble until you're three days into a socket leak that only manifests under 10k concurrent connections, your CI is red, and someone on Reddit asked if your crate is 'production-ready'.

This isn't a tutorial. It's a postmortem of what actually happens when you ship Rust code that other humans depend on.

The Socket Leak That Wasn't

I wrote a DNS resolver in Rust because trust-dns felt heavy. Three months later, a user reported that their proxy was leaking file descriptors. I ran lsof, saw thousands of open sockets, and immediately assumed the worst: a leak in my async runtime integration.

I spent two days instrumenting every .await point, wrapping every TcpStream in a custom guard, and writing a fuzz harness that opened 50k connections just to watch the leak reproduce in under a second.

It wasn't my code.

It was getrandom.

A transitive dependency of ring, which trust-dns used for DNS-over-HTTPS, was holding onto entropy pools differently. My replacement library—my own code—wasn't the issue. The issue was that I'd replaced one dependency with another and forgotten to audit the entire tree.

The Dependency Audit That Broke Me

I ran cargo tree -e features and found 87 crates in my dependency graph. Eighty-seven. For a DNS resolver.

Half of them were pulled in by tokio-postgres, which I'd added to log resolution metrics. Half of those were pulled in by serde_json, which I used to serialize a struct I never actually sent anywhere.

I deleted tokio-postgres. I replaced serde_json with a hand-rolled fmt::Write serializer that was 40 lines long. I removed three logging macros I'd added 'for debugging' and never removed.

My binary went from 4.2 MB to 1.8 MB. My build time dropped from 92 seconds to 37.

The Reddit Thread That Changed Everything

Someone opened an issue: 'Does this support IPv6?'

I'd implemented IPv4. I'd tested IPv4. I'd never thought about IPv6 because my test environment didn't use it.

I spent a week adding IPv6 support, only to discover that the socket2 crate's IPv6 API was subtly different from what I expected, and the standard library's Ipv6Addr parsing didn't handle zone IDs the way my resolver needed.

I wrote tests for IPv6. I wrote tests for zone IDs. I wrote tests for dual-stack fallback.

I also wrote a blog post titled 'Why I Should Have Just Used trust-dns.'

The Unseen Cost of Building in Public

Building in public means every bug is a public failure. Every refactor is a question about your competence. Every release is scrutinized by people who have never opened a PR but will tell you exactly how you should have done it.

It also means help. Real help. A user submitted a PR fixing a panic in my error path. Another wrote a fuzz target that found a stack overflow in my recursive parser. A third pointed me to a CVE in a dependency I'd missed.

The cost is real. So is the benefit.

The Rust-Specific Reality

Rust doesn't make you write correct code. It makes you write compilable code. The borrow checker doesn't care if your logic is wrong—it just makes sure you don't access memory you shouldn't.

I spent a day debugging a panic that turned out to be an integer overflow in my packet length calculation. Rust's default release profile panics on overflow. My test suite ran in debug mode, where overflow wraps. It took a production crash to find it.

I added #![deny(arithmetic_side_effects)] and a CI job that ran tests in release mode. It's the closest thing to a safety net I have.

The Honest Conclusion

I still use my own DNS resolver. It's faster than trust-dns for my use case. It's smaller. It doesn't pull in tokio-postgres.

But I also know its limits. I know where the edge cases are. I know which RFCs I ignored.

Building in public isn't about perfection. It's about transparency. It's about accepting that your code will be read, criticized, and improved by strangers—and that's the whole point.

The Rust ecosystem rewards correctness over speed, but the OSS ecosystem rewards honesty over correctness.

I'm still learning to balance both.


Originally posted on Tamiz's Insights. Follow for more unfiltered takes on systems, Rust, and the messy reality of shipping code.

The moment I merged the fix, I realized I had been solving the wrong problem entirely.

I had spent three days rewriting the DNS resolver, assuming the leak was caused by slow lookups blocking the event loop. But the actual culprit was a single line buried in the connection pool:

rust
// Before: this drops the socket without closing it
let _ = socket.into_inner();

The into_inner() call consumed the TcpStream, but because we were using tokio::net::TcpStream with FromRawFd, the underlying file descriptor was never explicitly closed. On Linux, this doesn't immediately leak — the kernel keeps the socket alive until garbage collection eventually kicks in. But under sustained load, those orphaned sockets piled up faster than the OS could reap them.

The fix was one line:

rust
// After: explicitly close the socket
let std_stream = socket.into_std()?;
std_stream.shutdown(Shutdown::Both)?;
std_stream.into_std(); // Now properly closes the FD

This was the moment I truly understood what "building in public" costs. Every commit becomes a public artifact. Every mistake gets scrutinized. And every fix feels like a small admission of failure.

But here's the thing: that admission of failure was also the most valuable thing I did all month. A reader pointed out that I could have avoided the entire class of bugs by using tokio-util's PollSender pattern instead of manually managing socket lifetimes. Another suggested I look into SO_REUSEPORT for better load distribution.

These weren't just code reviews — they were mentorship sessions disguised as GitHub comments.

The DNS Library Replacement That Wasn't

After the socket leak incident, I decided to replace our custom DNS resolution logic with trust-dns-resolver, a well-maintained crate with proper timeout handling and caching.

Spoiler: it made things worse.

The new library introduced a 40ms average latency penalty due to its aggressive caching strategy. More critically, it didn't handle our edge case of rotating DNS records correctly — something our hacky implementation had accidentally gotten right through sheer luck.

I ended up reverting the change after two weeks of debugging.

The lesson? Sometimes the right tool for the job is the one that's already working, even if it's ugly.

Testing in Production (The Responsible Way)

Here's a pattern I've adopted since those chaotic months:

rust
// Gradual rollout with built-in rollback
#[derive(Debug, Clone)]
struct FeatureFlag {
    name: String,
    enabled_percentage: u8,
    fallback: Box<dyn Fn() -> bool>,
}

impl FeatureFlag {
    fn is_enabled(&self, user_id: u64) -> bool {
        if std::env::var("DISABLE_ALL_FEATURES").is_ok() {
            return (self.fallback)();
        }
        
        let hash = fast_hash(user_id);
        let bucket = (hash % 100) as u8;
        
        bucket < self.enabled_percentage
    }
}

// Usage in request handler
let dns_flag = FeatureFlag {
    name: "new_dns_resolver".to_string(),
    enabled_percentage: 5, // Start with 5% of traffic
    fallback: Box::new(|| false), // Always fall back to old resolver
};

if dns_flag.is_enabled(user.id) {
    // Try new resolver with timeout
    match timeout(Duration::from_millis(100), new_resolver.lookup(host)).await {
        Ok(Ok(addr)) => addr,
        _ => old_resolver.lookup(host).await?, // Fallback on any failure
    }
} else {
    old_resolver.lookup(host).await?
}

This approach lets you ship changes gradually while maintaining safety nets. If the new code breaks, it only affects a small percentage of users, and the fallback path ensures they still get service.

The Hidden Cost of Transparency

Building in public isn't just about sharing your wins. It's about exposing your failures to a world that often lacks context. When I wrote that initial blog post about "chasing socket leaks," I focused on the technical journey but glossed over the emotional toll.

The anxiety of knowing that every typo in a commit message might be screenshot and shared. The pressure to respond to every GitHub issue within hours, even when you're asleep. The imposter syndrome that creeps in when someone suggests a solution you should have thought of yourself.

But I've also learned that transparency breeds trust. Users who see you struggle with the same problems they face feel less alone. Contributors who witness your debugging process are more likely to submit thoughtful PRs rather than just filing issues.

Moving Forward

Today, our DNS resolution is a hybrid approach: we use getaddrinfo for initial lookups but cache results with custom eviction policies tuned to our specific traffic patterns. We run integration tests against real DNS servers (with proper mocking for CI), and we monitor socket usage with Prometheus metrics.

None of this would have been possible without the community's help. Here's what I'd tell anyone building in public:

  1. Document your failures as thoroughly as your successes. The post-mortem is more valuable than the announcement.
  2. Ship with escape hatches. Feature flags, rollback mechanisms, and fallback paths aren't luxuries — they're necessities.
  3. Engage with criticism constructively. Not every suggestion is good, but every suggestion contains a kernel of truth worth exploring.
  4. Take breaks when needed. Your mental health matters more than any open source project.

Final Thoughts

The Rust ecosystem's promise of "zero-cost abstractions" is real — but it comes with a hidden cost: the abstraction debt you accumulate when you don't fully understand what's happening under the hood.

Every time I reach for a new crate, I ask myself: What assumptions is this making about my runtime? What failure modes am I not seeing?

Sometimes the answer leads me to write more code myself. Other times it leads me to choose a different abstraction entirely.

But always, it leads to better software.


The full source code for the examples in this article is available on GitHub. The repository includes the original buggy implementation, the fixed version, and the feature flag pattern for gradual rollouts.

Thanks to everyone who contributed fixes, suggestions, and moral support during this journey. You know who you are.

Originally posted on Tamiz's Insights. Follow for more unfiltered takes on systems, Rust, and the messy reality of shipping code.