Skip to content

fix: [node-core-library] Retry transient Windows EPERM/EBUSY in FileSystem.deleteFolder(Async)#5836

Open
bartvandenende-wm wants to merge 2 commits into
microsoft:mainfrom
bartvandenende-wm:bartvandenende-wm/5373
Open

fix: [node-core-library] Retry transient Windows EPERM/EBUSY in FileSystem.deleteFolder(Async)#5836
bartvandenende-wm wants to merge 2 commits into
microsoft:mainfrom
bartvandenende-wm:bartvandenende-wm/5373

Conversation

@bartvandenende-wm

@bartvandenende-wm bartvandenende-wm commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Restores retry-on-transient-error behavior to FileSystem.deleteFolder and FileSystem.deleteFolderAsync on Windows, fixing EPERM: operation not permitted, rmdir failures during rush rebuild / Heft cleanFiles on self-hosted Windows runners.

Fixes #5373.

Details

Root cause
In v5.11.0 (PR #5088) node-core-library bumped its fs-extra dependency from ~7.0.1 to ~11.3.0.

  • The v7 implementation of fs-extra.remove(Sync) was backed by rimraf@2, which retries EBUSY/EPERM/ENOTEMPTY on Windows by default (maxBusyTries: 3).
  • From fs-extra@10 onward, remove(Sync) is a thin wrapper around fs.rm(path, { recursive: true, force: true }) with no retry options.

The retry behavior was silently lost, which made consumers susceptible to the documented Windows transient where another process briefly holds a handle to a child of the directory being removed.

Change
Replace the two affected methods with native fs.rmSync / fsPromises.rm using { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }. fs-extra@11's remove(Sync) is already a thin wrapper over the same native call.

Note
I also added ensureEmptyFolder / ensureEmptyFolderAsync for consistency as they exhibit the same regression.

How it was tested

manual testing

@bartvandenende-wm

Copy link
Copy Markdown
Contributor Author

@iclanton any chance this could get a review?

@DavidRieman

DavidRieman commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

(FWIW: Although we're at the same company, I did independently encounter and come up with basically the same solution as Bart did, just on a different repo. Without the fix, a very basic one-line TS "hello world" with just TS+heft as the main dependencies will hit the EPERM issue maybe about a quarter of the time, simply from heft build --clean on Windows.)

"changes": [
{
"packageName": "@rushstack/node-core-library",
"comment": "Make FileSystem.deleteFolder, FileSystem.deleteFolderAsync, FileSystem.ensureEmptyFolder, and FileSystem.ensureEmptyFolderAsync resilient to transient Windows EPERM/EBUSY/ENOTEMPTY errors by using native fs.rm with retries.",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
"comment": "Make FileSystem.deleteFolder, FileSystem.deleteFolderAsync, FileSystem.ensureEmptyFolder, and FileSystem.ensureEmptyFolderAsync resilient to transient Windows EPERM/EBUSY/ENOTEMPTY errors by using native fs.rm with retries.",
"comment": "Make `FileSystem.deleteFolder`, `FileSystem.deleteFolderAsync`, `FileSystem.ensureEmptyFolder`, and `FileSystem.ensureEmptyFolderAsync` resilient to transient Windows `EPERM`/`EBUSY`/`ENOTEMPTY` errors by using native `fs.rm` with retries.",

public static deleteFolder(folderPath: string): void {
FileSystem._wrapException(() => {
fsx.removeSync(folderPath);
fs.rmSync(folderPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Stick this options object in a shared constant?

try {
items = fsx.readdirSync(folderPath);
} catch {
fsx.ensureDirSync(folderPath);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Specifically check for a not-exist error using FileSystem.isNotExistError, and otherwise re-throw

fsx.emptyDirSync(folderPath);
let items: string[];
try {
items = fsx.readdirSync(folderPath);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Use FileSystem.readFolderItems and handle subfolders.

return;
}
for (const item of items) {
fs.rmSync(nodeJsPath.join(folderPath, item), {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Forward shashes work fine on Windows.

Suggested change
fs.rmSync(nodeJsPath.join(folderPath, item), {
fs.rmSync(`${folderPath}/${item}`, {

await fsx.ensureDir(folderPath);
return;
}
await Promise.all(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Limit concurrency here using Async.forEachAsync. It may be worthwhile to recursive enumerate files and sort them by depth, and delete from deepest-in.

/**
* An async version of {@link FileSystem.ensureEmptyFolder}.
*/
public static async ensureEmptyFolderAsync(folderPath: string): Promise<void> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same feedback as the sync version applies here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Needs triage

Development

Successfully merging this pull request may close these issues.

[heft] Windows support regression

3 participants