Skip to content

Commit 99ef23c

Browse files
authored
Don't dispose channel when completing SshCommand (#1596)
The new(-ish) implementation of SshCommand has a race condition for short-lived commands where SSH_MSG_CHANNEL_CLOSE may be processed on the message loop thread before SSH_MSG_CHANNEL_SUCCESS is waited upon on the Execute (main) thread. This manifests in an ArgumentNull/NullReference exception on the wait handle because the channel has already been closed and disposed. Fix this by only delaying the channel dispose until the command dispose.
1 parent a0c6bac commit 99ef23c

File tree

4 files changed

+22
-99
lines changed

4 files changed

+22
-99
lines changed

src/Renci.SshNet/SshCommand.cs

+21-20
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public class SshCommand : IDisposable
2121
private readonly ISession _session;
2222
private readonly Encoding _encoding;
2323

24-
private IChannelSession? _channel;
24+
private IChannelSession _channel;
2525
private TaskCompletionSource<object>? _tcs;
2626
private CancellationTokenSource? _cts;
2727
private CancellationTokenRegistration _tokenRegistration;
@@ -142,14 +142,14 @@ public int? ExitStatus
142142
/// </example>
143143
public Stream CreateInputStream()
144144
{
145-
if (_channel == null)
145+
if (!_channel.IsOpen)
146146
{
147-
throw new InvalidOperationException($"The input stream can be used only after calling BeginExecute and before calling EndExecute.");
147+
throw new InvalidOperationException("The input stream can be used only during execution.");
148148
}
149149

150150
if (_inputStream != null)
151151
{
152-
throw new InvalidOperationException($"The input stream already exists.");
152+
throw new InvalidOperationException("The input stream already exists.");
153153
}
154154

155155
_inputStream = new ChannelInputStream(_channel);
@@ -226,6 +226,7 @@ internal SshCommand(ISession session, string commandText, Encoding encoding)
226226
ExtendedOutputStream = new PipeStream();
227227
_session.Disconnected += Session_Disconnected;
228228
_session.ErrorOccured += Session_ErrorOccured;
229+
_channel = _session.CreateChannelSession();
229230
}
230231

231232
/// <summary>
@@ -257,6 +258,8 @@ public Task ExecuteAsync(CancellationToken cancellationToken = default)
257258
throw new InvalidOperationException("Asynchronous operation is already in progress.");
258259
}
259260

261+
UnsubscribeFromChannelEvents(dispose: true);
262+
260263
OutputStream.Dispose();
261264
ExtendedOutputStream.Dispose();
262265

@@ -265,6 +268,7 @@ public Task ExecuteAsync(CancellationToken cancellationToken = default)
265268
// so we just need to reinitialise them for subsequent executions.
266269
OutputStream = new PipeStream();
267270
ExtendedOutputStream = new PipeStream();
271+
_channel = _session.CreateChannelSession();
268272
}
269273

270274
_exitStatus = default;
@@ -282,7 +286,6 @@ public Task ExecuteAsync(CancellationToken cancellationToken = default)
282286
_tcs = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
283287
_userToken = cancellationToken;
284288

285-
_channel = _session.CreateChannelSession();
286289
_channel.DataReceived += Channel_DataReceived;
287290
_channel.ExtendedDataReceived += Channel_ExtendedDataReceived;
288291
_channel.RequestReceived += Channel_RequestReceived;
@@ -542,7 +545,10 @@ private void SetAsyncComplete(bool setResult = true)
542545
}
543546
}
544547

545-
UnsubscribeFromEventsAndDisposeChannel();
548+
// We don't dispose the channel here to avoid a race condition
549+
// where SSH_MSG_CHANNEL_CLOSE arrives before _channel starts
550+
// waiting for a response in _channel.SendExecRequest().
551+
UnsubscribeFromChannelEvents(dispose: false);
546552

547553
OutputStream.Dispose();
548554
ExtendedOutputStream.Dispose();
@@ -568,7 +574,7 @@ private void Channel_RequestReceived(object? sender, ChannelRequestEventArgs e)
568574

569575
Debug.Assert(!exitSignalInfo.WantReply, "exit-signal is want_reply := false by definition.");
570576
}
571-
else if (e.Info.WantReply && _channel?.RemoteChannelNumber is uint remoteChannelNumber)
577+
else if (e.Info.WantReply && sender is IChannel { RemoteChannelNumber: uint remoteChannelNumber })
572578
{
573579
var replyMessage = new ChannelFailureMessage(remoteChannelNumber);
574580
_session.SendMessage(replyMessage);
@@ -591,29 +597,24 @@ private void Channel_DataReceived(object? sender, ChannelDataEventArgs e)
591597
}
592598

593599
/// <summary>
594-
/// Unsubscribes the current <see cref="SshCommand"/> from channel events, and disposes
595-
/// the <see cref="_channel"/>.
600+
/// Unsubscribes the current <see cref="SshCommand"/> from channel events, and optionally,
601+
/// disposes <see cref="_channel"/>.
596602
/// </summary>
597-
private void UnsubscribeFromEventsAndDisposeChannel()
603+
private void UnsubscribeFromChannelEvents(bool dispose)
598604
{
599605
var channel = _channel;
600606

601-
if (channel is null)
602-
{
603-
return;
604-
}
605-
606-
_channel = null;
607-
608607
// unsubscribe from events as we do not want to be signaled should these get fired
609608
// during the dispose of the channel
610609
channel.DataReceived -= Channel_DataReceived;
611610
channel.ExtendedDataReceived -= Channel_ExtendedDataReceived;
612611
channel.RequestReceived -= Channel_RequestReceived;
613612
channel.Closed -= Channel_Closed;
614613

615-
// actually dispose the channel
616-
channel.Dispose();
614+
if (dispose)
615+
{
616+
channel.Dispose();
617+
}
617618
}
618619

619620
/// <summary>
@@ -645,7 +646,7 @@ protected virtual void Dispose(bool disposing)
645646

646647
// unsubscribe from channel events to ensure other objects that we're going to dispose
647648
// are not accessed while disposing
648-
UnsubscribeFromEventsAndDisposeChannel();
649+
UnsubscribeFromChannelEvents(dispose: true);
649650

650651
_inputStream?.Dispose();
651652
_inputStream = null;

test/Renci.SshNet.Tests/Classes/SshCommandTest_EndExecute.cs

-72
This file was deleted.

test/Renci.SshNet.Tests/Classes/SshCommandTest_EndExecute_AsyncResultIsNull.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ protected override void OnInit()
3030

3131
private void Arrange()
3232
{
33-
_sessionMock = new Mock<ISession>(MockBehavior.Strict);
33+
_sessionMock = new Mock<ISession>();
3434
_commandText = new Random().Next().ToString(CultureInfo.InvariantCulture);
3535
_encoding = Encoding.UTF8;
3636
_asyncResult = null;

test/Renci.SshNet.Tests/Classes/SshCommandTest_EndExecute_ChannelOpen.cs

-6
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,6 @@ private void Act()
8181
_actual = _sshCommand.EndExecute(_asyncResult);
8282
}
8383

84-
[TestMethod]
85-
public void ChannelSessionShouldBeDisposedOnce()
86-
{
87-
_channelSessionMock.Verify(p => p.Dispose(), Times.Once);
88-
}
89-
9084
[TestMethod]
9185
public void EndExecuteShouldReturnAllDataReceivedInSpecifiedEncoding()
9286
{

0 commit comments

Comments
 (0)