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 0000000000..839e9826ea --- /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`, `FileSystem.deleteFolderAsync`, `FileSystem.ensureEmptyFolder`, and `FileSystem.ensureEmptyFolderAsync` 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 0975847f35..9b6a399e22 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. * @@ -722,14 +737,14 @@ 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()`. * @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, FS_RM_DEFAULT_OPTIONS); }); } @@ -738,13 +753,13 @@ export class FileSystem { */ public static async deleteFolderAsync(folderPath: string): Promise { await FileSystem._wrapExceptionAsync(() => { - return fsx.remove(folderPath); + return fsPromises.rm(folderPath, FS_RM_DEFAULT_OPTIONS); }); } /** * 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. @@ -752,7 +767,20 @@ export class FileSystem { */ public static ensureEmptyFolder(folderPath: string): void { FileSystem._wrapException(() => { - fsx.emptyDirSync(folderPath); + let items: FolderItem[]; + try { + items = this.readFolderItems(folderPath); + } catch (error) { + if (!FileSystem.isNotExistError(error as Error)) { + throw error; + } + fsx.ensureDirSync(folderPath); + return; + } + for (const item of items) { + const itemPath: string = nodeJsPath.join(item.parentPath, item.name); + fs.rmSync(itemPath, FS_RM_DEFAULT_OPTIONS); + } }); } @@ -760,8 +788,25 @@ 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: FolderItem[]; + try { + items = await this.readFolderItemsAsync(folderPath); + } catch (error) { + if (!FileSystem.isNotExistError(error as Error)) { + throw error; + } + await fsx.ensureDir(folderPath); + return; + } + 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 8aa3432f6c..80e0ef28fe 100644 --- a/libraries/node-core-library/src/test/FileSystem.test.ts +++ b/libraries/node-core-library/src/test/FileSystem.test.ts @@ -149,6 +149,92 @@ 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([]); + }); + + 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, () => { + 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([]); + }); + + 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, () => { const tempDir: string = `${testTempFolder}/createWriteStreamAsync`;