When !gcroot Stops at the Handle Table: Auditing GCHandles by Type Instead of Chasing a Stack Frame

You run !gcroot on the object the incident report calls a leaked socket. The output ends like this:

HandleTable:
    00007F6C98BB15F8 (pinned handle)
    -> 00007F6BDFFFF038 System.Object[]
    -> ...
    -> 00007F6AD09421F8 System.String
Found 2 roots.

No managed stack frame. No static field. Just a handle address and a type label. The instinct is to keep walking the heap looking for the “real” root. Don’t. The handle table is a root. The !gcroot result is complete, and the next move is to account for handles by type, not to keep searching for a frame that does not exist.

This is the procedure for that pivot. It assumes you have a dump open in WinDbg with SOS loaded, or a dotnet-dump analyze session where the handle commands are available. It assumes the object in question is described as “disposed” by application code, which is a claim about application state, not about GC reachability. Those are different facts and the gap between them is where this investigation lives.

Why the handle table is a legitimate stopping point

The GC decides what is garbage by walking the application’s roots. Microsoft’s GC fundamentals page lists those roots explicitly: static fields, local variables on a thread’s stack, CPU registers, GC handles, and the finalize queue. A GCHandle is therefore not a hint or a side channel. It is one of the things the mark phase starts from.

The GCHandle documentation describes the mechanism: the handle can prevent the managed object from being collected when an unmanaged client holds the only reference. That is the whole point of the type. It exists so that code outside the managed heap can keep an object alive without the GC knowing about the external reference. When !gcroot reports a HandleTable entry, it is telling you the object is reachable through exactly that mechanism.

The same documentation is blunt about the obligation: when the handle goes out of scope you must explicitly release it by calling Free, otherwise memory leaks may occur. There is no finalizer on GCHandle that cleans up after you. Dispose on your socket wrapper and GCHandle.Free on the handle are independent obligations. A type can honor the first and miss the second, and the object stays reachable.

The handle-type taxonomy, and which types actually keep objects alive

GCHandleType has four members. Two of them are roots in the sense that matters here; two are not.

  • Weak tracks an object but allows it to be collected. When the object is collected, the handle contents are zeroed. The documentation notes weak references are zeroed before the finalizer runs, so even finalizer resurrection does not revive the weak reference.
  • WeakTrackResurrection is the same idea with one difference: the handle is not zeroed if the object is resurrected during finalization.
  • Normal is an opaque handle that tracks an object and prevents its collection. You cannot resolve the address of the target through it.
  • Pinned is Normal plus addressability: it allows the address of the pinned object to be taken, which prevents the GC from moving it. The documentation is direct that this undermines GC efficiency, and that you should free the handle as soon as possible.

So when you see a growing population of handles and want to know whether it explains a leak, the question is not “how many handles exist.” It is “how many Strong and Pinned handles exist, and is that count growing.” Weak handles are expected to exist in normal quantities. They are not leaks. A large stable population of weak handles is background noise, and treating it as evidence will send you down the wrong path.

Reading !gchandles as a distribution

!gchandles displays statistics about garbage collector handles in the process. The output includes a statistics block grouped by handle type. The statistics block is where you start, because it answers the distribution question in one screen.

A representative shape, from a documented WinDbg walkthrough, looks like this:

GC Handle Statistics:
      Strong Handles:       33
      Pinned Handles:        8
  Async Pinned Handles:      0
     Ref Count Handles:      0
    Weak Long Handles:      56
   Weak Short Handles:     384
       Other Handles:        0

Read this as a distribution, not a total. The 384 weak short handles are not the story. The 33 strong handles might be, depending on whether that number is stable or climbing. The 8 pinned handles might be, for the same reason. The async pinned count is the one people reach for first when the incident report mentions async I/O, and it is worth being precise about what that number does and does not tell you. It tells you how many async pinned handles exist at the moment of the dump. It does not tell you which code created them, whether they are expected, or whether they are the ones holding your object. You still have to dereference.

One caveat on tooling: the SOS documentation marks GCHandles and GCHandleLeaks as only supported on Windows. If you are analyzing a Linux core file with dotnet-dump, do not assume these commands will behave the same way or be present at all. Plan the handle audit around a Windows dump, or confirm command availability in your specific tool version before you build a procedure that depends on it.

The pivot: dereference the handle address

Take the handle address from the !gcroot output and dereference it. The documented pattern is !do poi(<handle address>). In the walkthrough above, that produces the object the handle references, with its type name and fields.

0:004> !do poi(001c1118)
Name: WindowsApplication1.Form1
...

This is the step that converts a handle address into a diagnosis. Before you attribute the leak to the socket type, confirm the target’s type. A handle pointing at a byte array, a cache object, or a delegate is a different bug than one pointing at the socket wrapper itself. The incident report says “disposed socket.” The handle says what it actually holds. Trust the handle.

If the target is the socket wrapper, the next question is which handle type is holding it. The statistics block gives you the population; the handle address from !gcroot gives you the specific handle to inspect. Correlating the two is the work. If the target is something else, you have found a different leak and the socket hypothesis is dead. Say so and move on.

What !GCHandleLeaks adds, and what it does not

!GCHandleLeaks searches process memory for references to strong and pinned handles and reports the address of each reference it finds. If a handle is not found in memory, it reports that too. The appeal is obvious: it looks like it answers “is this handle orphaned?”

The documentation and the walkthrough both warn against over-reading a single run. A general scan of process memory cannot distinguish between garbage and valid structures, so false positives are possible. The walkthrough’s guidance is to use the command over time to identify a trend. A single snapshot showing handles that were not found is weak evidence. Two or three snapshots taken while the process keeps running, showing a growing set of not-found handles, is a different claim.

This is the same discipline the memory-leak tutorial applies to dumps: let the original process continue running after collecting the first dump and collect a second one the same way, so you have two dumps over a period of time to compare. Handle accounting without a time axis is a census, not a leak diagnosis.

The decision procedure

Forty minutes, one pod cycle. Here is the order that gets you to a decision.

  1. Confirm growth before you collect anything. The memory-leak tutorial is explicit that you should make sure you are actually seeing a memory leak before collecting diagnostic data, and that dotnet-counters is the tool for that confirmation. Watch dotnet.gc.last_collection.heap.size and the committed size across several minutes. If the managed heap is flat and the process working set is climbing, the leak is not in the managed heap and no amount of !gchandles will find it.
  2. Capture two dumps, not one. Let the process run between them. The delta is the evidence. A single dump tells you what exists; two dumps tell you what is accumulating.
  3. Run !gchandles on both dumps and compare the statistics block line by line. Growth concentrated in Strong or Pinned is the signature that matches a leaked GCHandle. Growth spread evenly across weak handles is churn, not a leak. Growth in Ref Count handles points at a component that is holding references through a different mechanism, and the investigation changes shape.
  4. For each handle type that grew, take a handle address from the !gcroot output or from !GCHandleLeaks and dereference it. Confirm the target type. If the target is the object from the incident report, you have your mechanism. If it is not, you have a different leak and you should say so.
  5. Only then go to code. Search for GCHandle.Alloc calls that pair with a Free on a path that can be skipped. The documentation’s warning is the search pattern: every allocation needs a release, and the release must be reachable on every exit path, including the exception paths and the cancellation paths.

If step 3 shows no growth in strong or pinned handles, stop. The handle table is not your leak. The !gcroot result that ended at the handle table was a true answer to a different question, and the object you were chasing is being kept alive by something else, or is not being kept alive at all and the incident report’s premise is wrong.

Frequently asked questions

Does a !gcroot result that ends at the handle table mean the command failed?

No. The handle table is one of the roots the GC examines, and !gcroot is documented to search the managed heap and the handle table for handles within other objects and handles on the stack. A result that terminates at a HandleTable entry is a complete answer: the object is reachable through a GC handle. The follow-up is to identify the handle type and target, not to re-run the command hoping for a stack frame.

Is an async pinned handle the same as a pinned handle?

They are distinct categories in the !gchandles statistics block, which reports them on separate lines. The GCHandleType enum documents four members: Weak, WeakTrackResurrection, Normal, and Pinned. The async pinned category appears in SOS output as a runtime-level classification. Do not assume that a nonzero async pinned count identifies the code that created the handle, and do not assume that async I/O in your application is the only source of them. Dereference the handle and confirm the target before drawing a conclusion about which operation allocated it.

Why does !gchandles not work in my dotnet-dump session on Linux?

The SOS documentation marks GCHandles and GCHandleLeaks as only supported on Windows. dotnet-dump analyze starts an interactive shell that accepts SOS commands, but command availability depends on the platform and the tool version. If your production artifact is a Linux core file, plan the handle audit around a Windows dump or verify command availability in your specific tool before you build a runbook that depends on it.

Can a single !GCHandleLeaks run prove a handle leak?

No. The documented behavior is that a general memory scan cannot differentiate between garbage and valid structures, so false positives are possible. The useful signal is the trend across multiple runs taken while the process continues to run. A single run showing handles that were not found is a data point, not a verdict.

What if the handle target is not the object I expected?

Then the handle is not the mechanism keeping your object alive, and the investigation should move to the object the handle actually references. This is a common outcome and it is useful: it eliminates a hypothesis quickly. Dereference first, conclude second.

Sources