Skip to content

Commit 0ff2c50

Browse files
authored
SftpClient: add IProgress to DownloadFileAsync and UploadFileAsync (#1771)
* SftpClient: add DownloadFileAsync overload with downloadCallback fixes #1765 * Change to IProgress and also add to UploadFileAsync
1 parent 8cd6ad6 commit 0ff2c50

File tree

6 files changed

+183
-19
lines changed

6 files changed

+183
-19
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace Renci.SshNet
2+
{
3+
/// <summary>
4+
/// Provides the progress for a file download.
5+
/// </summary>
6+
public struct DownloadFileProgressReport
7+
{
8+
/// <summary>
9+
/// Gets the total number of bytes downloaded.
10+
/// </summary>
11+
public ulong TotalBytesDownloaded { get; internal set; }
12+
}
13+
}

src/Renci.SshNet/ISftpClient.cs

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,23 @@ public interface ISftpClient : IBaseClient
578578
/// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
579579
Task DownloadFileAsync(string path, Stream output, CancellationToken cancellationToken = default);
580580

581+
/// <summary>
582+
/// Asynchronously downloads a remote file into a <see cref="Stream"/>.
583+
/// </summary>
584+
/// <param name="path">The path to the remote file.</param>
585+
/// <param name="output">The <see cref="Stream"/> to write the file into.</param>
586+
/// <param name="downloadProgress">The download progress.</param>
587+
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to observe.</param>
588+
/// <returns>A <see cref="Task"/> that represents the asynchronous download operation.</returns>
589+
/// <exception cref="ArgumentNullException"><paramref name="output"/> or <paramref name="path"/> is <see langword="null"/>.</exception>
590+
/// <exception cref="ArgumentException"><paramref name="path"/> is empty or contains only whitespace characters.</exception>
591+
/// <exception cref="SshConnectionException">Client is not connected.</exception>
592+
/// <exception cref="SftpPermissionDeniedException">Permission to perform the operation was denied by the remote host. <para>-or-</para> An SSH command was denied by the server.</exception>
593+
/// <exception cref="SftpPathNotFoundException"><paramref name="path"/> was not found on the remote host.</exception>
594+
/// <exception cref="SshException">An SSH error where <see cref="Exception.Message" /> is the message from the remote host.</exception>
595+
/// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
596+
Task DownloadFileAsync(string path, Stream output, IProgress<DownloadFileProgressReport>? downloadProgress, CancellationToken cancellationToken = default);
597+
581598
/// <summary>
582599
/// Ends an asynchronous file downloading into the stream.
583600
/// </summary>
@@ -1133,12 +1150,29 @@ public interface ISftpClient : IBaseClient
11331150
/// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
11341151
Task UploadFileAsync(Stream input, string path, CancellationToken cancellationToken = default);
11351152

1153+
/// <summary>
1154+
/// Asynchronously uploads a <see cref="Stream"/> to a remote file path.
1155+
/// </summary>
1156+
/// <param name="input">The <see cref="Stream"/> to write to the remote path.</param>
1157+
/// <param name="path">The remote file path to write to.</param>
1158+
/// <param name="uploadProgress">The upload progress.</param>
1159+
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to observe.</param>
1160+
/// <returns>A <see cref="Task"/> that represents the asynchronous upload operation.</returns>
1161+
/// <exception cref="ArgumentNullException"><paramref name="input"/> or <paramref name="path"/> is <see langword="null"/>.</exception>
1162+
/// <exception cref="ArgumentException"><paramref name="path" /> is empty or contains only whitespace characters.</exception>
1163+
/// <exception cref="SshConnectionException">Client is not connected.</exception>
1164+
/// <exception cref="SftpPermissionDeniedException">Permission to upload the file was denied by the remote host. <para>-or-</para> An SSH command was denied by the server.</exception>
1165+
/// <exception cref="SshException">An SSH error where <see cref="Exception.Message" /> is the message from the remote host.</exception>
1166+
/// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
1167+
Task UploadFileAsync(Stream input, string path, IProgress<UploadFileProgressReport>? uploadProgress, CancellationToken cancellationToken = default);
1168+
11361169
/// <summary>
11371170
/// Asynchronously uploads a <see cref="Stream"/> to a remote file path.
11381171
/// </summary>
11391172
/// <param name="input">The <see cref="Stream"/> to write to the remote path.</param>
11401173
/// <param name="path">The remote file path to write to.</param>
11411174
/// <param name="canOverride">Whether the remote file can be overwritten if it already exists.</param>
1175+
/// <param name="uploadProgress">The upload progress.</param>
11421176
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to observe.</param>
11431177
/// <returns>A <see cref="Task"/> that represents the asynchronous upload operation.</returns>
11441178
/// <exception cref="ArgumentNullException"><paramref name="input"/> or <paramref name="path"/> is <see langword="null"/>.</exception>
@@ -1147,7 +1181,7 @@ public interface ISftpClient : IBaseClient
11471181
/// <exception cref="SftpPermissionDeniedException">Permission to upload the file was denied by the remote host. <para>-or-</para> An SSH command was denied by the server.</exception>
11481182
/// <exception cref="SshException">An SSH error where <see cref="Exception.Message" /> is the message from the remote host.</exception>
11491183
/// <exception cref="ObjectDisposedException">The method was called after the client was disposed.</exception>
1150-
Task UploadFileAsync(Stream input, string path, bool canOverride, CancellationToken cancellationToken = default);
1184+
Task UploadFileAsync(Stream input, string path, bool canOverride, IProgress<UploadFileProgressReport>? uploadProgress = null, CancellationToken cancellationToken = default);
11511185

11521186
/// <summary>
11531187
/// Writes the specified byte array to the specified file, and closes the file.

src/Renci.SshNet/SftpClient.cs

Lines changed: 64 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -901,17 +901,30 @@ public void DownloadFile(string path, Stream output, Action<ulong>? downloadCall
901901
ArgumentNullException.ThrowIfNull(output);
902902
CheckDisposed();
903903

904+
IProgress<DownloadFileProgressReport>? downloadProgress = null;
905+
906+
if (downloadCallback != null)
907+
{
908+
downloadProgress = new Progress<DownloadFileProgressReport>(r => downloadCallback(r.TotalBytesDownloaded));
909+
}
910+
904911
InternalDownloadFile(
905912
path,
906913
output,
907914
asyncResult: null,
908-
downloadCallback,
915+
downloadProgress,
909916
isAsync: false,
910917
CancellationToken.None).GetAwaiter().GetResult();
911918
}
912919

913920
/// <inheritdoc />
914921
public Task DownloadFileAsync(string path, Stream output, CancellationToken cancellationToken = default)
922+
{
923+
return DownloadFileAsync(path, output, downloadProgress: null, cancellationToken);
924+
}
925+
926+
/// <inheritdoc />
927+
public Task DownloadFileAsync(string path, Stream output, IProgress<DownloadFileProgressReport>? downloadProgress, CancellationToken cancellationToken = default)
915928
{
916929
ArgumentException.ThrowIfNullOrWhiteSpace(path);
917930
ArgumentNullException.ThrowIfNull(output);
@@ -921,7 +934,7 @@ public Task DownloadFileAsync(string path, Stream output, CancellationToken canc
921934
path,
922935
output,
923936
asyncResult: null,
924-
downloadCallback: null,
937+
downloadProgress: downloadProgress,
925938
isAsync: true,
926939
cancellationToken);
927940
}
@@ -994,6 +1007,13 @@ public IAsyncResult BeginDownloadFile(string path, Stream output, AsyncCallback?
9941007
ArgumentNullException.ThrowIfNull(output);
9951008
CheckDisposed();
9961009

1010+
IProgress<DownloadFileProgressReport>? downloadProgress = null;
1011+
1012+
if (downloadCallback != null)
1013+
{
1014+
downloadProgress = new Progress<DownloadFileProgressReport>(r => downloadCallback(r.TotalBytesDownloaded));
1015+
}
1016+
9971017
var asyncResult = new SftpDownloadAsyncResult(asyncCallback, state);
9981018

9991019
_ = DoDownloadAndSetResult();
@@ -1006,7 +1026,7 @@ await InternalDownloadFile(
10061026
path,
10071027
output,
10081028
asyncResult,
1009-
downloadCallback,
1029+
downloadProgress,
10101030
isAsync: true,
10111031
CancellationToken.None).ConfigureAwait(false);
10121032

@@ -1065,24 +1085,37 @@ public void UploadFile(Stream input, string path, bool canOverride, Action<ulong
10651085
flags |= Flags.CreateNew;
10661086
}
10671087

1088+
IProgress<UploadFileProgressReport>? uploadProgress = null;
1089+
1090+
if (uploadCallback != null)
1091+
{
1092+
uploadProgress = new Progress<UploadFileProgressReport>(r => uploadCallback(r.TotalBytesUploaded));
1093+
}
1094+
10681095
InternalUploadFile(
10691096
input,
10701097
path,
10711098
flags,
10721099
asyncResult: null,
1073-
uploadCallback,
1100+
uploadProgress,
10741101
isAsync: false,
10751102
CancellationToken.None).GetAwaiter().GetResult();
10761103
}
10771104

10781105
/// <inheritdoc />
10791106
public Task UploadFileAsync(Stream input, string path, CancellationToken cancellationToken = default)
10801107
{
1081-
return UploadFileAsync(input, path, canOverride: true, cancellationToken);
1108+
return UploadFileAsync(input, path, canOverride: true, uploadProgress: null, cancellationToken);
10821109
}
10831110

10841111
/// <inheritdoc />
1085-
public Task UploadFileAsync(Stream input, string path, bool canOverride, CancellationToken cancellationToken = default)
1112+
public Task UploadFileAsync(Stream input, string path, IProgress<UploadFileProgressReport>? uploadProgress, CancellationToken cancellationToken = default)
1113+
{
1114+
return UploadFileAsync(input, path, canOverride: true, uploadProgress, cancellationToken);
1115+
}
1116+
1117+
/// <inheritdoc />
1118+
public Task UploadFileAsync(Stream input, string path, bool canOverride, IProgress<UploadFileProgressReport>? uploadProgress = null, CancellationToken cancellationToken = default)
10861119
{
10871120
ArgumentNullException.ThrowIfNull(input);
10881121
ArgumentException.ThrowIfNullOrWhiteSpace(path);
@@ -1104,7 +1137,7 @@ public Task UploadFileAsync(Stream input, string path, bool canOverride, Cancell
11041137
path,
11051138
flags,
11061139
asyncResult: null,
1107-
uploadCallback: null,
1140+
uploadProgress,
11081141
isAsync: true,
11091142
cancellationToken);
11101143
}
@@ -1236,6 +1269,13 @@ public IAsyncResult BeginUploadFile(Stream input, string path, bool canOverride,
12361269
flags |= Flags.CreateNew;
12371270
}
12381271

1272+
IProgress<UploadFileProgressReport>? uploadProgress = null;
1273+
1274+
if (uploadCallback != null)
1275+
{
1276+
uploadProgress = new Progress<UploadFileProgressReport>(r => uploadCallback(r.TotalBytesUploaded));
1277+
}
1278+
12391279
var asyncResult = new SftpUploadAsyncResult(asyncCallback, state);
12401280

12411281
_ = DoUploadAndSetResult();
@@ -1249,7 +1289,7 @@ await InternalUploadFile(
12491289
path,
12501290
flags,
12511291
asyncResult,
1252-
uploadCallback,
1292+
uploadProgress,
12531293
isAsync: true,
12541294
CancellationToken.None).ConfigureAwait(false);
12551295

@@ -2195,7 +2235,7 @@ private List<FileInfo> InternalSynchronizeDirectories(string sourcePath, string
21952235
remoteFileName,
21962236
uploadFlag,
21972237
asyncResult: null,
2198-
uploadCallback: null,
2238+
uploadProgress: null,
21992239
isAsync: false,
22002240
CancellationToken.None).GetAwaiter().GetResult();
22012241
#pragma warning restore CA2025 // Do not pass 'IDisposable' instances into unawaited tasks
@@ -2291,7 +2331,7 @@ private async Task InternalDownloadFile(
22912331
string path,
22922332
Stream output,
22932333
SftpDownloadAsyncResult? asyncResult,
2294-
Action<ulong>? downloadCallback,
2334+
IProgress<DownloadFileProgressReport>? downloadProgress,
22952335
bool isAsync,
22962336
CancellationToken cancellationToken)
22972337
{
@@ -2377,13 +2417,15 @@ private async Task InternalDownloadFile(
23772417

23782418
asyncResult?.Update(totalBytesRead);
23792419

2380-
if (downloadCallback is not null)
2420+
if (downloadProgress is not null)
23812421
{
23822422
// Copy offset to ensure it's not modified between now and execution of callback
2383-
var downloadOffset = totalBytesRead;
2423+
var report = new DownloadFileProgressReport()
2424+
{
2425+
TotalBytesDownloaded = totalBytesRead,
2426+
};
23842427

2385-
// Execute callback on different thread
2386-
ThreadAbstraction.ExecuteThread(() => { downloadCallback(downloadOffset); });
2428+
downloadProgress.Report(report);
23872429
}
23882430
}
23892431
}
@@ -2407,7 +2449,7 @@ private async Task InternalUploadFile(
24072449
string path,
24082450
Flags flags,
24092451
SftpUploadAsyncResult? asyncResult,
2410-
Action<ulong>? uploadCallback,
2452+
IProgress<UploadFileProgressReport>? uploadProgress,
24112453
bool isAsync,
24122454
CancellationToken cancellationToken)
24132455
{
@@ -2495,10 +2537,14 @@ private async Task InternalUploadFile(
24952537
asyncResult?.Update(writtenBytes);
24962538

24972539
// Call callback to report number of bytes written
2498-
if (uploadCallback is not null)
2540+
if (uploadProgress is not null)
24992541
{
2500-
// Execute callback on different thread
2501-
ThreadAbstraction.ExecuteThread(() => uploadCallback(writtenBytes));
2542+
UploadFileProgressReport report = new()
2543+
{
2544+
TotalBytesUploaded = writtenBytes,
2545+
};
2546+
2547+
uploadProgress.Report(report);
25022548
}
25032549
}
25042550
finally
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
namespace Renci.SshNet
2+
{
3+
/// <summary>
4+
/// Provides the progress for a file upload.
5+
/// </summary>
6+
public struct UploadFileProgressReport
7+
{
8+
/// <summary>
9+
/// Gets the total number of bytes uploaded.
10+
/// </summary>
11+
public ulong TotalBytesUploaded { get; internal set; }
12+
}
13+
}

test/Renci.SshNet.IntegrationTests/OldIntegrationTests/SftpClientTest.Download.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,5 +124,34 @@ public void Test_Sftp_EndDownloadFile_Invalid_Async_Handle()
124124
Assert.ThrowsExactly<ArgumentException>(() => sftp.EndDownloadFile(async1));
125125
}
126126
}
127+
128+
[TestMethod]
129+
[TestCategory("Sftp")]
130+
public async Task Test_Sftp_DownloadFileAsync_DownloadProgress()
131+
{
132+
using (var sftp = new SftpClient(SshServerHostName, SshServerPort, User.UserName, User.Password))
133+
{
134+
await sftp.ConnectAsync(CancellationToken.None);
135+
var filename = Path.GetTempFileName();
136+
int testFileSizeMB = 1;
137+
CreateTestFile(filename, testFileSizeMB);
138+
await sftp.UploadFileAsync(File.OpenRead(filename), "test123");
139+
using ManualResetEventSlim finalCallbackCalledEvent = new();
140+
141+
IProgress<DownloadFileProgressReport> progress = new Progress<DownloadFileProgressReport>(r =>
142+
{
143+
if ((int)r.TotalBytesDownloaded == testFileSizeMB * 1024 * 1024)
144+
{
145+
finalCallbackCalledEvent.Set();
146+
}
147+
});
148+
149+
await sftp.DownloadFileAsync("test123", new MemoryStream(), progress, CancellationToken.None);
150+
151+
// since the callback is queued to the thread pool, wait for the event.
152+
bool callbackCalled = finalCallbackCalledEvent.Wait(5000);
153+
Assert.IsTrue(callbackCalled);
154+
}
155+
}
127156
}
128157
}

test/Renci.SshNet.IntegrationTests/OldIntegrationTests/SftpClientTest.Upload.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,5 +453,34 @@ public void Test_Sftp_EndUploadFile_Invalid_Async_Handle()
453453
Assert.ThrowsExactly<ArgumentException>(() => sftp.EndUploadFile(async1));
454454
}
455455
}
456+
457+
[TestMethod]
458+
[TestCategory("Sftp")]
459+
public async Task Test_Sftp_UploadFileAsync_UploadProgress()
460+
{
461+
using (var sftp = new SftpClient(SshServerHostName, SshServerPort, User.UserName, User.Password))
462+
{
463+
await sftp.ConnectAsync(CancellationToken.None);
464+
var filename = Path.GetTempFileName();
465+
int testFileSizeMB = 1;
466+
CreateTestFile(filename, testFileSizeMB);
467+
using var fileStream = File.OpenRead(filename);
468+
using ManualResetEventSlim finalCallbackCalledEvent = new();
469+
470+
IProgress<UploadFileProgressReport> progress = new Progress<UploadFileProgressReport>(r =>
471+
{
472+
if ((int)r.TotalBytesUploaded == testFileSizeMB * 1024 * 1024)
473+
{
474+
finalCallbackCalledEvent.Set();
475+
}
476+
});
477+
478+
await sftp.UploadFileAsync(fileStream, "test", progress);
479+
480+
// since the callback is queued to the thread pool, wait for the event.
481+
bool callbackCalled = finalCallbackCalledEvent.Wait(5000);
482+
Assert.IsTrue(callbackCalled);
483+
}
484+
}
456485
}
457486
}

0 commit comments

Comments
 (0)