Skip to content

TreeView.CustomDraw leaks one HFONT per visible item per paint when NodeFont is set, leading to GDI handle exhaustion and crash #14721

Description

@tvomacka

.NET version

.NET 10.0.7 (CoreCLR 10.0.726.21808), also verified against current main sources

Did it work in .NET Framework?

Not tested/verified

Did it work in any of the earlier releases of .NET Core or .NET 5+?

No response

Issue description

TreeView.CustomDraw allocates a fresh OwnerDrawPropertyBag for every visible item on every paint cycle (via GetItemRenderStyles). The bag's lazy FontHandle getter creates a new Control.FontHandleWrapper, which calls Font.ToHfont() — allocating an HFONT GDI handle. The bag is then discarded with no Dispose call; the HFONT survives until garbage collection finalizes the wrapper.

Because the wrappers are only a few bytes of managed memory, the GC feels no pressure and rarely runs. In any high-paint-rate scenario (drag & drop, expanding/scrolling a large tree, animations, rapid invalidation), the allocation rate (visible items × paint rate) far outpaces finalization, and the process's GDI handle table fills up. When the per-process 10,000-handle limit is hit, Font.ToHfont() returns NULL with GetLastError() == 0, producing the misleading exception:

System.ComponentModel.Win32Exception: The operation completed successfully.
   at System.Drawing.Font.ToHfont()
   at System.Windows.Forms.OwnerDrawPropertyBag.get_FontHandle()
   at System.Windows.Forms.TreeView.CustomDraw(Message& m)
   at System.Windows.Forms.TreeView.WmNotify(Message& m)
   at System.Windows.Forms.TreeView.WndProc(Message& m)

If the exception escapes the WndProc callback boundary, Windows terminates the process with STATUS_FATAL_USER_CALLBACK_EXCEPTION (0xC000041D). The failure can also surface as a follow-on crash in unrelated GDI consumers once the handle table is full — e.g. the crash dialog itself failing:

System.ArgumentException: Win32 handle that was passed to Icon is not valid or is the wrong type.
   at System.Drawing.Icon..ctor(HICON handle, Boolean takeOwnership)
   at System.Drawing.SystemIcons.GetStockIcon(StockIconId stockIcon, Int32 size)
   at System.Windows.Forms.ThreadExceptionDialog..ctor(Exception t)
   at System.Windows.Forms.Application.ThreadContext.OnThreadException(Exception ex)

Diagnostic data (real-world application)

GDIView snapshots taken 2 seconds apart during a drag in a production application:

Type Before After Δ
Pen 3 3 0
Brush 6 6 0
Bitmap 39 50 +11
Font 26 6328 +6302
Region 12 12 0
DC 31 43 +12

The growth is entirely in the Font handle type — confirming the leak is specifically Font.ToHfont() allocations.

Root cause

src/System.Windows.Forms/System/Windows/Forms/Controls/TreeView/TreeView.cs (~line 2864):

protected OwnerDrawPropertyBag GetItemRenderStyles(TreeNode? node, int state)
{
    OwnerDrawPropertyBag retval = new();    // fresh bag every call — no caching
    if (node is null || node._propBag is null)
        return retval;
    ...
    retval.Font = node._propBag.Font;
    return retval;
}

The caller in CustomDraw (~line 2785):

OwnerDrawPropertyBag renderinfo = GetItemRenderStyles(node, (int)state);
...
if (renderinfo is not null && renderinfo.Font is not null)
{
    PInvokeCore.SelectObject(nmtvcd->nmcd.hdc, renderinfo.FontHandle);
    ...
}

renderinfo.FontHandle is the lazy getter in OwnerDrawPropertyBag.cs:

internal HFONT FontHandle
{
    get
    {
        if (_fontWrapper is null)
        {
            Debug.Assert(Font is not null);
            _fontWrapper = new Control.FontHandleWrapper(Font);
        }
        return _fontWrapper.Handle;
    }
}

and Control.FontHandleWrapper's constructor allocates the HFONT:

internal FontHandleWrapper(Font font) => _handle = (HFONT)font.ToHfont();

OwnerDrawPropertyBag does not implement IDisposable. FontHandleWrapper does, but the wrapper is only reachable via the discarded property bag, so it is only cleaned up when GC finalizes it (~FontHandleWrapper() => Dispose();).

The per-node cache on node._propBag is the right design — the HFONT would live for the lifetime of the node. But GetItemRenderStyles does not use it as a cache; it copies the Font reference into a fresh bag instead, defeating the cache.

Steps to reproduce

Minimal repro attached (single ~60-line Program.cs, no dependencies): a TreeView with 220 nodes whose NodeFont is set to one shared bold Font, and a timer that calls Invalidate() every 10 ms to simulate paint churn. The window title shows the live GDI object count via GetGuiResources.

GDI-Crash-Sample.zip

Measured on .NET 10.0.7 / Windows 11: the GDI object count climbs from ~4,000 to the 10,000 cap in 3 seconds (~2,400 handles/second with ~40 visible items), after which all further GDI object creation in the process fails.

Note the repro creates exactly one Font object and never calls ToHfont itself — every leaked handle is allocated inside the framework.

The same leak reproduces without the timer through ordinary user interaction:

  1. TreeView with ~50–200 nodes with NodeFont set to a non-default font.
  2. Either start a drag (DoDragDrop from an ItemDrag handler) and move the mouse for a few seconds, or keep expanding/scrolling a large tree.
  3. Watch the process GDI Font handle count climb at roughly (visible nodes × paint rate) per second.

Metadata

Metadata

Assignees

No one assigned

    Labels

    untriagedThe team needs to look at this issue in the next triage

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions