From fa0eae7b61f57f134dfd30ebf1e59e9cc2cccfc5 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sat, 11 Jul 2026 14:55:36 +0900 Subject: [PATCH] fix: preserve operator window between scene refreshes --- MainWindow.Playout.cs | 220 +++++++++----- .../Playout/Scenes/LegacyRefreshEpoch.cs | 133 ++++++++ .../Playout/Scenes/LegacyRefreshScheduler.cs | 101 +++++++ .../Properties/AssemblyInfo.cs | 3 + .../LegacyRefreshEpochTests.cs | 62 ++++ .../LegacyRefreshSchedulerTests.cs | 283 ++++++++++++++++++ 6 files changed, 721 insertions(+), 81 deletions(-) create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyRefreshEpoch.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyRefreshScheduler.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Core/Properties/AssemblyInfo.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyRefreshEpochTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyRefreshSchedulerTests.cs diff --git a/MainWindow.Playout.cs b/MainWindow.Playout.cs index 4fa88c1..da1505f 100644 --- a/MainWindow.Playout.cs +++ b/MainWindow.Playout.cs @@ -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 _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() { diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyRefreshEpoch.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyRefreshEpoch.cs new file mode 100644 index 0000000..65114dc --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyRefreshEpoch.cs @@ -0,0 +1,133 @@ +#nullable enable + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// 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. +/// +public sealed class LegacyRefreshEpoch +{ + 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 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 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 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 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. + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyRefreshScheduler.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyRefreshScheduler.cs new file mode 100644 index 0000000..96d0811 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyRefreshScheduler.cs @@ -0,0 +1,101 @@ +#nullable enable + +namespace MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +/// +/// 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. +/// +public sealed class LegacyRefreshScheduler +{ + private readonly TimeSpan _recurringDelay; + private readonly Func _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 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; + + /// + /// Waits for the prior Play callback first, then starts the entire one-shot + /// delay. A false callback result prevents any delay or refresh dispatch. + /// + public async Task WaitForNextRefreshAsync( + Func> waitForPlayCompletionAsync, + Action? 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; + } + + /// + /// Executes at most one refresh at a time, even if the scheduler is used + /// incorrectly by more than one caller. + /// + public async Task ExecuteRefreshAsync( + Func> 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."); + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Properties/AssemblyInfo.cs b/src/MBN_STOCK_WEBVIEW.Core/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..d72da65 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.Core.Tests")] diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyRefreshEpochTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyRefreshEpochTests.cs new file mode 100644 index 0000000..6cccf48 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyRefreshEpochTests.cs @@ -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("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("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("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()); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyRefreshSchedulerTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyRefreshSchedulerTests.cs new file mode 100644 index 0000000..50d8355 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyRefreshSchedulerTests.cs @@ -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(); + 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(); + 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(() => 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(() => 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(); + var refreshEntered = NewCompletion(); + + 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(() => + 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 RunOneRefreshAsync( + LegacyRefreshScheduler scheduler, + Func> waitForPlayCompletionAsync, + Func> refreshAsync, + CancellationToken cancellationToken) + { + if (!await scheduler.WaitForNextRefreshAsync( + waitForPlayCompletionAsync, + null, + cancellationToken)) + { + return false; + } + + return await scheduler.ExecuteRefreshAsync(refreshAsync, cancellationToken); + } + + private static TaskCompletionSource NewCompletion() => + new(TaskCreationOptions.RunContinuationsAsynchronously); + + private sealed class ControlledDelay + { + private readonly object _gate = new(); + private readonly List _requests = []; + private TaskCompletionSource _requestAdded = NewCompletion(); + + public int CallCount + { + get + { + lock (_gate) + { + return _requests.Count; + } + } + } + + public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken) + { + DelayRequest request; + TaskCompletionSource requestAdded; + lock (_gate) + { + request = new DelayRequest(delay); + _requests.Add(request); + requestAdded = _requestAdded; + _requestAdded = NewCompletion(); + } + + requestAdded.TrySetResult(true); + return request.Release.Task.WaitAsync(cancellationToken); + } + + public async Task 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 release; + lock (_gate) + { + release = _requests[index].Release; + } + + release.SetResult(true); + } + + private sealed record DelayRequest( + TimeSpan Delay, + TaskCompletionSource Release) + { + public DelayRequest(TimeSpan delay) + : this(delay, NewCompletion()) + { + } + } + } +}