diff --git a/Dummies.UnitTests/AnySetTypeTests.cs b/Dummies.UnitTests/AnySetTypeTests.cs index a2b6dfac..44370a7c 100644 --- a/Dummies.UnitTests/AnySetTypeTests.cs +++ b/Dummies.UnitTests/AnySetTypeTests.cs @@ -77,6 +77,39 @@ public void GuidSets() { Check.ThatCode(() => Any.Guid().OneOf(first).Except(first)).Throws(); } + [Fact(DisplayName = "Guid: excluding all 256 last-byte variants of the drawn prefix escapes by carry, never hangs, and stays reproducible.")] + public async Task GuidExclusionByteWraparoundTerminates() { + const int seed = 20260718; + + // The first unconstrained draw under this seed fixes the 15-byte prefix the escape starts from; a + // second context with the same seed replays that same first draw, since Except() consumes no randomness. + Guid drawn = Any.WithSeed(seed).Guid().Generate(); + byte[] prefix = drawn.ToByteArray(); + + // Every identifier sharing that prefix and differing only in the last byte — the exact block the former + // last-byte-only walk cycled inside forever. + Guid[] block = new Guid[256]; + for (int last = 0; last < 256; last++) { + byte[] variant = (byte[])prefix.Clone(); + variant[15] = (byte)last; + block[last] = new Guid(variant); + } + + // Generate off-thread and race a deadline: a regression that reintroduces the unbounded loop loses the + // race and fails the test instead of hanging the whole suite. + Task run = Task.Run(() => Any.WithSeed(seed).Guid().Except(block).Generate()); + Task first = await Task.WhenAny(run, Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken)); + Check.That(first == run).IsTrue(); + + Guid escaped = await run; + Check.That(block.Contains(escaped)).IsFalse(); + Check.That(escaped).IsNotEqualTo(drawn); + + // Same seed and same exclusions yield the same escaped identifier. + Guid again = Any.WithSeed(seed).Guid().Except(block).Generate(); + Check.That(again).IsEqualTo(escaped); + } + [Fact(DisplayName = "Enum: unconstrained draws yield only declared members and reach all of them.")] public void EnumDrawsDeclaredMembers() { HashSet seen = new(); diff --git a/Dummies/AnyGuid.cs b/Dummies/AnyGuid.cs index a0a507ba..97c2a8e0 100644 --- a/Dummies/AnyGuid.cs +++ b/Dummies/AnyGuid.cs @@ -24,6 +24,16 @@ private static string Join(Guid[] values) { return string.Join(", ", values.Select(V)); } + // Increments the 16-byte buffer by one with carry, from the last byte down — the full-width successor of + // new Guid(bytes). Incrementing only the last byte (the former behaviour) wraps 255 back to 0 and can cycle + // forever when every last-byte variant of a prefix is excluded; propagating the carry into the higher bytes + // cannot, because it walks distinct values across the whole 128-bit space. + private static void Increment(byte[] bytes) { + for (int i = bytes.Length - 1; i >= 0; i--) { + if (++bytes[i] != 0) { return; } + } + } + #endregion #region Fields declarations @@ -32,6 +42,7 @@ private static string Join(Guid[] values) { private readonly string? _allowedConstraint; private readonly List? _effectiveAllowed; private readonly IReadOnlyList _excluded; + private readonly HashSet _excludedSet; private readonly Guid? _pinned; private readonly string? _pinnedConstraint; private readonly RandomSource _source; @@ -46,8 +57,10 @@ private AnyGuid(RandomSource source, Guid? pinned, string? pinnedConstraint, _allowed = allowed; _allowedConstraint = allowedConstraint; _excluded = excluded; - // Materialized once here — "constrain once, draw many": Generate never refilters the allow-list. - _effectiveAllowed = allowed?.Where(value => !excluded.Contains(value)).ToList(); + // Materialized once here — "constrain once, draw many": Generate never refilters the allow-list, and + // the exclusion walk tests membership against a set rather than rescanning the list on every step. + _excludedSet = new HashSet(excluded); + _effectiveAllowed = allowed?.Where(value => !_excludedSet.Contains(value)).ToList(); } RandomSource? IHasRandomSource.Source => _source; @@ -121,11 +134,14 @@ public Guid Generate() { byte[] bytes = new byte[16]; random.NextBytes(bytes); Guid candidate = new(bytes); - // Colliding with an excluded identifier has probability ~2^-122 per draw; walking the last byte is a - // deterministic, bounded escape — not a retry loop. - while (_excluded.Contains(candidate)) { - bytes[15]++; - candidate = new Guid(bytes); + // Colliding with an excluded identifier has probability |excluded| / 2^128 per draw. On a collision, + // walk the whole 128-bit value with carry — the full-width successor of the drawn bytes — off the + // excluded values. The exclusion set can never fill the 128-bit space, so the walk visits distinct + // values until it lands on an allowed one and terminates: the same deterministic escape + // OrdinalIntervalSpec and WideIntervalSpec already use for their 128-bit siblings. + while (_excludedSet.Contains(candidate)) { + Increment(bytes); + candidate = new(bytes); } return candidate;