fix: preserve operator window between scene refreshes
This commit is contained in:
@@ -20,10 +20,9 @@ public sealed partial class MainWindow
|
||||
private int _playoutCommandInFlight;
|
||||
private int _playoutShutdownStarted;
|
||||
private int _browserCorrelationQuarantined;
|
||||
private CancellationTokenSource? _legacyRefreshCancellation;
|
||||
private Task? _legacyRefreshTask;
|
||||
private readonly object _legacyRefreshStatusGate = new();
|
||||
private LegacyRefreshRuntimeStatus _legacyRefreshStatus = LegacyRefreshRuntimeStatus.Empty;
|
||||
private readonly LegacyRefreshEpoch<LegacyRefreshRuntimeStatus> _legacyRefreshEpoch =
|
||||
new(LegacyRefreshRuntimeStatus.Empty);
|
||||
|
||||
private void InitializePlayoutRuntime()
|
||||
{
|
||||
@@ -174,10 +173,6 @@ public sealed partial class MainWindow
|
||||
try
|
||||
{
|
||||
Interlocked.Exchange(ref _playoutCommandInFlight, 1);
|
||||
CancelActiveMarketDataRequest();
|
||||
CancelActiveDatabaseHealthCheck();
|
||||
await _databaseActivityGate.WaitAsync(_lifetimeCancellation.Token);
|
||||
databaseActivityEntered = true;
|
||||
if (IsBrowserCorrelationQuarantined())
|
||||
{
|
||||
PostPlayoutCommandError(
|
||||
@@ -208,6 +203,37 @@ public sealed partial class MainWindow
|
||||
// MainForm stops timer1 before every operator command. TAKE IN and a
|
||||
// successful playlist NEXT start it again; Page NEXT intentionally does not.
|
||||
StopLegacyRefreshLoop();
|
||||
// Do not wait or enter the workflow while a prior Play callback is
|
||||
// pending. NEXT fails closed immediately so the browser request and both
|
||||
// gates are released, leaving an emergency TAKE OUT actionable.
|
||||
if (request.Command == "next" && engine.Status.IsPlayCompletionPending)
|
||||
{
|
||||
PostPlayoutCommandError(
|
||||
request.RequestId,
|
||||
request.Command,
|
||||
"PLAY_CALLBACK_PENDING",
|
||||
"Tornado play completion is still pending. Wait for the status update or use TAKE OUT.",
|
||||
retryable: true,
|
||||
outcomeUnknown: false);
|
||||
return;
|
||||
}
|
||||
|
||||
CancelActiveMarketDataRequest();
|
||||
CancelActiveDatabaseHealthCheck();
|
||||
await _databaseActivityGate.WaitAsync(_lifetimeCancellation.Token);
|
||||
databaseActivityEntered = true;
|
||||
if (IsBrowserCorrelationQuarantined())
|
||||
{
|
||||
PostPlayoutCommandError(
|
||||
request.RequestId,
|
||||
request.Command,
|
||||
"OUTCOME_UNKNOWN",
|
||||
BrowserCorrelationQuarantineMessage,
|
||||
retryable: false,
|
||||
outcomeUnknown: true);
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await ExecutePlayoutCommandAsync(
|
||||
workflow,
|
||||
request,
|
||||
@@ -730,76 +756,97 @@ public sealed partial class MainWindow
|
||||
|
||||
var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
_lifetimeCancellation.Token);
|
||||
_legacyRefreshCancellation = cancellation;
|
||||
SetLegacyRefreshStatus(new LegacyRefreshRuntimeStatus(
|
||||
true,
|
||||
DateTimeOffset.UtcNow.Add(interval),
|
||||
GetLegacyRefreshStatus().LastSuccessAtUtc,
|
||||
false,
|
||||
null,
|
||||
null));
|
||||
var scheduler = new LegacyRefreshScheduler(
|
||||
interval,
|
||||
LegacySceneRefreshPolicy.RecurringInterval);
|
||||
_legacyRefreshEpoch.Replace(
|
||||
cancellation,
|
||||
status => new LegacyRefreshRuntimeStatus(
|
||||
true,
|
||||
null,
|
||||
status.LastSuccessAtUtc,
|
||||
false,
|
||||
null,
|
||||
null));
|
||||
_legacyRefreshTask = RunLegacyRefreshLoopAsync(
|
||||
workflow,
|
||||
interval,
|
||||
scheduler,
|
||||
cancellation);
|
||||
}
|
||||
|
||||
private void StopLegacyRefreshLoop()
|
||||
{
|
||||
var cancellation = Interlocked.Exchange(
|
||||
ref _legacyRefreshCancellation,
|
||||
null);
|
||||
if (cancellation is not null)
|
||||
_legacyRefreshEpoch.Stop(status => status with
|
||||
{
|
||||
cancellation.Cancel();
|
||||
}
|
||||
|
||||
var status = GetLegacyRefreshStatus();
|
||||
if (status.IsActive)
|
||||
{
|
||||
SetLegacyRefreshStatus(status with { IsActive = false, NextAtUtc = null });
|
||||
}
|
||||
IsActive = false,
|
||||
NextAtUtc = null
|
||||
});
|
||||
}
|
||||
|
||||
private async Task RunLegacyRefreshLoopAsync(
|
||||
LegacyPlayoutWorkflow workflow,
|
||||
TimeSpan interval,
|
||||
LegacyRefreshScheduler scheduler,
|
||||
CancellationTokenSource cancellation)
|
||||
{
|
||||
var token = cancellation.Token;
|
||||
var nextDelay = interval;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await Task.Delay(nextDelay, token);
|
||||
|
||||
var engine = _playoutEngine;
|
||||
if (engine is null ||
|
||||
!ReferenceEquals(_playoutWorkflow, workflow) ||
|
||||
!ReferenceEquals(_legacyRefreshCancellation, cancellation) ||
|
||||
!_legacyRefreshEpoch.IsCurrent(cancellation) ||
|
||||
Volatile.Read(ref _playoutShutdownStarted) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Play returns before Tornado necessarily delivers OnScenePlayed. Waiting
|
||||
// outside the app command gate keeps emergency TAKE OUT available and does
|
||||
// not dispatch, repeat, or probe any SDK mutation.
|
||||
if (!await WaitForPlayCompletionAsync(engine, token))
|
||||
// The original WinForms timer coalesced non-reentrant UI ticks but did
|
||||
// not wait for the vendor callback. The migration's safety invariant is
|
||||
// stricter: observe OnScenePlayed and then start a complete one-shot
|
||||
// cooldown. This intentionally lengthens the effective cadence while
|
||||
// keeping the command gate free for a real operator quiescent window.
|
||||
if (!await scheduler.WaitForNextRefreshAsync(
|
||||
cancellationToken => WaitForPlayCompletionAsync(
|
||||
engine,
|
||||
cancellationToken),
|
||||
scheduledDelay =>
|
||||
{
|
||||
if (_legacyRefreshEpoch.TryUpdate(
|
||||
cancellation,
|
||||
status => status.IsActive
|
||||
? status with
|
||||
{
|
||||
NextAtUtc = DateTimeOffset.UtcNow.Add(scheduledDelay)
|
||||
}
|
||||
: status))
|
||||
{
|
||||
QueuePlayoutStatus();
|
||||
}
|
||||
},
|
||||
token))
|
||||
{
|
||||
SetLegacyRefreshFault(
|
||||
TrySetLegacyRefreshFault(
|
||||
cancellation,
|
||||
"PLAY_CALLBACK_TIMEOUT",
|
||||
"Tornado 재생 완료 이벤트를 제한 시간 안에 확인하지 못해 자동 장면 갱신을 중단했습니다. TAKE OUT 후 실제 화면을 확인하세요.");
|
||||
QueuePlayoutStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(_playoutWorkflow, workflow) ||
|
||||
!_legacyRefreshEpoch.IsCurrent(cancellation) ||
|
||||
Volatile.Read(ref _playoutShutdownStarted) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _playoutCommandGate.WaitAsync(token);
|
||||
var databaseActivityEntered = false;
|
||||
try
|
||||
{
|
||||
if (!ReferenceEquals(_legacyRefreshCancellation, cancellation) ||
|
||||
if (!_legacyRefreshEpoch.IsCurrent(cancellation) ||
|
||||
Volatile.Read(ref _playoutShutdownStarted) != 0)
|
||||
{
|
||||
return;
|
||||
@@ -810,27 +857,36 @@ public sealed partial class MainWindow
|
||||
CancelActiveDatabaseHealthCheck();
|
||||
await _databaseActivityGate.WaitAsync(token);
|
||||
databaseActivityEntered = true;
|
||||
var result = await workflow.RefreshOnAirAsync(token);
|
||||
var result = await scheduler.ExecuteRefreshAsync(
|
||||
workflow.RefreshOnAirAsync,
|
||||
token);
|
||||
// A failed, timed-out, or unknown update never repeats. The operator
|
||||
// must reconcile native/PGM state before starting another command.
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
SetLegacyRefreshFault(
|
||||
TrySetLegacyRefreshFault(
|
||||
cancellation,
|
||||
ToWireValue(result.Code),
|
||||
"자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면과 데이터를 확인하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// MainForm changes timer1.Interval to 3000 after the first
|
||||
// successful same-scene refresh.
|
||||
nextDelay = LegacySceneRefreshPolicy.RecurringInterval;
|
||||
SetLegacyRefreshStatus(new LegacyRefreshRuntimeStatus(
|
||||
true,
|
||||
DateTimeOffset.UtcNow.Add(nextDelay),
|
||||
DateTimeOffset.UtcNow,
|
||||
false,
|
||||
null,
|
||||
null));
|
||||
// successful same-scene refresh. The next interval does not
|
||||
// begin until this Play's completion callback is observed.
|
||||
scheduler.MarkRefreshSucceeded();
|
||||
if (!_legacyRefreshEpoch.TryUpdate(
|
||||
cancellation,
|
||||
_ => new LegacyRefreshRuntimeStatus(
|
||||
true,
|
||||
null,
|
||||
DateTimeOffset.UtcNow,
|
||||
false,
|
||||
null,
|
||||
null)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||
{
|
||||
@@ -838,21 +894,24 @@ public sealed partial class MainWindow
|
||||
}
|
||||
catch (DatabaseOperationException)
|
||||
{
|
||||
SetLegacyRefreshFault(
|
||||
TrySetLegacyRefreshFault(
|
||||
cancellation,
|
||||
"DATABASE_UNAVAILABLE",
|
||||
"자동 장면 갱신 중 데이터베이스 조회가 실패했습니다. TAKE OUT 후 확인하세요.");
|
||||
return;
|
||||
}
|
||||
catch (LegacySceneDataException)
|
||||
{
|
||||
SetLegacyRefreshFault(
|
||||
TrySetLegacyRefreshFault(
|
||||
cancellation,
|
||||
"SCENE_DATA_INVALID",
|
||||
"자동 장면 갱신 데이터가 유효하지 않습니다. TAKE OUT 후 확인하세요.");
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
SetLegacyRefreshFault(
|
||||
TrySetLegacyRefreshFault(
|
||||
cancellation,
|
||||
"REFRESH_FAILED",
|
||||
"자동 장면 갱신을 완료하지 못했습니다. TAKE OUT 후 확인하세요.");
|
||||
return;
|
||||
@@ -875,11 +934,18 @@ public sealed partial class MainWindow
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.CompareExchange(
|
||||
ref _legacyRefreshCancellation,
|
||||
null,
|
||||
cancellation);
|
||||
var completedCurrentEpoch = _legacyRefreshEpoch.TryComplete(
|
||||
cancellation,
|
||||
status => status with
|
||||
{
|
||||
IsActive = false,
|
||||
NextAtUtc = null
|
||||
});
|
||||
cancellation.Dispose();
|
||||
if (completedCurrentEpoch)
|
||||
{
|
||||
QueuePlayoutStatus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -941,33 +1007,25 @@ public sealed partial class MainWindow
|
||||
return true;
|
||||
}
|
||||
|
||||
private LegacyRefreshRuntimeStatus GetLegacyRefreshStatus()
|
||||
{
|
||||
lock (_legacyRefreshStatusGate)
|
||||
{
|
||||
return _legacyRefreshStatus;
|
||||
}
|
||||
}
|
||||
private LegacyRefreshRuntimeStatus GetLegacyRefreshStatus() =>
|
||||
_legacyRefreshEpoch.ReadState();
|
||||
|
||||
private void SetLegacyRefreshStatus(LegacyRefreshRuntimeStatus status)
|
||||
{
|
||||
lock (_legacyRefreshStatusGate)
|
||||
{
|
||||
_legacyRefreshStatus = status;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetLegacyRefreshFault(string code, string message) =>
|
||||
SetLegacyRefreshStatus(new LegacyRefreshRuntimeStatus(
|
||||
false,
|
||||
null,
|
||||
GetLegacyRefreshStatus().LastSuccessAtUtc,
|
||||
true,
|
||||
code,
|
||||
message));
|
||||
private bool TrySetLegacyRefreshFault(
|
||||
CancellationTokenSource cancellation,
|
||||
string code,
|
||||
string message) =>
|
||||
_legacyRefreshEpoch.TryUpdate(
|
||||
cancellation,
|
||||
status => new LegacyRefreshRuntimeStatus(
|
||||
false,
|
||||
null,
|
||||
status.LastSuccessAtUtc,
|
||||
true,
|
||||
code,
|
||||
message));
|
||||
|
||||
private void ResetLegacyRefreshStatus() =>
|
||||
SetLegacyRefreshStatus(LegacyRefreshRuntimeStatus.Empty);
|
||||
_legacyRefreshEpoch.Stop(_ => LegacyRefreshRuntimeStatus.Empty);
|
||||
|
||||
private void ShutdownPlayoutRuntime()
|
||||
{
|
||||
|
||||
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")]
|
||||
@@ -0,0 +1,62 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||
|
||||
public sealed class LegacyRefreshEpochTests
|
||||
{
|
||||
[Fact]
|
||||
public void Stop_PreventsAStaleWriteFromReactivatingState()
|
||||
{
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var epoch = new LegacyRefreshEpoch<string>("idle");
|
||||
epoch.Replace(cancellation, _ => "active");
|
||||
|
||||
epoch.Stop(_ => "stopped");
|
||||
var staleWriteAccepted = epoch.TryUpdate(cancellation, _ => "active-again");
|
||||
|
||||
Assert.True(cancellation.IsCancellationRequested);
|
||||
Assert.False(staleWriteAccepted);
|
||||
Assert.Equal("stopped", epoch.ReadState());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replacement_RejectsAllWritesFromTheOldGeneration()
|
||||
{
|
||||
using var oldCancellation = new CancellationTokenSource();
|
||||
using var newCancellation = new CancellationTokenSource();
|
||||
var epoch = new LegacyRefreshEpoch<string>("idle");
|
||||
epoch.Replace(oldCancellation, _ => "old-active");
|
||||
|
||||
epoch.Replace(newCancellation, _ => "new-active");
|
||||
var staleFaultAccepted = epoch.TryUpdate(oldCancellation, _ => "old-fault");
|
||||
var currentWriteAccepted = epoch.TryUpdate(newCancellation, _ => "new-scheduled");
|
||||
|
||||
Assert.True(oldCancellation.IsCancellationRequested);
|
||||
Assert.False(newCancellation.IsCancellationRequested);
|
||||
Assert.False(staleFaultAccepted);
|
||||
Assert.True(currentWriteAccepted);
|
||||
Assert.Equal("new-scheduled", epoch.ReadState());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OldCompletion_DoesNotClearOrRewriteTheReplacement()
|
||||
{
|
||||
using var oldCancellation = new CancellationTokenSource();
|
||||
using var newCancellation = new CancellationTokenSource();
|
||||
var epoch = new LegacyRefreshEpoch<string>("idle");
|
||||
epoch.Replace(oldCancellation, _ => "old-active");
|
||||
epoch.Replace(newCancellation, _ => "new-active");
|
||||
|
||||
var oldCompletionAccepted = epoch.TryComplete(
|
||||
oldCancellation,
|
||||
_ => "old-complete");
|
||||
|
||||
Assert.False(oldCompletionAccepted);
|
||||
Assert.True(epoch.IsCurrent(newCancellation));
|
||||
Assert.Equal("new-active", epoch.ReadState());
|
||||
|
||||
Assert.True(epoch.TryComplete(newCancellation, _ => "new-complete"));
|
||||
Assert.False(epoch.IsCurrent(newCancellation));
|
||||
Assert.Equal("new-complete", epoch.ReadState());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Core.Tests;
|
||||
|
||||
public sealed class LegacyRefreshSchedulerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LongPlayCallback_DoesNotConsumeTheInitialDelay()
|
||||
{
|
||||
var callbackCompletion = NewCompletion<bool>();
|
||||
var delays = new ControlledDelay();
|
||||
var scheduler = CreateScheduler(delays);
|
||||
|
||||
var opportunity = scheduler.WaitForNextRefreshAsync(
|
||||
cancellationToken => callbackCompletion.Task.WaitAsync(cancellationToken),
|
||||
null,
|
||||
CancellationToken.None);
|
||||
|
||||
await Task.Yield();
|
||||
Assert.Equal(0, delays.CallCount);
|
||||
|
||||
callbackCompletion.SetResult(true);
|
||||
Assert.Equal(TimeSpan.FromSeconds(2), await delays.WaitForCallAsync(0));
|
||||
Assert.False(opportunity.IsCompleted);
|
||||
|
||||
delays.Complete(0);
|
||||
Assert.True(await opportunity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OneShotScheduling_CoalescesMissedTicksAndPreventsBackToBackPlays()
|
||||
{
|
||||
var delays = new ControlledDelay();
|
||||
var scheduler = CreateScheduler(delays);
|
||||
var playCount = 0;
|
||||
|
||||
var firstOpportunity = scheduler.WaitForNextRefreshAsync(
|
||||
_ => Task.FromResult(true),
|
||||
null,
|
||||
CancellationToken.None);
|
||||
Assert.Equal(TimeSpan.FromSeconds(2), await delays.WaitForCallAsync(0));
|
||||
delays.Complete(0);
|
||||
Assert.True(await firstOpportunity);
|
||||
Assert.True(await scheduler.ExecuteRefreshAsync(
|
||||
_ => Task.FromResult(++playCount == 1),
|
||||
CancellationToken.None));
|
||||
scheduler.MarkRefreshSucceeded();
|
||||
|
||||
var firstPlayCompletion = NewCompletion<bool>();
|
||||
var secondOpportunity = scheduler.WaitForNextRefreshAsync(
|
||||
cancellationToken => firstPlayCompletion.Task.WaitAsync(cancellationToken),
|
||||
null,
|
||||
CancellationToken.None);
|
||||
|
||||
await Task.Yield();
|
||||
Assert.Equal(1, delays.CallCount);
|
||||
Assert.Equal(1, playCount);
|
||||
|
||||
firstPlayCompletion.SetResult(true);
|
||||
Assert.Equal(TimeSpan.FromSeconds(3), await delays.WaitForCallAsync(1));
|
||||
Assert.Equal(1, playCount);
|
||||
Assert.Equal(2, delays.CallCount);
|
||||
|
||||
delays.Complete(1);
|
||||
Assert.True(await secondOpportunity);
|
||||
Assert.True(await scheduler.ExecuteRefreshAsync(
|
||||
_ => Task.FromResult(++playCount == 2),
|
||||
CancellationToken.None));
|
||||
Assert.Equal(2, playCount);
|
||||
Assert.Equal(2, delays.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScheduledDelay_LeavesAQuiescentWindowForOperatorNext()
|
||||
{
|
||||
var delays = new ControlledDelay();
|
||||
var scheduler = CreateScheduler(delays);
|
||||
using var commandGate = new SemaphoreSlim(1, 1);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var refreshCalled = false;
|
||||
|
||||
var opportunity = RunOneRefreshAsync(
|
||||
scheduler,
|
||||
_ => Task.FromResult(true),
|
||||
async cancellationToken =>
|
||||
{
|
||||
await commandGate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
refreshCalled = true;
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
commandGate.Release();
|
||||
}
|
||||
},
|
||||
cancellation.Token);
|
||||
await delays.WaitForCallAsync(0);
|
||||
|
||||
Assert.False(scheduler.IsRefreshInFlight);
|
||||
Assert.True(await commandGate.WaitAsync(0));
|
||||
commandGate.Release();
|
||||
|
||||
cancellation.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => opportunity);
|
||||
Assert.False(refreshCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StopCancellation_DropsTheScheduledRefresh()
|
||||
{
|
||||
var delays = new ControlledDelay();
|
||||
var scheduler = CreateScheduler(delays);
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var refreshCalled = false;
|
||||
|
||||
var opportunity = RunOneRefreshAsync(
|
||||
scheduler,
|
||||
_ => Task.FromResult(true),
|
||||
_ =>
|
||||
{
|
||||
refreshCalled = true;
|
||||
return Task.FromResult(true);
|
||||
},
|
||||
cancellation.Token);
|
||||
await delays.WaitForCallAsync(0);
|
||||
|
||||
cancellation.Cancel();
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => opportunity);
|
||||
|
||||
Assert.False(refreshCalled);
|
||||
Assert.False(scheduler.IsRefreshInFlight);
|
||||
Assert.Equal(1, delays.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteRefresh_RejectsASecondInFlightRefresh()
|
||||
{
|
||||
var scheduler = CreateScheduler(new ControlledDelay());
|
||||
var releaseRefresh = NewCompletion<bool>();
|
||||
var refreshEntered = NewCompletion<bool>();
|
||||
|
||||
var firstRefresh = scheduler.ExecuteRefreshAsync(
|
||||
async cancellationToken =>
|
||||
{
|
||||
refreshEntered.SetResult(true);
|
||||
return await releaseRefresh.Task.WaitAsync(cancellationToken);
|
||||
},
|
||||
CancellationToken.None);
|
||||
await refreshEntered.Task;
|
||||
|
||||
Assert.True(scheduler.IsRefreshInFlight);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
scheduler.ExecuteRefreshAsync(_ => Task.FromResult(true), CancellationToken.None));
|
||||
|
||||
releaseRefresh.SetResult(true);
|
||||
Assert.True(await firstRefresh);
|
||||
Assert.False(scheduler.IsRefreshInFlight);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallbackTimeout_DoesNotScheduleOrDispatchARefresh()
|
||||
{
|
||||
var delays = new ControlledDelay();
|
||||
var scheduler = CreateScheduler(delays);
|
||||
var refreshCalled = false;
|
||||
|
||||
var ready = await RunOneRefreshAsync(
|
||||
scheduler,
|
||||
_ => Task.FromResult(false),
|
||||
_ =>
|
||||
{
|
||||
refreshCalled = true;
|
||||
return Task.FromResult(true);
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(ready);
|
||||
Assert.False(refreshCalled);
|
||||
Assert.Equal(0, delays.CallCount);
|
||||
}
|
||||
|
||||
private static LegacyRefreshScheduler CreateScheduler(ControlledDelay delays) =>
|
||||
new(
|
||||
TimeSpan.FromSeconds(2),
|
||||
TimeSpan.FromSeconds(3),
|
||||
delays.DelayAsync);
|
||||
|
||||
private static async Task<bool> RunOneRefreshAsync(
|
||||
LegacyRefreshScheduler scheduler,
|
||||
Func<CancellationToken, Task<bool>> waitForPlayCompletionAsync,
|
||||
Func<CancellationToken, Task<bool>> refreshAsync,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await scheduler.WaitForNextRefreshAsync(
|
||||
waitForPlayCompletionAsync,
|
||||
null,
|
||||
cancellationToken))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return await scheduler.ExecuteRefreshAsync(refreshAsync, cancellationToken);
|
||||
}
|
||||
|
||||
private static TaskCompletionSource<T> NewCompletion<T>() =>
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
private sealed class ControlledDelay
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly List<DelayRequest> _requests = [];
|
||||
private TaskCompletionSource<bool> _requestAdded = NewCompletion<bool>();
|
||||
|
||||
public int CallCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _requests.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
|
||||
{
|
||||
DelayRequest request;
|
||||
TaskCompletionSource<bool> requestAdded;
|
||||
lock (_gate)
|
||||
{
|
||||
request = new DelayRequest(delay);
|
||||
_requests.Add(request);
|
||||
requestAdded = _requestAdded;
|
||||
_requestAdded = NewCompletion<bool>();
|
||||
}
|
||||
|
||||
requestAdded.TrySetResult(true);
|
||||
return request.Release.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TimeSpan> WaitForCallAsync(int index)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
Task requestAdded;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_requests.Count > index)
|
||||
{
|
||||
return _requests[index].Delay;
|
||||
}
|
||||
|
||||
requestAdded = _requestAdded.Task;
|
||||
}
|
||||
|
||||
await requestAdded.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
public void Complete(int index)
|
||||
{
|
||||
TaskCompletionSource<bool> release;
|
||||
lock (_gate)
|
||||
{
|
||||
release = _requests[index].Release;
|
||||
}
|
||||
|
||||
release.SetResult(true);
|
||||
}
|
||||
|
||||
private sealed record DelayRequest(
|
||||
TimeSpan Delay,
|
||||
TaskCompletionSource<bool> Release)
|
||||
{
|
||||
public DelayRequest(TimeSpan delay)
|
||||
: this(delay, NewCompletion<bool>())
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user