fix: preserve operator window between scene refreshes
This commit is contained in:
@@ -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