.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:
TreeView with ~50–200 nodes with NodeFont set to a non-default font.
- Either start a drag (
DoDragDrop from an ItemDrag handler) and move the mouse for a few seconds, or keep expanding/scrolling a large tree.
- Watch the process GDI Font handle count climb at roughly (visible nodes × paint rate) per second.
.NET version
.NET 10.0.7 (CoreCLR 10.0.726.21808), also verified against current
mainsourcesDid 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.CustomDrawallocates a freshOwnerDrawPropertyBagfor every visible item on every paint cycle (viaGetItemRenderStyles). The bag's lazyFontHandlegetter creates a newControl.FontHandleWrapper, which callsFont.ToHfont()— allocating an HFONT GDI handle. The bag is then discarded with noDisposecall; 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 withGetLastError() == 0, producing the misleading exception: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:Diagnostic data (real-world application)
GDIView snapshots taken 2 seconds apart during a drag in a production application:
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):The caller in
CustomDraw(~line 2785):renderinfo.FontHandleis the lazy getter inOwnerDrawPropertyBag.cs:and
Control.FontHandleWrapper's constructor allocates the HFONT:OwnerDrawPropertyBagdoes not implementIDisposable.FontHandleWrapperdoes, 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._propBagis the right design — the HFONT would live for the lifetime of the node. ButGetItemRenderStylesdoes not use it as a cache; it copies theFontreference into a fresh bag instead, defeating the cache.Steps to reproduce
Minimal repro attached (single ~60-line
Program.cs, no dependencies): aTreeViewwith 220 nodes whoseNodeFontis set to one shared boldFont, and a timer that callsInvalidate()every 10 ms to simulate paint churn. The window title shows the live GDI object count viaGetGuiResources.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
Fontobject and never callsToHfontitself — every leaked handle is allocated inside the framework.The same leak reproduces without the timer through ordinary user interaction:
TreeViewwith ~50–200 nodes withNodeFontset to a non-default font.DoDragDropfrom anItemDraghandler) and move the mouse for a few seconds, or keep expanding/scrolling a large tree.