Skip to content

Latest commit

 

History

History
559 lines (463 loc) · 40 KB

File metadata and controls

559 lines (463 loc) · 40 KB

Platform support

Previous: Overview

ProcessKit treats platform behaviour as first-class. Every child you start lives inside the operating system's own containment primitive, so the kill-on-dispose tree guarantee holds on Windows, Linux, and macOS/BSD alike. Where a mechanism is genuinely weaker than another, the difference is reported honestly — the active Mechanism is queryable and unsupported operations return a typed ProcessError, never a silent downgrade. This page collects every per-OS mechanism, capability matrix, and caveat in one place.

Containment mechanisms

A ProcessGroup wraps one of three OS primitives. Whichever it gets, disposing the group (or the live RunningProcess from a one-shot verb) reaps the whole tree — children, grandchildren, and anything they spawned — as a single kernel operation.

Mechanism Platform How containment works
Mechanism.JobObject Windows A Job Object created with kill-on-close. Children are spawned suspended, assigned to the job, then resumed, so even a grandchild forked in the first instant is already contained. Teardown closes the job handle (KILL_ON_JOB_CLOSE) or terminates the job.
Mechanism.CgroupV2 Linux (when resource limits are requested and a usable cgroup v2 root exists) A private cgroup under the unified hierarchy. Each child is launched through a small /bin/sh helper that joins the cgroup (writes its own pid to cgroup.procs) before execing the target in place, so the target is contained on its first instruction and a child it forks immediately inherits the limits; teardown is cgroup.kill followed by removing the cgroup directory.
Mechanism.ProcessGroup macOS/BSD, and the Linux default when no limits are requested POSIX process groups. Each spawned child forms its own process-group id (pgid); teardown sends SIGKILL to the tracked pgids (killpg).

When each mechanism is chosen

The selection at ProcessGroup.Create is deterministic per platform:

  • Windows always uses a Job Object (Mechanism.JobObject), with or without limits. When limits are requested they are applied to the job; if they cannot be applied, creation fails with ProcessError.ResourceLimit.
  • Linux uses a cgroup v2 (Mechanism.CgroupV2) only when whole-tree resource limits are requested and cgroup v2 is mounted and usable at the real cgroup-v2 root. Without limits, Linux uses the POSIX process group (Mechanism.ProcessGroup) — so an ordinary, limit-free group on Linux reports ProcessGroup, not CgroupV2. A CPU-time-only group also uses ProcessGroup and applies RLIMIT_CPU per spawned child. If whole-tree limits are requested but no usable cgroup exists, creation fails with ProcessError.ResourceLimit rather than running unbounded.
  • macOS / BSD always use a POSIX process group (Mechanism.ProcessGroup). They have no whole-tree limit primitive, so requesting one fails fast with ProcessError.ResourceLimit; a CPU-time-only limit remains available per child.

Reading the active mechanism

ProcessGroup.Mechanism reports which primitive you actually got, so code that depends on a guarantee can check rather than assume:

F#

match ProcessGroup.Create() with
| Ok group ->
    use group = group

    match group.Mechanism with
    | Mechanism.JobObject -> printfn "Windows Job Object — whole-tree kill, members, stats"
    | Mechanism.CgroupV2 -> printfn "Linux cgroup v2 — whole-tree kill, signals, limits, stats"
    | Mechanism.ProcessGroup -> printfn "POSIX process group — kill-on-dispose, leaders-only members"
| Error err -> eprintfn $"{err.Message}"

C#

using var group = ProcessGroup.Create().GetValueOrThrow();

Console.WriteLine(group.Mechanism switch
{
    { IsJobObject: true }    => "Windows Job Object — whole-tree kill, members, stats",
    { IsCgroupV2: true }     => "Linux cgroup v2 — whole-tree kill, signals, limits, stats",
    { IsProcessGroup: true } => "POSIX process group — kill-on-dispose, leaders-only members",
    _                        => "unknown mechanism",
});

The Mechanism.IsJobObject / IsCgroupV2 / IsProcessGroup properties are the same check in boolean form, convenient from C#.

Target frameworks

ProcessKit targets .NET 8.0 and .NET 10.0, and is usable from F# and C# alike. The containment work is done through platform P/Invoke (Win32 for the Job Object, the cgroup filesystem and libc on Unix), so the supported runtime set is Windows, Linux, and macOS/BSD — the desktop and server platforms these target frameworks run on.

The full test suite (minus the Stress category) runs in CI's test job matrix on ubuntu-latest, ubuntu-24.04-arm, windows-11-arm, windows-latest, and macos-latest — so the native syscall layer (direct syscall(2) invocations, siginfo struct layout, signal/epoll handling in Native.Posix.fs) is verified on Linux ARM64 as well as x64, not merely asserted correct by argument-passing convention. macOS's GitHub-hosted runner is Apple Silicon (arm64) already; Windows CI now covers both x64 (windows-latest) and ARM64 (windows-11-arm). On ARM64, actions/setup-dotnet auto-resolves the .NET SDK; no x64-specific test fences were required (native P/Invoke code for Job Objects, overlapped named-pipe I/O, and struct marshalling is pointer-width-safe). This ARM64 coverage is documented reasoning pending the first real post-merge CI run on the windows-11-arm leg.

Trimming and NativeAOT

CLI tools — a common consumer of a process library — increasingly ship as PublishTrimmed or NativeAOT images, so ProcessKit's runtime packages declare their compatibility explicitly and back the claim with a CI smoke that actually publishes and runs a NativeAOT consumer.

Package IsTrimmable IsAotCompatible Notes
ProcessKit Containment is platform P/Invoke with no reflection, dynamic codegen, or reflection-backed printf/%A; the reflection-based JSON overload is annotated, while the JsonTypeInfo overload is AOT-safe (see below).
ProcessKit.Extensions.DependencyInjection Factory-based registration; the AddProcessKit/AddProcessKitGroup IConfiguration overloads are the one exception (see below).
ProcessKit.Extensions.Hosting Factory-based DI plus an IHostedService wrapper; options come from the AOT-safe Activator.CreateInstance<T>() path.
ProcessKit.Testing Not trim/AOT-safe by design — see the boundary below. This is a test-only package, referenced from test projects that are not themselves trimmed/AOT-published.

The one annotated exception (DI). AddProcessKit(IConfiguration) and AddProcessKitGroup(IConfiguration) bind ProcessKitOptions from configuration by reflection, which is not trim/AOT-safe. Both carry [RequiresUnreferencedCode] / [RequiresDynamicCode], so a consumer that calls them from a trimmed/AOT app gets a precise warning pointing at the overload — exactly as Microsoft's own DI/options packages behave. Use the Action<ProcessKitOptions> overload (or bind configuration yourself and call configure) from an AOT app.

The OutputJsonAsync boundary (core). The existing typed JSON verb (Command.OutputJsonAsync<'T>, IProcessRunner.OutputJsonAsync<'T>, CliClient.OutputJsonAsync<'T>, Pipeline.OutputJsonAsync<'T>, and the underlying Runner.outputJson) uses reflection-based JsonSerializer.Deserialize(string, Type, JsonSerializerOptions) and remains annotated [RequiresUnreferencedCode] / [RequiresDynamicCode]. For trimmed/NativeAOT applications, use the additive OutputJsonAsync(typeInfo) overload on each object surface, or Runner.outputJsonTyped, with source-generated JsonTypeInfo<'T> metadata. Those overloads call the metadata-based JsonSerializer.Deserialize(string, JsonTypeInfo) API and carry no trimming/AOT annotations. F# cannot itself author the Roslyn System.Text.Json source generator, but a C# project's generated context can pass its JsonTypeInfo<'T> to F# or C# alike. The aot-smoke CI job (below) does not call this verb, so it stays unaffected by this boundary.

The ProcessKit.Testing boundary. The record/replay cassette surface (RecordReplayRunner) serializes and deserializes with reflection-based System.Text.Json. F# cannot use the System.Text.Json source generator (it is a Roslyn/C# source generator that the F# compiler does not run), so the usual AOT remedy is unavailable. Rather than emit silent "assembly was not verified" warnings, the package is honestly not declared trimmable/AOT-compatible. Because it is meant to be referenced only from test projects — code never shipped inside a trimmed/AOT application — this is a boundary in practice, not a limitation of what you deploy.

F# runtime baseline. FSharp.Core — the F# runtime every F# assembly depends on — is not fully trim/AOT-annotated (its printf/quotation/reflection surface), so a NativeAOT publish of any F# application surfaces IL2104/IL3053 warnings attributed to FSharp.Core, independent of ProcessKit. Those are a known F# baseline, not a ProcessKit defect; warnings attributed to a ProcessKit* assembly would be. ProcessKit's own assemblies publish warning-free.

How this is validated. samples/FSharp.NativeAot is a minimal consumer of ProcessKit and ProcessKit.Extensions.DependencyInjection, published with PublishAot=true and run by the aot-smoke job in the CI workflow on both linux-x64 (POSIX process-group backend) and win-x64 (Windows Job Object backend). It spawns a child, captures a non-zero exit as an honest result, runs a child inside a kill-on-dispose ProcessGroup, and runs a child through a DI-resolved IProcessRunner (AddProcessKit); the job fails if ilc attributes any warning to a ProcessKit* assembly or if the native binary exits non-zero. So the compatibility above is exercised in a real ahead-of-time-compiled image, not merely declared in metadata. (ProcessKit.Extensions.Hosting shares the same factory-based, reflection-free pattern; its declaration rests on that analysis rather than a running hosted-service image in this smoke.)

Capability matrices

In the matrices below the columns are the three mechanisms. The POSIX process group column covers macOS/BSD and the Linux default (a limit-free group), since they share one backend. Legend: ✅ full support · 🟡 supported with a documented qualification · ❌ not available.

Whole-tree teardown

Capability Windows (Job Object) Linux cgroup v2 POSIX process group
Kill-on-dispose, whole tree
Graceful ShutdownAsync (configured soft signal → grace → hard kill) 🟡 best-effort WM_CLOSE → grace → atomic kill

ShutdownAsync(grace) on Windows has no per-job graceful signal, but a windowed child (Electron/GUI tool) closes gracefully on a best-effort WM_CLOSE posted to its top-level windows: the soft phase posts one to every member's windows, waits up to the grace window for the tree to drain, then unconditionally terminates the Job — so a child with no window (or one that vetoes the close) is still hard-killed exactly as before, and the kill-on-dispose guarantee is never weakened. On the Unix mechanisms it is the configured ProcessGroupOptions.StopSignal (default Signal.Term), then a grace window, then SIGKILL.

Adopting an external process (Adopt)

Capability Windows (Job Object) Linux cgroup v2 POSIX process group
Adopt(process) an already-running external process AssignProcessToJobObject 🟡 write pid to cgroup.procslimited groups only ProcessError.Unsupported

Adopt brings a process ProcessKit did not start into the container, so kill-on-dispose and every whole-tree control/stat/limit thereafter covers it. It takes a System.Diagnostics.Process (not a raw pid) so the caller's open handle pins the pid against recycling on Windows. Linux can adopt only into a group created with resource limits (which is what selects the cgroup v2 mechanism); a limit-free Linux group and every macOS/BSD group use the POSIX process-group mechanism, which cannot relocate a foreign process (setpgid moves only our own children, before exec) and refuses honestly with ProcessError.Unsupported — never a silent no-op. A dead/gone pid, missing rights, or a process already in an incompatible Job returns the typed ProcessError.Adopt. The adopted process is not ProcessKit's child: it is contained and killed through the OS primitive alone (KILL_ON_JOB_CLOSE / cgroup.kill) and never waitpided, so its exit is observed through the caller's own Process, not a RunningProcess.

Launching outside containment (Command.LaunchDetached)

The deliberate inverse of Adopt: instead of pulling a process into the container, it launches one that never enters any — the opt-out for spawn-and-forget work (a self-updater, a restart-myself relaunch, a daemon handed to the OS). See Detached launch for the full contract and the typed refusals; the platform divergences are:

Capability Windows Linux / macOS / BSD (POSIX)
Detachment mechanism ✅ created running, assigned to no Job Object, no handle retained POSIX_SPAWN_SETSID — its own session, no controlling terminal
Survives a terminal/console close 🟡 only with CreateNoWindow() (or WindowsCtrlSignals()) — it otherwise shares the caller's console ✅ a new session cannot be reached by the terminal's hangup
Leaves no entry behind when it exits first ✅ nothing references the process 🟡 it stays our direct child (posix_spawn cannot reparent), so its zombie lingers until this process exits

Both platforms return the same DetachedProcess (pid + start-time identity) and neither observes the child's exit — that is what "detached" means here. The POSIX zombie window is the honest cost of returning the real target pid rather than double-forking through a helper: the child genuinely outlives us (init adopts it then), and ProcessKit never reaps a process it does not contain.

The opt-out covers the containment ProcessKit creates, not one your own process was placed in by someone else: a child of a job-bound Windows process joins that job by kernel rule (breakaway is not requested — most ambient jobs forbid it, so asking would turn a working launch into a spawn failure), and a Linux child inherits your cgroup, so a systemctl stop of your unit still reaps it. Work that must survive that belongs with the platform's own supervisor, not with a child process.

Additional child file descriptors (Command.ExtraFd)

Capability Windows Linux / macOS / BSD (POSIX)
Full-duplex channel at child fd 3+ ProcessError.Unsupported ✅ socketpair + explicit dup2
Parent access through RunningProcess.TakeExtraFd ✅ one-time Stream claim

Each configured target must be unique and at least 3. The socketpair is close-on-exec by default and only the explicitly mapped child end survives the spawn, so concurrent children do not inherit one another's control channels. Pipelines, detached launches, and in-memory/cassette runners reject this feature because they cannot expose or preserve the per-run parent stream.

Reaping on sudden parent death (Command.KillOnParentDeath)

Kill-on-dispose covers the parent tearing the group down; it cannot cover the parent being killed outright (SIGKILL, a crash, a Windows TerminateProcess), because no Dispose/finalizer runs. Command.KillOnParentDeath() opts a child in to being reaped in that case, and Command.KillOnParentDeathScope() reports the honest, platform-fixed scope (independent of whether the verb was set):

Capability Windows (Job Object) Linux macOS/BSD
KillOnParentDeathScope() WholeTree DirectChildOnly Nothing
Reap child on sudden parent death ✅ whole tree, no opt-in needed 🟡 direct child only ProcessError.Unsupported
  • Windows already reaps the whole tree with no extra action: every child lives in a Job Object created with KILL_ON_JOB_CLOSE whose sole handle the parent owns, so the kernel's handle rundown on parent death closes that last handle and terminates the Job. KillOnParentDeath() is a documented no-op there, not a silent one.
  • Linux arms PR_SET_PDEATHSIG(SIGKILL) on the child through the setpriv --pdeathsig helper, reaching the direct child only (see the caveat below for what that excludes).
  • macOS/BSD have no PR_SET_PDEATHSIG analog, so a set value fails the spawn with ProcessError.Unsupported — never a silent no-op.

Signals (Signal)

Capability Windows (Job Object) Linux cgroup v2 POSIX process group
Signal.Kill ✅ maps to Job terminate
Signal.Int / Signal.Term 🟡 best-effort CTRL+BREAK (a WindowsCtrlSignals() child) and/or WM_CLOSE (a windowed member); Unsupported only when the group has neither
Any other signal (Hup, Quit, Usr1, Usr2, Other n) ProcessError.Unsupported

Suspend / resume

Capability Windows (Job Object) Linux cgroup v2 POSIX process group
Suspend / Resume the whole tree ✅ per-process freeze across the job cgroup.freeze SIGSTOP / SIGCONT

Member listing (Members)

Capability Windows (Job Object) Linux cgroup v2 POSIX process group
Members() snapshot ✅ whole tree ✅ whole tree 🟡 tracked group leaders only

MembersInfo() returns that same membership enriched per pid (MemberInfo: Pid, Ppid, ExeName, StartTime). Enrichment follows the OS, not the mechanism — on Linux both the cgroup v2 and the process-group backend read /proc identically. Every enriching field is an option, None where the platform cannot honestly report it (never a fabricated value); a member that exits between enumeration and its metadata read is omitted, not invented; and the member's command line and environment are never included on any platform.

MemberInfo field Windows (Job Object) Linux (cgroup v2 or process group) macOS other BSD
Pid
Ppid ✅ process snapshot /proc/<pid>/stat proc_pidinfo
ExeName ✅ image file name (foo.exe) /proc comm (~15 chars) proc_pidinfo
StartTime 🟡 best-effort

ExeName is a base image name, never an argv — Windows reports the full foo.exe; Linux/macOS the kernel comm (truncated to ~15 chars). StartTime is System.Diagnostics.Process.StartTime; on a BSD other than macOS, where no per-pid parent/image reader exists, only the pid and a best-effort start time are reported.

Stats (Stats / SampleStatsAsync)

Capability Windows (Job Object) Linux cgroup v2 POSIX process group
ActiveProcessCount
TotalCpuTime + PeakMemoryBytes ❌ active count only
IoReadBytes / IoWriteBytes + operation counts ✅ Job aggregate 🟡 io.stat when I/O is delegated None

On the POSIX process-group mechanism, all optional ProcessGroupStats metrics are None — only the live process count is available. Windows reads Job Object accounting; the cgroup mechanism reads cpu.stat, memory.peak, and, when that controller is delegated, block-device counters from io.stat.

Resource limits (ProcessGroupOptions)

Capability Windows (Job Object) Linux cgroup v2 POSIX process group
WithMemoryMax (whole tree) ProcessError.ResourceLimit
WithMaxProcesses ProcessError.ResourceLimit
WithCpuQuota 🟡 approximate ProcessError.ResourceLimit
WithCpuTimeMax ✅ Job aggregate ✅ per child RLIMIT_CPU ✅ per child RLIMIT_CPU
WithCpuAffinity (pin the tree to cores) 🟡 JOB_OBJECT_LIMIT_AFFINITY, cores 0–63 only cpuset.cpus (needs the cpuset controller) ProcessError.ResourceLimit
WithUiRestrictions (clipboard/desktop/exit-Windows) JOBOBJECT_BASIC_UI_RESTRICTIONS ProcessError.Unsupported ProcessError.Unsupported

WithCpuQuota is a fraction of a single core (0.5 = half a core, 2.0 = two cores). On Windows it is converted against the host's CPU count and is approximate. Whole-tree limits need a real limit-capable container; the POSIX process-group mechanism supports only the per-child CPU-time rlimit. Any unsupported request fails at creation with ProcessError.ResourceLimit rather than returning a silently-unbounded group.

WithCpuAffinity pins the tree to a set of zero-based core indices and carries two platform ceilings, both reported as a typed ProcessError.ResourceLimit at creation/update rather than as a silently dropped pin. Windows: JOBOBJECT_BASIC_LIMIT_INFORMATION.Affinity is one pointer-sized mask covering a single processor group, so only cores 063 are nameable on x64 (031 on x86); a host with more logical processors splits them across groups the mask cannot reach. Linux: cpuset is a controller a hierarchy may simply not carry, and unlike memory/pids/cpu its absence is not implied by cgroup v2 being mounted — where cgroup.controllers omits it, no pin can be enforced. On both, every requested core must exist on the host and be available to the caller: a Job's affinity mask must be a subset of the creating process's own, and a cgroup's cpuset.cpus a subset of the parent's effective cores. macOS and BSD have no whole-tree affinity primitive at all (nor even a per-process one comparable to sched_setaffinity), so the refusal there is unconditional.

WithUiRestrictions is the one dimension that is Windows-only rather than limit-capable-container-only: it restricts what the contained tree may do to the interactive desktop session (clipboard, desktops, display/system parameters, global atoms, ExitWindows), which no POSIX primitive — cgroup v2 included — has any analogue for. That is why it refuses with ProcessError.Unsupported rather than the ResourceLimit the caps above use: a memory cap is a concept everywhere and merely unenforceable on some mechanisms, while a clipboard restriction does not exist off Windows at all. Either way the request is never silently dropped.

These caps are also updatable on a live group via ProcessGroup.UpdateLimits(ResourceLimits) — an optional runtime operation that re-applies a full replacement cap set without recreating the group or restarting its children. It follows the same platform matrix as creation: the Windows Job Object re-applies via SetInformationJobObject (the caps, the affinity mask, and the UI restrictions), the Linux cgroup v2 mechanism rewrites memory.max / pids.max / cpu.max / cpuset.cpus (and refuses a set carrying UI restrictions with ProcessError.Unsupported, leaving the previous caps untouched), and the POSIX process-group mechanism (macOS/BSD, or Linux without cgroup v2) returns ProcessError.ResourceLimit — never a silent no-op. See process-groups.md for the API and semantics.

Windows privilege drop (Command.WindowsRestrictedToken / WindowsIntegrityLevel)

The mirror image of the Unix privilege drop below: Windows has no setuid, so a child is hardened by handing it a weakened copy of the caller's own token instead of a different identity.

Capability Windows Linux / macOS / BSD (POSIX)
WindowsRestrictedToken() (no privilege but SeChangeNotifyPrivilege) CreateRestrictedToken + CreateProcessAsUser ProcessError.Unsupported
WindowsIntegrityLevel(Medium/Low/Untrusted) SetTokenInformation(TokenIntegrityLevel) ProcessError.Unsupported
Either, combined with Command.Pty ArgumentException at the builder (ConPTY spawns through a call that cannot carry the token) ❌ same builder refusal
Either, combined with Uid/Gid/Groups/Umask/Setsid ArgumentException at the builder ❌ same builder refusal

Both apply to the direct child (and, through token inheritance, the descendants it starts); they are honoured on the contained spawn and on Command.LaunchDetached alike. Neither can raise privilege — a restricted token only loses rights and Windows refuses to raise a token's integrity, so there is deliberately no elevating variant. A host policy that refuses to let ProcessKit assign the derived token fails the spawn with a typed ProcessError.Spawn naming that refusal, never a silent fallback to an unhardened child. The cross-platform pair is rejected at the builder rather than at the spawn because each half is Unsupported on the platform the other half needs, so such a command could not run anywhere. See commands.md and hardening.md.

Pseudo-terminal (PTY) capabilities

Command.Pty gives a child a controlling terminal and one merged stdout+stderr stream. Every unavailable case is a typed ProcessError.Unsupported; ProcessKit never quietly falls back to pipes.

Capability Windows (ConPTY) Linux (openpty + setsid --ctty) macOS/BSD (POSIX pgid + ctty helper)
PTY spawn ✅ Windows 10 1809+ 🟡 needs a controlling-terminal helper
ResizeAsync on a PTY ResizePseudoConsole TIOCSWINSZ + SIGWINCH TIOCSWINSZ + SIGWINCH
ResizeAsync on a non-PTY Unsupported Unsupported Unsupported
Containment under PTY ✅ Job Object ✅ cgroup v2 or pgid ✅ pgid

Windows older than 10 version 1809 returns Unsupported. Linux needs the setsid --ctty helper in one of the trusted system directories (/usr/bin, /bin, /usr/sbin, /sbin, where util-linux installs it — the helper is never taken from PATH, see Hardening → Where the Unix helper binaries come from) as well as a usable PTY device. openpty exists on macOS/BSD, but their standard setsid does not provide --ctty; until a helper is supplied, a PTY spawn there is Unsupported rather than a controlling-terminal-less half implementation.

Everything not listed here — capture, line streaming, interactive stdin, encodings, buffer policies, timeouts, retry, pipelines, supervision, readiness probes, cancellation, redirecting stdout/stderr straight to a file (Command.StdoutToFile/StderrToFile — an inheritable file handle in STARTUPINFO on Windows, a file fd via a posix_spawn file action on POSIX; the same create/truncate/append semantics and the same builder-boundary conflict rules on every platform), and the testing seams — is platform-agnostic and behaves identically everywhere. See commands.md, streaming.md, pipelines.md, supervision.md, and testing.md.

Caveats

The honest fine print — mostly consequences of OS semantics, plus a few tracked internal constraints that do not change the public surface.

Windows ConPTY sidecar ownership. The conhost / OpenConsole.exe sidecar created for a ConPTY is not a Job Object member. That is a real difference from the child process tree, not a hidden containment claim: ProcessKit owns the sidecar through the pseudoconsole handle and closes it deterministically with ClosePseudoConsole during teardown. The child itself is still born inside the Job Object.

Windows PTY echo belongs to the child. PtyConfig.Echo = false clears the POSIX slave terminal's ECHO bit before spawn, but Windows echo is controlled by the child's CONIN$ console mode. ConPTY does not expose a supported parent-side pre-spawn override, so a Windows credential prompt must suppress its own echo. This is documented rather than silently treating Echo = false as a Windows guarantee.

Windows .cmd/.bat shims launch through cmd.exe. A Windows bare name whose only PATH match carries a non-.exe extension (the .cmd/.bat wrappers npm, yarn, az, and many dotnet-tool shims ship) is unreachable by the OS's own bare-name search, which appends only .exe — yet Exec.which locates it through the same PATHEXT-aware lookup. ProcessKit closes that which-vs-spawn gap: it substitutes the resolved absolute path into the launch, and routes a .cmd/.bat through cmd.exe /d /c (a batch file is not a directly-launchable image). Because a batch wrapper reintroduces a shell, arguments are quoted for cmd.exe's own grammar, not just the ordinary argv rules — a metacharacter such as &, |, <, >, or " is delivered literally, never executed (the "BatBadBut" class, CVE-2024-24576). An argument cmd.exe cannot escape at all — a %, a !, or a line break — fails the spawn with a typed ProcessError.Spawn rather than launching unsafely. A .exe match, a path-form program, and anything on POSIX are unaffected (POSIX has no PATHEXT; the OS resolves them exactly as before).

Command.WindowsRawArg is Windows-only. It appends a trusted fragment verbatim after all ordinarily quoted arguments for children with a non-MSVCRT parser. POSIX has an argv vector rather than a mutable raw command line, so requesting it there fails with ProcessError.Unsupported. Automatic .cmd/.bat wrapping is also refused when raw fragments are present; invoke cmd.exe explicitly if its grammar is intentionally the parser. See Running commands for ordering and injection rules.

POSIX process groups: a setsid child can escape. The process-group mechanism tracks each child's pgid, and teardown signals those pgids. A descendant that deliberately starts a new session (a setsid call) gets a fresh process group that the parent group does not track, so it can outlive the teardown. This is the genuine weakness of the process-group mechanism; it is why ProcessGroup.Mechanism is reported rather than papered over. The Job Object and cgroup v2 mechanisms have no such hole — membership is enforced by the kernel container, not by group bookkeeping. When this matters, check the active mechanism.

Unix privilege drop clears supplementary groups unless you set them. A Uid/Gid/User drop runs through the setpriv helper (util-linux), which by default clears the parent's supplementary groups so the child never keeps root's — but a child dropped to a service user then lacks that user's group memberships (docker, video, adm, …). Pass Command.Groups(gids) to set the child's supplementary groups explicitly (mapped to setpriv --groups); it is honoured only alongside a Uid/Gid drop, so requesting it without one fails with ProcessError.Spawn rather than being silently ignored. The whole family is Unix-only: on Windows Uid/Gid/Groups/Setsid/Umask each fail the spawn with ProcessError.Unsupported, never a silent no-op. The helper is loaded only from a trusted system directory (/usr/bin, /bin, /usr/sbin, /sbin) and launched by absolute path, never resolved on PATH, so it cannot be hijacked by a planted binary — see Hardening → Where the Unix helper binaries come from. setpriv ships there on mainstream Linux; where no trusted directory holds it (macOS/BSD, and non-FHS layouts such as NixOS) a Uid/Gid/Groups drop fails with a typed ProcessError.Spawn naming the missing helper.

A Windows-hardened child keeps the caller's identity. WindowsRestrictedToken and WindowsIntegrityLevel reduce privilege and write access; they do not change who the child is. It still runs as the caller, so it can read whatever the caller can read and open network connections freely — privilege reduction, not isolation. In particular a secret the caller can read is a secret the child can read, which is why Command.EnvClear and the rest of the perimeter in hardening.md still matter. Two further honest edges: the child's already-open stdio handles keep working at any integrity level (their access check happened in the parent — by design, or it could not report anything back), and at Untrusted many programs cannot start at all, which surfaces as that child's own non-zero exit rather than as a ProcessKit error.

KillOnParentDeath reaps only the direct child on Linux, and only up to a set-uid exec. The opt-in Command.KillOnParentDeath() reaps a child when its parent dies suddenly, but the guarantee is platform-specific — Command.KillOnParentDeathScope() reports the honest scope. On Linux it is armed as PR_SET_PDEATHSIG(SIGKILL) via the setpriv --pdeathsig helper (util-linux, loaded from a trusted system directory rather than PATH exactly as the privilege drop is; a helper absent from all of them is a typed ProcessError.Spawn, like the privilege drop) and reaches the direct child only: the parent-death signal is not inherited across a fork, so a grandchild the child spawns is not covered — with the child's parent gone, nothing reaps its cgroup/pgroup. The kernel also resets the signal when the child execves a set-uid/set-gid image, so for a sudo-like child it holds only up to that exec. And because the parent-death signal fires when the spawning thread (not merely the process) exits — and ProcessKit spawns on a thread-pool thread .NET may retire while the process lives — the reap is best-effort and can, in principle, fire early if that thread is reclaimed. On Windows the whole tree is reaped with no opt-in (the Job Object's KILL_ON_JOB_CLOSE fires when the kernel closes the dead parent's last Job handle during process rundown). On macOS/BSD there is no analog, so a request fails the spawn with ProcessError.Unsupported rather than pretending the cleanup happens.

Windows has a narrow signal mapping. Signal.Kill terminates the Job/run; Signal.Int and Signal.Term use best-effort CTRL+BREAK for opted-in console children and/or WM_CLOSE for windowed children. Other values return ProcessError.Unsupported. A custom Command.StopSignal is likewise refused at spawn on Windows instead of being silently replaced.

No whole-tree resource limits on macOS/BSD or the Linux process-group fallback. Limits require a Windows Job Object or a Linux cgroup v2; the POSIX process-group mechanism has no primitive to cap a tree's memory, process count, CPU quota, or affinity. CpuTimeMax is the exception: POSIX enforces it per spawned process through RLIMIT_CPU. Requesting any other limit there makes ProcessGroup.Create return ProcessError.ResourceLimit immediately — an unapplied cap is no protection, so the group is never created unbounded. See Running in containers for what this means in practice inside Docker/Kubernetes.

cgroup v2 needs the real cgroup root. The cgroup v2 mechanism is selected on Linux only when limits are requested and a usable cgroup v2 hierarchy is available. Enabling the controllers a limit needs (writing the parent's cgroup.subtree_control) is permitted by cgroup v2's "no internal processes" rule only at the real hierarchy root. A cgroup namespace root — what an ordinary container or a systemd session/scope/service sees — does not qualify and the write is refused (surfacing as ProcessError.ResourceLimit). In practice real cgroup limit enforcement needs a minimal init sitting at the true root; elsewhere a limit-free group simply uses the POSIX process-group mechanism. Check ProcessGroup.Mechanism when the limit must not silently fail to apply. See Running in containers for the container-specific consequences — PID 1, minimal/shell-less images, and container-level limits vs ProcessGroupOptions limits.

Output is decoded as UTF-8 by default. Captured stdout/stderr text is decoded as UTF-8 unless you say otherwise. A Windows console program that emits a legacy OEM code page will decode incorrectly; Command.ConsoleEncoding() fixes that in one call — it resolves this host's console output code page (or the system OEM code page when the process has no console), registers the code-page provider itself, and is a no-op off Windows. To name a different encoding, set it explicitly per stream with Command.StdoutEncoding / Command.StderrEncoding (or Command.Encoding for both), registering the code-page provider first (System.Text.Encoding.RegisterProvider(CodePagesEncodingProvider.Instance)) if it is a legacy code page.

POSIX pgid reuse. Process-group signalling is inherently best-effort against pid/pgid reuse: between a child exiting and the group teardown running, the OS can recycle that pgid for an unrelated process. The backend prunes dead entries on every probe to keep the window minimal, but it cannot be eliminated at the process-group layer — the cgroup v2 mechanism (used when limits are requested) closes it, since membership is kernel-enforced.

In-flight line without a byte cap, and streaming backlog. OutputBufferPolicy.MaxBytes bounds the in-flight (not-yet-terminated) line too for the buffered verbs — it is force-flushed at the cap, so a newline-free flood can't outgrow the buffer. Without a byte cap, a single not-yet-terminated line still grows until end of stream (MaxBytes does not apply to the streaming verbs, which are consumer-paced instead). By default, a streamed consumer (StdoutLinesAsync / OutputEventsAsync) that stops draining while the child keeps writing grows the backing channel unbounded. Opt in to Command.StreamBuffer/StreamBufferPolicy to cap that channel instead — Backpressure, DropOldest/DropNewest, or Error; see Streaming — or pair an untrusted or chatty child with a Command.Timeout, which bounds the run and ends the stream at the deadline either way.

One consumption per RunningProcess. The streaming verbs compose in one session (WaitForLineAsyncStdoutLinesAsyncFinishAsync); OutputStringAsync / OutputBytesAsync / WaitAsync / ProfileAsync are each a standalone terminal. The handle enforces this: once one consumer has claimed the output pipes, a second, conflicting one is refused rather than racing two readers on the same pipe — the Result-returning verbs return ProcessError.Unsupported, while WaitAsync / ProfileAsync / StdoutLinesAsync / OutputEventsAsync throw InvalidOperationException. Pick one consumption model per handle.

Concurrency-friendly I/O. Waiting on a running child no longer blocks a dedicated thread on either platform — Windows uses a thread-pool registered wait, Linux uses pidfd/epoll, macOS uses EVFILT_PROC on one shared kqueue, and the remaining POSIX fallback uses an event-driven SIGCHLD registration (see the changelog) — and the parent side of a child's pipes is now genuinely asynchronous on both: Windows uses overlapped named pipes over IOCP, and Linux/macOS wrap each stdio channel's parent end (an AF_UNIX socketpair) in a Socket/NetworkStream whose reads and writes complete through the runtime's epoll/kqueue event loop — no thread-pool thread parked per piped stream. So a very large WaitAllAsync, a busy Supervisor, or a wide Exec.outputAll fan-out of many piped children no longer grows thread-pool occupancy in step with the fleet size. This is an internal characteristic only — the Task-based public API is unchanged.


Next: Hardening untrusted children