Process groups
A ProcessGroup ties the lifetime of a whole child-process tree to a single
disposable value: every process you start into the group — and everything those
processes spawn — is killed when the group is disposed. An owner that returns
early, throws, or has its task dropped never leaks subprocesses, because the
kernel object behind the group (a Windows Job Object, a Linux cgroup v2, the
procctl(2) process reaper on FreeBSD, or a POSIX process group) reaps even
grandchildren you never knew existed.
That whole-tree containment is the reason this library exists:
System.Diagnostics.Process reaches the direct child at best, so a build tool's
compiler children, the real payload behind a cmd /c … / sh -c … wrapper, or a
test's helper servers can outlive a timeout or an exception as orphans.
You rarely create a group by hand for one-shot runs: every one-shot verb
(RunAsync, OutputStringAsync, …) already spawns into a fresh private group that dies
with the run. Reach for an explicit ProcessGroup when several children should
share one fate, or when you need the group-level verbs below — signals,
suspend/resume, member listing, resource limits, or stats.
- Creating a group
- Putting processes in
- Tearing down: dispose, terminate, shutdown
- Signals and suspend/resume
- Listing members
- Resource limits
- Disk I/O rate limits
- Stats
- Limit Evidence
Creating a group
ProcessGroup.Create() builds an empty, unbounded group on the current platform.
It returns a Result<ProcessGroup, ProcessError> — match it, then bind the group
with use so it (and the tree it contains) is reaped on scope exit:
F#
task {
match ProcessGroup.Create() with
| Error err -> eprintfn $"could not create a group: {err.Message}"
| Ok group ->
use group = group // disposes — and hard-kills the whole tree — on scope exit
// ... start children into `group` ...
()
}
C#
var created = ProcessGroup.Create();
if (created is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine($"could not create a group: {err.Message}");
return;
}
using var group = created.GetValueOrThrow(); // disposes — and hard-kills the whole tree — on scope exit
// ... start children into `group` ...
ProcessGroup.Create(options) takes a ProcessGroupOptions to tune the
graceful-shutdown window and apply whole-tree resource limits (see
Resource limits):
F#
let options = ProcessGroupOptions().WithShutdownTimeout(TimeSpan.FromSeconds 10.0)
match ProcessGroup.Create options with
| Ok group ->
use group = group
() // ...
| Error err -> eprintfn $"{err.Message}"
C#
var options = new ProcessGroupOptions().WithShutdownTimeout(TimeSpan.FromSeconds(10));
var created = ProcessGroup.Create(options);
if (created is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine(err.Message);
return;
}
using var group = created.GetValueOrThrow(); // ...
Two read-only properties report what you actually got. Options echoes back the
ProcessGroupOptions the group was created with (its ShutdownTimeout and
Limits); Mechanism reports the OS primitive containing the tree:
F#
match group.Mechanism with
| Mechanism.JobObject -> printfn "Windows Job Object"
| Mechanism.CgroupV2 -> printfn "Linux cgroup v2"
| Mechanism.ProcessReaper -> printfn "FreeBSD process reaper"
| Mechanism.ProcessGroup -> printfn "POSIX process group"
| _ -> ()
C#
Console.WriteLine(group.Mechanism switch
{
{ IsJobObject: true } => "Windows Job Object",
{ IsCgroupV2: true } => "Linux cgroup v2",
{ IsProcessReaper: true } => "FreeBSD process reaper",
{ IsProcessGroup: true } => "POSIX process group",
_ => "unknown mechanism",
});
Which mechanism you get is not a free choice — it follows the platform and whether you asked for limits:
- Windows always uses a Job Object (
Mechanism.JobObject). - Linux uses a cgroup v2 (
Mechanism.CgroupV2) only when you request resource limits and the host can deliver them; for plain containment — and on any Linux host without delegated cgroup v2 — it uses a POSIX process group (Mechanism.ProcessGroup). - FreeBSD uses the kernel process reaper (
Mechanism.ProcessReaper) — the whole descendant tree,setsidescapees included — falling back to the POSIX process group and saying so if reaper status cannot be acquired. - macOS and the other BSDs always use a POSIX process group (
Mechanism.ProcessGroup).
Because the mechanism is reported rather than assumed, a weaker backend is never
a silent downgrade — you can branch on Mechanism if a capability matters. The
full per-OS matrix lives in platform-support.md.
Asking before you create one
group.Mechanism answers only once a group exists. To pick a portable policy
before the first spawn, ProcessGroup.Capabilities() (or
Capabilities(options)) returns a ContainmentCapabilities snapshot: the
mechanism Create would select for those options, plus, per axis — resource
limits, signals, adoption (from a Process and, separately, from a bare pid),
PTY and its resize, kill-on-parent-death, and the
platform helper binaries — either a real availability or a typed
Capability.Unsupported naming the precondition that is missing:
F#
let capabilities = ProcessGroup.Capabilities()
match capabilities.Adoption with
| Capability.Available -> printfn "Adopt() can pull an external process in here"
| Capability.Qualified qualification -> printfn $"Adopt() works, with a caveat: {qualification}"
| Capability.Unsupported requires -> printfn $"no Adopt() on this host; it needs {requires}"
C#
var capabilities = ProcessGroup.Capabilities(options);
if (capabilities.ResourceLimits.MemoryMax is Capability.Unsupported noMemoryCap)
{
Console.WriteLine($"this host cannot cap the tree's memory; it needs {noMemoryCap.Requires}");
}
It creates nothing — no process, no group, no container — and reads neither argv nor environment. See Capability snapshot for what each axis answers and what the snapshot does and does not promise.
Putting processes in
A ProcessGroup is itself an IProcessRunner, so the same run/capture
vocabulary you use on a Command works against the shared group — every child
lands in the one container.
The direct door is StartAsync(command), which returns a live RunningProcess (the
full streaming / stdin / readiness surface from
streaming.md). The key ownership rule: the group owns the
child's lifetime. Disposing the returned RunningProcess detaches only that
run's I/O; the child keeps running until you reap the whole tree
(ShutdownAsync / dispose) or kill just that run with its own Kill.
F#
task {
match ProcessGroup.Create() with
| Error err -> eprintfn $"{err.Message}"
| Ok group ->
use group = group
match! group.StartAsync(Command.create "dev-server") with
| Ok server ->
// `server` streams/probes as usual, but the GROUP owns its lifetime.
let! _ready = server.WaitForLineAsync((fun l -> l.Contains "ready"), System.TimeSpan.FromSeconds 10.0)
()
| Error err -> eprintfn $"{err.Message}"
}
C#
var created = ProcessGroup.Create();
if (created is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine(err.Message);
return;
}
using var group = created.GetValueOrThrow();
var started = await group.StartAsync(new Command("dev-server"));
if (started is { IsOk: false, ErrorValue: var startErr })
{
Console.Error.WriteLine(startErr.Message);
return;
}
var server = started.GetValueOrThrow();
// `server` streams/probes as usual, but the GROUP owns its lifetime.
var ready = await server.WaitForLineAsync(l => l.Contains("ready"), TimeSpan.FromSeconds(10));
To capture a child to completion inside the shared group, drive the group
through the IProcessRunner verbs in the Runner module — they take the runner,
a CancellationToken, and the Command:
F#
task {
match ProcessGroup.Create() with
| Ok group ->
use group = group
match! Runner.outputString group CancellationToken.None (Command.create "probe-tool") with
| Ok result -> printfn $"exit={result.Code}: {result.Stdout}"
| Error err -> eprintfn $"{err.Message}"
// `Runner.outputBytes` is the binary companion; `Runner.start` mirrors `group.StartAsync`.
| Error err -> eprintfn $"{err.Message}"
}
C#
var created = ProcessGroup.Create();
if (created is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine(err.Message);
return;
}
using var group = created.GetValueOrThrow();
Console.WriteLine(await Runner.outputString(group, CancellationToken.None, new Command("probe-tool")) switch
{
{ IsOk: true, ResultValue: var result } => $"exit={result.Code}: {result.Stdout}",
{ IsOk: false, ErrorValue: var runErr } => runErr.Message,
});
// `Runner.outputBytes` is the binary companion; `Runner.start` mirrors `group.StartAsync`.
Capture normalization, and a Windows caveat. A capture through a shared group goes through the same path as the default runner, so output encoding, line-ending normalization,
OkCodes, and theOutputBufferpolicy match exactly — aProcessGrouprunner is interchangeable with the default one. One platform caveat: on Windows a per-runTimeout/CancelOnhard-kills only the run's leader process (its descendants stay in the shared Job until the group is torn down). So if a descendant inherited the leader's stdout/stderr pipe and outlives it, the capture can stall past the deadline until that descendant exits or the group is disposed. POSIX kills the leader's whole process group, so it is unaffected. For a hard per-run deadline on Windows, give the run its own group (the default runner) rather than a shared one.
Because a group satisfies IProcessRunner, you can also hand it to anything that
accepts a runner so a whole fleet shares one kill-on-dispose container: pass it as
the runner to Exec.outputAll / Exec.outputAllBytes, or to
Supervisor.WithRunner so every restarted incarnation stays in the same group
(see supervision.md).
Adopting an already-running external process
Sometimes the process you need to contain was not started through ProcessKit —
another library launched it, you inherited it from a different layer, or you only
have its pid. Adopt(process) brings such a process into the group so it obeys the
same whole-tree rules as one started with StartAsync: kill-on-dispose, and
participation in Signal / Suspend / Resume / Members / MembersInfo /
Stats and any resource limits. It restores the "kill the whole tree" guarantee for
a wrapper whose child runs outside ProcessKit.
F#
// `external` is a live System.Diagnostics.Process started by someone else.
use group = (ProcessGroup.Create() |> function Ok g -> g | Error e -> failwith e.Message)
match group.Adopt external with
| Ok() -> () // now a Job/cgroup member: disposing `group` kills it too
| Error err -> eprintfn $"{err.Message}"
C#
using var group = ProcessGroup.Create().GetValueOrThrow();
if (group.Adopt(external) is { IsOk: false, ErrorValue: var err })
Console.Error.WriteLine(err.Message);
A few deliberate contract points:
- The argument is a
System.Diagnostics.Process, not a bare pid. A raw pid can be recycled onto an unrelated process between when you read it and when the adopt lands; a liveProcessholds an open OS handle that on Windows pins the pid, so the adopt cannot race a recycle. On Linux there is no handle that pins a pid, so the residual (tiny) recycle window between the liveness check and thecgroup.procswrite cannot be fully closed by number alone — an honest limitation, documented rather than hidden. Holding noProcessat all — a pid from a pidfile, a registry, an FFI or IPC boundary — is whatAdoptByPidis for; where a liveProcessis available, this overload stays the stronger of the two. - The group keeps only containment; you keep the wait. Adoption returns
Result<unit, _>— not aRunningProcess— because the external process's stdio is not ours to stream. The adopted process is not ProcessKit's child, so ProcessKit neverwaitpids it or signals its process group; it is contained and killed purely by the OS primitive (the Job'sKILL_ON_JOB_CLOSE/ a cgroupcgroup.kill), and its real parent (orinit, once reparented) reaps it. Observe its exit through your ownProcess(Process.WaitForExitAsync()/HasExited). - Honest, typed failures — never a silent success. Adopting a process that has already
exited (or whose pid no longer exists — a lost race), one you lack the rights to, or one
already assigned to an incompatible Windows Job returns
ProcessError.Adopt. On a mechanism that fundamentally cannot adopt — the POSIX process group — you getProcessError.Unsupported(see the platform note next). - Platform availability. Windows (Job Object) adopts with or without limits; Linux
can adopt only into a group created with resource limits (that is what selects the
cgroup v2 mechanism). A plain, limit-free group on Linux, and every group on macOS/BSD,
uses the POSIX process-group mechanism, which cannot relocate a foreign process and
refuses with
ProcessError.Unsupported. See platform-support.md.
Adopting from a bare pid
Often the number is all you have: a pid read from a pidfile, one handed over by an
outside supervisor, one that arrived over an IPC or FFI boundary. AdoptByPid(pid) is
the door for that case — the same containment as Adopt, taken from an int.
F#
// A pid from outside this process — a pidfile, a registry, an FFI caller.
let pid = 4321
use group = (ProcessGroup.Create() |> function Ok g -> g | Error e -> failwith e.Message)
match group.AdoptByPid pid with
| Ok() -> () // contained from here on: disposing `group` kills it too
| Error err -> eprintfn $"{err.Message}"
C#
// A pid from outside this process — a pidfile, a registry, an FFI caller.
var pid = 4321;
using var group = ProcessGroup.Create().GetValueOrThrow();
if (group.AdoptByPid(pid) is { IsOk: false, ErrorValue: var err })
Console.Error.WriteLine(err.Message);
A pid is an address, not a handle. Once a process is reaped the OS may give its number to an unrelated one, so the number is used to find the process and the group is then bound to an identity anchor of its own for whatever that number currently names:
| Mechanism | What the group holds afterwards |
|---|---|
| Windows Job Object | The process object. The number is used exactly once, by this call's OpenProcess; the assign puts that object in the Job and the kernel keeps membership per object. |
| Linux cgroup v2 | Kernel-maintained cgroup membership. A /proc/<pid>/stat start-time read on either side of the cgroup.procs write detects a number that changed hands across it — detection, not prevention (see the failure list below). |
| POSIX process group | The tracked pid plus the start-time token read here, re-read before every probe, signal, suspend/resume and teardown kill. |
So a process that recycles the number after the call is rejected rather than signalled.
What no library can close is the window before it — whether pid still named the
process you meant when you passed it. Look the number up as late as you can. The token
row carries one residual the other two do not: its resolution (a clock tick on Linux, a
microsecond on macOS) cannot separate two processes that held the number within one tick.
What the group covers. Processes the adopted one had already started keep their
original containment. What happens to the ones it starts afterwards follows the
mechanism: on the Job Object and cgroup v2 a later fork joins the container with its
parent, so the subtree grown from here is contained; on the POSIX process group the
process is tracked individually — signalled and killed with the group, but its future
forks are not, because no POSIX primitive moves a foreign, already-execed process into
another process group.
Ownership is unchanged from Adopt: the group contains, signals, lists and kills it;
it never waitpids it, and no exit status for it is reported through this API. On the
POSIX mechanism, note that a process which exits and is not reaped by its own parent
becomes a zombie, and a zombie still answers the identity probe — a graceful
ShutdownAsync then waits out its whole grace on it, and no kill can clear it; only its
parent's wait can.
Refusals, each typed and specific:
pid <= 0and this process's own pid are refused withProcessError.Adoptbefore any mechanism is consulted. Neither is adoptable and both are dangerous as numbers:0means "the caller's own process group" tokill, a negative number addresses a process group, and adopting ourselves would enlist this process in its own group's teardown.- A POSIX host with no start-time identity reader (the BSDs) returns
ProcessError.Unsupported: with no anchor to capture, tracking the bare number would mean SIGKILLing whatever holds it at teardown. Never a silent downgrade. - A process this caller may not signal — another user's, or a protected/system one —
is refused with
ProcessError.Adopt. On the POSIX process group that is an explicitkill(pid, 0)probe at adoption, because reading the anchor proves only that the process can be identified (on Linux/proc/<pid>/statis world-readable), never that it can be controlled; the Job Object and cgroup v2 mechanisms reach the same refusal through their deniedOpenProcess/cgroup.procswrite. A member the group could not signal or SIGKILL would be containment reported but not held. The check speaks for the moment of adoption — a process that changes credentials afterwards is no more foreseeable here than one that exits afterwards. - A pid that names nothing, an identity that cannot be read (a
hidepid/procmount, another user's process on macOS), a deniedOpenProcessorcgroup.procswrite, an assign Windows refuses, or a number that changed hands while the call ran all returnProcessError.Adoptwith the cause. On cgroup v2 that last case has already written the stranger into this group's cgroup, so the call moves it back out to the parent cgroup and says so — and where even that is refused, says that the process stays a member of this group and will be killed by its teardown.
Platform availability. Windows and Linux cgroup v2 as for Adopt. The POSIX
process-group mechanism — which cannot Adopt at all — can adopt by pid wherever this
host has an identity reader (Linux, macOS), so the two axes deliberately differ; ask
ProcessGroup.Capabilities().AdoptionByPid rather than assuming they match.
Tearing down: dispose, terminate, shutdown
There are three ways out, from blunt to graceful:
| Verb | What happens | When to use it |
|---|---|---|
dispose (use / Dispose() / DisposeAsync()) | Immediate hard kill of the whole tree, then releases the container | The safety net — always on, even on an exception or early return |
group.KillAll() | The same hard kill; after Ok, the group stays usable for further spawns; idempotent | Explicit teardown mid-flight when you want to keep the group |
group.ShutdownAsync() / group.ShutdownAsync(grace) | Graceful: on Unix the configured Options.StopSignal → wait the grace window → SIGKILL survivors; on Windows the default uses best-effort WM_CLOSE → wait → atomic Job kill. Releases the group | A clean service stop |
group.ShutdownReportAsync() / group.ShutdownReportAsync(grace) | The exact same teardown as ShutdownAsync, additionally returning a ShutdownReport of what it actually observed | A clean service stop where the caller wants to know what actually happened, not just that it finished |
ProcessGroup implements both IDisposable and IAsyncDisposable, so a use
binding reaps the tree deterministically on scope exit — disposing is a pure hard
kill with no grace, which is exactly what you want as the guaranteed backstop.
For an orderly stop, prefer ShutdownAsync, which awaits a Task:
F#
task {
match ProcessGroup.Create() with
| Ok group ->
use group = group
let! _service = group.StartAsync(Command.create "my-service")
// SIGTERM, give it 5s to flush and exit, then SIGKILL any straggler:
do! group.ShutdownAsync(TimeSpan.FromSeconds 5.0)
| Error err -> eprintfn $"{err.Message}"
}
C#
var created = ProcessGroup.Create();
if (created is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine(err.Message);
return;
}
using var group = created.GetValueOrThrow();
await group.StartAsync(new Command("my-service"));
// SIGTERM, give it 5s to flush and exit, then SIGKILL any straggler:
await group.ShutdownAsync(TimeSpan.FromSeconds(5));
ShutdownAsync() with no argument uses the group's configured
Options.ShutdownTimeout (the default is 2 seconds; set it with
WithShutdownTimeout). Select the soft signal with WithStopSignal (default Signal.Term). A child that handles it and exits ends the grace
early — ShutdownAsync returns as soon as the tree is empty, not after the full
window. ShutdownAsync and dispose are idempotent with each other, so a use-bound
group you also ShutdownAsync explicitly is safe. Note that a suspended tree can
still be hard-killed (dispose / KillAll), but a graceful ShutdownAsync opens
with a SIGTERM a frozen tree cannot act on — Resume first for a clean stop
(see below).
ShutdownReportAsync: what the graceful teardown actually observed
ShutdownAsync only ever reports success (or a thrown exception): it does not say
whether the soft signal actually landed, how many members were alive, or whether
the tree drained on its own or had to be hard-killed. ShutdownReportAsync drives
the identical teardown — same soft signal, same grace, same unconditional hard kill
of any survivor, same release — and additionally returns a ShutdownReport:
F#
task {
use group = group
let! _service = group.StartAsync(Command.create "my-service")
match! group.ShutdownReportAsync(TimeSpan.FromSeconds 5.0) with
| Ok report ->
printfn $"soft signal: {report.SoftSignal}"
printfn $"members before/after: {report.MembersBefore}/{report.MembersAfter}"
printfn $"drained within grace: {report.DrainedWithinGrace}, escalated: {report.Escalated}"
printfn $"elapsed: {report.Elapsed}"
| Error err -> eprintfn $"{err.Message}"
}
C#
await group.StartAsync(new Command("my-service"));
var reported = await group.ShutdownReportAsync(TimeSpan.FromSeconds(5));
if (reported is { IsOk: true, ResultValue: var report })
{
Console.WriteLine($"soft signal: {report.SoftSignal}");
Console.WriteLine($"members before/after: {report.MembersBefore}/{report.MembersAfter}");
Console.WriteLine($"drained within grace: {report.DrainedWithinGrace}, escalated: {report.Escalated}");
Console.WriteLine($"elapsed: {report.Elapsed}");
}
ShutdownReport.SoftSignal is a SoftSignalDelivery: Sent(signal) when the soft
signal was delivered best-effort (a member that ignores it and keeps running still
counts as sent to — whether the tree then drained is DrainedWithinGrace, not
this), Unsupported when the platform had no soft-signal tier at all for this group
(a windowless Windows Job Object with no windowed member — every Unix mechanism
always has a real SIGTERM tier), or Failed(signal) when a soft-signal tier
existed but delivery failed for every target this teardown could still reach (a
uid-changed member that rejected it with EPERM) — the teardown still proceeds to
its grace/escalation regardless. That "every" reading is exact on the POSIX
process-group mechanism (a partial failure among several reachable members is
still Sent, since the phase genuinely reached at least one of them); on Linux
cgroup v2 the underlying broadcast stops at its first genuine per-member failure
even when other members went on to receive the signal, so there Failed means "at
least one member's delivery failed" rather than "every member's." Windows never
produces Failed. MembersBefore/MembersAfter count the same member set
Members() reports and are None only if that membership read itself failed,
never a fabricated 0. Escalated is true only when the grace elapsed with
survivors still alive and they were hard-killed; Elapsed reports the real
wall-clock time, so an early drain reads far below the requested grace.
This port deliberately does not offer ProcessKit-rs's non-escalating
stop(grace, escalate = false) — a mode that leaves survivors running and keeps the
group usable. ShutdownAsync/ShutdownReportAsync both always release the
container at the end, and on Windows closing the Job handle unconditionally kills
whatever it still holds (KILL_ON_JOB_CLOSE); there is no way to report a spared
survivor honestly on a call that is about to kill it anyway. The kill-on-drop tree
guarantee stays unconditional either way — see the four teardown needs (dispose,
KillAll, ShutdownAsync/ShutdownReportAsync) at the top of this section.
SoftStopScope: how far a soft stop reaches, before you try it
group.SoftStopScope() answers "if I call Signal(Signal.Term) (or
ShutdownAsync/ShutdownReportAsync) right now, which of this group's current
members have a live target for that soft signal?" — a side-effect-free capability
query read from the group's live membership, so asking never changes what a later
soft stop does:
| Mechanism | SoftStopScope |
|---|---|
| Linux cgroup v2 | WholeTree — the signal reaches every process in the cgroup |
| FreeBSD process reaper | WholeTree — PROC_REAP_KILL reaches every process in every subtree the group owns, with no escapee at all: this is the strongest form of the promise on any unix |
| POSIX process group (macOS / the other BSDs / Linux without cgroup v2) | WholeTree — killpg reaches every tracked leader and its descendants (a setsid'd child escapes, the same documented weakness kill-on-drop already has) |
| Windows Job Object | OptInMembers when the group has a live console-CTRL leader (Command.WindowsCtrlSignals()) or a live windowed member, else Unsupported |
Unlike ContainmentCapabilities (a fixed, pre-creation snapshot for a set of
ProcessGroupOptions), this is read from the group's live membership on every
call — the same reason ProcessGroup.Mechanism is per-group rather than a platform
constant. It only ever describes the soft tier: the unconditional hard kill
(Signal.Kill, KillAll, disposing the group) always reaches the whole tree
regardless of what this reports. On Windows, OptInMembers is a capability report,
not a delivery guarantee: a live console-CTRL leader can still see
GenerateConsoleCtrlEvent fail for reasons this read does not probe (such as the
caller having no console to share), in which case the later Signal(Int/Term)
call returns ProcessError.Unsupported even though SoftStopScope reported
OptInMembers.
Signals and suspend/resume
Beyond teardown, a group can broadcast a signal to every member, or freeze and
thaw the whole tree. All of these are synchronous and return
Result<unit, ProcessError>.
Signal(signal) delivers a portable Signal to every process in the group:
For a single live handle, RunningProcess.Signal(signal) uses the same backend-safe delivery but
targets only that run's own process group/Job child. It is non-consuming, so the caller can signal and
then continue streaming or await the outcome; lifecycle gates prevent post-teardown PID reuse.
F#
let reload (group: ProcessGroup) =
match group.Signal Signal.Hup with // "reload your configuration"
| Ok () -> ()
| Error err -> eprintfn $"{err.Message}"
C#
void reload(ProcessGroup group)
{
if (group.Signal(Signal.Hup) is { IsOk: false, ErrorValue: var err }) // "reload your configuration"
Console.Error.WriteLine(err.Message);
}
The portable Signal values are Signal.Term, Signal.Kill, Signal.Int,
Signal.Hup, Signal.Quit, Signal.Usr1, Signal.Usr2, and the raw escape
hatch Signal.Other n for any other signal number.
| Platform | Deliverable signals |
|---|---|
| Linux (cgroup or process group), macOS / BSD | Any — Term, Kill, Int, Hup, Quit, Usr1, Usr2, Other n |
| Windows | Kill (maps to the Job terminate); Int / Term as a best-effort soft stop (see below); anything else → ProcessError.Unsupported |
On Windows, Signal.Int and Signal.Term map to a best-effort soft stop built
from two individually-targeted mechanisms:
- a console CTRL+BREAK to each child started with
Command.WindowsCtrlSignals()(spawned in its own console process group), and - a
WM_CLOSEposted to the top-level windows of every member that has one — the standard graceful close a windowed app (an Electron/GUI tool) turns into its own shutdown, exactly whattaskkill(without/F) does. It is targeted strictly by process id, so it never reaches a window outside the group, and needs no opt-in.
Either mechanism reaching at least one member is a best-effort Ok (delivery is not
compliance — a child may install its own handler or a window may prompt/veto the
close). The call returns ProcessError.Unsupported only when the group has
neither a CTRL-capable child nor any member with a top-level window — nothing to
soft-signal at all — never a silent downgrade to the hard Job kill. A child with no
window is simply a WM_CLOSE no-op, not a regression.
Signal.Kill takes the same hard-kill path, with the same return contract, as
KillAll; other signals are a best-effort per-member broadcast against a tree
that may be forking at that instant. On Linux cgroup v2, both hard-kill entry
points explicitly thaw and verify the reusable cgroup after either atomic
cgroup.kill or the legacy per-member fallback. A freezer that still reports
frozen or cannot be read returns ProcessError.Io; an already-unfrozen or
removed freezer is a best-effort success. Only the legacy fallback can also
return ProcessError.Io for a member-delivery failure that leaves the cgroup
populated or unreadable. After an error, reuse is not guaranteed; final disposal
still runs its bounded best-effort drain and reclaim attempt. An already-exited
member is skipped, and an empty group accepts any deliverable signal trivially.
On Windows, an undeliverable signal fails fast:
F#
match group.Signal Signal.Hup with
| Ok () -> ()
| Error(ProcessError.Unsupported operation) -> eprintfn $"not on this platform: {operation}"
| Error err -> eprintfn $"{err.Message}"
C#
if (group.Signal(Signal.Hup) is { IsOk: false, ErrorValue: var err })
Console.Error.WriteLine(err switch
{
ProcessError.Unsupported { Operation: var op } => $"not on this platform: {op}",
_ => err.Message,
});
Suspend() freezes the whole tree (to snapshot it, to starve a runaway while you
investigate, or to pause background work) and Resume() thaws it:
F#
let pauseWhile (group: ProcessGroup) (inspect: unit -> unit) =
group.Suspend() |> ignore // the whole tree stops consuming CPU
inspect ()
group.Resume() |> ignore
C#
void pauseWhile(ProcessGroup group, Action inspect)
{
group.Suspend(); // the whole tree stops consuming CPU
inspect();
group.Resume();
}
Suspend/resume work wherever a container exists, but the machinery differs:
- Linux cgroup v2 — a single
cgroup.freezewrite; atomic over the subtree. - Linux process group, macOS / BSD — a
SIGSTOP/SIGCONTbroadcast; level-triggered, so it is idempotent. - Windows — a per-thread suspend walk over every member. Best-effort against
threads churning mid-walk, and counted: N
Suspendcalls need NResumecalls.
A practical rule: Resume before starting new work into the group, and Resume
before a graceful ShutdownAsync. See platform-support.md for
the caveats in full.
Listing members
Members() returns a point-in-time snapshot of the live member pids as an
IReadOnlyList<int>, wrapped in a Result:
F#
task {
match ProcessGroup.Create() with
| Ok group ->
use group = group
let! _a = group.StartAsync(Command.create "worker-a")
let! _b = group.StartAsync(Command.create "worker-b")
match group.Members() with
| Ok pids -> printfn $"{pids.Count} live members: {pids}"
| Error err -> eprintfn $"{err.Message}"
| Error err -> eprintfn $"{err.Message}"
}
C#
var created = ProcessGroup.Create();
if (created is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine(err.Message);
return;
}
using var group = created.GetValueOrThrow();
await group.StartAsync(new Command("worker-a"));
await group.StartAsync(new Command("worker-b"));
Console.WriteLine((group.Members()) switch
{
{ IsOk: true, ResultValue: var pids } => $"{pids.Count} live members: {string.Join(", ", pids)}",
{ IsOk: false, ErrorValue: var membersErr } => membersErr.Message,
});
What "members" means depends on the mechanism. On Windows (Job Object) and
the Linux cgroup v2 backend, Members() lists the whole tree — every
descendant pid. On the POSIX process-group backend it lists the tracked group
leaders only (one pid per started child); their descendants are still contained
and killed with the group, just not enumerated. An exited child still counts until
it is reaped, and because the snapshot is point-in-time, a tree that is actively
forking races it.
Enriched members
MembersInfo() returns the same membership as Members(), but each pid comes
as a MemberInfo carrying its parent pid, executable image name, and OS-reported
start time — the "who is in this tree, who is whose parent, what image, since when"
snapshot, without hand-rolling System.Diagnostics.Process / /proc reads and
racing process exit yourself:
F#
match group.MembersInfo() with
| Ok members ->
for m in members do
printfn $"pid={m.Pid} ppid={m.Ppid} exe={m.ExeName} started={m.StartTime}"
| Error err -> eprintfn $"{err.Message}"
C#
if (group.MembersInfo() is { IsOk: true, ResultValue: var members })
foreach (var m in members)
Console.WriteLine($"pid={m.Pid} ppid={m.Ppid} exe={m.ExeName} started={m.StartTime}");
Pid is always present; Ppid, ExeName, and StartTime are each an option
and are None wherever the platform cannot honestly report them — never a
fabricated value. A member that exits between the enumeration and its metadata read
is omitted rather than filled with invented fields, so under an actively
forking-and-exiting tree the result is a subset of Members(). The member's
command line and environment are never included on any platform — argv
routinely carries secrets and redaction is your policy, the same exclusion the
logging / tracing / metrics paths make. Which enriching fields each platform can
supply is in platform-support.md.
To wait on members rather than list them, race the started handles with
RunningProcess.WaitAny — see streaming.md.
Looking up a process outside any group
MembersInfo() (above) enriches a group's own membership. ProcessLookup.processInfo(pid) /
processIsAlive(pid, startTime) answer the same questions for an arbitrary pid the caller holds
outside any ProcessGroup — one saved to disk across runs, kept in a launch registry, or watched by
an external probe this library never itself contained. No group is created or needed; these are plain
module functions over a bare pid.
F#
let pid = 4321
match ProcessLookup.processInfo pid with
| Ok(Some info) -> printfn $"pid={info.Pid} ppid={info.Ppid} exe={info.ExeName} started={info.StartTime}"
| Ok None -> printfn $"pid {pid} is not running"
| Error err -> eprintfn $"{err.Message}"
C#
var pid = 4321;
var lookup = ProcessLookup.processInfo(pid);
if (lookup is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine(err.Message);
}
else if (lookup.ResultValue is { } found) // a non-null FSharpOption means "Some"
{
var info = found.Value;
Console.WriteLine($"pid={info.Pid} ppid={info.Ppid} exe={info.ExeName}");
}
else
{
Console.WriteLine($"pid {pid} is not running");
}
processInfo returns the same MemberInfo contract MembersInfo() gives a group member — Ppid /
ExeName / StartTime, each None where the platform cannot honestly report it, argv/environment never
included — with three outcomes that must not be confused: Ok(Some info) (the process exists), Ok None
(an honest negative — no such process, never an error), and Error (the process may well exist but its
state could not be determined — a denied OpenProcess, a Linux EACCES under hidepid=1 — never
read this as "dead"). pid <= 0 is refused up front with Ok None, before any native call; this
process's own pid is an entirely ordinary target (unlike AdoptByPid, a read-only lookup enlists nothing
into a group).
Two platform divergences worth knowing before you rely on either outcome:
hidepid=2(orsubset=pid) makes a foreign/proc/<pid>invisible, not merely unreadable. TheEACCES-vs-ENOENTsplit above holds exactly forhidepid=1(the directory is visible, its contents refused). Underhidepid=2/hidepid=invisiblethe kernel makes another user's/proc/<pid>vanish entirely, so a live foreign process there reads as the ordinaryENOENTan already-gone pid also produces —Ok Nonefor a process that is, in fact, still running. There is no syscall signal that tells the two apart on such a host.- A POSIX "zombie" (exited but not yet
waited by its real parent) still answersOk(Some _). Linux/proc/<pid>/stat, macOSproc_pidinfo, and the bare-BSDkill(pid, 0)probe all keep answering for a zombie — this lookup does not inspect thestatstate field to filter it out — soprocessInfoandprocessIsAliveboth read a zombie as present/alive. Windows has no equivalent state: an exited Windows process is simply gone (Ok None), collected or not. A reaper-style caller pollingprocessIsAliveon POSIX should not assumetruemeans the process is still doing useful work — only that nothing has collected its exit status yet.
processIsAlive is the reuse-safe liveness question a caller who saved a MemberInfo.StartTime (from an
earlier processInfo or MembersInfo()) actually wants: is the pid still that same process, or has
the OS recycled the number for a stranger?
F#
let pid = 4321
let saved: DateTime option = None // read back from wherever `processInfo`'s StartTime was saved
// … later, perhaps after a restart …
match ProcessLookup.processIsAlive pid saved with
| Ok true -> printfn "still the original process"
| Ok false -> printfn "gone — exited, or the pid was recycled"
| Error err -> eprintfn $"{err.Message}"
C#
using Microsoft.FSharp.Core;
var pid = 4321;
// `saved` is read back from wherever the earlier `MemberInfo.StartTime` was persisted; `null` here
// is the `None` case FSharpOption<DateTime> compiles down to ("no token was saved").
FSharpOption<DateTime> saved = null;
// … later, perhaps after a restart …
Console.WriteLine(ProcessLookup.processIsAlive(pid, saved) switch
{
{ IsOk: true, ResultValue: true } => "still the original process",
{ IsOk: true, ResultValue: false } => "gone — exited, or the pid was recycled",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
Pass the pid together with the StartTime you saved: a bare pid check would answer "alive" for a
stranger that recycled the number after the original process exited, while pairing it with the start
time — fixed at creation, distinct for a later occupant — tells the two apart. When no token was
saved (saved = None), the check honestly degrades to bare-pid liveness rather than reporting a false
"dead" it cannot prove. But when a token was saved and the current process's start time cannot be
read right now, processIsAlive never falls back to "alive" just because the pid still exists — that is
exactly the reuse false positive this API exists to prevent — it returns a typed ProcessError.Io
instead: the start-time reader is attempted on every platform (a best-effort Process.StartTime even on
a BSD other than macOS), so a failed read is always about this one pid right now, never a platform
lacking the mechanism (ProcessError.Unsupported). Both functions reuse exactly the same per-platform
readers MembersInfo() uses — no second, parallel identity-reading mechanism — so the two APIs can never
disagree about the same pid.
Resource limits
Caps are a property of the group, set once at creation through
ProcessGroupOptions and enforced by the same kernel object that contains the
tree. The builder is fluent and immutable:
F#
task {
let options =
ProcessGroupOptions()
.WithMemoryMax(512L * 1024L * 1024L) // bytes, whole tree (512 MiB)
.WithMaxProcesses(64) // fork-bomb ceiling
.WithCpuQuota(0.5) // half of one core
match ProcessGroup.Create options with
| Ok group ->
use group = group
let! _sandboxed = group.StartAsync(Command.create "untrusted-tool")
() // ... runs within the limited group ...
| Error err -> eprintfn $"limits unavailable: {err.Message}" // ProcessError.ResourceLimit
}
C#
var options = new ProcessGroupOptions()
.WithMemoryMax(512L * 1024L * 1024L) // bytes, whole tree (512 MiB)
.WithMaxProcesses(64) // fork-bomb ceiling
.WithCpuQuota(0.5); // half of one core
var created = ProcessGroup.Create(options);
if (created is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine($"limits unavailable: {err.Message}"); // ProcessError.ResourceLimit
return;
}
using var group = created.GetValueOrThrow();
await group.StartAsync(new Command("untrusted-tool")); // ... runs within the limited group ...
The six caps are:
WithMemoryMax(bytes)— a whole-tree memory ceiling, in bytes (int64).WithMaxProcesses(count)— the maximum number of processes the tree may hold.WithCpuQuota(cores)— CPU as a fraction of a single core (0.5= half a core,2.0= two cores). On Windows this is converted against the host's CPU count and is approximate (a rate cap, not an exact share); on Linux cgroup v2 it maps tocpu.max.WithCpuAffinity(cores)— the CPU cores (zero-based logical processor indices) the tree may be scheduled on:[0; 1]pins it to the first two. The complement of the quota — whereWithCpuQuotabounds how much CPU the tree gets, this bounds which cores it gets it from, so a noisy child can be kept off the ones a latency-critical workload runs on. Windows writes a Job Object affinity mask (JOB_OBJECT_LIMIT_AFFINITY); Linux cgroup v2 writescpuset.cpus. See CPU affinity for the two platform ceilings.WithCpuTimeMax(duration)— CPU time, not wall time. Windows applies the Job'sPerJobUserTimeLimit; POSIX installsRLIMIT_CPUbefore each childexec(soft limit rounded up to seconds, hard limit one second later soSIGXCPUcan be observed). This is the one cap a command's ownRlimit(RlimitResource.Cpu, …)also targets — see Per-process limits on a command for which of the two wins.WithIoMax(target, readBytesPerSecond, writeBytesPerSecond, readOperationsPerSecond, writeOperationsPerSecond)— directional disk bandwidth and IOPS ceilings for one explicit device or volume. The overload usingint64treats zero as unbounded; the option overload usesNone. At least one direction must be bounded, and every supplied rate must be positive.
Linux cgroup v2 also offers the WithOomGroupKill() policy. It writes
memory.oom.group=1, so an OOM event kills every process in the contained tree as one unit instead
of leaving survivors after the kernel selects a single victim. This semantic has no Job Object or
POSIX process-group equivalent: ProcessGroup.Create returns ProcessError.Unsupported outside
Linux cgroup v2. It composes naturally with WithMemoryMax, but can also protect against an OOM
triggered by an ancestor cgroup.
The configured caps are also readable back: group.Options.Limits is a
ResourceLimits whose MemoryMax (int64 option), MaxProcesses (int option),
CpuQuota (float option), CpuTimeMax (TimeSpan option), OomGroupKill (bool), and CpuAffinity (IReadOnlyList<int> option, in
ascending order), plus IoMax (IoMax option), are Some only for the limits you set
(ResourceLimits.None is the empty set). IoMax reads back the target and all four
directional rates accepted by the backend. You can build a ResourceLimits value directly
with the same WithMemoryMax / WithMaxProcesses / WithCpuQuota / WithCpuAffinity /
WithIoMax methods if you want to inspect or compose limits before applying them.
Limits need a real container — a Windows Job Object or a Linux cgroup v2. The FreeBSD process reaper does not qualify and does not pretend to: it contains a whole tree but keeps no aggregate accounting, so it refuses a whole-tree cap exactly as the process group does.
| Capability | Windows Job Object | Linux cgroup v2 | POSIX process group / macOS / BSD / FreeBSD reaper |
|---|---|---|---|
| Memory cap | ✅ whole-tree | ✅ whole-tree (memory.max) | ❌ |
| Atomic whole-tree OOM kill | ❌ Unsupported | ✅ (memory.oom.group) | ❌ Unsupported |
| Process-count cap | ✅ | ✅ (pids.max) | ❌ |
| CPU quota | 🟡 approximate | ✅ (cpu.max) | ❌ |
| CPU-time maximum | ✅ whole Job | ✅ per spawned process (RLIMIT_CPU) | ✅ per spawned process (RLIMIT_CPU) |
| CPU affinity | ✅ mask, cores 0–63 | ✅ (cpuset.cpus) | ❌ |
| Disk I/O rate | ✅ per-volume aggregate | ✅ per-device (io.max) | ❌ Unsupported |
| UI restrictions | ✅ clipboard/desktop/exit-Windows | ❌ Unsupported | ❌ Unsupported |
Where a requested cap can't be enforced, Create fails fast with
ProcessError.ResourceLimit rather than handing back a silently-unbounded group —
so a limit is a guarantee, not a hint. That covers macOS / BSD and the Linux
process-group fallback (no whole-tree primitive at all), and a Linux host where
cgroup v2 isn't mounted. On Linux, enforcing limits also requires the process to
run at the real cgroup v2 root (cgroup v2's "no internal processes" rule lets
the controllers be enabled only there) — so an ordinary container or a
systemd-managed process fails too. The prerequisites are spelled out in
platform-support.md.
Per-process limits on a command
The caps above are whole-tree: one budget the group's kernel container enforces over
every process in it at once. A command can also carry per-process Unix rlimits of its
own — Command.Rlimit(resource, soft, hard), documented in
Running commands — which are applied to
the child before its program starts and inherited individually by each descendant it
forks. Ten descendants under a MemoryMax share one memory budget; under an rlimit each
gets its own copy of the cap, and each may lower it further or raise its soft value back
to the hard one. The two compose: use the group for a boundary, an rlimit for a bound on
each process.
They meet on exactly one axis, CPU time, which WithCpuTimeMax and
Rlimit(RlimitResource.Cpu, soft, hard) both cap. When a command carrying the latter runs
in a group carrying the former, the stricter of the two is applied on each of the soft
and the hard value — the smaller number wins, whichever knob it came from, and both are
installed in a single step so the looser one can never overwrite the tighter one on the
way to the child. Adding either can therefore only tighten what the child actually gets:
Group WithCpuTimeMax | Command Rlimit(Cpu, …) | The child gets |
|---|---|---|
| 100 s | 5, 6 | soft 5 s, hard 6 s (the command's, stricter) |
| 3 s | 50, 60 | soft 3 s, hard 4 s (the group's, stricter) |
| 4 s | 2, 90 | soft 2 s, hard 5 s (the stricter of each value) |
| 2.5 s | (none) | soft 3 s, hard 4 s (the group's, rounded up as always) |
Per-process rlimits are Unix-only and need util-linux's prlimit in a trusted system
directory. Windows refuses them with ProcessError.Unsupported (its whole-tree Job Object
caps above are the Windows answer); a POSIX host without the helper refuses with
ProcessError.ResourceLimit. Neither ever runs the child with the caps silently dropped.
Whether this host holds the helper is readable before any spawn: prlimit is one of the
entries in ProcessGroup.Capabilities().Helpers,
carrying the same precondition the refusal would state.
Disk I/O rate limits
WithIoMax applies one directional I/O policy to one explicit target and is exposed
through both ResourceLimits.WithIoMax and ProcessGroupOptions.WithIoMax. The target
and rates are preserved in group.Options.Limits.IoMax after a successful create or
live update, so callers can read back the accepted policy without reconstructing it
from the builder.
The target has platform-specific meaning:
- Linux cgroup v2 treats
targetas amajor:minorblock-device key inio.max. Read bandwidth (rbps), write bandwidth (wbps), read IOPS (riops), and write IOPS (wiops) are independent; an unbounded direction is rendered asmax. Theiocontroller must be delegated. Replacing one target with another is two separateio.maxwrites — first clearing the old device key, then writing the new key — because each nested device key is updated independently. If a later write fails, the backend rolls back the already-applied writes in reverse order, including the old target. - Windows Job Objects treats
targetas an NT volume device name. The Job Object I/O rate controller provides one aggregate bandwidth ceiling and one aggregate IOPS ceiling per volume, so read/write byte rates must match and read/write operation rates must match. An unavailable Job I/O API is reported asProcessError.Unsupported; an invalid volume or incompatible rates remain a typedProcessError.ResourceLimit.
UpdateLimits is a full replacement on both limit-capable mechanisms. Passing
ResourceLimits.None removes the I/O policy; changing only the rates updates the
same target, while changing the target performs the separate disable/write sequence
described above. A failed live update restores the previous native policy before
returning its typed error, and group.Options.Limits changes only after the complete
replacement succeeds.
macOS, BSD, and the Linux POSIX process-group fallback have no whole-tree I/O
controller. Create and UpdateLimits therefore return
ProcessError.Unsupported for WithIoMax instead of running the tree without the
requested cap. A Linux cgroup v2 hierarchy without the delegated io controller
also returns Unsupported before attempting controller writes.
F#
match ProcessGroup.Create options with
| Ok group ->
use group = group
() // ...
| Error(ProcessError.ResourceLimit message) -> eprintfn $"cannot enforce limits here: {message}"
| Error err -> eprintfn $"{err.Message}"
C#
var created = ProcessGroup.Create(options);
if (created is { IsOk: false, ErrorValue: var err })
{
Console.Error.WriteLine(err switch
{
ProcessError.ResourceLimit { Detail: var m } => $"cannot enforce limits here: {m}",
_ => err.Message,
});
return;
}
using var group = created.GetValueOrThrow(); // ...
CPU affinity
WithCpuAffinity(cores) pins the whole tree to a set of CPU cores — zero-based
logical processor indices, treated as a set rather than an ordered sequence, so
[3; 0; 2] and [0; 2; 3] are the same pin and both read back ascending. It
composes with the quota: cap how much CPU the tree may burn and which cores it
may burn it on.
F#
let pinned =
ProcessGroupOptions()
.WithCpuQuota(2.0) // at most two cores' worth of CPU ...
.WithCpuAffinity([ 2; 3 ]) // ... and only ever on cores 2 and 3
C#
var pinned = new ProcessGroupOptions()
.WithCpuQuota(2.0) // at most two cores' worth of CPU ...
.WithCpuAffinity(new[] { 2, 3 }); // ... and only ever on cores 2 and 3
Invalid sets are rejected at the builder, not deep inside a native call: null
throws ArgumentNullException, an empty set or a repeated index throws
ArgumentException (a set with no core could never run anything, and a repeat is a
typo rather than an intent), and a negative index throws
ArgumentOutOfRangeException.
Two further constraints are the platform's, not the builder's, so they are reported
as a typed ProcessError.ResourceLimit when the group is created or updated — never
as a pin quietly dropped, and never as an index wrapped onto some other core:
- Windows holds the pin in a single pointer-sized affinity mask covering one
processor group,
so only cores
0–63can be named on x64 (0–31on x86). A machine with more logical processors than that splits them across groups the mask cannot reach. - Every requested core must exist on the host and be available to this process. A Job's affinity mask has to be a subset of the creating process's own, so pinning to core 12 on an 8-core host — or to a core the process has itself been excluded from — fails rather than silently landing somewhere else. Linux cgroup v2 applies the same rule against the parent cgroup's effective cores.
On Linux, cpuset is a separate cgroup v2 controller, so the pin needs it
enabled in the parent's cgroup.subtree_control — which ProcessKit does for you, on
the same terms as memory/pids/cpu (see Resource limits
for the real-cgroup-root prerequisite). A hierarchy whose cgroup.controllers does
not carry cpuset at all cannot host a pin, and says so with
ProcessError.ResourceLimit. macOS, BSD, and the Linux process-group fallback have
no whole-tree affinity primitive at all, so a pin fails fast there for the same
reason every other cap does.
The pin is live-updatable through UpdateLimits
with the same replace semantics as every other dimension: passing a limit set
without a pin lifts it (the tree may use every core again) rather than leaving the
previous mask in force, and group.Options.Limits.CpuAffinity follows only what
actually got applied.
Windows UI restrictions
A Job Object can also restrict what its tree may do to the interactive desktop
session it shares with you — a different axis from the resource caps above, and
one with no POSIX counterpart. WithUiRestrictions(...) takes a [<Flags>]
WindowsUiRestrictions set (combine with ||| in F#, | in C#, or take the lot
with All):
| Flag | Denies the tree |
|---|---|
Handles | using USER handles owned by processes outside the job |
ReadClipboard / WriteClipboard | reading / writing the clipboard |
SystemParameters | SystemParametersInfo (system-wide settings) |
DisplaySettings | ChangeDisplaySettings |
GlobalAtoms | the session's global atom table (the job gets its own) |
Desktop | creating or switching desktops |
ExitWindows | logging off, shutting down, or restarting the machine |
F#
task {
// A tool that has no business touching the desktop session it runs in.
let options =
ProcessGroupOptions()
.WithMaxProcesses(32)
.WithUiRestrictions(
WindowsUiRestrictions.ReadClipboard
||| WindowsUiRestrictions.WriteClipboard
||| WindowsUiRestrictions.ExitWindows
)
match ProcessGroup.Create options with
| Ok group ->
use group = group
let! _restricted = group.StartAsync(Command.create "untrusted-tool")
()
| Error err -> eprintfn $"{err.Message}" // ProcessError.Unsupported off Windows
}
C#
var uiOptions = new ProcessGroupOptions()
.WithMaxProcesses(32)
.WithUiRestrictions(
WindowsUiRestrictions.ReadClipboard
| WindowsUiRestrictions.WriteClipboard
| WindowsUiRestrictions.ExitWindows);
var uiCreated = ProcessGroup.Create(uiOptions);
if (uiCreated is { IsOk: false, ErrorValue: var uiErr })
{
Console.Error.WriteLine(uiErr.Message); // ProcessError.Unsupported off Windows
return;
}
using var uiGroup = uiCreated.GetValueOrThrow();
await uiGroup.StartAsync(new Command("untrusted-tool"));
The rules match the resource caps, with one deliberate difference in the error:
- Windows-only. Off Windows
ProcessGroup.Create(andUpdateLimits) fails withProcessError.Unsupported, notProcessError.ResourceLimit. A memory cap is a concept every platform has and only some can enforce; a clipboard or desktop restriction has no POSIX analogue at all, so it is reported as an unsupported operation rather than an unenforceable limit. Either way it is never dropped silently. - Replace semantics, like every other dimension:
WithUiRestrictions(WindowsUiRestrictions.None)lifts the restrictions on the next apply rather than leaving the previous set in force, andgroup.Options.Limits.UiRestrictionsreads back what is actually applied. - A set carrying undefined bits (an out-of-range cast) is rejected at the builder
boundary with
ArgumentOutOfRangeExceptionrather than written to the Job as an unknown restriction class. - These restrictions bound what the tree may do to the desktop session. They are
not a filesystem, network, or registry sandbox — pair them with the resource
caps and with
Command.WindowsRestrictedToken/WindowsIntegrityLevel(see Running commands) for a real perimeter.
Updating limits on a live group
Limits are not frozen at creation. UpdateLimits(ResourceLimits) re-applies a new
cap set to a live group — no recreation, no restart of the children — for
adaptive resource control: tighten memory on a batch that started sagging, widen a
long-lived worker pool's CPU quota under load, or drop a cap you no longer need.
The ResourceLimits you pass is a full replacement of the caps in force: a
dimension left None is reset to unbounded, not left at its previous value.
F#
// Halve the memory ceiling and drop the CPU cap on an already-running group.
match group.UpdateLimits(ResourceLimits.None.WithMemoryMax(256L * 1024L * 1024L)) with
| Ok() -> () // Options.Limits now reads back the new set
| Error(ProcessError.ResourceLimit message) -> eprintfn $"cannot update limits here: {message}"
| Error err -> eprintfn $"{err.Message}"
C#
var updated = group.UpdateLimits(ResourceLimits.None.WithMemoryMax(256L * 1024L * 1024L));
if (updated is { IsOk: false, ErrorValue: var err })
Console.Error.WriteLine(err.Message);
Behaviour follows the mechanism, honestly and without a silent downgrade:
| Mechanism | UpdateLimits |
|---|---|
| Windows Job Object | ✅ re-applies via SetInformationJobObject on the live job (caps, affinity mask, and UI restrictions) |
| Linux cgroup v2 | ✅ rewrites memory.max / memory.oom.group / pids.max / cpu.max / cpuset.cpus in place (UI restrictions → ProcessError.Unsupported) |
| POSIX process group / macOS / the other BSDs | ❌ ProcessError.ResourceLimit (no whole-tree limit primitive to update) |
| FreeBSD process reaper | ❌ ProcessError.ResourceLimit — it contains a whole tree but accounts for nothing in it, so there is no cap to update |
CpuTimeMax is spawn-time on POSIX, including the cgroup backend. A live update that changes it is
rejected before any controller file is written, so UpdateLimits never leaves a partially changed
limit set. Windows can replace the Job time limit live with the other Job limits.
On the POSIX process-group mechanism there is no container to re-tune, so
UpdateLimits returns ProcessError.ResourceLimit — the same typed refusal
Create gives for a limited group there — rather than pretending the caps were
applied. It is an optional runtime operation: a group that never needs to
change its caps simply never calls it. After a successful update,
group.Options.Limits reflects the new set; the call also passes through the
group's lifecycle gate, so invoking it after the group has been disposed/torn down
returns a typed error rather than touching the released container.
Stats
Stats() returns a point-in-time ProcessGroupStats snapshot of the group's
resource usage, wrapped in a Result:
F#
match group.Stats() with
| Ok stats ->
printfn $"procs={stats.ActiveProcessCount} peakProcs={stats.PeakProcessCount} cpu={stats.TotalCpuTime} read={stats.IoReadBytes} write={stats.IoWriteBytes}"
| Error err -> eprintfn $"{err.Message}"
C#
Console.WriteLine((group.Stats()) switch
{
{ IsOk: true, ResultValue: var stats } => $"procs={stats.ActiveProcessCount} peakProcs={stats.PeakProcessCount} cpu={stats.TotalCpuTime} read={stats.IoReadBytes} write={stats.IoWriteBytes}",
{ IsOk: false, ErrorValue: var err } => err.Message,
});
ProcessGroupStats carries ActiveProcessCount (an int, always populated),
PeakProcessCount (int64 option), TotalCpuTime (TimeSpan option),
PeakMemoryBytes (int64 option), and four int64 option I/O counters:
IoReadBytes, IoWriteBytes, IoReadOperations, and IoWriteOperations. Linux
cgroup v2 supplies PeakProcessCount from the kernel's lifetime pids.peak counter
only when MaxProcesses is configured and the kernel is version 6.6 or later. This
is the peak number of kernel tasks (processes and their threads), so it is not
directly comparable with ActiveProcessCount, which counts process leaders. The
peak is None on Windows and the POSIX fallback, and is never estimated from
caller-driven Stats() samples. Windows Job Object accounting and Linux cgroup v2
provide the other tree aggregates (cgroup bytes/operations come from block-device
io.stat when the I/O controller is delegated to that hierarchy). On the POSIX
process-group backend only the live count is reported and all optional metrics stay
None.
MemberStats() provides the per-member view when a tree aggregate is not enough:
F#
match group.MemberStats() with
| Ok members ->
for memberStats in members do
printfn $"pid={memberStats.Pid} cpu={memberStats.CpuTime} rss={memberStats.ResidentMemoryBytes} read={memberStats.IoReadBytes}"
| Error err -> eprintfn $"{err.Message}"
C#
if (group.MemberStats() is { IsOk: true, ResultValue: var members })
foreach (var member in members)
Console.WriteLine($"pid={member.Pid} cpu={member.CpuTime} rss={member.ResidentMemoryBytes}");
The returned list is a best-effort subset of the membership snapshot: a process that
exits between enumeration and its metric reads is omitted. Windows binds each Job
PID snapshot to a pre-sampling process identity before opening its verified
query-only handle. If query access is denied, a fresh Job membership and process
identity check must still confirm the same generation before the PID is retained
with None metrics; same-Job PID reuse is omitted. Linux cgroup reads /proc/<pid>
for every whole-tree member: tracked and adopted leaders use pinned identities,
while descendants use a snapshot identity checked again after the read. The POSIX
process-group fallback samples its tracked leaders, and any process adopted by bare
pid against the anchor captured for it. Metrics unavailable on a
platform or for a confirmed inaccessible member are None, never fabricated zeroes.
MemberStats()
holds the same lifecycle gate as Members() and Stats(), and returns the typed
released-group error after teardown. It never reads command lines or environments.
SampleStatsAsync(interval) turns the snapshot into a periodic series as an
IAsyncEnumerable<ProcessGroupStats> — the first sample immediately, then one per
interval:
F#
task {
let series = group.SampleStatsAsync(TimeSpan.FromSeconds 1.0)
let e = series.GetAsyncEnumerator()
try
let mutable go = true
while go do
match! e.MoveNextAsync() with
| true -> printfn $"rss now: {e.Current.PeakMemoryBytes}"
| false -> go <- false
finally
e.DisposeAsync().AsTask().Wait()
}
C#
await foreach (var s in group.SampleStatsAsync(TimeSpan.FromSeconds(1)))
Console.WriteLine($"rss now: {s.PeakMemoryBytes}");
From C# this is simply await foreach (var s in group.SampleStatsAsync(interval)). The
sampler is pull-based: it samples only as you pull the enumeration and runs no
background task, so it neither keeps the group alive nor leaks if you abandon it.
The series ends on the first snapshot the group can no longer report (notably once
the group has been torn down) or when the enumerator's token fires.
For a single run's end-to-end summary (exit code, duration, CPU, peak memory, and
private-tree I/O where available) rather than a live group series, use
RunningProcess.ProfileAsync — see
streaming.md.
Limit Evidence
Stats() reports what the tree consumed; LimitEvidence() answers a different
question a plain exit code or signal cannot: did a resource cap this group
configured actually fire? A child killed by its memory cap and a child that
crashed on its own both surface as an ordinary non-zero exit (or a SIGKILL) —
LimitEvidence() closes that gap with one LimitVerdict per axis —
Memory/Processes/Cpu — read from the container's own authoritative
post-mortem counters, never re-derived from the ResourceLimits that
requested the cap and never inferred from the run's outcome (a cap-driven kill
and a self-inflicted crash can look identical from the outside).
Available only after the group has been torn down —
ShutdownAsync/Dispose/DisposeAsync, or the finalizer — the opposite
lifetime rule Stats() follows. The evidence is captured exactly once, from the
still-live container, in the instant immediately before its counters (and, for
a Linux cgroup v2 group, the cgroup directory itself) are torn down, and is then
cached: any number of later reads return that same snapshot. Calling it
before teardown has completed returns a non-transient
ProcessError.Unsupported — an honest "not yet available", never a fabricated
verdict read off counters that are still changing:
F#
task {
use group =
match ProcessGroup.Create(ProcessGroupOptions().WithMemoryMax(64L * 1024L * 1024L)) with
| Ok g -> g
| Error err -> failwith err.Message
// ... run children into `group` ...
do! group.ShutdownAsync()
match group.LimitEvidence() with
| Ok evidence -> printfn $"memory={evidence.Memory} processes={evidence.Processes} cpu={evidence.Cpu}"
| Error err -> eprintfn $"{err.Message}"
}
C#
using var group = ProcessGroup.Create(new ProcessGroupOptions().WithMemoryMax(64L * 1024 * 1024)) switch
{
{ IsOk: true, ResultValue: var g } => g,
{ IsOk: false, ErrorValue: var err } => throw new InvalidOperationException(err.Message),
};
// ... run children into `group` ...
await group.ShutdownAsync();
var evidence = group.LimitEvidence();
if (evidence is { IsOk: true, ResultValue: var ev })
Console.WriteLine($"memory={ev.Memory} processes={ev.Processes} cpu={ev.Cpu}");
Each axis is a three-valued LimitVerdict, deliberately never folded into one
whole-group answer (folding would have to merge NotTripped and Unknown
together, turning "we have no evidence" into "no"):
Tripped— the kernel/OS recorded that this cap engaged: the tree was OOM-killed under its memory cap, denied a fork by its process cap, or throttled by its CPU quota.NotTripped— the cap did not engage: either its counter is present and reads zero, or this axis was never capped on this group at all — both are the same honest "no".Unknown— no authoritative evidence is available, so the answer is refused rather than guessed at.
| Mechanism | Memory / Processes / Cpu |
|---|---|
| Linux cgroup v2 | Real evidence from memory.events' oom, pids.events' max, and cpu.stat's nr_throttled — Tripped/NotTripped per axis, or Unknown when a counter file/key can't be read (an older kernel, a controller this hierarchy never enabled, a cgroup already gone). |
| Windows Job Object | Unknown on every axis it ever capped — not an oversight: a Job Object keeps no post-mortem record that any of these caps fired. An axis it never capped reads NotTripped without touching native at all. |
| POSIX process group / macOS / the other BSDs / the FreeBSD process reaper | Unknown on every axis, unconditionally — including one this group never capped. This mechanism has no whole-tree resource accounting to read at all (the same reason Create/UpdateLimits refuse any whole-tree cap on it in the first place), so unlike the Job Object it has no "nothing was capped" case to answer NotTripped from either. |
Cpu answers for ResourceLimits.CpuQuota specifically. When a group also
carries a ResourceLimits.CpuTimeMax cap (Windows Job-time, or POSIX
RLIMIT_CPU applied per spawned process), a NotTripped this axis would
otherwise report is downgraded to Unknown instead, on every mechanism: no
counter here — not a Job's accounting, not cgroup v2's nr_throttled — can
attribute a job-time/RLIMIT_CPU trip, so "the quota did not throttle" is not
the same honest "no" once a CpuTimeMax is in play too. A real Tripped from
quota-throttle evidence is never downgraded.
ResourceLimits.IoMax and WithCpuAffinity have no corresponding
LimitEvidence axis at all — no mechanism here keeps a post-mortem "this
whole-tree I/O rate or CPU-affinity cap engaged" record, so there is nothing
honest to report for them, not even Unknown.
Recorded sticky across a live UpdateLimits: an axis a request names joins
the group's cap record whether that request then succeeds or fails, so a cap
that fired and was later lifted still reads from the real counter instead of a
guessed NotTripped.