Skip to content

Commit c41ddce

Browse files
authored
Guard the counter value caches with a lock (#1871)
Each Counter cache is a plain Dictionary, written through GetOrAdd, while the number backing the name came from Interlocked.Increment. That mix only reads as thread safe: user code can reach Counter.Current from parallel work inside one test, and concurrent calls corrupted the dictionary. All the caches now share one lock, which makes the Interlocked increments redundant, so they become plain increments under that lock.
1 parent 26163fb commit c41ddce

9 files changed

Lines changed: 115 additions & 33 deletions
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// User code can reach Counter.Current from parallel work inside one test, so the
2+
// value caches have to survive concurrent access
3+
public class CounterConcurrencyTests
4+
{
5+
[Fact]
6+
public void ConcurrentGuids()
7+
{
8+
using var counter = Counter.Start();
9+
10+
var guids = Enumerable.Range(0, 500)
11+
.Select(_ => Guid.NewGuid())
12+
.ToList();
13+
14+
var names = new ConcurrentBag<string>();
15+
Parallel.ForEach(guids, guid => names.Add(counter.NextString(guid)));
16+
17+
// one name per distinct input, and no name handed out twice
18+
Assert.Equal(guids.Count, names.Distinct().Count());
19+
20+
// and the same input keeps the name it was given
21+
foreach (var guid in guids)
22+
{
23+
Assert.Contains(counter.NextString(guid), names);
24+
}
25+
26+
Assert.Equal(guids.Count, names.Distinct().Count());
27+
}
28+
29+
[Fact]
30+
public void ConcurrentNumericIds()
31+
{
32+
using var counter = Counter.Start();
33+
34+
var ids = Enumerable.Range(0, 500)
35+
.Select(_ => (long) _)
36+
.ToList();
37+
38+
var names = new ConcurrentBag<string>();
39+
Parallel.ForEach(ids, id => names.Add(counter.NextNumericIdString("TheEntity", id)));
40+
41+
Assert.Equal(ids.Count, names.Distinct().Count());
42+
43+
foreach (var id in ids)
44+
{
45+
Assert.Contains(counter.NextNumericIdString("TheEntity", id), names);
46+
}
47+
48+
Assert.Equal(ids.Count, names.Distinct().Count());
49+
}
50+
}

src/Verify/Counter.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ public partial class Counter :
1010
Dictionary<DateTime, string> namedDateTimes;
1111
Dictionary<Guid, string> namedGuids;
1212
Dictionary<DateTimeOffset, string> namedDateTimeOffsets;
13+
// Guards every value cache below. User code can reach Counter.Current from
14+
// parallel work inside one test, and a Dictionary does not survive that.
15+
internal object cacheLock = new();
16+
1317
public bool DateCounting { get; }
1418
public bool ScrubDateTimes { get; }
1519
public bool ScrubGuids { get; }

src/Verify/Counter_Date.cs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,18 @@ public string NextString(Date input) =>
2929
return new(0, name);
3030
}
3131

32-
return dateCache.GetOrAdd(
33-
input,
34-
_ => BuildDateValue());
32+
lock (cacheLock)
33+
{
34+
return dateCache.GetOrAdd(
35+
input,
36+
_ => BuildDateValue());
37+
}
3538
}
3639

40+
// Called under cacheLock
3741
(int intValue, string stringValue) BuildDateValue()
3842
{
39-
var value = Interlocked.Increment(ref currentDate);
43+
var value = ++currentDate;
4044

4145
if (DateCounting)
4246
{
@@ -54,9 +58,10 @@ public partial class Counter
5458
Dictionary<DateTime, (int intValue, string stringValue)> dateCache = new(dateTimeComparer);
5559
int currentDate;
5660

61+
// Called under cacheLock
5762
(int intValue, string stringValue) BuildDateValue()
5863
{
59-
var value = Interlocked.Increment(ref currentDate);
64+
var value = ++currentDate;
6065

6166
if (DateCounting)
6267
{
@@ -78,10 +83,13 @@ internal string ConvertDate(DateTime date)
7883
return "Date_MinValue";
7984
}
8085

81-
return dateCache.GetOrAdd(
82-
date,
83-
_ => BuildDateValue())
84-
.stringValue;
86+
lock (cacheLock)
87+
{
88+
return dateCache.GetOrAdd(
89+
date,
90+
_ => BuildDateValue())
91+
.stringValue;
92+
}
8593
}
8694
}
8795
#endif

src/Verify/Counter_DateTime.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,18 @@ public string NextString(DateTime input) =>
4848
return new(0, name);
4949
}
5050

51-
return dateTimeCache.GetOrAdd(
52-
input,
53-
_ => BuildDateTimeValue());
51+
lock (cacheLock)
52+
{
53+
return dateTimeCache.GetOrAdd(
54+
input,
55+
_ => BuildDateTimeValue());
56+
}
5457
}
5558

59+
// Called under cacheLock
5660
(int intValue, string stringValue) BuildDateTimeValue()
5761
{
58-
var value = Interlocked.Increment(ref currentDateTime);
62+
var value = ++currentDateTime;
5963

6064
if (DateCounting)
6165
{

src/Verify/Counter_DateTimeOffset.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,18 @@ public string NextString(DateTimeOffset input) =>
4848
return new(0, name);
4949
}
5050

51-
return dateTimeOffsetCache.GetOrAdd(
52-
input,
53-
_ => BuildDateTimeOffsetValue());
51+
lock (cacheLock)
52+
{
53+
return dateTimeOffsetCache.GetOrAdd(
54+
input,
55+
_ => BuildDateTimeOffsetValue());
56+
}
5457
}
5558

59+
// Called under cacheLock
5660
(int intValue, string stringValue) BuildDateTimeOffsetValue()
5761
{
58-
var value = Interlocked.Increment(ref currentDateTimeOffset);
62+
var value = ++currentDateTimeOffset;
5963

6064
if (DateCounting)
6165
{

src/Verify/Counter_Guid.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,18 @@ public string NextString(Guid input) =>
2828
return new(0, name);
2929
}
3030

31-
return guidCache.GetOrAdd(
32-
input,
33-
_ => BuildGuidValue());
31+
lock (cacheLock)
32+
{
33+
return guidCache.GetOrAdd(
34+
input,
35+
_ => BuildGuidValue());
36+
}
3437
}
3538

39+
// Called under cacheLock
3640
(int intValue, string stringValue) BuildGuidValue()
3741
{
38-
var value = Interlocked.Increment(ref currentGuid);
42+
var value = ++currentGuid;
3943
return (value, $"Guid_{value}");
4044
}
4145
}

src/Verify/Counter_NumericId.cs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,21 @@ static string ToKey(object value)
3333

3434
(int intValue, string stringValue) NextNumericIdValue(string entityName, string input)
3535
{
36-
if (!numericIdCache.TryGetValue(entityName, out var cache))
36+
lock (cacheLock)
3737
{
38-
cache = [];
39-
numericIdCache[entityName] = cache;
38+
if (!numericIdCache.TryGetValue(entityName, out var cache))
39+
{
40+
cache = [];
41+
numericIdCache[entityName] = cache;
42+
}
43+
44+
return cache.GetOrAdd(
45+
input,
46+
_ => BuildNumericIdValue(entityName));
4047
}
41-
42-
return cache.GetOrAdd(
43-
input,
44-
_ => BuildNumericIdValue(entityName));
4548
}
4649

50+
// Called under cacheLock
4751
(int intValue, string stringValue) BuildNumericIdValue(string entityName)
4852
{
4953
numericIdCounters.TryGetValue(entityName, out var current);

src/Verify/Counter_Time.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,18 @@ public string NextString(Time input) =>
4343
return new(0, name);
4444
}
4545

46-
return timeCache.GetOrAdd(
47-
input,
48-
_ => BuildTimeValue());
46+
lock (cacheLock)
47+
{
48+
return timeCache.GetOrAdd(
49+
input,
50+
_ => BuildTimeValue());
51+
}
4952
}
5053

54+
// Called under cacheLock
5155
(int intValue, string stringValue) BuildTimeValue()
5256
{
53-
var value = Interlocked.Increment(ref currentTime);
57+
var value = ++currentTime;
5458
return (value, $"Time_{value}");
5559
}
5660
}

src/todo.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,5 +82,5 @@ All six resolved 2026-08-16 (five fixed here; the inline item resolved as by-des
8282
- [x] **`PrefixUnique` set is case-sensitive on case-insensitive filesystems.**
8383
`Verify/Naming/PrefixUnique.cs:3` — methods `Foo` and `foo` map to the same files on NTFS/APFS but pass the uniqueness check and silently clobber each other.
8484

85-
- [ ] **`Counter` caches mix `Interlocked` counters with unsynchronized `Dictionary` writes.**
85+
- [x] **`Counter` caches mix `Interlocked` counters with unsynchronized `Dictionary` writes.**
8686
`Verify/Counter_*.cs``Extensions.cs:155-164` — concurrent `Counter.Current.Next(...)` calls from parallel user code inside one test can corrupt the plain `Dictionary`.

0 commit comments

Comments
 (0)