#1583·fnm

Windows: install fails at the post-extraction rename, reported as a download error (os error 5)

Author: sacru2redCreated Aug 5, 2026Updated Aug 5, 2026

TL;DR

The download succeeds. The error message names the wrong operation. What actually fails is one of the two std::fs::rename calls that run after extraction, and on Windows a directory rename is denied with ERROR_ACCESS_DENIED (5) when any file inside the tree has an open handle that does not grant FILE_SHARE_DELETE — which is exactly what a real-time A/V scanner does to a freshly written node.exe.

I traced this end-to-end on a machine where it reproduces 100 % of the time, proved the mechanism with a standalone repro, and eliminated the usual suspects (path ACLs, folder redirection, Controlled Folder Access, proxy/TLS inspection, privilege, fnm version). Details below.

This also explains every one of the mutually-inconsistent workarounds in #1193 (pwsh -noprofile, reboot, wsl --shutdown, "it worked the second time"): they all just release or avoid an open handle.

I'm happy to open a PR for any of the three fixes at the bottom — Fix 1 is a few lines and would already stop the next person losing hours to this.


1. The error message points at the wrong operation

Error::DownloadError renders as "Can't download the requested binary", but it is #[from] downloader::Error, whose IoError variant is #[error(transparent)]. So any std::io::Error raised anywhere in install_node_dist — including long after the HTTP body has been fully read — is reported as a download failure.

Two give-aways present in every report, including mine:

  • the progress bar reaches 100 % at full speed, so the HTTP transfer completed;
  • the message does not contain Can't extract the file:. That string is the CantExtractFile variant, so extraction completed too.

install_node_dist therefore failed at one of the post-extraction io::Error sites.

Compounding this: --log-level only accepts quiet|error|info, so there is no way for a user to get more detail on their own. The message is the only signal they have, and it sends them to the wrong subsystem.

2. Where it actually fails

src/downloader.rs::install_node_dist and src/directory_portal.rs:

1. create_dir_all   <installations_dir>                        # before download
2. create_dir_all   <installations_dir>\.downloads             # before download
3. TempDir::new_in  <installations_dir>\.downloads\<tmp>        # extract target
4. extract_archive_into(...)                                    # -> CantExtractFile on failure
5. read_dir(&portal)
6. fs::rename       <tmp>\node-vX.Y.Z-win-x64 -> <tmp>\installation
7. portal.teleport() = fs::rename  <tmp> -> <installations_dir>\vX.Y.Z

Steps 1–2 run before the download; step 4 has its own error variant. That leaves steps 6 and 7 — two directory renames issued microseconds after ~120 MB of files, node.exe among them, were written and closed.

Corroborating detail: after a failure, <installations_dir>\.downloads is empty. The TempDir was cleaned up on drop, confirming execution got past extraction.

3. Proof of the mechanism

Standalone repro, no fnm involved. Open a handle on a file, then rename its parent directory:

powershell
$t = Join-Path $env:TEMP "rntest"
New-Item -ItemType Directory "$t\src\inner" -Force | Out-Null
Set-Content "$t\src\inner\f.bin" "x"
$fs = [System.IO.File]::Open("$t\src\inner\f.bin", 'Open', 'Read', 'Read')  # no FILE_SHARE_DELETE
try { [System.IO.Directory]::Move("$t\src", "$t\dst") }
catch [System.IO.IOException] { "win32=" + ($_.Exception.HResult -band 0xFFFF) }
finally { $fs.Dispose() }
handle's FileShare on the inner file Directory.Move on the parent
None fails — Access to the path ... is denied
Read fails — HResult 0x80070005, win32 = 5
ReadWrite fails — Access is denied
ReadWrite | Delete succeeds

win32 = 5 is byte-for-byte the os error 5 users report. A scanner that opens the file with FILE_SHARE_DELETE is invisible here; one that does not blocks the rename for the full duration of its scan.

This is a known, previously-solved class of bug. graceful-fs — which npm itself depends on — patches fs.rename on Windows for precisely this reason. From its source comments:

on Windows, A/V software can lock the directory, causing this to fail with an EACCES or EPERM if the directory contains newly created files

some Windows Anti-Virus, such as Parity bit9, may lock files for up to a minute, causing npm package install failures

It retries on EACCES/EPERM/EBUSY with incremental backoff for up to 60 s. fnm currently does no retry at all on either rename.

4. Confirmed case: Symantec Endpoint Protection

I had access to two machines running the same fnm version and the same A/V product, with opposite outcomes. That contrast is the most informative part of this report.

Machine A — fails Machine B — succeeded
fnm 1.39.0 (Chocolatey; also failed via WinGet) 1.39.0 (Chocolatey)
OS Windows 11 Pro x64, freshly provisioned corporate image Windows 11 Pro x64, build 26200
Shell PowerShell 7 PowerShell 7
WSC — Symantec Endpoint Protection productState 0x041000 productState 0x041000
WSC — Windows Defender productState 0x060100 0x060100, AMRunningMode = Not running, RealTimeProtectionEnabled = False
SEP services SepMasterService Running, SepScanService Running, sepWscSvc Running identical
fnm install 24.15.0 fails every time installed fine (dir timestamped ~3 weeks earlier)

So SEP was the sole active real-time engine on both, with Defender passive. In the productState scanner byte the 0x1000 bit indicates real-time scanning enabled — set for SEP, clear for Defender, on both machines.

What this rules out. If SEP were applying a path- or content-based deny rule, both machines would fail. They don't. What remains is a timing race — whether the scan handle on the newly written node.exe is still held at the microsecond fnm issues its rename — modulated by per-machine SEP policy, definitions, and disk/CPU speed. On Machine A that race is lost 100 % of the time; on Machine B it was won. Note that fnm renames essentially immediately after close, so any endpoint agent that grabs the handle synchronously on close will fail deterministically, not intermittently. That matches Machine A exactly, and it matches the "works after a reboot / worked the second time" reports in #1193 from the other direction.

Systematic elimination on Machine A

Every one of these was tested and eliminated:

hypothesis evidence against
path ACL / ownership icacls on the target shows <HOST>\<user>:(OI)(CI)(F), plus SYSTEM and Administrators full control
the specific directory reproduced identically with FNM_DIR set to five different locations: C:\fnm, C:\Users\<user>\fnm, C:\Users\<user>\.fnm, C:\Users\<user>\Desktop\fnm, and the default %APPDATA%\fnm
insufficient privilege fails identically in an elevated shell (IsInRole('Administrators') = True). An Administrator denied a rename on a directory it fully owns is only explainable by a filesystem filter driver
%APPDATA% folder redirection / roaming profile %APPDATA% and [Environment]::GetFolderPath('ApplicationData') and the User Shell Folders registry value all agree on a plain local path; and it fails on C:\fnm anyway
Controlled Folder Access fails on C:\fnm, which CFA does not protect
network / proxy / TLS inspection the download completes at full speed (~9 MB/s, 34.78 MiB) on every attempt
corrupt archive extraction succeeds — the error is not Can't extract the file:
stale .downloads contents .downloads is empty before and after
fnm version Machine B ran the same 1.39.0 successfully
a false-positive quarantine no detection or quarantine event in the SEP risk log at the failure timestamps — consistent with a scan handle, not a block

Node versions attempted on Machine A: 24.15.0 explicitly, and 24.19.0 via --lts. Same failure on both, so it is not version-specific.

Why the affected user often cannot fix their own machine

The correct local remedy is a SEP Auto-Protect file/folder exclusion for the fnm directory. On corporate deployments that UI is greyed out because exclusions are centrally managed by SEP Manager policy, so the developer must file a security ticket and wait — for what looks to them like a bug in fnm. That is the main reason I think this is worth fixing on the fnm side rather than documenting: a large fraction of affected users have no ability to change the endpoint agent's behaviour, and graceful-fs establishes that the tooling side is where this normally gets absorbed.

The workaround that does work today

Bypassing steps 6–7 entirely succeeds on Machine A, which is itself evidence that the writes are fine and only the rename is blocked:

powershell
$ver = "v24.15.0"
$dst  = "$env:FNM_DIR\node-versions\$ver\installation"
New-Item -ItemType Directory $dst -Force | Out-Null
Invoke-WebRequest "https://nodejs.org/dist/$ver/node-$ver-win-x64.zip" -OutFile "$env:TEMP\n.zip"
Expand-Archive "$env:TEMP\n.zip" -DestinationPath "$env:TEMP\nodeex" -Force
Copy-Item "$env:TEMP\nodeex\node-$ver-win-x64\*" $dst -Recurse -Force
fnm ls          # picks the version up normally

Expand-Archive + Copy-Item never renames a directory containing the new node.exe. fnm then recognises the manually placed version through the normal node-versions\<ver>\installation\ convention — verified working.

5. This unifies the contradictory reports in #1193

Every workaround in that thread reduces to "something released or avoided a handle":

reported workaround explanation under this diagnosis
pwsh -noprofile works the profile's fnm env --use-on-cd activates a version, so the multishell path and/or a live node.exe holds a reference into the tree being replaced
rebooting works clears whatever stale handle was held
WSL: os error 13 (EACCES), fixed by wsl --shutdown same shape one layer down — a handle held across the filesystem bridge
"worked on retry" / "worked later" the scan finished in between. This is the race, observed directly
adding --corepack-enabled helped almost certainly coincidental timing. That flag only affects the post-install corepack enable step and cannot influence steps 6–7

The last row is worth stating explicitly, because the thread currently reads as though that flag were causal, which sends readers down a dead end.

6. How to tell whether you're hit by this

If you're in this thread with os error 5 (or EACCES/os error 13 on WSL):

  1. Confirm the download isn't the problem — the progress bar reaches 100 % and the message does not say Can't extract the file:.
  2. Get-CimInstance -Namespace root\SecurityCenter2 -ClassName AntiVirusProduct | Select displayName, productState — identify which engine is actually live. If a third-party agent is registered, Defender is likely passive and Defender exclusions will do nothing.
  3. Retry in an elevated shell. Still denied ⇒ not a permissions problem.
  4. Point FNM_DIR at a different volume or plain local path and retry. Still denied ⇒ not a path problem.
  5. Run the Directory.Move repro in §3 to confirm the mechanism on your machine.
  6. Check your endpoint agent's risk log at the failure timestamp. Nothing there ⇒ scan handle, not quarantine.

Proposed fixes

Fix 1 — stop calling it a download error (trivial, worth doing on its own)

With no behavioural change at all, splitting downloader::Error::IoError so the post-download filesystem steps report something like "Can't move the extracted files into place: {source}" would make this self-diagnosing. As it stands the message guarantees that everyone investigates proxies, mirrors and ACLs first; that is what happened to every reporter in #1193, and to me.

Fix 2 — retry the two renames with bounded backoff (the actual fix)

Same shape as graceful-fs, but bounded and Windows-only:

rust
fn rename_with_retry(from: &Path, to: &Path, budget: Duration) -> std::io::Result<()> {
    let start = Instant::now();
    let mut backoff = Duration::from_millis(0);
    loop {
        match std::fs::rename(from, to) {
            Ok(()) => return Ok(()),
            Err(e) if is_transient_lock(&e) && start.elapsed() < budget => {
                std::thread::sleep(backoff);
                backoff = (backoff + Duration::from_millis(10)).min(Duration::from_millis(100));
            }
            Err(e) => return Err(e),
        }
    }
}

#[cfg(windows)]
#[cfg(not(windows))]
fn is_transient_lock(_: &std::io::Error) -> bool { false }

Applied at step 6 and inside DirectoryPortal::teleport, with the budget read from an env var — FNM_RENAME_RETRY_TIMEOUT_MS, default 5000, 0 to disable.

Why bounded retry rather than a fixed sleep: no constant is correct, because scan time scales with file size, CPU and load — and a fixed delay taxes every healthy install. Retry costs nothing where no scanner interferes (the first attempt succeeds) and adapts where it does. Making the budget configurable is what gives users on aggressive endpoint agents an escape hatch without a code change, which matters given how many of them cannot alter the agent's policy.

Trade-off, stated plainly: retrying ERROR_ACCESS_DENIED delays a genuine permission error by up to the budget. Windows-only gating, a modest default, and returning the original error unchanged once the budget expires keep that cost small and the diagnostic intact.

(Probing for the offending handle instead — Restart Manager RmGetList, or NtQuerySystemInformation — is far more code, inherently racy anyway, and buys nothing over retrying.)

Fix 3 — copy-then-delete fallback once the retry budget is exhausted

Empirically the scanner blocks the rename, not the writes: the manual Copy-Item workaround in §4 succeeds on the machine where every fnm install fails. A copy_dir_all + remove_dir_all fallback would therefore let installs complete even under a policy that never releases the handle in time. Slower and non-atomic, so strictly a last resort behind the retry.


Glad to send a PR for whichever of these you'd accept — or all three, staged. Fix 1 alone would already save the next person in #1193 a long detour.