fix: await playout callbacks before refresh

This commit is contained in:
2026-07-11 05:03:31 +09:00
parent d8b9641a75
commit 3e3292b8a9
6 changed files with 358 additions and 32 deletions

View File

@@ -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)

View File

@@ -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.

View File

@@ -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.");

View File

@@ -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

View File

@@ -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()
{

View File

@@ -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]