using ErsatzTV.Application.Streaming; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Core.Tests.Streaming; // Verifies the atomic-claim contract of the work-ahead slot pool (ersatztv#536): at most // `limit` sessions may hold a slot at once, no matter how many race for one simultaneously. // // NEGATIVE CONTROL (per the ersatztv#231/#250 lesson — a one-shot Barrier + Task.WhenAll does NOT // catch this class on this hardware): to prove these tests are non-vacuous, temporarily replace the // compare-exchange in WorkAheadSlots.TryAcquire with the check-then-act shape this issue fixed — // // int current = Volatile.Read(ref _count); // if (current >= limit) return false; // Interlocked.Increment(ref _count); // return true; // // — and TryAcquire_ParallelCallers_NeverExceedsLimit must FAIL (badRounds > 0). Do NOT "break" it by // stubbing `if (true)`: that leaves values assigned-but-never-read, and CS0219 under // warnings-as-errors fails the build silently, so `dotnet test --no-build` then runs the STALE // (fixed) dll and the control falsely passes. Always grep the build output for `error CS` first. [TestFixture] public class WorkAheadSlotsTests { [Test] public void TryAcquire_BelowLimit_Succeeds() { var slots = new WorkAheadSlots(); slots.TryAcquire(2).ShouldBeTrue(); slots.TryAcquire(2).ShouldBeTrue(); slots.TryAcquire(2).ShouldBeFalse(); slots.Count.ShouldBe(2); } [Test] public void TryAcquire_DoesNotConsumeASlotWhenItFails() { var slots = new WorkAheadSlots(); slots.TryAcquire(1).ShouldBeTrue(); slots.TryAcquire(1).ShouldBeFalse(); slots.Count.ShouldBe(1); // the failed attempt must not have leaked a slot: releasing the one real holder frees the pool slots.Release(); slots.Count.ShouldBe(0); slots.TryAcquire(1).ShouldBeTrue(); } [Test] public void TryAcquire_WithNonPositiveLimit_NeverSucceeds() { var slots = new WorkAheadSlots(); slots.TryAcquire(0).ShouldBeFalse(); slots.TryAcquire(-1).ShouldBeFalse(); slots.Count.ShouldBe(0); } // The pool is process-wide and never recreated, so a negative count would not self-heal: it would // permanently admit more than `limit` unthrottled transcodes, with nothing in the logs to find it by. [Test] public void Release_WithoutAcquire_ClampsAtZeroAndIsRecorded() { var slots = new WorkAheadSlots(); slots.Release(); slots.Count.ShouldBe(0); slots.UnbalancedReleases.ShouldBe(1); // the budget is intact: a limit of 1 still admits exactly one holder, not two slots.TryAcquire(1).ShouldBeTrue(); slots.TryAcquire(1).ShouldBeFalse(); } [Test] public void UnbalancedReleases_IsZeroUnderCorrectUse() { var slots = new WorkAheadSlots(); slots.TryAcquire(1).ShouldBeTrue(); slots.Release(); slots.UnbalancedReleases.ShouldBe(0); } // Hammers the acquire race over many rounds rather than a single simultaneous burst: the // read->increment window is far too narrow to collide reliably when threads release once. [Test] [TestCase(1)] [TestCase(2)] [TestCase(3)] public void TryAcquire_ParallelCallers_NeverExceedsLimit(int limit) { const int threads = 8; const int rounds = 20_000; var slots = new WorkAheadSlots(); var winners = 0; var badRounds = 0; using var startRound = new Barrier(threads); using var endRound = new Barrier( threads, _ => { int held = Volatile.Read(ref winners); if (held > limit || held != slots.Count) { Interlocked.Increment(ref badRounds); } // reset for the next round: release every slot claimed this round for (var i = 0; i < held; i++) { slots.Release(); } Volatile.Write(ref winners, 0); }); var workers = new Thread[threads]; for (var t = 0; t < threads; t++) { workers[t] = new Thread(() => { for (var r = 0; r < rounds; r++) { startRound.SignalAndWait(); if (slots.TryAcquire(limit)) { Interlocked.Increment(ref winners); } endRound.SignalAndWait(); } }); workers[t].Start(); } foreach (Thread worker in workers) { worker.Join(); } badRounds.ShouldBe(0); slots.Count.ShouldBe(0); slots.UnbalancedReleases.ShouldBe(0); } // §1 (ersatztv#539): an unbalanced Release() must never publish a NEGATIVE count, even // transiently. The pre-#539 shape decremented FIRST (0 -> -1) and clamped afterwards, so a // concurrent TryAcquire(limit) could read the -1, see "-1 < limit" as phantom room, and admit a // holder the budget doesn't have (the second acquirer then reads 0 and admits another). Because // TryAcquire is the sole, CAS-guarded increment path, a count that is provably never negative is // exactly what forecloses that over-admit. Here one thread hammers unbalanced releases on an // empty pool while readers sample the count; none may ever observe a value below zero. // // NEGATIVE CONTROL (per the class-level ersatztv#231/#250 lesson) — revert Release() to its // pre-#539 decrement-first body to prove this test is non-vacuous: // // if (Interlocked.Decrement(ref _count) >= 0) return true; // Interlocked.Increment(ref _unbalancedReleases); // while (true) { int c = Volatile.Read(ref _count); // if (c >= 0 || Interlocked.CompareExchange(ref _count, 0, c) == c) return false; } // // — and this test must FAIL (sawNegative > 0): the readers catch the transient -1. Do NOT stub // `if (true)`: that leaves values assigned-but-never-read, and CS0219 under warnings-as-errors // fails the build silently so `dotnet test --no-build` runs the STALE dll and the control falsely // passes. Always grep the build output for `error CS` first. [Test] public void Release_Unbalanced_NeverPublishesNegativeCount() { const int releases = 2_000_000; const int readers = 4; var slots = new WorkAheadSlots(); var sawNegative = 0; var done = false; var readerThreads = new Thread[readers]; for (var i = 0; i < readers; i++) { readerThreads[i] = new Thread(() => { while (!Volatile.Read(ref done)) { if (slots.Count < 0) { Interlocked.Increment(ref sawNegative); } } }); readerThreads[i].Start(); } // every release finds the pool empty, so every one is unbalanced for (var r = 0; r < releases; r++) { slots.Release(); } Volatile.Write(ref done, true); foreach (Thread reader in readerThreads) { reader.Join(); } sawNegative.ShouldBe(0); slots.Count.ShouldBe(0); slots.UnbalancedReleases.ShouldBe(releases); } // The release path is the fiddly half: a slot freed by its owner must become available again, // and a burst of acquire/release cycles must not drift the count in either direction. [Test] public void AcquireAndRelease_UnderContention_LeavesNoLeakedOrDoubleFreedSlots() { const int threads = 8; const int rounds = 20_000; const int limit = 3; var slots = new WorkAheadSlots(); var overLimit = 0; var live = 0; var workers = new Thread[threads]; for (var t = 0; t < threads; t++) { workers[t] = new Thread(() => { for (var r = 0; r < rounds; r++) { if (!slots.TryAcquire(limit)) { continue; } if (Interlocked.Increment(ref live) > limit) { Interlocked.Increment(ref overLimit); } Interlocked.Decrement(ref live); slots.Release(); } }); workers[t].Start(); } foreach (Thread worker in workers) { worker.Join(); } overLimit.ShouldBe(0); slots.Count.ShouldBe(0); slots.UnbalancedReleases.ShouldBe(0); } }