Skip to content

fix: handle locked .exe shims on Windows during reshim#8517

Merged
jdx merged 5 commits intojdx:mainfrom
davireis:fix/windows-shims-locked-exe
Mar 8, 2026
Merged

fix: handle locked .exe shims on Windows during reshim#8517
jdx merged 5 commits intojdx:mainfrom
davireis:fix/windows-shims-locked-exe

Conversation

@davireis
Copy link
Copy Markdown
Contributor

@davireis davireis commented Mar 8, 2026

Summary

Fixes a regression introduced in v2026.2.7 (#8045) where mise reshim fails on Windows with:

mise ERROR failed rm -rf: C:\Users\...\mise\shims
mise ERROR Access is denied. (os error 5)

Root cause: In exe shim mode, reshim() unconditionally calls remove_dir_all on the shims directory. Windows locks .exe files while they're running, so if any shim-launched process is still alive, the delete fails.

Bisected: v2026.2.6 works, v2026.2.7 breaks.

Reproduction

mise settings set windows_shim_mode exe
mise install node@22
# Start a long-running process through the .exe shim
Start-Process "$env:LOCALAPPDATA\mise\shims\node.exe" -ArgumentList "-e","setTimeout(()=>{},600000)"
# reshim tries to rm -rf the shims dir → locked node.exe → Access Denied
mise reshim

Workaround: $env:MISE_WINDOWS_SHIM_MODE = "file"

Changes

  1. Stop nuking the entire shims directory for exe/hardlink mode on every reshim. Use the existing diff-based approach to only add/remove individual shims.

  2. When force or mode-change requires a full rebuild on Windows, remove shims individually instead of via remove_dir_all.

  3. Rename-fallback for locked files: When an .exe can't be deleted (ERROR_ACCESS_DENIED), rename it to .old — Windows allows renaming locked files. Cleaned up on next reshim.

Test plan

  • cargo check passes
  • Reproduced on GitHub Actions windows-latest
  • mise reshim with locked .exe shims should succeed
  • .old files cleaned up on next reshim

🤖 Generated with Claude Code

On Windows, native `.exe` shims (introduced in v2026.2.7 via jdx#8045)
get locked by processes or the shell when they are on PATH. When
`reshim()` tries to `rm -rf` the entire shims directory, it fails
with "Access is denied (os error 5)".

This change:

1. Stops nuking the entire shims directory for exe/hardlink mode on
   every reshim. Instead, use the existing diff-based approach to only
   add/remove individual shims as needed.

2. When force or mode-change requires a full rebuild on Windows,
   removes shims individually instead of via `remove_dir_all`.

3. For locked `.exe` files that cannot be deleted, renames them to
   `.old` (Windows allows renaming locked files) so the path is freed
   for the new shim. Old files are cleaned up on the next reshim.

Fixes the regression where `mise install` or `just setup` would fail
on Windows CI (GitHub Actions) with:
  mise ERROR failed to rebuild shims
  mise ERROR failed rm -rf: C:\Users\...\mise\shims
  mise ERROR Access is denied. (os error 5)

Bisected to v2026.2.7 which changed the default windows_shim_mode
from "file" to "exe".
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request resolves a regression on Windows where mise install or reshim operations would fail due to .exe shim files being locked by the operating system or other processes. The solution prevents the wholesale deletion of the shims directory, instead managing individual shims more robustly. It introduces a specific strategy for Windows to rename locked files for deferred cleanup, ensuring that shim operations complete successfully without access denied errors.

Highlights

  • Windows Shim Handling: The previous behavior of 'nuking' the entire shims directory for 'exe'/'hardlink' mode on every reshim has been replaced. Now, a diff-based approach is used to add or remove individual shims as needed.
  • Full Rebuilds on Windows: When a full rebuild is required due to 'force' or a mode change on Windows, shims are now removed individually instead of attempting to delete the entire directory, which could fail due to locked files.
  • Locked File Renaming: A new rename-fallback mechanism has been implemented for Windows. If a '.exe' file cannot be deleted because it's locked (Access is denied, error 5), it is renamed to a '.old' file. Windows allows renaming locked files, and these '.old' files will be cleaned up during the next reshim operation.
Changelog
  • src/shims.rs
    • Modified the reshim function to conditionally remove shims individually on Windows when a full rebuild is triggered, instead of attempting to delete the entire shims directory.
    • Adjusted the logic within reshim to use get_desired_shims for full rebuilds and get_shim_diffs for incremental updates, particularly for Windows exe/hardlink modes.
    • Integrated a new remove_shim_with_rename_fallback function when adding or removing individual shims on Windows to handle locked files.
    • Added a new private function remove_shims_individually to iterate through a directory and remove shim files one by one, skipping metadata files.
    • Implemented a new private function remove_shim_with_rename_fallback that attempts to delete a file, and if it fails on Windows due to an access denied error, renames the file to a .old extension for later cleanup, also cleaning up any existing .old files.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request effectively addresses a regression on Windows where reshim would fail due to locked .exe shims. The approach of individually removing shims and using a rename-fallback for locked files is a solid solution. The changes are well-contained and specific to Windows, preserving existing behavior on other platforms. I have a couple of suggestions to improve code clarity and error handling.

Comment thread src/shims.rs Outdated
Comment on lines +142 to +149
} else if is_windows_hardlink_or_exe {
// For exe/hardlink mode we cannot rely on symlink staleness checks, so
// recompute diffs but only add/remove individual shims — never nuke the
// whole directory.
get_shim_diffs(config, &mise_bin, ts).await?
} else {
get_shim_diffs(config, &mise_bin, ts).await?
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The else if and else blocks here are identical, both calling get_shim_diffs. They can be combined into a single else block to simplify the code and remove duplication. The explanatory comment can be adapted for the unified block.

    } else {
        // For exe/hardlink mode on Windows, we cannot rely on symlink staleness checks, so
        // we must recompute diffs. For other modes, this is also the correct behavior.
        // This approach avoids nuking the whole directory, which is important for locked .exe shims.
        get_shim_diffs(config, &mise_bin, ts).await?
    };

Comment thread src/shims.rs
Comment on lines +195 to +198
let entries = match shims_dir.read_dir() {
Ok(entries) => entries,
Err(_) => return Ok(()), // directory doesn't exist yet
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The error handling for shims_dir.read_dir() is a bit too broad. Using Err(_) swallows all potential errors, including permission issues, treating them as if the directory simply doesn't exist. It would be more robust to specifically handle the NotFound error and propagate any other errors with more context.

    let entries = match shims_dir.read_dir() {
        Ok(entries) => entries,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), // directory doesn't exist yet
        Err(e) => return Err(e).wrap_err_with(|| format!("failed to read shims dir: {}", display_path(shims_dir))),
    };

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 8, 2026

Greptile Summary

This PR fixes a Windows regression (introduced in v2026.2.7) where mise reshim failed with "Access is denied" when .exe shims were locked by a running process. It replaces the unconditional remove_dir_all with per-file removal and a rename-to-.old fallback for locked executables.

Key changes:

  • reshim() no longer nukes the entire shims directory for exe/hardlink mode on every run; the diff-based path is now the default for all modes.
  • A new remove_shims_individually() function removes shims one-by-one (skipping dotfiles like .mode) when a full rebuild is needed (force or mode change).
  • A new remove_shim_with_rename_fallback() function handles ERROR_ACCESS_DENIED (5) and ERROR_SHARING_VIOLATION (32) by renaming the locked file to <name>.old instead of aborting.
  • Windows shims in shims_to_add are pre-emptively removed before re-creation to avoid conflicts.

Issues found:

  • .old files are not cleaned up on the next normal reshim when the corresponding shim was renamed away in a previous force reshim — they only get cleaned up if that shim appears in shims_to_add or shims_to_remove in the subsequent call.
  • path.with_extension("old") creates a namespace collision: in "exe" mode, both node.exe and node (extensionless) map to node.old; in "file" mode, both node and node.cmd map to node.old. When multiple shims are processed in the same force reshim, the cleanup logic can inadvertently delete an .old file created by the current run.
  • .old files are not explicitly skipped in remove_shims_individually, relying instead on an indirect deletion via the old_path == path quirk in remove_shim_with_rename_fallback.

Confidence Score: 3/5

  • Mostly safe to merge as a regression fix, but .old file cleanup has edge cases that can leave orphaned binaries on disk.
  • The core fix (avoiding remove_dir_all on Windows) correctly addresses the reported regression. However, three interrelated .old file handling issues remain: (1) .old files persist after normal reshims if the corresponding shim doesn't appear in shims_to_add/shims_to_remove; (2) multiple shim variants (.exe, extensionless, .cmd) collapse to the same .old path, causing inadvertent deletion of current-run backups; (3) .old files are not explicitly skipped in remove_shims_individually. These are not crash-level bugs but do leave orphaned binary files on disk and weaken the rename-fallback guarantee.
  • src/shims.rs — specifically the shims_to_add loop (lines 147–154), remove_shim_with_rename_fallback (lines 220–248), and remove_shims_individually (lines 203–212)

Comments Outside Diff (3)

  1. src/shims.rs, line 147-154 (link)

    .old files are not cleaned up on the following normal reshim

    When a locked shim is renamed to <name>.old and a new <name>.exe is immediately written in a force reshim, everything is fine. However, on the next normal (non-force) reshim the stale .old file is silently left behind.

    The scenario:

    1. Force reshim: node.exe is locked → renamed to node.old; a fresh node.exe is then written.
    2. Normal reshim: node.exe now exists and is desired, so it's NOT in shims_to_add (which is desired − actual) → the cleanup code in remove_shim_with_rename_fallback (which runs at line 221–225) is never called → node.old persists.

    Over time, one .old file per previously-locked shim accumulates.

    Suggested fix: add best-effort .old cleanup in the shims_to_add loop even when symlink_path itself does not exist:

  2. src/shims.rs, line 220-248 (link)

    remove_shim_with_rename_fallback has path collisions for .old files

    path.with_extension("old") strips the current extension and appends old, so multiple shims map to the same .old path:

    • In "exe" mode: both node.exe and node (extensionless) → node.old
    • In "file" mode: both node (extensionless) and node.cmdnode.old

    When remove_shims_individually processes both files in the same call (during force reshim):

    1. node.exe is locked → renamed to node.old
    2. node (extensionless) is processed → its cleanup at line 223–224 deletes the .old that was just created by the first file

    This silently discards the only record that the first file was ever renamed, and risks deleting an .old backup from the current run rather than a prior one.

    A more robust key would include the full original extension:

    This produces unambiguous paths (e.g., node.exe.old, node.cmd.old, node.old) and eliminates the collision.

  3. src/shims.rs, line 203-212 (link)

    Explicitly skip .old files in remove_shims_individually

    The dotfile guard (starts_with('.')) correctly skips .mode, but .old files do not start with . and are not skipped. When encountered, they are passed to remove_shim_with_rename_fallback, where path.with_extension("old") returns the same path (since the extension is already old), so the file is deleted indirectly via the cleanup at line 224 rather than a clear guard.

    Make the intent explicit by also skipping files ending in .old:

Last reviewed commit: f5f3eb2

Comment thread src/shims.rs Outdated
Comment thread src/shims.rs
Comment thread src/shims.rs
bonitao and others added 2 commits March 8, 2026 11:37
- Collapse redundant else-if/else branches into single else arm
- Remove unused is_windows_hardlink_or_exe variable
- Only suppress NotFound in read_dir, propagate other errors
- Handle ERROR_SHARING_VIOLATION (32) in addition to ACCESS_DENIED (5)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
jdx added a commit that referenced this pull request Mar 8, 2026
## Summary

- Pin `goreleaser` to v2.14.1 in `test_aqua_github_attestations` e2e
test instead of using `@latest`
- goreleaser v2.14.2 (released 2026-03-08) has no GitHub artifact
attestations published, causing the test to fail on both main and PRs
(e.g. #8517)

## Test plan

- [x] `mise run test:e2e test_aqua_github_attestations` passes locally

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Test-only change that reduces flakiness by avoiding dependence on the
latest upstream release; no production code paths are modified.
> 
> **Overview**
> Pins the `test_aqua_github_attestations` e2e script to
install/uninstall `aqua:goreleaser/goreleaser@2.14.1` instead of
`@latest`, and documents the rationale to avoid CI failures when new
releases ship without GitHub artifact attestations.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
7a8655b. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jdx jdx enabled auto-merge (squash) March 8, 2026 20:48
@jdx jdx merged commit 9750070 into jdx:main Mar 8, 2026
34 checks passed
jdx pushed a commit that referenced this pull request Mar 9, 2026
### 🐛 Bug Fixes

- **(activate)** reorder shims to front of PATH on re-source in fish by
@jdx in [#8534](#8534)
- **(backend)** strip mise shims from dependency_env PATH to prevent
fork bomb by @pose in [#8475](#8475)
- **(github)** resolve "latest" version correctly via GitHub API by @jdx
in [#8532](#8532)
- **(lock)** set env tags and clarify lockfile docs by @jdx in
[#8519](#8519)
- **(lock)** use separate mise.<env>.lock files instead of env tags by
@jdx in [#8523](#8523)
- **(task)** include args in task output prefix and truncate long
prefixes by @jdx in [#8533](#8533)
- **(task)** only include args in task prefix when disambiguating
duplicates by @jdx in [#8536](#8536)
- **(test)** pin goreleaser version in attestation e2e test by @jdx in
[#8518](#8518)
- **(windows)** env._.source needs to run bash.exe on Windows (fix
#6513) by @pjeby in [#8520](#8520)
- handle locked .exe shims on Windows during reshim by @davireis in
[#8517](#8517)

### 🚜 Refactor

- **(prepare)** remove touch_outputs and update docs to reflect blake3
hashing by @jdx in [#8535](#8535)

### 📚 Documentation

- **(docker)** replace jdxcode/mise image with curl install, update to
debian:13-slim by @jdx in [#8526](#8526)
- fix "gzip: stdin is encrypted" error in shell tricks cookbook by
@pjeby in [#8512](#8512)

### 📦 Registry

- add tigerbeetle
([github:tigerbeetle/tigerbeetle](https://github.com/tigerbeetle/tigerbeetle))
by @risu729 in [#8514](#8514)

### New Contributors

- @pjeby made their first contribution in
[#8520](#8520)
- @davireis made their first contribution in
[#8517](#8517)
- @Aurorxa made their first contribution in
[#8511](#8511)

## 📦 Aqua Registry Updates

#### New Packages (6)

-
[`betterleaks/betterleaks`](https://github.com/betterleaks/betterleaks)
- [`majorcontext/moat`](https://github.com/majorcontext/moat)
- [`princjef/gomarkdoc`](https://github.com/princjef/gomarkdoc)
- [`remko/age-plugin-se`](https://github.com/remko/age-plugin-se)
- [`sudorandom/fauxrpc`](https://github.com/sudorandom/fauxrpc)
- [`swanysimon/mdlint`](https://github.com/swanysimon/mdlint)

#### Updated Packages (1)

- [`moonrepo/moon`](https://github.com/moonrepo/moon)
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.

3 participants