fix: preserve operator window between scene refreshes

This commit is contained in:
2026-07-11 14:55:36 +09:00
parent 673d40b09f
commit fa0eae7b61
6 changed files with 721 additions and 81 deletions

View 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.
}
}
}

View File

@@ -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.");
}
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.Core.Tests")]