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 _playoutCommandInFlight;
|
||||||
private int _playoutShutdownStarted;
|
private int _playoutShutdownStarted;
|
||||||
private int _browserCorrelationQuarantined;
|
private int _browserCorrelationQuarantined;
|
||||||
private CancellationTokenSource? _legacyRefreshCancellation;
|
|
||||||
private Task? _legacyRefreshTask;
|
private Task? _legacyRefreshTask;
|
||||||
private readonly object _legacyRefreshStatusGate = new();
|
private readonly LegacyRefreshEpoch<LegacyRefreshRuntimeStatus> _legacyRefreshEpoch =
|
||||||
private LegacyRefreshRuntimeStatus _legacyRefreshStatus = LegacyRefreshRuntimeStatus.Empty;
|
new(LegacyRefreshRuntimeStatus.Empty);
|
||||||
|
|
||||||
private void InitializePlayoutRuntime()
|
private void InitializePlayoutRuntime()
|
||||||
{
|
{
|
||||||
@@ -174,10 +173,6 @@ public sealed partial class MainWindow
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
Interlocked.Exchange(ref _playoutCommandInFlight, 1);
|
Interlocked.Exchange(ref _playoutCommandInFlight, 1);
|
||||||
CancelActiveMarketDataRequest();
|
|
||||||
CancelActiveDatabaseHealthCheck();
|
|
||||||
await _databaseActivityGate.WaitAsync(_lifetimeCancellation.Token);
|
|
||||||
databaseActivityEntered = true;
|
|
||||||
if (IsBrowserCorrelationQuarantined())
|
if (IsBrowserCorrelationQuarantined())
|
||||||
{
|
{
|
||||||
PostPlayoutCommandError(
|
PostPlayoutCommandError(
|
||||||
@@ -208,6 +203,37 @@ public sealed partial class MainWindow
|
|||||||
// MainForm stops timer1 before every operator command. TAKE IN and a
|
// MainForm stops timer1 before every operator command. TAKE IN and a
|
||||||
// successful playlist NEXT start it again; Page NEXT intentionally does not.
|
// successful playlist NEXT start it again; Page NEXT intentionally does not.
|
||||||
StopLegacyRefreshLoop();
|
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(
|
var result = await ExecutePlayoutCommandAsync(
|
||||||
workflow,
|
workflow,
|
||||||
request,
|
request,
|
||||||
@@ -730,76 +756,97 @@ public sealed partial class MainWindow
|
|||||||
|
|
||||||
var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
|
var cancellation = CancellationTokenSource.CreateLinkedTokenSource(
|
||||||
_lifetimeCancellation.Token);
|
_lifetimeCancellation.Token);
|
||||||
_legacyRefreshCancellation = cancellation;
|
var scheduler = new LegacyRefreshScheduler(
|
||||||
SetLegacyRefreshStatus(new LegacyRefreshRuntimeStatus(
|
interval,
|
||||||
true,
|
LegacySceneRefreshPolicy.RecurringInterval);
|
||||||
DateTimeOffset.UtcNow.Add(interval),
|
_legacyRefreshEpoch.Replace(
|
||||||
GetLegacyRefreshStatus().LastSuccessAtUtc,
|
cancellation,
|
||||||
false,
|
status => new LegacyRefreshRuntimeStatus(
|
||||||
null,
|
true,
|
||||||
null));
|
null,
|
||||||
|
status.LastSuccessAtUtc,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null));
|
||||||
_legacyRefreshTask = RunLegacyRefreshLoopAsync(
|
_legacyRefreshTask = RunLegacyRefreshLoopAsync(
|
||||||
workflow,
|
workflow,
|
||||||
interval,
|
scheduler,
|
||||||
cancellation);
|
cancellation);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void StopLegacyRefreshLoop()
|
private void StopLegacyRefreshLoop()
|
||||||
{
|
{
|
||||||
var cancellation = Interlocked.Exchange(
|
_legacyRefreshEpoch.Stop(status => status with
|
||||||
ref _legacyRefreshCancellation,
|
|
||||||
null);
|
|
||||||
if (cancellation is not null)
|
|
||||||
{
|
{
|
||||||
cancellation.Cancel();
|
IsActive = false,
|
||||||
}
|
NextAtUtc = null
|
||||||
|
});
|
||||||
var status = GetLegacyRefreshStatus();
|
|
||||||
if (status.IsActive)
|
|
||||||
{
|
|
||||||
SetLegacyRefreshStatus(status with { IsActive = false, NextAtUtc = null });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RunLegacyRefreshLoopAsync(
|
private async Task RunLegacyRefreshLoopAsync(
|
||||||
LegacyPlayoutWorkflow workflow,
|
LegacyPlayoutWorkflow workflow,
|
||||||
TimeSpan interval,
|
LegacyRefreshScheduler scheduler,
|
||||||
CancellationTokenSource cancellation)
|
CancellationTokenSource cancellation)
|
||||||
{
|
{
|
||||||
var token = cancellation.Token;
|
var token = cancellation.Token;
|
||||||
var nextDelay = interval;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
await Task.Delay(nextDelay, token);
|
|
||||||
|
|
||||||
var engine = _playoutEngine;
|
var engine = _playoutEngine;
|
||||||
if (engine is null ||
|
if (engine is null ||
|
||||||
!ReferenceEquals(_playoutWorkflow, workflow) ||
|
!ReferenceEquals(_playoutWorkflow, workflow) ||
|
||||||
!ReferenceEquals(_legacyRefreshCancellation, cancellation) ||
|
!_legacyRefreshEpoch.IsCurrent(cancellation) ||
|
||||||
Volatile.Read(ref _playoutShutdownStarted) != 0)
|
Volatile.Read(ref _playoutShutdownStarted) != 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play returns before Tornado necessarily delivers OnScenePlayed. Waiting
|
// The original WinForms timer coalesced non-reentrant UI ticks but did
|
||||||
// outside the app command gate keeps emergency TAKE OUT available and does
|
// not wait for the vendor callback. The migration's safety invariant is
|
||||||
// not dispatch, repeat, or probe any SDK mutation.
|
// stricter: observe OnScenePlayed and then start a complete one-shot
|
||||||
if (!await WaitForPlayCompletionAsync(engine, token))
|
// 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",
|
"PLAY_CALLBACK_TIMEOUT",
|
||||||
"Tornado 재생 완료 이벤트를 제한 시간 안에 확인하지 못해 자동 장면 갱신을 중단했습니다. TAKE OUT 후 실제 화면을 확인하세요.");
|
"Tornado 재생 완료 이벤트를 제한 시간 안에 확인하지 못해 자동 장면 갱신을 중단했습니다. TAKE OUT 후 실제 화면을 확인하세요.");
|
||||||
QueuePlayoutStatus();
|
QueuePlayoutStatus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!ReferenceEquals(_playoutWorkflow, workflow) ||
|
||||||
|
!_legacyRefreshEpoch.IsCurrent(cancellation) ||
|
||||||
|
Volatile.Read(ref _playoutShutdownStarted) != 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await _playoutCommandGate.WaitAsync(token);
|
await _playoutCommandGate.WaitAsync(token);
|
||||||
var databaseActivityEntered = false;
|
var databaseActivityEntered = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!ReferenceEquals(_legacyRefreshCancellation, cancellation) ||
|
if (!_legacyRefreshEpoch.IsCurrent(cancellation) ||
|
||||||
Volatile.Read(ref _playoutShutdownStarted) != 0)
|
Volatile.Read(ref _playoutShutdownStarted) != 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -810,27 +857,36 @@ public sealed partial class MainWindow
|
|||||||
CancelActiveDatabaseHealthCheck();
|
CancelActiveDatabaseHealthCheck();
|
||||||
await _databaseActivityGate.WaitAsync(token);
|
await _databaseActivityGate.WaitAsync(token);
|
||||||
databaseActivityEntered = true;
|
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
|
// A failed, timed-out, or unknown update never repeats. The operator
|
||||||
// must reconcile native/PGM state before starting another command.
|
// must reconcile native/PGM state before starting another command.
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
SetLegacyRefreshFault(
|
TrySetLegacyRefreshFault(
|
||||||
|
cancellation,
|
||||||
ToWireValue(result.Code),
|
ToWireValue(result.Code),
|
||||||
"자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면과 데이터를 확인하세요.");
|
"자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면과 데이터를 확인하세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MainForm changes timer1.Interval to 3000 after the first
|
// MainForm changes timer1.Interval to 3000 after the first
|
||||||
// successful same-scene refresh.
|
// successful same-scene refresh. The next interval does not
|
||||||
nextDelay = LegacySceneRefreshPolicy.RecurringInterval;
|
// begin until this Play's completion callback is observed.
|
||||||
SetLegacyRefreshStatus(new LegacyRefreshRuntimeStatus(
|
scheduler.MarkRefreshSucceeded();
|
||||||
true,
|
if (!_legacyRefreshEpoch.TryUpdate(
|
||||||
DateTimeOffset.UtcNow.Add(nextDelay),
|
cancellation,
|
||||||
DateTimeOffset.UtcNow,
|
_ => new LegacyRefreshRuntimeStatus(
|
||||||
false,
|
true,
|
||||||
null,
|
null,
|
||||||
null));
|
DateTimeOffset.UtcNow,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
catch (OperationCanceledException) when (token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -838,21 +894,24 @@ public sealed partial class MainWindow
|
|||||||
}
|
}
|
||||||
catch (DatabaseOperationException)
|
catch (DatabaseOperationException)
|
||||||
{
|
{
|
||||||
SetLegacyRefreshFault(
|
TrySetLegacyRefreshFault(
|
||||||
|
cancellation,
|
||||||
"DATABASE_UNAVAILABLE",
|
"DATABASE_UNAVAILABLE",
|
||||||
"자동 장면 갱신 중 데이터베이스 조회가 실패했습니다. TAKE OUT 후 확인하세요.");
|
"자동 장면 갱신 중 데이터베이스 조회가 실패했습니다. TAKE OUT 후 확인하세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
catch (LegacySceneDataException)
|
catch (LegacySceneDataException)
|
||||||
{
|
{
|
||||||
SetLegacyRefreshFault(
|
TrySetLegacyRefreshFault(
|
||||||
|
cancellation,
|
||||||
"SCENE_DATA_INVALID",
|
"SCENE_DATA_INVALID",
|
||||||
"자동 장면 갱신 데이터가 유효하지 않습니다. TAKE OUT 후 확인하세요.");
|
"자동 장면 갱신 데이터가 유효하지 않습니다. TAKE OUT 후 확인하세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
SetLegacyRefreshFault(
|
TrySetLegacyRefreshFault(
|
||||||
|
cancellation,
|
||||||
"REFRESH_FAILED",
|
"REFRESH_FAILED",
|
||||||
"자동 장면 갱신을 완료하지 못했습니다. TAKE OUT 후 확인하세요.");
|
"자동 장면 갱신을 완료하지 못했습니다. TAKE OUT 후 확인하세요.");
|
||||||
return;
|
return;
|
||||||
@@ -875,11 +934,18 @@ public sealed partial class MainWindow
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
Interlocked.CompareExchange(
|
var completedCurrentEpoch = _legacyRefreshEpoch.TryComplete(
|
||||||
ref _legacyRefreshCancellation,
|
cancellation,
|
||||||
null,
|
status => status with
|
||||||
cancellation);
|
{
|
||||||
|
IsActive = false,
|
||||||
|
NextAtUtc = null
|
||||||
|
});
|
||||||
cancellation.Dispose();
|
cancellation.Dispose();
|
||||||
|
if (completedCurrentEpoch)
|
||||||
|
{
|
||||||
|
QueuePlayoutStatus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -941,33 +1007,25 @@ public sealed partial class MainWindow
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private LegacyRefreshRuntimeStatus GetLegacyRefreshStatus()
|
private LegacyRefreshRuntimeStatus GetLegacyRefreshStatus() =>
|
||||||
{
|
_legacyRefreshEpoch.ReadState();
|
||||||
lock (_legacyRefreshStatusGate)
|
|
||||||
{
|
|
||||||
return _legacyRefreshStatus;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void SetLegacyRefreshStatus(LegacyRefreshRuntimeStatus status)
|
private bool TrySetLegacyRefreshFault(
|
||||||
{
|
CancellationTokenSource cancellation,
|
||||||
lock (_legacyRefreshStatusGate)
|
string code,
|
||||||
{
|
string message) =>
|
||||||
_legacyRefreshStatus = status;
|
_legacyRefreshEpoch.TryUpdate(
|
||||||
}
|
cancellation,
|
||||||
}
|
status => new LegacyRefreshRuntimeStatus(
|
||||||
|
false,
|
||||||
private void SetLegacyRefreshFault(string code, string message) =>
|
null,
|
||||||
SetLegacyRefreshStatus(new LegacyRefreshRuntimeStatus(
|
status.LastSuccessAtUtc,
|
||||||
false,
|
true,
|
||||||
null,
|
code,
|
||||||
GetLegacyRefreshStatus().LastSuccessAtUtc,
|
message));
|
||||||
true,
|
|
||||||
code,
|
|
||||||
message));
|
|
||||||
|
|
||||||
private void ResetLegacyRefreshStatus() =>
|
private void ResetLegacyRefreshStatus() =>
|
||||||
SetLegacyRefreshStatus(LegacyRefreshRuntimeStatus.Empty);
|
_legacyRefreshEpoch.Stop(_ => LegacyRefreshRuntimeStatus.Empty);
|
||||||
|
|
||||||
private void ShutdownPlayoutRuntime()
|
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