fix: add truthful KTAP monitoring diagnostics

This commit is contained in:
2026-07-10 12:17:42 +09:00
parent fc932b27f6
commit 306d3a70a1
15 changed files with 764 additions and 33 deletions

View File

@@ -45,6 +45,14 @@ public enum PlayoutResultCode
OutcomeUnknown
}
public enum PlayoutKtapConnectState
{
NotAttempted,
Attempted,
AcceptedUnconfirmed,
Failed
}
public enum PlayoutTakeOutScope
{
Layout,
@@ -83,6 +91,52 @@ public sealed record PlayoutStatus(
long Sequence)
{
public int OperationTimeoutMilliseconds { get; init; } = 5_000;
/// <summary>
/// True only after the adapter entered the KAEngine.KTAPConnect dispatch path.
/// Process discovery, registration probes, DryRun, and configuration preflight do not set it.
/// Local reflection or COM marshalling can still fail before a server receives anything.
/// </summary>
public PlayoutKtapConnectState LastKtapConnectState { get; init; } =
PlayoutKtapConnectState.NotAttempted;
public bool KtapConnectAttempted =>
LastKtapConnectState != PlayoutKtapConnectState.NotAttempted;
/// <summary>
/// True only when KTAPConnect returned the SDK success value (1). This does not prove that
/// OnHello was observed or that Tornado Network Monitoring contains matching [R]/[S] rows.
/// </summary>
public bool? KtapConnectAccepted => LastKtapConnectState switch
{
PlayoutKtapConnectState.AcceptedUnconfirmed => true,
PlayoutKtapConnectState.NotAttempted => false,
_ => null
};
/// <summary>
/// The late-bound adapter currently cannot observe the 282-method IKAEventHandler callback
/// interface without a separately validated interop strategy. Null therefore means that the
/// OnHello callback was not programmatically verified, not that Tornado rejected the request.
/// </summary>
public bool? KtapHelloObserved { get; init; }
/// <summary>
/// True only when the SDK returned its success value, false when no dispatch was attempted,
/// and null when a local/COM failure or timeout prevents a reliable prediction.
/// </summary>
public bool? NetworkMonitoringRecordExpected => LastKtapConnectState switch
{
PlayoutKtapConnectState.AcceptedUnconfirmed => true,
PlayoutKtapConnectState.NotAttempted => false,
_ => null
};
/// <summary>
/// Any KTAP dispatch attempt requires an operator to inspect the isolated Test instance,
/// even when the adapter cannot predict whether a matching record should exist.
/// </summary>
public bool NetworkMonitoringCheckRequired => KtapConnectAttempted;
}
public sealed class PlayoutStatusChangedEventArgs : EventArgs

View File

@@ -11,6 +11,10 @@ internal interface IK3dSession : IDisposable
{
bool IsConnected { get; }
PlayoutKtapConnectState LastKtapConnectState { get; }
bool TryPreventKtapConnect();
void Connect(ValidatedPlayoutOptions options);
void Disconnect();
@@ -87,6 +91,8 @@ internal sealed class DynamicK3dSession : IK3dSession
private object? _player;
private object? _scene;
private int? _ownerThreadId;
private int _lastKtapConnectState;
private int _ktapDispatchGate;
private bool _disposed;
public DynamicK3dSession(ILateBoundComActivator activator)
@@ -104,6 +110,23 @@ internal sealed class DynamicK3dSession : IK3dSession
public bool IsConnected => _engine is not null && _player is not null;
public PlayoutKtapConnectState LastKtapConnectState =>
(PlayoutKtapConnectState)Volatile.Read(ref _lastKtapConnectState);
public bool TryPreventKtapConnect()
{
var previous = Interlocked.CompareExchange(ref _ktapDispatchGate, 2, 0);
if (previous == 1)
{
Interlocked.CompareExchange(
ref _lastKtapConnectState,
(int)PlayoutKtapConnectState.Attempted,
(int)PlayoutKtapConnectState.NotAttempted);
}
return previous == 0;
}
public void Connect(ValidatedPlayoutOptions options)
{
ArgumentNullException.ThrowIfNull(options);
@@ -119,7 +142,14 @@ internal sealed class DynamicK3dSession : IK3dSession
{
_eventHandler = _activator.Create(K3dComConstants.KaEventHandlerClassGuid);
_engine = _activator.Create(K3dComConstants.KaEngineClassGuid);
if (Interlocked.CompareExchange(ref _ktapDispatchGate, 1, 0) != 0)
{
throw new InvalidOperationException(
"The K3D connection was abandoned before KTAP dispatch.");
}
ktapConnectAttempted = true;
SetKtapConnectState(PlayoutKtapConnectState.Attempted);
var result = Invoke(
_engine,
"KTAPConnect",
@@ -133,15 +163,23 @@ internal sealed class DynamicK3dSession : IK3dSession
: Convert.ToInt32(result, CultureInfo.InvariantCulture);
if (hresult != 1)
{
SetKtapConnectState(PlayoutKtapConnectState.Failed);
throw new InvalidOperationException("The K3D connection was rejected.");
}
SetKtapConnectState(PlayoutKtapConnectState.AcceptedUnconfirmed);
_player = options.OutputChannel is { } channel
? InvokeRequired(_engine, "GetScenePlayerOnChannel", channel)
: InvokeRequired(_engine, "GetScenePlayer");
}
catch (Exception exception)
{
if (LastKtapConnectState == PlayoutKtapConnectState.Attempted)
{
SetKtapConnectState(PlayoutKtapConnectState.Failed);
}
var originalFailure = ExceptionDispatchInfo.Capture(exception);
if (ktapConnectAttempted && _engine is not null)
{
@@ -161,6 +199,9 @@ internal sealed class DynamicK3dSession : IK3dSession
}
}
private void SetKtapConnectState(PlayoutKtapConnectState state) =>
Volatile.Write(ref _lastKtapConnectState, (int)state);
public void Disconnect()
{
EnsureThread();
@@ -277,6 +318,7 @@ internal sealed class DynamicK3dSession : IK3dSession
}
EnsureThread();
TryPreventKtapConnect();
_disposed = true;
ReleaseAll();
}

View File

@@ -33,6 +33,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
private DateTimeOffset _nextReconnectAt;
private int _reconnectAttempts;
private long _sequence;
private int _lastKtapConnectState;
private bool _connectionRequested;
private volatile bool _outcomeUnknown;
private volatile bool _disposed;
@@ -215,7 +216,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
{
if (_processSnapshot != previousSnapshot)
{
PublishStatus("송출 dry-run이 준비되었습니다.");
PublishStatus(DryRunMonitoringMessage);
}
return;
@@ -378,11 +379,12 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
if (_options.Mode == PlayoutMode.DryRun)
{
_connectionState = PlayoutConnectionState.DryRunReady;
PublishStatus("송출 dry-run이 준비되었습니다.");
var message = DryRunOperationMessage("연결 요청을 확인했습니다.");
PublishStatus(message);
return Task.FromResult(Result(
PlayoutOperation.Connect,
PlayoutResultCode.Success,
"송출 연결을 dry-run으로 확인했습니다."));
message));
}
_connectionRequested = true;
@@ -500,7 +502,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
() =>
{
var session = _sessionFactory.Create();
pendingSession = session;
Volatile.Write(ref pendingSession, session);
try
{
session.Connect(_options);
@@ -515,7 +517,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
_options.ConnectTimeout,
cancellationToken,
static lateSession => lateSession.Dispose(),
() => pendingSession?.Dispose()).ConfigureAwait(false);
() => Volatile.Read(ref pendingSession)?.Dispose()).ConfigureAwait(false);
CaptureKtapEvidence(_session);
_processSnapshot = CaptureProcessSnapshot();
if (!IsProcessEligible(_processSnapshot) ||
@@ -545,25 +549,32 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
_reconnectAttempts = 0;
_preparedCue = null;
_onAirSceneName = null;
PublishStatus("Tornado 송출에 연결되었습니다.");
return Result(PlayoutOperation.Connect, PlayoutResultCode.Success, "송출 연결이 준비되었습니다.");
PublishStatus("KTAPConnect가 수락되었습니다. OnHello 및 Network Monitoring [R]/[S]는 아직 확인되지 않았습니다.");
return Result(
PlayoutOperation.Connect,
PlayoutResultCode.Success,
"KTAPConnect 요청이 수락되었습니다. 서버 콜백은 운영자 확인이 필요합니다.");
}
catch (OperationCanceledException)
{
PreventLateKtapDispatchAndCapture(Volatile.Read(ref pendingSession));
_connectionState = PlayoutConnectionState.Disconnected;
PublishStatus("송출 연결 요청이 취소되었습니다.");
return Result(PlayoutOperation.Connect, PlayoutResultCode.Cancelled, "송출 요청이 취소되었습니다.");
}
catch (StaOperationTimedOutException)
{
PreventLateKtapDispatchAndCapture(Volatile.Read(ref pendingSession));
return MarkOutcomeUnknown(PlayoutOperation.Connect);
}
catch (StaDispatcherQuarantinedException)
{
PreventLateKtapDispatchAndCapture(Volatile.Read(ref pendingSession));
return MarkOutcomeUnknown(PlayoutOperation.Connect);
}
catch (Exception)
{
CaptureKtapEvidence(Volatile.Read(ref pendingSession));
_session = null;
ScheduleReconnect();
_connectionState = reconnecting
@@ -588,8 +599,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
_preparedCue = null;
_onAirSceneName = null;
_connectionState = PlayoutConnectionState.DryRunReady;
PublishStatus("송출 dry-run이 준비되었습니다.");
return Result(PlayoutOperation.Disconnect, PlayoutResultCode.Success, "송출 해제를 dry-run으로 확인했습니다.");
var message = DryRunOperationMessage("연결 해제를 확인했습니다.");
PublishStatus(message);
return Result(PlayoutOperation.Disconnect, PlayoutResultCode.Success, message);
}
if (_outcomeUnknown || _dispatcher?.IsQuarantined == true)
@@ -627,8 +639,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
if (_options.Mode == PlayoutMode.DryRun)
{
_preparedCue = cue with { SceneName = SafeSceneName(cue) };
PublishStatus("장면 준비를 dry-run으로 확인했습니다.");
return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Success, "장면 준비를 dry-run으로 확인했습니다."));
var message = DryRunOperationMessage("장면 준비를 확인했습니다.");
PublishStatus(message);
return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Success, message));
}
if (_options.Mode == PlayoutMode.Live && !IsLiveAuthorized())
@@ -698,8 +711,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
{
_onAirSceneName = SafeSceneName(_preparedCue);
_preparedCue = null;
PublishStatus("TAKE IN을 dry-run으로 확인했습니다.");
return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Success, "TAKE IN을 dry-run으로 확인했습니다.");
var message = DryRunOperationMessage("TAKE IN을 확인했습니다.");
PublishStatus(message);
return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Success, message);
}
if (!CanTakeIn())
@@ -754,8 +768,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
{
_onAirSceneName = SafeSceneName(cue);
_preparedCue = null;
PublishStatus("NEXT를 dry-run으로 확인했습니다.");
return Result(PlayoutOperation.Next, PlayoutResultCode.Success, "NEXT를 dry-run으로 확인했습니다.");
var message = DryRunOperationMessage("NEXT를 확인했습니다.");
PublishStatus(message);
return Result(PlayoutOperation.Next, PlayoutResultCode.Success, message);
}
if (!CanTakeIn())
@@ -822,8 +837,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
{
_preparedCue = null;
_onAirSceneName = null;
PublishStatus("TAKE OUT을 dry-run으로 확인했습니다.");
return Result(PlayoutOperation.TakeOut, PlayoutResultCode.Success, "TAKE OUT을 dry-run으로 확인했습니다.");
var message = DryRunOperationMessage("TAKE OUT을 확인했습니다.");
PublishStatus(message);
return Result(PlayoutOperation.TakeOut, PlayoutResultCode.Success, message);
}
if (_options.Mode == PlayoutMode.Live && !IsLiveAuthorized())
@@ -1285,10 +1301,38 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
_timeProvider.GetUtcNow(),
Interlocked.Increment(ref _sequence))
{
OperationTimeoutMilliseconds = (int)_options.OperationTimeout.TotalMilliseconds
OperationTimeoutMilliseconds = (int)_options.OperationTimeout.TotalMilliseconds,
LastKtapConnectState = (PlayoutKtapConnectState)Volatile.Read(
ref _lastKtapConnectState),
KtapHelloObserved = null
};
}
private void CaptureKtapEvidence(IK3dSession? session)
{
if (session is null)
{
return;
}
var state = session.LastKtapConnectState;
if (state != PlayoutKtapConnectState.NotAttempted)
{
Volatile.Write(ref _lastKtapConnectState, (int)state);
}
}
private void PreventLateKtapDispatchAndCapture(IK3dSession? session)
{
if (session is null)
{
return;
}
session.TryPreventKtapConnect();
CaptureKtapEvidence(session);
}
private PlayoutResult Result(
PlayoutOperation operation,
PlayoutResultCode code,
@@ -1323,7 +1367,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
private string CurrentMessage() => _connectionState switch
{
PlayoutConnectionState.Connected => "Tornado 송출에 연결되었습니다.",
PlayoutConnectionState.Connected =>
"KTAPConnect가 수락되었습니다. OnHello 및 Network Monitoring [R]/[S]는 아직 확인되지 않았습니다.",
PlayoutConnectionState.Reconnecting => "Tornado 송출에 다시 연결하는 중입니다.",
PlayoutConnectionState.Faulted => "Tornado 송출 연결을 확인하세요.",
_ => InitialMessage(_options.Mode)
@@ -1332,10 +1377,16 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
private static string InitialMessage(PlayoutMode mode) => mode switch
{
PlayoutMode.Disabled => "송출 기능이 비활성화되어 있습니다.",
PlayoutMode.DryRun => "송출 dry-run이 준비되었습니다.",
PlayoutMode.DryRun => DryRunMonitoringMessage,
_ => "Tornado 송출 연결이 필요합니다."
};
private const string DryRunMonitoringMessage =
"DryRun: COM/KTAPConnect를 호출하지 않았습니다. Network Monitoring 기록 없음이 정상입니다.";
private static string DryRunOperationMessage(string operation) =>
$"DryRun: {operation} COM/KTAPConnect는 호출하지 않았으며 Network Monitoring 기록 없음이 정상입니다.";
private string ProcessUnavailableMessage(TornadoProcessSnapshot snapshot)
{
if (_options.Mode == PlayoutMode.Test)