Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Dec 25, 2025

main PR

Description

Two iterator classes (used when UseSizeOptimizedLinq=true) hold _enumerator references but did not override Dispose() to clean them up. Custom enumerators never had Dispose called, breaking user code that relies on disposal for cleanup/invariant tracking.

Fixed iterators:

  1. SizeOptIListSelectIterator in Select.SizeOpt.cs
  2. SizeOptIListWhereIterator in Where.SizeOpt.cs

Added the missing Dispose() overrides using the recommended pattern:

public override void Dispose()
{
    if (_enumerator is { } e)
    {
        _enumerator = null;
        e.Dispose();
    }
    base.Dispose();
}

Also added shared DisposeTrackingList<T> test helper to EnumerableTests.cs for testing enumerator disposal behavior.

Customer Impact

User code with custom IList<T> implementations that track enumerator lifecycle (e.g., detecting concurrent modification) will fail silently or throw when UseSizeOptimizedLinq=true. This breaks real-world projects like ICSharpCode.Decompiler.

Regression

Yes. Works in .NET 9 and earlier. Broke in .NET 10 when SizeOptIListSelectIterator and SizeOptIListWhereIterator were introduced without the Dispose overrides.

Testing

  • Added Select_SourceIsIList_EnumeratorDisposedOnComplete and Select_SourceIsIList_EnumeratorDisposedOnExplicitDispose tests
  • Added Where_SourceIsIList_EnumeratorDisposedOnComplete and Where_SourceIsIList_EnumeratorDisposedOnExplicitDispose tests
  • Added shared DisposeTrackingList<T> helper class to EnumerableTests.cs
  • All SelectTests pass
  • All WhereTests pass
  • All LifecycleTests pass
  • Full System.Linq test suite (52,190 tests) passes

Risk

Low. Single-method additions following established patterns from IListSelectIterator and IEnumerableWhereIterator. No behavior change for correctly-behaving enumerators.

Package authoring no longer needed in .NET 9

IMPORTANT: Starting with .NET 9, you no longer need to edit a NuGet package's csproj to enable building and bump the version.
Keep in mind that we still need package authoring in .NET 8 and older versions.

Original prompt

This section details on the original issue you should resolve

<issue_title>Dispose of custom enumerator not getting called if UseSizeOptimizedLinq is used</issue_title>
<issue_description>### 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 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 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(cur...

</details>



<!-- START COPILOT CODING AGENT SUFFIX -->

- Fixes dotnet/runtime#122725

<!-- START COPILOT CODING AGENT TIPS -->
---

💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more [Copilot coding agent tips](https://gh.io/copilot-coding-agent-tips) in the docs.

Add Dispose() method to properly dispose the enumerator when
UseSizeOptimizedLinq is enabled. This fixes the bug where
custom enumerator's Dispose was not called when the source
implements IList<T>.

Fixes #115239

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix custom enumerator disposal with UseSizeOptimizedLinq Fix missing Dispose override in SizeOptIListSelectIterator Dec 25, 2025
Copilot AI requested a review from stephentoub December 25, 2025 03:04
@dotnet-policy-service
Copy link
Contributor

Tagging subscribers to this area: @dotnet/area-system-linq
See info in area-owners.md if you want to be subscribed.

@stephentoub stephentoub marked this pull request as ready for review December 25, 2025 20:35
Copilot AI review requested due to automatic review settings December 25, 2025 20:35
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a regression in .NET 10 where SizeOptIListSelectIterator (used when UseSizeOptimizedLinq=true) failed to dispose custom enumerators, breaking user code that relies on disposal for cleanup or invariant tracking.

Key Changes:

  • Added missing Dispose() override to SizeOptIListSelectIterator matching the existing pattern in IListSelectIterator
  • Added comprehensive tests to verify enumerator disposal in both completion and explicit disposal scenarios

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
src/libraries/System.Linq/src/System/Linq/Select.SizeOpt.cs Added Dispose() override to properly clean up _enumerator reference
src/libraries/System.Linq/tests/SelectTests.cs Added two tests and a helper class to validate enumerator disposal behavior

@skyoxZ
Copy link
Contributor

skyoxZ commented Dec 26, 2025

SizeOptIListWhereIterator is also missing a Dispose override.

It's a bit inconvenient that the PR is not linked to the issue.

@stephentoub
Copy link
Member

@copilot, please search for any other missing dispose overrides.

Found and fixed another missing Dispose override in Where.SizeOpt.cs
for SizeOptIListWhereIterator. Added tests for both Select and Where
disposal behavior with IList sources.

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Dec 26, 2025

@copilot, please search for any other missing dispose overrides.

Searched all iterator classes with _enumerator fields and found one additional missing dispose override in SizeOptIListWhereIterator (in Where.SizeOpt.cs). Fixed in 825e6eb.

All other iterators with enumerator fields (OfTypeIterator, CastICollectionIterator, DistinctIterator, ConcatIterator, IEnumerableSkipTakeIterator, UnionIterator, IEnumerableWhereIterator, IEnumerableWhereSelectIterator, DefaultIfEmptyIterator, IEnumerableSelectIterator, IListSelectIterator) already have proper Dispose() overrides.

Copilot AI changed the title Fix missing Dispose override in SizeOptIListSelectIterator Fix missing Dispose override in SizeOptIListSelectIterator and SizeOptIListWhereIterator Dec 26, 2025
@skyoxZ
Copy link
Contributor

skyoxZ commented Dec 26, 2025

Consider creating a DisposeTrackingList.cs file to avoid repeatedly implementing the DisposeTrackingList class.

Assert.Equal(1, source.DisposeCalls);
}

private sealed class DisposeTrackingList<T> : IList<T>, IReadOnlyList<T>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot, move this to the shared EnumerableTests file and remove the duplication.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved DisposeTrackingList to EnumerableTests.cs and removed duplicates from both test files in c3487f5.


public override void Dispose()
{
if (_enumerator is not null)
Copy link
Member

@stephentoub stephentoub Dec 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot, please restructure these as:

if (_enumerator is {} e)
{
    _enumerator = null;
    e.Dispose()
}

base.Dispose();

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restructured both Dispose methods (in Select.SizeOpt.cs and Where.SizeOpt.cs) to use the requested pattern in c3487f5.

- Restructured Dispose() methods to use `if (_enumerator is {} e)` pattern
- Moved DisposeTrackingList class to shared EnumerableTests.cs file
- Removed duplicate DisposeTrackingList from SelectTests.cs and WhereTests.cs

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dispose of custom enumerator not getting called if UseSizeOptimizedLinq is used

4 participants