diff --git a/README.md b/README.md index c023896..eee4572 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # FEFF.TestFixtures [![Test](https://github.com/metacoder-feff/FEFF.TestFixtures/actions/workflows/test.yml/badge.svg)](https://github.com/metacoder-feff/FEFF.TestFixtures/actions/workflows/test.yml) -[![Build](https://img.shields.io/github/actions/workflow/status/metacoder-feff/FEFF.TestFixtures/package.yml?label=Build)](https://github.com/metacoder-feff/FEFF.TestFixtures/actions/workflows/package.yml) +[![CI Build](https://github.com/metacoder-feff/FEFF.TestFixtures/actions/workflows/build.yml/badge.svg)](https://github.com/metacoder-feff/FEFF.TestFixtures/actions/workflows/build.yml) Integrations: diff --git a/src/FEFF.TestFixtures.AspNetCore/FakeRandom/FakeRandom.cs b/src/FEFF.TestFixtures.AspNetCore/FakeRandom/FakeRandom.cs index af9e7d2..5704a65 100644 --- a/src/FEFF.TestFixtures.AspNetCore/FakeRandom/FakeRandom.cs +++ b/src/FEFF.TestFixtures.AspNetCore/FakeRandom/FakeRandom.cs @@ -1,7 +1,8 @@ namespace FEFF.TestFixtures.AspNetCore.Randomness; /// -/// A configurable random number generator for testing. +/// A configurable random number generator for testing.
+/// All public methods ARE threadsafe via lock(). ///
/// /// The default behavior uses a constant seed. Additional strategies such as @@ -40,6 +41,16 @@ namespace FEFF.TestFixtures.AspNetCore.Randomness; /// public class FakeRandom : Random { + + #if NET9_0_OR_GREATER + private readonly Lock _lock = new(); + #else + private readonly Object _lock = new(); + #endif + private INormalizationStrategy _normalizationStrategy = new ThrowNormalization(); + + #region INextStrategy options + /// /// Gets or sets the strategy for generating values, or null to use the default. /// @@ -64,8 +75,7 @@ public class FakeRandom : Random /// Gets or sets the strategy for generating values, or null to use the default. /// public INextStrategy? ByteNext { get; set; } - - private INormalizationStrategy _normalizationStrategy = new ThrowNormalization(); + #endregion /// /// Gets or sets the normalization strategy used when a generated value falls outside the valid @@ -108,42 +118,51 @@ private int NextI32Internal(INextStrategy int32Next, int min, int max) /// public override int Next() { - // just do as other "Assert arguments" pattern - var def = base.Next(); + lock(_lock) + { + // just do as other "Assert arguments" pattern + var def = base.Next(); - // avoid race condition - var strategy = Int32Next; + // avoid race condition + var strategy = Int32Next; - if (strategy == null) - return def; + if (strategy == null) + return def; - // int32Next is not null here - return NextI32Internal(strategy, 0, int.MaxValue); + // int32Next is not null here + return NextI32Internal(strategy, 0, int.MaxValue); + } } /// public override int Next(int maxValue) { - // Assert arguments - var def = base.Next(maxValue); + lock(_lock) + { + // Assert arguments + var def = base.Next(maxValue); - var strategy = Int32Next; - if (strategy == null) - return def; + var strategy = Int32Next; + if (strategy == null) + return def; - return NextI32Internal(strategy, 0, maxValue); + return NextI32Internal(strategy, 0, maxValue); + } } /// public override int Next(int minValue, int maxValue) { - // Assert arguments - var def = base.Next(minValue, maxValue); - var strategy = Int32Next; - if (strategy == null) - return def; + lock(_lock) + { + // Assert arguments + var def = base.Next(minValue, maxValue); + var strategy = Int32Next; + if (strategy == null) + return def; - return NextI32Internal(strategy, minValue, maxValue); + return NextI32Internal(strategy, minValue, maxValue); + } } #endregion @@ -166,36 +185,45 @@ private long NextI64Internal(INextStrategy int64Next, long min, long max) /// public override long NextInt64() { - var def = base.NextInt64(); - var strategy = Int64Next; - if (strategy == null) - return def; + lock(_lock) + { + var def = base.NextInt64(); + var strategy = Int64Next; + if (strategy == null) + return def; - return NextI64Internal(strategy, 0, long.MaxValue); + return NextI64Internal(strategy, 0, long.MaxValue); + } } /// public override long NextInt64(long maxValue) { - // Assert arguments - var def = base.NextInt64(maxValue); - var strategy = Int64Next; - if (strategy == null) - return def; + lock(_lock) + { + // Assert arguments + var def = base.NextInt64(maxValue); + var strategy = Int64Next; + if (strategy == null) + return def; - return NextI64Internal(strategy, 0, maxValue); + return NextI64Internal(strategy, 0, maxValue); + } } /// public override long NextInt64(long minValue, long maxValue) { - // Assert arguments - var def = base.NextInt64(minValue, maxValue); - var strategy = Int64Next; - if (strategy == null) - return def; + lock(_lock) + { + // Assert arguments + var def = base.NextInt64(minValue, maxValue); + var strategy = Int64Next; + if (strategy == null) + return def; - return NextI64Internal(strategy, minValue, maxValue); + return NextI64Internal(strategy, minValue, maxValue); + } } #endregion @@ -215,12 +243,15 @@ private float NextSingleInternal(INextStrategy singleNext) /// public override float NextSingle() { - var def = base.NextSingle(); - var strategy = SingleNext; - if (strategy == null) - return def; + lock(_lock) + { + var def = base.NextSingle(); + var strategy = SingleNext; + if (strategy == null) + return def; - return NextSingleInternal(strategy); + return NextSingleInternal(strategy); + } } // doubleNext is not null here @@ -237,12 +268,15 @@ private double NextDoubleInternal(INextStrategy doubleNext) /// public override double NextDouble() { - var def = base.NextDouble(); - var strategy = DoubleNext; - if (strategy == null) - return def; + lock(_lock) + { + var def = base.NextDouble(); + var strategy = DoubleNext; + if (strategy == null) + return def; - return NextDoubleInternal(strategy); + return NextDoubleInternal(strategy); + } } #endregion @@ -257,15 +291,18 @@ public override void NextBytes(byte[] buffer) /// public override void NextBytes(Span buffer) { - // Assert arguments - base.NextBytes(buffer); - - var strategy = ByteNext; - if (strategy != null) + lock(_lock) { - for (int i = 0; i < buffer.Length; i++) + // Assert arguments + base.NextBytes(buffer); + + var strategy = ByteNext; + if (strategy != null) { - buffer[i] = strategy.Next(); + for (int i = 0; i < buffer.Length; i++) + { + buffer[i] = strategy.Next(); + } } } } diff --git a/tests/FEFF.TestFixtures.Tests/Utils/FakeRandomTests/ThreadSafeTests.cs b/tests/FEFF.TestFixtures.Tests/Utils/FakeRandomTests/ThreadSafeTests.cs new file mode 100644 index 0000000..f511875 --- /dev/null +++ b/tests/FEFF.TestFixtures.Tests/Utils/FakeRandomTests/ThreadSafeTests.cs @@ -0,0 +1,270 @@ +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; + +namespace FEFF.TestFixtures.AspNetCore.Randomness.Tests; + +public class ThreadSafeTests +{ + private class RaceStrategy(T a, T b) : INextStrategy + { + private volatile int _counter = 0; + public T Next() + { + var current = Interlocked.Increment(ref _counter); + if(current == 1) + { + Thread.Sleep(1000); + return a; + } + return b; + } + } + + // fast set by index by counter + private class ConcurrentList(int capacity) + { + private readonly T[] _list = new T[capacity]; + private volatile int _nextIdx = -1; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void Add(T item) + { + var current = Interlocked.Increment(ref _nextIdx); + + if(current >= capacity) + throw new InvalidOperationException("List is full"); + + _list[current] = item; + } + + internal List ToList() + { + var n = _nextIdx; + if(n < 0) + return []; + return _list.Take(n + 1).ToList(); + } + } + + private static RaceStrategy CreateRaceStrategyFrom(T a, T b) => new(a,b); + + private const int ThreadCount = 2; + private readonly Barrier _barrier = new(ThreadCount); + + protected FakeRandom Rand { get; } = new(); + + #region int-32 + + [Fact] + public void Next__when_multi_threaded__should_not_mix_values() + { + Rand.Int32Next = CreateRaceStrategyFrom(1, 2); + var results = new ConcurrentQueue(); + + Parallel.For(0, ThreadCount, _ => + { + _barrier.SignalAndWait(); + var value = Rand.Next(); + results.Enqueue(value); + }); + + results.ToList() + .Should().BeEquivalentTo( + [ 1, 2 ], + options => options.WithStrictOrdering() + ); + + // without lock it would be: [2, 1] + // because '1' has a delay + } + + [Fact] + public void Next_max__when_multi_threaded__should_not_mix_values() + { + Rand.Int32Next = CreateRaceStrategyFrom(10, 20); + var results = new ConcurrentList(ThreadCount); + + Parallel.For(0, ThreadCount, _ => + { + _barrier.SignalAndWait(); + var value = Rand.Next(100); + results.Add(value); + }); + + results.ToList() + .Should().BeEquivalentTo( + [ 10, 20 ], + options => options.WithStrictOrdering() + ); + } + + [Fact] + public void Next_min_max__when_multi_threaded__should_not_mix_values() + { + Rand.Int32Next = CreateRaceStrategyFrom(100, 200); + var results = new ConcurrentList(ThreadCount); + + Parallel.For(0, ThreadCount, _ => + { + _barrier.SignalAndWait(); + var value = Rand.Next(50, 300); + results.Add(value); + }); + + results.ToList() + .Should().BeEquivalentTo( + [ 100, 200 ], + options => options.WithStrictOrdering() + ); + } + #endregion + + #region int-64 + + [Fact] + public void Next64__when_multi_threaded__should_not_mix_values() + { + Rand.Int64Next = CreateRaceStrategyFrom(1L, 2L); + var results = new ConcurrentList(ThreadCount); + + Parallel.For(0, ThreadCount, _ => + { + _barrier.SignalAndWait(); + var value = Rand.NextInt64(); + results.Add(value); + }); + + results.ToList() + .Should().BeEquivalentTo( + [ 1L, 2L ], + options => options.WithStrictOrdering() + ); + } + + [Fact] + public void Next64_max__when_multi_threaded__should_not_mix_values() + { + Rand.Int64Next = CreateRaceStrategyFrom(10L, 20L); + var results = new ConcurrentList(ThreadCount); + + Parallel.For(0, ThreadCount, _ => + { + _barrier.SignalAndWait(); + var value = Rand.NextInt64(100L); + results.Add(value); + }); + + results.ToList() + .Should().BeEquivalentTo( + [ 10L, 20L ], + options => options.WithStrictOrdering() + ); + } + + [Fact] + public void Next64_min_max__when_multi_threaded__should_not_mix_values() + { + Rand.Int64Next = CreateRaceStrategyFrom(100L, 200L); + var results = new ConcurrentList(ThreadCount); + + Parallel.For(0, ThreadCount, _ => + { + _barrier.SignalAndWait(); + var value = Rand.NextInt64(50L, 300L); + results.Add(value); + }); + + results.ToList() + .Should().BeEquivalentTo( + [ 100L, 200L ], + options => options.WithStrictOrdering() + ); + } + #endregion + + #region float/double + + [Fact] + public void NextSingle__when_multi_threaded__should_not_mix_values() + { + Rand.SingleNext = CreateRaceStrategyFrom(0.1f, 0.2f); + var results = new ConcurrentList(ThreadCount); + + Parallel.For(0, ThreadCount, _ => + { + _barrier.SignalAndWait(); + var value = Rand.NextSingle(); + results.Add(value); + }); + + results.ToList() + .Should().BeEquivalentTo( + [ 0.1f, 0.2f ], + options => options.WithStrictOrdering() + ); + } + + [Fact] + public void NextDouble__when_multi_threaded__should_not_mix_values() + { + Rand.DoubleNext = CreateRaceStrategyFrom(0.1, 0.2); + var results = new ConcurrentList(ThreadCount); + + Parallel.For(0, ThreadCount, _ => + { + _barrier.SignalAndWait(); + var value = Rand.NextDouble(); + results.Add(value); + }); + + results.ToList() + .Should().BeEquivalentTo( + [ 0.1, 0.2 ], + options => options.WithStrictOrdering() + ); + } + #endregion + + #region byte[] + + [Fact] + public void NextBytes__when_multi_threaded__should_not_mix_values() + { + Rand.ByteNext = CreateRaceStrategyFrom((byte)1, (byte)2); + var results = new ConcurrentList(ThreadCount); + + Parallel.For(0, ThreadCount, _ => + { + var buffer = new byte[1]; + _barrier.SignalAndWait(); + Rand.NextBytes(buffer); + results.Add(buffer[0]); + }); + results.ToList() + .Should().BeEquivalentTo( + [ (byte)1, (byte)2 ], + options => options.WithStrictOrdering() + ); + } + + [Fact] + public void NextBytes_span__when_multi_threaded__should_not_mix_values() + { + Rand.ByteNext = CreateRaceStrategyFrom((byte)1, (byte)2); + var results = new ConcurrentList(ThreadCount); + + Parallel.For(0, ThreadCount, _ => + { + Span buffer = new byte[1]; + _barrier.SignalAndWait(); + Rand.NextBytes(buffer); + results.Add(buffer[0]); + }); + results.ToList() + .Should().BeEquivalentTo( + [ (byte)1, (byte)2 ], + options => options.WithStrictOrdering() + ); + } + #endregion +} \ No newline at end of file