Stream large output
SshClient.StartCommandAsync starts a command and returns an SshSession whose StandardInput, StandardOutput and
StandardError are streams. You read output as it arrives and write input while the program runs, with nothing collected in memory.
When to use
- The output is large (a database dump, a log archive) or endless (
tail -f,journalctl -f). - You want to show progress, or act on each line as it arrives.
- The program reads its standard input (
gzip -c,psql,tar -x).
For a short command whose output fits in memory, RunCommandAsync is simpler.
Read lines as they arrive
await using var session = await client.StartCommandAsync("journalctl -f -u myapp", cancellationToken: cancellationToken);
using var reader = new StreamReader(session.StandardOutput);
while (await reader.ReadLineAsync(cancellationToken) is { } line)
{
Console.WriteLine(line);
}
Read stdout and stderr together
Read both streams at the same time. SSH flow control gives each channel a window (2 MiB here); output that nobody reads fills it,
and then the server stops sending on both streams, so a program that writes a lot to stderr while you wait on stdout stalls.
This is the same rule as for redirected Process streams.
await using var session = await client.StartCommandAsync("make test", cancellationToken: cancellationToken);
// Read both streams at the same time: a stream nobody reads fills its window, and then the server stops sending on both.
var stdout = PumpAsync(session.StandardOutput, Console.Out, cancellationToken);
var stderr = PumpAsync(session.StandardError, Console.Error, cancellationToken);
await Task.WhenAll(stdout, stderr);
await session.WaitForExitAsync(cancellationToken);
Console.WriteLine($"make exited with {session.ExitStatus}");
static async Task PumpAsync(Stream source, TextWriter target, CancellationToken cancellationToken)
{
using var reader = new StreamReader(source);
while (await reader.ReadLineAsync(cancellationToken) is { } line)
{
await target.WriteLineAsync(line);
}
}
Save output of any size to a file
// A database dump of any size, straight to disk: nothing is held in memory.
await using var session = await client.StartCommandAsync("pg_dump --format=custom appdb", cancellationToken: cancellationToken);
await using (var file = File.Create("appdb.dump"))
{
var errors = new StreamReader(session.StandardError).ReadToEndAsync(cancellationToken);
await session.StandardOutput.CopyToAsync(file, cancellationToken);
Console.Error.Write(await errors);
}
await session.WaitForExitAsync(cancellationToken);
if (session.ExitStatus != 0)
{
throw new InvalidOperationException($"pg_dump failed with exit status {session.ExitStatus}.");
}
ExitStatus is known once WaitForExitAsync returns. Check it: a dump that failed halfway still produced a file.
Send input
Write to StandardInput, then call CloseInputAsync to send end-of-file, as closing a pipe does. Programs that read until
end-of-file (gzip, sort, tar -x) finish only then.
await using var session = await client.StartCommandAsync("gzip -c > /tmp/backup.tar.gz", cancellationToken: cancellationToken);
await using (var source = File.OpenRead("backup.tar"))
{
await source.CopyToAsync(session.StandardInput, cancellationToken);
}
await session.CloseInputAsync(cancellationToken); // end of input: gzip finishes and exits
await session.WaitForExitAsync(cancellationToken);
Stop early
await using var session = await client.StartCommandAsync("find / -name '*.log'", cancellationToken: cancellationToken);
using var reader = new StreamReader(session.StandardOutput);
var first = new List<string>();
while (first.Count < 10 && await reader.ReadLineAsync(cancellationToken) is { } line)
{
first.Add(line);
}
// Enough: ask the program to stop. Disposing the session (at the end of the block) closes the channel either way.
await session.SendSignalAsync("TERM", cancellationToken);
Disposing StandardOutput while the program still runs tells an OpenSSH server to stop sending output (eow@openssh.com), so it
is not sent only to be dropped; the program may then get SIGPIPE on its next write, as yes | head does.
Notes and pitfalls
- Read what you start. An unread stderr is the most common cause of a stalled command. If you do not need it, drain it:
_ = session.StandardError.CopyToAsync(Stream.Null). - Exit status.
ExitStatusandExitSignalare set when the program ends;WaitForExitAsyncwaits for that. - Disposing. Disposing the session closes the channel. A program still running then loses its input and output (with a terminal, it gets a hangup).
- Text encoding. The streams carry bytes.
StreamReaderdecodes UTF-8 by default; pass anotherEncodingif the program prints something else. - Lines that never end. A progress bar that redraws with
\rnever completes a line; read raw bytes for such output.
See also
- Run a command
- Interactive shells and SshExpect
- Channels and flow control
- SshSession in the API reference