Make MaxPendingReads configurable on SftpClient to prevent connection freezes on resource-constrained platforms

Add MaxPendingReads property to ISftpClient and SftpClient (default 100 for backward compatibility).
Pass the value through SftpFileStream.Open/OpenAsync instead of using a hardcoded constant.
Users on Android or other constrained platforms can now reduce this value (e.g., to 10) to prevent freezes when downloading larger files.

Agent-Logs-Url: https://github.com/sshnet/SSH.NET/sessions/b4b9b5e6-e214-41a2-9efd-0c2a63f65426

Co-authored-by: WojciechNagorski <17333903+WojciechNagorski@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-05-08 13:44:31 +00:00
committed by GitHub
parent 0a0e5e0ad9
commit 56f9e2a744
3 changed files with 89 additions and 16 deletions
+21
View File
@@ -49,6 +49,27 @@ namespace Renci.SshNet
/// <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"><paramref name="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>
+16 -11
View File
@@ -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;
@@ -140,6 +140,7 @@ namespace Renci.SshNet.Sftp
int writeBufferSize,
byte[] handle,
long position,
int maxPendingReads,
SftpFileReader? initialReader)
{
Timeout = TimeSpan.FromSeconds(30);
@@ -148,6 +149,7 @@ namespace Renci.SshNet.Sftp
_session = session;
_access = access;
_canSeek = canSeek;
_maxPendingReads = maxPendingReads;
Handle = handle;
_readBufferSize = readBufferSize;
@@ -163,9 +165,10 @@ namespace Renci.SshNet.Sftp
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(
@@ -175,9 +178,10 @@ namespace Renci.SshNet.Sftp
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(
@@ -186,6 +190,7 @@ namespace Renci.SshNet.Sftp
FileMode mode,
FileAccess access,
int bufferSize,
int maxPendingReads,
bool isDownloadFile,
bool isAsync,
CancellationToken cancellationToken)
@@ -309,15 +314,15 @@ namespace Renci.SshNet.Sftp
// 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
@@ -327,7 +332,7 @@ namespace Renci.SshNet.Sftp
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/>
@@ -421,7 +426,7 @@ namespace Renci.SshNet.Sftp
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();
@@ -475,7 +480,7 @@ namespace Renci.SshNet.Sftp
{
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);
+52 -5
View File
@@ -42,6 +42,11 @@ namespace Renci.SshNet
/// </summary>
private uint _bufferSize;
/// <summary>
/// Holds the maximum number of pending reads.
/// </summary>
private int _maxPendingReads;
/// <summary>
/// Gets or sets the operation timeout.
/// </summary>
@@ -112,6 +117,45 @@ namespace Renci.SshNet
}
}
/// <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"><paramref name="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.
@@ -279,6 +323,7 @@ namespace Renci.SshNet
{
_operationTimeout = Timeout.Infinite;
_bufferSize = 1024 * 32;
_maxPendingReads = 100;
}
#endregion Constructors
@@ -1544,7 +1589,7 @@ namespace Renci.SshNet
{
CheckDisposed();
return SftpFileStream.Open(_sftpSession, path, FileMode.Create, FileAccess.ReadWrite, bufferSize);
return SftpFileStream.Open(_sftpSession, path, FileMode.Create, FileAccess.ReadWrite, bufferSize, maxPendingReads: _maxPendingReads);
}
/// <inheritdoc/>
@@ -1682,7 +1727,7 @@ namespace Renci.SshNet
{
CheckDisposed();
return SftpFileStream.Open(_sftpSession, path, mode, access, (int)_bufferSize);
return SftpFileStream.Open(_sftpSession, path, mode, access, (int)_bufferSize, maxPendingReads: _maxPendingReads);
}
/// <summary>
@@ -1703,7 +1748,7 @@ namespace Renci.SshNet
{
CheckDisposed();
return SftpFileStream.OpenAsync(_sftpSession, path, mode, access, (int)_bufferSize, cancellationToken);
return SftpFileStream.OpenAsync(_sftpSession, path, mode, access, (int)_bufferSize, cancellationToken, maxPendingReads: _maxPendingReads);
}
/// <summary>
@@ -2357,7 +2402,8 @@ namespace Renci.SshNet
FileAccess.Read,
(int)_bufferSize,
cancellationToken,
isDownloadFile: true).ConfigureAwait(false);
isDownloadFile: true,
maxPendingReads: _maxPendingReads).ConfigureAwait(false);
}
else
{
@@ -2369,7 +2415,8 @@ namespace Renci.SshNet
FileMode.Open,
FileAccess.Read,
(int)_bufferSize,
isDownloadFile: true);
isDownloadFile: true,
maxPendingReads: _maxPendingReads);
}
// The below is effectively sftpStream.CopyTo{Async}(output) with consideration