Skip to content

Dispose of custom enumerator not getting called if UseSizeOptimizedLinq is usedΒ #122725

@siegfriedpammer

Description

@siegfriedpammer

Description

Context

Initially this was noticed in a .NET 10 dotnet run .cs app that was using the ICSharpCode.Decompiler project, where we have sanity checks in our data structures to avoid certain modifications while the data is enumerated.

Analysis

  • First, I noticed that the issue is not present in the ILSpy .NET 10 app, but can be consistently reproduced using dotnet run .cs
  • Second, I discovered that the issue goes away if I target .NET 9 or lower in the dotnet run .cs app.
  • At first, I didn't manage to reproduce the problem in a minimal app (see below), however, I noticed that System.Linq.Enumerable.SizeOptIListSelectIterator2appears in the stack, when the problem occurs, butSystem.Linq.Enumerable.IListSelectIterator2 appears in the stack, when it doesn't occur.
  • Then I looked at the source code of LINQ in .NET and noticed System.Linq.Enumerable.IsSizeOptimized, which is a feature flag that causes the SizeOptIListSelectIterator to be used, which causes the bug.
  • I can confirm that the problem goes away, if I set UseSizeOptimizedLinq to false in the csproj or using #:property in the dotnet run .cs app.

Reproduction Steps

Reproducer:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <UseSizeOptimizedLinq>true</UseSizeOptimizedLinq> <!-- if this is set to false the problem goes away -->
  </PropertyGroup>

</Project>
using System.Collections;
using System.Diagnostics;

Console.WriteLine("Hello, World!");

var collection = new SomeCollection<MyBase>();
var collection2 = new SomeCollection<MyBase>();

for (int i = 1; i <= 100; i++)
{
    collection.Add(new MyBase { Id = i });
}

collection2.AddRange(collection.Select(x => x.Copy()));

collection.AssertNoActiveEnumerators(); // this fails
collection2.AssertNoActiveEnumerators();

foreach (var item in collection)
{
    Console.WriteLine(item.Id);
}
collection.AssertNoActiveEnumerators();
foreach (var item in collection2)
{
    Console.WriteLine(item.Id);
}
collection2.AssertNoActiveEnumerators();

class MyBase
{
    public int Id { get; set; } = 0;

    public MyBase Copy()
    {
        return new MyBase { Id = this.Id * this.Id };
    }
}

sealed class SomeCollection<T> : IList<T>, IReadOnlyList<T> where T : MyBase
{
    readonly List<T> _innerList = new();

    public T this[int index] { get => ((IList<T>)_innerList)[index]; set => ((IList<T>)_innerList)[index] = value; }

    public int Count => ((ICollection<T>)_innerList).Count;

    public bool IsReadOnly => ((ICollection<T>)_innerList).IsReadOnly;

    public void Add(T item)
    {
        ((ICollection<T>)_innerList).Add(item);
    }

    public void Clear()
    {
        ((ICollection<T>)_innerList).Clear();
    }

    public bool Contains(T item)
    {
        return ((ICollection<T>)_innerList).Contains(item);
    }

    public void CopyTo(T[] array, int arrayIndex)
    {
        ((ICollection<T>)_innerList).CopyTo(array, arrayIndex);
    }

    StructEnumerator GetEnumerator()
    {
        return new StructEnumerator(this);
    }

    IEnumerator<T> IEnumerable<T>.GetEnumerator()
    {
        return GetEnumerator();
    }

    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }

    public int IndexOf(T item)
    {
        return ((IList<T>)_innerList).IndexOf(item);
    }

    public void Insert(int index, T item)
    {
        ((IList<T>)_innerList).Insert(index, item);
    }

    public bool Remove(T item)
    {
        return ((ICollection<T>)_innerList).Remove(item);
    }

    public void RemoveAt(int index)
    {
        ((IList<T>)_innerList).RemoveAt(index);
    }

    struct StructEnumerator : IEnumerator<T>
    {
        private readonly SomeCollection<T> _collection;
        private readonly IList<T> _list;
        private int _index;

        public StructEnumerator(SomeCollection<T> list)
        {
            this._collection = list;
            this._list = list._innerList;
            this._index = -1;
            this._collection.NotifyStart();
        }

        public T Current => this._list[_index];

        object IEnumerator.Current => Current;

        public void Dispose()
        {
            this._collection.NotifyEnd();
        }

        public bool MoveNext()
        {
            return ++this._index < this._collection.Count;
        }

        public void Reset()
        {
            this._index = -1;
        }
    }

    int currentEnumeratorCount = 0;

    private void NotifyEnd()
    {
        Console.WriteLine("End: " + Environment.StackTrace);
        Debug.Assert(currentEnumeratorCount > 0);
        currentEnumeratorCount--;
    }

    private void NotifyStart()
    {
        Console.WriteLine("Start: " + Environment.StackTrace);
        currentEnumeratorCount++;
    }

    public void AssertNoActiveEnumerators()
    {
        Debug.Assert(currentEnumeratorCount == 0);
    }
}

static class Extensions
{
    public static void AddRange<T>(this SomeCollection<T> collection, IEnumerable<T> items) where T : MyBase
    {
        foreach (var item in items)
        {
            collection.Add(item);
        }
    }
}

Expected behavior

The Dispose method is called as in previous versions of .NET or without the UseSizeOptimizedLinq flag set.

Actual behavior

Crash:

Assertion failed.
currentEnumeratorCount == 0
   at SomeCollection`1.AssertNoActiveEnumerators() in DisposableStructBug\Program.cs:line 157
   at Program.<Main>$(String[] args) in DisposableStructBug\Program.cs:line 17

Regression?

Yes, as described above works in .NET 9 and earlier and in .NET 10 with the UseSizeOptimizedLinq flag set to false.

Known Workarounds

Set the UseSizeOptimizedLinq flag to false.

Configuration

  • .NET 10.0.101 / Microsoft.NETCore.App 10.0.1
  • Windows 11 Version 25H2 (OS Build 26200.7462)
  • x64

Other information

Questions:

  • Why is UseSizeOptimizedLinq enabled by default in dotnet run .cs? This is a bit unexpected.

Related:

Metadata

Metadata

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions