fix(updater): await helper file locks before starting setup

This commit is contained in:
kuddev committed 2026-09-23 14:34:43 +08:00
1 parent df34a7d91a
commit 0fca583304
4 files changed
+60 -8

No files matched your search

@@ -23,6 +23,9 @@ Retain package SHA-256/size checks, commit authority, participant identity and
exit waiting, installer exit-code checks, and the installed version probe.
Remove the additional requirement that a same-version main binary change hash.
Record the installed hash as evidence, not as a repair-success predicate.
Before starting setup, retry exclusive write access to installed Hook helper files
for five seconds. Short-lived forwarding can finish; persistent locks fail before
any installation file is replaced. The helper never kills unrelated processes.
## Rejected alternatives
@@ -39,11 +42,13 @@ asset naming rule changes.
## Validation
The native handoff suite passes ten scenarios, including a byte-identical main
The native handoff suite passes twelve scenarios, including a byte-identical main
binary with a repaired helper. The former same-version/no-change assertion was
incorrect; its negative intent is retained as `upgrade-noop`, which returns
installer success but leaves the wrong application version and must still fail.
Cancellation, checksum/identity failures and unprepared processes remain covered.
A released helper-file lock permits installation; a held lock must prevent setup
from starting and recover the unchanged original application.
## Supersedes
@@ -67,6 +67,30 @@ function Check-UnpreparedProcesses {
}
}
function Wait-RuntimeHelperFiles {
# New helpers have a bounded lifetime, but can still be draining as the
# application exits. Check before setup copies any file. Old stuck helpers
# remain a visible failure; never terminate processes by their image name.
$deadline = [DateTime]::UtcNow.AddSeconds(5)
$helpers = @('runtime\pebrel-hook.exe', 'pebrel-hook.exe',
'runtime\nebula-hook.exe', 'nebula-hook.exe')
while ($true) {
$busy = $null
foreach ($relative in $helpers) {
$path = Join-Path $installation $relative
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { continue }
try {
$probe = [System.IO.FileStream]::new($path, [System.IO.FileMode]::Open,
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
$probe.Dispose()
} catch { $busy = $_.Exception }
}
if (-not $busy) { return }
if ([DateTime]::UtcNow -ge $deadline) { throw $busy }
Start-Sleep -Milliseconds 100
}
}
try {
if ((Get-Item -LiteralPath $PlanPath).Length -gt 1048576) { throw 'Update plan exceeds limit' }
$plan = Get-Content -LiteralPath $PlanPath -Raw -Encoding UTF8 | ConvertFrom-Json
@@ -133,6 +157,7 @@ try {
Check-UnpreparedProcesses
# No Restart Manager process-name shutdown: every participant has already
# saved and exited. DIR reuses this validated installation without a chooser.
Wait-RuntimeHelperFiles
$setupLog = Join-Path $transaction 'installer.log'
$arguments = '/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /NOCLOSEAPPLICATIONS /NORESTARTAPPLICATIONS' +
' /DIR="' + $installation + '" /LOG="' + $setupLog + '"'
+5 -3
View File
@@ -17,9 +17,11 @@ class UpdateProcess {
if (!File.Exists(Path.Combine(directory, "parent-exited"))) return 3;
if (Environment.GetEnvironmentVariable("PEBREL_HANDOFF_FAIL_INSTALL") == "1") return 17;
if (Environment.GetEnvironmentVariable("PEBREL_HANDOFF_NOOP_INSTALL") == "1") return 0;
File.Copy(Path.Combine(directory, "candidate.exe"), Path.Combine(target, "pebrel.exe"), true);
Directory.CreateDirectory(Path.Combine(target, "runtime"));
File.WriteAllText(Path.Combine(target, "runtime", "pebrel-hook.exe"), "repaired helper fixture");
try {
File.Copy(Path.Combine(directory, "candidate.exe"), Path.Combine(target, "pebrel.exe"), true);
Directory.CreateDirectory(Path.Combine(target, "runtime"));
File.WriteAllText(Path.Combine(target, "runtime", "pebrel-hook.exe"), "repaired helper fixture");
} catch (IOException) { return 5; }
return 0;
#else
if (args.Length > 0 && args[0] == "--version") {
+24 -4
View File
@@ -36,7 +36,7 @@ function Write-Json([string]$Path, $Value) {
}
$results = @()
foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'installer-failure', 'other-process', 'late-process', 'reinstall', 'repair-identical', 'upgrade-noop')) {
foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'installer-failure', 'other-process', 'late-process', 'reinstall', 'repair-identical', 'upgrade-noop', 'helper-unlocked', 'helper-held')) {
$directory = Join-Path $OutputRoot $scenario
$installation = Join-Path $directory 'installed app'
$transaction = Join-Path $directory 'transaction'
@@ -54,7 +54,15 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
$null = $parent.Handle
$runner = $null
$other = $null
$helperLock = $null
try {
if ($scenario -in @('helper-unlocked', 'helper-held')) {
$null = New-Item -ItemType Directory -Path (Join-Path $installation 'runtime') -Force
$helperPath = Join-Path $installation 'runtime\pebrel-hook.exe'
[System.IO.File]::WriteAllText($helperPath, 'old helper fixture')
$helperLock = [System.IO.FileStream]::new($helperPath, [System.IO.FileMode]::Open,
[System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
}
$plan = @{
schema = 1; transaction = $scenario; executable = $executable; installation = $installation
config_directory = $config; installer = $installer
@@ -104,8 +112,14 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup ran while the old process was alive'
[System.IO.File]::WriteAllText((Join-Path $directory 'exit-parent'), 'exit')
Assert ($parent.WaitForExit(5000)) 'Fixture parent did not exit'
if ($scenario -eq 'helper-unlocked') {
Start-Sleep -Milliseconds 350
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup started with the helper still locked'
$helperLock.Dispose()
$helperLock = $null
}
Wait-File $resultPath
if ($scenario -in @('success', 'installer-failure', 'reinstall', 'repair-identical', 'upgrade-noop')) {
if ($scenario -in @('success', 'installer-failure', 'reinstall', 'repair-identical', 'upgrade-noop', 'helper-unlocked', 'helper-held')) {
Wait-File (Join-Path $directory 'new-launch')
$launch = [System.IO.File]::ReadAllLines((Join-Path $directory 'new-launch'))
Assert ($launch[0] -eq $executable) 'Relaunch selected another installation'
@@ -117,25 +131,31 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
}
Assert ($runner.WaitForExit(5000)) 'Helper did not finish'
$result = Get-Content -LiteralPath $resultPath -Raw -Encoding UTF8 | ConvertFrom-Json
Assert ($result.success -eq ($scenario -in @('success', 'reinstall', 'repair-identical'))) 'Unexpected helper outcome'
Assert ($result.success -eq ($scenario -in @('success', 'reinstall', 'repair-identical', 'helper-unlocked'))) 'Unexpected helper outcome'
if ($result.success) {
$expected = (Get-FileHash -LiteralPath $payload -Algorithm SHA256).Hash
Assert ($result.executable_sha256 -eq $expected) 'Installed binary differs from candidate'
Assert (Test-Path -LiteralPath (Join-Path $installation 'runtime\pebrel-hook.exe')) 'Successful setup did not repair the helper'
Assert ([System.IO.File]::ReadAllText((Join-Path $installation 'runtime\pebrel-hook.exe')) -eq 'repaired helper fixture') 'Helper still contains the original payload'
}
if ($scenario -in @('other-process', 'late-process')) {
Assert (-not $other.HasExited) 'Update stopped an unprepared process'
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup ran with an unprepared process'
}
if ($scenario -in @('installer-failure', 'upgrade-noop')) {
if ($scenario -in @('installer-failure', 'upgrade-noop', 'helper-held')) {
Assert $result.recovered_original 'Untouched old executable was not recovered'
}
if ($scenario -eq 'helper-held') {
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'A blocked helper allowed a partial installation'
Assert ($result.error -match 'pebrel-hook.exe') 'The blocked file is missing from the failure details'
}
$probe = [System.IO.FileStream]::new($plan.guard_path, [System.IO.FileMode]::OpenOrCreate,
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
$probe.Dispose()
$results += @{ scenario = $scenario; passed = $true }
Write-Output "$scenario : passed"
} finally {
if ($helperLock) { $helperLock.Dispose() }
if ($runner -and -not $runner.HasExited) { $runner.Kill(); $runner.WaitForExit() }
if (-not $parent.HasExited) {
[System.IO.File]::WriteAllText((Join-Path $directory 'exit-parent'), 'exit')