diff --git a/README.md b/README.md index ba3d60d..7dd0c55 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,8 @@ dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke ` -c Debug -p:Platform=x64 -- --dry-run ``` +격리 Tornado TEST의 프로세스·Network Server 포트·loopback 주소·LISTEN 소유권은 COM을 열기 전에 [읽기 전용 점검 스크립트](scripts/Inspect-TornadoTestIsolation.ps1)로 확인합니다. 설치 폴더의 다른 Tornado2 버전 EXE는 현재 사용자 설정과 출력 장치를 공유할 수 있으므로 PGM 옆에서 TEST 대용으로 실행하지 않습니다. + 별도 Test 인스턴스 검증은 [Tornado/K3D 운영 가이드](docs/PLAYOUT.md)의 단계별 CLI를 사용합니다. `--test-plan`은 절대 경로 로컬 JSON과 씬 자산만 확인하며 엔진/COM을 만들지 않습니다. PGM/PROGRAM이 전혀 없는 격리 출력에서만 `--test-connect`로 연결·해제를 확인한 뒤 `--test-sequence`로 `PREPARE → TAKE IN → NEXT → TAKE OUT`을 실행합니다. Test 명령은 `MBN_STOCK_PLAYOUT_*` 환경 override와 Live 설정을 거부하고 자동 재연결·재생을 하지 않습니다. 승인된 Test 씬 후보는 basename `5001`, `5006`이며 실제 자산 경로는 저장소에 기록하지 않습니다. 중요: 앱의 기본 `DryRun`, `--probe`, `--dry-run`, `--test-plan`은 `KTAPConnect`를 호출하지 않습니다. 따라서 이 단계에서 Tornado2의 `View > Network Monitoring Window`에 앱이 보낸 통신 기록이 없는 것은 정상입니다. 이 CLI 절차에서는 모든 안전 게이트를 통과한 격리 `Test`의 `--test-connect`에서 처음 네트워크 기록을 기대하며, 별도로 승인된 UI Test 실행도 기록을 만들 수 있습니다. 사용할 포트는 예제 숫자가 아니라 해당 격리 Test Tornado의 `Tools > Option > Control > Network Server > TCP Port` 실값이어야 합니다. diff --git a/docs/PLAYOUT.md b/docs/PLAYOUT.md index 47cf10a..dda8b17 100644 --- a/docs/PLAYOUT.md +++ b/docs/PLAYOUT.md @@ -163,6 +163,10 @@ powershell -NoProfile -ExecutionPolicy Bypass ` 실제 Test 검증은 아래 세 단계를 순서대로 사용합니다. 세 명령 모두 절대 경로의 일반 로컬 JSON 파일과 `--acknowledge-isolated-non-program-test-output` 승인이 필요합니다. `MBN_STOCK_PLAYOUT_*` 환경 변수가 하나라도 존재하면 파일과 환경의 합성 결과가 달라지는 것을 막기 위해 엔진 생성 전에 거부합니다. JSON은 반드시 `mode: "Test"`, `trustedLiveOutputEnabled: false`여야 하며 기존 Test 안전 게이트를 그대로 통과해야 합니다. +이 장비의 Tornado2 설정은 실행 파일별 프로필이 아니라 현재 사용자 공용 `HKCU\Software\Visual Research Inc\Tornado2\Option\Control`에 저장됩니다. 설치된 Tornado2 매뉴얼 286쪽도 프로그램 실행 시 이 Network Server 설정의 자동 시작을 설명하며 별도 명령행 프로필이나 다중 인스턴스 절차를 제시하지 않습니다. 따라서 `C:\Tornado2`에 함께 설치된 다른 버전 EXE를 PGM 옆에서 실행해 TEST로 간주하지 않습니다. 동일 포트, 동일 출력 보드와 동일 사용자 설정을 공유할 수 있습니다. + +권장 구성은 별도 TEST 장비 또는 독립 VM/Windows 사용자 프로필에서 출력 보드를 preview 전용 또는 `None`으로 두고, 그 환경에 Tornado2를 정확히 하나만 실행하는 것입니다. 같은 장비를 사용해야 한다면 방송 운영자가 승인한 정비 시간에 PGM을 정상 종료하고 Tornado UI에서 Network Server와 출력 채널을 TEST 전용으로 구성하며, 창 제목에 `TEST` 식별자가 나타나는 공식 운영 절차까지 확인한 뒤 진행합니다. 실행 중 PGM의 레지스트리나 옵션을 자동 변경하거나 다른 버전 EXE를 병렬 실행하지 않습니다. + 먼저 씬 파일과 설정만 검사합니다. 이 단계는 엔진·STA·COM을 생성하지 않으며 실행 중 프로세스의 적격성도 아직 판단하지 않습니다. 승인된 Test 씬 후보인 `5001.t2s`와 `5006.t2s`를 사용할 때 로컬 JSON의 외부 `sceneDirectory` 및 `testSceneAllowlist`에는 각각 basename `5001`, `5006`을 설정합니다. 실제 절대 경로는 로컬 JSON에만 두고 저장소 문서나 예제 설정에 기록하지 않습니다. ```powershell @@ -179,6 +183,17 @@ dotnet run --project .\tools\MBN_STOCK_WEBVIEW.PlayoutSmoke ` --observe-ms 5000 ``` +자산 검사가 끝나면 COM을 만들기 전, 즉 `--test-connect` 직전에 다음 읽기 전용 점검으로 프로세스, 현재 사용자 Network Server 설정과 LISTEN 소유권을 함께 확인합니다. `ExpectedTestWindowTitle`은 실제 창 제목과 대소문자만 무시하고 정확히 같아야 하며 독립된 `TEST` 토큰이 필요합니다. 결과에는 PID, IP 주소와 실제 창 제목을 기록하지 않습니다. + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass ` + -File .\scripts\Inspect-TornadoTestIsolation.ps1 ` + -ExpectedTcpPort ` + -ExpectedTestWindowTitle 'Tornado2 TEST' +``` + +`ready: true`인 경우에만 `--test-connect`로 진행합니다. 이 점검은 COM과 `KTAPConnect`를 호출하지 않으므로 Network Monitoring 기록이 없어야 합니다. `program-window-detected`, `network-address-not-loopback`, `listener-address-not-loopback`, `listener-not-owned-by-test-instance` 중 하나라도 있으면 현재 실행 환경은 격리 TEST가 아닙니다. 레지스트리 설정뿐 아니라 실제 LISTEN endpoint도 모두 loopback이어야 하며 wildcard(`0.0.0.0`, `::`) 리스너는 거부합니다. + 별도 Test Tornado만 정확히 하나 실행되고 PGM/PROGRAM 창이 전혀 없는 것을 사람이 다시 확인한 뒤 연결만 검증합니다. 이 명령은 씬이나 출력 상태를 변경하지 않고 `Connect → Disconnect`만 요청합니다. 명령 전에 Network Monitoring을 열고 `TCPSession`과 로그 시각을 기준선으로 기록합니다. `--test-plan`에는 변화가 없어야 합니다. `--test-connect`는 짧게 `Connect → Disconnect`하므로 세션 수가 곧 원래 값으로 돌아갈 수 있습니다. 일시적인 세션 수만 보지 말고 같은 시각의 `[R]`/`[S]` 양방향 기록을 확인합니다. `[R]`만 있고 `[S]`가 없거나 양쪽 모두 없으면 명령을 반복하지 말고 중단합니다. JSON의 `completed: true`, `outcomeUnknown: false`, `lastKtapConnectState: "accepted-unconfirmed"`와 운영자가 본 모니터 기록을 함께 확인한 뒤에만 5001→5006 시퀀스로 진행합니다. diff --git a/scripts/Inspect-TornadoTestIsolation.ps1 b/scripts/Inspect-TornadoTestIsolation.ps1 new file mode 100644 index 0000000..eae4997 --- /dev/null +++ b/scripts/Inspect-TornadoTestIsolation.ps1 @@ -0,0 +1,218 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateRange(1, 65535)] + [int]$ExpectedTcpPort, + + [Parameter(Mandatory = $true)] + [ValidateLength(1, 128)] + [string]$ExpectedTestWindowTitle +) + +$ErrorActionPreference = 'Stop' + +function Write-SafeResult { + param( + [Parameter(Mandatory = $true)] + [object]$Value, + + [Parameter(Mandatory = $true)] + [int]$ExitCode + ) + + $Value | ConvertTo-Json -Depth 6 + exit $ExitCode +} + +function Test-ProgramWindowTitle { + param([AllowEmptyString()][string]$Title) + + return $Title -match '(?i)(^|[^A-Z0-9])(PGM|PROGRAM)([^A-Z0-9]|$)' +} + +try { + $expectedTitle = $ExpectedTestWindowTitle.Trim() + if (-not $expectedTitle.StartsWith('Tornado2', [StringComparison]::OrdinalIgnoreCase) -or + $expectedTitle -notmatch '(?i)(^|[^A-Z0-9])TEST([^A-Z0-9]|$)' -or + (Test-ProgramWindowTitle $expectedTitle)) { + Write-SafeResult -ExitCode 64 -Value ([ordered]@{ + command = 'test-isolation-probe' + ready = $false + errorCode = 'invalid-test-window-title' + connectRequestIssued = $false + comActivationAttempted = $false + ktapConnectAttempted = $false + }) + } + + $processInspectionAvailable = $true + $tornadoProcesses = @() + try { + foreach ($process in Get-Process -ErrorAction Stop) { + try { + if (-not $process.ProcessName.StartsWith( + 'Tornado2', + [StringComparison]::OrdinalIgnoreCase)) { + continue + } + + $title = $process.MainWindowTitle + $tornadoProcesses += [pscustomobject]@{ + Id = $process.Id + IsExpectedTest = [string]::Equals( + $title, + $expectedTitle, + [StringComparison]::OrdinalIgnoreCase) + IsProgram = Test-ProgramWindowTitle $title + } + } + catch { + $processInspectionAvailable = $false + } + finally { + $process.Dispose() + } + } + } + catch { + $processInspectionAvailable = $false + $tornadoProcesses = @() + } + + $eligibleProcesses = @($tornadoProcesses | Where-Object IsExpectedTest) + $programProcesses = @($tornadoProcesses | Where-Object IsProgram) + + $registryAvailable = $false + $configuredPortMatches = $false + $networkAddressIsLoopback = $false + $networkAutoStart = $false + try { + $controlKey = Get-Item -LiteralPath ( + 'Registry::HKEY_CURRENT_USER\Software\Visual Research Inc\Tornado2\Option\Control') + $configuredPort = [int]$controlKey.GetValue('Network TCP Port', 0) + $configuredAddress = [string]$controlKey.GetValue('Network Address', '') + $networkAutoStart = [int]$controlKey.GetValue('Network Auto Start', 0) -ne 0 + $parsedAddress = $null + $addressParsed = [Net.IPAddress]::TryParse($configuredAddress, [ref]$parsedAddress) + $networkAddressIsLoopback = $addressParsed -and + [Net.IPAddress]::IsLoopback($parsedAddress) + $configuredPortMatches = $configuredPort -eq $ExpectedTcpPort + $registryAvailable = $true + } + catch { + $registryAvailable = $false + } + + $listenerInspectionAvailable = $true + $listeners = @() + try { + $listeners = @(Get-NetTCPConnection ` + -State Listen ` + -LocalPort $ExpectedTcpPort ` + -ErrorAction Stop) + } + catch [Microsoft.PowerShell.Cmdletization.Cim.CimJobException] { + # Get-NetTCPConnection uses this exception for an empty query on some Windows builds. + $listeners = @() + } + catch { + $listenerInspectionAvailable = $false + $listeners = @() + } + + $listenerAddressesAreLoopback = $listeners.Count -gt 0 + foreach ($listener in $listeners) { + $parsedListenerAddress = $null + $listenerAddressParsed = [Net.IPAddress]::TryParse( + [string]$listener.LocalAddress, + [ref]$parsedListenerAddress) + if (-not $listenerAddressParsed -or + -not [Net.IPAddress]::IsLoopback($parsedListenerAddress)) { + $listenerAddressesAreLoopback = $false + break + } + } + + $eligibleProcessIds = @($eligibleProcesses | ForEach-Object Id) + $listenerOwnerIds = @($listeners | ForEach-Object OwningProcess | Sort-Object -Unique) + $listenerOwnedOnlyByTest = $listeners.Count -gt 0 -and + $listenerOwnerIds.Count -gt 0 -and + @($listenerOwnerIds | Where-Object { $_ -notin $eligibleProcessIds }).Count -eq 0 + + $reasons = [Collections.Generic.List[string]]::new() + if (-not $processInspectionAvailable) { + $reasons.Add('process-inspection-unavailable') + } + if ($tornadoProcesses.Count -ne 1) { + $reasons.Add('tornado-process-count-not-one') + } + if ($eligibleProcesses.Count -ne 1) { + $reasons.Add('test-window-not-exact-match') + } + if ($programProcesses.Count -gt 0) { + $reasons.Add('program-window-detected') + } + if (-not $registryAvailable) { + $reasons.Add('network-settings-unavailable') + } + elseif (-not $configuredPortMatches) { + $reasons.Add('network-tcp-port-mismatch') + } + if ($registryAvailable -and -not $networkAddressIsLoopback) { + $reasons.Add('network-address-not-loopback') + } + if (-not $listenerInspectionAvailable) { + $reasons.Add('listener-inspection-unavailable') + } + elseif ($listeners.Count -eq 0) { + $reasons.Add('network-server-not-listening') + } + else { + if (-not $listenerAddressesAreLoopback) { + $reasons.Add('listener-address-not-loopback') + } + if (-not $listenerOwnedOnlyByTest) { + $reasons.Add('listener-not-owned-by-test-instance') + } + } + + $ready = $reasons.Count -eq 0 + Write-SafeResult -ExitCode $(if ($ready) { 0 } else { 3 }) -Value ([ordered]@{ + command = 'test-isolation-probe' + ready = $ready + expectedTcpPort = $ExpectedTcpPort + process = [ordered]@{ + inspectionAvailable = $processInspectionAvailable + totalCount = $tornadoProcesses.Count + eligibleTestCount = $eligibleProcesses.Count + programWindowDetected = $programProcesses.Count -gt 0 + } + networkServer = [ordered]@{ + settingsAvailable = $registryAvailable + configuredPortMatches = $configuredPortMatches + addressIsLoopback = $networkAddressIsLoopback + autoStartEnabled = $networkAutoStart + listenerInspectionAvailable = $listenerInspectionAvailable + listenerEndpointCount = $listeners.Count + listenerAddressesAreLoopback = $listenerAddressesAreLoopback + listenerOwnedOnlyByTest = $listenerOwnedOnlyByTest + } + reasons = @($reasons) + connectRequestIssued = $false + comActivationAttempted = $false + lastKtapConnectState = 'not-attempted' + ktapConnectAttempted = $false + networkMonitoringRecordExpected = $false + networkMonitoringCheckRequired = $false + }) +} +catch { + Write-SafeResult -ExitCode 70 -Value ([ordered]@{ + command = 'test-isolation-probe' + ready = $false + errorCode = 'internal-probe-failure' + connectRequestIssued = $false + comActivationAttempted = $false + ktapConnectAttempted = $false + }) +}