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

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