Files
ersatztv/ErsatzTV.Core.Tests/Streaming/WorkAheadSlotsTests.cs
T
timothyandClaude Opus 4.8 1ce5743bc1
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 12s
PR Gates / Docs update reminder (pull_request) Successful in 15s
PR Gates / decisions lifecycle (pull_request) Successful in 16s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m31s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 9s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 20m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 20m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(539): WorkAheadSlots.Release clamps before decrementing, reports unbalance in-band
Three Low findings from the #536 clamp re-review, unreachable today (one
guarded release site) but filed against the day a second release site is added.

- §1: Release() now reads the count and CAS-decrements only when current > 0,
  so it never publishes a negative count even transiently. The prior
  decrement-first-then-clamp shape dipped to -1, which a concurrent TryAcquire
  could read as phantom room and over-admit at the limit (re-opening the #529
  QSV pool exhaustion). It records the unbalanced release synchronously on the
  offending thread rather than blaming a later innocent release.
- §3: Release() returns bool; HlsSessionWorker logs a warning on the false
  (unbalanced) return — the one in-band signal a future second release site
  would need. WorkAheadSlots stays logger-free by design.
- §2: UnbalancedReleases doc-comment corrected — it can under-count (an
  over-release while count > 0 cancels a coexisting leak and goes unrecorded);
  no false positives, but zero does not prove correctness.

Test: Release_Unbalanced_NeverPublishesNegativeCount (2M unbalanced releases vs
4 count-samplers) with a documented, verified negative control (reverting to
the decrement-first body makes readers observe the transient -1).

Adds a decisions.md entry (ffmpeg.work-ahead-slot-release-never-negative).

fixes #539

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:59:32 +02:00

264 lines
8.8 KiB
C#

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);
}
}