feat: verify Tornado PGM KTAP connection
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Diagnostics;
|
||||
|
||||
internal interface IKtapConnectOnlySession
|
||||
{
|
||||
bool ComActivationAttempted { get; }
|
||||
|
||||
PlayoutKtapConnectState LastKtapConnectState { get; }
|
||||
|
||||
bool TryPreventKtapConnect();
|
||||
|
||||
void Connect(string host, int port, Func<bool> finalSafetyCheck);
|
||||
|
||||
void Disconnect();
|
||||
|
||||
void Abandon();
|
||||
}
|
||||
|
||||
internal interface IKtapConnectOnlySessionFactory
|
||||
{
|
||||
IKtapConnectOnlySession Create();
|
||||
}
|
||||
|
||||
internal sealed class DynamicKtapConnectOnlySessionFactory : IKtapConnectOnlySessionFactory
|
||||
{
|
||||
private readonly IK3dConnectOnlyComBindingFactory _bindingFactory;
|
||||
|
||||
public DynamicKtapConnectOnlySessionFactory()
|
||||
: this(new InstalledK3dInteropConnectBindingFactory())
|
||||
{
|
||||
}
|
||||
|
||||
internal DynamicKtapConnectOnlySessionFactory(IK3dConnectOnlyComBindingFactory bindingFactory)
|
||||
{
|
||||
_bindingFactory = bindingFactory;
|
||||
}
|
||||
|
||||
public IKtapConnectOnlySession Create() =>
|
||||
new DynamicKtapConnectOnlySession(_bindingFactory.Create());
|
||||
}
|
||||
|
||||
internal sealed class DynamicKtapConnectOnlySession : IKtapConnectOnlySession
|
||||
{
|
||||
private readonly IK3dConnectOnlyComBinding _binding;
|
||||
private object? _engine;
|
||||
private object? _eventHandler;
|
||||
private int? _ownerThreadId;
|
||||
private int _lastKtapConnectState;
|
||||
private int _ktapDispatchGate;
|
||||
private bool _accepted;
|
||||
private bool _abandoned;
|
||||
|
||||
public DynamicKtapConnectOnlySession(IK3dConnectOnlyComBinding binding)
|
||||
{
|
||||
_binding = binding;
|
||||
}
|
||||
|
||||
public bool ComActivationAttempted { get; private set; }
|
||||
|
||||
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(string host, int port, Func<bool> finalSafetyCheck)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(finalSafetyCheck);
|
||||
EnsureThread();
|
||||
if (_abandoned || _engine is not null || _accepted)
|
||||
{
|
||||
throw new InvalidOperationException("The connection-only session is unavailable.");
|
||||
}
|
||||
|
||||
if (Volatile.Read(ref _ktapDispatchGate) != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The connection-only diagnostic was stopped before COM activation.");
|
||||
}
|
||||
|
||||
var ktapDispatchAttempted = false;
|
||||
try
|
||||
{
|
||||
ComActivationAttempted = true;
|
||||
_eventHandler = _binding.CreateEventHandler();
|
||||
_engine = _binding.CreateEngine();
|
||||
if (Volatile.Read(ref _ktapDispatchGate) != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The connection-only diagnostic was stopped during COM activation.");
|
||||
}
|
||||
|
||||
if (!finalSafetyCheck())
|
||||
{
|
||||
throw new PgmDiagnosticSafetyException();
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref _ktapDispatchGate, 1, 0) != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The connection-only diagnostic was stopped before KTAP dispatch.");
|
||||
}
|
||||
|
||||
SetKtapConnectState(PlayoutKtapConnectState.Attempted);
|
||||
ktapDispatchAttempted = true;
|
||||
var returnValue = _binding.KtapConnect(
|
||||
_engine,
|
||||
host,
|
||||
port,
|
||||
_eventHandler);
|
||||
if (returnValue != 1)
|
||||
{
|
||||
SetKtapConnectState(PlayoutKtapConnectState.Failed);
|
||||
throw new KtapConnectRejectedException();
|
||||
}
|
||||
|
||||
_accepted = true;
|
||||
SetKtapConnectState(PlayoutKtapConnectState.AcceptedUnconfirmed);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
if (LastKtapConnectState == PlayoutKtapConnectState.Attempted)
|
||||
{
|
||||
SetKtapConnectState(PlayoutKtapConnectState.Failed);
|
||||
}
|
||||
|
||||
_accepted = false;
|
||||
ReleaseAll();
|
||||
if (exception is KtapConnectRejectedException || !ktapDispatchAttempted)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
throw new KtapConnectInvocationException(
|
||||
ClassifyInvocationFailure(exception),
|
||||
exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void Disconnect()
|
||||
{
|
||||
EnsureThread();
|
||||
if (_abandoned || !_accepted || _engine is null)
|
||||
{
|
||||
throw new InvalidOperationException("No accepted KTAP session is available.");
|
||||
}
|
||||
|
||||
Exception? failure = null;
|
||||
try
|
||||
{
|
||||
_binding.Disconnect(_engine);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
failure = exception;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_accepted = false;
|
||||
ReleaseAll();
|
||||
}
|
||||
|
||||
if (failure is not null)
|
||||
{
|
||||
ExceptionDispatchInfo.Capture(failure).Throw();
|
||||
}
|
||||
}
|
||||
|
||||
public void Abandon()
|
||||
{
|
||||
if (_abandoned)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureThread();
|
||||
TryPreventKtapConnect();
|
||||
_abandoned = true;
|
||||
_accepted = false;
|
||||
ReleaseAll();
|
||||
}
|
||||
|
||||
private void SetKtapConnectState(PlayoutKtapConnectState state) =>
|
||||
Volatile.Write(ref _lastKtapConnectState, (int)state);
|
||||
|
||||
private void EnsureThread()
|
||||
{
|
||||
var currentThreadId = Environment.CurrentManagedThreadId;
|
||||
if (_ownerThreadId is null)
|
||||
{
|
||||
if (Thread.CurrentThread.GetApartmentState() != ApartmentState.STA)
|
||||
{
|
||||
throw new InvalidOperationException("K3D access requires an STA thread.");
|
||||
}
|
||||
|
||||
_ownerThreadId = currentThreadId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_ownerThreadId.Value != currentThreadId)
|
||||
{
|
||||
throw new InvalidOperationException("K3D access changed STA threads.");
|
||||
}
|
||||
}
|
||||
|
||||
private void ReleaseAll()
|
||||
{
|
||||
var engine = _engine;
|
||||
_engine = null;
|
||||
var eventHandler = _eventHandler;
|
||||
_eventHandler = null;
|
||||
Release(engine);
|
||||
Release(eventHandler);
|
||||
}
|
||||
|
||||
private void Release(object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_binding.Release(value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Local RCW release is best effort and must not cause another SDK command.
|
||||
}
|
||||
}
|
||||
|
||||
private static string ClassifyInvocationFailure(Exception exception) => exception switch
|
||||
{
|
||||
InvalidCastException => "ktap-argument-cast-failed",
|
||||
ArgumentException => "ktap-argument-binding-failed",
|
||||
_ => "ktap-connect-invocation-failed"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
using System.Reflection;
|
||||
using System.Reflection.PortableExecutable;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Loader;
|
||||
using System.Security.Cryptography;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Registration;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Diagnostics;
|
||||
|
||||
internal interface IK3dConnectOnlyComBinding
|
||||
{
|
||||
object CreateEventHandler();
|
||||
|
||||
object CreateEngine();
|
||||
|
||||
int KtapConnect(object engine, string host, int port, object eventHandler);
|
||||
|
||||
void Disconnect(object engine);
|
||||
|
||||
void Release(object value);
|
||||
}
|
||||
|
||||
internal interface IK3dConnectOnlyComBindingFactory
|
||||
{
|
||||
IK3dConnectOnlyComBinding Create();
|
||||
}
|
||||
|
||||
internal sealed class InstalledK3dInteropConnectBindingFactory : IK3dConnectOnlyComBindingFactory
|
||||
{
|
||||
public IK3dConnectOnlyComBinding Create() =>
|
||||
new InstalledK3dInteropConnectBinding(
|
||||
InstalledK3dInteropMetadata.Resolve(),
|
||||
new RuntimeComObjectReleaser());
|
||||
}
|
||||
|
||||
internal static class InstalledK3dInteropMetadata
|
||||
{
|
||||
internal const string ApprovedSha256EnvironmentVariable =
|
||||
"MBN_STOCK_K3D_INTEROP_SHA256";
|
||||
internal const string ApprovedNativeSha256EnvironmentVariable =
|
||||
"MBN_STOCK_K3D_NATIVE_SHA256";
|
||||
|
||||
private static readonly Lazy<InstalledK3dInteropTypes> Types = new(
|
||||
ResolveCore,
|
||||
LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
public static InstalledK3dInteropTypes Resolve() => Types.Value;
|
||||
|
||||
private static InstalledK3dInteropTypes ResolveCore()
|
||||
{
|
||||
try
|
||||
{
|
||||
return ResolveAndHoldTrustedFiles();
|
||||
}
|
||||
catch (InstalledK3dInteropMetadataUnavailableException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Do not put the approved hash, discovered path, or registry values in reports.
|
||||
throw new InstalledK3dInteropMetadataUnavailableException();
|
||||
}
|
||||
}
|
||||
|
||||
private static InstalledK3dInteropTypes ResolveAndHoldTrustedFiles()
|
||||
{
|
||||
FileStream? nativeStream = null;
|
||||
FileStream? interopStream = null;
|
||||
try
|
||||
{
|
||||
var snapshot = new WindowsK3dRegistrySnapshotSource().Capture();
|
||||
var paths = ResolveRegisteredPaths(snapshot);
|
||||
EnsureFixedDrive(paths.NativeBinaryPath);
|
||||
EnsureFixedDrive(paths.InteropAssemblyPath);
|
||||
EnsureNoReparsePointInAncestry(paths.NativeBinaryPath);
|
||||
EnsureNoReparsePointInAncestry(paths.InteropAssemblyPath);
|
||||
|
||||
nativeStream = OpenReadLocked(paths.NativeBinaryPath);
|
||||
ValidateAmd64Pe(nativeStream, requireManagedMetadata: false);
|
||||
ValidateApprovedSha256(
|
||||
nativeStream,
|
||||
Environment.GetEnvironmentVariable(
|
||||
ApprovedNativeSha256EnvironmentVariable,
|
||||
EnvironmentVariableTarget.Process));
|
||||
|
||||
interopStream = OpenReadLocked(paths.InteropAssemblyPath);
|
||||
ValidateAmd64Pe(interopStream, requireManagedMetadata: true);
|
||||
ValidateApprovedSha256(
|
||||
interopStream,
|
||||
Environment.GetEnvironmentVariable(
|
||||
ApprovedSha256EnvironmentVariable,
|
||||
EnvironmentVariableTarget.Process));
|
||||
|
||||
// Both non-delete/non-write shared handles remain rooted by the Lazy metadata
|
||||
// for the process lifetime, including COM activation and subsequent use.
|
||||
EnsureNoReparsePointInAncestry(paths.NativeBinaryPath);
|
||||
EnsureNoReparsePointInAncestry(paths.InteropAssemblyPath);
|
||||
var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(
|
||||
paths.InteropAssemblyPath);
|
||||
var lease = new InstalledK3dInteropFileLease(nativeStream, interopStream);
|
||||
var result = ValidateLoadedAssembly(assembly, lease);
|
||||
nativeStream = null;
|
||||
interopStream = null;
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
interopStream?.Dispose();
|
||||
nativeStream?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal static InstalledK3dInteropPaths ResolveRegisteredPaths(
|
||||
K3dRegistrySnapshot snapshot)
|
||||
{
|
||||
if (!snapshot.Is64BitProcess ||
|
||||
snapshot.CurrentUserOverridePresent ||
|
||||
!IsExpectedClassRegistration(
|
||||
snapshot.Engine,
|
||||
K3dComConstants.KaEngineProgId,
|
||||
K3dComConstants.KaEngineClassGuid) ||
|
||||
!IsExpectedClassRegistration(
|
||||
snapshot.EventHandler,
|
||||
K3dComConstants.KaEventHandlerProgId,
|
||||
K3dComConstants.KaEventHandlerClassGuid))
|
||||
{
|
||||
throw new InstalledK3dInteropMetadataUnavailableException();
|
||||
}
|
||||
|
||||
var typeLibraryPath = NormalizeRegisteredPath(snapshot.Win64TypeLibraryPath);
|
||||
var enginePath = NormalizeRegisteredPath(snapshot.Engine.InprocServerPath);
|
||||
var eventHandlerPath = NormalizeRegisteredPath(snapshot.EventHandler.InprocServerPath);
|
||||
if (!string.Equals(typeLibraryPath, enginePath, StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(typeLibraryPath, eventHandlerPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InstalledK3dInteropMetadataUnavailableException();
|
||||
}
|
||||
|
||||
var nativeFile = new FileInfo(typeLibraryPath);
|
||||
var releaseDirectory = nativeFile.Directory;
|
||||
var x64Directory = releaseDirectory?.Parent;
|
||||
var dllDirectory = x64Directory?.Parent;
|
||||
var vendorRoot = dllDirectory?.Parent;
|
||||
if (!string.Equals(
|
||||
nativeFile.Name,
|
||||
"K3DAsyncEngine.dll",
|
||||
StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(releaseDirectory?.Name, "Release", StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(x64Directory?.Name, "x64", StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(dllDirectory?.Name, "DLL", StringComparison.OrdinalIgnoreCase) ||
|
||||
vendorRoot is null)
|
||||
{
|
||||
throw new InstalledK3dInteropMetadataUnavailableException();
|
||||
}
|
||||
|
||||
var interopPath = Path.GetFullPath(
|
||||
Path.Combine(
|
||||
vendorRoot.FullName,
|
||||
"Bin",
|
||||
"x64",
|
||||
"C#",
|
||||
"Interop.K3DAsyncEngineLib.dll"));
|
||||
return new InstalledK3dInteropPaths(typeLibraryPath, interopPath);
|
||||
}
|
||||
|
||||
internal static byte[] ParseApprovedSha256(string? value)
|
||||
{
|
||||
if (value is null || value.Length != 64 || value.Any(character => !IsHex(character)))
|
||||
{
|
||||
throw new InstalledK3dInteropMetadataUnavailableException();
|
||||
}
|
||||
|
||||
return Convert.FromHexString(value);
|
||||
}
|
||||
|
||||
internal static void ValidateApprovedSha256(Stream stream, string? approvedValue)
|
||||
{
|
||||
var approvedHash = ParseApprovedSha256(approvedValue);
|
||||
stream.Position = 0;
|
||||
var actualHash = SHA256.HashData(stream);
|
||||
try
|
||||
{
|
||||
if (!CryptographicOperations.FixedTimeEquals(actualHash, approvedHash))
|
||||
{
|
||||
throw new InstalledK3dInteropMetadataUnavailableException();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(actualHash);
|
||||
CryptographicOperations.ZeroMemory(approvedHash);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsExpectedClassRegistration(
|
||||
K3dClassRegistrationSnapshot registration,
|
||||
string expectedProgId,
|
||||
Guid expectedClassId) =>
|
||||
registration.ClassKeyPresent &&
|
||||
string.Equals(registration.ClassProgId, expectedProgId, StringComparison.OrdinalIgnoreCase) &&
|
||||
Guid.TryParse(registration.ProgIdClassId, out var classId) &&
|
||||
classId == expectedClassId &&
|
||||
string.Equals(
|
||||
registration.ThreadingModel,
|
||||
K3dComConstants.ApartmentThreadingModel,
|
||||
StringComparison.OrdinalIgnoreCase) &&
|
||||
Guid.TryParse(registration.TypeLibraryId, out var typeLibraryId) &&
|
||||
typeLibraryId == Guid.Parse(K3dComConstants.TypeLibraryId);
|
||||
|
||||
private static string NormalizeRegisteredPath(string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path) ||
|
||||
!string.Equals(path, path.Trim(), StringComparison.Ordinal) ||
|
||||
!Path.IsPathFullyQualified(path))
|
||||
{
|
||||
throw new InstalledK3dInteropMetadataUnavailableException();
|
||||
}
|
||||
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
|
||||
private static void EnsureFixedDrive(string path)
|
||||
{
|
||||
var root = Path.GetPathRoot(path);
|
||||
if (string.IsNullOrEmpty(root) || new DriveInfo(root).DriveType != DriveType.Fixed)
|
||||
{
|
||||
throw new InstalledK3dInteropMetadataUnavailableException();
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureNoReparsePointInAncestry(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new InstalledK3dInteropMetadataUnavailableException();
|
||||
}
|
||||
|
||||
if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
throw new InstalledK3dInteropMetadataUnavailableException();
|
||||
}
|
||||
|
||||
var current = Path.GetDirectoryName(path);
|
||||
while (!string.IsNullOrEmpty(current))
|
||||
{
|
||||
if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
throw new InstalledK3dInteropMetadataUnavailableException();
|
||||
}
|
||||
|
||||
current = Directory.GetParent(current)?.FullName;
|
||||
}
|
||||
}
|
||||
|
||||
private static FileStream OpenReadLocked(string path) => new(
|
||||
path,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read);
|
||||
|
||||
private static void ValidateAmd64Pe(Stream stream, bool requireManagedMetadata)
|
||||
{
|
||||
stream.Position = 0;
|
||||
using var reader = new PEReader(stream, PEStreamOptions.LeaveOpen);
|
||||
if (reader.PEHeaders.CoffHeader.Machine != Machine.Amd64 ||
|
||||
reader.PEHeaders.PEHeader?.Magic != PEMagic.PE32Plus ||
|
||||
(requireManagedMetadata && !reader.HasMetadata))
|
||||
{
|
||||
throw new InstalledK3dInteropMetadataUnavailableException();
|
||||
}
|
||||
}
|
||||
|
||||
private static InstalledK3dInteropTypes ValidateLoadedAssembly(
|
||||
Assembly assembly,
|
||||
InstalledK3dInteropFileLease fileLease)
|
||||
{
|
||||
var assemblyName = assembly.GetName();
|
||||
if (!string.Equals(
|
||||
assemblyName.Name,
|
||||
"Interop.K3DAsyncEngineLib",
|
||||
StringComparison.Ordinal) ||
|
||||
assemblyName.Version != new Version(1, 0, 0, 0) ||
|
||||
!Guid.TryParse(assembly.GetCustomAttribute<GuidAttribute>()?.Value, out var typeLibraryId) ||
|
||||
typeLibraryId != Guid.Parse(K3dComConstants.TypeLibraryId) ||
|
||||
!string.Equals(
|
||||
assembly.GetCustomAttribute<ImportedFromTypeLibAttribute>()?.Value,
|
||||
"K3DAsyncEngineLib",
|
||||
StringComparison.Ordinal) ||
|
||||
assembly.GetCustomAttribute<TypeLibVersionAttribute>() is not { MajorVersion: 1, MinorVersion: 0 })
|
||||
{
|
||||
throw new InstalledK3dInteropMetadataUnavailableException();
|
||||
}
|
||||
|
||||
var engineType = RequireComType(
|
||||
assembly,
|
||||
"K3DAsyncEngineLib.KAEngineClass",
|
||||
K3dComConstants.KaEngineClassGuid);
|
||||
var eventHandlerType = RequireComType(
|
||||
assembly,
|
||||
"K3DAsyncEngineLib.KAEventHandlerClass",
|
||||
K3dComConstants.KaEventHandlerClassGuid);
|
||||
var scenePlayerType = RequireComType(
|
||||
assembly,
|
||||
"K3DAsyncEngineLib.IKAScenePlayer",
|
||||
Guid.Parse("14083C84-4B17-41BF-AE1A-206F13C0E5C5"));
|
||||
var sceneType = RequireComType(
|
||||
assembly,
|
||||
"K3DAsyncEngineLib.IKAScene",
|
||||
Guid.Parse("BFD73274-AC76-437B-AF85-35682543F44C"));
|
||||
var objectType = RequireComType(
|
||||
assembly,
|
||||
"K3DAsyncEngineLib.IKAObject",
|
||||
Guid.Parse("B138B515-505B-4C7B-A3E1-F82782005ECF"));
|
||||
|
||||
var connectMethod = engineType.GetMethods(BindingFlags.Public | BindingFlags.Instance)
|
||||
.SingleOrDefault(method =>
|
||||
method.Name == "KTAPConnect" &&
|
||||
method.ReturnType == typeof(int) &&
|
||||
method.GetParameters() is { Length: 5 } parameters &&
|
||||
parameters[0].ParameterType == typeof(int) &&
|
||||
parameters[1].ParameterType == typeof(string) &&
|
||||
parameters[2].ParameterType == typeof(int) &&
|
||||
parameters[3].ParameterType == typeof(int) &&
|
||||
parameters[4].ParameterType.GUID ==
|
||||
Guid.Parse("C21817DB-C0D3-4647-8D85-027338DCF832") &&
|
||||
parameters[4].ParameterType.IsAssignableFrom(eventHandlerType));
|
||||
var disconnectMethod = engineType.GetMethod(
|
||||
"Disconnect",
|
||||
BindingFlags.Public | BindingFlags.Instance,
|
||||
binder: null,
|
||||
types: Type.EmptyTypes,
|
||||
modifiers: null);
|
||||
var setOutputChannelMethod = sceneType
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
|
||||
.SingleOrDefault(method =>
|
||||
method.Name == "SetOutputChannelIndex" &&
|
||||
method.ReturnType == typeof(void) &&
|
||||
method.GetParameters() is { Length: 1 } parameters &&
|
||||
parameters[0].ParameterType == typeof(int));
|
||||
var unloadMethod = sceneType.GetMethod(
|
||||
"Unload",
|
||||
BindingFlags.Public | BindingFlags.Instance,
|
||||
binder: null,
|
||||
types: Type.EmptyTypes,
|
||||
modifiers: null);
|
||||
var endTransactionOnChannelMethod = engineType
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
|
||||
.SingleOrDefault(method =>
|
||||
method.Name == "EndTransactionOnChannel" &&
|
||||
method.ReturnType == typeof(void) &&
|
||||
method.GetParameters() is { Length: 1 } parameters &&
|
||||
parameters[0].ParameterType == typeof(int));
|
||||
if (connectMethod is null ||
|
||||
disconnectMethod?.ReturnType != typeof(void) ||
|
||||
setOutputChannelMethod is null ||
|
||||
unloadMethod?.ReturnType != typeof(void) ||
|
||||
endTransactionOnChannelMethod is null)
|
||||
{
|
||||
throw new InstalledK3dInteropMetadataUnavailableException();
|
||||
}
|
||||
|
||||
return new InstalledK3dInteropTypes(
|
||||
engineType,
|
||||
eventHandlerType,
|
||||
scenePlayerType,
|
||||
sceneType,
|
||||
objectType,
|
||||
connectMethod,
|
||||
disconnectMethod,
|
||||
fileLease);
|
||||
}
|
||||
|
||||
private static Type RequireComType(Assembly assembly, string name, Guid expectedGuid)
|
||||
{
|
||||
var type = assembly.GetType(name, throwOnError: false, ignoreCase: false);
|
||||
if (type is null || !type.IsImport || type.GUID != expectedGuid)
|
||||
{
|
||||
throw new InstalledK3dInteropMetadataUnavailableException();
|
||||
}
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
private static bool IsHex(char value) =>
|
||||
value is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F';
|
||||
}
|
||||
|
||||
internal sealed class InstalledK3dInteropMetadataUnavailableException : Exception
|
||||
{
|
||||
public InstalledK3dInteropMetadataUnavailableException()
|
||||
: base("The installed K3D interop metadata is unavailable or not approved.")
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal readonly record struct InstalledK3dInteropPaths(
|
||||
string NativeBinaryPath,
|
||||
string InteropAssemblyPath);
|
||||
|
||||
internal sealed class InstalledK3dInteropFileLease
|
||||
{
|
||||
internal InstalledK3dInteropFileLease(FileStream nativeStream, FileStream interopStream)
|
||||
{
|
||||
NativeStream = nativeStream;
|
||||
InteropStream = interopStream;
|
||||
}
|
||||
|
||||
internal FileStream NativeStream { get; }
|
||||
|
||||
internal FileStream InteropStream { get; }
|
||||
}
|
||||
|
||||
internal sealed record InstalledK3dInteropTypes(
|
||||
Type EngineType,
|
||||
Type EventHandlerType,
|
||||
Type ScenePlayerType,
|
||||
Type SceneType,
|
||||
Type ObjectType,
|
||||
MethodInfo ConnectMethod,
|
||||
MethodInfo DisconnectMethod,
|
||||
InstalledK3dInteropFileLease FileLease);
|
||||
|
||||
internal sealed class InstalledK3dInteropComActivator : ILateBoundComActivator
|
||||
{
|
||||
public object Create(Guid classId)
|
||||
{
|
||||
var types = InstalledK3dInteropMetadata.Resolve();
|
||||
var type = classId switch
|
||||
{
|
||||
_ when classId == K3dComConstants.KaEngineClassGuid => types.EngineType,
|
||||
_ when classId == K3dComConstants.KaEventHandlerClassGuid => types.EventHandlerType,
|
||||
_ => throw new InvalidOperationException("The K3D COM class is not allowlisted.")
|
||||
};
|
||||
|
||||
return Activator.CreateInstance(type)
|
||||
?? throw new InvalidOperationException("The installed K3D COM class could not be created.");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InstalledK3dInteropMethodInvoker : ILateBoundComMethodInvoker
|
||||
{
|
||||
private static readonly IReadOnlySet<string> EngineMethods = new HashSet<string>(
|
||||
[
|
||||
"KTAPConnect",
|
||||
"Disconnect",
|
||||
"GetScenePlayer",
|
||||
"GetScenePlayerOnChannel",
|
||||
"LoadScene",
|
||||
"BeginTransaction",
|
||||
"EndTransaction",
|
||||
"EndTransactionOnChannel",
|
||||
"RollbackTransaction"
|
||||
], StringComparer.Ordinal);
|
||||
|
||||
private static readonly IReadOnlySet<string> PlayerMethods = new HashSet<string>(
|
||||
[
|
||||
"Prepare",
|
||||
"Play",
|
||||
"StopAll",
|
||||
"CutOut",
|
||||
"GetPlayingScene"
|
||||
], StringComparer.Ordinal);
|
||||
|
||||
private static readonly IReadOnlySet<string> SceneMethods = new HashSet<string>(
|
||||
[
|
||||
"SetSceneEffectType",
|
||||
"SetOutputChannelIndex",
|
||||
"QueryVariables",
|
||||
"GetObject",
|
||||
"Unload"
|
||||
], StringComparer.Ordinal);
|
||||
|
||||
private static readonly IReadOnlySet<string> ObjectMethods = new HashSet<string>(
|
||||
[
|
||||
"SetValue",
|
||||
"SetVisible"
|
||||
], StringComparer.Ordinal);
|
||||
|
||||
public object? Invoke(object target, string method, params object?[] arguments)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(target);
|
||||
if (!Marshal.IsComObject(target))
|
||||
{
|
||||
return InvokeMember(target.GetType(), target, method, arguments);
|
||||
}
|
||||
|
||||
var types = InstalledK3dInteropMetadata.Resolve();
|
||||
var invocationType = EngineMethods.Contains(method)
|
||||
? types.EngineType
|
||||
: PlayerMethods.Contains(method)
|
||||
? types.ScenePlayerType
|
||||
: SceneMethods.Contains(method)
|
||||
? types.SceneType
|
||||
: ObjectMethods.Contains(method)
|
||||
? types.ObjectType
|
||||
: throw new MissingMethodException("The K3D method is not allowlisted.");
|
||||
var candidates = invocationType
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(candidate =>
|
||||
candidate.Name == method &&
|
||||
candidate.GetParameters().Length == arguments.Length)
|
||||
.ToArray();
|
||||
if (candidates.Length != 1)
|
||||
{
|
||||
throw new MissingMethodException("The installed K3D method signature is ambiguous.");
|
||||
}
|
||||
|
||||
var invokeArguments = NormalizeArguments(candidates[0], arguments);
|
||||
return InvokeMethod(candidates[0], target, invokeArguments);
|
||||
}
|
||||
|
||||
private static object?[] NormalizeArguments(MethodInfo method, object?[] arguments)
|
||||
{
|
||||
var parameters = method.GetParameters();
|
||||
var result = new object?[arguments.Length];
|
||||
for (var index = 0; index < arguments.Length; index++)
|
||||
{
|
||||
var value = arguments[index];
|
||||
var expectedType = parameters[index].ParameterType;
|
||||
if (value is null || expectedType.IsInstanceOfType(value))
|
||||
{
|
||||
result[index] = value;
|
||||
}
|
||||
else if (expectedType.IsEnum && value is IConvertible)
|
||||
{
|
||||
result[index] = Enum.ToObject(expectedType, value);
|
||||
}
|
||||
else if (value is IConvertible && typeof(IConvertible).IsAssignableFrom(expectedType))
|
||||
{
|
||||
result[index] = Convert.ChangeType(
|
||||
value,
|
||||
expectedType,
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
else
|
||||
{
|
||||
result[index] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static object? InvokeMember(
|
||||
Type type,
|
||||
object target,
|
||||
string method,
|
||||
object?[] arguments)
|
||||
{
|
||||
try
|
||||
{
|
||||
return type.InvokeMember(
|
||||
method,
|
||||
BindingFlags.InvokeMethod | BindingFlags.Public | BindingFlags.Instance,
|
||||
binder: null,
|
||||
target,
|
||||
arguments,
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (TargetInvocationException exception) when (exception.InnerException is not null)
|
||||
{
|
||||
ExceptionDispatchInfo.Capture(exception.InnerException).Throw();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static object? InvokeMethod(
|
||||
MethodInfo method,
|
||||
object target,
|
||||
object?[] arguments)
|
||||
{
|
||||
try
|
||||
{
|
||||
return method.Invoke(target, arguments);
|
||||
}
|
||||
catch (TargetInvocationException exception) when (exception.InnerException is not null)
|
||||
{
|
||||
ExceptionDispatchInfo.Capture(exception.InnerException).Throw();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InstalledK3dInteropConnectBinding : IK3dConnectOnlyComBinding
|
||||
{
|
||||
private readonly InstalledK3dInteropTypes _types;
|
||||
private readonly ILateBoundComObjectReleaser _releaser;
|
||||
|
||||
public InstalledK3dInteropConnectBinding(
|
||||
InstalledK3dInteropTypes types,
|
||||
ILateBoundComObjectReleaser releaser)
|
||||
{
|
||||
_types = types;
|
||||
_releaser = releaser;
|
||||
}
|
||||
|
||||
public object CreateEventHandler() =>
|
||||
Activator.CreateInstance(_types.EventHandlerType)
|
||||
?? throw new InvalidOperationException("The K3D event handler could not be created.");
|
||||
|
||||
public object CreateEngine() =>
|
||||
Activator.CreateInstance(_types.EngineType)
|
||||
?? throw new InvalidOperationException("The K3D engine could not be created.");
|
||||
|
||||
public int KtapConnect(object engine, string host, int port, object eventHandler) =>
|
||||
Convert.ToInt32(
|
||||
Invoke(_types.ConnectMethod, engine, [1, host, port, 0, eventHandler]),
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
public void Disconnect(object engine) =>
|
||||
_ = Invoke(_types.DisconnectMethod, engine, null);
|
||||
|
||||
public void Release(object value) => _releaser.Release(value);
|
||||
|
||||
private static object? Invoke(MethodInfo method, object target, object?[]? arguments)
|
||||
{
|
||||
try
|
||||
{
|
||||
return method.Invoke(target, arguments);
|
||||
}
|
||||
catch (TargetInvocationException exception) when (exception.InnerException is not null)
|
||||
{
|
||||
ExceptionDispatchInfo.Capture(exception.InnerException).Throw();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
using System.Net;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Registration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Diagnostics;
|
||||
|
||||
internal sealed record PgmConnectDiagnosticRequest(
|
||||
string Host,
|
||||
int Port,
|
||||
string ExpectedPgmWindowTitle);
|
||||
|
||||
internal sealed record PgmConnectDiagnosticReport(
|
||||
bool RequestValidated,
|
||||
bool ProcessGatePassed,
|
||||
bool ListenerOwnershipConfirmed,
|
||||
bool RegistrationReady,
|
||||
bool ConnectRequestIssued,
|
||||
bool? ComActivationAttempted,
|
||||
PlayoutKtapConnectState LastKtapConnectState,
|
||||
bool KtapConnectAttempted,
|
||||
bool? KtapConnectAccepted,
|
||||
bool? KtapHelloObserved,
|
||||
bool? NetworkMonitoringRecordExpected,
|
||||
bool NetworkMonitoringCheckRequired,
|
||||
bool? NetworkMonitoringVerified,
|
||||
bool DisconnectAttempted,
|
||||
string DisconnectCode,
|
||||
bool Completed,
|
||||
bool OutcomeUnknown,
|
||||
bool RenderCommandSurfaceExposed,
|
||||
bool RenderCommandAttempted,
|
||||
bool AutomaticReconnectEnabled,
|
||||
string? ErrorCode);
|
||||
|
||||
internal sealed class PgmConnectDiagnosticRunner
|
||||
{
|
||||
internal static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(3);
|
||||
internal static readonly TimeSpan DisconnectTimeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
private readonly IPgmDiagnosticSafetyProbe _safetyProbe;
|
||||
private readonly IKtapConnectOnlySessionFactory _sessionFactory;
|
||||
private readonly IK3dRegistrationProbe _registrationProbe;
|
||||
private readonly IStaDispatcherFactory _dispatcherFactory;
|
||||
private readonly TimeSpan _connectTimeout;
|
||||
private readonly TimeSpan _disconnectTimeout;
|
||||
|
||||
internal PgmConnectDiagnosticRunner()
|
||||
: this(
|
||||
new WindowsPgmDiagnosticSafetyProbe(),
|
||||
new DynamicKtapConnectOnlySessionFactory(),
|
||||
new K3dRegistrationProbe(),
|
||||
new StaDispatcherFactory(),
|
||||
ConnectTimeout,
|
||||
DisconnectTimeout)
|
||||
{
|
||||
}
|
||||
|
||||
internal PgmConnectDiagnosticRunner(
|
||||
IPgmDiagnosticSafetyProbe safetyProbe,
|
||||
IKtapConnectOnlySessionFactory sessionFactory,
|
||||
IK3dRegistrationProbe registrationProbe,
|
||||
IStaDispatcherFactory dispatcherFactory,
|
||||
TimeSpan? connectTimeout = null,
|
||||
TimeSpan? disconnectTimeout = null)
|
||||
{
|
||||
_safetyProbe = safetyProbe;
|
||||
_sessionFactory = sessionFactory;
|
||||
_registrationProbe = registrationProbe;
|
||||
_dispatcherFactory = dispatcherFactory;
|
||||
_connectTimeout = connectTimeout ?? ConnectTimeout;
|
||||
_disconnectTimeout = disconnectTimeout ?? DisconnectTimeout;
|
||||
if (_connectTimeout <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(connectTimeout));
|
||||
}
|
||||
|
||||
if (_disconnectTimeout <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(disconnectTimeout));
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task<PgmConnectDiagnosticReport> ExecuteAsync(
|
||||
PgmConnectDiagnosticRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (!TryValidateRequest(request, out var parsedHost))
|
||||
{
|
||||
return Empty("request-rejected");
|
||||
}
|
||||
|
||||
var validatedHost = parsedHost!;
|
||||
|
||||
PgmDiagnosticTargetSnapshot baseline;
|
||||
try
|
||||
{
|
||||
baseline = _safetyProbe.Capture(request, validatedHost);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Empty("process-listener-inspection-unavailable") with
|
||||
{
|
||||
RequestValidated = true
|
||||
};
|
||||
}
|
||||
|
||||
if (!baseline.IsEligible)
|
||||
{
|
||||
return Empty("pgm-target-rejected") with
|
||||
{
|
||||
RequestValidated = true,
|
||||
ProcessGatePassed = baseline.ProcessGatePassed,
|
||||
ListenerOwnershipConfirmed = baseline.ListenerOwnershipConfirmed
|
||||
};
|
||||
}
|
||||
|
||||
K3dRegistrationReport registration;
|
||||
try
|
||||
{
|
||||
registration = _registrationProbe.Probe();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Empty("registration-inspection-unavailable") with
|
||||
{
|
||||
RequestValidated = true,
|
||||
ProcessGatePassed = true,
|
||||
ListenerOwnershipConfirmed = true
|
||||
};
|
||||
}
|
||||
|
||||
if (!registration.IsReady)
|
||||
{
|
||||
return Empty("registration-not-ready") with
|
||||
{
|
||||
RequestValidated = true,
|
||||
ProcessGatePassed = true,
|
||||
ListenerOwnershipConfirmed = true
|
||||
};
|
||||
}
|
||||
|
||||
var state = new ExecutionState
|
||||
{
|
||||
RequestValidated = true,
|
||||
ProcessGatePassed = true,
|
||||
ListenerOwnershipConfirmed = true,
|
||||
RegistrationReady = true,
|
||||
ConnectRequestIssued = true
|
||||
};
|
||||
IKtapConnectOnlySession? pendingSession = null;
|
||||
IKtapConnectOnlySession? acceptedSession = null;
|
||||
await using var dispatcher = _dispatcherFactory.Create(1);
|
||||
|
||||
try
|
||||
{
|
||||
var session = _sessionFactory.Create();
|
||||
Volatile.Write(ref pendingSession, session);
|
||||
acceptedSession = await dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
var current = _safetyProbe.Capture(request, validatedHost);
|
||||
if (!current.IsEligible || !baseline.HasSameTarget(current))
|
||||
{
|
||||
throw new PgmDiagnosticSafetyException();
|
||||
}
|
||||
|
||||
session.Connect(
|
||||
validatedHost.ToString(),
|
||||
request.Port,
|
||||
() =>
|
||||
{
|
||||
var final = _safetyProbe.Capture(request, validatedHost);
|
||||
return final.IsEligible && baseline.HasSameTarget(final);
|
||||
});
|
||||
return session;
|
||||
},
|
||||
_connectTimeout,
|
||||
cancellationToken,
|
||||
static lateSession => lateSession.Abandon(),
|
||||
() => Volatile.Read(ref pendingSession)?.TryPreventKtapConnect())
|
||||
.ConfigureAwait(false);
|
||||
|
||||
CaptureSessionEvidence(state, acceptedSession);
|
||||
state.DisconnectAttempted = true;
|
||||
await dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
var current = _safetyProbe.Capture(request, validatedHost);
|
||||
if (!current.IsEligible || !baseline.HasSameTarget(current))
|
||||
{
|
||||
acceptedSession.Abandon();
|
||||
throw new PgmDiagnosticSafetyException();
|
||||
}
|
||||
|
||||
acceptedSession.Disconnect();
|
||||
return true;
|
||||
},
|
||||
_disconnectTimeout,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
state.DisconnectCode = "success";
|
||||
state.Completed = true;
|
||||
}
|
||||
catch (PgmDiagnosticSafetyException)
|
||||
{
|
||||
CaptureSessionEvidence(state, Volatile.Read(ref pendingSession));
|
||||
state.ErrorCode = state.KtapConnectAttempted
|
||||
? "pgm-target-changed-after-connect"
|
||||
: "pgm-target-changed-before-connect";
|
||||
state.OutcomeUnknown = state.KtapConnectAttempted;
|
||||
state.DisconnectCode = state.DisconnectAttempted
|
||||
? "not-issued-target-changed"
|
||||
: "not-attempted";
|
||||
}
|
||||
catch (KtapConnectRejectedException)
|
||||
{
|
||||
CaptureSessionEvidence(state, Volatile.Read(ref pendingSession));
|
||||
state.ErrorCode = "ktap-connect-rejected";
|
||||
}
|
||||
catch (KtapConnectInvocationException exception)
|
||||
{
|
||||
CaptureSessionEvidence(state, Volatile.Read(ref pendingSession));
|
||||
state.ErrorCode = exception.SafeErrorCode;
|
||||
state.OutcomeUnknown = true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
PreventLateDispatchAndCapture(state, Volatile.Read(ref pendingSession));
|
||||
state.ErrorCode = "cancelled";
|
||||
state.OutcomeUnknown = state.KtapConnectAttempted;
|
||||
}
|
||||
catch (StaOperationTimedOutException)
|
||||
{
|
||||
PreventLateDispatchAndCapture(state, Volatile.Read(ref pendingSession));
|
||||
state.ErrorCode = state.DisconnectAttempted
|
||||
? "disconnect-timeout"
|
||||
: "connect-timeout";
|
||||
state.DisconnectCode = state.DisconnectAttempted
|
||||
? "outcome-unknown"
|
||||
: "not-attempted";
|
||||
state.OutcomeUnknown = state.KtapConnectAttempted || state.DisconnectAttempted;
|
||||
}
|
||||
catch (StaDispatcherQuarantinedException)
|
||||
{
|
||||
PreventLateDispatchAndCapture(state, Volatile.Read(ref pendingSession));
|
||||
state.ErrorCode = "dispatcher-quarantined";
|
||||
state.OutcomeUnknown = state.KtapConnectAttempted || state.DisconnectAttempted;
|
||||
}
|
||||
catch (InstalledK3dInteropMetadataUnavailableException)
|
||||
{
|
||||
CaptureSessionEvidence(state, Volatile.Read(ref pendingSession));
|
||||
state.ErrorCode = "installed-interop-metadata-unavailable";
|
||||
state.DisconnectCode = "not-attempted";
|
||||
}
|
||||
catch
|
||||
{
|
||||
CaptureSessionEvidence(state, Volatile.Read(ref pendingSession));
|
||||
state.ErrorCode = state.DisconnectAttempted
|
||||
? "disconnect-failed"
|
||||
: state.KtapConnectAttempted
|
||||
? "ktap-connect-failed"
|
||||
: "com-activation-failed";
|
||||
state.DisconnectCode = state.DisconnectAttempted ? "failed" : "not-attempted";
|
||||
state.OutcomeUnknown = state.KtapConnectAttempted || state.DisconnectAttempted;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!state.Completed && acceptedSession is not null && !dispatcher.IsQuarantined)
|
||||
{
|
||||
try
|
||||
{
|
||||
await dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
acceptedSession.Abandon();
|
||||
return true;
|
||||
},
|
||||
_disconnectTimeout,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Local COM release is best effort. Never issue a second SDK command here.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return state.ToReport();
|
||||
}
|
||||
|
||||
internal static bool TryValidateRequest(
|
||||
PgmConnectDiagnosticRequest request,
|
||||
out IPAddress? host)
|
||||
{
|
||||
host = null;
|
||||
if (request.Port is < 1 or > 65_535 ||
|
||||
string.IsNullOrWhiteSpace(request.Host) ||
|
||||
!IPAddress.TryParse(request.Host, out host) ||
|
||||
!IPAddress.IsLoopback(host) ||
|
||||
string.IsNullOrWhiteSpace(request.ExpectedPgmWindowTitle) ||
|
||||
request.ExpectedPgmWindowTitle.Length > 128 ||
|
||||
HasToken(request.ExpectedPgmWindowTitle, "TEST") ||
|
||||
!(HasToken(request.ExpectedPgmWindowTitle, "PGM") ||
|
||||
HasToken(request.ExpectedPgmWindowTitle, "PROGRAM")))
|
||||
{
|
||||
host = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasToken(string value, string token)
|
||||
{
|
||||
for (var start = 0; start <= value.Length - token.Length; start++)
|
||||
{
|
||||
if (!value.AsSpan(start, token.Length).Equals(
|
||||
token.AsSpan(),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var beforeIsBoundary = start == 0 || !char.IsLetterOrDigit(value[start - 1]);
|
||||
var after = start + token.Length;
|
||||
var afterIsBoundary = after == value.Length || !char.IsLetterOrDigit(value[after]);
|
||||
if (beforeIsBoundary && afterIsBoundary)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void PreventLateDispatchAndCapture(
|
||||
ExecutionState state,
|
||||
IKtapConnectOnlySession? session)
|
||||
{
|
||||
session?.TryPreventKtapConnect();
|
||||
CaptureSessionEvidence(state, session);
|
||||
}
|
||||
|
||||
private static void CaptureSessionEvidence(
|
||||
ExecutionState state,
|
||||
IKtapConnectOnlySession? session)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
state.ComActivationAttempted = session.ComActivationAttempted;
|
||||
state.LastKtapConnectState = session.LastKtapConnectState;
|
||||
state.KtapConnectAttempted =
|
||||
session.LastKtapConnectState != PlayoutKtapConnectState.NotAttempted;
|
||||
}
|
||||
|
||||
private static PgmConnectDiagnosticReport Empty(string errorCode) => new(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
PlayoutKtapConnectState.NotAttempted,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
"not-attempted",
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
errorCode);
|
||||
|
||||
private sealed class ExecutionState
|
||||
{
|
||||
public bool RequestValidated { get; set; }
|
||||
public bool ProcessGatePassed { get; set; }
|
||||
public bool ListenerOwnershipConfirmed { get; set; }
|
||||
public bool RegistrationReady { get; set; }
|
||||
public bool ConnectRequestIssued { get; set; }
|
||||
public bool? ComActivationAttempted { get; set; } = false;
|
||||
public PlayoutKtapConnectState LastKtapConnectState { get; set; }
|
||||
public bool KtapConnectAttempted { get; set; }
|
||||
public bool DisconnectAttempted { get; set; }
|
||||
public string DisconnectCode { get; set; } = "not-attempted";
|
||||
public bool Completed { get; set; }
|
||||
public bool OutcomeUnknown { get; set; }
|
||||
public string? ErrorCode { get; set; }
|
||||
|
||||
public PgmConnectDiagnosticReport ToReport()
|
||||
{
|
||||
bool? accepted = LastKtapConnectState switch
|
||||
{
|
||||
PlayoutKtapConnectState.AcceptedUnconfirmed => true,
|
||||
PlayoutKtapConnectState.NotAttempted => false,
|
||||
_ => null
|
||||
};
|
||||
bool? monitoringExpected = LastKtapConnectState switch
|
||||
{
|
||||
PlayoutKtapConnectState.AcceptedUnconfirmed => true,
|
||||
PlayoutKtapConnectState.NotAttempted => false,
|
||||
_ => null
|
||||
};
|
||||
|
||||
return new PgmConnectDiagnosticReport(
|
||||
RequestValidated,
|
||||
ProcessGatePassed,
|
||||
ListenerOwnershipConfirmed,
|
||||
RegistrationReady,
|
||||
ConnectRequestIssued,
|
||||
ComActivationAttempted,
|
||||
LastKtapConnectState,
|
||||
KtapConnectAttempted,
|
||||
accepted,
|
||||
null,
|
||||
monitoringExpected,
|
||||
KtapConnectAttempted,
|
||||
null,
|
||||
DisconnectAttempted,
|
||||
DisconnectCode,
|
||||
Completed,
|
||||
OutcomeUnknown,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ErrorCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PgmDiagnosticSafetyException : Exception;
|
||||
|
||||
internal sealed class KtapConnectRejectedException : Exception;
|
||||
|
||||
internal sealed class KtapConnectInvocationException : Exception
|
||||
{
|
||||
public KtapConnectInvocationException(string safeErrorCode, Exception innerException)
|
||||
: base("The KTAP invocation failed.", innerException)
|
||||
{
|
||||
SafeErrorCode = safeErrorCode;
|
||||
}
|
||||
|
||||
public string SafeErrorCode { get; }
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Diagnostics;
|
||||
|
||||
internal readonly record struct PgmDiagnosticTargetIdentity(
|
||||
int ProcessId,
|
||||
long StartTimeUtcTicks);
|
||||
|
||||
internal sealed record PgmDiagnosticTargetSnapshot(
|
||||
bool InspectionSucceeded,
|
||||
int TotalTornadoProcessCount,
|
||||
int ExactWindowCount,
|
||||
bool ListenerOwnershipConfirmed,
|
||||
PgmDiagnosticTargetIdentity? Identity)
|
||||
{
|
||||
public bool ProcessGatePassed =>
|
||||
InspectionSucceeded &&
|
||||
TotalTornadoProcessCount == 1 &&
|
||||
ExactWindowCount == 1 &&
|
||||
Identity is not null;
|
||||
|
||||
public bool IsEligible => ProcessGatePassed && ListenerOwnershipConfirmed;
|
||||
|
||||
public bool HasSameTarget(PgmDiagnosticTargetSnapshot other) =>
|
||||
IsEligible && other.IsEligible && Identity == other.Identity;
|
||||
}
|
||||
|
||||
internal interface IPgmDiagnosticSafetyProbe
|
||||
{
|
||||
PgmDiagnosticTargetSnapshot Capture(
|
||||
PgmConnectDiagnosticRequest request,
|
||||
IPAddress parsedHost);
|
||||
}
|
||||
|
||||
internal interface ITcpListenerOwnershipProbe
|
||||
{
|
||||
bool TryIsOwnedBy(
|
||||
IPAddress requestedLocalAddress,
|
||||
int port,
|
||||
int processId,
|
||||
out bool isOwned);
|
||||
}
|
||||
|
||||
internal readonly record struct TcpListenerOwnershipEntry(
|
||||
IPAddress LocalAddress,
|
||||
int Port,
|
||||
int ProcessId);
|
||||
|
||||
internal sealed class WindowsPgmDiagnosticSafetyProbe : IPgmDiagnosticSafetyProbe
|
||||
{
|
||||
private readonly ITcpListenerOwnershipProbe _listenerProbe;
|
||||
|
||||
public WindowsPgmDiagnosticSafetyProbe()
|
||||
: this(new WindowsTcpListenerOwnershipProbe())
|
||||
{
|
||||
}
|
||||
|
||||
internal WindowsPgmDiagnosticSafetyProbe(ITcpListenerOwnershipProbe listenerProbe)
|
||||
{
|
||||
_listenerProbe = listenerProbe;
|
||||
}
|
||||
|
||||
public PgmDiagnosticTargetSnapshot Capture(
|
||||
PgmConnectDiagnosticRequest request,
|
||||
IPAddress parsedHost)
|
||||
{
|
||||
var inspectionSucceeded = true;
|
||||
var totalCount = 0;
|
||||
var exactWindowCount = 0;
|
||||
PgmDiagnosticTargetIdentity? identity = null;
|
||||
Process[] processes;
|
||||
try
|
||||
{
|
||||
processes = Process.GetProcesses();
|
||||
}
|
||||
catch (Exception exception) when (IsInspectionException(exception))
|
||||
{
|
||||
return new PgmDiagnosticTargetSnapshot(false, 0, 0, false, null);
|
||||
}
|
||||
|
||||
foreach (var process in processes)
|
||||
{
|
||||
try
|
||||
{
|
||||
string processName;
|
||||
try
|
||||
{
|
||||
processName = process.ProcessName;
|
||||
}
|
||||
catch (Exception exception) when (IsExitedProcessException(exception))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
catch (Exception exception) when (IsInspectionException(exception))
|
||||
{
|
||||
inspectionSucceeded = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!processName.StartsWith("Tornado2", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
totalCount++;
|
||||
string title;
|
||||
try
|
||||
{
|
||||
title = process.MainWindowTitle;
|
||||
}
|
||||
catch (Exception exception) when (IsInspectionException(exception))
|
||||
{
|
||||
inspectionSucceeded = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(
|
||||
title,
|
||||
request.ExpectedPgmWindowTitle,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
exactWindowCount++;
|
||||
try
|
||||
{
|
||||
identity = new PgmDiagnosticTargetIdentity(
|
||||
process.Id,
|
||||
process.StartTime.ToUniversalTime().Ticks);
|
||||
}
|
||||
catch (Exception exception) when (IsInspectionException(exception))
|
||||
{
|
||||
inspectionSucceeded = false;
|
||||
identity = null;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
var listenerOwned = false;
|
||||
if (inspectionSucceeded &&
|
||||
totalCount == 1 &&
|
||||
exactWindowCount == 1 &&
|
||||
identity is { } target)
|
||||
{
|
||||
if (!_listenerProbe.TryIsOwnedBy(
|
||||
parsedHost,
|
||||
request.Port,
|
||||
target.ProcessId,
|
||||
out listenerOwned))
|
||||
{
|
||||
inspectionSucceeded = false;
|
||||
listenerOwned = false;
|
||||
}
|
||||
}
|
||||
|
||||
return new PgmDiagnosticTargetSnapshot(
|
||||
inspectionSucceeded,
|
||||
totalCount,
|
||||
exactWindowCount,
|
||||
listenerOwned,
|
||||
identity);
|
||||
}
|
||||
|
||||
private static bool IsInspectionException(Exception exception) =>
|
||||
exception is InvalidOperationException or
|
||||
ArgumentException or
|
||||
Win32Exception or
|
||||
NotSupportedException or
|
||||
UnauthorizedAccessException;
|
||||
|
||||
private static bool IsExitedProcessException(Exception exception) =>
|
||||
exception is InvalidOperationException or ArgumentException;
|
||||
}
|
||||
|
||||
internal sealed class WindowsTcpListenerOwnershipProbe : ITcpListenerOwnershipProbe
|
||||
{
|
||||
private const uint ErrorInsufficientBuffer = 122;
|
||||
private const uint NoError = 0;
|
||||
private const int AfInet = 2;
|
||||
private const int AfInet6 = 23;
|
||||
|
||||
public bool TryIsOwnedBy(
|
||||
IPAddress requestedLocalAddress,
|
||||
int port,
|
||||
int processId,
|
||||
out bool isOwned)
|
||||
{
|
||||
isOwned = false;
|
||||
if (!IsValidRequestedEndpoint(requestedLocalAddress, port, processId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var addressFamily = requestedLocalAddress.AddressFamily;
|
||||
var family = addressFamily switch
|
||||
{
|
||||
AddressFamily.InterNetwork => AfInet,
|
||||
AddressFamily.InterNetworkV6 => AfInet6,
|
||||
_ => 0
|
||||
};
|
||||
if (family == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
IntPtr buffer = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
var size = 0;
|
||||
var result = GetExtendedTcpTable(
|
||||
IntPtr.Zero,
|
||||
ref size,
|
||||
false,
|
||||
family,
|
||||
TcpTableClass.OwnerPidListener,
|
||||
0);
|
||||
if (result is not (ErrorInsufficientBuffer or NoError) || size <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
buffer = Marshal.AllocHGlobal(size);
|
||||
result = GetExtendedTcpTable(
|
||||
buffer,
|
||||
ref size,
|
||||
false,
|
||||
family,
|
||||
TcpTableClass.OwnerPidListener,
|
||||
0);
|
||||
if (result != NoError)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var count = Marshal.ReadInt32(buffer);
|
||||
if (count < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var listeners = new List<TcpListenerOwnershipEntry>(count);
|
||||
var rowPointer = IntPtr.Add(buffer, sizeof(uint));
|
||||
if (addressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
var rowSize = Marshal.SizeOf<MibTcpRowOwnerPid>();
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
var row = Marshal.PtrToStructure<MibTcpRowOwnerPid>(rowPointer);
|
||||
listeners.Add(new TcpListenerOwnershipEntry(
|
||||
new IPAddress(row.LocalAddress),
|
||||
DecodePort(row.LocalPort),
|
||||
row.OwningPid));
|
||||
|
||||
rowPointer = IntPtr.Add(rowPointer, rowSize);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var rowSize = Marshal.SizeOf<MibTcp6RowOwnerPid>();
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
var row = Marshal.PtrToStructure<MibTcp6RowOwnerPid>(rowPointer);
|
||||
if (row.LocalAddress is not { Length: 16 })
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
listeners.Add(new TcpListenerOwnershipEntry(
|
||||
new IPAddress(row.LocalAddress, row.LocalScopeId),
|
||||
DecodePort(row.LocalPort),
|
||||
row.OwningPid));
|
||||
|
||||
rowPointer = IntPtr.Add(rowPointer, rowSize);
|
||||
}
|
||||
}
|
||||
|
||||
return TryEvaluateOwnership(
|
||||
requestedLocalAddress,
|
||||
port,
|
||||
processId,
|
||||
listeners,
|
||||
out isOwned);
|
||||
}
|
||||
catch
|
||||
{
|
||||
isOwned = false;
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (buffer != IntPtr.Zero)
|
||||
{
|
||||
Marshal.FreeHGlobal(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryEvaluateOwnership(
|
||||
IPAddress requestedLocalAddress,
|
||||
int port,
|
||||
int processId,
|
||||
IReadOnlyList<TcpListenerOwnershipEntry> listeners,
|
||||
out bool isOwned)
|
||||
{
|
||||
isOwned = false;
|
||||
if (!IsValidRequestedEndpoint(requestedLocalAddress, port, processId) ||
|
||||
listeners is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var wildcard = requestedLocalAddress.AddressFamily switch
|
||||
{
|
||||
AddressFamily.InterNetwork => IPAddress.Any,
|
||||
AddressFamily.InterNetworkV6 => IPAddress.IPv6Any,
|
||||
_ => null
|
||||
};
|
||||
if (wildcard is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var targetListenerFound = false;
|
||||
foreach (var listener in listeners)
|
||||
{
|
||||
if (listener.LocalAddress is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listener.Port != port ||
|
||||
listener.LocalAddress.AddressFamily != requestedLocalAddress.AddressFamily ||
|
||||
(!listener.LocalAddress.Equals(requestedLocalAddress) &&
|
||||
!listener.LocalAddress.Equals(wildcard)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (listener.ProcessId != processId)
|
||||
{
|
||||
// Any competing owner able to accept the requested endpoint fails closed,
|
||||
// even if the target-owned row appeared earlier in the table.
|
||||
return true;
|
||||
}
|
||||
|
||||
targetListenerFound = true;
|
||||
}
|
||||
|
||||
isOwned = targetListenerFound;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsValidRequestedEndpoint(
|
||||
IPAddress? requestedLocalAddress,
|
||||
int port,
|
||||
int processId) =>
|
||||
requestedLocalAddress is not null &&
|
||||
IPAddress.IsLoopback(requestedLocalAddress) &&
|
||||
requestedLocalAddress.AddressFamily is
|
||||
AddressFamily.InterNetwork or AddressFamily.InterNetworkV6 &&
|
||||
port is >= 1 and <= 65_535 &&
|
||||
processId > 0;
|
||||
|
||||
internal static int DecodePort(uint value) =>
|
||||
unchecked((ushort)IPAddress.NetworkToHostOrder((short)value));
|
||||
|
||||
[DllImport("iphlpapi.dll", SetLastError = true)]
|
||||
private static extern uint GetExtendedTcpTable(
|
||||
IntPtr tcpTable,
|
||||
ref int size,
|
||||
[MarshalAs(UnmanagedType.Bool)] bool order,
|
||||
int addressFamily,
|
||||
TcpTableClass tableClass,
|
||||
uint reserved);
|
||||
|
||||
private enum TcpTableClass
|
||||
{
|
||||
OwnerPidListener = 3
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MibTcpRowOwnerPid
|
||||
{
|
||||
public uint State;
|
||||
public uint LocalAddress;
|
||||
public uint LocalPort;
|
||||
public uint RemoteAddress;
|
||||
public uint RemotePort;
|
||||
public int OwningPid;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MibTcp6RowOwnerPid
|
||||
{
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)]
|
||||
public byte[] LocalAddress;
|
||||
public uint LocalScopeId;
|
||||
public uint LocalPort;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)]
|
||||
public byte[] RemoteAddress;
|
||||
public uint RemoteScopeId;
|
||||
public uint RemotePort;
|
||||
public uint State;
|
||||
public int OwningPid;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using System.Runtime.ExceptionServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
|
||||
@@ -36,18 +37,32 @@ internal interface IK3dSessionFactory
|
||||
internal sealed class DynamicK3dSessionFactory : IK3dSessionFactory
|
||||
{
|
||||
private readonly ILateBoundComActivator _activator;
|
||||
private readonly ILateBoundComMethodInvoker _invoker;
|
||||
|
||||
public DynamicK3dSessionFactory()
|
||||
: this(new RegisteredComActivator())
|
||||
: this(
|
||||
new InstalledK3dInteropComActivator(),
|
||||
new InstalledK3dInteropMethodInvoker())
|
||||
{
|
||||
}
|
||||
|
||||
internal DynamicK3dSessionFactory(ILateBoundComActivator activator)
|
||||
: this(activator, new InstalledK3dInteropMethodInvoker())
|
||||
{
|
||||
_activator = activator;
|
||||
}
|
||||
|
||||
public IK3dSession Create() => new DynamicK3dSession(_activator);
|
||||
internal DynamicK3dSessionFactory(
|
||||
ILateBoundComActivator activator,
|
||||
ILateBoundComMethodInvoker invoker)
|
||||
{
|
||||
_activator = activator;
|
||||
_invoker = invoker;
|
||||
}
|
||||
|
||||
public IK3dSession Create() => new DynamicK3dSession(
|
||||
_activator,
|
||||
new RuntimeComObjectReleaser(),
|
||||
_invoker);
|
||||
}
|
||||
|
||||
internal interface ILateBoundComActivator
|
||||
@@ -60,6 +75,11 @@ internal interface ILateBoundComObjectReleaser
|
||||
void Release(object value);
|
||||
}
|
||||
|
||||
internal interface ILateBoundComMethodInvoker
|
||||
{
|
||||
object? Invoke(object target, string method, params object?[] arguments);
|
||||
}
|
||||
|
||||
internal sealed class RegisteredComActivator : ILateBoundComActivator
|
||||
{
|
||||
public object Create(Guid classId)
|
||||
@@ -86,26 +106,42 @@ internal sealed class DynamicK3dSession : IK3dSession
|
||||
{
|
||||
private readonly ILateBoundComActivator _activator;
|
||||
private readonly ILateBoundComObjectReleaser _releaser;
|
||||
private readonly ILateBoundComMethodInvoker _invoker;
|
||||
private object? _engine;
|
||||
private object? _eventHandler;
|
||||
private object? _player;
|
||||
private object? _scene;
|
||||
private object? _preparedScene;
|
||||
private object? _currentScene;
|
||||
private readonly List<object> _retiredScenes = [];
|
||||
private int? _outputChannel;
|
||||
private int? _ownerThreadId;
|
||||
private int _lastKtapConnectState;
|
||||
private int _ktapDispatchGate;
|
||||
private bool _disposed;
|
||||
|
||||
public DynamicK3dSession(ILateBoundComActivator activator)
|
||||
: this(activator, new RuntimeComObjectReleaser())
|
||||
: this(
|
||||
activator,
|
||||
new RuntimeComObjectReleaser(),
|
||||
new InstalledK3dInteropMethodInvoker())
|
||||
{
|
||||
}
|
||||
|
||||
internal DynamicK3dSession(
|
||||
ILateBoundComActivator activator,
|
||||
ILateBoundComObjectReleaser releaser)
|
||||
: this(activator, releaser, new InstalledK3dInteropMethodInvoker())
|
||||
{
|
||||
}
|
||||
|
||||
internal DynamicK3dSession(
|
||||
ILateBoundComActivator activator,
|
||||
ILateBoundComObjectReleaser releaser,
|
||||
ILateBoundComMethodInvoker invoker)
|
||||
{
|
||||
_activator = activator;
|
||||
_releaser = releaser;
|
||||
_invoker = invoker;
|
||||
}
|
||||
|
||||
public bool IsConnected => _engine is not null && _player is not null;
|
||||
@@ -169,9 +205,11 @@ internal sealed class DynamicK3dSession : IK3dSession
|
||||
|
||||
SetKtapConnectState(PlayoutKtapConnectState.AcceptedUnconfirmed);
|
||||
|
||||
_player = options.OutputChannel is { } channel
|
||||
var outputChannel = options.OutputChannel;
|
||||
_player = outputChannel is { } channel
|
||||
? InvokeRequired(_engine, "GetScenePlayerOnChannel", channel)
|
||||
: InvokeRequired(_engine, "GetScenePlayer");
|
||||
_outputChannel = outputChannel;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -238,16 +276,22 @@ internal sealed class DynamicK3dSession : IK3dSession
|
||||
EnsureConnected();
|
||||
var engine = _engine!;
|
||||
var player = _player!;
|
||||
var outputChannel = _outputChannel;
|
||||
object? nextScene = null;
|
||||
var transactionStarted = false;
|
||||
|
||||
try
|
||||
{
|
||||
nextScene = InvokeRequired(engine, "LoadScene", cue.SceneFile, cue.SceneName);
|
||||
if (outputChannel.HasValue)
|
||||
{
|
||||
Invoke(nextScene, "SetOutputChannelIndex", outputChannel.GetValueOrDefault());
|
||||
}
|
||||
|
||||
Invoke(
|
||||
nextScene,
|
||||
"SetSceneEffectType",
|
||||
layoutIndex,
|
||||
K3dComConstants.SceneEffectInEnabled,
|
||||
K3dComConstants.SceneChangeEffectFade,
|
||||
cue.FadeDuration);
|
||||
|
||||
@@ -258,14 +302,23 @@ internal sealed class DynamicK3dSession : IK3dSession
|
||||
ApplyField(nextScene, field);
|
||||
}
|
||||
|
||||
Invoke(nextScene, "QueryVariables");
|
||||
Invoke(engine, "EndTransaction");
|
||||
if (outputChannel.HasValue)
|
||||
{
|
||||
Invoke(engine, "EndTransactionOnChannel", outputChannel.GetValueOrDefault());
|
||||
}
|
||||
else
|
||||
{
|
||||
Invoke(engine, "EndTransaction");
|
||||
}
|
||||
|
||||
transactionStarted = false;
|
||||
Invoke(nextScene, "QueryVariables");
|
||||
Invoke(player, "Prepare", layoutIndex, nextScene);
|
||||
|
||||
ReleaseComObject(_scene);
|
||||
_scene = nextScene;
|
||||
var replacedPreparedScene = _preparedScene;
|
||||
_preparedScene = nextScene;
|
||||
nextScene = null;
|
||||
RetainRetiredScene(replacedPreparedScene);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -285,7 +338,7 @@ internal sealed class DynamicK3dSession : IK3dSession
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReleaseComObject(nextScene);
|
||||
RetainRetiredScene(nextScene);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,6 +347,20 @@ internal sealed class DynamicK3dSession : IK3dSession
|
||||
EnsureThread();
|
||||
EnsureConnected();
|
||||
Invoke(_player!, "Play", layoutIndex);
|
||||
|
||||
var preparedScene = _preparedScene;
|
||||
if (preparedScene is not null)
|
||||
{
|
||||
_preparedScene = null;
|
||||
var previousCurrentScene = _currentScene;
|
||||
_currentScene = preparedScene;
|
||||
if (previousCurrentScene is not null &&
|
||||
!ReferenceEquals(previousCurrentScene, preparedScene))
|
||||
{
|
||||
RetainRetiredScene(previousCurrentScene);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void TakeOut(int layoutIndex, PlayoutTakeOutScope scope)
|
||||
@@ -308,6 +375,14 @@ internal sealed class DynamicK3dSession : IK3dSession
|
||||
{
|
||||
Invoke(_player!, "CutOut", layoutIndex);
|
||||
}
|
||||
|
||||
var currentScene = _currentScene;
|
||||
_currentScene = null;
|
||||
if (currentScene is not null)
|
||||
{
|
||||
RetainRetiredScene(currentScene);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void Abandon()
|
||||
@@ -406,43 +481,60 @@ internal sealed class DynamicK3dSession : IK3dSession
|
||||
|
||||
private void ReleaseAll()
|
||||
{
|
||||
var scene = _scene;
|
||||
_scene = null;
|
||||
var scenes = new List<object>();
|
||||
AddDistinctScene(scenes, _preparedScene);
|
||||
_preparedScene = null;
|
||||
AddDistinctScene(scenes, _currentScene);
|
||||
_currentScene = null;
|
||||
foreach (var retiredScene in _retiredScenes)
|
||||
{
|
||||
AddDistinctScene(scenes, retiredScene);
|
||||
}
|
||||
|
||||
_retiredScenes.Clear();
|
||||
var player = _player;
|
||||
_player = null;
|
||||
_outputChannel = null;
|
||||
var engine = _engine;
|
||||
_engine = null;
|
||||
var eventHandler = _eventHandler;
|
||||
_eventHandler = null;
|
||||
|
||||
ReleaseComObject(scene);
|
||||
foreach (var scene in scenes)
|
||||
{
|
||||
ReleaseComObject(scene);
|
||||
}
|
||||
|
||||
ReleaseComObject(player);
|
||||
ReleaseComObject(engine);
|
||||
ReleaseComObject(eventHandler);
|
||||
}
|
||||
|
||||
private static object InvokeRequired(object target, string method, params object?[] arguments) =>
|
||||
private void RetainRetiredScene(object? scene)
|
||||
{
|
||||
if (scene is not null &&
|
||||
!ReferenceEquals(scene, _preparedScene) &&
|
||||
!ReferenceEquals(scene, _currentScene) &&
|
||||
!_retiredScenes.Any(item => ReferenceEquals(item, scene)))
|
||||
{
|
||||
_retiredScenes.Add(scene);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddDistinctScene(List<object> scenes, object? scene)
|
||||
{
|
||||
if (scene is not null && !scenes.Any(item => ReferenceEquals(item, scene)))
|
||||
{
|
||||
scenes.Add(scene);
|
||||
}
|
||||
}
|
||||
|
||||
private object InvokeRequired(object target, string method, params object?[] arguments) =>
|
||||
Invoke(target, method, arguments)
|
||||
?? throw new InvalidOperationException("The K3D operation returned no object.");
|
||||
|
||||
private static object? Invoke(object target, string method, params object?[] arguments)
|
||||
{
|
||||
try
|
||||
{
|
||||
return target.GetType().InvokeMember(
|
||||
method,
|
||||
BindingFlags.InvokeMethod | BindingFlags.Public | BindingFlags.Instance,
|
||||
binder: null,
|
||||
target,
|
||||
arguments,
|
||||
CultureInfo.InvariantCulture);
|
||||
}
|
||||
catch (TargetInvocationException exception) when (exception.InnerException is not null)
|
||||
{
|
||||
ExceptionDispatchInfo.Capture(exception.InnerException).Throw();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
private object? Invoke(object target, string method, params object?[] arguments) =>
|
||||
_invoker.Invoke(target, method, arguments);
|
||||
|
||||
private void ReleaseComObject(object? value)
|
||||
{
|
||||
|
||||
@@ -15,5 +15,6 @@ public static class K3dComConstants
|
||||
|
||||
public const string ApartmentThreadingModel = "Apartment";
|
||||
public const int LegacyLayoutIndex = 10;
|
||||
public const int SceneEffectInEnabled = 1;
|
||||
public const int SceneChangeEffectFade = 7;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.Playout.Tests")]
|
||||
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.PlayoutSmoke")]
|
||||
|
||||
@@ -498,11 +498,11 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
IK3dSession? pendingSession = null;
|
||||
try
|
||||
{
|
||||
var session = _sessionFactory.Create();
|
||||
Volatile.Write(ref pendingSession, session);
|
||||
_session = await _dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
var session = _sessionFactory.Create();
|
||||
Volatile.Write(ref pendingSession, session);
|
||||
try
|
||||
{
|
||||
session.Connect(_options);
|
||||
@@ -517,7 +517,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
_options.ConnectTimeout,
|
||||
cancellationToken,
|
||||
static lateSession => lateSession.Dispose(),
|
||||
() => Volatile.Read(ref pendingSession)?.Dispose()).ConfigureAwait(false);
|
||||
() => Volatile.Read(ref pendingSession)?.TryPreventKtapConnect()).ConfigureAwait(false);
|
||||
|
||||
CaptureKtapEvidence(_session);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user