feat: harden isolated Tornado test workflow

This commit is contained in:
2026-07-10 11:27:40 +09:00
parent 5a8c7028dc
commit fc932b27f6
36 changed files with 3919 additions and 226 deletions

View File

@@ -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;
}