Skip to content
Draft
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
21 changes: 21 additions & 0 deletions src/Renci.SshNet/ISftpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,27 @@ public interface ISftpClient : IBaseClient
/// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
uint BufferSize { get; set; }

/// <summary>
/// Gets or sets the maximum number of pending read requests allowed in read-ahead mode.
/// </summary>
/// <value>
/// The maximum number of pending read requests. The default value is 100.
/// </value>
/// <remarks>
/// <para>
/// This controls how many SSH_FXP_READ requests can be in-flight simultaneously
/// when sequentially reading a file. Higher values allow the library to pipeline
/// more requests, improving throughput on high-latency connections.
/// </para>
/// <para>
/// On resource-constrained platforms (e.g., mobile devices), reducing this value
/// can prevent connection stalls when downloading larger files.
/// </para>
/// </remarks>
/// <exception cref="ArgumentOutOfRangeException">The value is less than 1.</exception>
/// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
int MaxPendingReads { get; set; }

/// <summary>
/// Gets or sets the operation timeout.
/// </summary>
Expand Down
27 changes: 16 additions & 11 deletions src/Renci.SshNet/Sftp/SftpFileStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ namespace Renci.SshNet.Sftp
/// </summary>
public sealed partial class SftpFileStream : Stream
{
private const int MaxPendingReads = 100;
private readonly int _maxPendingReads;

private readonly ISftpSession _session;
private readonly FileAccess _access;
Expand Down Expand Up @@ -140,6 +140,7 @@ private SftpFileStream(
int writeBufferSize,
byte[] handle,
long position,
int maxPendingReads,
SftpFileReader? initialReader)
{
Timeout = TimeSpan.FromSeconds(30);
Expand All @@ -148,6 +149,7 @@ private SftpFileStream(
_session = session;
_access = access;
_canSeek = canSeek;
_maxPendingReads = maxPendingReads;

Handle = handle;
_readBufferSize = readBufferSize;
Expand All @@ -163,9 +165,10 @@ internal static SftpFileStream Open(
FileMode mode,
FileAccess access,
int bufferSize,
bool isDownloadFile = false)
bool isDownloadFile = false,
int maxPendingReads = 100)
{
return Open(session, path, mode, access, bufferSize, isDownloadFile, isAsync: false, CancellationToken.None).GetAwaiter().GetResult();
return Open(session, path, mode, access, bufferSize, maxPendingReads, isDownloadFile, isAsync: false, CancellationToken.None).GetAwaiter().GetResult();
}

internal static Task<SftpFileStream> OpenAsync(
Expand All @@ -175,9 +178,10 @@ internal static Task<SftpFileStream> OpenAsync(
FileAccess access,
int bufferSize,
CancellationToken cancellationToken,
bool isDownloadFile = false)
bool isDownloadFile = false,
int maxPendingReads = 100)
{
return Open(session, path, mode, access, bufferSize, isDownloadFile, isAsync: true, cancellationToken);
return Open(session, path, mode, access, bufferSize, maxPendingReads, isDownloadFile, isAsync: true, cancellationToken);
}

private static async Task<SftpFileStream> Open(
Expand All @@ -186,6 +190,7 @@ private static async Task<SftpFileStream> Open(
FileMode mode,
FileAccess access,
int bufferSize,
int maxPendingReads,
bool isDownloadFile,
bool isAsync,
CancellationToken cancellationToken)
Expand Down Expand Up @@ -309,15 +314,15 @@ private static async Task<SftpFileStream> Open(
// so we can let there be several in-flight requests from the get go.
// This optimisation is mostly only beneficial to smaller files on higher latency connections.
// The +2 is +1 for rounding up to cover the whole file, and +1 for the final request to receive EOF.
var initialPendingReads = (int)Math.Max(1, Math.Min(MaxPendingReads, 2 + (attributes.Size / readBufferSize)));
var initialPendingReads = (int)Math.Max(1, Math.Min(maxPendingReads, 2 + (attributes.Size / readBufferSize)));

initialReader = new(handle, session, readBufferSize, position, MaxPendingReads, (ulong)attributes.Size, initialPendingReads);
initialReader = new(handle, session, readBufferSize, position, maxPendingReads, (ulong)attributes.Size, initialPendingReads);
}
else if ((access & FileAccess.Read) == FileAccess.Read)
{
// The reader can use the size information to reduce in-flight requests near the expected EOF,
// so pass it in here.
initialReader = new(handle, session, readBufferSize, position, MaxPendingReads, (ulong)attributes.Size);
initialReader = new(handle, session, readBufferSize, position, maxPendingReads, (ulong)attributes.Size);
}
}
else
Expand All @@ -327,7 +332,7 @@ private static async Task<SftpFileStream> Open(
canSeek = false;
}

return new SftpFileStream(session, path, access, canSeek, readBufferSize, writeBufferSize, handle, position, initialReader);
return new SftpFileStream(session, path, access, canSeek, readBufferSize, writeBufferSize, handle, position, maxPendingReads, initialReader);
}

/// <inheritdoc/>
Expand Down Expand Up @@ -421,7 +426,7 @@ private int Read(Span<byte> buffer)
if (_sftpFileReader is null)
{
Flush();
_sftpFileReader = new(Handle, _session, _readBufferSize, _position, MaxPendingReads);
_sftpFileReader = new(Handle, _session, _readBufferSize, _position, _maxPendingReads);
}

_readBuffer = _sftpFileReader.ReadAsync(CancellationToken.None).GetAwaiter().GetResult();
Expand Down Expand Up @@ -475,7 +480,7 @@ private async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ca
{
await FlushAsync(cancellationToken).ConfigureAwait(false);

_sftpFileReader = new(Handle, _session, _readBufferSize, _position, MaxPendingReads);
_sftpFileReader = new(Handle, _session, _readBufferSize, _position, _maxPendingReads);
}

_readBuffer = await _sftpFileReader.ReadAsync(cancellationToken).ConfigureAwait(false);
Expand Down
57 changes: 52 additions & 5 deletions src/Renci.SshNet/SftpClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ public class SftpClient : BaseClient, ISftpClient
/// </summary>
private uint _bufferSize;

/// <summary>
/// Holds the maximum number of pending reads.
/// </summary>
private int _maxPendingReads;

/// <summary>
/// Gets or sets the operation timeout.
/// </summary>
Expand Down Expand Up @@ -112,6 +117,45 @@ public uint BufferSize
}
}

/// <summary>
/// Gets or sets the maximum number of pending read requests allowed in read-ahead mode.
/// </summary>
/// <value>
/// The maximum number of pending read requests. The default value is 100.
/// </value>
/// <remarks>
/// <para>
/// This controls how many SSH_FXP_READ requests can be in-flight simultaneously
/// when sequentially reading a file. Higher values allow the library to pipeline
/// more requests, improving throughput on high-latency connections.
/// </para>
/// <para>
/// On resource-constrained platforms (e.g., mobile devices), reducing this value
/// can prevent connection stalls when downloading larger files.
/// </para>
/// </remarks>
/// <exception cref="ArgumentOutOfRangeException">The value is less than 1.</exception>
/// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
public int MaxPendingReads
{
get
{
CheckDisposed();
return _maxPendingReads;
}
set
{
CheckDisposed();

if (value < 1)
{
throw new ArgumentOutOfRangeException(nameof(value), "Cannot be less than one.");
}

_maxPendingReads = value;
}
}

/// <summary>
/// Gets a value indicating whether this client is connected to the server and
/// the SFTP session is open.
Expand Down Expand Up @@ -279,6 +323,7 @@ internal SftpClient(ConnectionInfo connectionInfo, bool ownsConnectionInfo, ISer
{
_operationTimeout = Timeout.Infinite;
_bufferSize = 1024 * 32;
_maxPendingReads = 100;
}

#endregion Constructors
Expand Down Expand Up @@ -1544,7 +1589,7 @@ public SftpFileStream Create(string path, int bufferSize)
{
CheckDisposed();

return SftpFileStream.Open(_sftpSession, path, FileMode.Create, FileAccess.ReadWrite, bufferSize);
return SftpFileStream.Open(_sftpSession, path, FileMode.Create, FileAccess.ReadWrite, bufferSize, maxPendingReads: _maxPendingReads);
}

/// <inheritdoc/>
Expand Down Expand Up @@ -1682,7 +1727,7 @@ public SftpFileStream Open(string path, FileMode mode, FileAccess access)
{
CheckDisposed();

return SftpFileStream.Open(_sftpSession, path, mode, access, (int)_bufferSize);
return SftpFileStream.Open(_sftpSession, path, mode, access, (int)_bufferSize, maxPendingReads: _maxPendingReads);
}

/// <summary>
Expand All @@ -1703,7 +1748,7 @@ public Task<SftpFileStream> OpenAsync(string path, FileMode mode, FileAccess acc
{
CheckDisposed();

return SftpFileStream.OpenAsync(_sftpSession, path, mode, access, (int)_bufferSize, cancellationToken);
return SftpFileStream.OpenAsync(_sftpSession, path, mode, access, (int)_bufferSize, cancellationToken, maxPendingReads: _maxPendingReads);
}

/// <summary>
Expand Down Expand Up @@ -2357,7 +2402,8 @@ private async Task InternalDownloadFile(
FileAccess.Read,
(int)_bufferSize,
cancellationToken,
isDownloadFile: true).ConfigureAwait(false);
isDownloadFile: true,
maxPendingReads: _maxPendingReads).ConfigureAwait(false);
}
else
{
Expand All @@ -2369,7 +2415,8 @@ private async Task InternalDownloadFile(
FileMode.Open,
FileAccess.Read,
(int)_bufferSize,
isDownloadFile: true);
isDownloadFile: true,
maxPendingReads: _maxPendingReads);
}

// The below is effectively sftpStream.CopyTo{Async}(output) with consideration
Expand Down
Loading