fix: Backup.ps1 与 Restore.ps1 并发撞车时互踩账本(加运行锁)
问题:两个入口都会写 manifest.json,也都会在备份目录里用 <归档>.tmp 这个名字生成临时
归档。计划任务与手动运行撞在一起时,两边会互相覆盖对方的账本;更糟的是两边会把彼此的临时
归档当成自己的。计划任务的 -MultipleInstances IgnoreNew 只挡住"计划任务之间",挡不住手动运行。
修法:备份目录上的一把跨进程锁,用**独占文件句柄**(FileShare.None)而不是命名互斥体:
* 句柄由内核持有,进程被杀 / 崩溃时自动关闭,锁自动释放 —— 不会留下需要人工清理的陈旧锁;
命名互斥体要跨会话(计划任务在另一个会话里跑)还得用 Global\ 前缀,那需要额外权限。
* 它是文件系统的锁:不区分会话、不区分终端,计划任务与手动运行会互相看见。
* 锁文件里写明持有进程(pid / 起始时间 / 主机 / 用户)—— "到底是谁占着"不该靠猜。
拿不到锁就直接失败(退出码 1 + 明确消息),不等待:单个条目压缩可能十几分钟,"等它跑完"
对用户来说和挂住没区别。
只读模式不取锁(Backup 的 -DryRun;Restore 的 -DryRun / -WhatIf / -VerifyOnly):
它们一个字节都不写,没必要被正在跑的备份挡在外面。
踩到并记下的两个坑:
1) catch [System.IO.IOException] 接不住 —— PowerShell 把 .NET 方法抛出的异常包成
MethodInvocationException,按内层类型做的 catch 会漏。现在沿 InnerException 链找,
不是 IOException 就把原异常抛回去(目录不可写是 UnauthorizedAccessException,那是真
错误,不该伪装成"另一次运行在进行中")。
2) 锁文件是独占打开的,所以内容只能在**释放之后**读 —— 第一版断言在持锁时去 Get-Content,
被自己的锁拒了;这条断言现在挪到释放之后。
跨进程证据(真跑,不是推理):父进程持锁 → 另一个进程取锁得到 DENIED;持锁状态下跑
真实的 Backup.ps1 → 退出码 1、日志点名锁文件、manifest 的 SHA256 未变;释放后另一进程
得到 GOT。
验收:test.ps1 9/9 全绿(7 与 5.1);tests\Run-RealSmoke.ps1 4/4 全绿。
This commit is contained in:
1 parent
79f83f6760
commit
8d67a38fb7
5 files changed
+197
No files matched your search
+13
@@ -118,6 +118,18 @@ Write-Log ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath
|
|||||||
if (-not (Test-Administrator)) {
|
if (-not (Test-Administrator)) {
|
||||||
Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
|
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
|
$logPath = Get-BaknretLogPath
|
||||||
if ($logPath) { Write-Log "日志已写入:$logPath" -Level INFO }
|
if ($logPath) { Write-Log "日志已写入:$logPath" -Level INFO }
|
||||||
|
Exit-BaknretRunLock -Lock $runLock
|
||||||
Stop-BaknretLog
|
Stop-BaknretLog
|
||||||
|
|
||||||
if ($failed -gt 0) { exit 1 }
|
if ($failed -gt 0) { exit 1 }
|
||||||
|
|||||||
+84
@@ -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 @(
|
Export-ModuleMember -Function @(
|
||||||
|
'Enter-BaknretRunLock',
|
||||||
|
'Exit-BaknretRunLock',
|
||||||
|
'Get-BaknretRunLockPath',
|
||||||
'Set-BaknretDebug', 'Start-BaknretLog', 'Stop-BaknretLog', 'Get-BaknretLogPath', 'Write-Log',
|
'Set-BaknretDebug', 'Start-BaknretLog', 'Stop-BaknretLog', 'Get-BaknretLogPath', 'Write-Log',
|
||||||
'Test-Administrator', 'Get-BaknretFreeSpaceGB',
|
'Test-Administrator', 'Get-BaknretFreeSpaceGB',
|
||||||
'ConvertTo-NativeArgumentString', 'Invoke-ExternalCommand', 'Resolve-CompressionTool', 'Get-Optimized7zArgument',
|
'ConvertTo-NativeArgumentString', 'Invoke-ExternalCommand', 'Resolve-CompressionTool', 'Get-Optimized7zArgument',
|
||||||
|
|||||||
+13
@@ -109,6 +109,18 @@ if ($WhatIfPreference) { Write-Log '试运行模式(-WhatIf / -DryRun):不
|
|||||||
if (-not (Test-Administrator)) {
|
if (-not (Test-Administrator)) {
|
||||||
Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
|
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 }
|
$passwordFile = if ($KeyFile) { $KeyFile } else { $script:Config.Encryption.PasswordFile }
|
||||||
$password = Get-BaknretPassword -PasswordFile $passwordFile
|
$password = Get-BaknretPassword -PasswordFile $passwordFile
|
||||||
@@ -853,6 +865,7 @@ Write-Log $summaryText -Level INFO
|
|||||||
|
|
||||||
$logPath = Get-BaknretLogPath
|
$logPath = Get-BaknretLogPath
|
||||||
if ($logPath) { Write-Log "日志已写入:$logPath" -Level INFO }
|
if ($logPath) { Write-Log "日志已写入:$logPath" -Level INFO }
|
||||||
|
Exit-BaknretRunLock -Lock $runLock
|
||||||
Stop-BaknretLog
|
Stop-BaknretLog
|
||||||
|
|
||||||
if ($stats.failed -gt 0) { exit 1 }
|
if ($stats.failed -gt 0) { exit 1 }
|
||||||
|
|||||||
@@ -1139,6 +1139,47 @@ Describe '外部命令退出码(旧实现的核心缺陷)' {
|
|||||||
Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue
|
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(否则空间守卫会静默失效)' {
|
It '空目录的摘要给 0 而不是 $null(否则空间守卫会静默失效)' {
|
||||||
# Measure-Object 对空输入返回 $null 而不是 0,于是 .Sum 也是 $null;而 $null / 1GB
|
# Measure-Object 对空输入返回 $null 而不是 0,于是 .Sum 也是 $null;而 $null / 1GB
|
||||||
# 得 0,空间守卫判的是 -gt 0 —— 这一档就不再拦截了。
|
# 得 0,空间守卫判的是 -gt 0 —— 这一档就不再拦截了。
|
||||||
|
|||||||
@@ -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 $sandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
|||||||
Reference in new issue
Block a user