Skip to content

Add IAsyncEnumerable streaming directory listing (Issue #45)#54

Merged
sparkeh9 merged 2 commits intomasterfrom
feature/async-enumerable-listing
Mar 1, 2026
Merged

Add IAsyncEnumerable streaming directory listing (Issue #45)#54
sparkeh9 merged 2 commits intomasterfrom
feature/async-enumerable-listing

Conversation

@sparkeh9
Copy link
Copy Markdown
Owner

@sparkeh9 sparkeh9 commented Mar 1, 2026

Summary

Adds streaming IAsyncEnumerable<FtpNodeInformation> directory listing methods that yield parsed entries as they arrive from the server, without buffering the entire listing in memory first.

Closes #45

New Public API

// Stream all nodes, files, or directories
await foreach (var node in ftpClient.ListAllEnumerableAsync(cancellationToken))
{
    Console.WriteLine(node.Name);
}

await foreach (var file in ftpClient.ListFilesEnumerableAsync(cancellationToken))
{
    // Process files as they arrive — no need to wait for the full listing
}

await foreach (var dir in ftpClient.ListDirectoriesEnumerableAsync(cancellationToken))
{
    // Can be cancelled mid-stream via CancellationToken
}

Changes

New Methods

  • FtpClient.ListAllEnumerableAsync(CancellationToken)
  • FtpClient.ListFilesEnumerableAsync(CancellationToken)
  • FtpClient.ListDirectoriesEnumerableAsync(CancellationToken)

Internal Changes

  • Added IAsyncEnumerable streaming methods to IDirectoryProvider, DirectoryProviderBase, ListDirectoryProvider, and MlsdDirectoryProvider
  • Streaming methods properly acquire/release the dataSocketSemaphore and dispose the data stream in finally blocks
  • Support CancellationToken via [EnumeratorCancellation] attribute

Build Infrastructure

  • Added Microsoft.Bcl.AsyncInterfaces polyfill (conditional for netstandard2.0/net462 only)
  • Set <LangVersion>latest</LangVersion> to enable C# 8+ async stream syntax on net462

Backward Compatibility

  • Existing ListAllAsync(), ListFilesAsync(), ListDirectoriesAsync() remain unchanged
  • No breaking changes

- Add ListAllEnumerableAsync, ListFilesEnumerableAsync, ListDirectoriesEnumerableAsync
- Streaming methods yield return parsed entries as they arrive from the server
- Support CancellationToken for early termination of large listings
- Add Microsoft.Bcl.AsyncInterfaces polyfill for netstandard2.0/net462
- Set LangVersion to latest for C# 8+ async stream support on net462
- Existing buffered methods remain unchanged for backward compatibility
Copilot AI review requested due to automatic review settings March 1, 2026 00:56
@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request significantly enhances the CoreFtp library by integrating IAsyncEnumerable for directory listing operations. This change allows for more efficient handling of large directory listings by streaming data as it becomes available, rather than loading everything into memory at once. The new API provides a modern, asynchronous way to iterate over FTP nodes, improving performance and responsiveness for applications interacting with FTP servers.

Highlights

  • New Streaming API: Introduced IAsyncEnumerable<FtpNodeInformation> methods for streaming directory listings (ListAllEnumerableAsync, ListFilesEnumerableAsync, ListDirectoriesEnumerableAsync), allowing entries to be processed as they arrive without buffering the entire list in memory.
  • Internal Implementation: Implemented streaming methods across IDirectoryProvider, DirectoryProviderBase, ListDirectoryProvider, and MlsdDirectoryProvider, ensuring proper resource management with dataSocketSemaphore and stream disposal in finally blocks.
  • Cancellation Support: Added support for CancellationToken in the new streaming methods via the [EnumeratorCancellation] attribute.
  • Build System Updates: Updated the project to use <LangVersion>latest</LangVersion> for C# 8+ async stream syntax and conditionally added Microsoft.Bcl.AsyncInterfaces as a polyfill for netstandard2.0 and net462.
  • Backward Compatibility: Ensured that existing ListAllAsync(), ListFilesAsync(), and ListDirectoriesAsync() methods remain unchanged, introducing no breaking changes.
Changelog
  • src/CoreFtp/Components/DirectoryListing/DirectoryProviderBase.cs
    • Added System.Runtime.CompilerServices and System.Threading using directives.
    • Introduced virtual IAsyncEnumerable<FtpNodeInformation> methods for streaming directory listings.
  • src/CoreFtp/Components/DirectoryListing/IDirectoryProvider.cs
    • Added System.Collections.Generic and System.Threading using directives.
    • Declared new IAsyncEnumerable<FtpNodeInformation> methods with XML documentation for streaming directory listings.
  • src/CoreFtp/Components/DirectoryListing/ListDirectoryProvider.cs
    • Added System.Runtime.CompilerServices and System.Threading using directives.
    • Overrode and implemented ListAllEnumerableAsync, ListFilesEnumerableAsync, and ListDirectoriesEnumerableAsync using IAsyncEnumerable.
    • Introduced a new private ListNodesEnumerableAsync method to stream nodes from the LIST command response, including semaphore management and cancellation token support.
  • src/CoreFtp/Components/DirectoryListing/MlsdDirectoryProvider.cs
    • Added System.Collections.Generic, System.Runtime.CompilerServices, and System.Threading using directives.
    • Overrode and implemented ListAllEnumerableAsync, ListFilesEnumerableAsync, and ListDirectoriesEnumerableAsync using IAsyncEnumerable.
    • Introduced a new private ListNodeTypeEnumerableAsync method to stream nodes from the MLSD command response, including semaphore management and cancellation token support.
  • src/CoreFtp/CoreFtp.csproj
    • Set <LangVersion> to latest to enable C# 8+ features.
    • Added a conditional PackageReference for Microsoft.Bcl.AsyncInterfaces for netstandard2.0 and net462 target frameworks.
  • src/CoreFtp/FtpClient.cs
    • Added System.Runtime.CompilerServices using directive.
    • Introduced public IAsyncEnumerable<FtpNodeInformation> methods: ListAllEnumerableAsync, ListFilesEnumerableAsync, ListDirectoriesEnumerableAsync, which delegate to the directoryProvider.
Activity
  • No human activity has been recorded for this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0b0a4c5b9e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +435 to +438
await foreach ( var node in directoryProvider.ListAllEnumerableAsync( cancellationToken ).ConfigureAwait( false ) )
yield return node;

await ControlStream.GetResponseAsync();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Drain control reply even when stream enumeration stops early

The new async-stream listing methods only call ControlStream.GetResponseAsync() after the await foreach completes normally, so if callers cancel the token or break out early (a common streaming use case), this line is never reached. In that case the final LIST/MLSD control reply (e.g., 226/426) remains unread, and subsequent FTP commands can consume that stale reply and fail or desynchronize the session; the same pattern appears in all three *EnumerableAsync methods.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces IAsyncEnumerable for streaming directory listings, which is an excellent enhancement for performance and memory efficiency. However, a critical vulnerability exists in FtpClient.cs due to a lack of proper exception handling for the FTP control connection. If an exception occurs during streaming, the client fails to read the completion response, leading to protocol desynchronization and potential misinterpretation of subsequent FTP commands, which could result in incorrect program logic or security-relevant state errors. Additionally, there are opportunities for code simplification and consistency.

Comment on lines +430 to +439
public async IAsyncEnumerable<FtpNodeInformation> ListAllEnumerableAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default )
{
EnsureLoggedIn();
Logger?.LogDebug( $"[FtpClient] Streaming all nodes in {WorkingDirectory}" );

await foreach ( var node in directoryProvider.ListAllEnumerableAsync( cancellationToken ).ConfigureAwait( false ) )
yield return node;

await ControlStream.GetResponseAsync();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-high high

The newly introduced streaming methods (ListAllEnumerableAsync, ListFilesEnumerableAsync, and ListDirectoriesEnumerableAsync) are missing a try-finally block to ensure the FTP control connection remains synchronized.

In the FTP protocol, a directory listing command expects a completion response (e.g., 226 Transfer complete) on the control channel after the data transfer is finished. Currently, await ControlStream.GetResponseAsync() is only called if the await foreach loop completes successfully. If an exception occurs during iteration (due to network issues, parsing errors, or cancellation), this call is skipped, leaving a stale response on the control socket.

Subsequent commands on the same FtpClient instance will read this stale response instead of their own, which can lead to 'Response Smuggling' where a command appears to succeed (because it read a 226 success code) even if it failed or was never processed.

To fix this, wrap the await foreach in a try-finally block and move the GetResponseAsync() call into the finally block, similar to the implementation in the non-streaming ListAllAsync method.

Comment on lines +446 to +455
public async IAsyncEnumerable<FtpNodeInformation> ListFilesEnumerableAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default )
{
EnsureLoggedIn();
Logger?.LogDebug( $"[FtpClient] Streaming files in {WorkingDirectory}" );

await foreach ( var node in directoryProvider.ListFilesEnumerableAsync( cancellationToken ).ConfigureAwait( false ) )
yield return node;

await ControlStream.GetResponseAsync();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-high high

Similar to ListAllEnumerableAsync, this method is missing a try-finally block to ensure ControlStream.GetResponseAsync() is called even if an exception occurs during streaming. This is necessary to maintain FTP protocol synchronization.

Comment on lines +462 to +471
public async IAsyncEnumerable<FtpNodeInformation> ListDirectoriesEnumerableAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default )
{
EnsureLoggedIn();
Logger?.LogDebug( $"[FtpClient] Streaming directories in {WorkingDirectory}" );

await foreach ( var node in directoryProvider.ListDirectoriesEnumerableAsync( cancellationToken ).ConfigureAwait( false ) )
yield return node;

await ControlStream.GetResponseAsync();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-high high

Similar to ListAllEnumerableAsync, this method is missing a try-finally block to ensure ControlStream.GetResponseAsync() is called even if an exception occurs during streaming. This is necessary to maintain FTP protocol synchronization.

await ftpClient.dataSocketSemaphore.WaitAsync( cancellationToken );
try
{
stream = await ftpClient.ConnectDataStreamAsync();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The MlsdDirectoryProvider checks if the stream returned from ConnectDataStreamAsync is null. For consistency and robustness, it's a good idea to add a similar null check here to handle cases where a data connection could not be established.

                stream = await ftpClient.ConnectDataStreamAsync();
                if ( stream == null )
                    throw new FtpException( "Could not establish a data connection" );

Comment on lines +86 to +90
public override async IAsyncEnumerable<FtpNodeInformation> ListAllEnumerableAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default )
{
await foreach ( var node in ListNodesEnumerableAsync( null, cancellationToken ).ConfigureAwait( false ) )
yield return node;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This async iterator method unnecessarily re-wraps the IAsyncEnumerable returned by ListNodesEnumerableAsync. This creates an extra state machine with a slight performance overhead. You can simplify this by directly returning the result of ListNodesEnumerableAsync. This feedback also applies to ListFilesEnumerableAsync and ListDirectoriesEnumerableAsync.

        public override IAsyncEnumerable<FtpNodeInformation> ListAllEnumerableAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default )
        {
            return ListNodesEnumerableAsync( null, cancellationToken );
        }

}
finally
{
stream?.Dispose();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

For consistency with MlsdDirectoryProvider and to prevent accidental use of a disposed object, it's good practice to set the stream field to null after it has been disposed.

                stream?.Dispose();
                stream = null;

Comment on lines +68 to +72
public override async IAsyncEnumerable<FtpNodeInformation> ListAllEnumerableAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default )
{
await foreach ( var node in ListNodeTypeEnumerableAsync( null, cancellationToken ).ConfigureAwait( false ) )
yield return node;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Similar to the ListDirectoryProvider, this async iterator method re-wraps the IAsyncEnumerable returned by ListNodeTypeEnumerableAsync, creating an unnecessary state machine. You can simplify this by directly returning the enumerable. This feedback also applies to ListFilesEnumerableAsync and ListDirectoriesEnumerableAsync.

        public override IAsyncEnumerable<FtpNodeInformation> ListAllEnumerableAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default )
        {
            return ListNodeTypeEnumerableAsync( null, cancellationToken );
        }

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds async-streaming (IAsyncEnumerable<FtpNodeInformation>) directory listing APIs so callers can process entries as they arrive instead of buffering full listings in memory, addressing Issue #45.

Changes:

  • Added new public FtpClient streaming listing methods: ListAllEnumerableAsync, ListFilesEnumerableAsync, ListDirectoriesEnumerableAsync.
  • Added streaming counterparts throughout directory listing providers (IDirectoryProvider, DirectoryProviderBase, ListDirectoryProvider, MlsdDirectoryProvider).
  • Updated build settings to support async streams on older TFMs (LangVersion + conditional Microsoft.Bcl.AsyncInterfaces).

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/CoreFtp/FtpClient.cs Introduces public async-stream listing APIs that delegate to providers and then read the final control-stream response.
src/CoreFtp/CoreFtp.csproj Enables newer C# language features and adds async-interfaces polyfill conditionally for older target frameworks.
src/CoreFtp/Components/DirectoryListing/IDirectoryProvider.cs Extends provider abstraction with streaming listing methods.
src/CoreFtp/Components/DirectoryListing/DirectoryProviderBase.cs Adds virtual streaming method stubs to the base provider class.
src/CoreFtp/Components/DirectoryListing/ListDirectoryProvider.cs Implements streaming parsing over LIST responses with cancellation support and semaphore handling.
src/CoreFtp/Components/DirectoryListing/MlsdDirectoryProvider.cs Implements streaming parsing over MLSD responses with cancellation support and semaphore handling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +430 to +439
public async IAsyncEnumerable<FtpNodeInformation> ListAllEnumerableAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default )
{
EnsureLoggedIn();
Logger?.LogDebug( $"[FtpClient] Streaming all nodes in {WorkingDirectory}" );

await foreach ( var node in directoryProvider.ListAllEnumerableAsync( cancellationToken ).ConfigureAwait( false ) )
yield return node;

await ControlStream.GetResponseAsync();
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

These async-iterator methods only call ControlStream.GetResponseAsync() after the await foreach completes. If the consumer stops enumeration early (break/exception/cancellation), that code will not run, leaving the control connection with an unread final response (e.g., 226/426) and potentially breaking subsequent commands. Wrap the enumeration in a try/finally so the final response is drained whenever the iterator is disposed (and consider whether to pass the same CancellationToken).

Copilot uses AI. Check for mistakes.
Comment on lines +430 to +447
public async IAsyncEnumerable<FtpNodeInformation> ListAllEnumerableAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default )
{
EnsureLoggedIn();
Logger?.LogDebug( $"[FtpClient] Streaming all nodes in {WorkingDirectory}" );

await foreach ( var node in directoryProvider.ListAllEnumerableAsync( cancellationToken ).ConfigureAwait( false ) )
yield return node;

await ControlStream.GetResponseAsync();
}

/// <summary>
/// Streams all files in the current working directory as they are parsed from the server
/// </summary>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async IAsyncEnumerable<FtpNodeInformation> ListFilesEnumerableAsync( [EnumeratorCancellation] CancellationToken cancellationToken = default )
{
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

New streaming listing methods were added as public methods on FtpClient, but IFtpClient (which FtpClient implements) does not expose them. If consumers depend on IFtpClient (e.g., DI/mocking), they won't be able to use the new API; consider adding these methods to IFtpClient (and any related abstractions) to keep the public surface consistent.

Copilot uses AI. Check for mistakes.
<AssemblyTitle>CoreFTP</AssemblyTitle>

<Authors>Nick Briscoe</Authors>
<LangVersion>latest</LangVersion>
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

Using <LangVersion>latest</LangVersion> makes builds non-deterministic across SDK upgrades (language version can change and introduce new warnings/errors/semantics). Consider pinning to the minimum required (C# 8.0 for async streams) or latestMajor, or applying it conditionally only where needed (e.g., net462) to reduce upgrade risk.

Suggested change
<LangVersion>latest</LangVersion>
<LangVersion>latestMajor</LangVersion>

Copilot uses AI. Check for mistakes.
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Runtime.CompilerServices;
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

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

using System.Runtime.CompilerServices; appears unused in this file (no references to types from this namespace). Consider removing it to avoid unnecessary usings / analyzer warnings.

Suggested change
using System.Runtime.CompilerServices;

Copilot uses AI. Check for mistakes.
@sparkeh9
Copy link
Copy Markdown
Owner Author

sparkeh9 commented Mar 1, 2026

Addressed all review feedback in 94999d6:

  1. 🔴 Response Smuggling Fix: All three *EnumerableAsync methods in FtpClient.cs now wrap the await foreach in try-finally to guarantee ControlStream.GetResponseAsync() is called even on early cancellation or break. This prevents stale 226/426 responses from desynchronizing the control channel.

  2. 🟡 Simplified Provider Wrappers: Changed the ListAllEnumerableAsync/ListFilesEnumerableAsync/ListDirectoriesEnumerableAsync overrides in both ListDirectoryProvider and MlsdDirectoryProvider from unnecessary async wrappers with await foreach + yield return to direct return expression bodies, eliminating redundant state machines.

  3. 🟡 Null Stream Guard: Added if (stream == null) throw new FtpException(...) after ConnectDataStreamAsync() in ListDirectoryProvider.ListNodesEnumerableAsync for consistency with MlsdDirectoryProvider.

  4. 🟡 Stream Cleanup: Added stream = null after stream?.Dispose() in the finally block of ListDirectoryProvider.ListNodesEnumerableAsync to prevent accidental use of a disposed object.

@sparkeh9 sparkeh9 merged commit 5561958 into master Mar 1, 2026
1 check passed
@sparkeh9 sparkeh9 deleted the feature/async-enumerable-listing branch March 1, 2026 01:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

List files with Data (options) and return IAsyncEnumerable

2 participants