📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

๐Ÿ“š Interview question

A .NET service on Linux starts failing with "Too many open files" and SocketException errors under load. Walk me through how you would diagnose it, including HttpClient socket exhaustion and TIME_WAIT.

Diagnosing descriptor exhaustion and socket exhaustion in .NET on Linux: ulimit, lsof, ss, TIME_WAIT, ephemeral ports, and the HttpClient/IHttpClientFactory fixes.

Senior Debugging Advanced file-descriptor httpclient linux operating-system sockets time-wait troubleshooting

๐ŸŽฏ Key points

  • First separate the two limits by errno: EMFILE ('Too many open files') is the per-process descriptor limit; EADDRNOTAVAIL ('Cannot assign requested address') is ephemeral-port exhaustion to one destination.
  • TIME_WAIT is a normal 60-second state on the side that closed first; CLOSE_WAIT never clears on its own and means our code did not dispose a response, stream or socket.
  • Do the arithmetic: about 28,000 ephemeral ports divided by 60 s of TIME_WAIT is roughly 466 new connections per second to one destination before ports run out.
  • new HttpClient per request creates a new SocketsHttpHandler and connection pool each time, so every call pays a TCP and TLS handshake and leaves a TIME_WAIT socket.
  • Share connections with IHttpClientFactory (typed or named clients) or one static HttpClient over SocketsHttpHandler with PooledConnectionLifetime so DNS changes are still picked up.
  • Prefer HTTP/2 (or HTTP/3) to internal services where supported: many requests multiplex over one connection, which removes most connection churn.
  • Add timeouts and resilience with Microsoft.Extensions.Http.Resilience (AddStandardResilienceHandler: timeout, retry with jitter, circuit breaker) so a slow downstream cannot pile up open connections.
  • Triage with /proc/<pid>/limits, /proc/<pid>/fd, lsof, ss -s and ss state time-wait/close-wait; only raise LimitNOFILE, widen ip_local_port_range or enable tcp_tw_reuse after fixing reuse, and never use tcp_tw_recycle (removed in Linux 4.12).

โฑ 30-second answer

Two different limits produce similar errors:

  1. Descriptor exhaustion (EMFILE, "Too many open files"): the process hit its ulimit -n. Something is leaking files or sockets.
  2. Ephemeral port exhaustion (EADDRNOTAVAIL, "Cannot assign requested address"): too many outbound connections to the same destination, usually closed sockets stuck in TIME_WAIT for 60 s.

The classic .NET cause is new HttpClient() per request. Fix it with a shared HttpClient using SocketsHttpHandler { PooledConnectionLifetime = ... }, or IHttpClientFactory. Diagnose with ls /proc/<pid>/fd | wc -l, lsof -p, ss -s and ss -tan state time-wait.

๐Ÿ—ฃ Full answer (senior)

Step 1: Which limit did we hit?

Symptom errno Meaning
IOException: Too many open files EMFILE (24) Process descriptor limit reached
SocketException: Too many open files EMFILE Same, from a socket call
SocketException: Cannot assign requested address EADDRNOTAVAIL (99) No free local port for this destination
SocketException: Address already in use EADDRINUSE Bind conflict, often after restart without SO_REUSEADDR

Step 2: Look at the process

PID=$(pgrep -f OrdersApi)
cat /proc/$PID/limits | grep "open files"     # the limit
ls /proc/$PID/fd | wc -l                       # current count
lsof -p $PID | awk '{print $5}' | sort | uniq -c   # REG files vs IPv4/IPv6 sockets
ss -tanp | grep "pid=$PID" | awk '{print $1}' | sort | uniq -c   # ESTAB vs CLOSE-WAIT
ss -s                                          # system-wide summary incl. timewait
ss -tan state time-wait | awk '{print $4}' | sort | uniq -c | sort -rn | head   # TIME_WAIT by remote

Patterns:

  • Many REG files โ†’ a file stream leak.
  • Many ESTABLISHED sockets to one host โ†’ a connection pool per request (new HttpClient each time) or a missing dispose.
  • Many CLOSE_WAIT โ†’ we are not closing sockets after the peer closed: a response or stream not disposed.
  • Many TIME_WAIT to one destination (thousands) โ†’ we open and close connections quickly; ports run out.

Step 3: Production example

An orders API created using var client = new HttpClient(); in each request to call a pricing service. At 400 requests/s, each request opened a TCP connection, and disposing it put the socket in TIME_WAIT for 60 s. That is 24,000 sockets in TIME_WAIT, against ~28,000 ephemeral ports (32768โ€“60999). Under a sale spike it failed with Cannot assign requested address.

Fix: one long-lived client via IHttpClientFactory (typed client). Connections were then reused (keep-alive). TIME_WAIT dropped to tens, and p95 latency dropped too because TLS handshakes stopped.

Step 4: Fix the cause, then tune

  • Share connections: IHttpClientFactory or a static HttpClient with SocketsHttpHandler.PooledConnectionLifetime (e.g. 2โ€“5 min) so DNS changes are picked up.
  • Dispose HttpResponseMessage and response streams (fixes CLOSE_WAIT).
  • using all file streams.
  • Only then consider raising LimitNOFILE / --ulimit nofile or widening net.ipv4.ip_local_port_range.

๐Ÿ” What the interviewer is checking

A practical production-debugging question that combines OS knowledge (descriptors, TCP states, ports) with a very common .NET pitfall.

Green flags

  • Distinguishes EMFILE from EADDRNOTAVAIL.
  • Uses ss, lsof, /proc/<pid>/fd and /proc/<pid>/limits.
  • Explains TIME_WAIT vs CLOSE_WAIT correctly.
  • Knows the HttpClient per-request problem, plus the DNS caveat of a static client.

Red flags

  • "Just raise the ulimit."
  • Wraps HttpClient in using per call and thinks that is correct.
  • Suggests tcp_tw_recycle.

๐Ÿ“š Learn in depth

1. TCP connection lifecycle and TIME_WAIT

Client (active closer)                 Server
   ESTABLISHED โ”€โ”€โ”€ FIN โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ  CLOSE_WAIT   (server app must close)
   FIN_WAIT_1                               
   FIN_WAIT_2  โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ACK โ”€โ”€โ”€
               โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ FIN โ”€โ”€โ”€   LAST_ACK
   TIME_WAIT   โ”€โ”€โ”€ ACK โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ  CLOSED
      โ”‚
      โ””โ”€ waits 2 ร— MSL (60 s on Linux) before the port can be reused for
         the same (src ip, src port, dst ip, dst port) tuple
  • TIME_WAIT is on the side that closes first. It protects against late packets from the old connection being delivered to a new one. It is normal; it only hurts when you create lots of short connections to the same destination.
  • CLOSE_WAIT means the peer closed and our application has not. It never times out by itself, so a growing CLOSE_WAIT count is always an application bug (undisposed response or socket).

2. Ephemeral ports

An outbound connection needs a unique 4-tuple. With the same destination IP and port, only the source port varies.

cat /proc/sys/net/ipv4/ip_local_port_range   # usually 32768 60999 โ†’ ~28k ports

At 28,000 ports รท 60 s TIME_WAIT โ‰ˆ 466 new connections per second to one destination before you run out. Many services exceed that easily if they do not reuse connections.

3. Why new HttpClient() per request hurts

Request โ”€โ–บ new HttpClient โ”€โ–บ new SocketsHttpHandler โ”€โ–บ new pool โ”€โ–บ new TCP (+TLS) โ”€โ–บ dispose โ”€โ–บ TIME_WAIT
Request โ”€โ–บ new HttpClient โ”€โ–บ new SocketsHttpHandler โ”€โ–บ new pool โ”€โ–บ new TCP (+TLS) โ”€โ–บ dispose โ”€โ–บ TIME_WAIT
...

Each HttpClient owns a handler with its own connection pool, so nothing is reused. Compare:

Requests โ”€โ–บ shared HttpClient โ”€โ–บ one pool โ”€โ–บ a few long-lived TCP connections (keep-alive, HTTP/2 multiplexing)

4. The DNS caveat with a static client

A single static HttpClient keeps connections forever by default, so if the target's DNS changes (blue/green, failover), you keep talking to the old IP. Fix with PooledConnectionLifetime, which recycles connections after the given time. IHttpClientFactory handles this for you by rotating handlers (default handler lifetime 2 minutes).

5. Descriptor limits in containers and systemd

Where How to see How to set
Shell ulimit -n, ulimit -Hn ulimit -n 65535 (up to hard limit)
Process /proc/<pid>/limits inherited from parent
systemd systemctl show myapp -p LimitNOFILE LimitNOFILE=65535 in the unit
Docker docker exec ... cat /proc/1/limits --ulimit nofile=65535:65535
Kubernetes as Docker container runtime config (no pod-spec field)

.NET raises the soft limit to the hard limit at startup on Linux, so check the hard limit.

6. Other leak sources

  • FileSystemWatcher instances (each uses inotify descriptors; also limited by fs.inotify.max_user_instances). Creating one per request or per config reload leaks quickly.
  • Process.Start without disposing the Process (pipes for redirected stdio).
  • Database connections not returned to the pool (also shows as pool timeout first).
  • gRPC channels created per call instead of reused.

7. Try it yourself

  1. Write a minimal API that calls https://example.com using new HttpClient() per request.
  2. Load it with bombardier -c 50 -d 30s http://localhost:5000/.
  3. In another terminal: watch -n1 "ss -tan state time-wait | wc -l" and see thousands.
  4. Switch to builder.Services.AddHttpClient("example") and inject IHttpClientFactory. Repeat and watch TIME_WAIT stay low and throughput rise.

๐Ÿ’ป Code

// Bad: new client (and new connection pool) per request
app.MapGet("/price/{sku}", async (string sku) =>
{
    using var client = new HttpClient();               // new TCP + TLS every time
    return await client.GetStringAsync($"https://pricing.internal/api/{sku}");
});
// Good: typed client via IHttpClientFactory
builder.Services.AddHttpClient<PricingClient>(client =>
{
    client.BaseAddress = new Uri("https://pricing.internal/");
    client.Timeout = TimeSpan.FromSeconds(5);
});

public sealed class PricingClient(HttpClient http)
{
    public async Task<decimal> GetPriceAsync(string sku, CancellationToken ct)
    {
        using var response = await http.GetAsync($"api/{sku}", ct); // dispose โ†’ no CLOSE_WAIT
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<decimal>(ct);
    }
}
// Also good outside DI: one static client with a bounded connection lifetime
private static readonly HttpClient Shared = new(new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(2),   // picks up DNS changes
    PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),
    MaxConnectionsPerServer = 100
});
# Triage commands
PID=$(pgrep -f OrdersApi)
grep "open files" /proc/$PID/limits
ls /proc/$PID/fd | wc -l
ss -s
ss -tan state time-wait | awk '{print $4}' | sort | uniq -c | sort -rn | head
ss -tanp state close-wait | grep "pid=$PID" | wc -l

โš  Common mistakes

Mistake: Creating and disposing HttpClient per request because it is IDisposable. Why it is wrong: Each instance owns its own connection pool, so every call opens a new TCP and TLS connection and disposing it leaves the socket in TIME_WAIT. Instead: Share one client per target via IHttpClientFactory, or a static client with PooledConnectionLifetime.

Mistake: Using a static HttpClient with default settings forever. Why it is wrong: Pooled connections never recycle, so DNS changes during failover or blue/green deployments are ignored. Instead: Set PooledConnectionLifetime (for example two to five minutes) or let IHttpClientFactory rotate handlers.

Mistake: Confusing TIME_WAIT with CLOSE_WAIT. Why it is wrong: They point to opposite causes: TIME_WAIT comes from too many short-lived connections, CLOSE_WAIT from our code not closing. Instead: Count states with ss and treat a growing CLOSE_WAIT count as an undisposed HttpResponseMessage or stream.

Mistake: Reaching for kernel tweaks first. Why it is wrong: Raising ulimit -n hides leaks, and tcp_tw_recycle broke clients behind NAT and was removed from Linux 4.12. Instead: Fix connection reuse first; then, with measurements, widen ip_local_port_range or enable tcp_tw_reuse, which only affects outbound connections.

Mistake: Having no timeout or circuit breaker on outbound calls. Why it is wrong: When a downstream slows down, requests and their connections pile up until descriptors or the pool run out. Instead: Set HttpClient.Timeout or a resilience pipeline with a timeout and circuit breaker, and pass CancellationTokens through.

๐Ÿ› Senior insight: trade-offs

The senior skill is separating two limits that both surface as socket errors. The errno and a 30-second look at ss -s tell you whether to hunt for a leak (EMFILE, CLOSE_WAIT) or for missing reuse (EADDRNOTAVAIL, TIME_WAIT). Then I do the arithmetic out loud: about 466 new connections per second per destination is the ceiling without reuse.

Option Benefit Cost or risk When to use it
IHttpClientFactory typed client Pooled connections, handler rotation for DNS, DI-friendly, resilience handlers Must not capture typed clients in singletons Default in ASP.NET Core
Static HttpClient + PooledConnectionLifetime Simple, no DI needed You own all settings Libraries, console apps
HTTP/2 or HTTP/3 to downstream Many requests per connection, far less churn Needs server support; one slow stream can share a connection's fate Internal service calls, gRPC
AddStandardResilienceHandler Timeouts, retries with jitter, circuit breaker Retries can amplify load if misconfigured All outbound calls
Kernel tuning (ip_local_port_range, tcp_tw_reuse) More headroom Hides design problems Only after reuse is fixed and measured

Monitoring: I want open descriptors and TCP state counts per pod, plus the System.Net.Http metrics in .NET (http.client.open_connections, http.client.connection.duration, request duration) so connection churn is visible before it becomes an outage.

๐Ÿ”— Follow-ups the interviewer may ask

  1. When: Candidate is vague about what a descriptor is Why: Checks the fundamentals behind the error
    How to answer

    A file descriptor (Linux, a small integer) or handle (Windows, an opaque value) is the process's reference to a kernel object: an open file, socket, pipe, event, mutex, process or thread. The kernel owns the object, and the process holds an index into its own table.

    They are limited (e.g. ulimit -n often 1,024 soft by default on Linux shells, much higher in many containers and under systemd), so leaks eventually break the app.

    In .NET, SafeHandle wraps the raw value: it releases it in a critical finaliser if you forget, guarantees release even under thread aborts or async exceptions, and prevents handle recycling bugs by reference counting during P/Invoke calls. You still call Dispose (using) to release deterministically.

  2. When: Candidate mentions many threads blocked on sockets Why: Probes threading and context-switch cost under load
    How to answer

    A context switch is the OS stopping one thread and starting another on a core: it saves the registers, instruction pointer and stack pointer of the old thread and restores those of the new one. Switching between processes also swaps the address space, which is more expensive. The direct cost is a few microseconds, but the indirect cost is often bigger: the new thread finds caches and TLBs cold. Switches are voluntary when a thread blocks, and involuntary when its time slice ends. Too many threads in a .NET service, usually caused by blocking code that makes the thread pool inject more threads, means more switching, more cache misses, more memory for stacks and more lock contention, so throughput falls and latency rises. The fix is async I/O, bounded parallelism around the core count, and less lock contention.

๐Ÿ›  Real-world problems: how you handle them

Port exhaustion from HttpClient per request during a sale
Situation
[Orders API] on .NET [10] in Kubernetes calling a [pricing service] over HTTPS. Traffic rose from [100] to [400+] requests/s during a [sale].
Signals
Spike of `SocketException: Cannot assign requested address`, 5xx rate [8%], pricing latency up because of repeated TLS handshakes, `ss -s` showing [24,000] timewait.
Investigation
Exec'd into a pod: `ss -tan state time-wait` grouped by remote showed almost all to the pricing service IP. `/proc/<pid>/fd` count was normal, so not EMFILE. Code search found `using var client = new HttpClient()` in the pricing call.
Resolution
Hotfix: registered a typed client with `AddHttpClient<PricingClient>()` and redeployed. TIME_WAIT fell to [~50], errors stopped and p95 improved by [70 ms]. Follow-up: audited all `new HttpClient` uses and added a Roslyn analyser rule.
Prevention
Dashboard for open descriptors and TCP states per pod, load test before sales that includes downstream calls, code review checklist item for `HttpClient` creation.

How to tell it (STAR)

**S:** During a [sale], our [orders API] started failing [8%] of requests with socket errors. **T:** I was on call and needed it stable fast. **A:** The error was "Cannot assign requested address", not "Too many open files", so I suspected ports, not a leak. `ss` showed [24,000] TIME_WAIT sockets to the pricing service, and the code created a new `HttpClient` per request. I moved it to `IHttpClientFactory` and redeployed. **R:** Errors went to zero within minutes and p95 dropped by [70 ms]. Afterwards I added a TCP state dashboard and an analyser rule so it could not come back.

โ†ฉ Usually asked after

More in Operating System

Last updated 23 Oct 2026.

๐ŸŒ Sangam's Webring

โช Previous ๐Ÿ  Main Next โฉ
๐Ÿ’ฌ Chat with me