Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions architecture/notes/packaging/2026-09-23-same-version-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Same-version repair is not identified by a changed main binary

## Status

Implemented; pending review.

## Context

[Issue #258](https://github.com/Kuddev/pebrel/issues/258) supplies an Inno log
reporting successful installation and the expected Pebrel version, while the
update helper reports that the same-version binary did not change.

## Evidence

The reported preceding update replaced the main executable but failed with a
locked Hook helper. Reinstalling the same package repairs other files while
leaving the main executable byte-identical. Its hash cannot distinguish that
successful repair from an installation which did no useful work.

## Decision

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

- Suppressing every installer error would hide partial/failed installations.
- Forcing a main-binary byte difference does not validate the repaired files.
- Disabling same-version retries prevents recovery after a partial upgrade.

## Consequences

Installer success and the expected application version determine success for a
verified package. The updater does not independently audit every installed file;
installer errors continue to fail the transaction. No installed version tag or
asset naming rule changes.

## Validation

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

The same-version changed-hash assertion in the handoff helper and its old fixture.

## Revisit when

A versioned installed-file manifest provides a stronger package-completeness
check without confusing unchanged files with failed installation.
31 changes: 28 additions & 3 deletions nebula_app/src/update_download/handoff.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 + '"'
Expand All @@ -144,9 +169,9 @@ try {
throw 'Installed application did not report the expected version'
}
$installedDigest = (Get-FileHash -LiteralPath $exe -Algorithm SHA256).Hash
if ($plan.version -eq $plan.original_version -and $installedDigest -eq $originalDigest) {
throw 'Same-version installation did not replace the application binary'
}
# A repair can replace a previously locked helper while leaving pebrel.exe
# byte-identical. Setup success plus the expected version is authoritative;
# a changed main-executable hash is not a same-version success requirement.
Write-State 'result.json' @{
transaction = $plan.transaction; success = $true; version = $plan.version
executable_sha256 = $installedDigest
Expand Down
6 changes: 5 additions & 1 deletion scripts/tests/fixtures/update_process.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ static int Main(string[] args) {
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);
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") {
Expand Down
35 changes: 28 additions & 7 deletions scripts/tests/test_update_handoff.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -36,25 +36,33 @@ function Write-Json([string]$Path, $Value) {
}

$results = @()
foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'installer-failure', 'other-process', 'late-process', 'reinstall', 'reinstall-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'
$config = Join-Path $directory 'config'
$null = New-Item -ItemType Directory -Path $installation, $transaction, $config -Force
$executable = Join-Path $installation 'pebrel.exe'
Copy-Item -LiteralPath $old -Destination $executable
$payload = if ($scenario -eq 'reinstall') { $reinstall } else { $candidate }
$payload = if ($scenario -eq 'reinstall') { $reinstall } elseif ($scenario -eq 'repair-identical') { $old } else { $candidate }
Copy-Item -LiteralPath $payload -Destination (Join-Path $directory 'candidate.exe')
[System.IO.File]::WriteAllText((Join-Path $installation 'unins000.exe'), 'fixture marker')
$env:PEBREL_HANDOFF_FIXTURE = $directory
$env:PEBREL_HANDOFF_FAIL_INSTALL = if ($scenario -eq 'installer-failure') { '1' } else { '0' }
$env:PEBREL_HANDOFF_NOOP_INSTALL = if ($scenario -eq 'reinstall-noop') { '1' } else { '0' }
$env:PEBREL_HANDOFF_NOOP_INSTALL = if ($scenario -eq 'upgrade-noop') { '1' } else { '0' }
$parent = Start-Process -FilePath $executable -ArgumentList 'wait' -PassThru
$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
Expand All @@ -64,7 +72,7 @@ foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'insta
participants = @(@{ pid = $parent.Id; created = $parent.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() })
}
if ($scenario -eq 'checksum') { $plan.sha256 = '0' * 64 }
if ($scenario -in @('reinstall', 'reinstall-noop')) { $plan.version = '1.8.0' }
if ($scenario -in @('reinstall', 'repair-identical')) { $plan.version = '1.8.0' }
if ($scenario -eq 'creation-time') { $plan.participants[0].created = '1' }
if ($scenario -eq 'other-process') {
$other = Start-Process -FilePath $executable -ArgumentList 'wait-other' -PassThru
Expand Down Expand Up @@ -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', 'reinstall-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'
Expand All @@ -117,24 +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'))) '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', 'reinstall-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')
Expand Down
Loading