Table of Contents

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. ExitStatus and ExitSignal are set when the program ends; WaitForExitAsync waits 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. StreamReader decodes UTF-8 by default; pass another Encoding if the program prints something else.
  • Lines that never end. A progress bar that redraws with \r never completes a line; read raw bytes for such output.

See also