using MBN_STOCK_WEBVIEW.Core.Playout; using MBN_STOCK_WEBVIEW.Playout.Configuration; using MBN_STOCK_WEBVIEW.Playout.Interop; using MBN_STOCK_WEBVIEW.Playout.Registration; using MBN_STOCK_WEBVIEW.Playout.Runtime; using MBN_STOCK_WEBVIEW.Playout.Safety; 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; private readonly ITornadoProcessProbe _processProbe; private readonly IStaDispatcher? _dispatcher; private readonly ILiveAuthorization _liveAuthorization; private readonly TimeProvider _timeProvider; private readonly SemaphoreSlim _gate = new(1, 1); private readonly CancellationTokenSource _monitorCancellation = new(); private readonly object _statusSync = new(); private readonly Task _monitorTask; private IK3dSession? _session; private TornadoProcessSnapshot? _connectedProcessSnapshot; private PlayoutCue? _preparedCue; private string? _onAirSceneName; private TornadoProcessSnapshot _processSnapshot = new(0, 0, 0); private PlayoutConnectionState _connectionState; private DateTimeOffset _nextReconnectAt; private int _reconnectAttempts; private long _sequence; private int _lastKtapConnectState; private bool _connectionRequested; private volatile bool _outcomeUnknown; private volatile bool _disposed; private int _disposeStarted; internal TornadoPlayoutEngine( ValidatedPlayoutOptions options, IK3dSessionFactory sessionFactory, IK3dRegistrationProbe registrationProbe, ITornadoProcessProbe processProbe, IStaDispatcher? dispatcher, ILiveAuthorization liveAuthorization, TimeProvider timeProvider, bool startMonitor = true) { _options = options; _sessionFactory = sessionFactory; _registrationProbe = registrationProbe; _processProbe = processProbe; _dispatcher = dispatcher; _liveAuthorization = liveAuthorization; _timeProvider = timeProvider; _connectionState = options.Mode switch { PlayoutMode.Disabled => PlayoutConnectionState.Disabled, PlayoutMode.DryRun => PlayoutConnectionState.DryRunReady, _ => PlayoutConnectionState.Disconnected }; if (startMonitor && options.Mode != PlayoutMode.Disabled) { _processSnapshot = CaptureProcessSnapshot(); } Status = BuildStatus(InitialMessage(options.Mode)); _monitorTask = startMonitor && options.Mode != PlayoutMode.Disabled ? Task.Run(MonitorLoopAsync) : Task.CompletedTask; } public PlayoutStatus Status { get; private set; } public event EventHandler? StatusChanged; public Task ConnectAsync(CancellationToken cancellationToken = default) => SerializedAsync(PlayoutOperation.Connect, cancellationToken, ConnectCoreAsync); public Task DisconnectAsync(CancellationToken cancellationToken = default) => SerializedAsync(PlayoutOperation.Disconnect, cancellationToken, DisconnectCoreAsync); public Task PrepareAsync( PlayoutCue cue, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(cue); return SerializedAsync( PlayoutOperation.Prepare, cancellationToken, token => PrepareCoreAsync(cue, token)); } public Task TakeInAsync(CancellationToken cancellationToken = default) => SerializedAsync(PlayoutOperation.TakeIn, cancellationToken, TakeInCoreAsync); public Task NextAsync( PlayoutCue cue, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(cue); return SerializedAsync( PlayoutOperation.Next, cancellationToken, token => NextCoreAsync(cue, token)); } public Task TakeOutAsync( PlayoutTakeOutScope scope, CancellationToken cancellationToken = default) => SerializedAsync( PlayoutOperation.TakeOut, 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 (Interlocked.CompareExchange(ref _disposeStarted, 1, 0) != 0) { return; } _disposed = true; _connectionRequested = false; _monitorCancellation.Cancel(); try { await _monitorTask.ConfigureAwait(false); } catch (OperationCanceledException) { } await _gate.WaitAsync().ConfigureAwait(false); try { if (_session is not null && _dispatcher is not null && !_dispatcher.IsQuarantined) { if (_onAirSceneName is not null || _outcomeUnknown) { _outcomeUnknown = true; await AbandonCurrentSessionAsync().ConfigureAwait(false); } else { await ReleaseSessionAsync(CancellationToken.None).ConfigureAwait(false); } } _preparedCue = null; _onAirSceneName = null; _connectionState = PlayoutConnectionState.Disposed; PublishStatus("송출 어댑터가 종료되었습니다."); } finally { _gate.Release(); } if (_dispatcher is not null) { await _dispatcher.DisposeAsync().ConfigureAwait(false); } _monitorCancellation.Dispose(); } internal async Task PollProcessOnceAsync(CancellationToken cancellationToken = default) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { if (_disposed) { return; } var previousSnapshot = _processSnapshot; _processSnapshot = CaptureProcessSnapshot(); if (_options.Mode == PlayoutMode.DryRun) { if (_processSnapshot != previousSnapshot) { PublishStatus(DryRunMonitoringMessage); } return; } if (_options.Mode is not (PlayoutMode.Test or PlayoutMode.Live)) { return; } if (_outcomeUnknown) { _connectionState = PlayoutConnectionState.OutcomeUnknown; PublishStatus("송출 결과를 확인할 수 없습니다. 앱을 다시 시작하고 출력 상태를 확인하세요."); return; } var eligible = IsProcessEligible(_processSnapshot); 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)) { MarkOutcomeUnknown(PlayoutOperation.Disconnect); return; } } _preparedCue = null; _onAirSceneName = null; ScheduleReconnect(); _connectionState = _connectionRequested ? PlayoutConnectionState.Reconnecting : PlayoutConnectionState.Disconnected; PublishStatus(ProcessUnavailableMessage(_processSnapshot)); 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) { PublishStatus(CurrentMessage()); return; } _reconnectAttempts++; var result = await ConnectActualAsync(reconnecting: true, cancellationToken) .ConfigureAwait(false); if (!result.IsSuccess) { ScheduleReconnect(); } } finally { _gate.Release(); } } private async Task SerializedAsync( PlayoutOperation operation, CancellationToken cancellationToken, Func> callback) { try { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) { // 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 _outcomeUnknown ? ExistingOutcomeUnknown(operation) : Result(operation, PlayoutResultCode.Unavailable, "송출 어댑터가 종료되었습니다."); } if (_outcomeUnknown) { return ExistingOutcomeUnknown(operation); } return await callback(cancellationToken).ConfigureAwait(false); } finally { _gate.Release(); } } private Task ConnectCoreAsync(CancellationToken cancellationToken) { if (_options.Mode == PlayoutMode.Disabled) { return Task.FromResult(Result( PlayoutOperation.Connect, PlayoutResultCode.Rejected, "송출 기능이 비활성화되어 있습니다.")); } if (_options.Mode == PlayoutMode.DryRun) { _connectionState = PlayoutConnectionState.DryRunReady; var message = DryRunOperationMessage("연결 요청을 확인했습니다."); PublishStatus(message); return Task.FromResult(Result( PlayoutOperation.Connect, PlayoutResultCode.Success, message)); } _connectionRequested = true; _reconnectAttempts = 0; if (_options.Mode == PlayoutMode.Live && !IsLiveAuthorized()) { _connectionRequested = false; _connectionState = PlayoutConnectionState.Disconnected; PublishStatus("라이브 송출 승인이 필요합니다."); return Task.FromResult(Result( PlayoutOperation.Connect, PlayoutResultCode.Rejected, "라이브 송출 승인이 필요합니다.")); } return ConnectActualAsync(reconnecting: false, cancellationToken); } private async Task ConnectActualAsync( bool reconnecting, CancellationToken cancellationToken) { if (_outcomeUnknown || _dispatcher is null || _dispatcher.IsQuarantined) { return MarkOutcomeUnknown(PlayoutOperation.Connect); } _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; PublishStatus(ProcessUnavailableMessage(_processSnapshot)); return Result( PlayoutOperation.Connect, PlayoutResultCode.Unavailable, 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 { registration = _registrationProbe.Probe(); } catch (Exception) { _connectionState = PlayoutConnectionState.Faulted; PublishStatus("K3D 송출 구성 요소를 확인할 수 없습니다."); return Result( PlayoutOperation.Connect, PlayoutResultCode.Unavailable, "K3D 송출 구성 요소를 확인할 수 없습니다."); } if (!registration.IsReady) { _connectionState = PlayoutConnectionState.Faulted; PublishStatus(registration.Message); return Result(PlayoutOperation.Connect, PlayoutResultCode.Unavailable, registration.Message); } _connectionState = reconnecting ? PlayoutConnectionState.Reconnecting : PlayoutConnectionState.Connecting; PublishStatus(reconnecting ? "Tornado 테스트 송출에 다시 연결하는 중입니다." : "Tornado 송출에 연결하는 중입니다."); IK3dSession? pendingSession = null; try { _session = await _dispatcher.InvokeAsync( () => { var session = _sessionFactory.Create(); Volatile.Write(ref pendingSession, session); try { session.Connect(_options); return session; } catch { session.Dispose(); throw; } }, _options.ConnectTimeout, cancellationToken, static lateSession => lateSession.Dispose(), () => Volatile.Read(ref pendingSession)?.Dispose()).ConfigureAwait(false); CaptureKtapEvidence(_session); _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; _onAirSceneName = null; PublishStatus("KTAPConnect가 수락되었습니다. OnHello 및 Network Monitoring [R]/[S]는 아직 확인되지 않았습니다."); return Result( PlayoutOperation.Connect, PlayoutResultCode.Success, "KTAPConnect 요청이 수락되었습니다. 서버 콜백은 운영자 확인이 필요합니다."); } catch (OperationCanceledException) { PreventLateKtapDispatchAndCapture(Volatile.Read(ref pendingSession)); _connectionState = PlayoutConnectionState.Disconnected; PublishStatus("송출 연결 요청이 취소되었습니다."); return Result(PlayoutOperation.Connect, PlayoutResultCode.Cancelled, "송출 요청이 취소되었습니다."); } catch (StaOperationTimedOutException) { PreventLateKtapDispatchAndCapture(Volatile.Read(ref pendingSession)); return MarkOutcomeUnknown(PlayoutOperation.Connect); } catch (StaDispatcherQuarantinedException) { PreventLateKtapDispatchAndCapture(Volatile.Read(ref pendingSession)); return MarkOutcomeUnknown(PlayoutOperation.Connect); } catch (Exception) { CaptureKtapEvidence(Volatile.Read(ref pendingSession)); _session = null; ScheduleReconnect(); _connectionState = reconnecting ? PlayoutConnectionState.Reconnecting : PlayoutConnectionState.Faulted; PublishStatus("Tornado 송출에 연결할 수 없습니다."); return Result(PlayoutOperation.Connect, PlayoutResultCode.Failed, "Tornado 송출에 연결할 수 없습니다."); } } private async Task DisconnectCoreAsync(CancellationToken cancellationToken) { _connectionRequested = false; _reconnectAttempts = 0; if (_options.Mode == PlayoutMode.Disabled) { return Result(PlayoutOperation.Disconnect, PlayoutResultCode.Rejected, "송출 기능이 비활성화되어 있습니다."); } if (_options.Mode == PlayoutMode.DryRun) { _preparedCue = null; _onAirSceneName = null; _connectionState = PlayoutConnectionState.DryRunReady; var message = DryRunOperationMessage("연결 해제를 확인했습니다."); PublishStatus(message); return Result(PlayoutOperation.Disconnect, PlayoutResultCode.Success, message); } if (_outcomeUnknown || _dispatcher?.IsQuarantined == true) { 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)) { return MarkOutcomeUnknown(PlayoutOperation.Disconnect); } _preparedCue = null; _onAirSceneName = null; _connectionState = PlayoutConnectionState.Disconnected; PublishStatus("Tornado 송출 연결이 해제되었습니다."); return Result(PlayoutOperation.Disconnect, PlayoutResultCode.Success, "송출 연결이 해제되었습니다."); } private Task PrepareCoreAsync(PlayoutCue cue, CancellationToken cancellationToken) { if (_options.Mode is PlayoutMode.Disabled) { return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, "송출 기능이 비활성화되어 있습니다.")); } if (_options.Mode == PlayoutMode.DryRun) { _preparedCue = cue with { SceneName = SafeSceneName(cue) }; var message = DryRunOperationMessage("장면 준비를 확인했습니다."); PublishStatus(message); return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Success, message)); } if (_options.Mode == PlayoutMode.Live && !IsLiveAuthorized()) { return Task.FromResult(Result( PlayoutOperation.Prepare, PlayoutResultCode.Rejected, "라이브 송출 승인이 필요합니다.")); } PlayoutCue resolved; try { resolved = _options.ResolveCue(cue); } catch (PlayoutRequestException exception) { return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, exception.Message)); } if (_options.Mode == PlayoutMode.Test && !_options.IsTestSceneAllowed(resolved)) { return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Rejected, "테스트에 허용되지 않은 장면입니다.")); } return PrepareActualAsync(resolved, cancellationToken); } private async Task PrepareActualAsync( PlayoutCue cue, CancellationToken cancellationToken) { var availability = await EnsureCommandAvailableAsync(PlayoutOperation.Prepare, cancellationToken) .ConfigureAwait(false); if (availability is not null) { return availability; } var result = await RunSessionOperationAsync( PlayoutOperation.Prepare, session => session.Prepare(cue, _options.LayoutIndex), partialOutcomeUnknown: false, cancellationToken).ConfigureAwait(false); if (result.IsSuccess) { _preparedCue = cue; PublishStatus("장면이 준비되었습니다."); } return result; } private async Task TakeInCoreAsync(CancellationToken cancellationToken) { if (_options.Mode == PlayoutMode.Disabled) { return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "송출 기능이 비활성화되어 있습니다."); } if (_preparedCue is null) { return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "먼저 장면을 준비해야 합니다."); } if (_options.Mode == PlayoutMode.DryRun) { _onAirSceneName = SafeSceneName(_preparedCue); _preparedCue = null; var message = DryRunOperationMessage("TAKE IN을 확인했습니다."); PublishStatus(message); return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Success, message); } if (!CanTakeIn()) { return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "라이브 송출 승인이 필요합니다."); } if (_options.Mode == PlayoutMode.Test && !_options.IsTestSceneAllowed(_preparedCue)) { return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Rejected, "테스트에 허용되지 않은 장면입니다."); } var availability = await EnsureCommandAvailableAsync(PlayoutOperation.TakeIn, cancellationToken) .ConfigureAwait(false); if (availability is not null) { return availability; } var prepared = _preparedCue; var result = await RunSessionOperationAsync( PlayoutOperation.TakeIn, session => session.Play(_options.LayoutIndex), partialOutcomeUnknown: true, cancellationToken).ConfigureAwait(false); if (result.IsSuccess) { _onAirSceneName = SafeSceneName(prepared); _preparedCue = null; PublishStatus("장면이 송출되었습니다."); } return result; } private async Task 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, "송출 기능이 비활성화되어 있습니다."); } if (_options.Mode == PlayoutMode.DryRun) { _onAirSceneName = SafeSceneName(cue); _preparedCue = null; var message = DryRunOperationMessage("NEXT를 확인했습니다."); PublishStatus(message); return Result(PlayoutOperation.Next, PlayoutResultCode.Success, message); } if (!CanTakeIn()) { return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, "라이브 송출 승인이 필요합니다."); } PlayoutCue resolved; try { resolved = _options.ResolveCue(cue); } catch (PlayoutRequestException exception) { return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, exception.Message); } if (_options.Mode == PlayoutMode.Test && !_options.IsTestSceneAllowed(resolved)) { return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, "테스트에 허용되지 않은 장면입니다."); } var availability = await EnsureCommandAvailableAsync(PlayoutOperation.Next, cancellationToken) .ConfigureAwait(false); if (availability is not null) { return availability; } var result = await RunSessionOperationAsync( PlayoutOperation.Next, session => { session.Prepare(resolved, _options.LayoutIndex); session.Play(_options.LayoutIndex); }, partialOutcomeUnknown: true, cancellationToken).ConfigureAwait(false); if (result.IsSuccess) { _onAirSceneName = SafeSceneName(resolved); _preparedCue = null; PublishStatus("다음 장면이 송출되었습니다."); } return result; } private async Task TakeOutCoreAsync( PlayoutTakeOutScope scope, CancellationToken cancellationToken) { if (!Enum.IsDefined(scope)) { return Result(PlayoutOperation.TakeOut, PlayoutResultCode.Rejected, "TAKE OUT 범위가 올바르지 않습니다."); } if (_options.Mode == PlayoutMode.Disabled) { return Result(PlayoutOperation.TakeOut, PlayoutResultCode.Rejected, "송출 기능이 비활성화되어 있습니다."); } if (_options.Mode == PlayoutMode.DryRun) { _preparedCue = null; _onAirSceneName = null; var message = DryRunOperationMessage("TAKE OUT을 확인했습니다."); PublishStatus(message); return Result(PlayoutOperation.TakeOut, PlayoutResultCode.Success, message); } if (_options.Mode == PlayoutMode.Live && !IsLiveAuthorized()) { return Result(PlayoutOperation.TakeOut, PlayoutResultCode.Rejected, "라이브 송출 승인이 필요합니다."); } var availability = await EnsureCommandAvailableAsync(PlayoutOperation.TakeOut, cancellationToken) .ConfigureAwait(false); if (availability is not null) { return availability; } var result = await RunSessionOperationAsync( PlayoutOperation.TakeOut, session => session.TakeOut(_options.LayoutIndex, scope), partialOutcomeUnknown: true, cancellationToken).ConfigureAwait(false); if (result.IsSuccess) { _preparedCue = null; _onAirSceneName = null; PublishStatus("장면 송출이 종료되었습니다."); } return result; } private async Task EnsureCommandAvailableAsync( PlayoutOperation operation, CancellationToken cancellationToken) { if (_outcomeUnknown || _dispatcher?.IsQuarantined == true) { return MarkOutcomeUnknown(operation); } _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)) { return MarkOutcomeUnknown(operation); } } _preparedCue = null; _onAirSceneName = null; _connectionState = PlayoutConnectionState.Reconnecting; PublishStatus(ProcessUnavailableMessage(_processSnapshot)); 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 송출 연결이 필요합니다."); } return null; } private async Task RunSessionOperationAsync( PlayoutOperation operation, Action callback, bool partialOutcomeUnknown, 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); } finally { if (_dispatcher.IsQuarantined) { CleanupAbandonedOperation(); } } return true; }, _options.OperationTimeout, cancellationToken, _ => 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) { return MarkOutcomeUnknown(operation); } catch (StaDispatcherQuarantinedException) { return MarkOutcomeUnknown(operation); } catch (Exception) { if (preserveActiveOutput) { _session = null; _connectedProcessSnapshot = null; await AbandonSessionOnStaAsync(session).ConfigureAwait(false); return MarkOutcomeUnknown(operation); } _session = session; if (!await RecycleFailedSessionAsync().ConfigureAwait(false)) { return MarkOutcomeUnknown(operation); } return Result( operation, PlayoutResultCode.Failed, "송출 명령을 처리할 수 없습니다."); } } private async Task RecycleFailedSessionAsync() { _preparedCue = null; _onAirSceneName = null; var released = await ReleaseSessionAsync(CancellationToken.None).ConfigureAwait(false); if (!released) { return false; } _connectionState = _connectionRequested ? PlayoutConnectionState.Reconnecting : PlayoutConnectionState.Faulted; ScheduleReconnect(); PublishStatus("Tornado 송출 세션을 다시 연결해야 합니다."); return true; } private async Task 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; } if (_dispatcher is null || _dispatcher.IsQuarantined) { return false; } try { await CleanupSessionOnStaAsync(session, cancellationToken).ConfigureAwait(false); return true; } catch (OperationCanceledException) { if (_dispatcher.IsQuarantined) { return false; } try { await CleanupSessionOnStaAsync(session, CancellationToken.None).ConfigureAwait(false); return true; } catch (Exception) { return false; } } catch (Exception) { return false; } } private async Task 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 CleanupSessionOnStaAsync( IK3dSession session, CancellationToken cancellationToken) => _dispatcher!.InvokeAsync( () => { try { session.Disconnect(); } finally { session.Dispose(); } return true; }, _options.DisconnectTimeout, cancellationToken); private async Task MonitorLoopAsync() { var cancellationToken = _monitorCancellation.Token; try { while (!cancellationToken.IsCancellationRequested) { try { await PollProcessOnceAsync(cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { break; } catch (Exception) { // Process inspection must never terminate the app or the monitor loop. } await Task.Delay( _options.ProcessPollInterval, _timeProvider, cancellationToken).ConfigureAwait(false); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } } private TornadoProcessSnapshot CaptureProcessSnapshot() { try { return _processProbe.Capture( _options.Mode == PlayoutMode.Test ? _options.TestProcessWindowTitleRegex : null); } catch (Exception) { 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.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, PlayoutMode.Live => IsLiveAuthorized(), _ => false }; private bool IsLiveAuthorized() => _options.TrustedLiveOutputEnabled && _liveAuthorization.IsAuthorizedForThisLaunch; private PlayoutResult MarkOutcomeUnknown(PlayoutOperation operation) { _outcomeUnknown = true; _connectionRequested = false; _session = null; _connectedProcessSnapshot = null; _preparedCue = null; _onAirSceneName = null; _connectionState = PlayoutConnectionState.OutcomeUnknown; PublishStatus("송출 결과를 확인할 수 없습니다. 앱을 다시 시작하고 출력 상태를 확인하세요."); return ExistingOutcomeUnknown(operation); } private PlayoutResult ExistingOutcomeUnknown(PlayoutOperation operation) => Result( operation, PlayoutResultCode.OutcomeUnknown, "송출 결과를 확인할 수 없습니다. 앱을 다시 시작하고 출력 상태를 확인하세요."); private void ScheduleReconnect() => _nextReconnectAt = _timeProvider.GetUtcNow() + _options.ReconnectDelay; private void PublishStatus(string message) { PlayoutStatus previous; PlayoutStatus current; lock (_statusSync) { previous = Status; current = BuildStatus(message); Status = current; } var handlers = StatusChanged; if (handlers is null) { return; } foreach (EventHandler handler in handlers.GetInvocationList()) { try { handler(this, new PlayoutStatusChangedEventArgs(previous, current)); } catch (Exception) { // A UI subscriber cannot break the playout state machine. } } } private PlayoutStatus BuildStatus(string message) { var connected = _session is not null && !_outcomeUnknown; var commandAvailable = _options.Mode == PlayoutMode.DryRun || (connected && IsProcessEligible(_processSnapshot)); var liveAllowed = connected && IsProcessEligible(_processSnapshot) && CanTakeIn(); return new PlayoutStatus( _options.Mode, _connectionState, connected, _processSnapshot.AnyTornadoProcess, commandAvailable, liveAllowed, _preparedCue is null ? null : SafeSceneName(_preparedCue), _onAirSceneName, message, _timeProvider.GetUtcNow(), Interlocked.Increment(ref _sequence)) { OperationTimeoutMilliseconds = (int)_options.OperationTimeout.TotalMilliseconds, LastKtapConnectState = (PlayoutKtapConnectState)Volatile.Read( ref _lastKtapConnectState), KtapHelloObserved = null }; } private void CaptureKtapEvidence(IK3dSession? session) { if (session is null) { return; } var state = session.LastKtapConnectState; if (state != PlayoutKtapConnectState.NotAttempted) { Volatile.Write(ref _lastKtapConnectState, (int)state); } } private void PreventLateKtapDispatchAndCapture(IK3dSession? session) { if (session is null) { return; } session.TryPreventKtapConnect(); CaptureKtapEvidence(session); } private PlayoutResult Result( PlayoutOperation operation, PlayoutResultCode code, string message) => new( operation, code, _options.Mode == PlayoutMode.DryRun, message, _timeProvider.GetUtcNow()); private static string SafeSceneName(PlayoutCue cue) { var value = cue.SceneName?.Trim(); if (string.IsNullOrEmpty(value) || value.Length > 64) { return "장면"; } foreach (var character in value) { if (!((character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || character is '_' or '-')) { return "장면"; } } return value; } private string CurrentMessage() => _connectionState switch { PlayoutConnectionState.Connected => "KTAPConnect가 수락되었습니다. OnHello 및 Network Monitoring [R]/[S]는 아직 확인되지 않았습니다.", PlayoutConnectionState.Reconnecting => "Tornado 송출에 다시 연결하는 중입니다.", PlayoutConnectionState.Faulted => "Tornado 송출 연결을 확인하세요.", _ => InitialMessage(_options.Mode) }; private static string InitialMessage(PlayoutMode mode) => mode switch { PlayoutMode.Disabled => "송출 기능이 비활성화되어 있습니다.", PlayoutMode.DryRun => DryRunMonitoringMessage, _ => "Tornado 송출 연결이 필요합니다." }; private const string DryRunMonitoringMessage = "DryRun: COM/KTAPConnect를 호출하지 않았습니다. Network Monitoring 기록 없음이 정상입니다."; private static string DryRunOperationMessage(string operation) => $"DryRun: {operation} COM/KTAPConnect는 호출하지 않았으며 Network Monitoring 기록 없음이 정상입니다."; private string ProcessUnavailableMessage(TornadoProcessSnapshot snapshot) { if (_options.Mode == PlayoutMode.Test) { if (snapshot.ProgramProcessCount > 0) { return "PROGRAM Tornado가 실행 중이므로 테스트 연결을 차단했습니다."; } if (snapshot.TotalProcessCount > 1) { return "Tornado 인스턴스가 여러 개여서 테스트 연결을 차단했습니다."; } return "허용된 테스트 Tornado 인스턴스를 찾을 수 없습니다."; } return "Tornado 프로세스를 찾을 수 없습니다."; } private static string SuccessMessage(PlayoutOperation operation) => operation switch { PlayoutOperation.Prepare => "장면이 준비되었습니다.", PlayoutOperation.TakeIn => "장면이 송출되었습니다.", PlayoutOperation.Next => "다음 장면이 송출되었습니다.", PlayoutOperation.TakeOut => "장면 송출이 종료되었습니다.", _ => "송출 명령이 완료되었습니다." }; }