namespace ErsatzTV.Application.Streaming;
///
/// The process-wide pool of work-ahead slots shared by every HLS session (ersatztv#536).
///
///
///
/// workAheadSegmenterLimit is a resource guarantee, not a tuning knob: it bounds how many
/// transcodes may run unthrottled (no -readrate) at once, and the QSV hardware-frame pool
/// sizing from ersatztv#529 assumes that bound holds.
///
///
/// Acquisition must therefore be atomic. The previous shape — Volatile.Read(count) < limit
/// in the caller, Interlocked.Increment later inside the transcode — is a check-then-act
/// TOCTOU separated by at least one await (the limit is a DB-backed config read), so N
/// simultaneous tune-ins all observed 0 < limit and all ran unthrottled. Same class as
/// ersatztv#231 / #250.
///
///
public sealed class WorkAheadSlots
{
private int _count;
private int _unbalancedReleases;
///
/// Gets the number of slots currently held. For diagnostics and tests only — never branch on
/// this to decide whether to work ahead; that is exactly the race exists to close.
///
public int Count => Volatile.Read(ref _count);
///
/// Atomically claims one slot if fewer than are held.
///
/// true when a slot was claimed; the caller then owns it and MUST
/// it exactly once.
public bool TryAcquire(int limit)
{
while (true)
{
int current = Volatile.Read(ref _count);
if (current >= limit)
{
return false;
}
// only the thread whose compare-exchange observes the value it read wins the slot, so
// the count can never transiently exceed the limit and two racers can never both claim
if (Interlocked.CompareExchange(ref _count, current + 1, current) == current)
{
return true;
}
}
}
///
/// Gets the number of releases that were not matched by a successful acquire. Non-zero always
/// means the ownership contract was broken somewhere (no false positives), so the value is a
/// reliable "something is wrong" signal — but it can UNDER-count and zero does not prove
/// correctness. It only increments when a release finds the pool already empty; an over-release
/// that happens while the count is positive — e.g. one cancelling out a coexisting leak —
/// decrements a real-looking slot and is never recorded, so the two bugs hide each other. This
/// is inherent to a single counter; exact accounting would need per-owner tokens (ersatztv#539 §2).
///
public int UnbalancedReleases => Volatile.Read(ref _unbalancedReleases);
///
/// Returns a slot claimed by . Only ever called by the owner of that slot.
///
///
/// true when a held slot was returned; false when the pool was already empty, i.e.
/// the release was unbalanced (also counted in ). Callers should
/// log the false case: it is the only in-band signal that the budget contract was broken.
///
///
/// Ownership is a discipline, not a token — the same call-once contract as `EntityLocker` (#231).
/// The one failure this defends against is an unbalanced release inflating the budget: this pool
/// is process-wide and lives for the life of the app, so a leaked slot would silently and
/// permanently admit one extra unthrottled transcode, re-opening the #529 QSV pool exhaustion.
/// It clamps at zero rather than throwing — the single caller releases from a `finally`, where a
/// throw would swallow the real exception. Unlike a decrement-first-then-clamp shape, this never
/// publishes a negative count even transiently, so a concurrent can
/// never read the pool as having phantom room and over-admit (ersatztv#539 §1); and it records
/// the unbalanced release synchronously here, rather than blaming a later, innocent release.
///
public bool Release()
{
while (true)
{
int current = Volatile.Read(ref _count);
if (current <= 0)
{
Interlocked.Increment(ref _unbalancedReleases);
return false;
}
if (Interlocked.CompareExchange(ref _count, current - 1, current) == current)
{
return true;
}
}
}
}