Summary
Control.Dispose disposes every Font cached in _dpiFonts but does not clear _scaledControlFont, which aliases those exact instances. After disposal, Control.Font therefore returns a disposed Font, and every member that touches the native GDI+ handle throws.
This has been present since #9112 (May 2023), i.e. it ships in .NET 8 and later. It only manifests in PerMonitorV2 applications where font scaling has produced a cached DPI font.
Observed exception
System.ArgumentException: Parameter is not valid.
at System.Drawing.Font.GetHeight()
at System.Windows.Forms.ButtonInternal.RadioButtonModernAdapter.PaintCore(PaintEventArgs e)
at System.Windows.Forms.ButtonBase.OnPaint(PaintEventArgs pevent)
at System.Windows.Forms.RadioButton.OnPaint(PaintEventArgs pevent)
at System.Windows.Forms.Control.PaintWithErrorHandling(PaintEventArgs e, Int16 layer)
at System.Windows.Forms.Control.WmPaint(Message& m)
at System.Windows.Forms.Control.WndProc(Message& m)
at System.Windows.Forms.NativeWindow.Callback(HWND hWnd, UInt32 msg, WPARAM wparam, LPARAM lparam)
GdipGetFontHeight returns InvalidParameter for a freed native font handle, which surfaces as ArgumentException("Parameter is not valid.").
Root cause
In Control:
GetScaledFont caches owned Font instances in _dpiFonts and returns the dictionary values by reference.
- DPI handling assigns one of those same instances to
ScaledControlFont (_scaledControlFont).
GetCurrentFontAndDpi — which backs the public Control.Font getter — returns ScaledControlFont first, before any explicit font or parent walk.
ClearDpiFonts() disposes every value in _dpiFonts and clears the dictionary, but leaves _scaledControlFont pointing at a now-disposed instance.
Dispose(bool) calls ClearDpiFonts().
The intended invariant is visible in the Font setter, which does the right thing:
if (ScaleHelper.IsThreadPerMonitorV2Aware)
{
ScaledControlFont = null; // clear the alias FIRST
ClearDpiFonts();
}
The dispose path omits the first line.
Two related loose ends:
ClearDpiFonts does not reset the s_fontHeightProperty cache, producing an asymmetry where Control.FontHeight still returns a value while Control.Font.Height throws.
SetScaledFont can also alias the same cached instance into s_fontProperty, so clearing ScaledControlFont alone would not fully close the hole.
Impact
After control.Dispose(), on a PerMonitorV2 app where font scaling has occurred:
Control.Font returns a disposed instance (non-null).
- Managed members still return correct values (
Name, Size, Style, Unit, FontFamily) because those are cached managed fields.
- Native-touching members throw:
Height, GetHeight(), ToHfont(), ToLogFont(), SizeInPoints (when Unit != Point).
Anything that paints or measures using Control.Font after disposal has begun — including a WM_PAINT that arrives while a control tree is being torn down — can fault. Because Control.PaintWithErrorHandling rethrows after setting States.ExceptionWhilePainting, this surfaces as an unhandled-exception dialog in a Release build with no debugger attached.
Proposed fix
Two options were considered. Option B is recommended on backward-compatibility grounds.
Option A — clear ScaledControlFont before disposing _dpiFonts (mirrors the Font setter).
After the fix, Control.Font on a disposed control falls through the parent walk (the parent is already detached) and returns DefaultFont. That is a silent behaviour change: code that reads control.Font.Name or .Size in a Disposed handler — for example persisting UI state during teardown — currently receives the real scaled-font values and would afterwards receive DefaultFont values, with no exception to signal it. A silent wrong-value regression is arguably worse than the current throw.
Option B (recommended) — do not dispose instances that are still aliased.
In ClearDpiFonts, skip disposing the instance referenced by _scaledControlFont (and any instance aliased into s_fontProperty by SetScaledFont); dispose the remaining _dpiFonts entries exactly as today.
Control.Font keeps returning the same instance with the same values as today.
- Members that previously threw now succeed and return the correct value.
- This is strictly a throw → success transition with no observable value change anywhere.
- Cost: at most one
Font per disposed control is released by its finalizer rather than eagerly. Font has a finalizer that frees the native handle, so this is deferred cleanup, not a leak.
Known workaround shapes all survive Option B:
| Workaround |
Survives Option B |
control.Font = null before disposing (already takes the safe setter path) |
Yes, untouched |
catch (ArgumentException) around paint / font access |
Yes — the catch simply stops being hit |
Overriding OnPaint to skip while Disposing |
Yes, untouched |
| Disposing the font manually |
Yes — Font.Dispose is idempotent |
No AppContext switch is required for Option B. Option A would need one.
Additionally: reset the s_fontHeightProperty cache in the same place so Control.FontHeight and Control.Font.Height cannot disagree.
Suggested tests
PerMonitorV2 coverage for, after Dispose():
- explicitly set font
- inherited font
- font scaled to a non-96 DPI and then scaled back to 96 DPI
Control.Font.Height does not throw, and Control.FontHeight agrees with it
Notes
Found while investigating an animation stress test that closes a Form with many animated ButtonBase controls. The animation work is unrelated to this defect and is tracked separately; this bug is reachable without any of it, on .NET 8+.
Summary
Control.Disposedisposes everyFontcached in_dpiFontsbut does not clear_scaledControlFont, which aliases those exact instances. After disposal,Control.Fonttherefore returns a disposedFont, and every member that touches the native GDI+ handle throws.This has been present since #9112 (May 2023), i.e. it ships in .NET 8 and later. It only manifests in PerMonitorV2 applications where font scaling has produced a cached DPI font.
Observed exception
GdipGetFontHeightreturnsInvalidParameterfor a freed native font handle, which surfaces asArgumentException("Parameter is not valid.").Root cause
In
Control:GetScaledFontcaches ownedFontinstances in_dpiFontsand returns the dictionary values by reference.ScaledControlFont(_scaledControlFont).GetCurrentFontAndDpi— which backs the publicControl.Fontgetter — returnsScaledControlFontfirst, before any explicit font or parent walk.ClearDpiFonts()disposes every value in_dpiFontsand clears the dictionary, but leaves_scaledControlFontpointing at a now-disposed instance.Dispose(bool)callsClearDpiFonts().The intended invariant is visible in the
Fontsetter, which does the right thing:The dispose path omits the first line.
Two related loose ends:
ClearDpiFontsdoes not reset thes_fontHeightPropertycache, producing an asymmetry whereControl.FontHeightstill returns a value whileControl.Font.Heightthrows.SetScaledFontcan also alias the same cached instance intos_fontProperty, so clearingScaledControlFontalone would not fully close the hole.Impact
After
control.Dispose(), on a PerMonitorV2 app where font scaling has occurred:Control.Fontreturns a disposed instance (non-null).Name,Size,Style,Unit,FontFamily) because those are cached managed fields.Height,GetHeight(),ToHfont(),ToLogFont(),SizeInPoints(whenUnit != Point).Anything that paints or measures using
Control.Fontafter disposal has begun — including aWM_PAINTthat arrives while a control tree is being torn down — can fault. BecauseControl.PaintWithErrorHandlingrethrows after settingStates.ExceptionWhilePainting, this surfaces as an unhandled-exception dialog in a Release build with no debugger attached.Proposed fix
Two options were considered. Option B is recommended on backward-compatibility grounds.
Option A — clear
ScaledControlFontbefore disposing_dpiFonts(mirrors theFontsetter).After the fix,
Control.Fonton a disposed control falls through the parent walk (the parent is already detached) and returnsDefaultFont. That is a silent behaviour change: code that readscontrol.Font.Nameor.Sizein aDisposedhandler — for example persisting UI state during teardown — currently receives the real scaled-font values and would afterwards receiveDefaultFontvalues, with no exception to signal it. A silent wrong-value regression is arguably worse than the current throw.Option B (recommended) — do not dispose instances that are still aliased.
In
ClearDpiFonts, skip disposing the instance referenced by_scaledControlFont(and any instance aliased intos_fontPropertybySetScaledFont); dispose the remaining_dpiFontsentries exactly as today.Control.Fontkeeps returning the same instance with the same values as today.Fontper disposed control is released by its finalizer rather than eagerly.Fonthas a finalizer that frees the native handle, so this is deferred cleanup, not a leak.Known workaround shapes all survive Option B:
control.Font = nullbefore disposing (already takes the safe setter path)catch (ArgumentException)around paint / font accessOnPaintto skip whileDisposingFont.Disposeis idempotentNo
AppContextswitch is required for Option B. Option A would need one.Additionally: reset the
s_fontHeightPropertycache in the same place soControl.FontHeightandControl.Font.Heightcannot disagree.Suggested tests
PerMonitorV2 coverage for, after
Dispose():Control.Font.Heightdoes not throw, andControl.FontHeightagrees with itNotes
Found while investigating an animation stress test that closes a Form with many animated
ButtonBasecontrols. The animation work is unrelated to this defect and is tracked separately; this bug is reachable without any of it, on .NET 8+.