Files
MBN_STOCK_WEBVIEW/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/IsolatedTestCommand.cs

551 lines
19 KiB
C#

using System.Collections;
using MBN_STOCK_WEBVIEW.Core.Playout;
using MBN_STOCK_WEBVIEW.Playout;
using MBN_STOCK_WEBVIEW.Playout.Configuration;
namespace MBN_STOCK_WEBVIEW.PlayoutSmoke;
internal sealed record IsolatedTestCommandPlan(
SmokeCommandKind Kind,
PlayoutOptions Options,
PlayoutCue? PrepareCue,
PlayoutCue? NextCue,
TimeSpan ObservationDuration);
internal sealed class IsolatedTestPreflightException : Exception
{
public IsolatedTestPreflightException(string errorCode)
: base("The isolated Test command was rejected before engine creation.")
{
ErrorCode = errorCode;
}
public string ErrorCode { get; }
}
internal static class IsolatedTestCommandPreflight
{
internal const string EnvironmentVariablePrefix = "MBN_STOCK_PLAYOUT_";
public static IsolatedTestCommandPlan Create(SmokeCommandInvocation invocation)
{
ArgumentNullException.ThrowIfNull(invocation);
if (invocation.Kind is not (
SmokeCommandKind.TestPlan or
SmokeCommandKind.TestConnect or
SmokeCommandKind.TestSequence))
{
throw Rejected("invalid-test-command");
}
if (HasPlayoutEnvironmentOverride(Environment.GetEnvironmentVariables()))
{
throw Rejected("environment-override-present");
}
var configPath = ValidateConfigPath(invocation.ConfigPath);
PlayoutOptions options;
try
{
options = PlayoutOptionsLoader.LoadFileOnly(configPath);
}
catch (Exception exception) when (
exception is PlayoutConfigurationException or
ArgumentException or
IOException or
UnauthorizedAccessException)
{
throw Rejected("configuration-rejected");
}
if (options.Mode != PlayoutMode.Test || options.TrustedLiveOutputEnabled)
{
throw Rejected("test-mode-required");
}
// This one-shot diagnostic never reconnects or replays a command. The runtime
// still checks process eligibility before every operation.
options.ReconnectEnabled = false;
options.MaximumReconnectAttempts = 0;
PlayoutCue? prepareCue = null;
PlayoutCue? nextCue = null;
if (invocation.Kind is SmokeCommandKind.TestPlan or SmokeCommandKind.TestSequence)
{
if (invocation.PrepareSceneCode is null || invocation.NextSceneCode is null ||
invocation.ObservationMilliseconds is not (>= 1_000 and <= 30_000) ||
!SmokeCommandLine.IsSafeSceneCode(invocation.PrepareSceneCode) ||
!SmokeCommandLine.IsSafeSceneCode(invocation.NextSceneCode))
{
throw Rejected("scene-code-rejected");
}
prepareCue = CreateCue(invocation.PrepareSceneCode);
nextCue = CreateCue(invocation.NextSceneCode);
}
try
{
PlayoutEngineFactory.ValidateIsolatedTestCommand(
options,
prepareCue is null ? [] : [prepareCue, nextCue!]);
}
catch (Exception exception) when (
exception is PlayoutConfigurationException or ArgumentException)
{
throw Rejected("safety-gate-rejected");
}
return new IsolatedTestCommandPlan(
invocation.Kind,
options,
prepareCue,
nextCue,
TimeSpan.FromMilliseconds(invocation.ObservationMilliseconds ?? 0));
}
internal static bool HasPlayoutEnvironmentOverride(IDictionary environment)
{
ArgumentNullException.ThrowIfNull(environment);
foreach (var key in environment.Keys)
{
if (key is string name &&
name.StartsWith(EnvironmentVariablePrefix, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static string ValidateConfigPath(string? value)
{
if (string.IsNullOrWhiteSpace(value) || !Path.IsPathFullyQualified(value))
{
throw Rejected("config-file-rejected");
}
string fullPath;
FileAttributes attributes;
try
{
fullPath = Path.GetFullPath(value);
var root = Path.GetPathRoot(fullPath);
if (string.IsNullOrEmpty(root) ||
fullPath.StartsWith("\\\\", StringComparison.Ordinal) ||
new DriveInfo(root).DriveType != DriveType.Fixed)
{
throw Rejected("config-file-rejected");
}
if (!File.Exists(fullPath))
{
throw Rejected("config-file-rejected");
}
attributes = File.GetAttributes(fullPath);
}
catch (IsolatedTestPreflightException)
{
throw;
}
catch (Exception exception) when (
exception is ArgumentException or
IOException or
NotSupportedException or
System.Security.SecurityException or
UnauthorizedAccessException)
{
throw Rejected("config-file-rejected");
}
if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0)
{
throw Rejected("config-file-rejected");
}
return fullPath;
}
private static PlayoutCue CreateCue(string sceneCode) =>
new($"{sceneCode}.t2s", sceneCode);
private static IsolatedTestPreflightException Rejected(string errorCode) => new(errorCode);
}
internal sealed record SafeCommandStep(
string Phase,
string Operation,
string Code);
internal sealed record SafeCleanupReport(
bool DisconnectAttempted,
string? DisconnectCode,
bool QuarantineAttempted,
bool QuarantineCompleted,
bool AdapterDisposed);
internal sealed record SafeTestCommandReport(
string Command,
string Mode,
bool ConnectRequestIssued,
bool? ComActivationAttempted,
string LastKtapConnectState,
bool KtapConnectAttempted,
bool? KtapConnectAccepted,
bool? KtapHelloObserved,
bool? NetworkMonitoringRecordExpected,
bool NetworkMonitoringCheckRequired,
bool? NetworkMonitoringVerified,
bool Completed,
bool OutcomeUnknown,
bool OutputMayBeActive,
string? StoppedAfter,
IReadOnlyList<SafeCommandStep> Steps,
SafeCleanupReport Cleanup);
internal static class IsolatedTestCommandExecutor
{
public static Task<SafeTestCommandReport> ExecuteAsync(
IsolatedTestCommandPlan plan,
CancellationToken cancellationToken = default) =>
ExecuteAsync(
plan,
PlayoutEngineFactory.Create,
static (delay, token) => Task.Delay(delay, token),
cancellationToken);
internal static async Task<SafeTestCommandReport> ExecuteAsync(
IsolatedTestCommandPlan plan,
Func<PlayoutOptions, IPlayoutEngine> engineFactory,
Func<TimeSpan, CancellationToken, Task> observationDelay,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plan);
ArgumentNullException.ThrowIfNull(engineFactory);
ArgumentNullException.ThrowIfNull(observationDelay);
if (plan.Kind is not (SmokeCommandKind.TestConnect or SmokeCommandKind.TestSequence))
{
throw new ArgumentException("The plan is not executable.", nameof(plan));
}
var steps = new List<SafeCommandStep>();
IPlayoutEngine? engine = null;
var connectRequestIssued = false;
var connectResultReceived = false;
bool? comActivationAttempted = false;
var lastKtapConnectState = PlayoutKtapConnectState.NotAttempted;
bool? ktapHelloObserved = null;
var completed = false;
var outcomeUnknown = false;
string? stoppedAfter = null;
var disconnectAttempted = false;
string? disconnectCode = null;
var adapterDisposed = false;
var quarantineAttempted = false;
var quarantineCompleted = false;
var normalDisconnectCompleted = false;
var outputMayBeActive = false;
var takeOutAttempted = false;
try
{
engine = engineFactory(plan.Options);
do
{
connectRequestIssued = true;
var connect = await engine.ConnectAsync(cancellationToken).ConfigureAwait(false);
connectResultReceived = true;
CaptureKtapEvidence();
AddStep(steps, "command", connect);
comActivationAttempted = InferComActivationAttempt(connect.Code);
if (!connect.IsSuccess)
{
stoppedAfter = connect.Operation.ToString();
outcomeUnknown = IsOutcomeUnknown(connect);
break;
}
if (plan.Kind == SmokeCommandKind.TestSequence)
{
var prepare = await engine.PrepareAsync(plan.PrepareCue!, cancellationToken)
.ConfigureAwait(false);
AddStep(steps, "command", prepare);
if (!prepare.IsSuccess)
{
stoppedAfter = prepare.Operation.ToString();
outcomeUnknown = IsOutcomeUnknown(prepare);
break;
}
// Once TAKE IN is issued, an exception or ambiguous result can mean
// the Test output became active even though no success was observed.
outputMayBeActive = true;
var takeIn = await engine.TakeInAsync(cancellationToken).ConfigureAwait(false);
AddStep(steps, "command", takeIn);
if (!takeIn.IsSuccess)
{
stoppedAfter = takeIn.Operation.ToString();
outcomeUnknown = IsOutcomeUnknown(takeIn);
if (!outcomeUnknown)
{
outputMayBeActive = false;
}
break;
}
if (!await ObserveAsync().ConfigureAwait(false))
{
stoppedAfter = "Observation";
break;
}
var next = await engine.NextAsync(plan.NextCue!, cancellationToken)
.ConfigureAwait(false);
AddStep(steps, "command", next);
if (!next.IsSuccess)
{
stoppedAfter = next.Operation.ToString();
outcomeUnknown = IsOutcomeUnknown(next);
break;
}
if (!await ObserveAsync().ConfigureAwait(false))
{
stoppedAfter = "Observation";
break;
}
takeOutAttempted = true;
var takeOut = await engine.TakeOutAsync(
PlayoutTakeOutScope.All,
cancellationToken).ConfigureAwait(false);
AddStep(steps, "command", takeOut);
if (!takeOut.IsSuccess)
{
stoppedAfter = takeOut.Operation.ToString();
// TAKE IN succeeded, so every non-successful TAKE OUT leaves the
// physical Test output state unsafe to infer from its result code.
outcomeUnknown = true;
break;
}
outputMayBeActive = false;
// K3D output methods complete asynchronously through event callbacks.
// The current adapter does not consume those callbacks yet, so keep
// the connection alive for the same bounded observation window after
// TAKE OUT before requesting Disconnect.
if (!await ObserveAsync().ConfigureAwait(false))
{
stoppedAfter = "TakeOutObservation";
break;
}
}
disconnectAttempted = true;
var disconnect = await engine.DisconnectAsync(cancellationToken).ConfigureAwait(false);
disconnectCode = disconnect.Code.ToString();
AddStep(steps, "command", disconnect);
normalDisconnectCompleted = true;
if (!disconnect.IsSuccess)
{
stoppedAfter = disconnect.Operation.ToString();
outcomeUnknown = IsOutcomeUnknown(disconnect);
break;
}
completed = true;
}
while (false);
}
catch
{
stoppedAfter ??= connectRequestIssued ? "Internal" : "EngineCreation";
if (!connectResultReceived)
{
comActivationAttempted = connectRequestIssued ? null : false;
}
outcomeUnknown = connectRequestIssued;
}
finally
{
if (engine is not null)
{
CaptureKtapEvidence();
if (outputMayBeActive && !takeOutAttempted && !outcomeUnknown)
{
takeOutAttempted = true;
try
{
var cleanupTakeOut = await engine.TakeOutAsync(
PlayoutTakeOutScope.All,
CancellationToken.None).ConfigureAwait(false);
AddStep(steps, "cleanup", cleanupTakeOut);
if (cleanupTakeOut.IsSuccess)
{
outputMayBeActive = false;
}
else
{
outcomeUnknown = true;
}
}
catch
{
outcomeUnknown = true;
}
}
if (!normalDisconnectCompleted && !outcomeUnknown)
{
disconnectAttempted = true;
try
{
var cleanup = await engine.DisconnectAsync(CancellationToken.None)
.ConfigureAwait(false);
disconnectCode = cleanup.Code.ToString();
AddStep(steps, "cleanup", cleanup);
outcomeUnknown |= IsOutcomeUnknown(cleanup);
}
catch
{
disconnectCode = "Failed";
outcomeUnknown = true;
}
}
var safeToDispose = true;
if (outcomeUnknown)
{
quarantineAttempted = true;
try
{
await engine.QuarantineAsync().ConfigureAwait(false);
quarantineCompleted = true;
}
catch
{
// A normal Dispose may issue SDK Disconnect. If quarantine could
// not first detach the session, leaking locally is safer than an
// unrequested control call against an unknown physical output.
safeToDispose = false;
}
}
if (safeToDispose)
{
try
{
await engine.DisposeAsync().ConfigureAwait(false);
CaptureKtapEvidence();
adapterDisposed = true;
}
catch
{
outcomeUnknown = true;
}
}
}
}
return BuildReport();
async Task<bool> ObserveAsync()
{
try
{
await observationDelay(plan.ObservationDuration, cancellationToken)
.ConfigureAwait(false);
return true;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
return false;
}
catch
{
// Observation is local timing only. Its failure does not make a COM
// command ambiguous, so retain the bounded TAKE OUT cleanup path.
return false;
}
}
SafeTestCommandReport BuildReport() => new(
plan.Kind == SmokeCommandKind.TestConnect ? "test-connect" : "test-sequence",
"Test",
connectRequestIssued,
comActivationAttempted,
ToWireValue(lastKtapConnectState),
lastKtapConnectState != PlayoutKtapConnectState.NotAttempted,
lastKtapConnectState switch
{
PlayoutKtapConnectState.AcceptedUnconfirmed => true,
PlayoutKtapConnectState.NotAttempted => false,
_ => null
},
ktapHelloObserved,
lastKtapConnectState switch
{
PlayoutKtapConnectState.AcceptedUnconfirmed => true,
PlayoutKtapConnectState.NotAttempted => false,
_ => null
},
lastKtapConnectState != PlayoutKtapConnectState.NotAttempted,
null,
completed,
outcomeUnknown,
outputMayBeActive,
stoppedAfter,
steps.AsReadOnly(),
new SafeCleanupReport(
disconnectAttempted,
disconnectCode,
quarantineAttempted,
quarantineCompleted,
adapterDisposed));
void CaptureKtapEvidence()
{
if (engine is null)
{
return;
}
var status = engine.Status;
lastKtapConnectState = status.LastKtapConnectState;
ktapHelloObserved = status.KtapHelloObserved;
}
}
private static string ToWireValue(PlayoutKtapConnectState state) => state switch
{
PlayoutKtapConnectState.NotAttempted => "not-attempted",
PlayoutKtapConnectState.Attempted => "attempted",
PlayoutKtapConnectState.AcceptedUnconfirmed => "accepted-unconfirmed",
PlayoutKtapConnectState.Failed => "failed",
_ => "not-attempted"
};
private static void AddStep(
ICollection<SafeCommandStep> steps,
string phase,
PlayoutResult result) =>
steps.Add(new SafeCommandStep(
phase,
result.Operation.ToString(),
result.Code.ToString()));
private static bool IsOutcomeUnknown(PlayoutResult result) =>
result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut;
private static bool? InferComActivationAttempt(PlayoutResultCode code) => code switch
{
PlayoutResultCode.Success or PlayoutResultCode.Failed => true,
PlayoutResultCode.Rejected => false,
_ => null
};
}