Skip to content

Releases: ZelAnton/ProcessKit-fSharp

v2.4.2

Choose a tag to compare

@github-actions github-actions released this 13 Jul 18:32

Fixed

  • Made missing Stdin.FromFile sources reliably report ProcessError.Stdin when a child exits successfully.
  • Restored XPlat code-coverage collection for the C# test project in CI.
  • RunningProcess.StopAsync(gracePeriod) and ProcessGroup.ShutdownAsync(gracePeriod) now reject negative grace periods with ArgumentOutOfRangeException instead of silently treating them as an immediate kill.
  • On POSIX, process groups that reject a signal-0 liveness probe with EPERM are no longer treated as gone: they remain tracked for later control and teardown, while only ESRCH proves that a group no longer exists.
  • A Windows child spawned with StdioMode.Inherit (or Command.InheritStdin) no longer silently receives a handle to the parent process itself as its std input/output/error when the corresponding GetStdHandle fails. GetStdHandle returns INVALID_HANDLE_VALUE (-1) on failure, and to DuplicateHandle that -1 is the current-process pseudo-handle — the old check rejected only NULL, so -1 slipped through and duplicated the parent's full-access process handle into the child. Both failure sentinels are now rejected, so a broken GetStdHandle produces an honest ProcessError.Spawn instead.
  • Timeouts.raceTimeout now cancels the unused total-timeout timer as soon as an idle timeout wins, so long configured total deadlines no longer remain scheduled after the process has been timed out.
  • Exec.outputAll / Exec.outputAllBytes now cancel commands still waiting for a batch-concurrency slot immediately, while preserving results already completed before cancellation.
  • Command.Stdin(source) combined with Command.KeepStdinOpen() now works as documented instead of being a silent no-op. The stdin pipe is no longer force-closed once the source is exhausted: the source is fed first and the pipe is left open, and RunningProcess.TakeStdin then hands back a writable handle so the caller can keep writing to the same child interactively. The handle becomes available only after the background feeder has finished draining the source, so the source and the interactive writer never write the pipe at the same time. TakeStdin's wait for that feeder is deadlock-safe even when called from a single-threaded SynchronizationContext (a WPF/WinForms UI thread, classic ASP.NET): the feeder runs on the thread pool and never needs the caller's context to make progress. Both spawn paths (Command.StartAsync/ProcessGroup and the pipeline runner) close stdin after the source exactly as before when KeepStdinOpen is not set.
  • ProcessKit.Extensions.Hosting: a HostedProcessService.StopAsync call that found no active child to stop (supervision mid-backoff-sleep, or already ended) no longer resets LastStopOutcome to None, discarding a previous real stop's outcome; and a StopAsync whose internal stop wait was abandoned because the caller's cancellationToken expired first no longer risks an unobserved task exception if that abandoned stop later completes with a fault.

v2.4.1

Choose a tag to compare

@github-actions github-actions released this 12 Jul 20:00

Added

  • A published guides site: the docs/ guide set is now built with mdBook and published to GitHub Pages at https://zelanton.github.io/ProcessKit-fSharp/, with the generated fsdocs API reference served alongside it under https://zelanton.github.io/ProcessKit-fSharp/api/. The book is rebuilt on pushes to docs/**/theme/**/book.toml; the API reference still tracks the latest published release. (docs/internals/ and docs/planning/ remain internal — they are not part of the published site.)

v2.4.0

Choose a tag to compare

@github-actions github-actions released this 11 Jul 16:41

Added

  • Command.Groups(gids) (and the pipe-friendly Command.groups): set the child's Unix supplementary groups, replacing the inherited set — the missing third leg of a privilege drop next to Uid/Gid. A bare Uid/Gid/User drop clears the parent's supplementary groups (so a child dropped to a service user loses that user's docker/video/adm membership); pass the target user's gids here to grant them back, or [] to keep the cleared default. Applied by the same setpriv helper (mapped to setpriv --groups), so it is honoured only alongside a Uid/Gid drop — set without one it fails the spawn with ProcessError.Spawn rather than being silently ignored. Unix-only: on Windows it fails with ProcessError.Unsupported, exactly like Uid/Gid. Each gid must be non-negative (rejected at the builder boundary with ArgumentOutOfRangeException, naming the offending index).
  • The ProcessKit, ProcessKit.Extensions.DependencyInjection, and ProcessKit.Extensions.Hosting packages now declare trimming/NativeAOT compatibility (IsTrimmable/IsAotCompatible), so a consumer that publishes a PublishTrimmed/NativeAOT app no longer gets "assembly was not verified" warnings for them; a CI smoke publishes and runs a NativeAOT consumer that spawns, captures, and contains a child on both Linux (linux-x64) and Windows (win-x64). See docs/platform-support.md — including the documented boundary that ProcessKit.Testing is not trim/AOT-safe (its reflection-based System.Text.Json cassettes), which is fine because it is a test-only dependency.
  • Command.InheritStdin (and the pipe-friendly Command.inheritStdin): hand the child the parent process's own standard input directly — inherited at the OS level, with no pipe and no feeder — for interactive/console programs (an editor launched by git commit, a tool that prompts on the terminal, a pipe from the parent's own stdin). The stdin analogue of StdioMode.Inherit. Incompatible with a feeder Stdin source and KeepStdinOpen (rejected at the builder boundary in either chaining order); RunningProcess.TakeStdin returns None for an inherited-stdin child. Repeatable under Retry/supervision and supported by the ProcessKit.Testing record/replay cassette (keyed by a stable "inherit" marker).
  • OutputJsonAsync<'T>: a typed JSON verb alongside ParseAsync/TryParseAsync, available on Command, any IProcessRunner (via the ProcessRunnerExtensions seam, so ScriptedRunner and other test doubles get it for free), CliClient, and Pipeline. Deserializes the captured stdout via the in-box System.Text.Json (no new package dependency), with an overload accepting a JsonSerializerOptions; invalid JSON becomes ProcessError.Parse and a non-zero exit becomes ProcessError.Exit, never a raised exception. From F#, Runner.outputJson is the module-function form.
  • ProcessKit.Extensions.Hosting: opt-in IHealthCheck support for a hosted process. AddProcessKitHostedProcessHealthCheck(name) registers a keyed HostedProcessHealthCheck (same key as AddProcessKitHostedProcess) mapping supervision state to Healthy (running, including in-policy restarts) / Degraded (the failure-storm guard is pausing restarts) / Unhealthy (supervision not active — not started, or ended in an error/exhausted budget/give-up/stop predicate). HostedProcessService gains the observable IsSupervisionActive, RestartCount, and IsStormPaused the check reads, updated live from Supervisor.OnRestart/OnStormPause rather than only once supervision ends. The only added dependency is Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions; see docs/dependency-injection.md for how to wire the registered check into your own health-checks pipeline.
  • Exec.which(program): preflight-resolve a program to a full path without spawning it — a doctor/install-wizard check ("is this tool installed?") cheaper and side-effect-free next to probing availability by actually running the program. Reuses the same PATH/PATHEXT-aware lookup the spawn path itself falls back on to name the directories it searched (including full PATHEXT semantics on Windows), so which and an actual spawn of the same program name never disagree on found-vs-not-found; returns the resolved path on success or a typed ProcessError.NotFound (Searched names the probed PATH for a bare name) otherwise. CliClient.EnsureAvailableAsync() is the same check for a client's own program — always a local host check, never delegated to an injected IProcessRunner test double.

Changed

  • AddProcessKit(IConfiguration) / AddProcessKitGroup(IConfiguration) are now annotated [RequiresUnreferencedCode]/[RequiresDynamicCode]: because they bind ProcessKitOptions from configuration by reflection, a trimmed/NativeAOT app that calls them now gets a precise warning pointing at the overload — use the Action<ProcessKitOptions> overload from an AOT app. No effect on a non-trimmed build.

Fixed

  • RunningProcess.CpuTime / PeakMemoryBytes / ProfileAsync no longer report a pid-reused stranger's metrics: each child's pid identity (OS-reported creation time) is captured once at spawn and re-checked before every metrics read, so a pid the OS recycled for an unrelated process after the original child was reaped now yields None instead of that stranger's CPU/memory.
  • ProcessStdin.FinishAsync no longer risks surfacing an IOException to the caller when the
  • HostedProcessService no longer silently discards a user-configured Supervisor.StopWhen (set via AddProcessKitHostedProcess's configureSupervisor callback): it now combines the caller's predicate with its own host-shutdown stop condition instead of overwriting it, so a predicate like "stop once the child exits 0 with a marker in stdout" still ends supervision as configured.
  • RunningProcess.Kill()/StopAsync() (and the per-run timeout/pump-fault kills) no longer touch the
  • RunningProcess.WaitForLineAsync now reports the clamped (armable) timeout in ProcessError.NotReady
  • ProcessGroup.StartAsync now guards the shared-group RunningProcess handle construction exactly like
  • ProcessKit.Extensions.Hosting's internal TrackingRunner now honours Command.CancelOn and always

v2.3.0

Choose a tag to compare

@github-actions github-actions released this 11 Jul 00:54

Added

  • RecordReplayOptions.WithCwdMatching(): opt-in restoration of the working directory (Command.CurrentDir) as part of a ProcessKit.Testing cassette's replay match key.

Changed

  • ProcessKit.Testing cassette matching no longer keys on the working directory (Command.CurrentDir) by default: two otherwise-identical invocations recorded/replayed from different absolute directories (a developer's checkout vs. a CI runner's workspace) now match, instead of missing with ProcessError.CassetteMiss purely because of cwd. CassetteEntry.Cwd still stores the working directory verbatim for inspection. This is a behavioural change for an existing cassette that relied on cwd alone to distinguish two entries — those entries now collapse to the same match key and one of them "wins" (replays for both) in capture order; add RecordReplayOptions.WithCwdMatching() (applied symmetrically at record and replay time) to restore the previous cwd-sensitive matching.

Fixed

  • RunningProcess.WaitForAsync / WaitForPortAsync now enforce their timeout and cancellation as a
  • RunningProcess.WaitForPortAsync / WaitForAsync now background-drain the child's piped
  • ProcessKit.Testing cassette writes (RecordReplayRunner.Save/dispose) now flush the recorded content to disk before the atomic rename swaps it into place, and best-effort fsync the cassette's parent directory on Unix afterwards, so a crash right after a save can no longer leave the cassette file or the rename itself unpersisted.
  • Command.Retry/Supervisor now refuse a command whose stdin comes from a one-shot source (Stdin.FromStream/FromLines/FromAsyncLines) up front, with ProcessError.Unsupported, instead of silently re-running or restarting it against the already-exhausted source on the second attempt — previously the child could receive truncated or empty stdin without any error. Repeatable sources (FromString/FromBytes/FromFile/Stdin.Empty) and a single run without an active retry/restart are unaffected.
  • A buffered tee sink (Command.StdoutTee/StderrTee, e.g. a BufferedStream-backed writer) now gets flushed as soon as the pump's read loop ends — on both clean EOF and a read failure — instead of only once the caller eventually disposes it, so a consumer reading the tee concurrently no longer sees truncated output. ProcessKit still never disposes the caller-supplied tee.
  • On Windows, ProcessGroup.Suspend/Resume now re-verify (IsProcessInJob) that a job member's process handle is still actually a member of the job right after opening it, closing a narrow pid-reuse race: previously, if a member (typically a handle-less grandchild) exited and its pid was immediately reused by an unrelated process between the member snapshot and OpenProcess, that unrelated process could be frozen/thawed instead. The re-check is fail-safe (any failure to open the process or query its membership leaves it untouched) and the fix touches only this internal handle-acquisition step — no public API change.
  • On Linux, a failed cgroup.procs read (e.g. EACCES/EIO racing teardown) is no longer folded into an empty member list. Graceful shutdown and the kill fallback for the cgroup v2 limits backend now treat a read failure as "membership unknown, not drained," so they keep escalating instead of concluding the tree already exited early; ProcessGroup.Members/Stats propagate the read failure as an honest ProcessError.Io rather than reporting a fabricated empty group / zero active processes.
  • A genuine OS-level read failure on the child's stdout/stderr pipe now surfaces as ProcessError.Io from FinishAsync/WaitAsync/ProfileAsync/OutputStringAsync (and faults the streaming enumerator for StdoutLinesAsync/OutputEventsAsync), instead of silently ending the capture as if the child had produced a short, complete output. The routine dispose/broken-pipe race this library's own teardown triggers by design (closing the pipe streams while a still-running background pump may be mid-read) is still swallowed exactly as before — only a fault raised before teardown began is now reported.
  • On Linux, per-member cgroup v2 signal delivery is now identity-safe against pid recycling.
  • On the POSIX process-group backend (macOS/BSD, or Linux without cgroup delegation), every

v2.2.0

Choose a tag to compare

@github-actions github-actions released this 10 Jul 11:31

Added

  • Command.MergeStderr / Command.mergeStderr: fold the child's stderr into its stdout at the OS level — the library equivalent of a shell 2>&1. The native spawn routes the child's stderr at the same pipe/handle as its stdout (POSIX dup2 of fd 2 onto stdout's target; Windows shares one handle across STARTUPINFO.hStdOutput/hStdError), so the two streams interleave honestly, byte for byte, on the single stdout stream — the real terminal-order view that the post-hoc ProcessResult.Combined (a concatenation of two separately captured streams) cannot reproduce. It works uniformly for the buffering verbs, the streaming verbs (StdoutLinesAsync/OutputEventsAsync), and pipeline stages; the default is off (separate stdout/stderr, unchanged). There is then no separate stderr stream, and the API reflects that honestly rather than downgrading silently: ProcessResult.Stderr is empty, OutputEventsAsync emits only Stdout events, and the separate-stderr observation hooks StderrTee/OnStderrLine are rejected in combination with MergeStderr (ArgumentException, in either chaining order) — while StderrEncoding/StderrLineTerminator/Stderr mode are documented no-ops (the merged bytes follow stdout's encoding/framing/destination). Inside a Pipeline it is allowed only on the last stage (whose stdout is the captured output); an earlier stage is rejected the moment it stops being last, since a merge there would inject its stderr into the next stage's input.
  • Runnable F# and C# sample projects under samples/, built separately in CI, covering capture, streaming readiness, pipelines, supervision, dependency injection, and test doubles.
  • ProcessKit.Extensions.Hosting: register a supervised child process as an IHostedService with AddProcessKitHostedProcess, including graceful host-shutdown via RunningProcess.StopAsync and final supervision outcome access for health reporting.
  • Command.IdleTimeout / Command.idleTimeout: kill the run's whole process tree when it produces no output — on neither stdout nor stderr — for the given duration, catching a run that is alive but stuck (stopped printing) rather than one that is merely slow overall. Independent of Command.Timeout (the total-length deadline): set both and each fires on its own condition, whichever comes first, with a single kill and a single reported outcome. Every chunk of output resets the deadline, measured at byte granularity across every verb (buffered capture, streaming, raw OutputBytesAsync, and the output-discarding WaitAsync/ProfileAsync), so a long newline-free blob still counts as active; the idle clock starts when output consumption begins. An idle kill surfaces exactly like the total timeout — Outcome.TimedOut (IsTimedOut on the capture verbs, ProcessError.Timeout on the success-checking verbs), distinguished only in the log message (which names an idle kill and reports the idle window, under the same ProcessTimedOut event id). Honours Command.TimeoutGrace (SIGTERM → grace → SIGKILL) like Timeout; a negative duration is rejected (ArgumentOutOfRangeException) and one larger than ~24.8 days is treated as no idle deadline. A per-stage IdleTimeout on a pipeline stage is rejected by .Pipe (a pipeline monitors only the last stage's captured output), mirroring the per-stage Timeout rejection.
  • RunningProcess.StopAsync(gracePeriod) and StopAsync(): gracefully stop a live handle — send the child a soft signal (SIGTERM), wait up to the grace window for it to exit on its own, then hard-kill whatever is still alive — and reap the tree, returning the honest Outcome of how the child actually concluded (a killed/non-zero exit is data, never a raised error). It reuses the same graceful-kill machinery as Command.TimeoutGrace / ProcessGroup.ShutdownAsync, drains the child's output while it shuts down, and reuses any in-flight streaming/capturing session's wait so it is safe to call after StdoutLinesAsync/OutputEventsAsync or concurrently with FinishAsync/WaitAsync/Kill/Dispose (idempotent — the tree is reaped once). The parameterless overload uses a 2-second default grace, matching ProcessGroupOptions.ShutdownTimeout. Degrades honestly with no new silent downgrade: on Windows (no per-tree graceful signal) and on a shared group from ProcessGroup.StartAsync (no per-child graceful signal), gracePeriod is skipped and the child/tree is hard-killed immediately — exactly as TimeoutGrace already degrades there; a default-runner handle (Command.StartAsync()/IProcessRunner.SpawnAsync) gets the full SIGTERM → grace → SIGKILL on Unix.
  • A browsable API reference, generated from the XML doc comments of ProcessKit and
  • Supervisor.OnRestart / OnStormPause: observe restarts and failure-storm pauses live, as supervision runs, instead of only in the final SupervisionOutcome — useful for a health check or crash-loop alerting on a long-lived supervised service. Both callbacks are invoked synchronously from the supervision loop, right before the corresponding delay is slept out, and are purely additive (SupervisionOutcome.Restarts/StormPauses/Stopped are unchanged).
  • Command.WindowsCtrlSignals / Command.windowsCtrlSignals: spawn a Windows child in its own console process group (CREATE_NEW_PROCESS_GROUP) so ProcessGroup.Signal(Signal.Int) / Signal.Term can deliver it a best-effort console CTRL+BREAK — the closest Windows analogue to a graceful SIGINT/SIGTERM — instead of the hard atomic Job kill, giving a console child a chance to clean up. Best-effort and console-only: the event reaches only a console child that shares the caller's console (a child given its own/hidden console via CreateNoWindow, or a parent with no console, cannot receive it), and even a successful send is not guaranteed to be handled. No effect on Unix (which signals the child's process group regardless). The default is unchanged.
  • Command.Uid / Command.Gid / Command.User (and Command.uid / gid / user): run a child under a different Unix user / group id — a privilege drop for daemons, CI runners, and sandboxes. User(uid, gid) is the common pair. Because posix_spawn has no uid/gid attribute (and forking a managed .NET runtime to drop in the child is unsafe), a command requesting either is rewritten to run through the setpriv helper (util-linux) on the ordinary posix_spawn path: setpriv sets the gid before the uid, clears the parent's supplementary groups, then execs the real program in place (same pid, so kill-on-drop containment is unchanged). Honest by construction — dropping to another user is root-only (euid == 0); a non-root caller asking for a different id fails the spawn with ProcessError.Spawn (never a child that kept the parent's ids), as does a host with no setpriv on PATH (present on mainstream Linux; absent on macOS/BSD). On Windows (no equivalent) any of these fails with ProcessError.Unsupported. uid/gid must be non-negative (ArgumentOutOfRangeException at the builder boundary).
  • Command.Setsid / Command.setsid: detach the child into a new session (setsid()) — its own session and process group, with no controlling terminal. Kill-on-drop containment is preserved: a new session still makes the child its own process-group leader (pgid == pid), so the group teardown (killpg) reaches it; the session detach replaces the group's default POSIX_SPAWN_SETPGROUP for that command rather than combining with it. Unix-only: on Windows it fails the spawn with ProcessError.Unsupported, never a silent no-op.
  • HostedProcessService now also implements IAsyncDisposable: DisposeAsync() performs the same idempotent hard teardown as Dispose() (forbid new starts, hard-kill the active child, cancel supervision) but additionally awaits the supervision loop to actually finish before returning — a deterministic guarantee the plain, synchronous Dispose() cannot make since it cannot await. Both share one teardown transition, so only the first call across either actually performs the teardown (hard-kill + cancel), but DisposeAsync() always awaits supervision to completion before returning regardless of whether it won or lost that transition — including when a prior/concurrent Dispose() already claimed it.

Changed

  • On Linux ≥ 5.4, waiting for a child to exit now goes through a per-child pidfd on one shared epoll reaper thread instead of the process-wide SIGCHLD rescan: the child's pidfd is registered once, and when it becomes readable the child is reaped with waitid(P_PIDFD) — which refers to the exact process, not a pid number, so the reap is immune to pid reuse and dispatch is O(1) per child rather than an O(pending) rescan on every SIGCHLD. macOS, other POSIX hosts, and older Linux kernels transparently keep the shared-SIGCHLD reaper (the choice is made once per process, at first use — pidfd_open/waitid(P_PIDFD) are probed on startup and fall back on ENOSYS/EINVAL). No public-API change and no change to the decoded Outcome, teardown, or the nativeint pid handle; this only narrows the pid-reuse window the ROADMAP tracked and removes the per-SIGCHLD rescan under heavy concurrency.
  • On Linux/macOS, the parent side of a child's piped stdin/stdout/stderr is now genuinely asynchronous: each stream is one end of an AF_UNIX SOCK_STREAM socketpair wrapped in a Socket/NetworkStream, so its reads and writes complete through the runtime's epoll/kqueue event loop instead of a plain FileStream parking a thread-pool thread per piped stream for the stream's whole lifetime. A large concurrent fan-out of piped POSIX children (WaitAllAsync, Supervisor, Exec.outputAll) no longer pressures the thread pool linearly — measured on Linux, 128 concurrent pending reads dropped from ~130 parked thread-pool threads to ~3. No public-API change and no change to captured output (byte-exact, with BOM strip and...
Read more

v2.1.0

Choose a tag to compare

@github-actions github-actions released this 06 Jul 14:10

Added

  • Command.LineTerminator / StdoutLineTerminator / StderrLineTerminator (and their Command module pipe-equivalents) plus the LineTerminator type (Lf/Cr/CrLf/Any) to choose how the line-pumped path frames a line. The default Lf is unchanged (split on \n, stripping a preceding \r); Cr/Any also split on a bare \r, so carriage-return progress output (curl/pip/apt redrawing a line in place) streams as per-frame lines instead of piling up as one growing line. The choice applies uniformly to the buffered verbs, the streaming verbs (StdoutLinesAsync/OutputEventsAsync/WaitForLineAsync), and the OnStdoutLine/OnStderrLine callbacks; the raw OutputBytesAsync bytes and the tees stay byte-exact and unaffected.
  • Supervisor.GiveUpWhen(classifier) and StopReason.GaveUp: classify a crash's or a runner failure's ProcessError as permanent, so the supervisor gives up instead of restarting it forever.
  • ProcessKit.Testing.DryRunRunner — a subprocess-free IProcessRunner for a --dry-run seam: every verb renders the command deterministically (program, arguments, working directory) instead of spawning it, and History exposes every command "run" so far for inspection.
  • Command.Priority / Command.priority and the portable Priority type (Idle/BelowNormal/Normal/AboveNormal/High) to launch a child at a lower or higher CPU-scheduling priority: a Windows priority class set at process creation, or a Unix nice/setpriority value. The child's whole spawned tree runs at it on Unix (inherited across fork) and on Windows for the lowered classes (Idle/BelowNormal); on Windows AboveNormal/High apply to the immediate child but are not inherited by the grandchildren it later spawns, which default to Normal. Supported on both platform families (never Unsupported); the default is unchanged (normal priority). Raising priority above the inherited level on Unix (AboveNormal/High) needs privilege (e.g. CAP_SYS_NICE) — without it the spawn fails with ProcessError.Spawn rather than silently running lower.
  • Command.RetryNever / Command.retryNever: explicitly disable retrying for a command, always running it exactly once — distinct from simply not calling Retry, and the way to opt a command out of a Retry policy inherited from a CliClient.WithDefaults template.
  • Command.Umask / Command.umask: set the child's Unix file-mode creation mask (umask(2)), so files it creates land at the permissions you intend (pass the value you'd give the umask shell builtin, e.g. 0o022). Unix-only: because posix_spawn has no umask attribute, the mask is set on the parent and restored around the spawn under a lock that serializes it against every concurrent spawn — so a sibling spawn can never inherit a temporarily-set mask. On Windows (which has no equivalent) a set mask fails the spawn with ProcessError.Unsupported rather than being silently ignored. The default is unchanged (the inherited umask is left untouched).

Fixed

  • A pipeline whose checked stage fails now tears the whole chain down at once instead of waiting for a pipe EOF: a quiet, still-running sibling — classically an upstream producer that never writes, so never dies of a broken pipe — can no longer hold an already-failed chain open indefinitely. The pipefail result is unchanged (the stage that actually failed keeps the blame; the siblings the teardown kills are de-prioritized as victims).

v2.0.0

Choose a tag to compare

@github-actions github-actions released this 06 Jul 00:13

Added

  • Core run vocabulary: an immutable Command builder, the IProcessRunner seam, and the Runner verbs run/runUnit/outputString/outputBytes/exitCode/probe, returning Task<Result<_, ProcessError>> (a non-zero exit is data, not an error).
  • ProcessResult<'T>, Outcome, Mechanism, and the structured ProcessError failure type.
  • ProcessKit.Testing.ScriptedRunner and Reply: a subprocess-free IProcessRunner for hermetic tests.
  • ProcessGroup, a kill-on-dispose container for a process tree, and JobRunner, the default real-process IProcessRunner. Windows containment uses a Job Object (KILL_ON_JOB_CLOSE) with an atomic suspended-spawn → assign → resume so no descendant can escape; Linux/macOS use a POSIX process group (posix_spawn with POSIX_SPAWN_SETPGROUP, killpg teardown). The tree is reaped on dispose or GC finalization, and the active Mechanism is reported honestly.
  • ProcessGroup.ShutdownAsync(gracePeriod): graceful teardown — SIGTERM then SIGKILL after the grace period on Unix, an atomic Job kill on Windows — releasing the group when done.
  • Streaming & interactive I/O: IProcessRunner.SpawnAsync / Command.StartAsync() return a live RunningProcess with StdoutLinesAsync() / OutputEventsAsync() (as IAsyncEnumerable), WaitAsync/OutputStringAsync/OutputBytesAsync/FinishAsync, TakeStdin, Kill, Pid/Elapsed/StartTime, and kill-on-dispose (IAsyncDisposable).
  • Stdin input sources (FromString/FromBytes/FromFile/FromStream/FromLines/FromAsyncLines/Empty) and the interactive ProcessStdin handle; OutputLine, OutputEvent, Finished.
  • Per-stream Command builders: Stdin/KeepStdinOpen, Stdout/Stderr (StdioMode Piped/Inherit/Null), StdoutEncoding/StderrEncoding/Encoding, OnStdoutLine/OnStderrLine, StdoutTee/StderrTee, and OutputBuffer (OutputBufferPolicy with line/byte caps and OverflowMode).
  • ProcessError.OutputTooLarge (fail-loud output ceiling) and ProcessError.Stdin — the latter surfaces a stdin source that could not be read (e.g. a missing FromFile path) on an otherwise-successful run, instead of silently feeding the child empty input; a routine broken pipe (the child closed stdin early) is never reported. A pipeline surfaces the same failure for its first stage's stdin source.
  • ProcessError.Message — a short human-readable description (also its ToString), for logging and diagnostics.
  • ProcessError.isNotFound / ProcessError.isTransient — classifiers for error handling (e.g. retriable spawn/I/O failures vs a missing program). The instance property ProcessError.IsTransient is the C#-friendly form (err.IsTransient), alongside the generated err.IsNotFound tester.
  • Read-without-destructure accessors on ProcessError.Program, .Stdout, .Stderr, .Combined, .Code, .Signal (each an option, populated for the cases that carry the field) — so a wrapper reads a failure's fields off the base error without pattern-matching every case, the only practical way from C# (which can't destructure an F# union). Alongside the generated IsExit/IsSignalled/IsTimeout/IsCancelled case testers.
  • ProcessResult.Combined — captured stdout and stderr joined into one string (stdout, then stderr on a new line when both are non-empty; the same join ProcessError.Combined uses) — and ProcessResult.OutputContainsAny(needles) — a case-insensitive search across both streams, for the "a specific non-zero exit is benign when a known stdout/stderr marker is present" idiom.
  • C#-idiomatic consumption of the Result<_, ProcessError> every verb returns: C# pattern-matches the result directly, no helper call — await cmd.RunAsync() switch { { IsOk: true, ResultValue: var v } => …, { IsOk: false, ErrorValue: var e } => … } — which is exhaustive (the IsOk bool covers both arms, no discard) and binds the value non-null (no !). Plus Match / Switch / TryGetValue / GetValueOrThrow extension methods for non-switch styles. GetValueOrThrow raises the new ProcessException (which carries the structured Error); TryGetValue's out-parameters are nullability-annotated so misuse is a compile-time warning in nullable-aware C#. F# continues to match Ok/Error directly.
  • Readiness probes on RunningProcess: WaitForLineAsync (await a matching stdout line), WaitForPortAsync (await a TCP port), WaitForAsync (poll a custom predicate); ProcessError.NotReady on timeout.
  • RunningProcess.WaitAnyAsync / WaitAllAsync to race or await several started processes.
  • Command.Timeout / TimeoutGrace (kill the run at a deadline, reporting Outcome.TimedOut; graceful = SIGTERM→grace→SIGKILL on Unix, atomic on Windows), Command.CancelOn (tie a run to a CancellationToken), and Command.Retry(maxAttempts, …) (re-run on a retriable error; maxAttempts is the total number of runs — 3 means one run plus up to two retries, 0/1 a single run); ProcessError.NotReady joined by per-run timeout handling.
  • Shell-free pipelines: Command.Pipe(next) builds a Pipeline that wires each stage's stdout into the next stage's stdin, running the whole chain in one kill-on-dispose group. The same verbs as a single command (RunAsync/RunUnitAsync/OutputStringAsync/OutputBytesAsync/ExitCodeAsync/ProbeAsync/ParseAsync/TryParseAsync) plus Pipeline.Timeout/CancelOn. The exit status follows shell pipefail (the rightmost checked stage that did not exit 0 decides the result); Command.UncheckedInPipe() lets a stage fail without failing the pipeline.
  • Completed the Command convenience verbs: RunUnitAsync/ExitCodeAsync/ProbeAsync/ParseAsync/TryParseAsync/FirstLineAsync now sit alongside RunAsync/OutputStringAsync/OutputBytesAsync (all on the default runner), and every convenience verb takes an optional CancellationToken (omit it or pass one — cmd.RunAsync() / cmd.RunAsync(ct)).
  • Supervisor — keep a command alive with policy-driven restarts: RestartPolicy (Always/OnCrash/Never), exponential Backoff + MaxBackoff + Jitter, MaxRestarts, a failure-storm guard (StormPause + FailureThreshold + FailureDecay), and a StopWhen predicate, reporting a SupervisionOutcome (FinalResult/Restarts/Stopped/StormPauses) with StopReason. Runs through any IProcessRunner (WithRunner) so supervision is testable without spawning processes. A restart is attempted only for a transient failure (a spawn race, transient I/O) or an actual crash; a deterministic error — the program isn't found, the stdin source can't be read, a resource limit can't be enforced — is terminal, so a misconfiguration fails fast instead of restart-storming forever. The exponential backoff resets after a healthy incarnation (one that stayed up at least as long as MaxBackoff and wasn't a hang killed by its own timeout), so a long-lived service that crashes occasionally restarts promptly instead of being pinned at the ceiling, while a tight crash loop — or a per-incarnation timeout/hang loop — keeps climbing and self-throttles.
  • Process-tree control on ProcessGroup: Signal (the portable Signal type — Term/Kill/Int/Hup/Quit/Usr1/Usr2/Other; Windows delivers only Kill), Suspend/Resume (freeze/thaw the whole tree), Members (a pid snapshot), and KillAll (an immediate whole-tree hard kill that leaves the group usable).
  • ProcessGroup now implements IProcessRunner (SpawnAsync/CaptureStringAsync/CaptureBytesAsync): every run goes into that one shared kill-on-dispose group, so a fleet can share a container — e.g. Supervisor.WithRunner(group). ProcessGroup.StartAsync (taking an optional CancellationToken) returns a RunningProcess whose lifetime the group owns.
  • Command.OkCodes(codes) — set which exit codes count as success (the codes replace the default {0}, so include 0 to keep it; an empty set is a no-op that keeps the configured codes), driving ProcessResult.IsSuccess (also exposed as AcceptedCodes), ensureSuccess, the RunAsync verbs, and Supervisor crash classification. Command.CreateNoWindow() (Windows CREATE_NO_WINDOW); clear the child's environment with Command.EnvClear().
  • CliClient — a reusable handle to one program with shared defaults: WithDefaults(configure) applies the full Command builder to a template (timeout, working directory, environment, encoding, ok-codes, retry, logger, …); build configured Commands for argument lists, or run them through the client's runner.
  • Top-level Exec conveniences: Exec.run / Exec.outputString (run a program by name), and Exec.outputAll / Exec.outputAllBytes (run a batch of commands with bounded concurrency, collecting every result in input order).
  • Resource stats: ProcessGroup.Stats (a ProcessGroupStats snapshot — active process count, plus total CPU time and peak memory on Windows; CPU/memory are None on the POSIX fallback) and ProcessGroup.SampleStatsAsync(interval) (a periodic IAsyncEnumerable series). Per-process RunningProcess.CpuTime / PeakMemoryBytes, and RunningProcess.ProfileAsync returning a RunProfile (the full Outcome — so it distinguishes a clean exit from a signal kill from a timeout, with ExitCode/Signal/TimedOut convenience reads — plus duration, CPU, peak memory, sample count, and AvgCpuCores).
  • Whole-tree resource limits: ResourceLimits / ProcessGroupOptions (WithMemoryMax / WithMaxProcesses / WithCpuQuota) applied via ProcessGroup.Create(options). Enforced by a Windows Job Object or a Linux cgroup v2 (Mechanism.CgroupV2, with cgroup.kill teardown and cgroup accounting in Stats; a usable v2 hierarchy — one whose cgroup.controllers is non-empty — is detected at either /sys/fs/cgroup or the systemd hybrid mount /sys/fs/cgroup/unified, so a hybrid host with delegated controllers isn't wrongly downgraded, while a cont...
Read more