fix: await playout callbacks before refresh
This commit is contained in:
@@ -250,6 +250,7 @@ public sealed partial class MainWindow
|
|||||||
message = result.Message,
|
message = result.Message,
|
||||||
preparedCode = status.PreparedSceneName,
|
preparedCode = status.PreparedSceneName,
|
||||||
onAirCode = status.OnAirSceneName,
|
onAirCode = status.OnAirSceneName,
|
||||||
|
playCompletionPending = status.IsPlayCompletionPending,
|
||||||
currentCueIndex = workflowState.CurrentCueIndexZeroBased,
|
currentCueIndex = workflowState.CurrentCueIndexZeroBased,
|
||||||
currentEntryId = workflowState.CurrentEntryId,
|
currentEntryId = workflowState.CurrentEntryId,
|
||||||
builderKey = workflowState.BuilderKey,
|
builderKey = workflowState.BuilderKey,
|
||||||
@@ -414,6 +415,7 @@ public sealed partial class MainWindow
|
|||||||
networkMonitoringCheckRequired = false,
|
networkMonitoringCheckRequired = false,
|
||||||
commandAvailable = false,
|
commandAvailable = false,
|
||||||
liveTakeInAllowed = false,
|
liveTakeInAllowed = false,
|
||||||
|
playCompletionPending = false,
|
||||||
outcomeUnknown = browserCorrelationQuarantined,
|
outcomeUnknown = browserCorrelationQuarantined,
|
||||||
browserCorrelationQuarantined,
|
browserCorrelationQuarantined,
|
||||||
operationTimeoutMilliseconds = 5000,
|
operationTimeoutMilliseconds = 5000,
|
||||||
@@ -460,6 +462,7 @@ public sealed partial class MainWindow
|
|||||||
networkMonitoringCheckRequired = false,
|
networkMonitoringCheckRequired = false,
|
||||||
commandAvailable = false,
|
commandAvailable = false,
|
||||||
liveTakeInAllowed = false,
|
liveTakeInAllowed = false,
|
||||||
|
playCompletionPending = false,
|
||||||
outcomeUnknown = browserCorrelationQuarantined,
|
outcomeUnknown = browserCorrelationQuarantined,
|
||||||
browserCorrelationQuarantined,
|
browserCorrelationQuarantined,
|
||||||
operationTimeoutMilliseconds = 5000,
|
operationTimeoutMilliseconds = 5000,
|
||||||
@@ -515,6 +518,7 @@ public sealed partial class MainWindow
|
|||||||
networkMonitoringCheckRequired = status.NetworkMonitoringCheckRequired,
|
networkMonitoringCheckRequired = status.NetworkMonitoringCheckRequired,
|
||||||
commandAvailable = status.IsCommandAvailable && !browserCorrelationQuarantined,
|
commandAvailable = status.IsCommandAvailable && !browserCorrelationQuarantined,
|
||||||
liveTakeInAllowed = status.LiveTakeInAllowed && !browserCorrelationQuarantined,
|
liveTakeInAllowed = status.LiveTakeInAllowed && !browserCorrelationQuarantined,
|
||||||
|
playCompletionPending = status.IsPlayCompletionPending,
|
||||||
outcomeUnknown = browserCorrelationQuarantined ||
|
outcomeUnknown = browserCorrelationQuarantined ||
|
||||||
status.State == PlayoutConnectionState.OutcomeUnknown,
|
status.State == PlayoutConnectionState.OutcomeUnknown,
|
||||||
browserCorrelationQuarantined,
|
browserCorrelationQuarantined,
|
||||||
@@ -569,6 +573,7 @@ public sealed partial class MainWindow
|
|||||||
networkMonitoringCheckRequired = status.NetworkMonitoringCheckRequired,
|
networkMonitoringCheckRequired = status.NetworkMonitoringCheckRequired,
|
||||||
commandAvailable = status.IsCommandAvailable && !browserCorrelationQuarantined,
|
commandAvailable = status.IsCommandAvailable && !browserCorrelationQuarantined,
|
||||||
liveTakeInAllowed = status.LiveTakeInAllowed && !browserCorrelationQuarantined,
|
liveTakeInAllowed = status.LiveTakeInAllowed && !browserCorrelationQuarantined,
|
||||||
|
playCompletionPending = status.IsPlayCompletionPending,
|
||||||
outcomeUnknown = browserCorrelationQuarantined ||
|
outcomeUnknown = browserCorrelationQuarantined ||
|
||||||
status.State == PlayoutConnectionState.OutcomeUnknown,
|
status.State == PlayoutConnectionState.OutcomeUnknown,
|
||||||
browserCorrelationQuarantined,
|
browserCorrelationQuarantined,
|
||||||
@@ -768,6 +773,28 @@ public sealed partial class MainWindow
|
|||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
await Task.Delay(nextDelay, token);
|
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);
|
await _playoutCommandGate.WaitAsync(token);
|
||||||
var databaseActivityEntered = false;
|
var databaseActivityEntered = false;
|
||||||
try
|
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()
|
private LegacyRefreshRuntimeStatus GetLegacyRefreshStatus()
|
||||||
{
|
{
|
||||||
lock (_legacyRefreshStatusGate)
|
lock (_legacyRefreshStatusGate)
|
||||||
|
|||||||
@@ -213,6 +213,13 @@ public sealed record PlayoutStatus(
|
|||||||
{
|
{
|
||||||
public int OperationTimeoutMilliseconds { get; init; } = 5_000;
|
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>
|
/// <summary>
|
||||||
/// True only after the adapter entered the KAEngine.KTAPConnect dispatch path.
|
/// True only after the adapter entered the KAEngine.KTAPConnect dispatch path.
|
||||||
/// Process discovery, registration probes, DryRun, and configuration preflight do not set it.
|
/// 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? _engine;
|
||||||
private object? _eventHandler;
|
private object? _eventHandler;
|
||||||
private object? _player;
|
private object? _player;
|
||||||
private object? _preparedScene;
|
private SceneReference? _preparedScene;
|
||||||
private object? _currentScene;
|
private SceneReference? _currentScene;
|
||||||
private readonly List<object> _retiredScenes = [];
|
private readonly List<SceneReference> _retiredScenes = [];
|
||||||
private readonly List<object> _pendingTakeOutScenes = [];
|
private readonly List<SceneReference> _pendingTakeOutScenes = [];
|
||||||
private int? _outputChannel;
|
private int? _outputChannel;
|
||||||
private int? _ownerThreadId;
|
private int? _ownerThreadId;
|
||||||
private int _lastKtapConnectState;
|
private int _lastKtapConnectState;
|
||||||
@@ -408,19 +408,21 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
|||||||
var engine = _engine!;
|
var engine = _engine!;
|
||||||
var player = _player!;
|
var player = _player!;
|
||||||
var outputChannel = _outputChannel;
|
var outputChannel = _outputChannel;
|
||||||
object? nextScene = null;
|
SceneReference? nextScene = null;
|
||||||
var transactionStarted = false;
|
var transactionStarted = false;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
nextScene = InvokeRequired(engine, "LoadScene", cue.SceneFile, cue.SceneName);
|
nextScene = new SceneReference(
|
||||||
|
InvokeRequired(engine, "LoadScene", cue.SceneFile, cue.SceneName),
|
||||||
|
cue.SceneName);
|
||||||
if (outputChannel.HasValue)
|
if (outputChannel.HasValue)
|
||||||
{
|
{
|
||||||
Invoke(nextScene, "SetOutputChannelIndex", outputChannel.GetValueOrDefault());
|
Invoke(nextScene.Value, "SetOutputChannelIndex", outputChannel.GetValueOrDefault());
|
||||||
}
|
}
|
||||||
|
|
||||||
Invoke(
|
Invoke(
|
||||||
nextScene,
|
nextScene.Value,
|
||||||
"SetSceneEffectType",
|
"SetSceneEffectType",
|
||||||
K3dComConstants.SceneEffectInEnabled,
|
K3dComConstants.SceneEffectInEnabled,
|
||||||
K3dComConstants.SceneChangeEffectFade,
|
K3dComConstants.SceneChangeEffectFade,
|
||||||
@@ -430,7 +432,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
|||||||
{
|
{
|
||||||
if (IsBeforeTransactionSceneSetupMutation(mutation))
|
if (IsBeforeTransactionSceneSetupMutation(mutation))
|
||||||
{
|
{
|
||||||
ApplySceneSetupMutation(nextScene, mutation);
|
ApplySceneSetupMutation(nextScene.Value, mutation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,18 +440,18 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
|||||||
transactionStarted = true;
|
transactionStarted = true;
|
||||||
foreach (var field in cue.Fields ?? [])
|
foreach (var field in cue.Fields ?? [])
|
||||||
{
|
{
|
||||||
ApplyField(nextScene, field);
|
ApplyField(nextScene.Value, field);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var mutation in cue.Mutations ?? [])
|
foreach (var mutation in cue.Mutations ?? [])
|
||||||
{
|
{
|
||||||
if (!IsBeforeTransactionSceneSetupMutation(mutation))
|
if (!IsBeforeTransactionSceneSetupMutation(mutation))
|
||||||
{
|
{
|
||||||
ApplyTransactionMutation(nextScene, mutation);
|
ApplyTransactionMutation(nextScene.Value, mutation);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Invoke(nextScene, "QueryVariables");
|
Invoke(nextScene.Value, "QueryVariables");
|
||||||
|
|
||||||
if (outputChannel.HasValue)
|
if (outputChannel.HasValue)
|
||||||
{
|
{
|
||||||
@@ -461,7 +463,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
|||||||
}
|
}
|
||||||
|
|
||||||
transactionStarted = false;
|
transactionStarted = false;
|
||||||
Invoke(player, "Prepare", layoutIndex, nextScene);
|
Invoke(player, "Prepare", layoutIndex, nextScene.Value);
|
||||||
|
|
||||||
var replacedPreparedScene = _preparedScene;
|
var replacedPreparedScene = _preparedScene;
|
||||||
_preparedScene = nextScene;
|
_preparedScene = nextScene;
|
||||||
@@ -503,7 +505,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
|||||||
var previousCurrentScene = _currentScene;
|
var previousCurrentScene = _currentScene;
|
||||||
_currentScene = preparedScene;
|
_currentScene = preparedScene;
|
||||||
if (previousCurrentScene is not null &&
|
if (previousCurrentScene is not null &&
|
||||||
!ReferenceEquals(previousCurrentScene, preparedScene))
|
!ReferenceEquals(previousCurrentScene.Value, preparedScene.Value))
|
||||||
{
|
{
|
||||||
RetainRetiredScene(previousCurrentScene);
|
RetainRetiredScene(previousCurrentScene);
|
||||||
}
|
}
|
||||||
@@ -586,10 +588,10 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
|||||||
// scene. An in-place page update must never retire/unload the active scene.
|
// scene. An in-place page update must never retire/unload the active scene.
|
||||||
if (_currentScene is null)
|
if (_currentScene is null)
|
||||||
{
|
{
|
||||||
_currentScene = playingScene;
|
_currentScene = new SceneReference(playingScene, cue.SceneName);
|
||||||
playingScene = null;
|
playingScene = null;
|
||||||
}
|
}
|
||||||
else if (ReferenceEquals(_currentScene, playingScene))
|
else if (ReferenceEquals(_currentScene.Value, playingScene))
|
||||||
{
|
{
|
||||||
playingScene = null;
|
playingScene = null;
|
||||||
}
|
}
|
||||||
@@ -1029,7 +1031,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
|||||||
|
|
||||||
private void ReleaseAll()
|
private void ReleaseAll()
|
||||||
{
|
{
|
||||||
var scenes = new List<object>();
|
var scenes = new List<SceneReference>();
|
||||||
AddDistinctScene(scenes, _preparedScene);
|
AddDistinctScene(scenes, _preparedScene);
|
||||||
_preparedScene = null;
|
_preparedScene = null;
|
||||||
AddDistinctScene(scenes, _currentScene);
|
AddDistinctScene(scenes, _currentScene);
|
||||||
@@ -1059,7 +1061,7 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
|||||||
|
|
||||||
foreach (var scene in scenes)
|
foreach (var scene in scenes)
|
||||||
{
|
{
|
||||||
ReleaseComObject(scene);
|
ReleaseComObject(scene.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
ReleaseComObject(player);
|
ReleaseComObject(player);
|
||||||
@@ -1067,12 +1069,12 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
|||||||
ReleaseComObject(eventHandler);
|
ReleaseComObject(eventHandler);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RetainRetiredScene(object? scene)
|
private void RetainRetiredScene(SceneReference? scene)
|
||||||
{
|
{
|
||||||
if (scene is not null &&
|
if (scene is not null &&
|
||||||
!ReferenceEquals(scene, _preparedScene) &&
|
!ReferenceEquals(scene.Value, _preparedScene?.Value) &&
|
||||||
!ReferenceEquals(scene, _currentScene) &&
|
!ReferenceEquals(scene.Value, _currentScene?.Value) &&
|
||||||
!_retiredScenes.Any(item => ReferenceEquals(item, scene)))
|
!_retiredScenes.Any(item => ReferenceEquals(item.Value, scene.Value)))
|
||||||
{
|
{
|
||||||
_retiredScenes.Add(scene);
|
_retiredScenes.Add(scene);
|
||||||
}
|
}
|
||||||
@@ -1092,16 +1094,38 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
|||||||
_retiredScenes.Clear();
|
_retiredScenes.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private int UnloadAndRelease(List<object> scenes)
|
private int UnloadAndRelease(List<SceneReference> scenes)
|
||||||
{
|
{
|
||||||
var unloaded = 0;
|
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)
|
while (scenes.Count > 0)
|
||||||
{
|
{
|
||||||
var scene = scenes[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);
|
scenes.RemoveAt(0);
|
||||||
ReleaseComObject(scene);
|
ReleaseComObject(scene.Value);
|
||||||
unloaded++;
|
if (shouldUnload)
|
||||||
|
{
|
||||||
|
unloaded++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return unloaded;
|
return unloaded;
|
||||||
@@ -1114,14 +1138,19 @@ internal sealed class DynamicK3dSession : IK3dGuardedConnectSession
|
|||||||
callback.LayerIndex == layoutIndex;
|
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);
|
scenes.Add(scene);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record SceneReference(object Value, string Name);
|
||||||
|
|
||||||
private object InvokeRequired(object target, string method, params object?[] arguments) =>
|
private object InvokeRequired(object target, string method, params object?[] arguments) =>
|
||||||
Invoke(target, method, arguments)
|
Invoke(target, method, arguments)
|
||||||
?? throw new InvalidOperationException("The K3D operation returned no object.");
|
?? throw new InvalidOperationException("The K3D operation returned no object.");
|
||||||
|
|||||||
@@ -1829,6 +1829,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
|||||||
Interlocked.Increment(ref _sequence))
|
Interlocked.Increment(ref _sequence))
|
||||||
{
|
{
|
||||||
OperationTimeoutMilliseconds = (int)_options.OperationTimeout.TotalMilliseconds,
|
OperationTimeoutMilliseconds = (int)_options.OperationTimeout.TotalMilliseconds,
|
||||||
|
IsPlayCompletionPending = _session?.HasPendingPlayCallbacks == true,
|
||||||
LastKtapConnectState = (PlayoutKtapConnectState)Volatile.Read(
|
LastKtapConnectState = (PlayoutKtapConnectState)Volatile.Read(
|
||||||
ref _lastKtapConnectState),
|
ref _lastKtapConnectState),
|
||||||
KtapHelloObserved = Volatile.Read(ref _ktapHelloObservedState) switch
|
KtapHelloObserved = Volatile.Read(ref _ktapHelloObservedState) switch
|
||||||
|
|||||||
@@ -681,6 +681,144 @@ public sealed class DynamicK3dSessionTests
|
|||||||
Assert.Equal(1, log.Names.Count(name => name == "Unload:5006"));
|
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]
|
[Fact]
|
||||||
public async Task StopAllSuccess_ClearsInterruptedPlayBeforeDisconnect()
|
public async Task StopAllSuccess_ClearsInterruptedPlayBeforeDisconnect()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1295,12 +1295,16 @@ public sealed class TornadoPlayoutEngineTests
|
|||||||
Assert.Null(engine.Status.OnAirSceneName);
|
Assert.Null(engine.Status.OnAirSceneName);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Theory]
|
||||||
public async Task Status_ExposesValidatedOperationTimeoutMilliseconds()
|
[InlineData(100)]
|
||||||
|
[InlineData(837)]
|
||||||
|
[InlineData(300_000)]
|
||||||
|
public async Task Status_ExposesValidatedOperationTimeoutMilliseconds(
|
||||||
|
int operationTimeoutMilliseconds)
|
||||||
{
|
{
|
||||||
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
|
||||||
var options = TestOptions(scenes.Path, "test-scene");
|
var options = TestOptions(scenes.Path, "test-scene");
|
||||||
options.OperationTimeoutMilliseconds = 837;
|
options.OperationTimeoutMilliseconds = operationTimeoutMilliseconds;
|
||||||
await using var engine = CreateEngine(
|
await using var engine = CreateEngine(
|
||||||
options,
|
options,
|
||||||
new FakeK3dSessionFactory(),
|
new FakeK3dSessionFactory(),
|
||||||
@@ -1308,7 +1312,7 @@ public sealed class TornadoPlayoutEngineTests
|
|||||||
new FakeTornadoProcessProbe(ProcessSnapshot("eligible-generation-a")),
|
new FakeTornadoProcessProbe(ProcessSnapshot("eligible-generation-a")),
|
||||||
liveAuthorized: false);
|
liveAuthorized: false);
|
||||||
|
|
||||||
Assert.Equal(837, engine.Status.OperationTimeoutMilliseconds);
|
Assert.Equal(operationTimeoutMilliseconds, engine.Status.OperationTimeoutMilliseconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -2186,6 +2190,64 @@ public sealed class TornadoPlayoutEngineTests
|
|||||||
Assert.Equal(1, session.Calls.Count(call => call == "ProcessPendingCallbacks"));
|
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]
|
[Fact]
|
||||||
public async Task PendingPlayCallback_BlocksDelayedMultiNextUntilEachCompletionIsDrained()
|
public async Task PendingPlayCallback_BlocksDelayedMultiNextUntilEachCompletionIsDrained()
|
||||||
{
|
{
|
||||||
@@ -2229,6 +2291,7 @@ public sealed class TornadoPlayoutEngineTests
|
|||||||
CancellationToken.None)).IsSuccess);
|
CancellationToken.None)).IsSuccess);
|
||||||
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
|
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
|
||||||
Assert.True(session.HasPendingPlayCallbacks);
|
Assert.True(session.HasPendingPlayCallbacks);
|
||||||
|
Assert.True(engine.Status.IsPlayCompletionPending);
|
||||||
|
|
||||||
var firstEarlyNext = await engine.NextAsync(
|
var firstEarlyNext = await engine.NextAsync(
|
||||||
Cue("5006"),
|
Cue("5006"),
|
||||||
@@ -2261,6 +2324,7 @@ public sealed class TornadoPlayoutEngineTests
|
|||||||
callbackReady = true;
|
callbackReady = true;
|
||||||
await engine.PollProcessOnceAsync(CancellationToken.None);
|
await engine.PollProcessOnceAsync(CancellationToken.None);
|
||||||
Assert.False(session.HasPendingPlayCallbacks);
|
Assert.False(session.HasPendingPlayCallbacks);
|
||||||
|
Assert.False(engine.Status.IsPlayCompletionPending);
|
||||||
Assert.True((await engine.TakeOutAsync(
|
Assert.True((await engine.TakeOutAsync(
|
||||||
PlayoutTakeOutScope.All,
|
PlayoutTakeOutScope.All,
|
||||||
CancellationToken.None)).IsSuccess);
|
CancellationToken.None)).IsSuccess);
|
||||||
@@ -2373,6 +2437,7 @@ public sealed class TornadoPlayoutEngineTests
|
|||||||
CancellationToken.None)).IsSuccess);
|
CancellationToken.None)).IsSuccess);
|
||||||
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
|
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
|
||||||
Assert.True(session.HasPendingPlayCallbacks);
|
Assert.True(session.HasPendingPlayCallbacks);
|
||||||
|
Assert.True(engine.Status.IsPlayCompletionPending);
|
||||||
|
|
||||||
var result = await engine.TakeOutAsync(
|
var result = await engine.TakeOutAsync(
|
||||||
PlayoutTakeOutScope.All,
|
PlayoutTakeOutScope.All,
|
||||||
@@ -2380,6 +2445,7 @@ public sealed class TornadoPlayoutEngineTests
|
|||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
Assert.Contains("TakeOut:All", session.Calls);
|
Assert.Contains("TakeOut:All", session.Calls);
|
||||||
|
Assert.True(engine.Status.IsPlayCompletionPending);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
Reference in New Issue
Block a user