fix: add truthful KTAP monitoring diagnostics

This commit is contained in:
2026-07-10 12:17:42 +09:00
parent fc932b27f6
commit 306d3a70a1
15 changed files with 764 additions and 33 deletions

View File

@@ -15,6 +15,14 @@ public sealed class DryRunPlayoutEngineTests
Assert.Equal(PlayoutConnectionState.DryRunReady, engine.Status.State);
Assert.True(engine.Status.IsCommandAvailable);
Assert.False(engine.Status.IsConnected);
Assert.Equal(
PlayoutKtapConnectState.NotAttempted,
engine.Status.LastKtapConnectState);
Assert.False(engine.Status.KtapConnectAttempted);
Assert.False(engine.Status.NetworkMonitoringRecordExpected);
Assert.False(engine.Status.NetworkMonitoringCheckRequired);
Assert.Null(engine.Status.KtapHelloObserved);
Assert.Contains("Network Monitoring", engine.Status.Message, StringComparison.Ordinal);
Assert.True(result.IsSuccess);
Assert.True(result.IsDryRun);
}

View File

@@ -39,6 +39,9 @@ public sealed class DynamicK3dSessionTests
catch
{
Assert.False(session.IsConnected);
Assert.Equal(
PlayoutKtapConnectState.Failed,
session.LastKtapConnectState);
throw;
}
@@ -86,19 +89,29 @@ public sealed class DynamicK3dSessionTests
(engine, "Engine"),
(activator.EventHandler, "EventHandler"));
await using var dispatcher = new StaDispatcher(capacity: 1);
var lastKtapConnectState = PlayoutKtapConnectState.NotAttempted;
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(activator, releaser);
session.Connect(options);
try
{
session.Connect(options);
}
finally
{
lastKtapConnectState = session.LastKtapConnectState;
}
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None));
Assert.Same(originalFailure, exception);
Assert.Equal(PlayoutKtapConnectState.AcceptedUnconfirmed, lastKtapConnectState);
Assert.Equal(
new[]
{
@@ -134,19 +147,29 @@ public sealed class DynamicK3dSessionTests
(engine, "Engine"),
(activator.EventHandler, "EventHandler"));
await using var dispatcher = new StaDispatcher(capacity: 1);
var lastKtapConnectState = PlayoutKtapConnectState.NotAttempted;
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(activator, releaser);
session.Connect(options);
try
{
session.Connect(options);
}
finally
{
lastKtapConnectState = session.LastKtapConnectState;
}
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None));
Assert.Same(originalFailure, exception);
Assert.Equal(PlayoutKtapConnectState.Failed, lastKtapConnectState);
Assert.Equal(
new[]
{
@@ -190,6 +213,9 @@ public sealed class DynamicK3dSessionTests
{
using var session = new DynamicK3dSession(activator);
session.Connect(options);
Assert.Equal(
PlayoutKtapConnectState.AcceptedUnconfirmed,
session.LastKtapConnectState);
session.Prepare(cue, options.LayoutIndex);
session.Play(options.LayoutIndex);
session.TakeOut(options.LayoutIndex, takeOutScope);
@@ -236,6 +262,88 @@ public sealed class DynamicK3dSessionTests
Assert.Equal(typeof(object), create.ReturnType);
}
[Fact]
public async Task Connect_WhenComActivationFails_ReportsKtapNotAttempted()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
var originalFailure = new InvalidOperationException("fake activation failure");
var lastKtapConnectState = PlayoutKtapConnectState.Failed;
await using var dispatcher = new StaDispatcher(capacity: 1);
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(
new ThrowingActivator(originalFailure));
try
{
session.Connect(options);
}
finally
{
lastKtapConnectState = session.LastKtapConnectState;
}
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None));
Assert.Same(originalFailure, exception);
Assert.Equal(PlayoutKtapConnectState.NotAttempted, lastKtapConnectState);
}
[Fact]
public async Task PreventKtapConnect_DuringActivation_BlocksLateDispatch()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
using var activationStarted = new ManualResetEventSlim();
using var releaseActivation = new ManualResetEventSlim();
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
var log = new FakeComLog();
var engine = new FakeEngine(log, new FakePlayer(log), new FakeScene(log));
var activator = new BlockingEngineActivator(
log,
engine,
activationStarted,
releaseActivation);
var session = new DynamicK3dSession(activator);
await using var dispatcher = new StaDispatcher(capacity: 1);
try
{
var connectTask = dispatcher.InvokeAsync(
() =>
{
using (session)
{
session.Connect(options);
}
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.True(activationStarted.Wait(TimeSpan.FromSeconds(2)));
Assert.True(session.TryPreventKtapConnect());
releaseActivation.Set();
await Assert.ThrowsAsync<InvalidOperationException>(() => connectTask);
Assert.Equal(
PlayoutKtapConnectState.NotAttempted,
session.LastKtapConnectState);
Assert.DoesNotContain(log.Names, name =>
name.StartsWith("KTAPConnect:", StringComparison.Ordinal));
}
finally
{
releaseActivation.Set();
}
}
[Fact]
public async Task Abandon_ReleasesEveryComReferenceWithoutDisconnect()
{
@@ -325,6 +433,61 @@ public sealed class DynamicK3dSessionTests
}
}
private sealed class ThrowingActivator : ILateBoundComActivator
{
private readonly Exception _exception;
public ThrowingActivator(Exception exception)
{
_exception = exception;
}
public object Create(Guid classId) => throw _exception;
}
private sealed class BlockingEngineActivator : ILateBoundComActivator
{
private readonly FakeComLog _log;
private readonly FakeEngine _engine;
private readonly ManualResetEventSlim _activationStarted;
private readonly ManualResetEventSlim _releaseActivation;
private readonly object _eventHandler = new();
public BlockingEngineActivator(
FakeComLog log,
FakeEngine engine,
ManualResetEventSlim activationStarted,
ManualResetEventSlim releaseActivation)
{
_log = log;
_engine = engine;
_activationStarted = activationStarted;
_releaseActivation = releaseActivation;
}
public object Create(Guid classId)
{
_log.Add($"Create:{classId:B}");
if (classId == K3dComConstants.KaEventHandlerClassGuid)
{
return _eventHandler;
}
if (classId != K3dComConstants.KaEngineClassGuid)
{
throw new InvalidOperationException("Unexpected fake CLSID.");
}
_activationStarted.Set();
if (!_releaseActivation.Wait(TimeSpan.FromSeconds(5)))
{
throw new TimeoutException("fake activation coordination timeout");
}
return _engine;
}
}
public sealed class FakeEngine
{
private readonly FakeComLog _log;

View File

@@ -145,6 +145,12 @@ public sealed class IsolatedTestCommandTests : IDisposable
Assert.Equal([TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)], delays);
Assert.True(report.Completed);
Assert.True(report.ComActivationAttempted);
Assert.Equal("accepted-unconfirmed", report.LastKtapConnectState);
Assert.True(report.KtapConnectAttempted);
Assert.True(report.KtapConnectAccepted);
Assert.True(report.NetworkMonitoringRecordExpected);
Assert.True(report.NetworkMonitoringCheckRequired);
Assert.Null(report.NetworkMonitoringVerified);
Assert.False(report.OutcomeUnknown);
Assert.Equal(6, report.Steps.Count);
Assert.True(report.Cleanup.AdapterDisposed);
@@ -163,6 +169,9 @@ public sealed class IsolatedTestCommandTests : IDisposable
Assert.Equal(["Connect", "Disconnect", "Dispose"], engine.Calls);
Assert.True(report.Completed);
Assert.True(report.ComActivationAttempted);
Assert.Equal("accepted-unconfirmed", report.LastKtapConnectState);
Assert.True(report.NetworkMonitoringRecordExpected);
Assert.True(report.NetworkMonitoringCheckRequired);
}
[Fact]
@@ -177,6 +186,9 @@ public sealed class IsolatedTestCommandTests : IDisposable
(_, _) => Task.CompletedTask);
Assert.Null(report.ComActivationAttempted);
Assert.Equal("not-attempted", report.LastKtapConnectState);
Assert.False(report.NetworkMonitoringRecordExpected);
Assert.False(report.NetworkMonitoringCheckRequired);
Assert.Equal(["Connect", "Disconnect", "Dispose"], engine.Calls);
}
@@ -192,6 +204,11 @@ public sealed class IsolatedTestCommandTests : IDisposable
(_, _) => Task.CompletedTask);
Assert.Null(report.ComActivationAttempted);
Assert.Equal("attempted", report.LastKtapConnectState);
Assert.True(report.KtapConnectAttempted);
Assert.Null(report.KtapConnectAccepted);
Assert.Null(report.NetworkMonitoringRecordExpected);
Assert.True(report.NetworkMonitoringCheckRequired);
Assert.True(report.OutcomeUnknown);
Assert.Equal(["Connect", "Quarantine", "Dispose"], engine.Calls);
Assert.True(report.Cleanup.QuarantineCompleted);
@@ -492,7 +509,7 @@ public sealed class IsolatedTestCommandTests : IDisposable
public bool ThrowOnQuarantine { get; init; }
public PlayoutStatus Status { get; }
public PlayoutStatus Status { get; private set; }
public event EventHandler<PlayoutStatusChangedEventArgs>? StatusChanged
{
@@ -553,6 +570,21 @@ public sealed class IsolatedTestCommandTests : IDisposable
{
Calls.Add(call);
var code = _codes.GetValueOrDefault(operation, PlayoutResultCode.Success);
if (operation == PlayoutOperation.Connect)
{
Status = Status with
{
LastKtapConnectState = code switch
{
PlayoutResultCode.Success => PlayoutKtapConnectState.AcceptedUnconfirmed,
PlayoutResultCode.Failed => PlayoutKtapConnectState.Failed,
PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut =>
PlayoutKtapConnectState.Attempted,
_ => PlayoutKtapConnectState.NotAttempted
}
};
}
return Task.FromResult(new PlayoutResult(
operation,
code,

View File

@@ -34,9 +34,13 @@ internal sealed class FakeK3dSessionFactory : IK3dSessionFactory
internal sealed class FakeK3dSession : IK3dSession
{
private int _disposed;
private int _ktapDispatchGate;
private int _ktapDispatchCount;
public bool IsConnected { get; private set; }
public PlayoutKtapConnectState LastKtapConnectState { get; private set; }
public ConcurrentQueue<string> Calls { get; } = new();
public ConcurrentQueue<int> ThreadIds { get; } = new();
@@ -45,6 +49,10 @@ internal sealed class FakeK3dSession : IK3dSession
public Action<ValidatedPlayoutOptions>? ConnectAction { get; set; }
public Action? BeforeKtapDispatchAction { get; set; }
public int KtapDispatchCount => Volatile.Read(ref _ktapDispatchCount);
public Action? DisconnectAction { get; set; }
public Action<PlayoutCue, int>? PrepareAction { get; set; }
@@ -60,10 +68,39 @@ internal sealed class FakeK3dSession : IK3dSession
public void Connect(ValidatedPlayoutOptions options)
{
Record("Connect");
ConnectAction?.Invoke(options);
BeforeKtapDispatchAction?.Invoke();
if (Interlocked.CompareExchange(ref _ktapDispatchGate, 1, 0) != 0)
{
throw new InvalidOperationException("Fake KTAP dispatch was prevented.");
}
LastKtapConnectState = PlayoutKtapConnectState.Attempted;
Interlocked.Increment(ref _ktapDispatchCount);
try
{
ConnectAction?.Invoke(options);
}
catch
{
LastKtapConnectState = PlayoutKtapConnectState.Failed;
throw;
}
LastKtapConnectState = PlayoutKtapConnectState.AcceptedUnconfirmed;
IsConnected = true;
}
public bool TryPreventKtapConnect()
{
var previous = Interlocked.CompareExchange(ref _ktapDispatchGate, 2, 0);
if (previous == 1 && LastKtapConnectState == PlayoutKtapConnectState.NotAttempted)
{
LastKtapConnectState = PlayoutKtapConnectState.Attempted;
}
return previous == 0;
}
public void Disconnect()
{
Record("Disconnect");
@@ -91,6 +128,7 @@ internal sealed class FakeK3dSession : IK3dSession
public void Abandon()
{
TryPreventKtapConnect();
if (Interlocked.Exchange(ref _disposed, 1) == 0)
{
Record("Abandon");

View File

@@ -37,6 +37,12 @@ public sealed class TornadoPlayoutEngineTests
Assert.Equal(PlayoutResultCode.Success, prepare.Code);
Assert.Equal(0, sessionFactory.CreateCount);
Assert.Equal(0, registration.ProbeCount);
Assert.Equal(
PlayoutKtapConnectState.NotAttempted,
engine.Status.LastKtapConnectState);
Assert.False(engine.Status.NetworkMonitoringRecordExpected);
Assert.False(engine.Status.NetworkMonitoringCheckRequired);
Assert.Contains("Network Monitoring", engine.Status.Message, StringComparison.Ordinal);
}
[Fact]
@@ -94,6 +100,13 @@ public sealed class TornadoPlayoutEngineTests
Assert.Equal(expectedSuccess, result.IsSuccess);
Assert.Equal(expectedSuccess ? 1 : 0, sessionFactory.CreateCount);
Assert.Equal(expectedSuccess ? 1 : 0, registration.ProbeCount);
Assert.Equal(
expectedSuccess
? PlayoutKtapConnectState.AcceptedUnconfirmed
: PlayoutKtapConnectState.NotAttempted,
engine.Status.LastKtapConnectState);
Assert.Equal(expectedSuccess, engine.Status.NetworkMonitoringRecordExpected);
Assert.Equal(expectedSuccess, engine.Status.NetworkMonitoringCheckRequired);
Assert.NotNull(process.LastPattern);
if (expectedSuccess)
{
@@ -108,6 +121,169 @@ public sealed class TornadoPlayoutEngineTests
}
}
[Fact]
public async Task TestConnect_WhenKtapCallFails_ReportsAttemptedFailureEvidence()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var session = new FakeK3dSession
{
ConnectAction = _ => throw new InvalidOperationException("fake KTAP failure")
};
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
var result = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.Failed, result.Code);
Assert.Equal(
PlayoutKtapConnectState.Failed,
engine.Status.LastKtapConnectState);
Assert.True(engine.Status.KtapConnectAttempted);
Assert.Null(engine.Status.KtapConnectAccepted);
Assert.Null(engine.Status.NetworkMonitoringRecordExpected);
Assert.True(engine.Status.NetworkMonitoringCheckRequired);
Assert.Null(engine.Status.KtapHelloObserved);
}
[Fact]
public async Task TestConnect_WhenKtapCallTimesOut_PreservesAttemptedUnknownEvidence()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
using var connectStarted = new ManualResetEventSlim();
using var releaseConnect = new ManualResetEventSlim();
var session = new FakeK3dSession
{
ConnectAction = _ =>
{
connectStarted.Set();
if (!releaseConnect.Wait(TimeSpan.FromSeconds(5)))
{
throw new TimeoutException("fake test coordination timeout");
}
}
};
var options = TestOptions(scenes.Path, "test-scene");
options.ConnectTimeoutMilliseconds = 100;
await using var engine = CreateEngine(
options,
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
try
{
var connectTask = engine.ConnectAsync(CancellationToken.None);
Assert.True(connectStarted.Wait(TimeSpan.FromSeconds(2)));
var result = await connectTask;
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
Assert.Equal(
PlayoutKtapConnectState.Attempted,
engine.Status.LastKtapConnectState);
Assert.True(engine.Status.KtapConnectAttempted);
Assert.Null(engine.Status.KtapConnectAccepted);
Assert.Null(engine.Status.NetworkMonitoringRecordExpected);
Assert.True(engine.Status.NetworkMonitoringCheckRequired);
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
}
finally
{
releaseConnect.Set();
}
}
[Fact]
public async Task TestConnect_TimeoutDuringActivation_PreventsLateKtapDispatch()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
using var activationStarted = new ManualResetEventSlim();
using var releaseActivation = new ManualResetEventSlim();
var session = new FakeK3dSession
{
BeforeKtapDispatchAction = () =>
{
activationStarted.Set();
if (!releaseActivation.Wait(TimeSpan.FromSeconds(5)))
{
throw new TimeoutException("fake activation coordination timeout");
}
}
};
var options = TestOptions(scenes.Path, "test-scene");
options.ConnectTimeoutMilliseconds = 100;
await using var engine = CreateEngine(
options,
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
try
{
var connectTask = engine.ConnectAsync(CancellationToken.None);
Assert.True(activationStarted.Wait(TimeSpan.FromSeconds(2)));
var result = await connectTask;
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
Assert.Equal(
PlayoutKtapConnectState.NotAttempted,
engine.Status.LastKtapConnectState);
Assert.False(engine.Status.KtapConnectAttempted);
Assert.False(engine.Status.NetworkMonitoringRecordExpected);
Assert.False(engine.Status.NetworkMonitoringCheckRequired);
Assert.Equal(0, session.KtapDispatchCount);
releaseActivation.Set();
Assert.True(SpinWait.SpinUntil(
() => session.Calls.Contains("Dispose"),
TimeSpan.FromSeconds(2)));
Assert.Equal(0, session.KtapDispatchCount);
}
finally
{
releaseActivation.Set();
}
}
[Fact]
public async Task LastKtapEvidence_SurvivesLaterFailureBeforeAnotherDispatch()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var session = new FakeK3dSession();
var createCount = 0;
var sessionFactory = new FakeK3dSessionFactory(() =>
Interlocked.Increment(ref createCount) == 1
? session
: throw new InvalidOperationException("fake activation-stage failure"));
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True((await engine.DisconnectAsync(CancellationToken.None)).IsSuccess);
var secondConnect = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.Failed, secondConnect.Code);
Assert.Equal(PlayoutConnectionState.Faulted, engine.Status.State);
Assert.Equal(
PlayoutKtapConnectState.AcceptedUnconfirmed,
engine.Status.LastKtapConnectState);
Assert.True(engine.Status.NetworkMonitoringRecordExpected);
Assert.True(engine.Status.NetworkMonitoringCheckRequired);
Assert.Equal(2, sessionFactory.CreateCount);
}
[Fact]
public async Task TestWorkflow_ExecutesFakeSessionInCommandOrderOnSta()
{