feat: harden isolated Tornado test workflow
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace MBN_STOCK_WEBVIEW.Playout.Runtime;
|
||||
@@ -9,13 +12,55 @@ internal sealed record TornadoProcessSnapshot(
|
||||
int EligibleProcessCount,
|
||||
int ProgramProcessCount)
|
||||
{
|
||||
public TornadoProcessGeneration EligibleProcessGeneration { get; init; } =
|
||||
TornadoProcessGeneration.Synthetic("eligible", EligibleProcessCount);
|
||||
|
||||
public TornadoProcessGeneration ProgramProcessGeneration { get; init; } =
|
||||
TornadoProcessGeneration.Synthetic("program", ProgramProcessCount);
|
||||
|
||||
public bool IdentityInspectionSucceeded { get; init; } = true;
|
||||
|
||||
public bool AnyTornadoProcess => TotalProcessCount > 0;
|
||||
|
||||
public bool HasSingleEligibleProcess => EligibleProcessCount == 1;
|
||||
|
||||
public bool UnsafeProgramWindowMatched => ProgramProcessCount > 0;
|
||||
|
||||
public bool HasSameProcessGeneration(TornadoProcessSnapshot other) =>
|
||||
IdentityInspectionSucceeded &&
|
||||
other.IdentityInspectionSucceeded &&
|
||||
EligibleProcessGeneration == other.EligibleProcessGeneration &&
|
||||
ProgramProcessGeneration == other.ProgramProcessGeneration;
|
||||
}
|
||||
|
||||
internal readonly record struct TornadoProcessGeneration(string OpaqueValue)
|
||||
{
|
||||
internal static TornadoProcessGeneration FromIdentities(
|
||||
string category,
|
||||
IEnumerable<TornadoProcessIdentity> identities)
|
||||
{
|
||||
var canonical = string.Join(
|
||||
";",
|
||||
identities
|
||||
.OrderBy(identity => identity.ProcessId)
|
||||
.ThenBy(identity => identity.StartTimeUtcTicks)
|
||||
.Select(identity => string.Concat(
|
||||
identity.ProcessId.ToString(CultureInfo.InvariantCulture),
|
||||
":",
|
||||
identity.StartTimeUtcTicks.ToString(CultureInfo.InvariantCulture))));
|
||||
var digest = SHA256.HashData(Encoding.UTF8.GetBytes($"{category}|{canonical}"));
|
||||
return new TornadoProcessGeneration(Convert.ToHexString(digest));
|
||||
}
|
||||
|
||||
internal static TornadoProcessGeneration Synthetic(string category, int count) =>
|
||||
FromIdentities(
|
||||
category,
|
||||
Enumerable.Range(0, Math.Max(count, 0))
|
||||
.Select(index => new TornadoProcessIdentity(index, 0)));
|
||||
}
|
||||
|
||||
internal readonly record struct TornadoProcessIdentity(int ProcessId, long StartTimeUtcTicks);
|
||||
|
||||
internal interface ITornadoProcessProbe
|
||||
{
|
||||
TornadoProcessSnapshot Capture(Regex? testWindowPattern);
|
||||
@@ -30,6 +75,9 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe
|
||||
var totalCount = 0;
|
||||
var eligibleCount = 0;
|
||||
var programCount = 0;
|
||||
var identityInspectionSucceeded = true;
|
||||
var eligibleIdentities = new List<TornadoProcessIdentity>();
|
||||
var programIdentities = new List<TornadoProcessIdentity>();
|
||||
Process[] processes;
|
||||
|
||||
try
|
||||
@@ -38,7 +86,10 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe
|
||||
}
|
||||
catch (Exception exception) when (IsProcessInspectionException(exception))
|
||||
{
|
||||
return new TornadoProcessSnapshot(0, 0, 0);
|
||||
return new TornadoProcessSnapshot(0, 0, 0)
|
||||
{
|
||||
IdentityInspectionSucceeded = false
|
||||
};
|
||||
}
|
||||
|
||||
foreach (var process in processes)
|
||||
@@ -50,8 +101,15 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe
|
||||
{
|
||||
processName = process.ProcessName;
|
||||
}
|
||||
catch (Exception exception) when (IsExitedProcessInspectionException(exception))
|
||||
{
|
||||
// The process vanished after enumeration and can no longer own an output.
|
||||
continue;
|
||||
}
|
||||
catch (Exception exception) when (IsProcessInspectionException(exception))
|
||||
{
|
||||
// An unreadable process could be Tornado/PROGRAM; never undercount it as safe.
|
||||
identityInspectionSucceeded = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -61,12 +119,6 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe
|
||||
}
|
||||
|
||||
totalCount++;
|
||||
if (testWindowPattern is null)
|
||||
{
|
||||
eligibleCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
string title;
|
||||
try
|
||||
{
|
||||
@@ -74,31 +126,55 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe
|
||||
}
|
||||
catch (Exception exception) when (IsProcessInspectionException(exception))
|
||||
{
|
||||
identityInspectionSucceeded = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsProgramTitle(title))
|
||||
var isProgram = IsProgramTitle(title);
|
||||
if (isProgram)
|
||||
{
|
||||
programCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
bool titleMatches;
|
||||
try
|
||||
var isEligible = testWindowPattern is null;
|
||||
if (testWindowPattern is not null && !isProgram)
|
||||
{
|
||||
titleMatches = testWindowPattern.IsMatch(title);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
titleMatches = false;
|
||||
try
|
||||
{
|
||||
isEligible = HasExplicitTestMarker(title) &&
|
||||
testWindowPattern.IsMatch(title);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
isEligible = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!titleMatches)
|
||||
if (isEligible)
|
||||
{
|
||||
eligibleCount++;
|
||||
}
|
||||
|
||||
if (!isEligible && !isProgram)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
eligibleCount++;
|
||||
if (!TryGetIdentity(process, out var identity))
|
||||
{
|
||||
identityInspectionSucceeded = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isEligible)
|
||||
{
|
||||
eligibleIdentities.Add(identity);
|
||||
}
|
||||
|
||||
if (isProgram)
|
||||
{
|
||||
programIdentities.Add(identity);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -106,20 +182,79 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe
|
||||
}
|
||||
}
|
||||
|
||||
return new TornadoProcessSnapshot(totalCount, eligibleCount, programCount);
|
||||
return new TornadoProcessSnapshot(totalCount, eligibleCount, programCount)
|
||||
{
|
||||
EligibleProcessGeneration = TornadoProcessGeneration.FromIdentities(
|
||||
"eligible",
|
||||
eligibleIdentities),
|
||||
ProgramProcessGeneration = TornadoProcessGeneration.FromIdentities(
|
||||
"program",
|
||||
programIdentities),
|
||||
IdentityInspectionSucceeded = identityInspectionSucceeded
|
||||
};
|
||||
}
|
||||
|
||||
internal static bool IsTornadoProcessName(string? processName) =>
|
||||
processName?.StartsWith(ProcessNamePrefix, StringComparison.OrdinalIgnoreCase) == true;
|
||||
|
||||
internal static bool IsProgramTitle(string? title) =>
|
||||
!string.IsNullOrWhiteSpace(title) &&
|
||||
(title.Contains("PGM", StringComparison.OrdinalIgnoreCase) ||
|
||||
title.Contains("PROGRAM", StringComparison.OrdinalIgnoreCase));
|
||||
string.IsNullOrWhiteSpace(title) ||
|
||||
title.Equals(ProcessNamePrefix, StringComparison.OrdinalIgnoreCase) ||
|
||||
title.Contains("PGM", StringComparison.OrdinalIgnoreCase) ||
|
||||
title.Contains("PROGRAM", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
internal static bool HasExplicitTestMarker(string? title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const string marker = "TEST";
|
||||
for (var start = 0; start <= title.Length - marker.Length; start++)
|
||||
{
|
||||
if (!title.AsSpan(start, marker.Length).Equals(
|
||||
marker.AsSpan(),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var beforeIsBoundary = start == 0 || !char.IsLetterOrDigit(title[start - 1]);
|
||||
var after = start + marker.Length;
|
||||
var afterIsBoundary = after == title.Length || !char.IsLetterOrDigit(title[after]);
|
||||
if (beforeIsBoundary && afterIsBoundary)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryGetIdentity(Process process, out TornadoProcessIdentity identity)
|
||||
{
|
||||
try
|
||||
{
|
||||
identity = new TornadoProcessIdentity(
|
||||
process.Id,
|
||||
process.StartTime.ToUniversalTime().Ticks);
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception) when (IsProcessInspectionException(exception))
|
||||
{
|
||||
identity = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsProcessInspectionException(Exception exception) =>
|
||||
exception is InvalidOperationException or
|
||||
ArgumentException or
|
||||
Win32Exception or
|
||||
NotSupportedException or
|
||||
UnauthorizedAccessException;
|
||||
|
||||
private static bool IsExitedProcessInspectionException(Exception exception) =>
|
||||
exception is InvalidOperationException or ArgumentException;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user