diff --git a/MainWindow.Playout.cs b/MainWindow.Playout.cs index 33d2caa..6af397c 100644 --- a/MainWindow.Playout.cs +++ b/MainWindow.Playout.cs @@ -221,6 +221,12 @@ public sealed partial class MainWindow connectionState = "unavailable", processDetected = false, connected = false, + lastKtapConnectState = "not-attempted", + ktapConnectAttempted = false, + ktapConnectAccepted = false, + ktapHelloObserved = (bool?)null, + networkMonitoringRecordExpected = false, + networkMonitoringCheckRequired = false, commandAvailable = false, liveTakeInAllowed = false, outcomeUnknown = false, @@ -241,6 +247,12 @@ public sealed partial class MainWindow connectionState = "unavailable", processDetected = false, connected = false, + lastKtapConnectState = "not-attempted", + ktapConnectAttempted = false, + ktapConnectAccepted = false, + ktapHelloObserved = (bool?)null, + networkMonitoringRecordExpected = false, + networkMonitoringCheckRequired = false, commandAvailable = false, liveTakeInAllowed = false, outcomeUnknown = false, @@ -268,6 +280,12 @@ public sealed partial class MainWindow connectionState = ToWireValue(status.State), processDetected = status.IsProcessRunning, connected = status.IsConnected, + lastKtapConnectState = ToWireValue(status.LastKtapConnectState), + ktapConnectAttempted = status.KtapConnectAttempted, + ktapConnectAccepted = status.KtapConnectAccepted, + ktapHelloObserved = status.KtapHelloObserved, + networkMonitoringRecordExpected = status.NetworkMonitoringRecordExpected, + networkMonitoringCheckRequired = status.NetworkMonitoringCheckRequired, commandAvailable = status.IsCommandAvailable, liveTakeInAllowed = status.LiveTakeInAllowed, outcomeUnknown = status.State == PlayoutConnectionState.OutcomeUnknown, @@ -287,6 +305,12 @@ public sealed partial class MainWindow connectionState = ToWireValue(status.State), processDetected = status.IsProcessRunning, connected = status.IsConnected, + lastKtapConnectState = ToWireValue(status.LastKtapConnectState), + ktapConnectAttempted = status.KtapConnectAttempted, + ktapConnectAccepted = status.KtapConnectAccepted, + ktapHelloObserved = status.KtapHelloObserved, + networkMonitoringRecordExpected = status.NetworkMonitoringRecordExpected, + networkMonitoringCheckRequired = status.NetworkMonitoringCheckRequired, commandAvailable = status.IsCommandAvailable, liveTakeInAllowed = status.LiveTakeInAllowed, outcomeUnknown = status.State == PlayoutConnectionState.OutcomeUnknown, @@ -376,6 +400,15 @@ public sealed partial class MainWindow _ => "unavailable" }; + private static string ToWireValue(PlayoutKtapConnectState state) => state switch + { + PlayoutKtapConnectState.NotAttempted => "not-attempted", + PlayoutKtapConnectState.Attempted => "attempted", + PlayoutKtapConnectState.AcceptedUnconfirmed => "accepted-unconfirmed", + PlayoutKtapConnectState.Failed => "failed", + _ => "not-attempted" + }; + private static string ToWireValue(PlayoutResultCode code) => code switch { PlayoutResultCode.Rejected => "REJECTED", diff --git a/README.md b/README.md index 975f6c5..ba3d60d 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke ` 별도 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`이며 실제 자산 경로는 저장소에 기록하지 않습니다. +중요: 앱의 기본 `DryRun`, `--probe`, `--dry-run`, `--test-plan`은 `KTAPConnect`를 호출하지 않습니다. 따라서 이 단계에서 Tornado2의 `View > Network Monitoring Window`에 앱이 보낸 통신 기록이 없는 것은 정상입니다. 이 CLI 절차에서는 모든 안전 게이트를 통과한 격리 `Test`의 `--test-connect`에서 처음 네트워크 기록을 기대하며, 별도로 승인된 UI Test 실행도 기록을 만들 수 있습니다. 사용할 포트는 예제 숫자가 아니라 해당 격리 Test Tornado의 `Tools > Option > Control > Network Server > TCP Port` 실값이어야 합니다. + MSIX 생성: ```powershell diff --git a/Web/app.js b/Web/app.js index a181fb0..1282fdb 100644 --- a/Web/app.js +++ b/Web/app.js @@ -45,6 +45,12 @@ connectionState: "unavailable", processDetected: false, connected: false, + lastKtapConnectState: "not-attempted", + ktapConnectAttempted: false, + ktapConnectAccepted: false, + ktapHelloObserved: null, + networkMonitoringRecordExpected: false, + networkMonitoringCheckRequired: false, commandAvailable: false, liveTakeInAllowed: false, nativeOutcomeUnknown: false, @@ -419,9 +425,21 @@ "disposed": "Disposed", "unavailable": "Unavailable" }; - elements.playoutConnectionState.textContent = connectionLabels[state.playout.connectionState] || "Unavailable"; + const lastKtapLabels = { + "attempted": "마지막 KTAP 결과 미확인", + "accepted-unconfirmed": "마지막 KTAP 수락 · OnHello 미확인", + "failed": "마지막 KTAP 실패" + }; + const currentConnectionLabel = connectionLabels[state.playout.connectionState] || "Unavailable"; + elements.playoutConnectionState.textContent = dryRun + ? "KTAP 미호출" + : (state.playout.connected && state.playout.lastKtapConnectState === "accepted-unconfirmed" + ? "KTAP 수락 · OnHello 미확인" + : `${currentConnectionLabel}${lastKtapLabels[state.playout.lastKtapConnectState] + ? ` · ${lastKtapLabels[state.playout.lastKtapConnectState]}` + : ""}`); setStatusDot(elements.playoutConnectionDot, state.playout.connected - ? "healthy" + ? (state.playout.ktapHelloObserved === true ? "healthy" : "warn") : (transitionalConnection ? "warn" : (bridgeReady ? "error" : "unconfigured"))); const safetyContainer = elements.playoutSafetyMode.closest(".playout-health-item"); @@ -461,15 +479,17 @@ } else if (dryRun) { elements.playoutSummary.classList.add("dry-run"); elements.playoutSummaryBadge.textContent = "DRY RUN"; - elements.playoutSummaryDetail.textContent = bridgeReady ? "안전 모드 · PROGRAM 출력 차단" : "브라우저 미리보기 전용"; + elements.playoutSummaryDetail.textContent = bridgeReady ? "KTAP 미호출 · 모니터 기록 없음(정상)" : "브라우저 미리보기 전용"; } else if (state.playout.connected) { elements.playoutSummary.classList.add("ready"); elements.playoutSummaryBadge.textContent = testMode ? (state.playout.liveTakeInAllowed ? "TEST READY" : "TEST LOCKED") : (state.playout.liveTakeInAllowed ? "LIVE READY" : "CONNECTED"); - elements.playoutSummaryDetail.textContent = testMode - ? "테스트 전용 출력 · 안전 게이트 적용" - : (state.playout.processDetected ? "Tornado2 프로세스 · 엔진 연결됨" : "엔진 연결됨 · 프로세스 미감지"); + elements.playoutSummaryDetail.textContent = state.playout.lastKtapConnectState === "accepted-unconfirmed" + ? "KTAP 수락 · OnHello/[R]/[S] 확인 필요" + : (testMode + ? "테스트 전용 출력 · 안전 게이트 적용" + : (state.playout.processDetected ? "Tornado2 프로세스 · 엔진 연결됨" : "엔진 연결됨 · 프로세스 미감지")); } else { elements.playoutSummary.classList.add("error"); elements.playoutSummaryBadge.textContent = bridgeReady ? "OFFLINE" : "PREVIEW"; @@ -614,6 +634,19 @@ state.playout.connectionState = playoutSafety.normalizeConnectionState(payload.connectionState); state.playout.processDetected = payload.processDetected === true; state.playout.connected = payload.connected === true; + state.playout.lastKtapConnectState = ["not-attempted", "attempted", "accepted-unconfirmed", "failed"] + .includes(payload.lastKtapConnectState) ? payload.lastKtapConnectState : "not-attempted"; + state.playout.ktapConnectAttempted = payload.ktapConnectAttempted === true; + state.playout.ktapConnectAccepted = payload.ktapConnectAccepted === true + ? true + : (payload.ktapConnectAccepted === false ? false : null); + state.playout.ktapHelloObserved = payload.ktapHelloObserved === true + ? true + : (payload.ktapHelloObserved === false ? false : null); + state.playout.networkMonitoringRecordExpected = payload.networkMonitoringRecordExpected === true + ? true + : (payload.networkMonitoringRecordExpected === false ? false : null); + state.playout.networkMonitoringCheckRequired = payload.networkMonitoringCheckRequired === true; state.playout.commandAvailable = payload.commandAvailable === true; state.playout.liveTakeInAllowed = payload.liveTakeInAllowed === true; state.playout.nativeOutcomeUnknown = payload.outcomeUnknown === true || @@ -642,7 +675,7 @@ } renderPlayout(); - 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("|"); + const signature = [state.playout.mode, state.playout.engineState, state.playout.connectionState, state.playout.processDetected, state.playout.connected, state.playout.lastKtapConnectState, 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; diff --git a/docs/LEGACY_PLAYOUT_ANALYSIS.md b/docs/LEGACY_PLAYOUT_ANALYSIS.md index 8946abc..9234f28 100644 --- a/docs/LEGACY_PLAYOUT_ANALYSIS.md +++ b/docs/LEGACY_PLAYOUT_ANALYSIS.md @@ -6,6 +6,8 @@ 원본 연결은 UI STA에서 `KTAPConnect(1, "127.0.0.1", 30001, 0, event)`를 호출한 뒤 `GetScenePlayer()`를 얻습니다. 연결 성공 판정은 재연결 코드와 동일하게 반환값 `1`입니다. +여기의 `30001`은 원본 시스템의 당시 값일 뿐 새 Test endpoint의 기본값이나 검증값이 아닙니다. 새 설정은 격리 Test Tornado의 현재 `Tools > Option > Control > Network Server > TCP Port`를 직접 확인해 사용합니다. 반환값 `1`도 매뉴얼의 `OnHello` 또는 Network Monitoring `[R]`/`[S]` 확인을 대신하지 않습니다. + 새 장면 PREPARE의 기준 순서는 다음과 같습니다. 1. `LoadScene(Cuts\.t2s, )` diff --git a/docs/PLAYOUT.md b/docs/PLAYOUT.md index c345420..47cf10a 100644 --- a/docs/PLAYOUT.md +++ b/docs/PLAYOUT.md @@ -108,6 +108,14 @@ MBN_STOCK_PLAYOUT_RECONNECT_ENABLED `testSceneAllowlist`와 `trustedLiveOutputEnabled`는 환경 변수로 변경할 수 없으며 로컬 설정 파일에서만 관리합니다. `SceneDirectory`는 Test/Live에서 존재하는 비-reparse 외부 디렉터리여야 하며, 엔진은 상대 `.t2s` 파일을 정규화해 이 루트 밖으로 나가는 경로를 거부합니다. scene file의 basename과 scene name 및 Test allowlist 항목도 서로 일치해야 합니다. 설정 파일은 실행 계정만 읽을 수 있도록 ACL을 제한합니다. 라이선스 키나 인증정보를 이 파일에 기록하지 않습니다. +### KTAP 포트와 Network Monitoring 판정 + +K3DAsyncEngine 매뉴얼의 `KTAPConnect(bTCP, HostAddress, nHostPort, nClientPort, handler)`에서 Test/Live는 `tcpMode: 1`(TCP)만 허용하므로 로컬 JSON의 `port`는 격리 Test Tornado의 `Tools > Option > Control > Network Server > TCP Port`와 정확히 같아야 합니다. `clientPort`는 UDP일 때만 의미가 있고 `TAP TCP Port`/`TAP UDP Port`는 이 연결의 host port가 아닙니다. 매뉴얼 18쪽의 설명과 26쪽 및 192쪽 예시에 표시된 포트 숫자가 서로 다르므로 `30001`/`30002`를 추정하거나 원본·예제 값을 복사하지 않습니다. 해당 회차 Test 인스턴스 화면의 설정값이 유일한 기준입니다. `playout.example.json`의 `30001`도 DryRun 구조 예시일 뿐 새 Test endpoint의 검증값이 아닙니다. + +Tornado2의 `View > Network Monitoring Window`에서 `[R]`은 서버가 클라이언트 요청을 받은 기록, `[S]`는 서버가 응답을 보낸 기록이며 `TCPSession`은 TCP 세션 수입니다(매뉴얼 26~27쪽). 프로세스 감지, COM 등록 probe 또는 COM 객체 생성만으로는 이 기록이 생기지 않습니다. 기본 앱과 `--dry-run`, `--probe`, `--test-plan`은 KTAP를 호출하지 않으므로 빈 모니터가 정상입니다. + +상태의 `accepted-unconfirmed`는 `KTAPConnect`가 SDK 성공값 `1`을 반환했다는 뜻일 뿐입니다. 매뉴얼 41쪽의 `OnHello` 콜백이나 실제 `[R]`/`[S]`를 자동 확인했다는 뜻이 아닙니다. 현재 late-bound 어댑터는 282개 메서드 `IKAEventHandler` ABI를 안전하게 패키징하는 검증된 전략이 없어 `ktapHelloObserved`를 `null`로 보고합니다. `lastKtapConnectState`는 현재 연결 상태가 아니라 가장 최근 KTAP dispatch 시도의 증거이며, 화면은 `Connected`/`Faulted` 같은 현재 상태와 분리해 표시합니다. 따라서 격리 `--test-connect`가 성공했는데도 같은 시각의 `[R]`/`[S]`가 전혀 없다면 `--test-sequence`로 진행하지 말고 mode/config 파일, 실제 Network Server TCP Port와 안전 게이트 거부 여부를 먼저 확인합니다. + ## 모드와 안전 게이트 `Disabled`는 모든 송출 명령을 거부하는 운영 롤백 모드입니다. `DryRun`은 COM 없이 WebView 동작을 성공 결과로 모의합니다. `Test`와 `Live`만 등록된 COM을 사용할 수 있습니다. @@ -173,6 +181,8 @@ dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke ` 별도 Test Tornado만 정확히 하나 실행되고 PGM/PROGRAM 창이 전혀 없는 것을 사람이 다시 확인한 뒤 연결만 검증합니다. 이 명령은 씬이나 출력 상태를 변경하지 않고 `Connect → Disconnect`만 요청합니다. +명령 전에 Network Monitoring을 열고 `TCPSession`과 로그 시각을 기준선으로 기록합니다. `--test-plan`에는 변화가 없어야 합니다. `--test-connect`는 짧게 `Connect → Disconnect`하므로 세션 수가 곧 원래 값으로 돌아갈 수 있습니다. 일시적인 세션 수만 보지 말고 같은 시각의 `[R]`/`[S]` 양방향 기록을 확인합니다. `[R]`만 있고 `[S]`가 없거나 양쪽 모두 없으면 명령을 반복하지 말고 중단합니다. JSON의 `completed: true`, `outcomeUnknown: false`, `lastKtapConnectState: "accepted-unconfirmed"`와 운영자가 본 모니터 기록을 함께 확인한 뒤에만 5001→5006 시퀀스로 진행합니다. + ```powershell dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke ` -c Debug -p:Platform=x64 -- ` @@ -196,7 +206,9 @@ dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke ` 시퀀스는 `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`는 자산 계획만 검증했다는 뜻이며 실제 연결 가능성을 증명하지 않습니다. +JSON 결과는 단계별 operation/result code와 `connectRequestIssued`, nullable `comActivationAttempted`, `lastKtapConnectState`, `ktapConnectAttempted`, nullable `ktapConnectAccepted`, nullable `ktapHelloObserved`, nullable `networkMonitoringRecordExpected`, `networkMonitoringCheckRequired`, nullable `networkMonitoringVerified`, `outputMayBeActive`, quarantine 시도·완료 여부를 제공합니다. `connectRequestIssued`는 엔진 API 요청일 뿐 KTAP 통신 증거가 아니며, `comActivationAttempted`도 COM 활성화 추정값일 뿐입니다. `lastKtapConnectState`는 `not-attempted`, `attempted`, `accepted-unconfirmed`, `failed` 중 하나입니다. `networkMonitoringRecordExpected`는 성공값을 받은 경우 `true`, dispatch가 없으면 `false`, local reflection/COM 실패 또는 timeout으로 서버 도달을 예측할 수 없으면 `null`입니다. `networkMonitoringCheckRequired`는 KTAP dispatch 경로에 들어간 모든 경우 `true`이며, `networkMonitoringVerified`는 앱이 Tornado2 UI를 판독하지 않으므로 항상 `null`입니다. 운영자가 직접 `[R]`/`[S]`를 확인해야 합니다. `outputMayBeActive: true`이면 자동 정리를 성공으로 확인하지 못했으므로 사람이 격리 출력을 확인해야 합니다. 특히 `Unavailable`, 취소, timeout은 연결 전 거부와 연결 도중 안전 게이트 변화가 같은 결과 code가 될 수 있으므로 추측하지 않습니다. 로컬 경로, 씬 code, PID, 창 제목, HRESULT 및 엔진 원문 오류는 출력하지 않습니다. `--test-plan`의 `runtimeProcessGateChecked: false`는 자산 계획만 검증했다는 뜻이며 실제 연결 가능성을 증명하지 않습니다. + +연결 timeout은 COM 활성화와 KTAP dispatch 사이의 원자적 게이트를 닫습니다. timeout이 먼저 게이트를 닫으면 늦게 끝난 STA 작업도 `KTAPConnect`에 진입할 수 없고 `lastKtapConnectState: "not-attempted"`로 남습니다. dispatch가 먼저 게이트를 획득한 경우에는 `attempted` 이상으로 보고하고 `networkMonitoringCheckRequired: true`로 남겨 운영자 확인을 요구합니다. 현재 장비처럼 PGM Tornado가 실행 중이거나 loopback 포트를 PGM이 소유한 상태에서는 `--test-connect`와 `--test-sequence`를 실행하지 않습니다. 기존 Test 엔진도 exactly-one Tornado2, non-PGM 제목 정규식, loopback literal, Test outputChannel, 외부 non-reparse scene root와 allowlist를 연결 전 및 각 명령 전에 재검사합니다. diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs index 9673a9d..9de150a 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/IPlayoutEngine.cs @@ -45,6 +45,14 @@ public enum PlayoutResultCode OutcomeUnknown } +public enum PlayoutKtapConnectState +{ + NotAttempted, + Attempted, + AcceptedUnconfirmed, + Failed +} + public enum PlayoutTakeOutScope { Layout, @@ -83,6 +91,52 @@ public sealed record PlayoutStatus( long Sequence) { public int OperationTimeoutMilliseconds { get; init; } = 5_000; + + /// + /// True only after the adapter entered the KAEngine.KTAPConnect dispatch path. + /// Process discovery, registration probes, DryRun, and configuration preflight do not set it. + /// Local reflection or COM marshalling can still fail before a server receives anything. + /// + public PlayoutKtapConnectState LastKtapConnectState { get; init; } = + PlayoutKtapConnectState.NotAttempted; + + public bool KtapConnectAttempted => + LastKtapConnectState != PlayoutKtapConnectState.NotAttempted; + + /// + /// True only when KTAPConnect returned the SDK success value (1). This does not prove that + /// OnHello was observed or that Tornado Network Monitoring contains matching [R]/[S] rows. + /// + public bool? KtapConnectAccepted => LastKtapConnectState switch + { + PlayoutKtapConnectState.AcceptedUnconfirmed => true, + PlayoutKtapConnectState.NotAttempted => false, + _ => null + }; + + /// + /// The late-bound adapter currently cannot observe the 282-method IKAEventHandler callback + /// interface without a separately validated interop strategy. Null therefore means that the + /// OnHello callback was not programmatically verified, not that Tornado rejected the request. + /// + public bool? KtapHelloObserved { get; init; } + + /// + /// True only when the SDK returned its success value, false when no dispatch was attempted, + /// and null when a local/COM failure or timeout prevents a reliable prediction. + /// + public bool? NetworkMonitoringRecordExpected => LastKtapConnectState switch + { + PlayoutKtapConnectState.AcceptedUnconfirmed => true, + PlayoutKtapConnectState.NotAttempted => false, + _ => null + }; + + /// + /// Any KTAP dispatch attempt requires an operator to inspect the isolated Test instance, + /// even when the adapter cannot predict whether a matching record should exist. + /// + public bool NetworkMonitoringCheckRequired => KtapConnectAttempted; } public sealed class PlayoutStatusChangedEventArgs : EventArgs diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs index e7f0d57..64af6d6 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs @@ -11,6 +11,10 @@ internal interface IK3dSession : IDisposable { bool IsConnected { get; } + PlayoutKtapConnectState LastKtapConnectState { get; } + + bool TryPreventKtapConnect(); + void Connect(ValidatedPlayoutOptions options); void Disconnect(); @@ -87,6 +91,8 @@ internal sealed class DynamicK3dSession : IK3dSession private object? _player; private object? _scene; private int? _ownerThreadId; + private int _lastKtapConnectState; + private int _ktapDispatchGate; private bool _disposed; public DynamicK3dSession(ILateBoundComActivator activator) @@ -104,6 +110,23 @@ internal sealed class DynamicK3dSession : IK3dSession public bool IsConnected => _engine is not null && _player is not null; + public PlayoutKtapConnectState LastKtapConnectState => + (PlayoutKtapConnectState)Volatile.Read(ref _lastKtapConnectState); + + public bool TryPreventKtapConnect() + { + var previous = Interlocked.CompareExchange(ref _ktapDispatchGate, 2, 0); + if (previous == 1) + { + Interlocked.CompareExchange( + ref _lastKtapConnectState, + (int)PlayoutKtapConnectState.Attempted, + (int)PlayoutKtapConnectState.NotAttempted); + } + + return previous == 0; + } + public void Connect(ValidatedPlayoutOptions options) { ArgumentNullException.ThrowIfNull(options); @@ -119,7 +142,14 @@ internal sealed class DynamicK3dSession : IK3dSession { _eventHandler = _activator.Create(K3dComConstants.KaEventHandlerClassGuid); _engine = _activator.Create(K3dComConstants.KaEngineClassGuid); + if (Interlocked.CompareExchange(ref _ktapDispatchGate, 1, 0) != 0) + { + throw new InvalidOperationException( + "The K3D connection was abandoned before KTAP dispatch."); + } + ktapConnectAttempted = true; + SetKtapConnectState(PlayoutKtapConnectState.Attempted); var result = Invoke( _engine, "KTAPConnect", @@ -133,15 +163,23 @@ internal sealed class DynamicK3dSession : IK3dSession : Convert.ToInt32(result, CultureInfo.InvariantCulture); if (hresult != 1) { + SetKtapConnectState(PlayoutKtapConnectState.Failed); throw new InvalidOperationException("The K3D connection was rejected."); } + SetKtapConnectState(PlayoutKtapConnectState.AcceptedUnconfirmed); + _player = options.OutputChannel is { } channel ? InvokeRequired(_engine, "GetScenePlayerOnChannel", channel) : InvokeRequired(_engine, "GetScenePlayer"); } catch (Exception exception) { + if (LastKtapConnectState == PlayoutKtapConnectState.Attempted) + { + SetKtapConnectState(PlayoutKtapConnectState.Failed); + } + var originalFailure = ExceptionDispatchInfo.Capture(exception); if (ktapConnectAttempted && _engine is not null) { @@ -161,6 +199,9 @@ internal sealed class DynamicK3dSession : IK3dSession } } + private void SetKtapConnectState(PlayoutKtapConnectState state) => + Volatile.Write(ref _lastKtapConnectState, (int)state); + public void Disconnect() { EnsureThread(); @@ -277,6 +318,7 @@ internal sealed class DynamicK3dSession : IK3dSession } EnsureThread(); + TryPreventKtapConnect(); _disposed = true; ReleaseAll(); } diff --git a/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs b/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs index 205c355..f3d7888 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs @@ -33,6 +33,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine private DateTimeOffset _nextReconnectAt; private int _reconnectAttempts; private long _sequence; + private int _lastKtapConnectState; private bool _connectionRequested; private volatile bool _outcomeUnknown; private volatile bool _disposed; @@ -215,7 +216,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine { if (_processSnapshot != previousSnapshot) { - PublishStatus("송출 dry-run이 준비되었습니다."); + PublishStatus(DryRunMonitoringMessage); } return; @@ -378,11 +379,12 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine if (_options.Mode == PlayoutMode.DryRun) { _connectionState = PlayoutConnectionState.DryRunReady; - PublishStatus("송출 dry-run이 준비되었습니다."); + var message = DryRunOperationMessage("연결 요청을 확인했습니다."); + PublishStatus(message); return Task.FromResult(Result( PlayoutOperation.Connect, PlayoutResultCode.Success, - "송출 연결을 dry-run으로 확인했습니다.")); + message)); } _connectionRequested = true; @@ -500,7 +502,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine () => { var session = _sessionFactory.Create(); - pendingSession = session; + Volatile.Write(ref pendingSession, session); try { session.Connect(_options); @@ -515,7 +517,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine _options.ConnectTimeout, cancellationToken, static lateSession => lateSession.Dispose(), - () => pendingSession?.Dispose()).ConfigureAwait(false); + () => Volatile.Read(ref pendingSession)?.Dispose()).ConfigureAwait(false); + + CaptureKtapEvidence(_session); _processSnapshot = CaptureProcessSnapshot(); if (!IsProcessEligible(_processSnapshot) || @@ -545,25 +549,32 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine _reconnectAttempts = 0; _preparedCue = null; _onAirSceneName = null; - PublishStatus("Tornado 송출에 연결되었습니다."); - return Result(PlayoutOperation.Connect, PlayoutResultCode.Success, "송출 연결이 준비되었습니다."); + PublishStatus("KTAPConnect가 수락되었습니다. OnHello 및 Network Monitoring [R]/[S]는 아직 확인되지 않았습니다."); + return Result( + PlayoutOperation.Connect, + PlayoutResultCode.Success, + "KTAPConnect 요청이 수락되었습니다. 서버 콜백은 운영자 확인이 필요합니다."); } catch (OperationCanceledException) { + PreventLateKtapDispatchAndCapture(Volatile.Read(ref pendingSession)); _connectionState = PlayoutConnectionState.Disconnected; PublishStatus("송출 연결 요청이 취소되었습니다."); return Result(PlayoutOperation.Connect, PlayoutResultCode.Cancelled, "송출 요청이 취소되었습니다."); } catch (StaOperationTimedOutException) { + PreventLateKtapDispatchAndCapture(Volatile.Read(ref pendingSession)); return MarkOutcomeUnknown(PlayoutOperation.Connect); } catch (StaDispatcherQuarantinedException) { + PreventLateKtapDispatchAndCapture(Volatile.Read(ref pendingSession)); return MarkOutcomeUnknown(PlayoutOperation.Connect); } catch (Exception) { + CaptureKtapEvidence(Volatile.Read(ref pendingSession)); _session = null; ScheduleReconnect(); _connectionState = reconnecting @@ -588,8 +599,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine _preparedCue = null; _onAirSceneName = null; _connectionState = PlayoutConnectionState.DryRunReady; - PublishStatus("송출 dry-run이 준비되었습니다."); - return Result(PlayoutOperation.Disconnect, PlayoutResultCode.Success, "송출 해제를 dry-run으로 확인했습니다."); + var message = DryRunOperationMessage("연결 해제를 확인했습니다."); + PublishStatus(message); + return Result(PlayoutOperation.Disconnect, PlayoutResultCode.Success, message); } if (_outcomeUnknown || _dispatcher?.IsQuarantined == true) @@ -627,8 +639,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine if (_options.Mode == PlayoutMode.DryRun) { _preparedCue = cue with { SceneName = SafeSceneName(cue) }; - PublishStatus("장면 준비를 dry-run으로 확인했습니다."); - return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Success, "장면 준비를 dry-run으로 확인했습니다.")); + var message = DryRunOperationMessage("장면 준비를 확인했습니다."); + PublishStatus(message); + return Task.FromResult(Result(PlayoutOperation.Prepare, PlayoutResultCode.Success, message)); } if (_options.Mode == PlayoutMode.Live && !IsLiveAuthorized()) @@ -698,8 +711,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine { _onAirSceneName = SafeSceneName(_preparedCue); _preparedCue = null; - PublishStatus("TAKE IN을 dry-run으로 확인했습니다."); - return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Success, "TAKE IN을 dry-run으로 확인했습니다."); + var message = DryRunOperationMessage("TAKE IN을 확인했습니다."); + PublishStatus(message); + return Result(PlayoutOperation.TakeIn, PlayoutResultCode.Success, message); } if (!CanTakeIn()) @@ -754,8 +768,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine { _onAirSceneName = SafeSceneName(cue); _preparedCue = null; - PublishStatus("NEXT를 dry-run으로 확인했습니다."); - return Result(PlayoutOperation.Next, PlayoutResultCode.Success, "NEXT를 dry-run으로 확인했습니다."); + var message = DryRunOperationMessage("NEXT를 확인했습니다."); + PublishStatus(message); + return Result(PlayoutOperation.Next, PlayoutResultCode.Success, message); } if (!CanTakeIn()) @@ -822,8 +837,9 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine { _preparedCue = null; _onAirSceneName = null; - PublishStatus("TAKE OUT을 dry-run으로 확인했습니다."); - return Result(PlayoutOperation.TakeOut, PlayoutResultCode.Success, "TAKE OUT을 dry-run으로 확인했습니다."); + var message = DryRunOperationMessage("TAKE OUT을 확인했습니다."); + PublishStatus(message); + return Result(PlayoutOperation.TakeOut, PlayoutResultCode.Success, message); } if (_options.Mode == PlayoutMode.Live && !IsLiveAuthorized()) @@ -1285,10 +1301,38 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine _timeProvider.GetUtcNow(), Interlocked.Increment(ref _sequence)) { - OperationTimeoutMilliseconds = (int)_options.OperationTimeout.TotalMilliseconds + OperationTimeoutMilliseconds = (int)_options.OperationTimeout.TotalMilliseconds, + LastKtapConnectState = (PlayoutKtapConnectState)Volatile.Read( + ref _lastKtapConnectState), + KtapHelloObserved = null }; } + private void CaptureKtapEvidence(IK3dSession? session) + { + if (session is null) + { + return; + } + + var state = session.LastKtapConnectState; + if (state != PlayoutKtapConnectState.NotAttempted) + { + Volatile.Write(ref _lastKtapConnectState, (int)state); + } + } + + private void PreventLateKtapDispatchAndCapture(IK3dSession? session) + { + if (session is null) + { + return; + } + + session.TryPreventKtapConnect(); + CaptureKtapEvidence(session); + } + private PlayoutResult Result( PlayoutOperation operation, PlayoutResultCode code, @@ -1323,7 +1367,8 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine private string CurrentMessage() => _connectionState switch { - PlayoutConnectionState.Connected => "Tornado 송출에 연결되었습니다.", + PlayoutConnectionState.Connected => + "KTAPConnect가 수락되었습니다. OnHello 및 Network Monitoring [R]/[S]는 아직 확인되지 않았습니다.", PlayoutConnectionState.Reconnecting => "Tornado 송출에 다시 연결하는 중입니다.", PlayoutConnectionState.Faulted => "Tornado 송출 연결을 확인하세요.", _ => InitialMessage(_options.Mode) @@ -1332,10 +1377,16 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine private static string InitialMessage(PlayoutMode mode) => mode switch { PlayoutMode.Disabled => "송출 기능이 비활성화되어 있습니다.", - PlayoutMode.DryRun => "송출 dry-run이 준비되었습니다.", + PlayoutMode.DryRun => DryRunMonitoringMessage, _ => "Tornado 송출 연결이 필요합니다." }; + private const string DryRunMonitoringMessage = + "DryRun: COM/KTAPConnect를 호출하지 않았습니다. Network Monitoring 기록 없음이 정상입니다."; + + private static string DryRunOperationMessage(string operation) => + $"DryRun: {operation} COM/KTAPConnect는 호출하지 않았으며 Network Monitoring 기록 없음이 정상입니다."; + private string ProcessUnavailableMessage(TornadoProcessSnapshot snapshot) { if (_options.Mode == PlayoutMode.Test) diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DryRunPlayoutEngineTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DryRunPlayoutEngineTests.cs index be3de6a..0c9070c 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DryRunPlayoutEngineTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DryRunPlayoutEngineTests.cs @@ -15,6 +15,14 @@ public sealed class DryRunPlayoutEngineTests Assert.Equal(PlayoutConnectionState.DryRunReady, engine.Status.State); Assert.True(engine.Status.IsCommandAvailable); Assert.False(engine.Status.IsConnected); + Assert.Equal( + PlayoutKtapConnectState.NotAttempted, + engine.Status.LastKtapConnectState); + Assert.False(engine.Status.KtapConnectAttempted); + Assert.False(engine.Status.NetworkMonitoringRecordExpected); + Assert.False(engine.Status.NetworkMonitoringCheckRequired); + Assert.Null(engine.Status.KtapHelloObserved); + Assert.Contains("Network Monitoring", engine.Status.Message, StringComparison.Ordinal); Assert.True(result.IsSuccess); Assert.True(result.IsDryRun); } diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs index b7a1e42..55de503 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs @@ -39,6 +39,9 @@ public sealed class DynamicK3dSessionTests catch { Assert.False(session.IsConnected); + Assert.Equal( + PlayoutKtapConnectState.Failed, + session.LastKtapConnectState); throw; } @@ -86,19 +89,29 @@ public sealed class DynamicK3dSessionTests (engine, "Engine"), (activator.EventHandler, "EventHandler")); await using var dispatcher = new StaDispatcher(capacity: 1); + var lastKtapConnectState = PlayoutKtapConnectState.NotAttempted; var exception = await Assert.ThrowsAsync( () => dispatcher.InvokeAsync( () => { using var session = new DynamicK3dSession(activator, releaser); - session.Connect(options); + try + { + session.Connect(options); + } + finally + { + lastKtapConnectState = session.LastKtapConnectState; + } + return true; }, TimeSpan.FromSeconds(5), CancellationToken.None)); Assert.Same(originalFailure, exception); + Assert.Equal(PlayoutKtapConnectState.AcceptedUnconfirmed, lastKtapConnectState); Assert.Equal( new[] { @@ -134,19 +147,29 @@ public sealed class DynamicK3dSessionTests (engine, "Engine"), (activator.EventHandler, "EventHandler")); await using var dispatcher = new StaDispatcher(capacity: 1); + var lastKtapConnectState = PlayoutKtapConnectState.NotAttempted; var exception = await Assert.ThrowsAsync( () => dispatcher.InvokeAsync( () => { using var session = new DynamicK3dSession(activator, releaser); - session.Connect(options); + try + { + session.Connect(options); + } + finally + { + lastKtapConnectState = session.LastKtapConnectState; + } + return true; }, TimeSpan.FromSeconds(5), CancellationToken.None)); Assert.Same(originalFailure, exception); + Assert.Equal(PlayoutKtapConnectState.Failed, lastKtapConnectState); Assert.Equal( new[] { @@ -190,6 +213,9 @@ public sealed class DynamicK3dSessionTests { using var session = new DynamicK3dSession(activator); session.Connect(options); + Assert.Equal( + PlayoutKtapConnectState.AcceptedUnconfirmed, + session.LastKtapConnectState); session.Prepare(cue, options.LayoutIndex); session.Play(options.LayoutIndex); session.TakeOut(options.LayoutIndex, takeOutScope); @@ -236,6 +262,88 @@ public sealed class DynamicK3dSessionTests Assert.Equal(typeof(object), create.ReturnType); } + [Fact] + public async Task Connect_WhenComActivationFails_ReportsKtapNotAttempted() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path)); + var originalFailure = new InvalidOperationException("fake activation failure"); + var lastKtapConnectState = PlayoutKtapConnectState.Failed; + await using var dispatcher = new StaDispatcher(capacity: 1); + + var exception = await Assert.ThrowsAsync( + () => dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession( + new ThrowingActivator(originalFailure)); + try + { + session.Connect(options); + } + finally + { + lastKtapConnectState = session.LastKtapConnectState; + } + + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None)); + + Assert.Same(originalFailure, exception); + Assert.Equal(PlayoutKtapConnectState.NotAttempted, lastKtapConnectState); + } + + [Fact] + public async Task PreventKtapConnect_DuringActivation_BlocksLateDispatch() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + using var activationStarted = new ManualResetEventSlim(); + using var releaseActivation = new ManualResetEventSlim(); + var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path)); + var log = new FakeComLog(); + var engine = new FakeEngine(log, new FakePlayer(log), new FakeScene(log)); + var activator = new BlockingEngineActivator( + log, + engine, + activationStarted, + releaseActivation); + var session = new DynamicK3dSession(activator); + await using var dispatcher = new StaDispatcher(capacity: 1); + + try + { + var connectTask = dispatcher.InvokeAsync( + () => + { + using (session) + { + session.Connect(options); + } + + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + Assert.True(activationStarted.Wait(TimeSpan.FromSeconds(2))); + + Assert.True(session.TryPreventKtapConnect()); + releaseActivation.Set(); + + await Assert.ThrowsAsync(() => connectTask); + Assert.Equal( + PlayoutKtapConnectState.NotAttempted, + session.LastKtapConnectState); + Assert.DoesNotContain(log.Names, name => + name.StartsWith("KTAPConnect:", StringComparison.Ordinal)); + } + finally + { + releaseActivation.Set(); + } + } + [Fact] public async Task Abandon_ReleasesEveryComReferenceWithoutDisconnect() { @@ -325,6 +433,61 @@ public sealed class DynamicK3dSessionTests } } + private sealed class ThrowingActivator : ILateBoundComActivator + { + private readonly Exception _exception; + + public ThrowingActivator(Exception exception) + { + _exception = exception; + } + + public object Create(Guid classId) => throw _exception; + } + + private sealed class BlockingEngineActivator : ILateBoundComActivator + { + private readonly FakeComLog _log; + private readonly FakeEngine _engine; + private readonly ManualResetEventSlim _activationStarted; + private readonly ManualResetEventSlim _releaseActivation; + private readonly object _eventHandler = new(); + + public BlockingEngineActivator( + FakeComLog log, + FakeEngine engine, + ManualResetEventSlim activationStarted, + ManualResetEventSlim releaseActivation) + { + _log = log; + _engine = engine; + _activationStarted = activationStarted; + _releaseActivation = releaseActivation; + } + + public object Create(Guid classId) + { + _log.Add($"Create:{classId:B}"); + if (classId == K3dComConstants.KaEventHandlerClassGuid) + { + return _eventHandler; + } + + if (classId != K3dComConstants.KaEngineClassGuid) + { + throw new InvalidOperationException("Unexpected fake CLSID."); + } + + _activationStarted.Set(); + if (!_releaseActivation.Wait(TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("fake activation coordination timeout"); + } + + return _engine; + } + } + public sealed class FakeEngine { private readonly FakeComLog _log; diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/IsolatedTestCommandTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/IsolatedTestCommandTests.cs index 97f4f51..c379ae8 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/IsolatedTestCommandTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/IsolatedTestCommandTests.cs @@ -145,6 +145,12 @@ public sealed class IsolatedTestCommandTests : IDisposable Assert.Equal([TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)], delays); Assert.True(report.Completed); Assert.True(report.ComActivationAttempted); + Assert.Equal("accepted-unconfirmed", report.LastKtapConnectState); + Assert.True(report.KtapConnectAttempted); + Assert.True(report.KtapConnectAccepted); + Assert.True(report.NetworkMonitoringRecordExpected); + Assert.True(report.NetworkMonitoringCheckRequired); + Assert.Null(report.NetworkMonitoringVerified); Assert.False(report.OutcomeUnknown); Assert.Equal(6, report.Steps.Count); Assert.True(report.Cleanup.AdapterDisposed); @@ -163,6 +169,9 @@ public sealed class IsolatedTestCommandTests : IDisposable Assert.Equal(["Connect", "Disconnect", "Dispose"], engine.Calls); Assert.True(report.Completed); Assert.True(report.ComActivationAttempted); + Assert.Equal("accepted-unconfirmed", report.LastKtapConnectState); + Assert.True(report.NetworkMonitoringRecordExpected); + Assert.True(report.NetworkMonitoringCheckRequired); } [Fact] @@ -177,6 +186,9 @@ public sealed class IsolatedTestCommandTests : IDisposable (_, _) => Task.CompletedTask); Assert.Null(report.ComActivationAttempted); + Assert.Equal("not-attempted", report.LastKtapConnectState); + Assert.False(report.NetworkMonitoringRecordExpected); + Assert.False(report.NetworkMonitoringCheckRequired); Assert.Equal(["Connect", "Disconnect", "Dispose"], engine.Calls); } @@ -192,6 +204,11 @@ public sealed class IsolatedTestCommandTests : IDisposable (_, _) => Task.CompletedTask); Assert.Null(report.ComActivationAttempted); + Assert.Equal("attempted", report.LastKtapConnectState); + Assert.True(report.KtapConnectAttempted); + Assert.Null(report.KtapConnectAccepted); + Assert.Null(report.NetworkMonitoringRecordExpected); + Assert.True(report.NetworkMonitoringCheckRequired); Assert.True(report.OutcomeUnknown); Assert.Equal(["Connect", "Quarantine", "Dispose"], engine.Calls); Assert.True(report.Cleanup.QuarantineCompleted); @@ -492,7 +509,7 @@ public sealed class IsolatedTestCommandTests : IDisposable public bool ThrowOnQuarantine { get; init; } - public PlayoutStatus Status { get; } + public PlayoutStatus Status { get; private set; } public event EventHandler? StatusChanged { @@ -553,6 +570,21 @@ public sealed class IsolatedTestCommandTests : IDisposable { Calls.Add(call); var code = _codes.GetValueOrDefault(operation, PlayoutResultCode.Success); + if (operation == PlayoutOperation.Connect) + { + Status = Status with + { + LastKtapConnectState = code switch + { + PlayoutResultCode.Success => PlayoutKtapConnectState.AcceptedUnconfirmed, + PlayoutResultCode.Failed => PlayoutKtapConnectState.Failed, + PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut => + PlayoutKtapConnectState.Attempted, + _ => PlayoutKtapConnectState.NotAttempted + } + }; + } + return Task.FromResult(new PlayoutResult( operation, code, diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs index 1e549e4..d081c99 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PlayoutFakes.cs @@ -34,9 +34,13 @@ internal sealed class FakeK3dSessionFactory : IK3dSessionFactory internal sealed class FakeK3dSession : IK3dSession { private int _disposed; + private int _ktapDispatchGate; + private int _ktapDispatchCount; public bool IsConnected { get; private set; } + public PlayoutKtapConnectState LastKtapConnectState { get; private set; } + public ConcurrentQueue Calls { get; } = new(); public ConcurrentQueue ThreadIds { get; } = new(); @@ -45,6 +49,10 @@ internal sealed class FakeK3dSession : IK3dSession public Action? ConnectAction { get; set; } + public Action? BeforeKtapDispatchAction { get; set; } + + public int KtapDispatchCount => Volatile.Read(ref _ktapDispatchCount); + public Action? DisconnectAction { get; set; } public Action? PrepareAction { get; set; } @@ -60,10 +68,39 @@ internal sealed class FakeK3dSession : IK3dSession public void Connect(ValidatedPlayoutOptions options) { Record("Connect"); - ConnectAction?.Invoke(options); + BeforeKtapDispatchAction?.Invoke(); + if (Interlocked.CompareExchange(ref _ktapDispatchGate, 1, 0) != 0) + { + throw new InvalidOperationException("Fake KTAP dispatch was prevented."); + } + + LastKtapConnectState = PlayoutKtapConnectState.Attempted; + Interlocked.Increment(ref _ktapDispatchCount); + try + { + ConnectAction?.Invoke(options); + } + catch + { + LastKtapConnectState = PlayoutKtapConnectState.Failed; + throw; + } + + LastKtapConnectState = PlayoutKtapConnectState.AcceptedUnconfirmed; IsConnected = true; } + public bool TryPreventKtapConnect() + { + var previous = Interlocked.CompareExchange(ref _ktapDispatchGate, 2, 0); + if (previous == 1 && LastKtapConnectState == PlayoutKtapConnectState.NotAttempted) + { + LastKtapConnectState = PlayoutKtapConnectState.Attempted; + } + + return previous == 0; + } + public void Disconnect() { Record("Disconnect"); @@ -91,6 +128,7 @@ internal sealed class FakeK3dSession : IK3dSession public void Abandon() { + TryPreventKtapConnect(); if (Interlocked.Exchange(ref _disposed, 1) == 0) { Record("Abandon"); diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs index 6b29e2d..2389a4a 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs @@ -37,6 +37,12 @@ public sealed class TornadoPlayoutEngineTests Assert.Equal(PlayoutResultCode.Success, prepare.Code); Assert.Equal(0, sessionFactory.CreateCount); Assert.Equal(0, registration.ProbeCount); + Assert.Equal( + PlayoutKtapConnectState.NotAttempted, + engine.Status.LastKtapConnectState); + Assert.False(engine.Status.NetworkMonitoringRecordExpected); + Assert.False(engine.Status.NetworkMonitoringCheckRequired); + Assert.Contains("Network Monitoring", engine.Status.Message, StringComparison.Ordinal); } [Fact] @@ -94,6 +100,13 @@ public sealed class TornadoPlayoutEngineTests Assert.Equal(expectedSuccess, result.IsSuccess); Assert.Equal(expectedSuccess ? 1 : 0, sessionFactory.CreateCount); Assert.Equal(expectedSuccess ? 1 : 0, registration.ProbeCount); + Assert.Equal( + expectedSuccess + ? PlayoutKtapConnectState.AcceptedUnconfirmed + : PlayoutKtapConnectState.NotAttempted, + engine.Status.LastKtapConnectState); + Assert.Equal(expectedSuccess, engine.Status.NetworkMonitoringRecordExpected); + Assert.Equal(expectedSuccess, engine.Status.NetworkMonitoringCheckRequired); Assert.NotNull(process.LastPattern); if (expectedSuccess) { @@ -108,6 +121,169 @@ public sealed class TornadoPlayoutEngineTests } } + [Fact] + public async Task TestConnect_WhenKtapCallFails_ReportsAttemptedFailureEvidence() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var session = new FakeK3dSession + { + ConnectAction = _ => throw new InvalidOperationException("fake KTAP 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); + + var result = await engine.ConnectAsync(CancellationToken.None); + + Assert.Equal(PlayoutResultCode.Failed, result.Code); + Assert.Equal( + PlayoutKtapConnectState.Failed, + engine.Status.LastKtapConnectState); + Assert.True(engine.Status.KtapConnectAttempted); + Assert.Null(engine.Status.KtapConnectAccepted); + Assert.Null(engine.Status.NetworkMonitoringRecordExpected); + Assert.True(engine.Status.NetworkMonitoringCheckRequired); + Assert.Null(engine.Status.KtapHelloObserved); + } + + [Fact] + public async Task TestConnect_WhenKtapCallTimesOut_PreservesAttemptedUnknownEvidence() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + using var connectStarted = new ManualResetEventSlim(); + using var releaseConnect = new ManualResetEventSlim(); + var session = new FakeK3dSession + { + ConnectAction = _ => + { + connectStarted.Set(); + if (!releaseConnect.Wait(TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("fake test coordination timeout"); + } + } + }; + var options = TestOptions(scenes.Path, "test-scene"); + options.ConnectTimeoutMilliseconds = 100; + await using var engine = CreateEngine( + options, + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + try + { + var connectTask = engine.ConnectAsync(CancellationToken.None); + Assert.True(connectStarted.Wait(TimeSpan.FromSeconds(2))); + + var result = await connectTask; + + Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code); + Assert.Equal( + PlayoutKtapConnectState.Attempted, + engine.Status.LastKtapConnectState); + Assert.True(engine.Status.KtapConnectAttempted); + Assert.Null(engine.Status.KtapConnectAccepted); + Assert.Null(engine.Status.NetworkMonitoringRecordExpected); + Assert.True(engine.Status.NetworkMonitoringCheckRequired); + Assert.Equal(PlayoutConnectionState.OutcomeUnknown, engine.Status.State); + } + finally + { + releaseConnect.Set(); + } + } + + [Fact] + public async Task TestConnect_TimeoutDuringActivation_PreventsLateKtapDispatch() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + using var activationStarted = new ManualResetEventSlim(); + using var releaseActivation = new ManualResetEventSlim(); + var session = new FakeK3dSession + { + BeforeKtapDispatchAction = () => + { + activationStarted.Set(); + if (!releaseActivation.Wait(TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("fake activation coordination timeout"); + } + } + }; + var options = TestOptions(scenes.Path, "test-scene"); + options.ConnectTimeoutMilliseconds = 100; + await using var engine = CreateEngine( + options, + new FakeK3dSessionFactory(() => session), + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + try + { + var connectTask = engine.ConnectAsync(CancellationToken.None); + Assert.True(activationStarted.Wait(TimeSpan.FromSeconds(2))); + + var result = await connectTask; + + Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code); + Assert.Equal( + PlayoutKtapConnectState.NotAttempted, + engine.Status.LastKtapConnectState); + Assert.False(engine.Status.KtapConnectAttempted); + Assert.False(engine.Status.NetworkMonitoringRecordExpected); + Assert.False(engine.Status.NetworkMonitoringCheckRequired); + Assert.Equal(0, session.KtapDispatchCount); + + releaseActivation.Set(); + Assert.True(SpinWait.SpinUntil( + () => session.Calls.Contains("Dispose"), + TimeSpan.FromSeconds(2))); + Assert.Equal(0, session.KtapDispatchCount); + } + finally + { + releaseActivation.Set(); + } + } + + [Fact] + public async Task LastKtapEvidence_SurvivesLaterFailureBeforeAnotherDispatch() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var session = new FakeK3dSession(); + var createCount = 0; + var sessionFactory = new FakeK3dSessionFactory(() => + Interlocked.Increment(ref createCount) == 1 + ? session + : throw new InvalidOperationException("fake activation-stage failure")); + 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); + Assert.True((await engine.DisconnectAsync(CancellationToken.None)).IsSuccess); + + var secondConnect = await engine.ConnectAsync(CancellationToken.None); + + Assert.Equal(PlayoutResultCode.Failed, secondConnect.Code); + Assert.Equal(PlayoutConnectionState.Faulted, engine.Status.State); + Assert.Equal( + PlayoutKtapConnectState.AcceptedUnconfirmed, + engine.Status.LastKtapConnectState); + Assert.True(engine.Status.NetworkMonitoringRecordExpected); + Assert.True(engine.Status.NetworkMonitoringCheckRequired); + Assert.Equal(2, sessionFactory.CreateCount); + } + [Fact] public async Task TestWorkflow_ExecutesFakeSessionInCommandOrderOnSta() { diff --git a/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/IsolatedTestCommand.cs b/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/IsolatedTestCommand.cs index d58e2eb..92fe109 100644 --- a/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/IsolatedTestCommand.cs +++ b/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/IsolatedTestCommand.cs @@ -191,6 +191,13 @@ internal sealed record SafeTestCommandReport( string Mode, bool ConnectRequestIssued, bool? ComActivationAttempted, + string LastKtapConnectState, + bool KtapConnectAttempted, + bool? KtapConnectAccepted, + bool? KtapHelloObserved, + bool? NetworkMonitoringRecordExpected, + bool NetworkMonitoringCheckRequired, + bool? NetworkMonitoringVerified, bool Completed, bool OutcomeUnknown, bool OutputMayBeActive, @@ -228,6 +235,8 @@ internal static class IsolatedTestCommandExecutor var connectRequestIssued = false; var connectResultReceived = false; bool? comActivationAttempted = false; + var lastKtapConnectState = PlayoutKtapConnectState.NotAttempted; + bool? ktapHelloObserved = null; var completed = false; var outcomeUnknown = false; string? stoppedAfter = null; @@ -248,6 +257,7 @@ internal static class IsolatedTestCommandExecutor connectRequestIssued = true; var connect = await engine.ConnectAsync(cancellationToken).ConfigureAwait(false); connectResultReceived = true; + CaptureKtapEvidence(); AddStep(steps, "command", connect); comActivationAttempted = InferComActivationAttempt(connect.Code); if (!connect.IsSuccess) @@ -355,6 +365,7 @@ internal static class IsolatedTestCommandExecutor { if (engine is not null) { + CaptureKtapEvidence(); if (outputMayBeActive && !takeOutAttempted && !outcomeUnknown) { takeOutAttempted = true; @@ -420,6 +431,7 @@ internal static class IsolatedTestCommandExecutor try { await engine.DisposeAsync().ConfigureAwait(false); + CaptureKtapEvidence(); adapterDisposed = true; } catch @@ -457,6 +469,23 @@ internal static class IsolatedTestCommandExecutor "Test", connectRequestIssued, comActivationAttempted, + ToWireValue(lastKtapConnectState), + lastKtapConnectState != PlayoutKtapConnectState.NotAttempted, + lastKtapConnectState switch + { + PlayoutKtapConnectState.AcceptedUnconfirmed => true, + PlayoutKtapConnectState.NotAttempted => false, + _ => null + }, + ktapHelloObserved, + lastKtapConnectState switch + { + PlayoutKtapConnectState.AcceptedUnconfirmed => true, + PlayoutKtapConnectState.NotAttempted => false, + _ => null + }, + lastKtapConnectState != PlayoutKtapConnectState.NotAttempted, + null, completed, outcomeUnknown, outputMayBeActive, @@ -468,8 +497,29 @@ internal static class IsolatedTestCommandExecutor quarantineAttempted, quarantineCompleted, adapterDisposed)); + + void CaptureKtapEvidence() + { + if (engine is null) + { + return; + } + + var status = engine.Status; + lastKtapConnectState = status.LastKtapConnectState; + ktapHelloObserved = status.KtapHelloObserved; + } } + private static string ToWireValue(PlayoutKtapConnectState state) => state switch + { + PlayoutKtapConnectState.NotAttempted => "not-attempted", + PlayoutKtapConnectState.Attempted => "attempted", + PlayoutKtapConnectState.AcceptedUnconfirmed => "accepted-unconfirmed", + PlayoutKtapConnectState.Failed => "failed", + _ => "not-attempted" + }; + private static void AddStep( ICollection steps, string phase, diff --git a/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/Program.cs b/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/Program.cs index b8c8b29..d6f5fca 100644 --- a/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/Program.cs +++ b/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/Program.cs @@ -78,6 +78,13 @@ static int Probe() architecture = Environment.Is64BitProcess ? "x64" : "x86", connectRequestIssued = false, comActivationAttempted = false, + lastKtapConnectState = "not-attempted", + ktapConnectAttempted = false, + ktapConnectAccepted = false, + ktapHelloObserved = (bool?)null, + networkMonitoringRecordExpected = false, + networkMonitoringCheckRequired = false, + networkMonitoringVerified = (bool?)null, runtimeRegistration = new { ready = runtimeRegistration.IsReady, @@ -123,8 +130,15 @@ static async Task DryRunAsync() command = "dry-run", mode = engine.Status.Mode.ToString(), state = engine.Status.State.ToString(), - connectRequestIssued = false, + connectRequestIssued = true, comActivationAttempted = false, + lastKtapConnectState = "not-attempted", + ktapConnectAttempted = false, + ktapConnectAccepted = false, + ktapHelloObserved = (bool?)null, + networkMonitoringRecordExpected = false, + networkMonitoringCheckRequired = false, + networkMonitoringVerified = (bool?)null, commands = results.Select(result => new { operation = result.Operation.ToString(), @@ -149,6 +163,13 @@ static int TestPlan(SmokeCommandInvocation invocation) runtimeProcessGateChecked = false, connectRequestIssued = false, comActivationAttempted = false, + lastKtapConnectState = "not-attempted", + ktapConnectAttempted = false, + ktapConnectAccepted = false, + ktapHelloObserved = (bool?)null, + networkMonitoringRecordExpected = false, + networkMonitoringCheckRequired = false, + networkMonitoringVerified = (bool?)null, sceneCount = plan.PrepareCue is null ? 0 : 2, automaticReconnectEnabled = plan.Options.ReconnectEnabled, safety = "Configuration and scene assets validated only; no engine or COM was created." @@ -209,6 +230,13 @@ static void WritePreflightFailure(string command, string errorCode) => runtimeProcessGateChecked = false, connectRequestIssued = false, comActivationAttempted = false, + lastKtapConnectState = "not-attempted", + ktapConnectAttempted = false, + ktapConnectAccepted = false, + ktapHelloObserved = (bool?)null, + networkMonitoringRecordExpected = false, + networkMonitoringCheckRequired = false, + networkMonitoringVerified = (bool?)null, errorCode, safety = "Rejected before engine creation; no COM activation was attempted." }); @@ -227,6 +255,13 @@ static int Usage(string? errorCode) command = "invalid", connectRequestIssued = false, comActivationAttempted = false, + lastKtapConnectState = "not-attempted", + ktapConnectAttempted = false, + ktapConnectAccepted = false, + ktapHelloObserved = (bool?)null, + networkMonitoringRecordExpected = false, + networkMonitoringCheckRequired = false, + networkMonitoringVerified = (bool?)null, errorCode = errorCode ?? "invalid-command-line" }); Console.Error.WriteLine(