From 5eb5fd6537c3a14e3f47fa999c8ccc496a5326dc Mon Sep 17 00:00:00 2001 From: Bart van den Ende Date: Tue, 16 Jun 2026 21:03:33 +0200 Subject: [PATCH 1/3] fix: [node-core-library] Retry transient Windows EPERM/EBUSY in FileSystem.deleteFolder(Async) --- .../bartvandenende-wm-5373_2026-06-16-19-03.json | 10 ++++++++++ libraries/node-core-library/src/FileSystem.ts | 7 ++++--- 2 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 common/changes/@rushstack/node-core-library/bartvandenende-wm-5373_2026-06-16-19-03.json diff --git a/common/changes/@rushstack/node-core-library/bartvandenende-wm-5373_2026-06-16-19-03.json b/common/changes/@rushstack/node-core-library/bartvandenende-wm-5373_2026-06-16-19-03.json new file mode 100644 index 00000000000..a59d5305c60 --- /dev/null +++ b/common/changes/@rushstack/node-core-library/bartvandenende-wm-5373_2026-06-16-19-03.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/node-core-library", + "comment": "Make FileSystem.deleteFolder and FileSystem.deleteFolderAsync resilient to transient Windows EPERM/EBUSY/ENOTEMPTY errors by using native fs.rm with retries.", + "type": "patch" + } + ], + "packageName": "@rushstack/node-core-library" +} \ No newline at end of file diff --git a/libraries/node-core-library/src/FileSystem.ts b/libraries/node-core-library/src/FileSystem.ts index 0975847f35d..3cc23b67536 100644 --- a/libraries/node-core-library/src/FileSystem.ts +++ b/libraries/node-core-library/src/FileSystem.ts @@ -722,14 +722,15 @@ export class FileSystem { /** * Deletes a folder, including all of its contents. - * Behind the scenes is uses `fs-extra.removeSync()`. + * Behind the scenes it uses `fs.rmSync()` with `recursive: true` and `force: true`, + * along with `maxRetries` to tolerate transient `EPERM`/`EBUSY`/`ENOTEMPTY` errors on windows. * @remarks * Does not throw if the folderPath does not exist. * @param folderPath - The absolute or relative path to the folder which should be deleted. */ public static deleteFolder(folderPath: string): void { FileSystem._wrapException(() => { - fsx.removeSync(folderPath); + fs.rmSync(folderPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); }); } @@ -738,7 +739,7 @@ export class FileSystem { */ public static async deleteFolderAsync(folderPath: string): Promise { await FileSystem._wrapExceptionAsync(() => { - return fsx.remove(folderPath); + return fsPromises.rm(folderPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); }); } From 45473becfa71701afc799f2d43c3a686a2275314 Mon Sep 17 00:00:00 2001 From: Bart van den Ende Date: Wed, 17 Jun 2026 00:03:19 +0200 Subject: [PATCH 2/3] chore: apply the patch consistent to FileSystem.ensureEmptyFolder and FileSystem.ensureEmptyFolderAsync --- ...rtvandenende-wm-5373_2026-06-16-19-03.json | 2 +- libraries/node-core-library/src/FileSystem.ts | 41 ++++++++++++-- .../src/test/FileSystem.test.ts | 56 +++++++++++++++++++ 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/common/changes/@rushstack/node-core-library/bartvandenende-wm-5373_2026-06-16-19-03.json b/common/changes/@rushstack/node-core-library/bartvandenende-wm-5373_2026-06-16-19-03.json index a59d5305c60..03a5a116d20 100644 --- a/common/changes/@rushstack/node-core-library/bartvandenende-wm-5373_2026-06-16-19-03.json +++ b/common/changes/@rushstack/node-core-library/bartvandenende-wm-5373_2026-06-16-19-03.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@rushstack/node-core-library", - "comment": "Make FileSystem.deleteFolder and FileSystem.deleteFolderAsync 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.", "type": "patch" } ], diff --git a/libraries/node-core-library/src/FileSystem.ts b/libraries/node-core-library/src/FileSystem.ts index 3cc23b67536..a2fc3145d41 100644 --- a/libraries/node-core-library/src/FileSystem.ts +++ b/libraries/node-core-library/src/FileSystem.ts @@ -722,8 +722,7 @@ export class FileSystem { /** * Deletes a folder, including all of its contents. - * Behind the scenes it uses `fs.rmSync()` with `recursive: true` and `force: true`, - * along with `maxRetries` to tolerate transient `EPERM`/`EBUSY`/`ENOTEMPTY` errors on windows. + * Behind the scenes it uses `fs.rmSync()`. * @remarks * Does not throw if the folderPath does not exist. * @param folderPath - The absolute or relative path to the folder which should be deleted. @@ -745,7 +744,7 @@ export class FileSystem { /** * Deletes the content of a folder, but not the folder itself. Also ensures the folder exists. - * Behind the scenes it uses `fs-extra.emptyDirSync()`. + * Each child entry is removed via `fs.rmSync()`. * @remarks * This is a workaround for a common race condition, where the virus scanner holds a lock on the folder * for a brief period after it was deleted, causing EBUSY errors for any code that tries to recreate the folder. @@ -753,7 +752,21 @@ export class FileSystem { */ public static ensureEmptyFolder(folderPath: string): void { FileSystem._wrapException(() => { - fsx.emptyDirSync(folderPath); + let items: string[]; + try { + items = fsx.readdirSync(folderPath); + } catch { + fsx.ensureDirSync(folderPath); + return; + } + for (const item of items) { + fs.rmSync(nodeJsPath.join(folderPath, item), { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100 + }); + } }); } @@ -761,8 +774,24 @@ export class FileSystem { * An async version of {@link FileSystem.ensureEmptyFolder}. */ public static async ensureEmptyFolderAsync(folderPath: string): Promise { - await FileSystem._wrapExceptionAsync(() => { - return fsx.emptyDir(folderPath); + await FileSystem._wrapExceptionAsync(async () => { + let items: string[]; + try { + items = await fsx.readdir(folderPath); + } catch { + await fsx.ensureDir(folderPath); + return; + } + await Promise.all( + items.map((item) => + fsPromises.rm(nodeJsPath.join(folderPath, item), { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100 + }) + ) + ); }); } diff --git a/libraries/node-core-library/src/test/FileSystem.test.ts b/libraries/node-core-library/src/test/FileSystem.test.ts index 8aa3432f6c3..83358e7faae 100644 --- a/libraries/node-core-library/src/test/FileSystem.test.ts +++ b/libraries/node-core-library/src/test/FileSystem.test.ts @@ -149,6 +149,62 @@ describe(FileSystem.name, () => { }); }); + describe(FileSystem.ensureEmptyFolder.name, () => { + const tempDir: string = `${testTempFolder}/ensureEmptyFolder`; + + afterEach(() => { + FileSystem.deleteFolder(tempDir); + }); + + test('empties an existing folder but keeps the folder itself', () => { + FileSystem.ensureFolder(`${tempDir}/sub`); + FileSystem.writeFile(`${tempDir}/a.txt`, 'a'); + FileSystem.writeFile(`${tempDir}/sub/b.txt`, 'b'); + + FileSystem.ensureEmptyFolder(tempDir); + + expect(fs.existsSync(tempDir)).toBe(true); + expect(fs.readdirSync(tempDir)).toEqual([]); + }); + + test('creates the folder when it does not exist', () => { + expect(fs.existsSync(tempDir)).toBe(false); + + FileSystem.ensureEmptyFolder(tempDir); + + expect(fs.existsSync(tempDir)).toBe(true); + expect(fs.readdirSync(tempDir)).toEqual([]); + }); + }); + + describe(FileSystem.ensureEmptyFolderAsync.name, () => { + const tempDir: string = `${testTempFolder}/ensureEmptyFolderAsync`; + + afterEach(async () => { + await FileSystem.deleteFolderAsync(tempDir); + }); + + test('empties an existing folder but keeps the folder itself', async () => { + await FileSystem.ensureFolderAsync(`${tempDir}/sub`); + await FileSystem.writeFileAsync(`${tempDir}/a.txt`, 'a'); + await FileSystem.writeFileAsync(`${tempDir}/sub/b.txt`, 'b'); + + await FileSystem.ensureEmptyFolderAsync(tempDir); + + expect(fs.existsSync(tempDir)).toBe(true); + expect(fs.readdirSync(tempDir)).toEqual([]); + }); + + test('creates the folder when it does not exist', async () => { + expect(fs.existsSync(tempDir)).toBe(false); + + await FileSystem.ensureEmptyFolderAsync(tempDir); + + expect(fs.existsSync(tempDir)).toBe(true); + expect(fs.readdirSync(tempDir)).toEqual([]); + }); + }); + describe(FileSystem.createWriteStreamAsync.name, () => { const tempDir: string = `${testTempFolder}/createWriteStreamAsync`; From 81fd31f695402c8e125d5cecb043e654bd4ada15 Mon Sep 17 00:00:00 2001 From: Bart van den Ende Date: Sat, 18 Jul 2026 13:05:38 +0200 Subject: [PATCH 3/3] chore: PR feedback --- ...rtvandenende-wm-5373_2026-06-16-19-03.json | 2 +- libraries/node-core-library/src/FileSystem.ts | 61 ++++++++++++------- .../src/test/FileSystem.test.ts | 30 +++++++++ 3 files changed, 69 insertions(+), 24 deletions(-) diff --git a/common/changes/@rushstack/node-core-library/bartvandenende-wm-5373_2026-06-16-19-03.json b/common/changes/@rushstack/node-core-library/bartvandenende-wm-5373_2026-06-16-19-03.json index 03a5a116d20..839e9826eac 100644 --- a/common/changes/@rushstack/node-core-library/bartvandenende-wm-5373_2026-06-16-19-03.json +++ b/common/changes/@rushstack/node-core-library/bartvandenende-wm-5373_2026-06-16-19-03.json @@ -2,7 +2,7 @@ "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.", + "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.", "type": "patch" } ], diff --git a/libraries/node-core-library/src/FileSystem.ts b/libraries/node-core-library/src/FileSystem.ts index a2fc3145d41..9b6a399e22d 100644 --- a/libraries/node-core-library/src/FileSystem.ts +++ b/libraries/node-core-library/src/FileSystem.ts @@ -9,6 +9,7 @@ import * as fsx from 'fs-extra'; import { Text, type NewlineKind, Encoding } from './Text'; import { PosixModeBits } from './PosixModeBits'; +import { Async } from './Async'; /** * An alias for the Node.js `fs.Stats` object. @@ -377,6 +378,20 @@ const DELETE_FILE_DEFAULT_OPTIONS: Partial = { throwIfNotExists: false }; +/** + * Default options for fs.rm / fsPromises.rm calls in this file. + * + * @remarks + * The retry settings mitigate transient EPERM/EBUSY/ENOTEMPTY errors on + * Windows (e.g. AV scanners holding a lock right after a delete). + */ +const FS_RM_DEFAULT_OPTIONS: Partial = { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100 +}; + /** * The FileSystem API provides a complete set of recommended operations for interacting with the file system. * @@ -729,7 +744,7 @@ export class FileSystem { */ public static deleteFolder(folderPath: string): void { FileSystem._wrapException(() => { - fs.rmSync(folderPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + fs.rmSync(folderPath, FS_RM_DEFAULT_OPTIONS); }); } @@ -738,7 +753,7 @@ export class FileSystem { */ public static async deleteFolderAsync(folderPath: string): Promise { await FileSystem._wrapExceptionAsync(() => { - return fsPromises.rm(folderPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + return fsPromises.rm(folderPath, FS_RM_DEFAULT_OPTIONS); }); } @@ -752,20 +767,19 @@ export class FileSystem { */ public static ensureEmptyFolder(folderPath: string): void { FileSystem._wrapException(() => { - let items: string[]; + let items: FolderItem[]; try { - items = fsx.readdirSync(folderPath); - } catch { + items = this.readFolderItems(folderPath); + } catch (error) { + if (!FileSystem.isNotExistError(error as Error)) { + throw error; + } fsx.ensureDirSync(folderPath); return; } for (const item of items) { - fs.rmSync(nodeJsPath.join(folderPath, item), { - recursive: true, - force: true, - maxRetries: 3, - retryDelay: 100 - }); + const itemPath: string = nodeJsPath.join(item.parentPath, item.name); + fs.rmSync(itemPath, FS_RM_DEFAULT_OPTIONS); } }); } @@ -775,22 +789,23 @@ export class FileSystem { */ public static async ensureEmptyFolderAsync(folderPath: string): Promise { await FileSystem._wrapExceptionAsync(async () => { - let items: string[]; + let items: FolderItem[]; try { - items = await fsx.readdir(folderPath); - } catch { + items = await this.readFolderItemsAsync(folderPath); + } catch (error) { + if (!FileSystem.isNotExistError(error as Error)) { + throw error; + } await fsx.ensureDir(folderPath); return; } - await Promise.all( - items.map((item) => - fsPromises.rm(nodeJsPath.join(folderPath, item), { - recursive: true, - force: true, - maxRetries: 3, - retryDelay: 100 - }) - ) + await Async.forEachAsync( + items, + (item) => { + const itemPath: string = nodeJsPath.join(item.parentPath, item.name); + return fsPromises.rm(itemPath, FS_RM_DEFAULT_OPTIONS); + }, + { concurrency: 10 } ); }); } diff --git a/libraries/node-core-library/src/test/FileSystem.test.ts b/libraries/node-core-library/src/test/FileSystem.test.ts index 83358e7faae..80e0ef28fea 100644 --- a/libraries/node-core-library/src/test/FileSystem.test.ts +++ b/libraries/node-core-library/src/test/FileSystem.test.ts @@ -175,6 +175,21 @@ describe(FileSystem.name, () => { expect(fs.existsSync(tempDir)).toBe(true); expect(fs.readdirSync(tempDir)).toEqual([]); }); + + test('re-throws errors from readFolderItems that are not not-exist errors', () => { + const permError: NodeJS.ErrnoException = Object.assign(new Error('EACCES: permission denied'), { + code: 'EACCES' + }); + const spy: jest.SpyInstance = jest.spyOn(FileSystem, 'readFolderItems').mockImplementationOnce(() => { + throw permError; + }); + + try { + expect(() => FileSystem.ensureEmptyFolder(tempDir)).toThrow(/EACCES/); + } finally { + spy.mockRestore(); + } + }); }); describe(FileSystem.ensureEmptyFolderAsync.name, () => { @@ -203,6 +218,21 @@ describe(FileSystem.name, () => { expect(fs.existsSync(tempDir)).toBe(true); expect(fs.readdirSync(tempDir)).toEqual([]); }); + + test('re-throws errors from readFolderItemsAsync that are not not-exist errors', async () => { + const permError: NodeJS.ErrnoException = Object.assign(new Error('EACCES: permission denied'), { + code: 'EACCES' + }); + const spy: jest.SpyInstance = jest + .spyOn(FileSystem, 'readFolderItemsAsync') + .mockRejectedValueOnce(permError); + + try { + await expect(FileSystem.ensureEmptyFolderAsync(tempDir)).rejects.toThrow(/EACCES/); + } finally { + spy.mockRestore(); + } + }); }); describe(FileSystem.createWriteStreamAsync.name, () => {