From f38fcc8f520d7107f2d2018cac097c4db7c206fd Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 11 Jul 2022 23:28:47 +0200 Subject: [PATCH 1/3] Updated ncrunch config. --- ...crunchproject => ControlCatalog.net6.0.v3.ncrunchproject} | 0 .ncrunch/ControlCatalog.netstandard2.0.v3.ncrunchproject | 5 +++++ 2 files changed, 5 insertions(+) rename .ncrunch/{ControlCatalog.v3.ncrunchproject => ControlCatalog.net6.0.v3.ncrunchproject} (100%) create mode 100644 .ncrunch/ControlCatalog.netstandard2.0.v3.ncrunchproject diff --git a/.ncrunch/ControlCatalog.v3.ncrunchproject b/.ncrunch/ControlCatalog.net6.0.v3.ncrunchproject similarity index 100% rename from .ncrunch/ControlCatalog.v3.ncrunchproject rename to .ncrunch/ControlCatalog.net6.0.v3.ncrunchproject diff --git a/.ncrunch/ControlCatalog.netstandard2.0.v3.ncrunchproject b/.ncrunch/ControlCatalog.netstandard2.0.v3.ncrunchproject new file mode 100644 index 0000000000..319cd523ce --- /dev/null +++ b/.ncrunch/ControlCatalog.netstandard2.0.v3.ncrunchproject @@ -0,0 +1,5 @@ + + + True + + \ No newline at end of file From 9e4a5972253cbdb550f64c1a71b73746e09b2649 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 12 Jul 2022 14:12:45 +0200 Subject: [PATCH 2/3] Added failing tests for #8480. --- .../ClassesTests.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/Avalonia.Controls.UnitTests/ClassesTests.cs b/tests/Avalonia.Controls.UnitTests/ClassesTests.cs index c10e7b467c..bcd2b6ec1f 100644 --- a/tests/Avalonia.Controls.UnitTests/ClassesTests.cs +++ b/tests/Avalonia.Controls.UnitTests/ClassesTests.cs @@ -168,5 +168,36 @@ namespace Avalonia.Controls.UnitTests Assert.Equal(new[] { "foo" }, target); } + + [Fact] + public void Listeners_Can_Be_Added_By_Listener() + { + var classes = new Classes(); + var listener1 = new ClassesChangedListener(() => { }); + var listener2 = new ClassesChangedListener(() => classes.AddListener(listener1)); + + classes.AddListener(listener2); + classes.Add("bar"); + } + + [Fact] + public void Listeners_Can_Be_Removed_By_Listener() + { + var classes = new Classes(); + var listener1 = new ClassesChangedListener(() => { }); + var listener2 = new ClassesChangedListener(() => classes.RemoveListener(listener1)); + + classes.AddListener(listener1); + classes.AddListener(listener2); + classes.Add("bar"); + } + + private class ClassesChangedListener : IClassesChangedListener + { + private Action _action; + + public ClassesChangedListener(Action action) => _action = action; + public void Changed() => _action(); + } } } From 76274f80b35bbe534a7c067457234212fd76e4b4 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Tue, 12 Jul 2022 15:10:00 +0200 Subject: [PATCH 3/3] Added SafeEnumerableList and use in Classes. Adds a simple copy-on-write list and uses it to store the listeners to `Classes` to prevent problems with re-entrancy. Fixes #8480 --- src/Avalonia.Base/Controls/Classes.cs | 5 +- .../Utilities/SafeEnumerableList.cs | 89 ++++++++++++ .../Utils/SafeEnumerableListTests.cs | 130 ++++++++++++++++++ 3 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 src/Avalonia.Base/Utilities/SafeEnumerableList.cs create mode 100644 tests/Avalonia.Controls.UnitTests/Utils/SafeEnumerableListTests.cs diff --git a/src/Avalonia.Base/Controls/Classes.cs b/src/Avalonia.Base/Controls/Classes.cs index e64209c3cb..c3d3fbca46 100644 --- a/src/Avalonia.Base/Controls/Classes.cs +++ b/src/Avalonia.Base/Controls/Classes.cs @@ -1,8 +1,7 @@ using System; using System.Collections.Generic; using Avalonia.Collections; - -#nullable enable +using Avalonia.Utilities; namespace Avalonia.Controls { @@ -14,7 +13,7 @@ namespace Avalonia.Controls /// public class Classes : AvaloniaList, IPseudoClasses { - private List? _listeners; + private SafeEnumerableList? _listeners; /// /// Initializes a new instance of the class. diff --git a/src/Avalonia.Base/Utilities/SafeEnumerableList.cs b/src/Avalonia.Base/Utilities/SafeEnumerableList.cs new file mode 100644 index 0000000000..dd437d27be --- /dev/null +++ b/src/Avalonia.Base/Utilities/SafeEnumerableList.cs @@ -0,0 +1,89 @@ +using System.Collections; +using System.Collections.Generic; + +namespace Avalonia.Utilities +{ + /// + /// Implements a simple list which is safe to modify during enumeration. + /// + /// The item type. + /// + /// Implements a list which, when written to while enumerating, performs a copy of the list + /// items. Note this this class doesn't actually implement as it's not + /// currently needed - feel free to add missing methods etc. + /// + internal class SafeEnumerableList : IEnumerable + { + private List _list = new(); + private int _generation; + private int _enumCount = 0; + + public int Count => _list.Count; + internal List Inner => _list; + + public void Add(T item) => GetList().Add(item); + public bool Remove(T item) => GetList().Remove(item); + + public Enumerator GetEnumerator() => new(this, _list); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + private List GetList() + { + if (_enumCount > 0) + { + _list = new(_list); + ++_generation; + _enumCount = 0; + } + + return _list; + } + + public struct Enumerator : IEnumerator, IEnumerator + { + private readonly SafeEnumerableList _owner; + private readonly List _list; + private readonly int _generation; + private int _index; + private T? _current; + + internal Enumerator(SafeEnumerableList owner, List list) + { + _owner = owner; + _list = list; + _generation = owner._generation; + _index = 0; + _current = default; + ++_owner._enumCount; + } + + public void Dispose() + { + if (_owner._generation == _generation) + --_owner._enumCount; + } + + public bool MoveNext() + { + if (_index < _list.Count) + { + _current = _list[_index++]; + return true; + } + + _current = default; + return false; + } + + public T Current => _current!; + object? IEnumerator.Current => _current; + + void IEnumerator.Reset() + { + _index = 0; + _current = default; + } + } + } +} diff --git a/tests/Avalonia.Controls.UnitTests/Utils/SafeEnumerableListTests.cs b/tests/Avalonia.Controls.UnitTests/Utils/SafeEnumerableListTests.cs new file mode 100644 index 0000000000..f48bde4731 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/Utils/SafeEnumerableListTests.cs @@ -0,0 +1,130 @@ +using System.Collections.Generic; +using Avalonia.Utilities; +using Xunit; + +namespace Avalonia.Controls.UnitTests.Utils +{ + public class SafeEnumerableListTests + { + [Fact] + public void List_Is_Not_Copied_Outside_Enumeration() + { + var target = new SafeEnumerableList(); + var inner = target.Inner; + + target.Add("foo"); + target.Add("bar"); + target.Remove("foo"); + + Assert.Same(inner, target.Inner); + } + + [Fact] + public void List_Is_Copied_Outside_Enumeration() + { + var target = new SafeEnumerableList(); + var inner = target.Inner; + + target.Add("foo"); + + foreach (var i in target) + { + Assert.Same(inner, target.Inner); + target.Add("bar"); + Assert.NotSame(inner, target.Inner); + Assert.Equal("foo", i); + } + + inner = target.Inner; + + foreach (var i in target) + { + target.Add("baz"); + Assert.NotSame(inner, target.Inner); + } + + Assert.Equal(new[] { "foo", "bar", "baz", "baz" }, target); + } + + [Fact] + public void List_Is_Not_Copied_After_Enumeration() + { + var target = new SafeEnumerableList(); + var inner = target.Inner; + + target.Add("foo"); + + foreach (var i in target) + { + target.Add("bar"); + Assert.NotSame(inner, target.Inner); + inner = target.Inner; + Assert.Equal("foo", i); + } + + target.Add("baz"); + Assert.Same(inner, target.Inner); + } + + [Fact] + public void List_Is_Copied_Only_Once_During_Enumeration() + { + var target = new SafeEnumerableList(); + var inner = target.Inner; + + target.Add("foo"); + + foreach (var i in target) + { + target.Add("bar"); + Assert.NotSame(inner, target.Inner); + inner = target.Inner; + target.Add("baz"); + Assert.Same(inner, target.Inner); + } + + target.Add("baz"); + } + + [Fact] + public void List_Is_Copied_During_Nested_Enumerations() + { + var target = new SafeEnumerableList(); + var initialInner = target.Inner; + var firstItems = new List(); + var secondItems = new List(); + List firstInner; + List secondInner; + + target.Add("foo"); + + foreach (var i in target) + { + target.Add("bar"); + + firstInner = target.Inner; + Assert.NotSame(initialInner, firstInner); + + foreach (var j in target) + { + target.Add("baz"); + + secondInner = target.Inner; + Assert.NotSame(firstInner, secondInner); + + secondItems.Add(j); + } + + firstItems.Add(i); + } + + Assert.Equal(new[] { "foo" }, firstItems); + Assert.Equal(new[] { "foo", "bar" }, secondItems); + Assert.Equal(new[] { "foo", "bar", "baz", "baz" }, target); + + var finalInner = target.Inner; + target.Add("final"); + Assert.Same(finalInner, target.Inner); + } + } +}