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
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>
264 lines
8.8 KiB
C#
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);
|
|
}
|
|
}
|