From a06a310eb76ee936c7495f6df9bb669b911e1b49 Mon Sep 17 00:00:00 2001 From: Kendall Bennett Date: Fri, 25 Mar 2022 14:10:44 -0400 Subject: [PATCH 1/2] Add support for TAP async over APM for SshClient.ExecuteCommand and SshCommand.Execute. --- .../Classes/SshClientTest.cs | 26 +++++ .../Classes/SshCommandTest.cs | 94 ++++++++++++++++++- src/Renci.SshNet/Channels/Channel.cs | 11 +-- src/Renci.SshNet/SshClient.cs | 29 ++++++ src/Renci.SshNet/SshCommand.cs | 85 ++++++++++++++++- 5 files changed, 235 insertions(+), 10 deletions(-) diff --git a/src/Renci.SshNet.Tests/Classes/SshClientTest.cs b/src/Renci.SshNet.Tests/Classes/SshClientTest.cs index 78f1db6c4..ff19ae07a 100644 --- a/src/Renci.SshNet.Tests/Classes/SshClientTest.cs +++ b/src/Renci.SshNet.Tests/Classes/SshClientTest.cs @@ -7,6 +7,10 @@ using System.IO; using System.Text; using System.Linq; +#if FEATURE_TAP +using System.Threading; +using System.Threading.Tasks; +#endif namespace Renci.SshNet.Tests.Classes { @@ -35,6 +39,28 @@ public void Test_Connect_Using_Correct_Password() #endregion } +#if FEATURE_TAP + [TestMethod] + [TestCategory("Authentication")] + [TestCategory("integration")] + public async Task Test_ConnectAsync_Using_Correct_Password() + { + var host = Resources.HOST; + var username = Resources.USERNAME; + var password = Resources.PASSWORD; + + #region Example SshClient(host, username) Connect + using (var client = new SshClient(host, username, password)) + { + var cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(60)).Token; + await client.ConnectAsync(cancellationToken); + // Do something here + client.Disconnect(); + } + #endregion + } +#endif + [TestMethod] [TestCategory("Authentication")] [TestCategory("integration")] diff --git a/src/Renci.SshNet.Tests/Classes/SshCommandTest.cs b/src/Renci.SshNet.Tests/Classes/SshCommandTest.cs index cfd68b078..491635903 100644 --- a/src/Renci.SshNet.Tests/Classes/SshCommandTest.cs +++ b/src/Renci.SshNet.Tests/Classes/SshCommandTest.cs @@ -56,6 +56,71 @@ public void Test_Run_SingleCommand() } } +#if FEATURE_TAP + [TestMethod] + [TestCategory("integration")] + public async Task Test_Run_SingleCommandAsync() + { + var host = Resources.HOST; + var username = Resources.USERNAME; + var password = Resources.PASSWORD; + + using (var client = new SshClient(host, username, password)) + { + #region Example SshCommand RunCommand Result Async + var cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(60)).Token; + await client.ConnectAsync(cancellationToken); + + var testValue = Guid.NewGuid().ToString(); + var command = await client.RunCommandAsync(string.Format("echo {0}", testValue), cancellationToken); + var result = await new StreamReader(command.OutputStream).ReadToEndAsync(); + result = result.Substring(0, result.Length - 1); // Remove \n character returned by command + + client.Disconnect(); + #endregion + + Assert.IsTrue(result.Equals(testValue)); + } + } + + [TestMethod] + [TestCategory("integration")] + public async Task Test_Run_SingleCommandAsync_WithShortTimeout() + { + var host = Resources.HOST; + var username = Resources.USERNAME; + var password = Resources.PASSWORD; + + using (var client = new SshClient(host, username, password)) + { + #region Example SshCommand RunCommand Result Async With Short Timeout + var cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(60)).Token; + await client.ConnectAsync(cancellationToken); + + // Timeout in 100ms + var shortTimeout = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)).Token; + + var testValue = Guid.NewGuid().ToString(); + string result; + try + { + var command = await client.RunCommandAsync(string.Format("echo {0};/bin/sleep 5", testValue), shortTimeout); + result = await new StreamReader(command.OutputStream).ReadToEndAsync(); + result = result.Substring(0, result.Length - 1); // Remove \n character returned by command + } + catch (OperationCanceledException) + { + result = "canceled"; + } + + client.Disconnect(); + #endregion + + Assert.IsTrue(result.Equals("canceled")); + } + } +#endif + [TestMethod] [TestCategory("integration")] public void Test_Execute_SingleCommand() @@ -266,7 +331,7 @@ public void Test_Execute_Command_ExitStatus() client.Connect(); var cmd = client.RunCommand("exit 128"); - + Console.WriteLine(cmd.ExitStatus); client.Disconnect(); @@ -500,6 +565,33 @@ public void Test_Execute_Invalid_Command() } } +#if FEATURE_TAP + [TestMethod] + [TestCategory("integration")] + public async Task Test_Execute_Invalid_CommandAsync() + { + using (var client = new SshClient(Resources.HOST, Resources.USERNAME, Resources.PASSWORD)) + { + #region Example SshCommand CreateCommand Error Async + + await client.ConnectAsync(default); + + var cmd = client.CreateCommand(";"); + await cmd.ExecuteAsync(default); + var error = await new StreamReader(cmd.ExtendedOutputStream).ReadToEndAsync(); + if (!string.IsNullOrEmpty(error)) + { + Console.WriteLine(error); + } + + client.Disconnect(); + + #endregion + + Assert.Inconclusive(); + } + } +#endif /// ///A test for BeginExecute diff --git a/src/Renci.SshNet/Channels/Channel.cs b/src/Renci.SshNet/Channels/Channel.cs index a6311a981..71f8c467d 100644 --- a/src/Renci.SshNet/Channels/Channel.cs +++ b/src/Renci.SshNet/Channels/Channel.cs @@ -562,14 +562,11 @@ protected virtual void Close() // this also ensures don't raise the Closed event more than once IsOpen = false; - if (_closeMessageReceived) + // raise event signaling that both ends of the channel have been closed + var closed = Closed; + if (closed != null) { - // raise event signaling that both ends of the channel have been closed - var closed = Closed; - if (closed != null) - { - closed(this, new ChannelEventArgs(LocalChannelNumber)); - } + closed(this, new ChannelEventArgs(LocalChannelNumber)); } } } diff --git a/src/Renci.SshNet/SshClient.cs b/src/Renci.SshNet/SshClient.cs index 881a173d4..cd40defc1 100644 --- a/src/Renci.SshNet/SshClient.cs +++ b/src/Renci.SshNet/SshClient.cs @@ -4,6 +4,10 @@ using System.Text; using System.Diagnostics.CodeAnalysis; using System.Net; +#if FEATURE_TAP +using System.Threading; +using System.Threading.Tasks; +#endif using Renci.SshNet.Common; namespace Renci.SshNet @@ -275,6 +279,31 @@ public SshCommand RunCommand(string commandText) return cmd; } +#if FEATURE_TAP + /// + /// Creates and executes the command. + /// + /// The command text. + /// The to observe. + /// Returns an instance of with execution results. + /// This method internally uses asynchronous calls. + /// + /// + /// + /// + /// CommandText property is empty. + /// Invalid Operation - An existing channel was used to execute this command. + /// Asynchronous operation is already in progress. + /// Client is not connected. + /// is null. + public async Task RunCommandAsync(string commandText, CancellationToken cancellationToken) + { + var cmd = CreateCommand(commandText); + await cmd.ExecuteAsync(cancellationToken).ConfigureAwait(false); + return cmd; + } +#endif + /// /// Creates the shell. /// diff --git a/src/Renci.SshNet/SshCommand.cs b/src/Renci.SshNet/SshCommand.cs index 37e91da08..d9e179508 100644 --- a/src/Renci.SshNet/SshCommand.cs +++ b/src/Renci.SshNet/SshCommand.cs @@ -8,6 +8,9 @@ using Renci.SshNet.Messages.Transport; using System.Globalization; using Renci.SshNet.Abstractions; +#if FEATURE_TAP +using System.Threading.Tasks; +#endif namespace Renci.SshNet { @@ -73,6 +76,7 @@ public class SshCommand : IDisposable /// /// /// + [Obsolete("Please read the result from the OutputStream. I.e. new StreamReader(shell.OutputStream).ReadToEnd().")] public string Result { get @@ -100,6 +104,7 @@ public string Result /// /// /// + [Obsolete("Please read the error result from the ExtendedOutputStream. I.e. new StreamReader(shell.ExtendedOutputStream).ReadToEnd().")] public string Error { get @@ -315,7 +320,9 @@ public string EndExecute(IAsyncResult asyncResult) commandAsyncResult.EndCalled = true; +#pragma warning disable CS0618 return Result; +#pragma warning restore CS0618 } } @@ -335,6 +342,80 @@ public string Execute() return EndExecute(BeginExecute(null, null)); } + /// + /// Waits for the pending asynchronous command execution to complete. + /// + /// The reference to the pending asynchronous request to finish. + /// Command execution exit status. + /// + /// + /// + /// 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 null. + public int EndExecuteWithStatus(IAsyncResult asyncResult) + { + if (asyncResult == null) + { + throw new ArgumentNullException("asyncResult"); + } + var commandAsyncResult = asyncResult as CommandAsyncResult; + if (commandAsyncResult == null || _asyncResult != commandAsyncResult) + { + throw new ArgumentException(string.Format("The {0} object was not returned from the corresponding asynchronous method on this class.", typeof(IAsyncResult).Name)); + } + lock (_endExecuteLock) + { + if (commandAsyncResult.EndCalled) + { + throw new ArgumentException("EndExecute can only be called once for each asynchronous operation."); + } + // wait for operation to complete (or time out) + WaitOnHandle(_asyncResult.AsyncWaitHandle); + UnsubscribeFromEventsAndDisposeChannel(_channel); + _channel = null; + + commandAsyncResult.EndCalled = true; + + return ExitStatus; + } + } + +#if FEATURE_TAP + /// + /// Executes the the command asynchronously. + /// + /// The to observe. + /// Exit status of the operation + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + bool wasCancelled = false; + var asyncResult = BeginExecute(); + var ctr = cancellationToken.Register(() => + { + _channel.SendExitSignalRequest("TERM", false, "Command execution has been cancelled.", "en"); + wasCancelled = true; + _channel.Dispose(); + }, false); + try + { + int status = await Task.Factory.FromAsync(asyncResult, EndExecuteWithStatus).ConfigureAwait(false); + if (wasCancelled) + { + cancellationToken.ThrowIfCancellationRequested(); + } + return status; + } + catch (Exception) when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException("Command execution has been cancelled.", cancellationToken); + } + finally + { + ctr.Dispose(); + } + } +#endif + /// /// Cancels command execution in asynchronous scenarios. /// @@ -514,7 +595,7 @@ private void UnsubscribeFromEventsAndDisposeChannel(IChannel channel) channel.Dispose(); } - #region IDisposable Members +#region IDisposable Members private bool _isDisposed; @@ -591,6 +672,6 @@ protected virtual void Dispose(bool disposing) Dispose(false); } - #endregion +#endregion } } From e98748d0a9fed43ce8acdeb0b368fd79e3945877 Mon Sep 17 00:00:00 2001 From: Kendall Bennett Date: Sun, 27 Mar 2022 16:04:08 -0400 Subject: [PATCH 2/2] Code for debugging and compiling with Rider --- .../.idea.Renci.SshNet.VS2019/.idea/.gitignore | 13 +++++++++++++ src/.idea/.idea.Renci.SshNet.VS2019/.idea/.name | 1 + .../.idea.Renci.SshNet.VS2019/.idea/deployment.xml | 14 ++++++++++++++ .../.idea/indexLayout.xml | 8 ++++++++ src/.idea/.idea.Renci.SshNet.VS2019/.idea/misc.xml | 6 ++++++ src/.idea/.idea.Renci.SshNet.VS2019/.idea/vcs.xml | 6 ++++++ src/Renci.SshNet.Tests/Properties/Resources.resx | 6 +++--- src/Renci.SshNet.Tests/Renci.SshNet.Tests.csproj | 4 +--- src/Renci.SshNet.VS2019.sln.DotSettings | 3 +++ src/Renci.SshNet/Renci.SshNet.csproj | 2 +- 10 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 src/.idea/.idea.Renci.SshNet.VS2019/.idea/.gitignore create mode 100644 src/.idea/.idea.Renci.SshNet.VS2019/.idea/.name create mode 100644 src/.idea/.idea.Renci.SshNet.VS2019/.idea/deployment.xml create mode 100644 src/.idea/.idea.Renci.SshNet.VS2019/.idea/indexLayout.xml create mode 100644 src/.idea/.idea.Renci.SshNet.VS2019/.idea/misc.xml create mode 100644 src/.idea/.idea.Renci.SshNet.VS2019/.idea/vcs.xml create mode 100644 src/Renci.SshNet.VS2019.sln.DotSettings diff --git a/src/.idea/.idea.Renci.SshNet.VS2019/.idea/.gitignore b/src/.idea/.idea.Renci.SshNet.VS2019/.idea/.gitignore new file mode 100644 index 000000000..ae739d855 --- /dev/null +++ b/src/.idea/.idea.Renci.SshNet.VS2019/.idea/.gitignore @@ -0,0 +1,13 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Rider ignored files +/modules.xml +/.idea.Renci.SshNet.VS2019.iml +/contentModel.xml +/projectSettingsUpdater.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/src/.idea/.idea.Renci.SshNet.VS2019/.idea/.name b/src/.idea/.idea.Renci.SshNet.VS2019/.idea/.name new file mode 100644 index 000000000..ff8bcf1ce --- /dev/null +++ b/src/.idea/.idea.Renci.SshNet.VS2019/.idea/.name @@ -0,0 +1 @@ +Renci.SshNet.VS2019 \ No newline at end of file diff --git a/src/.idea/.idea.Renci.SshNet.VS2019/.idea/deployment.xml b/src/.idea/.idea.Renci.SshNet.VS2019/.idea/deployment.xml new file mode 100644 index 000000000..43414e4dc --- /dev/null +++ b/src/.idea/.idea.Renci.SshNet.VS2019/.idea/deployment.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/.idea/.idea.Renci.SshNet.VS2019/.idea/indexLayout.xml b/src/.idea/.idea.Renci.SshNet.VS2019/.idea/indexLayout.xml new file mode 100644 index 000000000..7b08163ce --- /dev/null +++ b/src/.idea/.idea.Renci.SshNet.VS2019/.idea/indexLayout.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/.idea/.idea.Renci.SshNet.VS2019/.idea/misc.xml b/src/.idea/.idea.Renci.SshNet.VS2019/.idea/misc.xml new file mode 100644 index 000000000..1d8c84d0a --- /dev/null +++ b/src/.idea/.idea.Renci.SshNet.VS2019/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/src/.idea/.idea.Renci.SshNet.VS2019/.idea/vcs.xml b/src/.idea/.idea.Renci.SshNet.VS2019/.idea/vcs.xml new file mode 100644 index 000000000..6c0b86358 --- /dev/null +++ b/src/.idea/.idea.Renci.SshNet.VS2019/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/Renci.SshNet.Tests/Properties/Resources.resx b/src/Renci.SshNet.Tests/Properties/Resources.resx index c392eb5c2..302e4bcb7 100644 --- a/src/Renci.SshNet.Tests/Properties/Resources.resx +++ b/src/Renci.SshNet.Tests/Properties/Resources.resx @@ -149,7 +149,7 @@ D8DHbFwAT2mUv1QxRXYJO1y4pENboEzT6LUqxJgE+ae/F/29g2RD9DhtwqKqWjhM -----END DSA PRIVATE KEY----- - 192.168.10.192 + 127.0.0.1 -----BEGIN DSA PRIVATE KEY----- @@ -166,7 +166,7 @@ tM7dZpB+reWl9L5e2L8= -----END DSA PRIVATE KEY----- - tester + jasmine1015 22 @@ -239,6 +239,6 @@ Vakr7Sa3K5niCyH5kxdyO1t29l1ksBqpDUrj+vViFuLkd3XIiui8IA== -----END RSA PRIVATE KEY----- - tester + kendallb \ No newline at end of file diff --git a/src/Renci.SshNet.Tests/Renci.SshNet.Tests.csproj b/src/Renci.SshNet.Tests/Renci.SshNet.Tests.csproj index 3b38bfbe8..e13be3ca0 100644 --- a/src/Renci.SshNet.Tests/Renci.SshNet.Tests.csproj +++ b/src/Renci.SshNet.Tests/Renci.SshNet.Tests.csproj @@ -3,14 +3,12 @@ 7.3 true ..\Renci.SshNet.snk + net472 net35;net472;netcoreapp2.1 - - net35;net472;netcoreapp3.1;net5.0 - net472;netcoreapp3.1;net5.0;net6.0 diff --git a/src/Renci.SshNet.VS2019.sln.DotSettings b/src/Renci.SshNet.VS2019.sln.DotSettings new file mode 100644 index 000000000..66917f853 --- /dev/null +++ b/src/Renci.SshNet.VS2019.sln.DotSettings @@ -0,0 +1,3 @@ + + C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\MSBuild.exe + 1048576 \ No newline at end of file diff --git a/src/Renci.SshNet/Renci.SshNet.csproj b/src/Renci.SshNet/Renci.SshNet.csproj index 740ec7ee1..9a93752b5 100644 --- a/src/Renci.SshNet/Renci.SshNet.csproj +++ b/src/Renci.SshNet/Renci.SshNet.csproj @@ -7,7 +7,7 @@ ../Renci.SshNet.snk 6 true - net35;net40;net472;netstandard1.3;netstandard2.0 + net472