diff --git a/src/Renci.SshNet/SshCommand.cs b/src/Renci.SshNet/SshCommand.cs index ce1042244..eb06d19b7 100644 --- a/src/Renci.SshNet/SshCommand.cs +++ b/src/Renci.SshNet/SshCommand.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; +using Renci.SshNet.Abstractions; using Renci.SshNet.Channels; using Renci.SshNet.Common; using Renci.SshNet.Messages.Connection; @@ -21,7 +22,37 @@ public sealed class SshCommand : IDisposable private readonly ISession _session; private readonly Encoding _encoding; - private IChannelSession _channel; + /// + /// Guards , , + /// and . + /// + private readonly Lock _channelLock = new Lock(); + + /// + /// The channel of the current execution, or before the first execution + /// and after the channel of a completed execution has been released. + /// + private IChannelSession? _channel; + + /// + /// The number of threads currently blocked in a request on + /// ( or + /// ). The channel is not disposed + /// while this is non-zero, because disposing it would pull the wait handle out from under + /// the waiting thread (see #1596). + /// + private int _channelUseCount; + + /// + /// Whether has been closed at both ends. + /// + private bool _channelClosed; + + /// + /// Whether the execution that belongs to has completed. + /// + private bool _executionCompleted; + private TaskCompletionSource? _tcs; private CancellationTokenSource? _cts; private CancellationTokenRegistration _tokenRegistration; @@ -142,7 +173,9 @@ public int? ExitStatus /// public Stream CreateInputStream() { - if (!_channel.IsOpen) + var channel = _channel; + + if (channel is not { IsOpen: true }) { throw new InvalidOperationException("The input stream can be used only during execution."); } @@ -152,7 +185,7 @@ public Stream CreateInputStream() throw new InvalidOperationException("The input stream already exists."); } - _inputStream = new ChannelInputStream(_channel); + _inputStream = new ChannelInputStream(channel); return _inputStream; } @@ -224,9 +257,10 @@ internal SshCommand(ISession session, string commandText, Encoding encoding) CommandTimeout = Timeout.InfiniteTimeSpan; OutputStream = new PipeStream(); ExtendedOutputStream = new PipeStream(); - _session.Disconnected += Session_Disconnected; - _session.ErrorOccured += Session_ErrorOccurred; - _channel = _session.CreateChannelSession(); + + // Note: we deliberately do not subscribe to session events or create the channel here. + // Both are done in ExecuteAsync and undone when the execution completes, so that an + // SshCommand which is not disposed is not kept alive by a long-lived session (#1204). } /// @@ -258,7 +292,9 @@ public Task ExecuteAsync(CancellationToken cancellationToken = default) throw new InvalidOperationException("Asynchronous operation is already in progress."); } - UnsubscribeFromChannelEvents(dispose: true); + // The channel of the previous execution has been released when that execution + // completed; this is just a safety net. + ReleaseChannel(force: true); OutputStream.Dispose(); ExtendedOutputStream.Dispose(); @@ -268,7 +304,6 @@ public Task ExecuteAsync(CancellationToken cancellationToken = default) // so we just need to reinitialise them for subsequent executions. OutputStream = new PipeStream(); ExtendedOutputStream = new PipeStream(); - _channel = _session.CreateChannelSession(); } _exitStatus = default; @@ -283,16 +318,55 @@ public Task ExecuteAsync(CancellationToken cancellationToken = default) _cts = null; _cancellationRequested = false; + var channel = _session.CreateChannelSession(); + _tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _userToken = cancellationToken; - _channel.DataReceived += Channel_DataReceived; - _channel.ExtendedDataReceived += Channel_ExtendedDataReceived; - _channel.RequestReceived += Channel_RequestReceived; - _channel.Closed += Channel_Closed; - _channel.Open(); + lock (_channelLock) + { + _channel = channel; + _channelClosed = false; + _executionCompleted = false; + + // This thread is about to block in SendExecRequest; don't let the channel be + // disposed underneath it if SSH_MSG_CHANNEL_CLOSE is processed first (#1596). + _channelUseCount = 1; + } + + channel.DataReceived += Channel_DataReceived; + channel.ExtendedDataReceived += Channel_ExtendedDataReceived; + channel.RequestReceived += Channel_RequestReceived; + channel.Closed += Channel_Closed; + + // Subscribe after the channel has been created so that the channel's own + // session event handlers run before ours. + SubscribeToSessionEvents(); + + try + { + channel.Open(); + + _ = channel.SendExecRequest(CommandText); + } + catch (Exception ex) + { + // The command could not be started. Don't leave the channel and the session + // subscriptions behind, and don't leave this instance stuck "in progress". + _ = _tcs.TrySetException(ex); + _ = _tcs.Task.Exception; // Observed: the exception is rethrown to the caller below. + + UnsubscribeFromSessionEvents(); + EndChannelUse(channel); + ReleaseChannel(force: true); - _ = _channel.SendExecRequest(CommandText); + OutputStream.Dispose(); + ExtendedOutputStream.Dispose(); + + throw; + } + + EndChannelUse(channel); if (CommandTimeout != Timeout.InfiniteTimeSpan) { @@ -453,13 +527,24 @@ public void CancelAsync(bool forceKill = false, int millisecondsTimeout = 500) try { - // Try to send the cancellation signal. - if (_channel?.SendSignalRequest(forceKill ? "KILL" : "TERM") is null) + var channel = BeginChannelUse(); + + if (channel is null) { // Command has completed (in the meantime since the last check). return; } + try + { + // Try to send the cancellation signal. + _ = channel.SendSignalRequest(forceKill ? "KILL" : "TERM"); + } + finally + { + EndChannelUse(channel); + } + // 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 @@ -507,55 +592,71 @@ public string Execute(string commandText) private void Session_Disconnected(object? sender, EventArgs e) { - _ = _tcs?.TrySetException(new SshConnectionException("An established connection was aborted by the software in your host machine.", DisconnectReason.ConnectionLost)); - - SetAsyncComplete(setResult: false); + SetAsyncComplete(new SshConnectionException("An established connection was aborted by the software in your host machine.", DisconnectReason.ConnectionLost)); } private void Session_ErrorOccurred(object? sender, ExceptionEventArgs e) { - _ = _tcs?.TrySetException(e.Exception); - - SetAsyncComplete(setResult: false); + SetAsyncComplete(e.Exception); } - private void SetAsyncComplete(bool setResult = true) + private void SetAsyncComplete(Exception? exception = null) { - Interlocked.MemoryBarrier(); // ensure fresh read of _cancellationRequested (possibly unnecessary) + // Clean up before completing the task: once the task has completed, the instance may be + // executed again (e.g. on a continuation), and this cleanup must not then affect the + // session subscriptions or output streams of that new execution. - if (setResult) - { - Debug.Assert(_tcs is not null, "Should only be completing the task if we've started one."); + // The execution is over: stop listening to the session so that the session (which + // typically lives much longer than a command) no longer references this instance. + UnsubscribeFromSessionEvents(); - 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!); - } + // Release the channel unless a thread is blocked in a request on it, in which case that + // thread releases it when the request returns (see EndChannelUse). + lock (_channelLock) + { + _executionCompleted = true; } - // We don't dispose the channel here to avoid a race condition - // where SSH_MSG_CHANNEL_CLOSE arrives before _channel starts - // waiting for a response in _channel.SendExecRequest(). - UnsubscribeFromChannelEvents(dispose: false); + ReleaseChannel(force: false); OutputStream.Dispose(); ExtendedOutputStream.Dispose(); + + if (exception is not null) + { + _ = _tcs?.TrySetException(exception); + return; + } + + Interlocked.MemoryBarrier(); // ensure fresh read of _cancellationRequested (possibly unnecessary) + + 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 Channel_Closed(object? sender, ChannelEventArgs e) { + lock (_channelLock) + { + _channelClosed = true; + } + SetAsyncComplete(); } @@ -596,13 +697,96 @@ private void Channel_DataReceived(object? sender, ChannelDataEventArgs e) OutputStream.Write(e.Data.Array!, e.Data.Offset, e.Data.Count); } + private void SubscribeToSessionEvents() + { + // Guard against a double subscription should a previous execution not have unsubscribed. + UnsubscribeFromSessionEvents(); + + _session.Disconnected += Session_Disconnected; + _session.ErrorOccured += Session_ErrorOccurred; + } + + private void UnsubscribeFromSessionEvents() + { + _session.Disconnected -= Session_Disconnected; + _session.ErrorOccured -= Session_ErrorOccurred; + } + /// - /// Unsubscribes the current from channel events, and optionally, - /// disposes . + /// Marks the start of a blocking request on the current channel. /// - private void UnsubscribeFromChannelEvents(bool dispose) + /// + /// The channel, or if there is no channel or it has already been closed. + /// Each non-null return must be paired with a call to . + /// + private IChannelSession? BeginChannelUse() { - var channel = _channel; + lock (_channelLock) + { + if (_channel is null || _channelClosed) + { + return null; + } + + _channelUseCount++; + return _channel; + } + } + + /// + /// Marks the end of a blocking request on , and releases the channel + /// if the execution has completed in the meantime. + /// + /// The channel returned by . + private void EndChannelUse(IChannelSession channel) + { + lock (_channelLock) + { + if (!ReferenceEquals(channel, _channel)) + { + // That channel has already been released (forcibly, by Dispose or a subsequent + // execution); the count now belongs to another channel (or none). + return; + } + + Debug.Assert(_channelUseCount > 0, "EndChannelUse without matching use."); + + _channelUseCount--; + } + + ReleaseChannel(force: false); + } + + /// + /// Unsubscribes from the events of , disposes it and drops the reference to it. + /// + /// + /// to only release the channel when its execution has completed and no + /// thread is blocked in a request on it; to release it unconditionally + /// (and synchronously). + /// + private void ReleaseChannel(bool force) + { + IChannelSession? channel; + bool channelClosed; + + lock (_channelLock) + { + channel = _channel; + + if (channel is null) + { + return; + } + + if (!force && (!_executionCompleted || _channelUseCount > 0)) + { + return; + } + + channelClosed = _channelClosed; + _channel = null; + } // unsubscribe from events as we do not want to be signaled should these get fired // during the dispose of the channel @@ -611,10 +795,29 @@ private void UnsubscribeFromChannelEvents(bool dispose) channel.RequestReceived -= Channel_RequestReceived; channel.Closed -= Channel_Closed; - if (dispose) + // Disposing the channel unsubscribes it from the session's events and releases its + // wait handles. If the channel has been closed this does not send any message nor block. + if (force || channelClosed) { channel.Dispose(); + return; } + + // The execution completed while the channel is still open (cancellation without a reply + // from the server, session error/disconnect). Disposing it then sends SSH_MSG_CHANNEL_CLOSE + // and waits up to ConnectionInfo.ChannelCloseTimeout for the server to close it, which we + // don't want to do on the thread completing the execution (possibly the message loop). + ThreadAbstraction.ExecuteThread(() => + { + try + { + channel.Dispose(); + } + catch + { + // Swallow exceptions which would otherwise be unhandled. + } + }); } /// @@ -641,12 +844,11 @@ private void Dispose(bool disposing) { // unsubscribe from session events to ensure other objects that we're going to dispose // are not accessed while disposing - _session.Disconnected -= Session_Disconnected; - _session.ErrorOccured -= Session_ErrorOccurred; + UnsubscribeFromSessionEvents(); // unsubscribe from channel events to ensure other objects that we're going to dispose // are not accessed while disposing - UnsubscribeFromChannelEvents(dispose: true); + ReleaseChannel(force: true); _inputStream?.Dispose(); _inputStream = null; diff --git a/test/Renci.SshNet.IntegrationTests/ConnectivityTests.cs b/test/Renci.SshNet.IntegrationTests/ConnectivityTests.cs index 4791c188e..310810d46 100644 --- a/test/Renci.SshNet.IntegrationTests/ConnectivityTests.cs +++ b/test/Renci.SshNet.IntegrationTests/ConnectivityTests.cs @@ -404,7 +404,10 @@ public void Common_DetectSessionKilledOnServer() Assert.AreEqual(typeof(SshConnectionException), errorOccurred.GetType()); Assert.IsNull(errorOccurred.InnerException); Assert.AreEqual("An established connection was aborted by the server.", errorOccurred.Message); - Assert.IsFalse(client.IsConnected); + + // ErrorOccurred is raised on the message listener thread before the session disconnects, + // so IsConnected may still be true for a moment after it is signaled. + Assert.IsTrue(SpinWait.SpinUntil(() => !client.IsConnected, TimeSpan.FromSeconds(5))); } } diff --git a/test/Renci.SshNet.IntegrationTests/SshTests.cs b/test/Renci.SshNet.IntegrationTests/SshTests.cs index 2c9dc5655..6c57256ee 100644 --- a/test/Renci.SshNet.IntegrationTests/SshTests.cs +++ b/test/Renci.SshNet.IntegrationTests/SshTests.cs @@ -682,6 +682,10 @@ public void Ssh_RemotePortForwarding() s.WriteLine("ABC"); s.Flush(); s.Expect("ABC"); + + // The echo of "ABC" only means telnet has read the line: wait for it to reach the + // listener before closing the shell (which terminates telnet). + areBytesReceivedOnListener1 = bytesReceivedEventOnListener1.WaitOne(TimeSpan.FromSeconds(10)); s.Close(); } @@ -692,12 +696,11 @@ public void Ssh_RemotePortForwarding() s.WriteLine("DEF"); s.Flush(); s.Expect("DEF"); + + areBytesReceivedOnListener2 = bytesReceivedEventOnListener2.WaitOne(TimeSpan.FromSeconds(10)); s.Close(); } - areBytesReceivedOnListener1 = bytesReceivedEventOnListener1.WaitOne(1000); - areBytesReceivedOnListener2 = bytesReceivedEventOnListener2.WaitOne(1000); - forwardedPort1.Stop(); forwardedPort2.Stop(); } diff --git a/test/Renci.SshNet.Tests/Classes/SshCommandTest_NotDisposed.cs b/test/Renci.SshNet.Tests/Classes/SshCommandTest_NotDisposed.cs new file mode 100644 index 000000000..0eaf678ec --- /dev/null +++ b/test/Renci.SshNet.Tests/Classes/SshCommandTest_NotDisposed.cs @@ -0,0 +1,248 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; + +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Moq; + +using Renci.SshNet.Channels; +using Renci.SshNet.Common; +using Renci.SshNet.Tests.Common; + +namespace Renci.SshNet.Tests.Classes +{ + /// + /// Regression tests for https://github.com/sshnet/SSH.NET/issues/1204: an + /// that completed but was not disposed must not be kept alive by the (long-lived) session, and + /// must not keep its channel subscribed to the session. + /// + [TestClass] + public class SshCommandTest_NotDisposed : TestBase + { + private Mock _sessionMock; + private Mock _channelSessionMock; + private string _commandText; + + protected override void OnInit() + { + base.OnInit(); + + _sessionMock = new Mock(MockBehavior.Strict); + _sessionMock.Setup(p => p.SessionLoggerFactory).Returns(NullLoggerFactory.Instance); + _channelSessionMock = new Mock(MockBehavior.Strict); + _commandText = new Random().Next().ToString(CultureInfo.InvariantCulture); + } + + [TestMethod] + public void ConstructorShouldNotCreateChannelNorSubscribeToSession() + { + // Strict mock: CreateChannelSession has not been set up, so calling it would throw. + _ = new SshCommand(_sessionMock.Object, _commandText, Encoding.UTF8); + + _sessionMock.Verify(p => p.CreateChannelSession(), Times.Never); + } + + [TestMethod] + public void CompletedCommandShouldReleaseChannelWithoutDispose() + { + var seq = new MockSequence(); + _sessionMock.InSequence(seq).Setup(p => p.CreateChannelSession()).Returns(_channelSessionMock.Object); + _channelSessionMock.InSequence(seq).Setup(p => p.Open()); + _channelSessionMock.InSequence(seq).Setup(p => p.SendExecRequest(_commandText)).Returns(true); + _channelSessionMock.InSequence(seq).Setup(p => p.Dispose()); + + var sshCommand = new SshCommand(_sessionMock.Object, _commandText, Encoding.UTF8); + var asyncResult = sshCommand.BeginExecute(); + + _channelSessionMock.Verify(p => p.Dispose(), Times.Never); + + _channelSessionMock.Raise(c => c.Closed += null, new ChannelEventArgs(5)); + + _ = sshCommand.EndExecute(asyncResult); + + // The channel was released as soon as it was closed at both ends. + _channelSessionMock.Verify(p => p.Dispose(), Times.Once); + + // Disposing afterwards must not dispose the channel again. + sshCommand.Dispose(); + _channelSessionMock.Verify(p => p.Dispose(), Times.Once); + } + + [TestMethod] + public void ChannelClosedBeforeSendExecRequestReturnsShouldNotDisposeChannelUnderneathIt() + { + // Reproduces the race fixed by #1596: SSH_MSG_CHANNEL_CLOSE is processed on the + // message loop thread while the executing thread is still in SendExecRequest. + var disposedDuringSendExecRequest = false; + + var seq = new MockSequence(); + _sessionMock.InSequence(seq).Setup(p => p.CreateChannelSession()).Returns(_channelSessionMock.Object); + _channelSessionMock.InSequence(seq).Setup(p => p.Open()); + _channelSessionMock.InSequence(seq).Setup(p => p.SendExecRequest(_commandText)) + .Returns(true) + .Callback(() => + { + _channelSessionMock.Raise(c => c.Closed += null, new ChannelEventArgs(5)); + + disposedDuringSendExecRequest = _channelSessionMock.Invocations.Any(i => i.Method.Name == nameof(IDisposable.Dispose)); + }); + _channelSessionMock.InSequence(seq).Setup(p => p.Dispose()); + + var sshCommand = new SshCommand(_sessionMock.Object, _commandText, Encoding.UTF8); + var asyncResult = sshCommand.BeginExecute(); + _ = sshCommand.EndExecute(asyncResult); + + Assert.IsFalse(disposedDuringSendExecRequest); + _channelSessionMock.Verify(p => p.Dispose(), Times.Once); + } + + [TestMethod] + public void CompletedCommandShouldNotBeKeptAliveBySession() + { + var seq = new MockSequence(); + _sessionMock.InSequence(seq).Setup(p => p.CreateChannelSession()).Returns(_channelSessionMock.Object); + _channelSessionMock.InSequence(seq).Setup(p => p.Open()); + _channelSessionMock.InSequence(seq).Setup(p => p.SendExecRequest(_commandText)).Returns(true); + _channelSessionMock.InSequence(seq).Setup(p => p.Dispose()); + + var weakReference = ExecuteAndForget(); + + // Moq records invocations (including the event handler delegates passed to add_/remove_ + // accessors), which would otherwise keep the command alive. The event handlers that are + // still subscribed are stored separately and are not cleared by this - which is exactly + // what this test is about. + _sessionMock.Invocations.Clear(); + _channelSessionMock.Invocations.Clear(); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.IsFalse(weakReference.IsAlive, "The completed SshCommand is still referenced (by the session)."); + GC.KeepAlive(_sessionMock.Object); + } + + [TestMethod] + public void FailureToStartShouldReleaseChannelAndAllowReexecution() + { + var seq = new MockSequence(); + _sessionMock.InSequence(seq).Setup(p => p.CreateChannelSession()).Returns(_channelSessionMock.Object); + _channelSessionMock.InSequence(seq).Setup(p => p.Open()).Throws(new SshException("open failed")); + _channelSessionMock.InSequence(seq).Setup(p => p.Dispose()); + + var sshCommand = new SshCommand(_sessionMock.Object, _commandText, Encoding.UTF8); + + _ = Assert.ThrowsExactly(sshCommand.BeginExecute); + + _channelSessionMock.Verify(p => p.Dispose(), Times.Once); + + // Previously the instance was left "in progress" forever. + var channelSessionBMock = new Mock(MockBehavior.Strict); + _sessionMock.InSequence(seq).Setup(p => p.CreateChannelSession()).Returns(channelSessionBMock.Object); + channelSessionBMock.InSequence(seq).Setup(p => p.Open()); + channelSessionBMock.InSequence(seq).Setup(p => p.SendExecRequest(_commandText)).Returns(true); + + _ = sshCommand.BeginExecute(); + } + + [TestMethod] + public void CancelledCommandWhoseChannelIsNotClosedShouldReleaseChannel() + { + using var channelDisposed = new ManualResetEventSlim(); + + var seq = new MockSequence(); + _sessionMock.InSequence(seq).Setup(p => p.CreateChannelSession()).Returns(_channelSessionMock.Object); + _channelSessionMock.InSequence(seq).Setup(p => p.Open()); + _channelSessionMock.InSequence(seq).Setup(p => p.SendExecRequest(_commandText)).Returns(true); + _channelSessionMock.InSequence(seq).Setup(p => p.SendSignalRequest("TERM")).Returns(true); + _channelSessionMock.InSequence(seq).Setup(p => p.Dispose()).Callback(channelDisposed.Set); + + var sshCommand = new SshCommand(_sessionMock.Object, _commandText, Encoding.UTF8); + var executeTask = sshCommand.ExecuteAsync(); + + // The server does not reply to the signal and does not close the channel. + sshCommand.CancelAsync(millisecondsTimeout: 10); + + Assert.IsTrue(executeTask.IsCanceled); + Assert.IsTrue(channelDisposed.Wait(TimeSpan.FromSeconds(5)), "The channel was not released."); + } + + [TestMethod] + public void SessionErrorShouldReleaseChannel() + { + using var channelDisposed = new ManualResetEventSlim(); + var exception = new SshException("session error"); + + var seq = new MockSequence(); + _sessionMock.InSequence(seq).Setup(p => p.CreateChannelSession()).Returns(_channelSessionMock.Object); + _channelSessionMock.InSequence(seq).Setup(p => p.Open()); + _channelSessionMock.InSequence(seq).Setup(p => p.SendExecRequest(_commandText)).Returns(true); + _channelSessionMock.InSequence(seq).Setup(p => p.Dispose()).Callback(channelDisposed.Set); + + var sshCommand = new SshCommand(_sessionMock.Object, _commandText, Encoding.UTF8); + var executeTask = sshCommand.ExecuteAsync(); + + _sessionMock.Raise(s => s.ErrorOccured += null, new ExceptionEventArgs(exception)); + + Assert.AreSame(exception, executeTask.Exception?.InnerException); + Assert.IsTrue(channelDisposed.Wait(TimeSpan.FromSeconds(5)), "The channel was not released."); + } + + [TestMethod] + public void CancelledCommandWhoseChannelIsNotClosedShouldNotBeKeptAliveBySession() + { + using var channelDisposed = new ManualResetEventSlim(); + + var seq = new MockSequence(); + _sessionMock.InSequence(seq).Setup(p => p.CreateChannelSession()).Returns(_channelSessionMock.Object); + _channelSessionMock.InSequence(seq).Setup(p => p.Open()); + _channelSessionMock.InSequence(seq).Setup(p => p.SendExecRequest(_commandText)).Returns(true); + _channelSessionMock.InSequence(seq).Setup(p => p.SendSignalRequest("TERM")).Returns(true); + _channelSessionMock.InSequence(seq).Setup(p => p.Dispose()).Callback(channelDisposed.Set); + + var weakReference = ExecuteCancelAndForget(); + + Assert.IsTrue(channelDisposed.Wait(TimeSpan.FromSeconds(5)), "The channel was not released."); + + // See CompletedCommandShouldNotBeKeptAliveBySession. + _sessionMock.Invocations.Clear(); + _channelSessionMock.Invocations.Clear(); + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + + Assert.IsFalse(weakReference.IsAlive, "The cancelled SshCommand is still referenced (by the session or its channel)."); + GC.KeepAlive(_sessionMock.Object); + GC.KeepAlive(_channelSessionMock.Object); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private WeakReference ExecuteCancelAndForget() + { + var sshCommand = new SshCommand(_sessionMock.Object, _commandText, Encoding.UTF8); + _ = sshCommand.ExecuteAsync(); + sshCommand.CancelAsync(millisecondsTimeout: 10); + + // Deliberately not disposed. + return new WeakReference(sshCommand); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private WeakReference ExecuteAndForget() + { + var sshCommand = new SshCommand(_sessionMock.Object, _commandText, Encoding.UTF8); + var asyncResult = sshCommand.BeginExecute(); + _channelSessionMock.Raise(c => c.Closed += null, new ChannelEventArgs(5)); + _ = sshCommand.EndExecute(asyncResult); + + // Deliberately not disposed. + return new WeakReference(sshCommand); + } + } +}