From 4cfce2ea2d14b4583d7e21a2f5ce92d89b1520de Mon Sep 17 00:00:00 2001 From: seo-amugae Date: Thu, 8 Oct 2026 03:02:14 +0900 Subject: [PATCH] Recover stalled RTSP streams --- README.md | 9 ++- config.ini | 8 ++ screen-rtsp.ps1 | 190 +++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 205 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ac968c1..e3c570d 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,12 @@ RTSP는 `ddagrab` 기반 Desktop Duplication API로 화면과 마우스 커서 - 기본 실행과 예약 작업 모두 PowerShell 창을 표시하지 않는 숨김 백그라운드 실행 - 절전 해제나 장시간 polling 중단 감지 시 전체 채널 강제 재구성 - 로그인 트리거와 1분 간격 무기한 복구 트리거를 이용한 Watchdog 지속 복구 +- 각 RTSP 채널에서 실제 영상 프레임을 주기적으로 디코딩해 살아 있는 채 멈춘 FFmpeg도 자동 복구 - `config.ini` 기반 기능별 설정과 명령줄 override - 전체 오디오와 마이크 혼합을 각각 `1` 또는 `0`으로 활성화·비활성화 - FFmpeg와 MediaMTX 부재 시 HTTPS 다운로드, 게시 SHA-256 검증, 후보 선검증 및 백업 가능한 안전 교체 - `config.ini` 최상단에 비개인 환경·호환성 정보를 원자적으로 자동 기록 -- 모니터·오디오·NIC·프로세스·절전 복구·설정·안전장치용 내장 시뮬레이션 49건 +- 모니터·오디오·NIC·프로세스·RTSP 실제 프레임·절전 복구·설정·안전장치용 내장 시뮬레이션 51건 ## RTSP URL @@ -139,6 +140,9 @@ SignalThreshold=0.0005 | `Network` | `NicDescription` | 빈 값 | 비우면 기본 경로 NIC 자동 선택 | | `Network` | `HealthTarget` | 빈 값 | Source IP ping 대상 | | `RTSP` | `Port` | `8554` | MediaMTX RTSP 포트 | +| `RTSP` | `HealthCheckIntervalSec` | `10` | 모든 RTSP 채널의 실제 프레임 검사 주기 | +| `RTSP` | `HealthTimeoutSec` | `5` | 새 수신기가 영상 1프레임을 디코딩할 제한 시간 | +| `RTSP` | `HealthFailureThreshold` | `2` | 전체 AV 재시작 전 허용할 연속 검사 실패 횟수 | | `Dependencies` | `AutoInstall` | `1` | 실행 파일 부재 시 자동 설치 여부 | | `Dependencies` | `FFmpegUrl` | Gyan essentials ZIP | FFmpeg Windows ZIP 다운로드 주소 | | `Dependencies` | `FFmpegSha256Url` | Gyan SHA-256 | FFmpeg 게시 해시 주소 | @@ -213,6 +217,7 @@ INI를 해석한 최종 설정값만 확인하고 종료할 수도 있습니다. 9. 화면은 `ddagrab -> hwdownload -> BGRA -> 회전 보정 -> NV12 -> 하드웨어 H.264` 순서로 처리합니다. 10. 시스템 소리는 named pipe, 마이크는 DirectShow로 FFmpeg에 입력됩니다. 두 소리를 `amix`한 뒤 `asplit`으로 각 RTSP 출력에 넣고 AAC로 인코딩합니다. 11. 모니터, NIC, GPU, 드라이버, 실행 파일, 오디오 장치 또는 자식 프로세스 상태가 바뀌면 전체 채널을 종료한 뒤 검증과 구성을 새로 읽어 재시작합니다. +12. FFmpeg PID가 살아 있어도 각 RTSP URL에 새 로컬 수신기를 붙여 실제 영상 프레임을 확인하며, 연속 실패하면 단일 FFmpeg와 오디오 worker를 함께 재시작합니다. ## 설치 및 실행 @@ -247,6 +252,8 @@ Start-ScheduledTask -TaskName "Screen RTSP Watchdog" Watchdog 내부에서는 Windows `PowerModeChanged/Resume` 이벤트를 감시하고, 이벤트 누락에 대비해 polling 시간 공백도 함께 확인합니다. 절전 해제가 감지되면 단일 FFmpeg AV 파이프라인을 종료하고 모니터·NIC·오디오·GPU 구성을 다시 읽은 뒤 안정화 시간 이후 재시작합니다. 모니터 분리 시에는 채널을 종료하고 계속 대기하며, 필요한 수만큼 다시 연결되면 동일하게 자동 복구합니다. +프로세스 생존 여부와 실제 송출 상태는 별도로 검사합니다. `HealthCheckIntervalSec`마다 모든 `monitorN` URL에 숨김 로컬 FFmpeg 수신기를 동시에 연결하고, `HealthTimeoutSec` 안에 각 채널에서 영상 1프레임을 디코딩해야 정상으로 판정합니다. `HealthFailureThreshold`회 연속 실패하면 FFmpeg PID가 남아 있어도 전체 AV 파이프라인을 강제로 정리하고 3초 안정화 후 다시 만듭니다. 재시작 횟수 제한은 없습니다. + 등록된 작업은 `config.ini` 경로만 전달하므로 설정 파일을 수정한 뒤 작업을 다시 시작하면 변경값이 적용됩니다. 기존 작업이 등록돼 있다면 업데이트된 복구 설정을 적용하기 위해 `-InstallStartup`을 다시 실행해야 합니다. ```powershell diff --git a/config.ini b/config.ini index be84f11..2efe9a2 100644 --- a/config.ini +++ b/config.ini @@ -120,6 +120,14 @@ HealthTarget= [RTSP] Port=8554 +# 실행 중인 FFmpeg PID뿐 아니라 각 RTSP 채널에서 실제 영상 1프레임이 +# 디코딩되는지를 검사합니다. 모든 채널 검사는 동시에 수행됩니다. +HealthCheckIntervalSec=10 +HealthTimeoutSec=5 + +# 연속 실패가 이 횟수에 도달하면 전체 AV 파이프라인을 재시작합니다. +HealthFailureThreshold=2 + [Dependencies] # ffmpeg.exe 또는 mediamtx.exe가 없을 때 자동 다운로드: 1=사용, 0=오류 후 종료 AutoInstall=1 diff --git a/screen-rtsp.ps1 b/screen-rtsp.ps1 index 0bc7b79..965f9c0 100644 --- a/screen-rtsp.ps1 +++ b/screen-rtsp.ps1 @@ -41,6 +41,15 @@ param( [ValidateRange(1, 65535)] [int]$RtspPort = 8554, + [ValidateRange(1, 3600)] + [int]$RtspHealthCheckIntervalSec = 10, + + [ValidateRange(1, 60)] + [int]$RtspHealthTimeoutSec = 5, + + [ValidateRange(1, 10)] + [int]$RtspHealthFailureThreshold = 2, + [ValidateSet(0, 1)] [int]$AutoInstallDependencies = 1, @@ -701,7 +710,12 @@ function Assert-IniSettings { Video = @("FrameRate", "Bitrate", "EncoderPriority") Audio = @("Enabled", "MicrophoneEnabled", "Bitrate", "SignalThreshold") Network = @("NicDescription", "HealthTarget") - RTSP = @("Port") + RTSP = @( + "Port", + "HealthCheckIntervalSec", + "HealthTimeoutSec", + "HealthFailureThreshold" + ) Dependencies = @( "AutoInstall", "FFmpegUrl", @@ -805,6 +819,18 @@ if ($ConfigWasLoaded) { if ($setting.Found -and -not $ExplicitParameters.ContainsKey("RtspPort")) { $RtspPort = [int]$setting.Value } + $setting = Get-IniSetting $ini "RTSP" "HealthCheckIntervalSec" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("RtspHealthCheckIntervalSec")) { + $RtspHealthCheckIntervalSec = [int]$setting.Value + } + $setting = Get-IniSetting $ini "RTSP" "HealthTimeoutSec" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("RtspHealthTimeoutSec")) { + $RtspHealthTimeoutSec = [int]$setting.Value + } + $setting = Get-IniSetting $ini "RTSP" "HealthFailureThreshold" + if ($setting.Found -and -not $ExplicitParameters.ContainsKey("RtspHealthFailureThreshold")) { + $RtspHealthFailureThreshold = [int]$setting.Value + } $setting = Get-IniSetting $ini "Dependencies" "AutoInstall" if ($setting.Found -and -not $ExplicitParameters.ContainsKey("AutoInstallDependencies")) { @@ -1004,6 +1030,9 @@ function Get-HiddenWorkerArgumentList { "-StableTimeSec", [string]$StableTimeSec, "-PollIntervalSec", [string]$PollIntervalSec, "-RtspPort", [string]$RtspPort, + "-RtspHealthCheckIntervalSec", [string]$RtspHealthCheckIntervalSec, + "-RtspHealthTimeoutSec", [string]$RtspHealthTimeoutSec, + "-RtspHealthFailureThreshold", [string]$RtspHealthFailureThreshold, "-AutoInstallDependencies", [string]$AutoInstallDependencies, "-FFmpegDownloadUrl", $FFmpegDownloadUrl, "-FFmpegSha256Url", $FFmpegSha256Url, @@ -1091,6 +1120,9 @@ if ($ShowConfiguration) { NicDescription = $NicDescription HealthTarget = $HealthTarget RtspPort = $RtspPort + RtspHealthCheckIntervalSec = $RtspHealthCheckIntervalSec + RtspHealthTimeoutSec = $RtspHealthTimeoutSec + RtspHealthFailureThreshold = $RtspHealthFailureThreshold AutoInstallDependencies = $AutoInstallDependencies FFmpegDownloadUrl = $FFmpegDownloadUrl FFmpegSha256Url = $FFmpegSha256Url @@ -2699,6 +2731,8 @@ $script:MediaMTXProcess = $null $script:OwnsMediaMTX = $false $script:StreamProcesses = @{} $script:AudioProcesses = @{} +$script:LastRtspHealthCheck = [DateTime]::MinValue +$script:RtspHealthFailures = 0 function Test-ProcessRunning { param($Process) @@ -2750,6 +2784,8 @@ function Stop-Streams { } $script:StreamProcesses = @{} $script:AudioProcesses = @{} + $script:LastRtspHealthCheck = [DateTime]::MinValue + $script:RtspHealthFailures = 0 } function Get-RotationFilter { @@ -2949,6 +2985,9 @@ function Start-Streams { } throw "FFmpeg 또는 오디오 loopback worker가 실행 직후 종료되었습니다." } + + $script:LastRtspHealthCheck = Get-Date + $script:RtspHealthFailures = 0 } function Test-StreamsRunning { @@ -2963,6 +3002,92 @@ function Test-StreamsRunning { return $true } +function New-RtspHealthProbeArguments { + param([int]$Channel) + + return @( + "-hide_banner", "-loglevel", "error", "-nostdin", + "-rtsp_transport", "tcp", + "-rw_timeout", [string]($RtspHealthTimeoutSec * 1000000), + "-i", "$RTSPPublishBase/monitor$Channel", + "-map", "0:v:0", "-frames:v", "1", + "-an", "-sn", "-dn", "-f", "null", "NUL" + ) +} + +function Test-RtspStreamFrames { + $probes = @() + $failures = New-Object System.Collections.Generic.List[string] + + try { + for ($channel = 1; $channel -le $ChannelCount; $channel++) { + $errorLog = Join-Path $LogDir "rtsp-health-monitor$channel.log" + Remove-Item -LiteralPath $errorLog -Force -ErrorAction SilentlyContinue + + $process = Start-Process -FilePath $FFmpegPath ` + -ArgumentList (Join-ProcessArguments (New-RtspHealthProbeArguments $channel)) ` + -WorkingDirectory $BaseDir -WindowStyle Hidden ` + -RedirectStandardError $errorLog -PassThru + + $probes += [PSCustomObject]@{ + Channel = $channel + Process = $process + ErrorLog = $errorLog + } + } + + # Start every receiver first and give the group one shared deadline. + # This keeps recovery time bounded even when every channel is stalled. + $deadline = [DateTime]::UtcNow.AddSeconds($RtspHealthTimeoutSec + 2) + foreach ($probe in $probes) { + $remainingMs = [Math]::Max( + 1, + [int]($deadline - [DateTime]::UtcNow).TotalMilliseconds + ) + $completed = $probe.Process.WaitForExit($remainingMs) + + if (-not $completed) { + Stop-Process -Id $probe.Process.Id -Force -ErrorAction SilentlyContinue + $failures.Add( + "monitor$($probe.Channel): ${RtspHealthTimeoutSec}초 내 영상 프레임 없음" + ) + continue + } + + $probe.Process.WaitForExit() + if ($probe.Process.ExitCode -ne 0) { + $detail = "exit=$($probe.Process.ExitCode)" + if (Test-Path -LiteralPath $probe.ErrorLog) { + $tail = @( + Get-Content -LiteralPath $probe.ErrorLog -Tail 3 ` + -ErrorAction SilentlyContinue + ) + if ($tail.Count -gt 0) { $detail = ($tail -join " | ") } + } + $failures.Add("monitor$($probe.Channel): $detail") + } + } + } + catch { + $failures.Add($_.Exception.Message) + } + finally { + foreach ($probe in $probes) { + try { + if (-not $probe.Process.HasExited) { + Stop-Process -Id $probe.Process.Id -Force -ErrorAction SilentlyContinue + } + } + catch { } + } + } + + return [PSCustomObject]@{ + Healthy = ($failures.Count -eq 0) + Reason = ($failures -join "; ") + } +} + function Get-StreamingReadinessReason { param($Screens, $Network, $PlaybackEndpoint, $MicrophoneEndpoint) @@ -3072,6 +3197,21 @@ function Invoke-SimulationTests { finally { [System.IO.File]::Delete($path) } } + Invoke-SimulationCase "RTSP 실제 프레임 검사 설정 파싱" { + $path = New-SimulationIni ( + "[RTSP]`r`nPort=8554`r`nHealthCheckIntervalSec=10`r`n" + + "HealthTimeoutSec=5`r`nHealthFailureThreshold=2`r`n" + ) + try { + $parsed = Read-IniConfiguration $path + Assert-IniSettings $parsed + return ($parsed.RTSP.HealthCheckIntervalSec -eq "10" -and + $parsed.RTSP.HealthTimeoutSec -eq "5" -and + $parsed.RTSP.HealthFailureThreshold -eq "2") + } + finally { [System.IO.File]::Delete($path) } + } + Invoke-SimulationCase "자동 기록 갱신은 사용자 설정과 주석 보존" { $path = New-SimulationIni "# 사용자 주석`r`n[General]`r`nChannelCount=7`r`n" try { @@ -3378,6 +3518,9 @@ function Invoke-SimulationTests { $arguments -match '-AudioEnabled\s+[01]' -and $arguments -match '-MicrophoneEnabled\s+[01]' -and $arguments -match '-RtspPort\s+8554' -and + $arguments -match '-RtspHealthCheckIntervalSec\s+10' -and + $arguments -match '-RtspHealthTimeoutSec\s+5' -and + $arguments -match '-RtspHealthFailureThreshold\s+2' -and $arguments -match '-WatchdogWorker') } @@ -3466,6 +3609,15 @@ function Invoke-SimulationTests { return (Test-StreamsRunning) } + Invoke-SimulationCase "RTSP 상태 검사는 새 수신기로 실제 영상 1프레임 요구" { + $arguments = @(New-RtspHealthProbeArguments 2) + return ($arguments -contains '-rw_timeout' -and + $arguments -contains '-frames:v' -and + $arguments -contains '1' -and + $arguments -contains '0:v:0' -and + $arguments -contains "$RTSPPublishBase/monitor2") + } + Invoke-SimulationCase "비 HTTPS 의존성 URL 거부" { try { Assert-HttpsDependencyUrl "http://example.com/file.zip" "test"; return $false } catch { return $true } @@ -3619,6 +3771,11 @@ try { if ($NicDescription) { Write-Log "고정 NIC: $NicDescription" } else { Write-Log "NIC 선택: IPv4 기본 경로 자동 감지" } if ($HealthTarget) { Write-Log "통신 확인 대상: $HealthTarget" } + Write-Log ( + "RTSP 프레임 검사: ${RtspHealthCheckIntervalSec}초 주기 / " + + "${RtspHealthTimeoutSec}초 제한 / " + + "${RtspHealthFailureThreshold}회 연속 실패 시 재시작" + ) $lastSystemSignature = "" $healthySince = $null @@ -3733,6 +3890,37 @@ try { $healthySince = $null } + # A live FFmpeg PID does not prove that DDA and both RTSP outputs + # are progressing. A new local receiver must decode one frame from + # every channel within the configured deadline. + if ((Test-StreamsRunning) -and + ((Get-Date) - $script:LastRtspHealthCheck).TotalSeconds -ge + $RtspHealthCheckIntervalSec) { + $script:LastRtspHealthCheck = Get-Date + $rtspHealth = Test-RtspStreamFrames + + if ($rtspHealth.Healthy) { + if ($script:RtspHealthFailures -gt 0) { + Write-Log "RTSP 실제 프레임 수신 상태 복구" + } + $script:RtspHealthFailures = 0 + } + else { + $script:RtspHealthFailures++ + Write-Log ( + "RTSP 실제 프레임 검사 실패 " + + "($($script:RtspHealthFailures)/$RtspHealthFailureThreshold): " + + $rtspHealth.Reason + ) + + if ($script:RtspHealthFailures -ge $RtspHealthFailureThreshold) { + Write-Log "RTSP 송출 정지 감지 - 전체 AV 파이프라인 재시작" + Stop-Streams + $healthySince = $null + } + } + } + if (-not (Test-StreamsRunning)) { if (-not $healthySince) { $healthySince = Get-Date