feat: Add async overloads for File and Folder operations#1722
feat: Add async overloads for File and Folder operations#1722
Conversation
Fixes #1562 Adds async versions of sync-only file system operations: File.cs: - CreateAsync() - async file creation with proper stream disposal - DeleteAsync(CancellationToken) - async file deletion - MoveToAsync(string/Folder, CancellationToken) - async file move - CopyToAsync(string/Folder, CancellationToken) - async stream-based copy Folder.cs: - CreateAsync() - async folder creation - DeleteAsync(CancellationToken) - async recursive folder deletion - MoveToAsync(string, CancellationToken) - async folder move - CopyToAsync(string, bool, CancellationToken) - async stream-based folder copy Benefits: - Prevents thread pool starvation in highly concurrent pipelines - Uses stream-based copying for truly async I/O - Supports cancellation tokens for long-running operations - Consistent API with existing async methods (Read/Write) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
SummaryAdds async versions of file system operations (Create, Delete, Move, Copy) to File and Folder classes. Critical Issues1. Improper use of Task.Run for I/O operationsLocation: Multiple methods in both File.cs and Folder.cs The PR wraps synchronous I/O operations in // File.cs - DeleteAsync
public Task DeleteAsync(CancellationToken cancellationToken = default)
{
LogFileOperation("Deleting File: {Path} [Module: {ModuleName}, Activity: {ActivityId}]", this);
return Task.Run(() => FileInfo.Delete(), cancellationToken);
}
// Folder.cs - CreateAsync
public Task<Folder> CreateAsync()
{
LogFolderOperation("Creating Folder: {Path} [Module: {ModuleName}, Activity: {ActivityId}]", this);
return Task.Run(() =>
{
Directory.CreateDirectory(Path);
return this;
});
}Problem:
Solution: Either:
Note that 2. Missing ConfigureAwait in Folder.CreateAsyncLocation: Folder.cs line 99-116 public async Task<File> MoveToAsync(Folder folder, CancellationToken cancellationToken = default)
{
// ...
await folder.CreateAsync().ConfigureAwait(false); // ConfigureAwait used here
// ...
}
public Task<Folder> CreateAsync() // But CreateAsync doesn't use it
{
LogFolderOperation("Creating Folder: {Path} [Module: {ModuleName}, Activity: {ActivityId}]", this);
return Task.Run(() =>
{
Directory.CreateDirectory(Path);
return this;
});
}Problem: Since 3. Resource leak risk in File.CopyToAsyncLocation: File.cs lines 284-304 var sourceStream = System.IO.File.OpenRead(Path);
await using (sourceStream.ConfigureAwait(false))
{
var destStream = System.IO.File.Create(path);
await using (destStream.ConfigureAwait(false))
{
await sourceStream.CopyToAsync(destStream, cancellationToken).ConfigureAwait(false);
}
}Problem: If Solution: await using var sourceStream = System.IO.File.OpenRead(Path);
await using var destStream = System.IO.File.Create(path);
await sourceStream.CopyToAsync(destStream, cancellationToken).ConfigureAwait(false);Or separate the declarations to ensure proper nesting. 4. Same resource leak pattern in Folder.CopyToAsyncLocation: Folder.cs lines 356-369 (in the diff) Same issue as #3 - the nested SuggestionsConsider buffering for large file copiesThe Verdict |
Address review feedback: - Simplify CopyToAsync to use 'await using var' declarations (cleaner syntax) - Add <remarks> documenting that Task.Run is used for operations without native async APIs (.NET has no async file delete/move/directory create) - This is a common pattern for thread pool offloading of blocking I/O
There was a problem hiding this comment.
Pull request overview
This PR adds asynchronous overloads for file and folder operations (Create, Delete, Move, Copy) with the goal of preventing thread pool starvation in highly concurrent pipelines. However, several of the implementations use Task.Run to wrap synchronous operations, which actually increases thread pool pressure rather than reducing it, contradicting the stated goal.
Key changes:
- Adds async methods for File operations:
CreateAsync,DeleteAsync,MoveToAsync,CopyToAsync - Adds async methods for Folder operations:
CreateAsync,DeleteAsync,MoveToAsync,CopyToAsync - CopyTo implementations use stream-based async I/O (properly async)
- Create, Delete, and MoveTo implementations wrap sync operations in Task.Run (problematic)
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 21 comments.
| File | Description |
|---|---|
| src/ModularPipelines/FileSystem/File.cs | Adds async overloads for file operations including stream-based async copying, but wraps synchronous operations (create, delete, move) in Task.Run |
| src/ModularPipelines/FileSystem/Folder.cs | Adds async overloads for folder operations including stream-based async copying with timestamp preservation, but wraps synchronous operations (create, delete, move) in Task.Run |
| /// <summary> | ||
| /// Asynchronously copies the folder and its contents to the specified target path using stream-based file copying. | ||
| /// </summary> | ||
| /// <param name="targetPath">The destination path for the copied folder.</param> | ||
| /// <param name="cancellationToken">Cancellation token.</param> | ||
| /// <returns>A new <see cref="Folder"/> instance representing the copied folder.</returns> | ||
| public Task<Folder> CopyToAsync(string targetPath, CancellationToken cancellationToken = default) | ||
| { | ||
| return CopyToAsync(targetPath, preserveTimestamps: false, cancellationToken); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Asynchronously copies the folder and its contents to the specified target path using stream-based file copying. | ||
| /// </summary> | ||
| /// <param name="targetPath">The destination path for the copied folder.</param> | ||
| /// <param name="preserveTimestamps"> | ||
| /// When true, preserves CreationTimeUtc, LastWriteTimeUtc, and LastAccessTimeUtc | ||
| /// for all files and directories. | ||
| /// </param> | ||
| /// <param name="cancellationToken">Cancellation token.</param> | ||
| /// <returns>A new <see cref="Folder"/> instance representing the copied folder.</returns> | ||
| public async Task<Folder> CopyToAsync(string targetPath, bool preserveTimestamps, CancellationToken cancellationToken = default) | ||
| { | ||
| LogFolderOperationWithDestination("Copying Folder: {Source} > {Destination} [Module: {ModuleName}, Activity: {ActivityId}]", this, targetPath); | ||
|
|
||
| Directory.CreateDirectory(targetPath); | ||
|
|
||
| // Copy all subdirectories first | ||
| foreach (var dirPath in Directory.EnumerateDirectories(this, "*", SearchOption.AllDirectories)) | ||
| { | ||
| cancellationToken.ThrowIfCancellationRequested(); | ||
|
|
||
| var sourceDir = new DirectoryInfo(dirPath); | ||
| var relativePath = System.IO.Path.GetRelativePath(this, dirPath); | ||
| var newPath = System.IO.Path.Combine(targetPath, relativePath); | ||
| var targetDir = Directory.CreateDirectory(newPath); | ||
|
|
||
| targetDir.Attributes = sourceDir.Attributes; | ||
|
|
||
| if (preserveTimestamps) | ||
| { | ||
| targetDir.CreationTimeUtc = sourceDir.CreationTimeUtc; | ||
| targetDir.LastWriteTimeUtc = sourceDir.LastWriteTimeUtc; | ||
| targetDir.LastAccessTimeUtc = sourceDir.LastAccessTimeUtc; | ||
| } | ||
| } | ||
|
|
||
| // Copy all files using async stream copying | ||
| foreach (var filePath in Directory.EnumerateFiles(this, "*", SearchOption.AllDirectories)) | ||
| { | ||
| cancellationToken.ThrowIfCancellationRequested(); | ||
|
|
||
| var sourceFile = new FileInfo(filePath); | ||
| var relativePath = System.IO.Path.GetRelativePath(this, filePath); | ||
| var newPath = System.IO.Path.Combine(targetPath, relativePath); | ||
|
|
||
| await using var sourceStream = System.IO.File.OpenRead(filePath); | ||
| await using var destStream = System.IO.File.Create(newPath); | ||
| await sourceStream.CopyToAsync(destStream, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| var targetFile = new FileInfo(newPath); | ||
| targetFile.Attributes = sourceFile.Attributes; | ||
|
|
||
| if (preserveTimestamps) | ||
| { | ||
| targetFile.CreationTimeUtc = sourceFile.CreationTimeUtc; | ||
| targetFile.LastWriteTimeUtc = sourceFile.LastWriteTimeUtc; | ||
| targetFile.LastAccessTimeUtc = sourceFile.LastAccessTimeUtc; | ||
| } | ||
| } | ||
|
|
||
| // Preserve root directory attributes and timestamps | ||
| var targetRootDir = new DirectoryInfo(targetPath); | ||
| targetRootDir.Attributes = DirectoryInfo.Attributes; | ||
|
|
||
| if (preserveTimestamps) | ||
| { | ||
| targetRootDir.CreationTimeUtc = DirectoryInfo.CreationTimeUtc; | ||
| targetRootDir.LastWriteTimeUtc = DirectoryInfo.LastWriteTimeUtc; | ||
| targetRootDir.LastAccessTimeUtc = DirectoryInfo.LastAccessTimeUtc; | ||
| } | ||
|
|
||
| return new Folder(targetPath); | ||
| } |
There was a problem hiding this comment.
The new CopyToAsync methods lack test coverage. Since folder operations have comprehensive test coverage in FolderTests.cs, the async versions should have corresponding tests to verify stream-based copying works correctly, handles cancellation tokens properly, preserves file content and timestamps when requested, and correctly copies nested directory structures.
| /// <summary> | ||
| /// Asynchronously copies the file to a new path using stream-based copying. | ||
| /// </summary> | ||
| /// <param name="path">The destination path.</param> | ||
| /// <param name="cancellationToken">Cancellation token.</param> | ||
| /// <returns>A new File instance representing the copied file.</returns> | ||
| public async Task<File> CopyToAsync(string path, CancellationToken cancellationToken = default) | ||
| { | ||
| LogFileOperationWithDestination("Copying File: {Source} > {Destination} [Module: {ModuleName}, Activity: {ActivityId}]", this, path); |
There was a problem hiding this comment.
The file streams are not properly disposed if an exception occurs between opening the source stream and the await using block. Consider declaring the variable inside the await using statement or using a try-finally block to ensure proper disposal in all error cases.
| cancellationToken.ThrowIfCancellationRequested(); | ||
|
|
||
| var sourceFile = new FileInfo(filePath); | ||
| var relativePath = System.IO.Path.GetRelativePath(this, filePath); | ||
| var newPath = System.IO.Path.Combine(targetPath, relativePath); | ||
|
|
||
| await using var sourceStream = System.IO.File.OpenRead(filePath); | ||
| await using var destStream = System.IO.File.Create(newPath); | ||
| await sourceStream.CopyToAsync(destStream, cancellationToken).ConfigureAwait(false); |
There was a problem hiding this comment.
The file streams are not properly disposed if an exception occurs between opening the source stream and the await using block. Consider declaring the variable inside the await using statement or using a try-finally block to ensure proper disposal in all error cases.
| /// Asynchronously deletes the folder and all its contents. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Uses thread pool offloading as no native async delete API exists in .NET. | ||
| /// </remarks> | ||
| /// <param name="cancellationToken">Cancellation token.</param> |
There was a problem hiding this comment.
This implementation wraps a synchronous operation (DirectoryInfo.Delete) in Task.Run, which contradicts the PR's goal of preventing thread pool starvation. Wrapping synchronous operations in Task.Run actually increases thread pool usage rather than reducing it. The PR description claims stream-based async operations, but this is just offloading sync work to the thread pool.
| /// <param name="path">The destination path.</param> | ||
| /// <param name="cancellationToken">Cancellation token.</param> | ||
| /// <returns>This folder instance for method chaining.</returns> | ||
| public Task<Folder> MoveToAsync(string path, CancellationToken cancellationToken = default) | ||
| { | ||
| LogFolderOperationWithDestination("Moving Folder: {Source} > {Destination} [Module: {ModuleName}, Activity: {ActivityId}]", this, path); | ||
|
|
||
| return Task.Run(() => | ||
| { | ||
| DirectoryInfo.MoveTo(path); |
There was a problem hiding this comment.
The new MoveToAsync method lacks test coverage. Since folder operations have comprehensive test coverage in FolderTests.cs, the async version should have corresponding tests to verify it works correctly, properly handles cancellation tokens, and updates the folder path as expected.
| /// Uses thread pool offloading as no native async directory creation API exists in .NET. | ||
| /// </remarks> | ||
| /// <returns>This folder instance for method chaining.</returns> | ||
| public Task<Folder> CreateAsync() | ||
| { | ||
| LogFolderOperation("Creating Folder: {Path} [Module: {ModuleName}, Activity: {ActivityId}]", this); | ||
|
|
||
| return Task.Run(() => | ||
| { | ||
| Directory.CreateDirectory(Path); | ||
| return this; | ||
| }); |
There was a problem hiding this comment.
This implementation wraps a synchronous operation (Directory.CreateDirectory) in Task.Run, which defeats the stated goal of preventing thread pool starvation. Directory.CreateDirectory is already an efficient synchronous operation that doesn't benefit from being wrapped in Task.Run. Consider either removing this async overload or making it truly async if there's a genuine async implementation available. The PR description claims to use truly async I/O, but this is just a Task.Run wrapper.
| /// Uses thread pool offloading as no native async directory creation API exists in .NET. | |
| /// </remarks> | |
| /// <returns>This folder instance for method chaining.</returns> | |
| public Task<Folder> CreateAsync() | |
| { | |
| LogFolderOperation("Creating Folder: {Path} [Module: {ModuleName}, Activity: {ActivityId}]", this); | |
| return Task.Run(() => | |
| { | |
| Directory.CreateDirectory(Path); | |
| return this; | |
| }); | |
| /// This method performs directory creation synchronously and returns a completed task. | |
| /// </remarks> | |
| /// <returns>This folder instance for method chaining.</returns> | |
| public Task<Folder> CreateAsync() | |
| { | |
| LogFolderOperation("Creating Folder: {Path} [Module: {ModuleName}, Activity: {ActivityId}]", this); | |
| Directory.CreateDirectory(Path); | |
| return Task.FromResult(this); |
| { | ||
| LogFolderOperationWithDestination("Copying Folder: {Source} > {Destination} [Module: {ModuleName}, Activity: {ActivityId}]", this, targetPath); | ||
|
|
||
| Directory.CreateDirectory(targetPath); | ||
|
|
||
| // Copy all subdirectories first | ||
| foreach (var dirPath in Directory.EnumerateDirectories(this, "*", SearchOption.AllDirectories)) | ||
| { | ||
| cancellationToken.ThrowIfCancellationRequested(); | ||
|
|
||
| var sourceDir = new DirectoryInfo(dirPath); | ||
| var relativePath = System.IO.Path.GetRelativePath(this, dirPath); | ||
| var newPath = System.IO.Path.Combine(targetPath, relativePath); | ||
| var targetDir = Directory.CreateDirectory(newPath); | ||
|
|
||
| targetDir.Attributes = sourceDir.Attributes; | ||
|
|
||
| if (preserveTimestamps) |
There was a problem hiding this comment.
This synchronous enumeration with Directory.EnumerateDirectories blocks the calling thread. Consider using an approach that processes directories asynchronously, or at minimum, document that this portion is synchronous despite being in an async method.
| targetDir.LastWriteTimeUtc = sourceDir.LastWriteTimeUtc; | ||
| targetDir.LastAccessTimeUtc = sourceDir.LastAccessTimeUtc; | ||
| } | ||
| } | ||
|
|
||
| // Copy all files using async stream copying | ||
| foreach (var filePath in Directory.EnumerateFiles(this, "*", SearchOption.AllDirectories)) | ||
| { | ||
| cancellationToken.ThrowIfCancellationRequested(); | ||
|
|
||
| var sourceFile = new FileInfo(filePath); | ||
| var relativePath = System.IO.Path.GetRelativePath(this, filePath); | ||
| var newPath = System.IO.Path.Combine(targetPath, relativePath); | ||
|
|
||
| await using var sourceStream = System.IO.File.OpenRead(filePath); | ||
| await using var destStream = System.IO.File.Create(newPath); | ||
| await sourceStream.CopyToAsync(destStream, cancellationToken).ConfigureAwait(false); | ||
|
|
||
| var targetFile = new FileInfo(newPath); | ||
| targetFile.Attributes = sourceFile.Attributes; | ||
|
|
||
| if (preserveTimestamps) | ||
| { | ||
| targetFile.CreationTimeUtc = sourceFile.CreationTimeUtc; | ||
| targetFile.LastWriteTimeUtc = sourceFile.LastWriteTimeUtc; | ||
| targetFile.LastAccessTimeUtc = sourceFile.LastAccessTimeUtc; | ||
| } | ||
| } |
There was a problem hiding this comment.
This synchronous enumeration with Directory.EnumerateFiles blocks the calling thread. Consider using an approach that processes files asynchronously, or at minimum, document that the enumeration portion is synchronous despite being in an async method.
| /// Uses thread pool offloading as no native async directory creation API exists in .NET. | ||
| /// </remarks> | ||
| /// <returns>This folder instance for method chaining.</returns> | ||
| public Task<Folder> CreateAsync() | ||
| { | ||
| LogFolderOperation("Creating Folder: {Path} [Module: {ModuleName}, Activity: {ActivityId}]", this); | ||
|
|
||
| return Task.Run(() => | ||
| { | ||
| Directory.CreateDirectory(Path); |
There was a problem hiding this comment.
The new CreateAsync method lacks test coverage. Since file system operations have comprehensive test coverage in FolderTests.cs, the async version should have corresponding tests to verify it works correctly and handles edge cases.
| /// Asynchronously deletes the folder and all its contents. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Uses thread pool offloading as no native async delete API exists in .NET. | ||
| /// </remarks> | ||
| /// <param name="cancellationToken">Cancellation token.</param> |
There was a problem hiding this comment.
The new DeleteAsync method lacks test coverage. Since folder operations have comprehensive test coverage in FolderTests.cs, the async version should have corresponding tests to verify it works correctly, properly handles cancellation tokens, and deletes all contents recursively.
SummaryAdds async versions of file and folder operations (Create, Delete, Move, Copy) with proper stream-based I/O for copy operations. Critical Issues1. Inconsistent CancellationToken support in Folder.CreateAsync() (src/ModularPipelines/FileSystem/Folder.cs:99)
Recommended fix: public Task<Folder> CreateAsync(CancellationToken cancellationToken = default)
{
LogFolderOperation("Creating Folder: {Path} [Module: {ModuleName}, Activity: {ActivityId}]", this);
return Task.Run(() =>
{
Directory.CreateDirectory(Path);
return this;
}, cancellationToken);
}2. Potential file leak in File.CopyToAsync() (src/ModularPipelines/FileSystem/File.cs:298) If an exception occurs after Current code: await using var sourceStream = System.IO.File.OpenRead(Path);
await using var destStream = System.IO.File.Create(path); // If exception here, sourceStream leaked
await sourceStream.CopyToAsync(destStream, cancellationToken).ConfigureAwait(false);This is minor since SuggestionsPerformance: Parallel file copying in Folder.CopyToAsync() (src/ModularPipelines/FileSystem/Folder.cs:314) The folder copy operation processes files sequentially. For folders with many files, consider using parallel copying (e.g., Example approach: var fileCopyTasks = Directory.EnumerateFiles(this, "*", SearchOption.AllDirectories)
.Select(async filePath => { /* copy logic */ });
await Task.WhenAll(fileCopyTasks);Consistency: Missing async Clean methods The Previous Review StatusUnable to retrieve previous review comments due to API token permissions. Verdict |
Add optional CancellationToken parameter to CreateAsync for API consistency with other async methods in the class. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
SummaryAdds async versions of file and folder operations (Create, Delete, Move, Copy) to the FileSystem API. Critical IssuesNone found ✅ Suggestions1. Consider Directory.CreateDirectory cancellation in Folder.CopyToAsyncIn cancellationToken.ThrowIfCancellationRequested();
Directory.CreateDirectory(targetPath);This ensures the method can exit early if cancellation is requested before starting the copy operation. 2. Stream buffer size optimization (optional)The file copy operations use the default buffer size in await sourceStream.CopyToAsync(destStream, 81920, cancellationToken).ConfigureAwait(false);This is a minor performance optimization and not blocking. Verdict✅ APPROVE - No critical issues The implementation follows good async patterns:
|
Summary
Fixes #1562
Adds async versions of sync-only file system operations to prevent thread pool starvation in highly concurrent pipelines.
File.cs
CreateAsync()- async file creation with proper stream disposalDeleteAsync(CancellationToken)- async file deletionMoveToAsync(string/Folder, CancellationToken)- async file moveCopyToAsync(string/Folder, CancellationToken)- async stream-based copyFolder.cs
CreateAsync()- async folder creationDeleteAsync(CancellationToken)- async recursive folder deletionMoveToAsync(string, CancellationToken)- async folder moveCopyToAsync(string, bool, CancellationToken)- async stream-based folder copy with optional timestamp preservationBenefits
Test plan
🤖 Generated with Claude Code