Files
DodoSSH/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs
T
jaap-jan d71f057bde
ci / build and test (pull_request) Successful in 2m40s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 7s
ci / deploy ssh.dodotech.cloud (pull_request) Skipped
ci / android head (pull_request) Successful in 3m43s
SFTP: stop waiting for ever on a host whose login prints text, and draw the file panes' icons whole
SSH.NET 2026.0.0 runs the SFTP handshake synchronously inside ConnectAsync, with an infinite
operation timeout and without reading the cancellation token. A host whose login script prints
anything (an echo in .bashrc) puts text where the first SFTP packet belongs; SSH.NET reads its first
four bytes as a length of about a gigabyte and waits for the rest, so the file browser said
"Connecting…" for ever. The SFTP client now uses the connect timeout as its operation timeout for
the handshake and resets it to infinite once connected, so transfers on slow links are unchanged. A
timeout after authentication becomes a TimeoutException naming the likely cause.

The SSH test server gains a second port whose sessions print a banner before sftp-server, and
SftpSessionTests proves the open fails within a 3 s connect timeout with that message. Without the
fix the test runs into its 30 s guard.

Button.paneicon is a fixed 26x26 but kept Fluent's 8,5,8,6 padding, which left a 10px-wide content
box, so every 15-16px Material icon on the SFTP panes was drawn with its sides cut off. Its padding
is now 0. Rename on the host pane used U+E9AD, an italic T (text formatting); it now uses U+E9A2,
the rename glyph the phone's files screen already uses. The host pane's "Up one directory" drew the
design's arrow_downward; both panes now use arrow_upward.

SSH tests 123/123, layout tests 203/203, app tests 744/749 with the 5 Linux TPM skips.
2026-10-16 15:09:46 +02:00

759 lines
34 KiB
C#

using System.Text;
using Renci.SshNet;
using Renci.SshNet.Common;
namespace DodoSSH.Client.Ssh;
/// <summary>
/// Opens SSH connections with SSH.NET, checking host key trust during the handshake.
/// </summary>
/// <remarks>
/// The pinned fingerprint is looked up <em>before</em> connecting, so the comparison inside SSH.NET's
/// synchronous <c>HostKeyReceived</c> event is a pure equality check with no I/O and no chance of
/// blocking the handshake on a UI round trip. Anything the comparison cannot settle becomes an
/// exception the caller resolves asynchronously.
/// </remarks>
public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
: ISshConnectionFactory, ISftpSessionFactory
{
private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(15);
/// <summary>
/// How often to put a byte on the wire for a connection that is otherwise idle.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Without this a rebooted machine is never noticed, and the tab claims to be connected for ever.</b>
/// An idle SSH session is a socket nobody is writing to, and a TCP connection that is not written to has
/// no way to discover that the far end has gone: the read simply never returns. That is the whole of the
/// "reboot leaves it hanging" failure — nothing was broken, nothing had been asked, so nothing could
/// fail. This is what makes the question get asked.
/// </para>
/// <para>
/// <b>It is the write that matters, not the reply.</b> SSH.NET's keep-alive is <c>SSH_MSG_IGNORE</c>,
/// which a server is not obliged to answer, so this is not a heartbeat with a deadline — it is traffic.
/// A machine that has rebooted answers the first probe with a RST, and the session is torn down at once;
/// a network that has vanished is discovered when the OS gives up retransmitting. Neither of those is a
/// timeout this client sets, which is why there is no "no reply within N seconds" rule here to be wrong
/// about a slow link.
/// </para>
/// <para>
/// Thirty seconds, which is what OpenSSH's own <c>ServerAliveInterval</c> examples use. It costs a packet
/// a minute per idle session in each direction and it bounds how long a dead session can go on looking
/// alive; shorter would not make the RST arrive sooner, because what is being waited for is the first
/// probe after the machine came back.
/// </para>
/// </remarks>
private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(30);
/// <summary>
/// How much of a file SSH.NET reads or writes per SFTP request.
/// </summary>
/// <remarks>
/// SSH.NET's default is 32 KiB, which is a request per 32 KiB and a round trip's latency between each on
/// a link the window would happily keep full. 64 KiB is the largest an OpenSSH server accepts without
/// negotiation, so it is the ceiling rather than a guess — anything above it is answered with a shorter
/// read, which SSH.NET handles but which buys nothing.
/// </remarks>
private const uint SftpBufferSize = 64 * 1024;
/// <summary>Where a <see cref="SshLoopbackProxy"/> is, and the only address one is ever dialled at.</summary>
/// <remarks>
/// The literal rather than <c>IPAddress.Loopback.ToString()</c>, and rather than "localhost": SSH.NET
/// takes the proxy host as a string and resolves it, so a name would put a DNS lookup — and whatever
/// the machine's hosts file says <c>localhost</c> means — inside the connect path of every proxied
/// connection. It is also the half of the loopback promise this assembly can keep on its own; the other
/// half is that the proxy bound there, which is the caller's to get right.
/// </remarks>
private const string LoopbackAddress = "127.0.0.1";
/// <inheritdoc />
public async Task<ISshConnection> ConnectAsync(
SshConnectionRequest request,
IProgress<SshConnectionPhase>? progress,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var lifetime = request.Proxy?.Lifetime;
try
{
var client = new SshClient(BuildConnectionInfo(request)) { KeepAliveInterval = KeepAliveInterval };
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress, cancellationToken)
.ConfigureAwait(false);
return new SshNetConnection(client, gate.Presented!, lifetime);
}
catch
{
await DisposeAsync(lifetime).ConfigureAwait(false);
throw;
}
}
/// <inheritdoc />
/// <remarks>
/// A second connection to the host rather than a second channel on one that may already be open — see
/// <see cref="ISftpSession"/> for why SSH.NET leaves no choice. Everything that guards a shell guards this
/// too, because it is the same handshake: the same host key gate, the same pin, the same two refusals.
/// </remarks>
public async Task<ISftpSession> OpenSftpAsync(
SshConnectionRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var lifetime = request.Proxy?.Lifetime;
try
{
return await OpenSftpCoreAsync(request, lifetime, cancellationToken).ConfigureAwait(false);
}
catch
{
await DisposeAsync(lifetime).ConfigureAwait(false);
throw;
}
}
/// <summary>
/// Disposes a proxy the connection was given, when the connection it was given for did not happen.
/// </summary>
/// <remarks>
/// Every failure path, including the ones before a socket exists — an unreadable key throws from
/// <see cref="BuildConnectionInfo"/> — because a relay bridge left listening after a failed connect is a
/// loopback SOCKS proxy with an open relay session behind it, and nothing would ever close it.
/// </remarks>
private static async ValueTask DisposeAsync(IAsyncDisposable? lifetime)
{
if (lifetime is not null)
{
await lifetime.DisposeAsync().ConfigureAwait(false);
}
}
private async Task<ISftpSession> OpenSftpCoreAsync(
SshConnectionRequest request,
IAsyncDisposable? lifetime,
CancellationToken cancellationToken)
{
var info = BuildConnectionInfo(request);
// Keep-alive here for the reason it is on the shell client: a transfer connection left open while
// somebody browses is idle in exactly the same way, and a file listing against a machine that has
// since rebooted would otherwise wait on a socket nothing will ever answer.
//
// ◆ The operation timeout is the connect timeout for the handshake, and nothing afterwards. SSH.NET's
// default is to wait for ever, and its ConnectAsync runs the SFTP handshake — the version exchange
// and the home directory lookup — synchronously after the login, where the cancellation token is not
// read. A host whose login prints anything into the channel (an echo in .bashrc is the classic) sends
// text SSH.NET reads as the length of a packet a gigabyte long, and the file browser then said
// "Connecting…" for ever. Reset once connected, so a slow write on a slow link is not cut off.
var client = new SftpClient(info)
{
BufferSize = SftpBufferSize,
KeepAliveInterval = KeepAliveInterval,
OperationTimeout = info.Timeout,
};
// No progress for the file-transfer path. The screen that waits on one is the file browser, which
// reports itself, and a second connection opened behind an already-open shell has nothing the user
// is watching a step list for.
HostKeyGate gate;
try
{
gate = await ConnectThroughHostKeyGateAsync(client, request, progress: null, cancellationToken)
.ConfigureAwait(false);
}
catch (SshOperationTimeoutException exception) when (info.IsAuthenticated)
{
throw SftpHandshakeTimedOut(request, info, exception);
}
client.OperationTimeout = Timeout.InfiniteTimeSpan;
// Read once, here, rather than per call. SftpClient.WorkingDirectory canonicalises against the server
// on first read, so leaving it to the property would put a round trip behind something that reads
// like a field — and the session's own remark promises this is the one path known without asking.
string home;
try
{
home = client.WorkingDirectory;
}
catch
{
client.Dispose();
throw;
}
return new SshNetSftpSession(client, gate.Presented!, home, lifetime);
}
/// <summary>Says what a timeout after the login means for a file-transfer connection.</summary>
/// <remarks>
/// Only after the login: a timeout before it is the network's or the server's, and SSH.NET's own words
/// for it are left alone. One after it is the SFTP handshake's — the host let this account in and then
/// never answered as a file server — and SSH.NET's "Operation has timed out" would send somebody looking
/// at the network for what is nearly always a line of text in a login script on the host.
/// </remarks>
private static TimeoutException SftpHandshakeTimedOut(
SshConnectionRequest request,
ConnectionInfo info,
SshOperationTimeoutException exception) =>
new(
$"{request.Host} accepted the login, but its SFTP service did not answer within "
+ $"{info.Timeout.TotalSeconds:0} seconds. This is usually a login script on the host that "
+ "prints text (an echo in .bashrc, say): SFTP needs the remote shell to stay silent when "
+ "it is not interactive.",
exception);
/// <summary>
/// Runs the handshake with host key trust attached, and translates a refusal this factory caused.
/// </summary>
/// <remarks>
/// Shared by the shell and the file-transfer paths over <c>BaseClient</c>, which is where SSH.NET puts
/// both <c>ConnectAsync</c> and <c>HostKeyReceived</c>. The alternative was the same twelve lines twice,
/// and the half worth getting wrong is the translation: without it a user who has never seen a host is
/// told the connection was lost.
/// </remarks>
private async Task<HostKeyGate> ConnectThroughHostKeyGateAsync(
BaseClient client,
SshConnectionRequest request,
IProgress<SshConnectionPhase>? progress,
CancellationToken cancellationToken)
{
var endpointPins = await knownHosts
.FindForEndpointAsync(request.Host, request.Port, cancellationToken)
.ConfigureAwait(false);
PreferPinnedAlgorithms(client.ConnectionInfo, endpointPins);
var gate = new HostKeyGate(knownHosts, request, endpointPins, progress, cancellationToken);
client.HostKeyReceived += gate.OnHostKeyReceived;
// Before the await rather than inside the gate, because this phase is the part of the handshake
// that happens before there is anything to raise an event about: the lookup, the socket and the key
// exchange. Nothing else can report the start of it.
progress?.Report(SshConnectionPhase.Reaching);
try
{
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is SshConnectionException or SshAuthenticationException)
{
client.Dispose();
// Translate a refusal we caused ourselves into something the caller can act on. Without
// this the user sees "connection lost" for what is really "do you trust this key?".
throw gate.TranslateFailure() ?? exception;
}
catch
{
client.Dispose();
throw;
}
return gate;
}
/// <summary>
/// Offers the host key algorithms this endpoint has pins for ahead of every other.
/// </summary>
/// <remarks>
/// <para>
/// The server takes the first algorithm in the client's list that it also supports, so this is what
/// makes a server that still holds its pinned key negotiate that key — rather than, after it gained an
/// <c>ed25519</c> key alongside the <c>rsa</c> one that was pinned, a key nobody has approved. Without it
/// the gate below would have to choose between refusing a legitimate server and prompting for one that
/// may not be, which is exactly the choice it must not be left with.
/// </para>
/// <para>
/// Reordered rather than restricted, so a host whose pinned keys are all gone still completes the key
/// exchange and reaches the gate, which reports it as a changed key with both fingerprints. Restricting
/// would fail the exchange with "no matching host key algorithm", which is true and tells nobody what
/// happened. Cleared and refilled because that is the only way a dictionary promises an order.
/// </para>
/// </remarks>
private static void PreferPinnedAlgorithms(
ConnectionInfo info,
IReadOnlyList<HostKeyPresentation> pins)
{
if (pins.Count == 0)
{
return;
}
var pinned = pins
.Select(pin => pin.Algorithm)
.ToHashSet(StringComparer.Ordinal);
var ordered = info.HostKeyAlgorithms
.OrderBy(algorithm => pinned.Contains(algorithm.Key) ? 0 : 1)
.ToList();
info.HostKeyAlgorithms.Clear();
foreach (var algorithm in ordered)
{
info.HostKeyAlgorithms.Add(algorithm.Key, algorithm.Value);
}
}
/// <summary>
/// Decides host key trust during the handshake, and remembers enough to explain a refusal.
/// </summary>
/// <remarks>
/// <para>
/// Separate from the connect method because the decision is the security-relevant part and reads
/// better on its own: pinned and equal accepts, pinned and different refuses as a mismatch,
/// unpinned refuses as unknown. There is no fourth branch, and there is no prompt.
/// </para>
/// <para>
/// ◆ <b>"Pinned" means pinned for this endpoint, not for this algorithm.</b> Pins are stored per
/// algorithm, and an endpoint whose pins are all for other algorithms used to fall through to "unknown"
/// — so somebody in the middle who simply offered a different key type got the first-use question
/// instead of the hard refusal a pin exists to produce. Now a key under an algorithm with no pin of its
/// own is accepted only if it is the same key as one that is pinned (an RSA key is one key under
/// <c>rsa-sha2-256</c> and <c>rsa-sha2-512</c>, and its fingerprint says so), and is otherwise a
/// mismatch against the endpoint's pin. <see cref="PreferPinnedAlgorithms"/> is what keeps that from
/// refusing a server that still has its pinned key.
/// </para>
/// </remarks>
private sealed class HostKeyGate(
IKnownHostStore knownHosts,
SshConnectionRequest request,
IReadOnlyList<HostKeyPresentation> endpointPins,
IProgress<SshConnectionPhase>? progress,
CancellationToken cancellationToken)
{
/// <summary>What the server offered, once the handshake has reached that point.</summary>
public HostKeyPresentation? Presented { get; private set; }
private string? pinned;
private bool mismatch;
public void OnHostKeyReceived(object? sender, HostKeyEventArgs e)
{
var presentation = new HostKeyPresentation(
request.Host,
request.Port,
e.HostKeyName,
SshHostKeyFingerprint.Format(e.HostKey));
Presented = presentation;
// Reported before the lookup rather than after it, because the lookup is the wait: this is a
// vault-backed store on the handshake thread, and on a locked or cold vault it is the part of
// "checking the host key" long enough to be worth naming.
progress?.Report(SshConnectionPhase.CheckingHostKey);
// Looked up here rather than before connecting, because the negotiated algorithm is only
// known now and a server may choose a different one than it did last time.
//
// This is the one place the design cannot stay asynchronous: SSH.NET raises host key
// verification synchronously. It is a local store read rather than a UI round trip, and
// making the store synchronous instead would rule out a vault-backed implementation.
pinned = knownHosts
.FindAsync(request.Host, request.Port, e.HostKeyName, cancellationToken)
.AsTask()
.GetAwaiter()
.GetResult();
if (pinned is null && endpointPins.Count == 0)
{
// Refused, not prompted. The caller decides, off the handshake thread.
e.CanTrust = false;
return;
}
// Nothing pinned under this algorithm, but the endpoint has pins: the same key under another
// algorithm's name if any pin's fingerprint is this one, and otherwise a changed key, measured
// against the endpoint's first pin. See the class remarks.
pinned ??= endpointPins
.FirstOrDefault(pin => SshHostKeyFingerprint.Equal(pin.Fingerprint, presentation.Fingerprint))
?.Fingerprint
?? endpointPins[0].Fingerprint;
var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint);
mismatch = !matches;
e.CanTrust = matches;
// Only on acceptance, and here rather than after the await above, because this is the last
// moment SSH.NET gives anyone: returning true from this handler is what lets the handshake go on
// to offer the credential, and it does not come back until it has an answer either way. A
// refusal reports nothing — there is no authentication about to happen for it to be true of.
if (matches)
{
progress?.Report(SshConnectionPhase.Authenticating);
}
}
/// <summary>The specific exception for a refusal this gate caused, or null if it did not.</summary>
public Exception? TranslateFailure()
{
if (Presented is not { } presentation)
{
return null;
}
if (mismatch && pinned is { } pin)
{
return new SshHostKeyMismatchException(presentation, pin);
}
return pinned is null ? new SshHostKeyUnknownException(presentation) : null;
}
}
/// <remarks>
/// <para>
/// The known-host lookup inside the synchronous event is the one place this design cannot avoid
/// blocking. It is a local store read rather than a UI round trip, and the alternative — making
/// the store synchronous — would rule out the encrypted vault-backed implementation entirely.
/// </para>
/// <para>
/// ◆ <b>A proxied connection differs here and nowhere else.</b> The host, the port, the account and the
/// credential are the target's either way, and so is everything the gate above reads — which is what
/// makes a host reached through a bastion or a relay get pinned under its own name rather than under
/// <c>127.0.0.1</c>. The proxy is a route, not a destination, and this is the one method that needs to
/// know the difference. See <see cref="SshLoopbackProxy"/>.
/// </para>
/// <para>
/// <c>Timeout</c> is assigned after the branch rather than in two initialisers, because it covers the
/// whole of getting there — the proxy handshake included — and having it stated once is what stops the
/// two paths quietly drifting to different waits.
/// </para>
/// </remarks>
private static ConnectionInfo BuildConnectionInfo(SshConnectionRequest request)
{
AuthenticationMethod method = request.Credential switch
{
SshPasswordCredential password =>
new PasswordAuthenticationMethod(request.Username, password.Password),
SshPrivateKeyCredential key => new PrivateKeyAuthenticationMethod(
request.Username,
CreatePrivateKeyFile(key)),
_ => throw new NotSupportedException(
$"Credential type {request.Credential.GetType().Name} is not supported."),
};
var info = request.Proxy is { } proxy
? new ConnectionInfo(
request.Host,
request.Port,
request.Username,
ProxyTypes.Socks5,
LoopbackAddress,
proxy.Port,
// No proxy credentials, and empty rather than null: SSH.NET offers username/password
// authentication to a SOCKS5 server only when it has been given one, and both proxies this
// client will ever use are on its own loopback interface, where a password would be a
// secret shared between two halves of the same process.
string.Empty,
string.Empty,
method)
: new ConnectionInfo(request.Host, request.Port, request.Username, method);
info.Timeout = request.ConnectTimeout ?? DefaultConnectTimeout;
return info;
}
private static PrivateKeyFile CreatePrivateKeyFile(SshPrivateKeyCredential credential)
{
using var stream = new MemoryStream(credential.PrivateKeyPem, writable: false);
return credential.Passphrase is null
? new PrivateKeyFile(stream)
: new PrivateKeyFile(stream, credential.Passphrase);
}
}
/// <summary>An SSH.NET-backed connection.</summary>
/// <remarks>
/// Owns the proxy it was reached through when it was given one, and disposes it after the client — the
/// order that lets SSH.NET close its end of the loopback socket before the thing on the other end goes.
/// </remarks>
internal sealed class SshNetConnection(
SshClient client,
HostKeyPresentation hostKey,
IAsyncDisposable? proxyLifetime = null)
: ISshConnection
{
/// <inheritdoc />
public bool IsConnected => client.IsConnected;
/// <inheritdoc />
public HostKeyPresentation HostKey { get; } = hostKey;
/// <inheritdoc />
/// <remarks>
/// Read at construction rather than lazily: by the time an <see cref="SshNetConnection"/> exists,
/// <see cref="SshNetConnectionFactory.ConnectAsync"/> has already awaited <c>client.ConnectAsync</c>, so
/// <c>ConnectionInfo</c> is already populated and there is no earlier moment reading it would race. SSH.NET
/// types the property as nullable all the same — it is null before the key exchange completes — so the
/// coalesce is here to satisfy that annotation, not because this call site expects to see it. Empty is the
/// value this codebase already uses for a cipher that could not be read; see <c>MainWindowViewModel.NullIfEmpty</c>.
/// </remarks>
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
/// <inheritdoc />
/// <remarks>
/// <para>
/// On the thread pool, because <c>CreateShellStream</c> is synchronous and is three round trips to the
/// server — open a channel, ask for a terminal, ask for a shell — and this used to make them on whichever
/// thread called it, which is the UI thread. On a distant or overloaded server the window stopped
/// painting for as long as they took, and the cancellation token it was given was read once, before any
/// of them.
/// </para>
/// <para>
/// SSH.NET's call cannot itself be cancelled, so a cancellation stops the wait rather than the call. A
/// shell that arrives after nobody is waiting for it is closed as it lands rather than left holding a
/// channel on the server.
/// </para>
/// </remarks>
public async Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var effective = size.IsUsable ? size : TerminalSize.Default;
// 4 KiB read buffer inside SSH.NET. Output is coalesced a layer up, so a larger buffer here
// only delays the first byte reaching the screen.
var opening = Task.Run(
() => client.CreateShellStream(
"xterm-256color",
effective.Columns,
effective.Rows,
effective.PixelWidth,
effective.PixelHeight,
4096),
CancellationToken.None);
try
{
var shell = await opening.WaitAsync(cancellationToken).ConfigureAwait(false);
return new SshNetShellSession(shell, client);
}
catch (OperationCanceledException)
{
_ = CloseWhenItLandsAsync(opening);
throw;
}
}
/// <summary>Closes a shell nobody is waiting for any more, or lets its failure go unremarked.</summary>
/// <remarks>
/// A shell that failed to open after its caller gave up has nobody to tell, and the connection it was
/// on reports its own death through the session that owns it. The failures are the ones SSH.NET raises
/// for a channel or a connection that went away, named so that anything else still surfaces.
/// </remarks>
private static async Task CloseWhenItLandsAsync(Task<ShellStream> opening)
{
ShellStream shell;
try
{
shell = await opening.ConfigureAwait(false);
}
catch (Exception exception) when (exception is SshException
or System.Net.Sockets.SocketException
or ObjectDisposedException
or InvalidOperationException)
{
return;
}
await shell.DisposeAsync().ConfigureAwait(false);
}
/// <inheritdoc />
public Task<ISshPortForward> StartForwardAsync(SshPortForwardSpec spec, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(spec);
return SshNetPortForward.StartAsync(client, spec, cancellationToken);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
client.Dispose();
if (proxyLifetime is not null)
{
await proxyLifetime.DisposeAsync().ConfigureAwait(false);
}
}
}
/// <summary>An SSH.NET-backed shell session.</summary>
internal sealed class SshNetShellSession : ISshShellSession
{
private readonly ShellStream shell;
private readonly SshClient client;
private string? transportFailure;
/// <param name="shell">The channel this shell reads and writes.</param>
/// <param name="client">
/// The connection underneath it, subscribed to for the one thing the stream cannot report on its own —
/// see <see cref="TransportFailure"/>.
/// </param>
internal SshNetShellSession(ShellStream shell, SshClient client)
{
this.shell = shell;
this.client = client;
client.ErrorOccurred += OnTransportError;
}
/// <inheritdoc />
/// <remarks>
/// <c>CanWrite</c> rather than <c>CanRead</c>, which is what this used to read and which is a constant:
/// <c>ShellStream.CanRead</c> is documented as always true — it stays readable after disposal so that
/// buffered output can still be drained — so this property answered "yes" for every shell that had ever
/// existed. <c>CanWrite</c> is the one that goes false when the stream is disposed, which is what closing
/// the channel and losing the session both do.
/// </remarks>
public bool IsOpen => shell.CanWrite;
/// <inheritdoc />
/// <remarks>
/// Written on SSH.NET's message-listener thread and read on the pump's, which is why it is a
/// <see cref="Volatile"/> pair rather than a plain field: the two threads are ordered by the wake-up the
/// interface's own remark describes, but nothing in the language says so, and a torn or cached read here
/// would report a clean exit for a connection that had just died.
/// </remarks>
public string? TransportFailure => Volatile.Read(ref transportFailure);
/// <summary>
/// Latches the first transport failure and ends the read that is waiting on a connection which is
/// no longer there.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>THE SECOND HALF OF THIS IS WHY A REBOOTED MACHINE USED TO HANG FOR EVER, AND IT IS A DEFECT IN
/// SSH.NET RATHER THAN IN ANYTHING ABOVE IT.</b> <c>ShellStream</c> ends a blocked read from exactly two
/// places: its channel being closed, and the session raising <c>Disconnected</c>. A shell that exits
/// gives the first. But <c>Session</c> raises <c>Disconnected</c> from one method only —
/// <c>OnDisconnectReceived</c>, for a server that politely says goodbye — and a socket that simply fails
/// goes down a different path: it raises the error event, sets its disconnecting flag and disposes the
/// socket, without ever raising <c>Disconnected</c>. So the stream was never disposed, its
/// <c>Monitor.Wait</c> was never pulsed, and the read blocked until the process ended. The transport was
/// gone, the client knew the transport was gone, and the terminal went on looking connected.
/// </para>
/// <para>
/// Keep-alive is what makes the failure <em>happen</em> on an idle connection; this is what makes it
/// <em>arrive</em>. Neither is sufficient alone, which is why both are in this file.
/// </para>
/// <para>
/// <b>Queued rather than disposed inline, and that is not tidiness.</b> This runs on SSH.NET's own
/// message-listener thread. Closing a channel waits for the server's close to come back — which is
/// processed by that same thread — so disposing here would be the listener thread waiting on itself, and
/// the best case is that it unwinds when the wait times out.
/// </para>
/// <para>
/// The latch is set before the queue, so <see cref="TransportFailure"/> is already true by the time the
/// read it releases can look at it. That ordering is the whole of the promise this property makes.
/// </para>
/// </remarks>
private void OnTransportError(object? sender, ExceptionEventArgs e)
{
Interlocked.CompareExchange(ref transportFailure, e.Exception.Message, comparand: null);
ThreadPool.UnsafeQueueUserWorkItem(
static state =>
{
try
{
state.Dispose();
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Best-effort. The connection is already gone; what matters is that the read was
// released, and a fault tearing down a dead channel has no caller to report it to.
}
},
shell,
preferLocal: false);
}
/// <inheritdoc />
/// <remarks>
/// <c>ShellStream</c> does not override <c>ReadAsync</c>, so the base <see cref="Stream"/>
/// implementation runs the blocking read on a thread-pool thread. Every idle session therefore
/// parks one thread; see docs/platform-flags.md.
/// </remarks>
public ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken) =>
shell.ReadAsync(buffer, cancellationToken);
/// <inheritdoc />
/// <remarks>
/// The flush is mandatory, not an optimisation. <c>ShellStream.Write</c> accumulates into an
/// internal buffer and sends nothing until flushed, so without this a keystroke is accepted,
/// reported as written, and never reaches the remote — the terminal simply stops responding to
/// input while still displaying output perfectly. SSH.NET's own <c>WriteLine</c> flushes for this
/// reason, which is why the spike tests never hit it.
/// <para>
/// Flushed per write rather than batched: a terminal has to put a keystroke on the wire
/// immediately, and there is nothing to coalesce — a human types far below any rate at which
/// batching would matter.
/// </para>
/// </remarks>
public async ValueTask WriteAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
{
await shell.WriteAsync(data, cancellationToken).ConfigureAwait(false);
await shell.FlushAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public void Resize(TerminalSize size)
{
if (!size.IsUsable)
{
// A collapsed pane or a minimised window produces these. Forwarding one leaves the
// remote's idea of the terminal nonsensical until the next resize arrives.
return;
}
shell.ChangeWindowSize(size.Columns, size.Rows, size.PixelWidth, size.PixelHeight);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
// Unsubscribed before the stream goes, so a connection that outlives this shell — the workspace
// disposes the two in that order — is not still holding a reference to it through its own event.
client.ErrorOccurred -= OnTransportError;
await shell.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>Convenience helpers over a shell session.</summary>
public static class SshShellSessionExtensions
{
/// <summary>Writes UTF-8 text to the remote.</summary>
public static ValueTask WriteTextAsync(
this ISshShellSession session,
string text,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(session);
return session.WriteAsync(Encoding.UTF8.GetBytes(text), cancellationToken);
}
}