diff --git a/.gitignore b/.gitignore index ddd2eed..fcb46be 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ Config/database.local.json **/database.local.json Config/playout.local.json **/playout.local.json +Config/playout*.local.json +**/playout*.local.json *.local.ini .env .env.* diff --git a/MainWindow.Playout.cs b/MainWindow.Playout.cs index 7bcb2cf..33d2caa 100644 --- a/MainWindow.Playout.cs +++ b/MainWindow.Playout.cs @@ -8,9 +8,6 @@ namespace MBN_STOCK_WEBVIEW; public sealed partial class MainWindow { private const int MaximumPlayoutRequestIdLength = 128; - private const int MaximumPlayoutCodeLength = 64; - private const int MaximumPlayoutTitleLength = 200; - private const int MaximumPlayoutDetailLength = 500; private readonly SemaphoreSlim _playoutCommandGate = new(1, 1); private IPlayoutEngine? _playoutEngine; @@ -95,14 +92,15 @@ public sealed partial class MainWindow private async Task HandlePlayoutCommandAsync(JsonElement payload) { - var parsed = TryParsePlayoutCommand(payload, out var request, out var validationError); - if (!parsed) + var parsed = PlayoutBridgeProtocol.ParseCommand(payload); + var request = parsed.Request; + if (!parsed.IsValid) { PostPlayoutCommandError( request.RequestId, request.Command, "INVALID_REQUEST", - validationError, + parsed.Error, retryable: false, outcomeUnknown: false); return; @@ -147,7 +145,7 @@ public sealed partial class MainWindow ToWireValue(result.Code), result.Message, IsRetryable(result.Code), - result.Code == PlayoutResultCode.OutcomeUnknown); + result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut); return; } @@ -180,19 +178,23 @@ public sealed partial class MainWindow request.Command, "PLAYOUT_FAILED", "송출 엔진 명령을 완료하지 못했습니다.", - retryable: true, + retryable: false, outcomeUnknown: true); } finally { Interlocked.Exchange(ref _playoutCommandInFlight, 0); _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 static Task ExecutePlayoutCommandAsync( IPlayoutEngine engine, - ValidatedPlayoutCommand request, + PlayoutBridgeCommand request, CancellationToken cancellationToken) => request.Command switch { "prepare" => engine.PrepareAsync(request.Cue!, cancellationToken), @@ -216,9 +218,13 @@ public sealed partial class MainWindow { mode = "disabled", state = "IDLE", + connectionState = "unavailable", processDetected = false, connected = false, + commandAvailable = false, liveTakeInAllowed = false, + outcomeUnknown = false, + operationTimeoutMilliseconds = 5000, message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.", preparedCode = (string?)null, onAirCode = (string?)null, @@ -232,9 +238,13 @@ public sealed partial class MainWindow requestId, mode = "disabled", state = "IDLE", + connectionState = "unavailable", processDetected = false, connected = false, + commandAvailable = false, liveTakeInAllowed = false, + outcomeUnknown = false, + operationTimeoutMilliseconds = 5000, message = _playoutInitializationError ?? "Tornado 송출 어댑터가 비활성화되어 있습니다.", preparedCode = (string?)null, onAirCode = (string?)null, @@ -255,9 +265,13 @@ public sealed partial class MainWindow requestId, mode = ToWireValue(status.Mode), state = ToPlayoutPhase(status), + connectionState = ToWireValue(status.State), processDetected = status.IsProcessRunning, connected = status.IsConnected, + commandAvailable = status.IsCommandAvailable, liveTakeInAllowed = status.LiveTakeInAllowed, + outcomeUnknown = status.State == PlayoutConnectionState.OutcomeUnknown, + operationTimeoutMilliseconds = status.OperationTimeoutMilliseconds, message = status.Message, preparedCode = status.PreparedSceneName, onAirCode = status.OnAirSceneName, @@ -270,9 +284,13 @@ public sealed partial class MainWindow { mode = ToWireValue(status.Mode), state = ToPlayoutPhase(status), + connectionState = ToWireValue(status.State), processDetected = status.IsProcessRunning, connected = status.IsConnected, + commandAvailable = status.IsCommandAvailable, liveTakeInAllowed = status.LiveTakeInAllowed, + outcomeUnknown = status.State == PlayoutConnectionState.OutcomeUnknown, + operationTimeoutMilliseconds = status.OperationTimeoutMilliseconds, message = status.Message, preparedCode = status.PreparedSceneName, onAirCode = status.OnAirSceneName, @@ -298,91 +316,6 @@ public sealed partial class MainWindow }); } - private static bool TryParsePlayoutCommand( - JsonElement payload, - out ValidatedPlayoutCommand request, - out string error) - { - var requestId = payload.ValueKind == JsonValueKind.Object - ? GetString(payload, "requestId") ?? string.Empty - : string.Empty; - var command = payload.ValueKind == JsonValueKind.Object - ? GetString(payload, "command") ?? string.Empty - : string.Empty; - request = new ValidatedPlayoutCommand(requestId, command, null); - - if (payload.ValueKind != JsonValueKind.Object || - !HasOnlyProperties(payload, "requestId", "command", "cue")) - { - error = "송출 요청에 허용되지 않은 필드가 있습니다."; - return false; - } - - if (!TryGetSafeToken(payload, "requestId", MaximumPlayoutRequestIdLength, out requestId)) - { - error = "송출 요청 식별자가 올바르지 않습니다."; - return false; - } - - if (command is not ("prepare" or "take-in" or "next" or "take-out")) - { - error = "지원하지 않는 송출 명령입니다."; - request = new ValidatedPlayoutCommand(requestId, command, null); - return false; - } - - request = new ValidatedPlayoutCommand(requestId, command, null); - var cueRequired = command is not "take-out"; - if (!payload.TryGetProperty("cue", out var cueElement)) - { - if (!cueRequired) - { - error = string.Empty; - return true; - } - - error = "이 송출 명령에는 그래픽 cue가 필요합니다."; - return false; - } - - if (!TryParsePlayoutCue(cueElement, out var cue, out error)) - { - return false; - } - - request = new ValidatedPlayoutCommand(requestId, command, cue); - return true; - } - - private static bool TryParsePlayoutCue( - JsonElement payload, - out PlayoutCue? cue, - out string error) - { - cue = null; - if (payload.ValueKind != JsonValueKind.Object || - !HasOnlyProperties(payload, "code", "title", "detail") || - !TryGetSafeToken(payload, "code", MaximumPlayoutCodeLength, out var code) || - !TryGetBoundedText(payload, "title", MaximumPlayoutTitleLength, allowEmpty: false, out _) || - !TryGetBoundedText(payload, "detail", MaximumPlayoutDetailLength, allowEmpty: true, out _)) - { - error = "그래픽 cue 형식이 올바르지 않습니다."; - return false; - } - - var sceneFile = $"{code}.t2s"; - if (!string.Equals(Path.GetFileName(sceneFile), sceneFile, StringComparison.Ordinal) || - sceneFile.Contains("..", StringComparison.Ordinal)) - { - error = "그래픽 cue 경로가 올바르지 않습니다."; - return false; - } - - cue = new PlayoutCue(sceneFile, code); - error = string.Empty; - return true; - } - private static bool TryGetSafeToken( JsonElement payload, string propertyName, @@ -394,19 +327,6 @@ public sealed partial class MainWindow value.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-'); } - private static bool TryGetBoundedText( - JsonElement payload, - string propertyName, - int maximumLength, - bool allowEmpty, - out string value) - { - value = GetString(payload, propertyName) ?? string.Empty; - return value.Length <= maximumLength && - (allowEmpty || value.Length > 0) && - !value.Any(char.IsControl); - } - private static bool HasOnlyProperties(JsonElement payload, params string[] allowed) { var seen = new HashSet(StringComparer.Ordinal); @@ -441,6 +361,21 @@ public sealed partial class MainWindow _ => "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(PlayoutResultCode code) => code switch { PlayoutResultCode.Rejected => "REJECTED", @@ -453,10 +388,8 @@ public sealed partial class MainWindow private static bool IsRetryable(PlayoutResultCode code) => code is PlayoutResultCode.Cancelled or - PlayoutResultCode.TimedOut or PlayoutResultCode.Unavailable or - PlayoutResultCode.Failed or - PlayoutResultCode.OutcomeUnknown; + PlayoutResultCode.Failed; private void ShutdownPlayoutRuntime() { @@ -495,9 +428,4 @@ public sealed partial class MainWindow // Disposal is best-effort and bounded during window shutdown. } } - - private sealed record ValidatedPlayoutCommand( - string RequestId, - string Command, - PlayoutCue? Cue); } diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index 81ad1f1..af3744a 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Reflection; using System.Runtime.InteropServices; using System.Text.Json; +using MBN_STOCK_WEBVIEW.Core.Playout; using MBN_STOCK_WEBVIEW.Infrastructure; using Microsoft.UI.Windowing; using Microsoft.Web.WebView2.Core; @@ -126,8 +127,7 @@ public sealed partial class MainWindow : Window return; } - if ((uri.Scheme == Uri.UriSchemeHttps && - uri.Host.Equals(AppHost, StringComparison.OrdinalIgnoreCase)) || + if (PlayoutBridgeProtocol.IsTrustedSource(uri.AbsoluteUri, AppHost) || uri.Scheme == "about") { LoadingOverlay.Visibility = Visibility.Visible; @@ -166,9 +166,7 @@ public sealed partial class MainWindow : Window private void OnWebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs args) { - if (!Uri.TryCreate(args.Source, UriKind.Absolute, out var source) || - source.Scheme != Uri.UriSchemeHttps || - !source.Host.Equals(AppHost, StringComparison.OrdinalIgnoreCase)) + if (!PlayoutBridgeProtocol.IsTrustedSource(args.Source, AppHost)) { return; } diff --git a/README.md b/README.md index 3158326..975f6c5 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ - 기본 `DryRun`, Test 전용 인스턴스·채널·씬 allowlist 및 Live 이중 승인 안전 게이트 - MSIX 패키지 매니페스트와 x64 게시 프로필 -Oracle/MariaDB 연결 계층은 구현 및 실제 DB 스모크 검증을 완료했습니다. Tornado/K3D는 안전 기본값인 `DryRun`과 실제 어댑터 경계를 구현했습니다. 현재 장비에는 `PGM` Tornado만 실행 중이므로 그 프로세스에는 COM을 활성화하지 않았고, 실제 송출 호출 검증은 별도 Test 인스턴스·채널·씬이 준비된 뒤 진행합니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정과 롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 전체 현황은 [마이그레이션 문서](docs/MIGRATION.md)를 참고하세요. +Oracle/MariaDB 연결 계층은 구현 및 실제 DB 스모크 검증을 완료했습니다. Tornado/K3D는 안전 기본값인 `DryRun`과 실제 어댑터 경계를 구현했습니다. 현재 장비에는 `PGM` Tornado만 실행 중이므로 그 프로세스에는 COM을 활성화하지 않았고, 실제 송출 호출 검증은 별도 Test 인스턴스·채널·씬이 준비된 뒤 진행합니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정과 롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 전체 현황은 [마이그레이션 문서](docs/MIGRATION.md)를 참고하세요. ## Visual Studio 2026에서 실행 @@ -59,6 +59,8 @@ dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke ` -c Debug -p:Platform=x64 -- --dry-run ``` +별도 Test 인스턴스 검증은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md)의 단계별 CLI를 사용합니다. `--test-plan`은 절대 경로 로컬 JSON과 씬 자산만 확인하며 엔진/COM을 만들지 않습니다. PGM/PROGRAM이 전혀 없는 격리 출력에서만 `--test-connect`로 연결·해제를 확인한 뒤 `--test-sequence`로 `PREPARE → TAKE IN → NEXT → TAKE OUT`을 실행합니다. Test 명령은 `MBN_STOCK_PLAYOUT_*` 환경 override와 Live 설정을 거부하고 자동 재연결·재생을 하지 않습니다. 승인된 Test 씬 후보는 basename `5001`, `5006`이며 실제 자산 경로는 저장소에 기록하지 않습니다. + MSIX 생성: ```powershell @@ -88,7 +90,7 @@ src/MBN_STOCK_WEBVIEW.Infrastructure/ Oracle/MariaDB 공급자·설정·복원 src/MBN_STOCK_WEBVIEW.Playout/ x64 Tornado/K3D STA·COM 어댑터와 안전 게이트 tests/ Core/Infrastructure/Playout 단위 테스트 tools/MBN_STOCK_WEBVIEW.DbSmoke/ 비밀값을 출력하지 않는 실제 DB 스모크 -tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/ COM 비활성 등록 probe와 dry-run 스모크 +tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/ COM 비활성 probe/plan과 격리 Test 연결·시퀀스 CLI Config/ 비밀값 없는 구성 템플릿 ThirdPartyNotices/ 공급자 재배포 라이선스 고지 docs/ 마이그레이션 기록과 다음 단계 diff --git a/Web/app.js b/Web/app.js index 72970e1..a181fb0 100644 --- a/Web/app.js +++ b/Web/app.js @@ -1,6 +1,9 @@ (() => { "use strict"; + const playoutSafety = globalThis.MbnPlayoutSafety; + if (!playoutSafety) throw new Error("Playout safety module is unavailable."); + const catalog = [ { code: "5001", title: "1열판 · 기본", detail: "종목 현재가 / 등락", category: "plate", market: "kospi" }, { code: "5006", title: "1열판 · 시가", detail: "시가 정보", category: "plate", market: "kosdaq" }, @@ -39,9 +42,14 @@ playout: { mode: "unknown", engineState: "IDLE", + connectionState: "unavailable", processDetected: false, connected: false, + commandAvailable: false, liveTakeInAllowed: false, + nativeOutcomeUnknown: false, + operationTimeoutMs: 5000, + responseTimeoutMs: 10000, message: "송출 어댑터 상태를 확인하고 있습니다.", preparedCode: null, onAirCode: null, @@ -352,7 +360,9 @@ } function playoutDisplayState() { - return isDryRunMode() ? "DRY RUN" : state.playout.engineState; + if (isDryRunMode()) return "DRY RUN"; + if (isTestMode() && state.playout.engineState === "PROGRAM") return "TEST ON AIR"; + return state.playout.engineState; } function setStatusDot(element, status) { @@ -379,22 +389,40 @@ const liveMode = isLiveMode(); const testMode = isTestMode(); const bridgeReady = Boolean(nativeBridge); - const outcomeUnknown = state.playout.error?.outcomeUnknown === true; - const commandReady = bridgeReady && !pending && !outcomeUnknown && (state.playout.connected || dryRun); + const outcomeUnknown = state.playout.nativeOutcomeUnknown || state.playout.error?.outcomeUnknown === true; + const commandReady = bridgeReady && !pending && !outcomeUnknown && state.playout.commandAvailable; const currentCue = cueFromItem(state.playlist[state.currentIndex]); const prepared = state.playout.engineState === "PREPARED" || Boolean(state.playout.preparedCode); + const onAir = state.playout.engineState === "PROGRAM" || Boolean(state.playout.onAirCode); const takeInSafe = dryRun || ((testMode || liveMode) && state.playout.liveTakeInAllowed); elements.playoutState.textContent = displayState; - elements.playoutState.classList.remove("neutral", "live", "dry-run", "error"); + elements.playoutState.classList.remove("neutral", "live", "test-on-air", "dry-run", "error"); if (displayState === "IDLE") elements.playoutState.classList.add("neutral"); if (displayState === "PROGRAM") elements.playoutState.classList.add("live"); + if (displayState === "TEST ON AIR") elements.playoutState.classList.add("test-on-air"); if (displayState === "DRY RUN") elements.playoutState.classList.add("dry-run"); elements.tornadoProcessState.textContent = state.playout.processDetected ? "감지됨" : "미감지"; setStatusDot(elements.tornadoProcessDot, state.playout.processDetected ? "healthy" : (bridgeReady ? "error" : "unconfigured")); - elements.playoutConnectionState.textContent = state.playout.connected ? "연결됨" : "미연결"; - setStatusDot(elements.playoutConnectionDot, state.playout.connected ? "healthy" : (bridgeReady ? "error" : "unconfigured")); + const transitionalConnection = ["connecting", "reconnecting", "disconnecting"].includes(state.playout.connectionState); + const connectionLabels = { + "disabled": "Disabled", + "dry-run-ready": "DryRunReady", + "disconnected": "Disconnected", + "connecting": "Connecting", + "connected": "Connected", + "reconnecting": "Reconnecting", + "disconnecting": "Disconnecting", + "faulted": "Faulted", + "outcome-unknown": "OutcomeUnknown", + "disposed": "Disposed", + "unavailable": "Unavailable" + }; + elements.playoutConnectionState.textContent = connectionLabels[state.playout.connectionState] || "Unavailable"; + setStatusDot(elements.playoutConnectionDot, state.playout.connected + ? "healthy" + : (transitionalConnection ? "warn" : (bridgeReady ? "error" : "unconfigured"))); const safetyContainer = elements.playoutSafetyMode.closest(".playout-health-item"); safetyContainer.classList.remove("live-allowed", "live-locked"); @@ -423,10 +451,14 @@ ? `${pending.command.toUpperCase()} 요청 처리 중 · ${pending.cue?.code || "송출 엔진"}` : `${state.playout.message || "송출 상태를 확인할 수 없습니다."}${changedLabel}`; elements.playoutStatusMessage.classList.toggle("pending", Boolean(pending)); - elements.playoutStatusMessage.classList.toggle("error", Boolean(state.playout.error)); + elements.playoutStatusMessage.classList.toggle("error", Boolean(state.playout.error) || state.playout.nativeOutcomeUnknown); elements.playoutSummary.classList.remove("ready", "dry-run", "error"); - if (dryRun) { + if (state.playout.nativeOutcomeUnknown) { + elements.playoutSummary.classList.add("error"); + elements.playoutSummaryBadge.textContent = "UNKNOWN"; + elements.playoutSummaryDetail.textContent = "출력 상태 확인 후 앱 재시작 필요"; + } else if (dryRun) { elements.playoutSummary.classList.add("dry-run"); elements.playoutSummaryBadge.textContent = "DRY RUN"; elements.playoutSummaryDetail.textContent = bridgeReady ? "안전 모드 · PROGRAM 출력 차단" : "브라우저 미리보기 전용"; @@ -446,7 +478,7 @@ elements.prepareButton.disabled = !commandReady || !currentCue; elements.takeInButton.disabled = !commandReady || !prepared || !takeInSafe; - elements.nextButton.disabled = !commandReady || state.playlist.length === 0; + elements.nextButton.disabled = !commandReady || !onAir || state.playlist.length === 0; elements.takeOutButton.disabled = !commandReady || (state.playout.engineState === "IDLE" && !state.playout.preparedCode && !state.playout.onAirCode); for (const button of [elements.prepareButton, elements.takeInButton, elements.nextButton, elements.takeOutButton]) { button.setAttribute("aria-busy", String(Boolean(pending))); @@ -454,7 +486,9 @@ elements.playoutError.hidden = !state.playout.error; elements.playoutErrorMessage.textContent = state.playout.error?.message || ""; - document.querySelector("#clearPlayoutErrorButton").textContent = outcomeUnknown ? "확인 후 잠금 해제" : "닫기"; + document.querySelector("#clearPlayoutErrorButton").textContent = state.playout.nativeOutcomeUnknown + ? "닫기 · 잠금 유지" + : (outcomeUnknown ? "확인 후 잠금 해제" : "닫기"); } function clearPlayoutError(render = true) { @@ -496,6 +530,25 @@ return; } if (state.playout.pending) return; + const blockReason = playoutSafety.commandBlockReason({ + pending: state.playout.pending, + outcomeUnknown: state.playout.nativeOutcomeUnknown || state.playout.error?.outcomeUnknown === true, + commandAvailable: state.playout.commandAvailable, + engineState: state.playout.engineState, + preparedCode: state.playout.preparedCode, + onAirCode: state.playout.onAirCode + }, command); + if (blockReason) { + const message = { + "outcome-unknown": "출력 상태가 불명확합니다. 실제 출력 확인 후 앱을 다시 시작하세요.", + "unavailable": "Tornado 송출 명령을 사용할 수 없는 상태입니다.", + "next-requires-on-air": "TAKE IN 이후에만 NEXT를 실행할 수 있습니다.", + "take-in-requires-prepare": "먼저 PREPARE를 완료하세요.", + "nothing-to-take-out": "종료할 송출 장면이 없습니다." + }[blockReason] || "다른 송출 명령을 처리하고 있습니다."; + showToast(message); + return; + } const resolved = resolveCommand(command); if (!resolved) { @@ -518,7 +571,9 @@ addLog(`${resolved.cue?.code || "ENGINE"} ${command.toUpperCase()} 요청`); const payload = { requestId, command }; - if (resolved.cue) payload.cue = resolved.cue; + if (resolved.cue && (command === "prepare" || command === "next")) { + payload.cue = { code: resolved.cue.code }; + } postNative("playout-command", payload); pending.timeoutId = setTimeout(() => { @@ -527,12 +582,12 @@ showPlayoutError({ code: "WEB_TIMEOUT", message: "송출 엔진 응답 시간이 초과되었습니다.", - retryable: true, + retryable: false, outcomeUnknown: true }); addLog(`${command.toUpperCase()} 응답 시간 초과 · 결과 미확인`); requestPlayoutStatus(); - }, 15000); + }, state.playout.responseTimeoutMs); } function requestPlayoutStatus() { @@ -556,9 +611,17 @@ state.playout.mode = normalizePlayoutMode(payload.mode); state.playout.engineState = normalizePlayoutState(payload.state); + state.playout.connectionState = playoutSafety.normalizeConnectionState(payload.connectionState); state.playout.processDetected = payload.processDetected === true; state.playout.connected = payload.connected === true; + state.playout.commandAvailable = payload.commandAvailable === true; state.playout.liveTakeInAllowed = payload.liveTakeInAllowed === true; + state.playout.nativeOutcomeUnknown = payload.outcomeUnknown === true || + state.playout.connectionState === "outcome-unknown"; + state.playout.operationTimeoutMs = Number.isFinite(Number(payload.operationTimeoutMilliseconds)) + ? Number(payload.operationTimeoutMilliseconds) + : state.playout.operationTimeoutMs; + state.playout.responseTimeoutMs = playoutSafety.responseTimeoutFromNative(payload.operationTimeoutMilliseconds); state.playout.message = typeof payload.message === "string" && payload.message.trim() ? payload.message.trim() : "Tornado 송출 상태를 수신했습니다."; @@ -569,8 +632,17 @@ state.playout.changedAtMs = changedAtMs || state.playout.changedAtMs; if (payload.requestId === state.playout.latestStatusRequestId) state.playout.latestStatusRequestId = null; + if (state.playout.nativeOutcomeUnknown && state.playout.error?.outcomeUnknown !== true) { + state.playout.error = { + code: "OUTCOME_UNKNOWN", + message: "송출 결과를 확인할 수 없습니다. 실제 출력 확인 후 앱을 다시 시작하세요.", + retryable: false, + outcomeUnknown: true + }; + } + renderPlayout(); - const signature = [state.playout.mode, state.playout.engineState, state.playout.processDetected, state.playout.connected, state.playout.liveTakeInAllowed].join("|"); + const signature = [state.playout.mode, state.playout.engineState, state.playout.connectionState, state.playout.processDetected, state.playout.connected, state.playout.commandAvailable, state.playout.liveTakeInAllowed, state.playout.nativeOutcomeUnknown].join("|"); if (signature !== state.playout.lastSignature) { addLog(`Tornado 상태 · ${playoutDisplayState()} · ${state.playout.connected ? "연결" : "미연결"}`); state.playout.lastSignature = signature; @@ -579,7 +651,7 @@ function finishPendingCommand(payload) { const pending = state.playout.pending; - if (!pending || payload?.requestId !== pending.requestId || payload?.command !== pending.command) return null; + if (!playoutSafety.matchesPendingCommand(pending, payload)) return null; clearTimeout(pending.timeoutId); state.playout.pending = null; return pending; @@ -625,6 +697,7 @@ function handlePlayoutCommandError(payload) { const pending = finishPendingCommand(payload); if (!pending) return; + if (payload.outcomeUnknown === true) state.playout.nativeOutcomeUnknown = true; showPlayoutError({ code: payload.code, message: payload.message, diff --git a/Web/index.html b/Web/index.html index 2a09fa2..6fdb8f9 100644 --- a/Web/index.html +++ b/Web/index.html @@ -167,6 +167,7 @@
+ diff --git a/Web/playout-safety.js b/Web/playout-safety.js new file mode 100644 index 0000000..fd24b12 --- /dev/null +++ b/Web/playout-safety.js @@ -0,0 +1,57 @@ +(function (root, factory) { + "use strict"; + + const api = Object.freeze(factory()); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.MbnPlayoutSafety = api; +})(typeof globalThis === "object" ? globalThis : this, function () { + "use strict"; + + const connectionStates = new Set([ + "disabled", "dry-run-ready", "disconnected", "connecting", "connected", + "reconnecting", "disconnecting", "faulted", "outcome-unknown", "disposed" + ]); + + function normalizeConnectionState(value) { + const normalized = String(value || "unavailable") + .trim() + .toLocaleLowerCase("en-US") + .replace(/[\s_]+/g, "-"); + return connectionStates.has(normalized) ? normalized : "unavailable"; + } + + function responseTimeoutFromNative(value) { + const operationTimeout = Number(value); + if (!Number.isFinite(operationTimeout) || operationTimeout < 100 || operationTimeout > 300000) { + return 15000; + } + return Math.min(305000, Math.max(5100, Math.trunc(operationTimeout) + 5000)); + } + + function commandBlockReason(snapshot, command) { + if (!snapshot || snapshot.pending) return "busy"; + if (snapshot.outcomeUnknown) return "outcome-unknown"; + if (!snapshot.commandAvailable) return "unavailable"; + + const onAir = snapshot.engineState === "PROGRAM" || Boolean(snapshot.onAirCode); + const prepared = snapshot.engineState === "PREPARED" || Boolean(snapshot.preparedCode); + if (command === "next" && !onAir) return "next-requires-on-air"; + if (command === "take-in" && !prepared) return "take-in-requires-prepare"; + if (command === "take-out" && !onAir && !prepared) return "nothing-to-take-out"; + return null; + } + + function matchesPendingCommand(pending, payload) { + return Boolean( + pending && payload && + payload.requestId === pending.requestId && + payload.command === pending.command); + } + + return { + normalizeConnectionState, + responseTimeoutFromNative, + commandBlockReason, + matchesPendingCommand + }; +}); diff --git a/Web/styles.css b/Web/styles.css index 9993cbb..9f6aad5 100644 --- a/Web/styles.css +++ b/Web/styles.css @@ -148,6 +148,7 @@ h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.035em; } .badge { padding: 4px 7px; border: 1px solid rgba(50,213,164,.22); border-radius: 10px; background: var(--mint-soft); color: var(--mint); font: 8px Consolas, monospace; letter-spacing: .05em; } .badge.neutral { border-color: var(--border); background: var(--surface-2); color: var(--muted); } .badge.live { border-color: rgba(50,213,164,.36); background: rgba(50,213,164,.16); box-shadow: 0 0 0 3px rgba(50,213,164,.04); } +.badge.test-on-air { border-color: rgba(86,168,255,.36); background: rgba(86,168,255,.14); color: #8ec4ff; box-shadow: 0 0 0 3px rgba(86,168,255,.04); } .badge.dry-run { border-color: rgba(255,186,85,.32); background: rgba(255,186,85,.11); color: var(--amber); } .search-box { display: grid; grid-template-columns: 20px 1fr auto; align-items: center; gap: 6px; height: 40px; margin: 13px 13px 10px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: #091522; } diff --git a/docs/LEGACY_PLAYOUT_ANALYSIS.md b/docs/LEGACY_PLAYOUT_ANALYSIS.md new file mode 100644 index 0000000..8946abc --- /dev/null +++ b/docs/LEGACY_PLAYOUT_ANALYSIS.md @@ -0,0 +1,52 @@ +# 원본 Tornado 송출 흐름 분석 + +이 문서는 `MBN_STOCK_N`의 `MainForm`, `Scene` 35개 및 `PageN`/`Nxt_PageN`을 새 송출 어댑터와 대조한 기준선입니다. 원본 파일은 읽기만 했으며 새 저장소로 복사하지 않았습니다. + +## MainForm 호출 순서 + +원본 연결은 UI STA에서 `KTAPConnect(1, "127.0.0.1", 30001, 0, event)`를 호출한 뒤 `GetScenePlayer()`를 얻습니다. 연결 성공 판정은 재연결 코드와 동일하게 반환값 `1`입니다. + +새 장면 PREPARE의 기준 순서는 다음과 같습니다. + +1. `LoadScene(Cuts\.t2s, )` +2. layout `10`에 fade effect `7`과 `FadeInSec` 적용 +3. `BeginTransaction()` +4. 장면별 데이터와 오브젝트 변경 +5. `scene.QueryVariables()` +6. `EndTransaction()` +7. `player.Prepare(10, scene)` + +TAKE IN은 준비된 장면에 `Play(10)`을 호출하고 `m_TakeIn=true`로 전환합니다. TAKE OUT은 과거 `CutOut(10)` 대신 현재 운영 코드와 동일하게 `StopAll()`을 사용하고 on-air 상태를 해제합니다. NEXT는 `m_TakeIn`이 참일 때만 실행되므로 IDLE 또는 PREPARED 상태에서 바로 출력을 시작해서는 안 됩니다. + +새 `DynamicK3dSession`은 위 COM 호출 순서와 layout/effect/TAKE OUT 동작을 보존합니다. `TornadoPlayoutEngine`도 on-air 상태가 없으면 NEXT를 COM 호출 전에 거부합니다. + +## Scene 빌더의 범위 + +원본 `Scene` 폴더에는 35개 빌더가 있습니다. 단순 텍스트와 가시성 외에도 색상, 위치, 크기, crop key, path point, 그래프 데이터, 배경 texture/video 등 장면별 K3D 변형을 수행합니다. 이 로직은 데이터 조회와 WinForms 컨트롤에 강하게 결합되어 있어 단순한 Web 제목/설명 문자열로 대체할 수 없습니다. + +현재 어댑터의 `PlayoutField`는 COM 경계를 검증하는 공통 `SetValue`/`SetVisible`만 표현합니다. Web bridge는 presentation용 `title`/`detail`을 장면 데이터인 것처럼 버리거나 추측하지 않고, PREPARE/NEXT에 검증된 scene code만 보냅니다. 따라서 승인된 `5001.t2s`/`5006.t2s` 연결 시험은 파일 load·prepare·play·stop 경로를 검증하지만 원본 시장 데이터가 채워진 방송 화면의 동등성을 증명하지 않습니다. + +장면 데이터를 포팅할 때는 scene code별 builder가 Core의 조회 결과를 명시적인 mutation DTO로 변환하고, 허용된 K3D 메서드만 어댑터가 실행하도록 확장해야 합니다. 오브젝트 이름이나 메서드를 Web 입력에서 임의로 전달하는 범용 reflection API는 만들지 않습니다. + +## PageN과 NEXT + +`PageN`과 `Nxt_PageN`은 조회 행 수를 5·6·12개 단위로 나눠 최대 20페이지의 `m_pcnt`를 계산합니다. `MainForm.Next_Scene`은 5단/6종목/12종목 장면에서 다음 플레이리스트 항목으로 즉시 이동하지 않고 다음 페이지 데이터를 같은 scene에 다시 채웁니다. 경로에 따라 새 scene을 load하거나 `GetPlayingScene(10)`을 얻어 transaction 후 다시 prepare/play합니다. + +현재 Web NEXT는 on-air 상태에서 다음 플레이리스트 cue를 prepare/play하는 어댑터 수준의 동작입니다. `m_pcnt`, 현재 페이지, 같은 scene의 in-place update 및 `GetPlayingScene` 기반 갱신은 아직 장면 builder 계층이 없으므로 구현 범위에 포함되지 않습니다. 운영 동등성 검증에서는 이 항목을 별도 완료 조건으로 추적해야 하며, 현재 Test 시퀀스 성공을 PageN 포팅 완료로 해석하지 않습니다. + +## 현재 Test 판정 범위 + +격리 Test에서 확인할 수 있는 범위는 다음과 같습니다. + +- x64 COM 활성화와 KTAP 연결/해제 +- 승인된 `.t2s`의 load와 scene alias +- transaction, `QueryVariables`, layout 10 prepare +- TAKE IN, 다음 cue의 NEXT, TAKE OUT `StopAll` +- STA 직렬화, timeout, 프로세스 교체 및 오류 상태 + +다음 항목은 별도 장면 마이그레이션 작업이 필요합니다. + +- 35개 scene builder의 데이터/시각 속성 동등성 +- `PageN`/`Nxt_PageN` 페이지 계산과 같은 scene 갱신 +- 배경 영상·texture 및 그래프/path mutation +- 실제 시장 데이터와 원본 화면의 픽셀/내용 비교 diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index cb8418a..0187ba1 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -46,6 +46,8 @@ WebView는 `https://app.mbn.local` 가상 호스트로 패키지 내부 파일 ### Tornado/K3D +원본 `MainForm`/35개 Scene builder/`PageN`의 호출 순서와 현재 어댑터의 정확한 적용 범위는 [원본 Tornado 송출 흐름 분석](LEGACY_PLAYOUT_ANALYSIS.md)에 정리했습니다. + - 완료: Registry64의 K3D TypeLib/CLSID/ProgID/AMD64/Apartment 등록 검사 - 완료: 빌드 입력에 Interop DLL을 두지 않는 late binding과 선택적 진단용 `TlbImp` 스크립트 - 완료: `IPlayoutEngine` 경계, bounded STA FIFO/message pump, timeout 후 `OutcomeUnknown` 격리 @@ -54,6 +56,7 @@ WebView는 `https://app.mbn.local` 가상 호스트로 패키지 내부 파일 - 완료: 기본 DryRun, Test의 단일 loopback 테스트 인스턴스·채널·씬 allowlist, Live 이중 승인 - 확인 대기: 현재 `PGM`과 분리된 테스트 Tornado 인스턴스·출력 채널·테스트 씬의 실제 호출 - 확인 대기: 승인된 Test 환경에서 MSIX 컨텍스트의 실제 COM 활성화와 출력 관찰 +- 후속: 35개 scene builder의 복합 K3D mutation 및 `PageN`/`Nxt_PageN` 같은-scene 페이지 갱신 포팅 ### 화면 기능 diff --git a/docs/PLAYOUT.md b/docs/PLAYOUT.md index 9c42a57..c345420 100644 --- a/docs/PLAYOUT.md +++ b/docs/PLAYOUT.md @@ -115,12 +115,12 @@ MBN_STOCK_PLAYOUT_RECONNECT_ENABLED `Test` 전환 전 다음 조건을 모두 사람이 확인합니다. 1. Tornado 인스턴스가 현재 PGM과 물리적·논리적으로 분리되어 있고 Test의 `host`가 로컬 loopback IP literal(`127.0.0.1` 또는 `::1`)입니다. `localhost`를 포함한 호스트 이름은 허용하지 않습니다. -2. `testProcessWindowTitlePattern`이 전용 테스트 인스턴스 하나만 식별합니다. +2. 전용 테스트 창 제목에 독립된 `TEST` 토큰(예: `Tornado2 TEST`)이 있고 `testProcessWindowTitlePattern`이 그 인스턴스 하나만 식별합니다. 빈 제목이나 일반 `Tornado2` 제목은 허용하지 않습니다. 3. `outputChannel`이 라우터/엔진에서 테스트 출력임을 확인했습니다. 4. `sceneDirectory`가 승인된 테스트 자산 루트이고 사용할 scene name이 `testSceneAllowlist`에 있으며 운영 씬이 아닙니다. 5. 같은 플레이리스트로 `DryRun`의 `PREPARE`, `TAKE IN`, `NEXT`, `TAKE OUT` 메시지와 화면 상태를 먼저 확인했습니다. -프로세스 감시는 이름이 `Tornado2`로 시작하는 모든 프로세스를 대소문자 구분 없이 찾습니다. 이는 가용성 신호일 뿐 송출 권한이 아닙니다. Test에서는 Tornado2 프로세스가 정확히 하나이고 그 창 제목이 테스트 규칙에만 일치해야 하며, `PGM`/`PROGRAM` 창이 하나라도 있거나 여러 인스턴스가 모호하면 COM을 활성화하지 않습니다. 즉 현재 방송 PGM이 실행 중인 장비에서는 Test 연결을 거부합니다. +프로세스 감시는 이름이 `Tornado2`로 시작하는 모든 프로세스를 대소문자 구분 없이 찾습니다. 이는 가용성 신호일 뿐 송출 권한이 아닙니다. Test에서는 Tornado2 프로세스가 정확히 하나이고 그 창 제목이 독립된 `TEST` 토큰과 설정 규칙에 모두 일치해야 합니다. `PGM`/`PROGRAM`, 빈 제목, 일반 `Tornado2` 제목, TEST 표식 없는 제목 또는 여러 인스턴스가 모호하면 COM을 활성화하지 않습니다. 즉 현재 방송 PGM이 실행 중인 장비에서는 창 제목이 순간적으로 바뀌더라도 Test 연결을 거부합니다. Test에서 로컬 프로세스/창 제목 검사를 원격 KTAP endpoint의 신원 증명으로 사용하지 않습니다. 따라서 Test 모드는 loopback endpoint만 허용하며, 원격 host는 아래의 Live 이중 승인을 모두 통과한 경우에만 사용할 수 있습니다. @@ -145,10 +145,63 @@ dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke ` -c Debug -p:Platform=x64 -- --dry-run dotnet test .\MBN_STOCK_WEBVIEW.sln -c Debug -p:Platform=x64 dotnet test .\MBN_STOCK_WEBVIEW.sln -c Release -p:Platform=x64 +powershell -NoProfile -ExecutionPolicy Bypass ` + -File .\scripts\Test-WebPlayout.ps1 ``` `PlayoutSmoke --probe`의 기본 출력은 등록 준비 여부·issue 이름, Tornado2 프로세스 수와 PROGRAM 감지 여부만 제공합니다. 벤더 DLL 전체 경로, 프로세스 ID/이름 및 창 제목은 출력하거나 로그에 남기지 않습니다. 정확한 로컬 등록 경로가 필요한 관리자는 `Inspect-K3DRegistration.ps1` 결과를 해당 장비에서만 확인하고 지원 첨부파일에 포함하지 않습니다. +### 격리 Test 단계별 CLI + +실제 Test 검증은 아래 세 단계를 순서대로 사용합니다. 세 명령 모두 절대 경로의 일반 로컬 JSON 파일과 `--acknowledge-isolated-non-program-test-output` 승인이 필요합니다. `MBN_STOCK_PLAYOUT_*` 환경 변수가 하나라도 존재하면 파일과 환경의 합성 결과가 달라지는 것을 막기 위해 엔진 생성 전에 거부합니다. JSON은 반드시 `mode: "Test"`, `trustedLiveOutputEnabled: false`여야 하며 기존 Test 안전 게이트를 그대로 통과해야 합니다. + +먼저 씬 파일과 설정만 검사합니다. 이 단계는 엔진·STA·COM을 생성하지 않으며 실행 중 프로세스의 적격성도 아직 판단하지 않습니다. 승인된 Test 씬 후보인 `5001.t2s`와 `5006.t2s`를 사용할 때 로컬 JSON의 외부 `sceneDirectory` 및 `testSceneAllowlist`에는 각각 basename `5001`, `5006`을 설정합니다. 실제 절대 경로는 로컬 JSON에만 두고 저장소 문서나 예제 설정에 기록하지 않습니다. + +```powershell +$config = [IO.Path]::GetFullPath( + "$env:LOCALAPPDATA\MBN_STOCK_WEBVIEW\Config\playout.test.local.json") + +dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke ` + -c Debug -p:Platform=x64 -- ` + --test-plan ` + --acknowledge-isolated-non-program-test-output ` + --config $config ` + --prepare 5001 ` + --next 5006 ` + --observe-ms 5000 +``` + +별도 Test Tornado만 정확히 하나 실행되고 PGM/PROGRAM 창이 전혀 없는 것을 사람이 다시 확인한 뒤 연결만 검증합니다. 이 명령은 씬이나 출력 상태를 변경하지 않고 `Connect → Disconnect`만 요청합니다. + +```powershell +dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke ` + -c Debug -p:Platform=x64 -- ` + --test-connect ` + --acknowledge-isolated-non-program-test-output ` + --config $config +``` + +마지막으로 격리된 Test 모니터를 관찰하면서 실제 시퀀스를 실행합니다. `--observe-ms`는 `TAKE IN` 뒤와 `NEXT` 뒤에 각각 적용되는 필수 관찰 시간이며 1,000~30,000ms만 허용합니다. + +```powershell +dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke ` + -c Debug -p:Platform=x64 -- ` + --test-sequence ` + --acknowledge-isolated-non-program-test-output ` + --config $config ` + --prepare 5001 ` + --next 5006 ` + --observe-ms 5000 +``` + +시퀀스는 `Connect → Prepare(5001) → TakeIn → 관찰 → Next(5006) → 관찰 → TakeOut(All) → Disconnect` 순서입니다. 자동 재연결은 CLI가 강제로 비활성화합니다. 성공한 `TakeIn` 뒤 관찰 취소처럼 결과가 확정된 중단이면 `TakeOut(All)`을 한 번만 정리 단계로 요청한 뒤, 정리가 성공한 경우에만 `Disconnect`합니다. 이미 실행 결과가 불명확하거나 `TakeOut`이 어떤 비성공 결과라도 반환하면 출력이 남아 있을 수 있으므로 추가 출력 명령과 SDK `Disconnect`를 보내지 않습니다. 이때 `QuarantineAsync`가 같은 STA에서 제어 메서드 호출 없이 로컬 COM 참조만 해제한 다음 bounded 어댑터 폐기를 수행합니다. quarantine 자체를 완료하지 못하면 의도하지 않은 Disconnect보다 로컬 누수를 택해 일반 Dispose도 생략합니다. 어느 경우든 격리 모니터에서 최종 Test 출력 상태를 사람이 확인해야 합니다. + +JSON 결과는 단계별 operation/result code와 `connectRequestIssued`, nullable `comActivationAttempted`, `outputMayBeActive`, quarantine 시도·완료 여부를 제공합니다. `comActivationAttempted`의 `false`는 시도하지 않았음, `true`는 시도했음, `null`은 COM 활성화 시도 여부를 확정할 수 없음을 뜻합니다. `outputMayBeActive: true`이면 자동 정리를 성공으로 확인하지 못했으므로 사람이 격리 출력을 확인해야 합니다. 특히 `Unavailable`, 취소, timeout은 연결 전 거부와 연결 도중 안전 게이트 변화가 같은 결과 code가 될 수 있으므로 추측하지 않고 `null`로 보고합니다. 로컬 경로, 씬 code, PID, 창 제목, HRESULT 및 엔진 원문 오류는 출력하지 않습니다. `--test-plan`의 `runtimeProcessGateChecked: false`는 자산 계획만 검증했다는 뜻이며 실제 연결 가능성을 증명하지 않습니다. + +현재 장비처럼 PGM Tornado가 실행 중이거나 loopback 포트를 PGM이 소유한 상태에서는 `--test-connect`와 `--test-sequence`를 실행하지 않습니다. 기존 Test 엔진도 exactly-one Tornado2, non-PGM 제목 정규식, loopback literal, Test outputChannel, 외부 non-reparse scene root와 allowlist를 연결 전 및 각 명령 전에 재검사합니다. + +CLI용 Test JSON은 위처럼 앱 기본 경로인 `playout.local.json`과 다른 파일명을 사용합니다. 기본 경로에 Test 설정을 두면 일반 앱 시작 시 자동 연결을 요청할 수 있으므로, 운영자가 의도적으로 앱 UI Test 모드를 검증하는 회차 외에는 사용하지 않습니다. + 앱을 실행한 뒤 다음을 확인합니다. 1. 시작 상태가 `DRY RUN`으로 보이고 앱이 COM 또는 Tornado 없이 종료되지 않습니다. @@ -156,6 +209,12 @@ dotnet test .\MBN_STOCK_WEBVIEW.sln -c Release -p:Platform=x64 3. 잘못된 설정 또는 엔진 부재가 앱 종료가 아니라 연결 상태와 안전한 오류 메시지로 표시됩니다. 4. x64 MSIX를 설치해도 같은 dry-run 흐름이 동작합니다. +WebView 상태 wire는 `Disconnected`, `Connecting`, `Connected`, `Reconnecting`, `Faulted`, `OutcomeUnknown` 등 native connection state를 별도로 전달합니다. `OutcomeUnknown`과 timeout은 `retryable: false`이며 오류 창을 닫아도 native 잠금은 앱 재시작 전까지 유지됩니다. 브라우저 응답 제한 시간은 고정 15초가 아니라 검증된 native operation timeout에 5초 전달 여유를 더해 사용하고, native 명령이 끝나면 상관 응답을 놓친 경우에도 authoritative status를 다시 게시합니다. NEXT는 원본의 `m_TakeIn` 조건처럼 on-air 장면이 있을 때만 native와 Web 양쪽에서 허용됩니다. Test on-air 배지는 실제 PROGRAM과 구분해 `TEST ON AIR`로 표시합니다. + +On-air 표식이 남은 상태에서는 프로세스 감시, 연결 해제, 앱 종료 및 세션 재활용 경로도 SDK `Disconnect`를 호출하지 않습니다. 중앙 `ReleaseSessionAsync` 방어가 세션을 quarantine/abandon하고 결과를 불명확 상태로 승격하므로, 운영자는 먼저 성공한 `TAKE OUT`을 확인한 다음 정상 종료해야 합니다. + +승인 컷의 load/play 경로와 원본 Scene/PageN 데이터 표현력은 서로 다른 검증 범위입니다. 현재 지원 범위와 아직 포팅되지 않은 복합 mutation·페이지 갱신은 [원본 Tornado 송출 흐름 분석](LEGACY_PLAYOUT_ANALYSIS.md)을 기준으로 판단합니다. + 실제 COM 스모크는 별도 테스트 인스턴스와 테스트 씬이 준비된 때에만 진행합니다. 위 안전 게이트를 독립적으로 재확인하고 `mode`를 `Test`로 바꾼 뒤 테스트 모니터에서 `PREPARE → TAKE IN → NEXT → TAKE OUT` 결과를 관찰합니다. PGM/운영 출력에 변화가 보이면 즉시 앱을 종료하고 롤백합니다. 명령 timeout 뒤 결과가 불명확하면 명령을 자동 또는 수동으로 반복하지 말고 테스트 출력 상태를 먼저 확인합니다. 패키지 스모크에서는 벤더 x64 COM이 장비에 정식 등록되어 있어야 합니다. MSIX에 벤더 DLL을 복사해 활성화 오류를 우회하지 않습니다. 패키지 컨텍스트에서 COM 활성화가 막히면 `DryRun` 또는 `Disabled`를 유지하고 HRESULT와 등록 검사 결과만 보고합니다. diff --git a/scripts/Test-WebPlayout.ps1 b/scripts/Test-WebPlayout.ps1 new file mode 100644 index 0000000..80e68c8 --- /dev/null +++ b/scripts/Test-WebPlayout.ps1 @@ -0,0 +1,68 @@ +#Requires -Version 5.1 + +[CmdletBinding()] +param( + [string] $NodePath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-NodePath { + param([AllowEmptyString()][string] $ExplicitPath) + + if (-not [string]::IsNullOrWhiteSpace($ExplicitPath)) { + if (-not [IO.Path]::IsPathRooted($ExplicitPath) -or + -not (Test-Path -LiteralPath $ExplicitPath -PathType Leaf)) { + throw 'NodePath must identify an existing absolute node executable path.' + } + + return [IO.Path]::GetFullPath($ExplicitPath) + } + + $command = Get-Command node -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($null -ne $command) { + return $command.Source + } + + $programFiles = [Environment]::GetFolderPath( + [Environment+SpecialFolder]::ProgramFiles) + $candidates = @( + (Join-Path $programFiles 'Microsoft Visual Studio\18\Community\MSBuild\Microsoft\VisualStudio\NodeJs\node.exe'), + (Join-Path $programFiles 'Microsoft Visual Studio\2022\Community\MSBuild\Microsoft\VisualStudio\NodeJs\node.exe') + ) + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate -PathType Leaf) { + return [IO.Path]::GetFullPath($candidate) + } + } + + throw 'A Node.js executable was not found. Pass its absolute path with -NodePath.' +} + +$node = Resolve-NodePath -ExplicitPath $NodePath +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$scripts = @( + (Join-Path $repositoryRoot 'Web\playout-safety.js'), + (Join-Path $repositoryRoot 'Web\app.js') +) +$test = Join-Path $repositoryRoot 'tests\Web\playout-safety.test.cjs' + +foreach ($script in $scripts) { + & $node --check $script + if ($LASTEXITCODE -ne 0) { + throw "JavaScript syntax validation failed with exit code $LASTEXITCODE." + } +} + +& $node --test $test +if ($LASTEXITCODE -ne 0) { + throw "Web playout tests failed with exit code $LASTEXITCODE." +} + +[pscustomobject][ordered]@{ + Status = 'Passed' + SyntaxFiles = $scripts.Count + TestFile = [IO.Path]::GetFileName($test) +} diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs index 79924e0..9673a9d 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs @@ -80,7 +80,10 @@ public sealed record PlayoutStatus( string? OnAirSceneName, string Message, DateTimeOffset ChangedAtUtc, - long Sequence); + long Sequence) +{ + public int OperationTimeoutMilliseconds { get; init; } = 5_000; +} public sealed class PlayoutStatusChangedEventArgs : EventArgs { @@ -131,4 +134,10 @@ public interface IPlayoutEngine : IAsyncDisposable Task TakeOutAsync( PlayoutTakeOutScope scope, CancellationToken cancellationToken = default); + + /// + /// Latches OutcomeUnknown and releases local adapter/COM references without issuing + /// an SDK Disconnect. Used only when another control call could make output safety worse. + /// + ValueTask QuarantineAsync(); } diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs new file mode 100644 index 0000000..8bfdd8b --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/PlayoutBridgeProtocol.cs @@ -0,0 +1,131 @@ +#nullable enable + +using System.Text.Json; + +namespace MBN_STOCK_WEBVIEW.Core.Playout; + +public sealed record PlayoutBridgeCommand( + string RequestId, + string Command, + PlayoutCue? Cue); + +public sealed record PlayoutBridgeCommandParseResult( + bool IsValid, + PlayoutBridgeCommand Request, + string Error); + +/// +/// Strict, COM-neutral parser for the local WebView playout command boundary. +/// Presentation text is intentionally not accepted as scene mutation data. +/// +public static class PlayoutBridgeProtocol +{ + private const int MaximumRequestIdLength = 128; + private const int MaximumSceneCodeLength = 64; + + public static bool IsTrustedSource(string? source, string expectedHost) => + !string.IsNullOrWhiteSpace(source) && + !string.IsNullOrWhiteSpace(expectedHost) && + Uri.TryCreate(source, UriKind.Absolute, out var uri) && + uri.Scheme == Uri.UriSchemeHttps && + uri.IsDefaultPort && + string.IsNullOrEmpty(uri.UserInfo) && + uri.Host.Equals(expectedHost, StringComparison.OrdinalIgnoreCase); + + public static PlayoutBridgeCommandParseResult ParseCommand(JsonElement payload) + { + var empty = new PlayoutBridgeCommand(string.Empty, string.Empty, null); + if (payload.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(payload, "requestId", "command", "cue")) + { + return Invalid(empty, "송출 요청에 허용되지 않은 필드가 있습니다."); + } + + if (!TryGetSafeToken(payload, "requestId", MaximumRequestIdLength, out var requestId)) + { + return Invalid(empty, "송출 요청 식별자가 올바르지 않습니다."); + } + + var command = GetString(payload, "command") ?? string.Empty; + if (command is not ("prepare" or "take-in" or "next" or "take-out")) + { + return Invalid( + new PlayoutBridgeCommand(requestId, string.Empty, null), + "지원하지 않는 송출 명령입니다."); + } + + var hasCue = payload.TryGetProperty("cue", out var cueElement); + if (command is "prepare" or "next") + { + if (!hasCue || !TryParseCue(cueElement, out var cue)) + { + return Invalid( + new PlayoutBridgeCommand(requestId, command, null), + "그래픽 cue 형식이 올바르지 않습니다."); + } + + return Valid(new PlayoutBridgeCommand(requestId, command, cue)); + } + + if (hasCue) + { + return Invalid( + new PlayoutBridgeCommand(requestId, command, null), + "이 송출 명령에는 cue를 지정할 수 없습니다."); + } + + return Valid(new PlayoutBridgeCommand(requestId, command, null)); + } + + private static bool TryParseCue(JsonElement payload, out PlayoutCue? cue) + { + cue = null; + if (payload.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(payload, "code") || + !TryGetSafeToken(payload, "code", MaximumSceneCodeLength, out var code)) + { + return false; + } + + cue = new PlayoutCue($"{code}.t2s", code); + return true; + } + + 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? GetString(JsonElement payload, string propertyName) => + payload.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private static PlayoutBridgeCommandParseResult Valid(PlayoutBridgeCommand request) => + new(true, request, string.Empty); + + private static PlayoutBridgeCommandParseResult Invalid( + PlayoutBridgeCommand request, + string error) => new(false, request, error); +} diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs index a12b315..da3c775 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/PlayoutOptionsLoader.cs @@ -25,6 +25,16 @@ public static class PlayoutOptionsLoader return options; } + /// + /// Loads only the specified JSON file and deliberately ignores all environment + /// overrides. Intended for explicit, reproducible diagnostic commands. + /// + public static PlayoutOptions LoadFileOnly(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + return LoadJson(path); + } + internal static bool IsLiveAuthorizedForThisLaunch() => string.Equals( Environment.GetEnvironmentVariable(LiveAuthorizationEnvironmentVariable), diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs index bc5604d..3c1d07e 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs @@ -139,10 +139,11 @@ internal sealed record ValidatedPlayoutOptions( "테스트 Tornado 창 제목 설정이 올바르지 않습니다.", exception); } - if (CanMatchProgramTitle(titleRegex)) + if (!pattern.Contains("TEST", StringComparison.OrdinalIgnoreCase) || + CanMatchProgramTitle(titleRegex)) { throw new PlayoutConfigurationException( - "테스트 창 제목 규칙은 PROGRAM 출력을 가리킬 수 없습니다."); + "테스트 창 제목 규칙은 명시적인 TEST 표식이 필요하며 PROGRAM 또는 비식별 출력을 가리킬 수 없습니다."); } } @@ -249,6 +250,9 @@ internal sealed record ValidatedPlayoutOptions( { string[] protectedTitles = [ + string.Empty, + " ", + "Tornado2", "PGM", "PROGRAM", "Tornado2 PGM", diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs index 1a18dab..e7f0d57 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs @@ -20,6 +20,8 @@ internal interface IK3dSession : IDisposable void Play(int layoutIndex); void TakeOut(int layoutIndex, PlayoutTakeOutScope scope); + + void Abandon(); } internal interface IK3dSessionFactory @@ -49,6 +51,11 @@ internal interface ILateBoundComActivator object Create(Guid classId); } +internal interface ILateBoundComObjectReleaser +{ + void Release(object value); +} + internal sealed class RegisteredComActivator : ILateBoundComActivator { public object Create(Guid classId) @@ -60,9 +67,21 @@ internal sealed class RegisteredComActivator : ILateBoundComActivator } } +internal sealed class RuntimeComObjectReleaser : ILateBoundComObjectReleaser +{ + public void Release(object value) + { + if (Marshal.IsComObject(value)) + { + Marshal.FinalReleaseComObject(value); + } + } +} + internal sealed class DynamicK3dSession : IK3dSession { private readonly ILateBoundComActivator _activator; + private readonly ILateBoundComObjectReleaser _releaser; private object? _engine; private object? _eventHandler; private object? _player; @@ -71,8 +90,16 @@ internal sealed class DynamicK3dSession : IK3dSession private bool _disposed; public DynamicK3dSession(ILateBoundComActivator activator) + : this(activator, new RuntimeComObjectReleaser()) + { + } + + internal DynamicK3dSession( + ILateBoundComActivator activator, + ILateBoundComObjectReleaser releaser) { _activator = activator; + _releaser = releaser; } public bool IsConnected => _engine is not null && _player is not null; @@ -87,10 +114,12 @@ internal sealed class DynamicK3dSession : IK3dSession return; } + var ktapConnectAttempted = false; try { _eventHandler = _activator.Create(K3dComConstants.KaEventHandlerClassGuid); _engine = _activator.Create(K3dComConstants.KaEngineClassGuid); + ktapConnectAttempted = true; var result = Invoke( _engine, "KTAPConnect", @@ -111,9 +140,23 @@ internal sealed class DynamicK3dSession : IK3dSession ? InvokeRequired(_engine, "GetScenePlayerOnChannel", channel) : InvokeRequired(_engine, "GetScenePlayer"); } - catch + catch (Exception exception) { + var originalFailure = ExceptionDispatchInfo.Capture(exception); + if (ktapConnectAttempted && _engine is not null) + { + try + { + Invoke(_engine, "Disconnect"); + } + catch + { + // Preserve the initialization failure while still releasing every RCW. + } + } + ReleaseAll(); + originalFailure.Throw(); throw; } } @@ -226,6 +269,18 @@ internal sealed class DynamicK3dSession : IK3dSession } } + public void Abandon() + { + if (_disposed) + { + return; + } + + EnsureThread(); + _disposed = true; + ReleaseAll(); + } + public void Dispose() { if (_disposed) @@ -252,7 +307,7 @@ internal sealed class DynamicK3dSession : IK3dSession } } - private static void ApplyField(object scene, PlayoutField field) + private void ApplyField(object scene, PlayoutField field) { if (string.IsNullOrWhiteSpace(field.ObjectName)) { @@ -309,14 +364,19 @@ internal sealed class DynamicK3dSession : IK3dSession private void ReleaseAll() { - ReleaseComObject(_scene); + var scene = _scene; _scene = null; - ReleaseComObject(_player); + var player = _player; _player = null; - ReleaseComObject(_engine); + var engine = _engine; _engine = null; - ReleaseComObject(_eventHandler); + var eventHandler = _eventHandler; _eventHandler = null; + + ReleaseComObject(scene); + ReleaseComObject(player); + ReleaseComObject(engine); + ReleaseComObject(eventHandler); } private static object InvokeRequired(object target, string method, params object?[] arguments) => @@ -342,11 +402,20 @@ internal sealed class DynamicK3dSession : IK3dSession } } - private static void ReleaseComObject(object? value) + private void ReleaseComObject(object? value) { - if (value is not null && Marshal.IsComObject(value)) + if (value is null) { - Marshal.FinalReleaseComObject(value); + return; + } + + try + { + _releaser.Release(value); + } + catch + { + // Cleanup is best-effort and must not hide the SDK operation that failed. } } } diff --git a/src/MBN_STOCK_WEBVIEW.Playout/PlayoutEngineFactory.cs b/src/MBN_STOCK_WEBVIEW.Playout/PlayoutEngineFactory.cs index cb03e9c..67ac22d 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/PlayoutEngineFactory.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/PlayoutEngineFactory.cs @@ -28,4 +28,51 @@ public static class PlayoutEngineFactory new EnvironmentLiveAuthorization(), TimeProvider.System); } + + /// + /// Applies the same fail-closed Test-mode configuration, scene-root, file-name and + /// allowlist validation used by the runtime before a diagnostic tool creates an engine. + /// This method never creates a dispatcher or activates COM. + /// + public static void ValidateIsolatedTestCommand( + PlayoutOptions options, + IEnumerable cues) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(cues); + + if (options.Mode != PlayoutMode.Test || options.TrustedLiveOutputEnabled) + { + throw new PlayoutConfigurationException( + "The isolated test command requires Test mode with live output disabled."); + } + + var validated = ValidatedPlayoutOptions.Create(options); + foreach (var cue in cues) + { + if (cue is null) + { + throw new PlayoutConfigurationException( + "The isolated test command contains an invalid scene."); + } + + PlayoutCue resolved; + try + { + resolved = validated.ResolveCue(cue); + } + catch (PlayoutRequestException exception) + { + throw new PlayoutConfigurationException( + "The isolated test command contains an invalid scene.", + exception); + } + + if (!validated.IsTestSceneAllowed(resolved)) + { + throw new PlayoutConfigurationException( + "The isolated test command contains a scene that is not allowlisted."); + } + } + } } diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Runtime/TornadoProcessProbe.cs b/src/MBN_STOCK_WEBVIEW.Playout/Runtime/TornadoProcessProbe.cs index c1507da..aeaa365 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Runtime/TornadoProcessProbe.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Runtime/TornadoProcessProbe.cs @@ -1,5 +1,8 @@ using System.Diagnostics; using System.ComponentModel; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; using System.Text.RegularExpressions; namespace MBN_STOCK_WEBVIEW.Playout.Runtime; @@ -9,13 +12,55 @@ internal sealed record TornadoProcessSnapshot( int EligibleProcessCount, int ProgramProcessCount) { + public TornadoProcessGeneration EligibleProcessGeneration { get; init; } = + TornadoProcessGeneration.Synthetic("eligible", EligibleProcessCount); + + public TornadoProcessGeneration ProgramProcessGeneration { get; init; } = + TornadoProcessGeneration.Synthetic("program", ProgramProcessCount); + + public bool IdentityInspectionSucceeded { get; init; } = true; + public bool AnyTornadoProcess => TotalProcessCount > 0; public bool HasSingleEligibleProcess => EligibleProcessCount == 1; public bool UnsafeProgramWindowMatched => ProgramProcessCount > 0; + + public bool HasSameProcessGeneration(TornadoProcessSnapshot other) => + IdentityInspectionSucceeded && + other.IdentityInspectionSucceeded && + EligibleProcessGeneration == other.EligibleProcessGeneration && + ProgramProcessGeneration == other.ProgramProcessGeneration; } +internal readonly record struct TornadoProcessGeneration(string OpaqueValue) +{ + internal static TornadoProcessGeneration FromIdentities( + string category, + IEnumerable identities) + { + var canonical = string.Join( + ";", + identities + .OrderBy(identity => identity.ProcessId) + .ThenBy(identity => identity.StartTimeUtcTicks) + .Select(identity => string.Concat( + identity.ProcessId.ToString(CultureInfo.InvariantCulture), + ":", + identity.StartTimeUtcTicks.ToString(CultureInfo.InvariantCulture)))); + var digest = SHA256.HashData(Encoding.UTF8.GetBytes($"{category}|{canonical}")); + return new TornadoProcessGeneration(Convert.ToHexString(digest)); + } + + internal static TornadoProcessGeneration Synthetic(string category, int count) => + FromIdentities( + category, + Enumerable.Range(0, Math.Max(count, 0)) + .Select(index => new TornadoProcessIdentity(index, 0))); +} + +internal readonly record struct TornadoProcessIdentity(int ProcessId, long StartTimeUtcTicks); + internal interface ITornadoProcessProbe { TornadoProcessSnapshot Capture(Regex? testWindowPattern); @@ -30,6 +75,9 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe var totalCount = 0; var eligibleCount = 0; var programCount = 0; + var identityInspectionSucceeded = true; + var eligibleIdentities = new List(); + var programIdentities = new List(); Process[] processes; try @@ -38,7 +86,10 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe } catch (Exception exception) when (IsProcessInspectionException(exception)) { - return new TornadoProcessSnapshot(0, 0, 0); + return new TornadoProcessSnapshot(0, 0, 0) + { + IdentityInspectionSucceeded = false + }; } foreach (var process in processes) @@ -50,8 +101,15 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe { processName = process.ProcessName; } + catch (Exception exception) when (IsExitedProcessInspectionException(exception)) + { + // The process vanished after enumeration and can no longer own an output. + continue; + } catch (Exception exception) when (IsProcessInspectionException(exception)) { + // An unreadable process could be Tornado/PROGRAM; never undercount it as safe. + identityInspectionSucceeded = false; continue; } @@ -61,12 +119,6 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe } totalCount++; - if (testWindowPattern is null) - { - eligibleCount++; - continue; - } - string title; try { @@ -74,31 +126,55 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe } catch (Exception exception) when (IsProcessInspectionException(exception)) { + identityInspectionSucceeded = false; continue; } - if (IsProgramTitle(title)) + var isProgram = IsProgramTitle(title); + if (isProgram) { programCount++; - continue; } - bool titleMatches; - try + var isEligible = testWindowPattern is null; + if (testWindowPattern is not null && !isProgram) { - titleMatches = testWindowPattern.IsMatch(title); - } - catch (RegexMatchTimeoutException) - { - titleMatches = false; + try + { + isEligible = HasExplicitTestMarker(title) && + testWindowPattern.IsMatch(title); + } + catch (RegexMatchTimeoutException) + { + isEligible = false; + } } - if (!titleMatches) + if (isEligible) + { + eligibleCount++; + } + + if (!isEligible && !isProgram) { continue; } - eligibleCount++; + if (!TryGetIdentity(process, out var identity)) + { + identityInspectionSucceeded = false; + continue; + } + + if (isEligible) + { + eligibleIdentities.Add(identity); + } + + if (isProgram) + { + programIdentities.Add(identity); + } } finally { @@ -106,20 +182,79 @@ internal sealed class TornadoProcessProbe : ITornadoProcessProbe } } - return new TornadoProcessSnapshot(totalCount, eligibleCount, programCount); + return new TornadoProcessSnapshot(totalCount, eligibleCount, programCount) + { + EligibleProcessGeneration = TornadoProcessGeneration.FromIdentities( + "eligible", + eligibleIdentities), + ProgramProcessGeneration = TornadoProcessGeneration.FromIdentities( + "program", + programIdentities), + IdentityInspectionSucceeded = identityInspectionSucceeded + }; } internal static bool IsTornadoProcessName(string? processName) => processName?.StartsWith(ProcessNamePrefix, StringComparison.OrdinalIgnoreCase) == true; internal static bool IsProgramTitle(string? title) => - !string.IsNullOrWhiteSpace(title) && - (title.Contains("PGM", StringComparison.OrdinalIgnoreCase) || - title.Contains("PROGRAM", StringComparison.OrdinalIgnoreCase)); + string.IsNullOrWhiteSpace(title) || + title.Equals(ProcessNamePrefix, StringComparison.OrdinalIgnoreCase) || + title.Contains("PGM", StringComparison.OrdinalIgnoreCase) || + title.Contains("PROGRAM", StringComparison.OrdinalIgnoreCase); + + internal static bool HasExplicitTestMarker(string? title) + { + if (string.IsNullOrWhiteSpace(title)) + { + return false; + } + + const string marker = "TEST"; + for (var start = 0; start <= title.Length - marker.Length; start++) + { + if (!title.AsSpan(start, marker.Length).Equals( + marker.AsSpan(), + StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var beforeIsBoundary = start == 0 || !char.IsLetterOrDigit(title[start - 1]); + var after = start + marker.Length; + var afterIsBoundary = after == title.Length || !char.IsLetterOrDigit(title[after]); + if (beforeIsBoundary && afterIsBoundary) + { + return true; + } + } + + return false; + } + + private static bool TryGetIdentity(Process process, out TornadoProcessIdentity identity) + { + try + { + identity = new TornadoProcessIdentity( + process.Id, + process.StartTime.ToUniversalTime().Ticks); + return true; + } + catch (Exception exception) when (IsProcessInspectionException(exception)) + { + identity = default; + return false; + } + } private static bool IsProcessInspectionException(Exception exception) => exception is InvalidOperationException or + ArgumentException or Win32Exception or NotSupportedException or UnauthorizedAccessException; + + private static bool IsExitedProcessInspectionException(Exception exception) => + exception is InvalidOperationException or ArgumentException; } diff --git a/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs b/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs index c82c42e..205c355 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs @@ -9,6 +9,9 @@ namespace MBN_STOCK_WEBVIEW.Playout; internal sealed class TornadoPlayoutEngine : IPlayoutEngine { + private const string ProcessChangedMessage = + "Tornado 프로세스가 변경되어 안전한 재연결이 필요합니다."; + private readonly ValidatedPlayoutOptions _options; private readonly IK3dSessionFactory _sessionFactory; private readonly IK3dRegistrationProbe _registrationProbe; @@ -22,6 +25,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine private readonly Task _monitorTask; private IK3dSession? _session; + private TornadoProcessSnapshot? _connectedProcessSnapshot; private PlayoutCue? _preparedCue; private string? _onAirSceneName; private TornadoProcessSnapshot _processSnapshot = new(0, 0, 0); @@ -30,8 +34,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine private int _reconnectAttempts; private long _sequence; private bool _connectionRequested; - private bool _outcomeUnknown; - private bool _disposed; + private volatile bool _outcomeUnknown; + private volatile bool _disposed; + private int _disposeStarted; internal TornadoPlayoutEngine( ValidatedPlayoutOptions options, @@ -110,9 +115,41 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine cancellationToken, token => TakeOutCoreAsync(scope, token)); + public async ValueTask QuarantineAsync() + { + await _gate.WaitAsync(CancellationToken.None).ConfigureAwait(false); + try + { + if (_disposed) + { + return; + } + + _outcomeUnknown = true; + _connectionRequested = false; + _connectedProcessSnapshot = null; + _preparedCue = null; + _onAirSceneName = null; + _connectionState = PlayoutConnectionState.OutcomeUnknown; + + var session = _session; + _session = null; + if (session is not null) + { + await AbandonSessionOnStaAsync(session).ConfigureAwait(false); + } + + PublishStatus("송출 결과를 확인할 수 없어 연결을 격리했습니다. 출력 상태를 직접 확인하세요."); + } + finally + { + _gate.Release(); + } + } + public async ValueTask DisposeAsync() { - if (_disposed) + if (Interlocked.CompareExchange(ref _disposeStarted, 1, 0) != 0) { return; } @@ -133,7 +170,15 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine { if (_session is not null && _dispatcher is not null && !_dispatcher.IsQuarantined) { - await ReleaseSessionAsync(CancellationToken.None).ConfigureAwait(false); + if (_onAirSceneName is not null || _outcomeUnknown) + { + _outcomeUnknown = true; + await AbandonCurrentSessionAsync().ConfigureAwait(false); + } + else + { + await ReleaseSessionAsync(CancellationToken.None).ConfigureAwait(false); + } } _preparedCue = null; @@ -152,7 +197,6 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine } _monitorCancellation.Dispose(); - _gate.Dispose(); } internal async Task PollProcessOnceAsync(CancellationToken cancellationToken = default) @@ -193,6 +237,13 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine if (!eligible) { _reconnectAttempts = 0; + if (_onAirSceneName is not null) + { + await AbandonCurrentSessionAsync().ConfigureAwait(false); + MarkOutcomeUnknown(PlayoutOperation.Disconnect); + return; + } + if (_session is not null && !_outcomeUnknown) { if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false)) @@ -212,6 +263,30 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine return; } + if (HasConnectedProcessGenerationChanged(_processSnapshot)) + { + _reconnectAttempts = 0; + if (_onAirSceneName is not null) + { + await AbandonCurrentSessionAsync().ConfigureAwait(false); + MarkOutcomeUnknown(PlayoutOperation.Disconnect); + return; + } + + if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false)) + { + MarkOutcomeUnknown(PlayoutOperation.Disconnect); + return; + } + + _preparedCue = null; + _onAirSceneName = null; + ScheduleReconnect(); + _connectionState = _connectionRequested + ? PlayoutConnectionState.Reconnecting + : PlayoutConnectionState.Disconnected; + } + if (_session is not null || !_connectionRequested || !_options.ReconnectEnabled || _outcomeUnknown || _reconnectAttempts >= _options.MaximumReconnectAttempts || _timeProvider.GetUtcNow() < _nextReconnectAt) @@ -245,14 +320,41 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine } catch (OperationCanceledException) { - return Result(operation, PlayoutResultCode.Cancelled, "송출 요청이 취소되었습니다."); + // A caller can be cancelled while another serialized command is still + // deciding whether its COM outcome is known. Wait for that decision so an + // existing OutcomeUnknown latch can never be downgraded to retryable Cancelled. + await _gate.WaitAsync(CancellationToken.None).ConfigureAwait(false); + try + { + if (_disposed) + { + return _outcomeUnknown + ? ExistingOutcomeUnknown(operation) + : Result(operation, PlayoutResultCode.Unavailable, "송출 어댑터가 종료되었습니다."); + } + + return _outcomeUnknown + ? ExistingOutcomeUnknown(operation) + : Result(operation, PlayoutResultCode.Cancelled, "송출 요청이 취소되었습니다."); + } + finally + { + _gate.Release(); + } } try { if (_disposed) { - return Result(operation, PlayoutResultCode.Unavailable, "송출 어댑터가 종료되었습니다."); + return _outcomeUnknown + ? ExistingOutcomeUnknown(operation) + : Result(operation, PlayoutResultCode.Unavailable, "송출 어댑터가 종료되었습니다."); + } + + if (_outcomeUnknown) + { + return ExistingOutcomeUnknown(operation); } return await callback(cancellationToken).ConfigureAwait(false); @@ -308,16 +410,26 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine return MarkOutcomeUnknown(PlayoutOperation.Connect); } - if (_session is not null) - { - _connectionState = PlayoutConnectionState.Connected; - PublishStatus("Tornado 테스트 송출에 연결되었습니다."); - return Result(PlayoutOperation.Connect, PlayoutResultCode.Success, "송출 연결이 준비되었습니다."); - } - _processSnapshot = CaptureProcessSnapshot(); if (!IsProcessEligible(_processSnapshot)) { + if (_onAirSceneName is not null) + { + await AbandonCurrentSessionAsync().ConfigureAwait(false); + return MarkOutcomeUnknown(PlayoutOperation.Connect); + } + + if (_session is not null) + { + if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false)) + { + return MarkOutcomeUnknown(PlayoutOperation.Disconnect); + } + + _preparedCue = null; + _onAirSceneName = null; + } + _connectionState = reconnecting ? PlayoutConnectionState.Reconnecting : PlayoutConnectionState.Disconnected; @@ -328,6 +440,32 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine ProcessUnavailableMessage(_processSnapshot)); } + if (_session is not null && !HasConnectedProcessGenerationChanged(_processSnapshot)) + { + _connectionState = PlayoutConnectionState.Connected; + PublishStatus("Tornado 테스트 송출에 연결되었습니다."); + return Result(PlayoutOperation.Connect, PlayoutResultCode.Success, "송출 연결이 준비되었습니다."); + } + + if (_session is not null) + { + if (_onAirSceneName is not null) + { + await AbandonCurrentSessionAsync().ConfigureAwait(false); + return MarkOutcomeUnknown(PlayoutOperation.Connect); + } + + if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false)) + { + return MarkOutcomeUnknown(PlayoutOperation.Disconnect); + } + + _preparedCue = null; + _onAirSceneName = null; + } + + var processSnapshotBeforeConnect = _processSnapshot; + K3dRegistrationReport registration; try { @@ -379,6 +517,30 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine static lateSession => lateSession.Dispose(), () => pendingSession?.Dispose()).ConfigureAwait(false); + _processSnapshot = CaptureProcessSnapshot(); + if (!IsProcessEligible(_processSnapshot) || + !processSnapshotBeforeConnect.HasSameProcessGeneration(_processSnapshot)) + { + if (!await ReleaseSessionAsync(CancellationToken.None).ConfigureAwait(false)) + { + return MarkOutcomeUnknown(PlayoutOperation.Disconnect); + } + + ScheduleReconnect(); + _connectionState = reconnecting + ? PlayoutConnectionState.Reconnecting + : PlayoutConnectionState.Disconnected; + var processMessage = IsProcessEligible(_processSnapshot) + ? ProcessChangedMessage + : ProcessUnavailableMessage(_processSnapshot); + PublishStatus(processMessage); + return Result( + PlayoutOperation.Connect, + PlayoutResultCode.Unavailable, + processMessage); + } + + _connectedProcessSnapshot = _processSnapshot; _connectionState = PlayoutConnectionState.Connected; _reconnectAttempts = 0; _preparedCue = null; @@ -435,6 +597,12 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine return MarkOutcomeUnknown(PlayoutOperation.Disconnect); } + if (_onAirSceneName is not null) + { + await AbandonCurrentSessionAsync().ConfigureAwait(false); + return MarkOutcomeUnknown(PlayoutOperation.Disconnect); + } + _connectionState = PlayoutConnectionState.Disconnecting; PublishStatus("Tornado 송출 연결을 해제하는 중입니다."); if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false)) @@ -569,6 +737,14 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine private async Task NextCoreAsync(PlayoutCue cue, CancellationToken cancellationToken) { + if (_onAirSceneName is null) + { + return Result( + PlayoutOperation.Next, + PlayoutResultCode.Rejected, + "NEXT는 송출 중인 장면이 있을 때만 사용할 수 있습니다."); + } + if (_options.Mode == PlayoutMode.Disabled) { return Result(PlayoutOperation.Next, PlayoutResultCode.Rejected, "송출 기능이 비활성화되어 있습니다."); @@ -689,6 +865,12 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine _processSnapshot = CaptureProcessSnapshot(); if (!IsProcessEligible(_processSnapshot)) { + if (_onAirSceneName is not null) + { + await AbandonCurrentSessionAsync().ConfigureAwait(false); + return MarkOutcomeUnknown(operation); + } + if (_session is not null) { if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false)) @@ -704,6 +886,32 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine return Result(operation, PlayoutResultCode.Unavailable, ProcessUnavailableMessage(_processSnapshot)); } + if (HasConnectedProcessGenerationChanged(_processSnapshot)) + { + if (_onAirSceneName is not null) + { + await AbandonCurrentSessionAsync().ConfigureAwait(false); + return MarkOutcomeUnknown(operation); + } + + if (!await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false)) + { + return MarkOutcomeUnknown(operation); + } + + _preparedCue = null; + _onAirSceneName = null; + ScheduleReconnect(); + _connectionState = _connectionRequested + ? PlayoutConnectionState.Reconnecting + : PlayoutConnectionState.Disconnected; + PublishStatus(ProcessChangedMessage); + return Result( + operation, + PlayoutResultCode.Unavailable, + ProcessChangedMessage); + } + if (_session is null) { return Result(operation, PlayoutResultCode.Unavailable, "Tornado 송출 연결이 필요합니다."); @@ -719,11 +927,26 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine CancellationToken cancellationToken) { var session = _session!; + var callbackStarted = 0; + var preserveActiveOutput = partialOutcomeUnknown || _onAirSceneName is not null; + void CleanupAbandonedOperation() + { + if (preserveActiveOutput) + { + session.Abandon(); + } + else + { + session.Dispose(); + } + } + try { await _dispatcher!.InvokeAsync( () => { + Volatile.Write(ref callbackStarted, 1); try { callback(session); @@ -732,7 +955,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine { if (_dispatcher.IsQuarantined) { - session.Dispose(); + CleanupAbandonedOperation(); } } @@ -740,12 +963,20 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine }, _options.OperationTimeout, cancellationToken, - _ => session.Dispose(), - () => session.Dispose()).ConfigureAwait(false); + _ => CleanupAbandonedOperation(), + CleanupAbandonedOperation).ConfigureAwait(false); return Result(operation, PlayoutResultCode.Success, SuccessMessage(operation)); } catch (OperationCanceledException) { + if (preserveActiveOutput && Volatile.Read(ref callbackStarted) != 0) + { + _session = null; + _connectedProcessSnapshot = null; + await AbandonSessionOnStaAsync(session).ConfigureAwait(false); + return MarkOutcomeUnknown(operation); + } + return Result(operation, PlayoutResultCode.Cancelled, "송출 요청이 취소되었습니다."); } catch (StaOperationTimedOutException) @@ -758,14 +989,20 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine } catch (Exception) { - _session = session; - if (partialOutcomeUnknown) + if (preserveActiveOutput) + { + _session = null; + _connectedProcessSnapshot = null; + await AbandonSessionOnStaAsync(session).ConfigureAwait(false); + return MarkOutcomeUnknown(operation); + } + + _session = session; + if (!await RecycleFailedSessionAsync().ConfigureAwait(false)) { - await ReleaseSessionAsync(CancellationToken.None).ConfigureAwait(false); return MarkOutcomeUnknown(operation); } - await RecycleFailedSessionAsync().ConfigureAwait(false); return Result( operation, PlayoutResultCode.Failed, @@ -773,15 +1010,14 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine } } - private async Task RecycleFailedSessionAsync() + private async Task RecycleFailedSessionAsync() { _preparedCue = null; _onAirSceneName = null; var released = await ReleaseSessionAsync(CancellationToken.None).ConfigureAwait(false); if (!released) { - MarkOutcomeUnknown(PlayoutOperation.Disconnect); - return; + return false; } _connectionState = _connectionRequested @@ -789,12 +1025,20 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine : PlayoutConnectionState.Faulted; ScheduleReconnect(); PublishStatus("Tornado 송출 세션을 다시 연결해야 합니다."); + return true; } private async Task ReleaseSessionAsync(CancellationToken cancellationToken) { + if (_onAirSceneName is not null) + { + await AbandonCurrentSessionAsync().ConfigureAwait(false); + return false; + } + var session = _session; _session = null; + _connectedProcessSnapshot = null; if (session is null) { return true; @@ -833,6 +1077,45 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine } } + private async Task 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) => @@ -895,20 +1178,29 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine } catch (Exception) { - return new TornadoProcessSnapshot(0, 0, 0); + return new TornadoProcessSnapshot(0, 0, 0) + { + IdentityInspectionSucceeded = false + }; } } private bool IsProcessEligible(TornadoProcessSnapshot snapshot) => _options.Mode switch { PlayoutMode.Test => + snapshot.IdentityInspectionSucceeded && snapshot.TotalProcessCount == 1 && snapshot.EligibleProcessCount == 1 && snapshot.ProgramProcessCount == 0, - PlayoutMode.Live => snapshot.AnyTornadoProcess, + PlayoutMode.Live => snapshot.IdentityInspectionSucceeded && snapshot.AnyTornadoProcess, _ => false }; + private bool HasConnectedProcessGenerationChanged(TornadoProcessSnapshot snapshot) => + _session is not null && + (_connectedProcessSnapshot is null || + !_connectedProcessSnapshot.HasSameProcessGeneration(snapshot)); + private bool CanTakeIn() => _options.Mode switch { PlayoutMode.Test => true, @@ -925,15 +1217,19 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine _outcomeUnknown = true; _connectionRequested = false; _session = null; + _connectedProcessSnapshot = null; _preparedCue = null; _onAirSceneName = null; _connectionState = PlayoutConnectionState.OutcomeUnknown; PublishStatus("송출 결과를 확인할 수 없습니다. 앱을 다시 시작하고 출력 상태를 확인하세요."); - return Result( + return ExistingOutcomeUnknown(operation); + } + + private PlayoutResult ExistingOutcomeUnknown(PlayoutOperation operation) => + Result( operation, PlayoutResultCode.OutcomeUnknown, "송출 결과를 확인할 수 없습니다. 앱을 다시 시작하고 출력 상태를 확인하세요."); - } private void ScheduleReconnect() => _nextReconnectAt = _timeProvider.GetUtcNow() + _options.ReconnectDelay; @@ -987,7 +1283,10 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine _onAirSceneName, message, _timeProvider.GetUtcNow(), - Interlocked.Increment(ref _sequence)); + Interlocked.Increment(ref _sequence)) + { + OperationTimeoutMilliseconds = (int)_options.OperationTimeout.TotalMilliseconds + }; } private PlayoutResult Result( diff --git a/tests/MBN_STOCK_WEBVIEW.Core.Tests/PlayoutBridgeProtocolTests.cs b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PlayoutBridgeProtocolTests.cs new file mode 100644 index 0000000..d5c72f8 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Core.Tests/PlayoutBridgeProtocolTests.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using MBN_STOCK_WEBVIEW.Core.Playout; + +namespace MBN_STOCK_WEBVIEW.Core.Tests; + +public sealed class PlayoutBridgeProtocolTests +{ + [Theory] + [InlineData("https://app.mbn.local/index.html", true)] + [InlineData("HTTPS://APP.MBN.LOCAL/path", true)] + [InlineData("http://app.mbn.local/index.html", false)] + [InlineData("https://app.mbn.local:444/index.html", false)] + [InlineData("https://app.mbn.local@evil.example/index.html", false)] + [InlineData("https://evil.example/index.html", false)] + [InlineData("about:blank", false)] + [InlineData("not-a-uri", false)] + public void IsTrustedSource_RequiresTheExactHttpsDefaultPortOrigin( + string source, + bool expected) + { + Assert.Equal( + expected, + PlayoutBridgeProtocol.IsTrustedSource(source, "app.mbn.local")); + } + + [Theory] + [InlineData("prepare", true)] + [InlineData("next", true)] + [InlineData("take-in", false)] + [InlineData("take-out", false)] + public void ParseCommand_AcceptsOnlyTheCommandSpecificCueShape( + string command, + bool hasCue) + { + var cue = hasCue ? ",\"cue\":{\"code\":\"5001\"}" : string.Empty; + using var document = JsonDocument.Parse( + $"{{\"requestId\":\"REQ_1\",\"command\":\"{command}\"{cue}}}"); + + var result = PlayoutBridgeProtocol.ParseCommand(document.RootElement); + + Assert.True(result.IsValid); + Assert.Equal("REQ_1", result.Request.RequestId); + Assert.Equal(command, result.Request.Command); + if (hasCue) + { + Assert.Equal("5001", result.Request.Cue?.SceneName); + Assert.Equal("5001.t2s", result.Request.Cue?.SceneFile); + } + else + { + Assert.Null(result.Request.Cue); + } + } + + [Theory] + [InlineData("{\"requestId\":\"REQ\",\"command\":\"prepare\",\"cue\":{\"code\":\"../5001\"}}")] + [InlineData("{\"requestId\":\"REQ\",\"command\":\"prepare\",\"cue\":{\"code\":\"5001\",\"title\":\"ignored\"}}")] + [InlineData("{\"requestId\":\"REQ\",\"command\":\"take-in\",\"cue\":{\"code\":\"5001\"}}")] + [InlineData("{\"requestId\":\"REQ\",\"command\":\"next\"}")] + [InlineData("{\"requestId\":\"REQ\",\"command\":\"unknown\"}")] + [InlineData("{\"requestId\":\"REQ\",\"requestId\":\"REQ2\",\"command\":\"take-out\"}")] + [InlineData("{\"requestId\":\"REQ\",\"command\":\"take-out\",\"liveAuthorization\":true}")] + public void ParseCommand_RejectsUnknownDuplicateOrUnsafeInput(string json) + { + using var document = JsonDocument.Parse(json); + + var result = PlayoutBridgeProtocol.ParseCommand(document.RootElement); + + Assert.False(result.IsValid); + Assert.NotEmpty(result.Error); + Assert.Null(result.Request.Cue); + } + + [Fact] + public void ParseCommand_DoesNotReflectAnUnsafeRequestIdentifier() + { + const string unsafeRequestId = "../../private-value"; + using var document = JsonDocument.Parse( + $"{{\"requestId\":\"{unsafeRequestId}\",\"command\":\"take-out\"}}"); + + var result = PlayoutBridgeProtocol.ParseCommand(document.RootElement); + + Assert.False(result.IsValid); + Assert.Equal(string.Empty, result.Request.RequestId); + Assert.DoesNotContain(unsafeRequestId, result.Error, StringComparison.Ordinal); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DryRunPlayoutEngineTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DryRunPlayoutEngineTests.cs index 3185c17..be3de6a 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DryRunPlayoutEngineTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DryRunPlayoutEngineTests.cs @@ -99,6 +99,31 @@ public sealed class DryRunPlayoutEngineTests Assert.Null(engine.Status.OnAirSceneName); } + [Fact] + public async Task DryRun_NextAfterPrepareButBeforeTakeInIsRejected() + { + await using var engine = CreateEngine(PlayoutMode.DryRun); + var idleNext = await engine.NextAsync( + Cue("next-scene"), + CancellationToken.None); + + Assert.Equal(PlayoutResultCode.Rejected, idleNext.Code); + Assert.Null(engine.Status.PreparedSceneName); + Assert.Null(engine.Status.OnAirSceneName); + + Assert.True((await engine.PrepareAsync( + Cue("first-scene"), + CancellationToken.None)).IsSuccess); + + var next = await engine.NextAsync( + Cue("next-scene"), + CancellationToken.None); + + Assert.Equal(PlayoutResultCode.Rejected, next.Code); + Assert.Equal("first-scene", engine.Status.PreparedSceneName); + Assert.Null(engine.Status.OnAirSceneName); + } + [Fact] public async Task CancelledDryRunCommand_DoesNotChangeStatus() { diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs index 6d34425..b7a1e42 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs @@ -21,14 +21,27 @@ public sealed class DynamicK3dSessionTests new FakeScene(log), connectResult); var activator = new FakeActivator(log, engine); + var releaser = new FakeReleaser( + log, + (engine, "Engine"), + (activator.EventHandler, "EventHandler")); await using var dispatcher = new StaDispatcher(capacity: 1); var exception = await Assert.ThrowsAnyAsync( () => dispatcher.InvokeAsync( () => { - using var session = new DynamicK3dSession(activator); - session.Connect(options); + using var session = new DynamicK3dSession(activator, releaser); + try + { + session.Connect(options); + } + catch + { + Assert.False(session.IsConnected); + throw; + } + return true; }, TimeSpan.FromSeconds(5), @@ -38,6 +51,114 @@ public sealed class DynamicK3dSessionTests Assert.DoesNotContain(scenes.Path, exception.Message, StringComparison.OrdinalIgnoreCase); Assert.DoesNotContain(connectResult.ToString(), exception.Message, StringComparison.Ordinal); Assert.DoesNotContain("GetScenePlayer", log.Names); + Assert.Equal( + new[] + { + $"Create:{K3dComConstants.KaEventHandlerClassGuid:B}", + $"Create:{K3dComConstants.KaEngineClassGuid:B}", + "KTAPConnect:1:127.0.0.1:30001:0", + "Disconnect", + "Release:Engine", + "Release:EventHandler" + }, + log.Names); + Assert.Equal(1, log.Names.Count(name => name == "Disconnect")); + Assert.Single(log.ThreadIds.Distinct()); + Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state)); + } + + [Fact] + public async Task Connect_WhenPlayerInitializationFails_DisconnectsOnceAndPreservesOriginalFailure() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path)); + var log = new FakeComLog(); + var originalFailure = new InvalidOperationException("fake original player failure"); + var engine = new FakeEngine( + log, + new FakePlayer(log), + new FakeScene(log), + playerFailure: originalFailure, + disconnectFailure: new InvalidOperationException("fake cleanup failure")); + var activator = new FakeActivator(log, engine); + var releaser = new FakeReleaser( + log, + (engine, "Engine"), + (activator.EventHandler, "EventHandler")); + await using var dispatcher = new StaDispatcher(capacity: 1); + + var exception = await Assert.ThrowsAsync( + () => dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession(activator, releaser); + session.Connect(options); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None)); + + Assert.Same(originalFailure, exception); + Assert.Equal( + new[] + { + $"Create:{K3dComConstants.KaEventHandlerClassGuid:B}", + $"Create:{K3dComConstants.KaEngineClassGuid:B}", + "KTAPConnect:1:127.0.0.1:30001:0", + "GetScenePlayerOnChannel:9", + "Disconnect", + "Release:Engine", + "Release:EventHandler" + }, + log.Names); + Assert.Equal(1, log.Names.Count(name => name == "Disconnect")); + Assert.Single(log.ThreadIds.Distinct()); + Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state)); + } + + [Fact] + public async Task Connect_WhenKtapInvocationThrows_AttemptsDisconnectAndPreservesOriginalFailure() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path)); + var log = new FakeComLog(); + var originalFailure = new InvalidOperationException("fake original KTAP failure"); + var engine = new FakeEngine( + log, + new FakePlayer(log), + new FakeScene(log), + connectFailure: originalFailure); + var activator = new FakeActivator(log, engine); + var releaser = new FakeReleaser( + log, + (engine, "Engine"), + (activator.EventHandler, "EventHandler")); + await using var dispatcher = new StaDispatcher(capacity: 1); + + var exception = await Assert.ThrowsAsync( + () => dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession(activator, releaser); + session.Connect(options); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None)); + + Assert.Same(originalFailure, exception); + Assert.Equal( + new[] + { + $"Create:{K3dComConstants.KaEventHandlerClassGuid:B}", + $"Create:{K3dComConstants.KaEngineClassGuid:B}", + "KTAPConnect:1:127.0.0.1:30001:0", + "Disconnect", + "Release:Engine", + "Release:EventHandler" + }, + log.Names); + Assert.Equal(1, log.Names.Count(name => name == "Disconnect")); } [Theory] @@ -115,6 +236,40 @@ public sealed class DynamicK3dSessionTests Assert.Equal(typeof(object), create.ReturnType); } + [Fact] + public async Task Abandon_ReleasesEveryComReferenceWithoutDisconnect() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path)); + var log = new FakeComLog(); + var player = new FakePlayer(log); + var scene = new FakeScene(log); + var engine = new FakeEngine(log, player, scene); + var activator = new FakeActivator(log, engine); + var releaser = new FakeReleaser( + log, + (player, "Player"), + (engine, "Engine"), + (activator.EventHandler, "EventHandler")); + await using var dispatcher = new StaDispatcher(capacity: 1); + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession(activator, releaser); + session.Connect(options); + session.Abandon(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.DoesNotContain("Disconnect", log.Names); + Assert.Equal(1, log.Names.Count(name => name == "Release:Player")); + Assert.Equal(1, log.Names.Count(name => name == "Release:Engine")); + Assert.Equal(1, log.Names.Count(name => name == "Release:EventHandler")); + } + private static PlayoutOptions TestOptions(string sceneDirectory) => new() { Mode = PlayoutMode.Test, @@ -156,6 +311,8 @@ public sealed class DynamicK3dSessionTests _engine = engine; } + public object EventHandler => _eventHandler; + public object Create(Guid classId) { _log.Add($"Create:{classId:B}"); @@ -174,17 +331,26 @@ public sealed class DynamicK3dSessionTests private readonly FakePlayer _player; private readonly FakeScene _scene; private readonly int _connectResult; + private readonly Exception? _playerFailure; + private readonly Exception? _disconnectFailure; + private readonly Exception? _connectFailure; public FakeEngine( FakeComLog log, FakePlayer player, FakeScene scene, - int connectResult = 1) + int connectResult = 1, + Exception? playerFailure = null, + Exception? disconnectFailure = null, + Exception? connectFailure = null) { _log = log; _player = player; _scene = scene; _connectResult = connectResult; + _playerFailure = playerFailure; + _disconnectFailure = disconnectFailure; + _connectFailure = connectFailure; } public int KTAPConnect( @@ -196,12 +362,22 @@ public sealed class DynamicK3dSessionTests { Assert.NotNull(eventHandler); _log.Add($"KTAPConnect:{tcpMode}:{host}:{hostPort}:{clientPort}"); + if (_connectFailure is not null) + { + throw _connectFailure; + } + return _connectResult; } public object GetScenePlayerOnChannel(int channel) { _log.Add($"GetScenePlayerOnChannel:{channel}"); + if (_playerFailure is not null) + { + throw _playerFailure; + } + return _player; } @@ -216,7 +392,34 @@ public sealed class DynamicK3dSessionTests public void EndTransaction() => _log.Add("EndTransaction"); - public void Disconnect() => _log.Add("Disconnect"); + public void Disconnect() + { + _log.Add("Disconnect"); + if (_disconnectFailure is not null) + { + throw _disconnectFailure; + } + } + } + + private sealed class FakeReleaser : ILateBoundComObjectReleaser + { + private readonly FakeComLog _log; + private readonly (object Value, string Name)[] _objects; + + public FakeReleaser( + FakeComLog log, + params (object Value, string Name)[] objects) + { + _log = log; + _objects = objects; + } + + public void Release(object value) + { + var match = Assert.Single(_objects, item => ReferenceEquals(item.Value, value)); + _log.Add($"Release:{match.Name}"); + } } public sealed class FakePlayer diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/IsolatedTestCommandTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/IsolatedTestCommandTests.cs new file mode 100644 index 0000000..97f4f51 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/IsolatedTestCommandTests.cs @@ -0,0 +1,564 @@ +using System.Collections; +using System.Text.Json; +using System.Text.Json.Serialization; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Playout.Configuration; +using MBN_STOCK_WEBVIEW.PlayoutSmoke; + +namespace MBN_STOCK_WEBVIEW.Playout.Tests; + +public sealed class IsolatedTestCommandTests : IDisposable +{ + private readonly TemporarySceneDirectory _scenes = + TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); + + [Fact] + public void Preflight_ValidSequenceBuildsExactBasenameCuesAndDisablesReconnect() + { + using var environment = ClearedPlayoutEnvironment(); + using var config = CreateConfig(ValidOptions()); + + var plan = IsolatedTestCommandPreflight.Create(Invocation( + SmokeCommandKind.TestSequence, + config.Path)); + + Assert.Equal("5001.t2s", plan.PrepareCue!.SceneFile); + Assert.Equal("5001", plan.PrepareCue.SceneName); + Assert.Equal("5006.t2s", plan.NextCue!.SceneFile); + Assert.Equal("5006", plan.NextCue.SceneName); + Assert.Equal(TimeSpan.FromSeconds(1), plan.ObservationDuration); + Assert.False(plan.Options.ReconnectEnabled); + Assert.Equal(0, plan.Options.MaximumReconnectAttempts); + } + + [Fact] + public void Preflight_TestConnectReusesTestConfigurationGatesWithoutScenes() + { + using var environment = ClearedPlayoutEnvironment(); + using var config = CreateConfig(ValidOptions()); + + var plan = IsolatedTestCommandPreflight.Create(new SmokeCommandInvocation( + SmokeCommandKind.TestConnect, + config.Path)); + + Assert.Null(plan.PrepareCue); + Assert.Null(plan.NextCue); + Assert.Equal(TimeSpan.Zero, plan.ObservationDuration); + Assert.Equal(PlayoutMode.Test, plan.Options.Mode); + } + + [Fact] + public void Preflight_RejectsAnyPlayoutEnvironmentOverrideBeforeReadingConfiguration() + { + using var environment = ClearedPlayoutEnvironment() + .Set("MBN_STOCK_PLAYOUT_FUTURE_OVERRIDE", "sensitive-value"); + + var exception = Assert.Throws(() => + IsolatedTestCommandPreflight.Create(Invocation( + SmokeCommandKind.TestSequence, + "C:\\sensitive\\missing.local.json"))); + + Assert.Equal("environment-override-present", exception.ErrorCode); + Assert.DoesNotContain("sensitive", exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Preflight_RejectsRelativeOrMissingConfigWithoutReflectingPath() + { + using var environment = ClearedPlayoutEnvironment(); + const string unsafePath = ".\\private\\playout.local.json"; + + var exception = Assert.Throws(() => + IsolatedTestCommandPreflight.Create(Invocation( + SmokeCommandKind.TestSequence, + unsafePath))); + + Assert.Equal("config-file-rejected", exception.ErrorCode); + Assert.DoesNotContain(unsafePath, exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData(PlayoutMode.DryRun, false)] + [InlineData(PlayoutMode.Live, false)] + [InlineData(PlayoutMode.Test, true)] + public void Preflight_RejectsAnythingThatCouldEnableLiveOutput( + PlayoutMode mode, + bool trustedLiveOutputEnabled) + { + using var environment = ClearedPlayoutEnvironment(); + var options = ValidOptions(); + options.Mode = mode; + options.TrustedLiveOutputEnabled = trustedLiveOutputEnabled; + using var config = CreateConfig(options); + + var exception = Assert.Throws(() => + IsolatedTestCommandPreflight.Create(Invocation( + SmokeCommandKind.TestSequence, + config.Path))); + + Assert.Equal("test-mode-required", exception.ErrorCode); + } + + [Fact] + public void Preflight_ReusesAllowlistAndExistingFileValidationBeforeEngineCreation() + { + using var environment = ClearedPlayoutEnvironment(); + var options = ValidOptions(); + options.TestSceneAllowlist = ["5001"]; + using var config = CreateConfig(options); + + var exception = Assert.Throws(() => + IsolatedTestCommandPreflight.Create(Invocation( + SmokeCommandKind.TestSequence, + config.Path))); + + Assert.Equal("safety-gate-rejected", exception.ErrorCode); + Assert.DoesNotContain("5006", exception.Message, StringComparison.Ordinal); + Assert.DoesNotContain(_scenes.Path, exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Executor_SuccessRunsExactSequenceWithObservationAndAllScopeTakeOut() + { + var engine = new RecordingPlayoutEngine(); + var delays = new List(); + + var report = await IsolatedTestCommandExecutor.ExecuteAsync( + ExecutionPlan(SmokeCommandKind.TestSequence), + _ => engine, + (delay, _) => + { + delays.Add(delay); + return Task.CompletedTask; + }); + + Assert.Equal( + [ + "Connect", + "Prepare", + "TakeIn", + "Next", + "TakeOut:All", + "Disconnect", + "Dispose" + ], engine.Calls); + Assert.Equal([TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)], delays); + Assert.True(report.Completed); + Assert.True(report.ComActivationAttempted); + Assert.False(report.OutcomeUnknown); + Assert.Equal(6, report.Steps.Count); + Assert.True(report.Cleanup.AdapterDisposed); + } + + [Fact] + public async Task Executor_TestConnectNeverIssuesSceneOrOutputMutation() + { + var engine = new RecordingPlayoutEngine(); + + var report = await IsolatedTestCommandExecutor.ExecuteAsync( + ExecutionPlan(SmokeCommandKind.TestConnect), + _ => engine, + (_, _) => throw new InvalidOperationException("No observation is allowed.")); + + Assert.Equal(["Connect", "Disconnect", "Dispose"], engine.Calls); + Assert.True(report.Completed); + Assert.True(report.ComActivationAttempted); + } + + [Fact] + public async Task Executor_ConnectUnavailableDoesNotGuessWhetherComWasAttempted() + { + var engine = new RecordingPlayoutEngine() + .Return(PlayoutOperation.Connect, PlayoutResultCode.Unavailable); + + var report = await IsolatedTestCommandExecutor.ExecuteAsync( + ExecutionPlan(SmokeCommandKind.TestConnect), + _ => engine, + (_, _) => Task.CompletedTask); + + Assert.Null(report.ComActivationAttempted); + Assert.Equal(["Connect", "Disconnect", "Dispose"], engine.Calls); + } + + [Fact] + public async Task Executor_UnknownConnectOutcomeReportsUnknownActivationAndSkipsDisconnect() + { + var engine = new RecordingPlayoutEngine() + .Return(PlayoutOperation.Connect, PlayoutResultCode.OutcomeUnknown); + + var report = await IsolatedTestCommandExecutor.ExecuteAsync( + ExecutionPlan(SmokeCommandKind.TestConnect), + _ => engine, + (_, _) => Task.CompletedTask); + + Assert.Null(report.ComActivationAttempted); + Assert.True(report.OutcomeUnknown); + Assert.Equal(["Connect", "Quarantine", "Dispose"], engine.Calls); + Assert.True(report.Cleanup.QuarantineCompleted); + } + + [Fact] + public async Task Executor_EngineCreationFailureReportsNoComActivationAttempt() + { + var report = await IsolatedTestCommandExecutor.ExecuteAsync( + ExecutionPlan(SmokeCommandKind.TestConnect), + _ => throw new InvalidOperationException("sensitive factory failure"), + (_, _) => Task.CompletedTask); + + Assert.False(report.ConnectRequestIssued); + Assert.False(report.ComActivationAttempted); + Assert.False(report.Completed); + Assert.False(report.OutcomeUnknown); + } + + [Fact] + public async Task Executor_IntermediateFailureStopsPlaybackAndPerformsDisconnectCleanup() + { + var engine = new RecordingPlayoutEngine() + .Return(PlayoutOperation.Prepare, PlayoutResultCode.Failed); + + var report = await IsolatedTestCommandExecutor.ExecuteAsync( + ExecutionPlan(SmokeCommandKind.TestSequence), + _ => engine, + (_, _) => Task.CompletedTask); + + Assert.Equal(["Connect", "Prepare", "Disconnect", "Dispose"], engine.Calls); + Assert.False(report.Completed); + Assert.Equal("Prepare", report.StoppedAfter); + Assert.Contains(report.Steps, step => + step.Phase == "cleanup" && step.Operation == "Disconnect"); + } + + [Fact] + public async Task Executor_OutcomeUnknownNeverAttemptsAutomaticDisconnectOrLaterCommand() + { + var engine = new RecordingPlayoutEngine() + .Return(PlayoutOperation.TakeIn, PlayoutResultCode.OutcomeUnknown); + + var report = await IsolatedTestCommandExecutor.ExecuteAsync( + ExecutionPlan(SmokeCommandKind.TestSequence), + _ => engine, + (_, _) => Task.CompletedTask); + + Assert.Equal(["Connect", "Prepare", "TakeIn", "Quarantine", "Dispose"], engine.Calls); + Assert.True(report.OutcomeUnknown); + Assert.True(report.OutputMayBeActive); + Assert.False(report.Cleanup.DisconnectAttempted); + Assert.True(report.Cleanup.AdapterDisposed); + } + + [Fact] + public async Task Executor_QuarantineFailureSkipsDisposeToAvoidImplicitDisconnect() + { + var engine = new RecordingPlayoutEngine + { + ThrowOnQuarantine = true + }.Return(PlayoutOperation.TakeIn, PlayoutResultCode.OutcomeUnknown); + + var report = await IsolatedTestCommandExecutor.ExecuteAsync( + ExecutionPlan(SmokeCommandKind.TestSequence), + _ => engine, + (_, _) => Task.CompletedTask); + + Assert.Equal(["Connect", "Prepare", "TakeIn", "Quarantine"], engine.Calls); + Assert.True(report.Cleanup.QuarantineAttempted); + Assert.False(report.Cleanup.QuarantineCompleted); + Assert.False(report.Cleanup.AdapterDisposed); + Assert.False(report.Cleanup.DisconnectAttempted); + } + + [Fact] + public async Task Executor_CancelledObservationStopsBeforeNextWithoutMarkingComOutcomeUnknown() + { + using var cancellation = new CancellationTokenSource(); + var engine = new RecordingPlayoutEngine(); + + var report = await IsolatedTestCommandExecutor.ExecuteAsync( + ExecutionPlan(SmokeCommandKind.TestSequence), + _ => engine, + (_, token) => + { + cancellation.Cancel(); + return Task.FromCanceled(token); + }, + cancellation.Token); + + Assert.Equal( + ["Connect", "Prepare", "TakeIn", "TakeOut:All", "Disconnect", "Dispose"], + engine.Calls); + Assert.Equal("Observation", report.StoppedAfter); + Assert.False(report.OutcomeUnknown); + Assert.False(report.OutputMayBeActive); + Assert.False(report.Completed); + Assert.Contains(report.Steps, step => + step.Phase == "cleanup" && step.Operation == "TakeOut"); + } + + [Fact] + public async Task Executor_UnknownTakeOutCleanupSkipsAutomaticDisconnect() + { + using var cancellation = new CancellationTokenSource(); + var engine = new RecordingPlayoutEngine() + .Return(PlayoutOperation.TakeOut, PlayoutResultCode.OutcomeUnknown); + + var report = await IsolatedTestCommandExecutor.ExecuteAsync( + ExecutionPlan(SmokeCommandKind.TestSequence), + _ => engine, + (_, token) => + { + cancellation.Cancel(); + return Task.FromCanceled(token); + }, + cancellation.Token); + + Assert.Equal( + ["Connect", "Prepare", "TakeIn", "TakeOut:All", "Quarantine", "Dispose"], + engine.Calls); + Assert.True(report.OutcomeUnknown); + Assert.True(report.OutputMayBeActive); + Assert.False(report.Cleanup.DisconnectAttempted); + } + + [Theory] + [InlineData(PlayoutResultCode.Failed)] + [InlineData(PlayoutResultCode.Unavailable)] + [InlineData(PlayoutResultCode.Cancelled)] + [InlineData(PlayoutResultCode.Rejected)] + public async Task Executor_NonSuccessfulCleanupTakeOutReportsPossibleActiveOutput( + PlayoutResultCode takeOutCode) + { + using var cancellation = new CancellationTokenSource(); + var engine = new RecordingPlayoutEngine() + .Return(PlayoutOperation.TakeOut, takeOutCode); + + var report = await IsolatedTestCommandExecutor.ExecuteAsync( + ExecutionPlan(SmokeCommandKind.TestSequence), + _ => engine, + (_, token) => + { + cancellation.Cancel(); + return Task.FromCanceled(token); + }, + cancellation.Token); + + Assert.Equal( + ["Connect", "Prepare", "TakeIn", "TakeOut:All", "Quarantine", "Dispose"], + engine.Calls); + Assert.True(report.OutcomeUnknown); + Assert.True(report.OutputMayBeActive); + Assert.False(report.Cleanup.DisconnectAttempted); + } + + [Theory] + [InlineData(PlayoutResultCode.Failed)] + [InlineData(PlayoutResultCode.Unavailable)] + [InlineData(PlayoutResultCode.Cancelled)] + [InlineData(PlayoutResultCode.Rejected)] + public async Task Executor_NonSuccessfulSequenceTakeOutReportsPossibleActiveOutput( + PlayoutResultCode takeOutCode) + { + var engine = new RecordingPlayoutEngine() + .Return(PlayoutOperation.TakeOut, takeOutCode); + + var report = await IsolatedTestCommandExecutor.ExecuteAsync( + ExecutionPlan(SmokeCommandKind.TestSequence), + _ => engine, + (_, _) => Task.CompletedTask); + + Assert.Equal( + ["Connect", "Prepare", "TakeIn", "Next", "TakeOut:All", "Quarantine", "Dispose"], + engine.Calls); + Assert.True(report.OutcomeUnknown); + Assert.True(report.OutputMayBeActive); + Assert.False(report.Cleanup.DisconnectAttempted); + } + + [Fact] + public async Task Executor_ObservationFailureStillPerformsTakeOutCleanup() + { + var engine = new RecordingPlayoutEngine(); + + var report = await IsolatedTestCommandExecutor.ExecuteAsync( + ExecutionPlan(SmokeCommandKind.TestSequence), + _ => engine, + (_, _) => Task.FromException(new InvalidOperationException("fake observation failure"))); + + Assert.Equal( + ["Connect", "Prepare", "TakeIn", "TakeOut:All", "Disconnect", "Dispose"], + engine.Calls); + Assert.Equal("Observation", report.StoppedAfter); + Assert.False(report.OutcomeUnknown); + Assert.False(report.OutputMayBeActive); + Assert.True(report.Cleanup.DisconnectAttempted); + } + + [Fact] + public async Task Executor_ReportDoesNotContainEngineMessagesPathsOrSceneCodes() + { + var engine = new RecordingPlayoutEngine("C:\\private\\5001.t2s HRESULT 0x80004005"); + + var report = await IsolatedTestCommandExecutor.ExecuteAsync( + ExecutionPlan(SmokeCommandKind.TestSequence), + _ => engine, + (_, _) => Task.CompletedTask); + var json = JsonSerializer.Serialize(report); + + Assert.DoesNotContain("private", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("5001", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("HRESULT", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("0x80004005", json, StringComparison.OrdinalIgnoreCase); + } + + public void Dispose() => _scenes.Dispose(); + + private IsolatedTestCommandPlan ExecutionPlan(SmokeCommandKind kind) => new( + kind, + ValidOptions(), + kind == SmokeCommandKind.TestSequence ? new PlayoutCue("5001.t2s", "5001") : null, + kind == SmokeCommandKind.TestSequence ? new PlayoutCue("5006.t2s", "5006") : null, + kind == SmokeCommandKind.TestSequence ? TimeSpan.FromSeconds(1) : TimeSpan.Zero); + + private SmokeCommandInvocation Invocation(SmokeCommandKind kind, string configPath) => new( + kind, + configPath, + "5001", + "5006", + 1000); + + private PlayoutOptions ValidOptions() => new() + { + Mode = PlayoutMode.Test, + Host = "127.0.0.1", + Port = 30001, + TcpMode = 1, + ClientPort = 0, + OutputChannel = 9, + LayoutIndex = 10, + SceneDirectory = _scenes.Path, + TestProcessWindowTitlePattern = "^Tornado2 TEST$", + TestSceneAllowlist = ["5001", "5006"], + TrustedLiveOutputEnabled = false, + ReconnectEnabled = true, + MaximumReconnectAttempts = 3 + }; + + private static TemporaryJsonFile CreateConfig(PlayoutOptions options) + { + var serializerOptions = new JsonSerializerOptions(); + serializerOptions.Converters.Add(new JsonStringEnumConverter()); + return TemporaryJsonFile.Create(JsonSerializer.Serialize(options, serializerOptions)); + } + + private static EnvironmentScope ClearedPlayoutEnvironment() + { + var result = new EnvironmentScope(); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + if (entry.Key is string name && + name.StartsWith( + IsolatedTestCommandPreflight.EnvironmentVariablePrefix, + StringComparison.OrdinalIgnoreCase)) + { + result.Set(name, null); + } + } + + return result; + } + + private sealed class RecordingPlayoutEngine : IPlayoutEngine + { + private readonly Dictionary _codes = []; + private readonly string _message; + + public RecordingPlayoutEngine(string message = "safe") + { + _message = message; + Status = new PlayoutStatus( + PlayoutMode.Test, + PlayoutConnectionState.Disconnected, + false, + true, + false, + false, + null, + null, + "safe", + DateTimeOffset.UnixEpoch, + 1); + } + + public List Calls { get; } = []; + + public bool ThrowOnQuarantine { get; init; } + + public PlayoutStatus Status { get; } + + public event EventHandler? StatusChanged + { + add { } + remove { } + } + + public RecordingPlayoutEngine Return( + PlayoutOperation operation, + PlayoutResultCode code) + { + _codes[operation] = code; + return this; + } + + public Task ConnectAsync(CancellationToken cancellationToken = default) => + Result(PlayoutOperation.Connect, "Connect"); + + public Task DisconnectAsync(CancellationToken cancellationToken = default) => + Result(PlayoutOperation.Disconnect, "Disconnect"); + + public Task PrepareAsync( + PlayoutCue cue, + CancellationToken cancellationToken = default) => + Result(PlayoutOperation.Prepare, "Prepare"); + + public Task TakeInAsync(CancellationToken cancellationToken = default) => + Result(PlayoutOperation.TakeIn, "TakeIn"); + + public Task NextAsync( + PlayoutCue cue, + CancellationToken cancellationToken = default) => + Result(PlayoutOperation.Next, "Next"); + + public Task TakeOutAsync( + PlayoutTakeOutScope scope, + CancellationToken cancellationToken = default) => + Result(PlayoutOperation.TakeOut, $"TakeOut:{scope}"); + + public ValueTask DisposeAsync() + { + Calls.Add("Dispose"); + return ValueTask.CompletedTask; + } + + public ValueTask QuarantineAsync() + { + Calls.Add("Quarantine"); + if (ThrowOnQuarantine) + { + throw new InvalidOperationException("fake quarantine failure"); + } + + return ValueTask.CompletedTask; + } + + private Task Result(PlayoutOperation operation, string call) + { + Calls.Add(call); + var code = _codes.GetValueOrDefault(operation, PlayoutResultCode.Success); + return Task.FromResult(new PlayoutResult( + operation, + code, + false, + _message, + DateTimeOffset.UnixEpoch)); + } + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/K3dRegistrationProbeTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/K3dRegistrationProbeTests.cs index 1609f98..9d1702e 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/K3dRegistrationProbeTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/K3dRegistrationProbeTests.cs @@ -118,6 +118,75 @@ public sealed class K3dRegistrationProbeTests AssertSafe(report); } + [Fact] + public void Probe_WhenProcessOrTypeLibraryIsNotX64_FailsClosed() + { + var snapshot = ReadySnapshot() with + { + Is64BitProcess = false, + Win64TypeLibraryPath = null + }; + var probe = CreateProbe(snapshot, ReadyBinaries()); + + var report = probe.Probe(); + + Assert.False(report.IsReady); + Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.ProcessIsNot64Bit)); + Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.TypeLibraryMissing)); + Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.Win64BinaryMissing)); + AssertSafe(report); + } + + [Fact] + public void Probe_WhenClassThreadingAndTypeLibraryDiffer_FailsClosed() + { + var snapshot = ReadySnapshot() with + { + Engine = ReadySnapshot().Engine with + { + ThreadingModel = "Both", + TypeLibraryId = K3dComConstants.KaEventHandlerClassId + }, + EventHandler = ReadySnapshot().EventHandler with + { + ThreadingModel = "Free", + TypeLibraryId = K3dComConstants.KaEngineClassId + } + }; + var probe = CreateProbe(snapshot, ReadyBinaries()); + + var report = probe.Probe(); + + Assert.False(report.IsReady); + Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EngineThreadingModelMismatch)); + Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EngineTypeLibraryMismatch)); + Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EventHandlerThreadingModelMismatch)); + Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EventHandlerTypeLibraryMismatch)); + Assert.False(report.IsEngineClassReady); + Assert.False(report.IsEventHandlerClassReady); + AssertSafe(report); + } + + [Fact] + public void Probe_WhenEitherClassRegistrationIsMissing_FailsClosed() + { + var snapshot = ReadySnapshot() with + { + Engine = ReadySnapshot().Engine with { ClassKeyPresent = false }, + EventHandler = ReadySnapshot().EventHandler with { ClassKeyPresent = false } + }; + var probe = CreateProbe(snapshot, ReadyBinaries()); + + var report = probe.Probe(); + + Assert.False(report.IsReady); + Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EngineClassMissing)); + Assert.True(report.Issues.HasFlag(K3dRegistrationIssue.EventHandlerClassMissing)); + Assert.False(report.IsEngineClassReady); + Assert.False(report.IsEventHandlerClassReady); + AssertSafe(report); + } + private static K3dRegistrationProbe CreateProbe( K3dRegistrySnapshot snapshot, FakeBinaryInspector binaries) => diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/MBN_STOCK_WEBVIEW.Playout.Tests.csproj b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/MBN_STOCK_WEBVIEW.Playout.Tests.csproj index a891a43..1b849cf 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/MBN_STOCK_WEBVIEW.Playout.Tests.csproj +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/MBN_STOCK_WEBVIEW.Playout.Tests.csproj @@ -23,6 +23,7 @@ + diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs index 2510fd5..1e549e4 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs @@ -53,6 +53,10 @@ internal sealed class FakeK3dSession : IK3dSession public Action? TakeOutAction { get; set; } + public Action? AbandonAction { get; set; } + + public Action? CallObserver { get; set; } + public void Connect(ValidatedPlayoutOptions options) { Record("Connect"); @@ -85,6 +89,16 @@ internal sealed class FakeK3dSession : IK3dSession TakeOutAction?.Invoke(layoutIndex, scope); } + public void Abandon() + { + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + Record("Abandon"); + AbandonAction?.Invoke(); + IsConnected = false; + } + } + public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) == 0) @@ -99,6 +113,7 @@ internal sealed class FakeK3dSession : IK3dSession Calls.Enqueue(name); ThreadIds.Enqueue(Environment.CurrentManagedThreadId); ApartmentStates.Enqueue(Thread.CurrentThread.GetApartmentState()); + CallObserver?.Invoke(name); } } diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs index d758c7b..a563f1b 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutOptionsLoaderTests.cs @@ -117,6 +117,26 @@ public sealed class PlayoutOptionsLoaderTests Assert.True(options.TrustedLiveOutputEnabled); } + [Fact] + public void LoadFileOnly_IgnoresEnvironmentForReproducibleExplicitDiagnostics() + { + using var environment = ClearedEnvironment() + .Set("MBN_STOCK_PLAYOUT_MODE", "Live") + .Set("MBN_STOCK_PLAYOUT_HOST", "sensitive-host"); + using var file = TemporaryJsonFile.Create( + """ + { + "mode": "Test", + "host": "127.0.0.1" + } + """); + + var options = PlayoutOptionsLoader.LoadFileOnly(file.Path); + + Assert.Equal(PlayoutMode.Test, options.Mode); + Assert.Equal("127.0.0.1", options.Host); + } + [Fact] public void Load_InvalidJson_ReturnsOnlySafeConfigurationMessage() { diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs index 39aa06b..b41ee44 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutSafetyValidationTests.cs @@ -25,16 +25,34 @@ public sealed class PlayoutSafetyValidationTests : IDisposable [Theory] [InlineData("Tornado2 TEST", false)] + [InlineData("Tornado2", true)] [InlineData("Tornado2 PGM", true)] [InlineData("PROGRAM OUTPUT", true)] [InlineData("preview pgm backup", true)] - [InlineData("", false)] - [InlineData(null, false)] + [InlineData("SAFE", false)] + [InlineData("", true)] + [InlineData(null, true)] public void IsProgramTitle_RejectsPgmAndProgramWindows(string? title, bool expected) { Assert.Equal(expected, TornadoProcessProbe.IsProgramTitle(title)); } + [Theory] + [InlineData("Tornado2 TEST", true)] + [InlineData("TEST-1", true)] + [InlineData("preview_test_output", true)] + [InlineData("Tornado2", false)] + [InlineData("CONTEST", false)] + [InlineData("TEST1", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void HasExplicitTestMarker_RequiresStandaloneTestToken( + string? title, + bool expected) + { + Assert.Equal(expected, TornadoProcessProbe.HasExplicitTestMarker(title)); + } + [Theory] [InlineData(0, false)] [InlineData(1, true)] @@ -96,6 +114,9 @@ public sealed class PlayoutSafetyValidationTests : IDisposable [InlineData("PGM")] [InlineData("PROGRAM")] [InlineData("^Tornado2 (TEST|PGM)$")] + [InlineData("^Tornado2$")] + [InlineData("^$")] + [InlineData("^SAFE$")] public void Validate_TestWindowPatternThatCanMatchProgramOutput_IsRejected(string pattern) { var options = ValidTestOptions(); diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/SmokeCommandLineTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/SmokeCommandLineTests.cs new file mode 100644 index 0000000..55307c1 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/SmokeCommandLineTests.cs @@ -0,0 +1,200 @@ +using MBN_STOCK_WEBVIEW.PlayoutSmoke; + +namespace MBN_STOCK_WEBVIEW.Playout.Tests; + +public sealed class SmokeCommandLineTests +{ + [Theory] + [InlineData("Tornado2 TEST", false)] + [InlineData("TEST-1", false)] + [InlineData("Tornado2", true)] + [InlineData("PGM", true)] + [InlineData("CONTEST", true)] + [InlineData("", true)] + [InlineData(null, true)] + public void TornadoWindowTitleWithoutExplicitTestMarkerIsUnsafe( + string? title, + bool expected) + { + Assert.Equal(expected, SmokeCommandLine.IsUnsafeTornadoWindowTitle(title)); + } + + [Fact] + public void Parse_NoArguments_PreservesReadOnlyProbeDefault() + { + var parsed = SmokeCommandLine.Parse([]); + + Assert.True(parsed.IsValid); + Assert.Equal(SmokeCommandKind.Probe, parsed.Invocation.Kind); + } + + [Theory] + [InlineData("--probe", "Probe")] + [InlineData("--PROBE", "Probe")] + [InlineData("--dry-run", "DryRun")] + public void Parse_ExistingComFreeCommandsRemainAvailable( + string argument, + string expected) + { + var parsed = SmokeCommandLine.Parse([argument]); + + Assert.True(parsed.IsValid); + Assert.Equal(expected, parsed.Invocation.Kind.ToString()); + } + + [Fact] + public void Parse_TestConnectRequiresExplicitAcknowledgementAndConfigArgument() + { + var withoutAcknowledgement = SmokeCommandLine.Parse( + ["--test-connect", "--config", "C:\\safe\\playout.local.json"]); + var complete = SmokeCommandLine.Parse( + [ + "--test-connect", + SmokeCommandLine.AcknowledgementFlag, + "--config", + "C:\\safe\\playout.local.json" + ]); + + Assert.False(withoutAcknowledgement.IsValid); + Assert.Equal("missing-acknowledgement", withoutAcknowledgement.ErrorCode); + Assert.True(complete.IsValid); + Assert.Equal(SmokeCommandKind.TestConnect, complete.Invocation.Kind); + } + + [Fact] + public void Parse_TestSequenceRequiresBothSceneCodesAndBoundedObservation() + { + var missingObservation = SmokeCommandLine.Parse( + [ + "--test-sequence", + SmokeCommandLine.AcknowledgementFlag, + "--config", + "C:\\safe\\playout.local.json", + "--prepare", + "5001", + "--next", + "5006" + ]); + var complete = SmokeCommandLine.Parse( + [ + "--test-sequence", + "--prepare", + "5001", + "--observe-ms", + "1000", + SmokeCommandLine.AcknowledgementFlag, + "--next", + "5006", + "--config", + "C:\\safe\\playout.local.json" + ]); + + Assert.False(missingObservation.IsValid); + Assert.Equal("missing-required-option", missingObservation.ErrorCode); + Assert.True(complete.IsValid); + Assert.Equal(1000, complete.Invocation.ObservationMilliseconds); + } + + [Theory] + [InlineData("999")] + [InlineData("30001")] + [InlineData("1.5")] + [InlineData("secret")] + public void Parse_RejectsUnboundedOrNonIntegralObservation(string value) + { + var parsed = ValidSequenceArguments(observation: value); + + Assert.False(parsed.IsValid); + Assert.Equal("invalid-observation-duration", parsed.ErrorCode); + } + + [Theory] + [InlineData("../5001")] + [InlineData("5001.t2s")] + [InlineData("장면")] + [InlineData("SCENE A")] + public void Parse_RejectsUnsafeSceneCodesWithoutReflectingThem(string sceneCode) + { + var parsed = ValidSequenceArguments(prepare: sceneCode); + + Assert.False(parsed.IsValid); + Assert.Equal("unsafe-scene-code", parsed.ErrorCode); + Assert.DoesNotContain(sceneCode, parsed.ErrorCode, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Parse_TestConnectRejectsMutationOptions() + { + var parsed = SmokeCommandLine.Parse( + [ + "--test-connect", + SmokeCommandLine.AcknowledgementFlag, + "--config", + "C:\\safe\\playout.local.json", + "--prepare", + "5001" + ]); + + Assert.False(parsed.IsValid); + Assert.Equal("unknown-option", parsed.ErrorCode); + } + + [Fact] + public void Parse_TestSequenceRequiresTwoDistinctSceneCodes() + { + var parsed = SmokeCommandLine.Parse( + [ + "--test-sequence", + SmokeCommandLine.AcknowledgementFlag, + "--config", + "C:\\safe\\playout.local.json", + "--prepare", + "5001", + "--next", + "5001", + "--observe-ms", + "1000" + ]); + + Assert.False(parsed.IsValid); + Assert.Equal("duplicate-scene-code", parsed.ErrorCode); + } + + [Fact] + public void Parse_TestPlanUsesTheSameSequenceInputsButNeverImpliesExecution() + { + var parsed = SmokeCommandLine.Parse( + [ + "--test-plan", + SmokeCommandLine.AcknowledgementFlag, + "--config", + "C:\\safe\\playout.local.json", + "--prepare", + "5001", + "--next", + "5006", + "--observe-ms", + "5000" + ]); + + Assert.True(parsed.IsValid); + Assert.Equal(SmokeCommandKind.TestPlan, parsed.Invocation.Kind); + } + + private static SmokeCommandParseResult ValidSequenceArguments( + string prepare = "5001", + string observation = "1000") => + SmokeCommandLine.Parse( + [ + "--test-sequence", + SmokeCommandLine.AcknowledgementFlag, + "--config", + "C:\\safe\\playout.local.json", + "--prepare", + prepare, + "--next", + "5006", + "--observe-ms", + observation + ]); +} diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs index f137615..6b29e2d 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Runtime.InteropServices; using MBN_STOCK_WEBVIEW.Playout.Configuration; using MBN_STOCK_WEBVIEW.Playout.Runtime; @@ -155,6 +156,165 @@ public sealed class TornadoPlayoutEngineTests Assert.Null(engine.Status.OnAirSceneName); } + [Fact] + public async Task UnchangedProcessGeneration_DoesNotReconnectAnExistingSession() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var session = new FakeK3dSession(); + var sessionFactory = new FakeK3dSessionFactory(() => session); + var unchanged = ProcessSnapshot("eligible-generation-a"); + await using var engine = CreateEngine( + TestOptions(scenes.Path, "test-scene"), + sessionFactory, + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(unchanged), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + await engine.PollProcessOnceAsync(CancellationToken.None); + + Assert.Equal(1, sessionFactory.CreateCount); + Assert.Equal(new[] { "Connect" }, session.Calls); + Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State); + } + + [Fact] + public async Task ChangedProcessGeneration_CleansOldSessionBeforeReconnect() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var calls = new ConcurrentQueue(); + var first = new FakeK3dSession + { + CallObserver = call => calls.Enqueue($"first:{call}") + }; + var second = new FakeK3dSession + { + CallObserver = call => calls.Enqueue($"second:{call}") + }; + var created = 0; + var sessionFactory = new FakeK3dSessionFactory( + () => Interlocked.Increment(ref created) == 1 ? first : second); + var original = ProcessSnapshot("eligible-generation-a"); + var replacement = ProcessSnapshot("eligible-generation-b"); + var options = TestOptions(scenes.Path, "test-scene"); + options.ReconnectDelayMilliseconds = 0; + await using var engine = CreateEngine( + options, + sessionFactory, + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(original, original, replacement), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + await engine.PollProcessOnceAsync(CancellationToken.None); + + Assert.Equal(2, sessionFactory.CreateCount); + Assert.Equal( + new[] + { + "first:Connect", + "first:Disconnect", + "first:Dispose", + "second:Connect" + }, + calls); + Assert.Equal(PlayoutConnectionState.Connected, engine.Status.State); + Assert.DoesNotContain("eligible-generation-a", engine.Status.Message, StringComparison.Ordinal); + Assert.DoesNotContain("eligible-generation-b", engine.Status.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task UnreliableProcessIdentity_FailsClosedBeforeRegistrationOrCom() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var sessionFactory = new FakeK3dSessionFactory(); + var registration = new FakeRegistrationProbe(); + var snapshot = ProcessSnapshot("unreliable") with + { + IdentityInspectionSucceeded = false + }; + await using var engine = CreateEngine( + TestOptions(scenes.Path, "test-scene"), + sessionFactory, + registration, + new FakeTornadoProcessProbe(snapshot), + liveAuthorized: false); + + var result = await engine.ConnectAsync(CancellationToken.None); + + Assert.Equal(PlayoutResultCode.Unavailable, result.Code); + Assert.Equal(0, registration.ProbeCount); + Assert.Equal(0, sessionFactory.CreateCount); + } + + [Fact] + public void ProcessGeneration_TracksEligibleAndProgramIdentitiesIndependently() + { + var baseline = ProcessSnapshot("eligible-generation-a") with + { + ProgramProcessGeneration = new TornadoProcessGeneration("program-generation-a") + }; + var same = ProcessSnapshot("eligible-generation-a") with + { + ProgramProcessGeneration = new TornadoProcessGeneration("program-generation-a") + }; + var changedProgram = same with + { + ProgramProcessGeneration = new TornadoProcessGeneration("program-generation-b") + }; + + Assert.True(baseline.HasSameProcessGeneration(same)); + Assert.False(baseline.HasSameProcessGeneration(changedProgram)); + } + + [Fact] + public async Task TestNextWithoutOnAirScene_IsRejectedBeforeNextSessionCalls() + { + using var scenes = TemporarySceneDirectory.Create("first.t2s", "next.t2s"); + var session = new FakeK3dSession(); + await using var engine = CreateEngine( + TestOptions(scenes.Path, "first", "next"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(ProcessSnapshot("eligible-generation-a")), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + var callsBeforeIdleNext = session.Calls.ToArray(); + + var idleNext = await engine.NextAsync(Cue("next"), CancellationToken.None); + + Assert.Equal(PlayoutResultCode.Rejected, idleNext.Code); + Assert.Equal(callsBeforeIdleNext, session.Calls); + + Assert.True((await engine.PrepareAsync(Cue("first"), CancellationToken.None)).IsSuccess); + var callsBeforeNext = session.Calls.ToArray(); + + var next = await engine.NextAsync(Cue("next"), CancellationToken.None); + + Assert.Equal(PlayoutResultCode.Rejected, next.Code); + Assert.Equal(callsBeforeNext, session.Calls); + Assert.DoesNotContain("Play", session.Calls); + Assert.Equal("first", engine.Status.PreparedSceneName); + Assert.Null(engine.Status.OnAirSceneName); + } + + [Fact] + public async Task Status_ExposesValidatedOperationTimeoutMilliseconds() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var options = TestOptions(scenes.Path, "test-scene"); + options.OperationTimeoutMilliseconds = 837; + await using var engine = CreateEngine( + options, + new FakeK3dSessionFactory(), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(ProcessSnapshot("eligible-generation-a")), + liveAuthorized: false); + + Assert.Equal(837, engine.Status.OperationTimeoutMilliseconds); + } + [Fact] public async Task TestSceneOutsideAllowlist_IsRejectedBeforeSessionCall() { @@ -236,7 +396,7 @@ public sealed class TornadoPlayoutEngineTests } [Fact] - public async Task TimedOutTakeIn_QuarantinesEngineAndNeverReconnectsOrReplays() + public async Task TimedOutTakeIn_WithProcessGenerationChange_NeverReconnectsOrReplays() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); using var playStarted = new ManualResetEventSlim(); @@ -253,11 +413,18 @@ public sealed class TornadoPlayoutEngineTests var options = TestOptions(scenes.Path, "test-scene"); options.OperationTimeoutMilliseconds = 100; options.ReconnectDelayMilliseconds = 0; + var originalProcess = ProcessSnapshot("eligible-generation-a"); + var restartedProcess = ProcessSnapshot("eligible-generation-b"); await using var engine = CreateEngine( options, sessionFactory, new FakeRegistrationProbe(), - new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + new FakeTornadoProcessProbe( + originalProcess, + originalProcess, + originalProcess, + originalProcess, + restartedProcess), liveAuthorized: false); Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); @@ -274,6 +441,9 @@ public sealed class TornadoPlayoutEngineTests Assert.Equal(1, session.Calls.Count(call => call == "Play")); releasePlay.Set(); + Assert.True(SpinWait.SpinUntil( + () => session.Calls.Contains("Abandon"), + TimeSpan.FromSeconds(5))); await engine.PollProcessOnceAsync(CancellationToken.None); var takeOut = await engine.TakeOutAsync( PlayoutTakeOutScope.All, @@ -282,6 +452,7 @@ public sealed class TornadoPlayoutEngineTests Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeOut.Code); Assert.Equal(1, sessionFactory.CreateCount); Assert.Equal(1, session.Calls.Count(call => call == "Play")); + Assert.DoesNotContain("Disconnect", session.Calls); Assert.DoesNotContain(session.Calls, call => call.StartsWith("TakeOut:", StringComparison.Ordinal)); } finally @@ -290,6 +461,208 @@ public sealed class TornadoPlayoutEngineTests } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FailedTakeOut_AbandonsSessionWithoutSdkDisconnect( + bool callbackThrowsCancellation) + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var session = new FakeK3dSession + { + TakeOutAction = (_, _) => + { + if (callbackThrowsCancellation) + { + throw new OperationCanceledException("fake callback cancellation"); + } + + throw new COMException("fake take-out failure"); + } + }; + await using var engine = CreateEngine( + TestOptions(scenes.Path, "test-scene"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.PrepareAsync( + Cue("test-scene"), + CancellationToken.None)).IsSuccess); + Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); + + var takeOut = await engine.TakeOutAsync( + PlayoutTakeOutScope.All, + CancellationToken.None); + + Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeOut.Code); + Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); + Assert.Contains("TakeOut:All", session.Calls); + Assert.Contains("Abandon", session.Calls); + Assert.DoesNotContain("Disconnect", session.Calls); + Assert.DoesNotContain("Dispose", session.Calls); + } + + [Fact] + public async Task FailedPrepareWhileOnAir_AbandonsWithoutSdkDisconnect() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var prepareCount = 0; + var session = new FakeK3dSession + { + PrepareAction = (_, _) => + { + if (Interlocked.Increment(ref prepareCount) > 1) + { + throw new InvalidOperationException("fake on-air prepare failure"); + } + } + }; + await using var engine = CreateEngine( + TestOptions(scenes.Path, "test-scene"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.PrepareAsync( + Cue("test-scene"), + CancellationToken.None)).IsSuccess); + Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); + + var prepare = await engine.PrepareAsync( + Cue("test-scene"), + CancellationToken.None); + + Assert.Equal(PlayoutResultCode.OutcomeUnknown, prepare.Code); + Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); + Assert.Contains("Abandon", session.Calls); + Assert.DoesNotContain("Disconnect", session.Calls); + Assert.DoesNotContain("Dispose", session.Calls); + } + + [Fact] + public async Task TimedOutTakeOut_LateCompletionAbandonsWithoutSdkDisconnect() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + using var takeOutStarted = new ManualResetEventSlim(); + using var releaseTakeOut = new ManualResetEventSlim(); + var session = new FakeK3dSession + { + TakeOutAction = (_, _) => + { + takeOutStarted.Set(); + Assert.True(releaseTakeOut.Wait(TimeSpan.FromSeconds(5))); + } + }; + var options = TestOptions(scenes.Path, "test-scene"); + options.OperationTimeoutMilliseconds = 100; + await using var engine = CreateEngine( + options, + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.PrepareAsync( + Cue("test-scene"), + CancellationToken.None)).IsSuccess); + Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); + + try + { + var takeOutTask = engine.TakeOutAsync( + PlayoutTakeOutScope.All, + CancellationToken.None); + Assert.True(takeOutStarted.Wait(TimeSpan.FromSeconds(5))); + var takeOut = await takeOutTask; + + Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeOut.Code); + releaseTakeOut.Set(); + Assert.True(SpinWait.SpinUntil( + () => session.Calls.Contains("Abandon"), + TimeSpan.FromSeconds(5))); + Assert.DoesNotContain("Disconnect", session.Calls); + Assert.DoesNotContain("Dispose", session.Calls); + } + finally + { + releaseTakeOut.Set(); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task TakeOut_WhenOnAirProcessBecomesUnsafe_AbandonsWithoutDisconnect( + bool generationChanged) + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var session = new FakeK3dSession(); + var original = ProcessSnapshot("eligible-generation-a"); + var unsafeSnapshot = generationChanged + ? ProcessSnapshot("eligible-generation-b") + : new TornadoProcessSnapshot(1, 0, 1); + var snapshots = Enumerable.Repeat(original, 4) + .Append(unsafeSnapshot) + .ToArray(); + await using var engine = CreateEngine( + TestOptions(scenes.Path, "test-scene"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(snapshots), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.PrepareAsync( + Cue("test-scene"), + CancellationToken.None)).IsSuccess); + Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); + + var takeOut = await engine.TakeOutAsync( + PlayoutTakeOutScope.All, + CancellationToken.None); + + Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeOut.Code); + Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); + Assert.Contains("Abandon", session.Calls); + Assert.DoesNotContain("Disconnect", session.Calls); + Assert.DoesNotContain(session.Calls, call => call.StartsWith("TakeOut:", StringComparison.Ordinal)); + } + + [Fact] + public async Task ProcessMonitor_WhenOutputIsOnAir_AbandonsWithoutDisconnect() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var session = new FakeK3dSession(); + var original = ProcessSnapshot("eligible-generation-a"); + var snapshots = Enumerable.Repeat(original, 4) + .Append(new TornadoProcessSnapshot(1, 0, 1)) + .ToArray(); + await using var engine = CreateEngine( + TestOptions(scenes.Path, "test-scene"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(snapshots), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.PrepareAsync( + Cue("test-scene"), + CancellationToken.None)).IsSuccess); + Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); + + await engine.PollProcessOnceAsync(CancellationToken.None); + + Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); + Assert.Contains("Abandon", session.Calls); + Assert.DoesNotContain("Disconnect", session.Calls); + } + [Fact] public async Task OutcomeUnknown_PreservesLatchAndNeverReconnectsOrReplays() { @@ -329,15 +702,133 @@ public sealed class TornadoPlayoutEngineTests var laterCommand = await engine.TakeOutAsync( PlayoutTakeOutScope.All, CancellationToken.None); + var laterTakeIn = await engine.TakeInAsync(CancellationToken.None); + var laterNext = await engine.NextAsync( + Cue("test-scene"), + CancellationToken.None); + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + var cancelledResults = new[] + { + await engine.ConnectAsync(cancelled.Token), + await engine.DisconnectAsync(cancelled.Token), + await engine.PrepareAsync(Cue("test-scene"), cancelled.Token), + await engine.TakeInAsync(cancelled.Token), + await engine.NextAsync(Cue("test-scene"), cancelled.Token), + await engine.TakeOutAsync(PlayoutTakeOutScope.All, cancelled.Token) + }; Assert.Equal(1, sessionFactory.CreateCount); Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); Assert.Equal(PlayoutResultCode.OutcomeUnknown, laterCommand.Code); + Assert.Equal(PlayoutResultCode.OutcomeUnknown, laterTakeIn.Code); + Assert.Equal(PlayoutResultCode.OutcomeUnknown, laterNext.Code); + Assert.All(cancelledResults, result => + Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code)); Assert.False(engine.Status.IsCommandAvailable); Assert.DoesNotContain("Play", second.Calls); Assert.DoesNotContain(second.Calls, call => call.StartsWith("Prepare:", StringComparison.Ordinal)); } + [Fact] + public async Task CancelledGateWait_WhenLeadingCommandLatchesUnknown_ReturnsOutcomeUnknown() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + using var playStarted = new ManualResetEventSlim(); + using var releasePlay = new ManualResetEventSlim(); + var session = new FakeK3dSession + { + PlayAction = _ => + { + playStarted.Set(); + Assert.True(releasePlay.Wait(TimeSpan.FromSeconds(5))); + throw new COMException("fake ambiguous play failure"); + } + }; + await using var engine = CreateEngine( + TestOptions(scenes.Path, "test-scene"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.PrepareAsync( + Cue("test-scene"), + CancellationToken.None)).IsSuccess); + + try + { + var takeInTask = engine.TakeInAsync(CancellationToken.None); + Assert.True(playStarted.Wait(TimeSpan.FromSeconds(5))); + using var cancellation = new CancellationTokenSource(); + var waitingCommand = engine.NextAsync(Cue("test-scene"), cancellation.Token); + cancellation.Cancel(); + releasePlay.Set(); + + var takeIn = await takeInTask; + var next = await waitingCommand; + + Assert.Equal(PlayoutResultCode.OutcomeUnknown, takeIn.Code); + Assert.Equal(PlayoutResultCode.OutcomeUnknown, next.Code); + } + finally + { + releasePlay.Set(); + } + } + + [Fact] + public async Task CancelledGateWaitBehindDispose_DoesNotReverseDisposedStateOrThrow() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + using var playStarted = new ManualResetEventSlim(); + using var releasePlay = new ManualResetEventSlim(); + var session = new FakeK3dSession + { + PlayAction = _ => + { + playStarted.Set(); + Assert.True(releasePlay.Wait(TimeSpan.FromSeconds(5))); + } + }; + var engine = CreateEngine( + TestOptions(scenes.Path, "test-scene"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.PrepareAsync( + Cue("test-scene"), + CancellationToken.None)).IsSuccess); + + try + { + var takeInTask = engine.TakeInAsync(CancellationToken.None); + Assert.True(playStarted.Wait(TimeSpan.FromSeconds(5))); + var disposeTask = engine.DisposeAsync().AsTask(); + using var cancellation = new CancellationTokenSource(); + var waitingCommand = engine.NextAsync(Cue("test-scene"), cancellation.Token); + cancellation.Cancel(); + releasePlay.Set(); + + Assert.True((await takeInTask).IsSuccess); + await disposeTask; + var next = await waitingCommand; + + Assert.Equal(PlayoutResultCode.OutcomeUnknown, next.Code); + Assert.Equal(PlayoutConnectionState.Disposed, engine.Status.State); + await engine.DisposeAsync(); + } + finally + { + releasePlay.Set(); + await engine.DisposeAsync(); + } + } + [Fact] public async Task FailedPrepare_ReconnectsWithoutReplayingTheFailedCommand() { @@ -374,6 +865,104 @@ public sealed class TornadoPlayoutEngineTests Assert.DoesNotContain("Play", second.Calls); } + [Fact] + public async Task FailedPrepare_WhenSessionCleanupFails_ReturnsOutcomeUnknown() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var session = new FakeK3dSession + { + PrepareAction = (_, _) => throw new InvalidOperationException("fake prepare failure"), + DisconnectAction = () => throw new InvalidOperationException("fake cleanup failure") + }; + var sessionFactory = new FakeK3dSessionFactory(() => session); + await using var engine = CreateEngine( + TestOptions(scenes.Path, "test-scene"), + sessionFactory, + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + + var prepare = await engine.PrepareAsync( + Cue("test-scene"), + CancellationToken.None); + + Assert.Equal(PlayoutResultCode.OutcomeUnknown, prepare.Code); + Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); + Assert.False(engine.Status.IsCommandAvailable); + Assert.Contains("Disconnect", session.Calls); + Assert.Contains("Dispose", session.Calls); + Assert.Equal(1, sessionFactory.CreateCount); + } + + [Fact] + public async Task Quarantine_ReleasesLocalSessionWithoutSdkDisconnect() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var session = new FakeK3dSession(); + var sessionFactory = new FakeK3dSessionFactory(() => session); + var engine = CreateEngine( + TestOptions(scenes.Path, "test-scene"), + sessionFactory, + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + await engine.QuarantineAsync(); + await engine.DisposeAsync(); + + Assert.Equal(PlayoutConnectionState.Disposed, engine.Status.State); + Assert.Contains("Abandon", session.Calls); + Assert.DoesNotContain("Disconnect", session.Calls); + Assert.DoesNotContain("Dispose", session.Calls); + Assert.Equal(1, sessionFactory.CreateCount); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CancelledTakeOutThenShutdown_AbandonsWithoutSdkDisconnect( + bool callDisconnectFirst) + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var session = new FakeK3dSession(); + var engine = CreateEngine( + TestOptions(scenes.Path, "test-scene"), + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + Assert.True((await engine.ConnectAsync(CancellationToken.None)).IsSuccess); + Assert.True((await engine.PrepareAsync( + Cue("test-scene"), + CancellationToken.None)).IsSuccess); + Assert.True((await engine.TakeInAsync(CancellationToken.None)).IsSuccess); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + var takeOut = await engine.TakeOutAsync( + PlayoutTakeOutScope.All, + cancellation.Token); + + Assert.Equal(PlayoutResultCode.Cancelled, takeOut.Code); + if (callDisconnectFirst) + { + var disconnect = await engine.DisconnectAsync(CancellationToken.None); + Assert.Equal(PlayoutResultCode.OutcomeUnknown, disconnect.Code); + } + + await engine.DisposeAsync(); + + Assert.Contains("Abandon", session.Calls); + Assert.DoesNotContain("Disconnect", session.Calls); + Assert.DoesNotContain("Dispose", session.Calls); + Assert.DoesNotContain(session.Calls, call => call.StartsWith("TakeOut:", StringComparison.Ordinal)); + Assert.Equal(PlayoutConnectionState.Disposed, engine.Status.State); + } + private static TornadoPlayoutEngine CreateEngine( PlayoutOptions rawOptions, FakeK3dSessionFactory sessionFactory, @@ -432,4 +1021,10 @@ public sealed class TornadoPlayoutEngineTests $"{sceneName}.t2s", sceneName, [new PlayoutField("headline", "fake", true)]); + + private static TornadoProcessSnapshot ProcessSnapshot(string generation) => + new(1, 1, 0) + { + EligibleProcessGeneration = new TornadoProcessGeneration(generation) + }; } diff --git a/tests/Web/playout-safety.test.cjs b/tests/Web/playout-safety.test.cjs new file mode 100644 index 0000000..e0879a8 --- /dev/null +++ b/tests/Web/playout-safety.test.cjs @@ -0,0 +1,77 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const safety = require("../../Web/playout-safety.js"); + +function ready(overrides = {}) { + return { + pending: false, + outcomeUnknown: false, + commandAvailable: true, + engineState: "IDLE", + preparedCode: null, + onAirCode: null, + ...overrides + }; +} + +test("NEXT is blocked until a scene is on air", () => { + assert.equal(safety.commandBlockReason(ready(), "next"), "next-requires-on-air"); + assert.equal( + safety.commandBlockReason(ready({ engineState: "PREPARED", preparedCode: "5001" }), "next"), + "next-requires-on-air"); + assert.equal( + safety.commandBlockReason(ready({ engineState: "PROGRAM", onAirCode: "5001" }), "next"), + null); +}); + +test("OutcomeUnknown and unavailable states block every command", () => { + for (const command of ["prepare", "take-in", "next", "take-out"]) { + assert.equal( + safety.commandBlockReason(ready({ outcomeUnknown: true }), command), + "outcome-unknown"); + assert.equal( + safety.commandBlockReason(ready({ commandAvailable: false }), command), + "unavailable"); + } +}); + +test("TAKE IN and TAKE OUT preserve the native state prerequisites", () => { + assert.equal(safety.commandBlockReason(ready(), "take-in"), "take-in-requires-prepare"); + assert.equal(safety.commandBlockReason(ready(), "take-out"), "nothing-to-take-out"); + assert.equal( + safety.commandBlockReason(ready({ preparedCode: "5001" }), "take-in"), + null); + assert.equal( + safety.commandBlockReason(ready({ onAirCode: "5001" }), "take-out"), + null); +}); + +test("Browser response timeout follows the bounded native operation timeout", () => { + assert.equal(safety.responseTimeoutFromNative(5000), 10000); + assert.equal(safety.responseTimeoutFromNative(300000), 305000); + assert.equal(safety.responseTimeoutFromNative(100), 5100); + assert.equal(safety.responseTimeoutFromNative(99), 15000); + assert.equal(safety.responseTimeoutFromNative("not-a-number"), 15000); +}); + +test("Connection state normalization is allowlisted", () => { + assert.equal(safety.normalizeConnectionState("Outcome_Unknown"), "outcome-unknown"); + assert.equal(safety.normalizeConnectionState("RECONNECTING"), "reconnecting"); + assert.equal(safety.normalizeConnectionState("attacker-defined"), "unavailable"); +}); + +test("Late or mismatched native responses cannot complete a pending command", () => { + const pending = { requestId: "REQ-2", command: "next" }; + assert.equal( + safety.matchesPendingCommand(pending, { requestId: "REQ-2", command: "next" }), + true); + assert.equal( + safety.matchesPendingCommand(pending, { requestId: "REQ-1", command: "next" }), + false); + assert.equal( + safety.matchesPendingCommand(pending, { requestId: "REQ-2", command: "take-in" }), + false); + assert.equal(safety.matchesPendingCommand(null, { requestId: "REQ-2", command: "next" }), false); +}); diff --git a/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/IsolatedTestCommand.cs b/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/IsolatedTestCommand.cs new file mode 100644 index 0000000..d58e2eb --- /dev/null +++ b/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/IsolatedTestCommand.cs @@ -0,0 +1,491 @@ +using System.Collections; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Playout; +using MBN_STOCK_WEBVIEW.Playout.Configuration; + +namespace MBN_STOCK_WEBVIEW.PlayoutSmoke; + +internal sealed record IsolatedTestCommandPlan( + SmokeCommandKind Kind, + PlayoutOptions Options, + PlayoutCue? PrepareCue, + PlayoutCue? NextCue, + TimeSpan ObservationDuration); + +internal sealed class IsolatedTestPreflightException : Exception +{ + public IsolatedTestPreflightException(string errorCode) + : base("The isolated Test command was rejected before engine creation.") + { + ErrorCode = errorCode; + } + + public string ErrorCode { get; } +} + +internal static class IsolatedTestCommandPreflight +{ + internal const string EnvironmentVariablePrefix = "MBN_STOCK_PLAYOUT_"; + + public static IsolatedTestCommandPlan Create(SmokeCommandInvocation invocation) + { + ArgumentNullException.ThrowIfNull(invocation); + if (invocation.Kind is not ( + SmokeCommandKind.TestPlan or + SmokeCommandKind.TestConnect or + SmokeCommandKind.TestSequence)) + { + throw Rejected("invalid-test-command"); + } + + if (HasPlayoutEnvironmentOverride(Environment.GetEnvironmentVariables())) + { + throw Rejected("environment-override-present"); + } + + var configPath = ValidateConfigPath(invocation.ConfigPath); + PlayoutOptions options; + try + { + options = PlayoutOptionsLoader.LoadFileOnly(configPath); + } + catch (Exception exception) when ( + exception is PlayoutConfigurationException or + ArgumentException or + IOException or + UnauthorizedAccessException) + { + throw Rejected("configuration-rejected"); + } + + if (options.Mode != PlayoutMode.Test || options.TrustedLiveOutputEnabled) + { + throw Rejected("test-mode-required"); + } + + // This one-shot diagnostic never reconnects or replays a command. The runtime + // still checks process eligibility before every operation. + options.ReconnectEnabled = false; + options.MaximumReconnectAttempts = 0; + + PlayoutCue? prepareCue = null; + PlayoutCue? nextCue = null; + if (invocation.Kind is SmokeCommandKind.TestPlan or SmokeCommandKind.TestSequence) + { + if (invocation.PrepareSceneCode is null || invocation.NextSceneCode is null || + invocation.ObservationMilliseconds is not (>= 1_000 and <= 30_000) || + !SmokeCommandLine.IsSafeSceneCode(invocation.PrepareSceneCode) || + !SmokeCommandLine.IsSafeSceneCode(invocation.NextSceneCode)) + { + throw Rejected("scene-code-rejected"); + } + + prepareCue = CreateCue(invocation.PrepareSceneCode); + nextCue = CreateCue(invocation.NextSceneCode); + } + + try + { + PlayoutEngineFactory.ValidateIsolatedTestCommand( + options, + prepareCue is null ? [] : [prepareCue, nextCue!]); + } + catch (Exception exception) when ( + exception is PlayoutConfigurationException or ArgumentException) + { + throw Rejected("safety-gate-rejected"); + } + + return new IsolatedTestCommandPlan( + invocation.Kind, + options, + prepareCue, + nextCue, + TimeSpan.FromMilliseconds(invocation.ObservationMilliseconds ?? 0)); + } + + internal static bool HasPlayoutEnvironmentOverride(IDictionary environment) + { + ArgumentNullException.ThrowIfNull(environment); + foreach (var key in environment.Keys) + { + if (key is string name && + name.StartsWith(EnvironmentVariablePrefix, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static string ValidateConfigPath(string? value) + { + if (string.IsNullOrWhiteSpace(value) || !Path.IsPathFullyQualified(value)) + { + throw Rejected("config-file-rejected"); + } + + string fullPath; + FileAttributes attributes; + try + { + fullPath = Path.GetFullPath(value); + var root = Path.GetPathRoot(fullPath); + if (string.IsNullOrEmpty(root) || + fullPath.StartsWith("\\\\", StringComparison.Ordinal) || + new DriveInfo(root).DriveType != DriveType.Fixed) + { + throw Rejected("config-file-rejected"); + } + + if (!File.Exists(fullPath)) + { + throw Rejected("config-file-rejected"); + } + + attributes = File.GetAttributes(fullPath); + } + catch (IsolatedTestPreflightException) + { + throw; + } + catch (Exception exception) when ( + exception is ArgumentException or + IOException or + NotSupportedException or + System.Security.SecurityException or + UnauthorizedAccessException) + { + throw Rejected("config-file-rejected"); + } + + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) != 0) + { + throw Rejected("config-file-rejected"); + } + + return fullPath; + } + + private static PlayoutCue CreateCue(string sceneCode) => + new($"{sceneCode}.t2s", sceneCode); + + private static IsolatedTestPreflightException Rejected(string errorCode) => new(errorCode); +} + +internal sealed record SafeCommandStep( + string Phase, + string Operation, + string Code); + +internal sealed record SafeCleanupReport( + bool DisconnectAttempted, + string? DisconnectCode, + bool QuarantineAttempted, + bool QuarantineCompleted, + bool AdapterDisposed); + +internal sealed record SafeTestCommandReport( + string Command, + string Mode, + bool ConnectRequestIssued, + bool? ComActivationAttempted, + bool Completed, + bool OutcomeUnknown, + bool OutputMayBeActive, + string? StoppedAfter, + IReadOnlyList Steps, + SafeCleanupReport Cleanup); + +internal static class IsolatedTestCommandExecutor +{ + public static Task ExecuteAsync( + IsolatedTestCommandPlan plan, + CancellationToken cancellationToken = default) => + ExecuteAsync( + plan, + PlayoutEngineFactory.Create, + static (delay, token) => Task.Delay(delay, token), + cancellationToken); + + internal static async Task ExecuteAsync( + IsolatedTestCommandPlan plan, + Func engineFactory, + Func observationDelay, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(plan); + ArgumentNullException.ThrowIfNull(engineFactory); + ArgumentNullException.ThrowIfNull(observationDelay); + if (plan.Kind is not (SmokeCommandKind.TestConnect or SmokeCommandKind.TestSequence)) + { + throw new ArgumentException("The plan is not executable.", nameof(plan)); + } + + var steps = new List(); + IPlayoutEngine? engine = null; + var connectRequestIssued = false; + var connectResultReceived = false; + bool? comActivationAttempted = false; + var completed = false; + var outcomeUnknown = false; + string? stoppedAfter = null; + var disconnectAttempted = false; + string? disconnectCode = null; + var adapterDisposed = false; + var quarantineAttempted = false; + var quarantineCompleted = false; + var normalDisconnectCompleted = false; + var outputMayBeActive = false; + var takeOutAttempted = false; + + try + { + engine = engineFactory(plan.Options); + do + { + connectRequestIssued = true; + var connect = await engine.ConnectAsync(cancellationToken).ConfigureAwait(false); + connectResultReceived = true; + AddStep(steps, "command", connect); + comActivationAttempted = InferComActivationAttempt(connect.Code); + if (!connect.IsSuccess) + { + stoppedAfter = connect.Operation.ToString(); + outcomeUnknown = IsOutcomeUnknown(connect); + break; + } + + if (plan.Kind == SmokeCommandKind.TestSequence) + { + var prepare = await engine.PrepareAsync(plan.PrepareCue!, cancellationToken) + .ConfigureAwait(false); + AddStep(steps, "command", prepare); + if (!prepare.IsSuccess) + { + stoppedAfter = prepare.Operation.ToString(); + outcomeUnknown = IsOutcomeUnknown(prepare); + break; + } + + // Once TAKE IN is issued, an exception or ambiguous result can mean + // the Test output became active even though no success was observed. + outputMayBeActive = true; + var takeIn = await engine.TakeInAsync(cancellationToken).ConfigureAwait(false); + AddStep(steps, "command", takeIn); + if (!takeIn.IsSuccess) + { + stoppedAfter = takeIn.Operation.ToString(); + outcomeUnknown = IsOutcomeUnknown(takeIn); + if (!outcomeUnknown) + { + outputMayBeActive = false; + } + + break; + } + + if (!await ObserveAsync().ConfigureAwait(false)) + { + stoppedAfter = "Observation"; + break; + } + + var next = await engine.NextAsync(plan.NextCue!, cancellationToken) + .ConfigureAwait(false); + AddStep(steps, "command", next); + if (!next.IsSuccess) + { + stoppedAfter = next.Operation.ToString(); + outcomeUnknown = IsOutcomeUnknown(next); + break; + } + + if (!await ObserveAsync().ConfigureAwait(false)) + { + stoppedAfter = "Observation"; + break; + } + + takeOutAttempted = true; + var takeOut = await engine.TakeOutAsync( + PlayoutTakeOutScope.All, + cancellationToken).ConfigureAwait(false); + AddStep(steps, "command", takeOut); + if (!takeOut.IsSuccess) + { + stoppedAfter = takeOut.Operation.ToString(); + // TAKE IN succeeded, so every non-successful TAKE OUT leaves the + // physical Test output state unsafe to infer from its result code. + outcomeUnknown = true; + break; + } + + outputMayBeActive = false; + } + + disconnectAttempted = true; + var disconnect = await engine.DisconnectAsync(cancellationToken).ConfigureAwait(false); + disconnectCode = disconnect.Code.ToString(); + AddStep(steps, "command", disconnect); + normalDisconnectCompleted = true; + if (!disconnect.IsSuccess) + { + stoppedAfter = disconnect.Operation.ToString(); + outcomeUnknown = IsOutcomeUnknown(disconnect); + break; + } + + completed = true; + } + while (false); + } + catch + { + stoppedAfter ??= connectRequestIssued ? "Internal" : "EngineCreation"; + if (!connectResultReceived) + { + comActivationAttempted = connectRequestIssued ? null : false; + } + + outcomeUnknown = connectRequestIssued; + } + finally + { + if (engine is not null) + { + if (outputMayBeActive && !takeOutAttempted && !outcomeUnknown) + { + takeOutAttempted = true; + try + { + var cleanupTakeOut = await engine.TakeOutAsync( + PlayoutTakeOutScope.All, + CancellationToken.None).ConfigureAwait(false); + AddStep(steps, "cleanup", cleanupTakeOut); + if (cleanupTakeOut.IsSuccess) + { + outputMayBeActive = false; + } + else + { + outcomeUnknown = true; + } + } + catch + { + outcomeUnknown = true; + } + } + + if (!normalDisconnectCompleted && !outcomeUnknown) + { + disconnectAttempted = true; + try + { + var cleanup = await engine.DisconnectAsync(CancellationToken.None) + .ConfigureAwait(false); + disconnectCode = cleanup.Code.ToString(); + AddStep(steps, "cleanup", cleanup); + outcomeUnknown |= IsOutcomeUnknown(cleanup); + } + catch + { + disconnectCode = "Failed"; + outcomeUnknown = true; + } + } + + var safeToDispose = true; + if (outcomeUnknown) + { + quarantineAttempted = true; + try + { + await engine.QuarantineAsync().ConfigureAwait(false); + quarantineCompleted = true; + } + catch + { + // A normal Dispose may issue SDK Disconnect. If quarantine could + // not first detach the session, leaking locally is safer than an + // unrequested control call against an unknown physical output. + safeToDispose = false; + } + } + + if (safeToDispose) + { + try + { + await engine.DisposeAsync().ConfigureAwait(false); + adapterDisposed = true; + } + catch + { + outcomeUnknown = true; + } + } + } + } + + return BuildReport(); + + async Task ObserveAsync() + { + try + { + await observationDelay(plan.ObservationDuration, cancellationToken) + .ConfigureAwait(false); + return true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return false; + } + catch + { + // Observation is local timing only. Its failure does not make a COM + // command ambiguous, so retain the bounded TAKE OUT cleanup path. + return false; + } + } + + SafeTestCommandReport BuildReport() => new( + plan.Kind == SmokeCommandKind.TestConnect ? "test-connect" : "test-sequence", + "Test", + connectRequestIssued, + comActivationAttempted, + completed, + outcomeUnknown, + outputMayBeActive, + stoppedAfter, + steps.AsReadOnly(), + new SafeCleanupReport( + disconnectAttempted, + disconnectCode, + quarantineAttempted, + quarantineCompleted, + adapterDisposed)); + } + + private static void AddStep( + ICollection steps, + string phase, + PlayoutResult result) => + steps.Add(new SafeCommandStep( + phase, + result.Operation.ToString(), + result.Code.ToString())); + + private static bool IsOutcomeUnknown(PlayoutResult result) => + result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut; + + private static bool? InferComActivationAttempt(PlayoutResultCode code) => code switch + { + PlayoutResultCode.Success or PlayoutResultCode.Failed => true, + PlayoutResultCode.Rejected => false, + _ => null + }; +} diff --git a/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/Program.cs b/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/Program.cs index 4460ec4..b8c8b29 100644 --- a/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/Program.cs +++ b/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/Program.cs @@ -4,14 +4,29 @@ using MBN_STOCK_WEBVIEW.Core.Playout; using MBN_STOCK_WEBVIEW.Playout; using MBN_STOCK_WEBVIEW.Playout.Configuration; using MBN_STOCK_WEBVIEW.Playout.Registration; +using MBN_STOCK_WEBVIEW.PlayoutSmoke; -var command = args.Length == 0 ? "--probe" : args[0].ToLowerInvariant(); - -return command switch +var parsed = SmokeCommandLine.Parse(args); +if (!parsed.IsValid) { - "--probe" => Probe(), - "--dry-run" => await DryRunAsync(), - _ => Usage() + return Usage(parsed.ErrorCode); +} + +using var cancellation = new CancellationTokenSource(); +Console.CancelKeyPress += (_, eventArgs) => +{ + eventArgs.Cancel = true; + cancellation.Cancel(); +}; + +return parsed.Invocation.Kind switch +{ + SmokeCommandKind.Probe => Probe(), + SmokeCommandKind.DryRun => await DryRunAsync(), + SmokeCommandKind.TestPlan => TestPlan(parsed.Invocation), + SmokeCommandKind.TestConnect or SmokeCommandKind.TestSequence => + await RunIsolatedTestCommandAsync(parsed.Invocation, cancellation.Token), + _ => Usage("unknown-command") }; static int Probe() @@ -42,8 +57,7 @@ static int Probe() tornadoProcessCount++; var title = process.MainWindowTitle; - if (title.Contains("PGM", StringComparison.OrdinalIgnoreCase) || - title.Contains("PROGRAM", StringComparison.OrdinalIgnoreCase)) + if (SmokeCommandLine.IsUnsafeTornadoWindowTitle(title)) { programWindowDetected = true; } @@ -58,9 +72,12 @@ static int Probe() } } - Console.WriteLine(JsonSerializer.Serialize(new + WriteJson(new { + command = "probe", architecture = Environment.Is64BitProcess ? "x64" : "x86", + connectRequestIssued = false, + comActivationAttempted = false, runtimeRegistration = new { ready = runtimeRegistration.IsReady, @@ -76,9 +93,9 @@ static int Probe() programWindowDetected }, safety = programWindowDetected - ? "PROGRAM window detected; no COM activation was attempted." + ? "PROGRAM or ambiguous Tornado window detected; no COM activation was attempted." : "Read-only probe complete; no COM activation was attempted." - }, new JsonSerializerOptions { WriteIndented = true })); + }); return runtimeRegistration.IsReady && Environment.Is64BitProcess ? 0 @@ -101,24 +118,124 @@ static async Task DryRunAsync() await engine.DisconnectAsync() }; - Console.WriteLine(JsonSerializer.Serialize(new + WriteJson(new { + command = "dry-run", mode = engine.Status.Mode.ToString(), state = engine.Status.State.ToString(), + connectRequestIssued = false, + comActivationAttempted = false, commands = results.Select(result => new { operation = result.Operation.ToString(), code = result.Code.ToString(), message = result.Message }) - }, new JsonSerializerOptions { WriteIndented = true })); + }); return results.All(result => result.IsSuccess) ? 0 : 3; } -static int Usage() +static int TestPlan(SmokeCommandInvocation invocation) { - Console.Error.WriteLine("Usage: MBN_STOCK_WEBVIEW.PlayoutSmoke [--probe|--dry-run]"); - Console.Error.WriteLine("This tool never enables Test or Live mode and never activates K3D COM in dry-run."); + try + { + var plan = IsolatedTestCommandPreflight.Create(invocation); + WriteJson(new + { + command = "test-plan", + mode = "Test", + preflightValidated = true, + runtimeProcessGateChecked = false, + connectRequestIssued = false, + comActivationAttempted = false, + sceneCount = plan.PrepareCue is null ? 0 : 2, + automaticReconnectEnabled = plan.Options.ReconnectEnabled, + safety = "Configuration and scene assets validated only; no engine or COM was created." + }); + return 0; + } + catch (IsolatedTestPreflightException exception) + { + WritePreflightFailure("test-plan", exception.ErrorCode); + return 65; + } + catch + { + WritePreflightFailure("test-plan", "internal-preflight-failure"); + return 70; + } +} + +static async Task RunIsolatedTestCommandAsync( + SmokeCommandInvocation invocation, + CancellationToken cancellationToken) +{ + var command = invocation.Kind == SmokeCommandKind.TestConnect + ? "test-connect" + : "test-sequence"; + IsolatedTestCommandPlan plan; + try + { + plan = IsolatedTestCommandPreflight.Create(invocation); + } + catch (IsolatedTestPreflightException exception) + { + WritePreflightFailure(command, exception.ErrorCode); + return 65; + } + catch + { + WritePreflightFailure(command, "internal-preflight-failure"); + return 70; + } + + var report = await IsolatedTestCommandExecutor.ExecuteAsync(plan, cancellationToken); + WriteJson(report); + if (report.Completed && !report.OutcomeUnknown) + { + return 0; + } + + return report.OutcomeUnknown ? 5 : 4; +} + +static void WritePreflightFailure(string command, string errorCode) => + WriteJson(new + { + command, + mode = "Test", + preflightValidated = false, + runtimeProcessGateChecked = false, + connectRequestIssued = false, + comActivationAttempted = false, + errorCode, + safety = "Rejected before engine creation; no COM activation was attempted." + }); + +static void WriteJson(T value) => + Console.WriteLine(JsonSerializer.Serialize(value, new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + })); + +static int Usage(string? errorCode) +{ + WriteJson(new + { + command = "invalid", + connectRequestIssued = false, + comActivationAttempted = false, + errorCode = errorCode ?? "invalid-command-line" + }); + Console.Error.WriteLine( + "Usage: MBN_STOCK_WEBVIEW.PlayoutSmoke [--probe|--dry-run|--test-plan|--test-connect|--test-sequence]"); + Console.Error.WriteLine( + $"Test commands require {SmokeCommandLine.AcknowledgementFlag} and --config ."); + Console.Error.WriteLine( + "--test-plan and --test-sequence also require --prepare --next --observe-ms <1000..30000>."); + Console.Error.WriteLine( + "Only --test-connect and --test-sequence may request COM, after all Test safety gates pass."); return 64; } diff --git a/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/Properties/AssemblyInfo.cs b/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..89e7956 --- /dev/null +++ b/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.Playout.Tests")] diff --git a/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/SmokeCommandLine.cs b/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/SmokeCommandLine.cs new file mode 100644 index 0000000..44c7a34 --- /dev/null +++ b/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/SmokeCommandLine.cs @@ -0,0 +1,257 @@ +namespace MBN_STOCK_WEBVIEW.PlayoutSmoke; + +internal enum SmokeCommandKind +{ + Invalid, + Probe, + DryRun, + TestPlan, + TestConnect, + TestSequence +} + +internal sealed record SmokeCommandInvocation( + SmokeCommandKind Kind, + string? ConfigPath = null, + string? PrepareSceneCode = null, + string? NextSceneCode = null, + int? ObservationMilliseconds = null); + +internal sealed record SmokeCommandParseResult( + bool IsValid, + SmokeCommandInvocation Invocation, + string? ErrorCode = null); + +internal static class SmokeCommandLine +{ + internal const string AcknowledgementFlag = + "--acknowledge-isolated-non-program-test-output"; + + public static SmokeCommandParseResult Parse(IReadOnlyList arguments) + { + ArgumentNullException.ThrowIfNull(arguments); + if (arguments.Count == 0) + { + return Valid(new SmokeCommandInvocation(SmokeCommandKind.Probe)); + } + + if (arguments.Count == 1) + { + return arguments[0].ToLowerInvariant() switch + { + "--probe" => Valid(new SmokeCommandInvocation(SmokeCommandKind.Probe)), + "--dry-run" => Valid(new SmokeCommandInvocation(SmokeCommandKind.DryRun)), + _ => Invalid("unknown-command") + }; + } + + var kind = arguments[0].ToLowerInvariant() switch + { + "--test-plan" => SmokeCommandKind.TestPlan, + "--test-connect" => SmokeCommandKind.TestConnect, + "--test-sequence" => SmokeCommandKind.TestSequence, + _ => SmokeCommandKind.Invalid + }; + if (kind == SmokeCommandKind.Invalid) + { + return Invalid("unknown-command"); + } + + string? configPath = null; + string? prepareSceneCode = null; + string? nextSceneCode = null; + int? observationMilliseconds = null; + var acknowledged = false; + + for (var index = 1; index < arguments.Count; index++) + { + var argument = arguments[index]; + switch (argument) + { + case AcknowledgementFlag: + if (acknowledged) + { + return Invalid("duplicate-option"); + } + + acknowledged = true; + break; + + case "--config": + if (configPath is not null) + { + return Invalid("duplicate-option"); + } + + if (!TryReadValue(arguments, ref index, out configPath)) + { + return Invalid("missing-option-value"); + } + + break; + + case "--prepare" when kind is SmokeCommandKind.TestPlan or SmokeCommandKind.TestSequence: + if (prepareSceneCode is not null) + { + return Invalid("duplicate-option"); + } + + if (!TryReadValue(arguments, ref index, out prepareSceneCode)) + { + return Invalid("missing-option-value"); + } + + break; + + case "--next" when kind is SmokeCommandKind.TestPlan or SmokeCommandKind.TestSequence: + if (nextSceneCode is not null) + { + return Invalid("duplicate-option"); + } + + if (!TryReadValue(arguments, ref index, out nextSceneCode)) + { + return Invalid("missing-option-value"); + } + + break; + + case "--observe-ms" when kind is SmokeCommandKind.TestPlan or SmokeCommandKind.TestSequence: + if (observationMilliseconds is not null) + { + return Invalid("duplicate-option"); + } + + if (!TryReadValue(arguments, ref index, out var observationText) || + !int.TryParse( + observationText, + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out var observationValue) || + observationValue is < 1_000 or > 30_000) + { + return Invalid("invalid-observation-duration"); + } + + observationMilliseconds = observationValue; + break; + + default: + return Invalid("unknown-option"); + } + } + + if (!acknowledged) + { + return Invalid("missing-acknowledgement"); + } + + if (string.IsNullOrWhiteSpace(configPath)) + { + return Invalid("missing-required-option"); + } + + if (kind is SmokeCommandKind.TestPlan or SmokeCommandKind.TestSequence) + { + if (prepareSceneCode is null || nextSceneCode is null || observationMilliseconds is null) + { + return Invalid("missing-required-option"); + } + + if (!IsSafeSceneCode(prepareSceneCode) || !IsSafeSceneCode(nextSceneCode)) + { + return Invalid("unsafe-scene-code"); + } + + if (string.Equals( + prepareSceneCode, + nextSceneCode, + StringComparison.OrdinalIgnoreCase)) + { + return Invalid("duplicate-scene-code"); + } + } + + return Valid(new SmokeCommandInvocation( + kind, + configPath, + prepareSceneCode, + nextSceneCode, + observationMilliseconds)); + } + + internal static bool IsSafeSceneCode(string value) + { + if (value.Length is < 1 or > 64) + { + return false; + } + + foreach (var character in value) + { + if (!((character >= 'A' && character <= 'Z') || + (character >= 'a' && character <= 'z') || + (character >= '0' && character <= '9') || + character is '_' or '-')) + { + return false; + } + } + + return true; + } + + internal static bool IsUnsafeTornadoWindowTitle(string? title) + { + if (string.IsNullOrWhiteSpace(title) || + title.Contains("PGM", StringComparison.OrdinalIgnoreCase) || + title.Contains("PROGRAM", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + const string marker = "TEST"; + for (var start = 0; start <= title.Length - marker.Length; start++) + { + if (!title.AsSpan(start, marker.Length).Equals( + marker.AsSpan(), + StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var beforeIsBoundary = start == 0 || !char.IsLetterOrDigit(title[start - 1]); + var after = start + marker.Length; + var afterIsBoundary = after == title.Length || !char.IsLetterOrDigit(title[after]); + if (beforeIsBoundary && afterIsBoundary) + { + return false; + } + } + + return true; + } + + private static bool TryReadValue( + IReadOnlyList arguments, + ref int index, + out string? value) + { + if (index + 1 >= arguments.Count || + string.IsNullOrWhiteSpace(arguments[index + 1]) || + arguments[index + 1].StartsWith("--", StringComparison.Ordinal)) + { + value = null; + return false; + } + + value = arguments[++index]; + return true; + } + + private static SmokeCommandParseResult Valid(SmokeCommandInvocation invocation) => + new(true, invocation); + + private static SmokeCommandParseResult Invalid(string errorCode) => + new(false, new SmokeCommandInvocation(SmokeCommandKind.Invalid), errorCode); +}