feat: harden isolated Tornado test workflow
This commit is contained in:
@@ -25,6 +25,16 @@ public static class PlayoutOptionsLoader
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads only the specified JSON file and deliberately ignores all environment
|
||||
/// overrides. Intended for explicit, reproducible diagnostic commands.
|
||||
/// </summary>
|
||||
public static PlayoutOptions LoadFileOnly(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
return LoadJson(path);
|
||||
}
|
||||
|
||||
internal static bool IsLiveAuthorizedForThisLaunch() =>
|
||||
string.Equals(
|
||||
Environment.GetEnvironmentVariable(LiveAuthorizationEnvironmentVariable),
|
||||
|
||||
@@ -139,10 +139,11 @@ internal sealed record ValidatedPlayoutOptions(
|
||||
"테스트 Tornado 창 제목 설정이 올바르지 않습니다.", exception);
|
||||
}
|
||||
|
||||
if (CanMatchProgramTitle(titleRegex))
|
||||
if (!pattern.Contains("TEST", StringComparison.OrdinalIgnoreCase) ||
|
||||
CanMatchProgramTitle(titleRegex))
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"테스트 창 제목 규칙은 PROGRAM 출력을 가리킬 수 없습니다.");
|
||||
"테스트 창 제목 규칙은 명시적인 TEST 표식이 필요하며 PROGRAM 또는 비식별 출력을 가리킬 수 없습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +250,9 @@ internal sealed record ValidatedPlayoutOptions(
|
||||
{
|
||||
string[] protectedTitles =
|
||||
[
|
||||
string.Empty,
|
||||
" ",
|
||||
"Tornado2",
|
||||
"PGM",
|
||||
"PROGRAM",
|
||||
"Tornado2 PGM",
|
||||
|
||||
@@ -20,6 +20,8 @@ internal interface IK3dSession : IDisposable
|
||||
void Play(int layoutIndex);
|
||||
|
||||
void TakeOut(int layoutIndex, PlayoutTakeOutScope scope);
|
||||
|
||||
void Abandon();
|
||||
}
|
||||
|
||||
internal interface IK3dSessionFactory
|
||||
@@ -49,6 +51,11 @@ internal interface ILateBoundComActivator
|
||||
object Create(Guid classId);
|
||||
}
|
||||
|
||||
internal interface ILateBoundComObjectReleaser
|
||||
{
|
||||
void Release(object value);
|
||||
}
|
||||
|
||||
internal sealed class RegisteredComActivator : ILateBoundComActivator
|
||||
{
|
||||
public object Create(Guid classId)
|
||||
@@ -60,9 +67,21 @@ internal sealed class RegisteredComActivator : ILateBoundComActivator
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RuntimeComObjectReleaser : ILateBoundComObjectReleaser
|
||||
{
|
||||
public void Release(object value)
|
||||
{
|
||||
if (Marshal.IsComObject(value))
|
||||
{
|
||||
Marshal.FinalReleaseComObject(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DynamicK3dSession : IK3dSession
|
||||
{
|
||||
private readonly ILateBoundComActivator _activator;
|
||||
private readonly ILateBoundComObjectReleaser _releaser;
|
||||
private object? _engine;
|
||||
private object? _eventHandler;
|
||||
private object? _player;
|
||||
@@ -71,8 +90,16 @@ internal sealed class DynamicK3dSession : IK3dSession
|
||||
private bool _disposed;
|
||||
|
||||
public DynamicK3dSession(ILateBoundComActivator activator)
|
||||
: this(activator, new RuntimeComObjectReleaser())
|
||||
{
|
||||
}
|
||||
|
||||
internal DynamicK3dSession(
|
||||
ILateBoundComActivator activator,
|
||||
ILateBoundComObjectReleaser releaser)
|
||||
{
|
||||
_activator = activator;
|
||||
_releaser = releaser;
|
||||
}
|
||||
|
||||
public bool IsConnected => _engine is not null && _player is not null;
|
||||
@@ -87,10 +114,12 @@ internal sealed class DynamicK3dSession : IK3dSession
|
||||
return;
|
||||
}
|
||||
|
||||
var ktapConnectAttempted = false;
|
||||
try
|
||||
{
|
||||
_eventHandler = _activator.Create(K3dComConstants.KaEventHandlerClassGuid);
|
||||
_engine = _activator.Create(K3dComConstants.KaEngineClassGuid);
|
||||
ktapConnectAttempted = true;
|
||||
var result = Invoke(
|
||||
_engine,
|
||||
"KTAPConnect",
|
||||
@@ -111,9 +140,23 @@ internal sealed class DynamicK3dSession : IK3dSession
|
||||
? InvokeRequired(_engine, "GetScenePlayerOnChannel", channel)
|
||||
: InvokeRequired(_engine, "GetScenePlayer");
|
||||
}
|
||||
catch
|
||||
catch (Exception exception)
|
||||
{
|
||||
var originalFailure = ExceptionDispatchInfo.Capture(exception);
|
||||
if (ktapConnectAttempted && _engine is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
Invoke(_engine, "Disconnect");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Preserve the initialization failure while still releasing every RCW.
|
||||
}
|
||||
}
|
||||
|
||||
ReleaseAll();
|
||||
originalFailure.Throw();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
@@ -226,6 +269,18 @@ internal sealed class DynamicK3dSession : IK3dSession
|
||||
}
|
||||
}
|
||||
|
||||
public void Abandon()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureThread();
|
||||
_disposed = true;
|
||||
ReleaseAll();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
@@ -252,7 +307,7 @@ internal sealed class DynamicK3dSession : IK3dSession
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyField(object scene, PlayoutField field)
|
||||
private void ApplyField(object scene, PlayoutField field)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(field.ObjectName))
|
||||
{
|
||||
@@ -309,14 +364,19 @@ internal sealed class DynamicK3dSession : IK3dSession
|
||||
|
||||
private void ReleaseAll()
|
||||
{
|
||||
ReleaseComObject(_scene);
|
||||
var scene = _scene;
|
||||
_scene = null;
|
||||
ReleaseComObject(_player);
|
||||
var player = _player;
|
||||
_player = null;
|
||||
ReleaseComObject(_engine);
|
||||
var engine = _engine;
|
||||
_engine = null;
|
||||
ReleaseComObject(_eventHandler);
|
||||
var eventHandler = _eventHandler;
|
||||
_eventHandler = null;
|
||||
|
||||
ReleaseComObject(scene);
|
||||
ReleaseComObject(player);
|
||||
ReleaseComObject(engine);
|
||||
ReleaseComObject(eventHandler);
|
||||
}
|
||||
|
||||
private static object InvokeRequired(object target, string method, params object?[] arguments) =>
|
||||
@@ -342,11 +402,20 @@ internal sealed class DynamicK3dSession : IK3dSession
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReleaseComObject(object? value)
|
||||
private void ReleaseComObject(object? value)
|
||||
{
|
||||
if (value is not null && Marshal.IsComObject(value))
|
||||
if (value is null)
|
||||
{
|
||||
Marshal.FinalReleaseComObject(value);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_releaser.Release(value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Cleanup is best-effort and must not hide the SDK operation that failed.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,4 +28,51 @@ public static class PlayoutEngineFactory
|
||||
new EnvironmentLiveAuthorization(),
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the same fail-closed Test-mode configuration, scene-root, file-name and
|
||||
/// allowlist validation used by the runtime before a diagnostic tool creates an engine.
|
||||
/// This method never creates a dispatcher or activates COM.
|
||||
/// </summary>
|
||||
public static void ValidateIsolatedTestCommand(
|
||||
PlayoutOptions options,
|
||||
IEnumerable<PlayoutCue> cues)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(cues);
|
||||
|
||||
if (options.Mode != PlayoutMode.Test || options.TrustedLiveOutputEnabled)
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"The isolated test command requires Test mode with live output disabled.");
|
||||
}
|
||||
|
||||
var validated = ValidatedPlayoutOptions.Create(options);
|
||||
foreach (var cue in cues)
|
||||
{
|
||||
if (cue is null)
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"The isolated test command contains an invalid scene.");
|
||||
}
|
||||
|
||||
PlayoutCue resolved;
|
||||
try
|
||||
{
|
||||
resolved = validated.ResolveCue(cue);
|
||||
}
|
||||
catch (PlayoutRequestException exception)
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"The isolated test command contains an invalid scene.",
|
||||
exception);
|
||||
}
|
||||
|
||||
if (!validated.IsTestSceneAllowed(resolved))
|
||||
{
|
||||
throw new PlayoutConfigurationException(
|
||||
"The isolated test command contains a scene that is not allowlisted.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ namespace MBN_STOCK_WEBVIEW.Playout;
|
||||
|
||||
internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
{
|
||||
private const string ProcessChangedMessage =
|
||||
"Tornado 프로세스가 변경되어 안전한 재연결이 필요합니다.";
|
||||
|
||||
private readonly ValidatedPlayoutOptions _options;
|
||||
private readonly IK3dSessionFactory _sessionFactory;
|
||||
private readonly IK3dRegistrationProbe _registrationProbe;
|
||||
@@ -22,6 +25,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
private readonly Task _monitorTask;
|
||||
|
||||
private IK3dSession? _session;
|
||||
private TornadoProcessSnapshot? _connectedProcessSnapshot;
|
||||
private PlayoutCue? _preparedCue;
|
||||
private string? _onAirSceneName;
|
||||
private TornadoProcessSnapshot _processSnapshot = new(0, 0, 0);
|
||||
@@ -30,8 +34,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
private int _reconnectAttempts;
|
||||
private long _sequence;
|
||||
private bool _connectionRequested;
|
||||
private bool _outcomeUnknown;
|
||||
private bool _disposed;
|
||||
private volatile bool _outcomeUnknown;
|
||||
private volatile bool _disposed;
|
||||
private int _disposeStarted;
|
||||
|
||||
internal TornadoPlayoutEngine(
|
||||
ValidatedPlayoutOptions options,
|
||||
@@ -110,9 +115,41 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
cancellationToken,
|
||||
token => TakeOutCoreAsync(scope, token));
|
||||
|
||||
public async ValueTask QuarantineAsync()
|
||||
{
|
||||
await _gate.WaitAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_outcomeUnknown = true;
|
||||
_connectionRequested = false;
|
||||
_connectedProcessSnapshot = null;
|
||||
_preparedCue = null;
|
||||
_onAirSceneName = null;
|
||||
_connectionState = PlayoutConnectionState.OutcomeUnknown;
|
||||
|
||||
var session = _session;
|
||||
_session = null;
|
||||
if (session is not null)
|
||||
{
|
||||
await AbandonSessionOnStaAsync(session).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
PublishStatus("송출 결과를 확인할 수 없어 연결을 격리했습니다. 출력 상태를 직접 확인하세요.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
if (Interlocked.CompareExchange(ref _disposeStarted, 1, 0) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -133,7 +170,15 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
{
|
||||
if (_session is not null && _dispatcher is not null && !_dispatcher.IsQuarantined)
|
||||
{
|
||||
await ReleaseSessionAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
if (_onAirSceneName is not null || _outcomeUnknown)
|
||||
{
|
||||
_outcomeUnknown = true;
|
||||
await AbandonCurrentSessionAsync().ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ReleaseSessionAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
_preparedCue = null;
|
||||
@@ -152,7 +197,6 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
}
|
||||
|
||||
_monitorCancellation.Dispose();
|
||||
_gate.Dispose();
|
||||
}
|
||||
|
||||
internal async Task PollProcessOnceAsync(CancellationToken cancellationToken = default)
|
||||
@@ -193,6 +237,13 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
if (!eligible)
|
||||
{
|
||||
_reconnectAttempts = 0;
|
||||
if (_onAirSceneName is not null)
|
||||
{
|
||||
await AbandonCurrentSessionAsync().ConfigureAwait(false);
|
||||
MarkOutcomeUnknown(PlayoutOperation.Disconnect);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_session is not null && !_outcomeUnknown)
|
||||
{
|
||||
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
|
||||
@@ -212,6 +263,30 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
return;
|
||||
}
|
||||
|
||||
if (HasConnectedProcessGenerationChanged(_processSnapshot))
|
||||
{
|
||||
_reconnectAttempts = 0;
|
||||
if (_onAirSceneName is not null)
|
||||
{
|
||||
await AbandonCurrentSessionAsync().ConfigureAwait(false);
|
||||
MarkOutcomeUnknown(PlayoutOperation.Disconnect);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
MarkOutcomeUnknown(PlayoutOperation.Disconnect);
|
||||
return;
|
||||
}
|
||||
|
||||
_preparedCue = null;
|
||||
_onAirSceneName = null;
|
||||
ScheduleReconnect();
|
||||
_connectionState = _connectionRequested
|
||||
? PlayoutConnectionState.Reconnecting
|
||||
: PlayoutConnectionState.Disconnected;
|
||||
}
|
||||
|
||||
if (_session is not null || !_connectionRequested || !_options.ReconnectEnabled ||
|
||||
_outcomeUnknown || _reconnectAttempts >= _options.MaximumReconnectAttempts ||
|
||||
_timeProvider.GetUtcNow() < _nextReconnectAt)
|
||||
@@ -245,14 +320,41 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return Result(operation, PlayoutResultCode.Cancelled, "송출 요청이 취소되었습니다.");
|
||||
// A caller can be cancelled while another serialized command is still
|
||||
// deciding whether its COM outcome is known. Wait for that decision so an
|
||||
// existing OutcomeUnknown latch can never be downgraded to retryable Cancelled.
|
||||
await _gate.WaitAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return _outcomeUnknown
|
||||
? ExistingOutcomeUnknown(operation)
|
||||
: Result(operation, PlayoutResultCode.Unavailable, "송출 어댑터가 종료되었습니다.");
|
||||
}
|
||||
|
||||
return _outcomeUnknown
|
||||
? ExistingOutcomeUnknown(operation)
|
||||
: Result(operation, PlayoutResultCode.Cancelled, "송출 요청이 취소되었습니다.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return Result(operation, PlayoutResultCode.Unavailable, "송출 어댑터가 종료되었습니다.");
|
||||
return _outcomeUnknown
|
||||
? ExistingOutcomeUnknown(operation)
|
||||
: Result(operation, PlayoutResultCode.Unavailable, "송출 어댑터가 종료되었습니다.");
|
||||
}
|
||||
|
||||
if (_outcomeUnknown)
|
||||
{
|
||||
return ExistingOutcomeUnknown(operation);
|
||||
}
|
||||
|
||||
return await callback(cancellationToken).ConfigureAwait(false);
|
||||
@@ -308,16 +410,26 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
return MarkOutcomeUnknown(PlayoutOperation.Connect);
|
||||
}
|
||||
|
||||
if (_session is not null)
|
||||
{
|
||||
_connectionState = PlayoutConnectionState.Connected;
|
||||
PublishStatus("Tornado 테스트 송출에 연결되었습니다.");
|
||||
return Result(PlayoutOperation.Connect, PlayoutResultCode.Success, "송출 연결이 준비되었습니다.");
|
||||
}
|
||||
|
||||
_processSnapshot = CaptureProcessSnapshot();
|
||||
if (!IsProcessEligible(_processSnapshot))
|
||||
{
|
||||
if (_onAirSceneName is not null)
|
||||
{
|
||||
await AbandonCurrentSessionAsync().ConfigureAwait(false);
|
||||
return MarkOutcomeUnknown(PlayoutOperation.Connect);
|
||||
}
|
||||
|
||||
if (_session is not null)
|
||||
{
|
||||
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return MarkOutcomeUnknown(PlayoutOperation.Disconnect);
|
||||
}
|
||||
|
||||
_preparedCue = null;
|
||||
_onAirSceneName = null;
|
||||
}
|
||||
|
||||
_connectionState = reconnecting
|
||||
? PlayoutConnectionState.Reconnecting
|
||||
: PlayoutConnectionState.Disconnected;
|
||||
@@ -328,6 +440,32 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
ProcessUnavailableMessage(_processSnapshot));
|
||||
}
|
||||
|
||||
if (_session is not null && !HasConnectedProcessGenerationChanged(_processSnapshot))
|
||||
{
|
||||
_connectionState = PlayoutConnectionState.Connected;
|
||||
PublishStatus("Tornado 테스트 송출에 연결되었습니다.");
|
||||
return Result(PlayoutOperation.Connect, PlayoutResultCode.Success, "송출 연결이 준비되었습니다.");
|
||||
}
|
||||
|
||||
if (_session is not null)
|
||||
{
|
||||
if (_onAirSceneName is not null)
|
||||
{
|
||||
await AbandonCurrentSessionAsync().ConfigureAwait(false);
|
||||
return MarkOutcomeUnknown(PlayoutOperation.Connect);
|
||||
}
|
||||
|
||||
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return MarkOutcomeUnknown(PlayoutOperation.Disconnect);
|
||||
}
|
||||
|
||||
_preparedCue = null;
|
||||
_onAirSceneName = null;
|
||||
}
|
||||
|
||||
var processSnapshotBeforeConnect = _processSnapshot;
|
||||
|
||||
K3dRegistrationReport registration;
|
||||
try
|
||||
{
|
||||
@@ -379,6 +517,30 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
static lateSession => lateSession.Dispose(),
|
||||
() => pendingSession?.Dispose()).ConfigureAwait(false);
|
||||
|
||||
_processSnapshot = CaptureProcessSnapshot();
|
||||
if (!IsProcessEligible(_processSnapshot) ||
|
||||
!processSnapshotBeforeConnect.HasSameProcessGeneration(_processSnapshot))
|
||||
{
|
||||
if (!await ReleaseSessionAsync(CancellationToken.None).ConfigureAwait(false))
|
||||
{
|
||||
return MarkOutcomeUnknown(PlayoutOperation.Disconnect);
|
||||
}
|
||||
|
||||
ScheduleReconnect();
|
||||
_connectionState = reconnecting
|
||||
? PlayoutConnectionState.Reconnecting
|
||||
: PlayoutConnectionState.Disconnected;
|
||||
var processMessage = IsProcessEligible(_processSnapshot)
|
||||
? ProcessChangedMessage
|
||||
: ProcessUnavailableMessage(_processSnapshot);
|
||||
PublishStatus(processMessage);
|
||||
return Result(
|
||||
PlayoutOperation.Connect,
|
||||
PlayoutResultCode.Unavailable,
|
||||
processMessage);
|
||||
}
|
||||
|
||||
_connectedProcessSnapshot = _processSnapshot;
|
||||
_connectionState = PlayoutConnectionState.Connected;
|
||||
_reconnectAttempts = 0;
|
||||
_preparedCue = null;
|
||||
@@ -435,6 +597,12 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
return MarkOutcomeUnknown(PlayoutOperation.Disconnect);
|
||||
}
|
||||
|
||||
if (_onAirSceneName is not null)
|
||||
{
|
||||
await AbandonCurrentSessionAsync().ConfigureAwait(false);
|
||||
return MarkOutcomeUnknown(PlayoutOperation.Disconnect);
|
||||
}
|
||||
|
||||
_connectionState = PlayoutConnectionState.Disconnecting;
|
||||
PublishStatus("Tornado 송출 연결을 해제하는 중입니다.");
|
||||
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
|
||||
@@ -569,6 +737,14 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
|
||||
private async Task<PlayoutResult> NextCoreAsync(PlayoutCue cue, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_onAirSceneName is null)
|
||||
{
|
||||
return Result(
|
||||
PlayoutOperation.Next,
|
||||
PlayoutResultCode.Rejected,
|
||||
"NEXT는 송출 중인 장면이 있을 때만 사용할 수 있습니다.");
|
||||
}
|
||||
|
||||
if (_options.Mode == PlayoutMode.Disabled)
|
||||
{
|
||||
return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, "송출 기능이 비활성화되어 있습니다.");
|
||||
@@ -689,6 +865,12 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
_processSnapshot = CaptureProcessSnapshot();
|
||||
if (!IsProcessEligible(_processSnapshot))
|
||||
{
|
||||
if (_onAirSceneName is not null)
|
||||
{
|
||||
await AbandonCurrentSessionAsync().ConfigureAwait(false);
|
||||
return MarkOutcomeUnknown(operation);
|
||||
}
|
||||
|
||||
if (_session is not null)
|
||||
{
|
||||
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
|
||||
@@ -704,6 +886,32 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
return Result(operation, PlayoutResultCode.Unavailable, ProcessUnavailableMessage(_processSnapshot));
|
||||
}
|
||||
|
||||
if (HasConnectedProcessGenerationChanged(_processSnapshot))
|
||||
{
|
||||
if (_onAirSceneName is not null)
|
||||
{
|
||||
await AbandonCurrentSessionAsync().ConfigureAwait(false);
|
||||
return MarkOutcomeUnknown(operation);
|
||||
}
|
||||
|
||||
if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return MarkOutcomeUnknown(operation);
|
||||
}
|
||||
|
||||
_preparedCue = null;
|
||||
_onAirSceneName = null;
|
||||
ScheduleReconnect();
|
||||
_connectionState = _connectionRequested
|
||||
? PlayoutConnectionState.Reconnecting
|
||||
: PlayoutConnectionState.Disconnected;
|
||||
PublishStatus(ProcessChangedMessage);
|
||||
return Result(
|
||||
operation,
|
||||
PlayoutResultCode.Unavailable,
|
||||
ProcessChangedMessage);
|
||||
}
|
||||
|
||||
if (_session is null)
|
||||
{
|
||||
return Result(operation, PlayoutResultCode.Unavailable, "Tornado 송출 연결이 필요합니다.");
|
||||
@@ -719,11 +927,26 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var session = _session!;
|
||||
var callbackStarted = 0;
|
||||
var preserveActiveOutput = partialOutcomeUnknown || _onAirSceneName is not null;
|
||||
void CleanupAbandonedOperation()
|
||||
{
|
||||
if (preserveActiveOutput)
|
||||
{
|
||||
session.Abandon();
|
||||
}
|
||||
else
|
||||
{
|
||||
session.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _dispatcher!.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
Volatile.Write(ref callbackStarted, 1);
|
||||
try
|
||||
{
|
||||
callback(session);
|
||||
@@ -732,7 +955,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
{
|
||||
if (_dispatcher.IsQuarantined)
|
||||
{
|
||||
session.Dispose();
|
||||
CleanupAbandonedOperation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -740,12 +963,20 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
},
|
||||
_options.OperationTimeout,
|
||||
cancellationToken,
|
||||
_ => session.Dispose(),
|
||||
() => session.Dispose()).ConfigureAwait(false);
|
||||
_ => CleanupAbandonedOperation(),
|
||||
CleanupAbandonedOperation).ConfigureAwait(false);
|
||||
return Result(operation, PlayoutResultCode.Success, SuccessMessage(operation));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (preserveActiveOutput && Volatile.Read(ref callbackStarted) != 0)
|
||||
{
|
||||
_session = null;
|
||||
_connectedProcessSnapshot = null;
|
||||
await AbandonSessionOnStaAsync(session).ConfigureAwait(false);
|
||||
return MarkOutcomeUnknown(operation);
|
||||
}
|
||||
|
||||
return Result(operation, PlayoutResultCode.Cancelled, "송출 요청이 취소되었습니다.");
|
||||
}
|
||||
catch (StaOperationTimedOutException)
|
||||
@@ -758,14 +989,20 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_session = session;
|
||||
if (partialOutcomeUnknown)
|
||||
if (preserveActiveOutput)
|
||||
{
|
||||
_session = null;
|
||||
_connectedProcessSnapshot = null;
|
||||
await AbandonSessionOnStaAsync(session).ConfigureAwait(false);
|
||||
return MarkOutcomeUnknown(operation);
|
||||
}
|
||||
|
||||
_session = session;
|
||||
if (!await RecycleFailedSessionAsync().ConfigureAwait(false))
|
||||
{
|
||||
await ReleaseSessionAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
return MarkOutcomeUnknown(operation);
|
||||
}
|
||||
|
||||
await RecycleFailedSessionAsync().ConfigureAwait(false);
|
||||
return Result(
|
||||
operation,
|
||||
PlayoutResultCode.Failed,
|
||||
@@ -773,15 +1010,14 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecycleFailedSessionAsync()
|
||||
private async Task<bool> RecycleFailedSessionAsync()
|
||||
{
|
||||
_preparedCue = null;
|
||||
_onAirSceneName = null;
|
||||
var released = await ReleaseSessionAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
if (!released)
|
||||
{
|
||||
MarkOutcomeUnknown(PlayoutOperation.Disconnect);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
_connectionState = _connectionRequested
|
||||
@@ -789,12 +1025,20 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
: PlayoutConnectionState.Faulted;
|
||||
ScheduleReconnect();
|
||||
PublishStatus("Tornado 송출 세션을 다시 연결해야 합니다.");
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> ReleaseSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_onAirSceneName is not null)
|
||||
{
|
||||
await AbandonCurrentSessionAsync().ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
var session = _session;
|
||||
_session = null;
|
||||
_connectedProcessSnapshot = null;
|
||||
if (session is null)
|
||||
{
|
||||
return true;
|
||||
@@ -833,6 +1077,45 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> AbandonSessionOnStaAsync(IK3dSession session)
|
||||
{
|
||||
if (_dispatcher is null || _dispatcher.IsQuarantined)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _dispatcher.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
session.Abandon();
|
||||
return true;
|
||||
},
|
||||
_options.DisconnectTimeout,
|
||||
CancellationToken.None,
|
||||
_ => session.Abandon(),
|
||||
() => session.Abandon()).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Never fall back to Disconnect when the physical output state is unknown.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AbandonCurrentSessionAsync()
|
||||
{
|
||||
var session = _session;
|
||||
_session = null;
|
||||
_connectedProcessSnapshot = null;
|
||||
if (session is not null)
|
||||
{
|
||||
await AbandonSessionOnStaAsync(session).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private Task<bool> CleanupSessionOnStaAsync(
|
||||
IK3dSession session,
|
||||
CancellationToken cancellationToken) =>
|
||||
@@ -895,20 +1178,29 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return new TornadoProcessSnapshot(0, 0, 0);
|
||||
return new TornadoProcessSnapshot(0, 0, 0)
|
||||
{
|
||||
IdentityInspectionSucceeded = false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsProcessEligible(TornadoProcessSnapshot snapshot) => _options.Mode switch
|
||||
{
|
||||
PlayoutMode.Test =>
|
||||
snapshot.IdentityInspectionSucceeded &&
|
||||
snapshot.TotalProcessCount == 1 &&
|
||||
snapshot.EligibleProcessCount == 1 &&
|
||||
snapshot.ProgramProcessCount == 0,
|
||||
PlayoutMode.Live => snapshot.AnyTornadoProcess,
|
||||
PlayoutMode.Live => snapshot.IdentityInspectionSucceeded && snapshot.AnyTornadoProcess,
|
||||
_ => false
|
||||
};
|
||||
|
||||
private bool HasConnectedProcessGenerationChanged(TornadoProcessSnapshot snapshot) =>
|
||||
_session is not null &&
|
||||
(_connectedProcessSnapshot is null ||
|
||||
!_connectedProcessSnapshot.HasSameProcessGeneration(snapshot));
|
||||
|
||||
private bool CanTakeIn() => _options.Mode switch
|
||||
{
|
||||
PlayoutMode.Test => true,
|
||||
@@ -925,15 +1217,19 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
_outcomeUnknown = true;
|
||||
_connectionRequested = false;
|
||||
_session = null;
|
||||
_connectedProcessSnapshot = null;
|
||||
_preparedCue = null;
|
||||
_onAirSceneName = null;
|
||||
_connectionState = PlayoutConnectionState.OutcomeUnknown;
|
||||
PublishStatus("송출 결과를 확인할 수 없습니다. 앱을 다시 시작하고 출력 상태를 확인하세요.");
|
||||
return Result(
|
||||
return ExistingOutcomeUnknown(operation);
|
||||
}
|
||||
|
||||
private PlayoutResult ExistingOutcomeUnknown(PlayoutOperation operation) =>
|
||||
Result(
|
||||
operation,
|
||||
PlayoutResultCode.OutcomeUnknown,
|
||||
"송출 결과를 확인할 수 없습니다. 앱을 다시 시작하고 출력 상태를 확인하세요.");
|
||||
}
|
||||
|
||||
private void ScheduleReconnect() =>
|
||||
_nextReconnectAt = _timeProvider.GetUtcNow() + _options.ReconnectDelay;
|
||||
@@ -987,7 +1283,10 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine
|
||||
_onAirSceneName,
|
||||
message,
|
||||
_timeProvider.GetUtcNow(),
|
||||
Interlocked.Increment(ref _sequence));
|
||||
Interlocked.Increment(ref _sequence))
|
||||
{
|
||||
OperationTimeoutMilliseconds = (int)_options.OperationTimeout.TotalMilliseconds
|
||||
};
|
||||
}
|
||||
|
||||
private PlayoutResult Result(
|
||||
|
||||
Reference in New Issue
Block a user