Use span-based read helper for control stream#50
Conversation
There was a problem hiding this comment.
💡 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".
| var value = ReadByte(); | ||
| if ( value == -1 ) | ||
| return 0; |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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 callsReadByte()to populate a span - Modified
ReadLineto usestackalloc byte[1]instead ofnew byte[1] - Added
System.Memorypackage reference (v4.5.5) to supportSpan<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.
src/CoreFtp/CoreFtp.csproj
Outdated
| <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" /> |
There was a problem hiding this comment.
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.
| if ( buffer.IsEmpty ) | ||
| return 0; | ||
|
|
||
| var value = ReadByte(); | ||
| if ( value == -1 ) | ||
| return 0; | ||
|
|
||
| buffer[ 0 ] = (byte) value; | ||
| return 1; |
There was a problem hiding this comment.
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:
- Override ReadByte() to directly read from NetworkStream, or
- 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.
| 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 ); |
|
|
||
| var data = new List<byte>(); | ||
| var buffer = new byte[ 1 ]; | ||
| Span<byte> buffer = stackalloc byte[ 1 ]; |
There was a problem hiding this comment.
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().
There was a problem hiding this comment.
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 ]; |
There was a problem hiding this comment.
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)
| if ( NetworkStream == null ) | ||
| return -1; | ||
|
|
||
| var bytesRead = NetworkStream.Read( readByteBuffer, 0, 1 ); |
There was a problem hiding this comment.
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.
|
@copilot check if any more changes are needed. Add tests to any potential edge cases |
Motivation
new byte[1]allocation and reduce per-byte GC pressure.Span<byte>-aware helper to keep EOF and cancellation semantics consistent across the loop.Span<T>support on the targeted frameworks by adding the required package reference where needed.Description
int Read(Span<byte> buffer)that usesReadByte()to populate a single-byteSpanand returns1for a read byte or0for EOF.var buffer = new byte[1]usage inReadLinewithSpan<byte> buffer = stackalloc byte[1]and switch the loop to callRead(buffer)to read one byte at a time.src/CoreFtp/CoreFtp.csprojto include aPackageReferencetoSystem.Memoryto provideSpan<T>support on the targeted frameworks.Testing
Codex Task
Note
Low Risk
Low risk perf refactor confined to
FtpControlStreamline-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.ReadLinenow reads bytes via aSpan<byte>(stackalloc) path instead of allocatingnew byte[1]per loop iteration, using a newRead(Span<byte>)helper backed by an overriddenReadByte()and a cached 1-byte buffer.Adds a
System.Memorypackage reference inCoreFtp.csprojto ensureSpan<T>support on targeted frameworks.Written by Cursor Bugbot for commit 1220b8b. This will update automatically on new commits. Configure here.