Skip to content

Commit 4f18509

Browse files
Fix HashSet copy constructor handling of instances that have fallen back to the randomized hashcode generator. (#107613)
1 parent 8351e5f commit 4f18509

File tree

3 files changed

+57
-6
lines changed

3 files changed

+57
-6
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Collections.Generic;
5+
using System.Reflection;
6+
using System.Runtime.Serialization;
7+
using Xunit;
8+
9+
namespace System.Collections.Tests
10+
{
11+
public static class HashSet_NonGeneric_Tests
12+
{
13+
[Fact]
14+
public static void HashSet_CopyConstructor_ShouldWorkWithRandomizedEffectiveComparer()
15+
{
16+
HashSet<string> set = CreateCopyWithRandomizedComparer(new HashSet<string>() { "a", "b" });
17+
Assert.True(set.Contains("a"));
18+
19+
HashSet<string> copiedSet = new(set);
20+
Assert.True(copiedSet.Contains("a"));
21+
22+
static HashSet<string> CreateCopyWithRandomizedComparer(HashSet<string> set)
23+
{
24+
// To reproduce the bug, we need a HashSet<string> instance that has fallen back to
25+
// the randomized comparer. This typically happens when there are many collisions but
26+
// it can also happen when the set is serialized and deserialized via ISerializable.
27+
// For consistent results and to avoid brute forcing collisions, use the latter approach.
28+
29+
SerializationInfo info = new(typeof(HashSet<string>), new FormatterConverter());
30+
StreamingContext context = new(StreamingContextStates.All);
31+
set.GetObjectData(info, context);
32+
33+
HashSet<string> copiedSet = (HashSet<string>)Activator.CreateInstance(typeof(HashSet<string>), BindingFlags.NonPublic | BindingFlags.Instance, null, [info, context], null);
34+
copiedSet.OnDeserialization(null);
35+
return copiedSet;
36+
}
37+
}
38+
}
39+
}

src/libraries/System.Collections/tests/System.Collections.Tests.csproj

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,7 @@
66
</PropertyGroup>
77
<ItemGroup>
88
<!-- Reference the `NetCoreAppMinimum` build which has a functional BinaryFormatter and force a private copy to ensure it's not excluded -->
9-
<ProjectReference Include="$(LibrariesProjectRoot)System.Runtime.Serialization.Formatters\src\System.Runtime.Serialization.Formatters.csproj"
10-
Private="true"
11-
SetTargetFramework="TargetFramework=$(NetCoreAppMinimum)" />
9+
<ProjectReference Include="$(LibrariesProjectRoot)System.Runtime.Serialization.Formatters\src\System.Runtime.Serialization.Formatters.csproj" Private="true" SetTargetFramework="TargetFramework=$(NetCoreAppMinimum)" />
1210
</ItemGroup>
1311
<ItemGroup>
1412
<RdXmlFile Include="default.rd.xml" />
@@ -34,6 +32,7 @@
3432
<Compile Include="$(CommonTestPath)System\EnumTypes.cs" Link="Common\System\EnumTypes.cs" />
3533
<Compile Include="$(CommonTestPath)System\ObjectCloner.cs" Link="Common\System\ObjectCloner.cs" />
3634
<Compile Include="$(CommonTestPath)System\Runtime\Serialization\Formatters\BinaryFormatterHelpers.cs" Link="Common\System\Runtime\Serialization\Formatters\BinaryFormatterHelpers.cs" />
35+
<Compile Include="Generic\HashSet\HashSet.NonGeneric.Tests.cs" />
3736
<Compile Include="Generic\ReadOnlySet\ReadOnlySetTests.cs" />
3837
<!-- Generic tests -->
3938
<Compile Include="Generic\SortedSet\SortedSet.TreeSubSet.Tests.cs" />

src/libraries/System.Private.CoreLib/src/System/Collections/Generic/HashSet.cs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public HashSet(IEqualityComparer<T>? comparer)
6969
// We use a non-randomized comparer for improved perf, falling back to a randomized comparer if the
7070
// hash buckets become unbalanced.
7171
if (typeof(T) == typeof(string) &&
72-
NonRandomizedStringEqualityComparer.GetStringComparer(_comparer!) is IEqualityComparer<string> stringComparer)
72+
NonRandomizedStringEqualityComparer.GetStringComparer(_comparer) is IEqualityComparer<string> stringComparer)
7373
{
7474
_comparer = (IEqualityComparer<T>)stringComparer;
7575
}
@@ -92,7 +92,7 @@ public HashSet(IEnumerable<T> collection, IEqualityComparer<T>? comparer) : this
9292
ThrowHelper.ThrowArgumentNullException(ExceptionArgument.collection);
9393
}
9494

95-
if (collection is HashSet<T> otherAsHashSet && EqualityComparersAreEqual(this, otherAsHashSet))
95+
if (collection is HashSet<T> otherAsHashSet && EffectiveEqualityComparersAreEqual(this, otherAsHashSet))
9696
{
9797
ConstructFrom(otherAsHashSet);
9898
}
@@ -145,6 +145,8 @@ protected HashSet(SerializationInfo info, StreamingContext context)
145145
/// <summary>Initializes the HashSet from another HashSet with the same element type and equality comparer.</summary>
146146
private void ConstructFrom(HashSet<T> source)
147147
{
148+
Debug.Assert(EffectiveEqualityComparersAreEqual(this, source), "must use identical effective comparers.");
149+
148150
if (source.Count == 0)
149151
{
150152
// As well as short-circuiting on the rest of the work done,
@@ -1250,6 +1252,11 @@ public IEqualityComparer<T> Comparer
12501252
}
12511253
}
12521254

1255+
/// <summary>
1256+
/// Similar to <see cref="Comparer"/> but surfaces the actual comparer being used to hash entries.
1257+
/// </summary>
1258+
internal IEqualityComparer<T> EffectiveComparer => _comparer ?? EqualityComparer<T>.Default;
1259+
12531260
/// <summary>Ensures that this hash set can hold the specified number of elements without growing.</summary>
12541261
public int EnsureCapacity(int capacity)
12551262
{
@@ -1768,7 +1775,13 @@ private unsafe void SymmetricExceptWithEnumerable(IEnumerable<T> other)
17681775
/// </summary>
17691776
internal static bool EqualityComparersAreEqual(HashSet<T> set1, HashSet<T> set2) => set1.Comparer.Equals(set2.Comparer);
17701777

1771-
#endregion
1778+
/// <summary>
1779+
/// Checks if effective equality comparers are equal. This is used for algorithms that
1780+
/// require that both collections use identical hashing implementations for their entries.
1781+
/// </summary>
1782+
internal static bool EffectiveEqualityComparersAreEqual(HashSet<T> set1, HashSet<T> set2) => set1.EffectiveComparer.Equals(set2.EffectiveComparer);
1783+
1784+
#endregion
17721785

17731786
private struct Entry
17741787
{

0 commit comments

Comments
 (0)