Преглед изворни кода

Add SshCommand.ExecuteAsync (#1426)

* 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.

* Update examples

---------

Co-authored-by: Wojciech Nagórski <wojtpl2@gmail.com>
Rob Hague пре 1 година
родитељ
комит
3bc568471e

+ 4 - 3
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!"
     }
 }
 ```

+ 8 - 0
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

+ 0 - 60
src/Renci.SshNet/CommandAsyncResult.cs

@@ -1,60 +0,0 @@
-using System;
-using System.Threading;
-
-namespace Renci.SshNet
-{
-    /// <summary>
-    /// Provides additional information for asynchronous command execution.
-    /// </summary>
-    public class CommandAsyncResult : IAsyncResult
-    {
-        /// <summary>
-        /// Initializes a new instance of the <see cref="CommandAsyncResult"/> class.
-        /// </summary>
-        internal CommandAsyncResult()
-        {
-        }
-
-        /// <summary>
-        /// Gets or sets the bytes received. If SFTP only file bytes are counted.
-        /// </summary>
-        /// <value>Total bytes received.</value>
-        public int BytesReceived { get; set; }
-
-        /// <summary>
-        /// Gets or sets the bytes sent by SFTP.
-        /// </summary>
-        /// <value>Total bytes sent.</value>
-        public int BytesSent { get; set; }
-
-        /// <summary>
-        /// Gets a user-defined object that qualifies or contains information about an asynchronous operation.
-        /// </summary>
-        /// <returns>A user-defined object that qualifies or contains information about an asynchronous operation.</returns>
-        public object AsyncState { get; internal set; }
-
-        /// <summary>
-        /// Gets a <see cref="WaitHandle"/> that is used to wait for an asynchronous operation to complete.
-        /// </summary>
-        /// <returns>
-        /// A <see cref="WaitHandle"/> that is used to wait for an asynchronous operation to complete.
-        /// </returns>
-        public WaitHandle AsyncWaitHandle { get; internal set; }
-
-        /// <summary>
-        /// Gets a value indicating whether the asynchronous operation completed synchronously.
-        /// </summary>
-        /// <returns>
-        /// true if the asynchronous operation completed synchronously; otherwise, false.
-        /// </returns>
-        public bool CompletedSynchronously { get; internal set; }
-
-        /// <summary>
-        /// Gets a value indicating whether the asynchronous operation has completed.
-        /// </summary>
-        /// <returns>
-        /// true if the operation is complete; otherwise, false.
-        /// </returns>
-        public bool IsCompleted { get; internal set; }
-    }
-}

+ 193 - 134
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
     /// </summary>
     public class SshCommand : IDisposable
     {
-        private static readonly object CompletedResult = new();
-
         private readonly ISession _session;
         private readonly Encoding _encoding;
 
-        /// <summary>
-        /// The result of the command: an exception, <see cref="CompletedResult"/>
-        /// or <see langword="null"/>.
-        /// </summary>
-        private object? _result;
-
         private IChannelSession? _channel;
-        private CommandAsyncResult? _asyncResult;
-        private AsyncCallback? _callback;
+        private TaskCompletionSource<object>? _tcs;
+        private CancellationTokenSource? _cts;
+        private CancellationTokenRegistration _tokenRegistration;
         private string? _stdOut;
         private string? _stdErr;
         private bool _hasError;
@@ -40,6 +32,17 @@ namespace Renci.SshNet
         private ChannelInputStream? _inputStream;
         private TimeSpan _commandTimeout;
 
+        /// <summary>
+        /// The token supplied as an argument to <see cref="ExecuteAsync(CancellationToken)"/>.
+        /// </summary>
+        private CancellationToken _userToken;
+
+        /// <summary>
+        /// Whether <see cref="CancelAsync(bool, int)"/> has been called
+        /// (either by a token or manually).
+        /// </summary>
+        private bool _cancellationRequested;
+
         private int _exitStatus;
         private volatile bool _haveExitStatus; // volatile to prevent re-ordering of reads/writes of _exitStatus.
 
@@ -113,6 +116,30 @@ namespace Renci.SshNet
         /// <returns>
         /// The stream that can be used to transfer data to the command's input stream.
         /// </returns>
+        /// <remarks>
+        /// Callers should ensure that <see cref="Stream.Dispose()"/> 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.
+        /// </remarks>
+        /// <example>
+        /// This example shows how to stream some data to 'cat' and have the server echo it back.
+        /// <code>
+        /// 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!"
+        /// }
+        /// </code>
+        /// </example>
         public Stream CreateInputStream()
         {
             if (_channel == null)
@@ -130,7 +157,7 @@ namespace Renci.SshNet
         }
 
         /// <summary>
-        /// Gets the command execution result.
+        /// Gets the standard output of the command by reading <see cref="OutputStream"/>.
         /// </summary>
         public string Result
         {
@@ -141,7 +168,7 @@ namespace Renci.SshNet
                     return _stdOut;
                 }
 
-                if (_asyncResult is null)
+                if (_tcs is null)
                 {
                     return string.Empty;
                 }
@@ -154,7 +181,8 @@ namespace Renci.SshNet
         }
 
         /// <summary>
-        /// Gets the command execution error.
+        /// Gets the standard error of the command by reading <see cref="ExtendedOutputStream"/>,
+        /// when extended data has been sent which has been marked as stderr.
         /// </summary>
         public string Error
         {
@@ -165,7 +193,7 @@ namespace Renci.SshNet
                     return _stdErr;
                 }
 
-                if (_asyncResult is null || !_hasError)
+                if (_tcs is null || !_hasError)
                 {
                     return string.Empty;
                 }
@@ -211,6 +239,92 @@ namespace Renci.SshNet
             _session.ErrorOccured += Session_ErrorOccured;
         }
 
+        /// <summary>
+        /// Executes the command asynchronously.
+        /// </summary>
+        /// <param name="cancellationToken">
+        /// The <see cref="CancellationToken"/>. When triggered, attempts to terminate the
+        /// remote command by sending a signal.
+        /// </param>
+        /// <returns>A <see cref="Task"/> representing the lifetime of the command.</returns>
+        /// <exception cref="InvalidOperationException">Command is already executing. Thrown synchronously.</exception>
+        /// <exception cref="ObjectDisposedException">Instance has been disposed. Thrown synchronously.</exception>
+        /// <exception cref="OperationCanceledException">The <see cref="Task"/> has been cancelled.</exception>
+        /// <exception cref="SshOperationTimeoutException">The command timed out according to <see cref="CommandTimeout"/>.</exception>
+#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<object>(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
+
         /// <summary>
         /// Begins an asynchronous command execution.
         /// </summary>
@@ -259,58 +373,7 @@ namespace Renci.SshNet
         /// <exception cref="SshOperationTimeoutException">Operation has timed out.</exception>
         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);
         }
 
         /// <summary>
@@ -340,38 +403,19 @@ namespace Renci.SshNet
         /// Waits for the pending asynchronous command execution to complete.
         /// </summary>
         /// <param name="asyncResult">The reference to the pending asynchronous request to finish.</param>
-        /// <returns>Command execution result.</returns>
+        /// <returns><see cref="Result"/>.</returns>
         /// <exception cref="ArgumentException">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.</exception>
         /// <exception cref="ArgumentNullException"><paramref name="asyncResult"/> is <see langword="null"/>.</exception>
         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 +445,59 @@ namespace Renci.SshNet
         /// <exception cref="InvalidOperationException">Command has not been started.</exception>
         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();
         }
 
         /// <summary>
         /// Executes command specified by <see cref="CommandText"/> property.
         /// </summary>
-        /// <returns>
-        /// Command execution result.
-        /// </returns>
+        /// <returns><see cref="Result"/>.</returns>
         /// <exception cref="SshConnectionException">Client is not connected.</exception>
         /// <exception cref="SshOperationTimeoutException">Operation has timed out.</exception>
         public string Execute()
         {
-            return EndExecute(BeginExecute(callback: null, state: null));
+            ExecuteAsync().GetAwaiter().GetResult();
+
+            return Result;
         }
 
         /// <summary>
@@ -465,44 +518,53 @@ namespace Renci.SshNet
 
         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 +602,6 @@ namespace Renci.SshNet
         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;
-                }
-            }
         }
 
         /// <summary>
@@ -613,10 +667,15 @@ namespace Renci.SshNet
                 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;

+ 3 - 0
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

+ 44 - 4
test/Renci.SshNet.IntegrationTests/OldIntegrationTests/SshCommandTest.cs

@@ -63,7 +63,8 @@ namespace Renci.SshNet.IntegrationTests.OldIntegrationTests
 
             cmd.CancelAsync();
 
-            Assert.ThrowsException<OperationCanceledException>(() => cmd.EndExecute(asyncResult));
+            var tce = Assert.ThrowsException<TaskCanceledException>(() => 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 @@ namespace Renci.SshNet.IntegrationTests.OldIntegrationTests
 
             cmd.CancelAsync(forceKill: true);
 
-            await Assert.ThrowsExceptionAsync<OperationCanceledException>(() => executeTask);
+            var tce = await Assert.ThrowsExceptionAsync<TaskCanceledException>(() => 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 @@ namespace Renci.SshNet.IntegrationTests.OldIntegrationTests
             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<TaskCanceledException>(() => 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 @@ namespace Renci.SshNet.IntegrationTests.OldIntegrationTests
         {
             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<SshOperationTimeoutException>(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<SshOperationTimeoutException>(() => executeTask);
+                client.Disconnect();
             }
         }
 

+ 0 - 23
test/Renci.SshNet.IntegrationTests/SshClientTests.cs

@@ -91,29 +91,6 @@ namespace Renci.SshNet.IntegrationTests
             }
         }
 
-        [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()
         {

+ 7 - 16
test/Renci.SshNet.IntegrationTests/SshTests.cs

@@ -284,7 +284,7 @@ namespace Renci.SshNet.IntegrationTests
         }
 
         [TestMethod]
-        public void Ssh_Command_IntermittentOutput_OutputStream()
+        public async Task Ssh_Command_IntermittentOutput_OutputStream()
         {
             const string remoteFile = "/home/sshnet/test.sh";
 
@@ -325,9 +325,11 @@ namespace Renci.SshNet.IntegrationTests
 
                     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>();
                             string line = null;
@@ -338,21 +340,10 @@ namespace Renci.SshNet.IntegrationTests
 
                             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

+ 0 - 34
test/Renci.SshNet.Tests/Classes/CommandAsyncResultTest.cs

@@ -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);
-        }
-    }
-}