Skip to content

v2.2.0

Choose a tag to compare

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

Added

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

Changed

  • On Linux ≥ 5.4, waiting for a child to exit now goes through a per-child pidfd on one shared epoll reaper thread instead of the process-wide SIGCHLD rescan: the child's pidfd is registered once, and when it becomes readable the child is reaped with waitid(P_PIDFD) — which refers to the exact process, not a pid number, so the reap is immune to pid reuse and dispatch is O(1) per child rather than an O(pending) rescan on every SIGCHLD. macOS, other POSIX hosts, and older Linux kernels transparently keep the shared-SIGCHLD reaper (the choice is made once per process, at first use — pidfd_open/waitid(P_PIDFD) are probed on startup and fall back on ENOSYS/EINVAL). No public-API change and no change to the decoded Outcome, teardown, or the nativeint pid handle; this only narrows the pid-reuse window the ROADMAP tracked and removes the per-SIGCHLD rescan under heavy concurrency.
  • On Linux/macOS, the parent side of a child's piped stdin/stdout/stderr is now genuinely asynchronous: each stream is one end of an AF_UNIX SOCK_STREAM socketpair wrapped in a Socket/NetworkStream, so its reads and writes complete through the runtime's epoll/kqueue event loop instead of a plain FileStream parking a thread-pool thread per piped stream for the stream's whole lifetime. A large concurrent fan-out of piped POSIX children (WaitAllAsync, Supervisor, Exec.outputAll) no longer pressures the thread pool linearly — measured on Linux, 128 concurrent pending reads dropped from ~130 parked thread-pool threads to ~3. No public-API change and no change to captured output (byte-exact, with BOM strip and tees unaffected); Windows (already overlapped named pipes over IOCP) is untouched.
  • On Windows, ProcessGroup.Signal(Signal.Int) / Signal.Term now deliver a best-effort console CTRL+BREAK to children started with the new Command.WindowsCtrlSignals() (previously every non-Kill signal was unconditionally ProcessError.Unsupported). When no child in the group opted in, or the caller has no console to share, the call still returns an honest ProcessError.Unsupported — never a silent downgrade to the Job kill. Signal.Kill and every other Windows signal are unchanged.
  • Clarified the cancellation contract for the live StartAsync/SpawnAsync handle (docstrings on Command.CancelOn, IProcessRunner.SpawnAsync, ProcessRunnerExtensions.StartAsync, and ProcessGroup.StartAsync): the token is checked exactly once, before the spawn (an already-cancelled token reports ProcessError.Cancelled and starts nothing), and once the child is running neither the verb token nor the command's CancelOn is tracked — a live handle is caller-driven, so cancel or reap it yourself (dispose it, call its Kill, or register the token to call Kill). The completion verbs (RunAsync/Output*/ExitCodeAsync/ProbeAsync/ParseAsync/FirstLineAsync) are unaffected and continue to watch the token for the whole run. No runtime behaviour changed; this only replaces a previously ambiguous wording that could be read as "the StartAsync token is observed after spawn".
  • RecordReplayRunner (ProcessKit.Testing) now folds the child's effective environment into the replay match key, so a recorded call and a live one match only when their environment agrees — the EnvClear flag plus the net effect of the explicit Env/EnvRemove overrides (last-write-wins per name, removals included, under the platform's env-name case rules: case-insensitive on Windows, case-sensitive on POSIX), while repeated or no-op overrides with the same final effect still match. Previously the environment was recorded but ignored when matching, so a call with a different value, name, removal, or EnvClear could silently replay an unrelated recording — most dangerously handing a test that swapped in a new secret the old successful answer without ever running the inner runner. Environment values are never stored in clear text: only the variable names and a stable, versioned SHA-256 EnvFingerprint reach disk. The cassette format is bumped to v3; an existing v1/v2 cassette still loads, with its entries keyed as the default, un-customized environment — a default-environment call replays them unchanged, while a call that customizes the environment misses honestly (and re-records under Auto) instead of falsely matching.

Fixed

  • A faulty observability sink can no longer disrupt process control. An exception thrown by a consumer's ILogger — or by a registered Meter/Activity listener — while a run emits its lifecycle events (spawn, exit, timeout, retry, supervisor restart, or a pipeline's whole-run events) is now swallowed at the emission seam instead of propagating into the spawn/run/retry/pipeline/supervisor logic, so the run's honest result and its metrics are unchanged by a broken logger and individual sinks are isolated from one another (a throwing logger never blocks the metric/trace emission, or the reverse). The in-flight processkit.runs.active counter is always balanced in a finally even when a log/metric/trace emission faults — a broken sink can no longer leave a run counted as forever in flight — and a logger fault during a RunningProcess's construction can no longer leave the freshly-spawned child ownerless: the handle is always constructed and returned to the caller (or, for any other construction fault, the tree is reaped and released) rather than orphaned to GC-time kill-on-close.
  • HostedProcessService.Dispose() (from ProcessKit.Extensions.Hosting) now idempotently forbids new starts, cancels supervision, and hard-kills the active child immediately, instead of only disposing its internal CancellationTokenSource and leaving the background restart loop and a live child running until the GC eventually collected it. StartAsync now honours an already-cancelled token per the standard IHostedService contract (no supervision/child starts), concurrent/repeated StartAsync/StopAsync/Dispose calls resolve to one unambiguous transition apiece without touching an already-disposed token source, LastOutcome/LastStopOutcome are published thread-safely, and an exception or null returned from the configureSupervisor callback — or an exception that later escapes the already-built supervisor's own run — now surfaces as an observable LastOutcome failure instead of only a log line on a silently non-functional service.
  • On Linux, a process launched into a resource-limited group (cgroup v2) is now placed inside its cgroup before it runs a single instruction, closing the spawn-to-migrate window where a descendant it forked in that first instant could be created in the parent cgroup and escape the tree's memory.max/pids.max/cpu.max caps. The child is launched through a tiny /bin/sh helper that writes its own pid into cgroup.procs and then execs the real program in place — the same safe helper-process pattern as the setpriv uid/gid drop, so no managed code runs in a post-fork child and the pid, process group, stdio, working directory, environment, priority, umask, uid/gid drop, and kill-on-drop are all unchanged. A cgroup that cannot be joined still fails the spawn honestly (ProcessError.ResourceLimit) and kills/reaps the launcher, never a silent unconstrained run.
  • Text output decoding now finalizes the configured decoder at EOF, so truncated multibyte sequences produce its replacement fallback or raise its decoder exception instead of being silently dropped.
  • Timeout errors now report the configured total, idle, or pipeline deadline that fired instead of teardown-inclusive elapsed time; custom runner results with no known configured cause continue to use actual elapsed time.
  • Pipeline: a successful chain can no longer be reported as TimedOut just because the last stage's StdoutTee (or a stage's stderr drain) ran slowly after every stage had already exited. The whole-chain deadline is now fixed by the stages' state the moment they all reach a terminal outcome and the timer is disarmed before the optional slow tee/drain, so a late-firing timer cannot retroactively convert a settled success into a timeout; a genuine timeout that catches a stage still running is still reported exactly as before.
  • Pipeline: pipefail now blames the right stage when two checked stages fail close together. The "finished before vs. after the proactive teardown fired" classification is decided in the stages' real completion order, so a stage that actually failed on its own before a sibling's failure is no longer mislabelled a torn-down teardown victim (and de-prioritized) merely because its wait continuation happened to run later — which could hand the blame to the wrong stage.
  • OverflowMode.Error output buffering with no line or byte limit now retains all output instead of immediately reporting it as too large.
  • The release pipeline now builds, checksums, publishes, and releases all four advertised packages — ProcessKit, ProcessKit.Testing, ProcessKit.Extensions.DependencyInjection, and ProcessKit.Extensions.Hosting — instead of only the core ProcessKit. Each companion package now declares an exact, version-matched ProcessKit dependency for both target frameworks (net8.0/net10.0), so installing ProcessKit.Testing, ProcessKit.Extensions.DependencyInjection, or ProcessKit.Extensions.Hosting from NuGet pulls in the correct core automatically — previously the companions were never published and, packed as-is, would have carried no core dependency at all.
  • Environment preflight scripts now require the .NET 8 runtime before reporting the full multi-target test environment ready.
  • Command.Umask / Command.umask now reject a mask outside 0..0o7777 with an ArgumentOutOfRangeException at the builder boundary, instead of silently handing an out-of-range value (e.g. -1) to umask(2) on the POSIX spawn path.
  • ProcessGroup now serializes its whole lifecycle — a spawn (and the start of a run) plus every control/accounting verb (Signal, Suspend/Resume, Members, Stats, KillAll) against the release transition (Dispose/DisposeAsync/ShutdownAsync/finalizer) — behind a single critical section, so each operation either completes fully on the live container or is refused with a non-transient Unsupported before it touches the OS handle. This closes the window where a concurrent teardown could free the Job/cgroup/process-group handles mid-operation (a use-after-close or a wrong-target kill), hand back a RunningProcess built over an already-released container, or double-reap a child after the OS recycled its PID — most acutely on the cgroup migration-failure path, where the failing spawn and the teardown could both killpg/waitpid the same PID. Teardown still drains its tracked children atomically and runs exactly once, and ShutdownAsync's graceful-stop wait no longer holds the lifecycle lock across its (arbitrarily long) grace window.
  • An exception raised while reading/iterating a Stdin.FromFile/FromStream source or a FromLines/FromAsyncLines generator is now always surfaced as ProcessError.Stdin, instead of being silently swallowed unless it happened to be a FileNotFoundException/DirectoryNotFoundException/UnauthorizedAccessException; a bug in a caller's stdin source no longer disappears as truncated input and a spurious successful run. Tolerance for a routine broken pipe — the child closing stdin early — is unchanged, since that is now told apart from a source fault by where the exception was thrown (reading the source vs. writing to the child), not by its type.
  • OutputStringAsync, OutputBytesAsync, and WaitAsync now await their in-flight output pumps before letting a fault from the exit wait propagate (aligned with ProfileAsync's existing guard), instead of risking an orphaned pump racing the stream Dispose that follows on the fault path.
  • Windows spawn: an unavailable StdioMode.Null/Inherit std handle (e.g. no console and the stream not otherwise redirected) now honestly fails the spawn with ProcessError.Spawn instead of silently handing the child a broken std handle; the handle-close cleanup no longer risks calling CloseHandle on an invalid handle; and a handle already created for one stdio stream is no longer leaked if setting up the other one throws afterwards.
  • Windows spawn: a failed ResumeThread on the freshly created child (its (DWORD)-1 sentinel, previously ignored) now fails the spawn honestly. The child — assigned to its Job but still suspended and unable to ever run — is terminated inside the Job and its process/thread handles and stdio streams are released, and the caller gets a ProcessError.Spawn, instead of a healthy-looking handle to a process hung forever in the suspended state.
  • Windows ProcessGroup.Members/Suspend/Resume: the Job-Object member list now grows its buffer to hold the whole job (previously capped at 1024 pids, silently truncating a larger tree) and surfaces a genuine QueryInformationJobObject failure as ProcessError.Io instead of an empty list — so Suspend/Resume can no longer report success without having actually touched the real job, and now act on the complete member snapshot (the documented best-effort race now covers only processes that appear after the snapshot, never members dropped by an artificial cap).
  • On Unix, a spawn whose open("/dev/null") fails (e.g. the process's file-descriptor table is full) for stdin's default null source or an explicit StdioMode.Null stdout/stderr now fails honestly with ProcessError.Spawn, instead of silently downgrading to inherit the parent's stream — for stdin, that previously meant the child could read the parent's real stdin/terminal instead of getting an immediate EOF.
  • Pipeline: every stage's stderr is now drained under that stage's own OutputBuffer byte cap (MaxBytes + Overflow), instead of unconditionally into an unbounded buffer — a chatty stage can no longer exhaust memory even when its OutputBufferPolicy caps it; a stage without MaxBytes set keeps its previous unbounded stderr.
  • Pipeline: a fail-loud (OverflowMode.Error) stderr overflow on any stage — intermediate or last — now surfaces ProcessError.OutputTooLarge (naming that stage's program and its own configured caps), instead of being silently dropped for every stream except the final stdout; the byte-capture verbs (OutputBytesAsync/OutputStringAsync) and the verbs built on them (RunAsync/ExitCodeAsync/ProbeAsync/…) now honour the documented fail-loud contract on stage stderr as they already did on the last stage's stdout. DropOldest/DropNewest stay lossy but non-erroring. When several streams overflow at once (multiple stages, and/or a stage's stderr together with the final stdout) the reported error is deterministic — the leftmost stage in pipeline order wins, and within one stage its stdout is preferred over its stderr — so the "only the final stdout overflowed" case is reported exactly as before.
  • On Unix, a failed posix_spawn file-action/attribute step is no longer ignored: a rejected CurrentDir registration (or a mis-wired stdio dup2/close) now fails the spawn with an honest ProcessError.Spawn instead of silently running the child in the parent's working directory (a lost CurrentDir). A libc too old to support CurrentDir (glibc < 2.29 / macOS < 10.15) now reports a typed ProcessError.Unsupported rather than a silent drop, and any fault while marshalling arguments/environment or opening the child's pipes is turned into an honest error with every file descriptor already opened closed, instead of escaping the spawn and leaking descriptors.
  • On Unix, the posix_spawn path is now exception-safe on both sides of the spawn. While marshalling argv/envp, an out-of-memory failure part-way through no longer strands the unmanaged strings already allocated — each is freed before the error surfaces. And after posix_spawnp has already started the child, a failure in the remaining parent-side setup — a refused setpriority (raising priority without privilege), or any error wrapping a stdout/stderr/stdin pipe end into its stream — now kills the child's process group, reaps the leader, and closes every parent/child descriptor exactly once before returning the original ProcessError.Spawn. Previously such a post-spawn failure could leave a live orphaned/zombie child that the caller never received a handle to, and the cleanup itself never masks the original cause or escapes as a raw exception.
  • The line-capturing output buffer (OutputBufferPolicy.MaxBytes on the text verbs and a byte verb's line-pumped stderr) now charges each retained line its own UTF-8 bytes plus one separator byte, matching what the captured text reassembles into. Previously an empty line was charged 0 bytes, so a MaxBytes cap set with no MaxLines failed to bound a child emitting an unbounded stream of bare newlines — an unbounded number of empty-string line records could be retained despite the configured byte cap. The separator was also previously uncounted for non-empty lines, so the cap and the actual reassembled text size could diverge; MaxBytes now genuinely bounds the reassembled text under all three OverflowModes, including MaxBytes = 0. The raw-byte capture path (OutputBytesAsync, pipeline stdout/stderr capture) is unaffected — it has no line structure and no separator surcharge.
  • A RunningProcess whose output pump faults — a throwing OnStdoutLine/OnStderrLine handler, a StdoutTee/StderrTee sink that throws, or a decode/IO error — now kills the process tree at once, so the run concludes and is reaped in bounded time even with no timeout configured; previously a still-producing child could wedge the exit wait forever by blocking on a full pipe its dead pump no longer drained, and OutputStringAsync/OutputBytesAsync/FinishAsync/OutputEventsAsync/WaitAsync would hang. The original handler/tee fault is still what surfaces, not a secondary closed-pipe error.
  • RunningProcess's single-consumption state machine is now atomic under concurrent verbs on one handle: two verbs racing to claim the pipes resolve to exactly one winner (the loser is refused with the existing Unsupported/InvalidOperationException), the WaitAnyAsync/WaitAllAsync exit wait is built exactly once, and TakeStdin() hands out the interactive stdin stream at most once — where previously two simultaneous calls could both observe the fresh state and start racing readers on the same pipe (or hand out stdin twice).
  • The background stdin feeder is now cancellable and is stopped on teardown, so a feed parked in the user's own source — a Stdin.FromAsyncLines hung in MoveNextAsync, an endless FromLines — no longer keeps running (holding the user's enumerator/stream) after the run has ended. A run's teardown, cancellation, timeout, or the child exiting early now cancels the feed and disposes the user's enumerator/stream, uniformly across all three feed sites (a single command, a Pipeline's first stage, and ProcessGroup.StartAsync), and a repeated teardown stays idempotent. Previously only a feed blocked on writing to the child was unblocked (by closing its stdin); a feed blocked on the source's own read could outlive the process.
  • The "a genuine stdin-source fault surfaces as ProcessError.Stdin" contract now also covers the source-acquisition and per-item stages — seq.GetEnumerator, IAsyncEnumerable.GetAsyncEnumerator, an enumerator's Current, and any Stdin.FromFile open failure (not only FileNotFoundException/DirectoryNotFoundException/UnauthorizedAccessException) — which previously slipped past into the benign broken-pipe bucket and let an otherwise-successful run silently hide a real source fault. A routine broken-pipe write (the child closing stdin early) stays benign, and it no longer masks a co-occurring source fault.
  • Stdin.FromString/FromBytes/FromFile/FromStream/FromLines/FromAsyncLines now reject a null argument with ArgumentNullException at the factory boundary, instead of deferring the failure to an obscure NullReferenceException inside the background feeder.
  • ProcessKit.Testing cassette replay is now honest about a malformed or ambiguous recorded entry instead of quietly fabricating a plausible-looking result: a corrupted base64 stdout/stderr payload now surfaces the same ProcessError.Io uniformly across the string, byte, and spawn-capture replay paths (previously at least one path silently substituted empty stdout); a record whose terminal state carries none of TimedOut/Signal/Code now replays as Outcome.Unobserved instead of a fabricated Exited 0; and a record with a contradictory terminal state or a missing Program is now rejected when the cassette loads — reporting the offending record's index — instead of surfacing later as a confusing replay mismatch.