From 850ac424fd1cdb978ff9298513d61169265823a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 08:01:18 +0000 Subject: [PATCH 1/8] test(justdummies): pin the concurrent-draw corruption of a seeded source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drawing from one seeded source on several threads at once collapses it: the unsynchronized Random's two internal indices converge under contention and it returns zero for ever, so every generator settles on the minimum of its range (0, "", Guid.Empty, int.MinValue) and stays there for the rest of the scope. Nothing throws. Five examples pin the regression at the coordinates where it was found — the ambient scope, a bounded generator collapsing onto its lower bound, the sibling generators the dead source poisons, the sequential draws taken afterwards, and a shared AnyContext. One property adds the seed as the input space, since nothing about the collapse depended on which seed was pinned. Refs: #310 --- .../SeedDeterminismProperties.cs | 47 ++++++ JustDummies.UnitTests/ConcurrentDrawTests.cs | 140 ++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 JustDummies.UnitTests/ConcurrentDrawTests.cs diff --git a/JustDummies.PropertyTests/SeedDeterminismProperties.cs b/JustDummies.PropertyTests/SeedDeterminismProperties.cs index 220cbe12..6bd00e07 100644 --- a/JustDummies.PropertyTests/SeedDeterminismProperties.cs +++ b/JustDummies.PropertyTests/SeedDeterminismProperties.cs @@ -357,4 +357,51 @@ public void DifferentSeedsProduceDifferentBatches() { .QuickCheckThrowOnFailure(); } + [Fact(DisplayName = "A source drawn from concurrently keeps generating, for every seed.")] + public void ConcurrentDrawsNeverCollapseTheSource() { + // Issue #310. The seed is the input space: nothing about the collapse depended on which seed was pinned, so + // pinning three by hand in the example suite could only ever prove it for those three. What is asserted is + // survival, not distribution — an unsynchronized Random whose indices converge under contention returns zero + // for ever, so every draw settles on the minimum of its range and the source never recovers. + // + // No claim is made here about WHICH values come out, or in what order: with a per-primitive lock two threads + // interleave inside a multi-draw Generate(), so neither the sequence nor the multiset of generated values is + // stable across threads. That is the documented contract, and asserting more would over-promise it. + Prop.ForAll(Generators.Seed().ToArbitrary(), + seed => { + int[] drawn = ConcurrentBurst(Any.WithSeed(seed)); + + return MostFrequent(drawn) < drawn.Length / 10; + }) + .QuickCheckThrowOnFailure(); + } + + #region Concurrency helpers + + private const int BurstThreads = 4; + private const int BurstDrawsPerThread = 1_500; + + /// + /// Draws from one context on every thread at once. Each worker writes its own slice, so the collection itself + /// adds no synchronization that could mask the source's. + /// + private static int[] ConcurrentBurst(AnyContext context) { + int[] drawn = new int[BurstThreads * BurstDrawsPerThread]; + Parallel.For(0, BurstThreads, new ParallelOptions { MaxDegreeOfParallelism = BurstThreads }, + worker => { + int offset = worker * BurstDrawsPerThread; + for (int index = 0; index < BurstDrawsPerThread; index++) { + drawn[offset + index] = context.Int32().Generate(); + } + }); + + return drawn; + } + + private static int MostFrequent(IEnumerable values) { + return values.GroupBy(value => value).Max(group => group.Count()); + } + + #endregion + } diff --git a/JustDummies.UnitTests/ConcurrentDrawTests.cs b/JustDummies.UnitTests/ConcurrentDrawTests.cs new file mode 100644 index 00000000..ef3a1943 --- /dev/null +++ b/JustDummies.UnitTests/ConcurrentDrawTests.cs @@ -0,0 +1,140 @@ +#region Usings declarations + +using System.Collections.Concurrent; + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace JustDummies.UnitTests; + +/// +/// Drawing from one seeded source on several threads at once. The ambient source flows with the execution +/// context, so it reaches every thread a test spawns; a context from is shared by +/// whoever holds it. Both therefore hand the same source to concurrent callers, and the source must survive it. +/// +/// +/// +/// Regression for issue #310. The defect was not a loss of quality but a collapse: an unsynchronized +/// whose two internal indices converge under contention returns zero for ever, so every +/// generator settles on the minimum of its declared range — 0, "", , +/// — and stays there for the rest of the scope. Those are exactly the values most +/// likely to make an assertion pass for the wrong reason, and nothing throws. +/// +/// +/// These tests are statistical in the direction that matters least: once the draw is serialized, corruption is +/// impossible rather than unlikely, so they pass deterministically. Before the fix they failed with very high +/// probability but not certainty — the usual bargain for a concurrency regression. The parallelism is +/// deliberately oversubscribed relative to the core count to make the contention reliable. +/// +/// +[TestSubject(typeof(Any))] +public sealed class ConcurrentDrawTests { + + #region Statics members declarations + + private const int Threads = 8; + private const int DrawsPerThread = 10_000; + private const int TotalDraws = Threads * DrawsPerThread; + + /// Runs on every thread at once and collects everything it produced. + private static List Storm(Func draw) { + ConcurrentBag drawn = new(); + Parallel.For(0, Threads, new ParallelOptions { MaxDegreeOfParallelism = Threads }, + _ => { + for (int index = 0; index < DrawsPerThread; index++) { drawn.Add(draw()); } + }); + + return drawn.ToList(); + } + + /// How many times the most frequent value came up — the collapse signal, not a distribution measure. + private static int MostFrequent(IEnumerable values) { + return values.GroupBy(value => value).Max(group => group.Count()); + } + + #endregion + + [Fact(DisplayName = "Concurrent draws from an ambient seed scope never collapse onto one value.")] + public void ConcurrentAmbientDrawsDoNotCollapse() { + List drawn = []; + + Any.Reproducibly(310, () => drawn = Storm(() => Any.Int32().Generate())); + + // A tenth of the draws sharing one value cannot happen by chance over the full Int32 range; it is the + // signature of a source that stopped generating. Far below what the defect produced (62% to 91%). + Check.WithCustomMessage($"{MostFrequent(drawn)} of {TotalDraws} draws returned the same value; the shared Random collapsed.") + .That(MostFrequent(drawn)) + .IsStrictlyLessThan(TotalDraws / 10); + } + + [Fact(DisplayName = "A seeded source is still usable for sequential draws taken after a concurrent burst.")] + public void ASeededSourceSurvivesAConcurrentBurst() { + List afterwards = []; + + Any.Reproducibly(310, () => { + Storm(() => Any.Int32().Generate()); + + // The heart of the regression: the defect was permanent. Once the indices had converged, every later + // draw on that source returned int.MinValue — including these, taken on one thread with no contention. + afterwards = Enumerable.Range(0, 20).Select(_ => Any.Int32().Generate()).ToList(); + }); + + Check.WithCustomMessage($"Sequential draws after the burst were all {afterwards[0]}; the source stayed dead.") + .That(afterwards.Distinct().Count()) + .IsStrictlyGreaterThan(1); + } + + [Fact(DisplayName = "A concurrent burst does not poison the other generators of the same source.")] + public void AConcurrentBurstDoesNotPoisonSiblingGenerators() { + string text = string.Empty; + Guid guid = Guid.Empty; + + Any.Reproducibly(310, () => { + Storm(() => Any.Int32().Generate()); + + // The corruption lives in the source, not in the generator that triggered it, so unrelated generators + // resolved from it afterwards collapsed too — a string to "" and a Guid to Guid.Empty. + text = Any.String().NonEmpty().Generate(); + guid = Any.Guid().Generate(); + }); + + Check.WithCustomMessage("A non-empty string generator returned an empty string after a concurrent burst.") + .That(text).IsNotEmpty(); + Check.WithCustomMessage("The Guid generator returned Guid.Empty after a concurrent burst.") + .That(guid).IsNotEqualTo(Guid.Empty); + } + + [Fact(DisplayName = "Concurrent draws never collapse a bounded generator onto its lower bound.")] + public void ConcurrentBoundedDrawsDoNotCollapseOntoTheirLowerBound() { + List drawn = []; + + // A bounded range makes the failure mode legible: a dead source does not return a random value inside the + // interval, it returns the interval's minimum, which reads like a plausible dummy. + Any.Reproducibly(310, () => drawn = Storm(() => Any.Int32().Between(1_000, 9_999).Generate())); + + int atTheBound = drawn.Count(value => value == 1_000); + + Check.WithCustomMessage($"{atTheBound} of {TotalDraws} draws returned the lower bound 1000.") + .That(atTheBound) + .IsStrictlyLessThan(TotalDraws / 10); + } + + [Fact(DisplayName = "A context shared across threads stays usable after concurrent draws.")] + public void ASharedContextSurvivesConcurrentDraws() { + AnyContext context = Any.WithSeed(310); + + List drawn = Storm(() => context.Int32().Generate()); + List afterwards = Enumerable.Range(0, 20).Select(_ => context.Int32().Generate()).ToList(); + + Check.WithCustomMessage($"{MostFrequent(drawn)} of {TotalDraws} draws from a shared context returned the same value.") + .That(MostFrequent(drawn)) + .IsStrictlyLessThan(TotalDraws / 10); + Check.WithCustomMessage($"Sequential draws from the shared context after the burst were all {afterwards[0]}.") + .That(afterwards.Distinct().Count()) + .IsStrictlyGreaterThan(1); + } + +} From ab9f219d3899b6b169962e27b5d582263409f4bf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 08:02:37 +0000 Subject: [PATCH 2/8] fix(justdummies): serialize draws on a seeded random source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SeededRandom stops handing out its System.Random and becomes the only door to it: the four primitives actually drawn (Next, Next(min,max), NextBytes, NextDouble) now go through it, each serialized on the source's own lock. Keeping the instance private is what makes the guarantee hold — a synchronized subclass would leak any member left un-overridden, whereas here a draw that bypasses the lock does not compile. An uncontended lock does not change the order a single thread consumes the stream, so a pinned seed replays bit-identically: verified against a captured fingerprint of 39 generator shapes over 7 seeds, unchanged before and after. What this does not buy is a value-level guarantee across threads: the lock is per primitive draw, so two threads interleave inside a multi-draw generation. That narrower contract is what the documentation states. Refs: #310 --- JustDummies/AnyBoolean.cs | 2 +- JustDummies/AnyByte.cs | 2 +- JustDummies/AnyChar.cs | 2 +- JustDummies/AnyDateOnly.cs | 2 +- JustDummies/AnyDateTime.cs | 2 +- JustDummies/AnyDateTimeOffset.cs | 4 +- JustDummies/AnyEnum.cs | 2 +- JustDummies/AnyGuid.cs | 2 +- JustDummies/AnyInt128.cs | 2 +- JustDummies/AnyInt16.cs | 2 +- JustDummies/AnyInt32.cs | 2 +- JustDummies/AnyInt64.cs | 2 +- JustDummies/AnyOneOf.cs | 2 +- JustDummies/AnyPattern.cs | 2 +- JustDummies/AnySByte.cs | 2 +- JustDummies/AnyStringOneOf.cs | 2 +- JustDummies/AnyTimeOnly.cs | 2 +- JustDummies/AnyTimeSpan.cs | 2 +- JustDummies/AnyUInt128.cs | 2 +- JustDummies/AnyUInt16.cs | 2 +- JustDummies/AnyUInt32.cs | 2 +- JustDummies/AnyUInt64.cs | 2 +- JustDummies/CollectionState.cs | 10 ++-- JustDummies/ContinuousIntervalSpec.cs | 7 ++- JustDummies/CountSpec.cs | 2 +- JustDummies/DecimalIntervalSpec.cs | 11 ++--- JustDummies/NullableExtensions.cs | 4 +- JustDummies/OrdinalIntervalSpec.cs | 2 +- JustDummies/RandomSource.cs | 70 +++++++++++++++++++++++---- JustDummies/RegexNode.cs | 4 +- JustDummies/StringSpec.cs | 6 +-- JustDummies/UriSpec.cs | 20 ++++---- JustDummies/WideIntervalSpec.cs | 4 +- 33 files changed, 118 insertions(+), 68 deletions(-) diff --git a/JustDummies/AnyBoolean.cs b/JustDummies/AnyBoolean.cs index 3e42f31f..8ffd354c 100644 --- a/JustDummies/AnyBoolean.cs +++ b/JustDummies/AnyBoolean.cs @@ -68,7 +68,7 @@ public AnyBoolean DifferentFrom(bool value) { /// public bool Generate() { - return _pinned ?? _source.Current.Random.Next(2) == 0; + return _pinned ?? _source.Current.Next(2) == 0; } private AnyBoolean Pin(bool value, string applying) { diff --git a/JustDummies/AnyByte.cs b/JustDummies/AnyByte.cs index feaeab4d..b768a197 100644 --- a/JustDummies/AnyByte.cs +++ b/JustDummies/AnyByte.cs @@ -170,7 +170,7 @@ public AnyByte DifferentFrom(byte value) { /// public byte Generate() { - return Val(_spec.GenerateOrdinal(_source.Current.Random)); + return Val(_spec.GenerateOrdinal(_source.Current)); } } diff --git a/JustDummies/AnyChar.cs b/JustDummies/AnyChar.cs index ae723498..71879f4a 100644 --- a/JustDummies/AnyChar.cs +++ b/JustDummies/AnyChar.cs @@ -145,7 +145,7 @@ public AnyChar DifferentFrom(char value) { /// public char Generate() { - return _pool[_source.Current.Random.Next(_pool.Count)]; + return _pool[_source.Current.Next(_pool.Count)]; } private AnyChar WithCharset(CharacterSet charset, string applying) { diff --git a/JustDummies/AnyDateOnly.cs b/JustDummies/AnyDateOnly.cs index 950b3a66..42187a24 100644 --- a/JustDummies/AnyDateOnly.cs +++ b/JustDummies/AnyDateOnly.cs @@ -145,7 +145,7 @@ public AnyDateOnly DifferentFrom(DateOnly value) { /// public DateOnly Generate() { - return Val(_spec.GenerateOrdinal(_source.Current.Random)); + return Val(_spec.GenerateOrdinal(_source.Current)); } } diff --git a/JustDummies/AnyDateTime.cs b/JustDummies/AnyDateTime.cs index 27378068..5b2c6fc9 100644 --- a/JustDummies/AnyDateTime.cs +++ b/JustDummies/AnyDateTime.cs @@ -175,7 +175,7 @@ public AnyDateTime DifferentFrom(DateTime value) { /// public DateTime Generate() { - ulong ordinal = _spec.GenerateOrdinal(_source.Current.Random); + ulong ordinal = _spec.GenerateOrdinal(_source.Current); if (_allowedOriginals is not null && _allowedOriginals.TryGetValue(ordinal, out DateTime original)) { return original; } return Val(ordinal); diff --git a/JustDummies/AnyDateTimeOffset.cs b/JustDummies/AnyDateTimeOffset.cs index 5659288d..fe64034c 100644 --- a/JustDummies/AnyDateTimeOffset.cs +++ b/JustDummies/AnyDateTimeOffset.cs @@ -241,8 +241,8 @@ public AnyDateTimeOffset DifferentFrom(DateTimeOffset value) { /// public DateTimeOffset Generate() { - Random random = _source.Current.Random; - ulong ordinal = _spec.GenerateOrdinal(random); + SeededRandom random = _source.Current; + ulong ordinal = _spec.GenerateOrdinal(random); if (_allowedOriginals is not null && _allowedOriginals.TryGetValue(ordinal, out DateTimeOffset original)) { return original; } if (!_offsetDeclared) { return Val(ordinal); } diff --git a/JustDummies/AnyEnum.cs b/JustDummies/AnyEnum.cs index f05f07bc..f01189ba 100644 --- a/JustDummies/AnyEnum.cs +++ b/JustDummies/AnyEnum.cs @@ -240,7 +240,7 @@ public AnyEnum DifferentFrom(TEnum value) { /// public TEnum Generate() { - return _pool[_source.Current.Random.Next(_pool.Count)]; + return _pool[_source.Current.Next(_pool.Count)]; } /// diff --git a/JustDummies/AnyGuid.cs b/JustDummies/AnyGuid.cs index 2d9a6c89..cd6926b5 100644 --- a/JustDummies/AnyGuid.cs +++ b/JustDummies/AnyGuid.cs @@ -134,7 +134,7 @@ public AnyGuid DifferentFrom(Guid value) { public Guid Generate() { if (_pinned is Guid pinned) { return pinned; } - Random random = _source.Current.Random; + SeededRandom random = _source.Current; if (_effectiveAllowed is not null) { return _effectiveAllowed[random.Next(_effectiveAllowed.Count)]; } diff --git a/JustDummies/AnyInt128.cs b/JustDummies/AnyInt128.cs index f4571ff3..a61d8b4e 100644 --- a/JustDummies/AnyInt128.cs +++ b/JustDummies/AnyInt128.cs @@ -186,7 +186,7 @@ public AnyInt128 DifferentFrom(Int128 value) { /// public Int128 Generate() { - return Val(_spec.GenerateOrdinal(_source.Current.Random)); + return Val(_spec.GenerateOrdinal(_source.Current)); } } diff --git a/JustDummies/AnyInt16.cs b/JustDummies/AnyInt16.cs index 1cd3346e..c1258911 100644 --- a/JustDummies/AnyInt16.cs +++ b/JustDummies/AnyInt16.cs @@ -184,7 +184,7 @@ public AnyInt16 DifferentFrom(short value) { /// public short Generate() { - return Val(_spec.GenerateOrdinal(_source.Current.Random)); + return Val(_spec.GenerateOrdinal(_source.Current)); } } diff --git a/JustDummies/AnyInt32.cs b/JustDummies/AnyInt32.cs index 2bd61b61..429c47e0 100644 --- a/JustDummies/AnyInt32.cs +++ b/JustDummies/AnyInt32.cs @@ -200,7 +200,7 @@ public AnyInt32 DifferentFrom(int value) { /// public int Generate() { - return Val(_spec.GenerateOrdinal(_source.Current.Random)); + return Val(_spec.GenerateOrdinal(_source.Current)); } } diff --git a/JustDummies/AnyInt64.cs b/JustDummies/AnyInt64.cs index 3fe565e9..4549ceaf 100644 --- a/JustDummies/AnyInt64.cs +++ b/JustDummies/AnyInt64.cs @@ -184,7 +184,7 @@ public AnyInt64 DifferentFrom(long value) { /// public long Generate() { - return Val(_spec.GenerateOrdinal(_source.Current.Random)); + return Val(_spec.GenerateOrdinal(_source.Current)); } } diff --git a/JustDummies/AnyOneOf.cs b/JustDummies/AnyOneOf.cs index ad993aa5..bc2ac711 100644 --- a/JustDummies/AnyOneOf.cs +++ b/JustDummies/AnyOneOf.cs @@ -69,7 +69,7 @@ private AnyOneOf(RandomSource source, IReadOnlyList values) { /// public T Generate() { - return _values[_source.Current.Random.Next(_values.Count)]; + return _values[_source.Current.Next(_values.Count)]; } } diff --git a/JustDummies/AnyPattern.cs b/JustDummies/AnyPattern.cs index 16ffa7cf..e1429140 100644 --- a/JustDummies/AnyPattern.cs +++ b/JustDummies/AnyPattern.cs @@ -57,7 +57,7 @@ internal AnyPattern(RandomSource source, RegexNode root) { /// public string Generate() { - RegexGenerationContext context = new(_source.Current.Random, GenerationLimit); + RegexGenerationContext context = new(_source.Current, GenerationLimit); _root.Append(context); return context.Result(); diff --git a/JustDummies/AnySByte.cs b/JustDummies/AnySByte.cs index 0a932c1c..741a1462 100644 --- a/JustDummies/AnySByte.cs +++ b/JustDummies/AnySByte.cs @@ -184,7 +184,7 @@ public AnySByte DifferentFrom(sbyte value) { /// public sbyte Generate() { - return Val(_spec.GenerateOrdinal(_source.Current.Random)); + return Val(_spec.GenerateOrdinal(_source.Current)); } } diff --git a/JustDummies/AnyStringOneOf.cs b/JustDummies/AnyStringOneOf.cs index 5fd9ad6a..8f09ec54 100644 --- a/JustDummies/AnyStringOneOf.cs +++ b/JustDummies/AnyStringOneOf.cs @@ -47,7 +47,7 @@ internal AnyStringOneOf(RandomSource source, IReadOnlyList values) { /// public string Generate() { - return _values[_source.Current.Random.Next(_values.Count)]; + return _values[_source.Current.Next(_values.Count)]; } } diff --git a/JustDummies/AnyTimeOnly.cs b/JustDummies/AnyTimeOnly.cs index 7466480b..d8340de6 100644 --- a/JustDummies/AnyTimeOnly.cs +++ b/JustDummies/AnyTimeOnly.cs @@ -163,7 +163,7 @@ public AnyTimeOnly DifferentFrom(TimeOnly value) { /// public TimeOnly Generate() { - return Val(_spec.GenerateOrdinal(_source.Current.Random)); + return Val(_spec.GenerateOrdinal(_source.Current)); } } diff --git a/JustDummies/AnyTimeSpan.cs b/JustDummies/AnyTimeSpan.cs index 8028932a..1514fba7 100644 --- a/JustDummies/AnyTimeSpan.cs +++ b/JustDummies/AnyTimeSpan.cs @@ -187,7 +187,7 @@ public AnyTimeSpan DifferentFrom(TimeSpan value) { /// public TimeSpan Generate() { - return Val(_spec.GenerateOrdinal(_source.Current.Random)); + return Val(_spec.GenerateOrdinal(_source.Current)); } } diff --git a/JustDummies/AnyUInt128.cs b/JustDummies/AnyUInt128.cs index cd54c3aa..7aa25df5 100644 --- a/JustDummies/AnyUInt128.cs +++ b/JustDummies/AnyUInt128.cs @@ -172,7 +172,7 @@ public AnyUInt128 DifferentFrom(UInt128 value) { /// public UInt128 Generate() { - return Val(_spec.GenerateOrdinal(_source.Current.Random)); + return Val(_spec.GenerateOrdinal(_source.Current)); } } diff --git a/JustDummies/AnyUInt16.cs b/JustDummies/AnyUInt16.cs index 411f2834..a31e59d7 100644 --- a/JustDummies/AnyUInt16.cs +++ b/JustDummies/AnyUInt16.cs @@ -170,7 +170,7 @@ public AnyUInt16 DifferentFrom(ushort value) { /// public ushort Generate() { - return Val(_spec.GenerateOrdinal(_source.Current.Random)); + return Val(_spec.GenerateOrdinal(_source.Current)); } } diff --git a/JustDummies/AnyUInt32.cs b/JustDummies/AnyUInt32.cs index c97e81e0..2cd9c84b 100644 --- a/JustDummies/AnyUInt32.cs +++ b/JustDummies/AnyUInt32.cs @@ -170,7 +170,7 @@ public AnyUInt32 DifferentFrom(uint value) { /// public uint Generate() { - return Val(_spec.GenerateOrdinal(_source.Current.Random)); + return Val(_spec.GenerateOrdinal(_source.Current)); } } diff --git a/JustDummies/AnyUInt64.cs b/JustDummies/AnyUInt64.cs index c43d37e7..20a25a15 100644 --- a/JustDummies/AnyUInt64.cs +++ b/JustDummies/AnyUInt64.cs @@ -170,7 +170,7 @@ public AnyUInt64 DifferentFrom(ulong value) { /// public ulong Generate() { - return Val(_spec.GenerateOrdinal(_source.Current.Random)); + return Val(_spec.GenerateOrdinal(_source.Current)); } } diff --git a/JustDummies/CollectionState.cs b/JustDummies/CollectionState.cs index 48406048..5284aa1f 100644 --- a/JustDummies/CollectionState.cs +++ b/JustDummies/CollectionState.cs @@ -43,7 +43,7 @@ private static IReadOnlyList Append(IReadOnlyList list, TIt return copy; } - private static void Shuffle(List items, Random random) { + private static void Shuffle(List items, SeededRandom random) { // Fisher-Yates: contained values and filler are appended in a fixed order, so a shuffle keeps a dummy // collection from advertising a positional invariant a caller might accidentally rely on. for (int index = items.Count - 1; index > 0; index--) { @@ -113,10 +113,10 @@ internal CollectionState WithContaining(IAny generator, string applying) { /// Builds one collection satisfying the whole specification — laid out directly, never generate-then-retry. internal List Materialize(RandomSource source) { - Random random = source.Current.Random; - int required = _fixedContaining.Count + _generatedContaining.Count; - int? cap = _distinct ? CardinalityCap() : null; - int count = _count.Resolve(random, required, cap); + SeededRandom random = source.Current; + int required = _fixedContaining.Count + _generatedContaining.Count; + int? cap = _distinct ? CardinalityCap() : null; + int count = _count.Resolve(random, required, cap); if (!_distinct) { List items = new(Math.Max(count, required)); diff --git a/JustDummies/ContinuousIntervalSpec.cs b/JustDummies/ContinuousIntervalSpec.cs index 519a3fd1..c71c4a09 100644 --- a/JustDummies/ContinuousIntervalSpec.cs +++ b/JustDummies/ContinuousIntervalSpec.cs @@ -172,8 +172,7 @@ internal bool Contains(double value) { /// Draws one value satisfying the whole specification. internal double Generate(RandomSource source) { - SeededRandom current = source.Current; - Random random = current.Random; + SeededRandom random = source.Current; if (_effectiveAllowed is not null) { return _effectiveAllowed[random.Next(_effectiveAllowed.Count)]; @@ -194,8 +193,8 @@ internal double Generate(RandomSource source) { double? free = NudgeToFree(candidate, ascending: true) ?? NudgeToFree(candidate, ascending: false); if (free is null) { throw new AnyGenerationException( - $"Generation failed: no {_typeName} value near the drawn candidate satisfies the exclusions. {source.ReplayGuidance(current.Seed)}", - current.Seed, + $"Generation failed: no {_typeName} value near the drawn candidate satisfies the exclusions. {source.ReplayGuidance(random.Seed)}", + random.Seed, new InvalidOperationException("No representable value in range remains after applying the exclusions.")); } diff --git a/JustDummies/CountSpec.cs b/JustDummies/CountSpec.cs index ad40ea29..dc7fa1a2 100644 --- a/JustDummies/CountSpec.cs +++ b/JustDummies/CountSpec.cs @@ -94,7 +94,7 @@ internal CountSpec WithMaxCount(int count, string applying) { /// the ceiling to the number of distinct values a distinct collection can hold. Both are already known to be /// compatible with the declared bounds — the collection validates them eagerly before generation. /// - internal int Resolve(Random random, int requiredMin, int? cap) { + internal int Resolve(SeededRandom random, int requiredMin, int? cap) { if (_exact is int exact) { return exact; } int min = Math.Max(_min, requiredMin); diff --git a/JustDummies/DecimalIntervalSpec.cs b/JustDummies/DecimalIntervalSpec.cs index d2e1da79..c3494e21 100644 --- a/JustDummies/DecimalIntervalSpec.cs +++ b/JustDummies/DecimalIntervalSpec.cs @@ -193,8 +193,7 @@ internal bool Contains(decimal value) { /// Draws one value satisfying the whole specification. internal decimal Generate(RandomSource source) { - SeededRandom current = source.Current; - Random random = current.Random; + SeededRandom random = source.Current; if (_effectiveAllowed is not null) { return _effectiveAllowed[random.Next(_effectiveAllowed.Count)]; @@ -228,8 +227,8 @@ internal decimal Generate(RandomSource source) { decimal? free = NudgeOnGrid(snapped, true) ?? NudgeOnGrid(snapped, false); if (free is null) { throw new AnyGenerationException( - $"Generation failed: no {_typeName} value near the drawn candidate satisfies the exclusions. {source.ReplayGuidance(current.Seed)}", - current.Seed, + $"Generation failed: no {_typeName} value near the drawn candidate satisfies the exclusions. {source.ReplayGuidance(random.Seed)}", + random.Seed, new InvalidOperationException("The grid nudge could not leave the excluded point within the allowed range.")); } @@ -244,8 +243,8 @@ internal decimal Generate(RandomSource source) { decimal next = Clamped(candidate + SmallestStep); if (next == candidate || budget-- == 0) { throw new AnyGenerationException( - $"Generation failed: no {_typeName} value near the drawn candidate satisfies the exclusions. {source.ReplayGuidance(current.Seed)}", - current.Seed, + $"Generation failed: no {_typeName} value near the drawn candidate satisfies the exclusions. {source.ReplayGuidance(random.Seed)}", + random.Seed, new InvalidOperationException("The exclusion nudge could not leave the excluded point within the allowed range.")); } diff --git a/JustDummies/NullableExtensions.cs b/JustDummies/NullableExtensions.cs index 8e30e311..1858f6e9 100644 --- a/JustDummies/NullableExtensions.cs +++ b/JustDummies/NullableExtensions.cs @@ -39,7 +39,7 @@ public static class NullableExtensions { return new DerivedAny(source, reproducible, () => { RandomSource working = source ?? AmbientRandomSource.Instance; - return working.Current.Random.Next(2) == 0 ? (T?)null : generator.Generate(); + return working.Current.Next(2) == 0 ? (T?)null : generator.Generate(); }); } @@ -75,7 +75,7 @@ public static class NullableReferenceExtensions { return new DerivedAny(source, reproducible, () => { RandomSource working = source ?? AmbientRandomSource.Instance; - return working.Current.Random.Next(2) == 0 ? (T?)null : generator.Generate(); + return working.Current.Next(2) == 0 ? (T?)null : generator.Generate(); }); } diff --git a/JustDummies/OrdinalIntervalSpec.cs b/JustDummies/OrdinalIntervalSpec.cs index 20d40d9f..a1ac575f 100644 --- a/JustDummies/OrdinalIntervalSpec.cs +++ b/JustDummies/OrdinalIntervalSpec.cs @@ -257,7 +257,7 @@ internal bool Contains(ulong ordinal) { } /// Draws one ordinal satisfying the whole specification — built directly, never generate-then-retry. - internal ulong GenerateOrdinal(Random random) { + internal ulong GenerateOrdinal(SeededRandom random) { if (_effectiveAllowed is not null) { return _effectiveAllowed[random.Next(_effectiveAllowed.Count)]; } diff --git a/JustDummies/RandomSource.cs b/JustDummies/RandomSource.cs index 14c78c59..bcfae48c 100644 --- a/JustDummies/RandomSource.cs +++ b/JustDummies/RandomSource.cs @@ -9,7 +9,7 @@ namespace JustDummies; /// internal abstract class RandomSource { - /// The seeded pseudo-random generator to draw from right now. + /// The seeded generator to draw from right now. Every draw goes through it, serialized on its own lock. internal abstract SeededRandom Current { get; } /// @@ -41,16 +41,68 @@ internal abstract class RandomSource { } -/// A pseudo-random generator that remembers the seed it was created from. +/// +/// A pseudo-random generator that remembers the seed it was created from, and the only door to it: the +/// underlying is never handed out, so every draw goes through this type and is serialized +/// on its own lock. +/// +/// +/// +/// is not thread-safe, and a source reaches several threads by two ordinary routes: the +/// ambient state flows with the execution context into every task a test spawns, and an +/// is shared by whoever holds it. Left unsynchronized, concurrent draws converge the +/// generator's two internal indices and it returns zero for ever — so every generator settles on the +/// minimum of its declared range (0, "", ) and the source never +/// recovers. Silent, and exactly the values most likely to make an assertion pass for the wrong reason. +/// +/// +/// Keeping the private is what makes the guarantee hold: a synchronized subclass would +/// leak any member left un-overridden, whereas here a draw that bypasses the lock does not compile. An +/// uncontended lock leaves single-threaded sequences bit-identical, so a pinned seed replays exactly as +/// before, and the cost is immaterial on paths that are not hot loops. +/// +/// +/// What this does not buy is a value-level guarantee across threads: the lock is per primitive draw, so +/// two threads interleave inside a multi-draw generation (a string consumes one draw per character). Neither +/// the sequence nor the multiset of generated values is stable under parallelism — see +/// for the per-work-item scope that is. +/// +/// internal sealed class SeededRandom { + #region Fields declarations + + private readonly object _gate = new(); + private readonly Random _random; + + #endregion + internal SeededRandom(int seed) { - Seed = seed; - Random = new Random(seed); + Seed = seed; + _random = new Random(seed); + } + + internal int Seed { get; } + + /// Draws a non-negative below . + internal int Next(int maxExclusive) { + lock (_gate) { return _random.Next(maxExclusive); } + } + + /// Draws an in the half-open range [, ). + internal int Next(int minInclusive, int maxExclusive) { + lock (_gate) { return _random.Next(minInclusive, maxExclusive); } } - internal int Seed { get; } - internal Random Random { get; } + /// Fills with random bytes. + internal void NextBytes(byte[] buffer) { + lock (_gate) { _random.NextBytes(buffer); } + } + + /// Draws a in the half-open range [0, 1). + internal double NextDouble() { + lock (_gate) { return _random.NextDouble(); } + } } @@ -218,7 +270,7 @@ internal static class RandomSampling { /// Random.NextInt64(long, long) instance method — whose upper bound is EXCLUSIVE — would win /// overload resolution over a same-named extension and silently change the semantics. /// - internal static long NextInt64Inclusive(this Random random, long minInclusive, long maxInclusive) { + internal static long NextInt64Inclusive(this SeededRandom random, long minInclusive, long maxInclusive) { if (minInclusive > maxInclusive) { throw new ArgumentOutOfRangeException(nameof(maxInclusive), "The maximum must be greater than or equal to the minimum."); } ulong rangeSize = (ulong)(maxInclusive - minInclusive) + 1UL; @@ -232,12 +284,12 @@ internal static long NextInt64Inclusive(this Random random, long minInclusive, l } /// Draws a uniform in the inclusive range — see . - internal static int NextInt32Inclusive(this Random random, int minInclusive, int maxInclusive) { + internal static int NextInt32Inclusive(this SeededRandom random, int minInclusive, int maxInclusive) { return (int)random.NextInt64Inclusive(minInclusive, maxInclusive); } /// Draws 8 random bytes as a — the raw material of the ordinal sampling. - internal static ulong NextUInt64(this Random random) { + internal static ulong NextUInt64(this SeededRandom random) { byte[] bytes = new byte[8]; random.NextBytes(bytes); diff --git a/JustDummies/RegexNode.cs b/JustDummies/RegexNode.cs index 2b89609b..a8e7958f 100644 --- a/JustDummies/RegexNode.cs +++ b/JustDummies/RegexNode.cs @@ -22,12 +22,12 @@ internal sealed class RegexGenerationContext { #endregion - internal RegexGenerationContext(Random random, int limit) { + internal RegexGenerationContext(SeededRandom random, int limit) { Random = random; _limit = limit; } - internal Random Random { get; } + internal SeededRandom Random { get; } internal void Append(char character) { if (_builder.Length >= _limit) { diff --git a/JustDummies/StringSpec.cs b/JustDummies/StringSpec.cs index ebf7a16f..23ce490a 100644 --- a/JustDummies/StringSpec.cs +++ b/JustDummies/StringSpec.cs @@ -241,7 +241,7 @@ internal StringSpec WithExcluded(IReadOnlyList values, string applying) /// exhausted budget means the exclusions leave the shape unsatisfiable, reported with the seed to replay. /// internal string Generate(RandomSource source) { - Random random = source.Current.Random; + SeededRandom random = source.Current; if (_excluded.Count == 0) { return BuildCandidate(random); } for (int collisions = 0;;) { @@ -251,7 +251,7 @@ internal string Generate(RandomSource source) { } } - private string BuildCandidate(Random random) { + private string BuildCandidate(SeededRandom random) { int required = RequiredLength(); int effectiveMin = Math.Max(_minLength, required); // Long arithmetic: a huge declared minimum must saturate instead of overflowing past int.MaxValue. @@ -290,7 +290,7 @@ private string DescribeExcluded() { return string.Join(", ", _excluded.Select(value => $"\"{value}\"")); } - private static void AppendFiller(StringBuilder builder, Random random, string pool, int count) { + private static void AppendFiller(StringBuilder builder, SeededRandom random, string pool, int count) { for (int i = 0; i < count; i++) { builder.Append(pool[random.Next(pool.Length)]); } diff --git a/JustDummies/UriSpec.cs b/JustDummies/UriSpec.cs index a0a14a0c..311632ae 100644 --- a/JustDummies/UriSpec.cs +++ b/JustDummies/UriSpec.cs @@ -172,15 +172,15 @@ internal UriSpec Rooted() { #region Generation internal Uri Generate(RandomSource source) { - Random random = source.Current.Random; - UriFamily family = _family ?? DefaultFamilies[random.Next(DefaultFamilies.Length)]; + SeededRandom random = source.Current; + UriFamily family = _family ?? DefaultFamilies[random.Next(DefaultFamilies.Length)]; return family == UriFamily.Relative ? new Uri(BuildRelative(source), UriKind.Relative) : new Uri(BuildAbsolute(family, random), UriKind.Absolute); } - private string BuildAbsolute(UriFamily family, Random random) { + private string BuildAbsolute(UriFamily family, SeededRandom random) { string scheme = ResolveScheme(family, random); StringBuilder builder = new(); builder.Append(scheme).Append(':'); @@ -212,7 +212,7 @@ private string BuildAbsolute(UriFamily family, Random random) { } private string BuildRelative(RandomSource source) { - Random random = source.Current.Random; + SeededRandom random = source.Current; StringBuilder builder = new(); builder.Append(Path(random, leadingSlash: _rooted)); if (_hasQuery) { builder.Append(Query(random)); } @@ -235,7 +235,7 @@ private string BuildRelative(RandomSource source) { return Draw(random, LowerAlphaNum, 1, 8); } - private string ResolveScheme(UriFamily family, Random random) { + private string ResolveScheme(UriFamily family, SeededRandom random) { if (_scheme is not null) { return _scheme; } return family switch { @@ -247,7 +247,7 @@ private string ResolveScheme(UriFamily family, Random random) { }; } - private string Path(Random random, bool leadingSlash) { + private string Path(SeededRandom random, bool leadingSlash) { int count = _pathMode switch { UriPathMode.Root => 0, UriPathMode.Exact => _pathSegments, @@ -265,7 +265,7 @@ private string Path(Random random, bool leadingSlash) { return builder.ToString(); } - private static string Query(Random random) { + private static string Query(SeededRandom random) { int pairs = random.Next(1, 3); // 1..2 StringBuilder builder = new("?"); for (int i = 0; i < pairs; i++) { @@ -276,16 +276,16 @@ private static string Query(Random random) { return builder.ToString(); } - private static string Host(Random random) { + private static string Host(SeededRandom random) { return Label(random) + "." + Draw(random, LowerLetters, 2, 4); } - private static string Label(Random random) { + private static string Label(SeededRandom random) { // A DNS-safe label: starts with a letter, then letters/digits — no leading digit, no hyphen edges. return LowerLetters[random.Next(LowerLetters.Length)].ToString() + Draw(random, LowerAlphaNum, 0, 7); } - private static string Draw(Random random, string pool, int min, int max) { + private static string Draw(SeededRandom random, string pool, int min, int max) { int length = min == max ? min : random.Next(min, max + 1); StringBuilder builder = new(length); for (int i = 0; i < length; i++) { builder.Append(pool[random.Next(pool.Length)]); } diff --git a/JustDummies/WideIntervalSpec.cs b/JustDummies/WideIntervalSpec.cs index 1f681a94..e3bed2f2 100644 --- a/JustDummies/WideIntervalSpec.cs +++ b/JustDummies/WideIntervalSpec.cs @@ -15,7 +15,7 @@ internal static WideIntervalSpec Unconstrained(string typeName, FuncDraws one ordinal satisfying the whole specification — built directly, never generate-then-retry. - internal UInt128 GenerateOrdinal(Random random) { + internal UInt128 GenerateOrdinal(SeededRandom random) { if (_effectiveAllowed is not null) { return _effectiveAllowed[random.Next(_effectiveAllowed.Count)]; } From e88757c176468a25c884f217f7ee85ae96471865 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 08:02:49 +0000 Subject: [PATCH 3/8] docs(justdummies): state the concurrency contract of the random sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two sources said different things and neither was true. AnyContext claimed to be "not thread-safe" with nothing enforcing it; the ambient source said nothing, while the user guide's only statement on the subject covered the cross-test axis and read as general safety. One contract now holds for both: safe to draw from concurrently, no value-level guarantee across threads, and a per-work-item Any.UseSeed scope for a parallel run that must replay — a recipe the public surface already allowed and nothing documented. Refs: #310 --- JustDummies/Any.cs | 20 +++++++++++++++++++ JustDummies/AnyContext.cs | 7 +++++-- JustDummies/IAny.cs | 6 ++++++ JustDummies/README.nuget.md | 5 ++++- .../for-users/ArbitraryTestValues.en.md | 18 +++++++++++++++++ .../for-users/ArbitraryTestValues.fr.md | 18 +++++++++++++++++ 6 files changed, 71 insertions(+), 3 deletions(-) diff --git a/JustDummies/Any.cs b/JustDummies/Any.cs index d74a58f6..086d013e 100644 --- a/JustDummies/Any.cs +++ b/JustDummies/Any.cs @@ -395,6 +395,11 @@ public static AnyOneOf ElementOf(IEnumerable values) { /// behavior and reports the seed only when the test fails; reach for when you need an /// explicit generator object, for example outside a test body. /// + /// + /// A context is safe to draw from concurrently, but sharing one across threads costs the replay rather than + /// the values: interleaved draws make neither the sequence nor the multiset stable across runs. Keep a + /// context to one thread at a time, or give each unit of work its own scope. + /// /// The seed pinning the context's value sequence. /// A deterministic generation context. public static AnyContext WithSeed(int seed) { @@ -420,6 +425,21 @@ public static AnyContext WithSeed(int seed) { /// disposing twice is harmless. Failing to dispose leaves the seed pinned for whatever runs next in the /// same execution context. /// + /// + /// Flowing with the execution context also means a scope opened around a parallel loop reaches every + /// worker, which is what makes this the seam for a reproducible parallel run: draws are safe under + /// concurrency but interleave, so one shared scope replays nothing, whereas a scope opened inside + /// the loop body gives each unit of work its own sequence and the whole run replays. + /// + /// + /// + /// Parallel.For(0, 64, index => { + /// using (Any.UseSeed(HashCode.Combine(runSeed, index))) { + /// sut.Handle(Any.String().NonEmpty().Generate()); + /// } + /// }); + /// + /// /// /// The seed pinning the ambient context's value sequence. /// A handle that restores the previous ambient context when disposed. diff --git a/JustDummies/AnyContext.cs b/JustDummies/AnyContext.cs index 6502942c..6ca2e214 100644 --- a/JustDummies/AnyContext.cs +++ b/JustDummies/AnyContext.cs @@ -20,8 +20,11 @@ namespace JustDummies; /// for example. /// /// -/// A context owns a single pseudo-random generator and is not thread-safe: use one context per test -/// or per generation sequence, not a shared one. +/// A context owns a single pseudo-random generator, and it is safe to draw from concurrently. What +/// parallelism costs is the replay, not the values: the draws of two threads interleave, so neither the +/// sequence nor the multiset of values a context produces is stable across runs once it is shared. A context +/// used from one thread at a time replays exactly; to keep a parallel run reproducible, give each unit of +/// work its own scope with rather than sharing one context across threads. /// /// public sealed class AnyContext { diff --git a/JustDummies/IAny.cs b/JustDummies/IAny.cs index 0b3f28af..a105fab5 100644 --- a/JustDummies/IAny.cs +++ b/JustDummies/IAny.cs @@ -31,6 +31,12 @@ public interface IAny { /// /// Produces one arbitrary value satisfying every constraint declared on this generator. /// + /// + /// Safe to call concurrently: draws on a random context are serialized, so no amount of parallelism can + /// degrade the values. Reproducibility is what parallelism costs — concurrent draws interleave, so a seed + /// replays a run only while its draws are taken one at a time. To keep a parallel run reproducible, open a + /// scope per unit of work with and derive its seed from the run's own. + /// /// A value that satisfies the declared constraints. /// /// Thrown when the value cannot be produced even though the declared constraints were accepted — for example diff --git a/JustDummies/README.nuget.md b/JustDummies/README.nuget.md index a936403b..a6f48408 100644 --- a/JustDummies/README.nuget.md +++ b/JustDummies/README.nuget.md @@ -119,7 +119,10 @@ matter — and that is the point. a caller that has no body to wrap — a test-framework adapter driving the seed from before/after hooks. Its second overload names what the reader must write to replay, so a run pinned from outside the test body never points at a call the test does not - contain. + contain. Drawing from several threads at once is safe — values stay arbitrary and + well-formed — but concurrent draws interleave, so a seed replays a run only while its + draws are taken one at a time; open a `Any.UseSeed(...)` scope per unit of work to + keep a parallel run reproducible. ## Example diff --git a/doc/handwritten/for-users/ArbitraryTestValues.en.md b/doc/handwritten/for-users/ArbitraryTestValues.en.md index bdad2506..cb0d7e85 100644 --- a/doc/handwritten/for-users/ArbitraryTestValues.en.md +++ b/doc/handwritten/for-users/ArbitraryTestValues.en.md @@ -144,6 +144,24 @@ Both draw from the same ambient source as `JustDummies.Any`, so running them ins `JustDummies.Any.Reproducibly`, `Clock.UseAny`, and `InstanceIds.UseAny` all take effect only for the run or `using` block they wrap, and the arbitrary source is restored when it exits. That source is stored in an `AsyncLocal`, so it follows the test's own execution flow and never leaks into other tests running at the same time. +### Inside a test that parallelises + +Following the test's execution flow means the source also reaches the threads the test itself starts — a `Parallel.For`, a `Task.WhenAll`. Drawing from several of them at once is safe: the values stay arbitrary and well-formed however many threads take them. + +What parallelism costs is the *replay*. Concurrent draws interleave, so a seed no longer pins which value lands in which call, and a run that parallelises does not reproduce from its seed alone. If you only need dummies, nothing to do. If you need the run to replay, give each unit of work its own scope and derive its seed from the run's: + +```csharp +Any.Reproducibly(() => { + Parallel.For(0, 64, index => { + using (Any.UseSeed(HashCode.Combine(runSeed, index))) { + sut.Handle(Any.String().NonEmpty().Generate()); + } + }); +}); +``` + +Each iteration then owns its own sequence, and the whole run replays for a given `runSeed`. + ## Review checklist Before reaching for an arbitrary value, verify that: diff --git a/doc/handwritten/for-users/ArbitraryTestValues.fr.md b/doc/handwritten/for-users/ArbitraryTestValues.fr.md index 7136cd87..f7870665 100644 --- a/doc/handwritten/for-users/ArbitraryTestValues.fr.md +++ b/doc/handwritten/for-users/ArbitraryTestValues.fr.md @@ -144,6 +144,24 @@ Les deux tirent de la même source ambiante que `JustDummies.Any` : les exécute `JustDummies.Any.Reproducibly`, `Clock.UseAny` et `InstanceIds.UseAny` ne prennent effet que pour l’exécution ou le bloc `using` qu’ils enveloppent, et la source arbitraire est restaurée à leur sortie. Cette source est stockée dans un `AsyncLocal` : elle suit le flux d’exécution du test lui-même et ne fuit jamais dans d’autres tests s’exécutant en même temps. +### À l’intérieur d’un test qui parallélise + +Suivre le flux d’exécution du test signifie aussi que la source atteint les threads que le test démarre lui-même — un `Parallel.For`, un `Task.WhenAll`. Y puiser depuis plusieurs d’entre eux à la fois est sûr : les valeurs restent arbitraires et bien formées, quel que soit le nombre de threads qui les tirent. + +Ce que le parallélisme coûte, c’est le *rejeu*. Les tirages concurrents s’entrelacent : une graine ne fixe plus quelle valeur atterrit dans quel appel, et une exécution parallélisée ne se reproduit pas à partir de sa seule graine. Si vous n’avez besoin que de dummies, il n’y a rien à faire. Si vous avez besoin que l’exécution rejoue, donnez à chaque unité de travail sa propre portée et dérivez sa graine de celle de l’exécution : + +```csharp +Any.Reproducibly(() => { + Parallel.For(0, 64, index => { + using (Any.UseSeed(HashCode.Combine(runSeed, index))) { + sut.Handle(Any.String().NonEmpty().Generate()); + } + }); +}); +``` + +Chaque itération possède alors sa propre séquence, et l’exécution entière rejoue pour un `runSeed` donné. + ## Checklist de revue Avant de recourir à une valeur arbitraire, vérifiez que : From 8c28c20679c99f9a8499f1affb585109e860b4a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 08:03:01 +0000 Subject: [PATCH 4/8] docs: add ADR-0042 on serializing draws on a random source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0006 named this hazard verbatim and chose AsyncLocal context-locality as the remedy — which answers which source is in effect, never how it is touched. It is superseded by ADR-0026, which does not revisit the question, so the concurrency contract is recorded as a new decision rather than as an edit to a superseded one. Drafted as Proposed for @reefact to accept. Refs: #310 --- ...2-serialize-draws-on-a-random-source.fr.md | 90 +++++++++++++++++++ ...0042-serialize-draws-on-a-random-source.md | 90 +++++++++++++++++++ doc/handwritten/for-maintainers/adr/README.md | 1 + 3 files changed, 181 insertions(+) create mode 100644 doc/handwritten/for-maintainers/adr/0042-serialize-draws-on-a-random-source.fr.md create mode 100644 doc/handwritten/for-maintainers/adr/0042-serialize-draws-on-a-random-source.md diff --git a/doc/handwritten/for-maintainers/adr/0042-serialize-draws-on-a-random-source.fr.md b/doc/handwritten/for-maintainers/adr/0042-serialize-draws-on-a-random-source.fr.md new file mode 100644 index 00000000..5447ecdd --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0042-serialize-draws-on-a-random-source.fr.md @@ -0,0 +1,90 @@ +# ADR-0042 | Sérialiser les tirages sur une source aléatoire, et borner la reproductibilité à la séquence de tirages + +🌍 🇫🇷 Français (ce fichier) · 🇬🇧 [English](0042-serialize-draws-on-a-random-source.md) + +**Statut :** Proposé +**Date :** 2026-07-27 +**Décideurs :** Reefact + +## Contexte + +`JustDummies` tire chaque valeur arbitraire d'une `RandomSource`, qui possède un `System.Random`. Il existe deux sources : l'ambiante, derrière les points d'entrée statiques `Any`, dont l'état vit dans un `AsyncLocal`, et la source fixe que possède un `AnyContext` issu de `Any.WithSeed`. + +`System.Random` n'est pas thread-safe. Son implémentation semée mute un tableau et deux index à chaque tirage sans aucune synchronisation ; sous contention, les deux index peuvent converger, après quoi le générateur retourne zéro définitivement. Rien ne le réinitialise. Comme la couche des valeurs projette un tirage nul sur le bas de la plage déclarée, chaque générateur se fige alors sur le minimum de son propre domaine — `0`, `""`, `Guid.Empty`, `int.MinValue` — pour toute la durée de vie restante de cette source, et aucune exception n'est levée. + +Une source atteint plusieurs threads par deux voies ordinaires, dont aucune n'est un mésusage. + +* Un `AsyncLocal` **descend** dans les tâches et les threads que son propriétaire démarre. Dès qu'une portée de graine est installée — ce que `Any.Reproducibly` et `Any.UseSeed` font toujours — un `Parallel.For` ou un `Task.WhenAll` à l'intérieur du test remet la même source à chaque worker. Hors portée de graine, l'état ambiant est créé paresseusement : chaque worker écrit son propre emplacement et obtient son propre générateur. Le chemin non semé est donc indemne, et c'est l'épinglage d'une graine qui crée le partage. +* Un `AnyContext` est un objet ; qui le détient peut le partager. + +L'ADR-0006 a enregistré la décision d'origine et a nommé ce danger exactement — *« a single shared, mutable `System.Random` is not thread-safe and would produce cross-test interference and non-reproducible values »* — puis a retenu la localité de contexte par `AsyncLocal` comme remède. Ce remède traite l'axe **inter-tests** : deux tests en parallèle ne voient jamais la graine l'un de l'autre, ce qui est vrai et fait l'objet d'un garde-fou distinct. Il ne traite pas l'axe **intra-test**, que l'ADR n'envisage pas ; et le mécanisme qu'il choisit est précisément ce qui propage l'instance partagée. L'ADR-0006 est remplacé par l'ADR-0026, qui rebase `FirstClassErrors.Testing` sur `JustDummies` sans rouvrir la question. + +Deux propriétés de la bibliothèque pèsent sur le remède. Les tirages se situent sur des chemins d'erreur et d'arrangement, jamais dans des boucles chaudes. Et `Any.UseSeed` est public depuis l'ADR-0038 — ouvert pour les adaptateurs de framework de test, mais utilisable par n'importe quel appelant, y compris dans le corps d'une boucle parallèle, où l'emplacement `AsyncLocal` propre à chaque worker rend la portée privée à cette itération. + +Les promesses affichées du paquet sont que les valeurs sont arbitraires mais valides, et qu'une exécution est reproductible à partir d'une graine rapportée. La documentation utilisateur indique que la source est *« safe under parallel tests »* et explique l'`AsyncLocal` ; les remarques d'`AnyContext` indiquent qu'il est *« not thread-safe »* sans que rien ne le fasse respecter. Aucune des deux sources n'est protégée, et les deux disent des choses différentes. + +`JustDummies` est pré-1.0 et non publié (ADR-0011) : le contrat peut encore être posé plutôt que corrigé. + +## Décision + +Chaque tirage sur une source aléatoire est sérialisé sur le verrou propre à cette source, et la promesse de reproductibilité est bornée à une séquence de tirages pris un à la fois — une exécution parallèle ne rejoue que si chaque unité de travail ouvre sa propre portée de graine. + +## Justification + +* **Le défaut est un danger de mutation, non de portée : le remède appartient donc là où se trouve la mutation.** L'`AsyncLocal` répond à *quelle* source est en vigueur ; il n'a jamais pu répondre à *comment* on y touche. Ajouter un verrou laisse intacte la décision de portée de l'ADR-0006 et fournit la propriété qu'on lui prêtait à tort. Retirer l'`AsyncLocal` casserait l'isolation inter-tests qu'il assure réellement, et le remplacer par un stockage lié au thread perdrait la graine au premier `await`. +* **Sérialiser ne coûte rien qui compte, et préserve toutes les exécutions existantes.** Un verrou non contendu ne change pas l'ordre dans lequel un thread unique consomme le flux : une graine épinglée rejoue donc à l'identique bit à bit — la propriété qui permet de livrer ceci sans invalider un seul test, ici comme chez un consommateur. Sur des chemins qui relèvent de l'arrangement et non du calcul, le coût du verrou est négligeable. +* **Le générateur doit être l'unique porte, pour qu'un contournement ne compile pas.** Livrer le `Random` sous-jacent derrière une façade synchronisée laisserait silencieusement non protégé tout membre que la façade ne redéfinit pas, et le prochain tirage ajouté déciderait par accident s'il est sûr. Garder l'instance privée transforme cela en erreur de compilation. C'est le raisonnement que les ADR-0031 et ADR-0035 appliquent ailleurs : rendre la règle incassable plutôt que seulement vérifiée. +* **La promesse doit se réduire à ce que la sérialisation apporte réellement.** Le verrou est pris par tirage primitif, et une seule valeur générée peut en consommer beaucoup — une chaîne tire un caractère à la fois — de sorte que deux threads s'entrelacent *à l'intérieur* d'une même génération. Ni la séquence ni le multiensemble des valeurs générées ne sont donc stables sous parallélisme, et une promesse de reproductibilité parallèle serait fausse. Énoncer la garantie la plus étroite est ce qui maintient la fiabilité du rapport de graine — la même exigence que la bibliothèque applique déjà lorsqu'elle retient sa promesse de rejeu complet face à un générateur étranger. +* **La promesse réduite ne coûte rien à l'utilisateur, car la plus large est déjà atteignable.** Une portée ouverte dans le corps d'une boucle parallèle est privée à son worker : dériver une graine par unité de travail à partir de celle de l'exécution fait rejouer l'ensemble. Ce mécanisme est déjà public, donc la décision n'ajoute aucune surface : elle documente une capacité au lieu de la construire. +* **Une seule règle pour les deux sources supprime une contradiction.** Verrouiller le point de passage commun protège d'un coup la source ambiante et `AnyContext`, ce qui permet de remplacer la remarque « not thread-safe » non appliquée de ce dernier par le contrat désormais vrai pour les deux. + +## Alternatives envisagées + +### Donner à chaque thread son propre générateur + +Supprime la corruption sans verrou, en dérivant un générateur par thread à partir de la graine de l'exécution. Rejetée parce qu'elle détruit la propriété pour laquelle la bibliothèque existe : la correspondance thread → sous-flux est fixée par l'ordonnanceur, donc la même graine produit des valeurs différentes d'une exécution à l'autre. Dans une bibliothèque semée, le nombre de générateurs et leur propriété *sont* le contrat de reproductibilité, pas un détail d'implémentation — la raison même pour laquelle ceci ne peut pas être traité comme un choix local de sûreté d'accès. + +### Lever une exception sur usage concurrent d'une source + +La branche « interdire explicitement », et la plus conforme à l'habitude de la bibliothèque d'échouer vite sur une contradiction. Rejetée pour deux motifs. Un tirage concurrent n'est pas une contradiction : un test qui parallélise sans avoir besoin d'un rejeu appel par appel est légitime, et sous verrou il fonctionne. Et la détection n'est pas fiable dans la forme qui compte — un test `async` reprend légitimement sur un autre thread sans la moindre concurrence, donc toute vérification d'affinité de thread rejetterait du code correct, tandis qu'un vrai détecteur de chevauchement coûte ce que coûte un verrou en apportant moins. + +### Utiliser un générateur thread-safe de la plateforme + +`Random.Shared` est thread-safe et sans verrou. Rejetée parce qu'il ne peut pas être semé, ce qui condamne toute la surface de reproductibilité, et parce qu'il n'existe pas sur la cible `netstandard2.0` sur laquelle la bibliothèque plancher (ADR-0022). + +### Ne rien faire et documenter la limitation + +Rejetée parce que la défaillance est silencieuse et que son résultat est indiscernable d'une valeur légitime : un dummy devenu `0`, `""` ou `Guid.Empty` est exactement la valeur la plus susceptible de faire passer une assertion pour la mauvaise raison. Une limitation qu'un utilisateur ne peut ni observer ni détecter n'est pas une limitation que la documentation peut solder. + +## Conséquences + +### Positives + +* Des tirages concurrents ne peuvent plus dégrader une source, ni sur le chemin ambiant ni sur celui du contexte, et une source reste utilisable pour les tirages séquentiels pris après une section parallèle. +* Les exécutions semées existantes sont inchangées : les séquences mono-thread sont identiques bit à bit. +* Les deux sources énoncent un seul contrat au lieu de deux contradictoires. +* La génération parallèle reproductible devient une recette exprimable et documentée, au lieu d'une impossibilité tue. + +### Négatives + +* Chaque tirage passe par un verrou, y compris l'immense majorité qui est mono-thread et ne peut pas contendre. +* La promesse de reproductibilité est désormais explicitement conditionnelle, ce qui est une phrase plus faible à écrire dans la documentation que celle qu'un lecteur aurait pu supposer. +* Les appelants de la source interne passent désormais par ses méthodes plutôt que par un `Random` : un futur tirage primitif devra être ajouté à ce type avant de pouvoir être utilisé. + +### Risques + +* Un utilisateur qui parallélise en attendant que la graine seule rejoue l'exécution constatera que non. Atténuation : la condition est énoncée dans la documentation XML du tirage et des deux points d'entrée de graine, et la recette par unité de travail est documentée dans le guide utilisateur. +* La sérialisation rend un arrangement *pathologiquement* parallèle plus lent qu'il ne le serait autrement. Accepté : la génération de dummies n'est pas un chemin chaud, et l'alternative est une corruption silencieuse. + +## Actions de suivi + +* À revisiter seulement si une charge mesurée montre que le verrou est significatif, ce qui reviendrait à rouvrir l'idée des sous-flux par unité de travail comme couture *publique* plutôt que comme substitution interne. + +## Références + +* Issue [#310](https://github.com/Reefact/first-class-errors/issues/310) — le défaut et ses mesures. +* [ADR-0006](0006-supply-arbitrary-test-values-from-a-seedable-source.fr.md) — la décision d'origine sur la source semable, qui nomme le danger et ne traite que l'axe inter-tests. +* [ADR-0026](0026-rebase-testing-arbitrary-values-on-dummies.fr.md) — remplace l'ADR-0006 sans rouvrir la question. +* [ADR-0038](0038-open-the-ambient-seed-scope-to-adapters.fr.md) — rend publique la portée de graine ambiante, ce qui met la recette par unité de travail à portée. +* [ADR-0022](0022-floor-the-library-on-net-framework-4-7-2.fr.md) — le plancher `netstandard2.0` qui écarte `Random.Shared`. +* [ADR-0031](0031-name-any-factories-after-their-clr-type.fr.md), [ADR-0035](0035-enforce-structural-any-conflicts-at-compile-time.fr.md) — le précédent « rendre la règle incassable plutôt que seulement vérifiée ». diff --git a/doc/handwritten/for-maintainers/adr/0042-serialize-draws-on-a-random-source.md b/doc/handwritten/for-maintainers/adr/0042-serialize-draws-on-a-random-source.md new file mode 100644 index 00000000..03d4813a --- /dev/null +++ b/doc/handwritten/for-maintainers/adr/0042-serialize-draws-on-a-random-source.md @@ -0,0 +1,90 @@ +# ADR-0042 | Serialize draws on a random source, and scope reproducibility to the draw sequence + +🌍 🇬🇧 English (this file) · 🇫🇷 [Français](0042-serialize-draws-on-a-random-source.fr.md) + +**Status:** Proposed +**Date:** 2026-07-27 +**Decision Makers:** Reefact + +## Context + +`JustDummies` draws every arbitrary value from a `RandomSource`, which owns one `System.Random`. Two sources exist: the ambient one behind the static `Any` entry points, whose state lives in an `AsyncLocal`, and the fixed one owned by an `AnyContext` from `Any.WithSeed`. + +`System.Random` is not thread-safe. Its seeded implementation mutates an array and two indices on every draw with no synchronisation; under contention the two indices can converge, after which the generator returns zero permanently. Nothing resets it. Because the value layer maps a zero draw onto the bottom of whatever range was declared, every generator then settles on the minimum of its own domain — `0`, `""`, `Guid.Empty`, `int.MinValue` — for the remaining life of that source, and no exception is raised. + +A source reaches several threads by two ordinary routes, neither of which is a misuse. + +* An `AsyncLocal` **flows into** the tasks and threads its owner starts. Once a seed scope is installed — which `Any.Reproducibly` and `Any.UseSeed` always do — a `Parallel.For` or a `Task.WhenAll` inside the test hands the same source to every worker. Outside a seed scope the ambient state is created lazily, so each worker writes its own slot and gets its own generator: the unseeded path is unaffected, and pinning a seed is what creates the sharing. +* An `AnyContext` is an object; whoever holds it can share it. + +ADR-0006 recorded the original decision and named this hazard exactly — *"a single shared, mutable `System.Random` is not thread-safe and would produce cross-test interference and non-reproducible values"* — then chose `AsyncLocal` context-locality as the remedy. That remedy addresses the **cross-test** axis: two tests running in parallel never see each other's seed, which holds and is separately guarded. It does not address the **intra-test** axis, which the ADR does not consider; and the mechanism it selects is what propagates the shared instance. ADR-0006 is superseded by ADR-0026, which rebases `FirstClassErrors.Testing` onto `JustDummies` and does not revisit the question. + +Two properties of the library bear on the remedy. Draws sit on error and arrangement paths, never in hot loops. And `Any.UseSeed` is public since ADR-0038 — opened for test-framework adapters, but usable by any caller, including inside a parallel loop body, where each worker's own `AsyncLocal` slot makes the scope private to that iteration. + +The package's stated promises are that values are arbitrary yet valid, and that a run is reproducible from a reported seed. The user documentation states that the source is *"safe under parallel tests"* and explains the `AsyncLocal`; `AnyContext`'s remarks state it is *"not thread-safe"* without anything enforcing it. Neither source is protected, and the two say different things. + +`JustDummies` is pre-1.0 and unpublished (ADR-0011), so the contract can still be set rather than corrected. + +## Decision + +Every draw on a random source is serialized on that source's own lock, and the reproducibility promise is scoped to a sequence of draws taken one at a time — a parallel run replays only when each unit of work opens its own seed scope. + +## Rationale + +* **The defect is a mutation hazard, not a scoping one, so the remedy belongs where the mutation is.** `AsyncLocal` answers *which* source is in effect; it was never able to answer *how* that source is touched. Adding a lock leaves the scoping decision of ADR-0006 intact and supplies the property it was mistakenly believed to provide. Removing the `AsyncLocal` would break the cross-test isolation it does provide, and replacing it with thread-affine storage would lose the seed at the first `await`. +* **Serializing costs nothing that matters, and preserves every existing run.** An uncontended lock does not change the order in which a single thread consumes the stream, so a pinned seed replays bit-identically — the property that lets this ship without invalidating any test, in this repository or in a consumer's. On paths that are arrangement rather than computation, the cost of the lock is immaterial. +* **The generator must be the only door, so that bypassing it cannot compile.** Handing out the underlying `Random` behind a synchronized façade would leave every member the façade does not override silently unprotected, and the next draw added would decide by accident whether it is safe. Keeping the instance private turns that into a compile error. This is the same reasoning ADR-0031 and ADR-0035 apply elsewhere: make the rule un-break-able rather than merely checked. +* **The promise has to shrink to what serialization actually delivers.** The lock is taken per primitive draw, and a single generated value may consume many — a string draws once per character — so two threads interleave *inside* one generation. Neither the sequence nor the multiset of generated values is therefore stable under parallelism, and a promise of parallel reproducibility would be false. Stating the narrower guarantee is what keeps the seed report trustworthy, which is the same standard the library already applies when it withholds a full-replay claim for a foreign generator. +* **The narrower promise costs the user nothing, because the wider one is already reachable.** A scope opened inside a parallel loop body is private to its worker, so deriving one seed per unit of work from the run's seed makes the whole run replay. That mechanism is already public, so the decision adds no surface: it documents a capability rather than building one. +* **One rule for both sources removes a contradiction.** Locking the shared choke point protects the ambient source and `AnyContext` alike, which lets the latter's unenforced "not thread-safe" remark be replaced by the contract that now holds for both. + +## Alternatives Considered + +### Give each thread its own generator + +Removes the corruption without a lock, by deriving a per-thread generator from the run's seed. Rejected because it destroys the property the library exists to provide: the mapping from thread to sub-stream is set by the scheduler, so the same seed yields different values from one run to the next. In a seeded library the number of generators and their ownership *is* the reproducibility contract, not an implementation detail — the very reason this cannot be treated as a local choice about thread safety. + +### Throw when a source is drawn from concurrently + +The "forbid it explicitly" branch, and the one most aligned with the library's habit of failing fast on a contradiction. Rejected on two counts. A concurrent draw is not a contradiction: a test that parallelises without needing a per-call replay is legitimate, and under a lock it works. And the detection is unsound in the shape that matters — an `async` test legitimately resumes on another thread with no concurrency at all, so any thread-affinity check would reject correct code, while a true overlap detector costs what a lock costs and delivers less. + +### Use a thread-safe generator from the platform + +`Random.Shared` is thread-safe and lock-free. Rejected because it cannot be seeded, which forecloses the entire reproducibility surface, and because it does not exist on the `netstandard2.0` target the library floors on (ADR-0022). + +### Leave it and document the limitation + +Rejected because the failure is silent and its result is indistinguishable from a legitimate value: a dummy that becomes `0`, `""` or `Guid.Empty` is exactly the value most likely to make an assertion pass for the wrong reason. A limitation a user can neither observe nor detect is not one documentation can discharge. + +## Consequences + +### Positive + +* Concurrent draws can no longer degrade a source, on either the ambient or the context path, and a source stays usable for the sequential draws taken after a parallel section. +* Existing seeded runs are unaffected: single-threaded sequences are bit-identical. +* The two sources state one contract instead of two contradictory ones. +* Reproducible parallel generation becomes an expressible, documented recipe rather than an unstated impossibility. + +### Negative + +* Every draw goes through a lock, including the overwhelming majority that are single-threaded and cannot contend. +* The reproducibility promise is now explicitly conditional, which is a weaker sentence to write in the documentation than the one a reader might have assumed. +* Callers of the internal source now go through its methods rather than a `Random`, so a future draw primitive must be added to that type before it can be used. + +### Risks + +* A user who parallelises and expects the seed alone to replay the run will find it does not. Mitigation: the condition is stated in the XML documentation of the draw and of both seeding entry points, and the per-work-item recipe is documented in the user guide. +* Serialization makes a *pathologically* parallel arrangement slower than it would otherwise be. Accepted: dummy generation is not a hot path, and the alternative is silent corruption. + +## Follow-up Actions + +* Revisit only if a measured workload shows the lock to be material, which would mean reopening the per-work-item sub-stream idea as a *public* seam rather than as an internal substitution. + +## References + +* Issue [#310](https://github.com/Reefact/first-class-errors/issues/310) — the defect and its measurements. +* [ADR-0006](0006-supply-arbitrary-test-values-from-a-seedable-source.md) — the original seedable-source decision, which named the hazard and addressed the cross-test axis only. +* [ADR-0026](0026-rebase-testing-arbitrary-values-on-dummies.md) — supersedes ADR-0006 without revisiting the question. +* [ADR-0038](0038-open-the-ambient-seed-scope-to-adapters.md) — makes the ambient seed scope public, which is what puts the per-work-item recipe within reach. +* [ADR-0022](0022-floor-the-library-on-net-framework-4-7-2.md) — the `netstandard2.0` floor that rules out `Random.Shared`. +* [ADR-0031](0031-name-any-factories-after-their-clr-type.md), [ADR-0035](0035-enforce-structural-any-conflicts-at-compile-time.md) — the "make the rule un-break-able rather than merely checked" precedent. diff --git a/doc/handwritten/for-maintainers/adr/README.md b/doc/handwritten/for-maintainers/adr/README.md index ee0fd471..ec581398 100644 --- a/doc/handwritten/for-maintainers/adr/README.md +++ b/doc/handwritten/for-maintainers/adr/README.md @@ -224,3 +224,4 @@ Optional supporting material: | [ADR-0039](0039-adapt-dummies-to-xunit-v3-through-a-companion-package.md) | Adapt JustDummies to xUnit v3 through a companion package | Accepted | | [ADR-0040](0040-split-the-justdummies-test-bed-between-example-and-property-suites.md) | Split the JustDummies test bed between an example suite and a property suite | Accepted | | [ADR-0041](0041-draw-flag-enum-combinations-behind-an-opt-in.md) | Draw flag-enum combinations behind an opt-in | Accepted | +| [ADR-0042](0042-serialize-draws-on-a-random-source.md) | Serialize draws on a random source, and scope reproducibility to the draw sequence | Proposed | From b1f74baf7f52c3907a24df0f2d6ef3544f29202b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 08:46:42 +0000 Subject: [PATCH 5/8] docs(justdummies): narrow the package promise to sequential runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NuGet Description promised "any run is reproducible from a reported seed" without qualification, which the package's own README now contradicts three files away: concurrent draws interleave, so a seed replays a run only while its draws are taken one at a time. Scope the headline promise to "any sequential run" so the shipped artifact stops claiming a reproducibility it cannot deliver under parallelism — the honest boundary the concurrency contract established. Refs: #310 --- JustDummies/JustDummies.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/JustDummies/JustDummies.csproj b/JustDummies/JustDummies.csproj index b78ca127..1c7c7948 100644 --- a/JustDummies/JustDummies.csproj +++ b/JustDummies/JustDummies.csproj @@ -26,7 +26,7 @@ - A fluent DSL for generating arbitrary yet valid test values: dummies. Constraints express the invariants a value must satisfy — never what the test asserts. Conflicting constraints fail fast with clear, actionable exceptions, and any run is reproducible from a reported seed. + A fluent DSL for generating arbitrary yet valid test values: dummies. Constraints express the invariants a value must satisfy — never what the test asserts. Conflicting constraints fail fast with clear, actionable exceptions, and any sequential run is reproducible from a reported seed. From c8075501fed24dd656cdf3132ada88d80bade5ef Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 08:58:57 +0000 Subject: [PATCH 6/8] docs(justdummies): correct the reproducible-parallel recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the concurrency docs found the parallel-reproducibility recipe misleading in a section whose subject is reproducibility: - it wrapped the loop in a seedless Any.Reproducibly(() => ...), whose reported seed replays nothing because every draw happens inside a per-item UseSeed scope that overrides the ambient source — dropped it, and show runSeed as a recorded constant instead, so the reader keeps the number that actually replays; - it derived the sub-seed with System.HashCode.Combine, which does not exist on netstandard2.0 / net472 (the floor ADR-0022 sets and ADR-0042 cites) — a consumer copying it verbatim would not compile; replaced with a floor-safe derivation, verified reproducible and collision-free over the illustrated range. Also fixes an article-agreement slip in the package README. English and French user guides and the Any.UseSeed XML example move in step. Refs: #310 --- JustDummies/Any.cs | 4 +++- JustDummies/README.nuget.md | 2 +- .../for-users/ArbitraryTestValues.en.md | 16 +++++++++------- .../for-users/ArbitraryTestValues.fr.md | 16 +++++++++------- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/JustDummies/Any.cs b/JustDummies/Any.cs index 086d013e..b8594bbf 100644 --- a/JustDummies/Any.cs +++ b/JustDummies/Any.cs @@ -433,8 +433,10 @@ public static AnyContext WithSeed(int seed) { /// /// /// + /// const int runSeed = 20240501; // recorded by hand: keep it to replay, change it to explore /// Parallel.For(0, 64, index => { - /// using (Any.UseSeed(HashCode.Combine(runSeed, index))) { + /// // a distinct, deterministic sub-seed per work item, floor-safe on netstandard2.0 (no System.HashCode) + /// using (Any.UseSeed(unchecked(runSeed * 397 ^ index))) { /// sut.Handle(Any.String().NonEmpty().Generate()); /// } /// }); diff --git a/JustDummies/README.nuget.md b/JustDummies/README.nuget.md index a6f48408..e8f260ce 100644 --- a/JustDummies/README.nuget.md +++ b/JustDummies/README.nuget.md @@ -121,7 +121,7 @@ matter — and that is the point. so a run pinned from outside the test body never points at a call the test does not contain. Drawing from several threads at once is safe — values stay arbitrary and well-formed — but concurrent draws interleave, so a seed replays a run only while its - draws are taken one at a time; open a `Any.UseSeed(...)` scope per unit of work to + draws are taken one at a time; open an `Any.UseSeed(...)` scope per unit of work to keep a parallel run reproducible. ## Example diff --git a/doc/handwritten/for-users/ArbitraryTestValues.en.md b/doc/handwritten/for-users/ArbitraryTestValues.en.md index cb0d7e85..3fba3155 100644 --- a/doc/handwritten/for-users/ArbitraryTestValues.en.md +++ b/doc/handwritten/for-users/ArbitraryTestValues.en.md @@ -151,16 +151,18 @@ Following the test's execution flow means the source also reaches the threads th What parallelism costs is the *replay*. Concurrent draws interleave, so a seed no longer pins which value lands in which call, and a run that parallelises does not reproduce from its seed alone. If you only need dummies, nothing to do. If you need the run to replay, give each unit of work its own scope and derive its seed from the run's: ```csharp -Any.Reproducibly(() => { - Parallel.For(0, 64, index => { - using (Any.UseSeed(HashCode.Combine(runSeed, index))) { - sut.Handle(Any.String().NonEmpty().Generate()); - } - }); +// runSeed is the number you record by hand: keep it to replay a run, change it to explore new ones. +const int runSeed = 20240501; + +Parallel.For(0, 64, index => { + // A distinct, deterministic sub-seed per work item. Floor-safe: System.HashCode does not exist on netstandard2.0. + using (Any.UseSeed(unchecked(runSeed * 397 ^ index))) { + sut.Handle(Any.String().NonEmpty().Generate()); + } }); ``` -Each iteration then owns its own sequence, and the whole run replays for a given `runSeed`. +Each iteration owns its own sequence, keyed on its index, so the whole run replays for a given `runSeed`. There is no outer `Any.Reproducibly` here: every draw happens inside a per-item scope, so a seed reported by an enclosing runner would replay nothing — the recorded `runSeed` is what you keep. ## Review checklist diff --git a/doc/handwritten/for-users/ArbitraryTestValues.fr.md b/doc/handwritten/for-users/ArbitraryTestValues.fr.md index f7870665..5635d215 100644 --- a/doc/handwritten/for-users/ArbitraryTestValues.fr.md +++ b/doc/handwritten/for-users/ArbitraryTestValues.fr.md @@ -151,16 +151,18 @@ Suivre le flux d’exécution du test signifie aussi que la source atteint les t Ce que le parallélisme coûte, c’est le *rejeu*. Les tirages concurrents s’entrelacent : une graine ne fixe plus quelle valeur atterrit dans quel appel, et une exécution parallélisée ne se reproduit pas à partir de sa seule graine. Si vous n’avez besoin que de dummies, il n’y a rien à faire. Si vous avez besoin que l’exécution rejoue, donnez à chaque unité de travail sa propre portée et dérivez sa graine de celle de l’exécution : ```csharp -Any.Reproducibly(() => { - Parallel.For(0, 64, index => { - using (Any.UseSeed(HashCode.Combine(runSeed, index))) { - sut.Handle(Any.String().NonEmpty().Generate()); - } - }); +// runSeed est le nombre que vous consignez à la main : gardez-le pour rejouer, changez-le pour explorer. +const int runSeed = 20240501; + +Parallel.For(0, 64, index => { + // Une sous-graine déterministe et distincte par unité de travail. Compatible plancher : System.HashCode n'existe pas sur netstandard2.0. + using (Any.UseSeed(unchecked(runSeed * 397 ^ index))) { + sut.Handle(Any.String().NonEmpty().Generate()); + } }); ``` -Chaque itération possède alors sa propre séquence, et l’exécution entière rejoue pour un `runSeed` donné. +Chaque itération possède sa propre séquence, indexée sur son rang, donc l’exécution entière rejoue pour un `runSeed` donné. Il n’y a pas de `Any.Reproducibly` englobant ici : chaque tirage se fait dans une portée par unité de travail, si bien qu’une graine rapportée par un runner englobant ne rejouerait rien — c’est le `runSeed` consigné que vous gardez. ## Checklist de revue From ab129f26c91f32ff716c240be0668b49f184ded5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 09:17:43 +0000 Subject: [PATCH 7/8] docs(justdummies): scope IAny.Generate concurrency to its own draw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review flagged the unconditional "safe to call concurrently" remark on the public IAny.Generate contract as an over-promise: the interface is user-implementable, and As/Combine/OrNull/ContainingAny run caller-supplied factories, composers, element generators and comparers without synchronization. A stateful user factory drawn concurrently races in the caller's code even though the random source stays healthy. Narrow the guarantee to what the library actually delivers — its own serialized draw, which cannot corrupt the source or breach the declared constraints — and state that supplied delegates and foreign IAny implementations must be concurrency-safe themselves. Refs: #310 --- JustDummies/IAny.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/JustDummies/IAny.cs b/JustDummies/IAny.cs index a105fab5..79f83440 100644 --- a/JustDummies/IAny.cs +++ b/JustDummies/IAny.cs @@ -32,10 +32,15 @@ public interface IAny { /// Produces one arbitrary value satisfying every constraint declared on this generator. /// /// - /// Safe to call concurrently: draws on a random context are serialized, so no amount of parallelism can - /// degrade the values. Reproducibility is what parallelism costs — concurrent draws interleave, so a seed - /// replays a run only while its draws are taken one at a time. To keep a parallel run reproducible, open a - /// scope per unit of work with and derive its seed from the run's own. + /// A built-in generator is safe to call concurrently: its draw on a random context is serialized, so no + /// amount of parallelism can corrupt the source or produce a value outside the declared constraints. That + /// covers the library's own draw only — a call may also run caller-supplied code (a factory passed to + /// , a composer passed to + /// , an element generator, a comparer) or a foreign + /// implementation, whose thread-safety is the caller's own responsibility. + /// Reproducibility is the separate cost of parallelism: concurrent draws interleave, so a seed replays a + /// run only while its draws are taken one at a time. To keep a parallel run reproducible, open a scope per + /// unit of work with and derive its seed from the run's own. /// /// A value that satisfies the declared constraints. /// From 20df36b8ada29b810a66fa6f12565e42c5bfc140 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 09:38:25 +0000 Subject: [PATCH 8/8] test(justdummies): cover the composed draw paths under concurrency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #310 regression suite pinned only the scalar draw paths, yet the fix (a lock at the SeededRandom choke point) protects every path. Extend it to the paths with the most moving parts, each routing its draws through that choke point: As and Combine (derived generators wrapping a draw), ListOf (the collection fill loop), SetOf (the bounded distinct dedup-draw), and StringMatching (the regex context). Each was confirmed red before the fix by stripping the lock from a copy and watching it collapse — 5/5 lock-stripped runs. Deliberately omitted, with the reasoning recorded in the test file: OrNull's null/value coin and a bare Any.Double(). Corruption is provoked only by NextBytes-heavy draws; a lone Next(2)/NextDouble() never builds enough contention to corrupt within a bounded run, so a lock-stripped mutant leaves them green (measured: 5/5 missed). Shipping a test that cannot fail on the broken code would only manufacture false confidence. Refs: #310 --- JustDummies.UnitTests/ConcurrentDrawTests.cs | 94 ++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/JustDummies.UnitTests/ConcurrentDrawTests.cs b/JustDummies.UnitTests/ConcurrentDrawTests.cs index ef3a1943..f7249c20 100644 --- a/JustDummies.UnitTests/ConcurrentDrawTests.cs +++ b/JustDummies.UnitTests/ConcurrentDrawTests.cs @@ -1,6 +1,7 @@ #region Usings declarations using System.Collections.Concurrent; +using System.Text.RegularExpressions; using JetBrains.Annotations; @@ -137,4 +138,97 @@ public void ASharedContextSurvivesConcurrentDraws() { .IsStrictlyGreaterThan(1); } + #region Composed draw paths + + // The lock lives at one choke point — SeededRandom — but every generator reaches it through a different path. + // The scalar cases above prove the choke point itself; these prove the paths with the most moving parts still + // route through it: the derived generators (As, Combine) that wrap a draw, the collection engine's fill and + // dedup-draw loops, and the regex context. Each collapses in its own way if the source dies, so each asserts + // the shape of its own non-collapse — and each was confirmed red before the fix by stripping the lock (#310). + // + // Paths whose per-draw work is a single light sample — OrNull's null/value coin, a bare Any.Double() — are + // deliberately absent. Corruption is reliably provoked only by NextBytes-heavy draws (an eight-byte ordinal + // fill, a regex drawing one choice per character); a lone Next(2) or NextDouble() never builds enough + // contention to corrupt the source within a bounded run, so a lock-stripped mutant does not make such a test + // fail. A test that cannot go red on the broken code would only manufacture false confidence, and the choke + // point those paths share is already pinned by the cases here. (Measured: OrNull and Double each missed 5/5 + // lock-stripped runs, while every case below tripped 5/5.) + + [Fact(DisplayName = "Concurrent draws through Combine never collapse either operand.")] + public void ConcurrentCombineDrawsDoNotCollapse() { + List<(int First, int Second)> drawn = []; + + Any.Reproducibly(310, () => drawn = Storm(() => Any.Combine(Any.Int32(), Any.Int32(), (first, second) => (first, second)).Generate())); + + Check.WithCustomMessage($"Combine's first operand collapsed: {MostFrequent(drawn.Select(pair => pair.First))} of {TotalDraws} identical.") + .That(MostFrequent(drawn.Select(pair => pair.First))) + .IsStrictlyLessThan(TotalDraws / 10); + Check.WithCustomMessage($"Combine's second operand collapsed: {MostFrequent(drawn.Select(pair => pair.Second))} of {TotalDraws} identical.") + .That(MostFrequent(drawn.Select(pair => pair.Second))) + .IsStrictlyLessThan(TotalDraws / 10); + } + + [Fact(DisplayName = "Concurrent draws through As keep the underlying draw healthy.")] + public void ConcurrentAsDrawsStayHealthy() { + // A pure projection carries no shared state, so any degeneration here is the library's own serialized draw + // collapsing, not a user-side race — the latter is the caller's responsibility, per the IAny contract. + List drawn = []; + + Any.Reproducibly(310, () => drawn = Storm(() => Any.Int32().As(value => (long)value * 2).Generate())); + + Check.WithCustomMessage($"{MostFrequent(drawn)} of {TotalDraws} As-projected values were identical; the underlying draw collapsed.") + .That(MostFrequent(drawn)) + .IsStrictlyLessThan(TotalDraws / 10); + } + + [Fact(DisplayName = "Concurrent draws through a list generator keep the right size and never collapse the elements.")] + public void ConcurrentListDrawsDoNotCollapse() { + List> drawn = []; + + Any.Reproducibly(310, () => drawn = Storm(() => Any.ListOf(Any.Int32()).WithCount(4).Generate())); + + List elements = drawn.SelectMany(list => list).ToList(); + + Check.WithCustomMessage("A fixed-count list came back the wrong size under concurrency.") + .That(drawn.All(list => list.Count == 4)).IsTrue(); + Check.WithCustomMessage($"{MostFrequent(elements)} of {elements.Count} list elements were identical; the element draw collapsed.") + .That(MostFrequent(elements)) + .IsStrictlyLessThan(elements.Count / 10); + } + + [Fact(DisplayName = "Concurrent draws through a distinct set generator stay valid.")] + public void ConcurrentSetDrawsStayValid() { + // The distinct path runs a bounded dedup-draw against a fresh HashSet per generation — the path most exposed + // to a dead source, which cannot supply the fresh values it needs and fails loudly rather than collapsing. + List> drawn = []; + + Any.Reproducibly(310, () => drawn = Storm(() => Any.SetOf(Any.Int32().Between(0, 100_000)).WithCount(5).Generate())); + + Check.WithCustomMessage("A distinct set came back the wrong size under concurrency.") + .That(drawn.All(set => set.Count == 5)).IsTrue(); + + List elements = drawn.SelectMany(set => set).ToList(); + Check.WithCustomMessage($"{MostFrequent(elements)} of {elements.Count} set elements were identical; the element draw collapsed.") + .That(MostFrequent(elements)) + .IsStrictlyLessThan(elements.Count / 5); + } + + [Fact(DisplayName = "Concurrent draws through a pattern generator match and never collapse.")] + public void ConcurrentPatternDrawsStayValid() { + Regex pattern = new("^[A-Z]{3}-[0-9]{4}$"); + List drawn = []; + + Any.Reproducibly(310, () => drawn = Storm(() => Any.StringMatching("^[A-Z]{3}-[0-9]{4}$").Generate())); + + Check.WithCustomMessage($"A pattern draw did not match under concurrency, e.g. \"{drawn.FirstOrDefault(value => !pattern.IsMatch(value))}\".") + .That(drawn.All(value => pattern.IsMatch(value))).IsTrue(); + // A dead regex context still matches (it picks the first choice every time — "AAA-0000"), so matching alone + // would not catch the collapse; the non-collapse assertion is what pins it. + Check.WithCustomMessage($"{MostFrequent(drawn)} of {TotalDraws} pattern draws were identical; generation collapsed.") + .That(MostFrequent(drawn)) + .IsStrictlyLessThan(TotalDraws / 10); + } + + #endregion + }