From 0fca5833047dd19e4168873e4fe44734c8c5e3d9 Mon Sep 17 00:00:00 2001 From: kuddev Date: Wed, 23 Sep 2026 14:34:43 +0800 Subject: [PATCH] fix(updater): await helper file locks before starting setup --- .../2026-09-23-same-version-repair.md | 7 ++++- nebula_app/src/update_download/handoff.ps1 | 25 +++++++++++++++++ scripts/tests/fixtures/update_process.cs | 8 ++++-- scripts/tests/test_update_handoff.ps1 | 28 ++++++++++++++++--- 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/architecture/notes/packaging/2026-09-23-same-version-repair.md b/architecture/notes/packaging/2026-09-23-same-version-repair.md index ba603f0..3d1de6c 100644 --- a/architecture/notes/packaging/2026-09-23-same-version-repair.md +++ b/architecture/notes/packaging/2026-09-23-same-version-repair.md @@ -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 diff --git a/nebula_app/src/update_download/handoff.ps1 b/nebula_app/src/update_download/handoff.ps1 index ccbdc63..7432de5 100644 --- a/nebula_app/src/update_download/handoff.ps1 +++ b/nebula_app/src/update_download/handoff.ps1 @@ -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 + '"' diff --git a/scripts/tests/fixtures/update_process.cs b/scripts/tests/fixtures/update_process.cs index 4bdc702..606469e 100644 --- a/scripts/tests/fixtures/update_process.cs +++ b/scripts/tests/fixtures/update_process.cs @@ -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") { diff --git a/scripts/tests/test_update_handoff.ps1 b/scripts/tests/test_update_handoff.ps1 index 2e05324..e92a31d 100644 --- a/scripts/tests/test_update_handoff.ps1 +++ b/scripts/tests/test_update_handoff.ps1 @@ -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')