feat: verify Tornado PGM cut sequence

This commit is contained in:
2026-07-10 16:05:06 +09:00
parent 930424b752
commit d12a5d8095
15 changed files with 4344 additions and 74 deletions

View File

@@ -185,6 +185,95 @@ public sealed class DynamicK3dSessionTests
Assert.Equal(1, log.Names.Count(name => name == "Disconnect"));
}
[Fact]
public async Task GuardedConnect_WhenFinalTargetCheckFails_BlocksKtapAndNeverDisconnects()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
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 FakeActivator(log, engine);
var releaser = new FakeReleaser(
log,
(engine, "Engine"),
(activator.EventHandler, "EventHandler"));
await using var dispatcher = new StaDispatcher(capacity: 1);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(activator, releaser);
Assert.Throws<K3dConnectSafetyException>(() => session.ConnectGuarded(
options,
() =>
{
log.Add("FinalSafetyCheck");
return false;
},
new FakeK3dSdkDispatchLatch()));
Assert.Equal(
PlayoutKtapConnectState.NotAttempted,
session.LastKtapConnectState);
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Equal(
new[]
{
$"Create:{K3dComConstants.KaEventHandlerClassGuid:B}",
$"Create:{K3dComConstants.KaEngineClassGuid:B}",
"FinalSafetyCheck",
"Release:Engine",
"Release:EventHandler"
},
log.Names);
Assert.DoesNotContain(log.Names, name =>
name.StartsWith("KTAPConnect:", StringComparison.Ordinal));
Assert.DoesNotContain("Disconnect", log.Names);
}
[Fact]
public async Task GuardedConnect_WhenKtapThrows_ReleasesWithoutSdkDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path));
var log = new FakeComLog();
var failure = new COMException("fake ambiguous guarded connect failure");
var engine = new FakeEngine(
log,
new FakePlayer(log),
new FakeScene(log),
connectFailure: failure);
var activator = new FakeActivator(log, engine);
var releaser = new FakeReleaser(
log,
(engine, "Engine"),
(activator.EventHandler, "EventHandler"));
await using var dispatcher = new StaDispatcher(capacity: 1);
await dispatcher.InvokeAsync(
() =>
{
using var session = new DynamicK3dSession(activator, releaser);
var exception = Assert.Throws<COMException>(
() => session.ConnectGuarded(
options,
() => true,
new FakeK3dSdkDispatchLatch()));
Assert.Same(failure, exception);
return true;
},
TimeSpan.FromSeconds(5),
CancellationToken.None);
Assert.Contains("KTAPConnect:1:127.0.0.1:30001:0", log.Names);
Assert.DoesNotContain("Disconnect", log.Names);
Assert.Equal(1, log.Names.Count(name => name == "Release:Engine"));
Assert.Equal(1, log.Names.Count(name => name == "Release:EventHandler"));
}
[Theory]
[InlineData(PlayoutTakeOutScope.All, "StopAll")]
[InlineData(PlayoutTakeOutScope.Layout, "CutOut:10")]

View File

@@ -0,0 +1,863 @@
using System.Collections.Concurrent;
using System.Text.Json;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Playout;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
using MBN_STOCK_WEBVIEW.Playout.Runtime;
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
public sealed class PgmCutsSequenceRunnerTests
{
[Fact]
public async Task StableTarget_ExecutesFixedSequenceWithExactSafetyAndMilestoneOrder()
{
using var environment = new EnvironmentScope()
.Set("MBN_STOCK_PLAYOUT_MODE", "DryRun")
.Set("MBN_STOCK_PLAYOUT_HOST", "192.0.2.99")
.Set("MBN_STOCK_PLAYOUT_OUTPUT_CHANNEL", "77")
.Set("MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT", "false");
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var events = new ConcurrentQueue<string>();
var safety = new FakeSafetyProbe(events);
var engine = new FakeEngine(events);
var factory = new FakeEngineFactory(events, engine);
var runner = new PgmCutsSequenceRunner(
safety,
new FakeHashPins(true),
factory,
new FakeDelay(events),
new FakeAssetLeases(true));
var report = await runner.ExecuteAsync(
Request(scenes.Path),
stage => events.Enqueue($"Milestone:{stage}"));
Assert.True(report.Completed);
Assert.False(report.OutcomeUnknown);
Assert.False(report.QuarantineAttempted);
Assert.Equal(
new[]
{
"Safety:1",
"CreateEngine",
"Safety:2",
"Connect",
"Safety:3",
"Safety:4",
"Prepare:5001",
"Safety:5",
"Safety:6",
"TakeIn",
"Safety:7",
"Safety:8",
"Milestone:First",
"Delay:5000",
"Safety:9",
"Safety:10",
"Next:5006",
"Safety:11",
"Safety:12",
"Milestone:Next",
"Delay:5000",
"Safety:13",
"Safety:14",
"TakeOut:All",
"Safety:15",
"Safety:16",
"Milestone:TakeOut",
"Delay:5000",
"Safety:17",
"Safety:18",
"Disconnect",
"Safety:19",
"Dispose"
},
events);
Assert.Equal(
new[]
{
PgmCutsSequenceStage.Connect,
PgmCutsSequenceStage.PrepareFirst,
PgmCutsSequenceStage.TakeIn,
PgmCutsSequenceStage.Next,
PgmCutsSequenceStage.TakeOut,
PgmCutsSequenceStage.Disconnect
},
report.Commands.Select(command => command.Stage));
var plan = Assert.IsType<PgmCutsSequencePlan>(factory.Plan);
Assert.Equal(PlayoutMode.Live, plan.Options.Mode);
Assert.Equal("127.0.0.1", plan.Options.Host);
Assert.Equal(30_001, plan.Options.Port);
Assert.Null(plan.Options.OutputChannel);
Assert.Equal(10, plan.Options.LayoutIndex);
Assert.True(plan.Options.TrustedLiveOutputEnabled);
Assert.False(plan.Options.ReconnectEnabled);
Assert.Equal(0, plan.Options.MaximumReconnectAttempts);
Assert.Equal(TimeSpan.FromMilliseconds(5_000), plan.ObservationDuration);
Assert.Equal("5001", plan.FirstCue.SceneName);
Assert.Equal("5006", plan.NextCue.SceneName);
Assert.Equal("5001.t2s", plan.FirstCue.SceneFile);
Assert.Equal("5006.t2s", plan.NextCue.SceneFile);
Assert.False(Path.IsPathFullyQualified(plan.FirstCue.SceneFile));
Assert.False(Path.IsPathFullyQualified(plan.NextCue.SceneFile));
Assert.True(Path.IsPathFullyQualified(plan.FirstAssetPath));
Assert.True(Path.IsPathFullyQualified(plan.NextAssetPath));
Assert.Equal("5001.t2s", Path.GetFileName(plan.FirstAssetPath));
Assert.Equal("5006.t2s", Path.GetFileName(plan.NextAssetPath));
}
[Fact]
public async Task RawPlanCue_IsResolvedExactlyOnceByTornadoEngineBeforeSdkPrepare()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var events = new ConcurrentQueue<string>();
var captureFactory = new FakeEngineFactory(events, new FakeEngine(events));
var runner = new PgmCutsSequenceRunner(
new FakeSafetyProbe(events),
new FakeHashPins(true),
captureFactory,
new FakeDelay(events),
new FakeAssetLeases(true));
Assert.True((await runner.ExecuteAsync(Request(scenes.Path))).Completed);
var plan = Assert.IsType<PgmCutsSequencePlan>(captureFactory.Plan);
PlayoutCue? sdkCue = null;
var session = new FakeK3dSession
{
PrepareAction = (cue, _) => sdkCue = cue
};
var dispatcher = new StaDispatcher(plan.Options.QueueCapacity);
await using var engine = new TornadoPlayoutEngine(
plan.Options,
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
dispatcher,
new FakeLiveAuthorization(true),
TimeProvider.System,
startMonitor: false);
Assert.False(Path.IsPathFullyQualified(plan.FirstCue.SceneFile));
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
var prepare = await engine.PrepareAsync(
plan.FirstCue,
CancellationToken.None);
Assert.True(prepare.IsSuccess);
var resolvedCue = Assert.IsType<PlayoutCue>(sdkCue);
Assert.True(Path.IsPathFullyQualified(resolvedCue.SceneFile));
Assert.Equal(
Path.GetFullPath(plan.FirstAssetPath),
Path.GetFullPath(resolvedCue.SceneFile),
StringComparer.OrdinalIgnoreCase);
Assert.True((await engine.DisconnectAsync(CancellationToken.None)).IsSuccess);
}
[Theory]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
[InlineData(6)]
[InlineData(7)]
[InlineData(8)]
[InlineData(9)]
[InlineData(10)]
[InlineData(11)]
[InlineData(12)]
[InlineData(13)]
[InlineData(14)]
[InlineData(15)]
[InlineData(16)]
[InlineData(17)]
[InlineData(18)]
[InlineData(19)]
public async Task TargetChangeAtEveryBoundary_StopsAndQuarantinesWithoutLaterOutput(
int changedCapture)
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var events = new ConcurrentQueue<string>();
var engine = new FakeEngine(events);
var runner = new PgmCutsSequenceRunner(
new FakeSafetyProbe(events, changedCapture),
new FakeHashPins(true),
new FakeEngineFactory(events, engine),
new FakeDelay(events),
new FakeAssetLeases(true));
var report = await runner.ExecuteAsync(
Request(scenes.Path),
stage => events.Enqueue($"Milestone:{stage}"));
Assert.False(report.Completed);
Assert.Equal("pgm-target-changed", report.ErrorCode);
Assert.True(report.QuarantineAttempted);
Assert.True(report.QuarantineCompleted);
Assert.Equal(1, events.Count(value => value == "Quarantine"));
var quarantineIndex = Array.IndexOf(events.ToArray(), "Quarantine");
Assert.DoesNotContain(
events.Skip(quarantineIndex + 1),
value => IsOutputCommand(value) || value == "Disconnect");
if (changedCapture <= 18)
{
Assert.DoesNotContain("Disconnect", events);
}
Assert.Equal(changedCapture is > 2 and < 19, report.OutcomeUnknown);
}
[Theory]
[InlineData((int)PgmCutsSequenceStage.Connect)]
[InlineData((int)PgmCutsSequenceStage.PrepareFirst)]
[InlineData((int)PgmCutsSequenceStage.TakeIn)]
[InlineData((int)PgmCutsSequenceStage.Next)]
[InlineData((int)PgmCutsSequenceStage.TakeOut)]
[InlineData((int)PgmCutsSequenceStage.Disconnect)]
public async Task OutcomeUnknownAtEveryCommand_QuarantinesWithoutSubsequentCommand(
int stageValue)
{
var stage = (PgmCutsSequenceStage)stageValue;
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var events = new ConcurrentQueue<string>();
var engine = new FakeEngine(events);
engine.Results[stage] = PlayoutResultCode.OutcomeUnknown;
var runner = Runner(events, engine);
var report = await runner.ExecuteAsync(Request(scenes.Path));
Assert.False(report.Completed);
Assert.True(report.OutcomeUnknown);
Assert.True(report.QuarantineCompleted);
Assert.Equal("command-outcome-unknown", report.ErrorCode);
var quarantineIndex = Array.IndexOf(events.ToArray(), "Quarantine");
Assert.DoesNotContain(
events.Skip(quarantineIndex + 1),
value => IsOutputCommand(value) || value == "Disconnect");
if (stage != PgmCutsSequenceStage.Disconnect)
{
Assert.DoesNotContain("Disconnect", events);
}
}
[Fact]
public async Task KnownPrepareFailure_PerformsExplicitSameTargetDisconnectCleanup()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var events = new ConcurrentQueue<string>();
var engine = new FakeEngine(events);
engine.Results[PgmCutsSequenceStage.PrepareFirst] = PlayoutResultCode.Failed;
var report = await Runner(events, engine).ExecuteAsync(Request(scenes.Path));
Assert.False(report.Completed);
Assert.False(report.OutcomeUnknown);
Assert.False(report.QuarantineAttempted);
Assert.True(report.DisconnectAttempted);
Assert.Equal(
new[] { "Connect", "Prepare:5001", "Disconnect", "Dispose" },
EngineCalls(events));
}
[Fact]
public async Task QuarantineFailure_SkipsDisposeAndEveryFurtherSdkCommand()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var events = new ConcurrentQueue<string>();
var engine = new FakeEngine(events)
{
QuarantineFailure = new InvalidOperationException("fake quarantine failure")
};
engine.Results[PgmCutsSequenceStage.Next] = PlayoutResultCode.OutcomeUnknown;
var report = await Runner(events, engine).ExecuteAsync(Request(scenes.Path));
Assert.True(report.QuarantineAttempted);
Assert.False(report.QuarantineCompleted);
Assert.True(report.OutcomeUnknown);
Assert.DoesNotContain("Dispose", events);
Assert.DoesNotContain("Disconnect", events);
Assert.Equal("Quarantine", events.Last());
}
[Fact]
public async Task TimedOutTakeOut_IsTreatedAsUnknownAndNeverDisconnects()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var events = new ConcurrentQueue<string>();
var engine = new FakeEngine(events);
engine.Results[PgmCutsSequenceStage.TakeOut] = PlayoutResultCode.TimedOut;
var report = await Runner(events, engine).ExecuteAsync(Request(scenes.Path));
Assert.True(report.RenderCommandAttempted);
Assert.True(report.OutcomeUnknown);
Assert.True(report.QuarantineCompleted);
Assert.DoesNotContain("Disconnect", events);
Assert.Equal(1, events.Count(value => value == "TakeOut:All"));
}
[Theory]
[InlineData(1, false)]
[InlineData(2, true)]
public async Task InterruptedOnAirObservation_PerformsOneShotTakeOutThenDisconnect(
int interruptedObservation,
bool nextWasRendered)
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var events = new ConcurrentQueue<string>();
var delay = new FakeDelay(events)
{
FailureOnCall = interruptedObservation
};
var engine = new FakeEngine(events);
var runner = new PgmCutsSequenceRunner(
new FakeSafetyProbe(events),
new FakeHashPins(true),
new FakeEngineFactory(events, engine),
delay,
new FakeAssetLeases(true));
var report = await runner.ExecuteAsync(Request(scenes.Path));
Assert.False(report.Completed);
Assert.False(report.OutcomeUnknown);
Assert.False(report.QuarantineAttempted);
Assert.Equal("observation-interrupted", report.ErrorCode);
Assert.Equal(1, events.Count(value => value == "TakeOut:All"));
Assert.Equal(1, events.Count(value => value == "Disconnect"));
Assert.Equal(nextWasRendered, events.Contains("Next:5006"));
Assert.Equal(
new[] { PgmCutsSequenceStage.TakeOut, PgmCutsSequenceStage.Disconnect },
report.Commands.TakeLast(2).Select(command => command.Stage));
}
[Theory]
[InlineData(PlayoutResultCode.Failed)]
[InlineData(PlayoutResultCode.TimedOut)]
[InlineData(PlayoutResultCode.OutcomeUnknown)]
public async Task InterruptedObservation_WhenOneShotTakeOutIsNotSuccessful_QuarantinesWithoutDisconnect(
PlayoutResultCode takeOutCode)
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var events = new ConcurrentQueue<string>();
var engine = new FakeEngine(events);
engine.Results[PgmCutsSequenceStage.TakeOut] = takeOutCode;
var delay = new FakeDelay(events)
{
FailureOnCall = 1
};
var runner = new PgmCutsSequenceRunner(
new FakeSafetyProbe(events),
new FakeHashPins(true),
new FakeEngineFactory(events, engine),
delay,
new FakeAssetLeases(true));
var report = await runner.ExecuteAsync(Request(scenes.Path));
Assert.False(report.Completed);
Assert.True(report.OutcomeUnknown);
Assert.True(report.QuarantineCompleted);
Assert.Equal(1, events.Count(value => value == "TakeOut:All"));
Assert.DoesNotContain("Disconnect", events);
var takeOutIndex = Array.IndexOf(events.ToArray(), "TakeOut:All");
Assert.DoesNotContain(
events.Skip(takeOutIndex + 1),
value => value == "TakeOut:All");
}
[Fact]
public async Task InterruptedPostTakeOutObservation_DoesNotRepeatTakeOutAndDisconnects()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var events = new ConcurrentQueue<string>();
var delay = new FakeDelay(events)
{
FailureOnCall = 3
};
var engine = new FakeEngine(events);
var runner = new PgmCutsSequenceRunner(
new FakeSafetyProbe(events),
new FakeHashPins(true),
new FakeEngineFactory(events, engine),
delay,
new FakeAssetLeases(true));
var report = await runner.ExecuteAsync(Request(scenes.Path));
Assert.False(report.Completed);
Assert.False(report.OutcomeUnknown);
Assert.False(report.QuarantineAttempted);
Assert.Equal("observation-interrupted", report.ErrorCode);
Assert.Equal(1, events.Count(value => value == "TakeOut:All"));
Assert.Equal(1, events.Count(value => value == "Disconnect"));
}
[Fact]
public async Task MissingPinsOrInvalidAssets_RejectBeforeEngineCreation()
{
using var missingScene = TemporarySceneDirectory.Create("5001.t2s");
using var validScenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var missingPinEvents = new ConcurrentQueue<string>();
var pinFactory = new FakeEngineFactory(missingPinEvents, new FakeEngine(missingPinEvents));
var missingPins = new PgmCutsSequenceRunner(
new FakeSafetyProbe(missingPinEvents),
new FakeHashPins(false),
pinFactory,
new FakeDelay(missingPinEvents));
var assetEvents = new ConcurrentQueue<string>();
var assetFactory = new FakeEngineFactory(assetEvents, new FakeEngine(assetEvents));
var invalidAssets = new PgmCutsSequenceRunner(
new FakeSafetyProbe(assetEvents),
new FakeHashPins(true),
assetFactory,
new FakeDelay(assetEvents),
new FakeAssetLeases(true));
var rejectedAssetEvents = new ConcurrentQueue<string>();
var rejectedAssetFactory = new FakeEngineFactory(
rejectedAssetEvents,
new FakeEngine(rejectedAssetEvents));
var rejectedAssets = new PgmCutsSequenceRunner(
new FakeSafetyProbe(rejectedAssetEvents),
new FakeHashPins(true),
rejectedAssetFactory,
new FakeDelay(rejectedAssetEvents),
new FakeAssetLeases(false));
var pinReport = await missingPins.ExecuteAsync(Request(validScenes.Path));
var assetReport = await invalidAssets.ExecuteAsync(Request(missingScene.Path));
var rejectedAssetReport = await rejectedAssets.ExecuteAsync(Request(validScenes.Path));
Assert.Equal("hash-pins-required", pinReport.ErrorCode);
Assert.Equal("request-rejected", assetReport.ErrorCode);
Assert.Equal("cut-assets-not-approved", rejectedAssetReport.ErrorCode);
Assert.Equal(0, pinFactory.CreateCount);
Assert.Equal(0, assetFactory.CreateCount);
Assert.Equal(0, rejectedAssetFactory.CreateCount);
}
[Fact]
public async Task ReportAndMilestonesContainNoEndpointTitleIdentityPathHashOrSceneCode()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
const string title = "Unique PGM Private Title";
var events = new ConcurrentQueue<string>();
var report = await Runner(events, new FakeEngine(events)).ExecuteAsync(
Request(scenes.Path) with { ExpectedPgmWindowTitle = title });
var json = JsonSerializer.Serialize(report);
Assert.DoesNotContain("127.0.0.1", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("30001", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(title, json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(scenes.Path, json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("4242", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(new string('A', 64), json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("5001", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("5006", json, StringComparison.OrdinalIgnoreCase);
Assert.All(
events.Where(value => value.StartsWith("Milestone:", StringComparison.Ordinal)),
value => Assert.DoesNotContain("500", value, StringComparison.Ordinal));
}
[Fact]
public void RunnerCapabilityAndEntryPointsAreNotPublic_AndNormalTestGateIsUnchanged()
{
Assert.False(typeof(PgmCutsSequenceRunner).IsPublic);
Assert.False(typeof(PgmCutsSequenceRequest).IsPublic);
Assert.False(typeof(PgmCutsSequenceReport).IsPublic);
Assert.All(
typeof(FullPgmCutsSequenceEngineFactory).GetNestedTypes(
System.Reflection.BindingFlags.Public |
System.Reflection.BindingFlags.NonPublic),
type => Assert.False(type.IsPublic));
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var normalTest = new PlayoutOptions
{
Mode = PlayoutMode.Test,
SceneDirectory = scenes.Path,
OutputChannel = 9,
TestProcessWindowTitlePattern = "^Tornado2 PGM$",
TestSceneAllowlist = ["5001", "5006"]
};
Assert.Throws<PlayoutConfigurationException>(
() => ValidatedPlayoutOptions.Create(normalTest));
}
[Fact]
public async Task FullFactoryUsesPrivateCapabilityAndNeverReadsAmbientLiveAuthorization()
{
using var environment = new EnvironmentScope()
.Set("MBN_STOCK_PLAYOUT_AUTHORIZE_LIVE_OUTPUT", "false");
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
var events = new ConcurrentQueue<string>();
var captureFactory = new FakeEngineFactory(events, new FakeEngine(events));
var runner = new PgmCutsSequenceRunner(
new FakeSafetyProbe(events),
new FakeHashPins(true),
captureFactory,
new FakeDelay(events),
new FakeAssetLeases(true));
Assert.True((await runner.ExecuteAsync(Request(scenes.Path))).Completed);
var plan = Assert.IsType<PgmCutsSequencePlan>(captureFactory.Plan);
var baseline = new PgmDiagnosticTargetSnapshot(
true,
1,
1,
true,
new PgmDiagnosticTargetIdentity(4_242, 123_456));
await using var engine = new FullPgmCutsSequenceEngineFactory(
new FakeSafetyProbe(new ConcurrentQueue<string>()))
.Create(plan, baseline);
var authorizationField = typeof(TornadoPlayoutEngine).GetField(
"_liveAuthorization",
System.Reflection.BindingFlags.Instance |
System.Reflection.BindingFlags.NonPublic);
Assert.NotNull(authorizationField);
var authorization = Assert.IsAssignableFrom<MBN_STOCK_WEBVIEW.Playout.Safety.ILiveAuthorization>(
authorizationField.GetValue(engine));
Assert.True(authorization.IsAuthorizedForThisLaunch);
Assert.True(authorization.GetType().IsNestedPrivate);
}
[Fact]
public void ProcessHashPinRequirementRequiresBothProcessScopedHexPins()
{
using var environment = new EnvironmentScope()
.Set(InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable, null)
.Set(InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable, null);
var requirement = new ProcessPgmCutsSequenceHashPinRequirement();
Assert.False(requirement.IsSatisfied());
environment
.Set(
InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable,
new string('A', 64))
.Set(
InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable,
new string('B', 64));
Assert.True(requirement.IsSatisfied());
environment.Set(
InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable,
"not-a-hash");
Assert.False(requirement.IsSatisfied());
}
[Fact]
public void CutAssetRequirementUsesExactApprovedHashesAndFixedTimeComparison()
{
Assert.Equal(
"99CE3B689A42D8C42BEB09A86FA10C2D7C1AEF4F50D324D81276C1A1E4C4D8A7",
FixedPgmCutsSequenceAssetLeaseFactory.FirstApprovedSha256);
Assert.Equal(
"25CD0AE931F51E4E3B84CE3E6FD21A40DB85464F157A23CC3511D63B336D8757",
FixedPgmCutsSequenceAssetLeaseFactory.NextApprovedSha256);
byte[] content = [1, 3, 3, 7, 42];
var contentHash = Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(content));
using var matching = new MemoryStream(content, writable: false);
using var rejected = new MemoryStream(content, writable: false);
Assert.True(FixedPgmCutsSequenceAssetLeaseFactory.HasExpectedSha256(
matching,
contentHash));
Assert.False(FixedPgmCutsSequenceAssetLeaseFactory.HasExpectedSha256(
rejected,
FixedPgmCutsSequenceAssetLeaseFactory.FirstApprovedSha256));
}
[Fact]
public void SceneTrustRequiresFixedLocalRootAndFilesUnderThatRoot()
{
using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s");
using var outside = TemporarySceneDirectory.Create("outside.t2s");
Assert.True(PgmCutsSequenceRunner.IsTrustedLocalScenePaths(
scenes.Path,
Path.Combine(scenes.Path, "5001.t2s"),
Path.Combine(scenes.Path, "5006.t2s")));
Assert.False(PgmCutsSequenceRunner.IsTrustedLocalScenePaths(
scenes.Path,
Path.Combine(scenes.Path, "5001.t2s"),
Path.Combine(outside.Path, "outside.t2s")));
Assert.False(PgmCutsSequenceRunner.IsTrustedLocalScenePaths(
"\\\\server\\share\\Cuts",
"\\\\server\\share\\Cuts\\5001.t2s"));
}
private static PgmCutsSequenceRunner Runner(
ConcurrentQueue<string> events,
FakeEngine engine) => new(
new FakeSafetyProbe(events),
new FakeHashPins(true),
new FakeEngineFactory(events, engine),
new FakeDelay(events),
new FakeAssetLeases(true));
private static PgmCutsSequenceRequest Request(string sceneRoot) => new(
"127.0.0.1",
30_001,
"Tornado2 PGM",
sceneRoot,
5_000);
private static bool IsOutputCommand(string value) =>
value.StartsWith("Prepare:", StringComparison.Ordinal) ||
value == "TakeIn" ||
value.StartsWith("Next:", StringComparison.Ordinal) ||
value.StartsWith("TakeOut:", StringComparison.Ordinal);
private static string[] EngineCalls(ConcurrentQueue<string> events) =>
events.Where(value =>
value == "Connect" ||
IsOutputCommand(value) ||
value is "Disconnect" or "Quarantine" or "Dispose").ToArray();
private sealed class FakeHashPins : IPgmCutsSequenceHashPinRequirement
{
private readonly bool _satisfied;
public FakeHashPins(bool satisfied)
{
_satisfied = satisfied;
}
public bool IsSatisfied() => _satisfied;
}
private sealed class FakeAssetLeases : IPgmCutsSequenceAssetLeaseFactory
{
private readonly bool _satisfied;
public FakeAssetLeases(bool satisfied)
{
_satisfied = satisfied;
}
public bool TryAcquire(
PgmCutsSequencePlan plan,
out IPgmCutsSequenceAssetLease? lease)
{
if (!_satisfied)
{
lease = null;
return false;
}
lease = new FakeAssetLease();
return true;
}
private sealed class FakeAssetLease : IPgmCutsSequenceAssetLease
{
public void Dispose()
{
}
}
}
private sealed class FakeSafetyProbe : IPgmDiagnosticSafetyProbe
{
private static readonly PgmDiagnosticTargetSnapshot Baseline = new(
true,
1,
1,
true,
new PgmDiagnosticTargetIdentity(4_242, 123_456));
private static readonly PgmDiagnosticTargetSnapshot Changed = new(
true,
1,
1,
true,
new PgmDiagnosticTargetIdentity(4_243, 123_457));
private readonly ConcurrentQueue<string> _events;
private readonly int? _changedCapture;
private int _captureCount;
public FakeSafetyProbe(
ConcurrentQueue<string> events,
int? changedCapture = null)
{
_events = events;
_changedCapture = changedCapture;
}
public PgmDiagnosticTargetSnapshot Capture(
PgmConnectDiagnosticRequest request,
System.Net.IPAddress parsedHost)
{
Assert.Equal("127.0.0.1", parsedHost.ToString());
Assert.Equal(30_001, request.Port);
var count = Interlocked.Increment(ref _captureCount);
_events.Enqueue($"Safety:{count}");
return _changedCapture is { } changed && count >= changed
? Changed
: Baseline;
}
}
private sealed class FakeDelay : IPgmCutsSequenceDelay
{
private readonly ConcurrentQueue<string> _events;
private int _calls;
public FakeDelay(ConcurrentQueue<string> events)
{
_events = events;
}
public int? FailureOnCall { get; init; }
public Task DelayAsync(TimeSpan delay, CancellationToken cancellationToken)
{
var call = Interlocked.Increment(ref _calls);
_events.Enqueue($"Delay:{delay.TotalMilliseconds:0}");
if (FailureOnCall == call)
{
throw new OperationCanceledException("fake observation interruption");
}
return Task.CompletedTask;
}
}
private sealed class FakeEngineFactory : IPgmCutsSequenceEngineFactory
{
private readonly ConcurrentQueue<string> _events;
private readonly FakeEngine _engine;
public FakeEngineFactory(
ConcurrentQueue<string> events,
FakeEngine engine)
{
_events = events;
_engine = engine;
}
public int CreateCount { get; private set; }
public PgmCutsSequencePlan? Plan { get; private set; }
public IPlayoutEngine Create(
PgmCutsSequencePlan plan,
PgmDiagnosticTargetSnapshot baseline)
{
CreateCount++;
Plan = plan;
Assert.True(baseline.IsEligible);
_events.Enqueue("CreateEngine");
return _engine;
}
}
private sealed class FakeEngine : IPlayoutEngine
{
private readonly ConcurrentQueue<string> _events;
public FakeEngine(ConcurrentQueue<string> events)
{
_events = events;
}
public Dictionary<PgmCutsSequenceStage, PlayoutResultCode> Results { get; } = [];
public Exception? QuarantineFailure { get; init; }
public PlayoutStatus Status { get; } = new(
PlayoutMode.Live,
PlayoutConnectionState.Disconnected,
false,
true,
true,
true,
null,
null,
"fake",
DateTimeOffset.UnixEpoch,
0);
public event EventHandler<PlayoutStatusChangedEventArgs>? StatusChanged
{
add { }
remove { }
}
public Task<PlayoutResult> ConnectAsync(CancellationToken cancellationToken = default)
{
_events.Enqueue("Connect");
return Result(PgmCutsSequenceStage.Connect, PlayoutOperation.Connect);
}
public Task<PlayoutResult> DisconnectAsync(CancellationToken cancellationToken = default)
{
_events.Enqueue("Disconnect");
return Result(PgmCutsSequenceStage.Disconnect, PlayoutOperation.Disconnect);
}
public Task<PlayoutResult> PrepareAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default)
{
_events.Enqueue($"Prepare:{cue.SceneName}");
return Result(PgmCutsSequenceStage.PrepareFirst, PlayoutOperation.Prepare);
}
public Task<PlayoutResult> TakeInAsync(CancellationToken cancellationToken = default)
{
_events.Enqueue("TakeIn");
return Result(PgmCutsSequenceStage.TakeIn, PlayoutOperation.TakeIn);
}
public Task<PlayoutResult> NextAsync(
PlayoutCue cue,
CancellationToken cancellationToken = default)
{
_events.Enqueue($"Next:{cue.SceneName}");
return Result(PgmCutsSequenceStage.Next, PlayoutOperation.Next);
}
public Task<PlayoutResult> TakeOutAsync(
PlayoutTakeOutScope scope,
CancellationToken cancellationToken = default)
{
_events.Enqueue($"TakeOut:{scope}");
return Result(PgmCutsSequenceStage.TakeOut, PlayoutOperation.TakeOut);
}
public ValueTask QuarantineAsync()
{
_events.Enqueue("Quarantine");
return QuarantineFailure is null
? ValueTask.CompletedTask
: ValueTask.FromException(QuarantineFailure);
}
public ValueTask DisposeAsync()
{
_events.Enqueue("Dispose");
return ValueTask.CompletedTask;
}
private Task<PlayoutResult> Result(
PgmCutsSequenceStage stage,
PlayoutOperation operation)
{
var code = Results.GetValueOrDefault(stage, PlayoutResultCode.Success);
return Task.FromResult(new PlayoutResult(
operation,
code,
false,
"fake",
DateTimeOffset.UnixEpoch));
}
}
}

View File

@@ -31,7 +31,19 @@ internal sealed class FakeK3dSessionFactory : IK3dSessionFactory
}
}
internal sealed class FakeK3dSession : IK3dSession
internal sealed class FakeK3dSdkDispatchLatch(bool allow = true) : IK3dSdkDispatchLatch
{
public IDisposable? TryClaim() => allow ? new Claim() : null;
private sealed class Claim : IDisposable
{
public void Dispose()
{
}
}
}
internal sealed class FakeK3dSession : IK3dGuardedConnectSession
{
private int _disposed;
private int _ktapDispatchGate;
@@ -66,24 +78,53 @@ internal sealed class FakeK3dSession : IK3dSession
public Action<string>? CallObserver { get; set; }
public void Connect(ValidatedPlayoutOptions options)
=> ConnectCore(options, finalSafetyCheck: null);
public void ConnectGuarded(
ValidatedPlayoutOptions options,
Func<bool> finalSafetyCheck,
IK3dSdkDispatchLatch dispatchLatch)
{
ArgumentNullException.ThrowIfNull(finalSafetyCheck);
ArgumentNullException.ThrowIfNull(dispatchLatch);
ConnectCore(options, finalSafetyCheck, dispatchLatch);
}
private void ConnectCore(
ValidatedPlayoutOptions options,
Func<bool>? finalSafetyCheck,
IK3dSdkDispatchLatch? dispatchLatch = null)
{
Record("Connect");
BeforeKtapDispatchAction?.Invoke();
if (Interlocked.CompareExchange(ref _ktapDispatchGate, 1, 0) != 0)
if (finalSafetyCheck is not null && !finalSafetyCheck())
{
throw new InvalidOperationException("Fake KTAP dispatch was prevented.");
throw new K3dConnectSafetyException();
}
LastKtapConnectState = PlayoutKtapConnectState.Attempted;
Interlocked.Increment(ref _ktapDispatchCount);
try
using (var dispatchClaim = dispatchLatch?.TryClaim())
{
ConnectAction?.Invoke(options);
}
catch
{
LastKtapConnectState = PlayoutKtapConnectState.Failed;
throw;
if (dispatchLatch is not null && dispatchClaim is null)
{
throw new K3dConnectSafetyException();
}
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;

View File

@@ -263,6 +263,79 @@ public sealed class SmokeCommandLineTests
Assert.Equal("invalid-port", parsed.ErrorCode);
}
[Fact]
public void Parse_PgmCutsSequenceRequiresItsOwnLongAcknowledgementAndFixedInputs()
{
var complete = ValidPgmCutsArguments();
var missingAcknowledgement = SmokeCommandLine.Parse(
complete.Where(value => value != SmokeCommandLine.PgmCutsSequenceAcknowledgementFlag)
.ToArray());
var wrongAcknowledgement = SmokeCommandLine.Parse(
complete.Select(value => value == SmokeCommandLine.PgmCutsSequenceAcknowledgementFlag
? SmokeCommandLine.AcknowledgementFlag
: value).ToArray());
var parsed = SmokeCommandLine.Parse(complete);
var explicitObservation = SmokeCommandLine.Parse(
complete.Concat(["--observe-ms", "5000"]).ToArray());
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.True(explicitObservation.IsValid);
Assert.Equal(SmokeCommandKind.PgmCutsSequence, parsed.Invocation.Kind);
Assert.Equal(5_000, parsed.Invocation.ObservationMilliseconds);
Assert.Equal("C:\\Cuts", parsed.Invocation.SceneRoot);
Assert.Null(parsed.Invocation.ConfigPath);
Assert.Null(parsed.Invocation.PrepareSceneCode);
Assert.Null(parsed.Invocation.NextSceneCode);
}
[Theory]
[InlineData("--config")]
[InlineData("--prepare")]
[InlineData("--next")]
public void Parse_PgmCutsSequenceRejectsConfigAndCallerSelectedScenes(string option)
{
var parsed = SmokeCommandLine.Parse(
ValidPgmCutsArguments().Concat([option, "sensitive-value"]).ToArray());
Assert.False(parsed.IsValid);
Assert.Equal("unknown-option", parsed.ErrorCode);
}
[Theory]
[InlineData("4999")]
[InlineData("5001")]
[InlineData("secret")]
public void Parse_PgmCutsSequenceOnlyAcceptsExplicitFiveSecondObservation(string value)
{
var parsed = SmokeCommandLine.Parse(
ValidPgmCutsArguments().Concat(["--observe-ms", value]).ToArray());
Assert.False(parsed.IsValid);
Assert.Equal("invalid-observation-duration", parsed.ErrorCode);
}
[Theory]
[InlineData("localhost", "C:\\Cuts")]
[InlineData("192.0.2.1", "C:\\Cuts")]
[InlineData("127.0.0.1", "relative-cuts")]
public void Parse_PgmCutsSequenceRequiresLiteralLoopbackAndAbsoluteSceneRoot(
string host,
string sceneRoot)
{
var arguments = ValidPgmCutsArguments();
arguments[Array.IndexOf(arguments, "127.0.0.1")] = host;
arguments[Array.IndexOf(arguments, "C:\\Cuts")] = sceneRoot;
var parsed = SmokeCommandLine.Parse(arguments);
Assert.False(parsed.IsValid);
Assert.Equal("missing-or-invalid-required-option", parsed.ErrorCode);
}
private static SmokeCommandParseResult ValidSequenceArguments(
string prepare = "5001",
string observation = "1000") =>
@@ -279,4 +352,18 @@ public sealed class SmokeCommandLineTests
"--observe-ms",
observation
]);
private static string[] ValidPgmCutsArguments() =>
[
"--pgm-cuts-sequence",
SmokeCommandLine.PgmCutsSequenceAcknowledgementFlag,
"--host",
"127.0.0.1",
"--port",
"30001",
"--expected-pgm-window-title",
"Tornado2 PGM",
"--scene-root",
"C:\\Cuts"
];
}

View File

@@ -327,6 +327,640 @@ public sealed class TornadoPlayoutEngineTests
}
}
[Fact]
public async Task GuardedConnect_FinalTargetFailure_PreventsKtapAndAbandonsWithoutDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var session = new FakeK3dSession();
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false,
finalConnectSafetyCheck: () => false,
abandonOnTargetMismatch: true);
var result = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.Failed, result.Code);
Assert.Equal(0, session.KtapDispatchCount);
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
}
[Fact]
public async Task GuardedConnect_KtapFailure_IsOutcomeUnknownAndNeverReconnectable()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var session = new FakeK3dSession
{
ConnectAction = _ => throw new COMException("fake ambiguous KTAP failure")
};
var sessionFactory = new FakeK3dSessionFactory(() => session);
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false,
finalConnectSafetyCheck: () => true,
abandonOnTargetMismatch: true);
var result = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
Assert.False(engine.Status.IsCommandAvailable);
Assert.Equal(1, session.KtapDispatchCount);
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
await engine.PollProcessOnceAsync(CancellationToken.None);
Assert.Equal(1, sessionFactory.CreateCount);
}
[Fact]
public async Task GuardedConnect_CallbackCancellationAfterKtap_IsOutcomeUnknown()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var session = new FakeK3dSession
{
ConnectAction = _ => throw new OperationCanceledException(
"fake cancellation after KTAP dispatch")
};
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false,
finalConnectSafetyCheck: () => true,
abandonOnTargetMismatch: true);
var result = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
Assert.Equal(1, session.KtapDispatchCount);
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
}
[Fact]
public async Task GuardedConnect_CancelledBeforeSerialization_RemainsKnownAndDoesNotCreateSession()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var sessionFactory = new FakeK3dSessionFactory();
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
sessionFactory,
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false,
finalConnectSafetyCheck: () => true,
abandonOnTargetMismatch: true);
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var result = await engine.ConnectAsync(cancellation.Token);
Assert.Equal(PlayoutResultCode.Cancelled, result.Code);
Assert.Equal(0, sessionFactory.CreateCount);
Assert.Equal(PlayoutKtapConnectState.NotAttempted, engine.Status.LastKtapConnectState);
}
[Fact]
public async Task GuardedConnect_SlowSafetyCheckPastTimeoutNeverDispatchesKtap()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
using var safetyStarted = new ManualResetEventSlim();
using var releaseSafety = new ManualResetEventSlim();
var session = new FakeK3dSession();
var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false,
finalConnectSafetyCheck: () =>
{
safetyStarted.Set();
Assert.True(releaseSafety.Wait(TimeSpan.FromSeconds(5)));
return true;
},
abandonOnTargetMismatch: true);
try
{
var connectTask = engine.ConnectAsync(CancellationToken.None);
Assert.True(safetyStarted.Wait(TimeSpan.FromSeconds(2)));
var result = await connectTask;
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
Assert.Equal(0, session.KtapDispatchCount);
releaseSafety.Set();
Assert.True(SpinWait.SpinUntil(
() => session.Calls.Contains("Abandon"),
TimeSpan.FromSeconds(2)));
Assert.Equal(0, session.KtapDispatchCount);
await Assert.ThrowsAsync<InvalidOperationException>(
() => engine.QuarantineAsync().AsTask());
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
}
finally
{
releaseSafety.Set();
await engine.DisposeAsync();
}
}
[Fact]
public async Task GuardedConnect_TimeoutWinningAtomicClaimPreventsKtapDispatch()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
using var claimStarted = new ManualResetEventSlim();
using var releaseClaim = new ManualResetEventSlim();
var session = new FakeK3dSession();
var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)),
liveAuthorized: false,
finalConnectSafetyCheck: () => true,
abandonOnTargetMismatch: true,
beforeSdkDispatchClaim: () =>
{
claimStarted.Set();
Assert.True(releaseClaim.Wait(TimeSpan.FromSeconds(5)));
});
try
{
var connectTask = engine.ConnectAsync(CancellationToken.None);
Assert.True(claimStarted.Wait(TimeSpan.FromSeconds(2)));
var connect = await connectTask;
Assert.Equal(PlayoutResultCode.OutcomeUnknown, connect.Code);
Assert.Equal(0, session.KtapDispatchCount);
releaseClaim.Set();
Assert.True(SpinWait.SpinUntil(
() => session.Calls.Contains("Abandon"),
TimeSpan.FromSeconds(2)));
Assert.Equal(0, session.KtapDispatchCount);
await Assert.ThrowsAsync<InvalidOperationException>(
() => engine.QuarantineAsync().AsTask());
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
}
finally
{
releaseClaim.Set();
await engine.DisposeAsync();
}
}
[Fact]
public async Task GuardedConnect_PostGenerationChange_AbandonsWithoutDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var original = ProcessSnapshot("pgm-generation-a");
var changed = ProcessSnapshot("pgm-generation-b");
var session = new FakeK3dSession();
var finalChecks = 0;
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(original, changed),
liveAuthorized: false,
finalConnectSafetyCheck: () => Interlocked.Increment(ref finalChecks) == 1,
abandonOnTargetMismatch: true);
var result = await engine.ConnectAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
Assert.Equal(1, finalChecks);
Assert.Equal(1, session.KtapDispatchCount);
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
}
[Fact]
public async Task GuardedTargetPolicy_PreCommandGenerationChange_AbandonsWithoutDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var original = ProcessSnapshot("pgm-generation-a");
var changed = ProcessSnapshot("pgm-generation-b");
var session = new FakeK3dSession();
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(original, original, changed),
liveAuthorized: false,
finalConnectSafetyCheck: () => true,
abandonOnTargetMismatch: true);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
var prepare = await engine.PrepareAsync(
Cue("test-scene"),
CancellationToken.None);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, prepare.Code);
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
Assert.DoesNotContain(session.Calls, call =>
call.StartsWith("Prepare:", StringComparison.Ordinal));
}
[Fact]
public async Task GuardedTargetPolicy_AmbiguousPrepareFailure_AbandonsWithoutDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var stable = ProcessSnapshot("pgm-generation-a");
var session = new FakeK3dSession
{
PrepareAction = (_, _) => throw new COMException("fake ambiguous prepare failure")
};
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(stable),
liveAuthorized: false,
finalConnectSafetyCheck: () => true,
abandonOnTargetMismatch: true);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
var prepare = await engine.PrepareAsync(
Cue("test-scene"),
CancellationToken.None);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, prepare.Code);
Assert.Equal(
new[] { "Connect", "Prepare:test-scene", "Abandon" },
session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
}
[Fact]
public async Task GuardedTargetPolicy_NextChecksTargetBetweenPrepareAndPlay()
{
using var scenes = TemporarySceneDirectory.Create(
"test-scene.t2s",
"next-scene.t2s");
var stable = ProcessSnapshot("pgm-generation-a");
var session = new FakeK3dSession();
var safetyChecks = 0;
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene", "next-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(stable),
liveAuthorized: false,
finalConnectSafetyCheck: () =>
Interlocked.Increment(ref safetyChecks) != 7,
abandonOnTargetMismatch: true);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True((await engine.PrepareAsync(
Cue("test-scene"),
CancellationToken.None)).IsSuccess);
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
var next = await engine.NextAsync(
Cue("next-scene"),
CancellationToken.None);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, next.Code);
Assert.Equal(7, safetyChecks);
Assert.Equal(1, session.Calls.Count(call => call == "Play"));
Assert.Equal(
new[]
{
"Connect",
"Prepare:test-scene",
"Play",
"Prepare:next-scene",
"Abandon"
},
session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
}
[Fact]
public async Task GuardedTargetPolicy_DisconnectPrecheckFailureAbandonsWithoutDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var stable = ProcessSnapshot("pgm-generation-a");
var session = new FakeK3dSession();
var safetyChecks = 0;
await using var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(stable),
liveAuthorized: false,
finalConnectSafetyCheck: () =>
Interlocked.Increment(ref safetyChecks) == 1,
abandonOnTargetMismatch: true);
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
var disconnect = await engine.DisconnectAsync(CancellationToken.None);
Assert.Equal(PlayoutResultCode.OutcomeUnknown, disconnect.Code);
Assert.Equal(2, safetyChecks);
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
}
[Fact]
public async Task GuardedTargetPolicy_SlowCommandSafetyCheckPastTimeoutNeverDispatchesSdkCall()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
using var safetyStarted = new ManualResetEventSlim();
using var releaseSafety = new ManualResetEventSlim();
var stable = ProcessSnapshot("pgm-generation-a");
var session = new FakeK3dSession();
var safetyChecks = 0;
var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(stable),
liveAuthorized: false,
finalConnectSafetyCheck: () =>
{
if (Interlocked.Increment(ref safetyChecks) == 2)
{
safetyStarted.Set();
Assert.True(releaseSafety.Wait(TimeSpan.FromSeconds(5)));
}
return true;
},
abandonOnTargetMismatch: true);
try
{
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
var prepareTask = engine.PrepareAsync(
Cue("test-scene"),
CancellationToken.None);
Assert.True(safetyStarted.Wait(TimeSpan.FromSeconds(2)));
var prepare = await prepareTask;
Assert.Equal(PlayoutResultCode.OutcomeUnknown, prepare.Code);
releaseSafety.Set();
Assert.True(SpinWait.SpinUntil(
() => session.Calls.Contains("Abandon"),
TimeSpan.FromSeconds(2)));
Assert.DoesNotContain(
session.Calls,
call => call.StartsWith("Prepare:", StringComparison.Ordinal));
await Assert.ThrowsAsync<InvalidOperationException>(
() => engine.QuarantineAsync().AsTask());
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
}
finally
{
releaseSafety.Set();
await engine.DisposeAsync();
}
}
[Fact]
public async Task GuardedTargetPolicy_SlowDisconnectSafetyCheckPastTimeoutNeverDisconnects()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
using var safetyStarted = new ManualResetEventSlim();
using var releaseSafety = new ManualResetEventSlim();
var stable = ProcessSnapshot("pgm-generation-a");
var session = new FakeK3dSession();
var safetyChecks = 0;
var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(stable),
liveAuthorized: false,
finalConnectSafetyCheck: () =>
{
if (Interlocked.Increment(ref safetyChecks) == 2)
{
safetyStarted.Set();
Assert.True(releaseSafety.Wait(TimeSpan.FromSeconds(5)));
}
return true;
},
abandonOnTargetMismatch: true);
try
{
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
var disconnectTask = engine.DisconnectAsync(CancellationToken.None);
Assert.True(safetyStarted.Wait(TimeSpan.FromSeconds(2)));
var disconnect = await disconnectTask;
Assert.Equal(PlayoutResultCode.OutcomeUnknown, disconnect.Code);
releaseSafety.Set();
Assert.True(SpinWait.SpinUntil(
() => session.Calls.Contains("Abandon"),
TimeSpan.FromSeconds(2)));
await Assert.ThrowsAsync<InvalidOperationException>(
() => engine.QuarantineAsync().AsTask());
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
}
finally
{
releaseSafety.Set();
await engine.DisposeAsync();
}
}
[Fact]
public async Task GuardedTargetPolicy_TimeoutWinningAtomicCommandClaimPreventsPrepare()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
using var claimStarted = new ManualResetEventSlim();
using var releaseClaim = new ManualResetEventSlim();
var session = new FakeK3dSession();
var claims = 0;
var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(ProcessSnapshot("pgm-generation-a")),
liveAuthorized: false,
finalConnectSafetyCheck: () => true,
abandonOnTargetMismatch: true,
beforeSdkDispatchClaim: () =>
{
if (Interlocked.Increment(ref claims) == 2)
{
claimStarted.Set();
Assert.True(releaseClaim.Wait(TimeSpan.FromSeconds(5)));
}
});
try
{
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
var prepareTask = engine.PrepareAsync(
Cue("test-scene"),
CancellationToken.None);
Assert.True(claimStarted.Wait(TimeSpan.FromSeconds(2)));
var prepare = await prepareTask;
Assert.Equal(PlayoutResultCode.OutcomeUnknown, prepare.Code);
releaseClaim.Set();
Assert.True(SpinWait.SpinUntil(
() => session.Calls.Contains("Abandon"),
TimeSpan.FromSeconds(2)));
Assert.DoesNotContain(
session.Calls,
call => call.StartsWith("Prepare:", StringComparison.Ordinal));
await Assert.ThrowsAsync<InvalidOperationException>(
() => engine.QuarantineAsync().AsTask());
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
}
finally
{
releaseClaim.Set();
await engine.DisposeAsync();
}
}
[Fact]
public async Task GuardedTargetPolicy_NextUsesSeparateAtomicClaimsForPrepareAndPlay()
{
using var scenes = TemporarySceneDirectory.Create(
"test-scene.t2s",
"next-scene.t2s");
using var playClaimStarted = new ManualResetEventSlim();
using var releasePlayClaim = new ManualResetEventSlim();
var session = new FakeK3dSession();
var claims = 0;
var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene", "next-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(ProcessSnapshot("pgm-generation-a")),
liveAuthorized: false,
finalConnectSafetyCheck: () => true,
abandonOnTargetMismatch: true,
beforeSdkDispatchClaim: () =>
{
if (Interlocked.Increment(ref claims) == 5)
{
playClaimStarted.Set();
Assert.True(releasePlayClaim.Wait(TimeSpan.FromSeconds(5)));
}
});
try
{
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
Assert.True((await engine.PrepareAsync(
Cue("test-scene"),
CancellationToken.None)).IsSuccess);
Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess);
var nextTask = engine.NextAsync(
Cue("next-scene"),
CancellationToken.None);
Assert.True(playClaimStarted.Wait(TimeSpan.FromSeconds(2)));
var next = await nextTask;
Assert.Equal(PlayoutResultCode.OutcomeUnknown, next.Code);
Assert.Contains("Prepare:next-scene", session.Calls);
Assert.Equal(1, session.Calls.Count(call => call == "Play"));
releasePlayClaim.Set();
Assert.True(SpinWait.SpinUntil(
() => session.Calls.Contains("Abandon"),
TimeSpan.FromSeconds(2)));
Assert.Equal(1, session.Calls.Count(call => call == "Play"));
await Assert.ThrowsAsync<InvalidOperationException>(
() => engine.QuarantineAsync().AsTask());
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
}
finally
{
releasePlayClaim.Set();
await engine.DisposeAsync();
}
}
[Fact]
public async Task GuardedTargetPolicy_TimeoutWinningAtomicDisconnectClaimPreventsDisconnect()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
using var claimStarted = new ManualResetEventSlim();
using var releaseClaim = new ManualResetEventSlim();
var session = new FakeK3dSession();
var claims = 0;
var engine = CreateEngine(
TestOptions(scenes.Path, "test-scene"),
new FakeK3dSessionFactory(() => session),
new FakeRegistrationProbe(),
new FakeTornadoProcessProbe(ProcessSnapshot("pgm-generation-a")),
liveAuthorized: false,
finalConnectSafetyCheck: () => true,
abandonOnTargetMismatch: true,
beforeSdkDispatchClaim: () =>
{
if (Interlocked.Increment(ref claims) == 2)
{
claimStarted.Set();
Assert.True(releaseClaim.Wait(TimeSpan.FromSeconds(5)));
}
});
try
{
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
var disconnectTask = engine.DisconnectAsync(CancellationToken.None);
Assert.True(claimStarted.Wait(TimeSpan.FromSeconds(2)));
var disconnect = await disconnectTask;
Assert.Equal(PlayoutResultCode.OutcomeUnknown, disconnect.Code);
releaseClaim.Set();
Assert.True(SpinWait.SpinUntil(
() => session.Calls.Contains("Abandon"),
TimeSpan.FromSeconds(2)));
await Assert.ThrowsAsync<InvalidOperationException>(
() => engine.QuarantineAsync().AsTask());
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
}
finally
{
releaseClaim.Set();
await engine.DisposeAsync();
}
}
[Fact]
public async Task LastKtapEvidence_SurvivesLaterFailureBeforeAnotherDispatch()
{
@@ -1171,6 +1805,34 @@ public sealed class TornadoPlayoutEngineTests
Assert.Equal(1, sessionFactory.CreateCount);
}
[Fact]
public async Task Quarantine_WhenLocalAbandonFails_ReportsFailureAndNeverDisconnectsOrDisposes()
{
using var scenes = TemporarySceneDirectory.Create("test-scene.t2s");
var session = new FakeK3dSession
{
AbandonAction = () => throw new InvalidOperationException(
"fake local abandon failure")
};
var sessionFactory = new FakeK3dSessionFactory(() => session);
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);
await Assert.ThrowsAsync<InvalidOperationException>(
() => engine.QuarantineAsync().AsTask());
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
Assert.DoesNotContain("Disconnect", session.Calls);
Assert.DoesNotContain("Dispose", session.Calls);
Assert.Equal(1, sessionFactory.CreateCount);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
@@ -1219,7 +1881,10 @@ public sealed class TornadoPlayoutEngineTests
FakeK3dSessionFactory sessionFactory,
FakeRegistrationProbe registrationProbe,
FakeTornadoProcessProbe processProbe,
bool liveAuthorized)
bool liveAuthorized,
Func<bool>? finalConnectSafetyCheck = null,
bool abandonOnTargetMismatch = false,
Action? beforeSdkDispatchClaim = null)
{
var options = ValidatedPlayoutOptions.Create(rawOptions);
IStaDispatcher? dispatcher = options.Mode is PlayoutMode.Test or PlayoutMode.Live
@@ -1233,7 +1898,10 @@ public sealed class TornadoPlayoutEngineTests
dispatcher,
new FakeLiveAuthorization(liveAuthorized),
TimeProvider.System,
startMonitor: false);
startMonitor: false,
finalConnectSafetyCheck: finalConnectSafetyCheck,
abandonOnTargetMismatch: abandonOnTargetMismatch,
beforeSdkDispatchClaim: beforeSdkDispatchClaim);
}
private static PlayoutOptions TestOptions(