Describe the bug π
The dynamic Filter operator throws System.InvalidOperationException: Collection was modified; enumeration operation may not execute on .NET Framework 4.8 whenever the filter predicate is re-evaluated while items are present.
- Works: DynamicData
9.4.1
- Broken: DynamicData
9.4.31
- Reproduces only on
net48 β not on net6.0 / net8.0.
Filter.Dynamic<...>.Subscription.ReFilter reassigns into the backing dictionary while enumerating its Keys:
private void ReFilter(TState predicateState)
{
foreach (var key in _itemStatesByKey.Keys) // enumerate Keys
{
...
_itemStatesByKey[key] = new() { IsIncluded = ..., Item = ... }; // invalidate
}
}
On .NET Framework 4.8, Dictionary<,>.this[set] bumps the internal version counter even when only updating an existing key (Insert does entries[i].value = value; version++;), so the in-progress Keys enumeration throws. .NET Core / .NET 5+ changed this so the update path no longer bumps version β which is why the same code is silent there and the bug is easy to miss when testing only on modern frameworks.
This breaks the (common) "dynamic filter whose predicate depends on the filtered output" pattern, e.g. an expand/collapse tree where the visible-node set feeds back into its own visibility filter.
Step to reproduce
Minimal console repro, target net48, reference DynamicData 9.4.31:
var source = new SourceCache<Node, int>(n => n.Id);
var visible = new SourceCache<Node, int>(n => n.Id); // input to the filter AND output of the pipeline
// Self-referential filter: a node is visible iff it is a root, or its parent is expanded.
var visibleFilter = visible.Connect()
.AutoRefresh(n => n.IsExpanded)
.StartWithEmpty()
.QueryWhenChanged(q =>
{
var expanded = q.Items.Where(n => n.IsExpanded).Select(n => n.Id).ToHashSet();
return new Func<Node, bool>(node => !node.ParentId.HasValue || expanded.Contains(node.ParentId.Value));
});
source.Connect()
.Filter(visibleFilter)
.PopulateInto(visible);
source.AddOrUpdate(new[] { new Node(1, null), new Node(2, 1), new Node(3, 2) });
// ^ throws on net48 + 9.4.31: root passes the filter -> PopulateInto mutates `visible`
// -> predicate re-emits -> ReFilter runs over the now non-empty _itemStatesByKey
dotnet build -c Release / dotnet run -c Release with DynamicDataVersion=9.4.31 on net48 β throws on load.
- Set
DynamicDataVersion=9.4.1, rebuild/run β prints After load (expect [1]): [1], no exception.
- (Optional) Retarget to
net8.0 on 9.4.31 β no exception (demonstrates it is framework-specific).
Node is a plain INotifyPropertyChanged with int Id, int? ParentId, bool IsExpanded.
Reproduction repository
https://gist.github.com/Snailya/da8dda754d84425c76bce67a5f9606c9 (3 files: Program.cs, Node.cs, DynamicDataRepro.csproj β git clone-able)
Expected behavior
No exception; the predicate re-evaluation completes (as on 9.4.1, and as on net8.0).
Screenshots πΌοΈ
n/a
IDE
Visual Studio 2022
Operating system
Windows
Version
Windows 11
Device
n/a
DynamicData Version
9.4.31 (regressed from 9.4.1)
Additional information βΉοΈ
Stack trace (net48, 9.4.31):
System.InvalidOperationException
at System.ThrowHelper.ThrowInvalidOperationException(ExceptionResource resource)
at System.Collections.Generic.Dictionary`2.KeyCollection.Enumerator.MoveNext()
at DynamicData.Cache.Internal.Filter.Dynamic<...>.Subscription.ReFilter(Func`2 predicateState) Line 368
at DynamicData.Cache.Internal.Filter.Dynamic<...>.Subscription.OnPredicateStateNext(Func`2 predicateState) Line 173
at System.Reactive.ObserverBase`1.OnNext(...)
Suggested fix β snapshot the keys before mutating:
foreach (var key in _itemStatesByKey.Keys.ToList())
{
...
_itemStatesByKey[key] = new() { IsIncluded = isIncluded, Item = itemState.Item };
}
Describe the bug π
The dynamic
Filteroperator throwsSystem.InvalidOperationException: Collection was modified; enumeration operation may not executeon .NET Framework 4.8 whenever the filter predicate is re-evaluated while items are present.9.4.19.4.31net48β not onnet6.0/net8.0.Filter.Dynamic<...>.Subscription.ReFilterreassigns into the backing dictionary while enumerating itsKeys:On .NET Framework 4.8,
Dictionary<,>.this[set]bumps the internal version counter even when only updating an existing key (Insertdoesentries[i].value = value; version++;), so the in-progressKeysenumeration throws. .NET Core / .NET 5+ changed this so the update path no longer bumps version β which is why the same code is silent there and the bug is easy to miss when testing only on modern frameworks.This breaks the (common) "dynamic filter whose predicate depends on the filtered output" pattern, e.g. an expand/collapse tree where the visible-node set feeds back into its own visibility filter.
Step to reproduce
Minimal console repro, target
net48, reference DynamicData9.4.31:dotnet build -c Release/dotnet run -c ReleasewithDynamicDataVersion=9.4.31onnet48β throws on load.DynamicDataVersion=9.4.1, rebuild/run β printsAfter load (expect [1]): [1], no exception.net8.0on9.4.31β no exception (demonstrates it is framework-specific).Nodeis a plainINotifyPropertyChangedwithint Id,int? ParentId,bool IsExpanded.Reproduction repository
https://gist.github.com/Snailya/da8dda754d84425c76bce67a5f9606c9 (3 files:
Program.cs,Node.cs,DynamicDataRepro.csprojβgit clone-able)Expected behavior
No exception; the predicate re-evaluation completes (as on 9.4.1, and as on net8.0).
Screenshots πΌοΈ
n/a
IDE
Visual Studio 2022
Operating system
Windows
Version
Windows 11
Device
n/a
DynamicData Version
9.4.31 (regressed from 9.4.1)
Additional information βΉοΈ
Stack trace (net48, 9.4.31):
Suggested fix β snapshot the keys before mutating: