bug: `asdf reshim` deletes and rebuilds every shim in place, so concurrent commands fail
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:
- shim absent → the shell reports
command not found, or silently falls through to a different binary earlier onPATH(e.g. macOS system Ruby 2.6); - shim present but truncated to 0 bytes;
- shim present but its
# asdf-plugin:metadata does not yet include the version selected by.tool-versions, so asdf reportsNo 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:
# ~/.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
endSo 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:
#!/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 / 1163The 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:
// 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:
// 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:
- Atomic write only — in
shims.Write, write to a temp file in the shims dir andos.Renameover 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. - Compute then write — build the complete version list for each shim in memory during
GenerateAlland 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 afterRemoveAll. - 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.
- Drop
RemoveAllfrom the tool-scoped path —asdf 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
d7c707c830dfa374560662984ca9de604b2139aaasdf 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.
Source: asdf-vm/asdf