Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions Containers.Test/SlotClearingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Containers.Tests;

using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Guards that removed and cleared slots stop keeping their contents alive, including for structs that
/// hold references, which a plain value-type check misses on the netstandard builds (issue #79).
/// </summary>
[TestClass]
public partial class SlotClearingTests
{
[GeneratedRegex(@"if\s*\(\s*!\s*typeof\([^)]*\)\.IsValueType\s*\)")]
private static partial Regex ValueTypeGuard();

[TestMethod]
public void IsNeeded_IsTrueForReferencesAndStructsHoldingThem()
{
Assert.IsTrue(SlotClearing.IsNeeded<object>());
Assert.IsTrue(SlotClearing.IsNeeded<KeyValuePair<int, object>>());
Assert.IsTrue(SlotClearing.IsNeeded<(string, int)>());
}

[TestMethod]
public void IsNeeded_IsFalseForUnmanagedTypesWhereTheRuntimeCanTell() =>
Assert.IsFalse(SlotClearing.IsNeeded<int>());

[TestMethod]
public void ContiguousMap_Clear_ReleasesValues()
{
ContiguousMap<int, object> map = [];
WeakReference[] references = FillMap(map, 3);

map.Clear();

AssertAllCollected(references);
}

[TestMethod]
public void ContiguousMap_RemovingEveryKey_ReleasesValues()
{
ContiguousMap<int, object> map = [];
WeakReference[] references = FillMap(map, 3);

for (int i = 0; i < references.Length; i++)
{
Assert.IsTrue(map.Remove(i));
}

AssertAllCollected(references);
}

[TestMethod]
public void ContiguousCollection_OfReferenceHoldingStructs_Clear_ReleasesValues()
{
ContiguousCollection<KeyValuePair<int, object>> collection = [];
WeakReference[] references = FillCollection(collection, 3);

collection.Clear();

AssertAllCollected(references);
}

[TestMethod]
public void ContiguousCollection_OfReferenceHoldingStructs_Remove_ReleasesValue()
{
ContiguousCollection<KeyValuePair<int, object>> collection = [];
WeakReference[] references = FillCollection(collection, 1);

collection.RemoveAt(0);

AssertAllCollected(references);
}

[TestMethod]
public void ContiguousSet_OfReferenceHoldingStructs_Clear_ReleasesValues()
{
ContiguousSet<KeyValuePair<int, object>> set = [];
WeakReference[] references = FillSet(set, 3);

set.Clear();

AssertAllCollected(references);
}

/// <summary>
/// The test project only runs on net10, where the runtime check is always available, so this guards
/// the netstandard fallback at the source: every container must go through
/// <see cref="SlotClearing.IsNeeded{T}"/> rather than test <c>IsValueType</c> itself.
/// </summary>
[TestMethod]
public void Containers_DoNotDecideSlotClearingWithAValueTypeCheck()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Containers.sln")))
{
directory = directory.Parent;
}

Assert.IsNotNull(directory, $"Could not locate Containers.sln above '{AppContext.BaseDirectory}'.");

string[] offenders =
[
.. Directory.EnumerateFiles(Path.Combine(directory.FullName, "Containers"), "*.cs")
.Where(file => ValueTypeGuard().IsMatch(File.ReadAllText(file)))
.Select(Path.GetFileName)
.OfType<string>()
];

Assert.IsEmpty(offenders, $"Use SlotClearing.IsNeeded<T>() instead of an IsValueType check in: {string.Join(", ", offenders)}");
}

private static void AssertAllCollected(WeakReference[] references)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

Assert.IsFalse(references.Any(r => r.IsAlive));

Check warning on line 122 in Containers.Test/SlotClearingTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.DoesNotContain' instead of 'Assert.IsFalse'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Containers&issues=AaEO2mEz5Fxz_DXxqZit&open=AaEO2mEz5Fxz_DXxqZit&pullRequest=103
}

[MethodImpl(MethodImplOptions.NoInlining)]
private static WeakReference[] FillMap(ContiguousMap<int, object> map, int count)
{
WeakReference[] references = new WeakReference[count];
for (int i = 0; i < count; i++)
{
object value = new();
map.Add(i, value);
references[i] = new WeakReference(value);
}

return references;
}

[MethodImpl(MethodImplOptions.NoInlining)]
private static WeakReference[] FillCollection(ContiguousCollection<KeyValuePair<int, object>> collection, int count)
{
WeakReference[] references = new WeakReference[count];
for (int i = 0; i < count; i++)
{
object value = new();
collection.Add(new KeyValuePair<int, object>(i, value));
references[i] = new WeakReference(value);
}

return references;
}

[MethodImpl(MethodImplOptions.NoInlining)]
private static WeakReference[] FillSet(ContiguousSet<KeyValuePair<int, object>> set, int count)
{
WeakReference[] references = new WeakReference[count];
for (int i = 0; i < count; i++)
{
object value = new();
set.Add(new KeyValuePair<int, object>(i, value));
references[i] = new WeakReference(value);
}

return references;
}
}
15 changes: 2 additions & 13 deletions Containers/ContiguousCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@ namespace ktsu.Containers;

using System.Collections;
using System.Diagnostics.CodeAnalysis;
#if NET5_0_OR_GREATER
using System.Runtime.CompilerServices;
#endif

/// <summary>
/// Represents a generic collection that guarantees contiguous memory allocation for optimal cache performance.
Expand Down Expand Up @@ -172,11 +169,7 @@ public void Add(T item)
/// </summary>
public void Clear()
{
#if NET5_0_OR_GREATER
if (RuntimeHelpers.IsReferenceOrContainsReferences<T>())
#else
if (!typeof(T).IsValueType)
#endif
if (SlotClearing.IsNeeded<T>())
{
// Clear references to help GC
Array.Clear(items, 0, Count);
Expand Down Expand Up @@ -255,11 +248,7 @@ public void RemoveAt(int index)
Array.Copy(items, index + 1, items, index, Count - index);
}

#if NET5_0_OR_GREATER
if (RuntimeHelpers.IsReferenceOrContainsReferences<T>())
#else
if (!typeof(T).IsValueType)
#endif
if (SlotClearing.IsNeeded<T>())
{
items[Count] = default!;
}
Expand Down
15 changes: 2 additions & 13 deletions Containers/ContiguousMap.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@ namespace ktsu.Containers;

using System.Collections;
using System.Diagnostics.CodeAnalysis;
#if NET5_0_OR_GREATER
using System.Runtime.CompilerServices;
#endif

/// <summary>
/// Represents a generic map/dictionary that maintains key-value pairs in contiguous memory for optimal cache performance.
Expand Down Expand Up @@ -389,11 +386,7 @@ public bool Remove(TKey key)
Array.Copy(items, index + 1, items, index, Count - index);
}

#if NET5_0_OR_GREATER
if (RuntimeHelpers.IsReferenceOrContainsReferences<Entry>())
#else
if (!typeof(Entry).IsValueType)
#endif
if (SlotClearing.IsNeeded<Entry>())
{
items[Count] = default;
}
Expand Down Expand Up @@ -462,11 +455,7 @@ public bool TryGetValue(TKey key, out TValue value)
public void Clear()
{
version++;
#if NET5_0_OR_GREATER
if (RuntimeHelpers.IsReferenceOrContainsReferences<Entry>())
#else
if (!typeof(Entry).IsValueType)
#endif
if (SlotClearing.IsNeeded<Entry>())
{
// Clear references to help GC
Array.Clear(items, 0, Count);
Expand Down
15 changes: 2 additions & 13 deletions Containers/ContiguousSet.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@ namespace ktsu.Containers;

using System.Collections;
using System.Diagnostics.CodeAnalysis;
#if NET5_0_OR_GREATER
using System.Runtime.CompilerServices;
#endif

/// <summary>
/// Represents a generic set that maintains unique elements in contiguous memory for optimal cache performance.
Expand Down Expand Up @@ -228,11 +225,7 @@ public bool Add(T item)
/// </summary>
public void Clear()
{
#if NET5_0_OR_GREATER
if (RuntimeHelpers.IsReferenceOrContainsReferences<T>())
#else
if (!typeof(T).IsValueType)
#endif
if (SlotClearing.IsNeeded<T>())
{
// Clear references to help GC
Array.Clear(items, 0, Count);
Expand Down Expand Up @@ -310,11 +303,7 @@ public bool Remove(T item)
Array.Copy(items, index + 1, items, index, Count - index);
}

#if NET5_0_OR_GREATER
if (RuntimeHelpers.IsReferenceOrContainsReferences<T>())
#else
if (!typeof(T).IsValueType)
#endif
if (SlotClearing.IsNeeded<T>())
{
items[Count] = default!;
}
Expand Down
9 changes: 1 addition & 8 deletions Containers/RingBuffer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ namespace ktsu.Containers;
using System.Collections;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
#if NET5_0_OR_GREATER
using System.Runtime.CompilerServices;
#endif

/// <summary>
/// Represents a fixed-size circular buffer (ring buffer) for storing elements of type <typeparamref name="T"/>.
Expand Down Expand Up @@ -307,11 +304,7 @@ public IEnumerator<T> GetEnumerator()
/// </remarks>
public void Clear()
{
#if NET5_0_OR_GREATER
if (RuntimeHelpers.IsReferenceOrContainsReferences<T>())
#else
if (!typeof(T).IsValueType)
#endif
if (SlotClearing.IsNeeded<T>())
{
// Clear references so cleared elements can be collected
Array.Clear(Buffer, 0, Buffer.Length);
Expand Down
32 changes: 32 additions & 0 deletions Containers/SlotClearing.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Containers;

#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP2_0_OR_GREATER
using System.Runtime.CompilerServices;
#endif

/// <summary>
/// Decides whether a vacated array slot has to be reset so the garbage collector can reclaim what it held.
/// </summary>
internal static class SlotClearing
{
/// <summary>
/// Returns whether a slot of type <typeparamref name="T"/> can keep an object alive, and so must be
/// cleared when an element is removed or the container is cleared.
/// </summary>
/// <remarks>
/// <c>RuntimeHelpers.IsReferenceOrContainsReferences</c> exists from netstandard2.1 and netcoreapp2.0.
/// On netstandard2.0 there is no cheap equivalent, so the answer is always true: clearing a slot that
/// holds no references costs little, while skipping one that does leaks. A plain value-type check is
/// not enough there, because a struct such as <c>KeyValuePair&lt;int, object&gt;</c> holds a reference.
/// </remarks>
/// <typeparam name="T">The slot's element type.</typeparam>
/// <returns>True when the slot must be cleared.</returns>
internal static bool IsNeeded<T>() =>

Check warning on line 26 in Containers/SlotClearing.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

'T' is not used in the method.

Check warning on line 26 in Containers/SlotClearing.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

'T' is not used in the method.

Check warning on line 26 in Containers/SlotClearing.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

'T' is not used in the method.

Check warning on line 26 in Containers/SlotClearing.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

'T' is not used in the method.

Check warning on line 26 in Containers/SlotClearing.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'T' is not used in the method.

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_Containers&issues=AaEO2mCd5Fxz_DXxqZis&open=AaEO2mCd5Fxz_DXxqZis&pullRequest=103
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP2_0_OR_GREATER
RuntimeHelpers.IsReferenceOrContainsReferences<T>();
#else
true;
#endif
}
Loading