Skip to content

A .NET library simplifying the execution and chaining of processes

License

Notifications You must be signed in to change notification settings

madelson/MedallionShell

Repository files navigation

MedallionShell

MedallionShell vastly simplifies working with processes in .NET.

Download the NuGet package NuGet Status (Release notes). There is also a strong-named release NuGet Status

With MedallionShell, running a process is as simple as:

Command.Run("git", "commit", "-m", "critical bugfix").Wait();

Why MedallionShell?

.NET ships with the powerful System.Diagnostics.Process class built in. However, the Process API is clunky to use and there are many pitfalls which must be accounted for even in basic scenarios. MedallionShell is built on top of Process and focuses on streamlining common use-cases while eliminating or containing traps so that things "just work" as much as possible.

Here are some of the things the library takes care of for you:

  • Clean integration with async/await and Task
  • Piping standard IO streams to and from various sources without creating deadlocks or race conditions
  • Properly escaping process arguments (a common source of security vulnerabilities)
  • Being able to recover from hangs through timeout, CancellationToken, and safe kill, and signal support
  • Cross-platform support (e. g. signals and workarounds for Mono oddities #6, #22, #43, and #44)

API Overview

Commands

The Command class represents an executing process:

// create a command via Command.Run
var command = Command.Run("executable", "arg1", "arg2", ...);

// wait for it to finish
command.Wait(); // or...
var result = command.Result; // or...
result = await command.Task;

// inspect the result
if (!result.Success)
{
	Console.Error.WriteLine($"command failed with exit code {result.ExitCode}: {result.StandardError}");
}

The Command.Task property means that you can easily compose the Command's execution with other Task-based async operations. You can terminate a Command by invoking its Kill() method.

Most APIs create a Command instance by starting a new process. However, you can also create a Command from an existing process via the Command.TryAttachToProcess API.

Standard IO

One of the main ways to interact with a process is via its standard IO streams (in, out and error). By default, MedallionShell configures the process to enable these streams and captures standard error and standard output in the Command's result:

var command = Command.Run(...);
var result = await command.Task;
Console.WriteLine($"{result.StandardOutput}, {result.StandardError}");

If you want to consume the output (stdout and stderr) as a merged stream of lines like you would see in the console, you can use the GetOutputAndErrorLines() method:

var command = Command.Run(...);
foreach (var line in command.GetOutputAndErrorLines())
{
    Console.WriteLine(line);
}

Additionally/alternatively, you can interact with these streams directly via the Command.StandardInput, Command.StandardOutput, and Command.StandardError properties. As with Process, these are TextWriter/TextReader objects that also expose the underlying Stream, giving you the option of writing/reading either text or raw bytes:

var command = Command.Run(...);
command.StandardInput.Write("some text"); // e.g. write as text
command.StandardInput.BaseStream.Write(new byte[100]); // e.g. write as bytes
command.StandardOutput.ReadLine(); // e.g. read as text
command.StandardError.BaseStream.Read(new byte[100]); // e.g. read as bytes

The standard IO streams also contain methods for piping to and from common sinks and sources, including Streams, TextReader/Writers, files, and collections. For example:

command.StandardInput.PipeFromAsync(new FileInfo("input.csv")); // pipes in all bytes from input.csv
var outputLines = new List<string>();
command.StandardOutput.PipeToAsync(outputLines); // pipe output text to a collection

You can also express piping directly on the Command object. This returns a new Command instance which represents both the underlying process execution and the IO piping operation, providing one thing you can await to know when everything has completed. You can even use this feature to chain together commands (like the | operator on the command line).

await Command.Run("processingStep1.exe")
	.RedirectFrom(new FileInfo("input.txt"))
	.PipeTo(Command.Run("processingStep2.exe"))
	.RedirectTo(new FileInfo("output.txt"));
	
// alternatively, this can be expressed with operators as on the command line
await Command.Run("ProcssingStep1.exe") < new FileInfo("input.txt")
	| Command.Run("processingStep2.exe") > new FileInfo("output.text");

Finally, note that **any content you read directly will not end up in the result; the result.StandardOutput and result.StandardError properties store only content that you have not already consumed via some other mechanism.

Stopping a Command

You can immediately terminate a command with the Kill() API. You can also use the TrySignalAsync API to send other types of signals which can allow for graceful shutdown if the target process handles them. CommandSignal.ControlC works across platforms, while other signals are OS-specific.

Command Options

When constructing a Command, you can specify various options to provide additional configuration:

Command.Run("foo.exe", new[] { "arg1" }, options => options.ThrowOnError()...);

The supported options are:

Option Description Default
ThrowOnError If true, the command will throw an exception if the underlying process returns a non-zero exit code rather than returning a failed result false
WorkingDirectory Sets the initial working directory for the process Environment.CurrentDirectory
CancellationToken Specifies a CancellationToken which will kill the process if canceled CancellationToken.None
Timeout Specifies a time period after which the process will be killed Timeout.Infinite
StartInfo Specifies arbitrary additional configuration of the ProcessStartInfo object
DisposeOnExit If true, the underlying Process object will be disposed when the process exits, removing the need to call Command.Dispose() true
EnvironmentVariable(s) Specifies environment variable overrides for the process Environment.GetEnvironmentVariables()
Encoding Specifies an Encoding to be used on all standard IO streams Console.OutputEncoding/Console.InputEncoding: note that what this is varies by platform!
Command Specifies arbitrary additional configuration of the Command object after it is created (generally only useful with Shells, which are described below)

Shells

It is frequently the case that within the context of a single application all the Commands you invoke will want the same or very similar options. To simplify this, you can package up a set of options in a Shell object for convenient re-use:

private static readonly Shell MyShell = new Shell(options => options.ThrowOnError().Timeout(...)...);

...

var command = MyShell.Run("foo.exe", new[] { "arg1", ... }, options => /* can still override/specify further options */);

Strong naming

MedallionShell 1.x is not strong-named. In 1.x, a parallel strong-named package MedallionShell.StrongName is maintained alongside with identical contents.

This package is published from the strong-name branch.

Contributing

Contributions are welcome! Please report any issues you encounter or ideas for enhancements. If you would like to contribute code, I ask that you file an issue first so that we can work out the details before you start coding and avoid wasted effort on your part.

To build the code, you will need VisualStudio 2019 or higher (community edition is fine) download. Running all tests will require that you have installed Mono (for the Mono compat tests only).

Windows: Build status Linux: Build Status

Release Notes

  • 1.6.2
    • Add net471 build as workaround for #75. Thanks Cloudmersive for reporting the issue and testing the fix!
  • 1.6.1
    • Strong-named release MedallionShell.StrongName (#65). Thanks ldennington!
    • Fixes transient error in signaling on Windows machines with slow disks (#61)
    • Reduces dependency footprint for .NET Standard 2.0 (#56)
    • Improves error messaging when trying to access the standard IO streams of Commands when those streams have been piped elsewhere (#59, #41)
    • Adds C#8 nullable reference type annotations
  • 1.6.0
    • Adds Command.TryAttachToProcess API for creating a Command attached to an already-running process (#30). Thanks konrad-kruczynski for coming up with the idea and implementing!
    • Adds Command.TrySignal API which provides cross-platform support for the CTRL+C (SIGINT) signal as well as support for OS-specific signals (#35)
    • Properly escape command line arguments when running under Mono on Unix. With this change, the default behavior should work across all platforms (#44)
    • Make StandardInput.Dispose() work properly when running under Mono on Unix (#43)
    • Add .NET Standard 2.0 and .NET 4.6 build targets so that users of more modern frameworks can take advantage of more modern APIs. The .NET Standard 1.3 and .NET 4.5 targets will likely be retired in the event of a 2.0 release.
    • Allow for setting piping and redirection via a Shell option with the new Command(Func<Command, Command>) option (#39)
    • Add CI testing for Mono and .NET Core on Linux
  • 1.5.1 Improves Mono.Android compatibility (#22). Thanks sushihangover for reporting the issue and testing the fix!
  • 1.5.0
    • Command overrides ToString() to simplify debugging (#19). Thanks Stephanvs!
    • WindowsCommandLineSyntax no longer quotes arguments that don't require it
  • 1.4.0
    • Adds cancellation support (#18)
    • Adds API for getting the underlying process ID for a command even with the DisposeOnExit option (#16)
    • Adds API for consuming standard out and standard error lines together as a single stream (#14)
    • Improves Mono compatibility (#6)
    • Changes Command.Result and Command.Wait() to throw unwrapped exceptions instead of AggregateException
  • 1.3.0 Fixes default standard IO stream encodings (thanks xjfnet!) and added support for specifying a custom encoding
  • 1.2.1 Adds .NET Core support (thanks kal!), adds new fluent APIs for each of the piping/redirection operators, and now respects StandardInput.AutoFlush when piping between commands
  • 1.1.0 Adds AutoFlush support to StandardInput, and fixed bug where small amounts of flushed data became "stuck" in the StandardOutput buffer
  • 1.0.3 Fixes bug with standard error (thanks nsdfxela!)
  • 1.0.2 Fixes bug where timeout would suppress errors from ThrowOnError option
  • 1.0.1 Allows for argument ommission in Command.Run(), other minor fixes
  • 1.0.0 Initial release