Skip to content

fix: use staged installation to prevent partial installs appearing in asdf list#2245

Open
RishiAhuja wants to merge 1 commit intoasdf-vm:masterfrom
RishiAhuja:feat/staging-dir
Open

fix: use staged installation to prevent partial installs appearing in asdf list#2245
RishiAhuja wants to merge 1 commit intoasdf-vm:masterfrom
RishiAhuja:feat/staging-dir

Conversation

@RishiAhuja
Copy link

@RishiAhuja RishiAhuja commented Feb 21, 2026

Problem

When asdf install <plugin> <version> is interrupted mid-way — by Ctrl+C
(SIGINT), SIGKILL, a dropped network connection, or a power outage — the
partially installed version directory is left behind on disk. Because
asdf list detects installed versions simply by checking which
subdirectories exist under $ASDF_DATA_DIR/installs/<plugin>/, the
incomplete installation is reported as if it were valid:

$ asdf install nodejs 22.14.0
# ... interrupted halfway through ...

$ asdf list nodejs
nodejs
  22.14.0   ← listed, but broken

The user is then forced to manually run asdf uninstall nodejs 22.14.0
before they can retry the install. This is confusing because the version
was never successfully installed.

Reported and discussed in: #2184


Root Cause

InstallOneVersion in internal/versions/versions.go created the final
install directory (e.g. installs/nodejs/22.14.0/) with os.MkdirAll
before running the plugin's install callback. If the process was
killed at any point after that MkdirAll call, the directory remained on
disk. IsInstalled and Installed both use os.Stat / os.ReadDir on
the installs directory, so they reported the partial directory as a
complete installation.


Solution: Staged Installation with Atomic Rename

This PR implements the staged installation pattern, which is the
standard approach used by package managers (npm, cargo, homebrew, apt)
to solve exactly this class of problem.

Before:

1. Create ~/.asdf/installs/nodejs/22.14.0/        ← final path, created immediately
2. Set ASDF_INSTALL_PATH=.../22.14.0
3. Run plugin install script (writes into 22.14.0/)
4. If interrupted → partial directory stays at final path, appears in asdf list

After:

1. Create ~/.asdf/installs/nodejs/22.14.0.incomplete/   ← staging path
2. Set ASDF_INSTALL_PATH=.../22.14.0.incomplete
3. Run plugin install script (writes into 22.14.0.incomplete/)
4. On SUCCESS → os.Rename(22.14.0.incomplete/, 22.14.0/)  ← atomic on POSIX
   On FAILURE  → os.RemoveAll(22.14.0.incomplete/)
   On SIGKILL  → 22.14.0.incomplete/ stays on disk, but is ignored by asdf list
                 and cleaned up automatically on the next install attempt

The final directory (22.14.0/) never exists while an install is in
progress
. It only appears, atomically, after the install has fully
succeeded. os.Rename() within the same filesystem is an atomic POSIX
operation — it either completes fully or not at all.

This approach is strictly more robust than signal handling because it
also covers SIGKILL and power loss, which cannot be caught by any process.


How does it look now?

# attempt to start a installation and simulate sigkill
(base) rishi@Rishis-MacBook-Pro asdf % ./asdf install nodejs 22.14.0 &
INSTALL_PID=$!
sleep 5
kill -9 $INSTALL_PID
[1] 85292
Trying to update node-build... ok
To follow progress, use 'tail -f /var/folders/5c/lzq441fs7bjdbbg_t7_0zzz40000gn/T/node-build.20260221171748.85326.log' or pass --verbose
Downloading node-v22.14.0-darwin-arm64.tar.gz...
-> https://nodejs.org/dist/v22.14.0/node-v22.14.0-darwin-arm64.tar.gz
Installing node-v22.14.0-darwin-arm64...
[1]  + killed     ./asdf install nodejs 22.14.0  

# list of installations
(base) rishi@Rishis-MacBook-Pro asdf % ls ~/.asdf/installs/nodejs/
22.14.0.incomplete      22.16.0                 24.9.0
(base) rishi@Rishis-MacBook-Pro asdf % ./asdf list nodejs
  22.16.0
  24.9.0
(base) rishi@Rishis-MacBook-Pro asdf % 

Changes

internal/installs/installs.go

  • Added incompleteSuffix = ".incomplete" constant.
  • Installed() — now skips any subdirectory whose name ends with
    .incomplete, so interrupted installs are never surfaced by asdf list.
  • StagingPath() — new function that returns
    InstallPath(...) + ".incomplete". This is the directory the plugin
    install script writes into.
  • CleanIncomplete() — new function that removes the staging
    directory if it exists. Safe to call when no such directory is present.
    Called at the start of every install attempt to clean up stale
    directories left behind by previous crashes.

internal/versions/versions.go

Changes are entirely within InstallOneVersion:

  • Computes stagingDir via installs.StagingPath() alongside the
    existing installDir and downloadDir.
  • Calls installs.CleanIncomplete() before starting to wipe any stale
    staging directory from a prior interrupted install.
  • Passes stagingDir (not installDir) as ASDF_INSTALL_PATH in the
    environment given to the plugin's download and install callbacks.
    Plugin scripts are unaffected — they simply write to $ASDF_INSTALL_PATH
    as before.
  • Creates stagingDir (not installDir) with os.MkdirAll.
  • On install callback failure: removes stagingDir (same cleanup
    behaviour as before, now correctly targeting the staging path).
  • On install callback success: calls os.Rename(stagingDir, installDir)
    to atomically promote the staged directory to the final install path.
    If the rename itself fails (very rare edge case), cleans up the
    staging directory and returns an error.

internal/installtest/installtest.go

Updated the test helper InstallOneVersion to mirror the same
staging-and-rename flow as the production code, so tests that rely on
this helper produce installations consistent with the new behaviour.


Tests

internal/installs/installs_test.go (new test cases)

Test What it verifies
TestIsInstalled/returns false for incomplete installation An .incomplete directory does not make IsInstalled return true
TestInstalled_FiltersIncomplete/does not list incomplete installations Installed() excludes .incomplete dirs from its results
TestStagingPath/returns install path with .incomplete suffix StagingPath() produces the correct string
TestCleanIncomplete/removes incomplete staging directory CleanIncomplete() recursively deletes the staging directory
TestCleanIncomplete/succeeds even when no incomplete directory exists CleanIncomplete() is safe to call when nothing exists

internal/versions/versions_test.go (new test cases)

Test What it verifies
TestInstallOneVersion/staging directory does not exist after successful install After a successful install, 22.14.0.incomplete/ is gone and 22.14.0/ exists with correct contents
TestInstallOneVersion/staging directory is cleaned up when install fails When the install script exits non-zero, both the staging dir and final dir are absent
TestInstallOneVersion/cleans up stale incomplete directory from previous failed install A pre-existing .incomplete dir (simulating a prior crash) is cleaned up and the new install succeeds

Behaviour by Failure Mode

Scenario Result after this PR
Install script returns non-zero Staging dir cleaned up. Final dir never created.
Ctrl+C (SIGINT) during install Go runtime returns error from RunCallback. Staging dir cleaned up. Final dir never created.
SIGKILL / power outage during install Staging dir left on disk. Not visible in asdf list. Cleaned up automatically on next install attempt.
Normal success Staging dir atomically renamed to final path.

Testing Locally

# Run all affected unit tests
go test ./internal/installs/ ./internal/versions/ -v -count=1

# Run only the new tests
go test ./internal/installs/ -v -run "TestInstalled_FiltersIncomplete|TestStagingPath|TestCleanIncomplete|TestIsInstalled"
go test ./internal/versions/ -v -run "TestInstallOneVersion/staging|TestInstallOneVersion/cleans_up_stale"

# Run the full internal test suite
go test ./internal/... -count=1

Compatibility

  • No breaking changes. The ASDF_INSTALL_PATH variable passed to
    plugin scripts now points to the staging directory during installation
    instead of the final directory. The path format is otherwise identical
    and the variable name is unchanged. Plugins that write to
    $ASDF_INSTALL_PATH (which is all of them) continue to work without
    modification.
  • Any .incomplete directories already sitting on disk from previous
    versions of asdf will be invisible to asdf list and will be
    automatically removed on the next asdf install attempt for that
    version.

@RishiAhuja RishiAhuja requested a review from a team as a code owner February 21, 2026 11:43
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