diff --git a/src/Renci.SshNet/ISftpClient.cs b/src/Renci.SshNet/ISftpClient.cs index 7b1237bc2..f79ae9c4c 100644 --- a/src/Renci.SshNet/ISftpClient.cs +++ b/src/Renci.SshNet/ISftpClient.cs @@ -49,6 +49,27 @@ public interface ISftpClient : IBaseClient /// The method was called after the client was disposed. uint BufferSize { get; set; } + /// + /// Gets or sets the maximum number of pending read requests allowed in read-ahead mode. + /// + /// + /// The maximum number of pending read requests. The default value is 100. + /// + /// + /// + /// 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. + /// + /// + /// On resource-constrained platforms (e.g., mobile devices), reducing this value + /// can prevent connection stalls when downloading larger files. + /// + /// + /// The value is less than 1. + /// The method was called after the client was disposed. + int MaxPendingReads { get; set; } + /// /// Gets or sets the operation timeout. /// diff --git a/src/Renci.SshNet/Sftp/SftpFileStream.cs b/src/Renci.SshNet/Sftp/SftpFileStream.cs index d8c0d126f..ce26a0ca3 100644 --- a/src/Renci.SshNet/Sftp/SftpFileStream.cs +++ b/src/Renci.SshNet/Sftp/SftpFileStream.cs @@ -18,7 +18,7 @@ namespace Renci.SshNet.Sftp /// 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 @@ private SftpFileStream( int writeBufferSize, byte[] handle, long position, + int maxPendingReads, SftpFileReader? initialReader) { Timeout = TimeSpan.FromSeconds(30); @@ -148,6 +149,7 @@ private SftpFileStream( _session = session; _access = access; _canSeek = canSeek; + _maxPendingReads = maxPendingReads; Handle = handle; _readBufferSize = readBufferSize; @@ -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 OpenAsync( @@ -175,9 +178,10 @@ internal static Task 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 Open( @@ -186,6 +190,7 @@ private static async Task Open( FileMode mode, FileAccess access, int bufferSize, + int maxPendingReads, bool isDownloadFile, bool isAsync, CancellationToken cancellationToken) @@ -309,15 +314,15 @@ private static async Task 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 @@ -327,7 +332,7 @@ private static async Task 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); } /// @@ -421,7 +426,7 @@ private int Read(Span 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(); @@ -475,7 +480,7 @@ private async ValueTask ReadAsync(Memory 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); diff --git a/src/Renci.SshNet/SftpClient.cs b/src/Renci.SshNet/SftpClient.cs index 960c6261f..194d79f80 100644 --- a/src/Renci.SshNet/SftpClient.cs +++ b/src/Renci.SshNet/SftpClient.cs @@ -42,6 +42,11 @@ public class SftpClient : BaseClient, ISftpClient /// private uint _bufferSize; + /// + /// Holds the maximum number of pending reads. + /// + private int _maxPendingReads; + /// /// Gets or sets the operation timeout. /// @@ -112,6 +117,45 @@ public uint BufferSize } } + /// + /// Gets or sets the maximum number of pending read requests allowed in read-ahead mode. + /// + /// + /// The maximum number of pending read requests. The default value is 100. + /// + /// + /// + /// 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. + /// + /// + /// On resource-constrained platforms (e.g., mobile devices), reducing this value + /// can prevent connection stalls when downloading larger files. + /// + /// + /// The value is less than 1. + /// The method was called after the client was disposed. + public int MaxPendingReads + { + get + { + CheckDisposed(); + return _maxPendingReads; + } + set + { + CheckDisposed(); + + if (value < 1) + { + throw new ArgumentOutOfRangeException(nameof(value), "Cannot be less than one."); + } + + _maxPendingReads = value; + } + } + /// /// Gets a value indicating whether this client is connected to the server and /// the SFTP session is open. @@ -279,6 +323,7 @@ internal SftpClient(ConnectionInfo connectionInfo, bool ownsConnectionInfo, ISer { _operationTimeout = Timeout.Infinite; _bufferSize = 1024 * 32; + _maxPendingReads = 100; } #endregion Constructors @@ -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); } /// @@ -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); } /// @@ -1703,7 +1748,7 @@ public Task 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); } /// @@ -2357,7 +2402,8 @@ private async Task InternalDownloadFile( FileAccess.Read, (int)_bufferSize, cancellationToken, - isDownloadFile: true).ConfigureAwait(false); + isDownloadFile: true, + maxPendingReads: _maxPendingReads).ConfigureAwait(false); } else { @@ -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