From 7ab21b1ef7d731e07dcf268796701bf3e12c0cd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:29:06 +0000 Subject: [PATCH 1/2] Make GetOrCreate(key) atomic on ConcurrentDictionary The one-argument GetOrCreate had no ConcurrentDictionary overload, so it bound to the IDictionary one. That does a lookup and then Add, which throws ArgumentException when another caller adds the key in between. Add a ConcurrentDictionary overload that uses GetOrAdd, as the two-argument form already does. Fixes #134 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KaXGMeYeXB1J32kSrLGhgJ --- Extensions.Test/DictionaryExtensionsTests.cs | 25 +++++++++++++++++ Extensions/DictionaryExtensions.cs | 29 ++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/Extensions.Test/DictionaryExtensionsTests.cs b/Extensions.Test/DictionaryExtensionsTests.cs index d9d8aee..8d0502c 100644 --- a/Extensions.Test/DictionaryExtensionsTests.cs +++ b/Extensions.Test/DictionaryExtensionsTests.cs @@ -90,6 +90,31 @@ public void GetOrCreateConcurrentDictionaryShouldReturnSameInstanceToParallelCal Assert.HasCount(1000, dictionary["key1"]); } + [TestMethod] + public void GetOrCreateConcurrentDictionaryWithoutDefaultShouldReturnStoredValueWhenAnotherCallerAddsFirst() + { + // The comparer adds a rival value for the key the second time it hashes it, which is the + // moment between a lookup that missed and the add that follows it + RacingComparer comparer = new(); + ConcurrentDictionary> dictionary = new(comparer); + List rival = []; + comparer.OnSecondHash = () => dictionary.TryAdd("key1", rival); + + List result = dictionary.GetOrCreate("key1"); + + Assert.AreSame(dictionary["key1"], result); + } + + [TestMethod] + public void GetOrCreateConcurrentDictionaryWithoutDefaultShouldReturnSameInstanceToParallelCallers() + { + ConcurrentDictionary> dictionary = new(); + + Parallel.For(0, 1000, i => dictionary.GetOrCreate("key1").Add(i)); + + Assert.HasCount(1000, dictionary["key1"]); + } + private sealed class RacingComparer : IEqualityComparer { private int hashCount; diff --git a/Extensions/DictionaryExtensions.cs b/Extensions/DictionaryExtensions.cs index a219967..21ea1b4 100644 --- a/Extensions/DictionaryExtensions.cs +++ b/Extensions/DictionaryExtensions.cs @@ -60,6 +60,35 @@ public static class DictionaryExtensions return defaultValue; } + /// + /// Method that gets a value from a dictionary if it exists, otherwise creates a new value and adds it to the dictionary. + /// + /// The type of the keys in the dictionary. + /// The type of the values in the dictionary. + /// The dictionary to get the value from. + /// The key to get the value for. + /// The value for the key if it exists, otherwise a new value. + public static TVal GetOrCreate(this ConcurrentDictionary dictionary, TKey key) where TKey : notnull where TVal : new() + { +#pragma warning disable KTSU0004 // Use Ensure.NotNull instead of manual null check + if (dictionary is null) + { + throw new ArgumentNullException(nameof(dictionary), "The dictionary cannot be null."); + } +#pragma warning restore KTSU0004 // Use Ensure.NotNull instead of manual null check + +#pragma warning disable KTSU0004 // Use Ensure.NotNull instead of manual null check + if (key is null) + { + throw new ArgumentNullException(nameof(key), "The key cannot be null."); + } +#pragma warning restore KTSU0004 // Use Ensure.NotNull instead of manual null check + + // Without this overload the call binds to the IDictionary one, whose lookup-then-Add throws + // when another caller adds the key in between. GetOrAdd is atomic. + return dictionary.GetOrAdd(key, _ => new TVal()); + } + /// /// Method that gets a value from a dictionary if it exists, otherwise creates a new value and adds it to the dictionary. /// From a5ac04791ffa28c43638d21597aaacb818e54249 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 12:40:17 +0000 Subject: [PATCH 2/2] Cover the null checks of the one-argument ConcurrentDictionary GetOrCreate Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KaXGMeYeXB1J32kSrLGhgJ --- Extensions.Test/DictionaryExtensionsTests.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Extensions.Test/DictionaryExtensionsTests.cs b/Extensions.Test/DictionaryExtensionsTests.cs index 8d0502c..216c544 100644 --- a/Extensions.Test/DictionaryExtensionsTests.cs +++ b/Extensions.Test/DictionaryExtensionsTests.cs @@ -150,6 +150,22 @@ public void GetOrCreateShouldThrowArgumentNullExceptionWhenKeyIsNull() Assert.ThrowsExactly(() => dictionary.GetOrCreate(null!)); } + [TestMethod] + public void GetOrCreateConcurrentDictionaryWithoutDefaultShouldThrowArgumentNullExceptionWhenDictionaryIsNull() + { + ConcurrentDictionary? dictionary = null!; + + Assert.ThrowsExactly(() => dictionary.GetOrCreate("key1")); + } + + [TestMethod] + public void GetOrCreateConcurrentDictionaryWithoutDefaultShouldThrowArgumentNullExceptionWhenKeyIsNull() + { + ConcurrentDictionary dictionary = new(); + + Assert.ThrowsExactly(() => dictionary.GetOrCreate(null!)); + } + [TestMethod] public void GetOrCreateShouldThrowArgumentNullExceptionWhenDefaultValueIsNull() {