#2315·asdf

bug: `asdf reshim` deletes and rebuilds every shim in place, so concurrent commands fail

Author: jward7Created Aug 16, 2026Updated Aug 16, 2026

Describe the Bug

asdf reshim <tool> removes all shims for all plugins and regenerates them one os.WriteFile at a time. For the whole duration of the reshim, any other process resolving a shim can see the file missing, empty, or listing only the subset of versions written so far.

Three distinct user-visible failures result, depending on which intermediate state is observed:

  1. shim absent → the shell reports command not found, or silently falls through to a different binary earlier on PATH (e.g. macOS system Ruby 2.6);
  2. shim present but truncated to 0 bytes;
  3. shim present but its # asdf-plugin: metadata does not yet include the version selected by .tool-versions, so asdf reports No version is set for command <cmd> and suggests versions that happen to have been written already.

(3) is the confusing one, because the error names the correct config file and claims no version is set, while .tool-versions is correct the entire time and the tool is installed.

This needs concurrency, so it is rare in interactive use — but it is not exotic, because some plugins reshim automatically. asdf-ruby installs a RubyGems plugin that runs asdf reshim ruby after every Bundler install:

ruby
# ~/.asdf/plugins/ruby/rubygems-plugin/rubygems_plugin.rb
module ReshimInstaller
  def install(options)
    super
    # We don't know which gems were installed, so always reshim.
    `asdf reshim ruby`
  end
end

So on any machine doing parallel Ruby work — two checkouts, a CI matrix, an editor task alongside a terminal — one bundle install can break an unrelated ruby/bundle/rspec invocation in another process. That is how I found it: an rspec run failed with (3) while a concurrent bundle install finished in another worktree.

I could not find an existing issue for this; apologies if I missed one.

Steps to Reproduce

Self-contained — creates a throwaway ASDF_DATA_DIR with a fake plugin, touches nothing real:

bash
#!/usr/bin/env bash
# Usage: ASDF=/path/to/asdf ./reshim-race.sh
set -u

ASDF="${ASDF:-asdf}"
DATA="$(mktemp -d "${TMPDIR:-/tmp}/asdf-reshim-race.XXXXXX")"
PROJECT="$(mktemp -d "${TMPDIR:-/tmp}/asdf-reshim-proj.XXXXXX")"
export ASDF_DATA_DIR="$DATA"
trap 'rm -rf "$DATA" "$PROJECT"' EXIT

# Sized to resemble a real language plugin: several installed versions, each
# shipping a handful of executables.
VERSIONS="${VERSIONS:-1.0.0 2.0.0 3.0.0 4.0.0 5.0.0 6.0.0}"
EXTRA_EXECUTABLES="${EXTRA_EXECUTABLES:-20}"
NUM_VERSIONS=$(echo "$VERSIONS" | wc -w | tr -d ' ')

mkdir -p "$DATA/plugins/faketool/bin"
printf '#!/usr/bin/env bash\necho 1.0.0\n' > "$DATA/plugins/faketool/bin/list-all"
chmod +x "$DATA/plugins/faketool/bin/list-all"
for v in $VERSIONS; do
  mkdir -p "$DATA/installs/faketool/$v/bin"
  printf '#!/usr/bin/env bash\necho "faketool %s"\n' "$v" > "$DATA/installs/faketool/$v/bin/faketool"
  chmod +x "$DATA/installs/faketool/$v/bin/faketool"
  for i in $(seq 1 "$EXTRA_EXECUTABLES"); do
    printf '#!/usr/bin/env bash\necho "other %s"\n' "$v" > "$DATA/installs/faketool/$v/bin/other$i"
    chmod +x "$DATA/installs/faketool/$v/bin/other$i"
  done
done

echo "faketool 1.0.0" > "$PROJECT/.tool-versions"
cd "$PROJECT" || exit 1

"$ASDF" reshim faketool >/dev/null 2>&1
echo "steady-state shim:"; sed 's/^/    /' "$DATA/shims/faketool"; echo

# What does a concurrent reader see while reshim runs?
( for _ in $(seq 1 60); do "$ASDF" reshim faketool >/dev/null 2>&1; done ) &
reshim_pid=$!
missing=0; partial=0; complete=0; total=0
while kill -0 "$reshim_pid" 2>/dev/null; do
  total=$((total + 1))
  if [ ! -f "$DATA/shims/faketool" ]; then
    missing=$((missing + 1))
  else
    n=$(grep -c '^# asdf-plugin:' "$DATA/shims/faketool" 2>/dev/null || echo 0)
    if [ "$n" -eq "$NUM_VERSIONS" ]; then complete=$((complete + 1)); else partial=$((partial + 1)); fi
  fi
done
wait "$reshim_pid" 2>/dev/null

echo "observations of \$ASDF_DATA_DIR/shims/faketool during reshim:"
echo "    complete (all versions): $complete / $total"
echo "    partial  (incomplete):   $partial / $total"
echo "    missing entirely:        $missing / $total"

Output against main at f87a31e (built with go build -o /tmp/asdf-main ./cmd/asdf):

observations of $ASDF_DATA_DIR/shims/faketool during reshim:
    complete (all versions): 151 / 1163
    partial  (incomplete):   300 / 1163
    missing entirely:        712 / 1163

The shim is fully valid for only ~13% of the time a reshim is running.

The three intermediate states, deterministically

Rather than rely on timing, the same states can be forced. With .tool-versions selecting faketool 1.0.0:

--- steady state (control) ---
exit=0 output=[faketool 1.0.0]
--- state 1: shim deleted (RemoveAll done, Write pending) ---
exit=126 output=[]
--- state 2: shim truncated to 0 bytes (WriteFile mid-write) ---
exit=126 output=[]
--- state 3: partial - lists only 3.0.0, .tool-versions wants 1.0.0 ---
exit=126 output=[No version is set for command faketool
Consider adding one of the following versions in your config file at /tmp/asdf-proj.lZE0HI/.tool-versions
faketool 3.0.0]

State 3 is exactly the error seen in the wild. Note states 1 and 2 exit 126 with no message at all.

Expected Behaviour

A reshim should be invisible to concurrent processes. At any instant, a shim on disk should be either the previous complete version or the new complete version — never absent, empty, or missing versions that are installed.

Actual Behaviour

asdf reshim <tool> with no version argument takes the regenerate-everything branch:

go
// internal/cli/cli.go:1391-1400
// if either tool or version are missing just regenerate all shims. This is
// fast enough now.
if tool == "" || version == "" {
    err = shims.RemoveAll(conf)
    if err != nil {
        return err
    }

    return shims.GenerateAll(conf, os.Stdout, os.Stderr)
}

shims.RemoveAll unlinks every entry in the shims directory — including shims belonging to plugins other than the one named. I verified the blast radius with two fake plugins alpha and beta: after asdf reshim alpha, the inode of beta-cmd had changed, so an unrelated plugin's shim is destroyed and recreated too.

shims.GenerateAll then rebuilds incrementally. shims.Write reads whatever is on disk, appends one version, and rewrites the file:

go
// internal/shims/shims.go:311-330
if _, err := os.Stat(shimPath); err == nil {
    oldVersions, err := GetToolsAndVersionsFromShimFile(shimPath)
    ...
    versions = toolversions.Unique(append(versions, oldVersions...))
}

return os.WriteFile(shimPath, []byte(encode(shimName, versions)), 0o777)

So the incomplete states are not merely torn writes. Because the file is rewritten once per (version, executable) and each rewrite accumulates one more version, the partial states are genuine, deliberately persisted intermediate states — which is why the window is the entire duration of the reshim rather than a few microseconds. os.WriteFile also truncates in place (O_TRUNC), which produces the 0-byte state.

On my real setup (276 shims, 6 plugins, 6 installed Rubies) a single asdf reshim ruby takes ~230-270ms, and hammering ruby --version through the shim across a loop of reshims failed 80 times out of 83.

Possible directions

I did not want to send a patch before checking which approach you would prefer, since the options differ a lot in scope:

  1. Atomic write only — in shims.Write, write to a temp file in the shims dir and os.Rename over the target. Small and self-contained; removes the 0-byte state and makes each individual update atomic. Does not fix the absent state or the accumulate-one-version- at-a-time states.
  2. Compute then write — build the complete version list for each shim in memory during GenerateAll and write each shim exactly once, atomically. Combined with (1) a reader then only ever sees a complete file, though a shim can still briefly be absent after RemoveAll.
  3. Regenerate into a temp dir and swap — build a full shims dir, then rename it into place. Closest to "invisible", but changes more and needs thought about what a rename of a directory that other processes hold open means in practice.
  4. Drop RemoveAll from the tool-scoped pathasdf reshim <tool> arguably should not be deleting other plugins' shims at all. Narrows the blast radius independently of the above.

Happy to open a PR for whichever of these you would accept, with bats coverage. I would suggest (1) plus (4) as a first, small step, and (2) as a follow-up, but I do not know the history here and will follow your lead.

Environment

Originally hit on the released 0.17.0; all figures and code references above are from main at f87a31e (0.20.0), built locally, so this is not a stale-version report.

OS:
Darwin 22.6.0 Darwin Kernel Version 22.6.0: Tue Jul 15 08:22:28 PDT 2025; root:xnu-8796.141.3.713.2~2/RELEASE_X86_64 x86_64

SHELL:
zsh 5.9 (x86_64-apple-darwin22.0)

BASH VERSION:
5.0.7(1)-release

ASDF VERSION:
0.17.0 (revision unknown)

ASDF INTERNAL VARIABLES:
ASDF_DEFAULT_TOOL_VERSIONS_FILENAME=.tool-versions
ASDF_DATA_DIR=/Users/<redacted>/.asdf
ASDF_CONFIG_FILE=/Users/<redacted>/.asdfrc

ASDF INSTALLED PLUGINS:
crystal   https://github.com/marciogm/asdf-crystal.git
          c14e8b330f07a52baf3d43e72cc8cc1456a78b36
postgis   https://github.com/knu/asdf-postgis.git
          d786e486f74b8bfb2a568edc5dbe259b89f64b9c
postgres  https://github.com/smashedtoatoms/asdf-postgres.git
          acbbc49a78c40225a7ae8fe1eb14ce9ff96c64ce
python    https://github.com/danhper/asdf-python.git
          b544ac9e512b1d95ad80d432b47b181be40199c0
ruby      https://github.com/asdf-vm/asdf-ruby.git
          498c76f787caf8b9bbe2032fa47848fd93d83be8
rust      https://github.com/code-lever/asdf-rust.git
          d7c707c830dfa374560662984ca9de604b2139aa

asdf plugins affected (if relevant)

Not plugin-specific — the repro uses a fake plugin and no real plugin code. ruby is only how I encountered it, via asdf-ruby's automatic post-Bundler-install reshim.