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
62 changes: 62 additions & 0 deletions JustDummies.UnitTests/AmbientSeedScopeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,68 @@ public void DisposingTwiceIsHarmless() {
Check.ThatCode(() => scope.Dispose()).DoesNotThrow();
}

[Fact(DisplayName = "Disposing an outer scope out of order leaves the still-open inner scope's seed pinned.")]
public void OutOfOrderDisposalKeepsTheInnerScopePinned() {
// Reference: what seed 2 yields as the sole, top-of-stack scope.
(int, string) reference;
using (Any.UseSeed(2)) { reference = Batch(); }

// Open two scopes, then dispose the OUTER first — the inner (seed 2) is still open, so the ambient
// context must still be pinned to seed 2. A blind restore-the-previous unpins it instead, leaving the
// still-open inner scope drawing from a fresh unseeded generator.
IDisposable outer = Any.UseSeed(1);
IDisposable inner = Any.UseSeed(2);
outer.Dispose();
(int, string) whileInnerStillOpen = Batch();
inner.Dispose();

Check.That(whileInnerStillOpen).IsEqualTo(reference);
}

[Fact(DisplayName = "Out-of-order disposal does not leak a pinned seed to what runs next.")]
public void OutOfOrderDisposalDoesNotLeakASeed() {
// Reference: seed 1's sequence, to prove it is NOT what a later, unseeded draw replays.
(int, string) seedOneSequence;
using (Any.UseSeed(1)) { seedOneSequence = Batch(); }

// Dispose the outer first, then the inner: a blind restore-the-previous now reinstates seed 1's frame,
// stranding it as the ambient context for whatever runs next.
IDisposable outer = Any.UseSeed(1);
IDisposable inner = Any.UseSeed(2);
outer.Dispose();
inner.Dispose();

// Both scopes are closed, so the ambient context must be unseeded again — not pinned to seed 1.
(int, string) afterAllDisposed = Batch();

Check.That(afterAllDisposed).IsNotEqualTo(seedOneSequence);
}

[Fact(DisplayName = "Disposing a middle scope out of order leaves the top scope and its live ancestors intact.")]
public void OutOfOrderMiddleDisposalPreservesTheStack() {
(int, string) seedThree;
using (Any.UseSeed(3)) { seedThree = Batch(); }
(int, string) seedOne;
using (Any.UseSeed(1)) { seedOne = Batch(); }

IDisposable bottom = Any.UseSeed(1);
IDisposable middle = Any.UseSeed(2);
IDisposable top = Any.UseSeed(3);

// Dispose the middle scope early: the top (seed 3) is untouched and stays pinned.
middle.Dispose();
(int, string) topStillPinned = Batch();

// Now the top goes: it must skip the already-disposed middle and land on the bottom (seed 1).
top.Dispose();
(int, string) afterTopDisposed = Batch();

bottom.Dispose();

Check.That(topStillPinned).IsEqualTo(seedThree);
Check.That(afterTopDisposed).IsEqualTo(seedOne);
}

[Fact(DisplayName = "The scope does not leak across parallel execution contexts.")]
public async Task TheScopeDoesNotLeakAcrossExecutionContexts() {
(int, string) inside;
Expand Down
55 changes: 41 additions & 14 deletions JustDummies/RandomSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
/// time — which is what lets a recipe built outside an <c>Any.Reproducibly(...)</c> scope generate
/// deterministically inside one.
/// </summary>
internal abstract class RandomSource {

Check warning on line 10 in JustDummies/RandomSource.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Convert this 'abstract' class to an interface.

Check warning on line 10 in JustDummies/RandomSource.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Convert this 'abstract' class to an interface.

/// <summary>The seeded generator to draw from right now. Every draw goes through it, serialized on its own lock.</summary>
internal abstract SeededRandom Current { get; }
Expand Down Expand Up @@ -79,7 +79,7 @@

internal SeededRandom(int seed) {
Seed = seed;
_random = new Random(seed);

Check warning on line 82 in JustDummies/RandomSource.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Use a cryptographically strong random number generator.

Check warning on line 82 in JustDummies/RandomSource.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Use a cryptographically strong random number generator.
}

internal int Seed { get; }
Expand Down Expand Up @@ -133,10 +133,10 @@
}

internal static IDisposable UseSeed(int seed, string? replaySnippet) {
AmbientState? previous = State.Value;
State.Value = new AmbientState(new SeededRandom(seed), replaySnippet);
AmbientState frame = new(new SeededRandom(seed), replaySnippet, State.Value);
State.Value = frame;

return new SeedScope(previous);
return new SeedScope(frame);
}

#endregion
Expand All @@ -147,7 +147,7 @@
get {
AmbientState? current = State.Value;
if (current is null) {
current = new AmbientState(new SeededRandom(NewSeed()), null);
current = new AmbientState(new SeededRandom(NewSeed()), null, null);
State.Value = current;
}

Expand Down Expand Up @@ -175,35 +175,62 @@

#region Nested types

/// <summary>The ambient state a seed scope installs: the seeded generator, and how to replay the run that uses it.</summary>
/// <summary>
/// One frame of the ambient seed stack a scope installs: the seeded generator, how to replay the run that uses
/// it, and the frame it was pushed on top of. The frames form a linked stack (each points at its
/// <see cref="Parent" />) so a scope disposed out of order can be removed without stranding the ones still open
/// — see <see cref="SeedScope" />. <see cref="Disposed" /> tombstones a frame whose scope has closed but which is
/// not yet the top of the stack, so the top's later disposal can skip past it.
/// </summary>
private sealed class AmbientState {

internal AmbientState(SeededRandom random, string? replaySnippet) {
internal AmbientState(SeededRandom random, string? replaySnippet, AmbientState? parent) {
if (random is null) { throw new ArgumentNullException(nameof(random)); }

Random = random;
ReplaySnippet = replaySnippet;
Parent = parent;
}

internal SeededRandom Random { get; }
internal string? ReplaySnippet { get; }
internal SeededRandom Random { get; }
internal string? ReplaySnippet { get; }

Check warning on line 196 in JustDummies/RandomSource.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Rename this property to not shadow the outer class' member with the same name.

Check warning on line 196 in JustDummies/RandomSource.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Rename this property to not shadow the outer class' member with the same name.

Check warning on line 196 in JustDummies/RandomSource.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Rename this property to not shadow the outer class' member with the same name.
internal AmbientState? Parent { get; }
internal bool Disposed { get; set; }

}

/// <summary>
/// The handle returned by <see cref="UseSeed(int, string?)" />. Disposal is <b>order-independent</b>: it
/// tombstones its own frame, and only the frame that is currently the top of the stack rewrites the ambient
/// slot — walking past any tombstoned ancestors to the nearest frame whose scope is still open (or to
/// <c>null</c> when none is). So the documented "scopes nest, disposing restores whatever was pinned before"
/// holds even when scopes are disposed out of order: an outer scope closed early strands nothing, and no order
/// leaves a dead seed pinned for whatever runs next. Disposing twice is a no-op.
/// </summary>
private sealed class SeedScope : IDisposable {

private readonly AmbientState? _previous;
private bool _disposed;
private readonly AmbientState _frame;
private bool _disposed;

internal SeedScope(AmbientState? previous) {
_previous = previous;
internal SeedScope(AmbientState frame) {
if (frame is null) { throw new ArgumentNullException(nameof(frame)); }

_frame = frame;
}

public void Dispose() {
if (_disposed) { return; }

_disposed = true;
State.Value = _previous;
_disposed = true;
_frame.Disposed = true;

// Only the current top owns the ambient slot; an out-of-order dispose of an inner frame just tombstones
// it and lets the top's own dispose skip it later.
if (ReferenceEquals(State.Value, _frame)) {
AmbientState? restored = _frame.Parent;
while (restored is { Disposed: true }) { restored = restored.Parent; }
State.Value = restored;
}
}

}
Expand Down Expand Up @@ -281,7 +308,7 @@
ulong rangeSize = (ulong)(maxInclusive - minInclusive) + 1UL;
ulong draw = random.NextUInt64();

// rangeSize is 0 only when the range spans the full ulong width, which int-derived bounds never do;

Check warning on line 311 in JustDummies/RandomSource.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Remove this commented out code.

Check warning on line 311 in JustDummies/RandomSource.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Remove this commented out code.

Check warning on line 311 in JustDummies/RandomSource.cs

View workflow job for this annotation

GitHub Actions / SonarQube Cloud analysis

Remove this commented out code.
// guard anyway so the helper stays correct if reused with wider bounds.
if (rangeSize == 0UL) { return unchecked((long)draw); }

Expand Down