Use this guide to identify the root cause of a CliInvoke failure by category rather than by surface symptom. Each section names the failure mode, lists the most common causes observed in this codebase, and gives a concrete detection method.
CliInvoke exposes exactly three Resource-Owning Types
that hold unmanaged handles or sensitive memory: IExternalProcess,
UserCredential, and UserCredentialSpec. Every reported leak in this
library traces back to one of these three.
System.IO.IOException: Too many open files (Linux/macOS) or
ERROR_HANDLE_DISK_FULL / 0x80070005 Access Denied (Windows).ObjectDisposedException on StandardOutput, StandardError, or
StandardInput.SecureString content still present in
process memory after the configuration is logically done.IExternalProcess is not disposed. The invoker returns an
IExternalProcess from StartAsync; the caller owns the lifetime.
Wrap in await using (preferred) or call Dispose() in a finally
block.StandardInput stream too early. The StreamWriter you assign to
ProcessConfiguration.StandardInput is caller-owned — ProcessConfiguration does not dispose
it. Do not dispose it while the process is still reading from it; dispose it only after the
invocation completes. StandardOutput / StandardError on IExternalProcess are released when the
IExternalProcess is disposed.UserCredential, a standalone UserCredentialSpec, or a builder-owned UserCredentialSpec is not disposed.
All three hold a SecureString. A standalone UserCredential or a UserCredentialSpec you create
and own must be wrapped in using (the spec and the UserCredential it builds have independent
lifetimes). A UserCredentialSpec configured through ProcessConfigurationBuilder.ConfigureUserCredential
is owned and disposed by the builder, so do not dispose it yourself. A UserCredential assigned to
ProcessConfiguration.Credential is not disposed by the configuration — dispose it yourself.ProcessConfiguration is allowed. ProcessConfiguration is not disposable, so it can
be safely reused across invocations. Ensure any StandardInput stream or UserCredential you supplied
is not disposed until after the final invocation that references it.ProcessConfiguration into a closure that extends
beyond the invocation, leaking the SecureString until the closure
is collected.| Platform | Command | What to look for |
|---|---|---|
| Any | dotnet-counters monitor --process-id <pid> --counters System.Runtime |
Rising gc-handle-count or allocated-bytes with no plateau. |
| Any | dotnet-counters monitor --process-id <pid> --counters Microsoft.AspNetCore.Hosting (if hosted) |
Open file handles via the file-descriptors counter on .NET 8+. |
| Linux | ls /proc/<pid>/fd \| wc -l and ls -l /proc/<pid>/fd |
Handle count grows across invocations; pipes to defunct children. |
| macOS | lsof -p <pid> \| wc -l |
Same as Linux, including Unix domain sockets to children. |
| Windows | Process Explorer → View → Lower Pane View → Handles | Pipe handles with no matching Process close; File handles to cmd.exe / pwsh.exe. |
| Windows | Get-Process -Id <pid> \| Select-Object Handles, NonpagedSystemMemorySize64 |
Handles rising; nonzero ChildCount after parent exit. |
| Any | dotnet-gcdump collect -p <pid> then dotnet-gcdump analyze <file> |
Retained graph contains SecureString or StreamWriter after disposal should have run. |
For the canonical disposal contracts, see Resource Disposal.
CliInvoke selects control adapters, shells, and path-matching behavior
based on OperatingSystem.IsWindows() /
OperatingSystem.IsLinux() / OperatingSystem.IsMacOS(). Code that
worked on one OS may fail on another when the developer assumed a
specific platform behavior.
FileNotFoundException for an executable that exists on disk.Win32Exception on Linux for a process-control call.FilePathResolver uses MatchCasing.CaseSensitive on Unix; passing
/usr/local/bin/MyTool will not resolve to /usr/local/bin/mytool.
Verify exact case with which <name> (Unix) or where.exe <name>
(Windows).cmd, cmd.exe, and
Windows PowerShell's powershell.exe are not present on Unix. Use
CliInvoke.Specializations configurations: CmdProcessConfiguration
is Windows-only, but PowershellProcessConfiguration and
PowershellProcessInvoker resolve to pwsh (PowerShell Core) and
are supported on Windows, macOS, Mac Catalyst, Linux, and FreeBSD.
Note that the PowerShell team's own support for FreeBSD is
unofficial; CliInvoke does not require any PowerShell-side
guarantees beyond pwsh being installed and runnable on the
target host. For other Unix shells, supply the shell executable
explicitly.SIGSTOP is signal 17 on macOS and 19 on
Linux. UnixProcessControlAdapter already accounts for this; do not
hardcode signal numbers in caller code.ProcessExitBehaviour.ForcefulExit to attempt to terminate child
processes together with the parent — it does not guarantee that every
descendant is killed. The tree-kill is best-effort: descendants
spawned while the tree is being killed may survive. CliInvoke does
not add a post-kill delay or a second kill pass, matching
.NET's own Kill(entireProcessTree: true) semantics.UserCredential.Domain and
LoadUserProfile are Windows-only concepts; the
WindowsProcessControlAdapter ignores them on Unix and the value is
silently lost.Path.Combine or pass arguments as separate string[]
entries rather than concatenating with \.| Platform | Command | What to look for |
|---|---|---|
| Any | RuntimeInformation.IsOSPlatform(OSPlatform.Linux) in a scratch app |
Confirms runtime detection matches expectation. |
| Linux | which <executable> |
Missing executable; non-zero exit indicates the name is not on PATH. |
| Linux | test -x <path> && echo executable |
Confirms the execute bit is set. |
| Windows | where.exe <executable> |
PATH resolution matches the executable passed to ProcessConfiguration. |
| Linux | ps -o stat= -p <pid> |
Single-character state code; Z = zombie, T = stopped, R = running. No header to parse. |
| macOS | ps -o stat= -p <pid> |
Same as Linux. |
| Linux / macOS | pgrep -P <ppid> -o stat= |
Lists child PIDs of <ppid> and their state codes; identifies orphaned children. |
Supported platforms and their status are listed in
Supported Operating Systems. iOS, tvOS,
and Browser targets are not supported because System.Diagnostics.Process
is unavailable on those platforms.
CliInvoke is async-first. Most threading failures in caller code come
from misusing CancellationToken, mixing sync and async disposal, or
blocking on a task in a way that deadlocks under a captured
SynchronizationContext.
OperationCanceledException thrown even though cancellation was not
requested.TaskCanceledException wrapping a TimeoutException at an unexpected
point..Result / .Wait().AggregateException containing ObjectDisposedException after
await using completes.CancellationToken to invoker methods. Without a
token, cancellation has no effect; the invoker ignores
ProcessExitConfiguration.RequestedCancellationExitBehaviour until
the process exits on its own.ProcessTimeoutPolicy that elapses triggers cancellation. If
cancellationThrowsException: true is set, callers must catch
OperationCanceledException regardless of whether the user or the
timer caused it.IExternalProcess
implements IAsyncDisposable. In an async path, prefer await using; calling Dispose() synchronously from a thread-pool thread
can starve the disposal work..Result or .Wait() under a captured context.
WinForms, WPF, and legacy ASP.NET install a single-threaded
SynchronizationContext. The default await resumes on that
context, which is blocked. Fix with .ConfigureAwait(false) in
library code, or refactor the UI handler to await end-to-end.CancellationToken.None into a long-lived field. A
token captured at startup cannot be cancelled. Either accept the
token as a parameter or build a CancellationTokenSource whose
lifetime matches the operation.IExternalProcess.Dispose(). Cancelling after
the process is disposed raises ObjectDisposedException. Register
the cancellation callback via CancellationToken.Register and check
IsCancellationRequested before touching the process.| Tool | Command | What to look for |
|---|---|---|
dotnet-trace (cross-platform via EventPipe) |
dotnet-trace collect -p <pid> --providers Microsoft-DotNETCore:0x14C14FCC80 (Task / ThreadPool keywords) |
ThreadPool starvation; tasks queued but never scheduled. Works on Windows, Linux, and macOS. |
dotnet-counters |
dotnet-counters monitor -p <pid> --counters System.Threading |
threadpool-thread-count pinned at the minimum, queue-length rising. |
dotnet-dump |
dotnet-dump collect -p <pid> then clrthreads |
Threads in (GC) or (debugger) for long periods; deadlock suspected if all worker threads are blocked on the same Monitor. |
| ETW (Windows only) | dotnet-trace collect -p <pid> --providers Microsoft-Windows-DotNETRuntime:0x14C14FCC80:Verbose |
Task wait chains showing circular wait. The Microsoft-Windows-DotNETRuntime provider is ETW and exists only on Windows; use the EventPipe row above on Linux and macOS. |
| Code review | rg -n '\.Result\b\|\.Wait()\b\|\.GetAwaiter\(\)\.GetResult\(\)' src/ (ripgrep is not preinstalled; install it separately, or use your editor's "Find in Files" on the same pattern) |
Any of these inside library code (under src/CliInvoke*) is a defect — library code must be awaitable end-to-end. |
If the failure involves the process itself, see Resource Management for disposal-related deadlocks.
These failures are not bugs in caller threading or disposal — they are
configurations of ProcessExitConfiguration, ProcessTimeoutPolicy,
and ProcessExitBehaviour that produce surprising exit behavior.
TimeoutException thrown for a process that completed in time.ProcessTimeoutPolicy.None used by accident. None is a
zero-second, disabled policy. Use
ProcessTimeoutPolicy.FromTimeSpan(...) or
ProcessTimeoutPolicy.Default (3 minutes; the parameterless
ProcessTimeoutPolicy() constructor returns a 2-minute policy with
cancellation enabled).ProcessExitBehaviour.WaitForExit with cancellation. When the
behaviour is WaitForExit, the process is not signalled on
cancellation; the caller must terminate the process manually.ProcessExitConfiguration.CreateGraceful paired with a very short
timeout. Graceful exit sends SIGTERM/Ctrl+C and waits. If the
timeout is shorter than the application's cleanup time, the
configuration forces a forceful exit and the buffered output is
truncated.Note: invokers return one of two result types — ProcessResult
and BufferedProcessResult. Neither represents an exit policy; the
policy is always on ProcessExitConfiguration and is consulted before
the result is constructed.
ProcessExitConfiguration at the start of each
invocation: ILogger.LogDebug("Exit config: {Timeout}, {Behaviour}", policy.TimeoutThreshold, policy.TimeoutExitBehaviour).ProcessExitBehaviour.ForcefulExit
to distinguish "process is slow" from "process is hung".The tools listed here are suggestions for further investigation. CliInvoke does not endorse, maintain, or warrant any of these third-party tools. Verify each tool's current version, license, and security posture before installing.
Commands throughout this guide reference the diagnostic tooling shown below. Review any command before running it in your terminal emulator.
| Tool | Install | Purpose |
|---|---|---|
dotnet-counters |
dotnet tool install -g dotnet-counters |
Real-time runtime metrics: handles, threads, allocations. Cross-platform. |
dotnet-trace |
dotnet tool install -g dotnet-trace |
EventPipe-based traces for performance and deadlock analysis. Cross-platform (Windows, Linux, macOS). |
dotnet-dump |
dotnet tool install -g dotnet-dump |
Memory dump collection and clrthreads, dumpheap, gcroot commands. Cross-platform. |
dotnet-gcdump |
dotnet tool install -g dotnet-gcdump |
Heap snapshot to identify retained objects and SecureString leaks. Cross-platform. |
dotnet-symbol |
dotnet tool install -g dotnet-symbol |
Symbol resolution for the above tools' output. Cross-platform. |
| ETW (Windows only) | Built into Windows; xperf from Windows SDK |
Native Event Tracing for Windows. Use only on Windows. Prefer dotnet-trace (EventPipe) for portability. |
| Process Explorer (Windows) | https://learn.microsoft.com/sysinternals/downloads/process-explorer |
Windows handle and DLL inspection. |
lsof |
Preinstalled on macOS; apt install lsof on Linux |
Per-process file / handle listing. Not installed by default on most Linux distributions. |
pgrep / pkill |
procps package on Linux; preinstalled on macOS |
POSIX process lookup; safer than parsing ps output. |
For environment-level process inspection on Linux, /proc/<pid>/{fd,status,task}
is always available without extra tooling.