feat: add audited one-shot prepare gate
This commit is contained in:
@@ -4,6 +4,7 @@ using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Diagnostics;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Safety;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
@@ -1678,6 +1679,128 @@ public sealed class DynamicK3dSessionTests
|
||||
Assert.Equal(1, log.Names.Count(name => name == "Release:EventHandler"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("mutation")]
|
||||
[InlineData("query")]
|
||||
[InlineData("end")]
|
||||
public async Task GateA_PrepareStageFailureNeverRollsBackDisconnectsOrRetries(
|
||||
string failureStage)
|
||||
{
|
||||
const string capability =
|
||||
"0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
||||
var rawOptions = new PlayoutOptions
|
||||
{
|
||||
Mode = PlayoutMode.Live,
|
||||
Host = "127.0.0.1",
|
||||
Port = 30001,
|
||||
TcpMode = 1,
|
||||
LayoutIndex = 10,
|
||||
SceneDirectory = scenes.Path,
|
||||
TestSceneAllowlist = ["5001"],
|
||||
TrustedLiveOutputEnabled = true,
|
||||
ReconnectEnabled = false,
|
||||
MaximumReconnectAttempts = 0,
|
||||
MaximumAutomaticRefreshesPerTakeIn = 0,
|
||||
LegacySceneFadeDuration = 6,
|
||||
ConnectTimeoutMilliseconds = 500,
|
||||
OperationTimeoutMilliseconds = 500,
|
||||
DisconnectTimeoutMilliseconds = 500,
|
||||
ProcessPollIntervalMilliseconds = 100
|
||||
};
|
||||
var options = ValidatedPlayoutOptions.Create(rawOptions);
|
||||
var failure = new COMException($"fake Gate A {failureStage} failure");
|
||||
var log = new FakeComLog();
|
||||
var scene = new FakeScene(log, "5001");
|
||||
var player = new FakePlayer(log);
|
||||
var vendorEngine = new FakeEngine(log, player, scene);
|
||||
switch (failureStage)
|
||||
{
|
||||
case "mutation":
|
||||
scene.GetObjectFailure = failure;
|
||||
break;
|
||||
case "query":
|
||||
scene.QueryVariablesFailure = failure;
|
||||
break;
|
||||
case "end":
|
||||
vendorEngine.EndTransactionFailure = failure;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException("Unknown test failure stage.");
|
||||
}
|
||||
|
||||
var activator = new FakeActivator(log, vendorEngine);
|
||||
var releaser = new FakeReleaser(
|
||||
log,
|
||||
(scene, "5001"),
|
||||
(player, "Player"),
|
||||
(vendorEngine, "Engine"),
|
||||
(activator.EventHandler, "EventHandler"));
|
||||
var sessionFactory = new DelegateK3dSessionFactory(() =>
|
||||
new DynamicK3dSession(
|
||||
activator,
|
||||
releaser,
|
||||
new InstalledK3dInteropMethodInvoker(),
|
||||
new ActivatorK3dEventHandlerFactory(activator),
|
||||
rollbackOnPrepareFailure: false));
|
||||
using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
|
||||
capability);
|
||||
var dispatcher = new StaDispatcher(options.QueueCapacity);
|
||||
var engine = new TornadoPlayoutEngine(
|
||||
options,
|
||||
sessionFactory,
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(
|
||||
ProcessSnapshot("approved-pgm"),
|
||||
ProcessSnapshot("approved-pgm")),
|
||||
dispatcher,
|
||||
authorization,
|
||||
TimeProvider.System,
|
||||
startMonitor: false,
|
||||
finalConnectSafetyCheck: () => true,
|
||||
abandonOnTargetMismatch: true,
|
||||
beforeSdkDispatchClaim: authorization.EnsureGateASdkDispatchAuthorized,
|
||||
launchAuthorization: authorization);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
Assert.True(authorization.TryClaimGateAPrepare(capability));
|
||||
var cue = new PlayoutCue(
|
||||
"5001.t2s",
|
||||
"5001",
|
||||
[new PlayoutField("headline", "test", true)],
|
||||
FadeDuration: 6,
|
||||
Mutations: [new PlayoutUseBackground(false)]);
|
||||
|
||||
var result = await engine.PrepareAsync(cue, CancellationToken.None);
|
||||
var retry = await engine.PrepareAsync(cue, CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, retry.Code);
|
||||
}
|
||||
finally
|
||||
{
|
||||
authorization.CompleteGateAPrepare();
|
||||
await engine.DisposeAsync();
|
||||
}
|
||||
|
||||
Assert.Equal(1, log.Names.Count(name => name == "BeginTransaction"));
|
||||
Assert.DoesNotContain("RollbackTransaction", log.Names);
|
||||
Assert.DoesNotContain("Disconnect", log.Names);
|
||||
Assert.DoesNotContain("Prepare:10", log.Names);
|
||||
Assert.Equal(1, log.Names.Count(name => name == "Release:5001"));
|
||||
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"));
|
||||
}
|
||||
|
||||
private static TornadoProcessSnapshot ProcessSnapshot(string generation) =>
|
||||
new(1, 1, 0)
|
||||
{
|
||||
EligibleProcessGeneration = new TornadoProcessGeneration(generation)
|
||||
};
|
||||
|
||||
private static PlayoutOptions TestOptions(string sceneDirectory) => new()
|
||||
{
|
||||
Mode = PlayoutMode.Test,
|
||||
@@ -1715,6 +1838,12 @@ public sealed class DynamicK3dSessionTests
|
||||
name.StartsWith("Unload:", StringComparison.Ordinal) ||
|
||||
name.StartsWith("Release:500", StringComparison.Ordinal)).ToArray();
|
||||
|
||||
private sealed class DelegateK3dSessionFactory(Func<IK3dSession> create)
|
||||
: IK3dSessionFactory
|
||||
{
|
||||
public IK3dSession Create() => create();
|
||||
}
|
||||
|
||||
public sealed class FakeComLog
|
||||
{
|
||||
private readonly ConcurrentQueue<string> _names = new();
|
||||
@@ -1865,6 +1994,8 @@ public sealed class DynamicK3dSessionTests
|
||||
|
||||
public Func<string, FakeScene>? SceneResolver { get; init; }
|
||||
|
||||
public Exception? EndTransactionFailure { get; set; }
|
||||
|
||||
public int KTAPConnect(
|
||||
int tcpMode,
|
||||
string host,
|
||||
@@ -1913,10 +2044,23 @@ public sealed class DynamicK3dSessionTests
|
||||
|
||||
public void BeginTransaction() => _log.Add("BeginTransaction");
|
||||
|
||||
public void EndTransaction() => _log.Add("EndTransaction");
|
||||
public void EndTransaction()
|
||||
{
|
||||
_log.Add("EndTransaction");
|
||||
if (EndTransactionFailure is not null)
|
||||
{
|
||||
throw EndTransactionFailure;
|
||||
}
|
||||
}
|
||||
|
||||
public void EndTransactionOnChannel(int channel) =>
|
||||
public void EndTransactionOnChannel(int channel)
|
||||
{
|
||||
_log.Add($"EndTransactionOnChannel:{channel}");
|
||||
if (EndTransactionFailure is not null)
|
||||
{
|
||||
throw EndTransactionFailure;
|
||||
}
|
||||
}
|
||||
|
||||
public void RollbackTransaction() => _log.Add("RollbackTransaction");
|
||||
|
||||
@@ -2026,6 +2170,11 @@ public sealed class DynamicK3dSessionTests
|
||||
public object GetObject(string objectName)
|
||||
{
|
||||
_log.Add($"GetObject:{objectName}");
|
||||
if (GetObjectFailure is not null)
|
||||
{
|
||||
throw GetObjectFailure;
|
||||
}
|
||||
|
||||
return new FakeSceneObject(_log, objectName);
|
||||
}
|
||||
|
||||
@@ -2044,6 +2193,8 @@ public sealed class DynamicK3dSessionTests
|
||||
set => _queryVariablesFailure = value;
|
||||
}
|
||||
|
||||
public Exception? GetObjectFailure { get; set; }
|
||||
|
||||
public void Unload()
|
||||
{
|
||||
_log.Add(_name is null ? "Unload" : $"Unload:{_name}");
|
||||
|
||||
@@ -0,0 +1,747 @@
|
||||
using System.Security.Cryptography;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout;
|
||||
using MBN_STOCK_WEBVIEW.Core.Playout.Scenes;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Safety;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class PlayoutLaunchAuthorizationTests
|
||||
{
|
||||
private const string Capability =
|
||||
"0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
||||
|
||||
[Fact]
|
||||
public void Missing_gate_environment_preserves_the_standard_launch_profile()
|
||||
{
|
||||
using var environment = ClearGateEnvironment();
|
||||
|
||||
var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment();
|
||||
|
||||
Assert.False(authorization.IsGateA);
|
||||
Assert.True(authorization.AllowsDatabaseWrites);
|
||||
Assert.True(authorization.AllowsCompositionMutation);
|
||||
Assert.False(authorization.TryClaimGateAPrepare(Capability));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Non_gate_capture_preserves_existing_live_authorization_semantics()
|
||||
{
|
||||
using var environment = ClearGateEnvironment()
|
||||
.Set(
|
||||
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
|
||||
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
|
||||
|
||||
using var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment();
|
||||
|
||||
Assert.False(authorization.IsGateA);
|
||||
Assert.True(authorization.IsAuthorizedForThisLaunch);
|
||||
Assert.Equal(
|
||||
PlayoutOptionsLoader.RequiredLiveAuthorizationValue,
|
||||
Environment.GetEnvironmentVariable(
|
||||
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_capture_consumes_static_live_bearer_and_second_capture_fails_closed()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
||||
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
|
||||
var clock = new ManualGateTimeProvider();
|
||||
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
|
||||
using var environment = GateEnvironment(
|
||||
Capability,
|
||||
"41308",
|
||||
"638878000000000000",
|
||||
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture))
|
||||
.Set(
|
||||
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
|
||||
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
|
||||
|
||||
using var first = PlayoutLaunchAuthorization.CaptureFromEnvironment(
|
||||
clock,
|
||||
scenePath,
|
||||
SceneHash(scenePath));
|
||||
PlayoutLaunchAuthorization? second = null;
|
||||
Assert.Throws<PlayoutConfigurationException>(() =>
|
||||
second = PlayoutLaunchAuthorization.CaptureFromEnvironment());
|
||||
|
||||
Assert.True(first.IsGateA);
|
||||
Assert.True(first.IsAuthorizedForThisLaunch);
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable));
|
||||
Assert.Null(second);
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_environment_is_captured_once_and_cleared_before_browser_startup()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
||||
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
|
||||
var clock = new ManualGateTimeProvider();
|
||||
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
|
||||
using var environment = GateEnvironment(
|
||||
Capability,
|
||||
"41308",
|
||||
"638878000000000000",
|
||||
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
using var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment(
|
||||
clock,
|
||||
scenePath,
|
||||
SceneHash(scenePath));
|
||||
|
||||
Assert.True(authorization.IsGateA);
|
||||
Assert.Equal(41308, authorization.ExpectedPgmProcessId);
|
||||
Assert.Equal(638878000000000000, authorization.ExpectedPgmStartTimeUtcTicks);
|
||||
Assert.Equal(expiry, authorization.ExpiresAtUtcTicks);
|
||||
Assert.False(authorization.AllowsDatabaseWrites);
|
||||
Assert.False(authorization.AllowsCompositionMutation);
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
|
||||
|
||||
environment.Set(
|
||||
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable,
|
||||
new string('F', 64));
|
||||
|
||||
Assert.True(authorization.TryClaimGateAPrepare(Capability));
|
||||
Assert.False(authorization.TryClaimGateAPrepare(new string('F', 64)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("lowercase0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab")]
|
||||
[InlineData("ABCDEF")]
|
||||
[InlineData(null)]
|
||||
public void Invalid_or_partial_gate_environment_fails_closed_and_is_cleared(
|
||||
string? capability)
|
||||
{
|
||||
using var environment = ClearGateEnvironment()
|
||||
.Set(
|
||||
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable,
|
||||
capability)
|
||||
.Set(
|
||||
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable,
|
||||
"41308");
|
||||
|
||||
Assert.Throws<PlayoutConfigurationException>(
|
||||
PlayoutLaunchAuthorization.CaptureFromEnvironment);
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_first_capture_permanently_blocks_reinjection_and_clears_every_bearer()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
||||
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
|
||||
var clock = new ManualGateTimeProvider();
|
||||
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
|
||||
using var environment = ClearGateEnvironment()
|
||||
.Set(
|
||||
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable,
|
||||
"ABCDEF")
|
||||
.Set(
|
||||
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable,
|
||||
"41308")
|
||||
.Set(
|
||||
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
|
||||
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
|
||||
|
||||
Assert.Throws<PlayoutConfigurationException>(() =>
|
||||
PlayoutLaunchAuthorization.CaptureFromEnvironment(
|
||||
clock,
|
||||
scenePath,
|
||||
SceneHash(scenePath)));
|
||||
|
||||
environment
|
||||
.Set(
|
||||
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable,
|
||||
Capability)
|
||||
.Set(
|
||||
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable,
|
||||
"41308")
|
||||
.Set(
|
||||
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable,
|
||||
"638878000000000000")
|
||||
.Set(
|
||||
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable,
|
||||
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture))
|
||||
.Set(
|
||||
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
|
||||
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
|
||||
|
||||
PlayoutLaunchAuthorization? second = null;
|
||||
Assert.Throws<PlayoutConfigurationException>(() =>
|
||||
second = PlayoutLaunchAuthorization.CaptureFromEnvironment(
|
||||
clock,
|
||||
scenePath,
|
||||
SceneHash(scenePath)));
|
||||
|
||||
Assert.Null(second);
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable));
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_gate_captures_have_one_success_and_all_later_calls_fail_closed()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
||||
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
|
||||
var clock = new ManualGateTimeProvider();
|
||||
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
|
||||
using var environment = GateEnvironment(
|
||||
Capability,
|
||||
"41308",
|
||||
"638878000000000000",
|
||||
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture))
|
||||
.Set(
|
||||
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable,
|
||||
PlayoutOptionsLoader.RequiredLiveAuthorizationValue);
|
||||
using var start = new ManualResetEventSlim();
|
||||
var tasks = Enumerable.Range(0, 8)
|
||||
.Select(_ => Task.Run(() =>
|
||||
{
|
||||
start.Wait();
|
||||
try
|
||||
{
|
||||
return (
|
||||
Authorization: PlayoutLaunchAuthorization.CaptureFromEnvironment(
|
||||
clock,
|
||||
scenePath,
|
||||
SceneHash(scenePath)),
|
||||
Error: (Exception?)null);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return (Authorization: (PlayoutLaunchAuthorization?)null, Error: exception);
|
||||
}
|
||||
}))
|
||||
.ToArray();
|
||||
|
||||
start.Set();
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
var successful = Assert.Single(results, static result => result.Authorization is not null);
|
||||
Assert.True(successful.Authorization!.IsGateA);
|
||||
foreach (var failed in results.Where(static result => result.Authorization is null))
|
||||
{
|
||||
Assert.IsType<PlayoutConfigurationException>(failed.Error);
|
||||
}
|
||||
|
||||
successful.Authorization.Dispose();
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0L)]
|
||||
[InlineData(-1L)]
|
||||
[InlineData(9000000001L)]
|
||||
public void Expired_or_overlong_gate_environment_fails_closed_and_clears_every_value(
|
||||
long expiryOffsetTicks)
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
||||
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
|
||||
var clock = new ManualGateTimeProvider();
|
||||
var expiry = clock.GetUtcNow().UtcTicks + expiryOffsetTicks;
|
||||
using var environment = GateEnvironment(
|
||||
Capability,
|
||||
"41308",
|
||||
"638878000000000000",
|
||||
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
Assert.Throws<PlayoutConfigurationException>(() =>
|
||||
PlayoutLaunchAuthorization.CaptureFromEnvironment(
|
||||
clock,
|
||||
scenePath,
|
||||
SceneHash(scenePath)));
|
||||
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable));
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable));
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable));
|
||||
Assert.Null(Environment.GetEnvironmentVariable(
|
||||
PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_scene_wrong_hash_fails_closed_and_does_not_leave_a_lease()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
||||
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
|
||||
var clock = new ManualGateTimeProvider();
|
||||
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
|
||||
using var environment = GateEnvironment(
|
||||
Capability,
|
||||
"41308",
|
||||
"638878000000000000",
|
||||
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
|
||||
Assert.Throws<PlayoutConfigurationException>(() =>
|
||||
PlayoutLaunchAuthorization.CaptureFromEnvironment(
|
||||
clock,
|
||||
scenePath,
|
||||
new string('A', 64)));
|
||||
|
||||
using var write = File.Open(
|
||||
scenePath,
|
||||
FileMode.Open,
|
||||
FileAccess.Write,
|
||||
FileShare.ReadWrite | FileShare.Delete);
|
||||
Assert.True(write.CanWrite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_scene_native_read_lease_blocks_write_and_delete_until_native_shutdown()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
||||
var scenePath = Path.Combine(scenes.Path, "5001.t2s");
|
||||
var clock = new ManualGateTimeProvider();
|
||||
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(5).Ticks;
|
||||
using var environment = GateEnvironment(
|
||||
Capability,
|
||||
"41308",
|
||||
"638878000000000000",
|
||||
expiry.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
var authorization = PlayoutLaunchAuthorization.CaptureFromEnvironment(
|
||||
clock,
|
||||
scenePath,
|
||||
SceneHash(scenePath));
|
||||
|
||||
using (var secondReader = File.Open(
|
||||
scenePath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read))
|
||||
{
|
||||
Assert.True(secondReader.CanRead);
|
||||
}
|
||||
|
||||
Assert.ThrowsAny<IOException>(() => File.Open(
|
||||
scenePath,
|
||||
FileMode.Open,
|
||||
FileAccess.Write,
|
||||
FileShare.ReadWrite | FileShare.Delete));
|
||||
Assert.ThrowsAny<IOException>(() => File.Delete(scenePath));
|
||||
|
||||
authorization.CompleteGateAPrepare();
|
||||
Assert.ThrowsAny<IOException>(() => File.Delete(scenePath));
|
||||
|
||||
authorization.Dispose();
|
||||
File.Delete(scenePath);
|
||||
Assert.False(File.Exists(scenePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Native_expiry_at_operator_claim_is_terminal_and_cannot_be_rearmed()
|
||||
{
|
||||
var clock = new ManualGateTimeProvider();
|
||||
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks;
|
||||
using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
|
||||
Capability,
|
||||
timeProvider: clock,
|
||||
expiresAtUtcTicks: expiry);
|
||||
|
||||
clock.Advance(TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.False(authorization.TryClaimGateAPrepare(Capability));
|
||||
clock.SetUtcNow(clock.GetUtcNow().AddHours(-1));
|
||||
Assert.False(authorization.TryClaimGateAPrepare(Capability));
|
||||
Assert.False(authorization.TryClaimEngineCommand(
|
||||
PlayoutOperation.Connect,
|
||||
cue: null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expiry_between_operator_claim_and_engine_prepare_blocks_vendor_boundary()
|
||||
{
|
||||
var clock = new ManualGateTimeProvider();
|
||||
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks;
|
||||
using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
|
||||
Capability,
|
||||
timeProvider: clock,
|
||||
expiresAtUtcTicks: expiry);
|
||||
|
||||
clock.Advance(TimeSpan.FromMinutes(1) - TimeSpan.FromTicks(1));
|
||||
Assert.True(authorization.TryClaimGateAPrepare(Capability));
|
||||
clock.Advance(TimeSpan.FromTicks(1));
|
||||
|
||||
Assert.False(authorization.TryClaimEngineCommand(
|
||||
PlayoutOperation.Prepare,
|
||||
GateACue()));
|
||||
Assert.False(authorization.TryClaimEngineCommand(
|
||||
PlayoutOperation.Prepare,
|
||||
GateACue()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_just_before_expiry_does_not_extend_later_prepare_authority()
|
||||
{
|
||||
var clock = new ManualGateTimeProvider();
|
||||
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks;
|
||||
using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
|
||||
Capability,
|
||||
timeProvider: clock,
|
||||
expiresAtUtcTicks: expiry);
|
||||
|
||||
clock.Advance(TimeSpan.FromMinutes(1) - TimeSpan.FromTicks(1));
|
||||
Assert.True(authorization.TryClaimEngineCommand(
|
||||
PlayoutOperation.Connect,
|
||||
cue: null));
|
||||
clock.Advance(TimeSpan.FromTicks(1));
|
||||
|
||||
Assert.False(authorization.TryClaimGateAPrepare(Capability));
|
||||
Assert.False(authorization.TryClaimEngineCommand(
|
||||
PlayoutOperation.Prepare,
|
||||
GateACue()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_claim_uses_monotonic_deadline_so_utc_rollback_cannot_extend_gate()
|
||||
{
|
||||
var clock = new ManualGateTimeProvider();
|
||||
var expiry = clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks;
|
||||
using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
|
||||
Capability,
|
||||
timeProvider: clock,
|
||||
expiresAtUtcTicks: expiry);
|
||||
|
||||
clock.SetUtcNow(clock.GetUtcNow().AddHours(-1));
|
||||
clock.AdvanceMonotonic(TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.False(authorization.TryClaimEngineCommand(
|
||||
PlayoutOperation.Connect,
|
||||
cue: null));
|
||||
Assert.False(authorization.TryClaimGateAPrepare(Capability));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Capability_and_engine_prepare_are_each_one_shot_and_never_rearm()
|
||||
{
|
||||
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
|
||||
var cue = GateACue();
|
||||
|
||||
Assert.False(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue));
|
||||
Assert.False(authorization.TryClaimGateAPrepare(new string('F', 64)));
|
||||
Assert.True(authorization.TryClaimGateAPrepare(Capability));
|
||||
Assert.False(authorization.TryClaimGateAPrepare(Capability));
|
||||
Assert.True(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue));
|
||||
Assert.False(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue));
|
||||
|
||||
authorization.CompleteGateAPrepare();
|
||||
|
||||
Assert.False(authorization.TryClaimGateAPrepare(Capability));
|
||||
Assert.False(authorization.TryClaimEngineCommand(PlayoutOperation.Prepare, cue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wrong_prepare_target_consumes_the_engine_phase_and_blocks_correction()
|
||||
{
|
||||
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
|
||||
Assert.True(authorization.TryClaimGateAPrepare(Capability));
|
||||
|
||||
Assert.False(authorization.TryClaimEngineCommand(
|
||||
PlayoutOperation.Prepare,
|
||||
GateACue() with { SceneName = "5006", SceneFile = "5006.t2s" }));
|
||||
Assert.False(authorization.TryClaimEngineCommand(
|
||||
PlayoutOperation.Prepare,
|
||||
GateACue()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateA_rejects_operator_fade_and_background_changes_before_engine_dispatch()
|
||||
{
|
||||
var blockedCues = new[]
|
||||
{
|
||||
GateACue() with { FadeDuration = 5 },
|
||||
GateACue() with { Mutations = [] },
|
||||
GateACue() with
|
||||
{
|
||||
Mutations = [new PlayoutUseBackground(true)]
|
||||
},
|
||||
GateACue() with
|
||||
{
|
||||
Mutations =
|
||||
[
|
||||
new PlayoutUseBackground(false),
|
||||
new PlayoutUseBackground(false)
|
||||
]
|
||||
},
|
||||
GateACue() with
|
||||
{
|
||||
Mutations = [new PlayoutSetBackgroundTexture("background.png")]
|
||||
},
|
||||
GateACue() with
|
||||
{
|
||||
Mutations = [new PlayoutSetBackgroundVideo("background.mp4", 0, 1)]
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var cue in blockedCues)
|
||||
{
|
||||
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
|
||||
Assert.True(authorization.TryClaimGateAPrepare(Capability));
|
||||
Assert.False(authorization.TryClaimEngineCommand(
|
||||
PlayoutOperation.Prepare,
|
||||
cue));
|
||||
Assert.False(authorization.TryClaimEngineCommand(
|
||||
PlayoutOperation.Prepare,
|
||||
GateACue()));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Real_5001_provider_cue_with_exact_disabled_background_claims_native_prepare()
|
||||
{
|
||||
var provider = new RegisteredLegacySceneCueProvider(
|
||||
new SinglePageDataSource(new LegacySceneDataPage(
|
||||
"s5001",
|
||||
new S5001DomesticStockSceneData(
|
||||
LegacyDomesticEquityMarket.Kospi,
|
||||
S5001DomesticStockMode.Current,
|
||||
S5001NxtBadge.None,
|
||||
"Samsung Electronics",
|
||||
ScenePriceDirection.Up,
|
||||
70_000,
|
||||
500,
|
||||
0.72m),
|
||||
1)));
|
||||
var page = await provider.CreatePageAsync(
|
||||
new LegacyPlaylistEntry("gate-a-5001", "5001", FadeDuration: 6),
|
||||
pageIndexZeroBased: 0);
|
||||
using var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
|
||||
|
||||
Assert.Equal(new PlayoutUseBackground(false), page.Cue.Mutations![0]);
|
||||
Assert.True(authorization.TryClaimGateAPrepare(Capability));
|
||||
Assert.True(authorization.TryClaimEngineCommand(
|
||||
PlayoutOperation.Prepare,
|
||||
page.Cue));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_operator_claims_have_exactly_one_winner()
|
||||
{
|
||||
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
|
||||
using var start = new ManualResetEventSlim();
|
||||
var tasks = Enumerable.Range(0, 16)
|
||||
.Select(_ => Task.Run(() =>
|
||||
{
|
||||
start.Wait();
|
||||
return authorization.TryClaimGateAPrepare(Capability);
|
||||
}))
|
||||
.ToArray();
|
||||
|
||||
start.Set();
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
Assert.Single(results, static result => result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GateA_options_require_the_exact_closed_profile()
|
||||
{
|
||||
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(Capability);
|
||||
var valid = GateAOptions();
|
||||
|
||||
PlayoutEngineFactory.ValidateGateAOptions(valid, authorization);
|
||||
|
||||
foreach (var invalid in new[]
|
||||
{
|
||||
GateAOptions().WithMode(PlayoutMode.DryRun),
|
||||
GateAOptions().WithAllowlist("5001", "5006"),
|
||||
GateAOptions().WithReconnect(true, 0),
|
||||
GateAOptions().WithReconnect(false, 1),
|
||||
GateAOptions().WithRefresh(null),
|
||||
GateAOptions().WithFade(5),
|
||||
GateAOptions().WithBackground(LegacySceneBackgroundKind.Texture),
|
||||
GateAOptions().WithSceneDirectory(Path.GetTempPath()),
|
||||
GateAOptions().WithClientPort(1),
|
||||
GateAOptions().WithOutputChannel(0)
|
||||
})
|
||||
{
|
||||
Assert.Throws<PlayoutConfigurationException>(() =>
|
||||
PlayoutEngineFactory.ValidateGateAOptions(invalid, authorization));
|
||||
}
|
||||
}
|
||||
|
||||
private static PlayoutCue GateACue() => new(
|
||||
"5001.t2s",
|
||||
"5001",
|
||||
FadeDuration: 6,
|
||||
Mutations: [new PlayoutUseBackground(false)]);
|
||||
|
||||
private static PlayoutOptions GateAOptions() => new()
|
||||
{
|
||||
Mode = PlayoutMode.Live,
|
||||
Host = "127.0.0.1",
|
||||
Port = 30001,
|
||||
TcpMode = 1,
|
||||
LayoutIndex = 10,
|
||||
TestSceneAllowlist = ["5001"],
|
||||
TrustedLiveOutputEnabled = true,
|
||||
ReconnectEnabled = false,
|
||||
MaximumReconnectAttempts = 0,
|
||||
MaximumAutomaticRefreshesPerTakeIn = 0,
|
||||
LegacySceneFadeDuration = 6,
|
||||
LegacySceneBackgroundKind = LegacySceneBackgroundKind.None,
|
||||
SceneDirectory = PlayoutLaunchAuthorization.GateAApprovedSceneDirectory
|
||||
};
|
||||
|
||||
private static string SceneHash(string path) =>
|
||||
Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)));
|
||||
|
||||
private sealed class SinglePageDataSource(LegacySceneDataPage page)
|
||||
: ILegacySceneDataSource
|
||||
{
|
||||
public Task<LegacySceneDataPage> LoadPageAsync(
|
||||
LegacyPlaylistEntry entry,
|
||||
int pageIndexZeroBased,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(page);
|
||||
}
|
||||
|
||||
private static EnvironmentScope ClearGateEnvironment()
|
||||
{
|
||||
PlayoutLaunchAuthorization.ResetGateACaptureLatchForTests();
|
||||
return new EnvironmentScope()
|
||||
.Set(PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, null)
|
||||
.Set(PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable, null)
|
||||
.Set(PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable, null)
|
||||
.Set(PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable, null)
|
||||
.Set(PlayoutOptionsLoader.LiveAuthorizationEnvironmentVariable, null);
|
||||
}
|
||||
|
||||
private static EnvironmentScope GateEnvironment(
|
||||
string capability,
|
||||
string processId,
|
||||
string startTimeUtcTicks,
|
||||
string expiresAtUtcTicks) =>
|
||||
ClearGateEnvironment()
|
||||
.Set(PlayoutLaunchAuthorization.GateACapabilityEnvironmentVariable, capability)
|
||||
.Set(PlayoutLaunchAuthorization.GateAPgmProcessIdEnvironmentVariable, processId)
|
||||
.Set(PlayoutLaunchAuthorization.GateAPgmStartTimeUtcTicksEnvironmentVariable, startTimeUtcTicks)
|
||||
.Set(PlayoutLaunchAuthorization.GateAExpiresAtUtcTicksEnvironmentVariable, expiresAtUtcTicks);
|
||||
}
|
||||
|
||||
internal sealed class ManualGateTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow = new(2026, 7, 18, 0, 0, 0, TimeSpan.Zero);
|
||||
private long _timestamp;
|
||||
|
||||
public override long TimestampFrequency => TimeSpan.TicksPerSecond;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public override long GetTimestamp() => _timestamp;
|
||||
|
||||
public void Advance(TimeSpan duration)
|
||||
{
|
||||
_utcNow = _utcNow.Add(duration);
|
||||
_timestamp += duration.Ticks;
|
||||
}
|
||||
|
||||
public void AdvanceMonotonic(TimeSpan duration) =>
|
||||
_timestamp += duration.Ticks;
|
||||
|
||||
public void SetUtcNow(DateTimeOffset value) => _utcNow = value;
|
||||
}
|
||||
|
||||
internal static class GateAOptionTestExtensions
|
||||
{
|
||||
public static PlayoutOptions WithMode(this PlayoutOptions options, PlayoutMode mode)
|
||||
{
|
||||
options.Mode = mode;
|
||||
return options;
|
||||
}
|
||||
|
||||
public static PlayoutOptions WithAllowlist(
|
||||
this PlayoutOptions options,
|
||||
params string[] scenes)
|
||||
{
|
||||
options.TestSceneAllowlist = [.. scenes];
|
||||
return options;
|
||||
}
|
||||
|
||||
public static PlayoutOptions WithReconnect(
|
||||
this PlayoutOptions options,
|
||||
bool enabled,
|
||||
int attempts)
|
||||
{
|
||||
options.ReconnectEnabled = enabled;
|
||||
options.MaximumReconnectAttempts = attempts;
|
||||
return options;
|
||||
}
|
||||
|
||||
public static PlayoutOptions WithRefresh(this PlayoutOptions options, int? maximum)
|
||||
{
|
||||
options.MaximumAutomaticRefreshesPerTakeIn = maximum;
|
||||
return options;
|
||||
}
|
||||
|
||||
public static PlayoutOptions WithFade(this PlayoutOptions options, int fade)
|
||||
{
|
||||
options.LegacySceneFadeDuration = fade;
|
||||
return options;
|
||||
}
|
||||
|
||||
public static PlayoutOptions WithBackground(
|
||||
this PlayoutOptions options,
|
||||
LegacySceneBackgroundKind kind)
|
||||
{
|
||||
options.LegacySceneBackgroundKind = kind;
|
||||
return options;
|
||||
}
|
||||
|
||||
public static PlayoutOptions WithSceneDirectory(
|
||||
this PlayoutOptions options,
|
||||
string sceneDirectory)
|
||||
{
|
||||
options.SceneDirectory = sceneDirectory;
|
||||
return options;
|
||||
}
|
||||
|
||||
public static PlayoutOptions WithClientPort(
|
||||
this PlayoutOptions options,
|
||||
int clientPort)
|
||||
{
|
||||
options.ClientPort = clientPort;
|
||||
return options;
|
||||
}
|
||||
|
||||
public static PlayoutOptions WithOutputChannel(
|
||||
this PlayoutOptions options,
|
||||
int outputChannel)
|
||||
{
|
||||
options.OutputChannel = outputChannel;
|
||||
return options;
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,15 @@ using System.Runtime.InteropServices;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Configuration;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Interop;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
using MBN_STOCK_WEBVIEW.Playout.Safety;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Tests;
|
||||
|
||||
public sealed class TornadoPlayoutEngineTests
|
||||
{
|
||||
private const string GateACapability =
|
||||
"0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF";
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(1, true)]
|
||||
@@ -2568,6 +2572,339 @@ public sealed class TornadoPlayoutEngineTests
|
||||
Assert.Contains("Disconnect", session.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GateA_AllowsExactlyOneConnectAndClaimed5001Prepare_ThenAbandonsWithoutBye()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
||||
var session = new FakeK3dSession();
|
||||
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
|
||||
GateACapability);
|
||||
var engine = CreateEngine(
|
||||
GateAOptions(scenes.Path),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")),
|
||||
liveAuthorized: true,
|
||||
launchAuthorization: authorization);
|
||||
|
||||
try
|
||||
{
|
||||
var cue = GateACue();
|
||||
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
Assert.Equal(
|
||||
PlayoutResultCode.Rejected,
|
||||
(await engine.ConnectAsync(CancellationToken.None)).Code);
|
||||
Assert.Equal(
|
||||
PlayoutResultCode.Rejected,
|
||||
(await engine.PrepareAsync(cue, CancellationToken.None)).Code);
|
||||
Assert.Equal(
|
||||
PlayoutResultCode.Rejected,
|
||||
(await engine.TakeInAsync(CancellationToken.None)).Code);
|
||||
Assert.Equal(
|
||||
PlayoutResultCode.Rejected,
|
||||
(await engine.NextAsync(cue, CancellationToken.None)).Code);
|
||||
Assert.Equal(
|
||||
PlayoutResultCode.Rejected,
|
||||
(await engine.UpdateOnAirAsync(cue, CancellationToken.None)).Code);
|
||||
Assert.Equal(
|
||||
PlayoutResultCode.Rejected,
|
||||
(await engine.TakeOutAsync(
|
||||
PlayoutTakeOutScope.All,
|
||||
CancellationToken.None)).Code);
|
||||
Assert.Equal(
|
||||
PlayoutResultCode.Rejected,
|
||||
(await engine.DisconnectAsync(CancellationToken.None)).Code);
|
||||
Assert.False(engine.Status.LiveTakeInAllowed);
|
||||
|
||||
Assert.True(authorization.TryClaimGateAPrepare(GateACapability));
|
||||
Assert.True((await engine.PrepareAsync(cue, CancellationToken.None)).IsSuccess);
|
||||
Assert.Equal(
|
||||
PlayoutResultCode.Rejected,
|
||||
(await engine.PrepareAsync(cue, CancellationToken.None)).Code);
|
||||
}
|
||||
finally
|
||||
{
|
||||
authorization.CompleteGateAPrepare();
|
||||
await engine.DisposeAsync();
|
||||
}
|
||||
|
||||
Assert.Equal(new[] { "Connect", "Prepare:5001", "Abandon" }, session.Calls);
|
||||
Assert.Equal(1, session.KtapDispatchCount);
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GateA_WrongPrepareTargetPermanentlyConsumesPrepareWithoutSdkMutation()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
||||
var session = new FakeK3dSession();
|
||||
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
|
||||
GateACapability);
|
||||
var engine = CreateEngine(
|
||||
GateAOptions(scenes.Path),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")),
|
||||
liveAuthorized: true,
|
||||
launchAuthorization: authorization);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
Assert.True(authorization.TryClaimGateAPrepare(GateACapability));
|
||||
|
||||
var wrong = await engine.PrepareAsync(
|
||||
new PlayoutCue("5006.t2s", "5006", FadeDuration: 6),
|
||||
CancellationToken.None);
|
||||
var correction = await engine.PrepareAsync(
|
||||
GateACue(),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.Rejected, wrong.Code);
|
||||
Assert.Equal(PlayoutResultCode.Rejected, correction.Code);
|
||||
}
|
||||
finally
|
||||
{
|
||||
authorization.CompleteGateAPrepare();
|
||||
await engine.DisposeAsync();
|
||||
}
|
||||
|
||||
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
|
||||
Assert.DoesNotContain(
|
||||
session.Calls,
|
||||
static call => call.StartsWith("Prepare:", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GateA_PgmGenerationChangeAbandonsWithoutReconnectOrBye()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
||||
var sessionFactory = new FakeK3dSessionFactory();
|
||||
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
|
||||
GateACapability);
|
||||
var options = GateAOptions(scenes.Path);
|
||||
options.ReconnectEnabled = true;
|
||||
options.MaximumReconnectAttempts = 3;
|
||||
var engine = CreateEngine(
|
||||
options,
|
||||
sessionFactory,
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(
|
||||
ProcessSnapshot("approved-pgm"),
|
||||
ProcessSnapshot("approved-pgm"),
|
||||
ProcessSnapshot("replacement-pgm")),
|
||||
liveAuthorized: true,
|
||||
launchAuthorization: authorization);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
|
||||
await engine.PollProcessOnceAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State);
|
||||
Assert.Equal(1, sessionFactory.CreateCount);
|
||||
var session = Assert.Single(sessionFactory.Sessions);
|
||||
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
}
|
||||
finally
|
||||
{
|
||||
authorization.CompleteGateAPrepare();
|
||||
await engine.DisposeAsync();
|
||||
}
|
||||
|
||||
Assert.Equal(1, sessionFactory.CreateCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GateA_ExpiredConnectIsRejectedBeforeProcessRegistrationOrVendorBoundary()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
||||
var clock = new ManualGateTimeProvider();
|
||||
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
|
||||
GateACapability,
|
||||
timeProvider: clock,
|
||||
expiresAtUtcTicks:
|
||||
clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks);
|
||||
clock.Advance(TimeSpan.FromMinutes(1));
|
||||
var sessionFactory = new FakeK3dSessionFactory();
|
||||
var registration = new FakeRegistrationProbe();
|
||||
var process = new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm"));
|
||||
var engine = CreateEngine(
|
||||
GateAOptions(scenes.Path),
|
||||
sessionFactory,
|
||||
registration,
|
||||
process,
|
||||
liveAuthorized: true,
|
||||
launchAuthorization: authorization);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await engine.ConnectAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.Rejected, result.Code);
|
||||
Assert.Equal(0, process.CaptureCount);
|
||||
Assert.Equal(0, registration.ProbeCount);
|
||||
Assert.Equal(0, sessionFactory.CreateCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await engine.DisposeAsync();
|
||||
authorization.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GateA_PrepareFailureBecomesOutcomeUnknownAndCannotRetryOrDisconnect()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
||||
var session = new FakeK3dSession
|
||||
{
|
||||
PrepareAction = (_, _) => throw new COMException(
|
||||
"fake ambiguous Gate A prepare failure")
|
||||
};
|
||||
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
|
||||
GateACapability);
|
||||
var engine = CreateEngine(
|
||||
GateAOptions(scenes.Path),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")),
|
||||
liveAuthorized: true,
|
||||
abandonOnTargetMismatch: true,
|
||||
launchAuthorization: authorization);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
Assert.True(authorization.TryClaimGateAPrepare(GateACapability));
|
||||
|
||||
var failed = await engine.PrepareAsync(GateACue(), CancellationToken.None);
|
||||
var retry = await engine.PrepareAsync(GateACue(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, failed.Code);
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, retry.Code);
|
||||
}
|
||||
finally
|
||||
{
|
||||
authorization.CompleteGateAPrepare();
|
||||
await engine.DisposeAsync();
|
||||
authorization.Dispose();
|
||||
}
|
||||
|
||||
Assert.Equal(new[] { "Connect", "Prepare:5001", "Abandon" }, session.Calls);
|
||||
Assert.Equal(1, session.Calls.Count(call => call == "Prepare:5001"));
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GateA_ConnectExpiryAtSdkBoundaryPreventsKtapDispatch()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
||||
var clock = new ManualGateTimeProvider();
|
||||
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
|
||||
GateACapability,
|
||||
timeProvider: clock,
|
||||
expiresAtUtcTicks:
|
||||
clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks);
|
||||
var session = new FakeK3dSession
|
||||
{
|
||||
BeforeKtapDispatchAction = () => clock.Advance(TimeSpan.FromMinutes(1))
|
||||
};
|
||||
var engine = CreateEngine(
|
||||
GateAOptions(scenes.Path),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")),
|
||||
liveAuthorized: true,
|
||||
finalConnectSafetyCheck: () => true,
|
||||
abandonOnTargetMismatch: true,
|
||||
beforeSdkDispatchClaim: authorization.EnsureGateASdkDispatchAuthorized,
|
||||
launchAuthorization: authorization);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await engine.ConnectAsync(CancellationToken.None);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal(0, session.KtapDispatchCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await engine.DisposeAsync();
|
||||
authorization.Dispose();
|
||||
}
|
||||
|
||||
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GateA_PrepareExpiryAtSdkBoundaryPreventsPrepareDispatchAndQuarantines()
|
||||
{
|
||||
using var scenes = TemporarySceneDirectory.Create("5001.t2s");
|
||||
var clock = new ManualGateTimeProvider();
|
||||
var authorization = PlayoutLaunchAuthorization.CreateGateAForTests(
|
||||
GateACapability,
|
||||
timeProvider: clock,
|
||||
expiresAtUtcTicks:
|
||||
clock.GetUtcNow().UtcTicks + TimeSpan.FromMinutes(1).Ticks);
|
||||
var dispatchBoundaryCount = 0;
|
||||
void DelayedDispatchGuard()
|
||||
{
|
||||
if (Interlocked.Increment(ref dispatchBoundaryCount) == 2)
|
||||
{
|
||||
clock.Advance(TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
authorization.EnsureGateASdkDispatchAuthorized();
|
||||
}
|
||||
|
||||
var session = new FakeK3dSession();
|
||||
var engine = CreateEngine(
|
||||
GateAOptions(scenes.Path),
|
||||
new FakeK3dSessionFactory(() => session),
|
||||
new FakeRegistrationProbe(),
|
||||
new FakeTornadoProcessProbe(ProcessSnapshot("approved-pgm")),
|
||||
liveAuthorized: true,
|
||||
finalConnectSafetyCheck: () => true,
|
||||
abandonOnTargetMismatch: true,
|
||||
beforeSdkDispatchClaim: DelayedDispatchGuard,
|
||||
launchAuthorization: authorization);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess);
|
||||
Assert.True(authorization.TryClaimGateAPrepare(GateACapability));
|
||||
|
||||
var result = await engine.PrepareAsync(GateACue(), CancellationToken.None);
|
||||
var retry = await engine.PrepareAsync(GateACue(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code);
|
||||
Assert.Equal(PlayoutResultCode.OutcomeUnknown, retry.Code);
|
||||
Assert.Equal(2, dispatchBoundaryCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
authorization.CompleteGateAPrepare();
|
||||
await engine.DisposeAsync();
|
||||
authorization.Dispose();
|
||||
}
|
||||
|
||||
Assert.Equal(new[] { "Connect", "Abandon" }, session.Calls);
|
||||
Assert.DoesNotContain(
|
||||
session.Calls,
|
||||
static call => call.StartsWith("Prepare:", StringComparison.Ordinal));
|
||||
Assert.DoesNotContain("Disconnect", session.Calls);
|
||||
Assert.DoesNotContain("Dispose", session.Calls);
|
||||
}
|
||||
|
||||
private static TornadoPlayoutEngine CreateEngine(
|
||||
PlayoutOptions rawOptions,
|
||||
FakeK3dSessionFactory sessionFactory,
|
||||
@@ -2577,7 +2914,8 @@ public sealed class TornadoPlayoutEngineTests
|
||||
Func<bool>? finalConnectSafetyCheck = null,
|
||||
bool abandonOnTargetMismatch = false,
|
||||
Action? beforeSdkDispatchClaim = null,
|
||||
FakeLiveAuthorization? liveAuthorization = null)
|
||||
FakeLiveAuthorization? liveAuthorization = null,
|
||||
PlayoutLaunchAuthorization? launchAuthorization = null)
|
||||
{
|
||||
var options = ValidatedPlayoutOptions.Create(rawOptions);
|
||||
IStaDispatcher? dispatcher = options.Mode is PlayoutMode.Test or PlayoutMode.Live
|
||||
@@ -2594,7 +2932,8 @@ public sealed class TornadoPlayoutEngineTests
|
||||
startMonitor: false,
|
||||
finalConnectSafetyCheck: finalConnectSafetyCheck,
|
||||
abandonOnTargetMismatch: abandonOnTargetMismatch,
|
||||
beforeSdkDispatchClaim: beforeSdkDispatchClaim);
|
||||
beforeSdkDispatchClaim: beforeSdkDispatchClaim,
|
||||
launchAuthorization: launchAuthorization);
|
||||
}
|
||||
|
||||
private static PlayoutOptions TestOptions(
|
||||
@@ -2630,6 +2969,33 @@ public sealed class TornadoPlayoutEngineTests
|
||||
MaximumReconnectAttempts = 3
|
||||
};
|
||||
|
||||
private static PlayoutOptions GateAOptions(string sceneDirectory) => new()
|
||||
{
|
||||
Mode = PlayoutMode.Live,
|
||||
Host = "127.0.0.1",
|
||||
Port = 30001,
|
||||
TcpMode = 1,
|
||||
LayoutIndex = 10,
|
||||
SceneDirectory = sceneDirectory,
|
||||
TestSceneAllowlist = ["5001"],
|
||||
TrustedLiveOutputEnabled = true,
|
||||
ReconnectEnabled = false,
|
||||
MaximumReconnectAttempts = 0,
|
||||
MaximumAutomaticRefreshesPerTakeIn = 0,
|
||||
LegacySceneFadeDuration = 6,
|
||||
ConnectTimeoutMilliseconds = 500,
|
||||
OperationTimeoutMilliseconds = 500,
|
||||
DisconnectTimeoutMilliseconds = 500,
|
||||
ProcessPollIntervalMilliseconds = 100,
|
||||
ReconnectDelayMilliseconds = 0
|
||||
};
|
||||
|
||||
private static PlayoutCue GateACue() => new(
|
||||
"5001.t2s",
|
||||
"5001",
|
||||
FadeDuration: 6,
|
||||
Mutations: [new PlayoutUseBackground(false)]);
|
||||
|
||||
private static PlayoutCue Cue(string sceneName) => new(
|
||||
$"{sceneName}.t2s",
|
||||
sceneName,
|
||||
|
||||
Reference in New Issue
Block a user