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

@@ -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()
{

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")]

View File

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

View File

@@ -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>())
{
}
}
}
}