diff --git a/MainWindow.Playout.cs b/MainWindow.Playout.cs index 792537c..4fa88c1 100644 --- a/MainWindow.Playout.cs +++ b/MainWindow.Playout.cs @@ -250,6 +250,7 @@ public sealed partial class MainWindow message = result.Message, preparedCode = status.PreparedSceneName, onAirCode = status.OnAirSceneName, + playCompletionPending = status.IsPlayCompletionPending, currentCueIndex = workflowState.CurrentCueIndexZeroBased, currentEntryId = workflowState.CurrentEntryId, builderKey = workflowState.BuilderKey, @@ -414,6 +415,7 @@ public sealed partial class MainWindow networkMonitoringCheckRequired = false, commandAvailable = false, liveTakeInAllowed = false, + playCompletionPending = false, outcomeUnknown = browserCorrelationQuarantined, browserCorrelationQuarantined, operationTimeoutMilliseconds = 5000, @@ -460,6 +462,7 @@ public sealed partial class MainWindow networkMonitoringCheckRequired = false, commandAvailable = false, liveTakeInAllowed = false, + playCompletionPending = false, outcomeUnknown = browserCorrelationQuarantined, browserCorrelationQuarantined, operationTimeoutMilliseconds = 5000, @@ -515,6 +518,7 @@ public sealed partial class MainWindow networkMonitoringCheckRequired = status.NetworkMonitoringCheckRequired, commandAvailable = status.IsCommandAvailable && !browserCorrelationQuarantined, liveTakeInAllowed = status.LiveTakeInAllowed && !browserCorrelationQuarantined, + playCompletionPending = status.IsPlayCompletionPending, outcomeUnknown = browserCorrelationQuarantined || status.State == PlayoutConnectionState.OutcomeUnknown, browserCorrelationQuarantined, @@ -569,6 +573,7 @@ public sealed partial class MainWindow networkMonitoringCheckRequired = status.NetworkMonitoringCheckRequired, commandAvailable = status.IsCommandAvailable && !browserCorrelationQuarantined, liveTakeInAllowed = status.LiveTakeInAllowed && !browserCorrelationQuarantined, + playCompletionPending = status.IsPlayCompletionPending, outcomeUnknown = browserCorrelationQuarantined || status.State == PlayoutConnectionState.OutcomeUnknown, browserCorrelationQuarantined, @@ -768,6 +773,28 @@ public sealed partial class MainWindow while (true) { await Task.Delay(nextDelay, token); + + var engine = _playoutEngine; + if (engine is null || + !ReferenceEquals(_playoutWorkflow, workflow) || + !ReferenceEquals(_legacyRefreshCancellation, 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)) + { + SetLegacyRefreshFault( + "PLAY_CALLBACK_TIMEOUT", + "Tornado 재생 완료 이벤트를 제한 시간 안에 확인하지 못해 자동 장면 갱신을 중단했습니다. TAKE OUT 후 실제 화면을 확인하세요."); + QueuePlayoutStatus(); + return; + } + await _playoutCommandGate.WaitAsync(token); var databaseActivityEntered = false; try @@ -856,6 +883,64 @@ public sealed partial class MainWindow } } + private static async Task WaitForPlayCompletionAsync( + IPlayoutEngine engine, + CancellationToken cancellationToken) + { + if (!engine.Status.IsPlayCompletionPending) + { + return true; + } + + var timeoutMilliseconds = Math.Clamp( + engine.Status.OperationTimeoutMilliseconds, + 100, + 300_000); + using var timeoutCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + timeoutCancellation.CancelAfter(timeoutMilliseconds); + + while (engine.Status.IsPlayCompletionPending) + { + var statusChanged = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + void OnStatusChanged(object? sender, PlayoutStatusChangedEventArgs args) + { + if (!args.Current.IsPlayCompletionPending) + { + statusChanged.TrySetResult(); + } + } + + engine.StatusChanged += OnStatusChanged; + try + { + // Close the subscribe-after-read race without causing an engine call. + if (!engine.Status.IsPlayCompletionPending) + { + return true; + } + + try + { + await statusChanged.Task.WaitAsync(timeoutCancellation.Token); + } + catch (OperationCanceledException) + when (!cancellationToken.IsCancellationRequested) + { + return false; + } + } + finally + { + engine.StatusChanged -= OnStatusChanged; + } + } + + return true; + } + private LegacyRefreshRuntimeStatus GetLegacyRefreshStatus() { lock (_legacyRefreshStatusGate) diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs index fc8bad3..75af4d6 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs @@ -213,6 +213,13 @@ public sealed record PlayoutStatus( { public int OperationTimeoutMilliseconds { get; init; } = 5_000; + /// + /// True while Tornado has accepted an SDK Play call but has not yet delivered the + /// corresponding scene-played completion callback. This is observation-only state; + /// callers must not retry or infer completion by issuing another scene mutation. + /// + public bool IsPlayCompletionPending { get; init; } + /// /// True only after the adapter entered the KAEngine.KTAPConnect dispatch path. /// Process discovery, registration probes, DryRun, and configuration preflight do not set it. diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs index bacd1c3..5efa714 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs @@ -157,10 +157,10 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession private object? _engine; private object? _eventHandler; private object? _player; - private object? _preparedScene; - private object? _currentScene; - private readonly List _retiredScenes = []; - private readonly List _pendingTakeOutScenes = []; + private SceneReference? _preparedScene; + private SceneReference? _currentScene; + private readonly List _retiredScenes = []; + private readonly List _pendingTakeOutScenes = []; private int? _outputChannel; private int? _ownerThreadId; private int _lastKtapConnectState; @@ -408,19 +408,21 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession var engine = _engine!; var player = _player!; var outputChannel = _outputChannel; - object? nextScene = null; + SceneReference? nextScene = null; var transactionStarted = false; try { - nextScene = InvokeRequired(engine, "LoadScene", cue.SceneFile, cue.SceneName); + nextScene = new SceneReference( + InvokeRequired(engine, "LoadScene", cue.SceneFile, cue.SceneName), + cue.SceneName); if (outputChannel.HasValue) { - Invoke(nextScene, "SetOutputChannelIndex", outputChannel.GetValueOrDefault()); + Invoke(nextScene.Value, "SetOutputChannelIndex", outputChannel.GetValueOrDefault()); } Invoke( - nextScene, + nextScene.Value, "SetSceneEffectType", K3dComConstants.SceneEffectInEnabled, K3dComConstants.SceneChangeEffectFade, @@ -430,7 +432,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession { if (IsBeforeTransactionSceneSetupMutation(mutation)) { - ApplySceneSetupMutation(nextScene, mutation); + ApplySceneSetupMutation(nextScene.Value, mutation); } } @@ -438,18 +440,18 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession transactionStarted = true; foreach (var field in cue.Fields ?? []) { - ApplyField(nextScene, field); + ApplyField(nextScene.Value, field); } foreach (var mutation in cue.Mutations ?? []) { if (!IsBeforeTransactionSceneSetupMutation(mutation)) { - ApplyTransactionMutation(nextScene, mutation); + ApplyTransactionMutation(nextScene.Value, mutation); } } - Invoke(nextScene, "QueryVariables"); + Invoke(nextScene.Value, "QueryVariables"); if (outputChannel.HasValue) { @@ -461,7 +463,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession } transactionStarted = false; - Invoke(player, "Prepare", layoutIndex, nextScene); + Invoke(player, "Prepare", layoutIndex, nextScene.Value); var replacedPreparedScene = _preparedScene; _preparedScene = nextScene; @@ -503,7 +505,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession var previousCurrentScene = _currentScene; _currentScene = preparedScene; if (previousCurrentScene is not null && - !ReferenceEquals(previousCurrentScene, preparedScene)) + !ReferenceEquals(previousCurrentScene.Value, preparedScene.Value)) { RetainRetiredScene(previousCurrentScene); } @@ -586,10 +588,10 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession // scene. An in-place page update must never retire/unload the active scene. if (_currentScene is null) { - _currentScene = playingScene; + _currentScene = new SceneReference(playingScene, cue.SceneName); playingScene = null; } - else if (ReferenceEquals(_currentScene, playingScene)) + else if (ReferenceEquals(_currentScene.Value, playingScene)) { playingScene = null; } @@ -1029,7 +1031,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession private void ReleaseAll() { - var scenes = new List(); + var scenes = new List(); AddDistinctScene(scenes, _preparedScene); _preparedScene = null; AddDistinctScene(scenes, _currentScene); @@ -1059,7 +1061,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession foreach (var scene in scenes) { - ReleaseComObject(scene); + ReleaseComObject(scene.Value); } ReleaseComObject(player); @@ -1067,12 +1069,12 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession ReleaseComObject(eventHandler); } - private void RetainRetiredScene(object? scene) + private void RetainRetiredScene(SceneReference? scene) { if (scene is not null && - !ReferenceEquals(scene, _preparedScene) && - !ReferenceEquals(scene, _currentScene) && - !_retiredScenes.Any(item => ReferenceEquals(item, scene))) + !ReferenceEquals(scene.Value, _preparedScene?.Value) && + !ReferenceEquals(scene.Value, _currentScene?.Value) && + !_retiredScenes.Any(item => ReferenceEquals(item.Value, scene.Value))) { _retiredScenes.Add(scene); } @@ -1092,16 +1094,38 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession _retiredScenes.Clear(); } - private int UnloadAndRelease(List scenes) + private int UnloadAndRelease(List scenes) { var unloaded = 0; + var activeSceneNames = new HashSet(StringComparer.OrdinalIgnoreCase); + if (_preparedScene is not null) + { + activeSceneNames.Add(_preparedScene.Name); + } + + if (_currentScene is not null) + { + activeSceneNames.Add(_currentScene.Name); + } + + var unloadedSceneNames = new HashSet(StringComparer.OrdinalIgnoreCase); while (scenes.Count > 0) { var scene = scenes[0]; - Invoke(scene, "Unload"); + var shouldUnload = + !activeSceneNames.Contains(scene.Name) && + unloadedSceneNames.Add(scene.Name); + if (shouldUnload) + { + Invoke(scene.Value, "Unload"); + } + scenes.RemoveAt(0); - ReleaseComObject(scene); - unloaded++; + ReleaseComObject(scene.Value); + if (shouldUnload) + { + unloaded++; + } } return unloaded; @@ -1114,14 +1138,19 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession callback.LayerIndex == layoutIndex; } - private static void AddDistinctScene(List scenes, object? scene) + private static void AddDistinctScene( + List scenes, + SceneReference? scene) { - if (scene is not null && !scenes.Any(item => ReferenceEquals(item, scene))) + if (scene is not null && + !scenes.Any(item => ReferenceEquals(item.Value, scene.Value))) { scenes.Add(scene); } } + private sealed record SceneReference(object Value, string Name); + private object InvokeRequired(object target, string method, params object?[] arguments) => Invoke(target, method, arguments) ?? throw new InvalidOperationException("The K3D operation returned no object."); diff --git a/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs b/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs index 88a11b0..a31ee26 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs @@ -1829,6 +1829,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine Interlocked.Increment(ref _sequence)) { OperationTimeoutMilliseconds = (int)_options.OperationTimeout.TotalMilliseconds, + IsPlayCompletionPending = _session?.HasPendingPlayCallbacks == true, LastKtapConnectState = (PlayoutKtapConnectState)Volatile.Read( ref _lastKtapConnectState), KtapHelloObserved = Volatile.Read(ref _ktapHelloObservedState) switch diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs index 117679c..2454063 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs @@ -681,6 +681,144 @@ public sealed class DynamicK3dSessionTests Assert.Equal(1, log.Names.Count(name => name == "Unload:5006")); } + [Fact] + public async Task LifecycleCallbacks_SameNameReplacement_UnloadsOnlyAfterStopAll() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var options = SceneOptions(scenes.Path, "5001"); + var log = new FakeComLog(); + var player = new FakePlayer(log); + var retiredScene = new FakeScene(log, "5001"); + var activeScene = new FakeScene(log, "5001"); + var scenesToLoad = new Queue([retiredScene, activeScene]); + var engine = new FakeEngine(log, player, retiredScene) + { + SceneResolver = _ => scenesToLoad.Dequeue() + }; + var activator = new FakeActivator(log, engine); + var callbacks = new RecordingEventHandlerFactory(); + var releaser = new FakeReleaser( + log, + (retiredScene, "5001-retired"), + (activeScene, "5001-active"), + (player, "Player"), + (engine, "Engine"), + (callbacks.Handler, "EventHandler")); + await using var dispatcher = new StaDispatcher(capacity: 3); + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession( + activator, + releaser, + new InstalledK3dInteropMethodInvoker(), + callbacks); + session.Connect(options); + + session.Prepare(SceneCue(options, "5001"), options.LayoutIndex); + session.Play(options.LayoutIndex); + callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex); + Assert.Equal( + 0, + session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); + + session.Prepare(SceneCue(options, "5001"), options.LayoutIndex); + session.Play(options.LayoutIndex); + callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex); + Assert.Equal( + 0, + session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); + Assert.DoesNotContain("Unload:5001", log.Names); + Assert.Contains("Release:5001-retired", log.Names); + Assert.DoesNotContain("Release:5001-active", log.Names); + + session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All); + callbacks.Fire("OnStopAll", 1); + Assert.Equal( + 1, + session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); + Assert.Equal(1, log.Names.Count(name => name == "Unload:5001")); + Assert.Contains("Release:5001-active", log.Names); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.Empty(scenesToLoad); + Assert.Equal(1, log.Names.Count(name => name == "Unload:5001")); + Assert.Equal(1, log.Names.Count(name => name == "Release:5001-retired")); + Assert.Equal(1, log.Names.Count(name => name == "Release:5001-active")); + } + + [Fact] + public async Task LifecycleCallbacks_SameNameReplacement_IgnoresSceneNameCasing() + { + using var scenes = TemporarySceneDirectory.Create("SceneA.t2s"); + var options = SceneOptions(scenes.Path, "SceneA"); + var log = new FakeComLog(); + var player = new FakePlayer(log); + var retiredScene = new FakeScene(log, "SceneA"); + var activeScene = new FakeScene(log, "scenea"); + var scenesToLoad = new Queue([retiredScene, activeScene]); + var engine = new FakeEngine(log, player, retiredScene) + { + SceneResolver = _ => scenesToLoad.Dequeue() + }; + var activator = new FakeActivator(log, engine); + var callbacks = new RecordingEventHandlerFactory(); + var releaser = new FakeReleaser( + log, + (retiredScene, "SceneA-retired"), + (activeScene, "scenea-active"), + (player, "Player"), + (engine, "Engine"), + (callbacks.Handler, "EventHandler")); + await using var dispatcher = new StaDispatcher(capacity: 3); + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession( + activator, + releaser, + new InstalledK3dInteropMethodInvoker(), + callbacks); + session.Connect(options); + + session.Prepare(SceneCue(options, "SceneA"), options.LayoutIndex); + session.Play(options.LayoutIndex); + callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex); + session.ProcessPendingCallbacks(options.LayoutIndex); + + session.Prepare(SceneCue(options, "scenea"), options.LayoutIndex); + session.Play(options.LayoutIndex); + callbacks.Fire("OnScenePlayed", 1, 9, options.LayoutIndex); + Assert.Equal( + 0, + session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); + Assert.DoesNotContain("Unload:SceneA", log.Names); + Assert.Contains("Release:SceneA-retired", log.Names); + + session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All); + callbacks.Fire("OnStopAll", 1); + Assert.Equal( + 1, + session.ProcessPendingCallbacks(options.LayoutIndex).UnloadedSceneCount); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.Empty(scenesToLoad); + Assert.DoesNotContain("Unload:SceneA", log.Names); + Assert.Equal(1, log.Names.Count(name => name == "Unload:scenea")); + Assert.Equal(1, log.Names.Count(name => name == "Release:SceneA-retired")); + Assert.Equal(1, log.Names.Count(name => name == "Release:scenea-active")); + } + [Fact] public async Task StopAllSuccess_ClearsInterruptedPlayBeforeDisconnect() { diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs index e69237f..2dc17ea 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs @@ -1295,12 +1295,16 @@ public sealed class TornadoPlayoutEngineTests Assert.Null(engine.Status.OnAirSceneName); } - [Fact] - public async Task Status_ExposesValidatedOperationTimeoutMilliseconds() + [Theory] + [InlineData(100)] + [InlineData(837)] + [InlineData(300_000)] + public async Task Status_ExposesValidatedOperationTimeoutMilliseconds( + int operationTimeoutMilliseconds) { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var options = TestOptions(scenes.Path, "test-scene"); - options.OperationTimeoutMilliseconds = 837; + options.OperationTimeoutMilliseconds = operationTimeoutMilliseconds; await using var engine = CreateEngine( options, new FakeK3dSessionFactory(), @@ -1308,7 +1312,7 @@ public sealed class TornadoPlayoutEngineTests new FakeTornadoProcessProbe(ProcessSnapshot("eligible-generation-a")), liveAuthorized: false); - Assert.Equal(837, engine.Status.OperationTimeoutMilliseconds); + Assert.Equal(operationTimeoutMilliseconds, engine.Status.OperationTimeoutMilliseconds); } [Fact] @@ -2186,6 +2190,64 @@ public sealed class TornadoPlayoutEngineTests Assert.Equal(1, session.Calls.Count(call => call == "ProcessPendingCallbacks")); } + [Fact] + public async Task PlayCompletionPending_StatusClearsOnlyAfterCallbackDrain() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var callbackReady = false; + var session = new FakeK3dSession + { + SupportsLifecycleCallbacks = true, + ClearPendingLifecycleCallbacksAfterProcess = false + }; + session.PlayAction = _ => + { + session.HasPendingPlayCallbacks = true; + session.HasPendingLifecycleCallbacks = true; + }; + session.ProcessPendingCallbacksAction = _ => + { + if (!callbackReady) + { + return new K3dCallbackDrainResult(0, 0, false); + } + + session.HasPendingPlayCallbacks = false; + session.HasPendingLifecycleCallbacks = false; + return new K3dCallbackDrainResult(1, 1, false); + }; + await using var engine = CreateEngine( + TestOptions(scenes.Path, "5001"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.PrepareAsync( + Cue("5001"), + CancellationToken.None)).IsSuccess); + var observedPendingStates = new List(); + engine.StatusChanged += (_, args) => + observedPendingStates.Add(args.Current.IsPlayCompletionPending); + + Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); + await engine.PollProcessOnceAsync(CancellationToken.None); + + Assert.True(engine.Status.IsPlayCompletionPending); + Assert.Equal([true, true], observedPendingStates); + + callbackReady = true; + await engine.PollProcessOnceAsync(CancellationToken.None); + + Assert.False(engine.Status.IsPlayCompletionPending); + Assert.Equal([true, true, false], observedPendingStates); + Assert.True((await engine.TakeOutAsync( + PlayoutTakeOutScope.All, + CancellationToken.None)).IsSuccess); + Assert.True((await engine.DisconnectAsync(CancellationToken.None)).IsSuccess); + } + [Fact] public async Task PendingPlayCallback_BlocksDelayedMultiNextUntilEachCompletionIsDrained() { @@ -2229,6 +2291,7 @@ public sealed class TornadoPlayoutEngineTests CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); Assert.True(session.HasPendingPlayCallbacks); + Assert.True(engine.Status.IsPlayCompletionPending); var firstEarlyNext = await engine.NextAsync( Cue("5006"), @@ -2261,6 +2324,7 @@ public sealed class TornadoPlayoutEngineTests callbackReady = true; await engine.PollProcessOnceAsync(CancellationToken.None); Assert.False(session.HasPendingPlayCallbacks); + Assert.False(engine.Status.IsPlayCompletionPending); Assert.True((await engine.TakeOutAsync( PlayoutTakeOutScope.All, CancellationToken.None)).IsSuccess); @@ -2373,6 +2437,7 @@ public sealed class TornadoPlayoutEngineTests CancellationToken.None)).IsSuccess); Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); Assert.True(session.HasPendingPlayCallbacks); + Assert.True(engine.Status.IsPlayCompletionPending); var result = await engine.TakeOutAsync( PlayoutTakeOutScope.All, @@ -2380,6 +2445,7 @@ public sealed class TornadoPlayoutEngineTests Assert.True(result.IsSuccess); Assert.Contains("TakeOut:All", session.Calls); + Assert.True(engine.Status.IsPlayCompletionPending); } [Theory]