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

@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Interop;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
@@ -233,15 +234,16 @@ public sealed class DynamicK3dSessionTests
"KTAPConnect:1:127.0.0.1:30001:0",
"GetScenePlayerOnChannel:9",
"LoadScene:test-scene",
"SetSceneEffectType:10:7:12",
"SetOutputChannelIndex:9",
"SetSceneEffectType:1:7:12",
"BeginTransaction",
"GetObject:headline",
"SetValue:headline:safe value",
"SetVisible:headline:1",
"GetObject:badge",
"SetVisible:badge:0",
"EndTransactionOnChannel:9",
"QueryVariables",
"EndTransaction",
"Prepare:10",
"Play:10",
expectedTakeOutCall,
@@ -252,6 +254,285 @@ public sealed class DynamicK3dSessionTests
Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state));
}
[Fact]
public async Task Prepare_WithoutOutputChannel_UsesDefaultPlayerAndDoesNotSetSceneChannel()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var unchanneledOptions = TestOptions(scenes.Path);
unchanneledOptions.Mode = PlayoutMode.Live;
unchanneledOptions.OutputChannel = null;
var options = ValidatedPlayoutOptions.Create(unchanneledOptions);
var cue = options.ResolveCue(new PlayoutCue(
"test-scene.t2s",
"test-scene",
[],
FadeDuration: 12));
var log = new FakeComLog();
var player = new FakePlayer(log);
var scene = new FakeScene(log);
var engine = new FakeEngine(log, player, scene);
var activator = new FakeActivator(log, engine);
await using var dispatcher = new StaDispatcher(capacity: 2);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(activator);
session.Connect(options);
session.Prepare(cue, options.LayoutIndex);
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Equal(
new[]
{
$"Create:{K3dComConstants.KaEventHandlerClassGuid:B}",
$"Create:{K3dComConstants.KaEngineClassGuid:B}",
"KTAPConnect:1:127.0.0.1:30001:0",
"GetScenePlayer",
"LoadScene:test-scene",
"SetSceneEffectType:1:7:12",
"BeginTransaction",
"EndTransaction",
"QueryVariables",
"Prepare:10",
"Disconnect"
},
log.Names);
Assert.DoesNotContain(log.Names, name => name.StartsWith("SetOutputChannelIndex:", StringComparison.Ordinal));
}
[Fact]
public async Task SceneReplacement_5001To5006_RetainsAllScenesUntilDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var options = SceneOptions(scenes.Path, "5001", "5006");
var cue5001 = SceneCue(options, "5001");
var cue5006 = SceneCue(options, "5006");
var log = new FakeComLog();
var player = new FakePlayer(log);
var scene5001 = new FakeScene(log, "5001");
var scene5006 = new FakeScene(log, "5006");
var engine = new FakeEngine(log, player, scene5001)
{
SceneResolver = name => name == "5006" ? scene5006 : scene5001
};
var activator = new FakeActivator(log, engine);
var releaser = new FakeReleaser(
log,
(scene5001, "5001"),
(scene5006, "5006"),
(player, "Player"),
(engine, "Engine"),
(activator.EventHandler, "EventHandler"));
await using var dispatcher = new StaDispatcher(capacity: 4);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(activator, releaser);
session.Connect(options);
session.Prepare(cue5001, options.LayoutIndex);
session.Play(options.LayoutIndex);
session.Prepare(cue5006, options.LayoutIndex);
Assert.DoesNotContain("Unload:5001", log.Names);
session.Play(options.LayoutIndex);
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
Assert.DoesNotContain(log.Names, name =>
name.StartsWith("Release:500", StringComparison.Ordinal));
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Equal(
new[]
{
"LoadScene:5001",
"Prepare:10",
"Play:10",
"LoadScene:5006",
"Prepare:10",
"Play:10",
"StopAll",
"Disconnect",
"Release:5001",
"Release:5006"
},
SceneLifecycleCalls(log));
}
[Fact]
public async Task Prepare_ReplacingUnplayedScene_RetainsBothScenesUntilDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var options = SceneOptions(scenes.Path, "5001", "5006");
var log = new FakeComLog();
var player = new FakePlayer(log);
var scene5001 = new FakeScene(log, "5001");
var scene5006 = new FakeScene(log, "5006");
var engine = new FakeEngine(log, player, scene5001)
{
SceneResolver = name => name == "5006" ? scene5006 : scene5001
};
var activator = new FakeActivator(log, engine);
var releaser = new FakeReleaser(
log,
(scene5001, "5001"),
(scene5006, "5006"),
(player, "Player"),
(engine, "Engine"),
(activator.EventHandler, "EventHandler"));
await using var dispatcher = new StaDispatcher(capacity: 2);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(activator, releaser);
session.Connect(options);
session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
session.Prepare(SceneCue(options, "5006"), options.LayoutIndex);
Assert.DoesNotContain(log.Names, name =>
name.StartsWith("Release:500", StringComparison.Ordinal));
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Equal(
new[]
{
"LoadScene:5001",
"Prepare:10",
"LoadScene:5006",
"Prepare:10",
"Disconnect",
"Release:5006",
"Release:5001"
},
SceneLifecycleCalls(log));
Assert.DoesNotContain("Unload:5006", log.Names);
}
[Fact]
public async Task Prepare_WhenReplacementFails_RetainsFailedAndOnAirScenesUntilDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var options = SceneOptions(scenes.Path, "5001", "5006");
var originalFailure = new COMException("fake replacement failure");
var log = new FakeComLog();
var player = new FakePlayer(log);
var scene5001 = new FakeScene(log, "5001");
var scene5006 = new FakeScene(
log,
"5006",
queryVariablesFailure: originalFailure);
var engine = new FakeEngine(log, player, scene5001)
{
SceneResolver = name => name == "5006" ? scene5006 : scene5001
};
var activator = new FakeActivator(log, engine);
var releaser = new FakeReleaser(
log,
(scene5001, "5001"),
(scene5006, "5006"),
(player, "Player"),
(engine, "Engine"),
(activator.EventHandler, "EventHandler"));
await using var dispatcher = new StaDispatcher(capacity: 3);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(activator, releaser);
session.Connect(options);
session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
session.Play(options.LayoutIndex);
var exception = Assert.Throws<COMException>(
() => session.Prepare(SceneCue(options, "5006"), options.LayoutIndex));
Assert.Same(originalFailure, exception);
Assert.DoesNotContain("Unload:5001", log.Names);
Assert.DoesNotContain(log.Names, name =>
name.StartsWith("Release:500", StringComparison.Ordinal));
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
Assert.DoesNotContain(log.Names, name =>
name.StartsWith("Release:500", StringComparison.Ordinal));
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Equal(
new[]
{
"LoadScene:5001",
"Prepare:10",
"Play:10",
"LoadScene:5006",
"StopAll",
"Disconnect",
"Release:5006",
"Release:5001"
},
SceneLifecycleCalls(log));
}
[Fact]
public async Task AsyncOutputSequence_DoesNotUnloadAndReleasesScenesOnlyAfterDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var options = SceneOptions(scenes.Path, "5001", "5006");
var log = new FakeComLog();
var player = new FakePlayer(log);
var scene5001 = new FakeScene(log, "5001");
var scene5006 = new FakeScene(log, "5006");
var engine = new FakeEngine(log, player, scene5001)
{
SceneResolver = name => name == "5006" ? scene5006 : scene5001
};
var activator = new FakeActivator(log, engine);
var releaser = new FakeReleaser(
log,
(scene5001, "5001"),
(scene5006, "5006"),
(player, "Player"),
(engine, "Engine"),
(activator.EventHandler, "EventHandler"));
await using var dispatcher = new StaDispatcher(capacity: 4);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(activator, releaser);
session.Connect(options);
session.Prepare(SceneCue(options, "5001"), options.LayoutIndex);
session.Play(options.LayoutIndex);
session.Prepare(SceneCue(options, "5006"), options.LayoutIndex);
session.Play(options.LayoutIndex);
session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All);
Assert.DoesNotContain(log.Names, name =>
name.StartsWith("Release:500", StringComparison.Ordinal));
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Equal(2, log.Names.Count(name => name == "Play:10"));
Assert.Equal(1, log.Names.Count(name => name == "StopAll"));
Assert.DoesNotContain(log.Names, name => name.StartsWith("Unload", StringComparison.Ordinal));
Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
Assert.Equal(1, log.Names.Count(name => name == "Release:5006"));
}
[Fact]
public void LateBoundActivatorContract_AcceptsOnlyAClsid()
{
@@ -345,17 +626,20 @@ public sealed class DynamicK3dSessionTests
}
[Fact]
public async Task Abandon_ReleasesEveryComReferenceWithoutDisconnect()
public async Task Abandon_AfterUnknownPlayOutcome_ReleasesReferencesWithoutSdkCleanup()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
var cue = SceneCue(options, "test-scene");
var playFailure = new COMException("fake ambiguous play failure");
var log = new FakeComLog();
var player = new FakePlayer(log);
var player = new FakePlayer(log, playFailure);
var scene = new FakeScene(log);
var engine = new FakeEngine(log, player, scene);
var activator = new FakeActivator(log, engine);
var releaser = new FakeReleaser(
log,
(scene, "Scene"),
(player, "Player"),
(engine, "Engine"),
(activator.EventHandler, "EventHandler"));
@@ -366,6 +650,10 @@ public sealed class DynamicK3dSessionTests
{
using var session = new DynamicK3dSession(activator, releaser);
session.Connect(options);
session.Prepare(cue, options.LayoutIndex);
var exception = Assert.Throws<COMException>(
() => session.Play(options.LayoutIndex));
Assert.Same(playFailure, exception);
session.Abandon();
return true;
},
@@ -373,6 +661,9 @@ public sealed class DynamicK3dSessionTests
CancellationToken.None);
Assert.DoesNotContain("Disconnect", log.Names);
Assert.DoesNotContain(log.Names, name => name.StartsWith("Unload", StringComparison.Ordinal));
Assert.Equal(1, log.Names.Count(name => name == "Play:10"));
Assert.Equal(1, log.Names.Count(name => name == "Release:Scene"));
Assert.Equal(1, log.Names.Count(name => name == "Release:Player"));
Assert.Equal(1, log.Names.Count(name => name == "Release:Engine"));
Assert.Equal(1, log.Names.Count(name => name == "Release:EventHandler"));
@@ -387,6 +678,34 @@ public sealed class DynamicK3dSessionTests
TestSceneAllowlist = ["test-scene"]
};
private static ValidatedPlayoutOptions SceneOptions(
string sceneDirectory,
params string[] sceneNames)
{
var options = TestOptions(sceneDirectory);
options.TestSceneAllowlist = [.. sceneNames];
return ValidatedPlayoutOptions.Create(options);
}
private static PlayoutCue SceneCue(ValidatedPlayoutOptions options, string sceneName) =>
options.ResolveCue(new PlayoutCue(
$"{sceneName}.t2s",
sceneName,
[],
FadeDuration: 0));
private static string[] SceneLifecycleCalls(FakeComLog log) =>
log.Names.Where(name =>
name.StartsWith("LoadScene:", StringComparison.Ordinal) ||
name == "Prepare:10" ||
name == "Play:10" ||
name == "StopAll" ||
name == "CutOut:10" ||
name == "Disconnect" ||
name == "RollbackTransaction" ||
name.StartsWith("Unload:", StringComparison.Ordinal) ||
name.StartsWith("Release:500", StringComparison.Ordinal)).ToArray();
public sealed class FakeComLog
{
private readonly ConcurrentQueue<string> _names = new();
@@ -516,6 +835,8 @@ public sealed class DynamicK3dSessionTests
_connectFailure = connectFailure;
}
public Func<string, FakeScene>? SceneResolver { get; init; }
public int KTAPConnect(
int tcpMode,
string host,
@@ -544,17 +865,33 @@ public sealed class DynamicK3dSessionTests
return _player;
}
public object GetScenePlayer()
{
_log.Add("GetScenePlayer");
if (_playerFailure is not null)
{
throw _playerFailure;
}
return _player;
}
public object LoadScene(string sceneFile, string sceneName)
{
Assert.True(System.IO.Path.IsPathFullyQualified(sceneFile));
_log.Add($"LoadScene:{sceneName}");
return _scene;
return SceneResolver?.Invoke(sceneName) ?? _scene;
}
public void BeginTransaction() => _log.Add("BeginTransaction");
public void EndTransaction() => _log.Add("EndTransaction");
public void EndTransactionOnChannel(int channel) =>
_log.Add($"EndTransactionOnChannel:{channel}");
public void RollbackTransaction() => _log.Add("RollbackTransaction");
public void Disconnect()
{
_log.Add("Disconnect");
@@ -588,10 +925,12 @@ public sealed class DynamicK3dSessionTests
public sealed class FakePlayer
{
private readonly FakeComLog _log;
private readonly Exception? _playFailure;
public FakePlayer(FakeComLog log)
public FakePlayer(FakeComLog log, Exception? playFailure = null)
{
_log = log;
_playFailure = playFailure;
}
public void Prepare(int layoutIndex, object scene)
@@ -600,7 +939,14 @@ public sealed class DynamicK3dSessionTests
_log.Add($"Prepare:{layoutIndex}");
}
public void Play(int layoutIndex) => _log.Add($"Play:{layoutIndex}");
public void Play(int layoutIndex)
{
_log.Add($"Play:{layoutIndex}");
if (_playFailure is not null)
{
throw _playFailure;
}
}
public void StopAll() => _log.Add("StopAll");
@@ -610,14 +956,27 @@ public sealed class DynamicK3dSessionTests
public sealed class FakeScene
{
private readonly FakeComLog _log;
private readonly string? _name;
private readonly Exception? _queryVariablesFailure;
private readonly Exception? _unloadFailure;
public FakeScene(FakeComLog log)
public FakeScene(
FakeComLog log,
string? name = null,
Exception? queryVariablesFailure = null,
Exception? unloadFailure = null)
{
_log = log;
_name = name;
_queryVariablesFailure = queryVariablesFailure;
_unloadFailure = unloadFailure;
}
public void SetSceneEffectType(int layoutIndex, int effectType, int duration) =>
_log.Add($"SetSceneEffectType:{layoutIndex}:{effectType}:{duration}");
public void SetOutputChannelIndex(int channel) =>
_log.Add($"SetOutputChannelIndex:{channel}");
public void SetSceneEffectType(int inEffect, int effectType, int duration) =>
_log.Add($"SetSceneEffectType:{inEffect}:{effectType}:{duration}");
public object GetObject(string objectName)
{
@@ -625,7 +984,23 @@ public sealed class DynamicK3dSessionTests
return new FakeSceneObject(_log, objectName);
}
public void QueryVariables() => _log.Add("QueryVariables");
public void QueryVariables()
{
_log.Add("QueryVariables");
if (_queryVariablesFailure is not null)
{
throw _queryVariablesFailure;
}
}
public void Unload()
{
_log.Add(_name is null ? "Unload" : $"Unload:{_name}");
if (_unloadFailure is not null)
{
throw _unloadFailure;
}
}
}
public sealed class FakeSceneObject

View File

@@ -0,0 +1,143 @@
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
using MBN_STOCK_WEBVIEW.Playout.Interop;
using MBN_STOCK_WEBVIEW.Playout.Registration;
using System.Security.Cryptography;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class InstalledK3dInteropMetadataTests
{
[Fact]
public void ApprovedSha256_RequiresAndDecodesExactly64HexCharacters()
{
var parsed = InstalledK3dInteropMetadata.ParseApprovedSha256(
"0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789ABCDEF");
Assert.Equal(32, parsed.Length);
Assert.Equal(0x01, parsed[0]);
Assert.Equal(0xEF, parsed[^1]);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde")]
[InlineData(" 123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")]
[InlineData("g123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")]
public void ApprovedSha256_RejectsMissingMalformedOrWhitespaceValues(string? value)
{
Assert.Throws<InstalledK3dInteropMetadataUnavailableException>(
() => InstalledK3dInteropMetadata.ParseApprovedSha256(value));
}
[Fact]
public void ApprovedSha256_RequiresExactContentForEitherBinaryPin()
{
byte[] content = [1, 2, 3, 4];
var approved = Convert.ToHexString(SHA256.HashData(content));
using var matching = new MemoryStream(content);
using var different = new MemoryStream([1, 2, 3, 5]);
InstalledK3dInteropMetadata.ValidateApprovedSha256(matching, approved);
Assert.Throws<InstalledK3dInteropMetadataUnavailableException>(
() => InstalledK3dInteropMetadata.ValidateApprovedSha256(different, approved));
Assert.Equal(
"MBN_STOCK_K3D_NATIVE_SHA256",
InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable);
Assert.Equal(
"MBN_STOCK_K3D_INTEROP_SHA256",
InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable);
}
[Fact]
public void FileLease_KeepsNativeAndInteropFilesWriteLocked()
{
var directory = Path.Combine(Path.GetTempPath(), $"k3d-lease-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
var nativePath = Path.Combine(directory, "native.dll");
var interopPath = Path.Combine(directory, "interop.dll");
File.WriteAllBytes(nativePath, [1]);
File.WriteAllBytes(interopPath, [2]);
InstalledK3dInteropFileLease? lease = null;
try
{
lease = new InstalledK3dInteropFileLease(
new FileStream(nativePath, FileMode.Open, FileAccess.Read, FileShare.Read),
new FileStream(interopPath, FileMode.Open, FileAccess.Read, FileShare.Read));
Assert.Throws<IOException>(() => OpenForWrite(nativePath).Dispose());
Assert.Throws<IOException>(() => OpenForWrite(interopPath).Dispose());
}
finally
{
lease?.InteropStream.Dispose();
lease?.NativeStream.Dispose();
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void RegisteredPaths_MapOnlyExactX64VendorLayout()
{
const string native = @"C:\Vendor\K3DAsyncEngine\DLL\x64\Release\K3DAsyncEngine.dll";
var paths = InstalledK3dInteropMetadata.ResolveRegisteredPaths(ReadySnapshot(native));
Assert.Equal(Path.GetFullPath(native), paths.NativeBinaryPath);
Assert.Equal(
Path.GetFullPath(
@"C:\Vendor\K3DAsyncEngine\Bin\x64\C#\Interop.K3DAsyncEngineLib.dll"),
paths.InteropAssemblyPath);
}
[Fact]
public void RegisteredPaths_RejectDivergentClassBinary()
{
const string native = @"C:\Vendor\K3DAsyncEngine\DLL\x64\Release\K3DAsyncEngine.dll";
var snapshot = ReadySnapshot(native) with
{
EventHandler = ReadySnapshot(native).EventHandler with
{
InprocServerPath =
@"C:\Other\K3DAsyncEngine\DLL\x64\Release\K3DAsyncEngine.dll"
}
};
Assert.Throws<InstalledK3dInteropMetadataUnavailableException>(
() => InstalledK3dInteropMetadata.ResolveRegisteredPaths(snapshot));
}
[Fact]
public void RegisteredPaths_RejectUnexpectedNativeLayout()
{
const string native = @"C:\Vendor\K3DAsyncEngine\bin\K3DAsyncEngine.dll";
Assert.Throws<InstalledK3dInteropMetadataUnavailableException>(
() => InstalledK3dInteropMetadata.ResolveRegisteredPaths(ReadySnapshot(native)));
}
private static K3dRegistrySnapshot ReadySnapshot(string nativePath) => new(
true,
nativePath,
new K3dClassRegistrationSnapshot(
true,
K3dComConstants.KaEngineProgId,
K3dComConstants.KaEngineClassId,
nativePath,
K3dComConstants.ApartmentThreadingModel,
K3dComConstants.TypeLibraryId),
new K3dClassRegistrationSnapshot(
true,
K3dComConstants.KaEventHandlerProgId,
K3dComConstants.KaEventHandlerClassId,
nativePath,
K3dComConstants.ApartmentThreadingModel,
K3dComConstants.TypeLibraryId),
false);
private static FileStream OpenForWrite(string path) => new(
path,
FileMode.Open,
FileAccess.Write,
FileShare.ReadWrite | FileShare.Delete);
}

View File

@@ -142,7 +142,13 @@ public sealed class IsolatedTestCommandTests : IDisposable
"Disconnect",
"Dispose"
], engine.Calls);
Assert.Equal([TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)], delays);
Assert.Equal(
[
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(1)
],
delays);
Assert.True(report.Completed);
Assert.True(report.ComActivationAttempted);
Assert.Equal("accepted-unconfirmed", report.LastKtapConnectState);

View File

@@ -0,0 +1,490 @@
using System.Collections.Concurrent;
using System.Net;
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
using MBN_STOCK_WEBVIEW.Playout.Interop;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class PgmConnectDiagnosticTests
{
[Fact]
public void RealContactDiagnosticApi_IsNotPublic()
{
Assert.False(typeof(PgmConnectDiagnosticRunner).IsVisible);
Assert.False(typeof(PgmConnectDiagnosticRequest).IsVisible);
Assert.False(typeof(PgmConnectDiagnosticReport).IsVisible);
Assert.Null(typeof(PgmConnectDiagnosticRunner).GetMethod(
nameof(PgmConnectDiagnosticRunner.ExecuteAsync),
System.Reflection.BindingFlags.Instance |
System.Reflection.BindingFlags.Public));
}
[Theory]
[InlineData("localhost", 30001, "PGM")]
[InlineData("192.0.2.1", 30001, "PGM")]
[InlineData("127.0.0.1", 0, "PGM")]
[InlineData("127.0.0.1", 30001, "TEST")]
[InlineData("127.0.0.1", 30001, "CONPROGRAM")]
public void RequestValidation_RejectsUnsafeEndpointOrWindow(
string host,
int port,
string title)
{
var valid = PgmConnectDiagnosticRunner.TryValidateRequest(
new PgmConnectDiagnosticRequest(host, port, title),
out _);
Assert.False(valid);
}
[Fact]
public async Task Runner_SuccessUsesOnlyConnectThenDisconnectAndReportsUnconfirmedEvidence()
{
var snapshot = ReadySnapshot(10);
var safety = new FakeSafetyProbe(snapshot, snapshot, snapshot, snapshot);
var session = new FakeConnectOnlySession();
var factory = new FakeConnectOnlySessionFactory(session);
var runner = new PgmConnectDiagnosticRunner(
safety,
factory,
new FakeRegistrationProbe(),
new TrackingStaDispatcherFactory());
var request = new PgmConnectDiagnosticRequest("127.0.0.1", 30001, "PGM");
var report = await runner.ExecuteAsync(request);
Assert.Equal(["Connect", "Disconnect"], session.Calls);
Assert.True(report.Completed);
Assert.False(report.OutcomeUnknown);
Assert.True(report.ConnectRequestIssued);
Assert.True(report.ComActivationAttempted);
Assert.Equal(PlayoutKtapConnectState.AcceptedUnconfirmed, report.LastKtapConnectState);
Assert.True(report.KtapConnectAttempted);
Assert.True(report.KtapConnectAccepted);
Assert.Null(report.KtapHelloObserved);
Assert.True(report.NetworkMonitoringRecordExpected);
Assert.True(report.NetworkMonitoringCheckRequired);
Assert.Null(report.NetworkMonitoringVerified);
Assert.Equal("success", report.DisconnectCode);
Assert.False(report.RenderCommandSurfaceExposed);
Assert.False(report.RenderCommandAttempted);
Assert.False(report.AutomaticReconnectEnabled);
var json = JsonSerializer.Serialize(report);
Assert.DoesNotContain("127.0.0.1", json, StringComparison.Ordinal);
Assert.DoesNotContain("30001", json, StringComparison.Ordinal);
Assert.DoesNotContain("PGM", json, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Runner_TargetRejectedStopsBeforeRegistrationAndCom()
{
var rejected = new PgmDiagnosticTargetSnapshot(false, 0, 0, false, null);
var factory = new FakeConnectOnlySessionFactory(new FakeConnectOnlySession());
var registration = new FakeRegistrationProbe();
var runner = new PgmConnectDiagnosticRunner(
new FakeSafetyProbe(rejected),
factory,
registration,
new TrackingStaDispatcherFactory());
var report = await runner.ExecuteAsync(
new PgmConnectDiagnosticRequest("127.0.0.1", 30001, "PGM"));
Assert.False(report.Completed);
Assert.False(report.ConnectRequestIssued);
Assert.False(report.ComActivationAttempted);
Assert.Equal(0, factory.CreateCount);
Assert.Equal(0, registration.ProbeCount);
Assert.Equal("pgm-target-rejected", report.ErrorCode);
}
[Fact]
public async Task Runner_UnapprovedInstalledInteropIsDistinctFromComActivationFailure()
{
var snapshot = ReadySnapshot(10);
var runner = new PgmConnectDiagnosticRunner(
new FakeSafetyProbe(snapshot),
new UnavailableInteropSessionFactory(),
new FakeRegistrationProbe(),
new TrackingStaDispatcherFactory());
var report = await runner.ExecuteAsync(
new PgmConnectDiagnosticRequest("127.0.0.1", 30001, "PGM"));
Assert.False(report.Completed);
Assert.False(report.ComActivationAttempted);
Assert.False(report.KtapConnectAttempted);
Assert.False(report.OutcomeUnknown);
Assert.Equal("not-attempted", report.DisconnectCode);
Assert.Equal("installed-interop-metadata-unavailable", report.ErrorCode);
}
[Fact]
public async Task Runner_TargetChangeAfterAcceptanceAbandonsWithoutDisconnect()
{
var baseline = ReadySnapshot(10);
var changed = ReadySnapshot(11);
var session = new FakeConnectOnlySession();
var runner = new PgmConnectDiagnosticRunner(
new FakeSafetyProbe(baseline, baseline, baseline, changed),
new FakeConnectOnlySessionFactory(session),
new FakeRegistrationProbe(),
new TrackingStaDispatcherFactory());
var report = await runner.ExecuteAsync(
new PgmConnectDiagnosticRequest("127.0.0.1", 30001, "PGM"));
Assert.Equal(["Connect", "Abandon"], session.Calls);
Assert.False(report.Completed);
Assert.True(report.OutcomeUnknown);
Assert.True(report.KtapConnectAttempted);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.Equal("pgm-target-changed-after-connect", report.ErrorCode);
}
[Fact]
public async Task DynamicSession_InvokesOnlyKtapConnectAndDisconnect()
{
var log = new ConcurrentQueue<string>();
var session = new DynamicKtapConnectOnlySession(new RecordingComBinding(log));
await using var dispatcher = new Runtime.StaDispatcher(1);
await dispatcher.InvokeAsync(
() =>
{
session.Connect("127.0.0.1", 30001, static () => true);
session.Disconnect();
return true;
},
TimeSpan.FromSeconds(2),
CancellationToken.None);
Assert.Equal(
[
"CreateEventHandler",
"CreateEngine",
"KTAPConnect",
"Disconnect",
"Release",
"Release"
],
log);
Assert.Equal(PlayoutKtapConnectState.AcceptedUnconfirmed, session.LastKtapConnectState);
}
[Fact]
public async Task DynamicSession_FinalSafetyCheckRunsAfterActivationAndBeforeKtap()
{
var log = new ConcurrentQueue<string>();
var session = new DynamicKtapConnectOnlySession(new RecordingComBinding(log));
await using var dispatcher = new Runtime.StaDispatcher(1);
await Assert.ThrowsAsync<PgmDiagnosticSafetyException>(() =>
dispatcher.InvokeAsync(
() =>
{
session.Connect(
"127.0.0.1",
30001,
() =>
{
log.Enqueue("FinalSafetyCheck");
return false;
});
return true;
},
TimeSpan.FromSeconds(2),
CancellationToken.None));
Assert.Equal(
[
"CreateEventHandler",
"CreateEngine",
"FinalSafetyCheck",
"Release",
"Release"
],
log);
Assert.DoesNotContain("KTAPConnect", log);
Assert.Equal(PlayoutKtapConnectState.NotAttempted, session.LastKtapConnectState);
}
[Fact]
public async Task Runner_ConnectTimeoutPreventsLateKtapDispatch()
{
var snapshot = ReadySnapshot(10);
using var safety = new BlockingSecondCaptureSafetyProbe(snapshot);
var session = new FakeConnectOnlySession();
var dispatcherFactory = new TrackingStaDispatcherFactory();
var runner = new PgmConnectDiagnosticRunner(
safety,
new FakeConnectOnlySessionFactory(session),
new FakeRegistrationProbe(),
dispatcherFactory,
TimeSpan.FromMilliseconds(50),
TimeSpan.FromSeconds(1));
var execution = runner.ExecuteAsync(
new PgmConnectDiagnosticRequest("127.0.0.1", 30001, "PGM"));
try
{
Assert.True(safety.SecondCaptureEntered.Wait(TimeSpan.FromSeconds(2)));
Assert.True(session.PreventionAttempted.Wait(TimeSpan.FromSeconds(2)));
}
finally
{
safety.ReleaseSecondCapture.Set();
}
var report = await execution.WaitAsync(TimeSpan.FromSeconds(3));
Assert.True(session.ConnectCompleted.Wait(TimeSpan.FromSeconds(2)));
Assert.Equal("connect-timeout", report.ErrorCode);
Assert.False(report.Completed);
Assert.False(report.OutcomeUnknown);
Assert.False(report.KtapConnectAttempted);
Assert.Equal(PlayoutKtapConnectState.NotAttempted, report.LastKtapConnectState);
Assert.False(session.ComActivationAttempted);
Assert.Equal(0, session.DispatchCount);
Assert.Contains("Connect", session.Calls);
}
[Fact]
public void ConnectOnlySessionSurfaceHasNoRenderCommand()
{
var methodNames = typeof(IKtapConnectOnlySession)
.GetMethods()
.Select(method => method.Name)
.ToArray();
string[] forbidden =
[
"GetScenePlayer",
"LoadScene",
"Prepare",
"Play",
"PlayDirect",
"TakeIn",
"Next",
"TakeOut",
"CutOut",
"Stop",
"StopAll"
];
Assert.DoesNotContain(methodNames, name =>
forbidden.Contains(name, StringComparer.OrdinalIgnoreCase));
Assert.Contains("Connect", methodNames);
Assert.Contains("Disconnect", methodNames);
}
[Fact]
public void ListenerPortDecoderUsesNetworkByteOrder()
{
const ushort port = 30001;
var encoded = unchecked((uint)(ushort)IPAddress.HostToNetworkOrder((short)port));
Assert.Equal(port, WindowsTcpListenerOwnershipProbe.DecodePort(encoded));
}
private static PgmDiagnosticTargetSnapshot ReadySnapshot(int processId) => new(
true,
1,
1,
true,
new PgmDiagnosticTargetIdentity(processId, 100));
private sealed class FakeSafetyProbe : IPgmDiagnosticSafetyProbe
{
private readonly Queue<PgmDiagnosticTargetSnapshot> _snapshots;
private PgmDiagnosticTargetSnapshot _last;
public FakeSafetyProbe(params PgmDiagnosticTargetSnapshot[] snapshots)
{
_snapshots = new Queue<PgmDiagnosticTargetSnapshot>(snapshots);
_last = snapshots.Last();
}
public PgmDiagnosticTargetSnapshot Capture(
PgmConnectDiagnosticRequest request,
IPAddress parsedHost)
{
if (_snapshots.Count > 0)
{
_last = _snapshots.Dequeue();
}
return _last;
}
}
private sealed class BlockingSecondCaptureSafetyProbe : IPgmDiagnosticSafetyProbe, IDisposable
{
private readonly PgmDiagnosticTargetSnapshot _snapshot;
private int _captureCount;
public BlockingSecondCaptureSafetyProbe(PgmDiagnosticTargetSnapshot snapshot)
{
_snapshot = snapshot;
}
public ManualResetEventSlim SecondCaptureEntered { get; } = new(false);
public ManualResetEventSlim ReleaseSecondCapture { get; } = new(false);
public PgmDiagnosticTargetSnapshot Capture(
PgmConnectDiagnosticRequest request,
IPAddress parsedHost)
{
if (Interlocked.Increment(ref _captureCount) == 2)
{
SecondCaptureEntered.Set();
ReleaseSecondCapture.Wait();
}
return _snapshot;
}
public void Dispose()
{
ReleaseSecondCapture.Set();
SecondCaptureEntered.Dispose();
ReleaseSecondCapture.Dispose();
}
}
private sealed class FakeConnectOnlySessionFactory : IKtapConnectOnlySessionFactory
{
private readonly IKtapConnectOnlySession _session;
public FakeConnectOnlySessionFactory(IKtapConnectOnlySession session)
{
_session = session;
}
public int CreateCount { get; private set; }
public IKtapConnectOnlySession Create()
{
CreateCount++;
return _session;
}
}
private sealed class UnavailableInteropSessionFactory : IKtapConnectOnlySessionFactory
{
public IKtapConnectOnlySession Create() =>
throw new InstalledK3dInteropMetadataUnavailableException();
}
private sealed class FakeConnectOnlySession : IKtapConnectOnlySession
{
private int _dispatchGate;
private int _dispatchCount;
private int _abandonRecorded;
public ConcurrentQueue<string> Calls { get; } = new();
public ManualResetEventSlim PreventionAttempted { get; } = new(false);
public ManualResetEventSlim ConnectCompleted { get; } = new(false);
public int DispatchCount => Volatile.Read(ref _dispatchCount);
public bool ComActivationAttempted { get; private set; }
public PlayoutKtapConnectState LastKtapConnectState { get; private set; }
public bool TryPreventKtapConnect()
{
PreventionAttempted.Set();
var previous = Interlocked.CompareExchange(ref _dispatchGate, 2, 0);
if (previous == 1 && LastKtapConnectState == PlayoutKtapConnectState.NotAttempted)
{
LastKtapConnectState = PlayoutKtapConnectState.Attempted;
}
return previous == 0;
}
public void Connect(string host, int port, Func<bool> finalSafetyCheck)
{
Calls.Enqueue("Connect");
try
{
if (Volatile.Read(ref _dispatchGate) != 0)
{
throw new InvalidOperationException();
}
ComActivationAttempted = true;
if (!finalSafetyCheck())
{
throw new PgmDiagnosticSafetyException();
}
if (Interlocked.CompareExchange(ref _dispatchGate, 1, 0) != 0)
{
throw new InvalidOperationException();
}
Interlocked.Increment(ref _dispatchCount);
LastKtapConnectState = PlayoutKtapConnectState.AcceptedUnconfirmed;
}
finally
{
ConnectCompleted.Set();
}
}
public void Disconnect() => Calls.Enqueue("Disconnect");
public void Abandon()
{
TryPreventKtapConnect();
if (Interlocked.Exchange(ref _abandonRecorded, 1) == 0)
{
Calls.Enqueue("Abandon");
}
}
}
private sealed class RecordingComBinding : IK3dConnectOnlyComBinding
{
private readonly ConcurrentQueue<string> _log;
public RecordingComBinding(ConcurrentQueue<string> log)
{
_log = log;
}
public object CreateEventHandler()
{
_log.Enqueue("CreateEventHandler");
return new object();
}
public object CreateEngine()
{
_log.Enqueue("CreateEngine");
return new object();
}
public int KtapConnect(
object engine,
string host,
int port,
object eventHandler)
{
_log.Enqueue("KTAPConnect");
return 1;
}
public void Disconnect(object engine) => _log.Enqueue("Disconnect");
public void Release(object value) => _log.Enqueue("Release");
}
}

View File

@@ -0,0 +1,150 @@
using System.Net;
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class PgmDiagnosticSafetyProbeTests
{
private const int Port = 30_001;
private const int TargetProcessId = 101;
private const int CompetingProcessId = 202;
[Theory]
[InlineData("127.0.0.1", "127.0.0.1")]
[InlineData("127.0.0.1", "0.0.0.0")]
[InlineData("::1", "::1")]
[InlineData("::1", "::")]
public void TargetOwnedExactOrWildcardListener_IsAccepted(
string requestedAddress,
string listenerAddress)
{
var queried = Evaluate(
requestedAddress,
[Entry(listenerAddress, TargetProcessId)],
out var isOwned);
Assert.True(queried);
Assert.True(isOwned);
}
[Theory]
[InlineData("127.0.0.1", "127.0.0.1", "0.0.0.0")]
[InlineData("127.0.0.1", "0.0.0.0", "127.0.0.1")]
[InlineData("::1", "::1", "::")]
[InlineData("::1", "::", "::1")]
public void MatchingEndpointWithCompetingOwner_FailsClosedEvenAfterTargetRow(
string requestedAddress,
string targetAddress,
string competingAddress)
{
var queried = Evaluate(
requestedAddress,
[
Entry(targetAddress, TargetProcessId),
Entry(competingAddress, CompetingProcessId)
],
out var isOwned);
Assert.True(queried);
Assert.False(isOwned);
}
[Theory]
[InlineData("127.0.0.1", "127.0.0.2")]
[InlineData("::1", "::2")]
public void DifferentLocalAddressOwnedByCompetitor_DoesNotCompete(
string requestedAddress,
string unrelatedAddress)
{
var queried = Evaluate(
requestedAddress,
[
Entry(requestedAddress, TargetProcessId),
Entry(unrelatedAddress, CompetingProcessId)
],
out var isOwned);
Assert.True(queried);
Assert.True(isOwned);
}
[Theory]
[InlineData("127.0.0.1", "127.0.0.2")]
[InlineData("::1", "::2")]
public void TargetOwnedDifferentLocalAddress_IsNotAccepted(
string requestedAddress,
string differentAddress)
{
var queried = Evaluate(
requestedAddress,
[Entry(differentAddress, TargetProcessId)],
out var isOwned);
Assert.True(queried);
Assert.False(isOwned);
}
[Theory]
[InlineData("127.0.0.1", "::")]
[InlineData("::1", "0.0.0.0")]
public void OtherAddressFamilyWildcard_DoesNotMatchRequestedEndpoint(
string requestedAddress,
string otherFamilyWildcard)
{
var queried = Evaluate(
requestedAddress,
[Entry(otherFamilyWildcard, TargetProcessId)],
out var isOwned);
Assert.True(queried);
Assert.False(isOwned);
}
[Theory]
[InlineData("0.0.0.0")]
[InlineData("192.0.2.1")]
[InlineData("::")]
[InlineData("2001:db8::1")]
public void RequestedAddressMustBeLoopback(string requestedAddress)
{
var queried = Evaluate(
requestedAddress,
[Entry(requestedAddress, TargetProcessId)],
out var isOwned);
Assert.False(queried);
Assert.False(isOwned);
}
[Theory]
[InlineData("127.0.0.1", "127.0.0.1")]
[InlineData("::1", "::1")]
public void MatchingEndpointOwnedOnlyByCompetitor_IsRejected(
string requestedAddress,
string competingAddress)
{
var queried = Evaluate(
requestedAddress,
[Entry(competingAddress, CompetingProcessId)],
out var isOwned);
Assert.True(queried);
Assert.False(isOwned);
}
private static bool Evaluate(
string requestedAddress,
IReadOnlyList<TcpListenerOwnershipEntry> listeners,
out bool isOwned) =>
WindowsTcpListenerOwnershipProbe.TryEvaluateOwnership(
IPAddress.Parse(requestedAddress),
Port,
TargetProcessId,
listeners,
out isOwned);
private static TcpListenerOwnershipEntry Entry(
string address,
int processId) =>
new(IPAddress.Parse(address), Port, processId);
}

View File

@@ -181,6 +181,88 @@ public sealed class SmokeCommandLineTests
Assert.Equal(SmokeCommandKind.TestPlan, parsed.Invocation.Kind);
}
[Fact]
public void Parse_PgmConnectDiagnosticRequiresDistinctAcknowledgementAndExactInputs()
{
string[] complete =
[
"--pgm-connect-diagnostic",
SmokeCommandLine.PgmConnectAcknowledgementFlag,
"--host",
"127.0.0.1",
"--port",
"30001",
"--expected-pgm-window-title",
"PGM"
];
var missingAcknowledgement = SmokeCommandLine.Parse(complete[0..1]
.Concat(complete[2..])
.ToArray());
var wrongAcknowledgement = SmokeCommandLine.Parse(
complete.Select(value => value == SmokeCommandLine.PgmConnectAcknowledgementFlag
? SmokeCommandLine.AcknowledgementFlag
: value).ToArray());
var parsed = SmokeCommandLine.Parse(complete);
Assert.False(missingAcknowledgement.IsValid);
Assert.Equal("missing-acknowledgement", missingAcknowledgement.ErrorCode);
Assert.False(wrongAcknowledgement.IsValid);
Assert.Equal("unknown-option", wrongAcknowledgement.ErrorCode);
Assert.True(parsed.IsValid);
Assert.Equal(SmokeCommandKind.PgmConnectDiagnostic, parsed.Invocation.Kind);
Assert.Equal("127.0.0.1", parsed.Invocation.Host);
Assert.Equal(30001, parsed.Invocation.Port);
Assert.Equal("PGM", parsed.Invocation.ExpectedPgmWindowTitle);
Assert.Null(parsed.Invocation.ConfigPath);
}
[Theory]
[InlineData("--config")]
[InlineData("--prepare")]
[InlineData("--next")]
[InlineData("--observe-ms")]
public void Parse_PgmConnectDiagnosticRejectsEveryConfigOrRenderOption(string option)
{
var parsed = SmokeCommandLine.Parse(
[
"--pgm-connect-diagnostic",
SmokeCommandLine.PgmConnectAcknowledgementFlag,
"--host",
"127.0.0.1",
"--port",
"30001",
"--expected-pgm-window-title",
"PGM",
option,
"5001"
]);
Assert.False(parsed.IsValid);
Assert.Equal("unknown-option", parsed.ErrorCode);
}
[Theory]
[InlineData("0")]
[InlineData("65536")]
[InlineData("secret")]
public void Parse_PgmConnectDiagnosticRejectsInvalidPort(string port)
{
var parsed = SmokeCommandLine.Parse(
[
"--pgm-connect-diagnostic",
SmokeCommandLine.PgmConnectAcknowledgementFlag,
"--host",
"127.0.0.1",
"--port",
port,
"--expected-pgm-window-title",
"PGM"
]);
Assert.False(parsed.IsValid);
Assert.Equal("invalid-port", parsed.ErrorCode);
}
private static SmokeCommandParseResult ValidSequenceArguments(
string prepare = "5001",
string observation = "1000") =>

View File

@@ -239,16 +239,91 @@ public sealed class TornadoPlayoutEngineTests
Assert.False(engine.Status.NetworkMonitoringRecordExpected);
Assert.False(engine.Status.NetworkMonitoringCheckRequired);
Assert.Equal(0, session.KtapDispatchCount);
Assert.DoesNotContain("Dispose", session.Calls);
releaseActivation.Set();
Assert.True(SpinWait.SpinUntil(
() => session.Calls.Contains("Dispose"),
TimeSpan.FromSeconds(2)));
Assert.Equal(0, session.KtapDispatchCount);
Assert.Equal(new[] { "Connect", "Dispose" }, session.Calls);
Assert.Single(session.ThreadIds.Distinct());
Assert.All(
session.ApartmentStates,
state => Assert.Equal(ApartmentState.STA, state));
}
finally
{
releaseActivation.Set();
}
}
[Fact]
public async Task TestConnect_BlockingFactoryThenConnectTimeout_PreventsLateKtapDispatch()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
using var factoryStarted = new ManualResetEventSlim();
using var releaseFactory = new ManualResetEventSlim();
using var beforeKtapDispatch = new ManualResetEventSlim();
using var releaseConnect = new ManualResetEventSlim();
var session = new FakeK3dSession
{
BeforeKtapDispatchAction = () =>
{
beforeKtapDispatch.Set();
if (!releaseConnect.Wait(TimeSpan.FromSeconds(5)))
{
throw new TimeoutException("fake connect coordination timeout");
}
}
};
var sessionFactory = new FakeK3dSessionFactory(() =>
{
factoryStarted.Set();
if (!releaseFactory.Wait(TimeSpan.FromSeconds(5)))
{
throw new TimeoutException("fake factory coordination timeout");
}
return session;
});
var options = TestOptions(scenes.Path, "test-scene");
options.ConnectTimeoutMilliseconds = 100;
await using var engine = CreateEngine(
options,
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
try
{
var connectTask = Task.Run(() => engine.ConnectAsync(CancellationToken.None));
Assert.True(factoryStarted.Wait(TimeSpan.FromSeconds(2)));
await Task.Delay(250);
Assert.False(connectTask.IsCompleted);
Assert.Equal(0, session.KtapDispatchCount);
releaseFactory.Set();
Assert.True(beforeKtapDispatch.Wait(TimeSpan.FromSeconds(2)));
var result = await connectTask;
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
Assert.Equal(PlayoutKtapConnectState.NotAttempted, engine.Status.LastKtapConnectState);
Assert.Equal(0, session.KtapDispatchCount);
Assert.DoesNotContain("Dispose", session.Calls);
releaseConnect.Set();
Assert.True(SpinWait.SpinUntil(
() => session.Calls.Contains("Dispose"),
TimeSpan.FromSeconds(2)));
Assert.Equal(0, session.KtapDispatchCount);
}
finally
{
releaseActivation.Set();
releaseFactory.Set();
releaseConnect.Set();
}
}