Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions Dummies.UnitTests/AnySetTypeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,39 @@ public void GuidSets() {
Check.ThatCode(() => Any.Guid().OneOf(first).Except(first)).Throws<ConflictingAnyConstraintException>();
}

[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<Guid> 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<OrderStatus> seen = new();
Expand Down
30 changes: 23 additions & 7 deletions Dummies/AnyGuid.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@
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
Expand All @@ -32,6 +42,7 @@
private readonly string? _allowedConstraint;
private readonly List<Guid>? _effectiveAllowed;
private readonly IReadOnlyList<Guid> _excluded;
private readonly HashSet<Guid> _excludedSet;
private readonly Guid? _pinned;
private readonly string? _pinnedConstraint;
private readonly RandomSource _source;
Expand All @@ -46,8 +57,10 @@
_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<Guid>(excluded);
_effectiveAllowed = allowed?.Where(value => !_excludedSet.Contains(value)).ToList();
}

RandomSource? IHasRandomSource.Source => _source;
Expand Down Expand Up @@ -121,11 +134,14 @@
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;
Expand All @@ -138,7 +154,7 @@
return Validated(new AnyGuid(_source, _pinned, _pinnedConstraint, _allowed, _allowedConstraint, excluded), applying);
}

private AnyGuid Validated(AnyGuid candidate, string applying) {

Check warning on line 157 in Dummies/AnyGuid.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Make 'Validated' a static method.

Check warning on line 157 in Dummies/AnyGuid.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Make 'Validated' a static method.

Check warning on line 157 in Dummies/AnyGuid.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Make 'Validated' a static method.

Check warning on line 157 in Dummies/AnyGuid.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Make 'Validated' a static method.
if (candidate._pinned is Guid pinned) {
if (candidate._excluded.Contains(pinned)) {
throw new ConflictingAnyConstraintException($"Cannot apply {applying} because {candidate._pinnedConstraint} already pins the value to {V(pinned)}, which the exclusions forbid.");
Expand Down