Previous: Comparison and migration guide
ProcessKit for .NET and ProcessKit-rs share the same contract: async process
execution, typed failures, honest non-zero outcomes, whole-tree containment, and
an injectable runner seam. The spelling follows each ecosystem. This guide maps
the concepts without pretending that Rust ownership and .NET disposal are the
same mechanism.
| ProcessKit-rs | ProcessKit for .NET | Result |
|---|---|---|
Command::run().await |
Command.RunAsync() |
accepted exit required; trimmed stdout |
Command::output_string().await |
Command.OutputStringAsync() |
full ProcessResult<string>; non-zero exit remains data |
Command::output_bytes().await |
Command.OutputBytesAsync() |
full ProcessResult<byte[]> |
Command::exit_code().await |
Command.ExitCodeAsync() |
exit code; a timeout or signal is an error |
Command::probe().await |
Command.ProbeAsync() |
0 → true, 1 → false, any other outcome is an error |
Command::start().await |
Command.StartAsync() |
live process for streaming, stdin, and readiness waits |
The most important distinction survives the language change: output_string /
OutputStringAsync captures a non-zero exit in the result, while run /
RunAsync promotes an unaccepted exit to a typed error.
The Rust blocks on this page are non-compiled illustrations; the repository's snippet harness compiles every paired F# and C# block against the current .NET API.
let output = Command::new("git")
.arg("status")
.output_string()
.await?;
println!("code={:?} {}", output.code(), output.stdout());F#
task {
let command = Command.create "git" |> Command.arg "status"
match! command.OutputStringAsync() with
| Ok output -> printfn $"code={output.Code} {output.Stdout}"
| Error err -> eprintfn $"{err.Message}"
}C#
var command = new Command("git").Arg("status");
Console.WriteLine(await command.OutputStringAsync() switch
{
{ IsOk: true, ResultValue: var output } => $"code={output.Code} {output.Stdout}",
{ IsOk: false, ErrorValue: var err } => $"error: {err.Message}",
});Rust makes teardown visible through ownership: dropping a RunningProcess or
ProcessGroup reaps its contained tree. .NET cannot attach correctness to the GC
lifetime, so the same deterministic boundary is IDisposable /
IAsyncDisposable. Bind the live handle with use in F# or using /
await using in C#. F# use! is the corresponding spelling when an async
factory returns the disposable directly; ProcessKit's built-in StartAsync
returns an honest Result, so match it first and then bind the successful handle
with use, as below. Do not leave containment to a finalizer.
let process = Command::new("server").start().await?;
// Dropping `process` tears down its private contained tree.F#
task {
match! (Command.create "server").StartAsync() with
| Error err -> eprintfn $"{err.Message}"
| Ok process ->
use process = process
match! process.WaitForLineAsync((fun line -> line.Contains "ready"), TimeSpan.FromSeconds 10.0) with
| Ok _ -> printfn "ready"
| Error err -> eprintfn $"{err.Message}"
}| Rust vocabulary | .NET vocabulary |
|---|---|
Result<T, processkit::Error> |
Task<Result<'T, ProcessError>> in F#; Task<FSharpResult<T, ProcessError>> in C# |
Error::reason() / ErrorReason |
pattern-match the ProcessError discriminated union |
ProcessResult<T> |
ProcessResult<'T> with Outcome, Code, Stdout, and Stderr |
ErrorReason::Unsupported |
ProcessError.Unsupported |
ErrorReason::NotFound |
ProcessError.NotFound |
ErrorReason::Cancelled |
ProcessError.Cancelled |
Both libraries keep a non-zero exit as data for capture verbs and make
cancellation an error. Match the structured case; do not parse Message.
Rust's Command::cancel_on(CancellationToken) maps directly to
Command.CancelOn(CancellationToken) / Command.cancelOn. Every consuming .NET
verb also accepts a call-scoped CancellationToken; CancelOn is useful when a
preconfigured command or CliClient carries its own lifetime.
let output = Command::new("worker")
.cancel_on(shutdown.child_token())
.output_string()
.await?;F#
task {
use shutdown = new CancellationTokenSource(TimeSpan.FromSeconds 30.0)
let command =
Command.create "worker"
|> Command.cancelOn shutdown.Token
let! result = command.OutputStringAsync()
return result
}Rust uses Cargo features to keep optional dependencies and platform surfaces out of a build. NuGet has no equivalent compile-time feature gate, so the core .NET package exposes production capabilities directly and isolates optional concerns in side packages.
| ProcessKit-rs feature/concept | ProcessKit for .NET |
|---|---|
default process control, stats, limits, record, pty |
core modules are available without feature flags; record/replay lives in ProcessKit.Testing |
tracing |
optional ILogger; ProcessKitDiagnostics.ActivitySource for traces |
metrics |
ProcessKitDiagnostics.Meter |
mock / ProcessRunner |
IProcessRunner plus ScriptedRunner, FakeProcess, and RecordReplayRunner in ProcessKit.Testing |
| application DI wiring | ProcessKit.Extensions.DependencyInjection |
| hosted supervision | ProcessKit.Extensions.Hosting |
The core remains DI-friendly rather than DI-coupled: production code can accept
the plain IProcessRunner interface without referencing a container package.
| Concern | Rust | .NET |
|---|---|---|
| Text encoding | per-stream encoding configuration, with raw byte capture when text is the wrong abstraction | Command.Encoding, StdoutEncoding, StderrEncoding, StdinEncoding, or OutputBytesAsync |
| PTY | Cargo pty feature plus Command::use_pty |
Command.Pty(PtyConfig) and PtySession; unsupported platform details are typed |
| Streaming | RunningProcess streams and waits |
RunningProcess exposes IAsyncEnumerable lines/events and readiness waits |
| Supervision | Supervisor restart/backoff policy |
Supervisor in the core package; hosted lifetime wiring in ProcessKit.Extensions.Hosting |
| Observability | tracing and metrics feature adapters |
ILogger, ActivitySource, and Meter, all secret-safe by contract |
The names differ, but the design test remains the same: select a capability explicitly, inspect a typed unsupported result when the platform cannot provide it, and keep the process tree owned by a deterministic lifetime.
Code that accepts Rust's &dyn ProcessRunner should usually accept .NET's
IProcessRunner. In tests, ScriptedRunner is the stable default on both sides;
the .NET version additionally provides FakeProcess for live streaming and
RecordReplayRunner for cassettes in the same ProcessKit.Testing package.
C#
IProcessRunner runner =
new ScriptedRunner().On(["git", "status"], Reply.Ok("clean"));
var result = await runner.RunAsync(new Command("git").Arg("status"));
Console.WriteLine(result);See Testing your code for structural invocation journals, streaming doubles, and cassette matching.
Next: Cookbook