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>
103 lines
4.7 KiB
C#
103 lines
4.7 KiB
C#
namespace ErsatzTV.Application.Streaming;
|
|
|
|
/// <summary>
|
|
/// The process-wide pool of work-ahead slots shared by every HLS session (ersatztv#536).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <c>workAheadSegmenterLimit</c> is a resource guarantee, not a tuning knob: it bounds how many
|
|
/// transcodes may run unthrottled (no <c>-readrate</c>) at once, and the QSV hardware-frame pool
|
|
/// sizing from ersatztv#529 assumes that bound holds.
|
|
/// </para>
|
|
/// <para>
|
|
/// Acquisition must therefore be atomic. The previous shape — <c>Volatile.Read(count) < limit</c>
|
|
/// in the caller, <c>Interlocked.Increment</c> later inside the transcode — is a check-then-act
|
|
/// TOCTOU separated by at least one <c>await</c> (the limit is a DB-backed config read), so N
|
|
/// simultaneous tune-ins all observed <c>0 < limit</c> and all ran unthrottled. Same class as
|
|
/// ersatztv#231 / #250.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class WorkAheadSlots
|
|
{
|
|
private int _count;
|
|
private int _unbalancedReleases;
|
|
|
|
/// <summary>
|
|
/// 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 <see cref="TryAcquire" /> exists to close.
|
|
/// </summary>
|
|
public int Count => Volatile.Read(ref _count);
|
|
|
|
/// <summary>
|
|
/// Atomically claims one slot if fewer than <paramref name="limit" /> are held.
|
|
/// </summary>
|
|
/// <returns><c>true</c> when a slot was claimed; the caller then owns it and MUST
|
|
/// <see cref="Release" /> it exactly once.</returns>
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
public int UnbalancedReleases => Volatile.Read(ref _unbalancedReleases);
|
|
|
|
/// <summary>
|
|
/// Returns a slot claimed by <see cref="TryAcquire" />. Only ever called by the owner of that slot.
|
|
/// </summary>
|
|
/// <returns>
|
|
/// <c>true</c> when a held slot was returned; <c>false</c> when the pool was already empty, i.e.
|
|
/// the release was unbalanced (also counted in <see cref="UnbalancedReleases" />). Callers should
|
|
/// log the <c>false</c> case: it is the only in-band signal that the budget contract was broken.
|
|
/// </returns>
|
|
/// <remarks>
|
|
/// 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 <see cref="TryAcquire" /> 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.
|
|
/// </remarks>
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|