From 930424b752e739e3f823ae22df5f5da0f8a6942f Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 10 Jul 2026 14:28:45 +0900 Subject: [PATCH] feat: verify Tornado PGM KTAP connection --- README.md | 4 +- docs/LEGACY_PLAYOUT_ANALYSIS.md | 11 +- docs/MIGRATION.md | 5 +- docs/PLAYOUT.md | 49 +- .../DynamicKtapConnectOnlySession.cs | 253 +++++++ .../InstalledK3dInteropConnectBinding.cs | 630 ++++++++++++++++++ .../Diagnostics/PgmConnectDiagnostic.cs | 456 +++++++++++++ .../Diagnostics/PgmDiagnosticSafetyProbe.cs | 415 ++++++++++++ .../Interop/DynamicK3dSession.cs | 160 ++++- .../Interop/K3dComConstants.cs | 1 + .../Properties/AssemblyInfo.cs | 1 + .../TornadoPlayoutEngine.cs | 6 +- .../DynamicK3dSessionTests.cs | 397 ++++++++++- .../InstalledK3dInteropMetadataTests.cs | 143 ++++ .../IsolatedTestCommandTests.cs | 8 +- .../PgmConnectDiagnosticTests.cs | 490 ++++++++++++++ .../PgmDiagnosticSafetyProbeTests.cs | 150 +++++ .../SmokeCommandLineTests.cs | 82 +++ .../TornadoPlayoutEngineTests.cs | 77 ++- .../IsolatedTestCommand.cs | 9 + .../MBN_STOCK_WEBVIEW.PlayoutSmoke/Program.cs | 107 ++- .../SmokeCommandLine.cs | 87 ++- 22 files changed, 3474 insertions(+), 67 deletions(-) create mode 100644 src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/DynamicKtapConnectOnlySession.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/InstalledK3dInteropConnectBinding.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/PgmConnectDiagnostic.cs create mode 100644 src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/PgmDiagnosticSafetyProbe.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Playout.Tests/InstalledK3dInteropMetadataTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Playout.Tests/PgmConnectDiagnosticTests.cs create mode 100644 tests/MBN_STOCK_WEBVIEW.Playout.Tests/PgmDiagnosticSafetyProbeTests.cs diff --git a/README.md b/README.md index 7dd0c55..eeb8e63 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), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 전체 현황은 [마이그레이션 문서](docs/MIGRATION.md)를 참고하세요. +Oracle/MariaDB 연결 계층은 구현 및 실제 DB 스모크 검증을 완료했습니다. Tornado/K3D는 안전 기본값인 `DryRun`과 실제 어댑터 경계를 구현했습니다. 현재 장비의 Tornado2 본체에는 렌더 API가 없는 전용 진단으로 실제 `KTAPConnect → Disconnect`를 수행했고 Network Monitoring의 `HELLO / SUCCESS HELLO / BYE`를 확인했습니다. PGM 화면을 바꾸는 `5001 → 5006 → TAKE OUT` 시험은 별도의 명시적 승인 뒤에만 진행합니다. DB 설정은 [DB 운영 가이드](docs/DATABASE.md), 송출 설정과 롤백은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md), 원본 Scene/PageN 대조는 [송출 흐름 분석](docs/LEGACY_PLAYOUT_ANALYSIS.md), 전체 현황은 [마이그레이션 문서](docs/MIGRATION.md)를 참고하세요. ## Visual Studio 2026에서 실행 @@ -65,6 +65,8 @@ dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke ` 중요: 앱의 기본 `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` 실값이어야 합니다. +Tornado2의 `PGM` 창은 본 프로그램의 KTAP 명령을 렌더링하는 출력 창입니다. 현재 PGM과의 네트워크 왕복만 진단할 때는 일반 Test/Live 엔진을 완화하지 않고 `--pgm-connect-diagnostic`을 사용합니다. 이 명령은 Registry64에서 계산한 x64 K3D 네이티브 DLL과 Interop이 운영자 승인 SHA-256 핀 `MBN_STOCK_K3D_NATIVE_SHA256`/`MBN_STOCK_K3D_INTEROP_SHA256`과 각각 일치할 때만 동적으로 사용하며 `KTAPConnect → Disconnect`만 한 번 수행합니다. Scene player, 장면 로드, PREPARE, PLAY, STOP 계열 API 표면은 포함하지 않으며 해시 승인 절차와 판정 방법은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md)에 있습니다. + MSIX 생성: ```powershell diff --git a/docs/LEGACY_PLAYOUT_ANALYSIS.md b/docs/LEGACY_PLAYOUT_ANALYSIS.md index 9234f28..a70f72b 100644 --- a/docs/LEGACY_PLAYOUT_ANALYSIS.md +++ b/docs/LEGACY_PLAYOUT_ANALYSIS.md @@ -8,10 +8,10 @@ 여기의 `30001`은 원본 시스템의 당시 값일 뿐 새 Test endpoint의 기본값이나 검증값이 아닙니다. 새 설정은 격리 Test Tornado의 현재 `Tools > Option > Control > Network Server > TCP Port`를 직접 확인해 사용합니다. 반환값 `1`도 매뉴얼의 `OnHello` 또는 Network Monitoring `[R]`/`[S]` 확인을 대신하지 않습니다. -새 장면 PREPARE의 기준 순서는 다음과 같습니다. +원본 장면 PREPARE의 호출 순서는 다음과 같습니다. 1. `LoadScene(Cuts\.t2s, )` -2. layout `10`에 fade effect `7`과 `FadeInSec` 적용 +2. IN effect 플래그에 fade effect `7`과 `FadeInSec` 적용 3. `BeginTransaction()` 4. 장면별 데이터와 오브젝트 변경 5. `scene.QueryVariables()` @@ -20,7 +20,9 @@ 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 호출 전에 거부합니다. +새 `DynamicK3dSession`은 매뉴얼의 transaction 제한에 맞춰 오브젝트 변경을 `BeginTransaction`/`EndTransaction[OnChannel]` 안에서 끝낸 뒤 `QueryVariables`를 호출하고 `Prepare`합니다. `SetSceneEffectType`의 첫 인수도 layout이 아니라 IN effect 플래그 `1`로 전달하며, layout `10`은 `Prepare`/`Play`/`CutOut`에만 사용합니다. `TornadoPlayoutEngine`은 on-air 상태가 없으면 NEXT를 COM 호출 전에 거부합니다. + +K3D의 Play/Stop 계열은 완료 이벤트가 별도인 비동기 명령입니다. 현재 callback handler를 아직 포팅하지 않았으므로 이전/on-air Scene을 명령 반환 직후 `Unload`하지 않고 연결 종료까지 보존합니다. 실제 운영의 장기 세션 정리는 `OnScenePlayed`/`OnCutOut`/`OnStopAll` 성공 콜백 기반으로 구현해야 합니다. ## Scene 빌더의 범위 @@ -42,7 +44,7 @@ TAKE IN은 준비된 장면에 `Play(10)`을 호출하고 `m_TakeIn=true`로 전 - x64 COM 활성화와 KTAP 연결/해제 - 승인된 `.t2s`의 load와 scene alias -- transaction, `QueryVariables`, layout 10 prepare +- 오브젝트 transaction 종료 뒤 `QueryVariables`, layout 10 prepare - TAKE IN, 다음 cue의 NEXT, TAKE OUT `StopAll` - STA 직렬화, timeout, 프로세스 교체 및 오류 상태 @@ -52,3 +54,4 @@ TAKE IN은 준비된 장면에 `Play(10)`을 호출하고 `m_TakeIn=true`로 전 - `PageN`/`Nxt_PageN` 페이지 계산과 같은 scene 갱신 - 배경 영상·texture 및 그래프/path mutation - 실제 시장 데이터와 원본 화면의 픽셀/내용 비교 +- `OnScenePlayed`/`OnCutOut`/`OnStopAll` callback 기반 Scene unload diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 0187ba1..9e66913 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -54,8 +54,11 @@ WebView는 `https://app.mbn.local` 가상 호스트로 패키지 내부 파일 - 완료: 연결/해제, 명시적 재연결(no replay), `Tornado2` 접두사 프로세스 감시 - 완료: `PREPARE`, `TAKE IN`, `NEXT`, `TAKE OUT` WebView 메시지 및 상태/오류 UI 연결 - 완료: 기본 DryRun, Test의 단일 loopback 테스트 인스턴스·채널·씬 allowlist, Live 이중 승인 -- 확인 대기: 현재 `PGM`과 분리된 테스트 Tornado 인스턴스·출력 채널·테스트 씬의 실제 호출 +- 완료: 현재 Tornado2 PGM 렌더 창에 대한 x64 K3D `KTAPConnect → Disconnect` 실제 왕복 및 Network Monitoring `[R] HELLO`/`[S] SUCCESS HELLO` 확인. 렌더 명령은 호출하지 않음 +- 완료: 네이티브/Interop 이중 SHA-256 핀, 프로세스 수명 파일 잠금, 실제 PGM listener 소유권 및 KTAP 지연 dispatch 차단 +- 확인 대기: 승인된 테스트 출력에서 `5001 → 5006 → TAKE OUT` 실제 호출 - 확인 대기: 승인된 Test 환경에서 MSIX 컨텍스트의 실제 COM 활성화와 출력 관찰 +- 후속: `OnScenePlayed`/`OnCutOut`/`OnStopAll` callback 기반 장기 세션 Scene unload - 후속: 35개 scene builder의 복합 K3D mutation 및 `PageN`/`Nxt_PageN` 같은-scene 페이지 갱신 포팅 ### 화면 기능 diff --git a/docs/PLAYOUT.md b/docs/PLAYOUT.md index dda8b17..a3877ef 100644 --- a/docs/PLAYOUT.md +++ b/docs/PLAYOUT.md @@ -34,7 +34,24 @@ powershell -NoProfile -ExecutionPolicy Bypass ` ## 런타임 참조 방식 -앱은 빌드 시점의 `Interop.K3DAsyncEngineLib.dll`을 참조하지 않습니다. HKLM 64비트 등록과 HKCU override 부재를 검증한 뒤 고정된 KAEngine/KAEventHandler CLSID로만 런타임 late binding하고, COM 객체와 호출 세부 사항은 `IPlayoutEngine` 구현 안에 격리합니다. 따라서 `obj` 폴더에 남은 Interop DLL 또는 특정 개발 장비의 벤더 설치 경로가 빌드 입력이 되지 않습니다. +앱은 빌드 시점의 `Interop.K3DAsyncEngineLib.dll`을 참조하거나 패키징하지 않습니다. 런타임에는 HKLM Registry64의 TypeLib·KAEngine·KAEventHandler 세 등록이 모두 같은 `DLL\x64\Release\K3DAsyncEngine.dll`을 가리키고 HKCU override가 없을 때만, 그 벤더 루트의 정확한 `Bin\x64\C#\Interop.K3DAsyncEngineLib.dll` 하나를 사용합니다. 상위 폴더 검색이나 `obj`/`artifacts` fallback은 없습니다. 두 파일과 모든 상위 경로는 reparse point가 아니어야 하며 x64 PE인지 확인합니다. COM 객체와 호출 세부 사항은 `IPlayoutEngine` 구현 안에 격리합니다. + +등록된 네이티브 `K3DAsyncEngine.dll`과 설치된 C# Interop은 모두 로드 전에 운영자가 승인한 SHA-256과 일치해야 합니다. 현재 프로세스 환경의 `MBN_STOCK_K3D_NATIVE_SHA256`과 `MBN_STOCK_K3D_INTEROP_SHA256`에 각각 공백 없는 64자리 16진수만 허용하며, 일반 Test/Live 어댑터와 PGM 연결 전용 진단이 같은 두 핀을 사용합니다. 먼저 벤더 배포 해시, 신뢰된 설치 매체 또는 관리자 검수로 **두 파일을 독립적으로 승인한 뒤** 그 정확한 파일들을 대상으로 다음 값을 설정합니다. + +```powershell +$approvedNative = '' +$approvedInterop = '' +$nativeHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $approvedNative).Hash +$interopHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $approvedInterop).Hash +if ($nativeHash -notmatch '^[0-9A-Fa-f]{64}$' -or + $interopHash -notmatch '^[0-9A-Fa-f]{64}$') { throw 'Invalid SHA-256.' } +$env:MBN_STOCK_K3D_NATIVE_SHA256 = $nativeHash +$env:MBN_STOCK_K3D_INTEROP_SHA256 = $interopHash +``` + +검수하지 않은 현재 설치 파일의 해시를 계산해 그대로 승인하는 것은 독립적인 신뢰 확인이 아닙니다. 파일 업데이트 후 어느 핀이든 바꾸는 행위는 새 바이너리를 신뢰한다는 명시적 운영 결정이므로 변경 이력과 승인 근거를 남기고, 위 `$env:`를 설정한 같은 셸에서 앱 또는 진단을 시작합니다. 핀과 설치 경로는 결과 JSON이나 로그에 출력하지 않습니다. 핀이 없거나 형식·해시·경로·PE·Interop 메타데이터 검사가 하나라도 실패하면 `installed-interop-metadata-unavailable`로 COM 활성화 전에 종료합니다. 검사를 통과한 두 파일은 프로세스 수명 동안 쓰기·교체 공유를 허용하지 않는 읽기 핸들로 유지합니다. + +이 이중 핀과 장기 파일 잠금은 개발 워크스테이션에서 우발적 교체 및 검사-사용 시점(TOCTOU) 교체를 완화하는 방어입니다. 운영 장비에서는 이것만으로 충분하지 않으며, 벤더 설치 루트 ACL을 `Administrators`/`SYSTEM`만 쓰기 가능하고 일반 `Users`는 읽기·실행만 가능하도록 배포 단계에서 구성해야 합니다. 또한 서명된 벤더 패키지 또는 관리자가 별도로 승인한 배포물을 사용해야 합니다. 앱과 진단은 ACL을 수정하거나 취약한 설치를 자동으로 신뢰하지 않습니다. 타입 정보를 조사해야 할 때만 [Generate-K3DInterop.ps1](../scripts/Generate-K3DInterop.ps1)을 선택적으로 사용합니다. 생성 전 Inspect와 같은 HKLM 양방향 매핑·두 COM 서버 AMD64·HKCU override 부재 검사를 통과해야 합니다. 그 뒤 알려진 Windows SDK의 x64 `TlbImp.exe` 위치를 선택하거나 명시적으로 받은 x64 경로를 검증하고 `/machine:X64`로 생성합니다. 입력 TypeLib과 도구 및 결과물 모두 AMD64인지 확인하며, 결과는 Git에서 제외된 다음 위치에만 씁니다. @@ -56,7 +73,7 @@ powershell -NoProfile -ExecutionPolicy Bypass ` -File .\scripts\Generate-K3DInterop.ps1 ``` -SDK가 기본 위치에 없다면 x64 SDK의 `TlbImp.exe` 절대 경로를 `-TlbImpPath`에 지정합니다. 생성물은 진단용일 뿐 프로젝트 참조나 MSIX 콘텐츠로 추가하지 않습니다. 이 스크립트도 COM 활성화와 등록 변경은 하지 않습니다. +SDK가 기본 위치에 없다면 x64 SDK의 `TlbImp.exe` 절대 경로를 `-TlbImpPath`에 지정합니다. 생성물은 타입 조사용일 뿐 프로젝트 참조나 MSIX 콘텐츠로 추가하지 않습니다. 런타임 resolver도 이 `artifacts` 생성물을 찾거나 로드하지 않습니다. 이 스크립트도 COM 활성화와 등록 변경은 하지 않습니다. ## 로컬 설정 @@ -116,6 +133,28 @@ Tornado2의 `View > Network Monitoring Window`에서 `[R]`은 서버가 클라 상태의 `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와 안전 게이트 거부 여부를 먼저 확인합니다. +### PGM 네트워크 연결 전용 진단 + +Tornado2의 `PGM` 창은 본 프로그램이 보낸 KTAP 송출 명령을 표시하는 렌더 출력 창입니다. 창 제목만으로 실제 방송 라우팅 여부를 판정할 수 없으므로 기존 Test 게이트에서 PGM을 허용하지 않습니다. 대신 운영자가 Network Monitoring 창을 먼저 열고 승인한 회차에만 다음 별도 명령으로 네트워크 세션 왕복을 확인합니다. + +```powershell +dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke ` + -c Debug -p:Platform=x64 -- ` + --pgm-connect-diagnostic ` + --i-understand-this-will-contact-current-pgm-tornado-via-ktap-connect-and-disconnect-only ` + --host 127.0.0.1 ` + --port ` + --expected-pgm-window-title PGM +``` + +이 명령은 설정 파일과 `MBN_STOCK_PLAYOUT_*` 환경 override를 받지 않으며 host는 숫자형 loopback만 허용합니다. 정확히 하나의 `Tornado2*` 프로세스, 지정한 PGM 창 제목, 프로세스 세대와 해당 TCP LISTEN 소유권을 COM 전·KTAP 직전·Disconnect 직전에 반복 확인합니다. 자동 재연결과 재시도는 없고 연결·해제 timeout은 각각 3초로 고정됩니다. + +COM 경로에는 `KTAPConnect`와 성공 후 최대 한 번의 `Disconnect`만 있으며 `GetScenePlayer[OnChannel]`, `LoadScene`, `Prepare`, `Play`, `PlayDirect`, `CutOut`, `Stop`, `StopAll`을 호출할 수 있는 API 표면이 없습니다. 따라서 명시적인 장면/레이어 출력 명령은 발생하지 않습니다. 다만 PGM에 TCP 제어 세션을 추가하므로 완전한 무영향을 보장하는 명령은 아니며, timeout이나 대상 변경 후에는 반복하지 않습니다. + +성공 JSON의 `lastKtapConnectState: "accepted-unconfirmed"`, `completed: true`, `outcomeUnknown: false`, `renderCommandSurfaceExposed: false`, `renderCommandAttempted: false`를 확인하고 같은 시각의 Network Monitoring `[R] HELLO`와 `[S] SUCCESS HELLO`를 사람이 확인합니다. `GetScenePlayer`를 호출하지 않으므로 이 진단의 성공은 장면 송출 준비 완료를 의미하지 않습니다. + +x64 SDK의 네이티브 DLL과 정식 Interop은 위의 Registry64 단일 경로·reparse ancestry·AMD64 PE·운영자별 SHA-256 핀을 모두 통과해야 합니다. 두 파일을 쓰기/교체할 수 없게 연 핸들은 COM 활성화와 사용을 포함한 프로세스 수명 동안 유지합니다. Interop 로드 후에는 assembly/TypeLib 버전, COM import GUID와 허용 메서드 서명도 확인합니다. vendor DLL은 Git 또는 MSIX에 복사하지 않습니다. + ## 모드와 안전 게이트 `Disabled`는 모든 송출 명령을 거부하는 운영 롤백 모드입니다. `DryRun`은 COM 없이 WebView 동작을 성공 결과로 모의합니다. `Test`와 `Live`만 등록된 COM을 사용할 수 있습니다. @@ -206,7 +245,7 @@ dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke ` --config $config ``` -마지막으로 격리된 Test 모니터를 관찰하면서 실제 시퀀스를 실행합니다. `--observe-ms`는 `TAKE IN` 뒤와 `NEXT` 뒤에 각각 적용되는 필수 관찰 시간이며 1,000~30,000ms만 허용합니다. +마지막으로 격리된 Test 모니터를 관찰하면서 실제 시퀀스를 실행합니다. `--observe-ms`는 `TAKE IN`, `NEXT`, `TAKE OUT` 뒤에 각각 적용되는 필수 관찰 시간이며 1,000~30,000ms만 허용합니다. ```powershell dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke ` @@ -219,7 +258,9 @@ dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke ` --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 출력 상태를 사람이 확인해야 합니다. +시퀀스는 `Connect → Prepare(5001) → TakeIn → 관찰 → Next(5006) → 관찰 → TakeOut(All) → 관찰 → Disconnect` 순서입니다. K3D의 Play/Stop 완료는 비동기 이벤트이므로 마지막 관찰 창이 끝나기 전에는 Disconnect하지 않습니다. 자동 재연결은 CLI가 강제로 비활성화합니다. 성공한 `TakeIn` 뒤 관찰 취소처럼 결과가 확정된 중단이면 `TakeOut(All)`을 한 번만 정리 단계로 요청한 뒤, 정리가 성공한 경우에만 `Disconnect`합니다. 이미 실행 결과가 불명확하거나 `TakeOut`이 어떤 비성공 결과라도 반환하면 출력이 남아 있을 수 있으므로 추가 출력 명령과 SDK `Disconnect`를 보내지 않습니다. 이때 `QuarantineAsync`가 같은 STA에서 제어 메서드 호출 없이 로컬 COM 참조만 해제한 다음 bounded 어댑터 폐기를 수행합니다. quarantine 자체를 완료하지 못하면 의도하지 않은 Disconnect보다 로컬 누수를 택해 일반 Dispose도 생략합니다. 어느 경우든 격리 모니터에서 최종 Test 출력 상태를 사람이 확인해야 합니다. + +현재 런타임은 vendor의 282-method event handler를 managed callback으로 아직 소비하지 않습니다. 따라서 `Play`, `CutOut`, `StopAll`의 COM 반환 직후 Scene을 `Unload`하지 않고 prepared/current/retired 참조를 연결 종료까지 보존합니다. 이는 두 장면만 쓰는 bounded 검증에서 조기 Unload로 화면 전환을 끊는 위험을 피하기 위한 동작입니다. 장시간 운영에서 Scene을 누적하지 않으려면 `OnScenePlayed`/`OnCutOut`/`OnStopAll` 성공 콜백 뒤에만 unload queue를 비우는 handler 포팅이 선행되어야 합니다. 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`는 자산 계획만 검증했다는 뜻이며 실제 연결 가능성을 증명하지 않습니다. diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/DynamicKtapConnectOnlySession.cs b/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/DynamicKtapConnectOnlySession.cs new file mode 100644 index 0000000..dbbb964 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/DynamicKtapConnectOnlySession.cs @@ -0,0 +1,253 @@ +using System.Globalization; +using System.Runtime.ExceptionServices; +using MBN_STOCK_WEBVIEW.Core.Playout; + +namespace MBN_STOCK_WEBVIEW.Playout.Diagnostics; + +internal interface IKtapConnectOnlySession +{ + bool ComActivationAttempted { get; } + + PlayoutKtapConnectState LastKtapConnectState { get; } + + bool TryPreventKtapConnect(); + + void Connect(string host, int port, Func finalSafetyCheck); + + void Disconnect(); + + void Abandon(); +} + +internal interface IKtapConnectOnlySessionFactory +{ + IKtapConnectOnlySession Create(); +} + +internal sealed class DynamicKtapConnectOnlySessionFactory : IKtapConnectOnlySessionFactory +{ + private readonly IK3dConnectOnlyComBindingFactory _bindingFactory; + + public DynamicKtapConnectOnlySessionFactory() + : this(new InstalledK3dInteropConnectBindingFactory()) + { + } + + internal DynamicKtapConnectOnlySessionFactory(IK3dConnectOnlyComBindingFactory bindingFactory) + { + _bindingFactory = bindingFactory; + } + + public IKtapConnectOnlySession Create() => + new DynamicKtapConnectOnlySession(_bindingFactory.Create()); +} + +internal sealed class DynamicKtapConnectOnlySession : IKtapConnectOnlySession +{ + private readonly IK3dConnectOnlyComBinding _binding; + private object? _engine; + private object? _eventHandler; + private int? _ownerThreadId; + private int _lastKtapConnectState; + private int _ktapDispatchGate; + private bool _accepted; + private bool _abandoned; + + public DynamicKtapConnectOnlySession(IK3dConnectOnlyComBinding binding) + { + _binding = binding; + } + + public bool ComActivationAttempted { get; private set; } + + 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(string host, int port, Func finalSafetyCheck) + { + ArgumentNullException.ThrowIfNull(finalSafetyCheck); + EnsureThread(); + if (_abandoned || _engine is not null || _accepted) + { + throw new InvalidOperationException("The connection-only session is unavailable."); + } + + if (Volatile.Read(ref _ktapDispatchGate) != 0) + { + throw new InvalidOperationException( + "The connection-only diagnostic was stopped before COM activation."); + } + + var ktapDispatchAttempted = false; + try + { + ComActivationAttempted = true; + _eventHandler = _binding.CreateEventHandler(); + _engine = _binding.CreateEngine(); + if (Volatile.Read(ref _ktapDispatchGate) != 0) + { + throw new InvalidOperationException( + "The connection-only diagnostic was stopped during COM activation."); + } + + if (!finalSafetyCheck()) + { + throw new PgmDiagnosticSafetyException(); + } + + if (Interlocked.CompareExchange(ref _ktapDispatchGate, 1, 0) != 0) + { + throw new InvalidOperationException( + "The connection-only diagnostic was stopped before KTAP dispatch."); + } + + SetKtapConnectState(PlayoutKtapConnectState.Attempted); + ktapDispatchAttempted = true; + var returnValue = _binding.KtapConnect( + _engine, + host, + port, + _eventHandler); + if (returnValue != 1) + { + SetKtapConnectState(PlayoutKtapConnectState.Failed); + throw new KtapConnectRejectedException(); + } + + _accepted = true; + SetKtapConnectState(PlayoutKtapConnectState.AcceptedUnconfirmed); + } + catch (Exception exception) + { + if (LastKtapConnectState == PlayoutKtapConnectState.Attempted) + { + SetKtapConnectState(PlayoutKtapConnectState.Failed); + } + + _accepted = false; + ReleaseAll(); + if (exception is KtapConnectRejectedException || !ktapDispatchAttempted) + { + throw; + } + + throw new KtapConnectInvocationException( + ClassifyInvocationFailure(exception), + exception); + } + } + + public void Disconnect() + { + EnsureThread(); + if (_abandoned || !_accepted || _engine is null) + { + throw new InvalidOperationException("No accepted KTAP session is available."); + } + + Exception? failure = null; + try + { + _binding.Disconnect(_engine); + } + catch (Exception exception) + { + failure = exception; + } + finally + { + _accepted = false; + ReleaseAll(); + } + + if (failure is not null) + { + ExceptionDispatchInfo.Capture(failure).Throw(); + } + } + + public void Abandon() + { + if (_abandoned) + { + return; + } + + EnsureThread(); + TryPreventKtapConnect(); + _abandoned = true; + _accepted = false; + ReleaseAll(); + } + + private void SetKtapConnectState(PlayoutKtapConnectState state) => + Volatile.Write(ref _lastKtapConnectState, (int)state); + + private void EnsureThread() + { + var currentThreadId = Environment.CurrentManagedThreadId; + if (_ownerThreadId is null) + { + if (Thread.CurrentThread.GetApartmentState() != ApartmentState.STA) + { + throw new InvalidOperationException("K3D access requires an STA thread."); + } + + _ownerThreadId = currentThreadId; + return; + } + + if (_ownerThreadId.Value != currentThreadId) + { + throw new InvalidOperationException("K3D access changed STA threads."); + } + } + + private void ReleaseAll() + { + var engine = _engine; + _engine = null; + var eventHandler = _eventHandler; + _eventHandler = null; + Release(engine); + Release(eventHandler); + } + + private void Release(object? value) + { + if (value is null) + { + return; + } + + try + { + _binding.Release(value); + } + catch + { + // Local RCW release is best effort and must not cause another SDK command. + } + } + + private static string ClassifyInvocationFailure(Exception exception) => exception switch + { + InvalidCastException => "ktap-argument-cast-failed", + ArgumentException => "ktap-argument-binding-failed", + _ => "ktap-connect-invocation-failed" + }; +} diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/InstalledK3dInteropConnectBinding.cs b/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/InstalledK3dInteropConnectBinding.cs new file mode 100644 index 0000000..e642432 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/InstalledK3dInteropConnectBinding.cs @@ -0,0 +1,630 @@ +using System.Reflection; +using System.Reflection.PortableExecutable; +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; +using System.Runtime.Loader; +using System.Security.Cryptography; +using MBN_STOCK_WEBVIEW.Playout.Interop; +using MBN_STOCK_WEBVIEW.Playout.Registration; + +namespace MBN_STOCK_WEBVIEW.Playout.Diagnostics; + +internal interface IK3dConnectOnlyComBinding +{ + object CreateEventHandler(); + + object CreateEngine(); + + int KtapConnect(object engine, string host, int port, object eventHandler); + + void Disconnect(object engine); + + void Release(object value); +} + +internal interface IK3dConnectOnlyComBindingFactory +{ + IK3dConnectOnlyComBinding Create(); +} + +internal sealed class InstalledK3dInteropConnectBindingFactory : IK3dConnectOnlyComBindingFactory +{ + public IK3dConnectOnlyComBinding Create() => + new InstalledK3dInteropConnectBinding( + InstalledK3dInteropMetadata.Resolve(), + new RuntimeComObjectReleaser()); +} + +internal static class InstalledK3dInteropMetadata +{ + internal const string ApprovedSha256EnvironmentVariable = + "MBN_STOCK_K3D_INTEROP_SHA256"; + internal const string ApprovedNativeSha256EnvironmentVariable = + "MBN_STOCK_K3D_NATIVE_SHA256"; + + private static readonly Lazy Types = new( + ResolveCore, + LazyThreadSafetyMode.ExecutionAndPublication); + + public static InstalledK3dInteropTypes Resolve() => Types.Value; + + private static InstalledK3dInteropTypes ResolveCore() + { + try + { + return ResolveAndHoldTrustedFiles(); + } + catch (InstalledK3dInteropMetadataUnavailableException) + { + throw; + } + catch (Exception) + { + // Do not put the approved hash, discovered path, or registry values in reports. + throw new InstalledK3dInteropMetadataUnavailableException(); + } + } + + private static InstalledK3dInteropTypes ResolveAndHoldTrustedFiles() + { + FileStream? nativeStream = null; + FileStream? interopStream = null; + try + { + var snapshot = new WindowsK3dRegistrySnapshotSource().Capture(); + var paths = ResolveRegisteredPaths(snapshot); + EnsureFixedDrive(paths.NativeBinaryPath); + EnsureFixedDrive(paths.InteropAssemblyPath); + EnsureNoReparsePointInAncestry(paths.NativeBinaryPath); + EnsureNoReparsePointInAncestry(paths.InteropAssemblyPath); + + nativeStream = OpenReadLocked(paths.NativeBinaryPath); + ValidateAmd64Pe(nativeStream, requireManagedMetadata: false); + ValidateApprovedSha256( + nativeStream, + Environment.GetEnvironmentVariable( + ApprovedNativeSha256EnvironmentVariable, + EnvironmentVariableTarget.Process)); + + interopStream = OpenReadLocked(paths.InteropAssemblyPath); + ValidateAmd64Pe(interopStream, requireManagedMetadata: true); + ValidateApprovedSha256( + interopStream, + Environment.GetEnvironmentVariable( + ApprovedSha256EnvironmentVariable, + EnvironmentVariableTarget.Process)); + + // Both non-delete/non-write shared handles remain rooted by the Lazy metadata + // for the process lifetime, including COM activation and subsequent use. + EnsureNoReparsePointInAncestry(paths.NativeBinaryPath); + EnsureNoReparsePointInAncestry(paths.InteropAssemblyPath); + var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath( + paths.InteropAssemblyPath); + var lease = new InstalledK3dInteropFileLease(nativeStream, interopStream); + var result = ValidateLoadedAssembly(assembly, lease); + nativeStream = null; + interopStream = null; + return result; + } + finally + { + interopStream?.Dispose(); + nativeStream?.Dispose(); + } + } + + internal static InstalledK3dInteropPaths ResolveRegisteredPaths( + K3dRegistrySnapshot snapshot) + { + if (!snapshot.Is64BitProcess || + snapshot.CurrentUserOverridePresent || + !IsExpectedClassRegistration( + snapshot.Engine, + K3dComConstants.KaEngineProgId, + K3dComConstants.KaEngineClassGuid) || + !IsExpectedClassRegistration( + snapshot.EventHandler, + K3dComConstants.KaEventHandlerProgId, + K3dComConstants.KaEventHandlerClassGuid)) + { + throw new InstalledK3dInteropMetadataUnavailableException(); + } + + var typeLibraryPath = NormalizeRegisteredPath(snapshot.Win64TypeLibraryPath); + var enginePath = NormalizeRegisteredPath(snapshot.Engine.InprocServerPath); + var eventHandlerPath = NormalizeRegisteredPath(snapshot.EventHandler.InprocServerPath); + if (!string.Equals(typeLibraryPath, enginePath, StringComparison.OrdinalIgnoreCase) || + !string.Equals(typeLibraryPath, eventHandlerPath, StringComparison.OrdinalIgnoreCase)) + { + throw new InstalledK3dInteropMetadataUnavailableException(); + } + + var nativeFile = new FileInfo(typeLibraryPath); + var releaseDirectory = nativeFile.Directory; + var x64Directory = releaseDirectory?.Parent; + var dllDirectory = x64Directory?.Parent; + var vendorRoot = dllDirectory?.Parent; + if (!string.Equals( + nativeFile.Name, + "K3DAsyncEngine.dll", + StringComparison.OrdinalIgnoreCase) || + !string.Equals(releaseDirectory?.Name, "Release", StringComparison.OrdinalIgnoreCase) || + !string.Equals(x64Directory?.Name, "x64", StringComparison.OrdinalIgnoreCase) || + !string.Equals(dllDirectory?.Name, "DLL", StringComparison.OrdinalIgnoreCase) || + vendorRoot is null) + { + throw new InstalledK3dInteropMetadataUnavailableException(); + } + + var interopPath = Path.GetFullPath( + Path.Combine( + vendorRoot.FullName, + "Bin", + "x64", + "C#", + "Interop.K3DAsyncEngineLib.dll")); + return new InstalledK3dInteropPaths(typeLibraryPath, interopPath); + } + + internal static byte[] ParseApprovedSha256(string? value) + { + if (value is null || value.Length != 64 || value.Any(character => !IsHex(character))) + { + throw new InstalledK3dInteropMetadataUnavailableException(); + } + + return Convert.FromHexString(value); + } + + internal static void ValidateApprovedSha256(Stream stream, string? approvedValue) + { + var approvedHash = ParseApprovedSha256(approvedValue); + stream.Position = 0; + var actualHash = SHA256.HashData(stream); + try + { + if (!CryptographicOperations.FixedTimeEquals(actualHash, approvedHash)) + { + throw new InstalledK3dInteropMetadataUnavailableException(); + } + } + finally + { + CryptographicOperations.ZeroMemory(actualHash); + CryptographicOperations.ZeroMemory(approvedHash); + } + } + + private static bool IsExpectedClassRegistration( + K3dClassRegistrationSnapshot registration, + string expectedProgId, + Guid expectedClassId) => + registration.ClassKeyPresent && + string.Equals(registration.ClassProgId, expectedProgId, StringComparison.OrdinalIgnoreCase) && + Guid.TryParse(registration.ProgIdClassId, out var classId) && + classId == expectedClassId && + string.Equals( + registration.ThreadingModel, + K3dComConstants.ApartmentThreadingModel, + StringComparison.OrdinalIgnoreCase) && + Guid.TryParse(registration.TypeLibraryId, out var typeLibraryId) && + typeLibraryId == Guid.Parse(K3dComConstants.TypeLibraryId); + + private static string NormalizeRegisteredPath(string? path) + { + if (string.IsNullOrEmpty(path) || + !string.Equals(path, path.Trim(), StringComparison.Ordinal) || + !Path.IsPathFullyQualified(path)) + { + throw new InstalledK3dInteropMetadataUnavailableException(); + } + + return Path.GetFullPath(path); + } + + private static void EnsureFixedDrive(string path) + { + var root = Path.GetPathRoot(path); + if (string.IsNullOrEmpty(root) || new DriveInfo(root).DriveType != DriveType.Fixed) + { + throw new InstalledK3dInteropMetadataUnavailableException(); + } + } + + private static void EnsureNoReparsePointInAncestry(string path) + { + if (!File.Exists(path)) + { + throw new InstalledK3dInteropMetadataUnavailableException(); + } + + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + { + throw new InstalledK3dInteropMetadataUnavailableException(); + } + + var current = Path.GetDirectoryName(path); + while (!string.IsNullOrEmpty(current)) + { + if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) + { + throw new InstalledK3dInteropMetadataUnavailableException(); + } + + current = Directory.GetParent(current)?.FullName; + } + } + + private static FileStream OpenReadLocked(string path) => new( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + + private static void ValidateAmd64Pe(Stream stream, bool requireManagedMetadata) + { + stream.Position = 0; + using var reader = new PEReader(stream, PEStreamOptions.LeaveOpen); + if (reader.PEHeaders.CoffHeader.Machine != Machine.Amd64 || + reader.PEHeaders.PEHeader?.Magic != PEMagic.PE32Plus || + (requireManagedMetadata && !reader.HasMetadata)) + { + throw new InstalledK3dInteropMetadataUnavailableException(); + } + } + + private static InstalledK3dInteropTypes ValidateLoadedAssembly( + Assembly assembly, + InstalledK3dInteropFileLease fileLease) + { + var assemblyName = assembly.GetName(); + if (!string.Equals( + assemblyName.Name, + "Interop.K3DAsyncEngineLib", + StringComparison.Ordinal) || + assemblyName.Version != new Version(1, 0, 0, 0) || + !Guid.TryParse(assembly.GetCustomAttribute()?.Value, out var typeLibraryId) || + typeLibraryId != Guid.Parse(K3dComConstants.TypeLibraryId) || + !string.Equals( + assembly.GetCustomAttribute()?.Value, + "K3DAsyncEngineLib", + StringComparison.Ordinal) || + assembly.GetCustomAttribute() is not { MajorVersion: 1, MinorVersion: 0 }) + { + throw new InstalledK3dInteropMetadataUnavailableException(); + } + + var engineType = RequireComType( + assembly, + "K3DAsyncEngineLib.KAEngineClass", + K3dComConstants.KaEngineClassGuid); + var eventHandlerType = RequireComType( + assembly, + "K3DAsyncEngineLib.KAEventHandlerClass", + K3dComConstants.KaEventHandlerClassGuid); + var scenePlayerType = RequireComType( + assembly, + "K3DAsyncEngineLib.IKAScenePlayer", + Guid.Parse("14083C84-4B17-41BF-AE1A-206F13C0E5C5")); + var sceneType = RequireComType( + assembly, + "K3DAsyncEngineLib.IKAScene", + Guid.Parse("BFD73274-AC76-437B-AF85-35682543F44C")); + var objectType = RequireComType( + assembly, + "K3DAsyncEngineLib.IKAObject", + Guid.Parse("B138B515-505B-4C7B-A3E1-F82782005ECF")); + + var connectMethod = engineType.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .SingleOrDefault(method => + method.Name == "KTAPConnect" && + method.ReturnType == typeof(int) && + method.GetParameters() is { Length: 5 } parameters && + parameters[0].ParameterType == typeof(int) && + parameters[1].ParameterType == typeof(string) && + parameters[2].ParameterType == typeof(int) && + parameters[3].ParameterType == typeof(int) && + parameters[4].ParameterType.GUID == + Guid.Parse("C21817DB-C0D3-4647-8D85-027338DCF832") && + parameters[4].ParameterType.IsAssignableFrom(eventHandlerType)); + var disconnectMethod = engineType.GetMethod( + "Disconnect", + BindingFlags.Public | BindingFlags.Instance, + binder: null, + types: Type.EmptyTypes, + modifiers: null); + var setOutputChannelMethod = sceneType + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .SingleOrDefault(method => + method.Name == "SetOutputChannelIndex" && + method.ReturnType == typeof(void) && + method.GetParameters() is { Length: 1 } parameters && + parameters[0].ParameterType == typeof(int)); + var unloadMethod = sceneType.GetMethod( + "Unload", + BindingFlags.Public | BindingFlags.Instance, + binder: null, + types: Type.EmptyTypes, + modifiers: null); + var endTransactionOnChannelMethod = engineType + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .SingleOrDefault(method => + method.Name == "EndTransactionOnChannel" && + method.ReturnType == typeof(void) && + method.GetParameters() is { Length: 1 } parameters && + parameters[0].ParameterType == typeof(int)); + if (connectMethod is null || + disconnectMethod?.ReturnType != typeof(void) || + setOutputChannelMethod is null || + unloadMethod?.ReturnType != typeof(void) || + endTransactionOnChannelMethod is null) + { + throw new InstalledK3dInteropMetadataUnavailableException(); + } + + return new InstalledK3dInteropTypes( + engineType, + eventHandlerType, + scenePlayerType, + sceneType, + objectType, + connectMethod, + disconnectMethod, + fileLease); + } + + private static Type RequireComType(Assembly assembly, string name, Guid expectedGuid) + { + var type = assembly.GetType(name, throwOnError: false, ignoreCase: false); + if (type is null || !type.IsImport || type.GUID != expectedGuid) + { + throw new InstalledK3dInteropMetadataUnavailableException(); + } + + return type; + } + + private static bool IsHex(char value) => + value is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F'; +} + +internal sealed class InstalledK3dInteropMetadataUnavailableException : Exception +{ + public InstalledK3dInteropMetadataUnavailableException() + : base("The installed K3D interop metadata is unavailable or not approved.") + { + } +} + +internal readonly record struct InstalledK3dInteropPaths( + string NativeBinaryPath, + string InteropAssemblyPath); + +internal sealed class InstalledK3dInteropFileLease +{ + internal InstalledK3dInteropFileLease(FileStream nativeStream, FileStream interopStream) + { + NativeStream = nativeStream; + InteropStream = interopStream; + } + + internal FileStream NativeStream { get; } + + internal FileStream InteropStream { get; } +} + +internal sealed record InstalledK3dInteropTypes( + Type EngineType, + Type EventHandlerType, + Type ScenePlayerType, + Type SceneType, + Type ObjectType, + MethodInfo ConnectMethod, + MethodInfo DisconnectMethod, + InstalledK3dInteropFileLease FileLease); + +internal sealed class InstalledK3dInteropComActivator : ILateBoundComActivator +{ + public object Create(Guid classId) + { + var types = InstalledK3dInteropMetadata.Resolve(); + var type = classId switch + { + _ when classId == K3dComConstants.KaEngineClassGuid => types.EngineType, + _ when classId == K3dComConstants.KaEventHandlerClassGuid => types.EventHandlerType, + _ => throw new InvalidOperationException("The K3D COM class is not allowlisted.") + }; + + return Activator.CreateInstance(type) + ?? throw new InvalidOperationException("The installed K3D COM class could not be created."); + } +} + +internal sealed class InstalledK3dInteropMethodInvoker : ILateBoundComMethodInvoker +{ + private static readonly IReadOnlySet EngineMethods = new HashSet( + [ + "KTAPConnect", + "Disconnect", + "GetScenePlayer", + "GetScenePlayerOnChannel", + "LoadScene", + "BeginTransaction", + "EndTransaction", + "EndTransactionOnChannel", + "RollbackTransaction" + ], StringComparer.Ordinal); + + private static readonly IReadOnlySet PlayerMethods = new HashSet( + [ + "Prepare", + "Play", + "StopAll", + "CutOut", + "GetPlayingScene" + ], StringComparer.Ordinal); + + private static readonly IReadOnlySet SceneMethods = new HashSet( + [ + "SetSceneEffectType", + "SetOutputChannelIndex", + "QueryVariables", + "GetObject", + "Unload" + ], StringComparer.Ordinal); + + private static readonly IReadOnlySet ObjectMethods = new HashSet( + [ + "SetValue", + "SetVisible" + ], StringComparer.Ordinal); + + public object? Invoke(object target, string method, params object?[] arguments) + { + ArgumentNullException.ThrowIfNull(target); + if (!Marshal.IsComObject(target)) + { + return InvokeMember(target.GetType(), target, method, arguments); + } + + var types = InstalledK3dInteropMetadata.Resolve(); + var invocationType = EngineMethods.Contains(method) + ? types.EngineType + : PlayerMethods.Contains(method) + ? types.ScenePlayerType + : SceneMethods.Contains(method) + ? types.SceneType + : ObjectMethods.Contains(method) + ? types.ObjectType + : throw new MissingMethodException("The K3D method is not allowlisted."); + var candidates = invocationType + .GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(candidate => + candidate.Name == method && + candidate.GetParameters().Length == arguments.Length) + .ToArray(); + if (candidates.Length != 1) + { + throw new MissingMethodException("The installed K3D method signature is ambiguous."); + } + + var invokeArguments = NormalizeArguments(candidates[0], arguments); + return InvokeMethod(candidates[0], target, invokeArguments); + } + + private static object?[] NormalizeArguments(MethodInfo method, object?[] arguments) + { + var parameters = method.GetParameters(); + var result = new object?[arguments.Length]; + for (var index = 0; index < arguments.Length; index++) + { + var value = arguments[index]; + var expectedType = parameters[index].ParameterType; + if (value is null || expectedType.IsInstanceOfType(value)) + { + result[index] = value; + } + else if (expectedType.IsEnum && value is IConvertible) + { + result[index] = Enum.ToObject(expectedType, value); + } + else if (value is IConvertible && typeof(IConvertible).IsAssignableFrom(expectedType)) + { + result[index] = Convert.ChangeType( + value, + expectedType, + System.Globalization.CultureInfo.InvariantCulture); + } + else + { + result[index] = value; + } + } + + return result; + } + + private static object? InvokeMember( + Type type, + object target, + string method, + object?[] arguments) + { + try + { + return type.InvokeMember( + method, + BindingFlags.InvokeMethod | BindingFlags.Public | BindingFlags.Instance, + binder: null, + target, + arguments, + System.Globalization.CultureInfo.InvariantCulture); + } + catch (TargetInvocationException exception) when (exception.InnerException is not null) + { + ExceptionDispatchInfo.Capture(exception.InnerException).Throw(); + throw; + } + } + + private static object? InvokeMethod( + MethodInfo method, + object target, + object?[] arguments) + { + try + { + return method.Invoke(target, arguments); + } + catch (TargetInvocationException exception) when (exception.InnerException is not null) + { + ExceptionDispatchInfo.Capture(exception.InnerException).Throw(); + throw; + } + } +} + +internal sealed class InstalledK3dInteropConnectBinding : IK3dConnectOnlyComBinding +{ + private readonly InstalledK3dInteropTypes _types; + private readonly ILateBoundComObjectReleaser _releaser; + + public InstalledK3dInteropConnectBinding( + InstalledK3dInteropTypes types, + ILateBoundComObjectReleaser releaser) + { + _types = types; + _releaser = releaser; + } + + public object CreateEventHandler() => + Activator.CreateInstance(_types.EventHandlerType) + ?? throw new InvalidOperationException("The K3D event handler could not be created."); + + public object CreateEngine() => + Activator.CreateInstance(_types.EngineType) + ?? throw new InvalidOperationException("The K3D engine could not be created."); + + public int KtapConnect(object engine, string host, int port, object eventHandler) => + Convert.ToInt32( + Invoke(_types.ConnectMethod, engine, [1, host, port, 0, eventHandler]), + System.Globalization.CultureInfo.InvariantCulture); + + public void Disconnect(object engine) => + _ = Invoke(_types.DisconnectMethod, engine, null); + + public void Release(object value) => _releaser.Release(value); + + private static object? Invoke(MethodInfo method, object target, object?[]? arguments) + { + try + { + return method.Invoke(target, arguments); + } + catch (TargetInvocationException exception) when (exception.InnerException is not null) + { + ExceptionDispatchInfo.Capture(exception.InnerException).Throw(); + throw; + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/PgmConnectDiagnostic.cs b/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/PgmConnectDiagnostic.cs new file mode 100644 index 0000000..564c907 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/PgmConnectDiagnostic.cs @@ -0,0 +1,456 @@ +using System.Net; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Playout.Registration; +using MBN_STOCK_WEBVIEW.Playout.Runtime; + +namespace MBN_STOCK_WEBVIEW.Playout.Diagnostics; + +internal sealed record PgmConnectDiagnosticRequest( + string Host, + int Port, + string ExpectedPgmWindowTitle); + +internal sealed record PgmConnectDiagnosticReport( + bool RequestValidated, + bool ProcessGatePassed, + bool ListenerOwnershipConfirmed, + bool RegistrationReady, + bool ConnectRequestIssued, + bool? ComActivationAttempted, + PlayoutKtapConnectState LastKtapConnectState, + bool KtapConnectAttempted, + bool? KtapConnectAccepted, + bool? KtapHelloObserved, + bool? NetworkMonitoringRecordExpected, + bool NetworkMonitoringCheckRequired, + bool? NetworkMonitoringVerified, + bool DisconnectAttempted, + string DisconnectCode, + bool Completed, + bool OutcomeUnknown, + bool RenderCommandSurfaceExposed, + bool RenderCommandAttempted, + bool AutomaticReconnectEnabled, + string? ErrorCode); + +internal sealed class PgmConnectDiagnosticRunner +{ + internal static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(3); + internal static readonly TimeSpan DisconnectTimeout = TimeSpan.FromSeconds(3); + + private readonly IPgmDiagnosticSafetyProbe _safetyProbe; + private readonly IKtapConnectOnlySessionFactory _sessionFactory; + private readonly IK3dRegistrationProbe _registrationProbe; + private readonly IStaDispatcherFactory _dispatcherFactory; + private readonly TimeSpan _connectTimeout; + private readonly TimeSpan _disconnectTimeout; + + internal PgmConnectDiagnosticRunner() + : this( + new WindowsPgmDiagnosticSafetyProbe(), + new DynamicKtapConnectOnlySessionFactory(), + new K3dRegistrationProbe(), + new StaDispatcherFactory(), + ConnectTimeout, + DisconnectTimeout) + { + } + + internal PgmConnectDiagnosticRunner( + IPgmDiagnosticSafetyProbe safetyProbe, + IKtapConnectOnlySessionFactory sessionFactory, + IK3dRegistrationProbe registrationProbe, + IStaDispatcherFactory dispatcherFactory, + TimeSpan? connectTimeout = null, + TimeSpan? disconnectTimeout = null) + { + _safetyProbe = safetyProbe; + _sessionFactory = sessionFactory; + _registrationProbe = registrationProbe; + _dispatcherFactory = dispatcherFactory; + _connectTimeout = connectTimeout ?? ConnectTimeout; + _disconnectTimeout = disconnectTimeout ?? DisconnectTimeout; + if (_connectTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(connectTimeout)); + } + + if (_disconnectTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(disconnectTimeout)); + } + } + + internal async Task ExecuteAsync( + PgmConnectDiagnosticRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + if (!TryValidateRequest(request, out var parsedHost)) + { + return Empty("request-rejected"); + } + + var validatedHost = parsedHost!; + + PgmDiagnosticTargetSnapshot baseline; + try + { + baseline = _safetyProbe.Capture(request, validatedHost); + } + catch + { + return Empty("process-listener-inspection-unavailable") with + { + RequestValidated = true + }; + } + + if (!baseline.IsEligible) + { + return Empty("pgm-target-rejected") with + { + RequestValidated = true, + ProcessGatePassed = baseline.ProcessGatePassed, + ListenerOwnershipConfirmed = baseline.ListenerOwnershipConfirmed + }; + } + + K3dRegistrationReport registration; + try + { + registration = _registrationProbe.Probe(); + } + catch + { + return Empty("registration-inspection-unavailable") with + { + RequestValidated = true, + ProcessGatePassed = true, + ListenerOwnershipConfirmed = true + }; + } + + if (!registration.IsReady) + { + return Empty("registration-not-ready") with + { + RequestValidated = true, + ProcessGatePassed = true, + ListenerOwnershipConfirmed = true + }; + } + + var state = new ExecutionState + { + RequestValidated = true, + ProcessGatePassed = true, + ListenerOwnershipConfirmed = true, + RegistrationReady = true, + ConnectRequestIssued = true + }; + IKtapConnectOnlySession? pendingSession = null; + IKtapConnectOnlySession? acceptedSession = null; + await using var dispatcher = _dispatcherFactory.Create(1); + + try + { + var session = _sessionFactory.Create(); + Volatile.Write(ref pendingSession, session); + acceptedSession = await dispatcher.InvokeAsync( + () => + { + var current = _safetyProbe.Capture(request, validatedHost); + if (!current.IsEligible || !baseline.HasSameTarget(current)) + { + throw new PgmDiagnosticSafetyException(); + } + + session.Connect( + validatedHost.ToString(), + request.Port, + () => + { + var final = _safetyProbe.Capture(request, validatedHost); + return final.IsEligible && baseline.HasSameTarget(final); + }); + return session; + }, + _connectTimeout, + cancellationToken, + static lateSession => lateSession.Abandon(), + () => Volatile.Read(ref pendingSession)?.TryPreventKtapConnect()) + .ConfigureAwait(false); + + CaptureSessionEvidence(state, acceptedSession); + state.DisconnectAttempted = true; + await dispatcher.InvokeAsync( + () => + { + var current = _safetyProbe.Capture(request, validatedHost); + if (!current.IsEligible || !baseline.HasSameTarget(current)) + { + acceptedSession.Abandon(); + throw new PgmDiagnosticSafetyException(); + } + + acceptedSession.Disconnect(); + return true; + }, + _disconnectTimeout, + CancellationToken.None) + .ConfigureAwait(false); + + state.DisconnectCode = "success"; + state.Completed = true; + } + catch (PgmDiagnosticSafetyException) + { + CaptureSessionEvidence(state, Volatile.Read(ref pendingSession)); + state.ErrorCode = state.KtapConnectAttempted + ? "pgm-target-changed-after-connect" + : "pgm-target-changed-before-connect"; + state.OutcomeUnknown = state.KtapConnectAttempted; + state.DisconnectCode = state.DisconnectAttempted + ? "not-issued-target-changed" + : "not-attempted"; + } + catch (KtapConnectRejectedException) + { + CaptureSessionEvidence(state, Volatile.Read(ref pendingSession)); + state.ErrorCode = "ktap-connect-rejected"; + } + catch (KtapConnectInvocationException exception) + { + CaptureSessionEvidence(state, Volatile.Read(ref pendingSession)); + state.ErrorCode = exception.SafeErrorCode; + state.OutcomeUnknown = true; + } + catch (OperationCanceledException) + { + PreventLateDispatchAndCapture(state, Volatile.Read(ref pendingSession)); + state.ErrorCode = "cancelled"; + state.OutcomeUnknown = state.KtapConnectAttempted; + } + catch (StaOperationTimedOutException) + { + PreventLateDispatchAndCapture(state, Volatile.Read(ref pendingSession)); + state.ErrorCode = state.DisconnectAttempted + ? "disconnect-timeout" + : "connect-timeout"; + state.DisconnectCode = state.DisconnectAttempted + ? "outcome-unknown" + : "not-attempted"; + state.OutcomeUnknown = state.KtapConnectAttempted || state.DisconnectAttempted; + } + catch (StaDispatcherQuarantinedException) + { + PreventLateDispatchAndCapture(state, Volatile.Read(ref pendingSession)); + state.ErrorCode = "dispatcher-quarantined"; + state.OutcomeUnknown = state.KtapConnectAttempted || state.DisconnectAttempted; + } + catch (InstalledK3dInteropMetadataUnavailableException) + { + CaptureSessionEvidence(state, Volatile.Read(ref pendingSession)); + state.ErrorCode = "installed-interop-metadata-unavailable"; + state.DisconnectCode = "not-attempted"; + } + catch + { + CaptureSessionEvidence(state, Volatile.Read(ref pendingSession)); + state.ErrorCode = state.DisconnectAttempted + ? "disconnect-failed" + : state.KtapConnectAttempted + ? "ktap-connect-failed" + : "com-activation-failed"; + state.DisconnectCode = state.DisconnectAttempted ? "failed" : "not-attempted"; + state.OutcomeUnknown = state.KtapConnectAttempted || state.DisconnectAttempted; + } + finally + { + if (!state.Completed && acceptedSession is not null && !dispatcher.IsQuarantined) + { + try + { + await dispatcher.InvokeAsync( + () => + { + acceptedSession.Abandon(); + return true; + }, + _disconnectTimeout, + CancellationToken.None) + .ConfigureAwait(false); + } + catch + { + // Local COM release is best effort. Never issue a second SDK command here. + } + } + } + + return state.ToReport(); + } + + internal static bool TryValidateRequest( + PgmConnectDiagnosticRequest request, + out IPAddress? host) + { + host = null; + if (request.Port is < 1 or > 65_535 || + string.IsNullOrWhiteSpace(request.Host) || + !IPAddress.TryParse(request.Host, out host) || + !IPAddress.IsLoopback(host) || + string.IsNullOrWhiteSpace(request.ExpectedPgmWindowTitle) || + request.ExpectedPgmWindowTitle.Length > 128 || + HasToken(request.ExpectedPgmWindowTitle, "TEST") || + !(HasToken(request.ExpectedPgmWindowTitle, "PGM") || + HasToken(request.ExpectedPgmWindowTitle, "PROGRAM"))) + { + host = null; + return false; + } + + return true; + } + + private static bool HasToken(string value, string token) + { + for (var start = 0; start <= value.Length - token.Length; start++) + { + if (!value.AsSpan(start, token.Length).Equals( + token.AsSpan(), + StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var beforeIsBoundary = start == 0 || !char.IsLetterOrDigit(value[start - 1]); + var after = start + token.Length; + var afterIsBoundary = after == value.Length || !char.IsLetterOrDigit(value[after]); + if (beforeIsBoundary && afterIsBoundary) + { + return true; + } + } + + return false; + } + + private static void PreventLateDispatchAndCapture( + ExecutionState state, + IKtapConnectOnlySession? session) + { + session?.TryPreventKtapConnect(); + CaptureSessionEvidence(state, session); + } + + private static void CaptureSessionEvidence( + ExecutionState state, + IKtapConnectOnlySession? session) + { + if (session is null) + { + return; + } + + state.ComActivationAttempted = session.ComActivationAttempted; + state.LastKtapConnectState = session.LastKtapConnectState; + state.KtapConnectAttempted = + session.LastKtapConnectState != PlayoutKtapConnectState.NotAttempted; + } + + private static PgmConnectDiagnosticReport Empty(string errorCode) => new( + false, + false, + false, + false, + false, + false, + PlayoutKtapConnectState.NotAttempted, + false, + false, + null, + false, + false, + null, + false, + "not-attempted", + false, + false, + false, + false, + false, + errorCode); + + private sealed class ExecutionState + { + public bool RequestValidated { get; set; } + public bool ProcessGatePassed { get; set; } + public bool ListenerOwnershipConfirmed { get; set; } + public bool RegistrationReady { get; set; } + public bool ConnectRequestIssued { get; set; } + public bool? ComActivationAttempted { get; set; } = false; + public PlayoutKtapConnectState LastKtapConnectState { get; set; } + public bool KtapConnectAttempted { get; set; } + public bool DisconnectAttempted { get; set; } + public string DisconnectCode { get; set; } = "not-attempted"; + public bool Completed { get; set; } + public bool OutcomeUnknown { get; set; } + public string? ErrorCode { get; set; } + + public PgmConnectDiagnosticReport ToReport() + { + bool? accepted = LastKtapConnectState switch + { + PlayoutKtapConnectState.AcceptedUnconfirmed => true, + PlayoutKtapConnectState.NotAttempted => false, + _ => null + }; + bool? monitoringExpected = LastKtapConnectState switch + { + PlayoutKtapConnectState.AcceptedUnconfirmed => true, + PlayoutKtapConnectState.NotAttempted => false, + _ => null + }; + + return new PgmConnectDiagnosticReport( + RequestValidated, + ProcessGatePassed, + ListenerOwnershipConfirmed, + RegistrationReady, + ConnectRequestIssued, + ComActivationAttempted, + LastKtapConnectState, + KtapConnectAttempted, + accepted, + null, + monitoringExpected, + KtapConnectAttempted, + null, + DisconnectAttempted, + DisconnectCode, + Completed, + OutcomeUnknown, + false, + false, + false, + ErrorCode); + } + } +} + +internal sealed class PgmDiagnosticSafetyException : Exception; + +internal sealed class KtapConnectRejectedException : Exception; + +internal sealed class KtapConnectInvocationException : Exception +{ + public KtapConnectInvocationException(string safeErrorCode, Exception innerException) + : base("The KTAP invocation failed.", innerException) + { + SafeErrorCode = safeErrorCode; + } + + public string SafeErrorCode { get; } +} diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/PgmDiagnosticSafetyProbe.cs b/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/PgmDiagnosticSafetyProbe.cs new file mode 100644 index 0000000..1064048 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.Playout/Diagnostics/PgmDiagnosticSafetyProbe.cs @@ -0,0 +1,415 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; + +namespace MBN_STOCK_WEBVIEW.Playout.Diagnostics; + +internal readonly record struct PgmDiagnosticTargetIdentity( + int ProcessId, + long StartTimeUtcTicks); + +internal sealed record PgmDiagnosticTargetSnapshot( + bool InspectionSucceeded, + int TotalTornadoProcessCount, + int ExactWindowCount, + bool ListenerOwnershipConfirmed, + PgmDiagnosticTargetIdentity? Identity) +{ + public bool ProcessGatePassed => + InspectionSucceeded && + TotalTornadoProcessCount == 1 && + ExactWindowCount == 1 && + Identity is not null; + + public bool IsEligible => ProcessGatePassed && ListenerOwnershipConfirmed; + + public bool HasSameTarget(PgmDiagnosticTargetSnapshot other) => + IsEligible && other.IsEligible && Identity == other.Identity; +} + +internal interface IPgmDiagnosticSafetyProbe +{ + PgmDiagnosticTargetSnapshot Capture( + PgmConnectDiagnosticRequest request, + IPAddress parsedHost); +} + +internal interface ITcpListenerOwnershipProbe +{ + bool TryIsOwnedBy( + IPAddress requestedLocalAddress, + int port, + int processId, + out bool isOwned); +} + +internal readonly record struct TcpListenerOwnershipEntry( + IPAddress LocalAddress, + int Port, + int ProcessId); + +internal sealed class WindowsPgmDiagnosticSafetyProbe : IPgmDiagnosticSafetyProbe +{ + private readonly ITcpListenerOwnershipProbe _listenerProbe; + + public WindowsPgmDiagnosticSafetyProbe() + : this(new WindowsTcpListenerOwnershipProbe()) + { + } + + internal WindowsPgmDiagnosticSafetyProbe(ITcpListenerOwnershipProbe listenerProbe) + { + _listenerProbe = listenerProbe; + } + + public PgmDiagnosticTargetSnapshot Capture( + PgmConnectDiagnosticRequest request, + IPAddress parsedHost) + { + var inspectionSucceeded = true; + var totalCount = 0; + var exactWindowCount = 0; + PgmDiagnosticTargetIdentity? identity = null; + Process[] processes; + try + { + processes = Process.GetProcesses(); + } + catch (Exception exception) when (IsInspectionException(exception)) + { + return new PgmDiagnosticTargetSnapshot(false, 0, 0, false, null); + } + + foreach (var process in processes) + { + try + { + string processName; + try + { + processName = process.ProcessName; + } + catch (Exception exception) when (IsExitedProcessException(exception)) + { + continue; + } + catch (Exception exception) when (IsInspectionException(exception)) + { + inspectionSucceeded = false; + continue; + } + + if (!processName.StartsWith("Tornado2", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + totalCount++; + string title; + try + { + title = process.MainWindowTitle; + } + catch (Exception exception) when (IsInspectionException(exception)) + { + inspectionSucceeded = false; + continue; + } + + if (!string.Equals( + title, + request.ExpectedPgmWindowTitle, + StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + exactWindowCount++; + try + { + identity = new PgmDiagnosticTargetIdentity( + process.Id, + process.StartTime.ToUniversalTime().Ticks); + } + catch (Exception exception) when (IsInspectionException(exception)) + { + inspectionSucceeded = false; + identity = null; + } + } + finally + { + process.Dispose(); + } + } + + var listenerOwned = false; + if (inspectionSucceeded && + totalCount == 1 && + exactWindowCount == 1 && + identity is { } target) + { + if (!_listenerProbe.TryIsOwnedBy( + parsedHost, + request.Port, + target.ProcessId, + out listenerOwned)) + { + inspectionSucceeded = false; + listenerOwned = false; + } + } + + return new PgmDiagnosticTargetSnapshot( + inspectionSucceeded, + totalCount, + exactWindowCount, + listenerOwned, + identity); + } + + private static bool IsInspectionException(Exception exception) => + exception is InvalidOperationException or + ArgumentException or + Win32Exception or + NotSupportedException or + UnauthorizedAccessException; + + private static bool IsExitedProcessException(Exception exception) => + exception is InvalidOperationException or ArgumentException; +} + +internal sealed class WindowsTcpListenerOwnershipProbe : ITcpListenerOwnershipProbe +{ + private const uint ErrorInsufficientBuffer = 122; + private const uint NoError = 0; + private const int AfInet = 2; + private const int AfInet6 = 23; + + public bool TryIsOwnedBy( + IPAddress requestedLocalAddress, + int port, + int processId, + out bool isOwned) + { + isOwned = false; + if (!IsValidRequestedEndpoint(requestedLocalAddress, port, processId)) + { + return false; + } + + var addressFamily = requestedLocalAddress.AddressFamily; + var family = addressFamily switch + { + AddressFamily.InterNetwork => AfInet, + AddressFamily.InterNetworkV6 => AfInet6, + _ => 0 + }; + if (family == 0) + { + return false; + } + + IntPtr buffer = IntPtr.Zero; + try + { + var size = 0; + var result = GetExtendedTcpTable( + IntPtr.Zero, + ref size, + false, + family, + TcpTableClass.OwnerPidListener, + 0); + if (result is not (ErrorInsufficientBuffer or NoError) || size <= 0) + { + return false; + } + + buffer = Marshal.AllocHGlobal(size); + result = GetExtendedTcpTable( + buffer, + ref size, + false, + family, + TcpTableClass.OwnerPidListener, + 0); + if (result != NoError) + { + return false; + } + + var count = Marshal.ReadInt32(buffer); + if (count < 0) + { + return false; + } + + var listeners = new List(count); + var rowPointer = IntPtr.Add(buffer, sizeof(uint)); + if (addressFamily == AddressFamily.InterNetwork) + { + var rowSize = Marshal.SizeOf(); + for (var index = 0; index < count; index++) + { + var row = Marshal.PtrToStructure(rowPointer); + listeners.Add(new TcpListenerOwnershipEntry( + new IPAddress(row.LocalAddress), + DecodePort(row.LocalPort), + row.OwningPid)); + + rowPointer = IntPtr.Add(rowPointer, rowSize); + } + } + else + { + var rowSize = Marshal.SizeOf(); + for (var index = 0; index < count; index++) + { + var row = Marshal.PtrToStructure(rowPointer); + if (row.LocalAddress is not { Length: 16 }) + { + return false; + } + + listeners.Add(new TcpListenerOwnershipEntry( + new IPAddress(row.LocalAddress, row.LocalScopeId), + DecodePort(row.LocalPort), + row.OwningPid)); + + rowPointer = IntPtr.Add(rowPointer, rowSize); + } + } + + return TryEvaluateOwnership( + requestedLocalAddress, + port, + processId, + listeners, + out isOwned); + } + catch + { + isOwned = false; + return false; + } + finally + { + if (buffer != IntPtr.Zero) + { + Marshal.FreeHGlobal(buffer); + } + } + } + + internal static bool TryEvaluateOwnership( + IPAddress requestedLocalAddress, + int port, + int processId, + IReadOnlyList listeners, + out bool isOwned) + { + isOwned = false; + if (!IsValidRequestedEndpoint(requestedLocalAddress, port, processId) || + listeners is null) + { + return false; + } + + var wildcard = requestedLocalAddress.AddressFamily switch + { + AddressFamily.InterNetwork => IPAddress.Any, + AddressFamily.InterNetworkV6 => IPAddress.IPv6Any, + _ => null + }; + if (wildcard is null) + { + return false; + } + + var targetListenerFound = false; + foreach (var listener in listeners) + { + if (listener.LocalAddress is null) + { + return false; + } + + if (listener.Port != port || + listener.LocalAddress.AddressFamily != requestedLocalAddress.AddressFamily || + (!listener.LocalAddress.Equals(requestedLocalAddress) && + !listener.LocalAddress.Equals(wildcard))) + { + continue; + } + + if (listener.ProcessId != processId) + { + // Any competing owner able to accept the requested endpoint fails closed, + // even if the target-owned row appeared earlier in the table. + return true; + } + + targetListenerFound = true; + } + + isOwned = targetListenerFound; + return true; + } + + private static bool IsValidRequestedEndpoint( + IPAddress? requestedLocalAddress, + int port, + int processId) => + requestedLocalAddress is not null && + IPAddress.IsLoopback(requestedLocalAddress) && + requestedLocalAddress.AddressFamily is + AddressFamily.InterNetwork or AddressFamily.InterNetworkV6 && + port is >= 1 and <= 65_535 && + processId > 0; + + internal static int DecodePort(uint value) => + unchecked((ushort)IPAddress.NetworkToHostOrder((short)value)); + + [DllImport("iphlpapi.dll", SetLastError = true)] + private static extern uint GetExtendedTcpTable( + IntPtr tcpTable, + ref int size, + [MarshalAs(UnmanagedType.Bool)] bool order, + int addressFamily, + TcpTableClass tableClass, + uint reserved); + + private enum TcpTableClass + { + OwnerPidListener = 3 + } + + [StructLayout(LayoutKind.Sequential)] + private struct MibTcpRowOwnerPid + { + public uint State; + public uint LocalAddress; + public uint LocalPort; + public uint RemoteAddress; + public uint RemotePort; + public int OwningPid; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MibTcp6RowOwnerPid + { + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] + public byte[] LocalAddress; + public uint LocalScopeId; + public uint LocalPort; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] + public byte[] RemoteAddress; + public uint RemoteScopeId; + public uint RemotePort; + public uint State; + public int OwningPid; + } +} diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs index 64af6d6..02c1799 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Interop/DynamicK3dSession.cs @@ -4,6 +4,7 @@ using System.Runtime.ExceptionServices; using System.Runtime.InteropServices; using MBN_STOCK_WEBVIEW.Core.Playout; using MBN_STOCK_WEBVIEW.Playout.Configuration; +using MBN_STOCK_WEBVIEW.Playout.Diagnostics; namespace MBN_STOCK_WEBVIEW.Playout.Interop; @@ -36,18 +37,32 @@ internal interface IK3dSessionFactory internal sealed class DynamicK3dSessionFactory : IK3dSessionFactory { private readonly ILateBoundComActivator _activator; + private readonly ILateBoundComMethodInvoker _invoker; public DynamicK3dSessionFactory() - : this(new RegisteredComActivator()) + : this( + new InstalledK3dInteropComActivator(), + new InstalledK3dInteropMethodInvoker()) { } internal DynamicK3dSessionFactory(ILateBoundComActivator activator) + : this(activator, new InstalledK3dInteropMethodInvoker()) { - _activator = activator; } - public IK3dSession Create() => new DynamicK3dSession(_activator); + internal DynamicK3dSessionFactory( + ILateBoundComActivator activator, + ILateBoundComMethodInvoker invoker) + { + _activator = activator; + _invoker = invoker; + } + + public IK3dSession Create() => new DynamicK3dSession( + _activator, + new RuntimeComObjectReleaser(), + _invoker); } internal interface ILateBoundComActivator @@ -60,6 +75,11 @@ internal interface ILateBoundComObjectReleaser void Release(object value); } +internal interface ILateBoundComMethodInvoker +{ + object? Invoke(object target, string method, params object?[] arguments); +} + internal sealed class RegisteredComActivator : ILateBoundComActivator { public object Create(Guid classId) @@ -86,26 +106,42 @@ internal sealed class DynamicK3dSession : IK3dSession { private readonly ILateBoundComActivator _activator; private readonly ILateBoundComObjectReleaser _releaser; + private readonly ILateBoundComMethodInvoker _invoker; private object? _engine; private object? _eventHandler; private object? _player; - private object? _scene; + private object? _preparedScene; + private object? _currentScene; + private readonly List _retiredScenes = []; + private int? _outputChannel; private int? _ownerThreadId; private int _lastKtapConnectState; private int _ktapDispatchGate; private bool _disposed; public DynamicK3dSession(ILateBoundComActivator activator) - : this(activator, new RuntimeComObjectReleaser()) + : this( + activator, + new RuntimeComObjectReleaser(), + new InstalledK3dInteropMethodInvoker()) { } internal DynamicK3dSession( ILateBoundComActivator activator, ILateBoundComObjectReleaser releaser) + : this(activator, releaser, new InstalledK3dInteropMethodInvoker()) + { + } + + internal DynamicK3dSession( + ILateBoundComActivator activator, + ILateBoundComObjectReleaser releaser, + ILateBoundComMethodInvoker invoker) { _activator = activator; _releaser = releaser; + _invoker = invoker; } public bool IsConnected => _engine is not null && _player is not null; @@ -169,9 +205,11 @@ internal sealed class DynamicK3dSession : IK3dSession SetKtapConnectState(PlayoutKtapConnectState.AcceptedUnconfirmed); - _player = options.OutputChannel is { } channel + var outputChannel = options.OutputChannel; + _player = outputChannel is { } channel ? InvokeRequired(_engine, "GetScenePlayerOnChannel", channel) : InvokeRequired(_engine, "GetScenePlayer"); + _outputChannel = outputChannel; } catch (Exception exception) { @@ -238,16 +276,22 @@ internal sealed class DynamicK3dSession : IK3dSession EnsureConnected(); var engine = _engine!; var player = _player!; + var outputChannel = _outputChannel; object? nextScene = null; var transactionStarted = false; try { nextScene = InvokeRequired(engine, "LoadScene", cue.SceneFile, cue.SceneName); + if (outputChannel.HasValue) + { + Invoke(nextScene, "SetOutputChannelIndex", outputChannel.GetValueOrDefault()); + } + Invoke( nextScene, "SetSceneEffectType", - layoutIndex, + K3dComConstants.SceneEffectInEnabled, K3dComConstants.SceneChangeEffectFade, cue.FadeDuration); @@ -258,14 +302,23 @@ internal sealed class DynamicK3dSession : IK3dSession ApplyField(nextScene, field); } - Invoke(nextScene, "QueryVariables"); - Invoke(engine, "EndTransaction"); + if (outputChannel.HasValue) + { + Invoke(engine, "EndTransactionOnChannel", outputChannel.GetValueOrDefault()); + } + else + { + Invoke(engine, "EndTransaction"); + } + transactionStarted = false; + Invoke(nextScene, "QueryVariables"); Invoke(player, "Prepare", layoutIndex, nextScene); - ReleaseComObject(_scene); - _scene = nextScene; + var replacedPreparedScene = _preparedScene; + _preparedScene = nextScene; nextScene = null; + RetainRetiredScene(replacedPreparedScene); } catch { @@ -285,7 +338,7 @@ internal sealed class DynamicK3dSession : IK3dSession } finally { - ReleaseComObject(nextScene); + RetainRetiredScene(nextScene); } } @@ -294,6 +347,20 @@ internal sealed class DynamicK3dSession : IK3dSession EnsureThread(); EnsureConnected(); Invoke(_player!, "Play", layoutIndex); + + var preparedScene = _preparedScene; + if (preparedScene is not null) + { + _preparedScene = null; + var previousCurrentScene = _currentScene; + _currentScene = preparedScene; + if (previousCurrentScene is not null && + !ReferenceEquals(previousCurrentScene, preparedScene)) + { + RetainRetiredScene(previousCurrentScene); + } + } + } public void TakeOut(int layoutIndex, PlayoutTakeOutScope scope) @@ -308,6 +375,14 @@ internal sealed class DynamicK3dSession : IK3dSession { Invoke(_player!, "CutOut", layoutIndex); } + + var currentScene = _currentScene; + _currentScene = null; + if (currentScene is not null) + { + RetainRetiredScene(currentScene); + } + } public void Abandon() @@ -406,43 +481,60 @@ internal sealed class DynamicK3dSession : IK3dSession private void ReleaseAll() { - var scene = _scene; - _scene = null; + var scenes = new List(); + AddDistinctScene(scenes, _preparedScene); + _preparedScene = null; + AddDistinctScene(scenes, _currentScene); + _currentScene = null; + foreach (var retiredScene in _retiredScenes) + { + AddDistinctScene(scenes, retiredScene); + } + + _retiredScenes.Clear(); var player = _player; _player = null; + _outputChannel = null; var engine = _engine; _engine = null; var eventHandler = _eventHandler; _eventHandler = null; - ReleaseComObject(scene); + foreach (var scene in scenes) + { + ReleaseComObject(scene); + } + ReleaseComObject(player); ReleaseComObject(engine); ReleaseComObject(eventHandler); } - private static object InvokeRequired(object target, string method, params object?[] arguments) => + private void RetainRetiredScene(object? scene) + { + if (scene is not null && + !ReferenceEquals(scene, _preparedScene) && + !ReferenceEquals(scene, _currentScene) && + !_retiredScenes.Any(item => ReferenceEquals(item, scene))) + { + _retiredScenes.Add(scene); + } + } + + private static void AddDistinctScene(List scenes, object? scene) + { + if (scene is not null && !scenes.Any(item => ReferenceEquals(item, scene))) + { + scenes.Add(scene); + } + } + + private object InvokeRequired(object target, string method, params object?[] arguments) => Invoke(target, method, arguments) ?? throw new InvalidOperationException("The K3D operation returned no object."); - private static object? Invoke(object target, string method, params object?[] arguments) - { - try - { - return target.GetType().InvokeMember( - method, - BindingFlags.InvokeMethod | BindingFlags.Public | BindingFlags.Instance, - binder: null, - target, - arguments, - CultureInfo.InvariantCulture); - } - catch (TargetInvocationException exception) when (exception.InnerException is not null) - { - ExceptionDispatchInfo.Capture(exception.InnerException).Throw(); - throw; - } - } + private object? Invoke(object target, string method, params object?[] arguments) => + _invoker.Invoke(target, method, arguments); private void ReleaseComObject(object? value) { diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Interop/K3dComConstants.cs b/src/MBN_STOCK_WEBVIEW.Playout/Interop/K3dComConstants.cs index 7f9344e..677ec4c 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Interop/K3dComConstants.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Interop/K3dComConstants.cs @@ -15,5 +15,6 @@ public static class K3dComConstants public const string ApartmentThreadingModel = "Apartment"; public const int LegacyLayoutIndex = 10; + public const int SceneEffectInEnabled = 1; public const int SceneChangeEffectFade = 7; } diff --git a/src/MBN_STOCK_WEBVIEW.Playout/Properties/AssemblyInfo.cs b/src/MBN_STOCK_WEBVIEW.Playout/Properties/AssemblyInfo.cs index 89e7956..a324ba2 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/Properties/AssemblyInfo.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/Properties/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.Playout.Tests")] +[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.PlayoutSmoke")] diff --git a/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs b/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs index f3d7888..ef8d6ae 100644 --- a/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs +++ b/src/MBN_STOCK_WEBVIEW.Playout/TornadoPlayoutEngine.cs @@ -498,11 +498,11 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine IK3dSession? pendingSession = null; try { + var session = _sessionFactory.Create(); + Volatile.Write(ref pendingSession, session); _session = await _dispatcher.InvokeAsync( () => { - var session = _sessionFactory.Create(); - Volatile.Write(ref pendingSession, session); try { session.Connect(_options); @@ -517,7 +517,7 @@ internal sealed class TornadoPlayoutEngine : IPlayoutEngine _options.ConnectTimeout, cancellationToken, static lateSession => lateSession.Dispose(), - () => Volatile.Read(ref pendingSession)?.Dispose()).ConfigureAwait(false); + () => Volatile.Read(ref pendingSession)?.TryPreventKtapConnect()).ConfigureAwait(false); CaptureKtapEvidence(_session); diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs index 55de503..ed0145f 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/DynamicK3dSessionTests.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Runtime.InteropServices; using MBN_STOCK_WEBVIEW.Playout.Configuration; using MBN_STOCK_WEBVIEW.Playout.Interop; using MBN_STOCK_WEBVIEW.Playout.Runtime; @@ -233,15 +234,16 @@ public sealed class DynamicK3dSessionTests "KTAPConnect:1:127.0.0.1:30001:0", "GetScenePlayerOnChannel:9", "LoadScene:test-scene", - "SetSceneEffectType:10:7:12", + "SetOutputChannelIndex:9", + "SetSceneEffectType:1:7:12", "BeginTransaction", "GetObject:headline", "SetValue:headline:safe value", "SetVisible:headline:1", "GetObject:badge", "SetVisible:badge:0", + "EndTransactionOnChannel:9", "QueryVariables", - "EndTransaction", "Prepare:10", "Play:10", expectedTakeOutCall, @@ -252,6 +254,285 @@ public sealed class DynamicK3dSessionTests Assert.All(log.ApartmentStates, state => Assert.Equal(ApartmentState.STA, state)); } + [Fact] + public async Task Prepare_WithoutOutputChannel_UsesDefaultPlayerAndDoesNotSetSceneChannel() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + var unchanneledOptions = TestOptions(scenes.Path); + unchanneledOptions.Mode = PlayoutMode.Live; + unchanneledOptions.OutputChannel = null; + var options = ValidatedPlayoutOptions.Create(unchanneledOptions); + var cue = options.ResolveCue(new PlayoutCue( + "test-scene.t2s", + "test-scene", + [], + FadeDuration: 12)); + 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); + await using var dispatcher = new StaDispatcher(capacity: 2); + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession(activator); + session.Connect(options); + session.Prepare(cue, options.LayoutIndex); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.Equal( + new[] + { + $"Create:{K3dComConstants.KaEventHandlerClassGuid:B}", + $"Create:{K3dComConstants.KaEngineClassGuid:B}", + "KTAPConnect:1:127.0.0.1:30001:0", + "GetScenePlayer", + "LoadScene:test-scene", + "SetSceneEffectType:1:7:12", + "BeginTransaction", + "EndTransaction", + "QueryVariables", + "Prepare:10", + "Disconnect" + }, + log.Names); + Assert.DoesNotContain(log.Names, name => name.StartsWith("SetOutputChannelIndex:", StringComparison.Ordinal)); + } + + [Fact] + public async Task SceneReplacement_5001To5006_RetainsAllScenesUntilDisconnect() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); + var options = SceneOptions(scenes.Path, "5001", "5006"); + var cue5001 = SceneCue(options, "5001"); + var cue5006 = SceneCue(options, "5006"); + var log = new FakeComLog(); + var player = new FakePlayer(log); + var scene5001 = new FakeScene(log, "5001"); + var scene5006 = new FakeScene(log, "5006"); + var engine = new FakeEngine(log, player, scene5001) + { + SceneResolver = name => name == "5006" ? scene5006 : scene5001 + }; + var activator = new FakeActivator(log, engine); + var releaser = new FakeReleaser( + log, + (scene5001, "5001"), + (scene5006, "5006"), + (player, "Player"), + (engine, "Engine"), + (activator.EventHandler, "EventHandler")); + await using var dispatcher = new StaDispatcher(capacity: 4); + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession(activator, releaser); + session.Connect(options); + session.Prepare(cue5001, options.LayoutIndex); + session.Play(options.LayoutIndex); + session.Prepare(cue5006, options.LayoutIndex); + Assert.DoesNotContain("Unload:5001", log.Names); + session.Play(options.LayoutIndex); + session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All); + Assert.DoesNotContain(log.Names, name => + name.StartsWith("Release:500", StringComparison.Ordinal)); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.Equal( + new[] + { + "LoadScene:5001", + "Prepare:10", + "Play:10", + "LoadScene:5006", + "Prepare:10", + "Play:10", + "StopAll", + "Disconnect", + "Release:5001", + "Release:5006" + }, + SceneLifecycleCalls(log)); + } + + [Fact] + public async Task Prepare_ReplacingUnplayedScene_RetainsBothScenesUntilDisconnect() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); + var options = SceneOptions(scenes.Path, "5001", "5006"); + var log = new FakeComLog(); + var player = new FakePlayer(log); + var scene5001 = new FakeScene(log, "5001"); + var scene5006 = new FakeScene(log, "5006"); + var engine = new FakeEngine(log, player, scene5001) + { + SceneResolver = name => name == "5006" ? scene5006 : scene5001 + }; + var activator = new FakeActivator(log, engine); + var releaser = new FakeReleaser( + log, + (scene5001, "5001"), + (scene5006, "5006"), + (player, "Player"), + (engine, "Engine"), + (activator.EventHandler, "EventHandler")); + await using var dispatcher = new StaDispatcher(capacity: 2); + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession(activator, releaser); + session.Connect(options); + session.Prepare(SceneCue(options, "5001"), options.LayoutIndex); + session.Prepare(SceneCue(options, "5006"), options.LayoutIndex); + Assert.DoesNotContain(log.Names, name => + name.StartsWith("Release:500", StringComparison.Ordinal)); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.Equal( + new[] + { + "LoadScene:5001", + "Prepare:10", + "LoadScene:5006", + "Prepare:10", + "Disconnect", + "Release:5006", + "Release:5001" + }, + SceneLifecycleCalls(log)); + Assert.DoesNotContain("Unload:5006", log.Names); + } + + [Fact] + public async Task Prepare_WhenReplacementFails_RetainsFailedAndOnAirScenesUntilDisconnect() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); + var options = SceneOptions(scenes.Path, "5001", "5006"); + var originalFailure = new COMException("fake replacement failure"); + var log = new FakeComLog(); + var player = new FakePlayer(log); + var scene5001 = new FakeScene(log, "5001"); + var scene5006 = new FakeScene( + log, + "5006", + queryVariablesFailure: originalFailure); + var engine = new FakeEngine(log, player, scene5001) + { + SceneResolver = name => name == "5006" ? scene5006 : scene5001 + }; + var activator = new FakeActivator(log, engine); + var releaser = new FakeReleaser( + log, + (scene5001, "5001"), + (scene5006, "5006"), + (player, "Player"), + (engine, "Engine"), + (activator.EventHandler, "EventHandler")); + await using var dispatcher = new StaDispatcher(capacity: 3); + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession(activator, releaser); + session.Connect(options); + session.Prepare(SceneCue(options, "5001"), options.LayoutIndex); + session.Play(options.LayoutIndex); + + var exception = Assert.Throws( + () => session.Prepare(SceneCue(options, "5006"), options.LayoutIndex)); + Assert.Same(originalFailure, exception); + Assert.DoesNotContain("Unload:5001", log.Names); + Assert.DoesNotContain(log.Names, name => + name.StartsWith("Release:500", StringComparison.Ordinal)); + + session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All); + Assert.DoesNotContain(log.Names, name => + name.StartsWith("Release:500", StringComparison.Ordinal)); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.Equal( + new[] + { + "LoadScene:5001", + "Prepare:10", + "Play:10", + "LoadScene:5006", + "StopAll", + "Disconnect", + "Release:5006", + "Release:5001" + }, + SceneLifecycleCalls(log)); + } + + [Fact] + public async Task AsyncOutputSequence_DoesNotUnloadAndReleasesScenesOnlyAfterDisconnect() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s", "5006.t2s"); + var options = SceneOptions(scenes.Path, "5001", "5006"); + var log = new FakeComLog(); + var player = new FakePlayer(log); + var scene5001 = new FakeScene(log, "5001"); + var scene5006 = new FakeScene(log, "5006"); + var engine = new FakeEngine(log, player, scene5001) + { + SceneResolver = name => name == "5006" ? scene5006 : scene5001 + }; + var activator = new FakeActivator(log, engine); + var releaser = new FakeReleaser( + log, + (scene5001, "5001"), + (scene5006, "5006"), + (player, "Player"), + (engine, "Engine"), + (activator.EventHandler, "EventHandler")); + await using var dispatcher = new StaDispatcher(capacity: 4); + + await dispatcher.InvokeAsync( + () => + { + using var session = new DynamicK3dSession(activator, releaser); + session.Connect(options); + session.Prepare(SceneCue(options, "5001"), options.LayoutIndex); + session.Play(options.LayoutIndex); + session.Prepare(SceneCue(options, "5006"), options.LayoutIndex); + session.Play(options.LayoutIndex); + session.TakeOut(options.LayoutIndex, PlayoutTakeOutScope.All); + Assert.DoesNotContain(log.Names, name => + name.StartsWith("Release:500", StringComparison.Ordinal)); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(5), + CancellationToken.None); + + Assert.Equal(2, log.Names.Count(name => name == "Play:10")); + Assert.Equal(1, log.Names.Count(name => name == "StopAll")); + Assert.DoesNotContain(log.Names, name => name.StartsWith("Unload", StringComparison.Ordinal)); + Assert.Equal(1, log.Names.Count(name => name == "Release:5001")); + Assert.Equal(1, log.Names.Count(name => name == "Release:5006")); + } + [Fact] public void LateBoundActivatorContract_AcceptsOnlyAClsid() { @@ -345,17 +626,20 @@ public sealed class DynamicK3dSessionTests } [Fact] - public async Task Abandon_ReleasesEveryComReferenceWithoutDisconnect() + public async Task Abandon_AfterUnknownPlayOutcome_ReleasesReferencesWithoutSdkCleanup() { using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); var options = ValidatedPlayoutOptions.Create(TestOptions(scenes.Path)); + var cue = SceneCue(options, "test-scene"); + var playFailure = new COMException("fake ambiguous play failure"); var log = new FakeComLog(); - var player = new FakePlayer(log); + var player = new FakePlayer(log, playFailure); var scene = new FakeScene(log); var engine = new FakeEngine(log, player, scene); var activator = new FakeActivator(log, engine); var releaser = new FakeReleaser( log, + (scene, "Scene"), (player, "Player"), (engine, "Engine"), (activator.EventHandler, "EventHandler")); @@ -366,6 +650,10 @@ public sealed class DynamicK3dSessionTests { using var session = new DynamicK3dSession(activator, releaser); session.Connect(options); + session.Prepare(cue, options.LayoutIndex); + var exception = Assert.Throws( + () => session.Play(options.LayoutIndex)); + Assert.Same(playFailure, exception); session.Abandon(); return true; }, @@ -373,6 +661,9 @@ public sealed class DynamicK3dSessionTests CancellationToken.None); Assert.DoesNotContain("Disconnect", log.Names); + Assert.DoesNotContain(log.Names, name => name.StartsWith("Unload", StringComparison.Ordinal)); + Assert.Equal(1, log.Names.Count(name => name == "Play:10")); + Assert.Equal(1, log.Names.Count(name => name == "Release:Scene")); 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")); @@ -387,6 +678,34 @@ public sealed class DynamicK3dSessionTests TestSceneAllowlist = ["test-scene"] }; + private static ValidatedPlayoutOptions SceneOptions( + string sceneDirectory, + params string[] sceneNames) + { + var options = TestOptions(sceneDirectory); + options.TestSceneAllowlist = [.. sceneNames]; + return ValidatedPlayoutOptions.Create(options); + } + + private static PlayoutCue SceneCue(ValidatedPlayoutOptions options, string sceneName) => + options.ResolveCue(new PlayoutCue( + $"{sceneName}.t2s", + sceneName, + [], + FadeDuration: 0)); + + private static string[] SceneLifecycleCalls(FakeComLog log) => + log.Names.Where(name => + name.StartsWith("LoadScene:", StringComparison.Ordinal) || + name == "Prepare:10" || + name == "Play:10" || + name == "StopAll" || + name == "CutOut:10" || + name == "Disconnect" || + name == "RollbackTransaction" || + name.StartsWith("Unload:", StringComparison.Ordinal) || + name.StartsWith("Release:500", StringComparison.Ordinal)).ToArray(); + public sealed class FakeComLog { private readonly ConcurrentQueue _names = new(); @@ -516,6 +835,8 @@ public sealed class DynamicK3dSessionTests _connectFailure = connectFailure; } + public Func? SceneResolver { get; init; } + public int KTAPConnect( int tcpMode, string host, @@ -544,17 +865,33 @@ public sealed class DynamicK3dSessionTests return _player; } + public object GetScenePlayer() + { + _log.Add("GetScenePlayer"); + if (_playerFailure is not null) + { + throw _playerFailure; + } + + return _player; + } + public object LoadScene(string sceneFile, string sceneName) { Assert.True(System.IO.Path.IsPathFullyQualified(sceneFile)); _log.Add($"LoadScene:{sceneName}"); - return _scene; + return SceneResolver?.Invoke(sceneName) ?? _scene; } public void BeginTransaction() => _log.Add("BeginTransaction"); public void EndTransaction() => _log.Add("EndTransaction"); + public void EndTransactionOnChannel(int channel) => + _log.Add($"EndTransactionOnChannel:{channel}"); + + public void RollbackTransaction() => _log.Add("RollbackTransaction"); + public void Disconnect() { _log.Add("Disconnect"); @@ -588,10 +925,12 @@ public sealed class DynamicK3dSessionTests public sealed class FakePlayer { private readonly FakeComLog _log; + private readonly Exception? _playFailure; - public FakePlayer(FakeComLog log) + public FakePlayer(FakeComLog log, Exception? playFailure = null) { _log = log; + _playFailure = playFailure; } public void Prepare(int layoutIndex, object scene) @@ -600,7 +939,14 @@ public sealed class DynamicK3dSessionTests _log.Add($"Prepare:{layoutIndex}"); } - public void Play(int layoutIndex) => _log.Add($"Play:{layoutIndex}"); + public void Play(int layoutIndex) + { + _log.Add($"Play:{layoutIndex}"); + if (_playFailure is not null) + { + throw _playFailure; + } + } public void StopAll() => _log.Add("StopAll"); @@ -610,14 +956,27 @@ public sealed class DynamicK3dSessionTests public sealed class FakeScene { private readonly FakeComLog _log; + private readonly string? _name; + private readonly Exception? _queryVariablesFailure; + private readonly Exception? _unloadFailure; - public FakeScene(FakeComLog log) + public FakeScene( + FakeComLog log, + string? name = null, + Exception? queryVariablesFailure = null, + Exception? unloadFailure = null) { _log = log; + _name = name; + _queryVariablesFailure = queryVariablesFailure; + _unloadFailure = unloadFailure; } - public void SetSceneEffectType(int layoutIndex, int effectType, int duration) => - _log.Add($"SetSceneEffectType:{layoutIndex}:{effectType}:{duration}"); + public void SetOutputChannelIndex(int channel) => + _log.Add($"SetOutputChannelIndex:{channel}"); + + public void SetSceneEffectType(int inEffect, int effectType, int duration) => + _log.Add($"SetSceneEffectType:{inEffect}:{effectType}:{duration}"); public object GetObject(string objectName) { @@ -625,7 +984,23 @@ public sealed class DynamicK3dSessionTests return new FakeSceneObject(_log, objectName); } - public void QueryVariables() => _log.Add("QueryVariables"); + public void QueryVariables() + { + _log.Add("QueryVariables"); + if (_queryVariablesFailure is not null) + { + throw _queryVariablesFailure; + } + } + + public void Unload() + { + _log.Add(_name is null ? "Unload" : $"Unload:{_name}"); + if (_unloadFailure is not null) + { + throw _unloadFailure; + } + } } public sealed class FakeSceneObject diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/InstalledK3dInteropMetadataTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/InstalledK3dInteropMetadataTests.cs new file mode 100644 index 0000000..7e1760c --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/InstalledK3dInteropMetadataTests.cs @@ -0,0 +1,143 @@ +using MBN_STOCK_WEBVIEW.Playout.Diagnostics; +using MBN_STOCK_WEBVIEW.Playout.Interop; +using MBN_STOCK_WEBVIEW.Playout.Registration; +using System.Security.Cryptography; + +namespace MBN_STOCK_WEBVIEW.Playout.Tests; + +public sealed class InstalledK3dInteropMetadataTests +{ + [Fact] + public void ApprovedSha256_RequiresAndDecodesExactly64HexCharacters() + { + var parsed = InstalledK3dInteropMetadata.ParseApprovedSha256( + "0123456789abcdef0123456789ABCDEF0123456789abcdef0123456789ABCDEF"); + + Assert.Equal(32, parsed.Length); + Assert.Equal(0x01, parsed[0]); + Assert.Equal(0xEF, parsed[^1]); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde")] + [InlineData(" 123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")] + [InlineData("g123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")] + public void ApprovedSha256_RejectsMissingMalformedOrWhitespaceValues(string? value) + { + Assert.Throws( + () => InstalledK3dInteropMetadata.ParseApprovedSha256(value)); + } + + [Fact] + public void ApprovedSha256_RequiresExactContentForEitherBinaryPin() + { + byte[] content = [1, 2, 3, 4]; + var approved = Convert.ToHexString(SHA256.HashData(content)); + using var matching = new MemoryStream(content); + using var different = new MemoryStream([1, 2, 3, 5]); + + InstalledK3dInteropMetadata.ValidateApprovedSha256(matching, approved); + Assert.Throws( + () => InstalledK3dInteropMetadata.ValidateApprovedSha256(different, approved)); + Assert.Equal( + "MBN_STOCK_K3D_NATIVE_SHA256", + InstalledK3dInteropMetadata.ApprovedNativeSha256EnvironmentVariable); + Assert.Equal( + "MBN_STOCK_K3D_INTEROP_SHA256", + InstalledK3dInteropMetadata.ApprovedSha256EnvironmentVariable); + } + + [Fact] + public void FileLease_KeepsNativeAndInteropFilesWriteLocked() + { + var directory = Path.Combine(Path.GetTempPath(), $"k3d-lease-{Guid.NewGuid():N}"); + Directory.CreateDirectory(directory); + var nativePath = Path.Combine(directory, "native.dll"); + var interopPath = Path.Combine(directory, "interop.dll"); + File.WriteAllBytes(nativePath, [1]); + File.WriteAllBytes(interopPath, [2]); + + InstalledK3dInteropFileLease? lease = null; + try + { + lease = new InstalledK3dInteropFileLease( + new FileStream(nativePath, FileMode.Open, FileAccess.Read, FileShare.Read), + new FileStream(interopPath, FileMode.Open, FileAccess.Read, FileShare.Read)); + + Assert.Throws(() => OpenForWrite(nativePath).Dispose()); + Assert.Throws(() => OpenForWrite(interopPath).Dispose()); + } + finally + { + lease?.InteropStream.Dispose(); + lease?.NativeStream.Dispose(); + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void RegisteredPaths_MapOnlyExactX64VendorLayout() + { + const string native = @"C:\Vendor\K3DAsyncEngine\DLL\x64\Release\K3DAsyncEngine.dll"; + var paths = InstalledK3dInteropMetadata.ResolveRegisteredPaths(ReadySnapshot(native)); + + Assert.Equal(Path.GetFullPath(native), paths.NativeBinaryPath); + Assert.Equal( + Path.GetFullPath( + @"C:\Vendor\K3DAsyncEngine\Bin\x64\C#\Interop.K3DAsyncEngineLib.dll"), + paths.InteropAssemblyPath); + } + + [Fact] + public void RegisteredPaths_RejectDivergentClassBinary() + { + const string native = @"C:\Vendor\K3DAsyncEngine\DLL\x64\Release\K3DAsyncEngine.dll"; + var snapshot = ReadySnapshot(native) with + { + EventHandler = ReadySnapshot(native).EventHandler with + { + InprocServerPath = + @"C:\Other\K3DAsyncEngine\DLL\x64\Release\K3DAsyncEngine.dll" + } + }; + + Assert.Throws( + () => InstalledK3dInteropMetadata.ResolveRegisteredPaths(snapshot)); + } + + [Fact] + public void RegisteredPaths_RejectUnexpectedNativeLayout() + { + const string native = @"C:\Vendor\K3DAsyncEngine\bin\K3DAsyncEngine.dll"; + + Assert.Throws( + () => InstalledK3dInteropMetadata.ResolveRegisteredPaths(ReadySnapshot(native))); + } + + private static K3dRegistrySnapshot ReadySnapshot(string nativePath) => new( + true, + nativePath, + new K3dClassRegistrationSnapshot( + true, + K3dComConstants.KaEngineProgId, + K3dComConstants.KaEngineClassId, + nativePath, + K3dComConstants.ApartmentThreadingModel, + K3dComConstants.TypeLibraryId), + new K3dClassRegistrationSnapshot( + true, + K3dComConstants.KaEventHandlerProgId, + K3dComConstants.KaEventHandlerClassId, + nativePath, + K3dComConstants.ApartmentThreadingModel, + K3dComConstants.TypeLibraryId), + false); + + private static FileStream OpenForWrite(string path) => new( + path, + FileMode.Open, + FileAccess.Write, + FileShare.ReadWrite | FileShare.Delete); +} diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/IsolatedTestCommandTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/IsolatedTestCommandTests.cs index c379ae8..6fb9234 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/IsolatedTestCommandTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/IsolatedTestCommandTests.cs @@ -142,7 +142,13 @@ public sealed class IsolatedTestCommandTests : IDisposable "Disconnect", "Dispose" ], engine.Calls); - Assert.Equal([TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1)], delays); + Assert.Equal( + [ + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(1) + ], + delays); Assert.True(report.Completed); Assert.True(report.ComActivationAttempted); Assert.Equal("accepted-unconfirmed", report.LastKtapConnectState); diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PgmConnectDiagnosticTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PgmConnectDiagnosticTests.cs new file mode 100644 index 0000000..86e635e --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PgmConnectDiagnosticTests.cs @@ -0,0 +1,490 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Text.Json; +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Playout.Diagnostics; +using MBN_STOCK_WEBVIEW.Playout.Interop; + +namespace MBN_STOCK_WEBVIEW.Playout.Tests; + +public sealed class PgmConnectDiagnosticTests +{ + [Fact] + public void RealContactDiagnosticApi_IsNotPublic() + { + Assert.False(typeof(PgmConnectDiagnosticRunner).IsVisible); + Assert.False(typeof(PgmConnectDiagnosticRequest).IsVisible); + Assert.False(typeof(PgmConnectDiagnosticReport).IsVisible); + Assert.Null(typeof(PgmConnectDiagnosticRunner).GetMethod( + nameof(PgmConnectDiagnosticRunner.ExecuteAsync), + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.Public)); + } + + [Theory] + [InlineData("localhost", 30001, "PGM")] + [InlineData("192.0.2.1", 30001, "PGM")] + [InlineData("127.0.0.1", 0, "PGM")] + [InlineData("127.0.0.1", 30001, "TEST")] + [InlineData("127.0.0.1", 30001, "CONPROGRAM")] + public void RequestValidation_RejectsUnsafeEndpointOrWindow( + string host, + int port, + string title) + { + var valid = PgmConnectDiagnosticRunner.TryValidateRequest( + new PgmConnectDiagnosticRequest(host, port, title), + out _); + + Assert.False(valid); + } + + [Fact] + public async Task Runner_SuccessUsesOnlyConnectThenDisconnectAndReportsUnconfirmedEvidence() + { + var snapshot = ReadySnapshot(10); + var safety = new FakeSafetyProbe(snapshot, snapshot, snapshot, snapshot); + var session = new FakeConnectOnlySession(); + var factory = new FakeConnectOnlySessionFactory(session); + var runner = new PgmConnectDiagnosticRunner( + safety, + factory, + new FakeRegistrationProbe(), + new TrackingStaDispatcherFactory()); + var request = new PgmConnectDiagnosticRequest("127.0.0.1", 30001, "PGM"); + + var report = await runner.ExecuteAsync(request); + + Assert.Equal(["Connect", "Disconnect"], session.Calls); + Assert.True(report.Completed); + Assert.False(report.OutcomeUnknown); + Assert.True(report.ConnectRequestIssued); + Assert.True(report.ComActivationAttempted); + Assert.Equal(PlayoutKtapConnectState.AcceptedUnconfirmed, report.LastKtapConnectState); + Assert.True(report.KtapConnectAttempted); + Assert.True(report.KtapConnectAccepted); + Assert.Null(report.KtapHelloObserved); + Assert.True(report.NetworkMonitoringRecordExpected); + Assert.True(report.NetworkMonitoringCheckRequired); + Assert.Null(report.NetworkMonitoringVerified); + Assert.Equal("success", report.DisconnectCode); + Assert.False(report.RenderCommandSurfaceExposed); + Assert.False(report.RenderCommandAttempted); + Assert.False(report.AutomaticReconnectEnabled); + + var json = JsonSerializer.Serialize(report); + Assert.DoesNotContain("127.0.0.1", json, StringComparison.Ordinal); + Assert.DoesNotContain("30001", json, StringComparison.Ordinal); + Assert.DoesNotContain("PGM", json, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Runner_TargetRejectedStopsBeforeRegistrationAndCom() + { + var rejected = new PgmDiagnosticTargetSnapshot(false, 0, 0, false, null); + var factory = new FakeConnectOnlySessionFactory(new FakeConnectOnlySession()); + var registration = new FakeRegistrationProbe(); + var runner = new PgmConnectDiagnosticRunner( + new FakeSafetyProbe(rejected), + factory, + registration, + new TrackingStaDispatcherFactory()); + + var report = await runner.ExecuteAsync( + new PgmConnectDiagnosticRequest("127.0.0.1", 30001, "PGM")); + + Assert.False(report.Completed); + Assert.False(report.ConnectRequestIssued); + Assert.False(report.ComActivationAttempted); + Assert.Equal(0, factory.CreateCount); + Assert.Equal(0, registration.ProbeCount); + Assert.Equal("pgm-target-rejected", report.ErrorCode); + } + + [Fact] + public async Task Runner_UnapprovedInstalledInteropIsDistinctFromComActivationFailure() + { + var snapshot = ReadySnapshot(10); + var runner = new PgmConnectDiagnosticRunner( + new FakeSafetyProbe(snapshot), + new UnavailableInteropSessionFactory(), + new FakeRegistrationProbe(), + new TrackingStaDispatcherFactory()); + + var report = await runner.ExecuteAsync( + new PgmConnectDiagnosticRequest("127.0.0.1", 30001, "PGM")); + + Assert.False(report.Completed); + Assert.False(report.ComActivationAttempted); + Assert.False(report.KtapConnectAttempted); + Assert.False(report.OutcomeUnknown); + Assert.Equal("not-attempted", report.DisconnectCode); + Assert.Equal("installed-interop-metadata-unavailable", report.ErrorCode); + } + + [Fact] + public async Task Runner_TargetChangeAfterAcceptanceAbandonsWithoutDisconnect() + { + var baseline = ReadySnapshot(10); + var changed = ReadySnapshot(11); + var session = new FakeConnectOnlySession(); + var runner = new PgmConnectDiagnosticRunner( + new FakeSafetyProbe(baseline, baseline, baseline, changed), + new FakeConnectOnlySessionFactory(session), + new FakeRegistrationProbe(), + new TrackingStaDispatcherFactory()); + + var report = await runner.ExecuteAsync( + new PgmConnectDiagnosticRequest("127.0.0.1", 30001, "PGM")); + + Assert.Equal(["Connect", "Abandon"], session.Calls); + Assert.False(report.Completed); + Assert.True(report.OutcomeUnknown); + Assert.True(report.KtapConnectAttempted); + Assert.DoesNotContain("Disconnect", session.Calls); + Assert.Equal("pgm-target-changed-after-connect", report.ErrorCode); + } + + [Fact] + public async Task DynamicSession_InvokesOnlyKtapConnectAndDisconnect() + { + var log = new ConcurrentQueue(); + var session = new DynamicKtapConnectOnlySession(new RecordingComBinding(log)); + await using var dispatcher = new Runtime.StaDispatcher(1); + + await dispatcher.InvokeAsync( + () => + { + session.Connect("127.0.0.1", 30001, static () => true); + session.Disconnect(); + return true; + }, + TimeSpan.FromSeconds(2), + CancellationToken.None); + + Assert.Equal( + [ + "CreateEventHandler", + "CreateEngine", + "KTAPConnect", + "Disconnect", + "Release", + "Release" + ], + log); + Assert.Equal(PlayoutKtapConnectState.AcceptedUnconfirmed, session.LastKtapConnectState); + } + + [Fact] + public async Task DynamicSession_FinalSafetyCheckRunsAfterActivationAndBeforeKtap() + { + var log = new ConcurrentQueue(); + var session = new DynamicKtapConnectOnlySession(new RecordingComBinding(log)); + await using var dispatcher = new Runtime.StaDispatcher(1); + + await Assert.ThrowsAsync(() => + dispatcher.InvokeAsync( + () => + { + session.Connect( + "127.0.0.1", + 30001, + () => + { + log.Enqueue("FinalSafetyCheck"); + return false; + }); + return true; + }, + TimeSpan.FromSeconds(2), + CancellationToken.None)); + + Assert.Equal( + [ + "CreateEventHandler", + "CreateEngine", + "FinalSafetyCheck", + "Release", + "Release" + ], + log); + Assert.DoesNotContain("KTAPConnect", log); + Assert.Equal(PlayoutKtapConnectState.NotAttempted, session.LastKtapConnectState); + } + + [Fact] + public async Task Runner_ConnectTimeoutPreventsLateKtapDispatch() + { + var snapshot = ReadySnapshot(10); + using var safety = new BlockingSecondCaptureSafetyProbe(snapshot); + var session = new FakeConnectOnlySession(); + var dispatcherFactory = new TrackingStaDispatcherFactory(); + var runner = new PgmConnectDiagnosticRunner( + safety, + new FakeConnectOnlySessionFactory(session), + new FakeRegistrationProbe(), + dispatcherFactory, + TimeSpan.FromMilliseconds(50), + TimeSpan.FromSeconds(1)); + + var execution = runner.ExecuteAsync( + new PgmConnectDiagnosticRequest("127.0.0.1", 30001, "PGM")); + + try + { + Assert.True(safety.SecondCaptureEntered.Wait(TimeSpan.FromSeconds(2))); + Assert.True(session.PreventionAttempted.Wait(TimeSpan.FromSeconds(2))); + } + finally + { + safety.ReleaseSecondCapture.Set(); + } + + var report = await execution.WaitAsync(TimeSpan.FromSeconds(3)); + Assert.True(session.ConnectCompleted.Wait(TimeSpan.FromSeconds(2))); + + Assert.Equal("connect-timeout", report.ErrorCode); + Assert.False(report.Completed); + Assert.False(report.OutcomeUnknown); + Assert.False(report.KtapConnectAttempted); + Assert.Equal(PlayoutKtapConnectState.NotAttempted, report.LastKtapConnectState); + Assert.False(session.ComActivationAttempted); + Assert.Equal(0, session.DispatchCount); + Assert.Contains("Connect", session.Calls); + } + + [Fact] + public void ConnectOnlySessionSurfaceHasNoRenderCommand() + { + var methodNames = typeof(IKtapConnectOnlySession) + .GetMethods() + .Select(method => method.Name) + .ToArray(); + string[] forbidden = + [ + "GetScenePlayer", + "LoadScene", + "Prepare", + "Play", + "PlayDirect", + "TakeIn", + "Next", + "TakeOut", + "CutOut", + "Stop", + "StopAll" + ]; + + Assert.DoesNotContain(methodNames, name => + forbidden.Contains(name, StringComparer.OrdinalIgnoreCase)); + Assert.Contains("Connect", methodNames); + Assert.Contains("Disconnect", methodNames); + } + + [Fact] + public void ListenerPortDecoderUsesNetworkByteOrder() + { + const ushort port = 30001; + var encoded = unchecked((uint)(ushort)IPAddress.HostToNetworkOrder((short)port)); + + Assert.Equal(port, WindowsTcpListenerOwnershipProbe.DecodePort(encoded)); + } + + private static PgmDiagnosticTargetSnapshot ReadySnapshot(int processId) => new( + true, + 1, + 1, + true, + new PgmDiagnosticTargetIdentity(processId, 100)); + + private sealed class FakeSafetyProbe : IPgmDiagnosticSafetyProbe + { + private readonly Queue _snapshots; + private PgmDiagnosticTargetSnapshot _last; + + public FakeSafetyProbe(params PgmDiagnosticTargetSnapshot[] snapshots) + { + _snapshots = new Queue(snapshots); + _last = snapshots.Last(); + } + + public PgmDiagnosticTargetSnapshot Capture( + PgmConnectDiagnosticRequest request, + IPAddress parsedHost) + { + if (_snapshots.Count > 0) + { + _last = _snapshots.Dequeue(); + } + + return _last; + } + } + + private sealed class BlockingSecondCaptureSafetyProbe : IPgmDiagnosticSafetyProbe, IDisposable + { + private readonly PgmDiagnosticTargetSnapshot _snapshot; + private int _captureCount; + + public BlockingSecondCaptureSafetyProbe(PgmDiagnosticTargetSnapshot snapshot) + { + _snapshot = snapshot; + } + + public ManualResetEventSlim SecondCaptureEntered { get; } = new(false); + + public ManualResetEventSlim ReleaseSecondCapture { get; } = new(false); + + public PgmDiagnosticTargetSnapshot Capture( + PgmConnectDiagnosticRequest request, + IPAddress parsedHost) + { + if (Interlocked.Increment(ref _captureCount) == 2) + { + SecondCaptureEntered.Set(); + ReleaseSecondCapture.Wait(); + } + + return _snapshot; + } + + public void Dispose() + { + ReleaseSecondCapture.Set(); + SecondCaptureEntered.Dispose(); + ReleaseSecondCapture.Dispose(); + } + } + + private sealed class FakeConnectOnlySessionFactory : IKtapConnectOnlySessionFactory + { + private readonly IKtapConnectOnlySession _session; + + public FakeConnectOnlySessionFactory(IKtapConnectOnlySession session) + { + _session = session; + } + + public int CreateCount { get; private set; } + + public IKtapConnectOnlySession Create() + { + CreateCount++; + return _session; + } + } + + private sealed class UnavailableInteropSessionFactory : IKtapConnectOnlySessionFactory + { + public IKtapConnectOnlySession Create() => + throw new InstalledK3dInteropMetadataUnavailableException(); + } + + private sealed class FakeConnectOnlySession : IKtapConnectOnlySession + { + private int _dispatchGate; + private int _dispatchCount; + private int _abandonRecorded; + + public ConcurrentQueue Calls { get; } = new(); + + public ManualResetEventSlim PreventionAttempted { get; } = new(false); + + public ManualResetEventSlim ConnectCompleted { get; } = new(false); + + public int DispatchCount => Volatile.Read(ref _dispatchCount); + + public bool ComActivationAttempted { get; private set; } + + public PlayoutKtapConnectState LastKtapConnectState { get; private set; } + + public bool TryPreventKtapConnect() + { + PreventionAttempted.Set(); + var previous = Interlocked.CompareExchange(ref _dispatchGate, 2, 0); + if (previous == 1 && LastKtapConnectState == PlayoutKtapConnectState.NotAttempted) + { + LastKtapConnectState = PlayoutKtapConnectState.Attempted; + } + + return previous == 0; + } + + public void Connect(string host, int port, Func finalSafetyCheck) + { + Calls.Enqueue("Connect"); + try + { + if (Volatile.Read(ref _dispatchGate) != 0) + { + throw new InvalidOperationException(); + } + + ComActivationAttempted = true; + if (!finalSafetyCheck()) + { + throw new PgmDiagnosticSafetyException(); + } + + if (Interlocked.CompareExchange(ref _dispatchGate, 1, 0) != 0) + { + throw new InvalidOperationException(); + } + + Interlocked.Increment(ref _dispatchCount); + LastKtapConnectState = PlayoutKtapConnectState.AcceptedUnconfirmed; + } + finally + { + ConnectCompleted.Set(); + } + } + + public void Disconnect() => Calls.Enqueue("Disconnect"); + + public void Abandon() + { + TryPreventKtapConnect(); + if (Interlocked.Exchange(ref _abandonRecorded, 1) == 0) + { + Calls.Enqueue("Abandon"); + } + } + } + + private sealed class RecordingComBinding : IK3dConnectOnlyComBinding + { + private readonly ConcurrentQueue _log; + + public RecordingComBinding(ConcurrentQueue log) + { + _log = log; + } + + public object CreateEventHandler() + { + _log.Enqueue("CreateEventHandler"); + return new object(); + } + + public object CreateEngine() + { + _log.Enqueue("CreateEngine"); + return new object(); + } + + public int KtapConnect( + object engine, + string host, + int port, + object eventHandler) + { + _log.Enqueue("KTAPConnect"); + return 1; + } + + public void Disconnect(object engine) => _log.Enqueue("Disconnect"); + + public void Release(object value) => _log.Enqueue("Release"); + } +} diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PgmDiagnosticSafetyProbeTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PgmDiagnosticSafetyProbeTests.cs new file mode 100644 index 0000000..5953a88 --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/PgmDiagnosticSafetyProbeTests.cs @@ -0,0 +1,150 @@ +using System.Net; +using MBN_STOCK_WEBVIEW.Playout.Diagnostics; + +namespace MBN_STOCK_WEBVIEW.Playout.Tests; + +public sealed class PgmDiagnosticSafetyProbeTests +{ + private const int Port = 30_001; + private const int TargetProcessId = 101; + private const int CompetingProcessId = 202; + + [Theory] + [InlineData("127.0.0.1", "127.0.0.1")] + [InlineData("127.0.0.1", "0.0.0.0")] + [InlineData("::1", "::1")] + [InlineData("::1", "::")] + public void TargetOwnedExactOrWildcardListener_IsAccepted( + string requestedAddress, + string listenerAddress) + { + var queried = Evaluate( + requestedAddress, + [Entry(listenerAddress, TargetProcessId)], + out var isOwned); + + Assert.True(queried); + Assert.True(isOwned); + } + + [Theory] + [InlineData("127.0.0.1", "127.0.0.1", "0.0.0.0")] + [InlineData("127.0.0.1", "0.0.0.0", "127.0.0.1")] + [InlineData("::1", "::1", "::")] + [InlineData("::1", "::", "::1")] + public void MatchingEndpointWithCompetingOwner_FailsClosedEvenAfterTargetRow( + string requestedAddress, + string targetAddress, + string competingAddress) + { + var queried = Evaluate( + requestedAddress, + [ + Entry(targetAddress, TargetProcessId), + Entry(competingAddress, CompetingProcessId) + ], + out var isOwned); + + Assert.True(queried); + Assert.False(isOwned); + } + + [Theory] + [InlineData("127.0.0.1", "127.0.0.2")] + [InlineData("::1", "::2")] + public void DifferentLocalAddressOwnedByCompetitor_DoesNotCompete( + string requestedAddress, + string unrelatedAddress) + { + var queried = Evaluate( + requestedAddress, + [ + Entry(requestedAddress, TargetProcessId), + Entry(unrelatedAddress, CompetingProcessId) + ], + out var isOwned); + + Assert.True(queried); + Assert.True(isOwned); + } + + [Theory] + [InlineData("127.0.0.1", "127.0.0.2")] + [InlineData("::1", "::2")] + public void TargetOwnedDifferentLocalAddress_IsNotAccepted( + string requestedAddress, + string differentAddress) + { + var queried = Evaluate( + requestedAddress, + [Entry(differentAddress, TargetProcessId)], + out var isOwned); + + Assert.True(queried); + Assert.False(isOwned); + } + + [Theory] + [InlineData("127.0.0.1", "::")] + [InlineData("::1", "0.0.0.0")] + public void OtherAddressFamilyWildcard_DoesNotMatchRequestedEndpoint( + string requestedAddress, + string otherFamilyWildcard) + { + var queried = Evaluate( + requestedAddress, + [Entry(otherFamilyWildcard, TargetProcessId)], + out var isOwned); + + Assert.True(queried); + Assert.False(isOwned); + } + + [Theory] + [InlineData("0.0.0.0")] + [InlineData("192.0.2.1")] + [InlineData("::")] + [InlineData("2001:db8::1")] + public void RequestedAddressMustBeLoopback(string requestedAddress) + { + var queried = Evaluate( + requestedAddress, + [Entry(requestedAddress, TargetProcessId)], + out var isOwned); + + Assert.False(queried); + Assert.False(isOwned); + } + + [Theory] + [InlineData("127.0.0.1", "127.0.0.1")] + [InlineData("::1", "::1")] + public void MatchingEndpointOwnedOnlyByCompetitor_IsRejected( + string requestedAddress, + string competingAddress) + { + var queried = Evaluate( + requestedAddress, + [Entry(competingAddress, CompetingProcessId)], + out var isOwned); + + Assert.True(queried); + Assert.False(isOwned); + } + + private static bool Evaluate( + string requestedAddress, + IReadOnlyList listeners, + out bool isOwned) => + WindowsTcpListenerOwnershipProbe.TryEvaluateOwnership( + IPAddress.Parse(requestedAddress), + Port, + TargetProcessId, + listeners, + out isOwned); + + private static TcpListenerOwnershipEntry Entry( + string address, + int processId) => + new(IPAddress.Parse(address), Port, processId); +} diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/SmokeCommandLineTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/SmokeCommandLineTests.cs index 55307c1..ddfe1dc 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/SmokeCommandLineTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/SmokeCommandLineTests.cs @@ -181,6 +181,88 @@ public sealed class SmokeCommandLineTests Assert.Equal(SmokeCommandKind.TestPlan, parsed.Invocation.Kind); } + [Fact] + public void Parse_PgmConnectDiagnosticRequiresDistinctAcknowledgementAndExactInputs() + { + string[] complete = + [ + "--pgm-connect-diagnostic", + SmokeCommandLine.PgmConnectAcknowledgementFlag, + "--host", + "127.0.0.1", + "--port", + "30001", + "--expected-pgm-window-title", + "PGM" + ]; + var missingAcknowledgement = SmokeCommandLine.Parse(complete[0..1] + .Concat(complete[2..]) + .ToArray()); + var wrongAcknowledgement = SmokeCommandLine.Parse( + complete.Select(value => value == SmokeCommandLine.PgmConnectAcknowledgementFlag + ? SmokeCommandLine.AcknowledgementFlag + : value).ToArray()); + var parsed = SmokeCommandLine.Parse(complete); + + Assert.False(missingAcknowledgement.IsValid); + Assert.Equal("missing-acknowledgement", missingAcknowledgement.ErrorCode); + Assert.False(wrongAcknowledgement.IsValid); + Assert.Equal("unknown-option", wrongAcknowledgement.ErrorCode); + Assert.True(parsed.IsValid); + Assert.Equal(SmokeCommandKind.PgmConnectDiagnostic, parsed.Invocation.Kind); + Assert.Equal("127.0.0.1", parsed.Invocation.Host); + Assert.Equal(30001, parsed.Invocation.Port); + Assert.Equal("PGM", parsed.Invocation.ExpectedPgmWindowTitle); + Assert.Null(parsed.Invocation.ConfigPath); + } + + [Theory] + [InlineData("--config")] + [InlineData("--prepare")] + [InlineData("--next")] + [InlineData("--observe-ms")] + public void Parse_PgmConnectDiagnosticRejectsEveryConfigOrRenderOption(string option) + { + var parsed = SmokeCommandLine.Parse( + [ + "--pgm-connect-diagnostic", + SmokeCommandLine.PgmConnectAcknowledgementFlag, + "--host", + "127.0.0.1", + "--port", + "30001", + "--expected-pgm-window-title", + "PGM", + option, + "5001" + ]); + + Assert.False(parsed.IsValid); + Assert.Equal("unknown-option", parsed.ErrorCode); + } + + [Theory] + [InlineData("0")] + [InlineData("65536")] + [InlineData("secret")] + public void Parse_PgmConnectDiagnosticRejectsInvalidPort(string port) + { + var parsed = SmokeCommandLine.Parse( + [ + "--pgm-connect-diagnostic", + SmokeCommandLine.PgmConnectAcknowledgementFlag, + "--host", + "127.0.0.1", + "--port", + port, + "--expected-pgm-window-title", + "PGM" + ]); + + Assert.False(parsed.IsValid); + Assert.Equal("invalid-port", parsed.ErrorCode); + } + private static SmokeCommandParseResult ValidSequenceArguments( string prepare = "5001", string observation = "1000") => diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs index 2389a4a..7a5a3ea 100644 --- a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/TornadoPlayoutEngineTests.cs @@ -239,16 +239,91 @@ public sealed class TornadoPlayoutEngineTests Assert.False(engine.Status.NetworkMonitoringRecordExpected); Assert.False(engine.Status.NetworkMonitoringCheckRequired); Assert.Equal(0, session.KtapDispatchCount); + Assert.DoesNotContain("Dispose", session.Calls); releaseActivation.Set(); Assert.True(SpinWait.SpinUntil( () => session.Calls.Contains("Dispose"), TimeSpan.FromSeconds(2))); Assert.Equal(0, session.KtapDispatchCount); + Assert.Equal(new[] { "Connect", "Dispose" }, session.Calls); + Assert.Single(session.ThreadIds.Distinct()); + Assert.All( + session.ApartmentStates, + state => Assert.Equal(ApartmentState.STA, state)); + } + finally + { + releaseActivation.Set(); + } + } + + [Fact] + public async Task TestConnect_BlockingFactoryThenConnectTimeout_PreventsLateKtapDispatch() + { + using var scenes = TemporarySceneDirectory.Create("test-scene.t2s"); + using var factoryStarted = new ManualResetEventSlim(); + using var releaseFactory = new ManualResetEventSlim(); + using var beforeKtapDispatch = new ManualResetEventSlim(); + using var releaseConnect = new ManualResetEventSlim(); + var session = new FakeK3dSession + { + BeforeKtapDispatchAction = () => + { + beforeKtapDispatch.Set(); + if (!releaseConnect.Wait(TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("fake connect coordination timeout"); + } + } + }; + var sessionFactory = new FakeK3dSessionFactory(() => + { + factoryStarted.Set(); + if (!releaseFactory.Wait(TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("fake factory coordination timeout"); + } + + return session; + }); + var options = TestOptions(scenes.Path, "test-scene"); + options.ConnectTimeoutMilliseconds = 100; + await using var engine = CreateEngine( + options, + sessionFactory, + new FakeRegistrationProbe(), + new FakeTornadoProcessProbe(new TornadoProcessSnapshot(1, 1, 0)), + liveAuthorized: false); + + try + { + var connectTask = Task.Run(() => engine.ConnectAsync(CancellationToken.None)); + Assert.True(factoryStarted.Wait(TimeSpan.FromSeconds(2))); + + await Task.Delay(250); + Assert.False(connectTask.IsCompleted); + Assert.Equal(0, session.KtapDispatchCount); + + releaseFactory.Set(); + Assert.True(beforeKtapDispatch.Wait(TimeSpan.FromSeconds(2))); + var result = await connectTask; + + Assert.Equal(PlayoutResultCode.OutcomeUnknown, result.Code); + Assert.Equal(PlayoutKtapConnectState.NotAttempted, engine.Status.LastKtapConnectState); + Assert.Equal(0, session.KtapDispatchCount); + Assert.DoesNotContain("Dispose", session.Calls); + + releaseConnect.Set(); + Assert.True(SpinWait.SpinUntil( + () => session.Calls.Contains("Dispose"), + TimeSpan.FromSeconds(2))); + Assert.Equal(0, session.KtapDispatchCount); } finally { - releaseActivation.Set(); + releaseFactory.Set(); + releaseConnect.Set(); } } diff --git a/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/IsolatedTestCommand.cs b/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/IsolatedTestCommand.cs index 92fe109..07a1a22 100644 --- a/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/IsolatedTestCommand.cs +++ b/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/IsolatedTestCommand.cs @@ -333,6 +333,15 @@ internal static class IsolatedTestCommandExecutor } outputMayBeActive = false; + // K3D output methods complete asynchronously through event callbacks. + // The current adapter does not consume those callbacks yet, so keep + // the connection alive for the same bounded observation window after + // TAKE OUT before requesting Disconnect. + if (!await ObserveAsync().ConfigureAwait(false)) + { + stoppedAfter = "TakeOutObservation"; + break; + } } disconnectAttempted = true; diff --git a/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/Program.cs b/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/Program.cs index d6f5fca..8cccc0d 100644 --- a/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/Program.cs +++ b/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/Program.cs @@ -3,6 +3,7 @@ using System.Text.Json; using MBN_STOCK_WEBVIEW.Core.Playout; using MBN_STOCK_WEBVIEW.Playout; using MBN_STOCK_WEBVIEW.Playout.Configuration; +using MBN_STOCK_WEBVIEW.Playout.Diagnostics; using MBN_STOCK_WEBVIEW.Playout.Registration; using MBN_STOCK_WEBVIEW.PlayoutSmoke; @@ -26,6 +27,8 @@ return parsed.Invocation.Kind switch SmokeCommandKind.TestPlan => TestPlan(parsed.Invocation), SmokeCommandKind.TestConnect or SmokeCommandKind.TestSequence => await RunIsolatedTestCommandAsync(parsed.Invocation, cancellation.Token), + SmokeCommandKind.PgmConnectDiagnostic => + await RunPgmConnectDiagnosticAsync(parsed.Invocation, cancellation.Token), _ => Usage("unknown-command") }; @@ -221,6 +224,104 @@ static async Task RunIsolatedTestCommandAsync( return report.OutcomeUnknown ? 5 : 4; } +static async Task RunPgmConnectDiagnosticAsync( + SmokeCommandInvocation invocation, + CancellationToken cancellationToken) +{ + if (IsolatedTestCommandPreflight.HasPlayoutEnvironmentOverride( + Environment.GetEnvironmentVariables())) + { + WritePgmDiagnosticFailure("environment-override-present"); + return 65; + } + + var request = new PgmConnectDiagnosticRequest( + invocation.Host ?? string.Empty, + invocation.Port ?? 0, + invocation.ExpectedPgmWindowTitle ?? string.Empty); + PgmConnectDiagnosticReport report; + try + { + report = await new PgmConnectDiagnosticRunner() + .ExecuteAsync(request, cancellationToken) + .ConfigureAwait(false); + } + catch + { + WritePgmDiagnosticFailure("internal-diagnostic-failure"); + return 70; + } + + WriteJson(new + { + command = "pgm-connect-diagnostic", + report.RequestValidated, + report.ProcessGatePassed, + report.ListenerOwnershipConfirmed, + report.RegistrationReady, + report.ConnectRequestIssued, + report.ComActivationAttempted, + lastKtapConnectState = ToWireValue(report.LastKtapConnectState), + report.KtapConnectAttempted, + report.KtapConnectAccepted, + report.KtapHelloObserved, + report.NetworkMonitoringRecordExpected, + report.NetworkMonitoringCheckRequired, + report.NetworkMonitoringVerified, + report.DisconnectAttempted, + report.DisconnectCode, + report.Completed, + report.OutcomeUnknown, + report.RenderCommandSurfaceExposed, + report.RenderCommandAttempted, + report.AutomaticReconnectEnabled, + report.ErrorCode + }); + + if (report.Completed && !report.OutcomeUnknown) + { + return 0; + } + + return report.OutcomeUnknown ? 5 : 4; +} + +static void WritePgmDiagnosticFailure(string errorCode) => + WriteJson(new + { + command = "pgm-connect-diagnostic", + requestValidated = false, + processGatePassed = false, + listenerOwnershipConfirmed = false, + registrationReady = false, + connectRequestIssued = false, + comActivationAttempted = false, + lastKtapConnectState = "not-attempted", + ktapConnectAttempted = false, + ktapConnectAccepted = false, + ktapHelloObserved = (bool?)null, + networkMonitoringRecordExpected = false, + networkMonitoringCheckRequired = false, + networkMonitoringVerified = (bool?)null, + disconnectAttempted = false, + disconnectCode = "not-attempted", + completed = false, + outcomeUnknown = false, + renderCommandSurfaceExposed = false, + renderCommandAttempted = false, + automaticReconnectEnabled = false, + errorCode + }); + +static string ToWireValue(PlayoutKtapConnectState state) => state switch +{ + PlayoutKtapConnectState.NotAttempted => "not-attempted", + PlayoutKtapConnectState.Attempted => "attempted", + PlayoutKtapConnectState.AcceptedUnconfirmed => "accepted-unconfirmed", + PlayoutKtapConnectState.Failed => "failed", + _ => "not-attempted" +}; + static void WritePreflightFailure(string command, string errorCode) => WriteJson(new { @@ -265,12 +366,14 @@ static int Usage(string? errorCode) errorCode = errorCode ?? "invalid-command-line" }); Console.Error.WriteLine( - "Usage: MBN_STOCK_WEBVIEW.PlayoutSmoke [--probe|--dry-run|--test-plan|--test-connect|--test-sequence]"); + "Usage: MBN_STOCK_WEBVIEW.PlayoutSmoke [--probe|--dry-run|--test-plan|--test-connect|--test-sequence|--pgm-connect-diagnostic]"); 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."); + "--test-connect and --test-sequence may request render-capable COM after all Test safety gates pass."); + Console.Error.WriteLine( + $"--pgm-connect-diagnostic requires {SmokeCommandLine.PgmConnectAcknowledgementFlag}, --host, --port and --expected-pgm-window-title; it exposes no render command."); return 64; } diff --git a/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/SmokeCommandLine.cs b/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/SmokeCommandLine.cs index 44c7a34..b368c68 100644 --- a/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/SmokeCommandLine.cs +++ b/tools/MBN_STOCK_WEBVIEW.PlayoutSmoke/SmokeCommandLine.cs @@ -7,7 +7,8 @@ internal enum SmokeCommandKind DryRun, TestPlan, TestConnect, - TestSequence + TestSequence, + PgmConnectDiagnostic } internal sealed record SmokeCommandInvocation( @@ -15,7 +16,10 @@ internal sealed record SmokeCommandInvocation( string? ConfigPath = null, string? PrepareSceneCode = null, string? NextSceneCode = null, - int? ObservationMilliseconds = null); + int? ObservationMilliseconds = null, + string? Host = null, + int? Port = null, + string? ExpectedPgmWindowTitle = null); internal sealed record SmokeCommandParseResult( bool IsValid, @@ -26,6 +30,8 @@ internal static class SmokeCommandLine { internal const string AcknowledgementFlag = "--acknowledge-isolated-non-program-test-output"; + internal const string PgmConnectAcknowledgementFlag = + "--i-understand-this-will-contact-current-pgm-tornado-via-ktap-connect-and-disconnect-only"; public static SmokeCommandParseResult Parse(IReadOnlyList arguments) { @@ -50,6 +56,7 @@ internal static class SmokeCommandLine "--test-plan" => SmokeCommandKind.TestPlan, "--test-connect" => SmokeCommandKind.TestConnect, "--test-sequence" => SmokeCommandKind.TestSequence, + "--pgm-connect-diagnostic" => SmokeCommandKind.PgmConnectDiagnostic, _ => SmokeCommandKind.Invalid }; if (kind == SmokeCommandKind.Invalid) @@ -61,6 +68,9 @@ internal static class SmokeCommandLine string? prepareSceneCode = null; string? nextSceneCode = null; int? observationMilliseconds = null; + string? host = null; + int? port = null; + string? expectedPgmWindowTitle = null; var acknowledged = false; for (var index = 1; index < arguments.Count; index++) @@ -68,7 +78,8 @@ internal static class SmokeCommandLine var argument = arguments[index]; switch (argument) { - case AcknowledgementFlag: + case AcknowledgementFlag when kind != SmokeCommandKind.PgmConnectDiagnostic: + case PgmConnectAcknowledgementFlag when kind == SmokeCommandKind.PgmConnectDiagnostic: if (acknowledged) { return Invalid("duplicate-option"); @@ -77,7 +88,54 @@ internal static class SmokeCommandLine acknowledged = true; break; - case "--config": + case "--host" when kind == SmokeCommandKind.PgmConnectDiagnostic: + if (host is not null) + { + return Invalid("duplicate-option"); + } + + if (!TryReadValue(arguments, ref index, out host)) + { + return Invalid("missing-option-value"); + } + + break; + + case "--port" when kind == SmokeCommandKind.PgmConnectDiagnostic: + if (port is not null) + { + return Invalid("duplicate-option"); + } + + if (!TryReadValue(arguments, ref index, out var portText) || + !int.TryParse( + portText, + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out var parsedPort) || + parsedPort is < 1 or > 65_535) + { + return Invalid("invalid-port"); + } + + port = parsedPort; + break; + + case "--expected-pgm-window-title" + when kind == SmokeCommandKind.PgmConnectDiagnostic: + if (expectedPgmWindowTitle is not null) + { + return Invalid("duplicate-option"); + } + + if (!TryReadValue(arguments, ref index, out expectedPgmWindowTitle)) + { + return Invalid("missing-option-value"); + } + + break; + + case "--config" when kind != SmokeCommandKind.PgmConnectDiagnostic: if (configPath is not null) { return Invalid("duplicate-option"); @@ -146,6 +204,22 @@ internal static class SmokeCommandLine return Invalid("missing-acknowledgement"); } + if (kind == SmokeCommandKind.PgmConnectDiagnostic) + { + if (string.IsNullOrWhiteSpace(host) || + port is null || + string.IsNullOrWhiteSpace(expectedPgmWindowTitle)) + { + return Invalid("missing-required-option"); + } + + return Valid(new SmokeCommandInvocation( + kind, + Host: host, + Port: port, + ExpectedPgmWindowTitle: expectedPgmWindowTitle)); + } + if (string.IsNullOrWhiteSpace(configPath)) { return Invalid("missing-required-option"); @@ -177,7 +251,10 @@ internal static class SmokeCommandLine configPath, prepareSceneCode, nextSceneCode, - observationMilliseconds)); + observationMilliseconds, + host, + port, + expectedPgmWindowTitle)); } internal static bool IsSafeSceneCode(string value)