fix: cap automatic playout refreshes

This commit is contained in:
2026-07-12 19:28:15 +09:00
parent 6e00955f1b
commit df60e09884
16 changed files with 603 additions and 37 deletions

View File

@@ -12,33 +12,59 @@ public sealed class LegacyRefreshScheduler
{
private readonly TimeSpan _recurringDelay;
private readonly Func<TimeSpan, CancellationToken, Task> _delayAsync;
private readonly int? _maximumRefreshCount;
private TimeSpan _nextDelay;
private int _refreshInFlight;
private int _completedRefreshCount;
private int _maximumCallbackDrained;
public LegacyRefreshScheduler(TimeSpan initialDelay, TimeSpan recurringDelay)
public LegacyRefreshScheduler(
TimeSpan initialDelay,
TimeSpan recurringDelay,
int? maximumRefreshCount = null)
: this(
initialDelay,
recurringDelay,
static (delay, cancellationToken) => Task.Delay(delay, cancellationToken))
static (delay, cancellationToken) => Task.Delay(delay, cancellationToken),
maximumRefreshCount)
{
}
internal LegacyRefreshScheduler(
TimeSpan initialDelay,
TimeSpan recurringDelay,
Func<TimeSpan, CancellationToken, Task> delayAsync)
Func<TimeSpan, CancellationToken, Task> delayAsync,
int? maximumRefreshCount = null)
{
ValidateDelay(initialDelay, nameof(initialDelay));
ValidateDelay(recurringDelay, nameof(recurringDelay));
ArgumentNullException.ThrowIfNull(delayAsync);
if (maximumRefreshCount is < 0)
{
throw new ArgumentOutOfRangeException(
nameof(maximumRefreshCount),
"A legacy refresh limit cannot be negative.");
}
_nextDelay = initialDelay;
_recurringDelay = recurringDelay;
_delayAsync = delayAsync;
_maximumRefreshCount = maximumRefreshCount;
_maximumCallbackDrained = maximumRefreshCount == 0 ? 1 : 0;
}
public bool IsRefreshInFlight => Volatile.Read(ref _refreshInFlight) != 0;
public int CompletedRefreshCount => Volatile.Read(ref _completedRefreshCount);
public int? MaximumRefreshCount => _maximumRefreshCount;
public bool HasReachedMaximum =>
_maximumRefreshCount is { } maximum && CompletedRefreshCount >= maximum;
public bool IsMaximumCallbackDrained =>
Volatile.Read(ref _maximumCallbackDrained) != 0;
/// <summary>
/// Waits for the prior Play callback first, then starts the entire one-shot
/// delay. A false callback result prevents any delay or refresh dispatch.
@@ -49,6 +75,11 @@ public sealed class LegacyRefreshScheduler
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(waitForPlayCompletionAsync);
if (HasReachedMaximum)
{
throw new InvalidOperationException(
"The configured legacy refresh limit has already been reached.");
}
if (!await waitForPlayCompletionAsync(cancellationToken))
{
@@ -66,11 +97,78 @@ public sealed class LegacyRefreshScheduler
/// Executes at most one refresh at a time, even if the scheduler is used
/// incorrectly by more than one caller.
/// </summary>
public async Task<TResult> ExecuteRefreshAsync<TResult>(
internal async Task<TResult> ExecuteRefreshAsync<TResult>(
Func<CancellationToken, Task<TResult>> refreshAsync,
CancellationToken cancellationToken)
{
if (_maximumRefreshCount is not null)
{
throw new InvalidOperationException(
"A bounded legacy refresh must use the tracked execution path.");
}
return await ExecuteRefreshCoreAsync(
refreshAsync,
successPredicate: null,
cancellationToken);
}
/// <summary>
/// Executes one refresh and records a successful dispatch before releasing the
/// in-flight latch. This closes the dispatch/count gap at a configured ceiling.
/// </summary>
public async Task<TResult> ExecuteRefreshAndTrackSuccessAsync<TResult>(
Func<CancellationToken, Task<TResult>> refreshAsync,
Func<TResult, bool> successPredicate,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(successPredicate);
return await ExecuteRefreshCoreAsync(
refreshAsync,
successPredicate,
cancellationToken);
}
/// <summary>
/// Confirms the final Play callback after the dispatch ceiling is reached.
/// A false callback result leaves the ceiling unconfirmed for fail-closed status.
/// </summary>
public async Task<bool> DrainMaximumCallbackAsync(
Func<CancellationToken, Task<bool>> waitForPlayCompletionAsync,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(waitForPlayCompletionAsync);
if (!HasReachedMaximum)
{
throw new InvalidOperationException(
"The configured legacy refresh limit has not been reached.");
}
if (IsMaximumCallbackDrained)
{
return true;
}
if (!await waitForPlayCompletionAsync(cancellationToken))
{
return false;
}
Volatile.Write(ref _maximumCallbackDrained, 1);
return true;
}
private async Task<TResult> ExecuteRefreshCoreAsync<TResult>(
Func<CancellationToken, Task<TResult>> refreshAsync,
Func<TResult, bool>? successPredicate,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(refreshAsync);
if (HasReachedMaximum)
{
throw new InvalidOperationException(
"The configured legacy refresh limit has already been reached.");
}
if (Interlocked.CompareExchange(ref _refreshInFlight, 1, 0) != 0)
{
@@ -79,7 +177,13 @@ public sealed class LegacyRefreshScheduler
try
{
return await refreshAsync(cancellationToken);
var result = await refreshAsync(cancellationToken);
if (successPredicate?.Invoke(result) == true)
{
TrackRefreshSucceeded();
}
return result;
}
finally
{
@@ -87,7 +191,28 @@ public sealed class LegacyRefreshScheduler
}
}
public void MarkRefreshSucceeded() => _nextDelay = _recurringDelay;
internal void MarkRefreshSucceeded()
{
if (_maximumRefreshCount is not null)
{
throw new InvalidOperationException(
"A bounded legacy refresh is tracked by its execution path.");
}
TrackRefreshSucceeded();
}
private void TrackRefreshSucceeded()
{
if (HasReachedMaximum)
{
throw new InvalidOperationException(
"The configured legacy refresh limit has already been reached.");
}
Interlocked.Increment(ref _completedRefreshCount);
_nextDelay = _recurringDelay;
}
private static void ValidateDelay(TimeSpan delay, string parameterName)
{

View File

@@ -75,4 +75,13 @@ public sealed class PlayoutOptions
public int MaximumReconnectAttempts { get; set; } = 3;
public bool ReconnectEnabled { get; set; } = true;
/// <summary>
/// Optional trusted local safety ceiling for timer-driven same-scene refreshes
/// started by one TAKE IN. Null preserves the legacy continuous-refresh behavior;
/// zero disables automatic refresh; a positive value stops after that many
/// successful refresh dispatches and drains the final play callback before
/// reporting that the limit was reached. Web content cannot change this value.
/// </summary>
public int? MaximumAutomaticRefreshesPerTakeIn { get; set; }
}

View File

@@ -98,6 +98,14 @@ internal sealed record ValidatedPlayoutOptions(
0,
1_000,
nameof(options.MaximumReconnectAttempts));
if (options.MaximumAutomaticRefreshesPerTakeIn is { } maximumAutomaticRefreshes)
{
RequireRange(
maximumAutomaticRefreshes,
0,
1_000_000,
nameof(options.MaximumAutomaticRefreshesPerTakeIn));
}
Regex? titleRegex = null;
var allowlist = new HashSet<string>(StringComparer.OrdinalIgnoreCase);