From a20055f9d30022632de4527dfa33d2db2792a152 Mon Sep 17 00:00:00 2001 From: PTah Date: Wed, 29 Apr 2026 16:23:16 +1000 Subject: [PATCH] Singleton lock: DeleteOnClose, Ctrl+C propagates, release in finally (v1.3.9) --- DEPLOY.md | 2 ++ Login_Monitor.ps1 | 65 +++++++++++++++++++++++++++++++++++++++-------- version.txt | 2 +- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/DEPLOY.md b/DEPLOY.md index bcd87a3..e9144a5 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -51,6 +51,8 @@ Watchdog регистрируется через **`schtasks.exe /Create /SC MIN Если watchdog срабатывает, а основной монитор «не поднимается», смотрите **`Logs\watchdog.log`** (сообщение о PID и предупреждение, если процесс сразу завершился) и **конец `Logs\login_monitor.log`** — частая причина (исправлено в **1.3.5+**): пустой **`$PSCommandPath`** у процесса, запущенного через **`Start-Process`**; скрипт теперь подставляет путь через **`$PSScriptRoot`**. +Один экземпляр монитора фиксируется **файлом блокировки** в **`C:\ProgramData\RDP-login-monitor\.login_monitor_single_instance.lock`** (не Global mutex): так и **SYSTEM** (задача планировщика), и **интерактивный администратор** могут корректно запускать скрипт вручную без «Отказано в доступе» к mutex (версия **1.3.6+**). + Проверка: ```powershell diff --git a/Login_Monitor.ps1 b/Login_Monitor.ps1 index 961f22b..b53859f 100644 --- a/Login_Monitor.ps1 +++ b/Login_Monitor.ps1 @@ -61,15 +61,15 @@ $script:InstallRoot = [System.IO.Path]::GetFullPath("$env:ProgramData\RDP-login- $script:CanonicalScriptName = "Login_Monitor.ps1" $script:ScheduledTaskNameMain = "RDP-Login-Monitor" $script:ScheduledTaskNameWatchdog = "RDP-Login-Monitor-Watchdog" -$script:MutexName = "Global\RDP-Login-Monitor-Singleton-v1" -$script:RdpInstanceMutex = $null +# Один экземпляр: эксклюзивная блокировка файла в InstallRoot (SYSTEM и интерактивный админ — одинаково; Global mutex давал «Отказано в доступе» между контекстами). +$script:MonitorSingletonLockStream = $null # Версия: пишется в лог и в Telegram. При доменном развёртывании через шару см. DEPLOY.md — # триггер обновления на клиентах даёт файл version.txt на шаре (его номер можно поднять и без смены # строки ниже, если правки «мелкие» и вы не хотите менять отображаемую версию в логах). # Рекомендация: при значимых релизах меняйте и $ScriptVersion, и version.txt одинаково; при только # исправлениях на шаре — достаточно поднять patch в version.txt (например 1.3.0.1). -$ScriptVersion = "1.3.5" +$ScriptVersion = "1.3.9" # Логи (все под InstallRoot) $LogFile = Join-Path $script:InstallRoot "Logs\login_monitor.log" @@ -277,11 +277,47 @@ function Invoke-RdpMonitorProcessMigrationAndRelaunch { } function Lock-RdpMonitorSingleInstance { - $mtx = New-Object System.Threading.Mutex($false, $script:MutexName) - $script:RdpInstanceMutex = $mtx - if (-not $mtx.WaitOne(0)) { - Write-Log "Экземпляр монитора уже активен (mutex). Выход без дублирования." + # Эксклюзивный поток (FileShare.None): пока монитор работает, файл нельзя удалить вручную — + # это нормально. DeleteOnClose: при любом завершении процесса ОС удалит файл при закрытии + # дескриптора (в т.ч. при «Снять задачу»), без «вечных» сирот на диске. + $lockPath = Join-Path $script:InstallRoot '.login_monitor_single_instance.lock' + try { + $script:MonitorSingletonLockStream = New-Object System.IO.FileStream( + $lockPath, + [System.IO.FileMode]::OpenOrCreate, + [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::None, + 4096, + [System.IO.FileOptions]::DeleteOnClose + ) + } catch [System.IO.IOException] { + Write-Log "Экземпляр монитора уже активен (блокировка файла). Выход без дублирования." exit 0 + } catch { + Write-Log "Не удалось занять блокировку экземпляра (${lockPath}): $($_.Exception.Message)" + exit 1 + } +} + +function Release-RdpMonitorSingletonLock { + $lockPath = Join-Path $script:InstallRoot '.login_monitor_single_instance.lock' + if ($null -ne $script:MonitorSingletonLockStream) { + try { + $script:MonitorSingletonLockStream.Close() + } catch { } + try { + $script:MonitorSingletonLockStream.Dispose() + } catch { } + $script:MonitorSingletonLockStream = $null + } + if (Test-Path -LiteralPath $lockPath) { + try { + Remove-Item -LiteralPath $lockPath -Force -ErrorAction Stop + } catch { + try { + [System.IO.File]::Delete($lockPath) + } catch { } + } } } @@ -1459,6 +1495,7 @@ function Start-LoginMonitor { $nextReportCheck = Check-AndSendDailyReport } } catch { + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } Write-Log "Ошибка цикла мониторинга: $($_.Exception.Message)" } Start-Sleep -Seconds $MonitorInterval @@ -1470,11 +1507,17 @@ try { Test-TelegramConnection | Out-Null Start-LoginMonitor -MonitorInterval 5 -MonitorInteractiveOnly } catch { - Write-Log "Критическая ошибка: $($_.Exception.Message)" - Send-StopNotification -Reason "Критическая ошибка: $($_.Exception.Message)" - $script:StopNotificationSent = $true - throw + if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { + Write-Log "Выполнение прервано (Ctrl+C / Stop-Pipeline)." + $script:StopNotificationSent = $true + } else { + Write-Log "Критическая ошибка: $($_.Exception.Message)" + Send-StopNotification -Reason "Критическая ошибка: $($_.Exception.Message)" + $script:StopNotificationSent = $true + throw + } } finally { + Release-RdpMonitorSingletonLock if (-not $script:StopNotificationSent) { Send-StopNotification -Reason "Скрипт завершил работу" } diff --git a/version.txt b/version.txt index 80e78df..d4c4950 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.3.5 +1.3.9