Summary
The animation infrastructure added in #14809 (HighPrecisionTimer, AnimationManager, AnimatedControlRenderer and the modern button adapters) has no disposal gating. Renderers keep being driven after their owning control is disposed, can be resurrected by a late paint, and the modern paint path touches Control.Font before any renderer is consulted.
This was found while stress-testing ~156 ButtonBase controls with ~40 animating concurrently and then closing the Form. All of it is new in .NET 11 preview and has never shipped stable, so there is no downlevel compatibility constraint on the fixes.
Defects
1. AnimatedControlRenderer has no disposed gate
StartAnimation, RestartAnimation, AnimationProc, Invalidate and CompleteAnimation all remain fully functional after Dispose(). There is no _disposed check and no check of Control.IsDisposed / Control.Disposing / GetAnyDisposingInHierarchy().
2. Dispose does not stop the animation
AnimatedControlRenderer.Dispose(bool) unsubscribes SystemVisualSettingsChanged and unregisters from AnimationManager, but never sets IsRunning = false. AnimationManager.OnFrameTickAsync iterates a snapshot, so a tick already in flight still reaches ProcessRenderer, which gates only on item.Renderer.IsRunning — and that is still true. The disposed renderer is driven for at least one more frame, calling AnimationProc and Invalidate on a disposed control.
3. Renderer resurrection via lazy properties
Every renderer is exposed through a lazy ??= new(...) property:
RadioButton.RadioGlyphRenderer, RadioButton.ToggleSwitchRenderer
CheckBox.CheckGlyphRenderer, CheckBox.ToggleSwitchRenderer
TextBoxBase.FocusIndicatorRenderer
UpDownBase.FocusIndicatorRenderer
RadioButton.Dispose correctly disposes and nulls _radioGlyphRenderer, but RadioButtonModernAdapter.PaintCore unconditionally reads Control.RadioGlyphRenderer. A WM_PAINT arriving after disposal therefore constructs a brand new renderer on a disposed control, re-subscribes SystemVisualSettingsChanged, and can re-register it with AnimationManager (NotifyCheckedChanged → RestartAnimation). That is a resurrection plus a rooted-object leak for the lifetime of the manager.
4. Modern adapters touch Control.Font before any renderer gate can help
RadioButtonModernAdapter.Layout and CheckBoxModernAdapter.Layout compute CheckSize from Control.Font.Height:
layout.CheckSize = Math.Max(
Control.LogicalToDeviceUnits(13),
(int)(Control.Font.Height * 0.9f));
This is new behaviour — every classic adapter uses a constant (StandardCheckSize = 13, RadioButtonFlatAdapter.FlatCheckSize = 12, CheckBoxBaseAdapter.FlatCheckSize = 11) or RadioButtonRenderer.GetGlyphSize. Two consequences:
Control.Font.Height is uncached: it acquires a screen DC, creates a Graphics, and calls GdipGetFontHeight on every paint.
- It runs in
Layout(e), i.e. before the renderer is reached, so gating the renderer alone cannot prevent a fault here. This is the frame that actually threw in the reported stack.
5. HighPrecisionTimer: InFlight can stick at 1 forever
DispatchCallbacks sets registration.InFlight on the timer thread before SynchronizationContext.Post. The flag is cleared only when the posted callback actually executes. If the marshalling control's handle is destroyed after the callback was queued, Control.DestroyHandle drains the callback list without invoking the delegate, so InFlight stays 1. That registration then coalesces every subsequent frame forever and emits a DroppedFrames event per tick without ever recovering.
If Post itself throws, the existing catch + ConsecutiveFaultLimit handles it correctly — it is only the "queued, then dropped" path that is unrecoverable.
6. Synchronously-completed ValueTask is never consumed
DispatchCallback inspects callbackTask.IsCompletedSuccessfully and returns without consuming the ValueTask. That violates the consumption contract for pooled IValueTaskSource implementations. It should call GetAwaiter().GetResult() on that path.
7. Bare catch (Exception) in the callback paths
DispatchCallback and AwaitCallbackAsync catch bare Exception, converting critical exceptions (e.g. StackOverflowException-adjacent, OutOfMemoryException, ThreadAbortException) into ordinary callback faults. Every neighbouring catch in the same file uses when (!ex.IsCriticalException()).
8. DroppedFrames diagnostics missing an IsEnabled() guard
Drift and ResidualSpin are guarded with HighPrecisionTimerEventSource.s_log.IsEnabled(...); DroppedFrames is not. It sits on the hot coalescing path, and defect 5 makes it fire every single frame once a registration is stuck.
Proposed approach
Apply the gate as defense in depth, because no single layer covers every path:
control disposal begins
→ AnimatedControlRenderer: set _disposed FIRST, set IsRunning = false,
unregister from AnimationManager immediately
→ renderer accessors do not allocate while disposing/disposed;
paint adapters use the backing field, not the lazy property
→ AnimationManager.ProcessRenderer re-checks before virtual dispatch
and drops disposed renderers
→ modern paint adapters bail out when GetAnyDisposingInHierarchy() is true
(required, because Layout(e) reads Control.Font before any renderer is reached)
Two guidelines that fell out of the analysis:
- Do not gate primarily on
HandleDestroyed — it also fires during legitimate handle recreation.
- Do not gate only on
Control.Disposing — the renderer-owning subclass (e.g. RadioButton.Dispose) begins disposing its renderers before Control.Dispose sets that flag.
One open design decision: when the modern adapter bails out during teardown, should it return completely early, or still paint the parent background? Returning early is simplest and invisible during window destruction, but a slow Dispose on a large form could briefly show an unpainted control.
Related performance follow-ups
Found in the same investigation, listed roughly by value-to-effort. Not blocking, but several are one-liners:
AnimationManager never releases its HighPrecisionTimer registration. It registers once per UI thread in its constructor and holds it until Application.ThreadExit, so a 60 Hz pacer, a BeginInvoke marshal and a dictionary walk run forever even when nothing is animating. Reference-count it with a short hysteresis (~200-500 ms) to avoid first-frame jank, and reset _hasTickTimestamp on restart so a new animation cannot inherit a stale TargetTimestamp. Largest idle CPU/power win.
RestartAnimation churns the registration. It goes StopAnimation → AnimationManager.Suspend (removes from the dictionary) → StartAnimation (allocates a new AnimationRendererItem). Under load this fires twice per control per state change. RegisterOrUpdateAnimationRenderer already has an in-place update path that this defeats. OnAnimationStarted() must still run so each renderer re-captures its start values and resets AnimationProgress; all five OnAnimationStopped() overrides are empty, so skipping the stop notification loses nothing. Largest active-load win.
AnimationManager.OnFrameTickAsync enumerates _renderer.Values, which snapshots a ConcurrentDictionary into a new collection every frame. Enumerate the dictionary directly.
- Use a cached control font height in the modern adapters instead of
Control.Font.Height (see defect 4). Note Control.FontHeight is protected and the adapters are not Control subclasses, so this needs a small internal accessor, and it must be validated across DPI transitions.
AnimatedRadioGlyphRenderer.DrawGlyph uses graphics.Save()/Restore() — a GraphicsState allocation plus a full GDI+ container save — when only SmoothingMode changes. AnimatedToggleSwitchRenderer already does the cheaper local save.
- Same method allocates
new Pen(borderColor, borderThickness) per paint instead of GetCachedPenScope(thickness) as the rest of the repo does. Tempered expectation: GdiPlusCache only caches width-1 pens, so this still allocates above 100% DPI.
Deliberately not proposed: moving rendering off the UI thread, or a Direct2D/Composition-backed path. Graphics is bound to a UI-thread-owned DC and WM_PAINT must be serviced there; the effort is architectural and far out of proportion to the measured win.
Worth profiling before committing to anything: ColorOptions.Calculate (two allocations and ~10 FindNearestColor calls per paint), ParentBackgroundRenderer.Paint (two GDI+ Regions plus a graphics.Clip read plus a parent repaint, per control per frame — likely the largest active cost, but transparency correctness is subtle), and the per-paint LayoutOptions/LayoutData allocations in every button adapter.
Notes
The same investigation surfaced a pre-existing defect that produces an identical-looking ArgumentException from Control.Font and is reachable on .NET 8+ without any of this animation code: #14839. The gating fixes proposed here will mask that bug in the animated-button path, so the two should be fixed independently.
The application used to reproduce this also had a genuine lifetime bug of its own (it disposed a shared Font that live controls still referenced, and it forced a synchronous Update() across 156 controls from OnHandleDestroyed). That app bug is being fixed separately — it is what made the framework gaps above fire reliably, but every defect listed here is independently reachable.
Suggested tests
- After
Dispose(): StartAnimation, RestartAnimation, AnimationProc and Invalidate are no-ops, IsRunning is false, and the renderer is unregistered.
- A tick delivered after renderer disposal does not invoke
AnimationProc; disposed renderers are dropped from the manager.
- Touching
RadioButton.RadioGlyphRenderer (and the CheckBox / TextBoxBase / UpDownBase equivalents) after Dispose() neither allocates a renderer nor re-registers one.
- Disposing a Form containing modern
RadioButtons / CheckBoxes while animations are running and a synchronous Update() is forced must not throw.
HighPrecisionTimer: InFlight recovers when a posted callback is dropped, and the registration continues receiving frames afterwards.
Summary
The animation infrastructure added in #14809 (
HighPrecisionTimer,AnimationManager,AnimatedControlRendererand the modern button adapters) has no disposal gating. Renderers keep being driven after their owning control is disposed, can be resurrected by a late paint, and the modern paint path touchesControl.Fontbefore any renderer is consulted.This was found while stress-testing ~156
ButtonBasecontrols with ~40 animating concurrently and then closing the Form. All of it is new in .NET 11 preview and has never shipped stable, so there is no downlevel compatibility constraint on the fixes.Defects
1.
AnimatedControlRendererhas no disposed gateStartAnimation,RestartAnimation,AnimationProc,InvalidateandCompleteAnimationall remain fully functional afterDispose(). There is no_disposedcheck and no check ofControl.IsDisposed/Control.Disposing/GetAnyDisposingInHierarchy().2.
Disposedoes not stop the animationAnimatedControlRenderer.Dispose(bool)unsubscribesSystemVisualSettingsChangedand unregisters fromAnimationManager, but never setsIsRunning = false.AnimationManager.OnFrameTickAsynciterates a snapshot, so a tick already in flight still reachesProcessRenderer, which gates only onitem.Renderer.IsRunning— and that is stilltrue. The disposed renderer is driven for at least one more frame, callingAnimationProcandInvalidateon a disposed control.3. Renderer resurrection via lazy properties
Every renderer is exposed through a lazy
??= new(...)property:RadioButton.RadioGlyphRenderer,RadioButton.ToggleSwitchRendererCheckBox.CheckGlyphRenderer,CheckBox.ToggleSwitchRendererTextBoxBase.FocusIndicatorRendererUpDownBase.FocusIndicatorRendererRadioButton.Disposecorrectly disposes and nulls_radioGlyphRenderer, butRadioButtonModernAdapter.PaintCoreunconditionally readsControl.RadioGlyphRenderer. AWM_PAINTarriving after disposal therefore constructs a brand new renderer on a disposed control, re-subscribesSystemVisualSettingsChanged, and can re-register it withAnimationManager(NotifyCheckedChanged→RestartAnimation). That is a resurrection plus a rooted-object leak for the lifetime of the manager.4. Modern adapters touch
Control.Fontbefore any renderer gate can helpRadioButtonModernAdapter.LayoutandCheckBoxModernAdapter.LayoutcomputeCheckSizefromControl.Font.Height:This is new behaviour — every classic adapter uses a constant (
StandardCheckSize = 13,RadioButtonFlatAdapter.FlatCheckSize = 12,CheckBoxBaseAdapter.FlatCheckSize = 11) orRadioButtonRenderer.GetGlyphSize. Two consequences:Control.Font.Heightis uncached: it acquires a screen DC, creates aGraphics, and callsGdipGetFontHeighton every paint.Layout(e), i.e. before the renderer is reached, so gating the renderer alone cannot prevent a fault here. This is the frame that actually threw in the reported stack.5.
HighPrecisionTimer:InFlightcan stick at 1 foreverDispatchCallbackssetsregistration.InFlighton the timer thread beforeSynchronizationContext.Post. The flag is cleared only when the posted callback actually executes. If the marshalling control's handle is destroyed after the callback was queued,Control.DestroyHandledrains the callback list without invoking the delegate, soInFlightstays1. That registration then coalesces every subsequent frame forever and emits aDroppedFramesevent per tick without ever recovering.If
Postitself throws, the existingcatch+ConsecutiveFaultLimithandles it correctly — it is only the "queued, then dropped" path that is unrecoverable.6. Synchronously-completed
ValueTaskis never consumedDispatchCallbackinspectscallbackTask.IsCompletedSuccessfullyand returns without consuming theValueTask. That violates the consumption contract for pooledIValueTaskSourceimplementations. It should callGetAwaiter().GetResult()on that path.7. Bare
catch (Exception)in the callback pathsDispatchCallbackandAwaitCallbackAsynccatch bareException, converting critical exceptions (e.g.StackOverflowException-adjacent,OutOfMemoryException,ThreadAbortException) into ordinary callback faults. Every neighbouring catch in the same file useswhen (!ex.IsCriticalException()).8.
DroppedFramesdiagnostics missing anIsEnabled()guardDriftandResidualSpinare guarded withHighPrecisionTimerEventSource.s_log.IsEnabled(...);DroppedFramesis not. It sits on the hot coalescing path, and defect 5 makes it fire every single frame once a registration is stuck.Proposed approach
Apply the gate as defense in depth, because no single layer covers every path:
Two guidelines that fell out of the analysis:
HandleDestroyed— it also fires during legitimate handle recreation.Control.Disposing— the renderer-owning subclass (e.g.RadioButton.Dispose) begins disposing its renderers beforeControl.Disposesets that flag.One open design decision: when the modern adapter bails out during teardown, should it return completely early, or still paint the parent background? Returning early is simplest and invisible during window destruction, but a slow
Disposeon a large form could briefly show an unpainted control.Related performance follow-ups
Found in the same investigation, listed roughly by value-to-effort. Not blocking, but several are one-liners:
AnimationManagernever releases itsHighPrecisionTimerregistration. It registers once per UI thread in its constructor and holds it untilApplication.ThreadExit, so a 60 Hz pacer, aBeginInvokemarshal and a dictionary walk run forever even when nothing is animating. Reference-count it with a short hysteresis (~200-500 ms) to avoid first-frame jank, and reset_hasTickTimestampon restart so a new animation cannot inherit a staleTargetTimestamp. Largest idle CPU/power win.RestartAnimationchurns the registration. It goesStopAnimation→AnimationManager.Suspend(removes from the dictionary) →StartAnimation(allocates a newAnimationRendererItem). Under load this fires twice per control per state change.RegisterOrUpdateAnimationRendereralready has an in-place update path that this defeats.OnAnimationStarted()must still run so each renderer re-captures its start values and resetsAnimationProgress; all fiveOnAnimationStopped()overrides are empty, so skipping the stop notification loses nothing. Largest active-load win.AnimationManager.OnFrameTickAsyncenumerates_renderer.Values, which snapshots aConcurrentDictionaryinto a new collection every frame. Enumerate the dictionary directly.Control.Font.Height(see defect 4). NoteControl.FontHeightisprotectedand the adapters are notControlsubclasses, so this needs a smallinternalaccessor, and it must be validated across DPI transitions.AnimatedRadioGlyphRenderer.DrawGlyphusesgraphics.Save()/Restore()— aGraphicsStateallocation plus a full GDI+ container save — when onlySmoothingModechanges.AnimatedToggleSwitchRendereralready does the cheaper local save.new Pen(borderColor, borderThickness)per paint instead ofGetCachedPenScope(thickness)as the rest of the repo does. Tempered expectation:GdiPlusCacheonly caches width-1 pens, so this still allocates above 100% DPI.Deliberately not proposed: moving rendering off the UI thread, or a Direct2D/Composition-backed path.
Graphicsis bound to a UI-thread-owned DC andWM_PAINTmust be serviced there; the effort is architectural and far out of proportion to the measured win.Worth profiling before committing to anything:
ColorOptions.Calculate(two allocations and ~10FindNearestColorcalls per paint),ParentBackgroundRenderer.Paint(two GDI+Regions plus agraphics.Clipread plus a parent repaint, per control per frame — likely the largest active cost, but transparency correctness is subtle), and the per-paintLayoutOptions/LayoutDataallocations in every button adapter.Notes
The same investigation surfaced a pre-existing defect that produces an identical-looking
ArgumentExceptionfromControl.Fontand is reachable on .NET 8+ without any of this animation code: #14839. The gating fixes proposed here will mask that bug in the animated-button path, so the two should be fixed independently.The application used to reproduce this also had a genuine lifetime bug of its own (it disposed a shared
Fontthat live controls still referenced, and it forced a synchronousUpdate()across 156 controls fromOnHandleDestroyed). That app bug is being fixed separately — it is what made the framework gaps above fire reliably, but every defect listed here is independently reachable.Suggested tests
Dispose():StartAnimation,RestartAnimation,AnimationProcandInvalidateare no-ops,IsRunningisfalse, and the renderer is unregistered.AnimationProc; disposed renderers are dropped from the manager.RadioButton.RadioGlyphRenderer(and theCheckBox/TextBoxBase/UpDownBaseequivalents) afterDispose()neither allocates a renderer nor re-registers one.RadioButtons /CheckBoxes while animations are running and a synchronousUpdate()is forced must not throw.HighPrecisionTimer:InFlightrecovers when a posted callback is dropped, and the registration continues receiving frames afterwards.