Files
MBN_STOCK_WEBVIEW/MainWindow.Playout.cs

1172 lines
46 KiB
C#

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.Infrastructure.Diagnostics;
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 PlayoutOptions? _playoutOptions;
private MutableLegacySceneCueCompositionOptionsSource? _legacyCompositionOptionsSource;
private string? _playoutInitializationError;
private int _playoutCommandInFlight;
private int _playoutShutdownStarted;
private int _browserCorrelationQuarantined;
private int _operatorMutationQuarantined;
private string? _operatorMutationQuarantineMessage;
private Task? _legacyRefreshTask;
private readonly LegacyRefreshEpoch<LegacyRefreshRuntimeStatus> _legacyRefreshEpoch =
new(LegacyRefreshRuntimeStatus.Empty);
private void InitializePlayoutRuntime()
{
try
{
var options = PlayoutOptionsLoader.Load();
var compositionOptions = PlayoutSceneCompositionFactory.Create(options);
_playoutOptions = options;
_legacyCompositionOptionsSource =
new MutableLegacySceneCueCompositionOptionsSource(compositionOptions);
_playoutEngine = PlayoutEngineFactory.Create(options);
_playoutEngine.StatusChanged += OnPlayoutStatusChanged;
ObserveNativeTornadoState(_playoutEngine.Status.State);
if (_databaseRuntime is not null)
{
_playoutWorkflow = LegacySceneRuntimeFactory.Create(
_playoutEngine,
_databaseRuntime.Executor,
compositionOptionsSource: _legacyCompositionOptionsSource,
trustedManualSource: _manualNetSellStore,
trustedViSource: _viManualListStore);
}
}
catch (Exception exception)
{
ObserveNativeTornadoState(PlayoutConnectionState.Faulted);
_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)
{
ObserveNativeTornadoState(args.Current.State);
_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
{
LogNativePlayoutCommandRequested(request.Command);
Interlocked.Exchange(ref _playoutCommandInFlight, 1);
if (IsBrowserCorrelationQuarantined())
{
LogNativePlayoutOutcomeUnknown(request.Command);
PostPlayoutCommandError(
request.RequestId,
request.Command,
"OUTCOME_UNKNOWN",
BrowserCorrelationQuarantineMessage,
retryable: false,
outcomeUnknown: true);
return;
}
var refreshBeforeCommand = GetLegacyRefreshStatus();
if (refreshBeforeCommand.IsFaulted && request.Command != "take-out")
{
LogNativePlayoutCommandCompleted(
request.Command,
NativeOperatorPlayoutCompletion.Rejected);
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();
// Do not wait or enter the workflow while a prior Play callback is
// pending. NEXT fails closed immediately so the browser request and both
// gates are released, leaving an emergency TAKE OUT actionable.
if (request.Command == "next" && engine.Status.IsPlayCompletionPending)
{
LogNativePlayoutCommandCompleted(
request.Command,
NativeOperatorPlayoutCompletion.Rejected);
PostPlayoutCommandError(
request.RequestId,
request.Command,
"PLAY_CALLBACK_PENDING",
"Tornado play completion is still pending. Wait for the status update or use TAKE OUT.",
retryable: true,
outcomeUnknown: false);
return;
}
CancelActiveMarketDataRequest();
CancelActiveDatabaseHealthCheck();
await _databaseActivityGate.WaitAsync(_lifetimeCancellation.Token);
databaseActivityEntered = true;
if (IsBrowserCorrelationQuarantined())
{
LogNativePlayoutOutcomeUnknown(request.Command);
PostPlayoutCommandError(
request.RequestId,
request.Command,
"OUTCOME_UNKNOWN",
BrowserCorrelationQuarantineMessage,
retryable: false,
outcomeUnknown: true);
return;
}
var result = await ExecutePlayoutCommandAsync(
workflow,
request,
_lifetimeCancellation.Token);
LogNativePlayoutCommandCompleted(request.Command, result.Code);
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)
{
LogNativePlayoutCommandCompleted(
request.Command,
NativeOperatorPlayoutCompletion.Cancelled);
PostPlayoutCommandError(
request.RequestId,
request.Command,
"CANCELLED",
"송출 요청이 취소되었습니다.",
retryable: true,
outcomeUnknown: false);
}
catch (DatabaseOperationException exception)
{
LogNativePlayoutCommandCompleted(
request.Command,
NativeOperatorPlayoutCompletion.Unavailable);
PostPlayoutCommandError(
request.RequestId,
request.Command,
"DATABASE_UNAVAILABLE",
exception.Message,
retryable: true,
outcomeUnknown: false);
}
catch (Exception exception) when (
exception is LegacySceneDataException or ArgumentException)
{
LogNativePlayoutCommandCompleted(
request.Command,
NativeOperatorPlayoutCompletion.Rejected);
PostPlayoutCommandError(
request.RequestId,
request.Command,
"SCENE_DATA_INVALID",
exception.Message,
retryable: false,
outcomeUnknown: false);
}
catch
{
LogNativePlayoutOutcomeUnknown(request.Command);
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.
LogCurrentNativePlayoutOutcomeUnknown();
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<PlayoutResult> 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<LegacyScenePreviewField>(),
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<LegacyScenePreviewField>(),
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<LegacyScenePreviewField>(),
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<LegacyScenePreviewField>(),
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<string>(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);
var scheduler = new LegacyRefreshScheduler(
interval,
LegacySceneRefreshPolicy.RecurringInterval);
_legacyRefreshEpoch.Replace(
cancellation,
status => new LegacyRefreshRuntimeStatus(
true,
null,
status.LastSuccessAtUtc,
false,
null,
null));
_legacyRefreshTask = RunLegacyRefreshLoopAsync(
workflow,
scheduler,
cancellation);
}
private void StopLegacyRefreshLoop()
{
_legacyRefreshEpoch.Stop(status => status with
{
IsActive = false,
NextAtUtc = null
});
}
private async Task RunLegacyRefreshLoopAsync(
LegacyPlayoutWorkflow workflow,
LegacyRefreshScheduler scheduler,
CancellationTokenSource cancellation)
{
var token = cancellation.Token;
try
{
while (true)
{
var engine = _playoutEngine;
if (engine is null ||
!ReferenceEquals(_playoutWorkflow, workflow) ||
!_legacyRefreshEpoch.IsCurrent(cancellation) ||
Volatile.Read(ref _playoutShutdownStarted) != 0)
{
return;
}
// The original WinForms timer coalesced non-reentrant UI ticks but did
// not wait for the vendor callback. The migration's safety invariant is
// stricter: observe OnScenePlayed and then start a complete one-shot
// cooldown. This intentionally lengthens the effective cadence while
// keeping the command gate free for a real operator quiescent window.
if (!await scheduler.WaitForNextRefreshAsync(
cancellationToken => WaitForPlayCompletionAsync(
engine,
cancellationToken),
scheduledDelay =>
{
if (_legacyRefreshEpoch.TryUpdate(
cancellation,
status => status.IsActive
? status with
{
NextAtUtc = DateTimeOffset.UtcNow.Add(scheduledDelay)
}
: status))
{
QueuePlayoutStatus();
}
},
token))
{
TrySetLegacyRefreshFault(
cancellation,
"PLAY_CALLBACK_TIMEOUT",
"Tornado 재생 완료 이벤트를 제한 시간 안에 확인하지 못해 자동 장면 갱신을 중단했습니다. TAKE OUT 후 실제 화면을 확인하세요.");
QueuePlayoutStatus();
return;
}
if (!ReferenceEquals(_playoutWorkflow, workflow) ||
!_legacyRefreshEpoch.IsCurrent(cancellation) ||
Volatile.Read(ref _playoutShutdownStarted) != 0)
{
return;
}
await _playoutCommandGate.WaitAsync(token);
var databaseActivityEntered = false;
try
{
if (!_legacyRefreshEpoch.IsCurrent(cancellation) ||
Volatile.Read(ref _playoutShutdownStarted) != 0)
{
return;
}
Interlocked.Exchange(ref _playoutCommandInFlight, 1);
CancelActiveMarketDataRequest();
CancelActiveDatabaseHealthCheck();
await _databaseActivityGate.WaitAsync(token);
databaseActivityEntered = true;
var result = await scheduler.ExecuteRefreshAsync(
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)
{
TrySetLegacyRefreshFault(
cancellation,
ToWireValue(result.Code),
"자동 장면 갱신이 중단되었습니다. TAKE OUT 후 실제 화면과 데이터를 확인하세요.");
return;
}
// MainForm changes timer1.Interval to 3000 after the first
// successful same-scene refresh. The next interval does not
// begin until this Play's completion callback is observed.
scheduler.MarkRefreshSucceeded();
if (!_legacyRefreshEpoch.TryUpdate(
cancellation,
_ => new LegacyRefreshRuntimeStatus(
true,
null,
DateTimeOffset.UtcNow,
false,
null,
null)))
{
return;
}
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
return;
}
catch (DatabaseOperationException)
{
TrySetLegacyRefreshFault(
cancellation,
"DATABASE_UNAVAILABLE",
"자동 장면 갱신 중 데이터베이스 조회가 실패했습니다. TAKE OUT 후 확인하세요.");
return;
}
catch (LegacySceneDataException)
{
TrySetLegacyRefreshFault(
cancellation,
"SCENE_DATA_INVALID",
"자동 장면 갱신 데이터가 유효하지 않습니다. TAKE OUT 후 확인하세요.");
return;
}
catch
{
TrySetLegacyRefreshFault(
cancellation,
"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
{
var completedCurrentEpoch = _legacyRefreshEpoch.TryComplete(
cancellation,
status => status with
{
IsActive = false,
NextAtUtc = null
});
cancellation.Dispose();
if (completedCurrentEpoch)
{
QueuePlayoutStatus();
}
}
}
private static async Task<bool> 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() =>
_legacyRefreshEpoch.ReadState();
private bool TrySetLegacyRefreshFault(
CancellationTokenSource cancellation,
string code,
string message) =>
_legacyRefreshEpoch.TryUpdate(
cancellation,
status => new LegacyRefreshRuntimeStatus(
false,
null,
status.LastSuccessAtUtc,
true,
code,
message));
private void ResetLegacyRefreshStatus() =>
_legacyRefreshEpoch.Stop(_ => LegacyRefreshRuntimeStatus.Empty);
/// <summary>
/// Provides the single fail-closed playout boundary used by every operator-data
/// mutation. A missing engine/workflow is not an idle state: it means native
/// state cannot be proven, so a write must not start.
/// </summary>
private bool CanStartOperatorMutation(bool familyWriteQuarantined)
{
var engine = _playoutEngine;
var workflow = _playoutWorkflow;
var status = engine?.Status;
var workflowState = workflow?.State;
var refreshStatus = GetLegacyRefreshStatus();
var snapshot = new OperatorMutationGateSnapshot(
IsFamilyWriteQuarantined: familyWriteQuarantined,
IsGlobalWriteQuarantined: IsOperatorMutationQuarantined(),
IsLifetimeCancellationRequested: _lifetimeCancellation.IsCancellationRequested,
IsPlayoutCommandInFlight: Volatile.Read(ref _playoutCommandInFlight) != 0,
IsPlayoutShutdownStarted: Volatile.Read(ref _playoutShutdownStarted) != 0,
IsBrowserCorrelationQuarantined: IsBrowserCorrelationQuarantined(),
IsEngineAvailable: engine is not null,
IsWorkflowAvailable: workflow is not null,
IsCommandAvailable: status?.IsCommandAvailable ?? false,
IsPlayCompletionPending: status?.IsPlayCompletionPending ?? true,
PreparedSceneName: status?.PreparedSceneName,
OnAirSceneName: status?.OnAirSceneName,
PreparedCutCode: workflowState?.PreparedCutCode,
OnAirCutCode: workflowState?.OnAirCutCode,
IsRefreshActive: refreshStatus.IsActive,
IsRefreshTaskRunning: _legacyRefreshTask is { IsCompleted: false });
return OperatorMutationGate.CanStart(snapshot);
}
private bool IsOperatorMutationQuarantined() =>
Volatile.Read(ref _operatorMutationQuarantined) != 0;
private string GetOperatorMutationQuarantineMessage(string fallback) =>
Volatile.Read(ref _operatorMutationQuarantineMessage) ?? fallback;
private void LatchOperatorMutationQuarantine(string message)
{
if (Interlocked.CompareExchange(ref _operatorMutationQuarantined, 1, 0) == 0)
{
Volatile.Write(
ref _operatorMutationQuarantineMessage,
string.IsNullOrWhiteSpace(message)
? "An operator-data mutation has an unknown outcome. Restart and reconcile every operator-data store before writing again."
: message);
}
}
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);
}
}