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

@@ -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),

View File

@@ -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",

View File

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

View File

@@ -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.");
}
}
}
}

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

View File

@@ -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(