#22232·Avalonia

ControlAutomationPeer subscribes to its owner in its constructor and never detaches

Author: m5xCreated Sep 14, 2026Updated Sep 14, 2026
Labelsbug

Describe the bug

Summary

ControlAutomationPeer is public and unsealed, so a control that manages its own child peers (a custom control exposing one automation child per logical unit — a segment of a diff, a token in a flow, a cell in a hand-rolled grid) naturally derives its child peers from it. Its constructor subscribes to Owner.PropertyChanged and Owner.VisualChildren.CollectionChanged inside a private Initialize() step, and nothing in the type — no Dispose, no protected detach hook, no virtual on the two handlers (OwnerPropertyChanged, VisualChildrenChanged) — ever removes those subscriptions. Every peer ever constructed over a control stays wired to that control for the control's lifetime, and the subscriptions keep the peer itself reachable, so retired peers are never collected.

For the framework's own one-peer-per-control usage this is invisible: the peer lives as long as the control. For any consumer that constructs more than one peer over a single control — the shape GetChildrenCore invites when the child peers are regenerated after a content change — it is an unbounded leak.

Observed behaviour (measured)

A custom control replaces its set of child peers each time its content changes (the previous generation is dropped from GetChildrenCore; InvalidateChildren is raised). The child peers derive from ControlAutomationPeer with the parent control as Owner. Counting handler invocations per content change, with the peer generations that are still subscribed:

content change live child peers ChildrenChanged raised during it PropertyChanged raised during it
1st 5 20 5
2nd 5 40 10
3rd 5 60 15
4th 5 80 20
5th 5 100 25

The live child count never leaves five; only the count of peers still listening grows, exactly linearly in the number of content changes. The same probe over child peers deriving from AutomationPeer directly (no owner subscription) reads 0 / 0 on every row.

Two secondary observations from the same probe, both consequences of the owner-derived handlers being private and non-virtual:

  • The PropertyChanged each child peer raises is a BoundingRectangleProperty change carrying the OWNER's bounds (0, 0, 255, 29 — the whole control) even though the child peer's own GetBoundingRectangleCore answers a much smaller rect (0, 0, 46.478, 28.05). A derived peer cannot correct this notification because the handler that raises it is not overridable.
  • With zero retired generations, five live child peers raise twenty ChildrenChanged events for one content change, each telling the consumer to re-walk a child list that did not change (child peers have no children).

Impact

A consumer that derives child peers from ControlAutomationPeer and regenerates them leaks peers and subscriptions without bound for the life of the control, with an accessibility notification storm that grows with every content change. The only workaround available to a consumer is to derive from AutomationPeer instead, which costs the base's screen conversion: AutomationPeer.ToScreenCore is private protected and, of the 55 AutomationPeer subtypes the framework ships, only ControlAutomationPeer declares it, so a peer not deriving from ControlAutomationPeer cannot supply a screen-space rectangle, and the Win32 UIA node then reports a default bounding rectangle for that element (the Win32 automation node computes Peer.ToScreen(Peer.GetBoundingRectangle()) ?? default). Consumers are therefore forced to choose between a leak and a screen reader that cannot highlight or click the element on Windows.

To Reproduce

  1. Create a Control subclass whose OnCreateAutomationPeer returns a peer that answers N child peers from GetChildrenCore, each child deriving from ControlAutomationPeer with the parent control as Owner (children answer their own GetBoundingRectangleCore).
  2. On a content change, build a fresh set of N child peers, drop the old set, and call InvalidateChildren.
  3. Subscribe a counter to ChildrenChanged and PropertyChanged on every child peer ever constructed.
  4. Change the content K times; on the next change count the raised events.
  5. Observe the counts grow linearly with K while the live child count stays N; observe every PropertyChanged carrying the owner's bounds.

Expected behavior

One of:

  • ControlAutomationPeer exposes a way to detach from its owner (a protected Detach/Dispose seam, or detaching automatically when the peer is no longer reachable from its parent's children), so a consumer regenerating child peers can release the previous generation; or
  • the two owner handlers are protected virtual (or the subscription step itself is overridable), so a derived peer whose bounding rectangle is not its owner's can suppress or correct the owner-derived notifications; or
  • the documentation of ControlAutomationPeer states that exactly one peer may be constructed per control and that derived multi-peer designs must use AutomationPeer directly.

Avalonia version

12.1.0

OS

Linux

Additional context

Root cause

ControlAutomationPeer's constructor calls a private Initialize() which subscribes OwnerPropertyChanged to Owner.PropertyChanged and VisualChildrenChanged to Owner.VisualChildren.CollectionChanged. All three members are private and no n-virtual; the type declares nothing that reverses the subscriptions. OwnerPropertyChanged raises BoundingRectangleProperty from the owner's Bounds change regardless of what the derived peer answers for its own rectangle.

Suggested fix

Add a protected detach seam (or make the peer IDisposable, called from the parent's children invalidation for peers that are no longer returned), and make OwnerPropertyChanged / VisualChildrenChanged protected virtual. Either half alone closes one of the two problems; both together let a multi-peer control be correct. If neither is wanted, document the one-peer-per-control constraint on the type.