fix: add truthful KTAP monitoring diagnostics
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user