fix: preserve operator window between scene refreshes
This commit is contained in:
133
src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyRefreshEpoch.cs
Normal file
133
src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyRefreshEpoch.cs
Normal file
@@ -0,0 +1,133 @@
|
||||
#nullable enable
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Couples the current refresh cancellation generation with its published
|
||||
/// state. Generation checks and state transitions happen under one lock so an
|
||||
/// obsolete loop can never overwrite a replacement or a stopped state.
|
||||
/// </summary>
|
||||
public sealed class LegacyRefreshEpoch<TState>
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private CancellationTokenSource? _current;
|
||||
private TState _state;
|
||||
|
||||
public LegacyRefreshEpoch(TState initialState)
|
||||
{
|
||||
_state = initialState;
|
||||
}
|
||||
|
||||
public TState ReadState()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _state;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsCurrent(CancellationTokenSource epoch)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(epoch);
|
||||
lock (_gate)
|
||||
{
|
||||
return ReferenceEquals(_current, epoch);
|
||||
}
|
||||
}
|
||||
|
||||
public void Replace(
|
||||
CancellationTokenSource epoch,
|
||||
Func<TState, TState> updateState)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(epoch);
|
||||
ArgumentNullException.ThrowIfNull(updateState);
|
||||
|
||||
CancellationTokenSource? previous;
|
||||
lock (_gate)
|
||||
{
|
||||
var nextState = updateState(_state);
|
||||
previous = _current;
|
||||
_current = epoch;
|
||||
_state = nextState;
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(previous, epoch))
|
||||
{
|
||||
CancelOutsideLock(previous);
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop(Func<TState, TState> updateState)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(updateState);
|
||||
|
||||
CancellationTokenSource? previous;
|
||||
lock (_gate)
|
||||
{
|
||||
var nextState = updateState(_state);
|
||||
previous = _current;
|
||||
_current = null;
|
||||
_state = nextState;
|
||||
}
|
||||
|
||||
CancelOutsideLock(previous);
|
||||
}
|
||||
|
||||
public bool TryUpdate(
|
||||
CancellationTokenSource epoch,
|
||||
Func<TState, TState> updateState)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(epoch);
|
||||
ArgumentNullException.ThrowIfNull(updateState);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (!ReferenceEquals(_current, epoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_state = updateState(_state);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryComplete(
|
||||
CancellationTokenSource epoch,
|
||||
Func<TState, TState> updateState)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(epoch);
|
||||
ArgumentNullException.ThrowIfNull(updateState);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (!ReferenceEquals(_current, epoch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var nextState = updateState(_state);
|
||||
_current = null;
|
||||
_state = nextState;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CancelOutsideLock(CancellationTokenSource? cancellation)
|
||||
{
|
||||
if (cancellation is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
cancellation.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// The obsolete loop may have completed between the atomic swap and
|
||||
// cancellation. Its generation is already detached from state.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
#nullable enable
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// Schedules each legacy on-air refresh as a one-shot operation after the
|
||||
/// preceding Play completion has been observed. The effective cadence is
|
||||
/// deliberately callback completion plus the configured delay, so missed
|
||||
/// intervals are coalesced instead of being replayed back-to-back.
|
||||
/// </summary>
|
||||
public sealed class LegacyRefreshScheduler
|
||||
{
|
||||
private readonly TimeSpan _recurringDelay;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _delayAsync;
|
||||
private TimeSpan _nextDelay;
|
||||
private int _refreshInFlight;
|
||||
|
||||
public LegacyRefreshScheduler(TimeSpan initialDelay, TimeSpan recurringDelay)
|
||||
: this(
|
||||
initialDelay,
|
||||
recurringDelay,
|
||||
static (delay, cancellationToken) => Task.Delay(delay, cancellationToken))
|
||||
{
|
||||
}
|
||||
|
||||
internal LegacyRefreshScheduler(
|
||||
TimeSpan initialDelay,
|
||||
TimeSpan recurringDelay,
|
||||
Func<TimeSpan, CancellationToken, Task> delayAsync)
|
||||
{
|
||||
ValidateDelay(initialDelay, nameof(initialDelay));
|
||||
ValidateDelay(recurringDelay, nameof(recurringDelay));
|
||||
ArgumentNullException.ThrowIfNull(delayAsync);
|
||||
|
||||
_nextDelay = initialDelay;
|
||||
_recurringDelay = recurringDelay;
|
||||
_delayAsync = delayAsync;
|
||||
}
|
||||
|
||||
public bool IsRefreshInFlight => Volatile.Read(ref _refreshInFlight) != 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.
|
||||
/// </summary>
|
||||
public async Task<bool> WaitForNextRefreshAsync(
|
||||
Func<CancellationToken, Task<bool>> waitForPlayCompletionAsync,
|
||||
Action<TimeSpan>? onDelayScheduled,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(waitForPlayCompletionAsync);
|
||||
|
||||
if (!await waitForPlayCompletionAsync(cancellationToken))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var delay = _nextDelay;
|
||||
onDelayScheduled?.Invoke(delay);
|
||||
await _delayAsync(delay, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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>(
|
||||
Func<CancellationToken, Task<TResult>> refreshAsync,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(refreshAsync);
|
||||
|
||||
if (Interlocked.CompareExchange(ref _refreshInFlight, 1, 0) != 0)
|
||||
{
|
||||
throw new InvalidOperationException("A legacy scene refresh is already in flight.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await refreshAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Volatile.Write(ref _refreshInFlight, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkRefreshSucceeded() => _nextDelay = _recurringDelay;
|
||||
|
||||
private static void ValidateDelay(TimeSpan delay, string parameterName)
|
||||
{
|
||||
if (delay <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
parameterName,
|
||||
"A legacy refresh delay must be positive.");
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/MBN_STOCK_WEBVIEW.Core/Properties/AssemblyInfo.cs
Normal file
3
src/MBN_STOCK_WEBVIEW.Core/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.Core.Tests")]
|
||||
Reference in New Issue
Block a user