Skip to content

(fix) Roll back partial installs on failure (closes #59) - #101

Merged
HMythical merged 1 commit into
rootdevfrom
fix/rollback-install-issues
Nov 13, 2026
Merged

HMythical merged 1 commit into
rootdevfrom
fix/rollback-install-issues

Conversation

@HMythical

Copy link
Copy Markdown
Owner

Description

draft, substitute and update install a plan as separate persisted steps (extract → link → record) with no transaction around them, and none of them undid those steps on a failure (#59). A failure partway through the plan, such as a 404 download, a missing binary or a vetoing pre-hook, left the earlier packages linked and on the roster.

This PR adds src/commands/rollback.rs, a Session in which each command records every step as it goes (begin before a package's first change, then downloaded / linked / recorded). On an error the command unwinds the session newest-first and then returns the original error:

  • Packages new to the run are unlinked, removed from the roster, and purged from the cache (extract dir and archive).
  • Packages the run found already installed are restored, not removed. This covers a draft --force reinstall, a dependency that substitute re-fetches (resolve_deps_with_root returns installed dependencies and substitute doesn't skip them), and the package update is upgrading. Their roster row and package_dependencies rows are written back, and the link points at the old binary again. Deleting them, as the issue's suggested fix would, uninstalls packages that worked before the run.
  • update prunes the old extract dir only after the new release is linked and recorded. A rollback therefore never needs files that were already deleted, and no backup/rename step is needed.
  • substitute also rolls back when the old package can't be unlinked or removed. If its roster row can't be deleted, it puts the old package's link back; the previous partial rollback left that link removed.

The install loops move into install_plan (draft), install_replacement (substitute) and install_update (update). The caller does the rollback.

Behaviour changes

  • A failed run prints Rolled back undid this run's changes to <names>. It is a warning, so -q and --json hide it; -v shows each step.
  • update prunes the old extract dir after the new version is committed, not before.
  • Success paths and --json documents are unchanged. draft's installed still lists what this run recorded.

Unchanged by design

  • Referee Phase A still runs before every install loop.
  • Post-hooks still never fail a command, so they can't trigger a rollback, and rollback runs no hooks.

Known limitations

  • Rollback doesn't undo what a system package manager, cargo install or a hook did. A warning says when a system or cargo package stays installed with only its roster entry removed.
  • A restored row's installed_at is reset to the time of the rollback.
  • A same-version --force reinstall whose own download fails partway has already re-extracted over the existing tree. That is existing download_and_extract behaviour: rollback doesn't make it worse, but can't bring those files back.

Found while testing, not fixed here: under -q / --json, a failed download leaves a 0-byte file at the archive's cache path, and cached_download then treats it as cached. Every retry fails with tar.gz extraction: failed to iterate over archive until baller sweep. The bug is in Downloader::download_archive and probably deserves its own issue.

Docs: docs/commands.md, docs/error-handling.md (which already described a draft/substitute rollback that didn't exist) and docs/architecture.md. CHANGELOG entry is under Unreleased → Fixed.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update
  • Refactoring
  • Test addition

Platform

  • Linux
  • Windows
  • Both

Testing

  • All existing tests pass
  • New tests added (if applicable)
  • Tested on Linux (Debian 13, x86_64) and Windows 11 (VM)

New tests

  • 5 unit tests in src/commands/rollback.rs:
    • a fresh package is removed and purged
    • an already-installed package is restored (version, user_installed, dependency rows, old tree kept, new tree purged)
    • a same-version reinstall keeps its tree
    • packages unwind newest-first
    • an empty session changes nothing
  • tests/rollback.rs: 8 end-to-end tests that drive the real binary against an in-process mock registry. Each case gets its own config dir and HOME / LOCALAPPDATA.
    • draft: a root whose archive 404s rolls back its fresh dependencies. A root vetoed by its pre_install hook does the same. A failed draft --force restores a reinstalled dependency's old version, link and user_installed flag.
    • substitute: a failed replacement leaves the old package and a shared dependency exactly as they were.
    • update: a new release whose archive 404s drops the new dependency and keeps the old release, link and extract dir. A vetoing pre_update hook does the same. A successful update still prunes the old extract dir.
    • A successful draft --json still reports installed in install order.
    • The tests check each version by reading the exposed binary itself, so they cover a Unix symlink and Windows' copied <name>.exe alike. BALLER_TEST_BIN overrides the binary, as in json_output.rs.
  • The 6 failure-path tests fail against the code before this change (checked by swapping the old command files back in). The 2 success-path tests pass on both.

Results

Platform Result
Linux (host) cargo build --all-targets, cargo clippy --all-targets -- -D warnings and cargo fmt --check are clean. cargo test passes all suites: 807, 837, 17 and 8 tests.
Windows 11 VM (PowerShell 5.1, execution policy Restricted), x86_64-pc-windows-gnu cross-build Rollback 8/8, lib 800/800, bin 830/830, json_output 17/17. Windows-target clippy is clean, with 0 build warnings.
Wine 10 Rollback unit tests 5/5 and end-to-end 6/8. The 2 hook-veto cases fail only because Wine's powershell is a stub that exits 0. The full suites show only the known Wine-only failures (core::hooks ×3, test_utils_symlink_integration, json_output's hook test).

Manual run with a fresh HOME: a draft whose root 404s after its dependency was installed prints the rollback warning and exits 1. No link, roster row, extract dir or archive is left for the dependency.

Checklist

  • Code follows project conventions
  • Self-reviewed code
  • Documentation updated
  • No new warnings
  • Changes limited to one platform (if applicable): N/A. This is shared command logic both platforms run, which CONTRIBUTING.md exempts from the one-platform rule.
  • Commit messages follow project style

Related Issues

Closes #59

Screenshots/Videos

N/A: this is a CLI change. The test results are summarised above.

If any package installation were to fail via 'draft', 'substitute' and/or 'update' commands,
B.A.L.L.E.R rolls back to the last version you installed.

An additional command is provided for you to manually roll back packages when installations
from source fail  unexpectedly.

New installs and/or installs before this change are unaffected for the first time.
@HMythical
HMythical merged commit 5c0e5da into rootdev Nov 13, 2026
5 checks passed
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.

Partial installs are never rolled back — the session_packages field exists only to be reported, not used for rollback

1 participant