From da9d3fc42036e6fb93b3eda8400f25e19831b8c7 Mon Sep 17 00:00:00 2001 From: Levi Gillis Date: Wed, 25 Mar 2026 14:28:37 +0100 Subject: [PATCH 1/2] Add ability to send posix/ansi signals --- src/Renci.SshNet/CommandSignal.cs | 74 +++++++++++++++++++++++++++++++ src/Renci.SshNet/SshCommand.cs | 67 ++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 src/Renci.SshNet/CommandSignal.cs diff --git a/src/Renci.SshNet/CommandSignal.cs b/src/Renci.SshNet/CommandSignal.cs new file mode 100644 index 000000000..5d37c24c6 --- /dev/null +++ b/src/Renci.SshNet/CommandSignal.cs @@ -0,0 +1,74 @@ +namespace Renci.SshNet +{ + /// + /// The ssh compatible POSIX/ANSI signals with their libc compatible values. + /// +#pragma warning disable CA1720 // Identifier contains type name + public enum CommandSignal + { + /// + /// Hangup (POSIX). + /// + HUP = 1, + + /// + /// Interrupt (ANSI). + /// + INT = 2, + + /// + /// Quit (POSIX). + /// + QUIT = 3, + + /// + /// Illegal instruction (ANSI). + /// + ILL = 4, + + /// + /// Abort (ANSI). + /// + ABRT = 6, + + /// + /// Floating-point exception (ANSI). + /// + FPE = 8, + + /// + /// Kill, unblockable (POSIX). + /// + KILL = 9, + + /// + /// User-defined signal 1 (POSIX). + /// + USR1 = 10, + + /// + /// Segmentation violation (ANSI). + /// + SEGV = 11, + + /// + /// User-defined signal 2 (POSIX). + /// + USR2 = 12, + + /// + /// Broken pipe (POSIX). + /// + PIPE = 13, + + /// + /// Alarm clock (POSIX). + /// + ALRM = 14, + + /// + /// Termination (ANSI). + /// + TERM = 15, + } +} diff --git a/src/Renci.SshNet/SshCommand.cs b/src/Renci.SshNet/SshCommand.cs index ce1042244..460e6c30e 100644 --- a/src/Renci.SshNet/SshCommand.cs +++ b/src/Renci.SshNet/SshCommand.cs @@ -478,6 +478,73 @@ public void CancelAsync(bool forceKill = false, int millisecondsTimeout = 500) } } + private static string? GetSignalName(CommandSignal signal) + { +#if NETCOREAPP + return Enum.GetName(signal); +#else + + // Boxes signal, but Enum.GetName does not have a non-boxing overload prior to .NET Core. + return Enum.GetName(typeof(CommandSignal), signal); +#endif + } + + /// + /// Tries to send a POSIX/ANSI signal to the remote process executing the command, such as SIGINT or SIGTERM. + /// + /// The signal to send + /// If the signal was sent. + public bool TrySendSignal(CommandSignal signal) + { + var signalName = GetSignalName(signal); + if (signalName is null) + { + return false; + } + + if (_tcs is null || _tcs.Task.IsCompleted || _channel?.IsOpen != true) + { + return false; + } + + try + { + // Try to send the cancellation signal. + return _channel.SendSignalRequest(signalName); + } + catch (Exception) + { + // Exception can be ignored since we are in a Try method + // Possible exceptions here: InvalidOperationException, SshConnectionException, SshOperationTimeoutException + } + + return false; + } + + /// + /// Tries to send a POSIX/ANSI signal to the remote process executing the command, such as SIGINT or SIGTERM. + /// + /// The signal to send + /// Signal was not a valid CommandSignal. + /// The client is not connected. + /// The operation timed out. + /// The size of the packet exceeds the maximum size defined by the protocol. + /// Command has not been started. + public void SendSignal(CommandSignal signal) + { + var signalName = GetSignalName(signal); + if (signalName is null) + { + throw new ArgumentException("Signal was not a valid CommandSignal."); + } + if (_tcs is null || _tcs.Task.IsCompleted || _channel?.IsOpen != true) + { + throw new InvalidOperationException("Command has not been started."); + } + + _ = _channel.SendSignalRequest(signalName); + } + /// /// Executes the command specified by . /// From 407e6af5e180620d9486fe3717decdef4dce9d65 Mon Sep 17 00:00:00 2001 From: Levi Gillis Date: Wed, 25 Mar 2026 16:02:33 +0100 Subject: [PATCH 2/2] Initial version of low allocation and low overhead SshCommandLite class --- .../Common/CommandExitedEventArgs.cs | 46 ++ .../Common/CommandOutputEventArgs.cs | 44 ++ .../Common/ExtendedCommandEventArgs.cs | 40 ++ src/Renci.SshNet/ISshClient.cs | 32 + src/Renci.SshNet/SshClient.cs | 23 + src/Renci.SshNet/SshCommandLite.cs | 589 ++++++++++++++++++ 6 files changed, 774 insertions(+) create mode 100644 src/Renci.SshNet/Common/CommandExitedEventArgs.cs create mode 100644 src/Renci.SshNet/Common/CommandOutputEventArgs.cs create mode 100644 src/Renci.SshNet/Common/ExtendedCommandEventArgs.cs create mode 100644 src/Renci.SshNet/SshCommandLite.cs diff --git a/src/Renci.SshNet/Common/CommandExitedEventArgs.cs b/src/Renci.SshNet/Common/CommandExitedEventArgs.cs new file mode 100644 index 000000000..40a3dc50f --- /dev/null +++ b/src/Renci.SshNet/Common/CommandExitedEventArgs.cs @@ -0,0 +1,46 @@ +#nullable enable +using System; + +namespace Renci.SshNet.Common +{ + /// + /// Class for command exit related events. + /// + public class CommandExitedEventArgs : EventArgs + { + /// + /// Initializes a new instance of the class. + /// + /// The exit status. + /// The exit signal. + public CommandExitedEventArgs(int? exitStatus, string? exitSignal) + { + ExitStatus = exitStatus; + ExitSignal = exitSignal; + } + + /// + /// Gets the number representing the exit status of the command, if applicable, + /// otherwise . + /// + /// + /// The value is not when an exit status code has been returned + /// from the server. If the command terminated due to a signal, + /// may be not instead. + /// + /// + public int? ExitStatus { get; } + + + /// + /// Gets the name of the signal due to which the command + /// terminated violently, if applicable, otherwise . + /// + /// + /// The value (if it exists) is supplied by the server and is usually one of the + /// following, as described in https://datatracker.ietf.org/doc/html/rfc4254#section-6.10: + /// ABRT, ALRM, FPE, HUP, ILL, INT, KILL, PIPE, QUIT, SEGV, TER, USR1, USR2. + /// + public string? ExitSignal { get; } + } +} diff --git a/src/Renci.SshNet/Common/CommandOutputEventArgs.cs b/src/Renci.SshNet/Common/CommandOutputEventArgs.cs new file mode 100644 index 000000000..d25bc6982 --- /dev/null +++ b/src/Renci.SshNet/Common/CommandOutputEventArgs.cs @@ -0,0 +1,44 @@ +#nullable enable +using System; +using System.Text; + +namespace Renci.SshNet.Common +{ + /// + /// Base class for command output related events. + /// + public class CommandOutputEventArgs : EventArgs + { + /// + /// Initializes a new instance of the class. + /// + /// The raw data received. + /// The encoding used for the transmission. + public CommandOutputEventArgs(ArraySegment rawData, Encoding encoding) + { + RawData = rawData; + Encoding = encoding; + } + + /// + /// Gets the received data as . + /// + public string Text + { + get + { + return Encoding.GetString(RawData.Array, RawData.Offset, RawData.Count); + } + } + + /// + /// Gets the raw data received from the server. This is the data that was used to create the property. + /// + public ArraySegment RawData { get; } + + /// + /// Gets the output encoding used. + /// + public Encoding Encoding { get; } + } +} diff --git a/src/Renci.SshNet/Common/ExtendedCommandEventArgs.cs b/src/Renci.SshNet/Common/ExtendedCommandEventArgs.cs new file mode 100644 index 000000000..fa0a9f5fe --- /dev/null +++ b/src/Renci.SshNet/Common/ExtendedCommandEventArgs.cs @@ -0,0 +1,40 @@ +using System; +#nullable enable +using System.Text; + +namespace Renci.SshNet.Common +{ + /// + /// Class for extended text output related events. + /// + public class ExtendedCommandEventArgs : CommandOutputEventArgs + { + /// + /// Initializes a new instance of the class. + /// + /// The raw data received. + /// The encoding used for the transmission. + /// The data type code. + public ExtendedCommandEventArgs(ArraySegment rawData, Encoding encoding, uint dataTypeCode) + : base(rawData, encoding) + { + DataTypeCode = dataTypeCode; + } + + /// + /// Gets the data type code. + /// + public uint DataTypeCode { get; } + + /// + /// Gets a value indicating whether the current data represents an stderr output. + /// + public bool IsError + { + get + { + return DataTypeCode == 1; + } + } + } +} diff --git a/src/Renci.SshNet/ISshClient.cs b/src/Renci.SshNet/ISshClient.cs index 4507c316b..c3b36964a 100644 --- a/src/Renci.SshNet/ISshClient.cs +++ b/src/Renci.SshNet/ISshClient.cs @@ -66,6 +66,38 @@ public interface ISshClient : IBaseClient /// is . public SshCommand RunCommand(string commandText); + /// + /// Creates the command to be executed. + /// + /// The command text. + /// object. + /// Client is not connected. + public SshCommandLite CreateCommandLite(string commandText); + + /// + /// Creates the command to be executed with specified encoding. + /// + /// The command text. + /// The encoding to use for results. + /// object which uses specified encoding. + /// This method will change current default encoding. + /// Client is not connected. + /// or is . + public SshCommandLite CreateCommandLite(string commandText, Encoding encoding); + + /// + /// Creates and executes the command. + /// + /// The command text. + /// 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 . + public SshCommandLite RunCommandLite(string commandText); + /// /// Creates the shell. /// diff --git a/src/Renci.SshNet/SshClient.cs b/src/Renci.SshNet/SshClient.cs index 331ccaf69..a510da23e 100644 --- a/src/Renci.SshNet/SshClient.cs +++ b/src/Renci.SshNet/SshClient.cs @@ -209,6 +209,29 @@ public SshCommand RunCommand(string commandText) return cmd; } + /// + public SshCommandLite CreateCommandLite(string commandText) + { + return CreateCommandLite(commandText, ConnectionInfo.Encoding); + } + + /// + public SshCommandLite CreateCommandLite(string commandText, Encoding encoding) + { + EnsureSessionIsOpen(); + + ConnectionInfo.Encoding = encoding; + return new SshCommandLite(Session!, commandText, encoding); + } + + /// + public SshCommandLite RunCommandLite(string commandText) + { + var cmd = CreateCommandLite(commandText); + _ = cmd.Execute(); + return cmd; + } + /// public Shell CreateShell(Stream input, Stream output, Stream extendedOutput, string terminalName, uint columns, uint rows, uint width, uint height, IDictionary? terminalModes, int bufferSize) { diff --git a/src/Renci.SshNet/SshCommandLite.cs b/src/Renci.SshNet/SshCommandLite.cs new file mode 100644 index 000000000..6a19d3144 --- /dev/null +++ b/src/Renci.SshNet/SshCommandLite.cs @@ -0,0 +1,589 @@ +#nullable enable +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +using Renci.SshNet.Channels; +using Renci.SshNet.Common; +using Renci.SshNet.Messages.Connection; +using Renci.SshNet.Messages.Transport; + +namespace Renci.SshNet +{ + /// + /// Represents an SSH command that can be executed. + /// + public sealed class SshCommandLite : IDisposable + { + private readonly ISession _session; + private readonly Encoding _encoding; + + private IChannelSession _channel; + private TaskCompletionSource? _tcs; + private CancellationTokenSource? _cts; + private CancellationTokenRegistration _tokenRegistration; + private bool _isDisposed; + 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. + + /// + /// Gets the command text. + /// + public string CommandText { get; private set; } + + /// + /// Gets the command input and output encoding. + /// + public Encoding CommandEncoding + { + get + { + return _encoding; + } + } + + /// + /// Gets or sets the command timeout. + /// + /// + /// The command timeout. + /// + public TimeSpan CommandTimeout + { + get + { + return _commandTimeout; + } + set + { + value.EnsureValidTimeout(nameof(CommandTimeout)); + + _commandTimeout = value; + } + } + + /// + /// Gets the number representing the exit status of the command, if applicable, + /// otherwise . + /// + /// + /// The value is not when an exit status code has been returned + /// from the server. If the command terminated due to a signal, + /// may be not instead. + /// + /// + public int? ExitStatus + { + get + { + return _haveExitStatus ? _exitStatus : null; + } + } + + /// + /// Gets the name of the signal due to which the command + /// terminated violently, if applicable, otherwise . + /// + /// + /// The value (if it exists) is supplied by the server and is usually one of the + /// following, as described in https://datatracker.ietf.org/doc/html/rfc4254#section-6.10: + /// ABRT, ALRM, FPE, HUP, ILL, INT, KILL, PIPE, QUIT, SEGV, TER, USR1, USR2. + /// + public string? ExitSignal { get; private set; } + + /// + /// Occurs when output is received. + /// + public event EventHandler? OutputReceived; + + /// + /// Occurs when ExtendedOutput is received. + /// + public event EventHandler? ExtendedOutputReceived; + + /// + /// Occurs when the command has finished executing and the channel has been closed. + /// Returns the exit status code if it was provided by the server, or otherwise. + /// + public event EventHandler? Exited; + + /// + /// Creates and returns the input stream for the command. + /// + /// + /// 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. + /// + /// + /// 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.IsOpen) + { + throw new InvalidOperationException("The input stream can be used only during execution."); + } + + if (_inputStream != null) + { + throw new InvalidOperationException("The input stream already exists."); + } + + _inputStream = new ChannelInputStream(_channel); + return _inputStream; + } + + /// + /// Initializes a new instance of the class. + /// + /// The session. + /// The command text. + /// The encoding to use for the results. + /// Either , is . + internal SshCommandLite(ISession session, string commandText, Encoding encoding) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentNullException.ThrowIfNull(commandText); + ArgumentNullException.ThrowIfNull(encoding); + + _session = session; + CommandText = commandText; + _encoding = encoding; + CommandTimeout = Timeout.InfiniteTimeSpan; + _session.Disconnected += Session_Disconnected; + _session.ErrorOccured += Session_ErrorOccurred; + _channel = _session.CreateChannelSession(); + } + + /// + /// 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) + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + + 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."); + } + + UnsubscribeFromChannelEvents(dispose: true); + + _channel = _session.CreateChannelSession(); + } + + _exitStatus = default; + _haveExitStatus = false; + ExitSignal = null; + _tokenRegistration.Dispose(); + _tokenRegistration = default; + _cts?.Dispose(); + _cts = null; + _cancellationRequested = false; + + _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(); + + _ = _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 => + { + try + { + ((SshCommand)cmd!).CancelAsync(); + } + catch + { + // Swallow exceptions which would otherwise be unhandled. + } + }, + this); + } + + return _tcs.Task; + } +#pragma warning restore CA1849 + + /// + /// Cancels a running command by sending a signal to the remote process. + /// + /// if true send SIGKILL instead of SIGTERM. + /// Time to wait for the server to reply. + /// + /// + /// This method stops the command running on the server by sending a SIGTERM + /// (or SIGKILL, depending on ) signal to the remote + /// process. When the server implements signals, it will send a response which + /// populates with the signal with which the command terminated. + /// + /// + /// When the server does not implement signals, it may send no response. As a fallback, + /// this method waits up to for a response + /// and then completes the object anyway if there was none. + /// + /// + /// If the command has already finished (with or without cancellation), this method does + /// nothing. + /// + /// + /// Command has not been started. + public void CancelAsync(bool forceKill = false, int millisecondsTimeout = 500) + { + if (_tcs is null) + { + throw new InvalidOperationException("Command has not been started."); + } + + if (_tcs.Task.IsCompleted) + { + return; + } + + _cancellationRequested = true; + Interlocked.MemoryBarrier(); // ensure fresh read in SetAsyncComplete (possibly unnecessary) + + try + { + // Try to send the cancellation signal. + if (_channel?.SendSignalRequest(forceKill ? "KILL" : "TERM") is null) + { + // Command has completed (in the meantime since the last check). + 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 complete the task ourselves to unblock waiters. + + _ = _tcs.Task.Wait(millisecondsTimeout); + } + catch (AggregateException) + { + // We expect to be here from the call to Wait if the server implements signals. + // But we don't want to propagate the exception on the task from here. + } + finally + { + SetAsyncComplete(); + } + } + + private static string? GetSignalName(CommandSignal signal) + { +#if NETCOREAPP + return Enum.GetName(signal); +#else + + // Boxes signal, but Enum.GetName does not have a non-boxing overload prior to .NET Core. + return Enum.GetName(typeof(CommandSignal), signal); +#endif + } + + /// + /// Tries to send a POSIX/ANSI signal to the remote process executing the command, such as SIGINT or SIGTERM. + /// + /// The signal to send + /// If the signal was sent. + public bool TrySendSignal(CommandSignal signal) + { + var signalName = GetSignalName(signal); + if (signalName is null) + { + return false; + } + + if (_tcs is null || _tcs.Task.IsCompleted || _channel?.IsOpen != true) + { + return false; + } + + try + { + // Try to send the cancellation signal. + return _channel.SendSignalRequest(signalName); + } + catch (Exception) + { + // Exception can be ignored since we are in a Try method + // Possible exceptions here: InvalidOperationException, SshConnectionException, SshOperationTimeoutException + } + + return false; + } + + /// + /// Tries to send a POSIX/ANSI signal to the remote process executing the command, such as SIGINT or SIGTERM. + /// + /// The signal to send + /// Signal was not a valid CommandSignal. + /// The client is not connected. + /// The operation timed out. + /// The size of the packet exceeds the maximum size defined by the protocol. + /// Command has not been started. + public void SendSignal(CommandSignal signal) + { + var signalName = GetSignalName(signal); + if (signalName is null) + { + throw new ArgumentException("Signal was not a valid CommandSignal."); + } + if (_tcs is null || _tcs.Task.IsCompleted || _channel?.IsOpen != true) + { + throw new InvalidOperationException("Command has not been started."); + } + + _ = _channel.SendSignalRequest(signalName); + } + + /// + /// Executes the command specified by . + /// + /// . + /// Client is not connected. + /// Operation has timed out. + public int? Execute() + { + ExecuteAsync().GetAwaiter().GetResult(); + return ExitStatus; + } + + /// + /// Executes the specified command. + /// + /// The command text. + /// . + /// Client is not connected. + /// Operation has timed out. + public int? Execute(string commandText) + { + CommandText = commandText; + + return Execute(); + } + + 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); + } + + private void Session_ErrorOccurred(object? sender, ExceptionEventArgs e) + { + _ = _tcs?.TrySetException(e.Exception); + + SetAsyncComplete(setResult: false); + } + + private void SetAsyncComplete(bool setResult = true) + { + Interlocked.MemoryBarrier(); // ensure fresh read of _cancellationRequested (possibly unnecessary) + + if (setResult) + { + 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!); + } + } + + // 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); + + Exited?.Invoke(this, new(ExitStatus, ExitSignal)); + } + + private void Channel_Closed(object? sender, ChannelEventArgs e) + { + SetAsyncComplete(); + } + + private void Channel_RequestReceived(object? sender, ChannelRequestEventArgs e) + { + if (e.Info is ExitStatusRequestInfo exitStatusInfo) + { + _exitStatus = (int)exitStatusInfo.ExitStatus; + _haveExitStatus = true; + + Debug.Assert(!exitStatusInfo.WantReply, "exit-status is want_reply := false by definition."); + } + else if (e.Info is ExitSignalRequestInfo exitSignalInfo) + { + ExitSignal = exitSignalInfo.SignalName; + + Debug.Assert(!exitSignalInfo.WantReply, "exit-signal is want_reply := false by definition."); + } + else if (e.Info.WantReply && sender is IChannel { RemoteChannelNumber: uint remoteChannelNumber }) + { + var replyMessage = new ChannelFailureMessage(remoteChannelNumber); + _session.SendMessage(replyMessage); + } + } + + private void Channel_ExtendedDataReceived(object? sender, ChannelExtendedDataEventArgs e) + { + ExtendedOutputReceived?.Invoke(this, new(e.Data, _encoding, e.DataTypeCode)); + } + + private void Channel_DataReceived(object? sender, ChannelDataEventArgs e) + { + OutputReceived?.Invoke(this, new(e.Data, _encoding)); + } + + /// + /// Unsubscribes the current from channel events, and optionally, + /// disposes . + /// + private void UnsubscribeFromChannelEvents(bool dispose) + { + var channel = _channel; + + // unsubscribe from events as we do not want to be signaled should these get fired + // during the dispose of the channel + channel.DataReceived -= Channel_DataReceived; + channel.ExtendedDataReceived -= Channel_ExtendedDataReceived; + channel.RequestReceived -= Channel_RequestReceived; + channel.Closed -= Channel_Closed; + + if (dispose) + { + channel.Dispose(); + } + } + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// + /// Releases unmanaged and - optionally - managed resources. + /// + /// to release both managed and unmanaged resources; to release only unmanaged resources. + private void Dispose(bool disposing) + { + if (_isDisposed) + { + return; + } + + if (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; + + // unsubscribe from channel events to ensure other objects that we're going to dispose + // are not accessed while disposing + UnsubscribeFromChannelEvents(dispose: true); + + _inputStream?.Dispose(); + _inputStream = 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. + _ = tcs.TrySetException(new ObjectDisposedException(GetType().FullName)); + } + + _isDisposed = true; + } + } + } +}