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
Command.CancelGrace(grace) / Command.CancelSignal(signal) (mirrors: Command.cancelGrace / Command.cancelSignal) make a cancellation graceful: when the token that cancels a run fires, its tree is sent the chosen soft signal (default Signal.Term), given up to grace to leave on its own, and only then hard-killed — the cancellation twin of TimeoutGrace/StopSignal, for the "one shared token, cancelled on Ctrl-C" shutdown where every child would otherwise be killed outright. Opt-in and off by default: without it a cancellation still hard-kills at once, unchanged. The outcome never changes either way — a cancelled run still reports ProcessError.Cancelled, whether the child left on the soft signal or was killed after the grace. It is independent of Timeout/TimeoutGrace/StopSignal (neither pair gap-fills the other, and it needs no deadline of its own) and applies to every cancellation path a run has: the completion verbs through any runner, a run through a shared ProcessGroup, a supervised incarnation, and a whole chain cancelled via Pipeline.CancelOn (set it on stage 0, which owns the pipeline-wide control configuration; a later stage is rejected with ArgumentException). Windows keeps the documented best-effort soft phase (WM_CLOSE / opt-in CTRL+BREAK) before the Job kill and refuses a non-default CancelSignal at spawn with ProcessError.Unsupported, exactly as StopSignal does; LaunchDetached refuses CancelGrace for the same reason it refuses TimeoutGrace. See docs/timeouts-and-cancellation.md.
Supervisor.Events(capacity) opts a supervision session in to a live lifecycle event stream: SupervisionSession.EventsAsync() returns an IAsyncEnumerable<SupervisionEvent> reporting every incarnation start and outcome, launch-failure class, scheduled restart (with its RestartCause), storm pause, health-check verdict, give-up decision, and the terminal reason, on identical terms for a real runner and a capture-only test double. Read SupervisionEvent.Kind (a version-safe enum) or its stable Name identifier (incarnation_started, restart_scheduled, …); events carry lifecycle facts only — never argv, environment values, captured output, or an error message, only its coarse FailureKind. Off by default and purely additive: the OnRestart/OnStormPause callbacks, Status, and every supervision decision are unchanged, and a supervisor without the opt-in allocates nothing. The buffer is bounded and never applies backpressure to supervision — a consumer that falls behind makes the oldest unread events drop, each gap reported in band as a SupervisionEventKind.EventsDropped event with the exact count and totalled by SupervisionSession.DroppedEventCount. See docs/supervision.md.
ReportJson — an opt-in, AOT-safe System.Text.Json serializer for Outcome, ProcessResult<string>/ProcessResult<byte[]>, ProcessGroupStats, RunProfile, and MemberInfo as self-describing JSONL report lines: a stable "kind" machine identifier per shape (never a raw enum ordinal), an explicit null for every metric the platform or run could not report, and no captured stdout/stderr/argv/environment value on the wire. Reach it through the ToReportJson() extension methods or ReportJson's hand-built JsonTypeInfo<'T> properties; see docs/jsonl-reports.md for the schema, versioning policy, and C#/F# consumer examples.
RecordReplayOptions.WithCommandProjection((program, args) -> (program, args)) — an opt-in projection of the persisted command line, so a secret that lives in argv (a --password=… flag, a token in a URL) can be kept out of a cassette the way WithRedaction already keeps one out of captured output, which never covered program/args. What the hook returns is what a recording stores in CassetteEntry.Program/Args, on identical terms for text, bytes, PTY and typed-failure entries and for Record and Auto alike. It cannot change which call replays: an entry is keyed on a new CassetteEntry.CommandFingerprint — a SHA-256 of the invoked program and its (normalized) arguments, taken before the projection runs — so two secrets that project to the same placeholder stay two recordings, and a reader replays a projected cassette with no projection configured (it is a write-side policy). Everything without the hook is unchanged, including what WithArgNormalizer does and what an unprojected recording writes (no fingerprint field at all); cassettes from every older format version still load and replay, keyed from their own verbatim program/args. Two consequences worth knowing: with the raw arguments gone from the file the match key is frozen at record time, so changing WithArgNormalizer afterwards needs a re-record, and a low-entropy argument stays brute-forcible from the digest — the same caveat the environment fingerprint carries. Cassette format v8 → v9.
ProcessGroup.AdoptByPid(pid) brings an already-running external process into the container from a bare pid — the door for a caller who holds no System.Diagnostics.Process at all (a pidfile, a registry, an FFI or IPC boundary), where Adopt(process) cannot be used. Containment is Adopt's: kill-on-dispose, Signal/Suspend/Resume/Members/MembersInfo/Stats and any resource limits, and the process is still never waitpided by ProcessKit — its exit belongs to its real parent. Because a pid is an address rather than a handle, the call captures an identity anchor of its own for whatever the number currently names and binds the group to that instead of to the number: the process object behind one OpenProcess (Windows Job Object), kernel cgroup membership plus a start-time read on either side of the cgroup.procs write (Linux cgroup v2), or the pid plus a start-time token re-read before every later probe, signal, suspend/resume and teardown kill (POSIX process group). The POSIX process-group mechanism — which cannot Adopt a Process at all — therefore can adopt by pid wherever the host has a start-time reader (Linux, macOS), with the honest qualification that only the adopted process itself is contained there, not the processes it forks afterwards. A host with no such reader (the BSDs) returns ProcessError.Unsupported rather than tracking a bare number, pid <= 0 and this process's own pid are refused with ProcessError.Adopt before any mechanism is consulted, and a pid naming nothing, an unreadable identity, a denied open/write, a refused Windows assign, a target this process may not signal (checked on the POSIX process group with its own kill(pid, 0) probe, since a readable /proc/<pid>/stat proves identifiability, not control), or a number that changed hands during the call all return ProcessError.Adopt — the last one rolled back out of the cgroup where that applies, and reported as still contained where even the rollback is refused. What it cannot close is the window before the call, so where a live Process is available Adopt remains the stronger choice. ProcessGroup.Capabilities().AdoptionByPid reports the new axis, separately from Adoption, because the two genuinely differ on the same host.
ProcessGroup.Capabilities() / Capabilities(options) returns a ContainmentCapabilities snapshot — the mechanism Create would select for those options, plus a three-valued Capability (Available / Qualified / Unsupported, each carrying its qualification or missing precondition rather than a bare false) for resource limits, signals, adoption, PTY and its resize, kill-on-parent-death, and the platform helper binaries — taken without creating a group, spawning a process, or reading argv/environment.
ProcessGroupStats.PeakProcessCount exposes Linux cgroup v2's native lifetime peak task count (processes and threads) when MaxProcesses is configured on Linux 6.6+, and returns None on Windows, the POSIX process-group fallback, or when pids.peak is unavailable.
Opt-in non-interactive jj editor configuration: scripts/setup-jj-noninteractive.ps1 sets a repository-wide guard that makes editor-driven jj commands fail with clear guidance instead of blocking on an interactive prompt, with an advisory check in scripts/check-env.ps1 and setup guidance in CONTRIBUTING.md.
ProcessError.OutputIncomplete — the typed refusal a checking verb (RunAsync/ParseAsync/OutputJsonAsync) gives a capture the bounded post-exit output drain cut short, when something that inherited the child's stdout/stderr outlived it. Distinct from OutputTooLarge, which stays what it always was: output measured against a ceiling. It carries the program and no totals, because there is no bound here for a total to be quoted against.
The diagnostic result returned by RunningProcess.FinishAsync now exposes Finished.Truncated; for a single streaming process it is true when a stream the run pumped dropped items — the stdout line or byte-chunk stream, or the stderr byte-chunk stream of StderrChunksAsync — when captured stderr was shortened by its OutputBuffer policy, or when the bounded post-exit output drain cut the tail short.
Exec.outputAllWithPolicy / outputAllBytesWithPolicy add an explicit BatchPolicy to the batch fan-out verbs: BatchPolicy.CollectAll behaves exactly like outputAll/outputAllBytes (the default, unchanged), while BatchPolicy.FailFast stops starting any command still waiting for a concurrency slot and cancels every command already running on the batch's first Error, while every element still gets a Result in input order and a command's own Retry policy sees the cancellation exactly like the caller's own CancellationToken.
Command.Arg0(arg0) — a Unix-only opt-in override of the child's argv[0] independently of the executable that is actually launched (Program), supporting multicall binaries (BusyBox/Toybox) and the login-shell -bash convention. Program alone still drives PATH/PreferLocal resolution, preflight, and spawn diagnostics; arg0 must be non-empty and NUL-free (ArgumentException otherwise). On Windows a set value fails the spawn with ProcessError.Unsupported (no separate argv[0] contract); on POSIX it is likewise refused there when combined with a knob whose spawn path re-execs the target by name through a helper with no argv[0] seam of its own — a Uid/Gid/Groups/KillOnParentDeath drop (setpriv), Pty (setsid --ctty), a run under the Linux cgroup backend, or a ResourceLimits.CpuTimeMax run on the POSIX process-group mechanism (the /bin/shRLIMIT_CPU shim) — never a silent fallback to Program or a misapplication to the helper's own argv[0]. DryRunRunner renders it as (argv0: <value>), RecordedInvocation.Arg0 (ScriptedRunner) carries it, and a RecordReplayRunner cassette entry's new Arg0 field folds into the replay match key (CassetteEntry/command-fingerprint scheme v1 → v2; cassette format v9 → v10), so a recording made with one argv[0] never replays for a call with a different one or none.
RunningProcess.StderrChunksAsync() streams stderr as byte-exact ReadOnlyMemory<byte> chunks — the stderr twin of StdoutChunksAsync(), for diagnostics that text is the wrong abstraction for (a binary progress protocol, a log you relay or hash byte-for-byte). Each item is exactly one underlying read, with the same Command.StreamBuffer backpressure/drop/fail-loud policy, the same raw StderrTee, the same one-shot claim (a second call, or any other consuming verb, is refused as already consumed), and the same read-fault/teardown behaviour the stdout chunk stream has. Because the handle is consumed one way and Finished has nowhere to return stdout, this session drains stdout and retains nothing of it — it is still read, framed, teed and handed to OnStdoutLine, so the child never blocks, but asking for it afterwards is refused rather than answered with an empty stream, and FinishAsync()'s Finished.Stderr is empty because those bytes went to you. A run with no separate parent-side stderr — MergeStderr, a Pty run, StderrToFile, StdioMode.Inherit/Null — throws ProcessException carrying ProcessError.Unsupported naming that configuration, before claiming anything, instead of handing back an empty stream (under a merge or PTY those bytes are in stdout: use StdoutChunksAsync()). FakeProcess, ScriptedRunner and cassette replay hand out the same chunks, so a consumer is testable without a subprocess.
Mutation-testing tier for the library's boundary core (retained-output buffers, retry backoff, resource-limit and line-splitting rules): pwsh ./scripts/mutate.ps1 runs it locally, and a weekly Mutation tier workflow runs it sharded in CI, publishing every surviving and timed-out mutant with its source location. It is a ratchet on its own schedule and never runs on pull requests, so it cannot slow down or block ordinary CI. The committed baseline pins the mutant population a score was recorded over as well as the score itself, so a scope that silently stops matching part of the code — a renamed type — skips the comparison loudly instead of ratcheting on a quietly smaller program. Contributor documentation, including why an in-repo Mono.Cecil/IL engine replaces Stryker.NET (which cannot mutate F#), is in CONTRIBUTING.md.
ProcessLookup.processInfo(pid) / processIsAlive(pid, startTime) — standalone, identity-safe process lookup and reuse-safe liveness for a bare pid the caller holds outside any ProcessGroup (a pid saved to disk across runs, a launch registry, an external probe). processInfo returns the same MemberInfo contract ProcessGroup.MembersInfo gives a group member (Ppid/ExeName/StartTime, each honestly None where the platform cannot report it, argv/environment never read), through exactly the same per-platform readers — Ok(Some info) when the process exists, an honest Ok None when the pid names none, and a typed ProcessError.Io (never a false "gone") when the process may exist but could not be inspected (a denied OpenProcess, a Linux EACCES under hidepid=1, an unexpected errno; under hidepid=2/subset=pid a foreign /proc/<pid> is invisible rather than merely unreadable, so it reads as the same Ok None a gone pid does — see docs/platform-support.md). pid <= 0 is refused up front with Ok None; this process's own pid is an ordinary target. processIsAlive pairs the pid with a saved MemberInfo.StartTime token to tell the original process apart from a stranger that recycled the number: it degrades honestly to bare-pid liveness only when no token was saved, and returns a typed ProcessError.Io — never a guessed "alive" — when a token was saved but the live process's current start time could not be read right now. On POSIX a "zombie" (exited but not yet waited by its real parent) still reads Ok(Some _)/alive, unlike Windows, where the same state is Ok None/not-alive.
Exec.outputStream / Exec.outputStreamBytes — the streaming siblings of outputAll/outputAllBytes: the same bounded, concurrency-capped fan-out, but returning an IAsyncEnumerable of BatchItems that yields each command's result the moment that command finishes (completion order, not input order), so a fast command never waits behind a slow sibling and every item already handed over survives a mid-fan-out cancellation. Each BatchItem carries the command's Index (its position in commands) and its own Result, with the same meaning as one element of outputAll. Nothing runs until you enumerate; the hand-off is bounded at the concurrency cap, so a slow consumer throttles the fan-out rather than letting it run the whole batch ahead. Cancelling the batch token (or the one passed to WithCancellation) cancels in-flight captures and leaves queued commands unstarted without truncating the stream — a command that never started yields ProcessError.Cancelled — while abandoning the stream cancels the in-flight captures and starts nothing further. The streaming verbs take no BatchPolicy: they never short-circuit; the fail-fast contract stays on outputAllWithPolicy/outputAllBytesWithPolicy. The existing collect-all verbs are unchanged.
ProcessGroup.LimitEvidence() — post-run, per-axis evidence of whether a resource cap the group ever configured actually fired: one LimitVerdict (Tripped/NotTripped/Unknown) for each of Memory/Processes/Cpu, read from the container's own authoritative post-mortem counters rather than re-derived from the ResourceLimits that requested the cap or inferred from the run's exit code/signal. Only the Linux cgroup v2 mechanism can answer Tripped/NotTripped from real evidence (memory.events's oom, pids.events's max, cpu.stat's nr_throttled); a Windows Job Object keeps no such post-mortem record for any axis it ever capped and answers Unknown there (but NotTripped for one it never capped, without touching native), while the POSIX process-group fallback has no evidence apparatus at all and answers Unknown on every axis unconditionally, including one it never capped. Cpu answers for CpuQuota specifically; a NotTripped it would otherwise report is downgraded to Unknown whenever the group also carries a CpuTimeMax cap, because no mechanism here can attribute a Windows job-time or POSIX RLIMIT_CPU trip either. IoMax and CpuAffinity have no corresponding axis at all. Available only after the group has been torn down (ShutdownAsync/Dispose/DisposeAsync/the finalizer) — the evidence is captured once, from the still-live container, in the instant before teardown, and cached from then on; calling it before that returns a non-transient ProcessError.Unsupported rather than a fabricated verdict. ReportJson.LimitEvidenceTypeInfo / ReportJsonExtensions.ToReportJson(LimitEvidence) add it to the opt-in JSONL report schema as a limit_evidence line. See docs/process-groups.md.
spec/identifiers.json — a generated, machine readable dictionary of ProcessKit's stable identifiers, so a sibling implementation, a conformance test, or a log pipeline can read one versioned file instead of transcribing wire names out of the documentation. It publishes every variant of Mechanism, Signal, RlimitResource, and IoPriorityClass (class: "configurable" — what a caller supplies) and of Outcome, ProcessError, LimitVerdict, and SupervisionEventKind (class: "report_only" — what ProcessKit reports), each as { "variant": "<F# case>", "identifier": "<wire name>" }, in the same shape as the ProcessKit-rs crate's manifest of the same name; Signal.Other is deliberately absent, since its meaning is the raw number the caller passed. The file is generated from the live cases and never hand edited: every identifier comes from the same library function through which ProcessKit emits that string — the "kind"ReportJson writes for an Outcome and for each limit_evidence axis, the FailureKind and the Name a SupervisionEvent carries, the Name an RlimitResource spells, the Name an IoPriorityClass spells — so what a consumer receives and what the dictionary publishes cannot disagree, and a test ties each published identifier back to the emitted one. RlimitResource and IoPriorityClass are tied the other way too, being the published vocabularies ProcessKit also parses: every identifier in those two blocks is asserted to round-trip through its own TryFromName, so a resource or I/O-class name read out of the dictionary is always one the builder accepts. Adding a union case without naming it fails the build; adding a SupervisionEventKind without naming it fails that test instead (an F# match over a .NET enum needs a wildcard arm, so the compiler cannot refuse it); adding any case without regenerating the file fails the test that CI runs as its own step. New identifiers are appended and a shipped one is never renamed, respelled, or reused. The dictionary carries identifiers only — never a program name, argument vector, environment value, path, or captured output. The dictionary covers the enum vocabularies only: a report line's own envelope tag (process_result, limit_evidence, ...) names a report shape rather than a case of a type and stays documented with the schema, and the processkit.outcome span and metric labels are a separate, older set that keeps its own spelling (timedout, not timed_out); see docs/jsonl-reports.md and docs/observability.md.
ProcessGroup.ShutdownReportAsync(gracePeriod) / ShutdownReportAsync() — the introspective sibling of ShutdownAsync: it drives the exact same teardown (the configured Options.StopSignal, then the grace, then an unconditional hard kill of any survivor, then release) but returns a ShutdownReport of what the teardown actually observed instead of a bare Task — the soft signal's fate (SoftSignalDelivery.Sent/Unsupported/Failed, carrying the Signal attempted), how many members were alive before and after (None only if that membership read failed, never a fabricated 0), whether the tree drained within the grace or needed the hard kill (DrainedWithinGrace/Escalated), and the real elapsed time. Purely additive — ShutdownAsync's own behaviour and signature are unchanged. ProcessGroup.SoftStopScope() is a companion, side-effect-free capability query: it reports how far a soft stop (Signal.Int/Signal.Term) reaches on the group's current live membership, right now — SoftStopScope.WholeTree on the Linux cgroup v2, FreeBSD process-reaper, and POSIX process-group mechanisms, OptInMembers or Unsupported on the Windows Job Object depending on whether a live console-CTRL leader or windowed member is present — so a caller can know the real reach before attempting a soft stop rather than parsing ProcessError.Unsupported back after the fact. See docs/process-groups.md.
Command.Rlimit(resource, soft, hard) (mirror: Command.rlimit) — typed per-process Unix resource limits applied to the child before its program starts (setrlimit(2) semantics), the per-child complement of the whole-tree ProcessGroupOptions/ResourceLimits caps: where a group cap is one budget the container enforces over the whole tree at once, an rlimit is inherited individually by every descendant, each free to lower it further or raise its soft value back to the inherited hard one. RlimitResource names the six resources — Cpu (seconds), Core, Data, FileSize, Stack (bytes), and NoFile (a count) — each with a stable machine identifier ("cpu", "core", "data", "file_size", "no_file", "stack") that a config-driven caller can map through RlimitResource.TryFromName (None on a miss) or RlimitResource.FromName (ArgumentException listing every accepted spelling), with RlimitResource.All enumerating the set; an unknown name is always an honest miss rather than a limit silently applied to the wrong axis or to none. Calls for different resources accumulate and a repeated one replaces its pair in place; soft/hard must be non-negative with soft <= hard, rejected at the builder boundary with ArgumentOutOfRangeException, and there is deliberately no "unlimited" value (the builder exists to lower what the child inherited). Command.Rlimits reads the configured set back and DryRunRunner renders it as (rlimits: no_file=64:128, …), so a preview never reports a weaker command than the one that would run. Where a per-process Cpu limit meets a group's ResourceLimits.CpuTimeMax — the one axis both cap — the stricter value wins on each of the soft and hard values, applied together in a single step so the looser one can never overwrite the tighter one. Unix-only and honestly so: on Windows, which has no setrlimit concept, the contained and detached spawn paths both fail with ProcessError.Unsupported; on POSIX the limits are installed by util-linux's prlimit (loaded only from a trusted system directory, never PATH, exactly as setpriv is), which sets them on itself and execs the target in place, so containment, Priority and a PTY are unaffected — a host holding that helper nowhere trusted (macOS/BSD, a minimal image) fails with ProcessError.ResourceLimit rather than running the child uncapped, and combining the builder with Command.Arg0 is refused with ProcessError.Unsupported for the same reason the other re-execing helpers refuse it. ProcessGroup.Capabilities().Helpers lists prlimit alongside setpriv/setsid//bin/sh, with that same precondition when this host holds it in no trusted directory, so the gap is discoverable up front rather than only at the refused spawn. Existing CpuTimeMax-only runs are unchanged and still need nothing but /bin/sh. See docs/commands.md and docs/process-groups.md.
Command.IoPriority(priority) (mirror: Command.ioPriority) — a typed Linux I/O-scheduling priority for the child and the tree it spawns, so background disk work can yield to the interactive users of the same device. A separate axis from the CPU-scheduling Command.Priority and not a substitute for it: that one decides how much processor the child gets, this one how its block-device requests are ordered. IoPriorityClass names the three kernel classes — Idle (I/O only while the device is otherwise idle), BestEffort and RealTime (each with a level 0, highest priority, through IoPriority.MaxLevel = 7, lowest) — each with a stable machine identifier ("idle", "best_effort", "real_time") that a config-driven caller can map through IoPriorityClass.TryFromName (None on a miss) or IoPriorityClass.FromName (ArgumentException listing every accepted spelling), with IoPriorityClass.All enumerating the set and spec/identifiers.json publishing it under ProcessKit.IoPriorityClass. Values are built by the validating factories IoPriority.Idle / IoPriority.BestEffort level / IoPriority.RealTime level, which reject a level outside 0..7 with ArgumentOutOfRangeException at construction rather than clamping it or handing the kernel a value it would refuse; DryRunRunner renders the request as (io_priority: best_effort:7) so a preview never reports a weaker command than the one that would run. It is applied by arming the spawning thread with ioprio_set(2) across the spawn itself and restoring it immediately after: Linux copies the creating task's I/O priority into the child, and the value survives exec, so the priority is in force for the child's first block-device request rather than from some moment after it started, is inherited by every descendant, and composes unchanged with Uid/Gid, Pty, Arg0, Command.Rlimit and a cgroup-contained group without an extra helper binary or argv layer. Linux-only and honestly so: Windows, macOS, and the BSDs have no such system call and fail the spawn with ProcessError.Unsupported, as does a Linux architecture whose ioprio_set system call number ProcessKit does not know and a kernel (or seccomp filter) that answers ENOSYS; Command.LaunchDetached refuses it on every platform, including Linux, because the setting is applied by the owner across the spawn and that verb deliberately gives ownership up. Idle and BestEffort need no privilege; RealTime needs CAP_SYS_ADMIN (or CAP_SYS_NICE on Linux 5.14+) and, without it, fails the spawn with ProcessError.Spawn rather than being quietly downgraded. What the knob promises is that the class and level are recorded on the child; whether they change the order requests are actually served in is the device's I/O scheduler's decision — Linux honours I/O priorities under BFQ (and the historical CFQ), while mq-deadline, kyber, and none largely ignore them. See docs/commands.md and docs/platform-support.md.
RunningProcess.WaitForPathAsync(path, timeout, cancellationToken) — a filesystem-path readiness probe, the portable signal used by pidfiles, sentinel/lock files, and a Unix-socket pathname a daemon creates before a caller dials it with WaitForSocketAsync. It is an existence check only (a file and a directory both count as ready; it does not wait for a writer to finish), a filesystem lookup failure is retried as "not ready yet" rather than surfaced as a fault, and it shares the same deadline/cancellation/early-exit-on-child-death contract as WaitForPortAsync/WaitForSocketAsync/WaitForHttpAsync/WaitForAsync. Never returns ProcessError.Unsupported — an existence check has no platform precondition. A relative path resolves against the run's own Command.CurrentDir (the child's working directory) when one was configured, otherwise against the calling process's own current directory, the same rule Command.PreferLocal already applies. See docs/streaming.md#readiness-probes.
Mechanism.ProcessReaper — a dedicated FreeBSD containment backend built on the kernel process reaper (procctl(2)'s PROC_REAP_ACQUIRE/PROC_REAP_GETPIDS/PROC_REAP_KILL), so FreeBSD is no longer folded in with macOS and the other BSDs on the weaker POSIX process-group mechanism. Acquiring reaper status makes the process the reaper of its whole descendant tree, which closes the process group's one documented escape hatch: a descendant that calls setsid() leaves the process group but not the reaper's subtree, so kill-on-dispose, KillAll, Signal, Suspend/Resume and the graceful ShutdownAsync/ShutdownReportAsync tiers all reach it — each delivered once per process through PROC_REAP_KILL, per subtree, so one group never touches another's tree inside the process-wide reaper. Members()/MembersInfo()/Stats().ActiveProcessCount report the whole live tree rather than the tracked group leaders (zombies excluded, since a member that has exited is not a member), and SoftStopScope() is WholeTree with no escapee at all. Being the reaper is also an obligation the library discharges: an orphaned descendant re-parents onto this process instead of init, so ProcessKit waits for those corpses on every reaper read plus a short bounded drain at teardown — and only for processes it did not fork itself, so no run verb's exit status is ever taken from it. What does not change: a reaper is a containment relationship, not a container, and accounts for nothing, so whole-tree resource limits stay refused with ProcessError.ResourceLimit (never a per-process RLIMIT_* surrogate presented as a whole-tree cap), LimitEvidence() is Unknown on every axis, Adopt/AdoptByPid stay Unsupported (the reaper holds this process's own descendants; PROC_REAP_ACQUIRE does not re-attach even children forked before it), and per-member CPU/memory stays honestly absent (FreeBSD has no /proc by default). Reaper status is acquired once per process, permanently, at the first ProcessGroup.Create; a host where that call is refused falls back to the POSIX process group and the created group reports Mechanism.ProcessGroup, so the mechanism query never overstates the containment in force. ProcessGroup.Capabilities() reports the new mechanism with Creation as Qualified for exactly that reason — a snapshot must not acquire reaper status to find out. Linux, macOS, the other BSDs and Windows are unaffected: the backend is selected only on a FreeBSD runtime. spec/identifiers.json gains the process_reaper identifier. See docs/platform-support.md.
RunningProcess.WaitForNamedPipeAsync(pipeName, timeout, cancellationToken) — a Windows named-pipe readiness probe, dialled through CreateFileW against duplex, then read-only, then write-only client access so readiness does not depend on which single direction a one-way server exposes. pipeName may be bare (resolved under the local \\.\pipe\ namespace) or already fully qualified (a local or a remote server's UNC path). A pipe reporting ERROR_PIPE_BUSY counts as ready, not absent — it proves a server created the pipe even though every instance is currently serving another client — and shares the same deadline/cancellation/early-exit-on-child-death contract as WaitForPortAsync/WaitForSocketAsync/WaitForPathAsync/WaitForHttpAsync/WaitForAsync. Windows-only: every other platform returns ProcessError.Unsupported immediately, before ever attempting to open a pipe, symmetric with WaitForSocketAsync's AF_UNIX gate. See docs/streaming.md#readiness-probes.
RunningProcess.WaitForStderrLineAsync(predicate, timeout, cancellationToken) and RunningProcess.WaitForStderrTailAsync(predicate, timeout, cancellationToken) — readiness waits on the diagnostic stream, for the many tools that publish their readiness marker or prompt to stderr rather than stdout. The line wait is WaitForLineAsync's exact contract pointed at stderr (returns the matching line; ProcessError.NotReady on the clamped deadline, Cancelled on your token, and a prompt NotReady — not a hang — once the child exits or stderr reaches EOF), framed with Command.StderrLineTerminator and decoded with Command.StderrEncoding, so it sees exactly the lines OnStderrLine, StderrTee and Finished.Stderr see. The tail wait matches the unterminated tail as it grows, so a prompt that never gets a newline (Password: , Continue? [y/N] ) is matchable at all — a line-framing wait holds such text in the pump's assembly buffer until a terminator arrives — and content that does end up terminated is still offered complete just before it is framed. Both observe stderr rather than taking it: what a wait matched still reaches OnStderrLine, the tee and the capture exactly once (a matched tail arrives there once, later, inside the line it is framed into, never as an extra line), while the readiness view itself is consumed so a later wait does not re-read it. Both join the one stdout streaming session, so they compose with WaitForLineAsync/StdoutLinesAsync/FinishAsync and are refused with the usual already-consumed ProcessError.Unsupported after a verb that owns the pipes (or after a terminal FinishAsync discarded stdout); a run with no separate stderr — MergeStderr, a Pty run, StderrToFile, StdioMode.Inherit/Null — is refused up front with Unsupported naming the cause instead of a NotReady about a stream that never existed. What they retain between successive waits (the framed lines, plus the current tail) is capped at OutputBufferPolicy.MaxBytes when the run set one, else 64 KiB, force-flushing the tail at the cap exactly as an unterminated line is force-flushed into a capture, so a newline-free stderr flood cannot grow it. See docs/streaming.md.
ProcessError gains a StdoutBytes: byte[] option read-only accessor (ProcessError.StdoutBytes, forwarded by RetryPredicate like Stdout/Stderr/Code) carrying the exact pre-decode stdout bytes on Exit/Signalled/Timeout when the failure came from a bytes-based capture — OutputBytesAsync (on a Command or a Pipeline) followed by ProcessResult.ensureSuccess/EnsureSuccess() on the resulting ProcessResult<byte[]>. None for a text-based capture (ProcessResult<string> — including RunAsync/ParseAsync/OutputJsonAsync and their pipeline twins, which are always string-typed and so never populate it): the bytes are never reconstructed from the already-decoded Stdout text. Purely additive — Stdout's decoded text, Message/ToString(), and the existing ProcessError.Exit/Signalled/Timeout constructors are unchanged.
RunningProcess.StdoutBytesSeen / RunningProcess.StderrBytesSeen — live, monotonic Int64 counters of the raw bytes read from the child, counted at the parent's own read of the pipe and therefore before decoding, line framing, or any policy could drop or refuse anything. They measure what came off the child rather than what was kept, so they include the bytes of a line a dropping StreamBuffer policy discarded, of output an OutputBuffer ceiling refused as too large, and of a run whose output is discarded entirely (WaitAsync); they are unaffected by the stream's encoding and line terminator (a UTF-16 stream counts wire bytes, not characters) and behave the same across every consumption mode — buffered captures, line/chunk/event streaming, and a readiness probe's background drain. Like the existing line counters they are cheap to read at any time, mid-stream and afterwards: each keeps its final total once the pumps end, including after FinishAsync()/DisposeAsync(). A stream the parent never reads answers a deterministic 0 — StdioMode.Null/Inherit, a file redirect (Command.StdoutToFile), or a stream the command did not configure — and a merged run (Command.MergeStderr(), or a Command.Pty() run's single terminal device) counts every byte into StdoutBytesSeen with StderrBytesSeen left 0, the same boundary StderrChunksAsync() reports as unsupported. See docs/streaming.md.
Command.CapturePolicy(policy) (mirror: Command.capturePolicy) installs an ICapturePolicy — a typed, named seam that shapes every decoded line just before it enters the in-memory capture backlog, so a secret a child echoes can be scrubbed out of ProcessResult.Stdout/Stderr and Finished.Stderr without changing the child's real output. ICapturePolicy.OnCapture(stream, line) receives a CaptureStream discriminator (new: Stdout/Stderr) so one policy can treat the two streams differently, and ICapturePolicy.Name is surfaced by the new Command.ConfiguredCapturePolicyName so a configured policy is introspectable rather than an anonymous callback. The boundary is deliberately narrow and unchanged for everything else: the per-line handlers (OnStdoutLine/OnStderrLine), the tees (StdoutTee/StderrTee), the streaming verbs (StdoutLinesAsync/OutputEventsAsync/WaitForLineAsync, the byte-chunk streams, PtySession, ContentLengthSession, the readiness probes) and a raw byte capture (OutputBytesAsync's stdout) all keep seeing the unshaped line — a bytes run's line-pumped stderr is shaped. A Pipeline captures nothing but raw bytes (its final stdout and every stage's stderr), so a stage carrying a policy is rejected by Pipe with an ArgumentException naming the field and the stage index — like the per-stage Timeout/Retry/CancelOn it already refuses — instead of running the chain with the redactor silently inactive. A policy that throws or returns nullfails closed: that line is retained empty, never raw, the run is not failed, and the policy stays active for later lines. It composes with OutputBuffer, whose retention accounting is computed from the shaped text, while the line and raw-byte counters keep reporting what the child produced. The three test doubles route their retained capture through the same seam — ScriptedRunner and FakeProcess because they capture through the real pump, and RecordReplayRunner on both halves (a recording stores the capture already shaped — a string entry's stdout and stderr, and a bytes entry's stderr; a bytes entry's byte[] stdout is the capture no policy shapes, and RecordReplayOptions.WithRedaction does not reach it either, so it is stored as captured. A replayed entry is shaped by the replaying command's policy, which keeps a cassette hit agreeing with the SpawnAsync replay of the same entry). Unset (the default) retains exactly what was framed, byte for byte as before. See docs/hardening.md and docs/testing.md.
Changed
Batch Exec.outputAll/outputAllBytes and streaming operations now schedule only O(concurrency) worker tasks, so large batches no longer create one semaphore-waiting task per command while preserving bounded execution and result ordering.
Command.Retry and Command.RetryBackoff now reject a negative maxAttempts with ArgumentOutOfRangeException when the command is built, including through their pipe-friendly mirrors and CliClient.WithDefaults, instead of silently treating it as a single run; 0 and 1 still mean one run.
A fail-loud streaming backlog overflow (StreamBufferPolicy.Bounded(capacity, StreamFullMode.Error)) now reports the raw bytes read from the child as the TotalBytes of the ProcessError.OutputTooLarge it raises — the same number the handle publishes as StdoutBytesSeen (both pipes' totals summed, for the merged OutputEventsAsync stream) — instead of re-deriving an approximation from the re-encoded size of the lines that had been queued. Neither overflow quotes a ByteLimit (what filled up is an item backlog), so this only makes the accompanying total exact; the LineLimit/TotalLines fields, and the byte totals a capture reports (ProcessResult's, which count retained, post-decode output in the currency of the OutputBuffer cap that bounds it) are unchanged.
The repository's Stress and randomized Interleaving test fixtures are now opt-in: ordinary local and CI test runs skip them, while selecting Category=Stress or Category=Interleaving executes the corresponding suite.
The repository's test hosts now enrol themselves in a Windows Job Object carrying JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE and no other limit, so nothing a test run spawns can outlive it — including the children no ProcessGroup contains by contract (a Command.LaunchDetached child and the ConPTY console-host sidecar) and whatever a test stranded by failing, timing out, or crashing before its own cleanup left behind. A no-op off Windows: the problem it solves is a Windows one, and off Windows the primitives that could hold the host's own descendants are the very mechanisms the suite tests. The library itself is unchanged.
RecordReplayRunner's best-effort flush on dispose is now opt-in: it happens only after Complete() — a new no-I/O, never-throwing mark declaring the recording finished — so a use recorder = RecordReplayRunner.Record(…) (or Auto) scope that previously persisted its cassette without an explicit Save() now needs one line, recorder.Complete(), as the last thing in that scope — after anything that can throw, since the mark and not the shape of the exit is what dispose reads. This is the deliberate price of the crash-safety fix below (.NET gives Dispose no way to tell a normal scope exit from an exception unwinding through it), and it is the TransactionScope.Complete() shape. Save() is unchanged and unaffected in both directions: it writes immediately, still returns the I/O error, and neither needs nor sets the mark — so a recording that must reach disk however the scope ends is one you Save(). Replay mode records nothing and is untouched, as are the cassette format, matching, redaction, atomic-and-owner-only writing, and cross-writer save serialization.
UncheckedInPipe on the last stage now accepts only actual exit codes; signal, timeout, and unobserved outcomes remain failures. Previously, an all-unchecked chain always reported success; its success now depends on the last stage's outcome type.
A call that used to succeed can now fail: RunAsync (and ParseAsync/TryParseAsync/OutputJsonAsync, on a command or a pipeline) returns ProcessError.OutputTooLarge when the OutputBuffer policy dropped output, where it previously returned the clipped remainder. If you set a DropOldest/DropNewest cap and want whatever survived it, switch that call to OutputStringAsync/OutputBytesAsync and read ProcessResult.Truncated; RunUnitAsync, ExitCodeAsync, and ProbeAsync are unaffected, as are runs with no cap configured (the default) and captures that land exactly on their cap.
A ProcessError.OutputTooLarge message now quotes only the totals that were actually counted, instead of printing an uncounted one as 0: a capture bounded by lines alone counts no bytes, so it reads 'tool' produced too much line output (5000 lines) rather than appending / 0 bytes, and a refusal over a result that carried no totals at all (a replayed cassette, a test double) reads 'tool' produced too much line output. Which totals each wording quotes is otherwise unchanged, and the TotalLines/TotalBytes fields themselves are untouched — a 0 there has always meant "not reported for this channel".
A ProcessError.Unobserved message no longer claims the process concluded — it now reads 'tool' has no observed exit status: <reason> — because an unobserved outcome can also come from a hard-killed tree that was not reaped inside the bounded post-kill window, whose child may still be alive. The Detail reason, unchanged, says which case it was.
A ProcessError.NotFound message now reports how many entries the PATH it searched held — program 'tool' was not found (searched 84 PATH entries) — instead of quoting the PATH value itself, so an environment value no longer lands in every not-found log line and the message no longer grows with the caller's PATH. The searched path is unchanged and complete on the Searched field; read it when you want to name the directories.
A failed run's message now quotes only the last non-blank line of Stderr rather than the whole captured stream: ProcessError.Exit no longer folds a multi-line stderr into its message, and ProcessError.Signalled/ProcessError.Timeout now carry that same one-line diagnostic (a hung or killed tool's last stderr line is usually the explanation) where they previously carried none. Stdout stays out of the render, and both streams remain available in full on Stdout/Stderr/Combined.
Command.Retry no longer refuses a one-shot stdin source (Stdin.FromStream/FromLines/FromAsyncLines) before the first attempt: the command always runs once, and a second attempt follows only after a failure that precedes a live child (NotFound, Spawn, or a launch-boundary Unsupported) — after anything that may have reached one (Exit, Timeout, Signalled, Stdin, OutputTooLarge, Cancelled, or the ambiguous Io) the run ends with that first error, never passed to the retry predicate, instead of replaying an exhausted source.
A run with a retry budget (Retry/RetryBackoff with more than one attempt) now reserves its one-shot stdin source for itself, so a second run over that same source — one that starts while the first still holds it, or a later one, once a child has been launched over it — is refused with ProcessError.Unsupported instead of being fed the exhausted remains of it. The reservation is what keeps the source off-limits to another run for the whole of a retrying run, including the gaps between its attempts; each individual launch takes the source for itself as well, whichever verb or runner makes it (see the one-shot stdin ownership entry under Fixed), so a run without a retry budget is refused a source some child has already read just the same. The hold is a loan, returned unless some attempt actually launched a child over the source — the spawn itself is what records that, so the launch and not the shape of the ending is what decides — and a run refused by an already-cancelled token, one cancelled during its retry backoff, one ended by a throwing retry classifier, one whose attempt threw before launching, and a run driven through a runner that spawns nothing at all (a DryRunRunner preview, a ScriptedRunner reply) all leave the source exactly as they found it. A run whose attempt did launch a child over it keeps the source spent for good, including one cancelled while that attempt was still in flight: a cancellation hands the source back only while nothing has been launched over it yet. The hold covers one launch at a time and never covers a source a child has already read, so having a retry policy cannot be turned into a second child: a decorator that calls its inner runner twice with the same command, and a command a runner kept and started after the run was over, are both refused exactly like any other second consumer.
Command.Arg0's new CassetteEntry.Arg0 field bumps the command-fingerprint scheme from v1 to v2 (cassette format v9 → v10, see Added). An existing cassette recorded with RecordReplayOptions.WithCommandProjection under a pre-v2 build stores a v1 fingerprint, which never matches a freshly computed v2 one — every entry in it becomes an ordinary, safe ProcessError.CassetteMiss (never a wrong hit) until it is re-recorded; a cassette recorded withoutWithCommandProjection is unaffected, since it keys from its own stored program/args rather than a fingerprint.
Fixed
DI registration overloads now reject a null IServiceCollection with ArgumentNullException using the consistent services parameter name.
JobRunner, ProcessGroup, and the in-memory testing runners now reject a null command with ArgumentNullException before cancellation handling, process launch, group mutation, or test-double recording.
PtySession no longer blocks construction while a Stdin(source) feeder is delayed by a child that is not reading; SendAsync, SendLineAsync, and CloseStdinAsync now await the feeder before using or closing the interactive pipe.
Interactive session close verbs now accept cancellation while waiting for a Stdin(source) feeder or send gate, returning ProcessError.Cancelled without starting EOF delivery; the existing uncancellable delivery and parameterless overloads are unchanged.
FreeBSD process-reaper signal delivery now reports a successful PROC_REAP_KILL that reached only part of a subtree, including the first member that refused the signal, instead of treating it as complete success.
ProcessGroup.UpdateLimits(null) now throws ArgumentNullException with ParamName = "limits" before lifecycle, sticky-evidence, or backend state is touched, instead of failing inside limit evidence with NullReferenceException.
JsonRpcSession now refuses requests, notifications, and peer responses that reach the send gate after the session has ended, returning the same terminal error without writing a frame or leaving a request pending.
POSIX executable lookup now preserves leading, middle, and trailing empty components in a non-empty PATH as the effective current directory at their exact search position, so Exec.which matches inherited native PATH selection and Command.ResolveProgram matches command-specific launches; an absent or wholly empty PATH still has no implicit current-directory search.
ProcessGroup.Create(options) now rejects null options with ArgumentNullException at the public API boundary, matching ProcessGroup.Capabilities(options), instead of failing later with NullReferenceException while reading the options.
ProcessError.RetryPredicate values constructed from C# with a nullOriginal now render the original attempt as unavailable through Message/ToString() and return absent values from Stdout, StdoutBytes, Stderr, Combined, Code, and Signal, instead of throwing NullReferenceException; the public union case and generated factory keep their existing shape.
A reusable Linux cgroup v2 ProcessGroup now explicitly thaws and verifies cgroup.freeze=0 after an atomic KillAll() or Signal(Signal.Kill), so suspending and hard-killing the group no longer leaves later children frozen; a refused thaw returns the existing typed I/O failure, while final cleanup still accepts a cgroup that has already disappeared.
Windows ConPTY creation and resize failures now decode FACILITY_WIN32 HRESULTs into their original Win32 error code and system message, while retaining non-Win32 HRESULTs verbatim, instead of reporting every failure as an opaque HRESULT; public failures remain ProcessError.Spawn and ProcessError.Io respectively.
PtySession.ExpectAsync now rejects zero-length string and regex matches with a consistent typed ProcessError.Unsupported result, so empty, anchor-only, and lookaround patterns cannot repeatedly consume an unchanged output window.
Stderr readiness retention now applies one byte cap to the combined pending framed lines and unterminated tail, preventing a handle from retaining nearly two caps of diagnostic output while preserving newest-line and bounded-tail behavior.
JsonRpcSession no longer silently drops an unread peer request when its bounded decoded-message backlog overflows: notifications remain lossy and counted by DroppedMessages, but evicting a request now ends the conversation with ProcessError.OutputTooLarge, faults MessagesAsync after its retained messages, and fails pending and later local requests with the same terminal error; response routing still bypasses the backlog and cannot be stalled by a slow message consumer.
A cancelled cassette Replay or Auto hit no longer consumes its matched entry before returning ProcessError.Cancelled: text, bytes, and SpawnAsync replay now keep duplicate entries in capture order, then repeat the last as documented, with lookup acceptance and cursor advancement committed atomically under the existing replay gate; existing cassette files and their format are unchanged.
POSIX commands that set, remove, or clear the child's PATH now launch the bare-name executable that Command.ResolveProgram() reports on ordinary, LaunchDetached, and Linux cgroup-v2 paths, or return its matching ProcessError.NotFound before spawn, instead of allowing posix_spawnp or the cgroup migration shell to select a same-named executable from the parent, default, or current-directory search context; inherited absent/empty paths are likewise resolved before native fallback search, while untouched non-empty child paths, path-form programs, PreferLocal, CurrentDir, and Arg0 retain their existing behavior.
ProcessGroup.Suspend()/Resume() and Signal(Signal.Usr1)/Signal(Signal.Usr2) now deliver the right signal on FreeBSD and the other non-macOS BSDs. The four signal numbers that differ between the Linux and BSD tables were selected by a macOS-only check, so every other BSD silently got the Linux numbers — and read against the BSD table those numbers name different signals, which inverted both halves of the suspend/resume pair: Suspend() delivered 19, which is SIGCONT there, and so resumed the tree, while Resume() delivered 18, which is SIGTSTP there, and so stopped it — each verb did its counterpart's job. Signal.Usr1/Signal.Usr2 delivered 10/12, which on that table are SIGBUS/SIGSYS — fatal by default — rather than the user-defined 30/31. The selector is now "does this host use the BSD signal table", which macOS and every BSD share. Linux and Windows are unaffected, as are Signal.Term/Kill/Int/Hup/Quit, whose numbers are the same on both tables.
A single-command run no longer hangs when the child spawned something that inherited its stdout/stderr and outlived it — a daemonized worker, a setsid helper, a shell's background job. The parent's pipe only reaches end-of-file when that last writer closes it, so OutputStringAsync/OutputBytesAsync, WaitAsync/ProfileAsync, the line/chunk/event streams' FinishAsync, WaitAnyAsync/WaitAllAsync and the interactive sessions all used to wait on it indefinitely with the child's exit status already in hand — and, never reaching their own teardown, left the run's private process group (and everything still in it) alive with them. Once that exit status is known the output pumps now get a short window to finish an ordinary tail, after which the run's own read ends are closed and the verb returns the outcome it already had: a partial stdout/stderr reports Truncated = true (Finished.Truncated for a streaming run) rather than passing for the whole output, a discard verb simply concludes, and teardown proceeds — reaping the leftovers of a private group, and detaching only this run's I/O from a shared ProcessGroup, whose other runs and their descendants are untouched. A read fault, a throwing OnStdoutLine/OnStderrLine handler or a failing tee that happens before that window still surfaces as the error it always was, and neither Command.Timeout nor a run's disposition changes. The bound covers every verb that runs through one RunningProcess handle — which is every Command/Exec capture verb and every streaming or interactive session; a Pipeline's own buffered verbs and its per-stage stderr joins are not part of this change and still wait for each stage's output to end. The checking verbs (RunAsync/ParseAsync/OutputJsonAsync) refuse a capture the bound cut short with the new ProcessError.OutputIncomplete rather than present a clipped string as whole output — its own case, not the buffer-ceiling OutputTooLarge, because nothing crossed a ceiling here and no OutputBuffer setting would have changed it; OutputStringAsync/OutputBytesAsync remain the lenient path.
OutputBytesAsync now keeps the stdout bytes it had already read when a concurrent StopAsync/Dispose closes the pipe mid-capture, instead of reporting an empty Stdout. The raw capture accumulates into a buffer the verb owns rather than one the read loop discarded on any non-EOF ending, so the teardown race ends as a partial capture — matching what the text verb already did.
A RecordReplayRunner no longer writes a cassette as a side effect of a crash that interrupts an unfinished recording. A recording scope left by a thrown exception or a failed assertion — Dispose runs while the stack unwinds, exactly as it does on a normal exit — used to flush whatever had been captured, so a test that recorded a call and then failed left program, args, cwd, and captured stdout/stderr (all stored verbatim, and redaction is opt-in) on disk, where a fixture directory can carry them into a commit. The drop-time flush now happens only for a recording declared finished with Complete(): an uncompleted one creates no cassette at the target path and leaves an existing one byte for byte as it was, without even opening it — while Save() remains the unconditional, error-reporting way to persist a recording whatever the scope did. That mark is the entire gate — Dispose is never told how the scope ended — so a scope that throws afterComplete() is still flushed exactly as a normal exit is: put the call last, after the assertions, or skip the dispose flush and Save() instead. Record and Auto behave identically here, and Dispose still never throws.
FinishAsync no longer holds a run's entire stdout in memory when nobody is streaming it. Called on a handle whose stdout stream was never handed out — a fresh handle, or one only WaitForLineAsync looked at — it now discards each line as it is framed instead of queueing every one of them into the streaming channel it never returns: that channel is unbounded unless Command.StreamBuffer opts in, and Finished carries the Outcome and stderr but never stdout, so a chatty child's whole output was pinned in memory until the handle was disposed, for output the caller had just declined to take — a multi-gigabyte producer could exhaust the process before it exited. Nothing else on that path changes: OnStdoutLine, StdoutTee, StdoutLineCount, line framing and encoding, and every handler/read fault behave exactly as before, as do the stderr capture, timeouts, stdin-source errors, and the reported Outcome. A stdout stream you did take (StdoutLinesAsync/StdoutJsonLinesAsync/StdoutChunksAsync) is untouched — its backlog is still retained for its enumerator under whichever StreamBuffer policy you chose, and finishing an abandoned one still releases it without hanging. Three consequences for an untaken stream only. That stdout is gone for good and the API now says so instead of implying otherwise: StdoutLinesAsync/StdoutJsonLinesAsync called after such a FinishAsync throw the already-consumed InvalidOperationException and WaitForLineAsync returns ProcessError.Unsupported — the same refusal they give after WaitAsync/ProfileAsync, and where before you would have been handed the queued output (take the stream, or use OutputStringAsync/OutputBytesAsync, before finishing if you want it). Nothing can be dropped from a backlog that no longer exists, so Finished.Truncated now reflects just the stderr capture. And a StreamBufferPolicy with StreamFullMode.Error no longer reports OutputTooLarge for a capacity that can no longer overflow.
Disposing a Linux cgroup v2 ProcessGroup no longer leaves its cgroup directory behind, so a long-lived process that creates many groups stops accumulating empty processkit-* directories in the cgroup hierarchy. cgroup.kill is asynchronous — a member leaves the cgroup when it exits, which can happen after the kill returns — so teardown used to rmdir a cgroup that was still occupied, get EBUSY, and swallow it. It now waits, bounded (~100 ms, on top of the existing post-kill reap and never spent when the cgroup is already empty — the ordinary case), for the cgroup to actually empty and retries the removal inside that same window. The wait reads cgroup membership rather than reaping, so it works for an adopted process too, and an unreadable membership is never mistaken for a drained cgroup: the directory is only ever reported reclaimed on the kernel's own confirmation, and a cgroup that will not drain keeps its directory (a still-live tree is never orphaned by a removal) with the reason recorded rather than discarded.
A hermetic cassette replay now runs the command's own output side effects instead of silently skipping them: a strict Replay — and an Auto hit — drives the recorded stdout/stderr through OnStdoutLine/OnStderrLine and StdoutTee/StderrTee with the same encoding, line splitting, final-unterminated-line and per-stream ordering rules a live run applies, because it replays through the very handle SpawnAsync replay reconstructs, driven by the very verb that was called. A progress parser or a log tee under test is therefore no longer inert on replay, and a fault one of them raises surfaces instead of the recorded success — a throwing handler as itself, a failing sink as ProcessError.Io, exactly as live. Each verb keeps its own shape: the bytes verb feeds a raw stdout tee but calls no stdout line handler (raw bytes have no lines) while stderr stays line-pumped, and a PTY recording replays as one merged stream with no separate stderr handler or tee (the builder refuses one on a Pty command in the first place). A Record call and an Automiss are unchanged and never double up — the inner runner already produced those effects — and the replayed ProcessResult itself, with its recorded duration, truncation flag and exact bytes, is exactly what it was.
A byte[] cassette recording now replays its exact bytes through SpawnAsync too: the reconstructed handle is scripted with the recorded bytes themselves rather than a decode-then-re-encode of them, so a StdoutTee, OutputBytesAsync, or StdoutChunksAsync on a replayed handle no longer turns stdout that is not valid in the command's stdout encoding into U+FFFD — it now agrees byte-for-byte with what CaptureBytesAsync replay has always returned. Text recordings, and every decoded-text projection of either kind, are unchanged.
A POSIX Pty child killed or signalled in the instant after it starts is now actually reached, instead of being dropped from the group as if it had already exited. A pty child becomes the leader of its own process group only once its controlling-terminal helper runs, so for a moment after the spawn there is no process group carrying its number — and the group-liveness probe that answers "is this still ours?" reported that as the child being gone, so Dispose/ShutdownAsync/KillAll/Kill, a Signal/Suspend/Resume, and the graceful stop's escalation all skipped it and left it running (and Members stopped listing it). The probe now also asks about the exact child pid, and a control operation that arrives in that window reaches it: a kill SIGKILLs that pid and sweeps its process group behind it, so a subtree the child forks in the instant it takes ownership of that group is killed with it rather than orphaned, while an ordinary signal is still delivered exactly once — to the group if it exists by the time the signal lands, to the pid if it does not. Once the child does own its process group — as it does for the whole rest of its life, and as every non-pty child does from the moment it is spawned — delivery goes to the whole group exactly as before, so a signal still reaches the subtree it started. The wrong-target protection is unchanged and gates this too: a pid whose recorded start time no longer matches — or cannot be read at all — is never signalled and is dropped from the group instead, so a number the OS recycled onto an unrelated process is left alone rather than killed.
A completion verb no longer hangs on a Command.KeepStdinOpen stdin the caller never took. OutputStringAsync/OutputBytesAsync/WaitAsync/ProfileAsync, a WaitAnyAsync/WaitAllAsync wait that is a handle's own terminal consumer, and FirstLineAsync (before it starts streaming stdout) now end the child's input themselves, so a child that reads stdin to EOF exits — and produces its first line — instead of waiting on an end of input nobody could deliver: RunAsync/ParseAsync/TryParseAsync/OutputJsonAsync/FirstLineAsync never hand the caller a RunningProcess to close it through. It is the very same once-only claim TakeStdin/TakeStdinAsync makes, so a caller that took the writer keeps it — a verb never closes a handle it gave away, and completion still waits for that caller's own FinishAsync — and a TakeStdin racing a verb leaves exactly one owner, never a double close and never an abandoned open pipe. On a Stdin(source) + KeepStdinOpen run the whole source is delivered first, exactly as TakeStdin waits for it, and the end of input then goes out over the transport's own path: a plain pipe is closed, a POSIX PTY receives the terminal's own end-of-input character, and a Windows ConPTY receives Ctrl-Z + Enter over a session pipe that stays open. A live StartAsync handle is unchanged — the streaming verbs, the readiness probes, and the interactive sessions all leave the kept-open pipe for TakeStdin exactly as before.
Cassette loading now bounds encoded payloads at 64 MiB before JSON materialization, returning ProcessError.Io for oversized files (including files that grow while being read); Auto reuses that bounded payload for its whitespace check instead of reading an existing cassette twice.
Pipelines now preserve the final stage's real program, outcome, stderr, and exit code when no checked stage failed, while accepting an unchecked voluntary non-zero exit through AcceptedCodes instead of rewriting it to zero.
Command.Timeout is now measured from the spawn instead of from the moment a live handle is first consumed, so the deadline bounds the run's total wall time as documented. A StartAsync handle left running while the caller does other work no longer gets its whole budget re-issued when a verb finally reaches it — Command.Timeout(1s) collected five seconds later previously let the child run for six — and every consumer of one run (OutputStringAsync/OutputBytesAsync/WaitAsync/ProfileAsync, a stdout or event streaming session's FinishAsync, a readiness probe, WaitAnyAsync/WaitAllAsync) now shares that single absolute deadline through the same one exit wait and one kill. A handle whose budget is already spent is killed as soon as it is collected — after at most a quarter-second settle window, itself never longer than the configured timeout, so a Timeout(50ms) reached late is still killed 50 ms later — rather than after another full timeout; that bounded window is what lets a child which had already exited on its own inside the deadline still report its real outcome and output rather than a fabricated TimedOut, however little of the budget was left when the collecting verb arrived. What a fired deadline reports is unchanged and always the duration that was configured — never the remainder that was left — and so are the surrounding contracts: Command.IdleTimeout remains an inactivity window measured from when output starts being consumed, a timeout longer than ~24.8 days is still no deadline at all, a negative one is still rejected at the builder, and the verbs that spawn and collect in one call are unaffected because they consume immediately.
RunAsync no longer passes a bounded buffer's clipped output off as the whole of stdout: when the command's OutputBuffer policy dropped output (DropOldest/DropNewest over a MaxLines/MaxBytes cap), the run now fails with ProcessError.OutputTooLarge, quoting the configured ceilings and the line/byte totals the pump actually counted, instead of returning the retained tail or prefix — which, once projected to a string, is indistinguishable from complete output. Every verb built on it is covered (ParseAsync/TryParseAsync/OutputJsonAsync, their CliClient and IProcessRunner twins, and the pipeline's own RunAsync/parse/JSON verbs), so a parser can no longer be handed a clipped document and return a plausible wrong answer — 1234\n5678 cut to its last line parses perfectly as 5678. The lenient path is unchanged: OutputStringAsync/OutputBytesAsync still return the bounded payload with ProcessResult.Truncated set for the caller to judge, RunUnitAsync still succeeds on an accepted exit however much of the output it discards was dropped, and output that lands exactly on its cap was never truncated and still comes back whole.
Cassette replay through SpawnAsync now preserves the recording's duration and truncation state across buffered, streaming, and PTY live handles, while still reporting truncation newly caused by the replay command's output-buffer policy.
A Windows command that sets, removes, or clears the child's PATH (Env("PATH", …), EnvRemove("PATH"), EnvClear) now launches the executable Command.ResolveProgram() names for that same command: the resolved absolute path is substituted into the launch on every Windows path — the ordinary spawn, a Pty (ConPTY) run, and LaunchDetached — so a bare .exe name can no longer be answered by a same-named executable sitting on the process'sPATH, which is what the OS's own bare-name search reads (it resolves the image in the parent's context, never from the environment block the child is handed). The child's PATH takes the process's place in that search without narrowing it: the application directory, the process's current directory, and the system and Windows directories are still searched before it — the order Command.ResolveProgram() reports, so a name one of those directories holds still comes from there, and a child PATH remains no way to pin one image (use an absolute program path or PreferLocal for that) — and ProcessError.NotFound, carrying the same Searched value, is returned before any process is created only when that whole search, the child's PATH included, finds nothing. Commands that leave the child's PATH alone are unchanged, as are prefer-local and .cmd/.bat wrapper resolution.
Parent-signal forwarding now keeps a repeated signal handled when the first forwarded stop completes concurrently with callback auto-unsubscription, without starting a second graceful stop.
Pipeline capture results now report truncation when the selected pipefail stage's published stderr was shortened by DropOldest or DropNewest, including streamed FinishAsync results, without inheriting truncation from an unselected stage.
A RecordReplayRunner no longer replays a recording across a different output wiring: the effective wiring — where the child's stdout and stderr go (Piped, Null, Inherit, or a direct StdoutToFile/StderrToFile redirect with its append flag), whether stderr is folded into stdout (MergeStderr), and whether the run is a Pty — is now part of the replay match key (format v8, a new CassetteEntry.OutputWiring fingerprint). A call whose stdout never reaches the parent, and which a real run therefore leaves empty, is no longer handed a piped recording's captured output through CaptureStringAsync, CaptureBytesAsync, or a replayed SpawnAsync handle — and the reverse pair, where a recording made with stdout going elsewhere would answer a piped call with its empty capture and hide the real output, misses too. A knob the spawn ignores does not split the key (a PTY's stdout/stderr mode, a merged run's stderr mode), so identical calls still match, and a redirect path is folded in as a SHA-256 digest rather than stored in clear text. Cassettes written by older builds keep loading and replaying: a pre-v8 entry recorded no wiring, so it is served wherever it could honestly have been recorded — its PTY shape must agree, and an entry holding captured stdout or stderr is refused (as an ordinary ProcessError.CassetteMiss, without disturbing the capture order of the entries around it) for a call that captures none, rather than fabricating output that run could not have produced, while an entry that captured nothing keeps replaying for either wiring, since an old file cannot say which one produced it and an empty capture invents nothing.
A RecordReplayRunner now records a call that ended in a typed failure, so an expected failure replays as faithfully as an expected success: NotFound, Spawn, Stdin, Exit, Signalled, Timeout, OutputTooLarge, Parse, and JsonRpc are written to the cassette (format v7, a new CassetteEntry.Failure) and replay as the very same ProcessError case with its payload — the searched PATH, exit code or signal number, timeout, captured stdout/stderr, output limits and totals, detail, method, and JSON-RPC data — instead of the ProcessError.CassetteMiss they previously produced, and instead of an Auto session re-running the real tool on every pass. A recorded failure replays identically through CaptureStringAsync, CaptureBytesAsync, and SpawnAsync; duplicates keep the capture-order-then-repeat-the-last rule; and the WithRedaction hook scrubs a failure's streams, detail, data, and searched path exactly as it scrubs a recorded result. An error the format cannot rebuild exactly (a cancellation, a CassetteMiss, a nested RetryPredicate, or the transient/host-dependent Io/Unsupported/ResourceLimit/Unobserved/NotReady/Adopt) is still returned to the caller and recorded nowhere, rather than replayed as a downgraded error, and a failure that arrives once the run's token is already cancelled is likewise not recorded (the caller still gets that failure verbatim). Cassettes written by older builds keep loading unchanged — a pre-v7 entry has no failure half — and a v7 entry whose recorded failure this build cannot rebuild is refused when the cassette loads, naming the offending entry.
A one-shot stdin source (Stdin.FromStream/FromLines/FromAsyncLines) now feeds at most one incarnation, whichever verb or runner launches it: the boundary that actually creates a child takes the source before it spawns and marks it spent the instant that child exists, so a second run over the same source — a later one, or one racing it — is refused with ProcessError.Unsupported while it still has no child of its own, instead of starting one and then handing it the exhausted remains (usually nothing at all) or splitting one stream between two concurrent children. Every path that can drain the source is covered: StartAsync and the streaming verbs, the capture verbs, JobRunner, a ProcessGroup (owned or shared), a supervised incarnation, and both Pipeline paths through stage 0 — where the refusal starts no stage of the chain at all. A launch that produced no child (NotFound, a failed spawn, an up-front capability refusal, a released group) hands the source back intact for the next attempt or run, while a stdin failure after the child launched leaves it spent, since a child did read it. Repeatable sources (Stdin.FromString/FromBytes/FromFile/Stdin.Empty, Command.InheritStdin) are unaffected and still feed every run. A DryRunRunner preview spawns nothing, so it never consumes the source and never keeps it: preview a command as often as you like and the real run that follows is still handed the whole payload (a preview of a command that also carries a retry budget does hold the source for as long as that preview runs, and hands it back when it ends).
Graceful process-tree shutdown now bounds every poll delay by the remaining grace period, so zero or sub-50 ms grace values escalate on time instead of waiting an extra full polling interval.
Windows ConPTY children now always start in their own console process group; by Win32 contract, this disables their default CTRL+C handling. As required by Windows, U+0003 sent through ConPTY input does not interrupt them, while WindowsCtrlSignals() remains the explicit opt-in for ProcessKit's targeted CTRL+BREAK API.
A Windows Pty (ConPTY) child's standard handles are now bound to the pseudoconsole in both launch environments, so its output reaches the run's merged stream instead of escaping to the launcher's own stdio: a console-attached launcher (a terminal, a debugger, a console-hosted test runner) severs its console handles for the spawn, while a headless one (a service-hosted CI step, a redirected test host) instead nulls its own standard-handle slots for the length of the spawn call and restores them afterwards — where a headless run previously captured only the terminal's setup frame, the child having propagated and written to the launcher's redirected stdout. That short launcher-side window is serialized with every ProcessKit Windows spawn, so no command started through ProcessKit — including one inheriting the caller's stdio — can observe it; code outside ProcessKit that spawns with inherited stdio, or that touches Console for the first time, can still race it from another thread, so a caller who needs strict isolation from such activity should run PTY sessions from a dedicated helper process.
ProcessError.Message, ProcessError.ToString(), and the ProcessException.Message they become are now sanitized and bounded, so printing a failure (eprintfn $"{err.Message}") can no longer be turned against the operator by whatever the child, the JSON-RPC peer, or a caller's parser wrote: every embedded fragment renders as a single line with terminal controls (ANSI escapes, BEL, NUL), CR/LF, the Unicode line/paragraph separators, and bidirectional-formatting controls (the "Trojan Source" class) replaced by U+FFFD, and anything past 512 characters cut with a trailing … — an ordinary TAB and printable Unicode are untouched, and a 100 KB stderr or unparsed dump now renders as the same small preview as a short one instead of flooding the log. Only the human-readable render is affected: Detail, Stdout, Stderr, Data, Original and their accessors still carry the caller's bytes in full.
RecordReplayRunner.Save() no longer silently loses recordings when saves to one cassette path overlap: the whole snapshot-write-rename-fsync sequence now runs under the recorder's own save lock and an advisory lock on a sibling <path>.lock file (a deny-share open on Windows, flock on Unix), so saves of one recorder complete in order — an older save can no longer land on top of a newer one — and a save from another recorder or process that loses the lock is refused with a transient, retryable ProcessError.Io instead of overwriting the cassette that writer just saved. The lock file is never deleted by a save (a crash releases the OS lock on its own), each save writes its own uniquely named temp and never removes one it did not create, and the drop-time flush stays best-effort and exception-free.
A saved cassette is now flushed to disk on Windows as well as Unix before it is renamed into place, and the Unix parent-directory fsync that makes the rename itself durable now actually runs — its libc bindings named entry points that do not exist, so the call failed invisibly inside best-effort error handling (and leaked a file descriptor per save).
ProcessStdin.WriteLineAsync now sends carriage return for Windows ConPTY input so cooked console line readers receive Enter, while plain pipes and POSIX PTYs continue to receive line feed and raw WriteAsync remains byte-exact.
SupervisionSession.StopAsync now immediately cancels an active capture-only incarnation instead of waiting for its capture to finish, including during initial capability detection and after the capture-only mode is latched; a stopped active capture reports StopReason.Stopped, while external cancellation remains ProcessError.Cancelled.
SupervisionSession.Status.StartTime now identifies an active capture-only incarnation even though its process id remains unavailable.
Stdin.FromBytes now takes a defensive copy of the caller's byte array at the API boundary, so mutating it after building a Command (or across retry attempts) no longer changes what is written to the child's stdin.
Newline-free captured text now applies OutputBuffer.MaxBytes to UTF-8 byte size during force-flush, including multibyte Unicode output.
Windows bare-name executable resolution now matches CreateProcessW by checking the current directory and other pre-PATH search locations.
Relative entries in PATH now resolve to canonical absolute executable paths, anchored to the effective working directory for Exec.which, Command.ResolveProgram, and CliClient.ResolveProgram.
FirstLineAsync now preserves cancellation that arrives after a matching line, including while the child is being reaped, and no longer masks a FinishAsync error.
Linux cgroup legacy hard kills now verify that the reusable cgroup thawed and surface a typed ProcessError.Io through ProcessGroup.KillAll() and Signal.Kill instead of reporting success for a group still frozen; final disposal remains best-effort.
JsonRpcSession now gives an already-claimed response priority over a concurrent timeout or cancellation, so a successful answer cannot be replaced by a typed deadline error during pending-request completion.
JsonRpcSession no longer fabricates total message or byte counts when a peer request is evicted from its decoded-message backlog: the terminal ProcessError.OutputTooLarge reports zero for both uncounted totals, while notification drops remain counted by DroppedMessages and session termination is unchanged.
POSIX LaunchDetached now transfers each direct child to a private background reaper, preventing zombies in long-lived parents while preserving the pid-and-start-time-only detached API.
Ending a POSIX Pty run's stdin now actually reaches the child: a drained Command.Stdin source, ProcessStdin.FinishAsync, and PtySession.CloseStdinAsync deliver the terminal's configured end-of-input character (termios.c_cc[VEOF], read from the pty rather than assumed to be Ctrl-D) twice — terminating an unterminated line and then ending input — so a child reading to EOF such as cat or a shell read loop finishes instead of hanging; the shared pty master stays open and owned by the merged output stream, writes through a finished stdin handle are refused, and a delivery that genuinely fails is now reported (IOException, or a typed ProcessError.Io/ProcessError.Stdin) instead of silently dropped.
WaitForPortAsync, WaitForSocketAsync, WaitForHttpAsync, and WaitForAsync now check their condition exactly one more time — bounded by the remaining timeout, a brief grace, and the caller's token — after observing the child's exit, so readiness published immediately before that exit reports Ok instead of being lost as NotReady; a WaitForAsync predicate is therefore invoked once more after the child exits, unless the token is already cancelled or the deadline already spent.
JsonRpcSession now rejects a peer request/notification whose id is not a string, number, or null instead of publishing an object/array/boolean id as JsonRpcMessage.Id; preserves an explicit id: null as a request instead of folding it into a notification; and correlates a string response id against the exact canonical decimal text of its numeric id, so a signed or whitespace-padded variant ("+1", " 1 ") no longer completes an unrelated pending request.
A Windows Pty (ConPTY) run no longer ends the child's console session as it starts: the pseudoconsole's host-input pipe now stays open for the child's whole lifetime instead of being closed the moment a run turns out to have no stdin source and no KeepStdinOpen, so a PTY child that never reads stdin runs to completion rather than risking a CTRL_CLOSE_EVENT teardown (exit 0xC000013A) before it executes.
Ending a Windows Pty run's stdin now reaches the child as end of input rather than closing its terminal: a drained Command.Stdin source, ProcessStdin.FinishAsync, and PtySession.CloseStdinAsync deliver the console's own end-of-input gesture (Ctrl-Z followed by Enter, the counterpart of the POSIX terminal's end-of-input character), so a child reading to EOF such as copy con or a ReadToEnd finishes cleanly; the session's host-input pipe stays open and is closed exactly once when the child exits, writes through a finished stdin handle are refused, a repeated finish is a no-op, and a delivery that genuinely fails is reported (IOException, or a typed ProcessError.Io/ProcessError.Stdin) instead of silently dropped. As on a POSIX pty, a PTY child that reads to end of input now needs a stdin source or an explicit finish to see one — its terminal is no longer taken away underneath it.
Linux hard kills on kernels without cgroup.kill now pin each member and reconfirm its cgroup membership before delivering SIGKILL, so a member pid recycled by an unrelated process between the cgroup.procs snapshot and the signal is skipped instead of killed; the fallback previously signalled raw pid numbers, which could terminate a process outside the group.
That same fallback now reports a typed ProcessError.Io through ProcessGroup.KillAll() and Signal.Kill when a delivery failed and the cgroup is still populated — including on a kernel too old for pidfd (below 5.3), where it refuses to downgrade to the racy raw kill — instead of reporting success; a sweep that ends with an empty cgroup remains a success.
A Stdin.FromStream/FromLines/FromAsyncLines source that fails only after a fast child (or pipeline chain) has already exited is now reported as ProcessError.Stdin instead of being lost behind a spurious success: OutputStringAsync, OutputBytesAsync, FinishAsync, and both pipeline paths give a still-running source a short bounded window to conclude before deciding an otherwise-successful run's result. Precedence is unchanged — an unaccepted exit, a timeout, a cancellation, a fail-loud output overflow (OutputTooLarge), or a relay read failure still wins, and pays nothing for the window — a routine broken pipe is still not a failure, and a source that stays hung past the window is stopped rather than waited on, so a run can never be held open by it.
JsonRpcSession now rejects responses sent through a session other than the one that received the peer request, permits a retry after a response fails before writing, and prevents another response once one starts writing.
A Pty run whose Stdin.FromFile path cannot be opened no longer leaves a child reading to EOF waiting forever: the terminal's own end-of-input gesture is now delivered where that source fails, exactly as it is for a drained source, so the run reports the typed ProcessError.Stdin instead of hanging until its timeout (or indefinitely, without one).
A KeepStdinOpen run whose Stdin.FromFile path cannot be opened now leaves the interactive stdin it hands the caller open, instead of closing it and leaving that caller nothing to write to — and, under Pty, no way to end the child's input at all.
A Linux Command.KillOnParentDeath child no longer outlives a parent that dies in the moment between the spawn and PR_SET_PDEATHSIG being armed: the child now also checks, immediately after the arming and before it runs the target program, that its parent is still the exact process that spawned it, and terminates itself with SIGKILL (the same outcome the armed signal would have produced) instead of running the target if it is not. The check compares the captured spawner pid rather than testing for pid 1, so a spawner that legitimately is pid 1 (a container entrypoint) and a reparent to a non-init subreaper are both handled correctly; it composes with Uid/Gid/Groups, Pty, and the cgroup launcher, and a host with no /bin/sh to run the check with now fails the spawn with a typed ProcessError.Spawn instead of arming without it.
Windows hard kills now report a refused termination instead of reporting success: ProcessGroup.KillAll(), ProcessGroup.Signal(Signal.Kill), and RunningProcess.Signal(Signal.Kill) return a typed ProcessError.Io when TerminateJobObject/TerminateProcess is rejected and the target is still live (or its state cannot be read at all), instead of the unconditional Ok these verbs previously returned for a tree or child that was never killed. Whether the target survived is decided through the Job/process handle the call already owns — its accounting or exit status, never the Win32 error number alone — so a target that had already exited (including one that exited with code 259) stays a successful no-op and repeat kills remain idempotent. Kill-on-dispose is unchanged and still reaps the tree when the group is disposed; the fire-and-forget RunningProcess.Kill() keeps its unit signature, where a refused kill remains observable as a run that has not concluded.
A hard kill is no longer allowed to be followed by an unbounded wait: every completion path that kills the tree and then reaps it — a fired Timeout/IdleTimeout, a cancelled run's kill, RunningProcess.Kill(), RunningProcess.StopAsync after its grace window escalates, and the POSIX teardown drain behind ProcessGroup.ShutdownAsync/Dispose — now bounds that reap by a post-kill budget (5 seconds; StopAsync gets its grace period plus that budget). A child wedged in uninterruptible (D-state) sleep defers even SIGKILL until its I/O unblocks, and such a child could previously hang a timeout, a cancellation, a graceful stop, or a group teardown indefinitely after the kill had been delivered and the answer was already decided. The disposition that had already won is what is reported — a fired deadline still reports Timeout/Outcome.TimedOut, a cancelled run still reports ProcessError.Cancelled — and a stop or wait with no status of its own reports an honest Outcome.Unobserved explaining that the tree was killed but not observed, never a fabricated exit. The wait is transferred, not dropped: a background reaper keeps the single remaining right to wait/reap that tree (on POSIX it joins the same shared reap, so nothing waits on it twice), observes its eventual failure, and holds it against the captured start-time identity, so the late conclusion is still reaped exactly once and a group Shutdown/Dispose neither re-kills nor re-waits a leader it recognizes, by that identity, as one it has handed over. Ownership only ever transfers for a target that provably still has an unobserved conclusion — on POSIX, a leader waitpid reports as still our live, unreaped child — so a leader already reaped, including one whose process group is kept alive by descendants it backgrounded, is never handed to a second waiter. A normally exiting child is unaffected: it is still reaped synchronously and still reports its real exit code or signal, with no budget paid anywhere on that path. The budget likewise bounds only the waiting a caller actually does: it runs from the kill or from the moment a wait begins, whichever is later, so a Kill() followed by unrelated work and only then a WaitAsync/OutputStringAsync still reports the child's real exit code or signal.
A genuine inter-stage relay failure now tears the whole pipeline down as soon as it is seen, instead of leaving the run waiting for every stage to exit on its own before the already-diagnosed ProcessError.Io could be reported — an upstream stage that stops writing but keeps running never earns a broken pipe, so a chain without a Timeout could previously hang indefinitely. Buffered verbs and a StartAsync session share the same behaviour and report the first relay failure seen; a whole-chain timeout or cancellation already tearing the chain down still takes precedence, a downstream broken pipe is still not a failure, and pipefail blame still lands on the stage that actually failed. Because the chain is now hard-killed at that point, the final stage's output is truncated wherever the kill lands.