From 9ef80b5d4f2327235305c920388a9732b761a628 Mon Sep 17 00:00:00 2001 From: Rob Hague Date: Mon, 10 Jun 2024 21:53:35 +0200 Subject: [PATCH 1/2] Add SshCommand.ExecuteAsync After the previous change (#1423), this basically entails swapping out the IAsyncResult for a TaskCompletionSource and hooking up the cancellation/timeout logic. As with the prior Begin/End implementation, the initiation of the command (SendExecRequest) happens synchronously, so there's a bit of room for improvement there, but otherwise it is the Task-based async that we know and like. I chose to make it void (Task)- returning instead of string like in the existing overloads, so that OutputStream is not automatically consumed (and encoded as a string) when that may not be desired. As in #650, I was initially considering changing the other overloads to be void-returning as well, but decided that it was not worth the break since most people will probably want to change over to ExecuteAsync anyway. --- src/Renci.SshNet/.editorconfig | 8 + src/Renci.SshNet/CommandAsyncResult.cs | 60 ---- src/Renci.SshNet/SshCommand.cs | 308 ++++++++++-------- .../.editorconfig | 3 + .../OldIntegrationTests/SshCommandTest.cs | 48 ++- .../SshClientTests.cs | 23 -- .../Renci.SshNet.IntegrationTests/SshTests.cs | 23 +- .../Classes/CommandAsyncResultTest.cs | 34 -- 8 files changed, 236 insertions(+), 271 deletions(-) delete mode 100644 src/Renci.SshNet/CommandAsyncResult.cs delete mode 100644 test/Renci.SshNet.Tests/Classes/CommandAsyncResultTest.cs diff --git a/src/Renci.SshNet/.editorconfig b/src/Renci.SshNet/.editorconfig index 9e17f2e36..2f3951902 100644 --- a/src/Renci.SshNet/.editorconfig +++ b/src/Renci.SshNet/.editorconfig @@ -180,3 +180,11 @@ dotnet_diagnostic.CA5350.severity = none # CA5351: Do Not Use Broken Cryptographic Algorithms # https://learn.microsoft.com/en-ca/dotnet/fundamentals/code-analysis/quality-rules/ca5351 dotnet_diagnostic.CA5351.severity = none + +# MA0040: Forward the CancellationToken parameter to methods that take one +# Partial/noisy duplicate of CA2016 +dotnet_diagnostic.MA0040.severity = none + +# MA0042: Do not use blocking calls in an async method +# duplicate of CA1849 +dotnet_diagnostic.MA0042.severity = none diff --git a/src/Renci.SshNet/CommandAsyncResult.cs b/src/Renci.SshNet/CommandAsyncResult.cs deleted file mode 100644 index ad96d39de..000000000 --- a/src/Renci.SshNet/CommandAsyncResult.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.Threading; - -namespace Renci.SshNet -{ - /// - /// Provides additional information for asynchronous command execution. - /// - public class CommandAsyncResult : IAsyncResult - { - /// - /// Initializes a new instance of the class. - /// - internal CommandAsyncResult() - { - } - - /// - /// Gets or sets the bytes received. If SFTP only file bytes are counted. - /// - /// Total bytes received. - public int BytesReceived { get; set; } - - /// - /// Gets or sets the bytes sent by SFTP. - /// - /// Total bytes sent. - public int BytesSent { get; set; } - - /// - /// Gets a user-defined object that qualifies or contains information about an asynchronous operation. - /// - /// A user-defined object that qualifies or contains information about an asynchronous operation. - public object AsyncState { get; internal set; } - - /// - /// Gets a that is used to wait for an asynchronous operation to complete. - /// - /// - /// A that is used to wait for an asynchronous operation to complete. - /// - public WaitHandle AsyncWaitHandle { get; internal set; } - - /// - /// Gets a value indicating whether the asynchronous operation completed synchronously. - /// - /// - /// true if the asynchronous operation completed synchronously; otherwise, false. - /// - public bool CompletedSynchronously { get; internal set; } - - /// - /// Gets a value indicating whether the asynchronous operation has completed. - /// - /// - /// true if the operation is complete; otherwise, false. - /// - public bool IsCompleted { get; internal set; } - } -} diff --git a/src/Renci.SshNet/SshCommand.cs b/src/Renci.SshNet/SshCommand.cs index cfede42dd..e4a4a818d 100644 --- a/src/Renci.SshNet/SshCommand.cs +++ b/src/Renci.SshNet/SshCommand.cs @@ -2,11 +2,10 @@ using System; using System.Diagnostics; using System.IO; -using System.Runtime.ExceptionServices; using System.Text; using System.Threading; +using System.Threading.Tasks; -using Renci.SshNet.Abstractions; using Renci.SshNet.Channels; using Renci.SshNet.Common; using Renci.SshNet.Messages.Connection; @@ -19,20 +18,13 @@ namespace Renci.SshNet /// public class SshCommand : IDisposable { - private static readonly object CompletedResult = new(); - private readonly ISession _session; private readonly Encoding _encoding; - /// - /// The result of the command: an exception, - /// or . - /// - private object? _result; - private IChannelSession? _channel; - private CommandAsyncResult? _asyncResult; - private AsyncCallback? _callback; + private TaskCompletionSource? _tcs; + private CancellationTokenSource? _cts; + private CancellationTokenRegistration _tokenRegistration; private string? _stdOut; private string? _stdErr; private bool _hasError; @@ -40,6 +32,17 @@ public class SshCommand : IDisposable private ChannelInputStream? _inputStream; private TimeSpan _commandTimeout; + /// + /// The token supplied as an argument to . + /// + private CancellationToken _userToken; + + /// + /// Whether has been called + /// (either by a token or manually). + /// + private bool _cancellationRequested; + private int _exitStatus; private volatile bool _haveExitStatus; // volatile to prevent re-ordering of reads/writes of _exitStatus. @@ -113,6 +116,11 @@ public int? ExitStatus /// /// The stream that can be used to transfer data to the command's input stream. /// + /// + /// Callers should ensure that is called on the + /// returned instance in order to notify the command that no more data will be sent. + /// Failure to do so may result in the command executing indefinitely. + /// public Stream CreateInputStream() { if (_channel == null) @@ -130,7 +138,7 @@ public Stream CreateInputStream() } /// - /// Gets the command execution result. + /// Gets the standard output of the command by reading . /// public string Result { @@ -141,7 +149,7 @@ public string Result return _stdOut; } - if (_asyncResult is null) + if (_tcs is null) { return string.Empty; } @@ -154,7 +162,8 @@ public string Result } /// - /// Gets the command execution error. + /// Gets the standard error of the command by reading , + /// when extended data has been sent which has been marked as stderr. /// public string Error { @@ -165,7 +174,7 @@ public string Error return _stdErr; } - if (_asyncResult is null || !_hasError) + if (_tcs is null || !_hasError) { return string.Empty; } @@ -211,6 +220,92 @@ internal SshCommand(ISession session, string commandText, Encoding encoding) _session.ErrorOccured += Session_ErrorOccured; } + /// + /// Executes the command asynchronously. + /// + /// + /// The . When triggered, attempts to terminate the + /// remote command by sending a signal. + /// + /// A representing the lifetime of the command. + /// Command is already executing. Thrown synchronously. + /// Instance has been disposed. Thrown synchronously. + /// The has been cancelled. + /// The command timed out according to . +#pragma warning disable CA1849 // Call async methods when in an async method; PipeStream.DisposeAsync would complete synchronously anyway. + public Task ExecuteAsync(CancellationToken cancellationToken = default) + { +#if NET7_0_OR_GREATER + ObjectDisposedException.ThrowIf(_isDisposed, this); +#else + if (_isDisposed) + { + throw new ObjectDisposedException(GetType().FullName); + } +#endif + + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + if (_tcs is not null) + { + if (!_tcs.Task.IsCompleted) + { + throw new InvalidOperationException("Asynchronous operation is already in progress."); + } + + OutputStream.Dispose(); + ExtendedOutputStream.Dispose(); + + // Initialize output streams. We already initialised them for the first + // execution in the constructor (to allow passing them around before execution) + // so we just need to reinitialise them for subsequent executions. + OutputStream = new PipeStream(); + ExtendedOutputStream = new PipeStream(); + } + + _exitStatus = default; + _haveExitStatus = false; + ExitSignal = null; + _stdOut = null; + _stdErr = null; + _hasError = false; + _tokenRegistration.Dispose(); + _tokenRegistration = default; + _cts?.Dispose(); + _cts = null; + _cancellationRequested = false; + + _tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _userToken = cancellationToken; + + _channel = _session.CreateChannelSession(); + _channel.DataReceived += Channel_DataReceived; + _channel.ExtendedDataReceived += Channel_ExtendedDataReceived; + _channel.RequestReceived += Channel_RequestReceived; + _channel.Closed += Channel_Closed; + _channel.Open(); + + _ = _channel.SendExecRequest(CommandText); + + if (CommandTimeout != Timeout.InfiniteTimeSpan) + { + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _cts.CancelAfter(CommandTimeout); + cancellationToken = _cts.Token; + } + + if (cancellationToken.CanBeCanceled) + { + _tokenRegistration = cancellationToken.Register(static cmd => ((SshCommand)cmd!).CancelAsync(), this); + } + + return _tcs.Task; + } +#pragma warning restore CA1849 + /// /// Begins an asynchronous command execution. /// @@ -259,58 +354,7 @@ public IAsyncResult BeginExecute(AsyncCallback? callback) /// Operation has timed out. public IAsyncResult BeginExecute(AsyncCallback? callback, object? state) { -#if NET7_0_OR_GREATER - ObjectDisposedException.ThrowIf(_isDisposed, this); -#else - if (_isDisposed) - { - throw new ObjectDisposedException(GetType().FullName); - } -#endif - - if (_asyncResult is not null) - { - if (!_asyncResult.AsyncWaitHandle.WaitOne(0)) - { - throw new InvalidOperationException("Asynchronous operation is already in progress."); - } - - OutputStream.Dispose(); - ExtendedOutputStream.Dispose(); - - // Initialize output streams. We already initialised them for the first - // execution in the constructor (to allow passing them around before execution) - // so we just need to reinitialise them for subsequent executions. - OutputStream = new PipeStream(); - ExtendedOutputStream = new PipeStream(); - } - - // Create new AsyncResult object - _asyncResult = new CommandAsyncResult - { - AsyncWaitHandle = new ManualResetEvent(initialState: false), - AsyncState = state, - }; - - _exitStatus = default; - _haveExitStatus = false; - ExitSignal = null; - _result = null; - _stdOut = null; - _stdErr = null; - _hasError = false; - _callback = callback; - - _channel = _session.CreateChannelSession(); - _channel.DataReceived += Channel_DataReceived; - _channel.ExtendedDataReceived += Channel_ExtendedDataReceived; - _channel.RequestReceived += Channel_RequestReceived; - _channel.Closed += Channel_Closed; - _channel.Open(); - - _ = _channel.SendExecRequest(CommandText); - - return _asyncResult; + return TaskToAsyncResult.Begin(ExecuteAsync(), callback, state); } /// @@ -340,38 +384,19 @@ public IAsyncResult BeginExecute(string commandText, AsyncCallback? callback, ob /// Waits for the pending asynchronous command execution to complete. /// /// The reference to the pending asynchronous request to finish. - /// Command execution result. + /// . /// Either the IAsyncResult object did not come from the corresponding async method on this type, or EndExecute was called multiple times with the same IAsyncResult. /// is . public string EndExecute(IAsyncResult asyncResult) { - if (asyncResult is null) - { - throw new ArgumentNullException(nameof(asyncResult)); - } + var executeTask = TaskToAsyncResult.Unwrap(asyncResult); - if (_asyncResult != asyncResult) + if (executeTask != _tcs?.Task) { throw new ArgumentException("Argument does not correspond to the currently executing command.", nameof(asyncResult)); } - _inputStream?.Dispose(); - - if (!_asyncResult.AsyncWaitHandle.WaitOne(CommandTimeout)) - { - // Complete the operation with a TimeoutException (which will be thrown below). - SetAsyncComplete(new SshOperationTimeoutException($"Command '{CommandText}' timed out. ({nameof(CommandTimeout)}: {CommandTimeout}).")); - } - - Debug.Assert(_asyncResult.IsCompleted); - - if (_result is Exception exception) - { - ExceptionDispatchInfo.Capture(exception).Throw(); - } - - Debug.Assert(_result == CompletedResult); - Debug.Assert(!OutputStream.CanWrite, $"{nameof(OutputStream)} should have been disposed (else we will block)."); + executeTask.GetAwaiter().GetResult(); return Result; } @@ -401,50 +426,59 @@ public string EndExecute(IAsyncResult asyncResult) /// Command has not been started. public void CancelAsync(bool forceKill = false, int millisecondsTimeout = 500) { - if (_asyncResult is not { } asyncResult) + if (_tcs is null) { throw new InvalidOperationException("Command has not been started."); } - var exception = new OperationCanceledException($"Command '{CommandText}' was cancelled."); - - if (Interlocked.CompareExchange(ref _result, exception, comparand: null) is not null) + if (_tcs.Task.IsCompleted) { - // Command has already completed. return; } + _cancellationRequested = true; + Interlocked.MemoryBarrier(); // ensure fresh read in SetAsyncComplete (possibly unnecessary) + // Try to send the cancellation signal. if (_channel?.SendSignalRequest(forceKill ? "KILL" : "TERM") is null) { // Command has completed (in the meantime since the last check). - // We won the race above and the command has finished by some other means, - // but will throw the OperationCanceledException. return; } // Having sent the "signal" message, we expect to receive "exit-signal" // and then a close message. But since a server may not implement signals, // we can't guarantee that, so we wait a short time for that to happen and - // if it doesn't, just set the WaitHandle ourselves to unblock EndExecute. + // if it doesn't, just complete the task ourselves to unblock waiters. - if (!asyncResult.AsyncWaitHandle.WaitOne(millisecondsTimeout)) + try + { + if (_tcs.Task.Wait(millisecondsTimeout)) + { + return; + } + } + catch (AggregateException) { - SetAsyncComplete(asyncResult); + // We expect to be here if the server implements signals. + // But we don't want to propagate the exception on the task from here. + return; } + + SetAsyncComplete(); } /// /// Executes command specified by property. /// - /// - /// Command execution result. - /// + /// . /// Client is not connected. /// Operation has timed out. public string Execute() { - return EndExecute(BeginExecute(callback: null, state: null)); + ExecuteAsync().GetAwaiter().GetResult(); + + return Result; } /// @@ -465,44 +499,53 @@ public string Execute(string commandText) private void Session_Disconnected(object? sender, EventArgs e) { - SetAsyncComplete(new SshConnectionException("An established connection was aborted by the software in your host machine.", DisconnectReason.ConnectionLost)); + _ = _tcs?.TrySetException(new SshConnectionException("An established connection was aborted by the software in your host machine.", DisconnectReason.ConnectionLost)); + + SetAsyncComplete(setResult: false); } private void Session_ErrorOccured(object? sender, ExceptionEventArgs e) { - SetAsyncComplete(e.Exception); + _ = _tcs?.TrySetException(e.Exception); + + SetAsyncComplete(setResult: false); } - private void SetAsyncComplete(object result) + private void SetAsyncComplete(bool setResult = true) { - _ = Interlocked.CompareExchange(ref _result, result, comparand: null); + Interlocked.MemoryBarrier(); // ensure fresh read of _cancellationRequested (possibly unnecessary) - if (_asyncResult is CommandAsyncResult asyncResult) + if (setResult) { - SetAsyncComplete(asyncResult); + Debug.Assert(_tcs is not null, "Should only be completing the task if we've started one."); + + if (_userToken.IsCancellationRequested) + { + _ = _tcs.TrySetCanceled(_userToken); + } + else if (_cts?.Token.IsCancellationRequested == true) + { + _ = _tcs.TrySetException(new SshOperationTimeoutException($"Command '{CommandText}' timed out. ({nameof(CommandTimeout)}: {CommandTimeout}).")); + } + else if (_cancellationRequested) + { + _ = _tcs.TrySetCanceled(); + } + else + { + _ = _tcs.TrySetResult(null!); + } } - } - private void SetAsyncComplete(CommandAsyncResult asyncResult) - { UnsubscribeFromEventsAndDisposeChannel(); OutputStream.Dispose(); ExtendedOutputStream.Dispose(); - - asyncResult.IsCompleted = true; - - _ = ((EventWaitHandle)asyncResult.AsyncWaitHandle).Set(); - - if (Interlocked.Exchange(ref _callback, value: null) is AsyncCallback callback) - { - ThreadAbstraction.ExecuteThread(() => callback(asyncResult)); - } } private void Channel_Closed(object? sender, ChannelEventArgs e) { - SetAsyncComplete(CompletedResult); + SetAsyncComplete(); } private void Channel_RequestReceived(object? sender, ChannelRequestEventArgs e) @@ -540,14 +583,6 @@ private void Channel_ExtendedDataReceived(object? sender, ChannelExtendedDataEve private void Channel_DataReceived(object? sender, ChannelDataEventArgs e) { OutputStream.Write(e.Data, 0, e.Data.Length); - - if (_asyncResult is CommandAsyncResult asyncResult) - { - lock (asyncResult) - { - asyncResult.BytesReceived += e.Data.Length; - } - } } /// @@ -613,10 +648,15 @@ protected virtual void Dispose(bool disposing) OutputStream.Dispose(); ExtendedOutputStream.Dispose(); - if (_asyncResult is not null && _result is null) + _tokenRegistration.Dispose(); + _tokenRegistration = default; + _cts?.Dispose(); + _cts = null; + + if (_tcs is { Task.IsCompleted: false } tcs) { // In case an operation is still running, try to complete it with an ObjectDisposedException. - SetAsyncComplete(new ObjectDisposedException(GetType().FullName)); + _ = tcs.TrySetException(new ObjectDisposedException(GetType().FullName)); } _isDisposed = true; diff --git a/test/Renci.SshNet.IntegrationTests/.editorconfig b/test/Renci.SshNet.IntegrationTests/.editorconfig index bd35b7268..ecfbce764 100644 --- a/test/Renci.SshNet.IntegrationTests/.editorconfig +++ b/test/Renci.SshNet.IntegrationTests/.editorconfig @@ -362,3 +362,6 @@ dotnet_diagnostic.IDE0047.severity = silent # IDE0032: Use auto-implemented property # https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0032 dotnet_diagnostic.IDE0032.severity = silent + +# CA1849: Call async methods when in an async method +dotnet_diagnostic.CA1849.severity = silent diff --git a/test/Renci.SshNet.IntegrationTests/OldIntegrationTests/SshCommandTest.cs b/test/Renci.SshNet.IntegrationTests/OldIntegrationTests/SshCommandTest.cs index 68013cfa1..fa7e8335f 100644 --- a/test/Renci.SshNet.IntegrationTests/OldIntegrationTests/SshCommandTest.cs +++ b/test/Renci.SshNet.IntegrationTests/OldIntegrationTests/SshCommandTest.cs @@ -63,7 +63,8 @@ public void Test_CancelAsync_Unfinished_Command() cmd.CancelAsync(); - Assert.ThrowsException(() => cmd.EndExecute(asyncResult)); + var tce = Assert.ThrowsException(() => cmd.EndExecute(asyncResult)); + Assert.AreEqual(CancellationToken.None, tce.CancellationToken); Assert.IsTrue(asyncResult.IsCompleted); Assert.IsTrue(asyncResult.AsyncWaitHandle.WaitOne(0)); Assert.AreEqual(string.Empty, cmd.Result); @@ -86,7 +87,8 @@ public async Task Test_CancelAsync_Kill_Unfinished_Command() cmd.CancelAsync(forceKill: true); - await Assert.ThrowsExceptionAsync(() => executeTask); + var tce = await Assert.ThrowsExceptionAsync(() => executeTask); + Assert.AreEqual(CancellationToken.None, tce.CancellationToken); Assert.IsTrue(asyncResult.IsCompleted); Assert.IsTrue(asyncResult.AsyncWaitHandle.WaitOne(0)); Assert.AreEqual(string.Empty, cmd.Result); @@ -116,6 +118,28 @@ public void Test_CancelAsync_Finished_Command() Assert.IsNull(cmd.ExitSignal); } + [TestMethod] + [Timeout(5000)] + public async Task Test_ExecuteAsync_CancellationToken() + { + using var client = new SshClient(SshServerHostName, SshServerPort, User.UserName, User.Password); + client.Connect(); + var testValue = Guid.NewGuid().ToString(); + using var cmd = client.CreateCommand($"sleep 15s; echo {testValue}"); + using CancellationTokenSource cts = new(); + + Task executeTask = cmd.ExecuteAsync(cts.Token); + + cts.Cancel(); + + var tce = await Assert.ThrowsExceptionAsync(() => executeTask); + Assert.AreSame(executeTask, tce.Task); + Assert.AreEqual(cts.Token, tce.CancellationToken); + Assert.AreEqual(string.Empty, cmd.Result); + Assert.AreEqual("TERM", cmd.ExitSignal); + Assert.IsNull(cmd.ExitStatus); + } + [TestMethod] public void Test_Execute_ExtendedOutputStream() { @@ -141,13 +165,29 @@ public void Test_Execute_Timeout() { using (var client = new SshClient(SshServerHostName, SshServerPort, User.UserName, User.Password)) { - #region Example SshCommand CreateCommand Execute CommandTimeout client.Connect(); using var cmd = client.CreateCommand("sleep 10s"); cmd.CommandTimeout = TimeSpan.FromSeconds(2); Assert.ThrowsException(cmd.Execute); client.Disconnect(); - #endregion + } + } + + [TestMethod] + public async Task Test_ExecuteAsync_Timeout() + { + using (var client = new SshClient(SshServerHostName, SshServerPort, User.UserName, User.Password)) + { + client.Connect(); + using var cmd = client.CreateCommand("sleep 10s"); + cmd.CommandTimeout = TimeSpan.FromSeconds(2); + + Task executeTask = cmd.ExecuteAsync(); + + Assert.IsTrue(((IAsyncResult)executeTask).AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(3))); + + await Assert.ThrowsExceptionAsync(() => executeTask); + client.Disconnect(); } } diff --git a/test/Renci.SshNet.IntegrationTests/SshClientTests.cs b/test/Renci.SshNet.IntegrationTests/SshClientTests.cs index 4a0d4df65..2a4a3fe54 100644 --- a/test/Renci.SshNet.IntegrationTests/SshClientTests.cs +++ b/test/Renci.SshNet.IntegrationTests/SshClientTests.cs @@ -91,29 +91,6 @@ public void Send_InputStream_to_Command_OneByteAtATime() } } - [TestMethod] - public void Send_InputStream_to_Command_InputStreamNotInUsingBlock_StillWorks() - { - var inputByteArray = Encoding.UTF8.GetBytes("Hello world!"); - - // Make the server echo back the input file with "cat" - using (var command = _sshClient.CreateCommand("cat")) - { - var asyncResult = command.BeginExecute(); - - var inputStream = command.CreateInputStream(); - for (var i = 0; i < inputByteArray.Length; i++) - { - inputStream.WriteByte(inputByteArray[i]); - } - - command.EndExecute(asyncResult); - - Assert.AreEqual("Hello world!", command.Result); - Assert.AreEqual(string.Empty, command.Error); - } - } - [TestMethod] public void CreateInputStream_BeforeBeginExecute_ThrowsInvalidOperationException() { diff --git a/test/Renci.SshNet.IntegrationTests/SshTests.cs b/test/Renci.SshNet.IntegrationTests/SshTests.cs index f65b8b5ff..8609af0a6 100644 --- a/test/Renci.SshNet.IntegrationTests/SshTests.cs +++ b/test/Renci.SshNet.IntegrationTests/SshTests.cs @@ -230,7 +230,7 @@ public void Ssh_Command_IntermittentOutput_EndExecute() } [TestMethod] - public void Ssh_Command_IntermittentOutput_OutputStream() + public async Task Ssh_Command_IntermittentOutput_OutputStream() { const string remoteFile = "/home/sshnet/test.sh"; @@ -271,9 +271,11 @@ public void Ssh_Command_IntermittentOutput_OutputStream() using (var command = sshClient.CreateCommand(remoteFile)) { - var asyncResult = command.BeginExecute(); + await command.ExecuteAsync(); + + Assert.AreEqual(13, command.ExitStatus); - using (var reader = new StreamReader(command.OutputStream, new UTF8Encoding(false), false, 10)) + using (var reader = new StreamReader(command.OutputStream)) { var lines = new List(); string line = null; @@ -284,21 +286,10 @@ public void Ssh_Command_IntermittentOutput_OutputStream() Assert.AreEqual(6, lines.Count, string.Join("\n", lines)); Assert.AreEqual(expectedResult, string.Join("\n", lines)); - Assert.AreEqual(13, command.ExitStatus); } - var actualResult = command.EndExecute(asyncResult); - - // command.Result (also returned from EndExecute) consumes OutputStream, - // which we've already read from, so Result will be empty. - // TODO consider the suggested changes in https://github.com/sshnet/SSH.NET/issues/650 - - //Assert.AreEqual(expectedResult, actualResult); - //Assert.AreEqual(expectedResult, command.Result); - - // For now just assert the current behaviour. - Assert.AreEqual(0, actualResult.Length); - Assert.AreEqual(0, command.Result.Length); + // We have already consumed OutputStream ourselves, so we expect Result to be empty. + Assert.AreEqual("", command.Result); } } finally diff --git a/test/Renci.SshNet.Tests/Classes/CommandAsyncResultTest.cs b/test/Renci.SshNet.Tests/Classes/CommandAsyncResultTest.cs deleted file mode 100644 index f8066bd03..000000000 --- a/test/Renci.SshNet.Tests/Classes/CommandAsyncResultTest.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; - -using Microsoft.VisualStudio.TestTools.UnitTesting; - -using Renci.SshNet.Tests.Common; - -namespace Renci.SshNet.Tests.Classes -{ - [TestClass()] - public class CommandAsyncResultTest : TestBase - { - [TestMethod()] - public void BytesSentTest() - { - var target = new CommandAsyncResult(); - var expected = new Random().Next(); - - target.BytesSent = expected; - - Assert.AreEqual(expected, target.BytesSent); - } - - [TestMethod()] - public void BytesReceivedTest() - { - var target = new CommandAsyncResult(); - var expected = new Random().Next(); - - target.BytesReceived = expected; - - Assert.AreEqual(expected, target.BytesReceived); - } - } -} From 585b9ceadfd2bf4e51655f1c593a363febf2544d Mon Sep 17 00:00:00 2001 From: Rob Hague Date: Sat, 15 Jun 2024 18:31:09 +0200 Subject: [PATCH 2/2] Update examples --- docfx/examples.md | 7 ++++--- src/Renci.SshNet/SshCommand.cs | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/docfx/examples.md b/docfx/examples.md index 7014b8f21..ee626bb44 100644 --- a/docfx/examples.md +++ b/docfx/examples.md @@ -113,16 +113,17 @@ using (var client = new SshClient("sftp.foo.com", "guest", "pwd")) // Make the server echo back the input file with "cat" using (SshCommand command = client.CreateCommand("cat")) { - IAsyncResult asyncResult = command.BeginExecute(); + Task executeTask = command.ExecuteAsync(CancellationToken.None); using (Stream inputStream = command.CreateInputStream()) { inputStream.Write("Hello World!"u8); } - string result = command.EndExecute(asyncResult); + await executeTask; - Console.WriteLine(result); // "Hello World!" + Console.WriteLine(command.ExitStatus); // 0 + Console.WriteLine(command.Result); // "Hello World!" } } ``` diff --git a/src/Renci.SshNet/SshCommand.cs b/src/Renci.SshNet/SshCommand.cs index e4a4a818d..d496031f2 100644 --- a/src/Renci.SshNet/SshCommand.cs +++ b/src/Renci.SshNet/SshCommand.cs @@ -121,6 +121,25 @@ public int? ExitStatus /// returned instance in order to notify the command that no more data will be sent. /// Failure to do so may result in the command executing indefinitely. /// + /// + /// This example shows how to stream some data to 'cat' and have the server echo it back. + /// + /// using (SshCommand command = mySshClient.CreateCommand("cat")) + /// { + /// Task executeTask = command.ExecuteAsync(CancellationToken.None); + /// + /// using (Stream inputStream = command.CreateInputStream()) + /// { + /// inputStream.Write("Hello World!"u8); + /// } + /// + /// await executeTask; + /// + /// Console.WriteLine(command.ExitStatus); // 0 + /// Console.WriteLine(command.Result); // "Hello World!" + /// } + /// + /// public Stream CreateInputStream() { if (_channel == null)