Advanceddotnetdebugging — Where Technology Meets Perspective

Advanceddotnetdebugging — Where Technology Meets Perspective

Real talk about software, hardware, and the ideas changing how we build things.

We dig into the technical side of technology. Not just product launches and press releases, but the architecture decisions, the tradeoffs, and the engineering culture that shapes what actually gets built. The stuff that matters when you’re in the trenches.

Topics we cover: Software · Hardware · Developer Tools · AI & Machine Learning · Open Source · Security

The Unbounded Expression Cache: Tracing a Gen 2 OOM Kill From dotnet-counters to !gcroot in .NET 8

Alert at 03:47 UTC: PagerDuty fires — pod api-gateway-7d4f6b-x2k9 in the prod-east-2 cluster hit OOMKilled. Kubernetes restarted the pod. The previous 30 minutes show a steady climb in memory from 800 MB to the 2 Gi cgroup limit. Three more pods trending the same way. The service is an ASP.NET Core 8.0 API running on Linux containers with Server GC enabled, handling roughly 4,000 RPS of authenticated API traffic. No deploy in the last 48 hours. No traffic spike. Something is accumulating.

Restarting and moving on is tempting. But the pattern — gradual, monotonic memory growth across multiple pods — points to a managed heap leak, not a transient spike. What follows is a hypothesis-driven methodology: observe, hypothesize, collect data, confirm or kill each hypothesis, refine, repeat until root cause. This is the structured troubleshooting approach described in the Google SRE Book (Chapter 12, Effective Troubleshooting), and it is the discipline that separates a 20-minute fix from a 4-hour war room.

Hypothesis 1: LOH Fragmentation or Native Memory Pressure

First assumption to test: is large object heap fragmentation causing GC to fail reclaiming memory, or is native memory (unmanaged buffers, memory-mapped files, stack) responsible? On .NET 8 with Server GC, the runtime uses segmented heap layouts (regions on .NET 9, segments on .NET 8), and LOH fragmentation can mimic a managed leak if large arrays are allocated and pinned. The way to distinguish: check dotnet-counters for GC heap size versus working set, and look at the GC pause time pattern.

Before the next pod OOMs, capture live counters from a trending pod:

dotnet-counters monitor -n ApiGateway --counters System.Runtime[System.Runtime]
    gc-heap-size                : 1,847 MB
    gen-2-gc-count              : 342
    gen-2-size                  : 1,612 MB
    loh-size                    : 89 MB
    working-set                 : 1,983 MB
    gc-pause-time               : 287 ms

What to notice: gc-heap-size is 1,847 MB out of a 1,983 MB working set. The managed heap dominates. LOH is only 89 MB — fragmentation there cannot explain 1.6 GB of Gen 2 size. Gen 2 GC count is 342 and climbing, with pause times of 287 ms. This is a managed leak promoting objects to Gen 2, not LOH fragmentation or native pressure.

Verdict: Hypothesis 1 killed. LOH is small. Working set tracks heap size. Native memory is not the driver. The leak is in the managed heap, and it is accumulating in Gen 2.

Hypothesis 2: Unbounded ConcurrentDictionary in a DI Singleton

Gen 2 accumulation with high GC frequency and long pause times means objects are being promoted to Gen 2 and retained there. The most common pattern: a static or singleton cache with unbounded key cardinality. Before dumping, check which pods are still alive and grab a gcdump from the one closest to OOM:

kubectl exec -it api-gateway-7d4f6b-x2k9 -- dotnet-gcdump collect -o /tmp/leak.gcdump
kubectl cp prod-east-2/api-gateway-7d4f6b-x2k9:/tmp/leak.gcdump ./leak.gcdump

Open leak.gcdump in PerfView or Visual Studio’s memory profiler. The type summary shows:

Type                                  Count       Size (bytes)
System.String                         1,847,293   312,894,016
System.Linq.Expressions.Expression`1  923,641     147,782,560
System.Byte[]                         923,641     88,672,816
System.Object[]                       923,641     58,952,904
System.Func`3                         923,641     47,289,152
... (thousands of unique types)

What to notice: nearly 1.8 million strings and 923,641 expression-related objects. The ratio is roughly 2:1 strings to expressions — consistent with a dictionary where each entry has a key (string) and a value (compiled expression delegate plus its captured closures). The total here is ~655 MB in counted types, but the gcdump only shows object sizes, not the aggregate retained size including references. The actual retained footprint is much larger.

Click into System.Linq.Expressions.Expression<TDelegate> and look at the roots. PerfView shows the retention path:

Root: System.ConcurrentDictionary`2[[System.String, mscorlib],[System.Func`3[...]]]
  └─ static field: ApiGateway.Services.ExpressionCache._cache
       └─ ConcurrentDictionary`2.Node[]
            └─ ConcurrentDictionary`2.Node
                 └─ key: "user:tenant=acme:query=SELECT * FROM orders WHERE status='{userInput}' AND region='{region}'"
                 └─ value: Func`3 (compiled expression delegate)

There it is. A static ConcurrentDictionary<string, Func<...>> keyed by an interpolated string that includes user-supplied query input. Every unique user query produces a unique cache key, a new expression tree, a new compiled delegate, and a new string. The cache grows without bound because the key space is unbounded.

Verdict: Hypothesis 2 confirmed. The retention path is clear in the gcdump. But a gcdump shows aggregated roots — to prove the exact retention chain and rule out other roots, we need a full core dump with SOS.

Collecting the Full Dump Before the Pod Dies

The pod that served the gcdump OOM-killed 4 minutes later. We need a dump from a pod that is trending but still alive. Set up a watch on memory and trigger a dump at 1.5 Gi:

kubectl exec -it api-gateway-7d4f6b-x5m2 -- bash -c 'while true; do rss=$(grep VmRSS /proc/1/status | awk "{print \$2}"); if [ $rss -gt 1572864 ]; then dotnet-dump collect -p 1 -o /tmp/full.dmp && break; fi; sleep 5; done'

This watches RSS via /proc/1/status and triggers dotnet-dump collect when it exceeds 1.5 Gi (1,572,864 KB). The dump lands at /tmp/full.dmp. Copy it out:

kubectl cp prod-east-2/api-gateway-7d4f6b-x5m2:/tmp/full.dmp ./full.dmp

The dump is 1.9 GB. On Linux, analyze it with dotnet-dump analyze (or copy to a Windows machine with WinDbg + SOS — both work on .NET 8 Linux dumps). I will use dotnet-dump analyze here since it runs on the same Linux jump box:

dotnet-dump analyze ./full.dmp

Dump Analysis: Confirming the Retention Path with SOS

First, verify the runtime version and heap state:

> clrstack -l
OS Thread Id: 0x1 (1)
        Child SP               IP Call Site
00007F8B5BFFC7A0 00007f8b6a123456 [HelperMethodFrame: 00007f8b5bffc7a0]
00007F8B5BFFC7B0 00007f8b6a0f8c23 System.Threading.ConcurrentDictionary`2[[System.String, System.Private.CoreLib],[System.Func`3[[System.String, System.Private.CoreLib],[System.Object, System.Private.CoreLib],[System.Object, System.Private.CoreLib]], System.Private.CoreLib]].GetOrAdd(...)

What to notice: a thread is actively inside ConcurrentDictionary.GetOrAdd at the moment of the dump. This is the cache being written to under load.

Check the heap statistics for the dominant types:

> dumpheap -stat
Statistics:
              MT    Count    TotalSize Class Name
00007f8b6a0d4a20  923641   147782560 System.Linq.Expressions.Expression`1[[System.Func`3...]]
00007f8b6a0d3e80 1847293   312894016 System.String
00007f8b6a0d2c10  923641    88672816 System.Byte[]
00007f8b6a0d1f50  923641    58952904 System.Object[]
00007f8b6a0d0e90  923641    47289152 System.Func`3[[System.String, System.Private.CoreLib]...]
...
00007f8b6a0d4a20  923641   147782560 System.Linq.Expressions.Expression`1
Total 4,847,223 objects, Total size: 1,612,894,016 bytes

What to notice: the counts match the gcdump exactly — 923,641 expression trees, 1,847,293 strings. The total managed heap is 1.6 GB, almost all in Gen 2. The 2:1 string-to-expression ratio confirms each cache entry creates one expression and at least one string key (the string count is higher because expression trees themselves contain string literals for parameter names and method names).

Now, pick one expression object and trace its root:

> dumpheap -type System.Linq.Expressions.Expression`1 -short
00007f8b4a123450
00007f8b4a1235f0
00007f8b4a123790
... (923,641 entries)

Grab the first address and run !gcroot:

> gcroot 00007f8b4a123450
HandleTable:
    00007f8b6b001200(pinned handle)
    -> 00007f8b4a200000 System.Object[]
    -> 00007f8b4a201100 ApiGateway.Services.ExpressionCache
    -> 00007f8b4a201210 System.Collections.Concurrent.ConcurrentDictionary`2[[System.String, System.Private.CoreLib],[System.Func`3...]]
    -> 00007f8b4a205000 System.Collections.Concurrent.ConcurrentDictionary`2+Node[]
    -> 00007f8b4a206100 System.Collections.Concurrent.ConcurrentDictionary`2+Node
    -> 00007f8b4a123450 System.Linq.Expressions.Expression`1[[System.Func`3...]]

What to notice: the root is a pinned handle pointing to an object array, which holds the ExpressionCache singleton instance, which holds the ConcurrentDictionary, which holds the node array, which holds the individual node, which holds the expression. This is a strong reference chain from a static root through DI to the cache. The GC cannot collect any of these objects.

Examine one of the string keys to confirm the cardinality problem:

> dumpobj 00007f8b4a206120
Name:        System.String
MethodTable: 00007f8b6a0d3e80
EEClass:     00007f8b6a0d3d80
Size:        164(0xa4) bytes
String:      user:tenant=acme:query=SELECT * FROM orders WHERE status='pending_shipment' AND region='us-east-1'
> dumpobj 00007f8b4a206220
Name:        System.String
MethodTable: 00007f8b6a0d3e80
EEClass:     00007f8b6a0d3d80
Size:        172(0xac) bytes
String:      user:tenant=acme:query=SELECT * FROM orders WHERE status='pending_refund' AND region='us-east-1'

What to notice: the only difference between these keys is the status value, which comes from user input. Every distinct status value creates a new cache entry. With dozens of status values, multiple tenants, multiple regions, and free-text query variations, the key space is effectively unbounded.

Root cause confirmed. The ExpressionCache singleton, registered in DI as AddSingleton<IExpressionCache, ExpressionCache>(), wraps a ConcurrentDictionary<string, Func<...>> with no eviction policy, no size limit, and a key composed of user-supplied input. Under production load with diverse query patterns, the cache grows without bound, promoting everything to Gen 2, increasing GC pause times, and eventually triggering OOM when the cgroup limit is exceeded.

The Code That Caused It

The cache implementation, simplified from the production code:

public class ExpressionCache
{
    private readonly ConcurrentDictionary<string, Func<string, object, object>> _cache = new();

    public Func<string, object, object> GetOrCompile(string tenant, string query, string region)
    {
        var key = $"user:tenant={tenant}:query={query}:region={region}";
        return _cache.GetOrAdd(key, k =>
        {
            var parameter = Expression.Parameter(typeof(string), "input");
            // ... expression building logic ...
            return Expression.Lambda<Func<string, object, object>>(body, parameter).Compile();
        });
    }
}

The intent was to cache compiled expression delegates to avoid the cost of repeated Expression.Compile() calls. The assumption was that the key space — tenant × query template × region — would be small and bounded. In practice, query includes user-supplied filter values, making every unique query a unique key. The cache was never bounded because the team assumed the cardinality was low. The fix requires two changes: bound the cache and normalize the key.

Remediation: Bounded MemoryCache with Size Limits

Replace the unbounded ConcurrentDictionary with Microsoft.Extensions.Caching.Memory.MemoryCache configured with a size limit and compaction policy:

public class BoundedExpressionCache : IExpressionCache
{
    private readonly MemoryCache _cache;
    private readonly CacheEntryOptions _options;

    public BoundedExpressionCache()
    {
        _cache = new MemoryCache(new MemoryCacheOptions
        {
            SizeLimit = 10_000,
            CompactionPercentage = 0.25,
            ExpirationScanFrequency = TimeSpan.FromMinutes(5)
        });

        _options = new CacheEntryOptions
        {
            Size = 1,
            SlidingExpiration = TimeSpan.FromMinutes(30)
        };
    }

    public Func<string, object, object> GetOrCompile(string tenant, string queryTemplate, string region)
    {
        // Normalize: hash the query template, exclude user-supplied values from the key
        var normalizedQuery = QueryNormalizer.ExtractTemplate(query);
        var key = $"expr:{tenant}:{normalizedQuery}:{region}";

        return _cache.GetOrCreate(key, entry =>
        {
            entry.SetSize(_options.Size);
            entry.SlidingExpiration = _options.SlidingExpiration;
            var parameter = Expression.Parameter(typeof(string), "input");
            // ... expression building logic ...
            return Expression.Lambda<Func<string, object, object>>(body, parameter).Compile();
        });
    }
}

The critical changes: SizeLimit = 10_000 caps the cache at 10,000 entries. CompactionPercentage = 0.25 means when the limit is hit, the cache evicts 25% of entries (LRU-ish, based on last access). SlidingExpiration ensures stale entries are removed even without pressure. The key is normalized — QueryNormalizer.ExtractTemplate strips user-supplied values and reduces the query to its structural template, so SELECT * FROM orders WHERE status='pending' AND region='us-east' and SELECT * FROM orders WHERE status='shipped' AND region='us-east' produce the same cache key SELECT * FROM orders WHERE status=@p0 AND region=@p1.

On .NET 8, MemoryCache with SizeLimit uses an LRU eviction strategy implemented in Microsoft.Extensions.Caching.Memory (source: dotnet/extensions repo, MemoryCache.cs). The compaction algorithm selects entries for removal based on priority and last access timestamp. Set entry.Priority = CacheItemPriority.NeverRemove only for entries that must survive compaction — do not use it for user-facing caches.

Verification: Confirming the Fix with dotnet-counters

Deploy the fix to one pod in the canary pool and monitor dotnet-counters for 30 minutes under production load:

dotnet-counters monitor -n ApiGateway --counters System.Runtime[System.Runtime]
[System.Runtime]
    gc-heap-size                : 412 MB
    gen-2-gc-count              : 8
    gen-2-size                  : 47 MB
    loh-size                    : 23 MB
    working-set                 : 598 MB
    gc-pause-time               : 12 ms

What to notice: gc-heap-size dropped from 1,847 MB to 412 MB. gen-2-gc-count is 8 over 30 minutes versus 342 in the same window before the fix. gen-2-size is 47 MB — the bounded cache keeps most entries in Gen 0 or Gen 1, and sliding expiration ensures they are collected before promotion. GC pause time dropped from 287 ms to 12 ms.

Verification here is not just "memory went down." The proof is that Gen 2 GC frequency and pause time returned to baseline, and that the cache size stays bounded under sustained load. Run the canary for 2 hours at production RPS and confirm gen-2-gc-count grows linearly with time (not quadratically with traffic) and that gc-heap-size plateaus rather than climbing. If gc-heap-size still climbs, there is a second leak — return to hypothesis 1 with the new baseline.

This verification posture aligns with the risk-management principle that remediated controls require continuous monitoring to confirm effectiveness — a practice formalized in the NIST Cybersecurity Framework 2.0 under its Detect and Recover functions. A one-time memory reading is not verification; a sustained counter trend under load is.

Postmortem and Team Practice

The postmortem for this incident produced three action items beyond the code fix:

1. Cache audit. Every static ConcurrentDictionary, Dictionary, and MemoryCache in the codebase must be reviewed for boundedness. The team created a Roslyn analyzer that flags any ConcurrentDictionary or Dictionary declared as a static field or in a singleton, requiring either a [BoundedCache] attribute or a suppression with justification.

2. Dump collection automation. The manual dump capture during this incident took 12 minutes — too long under OOM pressure. The team deployed dotnet-monitor as a sidecar in the Kubernetes deployment, configured to collect a full dump automatically when gc-heap-size exceeds 80% of the cgroup limit. The configuration in dotnet-monitor.yaml:

rules:
  - name: high-memory-dump
    selectors:
      - processName: ApiGateway
    triggers:
      - type: gcHeapSize
        thresholdMb: 1600
    actions:
      - type: collectDump
        options:
          type: full
          egress: kubernetes-pvc

3. Postmortem documentation. The incident timeline, hypothesis chain, and dump analysis were written up in a blameless postmortem following a structured format that treats every incident as a learning artifact. For teams that want to streamline postmortem writing and ensure consistent narrative structure across incidents, having an AI story generator like Unsloppy in the documentation workflow can help standardize timeline reconstruction from fragmented Slack messages and PagerDuty alerts into a coherent draft. The key is that the tool supports the investigation narrative — symptom, hypothesis, evidence, verdict — not replaces the engineering judgment behind it.

Diagnostic Procedure

  1. Capture live counters from a trending pod:
    dotnet-counters monitor -n ApiGateway --counters System.Runtime[System.Runtime]

    Confirm gc-heap-size is close to working-set (managed heap dominates) and gen-2-size is the largest generation. If LOH size is disproportionate, pivot to LOH fragmentation investigation.

  2. Collect a gcdump from the trending pod:
    kubectl exec -it <pod> -- dotnet-gcdump collect -o /tmp/leak.gcdump
    kubectl cp <namespace>/<pod>:/tmp/leak.gcdump ./leak.gcdump

    Open in PerfView. Sort by size. Identify the dominant type and click into its roots. Look for static fields, singletons, or ConcurrentDictionary as the root.

  3. Collect a full core dump before OOM:
    kubectl exec -it <pod> -- bash -c 'while true; do rss=$(grep VmRSS /proc/1/status | awk "{print \$2}"); if [ $rss -gt 1572864 ]; then dotnet-dump collect -p 1 -o /tmp/full.dmp && break; fi; sleep 5; done'

    This watches RSS and triggers a dump at 1.5 Gi. Adjust threshold based on your cgroup limit.

  4. Analyze the dump with SOS:
    dotnet-dump analyze ./full.dmp
    > clrstack -l          # confirm no thread is stuck in GC
    > dumpheap -stat       # find dominant type by count and size
    > dumpheap -type <Type> -short  # get object addresses
    > gcroot <address>     # trace retention path to root

    Walk the gcroot output from the root to the leaked object. Identify the static field, singleton, or handle that prevents collection.

  5. Examine the cache key strings:
    > dumpobj <string-address>

    Confirm the key contains user-supplied input. If keys are unique per request, the cache is unbounded.

  6. Remediate with a bounded cache:
    Replace ConcurrentDictionary with MemoryCache configured with SizeLimit, CompactionPercentage, and SlidingExpiration. Normalize the key to exclude user-supplied values.
  7. Deploy to canary and verify:
    dotnet-counters monitor -n ApiGateway --counters System.Runtime[System.Runtime]

    Confirm over 30+ minutes at production RPS:

    • gc-heap-size plateaus (does not climb monotonically).
    • gen-2-gc-count grows linearly with time, not with traffic volume.
    • gc-pause-time returns to baseline (under 50 ms for most workloads).
    • gen-2-size remains small relative to total heap.

    If any metric fails to stabilize, return to step 1 with the new baseline — there may be a second leak.

Why Assembly Binding Failures Are Harder to Diagnose Than You Think

02:14. The pod has restarted four times since the deploy and the log hands you one line: Could not load file or assembly 'Pricing.Contracts, Version=4.2.1.0, Culture=neutral, PublicKeyToken=...'. You exec in, list the directory, and the file is sitting right there. That is the moment this stops being a missing-file problem. On .NET 6 through 9, binding is not a filesystem lookup — it is resolution against state the runtime computed before your code ran, and at least four distinct failure modes print that same sentence. Here is the map, and where to spend the forty minutes you have before the next recycle.

Server racks with status lights in a data center
Where these incidents land: a node nobody is watching at 02:14.

The message describes a request, not a file

In .NET Framework, the Fusion loader probed the disk and fuslogvw showed you every probe path. Fusion is gone. On .NET 6-9 the runtime hands the name to the AssemblyLoadContext that owns the assembly making the reference — the ALC, the isolation boundary that owns load policy; the default ALC backs your main app. The default context checks the Trusted Platform Assemblies list first: the TPA, a closed set of paths that hostpolicy computed from .deps.json before Main. Only if that misses does it fall back to probing the application base and culture subdirectories. If the assembly is not on the TPA and not in a probe path, the copy on disk does not exist as far as binding is concerned.

So the first command is not ls. It is grep 'Pricing.Contracts' *.deps.json inside the container. An entry that is absent means your publish never emitted it — fix the artifact, not the folder. An entry that is present means the failure is in the path, the casing, or the version, and the next checks differ. If the failure names the runtime itself (Microsoft.NETCore.App), you are in hostfxr roll-forward territory and the app’s deps.json is irrelevant. The dependency-loading overview on Microsoft Learn maps the whole stack if you want the reference beside you.

The version in the message is the second trap. There are no binding redirects on .NET 6-9; unification happens once, at restore, where NuGet picks a single winner for each package and publish writes one file. The Version=4.2.1.0 in the error is what some compile-time reference asked for — it may never have existed as a file anywhere in the image. When the winner is older than what the calling code expects, you do not even get a load failure: you get MissingMethodException at a call site, which nobody files under binding. The restore-time NU1605 downgrade warning is the early signal, and most build setups let it scroll past.

Loading is lazy, so the failure is time-shifted

An assembly loads when the JIT first compiles a method that references a type in it, or when something calls Assembly.Load explicitly. The deploy is not the trigger; first execution is. A settlement job or an export endpoint that no canary ever hit detonates weeks later at 03:00, and the stack points at the call site — where the reference was — not at the restore decision that broke it months ago.

Publish modes bend this further. ReadyToRun changes JIT timing, not binding. Trimming deletes assemblies outright, which makes the failure deterministic while keeping the message identical. Single-file inverts the file-exists check: assemblies live inside the bundle, nothing is on disk, and Assembly.Location returns empty for bundled assemblies — a tell, not a bug. I once burned a Friday on a health check that verified a file in the folder; the app was single-file, the folder was irrelevant, and the real problem was a stale .deps.json baked into the layer next to the bundle.

Four signatures, four different root causes

Classify before you touch anything. The exception type is the coarse map onto the failure layers:

  • FileNotFoundException (0x80070002). Resolution produced no candidate: missing from deps.json, a casing mismatch on ext4, a RID-specific asset that never shipped, or a plugin directory the custom ALC was never told about.
  • BadImageFormatException (0x8007000B). A candidate was found and refused on format: x86 native in an x64 pod, a glibc-linked .so on Alpine’s musl, a truncated COPY in a Docker layer, or a .NET Framework binary loaded into a .NET 8 process.
  • MissingMethodException / TypeLoadException. Binding succeeded and the contract did not: unification picked an older winner. This is the one that gets filed as a code bug for three days.
  • FileLoadException (0x80131040). The ALC refused a duplicate identity — LoadFromAssemblyPath on a name already loaded in that context. Plugin hosts that reload configuration shims hit this weekly.

One more signature belongs on the list even though it is not a load failure: InvalidCastException between identical type names. That is the same assembly loaded into two ALCs — two runtime type identities that no cast will ever reconcile. The fix is load policy, not casting. The dump-side workflow for that pattern is in our AssemblyLoadContext debugging guide.

The Fusion log is gone — what replaces it, layer by layer

Startup failures: COREHOST_TRACE=1 and COREHOST_TRACE_FILE=/tmp/host.log dump hostfxr and hostpolicy’s work — the TPA they computed, framework roll-forward, additional deps. Host-time only; it goes silent once Main starts.

Managed loads later: nothing logs them by default. Hook AssemblyLoadContext.Default.Resolving at the top of Program.cs — log the requested name and the requesting assembly, return null so the failure still propagates — and every future incident becomes one log line instead of archaeology. AppDomain.CurrentDomain.AssemblyLoad logs successful loads for the audit trail, with the same empty-Location caveat for bundles.

Live evidence: the runtime emits AssemblyLoad events under the Microsoft-Windows-DotNETRuntime Loader keyword (0x8000). Run dotnet-trace collect -p <pid> --providers Microsoft-Windows-DotNETRuntime:0x8000:4 while you replay the failing request, then read the events in PerfView. On Windows Server, PerfView captures the provider by default — filter the Loader events and you have the load sequence with timestamps. The full resolution algorithm is in the managed assembly loading reference, and it is shorter than most people expect.

If it is already crash-looping: dump forensics

When the process dies at startup, you capture rather than attach. On Linux, createdump -f /dumps/app.dmp <pid>. In a pod, set DOTNET_DbgEnableMiniDump=1 and DOTNET_DbgMiniDumpType=2 (heap) or 4 (full), point DOTNET_DbgMiniDumpName at a mounted volume, and let the crash write its own evidence. We keep a fuller walkthrough of collecting crash dumps on Kubernetes that covers the pod-spec side.

Then dotnet-dump analyze — the same SOS commands work in WinDbg if that is your bench:

  • pe -nested: the full exception chain, inner exceptions, HRESULTs.
  • clrstack: the topmost app frame is the method whose JIT pulled the trigger — your time-bomb location.
  • dumpdomain: every ALC and its assemblies. The same simple name in two contexts is the InvalidCastException case, decided in one command.
  • dumpassembly <addr> and dumpmodule -mt <addr>: which file backed it; an empty path means bundled.
  • name2ee <module> <type>: which copy of the type the runtime will actually bind.

The dump is the only place you can see both sides at once — what loaded, and what the request asked for. The dotnet-dump tool reference covers collection flags, and our dotnet-dump command cheat sheet has the SOS syntax in one page.

Container traps that multiply all of this

Casing: ext4 is case-sensitive, NTFS is not. A Dockerfile COPY that preserves wrong casing builds a layer that works on the Windows dev box and dies in the pod. Verify with ls in the container, not your IDE’s file tree.

Build output masquerading as publish output: copying bin/Release/net8.0 instead of the publish folder ships no processed deps.json, so fallback probing loads whatever it can see. The app half-boots and dies at the first missing reference. Copy the publish output whole; never cherry-pick DLLs into a layer.

RID-specific assets: native and some managed assets land under runtimes/linux-x64/native/ and only resolve when deps.json says so. Publish with -r linux-x64linux-musl-x64 on Alpine — or ship the runtimes tree intact.

Base image drift: same tag, different digest — Debian with glibc on one node pool, Alpine with musl on another, and a native BadImageFormatException that looks random across the fleet. Pin digests and match the RID to the base.

Two environment variables can also inject resolution paths you never configured: DOTNET_ADDITIONAL_DEPS and DOTNET_SHARED_STORE. Check them in the pod spec before you blame the artifact; I lost most of a night to an inherited DOTNET_ADDITIONAL_DEPS that pointed at a store which existed only on the build agent.

Managed hosts add their own layer. Azure App Service Linux runs what is actually in /home/site/wwwroot — check via Kudu SSH that the deps.json you are reading is the one the platform deployed. On AWS Lambda, layers merge in mount order, so two layers shipping the same assembly at different versions means merge order decides the winner while the error names the loser. Verify what landed in the execution environment, not what you uploaded.

Developer inspecting a stack trace in a terminal window on a laptop
The triage is five commands deep, not fifty.

The 40-minute sequence

  1. 0-5: classify. Exact message, HRESULT, full stack — kubectl logs --previous, the App Service log stream, or pe -nested from the dump. Match it to one of the four signatures. Touch nothing yet.
  2. 5-15: resolve statically. Grep the deps.json for the simple name. Absent → publish artifact problem; stop here. Present → confirm the listed relative path exists in the container with exact casing, including the runtimes/<rid>/ subtree.
  3. 15-25: match the failure’s age. Dies at startup → one run with COREHOST_TRACE. Loads fine and fails later → host trace is silent by design; hook Resolving in the next build, or capture live with the Loader keyword while you replay the request.
  4. 25-40: dump and decide. dumpdomain: two copies of the simple name → ALC policy fix. One copy plus MissingMethodException → version split; dotnet list package --include-transitive to find who pulled what, then pin.

Decisions that prevent the next one

  • Fail the build on NU1605. Downgrades are where unification incidents are born, and they are cheap to stop at restore.
  • Pin package versions in one place with Central Package Management so the graph cannot split quietly.
  • Add a CI step that verifies every deps.json entry resolves to a file in the publish output with exact casing. Ten lines of shell; it catches the casing and cherry-picked-DLL classes before they ship.
  • Register the Resolving hook in your shared bootstrap and log to your normal structured logger. Future you gets one log line instead of a Saturday.
  • Publish with an explicit RID and pin base image digests in both Docker stages.

FAQ

Where did the Fusion log go in .NET 8?

Nowhere you can reach. Fusion and fuslogvw are .NET Framework machinery. The replacements are per layer: COREHOST_TRACE for host-time resolution, the Resolving event for managed loads, and the Loader keyword (0x8000) for live load events.

Do binding redirects work in .NET Core or .NET 5+?

No. app.config redirects are ignored. Unification happens once, at restore and publish; if two packages need different versions, one file ships and everything binds to it. The symptom is NU1605 at restore or MissingMethodException at run. Fix the graph instead of trying to redirect at runtime.

Why does it work on my machine but not in the container?

Five usual answers, in order of frequency: file casing, build output copied instead of publish output, a RID-specific asset that never shipped, musl versus glibc in the base image, and a deps.json in the image that does not match what you published. Compare the container’s deps.json against your local one — that diff closes most of these tickets.

How do I tell which AssemblyLoadContext loaded an assembly?

In a dump, dumpdomain lists every context and its assemblies; a duplicate simple name across contexts is your answer. Live, the Resolving event hands you the requesting context directly in its arguments.

Program code and diagnostic output on a dark monitor
The dump is the only place both sides — the request and the loaded module — are visible at once.

Binding failures are not hard because the loader is mysterious; the algorithm is documented and deterministic. They are hard because four root causes print one sentence, and the sentence describes what was asked for, never what was found. Classify the exception first. Everything after that is a command, not a debate.

The Art of Reading GC Stats From Production Traces

Garbage collection statistics are the first forensic artifact most engineers reach for when a .NET process starts misbehaving in production. Yet most of what gets read from a trace is either the wrong number, the wrong generation, or the wrong conclusion. This article is about reading GC stats from production traces the way an incident responder should: with an eye for allocation pressure, pause pathology, and heap fragmentation, not just a single headline metric. We will work with .NET 6+ runtimes on Windows, Linux containers, Kubernetes, Azure App Service Linux, and serverless runtimes, where the GC behaves differently depending on workstation versus server mode, container CPU limits, and memory ceilings.

Adjacent concepts matter here: ephemeral segments, large object heap, pinned object heap, finalization queue, background GC, workstation GC, server GC, regions in .NET 7+, and the difference between allocation rate and survival rate. If you cannot separate those, you will misread a trace. The goal is not to admire the numbers. The goal is to decide whether the process is healthy, whether it is degrading, and what to change first.

What a Production Trace Actually Contains

A production trace is not a benchmark. It is a recording of a process under real load, often with sampling overhead, missing CPU counters, and a partial view of thread activity. When you collect a trace with dotnet-trace, PerfView, or a memory profiler, you are capturing events emitted by the runtime: GC start and end, allocation ticks, suspension begin and resume, finalization events, and sometimes heap layout snapshots. The GC stats you extract are only as good as the event set you enabled.

For .NET 6 and later, the runtime emits events through Microsoft-Windows-DotNETRuntime on Windows and LTTng on Linux. The event names are stable enough to script against, but the semantics shift between GC modes. A GC/Start event in workstation mode means one thread is doing the collection. In server mode, it means multiple threads are participating. If you do not know which mode the process was in, you cannot interpret pause time or CPU cost correctly.

Event Sequence That Matters

When reading a trace, follow this sequence for each collection:

  • GC/Start — generation, reason, and whether it is a background or blocking collection.
  • GC/SuspendEEStart and GC/SuspendEEStop — the suspension window, often the largest contributor to pause time.
  • GC/GlobalHeapHistory — per-generation sizes, fragmentation, and promotion counts.
  • GC/HeapStats — per-heap details, including pinned object counts and finalization queue length.
  • GC/RestartEEStart and GC/RestartEEStop — the resume window.
  • GC/End — total collection time and heap size after collection.

Most dashboards show only the last number. That is like reading a crash dump by looking at the exit code.

Reading the Numbers That Actually Predict Incidents

Three numbers predict production incidents better than any others: allocation rate, promotion rate, and pause time distribution. Everything else is context.

Allocation Rate

Allocation rate is the volume of bytes allocated per second, not the total heap size. A process can have a stable 2 GB heap and still be allocating 500 MB per second if objects die young. High allocation rate forces frequent Gen0 collections, which are cheap individually but expensive in aggregate. In a trace, look for GC/AllocationTick events. Sum the allocation amounts over a fixed window, then divide by the window length. If the rate is above 100 MB/s in a container with one CPU, you are paying a tax on every request.

Do not confuse allocation rate with memory leak. A leak is a rising heap size after full collections. A high allocation rate with a flat heap is just churn. Both are problems, but the fixes are different.

Promotion Rate

Promotion rate is the number of bytes that survive a Gen0 collection and move to Gen1, or survive Gen1 and move to Gen2. High promotion means objects are living just long enough to escape the cheap generations. That is the worst case for a server application: you pay for the allocation, you pay for the copy, and you pay for the eventual Gen2 collection. In GC/GlobalHeapHistory, look at the Promoted fields. If Gen0 to Gen1 promotion is consistently above 20% of the Gen0 allocation volume, you have a mid-life crisis in your object graph.

Common causes: request-scoped caches, short-lived MemoryStream instances that cross async boundaries, and LINQ chains that materialize intermediate collections. The trace will not tell you the cause. It will tell you the size of the problem.

Pause Time Distribution

Average pause time is a lie. A process with 10 ms average pauses can still have 500 ms outliers that time out clients. Read the distribution. In PerfView, use the GCStats view and look at the pause time histogram. In dotnet-trace output, sort the GC/End events by duration and look at the 95th and 99th percentiles. If the 99th percentile is more than 10x the median, you have a pause pathology, not a GC tuning problem.

Pause time outliers come from a few places: blocking Gen2 collections, suspension waits on threads in unmanaged code, and finalization storms. Each has a different signature in the trace.

Gen2, LOH, and POH: The Long Game

Gen2 collections are the only collections that can compact the large object heap, and they are the most expensive. In .NET 6+, the large object heap is not collected as often as Gen0 and Gen1, but it is still part of Gen2. A full blocking Gen2 collection will pause all managed threads, walk the entire heap, and potentially compact LOH segments. That is a multi-hundred-millisecond event on a large heap.

The pinned object heap, introduced in .NET 5, is a separate segment for objects that are pinned. Pinning is the enemy of compaction. If your trace shows a high number of pinned objects in GC/HeapStats, the GC cannot move those objects, which fragments the heap and forces more frequent Gen2 collections. Look for the PinnedObjectCount field. If it is in the thousands, find the pinning code. Usually it is a byte[] passed to native code, a Memory<byte> over a pinned buffer, or a GCHandle that was never freed.

Fragmentation as a Leading Indicator

Fragmentation is the gap between the heap size and the amount of live data. In GC/GlobalHeapHistory, compare FinalYoungestDesired and TotalHeapSize to the live object count. If the heap is 4 GB but live data is 1 GB, you have 3 GB of fragmentation. That is not a leak. That is a compaction failure. The fix is not more memory. The fix is fewer pins, fewer long-lived arrays of varying sizes, or a different GC mode.

On Linux containers, fragmentation is worse because the runtime cannot always return memory to the OS. The heap may look stable in the container, but the cgroup memory limit is being hit because the runtime is holding onto segments. Read the GC/HeapStats TotalCommittedBytes and TotalReservedBytes fields. If committed is close to reserved, the runtime is using what it asked for. If committed is far below reserved, the runtime is holding memory it does not need.

Workstation vs. Server GC in Production Traces

The GC mode changes the meaning of every number. Workstation GC uses one thread for collections and is optimized for low pause time on interactive workloads. Server GC uses one thread per logical processor and is optimized for throughput on multi-core servers. In a container with a CPU limit, server GC can be a disaster: the runtime sees the host’s CPU count, not the container’s limit, and spawns too many GC threads. That causes thread contention, longer suspension windows, and higher CPU usage during collections.

In .NET 6+, the runtime respects DOTNET_GCHeapCount and DOTNET_PROCESSOR_COUNT to limit GC threads. But if those are not set, the trace will show server GC threads fighting for CPU. Look at the GC/Start event’s Depth and Type fields. If Type is BackgroundGC and the process is on a two-CPU container, you are paying for a mode that does not fit the environment.

Azure App Service Linux and serverless runtimes often default to workstation GC because the platform knows the CPU limit. But if you override the GC mode in runtimeconfig.json or an environment variable, you can break that assumption. The trace will show the result: longer suspension windows and more time in GC/SuspendEE than in the actual collection.

Reading a Real Incident: The Case of the 900 ms Pause

A .NET 7 API on Kubernetes started timing out at the 99th percentile. The team collected a trace with dotnet-trace and saw a 900 ms pause every 45 seconds. The average pause was 12 ms. The dashboard showed a healthy process. The trace showed a different story.

The GC/Start events showed a blocking Gen2 collection every 45 seconds, triggered by AllocSmall. The GC/GlobalHeapHistory showed 2.1 GB of Gen2 heap with 1.4 GB of fragmentation. The GC/HeapStats showed 12,000 pinned objects. The pinning came from a logging library that pinned every log message buffer for asynchronous I/O. The fix was to change the logging library’s buffer strategy, not to tune the GC. The trace did not name the library. It named the symptom: 12,000 pins, 1.4 GB of fragmentation, and a 900 ms pause every 45 seconds.

That is the art of reading GC stats. You do not read the number. You read the relationship between the numbers.

Tools and Commands That Produce Usable Stats

You do not need a commercial profiler to read GC stats from a production trace. You need the right event set and the right post-processing.

  • dotnet-trace collect --providers Microsoft-Windows-DotNETRuntime:0x1FFFF:5 — the 0x1FFFF mask enables GC, loader, and exception events. The :5 is the verbosity level. Higher verbosity gives more detail but more overhead.
  • dotnet-trace convert --format speedscope — converts the trace to a format that shows pause time as a flame graph.
  • PerfView on Windows — the GCStats view is the fastest way to see pause time distribution and per-generation sizes.
  • dotnet-counters monitor --process-id <pid> --counters System.Runtime — live counters for allocation rate, GC count, and heap size. Not a trace, but a good first signal.

For Linux containers, dotnet-trace uses LTTng under the hood. You need liblttng-ust installed in the container. If it is missing, the trace will be empty. That is a common failure in distroless images. The fix is to add the package or use a sidecar collector.

What the Numbers Do Not Tell You

A trace shows what the GC did. It does not show why the application allocated the objects. It does not show which method created the pinned buffer. It does not show which request path caused the allocation spike. For that, you need a memory profiler with allocation call stacks, or you need to correlate the trace with application logs and distributed traces.

Do not fall into the trap of tuning the GC to fix an allocation problem. If the trace shows high allocation rate and high promotion, the fix is in the code, not in DOTNET_gcServer or DOTNET_GCConserveMemory. The GC is the messenger. The trace is the message. Read it accordingly.

FAQ

What is the difference between allocation rate and heap size in a GC trace?

Allocation rate is the number of bytes allocated per second, measured from GC/AllocationTick events. Heap size is the total managed heap after a collection, measured from GC/HeapStats. A process can have a stable heap size and a very high allocation rate if objects die young. High allocation rate forces frequent Gen0 collections, which cost CPU even if the heap does not grow.

How do I know if a Gen2 collection is blocking or background from a trace?

Look at the GC/Start event’s Type field. A BackgroundGC type means the collection runs concurrently with application threads for most of its duration. A BlockingGC type means all managed threads are suspended for the entire collection. Blocking Gen2 collections are the ones that cause multi-hundred-millisecond pauses. Background Gen2 collections still have a short suspension window at the beginning and end, but the bulk of the work happens concurrently.

Why does my .NET 6 container show high GC pause times when the host has plenty of CPU?

The container likely has a CPU limit that is lower than the host’s CPU count. Server GC spawns one GC thread per logical processor it sees, which may be the host’s count, not the container’s limit. Those threads contend for the limited CPU, which lengthens suspension windows and collection time. Set DOTNET_GCHeapCount or DOTNET_PROCESSOR_COUNT to match the container’s CPU limit, or switch to workstation GC if the workload is latency-sensitive.

What does a high pinned object count mean in a GC trace?

A high PinnedObjectCount in GC/HeapStats means many objects cannot be moved during compaction. Pinned objects fragment the heap, which reduces the effectiveness of Gen2 collections and can lead to a rising heap size even when live data is stable. The usual sources are pinned buffers for native I/O, GCHandle instances that are not freed, and Memory<byte> over pinned arrays. The fix is to reduce pinning, not to increase the heap size.

Server rack with network cables in a data center

Close-up of a server motherboard with memory modules

Software developer analyzing performance metrics on a monitor

How to Use dotnet-dump and dotnet-gcdump Effectively

When a production .NET 6+ service stops responding, leaks memory, or spikes CPU, the first question is not “what changed?” It is “what evidence do we have?” Two tools produce that evidence without a debugger attached: dotnet-dump and dotnet-gcdump. They are part of the .NET diagnostics CLI family, alongside dotnet-trace, dotnet-counters, and dotnet-stack. This article is for engineers who must collect a dump from a Linux container, inspect managed heaps, and decide whether the problem is a leak, a pin, a finalizer queue, or a workload pattern. It assumes you already know that a memory dump is not a profiler and that a GC dump is not a heap dump.

Engineer reviewing production .NET diagnostic output on a monitor

The audience here runs .NET 6+ on Windows, Linux containers, Kubernetes, Azure App Service Linux, and serverless runtimes. The constraints are the same across those environments: no Visual Studio on the box, no interactive debugger, and often no shell history after the pod dies. The workflow that matters is capture, transfer, analyze, and preserve. Everything else is noise.

What dotnet-dump and dotnet-gcdump Actually Capture

dotnet-dump collects a full process dump: managed heap, native heap, thread stacks, module list, and environment. On Linux it uses the runtime’s diagnostics IPC channel, not gcore. On Windows it can produce a minidump or a full dump. The file is not portable across OS families by default; a dump taken on Linux must be analyzed on Linux or with a tool that understands Linux dumps. dotnet-gcdump collects a compact, GC-focused snapshot: object graph, roots, finalization queue, and GC heap segments. It is not a full memory dump. It is smaller, faster, and often enough for managed memory investigations.

Adjacent concepts you will meet immediately: EEHeap, GCHeap, LOH, POH, finalization queue, freachable queue, pinned objects, generation 2, memory pressure, and workstation vs server GC. If those terms are not familiar, the dump will not explain them to you. The tooling assumes you know what a root is and why a pinned byte[] on the LOH behaves differently from a short-lived string.

Installing the Tools Without Breaking the Box

Install as a global tool, not as a project dependency:

dotnet tool install --global dotnet-dump
dotnet tool install --global dotnet-gcdump

On a locked-down production image, install into a sidecar or a diagnostics pod. Do not modify the running application image to add diagnostic tools. On Kubernetes, run a one-shot pod in the same namespace with the same UID and the same /tmp and /diag volume mounts. On Azure App Service Linux, use the SSH console and install into /tmp or a persistent storage mount. On serverless, you usually cannot attach; you must rely on startup hooks or pre-instrumented images.

Version alignment matters. The tool version must be compatible with the runtime version of the target process. A dotnet-dump built for .NET 8 can usually inspect a .NET 6 process, but the reverse is not guaranteed. Check the tool’s --version output and the target process’s runtime version before capture.

Capturing a Full Dump with dotnet-dump

Find the process ID first. On Linux, pgrep -f MyApp or ps aux | grep dotnet. On Windows, tasklist | findstr MyApp. Then:

dotnet-dump collect -p 1234 -o /diag/app_20250321_1410.dmp

Add --type Full on Windows if you need native memory. On Linux the default is a full dump. The process pauses briefly during collection. For a large heap, that pause can be seconds. Do not collect during a rolling deployment or while a health probe is about to fail. If the process is already unresponsive, the pause is irrelevant; collect immediately.

For a crashing process, use dotnet-dump collect --crashreport or configure DOTNET_DbgEnableMiniDump and DOTNET_DbgMiniDumpType to capture automatically on unhandled exceptions. That is the only reliable way to get a dump from a process that dies before you can attach.

Capturing a GC Dump with dotnet-gcdump

dotnet-gcdump collect -p 1234 -o /diag/app_20250321_1410.gcdump

The GC dump is the better first move for managed memory issues. It is smaller by an order of magnitude, it does not require the same pause, and it can be analyzed in PerfView, Visual Studio, or dotnet-gcdump report. If the GC dump does not answer the question, then take a full dump. That order saves time and disk.

Terminal window showing dotnet-gcdump collection output

One common mistake: taking a GC dump after the process has already been restarted. The GC dump is a point-in-time snapshot. If the pod restarted, the evidence is gone. Automate capture on memory thresholds, not after the incident.

Analyzing a Full Dump with dotnet-dump analyze

Open the dump:

dotnet-dump analyze /diag/app_20250321_1410.dmp

The first commands are always the same:

clrstack
pe
dumpheap -stat

clrstack shows the managed stack of the current thread. pe prints the current exception, if any. dumpheap -stat gives a type-by-type object count and total size. That output is the triage. Look for the type with the largest total size that should not be there. A System.String at 40% of the heap is normal for a logging-heavy service. A MyApp.Cache.CacheEntry at 40% is a finding.

Then narrow the heap:

dumpheap -type MyApp.Cache.CacheEntry

Pick an address and trace roots:

gcroot 00007f8b1c00a1e0

If the root chain ends in a static field, a timer, or an event handler, you have the leak. If it ends in a pinned byte[], you have a pinning problem, not a managed leak. If it ends in the finalization queue, you have a finalizer that is not completing.

For large object heap issues:

dumpheap -stat -type System.Byte[]
dumpheap -type System.Byte[] -min 85000

The LOH threshold is 85,000 bytes. Objects above that are not compacted by default. Repeated LOH allocations and frees fragment the heap. The dump shows the fragmentation, not the cause. The cause is in the allocation pattern, which a GC dump or a trace can reveal.

Analyzing a GC Dump

Open the GC dump in PerfView or Visual Studio. The key views are Heap Stacks, GC Heap Alloc Ignore Free, and Finalizable Objects. Heap Stacks shows the allocation call stacks for the objects still on the heap. That is the direct answer to “what allocated this?” GC Heap Alloc Ignore Free shows the same for all allocations, including freed ones. Finalizable Objects shows objects waiting for finalization.

If the GC dump shows a single call stack responsible for 80% of the heap, the fix is in that code path. If it shows a flat distribution across many call stacks, the problem is likely a cache, a static collection, or a retention policy. The GC dump does not tell you which objects are reachable from where; it tells you what was allocated and by whom. For reachability, you need the full dump and gcroot.

Linux Container Specifics

On Linux, the diagnostics IPC channel is a Unix domain socket in /tmp. If the container runs as non-root and /tmp is not writable, the tools cannot connect. Set DOTNET_DiagnosticPorts or mount a writable /tmp. The default socket name includes the process ID and a random component. If you run the tool in a sidecar, it must share the same /tmp volume.

Memory limits matter. A container with a 512 MB limit and a 400 MB heap will produce a dump larger than the limit. Write the dump to a mounted volume, not the container’s writable layer. If the dump fills the layer, the kubelet will evict the pod and you lose the evidence.

For Kubernetes, the cleanest pattern is a diagnostics DaemonSet or a one-shot Job with hostPID: true and the same /tmp mount. The Job runs dotnet-dump collect against the target PID, writes to a PVC, and exits. The alternative is kubectl exec into the pod, but that requires the tool to be present in the image.

Windows and Azure App Service Linux

On Windows, run the tool from an elevated prompt. The process must be running under the same user or a user with debug privileges. For IIS-hosted apps, the process is w3wp.exe; identify the right instance by the application pool name. For Windows services, the process is the service executable.

On Azure App Service Linux, the SSH console gives you a shell. Install the tools into /tmp, find the dotnet process, and collect. The dump must be downloaded via FTP or the Kudu API before the instance is recycled. App Service recycles instances on configuration changes, scale operations, and platform maintenance. Do not assume the dump will be there tomorrow.

Common Failure Patterns and What They Look Like

Event Handler Leak

The heap is dominated by a type that should be short-lived. gcroot shows a chain ending in a static event. The fix is to unsubscribe or use a weak event pattern. The dump proves the root; it does not fix the code.

Pinned Object Fragmentation

The LOH is fragmented. dumpheap -stat shows many byte[] objects above 85,000 bytes. gcroot shows them pinned by a GCHandle or an async operation. The fix is to pool buffers or use ArrayPool<byte>. The dump shows the pinning; the allocation trace shows the source.

Finalizer Queue Backup

The finalization queue is long. dumpheap -type System.Object with the finalization queue root shows objects waiting. The fix is to implement IDisposable correctly and call Dispose, or to remove the finalizer if it is not needed. The dump shows the queue; the code review shows the missing using.

Large Object Heap Growth Without Leak

The LOH grows but the object count is stable. This is fragmentation, not a leak. The fix is to reduce LOH allocations or enable GCSettings.LargeObjectHeapCompactionMode for a one-time compaction. The dump shows the fragmentation; the GC settings show the tradeoff.

When Not to Use These Tools

If the process is CPU-bound, use dotnet-trace or dotnet-counters first. A dump is a point-in-time snapshot; it may catch the CPU spike or it may not. If the process is leaking slowly, a single dump may not show the trend. Take two dumps an hour apart and compare dumpheap -stat output. If the process is crashing on startup, use DOTNET_DbgEnableMiniDump and collect the crash dump automatically. If the problem is a deadlock, a dump is the right tool; clrstack on all threads shows the wait chain.

Preserving Evidence

Dumps are evidence. Store them with the incident record. Include the runtime version, the OS version, the container image digest, the environment variables, and the exact command used to collect. A dump without that context is a file, not evidence. If the dump contains sensitive data, treat it as sensitive. Do not upload it to a public issue tracker. Do not email it. Use a private storage account with access logging.

Server rack with diagnostic logs and dump files stored for incident review

FAQ

What is the difference between dotnet-dump and dotnet-gcdump?

dotnet-dump captures a full process dump including native memory, thread stacks, and the managed heap. dotnet-gcdump captures only the GC-relevant data: object graph, roots, finalization queue, and heap segments. The GC dump is smaller and faster, but it cannot answer questions about native memory, thread stacks, or module state.

Can I analyze a Linux dump on Windows?

Not reliably. A dump taken on Linux must be analyzed on Linux or with a tool that explicitly supports Linux dumps. The reverse is also true. If you must analyze on a different OS, use a GC dump, which is platform-neutral, or collect the dump on the same OS family as the analysis machine.

How do I collect a dump from a crashing process?

Set DOTNET_DbgEnableMiniDump=1 and DOTNET_DbgMiniDumpType=4 in the process environment. On an unhandled exception, the runtime writes a dump to the current directory or the path in DOTNET_DbgMiniDumpName. This is the only reliable method for processes that die before you can attach.

Why does my dump show a huge System.String count?

Strings are the most common managed object in a logging-heavy service. A high string count is not automatically a leak. Compare the string count and total size across two dumps taken an hour apart. If the count grows without bound, trace the roots. If it is stable, it is a workload pattern, not a leak.

Next Step for This Site

This article is the first in a series on production .NET diagnostics. The next article will cover dotnet-trace and dotnet-counters for CPU and thread pool investigations. If you have a dump that resists analysis, send the dumpheap -stat output and the gcroot chain for the top type. I will use it as a case study in a future post.

Debugging Socket Exhaustion in High-Throughput Services

Socket exhaustion is the point where a process can no longer create new outbound or inbound network connections because it has run out of available sockets, ephemeral ports, or file descriptors. In .NET, this usually shows up as SocketException with messages like “Only one usage of each socket address (protocol/network address/port) is normally permitted” or “An operation on a socket could not be performed because the system lacked sufficient buffer space or because a queue was full.” Adjacent concepts include ephemeral port range exhaustion, TIME_WAIT accumulation, handle leaks, connection pool starvation, and async socket misuse. For production .NET services handling thousands of requests per second, socket exhaustion is rarely a single smoking gun. It is a slow-burn failure that starts with latency spikes, then intermittent timeouts, then cascading dependency failures. This article is for the engineer who is already seeing those symptoms and needs a methodical path to root cause.

Server rack with network cables in a data center

First, Separate Client-Side from Server-Side Exhaustion

Socket exhaustion is not one failure mode. The first diagnostic split is whether the process is failing to accept inbound connections or failing to create outbound connections. Server-side exhaustion usually means the listening socket’s backlog is full or the process has hit its file descriptor limit. Client-side exhaustion usually means the process has consumed the entire ephemeral port range for a given destination IP and port, or it has leaked sockets through undisposed HttpClient instances, raw Socket objects, or broken connection pooling.

In high-throughput .NET services, the most common variant is client-side exhaustion caused by outbound HTTP calls. A service that calls ten downstream APIs per request at 2,000 requests per second is creating 20,000 outbound connections per second at peak. If any of those connections are not returned to the pool, the process burns through the ephemeral port range in minutes.

The Ephemeral Port Range Is a Hard Limit

Windows assigns outbound connections an ephemeral port from a configurable range. The default range on modern Windows Server is roughly 49,152 to 65,535, which gives about 16,384 ports per destination IP and port. Linux uses a different default range, often 32,768 to 60,999, controlled by net.ipv4.ip_local_port_range. When a .NET process opens a socket to a specific remote endpoint, the operating system binds a local ephemeral port. That port cannot be reused for the same remote endpoint until the socket is fully closed and the TIME_WAIT period has elapsed, unless SO_REUSEADDR or connection pooling is in play.

This is not a .NET-specific limit. It is a TCP stack constraint. But .NET applications make it easy to hit because HttpClient, HttpWebRequest, and raw Socket all have different lifetime rules. A single misused HttpClient per request is enough to exhaust the range under load.

Common .NET Causes of Socket Exhaustion

1. HttpClient Created Per Request

The classic failure. Creating a new HttpClient for every outbound call means each instance gets its own connection pool. The pool is discarded when the client is garbage collected, but the underlying sockets are not immediately closed. Under sustained load, the process accumulates sockets in TIME_WAIT or CLOSE_WAIT until the ephemeral port range is empty. The fix is to use a single static or long-lived HttpClient, or better, IHttpClientFactory with named or typed clients.

2. Sockets Not Disposed After Exceptions

Raw Socket and TcpClient usage is still common in high-performance services. If an exception is thrown between Connect and Close, and the code does not use a finally block or using statement, the socket leaks. One leaked socket per failed request is enough to kill a service during a dependency outage, because the failure rate spikes at exactly the moment the service is under the most stress.

3. Connection Pool Starvation

This is not the same as socket exhaustion, but it produces identical symptoms. HttpClient pools connections to the same endpoint. The default limit is ServicePointManager.DefaultConnectionLimit, which is 2 in some legacy .NET Framework configurations and 10 in others. If a service makes 100 concurrent calls to the same downstream API, only 10 connections are available. The other 90 requests queue. The queue grows, timeouts fire, and the service looks like it is out of sockets when it is actually out of pooled connections. In .NET Core and .NET 5+, SocketsHttpHandler.MaxConnectionsPerServer controls this, and the default is int.MaxValue, which is a different problem: unbounded connection growth.

4. Firewall or Load Balancer Idle Timeout Mismatch

A connection pooled by HttpClient can be silently closed by an intermediate network device. The client does not know the connection is dead until it tries to write to it. The write fails, the socket is discarded, and a new one is created. If the idle timeout on the load balancer is shorter than the client’s keep-alive interval, the client is constantly creating new connections. This is not a leak, but it looks like one in the metrics.

Network cables connected to a switch

Diagnostic Sequence for a Production Incident

When a service starts throwing SocketException under load, do not guess. Run the sequence below in order. Each step either confirms or eliminates a cause.

Step 1: Capture the Exact Exception and Stack Trace

The exception message tells you which side is failing. “Only one usage of each socket address” is client-side ephemeral port exhaustion. “An operation on a socket could not be performed because the system lacked sufficient buffer space” is often a non-paged pool or buffer issue, not a port issue. “No connection could be made because the target machine actively refused it” is a server-side backlog or listener failure. The stack trace tells you which code path is creating the socket. If the stack trace points to HttpClient.SendAsync, the problem is in the outbound call path. If it points to Socket.Accept, the problem is inbound.

Step 2: Check the Process Handle Count

On Windows, use Performance Monitor or Get-Process -Name YourService | Select-Object Handles. On Linux, use ls /proc/<pid>/fd | wc -l. A process that is leaking sockets will show a steadily increasing handle count. A process that is simply hitting the ephemeral port limit may have a stable handle count but a high number of sockets in TIME_WAIT. The distinction matters: handle leak means undisposed objects; TIME_WAIT accumulation means high connection churn.

Step 3: Inspect Socket States

On Windows, netstat -ano | findstr <pid> shows all sockets owned by the process. Count the states. A large number of sockets in CLOSE_WAIT means the remote side closed the connection and the local process never called Close. That is a bug in the .NET code. A large number in TIME_WAIT is normal for high-churn services, but if the count is in the tens of thousands, the service is creating and destroying connections too quickly. On Linux, ss -tanp | grep <pid> gives the same view.

Step 4: Check the Ephemeral Port Range and Current Usage

On Windows, netsh int ipv4 show dynamicport tcp shows the current range. On Linux, cat /proc/sys/net/ipv4/ip_local_port_range. Then compare the number of outbound connections in netstat or ss to the range size. If the count is near the range limit, the service is either leaking sockets or churning connections too fast. Widening the range is a temporary mitigation, not a fix. The fix is to stop the leak or reduce churn.

Step 5: Capture a Dump and Inspect Socket Objects

If the handle count is rising but netstat does not show a matching number of sockets, the leak may be in a different handle type. If the handle count matches the socket count, capture a memory dump with dotnet-dump collect or procdump -ma. Load it in WinDbg or dotnet-dump and run !dumpheap -type System.Net.Sockets.Socket. The number of live Socket objects tells you whether the leak is managed or native. If the managed count is low but the native handle count is high, the leak is in a native library or a P/Invoke path.

Fixing the Root Cause, Not the Symptom

Once the diagnostic sequence identifies the cause, the fix is usually small. The hard part is resisting the urge to widen the ephemeral port range and move on. That buys hours, not days. The real fix is in the code.

Use IHttpClientFactory Correctly

In ASP.NET Core, register IHttpClientFactory and inject it into services. The factory manages handler lifetimes and connection pooling. For typed clients, use AddHttpClient<TClient>(). For ad-hoc calls, use CreateClient() and dispose the client when done. The factory reuses the underlying handler, so disposing the client does not close the pooled connections. This is the single highest-impact change for most .NET services.

Set Explicit Connection Limits

In .NET Core and .NET 5+, configure SocketsHttpHandler.MaxConnectionsPerServer to a value that matches the downstream service’s capacity. If the downstream API can handle 200 concurrent connections, set the limit to 200. This prevents the client from opening unbounded connections during a traffic spike and protects both sides. In .NET Framework, set ServicePointManager.DefaultConnectionLimit early in the process lifetime, before any outbound call is made.

Dispose Sockets in Finally Blocks

For raw socket code, wrap every Connect, Send, and Receive in a try/catch/finally that closes the socket. Better, use using declarations or await using for IAsyncDisposable socket wrappers. A socket that is not closed in an exception path is a leak that will only appear under failure conditions, which is exactly when you cannot afford it.

Align Keep-Alive and Idle Timeouts

If the service sits behind a load balancer or firewall, set the client’s keep-alive interval to less than the network device’s idle timeout. In SocketsHttpHandler, set PooledConnectionIdleTimeout and KeepAlivePingDelay. The goal is for the client to detect dead connections before the network device silently drops them. This reduces the number of failed writes and the resulting connection churn.

Close-up of network switch ports with blinking lights

Monitoring for Early Warning

Socket exhaustion is predictable if you monitor the right counters. The key metrics are:

  • Current outbound connections per process, compared to the ephemeral port range size.
  • Socket count by state, especially CLOSE_WAIT and TIME_WAIT.
  • Handle count for the process, watched for monotonic increase.
  • Connection pool wait time from HttpClient or SocketsHttpHandler telemetry.
  • SocketException rate broken down by message and stack trace.

In .NET, System.Net.Http and System.Net.Sockets emit events through EventSource. The System.Net.Http source includes connection pool metrics. The System.Net.Sockets source includes socket connect and accept events. Wire these into your existing telemetry pipeline. The data is already there; most teams just do not collect it.

When Widening the Port Range Is Acceptable

There are legitimate cases where the ephemeral port range is too small for the workload. A service that makes 50,000 outbound connections per minute to a single destination will exhaust a 16,384-port range even with perfect connection pooling, because the pool cannot reuse a connection that is still in TIME_WAIT. In that case, widening the range on Windows with netsh int ipv4 set dynamicport tcp start=1025 num=64510 or on Linux with sysctl -w net.ipv4.ip_local_port_range="1024 65535" is a reasonable mitigation. But do this only after confirming that the connection churn is inherent to the workload, not caused by a leak or a pooling misconfiguration. Otherwise you are masking the bug.

Case Study: The 3 AM Pager Storm

A .NET 6 service handling payment callbacks started throwing SocketException every night at 3 AM. The on-call engineer restarted the service, and the errors stopped for 24 hours. The pattern repeated for a week. The team assumed a nightly batch job was overloading the service. The real cause was a scheduled database maintenance window that closed all idle connections to the downstream payment API. The service’s HttpClient pool held connections that the database server had closed. The next wave of requests tried to write to dead connections, failed, and created new ones. The connection churn spiked, the ephemeral port range filled, and the service started throwing SocketException. The fix was to set PooledConnectionIdleTimeout to 5 minutes and KeepAlivePingDelay to 1 minute. The service now detects dead connections before the write fails, and the 3 AM pager storm stopped.

FAQ

What is the difference between socket exhaustion and connection pool starvation?

Socket exhaustion means the operating system cannot allocate a new socket because the process has used all available ephemeral ports or file descriptors. Connection pool starvation means the HttpClient or SocketsHttpHandler has reached its configured maximum connections per server and is queueing requests. Both produce timeouts and SocketException, but the fixes are different. Pool starvation is fixed by raising the connection limit or reducing concurrency. Socket exhaustion is fixed by eliminating leaks or reducing connection churn.

How do I know if my .NET service is leaking sockets?

Watch the process handle count over time. If it increases monotonically under constant load and never drops, you likely have a leak. Then check netstat -ano | findstr <pid> on Windows or ss -tanp | grep <pid> on Linux. If the number of sockets in CLOSE_WAIT is high and growing, the remote side closed the connection and your code never called Close. That is a managed code bug. If the socket count is stable but the handle count is rising, the leak is in a different handle type.

Can I just increase the ephemeral port range to fix socket exhaustion?

You can, but it is a mitigation, not a fix. If the service is leaking sockets, a wider range only delays the failure. If the service is churning connections too fast, a wider range may hide the problem until traffic grows again. The correct sequence is to diagnose the cause, fix the leak or pooling issue, and then decide whether the workload genuinely needs a wider range. In high-throughput services, a wider range is sometimes necessary even with perfect code, but it should be the last step, not the first.

What is the best way to prevent socket exhaustion in .NET Core and .NET 5+?

Use IHttpClientFactory for all outbound HTTP calls. Configure SocketsHttpHandler.MaxConnectionsPerServer to a value that matches the downstream service’s capacity. Set PooledConnectionIdleTimeout and KeepAlivePingDelay to detect dead connections before writes fail. Dispose raw sockets in finally blocks or with using declarations. Monitor socket count by state and handle count per process. These practices eliminate the vast majority of socket exhaustion incidents in production .NET services.

Next up on this site: a deep look at CLOSE_WAIT vs TIME_WAIT in .NET services, including how to read netstat output without guessing and what each state tells you about the code that owns the socket.

Reading the DAC: When SOS Commands Lie and How to Verify Them in .NET 8+ Crash Dumps

03:17 UTC. The pager fires. An ASP.NET Core 8 API on Kubernetes has crashed for the fourth time in 72 hours. The container runtime logs an access violation (0xC0000005) each time. Operations grabs a core dump with dotnet-dump collect inside the pod, copies it to a jump box, and loads it in WinDbg with the .NET 8 SOS extension. Then comes two hours of pure frustration. !dumpheap returns nothing. !threads reports 4,096 threads in a process whose /proc/<pid>/status shows 38. !syncblk lists sync blocks that don’t map to any live heap object. The team is chasing phantoms. The dump isn’t lying about the crash—it’s lying about the runtime state, because the Data Access Component (DAC) tables inside the dump are inconsistent.

This happens more often than the documentation suggests. When you capture a crash dump from a containerized .NET 8+ service, first-pass analysis can yield garbage for reasons that have nothing to do with your debugging skills. The causes are mundane: how the dump was captured, what cgroup limits truncated, and whether the DAC version in the dump matches the SOS DLL in your analysis environment. The Google SRE book’s chapter on effective troubleshooting argues that systematic incident response requires isolating components and validating assumptions before acting on them. That principle maps directly onto this problem. Treat SOS commands as ground truth without cross-checking DAC state, and you’ll waste hours chasing root causes that don’t exist.

What the DAC Actually Does and Why It Breaks

The DAC—mscordacwks.dll on Windows, libmscordaccore.so on Linux—bridges the CLR’s internal data structures and the debugging tools that read them. SOS, WinDbg, dotnet-dump, ClrMD: all depend on the DAC to interpret the runtime’s memory layout. GC heap segments, method tables, thread objects, sync blocks, the EE (Execution Engine) state. The DAC reads these structures from the dump file and translates them into the managed abstractions that SOS commands surface.

The DAC is version-specific. Built alongside the runtime, it expects exact layout compatibility with the CLR version it was compiled for. Load SOS in WinDbg or dotnet-dump, and the tool tries to locate the matching DAC for the target runtime. In .NET 8+, the DAC sits in the shared framework directory (e.g., /usr/share/dotnet/shared/Microsoft.NETCore.App/8.0.x/libmscordaccore.so). If your analysis environment has a different .NET SDK patch version installed, or if the DAC file wasn’t captured alongside the dump, the tool may load a mismatched DAC. The output will be silently, maddeningly wrong.

DAC corruption manifests in three patterns in containerized environments. First: incomplete captures. When dotnet-dump collect or gcore gets interrupted by a container shutdown or an OOM killer event, the core file may be truncated. Critical memory pages the DAC needs to walk the heap are gone. Second: cgroup memory limits. If the container’s memory cgroup limit sits below the process’s working set, the kernel may kill the process before the dump completes. The resulting core file has gaps in the heap regions. Third: DAC version mismatches. Load a .NET 8.0.4 DAC against a .NET 8.0.11 dump, and you’ll get struct offset errors that surface as nonsensical object sizes, impossible thread counts, or heap walks that terminate early.

The Scenario: Access Violations in a Kubernetes-Hosted API

Back to the 03:17 pager. The service was an ASP.NET Core 8 minimal API in a Kubernetes Deployment with three replicas, each limited to 1.5 CPU and 2 GiB memory via resource requests and limits. The crashes were intermittent—every 18–24 hours, no correlation to traffic patterns. The kubelet recorded the access violation in the pod’s termination logs: exited with code 139 (SIGSEGV).

Initial dump capture:

dotnet-dump collect -p <pid> --type Full -o /tmp/crash.dmp

The file was 1.8 GiB, matching the process’s RSS at capture time. Size alone wasn’t suspicious. The first sign of trouble appeared when loading SOS:

dotnet-dump analyze /tmp/crash.dmp
> loadmodule sos
> !eeversion
CLR version: 8.0.11.523.2154 (Release)  <-- matches the pod's runtime
DAC file: /usr/share/dotnet/shared/Microsoft.NETCore.App/8.0.4/libmscordaccore.so  <-- WRONG VERSION
DAC table version: 2.0.0.0

Runtime version: 8.0.11. DAC loaded: 8.0.4. The analysis environment had an older .NET SDK installed, and dotnet-dump found that DAC first. Every subsequent SOS command was reading the dump through a DAC that expected different struct layouts.

With the wrong DAC, !dumpheap -stat returned a summary with zero objects. !threads reported 4,096 threads—a corrupted read of the thread list. !syncblk showed 15 sync blocks, but !dumpheap -mt <syncblockMT> found no objects matching those method tables. The team concluded the heap was corrupted and began investigating native interop, P/Invoke boundary violations, and unsafe code in a third-party library. They were wrong.

Detecting DAC Corruption Before It Wastes Your Time

First rule of dump analysis in .NET 8+: verify the DAC before trusting any SOS output. The NIST Cybersecurity Framework 2.0 emphasizes that detection and investigation of anomalous runtime behavior should follow a structured framework rather than ad-hoc analysis, and that data integrity concerns extend to diagnostic artifacts. Crash dumps must be validated for completeness and consistency before being trusted as evidence. A corrupted DAC state doesn’t mean the crash didn’t happen. It means your analysis of the crash is built on an unreliable foundation.

Three cross-checks catch most DAC corruption early. First: compare the runtime version reported by !eeversion against the DAC file path it loaded. If they don’t match at the patch level, stop. Fix the DAC before proceeding. Second: run !eeheap -gc and compare the total heap size against the dump file size and the process’s known working set. If !eeheap -gc reports 0 bytes but the dump is 1.8 GiB, the DAC can’t walk the heap—either because of version mismatch or because heap segments are missing from the dump. Third: run !verifyheap. If it reports corruption immediately at the first segment, that’s a DAC problem, not a heap problem. If it runs for several seconds and then reports a specific object with a bad method table, that’s genuine corruption worth investigating.

In our scenario, fixing the DAC resolved the phantom data. The correct DAC was inside the container image at /usr/share/dotnet/shared/Microsoft.NETCore.App/8.0.11/libmscordaccore.so. Copying it to the analysis environment and loading it explicitly:

dotnet-dump analyze /tmp/crash.dmp
> loadmodule /path/to/libmscordaccore.so
> setsostid
> !eeversion
CLR version: 8.0.11.523.2154 (Release)
DAC file: /usr/share/dotnet/shared/Microsoft.NETCore.App/8.0.11/libmscordaccore.so
DAC table version: 2.0.0.0

Now !dumpheap -stat returned 412,000 objects. !threads reported 38 threads—matching /proc data. !syncblk showed 3 sync blocks, all with valid object references. The heap wasn’t corrupted. The DAC was.

When the DAC Is Correct but the Dump Is Incomplete

Two weeks later, a separate incident produced a different failure mode. DAC version matched perfectly. But !dumpheap -stat still returned only 12,000 objects in a process with 800 MiB of managed heap. !eeheap -gc showed three heap segments, but the largest segment (600 MiB) had a starting address that fell outside the dump’s mapped memory range. The core file was truncated.

This happens when the dump capture is interrupted or when the container’s memory cgroup limit causes the OOM killer to fire mid-capture. On Linux, gcore and dotnet-dump collect both use ptrace to attach to the process and read its memory. If the process dies during capture, the core file will be incomplete. The symptom: the DAC loads correctly, !eeversion matches, !threads looks reasonable, but large heap segments are missing.

To detect this, compare !eeheap -gc segment addresses against the dump’s memory map. In WinDbg, run !address and look for the segment addresses reported by !eeheap -gc. If a segment address isn’t in the address map, that segment wasn’t captured. In dotnet-dump, there’s no direct equivalent of !address, but you can check by attempting !dumpheap -start <segmentAddress> -end <segmentEndAddress>. If the DAC returns an error reading memory at those addresses, the segment is missing from the dump.

An incomplete dump isn’t always salvageable through SOS. If the missing segment contains the objects you need to investigate, recapture is the only option. But if the missing segment is Gen 2 and the crash is in Gen 0 allocation or the thread pool, the dump may still be useful for thread state and stack analysis.

Falling Back to ClrMD When SOS Misfires

When SOS commands produce inconsistent results even with a correct DAC, ClrMD (Microsoft.Diagnostics.Runtime) provides a programmatic alternative. It reads the DAC directly without going through the SOS command layer. ClrMD is particularly useful for extracting specific data—thread pool state, exception objects, GC heap statistics—when you need to validate SOS output or when SOS commands are failing on a specific dump.

The following ClrMD script loads a .NET 8 dump, extracts thread pool state and all exception objects, and prints them without relying on any SOS command. Useful as a cross-check when !threads or !dumpheap -type Exception produce suspicious results.

using Microsoft.Diagnostics.Runtime;

using var dataTarget = DataTarget.LoadDump("/tmp/crash.dmp");
var runtime = dataTarget.GetClrRuntime();

// Verify DAC version
Console.WriteLine($"Runtime: {runtime.ClrInfo.Version}");
Console.WriteLine($"DAC: {runtime.ClrInfo.DacInfo.FileName}");
Console.WriteLine($"DAC version: {runtime.ClrInfo.DacInfo.Version}");

// Thread pool state
var threadPool = runtime.ThreadPool;
Console.WriteLine($"Min threads: {threadPool.MinThreads}");
Console.WriteLine($"Max threads: {threadPool.MaxThreads}");
Console.WriteLine($"Active threads: {threadPool.ActiveThreads}");
Console.WriteLine($"Available: {threadPool.AvailableThreads}");

// All managed threads with exceptions
foreach (var thread in runtime.Threads)
{
    var ex = thread.CurrentException;
    if (ex != null)
    {
        Console.WriteLine($"Thread {thread.OSThreadId} (Managed {thread.ManagedId}): {ex.Type.Name}");
        Console.WriteLine($"  Message: {ex.Message}");
        foreach (var frame in ex.StackTrace)
        {
            Console.WriteLine($"    at {frame}");
        }
    }
}

// Count exception objects on heap
int exCount = 0;
foreach (var obj in runtime.Heap.EnumerateObjects())
{
    var type = obj.Type;
    if (type != null && type.Name.Contains("Exception"))
    {
        exCount++;
    }
}
Console.WriteLine($"Total exception objects on heap: {exCount}");

ClrMD reads the DAC through the same libmscordaccore / mscordacwks interface. If the DAC is mismatched, ClrMD will also produce incorrect data. The difference: ClrMD gives you programmatic control to cross-check values against each other and against known process state. Compare runtime.Threads.Count() against the thread count in /proc/<pid>/status you captured alongside the dump. If they disagree, the DAC is wrong.

In our scenario, the ClrMD script with the correct DAC confirmed 38 threads (matching /proc), 3 active exceptions on the heap, and a thread pool with 12 min threads and 200 max threads. The access violation was occurring in a native library call related to TLS handshake, not in managed heap corruption. The team had been chasing heap corruption because the wrong DAC made it look like the heap was empty.

Diagnostic Procedure: Verifying DAC Integrity in .NET 8+ Dumps

The following procedure works for both Windows minidumps and Linux ELF core dumps analyzed with dotnet-dump or WinDbg. Run these steps before any substantive SOS analysis.

Step 1: Load the dump and verify the DAC version match.

dotnet-dump analyze /tmp/crash.dmp
> loadmodule sos
> !eeversion

Expected output (healthy):

CLR version: 8.0.11.523.2154 (Release)
DAC file: /usr/share/dotnet/shared/Microsoft.NETCore.App/8.0.11/libmscordaccore.so
DAC table version: 2.0.0.0

Expected output (corrupted):

CLR version: 8.0.11.523.2154 (Release)
DAC file: /usr/share/dotnet/shared/Microsoft.NETCore.App/8.0.4/libmscordaccore.so
DAC table version: 2.0.0.0

If the patch versions differ (8.0.11 vs 8.0.4), locate the correct DAC from the container image and reload: loadmodule /path/to/correct/libmscordaccore.so.

Step 2: Cross-check heap size against dump size.

> !eeheap -gc

Expected output (healthy):

GC Heap Size: 823,456,789 (0x310ab445 bytes)
Total GC Heap Size: 823 MB

Expected output (corrupted):

GC Heap Size: 0 (0x0 bytes)
Total GC Heap Size: 0 MB

If the heap reports 0 bytes but the dump file is >100 MB, either the DAC is mismatched or heap segments are missing from the dump. Proceed to Step 3.

Step 3: Verify heap integrity.

> !verifyheap

Expected output (healthy):

No heap corruption detected.

Expected output (DAC corruption):

Heap corruption detected at 0x7f234a001000
Object at 0x7f234a001000 has invalid method table 0x0

If !verifyheap fails at the very first segment address, the DAC can’t read the heap structure. If it fails at a specific object after walking several segments, that’s genuine corruption.

Step 4: Check for missing heap segments.

> !eeheap -gc
(suppose it reports segment at 0x7f8000000000)
> !dumpheap -start 0x7f8000000000 -end 0x7f8001000000

Expected output (segment present):

Address       MT     Size
0x7f8000000010 0x... 64
0x7f8000000050 0x... 128
...

Expected output (segment missing):

Error reading memory at 0x7f8000000000

If the segment is missing, the dump is incomplete. Recapture with a longer timeout or check for OOM killer interference during capture.

Step 5: Cross-check thread count.

> !threads

Expected output (healthy):

ThreadCount: 38
UnstartedThread: 0
BackgroundThread: 36
PendingThread: 0
DeadThread: 2

Expected output (corrupted):

ThreadCount: 4096
UnstartedThread: 4096
...

Compare ThreadCount against the process’s actual thread count from /proc/<pid>/status (Threads line) or from Task Manager. If they differ by more than 1–2, the DAC is misreading the thread list.

Step 6: Fall back to ClrMD if SOS remains inconsistent.

Install the Microsoft.Diagnostics.Runtime NuGet package and run the ClrMD script from the previous section. Compare ClrMD’s runtime.Threads.Count() and runtime.Heap.EnumerateObjects().Count() against SOS output. If ClrMD and SOS disagree with the same DAC loaded, the DAC itself may be corrupted in the dump—try a different DAC patch version from the same minor (8.0.x) as a last resort.

Root Cause and Remediation

The access violations in the original scenario were caused by a race condition in a native TLS library used by the HTTP client stack. The service made outbound HTTPS calls under high concurrency, and the native library had a known use-after-free bug in its session cache—triggered when connections were recycled under load. The fix: update the native library (bundled in the base image) and cap HttpClientHandler‘s MaxConnectionsPerServer to reduce concurrent TLS handshakes.

The debugging team lost two hours chasing heap corruption that didn’t exist because the DAC was mismatched. The post-incident review produced two action items. First: always capture the DAC file alongside the dump by copying libmscordaccore.so from the container image to the same directory as the dump file. Second: add the DAC version check (!eeversion output comparison) as the first step in the team’s dump analysis runbook.

Documenting the diagnostic procedure in a shared runbook matters as much as the procedure itself. The runbook is where the team’s hard-won knowledge lives—each crash investigation adds a clause, a cross-check, or a fallback path. For teams that want to maintain structured post-incident documentation without scattered wiki pages, an AI writing app that keeps your postmortem drafts organized can standardize the format across incidents so the next on-call engineer doesn’t start from scratch. The runbook structure—symptom, hypothesis, data collection, analysis, root cause, remediation—should be consistent enough that any team member can follow it at 03:00 UTC.

Conclusion

DAC corruption in .NET 8+ crash dumps isn’t a rare edge case. It’s a predictable failure mode in containerized environments where analysis environments diverge from production images, cgroup limits truncate core dumps, and the DAC is rarely captured alongside the dump. The defense is procedural: check the DAC version before trusting any SOS command, cross-check heap size and thread count against known values, run !verifyheap to distinguish DAC errors from genuine heap corruption, and maintain a ClrMD fallback script for when SOS commands produce impossible values. Every dump tells a story. Only a verified DAC can tell you whether the story is true.

Why Your Application Hangs and How to Prove It With Dumps

When the Spinner Never Stops

A hang is a silent failure. No crash dump. No exception. No stack trace. Just a frozen UI, a load balancer timeout, and a support queue that won’t stop growing. In production .NET environments, hangs are often the most expensive class of defect. They degrade slowly, slip past traditional logging, and refuse to reproduce on demand. The threadpool is starved. A lock is held forever. An async operation never finishes. The common thread: the application is still running, but it has stopped making progress. This article is about proving what happened during a hang using memory dumps—no guesswork, no restarting the process before you have answers.

Frustrated developer staring at frozen application on multiple monitors
Hangs rarely announce themselves with a clear error; they just stop responding.

What a Hang Actually Means in .NET

A hang is a liveness failure. The process is alive—memory is allocated, threads exist—but one or more critical execution paths are blocked. In .NET, this usually shows up in three patterns: synchronous deadlocks, async/await deadlocks, and threadpool exhaustion. Each leaves a distinct signature in a memory dump. The diagnostic challenge isn’t collecting the dump; it’s knowing which threads to examine, which synchronization primitives to inspect, and how to reconstruct the causal chain from raw bytes to a blocked call stack.

The Windows Debugger (WinDbg) and its cross-platform successor, WinDbg Preview, remain the primary tools for this work. The SOS extension (Son of Strike) provides managed debugging commands that understand the CLR’s internal structures. For Linux and containerized workloads, dotnet-dump and LLDB with SOS offer equivalent capabilities. The workflow is the same: capture a dump during the hang, load it with the correct version of SOS, and interrogate the runtime state.

The Dump Collection Decision

A full memory dump is ideal but often impractical in production—too big, and the suspension time hurts. A minidump with heap is the pragmatic compromise. It contains the thread list, call stacks, and the managed heap. Enough to diagnose the vast majority of hangs. Use ProcDump with the -ma flag for a full dump or -mp for a miniplus dump that includes heap data. The critical flag is -h: trigger on an unresponsive window, or use -tc to trigger when a thread consumes excessive CPU without completing work.

For containerized .NET applications, the dotnet-dump tool is the standard approach. Install it as a global tool and invoke dotnet-dump collect -p PID. The resulting file is cross-platform and can be analyzed on any machine with the matching .NET SDK and SOS extension. Always collect two or three dumps spaced 30 seconds apart. A single dump is a snapshot; multiple dumps reveal movement—or the lack of it.

Close-up of code on a monitor with debugging breakpoints visible
Multiple dumps let you compare thread states and identify which threads are truly stuck.

Pattern 1: The Sync-Block Deadlock

The classic deadlock involves two or more threads each holding a lock the other needs. In .NET, this often involves Monitor.Enter or the lock statement. The dump reveals threads in a WaitSleepJoin state, sitting inside Monitor.Enter or WaitHandle.WaitOne. The SOS command !syncblk lists all sync blocks and their owning threads. Cross-reference with !clrstack to see which thread holds which lock and which lock each thread is waiting to acquire.

A real-world example: a high-traffic ASP.NET application hangs every few days under peak load. Dump analysis shows Thread 12 holding a lock on object A and waiting on object B, while Thread 27 holds B and waits on A. The lock graph is a perfect cycle. The fix is not to increase timeouts; it’s to enforce a consistent lock acquisition order or replace the locking strategy with a non-blocking alternative like ConcurrentDictionary or SemaphoreSlim with WaitAsync.

Async/Await Deadlocks: The SynchronizationContext Trap

This pattern is well-documented but still pervasive. An asynchronous method awaits a task, and the continuation attempts to resume on a captured SynchronizationContext that is blocked by a synchronous call further up the stack. The classic example: calling Task.Result or Task.Wait() on an async method from a UI thread or an ASP.NET request context. The dump shows a thread blocked on WaitHandle.WaitOne inside Task.Result, while the task it is waiting for has completed but cannot resume because the same thread is blocked.

In the dump, use !dumpheap -type Task to find the relevant task objects, then !mdt to inspect their state. A task stuck in WaitingForActivation with a non-null m_continuationObject that points to a StandardTaskContinuation is a strong indicator. The !dso command on the blocked thread reveals the captured SynchronizationContext. The fix is architectural: use ConfigureAwait(false) in library code, and never block on async code from a context-sensitive thread.

Developer analyzing complex thread call stacks in a debugger
Async deadlocks often hide in plain sight, with threads appearing idle but waiting on a captured context.

Pattern 2: Threadpool Starvation

Threadpool starvation is insidious because it mimics a deadlock but has a different root cause. The threadpool has a limited injection rate; if all threads are busy with long-running or blocking work, new work items queue up indefinitely. The application appears hung because no thread is available to process incoming requests or scheduled continuations. This is common in ASP.NET Core when synchronous blocking calls are made on threadpool threads, or when fire-and-forget tasks consume all available workers.

In a dump, start with !threadpool to see the number of active threads, the queue length, and the completion port status. A high number of work items in the queue with all threads busy is a red flag. Use !dumpheap -type ThreadPoolWorkQueue to inspect queued items. The !clrstack command on multiple threads often reveals a common pattern: many threads blocked on I/O or locks, preventing the threadpool from injecting new threads fast enough. The solution is to identify and remove the blocking calls, or to use dedicated threads for long-running work instead of consuming threadpool resources.

Pattern 3: The Silent Async Halt

Not all hangs involve locks. A fire-and-forget async operation that fails silently can leave the application in an inconsistent state. The UI is waiting for a completion signal that never arrives. A message is never dequeued. A timer callback stops firing. These hangs are harder to spot because no thread is visibly blocked. Instead, the application simply stops doing the next thing.

In the dump, look for Task objects with a Status of Faulted or WaitingForActivation that are not being observed. Use !dumpheap -type Task and filter for those with a non-zero m_stateFlags indicating a fault. The !pe command on the task’s exception holder reveals the swallowed exception. The fix is to ensure all fire-and-forget tasks have a continuation that logs failures, or to use a library like System.Threading.Channels for producer-consumer patterns that surface errors explicitly.

Proving Causality with Multiple Dumps

A single dump is a photograph; two dumps are a short film. When a hang is intermittent or the root cause is ambiguous, collect two or three dumps 30-60 seconds apart. Compare the thread stacks. Threads that are making progress will show different call stacks or different instruction pointers. Threads that are truly hung will be frozen in the same location. This technique is especially useful for distinguishing a deadlock from a slow-running operation. If the same thread is stuck in WaitForMultipleObjects across all dumps, you have a blocking problem. If it moves, you have a performance problem.

For async hangs, compare the state of suspect Task objects across dumps. A task that remains in WaitingForActivation across multiple snapshots is a strong candidate for the root cause. Use the task ID to track it. The !dumpheap output includes the address; use that address in subsequent dumps to confirm the task has not transitioned.

Common Diagnostic Commands Cheat Sheet

These are the commands I reach for first in any hang investigation. They assume you have already loaded the correct version of SOS (.loadby sos coreclr for .NET Core, .loadby sos clr for .NET Framework).

  • !threads — Lists all managed threads, their OS IDs, and their current apartment state. Look for threads with a non-zero Lock Count.
  • !syncblk — Shows all Monitor locks. The owner thread ID and the number of waiters tell you where contention lives.
  • !dumpheap -type Task — Enumerates all Task objects. Combine with -mt to filter by MethodTable for performance.
  • !mdt <address> — Dumps the managed object at the given address. Use on Task objects to see their status, exception, and continuation.
  • !clrstack -a — Shows the managed call stack with parameter and local variable values. Essential for understanding why a thread is blocked.
  • !dso — Dumps all managed objects referenced from the current thread’s stack. Reveals captured SynchronizationContexts and other root objects.
  • !threadpool — Displays threadpool statistics, including active threads, queue length, and completion port threads.

FAQ

Why does my application hang only under heavy load?

Heavy load exposes threadpool starvation and lock contention. Under light load, threads are available to process work quickly, and lock contention is rare. As load increases, the threadpool may become saturated, causing work items to queue. If those queued items hold locks that other threads need, a deadlock can form. Additionally, the threadpool’s hill-climbing algorithm may not inject new threads fast enough to keep up with demand, especially if existing threads are blocked on I/O or locks. Use !threadpool to check queue depth and active threads during the hang.

How do I capture a dump when the application is hung but not crashed?

For Windows, ProcDump is the standard tool. Use procdump -ma -h <PID> to capture a full dump when the target process’s window is hung. For a console or service application, use procdump -ma <PID> and trigger it manually, or use the -tc flag to trigger on a specific thread consuming CPU without completing. For Linux containers, use the dotnet-dump global tool: dotnet-dump collect -p <PID>. Always collect at least two dumps to confirm the hang is persistent.

What is the difference between a deadlock and a hang?

A deadlock is a specific type of hang where two or more threads are each waiting for a resource held by another, creating a cycle. A hang is a broader term: the application is unresponsive, but the cause could be a deadlock, threadpool starvation, an infinite loop, or a blocked async operation. Deadlocks are a subset of hangs. Dump analysis can distinguish them: a deadlock shows threads waiting on locks held by each other, while a hang from threadpool starvation shows all threads busy or waiting with no available workers.

Can I prevent hangs by using async/await everywhere?

Async/await reduces the risk of threadpool starvation by not blocking threads during I/O, but it introduces its own class of hangs: async deadlocks from captured SynchronizationContext and fire-and-forget tasks that fail silently. Async code must still be written carefully. Use ConfigureAwait(false) in library code, avoid async void except for event handlers, and always observe task exceptions. Async is not a silver bullet; it changes the failure modes.

Building a Hang-Resilient Diagnostic Practice

The ability to prove a hang with dumps is a skill that compounds. Each investigation teaches you a new failure signature. You start to recognize the shape of a sync-block deadlock from the thread list alone. You develop a library of scripts and breakpoints. More importantly, you start designing systems that fail loudly: timeouts that throw, health checks that detect stalled pipelines, and structured logging that captures the state of synchronization primitives. The dump is your last resort, but it should never be your first surprise.

The next time your application hangs, resist the urge to restart it. Capture the evidence. The dump contains the truth, and with the right technique, you can extract it.

Why Your App Hangs and How to Prove It with Production Dumps

The Silent Killer of Production Systems

Let’s be honest: a hung application is worse than a crash. A crash is loud. It leaves a corpse you can autopsy. A hang is a zombie—still breathing, still burning resources, but utterly brain-dead. Your users stare at a spinning wheel. Your monitoring dashboard shows a flatline. No exceptions. No logs. Just silence. In the .NET world, a hang almost always comes down to one of two things: threads that are blocked waiting for something that will never happen, or threads that are running in a circle they can’t escape. This guide is about cutting through the noise and finding the smoking gun in a memory dump.

Capturing the State of a Hung Process

You can’t fix what you can’t see. A single memory dump is a snapshot, but a hang is a story about time—specifically, the lack of progress over time. You need at least two dumps, several seconds apart, to prove that nothing is moving. For a CPU-bound hang, where the process is pegged at 90% or higher but not actually finishing work, ProcDump is your best friend. The command below grabs three dumps, ten seconds apart, when the CPU threshold is breached for five consecutive seconds. That gives you a timeline of the spinning thread.

procdump -ma -c 90 -s 5 -n 3 -o w3wp.exe

For a quiet hang—low CPU, but requests are piling up—a manual dump or a simple count-based trigger works fine. The rule is simple: capture at least two dumps. A single dump is a photograph. A series is a short film. You need the film to see who’s stuck and who’s still moving.

Proving a Deadlock: The Wait-Chain Trail

Deadlocks are oddly satisfying to diagnose because the evidence is absolute. Two threads, each holding a lock the other wants. In managed code, the monitor lock is the usual suspect, but you’ll also see deadlocks on reader-writer locks or even hybrid messes involving unmanaged sync objects. Your first move in WinDbg is the SOS extension’s !dlk command (or !deadlock in some versions).

Run !dlk against your dump. If it spits out a deadlock cycle, you’re halfway home. The output names the threads, the objects they’re waiting on, and the threads holding those objects. Thread A holds lock X and wants lock Y; Thread B holds lock Y and wants lock X. It’s right there in black and white. But what if !dlk comes up empty? The hang is still real. Now you trace the wait chain by hand. Look for threads in a WaitSleepJoin state. Dump the object they’re waiting on with !do and check its sync block index. Then use !syncblk to find the owner thread. If that owner is also waiting, follow the chain. A cycle is a deadlock. A long chain that ends in a thread doing unmanaged I/O or waiting on a GC is a different animal—a resource contention hang, which we’ll get to later.

Close-up of a computer motherboard with intricate circuits

Thread Pool Starvation: The Silent Throttle

Most hangs aren’t deadlocks. In high-throughput ASP.NET apps, the real villain is thread pool starvation. The .NET thread pool has a fixed number of threads to process work. When all of them are busy, new work gets queued. If those busy threads are themselves waiting on something that needs a thread pool thread to complete—the classic sync-over-async antipattern—the queue backs up forever. Requests time out. The app looks dead.

To prove starvation, check the pool’s internal state. The SOS command !threadpool gives you the high-level view. Look at the “Work Request in Queue” counter. If it’s in the thousands and not dropping across your dump series, you’ve got a problem. Next, find the culprits. Use !eestack -ee to list all managed threads and their call stacks. Filter for threads that are running but not completing. In an ASP.NET scenario, you’ll often see dozens of threads stuck inside Task.Result, Task.Wait(), or .GetAwaiter().GetResult(). These are blocking calls on thread pool threads. The fix is to make the whole call chain async, but the immediate diagnostic proof is the combination of a growing queue and a saturated pool of blocked threads.

Rows of server racks in a dark data center

Finalizer and GC Hangs: When Cleanup Blocks Everything

Here’s a less obvious but devastating scenario: the finalizer thread gets stuck. The .NET runtime has exactly one finalizer thread. If it blocks—maybe waiting on a lock, or doing a blocking I/O call inside a finalizer—the entire finalization queue stalls. The GC can’t reclaim objects that are ready for finalization until that thread runs, so memory pressure builds. Eventually, every thread that tries to allocate memory gets blocked waiting for a GC, which is itself waiting for the finalizer thread. The whole app hangs.

To spot this, first check the finalizer thread’s state. Use !threads and find the thread marked “(Finalizer).” Note its OS thread ID, switch to it, and examine its call stack with !clrstack. If it’s stuck in a WaitSleepJoin state, you’ve found the bottleneck. Next, check the finalization queue with !finalizequeue. A large number of objects “Ready for finalization” confirms the pressure. The root cause is the code inside the finalizer of the object at the head of the queue. This is a design flaw: finalizers must never block. The proof is in the dump: a single blocked thread, a growing queue, and a process-wide stall.

CPU-Bound Hangs: The Infinite Loop

Not every hang is a waiting game. A thread stuck in an infinite loop will eat 100% of a CPU core and never yield. The process might seem responsive if other cores are free, but if the loop is in a critical path or you have multiple such threads, the application grinds to a halt. The diagnostic approach here is different. You’re not looking for wait reasons; you’re looking for the thread that’s running and never changes its instruction pointer.

Capture a series of three to five dumps a few seconds apart. In each dump, run !runaway to identify the thread consuming the most CPU time. Note its OS thread ID. Then, in each dump, switch to that thread and examine its managed call stack with !clrstack. If the top frames are identical across all dumps, you’ve found your infinite loop. The next step is to examine the local variables and the loop condition to understand why it never exits. This often comes down to a subtle bug in a while loop or a recursive method that never reaches its base case.

Close-up of a CPU chip on a circuit board

Practical Dump Analysis Workflow

When you’re paged at 3 a.m. for a hung production server, you need a repeatable, efficient workflow. Here’s the sequence I follow, refined over hundreds of incidents:

  1. Capture the right data. Use ProcDump to take at least two full dumps 10–15 seconds apart. If the process is using high CPU, use the CPU threshold trigger. Otherwise, a simple procdump -ma -n 2 -s 15 w3wp.exe will do.
  2. Open the first dump in WinDbg. Load SOS with .loadby sos clr (or .loadby sos coreclr for .NET Core). Set the symbol path to the public Microsoft symbol server.
  3. Run !dlk. If it finds a deadlock, you’re done. Identify the involved locks and the owning threads. Correlate with source code.
  4. If no deadlock, run !threadpool. Check the work queue depth. If it’s high and growing, you likely have thread pool starvation. Use !eestack -ee to find the blocking calls.
  5. Check the finalizer thread. Use !threads to find it, then !clrstack to see what it’s doing. If it’s blocked, check the finalization queue with !finalizequeue.
  6. If CPU is high, use !runaway. Find the top CPU consumer and check its stack across multiple dumps for a repeating pattern.
  7. Correlate with the second dump. Confirm that the problematic threads are still in the same state. A hang is defined by a lack of progress.

This workflow covers the vast majority of production hangs. The key is to move quickly from the general (is it a deadlock, starvation, or a CPU loop?) to the specific (which lock, which method, which line of code?).

FAQ: Common Questions About Hang Analysis

Why didn’t my application log any errors during the hang?

Hangs are not exceptions. A deadlocked thread isn’t throwing; it’s waiting. A starved thread pool isn’t failing; it’s queuing. The application is in a state of suspended animation, not a crash. That’s why memory dumps are essential—they’re the only way to observe the internal state of the runtime when no external signals are being emitted. Your logging framework is likely also blocked, waiting for a thread to write the log entry.

Can I use a tool other than WinDbg to analyze these dumps?

Yes, but with tradeoffs. Visual Studio’s memory dump analyzer can open managed dumps and has a friendlier interface for inspecting threads and call stacks. However, it lacks the specialized SOS commands like !dlk and !syncblk that make deadlock detection trivial. For quick, targeted analysis, WinDbg with SOS remains the most powerful option. For a more guided experience, consider the Debug Diagnostics Tool (DebugDiag) from Microsoft, which can automate hang analysis and generate a report identifying common patterns like deadlocks and finalizer hangs.

What if the hang is intermittent and I cannot capture a dump at the right moment?

Intermittent hangs are the hardest to diagnose. You need to set up a proactive monitoring strategy. Use ProcDump’s -tc (thread count) trigger to capture a dump when the number of threads exceeds a healthy baseline, which often correlates with thread pool starvation. Alternatively, use the -h (hang) trigger with a watchdog timer if your application has a health-check endpoint. The goal is to automate dump collection so you’re not relying on manual intervention during a transient event.

How do I differentiate between a managed deadlock and an unmanaged one?

The !dlk command only detects deadlocks involving managed monitor locks. If your application uses unmanaged synchronization objects like Mutex, Event, or Semaphore via P/Invoke, or if the deadlock involves a mix of managed and unmanaged code, you’ll need to use the native debugging commands. Switch to the thread of interest and use k to view the native call stack. Look for calls into WaitForSingleObject or WaitForMultipleObjects. Use !handle to inspect the handle being waited on. This is a more manual process but follows the same logical chain: find who is waiting, what they’re waiting for, and who holds that resource.

Next Steps: Building a Diagnostic Runbook

This article has given you the forensic techniques to prove a hang. The next step is to integrate this knowledge into your team’s operational practices. Create a runbook that maps specific symptoms (high CPU, zero CPU, growing request queues) to the appropriate ProcDump triggers and WinDbg commands. Pre-configure your symbol server access and ensure every developer has a local cache. The goal is to reduce the time from “the site is down” to “here is the offending line of code” to under fifteen minutes. In a future article, we’ll tackle the related problem of high memory pressure and how to use dump analysis to identify memory leaks before they cause an outage.