Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
59 changes: 52 additions & 7 deletions libraries/node-core-library/src/FileSystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -377,6 +378,20 @@ const DELETE_FILE_DEFAULT_OPTIONS: Partial<IFileSystemDeleteFileOptions> = {
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<fs.RmOptions> = {
recursive: true,
force: true,
maxRetries: 3,
retryDelay: 100
};

/**
* The FileSystem API provides a complete set of recommended operations for interacting with the file system.
*
Expand Down Expand Up @@ -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);
});
}

Expand All @@ -738,30 +753,60 @@ export class FileSystem {
*/
public static async deleteFolderAsync(folderPath: string): Promise<void> {
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.
* @param folderPath - The absolute or relative path to the folder which should have its contents deleted.
*/
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);
Comment thread
bartvandenende-wm marked this conversation as resolved.
return;
}
for (const item of items) {
const itemPath: string = nodeJsPath.join(item.parentPath, item.name);
fs.rmSync(itemPath, FS_RM_DEFAULT_OPTIONS);
}
});
}

/**
* An async version of {@link FileSystem.ensureEmptyFolder}.
*/
public static async ensureEmptyFolderAsync(folderPath: string): Promise<void> {
Comment thread
bartvandenende-wm marked this conversation as resolved.
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 }
);
});
}

Expand Down
86 changes: 86 additions & 0 deletions libraries/node-core-library/src/test/FileSystem.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand Down