Decision
CliWrap was trialled in Fallout.Migrate and rejected. Process execution stays on the in-house ProcessTasks (src/Fallout.Tooling/ProcessTasks.cs). No new package dependency.
Trialled and reverted in #576, which needed to shell out to dotnet tool uninstall / dotnet tool install from SwitchGlobalToolStep.
What the comparison showed
Both versions were built and run side by side against dotnet tool list --global, a deliberately failing dotnet tool uninstall, and a 1 ms timeout. Identical exit codes, identical stdout, identical stderr, and both return null on timeout. The difference is only in what the calling code costs to write.
|
Lines (non-blank) |
| CliWrap |
18 |
Raw System.Diagnostics.Process, written correctly |
47 |
ProcessTasks (what we shipped) |
31 |
With CliWrap:
using var cancellation = new CancellationTokenSource(commandTimeout);
return await Cli.Wrap("dotnet")
.WithArguments(arguments)
.WithValidation(CommandResultValidation.None)
.ExecuteBufferedAsync(cancellation.Token);
With ProcessTasks:
var argumentLine = arguments.Select(x => x.DoubleQuoteIfNeeded()).JoinSpace();
using var process = ProcessTasks.StartProcess(
"dotnet", argumentLine, timeout: (int)commandTimeout.TotalMilliseconds,
logOutput: false, logInvocation: false);
if (process == null || !process.WaitForExit())
return null;
return new CommandResult(
process.ExitCode,
Join(process.Output, OutputType.Std),
Join(process.Output, OutputType.Err));
What CliWrap actually solves
Worth recording, because these are the traps a hand-rolled runner falls into:
- The pipe-buffer deadlock. The obvious raw implementation is
WaitForExit() then StandardOutput.ReadToEnd(). That hangs once the child fills the ~64KB pipe buffer while the parent is blocked in WaitForExit. Avoiding it needs event-based reads plus counting two EOF sentinels. ProcessTasks already handles this, which is the main reason it was a viable replacement.
- Cancellation that actually kills.
WaitForExitAsync(token) returns on timeout but leaves the child running. An explicit Kill(entireProcessTree: true) is required or a timeout orphans the process.
- A single result object carrying exit code plus both streams.
What CliWrap does not solve any more
Its reputation is dated on two points:
- Argument escaping —
ProcessStartInfo.ArgumentList has handled quoting since .NET Core 2.1.
- Async —
WaitForExitAsync has been in the BCL since .NET 5.
What using ProcessTasks instead costs
Honest list, since these are the gaps a future improvement would close:
- No
CancellationToken. Only an int millisecond timeout.
- Synchronous only. No
WaitForExitAsync, so a step that is otherwise async has to wrap it.
- Arguments are one pre-quoted string. No
ArgumentList equivalent, so every caller does its own DoubleQuoteIfNeeded(). This is the one place the raw BCL is now better than our own layer.
- Output is one interleaved collection filtered by
OutputType, rather than separate stdout and stderr.
- Logs through Serilog by default. A tool that renders through Spectre must pass
logOutput: false, logInvocation: false.
- Asserts instead of returning null when the executable is not on
PATH, so callers need a try/catch.
- Pulls
Serilog and NuGet.Packaging into anything referencing Fallout.Tooling. For Fallout.Migrate this swapped one direct dependency for two transitive ones.
Acceptance criteria
Notes
Decision
CliWrapwas trialled inFallout.Migrateand rejected. Process execution stays on the in-houseProcessTasks(src/Fallout.Tooling/ProcessTasks.cs). No new package dependency.Trialled and reverted in #576, which needed to shell out to
dotnet tool uninstall/dotnet tool installfromSwitchGlobalToolStep.What the comparison showed
Both versions were built and run side by side against
dotnet tool list --global, a deliberately failingdotnet tool uninstall, and a 1 ms timeout. Identical exit codes, identical stdout, identical stderr, and both return null on timeout. The difference is only in what the calling code costs to write.System.Diagnostics.Process, written correctlyProcessTasks(what we shipped)With CliWrap:
With
ProcessTasks:What CliWrap actually solves
Worth recording, because these are the traps a hand-rolled runner falls into:
WaitForExit()thenStandardOutput.ReadToEnd(). That hangs once the child fills the ~64KB pipe buffer while the parent is blocked inWaitForExit. Avoiding it needs event-based reads plus counting two EOF sentinels.ProcessTasksalready handles this, which is the main reason it was a viable replacement.WaitForExitAsync(token)returns on timeout but leaves the child running. An explicitKill(entireProcessTree: true)is required or a timeout orphans the process.What CliWrap does not solve any more
Its reputation is dated on two points:
ProcessStartInfo.ArgumentListhas handled quoting since .NET Core 2.1.WaitForExitAsynchas been in the BCL since .NET 5.What using
ProcessTasksinstead costsHonest list, since these are the gaps a future improvement would close:
CancellationToken. Only anintmillisecond timeout.WaitForExitAsync, so a step that is otherwise async has to wrap it.ArgumentListequivalent, so every caller does its ownDoubleQuoteIfNeeded(). This is the one place the raw BCL is now better than our own layer.OutputType, rather than separate stdout and stderr.logOutput: false, logInvocation: false.PATH, so callers need atry/catch.SerilogandNuGet.Packaginginto anything referencingFallout.Tooling. ForFallout.Migratethis swapped one direct dependency for two transitive ones.Acceptance criteria
ProcessTasks(or its successor) accepts aCancellationTokenalongside the millisecond timeout.DoubleQuoteIfNeeded().PATHis reportable without atry/catcharound the call.docs/dependencies.mdkeeps pointing here as the reason there is no third-party process runner.Notes