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
KeepAliveCountMaxintervals;ssh -o ServerAliveIntervalsees the same. Against it, leaveKeepAliveIntervalunset 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'sChannelTimeout).