bug: `asdf reshim` deletes and rebuilds every shim in place, so concurrent commands fail
- Dominant language
- Go
- Stars
- 25.6k
- Forks
- 941
- Avg merge
- 9h 24m
- Merged PRs (30d)
- 3
Description
## Describe the Bug
`asdf reshim ` 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 ` 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 ` 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 path** — `asdf reshim ` 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//.asdf
ASDF_CONFIG_FILE=/Users//.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.
Contributor guide
Research direction
Start with the regenerate-everything branch in internal/cli/cli.go:1391-1400 and the incremental write logic in internal/shims/shims.go:311-330. Run the provided reshim-race.sh reproduction and inspect existing shims tests before choosing among atomic writes, complete generation, directory replacement, and tool-scoped cleanup. Done means concurrent readers see only complete shim states and reshim does not remove unrelated plugin shims.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- bash, go
- Domain
- cli, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100