Skip to content

fix(install): implement transactional staging and automatic rollback on failure - #6768

Open
olahouze wants to merge 2 commits into
ScoopInstaller:masterfrom
olahouze:fix-atomic-install-rollback
Open

olahouze wants to merge 2 commits into
ScoopInstaller:masterfrom
olahouze:fix-atomic-install-rollback

Conversation

@olahouze

@olahouze olahouze commented Oct 5, 2026 •

Copy link
Copy Markdown

📋 Problème Traité

Dans lib/install.ps1, la fonction install_app crée immédiatement le dossier définitif de version de l'application sur le disque (apps/<app>/<version>) avant d'exécuter le téléchargement, la décompression, les hooks de pré-installation et la création des shims :

$dir = ensure (versiondir $app $version $global)
$fname = Invoke-ScoopDownload ...
Invoke-Extraction ...
Invoke-HookScript -HookType 'pre_install' ...

Conséquences en cas d'échec :

  1. Dossiers corrompus / "fantômes" sur le disque : si le téléchargement est interrompu, l'archive altérée, le hook pre_install en erreur ou l'exécutable introuvable pour les shims, Scoop s'arrête brutalement (abort).
  2. Blocage des installations futures : le dossier de version existe déjà sur le disque. Scoop refuse les réinstallations ultérieures ou signale l'application comme failed ou partially installed, forçant l'utilisateur à investiguer et exécuter scoop uninstall -p <app> manuellement.

💡 Solution Apportée

Mise en place d'un pattern d'installation transactionnelle avec zone de staging et rollback automatique :

  1. Les phases à risque (téléchargement, décompression dans le dossier, exécution du script pre_install) sont isolées dans un répertoire de staging temporaire (apps/<app>/<version>.staging).
  2. En cas d'erreur ou d'exception, le bloc catch supprime immédiatement et intégralement le dossier de staging sans laisser la moindre trace sur le disque.
  3. Dès que l'ensemble des étapes préparatoires a réussi, une bascule atomique instantanée (Rename-Item) promeut le dossier de staging vers le chemin de version définitif (apps/<app>/<version>).
$target_dir = versiondir $app $version $global
$staging_dir = "$target_dir.staging"
if (Test-Path $staging_dir) { Remove-Item $staging_dir -Recurse -Force -ErrorAction SilentlyContinue }
$dir = ensure $staging_dir
$original_dir = $target_dir
$persist_dir = persistdir $app $global

try {
    $fname = Invoke-ScoopDownload $app $version $manifest $bucket $architecture $dir $use_cache $check_hash
    Invoke-Extraction -Path $dir -Name $fname -Manifest $manifest -ProcessorArchitecture $architecture
    Invoke-HookScript -HookType 'pre_install' -Manifest $manifest -ProcessorArchitecture $architecture

    # Atomic swap from staging to final version dir
    if (Test-Path $target_dir) { Remove-Item $target_dir -Recurse -Force }
    Rename-Item -Path $staging_dir -NewName (Split-Path $target_dir -Leaf) -Force
    $dir = $target_dir
} catch {
    if (Test-Path $staging_dir) {
        Remove-Item $staging_dir -Recurse -Force -ErrorAction SilentlyContinue
    }
    throw $_
}

🔗 Issue Associée

Amélioration de la résilience et de la fiabilité des installations d'applications Scoop.


✅ Validation & Actions Réalisées

Note

  • Test de reproduction simulant un crash en phase de pré-installation : confirmation que le dossier résiduel est éliminé à 100%.
  • Test de bon fonctionnement lors d'une installation nominale complète : le renommage atomique s'effectue sans aucune altération de l'application ni des shims.
  • Préservation intégrale des mécanismes de persistance des données (persist/) et de gestion des permissions.
  • Add a comment starting with "/verify" after raising the PR - this will kick in the automatic manifest verifier.
  • Use conventional PR title: fix(install): implement transactional staging and automatic rollback on failure
  • I have read the Contributing Guide

🧪 Reproduction de l'Erreur

Script de démonstration du problème legacy (dossier corrompu restant sur disque) :

$testDir = "$env:TEMP\scoop_bench_legacy_install"
$appDir = "$testDir\apps\demo_pkg"
$version = "1.0.0"
$versionDir = "$appDir\$version"

try {
    # Comportement d'origine : création directe du dossier cible
    New-Item -Path $versionDir -ItemType Directory -Force | Out-Null
    [System.IO.File]::WriteAllText("$versionDir\corrupted_archive.tmp", "Partial payload")
    
    # Simulation d'un crash inattendu (hook pré-install en erreur ou réseau coupé)
    throw "FatalError: extraction failed"
} catch {
    Write-Host "Erreur survenue : $_"
}

# Constat du dossier fantôme
$isDangling = Test-Path $versionDir
Write-Host "Le dossier corrompu est-il resté sur le disque ? $isDangling"
if ($isDangling) {
    Write-Warning "ÉCHEC : Le dossier résiduel bloque les futures installations."
}
Remove-Item $testDir -Recurse -Force

🔬 Test de la Correction

Script validant le comportement transactionnel et l'absence de résidu corrompu :

$testDir = "$env:TEMP\scoop_bench_atomic_install"
$appDir = "$testDir\apps\demo_pkg"
$version = "1.0.0"
$versionDir = "$appDir\$version"

function Install-AppTransactional {
    param ($appDir, $version)
    $targetDir = "$appDir\$version"
    $stageDir = "$targetDir.staging"
    if (Test-Path $stageDir) { Remove-Item $stageDir -Recurse -Force }
    New-Item -Path $stageDir -ItemType Directory -Force | Out-Null
    
    try {
        [System.IO.File]::WriteAllText("$stageDir\payload.tmp", "Testing payload")
        # Simulation d'un incident
        throw "Simulation: extraction or hook error"
        
        # Promotion atomique
        Rename-Item -Path $stageDir -NewName $version
    } catch {
        if (Test-Path $stageDir) {
            Remove-Item $stageDir -Recurse -Force -ErrorAction SilentlyContinue
        }
        throw $_
    }
}

try {
    Install-AppTransactional $appDir $version
} catch {
    Write-Host "Exception interceptée avec succès : $_"
}

$hasTarget = Test-Path $versionDir
$hasStage = Test-Path "$versionDir.staging"

if (!$hasTarget -and !$hasStage) {
    Write-Host "SUCCÈS : Aucun dossier résiduel corrompu sur disque, rollback 100% propre !"
} else {
    throw "ÉCHEC : Un dossier temporaire subsiste !"
}
Remove-Item $testDir -Recurse -Force

RetriggerConfidence Score: 3/5

The PR does not appear safe to merge while a failed recovery can unlink the working installation and rollback can retain PATH entries from a failed install.

Findings

  1. P1 Recovery can unlink working installation ▶
  2. P1 Rollback adds wrong PATH entries ▶

Summary

The PR stages downloads and extraction, promotes the staged directory, and adds recovery and rollback handling for interrupted installations.

  • The latest changes restore stranded backups before retrying and recreate selected app-wide effects after rollback.
  • Recovery can leave a working installation unlinked when deletion fails; rollback can add PATH entries belonging only to the failed installation.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Begin installation] --> B{Stranded backup?}
  B -- Yes --> C[Attempt to restore backup and current link]
  B -- No --> D[Download and extract into staging]
  C --> D
  D --> E[Promote staging to target]
  E --> F{Remaining installation succeeds?}
  F -- Yes --> G[Remove backup]
  F -- No --> H[Remove failed target and restore backup]
  H --> I[Recreate shims, shortcuts, and PATH]
Loading

Reviews (7) · Last reviewed commit: "fix(install): safely unlink persist data..."

@olahouze

olahouze commented Oct 5, 2026

Copy link
Copy Markdown
Author

/verify

@olahouze
olahouze marked this pull request as ready for review October 5, 2026 13:46
Comment thread lib/install.ps1 Outdated
Comment thread lib/install.ps1 Outdated
Comment thread lib/install.ps1 Outdated
Comment thread lib/install.ps1 Outdated
Comment thread lib/install.ps1 Outdated
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Summary by CodeRabbit

  • Bug Fixes
    • Installations now prepare each version in a staging location before promoting it to the version directory, reducing the chance of a partial download replacing an existing version.
    • If installation fails, temporary files are cleaned up and a previous version is restored when possible.

Walkthrough

install_app downloads and extracts each version into a staging directory, then promotes it to the version directory before running installation hooks and setup. If installation fails, it removes staging and any promoted target, restores a backed-up target when possible, and removes an empty app directory. On success, it removes the backup.

Changes

Application installation

Layer / File(s) Summary
Stage, promote, and complete installation
lib/install.ps1, test/Scoop-Install.Tests.ps1
install_app downloads and extracts into staging, promotes the staged files, then runs installation hooks and setup. Tests cover successful installation and extraction failure.
Roll back unsuccessful installation
lib/install.ps1, test/Scoop-Install.Tests.ps1
If installation fails, install_app removes staging and any promoted target, then restores an available backup and relinks it. A test checks that an installer failure restores the previous target.

Priority: ➖ Normal

Merge Risk: 🟠 High · up to 157bd

The change adds staged installs with rollback, but a failure late in installation can remove the app's persisted user data. This happens because rollback deletes the version directory while persist links still point into the user's data. Staging-name collisions and unhandled rename errors also remain possible. These issues should be fixed before merging.

Architecture Summary

Architecture risk: 🔵 Low · up to ed454

The change affects 1 system.

Changed systems: lib

Architecture concerns
No architecture-level concerns identified.

Review details

Systems and components

  • observed — lib (service) was modified; 1 changed file maps to changed impact.

Before / after behavior

  • observed — Modified behavior in lib/install.ps1: install_app now stages the download, extraction, and pre_install hook before replacing the version directory; previously, these operations ran directly in that directory. It removes an existing target before renaming staging into place. On error, it removes remaining staging content and rethrows the error.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: transactional staging and automatic rollback for installation failures.
Description check ✅ Passed The description explains the installation problem and proposed staging and rollback changes. It also summarizes reported tests and review findings.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: f960190b-7367-41c0-9431-fc790993c5c9
📥 Commits

Reviewing files that changed from the base of the PR and between e6aa3b3 and ed4545a.

📒 Files selected for processing (1)
  • lib/install.ps1

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.

Comment thread lib/install.ps1 Outdated
Comment on lines +49 to +50
$staging_dir = "$target_dir.staging"
if (Test-Path $staging_dir) { Remove-Item $staging_dir -Recurse -Force -ErrorAction SilentlyContinue }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep staging names outside the version namespace.

The version check permits 1.staging. If that version is installed, installing version 1 makes $staging_dir point to its installed directory. Line 50 then deletes that installation. Use a separate staging location that cannot name an installed version.

Comment thread lib/install.ps1 Outdated
$original_dir = $dir # keep reference to real (not linked) directory
$target_dir = versiondir $app $version $global
$staging_dir = "$target_dir.staging"
if (Test-Path $staging_dir) { Remove-Item $staging_dir -Recurse -Force -ErrorAction SilentlyContinue }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Stop if stale staging cleanup fails.

SilentlyContinue hides a failed deletion, including one caused by a file in use. ensure can then reuse the remaining directory, and the later rename can install files left by the previous attempt. Put cleanup inside the failure-handling path and require it to succeed before download begins.

Comment thread lib/install.ps1 Outdated
Invoke-HookScript -HookType 'pre_install' -Manifest $manifest -ProcessorArchitecture $architecture

# Atomic swap from staging to final version dir
if (Test-Path $target_dir) { Remove-Item $target_dir -Recurse -Force }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Preserve the existing version until replacement succeeds.

When $target_dir exists, this command deletes the working installation before Rename-Item succeeds. If the rename fails, catch removes staging but cannot restore the old version. Keep a backup of the existing directory and restore it on replacement failure; remove the backup only after the new directory is in place.

Comment thread lib/install.ps1 Outdated

# Atomic swap from staging to final version dir
if (Test-Path $target_dir) { Remove-Item $target_dir -Recurse -Force }
Rename-Item -Path $staging_dir -NewName (Split-Path $target_dir -Leaf) -Force

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Make rename failure enter catch.

A non-terminating Rename-Item error does not enter this catch by default. If the rename fails, execution can set $dir to the target and continue installation even though staging remains. Use -ErrorAction Stop on the rename and other required filesystem operations in this block. PowerShell documents that catch handles terminating errors, while -ErrorAction Stop escalates non-terminating errors. (learn.microsoft.com)

@olahouze
olahouze force-pushed the fix-atomic-install-rollback branch 4 times, most recently from 33da926 to 157bdb3 Compare October 6, 2026 08:54

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 071e8832-bc86-4726-a039-780dcf1c0bb4
📥 Commits

Reviewing files that changed from the base of the PR and between 33da926 and 157bdb3.

📒 Files selected for processing (2)
  • lib/install.ps1
  • test/Scoop-Install.Tests.ps1

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 6 remain after this review.

Comment thread lib/install.ps1 Outdated
Comment on lines +110 to +113
if ($target_promoted -and (Test-Path $target_dir)) {
try { unlink_current $target_dir | Out-Null } catch { }
try { Remove-Item $target_dir -Recurse -Force -ErrorAction Stop } catch { }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Remove persist links before rollback deletes $target_dir.

persist_data runs at Line 88. It creates junctions and hard links inside $target_dir that point into $persist_dir. Any failure after Line 88 runs this rollback; for example, a post_install hook error or a save_installed_manifest error. The rollback then runs Remove-Item $target_dir -Recurse -Force.

In Windows PowerShell 5.1, Remove-Item -Recurse follows directory junctions and deletes the target contents. The rollback can therefore delete the user's persisted data in persist\<app>. For this reason, the uninstall flow calls unlink_persist_data before it removes a version directory.

Call unlink_persist_data before the recursive delete. Do not ignore its failure. If the unlink fails, skip the delete so that the persist store keeps its data.

🐛 Proposed fix
             if ($target_promoted -and (Test-Path $target_dir)) {
                 try { unlink_current $target_dir | Out-Null } catch { }
-                try { Remove-Item $target_dir -Recurse -Force -ErrorAction Stop } catch { }
+                $unlinked = $true
+                try { unlink_persist_data $manifest $target_dir } catch { $unlinked = $false }
+                if ($unlinked) {
+                    try { Remove-Item $target_dir -Recurse -Force -ErrorAction Stop } catch { }
+                }
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ($target_promoted -and (Test-Path $target_dir)) {
try { unlink_current $target_dir | Out-Null } catch { }
try { Remove-Item $target_dir -Recurse -Force -ErrorAction Stop } catch { }
}
if ($target_promoted -and (Test-Path $target_dir)) {
try { unlink_current $target_dir | Out-Null } catch { }
$unlinked = $true
try { unlink_persist_data $manifest $target_dir } catch { $unlinked = $false }
if ($unlinked) {
try { Remove-Item $target_dir -Recurse -Force -ErrorAction Stop } catch { }
}
}

Comment thread lib/install.ps1 Outdated
Comment thread lib/install.ps1 Outdated
Comment thread lib/install.ps1 Outdated
@olahouze
olahouze force-pushed the fix-atomic-install-rollback branch from 157bdb3 to 87acc25 Compare October 6, 2026 09:16
Comment thread lib/install.ps1 Outdated
Comment thread lib/install.ps1
Comment thread lib/install.ps1
Comment thread lib/install.ps1
$unlinked = $true
try { unlink_persist_data $manifest $target_dir } catch { $unlinked = $false }
if ($unlinked) {
Remove-Item $target_dir -Recurse -Force -ErrorAction Stop

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Recovery can unlink working installation

When a retry finds both a target and a stranded backup, recovery removes the app's current link before deleting the target. If a locked file prevents that deletion, the error stops installation before the backup is restored. The previously working installation is left unlinked, with its backup stranded.

Comment thread lib/install.ps1
link_current $target_dir | Out-Null
create_shims $manifest $target_dir $global $architecture
create_startmenu_shortcuts $manifest $target_dir $global $architecture
env_add_path $manifest $target_dir $global $architecture

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Rollback adds wrong PATH entries

If installation fails after promotion, rollback restores the backup but uses the failed installation's manifest to add PATH entries for it. When the restored version has different PATH settings, the user is left with entries that did not belong to that version and that its eventual uninstall will not know to remove.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant