SSH: open a shell off the caller's thread, and stop waiting when the caller gives up

This commit is contained in:
jaap-jan committed 2026-10-02 14:02:18 +02:00
1 parent 365954c541
commit f997e87c5f
2 files changed
+99 -9

No files matched your search

@@ -468,7 +468,21 @@ internal sealed class SshNetConnection(
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
/// <inheritdoc />
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
/// <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();
@@ -476,15 +490,53 @@ internal sealed class SshNetConnection(
// 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 shell = client.CreateShellStream(
"xterm-256color",
effective.Columns,
effective.Rows,
effective.PixelWidth,
effective.PixelHeight,
4096);
var opening = Task.Run(
() => client.CreateShellStream(
"xterm-256color",
effective.Columns,
effective.Rows,
effective.PixelWidth,
effective.PixelHeight,
4096),
CancellationToken.None);
return Task.FromResult<ISshShellSession>(new SshNetShellSession(shell, client));
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 />