Skip to content

Use span-based read helper for control stream#50

Merged
sparkeh9 merged 6 commits intomasterfrom
codex/replace-manual-buffer-with-span-type
Feb 28, 2026
Merged

Use span-based read helper for control stream#50
sparkeh9 merged 6 commits intomasterfrom
codex/replace-manual-buffer-with-span-type

Conversation

@sparkeh9
Copy link
Copy Markdown
Owner

@sparkeh9 sparkeh9 commented Jan 28, 2026

Motivation

  • Modernize the control-stream byte-by-byte read path to avoid a heap-allocated new byte[1] allocation and reduce per-byte GC pressure.
  • Centralize single-byte reads into a small Span<byte>-aware helper to keep EOF and cancellation semantics consistent across the loop.
  • Ensure Span<T> support on the targeted frameworks by adding the required package reference where needed.

Description

  • Add a private helper int Read(Span<byte> buffer) that uses ReadByte() to populate a single-byte Span and returns 1 for a read byte or 0 for EOF.
  • Replace the previous var buffer = new byte[1] usage in ReadLine with Span<byte> buffer = stackalloc byte[1] and switch the loop to call Read(buffer) to read one byte at a time.
  • Update src/CoreFtp/CoreFtp.csproj to include a PackageReference to System.Memory to provide Span<T> support on the targeted frameworks.

Testing

  • No automated tests were run for this change.

Codex Task


Note

Low Risk
Low risk perf refactor confined to FtpControlStream line-reading and a new dependency; main risk is subtle EOF/byte-read behavior differences across target frameworks.

Overview
Reduces allocations in the FTP control-stream line reader. FtpControlStream.ReadLine now reads bytes via a Span<byte> (stackalloc) path instead of allocating new byte[1] per loop iteration, using a new Read(Span<byte>) helper backed by an overridden ReadByte() and a cached 1-byte buffer.

Adds a System.Memory package reference in CoreFtp.csproj to ensure Span<T> support on targeted frameworks.

Written by Cursor Bugbot for commit 1220b8b. This will update automatically on new commits. Configure here.

Copilot AI review requested due to automatic review settings January 28, 2026 17:44
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: 964e7d8b3e

ℹ️ 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 +119 to +121
var value = ReadByte();
if ( value == -1 )
return 0;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid per-byte allocation by not using Stream.ReadByte

The new helper calls ReadByte(), but Stream.ReadByte() allocates a new one-byte array on each call before delegating to Read(byte[], int, int). In this loop, that means a heap allocation per byte read, which is a regression from the previous single-buffer reuse and defeats the stated GC/perf motivation. This will be most visible when reading long control responses. Consider calling the existing Read(byte[], int, int)/NetworkStream.Read with the provided buffer instead of ReadByte() to keep the stackalloc benefit.

Useful? React with 👍 / 👎.

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

This PR attempts to modernize the FTP control stream's byte-reading implementation by introducing Span<byte> to reduce heap allocations during line-by-line reading of FTP responses. However, the implementation does not achieve the stated performance goals.

Changes:

  • Added a new private Read(Span<byte>) helper method that calls ReadByte() to populate a span
  • Modified ReadLine to use stackalloc byte[1] instead of new byte[1]
  • Added System.Memory package reference (v4.5.5) to support Span<T> on target frameworks

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
src/CoreFtp/Infrastructure/Stream/FtpControlStream.cs Added span-based Read helper and converted ReadLine to use stackalloc, but the optimization is ineffective due to ReadByte() implementation
src/CoreFtp/CoreFtp.csproj Added System.Memory package reference to enable Span support

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

<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="1.0.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="1.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="1.0.2" />
<PackageReference Include="System.Memory" Version="4.5.5" />
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

While this change adds the System.Memory package reference which is necessary for Span support, the PR description's claim about "modernizing to avoid a heap-allocated new byte[1] allocation and reduce per-byte GC pressure" is not actually achieved by the current implementation. The base Stream.ReadByte() still creates a heap allocation internally. Consider updating the PR description to reflect that additional work is needed to achieve the stated performance goals, or fix the implementation as noted in the other comment.

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +124
if ( buffer.IsEmpty )
return 0;

var value = ReadByte();
if ( value == -1 )
return 0;

buffer[ 0 ] = (byte) value;
return 1;
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

The optimization to avoid heap allocation is ineffective because ReadByte() is not overridden in this class. The base Stream.ReadByte() implementation allocates a temporary byte array and calls Read(byte[], int, int), which defeats the purpose of using Span.

To achieve the intended optimization, either:

  1. Override ReadByte() to directly read from NetworkStream, or
  2. Implement Read(Span) to directly read from NetworkStream instead of calling ReadByte()

The current implementation still results in a heap-allocated byte[1] array on each call.

Suggested change
if ( buffer.IsEmpty )
return 0;
var value = ReadByte();
if ( value == -1 )
return 0;
buffer[ 0 ] = (byte) value;
return 1;
if ( NetworkStream == null )
{
return 0;
}
return NetworkStream.Read( buffer );

Copilot uses AI. Check for mistakes.

var data = new List<byte>();
var buffer = new byte[ 1 ];
Span<byte> buffer = stackalloc byte[ 1 ];
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

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

The use of stackalloc in ReadLine creates a Span on the stack that is used across loop iterations and potentially across async boundaries (since ReadLine can be called from async methods like GetResponseAsync). While this specific usage appears safe because ReadLine itself is synchronous and the span doesn't escape the method, it's worth noting that this pattern requires careful consideration if the method is ever converted to async.

Additionally, the buffer is only used to read one byte at a time, so the performance benefit of Span over byte[] is minimal here. The real benefit would come from fixing the Read(Span) implementation to avoid the heap allocation in ReadByte().

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

protected readonly IDnsResolver dnsResolver;
protected Socket Socket;
protected Stream BaseStream;
private readonly byte[] readByteBuffer = new byte[ 1 ];
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shared buffer in ReadByte causes thread safety issue

Low Severity

The new readByteBuffer instance field is shared across all calls to ReadByte(). If multiple threads call ReadByte() concurrently on the same FtpControlStream instance, they will read into and return from the same buffer, causing data corruption. Previously, the base Stream.ReadByte() implementation used a local buffer per call, making it inherently thread-safe at the buffer level. The class's internal usage through GetResponseAsync is protected by receiveSemaphore, but ReadByte() is a public override that external code can call directly.

Additional Locations (1)

Fix in Cursor Fix in Web

if ( NetworkStream == null )
return -1;

var bytesRead = NetworkStream.Read( readByteBuffer, 0, 1 );
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

TOCTOU race in ReadByte can throw NullReferenceException

Low Severity

The new ReadByte() evaluates the NetworkStream property twice—once for the null check and again for the Read call. Since NetworkStream is a computed property (SslStream ?? BaseStream), a concurrent call to Disconnect() can set BaseStream to null between these evaluations, causing the second evaluation to return null and throw a NullReferenceException. The old Read(byte[], int, int) method used the null-conditional operator ?. which evaluates once and captures the reference, avoiding this race.

Fix in Cursor Fix in Web

@sparkeh9
Copy link
Copy Markdown
Owner Author

@copilot check if any more changes are needed. Add tests to any potential edge cases

Copy link
Copy Markdown
Contributor

Copilot AI commented Feb 28, 2026

@sparkeh9 I've opened a new pull request, #51, to work on those changes. Once the pull request is ready, I'll request review from you.

@sparkeh9 sparkeh9 merged commit 7c4cd0a into master Feb 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants