fix: await playout callbacks before refresh
This commit is contained in:
@@ -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<bool> 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)
|
||||
|
||||
@@ -213,6 +213,13 @@ public sealed record PlayoutStatus(
|
||||
{
|
||||
public int OperationTimeoutMilliseconds { get; init; } = 5_000;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool IsPlayCompletionPending { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// True only after the adapter entered the KAEngine.KTAPConnect dispatch path.
|
||||
/// Process discovery, registration probes, DryRun, and configuration preflight do not set it.
|
||||
|
||||
@@ -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<object> _retiredScenes = [];
|
||||
private readonly List<object> _pendingTakeOutScenes = [];
|
||||
private SceneReference? _preparedScene;
|
||||
private SceneReference? _currentScene;
|
||||
private readonly List<SceneReference> _retiredScenes = [];
|
||||
private readonly List<SceneReference> _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<object>();
|
||||
var scenes = new List<SceneReference>();
|
||||
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<object> scenes)
|
||||
private int UnloadAndRelease(List<SceneReference> scenes)
|
||||
{
|
||||
var unloaded = 0;
|
||||
var activeSceneNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (_preparedScene is not null)
|
||||
{
|
||||
activeSceneNames.Add(_preparedScene.Name);
|
||||
}
|
||||
|
||||
if (_currentScene is not null)
|
||||
{
|
||||
activeSceneNames.Add(_currentScene.Name);
|
||||
}
|
||||
|
||||
var unloadedSceneNames = new HashSet<string>(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<object> scenes, object? scene)
|
||||
private static void AddDistinctScene(
|
||||
List<SceneReference> 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.");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<FakeScene>([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<FakeScene>([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()
|
||||
{
|
||||
|
||||
@@ -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<bool>();
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user