From 6d6850e34eb7554ab55485796bffd0b2f5f84ab9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 16:19:59 +0000 Subject: [PATCH] fix(dummies): stop the Guid exclusion walk from cycling forever AnyGuid.Generate() drew 16 random bytes and, on colliding with an excluded identifier, incremented only the last byte until it found an allowed value. When every one of the 256 last-byte variants of the drawn 15-byte prefix was excluded, that byte wrapped 255 -> 0 and the loop cycled forever -- an unbounded hang reachable with a valid, seed-derived exclusion set, contradicting the library's bounded-work contract. Propagate the carry across all 16 bytes (a new Increment helper) so the walk is the full-width successor of the drawn bytes and leaves a fully excluded prefix instead of cycling inside it -- the exclusion set can never fill the 128-bit space, so the walk terminates deterministically. This is the same escape OrdinalIntervalSpec and WideIntervalSpec already use for their 128-bit siblings. Membership is tested against a HashSet materialized once. The common no-collision path is unchanged and still consumes a single draw, so seeded sequences stay reproducible. Add a regression test that excludes all 256 last-byte variants of the drawn prefix and proves the escape terminates and stays reproducible. Refs: #192 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Jktns4fipJLpBepFVqvAAb --- Dummies.UnitTests/AnySetTypeTests.cs | 33 ++++++++++++++++++++++++++++ Dummies/AnyGuid.cs | 30 +++++++++++++++++++------ 2 files changed, 56 insertions(+), 7 deletions(-) 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;