Table of Contents

Keep-alive and reconnecting

TCP connections die quietly: a NAT gateway forgets them, a VPN drops, a server reboots. Keep-alive notices a dead connection within a bounded time, and ConnectionClosed tells your code. SshSharp never reconnects behind your back: a connection that ended stays ended, and your code (or the dependency injection package) opens a new one.

When to use

  • Long-lived connections: services, workers, forwarders, anything that holds a connection for minutes or more.
  • Connections through firewalls or NAT that drop idle flows.

Turn on keep-alive

await using var client = await SshClient.ConnectAsync("example.com", 22, new SshClientOptions
{
    UserName = "deploy",
    PrivateKeys = await LoadKeysAsync(cancellationToken),
    HostKeyVerifier = verifier,
    KeepAliveInterval = TimeSpan.FromSeconds(15),  // ServerAliveInterval
    KeepAliveCountMax = 3,                         // ServerAliveCountMax: dead after about 45 s of silence
}, cancellationToken);

// Fires once, whatever ends the connection: the server, the network, missed keep-alives, or DisposeAsync.
client.ConnectionClosed.Register(() => Console.WriteLine($"Disconnected: {client.ConnectionError?.Message ?? "disposed"}"));

After KeepAliveInterval without traffic from the server the client sends a probe (OpenSSH's ping@openssh.com where the server announces it, keepalive@openssh.com otherwise); any packet from the server counts as an answer. After KeepAliveCountMax intervals without one, the connection fails with SshFailureReason.Timeout. The probes do not wait behind pending writes, so a server that stops reading is detected too.

With ssh_config, ServerAliveInterval and ServerAliveCountMax do the same when connecting by alias.

Check a connection on demand

using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(5));       // a silent server would otherwise be waited for until the connection ends
var roundTrip = await client.PingAsync(timeout.Token);
Console.WriteLine($"alive, {roundTrip.TotalMilliseconds:F1} ms");

IsConnected is a cheap local check; PingAsync proves the server answers, and measures the round trip.

Stop work when the connection ends

client.ConnectionClosed is a CancellationToken cancelled when the connection ends for any reason. Link it with your own token so reads and waits stop at once instead of failing one by one:

// Stop work when either the caller gives up or the connection ends.
using var both = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken, client.ConnectionClosed);
await using var session = await client.StartCommandAsync("tail -F /var/log/app.log", cancellationToken: both.Token);
using var reader = new StreamReader(session.StandardOutput);
while (await reader.ReadLineAsync(both.Token) is { } line)
{
    Console.WriteLine(line);
}

Reconnect

flowchart LR
    get(["GetAsync"]) --> check{"current client<br/>IsConnected?"}
    check -- yes --> use(["use it"])
    check -- no --> connect["dispose it,<br/>ConnectAsync"]
    connect -- success --> use
    connect -- failure --> wait["wait<br/>(doubling, capped)"]
    wait --> connect

In an application with dependency injection, ISshConnectionProvider does exactly this, with a backoff after failures and one attempt shared by concurrent callers; see ASP.NET Core and dependency injection. Without it, a small wrapper is enough:

/// <summary>One connection, opened on first use and opened again after it drops. Safe to share between tasks.</summary>
internal sealed class ReconnectingClient(Func<CancellationToken, Task<SshClient>> connect) : IAsyncDisposable
{
    private readonly SemaphoreSlim _gate = new(1, 1);
    private SshClient? _client;

    public async Task<SshClient> GetAsync(CancellationToken cancellationToken)
    {
        if (_client is { IsConnected: true } current)
        {
            return current;
        }

        await _gate.WaitAsync(cancellationToken);
        try
        {
            if (_client is { IsConnected: true } other)  // another caller reconnected meanwhile
            {
                return other;
            }

            if (_client is not null)
            {
                await _client.DisposeAsync();
                _client = null;
            }

            return _client = await connect(cancellationToken);
        }
        finally
        {
            _gate.Release();
        }
    }

    public async ValueTask DisposeAsync()
    {
        if (_client is not null)
        {
            await _client.DisposeAsync();
        }

        _gate.Dispose();
    }
}

Then retry the work, not the connection: only you know whether an operation is safe to run twice.

// Retry what is safe to run twice, with a growing delay. A command that failed because the connection dropped may
// or may not have run on the server: only you know whether running it again is harmless.
var delay = TimeSpan.FromSeconds(1);
for (var attempt = 1; ; attempt++)
{
    try
    {
        var client = await connection.GetAsync(cancellationToken);
        var result = await client.RunCommandAsync("cat /proc/loadavg", cancellationToken);
        Console.WriteLine(result.StandardOutputText);
        return;
    }
    catch (SshException ex) when (attempt < 5 && ex.Reason is SshFailureReason.ConnectFailed or SshFailureReason.ConnectionClosed
        or SshFailureReason.Timeout)
    {
        await Task.Delay(delay, cancellationToken);
        delay = TimeSpan.FromTicks(Math.Min(delay.Ticks * 2, TimeSpan.FromSeconds(30).Ticks));
    }
}

Notes and pitfalls

  • Nothing is retried for you. A command that was running when the connection dropped fails with ConnectionClosed. It may or may not have run on the server.
  • Sessions, SFTP clients and forwarders die with their connection. After reconnecting, open them again.
  • OpenSSH for Windows. The server built into Windows (OpenSSH for Windows 9.5) often fails to answer keep-alives, so connections to it end after KeepAliveCountMax intervals; ssh -o ServerAliveInterval sees the same. Against it, leave KeepAliveInterval unset or use a long interval with a generous count.
  • Idle channels. To close channels (not the connection) that carry no data for a while, use SshClientOptions.ChannelTimeouts (OpenSSH's ChannelTimeout).

See also