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
ProcessGroup.UpdateLimits(ResourceLimits) applies a new whole-tree resource-limit set to a live group without recreating it or restarting its children — adaptive resource control at runtime (tighten memory on a sagging batch, widen a long-lived worker pool's CPU quota). The ResourceLimits fully replaces the caps in force (a dimension left None becomes unbounded again): Windows re-issues SetInformationJobObject on the live Job, Linux cgroup v2 rewrites memory.max / pids.max / cpu.max, and the POSIX process-group mechanism (macOS/BSD, or Linux without cgroup v2) returns ProcessError.ResourceLimit — the same honest, typed refusal Create gives, never a silent no-op. The update runs through the group's lifecycle gate (a call after teardown returns a non-transient error rather than touching a closed container), and on success Options.Limits reads back the newly enforced set.
Command.StdoutToFile(path, append) / Command.StderrToFile(path, append) (and the pipe-friendly Command.stdoutToFile / Command.stderrToFile) redirect a child's stdout/stderr straight to a file at the OS level — the child is handed the open file as its std handle/fd on the spawn (Windows: an inheritable file handle in STARTUPINFO; POSIX: a file fd via a posix_spawn file action), with zero copying through the parent and no parent pump. The file therefore keeps growing even after the parent process (or a pump that would have drained a pipe) is gone — ideal for a long-lived service's log file under a Supervisor. append = false creates/truncates, true appends. There is then no parent-side view of that stream (ProcessResult.Stdout/Stderr empty, the streaming verbs yield nothing, the matching OutputEvent is never produced, exactly like StdioMode.Null), so the knobs that need one — StdoutTee/StderrTee, OnStdoutLine/OnStderrLine — plus MergeStderr and Pty are rejected at the builder boundary with ArgumentException (in either chaining order); redirecting one stream to a file while capturing the other normally, or redirecting both streams to separate files, is fully supported.
Supervisor liveness probes (LivenessHttp / LivenessCheck, off by default) restart a live but unresponsive child — closing the gap that RestartPolicy (exit-driven) and Command.IdleTimeout (stdout-silence-driven) miss for a "jammed" service that keeps running but stops answering. Every LivenessInterval, the supervisor polls an HTTP endpoint (with 2xx or response-predicate overloads) or an arbitrary async predicate; after LivenessFailures consecutive failed attempts (each bounded by LivenessTimeout) it gracefully stops the child (with the LivenessGrace window) and restarts it through the ordinary restart policy + backoff — no parallel restart mechanism. The probe checks the child's external health surface only and never reads its stdout/stderr or leaks a URL/predicate into argv/env/logs. A liveness-forced restart is distinguishable in Supervisor.OnRestart via the new SupervisorRestartEvent.Cause (RestartCause.Exit vs RestartCause.Liveness) and in observability via a dedicated ProcessKitDiagnostics.Events.SupervisorLivenessRestart log event and processkit.supervisor.liveness_restarts metric.
Randomized lifecycle interleaving harness (tests/ProcessKit.Tests/InterleavingTests.fs, [<Category("Interleaving")>]) that generates seeded, concurrent combinations of public RunningProcess/ProcessGroup operations against short-lived, long-lived, and output-flooding children and asserts the invariants every interleaving must uphold (no unexpected exceptions, no leaked zombies, no unobserved task faults, processkit.runs.active back to zero); a failure replays deterministically from its logged seed. It runs on the weekly scheduled / workflow_dispatch CI leg alongside the stress suite, so it never lengthens the ordinary PR/push run.
Supervisor.StartAsync() returns a live SupervisionSession handle for interactive supervision — a poll-and-control view over a running supervisor without pulling in Microsoft.Extensions.Hosting. The session exposes a consistent live Status snapshot (SupervisionStatus: IsActive, Restarts, IsStormPaused, and the current incarnation's Pid/StartTime), a graceful StopAsync(gracePeriod) that stops the current child through its own graceful path and ends supervision with the new StopReason.Stopped (a clean stop, never a crash or cancellation), and a Completion task carrying the final SupervisionOutcome. Supervisor.RunAsync is now a thin wrapper over StartAsync + awaiting Completion — its behaviour (exit classification, token cancellation, OnRestart/OnStormPause callbacks) is unchanged, and the session's live status adds to those callbacks rather than replacing them.
RunningProcess.WaitForHttpAsync polls an HTTP endpoint for 2xx readiness, with status-code and response-predicate overloads for custom health checks.
RunningProcess.WaitForSocketAsync(path, timeout, cancellationToken) polls a Unix domain socket path until a connection succeeds — the same NotReady/early-exit-on-child-death/background-drain contract WaitForPortAsync gives for TCP, for daemons that publish an AF_UNIX socket (docker/containerd-style, local agents). A host without AF_UNIX support fails immediately with a typed ProcessError.Unsupported, never a silent downgrade or a hang.
Add AOT-safe overloads of OutputJsonAsync<'T> accepting JsonTypeInfo<'T> for use with source-generated serialization contexts in trimmed/NativeAOT applications.
RunningProcess.StdoutJsonLinesAsync<'T>() streams stdout as NDJSON / JSON Lines, deserializing each non-empty line into a typed value as it arrives (blank lines are skipped silently, an unparseable line ends the stream with ProcessError.Parse) — a typed, thin wrapper over StdoutLinesAsync() for tools like docker events --format json / kubectl get -w -o json / rg --json. A JsonTypeInfo<'T> overload is available for trim-/NativeAOT-safe deserialization alongside the reflection-based JsonSerializerOptions overload.
Add comparative BenchmarkDotNet benchmarks (benchmarks/ProcessKit.Benchmarks/ComparisonBenchmarks.fs) measuring ProcessKit against raw System.Diagnostics.Process and CliWrap across a single spawn+capture, a large streamed stdout payload, and a concurrent fan-out of children; see docs/comparison.md for the measured numbers.
Opt-in pseudo-terminal (PTY) mode via Command.Pty(...) / Command.pty and the new PtyConfig type (initial Cols/Rows/Echo), for tools that demand a real terminal — an interactive ssh/sudo prompt, a credential helper, a TUI. A PTY gives the child a single merged stdout+stderr terminal stream (OutputEvent.Stderr is never produced), so it is rejected at the builder boundary alongside the separate-stderr observation hooks (StderrTee/OnStderrLine), alongside Setsid, and on any non-last pipeline stage. Windows uses ConPTY (CreatePseudoConsole), needing Windows 10 1809+ — an older host fails the spawn with ProcessError.Unsupported, never a silent pipe downgrade; the kill-on-dispose containment guarantee is unchanged (the child is born a Job member). On POSIX a PTY now gives the child a real controlling terminal via openpty (an all-libc posix_openpt/grantpt/unlockpt/ptsname allocation) and the setsid --ctty helper (util-linux), preserving the pgid containment model unchanged; a host without that controlling-terminal helper or the pty devfs (macOS/BSD) fails with ProcessError.Unsupported, never a socketpair pretending to be a tty. The child's terminal is sized to the configured Cols/Rows on both platforms (ConPTY on Windows, ioctl(TIOCSWINSZ) on POSIX), so a full-screen TUI renders at the requested geometry.
Command.Pty now composes with a ProcessGroup's cgroup v2 resource limits (ResourceLimits / Mechanism.CgroupV2) on Linux: a child spawned with a controlling PTY into a limited group is placed inside the cgroup (a real cgroup.procs member, its memory/pids/cpu caps enforced) at the same time as it gets its controlling terminal — the self-migrating cgroup launcher joins the cgroup, then execs the setsid --ctty shim on one unchanged pid, so Mechanism.CgroupV2 is never silently downgraded and kill-on-dispose containment is unchanged. Proven end to end by a Linux test exercised under the privileged cgroup CI leg.
RunningProcess.ResizeAsync(cols, rows) resizes a live Command.Pty run's terminal — Windows via ResizePseudoConsole, POSIX via ioctl(TIOCSWINSZ) then a SIGWINCH to the child — so a running full-screen TUI reflows to the new geometry; on a non-PTY run it returns a typed ProcessError.Unsupported, never a silent no-op, and out-of-range dimensions are rejected with ArgumentOutOfRangeException like the Command.Pty builder.
PtyConfig.Echo = false now takes effect (the flag was previously accepted but inert): it disables the PTY's cooked-mode terminal echo — POSIX clears the pty slave's termiosECHO bit at spawn — so a credential typed to the child through the PTY is not echoed back into the captured merged output stream.
ProcessKit.Testing doubles now model a Command.Pty run: FakeProcess.WithPty() (and a ScriptedRunner reply to a Command.Pty() command) builds a merged-stream handle whose OutputEventsAsync() yields only OutputEvent.Stdout (OutputEvent.Stderr is never produced), and ResizeAsync becomes a recorded no-op success whose last requested (cols, rows) is readable via FakeProcess.LastResize. A double cannot make the child observe isatty = true — that is inherent and documented, not silently faked.
RecordReplayRunner cassettes now record a PTY run: CassetteEntry gains a Pty flag and geometry (PtyCols/PtyRows), bumping the on-disk cassette format to v4 (v1/v2/v3 cassettes still load, a pre-v4 entry keying as a non-PTY run). A PTY recording replays as a merged-stream handle, and the RecordReplayOptions.WithRedaction hook scrubs the whole merged stream so an echoed credential never reaches a committed cassette.
ProcessKit.Testing's ScriptedRunner now keeps a structural call journal for verifying interactions without a hand-rolled IProcessRunner decorator: ScriptedRunner.Received : IReadOnlyList<RecordedInvocation> records each command routed through the runner — program, arguments, working directory, environment-variable names (never their values), whether stdin/pty were present (never stdin's content), and which primitive served it (RunnerVerb.CaptureString/CaptureBytes/Spawn). ScriptedRunner.CountReceived(predicate) counts matching calls and RecordedInvocation.Matches(tokens) mirrors On's token matching, so "code called git commit exactly once" is a one-line assertion. The same secret invariant as the RecordReplayRunner cassettes holds — env values and stdin content are never captured.
module Command gains Command.ptyConfig and Command.ptySize, pipe-friendly mirrors of the instance Command.Pty(PtyConfig) / Command.Pty(cols, rows) overloads — the pipe style can now set the PTY's geometry or Echo = false without dropping into method syntax, closing the last gap against the instance builder's Pty overload set.
Pipeline.StartAsync() starts a pipeline as a live streaming session — a PipelineSession, the multi-stage analogue of RunningProcess — closing the streaming-verb gap between Command and Pipeline for long-running or interactive chains (journalctl -f | grep …). It streams the final stage's stdout as it arrives (StdoutLinesAsync / StdoutJsonLinesAsync / OutputEventsAsync, single-consumption exactly like RunningProcess), waits on a readiness line (WaitForLineAsync), waits for the whole chain with the same pipefail classification the buffering verbs use (FinishAsync → a Finished carrying the pipefail representative's Outcome + that stage's stderr, never a final-stage-only view), and stops/reaps the entire chain — including a partially started one — with StopAsync / Kill / dispose (kill-on-drop). The chain-level Timeout/CancelOn still bound the live session (either firing hard-kills the whole tree, then reported as TimedOut/Cancelled), and the run emits the same single whole-chain telemetry triple as the buffering verbs.
Changed
NuGet Package Validation (ApiCompat) now runs on dotnet pack for all four packages, mechanically enforcing backward compatibility against the last published release (baseline 2.4.2) across net8.0 and net10.0 — the SemVer promise (breaking changes only in a new major version) is now a build/CI check, not just review discipline. One pre-existing, documented break is narrowly suppressed: the ProcessKit.Testing.CassetteEntry positional-constructor arity change from the added Pty/PtyCols/PtyRows cassette-v4 fields (a serialization DTO, not a construction contract — deserialization through its parameterless constructor is unaffected). Removing or renaming any other public member now fails the pack.
Command.Pty(...) combined with Command.InheritStdin() (in either chaining order) is now rejected at the builder boundary with ArgumentException, matching the existing Setsid+Pty guard: a PTY replaces the child's stdin with its own pty slave/ConPTY input, so InheritStdin used to be silently ignored rather than honoured — this closes that silent downgrade.
Documented PTY usage in docs/pty.md, the platform matrix, and cookbook; added F# and C# PTY samples; and moved PTY into the ROADMAP coverage section.
Windows graceful stop now soft-closes GUI children before the hard kill: ProcessGroup.ShutdownAsync, RunningProcess.StopAsync, and Command.TimeoutGrace post a best-effort WM_CLOSE to every member's top-level windows (an Electron/desktop child then runs its own shutdown, as taskkill without /F does) and wait the grace window before the unconditional Job terminate — a child with no window, or one that vetoes the close, is still force-killed exactly as before, so the kill-on-dispose guarantee is unchanged.
ProcessGroup.Signal(Signal.Int) / Signal(Signal.Term) on Windows now also post a best-effort WM_CLOSE (targeted by process id) to members with a top-level window, in addition to the existing CTRL+BREAK for Command.WindowsCtrlSignals() children; the call returns ProcessError.Unsupported only when the group has neither a CTRL-capable child nor a windowed member.
CI: added an automated Markdown link check (.github/workflows/link-check.yml, powered by lychee) — a fast, deterministic internal-links leg on every PR/push, plus a weekly scheduled leg for external URLs.
Hardened the release supply chain: packages are now published to NuGet.org via Trusted Publishing (short-lived OIDC-minted key per run) instead of a long-lived API-key secret, and every released artifact (.nupkg, .snupkg, and the SHA256SUMS manifest) now carries a build-provenance attestation. Consumers can verify a package's origin against this repository and workflow with gh attestation verify <file> --repo ZelAnton/ProcessKit-fSharp.
Supervisor.Backoff, MaxBackoff, StormPause, and FailureDecay now reject a negative TimeSpan with ArgumentOutOfRangeException at the builder boundary instead of silently coercing it (a negative MaxBackoff had also disabled the backoff-escalation reset, so the delay never climbed away from the base); TimeSpan.Zero stays accepted where it is meaningful (no backoff delay / zero cap / a storm pause that still counts without waiting / no failure history).
RunningProcess.ProfileAsync(interval) and ProcessGroup.SampleStatsAsync(interval) now reject a non-positive interval (<= TimeSpan.Zero) with ArgumentOutOfRangeException instead of silently sampling in a tight 1 ms loop.
IServiceCollection.AddProcessKitHostedProcess(name, ...) (ProcessKit.Extensions.Hosting) and AddProcessKitClient(name, ...) (ProcessKit.Extensions.DependencyInjection) now throw InvalidOperationException when name is already registered, instead of silently keeping the first registration and dropping the second call's command/configureSupervisor / program/configure. For the hosted process this also removes a second IHostedService registration that resolved to the same keyed instance (so the host would StartAsync/StopAsync one service twice); register each hosted process / client under a unique name. AddProcessKit / AddProcessKitGroup are unnamed idempotent registrations and keep their first-wins TryAdd semantics unchanged.
RunningProcess.WaitForPortAsync and WaitForAsync now return NotReady immediately when the child has already exited, instead of polling out the full timeout — matching WaitForHttpAsync/WaitForLineAsync, so a service that dies on startup is diagnosed promptly across all four readiness probes.
Potentially breaking:Command.OkCodes (and Command.okCodes) now reject an empty set of codes with ArgumentException at the builder boundary instead of silently keeping the previously configured codes. An empty ok-codes set has no meaningful semantics (no exit could count as success), so it is now treated as a misconfiguration and fails loud like every other invalid builder input — a dynamically built list that accidentally comes out empty is signalled instead of leaving the command with unexpected codes. Passing at least one code (as every documented use already does) is unaffected.
ProcessKit.Extensions.Hosting: a host-driven graceful stop (HostedProcessService.StopAsync, or host shutdown) now reports SupervisionOutcome.Stopped = StopReason.Stopped in LastOutcome — the honest reason for a deliberate stop — instead of the StopReason.Predicate the previous implementation surfaced as an artifact of folding the host stop into a combined StopWhen predicate. A user's own StopWhen predicate is preserved untouched and still reports StopReason.Predicate when it is what ends supervision.
Fixed
OutputBufferPolicy.MaxBytes = 0 no longer sends a phantom empty segment through an in-flight capped line pump before real output arrives, so line handlers and buffer accounting observe only actual output segments.
Waiting on the same POSIX child from two places at once is now idempotent: both waits resolve to the same real exit status instead of one intermittently observing a spurious Unobserved (...ECHILD race). All waiters for a pid now share a single reap (the child's status is still consumed from the OS exactly once and fanned out to every waiter), and a wait registered just after the child was reaped reads the real status from a briefly-cached outcome rather than losing an ECHILD race — removing an intermittent CI flake in the concurrent-wait path on Linux.
Exec.which and CliClient.EnsureAvailableAsync no longer risk throwing a raw exception when a candidate on PATH disappears mid-probe (a TOCTOU race between the existence check and the POSIX executable-bit check) or is otherwise inaccessible: the offending candidate is now treated as not found and the PATH search continues, and any unexpected failure at the whole-resolution level surfaces as a typed ProcessError.Io instead.
ProcessGroup.Suspend() and Resume() now report POSIX signal-delivery and cgroup freeze/thaw failures as ProcessError.Io instead of silently succeeding.
ProcessGroup.ShutdownAsync(gracePeriod) now guarantees the container is released even when the graceful-kill stage throws or its task faults, instead of leaving the Job handle/cgroup/process group unreleased forever; the original exception still propagates to the caller.
Windows CTRL+BREAK delivery now refuses an unavailable child process ID instead of broadcasting CTRL+BREAK to the caller's console group when GetProcessId fails.
ProcessKit.Extensions.Hosting: HostedProcessService.StartAsync no longer holds the service's internal lock across the synchronous supervision start — the caller's configureSupervisor callback and the native spawn of the first incarnation now run off the lock. StartAsync returns promptly per the IHostedService contract, observers reading IsSupervisionActive/RestartCount/IsStormPaused/LastOutcome (and the hosted-process health check) are no longer blocked for the duration of the spawn, and a configureSupervisor that touches the same service from another thread can no longer deadlock; start idempotency and Start/Stop/Dispose races are unchanged.
Fixed OverflowMode.DropNewest with OutputBufferPolicy.MaxBytes retaining later short lines after an over-cap line, so buffered text now always remains a contiguous prefix of the process output.
ProcessGroup.Signal(Signal.Other 0) on POSIX no longer reports a false success: signal 0 is a liveness probe (kill/killpg with a zero number checks the target exists but delivers nothing, yet returns success), so it — and a negative number, which is not a signal at all — is now refused up front with ProcessError.Unsupported on both POSIX backends (process-group and cgroup v2), even for an empty group, instead of masquerading as a delivered signal. A valid-but-platform-rejected number (e.g. an out-of-range Signal.Other 999) still fails honestly as ProcessError.Io.
A buffered RunningProcess verb (OutputStringAsync/OutputBytesAsync/WaitAsync/ProfileAsync/StopAsync) that faults before reaching its own completion — e.g. a throwing OnStdoutLine/OnStderrLine handler, or a faulted exit wait — no longer leaves the processkit.runs.active metric permanently inflated: the verb's teardown now clears it on every exit path, not just the successful one.
ProcessResult.StdoutText (and everything built on it — Combined, OutputContainsAny, and the Stdout field of a ProcessError.Exit/Signalled/Timeout reported via OutputBytesAsync().EnsureSuccess()/ensureSuccess) now decodes a byte[] capture with the command's configured StdoutEncoding instead of always assuming UTF-8, matching the already-correct behaviour of Pipeline.OutputStringAsync and cassette replay.
The cgroup v2 limits backend's confirmation write of a child's pid into cgroup.procs now treats a short write (fewer bytes landed than the pid's decimal payload) as a genuine migration failure instead of silently reporting a successful migration.
Fixed a Windows Job-handle race in the graceful stop (RunningProcess.StopAsync / Command.TimeoutGrace on a private group): its liveness poll and post-grace hard kill now run on a private duplicate of the Job handle, so a concurrent Dispose/teardown that closes the group's handle mid-grace can no longer make the poll query or TerminateJobObject a just-closed handle — which risked terminating an unrelated Job whose handle value had been recycled. StopAsync stays race-safe with Dispose, and the unconditional kill-on-dispose guarantee is unchanged.
StdioMode.Null on Windows now checks the result of making the NUL-device handle inheritable: if SetHandleInformation fails, the handle is closed and the spawn fails honestly with ProcessError.Spawn, instead of handing the child a std handle that looks valid but is not inherited, silently swallowing its stdout/stderr writes.
ProcessKit.Extensions.Hosting: HostedProcessService.StopAsync called between incarnations — while the supervised child is in a restart-backoff sleep with no child live right now — no longer stalls until the caller's cancellationToken expires (leaving the service to keep restarting instead of stopping) and no longer overwrites LastStopOutcome with the outcome of an already-dead child. A host stop now interrupts the backoff and ends supervision promptly, launching no further incarnation, and LastStopOutcome is published only when a genuinely live child was actually stopped. Internally the service now drives supervision through Supervisor.StartAsync/SupervisionSession — which already tracks the live incarnation correctly and interrupts an in-flight backoff on a graceful stop — instead of a separate active-child tracker that could keep pointing at a stale, already-torn-down handle after an incarnation ended.