feat: verify Tornado PGM KTAP connection

This commit is contained in:
2026-07-10 14:28:45 +09:00
parent b113830c14
commit 930424b752
22 changed files with 3474 additions and 67 deletions

View File

@@ -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"
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.Playout.Tests")]
[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.PlayoutSmoke")]

View File

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