Skip to content

Commit 56f9e2a

Browse files
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>
1 parent 0a0e5e0 commit 56f9e2a

3 files changed

Lines changed: 89 additions & 16 deletions

File tree

src/Renci.SshNet/ISftpClient.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,27 @@ public interface ISftpClient : IBaseClient
4949
/// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
5050
uint BufferSize { get; set; }
5151

52+
/// <summary>
53+
/// Gets or sets the maximum number of pending read requests allowed in read-ahead mode.
54+
/// </summary>
55+
/// <value>
56+
/// The maximum number of pending read requests. The default value is 100.
57+
/// </value>
58+
/// <remarks>
59+
/// <para>
60+
/// This controls how many SSH_FXP_READ requests can be in-flight simultaneously
61+
/// when sequentially reading a file. Higher values allow the library to pipeline
62+
/// more requests, improving throughput on high-latency connections.
63+
/// </para>
64+
/// <para>
65+
/// On resource-constrained platforms (e.g., mobile devices), reducing this value
66+
/// can prevent connection stalls when downloading larger files.
67+
/// </para>
68+
/// </remarks>
69+
/// <exception cref="ArgumentOutOfRangeException"><paramref name="value"/> is less than 1.</exception>
70+
/// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
71+
int MaxPendingReads { get; set; }
72+
5273
/// <summary>
5374
/// Gets or sets the operation timeout.
5475
/// </summary>

src/Renci.SshNet/Sftp/SftpFileStream.cs

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ namespace Renci.SshNet.Sftp
1818
/// </summary>
1919
public sealed partial class SftpFileStream : Stream
2020
{
21-
private const int MaxPendingReads = 100;
21+
private readonly int _maxPendingReads;
2222

2323
private readonly ISftpSession _session;
2424
private readonly FileAccess _access;
@@ -140,6 +140,7 @@ private SftpFileStream(
140140
int writeBufferSize,
141141
byte[] handle,
142142
long position,
143+
int maxPendingReads,
143144
SftpFileReader? initialReader)
144145
{
145146
Timeout = TimeSpan.FromSeconds(30);
@@ -148,6 +149,7 @@ private SftpFileStream(
148149
_session = session;
149150
_access = access;
150151
_canSeek = canSeek;
152+
_maxPendingReads = maxPendingReads;
151153

152154
Handle = handle;
153155
_readBufferSize = readBufferSize;
@@ -163,9 +165,10 @@ internal static SftpFileStream Open(
163165
FileMode mode,
164166
FileAccess access,
165167
int bufferSize,
166-
bool isDownloadFile = false)
168+
bool isDownloadFile = false,
169+
int maxPendingReads = 100)
167170
{
168-
return Open(session, path, mode, access, bufferSize, isDownloadFile, isAsync: false, CancellationToken.None).GetAwaiter().GetResult();
171+
return Open(session, path, mode, access, bufferSize, maxPendingReads, isDownloadFile, isAsync: false, CancellationToken.None).GetAwaiter().GetResult();
169172
}
170173

171174
internal static Task<SftpFileStream> OpenAsync(
@@ -175,9 +178,10 @@ internal static Task<SftpFileStream> OpenAsync(
175178
FileAccess access,
176179
int bufferSize,
177180
CancellationToken cancellationToken,
178-
bool isDownloadFile = false)
181+
bool isDownloadFile = false,
182+
int maxPendingReads = 100)
179183
{
180-
return Open(session, path, mode, access, bufferSize, isDownloadFile, isAsync: true, cancellationToken);
184+
return Open(session, path, mode, access, bufferSize, maxPendingReads, isDownloadFile, isAsync: true, cancellationToken);
181185
}
182186

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

314-
initialReader = new(handle, session, readBufferSize, position, MaxPendingReads, (ulong)attributes.Size, initialPendingReads);
319+
initialReader = new(handle, session, readBufferSize, position, maxPendingReads, (ulong)attributes.Size, initialPendingReads);
315320
}
316321
else if ((access & FileAccess.Read) == FileAccess.Read)
317322
{
318323
// The reader can use the size information to reduce in-flight requests near the expected EOF,
319324
// so pass it in here.
320-
initialReader = new(handle, session, readBufferSize, position, MaxPendingReads, (ulong)attributes.Size);
325+
initialReader = new(handle, session, readBufferSize, position, maxPendingReads, (ulong)attributes.Size);
321326
}
322327
}
323328
else
@@ -327,7 +332,7 @@ private static async Task<SftpFileStream> Open(
327332
canSeek = false;
328333
}
329334

330-
return new SftpFileStream(session, path, access, canSeek, readBufferSize, writeBufferSize, handle, position, initialReader);
335+
return new SftpFileStream(session, path, access, canSeek, readBufferSize, writeBufferSize, handle, position, maxPendingReads, initialReader);
331336
}
332337

333338
/// <inheritdoc/>
@@ -421,7 +426,7 @@ private int Read(Span<byte> buffer)
421426
if (_sftpFileReader is null)
422427
{
423428
Flush();
424-
_sftpFileReader = new(Handle, _session, _readBufferSize, _position, MaxPendingReads);
429+
_sftpFileReader = new(Handle, _session, _readBufferSize, _position, _maxPendingReads);
425430
}
426431

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

478-
_sftpFileReader = new(Handle, _session, _readBufferSize, _position, MaxPendingReads);
483+
_sftpFileReader = new(Handle, _session, _readBufferSize, _position, _maxPendingReads);
479484
}
480485

481486
_readBuffer = await _sftpFileReader.ReadAsync(cancellationToken).ConfigureAwait(false);

src/Renci.SshNet/SftpClient.cs

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ public class SftpClient : BaseClient, ISftpClient
4242
/// </summary>
4343
private uint _bufferSize;
4444

45+
/// <summary>
46+
/// Holds the maximum number of pending reads.
47+
/// </summary>
48+
private int _maxPendingReads;
49+
4550
/// <summary>
4651
/// Gets or sets the operation timeout.
4752
/// </summary>
@@ -112,6 +117,45 @@ public uint BufferSize
112117
}
113118
}
114119

120+
/// <summary>
121+
/// Gets or sets the maximum number of pending read requests allowed in read-ahead mode.
122+
/// </summary>
123+
/// <value>
124+
/// The maximum number of pending read requests. The default value is 100.
125+
/// </value>
126+
/// <remarks>
127+
/// <para>
128+
/// This controls how many SSH_FXP_READ requests can be in-flight simultaneously
129+
/// when sequentially reading a file. Higher values allow the library to pipeline
130+
/// more requests, improving throughput on high-latency connections.
131+
/// </para>
132+
/// <para>
133+
/// On resource-constrained platforms (e.g., mobile devices), reducing this value
134+
/// can prevent connection stalls when downloading larger files.
135+
/// </para>
136+
/// </remarks>
137+
/// <exception cref="ArgumentOutOfRangeException"><paramref name="value"/> is less than 1.</exception>
138+
/// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
139+
public int MaxPendingReads
140+
{
141+
get
142+
{
143+
CheckDisposed();
144+
return _maxPendingReads;
145+
}
146+
set
147+
{
148+
CheckDisposed();
149+
150+
if (value < 1)
151+
{
152+
throw new ArgumentOutOfRangeException(nameof(value), "Cannot be less than one.");
153+
}
154+
155+
_maxPendingReads = value;
156+
}
157+
}
158+
115159
/// <summary>
116160
/// Gets a value indicating whether this client is connected to the server and
117161
/// the SFTP session is open.
@@ -279,6 +323,7 @@ internal SftpClient(ConnectionInfo connectionInfo, bool ownsConnectionInfo, ISer
279323
{
280324
_operationTimeout = Timeout.Infinite;
281325
_bufferSize = 1024 * 32;
326+
_maxPendingReads = 100;
282327
}
283328

284329
#endregion Constructors
@@ -1544,7 +1589,7 @@ public SftpFileStream Create(string path, int bufferSize)
15441589
{
15451590
CheckDisposed();
15461591

1547-
return SftpFileStream.Open(_sftpSession, path, FileMode.Create, FileAccess.ReadWrite, bufferSize);
1592+
return SftpFileStream.Open(_sftpSession, path, FileMode.Create, FileAccess.ReadWrite, bufferSize, maxPendingReads: _maxPendingReads);
15481593
}
15491594

15501595
/// <inheritdoc/>
@@ -1682,7 +1727,7 @@ public SftpFileStream Open(string path, FileMode mode, FileAccess access)
16821727
{
16831728
CheckDisposed();
16841729

1685-
return SftpFileStream.Open(_sftpSession, path, mode, access, (int)_bufferSize);
1730+
return SftpFileStream.Open(_sftpSession, path, mode, access, (int)_bufferSize, maxPendingReads: _maxPendingReads);
16861731
}
16871732

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

1706-
return SftpFileStream.OpenAsync(_sftpSession, path, mode, access, (int)_bufferSize, cancellationToken);
1751+
return SftpFileStream.OpenAsync(_sftpSession, path, mode, access, (int)_bufferSize, cancellationToken, maxPendingReads: _maxPendingReads);
17071752
}
17081753

17091754
/// <summary>
@@ -2357,7 +2402,8 @@ private async Task InternalDownloadFile(
23572402
FileAccess.Read,
23582403
(int)_bufferSize,
23592404
cancellationToken,
2360-
isDownloadFile: true).ConfigureAwait(false);
2405+
isDownloadFile: true,
2406+
maxPendingReads: _maxPendingReads).ConfigureAwait(false);
23612407
}
23622408
else
23632409
{
@@ -2369,7 +2415,8 @@ private async Task InternalDownloadFile(
23692415
FileMode.Open,
23702416
FileAccess.Read,
23712417
(int)_bufferSize,
2372-
isDownloadFile: true);
2418+
isDownloadFile: true,
2419+
maxPendingReads: _maxPendingReads);
23732420
}
23742421

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

0 commit comments

Comments
 (0)