diff --git a/Backup.ps1 b/Backup.ps1 index e57bd8f..6352071 100644 --- a/Backup.ps1 +++ b/Backup.ps1 @@ -118,6 +118,18 @@ Write-Log ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath if (-not (Test-Administrator)) { Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN } +# 同一份备份目录同一时间只允许一个进程操作(见 Common.psm1 的「运行锁」一节)。 +# -DryRun 不取锁:它一个字节都不写,没必要被正在跑的备份挡在外面。 +$runLock = $null +if (-not $DryRun) { + $runLock = Enter-BaknretRunLock -Directory $BackupDir + if (-not $runLock) { + Write-Log ("另一次运行正在进行中(锁文件:{0},里面写明了持有者)。本次不执行。" -f (Get-BaknretRunLockPath -Directory $BackupDir)) -Level ERROR + Stop-BaknretLog + exit 1 + } + Write-Log ("已取得运行锁:{0}" -f (Get-BaknretRunLockPath -Directory $BackupDir)) -Level DEBUG +} # ============================================================================ # 准备 @@ -907,6 +919,7 @@ Write-Log $summaryText -Level INFO $logPath = Get-BaknretLogPath if ($logPath) { Write-Log "日志已写入:$logPath" -Level INFO } +Exit-BaknretRunLock -Lock $runLock Stop-BaknretLog if ($failed -gt 0) { exit 1 } diff --git a/Common.psm1 b/Common.psm1 index ad18a3e..bbc8565 100644 --- a/Common.psm1 +++ b/Common.psm1 @@ -135,6 +135,87 @@ function Write-Log { } } +# ============================================================================ +# 运行锁 +# ============================================================================ +# 为什么需要它:Backup.ps1 与 Restore.ps1 都会写 manifest.json,也都会在备份目录里生成 +# 与替换归档。计划任务与手动运行撞在一起时,两边会互相覆盖对方的账本;更糟的是两边会 +# 用同一个 <归档>.tmp 名字,把彼此的临时归档当成自己的。 +# +# 用**独占文件句柄**(FileShare.None)而不是命名互斥体: +# * 句柄由内核持有,进程被杀 / 崩溃时自动关闭,锁自动释放 —— 不会留下需要人工清理的 +# 陈旧锁;命名互斥体要跨会话(计划任务在另一个会话里跑)还得用 Global\ 前缀, +# 而那需要额外权限。 +# * 它是文件系统的锁:不区分会话、不区分终端,计划任务与手动运行会互相看见。 + +function Get-BaknretRunLockPath { + <# .SYNOPSIS 运行锁文件的位置(放在备份目录里,与它保护的账本同处)。 #> + param([Parameter(Mandatory = $true)][string]$Directory) + return (Join-Path $Directory '.baknret.lock') +} + +function Enter-BaknretRunLock { + <# + .SYNOPSIS + 取得"同一份备份目录同一时间只允许一个进程操作"的锁;拿不到时返回 $null。 + + .DESCRIPTION + 拿不到就直接返回 $null 交给调用方明确失败,**不等待**:单个条目压缩可能十几分钟, + "等它跑完"对用户来说和挂住没区别,不如直接说清楚是谁占着。 + + 返回的是一个已打开的文件流。**不需要刻意释放**:进程退出(含 exit)时句柄由系统 + 关闭,锁随之释放。调用方仍应显式调 Exit-BaknretRunLock,让锁的覆盖范围一眼可见。 + #> + param([Parameter(Mandatory = $true)][string]$Directory) + + if (-not (Test-Path -LiteralPath $Directory)) { + New-Item -ItemType Directory -Path $Directory -Force | Out-Null + } + + $path = Get-BaknretRunLockPath -Directory $Directory + try { + $stream = [System.IO.File]::Open( + $path, + [System.IO.FileMode]::OpenOrCreate, + [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::None) + } catch { + # 不能只写 `catch [System.IO.IOException]`:PowerShell 会把 .NET 方法抛出的异常包成 + # MethodInvocationException,按内层类型做的 catch 接不住,于是锁被占用时会直接抛出去, + # 而不是按约定返回 $null 让调用方明确失败(实测踩到,被自己的断言逮住)。 + # 这里沿 InnerException 链找那个 IOException;不是它就把原异常抛回去(例如目录不可写 + # 是 UnauthorizedAccessException,那是真错误,不该伪装成"另一次运行在进行中")。 + $inner = $_.Exception + while ($inner -and $inner -isnot [System.IO.IOException]) { $inner = $inner.InnerException } + if (-not $inner) { throw } + + Write-Log ("运行锁不可用({0}):{1}" -f $inner.GetType().Name, $inner.Message) -Level DEBUG + return $null + } + + # 写点线索进去:"到底是谁占着"这个问题不该靠猜 + $info = 'pid={0}; started={1:o}; host={2}; user={3}' -f $PID, (Get-Date), $env:COMPUTERNAME, $env:USERNAME + $bytes = [System.Text.Encoding]::UTF8.GetBytes($info) + $stream.SetLength(0) + $stream.Write($bytes, 0, $bytes.Length) + $stream.Flush() + return $stream +} + +function Exit-BaknretRunLock { + <# + .SYNOPSIS + 释放运行锁。锁文件本身留着 —— 它的内容是最后一次持有者的线索,删不删都无所谓。 + #> + param($Lock) + + if (-not $Lock) { return } + try { + $Lock.Dispose() + } catch { + Write-Log "释放运行锁失败(进程退出时会自动释放):$_" -Level WARN + } +} # ============================================================================ # 环境 # ============================================================================ @@ -3191,6 +3272,9 @@ function Get-BaknretPassword { } Export-ModuleMember -Function @( + 'Enter-BaknretRunLock', + 'Exit-BaknretRunLock', + 'Get-BaknretRunLockPath', 'Set-BaknretDebug', 'Start-BaknretLog', 'Stop-BaknretLog', 'Get-BaknretLogPath', 'Write-Log', 'Test-Administrator', 'Get-BaknretFreeSpaceGB', 'ConvertTo-NativeArgumentString', 'Invoke-ExternalCommand', 'Resolve-CompressionTool', 'Get-Optimized7zArgument', diff --git a/Restore.ps1 b/Restore.ps1 index 05848c2..f823d51 100644 --- a/Restore.ps1 +++ b/Restore.ps1 @@ -109,6 +109,18 @@ if ($WhatIfPreference) { Write-Log '试运行模式(-WhatIf / -DryRun):不 if (-not (Test-Administrator)) { Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN } +# 同一份备份目录同一时间只允许一个进程操作(见 Common.psm1 的「运行锁」一节)。 +# 三种只读模式不取锁:它们一个字节都不写,没必要被正在跑的备份挡在外面。 +$runLock = $null +if (-not $WhatIfPreference -and -not $VerifyOnly) { + $runLock = Enter-BaknretRunLock -Directory $BackupDir + if (-not $runLock) { + Write-Log ("另一次运行正在进行中(锁文件:{0},里面写明了持有者)。本次不执行。" -f (Get-BaknretRunLockPath -Directory $BackupDir)) -Level ERROR + Stop-BaknretLog + exit 1 + } + Write-Log ("已取得运行锁:{0}" -f (Get-BaknretRunLockPath -Directory $BackupDir)) -Level DEBUG +} $passwordFile = if ($KeyFile) { $KeyFile } else { $script:Config.Encryption.PasswordFile } $password = Get-BaknretPassword -PasswordFile $passwordFile @@ -853,6 +865,7 @@ Write-Log $summaryText -Level INFO $logPath = Get-BaknretLogPath if ($logPath) { Write-Log "日志已写入:$logPath" -Level INFO } +Exit-BaknretRunLock -Lock $runLock Stop-BaknretLog if ($stats.failed -gt 0) { exit 1 } diff --git a/tests/BakNRet.Tests.ps1 b/tests/BakNRet.Tests.ps1 index 47e650f..54d40ed 100644 --- a/tests/BakNRet.Tests.ps1 +++ b/tests/BakNRet.Tests.ps1 @@ -1139,6 +1139,47 @@ Describe '外部命令退出码(旧实现的核心缺陷)' { Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue } + It '运行锁:同一份备份目录同时只能有一个持有者' { + # Backup.ps1 与 Restore.ps1 都会写 manifest.json,也都会用 <归档>.tmp 这个名字; + # 计划任务与手动运行撞在一起时,两边会互相覆盖对方的账本、抢对方的临时归档。 + $dir = Join-Path $env:TEMP ("bnr-lock-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) + $first = $null + $second = $null + try { + $first = Enter-BaknretRunLock -Directory $dir + $first | Should -Not -BeNullOrEmpty + + $second = Enter-BaknretRunLock -Directory $dir + $second | Should -BeNullOrEmpty + + Test-Path -LiteralPath (Get-BaknretRunLockPath -Directory $dir) | Should -BeTrue + } finally { + Exit-BaknretRunLock -Lock $second + Exit-BaknretRunLock -Lock $first + } + + # 锁文件是独占打开的(FileShare.None),内容只能在释放之后读 + (Get-Content -Encoding UTF8 -LiteralPath (Get-BaknretRunLockPath -Directory $dir) -Raw) | + Should -Match "pid=$PID" + + Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue + } + + It '运行锁:释放之后可以重新取得' { + $dir = Join-Path $env:TEMP ("bnr-lock-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) + try { + $first = Enter-BaknretRunLock -Directory $dir + $first | Should -Not -BeNullOrEmpty + Exit-BaknretRunLock -Lock $first + + $second = Enter-BaknretRunLock -Directory $dir + $second | Should -Not -BeNullOrEmpty + Exit-BaknretRunLock -Lock $second + } finally { + Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue + } + } + It '空目录的摘要给 0 而不是 $null(否则空间守卫会静默失效)' { # Measure-Object 对空输入返回 $null 而不是 0,于是 .Sum 也是 $null;而 $null / 1GB # 得 0,空间守卫判的是 -gt 0 —— 这一档就不再拦截了。 diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index 2120272..d0c4211 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -1268,6 +1268,52 @@ Test-Case '原子替换:成功时新内容到位且不留 .tmp;失败时旧 } } +# ============================================================================ +Write-Host "`n== 运行锁 ==" -ForegroundColor Cyan +# ============================================================================ + +Test-Case '运行锁:同一份备份目录同时只能有一个持有者' { + # Backup.ps1 与 Restore.ps1 都会写 manifest.json,也都会用 <归档>.tmp 这个名字; + # 计划任务与手动运行撞在一起时,两边会互相覆盖对方的账本、抢对方的临时归档。 + $dir = Join-Path $env:TEMP ("bnr-lock-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) + $first = $null + $second = $null + try { + $first = Enter-BaknretRunLock -Directory $dir + Assert-True ($null -ne $first) '第一次应当拿到锁' + + $second = Enter-BaknretRunLock -Directory $dir + Assert-True ($null -eq $second) '同一目录的第二次不应当拿到锁' + + Assert-FileExists (Get-BaknretRunLockPath -Directory $dir) + } finally { + Exit-BaknretRunLock -Lock $second + Exit-BaknretRunLock -Lock $first + } + + # 锁文件是**独占**打开的(FileShare.None),内容只能在释放之后读 —— 而它本来就该 + # 留在那里给排查的人看:"到底是谁占着"这个问题不该靠猜。 + $info = Get-Content -Encoding UTF8 -LiteralPath (Get-BaknretRunLockPath -Directory $dir) -Raw + Assert-True ($info -match "pid=$PID") "锁文件里应当写明持有进程,实际内容是:$info" + + Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue +} + +Test-Case '运行锁:释放之后可以重新取得' { + $dir = Join-Path $env:TEMP ("bnr-lock-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) + try { + $first = Enter-BaknretRunLock -Directory $dir + Assert-True ($null -ne $first) '第一次应当拿到锁' + Exit-BaknretRunLock -Lock $first + + $second = Enter-BaknretRunLock -Directory $dir + Assert-True ($null -ne $second) '释放之后应当能重新拿到锁' + Exit-BaknretRunLock -Lock $second + } finally { + Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue + } +} + # ============================================================================ Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue