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