Recover stalled RTSP streams

This commit is contained in:
seo-amugae committed 2026-10-08 03:02:14 +09:00
1 parent 4adf42cc4c
commit 4cfce2ea2d
3 files changed
+205 -2

No files matched your search

+8 -1
View File
@@ -40,11 +40,12 @@ RTSP는 `ddagrab` 기반 Desktop Duplication API로 화면과 마우스 커서
- 기본 실행과 예약 작업 모두 PowerShell 창을 표시하지 않는 숨김 백그라운드 실행 - 기본 실행과 예약 작업 모두 PowerShell 창을 표시하지 않는 숨김 백그라운드 실행
- 절전 해제나 장시간 polling 중단 감지 시 전체 채널 강제 재구성 - 절전 해제나 장시간 polling 중단 감지 시 전체 채널 강제 재구성
- 로그인 트리거와 1분 간격 무기한 복구 트리거를 이용한 Watchdog 지속 복구 - 로그인 트리거와 1분 간격 무기한 복구 트리거를 이용한 Watchdog 지속 복구
- 각 RTSP 채널에서 실제 영상 프레임을 주기적으로 디코딩해 살아 있는 채 멈춘 FFmpeg도 자동 복구
- `config.ini` 기반 기능별 설정과 명령줄 override - `config.ini` 기반 기능별 설정과 명령줄 override
- 전체 오디오와 마이크 혼합을 각각 `1` 또는 `0`으로 활성화·비활성화 - 전체 오디오와 마이크 혼합을 각각 `1` 또는 `0`으로 활성화·비활성화
- FFmpeg와 MediaMTX 부재 시 HTTPS 다운로드, 게시 SHA-256 검증, 후보 선검증 및 백업 가능한 안전 교체 - FFmpeg와 MediaMTX 부재 시 HTTPS 다운로드, 게시 SHA-256 검증, 후보 선검증 및 백업 가능한 안전 교체
- `config.ini` 최상단에 비개인 환경·호환성 정보를 원자적으로 자동 기록 - `config.ini` 최상단에 비개인 환경·호환성 정보를 원자적으로 자동 기록
- 모니터·오디오·NIC·프로세스·절전 복구·설정·안전장치용 내장 시뮬레이션 49건 - 모니터·오디오·NIC·프로세스·RTSP 실제 프레임·절전 복구·설정·안전장치용 내장 시뮬레이션 51건
## RTSP URL ## RTSP URL
@@ -139,6 +140,9 @@ SignalThreshold=0.0005
| `Network` | `NicDescription` | 빈 값 | 비우면 기본 경로 NIC 자동 선택 | | `Network` | `NicDescription` | 빈 값 | 비우면 기본 경로 NIC 자동 선택 |
| `Network` | `HealthTarget` | 빈 값 | Source IP ping 대상 | | `Network` | `HealthTarget` | 빈 값 | Source IP ping 대상 |
| `RTSP` | `Port` | `8554` | MediaMTX RTSP 포트 | | `RTSP` | `Port` | `8554` | MediaMTX RTSP 포트 |
| `RTSP` | `HealthCheckIntervalSec` | `10` | 모든 RTSP 채널의 실제 프레임 검사 주기 |
| `RTSP` | `HealthTimeoutSec` | `5` | 새 수신기가 영상 1프레임을 디코딩할 제한 시간 |
| `RTSP` | `HealthFailureThreshold` | `2` | 전체 AV 재시작 전 허용할 연속 검사 실패 횟수 |
| `Dependencies` | `AutoInstall` | `1` | 실행 파일 부재 시 자동 설치 여부 | | `Dependencies` | `AutoInstall` | `1` | 실행 파일 부재 시 자동 설치 여부 |
| `Dependencies` | `FFmpegUrl` | Gyan essentials ZIP | FFmpeg Windows ZIP 다운로드 주소 | | `Dependencies` | `FFmpegUrl` | Gyan essentials ZIP | FFmpeg Windows ZIP 다운로드 주소 |
| `Dependencies` | `FFmpegSha256Url` | Gyan SHA-256 | FFmpeg 게시 해시 주소 | | `Dependencies` | `FFmpegSha256Url` | Gyan SHA-256 | FFmpeg 게시 해시 주소 |
@@ -213,6 +217,7 @@ INI를 해석한 최종 설정값만 확인하고 종료할 수도 있습니다.
9. 화면은 `ddagrab -> hwdownload -> BGRA -> 회전 보정 -> NV12 -> 하드웨어 H.264` 순서로 처리합니다. 9. 화면은 `ddagrab -> hwdownload -> BGRA -> 회전 보정 -> NV12 -> 하드웨어 H.264` 순서로 처리합니다.
10. 시스템 소리는 named pipe, 마이크는 DirectShow로 FFmpeg에 입력됩니다. 두 소리를 `amix`한 뒤 `asplit`으로 각 RTSP 출력에 넣고 AAC로 인코딩합니다. 10. 시스템 소리는 named pipe, 마이크는 DirectShow로 FFmpeg에 입력됩니다. 두 소리를 `amix`한 뒤 `asplit`으로 각 RTSP 출력에 넣고 AAC로 인코딩합니다.
11. 모니터, NIC, GPU, 드라이버, 실행 파일, 오디오 장치 또는 자식 프로세스 상태가 바뀌면 전체 채널을 종료한 뒤 검증과 구성을 새로 읽어 재시작합니다. 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 구성을 다시 읽은 뒤 안정화 시간 이후 재시작합니다. 모니터 분리 시에는 채널을 종료하고 계속 대기하며, 필요한 수만큼 다시 연결되면 동일하게 자동 복구합니다. Watchdog 내부에서는 Windows `PowerModeChanged/Resume` 이벤트를 감시하고, 이벤트 누락에 대비해 polling 시간 공백도 함께 확인합니다. 절전 해제가 감지되면 단일 FFmpeg AV 파이프라인을 종료하고 모니터·NIC·오디오·GPU 구성을 다시 읽은 뒤 안정화 시간 이후 재시작합니다. 모니터 분리 시에는 채널을 종료하고 계속 대기하며, 필요한 수만큼 다시 연결되면 동일하게 자동 복구합니다.
프로세스 생존 여부와 실제 송출 상태는 별도로 검사합니다. `HealthCheckIntervalSec`마다 모든 `monitorN` URL에 숨김 로컬 FFmpeg 수신기를 동시에 연결하고, `HealthTimeoutSec` 안에 각 채널에서 영상 1프레임을 디코딩해야 정상으로 판정합니다. `HealthFailureThreshold`회 연속 실패하면 FFmpeg PID가 남아 있어도 전체 AV 파이프라인을 강제로 정리하고 3초 안정화 후 다시 만듭니다. 재시작 횟수 제한은 없습니다.
등록된 작업은 `config.ini` 경로만 전달하므로 설정 파일을 수정한 뒤 작업을 다시 시작하면 변경값이 적용됩니다. 기존 작업이 등록돼 있다면 업데이트된 복구 설정을 적용하기 위해 `-InstallStartup`을 다시 실행해야 합니다. 등록된 작업은 `config.ini` 경로만 전달하므로 설정 파일을 수정한 뒤 작업을 다시 시작하면 변경값이 적용됩니다. 기존 작업이 등록돼 있다면 업데이트된 복구 설정을 적용하기 위해 `-InstallStartup`을 다시 실행해야 합니다.
```powershell ```powershell
+8
View File
@@ -120,6 +120,14 @@ HealthTarget=
[RTSP] [RTSP]
Port=8554 Port=8554
# 실행 중인 FFmpeg PID뿐 아니라 각 RTSP 채널에서 실제 영상 1프레임이
# 디코딩되는지를 검사합니다. 모든 채널 검사는 동시에 수행됩니다.
HealthCheckIntervalSec=10
HealthTimeoutSec=5
# 연속 실패가 이 횟수에 도달하면 전체 AV 파이프라인을 재시작합니다.
HealthFailureThreshold=2
[Dependencies] [Dependencies]
# ffmpeg.exe 또는 mediamtx.exe가 없을 때 자동 다운로드: 1=사용, 0=오류 후 종료 # ffmpeg.exe 또는 mediamtx.exe가 없을 때 자동 다운로드: 1=사용, 0=오류 후 종료
AutoInstall=1 AutoInstall=1
+189 -1
View File
@@ -41,6 +41,15 @@ param(
[ValidateRange(1, 65535)] [ValidateRange(1, 65535)]
[int]$RtspPort = 8554, [int]$RtspPort = 8554,
[ValidateRange(1, 3600)]
[int]$RtspHealthCheckIntervalSec = 10,
[ValidateRange(1, 60)]
[int]$RtspHealthTimeoutSec = 5,
[ValidateRange(1, 10)]
[int]$RtspHealthFailureThreshold = 2,
[ValidateSet(0, 1)] [ValidateSet(0, 1)]
[int]$AutoInstallDependencies = 1, [int]$AutoInstallDependencies = 1,
@@ -701,7 +710,12 @@ function Assert-IniSettings {
Video = @("FrameRate", "Bitrate", "EncoderPriority") Video = @("FrameRate", "Bitrate", "EncoderPriority")
Audio = @("Enabled", "MicrophoneEnabled", "Bitrate", "SignalThreshold") Audio = @("Enabled", "MicrophoneEnabled", "Bitrate", "SignalThreshold")
Network = @("NicDescription", "HealthTarget") Network = @("NicDescription", "HealthTarget")
RTSP = @("Port") RTSP = @(
"Port",
"HealthCheckIntervalSec",
"HealthTimeoutSec",
"HealthFailureThreshold"
)
Dependencies = @( Dependencies = @(
"AutoInstall", "AutoInstall",
"FFmpegUrl", "FFmpegUrl",
@@ -805,6 +819,18 @@ if ($ConfigWasLoaded) {
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("RtspPort")) { if ($setting.Found -and -not $ExplicitParameters.ContainsKey("RtspPort")) {
$RtspPort = [int]$setting.Value $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" $setting = Get-IniSetting $ini "Dependencies" "AutoInstall"
if ($setting.Found -and -not $ExplicitParameters.ContainsKey("AutoInstallDependencies")) { if ($setting.Found -and -not $ExplicitParameters.ContainsKey("AutoInstallDependencies")) {
@@ -1004,6 +1030,9 @@ function Get-HiddenWorkerArgumentList {
"-StableTimeSec", [string]$StableTimeSec, "-StableTimeSec", [string]$StableTimeSec,
"-PollIntervalSec", [string]$PollIntervalSec, "-PollIntervalSec", [string]$PollIntervalSec,
"-RtspPort", [string]$RtspPort, "-RtspPort", [string]$RtspPort,
"-RtspHealthCheckIntervalSec", [string]$RtspHealthCheckIntervalSec,
"-RtspHealthTimeoutSec", [string]$RtspHealthTimeoutSec,
"-RtspHealthFailureThreshold", [string]$RtspHealthFailureThreshold,
"-AutoInstallDependencies", [string]$AutoInstallDependencies, "-AutoInstallDependencies", [string]$AutoInstallDependencies,
"-FFmpegDownloadUrl", $FFmpegDownloadUrl, "-FFmpegDownloadUrl", $FFmpegDownloadUrl,
"-FFmpegSha256Url", $FFmpegSha256Url, "-FFmpegSha256Url", $FFmpegSha256Url,
@@ -1091,6 +1120,9 @@ if ($ShowConfiguration) {
NicDescription = $NicDescription NicDescription = $NicDescription
HealthTarget = $HealthTarget HealthTarget = $HealthTarget
RtspPort = $RtspPort RtspPort = $RtspPort
RtspHealthCheckIntervalSec = $RtspHealthCheckIntervalSec
RtspHealthTimeoutSec = $RtspHealthTimeoutSec
RtspHealthFailureThreshold = $RtspHealthFailureThreshold
AutoInstallDependencies = $AutoInstallDependencies AutoInstallDependencies = $AutoInstallDependencies
FFmpegDownloadUrl = $FFmpegDownloadUrl FFmpegDownloadUrl = $FFmpegDownloadUrl
FFmpegSha256Url = $FFmpegSha256Url FFmpegSha256Url = $FFmpegSha256Url
@@ -2699,6 +2731,8 @@ $script:MediaMTXProcess = $null
$script:OwnsMediaMTX = $false $script:OwnsMediaMTX = $false
$script:StreamProcesses = @{} $script:StreamProcesses = @{}
$script:AudioProcesses = @{} $script:AudioProcesses = @{}
$script:LastRtspHealthCheck = [DateTime]::MinValue
$script:RtspHealthFailures = 0
function Test-ProcessRunning { function Test-ProcessRunning {
param($Process) param($Process)
@@ -2750,6 +2784,8 @@ function Stop-Streams {
} }
$script:StreamProcesses = @{} $script:StreamProcesses = @{}
$script:AudioProcesses = @{} $script:AudioProcesses = @{}
$script:LastRtspHealthCheck = [DateTime]::MinValue
$script:RtspHealthFailures = 0
} }
function Get-RotationFilter { function Get-RotationFilter {
@@ -2949,6 +2985,9 @@ function Start-Streams {
} }
throw "FFmpeg 또는 오디오 loopback worker가 실행 직후 종료되었습니다." throw "FFmpeg 또는 오디오 loopback worker가 실행 직후 종료되었습니다."
} }
$script:LastRtspHealthCheck = Get-Date
$script:RtspHealthFailures = 0
} }
function Test-StreamsRunning { function Test-StreamsRunning {
@@ -2963,6 +3002,92 @@ function Test-StreamsRunning {
return $true 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 { function Get-StreamingReadinessReason {
param($Screens, $Network, $PlaybackEndpoint, $MicrophoneEndpoint) param($Screens, $Network, $PlaybackEndpoint, $MicrophoneEndpoint)
@@ -3072,6 +3197,21 @@ function Invoke-SimulationTests {
finally { [System.IO.File]::Delete($path) } 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 "자동 기록 갱신은 사용자 설정과 주석 보존" { Invoke-SimulationCase "자동 기록 갱신은 사용자 설정과 주석 보존" {
$path = New-SimulationIni "# 사용자 주석`r`n[General]`r`nChannelCount=7`r`n" $path = New-SimulationIni "# 사용자 주석`r`n[General]`r`nChannelCount=7`r`n"
try { try {
@@ -3378,6 +3518,9 @@ function Invoke-SimulationTests {
$arguments -match '-AudioEnabled\s+[01]' -and $arguments -match '-AudioEnabled\s+[01]' -and
$arguments -match '-MicrophoneEnabled\s+[01]' -and $arguments -match '-MicrophoneEnabled\s+[01]' -and
$arguments -match '-RtspPort\s+8554' -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') $arguments -match '-WatchdogWorker')
} }
@@ -3466,6 +3609,15 @@ function Invoke-SimulationTests {
return (Test-StreamsRunning) 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 거부" { Invoke-SimulationCase "비 HTTPS 의존성 URL 거부" {
try { Assert-HttpsDependencyUrl "http://example.com/file.zip" "test"; return $false } try { Assert-HttpsDependencyUrl "http://example.com/file.zip" "test"; return $false }
catch { return $true } catch { return $true }
@@ -3619,6 +3771,11 @@ try {
if ($NicDescription) { Write-Log "고정 NIC: $NicDescription" } if ($NicDescription) { Write-Log "고정 NIC: $NicDescription" }
else { Write-Log "NIC 선택: IPv4 기본 경로 자동 감지" } else { Write-Log "NIC 선택: IPv4 기본 경로 자동 감지" }
if ($HealthTarget) { Write-Log "통신 확인 대상: $HealthTarget" } if ($HealthTarget) { Write-Log "통신 확인 대상: $HealthTarget" }
Write-Log (
"RTSP 프레임 검사: ${RtspHealthCheckIntervalSec}초 주기 / " +
"${RtspHealthTimeoutSec}초 제한 / " +
"${RtspHealthFailureThreshold}회 연속 실패 시 재시작"
)
$lastSystemSignature = "" $lastSystemSignature = ""
$healthySince = $null $healthySince = $null
@@ -3733,6 +3890,37 @@ try {
$healthySince = $null $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 (Test-StreamsRunning)) {
if (-not $healthySince) { if (-not $healthySince) {
$healthySince = Get-Date $healthySince = Get-Date