Releases: ZelAnton/ProcessKit-fSharp
Releases · ZelAnton/ProcessKit-fSharp
Release list
v2.4.2
Fixed
- Made missing
Stdin.FromFilesources reliably reportProcessError.Stdinwhen a child exits successfully. - Restored XPlat code-coverage collection for the C# test project in CI.
RunningProcess.StopAsync(gracePeriod)andProcessGroup.ShutdownAsync(gracePeriod)now reject negative grace periods withArgumentOutOfRangeExceptioninstead of silently treating them as an immediate kill.- On POSIX, process groups that reject a signal-0 liveness probe with
EPERMare no longer treated as gone: they remain tracked for later control and teardown, while onlyESRCHproves that a group no longer exists. - A Windows child spawned with
StdioMode.Inherit(orCommand.InheritStdin) no longer silently receives a handle to the parent process itself as its std input/output/error when the correspondingGetStdHandlefails.GetStdHandlereturnsINVALID_HANDLE_VALUE(-1) on failure, and toDuplicateHandlethat-1is the current-process pseudo-handle — the old check rejected onlyNULL, so-1slipped through and duplicated the parent's full-access process handle into the child. Both failure sentinels are now rejected, so a brokenGetStdHandleproduces an honestProcessError.Spawninstead. Timeouts.raceTimeoutnow 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.outputAllBytesnow cancel commands still waiting for a batch-concurrency slot immediately, while preserving results already completed before cancellation.Command.Stdin(source)combined withCommand.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, andRunningProcess.TakeStdinthen 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-threadedSynchronizationContext(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/ProcessGroupand the pipeline runner) close stdin after the source exactly as before whenKeepStdinOpenis not set.ProcessKit.Extensions.Hosting: aHostedProcessService.StopAsynccall that found no active child to stop (supervision mid-backoff-sleep, or already ended) no longer resetsLastStopOutcometoNone, discarding a previous real stop's outcome; and aStopAsyncwhose internal stop wait was abandoned because the caller'scancellationTokenexpired first no longer risks an unobserved task exception if that abandoned stop later completes with a fault.
v2.4.1
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 todocs/**/theme/**/book.toml; the API reference still tracks the latest published release. (docs/internals/anddocs/planning/remain internal — they are not part of the published site.)
v2.4.0
Added
Command.Groups(gids)(and the pipe-friendlyCommand.groups): set the child's Unix supplementary groups, replacing the inherited set — the missing third leg of a privilege drop next toUid/Gid. A bareUid/Gid/Userdrop clears the parent's supplementary groups (so a child dropped to a service user loses that user'sdocker/video/admmembership); pass the target user's gids here to grant them back, or[]to keep the cleared default. Applied by the samesetprivhelper (mapped tosetpriv --groups), so it is honoured only alongside aUid/Giddrop — set without one it fails the spawn withProcessError.Spawnrather than being silently ignored. Unix-only: on Windows it fails withProcessError.Unsupported, exactly likeUid/Gid. Each gid must be non-negative (rejected at the builder boundary withArgumentOutOfRangeException, naming the offending index).- The
ProcessKit,ProcessKit.Extensions.DependencyInjection, andProcessKit.Extensions.Hostingpackages now declare trimming/NativeAOT compatibility (IsTrimmable/IsAotCompatible), so a consumer that publishes aPublishTrimmed/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 thatProcessKit.Testingis not trim/AOT-safe (its reflection-basedSystem.Text.Jsoncassettes), which is fine because it is a test-only dependency. Command.InheritStdin(and the pipe-friendlyCommand.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 bygit commit, a tool that prompts on the terminal, a pipe from the parent's own stdin). The stdin analogue ofStdioMode.Inherit. Incompatible with a feederStdinsource andKeepStdinOpen(rejected at the builder boundary in either chaining order);RunningProcess.TakeStdinreturnsNonefor an inherited-stdin child. Repeatable underRetry/supervision and supported by theProcessKit.Testingrecord/replay cassette (keyed by a stable "inherit" marker).OutputJsonAsync<'T>: a typed JSON verb alongsideParseAsync/TryParseAsync, available onCommand, anyIProcessRunner(via theProcessRunnerExtensionsseam, soScriptedRunnerand other test doubles get it for free),CliClient, andPipeline. Deserializes the captured stdout via the in-boxSystem.Text.Json(no new package dependency), with an overload accepting aJsonSerializerOptions; invalid JSON becomesProcessError.Parseand a non-zero exit becomesProcessError.Exit, never a raised exception. From F#,Runner.outputJsonis the module-function form.ProcessKit.Extensions.Hosting: opt-inIHealthChecksupport for a hosted process.AddProcessKitHostedProcessHealthCheck(name)registers a keyedHostedProcessHealthCheck(same key asAddProcessKitHostedProcess) 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).HostedProcessServicegains the observableIsSupervisionActive,RestartCount, andIsStormPausedthe check reads, updated live fromSupervisor.OnRestart/OnStormPauserather than only once supervision ends. The only added dependency isMicrosoft.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 — adoctor/install-wizard check ("is this tool installed?") cheaper and side-effect-free next to probing availability by actually running the program. Reuses the samePATH/PATHEXT-aware lookup the spawn path itself falls back on to name the directories it searched (including fullPATHEXTsemantics on Windows), sowhichand an actual spawn of the same program name never disagree on found-vs-not-found; returns the resolved path on success or a typedProcessError.NotFound(Searchednames the probedPATHfor 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 injectedIProcessRunnertest double.
Changed
AddProcessKit(IConfiguration)/AddProcessKitGroup(IConfiguration)are now annotated[RequiresUnreferencedCode]/[RequiresDynamicCode]: because they bindProcessKitOptionsfrom configuration by reflection, a trimmed/NativeAOT app that calls them now gets a precise warning pointing at the overload — use theAction<ProcessKitOptions>overload from an AOT app. No effect on a non-trimmed build.
Fixed
RunningProcess.CpuTime/PeakMemoryBytes/ProfileAsyncno 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 yieldsNoneinstead of that stranger's CPU/memory.ProcessStdin.FinishAsyncno longer risks surfacing anIOExceptionto the caller when theHostedProcessServiceno longer silently discards a user-configuredSupervisor.StopWhen(set viaAddProcessKitHostedProcess'sconfigureSupervisorcallback): 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 theRunningProcess.WaitForLineAsyncnow reports the clamped (armable) timeout inProcessError.NotReadyProcessGroup.StartAsyncnow guards the shared-groupRunningProcesshandle construction exactly likeProcessKit.Extensions.Hosting's internalTrackingRunnernow honoursCommand.CancelOnand always
v2.3.0
Added
RecordReplayOptions.WithCwdMatching(): opt-in restoration of the working directory (Command.CurrentDir) as part of aProcessKit.Testingcassette's replay match key.
Changed
ProcessKit.Testingcassette 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 withProcessError.CassetteMisspurely because ofcwd.CassetteEntry.Cwdstill stores the working directory verbatim for inspection. This is a behavioural change for an existing cassette that relied oncwdalone to distinguish two entries — those entries now collapse to the same match key and one of them "wins" (replays for both) in capture order; addRecordReplayOptions.WithCwdMatching()(applied symmetrically at record and replay time) to restore the previous cwd-sensitive matching.
Fixed
RunningProcess.WaitForAsync/WaitForPortAsyncnow enforce theirtimeoutand cancellation as aRunningProcess.WaitForPortAsync/WaitForAsyncnow background-drain the child's pipedProcessKit.Testingcassette 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/Supervisornow refuse a command whose stdin comes from a one-shot source (Stdin.FromStream/FromLines/FromAsyncLines) up front, withProcessError.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. aBufferedStream-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/Resumenow 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 andOpenProcess, 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.procsread (e.g. EACCES/EIO racing teardown) is no longer folded into an empty member list. Graceful shutdown and the kill fallback for the cgroup v2limitsbackend now treat a read failure as "membership unknown, not drained," so they keep escalating instead of concluding the tree already exited early;ProcessGroup.Members/Statspropagate the read failure as an honestProcessError.Iorather 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.IofromFinishAsync/WaitAsync/ProfileAsync/OutputStringAsync(and faults the streaming enumerator forStdoutLinesAsync/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
Added
Command.MergeStderr/Command.mergeStderr: fold the child's stderr into its stdout at the OS level — the library equivalent of a shell2>&1. The native spawn routes the child's stderr at the same pipe/handle as its stdout (POSIXdup2of fd 2 onto stdout's target; Windows shares one handle acrossSTARTUPINFO.hStdOutput/hStdError), so the two streams interleave honestly, byte for byte, on the single stdout stream — the real terminal-order view that the post-hocProcessResult.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.Stderris empty,OutputEventsAsyncemits onlyStdoutevents, and the separate-stderr observation hooksStderrTee/OnStderrLineare rejected in combination withMergeStderr(ArgumentException, in either chaining order) — whileStderrEncoding/StderrLineTerminator/Stderrmode are documented no-ops (the merged bytes follow stdout's encoding/framing/destination). Inside aPipelineit 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 anIHostedServicewithAddProcessKitHostedProcess, including graceful host-shutdown viaRunningProcess.StopAsyncand 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 ofCommand.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, rawOutputBytesAsync, and the output-discardingWaitAsync/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(IsTimedOuton the capture verbs,ProcessError.Timeouton the success-checking verbs), distinguished only in the log message (which names an idle kill and reports the idle window, under the sameProcessTimedOutevent id). HonoursCommand.TimeoutGrace(SIGTERM → grace → SIGKILL) likeTimeout; a negative duration is rejected (ArgumentOutOfRangeException) and one larger than ~24.8 days is treated as no idle deadline. A per-stageIdleTimeouton a pipeline stage is rejected by.Pipe(a pipeline monitors only the last stage's captured output), mirroring the per-stageTimeoutrejection.RunningProcess.StopAsync(gracePeriod)andStopAsync(): 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 honestOutcomeof how the child actually concluded (a killed/non-zero exit is data, never a raised error). It reuses the same graceful-kill machinery asCommand.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 afterStdoutLinesAsync/OutputEventsAsyncor concurrently withFinishAsync/WaitAsync/Kill/Dispose(idempotent — the tree is reaped once). The parameterless overload uses a 2-second default grace, matchingProcessGroupOptions.ShutdownTimeout. Degrades honestly with no new silent downgrade: on Windows (no per-tree graceful signal) and on a shared group fromProcessGroup.StartAsync(no per-child graceful signal),gracePeriodis skipped and the child/tree is hard-killed immediately — exactly asTimeoutGracealready 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
ProcessKitand Supervisor.OnRestart/OnStormPause: observe restarts and failure-storm pauses live, as supervision runs, instead of only in the finalSupervisionOutcome— 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/Stoppedare unchanged).Command.WindowsCtrlSignals/Command.windowsCtrlSignals: spawn a Windows child in its own console process group (CREATE_NEW_PROCESS_GROUP) soProcessGroup.Signal(Signal.Int)/Signal.Termcan deliver it a best-effort console CTRL+BREAK — the closest Windows analogue to a gracefulSIGINT/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 viaCreateNoWindow, 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(andCommand.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. Becauseposix_spawnhas 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 thesetprivhelper (util-linux) on the ordinaryposix_spawnpath:setprivsets the gid before the uid, clears the parent's supplementary groups, thenexecs 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 withProcessError.Spawn(never a child that kept the parent's ids), as does a host with nosetprivon PATH (present on mainstream Linux; absent on macOS/BSD). On Windows (no equivalent) any of these fails withProcessError.Unsupported.uid/gidmust be non-negative (ArgumentOutOfRangeExceptionat 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 defaultPOSIX_SPAWN_SETPGROUPfor that command rather than combining with it. Unix-only: on Windows it fails the spawn withProcessError.Unsupported, never a silent no-op.HostedProcessServicenow also implementsIAsyncDisposable:DisposeAsync()performs the same idempotent hard teardown asDispose()(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, synchronousDispose()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), butDisposeAsync()always awaits supervision to completion before returning regardless of whether it won or lost that transition — including when a prior/concurrentDispose()already claimed it.
Changed
- On Linux ≥ 5.4, waiting for a child to exit now goes through a per-child
pidfdon one sharedepollreaper thread instead of the process-wideSIGCHLDrescan: the child'spidfdis registered once, and when it becomes readable the child is reaped withwaitid(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 everySIGCHLD. macOS, other POSIX hosts, and older Linux kernels transparently keep the shared-SIGCHLDreaper (the choice is made once per process, at first use —pidfd_open/waitid(P_PIDFD)are probed on startup and fall back onENOSYS/EINVAL). No public-API change and no change to the decodedOutcome, teardown, or thenativeintpid handle; this only narrows the pid-reuse window the ROADMAP tracked and removes the per-SIGCHLDrescan 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_UNIXSOCK_STREAMsocketpair wrapped in aSocket/NetworkStream, so its reads and writes complete through the runtime's epoll/kqueue event loop instead of a plainFileStreamparking 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...
v2.1.0
Added
Command.LineTerminator/StdoutLineTerminator/StderrLineTerminator(and theirCommandmodule pipe-equivalents) plus theLineTerminatortype (Lf/Cr/CrLf/Any) to choose how the line-pumped path frames a line. The defaultLfis unchanged (split on\n, stripping a preceding\r);Cr/Anyalso split on a bare\r, so carriage-return progress output (curl/pip/aptredrawing 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 theOnStdoutLine/OnStderrLinecallbacks; the rawOutputBytesAsyncbytes and the tees stay byte-exact and unaffected.Supervisor.GiveUpWhen(classifier)andStopReason.GaveUp: classify a crash's or a runner failure'sProcessErroras permanent, so the supervisor gives up instead of restarting it forever.ProcessKit.Testing.DryRunRunner— a subprocess-freeIProcessRunnerfor a--dry-runseam: every verb renders the command deterministically (program, arguments, working directory) instead of spawning it, andHistoryexposes every command "run" so far for inspection.Command.Priority/Command.priorityand the portablePrioritytype (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 Unixnice/setpriorityvalue. The child's whole spawned tree runs at it on Unix (inherited acrossfork) and on Windows for the lowered classes (Idle/BelowNormal); on WindowsAboveNormal/Highapply to the immediate child but are not inherited by the grandchildren it later spawns, which default toNormal. Supported on both platform families (neverUnsupported); 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 withProcessError.Spawnrather than silently running lower.Command.RetryNever/Command.retryNever: explicitly disable retrying for a command, always running it exactly once — distinct from simply not callingRetry, and the way to opt a command out of aRetrypolicy inherited from aCliClient.WithDefaultstemplate.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 theumaskshell builtin, e.g.0o022). Unix-only: becauseposix_spawnhas 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 withProcessError.Unsupportedrather 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
Added
- Core run vocabulary: an immutable
Commandbuilder, theIProcessRunnerseam, and theRunnerverbsrun/runUnit/outputString/outputBytes/exitCode/probe, returningTask<Result<_, ProcessError>>(a non-zero exit is data, not an error). ProcessResult<'T>,Outcome,Mechanism, and the structuredProcessErrorfailure type.ProcessKit.Testing.ScriptedRunnerandReply: a subprocess-freeIProcessRunnerfor hermetic tests.ProcessGroup, a kill-on-dispose container for a process tree, andJobRunner, the default real-processIProcessRunner. 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_spawnwithPOSIX_SPAWN_SETPGROUP,killpgteardown). The tree is reaped on dispose or GC finalization, and the activeMechanismis 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 liveRunningProcesswithStdoutLinesAsync()/OutputEventsAsync()(asIAsyncEnumerable),WaitAsync/OutputStringAsync/OutputBytesAsync/FinishAsync,TakeStdin,Kill,Pid/Elapsed/StartTime, and kill-on-dispose (IAsyncDisposable). Stdininput sources (FromString/FromBytes/FromFile/FromStream/FromLines/FromAsyncLines/Empty) and the interactiveProcessStdinhandle;OutputLine,OutputEvent,Finished.- Per-stream
Commandbuilders:Stdin/KeepStdinOpen,Stdout/Stderr(StdioModePiped/Inherit/Null),StdoutEncoding/StderrEncoding/Encoding,OnStdoutLine/OnStderrLine,StdoutTee/StderrTee, andOutputBuffer(OutputBufferPolicywith line/byte caps andOverflowMode). ProcessError.OutputTooLarge(fail-loud output ceiling) andProcessError.Stdin— the latter surfaces a stdin source that could not be read (e.g. a missingFromFilepath) 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 itsToString), 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 propertyProcessError.IsTransientis the C#-friendly form (err.IsTransient), alongside the generatederr.IsNotFoundtester.- Read-without-destructure accessors on
ProcessError—.Program,.Stdout,.Stderr,.Combined,.Code,.Signal(each anoption, 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 generatedIsExit/IsSignalled/IsTimeout/IsCancelledcase 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 joinProcessError.Combineduses) — andProcessResult.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 (theIsOkbool covers both arms, no discard) and binds the value non-null (no!). PlusMatch/Switch/TryGetValue/GetValueOrThrowextension methods for non-switchstyles.GetValueOrThrowraises the newProcessException(which carries the structuredError);TryGetValue's out-parameters are nullability-annotated so misuse is a compile-time warning in nullable-aware C#. F# continues to matchOk/Errordirectly. - Readiness probes on
RunningProcess:WaitForLineAsync(await a matching stdout line),WaitForPortAsync(await a TCP port),WaitForAsync(poll a custom predicate);ProcessError.NotReadyon timeout. RunningProcess.WaitAnyAsync/WaitAllAsyncto race or await several started processes.Command.Timeout/TimeoutGrace(kill the run at a deadline, reportingOutcome.TimedOut; graceful = SIGTERM→grace→SIGKILL on Unix, atomic on Windows),Command.CancelOn(tie a run to aCancellationToken), andCommand.Retry(maxAttempts, …)(re-run on a retriable error;maxAttemptsis the total number of runs —3means one run plus up to two retries,0/1a single run);ProcessError.NotReadyjoined by per-run timeout handling.- Shell-free pipelines:
Command.Pipe(next)builds aPipelinethat 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) plusPipeline.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
Commandconvenience verbs:RunUnitAsync/ExitCodeAsync/ProbeAsync/ParseAsync/TryParseAsync/FirstLineAsyncnow sit alongsideRunAsync/OutputStringAsync/OutputBytesAsync(all on the default runner), and every convenience verb takes an optionalCancellationToken(omit it or pass one —cmd.RunAsync()/cmd.RunAsync(ct)). Supervisor— keep a command alive with policy-driven restarts:RestartPolicy(Always/OnCrash/Never), exponentialBackoff+MaxBackoff+Jitter,MaxRestarts, a failure-storm guard (StormPause+FailureThreshold+FailureDecay), and aStopWhenpredicate, reporting aSupervisionOutcome(FinalResult/Restarts/Stopped/StormPauses) withStopReason. Runs through anyIProcessRunner(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 asMaxBackoffand 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 portableSignaltype —Term/Kill/Int/Hup/Quit/Usr1/Usr2/Other; Windows delivers onlyKill),Suspend/Resume(freeze/thaw the whole tree),Members(a pid snapshot), andKillAll(an immediate whole-tree hard kill that leaves the group usable). ProcessGroupnow implementsIProcessRunner(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 optionalCancellationToken) returns aRunningProcesswhose lifetime the group owns.Command.OkCodes(codes)— set which exit codes count as success (the codes replace the default{0}, so include0to keep it; an empty set is a no-op that keeps the configured codes), drivingProcessResult.IsSuccess(also exposed asAcceptedCodes),ensureSuccess, theRunAsyncverbs, andSupervisorcrash classification.Command.CreateNoWindow()(WindowsCREATE_NO_WINDOW); clear the child's environment withCommand.EnvClear().CliClient— a reusable handle to one program with shared defaults:WithDefaults(configure)applies the fullCommandbuilder to a template (timeout, working directory, environment, encoding, ok-codes, retry, logger, …); build configuredCommands for argument lists, or run them through the client's runner.- Top-level
Execconveniences:Exec.run/Exec.outputString(run a program by name), andExec.outputAll/Exec.outputAllBytes(run a batch of commands with bounded concurrency, collecting every result in input order). - Resource stats:
ProcessGroup.Stats(aProcessGroupStatssnapshot — active process count, plus total CPU time and peak memory on Windows; CPU/memory areNoneon the POSIX fallback) andProcessGroup.SampleStatsAsync(interval)(a periodicIAsyncEnumerableseries). Per-processRunningProcess.CpuTime/PeakMemoryBytes, andRunningProcess.ProfileAsyncreturning aRunProfile(the fullOutcome— so it distinguishes a clean exit from a signal kill from a timeout, withExitCode/Signal/TimedOutconvenience reads — plus duration, CPU, peak memory, sample count, andAvgCpuCores). - Whole-tree resource limits:
ResourceLimits/ProcessGroupOptions(WithMemoryMax/WithMaxProcesses/WithCpuQuota) applied viaProcessGroup.Create(options). Enforced by a Windows Job Object or a Linux cgroup v2 (Mechanism.CgroupV2, withcgroup.killteardown and cgroup accounting inStats; a usable v2 hierarchy — one whosecgroup.controllersis non-empty — is detected at either/sys/fs/cgroupor the systemd hybrid mount/sys/fs/cgroup/unified, so a hybrid host with delegated controllers isn't wrongly downgraded, while a cont...