494 lines
17 KiB
C#
494 lines
17 KiB
C#
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()
|
|
{
|
|
var previous = Interlocked.CompareExchange(ref _dispatchGate, 2, 0);
|
|
if (previous == 1 && LastKtapConnectState == PlayoutKtapConnectState.NotAttempted)
|
|
{
|
|
LastKtapConnectState = PlayoutKtapConnectState.Attempted;
|
|
}
|
|
|
|
// Publish the test synchronization point only after the dispatch gate
|
|
// is closed. Signalling first lets the blocked safety probe resume and
|
|
// race KTAP dispatch before this prevention attempt has taken effect.
|
|
PreventionAttempted.Set();
|
|
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");
|
|
}
|
|
}
|