fix(tests): replace sleep-race concurrency proofs in RetailDatLoaderTests
Two tests proved "these two unrelated DAT reads ran concurrently" by racing a fixed Thread.Sleep(40) window against .NET thread-pool scheduling latency for a second Task.Run. Under the CPU contention of a full `dotnet test AcDream.slnx` run (all 9 test projects' VSTest hosts launch concurrently) plus a busy machine, thread-pool injection can occasionally miss the window, making MaxConcurrentReads read 1 instead of 2 and failing the assertion with no underlying code defect. RetailAnimationLoader and RetailPhysicsScriptLoader both coalesce same-key reads correctly via ConcurrentDictionary<K, Lazy<T>>.GetOrAdd, which is atomic and timing-independent (verified by reading, not just running) - only the test's method of proving cross-key overlap was timing-fragile. DecodedTextureCacheTests already uses the correct deterministic-gate pattern; this brings RetailDatLoaderTests in line with it via a Barrier-backed rendezvous instead of a sleep race. Filed as #248 (docs/ISSUES.md) with the full attempt matrix: could not catch the originally-reported AcDream.Content.Tests failure in the act despite ~72 Content.Tests executions across four contention strategies over ~30 full-suite-equivalent runs, though the general mechanism reproduced 3x in AcDream.App.Tests's already-known zero-allocation flake class (left untouched, out of scope here). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
3b5e099241
commit
dc0468cc2b
2 changed files with 50 additions and 7 deletions
|
|
@ -56,6 +56,7 @@ public sealed class RetailDatLoaderTests
|
|||
private int _activeReads;
|
||||
private int _maxConcurrentReads;
|
||||
private int _totalReads;
|
||||
private Barrier? _concurrencyGate;
|
||||
|
||||
public DatDatabase Db => null!;
|
||||
public int Iteration => 0;
|
||||
|
|
@ -63,6 +64,23 @@ public sealed class RetailDatLoaderTests
|
|||
public int MaxConcurrentReads => Volatile.Read(ref _maxConcurrentReads);
|
||||
public int TotalReads => Volatile.Read(ref _totalReads);
|
||||
public void Add(uint id, byte[] bytes) => _entries[id] = bytes;
|
||||
|
||||
/// <summary>
|
||||
/// Forces exactly <paramref name="participantCount"/> concurrent
|
||||
/// <see cref="TryGetFileBytes(uint, out byte[])"/> callers to
|
||||
/// rendezvous before any of them is allowed to return, proving true
|
||||
/// overlap deterministically. Earlier versions of these tests proved
|
||||
/// overlap by racing a fixed <see cref="ReadDelayMilliseconds"/>
|
||||
/// sleep against thread-pool scheduling latency; under system-wide
|
||||
/// CPU contention (e.g. `dotnet test` running many test projects
|
||||
/// concurrently) that race could lose, producing an intermittent
|
||||
/// failure with no code defect (issue #248). A barrier removes the
|
||||
/// wall-clock dependency entirely — the assertion holds regardless
|
||||
/// of how slow scheduling is, as long as both callers eventually run.
|
||||
/// </summary>
|
||||
public void ArmConcurrencyGate(int participantCount) =>
|
||||
_concurrencyGate = new Barrier(participantCount);
|
||||
|
||||
public IEnumerable<uint> GetAllIdsOfType<T>() where T : IDBObj => _entries.Keys;
|
||||
public bool TryGet<T>(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj
|
||||
{
|
||||
|
|
@ -82,6 +100,7 @@ public sealed class RetailDatLoaderTests
|
|||
&& Interlocked.CompareExchange(ref _maxConcurrentReads, active, observed) != observed);
|
||||
try
|
||||
{
|
||||
_concurrencyGate?.SignalAndWait();
|
||||
if (ReadDelayMilliseconds > 0)
|
||||
Thread.Sleep(ReadDelayMilliseconds);
|
||||
return _entries.TryGetValue(fileId, out value);
|
||||
|
|
@ -293,7 +312,7 @@ public sealed class RetailDatLoaderTests
|
|||
{
|
||||
const uint firstDid = 0x03010024u;
|
||||
const uint secondDid = 0x03010025u;
|
||||
var portal = new RawDatabase { ReadDelayMilliseconds = 40 };
|
||||
var portal = new RawDatabase();
|
||||
portal.Add(firstDid, AnimationBytes(firstDid));
|
||||
portal.Add(secondDid, AnimationBytes(secondDid));
|
||||
var loader = new RetailAnimationLoader(portal);
|
||||
|
|
@ -304,11 +323,15 @@ public sealed class RetailDatLoaderTests
|
|||
Assert.Same(firstPair[0], firstPair[1]);
|
||||
Assert.Equal(1, portal.TotalReads);
|
||||
|
||||
await InParallel(
|
||||
() => loader.LoadAnimation(secondDid),
|
||||
() => loader.LoadAnimation(0x03010026u));
|
||||
// secondDid and 0x03010026u are unrelated keys, so the loader must
|
||||
// not serialize their reads against each other. Prove that with a
|
||||
// rendezvous gate rather than a sleep race (see ArmConcurrencyGate).
|
||||
portal.ArmConcurrencyGate(2);
|
||||
await Task.WhenAll(
|
||||
Task.Run(() => loader.LoadAnimation(secondDid)),
|
||||
Task.Run(() => loader.LoadAnimation(0x03010026u)));
|
||||
|
||||
Assert.True(portal.MaxConcurrentReads >= 2);
|
||||
Assert.Equal(2, portal.MaxConcurrentReads);
|
||||
Assert.Equal(3, portal.TotalReads);
|
||||
}
|
||||
|
||||
|
|
@ -317,13 +340,17 @@ public sealed class RetailDatLoaderTests
|
|||
{
|
||||
const uint firstDid = 0x33010010u;
|
||||
const uint secondDid = 0x33010011u;
|
||||
var portal = new RawDatabase { ReadDelayMilliseconds = 40 };
|
||||
var portal = new RawDatabase();
|
||||
byte[] first = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
|
||||
byte[] second = ProjectileVfxDatFixtures.OrdinaryPhysicsScript.ToArray();
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(first, firstDid);
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(second, secondDid);
|
||||
portal.Add(firstDid, first);
|
||||
portal.Add(secondDid, second);
|
||||
// firstDid and secondDid are unrelated keys; prove the loader runs
|
||||
// their reads concurrently with a rendezvous gate rather than a
|
||||
// sleep race (see ArmConcurrencyGate).
|
||||
portal.ArmConcurrencyGate(2);
|
||||
var loader = new RetailPhysicsScriptLoader(portal);
|
||||
|
||||
await InParallel(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue