using System.Text.Json; using MBN_STOCK_WEBVIEW.Core.Playout; using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; using MBN_STOCK_WEBVIEW.Infrastructure; using MBN_STOCK_WEBVIEW.Playout; using MBN_STOCK_WEBVIEW.Playout.Configuration; namespace MBN_STOCK_WEBVIEW; public sealed partial class MainWindow { private const int MaximumPlayoutRequestIdLength = 128; private const string BrowserCorrelationQuarantineMessage = "Browser correlation was lost before the playout result could be confirmed. Verify PGM output and restart the app before issuing another command."; private readonly SemaphoreSlim _playoutCommandGate = new(1, 1); private IPlayoutEngine? _playoutEngine; private LegacyPlayoutWorkflow? _playoutWorkflow; private string? _playoutInitializationError; private int _playoutCommandInFlight; private int _playoutShutdownStarted; private int _browserCorrelationQuarantined; private CancellationTokenSource? _legacyRefreshCancellation; private Task? _legacyRefreshTask; private readonly object _legacyRefreshStatusGate = new(); private LegacyRefreshRuntimeStatus _legacyRefreshStatus = LegacyRefreshRuntimeStatus.Empty; private void InitializePlayoutRuntime() { try { var options = PlayoutOptionsLoader.Load(); var compositionOptions = PlayoutSceneCompositionFactory.Create(options); _playoutEngine = PlayoutEngineFactory.Create(options); _playoutEngine.StatusChanged += OnPlayoutStatusChanged; if (_databaseRuntime is not null) { _playoutWorkflow = LegacySceneRuntimeFactory.Create( _playoutEngine, _databaseRuntime.Executor, compositionOptions); } } catch (Exception exception) { _playoutInitializationError = exception is PlayoutConfigurationException ? exception.Message : "Tornado 송출 어댑터를 초기화하지 못했습니다."; } } private async Task ConnectPlayoutAsync(CancellationToken cancellationToken) { var engine = _playoutEngine; if (engine is null || cancellationToken.IsCancellationRequested) { QueuePlayoutStatus(); return; } try { await engine.ConnectAsync(cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { return; } catch { _playoutInitializationError = "Tornado 송출 엔진에 연결하지 못했습니다."; } QueuePlayoutStatus(); } private void OnPlayoutStatusChanged(object? sender, PlayoutStatusChangedEventArgs args) { _playoutWorkflow?.ObserveEngineStatus(args.Current); if (string.IsNullOrWhiteSpace(args.Current.PreparedSceneName) && string.IsNullOrWhiteSpace(args.Current.OnAirSceneName)) { StopLegacyRefreshLoop(); ResetLegacyRefreshStatus(); } if (Volatile.Read(ref _playoutCommandInFlight) != 0 || Volatile.Read(ref _playoutShutdownStarted) != 0) { return; } QueuePlayoutStatus(); } private void QueuePlayoutStatus() { if (DispatcherQueue.HasThreadAccess) { PostPlayoutStatus(); return; } DispatcherQueue.TryEnqueue(() => PostPlayoutStatus()); } private void HandlePlayoutStatusRequest(JsonElement payload) { if (payload.ValueKind != JsonValueKind.Object || !HasOnlyProperties(payload, "requestId") || !TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out var requestId)) { PostMessage("bridge-error", new { message = "올바르지 않은 송출 상태 요청입니다." }); return; } PostPlayoutStatus(requestId); } private async Task HandlePlayoutCommandAsync(JsonElement payload) { var parsed = PlayoutBridgeProtocol.ParseWorkflowCommand(payload); var request = parsed.Request; if (!parsed.IsValid) { PostPlayoutCommandError( request.RequestId, request.Command, "INVALID_REQUEST", parsed.Error, retryable: false, outcomeUnknown: false); return; } if (IsBrowserCorrelationQuarantined()) { PostPlayoutCommandError( request.RequestId, request.Command, "OUTCOME_UNKNOWN", BrowserCorrelationQuarantineMessage, retryable: false, outcomeUnknown: true); return; } var engine = _playoutEngine; var workflow = _playoutWorkflow; if (engine is null || workflow is null) { PostPlayoutCommandError( request.RequestId, request.Command, "PLAYOUT_UNAVAILABLE", _playoutInitializationError ?? "Tornado 송출 어댑터를 사용할 수 없습니다.", retryable: false, outcomeUnknown: false); return; } if (!await _playoutCommandGate.WaitAsync(0)) { PostPlayoutCommandError( request.RequestId, request.Command, "PLAYOUT_BUSY", "다른 송출 명령을 처리하고 있습니다.", retryable: true, outcomeUnknown: false); return; } var databaseActivityEntered = false; try { Interlocked.Exchange(ref _playoutCommandInFlight, 1); CancelActiveMarketDataRequest(); CancelActiveDatabaseHealthCheck(); await _databaseActivityGate.WaitAsync(_lifetimeCancellation.Token); databaseActivityEntered = true; if (IsBrowserCorrelationQuarantined()) { PostPlayoutCommandError( request.RequestId, request.Command, "OUTCOME_UNKNOWN", BrowserCorrelationQuarantineMessage, retryable: false, outcomeUnknown: true); return; } var refreshBeforeCommand = GetLegacyRefreshStatus(); if (refreshBeforeCommand.IsFaulted && request.Command != "take-out") { PostPlayoutCommandError( request.RequestId, request.Command, "REFRESH_FAULT", refreshBeforeCommand.FaultMessage ?? "자동 장면 갱신이 중단되었습니다. TAKE OUT 후 상태를 확인하세요.", retryable: false, outcomeUnknown: false); return; } var nextKindBeforeCommand = workflow.State.NextKind; // MainForm stops timer1 before every operator command. TAKE IN and a // successful playlist NEXT start it again; Page NEXT intentionally does not. StopLegacyRefreshLoop(); var result = await ExecutePlayoutCommandAsync( workflow, request, _lifetimeCancellation.Token); var status = engine.Status; var workflowState = workflow.State; if (!result.IsSuccess) { PostPlayoutCommandError( request.RequestId, request.Command, ToWireValue(result.Code), result.Message, IsRetryable(result.Code), result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut); return; } if (request.Command == "take-in" || (request.Command == "next" && nextKindBeforeCommand == LegacyWorkflowNextKind.PlaylistNext)) { StartLegacyRefreshLoop(workflow); } else if (request.Command == "take-out") { ResetLegacyRefreshStatus(); } var refreshStatus = GetLegacyRefreshStatus(); PostMessage("playout-command-result", new { requestId = request.RequestId, command = request.Command, succeeded = true, dryRun = result.IsDryRun, state = ToPlayoutPhase(status), message = result.Message, preparedCode = status.PreparedSceneName, onAirCode = status.OnAirSceneName, playCompletionPending = status.IsPlayCompletionPending, currentCueIndex = workflowState.CurrentCueIndexZeroBased, currentEntryId = workflowState.CurrentEntryId, builderKey = workflowState.BuilderKey, pageIndex = workflowState.PageIndexZeroBased, pageCount = workflowState.PageCount, pageSize = workflowState.PageSize, itemCount = workflowState.ItemCount, currentPageItemCount = workflowState.CurrentPageItemCount, isLastPage = workflowState.IsLastPage, nextKind = ToWireValue(workflowState.NextKind), previewFields = workflowState.PreviewFields, refreshActive = refreshStatus.IsActive, refreshNextAt = refreshStatus.NextAtUtc, refreshLastSuccessAt = refreshStatus.LastSuccessAtUtc, refreshFaulted = refreshStatus.IsFaulted, refreshFaultCode = refreshStatus.FaultCode, refreshFaultMessage = refreshStatus.FaultMessage }); } catch (OperationCanceledException) { PostPlayoutCommandError( request.RequestId, request.Command, "CANCELLED", "송출 요청이 취소되었습니다.", retryable: true, outcomeUnknown: false); } catch (DatabaseOperationException exception) { PostPlayoutCommandError( request.RequestId, request.Command, "DATABASE_UNAVAILABLE", exception.Message, retryable: true, outcomeUnknown: false); } catch (Exception exception) when ( exception is LegacySceneDataException or ArgumentException) { PostPlayoutCommandError( request.RequestId, request.Command, "SCENE_DATA_INVALID", exception.Message, retryable: false, outcomeUnknown: false); } catch { PostPlayoutCommandError( request.RequestId, request.Command, "PLAYOUT_FAILED", "송출 엔진 명령을 완료하지 못했습니다.", retryable: false, outcomeUnknown: true); } finally { Interlocked.Exchange(ref _playoutCommandInFlight, 0); if (databaseActivityEntered) { _databaseActivityGate.Release(); } _playoutCommandGate.Release(); // A browser-side timeout may have discarded the correlated result while the // native operation was still completing. Always publish the authoritative // engine state after the command leaves the serialization gate. QueuePlayoutStatus(); } } private async Task HandlePlayoutTimeoutQuarantineAsync(JsonElement payload) { var parsed = PlayoutBridgeProtocol.ParseTimeoutQuarantine(payload); if (!parsed.IsValid) { PostMessage("bridge-error", new { message = parsed.Error }); return; } await QuarantineForBrowserCorrelationLossAsync(); } private async Task QuarantineForBrowserCorrelationLossAsync() { if (Interlocked.Exchange(ref _browserCorrelationQuarantined, 1) != 0) { QueuePlayoutStatus(); return; } // Latch before the first await so a reload or a second WebView message can // never race ahead of native quarantine. The latch is process-lifetime only. StopLegacyRefreshLoop(); QueuePlayoutStatus(); var engine = _playoutEngine; if (engine is not null) { try { await engine.QuarantineAsync(); } catch { // The native latch remains authoritative even if abandoning the vendor // session cannot be confirmed. Retrying any command would be unsafe. } } QueuePlayoutStatus(); } private bool IsBrowserCorrelationQuarantined() => Volatile.Read(ref _browserCorrelationQuarantined) != 0; private static Task ExecutePlayoutCommandAsync( LegacyPlayoutWorkflow workflow, PlayoutBridgeWorkflowCommand request, CancellationToken cancellationToken) => request.Command switch { "prepare" => workflow.PrepareAsync( request.Playlist!, request.SelectedIndexZeroBased, cancellationToken), "take-in" => workflow.TakeInAsync(cancellationToken), "next" => workflow.NextAsync(cancellationToken), // Preserve the legacy MainForm TAKE OUT behavior (StopAll on the selected // scene player). Test/Live safety gates are enforced inside the engine. "take-out" => workflow.TakeOutAsync(PlayoutTakeOutScope.All, cancellationToken), _ => throw new InvalidOperationException("검증되지 않은 송출 명령입니다.") }; private void PostPlayoutStatus(string? requestId = null) { var status = _playoutEngine?.Status; var browserCorrelationQuarantined = IsBrowserCorrelationQuarantined(); if (status is null) { var changedAt = DateTimeOffset.UtcNow; if (requestId is null) { PostMessage("playout-status", new { mode = "disabled", state = "IDLE", connectionState = browserCorrelationQuarantined ? "outcome-unknown" : "unavailable", processDetected = false, connected = false, lastKtapConnectState = "not-attempted", ktapConnectAttempted = false, ktapConnectAccepted = false, ktapHelloObserved = (bool?)null, networkMonitoringRecordExpected = false, networkMonitoringCheckRequired = false, commandAvailable = false, liveTakeInAllowed = false, playCompletionPending = false, outcomeUnknown = browserCorrelationQuarantined, browserCorrelationQuarantined, operationTimeoutMilliseconds = 5000, message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.", preparedCode = (string?)null, onAirCode = (string?)null, currentCueIndex = -1, currentEntryId = (string?)null, builderKey = (string?)null, pageIndex = 0, pageCount = 0, pageSize = 0, itemCount = 0, currentPageItemCount = 0, isLastPage = true, nextKind = "none", previewFields = Array.Empty(), refreshActive = false, refreshNextAt = (DateTimeOffset?)null, refreshLastSuccessAt = (DateTimeOffset?)null, refreshFaulted = false, refreshFaultCode = (string?)null, refreshFaultMessage = (string?)null, changedAt }); } else { PostMessage("playout-status", new { requestId, mode = "disabled", state = "IDLE", connectionState = browserCorrelationQuarantined ? "outcome-unknown" : "unavailable", processDetected = false, connected = false, lastKtapConnectState = "not-attempted", ktapConnectAttempted = false, ktapConnectAccepted = false, ktapHelloObserved = (bool?)null, networkMonitoringRecordExpected = false, networkMonitoringCheckRequired = false, commandAvailable = false, liveTakeInAllowed = false, playCompletionPending = false, outcomeUnknown = browserCorrelationQuarantined, browserCorrelationQuarantined, operationTimeoutMilliseconds = 5000, message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.", preparedCode = (string?)null, onAirCode = (string?)null, currentCueIndex = -1, currentEntryId = (string?)null, builderKey = (string?)null, pageIndex = 0, pageCount = 0, pageSize = 0, itemCount = 0, currentPageItemCount = 0, isLastPage = true, nextKind = "none", previewFields = Array.Empty(), refreshActive = false, refreshNextAt = (DateTimeOffset?)null, refreshLastSuccessAt = (DateTimeOffset?)null, refreshFaulted = false, refreshFaultCode = (string?)null, refreshFaultMessage = (string?)null, changedAt }); } return; } if (requestId is null) { PostMessage("playout-status", CreatePlayoutStatusPayload(status)); } else { var workflowState = _playoutWorkflow?.State; var refreshStatus = GetLegacyRefreshStatus(); PostMessage("playout-status", new { requestId, mode = ToWireValue(status.Mode), state = ToPlayoutPhase(status), connectionState = browserCorrelationQuarantined ? "outcome-unknown" : ToWireValue(status.State), processDetected = status.IsProcessRunning, connected = status.IsConnected, lastKtapConnectState = ToWireValue(status.LastKtapConnectState), ktapConnectAttempted = status.KtapConnectAttempted, ktapConnectAccepted = status.KtapConnectAccepted, ktapHelloObserved = status.KtapHelloObserved, networkMonitoringRecordExpected = status.NetworkMonitoringRecordExpected, networkMonitoringCheckRequired = status.NetworkMonitoringCheckRequired, commandAvailable = status.IsCommandAvailable && !browserCorrelationQuarantined, liveTakeInAllowed = status.LiveTakeInAllowed && !browserCorrelationQuarantined, playCompletionPending = status.IsPlayCompletionPending, outcomeUnknown = browserCorrelationQuarantined || status.State == PlayoutConnectionState.OutcomeUnknown, browserCorrelationQuarantined, operationTimeoutMilliseconds = status.OperationTimeoutMilliseconds, message = browserCorrelationQuarantined ? BrowserCorrelationQuarantineMessage : status.Message, preparedCode = status.PreparedSceneName, onAirCode = status.OnAirSceneName, currentCueIndex = workflowState?.CurrentCueIndexZeroBased ?? -1, currentEntryId = workflowState?.CurrentEntryId, builderKey = workflowState?.BuilderKey, pageIndex = workflowState?.PageIndexZeroBased ?? 0, pageCount = workflowState?.PageCount ?? 0, pageSize = workflowState?.PageSize ?? 0, itemCount = workflowState?.ItemCount ?? 0, currentPageItemCount = workflowState?.CurrentPageItemCount ?? 0, isLastPage = workflowState?.IsLastPage ?? true, nextKind = ToWireValue(workflowState?.NextKind ?? LegacyWorkflowNextKind.None), previewFields = workflowState?.PreviewFields ?? Array.Empty(), refreshActive = refreshStatus.IsActive, refreshNextAt = refreshStatus.NextAtUtc, refreshLastSuccessAt = refreshStatus.LastSuccessAtUtc, refreshFaulted = refreshStatus.IsFaulted, refreshFaultCode = refreshStatus.FaultCode, refreshFaultMessage = refreshStatus.FaultMessage, changedAt = status.ChangedAtUtc }); } } private object CreatePlayoutStatusPayload(PlayoutStatus status) { var workflowState = _playoutWorkflow?.State; var refreshStatus = GetLegacyRefreshStatus(); var browserCorrelationQuarantined = IsBrowserCorrelationQuarantined(); return new { mode = ToWireValue(status.Mode), state = ToPlayoutPhase(status), connectionState = browserCorrelationQuarantined ? "outcome-unknown" : ToWireValue(status.State), processDetected = status.IsProcessRunning, connected = status.IsConnected, lastKtapConnectState = ToWireValue(status.LastKtapConnectState), ktapConnectAttempted = status.KtapConnectAttempted, ktapConnectAccepted = status.KtapConnectAccepted, ktapHelloObserved = status.KtapHelloObserved, networkMonitoringRecordExpected = status.NetworkMonitoringRecordExpected, networkMonitoringCheckRequired = status.NetworkMonitoringCheckRequired, commandAvailable = status.IsCommandAvailable && !browserCorrelationQuarantined, liveTakeInAllowed = status.LiveTakeInAllowed && !browserCorrelationQuarantined, playCompletionPending = status.IsPlayCompletionPending, outcomeUnknown = browserCorrelationQuarantined || status.State == PlayoutConnectionState.OutcomeUnknown, browserCorrelationQuarantined, operationTimeoutMilliseconds = status.OperationTimeoutMilliseconds, message = browserCorrelationQuarantined ? BrowserCorrelationQuarantineMessage : status.Message, preparedCode = status.PreparedSceneName, onAirCode = status.OnAirSceneName, currentCueIndex = workflowState?.CurrentCueIndexZeroBased ?? -1, currentEntryId = workflowState?.CurrentEntryId, builderKey = workflowState?.BuilderKey, pageIndex = workflowState?.PageIndexZeroBased ?? 0, pageCount = workflowState?.PageCount ?? 0, pageSize = workflowState?.PageSize ?? 0, itemCount = workflowState?.ItemCount ?? 0, currentPageItemCount = workflowState?.CurrentPageItemCount ?? 0, isLastPage = workflowState?.IsLastPage ?? true, nextKind = ToWireValue(workflowState?.NextKind ?? LegacyWorkflowNextKind.None), previewFields = workflowState?.PreviewFields ?? Array.Empty(), refreshActive = refreshStatus.IsActive, refreshNextAt = refreshStatus.NextAtUtc, refreshLastSuccessAt = refreshStatus.LastSuccessAtUtc, refreshFaulted = refreshStatus.IsFaulted, refreshFaultCode = refreshStatus.FaultCode, refreshFaultMessage = refreshStatus.FaultMessage, changedAt = status.ChangedAtUtc }; } private static string ToWireValue(LegacyWorkflowNextKind value) => value switch { LegacyWorkflowNextKind.None => "none", LegacyWorkflowNextKind.PageNext => "page-next", LegacyWorkflowNextKind.PlaylistNext => "playlist-next", LegacyWorkflowNextKind.EndOfPlaylist => "end-of-playlist", _ => "none" }; private void PostPlayoutCommandError( string requestId, string command, string code, string message, bool retryable, bool outcomeUnknown) { PostMessage("playout-command-error", new { requestId, command, code, message, retryable, outcomeUnknown }); } private static bool TryGetSafeToken( JsonElement payload, string propertyName, int maximumLength, out string value) { value = GetString(payload, propertyName) ?? string.Empty; return value.Length is > 0 && value.Length <= maximumLength && value.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-'); } private static bool HasOnlyProperties(JsonElement payload, params string[] allowed) { var seen = new HashSet(StringComparer.Ordinal); foreach (var property in payload.EnumerateObject()) { if (!seen.Add(property.Name) || !allowed.Contains(property.Name, StringComparer.Ordinal)) { return false; } } return true; } private static string ToPlayoutPhase(PlayoutStatus status) { if (!string.IsNullOrWhiteSpace(status.OnAirSceneName)) { return "PROGRAM"; } return !string.IsNullOrWhiteSpace(status.PreparedSceneName) ? "PREPARED" : "IDLE"; } private static string ToWireValue(PlayoutMode mode) => mode switch { PlayoutMode.Disabled => "disabled", PlayoutMode.DryRun => "dry-run", PlayoutMode.Test => "test", PlayoutMode.Live => "live", _ => "disabled" }; private static string ToWireValue(PlayoutConnectionState state) => state switch { PlayoutConnectionState.Disabled => "disabled", PlayoutConnectionState.DryRunReady => "dry-run-ready", PlayoutConnectionState.Disconnected => "disconnected", PlayoutConnectionState.Connecting => "connecting", PlayoutConnectionState.Connected => "connected", PlayoutConnectionState.Reconnecting => "reconnecting", PlayoutConnectionState.Disconnecting => "disconnecting", PlayoutConnectionState.Faulted => "faulted", PlayoutConnectionState.OutcomeUnknown => "outcome-unknown", PlayoutConnectionState.Disposed => "disposed", _ => "unavailable" }; private static string ToWireValue(PlayoutKtapConnectState state) => state switch { PlayoutKtapConnectState.NotAttempted => "not-attempted", PlayoutKtapConnectState.Attempted => "attempted", PlayoutKtapConnectState.AcceptedUnconfirmed => "accepted-unconfirmed", PlayoutKtapConnectState.Failed => "failed", _ => "not-attempted" }; private static string ToWireValue(PlayoutResultCode code) => code switch { PlayoutResultCode.Rejected => "REJECTED", PlayoutResultCode.Cancelled => "CANCELLED", PlayoutResultCode.TimedOut => "TIMED_OUT", PlayoutResultCode.Unavailable => "UNAVAILABLE", PlayoutResultCode.OutcomeUnknown => "OUTCOME_UNKNOWN", _ => "PLAYOUT_FAILED" }; private static bool IsRetryable(PlayoutResultCode code) => code is PlayoutResultCode.Cancelled or PlayoutResultCode.Unavailable or PlayoutResultCode.Failed; private void StartLegacyRefreshLoop(LegacyPlayoutWorkflow workflow) { StopLegacyRefreshLoop(); var cutCode = workflow.State.OnAirCutCode; if (!LegacySceneRefreshPolicy.TryGetInterval(cutCode, out var interval) || interval <= TimeSpan.Zero || Volatile.Read(ref _playoutShutdownStarted) != 0) { return; } var cancellation = CancellationTokenSource.CreateLinkedTokenSource( _lifetimeCancellation.Token); _legacyRefreshCancellation = cancellation; SetLegacyRefreshStatus(new LegacyRefreshRuntimeStatus( true, DateTimeOffset.UtcNow.Add(interval), GetLegacyRefreshStatus().LastSuccessAtUtc, false, null, null)); _legacyRefreshTask = RunLegacyRefreshLoopAsync( workflow, interval, cancellation); } private void StopLegacyRefreshLoop() { var cancellation = Interlocked.Exchange( ref _legacyRefreshCancellation, null); if (cancellation is not null) { cancellation.Cancel(); } var status = GetLegacyRefreshStatus(); if (status.IsActive) { SetLegacyRefreshStatus(status with { IsActive = false, NextAtUtc = null }); } } private async Task RunLegacyRefreshLoopAsync( LegacyPlayoutWorkflow workflow, TimeSpan interval, CancellationTokenSource cancellation) { var token = cancellation.Token; var nextDelay = interval; try { while (true) { await Task.Delay(nextDelay, token); var engine = _playoutEngine; if (engine is null || !ReferenceEquals(_playoutWorkflow, workflow) || !ReferenceEquals(_legacyRefreshCancellation, cancellation) || Volatile.Read(ref _playoutShutdownStarted) != 0) { return; } // Play returns before Tornado necessarily delivers OnScenePlayed. Waiting // outside the app command gate keeps emergency TAKE OUT available and does // not dispatch, repeat, or probe any SDK mutation. if (!await WaitForPlayCompletionAsync(engine, token)) { SetLegacyRefreshFault( "PLAY_CALLBACK_TIMEOUT", "Tornado 재생 완료 이벤트를 제한 시간 안에 확인하지 못해 자동 장면 갱신을 중단했습니다. TAKE OUT 후 실제 화면을 확인하세요."); QueuePlayoutStatus(); return; } await _playoutCommandGate.WaitAsync(token); var databaseActivityEntered = false; try { if (!ReferenceEquals(_legacyRefreshCancellation, cancellation) || Volatile.Read(ref _playoutShutdownStarted) != 0) { return; } Interlocked.Exchange(ref _playoutCommandInFlight, 1); CancelActiveMarketDataRequest(); CancelActiveDatabaseHealthCheck(); await _databaseActivityGate.WaitAsync(token); databaseActivityEntered = true; var result = await workflow.RefreshOnAirAsync(token); // A failed, timed-out, or unknown update never repeats. The operator // must reconcile native/PGM state before starting another command. if (!result.IsSuccess) { SetLegacyRefreshFault( ToWireValue(result.Code), "자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면과 데이터를 확인하세요."); return; } // MainForm changes timer1.Interval to 3000 after the first // successful same-scene refresh. nextDelay = LegacySceneRefreshPolicy.RecurringInterval; SetLegacyRefreshStatus(new LegacyRefreshRuntimeStatus( true, DateTimeOffset.UtcNow.Add(nextDelay), DateTimeOffset.UtcNow, false, null, null)); } catch (OperationCanceledException) when (token.IsCancellationRequested) { return; } catch (DatabaseOperationException) { SetLegacyRefreshFault( "DATABASE_UNAVAILABLE", "자동 장면 갱신 중 데이터베이스 조회가 실패했습니다. TAKE OUT 후 확인하세요."); return; } catch (LegacySceneDataException) { SetLegacyRefreshFault( "SCENE_DATA_INVALID", "자동 장면 갱신 데이터가 유효하지 않습니다. TAKE OUT 후 확인하세요."); return; } catch { SetLegacyRefreshFault( "REFRESH_FAILED", "자동 장면 갱신을 완료하지 못했습니다. TAKE OUT 후 확인하세요."); return; } finally { Interlocked.Exchange(ref _playoutCommandInFlight, 0); if (databaseActivityEntered) { _databaseActivityGate.Release(); } _playoutCommandGate.Release(); QueuePlayoutStatus(); } } } catch (OperationCanceledException) when (token.IsCancellationRequested) { // Operator commands and shutdown cancel the legacy timer deterministically. } finally { Interlocked.CompareExchange( ref _legacyRefreshCancellation, null, cancellation); cancellation.Dispose(); } } private static async Task WaitForPlayCompletionAsync( IPlayoutEngine engine, CancellationToken cancellationToken) { if (!engine.Status.IsPlayCompletionPending) { return true; } var timeoutMilliseconds = Math.Clamp( engine.Status.OperationTimeoutMilliseconds, 100, 300_000); using var timeoutCancellation = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken); timeoutCancellation.CancelAfter(timeoutMilliseconds); while (engine.Status.IsPlayCompletionPending) { var statusChanged = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); void OnStatusChanged(object? sender, PlayoutStatusChangedEventArgs args) { if (!args.Current.IsPlayCompletionPending) { statusChanged.TrySetResult(); } } engine.StatusChanged += OnStatusChanged; try { // Close the subscribe-after-read race without causing an engine call. if (!engine.Status.IsPlayCompletionPending) { return true; } try { await statusChanged.Task.WaitAsync(timeoutCancellation.Token); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { return false; } } finally { engine.StatusChanged -= OnStatusChanged; } } return true; } private LegacyRefreshRuntimeStatus GetLegacyRefreshStatus() { lock (_legacyRefreshStatusGate) { return _legacyRefreshStatus; } } private void SetLegacyRefreshStatus(LegacyRefreshRuntimeStatus status) { lock (_legacyRefreshStatusGate) { _legacyRefreshStatus = status; } } private void SetLegacyRefreshFault(string code, string message) => SetLegacyRefreshStatus(new LegacyRefreshRuntimeStatus( false, null, GetLegacyRefreshStatus().LastSuccessAtUtc, true, code, message)); private void ResetLegacyRefreshStatus() => SetLegacyRefreshStatus(LegacyRefreshRuntimeStatus.Empty); private void ShutdownPlayoutRuntime() { if (Interlocked.Exchange(ref _playoutShutdownStarted, 1) != 0) { return; } StopLegacyRefreshLoop(); var engine = Interlocked.Exchange(ref _playoutEngine, null); _playoutWorkflow = null; if (engine is null) { return; } engine.StatusChanged -= OnPlayoutStatusChanged; using var disconnectCancellation = new CancellationTokenSource(TimeSpan.FromSeconds(3)); try { var disconnect = engine.DisconnectAsync(disconnectCancellation.Token); if (!disconnect.Wait(TimeSpan.FromSeconds(4))) { disconnectCancellation.Cancel(); } } catch { // App shutdown continues even when the external playout process is unavailable. } try { engine.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(2)); } catch { // Disposal is best-effort and bounded during window shutdown. } } private sealed record LegacyRefreshRuntimeStatus( bool IsActive, DateTimeOffset? NextAtUtc, DateTimeOffset? LastSuccessAtUtc, bool IsFaulted, string? FaultCode, string? FaultMessage) { public static LegacyRefreshRuntimeStatus Empty { get; } = new( false, null, null, false, null, null); } }