You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 fullCommand 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 controller-less v2 mount still fails fast with the clear ResourceLimit error); when no limit-capable container is available (macOS, or a Linux cgroup that is not at the real root), creation fails fast with ProcessError.ResourceLimit rather than leaving the tree unbounded. ProcessGroupStats.TotalCpuTime / PeakMemoryBytes are now populated on the cgroup v2 backend.
ProcessKit.Testing.RecordReplayRunner — an IProcessRunner that records real runs to a JSON cassette (Record(path, inner) / Save, with a drop-time flush) and replays them hermetically (Replay(path)) with no subprocess. Matches on program + args + cwd + stdin-source digest; an unmatched call is ProcessError.CassetteMiss. Covers the text and bytes capture verbs (CaptureStringAsync / CaptureBytesAsync) and SpawnAsync (a live handle is reconstructed from the recording, so streaming/readiness consumers replay too). A bytes recording stores the exact stdout bytes (base64) and replays them byte-for-byte, including non-UTF-8 output; a text (or pre-v2) recording replayed through the bytes verb stays honestly ProcessError.Unsupported (no lossy re-encode). Record-mode SpawnAsync is Unsupported (a live stream can't be captured without racing the consumer) — record such a call through a capture verb, then replay it as a stream. A recording's truncation flag and wall-clock duration survive replay (so ProcessResult.Truncated / Duration are faithful on replay, not a synthetic false / 0). Cassettes are owner-only (0600) on Unix, written atomically (temp file + rename), and store env names only (values redacted). Old cassettes load unchanged (the new fields default).
ProcessKit.Testing.RecordReplayOptions — matching/redaction knobs for RecordReplayRunner (passed to Record / Replay / Auto; the same instance must be used on both sides): WithFileStdinContentHashing() keys a Stdin.FromFile source by its contents rather than its path (matching a Stdin.FromBytes of the same bytes); WithArgNormalizer(args -> args) normalizes the argument list before matching so a volatile argument (a temp dir, a nonce) no longer defeats the match, while the raw args are still stored; WithRedaction(text -> text) scrubs captured text before it is written, so a secret echoed to stdout/stderr never reaches disk.
ProcessKit.Testing.RecordReplayRunner.Auto(path, inner) — a VCR "new episodes" mode: replay what the cassette holds and record+persist any miss, so a cassette is grown incrementally instead of curated by hand (existing entries still replay hermetically; a missing or empty file starts fresh). Strict Replay still fails a miss loudly.
Optional Microsoft.Extensions.Logging integration: Command.Logger(logger) emits structured lifecycle events — spawn, exit, timeout, retry, and supervisor restart / failure-storm pause. Each event has a stable EventId (name + number, exposed as ProcessKitDiagnostics.Events so consumers filter/route without a magic number) and every run-scoped event carries a per-run RunId (stamped once per logical run, so a run and its retries correlate) plus the Pid on spawn; messages go through cached LoggerMessage.Define delegates, so a disabled level formats/logs nothing. argv and the environment are never logged (only the program name and non-secret facts). No-op and free when no logger is set.
Distributed tracing via System.Diagnostics: a processkit.run span per completed run on the ActivitySource named ProcessKitDiagnostics.ActivitySourceName ("ProcessKit"), tagged with program / run id / outcome / exit code / signal / pid (never argv or environment), backdated so its duration is the real run length and nested under the ambient Activity. Wire it into OpenTelemetry with AddSource(ProcessKitDiagnostics.ActivitySourceName); free when no listener.
Metrics via System.Diagnostics.Metrics: a Meter named ProcessKitDiagnostics.MeterName ("ProcessKit") publishing processkit.runs.started / runs.completed / runs.active / run.duration (histogram, in seconds per the OpenTelemetry norm) / retries / supervisor.restarts / supervisor.storm_pauses (dimensionless counts carry a {…} UCUM unit annotation), tagged only by program name and a bounded set of outcome labels. Wire it into OpenTelemetry with AddMeter(ProcessKitDiagnostics.MeterName); free when no listener.
Running a Pipeline is now visible to the same logging/metrics/tracing surface as a single command: one Log.spawn/Log.exit pair (plus Log.timeout on a timeout) and one Diag.runStarted/runCompleted/runEnded triple per whole-chain run, sharing one run id. Stage 0's Command.Logger becomes the pipeline's logger; the program tag/label is a composite of every stage's name ("a | b | c", built only from Command.Program — never argv/env). A pipeline that fails to spawn every stage still clears runs.active without counting as runs.completed, mirroring a single command's own spawn-failure handling.
New ProcessKit.Extensions.DependencyInjection package: IServiceCollection.AddProcessKit() registers IProcessRunner (a singleton JobRunner), wrapped to emit ProcessKit's lifecycle events when the container has an ILoggerFactory. Uses TryAdd, so a pre-existing IProcessRunner registration is left untouched.
DI ergonomics on ProcessKit.Extensions.DependencyInjection: AddProcessKit(Action<ProcessKitOptions>) / AddProcessKit(IConfiguration) configure a default timeout / working directory, applied to every DI-resolved run only where the command sets none; AddProcessKitClient(name, program, configure) registers a keyed per-tool CliClient (injected by role, running through the container's IProcessRunner, and the place for retry / encoding / other verb-or-template defaults); and AddProcessKitGroup() (with Action<ProcessKitOptions> / IConfiguration overloads applying the same defaults) backs the runner with a shared, container-managed ProcessGroup whose disposal reaps the whole tree. All still TryAdd (no-clobber).
Both NuGet packages now ship an XML API documentation file, so consumers get IntelliSense / quick-info and generated API references for the public surface.
Multi-targets net8.0 (LTS) and net10.0, so the packages run on .NET 8 and later (previously net10.0 only).
CliClient verb parity: RunUnitAsync/ExitCodeAsync/ProbeAsync/ParseAsync/TryParseAsync/FirstLineAsync/StartAsync now sit alongside RunAsync/OutputStringAsync/OutputBytesAsync, each taking an optional CancellationToken — and all route through the client's configured runner (WithRunner), so a client built on a test/shared runner stays hermetic for every verb, not just for captured output.
Exec.outputBytes — the single-command raw-bytes companion to Exec.outputString (a zero-config one-liner, like Exec.run / Exec.outputString).
OutputBufferPolicy.WithMaxLines — a composable retained-line cap, mirroring WithMaxBytes.
ProcessResult.EnsureSuccess() — an instance form of ensureSuccess for fluent C# use.
RunningProcess.WaitForLineAsync / WaitForPortAsync / WaitForAsync each take an optional CancellationToken; a cancelled wait reports ProcessError.Cancelled (a timeout still reports NotReady).
The interactive ProcessStdin write verbs (WriteAsync / WriteLineAsync / FlushAsync) each take an optional CancellationToken, so a write to a child that has stopped reading (a full stdin pipe) can be bounded instead of blocking forever. FinishAsync (close stdin) stays idempotent and uncancellable, mirroring DisposeAsync.
The full run-verb vocabulary is now available on anyIProcessRunner as extension methods (runner.RunAsync / RunUnitAsync / OutputStringAsync / OutputBytesAsync / ExitCodeAsync / ProbeAsync / ParseAsync / TryParseAsync / FirstLineAsync / StartAsync, each taking an optional CancellationToken). A chosen or injected runner — a shared ProcessGroup, a ScriptedRunner — now offers the same verbs as a Command (e.g. runner.ExitCodeAsync command), instead of only through the Runner module functions. The verb logic stays centralized in the Runner module; these are thin sugar over it. The three seam primitives are named apart from the verbs — CaptureStringAsync/CaptureBytesAsync/SpawnAsync — so a verb's optional CancellationToken parameter no longer collides with the seam, and every capture verb applies the command's Retry policy uniformly whether or not a token is passed (previously a separate token overload bound to the seam and silently bypassed retry); for a single raw capture with no retry, call CaptureStringAsync directly.
ProcessKit.Testing.FakeProcess — a fluent builder for an in-memory RunningProcess (FakeProcess.Create().WithStdoutLines(...).WithExit(...).Build()), so code that consumes a live handle (StdoutLinesAsync / OutputEventsAsync / FinishAsync / the readiness probes / the buffered verbs) can be unit-tested without spawning a real process.
ProcessKit.Testing.ScriptedRunner now serves the SpawnAsync seam (behind the StartAsync verb) — it returns a FakeProcess derived from the matched reply, so streaming/readiness consumers can be tested through the same scripting as the capture verbs (previously the seam returned Unsupported).
Reply.TimedOut and Reply.Error(processError) let a ScriptedRunner script a timed-out run and a runner-level failure (spawn/IO/not-found), so the error and timeout branches are testable — previously the double could only return Ok.
DelegatingProcessRunner — a public pass-through IProcessRunner base that forwards all three verbs to an inner runner, so a decorator (logging, retry, metrics, fault injection, recording) overrides only the verb it changes.
TryParser<'T> — the TryParseAsync verbs (on Command, a Pipeline, CliClient, and any IProcessRunner) take a C#-friendly parser in the standard .NET bool TryX(string, out 'T) shape, so C# passes a BCL parser like int.TryParse with an explicit type argument (TryParseAsync<int>(int.TryParse), needed because the BCL parsers are overloaded); a false return (or a parser that throws) becomes ProcessError.Parse. F# keeps the Result-returning Runner.tryParse for a parser that supplies its own error message.
ProcessResult.Success / ProcessResult.Failure / ProcessResult.Create — test factories that build a ProcessResult<'T> directly (its constructor is internal), so code that consumes a result can be unit-tested without spawning a process; the captured-stdout type is inferred (ProcessResult.Success("out")).
Command.StreamBuffer(policy) — an opt-in bounded/backpressure policy for the streaming verbs (StdoutLinesAsync/OutputEventsAsync/WaitForLineAsync), via the new StreamBufferPolicy and StreamFullMode (Backpressure/DropOldest/DropNewest/Error). Unset (the default) keeps the unbounded streaming channel ProcessKit has always used. Backpressure slows the producer — and, through it, the child, which observably blocks writing to a full pipe — once the channel fills, bounding memory losslessly at the cost of timing; DropOldest/DropNewest bound memory losslessly-in-count but lossy-in-content, surfaced on the new live RunningProcess.DroppedStreamLineCount counter; Error faults the streaming enumerator with ProcessError.OutputTooLarge once the cap is reached. See Streaming for the backpressure deadlock footgun before opting in.
Outcome.Unobserved(reason) and the matching ProcessError.Unobserved(program, detail) — an honest representation for the rare case where a process concluded but its actual exit status could not be observed (a native DuplicateHandle/GetExitCodeProcess failure on Windows, or an unresolved POSIX reap race), instead of ever fabricating a clean exit. Never counts as success; Reply.Unobserved(reason) lets a ScriptedRunner/test script this outcome.
Changed
The ProcessKit.Testing test doubles — ScriptedRunner/Reply, FakeProcess, and RecordReplayRunner with its CassetteFile/CassetteEntry/RecordReplayOptions cassette surface — now ship in a separate ProcessKit.Testing NuGet package instead of inside the main runtime package, so a consumer's production dependency never carries the on-disk/JSON record-replay surface. The types keep the ProcessKit.Testing namespace unchanged; reference the ProcessKit.Testing package from test projects.
RunningProcess now enforces single consumption of its output. Once a terminal verb (OutputStringAsync/OutputBytesAsync/WaitAsync/ProfileAsync) or a streaming session (StdoutLinesAsync/OutputEventsAsync, and their FinishAsync/WaitForLineAsync companions) has claimed the pipes, a second, conflicting consumer is refused instead of racing two readers on the same pipe (which split and lost output): the Result-returning verbs return ProcessError.Unsupported, while WaitAsync/ProfileAsync/StdoutLinesAsync/OutputEventsAsync throw InvalidOperationException.
Command.Timeout / Pipeline.Timeout now reject a negative duration up front (ArgumentOutOfRangeException) and treat a duration longer than the BCL timer range (~24.8 days) as "no timeout". Previously an out-of-range timeout threw from inside the run verb when the deadline was armed, breaking the honest-result contract and orphaning the in-flight output pumps.
Record/replay cassettes are now a versioned JSON envelope ({ "Version", "Entries" }) instead of a bare array (new ProcessKit.Testing.CassetteFile type). The format is now version 2 (a CassetteEntry.StdoutBase64 field holds the exact bytes of a byte[] capture). Loading follows a back-compat policy — a version newer than this build understands is rejected, an older still-supported one (a v1 cassette) loads with its missing fields defaulted — and normalizes a partial/crafted cassette (omitted fields default to empty ""/[]) so a malformed entry can no longer surface as a NullReferenceException at replay time.
Exec.outputAll / outputAllBytes (the batch fan-out verbs) now take an explicit CancellationToken, so a long batch can be cancelled. The single-command verbs (Exec.run / outputString / outputBytes) stay zero-config one-liners.
ProcessResult.ensureSuccess is now generic over the captured-stdout type ('T), so it works on a ProcessResult<byte[]> as well as <string> (a byte[] stdout is decoded UTF-8 to fill the error's stdout field).
ProcessGroup used as an IProcessRunner now honours Command.Timeout and Command.CancelOn: a run that exceeds its deadline is hard-killed (just that child's subtree, leaving sibling runs in a shared group untouched) and reported as Outcome.TimedOut, and the command's own cancellation token now ties the run as it already did through the default JobRunner — previously both were silently ignored on the group runner.
ProcessGroup.Members and ProcessResult.AcceptedCodes now return IReadOnlyList<int> (was an F# list), matching Command.Arguments and reading naturally from C#.
RunningProcess.WaitAnyAsync now returns a named WaitAnyResult (Index / Outcome) instead of an (int * Outcome) tuple, so the fields are clear from C#.
RunningProcess.WaitAnyAsync and WaitAllAsync now share one contract: both take Task<WaitAnyResult> / Task<Outcome[]> directly (no Result wrapper — WaitAnyAsync previously wrapped it only to report an empty array), and both reject a null array, an empty array, or a null element with ArgumentNullException/ArgumentException instead of a process-level error, since an invalid argument is a programmer error rather than a process outcome.
The control/error discriminated unions (ProcessError, Outcome, Mechanism, Signal, StopReason, RestartPolicy, StdioMode, OverflowMode) no longer implement IComparable / IStructuralComparable — ordering them was meaningless. Equality and pattern matching are unchanged.
A sweep of public builder parameters that previously accepted a meaningless value and either silently degraded or threw later from inside a Result-returning verb now reject it up front (ArgumentOutOfRangeException/ArgumentException/ArgumentNullException), documented on each member: ResourceLimits.WithMemoryMax/WithMaxProcesses/WithCpuQuota (non-positive, or NaN for the CPU quota), ProcessGroupOptions.WithShutdownTimeout (negative), OutputBufferPolicy.Bounded/FailLoud/WithMaxLines/WithMaxBytes (negative — 0 stays valid), Command.TimeoutGrace (negative, matching Command.Timeout), Command.Env (an empty key or one containing =), Supervisor.MaxRestarts (negative — 0 stays valid), and ProcessStdin.WriteAsync/WriteLineAsync (a null argument, previously a raw NullReferenceException). ProcessKitOptions.DefaultTimeout (the DI extensions package) now validates at assignment instead of throwing from inside DefaultsRunner when the default is later applied to a command.
On POSIX, ProcessGroup.Signal now honestly reports a failed killpg/kill delivery instead of always returning Ok(): a member that has already exited (errno ESRCH) stays a best-effort success, but a genuine delivery failure — most notably an invalid Signal.Other number (EINVAL) — now surfaces as ProcessError.Io with the errno detail; a multi-member group still delivers to every member, reporting only the first genuine failure.
The raw byte capture (RunningProcess.OutputBytesAsync's stdout, and a pipeline's captured last-stage stdout) now honours the configured OutputBufferbyte cap + Overflow, instead of always buffering unbounded: MaxBytes = Some cap enforces the cap — Error returns ProcessError.OutputTooLarge once the cumulative stdout exceeds it (the pipe is still drained, so the child never blocks), DropOldest keeps the last cap bytes, DropNewest keeps the first cap bytes — with ProcessResult.Truncated now reflecting truncation of stdout or stderr. MaxBytes = None (the default) keeps the raw capture unbounded, exactly as before; MaxLines does not apply to a raw byte stream (no line structure) and is ignored there. This is a deliberate, documented divergence from the Rust ProcessKit-rs reference, whose output_bytes bounds raw bytes only by Timeout — the port applies the byte cap honestly so a caller who set MaxBytes/FailLoud to bound memory is not handed an unbounded stdout buffer.
A Pipeline now rejects per-stage Command configuration it cannot honour when the stage is piped (.Pipe / Pipeline.pipe throws ArgumentException, naming the field and the stage index) instead of silently dropping it at run time: a Stdin source on any stage after the first (its stdin is always rewired to the previous stage's stdout — only stage 0 may feed a source), a per-stage Timeout on any stage (only the chain-level Pipeline.Timeout bounds a pipeline; a stage's own deadline never fires), a per-stage Retry on any stage (retry is a verb-layer mechanism the direct stage spawn bypasses), and a per-stage CancelOn on any stage (a stage's own cancellation token is likewise a verb-layer mechanism the direct stage spawn bypasses; only the chain-level Pipeline.CancelOn cancels a pipeline). Feeding stage 0's Stdin, the chain-level Pipeline.Timeout/CancelOn, per-stage OkCodes/UncheckedInPipe, and the last stage's StdoutEncoding/StdoutTee/OutputBuffer are unaffected. A per-stage Logger, StreamBuffer, or KeepStdinOpen stays a no-op inside a chain and is now documented as inapplicable rather than silently ignored.
Fixed
OutputBufferPolicy.MaxBytes now also bounds the in-flight (not-yet-newline-terminated) line for the buffered capture verbs: an unterminated line is force-flushed at the cap, so a child emitting a newline-free flood can no longer grow the assembly buffer without bound — the flushed segments are dropped or errored per Overflow like any other over-cap output. Previously only retained complete lines were bounded, so a single newline-free line grew until EOF. (Streaming stays consumer-paced; it applies no buffer policy.)
On Windows, waiting for a child to exit now uses a thread-pool registered wait (one wait thread serves many handles) instead of parking a dedicated thread per child for its whole lifetime — so a large concurrent fan-out (WaitAllAsync, Supervisor, Exec.outputAll) no longer holds one blocked thread per live process for its exit wait.
On Windows, a piped stdin/stdout/stderr stream is now a genuinely overlapped named pipe (PipeOptions.Asynchronous, completed via IOCP) instead of a synchronous anonymous pipe — reading/writing it no longer parks a dedicated thread-pool thread for the life of the stream, so a large concurrent fan-out of piped children no longer grows thread-pool usage linearly with how many are in flight. Purely an internal spawn-plumbing change: captured output stays byte-exact, and Mechanism/Outcome/the public API are unaffected.
On POSIX (Linux/macOS), waiting for a child to exit is now event-driven — a single process-wide SIGCHLD registration drives a shared pid-to-waiter registry — instead of a dedicated thread blocked in waitpid for the child's whole lifetime, so a large concurrent fan-out of POSIX children no longer holds one parked thread per child. Exit-status decoding, zombie-free teardown, and the public API are unaffected.
POSIX containment now reaps every child of a multi-child group (e.g. a pipeline), not just the last. Each posix_spawn forms its own process group, so the group tracks all of them; previously only the most-recent pgid was killed, letting an earlier long-running stage linger until its natural exit.
A throwing OnStdoutLine/OnStderrLine handler now surfaces the error on StdoutLinesAsync() / OutputEventsAsync() (and FinishAsync()) instead of hanging the stream reader: the output channel is always completed — carrying the fault — even when a line pump throws. Across both the streaming verbs and the capture verbs (OutputStringAsync/OutputBytesAsync) the two output pumps are now awaited together, so a fault in one never leaves the other running as an unobserved task.
Every terminal RunningProcess verb (OutputStringAsync/OutputBytesAsync/WaitAsync/ProfileAsync/FinishAsync) now reaps the process tree even when the run faults mid-flight (e.g. a throwing line handler), rather than deferring teardown to disposal/GC.
RunningProcess.ProfileAsync now cancels and awaits its background metric sampler on the fault path too — a fault mid-profile can no longer leave the sampler running against a soon-to-be-disposed token as an unobserved task.
POSIX process wait now retries when the blocking waitpid is interrupted by a signal (e.g. the runtime's own thread-suspension signal on Linux) instead of misreading the untouched status as exit 0 and leaking the child as a zombie.
Captured and streamed text now strips a leading byte-order mark of the chosen encoding (matching StreamReader); OutputBytesAsync and the stdout/stderr tees stay byte-exact.
FirstLineAsync now reaps the process tree on every exit path — a throwing predicate or a cancelled run no longer downgrades kill-on-drop to GC finalization — and a cancelled run is reported as ProcessError.Cancelled rather than raising.
Tree-control verbs (Signal / Suspend / Resume / Members / KillAll) now return an error once the group is released, like Stats, closing a use-after-close window when a verb races a concurrent Dispose / ShutdownAsync.
Spawning into a released/disposed ProcessGroup now fails fast with a non-transient error instead of risking an uncontained child (on Windows the child is created before it is assigned to the Job, so a post-teardown spawn could leak an orphan). The released-group error is now ProcessError.Unsupported (not Io), so a retry classifier no longer re-tries a permanently-dead group.
StdioMode.Inherit now keeps the child's stdout/stderr open on macOS — the spawn registers the inherited descriptors as file actions so the macOS "close all on exec" default no longer silently leaves the child with no stdout/stderr.
A pipeline that fails to spawn a later stage now reaps and closes the stages that did start (and observes their relay tasks) before returning the error, instead of leaving zombies, leaked pipe handles, and unobserved tasks.
POSIX/macOS teardown now reaps the group leaders it kills: disposing or shutting down a group whose children were never awaited through a run verb no longer leaves defunct (zombie) processes behind until the host exits.
Linux cgroup teardown now also kills and reaps the children it spawned directly — cgroup.kill does not waitpid our own descendants, and a child that failed to migrate into the cgroup (so escaping cgroup.kill) is now cleaned up via its process group as a fallback.
A pipeline now honours each stage's OkCodes: a stage that exits with one of its own accepted codes no longer fails the pipeline, and the pipeline result carries the blamed stage's accepted codes instead of a hardcoded {0} (so ensureSuccess / ExitCodeAsync / ProbeAsync on a pipeline agree with a single command's).
A pipeline stage whose stdout was set to Null or Inherit no longer deadlocks the next stage on its unwired stdin — the pipeline forces every stage's stdout to a pipe so the chain is always connected (the last stage's stdout is still what the verbs capture).
RunningProcess.ProfileAsync now also observes its stdout/stderr drains on the fault path, so a fault before they complete can't leave them running as unobserved tasks.
Record/replay cassettes are now written atomically and owner-only from creation: the secret-bearing data is serialized into a sibling temp file created 0600 on Unix and renamed over the target, closing the brief window in which the cassette was group/world-readable before its mode was tightened — and a reader never observes a half-written cassette.
RunningProcess.WaitAnyAsync/WaitAllAsync called on a handle whose buffered verb (OutputStringAsync/OutputBytesAsync/WaitAsync/ProfileAsync) has already started now reuses that verb's own in-flight wait instead of racing a second reader on the same stdout/stderr pipes and issuing a second process wait — previously this order (verb, then WaitAny/WaitAll) could split or drop the verb's captured output. The reverse order (WaitAny/WaitAll first, then a verb) is unchanged: the later verb is still refused.
The processkit.runs.active metric no longer drifts upward for a RunningProcess handle that is disposed (or dropped to GC) without ever reaching a terminal verb (e.g. a streaming handle consumed via StdoutLinesAsync/OutputEventsAsync alone) — disposing such a handle now clears its runs.active contribution, exactly like reaching a terminal verb does. runs.completed/run.duration/the trace span are unaffected: an abandoned run still isn't counted as completed, only as no-longer-active.
Windows: a wait that could not observe a child's real exit status (DuplicateHandle or GetExitCodeProcess failing) now reports Outcome.Unobserved instead of fabricating Outcome.Exited 0.
POSIX: the SIGCHLD dispatch callback no longer blocks on a Thread.Sleep spin while resolving a reap race against a concurrent reapLeader (group teardown) — the same bounded grace period now runs on the thread pool instead of the shared signal-dispatch thread, so it can no longer delay reaping every other pending child. A race that genuinely can't be resolved within the grace period now reports Outcome.Unobserved rather than a fabricated clean exit; the far more common case — the concurrent reap actually landing the real status — is unaffected.
Linux cgroup v2: a child that cannot be migrated into the cgroup (the write to cgroup.procs fails) is now killed and reaped, and the spawn fails with ProcessError.ResourceLimit, instead of being silently left to run in the parent cgroup entirely outside the requested resource limits. The Mechanism.CgroupV2 / ProcessGroup.Create docs now also state the spawn→migrate window honestly: the limits apply to the child and every descendant it forks after migration, while a grandchild forked in the brief window before the migration write completes stays in the parent cgroup — still reaped by kill-on-drop teardown, but outside the resource limits.